// 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) } } } } }