582 lines
22 KiB
Swift
582 lines
22 KiB
Swift
// Views/Dashboard/DashboardView.swift
|
|
// ------------------------------------
|
|
// Phase B: adds My Inspections list and Pending Sync status to the sidebar.
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import Combine
|
|
|
|
struct DashboardView: View {
|
|
|
|
@EnvironmentObject private var auth: AuthManager
|
|
@EnvironmentObject private var sync: SyncManager
|
|
@Environment(\.modelContext) private var context
|
|
|
|
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
|
|
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
|
@Query(
|
|
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
|
sort: \LocalInspection.lastModifiedAt,
|
|
order: .reverse
|
|
) private var myInspections: [LocalInspection]
|
|
|
|
@State private var selectedTab = 0
|
|
@State private var showNewInspection = false
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
// ── Sidebar ────────────────────────────────────────────────────
|
|
List {
|
|
Button { selectedTab = 0 } label: {
|
|
HStack {
|
|
Label("My Inspections", systemImage: "checklist")
|
|
.foregroundStyle(selectedTab == 0 ? .blue : .primary)
|
|
Spacer()
|
|
if !myInspections.isEmpty {
|
|
Text("\(myInspections.count)")
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.blue.opacity(0.15))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear)
|
|
|
|
Button { selectedTab = 1 } label: {
|
|
Label("Facilities", systemImage: "building.2")
|
|
.foregroundStyle(selectedTab == 1 ? .blue : .primary)
|
|
}
|
|
.listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear)
|
|
|
|
Button { selectedTab = 2 } label: {
|
|
Label("Templates", systemImage: "doc.text")
|
|
.foregroundStyle(selectedTab == 2 ? .blue : .primary)
|
|
}
|
|
.listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear)
|
|
|
|
Button { selectedTab = 3 } label: {
|
|
HStack {
|
|
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
|
.foregroundStyle(selectedTab == 3 ? .blue : .primary)
|
|
Spacer()
|
|
if sync.pendingCount > 0 {
|
|
Text("\(sync.pendingCount)")
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.orange.opacity(0.2))
|
|
.foregroundStyle(.orange)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear)
|
|
|
|
Button { selectedTab = 4 } label: {
|
|
Label("Settings", systemImage: "gear")
|
|
.foregroundStyle(selectedTab == 4 ? .blue : .primary)
|
|
}
|
|
.listRowBackground(selectedTab == 4 ? Color.blue.opacity(0.1) : Color.clear)
|
|
}
|
|
.navigationTitle("JQC Inspector")
|
|
.listStyle(.sidebar)
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showNewInspection = true
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
}
|
|
}
|
|
.safeAreaInset(edge: .bottom) {
|
|
syncStatusFooter
|
|
}
|
|
|
|
} detail: {
|
|
switch selectedTab {
|
|
case 0: MyInspectionsView()
|
|
case 1: FacilitiesListView()
|
|
case 2: TemplatesListView()
|
|
case 3: SyncStatusView()
|
|
default: SettingsView()
|
|
}
|
|
}
|
|
.sheet(isPresented: $showNewInspection) {
|
|
StartInspectionView()
|
|
}
|
|
.task {
|
|
if sync.isOnline {
|
|
await sync.triggerSync()
|
|
} else {
|
|
sync.updatePendingCount(context: context)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Sync Status Footer ─────────────────────────────────────────────────
|
|
|
|
private var syncStatusFooter: some View {
|
|
VStack(spacing: 0) {
|
|
Divider()
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(sync.isOnline ? Color.green : Color.orange)
|
|
.frame(width: 8, height: 8)
|
|
Text(sync.isOnline ? "Online" : "Offline")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
if sync.isSyncing {
|
|
ProgressView()
|
|
.scaleEffect(0.7)
|
|
} else if let lastSync = sync.lastSyncAt {
|
|
Text("Synced \(lastSync.formatted(.relative(presentation: .named)))")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - My Inspections
|
|
|
|
struct MyInspectionsView: View {
|
|
|
|
@Query(
|
|
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
|
sort: \LocalInspection.lastModifiedAt,
|
|
order: .reverse
|
|
) private var inspections: [LocalInspection]
|
|
|
|
@Environment(\.modelContext) private var context
|
|
|
|
var body: some View {
|
|
Group {
|
|
if inspections.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Inspections",
|
|
systemImage: "checklist",
|
|
description: Text("Tap + to start a new inspection.")
|
|
)
|
|
} else {
|
|
List(inspections) { inspection in
|
|
NavigationLink {
|
|
if inspection.status == "draft" {
|
|
ExecuteInspectionView(inspection: inspection)
|
|
} else {
|
|
CompletedInspectionView(inspection: inspection)
|
|
}
|
|
} label: {
|
|
InspectionRowView(inspection: inspection, context: context)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("My Inspections")
|
|
}
|
|
}
|
|
|
|
struct InspectionRowView: View {
|
|
let inspection: LocalInspection
|
|
let context: ModelContext
|
|
|
|
private var facilityName: String {
|
|
let id = inspection.facilityServerId
|
|
return (try? context.fetch(
|
|
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
|
).first?.name) ?? "Unknown Facility"
|
|
}
|
|
|
|
private var templateName: String {
|
|
let id = inspection.templateServerId
|
|
return (try? context.fetch(
|
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
|
).first?.name) ?? "Unknown Template"
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(templateName)
|
|
.font(.headline)
|
|
Spacer()
|
|
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
|
|
}
|
|
Text(facilityName)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
HStack {
|
|
Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
if let score = inspection.overallScore {
|
|
Spacer()
|
|
Text(String(format: "%.1f%%", score))
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
struct StatusBadge: View {
|
|
let status: String
|
|
let syncStatus: String
|
|
|
|
var label: String {
|
|
switch status {
|
|
case "draft": return "Draft"
|
|
case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
|
|
case "sync_failed": return "Sync Failed"
|
|
default: return status.capitalized
|
|
}
|
|
}
|
|
|
|
var color: Color {
|
|
switch status {
|
|
case "draft": return .blue
|
|
case "completed": return syncStatus == "pending" ? .orange : .green
|
|
case "sync_failed": return .red
|
|
default: return .secondary
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Text(label)
|
|
.font(.caption2)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(color.opacity(0.15))
|
|
.foregroundStyle(color)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
// MARK: - Completed Inspection (read-only view)
|
|
|
|
struct CompletedInspectionView: View {
|
|
let inspection: LocalInspection
|
|
@Environment(\.modelContext) private var context
|
|
|
|
private var templateName: String {
|
|
let id = inspection.templateServerId
|
|
return (try? context.fetch(
|
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
|
).first?.name) ?? "Inspection"
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
// Summary card
|
|
GroupBox {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if let score = inspection.overallScore {
|
|
HStack {
|
|
Text("Overall Score")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(String(format: "%.1f%%", score))
|
|
.font(.title2.bold())
|
|
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
|
}
|
|
}
|
|
if let completedAt = inspection.completedAt {
|
|
HStack {
|
|
Text("Completed")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(completedAt.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.callout)
|
|
}
|
|
}
|
|
HStack {
|
|
Text("Sync Status")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
StatusBadge(status: inspection.status,
|
|
syncStatus: inspection.syncStatus)
|
|
}
|
|
if let error = inspection.syncErrorMessage {
|
|
Text("Error: \(error)")
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
// Issues
|
|
if !inspection.localIssues.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Flagged Issues (\(inspection.localIssues.count))")
|
|
.font(.headline)
|
|
.padding(.horizontal)
|
|
ForEach(inspection.localIssues) { issue in
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Circle()
|
|
.fill(issue.severity == "critical" ? Color.red :
|
|
issue.severity == "high" ? Color.orange :
|
|
issue.severity == "medium" ? Color.yellow : Color.blue)
|
|
.frame(width: 8, height: 8)
|
|
.padding(.top, 4)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(issue.severity.capitalized)
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.secondary)
|
|
Text(issue.issueDescription)
|
|
.font(.callout)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical)
|
|
}
|
|
.navigationTitle(templateName)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync Status View
|
|
|
|
struct SyncStatusView: View {
|
|
|
|
@EnvironmentObject private var sync: SyncManager
|
|
@Environment(\.modelContext) private var context
|
|
|
|
@Query(
|
|
filter: #Predicate<LocalInspection> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
|
|
sort: \LocalInspection.createdAt
|
|
) private var pendingInspections: [LocalInspection]
|
|
|
|
@Query(
|
|
filter: #Predicate<LocalIssue> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
|
|
sort: \LocalIssue.createdAt
|
|
) private var pendingIssues: [LocalIssue]
|
|
|
|
var body: some View {
|
|
List {
|
|
Section("Status") {
|
|
HStack {
|
|
Circle()
|
|
.fill(sync.isOnline ? Color.green : Color.orange)
|
|
.frame(width: 8, height: 8)
|
|
Text(sync.isOnline ? "Online" : "Offline")
|
|
}
|
|
if let lastSync = sync.lastSyncAt {
|
|
LabeledContent("Last Sync",
|
|
value: lastSync.formatted(date: .abbreviated, time: .shortened))
|
|
}
|
|
if sync.isSyncing {
|
|
HStack {
|
|
ProgressView()
|
|
Text("Syncing…")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if let error = sync.syncError {
|
|
Text(error).foregroundStyle(.red).font(.callout)
|
|
}
|
|
Button {
|
|
Task { await sync.triggerSync() }
|
|
} label: {
|
|
Label("Sync Now", systemImage: "arrow.clockwise")
|
|
}
|
|
.disabled(!sync.isOnline || sync.isSyncing)
|
|
}
|
|
|
|
if !pendingInspections.isEmpty {
|
|
Section("Pending Inspections (\(pendingInspections.count))") {
|
|
ForEach(pendingInspections) { inspection in
|
|
SyncRowView(
|
|
title: "Inspection",
|
|
status: inspection.syncStatus,
|
|
retryCount: inspection.syncRetryCount,
|
|
error: inspection.syncErrorMessage,
|
|
date: inspection.createdAt
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !pendingIssues.isEmpty {
|
|
Section("Pending Issues (\(pendingIssues.count))") {
|
|
ForEach(pendingIssues) { issue in
|
|
SyncRowView(
|
|
title: "\(issue.severity.capitalized) Issue",
|
|
status: issue.syncStatus,
|
|
retryCount: issue.syncRetryCount,
|
|
error: issue.syncErrorMessage,
|
|
date: issue.createdAt
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if pendingInspections.isEmpty && pendingIssues.isEmpty && !sync.isSyncing {
|
|
Section {
|
|
Label("All items synced.", systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Pending Sync")
|
|
}
|
|
}
|
|
|
|
struct SyncRowView: View {
|
|
let title: String
|
|
let status: String
|
|
let retryCount: Int
|
|
let error: String?
|
|
let date: Date
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(title).font(.callout)
|
|
Spacer()
|
|
Text(status.capitalized)
|
|
.font(.caption2)
|
|
.foregroundStyle(status == "failed" ? .red : .orange)
|
|
}
|
|
Text(date.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
if let err = error {
|
|
Text(err)
|
|
.font(.caption2)
|
|
.foregroundStyle(.red)
|
|
.lineLimit(2)
|
|
}
|
|
if retryCount > 0 {
|
|
Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Facilities, Templates, Settings (unchanged from Phase A)
|
|
|
|
struct FacilitiesListView: View {
|
|
@Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility]
|
|
|
|
var body: some View {
|
|
Group {
|
|
if facilities.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Facilities",
|
|
systemImage: "building.2.slash",
|
|
description: Text("Connect to the internet to sync your assigned facilities.")
|
|
)
|
|
} else {
|
|
List(facilities) { facility in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(facility.name).font(.headline)
|
|
if !facility.address.isEmpty {
|
|
Text(facility.address).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
if facility.projectName != "No Contract" {
|
|
Text(facility.projectName).font(.caption2).foregroundStyle(.blue)
|
|
}
|
|
Text("\(facility.areas.count) area\(facility.areas.count == 1 ? "" : "s")")
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Facilities (\(facilities.count))")
|
|
}
|
|
}
|
|
|
|
struct TemplatesListView: View {
|
|
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
|
|
|
var body: some View {
|
|
Group {
|
|
if templates.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Templates",
|
|
systemImage: "doc.text.magnifyingglass",
|
|
description: Text("Connect to the internet to sync inspection templates.")
|
|
)
|
|
} else {
|
|
List(templates) { template in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(template.name).font(.headline)
|
|
if !template.templateDescription.isEmpty {
|
|
Text(template.templateDescription)
|
|
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
|
}
|
|
HStack {
|
|
if !template.frequency.isEmpty {
|
|
Label(template.frequencyLabel, systemImage: "clock")
|
|
.font(.caption2).foregroundStyle(.blue)
|
|
}
|
|
Spacer()
|
|
Text("\(template.formSchema.count) field\(template.formSchema.count == 1 ? "" : "s")")
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Templates (\(templates.count))")
|
|
}
|
|
}
|
|
|
|
struct SettingsView: View {
|
|
@EnvironmentObject private var auth: AuthManager
|
|
@EnvironmentObject private var sync: SyncManager
|
|
|
|
var body: some View {
|
|
List {
|
|
Section("Account") {
|
|
LabeledContent("Username", value: auth.currentUsername)
|
|
LabeledContent("Role", value: auth.currentUserRole.capitalized)
|
|
}
|
|
Section("Sync") {
|
|
Button { Task { await sync.triggerSync() } } label: {
|
|
Label("Sync Now", systemImage: "arrow.clockwise")
|
|
}
|
|
.disabled(!sync.isOnline || sync.isSyncing)
|
|
if let error = sync.syncError {
|
|
Text(error).font(.caption).foregroundStyle(.red)
|
|
}
|
|
}
|
|
Section {
|
|
Button(role: .destructive) {
|
|
Task { await auth.logout() }
|
|
} label: {
|
|
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
|
|
}
|
|
}
|
|
Section("App Info") {
|
|
LabeledContent("Version", value: "Phase B")
|
|
LabeledContent("Server", value: Constants.baseURL)
|
|
}
|
|
}
|
|
.navigationTitle("Settings")
|
|
}
|
|
}
|