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
@@ -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
)
}
}
}
@@ -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<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
private func handlePhotoSelected(localPath: String, field: [String: Any]) {
@@ -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<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)
try? context.save()
@@ -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