169 lines
7.3 KiB
Swift
169 lines
7.3 KiB
Swift
// Views/Dashboard/ScheduledInspectionsView.swift
|
|
// ----------------------------------------------
|
|
// Displays the inspector's planned/recurring inspection assignments (phase36),
|
|
// pulled read-only from GET /api/v1/scheduled-inspections by
|
|
// SyncManager.pullScheduledInspections().
|
|
//
|
|
// Two consumers share one ScheduledRow:
|
|
// • ScheduledInspectionsCard — 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 "Scheduled" List section inline,
|
|
// reusing ScheduledRow, with the start cover on the enclosing Group.
|
|
// Both self-hide when there are no scheduled inspections and present
|
|
// StartInspectionView (facility + template preselected) when a row is tapped.
|
|
// Both cover owners are views that outlive the schedule rows themselves —
|
|
// submitting the last scheduled inspection 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 schedule lifecycle stays server-driven: the submission carries
|
|
// `scheduled_inspection_id` and the server deactivates (one-time) or rolls
|
|
// forward (recurring). ExecuteInspectionView only invalidates the local cache
|
|
// row; pullScheduledInspections() re-reads the authoritative state.
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
// MARK: - Start target snapshot
|
|
|
|
/// Plain-value snapshot of the tapped schedule, used as the `.fullScreenCover`
|
|
/// item instead of the `LocalScheduledInspection` itself.
|
|
///
|
|
/// The model object is unsafe to hold across the presentation: the cached row is
|
|
/// deleted while the cover is still on screen — by `resolveAndFulfillSchedule()`
|
|
/// the instant Submit is tapped, and by `pullScheduledInspections()` 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.
|
|
struct ScheduledStartTarget: Identifiable {
|
|
/// Schedule `serverId` — also the identity for `.fullScreenCover(item:)`.
|
|
let id: Int
|
|
let templateServerId: Int
|
|
let facilityServerId: Int
|
|
/// Manager-authored instructions for this occurrence. Server field is still
|
|
/// `notes` (API key `notes`, column `scheduled_inspections.notes`); only the
|
|
/// user-facing wording is "Instructions".
|
|
let instructions: String?
|
|
|
|
init(_ schedule: LocalScheduledInspection) {
|
|
self.id = schedule.serverId
|
|
self.templateServerId = schedule.templateServerId
|
|
self.facilityServerId = schedule.facilityServerId
|
|
self.instructions = schedule.instructions
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared row
|
|
|
|
struct ScheduledRow: View {
|
|
let schedule: LocalScheduledInspection
|
|
|
|
private var dueText: String {
|
|
if let d = schedule.nextDue {
|
|
return d.formatted(date: .abbreviated, time: .omitted)
|
|
}
|
|
return schedule.dueDateString.isEmpty ? "—" : schedule.dueDateString
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Image(systemName: "calendar.badge.clock")
|
|
.font(.title3)
|
|
.foregroundStyle(schedule.isOverdue ? .red : .blue)
|
|
.padding(.top, 2)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(schedule.templateName.isEmpty ? "Inspection" : schedule.templateName)
|
|
.font(.callout.bold())
|
|
Text(schedule.facilityName.isEmpty ? "Facility" : schedule.facilityName)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 8) {
|
|
if schedule.isOverdue {
|
|
Text("Overdue")
|
|
.font(.caption2.bold())
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(Color.red.opacity(0.15))
|
|
.foregroundStyle(.red)
|
|
.clipShape(Capsule())
|
|
}
|
|
Text("Due \(dueText)")
|
|
.font(.caption2)
|
|
.foregroundStyle(schedule.isOverdue ? .red : .secondary)
|
|
if !schedule.frequencyLabel.isEmpty {
|
|
Text("· \(schedule.frequencyLabel)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
|
|
// Instructions 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 and
|
|
// again above the form itself.
|
|
if let instructions = schedule.instructions {
|
|
HStack(alignment: .top, spacing: 4) {
|
|
Image(systemName: "info.circle.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.blue)
|
|
Text(instructions)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
.padding(.top, 1)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
Label("Start", systemImage: "play.fill")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
|
.background(schedule.isOverdue ? Color.red : Color.blue)
|
|
.clipShape(Capsule())
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
// MARK: - Dashboard card (VStack)
|
|
|
|
struct ScheduledInspectionsCard: View {
|
|
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
|
|
private var scheduled: [LocalScheduledInspection]
|
|
|
|
/// Tap handler. The `.fullScreenCover` deliberately lives in the PARENT
|
|
/// (`DashboardStatsView`, on its always-present ScrollView) rather than here:
|
|
/// this card self-hides, and submitting the last scheduled inspection removes
|
|
/// the final row while the cover is still on screen. A cover owned by a view
|
|
/// that disappears is torn down with it, yanking the form away from the
|
|
/// inspector mid-submit. Keeping the card purely presentational also keeps
|
|
/// the empty case a true `EmptyView`, so the dashboard stack adds no spacing.
|
|
let onStart: (ScheduledStartTarget) -> Void
|
|
|
|
var body: some View {
|
|
if !scheduled.isEmpty {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("SCHEDULED")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.secondary)
|
|
.tracking(1)
|
|
|
|
ForEach(scheduled) { s in
|
|
Button { onStart(ScheduledStartTarget(s)) } label: {
|
|
ScheduledRow(schedule: s)
|
|
.padding(12)
|
|
.background(Color(.secondarySystemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|