From 6367dac1e6abfca16d6a96231dd033d432cb0763 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 11 May 2026 16:49:22 -0400 Subject: [PATCH] 05/11 Update issue's status, and inspector can changes issue's status --- JanitorialQC/API/APIClient.swift | 17 ++ JanitorialQC/API/APIModels.swift | 45 +++- JanitorialQC/Models/LocalIssue.swift | 2 + .../Views/Dashboard/DashboardView.swift | 197 ++++++++++++++++-- .../Inspection/InspectionHistoryView.swift | 4 +- 5 files changed, 238 insertions(+), 27 deletions(-) diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 37e0178..77f2414 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -201,6 +201,23 @@ actor APIClient { return r.issueId } + // ── 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 + } + // ── Token Refresh ───────────────────────────────────────────────────── private func refreshAccessToken() async -> Bool { diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index a20dca1..d7f8ba3 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -271,7 +271,7 @@ struct TemplateDetailResponseData: Decodable, Sendable { // ── Inspections ─────────────────────────────────────────────────────────────── -struct APIInspectionSummary: Decodable, Identifiable, Sendable { +struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable { let id: Int let templateId: Int let templateName: String @@ -335,3 +335,46 @@ struct InspectionHistoryResponseData: Decodable, Sendable { } 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? + + 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) + } + private enum CodingKeys: String, CodingKey { + case id, status, severity, description, assignedTo + case facilityId, facilityName, reportedAt, resolvedAt + } +} + +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 } +} diff --git a/JanitorialQC/Models/LocalIssue.swift b/JanitorialQC/Models/LocalIssue.swift index a4c1f5c..6b53d02 100644 --- a/JanitorialQC/Models/LocalIssue.swift +++ b/JanitorialQC/Models/LocalIssue.swift @@ -15,6 +15,7 @@ final class LocalIssue { var facilityServerId: Int // facility this issue belongs to (replaces areaServerId) var severity: String // "low" | "medium" | "high" | "critical" var issueDescription: String + var issueStatus: String = "open" // server status: "open" | "in_progress" | "resolved" | "pending_verification" var photoLocalPath: String? // local file path before upload var photoServerPath: String? // server path after upload @@ -37,6 +38,7 @@ final class LocalIssue { self.facilityServerId = facilityServerId self.severity = severity self.issueDescription = description + self.issueStatus = "open" self.photoLocalPath = nil self.photoServerPath = nil self.createdAt = Date() diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 97c5a95..5b2e00b 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -41,11 +41,38 @@ struct DashboardView: View { @State private var selectedTab: SidebarTab = .myInspections @State private var showNewInspection = false + /// 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] = [ + .myInspections: UUID(), + .issues: UUID(), + .facilities: UUID(), + .pendingSync: UUID(), + .history: 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 { // ── My Inspections ───────────────────────────────────────── - Button { selectedTab = .myInspections } label: { + Button { selectTab(.myInspections) } label: { HStack { Label("My Inspections", systemImage: "checklist") .foregroundStyle(selectedTab == .myInspections ? .blue : .primary) @@ -62,21 +89,21 @@ struct DashboardView: View { .listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear) // ── Issues (all roles) ───────────────────────────────────── - Button { selectedTab = .issues } label: { + 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 { selectedTab = .facilities } label: { + 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 { selectedTab = .pendingSync } label: { + Button { selectTab(.pendingSync) } label: { HStack { Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") .foregroundStyle(selectedTab == .pendingSync ? .blue : .primary) @@ -94,14 +121,14 @@ struct DashboardView: View { .listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear) // ── History (moved — sits between Pending Sync and Settings) - Button { selectedTab = .history } label: { + 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) // ── Settings ─────────────────────────────────────────────── - Button { selectedTab = .settings } label: { + Button { selectTab(.settings) } label: { Label("Settings", systemImage: "gear") .foregroundStyle(selectedTab == .settings ? .blue : .primary) } @@ -120,12 +147,37 @@ struct DashboardView: View { } detail: { switch selectedTab { - case .myInspections: NavigationStack { MyInspectionsView() } - case .issues: NavigationStack { IssuesListView() } - case .facilities: NavigationStack { FacilitiesListView() } - case .pendingSync: NavigationStack { SyncStatusView() } - case .history: NavigationStack { InspectionHistoryView() } - case .settings: NavigationStack { SettingsView() } + 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 .settings: + NavigationStack { SettingsView() } } } .sheet(isPresented: $showNewInspection) { @@ -197,13 +249,7 @@ struct MyInspectionsView: View { ) } else { List(inspections) { inspection in - NavigationLink { - if inspection.status == "draft" { - ExecuteInspectionView(inspection: inspection) - } else { - CompletedInspectionView(inspection: inspection) - } - } label: { + NavigationLink(value: inspection) { InspectionRowView(inspection: inspection, context: context) } // Only drafts may be deleted — submitted/pending-sync inspections are kept @@ -635,9 +681,7 @@ struct IssuesListView: View { ) } else { List(issues) { issue in - NavigationLink { - IssueDetailView(issue: issue) - } label: { + NavigationLink(value: issue) { IssueRowView(issue: issue, context: context) } } @@ -699,6 +743,12 @@ struct IssueRowView: View { struct IssueDetailView: View { let issue: LocalIssue @Environment(\.modelContext) private var context + @EnvironmentObject private var sync: SyncManager + + @State private var isLoadingStatus = false + @State private var isUpdatingStatus = false + @State private var statusError: String? + @State private var showStatusPicker = false private var facilityName: String { let id = issue.facilityServerId @@ -716,6 +766,30 @@ struct IssueDetailView: View { } } + /// Inspector can update status only if the issue has synced (has a serverId) + /// and we are online. Admins/directors can always update when online. + private var canUpdateStatus: Bool { + guard sync.isOnline, issue.serverId != nil else { return false } + let role = AuthManager.shared.currentUserRole + return role == "admin" || role == "director" || role == "inspector" + } + + private let allStatuses: [(value: String, label: String, color: Color)] = [ + ("open", "Open", .blue), + ("in_progress", "In Progress", .orange), + ("pending_verification", "Pending Verification", .purple), + ("resolved", "Resolved", .green), + ] + + private func statusColor(for status: String) -> Color { + allStatuses.first { $0.value == status }?.color ?? .secondary + } + + private func statusLabel(for status: String) -> String { + allStatuses.first { $0.value == status }?.label + ?? status.replacingOccurrences(of: "_", with: " ").capitalized + } + var body: some View { List { Section("Issue Details") { @@ -727,6 +801,46 @@ struct IssueDetailView: View { LabeledContent("Facility", value: facilityName) LabeledContent("Reported", value: issue.createdAt.formatted( date: .long, time: .shortened)) + + // ── Issue Status ─────────────────────────────────────────── + LabeledContent("Issue Status") { + HStack(spacing: 6) { + if isLoadingStatus { + ProgressView().scaleEffect(0.7) + } else { + Text(statusLabel(for: issue.issueStatus)) + .foregroundStyle(statusColor(for: issue.issueStatus)) + .fontWeight(.semibold) + } + } + } + + // ── Status picker (online + synced only) ─────────────────── + if canUpdateStatus { + if isUpdatingStatus { + HStack { + ProgressView() + Text("Updating…").foregroundStyle(.secondary).font(.callout) + } + } else { + Picker("Change Status", selection: Binding( + get: { issue.issueStatus }, + set: { newStatus in + Task { await changeStatus(to: newStatus) } + } + )) { + ForEach(allStatuses, id: \.value) { s in + Text(s.label).tag(s.value) + } + } + .pickerStyle(.menu) + .tint(statusColor(for: issue.issueStatus)) + } + } + + if let err = statusError { + Text(err).font(.caption).foregroundStyle(.red) + } } Section("Description") { @@ -735,7 +849,7 @@ struct IssueDetailView: View { } Section("Sync Status") { - LabeledContent("Status") { + LabeledContent("Sync") { StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus) } if let err = issue.syncErrorMessage { @@ -762,6 +876,43 @@ struct IssueDetailView: View { } .navigationTitle("Issue Detail") .navigationBarTitleDisplayMode(.inline) + .task { + await refreshStatusFromServer() + } + } + + // ── Fetch fresh status from server ──────────────────────────────────── + + private func refreshStatusFromServer() async { + guard sync.isOnline, let sid = issue.serverId else { return } + isLoadingStatus = true + statusError = nil + defer { isLoadingStatus = false } + do { + let detail = try await APIClient.shared.fetchIssueDetail(issueId: sid) + issue.issueStatus = detail.status + try? context.save() + } catch { + // Non-fatal — show cached status silently + } + } + + // ── Push status change to server ────────────────────────────────────── + + private func changeStatus(to newStatus: String) async { + guard let sid = issue.serverId else { return } + isUpdatingStatus = true + statusError = nil + defer { isUpdatingStatus = false } + do { + let confirmed = try await APIClient.shared.updateIssueStatus( + issueId: sid, status: newStatus + ) + issue.issueStatus = confirmed + try? context.save() + } catch { + statusError = error.localizedDescription + } } } diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index 2382049..6cda5db 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -42,9 +42,7 @@ struct InspectionHistoryView: View { } else { List { ForEach(inspections) { inspection in - NavigationLink { - HistoryDetailView(inspection: inspection) - } label: { + NavigationLink(value: inspection) { HistoryRowView(inspection: inspection) } }