From 20f9a99646d005995f039abf283f4b309011d9e2 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 13 Jul 2026 14:03:41 -0400 Subject: [PATCH] Jul 13 - Update codes to catch up with the web app updates: scheduled inspection and issue's handler --- JanitorialQC.xcodeproj/project.pbxproj | 4 +- JanitorialQC/API/APIClient.swift | 27 + JanitorialQC/API/APIModels.swift | 103 ++ .../AppIcon.appiconset/API/APIClient.swift | 566 +++++++ .../AppIcon.appiconset/API/APIModels.swift | 767 ++++++++++ .../AppIcon.appiconset/JanitorialQCApp.swift | 182 +++ .../Models/LocalIssue.swift | 228 +++ .../Models/LocalScheduledInspection.swift | 47 + .../AppIcon.appiconset/Sync/SyncManager.swift | 903 +++++++++++ .../Views/Dashboard/DashboardView.swift | 644 ++++++++ .../Views/Dashboard/IssuesView.swift | 1336 +++++++++++++++++ .../Views/Dashboard/MyInspectionsView.swift | 348 +++++ .../Dashboard/ScheduledInspectionsView.swift | 113 ++ JanitorialQC/JanitorialQCApp.swift | 1 + JanitorialQC/Models/LocalIssue.swift | 23 + .../Models/LocalScheduledInspection.swift | 47 + JanitorialQC/Sync/SyncManager.swift | 78 + .../Views/Dashboard/DashboardView.swift | 8 +- .../Dashboard/ExecuteInspectionView.swift | 14 + JanitorialQC/Views/Dashboard/IssuesView.swift | 204 +++ .../Views/Dashboard/MyInspectionsView.swift | 62 +- .../Dashboard/ScheduledInspectionsView.swift | 113 ++ 22 files changed, 5800 insertions(+), 18 deletions(-) create mode 100644 JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIClient.swift create mode 100644 JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIModels.swift create mode 100644 JanitorialQC/Assets.xcassets/AppIcon.appiconset/JanitorialQCApp.swift create mode 100644 JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalIssue.swift create mode 100644 JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalScheduledInspection.swift create mode 100644 JanitorialQC/Assets.xcassets/AppIcon.appiconset/Sync/SyncManager.swift create mode 100644 JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/DashboardView.swift create mode 100644 JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/IssuesView.swift create mode 100644 JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/MyInspectionsView.swift create mode 100644 JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/ScheduledInspectionsView.swift create mode 100644 JanitorialQC/Models/LocalScheduledInspection.swift create mode 100644 JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index 18e907f..0ec4978 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -432,7 +432,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -476,7 +476,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index b9caa51..d1d9285 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -367,6 +367,33 @@ actor APIClient { return result.issues } + // ── Scheduled Inspections (phase36) ─────────────────────────────────── + + func fetchScheduledInspections() async throws -> [APIScheduledInspection] { + let result: APIScheduledInspectionsResponseData = + try await request("/api/v1/scheduled-inspections") + return result.scheduled + } + + // ── Issue Handler ("Handled By") ────────────────────────────────────── + + /// Set who handles an issue. `details` carries any of the optional + /// facility_handler_* / vendor_* fields; only keys present are updated. + func updateIssueHandler( + issueId: Int, + handlerType: String, + details: [String: String] = [:] + ) async throws -> String { + var body: [String: Any] = ["handler_type": handlerType] + for (k, v) in details { body[k] = v } + let result: APIIssueHandlerUpdate = try await request( + "/api/v1/issues/\(issueId)/handler", + method: "PATCH", + body: body + ) + return result.handlerType + } + /// Fetch dashboard KPI counts for the current user (Phase B). func fetchDashboardStats() async throws -> APIDashboardStats { return try await request("/api/v1/stats/dashboard") diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index 74b85b3..e2c954a 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -408,6 +408,15 @@ struct APIIssueDetail: Decodable, Sendable { // Phase E — area and assignee context let areaName: String? let assignedToName: String? + // Handler ("Handled By", phase35) + let handlerType: String? + let handlerLabel: String? + let facilityHandlerName: String? + let facilityHandlerContact: String? + let facilityHandlerNotes: String? + let vendorName: String? + let vendorContact: String? + let vendorNotes: String? nonisolated init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -427,12 +436,23 @@ struct APIIssueDetail: Decodable, Sendable { resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] areaName = try? c.decode(String.self, forKey: .areaName) assignedToName = try? c.decode(String.self, forKey: .assignedToName) + handlerType = try? c.decode(String.self, forKey: .handlerType) + handlerLabel = try? c.decode(String.self, forKey: .handlerLabel) + facilityHandlerName = try? c.decode(String.self, forKey: .facilityHandlerName) + facilityHandlerContact = try? c.decode(String.self, forKey: .facilityHandlerContact) + facilityHandlerNotes = try? c.decode(String.self, forKey: .facilityHandlerNotes) + vendorName = try? c.decode(String.self, forKey: .vendorName) + vendorContact = try? c.decode(String.self, forKey: .vendorContact) + vendorNotes = try? c.decode(String.self, forKey: .vendorNotes) } private enum CodingKeys: String, CodingKey { case id, status, severity, description, assignedTo case facilityId, facilityName, reportedAt, resolvedAt case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos case areaName, assignedToName + case handlerType, handlerLabel + case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes + case vendorName, vendorContact, vendorNotes } } @@ -448,6 +468,18 @@ struct APIIssueStatusUpdate: Decodable, Sendable { private enum CodingKeys: String, CodingKey { case issueId, status } } +struct APIIssueHandlerUpdate: Decodable, Sendable { + let issueId: Int + let handlerType: String + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + issueId = try c.decode(Int.self, forKey: .issueId) + handlerType = try c.decode(String.self, forKey: .handlerType) + } + private enum CodingKeys: String, CodingKey { case issueId, handlerType } +} + // ── Notification polling (Phase C) ──────────────────────────────────────────── struct APINotification: Decodable, Identifiable, Sendable { @@ -517,6 +549,15 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable { // Phase E — area and assignee context let areaName: String? let assignedToName: String? + // Handler ("Handled By", phase35) — who resolves the issue + let handlerType: String? // "internal" | "facility" | "vendor" + let handlerLabel: String? // human-readable label + let facilityHandlerName: String? + let facilityHandlerContact: String? + let facilityHandlerNotes: String? + let vendorName: String? + let vendorContact: String? + let vendorNotes: String? nonisolated init(from decoder: any Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -538,6 +579,14 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable { reportedByName = try? c.decode(String.self, forKey: .reportedByName) areaName = try? c.decode(String.self, forKey: .areaName) assignedToName = try? c.decode(String.self, forKey: .assignedToName) + handlerType = try? c.decode(String.self, forKey: .handlerType) + handlerLabel = try? c.decode(String.self, forKey: .handlerLabel) + facilityHandlerName = try? c.decode(String.self, forKey: .facilityHandlerName) + facilityHandlerContact = try? c.decode(String.self, forKey: .facilityHandlerContact) + facilityHandlerNotes = try? c.decode(String.self, forKey: .facilityHandlerNotes) + vendorName = try? c.decode(String.self, forKey: .vendorName) + vendorContact = try? c.decode(String.self, forKey: .vendorContact) + vendorNotes = try? c.decode(String.self, forKey: .vendorNotes) } private enum CodingKeys: String, CodingKey { case id, status, severity, description, assignedTo @@ -545,6 +594,9 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable { case photoPath, mobilePhotoPaths, resultPhotos case resultNotes, verifiedAt, verificationNote, reportedByName case areaName, assignedToName + case handlerType, handlerLabel + case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes + case vendorName, vendorContact, vendorNotes } } @@ -564,6 +616,57 @@ struct APIAssignedIssuesResponseData: Decodable, Sendable { private enum CodingKeys: String, CodingKey { case issues, total, limit, offset } } +// ── Scheduled Inspections (phase36 — planned/recurring assignments) ─────────── + +struct APIScheduledInspection: Decodable, Identifiable, Sendable { + let id: Int + let facilityId: Int + let facilityName: String? + let templateId: Int + let templateName: String? + let inspectorId: Int? + let frequency: String + let frequencyLabel: String? + let nextDueDate: String? // ISO date "YYYY-MM-DD" + let isOverdue: Bool + let notes: String? + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + facilityId = try c.decode(Int.self, forKey: .facilityId) + facilityName = try? c.decode(String.self, forKey: .facilityName) + templateId = try c.decode(Int.self, forKey: .templateId) + templateName = try? c.decode(String.self, forKey: .templateName) + inspectorId = try? c.decode(Int.self, forKey: .inspectorId) + frequency = (try? c.decode(String.self, forKey: .frequency)) ?? "once" + frequencyLabel = try? c.decode(String.self, forKey: .frequencyLabel) + nextDueDate = try? c.decode(String.self, forKey: .nextDueDate) + isOverdue = (try? c.decode(Bool.self, forKey: .isOverdue)) ?? false + notes = try? c.decode(String.self, forKey: .notes) + } + private enum CodingKeys: String, CodingKey { + case id, facilityId, facilityName, templateId, templateName + case inspectorId, frequency, frequencyLabel, nextDueDate, isOverdue, notes + } +} + +struct APIScheduledInspectionsResponseData: Decodable, Sendable { + let scheduled: [APIScheduledInspection] + let total: Int + let limit: Int + let offset: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + scheduled = try c.decode([APIScheduledInspection].self, forKey: .scheduled) + total = try c.decode(Int.self, forKey: .total) + limit = try c.decode(Int.self, forKey: .limit) + offset = try c.decode(Int.self, forKey: .offset) + } + private enum CodingKeys: String, CodingKey { case scheduled, total, limit, offset } +} + // ── Dashboard Stats (Phase B) ───────────────────────────────────────────────── struct APIDashboardStats: Decodable, Sendable { diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIClient.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIClient.swift new file mode 100644 index 0000000..d1d9285 --- /dev/null +++ b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIClient.swift @@ -0,0 +1,566 @@ +// API/APIClient.swift + +import Foundation +import Combine +import UIKit + +enum APIError: Error, LocalizedError, Sendable { + case invalidURL + case notAuthenticated + case serverError(String) + case decodingError(String) + case networkError(String) + + var errorDescription: String? { + switch self { + case .invalidURL: return "Invalid URL." + case .notAuthenticated: return "Session expired. Please log in again." + case .serverError(let msg): return msg + case .decodingError(let msg): return "Data error: \(msg)" + case .networkError(let msg): return "Network error: \(msg)" + } + } +} + +// File-scope envelope — cannot be nested in a generic function (Swift restriction). +// T is constrained to Sendable so `data: T?` does not inherit @MainActor isolation. +private struct _Envelope: Decodable, Sendable { + let ok: Bool + let data: T? + let error: String? + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + ok = try c.decode(Bool.self, forKey: .ok) + data = try? c.decode(T.self, forKey: .data) + error = try? c.decode(String.self, forKey: .error) + } + private enum CodingKeys: String, CodingKey { case ok, data, error } +} + +// Free function removed — see refreshAccessToken() which decodes using a +// local JSONDecoder to avoid Swift 6 actor-isolation errors. + +// Refresh-only envelope — Sendable so it can cross actor boundaries in Swift 6. +// nonisolated init(from:) required on both types: without it the Swift 6 compiler +// infers @MainActor isolation on the Decodable conformance from the surrounding +// file context, producing "cannot be used in actor-isolated context" errors. +private struct _RefreshEnvelope: Decodable, Sendable { + struct Tokens: Decodable, Sendable { + let accessToken: String + let refreshToken: String + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + accessToken = try c.decode(String.self, forKey: .accessToken) + refreshToken = try c.decode(String.self, forKey: .refreshToken) + } + private enum CodingKeys: String, CodingKey { case accessToken, refreshToken } + } + let ok: Bool + let data: Tokens? + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + ok = try c.decode(Bool.self, forKey: .ok) + data = try? c.decode(Tokens.self, forKey: .data) + } + private enum CodingKeys: String, CodingKey { case ok, data } +} + +actor APIClient { + static let shared = APIClient() + + private let session: URLSession + private let decoder: JSONDecoder + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + self.session = URLSession(configuration: config) + self.decoder = JSONDecoder() + self.decoder.keyDecodingStrategy = .convertFromSnakeCase + } + + // ── Generic JSON Request ────────────────────────────────────────────── + + func request( + _ endpoint: String, + method: String = "GET", + body: [String: Any]? = nil, + retrying: Bool = false + ) async throws -> T { + let url = try buildURL(endpoint) + var req = buildRequest(url: url, method: method, body: body) + injectToken(&req) + + let (data, response) = try await performRequest(req) + + if shouldRefresh(response, retrying: retrying) { + let refreshed = await refreshAccessToken() + if refreshed { + return try await request(endpoint, method: method, body: body, retrying: true) + } + throw APIError.notAuthenticated + } + + return try decode(data) + } + + func post(_ endpoint: String, body: [String: Any]) async throws -> T { + return try await request(endpoint, method: "POST", body: body) + } + + // ── Inspection History ──────────────────────────────────────────────── + + func fetchInspectionHistory( + limit: Int = 50, + offset: Int = 0, + facilityId: Int? = nil, + fromDate: Date? = nil, + toDate: Date? = nil + ) async throws -> InspectionHistoryResponseData { + var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed" + if let fid = facilityId { ep += "&facility_id=\(fid)" } + if let d = fromDate { ep += "&from_date=\(Self.apiDateFmt.string(from: d))" } + if let d = toDate { ep += "&to_date=\(Self.apiDateFmt.string(from: d))" } + return try await request(ep) + } + + private static let apiDateFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + // ── Photo Upload ────────────────────────────────────────────────────── + + func uploadPhoto(localPath: String, entityType: String, retrying: Bool = false) async throws -> String { + let url = try buildURL("/api/v1/photos/upload") + + guard let imageData = FileManager.default.contents(atPath: localPath) else { + throw APIError.networkError("Could not read photo: \(localPath)") + } + + let boundary = "Boundary-\(UUID().uuidString)" + let filename = URL(fileURLWithPath: localPath).lastPathComponent + let ext = (filename as NSString).pathExtension.lowercased() + let mime = ext == "png" ? "image/png" : "image/jpeg" + + var body = Data() + body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\n\(entityType)\r\n".data(using: .utf8)!) + body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!) + body.append(imageData) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + injectToken(&req) + req.httpBody = body + + let (data, response) = try await performRequest(req) + + if shouldRefresh(response, retrying: retrying) { + let refreshed = await refreshAccessToken() + if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType, retrying: true) } + throw APIError.notAuthenticated + } + + struct PhotoResult: Decodable, Sendable { let serverPath: String } + if let env = try? decoder.decode(_Envelope.self, from: data), + env.ok, let r = env.data { return r.serverPath } + throw APIError.serverError("Photo upload failed") + } + + // ── Submit Inspection ───────────────────────────────────────────────── + + func submitInspection(_ inspection: LocalInspection) async throws -> Int { + // Sanitise form_data: replace any field values that are still a local + // file reference ("local://...") with an empty string. This happens + // when a photo upload failed but the inspection was submitted anyway. + // JSONSerialization silently drops non-serialisable values, so without + // this guard the server would receive a dict missing those fields + // entirely — worse than receiving an empty string. + var sanitisedFormData: [String: Any] = [:] + for (k, v) in inspection.formData { + if let s = v as? String, s.hasPrefix("local://") { + sanitisedFormData[k] = "" // upload failed; clear the field + } else { + sanitisedFormData[k] = v + } + } + + var body: [String: Any] = [ + "template_id": inspection.templateServerId, + "facility_id": inspection.facilityServerId, + "status": "completed", + "form_data": sanitisedFormData, + "mobile_local_id": inspection.localId, + ] + if let score = inspection.overallScore { body["overall_score"] = score } + if let areaId = inspection.areaServerId { body["area_id"] = areaId } + if let parentId = inspection.parentServerId { body["parent_inspection_id"] = parentId } + if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes } + if let lat = inspection.submitLatitude { body["submit_latitude"] = lat } + if let lng = inspection.submitLongitude { body["submit_longitude"] = lng } + + // IMPORTANT: timeZone must be explicitly set to UTC. + // ISO8601DateFormatter() default timeZone is the DEVICE local timezone, + // which produces offset strings like "2026-06-11T10:30:00-04:00". + // The server's _parse_datetime() only recognises the Z suffix as UTC; + // offset-format strings fail all strptime patterns and return None, + // causing the server to fall back to now_eastern() — the sync time — + // instead of the actual inspection/completion time. + let fmt = ISO8601DateFormatter() + fmt.timeZone = TimeZone(identifier: "UTC")! + body["inspection_date"] = fmt.string(from: inspection.inspectionDate) + if let c = inspection.completedAt { body["completed_at"] = fmt.string(from: c) } + + struct R: Decodable, Sendable { let inspectionId: Int; let duplicate: Bool } + let r: R = try await post("/api/v1/inspections", body: body) + return r.inspectionId + } + + // ── Submit Issue ────────────────────────────────────────────────────── + + func submitIssue(_ issue: LocalIssue, inspectionServerId: Int?) async throws -> Int { + var body: [String: Any] = [ + "facility_id": issue.facilityServerId, + "severity": issue.severity, + "description": issue.issueDescription, + "mobile_local_id": issue.localId, + ] + // Use the explicitly passed serverId rather than issue.inspection?.serverId. + // The ORM relationship object is a separate fetch instance from the one + // processInspectionQueue updated, so its serverId is nil even after the + // inspection synced in the same triggerSync() pass. + if let id = inspectionServerId { body["inspection_id"] = id } + if let areaId = issue.areaServerId { body["area_id"] = areaId } + // photo_path = primary photo. Additional photos are sent via a + // separate PATCH call in processIssueQueue after the issue is created, + // because the server create endpoint only stores a single photo_path. + if let first = issue.photoServerPaths.first { body["photo_path"] = first } + + struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool } + let r: R = try await post("/api/v1/issues", body: body) + return r.issueId + } + + // ── Attach additional photos to an existing issue ───────────────────── + // Called after submitIssue when the issue has more than one photo. + // PATCHes /api/v1/issues/{id}/photos with result_photos = [server paths beyond the first]. + // The create endpoint only stores photo_path (single); extras go here. + func updateIssuePhotos(issueId: Int, resultPhotos: [String]) async throws { + struct R: Decodable, Sendable { let issueId: Int; let resultPhotosCount: Int } + let _: R = try await request( + "/api/v1/issues/\(issueId)/photos", + method: "PATCH", + body: ["result_photos": resultPhotos] + ) + } + + // ── Upload a resolution photo (entity_type = issue_result) ──────────── + // Saves to issue_result_photos subfolder on the server — same bucket + // as photos uploaded via the web update form. + func uploadResultPhoto(localPath: String, retrying: Bool = false) async throws -> String { + let url = try buildURL("/api/v1/photos/upload") + + guard let imageData = FileManager.default.contents(atPath: localPath) else { + throw APIError.networkError("Could not read photo: \(localPath)") + } + + let boundary = "Boundary-\(UUID().uuidString)" + let filename = URL(fileURLWithPath: localPath).lastPathComponent + let ext = (filename as NSString).pathExtension.lowercased() + let mime = ext == "png" ? "image/png" : "image/jpeg" + + var body = Data() + body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\nissue_result\r\n".data(using: .utf8)!) + body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!) + body.append(imageData) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + injectToken(&req) + req.httpBody = body + + let (data, response) = try await performRequest(req) + + if shouldRefresh(response, retrying: retrying) { + let refreshed = await refreshAccessToken() + if refreshed { return try await uploadResultPhoto(localPath: localPath, retrying: true) } + throw APIError.notAuthenticated + } + + struct PhotoResult: Decodable, Sendable { let serverPath: String } + if let env = try? decoder.decode(_Envelope.self, from: data), + env.ok, let r = env.data { return r.serverPath } + throw APIError.serverError("Result photo upload failed") + } + + // ── Attach resolution photos to an existing issue ───────────────────── + // PATCHes /api/v1/issues/{id}/result_photos — writes to Issue.result_photos + // (Resolution Details on web), not mobile_photo_paths (Photo Evidence). + func updateIssueResultPhotos(issueId: Int, resultPhotos: [String]) async throws { + struct R: Decodable, Sendable { let issueId: Int; let resultPhotosCount: Int } + let _: R = try await request( + "/api/v1/issues/\(issueId)/result_photos", + method: "PATCH", + body: ["result_photos": resultPhotos] + ) + } + + // ── Fetch Issue Detail (status + assigned_to) ───────────────────────── + + func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail { + return try await request("/api/v1/issues/\(issueId)") + } + + // ── Update Issue Status ─────────────────────────────────────────────── + + func updateIssueStatus(issueId: Int, status: String) async throws -> String { + let result: APIIssueStatusUpdate = try await request( + "/api/v1/issues/\(issueId)/status", + method: "PATCH", + body: ["status": status] + ) + return result.status + } + + // ── Notification polling (Phase C) ──────────────────────────────────── + + // Shared formatter for the ?since= query parameter. + // DateFormatter init is expensive — creating one per fetchNotifications() + // call (every 60 seconds) adds unnecessary allocations on the sync cycle. + private static let notifSinceFmt: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return f + }() + + /// Fetch notifications, optionally scoped to those created after `since`. + func fetchNotifications(since: Date? = nil) async throws -> [APINotification] { + var ep = "/api/v1/notifications" + if let since { + ep += "?since=\(Self.notifSinceFmt.string(from: since))" + } + let result: APINotificationsResponseData = try await request(ep) + return result.notifications + } + + /// Mark the given notification IDs as read on the server. + func markNotificationsRead(ids: [Int]) async throws { + guard !ids.isEmpty else { return } + let _: APIMarkReadResponseData = try await request( + "/api/v1/notifications/mark-read", + method: "PATCH", + body: ["ids": ids] + ) + } + + /// Fetch issues assigned to the current user from the server. + func fetchAssignedIssues() async throws -> [APIAssignedIssue] { + let result: APIAssignedIssuesResponseData = try await request("/api/v1/issues") + return result.issues + } + + // ── Scheduled Inspections (phase36) ─────────────────────────────────── + + func fetchScheduledInspections() async throws -> [APIScheduledInspection] { + let result: APIScheduledInspectionsResponseData = + try await request("/api/v1/scheduled-inspections") + return result.scheduled + } + + // ── Issue Handler ("Handled By") ────────────────────────────────────── + + /// Set who handles an issue. `details` carries any of the optional + /// facility_handler_* / vendor_* fields; only keys present are updated. + func updateIssueHandler( + issueId: Int, + handlerType: String, + details: [String: String] = [:] + ) async throws -> String { + var body: [String: Any] = ["handler_type": handlerType] + for (k, v) in details { body[k] = v } + let result: APIIssueHandlerUpdate = try await request( + "/api/v1/issues/\(issueId)/handler", + method: "PATCH", + body: body + ) + return result.handlerType + } + + /// Fetch dashboard KPI counts for the current user (Phase B). + func fetchDashboardStats() async throws -> APIDashboardStats { + return try await request("/api/v1/stats/dashboard") + } + + // ── Device Registration ─────────────────────────────────────────────── + // Called on every app foreground (active scenePhase) when authenticated. + // Upserts a device_registrations row on the server so the admin can see + // all installed devices and their versions. + // Errors are suppressed — device registration is best-effort and must + // never block the normal app launch flow. + + /// Returns or creates a stable device UUID, persisted in Keychain so it + /// survives app restarts but is unique per physical device. + nonisolated static func stableDeviceId() -> String { + if let existing = KeychainHelper.get(Constants.Keychain.deviceId) { + return existing + } + let new = UUID().uuidString + KeychainHelper.set(new, forKey: Constants.Keychain.deviceId) + return new + } + + func registerDevice() async { + guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else { return } + + let deviceId = Self.stableDeviceId() + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + // UIDevice.current is @MainActor — read on MainActor then pass as plain Strings + let (deviceName, iosVersion): (String, String) = await MainActor.run { + (UIDevice.current.name, UIDevice.current.systemVersion) + } + + let body: [String: Any] = [ + "device_id": deviceId, + "device_name": deviceName, + "app_version": appVersion, + "ios_version": iosVersion, + ] + + do { + // nonisolated init required — SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor + // taints synthesised Decodable inits (CLAUDE.md rule 29). + struct R: Decodable, Sendable { + let registered: Bool + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + registered = try c.decode(Bool.self, forKey: .registered) + } + private enum CodingKeys: String, CodingKey { case registered } + } + let _: R = try await request("/api/v1/devices/register", method: "POST", body: body) + print("[JQC] registerDevice succeeded") + } catch { + // Log raw response to diagnose server-side failures + if let url = URL(string: ServerConfig.current + "/api/v1/devices/register"), + let token = KeychainHelper.get(Constants.Keychain.accessToken) { + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + if let (data, resp) = try? await URLSession.shared.data(for: req) { + let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 + let raw = String(data: data, encoding: .utf8) ?? "" + print("[JQC] registerDevice HTTP \(status): \(raw)") + } + } + print("[JQC] registerDevice error: \(error)") + } + } + + // ── Issue Comments (Phase D) ────────────────────────────────────────── + + /// Fetch all comments for an issue, oldest-first. + func fetchIssueComments(issueId: Int) async throws -> [APIIssueComment] { + let result: APIIssueCommentsResponseData = try await request( + "/api/v1/issues/\(issueId)/comments" + ) + return result.comments + } + + /// Post a new comment on an issue. Returns the new comment ID. + func postIssueComment(issueId: Int, body: String) async throws -> Int { + let result: APIAddCommentResponseData = try await request( + "/api/v1/issues/\(issueId)/comments", + method: "POST", + body: ["body": body] + ) + return result.commentId + } + + // ── Token Refresh ───────────────────────────────────────────────────── + + private func refreshAccessToken() async -> Bool { + guard let token = KeychainHelper.get(Constants.Keychain.refreshToken), + let url = URL(string: ServerConfig.current + "/api/v1/auth/refresh") + else { return false } + + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try? JSONSerialization.data(withJSONObject: ["refresh_token": token]) + + guard let (data, response) = try? await session.data(for: req), + let http = response as? HTTPURLResponse, http.statusCode == 200 + else { return false } + + // Use a local decoder — avoids referencing the actor-isolated self.decoder + // which would trigger a Swift 6 main-actor isolation error. + let localDecoder = JSONDecoder() + localDecoder.keyDecodingStrategy = .convertFromSnakeCase + guard let env = try? localDecoder.decode(_RefreshEnvelope.self, from: data), + env.ok, + let tokens = env.data + else { return false } + + KeychainHelper.set(tokens.accessToken, forKey: Constants.Keychain.accessToken) + KeychainHelper.set(tokens.refreshToken, forKey: Constants.Keychain.refreshToken) + return true + } + + // ── Private Helpers ─────────────────────────────────────────────────── + + private func buildURL(_ endpoint: String) throws -> URL { + guard let url = URL(string: ServerConfig.current + endpoint) else { + throw APIError.invalidURL + } + return url + } + + private func buildRequest(url: URL, method: String, body: [String: Any]?) -> URLRequest { + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let body { req.httpBody = try? JSONSerialization.data(withJSONObject: body) } + return req + } + + private func injectToken(_ req: inout URLRequest) { + if let token = KeychainHelper.get(Constants.Keychain.accessToken) { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + } + + private func performRequest(_ req: URLRequest) async throws -> (Data, URLResponse) { + do { + return try await session.data(for: req) + } catch { + throw APIError.networkError(error.localizedDescription) + } + } + + private func shouldRefresh(_ response: URLResponse, retrying: Bool) -> Bool { + guard !retrying, let http = response as? HTTPURLResponse else { return false } + return http.statusCode == 401 + } + + private func decode(_ data: Data) throws -> T { + if let env = try? decoder.decode(_Envelope.self, from: data) { + if env.ok, let result = env.data { return result } + throw APIError.serverError(env.error ?? "Unknown server error") + } + do { + return try decoder.decode(T.self, from: data) + } catch { + throw APIError.decodingError(error.localizedDescription) + } + } +} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIModels.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIModels.swift new file mode 100644 index 0000000..e2c954a --- /dev/null +++ b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIModels.swift @@ -0,0 +1,767 @@ +// API/APIModels.swift +// ------------------- +// All types in this file are pure value types with no actor isolation. +// AnyDecodable uses a JSONValue enum (not `Any`) so it is fully Sendable +// without @unchecked and causes no actor-isolation warnings. + +import Foundation + +// ── JSONValue — replaces AnyDecodable ──────────────────────────────────────── +// +// Using `Any` as a stored property is not Sendable, which causes the Swift +// compiler to defensively infer @MainActor on any struct containing it. +// A typed enum avoids this entirely: every case is a concrete Sendable type. + +enum JSONValue: Decodable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([JSONValue]) + case object([String: JSONValue]) + case null + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + // Order matters: Bool before Int (Bool is Int-decodable on some platforms) + if let v = try? c.decode(Bool.self) { self = .bool(v); return } + if let v = try? c.decode(Int.self) { self = .int(v); return } + if let v = try? c.decode(Double.self) { self = .double(v); return } + if let v = try? c.decode(String.self) { self = .string(v); return } + if let v = try? c.decode([JSONValue].self) { self = .array(v); return } + if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return } + self = .null + } + + // Convert to Any for compatibility with existing formData/formSchema code + var anyValue: Any { + switch self { + case .string(let v): return v + case .int(let v): return v + case .double(let v): return v + case .bool(let v): return v + case .null: return NSNull() + case .array(let v): return v.map(\.anyValue) + case .object(let v): return v.mapValues(\.anyValue) + } + } +} + +// Typealias so existing code that references AnyDecodable still compiles +typealias AnyDecodable = JSONValue + +// ── API Envelope ────────────────────────────────────────────────────────────── + +struct APIResponse: Decodable, Sendable { + let ok: Bool + let data: T? + let error: String? + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + ok = try c.decode(Bool.self, forKey: .ok) + data = try? c.decode(T.self, forKey: .data) + error = try? c.decode(String.self, forKey: .error) + } + private enum CodingKeys: String, CodingKey { case ok, data, error } +} + +// ── Auth ────────────────────────────────────────────────────────────────────── + +struct APIUser: Decodable, Sendable { + let id: Int + let username: String + let fullName: String // empty string when not set; never nil + let email: String + let role: String + let createdAt: String? + /// Returns full_name when set, otherwise falls back to username. + /// Mirrors User.display_name on the server. + var displayName: String { fullName.isEmpty ? username : fullName } + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + username = try c.decode(String.self, forKey: .username) + fullName = (try? c.decode(String.self, forKey: .fullName)) ?? "" + email = try c.decode(String.self, forKey: .email) + role = try c.decode(String.self, forKey: .role) + createdAt = try? c.decode(String.self, forKey: .createdAt) + } + private enum CodingKeys: String, CodingKey { + case id, username, fullName, email, role, createdAt + } +} + +struct LoginResponseData: Decodable, Sendable { + let accessToken: String + let refreshToken: String + let tokenType: String + let expiresIn: Int + let user: APIUser + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + accessToken = try c.decode(String.self, forKey: .accessToken) + refreshToken = try c.decode(String.self, forKey: .refreshToken) + tokenType = try c.decode(String.self, forKey: .tokenType) + expiresIn = try c.decode(Int.self, forKey: .expiresIn) + user = try c.decode(APIUser.self, forKey: .user) + } + private enum CodingKeys: String, CodingKey { + case accessToken, refreshToken, tokenType, expiresIn, user + } +} + +struct RefreshResponseData: Decodable, Sendable { + let accessToken: String + let refreshToken: String + let tokenType: String + let expiresIn: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + accessToken = try c.decode(String.self, forKey: .accessToken) + refreshToken = try c.decode(String.self, forKey: .refreshToken) + tokenType = try c.decode(String.self, forKey: .tokenType) + expiresIn = try c.decode(Int.self, forKey: .expiresIn) + } + private enum CodingKeys: String, CodingKey { + case accessToken, refreshToken, tokenType, expiresIn + } +} + +struct MeResponseData: Decodable, Sendable { + let user: APIUser + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + user = try c.decode(APIUser.self, forKey: .user) + } + private enum CodingKeys: String, CodingKey { case user } +} + +// ── Facilities ──────────────────────────────────────────────────────────────── + +struct APIFacility: Decodable, Identifiable, Sendable { + let id: Int + let name: String + let address: String + let contactPerson: String + let contactPhone: String + let projectId: Int? + let projectName: String? + let isActive: Bool + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + address = try c.decode(String.self, forKey: .address) + contactPerson = try c.decode(String.self, forKey: .contactPerson) + contactPhone = try c.decode(String.self, forKey: .contactPhone) + projectId = try? c.decode(Int.self, forKey: .projectId) + projectName = try? c.decode(String.self, forKey: .projectName) + isActive = try c.decode(Bool.self, forKey: .isActive) + } + private enum CodingKeys: String, CodingKey { + case id, name, address, contactPerson, contactPhone + case projectId, projectName, isActive + } +} + +struct FacilitiesResponseData: Decodable, Sendable { + let facilities: [APIFacility] + let count: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + facilities = try c.decode([APIFacility].self, forKey: .facilities) + count = try c.decode(Int.self, forKey: .count) + } + private enum CodingKeys: String, CodingKey { case facilities, count } +} + +struct APIArea: Decodable, Identifiable, Sendable { + let id: Int + let facilityId: Int + let name: String + let areaType: String + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + facilityId = try c.decode(Int.self, forKey: .facilityId) + name = try c.decode(String.self, forKey: .name) + areaType = try c.decode(String.self, forKey: .areaType) + } + private enum CodingKeys: String, CodingKey { case id, facilityId, name, areaType } +} + +struct AreasResponseData: Decodable, Sendable { + let facilityId: Int + let areas: [APIArea] + let count: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + facilityId = try c.decode(Int.self, forKey: .facilityId) + areas = try c.decode([APIArea].self, forKey: .areas) + count = try c.decode(Int.self, forKey: .count) + } + private enum CodingKeys: String, CodingKey { case facilityId, areas, count } +} + +// ── Templates ───────────────────────────────────────────────────────────────── + +struct APITemplateSummary: Decodable, Identifiable, Sendable { + let id: Int + let name: String + let description: String + let frequency: String + let isActive: Bool + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + description = try c.decode(String.self, forKey: .description) + frequency = try c.decode(String.self, forKey: .frequency) + // Default true — backwards compatible if server omits the field + isActive = (try? c.decode(Bool.self, forKey: .isActive)) ?? true + } + private enum CodingKeys: String, CodingKey { case id, name, description, frequency, isActive } +} + +struct TemplatesResponseData: Decodable, Sendable { + let templates: [APITemplateSummary] + let count: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + templates = try c.decode([APITemplateSummary].self, forKey: .templates) + count = try c.decode(Int.self, forKey: .count) + } + private enum CodingKeys: String, CodingKey { case templates, count } +} + +struct APITemplate: Decodable, Identifiable, Sendable { + let id: Int + let name: String + let description: String + let frequency: String + // Use [[String: JSONValue]] instead of [[String: AnyDecodable]] — fully Sendable + let formSchema: [[String: JSONValue]] + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + description = try c.decode(String.self, forKey: .description) + frequency = try c.decode(String.self, forKey: .frequency) + formSchema = try c.decode([[String: JSONValue]].self, forKey: .formSchema) + } + private enum CodingKeys: String, CodingKey { + case id, name, description, frequency, formSchema + } +} + +struct TemplateDetailResponseData: Decodable, Sendable { + let template: APITemplate + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + template = try c.decode(APITemplate.self, forKey: .template) + } + private enum CodingKeys: String, CodingKey { case template } +} + +// ── Inspections ─────────────────────────────────────────────────────────────── + +struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable { + let id: Int + let templateId: Int + let templateName: String + let facilityId: Int + let facilityName: String + let areaId: Int? + let areaName: String? + let status: String + let overallScore: Double? + let inspectionDate: String? + let completedAt: String? + let mobileLocalId: String? + let inspectorNotes: String + // Form responses and schema — included in every history response so the + // detail view works without a local SwiftData copy (e.g. after reinstall). + let formDataRaw: [String: JSONValue] + let formSchemaRaw: [[String: JSONValue]] + // ── Follow-up / re-inspection ───────────────────────────────────────── + let followUpRequired: Bool + let followUpNote: String? + let parentInspectionId: Int? + + /// Form field values as [fieldId: stringValue] for the grid renderer. + var formValues: [String: String] { + var result: [String: String] = [:] + for (k, v) in formDataRaw { + switch v { + case .string(let s): result[k] = s + case .int(let n): result[k] = String(n) + case .double(let d): result[k] = String(d) + case .bool(let b): result[k] = b ? "true" : "false" + case .array(let a): result[k] = a.map { "\($0.anyValue)" }.joined(separator: ", ") + case .null: result[k] = "" + case .object: result[k] = "" + } + } + return result + } + + /// Form schema as [[String: Any]] for ReadOnlyGridFormView. + var formSchema: [[String: Any]] { + formSchemaRaw.map { dict in dict.mapValues { $0.anyValue } } + } + + var inspectionDateParsed: Date? { + guard let str = inspectionDate else { return nil } + // Server sends "yyyy-MM-dd'T'HH:mm:ss" with no timezone suffix. + // ISO8601DateFormatter() requires a timezone by default and returns nil + // for timezone-less strings — use the shared DateFormatter instead. + return SyncManager.isoFormatter.date(from: str) + } + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + templateId = try c.decode(Int.self, forKey: .templateId) + templateName = try c.decode(String.self, forKey: .templateName) + facilityId = try c.decode(Int.self, forKey: .facilityId) + facilityName = try c.decode(String.self, forKey: .facilityName) + areaId = try? c.decode(Int.self, forKey: .areaId) + areaName = try? c.decode(String.self, forKey: .areaName) + status = try c.decode(String.self, forKey: .status) + overallScore = try? c.decode(Double.self, forKey: .overallScore) + inspectionDate = try? c.decode(String.self, forKey: .inspectionDate) + completedAt = try? c.decode(String.self, forKey: .completedAt) + mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) + inspectorNotes = (try? c.decode(String.self, forKey: .inspectorNotes)) ?? "" + formDataRaw = (try? c.decode([String: JSONValue].self, forKey: .formData)) ?? [:] + formSchemaRaw = (try? c.decode([[String: JSONValue]].self, forKey: .formSchema)) ?? [] + followUpRequired = (try? c.decode(Bool.self, forKey: .followUpRequired)) ?? false + followUpNote = try? c.decode(String.self, forKey: .followUpNote) + parentInspectionId = try? c.decode(Int.self, forKey: .parentInspectionId) + } + private enum CodingKeys: String, CodingKey { + case id, templateId, templateName, facilityId, facilityName + case areaId, areaName, status, overallScore + case inspectionDate, completedAt, mobileLocalId, inspectorNotes + case formData, formSchema + case followUpRequired, followUpNote, parentInspectionId + } + + // Explicit Hashable — formDataRaw/formSchemaRaw contain JSONValue which + // has no Hashable conformance; identity is determined by server id alone. + static func == (lhs: APIInspectionSummary, rhs: APIInspectionSummary) -> Bool { + lhs.id == rhs.id + } + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +struct InspectionHistoryResponseData: Decodable, Sendable { + let inspections: [APIInspectionSummary] + let total: Int + let limit: Int + let offset: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + inspections = try c.decode([APIInspectionSummary].self, forKey: .inspections) + total = try c.decode(Int.self, forKey: .total) + limit = try c.decode(Int.self, forKey: .limit) + offset = try c.decode(Int.self, forKey: .offset) + } + private enum CodingKeys: String, CodingKey { case inspections, total, limit, offset } +} + +// ── Issue Detail ────────────────────────────────────────────────────────────── + +struct APIIssueDetail: Decodable, Sendable { + let id: Int + let status: String + let severity: String + let description: String + let assignedTo: Int? + let facilityId: Int? + let facilityName: String? + let reportedAt: String? + let resolvedAt: String? + // Phase A — resolution details from web + let resultNotes: String? + let verifiedAt: String? + let verificationNote: String? + let reportedByName: String? + // Resolution photos uploaded via web or mobile resolve flow + let resultPhotos: [String] + // Phase E — area and assignee context + let areaName: String? + let assignedToName: String? + // Handler ("Handled By", phase35) + let handlerType: String? + let handlerLabel: String? + let facilityHandlerName: String? + let facilityHandlerContact: String? + let facilityHandlerNotes: String? + let vendorName: String? + let vendorContact: String? + let vendorNotes: String? + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + status = try c.decode(String.self, forKey: .status) + severity = try c.decode(String.self, forKey: .severity) + description = try c.decode(String.self, forKey: .description) + assignedTo = try? c.decode(Int.self, forKey: .assignedTo) + facilityId = try? c.decode(Int.self, forKey: .facilityId) + facilityName = try? c.decode(String.self, forKey: .facilityName) + reportedAt = try? c.decode(String.self, forKey: .reportedAt) + resolvedAt = try? c.decode(String.self, forKey: .resolvedAt) + resultNotes = try? c.decode(String.self, forKey: .resultNotes) + verifiedAt = try? c.decode(String.self, forKey: .verifiedAt) + verificationNote = try? c.decode(String.self, forKey: .verificationNote) + reportedByName = try? c.decode(String.self, forKey: .reportedByName) + resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] + areaName = try? c.decode(String.self, forKey: .areaName) + assignedToName = try? c.decode(String.self, forKey: .assignedToName) + handlerType = try? c.decode(String.self, forKey: .handlerType) + handlerLabel = try? c.decode(String.self, forKey: .handlerLabel) + facilityHandlerName = try? c.decode(String.self, forKey: .facilityHandlerName) + facilityHandlerContact = try? c.decode(String.self, forKey: .facilityHandlerContact) + facilityHandlerNotes = try? c.decode(String.self, forKey: .facilityHandlerNotes) + vendorName = try? c.decode(String.self, forKey: .vendorName) + vendorContact = try? c.decode(String.self, forKey: .vendorContact) + vendorNotes = try? c.decode(String.self, forKey: .vendorNotes) + } + private enum CodingKeys: String, CodingKey { + case id, status, severity, description, assignedTo + case facilityId, facilityName, reportedAt, resolvedAt + case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos + case areaName, assignedToName + case handlerType, handlerLabel + case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes + case vendorName, vendorContact, vendorNotes + } +} + +struct APIIssueStatusUpdate: Decodable, Sendable { + let issueId: Int + let status: String + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + issueId = try c.decode(Int.self, forKey: .issueId) + status = try c.decode(String.self, forKey: .status) + } + private enum CodingKeys: String, CodingKey { case issueId, status } +} + +struct APIIssueHandlerUpdate: Decodable, Sendable { + let issueId: Int + let handlerType: String + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + issueId = try c.decode(Int.self, forKey: .issueId) + handlerType = try c.decode(String.self, forKey: .handlerType) + } + private enum CodingKeys: String, CodingKey { case issueId, handlerType } +} + +// ── Notification polling (Phase C) ──────────────────────────────────────────── + +struct APINotification: Decodable, Identifiable, Sendable { + let id: Int + let title: String + let body: String + let eventType: String? + let issueId: Int? + let createdAt: String + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + title = try c.decode(String.self, forKey: .title) + body = try c.decode(String.self, forKey: .body) + eventType = try? c.decode(String.self, forKey: .eventType) + issueId = try? c.decode(Int.self, forKey: .issueId) + createdAt = try c.decode(String.self, forKey: .createdAt) + } + private enum CodingKeys: String, CodingKey { + case id, title, body, eventType, issueId, createdAt + } +} + +struct APINotificationsResponseData: Decodable, Sendable { + let notifications: [APINotification] + let count: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + notifications = try c.decode([APINotification].self, forKey: .notifications) + count = try c.decode(Int.self, forKey: .count) + } + private enum CodingKeys: String, CodingKey { case notifications, count } +} + +struct APIMarkReadResponseData: Decodable, Sendable { + let marked: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + marked = try c.decode(Int.self, forKey: .marked) + } + private enum CodingKeys: String, CodingKey { case marked } +} + +// ── Assigned issues list (Phase C) ──────────────────────────────────────────── + +struct APIAssignedIssue: Decodable, Identifiable, Sendable { + let id: Int + let status: String + let severity: String + let description: String + let assignedTo: Int? + let facilityId: Int? + let facilityName: String? + let reportedAt: String? + let mobileLocalId: String? + let photoPath: String? // primary evidence photo + let mobilePhotoPaths: [String] // extra evidence photos from iPad + let resultPhotos: [String] // resolution photos added via web + // Phase A — resolution details from web + let resultNotes: String? + let verifiedAt: String? + let verificationNote: String? + let reportedByName: String? + // Phase E — area and assignee context + let areaName: String? + let assignedToName: String? + // Handler ("Handled By", phase35) — who resolves the issue + let handlerType: String? // "internal" | "facility" | "vendor" + let handlerLabel: String? // human-readable label + let facilityHandlerName: String? + let facilityHandlerContact: String? + let facilityHandlerNotes: String? + let vendorName: String? + let vendorContact: String? + let vendorNotes: String? + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + status = try c.decode(String.self, forKey: .status) + severity = try c.decode(String.self, forKey: .severity) + description = try c.decode(String.self, forKey: .description) + assignedTo = try? c.decode(Int.self, forKey: .assignedTo) + facilityId = try? c.decode(Int.self, forKey: .facilityId) + facilityName = try? c.decode(String.self, forKey: .facilityName) + reportedAt = try? c.decode(String.self, forKey: .reportedAt) + mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) + photoPath = try? c.decode(String.self, forKey: .photoPath) + mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? [] + resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] + resultNotes = try? c.decode(String.self, forKey: .resultNotes) + verifiedAt = try? c.decode(String.self, forKey: .verifiedAt) + verificationNote = try? c.decode(String.self, forKey: .verificationNote) + reportedByName = try? c.decode(String.self, forKey: .reportedByName) + areaName = try? c.decode(String.self, forKey: .areaName) + assignedToName = try? c.decode(String.self, forKey: .assignedToName) + handlerType = try? c.decode(String.self, forKey: .handlerType) + handlerLabel = try? c.decode(String.self, forKey: .handlerLabel) + facilityHandlerName = try? c.decode(String.self, forKey: .facilityHandlerName) + facilityHandlerContact = try? c.decode(String.self, forKey: .facilityHandlerContact) + facilityHandlerNotes = try? c.decode(String.self, forKey: .facilityHandlerNotes) + vendorName = try? c.decode(String.self, forKey: .vendorName) + vendorContact = try? c.decode(String.self, forKey: .vendorContact) + vendorNotes = try? c.decode(String.self, forKey: .vendorNotes) + } + private enum CodingKeys: String, CodingKey { + case id, status, severity, description, assignedTo + case facilityId, facilityName, reportedAt, mobileLocalId + case photoPath, mobilePhotoPaths, resultPhotos + case resultNotes, verifiedAt, verificationNote, reportedByName + case areaName, assignedToName + case handlerType, handlerLabel + case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes + case vendorName, vendorContact, vendorNotes + } +} + +struct APIAssignedIssuesResponseData: Decodable, Sendable { + let issues: [APIAssignedIssue] + let total: Int + let limit: Int + let offset: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + issues = try c.decode([APIAssignedIssue].self, forKey: .issues) + total = try c.decode(Int.self, forKey: .total) + limit = try c.decode(Int.self, forKey: .limit) + offset = try c.decode(Int.self, forKey: .offset) + } + private enum CodingKeys: String, CodingKey { case issues, total, limit, offset } +} + +// ── Scheduled Inspections (phase36 — planned/recurring assignments) ─────────── + +struct APIScheduledInspection: Decodable, Identifiable, Sendable { + let id: Int + let facilityId: Int + let facilityName: String? + let templateId: Int + let templateName: String? + let inspectorId: Int? + let frequency: String + let frequencyLabel: String? + let nextDueDate: String? // ISO date "YYYY-MM-DD" + let isOverdue: Bool + let notes: String? + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + facilityId = try c.decode(Int.self, forKey: .facilityId) + facilityName = try? c.decode(String.self, forKey: .facilityName) + templateId = try c.decode(Int.self, forKey: .templateId) + templateName = try? c.decode(String.self, forKey: .templateName) + inspectorId = try? c.decode(Int.self, forKey: .inspectorId) + frequency = (try? c.decode(String.self, forKey: .frequency)) ?? "once" + frequencyLabel = try? c.decode(String.self, forKey: .frequencyLabel) + nextDueDate = try? c.decode(String.self, forKey: .nextDueDate) + isOverdue = (try? c.decode(Bool.self, forKey: .isOverdue)) ?? false + notes = try? c.decode(String.self, forKey: .notes) + } + private enum CodingKeys: String, CodingKey { + case id, facilityId, facilityName, templateId, templateName + case inspectorId, frequency, frequencyLabel, nextDueDate, isOverdue, notes + } +} + +struct APIScheduledInspectionsResponseData: Decodable, Sendable { + let scheduled: [APIScheduledInspection] + let total: Int + let limit: Int + let offset: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + scheduled = try c.decode([APIScheduledInspection].self, forKey: .scheduled) + total = try c.decode(Int.self, forKey: .total) + limit = try c.decode(Int.self, forKey: .limit) + offset = try c.decode(Int.self, forKey: .offset) + } + private enum CodingKeys: String, CodingKey { case scheduled, total, limit, offset } +} + +// ── Dashboard Stats (Phase B) ───────────────────────────────────────────────── + +struct APIDashboardStats: Decodable, Sendable { + let todayInspections: Int + let completedToday: Int + let openIssues: Int + let avgScore30d: Double? + let pendingFollowups: Int + let slaBreached: Int + let slaAtRisk: Int + // Phase E — severity breakdown of open issues + let severityCritical: Int + let severityHigh: Int + let severityMedium: Int + let severityLow: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + todayInspections = (try? c.decode(Int.self, forKey: .todayInspections)) ?? 0 + completedToday = (try? c.decode(Int.self, forKey: .completedToday)) ?? 0 + openIssues = (try? c.decode(Int.self, forKey: .openIssues)) ?? 0 + avgScore30d = try? c.decode(Double.self, forKey: .avgScore30d) + pendingFollowups = (try? c.decode(Int.self, forKey: .pendingFollowups)) ?? 0 + slaBreached = (try? c.decode(Int.self, forKey: .slaBreached)) ?? 0 + slaAtRisk = (try? c.decode(Int.self, forKey: .slaAtRisk)) ?? 0 + // Decode from nested severity_breakdown dict + if let breakdown = try? c.decode([String: Int].self, forKey: .severityBreakdown) { + severityCritical = breakdown["critical"] ?? 0 + severityHigh = breakdown["high"] ?? 0 + severityMedium = breakdown["medium"] ?? 0 + severityLow = breakdown["low"] ?? 0 + } else { + severityCritical = 0 + severityHigh = 0 + severityMedium = 0 + severityLow = 0 + } + } + private enum CodingKeys: String, CodingKey { + case todayInspections, completedToday, openIssues + case avgScore30d, pendingFollowups, slaBreached, slaAtRisk + case severityBreakdown + } +} + +// ── Issue Comments (Phase D) ────────────────────────────────────────────────── + +struct APIIssueComment: Decodable, Identifiable, Sendable { + let id: Int + let issueId: Int + let authorName: String + let authorRole: String + let statusAtTime: String + let body: String + let createdAt: String + + var createdAtDate: Date? { + SyncManager.isoFormatter.date(from: createdAt) + } + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + issueId = try c.decode(Int.self, forKey: .issueId) + authorName = (try? c.decode(String.self, forKey: .authorName)) ?? "Unknown" + authorRole = (try? c.decode(String.self, forKey: .authorRole)) ?? "" + statusAtTime = (try? c.decode(String.self, forKey: .statusAtTime)) ?? "" + body = try c.decode(String.self, forKey: .body) + createdAt = try c.decode(String.self, forKey: .createdAt) + } + private enum CodingKeys: String, CodingKey { + case id, issueId, authorName, authorRole, statusAtTime, body, createdAt + } +} + +struct APIIssueCommentsResponseData: Decodable, Sendable { + let issueId: Int + let comments: [APIIssueComment] + let count: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + issueId = try c.decode(Int.self, forKey: .issueId) + comments = try c.decode([APIIssueComment].self, forKey: .comments) + count = try c.decode(Int.self, forKey: .count) + } + private enum CodingKeys: String, CodingKey { case issueId, comments, count } +} + +struct APIAddCommentResponseData: Decodable, Sendable { + let commentId: Int + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + commentId = try c.decode(Int.self, forKey: .commentId) + } + private enum CodingKeys: String, CodingKey { case commentId } +} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/JanitorialQCApp.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/JanitorialQCApp.swift new file mode 100644 index 0000000..a0effae --- /dev/null +++ b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/JanitorialQCApp.swift @@ -0,0 +1,182 @@ +// JQCApp.swift +// ------------ +// App entry point. + +import SwiftUI +import SwiftData +import BackgroundTasks +import UserNotifications +import Combine + +// ── AppearanceManager ───────────────────────────────────────────────────────── +// Persists the user's preferred colour scheme to UserDefaults and exposes it +// as a @Published property so the root view can apply .preferredColorScheme. +// "system" (nil) means the app follows iOS system appearance — the default. + +enum AppearanceMode: String, CaseIterable { + case system = "system" + case light = "light" + case dark = "dark" + + var displayName: String { + switch self { + case .system: return "System" + case .light: return "Light" + case .dark: return "Dark" + } + } + + /// The SwiftUI ColorScheme value to pass to .preferredColorScheme(). + /// nil = follow the OS (system default). + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} + +final class AppearanceManager: ObservableObject { + static let shared = AppearanceManager() + private static let defaultsKey = "jqc.appearanceMode" + + @Published var mode: AppearanceMode { + didSet { + UserDefaults.standard.set(mode.rawValue, forKey: Self.defaultsKey) + } + } + + private init() { + let saved = UserDefaults.standard.string(forKey: Self.defaultsKey) ?? "" + mode = AppearanceMode(rawValue: saved) ?? .system + } +} + +// ── AppDelegate — runtime orientation lock ──────────────────────────────────── +// Info.plist must declare all 4 orientations so iPad multitasking is supported +// (App Store requirement). This delegate restricts the app to landscape-only +// at runtime by returning only the two landscape masks. +// Portrait is intentionally excluded: the grid-based inspection form is +// designed for landscape and does not adapt well to portrait on iPad. + +final class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + supportedInterfaceOrientationsFor window: UIWindow? + ) -> UIInterfaceOrientationMask { + return [.landscapeLeft, .landscapeRight] + } +} + +@main +struct JanitorialQCApp: App { + + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + @StateObject private var auth = AuthManager.shared + @StateObject private var sync = SyncManager.shared + @StateObject private var appearance = AppearanceManager.shared + + init() { + registerBackgroundTasks() + requestNotificationPermission() + // Set delegate so notifications display as banners when the app is in + // the foreground. Without this iOS silently drops them. + UNUserNotificationCenter.current().delegate = NotificationDelegate.shared + } + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(auth) + .environmentObject(sync) + .environmentObject(appearance) + .preferredColorScheme(appearance.mode.colorScheme) + } + .modelContainer(for: [ + LocalFacility.self, + LocalArea.self, + LocalTemplate.self, + LocalInspection.self, + LocalIssue.self, + LocalScheduledInspection.self, + PendingPhoto.self, + SyncQueueEntry.self, + ], isUndoEnabled: false) { result in + switch result { + case .success(let container): + // Only set the model context here — do NOT await anything. + // Session restore and sync are triggered by ContentView.task{} + // which runs on the MainActor inside the SwiftUI lifecycle, + // guaranteeing isLoading changes are seen by the view immediately. + SyncManager.shared.modelContext = container.mainContext + case .failure(let error): + fatalError("SwiftData container failed: \(error)") + } + } + } + + // ── Local notification permission ───────────────────────────────────── + + private func requestNotificationPermission() { + UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .sound, .badge] + ) { granted, error in + if let error { print("[JQC] Notification permission error: \(error)") } + } + } + + private func registerBackgroundTasks() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: "com.jqc.sync", + using: nil + ) { task in + guard let processingTask = task as? BGProcessingTask else { + task.setTaskCompleted(success: false) + return + } + handleBackgroundSync(task: processingTask) + } + } + + private func handleBackgroundSync(task: BGProcessingTask) { + scheduleBackgroundSync() + let syncTask = Task { + await SyncManager.shared.triggerSync() + } + task.expirationHandler = { + syncTask.cancel() + } + Task { + await syncTask.value + task.setTaskCompleted(success: !syncTask.isCancelled) + } + } +} + +// ── Notification delegate ───────────────────────────────────────────────────── +// Allows local notifications to appear as banners while the app is in the +// foreground. Without this delegate iOS discards them silently. + +final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { + static let shared = NotificationDelegate() + private override init() {} + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: + @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Show banner + play sound even when the app is active in foreground. + completionHandler([.banner, .sound]) + } +} + +func scheduleBackgroundSync() { + let request = BGProcessingTaskRequest(identifier: "com.jqc.sync") + request.requiresNetworkConnectivity = true + request.requiresExternalPower = false + try? BGTaskScheduler.shared.submit(request) +} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalIssue.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalIssue.swift new file mode 100644 index 0000000..4c2c683 --- /dev/null +++ b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalIssue.swift @@ -0,0 +1,228 @@ +// Models/LocalIssue.swift +// ----------------------- +// SwiftData model for issues flagged during an offline inspection. + +import Foundation +import SwiftData + +@Model +final class LocalIssue { + + @Attribute(.unique) var localId: String + var serverId: Int? + + var inspectionLocalId: String // references LocalInspection.localId + var facilityServerId: Int // facility this issue belongs to + /// Server ID of the area this issue was flagged in. Set when flagged during + /// an inspection that has an area selected. Nil for standalone issues. + var areaServerId: Int? + var severity: String // "low" | "medium" | "high" | "critical" + var issueDescription: String + var issueStatus: String = "open" // server status: "open" | "in_progress" | "resolved" | "pending_verification" + /// JSON-encoded array of absolute local file paths, e.g. ["/var/.../photo1.jpg", ...] + var photoLocalPathsJSON: String = "[]" + /// JSON-encoded array of server paths after upload, e.g. ["uploads/issue_photos/abc.jpg", ...] + var photoServerPathsJSON: String = "[]" + /// JSON-encoded array of resolution photo server paths (issue_result_photos bucket). + /// Mirrors Issue.result_photos on the server — shown under "Resolution Details". + var resultPhotoServerPathsJSON: String = "[]" + + // Shared coders — JSONDecoder/Encoder init is expensive (parses locale and + // calendar info). Allocating them inside computed property getters means + // a new instance per access; on a list showing 50 issues each with two + // JSON-backed arrays that's 200 allocations per render pass. Static + // instances are created once and reused for the lifetime of the app. + private static let jsonDecoder = JSONDecoder() + private static let jsonEncoder = JSONEncoder() + + // Lightweight decode cache — avoids re-parsing identical JSON strings. + // SwiftData may call the getter multiple times per render pass (once for + // isEmpty, once for count, once for ForEach). Caching the last-decoded + // value by JSON string identity means the JSON parse only happens when + // the underlying data actually changes. + // @Transient tells SwiftData not to persist these — they're in-memory only. + // Lightweight decode cache — split into key+value pairs because SwiftData's + // @Transient macro does not support tuple types. Two separate @Transient + // properties per cache entry achieve the same result with no schema impact. + @Transient private var _cachedLocalKey: String = "" + @Transient private var _cachedLocalValue: [String] = [] + @Transient private var _cachedServerKey: String = "" + @Transient private var _cachedServerValue: [String] = [] + @Transient private var _cachedResultKey: String = "" + @Transient private var _cachedResultValue: [String] = [] + + /// Decoded local photo paths (up to 5) + var photoLocalPaths: [String] { + get { + if _cachedLocalKey == photoLocalPathsJSON, !_cachedLocalKey.isEmpty { + return _cachedLocalValue + } + let decoded = (try? Self.jsonDecoder.decode([String].self, + from: Data(photoLocalPathsJSON.utf8))) ?? [] + _cachedLocalKey = photoLocalPathsJSON + _cachedLocalValue = decoded + return decoded + } + set { + let encoded = (try? String(data: Self.jsonEncoder.encode(newValue), + encoding: .utf8)) ?? "[]" + photoLocalPathsJSON = encoded + _cachedLocalKey = encoded + _cachedLocalValue = newValue + } + } + + /// Decoded server photo paths + var photoServerPaths: [String] { + get { + if _cachedServerKey == photoServerPathsJSON, !_cachedServerKey.isEmpty { + return _cachedServerValue + } + let decoded = (try? Self.jsonDecoder.decode([String].self, + from: Data(photoServerPathsJSON.utf8))) ?? [] + _cachedServerKey = photoServerPathsJSON + _cachedServerValue = decoded + return decoded + } + set { + let encoded = (try? String(data: Self.jsonEncoder.encode(newValue), + encoding: .utf8)) ?? "[]" + photoServerPathsJSON = encoded + _cachedServerKey = encoded + _cachedServerValue = newValue + } + } + + /// Decoded resolution photo server paths (issue_result_photos bucket). + /// Shown under "Resolution Details" — mirrors Issue.result_photos on the web. + var resultPhotoServerPaths: [String] { + get { + if _cachedResultKey == resultPhotoServerPathsJSON, !_cachedResultKey.isEmpty { + return _cachedResultValue + } + let decoded = (try? Self.jsonDecoder.decode([String].self, + from: Data(resultPhotoServerPathsJSON.utf8))) ?? [] + _cachedResultKey = resultPhotoServerPathsJSON + _cachedResultValue = decoded + return decoded + } + set { + let encoded = (try? String(data: Self.jsonEncoder.encode(newValue), + encoding: .utf8)) ?? "[]" + resultPhotoServerPathsJSON = encoded + _cachedResultKey = encoded + _cachedResultValue = newValue + } + } + + var createdAt: Date + var syncStatus: String // "pending" | "synced" | "failed" + var syncRetryCount: Int + var syncErrorMessage: String? + + // ── Phase A additions — persisted from server response ──────────────── + // All new String?/Date? fields default to nil; SwiftData lightweight migration + // supports nil-default optional properties without a migration plan. + + /// Facility display name cached from the server response. Used when the + /// local facility reference cache has been cleared (Settings → Clear Cache). + var facilityNameCache: String? + + /// Server-side reported_at timestamp. More accurate than createdAt for + /// server-pulled issues because createdAt falls back to device time when + /// the issue was created offline. + var serverReportedAt: Date? + + /// Resolution notes added by web staff after fixing the issue. + var resultNotes: String? + + /// Timestamp when a director/admin verified the fix. + var verifiedAt: Date? + + /// Note left by the verifier. + var verificationNote: String? + + /// Display name of the user who originally reported this issue. + var reportedByName: String? + + /// Name of the area this issue was flagged in (e.g. "Main Lobby"). + /// Set from server response; nil for standalone issues without area context. + var areaNameCache: String? + + /// Display name of the user currently assigned to this issue. + /// Nil when unassigned. Updated on every pullAssignedIssues(). + var assignedToName: String? + + // ── Handler ("Handled By", phase35) ─────────────────────────────────── + // Who resolves the issue: internal (our staff) | facility (facility's own + // staff) | vendor (external contractor). Synced from the server; the + // inspector may also set it from Issue Detail. nil-default optionals → + // SwiftData lightweight migration safe. + var handlerType: String? // "internal" | "facility" | "vendor" + var handlerLabel: String? // human-readable label from server + var facilityHandlerName: String? + var facilityHandlerContact: String? + var facilityHandlerNotes: String? + var vendorName: String? + var vendorContact: String? + var vendorNotes: String? + + // Explicit inverse declared so SwiftData has an unambiguous relationship + // graph at schema-build time. Without it the relationship is implicit, + // which can cause migration warnings or incorrect cascade behaviour on some + // SwiftData versions. The deleteRule is .nullify (default) — deleting the + // parent inspection cascades via LocalInspection.localIssues; this side + // only nullifies the back-pointer. + @Relationship(deleteRule: .nullify, inverse: \LocalInspection.localIssues) + var inspection: LocalInspection? + + init( + inspectionLocalId: String, + facilityServerId: Int, + severity: String, + description: String + ) { + self.localId = UUID().uuidString + self.serverId = nil + self.inspectionLocalId = inspectionLocalId + self.facilityServerId = facilityServerId + self.areaServerId = nil + self.severity = severity + self.issueDescription = description + self.issueStatus = "open" + self.photoLocalPathsJSON = "[]" + self.photoServerPathsJSON = "[]" + self.resultPhotoServerPathsJSON = "[]" + self.createdAt = Date() + self.syncStatus = "pending" + self.syncRetryCount = 0 + self.syncErrorMessage = nil + // Phase A fields — nil by default + self.facilityNameCache = nil + self.serverReportedAt = nil + self.resultNotes = nil + self.verifiedAt = nil + self.verificationNote = nil + self.reportedByName = nil + self.areaNameCache = nil + self.assignedToName = nil + // Handler fields — nil by default + self.handlerType = nil + self.handlerLabel = nil + self.facilityHandlerName = nil + self.facilityHandlerContact = nil + self.facilityHandlerNotes = nil + self.vendorName = nil + self.vendorContact = nil + self.vendorNotes = nil + } + + var severityColor: String { + switch severity { + case "critical": return "red" + case "high": return "orange" + case "medium": return "yellow" + default: return "blue" + } + } +} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalScheduledInspection.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalScheduledInspection.swift new file mode 100644 index 0000000..66adadc --- /dev/null +++ b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalScheduledInspection.swift @@ -0,0 +1,47 @@ +// Models/LocalScheduledInspection.swift +// -------------------------------------- +// SwiftData model for planned/recurring inspection assignments (phase36). +// +// Read-only reference data pulled from the server (GET /api/v1/scheduled-inspections) +// and refreshed by SyncManager.pullScheduledInspections() — never created or +// mutated on device. Surfaced in the "Scheduled" section on the Dashboard and +// My Inspections. Tapping "Start" opens the normal new-inspection flow with the +// facility + template preselected; the schedule lifecycle (fulfil / roll-forward) +// stays server-driven. +// +// All non-optional stored properties carry explicit inline defaults so SwiftData +// lightweight migration can add the new table without a migration plan. + +import Foundation +import SwiftData + +@Model +final class LocalScheduledInspection { + + /// Server ID of the ScheduledInspection row — stable unique identity. + @Attribute(.unique) var serverId: Int = 0 + + var facilityServerId: Int = 0 + var facilityName: String = "" + var templateServerId: Int = 0 + var templateName: String = "" + var inspectorId: Int? = nil + + var frequency: String = "once" // once | daily | weekly | monthly + var frequencyLabel: String = "" // human-readable label from server + + /// Raw ISO date string "YYYY-MM-DD" from the server (display fallback). + var dueDateString: String = "" + /// Parsed due date — used for @Query sorting. Nil if the string was absent. + var nextDue: Date? = nil + + var isOverdue: Bool = false + var notes: String? = nil + + /// Last time this row was refreshed from the server pull. + var updatedAt: Date = Date() + + init(serverId: Int) { + self.serverId = serverId + } +} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Sync/SyncManager.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Sync/SyncManager.swift new file mode 100644 index 0000000..c101099 --- /dev/null +++ b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Sync/SyncManager.swift @@ -0,0 +1,903 @@ +// Sync/SyncManager.swift +// ---------------------- +// Manages connectivity monitoring, reference data sync (Phase A), +// the outbox queue for offline inspection/issue submission (Phase B), +// and notification polling (Phase C). + +import Foundation +import Network +import SwiftData +import SwiftUI +import Combine +import UserNotifications + +@MainActor +class SyncManager: ObservableObject { + + // ── Published State ─────────────────────────────────────────────────── + + @Published var isOnline = false + @Published var isSyncing = false + @Published var lastSyncAt: Date? + @Published var syncError: String? + @Published var pendingCount = 0 + /// Dashboard KPI stats fetched from the server. Nil until first successful fetch. + @Published var dashboardStats: APIDashboardStats? + /// Count of notifications received since last resetNotificationPoller(). + /// Incremented on each poll that returns new items; reset to 0 on logout. + @Published var unreadNotificationCount = 0 + /// The most recent batch of notifications (up to 50) for the in-app inbox. + /// Replaced entirely on each successful poll; empty until first fetch. + @Published var recentNotifications: [APINotification] = [] + + // ── Dependencies ────────────────────────────────────────────────────── + + private let monitor = NWPathMonitor() + private let monitorQueue = DispatchQueue(label: "com.jqc.networkmonitor") + var modelContext: ModelContext? + + // ── Notification polling state ──────────────────────────────────────── + // Tracks the timestamp of the most recently fetched notification so each + // poll only retrieves newer records. Nil on first launch → server returns + // last 50 unread. Reset to nil on logout. + private var lastNotificationFetch: Date? + private var pollTask: Task? // replaces Timer — Task.sleep works correctly + private let pollInterval: UInt64 = 60_000_000_000 // 60 seconds in nanoseconds + + // ── Shared date formatters ──────────────────────────────────────────── + // DateFormatter init is expensive — allocating one per poll call or per + // issue would add measurable overhead at sync time. These are created + // once and reused across all calls. Both are nonisolated statics so they + // can be read from any context without actor-hopping. + // + // isoFormatter — parses/formats ISO 8601 strings from the server API + // e.g. "2026-05-01T14:30:00" + // notifFormatter — same format, used to advance the notification poll cursor + nonisolated static let isoFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return f + }() + + /// Parses server date-only strings ("YYYY-MM-DD"), e.g. scheduled due dates. + nonisolated static let dateOnlyFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f + }() + + static let shared = SyncManager() + private init() {} + + // ── Start Monitoring ────────────────────────────────────────────────── + + func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor [weak self] in + guard let self else { return } + let wasOffline = !self.isOnline + self.isOnline = path.status == .satisfied + if self.isOnline { + if wasOffline { + await self.triggerSync() + } + self.startPollTask() + } else { + self.stopPollTask() + } + } + } + monitor.start(queue: monitorQueue) + } + + // ── Notification poll task ──────────────────────────────────────────── + // Timer.scheduledTimer requires RunLoop.main to be ticking. When called + // from inside a Swift Concurrency Task { @MainActor } the current RunLoop + // is NOT RunLoop.main — the timer is added to a runloop that never runs, + // so it silently fires never. Task + Task.sleep has no such dependency. + + private func startPollTask() { + guard pollTask == nil else { return } // already running + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 60_000_000_000) + guard !Task.isCancelled else { break } + await MainActor.run { [weak self] in + guard let self, self.isOnline, + AuthManager.shared.isAuthenticated else { return } + Task { await self.pollNotifications() } + } + } + } + } + + private func stopPollTask() { + pollTask?.cancel() + pollTask = nil + } + + /// Called on logout so the next login starts a clean fetch. + func resetNotificationPoller() { + lastNotificationFetch = nil + unreadNotificationCount = 0 + recentNotifications = [] + stopPollTask() + } + + /// Called when the app enters the background (scenePhase == .background). + /// Stops the poll loop so it doesn't accumulate suspended sleep cycles. + func suspendPolling() { + stopPollTask() + } + + /// Called when the app returns to the foreground (scenePhase == .active). + /// Restarts the poll loop and immediately syncs so stale data is refreshed + /// without waiting up to 60s for the next scheduled tick. + func resumePolling() { + guard isOnline, AuthManager.shared.isAuthenticated else { return } + startPollTask() + Task { await triggerSync() } + } + + /// Call when the user opens the NotificationsView to clear the badge. + func markNotificationsViewed() { + unreadNotificationCount = 0 + } + + // ── Notification polling ────────────────────────────────────────────── + + func pollNotifications() async { + guard isOnline, AuthManager.shared.isAuthenticated else { return } + do { + let notifications = try await APIClient.shared.fetchNotifications(since: lastNotificationFetch) + guard !notifications.isEmpty else { return } + + // Deliver a local notification for each new item + for n in notifications { + deliverLocalNotification(n) + } + + // Update in-app inbox state. + // Prepend new notifications and cap at 50 — avoids allocating two + // arrays and concatenating them on every poll (the old pattern + // `notifications + recentNotifications.prefix(50 - count)` always + // created a new array even when notifications.count >= 50). + recentNotifications.insert(contentsOf: notifications, at: 0) + if recentNotifications.count > 50 { recentNotifications = Array(recentNotifications.prefix(50)) } + unreadNotificationCount += notifications.count + + // Update the cursor to the newest notification's timestamp so the + // next poll only fetches newer items — do NOT mark notifications as + // read on the server. Read state is a deliberate user action managed + // via the web app; marking read here would cause the web badge count + // to always show zero when the iPad has polled before the user checks. + let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) } + if let newest = dates.max() { + lastNotificationFetch = newest + } + + } catch APIError.notAuthenticated { + // Token expired and refresh failed — let AuthManager handle it + } catch { + // Network errors are silent; next poll will retry + } + } + + // ── Local notification delivery ─────────────────────────────────────── + + private func deliverLocalNotification(_ n: APINotification) { + let content = UNMutableNotificationContent() + content.title = n.title + content.body = n.body + content.sound = .default + + // Use the server notification ID as the identifier so duplicate + // deliveries (if the same record is fetched twice) replace rather + // than stack. + let identifier = "jqc-notif-\(n.id)" + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: nil // nil = deliver immediately + ) + UNUserNotificationCenter.current().add(request) { error in + if let error { + print("[JQC] Local notification delivery failed: \(error)") + } + } + } + + // ── Full Sync ───────────────────────────────────────────────────────── + + func triggerSync() async { + // Do not sync unless authenticated — avoids 401 loops before + // restoreSession() completes on first launch. + guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return } + + // Re-entrancy guard. triggerSync() has many independent call sites + // (issue submit, inspection submit, manual "Sync Now", NWPathMonitor + // reconnect, the 60s poll timer, app-foreground). Although this class + // is @MainActor, the `await` points inside processPhotoQueue/etc. + // yield the actor, so a second triggerSync() call can interleave + // between those awaits and run concurrently with the first. + // + // Without this guard, two overlapping passes both fetch the same + // "pending" PendingPhoto records (neither has flipped uploadStatus + // yet), both upload the same local file, and each appends its own + // distinct server-generated filename to LocalIssue.photoServerPaths. + // The `!paths.contains(serverPath)` dedup check in processPhotoQueue + // never catches this because the two server paths are different + // strings for the same photo content — producing duplicated photos + // in the issue's evidence (and therefore in the exported PDF). + // + // Guarding re-entrancy here closes the race at its source rather + // than trying to dedupe by content downstream. + guard !isSyncing else { return } + + isSyncing = true + syncError = nil + defer { isSyncing = false } + + await processPhotoQueue(context: context) + await processInspectionQueue(context: context) + await processIssueQueue(context: context) + await pullReferenceData() + await pullAssignedIssues(context: context) + await pullScheduledInspections(context: context) + + // Poll notifications immediately on every sync rather than waiting + // for the 60-second timer — ensures the inspector sees assignments + // and follow-up requests as soon as the app goes online. + await pollNotifications() + + // Fetch dashboard KPIs — best-effort, non-fatal on failure. + await fetchDashboardStats() + + // Periodically remove local photo files that are no longer needed. + // Runs at most once per hour to avoid repeated FileManager calls on + // every 60-second sync cycle. + cleanupOrphanedPhotos(context: context) + + updatePendingCount(context: context) + lastSyncAt = Date() + } + + // ── Outbox: Photos ──────────────────────────────────────────────────── + + private func processPhotoQueue(context: ModelContext) async { + // Fetch all then filter in Swift — #Predicate cannot reference + // string literals against PendingPhoto.uploadStatus reliably + // when the predicate type is inferred across model boundaries. + guard let allPhotos = try? context.fetch(FetchDescriptor()) else { return } + var pending = allPhotos + .filter { $0.uploadStatus == "pending" } + .sorted { $0.createdAt < $1.createdAt } + + // Defense-in-depth: if two PendingPhoto rows somehow reference the + // exact same local file (e.g. a future call site re-submitting the + // same photo array), only upload it once. The primary fix for photo + // duplication is the re-entrancy guard in triggerSync(), but this + // keeps processPhotoQueue itself safe even if it's ever invoked + // outside that guard. + var seenPaths = Set() + var duplicates: [PendingPhoto] = [] + pending = pending.filter { photo in + if seenPaths.contains(photo.localFilePath) { + duplicates.append(photo) + return false + } + seenPaths.insert(photo.localFilePath) + return true + } + for dup in duplicates { + // Mark the duplicate row as uploaded without re-uploading — the + // first row for this file will populate serverPath/photoServerPaths. + dup.uploadStatus = "uploaded" + } + + // Pre-fetch parent records ONCE before the loop. + // Without this, every successful photo upload fetched ALL LocalInspection + // and ALL LocalIssue records from SwiftData to find the parent — + // N photos → 2N full-table fetches. Pre-fetching here reduces that + // to 2 fetches regardless of how many photos are in the queue. + // Fetch-all + filter in Swift — #Predicate with a captured String variable + // causes "LocalInspection is ambiguous" under Xcode 26 (CLAUDE.md rule 3). + let allInspections = (try? context.fetch(FetchDescriptor())) ?? [] + let allIssues = (try? context.fetch(FetchDescriptor())) ?? [] + + for photo in pending { + do { + let serverPath = try await APIClient.shared.uploadPhoto( + localPath: photo.localFilePath, + entityType: photo.entityType + ) + photo.serverPath = serverPath + photo.uploadStatus = "uploaded" + + // Update parent inspection form field value. + // Pre-fetched before the loop — not repeated per photo. + if photo.entityType == "inspection", let fieldId = photo.fieldId { + let entityId = photo.entityLocalId + allInspections.first(where: { $0.localId == entityId })? + .setValue(serverPath, forFieldId: fieldId) + } + + // Update parent issue photo paths array. + // Pre-fetched before the loop — not repeated per photo. + if photo.entityType == "issue" { + let entityId = photo.entityLocalId + if let issue = allIssues.first(where: { $0.localId == entityId }) { + var paths = issue.photoServerPaths + if !paths.contains(serverPath) { paths.append(serverPath) } + issue.photoServerPaths = paths + } + } + + try? context.save() + + } catch { + photo.uploadStatus = "failed" + try? context.save() + } + } + } + + // ── Outbox: Inspections ─────────────────────────────────────────────── + + private func processInspectionQueue(context: ModelContext) async { + guard let all = try? context.fetch(FetchDescriptor()) else { return } + let pending = all + .filter { $0.status == "completed" && $0.syncStatus == "pending" } + .sorted { $0.createdAt < $1.createdAt } + + for inspection in pending { + let photosReady = inspection.pendingPhotos.allSatisfy { + $0.uploadStatus == "uploaded" || $0.uploadStatus == "failed" + } + guard photosReady else { continue } + + do { + let inspectionId = try await APIClient.shared.submitInspection(inspection) + inspection.serverId = inspectionId + inspection.syncStatus = "synced" + inspection.status = "synced" + + // Clear follow-up flag on parent. + // Reuses the `all` array already fetched at the top of this + // function — avoids a redundant full-table fetch per inspection. + if let parentLocalId = inspection.parentLocalId, + let parent = all.first(where: { $0.localId == parentLocalId }) { + parent.followUpRequired = false + parent.followUpNote = nil + } + + try? context.save() + + } catch { + inspection.syncRetryCount += 1 + inspection.syncErrorMessage = error.localizedDescription + if inspection.syncRetryCount >= 5 { + inspection.syncStatus = "failed" + } + syncError = "Failed to sync inspection: \(error.localizedDescription)" + try? context.save() + } + } + } + + // ── Outbox: Issues ──────────────────────────────────────────────────── + + private func processIssueQueue(context: ModelContext) async { + guard let all = try? context.fetch(FetchDescriptor()) else { return } + let pending = all + .filter { $0.syncStatus == "pending" } + .sorted { $0.createdAt < $1.createdAt } + + // Pre-fetch all inspections to check parent sync status AND to resolve + // the parent's serverId. The allInspections array is the same set of + // objects that processInspectionQueue updated (serverId written in-memory + // this same triggerSync pass), so looking up serverId here is reliable. + // issue.inspection?.serverId is NOT reliable — it navigates a @Relationship + // that SwiftData may have loaded as a separate object instance before + // processInspectionQueue wrote the serverId back, leaving it nil even + // when the parent inspection already synced successfully this same pass. + let allInspections = (try? context.fetch(FetchDescriptor())) ?? [] + + for issue in pending { + let parentLocalId = issue.inspectionLocalId + let parent = allInspections.first(where: { $0.localId == parentLocalId }) + + // ── Parent inspection status guards ─────────────────────────── + if let parent { + switch parent.syncStatus { + case "failed": + // Parent permanently failed — this issue can never be linked. + // Mark it failed immediately rather than creating an orphaned + // server record with no inspection_id. + issue.syncStatus = "failed" + issue.syncErrorMessage = "Parent inspection failed to sync — issue cannot be submitted." + try? context.save() + syncError = "Issue \(issue.localId.prefix(8))\u{2026} blocked: parent inspection did not sync." + continue + + case "synced": + // Parent has a serverId — proceed and link correctly. + break + + default: + // Parent is still "pending" (draft or awaiting submission). + // Submitting now would create a server issue with no + // inspection_id — the issue and inspection appear unlinked + // on the web. Defer until the next triggerSync() pass, by + // which point processInspectionQueue will have synced the + // parent and assigned it a serverId. + continue + } + } + // parent == nil means inspectionLocalId == "" (standalone issue) — submit without inspection_id. + + // Resolve the parent's server ID. Safe to force-unwrap serverId + // here — the switch above guarantees parent.syncStatus == "synced" + // when parent is non-nil, so serverId is always set at this point. + let inspectionServerId = parent?.serverId + + do { + let issueId = try await APIClient.shared.submitIssue(issue, inspectionServerId: inspectionServerId) + issue.serverId = issueId + issue.syncStatus = "synced" + // Photos are now represented by photoServerPaths on the server. + // Clear the local file paths so IssueDetailView doesn't render + // a duplicate "local photos" section alongside the server section. + issue.photoLocalPaths = [] + try? context.save() + + // If there are additional photos beyond the first (which was sent + // as photo_path on create), PATCH them to result_photos now. + // The server create endpoint only stores photo_path; result_photos + // must be set via a separate PATCH call. + let extras = Array(issue.photoServerPaths.dropFirst()) + if !extras.isEmpty { + try? await APIClient.shared.updateIssuePhotos( + issueId: issueId, resultPhotos: extras + ) + } + + } catch { + issue.syncRetryCount += 1 + issue.syncErrorMessage = error.localizedDescription + if issue.syncRetryCount >= 5 { + issue.syncStatus = "failed" + } + try? context.save() + } + } + } + + // ── Reference Data ──────────────────────────────────────────────────── + + func pullReferenceData() async { + guard isOnline, let context = modelContext else { return } + + do { + let facilitiesData: FacilitiesResponseData = + try await APIClient.shared.request("/api/v1/facilities") + let templatesData: TemplatesResponseData = + try await APIClient.shared.request("/api/v1/templates") + + let existingFacilities = try context.fetch(FetchDescriptor()) + let facilityMap = Dictionary( + existingFacilities.map { ($0.serverId, $0) }, + uniquingKeysWith: { a, _ in a } + ) + + // Deduplicate the server response by id before upserting. + // The server may return the same facility id more than once + // (e.g. one row per contract assignment), which would insert + // duplicate LocalFacility records and show buildings twice in + // every picker. Keep only the first occurrence of each id. + var seenFacilityIds = Set() + let uniqueFacilities = facilitiesData.facilities.filter { + seenFacilityIds.insert($0.id).inserted + } + + for apiFacility in uniqueFacilities { + let localFacility: LocalFacility + if let existing = facilityMap[apiFacility.id] { + existing.update(from: apiFacility) + localFacility = existing + } else { + let newFacility = LocalFacility(from: apiFacility) + context.insert(newFacility) + localFacility = newFacility + } + try await upsertAreas(for: apiFacility.id, facility: localFacility, context: context) + } + + let existingTemplates = try context.fetch(FetchDescriptor()) + let templateMap = Dictionary( + existingTemplates.map { ($0.serverId, $0) }, + uniquingKeysWith: { a, _ in a } + ) + + for apiSummary in templatesData.templates { + let summaryChanged: Bool + if let existing = templateMap[apiSummary.id] { + summaryChanged = existing.updateSummary(from: apiSummary) + } else { + context.insert(LocalTemplate(from: apiSummary)) + summaryChanged = true // new template — must fetch schema + } + try await upsertTemplateSchema( + id: apiSummary.id, + context: context, + templateMap: templateMap, + summaryChanged: summaryChanged + ) + } + + // Delete any cached templates the server no longer returns. + // The server endpoint now only returns active templates, so any + // locally cached template not in the response was deactivated. + // Deleting ensures they never appear in the picker even offline. + let returnedTemplateIds = Set(templatesData.templates.map { $0.id }) + for existing in existingTemplates { + if !returnedTemplateIds.contains(existing.serverId) { + context.delete(existing) + } + } + + try context.save() + + } catch APIError.notAuthenticated { + syncError = "Session expired. Please log in again." + } catch { + syncError = "Sync failed: \(error.localizedDescription)" + } + } + + // ── Pending Count ───────────────────────────────────────────────────── + + // ── Orphaned Photo Cleanup ──────────────────────────────────────────── + // Removes local JPEG files from Documents/JQCPhotos/ that are no longer + // referenced by any LocalInspection, LocalIssue, or PendingPhoto record. + // Once an inspection or issue is fully synced its local photos are no + // longer needed — the server holds the canonical copies. Without this, + // weeks of inspections accumulate hundreds of MBs of orphaned files. + // + // Throttled to once per hour via UserDefaults to avoid redundant + // FileManager enumeration on every 60-second sync cycle. + + private static let lastCleanupKey = "jqc.photoCleanup.lastRunAt" + private static let cleanupInterval: TimeInterval = 3600 // 1 hour + + private func cleanupOrphanedPhotos(context: ModelContext) { + let last = UserDefaults.standard.object(forKey: Self.lastCleanupKey) as? Date + guard last == nil || Date().timeIntervalSince(last!) >= Self.cleanupInterval else { return } + UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey) + + // ── Phase 1: collect referenced paths on @MainActor (SwiftData fetches) ── + // These are fast in-memory operations — always runs on the main actor. + var referencedPaths = Set() + + // PendingPhoto — not yet uploaded + if let pendingPhotos = try? context.fetch(FetchDescriptor()) { + for p in pendingPhotos where p.uploadStatus != "uploaded" { + referencedPaths.insert(p.localFilePath) + } + } + // LocalInspection — draft photos (formData values starting with "local://") + if let inspections = try? context.fetch(FetchDescriptor()) { + for insp in inspections where insp.status == "draft" { + for val in insp.formData.values { + if let s = val as? String, s.hasPrefix("local://") { + referencedPaths.insert(String(s.dropFirst("local://".count))) + } + } + } + } + // LocalIssue — unsync'd issue photos + if let issues = try? context.fetch(FetchDescriptor()) { + for issue in issues where issue.syncStatus != "synced" { + for path in issue.photoLocalPaths { referencedPaths.insert(path) } + } + } + + // ── Phase 2: FileManager enumeration + deletion on a background thread ── + // Directory enumeration and file removal are I/O-bound and can stutter + // the main thread when JQCPhotos/ contains hundreds of files. Dispatching + // here is safe because `referencedPaths` is a value type (Set) + // captured by copy — no shared mutable state crosses the boundary. + Task.detached(priority: .utility) { + let fm = FileManager.default + guard let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + let photosDir = docsDir.appendingPathComponent("JQCPhotos") + guard let diskFiles = try? fm.contentsOfDirectory( + at: photosDir, includingPropertiesForKeys: nil + ) else { return } + + var deletedCount = 0 + for fileURL in diskFiles { + if !referencedPaths.contains(fileURL.path) { + try? fm.removeItem(at: fileURL) + deletedCount += 1 + } + } + if deletedCount > 0 { + print("[JQC] Sync | cleanupOrphanedPhotos | removed \(deletedCount) file(s)") + } + } + } + + func updatePendingCount(context: ModelContext) { + // Fetch-all + filter in Swift — #Predicate with string literals is + // banned under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION (CLAUDE.md rule 3). + // These fetches are lightweight (no relationships loaded) and run once + // per sync cycle at the very end, not in a hot loop. + let inspCount = (try? context.fetch(FetchDescriptor()))? + .filter { $0.syncStatus == "pending" }.count ?? 0 + let issueCount = (try? context.fetch(FetchDescriptor()))? + .filter { $0.syncStatus == "pending" }.count ?? 0 + pendingCount = inspCount + issueCount + } + + // ── Private Helpers ─────────────────────────────────────────────────── + + private func upsertAreas(for facilityId: Int, facility: LocalFacility, context: ModelContext) async throws { + let areasData: AreasResponseData = try await APIClient.shared.request( + "/api/v1/facilities/\(facilityId)/areas" + ) + let existing = (try? context.fetch(FetchDescriptor()))? + .filter { $0.facilityServerId == facilityId } ?? [] + let areaMap = Dictionary(existing.map { ($0.serverId, $0) }, + uniquingKeysWith: { a, _ in a }) + for apiArea in areasData.areas { + if let ex = areaMap[apiArea.id] { + ex.update(from: apiArea) + // Re-wire relationship in case it was lost (e.g. cache clear) + if ex.facility == nil { ex.facility = facility } + } else { + let newArea = LocalArea(from: apiArea) + // Wire the inverse relationship so LocalFacility.areas is populated. + // Without this assignment SwiftData never links the area into the + // facility's areas array and selectedFacility?.areas returns []. + newArea.facility = facility + context.insert(newArea) + } + } + } + + private func upsertTemplateSchema( + id: Int, + context: ModelContext, + templateMap: [Int: LocalTemplate], + summaryChanged: Bool + ) async throws { + let existing = templateMap[id] ?? (try? context.fetch(FetchDescriptor()))? + .first { $0.serverId == id } + + // Skip the detail API call if: + // • The schema was already fetched (schemaFetchedAt is non-nil) + // • No summary fields changed this sync pass (name, frequency, isActive) + // • The local schema is non-empty (not a first-run blank) + // + // This reduces N sequential GET /api/v1/templates/{id} calls to zero + // on a typical sync where templates haven't changed — the common case. + // The schema is always re-fetched when any summary field changes, + // when schemaFetchedAt is nil (new template or first launch), or + // when the local schema is empty ("[]"). + if let existing, + existing.schemaFetchedAt != nil, + existing.formSchemaJSON != "[]", + !summaryChanged { + return // schema is current — no network call needed + } + + let detailData: TemplateDetailResponseData = try await APIClient.shared.request( + "/api/v1/templates/\(id)" + ) + existing?.updateSchema(from: detailData.template) + } + + // ── Pull server-assigned issues ─────────────────────────────────────── + // Fetches issues assigned to the current user on the server and upserts + // them into SwiftData so IssuesListView shows them alongside device-created issues. + // Keyed by serverId — existing records are updated in-place, new ones inserted. + // These records carry syncStatus = "synced" and a generated localId so they + // are never re-submitted to the server by processIssueQueue. + + func pullAssignedIssues(context: ModelContext) async { + guard isOnline, AuthManager.shared.isAuthenticated else { return } + do { + let apiIssues = try await APIClient.shared.fetchAssignedIssues() + // Do not return early on empty — deletion still needs to run + // to remove issues that were unassigned from this inspector. + + // Build a map of existing LocalIssues by serverId for upsert + let allLocal = (try? context.fetch(FetchDescriptor())) ?? [] + var serverIdMap: [Int: LocalIssue] = [:] + for local in allLocal { + if let sid = local.serverId { serverIdMap[sid] = local } + } + + for api in apiIssues { + if let existing = serverIdMap[api.id] { + // Update mutable fields on existing record + existing.issueStatus = api.status + existing.severity = api.severity + existing.issueDescription = api.description + if let fid = api.facilityId { existing.facilityServerId = fid } + // Cache facility name so IssueDetailView works when local + // facility reference cache has been cleared (Settings → Clear Cache). + if let fn = api.facilityName, !fn.isEmpty { + existing.facilityNameCache = fn + } + // Phase A — resolution details from web staff + existing.resultNotes = api.resultNotes + existing.verificationNote = api.verificationNote + existing.reportedByName = api.reportedByName + existing.areaNameCache = api.areaName + existing.assignedToName = api.assignedToName + // Handler ("Handled By", phase35) + existing.handlerType = api.handlerType + existing.handlerLabel = api.handlerLabel + existing.facilityHandlerName = api.facilityHandlerName + existing.facilityHandlerContact = api.facilityHandlerContact + existing.facilityHandlerNotes = api.facilityHandlerNotes + existing.vendorName = api.vendorName + existing.vendorContact = api.vendorContact + existing.vendorNotes = api.vendorNotes + if let vts = api.verifiedAt, + let date = Self.isoFormatter.date(from: vts) { + existing.verifiedAt = date + } + // Refresh photos in case they were added after first pull + // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). + // result_photos are resolution photos — shown separately under + // "Resolution Details" in resultPhotoServerPaths. + var serverPaths: [String] = [] + if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } + serverPaths.append(contentsOf: api.mobilePhotoPaths) + existing.photoServerPaths = serverPaths + existing.resultPhotoServerPaths = api.resultPhotos + } else { + // Insert new server-pulled issue + let local = LocalIssue( + inspectionLocalId: "", + facilityServerId: api.facilityId ?? 0, + severity: api.severity, + description: api.description + ) + local.serverId = api.id + local.issueStatus = api.status + local.syncStatus = "synced" // never re-submit + // Cache facility name for offline display + local.facilityNameCache = api.facilityName + // Phase A — resolution details from web staff + local.resultNotes = api.resultNotes + local.verificationNote = api.verificationNote + local.reportedByName = api.reportedByName + local.areaNameCache = api.areaName + local.assignedToName = api.assignedToName + // Handler ("Handled By", phase35) + local.handlerType = api.handlerType + local.handlerLabel = api.handlerLabel + local.facilityHandlerName = api.facilityHandlerName + local.facilityHandlerContact = api.facilityHandlerContact + local.facilityHandlerNotes = api.facilityHandlerNotes + local.vendorName = api.vendorName + local.vendorContact = api.vendorContact + local.vendorNotes = api.vendorNotes + if let vts = api.verifiedAt, + let date = Self.isoFormatter.date(from: vts) { + local.verifiedAt = date + } + // Store server photos so IssueDetailView can show them + // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). + var serverPaths: [String] = [] + if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } + serverPaths.append(contentsOf: api.mobilePhotoPaths) + local.photoServerPaths = serverPaths + local.resultPhotoServerPaths = api.resultPhotos + if let ts = api.reportedAt, + let date = Self.isoFormatter.date(from: ts) { + local.createdAt = date + local.serverReportedAt = date // accurate server timestamp + } + context.insert(local) + } + } + + // Remove server-pulled records that are no longer in the response. + // This happens when an issue is reassigned to a different inspector — + // the server stops returning it for this user, so the local copy must + // be deleted. Only remove records that were pulled from the server + // (syncStatus == "synced" AND serverId != nil AND inspectionLocalId == ""). + // Device-created issues (inspectionLocalId != "") are never touched. + let returnedServerIds = Set(apiIssues.map { $0.id }) + for local in allLocal { + guard let sid = local.serverId, + local.syncStatus == "synced", + local.inspectionLocalId == "" + else { continue } + if !returnedServerIds.contains(sid) { + context.delete(local) + } + } + + try? context.save() + + } catch APIError.notAuthenticated { + // Let AuthManager handle session expiry + } catch { + // Non-fatal — IssuesListView still shows device-created issues + } + } + + // ── Scheduled Inspections (phase36) ─────────────────────────────────── + // Read-only pull of planned/recurring assignments for the Dashboard and + // My Inspections "Scheduled" section. Upsert by serverId, then delete rows + // the server no longer returns (schedule fulfilled, deactivated, or + // reassigned to another inspector). Best-effort — never blocks the pipeline. + + func pullScheduledInspections(context: ModelContext) async { + guard isOnline, AuthManager.shared.isAuthenticated else { return } + do { + let apiRows = try await APIClient.shared.fetchScheduledInspections() + + // Fetch-all + filter/map in Swift — no #Predicate (CLAUDE.md rule 3). + let allLocal = (try? context.fetch(FetchDescriptor())) ?? [] + var byServerId: [Int: LocalScheduledInspection] = [:] + for row in allLocal { byServerId[row.serverId] = row } + + for api in apiRows { + let row = byServerId[api.id] ?? { + let r = LocalScheduledInspection(serverId: api.id) + context.insert(r) + return r + }() + row.facilityServerId = api.facilityId + row.facilityName = api.facilityName ?? "" + row.templateServerId = api.templateId + row.templateName = api.templateName ?? "" + row.inspectorId = api.inspectorId + row.frequency = api.frequency + row.frequencyLabel = api.frequencyLabel ?? "" + row.dueDateString = api.nextDueDate ?? "" + row.nextDue = api.nextDueDate.flatMap { Self.dateOnlyFormatter.date(from: $0) } + row.isOverdue = api.isOverdue + row.notes = api.notes + row.updatedAt = Date() + } + + // Delete rows the server no longer returns. + let returnedIds = Set(apiRows.map { $0.id }) + for row in allLocal where !returnedIds.contains(row.serverId) { + context.delete(row) + } + + try? context.save() + + } catch APIError.notAuthenticated { + // Let AuthManager handle session expiry + } catch { + // Non-fatal — stale scheduled rows stay visible until next pull + } + } + + // ── Dashboard Stats ─────────────────────────────────────────────────── + // Best-effort fetch — a network failure silently leaves dashboardStats nil + // so the UI falls back to a placeholder card. Never blocks the sync pipeline. + + func fetchDashboardStats() async { + guard isOnline, AuthManager.shared.isAuthenticated else { return } + do { + dashboardStats = try await APIClient.shared.fetchDashboardStats() + } catch APIError.notAuthenticated { + // Let AuthManager handle session expiry + } catch { + // Non-fatal — stale stats stay visible until next successful fetch + } + } +} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/DashboardView.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/DashboardView.swift new file mode 100644 index 0000000..71dd6fe --- /dev/null +++ b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/DashboardView.swift @@ -0,0 +1,644 @@ +// Views/Dashboard/DashboardView.swift +// ------------------------------------ +// Phase C: adds Inspection History tab, polished Settings with cache clear, +// and schedules background sync on scene enter background. +// +// CHANGED (sidebar update): +// - Templates removed from sidebar entirely. +// - Issues view added for all roles. +// - History moved to sit between Pending Sync and Settings. +// - Tab identity is now enum-based (SidebarTab) instead of raw Int +// so adding/removing tabs never breaks the detail switch. + +import SwiftUI +import SwiftData +import Combine +import MessageUI + +// MARK: - SidebarTab + +enum SidebarTab: Hashable { + case dashboard // landing page — KPI stats card + case myInspections + case issues + case facilities + case pendingSync + case history + case notifications // in-app notification inbox + case settings +} + +struct DashboardView: View { + + @EnvironmentObject private var auth: AuthManager + @EnvironmentObject private var sync: SyncManager + @Environment(\.modelContext) private var context + @Environment(\.scenePhase) private var scenePhase + + @Query( + filter: #Predicate { $0.status != "synced" }, + sort: \LocalInspection.lastModifiedAt, + order: .reverse + ) private var myInspections: [LocalInspection] + + @State private var selectedTab: SidebarTab = .dashboard + /// Each sidebar tap refreshes the UUID for that tab, forcing its + /// NavigationStack to be destroyed and recreated — even when the tab + /// hasn't changed (user is already on it but deep inside a detail view). + @State private var tabResetId: [SidebarTab: UUID] = [ + .dashboard: UUID(), + .myInspections: UUID(), + .issues: UUID(), + .facilities: UUID(), + .pendingSync: UUID(), + .history: UUID(), + .notifications: UUID(), + .settings: UUID(), + ] + + /// Explicit paths for the three tabs that push detail views. + /// Resetting these to empty pops the stack to root immediately and reliably. + @State private var inspectionsPath = NavigationPath() + @State private var issuesPath = NavigationPath() + @State private var historyPath = NavigationPath() + + /// Tap a sidebar tab: reset all navigable paths, then switch to it. + private func selectTab(_ tab: SidebarTab) { + inspectionsPath = NavigationPath() + issuesPath = NavigationPath() + historyPath = NavigationPath() + tabResetId[tab] = UUID() + selectedTab = tab + } + + var body: some View { + NavigationSplitView { + List { + // ── Dashboard ────────────────────────────────────────────── + Button { selectTab(.dashboard) } label: { + Label("Dashboard", systemImage: "chart.bar.xaxis") + .foregroundStyle(selectedTab == .dashboard ? .blue : .primary) + } + .listRowBackground(selectedTab == .dashboard ? Color.blue.opacity(0.1) : Color.clear) + + // ── My Inspections ───────────────────────────────────────── + Button { selectTab(.myInspections) } label: { + HStack { + Label("My Inspections", systemImage: "checklist") + .foregroundStyle(selectedTab == .myInspections ? .blue : .primary) + Spacer() + if !myInspections.isEmpty { + Text("\(myInspections.count)") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.blue.opacity(0.15)) + .clipShape(Capsule()) + } + } + } + .listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear) + + // ── Issues (all roles) ───────────────────────────────────── + Button { selectTab(.issues) } label: { + Label("Issues", systemImage: "exclamationmark.triangle") + .foregroundStyle(selectedTab == .issues ? .blue : .primary) + } + .listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear) + + // ── Facilities ───────────────────────────────────────────── + Button { selectTab(.facilities) } label: { + Label("Facilities", systemImage: "building.2") + .foregroundStyle(selectedTab == .facilities ? .blue : .primary) + } + .listRowBackground(selectedTab == .facilities ? Color.blue.opacity(0.1) : Color.clear) + + // ── Pending Sync ─────────────────────────────────────────── + Button { selectTab(.pendingSync) } label: { + HStack { + Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") + .foregroundStyle(selectedTab == .pendingSync ? .blue : .primary) + Spacer() + if sync.pendingCount > 0 { + Text("\(sync.pendingCount)") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.orange.opacity(0.2)) + .foregroundStyle(.orange) + .clipShape(Capsule()) + } + } + } + .listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear) + + // ── History (moved — sits between Pending Sync and Settings) + Button { selectTab(.history) } label: { + Label("History", systemImage: "clock.arrow.circlepath") + .foregroundStyle(selectedTab == .history ? .blue : .primary) + } + .listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear) + + // ── Notifications ───────────────────────────────────────── + Button { + selectTab(.notifications) + sync.markNotificationsViewed() + } label: { + HStack { + Label("Notifications", systemImage: "bell") + .foregroundStyle(selectedTab == .notifications ? .blue : .primary) + Spacer() + if sync.unreadNotificationCount > 0 { + Text("\(min(sync.unreadNotificationCount, 99))") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.red.opacity(0.85)) + .foregroundStyle(.white) + .clipShape(Capsule()) + } + } + } + .listRowBackground(selectedTab == .notifications ? Color.blue.opacity(0.1) : Color.clear) + + // ── Settings ─────────────────────────────────────────────── + Button { selectTab(.settings) } label: { + Label("Settings", systemImage: "gear") + .foregroundStyle(selectedTab == .settings ? .blue : .primary) + } + .listRowBackground(selectedTab == .settings ? Color.blue.opacity(0.1) : Color.clear) + } + .navigationTitle("JQC Inspector") + .listStyle(.sidebar) + .safeAreaInset(edge: .bottom) { syncStatusFooter } + + } detail: { + switch selectedTab { + case .dashboard: + NavigationStack { DashboardStatsView() } + case .myInspections: + NavigationStack(path: $inspectionsPath) { + MyInspectionsView() + .navigationDestination(for: LocalInspection.self) { inspection in + if inspection.status == "draft" { + ExecuteInspectionView(inspection: inspection) + } else { + CompletedInspectionView(inspection: inspection) + } + } + } + case .issues: + NavigationStack(path: $issuesPath) { + IssuesListView() + .navigationDestination(for: LocalIssue.self) { issue in + IssueDetailView(issue: issue) + } + } + case .facilities: + NavigationStack { FacilitiesListView() } + case .pendingSync: + NavigationStack { SyncStatusView() } + case .history: + NavigationStack(path: $historyPath) { + InspectionHistoryView() + .navigationDestination(for: APIInspectionSummary.self) { inspection in + HistoryDetailView(inspection: inspection) + } + } + case .notifications: + NavigationStack { NotificationsView() } + case .settings: + NavigationStack { SettingsView() } + } + } + .task { + if sync.isOnline { + await sync.triggerSync() + } else { + sync.updatePendingCount(context: context) + } + } + // Schedule background sync when app is backgrounded + .onChange(of: scenePhase) { + if scenePhase == .background { + scheduleBackgroundSync() + } + } + } + + private var syncStatusFooter: some View { + VStack(spacing: 0) { + Divider() + HStack(spacing: 8) { + Circle() + .fill(sync.isOnline ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text(sync.isOnline ? "Online" : "Offline") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if sync.isSyncing { + ProgressView().scaleEffect(0.7) + } else if let lastSync = sync.lastSyncAt { + Text("Synced \(lastSync.formatted(.relative(presentation: .named)))") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } +} + +// MARK: - Dashboard Stats View +// Shows inspector-scoped KPI cards fetched from GET /api/v1/stats/dashboard. +// Data is refreshed on every triggerSync() via SyncManager.fetchDashboardStats(). + +struct DashboardStatsView: View { + + @EnvironmentObject private var sync: SyncManager + + // Draft inspections — shown as a resume banner at the top of the dashboard + // so the inspector never has to hunt through My Inspections to find an + // in-progress form they left open. + @Query( + filter: #Predicate { $0.status == "draft" }, + sort: \LocalInspection.lastModifiedAt, + order: .reverse + ) private var draftInspections: [LocalInspection] + @Environment(\.modelContext) private var context + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + + // ── Draft Resume Banner ──────────────────────────────────── + if !draftInspections.isEmpty { + DraftResumeBanner(drafts: draftInspections, context: context) + } + + // ── Scheduled Inspections (phase36) ──────────────────────── + // Planned/recurring assignments for this inspector. Self-hides + // when there are none. Tap a row to start it (facility + + // template preselected). + ScheduledInspectionsCard() + + if let stats = sync.dashboardStats { + // ── Today ────────────────────────────────────────────── + statsSection(title: "Today") { + HStack(spacing: 12) { + statTile( + value: "\(stats.todayInspections)", + label: "Inspections", + icon: "checklist", + color: .blue + ) + statTile( + value: "\(stats.completedToday)", + label: "Completed", + icon: "checkmark.circle.fill", + color: .green + ) + } + } + + // ── Issues ───────────────────────────────────────────── + statsSection(title: "Issues") { + HStack(spacing: 12) { + statTile( + value: "\(stats.openIssues)", + label: "Open / In Progress", + icon: "exclamationmark.triangle", + color: .orange + ) + statTile( + value: "\(stats.pendingFollowups)", + label: "Pending Follow-ups", + icon: "exclamationmark.arrow.circlepath", + color: stats.pendingFollowups > 0 ? .orange : .secondary + ) + } + } + + // ── SLA ──────────────────────────────────────────────── + if stats.slaBreached > 0 || stats.slaAtRisk > 0 { + statsSection(title: "SLA") { + HStack(spacing: 12) { + statTile( + value: "\(stats.slaBreached)", + label: "Breached", + icon: "xmark.circle.fill", + color: stats.slaBreached > 0 ? .red : .secondary + ) + statTile( + value: "\(stats.slaAtRisk)", + label: "At Risk", + icon: "clock.badge.exclamationmark", + color: stats.slaAtRisk > 0 ? .orange : .secondary + ) + } + } + } + + // ── Severity breakdown ───────────────────────────────── + if stats.openIssues > 0 { + statsSection(title: "Open Issues by Severity") { + HStack(spacing: 8) { + if stats.severityCritical > 0 { + severityTile(count: stats.severityCritical, label: "Critical", color: .red) + } + if stats.severityHigh > 0 { + severityTile(count: stats.severityHigh, label: "High", color: .orange) + } + if stats.severityMedium > 0 { + severityTile(count: stats.severityMedium, label: "Medium", color: .yellow) + } + if stats.severityLow > 0 { + severityTile(count: stats.severityLow, label: "Low", color: .blue) + } + } + } + } + + // ── Score ────────────────────────────────────────────── + statsSection(title: "Performance (30 days)") { + if let avg = stats.avgScore30d { + let color: Color = avg >= 80 ? .green : avg >= 60 ? .orange : .red + HStack(spacing: 16) { + Text(String(format: "%.1f%%", avg)) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundStyle(color) + VStack(alignment: .leading, spacing: 4) { + Text("Average Score") + .font(.subheadline) + .foregroundStyle(.secondary) + Text(avg >= 80 ? "Excellent" : avg >= 60 ? "Needs Improvement" : "Below Standard") + .font(.caption.bold()) + .foregroundStyle(color) + } + } + .padding(.vertical, 4) + } else { + Text("No completed inspections in the last 30 days.") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + } else if !sync.isOnline { + ContentUnavailableView( + "Offline", + systemImage: "wifi.slash", + description: Text("Dashboard stats require an internet connection.") + ) + } else { + VStack(spacing: 16) { + ProgressView("Loading stats…") + Text("Stats appear after the first sync completes.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 60) + } + } + .padding(24) + } + .navigationTitle("Dashboard") + .navigationBarTitleDisplayMode(.large) + .refreshable { + await sync.fetchDashboardStats() + } + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + @ViewBuilder + private func statsSection( + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title.uppercased()) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .tracking(1) + content() + } + } + + private func statTile( + value: String, + label: String, + icon: String, + color: Color + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(color) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Text(value) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundStyle(color) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(color.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private func severityTile(count: Int, label: String, color: Color) -> some View { + VStack(spacing: 4) { + Text("\(count)") + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundStyle(color) + Text(label) + .font(.caption2.bold()) + .foregroundStyle(color.opacity(0.8)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(color.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +// MARK: - Draft Resume Banner +// Shown on the dashboard when the inspector has one or more in-progress +// (draft) inspections. Tapping a draft opens ExecuteInspectionView as a +// full-screen sheet — avoids cross-NavigationStack linking since the +// dashboard and My Inspections stacks are independent. + +struct DraftResumeBanner: View { + let drafts: [LocalInspection] + let context: ModelContext + @State private var selectedDraft: LocalInspection? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label(drafts.count == 1 + ? "Inspection in progress" + : "\(drafts.count) inspections in progress", + systemImage: "pencil.and.list.clipboard") + .font(.subheadline.bold()) + .foregroundStyle(.white) + + ForEach(drafts) { draft in + Button { + selectedDraft = draft + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(templateName(for: draft)) + .font(.callout.bold()) + .foregroundStyle(.white) + Text(facilityName(for: draft)) + .font(.caption) + .foregroundStyle(.white.opacity(0.85)) + Text("Last saved \(draft.lastModifiedAt.formatted(.relative(presentation: .named)))") + .font(.caption2) + .foregroundStyle(.white.opacity(0.70)) + } + Spacer() + Label("Resume", systemImage: "play.fill") + .font(.caption.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(Color.white.opacity(0.25)) + .clipShape(Capsule()) + } + .padding(10) + .background(Color.white.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + .padding(14) + .background(Color.blue.gradient) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .fullScreenCover(item: $selectedDraft) { draft in + // Wrap in NavigationStack so ExecuteInspectionView's toolbar + // and dismiss work correctly when presented as a sheet. + NavigationStack { + ExecuteInspectionView(inspection: draft) + } + } + } + + private func templateName(for inspection: LocalInspection) -> String { + let id = inspection.templateServerId + return (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.serverId == id }) + ).first?.name) ?? "Inspection" + } + + private func facilityName(for inspection: LocalInspection) -> String { + let id = inspection.facilityServerId + let all = (try? context.fetch(FetchDescriptor())) ?? [] + return all.first(where: { $0.serverId == id })?.name ?? "Unknown Facility" + } +} + + +// MARK: - Retryable Photo + +/// Loads a server photo via AsyncImage with a tap-to-retry failure state. +/// AsyncImage has no built-in retry — once it enters .failure it stays there +/// for the view's lifetime. Toggling the `id` forces SwiftUI to destroy and +/// recreate the AsyncImage, triggering a fresh network load. +// MARK: - PhotoCache +// Simple NSCache-backed in-memory image cache keyed by URL string. +// Prevents RetryablePhotoView from re-downloading the same photo on every +// view appearance (AsyncImage only caches within a single URLSession load; +// revisiting IssueDetailView or scrolling the inspection history starts a +// fresh download). Cache entries are evicted automatically by the OS under +// memory pressure — no manual lifetime management needed. + +final class PhotoCache { + static let shared = PhotoCache() + private let cache = NSCache() + + private init() { + cache.countLimit = 150 // max images in memory + cache.totalCostLimit = 80_000_000 // ~80 MB total + } + + func get(_ url: URL) -> UIImage? { cache.object(forKey: url.absoluteString as NSString) } + func set(_ image: UIImage, for url: URL) { cache.setObject(image, forKey: url.absoluteString as NSString, + cost: Int(image.size.width * image.size.height * 4)) } +} + +struct RetryablePhotoView: View { + let url: URL? + @State private var reloadToken = UUID() + @State private var cached: UIImage? = nil + + var body: some View { + Group { + if let img = cached { + // Cache hit — instant display, no spinner, no network + Image(uiImage: img) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onAppear { + // Store into cache so next appearance is instant + if let url, let ui = ImageRenderer(content: image).uiImage { + PhotoCache.shared.set(ui, for: url) + cached = ui + } + } + case .failure: + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + Text("Photo unavailable") + .font(.caption) + .foregroundStyle(.secondary) + Button { + reloadToken = UUID() + } label: { + Label("Retry", systemImage: "arrow.clockwise") + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + case .empty: + HStack(spacing: 8) { + ProgressView() + Text("Loading…").font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + @unknown default: + EmptyView() + } + } + .id(reloadToken) + .onAppear { + // Check cache before AsyncImage fires a network request + if let url, let img = PhotoCache.shared.get(url) { + cached = img + } + } + } + } + } +} + diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/IssuesView.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/IssuesView.swift new file mode 100644 index 0000000..3f6a6f9 --- /dev/null +++ b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/IssuesView.swift @@ -0,0 +1,1336 @@ +// 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())) ?? [] + 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(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//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() + 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() + 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(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) + .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() + } + } +} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/MyInspectionsView.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/MyInspectionsView.swift new file mode 100644 index 0000000..e9a7bb2 --- /dev/null +++ b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/MyInspectionsView.swift @@ -0,0 +1,348 @@ +// Views/Dashboard/MyInspectionsView.swift +import SwiftUI +import SwiftData +import MessageUI + +// MARK: - My Inspections + +struct MyInspectionsView: View { + + @Query( + filter: #Predicate { $0.status != "synced" }, + sort: \LocalInspection.lastModifiedAt, + order: .reverse + ) private var inspections: [LocalInspection] + + /// Scheduled assignments (phase36) — rendered as the top section and used + /// for the empty-state decision. Sorted by due date (ISO strings sort + /// chronologically). + @Query(sort: \LocalScheduledInspection.dueDateString, order: .forward) + private var scheduledAll: [LocalScheduledInspection] + + @Environment(\.modelContext) private var context + + @State private var showNewInspection = false + @State private var scheduledStartTarget: LocalScheduledInspection? + + // Deletion confirmation state + @State private var pendingDelete: LocalInspection? + @State private var showDeleteAlert = false + + var body: some View { + Group { + if inspections.isEmpty && scheduledAll.isEmpty { + ContentUnavailableView( + "No Inspections", + systemImage: "checklist", + description: Text("Tap + to start a new inspection.") + ) + } else { + List { + // Scheduled assignments (phase36) — self-hides when empty. + if !scheduledAll.isEmpty { + Section("Scheduled") { + ForEach(scheduledAll) { s in + Button { scheduledStartTarget = s } label: { + ScheduledRow(schedule: s) + } + .buttonStyle(.plain) + } + } + } + + if !inspections.isEmpty { + Section("In Progress") { + ForEach(inspections) { inspection in + NavigationLink(value: inspection) { + InspectionRowView(inspection: inspection, context: context) + } + // Only drafts may be deleted — submitted/pending-sync inspections are kept + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if inspection.status == "draft" { + Button(role: .destructive) { + pendingDelete = inspection + showDeleteAlert = true + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } + } + } + // Cover attached to the stable List, not a Section. + .fullScreenCover(item: $scheduledStartTarget) { s in + StartInspectionView( + preFillTemplateId: s.templateServerId, + preFillFacilityId: s.facilityServerId + ) + } + } + } + .navigationTitle("My Inspections") + // Confirmation before deletion — destructive action cannot be undone + .alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in + Button("Delete", role: .destructive) { deleteDraft(inspection) } + Button("Cancel", role: .cancel) { pendingDelete = nil } + } message: { inspection in + Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.") + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showNewInspection = true } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showNewInspection) { + StartInspectionView() + } + } + + private func draftName(_ inspection: LocalInspection) -> String { + let templateId = inspection.templateServerId // plain Int — safe to capture in #Predicate + return (try? context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.serverId == templateId } + ) + ).first?.name) ?? "this inspection" + } + + private func deleteDraft(_ inspection: LocalInspection) { + // Delete associated pending photos from disk and SwiftData + for photo in inspection.pendingPhotos { + try? FileManager.default.removeItem(atPath: photo.localFilePath) + context.delete(photo) + } + // Delete associated local issues + for issue in inspection.localIssues { + for path in issue.photoLocalPaths { + try? FileManager.default.removeItem(atPath: path) + } + context.delete(issue) + } + context.delete(inspection) + try? context.save() + pendingDelete = nil + } +} + +struct InspectionRowView: View { + let inspection: LocalInspection + let context: ModelContext + + private var facilityName: String { + let id = inspection.facilityServerId + return (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.serverId == id }) + ).first?.name) ?? "Unknown Facility" + } + + private var templateName: String { + let id = inspection.templateServerId + return (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.serverId == id }) + ).first?.name) ?? "Unknown Template" + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(templateName).font(.headline) + Spacer() + StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) + } + Text(facilityName).font(.callout).foregroundStyle(.secondary) + HStack { + Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened)) + .font(.caption2).foregroundStyle(.tertiary) + if let score = inspection.overallScore { + Spacer() + Text(String(format: "%.1f%%", score)) + .font(.caption).fontWeight(.medium) + .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) + } + } + // ── Follow-up badge ──────────────────────────────────────────── + if inspection.followUpRequired { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.arrow.circlepath") + .font(.caption2) + Text("Follow-up Required") + .font(.caption2.bold()) + } + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color.orange.opacity(0.15)) + .foregroundStyle(.orange) + .clipShape(Capsule()) + } + } + .padding(.vertical, 4) + } +} + +struct StatusBadge: View { + let status: String + let syncStatus: String + + var label: String { + switch status { + case "draft": return "Draft" + case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed" + case "failed": return "Sync Failed" + default: return status.capitalized + } + } + + var color: Color { + switch status { + case "draft": return .blue + case "completed": return syncStatus == "pending" ? .orange : .green + case "failed": return .red + default: return .secondary + } + } + + var body: some View { + Text(label) + .font(.caption2) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(color.opacity(0.15)) + .foregroundStyle(color) + .clipShape(Capsule()) + } +} + +// MARK: - Completed Inspection (read-only) + +struct CompletedInspectionView: View { + let inspection: LocalInspection + @Environment(\.modelContext) private var context + + @State private var showReInspect = false + + private var templateName: String { + let id = inspection.templateServerId + return (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.serverId == id }) + ).first?.name) ?? "Inspection" + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + + // ── Follow-up required banner ────────────────────────────── + if inspection.followUpRequired { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.arrow.circlepath") + .foregroundStyle(.orange) + .font(.title3) + VStack(alignment: .leading, spacing: 4) { + Text("Follow-up Inspection Required") + .font(.callout.bold()) + .foregroundStyle(.orange) + if let note = inspection.followUpNote, !note.isEmpty { + Text(note) + .font(.callout) + .foregroundStyle(.secondary) + } + Button { + showReInspect = true + } label: { + Label("Start Re-inspection", systemImage: "arrow.uturn.right.circle.fill") + .font(.callout.bold()) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .padding(.top, 4) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal) + } + + // ── Is a re-inspection — parent link ─────────────────────── + if let parentId = inspection.parentServerId { + HStack(spacing: 10) { + Image(systemName: "arrow.uturn.right.circle") + .foregroundStyle(.secondary) + Text("Re-inspection of inspection #\(parentId)") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + } + + GroupBox { + VStack(alignment: .leading, spacing: 8) { + if let score = inspection.overallScore { + HStack { + Text("Overall Score").font(.subheadline).foregroundStyle(.secondary) + Spacer() + Text(String(format: "%.1f%%", score)) + .font(.title2.bold()) + .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) + } + } + if let completedAt = inspection.completedAt { + HStack { + Text("Completed").font(.subheadline).foregroundStyle(.secondary) + Spacer() + Text(completedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.callout) + } + } + HStack { + Text("Sync Status").font(.subheadline).foregroundStyle(.secondary) + Spacer() + StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) + } + if let error = inspection.syncErrorMessage { + Text("Error: \(error)").font(.caption).foregroundStyle(.red) + } + } + } + .padding(.horizontal) + + if !inspection.localIssues.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Flagged Issues (\(inspection.localIssues.count))") + .font(.headline).padding(.horizontal) + ForEach(inspection.localIssues) { issue in + HStack(alignment: .top, spacing: 12) { + Circle() + .fill(issue.severity == "critical" ? Color.red : + issue.severity == "high" ? Color.orange : + issue.severity == "medium" ? Color.yellow : Color.blue) + .frame(width: 8, height: 8).padding(.top, 4) + VStack(alignment: .leading, spacing: 2) { + Text(issue.severity.capitalized) + .font(.caption.bold()).foregroundStyle(.secondary) + Text(issue.issueDescription).font(.callout) + } + } + .padding(.horizontal) + } + } + } + } + .padding(.vertical) + } + .navigationTitle(templateName) + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showReInspect) { + StartInspectionView( + preFillTemplateId: inspection.templateServerId, + preFillFacilityId: inspection.facilityServerId, + parentServerId: inspection.serverId, + parentLocalId: inspection.localId + ) + } + } +} + diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/ScheduledInspectionsView.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/ScheduledInspectionsView.swift new file mode 100644 index 0000000..7cd6f71 --- /dev/null +++ b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/ScheduledInspectionsView.swift @@ -0,0 +1,113 @@ +// Views/Dashboard/ScheduledInspectionsView.swift +// ---------------------------------------------- +// Displays the inspector's planned/recurring inspection assignments (phase36), +// pulled read-only from GET /api/v1/scheduled-inspections by +// SyncManager.pullScheduledInspections(). +// +// Two consumers share one ScheduledRow: +// • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView +// • MyInspectionsView renders its own "Scheduled" List section inline, +// reusing ScheduledRow, with the start cover attached to the List. +// Both self-hide when there are no scheduled inspections and present +// StartInspectionView (facility + template preselected) when a row is tapped. +// The schedule lifecycle (fulfil / roll-forward) stays server-driven; tapping +// "Start" simply seeds the normal new-inspection flow. + +import SwiftUI +import SwiftData + +// MARK: - Shared row + +struct ScheduledRow: View { + let schedule: LocalScheduledInspection + + private var dueText: String { + if let d = schedule.nextDue { + return d.formatted(date: .abbreviated, time: .omitted) + } + return schedule.dueDateString.isEmpty ? "—" : schedule.dueDateString + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "calendar.badge.clock") + .font(.title3) + .foregroundStyle(schedule.isOverdue ? .red : .blue) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 3) { + Text(schedule.templateName.isEmpty ? "Inspection" : schedule.templateName) + .font(.callout.bold()) + Text(schedule.facilityName.isEmpty ? "Facility" : schedule.facilityName) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + if schedule.isOverdue { + Text("Overdue") + .font(.caption2.bold()) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.red.opacity(0.15)) + .foregroundStyle(.red) + .clipShape(Capsule()) + } + Text("Due \(dueText)") + .font(.caption2) + .foregroundStyle(schedule.isOverdue ? .red : .secondary) + if !schedule.frequencyLabel.isEmpty { + Text("· \(schedule.frequencyLabel)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + Spacer(minLength: 8) + + Label("Start", systemImage: "play.fill") + .font(.caption.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(schedule.isOverdue ? Color.red : Color.blue) + .clipShape(Capsule()) + } + .contentShape(Rectangle()) + } +} + +// MARK: - Dashboard card (VStack) + +struct ScheduledInspectionsCard: View { + @Query(sort: \LocalScheduledInspection.dueDateString, order: .forward) + private var scheduled: [LocalScheduledInspection] + + @State private var startTarget: LocalScheduledInspection? = nil + + var body: some View { + if !scheduled.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("SCHEDULED") + .font(.caption.bold()) + .foregroundStyle(.secondary) + .tracking(1) + + ForEach(scheduled) { s in + Button { startTarget = s } label: { + ScheduledRow(schedule: s) + .padding(12) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + } + } + // Cover attached to the stable VStack root (mirrors DraftResumeBanner). + .fullScreenCover(item: $startTarget) { s in + StartInspectionView( + preFillTemplateId: s.templateServerId, + preFillFacilityId: s.facilityServerId + ) + } + } + } +} diff --git a/JanitorialQC/JanitorialQCApp.swift b/JanitorialQC/JanitorialQCApp.swift index 16a3fdc..a0effae 100644 --- a/JanitorialQC/JanitorialQCApp.swift +++ b/JanitorialQC/JanitorialQCApp.swift @@ -100,6 +100,7 @@ struct JanitorialQCApp: App { LocalTemplate.self, LocalInspection.self, LocalIssue.self, + LocalScheduledInspection.self, PendingPhoto.self, SyncQueueEntry.self, ], isUndoEnabled: false) { result in diff --git a/JanitorialQC/Models/LocalIssue.swift b/JanitorialQC/Models/LocalIssue.swift index a35de01..4c2c683 100644 --- a/JanitorialQC/Models/LocalIssue.swift +++ b/JanitorialQC/Models/LocalIssue.swift @@ -153,6 +153,20 @@ final class LocalIssue { /// Nil when unassigned. Updated on every pullAssignedIssues(). var assignedToName: String? + // ── Handler ("Handled By", phase35) ─────────────────────────────────── + // Who resolves the issue: internal (our staff) | facility (facility's own + // staff) | vendor (external contractor). Synced from the server; the + // inspector may also set it from Issue Detail. nil-default optionals → + // SwiftData lightweight migration safe. + var handlerType: String? // "internal" | "facility" | "vendor" + var handlerLabel: String? // human-readable label from server + var facilityHandlerName: String? + var facilityHandlerContact: String? + var facilityHandlerNotes: String? + var vendorName: String? + var vendorContact: String? + var vendorNotes: String? + // Explicit inverse declared so SwiftData has an unambiguous relationship // graph at schema-build time. Without it the relationship is implicit, // which can cause migration warnings or incorrect cascade behaviour on some @@ -192,6 +206,15 @@ final class LocalIssue { self.reportedByName = nil self.areaNameCache = nil self.assignedToName = nil + // Handler fields — nil by default + self.handlerType = nil + self.handlerLabel = nil + self.facilityHandlerName = nil + self.facilityHandlerContact = nil + self.facilityHandlerNotes = nil + self.vendorName = nil + self.vendorContact = nil + self.vendorNotes = nil } var severityColor: String { diff --git a/JanitorialQC/Models/LocalScheduledInspection.swift b/JanitorialQC/Models/LocalScheduledInspection.swift new file mode 100644 index 0000000..66adadc --- /dev/null +++ b/JanitorialQC/Models/LocalScheduledInspection.swift @@ -0,0 +1,47 @@ +// Models/LocalScheduledInspection.swift +// -------------------------------------- +// SwiftData model for planned/recurring inspection assignments (phase36). +// +// Read-only reference data pulled from the server (GET /api/v1/scheduled-inspections) +// and refreshed by SyncManager.pullScheduledInspections() — never created or +// mutated on device. Surfaced in the "Scheduled" section on the Dashboard and +// My Inspections. Tapping "Start" opens the normal new-inspection flow with the +// facility + template preselected; the schedule lifecycle (fulfil / roll-forward) +// stays server-driven. +// +// All non-optional stored properties carry explicit inline defaults so SwiftData +// lightweight migration can add the new table without a migration plan. + +import Foundation +import SwiftData + +@Model +final class LocalScheduledInspection { + + /// Server ID of the ScheduledInspection row — stable unique identity. + @Attribute(.unique) var serverId: Int = 0 + + var facilityServerId: Int = 0 + var facilityName: String = "" + var templateServerId: Int = 0 + var templateName: String = "" + var inspectorId: Int? = nil + + var frequency: String = "once" // once | daily | weekly | monthly + var frequencyLabel: String = "" // human-readable label from server + + /// Raw ISO date string "YYYY-MM-DD" from the server (display fallback). + var dueDateString: String = "" + /// Parsed due date — used for @Query sorting. Nil if the string was absent. + var nextDue: Date? = nil + + var isOverdue: Bool = false + var notes: String? = nil + + /// Last time this row was refreshed from the server pull. + var updatedAt: Date = Date() + + init(serverId: Int) { + self.serverId = serverId + } +} diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 70fc3ed..c101099 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -60,6 +60,14 @@ class SyncManager: ObservableObject { return f }() + /// Parses server date-only strings ("YYYY-MM-DD"), e.g. scheduled due dates. + nonisolated static let dateOnlyFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f + }() + static let shared = SyncManager() private init() {} @@ -237,6 +245,7 @@ class SyncManager: ObservableObject { await processIssueQueue(context: context) await pullReferenceData() await pullAssignedIssues(context: context) + await pullScheduledInspections(context: context) // Poll notifications immediately on every sync rather than waiting // for the 60-second timer — ensures the inspector sees assignments @@ -730,6 +739,15 @@ class SyncManager: ObservableObject { existing.reportedByName = api.reportedByName existing.areaNameCache = api.areaName existing.assignedToName = api.assignedToName + // Handler ("Handled By", phase35) + existing.handlerType = api.handlerType + existing.handlerLabel = api.handlerLabel + existing.facilityHandlerName = api.facilityHandlerName + existing.facilityHandlerContact = api.facilityHandlerContact + existing.facilityHandlerNotes = api.facilityHandlerNotes + existing.vendorName = api.vendorName + existing.vendorContact = api.vendorContact + existing.vendorNotes = api.vendorNotes if let vts = api.verifiedAt, let date = Self.isoFormatter.date(from: vts) { existing.verifiedAt = date @@ -762,6 +780,15 @@ class SyncManager: ObservableObject { local.reportedByName = api.reportedByName local.areaNameCache = api.areaName local.assignedToName = api.assignedToName + // Handler ("Handled By", phase35) + local.handlerType = api.handlerType + local.handlerLabel = api.handlerLabel + local.facilityHandlerName = api.facilityHandlerName + local.facilityHandlerContact = api.facilityHandlerContact + local.facilityHandlerNotes = api.facilityHandlerNotes + local.vendorName = api.vendorName + local.vendorContact = api.vendorContact + local.vendorNotes = api.vendorNotes if let vts = api.verifiedAt, let date = Self.isoFormatter.date(from: vts) { local.verifiedAt = date @@ -808,6 +835,57 @@ class SyncManager: ObservableObject { } } + // ── Scheduled Inspections (phase36) ─────────────────────────────────── + // Read-only pull of planned/recurring assignments for the Dashboard and + // My Inspections "Scheduled" section. Upsert by serverId, then delete rows + // the server no longer returns (schedule fulfilled, deactivated, or + // reassigned to another inspector). Best-effort — never blocks the pipeline. + + func pullScheduledInspections(context: ModelContext) async { + guard isOnline, AuthManager.shared.isAuthenticated else { return } + do { + let apiRows = try await APIClient.shared.fetchScheduledInspections() + + // Fetch-all + filter/map in Swift — no #Predicate (CLAUDE.md rule 3). + let allLocal = (try? context.fetch(FetchDescriptor())) ?? [] + var byServerId: [Int: LocalScheduledInspection] = [:] + for row in allLocal { byServerId[row.serverId] = row } + + for api in apiRows { + let row = byServerId[api.id] ?? { + let r = LocalScheduledInspection(serverId: api.id) + context.insert(r) + return r + }() + row.facilityServerId = api.facilityId + row.facilityName = api.facilityName ?? "" + row.templateServerId = api.templateId + row.templateName = api.templateName ?? "" + row.inspectorId = api.inspectorId + row.frequency = api.frequency + row.frequencyLabel = api.frequencyLabel ?? "" + row.dueDateString = api.nextDueDate ?? "" + row.nextDue = api.nextDueDate.flatMap { Self.dateOnlyFormatter.date(from: $0) } + row.isOverdue = api.isOverdue + row.notes = api.notes + row.updatedAt = Date() + } + + // Delete rows the server no longer returns. + let returnedIds = Set(apiRows.map { $0.id }) + for row in allLocal where !returnedIds.contains(row.serverId) { + context.delete(row) + } + + try? context.save() + + } catch APIError.notAuthenticated { + // Let AuthManager handle session expiry + } catch { + // Non-fatal — stale scheduled rows stay visible until next pull + } + } + // ── Dashboard Stats ─────────────────────────────────────────────────── // Best-effort fetch — a network failure silently leaves dashboardStats nil // so the UI falls back to a placeholder card. Never blocks the sync pipeline. diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index ae6f77e..daf8cea 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -275,6 +275,12 @@ struct DashboardStatsView: View { DraftResumeBanner(drafts: draftInspections, context: context) } + // ── Scheduled Inspections (phase36) ──────────────────────── + // Planned/recurring assignments for this inspector. Self-hides + // when there are none. Tap a row to start it (facility + + // template preselected). + ScheduledInspectionsCard() + if let stats = sync.dashboardStats { // ── Today ────────────────────────────────────────────── statsSection(title: "Today") { @@ -519,7 +525,7 @@ struct DraftResumeBanner: View { // Wrap in NavigationStack so ExecuteInspectionView's toolbar // and dismiss work correctly when presented as a sheet. NavigationStack { - ExecuteInspectionView(inspection: draft) + ExecuteInspectionView(inspection: draft, isModallyPresented: true) } } } diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 9fc465d..a844165 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -22,6 +22,13 @@ struct ExecuteInspectionView: View { let inspection: LocalInspection + /// True when presented as the root of a fullScreenCover/sheet (e.g. the + /// dashboard "Resume" banner) rather than pushed onto a NavigationStack. + /// In that case there is no navigation back button, so a leading "Close" + /// button is shown so the inspector can return home without submitting. + /// Work is preserved either way — .onDisappear calls saveDraft(). + var isModallyPresented: Bool = false + @State private var formValues: [String: String] = [:] @State private var showFlagIssue = false @State private var showSubmitAlert = false @@ -120,6 +127,13 @@ struct ExecuteInspectionView: View { .navigationTitle(template?.name ?? "Inspection") .navigationBarTitleDisplayMode(.inline) .toolbar { + if isModallyPresented { + ToolbarItem(placement: .topBarLeading) { + // Root of a modal presentation — no nav back button exists. + // Draft is saved on disappear, so closing loses nothing. + Button("Close") { dismiss() } + } + } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 10) { // Live score — updates on every field change via formValues binding. diff --git a/JanitorialQC/Views/Dashboard/IssuesView.swift b/JanitorialQC/Views/Dashboard/IssuesView.swift index a8d0799..3f6a6f9 100644 --- a/JanitorialQC/Views/Dashboard/IssuesView.swift +++ b/JanitorialQC/Views/Dashboard/IssuesView.swift @@ -258,6 +258,15 @@ struct IssueDetailView: View { @State private var showMailCompose = false @State private var generatedPDFData: Data? = nil + // ── Handler ("Handled By", phase35) editing state ────────────────────── + @State private var isEditingHandler = false + @State private var handlerDraftType = "internal" // internal | facility | vendor + @State private var handlerName = "" + @State private var handlerContact = "" + @State private var handlerNotes = "" + @State private var isSavingHandler = false + @State private var handlerError: String? + private var facilityName: String { let id = issue.facilityServerId return (try? context.fetch( @@ -289,6 +298,24 @@ struct IssueDetailView: View { ("resolved", "Resolved", .green), ] + /// Who may change the handler from the iPad. Per product decision, the + /// assigned inspector may set it here (the web form limits this to + /// admin/director/PM); the server enforces facility scope for inspectors. + private var canEditHandler: Bool { + guard sync.isOnline, issue.serverId != nil else { return false } + let role = AuthManager.shared.currentUserRole + return role == "admin" || role == "director" + || role == "inspector" || role == "project_manager" + } + + private func handlerTypeLabel(_ type: String) -> String { + switch type { + case "facility": return "Facility Staff" + case "vendor": return "External Vendor" + default: return "Janitorial Staff" + } + } + private func statusColor(for status: String) -> Color { allStatuses.first { $0.value == status }?.color ?? .secondary } @@ -363,6 +390,9 @@ struct IssueDetailView: View { } } + // ── Handled By (phase35) ─────────────────────────────────────── + handledBySection + // ── Upload Resolution Photos ─────────────────────────────────── // Shown when the issue is resolved, online, and synced. // Lets the inspector attach up to 5 photos showing the fix — @@ -703,6 +733,18 @@ struct IssueDetailView: View { } if let area = detail.areaName, !area.isEmpty { issue.areaNameCache = area } if let assignee = detail.assignedToName, !assignee.isEmpty { issue.assignedToName = assignee } + // Refresh handler ("Handled By") from live server data. Skipped + // while the inspector is mid-edit so their draft isn't disturbed. + if !isEditingHandler { + issue.handlerType = detail.handlerType + issue.handlerLabel = detail.handlerLabel + issue.facilityHandlerName = detail.facilityHandlerName + issue.facilityHandlerContact = detail.facilityHandlerContact + issue.facilityHandlerNotes = detail.facilityHandlerNotes + issue.vendorName = detail.vendorName + issue.vendorContact = detail.vendorContact + issue.vendorNotes = detail.vendorNotes + } // Refresh resolution photos from server if !detail.resultPhotos.isEmpty { issue.resultPhotoServerPaths = detail.resultPhotos @@ -763,6 +805,168 @@ struct IssueDetailView: View { } } + // ── Handled By (phase35) ─────────────────────────────────────────────── + + @ViewBuilder + private var handledBySection: some View { + Section("Handled By") { + let type = issue.handlerType ?? "internal" + + LabeledContent("Handler") { + Text(issue.handlerLabel ?? handlerTypeLabel(type)) + .fontWeight(.semibold) + } + + // Current detail — depends on handler category. + switch type { + case "facility": + if let n = issue.facilityHandlerName, !n.isEmpty { + LabeledContent("Name", value: n) + } + if let c = issue.facilityHandlerContact, !c.isEmpty { + LabeledContent("Contact", value: c) + } + if let notes = issue.facilityHandlerNotes, !notes.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Notes").font(.caption).foregroundStyle(.secondary) + Text(notes).font(.callout) + } + } + case "vendor": + if let n = issue.vendorName, !n.isEmpty { + LabeledContent("Vendor", value: n) + } + if let c = issue.vendorContact, !c.isEmpty { + LabeledContent("Contact", value: c) + } + if let notes = issue.vendorNotes, !notes.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Notes").font(.caption).foregroundStyle(.secondary) + Text(notes).font(.callout) + } + } + default: + if let a = issue.assignedToName, !a.isEmpty { + LabeledContent("Staff", value: a) + } + } + + // ── Inspector edit ───────────────────────────────────────────── + if canEditHandler { + if isEditingHandler { + Picker("Type", selection: $handlerDraftType) { + Text("Staff").tag("internal") + Text("Facility").tag("facility") + Text("Vendor").tag("vendor") + } + .pickerStyle(.segmented) + + if handlerDraftType != "internal" { + TextField( + handlerDraftType == "vendor" ? "Vendor name" : "Handler name", + text: $handlerName + ) + TextField("Contact (phone or email)", text: $handlerContact) + TextField("Notes", text: $handlerNotes, axis: .vertical) + .lineLimit(1...4) + } + + if let e = handlerError { + Text(e).font(.caption).foregroundStyle(.red) + } + + HStack { + Button("Cancel") { isEditingHandler = false } + .buttonStyle(.bordered) + Spacer() + Button { + Task { await saveHandler() } + } label: { + if isSavingHandler { + ProgressView() + } else { + Text("Save") + } + } + .buttonStyle(.borderedProminent) + .disabled(isSavingHandler) + } + } else { + Button { + beginEditHandler() + } label: { + Label("Change Handler", systemImage: "person.badge.shield.checkmark") + } + } + } + } + } + + private func beginEditHandler() { + let type = issue.handlerType ?? "internal" + handlerDraftType = type + switch type { + case "vendor": + handlerName = issue.vendorName ?? "" + handlerContact = issue.vendorContact ?? "" + handlerNotes = issue.vendorNotes ?? "" + case "facility": + handlerName = issue.facilityHandlerName ?? "" + handlerContact = issue.facilityHandlerContact ?? "" + handlerNotes = issue.facilityHandlerNotes ?? "" + default: + handlerName = ""; handlerContact = ""; handlerNotes = "" + } + handlerError = nil + isEditingHandler = true + } + + private func saveHandler() async { + guard let sid = issue.serverId else { return } + isSavingHandler = true + handlerError = nil + defer { isSavingHandler = false } + + let type = handlerDraftType + let name = handlerName.trimmingCharacters(in: .whitespacesAndNewlines) + let contact = handlerContact.trimmingCharacters(in: .whitespacesAndNewlines) + let notes = handlerNotes.trimmingCharacters(in: .whitespacesAndNewlines) + + var details: [String: String] = [:] + if type == "facility" { + details["facility_handler_name"] = name + details["facility_handler_contact"] = contact + details["facility_handler_notes"] = notes + } else if type == "vendor" { + details["vendor_name"] = name + details["vendor_contact"] = contact + details["vendor_notes"] = notes + } + + do { + let confirmed = try await APIClient.shared.updateIssueHandler( + issueId: sid, handlerType: type, details: details + ) + // Mirror the change into the local record so the UI reflects it + // immediately; the next pull re-confirms from the server. + issue.handlerType = confirmed + issue.handlerLabel = handlerTypeLabel(confirmed) + if type == "facility" { + issue.facilityHandlerName = name.isEmpty ? nil : name + issue.facilityHandlerContact = contact.isEmpty ? nil : contact + issue.facilityHandlerNotes = notes.isEmpty ? nil : notes + } else if type == "vendor" { + issue.vendorName = name.isEmpty ? nil : name + issue.vendorContact = contact.isEmpty ? nil : contact + issue.vendorNotes = notes.isEmpty ? nil : notes + } + try? context.save() + isEditingHandler = false + } catch { + handlerError = error.localizedDescription + } + } + // ── Resolution photo helpers ─────────────────────────────────────────── private func appendResultPhoto(_ img: UIImage) { diff --git a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift index 29876c2..0431ee0 100644 --- a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift +++ b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift @@ -13,9 +13,16 @@ struct MyInspectionsView: View { order: .reverse ) private var inspections: [LocalInspection] + /// Scheduled assignments (phase36) — rendered as the top section and used + /// for the empty-state decision. Sorted by due date (ISO strings sort + /// chronologically). + @Query(sort: \LocalScheduledInspection.dueDateString, order: .forward) + private var scheduledAll: [LocalScheduledInspection] + @Environment(\.modelContext) private var context @State private var showNewInspection = false + @State private var scheduledStartTarget: LocalScheduledInspection? // Deletion confirmation state @State private var pendingDelete: LocalInspection? @@ -23,28 +30,53 @@ struct MyInspectionsView: View { var body: some View { Group { - if inspections.isEmpty { + if inspections.isEmpty && scheduledAll.isEmpty { ContentUnavailableView( "No Inspections", systemImage: "checklist", description: Text("Tap + to start a new inspection.") ) } else { - List(inspections) { inspection in - NavigationLink(value: inspection) { - InspectionRowView(inspection: inspection, context: context) - } - // Only drafts may be deleted — submitted/pending-sync inspections are kept - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if inspection.status == "draft" { - Button(role: .destructive) { - pendingDelete = inspection - showDeleteAlert = true - } label: { - Label("Delete", systemImage: "trash") + List { + // Scheduled assignments (phase36) — self-hides when empty. + if !scheduledAll.isEmpty { + Section("Scheduled") { + ForEach(scheduledAll) { s in + Button { scheduledStartTarget = s } label: { + ScheduledRow(schedule: s) + } + .buttonStyle(.plain) } } } + + if !inspections.isEmpty { + Section("In Progress") { + ForEach(inspections) { inspection in + NavigationLink(value: inspection) { + InspectionRowView(inspection: inspection, context: context) + } + // Only drafts may be deleted — submitted/pending-sync inspections are kept + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if inspection.status == "draft" { + Button(role: .destructive) { + pendingDelete = inspection + showDeleteAlert = true + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } + } + } + // Cover attached to the stable List, not a Section. + .fullScreenCover(item: $scheduledStartTarget) { s in + StartInspectionView( + preFillTemplateId: s.templateServerId, + preFillFacilityId: s.facilityServerId + ) } } } @@ -63,7 +95,7 @@ struct MyInspectionsView: View { } } } - .sheet(isPresented: $showNewInspection) { + .fullScreenCover(isPresented: $showNewInspection) { StartInspectionView() } } @@ -303,7 +335,7 @@ struct CompletedInspectionView: View { } .navigationTitle(templateName) .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showReInspect) { + .fullScreenCover(isPresented: $showReInspect) { StartInspectionView( preFillTemplateId: inspection.templateServerId, preFillFacilityId: inspection.facilityServerId, diff --git a/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift b/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift new file mode 100644 index 0000000..7cd6f71 --- /dev/null +++ b/JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift @@ -0,0 +1,113 @@ +// Views/Dashboard/ScheduledInspectionsView.swift +// ---------------------------------------------- +// Displays the inspector's planned/recurring inspection assignments (phase36), +// pulled read-only from GET /api/v1/scheduled-inspections by +// SyncManager.pullScheduledInspections(). +// +// Two consumers share one ScheduledRow: +// • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView +// • MyInspectionsView renders its own "Scheduled" List section inline, +// reusing ScheduledRow, with the start cover attached to the List. +// Both self-hide when there are no scheduled inspections and present +// StartInspectionView (facility + template preselected) when a row is tapped. +// The schedule lifecycle (fulfil / roll-forward) stays server-driven; tapping +// "Start" simply seeds the normal new-inspection flow. + +import SwiftUI +import SwiftData + +// MARK: - Shared row + +struct ScheduledRow: View { + let schedule: LocalScheduledInspection + + private var dueText: String { + if let d = schedule.nextDue { + return d.formatted(date: .abbreviated, time: .omitted) + } + return schedule.dueDateString.isEmpty ? "—" : schedule.dueDateString + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "calendar.badge.clock") + .font(.title3) + .foregroundStyle(schedule.isOverdue ? .red : .blue) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 3) { + Text(schedule.templateName.isEmpty ? "Inspection" : schedule.templateName) + .font(.callout.bold()) + Text(schedule.facilityName.isEmpty ? "Facility" : schedule.facilityName) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + if schedule.isOverdue { + Text("Overdue") + .font(.caption2.bold()) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.red.opacity(0.15)) + .foregroundStyle(.red) + .clipShape(Capsule()) + } + Text("Due \(dueText)") + .font(.caption2) + .foregroundStyle(schedule.isOverdue ? .red : .secondary) + if !schedule.frequencyLabel.isEmpty { + Text("· \(schedule.frequencyLabel)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + Spacer(minLength: 8) + + Label("Start", systemImage: "play.fill") + .font(.caption.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(schedule.isOverdue ? Color.red : Color.blue) + .clipShape(Capsule()) + } + .contentShape(Rectangle()) + } +} + +// MARK: - Dashboard card (VStack) + +struct ScheduledInspectionsCard: View { + @Query(sort: \LocalScheduledInspection.dueDateString, order: .forward) + private var scheduled: [LocalScheduledInspection] + + @State private var startTarget: LocalScheduledInspection? = nil + + var body: some View { + if !scheduled.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("SCHEDULED") + .font(.caption.bold()) + .foregroundStyle(.secondary) + .tracking(1) + + ForEach(scheduled) { s in + Button { startTarget = s } label: { + ScheduledRow(schedule: s) + .padding(12) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + } + } + // Cover attached to the stable VStack root (mirrors DraftResumeBanner). + .fullScreenCover(item: $startTarget) { s in + StartInspectionView( + preFillTemplateId: s.templateServerId, + preFillFacilityId: s.facilityServerId + ) + } + } + } +}