05/05 Update the API to follow-up and resinspection

This commit is contained in:
Nguyen Ngo
2026-05-05 18:19:31 -04:00
parent 9d6b5e5bcb
commit b9e504b72c
8 changed files with 342 additions and 18 deletions
+1
View File
@@ -157,6 +157,7 @@ actor APIClient {
] ]
if let score = inspection.overallScore { body["overall_score"] = score } if let score = inspection.overallScore { body["overall_score"] = score }
if let areaId = inspection.areaServerId { body["area_id"] = areaId } 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 } if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes }
let fmt = ISO8601DateFormatter() let fmt = ISO8601DateFormatter()
+8
View File
@@ -284,6 +284,10 @@ struct APIInspectionSummary: Decodable, Identifiable, Sendable {
let inspectionDate: String? let inspectionDate: String?
let completedAt: String? let completedAt: String?
let mobileLocalId: String? let mobileLocalId: String?
// Follow-up / re-inspection
let followUpRequired: Bool
let followUpNote: String?
let parentInspectionId: Int?
var inspectionDateParsed: Date? { var inspectionDateParsed: Date? {
guard let str = inspectionDate else { return nil } guard let str = inspectionDate else { return nil }
@@ -304,11 +308,15 @@ struct APIInspectionSummary: Decodable, Identifiable, Sendable {
inspectionDate = try? c.decode(String.self, forKey: .inspectionDate) inspectionDate = try? c.decode(String.self, forKey: .inspectionDate)
completedAt = try? c.decode(String.self, forKey: .completedAt) completedAt = try? c.decode(String.self, forKey: .completedAt)
mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) 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 { private enum CodingKeys: String, CodingKey {
case id, templateId, templateName, facilityId, facilityName case id, templateId, templateName, facilityId, facilityName
case areaId, areaName, status, overallScore case areaId, areaName, status, overallScore
case inspectionDate, completedAt, mobileLocalId case inspectionDate, completedAt, mobileLocalId
case followUpRequired, followUpNote, parentInspectionId
} }
} }
+16
View File
@@ -39,6 +39,18 @@ final class LocalInspection {
var syncErrorMessage: String? var syncErrorMessage: String?
var syncRetryCount: Int 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 // Relationships
@Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto] @Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto]
@Relationship(deleteRule: .cascade) var localIssues: [LocalIssue] @Relationship(deleteRule: .cascade) var localIssues: [LocalIssue]
@@ -66,6 +78,10 @@ final class LocalInspection {
self.syncStatus = "pending" self.syncStatus = "pending"
self.syncErrorMessage = nil self.syncErrorMessage = nil
self.syncRetryCount = 0 self.syncRetryCount = 0
self.followUpRequired = false
self.followUpNote = nil
self.parentServerId = nil
self.parentLocalId = nil
self.pendingPhotos = [] self.pendingPhotos = []
self.localIssues = [] self.localIssues = []
} }
+13
View File
@@ -134,6 +134,19 @@ class SyncManager: ObservableObject {
inspection.serverId = inspectionId inspection.serverId = inspectionId
inspection.syncStatus = "synced" inspection.syncStatus = "synced"
inspection.status = "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<LocalInspection>())
if let parent = allInspections?.first(where: { $0.localId == parentLocalId }) {
parent.followUpRequired = false
parent.followUpNote = nil
}
}
try? context.save() try? context.save()
} catch { } catch {
@@ -294,6 +294,19 @@ struct InspectionRowView: View {
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) .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) .padding(.vertical, 4)
} }
@@ -337,6 +350,8 @@ struct CompletedInspectionView: View {
let inspection: LocalInspection let inspection: LocalInspection
@Environment(\.modelContext) private var context @Environment(\.modelContext) private var context
@State private var showReInspect = false
private var templateName: String { private var templateName: String {
let id = inspection.templateServerId let id = inspection.templateServerId
return (try? context.fetch( return (try? context.fetch(
@@ -347,6 +362,52 @@ struct CompletedInspectionView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { 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 { GroupBox {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if let score = inspection.overallScore { if let score = inspection.overallScore {
@@ -404,6 +465,14 @@ struct CompletedInspectionView: View {
} }
.navigationTitle(templateName) .navigationTitle(templateName)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showReInspect) {
StartInspectionView(
preFillTemplateId: inspection.templateServerId,
preFillFacilityId: inspection.facilityServerId,
parentServerId: inspection.serverId,
parentLocalId: inspection.localId
)
}
} }
} }
@@ -347,6 +347,13 @@ struct ExecuteInspectionView: View {
inspection.status = "completed" inspection.status = "completed"
inspection.completedAt = Date() inspection.completedAt = Date()
inspection.syncStatus = "pending" 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() try? context.save()
isSubmitting = false isSubmitting = false
@@ -366,6 +373,49 @@ struct ExecuteInspectionView: View {
dismiss() 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<LocalInspection>(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<LocalInspection>())
.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<LocalInspection>())
.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 // Photo Handling
private func handlePhotoSelected(localPath: String, field: [String: Any]) { private func handlePhotoSelected(localPath: String, field: [String: Any]) {
@@ -23,6 +23,16 @@ struct StartInspectionView: View {
@EnvironmentObject private var auth: AuthManager @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? { private var selectedFacility: LocalFacility? {
facilities.first { $0.serverId == selectedFacilityId } facilities.first { $0.serverId == selectedFacilityId }
} }
@@ -38,6 +48,24 @@ struct StartInspectionView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { 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 // Template picker
Section("Inspection Template") { Section("Inspection Template") {
if templates.isEmpty { if templates.isEmpty {
@@ -104,7 +132,12 @@ struct StartInspectionView: View {
} label: { } label: {
HStack { HStack {
Spacer() Spacer()
Label("Start Inspection", systemImage: "play.circle.fill") Label(
parentServerId != nil ? "Start Re-inspection" : "Start Inspection",
systemImage: parentServerId != nil
? "arrow.uturn.right.circle.fill"
: "play.circle.fill"
)
.font(.headline) .font(.headline)
Spacer() Spacer()
} }
@@ -112,7 +145,7 @@ struct StartInspectionView: View {
.disabled(!canStart) .disabled(!canStart)
} }
} }
.navigationTitle("New Inspection") .navigationTitle(parentServerId != nil ? "Re-inspection" : "New Inspection")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
@@ -124,6 +157,11 @@ struct StartInspectionView: View {
ExecuteInspectionView(inspection: inspection) 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, areaServerId: selectedAreaId,
inspectorUserId: auth.currentUserId 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<LocalInspection>())
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<LocalTemplate>(predicate: #Predicate { $0.serverId == tid })
).first?.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) context.insert(inspection)
try? context.save() try? context.save()
@@ -182,6 +182,19 @@ struct HistoryRowView: View {
.clipShape(Capsule()) .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) .padding(.vertical, 4)
} }
@@ -198,6 +211,7 @@ struct HistoryDetailView: View {
let inspection: APIInspectionSummary let inspection: APIInspectionSummary
@Environment(\.modelContext) private var context @Environment(\.modelContext) private var context
@State private var showReInspect = false
// Look up the local copy by mobileLocalId present only for this-device submissions // Look up the local copy by mobileLocalId present only for this-device submissions
private var localCopy: LocalInspection? { private var localCopy: LocalInspection? {
@@ -230,6 +244,46 @@ struct HistoryDetailView: View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { 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 // Summary card
summaryCard summaryCard
@@ -266,6 +320,37 @@ struct HistoryDetailView: View {
.background(Color(.systemBackground)) .background(Color(.systemBackground))
.navigationTitle(inspection.templateName) .navigationTitle(inspection.templateName)
.navigationBarTitleDisplayMode(.inline) .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 // Summary card