05/11 Update issue's status, and inspector can changes issue's status

This commit is contained in:
Nguyen Ngo
2026-05-11 16:49:22 -04:00
parent c0b47ec132
commit 6367dac1e6
5 changed files with 238 additions and 27 deletions
+17
View File
@@ -201,6 +201,23 @@ actor APIClient {
return r.issueId 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 // Token Refresh
private func refreshAccessToken() async -> Bool { private func refreshAccessToken() async -> Bool {
+44 -1
View File
@@ -271,7 +271,7 @@ struct TemplateDetailResponseData: Decodable, Sendable {
// Inspections // Inspections
struct APIInspectionSummary: Decodable, Identifiable, Sendable { struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
let id: Int let id: Int
let templateId: Int let templateId: Int
let templateName: String let templateName: String
@@ -335,3 +335,46 @@ struct InspectionHistoryResponseData: Decodable, Sendable {
} }
private enum CodingKeys: String, CodingKey { case inspections, total, limit, 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?
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 }
}
+2
View File
@@ -15,6 +15,7 @@ final class LocalIssue {
var facilityServerId: Int // facility this issue belongs to (replaces areaServerId) var facilityServerId: Int // facility this issue belongs to (replaces areaServerId)
var severity: String // "low" | "medium" | "high" | "critical" var severity: String // "low" | "medium" | "high" | "critical"
var issueDescription: String var issueDescription: String
var issueStatus: String = "open" // server status: "open" | "in_progress" | "resolved" | "pending_verification"
var photoLocalPath: String? // local file path before upload var photoLocalPath: String? // local file path before upload
var photoServerPath: String? // server path after upload var photoServerPath: String? // server path after upload
@@ -37,6 +38,7 @@ final class LocalIssue {
self.facilityServerId = facilityServerId self.facilityServerId = facilityServerId
self.severity = severity self.severity = severity
self.issueDescription = description self.issueDescription = description
self.issueStatus = "open"
self.photoLocalPath = nil self.photoLocalPath = nil
self.photoServerPath = nil self.photoServerPath = nil
self.createdAt = Date() self.createdAt = Date()
+174 -23
View File
@@ -41,11 +41,38 @@ struct DashboardView: View {
@State private var selectedTab: SidebarTab = .myInspections @State private var selectedTab: SidebarTab = .myInspections
@State private var showNewInspection = false @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 { var body: some View {
NavigationSplitView { NavigationSplitView {
List { List {
// My Inspections // My Inspections
Button { selectedTab = .myInspections } label: { Button { selectTab(.myInspections) } label: {
HStack { HStack {
Label("My Inspections", systemImage: "checklist") Label("My Inspections", systemImage: "checklist")
.foregroundStyle(selectedTab == .myInspections ? .blue : .primary) .foregroundStyle(selectedTab == .myInspections ? .blue : .primary)
@@ -62,21 +89,21 @@ struct DashboardView: View {
.listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear) .listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear)
// Issues (all roles) // Issues (all roles)
Button { selectedTab = .issues } label: { Button { selectTab(.issues) } label: {
Label("Issues", systemImage: "exclamationmark.triangle") Label("Issues", systemImage: "exclamationmark.triangle")
.foregroundStyle(selectedTab == .issues ? .blue : .primary) .foregroundStyle(selectedTab == .issues ? .blue : .primary)
} }
.listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear) .listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear)
// Facilities // Facilities
Button { selectedTab = .facilities } label: { Button { selectTab(.facilities) } label: {
Label("Facilities", systemImage: "building.2") Label("Facilities", systemImage: "building.2")
.foregroundStyle(selectedTab == .facilities ? .blue : .primary) .foregroundStyle(selectedTab == .facilities ? .blue : .primary)
} }
.listRowBackground(selectedTab == .facilities ? Color.blue.opacity(0.1) : Color.clear) .listRowBackground(selectedTab == .facilities ? Color.blue.opacity(0.1) : Color.clear)
// Pending Sync // Pending Sync
Button { selectedTab = .pendingSync } label: { Button { selectTab(.pendingSync) } label: {
HStack { HStack {
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
.foregroundStyle(selectedTab == .pendingSync ? .blue : .primary) .foregroundStyle(selectedTab == .pendingSync ? .blue : .primary)
@@ -94,14 +121,14 @@ struct DashboardView: View {
.listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear) .listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear)
// History (moved sits between Pending Sync and Settings) // History (moved sits between Pending Sync and Settings)
Button { selectedTab = .history } label: { Button { selectTab(.history) } label: {
Label("History", systemImage: "clock.arrow.circlepath") Label("History", systemImage: "clock.arrow.circlepath")
.foregroundStyle(selectedTab == .history ? .blue : .primary) .foregroundStyle(selectedTab == .history ? .blue : .primary)
} }
.listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear) .listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear)
// Settings // Settings
Button { selectedTab = .settings } label: { Button { selectTab(.settings) } label: {
Label("Settings", systemImage: "gear") Label("Settings", systemImage: "gear")
.foregroundStyle(selectedTab == .settings ? .blue : .primary) .foregroundStyle(selectedTab == .settings ? .blue : .primary)
} }
@@ -120,12 +147,37 @@ struct DashboardView: View {
} detail: { } detail: {
switch selectedTab { switch selectedTab {
case .myInspections: NavigationStack { MyInspectionsView() } case .myInspections:
case .issues: NavigationStack { IssuesListView() } NavigationStack(path: $inspectionsPath) {
case .facilities: NavigationStack { FacilitiesListView() } MyInspectionsView()
case .pendingSync: NavigationStack { SyncStatusView() } .navigationDestination(for: LocalInspection.self) { inspection in
case .history: NavigationStack { InspectionHistoryView() } if inspection.status == "draft" {
case .settings: NavigationStack { SettingsView() } 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) { .sheet(isPresented: $showNewInspection) {
@@ -197,13 +249,7 @@ struct MyInspectionsView: View {
) )
} else { } else {
List(inspections) { inspection in List(inspections) { inspection in
NavigationLink { NavigationLink(value: inspection) {
if inspection.status == "draft" {
ExecuteInspectionView(inspection: inspection)
} else {
CompletedInspectionView(inspection: inspection)
}
} label: {
InspectionRowView(inspection: inspection, context: context) InspectionRowView(inspection: inspection, context: context)
} }
// Only drafts may be deleted submitted/pending-sync inspections are kept // Only drafts may be deleted submitted/pending-sync inspections are kept
@@ -635,9 +681,7 @@ struct IssuesListView: View {
) )
} else { } else {
List(issues) { issue in List(issues) { issue in
NavigationLink { NavigationLink(value: issue) {
IssueDetailView(issue: issue)
} label: {
IssueRowView(issue: issue, context: context) IssueRowView(issue: issue, context: context)
} }
} }
@@ -699,6 +743,12 @@ struct IssueRowView: View {
struct IssueDetailView: View { struct IssueDetailView: View {
let issue: LocalIssue let issue: LocalIssue
@Environment(\.modelContext) private var context @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 { private var facilityName: String {
let id = issue.facilityServerId 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 { var body: some View {
List { List {
Section("Issue Details") { Section("Issue Details") {
@@ -727,6 +801,46 @@ struct IssueDetailView: View {
LabeledContent("Facility", value: facilityName) LabeledContent("Facility", value: facilityName)
LabeledContent("Reported", value: issue.createdAt.formatted( LabeledContent("Reported", value: issue.createdAt.formatted(
date: .long, time: .shortened)) 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") { Section("Description") {
@@ -735,7 +849,7 @@ struct IssueDetailView: View {
} }
Section("Sync Status") { Section("Sync Status") {
LabeledContent("Status") { LabeledContent("Sync") {
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus) StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
} }
if let err = issue.syncErrorMessage { if let err = issue.syncErrorMessage {
@@ -762,6 +876,43 @@ struct IssueDetailView: View {
} }
.navigationTitle("Issue Detail") .navigationTitle("Issue Detail")
.navigationBarTitleDisplayMode(.inline) .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
}
} }
} }
@@ -42,9 +42,7 @@ struct InspectionHistoryView: View {
} else { } else {
List { List {
ForEach(inspections) { inspection in ForEach(inspections) { inspection in
NavigationLink { NavigationLink(value: inspection) {
HistoryDetailView(inspection: inspection)
} label: {
HistoryRowView(inspection: inspection) HistoryRowView(inspection: inspection)
} }
} }