06/22 Fix Low impact items
This commit is contained in:
@@ -0,0 +1,929 @@
|
||||
// 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
|
||||
// ── 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
|
||||
|
||||
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),
|
||||
]
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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: "_")
|
||||
)
|
||||
}
|
||||
}
|
||||
.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 }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user