Jul 13 - Update codes to catch up with the web app updates: scheduled inspection and issue's handler
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user