Jul 27 - Update for scheduled task instruction
This commit is contained in:
@@ -432,7 +432,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.6;
|
MARKETING_VERSION = 1.7;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -476,7 +476,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.6;
|
MARKETING_VERSION = 1.7;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
+10
-1
@@ -389,6 +389,12 @@ Without it — the original bug — the schedule was never fulfilled: the banner
|
|||||||
|
|
||||||
No SyncManager change was needed: `pullScheduledInspections()` already runs after `processInspectionQueue()` in the same `triggerSync()` pass and deletes rows the server no longer returns, so the section clears on the same sync that submits the inspection.
|
No SyncManager change was needed: `pullScheduledInspections()` already runs after `processInspectionQueue()` in the same `triggerSync()` pass and deletes rows the server no longer returns, so the section clears on the same sync that submits the inspection.
|
||||||
|
|
||||||
|
**Second fix (July 2026) — the `+` path and the vanishing cover.** The above only covers inspections *started from a Scheduled row*. The iPad's `+` button reaches the identical form with the facility and template hand-picked, and that path leaves `scheduledInspectionServerId` nil — the server stores `scheduled_inspection_id = NULL`, never calls `_fulfill_schedule()`, and the schedule stays **Active** on the web. On the web this cannot happen: `scheduled_inspections.start` is the only way in. `ExecuteInspectionView.resolveAndFulfillSchedule()` (called from `submitInspection()`, before `context.save()`) restores parity — see rule 69 for the match criteria and why the cached row is deleted rather than rolled forward.
|
||||||
|
|
||||||
|
Deleting that row at submit time exposed a second problem: `ScheduledInspectionsCard` self-hid the moment its `@Query` emptied, tearing down the `.fullScreenCover` it owned — with the inspector's form inside it. Cover ownership therefore moved to the parents (`DashboardStatsView`'s `ScrollView`, `MyInspectionsView`'s `Group`) and the cover item became the value type `ScheduledStartTarget`. See rule 68.
|
||||||
|
|
||||||
|
**Instructions (July 2026).** The manager-authored text on a schedule is labelled **"Instructions"** everywhere the user sees it, but remains `notes` on the wire, in `APIScheduledInspection`, and in `LocalScheduledInspection`. `LocalScheduledInspection.instructions` is a *computed* accessor over `notes` that trims and nils-out blank text — no stored property, so no schema change and no migration. Three surfaces: a one-line preview on `ScheduledRow`, a full Section at the top of `StartInspectionView` (passed in as `preFillScheduleInstructions` via `ScheduledStartTarget.instructions`), and a collapsible banner above the form in `ExecuteInspectionView` (looked up from SwiftData, so it also works on the draft-resume path where no parameter is threaded). See rule 70.
|
||||||
|
|
||||||
### Inspection-start presentation (July 2026)
|
### Inspection-start presentation (July 2026)
|
||||||
|
|
||||||
All start flows are full-screen for consistency (rule 66): draft-resume (dashboard) → `.fullScreenCover` → `ExecuteInspectionView(isModallyPresented: true)` with a leading `Close`; scheduled / new (`+`) / re-inspection → `.fullScreenCover` → `StartInspectionView` (its own Cancel). Pushed presentations (My Inspections row → `ExecuteInspectionView`) keep `isModallyPresented = false` and rely on the nav back button.
|
All start flows are full-screen for consistency (rule 66): draft-resume (dashboard) → `.fullScreenCover` → `ExecuteInspectionView(isModallyPresented: true)` with a leading `Close`; scheduled / new (`+`) / re-inspection → `.fullScreenCover` → `StartInspectionView` (its own Cancel). Pushed presentations (My Inspections row → `ExecuteInspectionView`) keep `isModallyPresented = false` and rely on the nav back button.
|
||||||
@@ -682,10 +688,13 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
|
|||||||
| 62 | **Sort `schema` by `(row, col)` before grouping fields into row buckets** | The form editor stores fields in creation/drag order, NOT row-numeric order. Section fields have their own row numbers but may appear anywhere in the JSON array. Any code that groups fields by `row` and attaches section headers must first sort by `(f["row"], f["col"])` — exactly like the web's `sorted(key=lambda f: (f['row'], f['col']))`. Without this, sections attach to the wrong rows and appear displaced or missing. Sites that use absolute `(col, row)` pixel offsets (e.g. `GridFormView` ZStack, `canvasHeight()`) are unaffected — sort order only matters when grouping by row for sequential rendering. |
|
| 62 | **Sort `schema` by `(row, col)` before grouping fields into row buckets** | The form editor stores fields in creation/drag order, NOT row-numeric order. Section fields have their own row numbers but may appear anywhere in the JSON array. Any code that groups fields by `row` and attaches section headers must first sort by `(f["row"], f["col"])` — exactly like the web's `sorted(key=lambda f: (f['row'], f['col']))`. Without this, sections attach to the wrong rows and appear displaced or missing. Sites that use absolute `(col, row)` pixel offsets (e.g. `GridFormView` ZStack, `canvasHeight()`) are unaffected — sort order only matters when grouping by row for sequential rendering. |
|
||||||
|
|
||||||
| 63 | **`@Attribute(.unique)` must NOT carry an inline default value** | A `.unique` key with a default (`@Attribute(.unique) var serverId: Int = 0`) stops the `@Model` macro from emitting a clean `PersistentModel` conformance. Symptom is misleading: the `.modelContainer(for: [ … ])` array literal fails to type-check and Xcode reports **"Cannot find '<OtherModel>' in scope" on the *other* schema elements**, not the offending one. Declare the unique key with no default (`@Attribute(.unique) var serverId: Int`) and set it in `init`, exactly like `LocalFacility`/`LocalArea`. (Bit us adding `LocalScheduledInspection`, July 2026.) |
|
| 63 | **`@Attribute(.unique)` must NOT carry an inline default value** | A `.unique` key with a default (`@Attribute(.unique) var serverId: Int = 0`) stops the `@Model` macro from emitting a clean `PersistentModel` conformance. Symptom is misleading: the `.modelContainer(for: [ … ])` array literal fails to type-check and Xcode reports **"Cannot find '<OtherModel>' in scope" on the *other* schema elements**, not the offending one. Declare the unique key with no default (`@Attribute(.unique) var serverId: Int`) and set it in `init`, exactly like `LocalFacility`/`LocalArea`. (Bit us adding `LocalScheduledInspection`, July 2026.) |
|
||||||
| 64 | **Attach `.fullScreenCover` / `.sheet` to a stable view, NEVER to a `Section`** | A `Section` inside a `List` is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing `List`/`ScrollView`/`VStack` root instead. The dashboard scheduled card puts its cover on the VStack; `MyInspectionsView` puts the scheduled "Start" cover on the `List`. |
|
| 64 | **Attach `.fullScreenCover` / `.sheet` to a stable view, NEVER to a `Section`** | A `Section` inside a `List` is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing `List`/`ScrollView`/`VStack`/`Group` root instead — and that root must also **outlive the data that drives the presentation** (see rule 68). The dashboard scheduled cover lives on `DashboardStatsView`'s `ScrollView`; `MyInspectionsView` puts the scheduled "Start" cover on its enclosing `Group`. |
|
||||||
| 65 | **`CodingKeys` stay plain camelCase (decoder uses `.convertFromSnakeCase`); request-body keys are raw snake_case** | The shared `JSONDecoder` sets `keyDecodingStrategy = .convertFromSnakeCase`, converting JSON `facility_handler_name` → `facilityHandlerName` **before** matching — so `CodingKeys` must be bare camelCase; adding an explicit `= "facility_handler_name"` raw value double-converts and breaks decode. Conversely PATCH/POST bodies are `[String: Any]` encoded with `JSONSerialization` (no key strategy), so body keys must be the literal snake_case the server reads (`"handler_type"`, `"vendor_name"`, …). |
|
| 65 | **`CodingKeys` stay plain camelCase (decoder uses `.convertFromSnakeCase`); request-body keys are raw snake_case** | The shared `JSONDecoder` sets `keyDecodingStrategy = .convertFromSnakeCase`, converting JSON `facility_handler_name` → `facilityHandlerName` **before** matching — so `CodingKeys` must be bare camelCase; adding an explicit `= "facility_handler_name"` raw value double-converts and breaks decode. Conversely PATCH/POST bodies are `[String: Any]` encoded with `JSONSerialization` (no key strategy), so body keys must be the literal snake_case the server reads (`"handler_type"`, `"vendor_name"`, …). |
|
||||||
| 66 | **Modal (fullScreenCover root) views need an explicit Close/Cancel; pushed views get the nav back button for free** | `ExecuteInspectionView` takes `isModallyPresented` and shows a leading `Close` only when true (draft-resume from the dashboard is the root of its `NavigationStack`, no back button). All inspection-start flows now use `.fullScreenCover` for a consistent full-screen form: draft-resume → `ExecuteInspectionView(isModallyPresented: true)`; scheduled/new/re-inspection → `StartInspectionView` (its own `.cancellationAction` Cancel). `.onDisappear`/auto-save preserves work, so Close is always safe. |
|
| 66 | **Modal (fullScreenCover root) views need an explicit Close/Cancel; pushed views get the nav back button for free** | `ExecuteInspectionView` takes `isModallyPresented` and shows a leading `Close` only when true (draft-resume from the dashboard is the root of its `NavigationStack`, no back button). All inspection-start flows now use `.fullScreenCover` for a consistent full-screen form: draft-resume → `ExecuteInspectionView(isModallyPresented: true)`; scheduled/new/re-inspection → `StartInspectionView` (its own `.cancellationAction` Cancel). `.onDisappear`/auto-save preserves work, so Close is always safe. |
|
||||||
| 67 | **Render server photos through `ServerConfig.mediaURL(absolute:path:)`, never by hand-building `current + "/static/" + path`** | After the R2 migration the server returns absolute display URLs (presigned R2, or absolute-static on the local backend): `photo_urls`/`result_photo_urls` on issues (`APIAssignedIssue`/`APIIssueDetail` → `LocalIssue.photoServerUrls`/`resultPhotoServerUrls`, parallel to the path arrays), and `form_media` `{fieldId: url}` on inspection detail (`APIInspectionSummary.mediaURLByPath`, injected into the read-only grid via the `\.mediaURLByPath` environment for `PhotoThumbnailView`). The resolver prefers the absolute URL and falls back to `/static/` for older servers. Presigned URLs expire (24h) — always render from the freshest pull/detail fetch; don't persist a URL and reuse it days later. |
|
| 67 | **Render server photos through `ServerConfig.mediaURL(absolute:path:)`, never by hand-building `current + "/static/" + path`** | After the R2 migration the server returns absolute display URLs (presigned R2, or absolute-static on the local backend): `photo_urls`/`result_photo_urls` on issues (`APIAssignedIssue`/`APIIssueDetail` → `LocalIssue.photoServerUrls`/`resultPhotoServerUrls`, parallel to the path arrays), and `form_media` `{fieldId: url}` on inspection detail (`APIInspectionSummary.mediaURLByPath`, injected into the read-only grid via the `\.mediaURLByPath` environment for `PhotoThumbnailView`). The resolver prefers the absolute URL and falls back to `/static/` for older servers. Presigned URLs expire (24h) — always render from the freshest pull/detail fetch; don't persist a URL and reuse it days later. |
|
||||||
|
| 68 | **A `.fullScreenCover` owner must outlive the rows that trigger it; carry a value snapshot, not the `@Model` object** | `ScheduledInspectionsCard` self-hides on `scheduled.isEmpty`, and submitting the last scheduled inspection empties that `@Query` **while the cover is still on screen** — the card disappears and takes its cover (and the inspector's form) with it. Fix: the card is presentational and reports taps via `onStart`; `DashboardStatsView` owns the cover on its always-present `ScrollView`, `MyInspectionsView` on its `Group`. The cover item is `ScheduledStartTarget` (three plain `Int`s), never `LocalScheduledInspection` — reading a deleted `PersistentModel` traps. |
|
||||||
|
| 69 | **`ExecuteInspectionView.resolveAndFulfillSchedule()` links the submission to its schedule and invalidates the cached row — it never computes the next due date** | Two entry points reach the identical form (Scheduled row → prefilled; `+` → hand-picked), but only the first sets `scheduledInspectionServerId`, so a `+`-started inspection lands with `scheduled_inspection_id = NULL` and the schedule stays **Active** on the web. The fallback matches an active `LocalScheduledInspection` on facility + template, gated to `dueDateString <= today`, assignee `nil`-or-self, earliest due first, and skipped for re-inspections. Roll-forward stays server-side: the local model has no phase43 recurrence detail (weekdays / month_mode / day_of_month / nth_week / nth_weekday), so the row is **deleted**, and `pullScheduledInspections()` re-inserts it with the authoritative `next_due_date` — which also self-heals a submission that never lands. |
|
||||||
|
| 70 | **Snapshot schedule instructions into `@State` in `onAppear` — never read them from SwiftData during `body`** | `ExecuteInspectionView` shows the schedule's instructions above the form, but `resolveAndFulfillSchedule()` **deletes** that `LocalScheduledInspection` the instant Submit is tapped and the view stays up for another 2.5 s showing the success banner. A computed lookup would re-read a deleted `PersistentModel` in that window and trap. `loadScheduleInstructions()` copies the `String` once, at appear. Same reasoning as `ScheduledStartTarget` (rule 68): once the fulfilment path can delete a cached row mid-flow, every consumer must hold a value, not the model. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
JQC — "Instructions" on scheduled inspections
|
||||||
|
=============================================
|
||||||
|
NO migration. NO schema change (web or iOS). NO API change.
|
||||||
|
The field stays `notes` end-to-end: WTForms field name, ScheduledInspection.notes,
|
||||||
|
scheduled_inspections.notes column, and the JSON key in
|
||||||
|
GET /api/v1/scheduled-inspections. Only the wording changed.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
WEB — repo: lt_janitorial_quality_control
|
||||||
|
deploy root: /home/jqc/janitorial_qc/
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
Overwrite:
|
||||||
|
app/utils/forms.py (label 'Notes' -> 'Instructions')
|
||||||
|
app/templates/scheduled_inspections/form.html (placeholder + visibility hint, rows 2->3)
|
||||||
|
app/templates/inspections/execute.html (NEW: instructions panel)
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
Deploy (code only — no alembic step):
|
||||||
|
cd /home/jqc/janitorial_qc
|
||||||
|
git pull
|
||||||
|
sudo systemctl restart janitorial_qc
|
||||||
|
sudo systemctl status janitorial_qc --no-pager
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
iOS — repo: jqc_ios_app
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
Overwrite:
|
||||||
|
JanitorialQC/Models/LocalScheduledInspection.swift (computed `instructions`)
|
||||||
|
JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift
|
||||||
|
JanitorialQC/Views/Dashboard/StartInspectionView.swift
|
||||||
|
JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift
|
||||||
|
JanitorialQC/Views/Dashboard/MyInspectionsView.swift
|
||||||
|
JanitorialQC/Views/Dashboard/DashboardView.swift
|
||||||
|
JanitorialQC/CLAUDE.md
|
||||||
|
|
||||||
|
No new files, none removed, no .xcodeproj edit
|
||||||
|
(PBXFileSystemSynchronizedRootGroup picks changes up automatically).
|
||||||
|
`instructions` is COMPUTED, not stored -> no SwiftData migration,
|
||||||
|
no need to delete the app from the iPad.
|
||||||
|
|
||||||
|
Build: Product > Clean Build Folder (Shift-Cmd-K), then run.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
VERIFY
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
1. Web > Inspections > Scheduled > New Schedule
|
||||||
|
- field reads "Instructions", hint below it
|
||||||
|
- save some text
|
||||||
|
2. Web, as the assigned inspector: Start that schedule
|
||||||
|
- indigo "Instructions for this inspection" panel between header and form
|
||||||
|
3. iPad, after a sync:
|
||||||
|
- Dashboard SCHEDULED card: 1-line preview with an info icon
|
||||||
|
- tap the row: "Instructions" section at the top of the start screen
|
||||||
|
- Start Inspection: collapsible "Instructions" banner above the form,
|
||||||
|
tap the header to collapse/expand
|
||||||
|
4. Draft-resume regression: back out mid-inspection, reopen from the blue
|
||||||
|
"Inspection in progress" banner -> instructions still shown
|
||||||
|
5. Empty case: a schedule with no instructions shows NOTHING extra anywhere
|
||||||
|
(blank and whitespace-only both normalise to nil)
|
||||||
|
6. Submit regression: banner stays readable through the 2.5s success screen
|
||||||
|
and the app does not crash when the schedule row is deleted
|
||||||
@@ -38,6 +38,24 @@ final class LocalScheduledInspection {
|
|||||||
var isOverdue: Bool
|
var isOverdue: Bool
|
||||||
var notes: String?
|
var notes: String?
|
||||||
|
|
||||||
|
/// Manager-authored instructions for this occurrence, normalised.
|
||||||
|
///
|
||||||
|
/// The wire field, the server model attribute and the DB column are all
|
||||||
|
/// still `notes` — only the user-facing wording changed to "Instructions"
|
||||||
|
/// (web form label + both iPad surfaces). Renaming the stored property would
|
||||||
|
/// break `init(from:)`/`update(from:)` against `APIScheduledInspection.notes`
|
||||||
|
/// for no benefit, so the rename lives here.
|
||||||
|
///
|
||||||
|
/// Returns 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 instructions: String? {
|
||||||
|
guard let t = notes?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!t.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
/// Last time this row was refreshed from the server pull.
|
/// Last time this row was refreshed from the server pull.
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
JQC iOS — scheduled inspection fulfilment fix
|
||||||
|
=============================================
|
||||||
|
Repo: jqc_ios_app Branch target: main
|
||||||
|
All paths are relative to the repo root (the folder containing JanitorialQC.xcodeproj).
|
||||||
|
|
||||||
|
DROP-IN (overwrite):
|
||||||
|
JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift
|
||||||
|
JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift
|
||||||
|
JanitorialQC/Views/Dashboard/MyInspectionsView.swift
|
||||||
|
JanitorialQC/Views/Dashboard/DashboardView.swift
|
||||||
|
JanitorialQC/CLAUDE.md
|
||||||
|
|
||||||
|
NO new files. NO files removed. NO Xcode project edits
|
||||||
|
(the target uses PBXFileSystemSynchronizedRootGroup — new/changed
|
||||||
|
files under JanitorialQC/ are picked up automatically).
|
||||||
|
|
||||||
|
NO server changes. NO SwiftData schema change, so no migration
|
||||||
|
and no need to delete the app from the iPad.
|
||||||
|
|
||||||
|
BUILD
|
||||||
|
1. git pull / copy files in
|
||||||
|
2. Xcode → Product → Clean Build Folder (Shift-Cmd-K)
|
||||||
|
3. Build & run on the iPad
|
||||||
|
|
||||||
|
VERIFY
|
||||||
|
A. Scheduled row path
|
||||||
|
Dashboard → SCHEDULED → tap row → Start → complete → Submit
|
||||||
|
→ SCHEDULED card clears immediately
|
||||||
|
→ web: Inspections → Scheduled → one-time schedule shows "Inactive"
|
||||||
|
B. "+" path (this is the case that was broken)
|
||||||
|
Dashboard/My Inspections → "+" → pick the SAME facility + template
|
||||||
|
as a schedule due today or earlier → complete → Submit
|
||||||
|
→ same result as A
|
||||||
|
C. Offline
|
||||||
|
Airplane mode → run A → SCHEDULED card clears at once
|
||||||
|
→ re-enable network → schedule goes Inactive on the web
|
||||||
|
D. Regression
|
||||||
|
Re-inspection (History → Re-inspect) must NOT deactivate any schedule
|
||||||
|
Ad-hoc inspection for a facility whose schedule is due NEXT month
|
||||||
|
must NOT deactivate it
|
||||||
|
|
||||||
|
SERVER-SIDE CHECK
|
||||||
|
sudo journalctl -u janitorial_qc --since "10 min ago" | grep "API INSPECTIONS"
|
||||||
|
expect: API INSPECTIONS | schedule fulfilled | schedule=N | inspection=M | next=deactivated
|
||||||
@@ -266,6 +266,11 @@ struct DashboardStatsView: View {
|
|||||||
) private var draftInspections: [LocalInspection]
|
) private var draftInspections: [LocalInspection]
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
|
/// Schedule the inspector tapped in ScheduledInspectionsCard. Held here, not
|
||||||
|
/// in the card: the card self-hides, and submitting the last scheduled
|
||||||
|
/// inspection empties its @Query while the start form is still presented.
|
||||||
|
@State private var scheduledStartTarget: ScheduledStartTarget? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
@@ -279,7 +284,9 @@ struct DashboardStatsView: View {
|
|||||||
// 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 +
|
||||||
// template preselected).
|
// template preselected).
|
||||||
ScheduledInspectionsCard()
|
ScheduledInspectionsCard(onStart: { target in
|
||||||
|
scheduledStartTarget = target
|
||||||
|
})
|
||||||
|
|
||||||
if let stats = sync.dashboardStats {
|
if let stats = sync.dashboardStats {
|
||||||
// ── Today ──────────────────────────────────────────────
|
// ── Today ──────────────────────────────────────────────
|
||||||
@@ -407,6 +414,19 @@ struct DashboardStatsView: View {
|
|||||||
.refreshable {
|
.refreshable {
|
||||||
await sync.fetchDashboardStats()
|
await sync.fetchDashboardStats()
|
||||||
}
|
}
|
||||||
|
// Start cover for a tapped scheduled inspection. Owned here rather than
|
||||||
|
// by ScheduledInspectionsCard because that card self-hides the instant
|
||||||
|
// its last row is removed — which is exactly when this cover is on
|
||||||
|
// screen (submit deletes the cached schedule row). The ScrollView is
|
||||||
|
// always present, so the form is never torn down mid-submit.
|
||||||
|
.fullScreenCover(item: $scheduledStartTarget) { t in
|
||||||
|
StartInspectionView(
|
||||||
|
preFillTemplateId: t.templateServerId,
|
||||||
|
preFillFacilityId: t.facilityServerId,
|
||||||
|
preFillScheduleId: t.id,
|
||||||
|
preFillScheduleInstructions: t.instructions
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ struct ExecuteInspectionView: View {
|
|||||||
@State private var isSubmitting = false
|
@State private var isSubmitting = false
|
||||||
@State private var submitResult: SubmitResult?
|
@State private var submitResult: SubmitResult?
|
||||||
|
|
||||||
|
// ── Schedule instructions ─────────────────────────────────────────────
|
||||||
|
// Snapshotted into @State in onAppear rather than read from SwiftData on
|
||||||
|
// every body pass: resolveAndFulfillSchedule() DELETES the cached
|
||||||
|
// LocalScheduledInspection row at submit time, while this view is still on
|
||||||
|
// screen showing the success banner. Reading a deleted PersistentModel traps.
|
||||||
|
@State private var scheduleInstructions: String? = nil
|
||||||
|
@State private var instructionsExpanded = true
|
||||||
|
|
||||||
// Location manager — created on view init. requestLocation() is called in
|
// Location manager — created on view init. requestLocation() is called in
|
||||||
// onAppear so the permission prompt (and GPS fix acquisition) starts as
|
// onAppear so the permission prompt (and GPS fix acquisition) starts as
|
||||||
// soon as the inspector opens the inspection, maximising the chance of
|
// soon as the inspector opens the inspection, maximising the chance of
|
||||||
@@ -158,6 +166,7 @@ struct ExecuteInspectionView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
formValues = inspection.formData.compactMapValues { "\($0)" }
|
formValues = inspection.formData.compactMapValues { "\($0)" }
|
||||||
|
loadScheduleInstructions()
|
||||||
// Request Location permission (and start acquiring a fix) the moment
|
// Request Location permission (and start acquiring a fix) the moment
|
||||||
// the inspector opens the inspection — gives GPS the entire duration
|
// the inspector opens the inspection — gives GPS the entire duration
|
||||||
// of the inspection to get a fix, rather than only the few seconds
|
// of the inspection to get a fix, rather than only the few seconds
|
||||||
@@ -260,6 +269,15 @@ struct ExecuteInspectionView: View {
|
|||||||
headerCard
|
headerCard
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
|
// Instructions from the schedule this inspection fulfils. Sits directly
|
||||||
|
// above the form so the inspector can re-read it mid-inspection without
|
||||||
|
// leaving the screen. Collapsible because long instructions would
|
||||||
|
// otherwise push the first form field below the fold.
|
||||||
|
if let instructions = scheduleInstructions {
|
||||||
|
instructionsBanner(instructions)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
|
|
||||||
// CHANGED: grid-based form rendering (replaces card-grouped linear list)
|
// CHANGED: grid-based form rendering (replaces card-grouped linear list)
|
||||||
if formSchema.isEmpty {
|
if formSchema.isEmpty {
|
||||||
emptyFormPlaceholder
|
emptyFormPlaceholder
|
||||||
@@ -297,6 +315,60 @@ struct ExecuteInspectionView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Instructions Banner ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Manager-authored instructions for the schedule this inspection fulfils.
|
||||||
|
/// Wire/DB field is `notes`; "Instructions" is the user-facing wording.
|
||||||
|
private func instructionsBanner(_ text: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
instructionsExpanded.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle.fill")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text("Instructions")
|
||||||
|
.font(.callout.bold())
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: instructionsExpanded ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if instructionsExpanded {
|
||||||
|
Text(text)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.blue.opacity(0.10))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy the schedule's instructions into `@State` once, at appear.
|
||||||
|
///
|
||||||
|
/// Deliberately a snapshot, not a computed lookup: the cached
|
||||||
|
/// `LocalScheduledInspection` is deleted by `resolveAndFulfillSchedule()`
|
||||||
|
/// the instant Submit is tapped, and this view stays on screen for another
|
||||||
|
/// 2.5 s afterwards. A computed property would re-read a deleted
|
||||||
|
/// `PersistentModel` during that window and trap.
|
||||||
|
private func loadScheduleInstructions() {
|
||||||
|
guard let schedId = inspection.scheduledInspectionServerId else { return }
|
||||||
|
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||||
|
let all = (try? context.fetch(FetchDescriptor<LocalScheduledInspection>())) ?? []
|
||||||
|
scheduleInstructions = all.first(where: { $0.serverId == schedId })?.instructions
|
||||||
|
}
|
||||||
|
|
||||||
// ── Header Card ───────────────────────────────────────────────────────
|
// ── Header Card ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private var headerCard: some View {
|
private var headerCard: some View {
|
||||||
@@ -576,6 +648,15 @@ struct ExecuteInspectionView: View {
|
|||||||
// regardless of connectivity or sync timing.
|
// regardless of connectivity or sync timing.
|
||||||
clearParentFollowUpFlag()
|
clearParentFollowUpFlag()
|
||||||
|
|
||||||
|
// ── Fulfil the originating scheduled inspection ───────────────────
|
||||||
|
// Two jobs, both mirroring the web app's execute route:
|
||||||
|
// 1. Make sure the submission carries scheduled_inspection_id, even
|
||||||
|
// when the inspector reached this form via "+" instead of the
|
||||||
|
// Scheduled row — the server cannot fulfil an unlinked inspection.
|
||||||
|
// 2. Drop the cached schedule row so the SCHEDULED card clears the
|
||||||
|
// moment Submit is tapped, online or offline.
|
||||||
|
resolveAndFulfillSchedule()
|
||||||
|
|
||||||
try? context.save()
|
try? context.save()
|
||||||
|
|
||||||
isSubmitting = false
|
isSubmitting = false
|
||||||
@@ -638,6 +719,84 @@ struct ExecuteInspectionView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Scheduled inspection fulfilment ───────────────────────────────────
|
||||||
|
|
||||||
|
/// Link this submission to the schedule it satisfies, then drop the cached
|
||||||
|
/// schedule row.
|
||||||
|
///
|
||||||
|
/// **Why the fallback link exists.** On the web, `scheduled_inspections.start`
|
||||||
|
/// is the *only* way to open a scheduled inspection, so the link is always
|
||||||
|
/// present. On the iPad the Scheduled row merely pre-selects facility +
|
||||||
|
/// template — the inspector can reach the identical form through the "+"
|
||||||
|
/// button, and that path leaves `scheduledInspectionServerId` nil. The
|
||||||
|
/// server then stores `scheduled_inspection_id = NULL`, never calls
|
||||||
|
/// `_fulfill_schedule()`, and the schedule stays **Active** on the web.
|
||||||
|
/// Matching facility + template here restores parity of outcome between the
|
||||||
|
/// two entry points.
|
||||||
|
///
|
||||||
|
/// **Why the match is narrow.** Only schedules already due (due date on or
|
||||||
|
/// before today) are eligible, so an ad-hoc inspection today cannot silently
|
||||||
|
/// close out an occurrence planned for next month. Assignment must also fit:
|
||||||
|
/// unassigned schedules, or ones assigned to this inspector. When several
|
||||||
|
/// qualify, the earliest due date wins — that is the occurrence being worked.
|
||||||
|
///
|
||||||
|
/// **Why the row is deleted rather than rolled forward.** `LocalScheduledInspection`
|
||||||
|
/// is a read-only cache and does not carry the phase43 recurrence detail
|
||||||
|
/// (weekdays / month_mode / day_of_month / nth_week / nth_weekday), so the
|
||||||
|
/// next due date cannot be computed correctly on device. Deleting invalidates
|
||||||
|
/// the cache instead: `pullScheduledInspections()` re-inserts recurring
|
||||||
|
/// schedules with the server-authoritative `next_due_date` on the next pull,
|
||||||
|
/// and a one-time schedule stays gone because the server has deactivated it.
|
||||||
|
/// The same pull also restores the row if the submission never lands, so a
|
||||||
|
/// failed sync self-heals.
|
||||||
|
private func resolveAndFulfillSchedule() {
|
||||||
|
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||||
|
let all = (try? context.fetch(FetchDescriptor<LocalScheduledInspection>())) ?? []
|
||||||
|
guard !all.isEmpty else { return }
|
||||||
|
|
||||||
|
// Fallback link for inspections not started from a Scheduled row.
|
||||||
|
// Re-inspections are excluded: a follow-up shares its parent's facility
|
||||||
|
// and template, so it would otherwise close out an unrelated planned
|
||||||
|
// occurrence. The web app keeps the two workflows separate the same way.
|
||||||
|
let isReInspection = inspection.parentServerId != nil || inspection.parentLocalId != nil
|
||||||
|
if inspection.scheduledInspectionServerId == nil && !isReInspection {
|
||||||
|
let tid = inspection.templateServerId
|
||||||
|
let fid = inspection.facilityServerId
|
||||||
|
let uid = inspection.inspectorUserId
|
||||||
|
let today = Self.dueDateFormatter.string(from: Date())
|
||||||
|
|
||||||
|
let candidate = all
|
||||||
|
.filter {
|
||||||
|
$0.templateServerId == tid &&
|
||||||
|
$0.facilityServerId == fid &&
|
||||||
|
($0.inspectorId == nil || $0.inspectorId == uid) &&
|
||||||
|
!$0.dueDateString.isEmpty &&
|
||||||
|
$0.dueDateString <= today // ISO strings sort chronologically
|
||||||
|
}
|
||||||
|
.sorted { $0.dueDateString < $1.dueDateString }
|
||||||
|
.first
|
||||||
|
|
||||||
|
if let candidate {
|
||||||
|
inspection.scheduledInspectionServerId = candidate.serverId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the cached row for whichever schedule this submission fulfils.
|
||||||
|
guard let schedId = inspection.scheduledInspectionServerId,
|
||||||
|
let sched = all.first(where: { $0.serverId == schedId })
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
context.delete(sched)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "yyyy-MM-dd", matching `LocalScheduledInspection.dueDateString`.
|
||||||
|
private static let dueDateFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
// ── Photo Handling ────────────────────────────────────────────────────
|
// ── Photo Handling ────────────────────────────────────────────────────
|
||||||
|
|
||||||
private func handlePhotoSelected(localPath: String, field: [String: Any]) {
|
private func handlePhotoSelected(localPath: String, field: [String: Any]) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ struct MyInspectionsView: View {
|
|||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
@State private var showNewInspection = false
|
@State private var showNewInspection = false
|
||||||
@State private var scheduledStartTarget: LocalScheduledInspection?
|
@State private var scheduledStartTarget: ScheduledStartTarget?
|
||||||
|
|
||||||
// Deletion confirmation state
|
// Deletion confirmation state
|
||||||
@State private var pendingDelete: LocalInspection?
|
@State private var pendingDelete: LocalInspection?
|
||||||
@@ -42,7 +42,7 @@ struct MyInspectionsView: View {
|
|||||||
if !scheduledAll.isEmpty {
|
if !scheduledAll.isEmpty {
|
||||||
Section("Scheduled") {
|
Section("Scheduled") {
|
||||||
ForEach(scheduledAll) { s in
|
ForEach(scheduledAll) { s in
|
||||||
Button { scheduledStartTarget = s } label: {
|
Button { scheduledStartTarget = ScheduledStartTarget(s) } label: {
|
||||||
ScheduledRow(schedule: s)
|
ScheduledRow(schedule: s)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -71,16 +71,21 @@ struct MyInspectionsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cover attached to the stable List, not a Section.
|
}
|
||||||
.fullScreenCover(item: $scheduledStartTarget) { s in
|
}
|
||||||
|
// Cover attached to the enclosing Group, not the List and never a
|
||||||
|
// Section (rule 64). The List itself is conditional: submitting the last
|
||||||
|
// scheduled inspection can flip this view to ContentUnavailableView while
|
||||||
|
// the cover is still presented, which would tear the form down mid-submit.
|
||||||
|
// The Group is always present.
|
||||||
|
.fullScreenCover(item: $scheduledStartTarget) { t in
|
||||||
StartInspectionView(
|
StartInspectionView(
|
||||||
preFillTemplateId: s.templateServerId,
|
preFillTemplateId: t.templateServerId,
|
||||||
preFillFacilityId: s.facilityServerId,
|
preFillFacilityId: t.facilityServerId,
|
||||||
preFillScheduleId: s.serverId
|
preFillScheduleId: t.id,
|
||||||
|
preFillScheduleInstructions: t.instructions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
.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
|
||||||
|
|||||||
@@ -5,17 +5,54 @@
|
|||||||
// SyncManager.pullScheduledInspections().
|
// SyncManager.pullScheduledInspections().
|
||||||
//
|
//
|
||||||
// Two consumers share one ScheduledRow:
|
// Two consumers share one ScheduledRow:
|
||||||
// • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView
|
// • ScheduledInspectionsCard — 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 "Scheduled" List section inline,
|
// • MyInspectionsView renders its own "Scheduled" List section inline,
|
||||||
// reusing ScheduledRow, with the start cover attached to the List.
|
// reusing ScheduledRow, with the start cover on the enclosing Group.
|
||||||
// Both self-hide when there are no scheduled inspections and present
|
// Both self-hide when there are no scheduled inspections and present
|
||||||
// StartInspectionView (facility + template preselected) when a row is tapped.
|
// StartInspectionView (facility + template preselected) when a row is tapped.
|
||||||
// The schedule lifecycle (fulfil / roll-forward) stays server-driven; tapping
|
// Both cover owners are views that outlive the schedule rows themselves —
|
||||||
// "Start" simply seeds the normal new-inspection flow.
|
// submitting the last scheduled inspection 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 schedule lifecycle stays server-driven: the submission carries
|
||||||
|
// `scheduled_inspection_id` and the server deactivates (one-time) or rolls
|
||||||
|
// forward (recurring). ExecuteInspectionView only invalidates the local cache
|
||||||
|
// row; pullScheduledInspections() re-reads the authoritative state.
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
// MARK: - Start target snapshot
|
||||||
|
|
||||||
|
/// Plain-value snapshot of the tapped schedule, used as the `.fullScreenCover`
|
||||||
|
/// item instead of the `LocalScheduledInspection` itself.
|
||||||
|
///
|
||||||
|
/// The model object is unsafe to hold across the presentation: the cached row is
|
||||||
|
/// deleted while the cover is still on screen — by `resolveAndFulfillSchedule()`
|
||||||
|
/// the instant Submit is tapped, and by `pullScheduledInspections()` 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.
|
||||||
|
struct ScheduledStartTarget: Identifiable {
|
||||||
|
/// Schedule `serverId` — also the identity for `.fullScreenCover(item:)`.
|
||||||
|
let id: Int
|
||||||
|
let templateServerId: Int
|
||||||
|
let facilityServerId: Int
|
||||||
|
/// Manager-authored instructions for this occurrence. Server field is still
|
||||||
|
/// `notes` (API key `notes`, column `scheduled_inspections.notes`); only the
|
||||||
|
/// user-facing wording is "Instructions".
|
||||||
|
let instructions: String?
|
||||||
|
|
||||||
|
init(_ schedule: LocalScheduledInspection) {
|
||||||
|
self.id = schedule.serverId
|
||||||
|
self.templateServerId = schedule.templateServerId
|
||||||
|
self.facilityServerId = schedule.facilityServerId
|
||||||
|
self.instructions = schedule.instructions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Shared row
|
// MARK: - Shared row
|
||||||
|
|
||||||
struct ScheduledRow: View {
|
struct ScheduledRow: View {
|
||||||
@@ -60,6 +97,24 @@ struct ScheduledRow: View {
|
|||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Instructions 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 and
|
||||||
|
// again above the form itself.
|
||||||
|
if let instructions = schedule.instructions {
|
||||||
|
HStack(alignment: .top, spacing: 4) {
|
||||||
|
Image(systemName: "info.circle.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(instructions)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
@@ -81,7 +136,14 @@ struct ScheduledInspectionsCard: View {
|
|||||||
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
|
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
|
||||||
private var scheduled: [LocalScheduledInspection]
|
private var scheduled: [LocalScheduledInspection]
|
||||||
|
|
||||||
@State private var startTarget: LocalScheduledInspection? = nil
|
/// Tap handler. The `.fullScreenCover` deliberately lives in the PARENT
|
||||||
|
/// (`DashboardStatsView`, on its always-present ScrollView) rather than here:
|
||||||
|
/// this card self-hides, and submitting the last scheduled inspection removes
|
||||||
|
/// the final row while the cover is still on screen. A cover owned by a view
|
||||||
|
/// that disappears is torn down with it, yanking the form away from the
|
||||||
|
/// inspector mid-submit. Keeping the card purely presentational also keeps
|
||||||
|
/// the empty case a true `EmptyView`, so the dashboard stack adds no spacing.
|
||||||
|
let onStart: (ScheduledStartTarget) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if !scheduled.isEmpty {
|
if !scheduled.isEmpty {
|
||||||
@@ -92,7 +154,7 @@ struct ScheduledInspectionsCard: View {
|
|||||||
.tracking(1)
|
.tracking(1)
|
||||||
|
|
||||||
ForEach(scheduled) { s in
|
ForEach(scheduled) { s in
|
||||||
Button { startTarget = s } label: {
|
Button { onStart(ScheduledStartTarget(s)) } label: {
|
||||||
ScheduledRow(schedule: s)
|
ScheduledRow(schedule: s)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
||||||
@@ -101,14 +163,6 @@ struct ScheduledInspectionsCard: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cover attached to the stable VStack root (mirrors DraftResumeBanner).
|
|
||||||
.fullScreenCover(item: $startTarget) { s in
|
|
||||||
StartInspectionView(
|
|
||||||
preFillTemplateId: s.templateServerId,
|
|
||||||
preFillFacilityId: s.facilityServerId,
|
|
||||||
preFillScheduleId: s.serverId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ struct StartInspectionView: View {
|
|||||||
/// the schedule and the "Scheduled" banner never clears.
|
/// the schedule and the "Scheduled" banner never clears.
|
||||||
var preFillScheduleId: Int? = nil
|
var preFillScheduleId: Int? = nil
|
||||||
|
|
||||||
|
/// Instructions the manager attached to this schedule ("Instructions" in the
|
||||||
|
/// UI; still `notes` on the wire and in the DB). Passed as a plain String
|
||||||
|
/// rather than read from SwiftData here, for the same reason
|
||||||
|
/// ScheduledStartTarget exists — the cached schedule row is deleted at
|
||||||
|
/// submit while this flow is still on screen.
|
||||||
|
var preFillScheduleInstructions: String? = nil
|
||||||
|
|
||||||
// ── Derived lists ─────────────────────────────────────────────────────
|
// ── Derived lists ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Unique contracts (projectId, projectName) sorted by name.
|
/// Unique contracts (projectId, projectName) sorted by name.
|
||||||
@@ -85,6 +92,25 @@ struct StartInspectionView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
// ── Instructions from the schedule ─────────────────────────
|
||||||
|
// Shown first: this is the reason the inspector was sent here,
|
||||||
|
// and it may change what they carry in with them. Repeated
|
||||||
|
// above the form itself in ExecuteInspectionView.
|
||||||
|
if let instructions = preFillScheduleInstructions,
|
||||||
|
!instructions.isEmpty {
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Label("Instructions", systemImage: "info.circle.fill")
|
||||||
|
.font(.callout.bold())
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(instructions)
|
||||||
|
.font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Re-inspection notice ───────────────────────────────────
|
// ── Re-inspection notice ───────────────────────────────────
|
||||||
if parentServerId != nil {
|
if parentServerId != nil {
|
||||||
Section {
|
Section {
|
||||||
|
|||||||
Reference in New Issue
Block a user