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
@@ -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) {