355 lines
15 KiB
Swift
355 lines
15 KiB
Swift
// Views/Dashboard/MyInspectionsView.swift
|
|
import SwiftUI
|
|
import SwiftData
|
|
import MessageUI
|
|
|
|
// MARK: - My Inspections
|
|
|
|
struct MyInspectionsView: View {
|
|
|
|
@Query(
|
|
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
|
sort: \LocalInspection.lastModifiedAt,
|
|
order: .reverse
|
|
) private var inspections: [LocalInspection]
|
|
|
|
/// Scheduled assignments (phase36) — rendered as the top section and used
|
|
/// for the empty-state decision. Sorted by due date (ISO strings sort
|
|
/// chronologically).
|
|
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
|
|
private var scheduledAll: [LocalScheduledInspection]
|
|
|
|
@Environment(\.modelContext) private var context
|
|
|
|
@State private var showNewInspection = false
|
|
@State private var scheduledStartTarget: ScheduledStartTarget?
|
|
|
|
// Deletion confirmation state
|
|
@State private var pendingDelete: LocalInspection?
|
|
@State private var showDeleteAlert = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if inspections.isEmpty && scheduledAll.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Inspections",
|
|
systemImage: "checklist",
|
|
description: Text("Tap + to start a new inspection.")
|
|
)
|
|
} else {
|
|
List {
|
|
// Scheduled assignments (phase36) — self-hides when empty.
|
|
if !scheduledAll.isEmpty {
|
|
Section("Scheduled") {
|
|
ForEach(scheduledAll) { s in
|
|
Button { scheduledStartTarget = ScheduledStartTarget(s) } label: {
|
|
ScheduledRow(schedule: s)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !inspections.isEmpty {
|
|
Section("In Progress") {
|
|
ForEach(inspections) { inspection in
|
|
NavigationLink(value: inspection) {
|
|
InspectionRowView(inspection: inspection, context: context)
|
|
}
|
|
// Only drafts may be deleted — submitted/pending-sync inspections are kept
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
if inspection.status == "draft" {
|
|
Button(role: .destructive) {
|
|
pendingDelete = inspection
|
|
showDeleteAlert = true
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Cover attached to the enclosing Group, not the List and never a
|
|
// Section (rule 64). The List itself is conditional: submitting the last
|
|
// scheduled inspection can flip this view to ContentUnavailableView while
|
|
// the cover is still presented, which would tear the form down mid-submit.
|
|
// The Group is always present.
|
|
.fullScreenCover(item: $scheduledStartTarget) { t in
|
|
StartInspectionView(
|
|
preFillTemplateId: t.templateServerId,
|
|
preFillFacilityId: t.facilityServerId,
|
|
preFillScheduleId: t.id,
|
|
preFillScheduleInstructions: t.instructions
|
|
)
|
|
}
|
|
.navigationTitle("My Inspections")
|
|
// Confirmation before deletion — destructive action cannot be undone
|
|
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
|
|
Button("Delete", role: .destructive) { deleteDraft(inspection) }
|
|
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
|
} message: { inspection in
|
|
Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.")
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button { showNewInspection = true } label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
}
|
|
}
|
|
.fullScreenCover(isPresented: $showNewInspection) {
|
|
StartInspectionView()
|
|
}
|
|
}
|
|
|
|
private func draftName(_ inspection: LocalInspection) -> String {
|
|
let templateId = inspection.templateServerId // plain Int — safe to capture in #Predicate
|
|
return (try? context.fetch(
|
|
FetchDescriptor<LocalTemplate>(
|
|
predicate: #Predicate { $0.serverId == templateId }
|
|
)
|
|
).first?.name) ?? "this inspection"
|
|
}
|
|
|
|
private func deleteDraft(_ inspection: LocalInspection) {
|
|
// Delete associated pending photos from disk and SwiftData
|
|
for photo in inspection.pendingPhotos {
|
|
try? FileManager.default.removeItem(atPath: photo.localFilePath)
|
|
context.delete(photo)
|
|
}
|
|
// Delete associated local issues
|
|
for issue in inspection.localIssues {
|
|
for path in issue.photoLocalPaths {
|
|
try? FileManager.default.removeItem(atPath: path)
|
|
}
|
|
context.delete(issue)
|
|
}
|
|
context.delete(inspection)
|
|
try? context.save()
|
|
pendingDelete = nil
|
|
}
|
|
}
|
|
|
|
struct InspectionRowView: View {
|
|
let inspection: LocalInspection
|
|
let context: ModelContext
|
|
|
|
private var facilityName: String {
|
|
let id = inspection.facilityServerId
|
|
return (try? context.fetch(
|
|
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
|
).first?.name) ?? "Unknown Facility"
|
|
}
|
|
|
|
private var templateName: String {
|
|
let id = inspection.templateServerId
|
|
return (try? context.fetch(
|
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
|
).first?.name) ?? "Unknown Template"
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(templateName).font(.headline)
|
|
Spacer()
|
|
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
|
|
}
|
|
Text(facilityName).font(.callout).foregroundStyle(.secondary)
|
|
HStack {
|
|
Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
if let score = inspection.overallScore {
|
|
Spacer()
|
|
Text(String(format: "%.1f%%", score))
|
|
.font(.caption).fontWeight(.medium)
|
|
.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)
|
|
}
|
|
}
|
|
|
|
struct StatusBadge: View {
|
|
let status: String
|
|
let syncStatus: String
|
|
|
|
var label: String {
|
|
switch status {
|
|
case "draft": return "Draft"
|
|
case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
|
|
case "failed": return "Sync Failed"
|
|
default: return status.capitalized
|
|
}
|
|
}
|
|
|
|
var color: Color {
|
|
switch status {
|
|
case "draft": return .blue
|
|
case "completed": return syncStatus == "pending" ? .orange : .green
|
|
case "failed": return .red
|
|
default: return .secondary
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Text(label)
|
|
.font(.caption2)
|
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
|
.background(color.opacity(0.15))
|
|
.foregroundStyle(color)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
// MARK: - Completed Inspection (read-only)
|
|
|
|
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(
|
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
|
).first?.name) ?? "Inspection"
|
|
}
|
|
|
|
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 {
|
|
HStack {
|
|
Text("Overall Score").font(.subheadline).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(String(format: "%.1f%%", score))
|
|
.font(.title2.bold())
|
|
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
|
}
|
|
}
|
|
if let completedAt = inspection.completedAt {
|
|
HStack {
|
|
Text("Completed").font(.subheadline).foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(completedAt.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.callout)
|
|
}
|
|
}
|
|
HStack {
|
|
Text("Sync Status").font(.subheadline).foregroundStyle(.secondary)
|
|
Spacer()
|
|
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
|
|
}
|
|
if let error = inspection.syncErrorMessage {
|
|
Text("Error: \(error)").font(.caption).foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
if !inspection.localIssues.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Flagged Issues (\(inspection.localIssues.count))")
|
|
.font(.headline).padding(.horizontal)
|
|
ForEach(inspection.localIssues) { issue in
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Circle()
|
|
.fill(issue.severity == "critical" ? Color.red :
|
|
issue.severity == "high" ? Color.orange :
|
|
issue.severity == "medium" ? Color.yellow : Color.blue)
|
|
.frame(width: 8, height: 8).padding(.top, 4)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(issue.severity.capitalized)
|
|
.font(.caption.bold()).foregroundStyle(.secondary)
|
|
Text(issue.issueDescription).font(.callout)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical)
|
|
}
|
|
.navigationTitle(templateName)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.fullScreenCover(isPresented: $showReInspect) {
|
|
StartInspectionView(
|
|
preFillTemplateId: inspection.templateServerId,
|
|
preFillFacilityId: inspection.facilityServerId,
|
|
parentServerId: inspection.serverId,
|
|
parentLocalId: inspection.localId
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|