// 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() 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() 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(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(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(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(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 { ExecuteInspectionView(inspection: inspection) } } .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())) ?? [] 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 = ["rating", "pass_fail", "image", "signature"] var excludeIds = Set() 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())) ?? [] 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 } }