diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 5cf9f37..da1ebbe 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -238,6 +238,11 @@ actor APIClient { if let score = inspection.overallScore { body["overall_score"] = score } if let areaId = inspection.areaServerId { body["area_id"] = areaId } if let parentId = inspection.parentServerId { body["parent_inspection_id"] = parentId } + // Links the submission back to the schedule it was started from so the + // server fulfils it (clears the banner) and badges it as "Scheduled". + if let schedId = inspection.scheduledInspectionServerId { + body["scheduled_inspection_id"] = schedId + } if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes } if let lat = inspection.submitLatitude { body["submit_latitude"] = lat } if let lng = inspection.submitLongitude { body["submit_longitude"] = lng } diff --git a/JanitorialQC/CLAUDE.md b/JanitorialQC/CLAUDE.md index cabdf36..c74b09c 100644 --- a/JanitorialQC/CLAUDE.md +++ b/JanitorialQC/CLAUDE.md @@ -193,7 +193,7 @@ PendingPhoto.self, SyncQueueEntry.self | `LocalFacility` | Read-only cached facility reference | `serverId` (`@Attribute(.unique)`), `name`, `projectId`, `projectName`, `areas` (cascade) | | `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` | | `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`, `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) | | `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` | | `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` | @@ -381,7 +381,13 @@ Contract → Facility cascade pickers (same as `StandaloneIssueView`). `onChange ### Scheduled inspections (phase36, July 2026) -`ScheduledInspectionsView.swift` holds `ScheduledRow` + `ScheduledInspectionsCard`. The card renders on the Dashboard (`DashboardStatsView`); `MyInspectionsView` renders its own inline "Scheduled" `List` section reusing `ScheduledRow`. Both query `LocalScheduledInspection` (sorted by `dueDateString`), self-hide when empty, and tap-to-Start opens `StartInspectionView(preFillTemplateId:preFillFacilityId:)`. Data is pulled read-only by `pullScheduledInspections()` (see rules 63–64 for the model + cover pitfalls hit while building it). +`ScheduledInspectionsView.swift` holds `ScheduledRow` + `ScheduledInspectionsCard`. The card renders on the Dashboard (`DashboardStatsView`); `MyInspectionsView` renders its own inline "Scheduled" `List` section reusing `ScheduledRow`. Both query `LocalScheduledInspection` (sorted by `dueDateString`), self-hide when empty, and tap-to-Start opens `StartInspectionView(preFillTemplateId:preFillFacilityId:preFillScheduleId:)`. Data is pulled read-only by `pullScheduledInspections()` (see rules 63–64 for the model + cover pitfalls hit while building it). + +**Fulfilling the schedule (July 2026 fix).** `preFillScheduleId` is the schedule's `serverId`; `startInspection()` copies it onto `LocalInspection.scheduledInspectionServerId`, and `submitInspection()` sends it as **`scheduled_inspection_id`**. The server then fulfils the schedule (one-time → deactivated, recurring → rolled forward) in the same commit as the inspection. + +Without it — the original bug — the schedule was never fulfilled: the banner stayed on the inspector's Dashboard and My Inspections, it stayed on the web dashboard for admin/director, and the web inspection list showed no "Scheduled" badge. **Both** Start call sites must pass `preFillScheduleId` (`ScheduledInspectionsCard` and the `MyInspectionsView` inline section); re-inspection launches correctly leave it nil. + +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. ### Inspection-start presentation (July 2026) diff --git a/JanitorialQC/Models/LocalInspection.swift b/JanitorialQC/Models/LocalInspection.swift index 0e06fb3..c27f0cd 100644 --- a/JanitorialQC/Models/LocalInspection.swift +++ b/JanitorialQC/Models/LocalInspection.swift @@ -51,6 +51,16 @@ final class LocalInspection { /// followUpRequired badge without relying on parentServerId being non-nil. var parentLocalId: String? + // ── Scheduled inspection link ────────────────────────────────────────── + /// Server ID of the ScheduledInspection this inspection was started from, + /// set when the inspector taps Start on a scheduled row. Sent as + /// `scheduled_inspection_id` on submit so the server can fulfil the + /// schedule (deactivate a one-time / roll a recurring one forward) and + /// flag the inspection as "Scheduled" in the web list. Nil for ad-hoc work. + /// + /// Declared with an inline default so existing stores migrate lightweight. + var scheduledInspectionServerId: Int? = nil + // ── GPS (captured at submit time via CoreLocation) ───────────────────── /// Device latitude at the moment the inspector tapped Submit. Nil if /// location permission was denied or a fix could not be obtained in time. @@ -89,6 +99,7 @@ final class LocalInspection { self.followUpNote = nil self.parentServerId = nil self.parentLocalId = nil + self.scheduledInspectionServerId = nil self.submitLatitude = nil self.submitLongitude = nil self.pendingPhotos = [] diff --git a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift index 0431ee0..394ab82 100644 --- a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift @@ -75,7 +75,8 @@ struct MyInspectionsView: View { .fullScreenCover(item: $scheduledStartTarget) { s in StartInspectionView( preFillTemplateId: s.templateServerId, - preFillFacilityId: s.facilityServerId + preFillFacilityId: s.facilityServerId, + preFillScheduleId: s.serverId ) } } diff --git a/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift b/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift index 7cd6f71..df684fa 100644 --- a/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift @@ -105,7 +105,8 @@ struct ScheduledInspectionsCard: View { .fullScreenCover(item: $startTarget) { s in StartInspectionView( preFillTemplateId: s.templateServerId, - preFillFacilityId: s.facilityServerId + preFillFacilityId: s.facilityServerId, + preFillScheduleId: s.serverId ) } } diff --git a/JanitorialQC/Views/Dashboard/StartInspectionView.swift b/JanitorialQC/Views/Dashboard/StartInspectionView.swift index 3e4c904..10fd835 100644 --- a/JanitorialQC/Views/Dashboard/StartInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/StartInspectionView.swift @@ -37,6 +37,13 @@ struct StartInspectionView: View { var parentServerId: Int? = nil var parentLocalId: String? = nil + // ── Scheduled inspection launch ─────────────────────────────────────── + /// Server ID of the ScheduledInspection this run fulfils, passed when the + /// inspector taps Start on a scheduled row. Carried onto the LocalInspection + /// so submitInspection() can send it; without it the server cannot fulfil + /// the schedule and the "Scheduled" banner never clears. + var preFillScheduleId: Int? = nil + // ── Derived lists ───────────────────────────────────────────────────── /// Unique contracts (projectId, projectName) sorted by name. @@ -266,6 +273,8 @@ struct StartInspectionView: View { // Link to parent if this is a re-inspection inspection.parentServerId = parentServerId inspection.parentLocalId = parentLocalId + // Link to the schedule if launched from a scheduled row + inspection.scheduledInspectionServerId = preFillScheduleId // ── Pre-fill from parent (mirrors web app behaviour) ─────────────── // Copy non-scoring field values from the parent inspection so the