Jul 30 - Update iPad - inspector can re-inspect and create follow-up
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user