205 lines
9.1 KiB
Swift
205 lines
9.1 KiB
Swift
// 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)
|
|
}
|
|
}
|
|
|
|
// Assigned to somebody other than whoever ran the original
|
|
// inspection (phase53). The endpoint only returns follow-ups
|
|
// this user owns, so seeing this badge means "you were given
|
|
// this one" — worth calling out, because it is NOT the usual
|
|
// case of re-inspecting your own work.
|
|
if request.assignedToName != nil {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "person.crop.circle.badge.checkmark")
|
|
.font(.caption2)
|
|
Text("Assigned to you")
|
|
.font(.caption2.weight(.semibold))
|
|
}
|
|
.foregroundStyle(.orange)
|
|
.padding(.top, 1)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|