05/27 Update functionalities

This commit is contained in:
Nguyen Ngo
2026-05-27 17:26:52 -04:00
parent ea7e523f25
commit 7d05c3862d
7 changed files with 800 additions and 27 deletions
+26
View File
@@ -211,6 +211,7 @@ actor APIClient {
"mobile_local_id": issue.localId,
]
if let id = issue.inspection?.serverId { body["inspection_id"] = id }
if let areaId = issue.areaServerId { body["area_id"] = areaId }
// photo_path = primary photo. Additional photos are sent via a
// separate PATCH call in processIssueQueue after the issue is created,
// because the server create endpoint only stores a single photo_path.
@@ -281,6 +282,31 @@ actor APIClient {
return result.issues
}
/// Fetch dashboard KPI counts for the current user (Phase B).
func fetchDashboardStats() async throws -> APIDashboardStats {
return try await request("/api/v1/stats/dashboard")
}
// Issue Comments (Phase D)
/// Fetch all comments for an issue, oldest-first.
func fetchIssueComments(issueId: Int) async throws -> [APIIssueComment] {
let result: APIIssueCommentsResponseData = try await request(
"/api/v1/issues/\(issueId)/comments"
)
return result.comments
}
/// Post a new comment on an issue. Returns the new comment ID.
func postIssueComment(issueId: Int, body: String) async throws -> Int {
let result: APIAddCommentResponseData = try await request(
"/api/v1/issues/\(issueId)/comments",
method: "POST",
body: ["body": body]
)
return result.commentId
}
// Token Refresh
private func refreshAccessToken() async -> Bool {
+131
View File
@@ -395,6 +395,14 @@ struct APIIssueDetail: Decodable, Sendable {
let facilityName: String?
let reportedAt: String?
let resolvedAt: String?
// Phase A resolution details from web
let resultNotes: String?
let verifiedAt: String?
let verificationNote: String?
let reportedByName: String?
// Phase E area and assignee context
let areaName: String?
let assignedToName: String?
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -407,10 +415,18 @@ struct APIIssueDetail: Decodable, Sendable {
facilityName = try? c.decode(String.self, forKey: .facilityName)
reportedAt = try? c.decode(String.self, forKey: .reportedAt)
resolvedAt = try? c.decode(String.self, forKey: .resolvedAt)
resultNotes = try? c.decode(String.self, forKey: .resultNotes)
verifiedAt = try? c.decode(String.self, forKey: .verifiedAt)
verificationNote = try? c.decode(String.self, forKey: .verificationNote)
reportedByName = try? c.decode(String.self, forKey: .reportedByName)
areaName = try? c.decode(String.self, forKey: .areaName)
assignedToName = try? c.decode(String.self, forKey: .assignedToName)
}
private enum CodingKeys: String, CodingKey {
case id, status, severity, description, assignedTo
case facilityId, facilityName, reportedAt, resolvedAt
case resultNotes, verifiedAt, verificationNote, reportedByName
case areaName, assignedToName
}
}
@@ -487,6 +503,14 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
let photoPath: String? // primary evidence photo
let mobilePhotoPaths: [String] // extra evidence photos from iPad
let resultPhotos: [String] // resolution photos added via web
// Phase A resolution details from web
let resultNotes: String?
let verifiedAt: String?
let verificationNote: String?
let reportedByName: String?
// Phase E area and assignee context
let areaName: String?
let assignedToName: String?
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -502,11 +526,19 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
photoPath = try? c.decode(String.self, forKey: .photoPath)
mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? []
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
resultNotes = try? c.decode(String.self, forKey: .resultNotes)
verifiedAt = try? c.decode(String.self, forKey: .verifiedAt)
verificationNote = try? c.decode(String.self, forKey: .verificationNote)
reportedByName = try? c.decode(String.self, forKey: .reportedByName)
areaName = try? c.decode(String.self, forKey: .areaName)
assignedToName = try? c.decode(String.self, forKey: .assignedToName)
}
private enum CodingKeys: String, CodingKey {
case id, status, severity, description, assignedTo
case facilityId, facilityName, reportedAt, mobileLocalId
case photoPath, mobilePhotoPaths, resultPhotos
case resultNotes, verifiedAt, verificationNote, reportedByName
case areaName, assignedToName
}
}
@@ -525,3 +557,102 @@ struct APIAssignedIssuesResponseData: Decodable, Sendable {
}
private enum CodingKeys: String, CodingKey { case issues, total, limit, offset }
}
// Dashboard Stats (Phase B)
struct APIDashboardStats: Decodable, Sendable {
let todayInspections: Int
let completedToday: Int
let openIssues: Int
let avgScore30d: Double?
let pendingFollowups: Int
let slaBreached: Int
let slaAtRisk: Int
// Phase E severity breakdown of open issues
let severityCritical: Int
let severityHigh: Int
let severityMedium: Int
let severityLow: Int
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
todayInspections = (try? c.decode(Int.self, forKey: .todayInspections)) ?? 0
completedToday = (try? c.decode(Int.self, forKey: .completedToday)) ?? 0
openIssues = (try? c.decode(Int.self, forKey: .openIssues)) ?? 0
avgScore30d = try? c.decode(Double.self, forKey: .avgScore30d)
pendingFollowups = (try? c.decode(Int.self, forKey: .pendingFollowups)) ?? 0
slaBreached = (try? c.decode(Int.self, forKey: .slaBreached)) ?? 0
slaAtRisk = (try? c.decode(Int.self, forKey: .slaAtRisk)) ?? 0
// Decode from nested severity_breakdown dict
if let breakdown = try? c.decode([String: Int].self, forKey: .severityBreakdown) {
severityCritical = breakdown["critical"] ?? 0
severityHigh = breakdown["high"] ?? 0
severityMedium = breakdown["medium"] ?? 0
severityLow = breakdown["low"] ?? 0
} else {
severityCritical = 0
severityHigh = 0
severityMedium = 0
severityLow = 0
}
}
private enum CodingKeys: String, CodingKey {
case todayInspections, completedToday, openIssues
case avgScore30d, pendingFollowups, slaBreached, slaAtRisk
case severityBreakdown
}
}
// Issue Comments (Phase D)
struct APIIssueComment: Decodable, Identifiable, Sendable {
let id: Int
let issueId: Int
let authorName: String
let authorRole: String
let statusAtTime: String
let body: String
let createdAt: String
var createdAtDate: Date? {
SyncManager.isoFormatter.date(from: createdAt)
}
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
issueId = try c.decode(Int.self, forKey: .issueId)
authorName = (try? c.decode(String.self, forKey: .authorName)) ?? "Unknown"
authorRole = (try? c.decode(String.self, forKey: .authorRole)) ?? ""
statusAtTime = (try? c.decode(String.self, forKey: .statusAtTime)) ?? ""
body = try c.decode(String.self, forKey: .body)
createdAt = try c.decode(String.self, forKey: .createdAt)
}
private enum CodingKeys: String, CodingKey {
case id, issueId, authorName, authorRole, statusAtTime, body, createdAt
}
}
struct APIIssueCommentsResponseData: Decodable, Sendable {
let issueId: Int
let comments: [APIIssueComment]
let count: Int
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
issueId = try c.decode(Int.self, forKey: .issueId)
comments = try c.decode([APIIssueComment].self, forKey: .comments)
count = try c.decode(Int.self, forKey: .count)
}
private enum CodingKeys: String, CodingKey { case issueId, comments, count }
}
struct APIAddCommentResponseData: Decodable, Sendable {
let commentId: Int
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
commentId = try c.decode(Int.self, forKey: .commentId)
}
private enum CodingKeys: String, CodingKey { case commentId }
}
+20 -3
View File
@@ -314,9 +314,17 @@ All server URLs built as: `ServerConfig.current + endpoint` — **`Constants.bas
let photoPath: String? // primary evidence photo
let mobilePhotoPaths: [String] // extra evidence photos from iPad (mobile_photo_paths)
let resultPhotos: [String] // resolution photos — NOT stored in photoServerPaths on iPad
// Phase A:
let resultNotes: String? // resolution notes from web staff
let verifiedAt: String? // ISO 8601 — when fix was verified
let verificationNote: String? // verifier note
let reportedByName: String? // reporter display_name
// Phase E:
let areaName: String? // area the issue was flagged in
let assignedToName: String? // assigned user display_name
```
`SyncManager.pullAssignedIssues` merges `photoPath + mobilePhotoPaths` into `photoServerPaths`. `resultPhotos` is decoded but intentionally excluded from `photoServerPaths` — resolution photos are web-only.
`SyncManager.pullAssignedIssues` merges `photoPath + mobilePhotoPaths` into `photoServerPaths`. `resultPhotos` is decoded but intentionally excluded from `photoServerPaths` — resolution photos are web-only. All Phase A/E optional fields are persisted to `LocalIssue` on both insert and update paths.
---
@@ -329,11 +337,13 @@ JanitorialQCApp
│ └── Server picker (segmented: jqc Primary / jqc1 Secondary)
└── DashboardView (authenticated)
├── Sidebar (NavigationSplitView — no selection: binding)
│ ├── Dashboard → DashboardStatsView [default landing — KPI tiles]
│ ├── My Inspections → MyInspectionsView [+ button here, NOT in sidebar]
│ ├── Issues → IssuesListView [+ button here → StandaloneIssueView]
│ ├── Issues → IssuesListView [+ button → StandaloneIssueView; resolved excluded]
│ ├── Facilities → FacilitiesListView
│ ├── Pending Sync → SyncStatusView
│ ├── History → InspectionHistoryView
│ ├── Notifications → NotificationsView [red badge when unreadCount > 0]
│ └── Settings → SettingsView [server picker + logout alert]
└── (sidebar has NO + button)
```
@@ -391,7 +401,7 @@ else { continue }
## 13. Issue Flagging Workflow
`FlagIssueView` — presented as sheet from `ExecuteInspectionView`. Sets `inspectionLocalId = inspection.localId` and appends to `inspection.localIssues`. Creates `PendingPhoto` with `entityType = "issue"`.
`FlagIssueView` — presented as sheet from `ExecuteInspectionView`. Sets `inspectionLocalId = inspection.localId`, `areaServerId = inspection.areaServerId`, and appends to `inspection.localIssues`. Creates `PendingPhoto` with `entityType = "issue"`. `areaServerId` is sent as `area_id` in `submitIssue()`.
**Issue sync guard:** If parent `inspection.syncStatus == "failed"`, issue is immediately marked `"failed"` — prevents orphaned server records.
@@ -623,6 +633,13 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
| 43 | **`processIssueQueue` clears `photoLocalPaths` after successful submit** | Prevents `IssueDetailView` from rendering a duplicate "local photos" section alongside the server photos section for synced issues. |
| 44 | **`StandaloneIssueView` uses `inspectionLocalId = ""`** | Same pattern as server-pulled issues. `processIssueQueue`'s parent-inspection guard evaluates `parent?.syncStatus == "failed"``false` for `""`, so standalone issues submit normally. |
| 45 | **Facility lists deduplicate by `serverId` at both storage and display layers** | Storage: `pullReferenceData()` deduplicates server response before upsert. Display: `filteredFacilities` in both `StartInspectionView` and `StandaloneIssueView` uses `filter { seen.insert($0.serverId).inserted }`. |
| 46 | **`LocalIssue` Phase AE fields all use nil-default optionals** | `String?`, `Date?` optionals default to nil. SwiftData lightweight migration supports nil-default optional properties without a migration plan. |
| 47 | **`IssueDetailView` comments block must be inside `List { }`** | `.navigationTitle` and `.task` must chain on the `List` view. Placing the `if sync.isOnline { }` comments block outside `List` causes `Instance member navigationTitle cannot be used on type View` build error. |
| 48 | **`IssuesListView` filters resolved in Swift, not via `#Predicate`** | `@Query` returns `allIssues`; computed `var issues` filters `$0.issueStatus != "resolved"`. `#Predicate` with string literals on `LocalIssue` is unreliable under Xcode 26 `SWIFT_DEFAULT_ACTOR_ISOLATION` (rule 3). |
| 49 | **`SyncManager.fetchDashboardStats()` is best-effort — never blocks pipeline** | Called last in `triggerSync()`. Failure leaves `dashboardStats` nil; UI shows placeholder. Never propagates errors. |
| 50 | **`NotificationsView.markNotificationsViewed()` on both `.onAppear` and sidebar tap** | Sidebar tap calls `sync.markNotificationsViewed()` inline to clear the badge immediately without waiting for navigation. |
| 51 | **`FlagIssueView.submitIssue()` must copy `inspection.areaServerId` to `issue.areaServerId`** | Without this, the server cannot link the issue to the correct area. `APIClient.submitIssue` sends `area_id` only when `issue.areaServerId` is non-nil. |
| 52 | **`LocalIssue.swift` zip delivery must include all Phase AE fields** | When packaging, copy from the working-tree file and verify every field with `grep` before zipping. Partial field sets cause `SyncManager` build failures. The recurring hotfix pattern traces to this: each phase patched the working tree but the prior phases file was not in working tree. Fix: always `cp` back immediately after creating a phase file. |
---
+47 -1
View File
@@ -12,7 +12,10 @@ final class LocalIssue {
var serverId: Int?
var inspectionLocalId: String // references LocalInspection.localId
var facilityServerId: Int // facility this issue belongs to (replaces areaServerId)
var facilityServerId: Int // facility this issue belongs to
/// Server ID of the area this issue was flagged in. Set when flagged during
/// an inspection that has an area selected. Nil for standalone issues.
var areaServerId: Int?
var severity: String // "low" | "medium" | "high" | "critical"
var issueDescription: String
var issueStatus: String = "open" // server status: "open" | "in_progress" | "resolved" | "pending_verification"
@@ -38,6 +41,39 @@ final class LocalIssue {
var syncRetryCount: Int
var syncErrorMessage: String?
// Phase A additions persisted from server response
// All new String?/Date? fields default to nil; SwiftData lightweight migration
// supports nil-default optional properties without a migration plan.
/// Facility display name cached from the server response. Used when the
/// local facility reference cache has been cleared (Settings Clear Cache).
var facilityNameCache: String?
/// Server-side reported_at timestamp. More accurate than createdAt for
/// server-pulled issues because createdAt falls back to device time when
/// the issue was created offline.
var serverReportedAt: Date?
/// Resolution notes added by web staff after fixing the issue.
var resultNotes: String?
/// Timestamp when a director/admin verified the fix.
var verifiedAt: Date?
/// Note left by the verifier.
var verificationNote: String?
/// Display name of the user who originally reported this issue.
var reportedByName: String?
/// Name of the area this issue was flagged in (e.g. "Main Lobby").
/// Set from server response; nil for standalone issues without area context.
var areaNameCache: String?
/// Display name of the user currently assigned to this issue.
/// Nil when unassigned. Updated on every pullAssignedIssues().
var assignedToName: String?
// Explicit inverse declared so SwiftData has an unambiguous relationship
// graph at schema-build time. Without it the relationship is implicit,
// which can cause migration warnings or incorrect cascade behaviour on some
@@ -57,6 +93,7 @@ final class LocalIssue {
self.serverId = nil
self.inspectionLocalId = inspectionLocalId
self.facilityServerId = facilityServerId
self.areaServerId = nil
self.severity = severity
self.issueDescription = description
self.issueStatus = "open"
@@ -66,6 +103,15 @@ final class LocalIssue {
self.syncStatus = "pending"
self.syncRetryCount = 0
self.syncErrorMessage = nil
// Phase A fields nil by default
self.facilityNameCache = nil
self.serverReportedAt = nil
self.resultNotes = nil
self.verifiedAt = nil
self.verificationNote = nil
self.reportedByName = nil
self.areaNameCache = nil
self.assignedToName = nil
}
var severityColor: String {
+65
View File
@@ -21,6 +21,14 @@ class SyncManager: ObservableObject {
@Published var lastSyncAt: Date?
@Published var syncError: String?
@Published var pendingCount = 0
/// Dashboard KPI stats fetched from the server. Nil until first successful fetch.
@Published var dashboardStats: APIDashboardStats?
/// Count of notifications received since last resetNotificationPoller().
/// Incremented on each poll that returns new items; reset to 0 on logout.
@Published var unreadNotificationCount = 0
/// The most recent batch of notifications (up to 50) for the in-app inbox.
/// Replaced entirely on each successful poll; empty until first fetch.
@Published var recentNotifications: [APINotification] = []
// Dependencies
@@ -105,9 +113,16 @@ class SyncManager: ObservableObject {
/// Called on logout so the next login starts a clean fetch.
func resetNotificationPoller() {
lastNotificationFetch = nil
unreadNotificationCount = 0
recentNotifications = []
stopPollTask()
}
/// Call when the user opens the NotificationsView to clear the badge.
func markNotificationsViewed() {
unreadNotificationCount = 0
}
// Notification polling
func pollNotifications() async {
@@ -121,6 +136,10 @@ class SyncManager: ObservableObject {
deliverLocalNotification(n)
}
// Update in-app inbox state
recentNotifications = notifications + recentNotifications.prefix(50 - notifications.count)
unreadNotificationCount += notifications.count
// Update the cursor to the newest notification's timestamp
let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) }
if let newest = dates.max() {
@@ -183,6 +202,9 @@ class SyncManager: ObservableObject {
// and follow-up requests as soon as the app goes online.
await pollNotifications()
// Fetch dashboard KPIs best-effort, non-fatal on failure.
await fetchDashboardStats()
updatePendingCount(context: context)
lastSyncAt = Date()
}
@@ -470,6 +492,21 @@ class SyncManager: ObservableObject {
existing.severity = api.severity
existing.issueDescription = api.description
if let fid = api.facilityId { existing.facilityServerId = fid }
// Cache facility name so IssueDetailView works when local
// facility reference cache has been cleared (Settings Clear Cache).
if let fn = api.facilityName, !fn.isEmpty {
existing.facilityNameCache = fn
}
// Phase A resolution details from web staff
existing.resultNotes = api.resultNotes
existing.verificationNote = api.verificationNote
existing.reportedByName = api.reportedByName
existing.areaNameCache = api.areaName
existing.assignedToName = api.assignedToName
if let vts = api.verifiedAt,
let date = Self.isoFormatter.date(from: vts) {
existing.verifiedAt = date
}
// Refresh photos in case they were added after first pull
// photoServerPaths = evidence photos only (photo_path + mobile_photo_paths).
// result_photos are resolution photos shown separately on the web,
@@ -489,6 +526,18 @@ class SyncManager: ObservableObject {
local.serverId = api.id
local.issueStatus = api.status
local.syncStatus = "synced" // never re-submit
// Cache facility name for offline display
local.facilityNameCache = api.facilityName
// Phase A resolution details from web staff
local.resultNotes = api.resultNotes
local.verificationNote = api.verificationNote
local.reportedByName = api.reportedByName
local.areaNameCache = api.areaName
local.assignedToName = api.assignedToName
if let vts = api.verifiedAt,
let date = Self.isoFormatter.date(from: vts) {
local.verifiedAt = date
}
// Store server photos so IssueDetailView can show them
// photoServerPaths = evidence photos only (photo_path + mobile_photo_paths).
var serverPaths: [String] = []
@@ -498,6 +547,7 @@ class SyncManager: ObservableObject {
if let ts = api.reportedAt,
let date = Self.isoFormatter.date(from: ts) {
local.createdAt = date
local.serverReportedAt = date // accurate server timestamp
}
context.insert(local)
}
@@ -528,4 +578,19 @@ class SyncManager: ObservableObject {
// Non-fatal IssuesListView still shows device-created issues
}
}
// Dashboard Stats
// Best-effort fetch a network failure silently leaves dashboardStats nil
// so the UI falls back to a placeholder card. Never blocks the sync pipeline.
func fetchDashboardStats() async {
guard isOnline, AuthManager.shared.isAuthenticated else { return }
do {
dashboardStats = try await APIClient.shared.fetchDashboardStats()
} catch APIError.notAuthenticated {
// Let AuthManager handle session expiry
} catch {
// Non-fatal stale stats stay visible until next successful fetch
}
}
}
@@ -17,11 +17,13 @@ import Combine
// MARK: - SidebarTab
enum SidebarTab: Hashable {
case dashboard // landing page KPI stats card
case myInspections
case issues // inspector role only
case issues
case facilities
case pendingSync
case history // moved after Pending Sync
case history
case notifications // in-app notification inbox
case settings
}
@@ -38,16 +40,18 @@ struct DashboardView: View {
order: .reverse
) private var myInspections: [LocalInspection]
@State private var selectedTab: SidebarTab = .myInspections
@State private var selectedTab: SidebarTab = .dashboard
/// Each sidebar tap refreshes the UUID for that tab, forcing its
/// NavigationStack to be destroyed and recreated even when the tab
/// hasn't changed (user is already on it but deep inside a detail view).
@State private var tabResetId: [SidebarTab: UUID] = [
.dashboard: UUID(),
.myInspections: UUID(),
.issues: UUID(),
.facilities: UUID(),
.pendingSync: UUID(),
.history: UUID(),
.notifications: UUID(),
.settings: UUID(),
]
@@ -69,6 +73,13 @@ struct DashboardView: View {
var body: some View {
NavigationSplitView {
List {
// Dashboard
Button { selectTab(.dashboard) } label: {
Label("Dashboard", systemImage: "chart.bar.xaxis")
.foregroundStyle(selectedTab == .dashboard ? .blue : .primary)
}
.listRowBackground(selectedTab == .dashboard ? Color.blue.opacity(0.1) : Color.clear)
// My Inspections
Button { selectTab(.myInspections) } label: {
HStack {
@@ -125,6 +136,27 @@ struct DashboardView: View {
}
.listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear)
// Notifications
Button {
selectTab(.notifications)
sync.markNotificationsViewed()
} label: {
HStack {
Label("Notifications", systemImage: "bell")
.foregroundStyle(selectedTab == .notifications ? .blue : .primary)
Spacer()
if sync.unreadNotificationCount > 0 {
Text("\(min(sync.unreadNotificationCount, 99))")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.red.opacity(0.85))
.foregroundStyle(.white)
.clipShape(Capsule())
}
}
}
.listRowBackground(selectedTab == .notifications ? Color.blue.opacity(0.1) : Color.clear)
// Settings
Button { selectTab(.settings) } label: {
Label("Settings", systemImage: "gear")
@@ -138,6 +170,8 @@ struct DashboardView: View {
} detail: {
switch selectedTab {
case .dashboard:
NavigationStack { DashboardStatsView() }
case .myInspections:
NavigationStack(path: $inspectionsPath) {
MyInspectionsView()
@@ -167,6 +201,8 @@ struct DashboardView: View {
HistoryDetailView(inspection: inspection)
}
}
case .notifications:
NavigationStack { NotificationsView() }
case .settings:
NavigationStack { SettingsView() }
}
@@ -211,6 +247,204 @@ struct DashboardView: View {
}
}
// MARK: - Dashboard Stats View
// Shows inspector-scoped KPI cards fetched from GET /api/v1/stats/dashboard.
// Data is refreshed on every triggerSync() via SyncManager.fetchDashboardStats().
struct DashboardStatsView: View {
@EnvironmentObject private var sync: SyncManager
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if let stats = sync.dashboardStats {
// Today
statsSection(title: "Today") {
HStack(spacing: 12) {
statTile(
value: "\(stats.todayInspections)",
label: "Inspections",
icon: "checklist",
color: .blue
)
statTile(
value: "\(stats.completedToday)",
label: "Completed",
icon: "checkmark.circle.fill",
color: .green
)
}
}
// Issues
statsSection(title: "Issues") {
HStack(spacing: 12) {
statTile(
value: "\(stats.openIssues)",
label: "Open / In Progress",
icon: "exclamationmark.triangle",
color: .orange
)
statTile(
value: "\(stats.pendingFollowups)",
label: "Pending Follow-ups",
icon: "exclamationmark.arrow.circlepath",
color: stats.pendingFollowups > 0 ? .orange : .secondary
)
}
}
// SLA
if stats.slaBreached > 0 || stats.slaAtRisk > 0 {
statsSection(title: "SLA") {
HStack(spacing: 12) {
statTile(
value: "\(stats.slaBreached)",
label: "Breached",
icon: "xmark.circle.fill",
color: stats.slaBreached > 0 ? .red : .secondary
)
statTile(
value: "\(stats.slaAtRisk)",
label: "At Risk",
icon: "clock.badge.exclamationmark",
color: stats.slaAtRisk > 0 ? .orange : .secondary
)
}
}
}
// Severity breakdown
if stats.openIssues > 0 {
statsSection(title: "Open Issues by Severity") {
HStack(spacing: 8) {
if stats.severityCritical > 0 {
severityTile(count: stats.severityCritical, label: "Critical", color: .red)
}
if stats.severityHigh > 0 {
severityTile(count: stats.severityHigh, label: "High", color: .orange)
}
if stats.severityMedium > 0 {
severityTile(count: stats.severityMedium, label: "Medium", color: .yellow)
}
if stats.severityLow > 0 {
severityTile(count: stats.severityLow, label: "Low", color: .blue)
}
}
}
}
// Score
statsSection(title: "Performance (30 days)") {
if let avg = stats.avgScore30d {
let color: Color = avg >= 80 ? .green : avg >= 60 ? .orange : .red
HStack(spacing: 16) {
Text(String(format: "%.1f%%", avg))
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(color)
VStack(alignment: .leading, spacing: 4) {
Text("Average Score")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(avg >= 80 ? "Excellent" : avg >= 60 ? "Needs Improvement" : "Below Standard")
.font(.caption.bold())
.foregroundStyle(color)
}
}
.padding(.vertical, 4)
} else {
Text("No completed inspections in the last 30 days.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
} else if !sync.isOnline {
ContentUnavailableView(
"Offline",
systemImage: "wifi.slash",
description: Text("Dashboard stats require an internet connection.")
)
} else {
VStack(spacing: 16) {
ProgressView("Loading stats…")
Text("Stats appear after the first sync completes.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 60)
}
}
.padding(24)
}
.navigationTitle("Dashboard")
.navigationBarTitleDisplayMode(.large)
.refreshable {
await sync.fetchDashboardStats()
}
}
// Helpers
@ViewBuilder
private func statsSection<Content: View>(
title: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title.uppercased())
.font(.caption.bold())
.foregroundStyle(.secondary)
.tracking(1)
content()
}
}
private func statTile(
value: String,
label: String,
icon: String,
color: Color
) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(color)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Text(value)
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(color)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(color.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private func severityTile(count: Int, label: String, color: Color) -> some View {
VStack(spacing: 4) {
Text("\(count)")
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(color)
Text(label)
.font(.caption2.bold())
.foregroundStyle(color.opacity(0.8))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(color.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
// MARK: - My Inspections
struct MyInspectionsView: View {
@@ -664,10 +898,17 @@ struct FacilitiesListView: View {
struct IssuesListView: View {
// Fetch all then filter in Swift #Predicate with string literals on
// LocalIssue is unreliable under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION
// (CLAUDE.md rule 3). Resolved issues are excluded to match the web default.
@Query(
sort: \LocalIssue.createdAt,
order: .reverse
) private var issues: [LocalIssue]
) private var allIssues: [LocalIssue]
private var issues: [LocalIssue] {
allIssues.filter { $0.issueStatus != "resolved" }
}
@Environment(\.modelContext) private var context
@State private var showNewIssue = false
@@ -707,10 +948,16 @@ struct IssueRowView: View {
let context: ModelContext
private var facilityName: String {
// Primary: look up from local reference cache (fast, works offline).
// Fallback: facilityNameCache persisted from the last server sync.
// This covers the case where the user cleared the local cache in Settings
// while server-pulled issues are still present.
let id = issue.facilityServerId
return (try? context.fetch(
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Unknown Facility"
let all = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
if let name = all.first(where: { $0.serverId == id })?.name {
return name
}
return issue.facilityNameCache ?? "Unknown Facility"
}
private var severityColor: Color {
@@ -722,6 +969,16 @@ struct IssueRowView: View {
}
}
private func issueStatusColor(_ status: String) -> Color {
switch status {
case "open": return .blue
case "in_progress": return .orange
case "pending_verification": return .purple
case "resolved": return .green
default: return .secondary
}
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Circle()
@@ -734,6 +991,12 @@ struct IssueRowView: View {
.font(.caption.bold())
.foregroundStyle(severityColor)
Spacer()
Text(issue.issueStatus.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(issueStatusColor(issue.issueStatus).opacity(0.15))
.foregroundStyle(issueStatusColor(issue.issueStatus))
.clipShape(Capsule())
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
}
Text(issue.issueDescription)
@@ -760,6 +1023,12 @@ struct IssueDetailView: View {
@State private var isUpdatingStatus = false
@State private var statusError: String?
@State private var showStatusPicker = false
// Comments
@State private var comments: [APIIssueComment] = []
@State private var isLoadingComments = false
@State private var newCommentText = ""
@State private var isPostingComment = false
@State private var commentError: String?
private var facilityName: String {
let id = issue.facilityServerId
@@ -810,8 +1079,20 @@ struct IssueDetailView: View {
.fontWeight(.semibold)
}
LabeledContent("Facility", value: facilityName)
LabeledContent("Reported", value: issue.createdAt.formatted(
if let area = issue.areaNameCache, !area.isEmpty {
LabeledContent("Area", value: area)
}
if let assignee = issue.assignedToName, !assignee.isEmpty {
LabeledContent("Assigned To", value: assignee)
}
// Use serverReportedAt when available more accurate than
// createdAt (device time) for server-pulled issues.
let reportDate = issue.serverReportedAt ?? issue.createdAt
LabeledContent("Reported", value: reportDate.formatted(
date: .long, time: .shortened))
if let reporter = issue.reportedByName, !reporter.isEmpty {
LabeledContent("Reporter", value: reporter)
}
// Issue Status
LabeledContent("Issue Status") {
@@ -859,6 +1140,28 @@ struct IssueDetailView: View {
.font(.callout)
}
// Resolution Details (web-staff only read-only on iPad)
if let notes = issue.resultNotes, !notes.isEmpty {
Section("Resolution Notes") {
Text(notes)
.font(.callout)
.foregroundStyle(.primary)
}
}
// Verification Details
if let vAt = issue.verifiedAt {
Section("Verification") {
LabeledContent("Verified", value: vAt.formatted(
date: .long, time: .shortened))
if let note = issue.verificationNote, !note.isEmpty {
Text(note)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
Section("Sync Status") {
LabeledContent("Sync") {
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
@@ -906,11 +1209,63 @@ struct IssueDetailView: View {
}
}
}
// Comments
if sync.isOnline, issue.serverId != nil {
if isLoadingComments {
Section("Comments") {
HStack { Spacer(); ProgressView(); Spacer() }
}
} else if !comments.isEmpty {
Section("Comments (\(comments.count))") {
ForEach(comments) { comment in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(comment.authorName)
.font(.caption.bold())
Spacer()
if let date = comment.createdAtDate {
Text(date.formatted(.relative(presentation: .named)))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
Text(comment.body)
.font(.callout)
}
.padding(.vertical, 2)
}
}
}
// Add comment
Section("Add Comment") {
TextEditor(text: $newCommentText)
.frame(minHeight: 60)
if let err = commentError {
Text(err).font(.caption).foregroundStyle(.red)
}
Button {
Task { await postComment() }
} label: {
if isPostingComment {
HStack { ProgressView(); Text("Posting…") }
} else {
Label("Post Comment", systemImage: "paperplane.fill")
}
}
.disabled(
newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| isPostingComment
)
}
}
}
.navigationTitle("Issue Detail")
.navigationBarTitleDisplayMode(.inline)
.task {
await refreshStatusFromServer()
await loadComments()
}
}
@@ -924,9 +1279,54 @@ struct IssueDetailView: View {
do {
let detail = try await APIClient.shared.fetchIssueDetail(issueId: sid)
issue.issueStatus = detail.status
// Refresh Phase A resolution fields from live server data
if let notes = detail.resultNotes { issue.resultNotes = notes }
if let vNote = detail.verificationNote { issue.verificationNote = vNote }
if let rName = detail.reportedByName { issue.reportedByName = rName }
if let fName = detail.facilityName, !fName.isEmpty {
issue.facilityNameCache = fName
}
if let vts = detail.verifiedAt,
let date = SyncManager.isoFormatter.date(from: vts) {
issue.verifiedAt = date
}
if let area = detail.areaName, !area.isEmpty { issue.areaNameCache = area }
if let assignee = detail.assignedToName, !assignee.isEmpty { issue.assignedToName = assignee }
try? context.save()
} catch {
// Non-fatal show cached status silently
// Non-fatal show cached values silently
}
}
// Load comments from server
private func loadComments() async {
guard sync.isOnline, let sid = issue.serverId else { return }
isLoadingComments = true
defer { isLoadingComments = false }
do {
comments = try await APIClient.shared.fetchIssueComments(issueId: sid)
} catch {
// Non-fatal empty list shown
}
}
// Post a new comment
private func postComment() async {
guard let sid = issue.serverId else { return }
let body = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !body.isEmpty else { return }
isPostingComment = true
commentError = nil
defer { isPostingComment = false }
do {
_ = try await APIClient.shared.postIssueComment(issueId: sid, body: body)
newCommentText = ""
// Reload comments so the new one appears
comments = try await APIClient.shared.fetchIssueComments(issueId: sid)
} catch {
commentError = error.localizedDescription
}
}
@@ -1246,6 +1646,91 @@ struct StandaloneIssueView: View {
}
}
// MARK: - Notifications Inbox
// Shows the most recent notifications fetched during polling.
// Notifications are already marked read on the server by pollNotifications().
struct NotificationsView: View {
@EnvironmentObject private var sync: SyncManager
var body: some View {
Group {
if sync.recentNotifications.isEmpty {
if !sync.isOnline {
ContentUnavailableView(
"Offline",
systemImage: "wifi.slash",
description: Text("Notifications are delivered when you go online.")
)
} else {
ContentUnavailableView(
"No Notifications",
systemImage: "bell.slash",
description: Text("You\'re all caught up.")
)
}
} else {
List(sync.recentNotifications) { notif in
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
Image(systemName: iconName(for: notif.eventType))
.foregroundStyle(iconColor(for: notif.eventType))
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(notif.title)
.font(.callout.bold())
.lineLimit(2)
Text(notif.body)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
}
}
if let date = SyncManager.isoFormatter.date(from: notif.createdAt) {
Text(date.formatted(.relative(presentation: .named)))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("Notifications")
.navigationBarTitleDisplayMode(.large)
.onAppear {
sync.markNotificationsViewed()
}
.refreshable {
await sync.pollNotifications()
sync.markNotificationsViewed()
}
}
private func iconName(for eventType: String?) -> String {
switch eventType {
case "inspection_completed": return "checkmark.circle.fill"
case "issue_flagged": return "exclamationmark.triangle.fill"
case "issue_resolved": return "checkmark.seal.fill"
case "sla_alert": return "clock.badge.exclamationmark"
case "follow_up_required": return "exclamationmark.arrow.circlepath"
default: return "bell.fill"
}
}
private func iconColor(for eventType: String?) -> Color {
switch eventType {
case "inspection_completed": return .green
case "issue_flagged": return .orange
case "issue_resolved": return .green
case "sla_alert": return .red
case "follow_up_required": return .orange
default: return .blue
}
}
}
// MARK: - Retryable Photo
/// Loads a server photo via AsyncImage with a tap-to-retry failure state.
@@ -279,6 +279,9 @@ struct FlagIssueView: View {
severity: severity,
description: description.trimmingCharacters(in: .whitespaces)
)
// Carry area context forward so the server can link the issue to the
// correct area. areaServerId is nil when the inspection has no area.
issue.areaServerId = inspection.areaServerId
issue.photoLocalPaths = photos.map(\.path)
issue.inspection = inspection
inspection.localIssues.append(issue)