Files
JQC_iOS_App/JanitorialQC/Views/Dashboard/IssuesView.swift
T

1337 lines
59 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
Button {
showNewIssue = true
} label: {
Label("New Issue", systemImage: "plus")
}
.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<LocalFacility>())) ?? []
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 ─────────────────────────────────────────────────
@State private var resultPhotos: [(image: UIImage, path: String)] = []
@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<LocalFacility>(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.
private var canUpdateStatus: Bool {
guard sync.isOnline, issue.serverId != nil else { return false }
let role = AuthManager.shared.currentUserRole
return role == "admin" || role == "director" || role == "inspector"
}
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 }
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
}
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))") {
ForEach(issue.resultPhotoServerPaths, id: \.self) { relativePath in
RetryablePhotoView(
url: URL(string: ServerConfig.current + "/static/" + relativePath)
)
}
}
}
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 img = UIImage(contentsOfFile: path) {
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))") {
ForEach(issue.photoServerPaths, id: \.self) { relativePath in
RetryablePhotoView(
url: URL(string: ServerConfig.current + "/static/" + relativePath)
)
}
}
}
}
// ── 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)
.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
}
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((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((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/<id>/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)
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 = ""
@State private var photos: [(image: UIImage, path: String)] = []
@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 }
/// Unique contracts derived from cached facilities, sorted by name.
private var contracts: [(id: Int, name: String)] {
var seen = Set<Int>()
var result: [(id: Int, name: String)] = []
for f in facilities {
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<Int>()
return facilities
.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<Int>(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<Int>(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)
.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((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((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
)
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()
}
}
}