// Views/Dashboard/IssuesView.swift import SwiftUI import SwiftData import MessageUI // MARK: - Facilities struct FacilitiesListView: View { @Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility] var body: some View { Group { if facilities.isEmpty { ContentUnavailableView("No Facilities", systemImage: "building.2.slash", description: Text("Connect to the internet to sync your assigned facilities.")) } else { List(facilities) { facility in VStack(alignment: .leading, spacing: 4) { Text(facility.name).font(.headline) if !facility.address.isEmpty { Text(facility.address).font(.caption).foregroundStyle(.secondary) } if facility.projectName != "No Contract" { Text(facility.projectName).font(.caption2).foregroundStyle(.blue) } Text("\(facility.areas.count) area\(facility.areas.count == 1 ? "" : "s")") .font(.caption2).foregroundStyle(.tertiary) } .padding(.vertical, 4) } } } .navigationTitle("Facilities (\(facilities.count))") } } // MARK: - Issues (Inspector) // Shows issues flagged by this inspector across all inspections. // Read-only list — tapping shows description and sync status detail. struct IssuesListView: View { // Fetch all then filter in Swift — #Predicate with string literals on // LocalIssue is unreliable under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION // (CLAUDE.md rule 3). Resolved issues are excluded to match the web default. @Query( sort: \LocalIssue.createdAt, order: .reverse ) private var allIssues: [LocalIssue] @Environment(\.modelContext) private var context @State private var showNewIssue = false @State private var searchText = "" @State private var severityFilter: String? = nil // nil = all private let severities = ["critical", "high", "medium", "low"] /// Date formatter shared for search matching. /// Formats to e.g. "Jun 19, 2026 4:55 PM" so inspectors can type /// partial strings: "jun", "2026", "19", "4:55" all match. private static let searchDateFmt: DateFormatter = { let f = DateFormatter() f.dateStyle = .medium f.timeStyle = .short return f }() private func dateString(for issue: LocalIssue) -> String { let d = issue.serverReportedAt ?? issue.createdAt return Self.searchDateFmt.string(from: d) } private var issues: [LocalIssue] { var list = allIssues.filter { $0.issueStatus != "resolved" } if let sev = severityFilter { list = list.filter { $0.severity == sev } } if !searchText.isEmpty { let q = searchText.lowercased() list = list.filter { // ID — match "#58" or bare "58" let idStr = $0.serverId.map { String($0) } ?? "" let idMatch = idStr == q || idStr == q.replacingOccurrences(of: "#", with: "") return idMatch || $0.issueDescription.lowercased().contains(q) || ($0.facilityNameCache?.lowercased().contains(q) ?? false) || ($0.areaNameCache?.lowercased().contains(q) ?? false) || ($0.assignedToName?.lowercased().contains(q) ?? false) || dateString(for: $0).lowercased().contains(q) } } return list } var body: some View { Group { if allIssues.filter({ $0.issueStatus != "resolved" }).isEmpty { ContentUnavailableView( "No Issues", systemImage: "exclamationmark.triangle", description: Text("Tap + to log a new issue, or flag one during an inspection.") ) } else if issues.isEmpty { ContentUnavailableView.search(text: searchText) } else { List(issues) { issue in NavigationLink(value: issue) { IssueRowView(issue: issue, context: context) } } } } .navigationTitle("Issues (\(issues.count))") .searchable(text: $searchText, prompt: "Search ID, date, description, facility…") .toolbar { ToolbarItemGroup(placement: .primaryAction) { // Severity filter Menu { Button { severityFilter = nil } label: { Label("All Severities", systemImage: severityFilter == nil ? "checkmark" : "line.3.horizontal.decrease") } Divider() ForEach(severities, id: \.self) { sev in Button { severityFilter = (severityFilter == sev) ? nil : sev } label: { Label(sev.capitalized, systemImage: severityFilter == sev ? "checkmark" : "circle") } } } label: { Image(systemName: severityFilter != nil ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") .foregroundStyle(severityFilter != nil ? .orange : .primary) } // New Issue — borderedProminent so it stands out clearly // from the filter icon and is easy to find at a glance. // `.titleAndIcon` is required: without it SwiftUI collapses the // Label to icon-only in a toolbar, so this rendered as a bare // "+" despite having a title in code. Button { showNewIssue = true } label: { Label("New Issue", systemImage: "plus") } .labelStyle(.titleAndIcon) .buttonStyle(.borderedProminent) } } .sheet(isPresented: $showNewIssue) { StandaloneIssueView() } } } struct IssueRowView: View { let issue: LocalIssue let context: ModelContext private var facilityName: String { // Primary: look up from local reference cache (fast, works offline). // Fallback: facilityNameCache persisted from the last server sync. // This covers the case where the user cleared the local cache in Settings // while server-pulled issues are still present. let id = issue.facilityServerId let all = (try? context.fetch(FetchDescriptor())) ?? [] if let name = all.first(where: { $0.serverId == id })?.name { return name } return issue.facilityNameCache ?? "Unknown Facility" } private var severityColor: Color { switch issue.severity { case "critical": return .red case "high": return .orange case "medium": return .yellow default: return .blue } } private func issueStatusColor(_ status: String) -> Color { switch status { case "open": return .blue case "in_progress": return .orange case "pending_verification": return .purple case "resolved": return .green default: return .secondary } } var body: some View { HStack(alignment: .top, spacing: 12) { Circle() .fill(severityColor) .frame(width: 10, height: 10) .padding(.top, 5) VStack(alignment: .leading, spacing: 3) { HStack { Text(issue.severity.capitalized) .font(.caption.bold()) .foregroundStyle(severityColor) Spacer() Text(issue.issueStatus.replacingOccurrences(of: "_", with: " ").capitalized) .font(.caption2) .padding(.horizontal, 6).padding(.vertical, 2) .background(issueStatusColor(issue.issueStatus).opacity(0.15)) .foregroundStyle(issueStatusColor(issue.issueStatus)) .clipShape(Capsule()) StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus) } Text(issue.issueDescription) .font(.callout) .lineLimit(2) Text(facilityName) .font(.caption) .foregroundStyle(.secondary) Text(issue.createdAt.formatted(date: .abbreviated, time: .shortened)) .font(.caption2) .foregroundStyle(.tertiary) } } .padding(.vertical, 4) } } struct IssueDetailView: View { let issue: LocalIssue @Environment(\.modelContext) private var context @EnvironmentObject private var sync: SyncManager @State private var isLoadingStatus = false @State private var isUpdatingStatus = false @State private var statusError: String? @State private var showStatusPicker = false // ── Resolution Photos ───────────────────────────────────────────────── // Resolution photos are evidence too — record capture time + GPS at shutter. @State private var resultPhotos: [CapturedPhoto] = [] @State private var showResultCamera = false @State private var showResultLibrary = false @State private var isUploadingResultPhotos = false @State private var resultPhotoError: String? @State private var resultPhotoSuccess = false private let maxResultPhotos = 5 // ── Comments ────────────────────────────────────────────────────────── @State private var comments: [APIIssueComment] = [] @State private var isLoadingComments = false @State private var newCommentText = "" @State private var isPostingComment = false @State private var commentError: String? // ── Email PDF ───────────────────────────────────────────────────────── @State private var isGeneratingPDF = false @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( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first?.name) ?? "Unknown Facility" } private var severityColor: Color { switch issue.severity { case "critical": return .red case "high": return .orange case "medium": return .yellow default: return .blue } } /// Inspector can update status only if the issue has synced (has a serverId) /// and we are online. Admins/directors can always update when online. /// /// Uses `Constants.Roles.issueActors` rather than a hand-written list: the /// old `role == "inspector"` check silently excluded Customer Inspectors /// (`external_inspector`), who the API has always accepted here — the /// Update Status control simply never appeared for them, with no error to /// explain why. private var canUpdateStatus: Bool { guard sync.isOnline, issue.serverId != nil else { return false } return Constants.Roles.issueActors.contains(AuthManager.shared.currentUserRole) } private let allStatuses: [(value: String, label: String, color: Color)] = [ ("open", "Open", .blue), ("in_progress", "In Progress", .orange), ("pending_verification", "Pending Verification", .purple), ("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 } return Constants.Roles.issueActors.contains(AuthManager.shared.currentUserRole) } 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 } private func statusLabel(for status: String) -> String { allStatuses.first { $0.value == status }?.label ?? status.replacingOccurrences(of: "_", with: " ").capitalized } var body: some View { List { Section("Issue Details") { LabeledContent("Severity") { Text(issue.severity.capitalized) .foregroundStyle(severityColor) .fontWeight(.semibold) } LabeledContent("Facility", value: facilityName) if let area = issue.areaNameCache, !area.isEmpty { LabeledContent("Area", value: area) } if let assignee = issue.assignedToName, !assignee.isEmpty { LabeledContent("Assigned To", value: assignee) } // Use serverReportedAt when available — more accurate than // createdAt (device time) for server-pulled issues. let reportDate = issue.serverReportedAt ?? issue.createdAt LabeledContent("Reported", value: reportDate.formatted( date: .long, time: .shortened)) if let reporter = issue.reportedByName, !reporter.isEmpty { LabeledContent("Reporter", value: reporter) } // ── Issue Status ─────────────────────────────────────────── LabeledContent("Issue Status") { HStack(spacing: 6) { if isLoadingStatus { ProgressView().scaleEffect(0.7) } else { Text(statusLabel(for: issue.issueStatus)) .foregroundStyle(statusColor(for: issue.issueStatus)) .fontWeight(.semibold) } } } // ── Status picker (online + synced only) ─────────────────── if canUpdateStatus { if isUpdatingStatus { HStack { ProgressView() Text("Updating…").foregroundStyle(.secondary).font(.callout) } } else { Picker("Change Status", selection: Binding( get: { issue.issueStatus }, set: { newStatus in Task { await changeStatus(to: newStatus) } } )) { ForEach(allStatuses, id: \.value) { s in Text(s.label).tag(s.value) } } .pickerStyle(.menu) .tint(statusColor(for: issue.issueStatus)) } } if let err = statusError { Text(err).font(.caption).foregroundStyle(.red) } } // ── 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 — // identical to the "Result Photos" upload on the web update form. if issue.issueStatus == "resolved", sync.isOnline, issue.serverId != nil { Section { // Thumbnail strip for staged photos if !resultPhotos.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(resultPhotos.indices, id: \.self) { i in ZStack(alignment: .topTrailing) { Image(uiImage: resultPhotos[i].image) .resizable() .scaledToFill() .frame(width: 90, height: 90) .clipShape(RoundedRectangle(cornerRadius: 8)) Button { removeResultPhoto(at: i) } label: { Image(systemName: "xmark.circle.fill") .font(.title3) .symbolRenderingMode(.palette) .foregroundStyle(.white, .black.opacity(0.7)) } .offset(x: 6, y: -6) } } } .padding(.vertical, 6) } } let remaining = maxResultPhotos - resultPhotos.count if remaining > 0 { let countLabel = resultPhotos.isEmpty ? "Up to \(maxResultPhotos) photos" : "\(resultPhotos.count)/\(maxResultPhotos) — \(remaining) remaining" Text(countLabel).font(.caption).foregroundStyle(.secondary) if UIImagePickerController.isSourceTypeAvailable(.camera) { Button { showResultCamera = true } label: { HStack { Image(systemName: "camera.fill").font(.title3).frame(width: 36) Text("Take Photo") Spacer() } .padding(.vertical, 8).contentShape(Rectangle()) } .foregroundStyle(.primary) } Button { showResultLibrary = true } label: { HStack { Image(systemName: "photo.on.rectangle.angled").font(.title3).frame(width: 36) Text("Choose from Library") Spacer() } .padding(.vertical, 8).contentShape(Rectangle()) } .foregroundStyle(.primary) } if let err = resultPhotoError { Text(err).font(.caption).foregroundStyle(.red) } if resultPhotoSuccess { Label("Photos uploaded successfully.", systemImage: "checkmark.circle.fill") .font(.caption).foregroundStyle(.green) } if !resultPhotos.isEmpty { Button { Task { await uploadAndAttachResultPhotos() } } label: { if isUploadingResultPhotos { HStack { ProgressView(); Text("Uploading…") } } else { Label("Upload Resolution Photos", systemImage: "arrow.up.circle.fill") .fontWeight(.semibold) } } .disabled(isUploadingResultPhotos) .buttonStyle(.borderedProminent) } } header: { Text("Add Resolution Photos") } footer: { if resultPhotos.isEmpty { Text("Attach photos showing the resolution (up to \(maxResultPhotos)).") .font(.caption) } else { Text("Tap × on a photo to remove it before uploading.") .font(.caption) } } } // ── Resolution Photos (server-side, read display) ────────────── if !issue.resultPhotoServerPaths.isEmpty { Section("Resolution Photos (\(issue.resultPhotoServerPaths.count))") { let paths = issue.resultPhotoServerPaths let urls = issue.resultPhotoServerUrls ForEach(paths.indices, id: \.self) { idx in RetryablePhotoView( url: ServerConfig.mediaURL( absolute: idx < urls.count ? urls[idx] : nil, path: paths[idx]) ) } } } Section("Description") { Text(issue.issueDescription) .font(.callout) } // ── Resolution Details (web-staff only — read-only on iPad) ──── if let notes = issue.resultNotes, !notes.isEmpty { Section("Resolution Notes") { Text(notes) .font(.callout) .foregroundStyle(.primary) } } // ── Verification Details ─────────────────────────────────────── if let vAt = issue.verifiedAt { Section("Verification") { LabeledContent("Verified", value: vAt.formatted( date: .long, time: .shortened)) if let note = issue.verificationNote, !note.isEmpty { Text(note) .font(.callout) .foregroundStyle(.secondary) } } } Section("Sync Status") { LabeledContent("Sync") { StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus) } if let err = issue.syncErrorMessage { Text(err).font(.caption).foregroundStyle(.red) } if issue.syncRetryCount > 0 { LabeledContent("Retry Count", value: "\(issue.syncRetryCount)") } } // ── Photo display logic ──────────────────────────────────────── // While the issue is pending (not yet submitted to the server), // show only local photos from disk — photoServerPaths may be // partially populated from mid-sync photo uploads, causing a mix // of working and broken images. Once synced, photoLocalPaths is // cleared and only the server paths section renders. if issue.syncStatus != "synced" { // Pending / failed: show local files only if !issue.photoLocalPaths.isEmpty { Section("Photos (\(issue.photoLocalPaths.count))") { ForEach(issue.photoLocalPaths, id: \.self) { path in if let live = PhotoStore.resolve(path), let img = UIImage(contentsOfFile: live) { Image(uiImage: img) .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 8)) } else { Label("Photo pending upload", systemImage: "photo") .foregroundStyle(.secondary) } } } } } else { // Synced: show server photos only if !issue.photoServerPaths.isEmpty { Section("Photos (\(issue.photoServerPaths.count))") { let paths = issue.photoServerPaths let urls = issue.photoServerUrls ForEach(paths.indices, id: \.self) { idx in RetryablePhotoView( url: ServerConfig.mediaURL( absolute: idx < urls.count ? urls[idx] : nil, path: paths[idx]) ) } } } } // ── Comments ────────────────────────────────────────────────── if sync.isOnline, issue.serverId != nil { if isLoadingComments { Section("Comments") { HStack { Spacer(); ProgressView(); Spacer() } } } else if !comments.isEmpty { Section("Comments (\(comments.count))") { ForEach(comments) { comment in VStack(alignment: .leading, spacing: 4) { HStack { Text(comment.authorName) .font(.caption.bold()) Spacer() if let date = comment.createdAtDate { Text(date.formatted(.relative(presentation: .named))) .font(.caption2) .foregroundStyle(.tertiary) } } Text(comment.body) .font(.callout) } .padding(.vertical, 2) } } } // ── Add comment ──────────────────────────────────────────── Section("Add Comment") { TextEditor(text: $newCommentText) .frame(minHeight: 60) if let err = commentError { Text(err).font(.caption).foregroundStyle(.red) } Button { Task { await postComment() } } label: { if isPostingComment { HStack { ProgressView(); Text("Posting…") } } else { Label("Post Comment", systemImage: "paperplane.fill") } } .disabled( newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment ) } } } .navigationTitle("Issue Detail") .navigationBarTitleDisplayMode(.inline) // Warm up GPS so resolution photos taken here carry coordinates. .onAppear { PhotoLocationProvider.shared.start() } .toolbar { ToolbarItem(placement: .primaryAction) { Button { Task { await prepareAndShowMail() } } label: { if isGeneratingPDF { ProgressView() } else { Label("Share via Email", systemImage: "envelope") } } .disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF) } } .sheet(isPresented: $showMailCompose) { if let pdfData = generatedPDFData { MailComposeView( subject: "Issue Report — Issue #\(issue.serverId ?? 0) (\(facilityName))", body: issueEmailBody, pdfData: pdfData, pdfFilename: "Issue_\(issue.serverId ?? 0)_\(facilityName).pdf" .replacingOccurrences(of: " ", with: "_") ) } } .fullScreenCover(isPresented: $showResultCamera) { CameraPickerView(image: .constant(nil), onSelected: appendResultPhoto) .ignoresSafeArea() } .sheet(isPresented: $showResultLibrary) { MultiLibraryPickerView( selectionLimit: maxResultPhotos - resultPhotos.count, onSelected: appendResultPhotos ) } .task { await refreshStatusFromServer() await loadComments() } } /// Generates the issue PDF (re-encoding photos to keep file size minimal, /// fetching server photos over the network if synced), then presents the /// mail compose sheet with it attached. private func prepareAndShowMail() async { isGeneratingPDF = true let data = await IssuePDFGenerator.generate(issue: issue, facilityName: facilityName) generatedPDFData = data isGeneratingPDF = false showMailCompose = true } private var issueEmailBody: String { var lines: [String] = [] lines.append("ISSUE REPORT — Issue #\(issue.serverId ?? 0)") lines.append(String(repeating: "=", count: 40)) lines.append("") lines.append("Severity : \(issue.severity.capitalized)") lines.append("Status : \(statusLabel(for: issue.issueStatus))") lines.append("Facility : \(facilityName)") if let area = issue.areaNameCache, !area.isEmpty { lines.append("Area : \(area)") } if let assignee = issue.assignedToName, !assignee.isEmpty { lines.append("Assigned : \(assignee)") } let reportDate = issue.serverReportedAt ?? issue.createdAt lines.append("Reported : \(reportDate.formatted(date: .long, time: .shortened))") lines.append("") lines.append("DESCRIPTION") lines.append(String(repeating: "-", count: 40)) lines.append(issue.issueDescription) lines.append("") lines.append("— Sent from JanitorialQC Inspector") return lines.joined(separator: "\n") } // ── Fetch fresh status from server ──────────────────────────────────── private func refreshStatusFromServer() async { guard sync.isOnline, let sid = issue.serverId else { return } isLoadingStatus = true statusError = nil defer { isLoadingStatus = false } do { let detail = try await APIClient.shared.fetchIssueDetail(issueId: sid) issue.issueStatus = detail.status // Refresh Phase A resolution fields from live server data if let notes = detail.resultNotes { issue.resultNotes = notes } if let vNote = detail.verificationNote { issue.verificationNote = vNote } if let rName = detail.reportedByName { issue.reportedByName = rName } if let fName = detail.facilityName, !fName.isEmpty { issue.facilityNameCache = fName } if let vts = detail.verifiedAt, let date = SyncManager.isoFormatter.date(from: vts) { issue.verifiedAt = date } 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 issue.resultPhotoServerUrls = detail.resultPhotoUrls } try? context.save() } catch { // Non-fatal — show cached values silently } } // ── Load comments from server ────────────────────────────────────────── private func loadComments() async { guard sync.isOnline, let sid = issue.serverId else { return } isLoadingComments = true defer { isLoadingComments = false } do { comments = try await APIClient.shared.fetchIssueComments(issueId: sid) } catch { // Non-fatal — empty list shown } } // ── Post a new comment ───────────────────────────────────────────────── private func postComment() async { guard let sid = issue.serverId else { return } let body = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines) guard !body.isEmpty else { return } isPostingComment = true commentError = nil defer { isPostingComment = false } do { _ = try await APIClient.shared.postIssueComment(issueId: sid, body: body) newCommentText = "" // Reload comments so the new one appears comments = try await APIClient.shared.fetchIssueComments(issueId: sid) } catch { commentError = error.localizedDescription } } // ── Push status change to server ────────────────────────────────────── private func changeStatus(to newStatus: String) async { guard let sid = issue.serverId else { return } isUpdatingStatus = true statusError = nil defer { isUpdatingStatus = false } do { let confirmed = try await APIClient.shared.updateIssueStatus( issueId: sid, status: newStatus ) issue.issueStatus = confirmed try? context.save() } catch { statusError = error.localizedDescription } } // ── 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) { guard resultPhotos.count < maxResultPhotos, let path = saveResultPhotoToDisk(img) else { return } resultPhotos.append(CapturedPhoto(image: img, path: path)) } private func appendResultPhotos(_ images: [UIImage]) { for img in images { guard resultPhotos.count < maxResultPhotos, let path = saveResultPhotoToDisk(img) else { break } resultPhotos.append(CapturedPhoto(image: img, path: path)) } } private func removeResultPhoto(at index: Int) { guard index < resultPhotos.count else { return } try? FileManager.default.removeItem(atPath: resultPhotos[index].path) resultPhotos.remove(at: index) } private func saveResultPhotoToDisk(_ img: UIImage) -> String? { guard let data = img.jpegData(compressionQuality: 0.8) else { return nil } let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let photosDir = docsDir.appendingPathComponent("JQC/ResultPhotos", isDirectory: true) try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true) let fileURL = photosDir.appendingPathComponent("\(UUID().uuidString).jpg") try? data.write(to: fileURL) return fileURL.path } // ── Upload resolution photos and PATCH to server ────────────────────── // 1. Uploads each staged photo via /api/v1/photos/upload (entity_type=issue_result) // 2. PATCHes /api/v1/issues//result_photos with the returned server paths // 3. Appends to issue.resultPhotoServerPaths so the display section updates // 4. Clears the staged resultPhotos array and deletes temp files private func uploadAndAttachResultPhotos() async { guard let sid = issue.serverId, !resultPhotos.isEmpty else { return } isUploadingResultPhotos = true resultPhotoError = nil resultPhotoSuccess = false defer { isUploadingResultPhotos = false } do { var serverPaths: [String] = [] for photo in resultPhotos { let path = try await APIClient.shared.uploadResultPhoto( localPath: photo.path, capturedAt: photo.capturedAt, latitude: photo.latitude, longitude: photo.longitude ) serverPaths.append(path) } try await APIClient.shared.updateIssueResultPhotos(issueId: sid, resultPhotos: serverPaths) // Append to local cache so display section updates immediately issue.resultPhotoServerPaths = issue.resultPhotoServerPaths + serverPaths try? context.save() // Clean up temp files and clear staging for photo in resultPhotos { try? FileManager.default.removeItem(atPath: photo.path) } resultPhotos = [] resultPhotoSuccess = true } catch { resultPhotoError = error.localizedDescription } } } // MARK: - Standalone Issue Creation // Allows inspectors to log an issue directly from the Issues page, // without being inside an active inspection. The issue is created with // inspectionLocalId == "" and synced to the server via processIssueQueue. struct StandaloneIssueView: View { @Environment(\.modelContext) private var context @Environment(\.dismiss) private var dismiss @EnvironmentObject private var sync: SyncManager @Query(sort: \LocalFacility.name) private var facilities: [LocalFacility] // ── Contract → Facility cascade (mirrors StartInspectionView) ───────── @State private var selectedProjectId: Int? = nil @State private var selectedFacilityId: Int? = nil @State private var severity = "medium" @State private var description = "" // CapturedPhoto records the capture moment + GPS fix at shutter time; the // `image` / `path` members match the tuple this replaced. @State private var photos: [CapturedPhoto] = [] @State private var showCamera = false @State private var showLibrary = false @State private var showBanner = false private let maxPhotos = 5 private let severities = ["low", "medium", "high", "critical"] private var cameraAvailable: Bool { UIImagePickerController.isSourceTypeAvailable(.camera) } private var remainingSlots: Int { maxPhotos - photos.count } /// Facilities this user may file a new issue against. /// Excludes rows SyncManager retained purely so an unsynced draft could /// still show its facility name — see StartInspectionView for the full /// explanation. Out-of-scope facilities must not be offered for new work. private var availableFacilities: [LocalFacility] { facilities.filter { $0.isActive } } /// Unique contracts derived from cached facilities, sorted by name. private var contracts: [(id: Int, name: String)] { var seen = Set() var result: [(id: Int, name: String)] = [] for f in availableFacilities { if seen.insert(f.projectId).inserted { result.append((id: f.projectId, name: f.projectName)) } } return result.sorted { $0.name < $1.name } } /// Facilities belonging to the selected contract. /// Facilities for the selected contract, deduplicated by serverId. private var filteredFacilities: [LocalFacility] { guard let pid = selectedProjectId else { return [] } var seen = Set() return availableFacilities .filter { $0.projectId == pid } .filter { seen.insert($0.serverId).inserted } } private var canSubmit: Bool { selectedFacilityId != nil && !description.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { NavigationStack { Form { // ── Contract picker ──────────────────────────────────────── Section("Contract") { if contracts.isEmpty { Text("No contracts available. Sync required.") .foregroundStyle(.secondary).font(.callout) } else { Picker("Contract", selection: $selectedProjectId) { Text("Select a contract…").tag(Optional(nil)) ForEach(contracts, id: \.id) { contract in Text(contract.name).tag(Optional(contract.id)) } } .pickerStyle(.navigationLink) .onChange(of: selectedProjectId) { // Reset facility when contract changes let facilityBelongsToContract = facilities.contains { $0.serverId == selectedFacilityId && $0.projectId == selectedProjectId } if !facilityBelongsToContract { selectedFacilityId = nil } } } } // ── Facility picker (gated on contract selection) ────────── if selectedProjectId != nil { Section("Facility") { if filteredFacilities.isEmpty { Text("No facilities in this contract.") .foregroundStyle(.secondary).font(.callout) } else { Picker("Facility", selection: $selectedFacilityId) { Text("Select a facility…").tag(Optional(nil)) ForEach(filteredFacilities) { facility in Text(facility.name).tag(Optional(facility.serverId)) } } .pickerStyle(.navigationLink) } } } // ── Severity ─────────────────────────────────────────────── Section("Severity") { Picker("Severity", selection: $severity) { ForEach(severities, id: \.self) { s in Text(s.capitalized).tag(s) } } .pickerStyle(.segmented) } // ── Description ──────────────────────────────────────────── Section("Description") { TextEditor(text: $description) .frame(minHeight: 100) } // ── Photos ───────────────────────────────────────────────── Section { if !photos.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(photos.indices, id: \.self) { i in ZStack(alignment: .topTrailing) { Image(uiImage: photos[i].image) .resizable() .scaledToFill() .frame(width: 100, height: 100) .clipShape(RoundedRectangle(cornerRadius: 10)) Button { removePhoto(at: i) } label: { Image(systemName: "xmark.circle.fill") .font(.title3) .symbolRenderingMode(.palette) .foregroundStyle(.white, .black.opacity(0.7)) } .offset(x: 6, y: -6) } } } .padding(.vertical, 6) } } if remainingSlots > 0 { let countLabel = photos.isEmpty ? "Up to \(maxPhotos) photos" : "\(photos.count)/\(maxPhotos) — \(remainingSlots) remaining" Text(countLabel).font(.caption).foregroundStyle(.secondary) if cameraAvailable { Button { showCamera = true } label: { HStack { Image(systemName: "camera.fill").font(.title3).frame(width: 36) Text("Take Photo") Spacer() } .padding(.vertical, 10).contentShape(Rectangle()) } .foregroundStyle(.primary) } Button { showLibrary = true } label: { HStack { Image(systemName: "photo.on.rectangle.angled").font(.title3).frame(width: 36) Text("Choose from Library") Spacer() } .padding(.vertical, 10).contentShape(Rectangle()) } .foregroundStyle(.primary) } } header: { Text("Photos (Optional)") } footer: { if !photos.isEmpty { Text("Tap × on a photo to remove it.").font(.caption) } } // ── Offline notice ───────────────────────────────────────── if !sync.isOnline { Section { Label("You\'re offline — this issue will sync automatically.", systemImage: "wifi.slash") .font(.callout).foregroundStyle(.orange) } } } .navigationTitle("New Issue") .navigationBarTitleDisplayMode(.inline) // Warm up GPS so a fix exists the instant a photo is taken; the // coordinates are burned into the photo server-side. .onAppear { PhotoLocationProvider.shared.start() } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Submit") { submitIssue() } .disabled(!canSubmit) .fontWeight(.semibold) } } .overlay(alignment: .top) { if showBanner { HStack(spacing: 12) { Image(systemName: "checkmark.circle.fill") .font(.title2).foregroundStyle(.green) VStack(alignment: .leading, spacing: 2) { Text("Issue Logged").font(.headline) Text(sync.isOnline ? "Submitted to server." : "Saved — will sync when online.") .font(.caption).foregroundStyle(.secondary) } Spacer() } .padding(16) .background(Color(.secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(0.1), radius: 8, y: 4) .padding(.horizontal, 24).padding(.top, 8) .transition(.move(edge: .top).combined(with: .opacity)) .zIndex(10) } } .animation(.spring(duration: 0.35), value: showBanner) .fullScreenCover(isPresented: $showCamera) { CameraPickerView(image: .constant(nil), onSelected: appendPhoto) .ignoresSafeArea() } .sheet(isPresented: $showLibrary) { MultiLibraryPickerView(selectionLimit: remainingSlots, onSelected: appendPhotos) } } } // ── Photo helpers ───────────────────────────────────────────────────── private func appendPhoto(_ img: UIImage) { guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { return } photos.append(CapturedPhoto(image: img, path: path)) } private func appendPhotos(_ images: [UIImage]) { for img in images { guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { break } photos.append(CapturedPhoto(image: img, path: path)) } } private func removePhoto(at index: Int) { guard index < photos.count else { return } try? FileManager.default.removeItem(atPath: photos[index].path) photos.remove(at: index) } private func savePhotoToDisk(_ img: UIImage) -> String? { guard let data = img.jpegData(compressionQuality: 0.8) else { return nil } let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true) try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true) let fileURL = photosDir.appendingPathComponent("\(UUID().uuidString).jpg") try? data.write(to: fileURL) return fileURL.path } // ── Submit ──────────────────────────────────────────────────────────── private func submitIssue() { guard let facilityId = selectedFacilityId else { return } let issue = LocalIssue( inspectionLocalId: "", // standalone — not tied to any inspection facilityServerId: facilityId, severity: severity, description: description.trimmingCharacters(in: .whitespaces) ) issue.photoLocalPaths = photos.map(\.path) context.insert(issue) for photo in photos { let pending = PendingPhoto( localFilePath: photo.path, entityType: "issue", entityLocalId: issue.localId, capturedAt: photo.capturedAt, captureLatitude: photo.latitude, captureLongitude: photo.longitude ) context.insert(pending) } try? context.save() if sync.isOnline { Task { await sync.triggerSync() } } withAnimation { showBanner = true } Task { try? await Task.sleep(for: .seconds(2)) dismiss() } } }