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

365 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]
/// Rows still awaiting action — see ScheduledInspectionsCard.visible.
private var scheduledVisible: [LocalScheduledInspection] {
scheduledAll.filter { !$0.fulfilledLocally }
}
@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 && scheduledVisible.isEmpty {
ContentUnavailableView(
"No Inspections",
systemImage: "checklist",
description: Text("Tap + to start a new inspection.")
)
} else {
List {
// Scheduled assignments (phase36) — self-hides when empty.
if !scheduledVisible.isEmpty {
Section("Scheduled") {
ForEach(scheduledVisible) { 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) {
// Labelled, not a bare "+". `.titleAndIcon` is required:
// SwiftUI collapses a toolbar Label to icon-only on its own,
// which is what made this read as an unlabelled plus sign.
Button { showNewInspection = true } label: {
Label("New Inspection", systemImage: "plus")
}
.labelStyle(.titleAndIcon)
.buttonStyle(.borderedProminent)
}
}
.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
)
}
}
}