diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index b4c0851..3237e85 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -155,9 +155,10 @@ actor APIClient { "form_data": inspection.formData, "mobile_local_id": inspection.localId, ] - if let score = inspection.overallScore { body["overall_score"] = score } - if let areaId = inspection.areaServerId { body["area_id"] = areaId } - if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes } + if let score = inspection.overallScore { body["overall_score"] = score } + if let areaId = inspection.areaServerId { body["area_id"] = areaId } + if let parentId = inspection.parentServerId { body["parent_inspection_id"] = parentId } + if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes } let fmt = ISO8601DateFormatter() body["inspection_date"] = fmt.string(from: inspection.inspectionDate) diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index 01b430d..a20dca1 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -284,6 +284,10 @@ struct APIInspectionSummary: Decodable, Identifiable, Sendable { let inspectionDate: String? let completedAt: String? let mobileLocalId: String? + // ── Follow-up / re-inspection ───────────────────────────────────────── + let followUpRequired: Bool + let followUpNote: String? + let parentInspectionId: Int? var inspectionDateParsed: Date? { guard let str = inspectionDate else { return nil } @@ -292,23 +296,27 @@ struct APIInspectionSummary: Decodable, Identifiable, Sendable { nonisolated init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - templateId = try c.decode(Int.self, forKey: .templateId) - templateName = try c.decode(String.self, forKey: .templateName) - facilityId = try c.decode(Int.self, forKey: .facilityId) - facilityName = try c.decode(String.self, forKey: .facilityName) - areaId = try? c.decode(Int.self, forKey: .areaId) - areaName = try? c.decode(String.self, forKey: .areaName) - status = try c.decode(String.self, forKey: .status) - overallScore = try? c.decode(Double.self, forKey: .overallScore) - inspectionDate = try? c.decode(String.self, forKey: .inspectionDate) - completedAt = try? c.decode(String.self, forKey: .completedAt) - mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) + id = try c.decode(Int.self, forKey: .id) + templateId = try c.decode(Int.self, forKey: .templateId) + templateName = try c.decode(String.self, forKey: .templateName) + facilityId = try c.decode(Int.self, forKey: .facilityId) + facilityName = try c.decode(String.self, forKey: .facilityName) + areaId = try? c.decode(Int.self, forKey: .areaId) + areaName = try? c.decode(String.self, forKey: .areaName) + status = try c.decode(String.self, forKey: .status) + overallScore = try? c.decode(Double.self, forKey: .overallScore) + inspectionDate = try? c.decode(String.self, forKey: .inspectionDate) + completedAt = try? c.decode(String.self, forKey: .completedAt) + mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) + followUpRequired = (try? c.decode(Bool.self, forKey: .followUpRequired)) ?? false + followUpNote = try? c.decode(String.self, forKey: .followUpNote) + parentInspectionId = try? c.decode(Int.self, forKey: .parentInspectionId) } private enum CodingKeys: String, CodingKey { case id, templateId, templateName, facilityId, facilityName case areaId, areaName, status, overallScore case inspectionDate, completedAt, mobileLocalId + case followUpRequired, followUpNote, parentInspectionId } } diff --git a/JanitorialQC/Models/LocalInspection.swift b/JanitorialQC/Models/LocalInspection.swift index d165b01..e1d7f76 100644 --- a/JanitorialQC/Models/LocalInspection.swift +++ b/JanitorialQC/Models/LocalInspection.swift @@ -39,6 +39,18 @@ final class LocalInspection { var syncErrorMessage: String? var syncRetryCount: Int + // ── Follow-up / re-inspection (populated from server after sync) ─────── + /// Set by director/admin on the web app; signals this inspection needs a follow-up. + @Attribute var followUpRequired: Bool = false + /// Optional note explaining what the follow-up should address. + var followUpNote: String? + /// Server ID of the parent inspection this record is a re-inspection of. + var parentServerId: Int? + /// Local UUID of the parent LocalInspection — set at creation, always available + /// regardless of whether the parent has synced. Used to clear the parent's + /// followUpRequired badge without relying on parentServerId being non-nil. + var parentLocalId: String? + // ── Relationships ────────────────────────────────────────────────────── @Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto] @Relationship(deleteRule: .cascade) var localIssues: [LocalIssue] @@ -66,6 +78,10 @@ final class LocalInspection { self.syncStatus = "pending" self.syncErrorMessage = nil self.syncRetryCount = 0 + self.followUpRequired = false + self.followUpNote = nil + self.parentServerId = nil + self.parentLocalId = nil self.pendingPhotos = [] self.localIssues = [] } diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index cea58b8..a64dbb4 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -134,6 +134,19 @@ class SyncManager: ObservableObject { inspection.serverId = inspectionId inspection.syncStatus = "synced" inspection.status = "synced" + + // ── Clear follow-up flag on parent ──────────────────────── + // Use parentLocalId (always set at creation) rather than + // parentServerId (nil until parent syncs) so the badge clears + // regardless of whether the parent has been synced yet. + if let parentLocalId = inspection.parentLocalId { + let allInspections = try? context.fetch(FetchDescriptor()) + if let parent = allInspections?.first(where: { $0.localId == parentLocalId }) { + parent.followUpRequired = false + parent.followUpNote = nil + } + } + try? context.save() } catch { diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 9cc1327..97c5a95 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -294,6 +294,19 @@ struct InspectionRowView: View { .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) } } + // ── Follow-up badge ──────────────────────────────────────────── + if inspection.followUpRequired { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.arrow.circlepath") + .font(.caption2) + Text("Follow-up Required") + .font(.caption2.bold()) + } + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color.orange.opacity(0.15)) + .foregroundStyle(.orange) + .clipShape(Capsule()) + } } .padding(.vertical, 4) } @@ -337,6 +350,8 @@ struct CompletedInspectionView: View { let inspection: LocalInspection @Environment(\.modelContext) private var context + @State private var showReInspect = false + private var templateName: String { let id = inspection.templateServerId return (try? context.fetch( @@ -347,6 +362,52 @@ struct CompletedInspectionView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { + + // ── Follow-up required banner ────────────────────────────── + if inspection.followUpRequired { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.arrow.circlepath") + .foregroundStyle(.orange) + .font(.title3) + VStack(alignment: .leading, spacing: 4) { + Text("Follow-up Inspection Required") + .font(.callout.bold()) + .foregroundStyle(.orange) + if let note = inspection.followUpNote, !note.isEmpty { + Text(note) + .font(.callout) + .foregroundStyle(.secondary) + } + Button { + showReInspect = true + } label: { + Label("Start Re-inspection", systemImage: "arrow.uturn.right.circle.fill") + .font(.callout.bold()) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .padding(.top, 4) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal) + } + + // ── Is a re-inspection — parent link ─────────────────────── + if let parentId = inspection.parentServerId { + HStack(spacing: 10) { + Image(systemName: "arrow.uturn.right.circle") + .foregroundStyle(.secondary) + Text("Re-inspection of inspection #\(parentId)") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + } + GroupBox { VStack(alignment: .leading, spacing: 8) { if let score = inspection.overallScore { @@ -404,6 +465,14 @@ struct CompletedInspectionView: View { } .navigationTitle(templateName) .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showReInspect) { + StartInspectionView( + preFillTemplateId: inspection.templateServerId, + preFillFacilityId: inspection.facilityServerId, + parentServerId: inspection.serverId, + parentLocalId: inspection.localId + ) + } } } diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 6ef421d..b841b71 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -347,6 +347,13 @@ struct ExecuteInspectionView: View { inspection.status = "completed" inspection.completedAt = Date() inspection.syncStatus = "pending" + + // ── Clear follow-up flag on parent immediately ──────────────────── + // Do this at submit time rather than relying solely on SyncManager, + // so the badge disappears the moment the inspector taps Submit — + // regardless of connectivity or sync timing. + clearParentFollowUpFlag() + try? context.save() isSubmitting = false @@ -366,6 +373,49 @@ struct ExecuteInspectionView: View { dismiss() } + /// Find the parent LocalInspection and clear its followUpRequired flag. + /// Tries parentLocalId first (set for new re-inspections), then falls back + /// to parentServerId (set after parent has synced), then as a last resort + /// matches by template+facility for re-inspections created before these + /// fields were added (parentLocalId=nil, parentServerId=nil). + private func clearParentFollowUpFlag() { + var parent: LocalInspection? + + // Primary: match by the parent's localId UUID (always available if set) + if let lid = inspection.parentLocalId { + parent = try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.localId == lid }) + ).first + } + + // Fallback 1: match by server ID (available once parent has synced) + if parent == nil, let sid = inspection.parentServerId { + parent = try? context.fetch(FetchDescriptor()) + .first(where: { $0.serverId == sid }) + } + + // Fallback 2: for stale re-inspections created before parentLocalId existed, + // find any LocalInspection with the same template+facility that has + // followUpRequired=true and is not this inspection itself. + if parent == nil { + let tid = inspection.templateServerId + let fid = inspection.facilityServerId + let selfId = inspection.localId + parent = try? context.fetch(FetchDescriptor()) + .first(where: { + $0.templateServerId == tid && + $0.facilityServerId == fid && + $0.followUpRequired == true && + $0.localId != selfId + }) + } + + if let parent { + parent.followUpRequired = false + parent.followUpNote = nil + } + } + // ── Photo Handling ──────────────────────────────────────────────────── private func handlePhotoSelected(localPath: String, field: [String: Any]) { diff --git a/JanitorialQC/Views/Dashboard/StartInspectionView.swift b/JanitorialQC/Views/Dashboard/StartInspectionView.swift index 989a5ab..9f2c670 100644 --- a/JanitorialQC/Views/Dashboard/StartInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/StartInspectionView.swift @@ -23,6 +23,16 @@ struct StartInspectionView: View { @EnvironmentObject private var auth: AuthManager + // ── Pre-fill for re-inspections ─────────────────────────────────────── + /// When launching from a "Start Re-inspection" button, these are set so + /// the form opens with the parent's template and facility pre-selected. + var preFillTemplateId: Int? = nil + var preFillFacilityId: Int? = nil + var parentServerId: Int? = nil + /// Local UUID of the parent — always available, used by SyncManager to + /// clear the parent's followUpRequired badge after the re-inspection syncs. + var parentLocalId: String? = nil + private var selectedFacility: LocalFacility? { facilities.first { $0.serverId == selectedFacilityId } } @@ -38,6 +48,24 @@ struct StartInspectionView: View { var body: some View { NavigationStack { Form { + // ── 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 { @@ -104,15 +132,20 @@ struct StartInspectionView: View { } label: { HStack { Spacer() - Label("Start Inspection", systemImage: "play.circle.fill") - .font(.headline) + 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("New Inspection") + .navigationTitle(parentServerId != nil ? "Re-inspection" : "New Inspection") .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -124,6 +157,11 @@ struct StartInspectionView: View { ExecuteInspectionView(inspection: inspection) } } + .onAppear { + // Apply pre-fill from re-inspection launch + if let tid = preFillTemplateId { selectedTemplateId = tid } + if let fid = preFillFacilityId { selectedFacilityId = fid } + } } } @@ -138,6 +176,50 @@ struct StartInspectionView: View { areaServerId: selectedAreaId, inspectorUserId: auth.currentUserId ) + // Link to parent if this is a re-inspection + inspection.parentServerId = parentServerId + inspection.parentLocalId = parentLocalId + + // ── 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()) + if let parent = allInspections?.first(where: { $0.serverId == parentId }), + !parent.formData.isEmpty { + + // Fetch the template schema to identify field types + let tid = templateId + let schema = (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.serverId == tid }) + ).first?.formSchema) ?? [] + + // 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 + 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() diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index 0e3993f..2382049 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -182,6 +182,19 @@ struct HistoryRowView: View { .clipShape(Capsule()) } } + // ── Follow-up badge ──────────────────────────────────────────── + if inspection.followUpRequired { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.arrow.circlepath") + .font(.caption2) + Text("Follow-up Required") + .font(.caption2.bold()) + } + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color.orange.opacity(0.15)) + .foregroundStyle(.orange) + .clipShape(Capsule()) + } } .padding(.vertical, 4) } @@ -198,6 +211,7 @@ struct HistoryDetailView: View { let inspection: APIInspectionSummary @Environment(\.modelContext) private var context + @State private var showReInspect = false // Look up the local copy by mobileLocalId — present only for this-device submissions private var localCopy: LocalInspection? { @@ -230,6 +244,46 @@ struct HistoryDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { + // ── Follow-up required banner ────────────────────────────── + if inspection.followUpRequired { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.arrow.circlepath") + .foregroundStyle(.orange) + .font(.title3) + VStack(alignment: .leading, spacing: 4) { + Text("Follow-up Inspection Required") + .font(.callout.bold()) + .foregroundStyle(.orange) + if let note = inspection.followUpNote, !note.isEmpty { + Text(note) + .font(.callout) + .foregroundStyle(.secondary) + } + Button { + showReInspect = true + } label: { + Label("Start Re-inspection", systemImage: "arrow.uturn.right.circle.fill") + .font(.callout.bold()) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .padding(.top, 4) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 24) + } + + // ── Is a re-inspection — parent link ─────────────────────── + if let parentId = inspection.parentInspectionId { + infoRow(icon: "arrow.uturn.right.circle", + text: "Re-inspection of inspection #\(parentId)") + .padding(.horizontal, 24) + } + // ── Summary card ─────────────────────────────────────────── summaryCard @@ -266,6 +320,37 @@ struct HistoryDetailView: View { .background(Color(.systemBackground)) .navigationTitle(inspection.templateName) .navigationBarTitleDisplayMode(.inline) + .onAppear { + syncFollowUpToLocalCopy() + } + .sheet(isPresented: $showReInspect) { + StartInspectionView( + preFillTemplateId: inspection.templateId, + preFillFacilityId: inspection.facilityId, + parentServerId: inspection.id, + parentLocalId: inspection.mobileLocalId + ) + } + } + + /// Write the server's follow-up fields back onto the local SwiftData copy + /// so that MyInspectionsView and CompletedInspectionView reflect the latest state. + private func syncFollowUpToLocalCopy() { + guard let copy = localCopy else { return } + var changed = false + if copy.followUpRequired != inspection.followUpRequired { + copy.followUpRequired = inspection.followUpRequired + changed = true + } + if copy.followUpNote != inspection.followUpNote { + copy.followUpNote = inspection.followUpNote + changed = true + } + if copy.parentServerId != inspection.parentInspectionId { + copy.parentServerId = inspection.parentInspectionId + changed = true + } + if changed { try? context.save() } } // ── Summary card ───────────────────────────────────────────────────────