Jul 30 - Update iPad - inspector can re-inspect and create follow-up
This commit is contained in:
@@ -429,6 +429,56 @@ actor APIClient {
|
|||||||
return result.scheduled
|
return result.scheduled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plan a follow-up re-inspection of `parentInspectionId` for `dueDate`
|
||||||
|
/// (phase45) — the deferred twin of "Re-inspect Now" in history detail.
|
||||||
|
///
|
||||||
|
/// Only the parent and the date are sent: the server derives facility,
|
||||||
|
/// template and assignee from the parent inspection, so a follow-up can
|
||||||
|
/// only ever target the thing it is a follow-up of. The schedule it creates
|
||||||
|
/// carries `parent_inspection_id`, which the inspection started from it
|
||||||
|
/// inherits — that is what makes the eventual run a linked re-inspection.
|
||||||
|
///
|
||||||
|
/// Idempotent server-side: retrying re-dates the existing active follow-up
|
||||||
|
/// for this parent instead of creating a second one.
|
||||||
|
///
|
||||||
|
/// `dueDate` must be formatted `yyyy-MM-dd`; the server rejects a past date.
|
||||||
|
func createScheduledFollowUp(
|
||||||
|
parentInspectionId: Int,
|
||||||
|
dueDate: String,
|
||||||
|
notes: String?
|
||||||
|
) async throws -> APIScheduledInspection {
|
||||||
|
var body: [String: Any] = [
|
||||||
|
"parent_inspection_id": parentInspectionId,
|
||||||
|
"due_date": dueDate,
|
||||||
|
]
|
||||||
|
// Raw snake_case body keys — JSONSerialization applies no key strategy
|
||||||
|
// (rule 65).
|
||||||
|
if let n = notes?.trimmingCharacters(in: .whitespacesAndNewlines), !n.isEmpty {
|
||||||
|
body["notes"] = n
|
||||||
|
}
|
||||||
|
let result: APIScheduledFollowUpResponseData = try await post(
|
||||||
|
"/api/v1/scheduled-inspections/follow-up", body: body
|
||||||
|
)
|
||||||
|
return result.scheduled
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Follow-up Requests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Inspections a director/admin has flagged as needing a follow-up.
|
||||||
|
///
|
||||||
|
/// Same endpoint and response shape as `fetchInspectionHistory`, but with
|
||||||
|
/// the `follow_up_required` filter so the server returns the complete
|
||||||
|
/// outstanding set rather than the recent page history shows. The limit is
|
||||||
|
/// the endpoint's maximum for the same reason — this list drives actionable
|
||||||
|
/// work, and a follow-up raised on a months-old inspection must still
|
||||||
|
/// appear. Inspector-scoped server-side.
|
||||||
|
func fetchFollowUpRequests() async throws -> [APIInspectionSummary] {
|
||||||
|
let result: InspectionHistoryResponseData = try await request(
|
||||||
|
"/api/v1/inspections?follow_up_required=true&limit=200"
|
||||||
|
)
|
||||||
|
return result.inspections
|
||||||
|
}
|
||||||
|
|
||||||
// ── Issue Handler ("Handled By") ──────────────────────────────────────
|
// ── Issue Handler ("Handled By") ──────────────────────────────────────
|
||||||
|
|
||||||
/// Set who handles an issue. `details` carries any of the optional
|
/// Set who handles an issue. `details` carries any of the optional
|
||||||
|
|||||||
@@ -658,6 +658,11 @@ struct APIScheduledInspection: Decodable, Identifiable, Sendable {
|
|||||||
let nextDueDate: String? // ISO date "YYYY-MM-DD"
|
let nextDueDate: String? // ISO date "YYYY-MM-DD"
|
||||||
let isOverdue: Bool
|
let isOverdue: Bool
|
||||||
let notes: String?
|
let notes: String?
|
||||||
|
/// Set when this schedule is a planned follow-up of a specific inspection
|
||||||
|
/// (phase45, "Schedule Follow-up"). Carried onto the inspection started
|
||||||
|
/// from it so the run lands as a linked re-inspection. Nil for an ordinary
|
||||||
|
/// schedule, and on servers older than phase45.
|
||||||
|
let parentInspectionId: Int?
|
||||||
|
|
||||||
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)
|
||||||
@@ -672,10 +677,12 @@ struct APIScheduledInspection: Decodable, Identifiable, Sendable {
|
|||||||
nextDueDate = try? c.decode(String.self, forKey: .nextDueDate)
|
nextDueDate = try? c.decode(String.self, forKey: .nextDueDate)
|
||||||
isOverdue = (try? c.decode(Bool.self, forKey: .isOverdue)) ?? false
|
isOverdue = (try? c.decode(Bool.self, forKey: .isOverdue)) ?? false
|
||||||
notes = try? c.decode(String.self, forKey: .notes)
|
notes = try? c.decode(String.self, forKey: .notes)
|
||||||
|
parentInspectionId = try? c.decode(Int.self, forKey: .parentInspectionId)
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id, facilityId, facilityName, templateId, templateName
|
case id, facilityId, facilityName, templateId, templateName
|
||||||
case inspectorId, frequency, frequencyLabel, nextDueDate, isOverdue, notes
|
case inspectorId, frequency, frequencyLabel, nextDueDate, isOverdue, notes
|
||||||
|
case parentInspectionId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,6 +702,21 @@ struct APIScheduledInspectionsResponseData: Decodable, Sendable {
|
|||||||
private enum CodingKeys: String, CodingKey { case scheduled, total, limit, offset }
|
private enum CodingKeys: String, CodingKey { case scheduled, total, limit, offset }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response of `POST /api/v1/scheduled-inspections/follow-up` (phase45).
|
||||||
|
/// `created` is false when the server re-dated an existing follow-up for the
|
||||||
|
/// same parent instead of adding a second one.
|
||||||
|
struct APIScheduledFollowUpResponseData: Decodable, Sendable {
|
||||||
|
let scheduled: APIScheduledInspection
|
||||||
|
let created: Bool
|
||||||
|
|
||||||
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
scheduled = try c.decode(APIScheduledInspection.self, forKey: .scheduled)
|
||||||
|
created = (try? c.decode(Bool.self, forKey: .created)) ?? true
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey { case scheduled, created }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dashboard Stats (Phase B) ─────────────────────────────────────────────────
|
// ── Dashboard Stats (Phase B) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
struct APIDashboardStats: Decodable, Sendable {
|
struct APIDashboardStats: Decodable, Sendable {
|
||||||
|
|||||||
+23
-2
@@ -195,7 +195,8 @@ PendingPhoto.self, SyncQueueEntry.self
|
|||||||
| `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) |
|
| `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) |
|
||||||
| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId`, `scheduledInspectionServerId` (Int?, inline default — links the submission to the schedule it fulfils), `submitLatitude` (Double?), `submitLongitude` (Double?) |
|
| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId`, `scheduledInspectionServerId` (Int?, inline default — links the submission to the schedule it fulfils), `submitLatitude` (Double?), `submitLongitude` (Double?) |
|
||||||
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON`, **handler fields** (`handlerType`, `handlerLabel`, `facilityHandler*`, `vendor*` — all optional, synced from server, inspector-editable) |
|
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON`, **handler fields** (`handlerType`, `handlerLabel`, `facilityHandler*`, `vendor*` — all optional, synced from server, inspector-editable) |
|
||||||
| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` |
|
| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed), `parentInspectionServerId` (`Int?`, phase45 — set when the schedule is a planned follow-up; becomes the run's `parentServerId`). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` |
|
||||||
|
| `LocalFollowUpRequest` | Read-only cached follow-up request raised on the web (July 2026) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63; this is the *flagged parent* inspection's id and the `parentServerId` the re-inspection links to), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `overallScore`, `inspectionDateString` (sort key), `followUpNote`, `note` (computed, trimmed/nil-ed), `inspectedOn` (computed, parses the `yyyy-MM-dd` prefix only — see the file comment), `fulfilledLocally` (`= false`, rule 71), `parentFormDataJSON` (`= "{}"`, the parent's answers cached for re-inspection prefill — rule 79), `parentFormData` (computed). Pulled by `pullFollowUpRequests()` |
|
||||||
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
||||||
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
|
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
|
||||||
|
|
||||||
@@ -252,7 +253,9 @@ guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated e
|
|||||||
|
|
||||||
5. **`pullAssignedIssues`** — fetches `GET /api/v1/issues`. Merges `api.photoPath` + `api.mobilePhotoPaths` into `photoServerPaths`. **Does NOT include `api.resultPhotos`** — resolution photos are web-only. Deletion pass runs always (not short-circuited on empty response).
|
5. **`pullAssignedIssues`** — fetches `GET /api/v1/issues`. Merges `api.photoPath` + `api.mobilePhotoPaths` into `photoServerPaths`. **Does NOT include `api.resultPhotos`** — resolution photos are web-only. Deletion pass runs always (not short-circuited on empty response).
|
||||||
|
|
||||||
6. **`pollNotifications`** — fetches new notifications since `lastNotificationFetch` cursor.
|
6. **`pullFollowUpRequests`** — fetches `GET /api/v1/inspections?follow_up_required=true`. Upserts `LocalFollowUpRequest` by `serverId`, deletes rows the server no longer returns, and mirrors `followUpRequired`/`followUpNote` onto the matching `LocalInspection` so the history badge agrees with the card. Best-effort — never blocks the pipeline. Runs after `processInspectionQueue`, so the section clears on the same sync that submits the re-inspection.
|
||||||
|
|
||||||
|
7. **`pollNotifications`** — fetches new notifications since `lastNotificationFetch` cursor.
|
||||||
|
|
||||||
### Server-pulled issue identification
|
### Server-pulled issue identification
|
||||||
|
|
||||||
@@ -310,6 +313,8 @@ All server URLs built as: `ServerConfig.current + endpoint` — **`Constants.bas
|
|||||||
| `fetchNotifications` | `GET /api/v1/notifications` | Optional `since: Date` cursor |
|
| `fetchNotifications` | `GET /api/v1/notifications` | Optional `since: Date` cursor |
|
||||||
| `markNotificationsRead` | `PATCH /api/v1/notifications/mark-read` | Marks IDs read on server |
|
| `markNotificationsRead` | `PATCH /api/v1/notifications/mark-read` | Marks IDs read on server |
|
||||||
| `fetchScheduledInspections` | `GET /api/v1/scheduled-inspections` | Active scheduled assignments; inspector-scoped server-side. Pulled into `LocalScheduledInspection` |
|
| `fetchScheduledInspections` | `GET /api/v1/scheduled-inspections` | Active scheduled assignments; inspector-scoped server-side. Pulled into `LocalScheduledInspection` |
|
||||||
|
| `createScheduledFollowUp` | `POST /api/v1/scheduled-inspections/follow-up` | Plans a follow-up re-inspection for a later date (phase45). Body is only `parent_inspection_id` + `due_date` (`yyyy-MM-dd`) + optional `notes` — the server derives facility/template/assignee from the parent. Idempotent: a retry re-dates the existing active follow-up. Inspector-writable (deliberate divergence — the web is `@project_manager_required`). Online-only; see rule 80 |
|
||||||
|
| `fetchFollowUpRequests` | `GET /api/v1/inspections?follow_up_required=true&limit=200` | Outstanding follow-up requests; same response shape as `fetchInspectionHistory`. Limit is the endpoint max on purpose — a follow-up raised on a months-old inspection must still appear. Pulled into `LocalFollowUpRequest`. See rule 78 for what the server-side filter must mean |
|
||||||
| `updateIssueHandler` | `PATCH /api/v1/issues/<id>/handler` | Sets "Handled By". Body `["handler_type": …]` + optional snake_case detail keys (rule 65). Inspector-writable (server scopes by facility) |
|
| `updateIssueHandler` | `PATCH /api/v1/issues/<id>/handler` | Sets "Handled By". Body `["handler_type": …]` + optional snake_case detail keys (rule 65). Inspector-writable (server scopes by facility) |
|
||||||
|
|
||||||
### `APIAssignedIssue` fields
|
### `APIAssignedIssue` fields
|
||||||
@@ -467,6 +472,19 @@ else { continue }
|
|||||||
|
|
||||||
`CompletedInspectionView` shows orange banner with **Start Re-inspection** when `followUpRequired == true`. Opens `StartInspectionView` with `preFillTemplateId`, `preFillFacilityId`, `parentServerId`, `parentLocalId`.
|
`CompletedInspectionView` shows orange banner with **Start Re-inspection** when `followUpRequired == true`. Opens `StartInspectionView` with `preFillTemplateId`, `preFillFacilityId`, `parentServerId`, `parentLocalId`.
|
||||||
|
|
||||||
|
The **Follow-up Requested** card / section (July 2026) is the second trigger, and reaches the same view with `parentServerId` plus `preFillFollowUpNote` and `preFillParentFormDataJSON`.
|
||||||
|
|
||||||
|
**History detail** (`HistoryDetailView`) is the third and fourth: a **Re-inspect Now** toolbar button (immediate, passing this response's own answers as `preFillParentFormDataJSON` — history is served from the API, so the parent is usually not local) and **Schedule Follow-up**, which plans it for a later date via `createScheduledFollowUp`. The scheduled row then starts as a linked re-inspection because it carries `parentInspectionServerId`. See rule 80.
|
||||||
|
|
||||||
|
### Parent pre-fill — two sources
|
||||||
|
|
||||||
|
`StartInspectionView.startInspection()` copies the parent's answers forward, excluding `rating`, `pass_fail`, `image`, `signature` so every scoreable item is re-evaluated fresh and the parent's photos stay with the parent. This mirrors the web's `inspections.execute` prefill.
|
||||||
|
|
||||||
|
`resolvedParentFormData()` resolves the source in order:
|
||||||
|
|
||||||
|
1. The local `LocalInspection` with a matching `serverId` — the `CompletedInspectionView` path, where the inspector just finished it on this iPad. Used only when it actually holds values, so an empty local shell can't shadow source 2.
|
||||||
|
2. `preFillParentFormDataJSON` — snapshotted from the server onto `LocalFollowUpRequest.parentFormDataJSON` at pull time. This is the follow-up-request path. See rule 79.
|
||||||
|
|
||||||
### followUpRequired clearing — three points
|
### followUpRequired clearing — three points
|
||||||
|
|
||||||
1. Immediately on Submit in `ExecuteInspectionView.submitInspection()` via `clearParentFollowUpFlag()`.
|
1. Immediately on Submit in `ExecuteInspectionView.submitInspection()` via `clearParentFollowUpFlag()`.
|
||||||
@@ -706,6 +724,9 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
|
|||||||
| 75 | **A background launch has no ContentView and may have no ModelContainer** | `restoreSession()` runs from `ContentView.task{}` and the container comes from the `.modelContainer` scene modifier — neither happens on a cold BGTaskScheduler launch, so `triggerSync()`'s `isAuthenticated` / `modelContext` guards silently no-op. `handleBackgroundSync()` now restores the session itself and logs-and-returns when there is no context. Background sync therefore covers the *suspended-but-resident* case; cold relaunch needs the container hoisted out of the scene modifier. |
|
| 75 | **A background launch has no ContentView and may have no ModelContainer** | `restoreSession()` runs from `ContentView.task{}` and the container comes from the `.modelContainer` scene modifier — neither happens on a cold BGTaskScheduler launch, so `triggerSync()`'s `isAuthenticated` / `modelContext` guards silently no-op. `handleBackgroundSync()` now restores the session itself and logs-and-returns when there is no context. Background sync therefore covers the *suspended-but-resident* case; cold relaunch needs the container hoisted out of the scene modifier. |
|
||||||
| 76 | **A collapsed `NavigationSplitView` shows only its sidebar — Button-driven rows navigate nowhere on iPhone** | On compact width the split view collapses to a stack rooted at the sidebar, and the `detail:` column is presented only when something *pushes* it. Rule 2 forbids a `selection:` binding, so the rows are plain `Button`s that mutate `@State` — and a state change alone cannot push the detail column. The app installs on iPhone (`TARGETED_DEVICE_FAMILY = "1,2"`), so every inspector on a phone got a list where tapping highlighted the row and opened nothing: Dashboard, Inspections, Issues, Settings were all unreachable. Verified in the simulator: setting `selectedTab` programmatically still rendered only the sidebar. `DashboardView` now branches on `horizontalSizeClass` and gives compact width a real `NavigationStack` with `NavigationLink` rows. Never "fix" this by adding a `selection:` binding — that breaks iPadOS 17 (rule 2). |
|
| 76 | **A collapsed `NavigationSplitView` shows only its sidebar — Button-driven rows navigate nowhere on iPhone** | On compact width the split view collapses to a stack rooted at the sidebar, and the `detail:` column is presented only when something *pushes* it. Rule 2 forbids a `selection:` binding, so the rows are plain `Button`s that mutate `@State` — and a state change alone cannot push the detail column. The app installs on iPhone (`TARGETED_DEVICE_FAMILY = "1,2"`), so every inspector on a phone got a list where tapping highlighted the row and opened nothing: Dashboard, Inspections, Issues, Settings were all unreachable. Verified in the simulator: setting `selectedTab` programmatically still rendered only the sidebar. `DashboardView` now branches on `horizontalSizeClass` and gives compact width a real `NavigationStack` with `NavigationLink` rows. Never "fix" this by adding a `selection:` binding — that breaks iPadOS 17 (rule 2). |
|
||||||
| 77 | **The 12-column form grid is unusable below ~600 pt — reflow to one field per line, don't shrink it** | `GridFormView` positions cells absolutely from `cellW = (W - 32 - 88) / 12`. At 375 pt (iPhone SE/6/7/8) that is a 21 pt column and a 15 pt row, so an ordinary 6x2 field renders ~167x35 pt — less than the label needs. Cells are deliberately unclipped (matching the web's `overflow: visible`), so the excess draws *on top of* the row below and the form becomes an unreadable pile of overlapping controls. Below `GridFormView.minGridWidth` (600 pt, keeping a column at >=40 pt) the view switches to `stackedLayout`: fields sorted by `(row, col)` (rule 62), one per line, full width, natural height. Widgets with no intrinsic height (`textarea`, `signature`, `table`, `image`) get floors from `stackedMinH` or they collapse to nothing. In the stacked branch the card must be a `.background` modifier, **not** a `ZStack` sibling — as a sibling the flexible `RoundedRectangle` competes with the `VStack` for the container's size and the card ends up shorter than its own content, cutting off the last fields. `ReadOnlyGridFormView` (history detail) has the same 12-column assumption and the same compact branch, keyed off `horizontalSizeClass`. |
|
| 77 | **The 12-column form grid is unusable below ~600 pt — reflow to one field per line, don't shrink it** | `GridFormView` positions cells absolutely from `cellW = (W - 32 - 88) / 12`. At 375 pt (iPhone SE/6/7/8) that is a 21 pt column and a 15 pt row, so an ordinary 6x2 field renders ~167x35 pt — less than the label needs. Cells are deliberately unclipped (matching the web's `overflow: visible`), so the excess draws *on top of* the row below and the form becomes an unreadable pile of overlapping controls. Below `GridFormView.minGridWidth` (600 pt, keeping a column at >=40 pt) the view switches to `stackedLayout`: fields sorted by `(row, col)` (rule 62), one per line, full width, natural height. Widgets with no intrinsic height (`textarea`, `signature`, `table`, `image`) get floors from `stackedMinH` or they collapse to nothing. In the stacked branch the card must be a `.background` modifier, **not** a `ZStack` sibling — as a sibling the flexible `RoundedRectangle` competes with the `VStack` for the container's size and the card ends up shorter than its own content, cutting off the last fields. `ReadOnlyGridFormView` (history detail) has the same 12-column assumption and the same compact branch, keyed off `horizontalSizeClass`. |
|
||||||
|
| 78 | **"Outstanding follow-up" is three conditions, not one — `follow_up_required` alone is not the definition** | Every web surface (`inspections.list` / `reports` `status_filter == 'follow_up'`, `stats.pending_followups`) means **flagged AND `status == 'completed'` AND `~follow_ups.any()`**. The reason the third clause exists: the **web execute route never clears `follow_up_required` on the parent** — it only stops *listing* the parent once a child re-inspection exists. (The mobile POST path *does* clear the parent flag, `app/api/inspections.py`, so only web-completed re-inspections leave a stale flag.) The first cut of the API's `?follow_up_required=true` filter matched the flag alone, which would have returned follow-ups already satisfied on the web — and on the iPad those rows are **undismissable**: `pullFollowUpRequests()` keeps receiving them, `update(from:)` deliberately resets `fulfilledLocally = false` (the server is authoritative), so FOLLOW-UP REQUESTED would never clear and the only way out is a duplicate re-inspection. Fixed server-side so one definition serves every client. Never re-narrow this filter to the bare flag, and never "fix" a stuck row on the client — `fulfilledLocally` is a display flag, not state. |
|
||||||
|
| 79 | **A re-inspection's parent is usually NOT on the device — prefill must fall back to the cached snapshot, and never prefill without the template schema** | `startInspection()`'s prefill originally matched only a local `LocalInspection` by `serverId`. That works for `CompletedInspectionView` (the inspector just finished it here) but **not for a follow-up raised on the web**: that parent synced long ago and is routinely absent (reinstall, second iPad, follow-up raised weeks later — the same premise `LocalFollowUpRequest` exists for). The lookup found nothing, the whole block silently no-opped, and the form opened blank where the web pre-fills it. Fix: `LocalFollowUpRequest.parentFormDataJSON` caches the parent's answers at pull time — free, because `GET /api/v1/inspections` already returns `form_data` on every row via `_inspection_payload`, so there is no extra request and prefill works offline. Store `formDataRaw.mapValues(\.anyValue)`, **not** `formValues`: the latter joins arrays into `"a, b"`, which would be written back as one bogus string. Second trap, only reachable once prefill actually runs: the exclude set is derived from the template schema, so an unresolved schema (`?? []`) yields **no exclusions and copies everything** — including the parent's `image` paths, attaching its photos as this inspection's evidence. Guard on `!schema.isEmpty` and copy nothing instead. |
|
||||||
|
| 80 | **"Schedule Follow-up" is a server-side plan — it is the one action in the app that cannot work offline, and its link must survive the client forgetting it** | History detail (`HistoryDetailView`) carries three toolbar actions: **Re-inspect Now** (immediate, opens the linked re-inspection), **Schedule Follow-up** (deferred), and the existing email button. Starting an inspection writes locally and syncs later, but scheduling writes a `ScheduledInspection` row that only the server can create — there is no local record to queue, so the button is `.disabled(!sync.isOnline)` and failures report inline instead of dismissing as though they worked. Do not "fix" this by faking a local schedule: `pullScheduledInspections()` deletes any row the server doesn't return, so it would vanish on the next sync. The link itself is `scheduled_inspections.parent_inspection_id` (phase45): both start paths inherit it onto the inspection (`ScheduledStartTarget.parentServerId` on iPad, the web's `scheduled_inspections.start`), **and** the API's create-inspection endpoint re-derives it from the schedule when the client sends none — a belt-and-braces step that matters because an older build or a resumed draft would otherwise submit a plain inspection and leave the parent flagged forever. Creation is inspector-writable, a deliberate divergence from the web's `@project_manager_required`, and the endpoint is deliberately narrow: it takes only a parent + date and derives facility/template/assignee, so a follow-up can only ever target the thing it follows up on. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>com.jqc.sync</string>
|
<string>com.jqc.sync</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>processing</string>
|
<string>processing</string>
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ struct JanitorialQCApp: App {
|
|||||||
LocalInspection.self,
|
LocalInspection.self,
|
||||||
LocalIssue.self,
|
LocalIssue.self,
|
||||||
LocalScheduledInspection.self,
|
LocalScheduledInspection.self,
|
||||||
|
LocalFollowUpRequest.self,
|
||||||
PendingPhoto.self,
|
PendingPhoto.self,
|
||||||
SyncQueueEntry.self,
|
SyncQueueEntry.self,
|
||||||
], isUndoEnabled: false) { result in
|
], isUndoEnabled: false) { result in
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Models/LocalFollowUpRequest.swift
|
||||||
|
// ---------------------------------
|
||||||
|
// SwiftData model for follow-up requests raised by a director/admin on the web
|
||||||
|
// app against an inspection this inspector already completed.
|
||||||
|
//
|
||||||
|
// Read-only reference data pulled from the server
|
||||||
|
// (GET /api/v1/inspections?follow_up_required=true) and refreshed by
|
||||||
|
// SyncManager.pullFollowUpRequests() — never created or mutated on device.
|
||||||
|
// Surfaced in the "Follow-up Requested" section on the Dashboard and My
|
||||||
|
// Inspections. Tapping "Re-inspect" opens the normal new-inspection flow with
|
||||||
|
// the facility + template preselected and `parentServerId` set, so the
|
||||||
|
// submission lands as a linked re-inspection.
|
||||||
|
//
|
||||||
|
// WHY THIS EXISTS AS ITS OWN CACHE, rather than reading followUpRequired off
|
||||||
|
// LocalInspection: the flag is set on the WEB, after the inspection has already
|
||||||
|
// synced. By then the local copy either shows `status == "synced"` (filtered out
|
||||||
|
// of every My Inspections @Query) or is not on this device at all — a reinstall,
|
||||||
|
// a second iPad, or a follow-up raised weeks later all leave nothing to badge.
|
||||||
|
// The request has to be pulled as its own work item to be actionable.
|
||||||
|
//
|
||||||
|
// Follows LocalScheduledInspection field-for-field: a `.unique` serverId WITHOUT
|
||||||
|
// an inline default (a default on the unique key breaks @Model's PersistentModel
|
||||||
|
// conformance) and a full init(from:)/update(from:) pair.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class LocalFollowUpRequest {
|
||||||
|
|
||||||
|
/// Server ID of the flagged (parent) Inspection — stable unique identity,
|
||||||
|
/// and the value passed as `parentServerId` when the re-inspection starts.
|
||||||
|
@Attribute(.unique) var serverId: Int
|
||||||
|
|
||||||
|
var facilityServerId: Int
|
||||||
|
var facilityName: String
|
||||||
|
var templateServerId: Int
|
||||||
|
var templateName: String
|
||||||
|
|
||||||
|
/// Score the flagged inspection came back with, when it has one. Shown on
|
||||||
|
/// the row: the reason a follow-up was raised is usually the low score.
|
||||||
|
var overallScore: Double?
|
||||||
|
|
||||||
|
/// Raw server date string of the original inspection — sortable (ISO strings
|
||||||
|
/// sort chronologically) and the source for the parsed `inspectedOn`.
|
||||||
|
var inspectionDateString: String
|
||||||
|
|
||||||
|
/// The director's note explaining what the follow-up should address.
|
||||||
|
/// Stored raw; read through `note` for the normalised form.
|
||||||
|
var followUpNote: String?
|
||||||
|
|
||||||
|
/// Set on device the moment a re-inspection of this request is submitted, so
|
||||||
|
/// the FOLLOW-UP REQUESTED lists hide the row immediately — online or
|
||||||
|
/// offline — without waiting for the round trip.
|
||||||
|
///
|
||||||
|
/// Purely a display flag, and the exact counterpart of
|
||||||
|
/// `LocalScheduledInspection.fulfilledLocally` (see that file for the full
|
||||||
|
/// rationale). The lifecycle stays server-driven: the server clears
|
||||||
|
/// `follow_up_required` when the linked re-inspection arrives, the next pull
|
||||||
|
/// stops returning the row, and it is deleted. If the submission never
|
||||||
|
/// lands, the server still reports the flag and the row comes back —
|
||||||
|
/// self-healing.
|
||||||
|
///
|
||||||
|
/// Non-optional with an inline default, so SwiftData migrates lightweight
|
||||||
|
/// (CLAUDE.md rule 12): existing rows read as `false`, no app reinstall.
|
||||||
|
var fulfilledLocally: Bool = false
|
||||||
|
|
||||||
|
/// The flagged inspection's answers, JSON-encoded `[String: String]`, cached
|
||||||
|
/// so the re-inspection can pre-fill from them.
|
||||||
|
///
|
||||||
|
/// WHY CACHED HERE rather than read from the parent at start time: the
|
||||||
|
/// parent `LocalInspection` is usually **not on this device**. A follow-up is
|
||||||
|
/// raised on the web after the inspection already synced, so by then the
|
||||||
|
/// local copy may be long gone (reinstall, a second iPad, a follow-up raised
|
||||||
|
/// weeks later) — the same reason this model exists at all. The prefill in
|
||||||
|
/// `StartInspectionView` matched on a local parent only, found nothing, and
|
||||||
|
/// silently produced a blank form, unlike the web.
|
||||||
|
///
|
||||||
|
/// Free to carry: `GET /api/v1/inspections` already returns `form_data` on
|
||||||
|
/// every row (`_inspection_payload`), so this costs no extra request and the
|
||||||
|
/// prefill works offline — the values are captured at pull time.
|
||||||
|
///
|
||||||
|
/// Non-optional with an inline default so SwiftData migrates lightweight
|
||||||
|
/// (CLAUDE.md rule 12); `"{}"` decodes to an empty dictionary.
|
||||||
|
var parentFormDataJSON: String = "{}"
|
||||||
|
|
||||||
|
/// Last time this row was refreshed from the server pull.
|
||||||
|
var updatedAt: Date
|
||||||
|
|
||||||
|
/// The follow-up note, normalised: nil for nil, empty, or whitespace-only
|
||||||
|
/// text so callers can guard with a single `if let` instead of repeating the
|
||||||
|
/// emptiness check. Computed, not stored — no SwiftData schema change.
|
||||||
|
var note: String? {
|
||||||
|
guard let t = followUpNote?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!t.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed inspection date for display. Computed properties are not persisted
|
||||||
|
/// by SwiftData; sort on `inspectionDateString` (not this) in @Query.
|
||||||
|
///
|
||||||
|
/// Parses the leading `yyyy-MM-dd` rather than the whole timestamp on
|
||||||
|
/// purpose. `SyncManager.isoFormatter` is fixed at `yyyy-MM-dd'T'HH:mm:ss`
|
||||||
|
/// and returns nil the moment the server includes fractional seconds —
|
||||||
|
/// which it does whenever the column carries microseconds (SQLite keeps
|
||||||
|
/// them; MySQL truncates by default), so the same field parses on one
|
||||||
|
/// deployment and not another. Only the day is displayed here, so taking the
|
||||||
|
/// date prefix sidesteps the whole variation.
|
||||||
|
var inspectedOn: Date? {
|
||||||
|
guard inspectionDateString.count >= 10 else { return nil }
|
||||||
|
return Self.dateOnlyFormatter.date(from: String(inspectionDateString.prefix(10)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let dateOnlyFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
init(from api: APIInspectionSummary) {
|
||||||
|
self.serverId = api.id
|
||||||
|
self.facilityServerId = api.facilityId
|
||||||
|
self.facilityName = api.facilityName
|
||||||
|
self.templateServerId = api.templateId
|
||||||
|
self.templateName = api.templateName
|
||||||
|
self.overallScore = api.overallScore
|
||||||
|
self.inspectionDateString = api.inspectionDate ?? ""
|
||||||
|
self.followUpNote = api.followUpNote
|
||||||
|
self.fulfilledLocally = false
|
||||||
|
self.parentFormDataJSON = Self.encode(api)
|
||||||
|
self.updatedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialise the parent's answers for storage.
|
||||||
|
///
|
||||||
|
/// Uses the raw `formDataRaw` values rather than the flattened `formValues`
|
||||||
|
/// so the structure survives the round trip exactly as the web's prefill
|
||||||
|
/// copies it — an array field stays an array instead of being joined into
|
||||||
|
/// `"a, b"`, which would be written back as a single bogus string.
|
||||||
|
/// `JSONValue.anyValue` yields only JSON-serialisable types (NSNull for
|
||||||
|
/// null), so `JSONSerialization` accepts the result.
|
||||||
|
///
|
||||||
|
/// Returns `"{}"` on failure so the property is always valid JSON and
|
||||||
|
/// `parentFormData` can decode it without a special case.
|
||||||
|
private static func encode(_ api: APIInspectionSummary) -> String {
|
||||||
|
let raw = api.formDataRaw.mapValues(\.anyValue)
|
||||||
|
guard JSONSerialization.isValidJSONObject(raw),
|
||||||
|
let data = try? JSONSerialization.data(withJSONObject: raw),
|
||||||
|
let str = String(data: data, encoding: .utf8)
|
||||||
|
else { return "{}" }
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The flagged inspection's answers, decoded for prefill. Typed `[String: Any]`
|
||||||
|
/// to match `LocalInspection.formData`, which is what the value is copied into.
|
||||||
|
var parentFormData: [String: Any] {
|
||||||
|
guard let data = parentFormDataJSON.data(using: .utf8),
|
||||||
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
else { return [:] }
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(from api: APIInspectionSummary) {
|
||||||
|
self.facilityServerId = api.facilityId
|
||||||
|
self.facilityName = api.facilityName
|
||||||
|
self.templateServerId = api.templateId
|
||||||
|
self.templateName = api.templateName
|
||||||
|
self.overallScore = api.overallScore
|
||||||
|
self.inspectionDateString = api.inspectionDate ?? ""
|
||||||
|
self.followUpNote = api.followUpNote
|
||||||
|
self.parentFormDataJSON = Self.encode(api)
|
||||||
|
// The server is authoritative. Being returned by the pull at all means
|
||||||
|
// the follow-up is still outstanding, so any local "just did it" flag is
|
||||||
|
// stale by definition — a re-inspection that never synced, or one the
|
||||||
|
// server rejected.
|
||||||
|
self.fulfilledLocally = false
|
||||||
|
self.updatedAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,16 @@ final class LocalScheduledInspection {
|
|||||||
var isOverdue: Bool
|
var isOverdue: Bool
|
||||||
var notes: String?
|
var notes: String?
|
||||||
|
|
||||||
|
/// Server ID of the inspection this schedule is a planned follow-up of
|
||||||
|
/// (phase45). Carried onto the `LocalInspection` as `parentServerId` when
|
||||||
|
/// the inspector starts it, so the run lands as a linked re-inspection
|
||||||
|
/// rather than an ordinary scheduled one.
|
||||||
|
///
|
||||||
|
/// Optional, so SwiftData migrates lightweight without a plan (rule 46);
|
||||||
|
/// nil for an ordinary schedule and for every row pulled from a
|
||||||
|
/// pre-phase45 server.
|
||||||
|
var parentInspectionServerId: Int?
|
||||||
|
|
||||||
/// Set on device the moment an inspection fulfilling this schedule is
|
/// Set on device the moment an inspection fulfilling this schedule is
|
||||||
/// submitted, so the SCHEDULED lists hide the row immediately — online or
|
/// submitted, so the SCHEDULED lists hide the row immediately — online or
|
||||||
/// offline — without waiting for the round trip.
|
/// offline — without waiting for the round trip.
|
||||||
@@ -105,6 +115,7 @@ final class LocalScheduledInspection {
|
|||||||
self.dueDateString = api.nextDueDate ?? ""
|
self.dueDateString = api.nextDueDate ?? ""
|
||||||
self.isOverdue = api.isOverdue
|
self.isOverdue = api.isOverdue
|
||||||
self.notes = api.notes
|
self.notes = api.notes
|
||||||
|
self.parentInspectionServerId = api.parentInspectionId
|
||||||
self.fulfilledLocally = false
|
self.fulfilledLocally = false
|
||||||
self.updatedAt = Date()
|
self.updatedAt = Date()
|
||||||
}
|
}
|
||||||
@@ -120,6 +131,7 @@ final class LocalScheduledInspection {
|
|||||||
self.dueDateString = api.nextDueDate ?? ""
|
self.dueDateString = api.nextDueDate ?? ""
|
||||||
self.isOverdue = api.isOverdue
|
self.isOverdue = api.isOverdue
|
||||||
self.notes = api.notes
|
self.notes = api.notes
|
||||||
|
self.parentInspectionServerId = api.parentInspectionId
|
||||||
// The server is authoritative. Being returned by the pull at all means
|
// The server is authoritative. Being returned by the pull at all means
|
||||||
// this schedule is live again — for a recurring one, on its NEXT
|
// this schedule is live again — for a recurring one, on its NEXT
|
||||||
// occurrence — so any local "just did it" flag is stale by definition.
|
// occurrence — so any local "just did it" flag is stale by definition.
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ class SyncManager: ObservableObject {
|
|||||||
await pullReferenceData()
|
await pullReferenceData()
|
||||||
await pullAssignedIssues(context: context)
|
await pullAssignedIssues(context: context)
|
||||||
await pullScheduledInspections(context: context)
|
await pullScheduledInspections(context: context)
|
||||||
|
await pullFollowUpRequests(context: context)
|
||||||
|
|
||||||
// Poll notifications immediately on every sync rather than waiting
|
// Poll notifications immediately on every sync rather than waiting
|
||||||
// for the 60-second timer — ensures the inspector sees assignments
|
// for the 60-second timer — ensures the inspector sees assignments
|
||||||
@@ -918,6 +919,65 @@ class SyncManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Follow-up Requests ────────────────────────────────────────────────
|
||||||
|
// Read-only pull of inspections a director flagged for follow-up, for the
|
||||||
|
// Dashboard and My Inspections "Follow-up Requested" section. Upsert by
|
||||||
|
// serverId, then delete rows the server no longer returns (the follow-up was
|
||||||
|
// fulfilled by a linked re-inspection, or the director cleared the flag).
|
||||||
|
// Best-effort — never blocks the pipeline.
|
||||||
|
|
||||||
|
func pullFollowUpRequests(context: ModelContext) async {
|
||||||
|
guard isOnline, AuthManager.shared.isAuthenticated else { return }
|
||||||
|
do {
|
||||||
|
let apiRows = try await APIClient.shared.fetchFollowUpRequests()
|
||||||
|
|
||||||
|
// Fetch-all + filter/map in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||||
|
let allLocal = (try? context.fetch(FetchDescriptor<LocalFollowUpRequest>())) ?? []
|
||||||
|
var byServerId: [Int: LocalFollowUpRequest] = [:]
|
||||||
|
for row in allLocal { byServerId[row.serverId] = row }
|
||||||
|
|
||||||
|
for api in apiRows {
|
||||||
|
if let existing = byServerId[api.id] {
|
||||||
|
existing.update(from: api)
|
||||||
|
} else {
|
||||||
|
context.insert(LocalFollowUpRequest(from: api))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete rows the server no longer returns.
|
||||||
|
let returnedIds = Set(apiRows.map { $0.id })
|
||||||
|
for row in allLocal where !returnedIds.contains(row.serverId) {
|
||||||
|
context.delete(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the local copy of the flagged inspection in step, so the
|
||||||
|
// follow-up badge in My Inspections / history detail agrees with the
|
||||||
|
// card without waiting for the inspector to open that detail view
|
||||||
|
// (which was previously the only thing that wrote these fields).
|
||||||
|
var noteByServerId: [Int: String] = [:]
|
||||||
|
for api in apiRows {
|
||||||
|
if let note = api.followUpNote { noteByServerId[api.id] = note }
|
||||||
|
}
|
||||||
|
for local in (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? [] {
|
||||||
|
guard let sid = local.serverId else { continue }
|
||||||
|
if returnedIds.contains(sid) {
|
||||||
|
local.followUpRequired = true
|
||||||
|
local.followUpNote = noteByServerId[sid]
|
||||||
|
} else if local.followUpRequired {
|
||||||
|
local.followUpRequired = false
|
||||||
|
local.followUpNote = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try? context.save()
|
||||||
|
|
||||||
|
} catch APIError.notAuthenticated {
|
||||||
|
// Let AuthManager handle session expiry
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — stale follow-up rows stay visible until next pull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dashboard Stats ───────────────────────────────────────────────────
|
// ── Dashboard Stats ───────────────────────────────────────────────────
|
||||||
// Best-effort fetch — a network failure silently leaves dashboardStats nil
|
// Best-effort fetch — a network failure silently leaves dashboardStats nil
|
||||||
// so the UI falls back to a placeholder card. Never blocks the sync pipeline.
|
// so the UI falls back to a placeholder card. Never blocks the sync pipeline.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Foundation
|
|||||||
|
|
||||||
/// The two known JQC servers the inspector can connect to.
|
/// The two known JQC servers the inspector can connect to.
|
||||||
nonisolated enum ServerOption: String, CaseIterable, Sendable {
|
nonisolated enum ServerOption: String, CaseIterable, Sendable {
|
||||||
case primary = "https://jqc.ltservicesinc.com"
|
case primary = "http://127.0.0.1:5055"
|
||||||
case secondary = "https://jqc1.ltservicesinc.com"
|
case secondary = "https://jqc1.ltservicesinc.com"
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ struct DashboardView: View {
|
|||||||
order: .reverse
|
order: .reverse
|
||||||
) private var myInspections: [LocalInspection]
|
) private var myInspections: [LocalInspection]
|
||||||
|
|
||||||
|
/// Outstanding follow-up requests, counted into the My Inspections badge so
|
||||||
|
/// the inspector sees there is work waiting from any tab — the same reason
|
||||||
|
/// in-progress inspections are counted there.
|
||||||
|
@Query private var followUpRequests: [LocalFollowUpRequest]
|
||||||
|
|
||||||
|
/// Badge count for My Inspections: in-progress work plus outstanding
|
||||||
|
/// follow-ups. `fulfilledLocally` rows are excluded in Swift, not in the
|
||||||
|
/// @Query predicate (CLAUDE.md rule 3), so the badge drops the instant a
|
||||||
|
/// re-inspection is submitted.
|
||||||
|
private var myInspectionsBadgeCount: Int {
|
||||||
|
myInspections.count + followUpRequests.filter { !$0.fulfilledLocally }.count
|
||||||
|
}
|
||||||
|
|
||||||
@State private var selectedTab: SidebarTab = .dashboard
|
@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
|
||||||
@@ -181,8 +194,8 @@ struct DashboardView: View {
|
|||||||
Label("My Inspections", systemImage: "checklist")
|
Label("My Inspections", systemImage: "checklist")
|
||||||
.foregroundStyle(tint)
|
.foregroundStyle(tint)
|
||||||
Spacer()
|
Spacer()
|
||||||
if !myInspections.isEmpty {
|
if myInspectionsBadgeCount > 0 {
|
||||||
Text("\(myInspections.count)")
|
Text("\(myInspectionsBadgeCount)")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||||
.background(Color.blue.opacity(0.15))
|
.background(Color.blue.opacity(0.15))
|
||||||
@@ -332,6 +345,10 @@ struct DashboardStatsView: View {
|
|||||||
/// inspection empties its @Query while the start form is still presented.
|
/// inspection empties its @Query while the start form is still presented.
|
||||||
@State private var scheduledStartTarget: ScheduledStartTarget? = nil
|
@State private var scheduledStartTarget: ScheduledStartTarget? = nil
|
||||||
|
|
||||||
|
/// The follow-up request tapped in FollowUpRequestsCard. Held here for the
|
||||||
|
/// same reason as `scheduledStartTarget` — see the covers at the bottom.
|
||||||
|
@State private var followUpStartTarget: FollowUpStartTarget? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
@@ -341,6 +358,15 @@ struct DashboardStatsView: View {
|
|||||||
DraftResumeBanner(drafts: draftInspections, context: context)
|
DraftResumeBanner(drafts: draftInspections, context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Follow-up Requests ─────────────────────────────────────
|
||||||
|
// Re-inspections a director asked for. Ranked above SCHEDULED:
|
||||||
|
// a follow-up is remedial work on a facility that already failed
|
||||||
|
// once, so it is the more urgent of the two. Self-hides when
|
||||||
|
// there are none. Tap a row to start the linked re-inspection.
|
||||||
|
FollowUpRequestsCard(onStart: { target in
|
||||||
|
followUpStartTarget = target
|
||||||
|
})
|
||||||
|
|
||||||
// ── Scheduled Inspections (phase36) ────────────────────────
|
// ── Scheduled Inspections (phase36) ────────────────────────
|
||||||
// Planned/recurring assignments for this inspector. Self-hides
|
// Planned/recurring assignments for this inspector. Self-hides
|
||||||
// when there are none. Tap a row to start it (facility +
|
// when there are none. Tap a row to start it (facility +
|
||||||
@@ -484,10 +510,34 @@ struct DashboardStatsView: View {
|
|||||||
StartInspectionView(
|
StartInspectionView(
|
||||||
preFillTemplateId: t.templateServerId,
|
preFillTemplateId: t.templateServerId,
|
||||||
preFillFacilityId: t.facilityServerId,
|
preFillFacilityId: t.facilityServerId,
|
||||||
|
// phase45 — nil for an ordinary schedule; set when this row is a
|
||||||
|
// planned follow-up, which makes the run a linked re-inspection.
|
||||||
|
// Must precede preFillScheduleId: argument order follows the
|
||||||
|
// property declaration order in StartInspectionView.
|
||||||
|
parentServerId: t.parentServerId,
|
||||||
preFillScheduleId: t.id,
|
preFillScheduleId: t.id,
|
||||||
preFillScheduleInstructions: t.instructions
|
preFillScheduleInstructions: t.instructions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Start cover for a tapped follow-up request. Owned here for the same
|
||||||
|
// reason as the scheduled cover above: FollowUpRequestsCard self-hides
|
||||||
|
// the instant its last row is invalidated at submit, which is exactly
|
||||||
|
// when this cover is on screen.
|
||||||
|
//
|
||||||
|
// `parentServerId` is what makes this a re-inspection rather than a
|
||||||
|
// fresh one — the server reads it to clear follow_up_required on the
|
||||||
|
// flagged inspection. `parentLocalId` stays nil: the parent synced long
|
||||||
|
// ago (that is how it got flagged), so serverId is the reliable handle,
|
||||||
|
// and clearParentFollowUpFlag()'s fallback-1 matches on it.
|
||||||
|
.fullScreenCover(item: $followUpStartTarget) { t in
|
||||||
|
StartInspectionView(
|
||||||
|
preFillTemplateId: t.templateServerId,
|
||||||
|
preFillFacilityId: t.facilityServerId,
|
||||||
|
parentServerId: t.id,
|
||||||
|
preFillFollowUpNote: t.note,
|
||||||
|
preFillParentFormDataJSON: t.parentFormDataJSON
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -648,6 +648,13 @@ struct ExecuteInspectionView: View {
|
|||||||
// regardless of connectivity or sync timing.
|
// regardless of connectivity or sync timing.
|
||||||
clearParentFollowUpFlag()
|
clearParentFollowUpFlag()
|
||||||
|
|
||||||
|
// ── Drop the cached follow-up request row ─────────────────────────
|
||||||
|
// Same immediacy as above, for the other surface: the FOLLOW-UP
|
||||||
|
// REQUESTED card on the Dashboard and My Inspections reads its own
|
||||||
|
// pulled cache, not LocalInspection, so clearing the flag above is not
|
||||||
|
// enough to make the row disappear.
|
||||||
|
fulfillFollowUpRequest()
|
||||||
|
|
||||||
// ── Fulfil the originating scheduled inspection ───────────────────
|
// ── Fulfil the originating scheduled inspection ───────────────────
|
||||||
// Two jobs, both mirroring the web app's execute route:
|
// Two jobs, both mirroring the web app's execute route:
|
||||||
// 1. Make sure the submission carries scheduled_inspection_id, even
|
// 1. Make sure the submission carries scheduled_inspection_id, even
|
||||||
@@ -719,6 +726,28 @@ struct ExecuteInspectionView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Invalidate the cached follow-up request this submission satisfies, so the
|
||||||
|
/// FOLLOW-UP REQUESTED card and section clear the moment Submit is tapped —
|
||||||
|
/// online or offline — rather than waiting for the round trip.
|
||||||
|
///
|
||||||
|
/// Matches on `parentServerId` alone. Unlike the schedule fallback there is
|
||||||
|
/// no facility+template guess here: a request is keyed by the exact
|
||||||
|
/// inspection it was raised against, and that id is set whenever the run was
|
||||||
|
/// launched from a follow-up row or from CompletedInspectionView's banner.
|
||||||
|
/// An ad-hoc inspection of the same facility is genuinely not the follow-up
|
||||||
|
/// the director asked for, and must not clear it.
|
||||||
|
///
|
||||||
|
/// The row is flagged, not deleted, for the same reason as
|
||||||
|
/// `LocalScheduledInspection.fulfilledLocally`: the server is authoritative,
|
||||||
|
/// and `pullFollowUpRequests()` deletes the row once the flag actually
|
||||||
|
/// clears — or brings it back if the submission never landed.
|
||||||
|
private func fulfillFollowUpRequest() {
|
||||||
|
guard let sid = inspection.parentServerId else { return }
|
||||||
|
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||||
|
let all = (try? context.fetch(FetchDescriptor<LocalFollowUpRequest>())) ?? []
|
||||||
|
all.first { $0.serverId == sid }?.fulfilledLocally = true
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scheduled inspection fulfilment ───────────────────────────────────
|
// ── Scheduled inspection fulfilment ───────────────────────────────────
|
||||||
|
|
||||||
/// Link this submission to the schedule it satisfies, then drop the cached
|
/// Link this submission to the schedule it satisfies, then drop the cached
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Views/Dashboard/FollowUpRequestsView.swift
|
||||||
|
// ------------------------------------------
|
||||||
|
// Displays inspections a director/admin flagged as needing a follow-up, pulled
|
||||||
|
// read-only from GET /api/v1/inspections?follow_up_required=true by
|
||||||
|
// SyncManager.pullFollowUpRequests().
|
||||||
|
//
|
||||||
|
// Deliberately built as the twin of ScheduledInspectionsView: a follow-up
|
||||||
|
// request is assigned work the inspector must recognise and act on, exactly
|
||||||
|
// like a scheduled assignment, so it gets the same two surfaces and the same
|
||||||
|
// ownership rules.
|
||||||
|
//
|
||||||
|
// Two consumers share one FollowUpRow:
|
||||||
|
// • FollowUpRequestsCard — VStack card for the Dashboard ScrollView.
|
||||||
|
// Presentational only; it reports taps via `onStart` and DashboardStatsView
|
||||||
|
// owns the .fullScreenCover on its always-present ScrollView.
|
||||||
|
// • MyInspectionsView renders its own "Follow-up Requested" List section
|
||||||
|
// inline, reusing FollowUpRow, with the start cover on the enclosing Group.
|
||||||
|
// Both self-hide when there are none, and present StartInspectionView with the
|
||||||
|
// facility + template preselected AND `parentServerId` set, so the submission
|
||||||
|
// lands as a linked re-inspection — the same path CompletedInspectionView's
|
||||||
|
// "Start Re-inspection" banner has always used.
|
||||||
|
// Both cover owners are views that outlive the rows themselves — submitting the
|
||||||
|
// last follow-up empties the @Query while the cover is still up, so a cover
|
||||||
|
// owned by the self-hiding card would be torn down with it.
|
||||||
|
//
|
||||||
|
// The lifecycle stays server-driven: the re-inspection carries
|
||||||
|
// `parent_inspection_id` and the server clears `follow_up_required` on arrival.
|
||||||
|
// ExecuteInspectionView only invalidates the local cache row;
|
||||||
|
// pullFollowUpRequests() re-reads the authoritative state.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
// MARK: - Start target snapshot
|
||||||
|
|
||||||
|
/// Plain-value snapshot of the tapped request, used as the `.fullScreenCover`
|
||||||
|
/// item instead of the `LocalFollowUpRequest` itself.
|
||||||
|
///
|
||||||
|
/// The model object is unsafe to hold across the presentation: the cached row is
|
||||||
|
/// invalidated while the cover is still on screen — by `fulfillFollowUpRequest()`
|
||||||
|
/// the instant Submit is tapped, and deleted by `pullFollowUpRequests()` once the
|
||||||
|
/// server stops returning it. Reading a deleted `PersistentModel` traps, and a
|
||||||
|
/// `@Query` that empties out would also tear the cover down mid-submit. Copying
|
||||||
|
/// the values at tap time removes both hazards. (Same reasoning as
|
||||||
|
/// `ScheduledStartTarget`.)
|
||||||
|
struct FollowUpStartTarget: Identifiable {
|
||||||
|
/// Flagged inspection's `serverId` — the identity for `.fullScreenCover(item:)`
|
||||||
|
/// and the `parentServerId` the re-inspection is linked to.
|
||||||
|
let id: Int
|
||||||
|
let templateServerId: Int
|
||||||
|
let facilityServerId: Int
|
||||||
|
/// The director's note explaining what the follow-up should address.
|
||||||
|
let note: String?
|
||||||
|
/// The flagged inspection's answers, JSON-encoded, carried so the
|
||||||
|
/// re-inspection can pre-fill from them. Snapshotted here for the same
|
||||||
|
/// reason as every other field: the row it came from is invalidated while
|
||||||
|
/// the start form is still on screen.
|
||||||
|
let parentFormDataJSON: String
|
||||||
|
|
||||||
|
init(_ request: LocalFollowUpRequest) {
|
||||||
|
self.id = request.serverId
|
||||||
|
self.templateServerId = request.templateServerId
|
||||||
|
self.facilityServerId = request.facilityServerId
|
||||||
|
self.note = request.note
|
||||||
|
self.parentFormDataJSON = request.parentFormDataJSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared row
|
||||||
|
|
||||||
|
struct FollowUpRow: View {
|
||||||
|
let request: LocalFollowUpRequest
|
||||||
|
|
||||||
|
private var inspectedText: String {
|
||||||
|
if let d = request.inspectedOn {
|
||||||
|
return d.formatted(date: .abbreviated, time: .omitted)
|
||||||
|
}
|
||||||
|
return request.inspectionDateString.isEmpty ? "—" : request.inspectionDateString
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.arrow.circlepath")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.top, 2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(request.templateName.isEmpty ? "Inspection" : request.templateName)
|
||||||
|
.font(.callout.bold())
|
||||||
|
Text(request.facilityName.isEmpty ? "Facility" : request.facilityName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("Follow-up")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||||
|
.background(Color.orange.opacity(0.15))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
Text("Inspected \(inspectedText)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
// The score is usually why the follow-up was raised, so it
|
||||||
|
// is the one number worth showing before the tap.
|
||||||
|
if let score = request.overallScore {
|
||||||
|
Text("· \(String(format: "%.1f%%", score))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note preview — so the inspector can see there is something to
|
||||||
|
// read before committing to the tap. Truncated to one line; the
|
||||||
|
// full text is shown on the start screen. Mirrors the
|
||||||
|
// instructions preview on ScheduledRow.
|
||||||
|
if let note = request.note {
|
||||||
|
HStack(alignment: .top, spacing: 4) {
|
||||||
|
Image(systemName: "info.circle.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text(note)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
Label("Re-inspect", systemImage: "arrow.uturn.right.circle.fill")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
||||||
|
.background(Color.orange)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dashboard card (VStack)
|
||||||
|
|
||||||
|
struct FollowUpRequestsCard: View {
|
||||||
|
@Query(sort: \LocalFollowUpRequest.inspectionDateString, order: .forward)
|
||||||
|
private var requests: [LocalFollowUpRequest]
|
||||||
|
|
||||||
|
/// Rows still awaiting action. Filtered in Swift rather than in the @Query
|
||||||
|
/// predicate, per CLAUDE.md rule 3. `fulfilledLocally` is set at submit and
|
||||||
|
/// cleared by the next pull, so a completed follow-up leaves the card at
|
||||||
|
/// once and reappears only if the re-inspection never reached the server.
|
||||||
|
private var visible: [LocalFollowUpRequest] {
|
||||||
|
requests.filter { !$0.fulfilledLocally }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tap handler. The `.fullScreenCover` deliberately lives in the PARENT
|
||||||
|
/// (`DashboardStatsView`, on its always-present ScrollView) rather than here,
|
||||||
|
/// for the same reason as `ScheduledInspectionsCard`: this card self-hides,
|
||||||
|
/// and submitting the last follow-up removes the final row while the cover is
|
||||||
|
/// still on screen. Keeping the card purely presentational also keeps the
|
||||||
|
/// empty case a true `EmptyView`, so the dashboard stack adds no spacing.
|
||||||
|
let onStart: (FollowUpStartTarget) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !visible.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("FOLLOW-UP REQUESTED")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.tracking(1)
|
||||||
|
|
||||||
|
ForEach(visible) { r in
|
||||||
|
Button { onStart(FollowUpStartTarget(r)) } label: {
|
||||||
|
FollowUpRow(request: r)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,10 +24,23 @@ struct MyInspectionsView: View {
|
|||||||
scheduledAll.filter { !$0.fulfilledLocally }
|
scheduledAll.filter { !$0.fulfilledLocally }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Follow-up requests raised on the web — rendered as the top section, above
|
||||||
|
/// Scheduled, and counted in the empty-state decision. Sorted by the flagged
|
||||||
|
/// inspection's date (ISO strings sort chronologically), oldest first: the
|
||||||
|
/// longest-outstanding request is the one to clear next.
|
||||||
|
@Query(sort: \LocalFollowUpRequest.inspectionDateString, order: .forward)
|
||||||
|
private var followUpsAll: [LocalFollowUpRequest]
|
||||||
|
|
||||||
|
/// Rows still awaiting action — see FollowUpRequestsCard.visible.
|
||||||
|
private var followUpsVisible: [LocalFollowUpRequest] {
|
||||||
|
followUpsAll.filter { !$0.fulfilledLocally }
|
||||||
|
}
|
||||||
|
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
@State private var showNewInspection = false
|
@State private var showNewInspection = false
|
||||||
@State private var scheduledStartTarget: ScheduledStartTarget?
|
@State private var scheduledStartTarget: ScheduledStartTarget?
|
||||||
|
@State private var followUpStartTarget: FollowUpStartTarget?
|
||||||
|
|
||||||
// Deletion confirmation state
|
// Deletion confirmation state
|
||||||
@State private var pendingDelete: LocalInspection?
|
@State private var pendingDelete: LocalInspection?
|
||||||
@@ -35,7 +48,7 @@ struct MyInspectionsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if inspections.isEmpty && scheduledVisible.isEmpty {
|
if inspections.isEmpty && scheduledVisible.isEmpty && followUpsVisible.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Inspections",
|
"No Inspections",
|
||||||
systemImage: "checklist",
|
systemImage: "checklist",
|
||||||
@@ -43,6 +56,20 @@ struct MyInspectionsView: View {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
|
// Follow-up requests — self-hides when empty. First section:
|
||||||
|
// remedial work on a facility that already failed once
|
||||||
|
// outranks a routine scheduled visit.
|
||||||
|
if !followUpsVisible.isEmpty {
|
||||||
|
Section("Follow-up Requested") {
|
||||||
|
ForEach(followUpsVisible) { r in
|
||||||
|
Button { followUpStartTarget = FollowUpStartTarget(r) } label: {
|
||||||
|
FollowUpRow(request: r)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Scheduled assignments (phase36) — self-hides when empty.
|
// Scheduled assignments (phase36) — self-hides when empty.
|
||||||
if !scheduledVisible.isEmpty {
|
if !scheduledVisible.isEmpty {
|
||||||
Section("Scheduled") {
|
Section("Scheduled") {
|
||||||
@@ -87,10 +114,28 @@ struct MyInspectionsView: View {
|
|||||||
StartInspectionView(
|
StartInspectionView(
|
||||||
preFillTemplateId: t.templateServerId,
|
preFillTemplateId: t.templateServerId,
|
||||||
preFillFacilityId: t.facilityServerId,
|
preFillFacilityId: t.facilityServerId,
|
||||||
|
// phase45 — nil for an ordinary schedule; set when this row is a
|
||||||
|
// planned follow-up, which makes the run a linked re-inspection.
|
||||||
|
// Must precede preFillScheduleId: argument order follows the
|
||||||
|
// property declaration order in StartInspectionView.
|
||||||
|
parentServerId: t.parentServerId,
|
||||||
preFillScheduleId: t.id,
|
preFillScheduleId: t.id,
|
||||||
preFillScheduleInstructions: t.instructions
|
preFillScheduleInstructions: t.instructions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Also on the Group, not the List — see the comment above. Submitting the
|
||||||
|
// last follow-up can flip this view to ContentUnavailableView while the
|
||||||
|
// cover is still presented. `parentServerId` is what links the run back
|
||||||
|
// to the flagged inspection; see the twin cover in DashboardStatsView.
|
||||||
|
.fullScreenCover(item: $followUpStartTarget) { t in
|
||||||
|
StartInspectionView(
|
||||||
|
preFillTemplateId: t.templateServerId,
|
||||||
|
preFillFacilityId: t.facilityServerId,
|
||||||
|
parentServerId: t.id,
|
||||||
|
preFillFollowUpNote: t.note,
|
||||||
|
preFillParentFormDataJSON: t.parentFormDataJSON
|
||||||
|
)
|
||||||
|
}
|
||||||
.navigationTitle("My Inspections")
|
.navigationTitle("My Inspections")
|
||||||
// Confirmation before deletion — destructive action cannot be undone
|
// Confirmation before deletion — destructive action cannot be undone
|
||||||
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
|
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
|
||||||
|
|||||||
@@ -44,12 +44,18 @@ struct ScheduledStartTarget: Identifiable {
|
|||||||
/// `notes` (API key `notes`, column `scheduled_inspections.notes`); only the
|
/// `notes` (API key `notes`, column `scheduled_inspections.notes`); only the
|
||||||
/// user-facing wording is "Instructions".
|
/// user-facing wording is "Instructions".
|
||||||
let instructions: String?
|
let instructions: String?
|
||||||
|
/// Inspection this schedule is a planned follow-up of (phase45), or nil for
|
||||||
|
/// an ordinary schedule. Passed to `StartInspectionView` as `parentServerId`
|
||||||
|
/// so the run lands as a linked re-inspection — the whole point of
|
||||||
|
/// "Schedule Follow-up".
|
||||||
|
let parentServerId: Int?
|
||||||
|
|
||||||
init(_ schedule: LocalScheduledInspection) {
|
init(_ schedule: LocalScheduledInspection) {
|
||||||
self.id = schedule.serverId
|
self.id = schedule.serverId
|
||||||
self.templateServerId = schedule.templateServerId
|
self.templateServerId = schedule.templateServerId
|
||||||
self.facilityServerId = schedule.facilityServerId
|
self.facilityServerId = schedule.facilityServerId
|
||||||
self.instructions = schedule.instructions
|
self.instructions = schedule.instructions
|
||||||
|
self.parentServerId = schedule.parentInspectionServerId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,27 @@ struct StartInspectionView: View {
|
|||||||
var parentServerId: Int? = nil
|
var parentServerId: Int? = nil
|
||||||
var parentLocalId: String? = nil
|
var parentLocalId: String? = nil
|
||||||
|
|
||||||
|
/// The director's note explaining what the follow-up should address, set when
|
||||||
|
/// the inspector taps a row in the FOLLOW-UP REQUESTED card. Passed as a plain
|
||||||
|
/// String rather than read from SwiftData here, for the same reason
|
||||||
|
/// FollowUpStartTarget exists — the cached request row is invalidated at
|
||||||
|
/// submit while this flow is still on screen. Nil for a re-inspection the
|
||||||
|
/// inspector started themselves from CompletedInspectionView.
|
||||||
|
var preFillFollowUpNote: String? = nil
|
||||||
|
|
||||||
|
/// The flagged inspection's answers, JSON-encoded, used to pre-fill this
|
||||||
|
/// re-inspection when the parent `LocalInspection` is not on this device.
|
||||||
|
///
|
||||||
|
/// The local-parent lookup in `startInspection()` covers the
|
||||||
|
/// CompletedInspectionView path, where the inspector is re-inspecting
|
||||||
|
/// something they just finished on this iPad. It does **not** cover a
|
||||||
|
/// follow-up raised on the web: that parent synced long ago and is often
|
||||||
|
/// absent locally, so the lookup found nothing and the form came up blank —
|
||||||
|
/// where the web pre-fills it. Cached at pull time on
|
||||||
|
/// `LocalFollowUpRequest`, so this works offline too. Nil for every other
|
||||||
|
/// start path.
|
||||||
|
var preFillParentFormDataJSON: String? = nil
|
||||||
|
|
||||||
// ── Scheduled inspection launch ───────────────────────────────────────
|
// ── Scheduled inspection launch ───────────────────────────────────────
|
||||||
/// Server ID of the ScheduledInspection this run fulfils, passed when the
|
/// Server ID of the ScheduledInspection this run fulfils, passed when the
|
||||||
/// inspector taps Start on a scheduled row. Carried onto the LocalInspection
|
/// inspector taps Start on a scheduled row. Carried onto the LocalInspection
|
||||||
@@ -123,6 +144,16 @@ struct StartInspectionView: View {
|
|||||||
Text("This will be linked to inspection #\(parentServerId!).")
|
Text("This will be linked to inspection #\(parentServerId!).")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
// What the director actually asked for. Shown
|
||||||
|
// here rather than in its own section so it
|
||||||
|
// reads as part of the request, and repeated in
|
||||||
|
// full because the card truncates it to a line.
|
||||||
|
if let note = preFillFollowUpNote, !note.isEmpty {
|
||||||
|
Text(note)
|
||||||
|
.font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
@@ -307,17 +338,30 @@ struct StartInspectionView: View {
|
|||||||
// inspector doesn't re-enter static data. Scoring fields (rating,
|
// inspector doesn't re-enter static data. Scoring fields (rating,
|
||||||
// pass_fail) and media fields (image, signature) are always left blank
|
// pass_fail) and media fields (image, signature) are always left blank
|
||||||
// so every scoreable item must be re-evaluated fresh.
|
// so every scoreable item must be re-evaluated fresh.
|
||||||
if let parentId = parentServerId {
|
if parentServerId != nil {
|
||||||
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
// Prefer the local parent (the CompletedInspectionView path, where
|
||||||
if let parent = allInspections.first(where: { $0.serverId == parentId }),
|
// the inspector just finished it on this iPad); otherwise fall back
|
||||||
!parent.formData.isEmpty {
|
// to the snapshot cached on the follow-up request. A follow-up
|
||||||
|
// raised on the web has usually synced and been dropped locally, so
|
||||||
|
// without the fallback this whole block silently no-opped and the
|
||||||
|
// form came up blank — the bug this fixes.
|
||||||
|
let parentData = resolvedParentFormData()
|
||||||
|
|
||||||
// Fetch the template schema to identify field types.
|
// Fetch the template schema to identify field types.
|
||||||
// Split into two statements — avoids Xcode 26 #Predicate
|
// Split into two statements — avoids Xcode 26 #Predicate
|
||||||
// ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
|
// ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
|
||||||
let allTemplates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
let allTemplates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||||||
let tid = templateId
|
let tid = templateId
|
||||||
let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? []
|
let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? []
|
||||||
|
|
||||||
|
// The schema is what identifies which fields must NOT be carried
|
||||||
|
// over, so without it there is no safe prefill: an empty exclude set
|
||||||
|
// would copy *everything*, including the parent's `image` paths and
|
||||||
|
// its ratings — attaching the previous inspection's photos as this
|
||||||
|
// one's evidence and pre-answering the scoreable items. Copy nothing
|
||||||
|
// instead. (Unreachable in practice: the template was chosen from
|
||||||
|
// the local picker, so it is cached — this is a guard, not a case.)
|
||||||
|
if !parentData.isEmpty, !schema.isEmpty {
|
||||||
|
|
||||||
// Build the set of field IDs that must NOT be carried over
|
// Build the set of field IDs that must NOT be carried over
|
||||||
let excludeTypes: Set<String> = ["rating", "pass_fail", "image", "signature"]
|
let excludeTypes: Set<String> = ["rating", "pass_fail", "image", "signature"]
|
||||||
@@ -330,7 +374,6 @@ struct StartInspectionView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Copy all parent values except excluded fields
|
// Copy all parent values except excluded fields
|
||||||
let parentData = parent.formData
|
|
||||||
var prefilled: [String: Any] = [:]
|
var prefilled: [String: Any] = [:]
|
||||||
for (key, value) in parentData {
|
for (key, value) in parentData {
|
||||||
if !excludeIds.contains(key) {
|
if !excludeIds.contains(key) {
|
||||||
@@ -349,4 +392,34 @@ struct StartInspectionView: View {
|
|||||||
createdInspection = inspection
|
createdInspection = inspection
|
||||||
navigateToExecution = true
|
navigateToExecution = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The parent inspection's answers to pre-fill from, or `[:]` when there are
|
||||||
|
/// none to carry.
|
||||||
|
///
|
||||||
|
/// Two sources, in order:
|
||||||
|
/// 1. The local `LocalInspection` with a matching `serverId` — the
|
||||||
|
/// re-inspection-from-history path, where the parent is on this device
|
||||||
|
/// and is the freshest copy.
|
||||||
|
/// 2. `preFillParentFormDataJSON`, snapshotted from the server at pull
|
||||||
|
/// time — the follow-up-request path, where the parent has synced and
|
||||||
|
/// is typically no longer local.
|
||||||
|
///
|
||||||
|
/// Source 1 is checked first but only wins when it actually holds values, so
|
||||||
|
/// a stray empty local shell can't shadow a good server snapshot.
|
||||||
|
private func resolvedParentFormData() -> [String: Any] {
|
||||||
|
if let parentId = parentServerId {
|
||||||
|
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||||
|
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||||
|
if let parent = allInspections.first(where: { $0.serverId == parentId }) {
|
||||||
|
let localData = parent.formData
|
||||||
|
if !localData.isEmpty { return localData }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let json = preFillParentFormDataJSON,
|
||||||
|
let data = json.data(using: .utf8),
|
||||||
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
else { return [:] }
|
||||||
|
return dict
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,11 +350,32 @@ struct HistoryDetailView: View {
|
|||||||
let inspection: APIInspectionSummary
|
let inspection: APIInspectionSummary
|
||||||
|
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
@EnvironmentObject private var auth: AuthManager
|
||||||
|
@EnvironmentObject private var sync: SyncManager
|
||||||
@State private var showReInspect = false
|
@State private var showReInspect = false
|
||||||
@State private var showMailCompose = false
|
@State private var showMailCompose = false
|
||||||
@State private var isGeneratingPDF = false
|
@State private var isGeneratingPDF = false
|
||||||
@State private var generatedPDFData: Data? = nil
|
@State private var generatedPDFData: Data? = nil
|
||||||
|
|
||||||
|
// ── Schedule Follow-up (phase45) ──────────────────────────────────────
|
||||||
|
@State private var showScheduleSheet = false
|
||||||
|
/// Defaults to tomorrow: the point of this action is to plan the follow-up
|
||||||
|
/// for another day. Today is still selectable — the server allows it.
|
||||||
|
@State private var followUpDate = Calendar.current.date(
|
||||||
|
byAdding: .day, value: 1, to: Date()
|
||||||
|
) ?? Date()
|
||||||
|
@State private var followUpNotes = ""
|
||||||
|
@State private var isSchedulingFollowUp = false
|
||||||
|
@State private var scheduleError: String? = nil
|
||||||
|
@State private var scheduleConfirmation: String? = nil
|
||||||
|
|
||||||
|
/// Auditors are read-only everywhere else and the API rejects them (403),
|
||||||
|
/// so the two action buttons are hidden rather than shown failing.
|
||||||
|
private var canStartFollowUp: Bool {
|
||||||
|
["admin", "director", "inspector", "project_manager"]
|
||||||
|
.contains(auth.currentUserRole)
|
||||||
|
}
|
||||||
|
|
||||||
// Local SwiftData copy — used only for follow-up sync-back.
|
// Local SwiftData copy — used only for follow-up sync-back.
|
||||||
// Form data and schema come from the server response directly so
|
// Form data and schema come from the server response directly so
|
||||||
// History works even after app reinstall or on a different device.
|
// History works even after app reinstall or on a different device.
|
||||||
@@ -463,6 +484,34 @@ struct HistoryDetailView: View {
|
|||||||
.navigationTitle(inspection.templateName)
|
.navigationTitle(inspection.templateName)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
// ── Re-inspect now ────────────────────────────────────────────
|
||||||
|
// The immediate half of the follow-up pair. Opens the same linked
|
||||||
|
// re-inspection flow the follow-up banner has always used, but
|
||||||
|
// without waiting to be asked for one.
|
||||||
|
if canStartFollowUp {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showReInspect = true
|
||||||
|
} label: {
|
||||||
|
Label("Re-inspect Now", systemImage: "arrow.uturn.right.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schedule follow-up ────────────────────────────────────
|
||||||
|
// The deferred half. Needs the network: it creates a schedule
|
||||||
|
// server-side rather than a local record, so unlike starting an
|
||||||
|
// inspection it cannot be queued offline.
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
scheduleError = nil
|
||||||
|
showScheduleSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Schedule Follow-up", systemImage: "calendar.badge.plus")
|
||||||
|
}
|
||||||
|
.disabled(!sync.isOnline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button {
|
Button {
|
||||||
Task { await prepareAndShowMail() }
|
Task { await prepareAndShowMail() }
|
||||||
@@ -476,16 +525,34 @@ struct HistoryDetailView: View {
|
|||||||
.disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF)
|
.disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showScheduleSheet) { scheduleFollowUpSheet }
|
||||||
|
// Confirmation of a successful schedule. An alert rather than an inline
|
||||||
|
// banner because the sheet has already dismissed by this point.
|
||||||
|
.alert("Follow-up Scheduled",
|
||||||
|
isPresented: Binding(get: { scheduleConfirmation != nil },
|
||||||
|
set: { if !$0 { scheduleConfirmation = nil } })) {
|
||||||
|
Button("OK") { scheduleConfirmation = nil }
|
||||||
|
} message: {
|
||||||
|
Text(scheduleConfirmation ?? "")
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadLocalData()
|
loadLocalData()
|
||||||
syncFollowUpToLocalCopy()
|
syncFollowUpToLocalCopy()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showReInspect) {
|
// Full-screen, not a sheet: every inspection-start flow is full-screen
|
||||||
|
// (rule 66), and this one is now reachable from the toolbar on any
|
||||||
|
// completed inspection rather than only the follow-up banner.
|
||||||
|
.fullScreenCover(isPresented: $showReInspect) {
|
||||||
StartInspectionView(
|
StartInspectionView(
|
||||||
preFillTemplateId: inspection.templateId,
|
preFillTemplateId: inspection.templateId,
|
||||||
preFillFacilityId: inspection.facilityId,
|
preFillFacilityId: inspection.facilityId,
|
||||||
parentServerId: inspection.id,
|
parentServerId: inspection.id,
|
||||||
parentLocalId: inspection.mobileLocalId
|
parentLocalId: inspection.mobileLocalId,
|
||||||
|
// History is served from the API, so this inspection is often
|
||||||
|
// not on this device at all and the local-parent lookup finds
|
||||||
|
// nothing — the form would open blank (rule 79). The answers are
|
||||||
|
// already in this very response, so pass them straight through.
|
||||||
|
preFillParentFormDataJSON: parentFormDataJSON
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showMailCompose) {
|
.sheet(isPresented: $showMailCompose) {
|
||||||
@@ -501,6 +568,138 @@ struct HistoryDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This inspection's answers, JSON-encoded for `StartInspectionView`'s
|
||||||
|
/// parent prefill. Raw values, not the flattened `formValues`, so an array
|
||||||
|
/// field survives as an array (rule 79).
|
||||||
|
private var parentFormDataJSON: String {
|
||||||
|
let raw = inspection.formDataRaw.mapValues(\.anyValue)
|
||||||
|
guard JSONSerialization.isValidJSONObject(raw),
|
||||||
|
let data = try? JSONSerialization.data(withJSONObject: raw),
|
||||||
|
let str = String(data: data, encoding: .utf8)
|
||||||
|
else { return "{}" }
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schedule Follow-up sheet (phase45) ────────────────────────────────
|
||||||
|
|
||||||
|
/// Date + note picker for planning a follow-up re-inspection.
|
||||||
|
///
|
||||||
|
/// Only the date and an optional note are collected: the server derives
|
||||||
|
/// facility, template and assignee from the parent inspection, so there is
|
||||||
|
/// nothing else for the inspector to get wrong.
|
||||||
|
private var scheduleFollowUpSheet: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Text(inspection.templateName)
|
||||||
|
.font(.callout.bold())
|
||||||
|
Text(inspection.facilityName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} header: {
|
||||||
|
Text("Follow-up of Inspection #\(inspection.id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
DatePicker(
|
||||||
|
"Due Date",
|
||||||
|
selection: $followUpDate,
|
||||||
|
in: Date()..., // the server rejects a past date
|
||||||
|
displayedComponents: .date
|
||||||
|
)
|
||||||
|
.datePickerStyle(.graphical)
|
||||||
|
} header: {
|
||||||
|
Text("When")
|
||||||
|
} footer: {
|
||||||
|
Text("The follow-up appears in Scheduled on this date, "
|
||||||
|
+ "assigned to the inspector who did the original.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField(
|
||||||
|
"What should the follow-up address?",
|
||||||
|
text: $followUpNotes,
|
||||||
|
axis: .vertical
|
||||||
|
)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
} header: {
|
||||||
|
Text("Instructions (optional)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let err = scheduleError {
|
||||||
|
Section {
|
||||||
|
Label(err, systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Schedule Follow-up")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { showScheduleSheet = false }
|
||||||
|
.disabled(isSchedulingFollowUp)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button {
|
||||||
|
Task { await submitScheduledFollowUp() }
|
||||||
|
} label: {
|
||||||
|
if isSchedulingFollowUp {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text("Schedule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isSchedulingFollowUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the follow-up schedule on the server, then refresh so it appears
|
||||||
|
/// in the Scheduled lists without waiting for the next timed sync.
|
||||||
|
///
|
||||||
|
/// Online-only by nature: this writes a server-side plan, not a local
|
||||||
|
/// record, so there is nothing meaningful to queue offline — the button is
|
||||||
|
/// disabled when offline and this reports any failure inline rather than
|
||||||
|
/// dismissing as if it had worked.
|
||||||
|
private func submitScheduledFollowUp() async {
|
||||||
|
isSchedulingFollowUp = true
|
||||||
|
scheduleError = nil
|
||||||
|
|
||||||
|
let due = Self.dueDateFormatter.string(from: followUpDate)
|
||||||
|
do {
|
||||||
|
_ = try await APIClient.shared.createScheduledFollowUp(
|
||||||
|
parentInspectionId: inspection.id,
|
||||||
|
dueDate: due,
|
||||||
|
notes: followUpNotes
|
||||||
|
)
|
||||||
|
// Pull the new schedule straight into the Scheduled section.
|
||||||
|
await sync.pullScheduledInspections(context: context)
|
||||||
|
|
||||||
|
isSchedulingFollowUp = false
|
||||||
|
showScheduleSheet = false
|
||||||
|
followUpNotes = ""
|
||||||
|
scheduleConfirmation =
|
||||||
|
"A follow-up re-inspection of \(inspection.facilityName) is scheduled for "
|
||||||
|
+ followUpDate.formatted(date: .abbreviated, time: .omitted) + "."
|
||||||
|
} catch {
|
||||||
|
isSchedulingFollowUp = false
|
||||||
|
scheduleError = (error as? APIError)?.localizedDescription
|
||||||
|
?? "Could not schedule the follow-up. Check your connection and try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `yyyy-MM-dd` for the API's `due_date`. Fixed POSIX locale so a non-
|
||||||
|
/// Gregorian device calendar cannot emit a date the server can't parse.
|
||||||
|
private static let dueDateFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
/// Generates the PDF (fetching any server photos over the network),
|
/// Generates the PDF (fetching any server photos over the network),
|
||||||
/// then presents the mail compose sheet with it attached.
|
/// then presents the mail compose sheet with it attached.
|
||||||
/// Photo fetches happen here, off the synchronous PDF drawing pass.
|
/// Photo fetches happen here, off the synchronous PDF drawing pass.
|
||||||
|
|||||||
Reference in New Issue
Block a user