Jul 13 - Update codes to catch up with the web app updates: scheduled inspection and issue's handler

This commit is contained in:
Nguyen Ngo
2026-07-13 14:03:41 -04:00
parent c05f0029fb
commit 20f9a99646
22 changed files with 5800 additions and 18 deletions
@@ -275,6 +275,12 @@ struct DashboardStatsView: View {
DraftResumeBanner(drafts: draftInspections, context: context)
}
// Scheduled Inspections (phase36)
// Planned/recurring assignments for this inspector. Self-hides
// when there are none. Tap a row to start it (facility +
// template preselected).
ScheduledInspectionsCard()
if let stats = sync.dashboardStats {
// Today
statsSection(title: "Today") {
@@ -519,7 +525,7 @@ struct DraftResumeBanner: View {
// Wrap in NavigationStack so ExecuteInspectionView's toolbar
// and dismiss work correctly when presented as a sheet.
NavigationStack {
ExecuteInspectionView(inspection: draft)
ExecuteInspectionView(inspection: draft, isModallyPresented: true)
}
}
}
@@ -22,6 +22,13 @@ struct ExecuteInspectionView: View {
let inspection: LocalInspection
/// True when presented as the root of a fullScreenCover/sheet (e.g. the
/// dashboard "Resume" banner) rather than pushed onto a NavigationStack.
/// In that case there is no navigation back button, so a leading "Close"
/// button is shown so the inspector can return home without submitting.
/// Work is preserved either way .onDisappear calls saveDraft().
var isModallyPresented: Bool = false
@State private var formValues: [String: String] = [:]
@State private var showFlagIssue = false
@State private var showSubmitAlert = false
@@ -120,6 +127,13 @@ struct ExecuteInspectionView: View {
.navigationTitle(template?.name ?? "Inspection")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if isModallyPresented {
ToolbarItem(placement: .topBarLeading) {
// Root of a modal presentation no nav back button exists.
// Draft is saved on disappear, so closing loses nothing.
Button("Close") { dismiss() }
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 10) {
// Live score updates on every field change via formValues binding.
@@ -258,6 +258,15 @@ struct IssueDetailView: View {
@State private var showMailCompose = false
@State private var generatedPDFData: Data? = nil
// Handler ("Handled By", phase35) editing state
@State private var isEditingHandler = false
@State private var handlerDraftType = "internal" // internal | facility | vendor
@State private var handlerName = ""
@State private var handlerContact = ""
@State private var handlerNotes = ""
@State private var isSavingHandler = false
@State private var handlerError: String?
private var facilityName: String {
let id = issue.facilityServerId
return (try? context.fetch(
@@ -289,6 +298,24 @@ struct IssueDetailView: View {
("resolved", "Resolved", .green),
]
/// Who may change the handler from the iPad. Per product decision, the
/// assigned inspector may set it here (the web form limits this to
/// admin/director/PM); the server enforces facility scope for inspectors.
private var canEditHandler: Bool {
guard sync.isOnline, issue.serverId != nil else { return false }
let role = AuthManager.shared.currentUserRole
return role == "admin" || role == "director"
|| role == "inspector" || role == "project_manager"
}
private func handlerTypeLabel(_ type: String) -> String {
switch type {
case "facility": return "Facility Staff"
case "vendor": return "External Vendor"
default: return "Janitorial Staff"
}
}
private func statusColor(for status: String) -> Color {
allStatuses.first { $0.value == status }?.color ?? .secondary
}
@@ -363,6 +390,9 @@ struct IssueDetailView: View {
}
}
// Handled By (phase35)
handledBySection
// Upload Resolution Photos
// Shown when the issue is resolved, online, and synced.
// Lets the inspector attach up to 5 photos showing the fix
@@ -703,6 +733,18 @@ struct IssueDetailView: View {
}
if let area = detail.areaName, !area.isEmpty { issue.areaNameCache = area }
if let assignee = detail.assignedToName, !assignee.isEmpty { issue.assignedToName = assignee }
// Refresh handler ("Handled By") from live server data. Skipped
// while the inspector is mid-edit so their draft isn't disturbed.
if !isEditingHandler {
issue.handlerType = detail.handlerType
issue.handlerLabel = detail.handlerLabel
issue.facilityHandlerName = detail.facilityHandlerName
issue.facilityHandlerContact = detail.facilityHandlerContact
issue.facilityHandlerNotes = detail.facilityHandlerNotes
issue.vendorName = detail.vendorName
issue.vendorContact = detail.vendorContact
issue.vendorNotes = detail.vendorNotes
}
// Refresh resolution photos from server
if !detail.resultPhotos.isEmpty {
issue.resultPhotoServerPaths = detail.resultPhotos
@@ -763,6 +805,168 @@ struct IssueDetailView: View {
}
}
// Handled By (phase35)
@ViewBuilder
private var handledBySection: some View {
Section("Handled By") {
let type = issue.handlerType ?? "internal"
LabeledContent("Handler") {
Text(issue.handlerLabel ?? handlerTypeLabel(type))
.fontWeight(.semibold)
}
// Current detail depends on handler category.
switch type {
case "facility":
if let n = issue.facilityHandlerName, !n.isEmpty {
LabeledContent("Name", value: n)
}
if let c = issue.facilityHandlerContact, !c.isEmpty {
LabeledContent("Contact", value: c)
}
if let notes = issue.facilityHandlerNotes, !notes.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text("Notes").font(.caption).foregroundStyle(.secondary)
Text(notes).font(.callout)
}
}
case "vendor":
if let n = issue.vendorName, !n.isEmpty {
LabeledContent("Vendor", value: n)
}
if let c = issue.vendorContact, !c.isEmpty {
LabeledContent("Contact", value: c)
}
if let notes = issue.vendorNotes, !notes.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text("Notes").font(.caption).foregroundStyle(.secondary)
Text(notes).font(.callout)
}
}
default:
if let a = issue.assignedToName, !a.isEmpty {
LabeledContent("Staff", value: a)
}
}
// Inspector edit
if canEditHandler {
if isEditingHandler {
Picker("Type", selection: $handlerDraftType) {
Text("Staff").tag("internal")
Text("Facility").tag("facility")
Text("Vendor").tag("vendor")
}
.pickerStyle(.segmented)
if handlerDraftType != "internal" {
TextField(
handlerDraftType == "vendor" ? "Vendor name" : "Handler name",
text: $handlerName
)
TextField("Contact (phone or email)", text: $handlerContact)
TextField("Notes", text: $handlerNotes, axis: .vertical)
.lineLimit(1...4)
}
if let e = handlerError {
Text(e).font(.caption).foregroundStyle(.red)
}
HStack {
Button("Cancel") { isEditingHandler = false }
.buttonStyle(.bordered)
Spacer()
Button {
Task { await saveHandler() }
} label: {
if isSavingHandler {
ProgressView()
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(isSavingHandler)
}
} else {
Button {
beginEditHandler()
} label: {
Label("Change Handler", systemImage: "person.badge.shield.checkmark")
}
}
}
}
}
private func beginEditHandler() {
let type = issue.handlerType ?? "internal"
handlerDraftType = type
switch type {
case "vendor":
handlerName = issue.vendorName ?? ""
handlerContact = issue.vendorContact ?? ""
handlerNotes = issue.vendorNotes ?? ""
case "facility":
handlerName = issue.facilityHandlerName ?? ""
handlerContact = issue.facilityHandlerContact ?? ""
handlerNotes = issue.facilityHandlerNotes ?? ""
default:
handlerName = ""; handlerContact = ""; handlerNotes = ""
}
handlerError = nil
isEditingHandler = true
}
private func saveHandler() async {
guard let sid = issue.serverId else { return }
isSavingHandler = true
handlerError = nil
defer { isSavingHandler = false }
let type = handlerDraftType
let name = handlerName.trimmingCharacters(in: .whitespacesAndNewlines)
let contact = handlerContact.trimmingCharacters(in: .whitespacesAndNewlines)
let notes = handlerNotes.trimmingCharacters(in: .whitespacesAndNewlines)
var details: [String: String] = [:]
if type == "facility" {
details["facility_handler_name"] = name
details["facility_handler_contact"] = contact
details["facility_handler_notes"] = notes
} else if type == "vendor" {
details["vendor_name"] = name
details["vendor_contact"] = contact
details["vendor_notes"] = notes
}
do {
let confirmed = try await APIClient.shared.updateIssueHandler(
issueId: sid, handlerType: type, details: details
)
// Mirror the change into the local record so the UI reflects it
// immediately; the next pull re-confirms from the server.
issue.handlerType = confirmed
issue.handlerLabel = handlerTypeLabel(confirmed)
if type == "facility" {
issue.facilityHandlerName = name.isEmpty ? nil : name
issue.facilityHandlerContact = contact.isEmpty ? nil : contact
issue.facilityHandlerNotes = notes.isEmpty ? nil : notes
} else if type == "vendor" {
issue.vendorName = name.isEmpty ? nil : name
issue.vendorContact = contact.isEmpty ? nil : contact
issue.vendorNotes = notes.isEmpty ? nil : notes
}
try? context.save()
isEditingHandler = false
} catch {
handlerError = error.localizedDescription
}
}
// Resolution photo helpers
private func appendResultPhoto(_ img: UIImage) {
@@ -13,9 +13,16 @@ struct MyInspectionsView: View {
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: LocalScheduledInspection?
// Deletion confirmation state
@State private var pendingDelete: LocalInspection?
@@ -23,28 +30,53 @@ struct MyInspectionsView: View {
var body: some View {
Group {
if inspections.isEmpty {
if inspections.isEmpty && scheduledAll.isEmpty {
ContentUnavailableView(
"No Inspections",
systemImage: "checklist",
description: Text("Tap + to start a new inspection.")
)
} else {
List(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")
List {
// Scheduled assignments (phase36) self-hides when empty.
if !scheduledAll.isEmpty {
Section("Scheduled") {
ForEach(scheduledAll) { s in
Button { 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 stable List, not a Section.
.fullScreenCover(item: $scheduledStartTarget) { s in
StartInspectionView(
preFillTemplateId: s.templateServerId,
preFillFacilityId: s.facilityServerId
)
}
}
}
@@ -63,7 +95,7 @@ struct MyInspectionsView: View {
}
}
}
.sheet(isPresented: $showNewInspection) {
.fullScreenCover(isPresented: $showNewInspection) {
StartInspectionView()
}
}
@@ -303,7 +335,7 @@ struct CompletedInspectionView: View {
}
.navigationTitle(templateName)
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showReInspect) {
.fullScreenCover(isPresented: $showReInspect) {
StartInspectionView(
preFillTemplateId: inspection.templateServerId,
preFillFacilityId: inspection.facilityServerId,
@@ -0,0 +1,113 @@
// Views/Dashboard/ScheduledInspectionsView.swift
// ----------------------------------------------
// Displays the inspector's planned/recurring inspection assignments (phase36),
// pulled read-only from GET /api/v1/scheduled-inspections by
// SyncManager.pullScheduledInspections().
//
// Two consumers share one ScheduledRow:
// ScheduledInspectionsCard VStack card for the Dashboard ScrollView
// MyInspectionsView renders its own "Scheduled" List section inline,
// reusing ScheduledRow, with the start cover attached to the List.
// Both self-hide when there are no scheduled inspections and present
// StartInspectionView (facility + template preselected) when a row is tapped.
// The schedule lifecycle (fulfil / roll-forward) stays server-driven; tapping
// "Start" simply seeds the normal new-inspection flow.
import SwiftUI
import SwiftData
// MARK: - Shared row
struct ScheduledRow: View {
let schedule: LocalScheduledInspection
private var dueText: String {
if let d = schedule.nextDue {
return d.formatted(date: .abbreviated, time: .omitted)
}
return schedule.dueDateString.isEmpty ? "" : schedule.dueDateString
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "calendar.badge.clock")
.font(.title3)
.foregroundStyle(schedule.isOverdue ? .red : .blue)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 3) {
Text(schedule.templateName.isEmpty ? "Inspection" : schedule.templateName)
.font(.callout.bold())
Text(schedule.facilityName.isEmpty ? "Facility" : schedule.facilityName)
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
if schedule.isOverdue {
Text("Overdue")
.font(.caption2.bold())
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.red.opacity(0.15))
.foregroundStyle(.red)
.clipShape(Capsule())
}
Text("Due \(dueText)")
.font(.caption2)
.foregroundStyle(schedule.isOverdue ? .red : .secondary)
if !schedule.frequencyLabel.isEmpty {
Text("· \(schedule.frequencyLabel)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
Spacer(minLength: 8)
Label("Start", systemImage: "play.fill")
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(schedule.isOverdue ? Color.red : Color.blue)
.clipShape(Capsule())
}
.contentShape(Rectangle())
}
}
// MARK: - Dashboard card (VStack)
struct ScheduledInspectionsCard: View {
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
private var scheduled: [LocalScheduledInspection]
@State private var startTarget: LocalScheduledInspection? = nil
var body: some View {
if !scheduled.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("SCHEDULED")
.font(.caption.bold())
.foregroundStyle(.secondary)
.tracking(1)
ForEach(scheduled) { s in
Button { startTarget = s } label: {
ScheduledRow(schedule: s)
.padding(12)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
// Cover attached to the stable VStack root (mirrors DraftResumeBanner).
.fullScreenCover(item: $startTarget) { s in
StartInspectionView(
preFillTemplateId: s.templateServerId,
preFillFacilityId: s.facilityServerId
)
}
}
}
}