05/27 Update functionalities
This commit is contained in:
@@ -210,7 +210,8 @@ actor APIClient {
|
|||||||
"description": issue.issueDescription,
|
"description": issue.issueDescription,
|
||||||
"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 {
|
||||||
|
|||||||
@@ -395,22 +395,38 @@ 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)
|
||||||
id = try c.decode(Int.self, forKey: .id)
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
status = try c.decode(String.self, forKey: .status)
|
status = try c.decode(String.self, forKey: .status)
|
||||||
severity = try c.decode(String.self, forKey: .severity)
|
severity = try c.decode(String.self, forKey: .severity)
|
||||||
description = try c.decode(String.self, forKey: .description)
|
description = try c.decode(String.self, forKey: .description)
|
||||||
assignedTo = try? c.decode(Int.self, forKey: .assignedTo)
|
assignedTo = try? c.decode(Int.self, forKey: .assignedTo)
|
||||||
facilityId = try? c.decode(Int.self, forKey: .facilityId)
|
facilityId = try? c.decode(Int.self, forKey: .facilityId)
|
||||||
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 }
|
||||||
|
}
|
||||||
|
|||||||
+22
-5
@@ -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,12 +337,14 @@ 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)
|
||||||
│ ├── My Inspections → MyInspectionsView [+ button here, NOT in sidebar]
|
│ ├── Dashboard → DashboardStatsView [default landing — KPI tiles]
|
||||||
│ ├── Issues → IssuesListView [+ button here → StandaloneIssueView]
|
│ ├── My Inspections → MyInspectionsView [+ button here, NOT in sidebar]
|
||||||
|
│ ├── Issues → IssuesListView [+ button → StandaloneIssueView; resolved excluded]
|
||||||
│ ├── Facilities → FacilitiesListView
|
│ ├── Facilities → FacilitiesListView
|
||||||
│ ├── Pending Sync → SyncStatusView
|
│ ├── Pending Sync → SyncStatusView
|
||||||
│ ├── History → InspectionHistoryView
|
│ ├── 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)
|
└── (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 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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,10 +112,17 @@ 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] = []
|
||||||
@@ -497,7 +546,8 @@ class SyncManager: ObservableObject {
|
|||||||
local.photoServerPaths = serverPaths
|
local.photoServerPaths = serverPaths
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user