Files
JQC_iOS_App/JanitorialQC/Views/Inspection/InspectionHistoryView.swift
T

677 lines
27 KiB
Swift

// Views/Inspection/InspectionHistoryView.swift
// --------------------------------------------
// Shows the inspector's synced inspection history fetched from the server.
// Only available when online. Displays score, facility, template, and date.
import SwiftUI
import SwiftData
struct InspectionHistoryView: View {
@EnvironmentObject private var sync: SyncManager
@State private var inspections: [APIInspectionSummary] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var total = 0
@State private var offset = 0
private let limit = 30
var body: some View {
Group {
if !sync.isOnline && inspections.isEmpty {
ContentUnavailableView(
"Offline",
systemImage: "wifi.slash",
description: Text("Inspection history requires an internet connection.")
)
} else if isLoading && inspections.isEmpty {
ProgressView("Loading history…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = errorMessage, inspections.isEmpty {
ContentUnavailableView(
"Could Not Load",
systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else if inspections.isEmpty {
ContentUnavailableView(
"No History",
systemImage: "clock.arrow.circlepath",
description: Text("Completed inspections will appear here after syncing.")
)
} else {
List {
ForEach(inspections) { inspection in
NavigationLink(value: inspection) {
HistoryRowView(inspection: inspection)
}
}
// Load more
if inspections.count < total {
HStack {
Spacer()
Button("Load More") {
Task { await loadMore() }
}
.disabled(isLoading)
Spacer()
}
.listRowSeparator(.hidden)
}
if isLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
.listRowSeparator(.hidden)
}
}
.refreshable {
await load(reset: true)
}
}
}
.navigationTitle("Inspection History")
.task {
if sync.isOnline {
await load(reset: true)
}
}
.onChange(of: sync.isOnline) {
if sync.isOnline && inspections.isEmpty {
Task { await load(reset: true) }
}
}
}
private func load(reset: Bool) async {
if reset { offset = 0 }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let result = try await APIClient.shared.fetchInspectionHistory(
limit: limit, offset: reset ? 0 : offset
)
if reset {
inspections = result.inspections
} else {
inspections.append(contentsOf: result.inspections)
}
total = result.total
offset = inspections.count
} catch {
errorMessage = error.localizedDescription
}
}
private func loadMore() async {
await load(reset: false)
}
}
// MARK: - History Row
struct HistoryRowView: View {
let inspection: APIInspectionSummary
private var scoreColor: Color {
guard let score = inspection.overallScore else { return .secondary }
return score >= 80 ? .green : score >= 60 ? .orange : .red
}
private var dateText: String {
guard let date = inspection.inspectionDateParsed else {
return inspection.inspectionDate ?? ""
}
return date.formatted(date: .abbreviated, time: .shortened)
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 2) {
Text(inspection.templateName)
.font(.headline)
.lineLimit(1)
Text(inspection.facilityName)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(1)
if let area = inspection.areaName {
Text(area)
.font(.caption)
.foregroundStyle(.tertiary)
}
}
Spacer()
if let score = inspection.overallScore {
VStack(alignment: .trailing, spacing: 2) {
Text(String(format: "%.1f%%", score))
.font(.title3.bold())
.foregroundStyle(scoreColor)
Text("Score")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
HStack {
Image(systemName: "calendar")
.font(.caption2)
.foregroundStyle(.tertiary)
Text(dateText)
.font(.caption2)
.foregroundStyle(.tertiary)
Spacer()
// Sync origin badge
if inspection.mobileLocalId != nil {
Label("Mobile", systemImage: "ipad")
.font(.caption2)
.foregroundStyle(.blue)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.1))
.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)
}
}
// MARK: - History Detail View
// Shows submitted inspection details.
// For inspections originally submitted from this device (matched via mobileLocalId),
// the filled-in form responses are shown using the same grid as ExecuteInspectionView.
// For inspections submitted elsewhere, only summary fields are shown.
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? {
guard let lid = inspection.mobileLocalId else { return nil }
return try? context.fetch(
FetchDescriptor<LocalInspection>(
predicate: #Predicate { $0.localId == lid }
)
).first
}
// Fetch the template schema so we can render the form grid
private var localTemplate: LocalTemplate? {
guard let copy = localCopy else { return nil }
let id = copy.templateServerId
return try? context.fetch(
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
).first
}
private var formSchema: [[String: Any]] { localTemplate?.formSchema ?? [] }
// Convert saved form data to [String: String] for the grid renderer
private var savedValues: [String: String] {
guard let copy = localCopy else { return [:] }
return copy.formData.compactMapValues { "\($0)" }
}
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, 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
// ── Flagged issues ─────────────────────────────────────────
if let copy = localCopy, !copy.localIssues.isEmpty {
issuesCard(copy.localIssues)
}
// ── Form responses ─────────────────────────────────────────
if !formSchema.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Form Responses")
.font(.headline)
.padding(.horizontal, 24)
// Read-only form grid — reuses GridFormView with disabled inputs
ReadOnlyGridFormView(
schema: formSchema,
formValues: savedValues
)
.padding(.horizontal, 24)
}
} else if localCopy != nil {
// Template schema no longer cached locally
infoRow(
icon: "doc.text",
text: "Form schema not available offline. Sync to view full responses."
)
.padding(.horizontal, 24)
}
}
.padding(.vertical, 16)
}
.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 ───────────────────────────────────────────────────────
private var summaryCard: some View {
VStack(alignment: .leading, spacing: 12) {
// Score
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)
}
}
Divider()
infoRow(icon: "building.2", text: inspection.facilityName)
if let area = inspection.areaName {
infoRow(icon: "mappin", text: area)
}
if let date = inspection.inspectionDateParsed {
infoRow(icon: "calendar", text: date.formatted(date: .long, time: .shortened))
}
if inspection.mobileLocalId != nil {
infoRow(icon: "ipad", text: "Submitted from this device")
}
}
.padding(16)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 24)
}
// ── Flagged issues card ────────────────────────────────────────────────
private func issuesCard(_ issues: [LocalIssue]) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Flagged Issues (\(issues.count))")
.font(.headline)
ForEach(issues) { issue in
HStack(alignment: .top, spacing: 10) {
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, 5)
VStack(alignment: .leading, spacing: 2) {
Text(issue.severity.capitalized)
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(issue.issueDescription)
.font(.callout)
}
}
}
}
.padding(16)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 24)
}
// ── Helper ─────────────────────────────────────────────────────────────
private func infoRow(icon: String, text: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
.foregroundStyle(.secondary)
.frame(width: 18)
Text(text)
.font(.callout)
.foregroundStyle(.primary)
}
}
}
// MARK: - ReadOnlyGridFormView
// Renders a submitted form in the same 12-column grid as ExecuteInspectionView
// but with all inputs disabled/display-only — no editing allowed.
struct ReadOnlyGridFormView: View {
let schema: [[String: Any]]
let formValues: [String: String]
private struct ReadOnlyWidthKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
static let totalColumns: Int = 12
static let cellGap: CGFloat = 8
static let rowGap: CGFloat = 4
static let cellAspect: CGFloat = 52/72
static let cardPadding: CGFloat = 16
@State private var containerWidth: CGFloat = 952
private var computedCellW: CGFloat {
(containerWidth - 2 * Self.cardPadding
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
/ CGFloat(Self.totalColumns)
}
private func canvasHeight() -> CGFloat {
let cellH = computedCellW * Self.cellAspect
let maxRow = schema.reduce(0) { acc, f in
max(acc, (f["row"] as? Int ?? 1) + (f["rowSpan"] as? Int ?? 2) - 1)
}
return CGFloat(maxRow) * cellH + CGFloat(max(maxRow - 1, 0)) * Self.rowGap
}
var body: some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemBackground))
Color.clear
.frame(maxWidth: .infinity).frame(height: 0)
.background(GeometryReader { geo in
Color.clear.preference(key: ReadOnlyWidthKey.self, value: geo.size.width)
})
let cellW = computedCellW
let cellH = cellW * Self.cellAspect
ForEach(schema.indices, id: \.self) { idx in
let field = schema[idx]
let ftype = field["type"] as? String ?? "text"
if !["button_submit", "button_print", "button_email"].contains(ftype) {
readOnlyCell(field: field, cellW: cellW, cellH: cellH)
}
}
}
.onPreferenceChange(ReadOnlyWidthKey.self) { if $0 > 0 { containerWidth = $0 } }
.frame(height: canvasHeight() + 2 * Self.cardPadding)
}
@ViewBuilder
private func readOnlyCell(field: [String: Any], cellW: CGFloat, cellH: CGFloat) -> some View {
let col = max(1, field["col"] as? Int ?? 1)
let row = max(1, field["row"] as? Int ?? 1)
let colSpan = max(1, field["colSpan"] as? Int ?? 6)
let rowSpan = max(1, field["rowSpan"] as? Int ?? 2)
let xOff = CGFloat(col - 1) * (cellW + Self.cellGap) + Self.cardPadding
let yOff = CGFloat(row - 1) * (cellH + Self.rowGap) + Self.cardPadding
let width = CGFloat(colSpan) * cellW + CGFloat(colSpan - 1) * Self.cellGap
let height = CGFloat(rowSpan) * cellH + CGFloat(rowSpan - 1) * Self.rowGap
let fid = field["id"] as? String ?? (field["id"] as? Int).map(String.init) ?? ""
let value = formValues[fid] ?? ""
let ftype = field["type"] as? String ?? "text"
let label = field["label"] as? String ?? ""
ReadOnlyCellView(field: field, value: value, fieldType: ftype, label: label)
.frame(width: width, height: height, alignment: .topLeading)
.offset(x: xOff, y: yOff)
}
}
// MARK: - ReadOnlyCellView
// Displays a single form cell as plain text — no editable controls.
struct ReadOnlyCellView: View {
let field: [String: Any]
let value: String
let fieldType: String
let label: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
// Label (same as GridCellContentView)
if !["section", "label", "checkbox",
"button_submit", "button_print", "button_email"].contains(fieldType),
!label.isEmpty {
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(.secondaryLabel))
.lineLimit(1)
.truncationMode(.tail)
}
// Value display
valueView
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
@ViewBuilder
private var valueView: some View {
switch fieldType {
case "section":
VStack(alignment: .leading, spacing: 0) {
Divider()
Text(label)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(Color(.label))
.padding(.top, 4)
}
.frame(maxWidth: .infinity)
case "label":
let fsMap: [String: CGFloat] = ["small": 11, "normal": 13, "large": 15, "x-large": 18]
let fs = fsMap[field["font_size"] as? String ?? "normal"] ?? 13
let fw: Font.Weight = (field["font_weight"] as? String == "bold") ? .bold : .regular
Text(field["text_content"] as? String ?? "")
.font(.system(size: fs, weight: fw))
.foregroundStyle(Color(.label))
.fixedSize(horizontal: false, vertical: true)
case "checkbox":
HStack(spacing: 6) {
Image(systemName: value == "true" ? "checkmark.square.fill" : "square")
.foregroundStyle(value == "true" ? .blue : Color(.systemGray3))
.font(.system(size: 14))
Text(label)
.font(.system(size: 12))
.foregroundStyle(Color(.secondaryLabel))
}
case "pass_fail":
let options = field["options"] as? [String] ?? ["Pass", "Fail"]
HStack(spacing: 6) {
ForEach(options, id: \.self) { opt in
let isPass = ["pass","yes","ok","good","acceptable","compliant"].contains(opt.lowercased())
let isActive = value == opt
Text(opt)
.font(.system(size: 12, weight: .semibold))
.padding(.horizontal, 10).padding(.vertical, 4)
.background(isActive ? (isPass ? Color.green : Color.red) : Color.clear)
.foregroundStyle(isActive ? .white : (isPass ? Color.green : Color.red))
.clipShape(Capsule())
.overlay(Capsule().stroke(isPass ? Color.green : Color.red, lineWidth: 1.5))
}
}
case "rating":
let intVal = Int(value) ?? 0
let maxRating = field["max"] as? Int ?? 5
HStack(spacing: 2) {
ForEach(1...Swift.max(maxRating, 1), id: \.self) { star in
Text("★")
.font(.system(size: 16))
.foregroundStyle(star <= intVal ? Color.yellow : Color(.systemGray4))
}
}
case "image":
if value.hasPrefix("local://") {
// Photo taken on this device — may still be on disk
let path = String(value.dropFirst("local://".count))
if let img = UIImage(contentsOfFile: path) {
Image(uiImage: img)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 5))
} else {
// Local file cleaned up — show placeholder
Label("Photo no longer on device", systemImage: "photo.badge.exclamationmark")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
} else if value.hasPrefix("uploads/") {
// Photo synced to server — load via AsyncImage
let url = URL(string: "\(Constants.baseURL)/static/\(value)")
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 5))
case .failure:
Label("Could not load photo", systemImage: "photo.badge.exclamationmark")
.font(.system(size: 11))
.foregroundStyle(.secondary)
case .empty:
HStack(spacing: 6) {
ProgressView().scaleEffect(0.7)
Text("Loading photo…")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
@unknown default:
EmptyView()
}
}
} else if !value.isEmpty {
// Unknown path format — generic indicator
Label("Photo attached", systemImage: "photo")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} else {
Text("—")
.font(.system(size: 12))
.foregroundStyle(Color(.tertiaryLabel))
}
default:
// Text, textarea, number, email, date, select, radio, checkbox_group
Text(value.isEmpty ? "—" : value)
.font(.system(size: 12))
.foregroundStyle(value.isEmpty ? Color(.tertiaryLabel) : Color(.label))
.lineLimit(3)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 5))
.overlay(RoundedRectangle(cornerRadius: 5)
.stroke(Color(.systemGray5), lineWidth: 1))
}
}
}