05/11 Update issue's status, and inspector can changes issue's status
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user