441 lines
22 KiB
Swift
441 lines
22 KiB
Swift
// Views/Dashboard/StartInspectionView.swift
|
|
// ------------------------------------------
|
|
// Screen where the inspector chooses a template, contract, facility,
|
|
// and optional area before starting a new inspection.
|
|
// Creates the LocalInspection record immediately so the form can be
|
|
// resumed if the app is backgrounded.
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct StartInspectionView: View {
|
|
|
|
@Environment(\.modelContext) private var context
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@Query(sort: \LocalTemplate.name) private var allTemplates: [LocalTemplate]
|
|
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
|
|
|
|
/// Active templates only — inactive templates excluded from the picker.
|
|
/// Filtered in Swift (not #Predicate) per CLAUDE.md rule 3.
|
|
private var templates: [LocalTemplate] {
|
|
allTemplates.filter { $0.isActive }
|
|
}
|
|
|
|
@State private var selectedTemplateId: Int?
|
|
@State private var selectedProjectId: Int?
|
|
@State private var selectedFacilityId: Int?
|
|
@State private var selectedAreaId: Int?
|
|
@State private var navigateToExecution = false
|
|
@State private var createdInspection: LocalInspection?
|
|
|
|
@EnvironmentObject private var auth: AuthManager
|
|
|
|
// ── Pre-fill for re-inspections ───────────────────────────────────────
|
|
var preFillTemplateId: Int? = nil
|
|
var preFillFacilityId: Int? = nil
|
|
var parentServerId: Int? = nil
|
|
var parentLocalId: String? = nil
|
|
|
|
/// The director's note explaining what the follow-up should address, set when
|
|
/// the inspector taps a row in the FOLLOW-UP REQUESTED card. Passed as a plain
|
|
/// String rather than read from SwiftData here, for the same reason
|
|
/// FollowUpStartTarget exists — the cached request row is invalidated at
|
|
/// submit while this flow is still on screen. Nil for a re-inspection the
|
|
/// inspector started themselves from CompletedInspectionView.
|
|
var preFillFollowUpNote: String? = nil
|
|
|
|
/// The flagged inspection's answers, JSON-encoded, used to pre-fill this
|
|
/// re-inspection when the parent `LocalInspection` is not on this device.
|
|
///
|
|
/// The local-parent lookup in `startInspection()` covers the
|
|
/// CompletedInspectionView path, where the inspector is re-inspecting
|
|
/// something they just finished on this iPad. It does **not** cover a
|
|
/// follow-up raised on the web: that parent synced long ago and is often
|
|
/// absent locally, so the lookup found nothing and the form came up blank —
|
|
/// where the web pre-fills it. Cached at pull time on
|
|
/// `LocalFollowUpRequest`, so this works offline too. Nil for every other
|
|
/// start path.
|
|
var preFillParentFormDataJSON: String? = nil
|
|
|
|
// ── Scheduled inspection launch ───────────────────────────────────────
|
|
/// Server ID of the ScheduledInspection this run fulfils, passed when the
|
|
/// inspector taps Start on a scheduled row. Carried onto the LocalInspection
|
|
/// so submitInspection() can send it; without it the server cannot fulfil
|
|
/// the schedule and the "Scheduled" banner never clears.
|
|
var preFillScheduleId: Int? = nil
|
|
|
|
/// Instructions the manager attached to this schedule ("Instructions" in the
|
|
/// UI; still `notes` on the wire and in the DB). Passed as a plain String
|
|
/// rather than read from SwiftData here, for the same reason
|
|
/// ScheduledStartTarget exists — the cached schedule row is deleted at
|
|
/// submit while this flow is still on screen.
|
|
var preFillScheduleInstructions: String? = nil
|
|
|
|
// ── Derived lists ─────────────────────────────────────────────────────
|
|
|
|
/// Facilities this inspector may actually start work at.
|
|
///
|
|
/// The cache can hold a facility that is no longer in scope — SyncManager
|
|
/// keeps such a row (marked inactive) when an unsynced draft still needs
|
|
/// its name, rather than deleting it and showing "Unknown Facility". It
|
|
/// must not be offered for NEW work, and neither must its contract, so
|
|
/// every derived list below starts here rather than from `facilities`.
|
|
private var availableFacilities: [LocalFacility] {
|
|
facilities.filter { $0.isActive }
|
|
}
|
|
|
|
/// Unique contracts (projectId, projectName) sorted by name.
|
|
/// Facilities with projectId == 0 are grouped under "No Contract".
|
|
private var contracts: [(id: Int, name: String)] {
|
|
var seen = Set<Int>()
|
|
var result: [(id: Int, name: String)] = []
|
|
for f in availableFacilities {
|
|
if seen.insert(f.projectId).inserted {
|
|
result.append((id: f.projectId, name: f.projectName))
|
|
}
|
|
}
|
|
return result.sorted { $0.name < $1.name }
|
|
}
|
|
|
|
/// Facilities that belong to the selected contract, deduplicated by serverId.
|
|
/// Guards against duplicate LocalFacility records if the server ever returns
|
|
/// the same facility id more than once in the /api/v1/facilities response.
|
|
private var filteredFacilities: [LocalFacility] {
|
|
guard let pid = selectedProjectId else { return [] }
|
|
var seen = Set<Int>()
|
|
return availableFacilities
|
|
.filter { $0.projectId == pid }
|
|
.filter { seen.insert($0.serverId).inserted }
|
|
}
|
|
|
|
private var selectedFacility: LocalFacility? {
|
|
filteredFacilities.first { $0.serverId == selectedFacilityId }
|
|
}
|
|
|
|
private var areas: [LocalArea] {
|
|
selectedFacility?.areas.sorted { $0.name < $1.name } ?? []
|
|
}
|
|
|
|
private var canStart: Bool {
|
|
selectedTemplateId != nil && selectedFacilityId != nil
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
// ── Instructions from the schedule ─────────────────────────
|
|
// Shown first: this is the reason the inspector was sent here,
|
|
// and it may change what they carry in with them. Repeated
|
|
// above the form itself in ExecuteInspectionView.
|
|
if let instructions = preFillScheduleInstructions,
|
|
!instructions.isEmpty {
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Label("Instructions", systemImage: "info.circle.fill")
|
|
.font(.callout.bold())
|
|
.foregroundStyle(.blue)
|
|
Text(instructions)
|
|
.font(.callout)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
// ── Re-inspection notice ───────────────────────────────────
|
|
if parentServerId != nil {
|
|
Section {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "arrow.uturn.right.circle.fill")
|
|
.foregroundStyle(.orange)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Re-inspection")
|
|
.font(.callout.bold())
|
|
Text("This will be linked to inspection #\(parentServerId!).")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
// What the director actually asked for. Shown
|
|
// here rather than in its own section so it
|
|
// reads as part of the request, and repeated in
|
|
// full because the card truncates it to a line.
|
|
if let note = preFillFollowUpNote, !note.isEmpty {
|
|
Text(note)
|
|
.font(.callout)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
// ── Template picker ────────────────────────────────────────
|
|
Section("Inspection Template") {
|
|
if templates.isEmpty {
|
|
Text("No templates available. Sync required.")
|
|
.foregroundStyle(.secondary)
|
|
.font(.callout)
|
|
} else {
|
|
Picker("Template", selection: $selectedTemplateId) {
|
|
Text("Select a template…").tag(Optional<Int>(nil))
|
|
ForEach(templates) { template in
|
|
VStack(alignment: .leading) {
|
|
Text(template.name)
|
|
if !template.frequency.isEmpty {
|
|
Text(template.frequencyLabel)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.tag(Optional(template.serverId))
|
|
}
|
|
}
|
|
.pickerStyle(.navigationLink)
|
|
}
|
|
}
|
|
|
|
// ── Contract picker ────────────────────────────────────────
|
|
Section("Contract") {
|
|
if contracts.isEmpty {
|
|
Text("No contracts available. Sync required.")
|
|
.foregroundStyle(.secondary)
|
|
.font(.callout)
|
|
} else {
|
|
Picker("Contract", selection: $selectedProjectId) {
|
|
Text("Select a contract…").tag(Optional<Int>(nil))
|
|
ForEach(contracts, id: \.id) { contract in
|
|
Text(contract.name).tag(Optional(contract.id))
|
|
}
|
|
}
|
|
.pickerStyle(.navigationLink)
|
|
.onChange(of: selectedProjectId) {
|
|
// Reset downstream selections when the user changes contract.
|
|
// Do NOT reset if the current facilityId already belongs to
|
|
// the newly selected contract — this covers the pre-fill path
|
|
// where applyPreFill() sets both projectId and facilityId and
|
|
// the onChange fires before facilityId is applied, wiping it.
|
|
let facilityBelongsToContract = facilities.contains {
|
|
$0.serverId == selectedFacilityId &&
|
|
$0.projectId == selectedProjectId
|
|
}
|
|
if !facilityBelongsToContract {
|
|
selectedFacilityId = nil
|
|
selectedAreaId = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Facility picker (gated on contract selection) ──────────
|
|
if selectedProjectId != nil {
|
|
Section("Facility") {
|
|
if filteredFacilities.isEmpty {
|
|
Text("No facilities in this contract.")
|
|
.foregroundStyle(.secondary)
|
|
.font(.callout)
|
|
} else {
|
|
Picker("Facility", selection: $selectedFacilityId) {
|
|
Text("Select a facility…").tag(Optional<Int>(nil))
|
|
ForEach(filteredFacilities) { facility in
|
|
Text(facility.name).tag(Optional(facility.serverId))
|
|
}
|
|
}
|
|
.pickerStyle(.navigationLink)
|
|
.onChange(of: selectedFacilityId) {
|
|
selectedAreaId = nil // reset area when facility changes
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Area picker (optional, gated on facility selection) ────
|
|
if selectedFacilityId != nil {
|
|
Section("Area (Optional)") {
|
|
if areas.isEmpty {
|
|
Text("No areas defined for this facility.")
|
|
.foregroundStyle(.secondary)
|
|
.font(.callout)
|
|
} else {
|
|
Picker("Area", selection: $selectedAreaId) {
|
|
Text("No specific area").tag(Optional<Int>(nil))
|
|
ForEach(areas) { area in
|
|
Text(area.name).tag(Optional(area.serverId))
|
|
}
|
|
}
|
|
.pickerStyle(.navigationLink)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Start button ───────────────────────────────────────────
|
|
Section {
|
|
Button {
|
|
startInspection()
|
|
} label: {
|
|
HStack {
|
|
Spacer()
|
|
Label(
|
|
parentServerId != nil ? "Start Re-inspection" : "Start Inspection",
|
|
systemImage: parentServerId != nil
|
|
? "arrow.uturn.right.circle.fill"
|
|
: "play.circle.fill"
|
|
)
|
|
.font(.headline)
|
|
Spacer()
|
|
}
|
|
}
|
|
.disabled(!canStart)
|
|
}
|
|
}
|
|
.navigationTitle(parentServerId != nil ? "Re-inspection" : "New Inspection")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: $navigateToExecution) {
|
|
if let inspection = createdInspection {
|
|
// onFinished closes THIS cover rather than just popping back
|
|
// to the form the inspector already finished with — see the
|
|
// property's doc comment on ExecuteInspectionView.
|
|
ExecuteInspectionView(inspection: inspection,
|
|
onFinished: { dismiss() })
|
|
}
|
|
}
|
|
.onAppear {
|
|
applyPreFill()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Pre-fill ──────────────────────────────────────────────────────────
|
|
|
|
/// Apply pre-fill values from re-inspection launch.
|
|
/// Contract must be resolved first so the facility picker shows the
|
|
/// correct filtered list before selectedFacilityId is applied.
|
|
private func applyPreFill() {
|
|
if let tid = preFillTemplateId {
|
|
selectedTemplateId = tid
|
|
}
|
|
if let fid = preFillFacilityId {
|
|
// Resolve the contract that owns this facility
|
|
if let facility = facilities.first(where: { $0.serverId == fid }) {
|
|
selectedProjectId = facility.projectId
|
|
}
|
|
selectedFacilityId = fid
|
|
}
|
|
}
|
|
|
|
// ── Create inspection ─────────────────────────────────────────────────
|
|
|
|
private func startInspection() {
|
|
guard let templateId = selectedTemplateId,
|
|
let facilityId = selectedFacilityId
|
|
else { return }
|
|
|
|
let inspection = LocalInspection(
|
|
templateServerId: templateId,
|
|
facilityServerId: facilityId,
|
|
areaServerId: selectedAreaId,
|
|
inspectorUserId: auth.currentUserId
|
|
)
|
|
// Link to parent if this is a re-inspection
|
|
inspection.parentServerId = parentServerId
|
|
inspection.parentLocalId = parentLocalId
|
|
// Link to the schedule if launched from a scheduled row
|
|
inspection.scheduledInspectionServerId = preFillScheduleId
|
|
|
|
// ── Pre-fill from parent (mirrors web app behaviour) ───────────────
|
|
// Copy non-scoring field values from the parent inspection so the
|
|
// inspector doesn't re-enter static data. Scoring fields (rating,
|
|
// pass_fail) and media fields (image, signature) are always left blank
|
|
// so every scoreable item must be re-evaluated fresh.
|
|
if parentServerId != nil {
|
|
// Prefer the local parent (the CompletedInspectionView path, where
|
|
// the inspector just finished it on this iPad); otherwise fall back
|
|
// to the snapshot cached on the follow-up request. A follow-up
|
|
// raised on the web has usually synced and been dropped locally, so
|
|
// without the fallback this whole block silently no-opped and the
|
|
// form came up blank — the bug this fixes.
|
|
let parentData = resolvedParentFormData()
|
|
|
|
// Fetch the template schema to identify field types.
|
|
// Split into two statements — avoids Xcode 26 #Predicate
|
|
// ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
|
|
let allTemplates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
|
let tid = templateId
|
|
let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? []
|
|
|
|
// The schema is what identifies which fields must NOT be carried
|
|
// over, so without it there is no safe prefill: an empty exclude set
|
|
// would copy *everything*, including the parent's `image` paths and
|
|
// its ratings — attaching the previous inspection's photos as this
|
|
// one's evidence and pre-answering the scoreable items. Copy nothing
|
|
// instead. (Unreachable in practice: the template was chosen from
|
|
// the local picker, so it is cached — this is a guard, not a case.)
|
|
if !parentData.isEmpty, !schema.isEmpty {
|
|
|
|
// Build the set of field IDs that must NOT be carried over
|
|
let excludeTypes: Set<String> = ["rating", "pass_fail", "image", "signature"]
|
|
var excludeIds = Set<String>()
|
|
for field in schema {
|
|
if let type_ = field["type"] as? String, excludeTypes.contains(type_),
|
|
let id = field["id"] {
|
|
excludeIds.insert("\(id)")
|
|
}
|
|
}
|
|
|
|
// Copy all parent values except excluded fields
|
|
var prefilled: [String: Any] = [:]
|
|
for (key, value) in parentData {
|
|
if !excludeIds.contains(key) {
|
|
prefilled[key] = value
|
|
}
|
|
}
|
|
if !prefilled.isEmpty {
|
|
inspection.formData = prefilled
|
|
}
|
|
}
|
|
}
|
|
|
|
context.insert(inspection)
|
|
try? context.save()
|
|
|
|
createdInspection = inspection
|
|
navigateToExecution = true
|
|
}
|
|
|
|
/// The parent inspection's answers to pre-fill from, or `[:]` when there are
|
|
/// none to carry.
|
|
///
|
|
/// Two sources, in order:
|
|
/// 1. The local `LocalInspection` with a matching `serverId` — the
|
|
/// re-inspection-from-history path, where the parent is on this device
|
|
/// and is the freshest copy.
|
|
/// 2. `preFillParentFormDataJSON`, snapshotted from the server at pull
|
|
/// time — the follow-up-request path, where the parent has synced and
|
|
/// is typically no longer local.
|
|
///
|
|
/// Source 1 is checked first but only wins when it actually holds values, so
|
|
/// a stray empty local shell can't shadow a good server snapshot.
|
|
private func resolvedParentFormData() -> [String: Any] {
|
|
if let parentId = parentServerId {
|
|
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
|
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
|
if let parent = allInspections.first(where: { $0.serverId == parentId }) {
|
|
let localData = parent.formData
|
|
if !localData.isEmpty { return localData }
|
|
}
|
|
}
|
|
|
|
guard let json = preFillParentFormDataJSON,
|
|
let data = json.data(using: .utf8),
|
|
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
else { return [:] }
|
|
return dict
|
|
}
|
|
}
|