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, "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 // photo_path = primary photo. Additional photos are sent via a
// separate PATCH call in processIssueQueue after the issue is created, // separate PATCH call in processIssueQueue after the issue is created,
// because the server create endpoint only stores a single photo_path. // because the server create endpoint only stores a single photo_path.
@@ -281,6 +282,31 @@ actor APIClient {
return result.issues 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 // Token Refresh
private func refreshAccessToken() async -> Bool { private func refreshAccessToken() async -> Bool {
+131
View File
@@ -395,6 +395,14 @@ struct APIIssueDetail: Decodable, Sendable {
let facilityName: String? let facilityName: String?
let reportedAt: String? let reportedAt: String?
let resolvedAt: 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 { nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -407,10 +415,18 @@ struct APIIssueDetail: Decodable, Sendable {
facilityName = try? c.decode(String.self, forKey: .facilityName) facilityName = try? c.decode(String.self, forKey: .facilityName)
reportedAt = try? c.decode(String.self, forKey: .reportedAt) reportedAt = try? c.decode(String.self, forKey: .reportedAt)
resolvedAt = try? c.decode(String.self, forKey: .resolvedAt) 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 { private enum CodingKeys: String, CodingKey {
case id, status, severity, description, assignedTo case id, status, severity, description, assignedTo
case facilityId, facilityName, reportedAt, resolvedAt 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 photoPath: String? // primary evidence photo
let mobilePhotoPaths: [String] // extra evidence photos from iPad let mobilePhotoPaths: [String] // extra evidence photos from iPad
let resultPhotos: [String] // resolution photos added via web 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 { nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) 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) photoPath = try? c.decode(String.self, forKey: .photoPath)
mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? [] mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? []
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] 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 { private enum CodingKeys: String, CodingKey {
case id, status, severity, description, assignedTo case id, status, severity, description, assignedTo
case facilityId, facilityName, reportedAt, mobileLocalId case facilityId, facilityName, reportedAt, mobileLocalId
case photoPath, mobilePhotoPaths, resultPhotos 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 } 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 photoPath: String? // primary evidence photo
let mobilePhotoPaths: [String] // extra evidence photos from iPad (mobile_photo_paths) let mobilePhotoPaths: [String] // extra evidence photos from iPad (mobile_photo_paths)
let resultPhotos: [String] // resolution photos NOT stored in photoServerPaths on iPad 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) │ └── Server picker (segmented: jqc Primary / jqc1 Secondary)
└── DashboardView (authenticated) └── DashboardView (authenticated)
├── Sidebar (NavigationSplitView — no selection: binding) ├── Sidebar (NavigationSplitView — no selection: binding)
│ ├── Dashboard → DashboardStatsView [default landing — KPI tiles]
│ ├── My Inspections → MyInspectionsView [+ button here, NOT in sidebar] │ ├── My Inspections → MyInspectionsView [+ button here, NOT in sidebar]
│ ├── Issues → IssuesListView [+ button here → StandaloneIssueView] │ ├── Issues → IssuesListView [+ button → StandaloneIssueView; resolved excluded]
│ ├── Facilities → FacilitiesListView │ ├── Facilities → FacilitiesListView
│ ├── Pending Sync → SyncStatusView │ ├── Pending Sync → SyncStatusView
│ ├── History → InspectionHistoryView │ ├── History → InspectionHistoryView
│ ├── Notifications → NotificationsView [red badge when unreadCount > 0]
│ └── Settings → SettingsView [server picker + logout alert] │ └── Settings → SettingsView [server picker + logout alert]
└── (sidebar has NO + button) └── (sidebar has NO + button)
``` ```
@@ -391,7 +401,7 @@ else { continue }
## 13. Issue Flagging Workflow ## 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. **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. | | 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. | | 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 }`. | | 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 serverId: Int?
var inspectionLocalId: String // references LocalInspection.localId 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 severity: String // "low" | "medium" | "high" | "critical"
var issueDescription: String var issueDescription: String
var issueStatus: String = "open" // server status: "open" | "in_progress" | "resolved" | "pending_verification" var issueStatus: String = "open" // server status: "open" | "in_progress" | "resolved" | "pending_verification"
@@ -38,6 +41,39 @@ final class LocalIssue {
var syncRetryCount: Int var syncRetryCount: Int
var syncErrorMessage: String? 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 // Explicit inverse declared so SwiftData has an unambiguous relationship
// graph at schema-build time. Without it the relationship is implicit, // graph at schema-build time. Without it the relationship is implicit,
// which can cause migration warnings or incorrect cascade behaviour on some // which can cause migration warnings or incorrect cascade behaviour on some
@@ -57,6 +93,7 @@ final class LocalIssue {
self.serverId = nil self.serverId = nil
self.inspectionLocalId = inspectionLocalId self.inspectionLocalId = inspectionLocalId
self.facilityServerId = facilityServerId self.facilityServerId = facilityServerId
self.areaServerId = nil
self.severity = severity self.severity = severity
self.issueDescription = description self.issueDescription = description
self.issueStatus = "open" self.issueStatus = "open"
@@ -66,6 +103,15 @@ final class LocalIssue {
self.syncStatus = "pending" self.syncStatus = "pending"
self.syncRetryCount = 0 self.syncRetryCount = 0
self.syncErrorMessage = nil 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 { var severityColor: String {
+65
View File
@@ -21,6 +21,14 @@ class SyncManager: ObservableObject {
@Published var lastSyncAt: Date? @Published var lastSyncAt: Date?
@Published var syncError: String? @Published var syncError: String?
@Published var pendingCount = 0 @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 // Dependencies
@@ -105,9 +113,16 @@ class SyncManager: ObservableObject {
/// Called on logout so the next login starts a clean fetch. /// Called on logout so the next login starts a clean fetch.
func resetNotificationPoller() { func resetNotificationPoller() {
lastNotificationFetch = nil lastNotificationFetch = nil
unreadNotificationCount = 0
recentNotifications = []
stopPollTask() stopPollTask()
} }
/// Call when the user opens the NotificationsView to clear the badge.
func markNotificationsViewed() {
unreadNotificationCount = 0
}
// Notification polling // Notification polling
func pollNotifications() async { func pollNotifications() async {
@@ -121,6 +136,10 @@ class SyncManager: ObservableObject {
deliverLocalNotification(n) 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 // Update the cursor to the newest notification's timestamp
let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) } let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) }
if let newest = dates.max() { if let newest = dates.max() {
@@ -183,6 +202,9 @@ class SyncManager: ObservableObject {
// and follow-up requests as soon as the app goes online. // and follow-up requests as soon as the app goes online.
await pollNotifications() await pollNotifications()
// Fetch dashboard KPIs best-effort, non-fatal on failure.
await fetchDashboardStats()
updatePendingCount(context: context) updatePendingCount(context: context)
lastSyncAt = Date() lastSyncAt = Date()
} }
@@ -470,6 +492,21 @@ class SyncManager: ObservableObject {
existing.severity = api.severity existing.severity = api.severity
existing.issueDescription = api.description existing.issueDescription = api.description
if let fid = api.facilityId { existing.facilityServerId = fid } 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 // Refresh photos in case they were added after first pull
// photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths).
// result_photos are resolution photos shown separately on the web, // result_photos are resolution photos shown separately on the web,
@@ -489,6 +526,18 @@ class SyncManager: ObservableObject {
local.serverId = api.id local.serverId = api.id
local.issueStatus = api.status local.issueStatus = api.status
local.syncStatus = "synced" // never re-submit 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 // Store server photos so IssueDetailView can show them
// photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths).
var serverPaths: [String] = [] var serverPaths: [String] = []
@@ -498,6 +547,7 @@ class SyncManager: ObservableObject {
if let ts = api.reportedAt, if let ts = api.reportedAt,
let date = Self.isoFormatter.date(from: ts) { let date = Self.isoFormatter.date(from: ts) {
local.createdAt = date local.createdAt = date
local.serverReportedAt = date // accurate server timestamp
} }
context.insert(local) context.insert(local)
} }
@@ -528,4 +578,19 @@ class SyncManager: ObservableObject {
// Non-fatal IssuesListView still shows device-created issues // 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 // MARK: - SidebarTab
enum SidebarTab: Hashable { enum SidebarTab: Hashable {
case dashboard // landing page KPI stats card
case myInspections case myInspections
case issues // inspector role only case issues
case facilities case facilities
case pendingSync case pendingSync
case history // moved after Pending Sync case history
case notifications // in-app notification inbox
case settings case settings
} }
@@ -38,16 +40,18 @@ struct DashboardView: View {
order: .reverse order: .reverse
) private var myInspections: [LocalInspection] ) 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 /// Each sidebar tap refreshes the UUID for that tab, forcing its
/// NavigationStack to be destroyed and recreated even when the tab /// NavigationStack to be destroyed and recreated even when the tab
/// hasn't changed (user is already on it but deep inside a detail view). /// hasn't changed (user is already on it but deep inside a detail view).
@State private var tabResetId: [SidebarTab: UUID] = [ @State private var tabResetId: [SidebarTab: UUID] = [
.dashboard: UUID(),
.myInspections: UUID(), .myInspections: UUID(),
.issues: UUID(), .issues: UUID(),
.facilities: UUID(), .facilities: UUID(),
.pendingSync: UUID(), .pendingSync: UUID(),
.history: UUID(), .history: UUID(),
.notifications: UUID(),
.settings: UUID(), .settings: UUID(),
] ]
@@ -69,6 +73,13 @@ struct DashboardView: View {
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
List { 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 // My Inspections
Button { selectTab(.myInspections) } label: { Button { selectTab(.myInspections) } label: {
HStack { HStack {
@@ -125,6 +136,27 @@ struct DashboardView: View {
} }
.listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear) .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 // Settings
Button { selectTab(.settings) } label: { Button { selectTab(.settings) } label: {
Label("Settings", systemImage: "gear") Label("Settings", systemImage: "gear")
@@ -138,6 +170,8 @@ struct DashboardView: View {
} detail: { } detail: {
switch selectedTab { switch selectedTab {
case .dashboard:
NavigationStack { DashboardStatsView() }
case .myInspections: case .myInspections:
NavigationStack(path: $inspectionsPath) { NavigationStack(path: $inspectionsPath) {
MyInspectionsView() MyInspectionsView()
@@ -167,6 +201,8 @@ struct DashboardView: View {
HistoryDetailView(inspection: inspection) HistoryDetailView(inspection: inspection)
} }
} }
case .notifications:
NavigationStack { NotificationsView() }
case .settings: case .settings:
NavigationStack { SettingsView() } 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 // MARK: - My Inspections
struct MyInspectionsView: View { struct MyInspectionsView: View {
@@ -664,10 +898,17 @@ struct FacilitiesListView: View {
struct IssuesListView: 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( @Query(
sort: \LocalIssue.createdAt, sort: \LocalIssue.createdAt,
order: .reverse order: .reverse
) private var issues: [LocalIssue] ) private var allIssues: [LocalIssue]
private var issues: [LocalIssue] {
allIssues.filter { $0.issueStatus != "resolved" }
}
@Environment(\.modelContext) private var context @Environment(\.modelContext) private var context
@State private var showNewIssue = false @State private var showNewIssue = false
@@ -707,10 +948,16 @@ struct IssueRowView: View {
let context: ModelContext let context: ModelContext
private var facilityName: String { 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 let id = issue.facilityServerId
return (try? context.fetch( let all = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id }) if let name = all.first(where: { $0.serverId == id })?.name {
).first?.name) ?? "Unknown Facility" return name
}
return issue.facilityNameCache ?? "Unknown Facility"
} }
private var severityColor: Color { 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 { var body: some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Circle() Circle()
@@ -734,6 +991,12 @@ struct IssueRowView: View {
.font(.caption.bold()) .font(.caption.bold())
.foregroundStyle(severityColor) .foregroundStyle(severityColor)
Spacer() 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) StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
} }
Text(issue.issueDescription) Text(issue.issueDescription)
@@ -760,6 +1023,12 @@ struct IssueDetailView: View {
@State private var isUpdatingStatus = false @State private var isUpdatingStatus = false
@State private var statusError: String? @State private var statusError: String?
@State private var showStatusPicker = false @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 { private var facilityName: String {
let id = issue.facilityServerId let id = issue.facilityServerId
@@ -810,8 +1079,20 @@ struct IssueDetailView: View {
.fontWeight(.semibold) .fontWeight(.semibold)
} }
LabeledContent("Facility", value: facilityName) 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)) date: .long, time: .shortened))
if let reporter = issue.reportedByName, !reporter.isEmpty {
LabeledContent("Reporter", value: reporter)
}
// Issue Status // Issue Status
LabeledContent("Issue Status") { LabeledContent("Issue Status") {
@@ -859,6 +1140,28 @@ struct IssueDetailView: View {
.font(.callout) .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") { Section("Sync Status") {
LabeledContent("Sync") { LabeledContent("Sync") {
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus) 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") .navigationTitle("Issue Detail")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { .task {
await refreshStatusFromServer() await refreshStatusFromServer()
await loadComments()
} }
} }
@@ -924,9 +1279,54 @@ struct IssueDetailView: View {
do { do {
let detail = try await APIClient.shared.fetchIssueDetail(issueId: sid) let detail = try await APIClient.shared.fetchIssueDetail(issueId: sid)
issue.issueStatus = detail.status 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() try? context.save()
} catch { } 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 // MARK: - Retryable Photo
/// Loads a server photo via AsyncImage with a tap-to-retry failure state. /// Loads a server photo via AsyncImage with a tap-to-retry failure state.
@@ -279,6 +279,9 @@ struct FlagIssueView: View {
severity: severity, severity: severity,
description: description.trimmingCharacters(in: .whitespaces) 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.photoLocalPaths = photos.map(\.path)
issue.inspection = inspection issue.inspection = inspection
inspection.localIssues.append(issue) inspection.localIssues.append(issue)