Jul 13 - Update codes to catch up with the web app updates: scheduled inspection and issue's handler
This commit is contained in:
@@ -432,7 +432,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5;
|
MARKETING_VERSION = 1.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -476,7 +476,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5;
|
MARKETING_VERSION = 1.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@@ -367,6 +367,33 @@ actor APIClient {
|
|||||||
return result.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).
|
/// Fetch dashboard KPI counts for the current user (Phase B).
|
||||||
func fetchDashboardStats() async throws -> APIDashboardStats {
|
func fetchDashboardStats() async throws -> APIDashboardStats {
|
||||||
return try await request("/api/v1/stats/dashboard")
|
return try await request("/api/v1/stats/dashboard")
|
||||||
|
|||||||
@@ -408,6 +408,15 @@ struct APIIssueDetail: Decodable, Sendable {
|
|||||||
// Phase E — area and assignee context
|
// Phase E — area and assignee context
|
||||||
let areaName: String?
|
let areaName: String?
|
||||||
let assignedToName: 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 {
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
@@ -427,12 +436,23 @@ struct APIIssueDetail: Decodable, Sendable {
|
|||||||
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
|
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
|
||||||
areaName = try? c.decode(String.self, forKey: .areaName)
|
areaName = try? c.decode(String.self, forKey: .areaName)
|
||||||
assignedToName = try? c.decode(String.self, forKey: .assignedToName)
|
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 {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id, status, severity, description, assignedTo
|
case id, status, severity, description, assignedTo
|
||||||
case facilityId, facilityName, reportedAt, resolvedAt
|
case facilityId, facilityName, reportedAt, resolvedAt
|
||||||
case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos
|
case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos
|
||||||
case areaName, assignedToName
|
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 }
|
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) ────────────────────────────────────────────
|
// ── Notification polling (Phase C) ────────────────────────────────────────────
|
||||||
|
|
||||||
struct APINotification: Decodable, Identifiable, Sendable {
|
struct APINotification: Decodable, Identifiable, Sendable {
|
||||||
@@ -517,6 +549,15 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
|
|||||||
// Phase E — area and assignee context
|
// Phase E — area and assignee context
|
||||||
let areaName: String?
|
let areaName: String?
|
||||||
let assignedToName: 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 {
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
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)
|
reportedByName = try? c.decode(String.self, forKey: .reportedByName)
|
||||||
areaName = try? c.decode(String.self, forKey: .areaName)
|
areaName = try? c.decode(String.self, forKey: .areaName)
|
||||||
assignedToName = try? c.decode(String.self, forKey: .assignedToName)
|
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 {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id, status, severity, description, assignedTo
|
case id, status, severity, description, assignedTo
|
||||||
@@ -545,6 +594,9 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
|
|||||||
case photoPath, mobilePhotoPaths, resultPhotos
|
case photoPath, mobilePhotoPaths, resultPhotos
|
||||||
case resultNotes, verifiedAt, verificationNote, reportedByName
|
case resultNotes, verifiedAt, verificationNote, reportedByName
|
||||||
case areaName, assignedToName
|
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 }
|
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) ─────────────────────────────────────────────────
|
// ── Dashboard Stats (Phase B) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
struct APIDashboardStats: Decodable, Sendable {
|
struct APIDashboardStats: Decodable, Sendable {
|
||||||
|
|||||||
@@ -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<T: Decodable & Sendable>: 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<T: Decodable & Sendable>(
|
||||||
|
_ 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<T: Decodable & Sendable>(_ 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<PhotoResult>.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<PhotoResult>.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) ?? "<binary>"
|
||||||
|
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<T: Decodable & Sendable>(_ data: Data) throws -> T {
|
||||||
|
if let env = try? decoder.decode(_Envelope<T>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T: Decodable & Sendable>: 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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Void, Never>? // 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<PendingPhoto>()) 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<String>()
|
||||||
|
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<LocalInspection>())) ?? []
|
||||||
|
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||||
|
|
||||||
|
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<LocalInspection>()) 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<LocalIssue>()) 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<LocalInspection>())) ?? []
|
||||||
|
|
||||||
|
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<LocalFacility>())
|
||||||
|
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<Int>()
|
||||||
|
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<LocalTemplate>())
|
||||||
|
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<String>()
|
||||||
|
|
||||||
|
// PendingPhoto — not yet uploaded
|
||||||
|
if let pendingPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) {
|
||||||
|
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<LocalInspection>()) {
|
||||||
|
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<LocalIssue>()) {
|
||||||
|
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<String>)
|
||||||
|
// 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<LocalInspection>()))?
|
||||||
|
.filter { $0.syncStatus == "pending" }.count ?? 0
|
||||||
|
let issueCount = (try? context.fetch(FetchDescriptor<LocalIssue>()))?
|
||||||
|
.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<LocalArea>()))?
|
||||||
|
.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<LocalTemplate>()))?
|
||||||
|
.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<LocalIssue>())) ?? []
|
||||||
|
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<LocalScheduledInspection>())) ?? []
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LocalInspection> { $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<LocalInspection> { $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<Content: View>(
|
||||||
|
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<LocalTemplate>(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<LocalFacility>())) ?? []
|
||||||
|
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<NSString, UIImage>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
+348
@@ -0,0 +1,348 @@
|
|||||||
|
// Views/Dashboard/MyInspectionsView.swift
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import MessageUI
|
||||||
|
|
||||||
|
// MARK: - My Inspections
|
||||||
|
|
||||||
|
struct MyInspectionsView: View {
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
filter: #Predicate<LocalInspection> { $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<LocalTemplate>(
|
||||||
|
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<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first?.name) ?? "Unknown Facility"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var templateName: String {
|
||||||
|
let id = inspection.templateServerId
|
||||||
|
return (try? context.fetch(
|
||||||
|
FetchDescriptor<LocalTemplate>(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<LocalTemplate>(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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+113
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ struct JanitorialQCApp: App {
|
|||||||
LocalTemplate.self,
|
LocalTemplate.self,
|
||||||
LocalInspection.self,
|
LocalInspection.self,
|
||||||
LocalIssue.self,
|
LocalIssue.self,
|
||||||
|
LocalScheduledInspection.self,
|
||||||
PendingPhoto.self,
|
PendingPhoto.self,
|
||||||
SyncQueueEntry.self,
|
SyncQueueEntry.self,
|
||||||
], isUndoEnabled: false) { result in
|
], isUndoEnabled: false) { result in
|
||||||
|
|||||||
@@ -153,6 +153,20 @@ final class LocalIssue {
|
|||||||
/// Nil when unassigned. Updated on every pullAssignedIssues().
|
/// Nil when unassigned. Updated on every pullAssignedIssues().
|
||||||
var assignedToName: String?
|
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
|
// Explicit inverse declared so SwiftData has an unambiguous relationship
|
||||||
// graph at schema-build time. Without it the relationship is implicit,
|
// graph at schema-build time. Without it the relationship is implicit,
|
||||||
// which can cause migration warnings or incorrect cascade behaviour on some
|
// which can cause migration warnings or incorrect cascade behaviour on some
|
||||||
@@ -192,6 +206,15 @@ final class LocalIssue {
|
|||||||
self.reportedByName = nil
|
self.reportedByName = nil
|
||||||
self.areaNameCache = nil
|
self.areaNameCache = nil
|
||||||
self.assignedToName = 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 {
|
var severityColor: String {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,14 @@ class SyncManager: ObservableObject {
|
|||||||
return f
|
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()
|
static let shared = SyncManager()
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
@@ -237,6 +245,7 @@ class SyncManager: ObservableObject {
|
|||||||
await processIssueQueue(context: context)
|
await processIssueQueue(context: context)
|
||||||
await pullReferenceData()
|
await pullReferenceData()
|
||||||
await pullAssignedIssues(context: context)
|
await pullAssignedIssues(context: context)
|
||||||
|
await pullScheduledInspections(context: context)
|
||||||
|
|
||||||
// Poll notifications immediately on every sync rather than waiting
|
// Poll notifications immediately on every sync rather than waiting
|
||||||
// for the 60-second timer — ensures the inspector sees assignments
|
// for the 60-second timer — ensures the inspector sees assignments
|
||||||
@@ -730,6 +739,15 @@ class SyncManager: ObservableObject {
|
|||||||
existing.reportedByName = api.reportedByName
|
existing.reportedByName = api.reportedByName
|
||||||
existing.areaNameCache = api.areaName
|
existing.areaNameCache = api.areaName
|
||||||
existing.assignedToName = api.assignedToName
|
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,
|
if let vts = api.verifiedAt,
|
||||||
let date = Self.isoFormatter.date(from: vts) {
|
let date = Self.isoFormatter.date(from: vts) {
|
||||||
existing.verifiedAt = date
|
existing.verifiedAt = date
|
||||||
@@ -762,6 +780,15 @@ class SyncManager: ObservableObject {
|
|||||||
local.reportedByName = api.reportedByName
|
local.reportedByName = api.reportedByName
|
||||||
local.areaNameCache = api.areaName
|
local.areaNameCache = api.areaName
|
||||||
local.assignedToName = api.assignedToName
|
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,
|
if let vts = api.verifiedAt,
|
||||||
let date = Self.isoFormatter.date(from: vts) {
|
let date = Self.isoFormatter.date(from: vts) {
|
||||||
local.verifiedAt = date
|
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<LocalScheduledInspection>())) ?? []
|
||||||
|
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 ───────────────────────────────────────────────────
|
// ── Dashboard Stats ───────────────────────────────────────────────────
|
||||||
// Best-effort fetch — a network failure silently leaves dashboardStats nil
|
// Best-effort fetch — a network failure silently leaves dashboardStats nil
|
||||||
// so the UI falls back to a placeholder card. Never blocks the sync pipeline.
|
// so the UI falls back to a placeholder card. Never blocks the sync pipeline.
|
||||||
|
|||||||
@@ -275,6 +275,12 @@ struct DashboardStatsView: View {
|
|||||||
DraftResumeBanner(drafts: draftInspections, context: context)
|
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 {
|
if let stats = sync.dashboardStats {
|
||||||
// ── Today ──────────────────────────────────────────────
|
// ── Today ──────────────────────────────────────────────
|
||||||
statsSection(title: "Today") {
|
statsSection(title: "Today") {
|
||||||
@@ -519,7 +525,7 @@ struct DraftResumeBanner: View {
|
|||||||
// Wrap in NavigationStack so ExecuteInspectionView's toolbar
|
// Wrap in NavigationStack so ExecuteInspectionView's toolbar
|
||||||
// and dismiss work correctly when presented as a sheet.
|
// and dismiss work correctly when presented as a sheet.
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ExecuteInspectionView(inspection: draft)
|
ExecuteInspectionView(inspection: draft, isModallyPresented: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ struct ExecuteInspectionView: View {
|
|||||||
|
|
||||||
let inspection: LocalInspection
|
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 formValues: [String: String] = [:]
|
||||||
@State private var showFlagIssue = false
|
@State private var showFlagIssue = false
|
||||||
@State private var showSubmitAlert = false
|
@State private var showSubmitAlert = false
|
||||||
@@ -120,6 +127,13 @@ struct ExecuteInspectionView: View {
|
|||||||
.navigationTitle(template?.name ?? "Inspection")
|
.navigationTitle(template?.name ?? "Inspection")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.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) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
// Live score — updates on every field change via formValues binding.
|
// Live score — updates on every field change via formValues binding.
|
||||||
|
|||||||
@@ -258,6 +258,15 @@ struct IssueDetailView: View {
|
|||||||
@State private var showMailCompose = false
|
@State private var showMailCompose = false
|
||||||
@State private var generatedPDFData: Data? = nil
|
@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 {
|
private var facilityName: String {
|
||||||
let id = issue.facilityServerId
|
let id = issue.facilityServerId
|
||||||
return (try? context.fetch(
|
return (try? context.fetch(
|
||||||
@@ -289,6 +298,24 @@ struct IssueDetailView: View {
|
|||||||
("resolved", "Resolved", .green),
|
("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 {
|
private func statusColor(for status: String) -> Color {
|
||||||
allStatuses.first { $0.value == status }?.color ?? .secondary
|
allStatuses.first { $0.value == status }?.color ?? .secondary
|
||||||
}
|
}
|
||||||
@@ -363,6 +390,9 @@ struct IssueDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Handled By (phase35) ───────────────────────────────────────
|
||||||
|
handledBySection
|
||||||
|
|
||||||
// ── Upload Resolution Photos ───────────────────────────────────
|
// ── Upload Resolution Photos ───────────────────────────────────
|
||||||
// Shown when the issue is resolved, online, and synced.
|
// Shown when the issue is resolved, online, and synced.
|
||||||
// Lets the inspector attach up to 5 photos showing the fix —
|
// 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 area = detail.areaName, !area.isEmpty { issue.areaNameCache = area }
|
||||||
if let assignee = detail.assignedToName, !assignee.isEmpty { issue.assignedToName = assignee }
|
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
|
// Refresh resolution photos from server
|
||||||
if !detail.resultPhotos.isEmpty {
|
if !detail.resultPhotos.isEmpty {
|
||||||
issue.resultPhotoServerPaths = detail.resultPhotos
|
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 ───────────────────────────────────────────
|
// ── Resolution photo helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
private func appendResultPhoto(_ img: UIImage) {
|
private func appendResultPhoto(_ img: UIImage) {
|
||||||
|
|||||||
@@ -13,9 +13,16 @@ struct MyInspectionsView: View {
|
|||||||
order: .reverse
|
order: .reverse
|
||||||
) private var inspections: [LocalInspection]
|
) 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
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
@State private var showNewInspection = false
|
@State private var showNewInspection = false
|
||||||
|
@State private var scheduledStartTarget: LocalScheduledInspection?
|
||||||
|
|
||||||
// Deletion confirmation state
|
// Deletion confirmation state
|
||||||
@State private var pendingDelete: LocalInspection?
|
@State private var pendingDelete: LocalInspection?
|
||||||
@@ -23,28 +30,53 @@ struct MyInspectionsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if inspections.isEmpty {
|
if inspections.isEmpty && scheduledAll.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Inspections",
|
"No Inspections",
|
||||||
systemImage: "checklist",
|
systemImage: "checklist",
|
||||||
description: Text("Tap + to start a new inspection.")
|
description: Text("Tap + to start a new inspection.")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
List(inspections) { inspection in
|
List {
|
||||||
NavigationLink(value: inspection) {
|
// Scheduled assignments (phase36) — self-hides when empty.
|
||||||
InspectionRowView(inspection: inspection, context: context)
|
if !scheduledAll.isEmpty {
|
||||||
}
|
Section("Scheduled") {
|
||||||
// Only drafts may be deleted — submitted/pending-sync inspections are kept
|
ForEach(scheduledAll) { s in
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
Button { scheduledStartTarget = s } label: {
|
||||||
if inspection.status == "draft" {
|
ScheduledRow(schedule: s)
|
||||||
Button(role: .destructive) {
|
}
|
||||||
pendingDelete = inspection
|
.buttonStyle(.plain)
|
||||||
showDeleteAlert = true
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
StartInspectionView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,7 +335,7 @@ struct CompletedInspectionView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(templateName)
|
.navigationTitle(templateName)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.sheet(isPresented: $showReInspect) {
|
.fullScreenCover(isPresented: $showReInspect) {
|
||||||
StartInspectionView(
|
StartInspectionView(
|
||||||
preFillTemplateId: inspection.templateServerId,
|
preFillTemplateId: inspection.templateServerId,
|
||||||
preFillFacilityId: inspection.facilityServerId,
|
preFillFacilityId: inspection.facilityServerId,
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user