From 7d05c3862d59d7c96994b8feba981182cf0d774a Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Wed, 27 May 2026 17:26:52 -0400 Subject: [PATCH] 05/27 Update functionalities --- JanitorialQC/API/APIClient.swift | 28 +- JanitorialQC/API/APIModels.swift | 149 +++++- JanitorialQC/CLAUDE.md | 27 +- JanitorialQC/Models/LocalIssue.swift | 48 +- JanitorialQC/Sync/SyncManager.swift | 69 ++- .../Views/Dashboard/DashboardView.swift | 503 +++++++++++++++++- .../Views/Dashboard/FlagIssueView.swift | 3 + 7 files changed, 800 insertions(+), 27 deletions(-) diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 875deb4..8122214 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -210,7 +210,8 @@ actor APIClient { "description": issue.issueDescription, "mobile_local_id": issue.localId, ] - if let id = issue.inspection?.serverId { body["inspection_id"] = id } + 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 { diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index 07c60e8..fb5ab27 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -395,22 +395,38 @@ 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) - id = try c.decode(Int.self, forKey: .id) - status = try c.decode(String.self, forKey: .status) - severity = try c.decode(String.self, forKey: .severity) - description = try c.decode(String.self, forKey: .description) - assignedTo = try? c.decode(Int.self, forKey: .assignedTo) - facilityId = try? c.decode(Int.self, forKey: .facilityId) - facilityName = try? c.decode(String.self, forKey: .facilityName) - reportedAt = try? c.decode(String.self, forKey: .reportedAt) - resolvedAt = try? c.decode(String.self, forKey: .resolvedAt) + id = try c.decode(Int.self, forKey: .id) + status = try c.decode(String.self, forKey: .status) + severity = try c.decode(String.self, forKey: .severity) + description = try c.decode(String.self, forKey: .description) + assignedTo = try? c.decode(Int.self, forKey: .assignedTo) + facilityId = try? c.decode(Int.self, forKey: .facilityId) + 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 } +} diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index 7029c96..6b5c3ce 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -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,12 +337,14 @@ JanitorialQCApp │ └── Server picker (segmented: jqc Primary / jqc1 Secondary) └── DashboardView (authenticated) ├── Sidebar (NavigationSplitView — no selection: binding) - │ ├── My Inspections → MyInspectionsView [+ button here, NOT in sidebar] - │ ├── Issues → IssuesListView [+ button here → StandaloneIssueView] + │ ├── Dashboard → DashboardStatsView [default landing — KPI tiles] + │ ├── My Inspections → MyInspectionsView [+ button here, NOT in sidebar] + │ ├── Issues → IssuesListView [+ button → StandaloneIssueView; resolved excluded] │ ├── Facilities → FacilitiesListView │ ├── Pending Sync → SyncStatusView │ ├── History → InspectionHistoryView - │ └── Settings → SettingsView [server picker + logout alert] + │ ├── 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 A–E 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 A–E 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 phase’s file was not in working tree. Fix: always `cp` back immediately after creating a phase file. | --- diff --git a/JanitorialQC/Models/LocalIssue.swift b/JanitorialQC/Models/LocalIssue.swift index f94562b..46f87ea 100644 --- a/JanitorialQC/Models/LocalIssue.swift +++ b/JanitorialQC/Models/LocalIssue.swift @@ -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 { diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 73807a5..a681f3f 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -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 ────────────────────────────────────────────────────── @@ -104,10 +112,17 @@ class SyncManager: ObservableObject { /// Called on logout so the next login starts a clean fetch. func resetNotificationPoller() { - lastNotificationFetch = nil + 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] = [] @@ -497,7 +546,8 @@ class SyncManager: ObservableObject { local.photoServerPaths = serverPaths if let ts = api.reportedAt, let date = Self.isoFormatter.date(from: ts) { - local.createdAt = date + 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 + } + } } diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 9bb420c..b24a995 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -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( + 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(predicate: #Predicate { $0.serverId == id }) - ).first?.name) ?? "Unknown Facility" + let all = (try? context.fetch(FetchDescriptor())) ?? [] + 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. diff --git a/JanitorialQC/Views/Dashboard/FlagIssueView.swift b/JanitorialQC/Views/Dashboard/FlagIssueView.swift index 3dbcea7..bf9a3ef 100644 --- a/JanitorialQC/Views/Dashboard/FlagIssueView.swift +++ b/JanitorialQC/Views/Dashboard/FlagIssueView.swift @@ -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)