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