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
|
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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user