Files
JQC_iOS_App/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift

183 lines
8.0 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?
/// 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
}
}
// 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]
/// 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 schedule leaves the card at
/// once and reappears only when the server says it is due again.
private var visible: [LocalScheduledInspection] {
scheduled.filter { !$0.fulfilledLocally }
}
/// 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 !visible.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("SCHEDULED")
.font(.caption.bold())
.foregroundStyle(.secondary)
.tracking(1)
ForEach(visible) { s in
Button { onStart(ScheduledStartTarget(s)) } label: {
ScheduledRow(schedule: s)
.padding(12)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
}
}
}