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
+174 -23
View File
@@ -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
}
}
}
@@ -42,9 +42,7 @@ struct InspectionHistoryView: View {
} else {
List {
ForEach(inspections) { inspection in
NavigationLink {
HistoryDetailView(inspection: inspection)
} label: {
NavigationLink(value: inspection) {
HistoryRowView(inspection: inspection)
}
}