Files
JQC_iOS_App/JanitorialQC/Views/Dashboard/StartInspectionView.swift
T

353 lines
16 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
// ── 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 ─────────────────────────────────────────────────────
/// 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 facilities {
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 facilities
.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)
}
}
.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 {
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 let parentId = parentServerId {
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
if let parent = allInspections.first(where: { $0.serverId == parentId }),
!parent.formData.isEmpty {
// 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 ?? []
// 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
let parentData = parent.formData
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
}
}