Jul 30 - Update iPad - inspector can re-inspect and create follow-up
This commit is contained in:
@@ -42,6 +42,19 @@ struct DashboardView: View {
|
||||
order: .reverse
|
||||
) 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
|
||||
/// Each sidebar tap refreshes the UUID for that tab, forcing its
|
||||
/// NavigationStack to be destroyed and recreated — even when the tab
|
||||
@@ -181,8 +194,8 @@ struct DashboardView: View {
|
||||
Label("My Inspections", systemImage: "checklist")
|
||||
.foregroundStyle(tint)
|
||||
Spacer()
|
||||
if !myInspections.isEmpty {
|
||||
Text("\(myInspections.count)")
|
||||
if myInspectionsBadgeCount > 0 {
|
||||
Text("\(myInspectionsBadgeCount)")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.15))
|
||||
@@ -332,6 +345,10 @@ struct DashboardStatsView: View {
|
||||
/// inspection empties its @Query while the start form is still presented.
|
||||
@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 {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
@@ -341,6 +358,15 @@ struct DashboardStatsView: View {
|
||||
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) ────────────────────────
|
||||
// Planned/recurring assignments for this inspector. Self-hides
|
||||
// when there are none. Tap a row to start it (facility +
|
||||
@@ -484,10 +510,34 @@ struct DashboardStatsView: View {
|
||||
StartInspectionView(
|
||||
preFillTemplateId: t.templateServerId,
|
||||
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,
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -648,6 +648,13 @@ struct ExecuteInspectionView: View {
|
||||
// regardless of connectivity or sync timing.
|
||||
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 ───────────────────
|
||||
// Two jobs, both mirroring the web app's execute route:
|
||||
// 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 ───────────────────────────────────
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@State private var showNewInspection = false
|
||||
@State private var scheduledStartTarget: ScheduledStartTarget?
|
||||
@State private var followUpStartTarget: FollowUpStartTarget?
|
||||
|
||||
// Deletion confirmation state
|
||||
@State private var pendingDelete: LocalInspection?
|
||||
@@ -35,7 +48,7 @@ struct MyInspectionsView: View {
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if inspections.isEmpty && scheduledVisible.isEmpty {
|
||||
if inspections.isEmpty && scheduledVisible.isEmpty && followUpsVisible.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Inspections",
|
||||
systemImage: "checklist",
|
||||
@@ -43,6 +56,20 @@ struct MyInspectionsView: View {
|
||||
)
|
||||
} else {
|
||||
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.
|
||||
if !scheduledVisible.isEmpty {
|
||||
Section("Scheduled") {
|
||||
@@ -87,10 +114,28 @@ struct MyInspectionsView: View {
|
||||
StartInspectionView(
|
||||
preFillTemplateId: t.templateServerId,
|
||||
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,
|
||||
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")
|
||||
// Confirmation before deletion — destructive action cannot be undone
|
||||
.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
|
||||
/// user-facing wording is "Instructions".
|
||||
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) {
|
||||
self.id = schedule.serverId
|
||||
self.templateServerId = schedule.templateServerId
|
||||
self.facilityServerId = schedule.facilityServerId
|
||||
self.instructions = schedule.instructions
|
||||
self.parentServerId = schedule.parentInspectionServerId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,27 @@ struct StartInspectionView: View {
|
||||
var parentServerId: Int? = 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 ───────────────────────────────────────
|
||||
/// Server ID of the ScheduledInspection this run fulfils, passed when the
|
||||
/// 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!).")
|
||||
.font(.caption)
|
||||
.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)
|
||||
@@ -307,17 +338,30 @@ struct StartInspectionView: View {
|
||||
// inspector doesn't re-enter static data. Scoring fields (rating,
|
||||
// pass_fail) and media fields (image, signature) are always left blank
|
||||
// so every scoreable item must be re-evaluated fresh.
|
||||
if let parentId = parentServerId {
|
||||
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||
if let parent = allInspections.first(where: { $0.serverId == parentId }),
|
||||
!parent.formData.isEmpty {
|
||||
if parentServerId != nil {
|
||||
// Prefer the local parent (the CompletedInspectionView path, where
|
||||
// the inspector just finished it on this iPad); otherwise fall back
|
||||
// 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.
|
||||
// Split into two statements — avoids Xcode 26 #Predicate
|
||||
// ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
|
||||
let allTemplates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||||
let tid = templateId
|
||||
let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? []
|
||||
// Fetch the template schema to identify field types.
|
||||
// Split into two statements — avoids Xcode 26 #Predicate
|
||||
// ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
|
||||
let allTemplates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||||
let tid = templateId
|
||||
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
|
||||
let excludeTypes: Set<String> = ["rating", "pass_fail", "image", "signature"]
|
||||
@@ -330,7 +374,6 @@ struct StartInspectionView: View {
|
||||
}
|
||||
|
||||
// Copy all parent values except excluded fields
|
||||
let parentData = parent.formData
|
||||
var prefilled: [String: Any] = [:]
|
||||
for (key, value) in parentData {
|
||||
if !excludeIds.contains(key) {
|
||||
@@ -349,4 +392,34 @@ struct StartInspectionView: View {
|
||||
createdInspection = inspection
|
||||
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
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
@EnvironmentObject private var auth: AuthManager
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@State private var showReInspect = false
|
||||
@State private var showMailCompose = false
|
||||
@State private var isGeneratingPDF = false
|
||||
@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.
|
||||
// Form data and schema come from the server response directly so
|
||||
// History works even after app reinstall or on a different device.
|
||||
@@ -463,6 +484,34 @@ struct HistoryDetailView: View {
|
||||
.navigationTitle(inspection.templateName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.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) {
|
||||
Button {
|
||||
Task { await prepareAndShowMail() }
|
||||
@@ -476,16 +525,34 @@ struct HistoryDetailView: View {
|
||||
.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 {
|
||||
loadLocalData()
|
||||
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(
|
||||
preFillTemplateId: inspection.templateId,
|
||||
preFillFacilityId: inspection.facilityId,
|
||||
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) {
|
||||
@@ -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),
|
||||
/// then presents the mail compose sheet with it attached.
|
||||
/// Photo fetches happen here, off the synchronous PDF drawing pass.
|
||||
|
||||
Reference in New Issue
Block a user