Merge branch 'main' of https://gitea.ngodanguyen.tech/nngo/JQC_iOS_App
This commit is contained in:
@@ -34,6 +34,7 @@ struct DashboardView: View {
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
|
||||
@Query(
|
||||
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
||||
@@ -41,6 +42,19 @@ struct DashboardView: View {
|
||||
order: .reverse
|
||||
) private var myInspections: [LocalInspection]
|
||||
|
||||
/// Outstanding follow-up requests, counted into the My Inspections badge so
|
||||
/// the inspector sees there is work waiting from any tab — the same reason
|
||||
/// in-progress inspections are counted there.
|
||||
@Query private var followUpRequests: [LocalFollowUpRequest]
|
||||
|
||||
/// Badge count for My Inspections: in-progress work plus outstanding
|
||||
/// follow-ups. `fulfilledLocally` rows are excluded in Swift, not in the
|
||||
/// @Query predicate (CLAUDE.md rule 3), so the badge drops the instant a
|
||||
/// re-inspection is submitted.
|
||||
private var myInspectionsBadgeCount: Int {
|
||||
myInspections.count + followUpRequests.filter { !$0.fulfilledLocally }.count
|
||||
}
|
||||
|
||||
@State private var selectedTab: SidebarTab = .dashboard
|
||||
/// Each sidebar tap refreshes the UUID for that tab, forcing its
|
||||
/// NavigationStack to be destroyed and recreated — even when the tab
|
||||
@@ -71,141 +85,26 @@ struct DashboardView: View {
|
||||
selectedTab = tab
|
||||
}
|
||||
|
||||
/// Sidebar order — single source of truth for both the regular-width
|
||||
/// sidebar and the compact-width root list.
|
||||
private let sidebarTabs: [SidebarTab] = [
|
||||
.dashboard, .myInspections, .issues, .facilities,
|
||||
.pendingSync, .history, .notifications, .settings,
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
// ── Dashboard ──────────────────────────────────────────────
|
||||
Button { selectTab(.dashboard) } label: {
|
||||
Label("Dashboard", systemImage: "chart.bar.xaxis")
|
||||
.foregroundStyle(selectedTab == .dashboard ? .blue : .primary)
|
||||
}
|
||||
.listRowBackground(selectedTab == .dashboard ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// ── My Inspections ─────────────────────────────────────────
|
||||
Button { selectTab(.myInspections) } label: {
|
||||
HStack {
|
||||
Label("My Inspections", systemImage: "checklist")
|
||||
.foregroundStyle(selectedTab == .myInspections ? .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 == .myInspections ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// ── Issues (all roles) ─────────────────────────────────────
|
||||
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 { 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 { selectTab(.pendingSync) } label: {
|
||||
HStack {
|
||||
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(selectedTab == .pendingSync ? .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 == .pendingSync ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// ── History (moved — sits between Pending Sync and Settings)
|
||||
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)
|
||||
|
||||
// ── Notifications ─────────────────────────────────────────
|
||||
Button {
|
||||
selectTab(.notifications)
|
||||
sync.markNotificationsViewed()
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Notifications", systemImage: "bell")
|
||||
.foregroundStyle(selectedTab == .notifications ? .blue : .primary)
|
||||
Spacer()
|
||||
if sync.unreadNotificationCount > 0 {
|
||||
Text("\(min(sync.unreadNotificationCount, 99))")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Color.red.opacity(0.85))
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(selectedTab == .notifications ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────
|
||||
Button { selectTab(.settings) } label: {
|
||||
Label("Settings", systemImage: "gear")
|
||||
.foregroundStyle(selectedTab == .settings ? .blue : .primary)
|
||||
}
|
||||
.listRowBackground(selectedTab == .settings ? Color.blue.opacity(0.1) : Color.clear)
|
||||
}
|
||||
.navigationTitle("JQC Inspector")
|
||||
.listStyle(.sidebar)
|
||||
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
||||
|
||||
} detail: {
|
||||
switch selectedTab {
|
||||
case .dashboard:
|
||||
NavigationStack { DashboardStatsView() }
|
||||
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 .notifications:
|
||||
NavigationStack { NotificationsView() }
|
||||
case .settings:
|
||||
NavigationStack { SettingsView() }
|
||||
Group {
|
||||
// On compact width (iPhone) a NavigationSplitView collapses to show
|
||||
// ONLY the sidebar: its `detail:` column is never presented, because
|
||||
// nothing pushes it. The rows here are plain Buttons driving @State
|
||||
// (rule 2 forbids a `selection:` binding), and a state change alone
|
||||
// cannot push the detail column — so every destination was
|
||||
// unreachable on iPhone. Compact width therefore gets a real
|
||||
// NavigationStack whose rows are NavigationLinks.
|
||||
if hSizeClass == .compact {
|
||||
compactBody
|
||||
} else {
|
||||
regularBody
|
||||
}
|
||||
}
|
||||
.task {
|
||||
@@ -223,6 +122,181 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Regular width (iPad) — unchanged two-column split view ────────────
|
||||
|
||||
private var regularBody: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(sidebarTabs, id: \.self) { tab in
|
||||
Button {
|
||||
selectTab(tab)
|
||||
if tab == .notifications { sync.markNotificationsViewed() }
|
||||
} label: {
|
||||
sidebarRowLabel(tab, tinted: selectedTab == tab)
|
||||
}
|
||||
.listRowBackground(
|
||||
selectedTab == tab ? Color.blue.opacity(0.1) : Color.clear
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("JQC Inspector")
|
||||
.listStyle(.sidebar)
|
||||
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
||||
|
||||
} detail: {
|
||||
switch selectedTab {
|
||||
case .myInspections:
|
||||
NavigationStack(path: $inspectionsPath) { detailRoot(for: .myInspections) }
|
||||
case .issues:
|
||||
NavigationStack(path: $issuesPath) { detailRoot(for: .issues) }
|
||||
case .history:
|
||||
NavigationStack(path: $historyPath) { detailRoot(for: .history) }
|
||||
default:
|
||||
NavigationStack { detailRoot(for: selectedTab) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compact width (iPhone) — push-based stack ─────────────────────────
|
||||
// One NavigationStack whose root is the same destination list. Rows are
|
||||
// NavigationLinks so tapping actually pushes. The per-tab paths used by
|
||||
// the iPad split view are not needed here: this single stack owns the
|
||||
// whole hierarchy, and the nested `.navigationDestination`s declared in
|
||||
// detailRoot(for:) register against it.
|
||||
|
||||
private var compactBody: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(sidebarTabs, id: \.self) { tab in
|
||||
NavigationLink(value: tab) {
|
||||
sidebarRowLabel(tab, tinted: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("JQC Inspector")
|
||||
.navigationDestination(for: SidebarTab.self) { detailRoot(for: $0) }
|
||||
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared row label ──────────────────────────────────────────────────
|
||||
|
||||
@ViewBuilder
|
||||
private func sidebarRowLabel(_ tab: SidebarTab, tinted: Bool) -> some View {
|
||||
let tint: Color = tinted ? .blue : .primary
|
||||
switch tab {
|
||||
case .dashboard:
|
||||
Label("Dashboard", systemImage: "chart.bar.xaxis")
|
||||
.foregroundStyle(tint)
|
||||
|
||||
case .myInspections:
|
||||
HStack {
|
||||
Label("My Inspections", systemImage: "checklist")
|
||||
.foregroundStyle(tint)
|
||||
Spacer()
|
||||
if myInspectionsBadgeCount > 0 {
|
||||
Text("\(myInspectionsBadgeCount)")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
case .issues:
|
||||
Label("Issues", systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(tint)
|
||||
|
||||
case .facilities:
|
||||
Label("Facilities", systemImage: "building.2")
|
||||
.foregroundStyle(tint)
|
||||
|
||||
case .pendingSync:
|
||||
HStack {
|
||||
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(tint)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
case .history:
|
||||
Label("History", systemImage: "clock.arrow.circlepath")
|
||||
.foregroundStyle(tint)
|
||||
|
||||
case .notifications:
|
||||
HStack {
|
||||
Label("Notifications", systemImage: "bell")
|
||||
.foregroundStyle(tint)
|
||||
Spacer()
|
||||
if sync.unreadNotificationCount > 0 {
|
||||
Text("\(min(sync.unreadNotificationCount, 99))")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Color.red.opacity(0.85))
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
case .settings:
|
||||
Label("Settings", systemImage: "gear")
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared destination root ───────────────────────────────────────────
|
||||
// The NavigationStack wrapper lives at the call site, so the same content
|
||||
// serves as a split-view detail root (iPad) and a pushed view (iPhone).
|
||||
|
||||
@ViewBuilder
|
||||
private func detailRoot(for tab: SidebarTab) -> some View {
|
||||
switch tab {
|
||||
case .dashboard:
|
||||
DashboardStatsView()
|
||||
|
||||
case .myInspections:
|
||||
MyInspectionsView()
|
||||
.navigationDestination(for: LocalInspection.self) { inspection in
|
||||
if inspection.status == "draft" {
|
||||
ExecuteInspectionView(inspection: inspection)
|
||||
} else {
|
||||
CompletedInspectionView(inspection: inspection)
|
||||
}
|
||||
}
|
||||
|
||||
case .issues:
|
||||
IssuesListView()
|
||||
.navigationDestination(for: LocalIssue.self) { issue in
|
||||
IssueDetailView(issue: issue)
|
||||
}
|
||||
|
||||
case .facilities:
|
||||
FacilitiesListView()
|
||||
|
||||
case .pendingSync:
|
||||
SyncStatusView()
|
||||
|
||||
case .history:
|
||||
InspectionHistoryView()
|
||||
.navigationDestination(for: APIInspectionSummary.self) { inspection in
|
||||
HistoryDetailView(inspection: inspection)
|
||||
}
|
||||
|
||||
case .notifications:
|
||||
NotificationsView()
|
||||
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
|
||||
private var syncStatusFooter: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
@@ -266,6 +340,15 @@ struct DashboardStatsView: View {
|
||||
) private var draftInspections: [LocalInspection]
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
/// Schedule the inspector tapped in ScheduledInspectionsCard. Held here, not
|
||||
/// in the card: the card self-hides, and submitting the last scheduled
|
||||
/// inspection empties its @Query while the start form is still presented.
|
||||
@State private var scheduledStartTarget: ScheduledStartTarget? = nil
|
||||
|
||||
/// The follow-up request tapped in FollowUpRequestsCard. Held here for the
|
||||
/// same reason as `scheduledStartTarget` — see the covers at the bottom.
|
||||
@State private var followUpStartTarget: FollowUpStartTarget? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
@@ -275,11 +358,22 @@ struct DashboardStatsView: View {
|
||||
DraftResumeBanner(drafts: draftInspections, context: context)
|
||||
}
|
||||
|
||||
// ── Follow-up Requests ─────────────────────────────────────
|
||||
// Re-inspections a director asked for. Ranked above SCHEDULED:
|
||||
// a follow-up is remedial work on a facility that already failed
|
||||
// once, so it is the more urgent of the two. Self-hides when
|
||||
// there are none. Tap a row to start the linked re-inspection.
|
||||
FollowUpRequestsCard(onStart: { target in
|
||||
followUpStartTarget = target
|
||||
})
|
||||
|
||||
// ── Scheduled Inspections (phase36) ────────────────────────
|
||||
// Planned/recurring assignments for this inspector. Self-hides
|
||||
// when there are none. Tap a row to start it (facility +
|
||||
// template preselected).
|
||||
ScheduledInspectionsCard()
|
||||
ScheduledInspectionsCard(onStart: { target in
|
||||
scheduledStartTarget = target
|
||||
})
|
||||
|
||||
if let stats = sync.dashboardStats {
|
||||
// ── Today ──────────────────────────────────────────────
|
||||
@@ -407,6 +501,43 @@ struct DashboardStatsView: View {
|
||||
.refreshable {
|
||||
await sync.fetchDashboardStats()
|
||||
}
|
||||
// Start cover for a tapped scheduled inspection. Owned here rather than
|
||||
// by ScheduledInspectionsCard because that card self-hides the instant
|
||||
// its last row is removed — which is exactly when this cover is on
|
||||
// screen (submit deletes the cached schedule row). The ScrollView is
|
||||
// always present, so the form is never torn down mid-submit.
|
||||
.fullScreenCover(item: $scheduledStartTarget) { t in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: t.templateServerId,
|
||||
preFillFacilityId: t.facilityServerId,
|
||||
// phase45 — nil for an ordinary schedule; set when this row is a
|
||||
// planned follow-up, which makes the run a linked re-inspection.
|
||||
// Must precede preFillScheduleId: argument order follows the
|
||||
// property declaration order in StartInspectionView.
|
||||
parentServerId: t.parentServerId,
|
||||
preFillScheduleId: t.id,
|
||||
preFillScheduleInstructions: t.instructions
|
||||
)
|
||||
}
|
||||
// Start cover for a tapped follow-up request. Owned here for the same
|
||||
// reason as the scheduled cover above: FollowUpRequestsCard self-hides
|
||||
// the instant its last row is invalidated at submit, which is exactly
|
||||
// when this cover is on screen.
|
||||
//
|
||||
// `parentServerId` is what makes this a re-inspection rather than a
|
||||
// fresh one — the server reads it to clear follow_up_required on the
|
||||
// flagged inspection. `parentLocalId` stays nil: the parent synced long
|
||||
// ago (that is how it got flagged), so serverId is the reliable handle,
|
||||
// and clearParentFollowUpFlag()'s fallback-1 matches on it.
|
||||
.fullScreenCover(item: $followUpStartTarget) { t in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: t.templateServerId,
|
||||
preFillFacilityId: t.facilityServerId,
|
||||
parentServerId: t.id,
|
||||
preFillFollowUpNote: t.note,
|
||||
preFillParentFormDataJSON: t.parentFormDataJSON
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
@@ -440,6 +571,9 @@ struct DashboardStatsView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
// Tiles are 2-up, so on a phone each is ~170 pt wide and
|
||||
// longer labels ("Open / In Progress") would truncate.
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
Text(value)
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
|
||||
@@ -40,6 +40,14 @@ struct ExecuteInspectionView: View {
|
||||
@State private var isSubmitting = false
|
||||
@State private var submitResult: SubmitResult?
|
||||
|
||||
// ── Schedule instructions ─────────────────────────────────────────────
|
||||
// Snapshotted into @State in onAppear rather than read from SwiftData on
|
||||
// every body pass: resolveAndFulfillSchedule() DELETES the cached
|
||||
// LocalScheduledInspection row at submit time, while this view is still on
|
||||
// screen showing the success banner. Reading a deleted PersistentModel traps.
|
||||
@State private var scheduleInstructions: String? = nil
|
||||
@State private var instructionsExpanded = true
|
||||
|
||||
// Location manager — created on view init. requestLocation() is called in
|
||||
// onAppear so the permission prompt (and GPS fix acquisition) starts as
|
||||
// soon as the inspector opens the inspection, maximising the chance of
|
||||
@@ -158,6 +166,7 @@ struct ExecuteInspectionView: View {
|
||||
}
|
||||
.onAppear {
|
||||
formValues = inspection.formData.compactMapValues { "\($0)" }
|
||||
loadScheduleInstructions()
|
||||
// Request Location permission (and start acquiring a fix) the moment
|
||||
// the inspector opens the inspection — gives GPS the entire duration
|
||||
// of the inspection to get a fix, rather than only the few seconds
|
||||
@@ -260,6 +269,15 @@ struct ExecuteInspectionView: View {
|
||||
headerCard
|
||||
.padding(.bottom, 16)
|
||||
|
||||
// Instructions from the schedule this inspection fulfils. Sits directly
|
||||
// above the form so the inspector can re-read it mid-inspection without
|
||||
// leaving the screen. Collapsible because long instructions would
|
||||
// otherwise push the first form field below the fold.
|
||||
if let instructions = scheduleInstructions {
|
||||
instructionsBanner(instructions)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
// CHANGED: grid-based form rendering (replaces card-grouped linear list)
|
||||
if formSchema.isEmpty {
|
||||
emptyFormPlaceholder
|
||||
@@ -297,6 +315,60 @@ struct ExecuteInspectionView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
// ── Instructions Banner ───────────────────────────────────────────────
|
||||
|
||||
/// Manager-authored instructions for the schedule this inspection fulfils.
|
||||
/// Wire/DB field is `notes`; "Instructions" is the user-facing wording.
|
||||
private func instructionsBanner(_ text: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
instructionsExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundStyle(.blue)
|
||||
Text("Instructions")
|
||||
.font(.callout.bold())
|
||||
.foregroundStyle(.blue)
|
||||
Spacer()
|
||||
Image(systemName: instructionsExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if instructionsExpanded {
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.blue.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
/// Copy the schedule's instructions into `@State` once, at appear.
|
||||
///
|
||||
/// Deliberately a snapshot, not a computed lookup: the cached
|
||||
/// `LocalScheduledInspection` is deleted by `resolveAndFulfillSchedule()`
|
||||
/// the instant Submit is tapped, and this view stays on screen for another
|
||||
/// 2.5 s afterwards. A computed property would re-read a deleted
|
||||
/// `PersistentModel` during that window and trap.
|
||||
private func loadScheduleInstructions() {
|
||||
guard let schedId = inspection.scheduledInspectionServerId else { return }
|
||||
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||
let all = (try? context.fetch(FetchDescriptor<LocalScheduledInspection>())) ?? []
|
||||
scheduleInstructions = all.first(where: { $0.serverId == schedId })?.instructions
|
||||
}
|
||||
|
||||
// ── Header Card ───────────────────────────────────────────────────────
|
||||
|
||||
private var headerCard: some View {
|
||||
@@ -576,6 +648,22 @@ struct ExecuteInspectionView: View {
|
||||
// regardless of connectivity or sync timing.
|
||||
clearParentFollowUpFlag()
|
||||
|
||||
// ── Drop the cached follow-up request row ─────────────────────────
|
||||
// Same immediacy as above, for the other surface: the FOLLOW-UP
|
||||
// REQUESTED card on the Dashboard and My Inspections reads its own
|
||||
// pulled cache, not LocalInspection, so clearing the flag above is not
|
||||
// enough to make the row disappear.
|
||||
fulfillFollowUpRequest()
|
||||
|
||||
// ── Fulfil the originating scheduled inspection ───────────────────
|
||||
// Two jobs, both mirroring the web app's execute route:
|
||||
// 1. Make sure the submission carries scheduled_inspection_id, even
|
||||
// when the inspector reached this form via "+" instead of the
|
||||
// Scheduled row — the server cannot fulfil an unlinked inspection.
|
||||
// 2. Drop the cached schedule row so the SCHEDULED card clears the
|
||||
// moment Submit is tapped, online or offline.
|
||||
resolveAndFulfillSchedule()
|
||||
|
||||
try? context.save()
|
||||
|
||||
isSubmitting = false
|
||||
@@ -638,6 +726,116 @@ struct ExecuteInspectionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate the cached follow-up request this submission satisfies, so the
|
||||
/// FOLLOW-UP REQUESTED card and section clear the moment Submit is tapped —
|
||||
/// online or offline — rather than waiting for the round trip.
|
||||
///
|
||||
/// Matches on `parentServerId` alone. Unlike the schedule fallback there is
|
||||
/// no facility+template guess here: a request is keyed by the exact
|
||||
/// inspection it was raised against, and that id is set whenever the run was
|
||||
/// launched from a follow-up row or from CompletedInspectionView's banner.
|
||||
/// An ad-hoc inspection of the same facility is genuinely not the follow-up
|
||||
/// the director asked for, and must not clear it.
|
||||
///
|
||||
/// The row is flagged, not deleted, for the same reason as
|
||||
/// `LocalScheduledInspection.fulfilledLocally`: the server is authoritative,
|
||||
/// and `pullFollowUpRequests()` deletes the row once the flag actually
|
||||
/// clears — or brings it back if the submission never landed.
|
||||
private func fulfillFollowUpRequest() {
|
||||
guard let sid = inspection.parentServerId else { return }
|
||||
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||
let all = (try? context.fetch(FetchDescriptor<LocalFollowUpRequest>())) ?? []
|
||||
all.first { $0.serverId == sid }?.fulfilledLocally = true
|
||||
}
|
||||
|
||||
// ── Scheduled inspection fulfilment ───────────────────────────────────
|
||||
|
||||
/// Link this submission to the schedule it satisfies, then drop the cached
|
||||
/// schedule row.
|
||||
///
|
||||
/// **Why the fallback link exists.** On the web, `scheduled_inspections.start`
|
||||
/// is the *only* way to open a scheduled inspection, so the link is always
|
||||
/// present. On the iPad the Scheduled row merely pre-selects facility +
|
||||
/// template — the inspector can reach the identical form through the "+"
|
||||
/// button, and that path leaves `scheduledInspectionServerId` nil. The
|
||||
/// server then stores `scheduled_inspection_id = NULL`, never calls
|
||||
/// `_fulfill_schedule()`, and the schedule stays **Active** on the web.
|
||||
/// Matching facility + template here restores parity of outcome between the
|
||||
/// two entry points.
|
||||
///
|
||||
/// **Why the match is narrow.** Only schedules already due (due date on or
|
||||
/// before today) are eligible, so an ad-hoc inspection today cannot silently
|
||||
/// close out an occurrence planned for next month. Assignment must also fit:
|
||||
/// unassigned schedules, or ones assigned to this inspector. When several
|
||||
/// qualify, the earliest due date wins — that is the occurrence being worked.
|
||||
///
|
||||
/// **Why the row is deleted rather than rolled forward.** `LocalScheduledInspection`
|
||||
/// is a read-only cache and does not carry the phase43 recurrence detail
|
||||
/// (weekdays / month_mode / day_of_month / nth_week / nth_weekday), so the
|
||||
/// next due date cannot be computed correctly on device. Deleting invalidates
|
||||
/// the cache instead: `pullScheduledInspections()` re-inserts recurring
|
||||
/// schedules with the server-authoritative `next_due_date` on the next pull,
|
||||
/// and a one-time schedule stays gone because the server has deactivated it.
|
||||
/// The same pull also restores the row if the submission never lands, so a
|
||||
/// failed sync self-heals.
|
||||
private func resolveAndFulfillSchedule() {
|
||||
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||
let all = (try? context.fetch(FetchDescriptor<LocalScheduledInspection>())) ?? []
|
||||
guard !all.isEmpty else { return }
|
||||
|
||||
// Fallback link for inspections not started from a Scheduled row.
|
||||
// Re-inspections are excluded: a follow-up shares its parent's facility
|
||||
// and template, so it would otherwise close out an unrelated planned
|
||||
// occurrence. The web app keeps the two workflows separate the same way.
|
||||
let isReInspection = inspection.parentServerId != nil || inspection.parentLocalId != nil
|
||||
if inspection.scheduledInspectionServerId == nil && !isReInspection {
|
||||
let tid = inspection.templateServerId
|
||||
let fid = inspection.facilityServerId
|
||||
let uid = inspection.inspectorUserId
|
||||
let today = Self.dueDateFormatter.string(from: Date())
|
||||
|
||||
let candidate = all
|
||||
.filter {
|
||||
$0.templateServerId == tid &&
|
||||
$0.facilityServerId == fid &&
|
||||
($0.inspectorId == nil || $0.inspectorId == uid) &&
|
||||
!$0.fulfilledLocally && // already satisfied, awaiting sync
|
||||
!$0.dueDateString.isEmpty &&
|
||||
$0.dueDateString <= today // ISO strings sort chronologically
|
||||
}
|
||||
.sorted { $0.dueDateString < $1.dueDateString }
|
||||
.first
|
||||
|
||||
if let candidate {
|
||||
inspection.scheduledInspectionServerId = candidate.serverId
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the row until the server confirms what happened to it.
|
||||
//
|
||||
// NOT a delete. A recurring schedule comes back from the server on its
|
||||
// next occurrence, so deleting turned every completion into a
|
||||
// delete-then-reinsert against the `@Attribute(.unique)` serverId, and
|
||||
// the reinserted row did not reliably pick up the new due date — a
|
||||
// daily schedule kept showing today's date after being completed.
|
||||
// One-time schedules masked it, because the server stops returning them
|
||||
// and they are never reinserted. Flagging leaves `update(from:)` as the
|
||||
// single path that ever writes a cached schedule's dates.
|
||||
guard let schedId = inspection.scheduledInspectionServerId,
|
||||
let sched = all.first(where: { $0.serverId == schedId })
|
||||
else { return }
|
||||
|
||||
sched.fulfilledLocally = true
|
||||
}
|
||||
|
||||
/// "yyyy-MM-dd", matching `LocalScheduledInspection.dueDateString`.
|
||||
private static let dueDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
// ── Photo Handling ────────────────────────────────────────────────────
|
||||
|
||||
private func handlePhotoSelected(localPath: String, field: [String: Any]) {
|
||||
@@ -701,6 +899,25 @@ struct GridFormView: View {
|
||||
static let cellAspect: CGFloat = 52/72 // cellH / cellW — matches editor CELL_H/CELL_W
|
||||
static let cardPadding: CGFloat = 16 // card inset on all sides
|
||||
|
||||
// Below this container width the 12-column grid stops being usable: at
|
||||
// 375 pt (iPhone SE/6/7/8) a column is only ~21 pt wide and a row ~15 pt
|
||||
// tall, so a normal 6x2 field renders ~167x35 pt — the label alone eats
|
||||
// most of it. Cells are absolutely positioned and deliberately unclipped
|
||||
// (see body), so the overflow draws on top of the row beneath and the
|
||||
// form becomes an unreadable pile. Under this width we reflow to one
|
||||
// field per line instead. 600 pt keeps a column at >=40 pt.
|
||||
static let minGridWidth: CGFloat = 600
|
||||
|
||||
// Heights for widgets that have no intrinsic size of their own. In the
|
||||
// absolute grid these are driven by rowSpan; in the stacked layout there
|
||||
// is no rowSpan to read, so they would otherwise collapse to nothing.
|
||||
static let stackedMinH: [String: CGFloat] = [
|
||||
"textarea": 96,
|
||||
"signature": 120,
|
||||
"table": 120,
|
||||
"image": 88,
|
||||
]
|
||||
|
||||
// Minimum cell height (points) per field type — ensures 44pt touch targets
|
||||
// on iPad even when the template author assigned a very short rowSpan.
|
||||
static let minCellH: [String: CGFloat] = [
|
||||
@@ -725,54 +942,115 @@ struct GridFormView: View {
|
||||
@State private var containerWidth: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
// ── Card background ────────────────────────────────────────────
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
Group {
|
||||
if containerWidth > 0 && isCompact {
|
||||
// ── Compact: content drives the height ─────────────────────
|
||||
// The card is a .background modifier rather than a ZStack
|
||||
// sibling so it takes its size FROM the stack. As a ZStack
|
||||
// sibling the flexible RoundedRectangle competes with the
|
||||
// VStack for the container's size and the card ends up
|
||||
// shorter than its own content, cutting off the last fields.
|
||||
stackedLayout
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
} else {
|
||||
// ── Regular: absolute 12-column canvas ─────────────────────
|
||||
ZStack(alignment: .topLeading) {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
|
||||
// ── Width probe — zero-size overlay, reports container width ───
|
||||
// Using a background Color.clear with a GeometryReader that sends
|
||||
// its width via PreferenceKey is the idiomatic SwiftUI pattern that
|
||||
// works correctly inside ScrollView on all iOS versions.
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 0)
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear.preference(
|
||||
key: WidthPreferenceKey.self,
|
||||
value: geo.size.width
|
||||
)
|
||||
}
|
||||
)
|
||||
// Field overlays — only rendered after width is measured.
|
||||
// containerWidth == 0 means the PreferenceKey has not fired
|
||||
// yet (first layout pass). Skipping the overlay pass on the
|
||||
// zero frame prevents fields from being positioned using a
|
||||
// stale width and overflowing the modal on narrow sheet
|
||||
// presentations (iPad 10th gen).
|
||||
if containerWidth > 0 {
|
||||
let cellW = computedCellW
|
||||
let cellH = cellW * Self.cellAspect
|
||||
|
||||
// ── Field overlays — only rendered after width is measured ─────
|
||||
// containerWidth == 0 means the PreferenceKey has not fired yet
|
||||
// (first layout pass). Skipping the overlay pass on the zero frame
|
||||
// prevents fields from being positioned using a stale width and
|
||||
// overflowing the modal on narrow sheet presentations (iPad 10th gen).
|
||||
if containerWidth > 0 {
|
||||
let cellW = computedCellW
|
||||
let cellH = cellW * Self.cellAspect
|
||||
|
||||
ForEach(schema.indices, id: \.self) { idx in
|
||||
let field = schema[idx]
|
||||
let ftype = field["type"] as? String ?? "text"
|
||||
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
||||
gridCell(field: field, cellW: cellW, cellH: cellH)
|
||||
ForEach(schema.indices, id: \.self) { idx in
|
||||
let field = schema[idx]
|
||||
let ftype = field["type"] as? String ?? "text"
|
||||
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
||||
gridCell(field: field, cellW: cellW, cellH: cellH)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Height is derived from the same arithmetic as the cell
|
||||
// offsets — the ScrollView measures this frame and can never
|
||||
// be wrong.
|
||||
.frame(height: containerWidth > 0
|
||||
? canvasHeight() + 2 * Self.cardPadding
|
||||
: 0)
|
||||
}
|
||||
}
|
||||
// ── Width probe ───────────────────────────────────────────────────
|
||||
// Attached as a background so it reports the resolved container width
|
||||
// without taking part in sizing the content itself.
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear.preference(
|
||||
key: WidthPreferenceKey.self,
|
||||
value: geo.size.width
|
||||
)
|
||||
}
|
||||
)
|
||||
.onPreferenceChange(WidthPreferenceKey.self) { width in
|
||||
if width > 0 { containerWidth = width }
|
||||
}
|
||||
// Height is always derived from the same arithmetic as cell offsets —
|
||||
// the ScrollView measures this frame and can never be wrong.
|
||||
// When containerWidth is 0, canvasHeight() still returns the correct
|
||||
// value (it uses computedCellW which returns 0 when containerWidth is 0),
|
||||
// so the card reserves space and avoids a layout jump.
|
||||
.frame(height: containerWidth > 0 ? canvasHeight() + 2 * Self.cardPadding : 0)
|
||||
}
|
||||
|
||||
// ── Compact (narrow) layout ───────────────────────────────────────────
|
||||
// One field per line, full width, natural height. Fields are ordered by
|
||||
// (row, col) because the form editor stores them in drag/creation order,
|
||||
// not visual order — the same sort the PDF and read-only renderers use
|
||||
// (rule 62). Nothing is absolutely positioned here, so nothing can
|
||||
// overlap regardless of how narrow the screen gets.
|
||||
|
||||
private var isCompact: Bool { containerWidth < Self.minGridWidth }
|
||||
|
||||
private var orderedFields: [[String: Any]] {
|
||||
schema
|
||||
.filter { f in
|
||||
let t = f["type"] as? String ?? "text"
|
||||
return !["button_submit", "button_print", "button_email"].contains(t)
|
||||
}
|
||||
.sorted {
|
||||
let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0
|
||||
if r0 != r1 { return r0 < r1 }
|
||||
return ($0["col"] as? Int ?? 0) < ($1["col"] as? Int ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var stackedLayout: some View {
|
||||
let fields = orderedFields
|
||||
return VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(fields.indices, id: \.self) { idx in
|
||||
let field = fields[idx]
|
||||
let ftype = field["type"] as? String ?? "text"
|
||||
let fid = fieldId(field)
|
||||
|
||||
GridCellContentView(
|
||||
field: field,
|
||||
value: Binding(
|
||||
get: { formValues[fid] ?? "" },
|
||||
set: { formValues[fid] = $0; onFieldChanged?() }
|
||||
),
|
||||
onPhotoSelected: { path in onPhotoSelected?(path, field) }
|
||||
)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: Self.stackedMinH[ftype] ?? 0,
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(Self.cardPadding)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
// ── Derived cell width from current containerWidth ────────────────────
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// Views/Dashboard/FollowUpRequestsView.swift
|
||||
// ------------------------------------------
|
||||
// Displays inspections a director/admin flagged as needing a follow-up, pulled
|
||||
// read-only from GET /api/v1/inspections?follow_up_required=true by
|
||||
// SyncManager.pullFollowUpRequests().
|
||||
//
|
||||
// Deliberately built as the twin of ScheduledInspectionsView: a follow-up
|
||||
// request is assigned work the inspector must recognise and act on, exactly
|
||||
// like a scheduled assignment, so it gets the same two surfaces and the same
|
||||
// ownership rules.
|
||||
//
|
||||
// Two consumers share one FollowUpRow:
|
||||
// • FollowUpRequestsCard — VStack card for the Dashboard ScrollView.
|
||||
// Presentational only; it reports taps via `onStart` and DashboardStatsView
|
||||
// owns the .fullScreenCover on its always-present ScrollView.
|
||||
// • MyInspectionsView renders its own "Follow-up Requested" List section
|
||||
// inline, reusing FollowUpRow, with the start cover on the enclosing Group.
|
||||
// Both self-hide when there are none, and present StartInspectionView with the
|
||||
// facility + template preselected AND `parentServerId` set, so the submission
|
||||
// lands as a linked re-inspection — the same path CompletedInspectionView's
|
||||
// "Start Re-inspection" banner has always used.
|
||||
// Both cover owners are views that outlive the rows themselves — submitting the
|
||||
// last follow-up empties the @Query while the cover is still up, so a cover
|
||||
// owned by the self-hiding card would be torn down with it.
|
||||
//
|
||||
// The lifecycle stays server-driven: the re-inspection carries
|
||||
// `parent_inspection_id` and the server clears `follow_up_required` on arrival.
|
||||
// ExecuteInspectionView only invalidates the local cache row;
|
||||
// pullFollowUpRequests() re-reads the authoritative state.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Start target snapshot
|
||||
|
||||
/// Plain-value snapshot of the tapped request, used as the `.fullScreenCover`
|
||||
/// item instead of the `LocalFollowUpRequest` itself.
|
||||
///
|
||||
/// The model object is unsafe to hold across the presentation: the cached row is
|
||||
/// invalidated while the cover is still on screen — by `fulfillFollowUpRequest()`
|
||||
/// the instant Submit is tapped, and deleted by `pullFollowUpRequests()` once the
|
||||
/// server stops returning it. Reading a deleted `PersistentModel` traps, and a
|
||||
/// `@Query` that empties out would also tear the cover down mid-submit. Copying
|
||||
/// the values at tap time removes both hazards. (Same reasoning as
|
||||
/// `ScheduledStartTarget`.)
|
||||
struct FollowUpStartTarget: Identifiable {
|
||||
/// Flagged inspection's `serverId` — the identity for `.fullScreenCover(item:)`
|
||||
/// and the `parentServerId` the re-inspection is linked to.
|
||||
let id: Int
|
||||
let templateServerId: Int
|
||||
let facilityServerId: Int
|
||||
/// The director's note explaining what the follow-up should address.
|
||||
let note: String?
|
||||
/// The flagged inspection's answers, JSON-encoded, carried so the
|
||||
/// re-inspection can pre-fill from them. Snapshotted here for the same
|
||||
/// reason as every other field: the row it came from is invalidated while
|
||||
/// the start form is still on screen.
|
||||
let parentFormDataJSON: String
|
||||
|
||||
init(_ request: LocalFollowUpRequest) {
|
||||
self.id = request.serverId
|
||||
self.templateServerId = request.templateServerId
|
||||
self.facilityServerId = request.facilityServerId
|
||||
self.note = request.note
|
||||
self.parentFormDataJSON = request.parentFormDataJSON
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared row
|
||||
|
||||
struct FollowUpRow: View {
|
||||
let request: LocalFollowUpRequest
|
||||
|
||||
private var inspectedText: String {
|
||||
if let d = request.inspectedOn {
|
||||
return d.formatted(date: .abbreviated, time: .omitted)
|
||||
}
|
||||
return request.inspectionDateString.isEmpty ? "—" : request.inspectionDateString
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.arrow.circlepath")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(request.templateName.isEmpty ? "Inspection" : request.templateName)
|
||||
.font(.callout.bold())
|
||||
Text(request.facilityName.isEmpty ? "Facility" : request.facilityName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Follow-up")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.foregroundStyle(.orange)
|
||||
.clipShape(Capsule())
|
||||
Text("Inspected \(inspectedText)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
// The score is usually why the follow-up was raised, so it
|
||||
// is the one number worth showing before the tap.
|
||||
if let score = request.overallScore {
|
||||
Text("· \(String(format: "%.1f%%", score))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
||||
}
|
||||
}
|
||||
|
||||
// Note preview — so the inspector can see there is something to
|
||||
// read before committing to the tap. Truncated to one line; the
|
||||
// full text is shown on the start screen. Mirrors the
|
||||
// instructions preview on ScheduledRow.
|
||||
if let note = request.note {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
Text(note)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Label("Re-inspect", systemImage: "arrow.uturn.right.circle.fill")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 10).padding(.vertical, 5)
|
||||
.background(Color.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dashboard card (VStack)
|
||||
|
||||
struct FollowUpRequestsCard: View {
|
||||
@Query(sort: \LocalFollowUpRequest.inspectionDateString, order: .forward)
|
||||
private var requests: [LocalFollowUpRequest]
|
||||
|
||||
/// Rows still awaiting action. Filtered in Swift rather than in the @Query
|
||||
/// predicate, per CLAUDE.md rule 3. `fulfilledLocally` is set at submit and
|
||||
/// cleared by the next pull, so a completed follow-up leaves the card at
|
||||
/// once and reappears only if the re-inspection never reached the server.
|
||||
private var visible: [LocalFollowUpRequest] {
|
||||
requests.filter { !$0.fulfilledLocally }
|
||||
}
|
||||
|
||||
/// Tap handler. The `.fullScreenCover` deliberately lives in the PARENT
|
||||
/// (`DashboardStatsView`, on its always-present ScrollView) rather than here,
|
||||
/// for the same reason as `ScheduledInspectionsCard`: this card self-hides,
|
||||
/// and submitting the last follow-up removes the final row while the cover is
|
||||
/// still on screen. Keeping the card purely presentational also keeps the
|
||||
/// empty case a true `EmptyView`, so the dashboard stack adds no spacing.
|
||||
let onStart: (FollowUpStartTarget) -> Void
|
||||
|
||||
var body: some View {
|
||||
if !visible.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("FOLLOW-UP REQUESTED")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.orange)
|
||||
.tracking(1)
|
||||
|
||||
ForEach(visible) { r in
|
||||
Button { onStart(FollowUpStartTarget(r)) } label: {
|
||||
FollowUpRow(request: r)
|
||||
.padding(12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,11 +145,15 @@ struct IssuesListView: View {
|
||||
|
||||
// New Issue — borderedProminent so it stands out clearly
|
||||
// from the filter icon and is easy to find at a glance.
|
||||
// `.titleAndIcon` is required: without it SwiftUI collapses the
|
||||
// Label to icon-only in a toolbar, so this rendered as a bare
|
||||
// "+" despite having a title in code.
|
||||
Button {
|
||||
showNewIssue = true
|
||||
} label: {
|
||||
Label("New Issue", systemImage: "plus")
|
||||
}
|
||||
.labelStyle(.titleAndIcon)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,28 @@ struct MyInspectionsView: View {
|
||||
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
|
||||
private var scheduledAll: [LocalScheduledInspection]
|
||||
|
||||
/// Rows still awaiting action — see ScheduledInspectionsCard.visible.
|
||||
private var scheduledVisible: [LocalScheduledInspection] {
|
||||
scheduledAll.filter { !$0.fulfilledLocally }
|
||||
}
|
||||
|
||||
/// Follow-up requests raised on the web — rendered as the top section, above
|
||||
/// Scheduled, and counted in the empty-state decision. Sorted by the flagged
|
||||
/// inspection's date (ISO strings sort chronologically), oldest first: the
|
||||
/// longest-outstanding request is the one to clear next.
|
||||
@Query(sort: \LocalFollowUpRequest.inspectionDateString, order: .forward)
|
||||
private var followUpsAll: [LocalFollowUpRequest]
|
||||
|
||||
/// Rows still awaiting action — see FollowUpRequestsCard.visible.
|
||||
private var followUpsVisible: [LocalFollowUpRequest] {
|
||||
followUpsAll.filter { !$0.fulfilledLocally }
|
||||
}
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
@State private var showNewInspection = false
|
||||
@State private var scheduledStartTarget: LocalScheduledInspection?
|
||||
@State private var scheduledStartTarget: ScheduledStartTarget?
|
||||
@State private var followUpStartTarget: FollowUpStartTarget?
|
||||
|
||||
// Deletion confirmation state
|
||||
@State private var pendingDelete: LocalInspection?
|
||||
@@ -30,7 +48,7 @@ struct MyInspectionsView: View {
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if inspections.isEmpty && scheduledAll.isEmpty {
|
||||
if inspections.isEmpty && scheduledVisible.isEmpty && followUpsVisible.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Inspections",
|
||||
systemImage: "checklist",
|
||||
@@ -38,11 +56,25 @@ struct MyInspectionsView: View {
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
// Follow-up requests — self-hides when empty. First section:
|
||||
// remedial work on a facility that already failed once
|
||||
// outranks a routine scheduled visit.
|
||||
if !followUpsVisible.isEmpty {
|
||||
Section("Follow-up Requested") {
|
||||
ForEach(followUpsVisible) { r in
|
||||
Button { followUpStartTarget = FollowUpStartTarget(r) } label: {
|
||||
FollowUpRow(request: r)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduled assignments (phase36) — self-hides when empty.
|
||||
if !scheduledAll.isEmpty {
|
||||
if !scheduledVisible.isEmpty {
|
||||
Section("Scheduled") {
|
||||
ForEach(scheduledAll) { s in
|
||||
Button { scheduledStartTarget = s } label: {
|
||||
ForEach(scheduledVisible) { s in
|
||||
Button { scheduledStartTarget = ScheduledStartTarget(s) } label: {
|
||||
ScheduledRow(schedule: s)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -71,15 +103,39 @@ struct MyInspectionsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cover attached to the stable List, not a Section.
|
||||
.fullScreenCover(item: $scheduledStartTarget) { s in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: s.templateServerId,
|
||||
preFillFacilityId: s.facilityServerId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cover attached to the enclosing Group, not the List and never a
|
||||
// Section (rule 64). The List itself is conditional: submitting the last
|
||||
// scheduled inspection can flip this view to ContentUnavailableView while
|
||||
// the cover is still presented, which would tear the form down mid-submit.
|
||||
// The Group is always present.
|
||||
.fullScreenCover(item: $scheduledStartTarget) { t in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: t.templateServerId,
|
||||
preFillFacilityId: t.facilityServerId,
|
||||
// phase45 — nil for an ordinary schedule; set when this row is a
|
||||
// planned follow-up, which makes the run a linked re-inspection.
|
||||
// Must precede preFillScheduleId: argument order follows the
|
||||
// property declaration order in StartInspectionView.
|
||||
parentServerId: t.parentServerId,
|
||||
preFillScheduleId: t.id,
|
||||
preFillScheduleInstructions: t.instructions
|
||||
)
|
||||
}
|
||||
// Also on the Group, not the List — see the comment above. Submitting the
|
||||
// last follow-up can flip this view to ContentUnavailableView while the
|
||||
// cover is still presented. `parentServerId` is what links the run back
|
||||
// to the flagged inspection; see the twin cover in DashboardStatsView.
|
||||
.fullScreenCover(item: $followUpStartTarget) { t in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: t.templateServerId,
|
||||
preFillFacilityId: t.facilityServerId,
|
||||
parentServerId: t.id,
|
||||
preFillFollowUpNote: t.note,
|
||||
preFillParentFormDataJSON: t.parentFormDataJSON
|
||||
)
|
||||
}
|
||||
.navigationTitle("My Inspections")
|
||||
// Confirmation before deletion — destructive action cannot be undone
|
||||
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
|
||||
@@ -90,9 +146,14 @@ struct MyInspectionsView: View {
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
// Labelled, not a bare "+". `.titleAndIcon` is required:
|
||||
// SwiftUI collapses a toolbar Label to icon-only on its own,
|
||||
// which is what made this read as an unlabelled plus sign.
|
||||
Button { showNewInspection = true } label: {
|
||||
Image(systemName: "plus")
|
||||
Label("New Inspection", systemImage: "plus")
|
||||
}
|
||||
.labelStyle(.titleAndIcon)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showNewInspection) {
|
||||
|
||||
@@ -5,17 +5,60 @@
|
||||
// SyncManager.pullScheduledInspections().
|
||||
//
|
||||
// Two consumers share one ScheduledRow:
|
||||
// • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView
|
||||
// • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView.
|
||||
// Presentational only; it reports taps via `onStart` and DashboardStatsView
|
||||
// owns the .fullScreenCover on its always-present ScrollView.
|
||||
// • MyInspectionsView renders its own "Scheduled" List section inline,
|
||||
// reusing ScheduledRow, with the start cover attached to the List.
|
||||
// reusing ScheduledRow, with the start cover on the enclosing Group.
|
||||
// Both self-hide when there are no scheduled inspections and present
|
||||
// StartInspectionView (facility + template preselected) when a row is tapped.
|
||||
// The schedule lifecycle (fulfil / roll-forward) stays server-driven; tapping
|
||||
// "Start" simply seeds the normal new-inspection flow.
|
||||
// Both cover owners are views that outlive the schedule rows themselves —
|
||||
// submitting the last scheduled inspection empties the @Query while the cover is
|
||||
// still up, so a cover owned by the self-hiding card would be torn down with it.
|
||||
//
|
||||
// The schedule lifecycle stays server-driven: the submission carries
|
||||
// `scheduled_inspection_id` and the server deactivates (one-time) or rolls
|
||||
// forward (recurring). ExecuteInspectionView only invalidates the local cache
|
||||
// row; pullScheduledInspections() re-reads the authoritative state.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Start target snapshot
|
||||
|
||||
/// Plain-value snapshot of the tapped schedule, used as the `.fullScreenCover`
|
||||
/// item instead of the `LocalScheduledInspection` itself.
|
||||
///
|
||||
/// The model object is unsafe to hold across the presentation: the cached row is
|
||||
/// deleted while the cover is still on screen — by `resolveAndFulfillSchedule()`
|
||||
/// the instant Submit is tapped, and by `pullScheduledInspections()` once the
|
||||
/// server stops returning it. Reading a deleted `PersistentModel` traps, and a
|
||||
/// `@Query` that empties out would also tear the cover down mid-submit. Copying
|
||||
/// the values at tap time removes both hazards.
|
||||
struct ScheduledStartTarget: Identifiable {
|
||||
/// Schedule `serverId` — also the identity for `.fullScreenCover(item:)`.
|
||||
let id: Int
|
||||
let templateServerId: Int
|
||||
let facilityServerId: Int
|
||||
/// Manager-authored instructions for this occurrence. Server field is still
|
||||
/// `notes` (API key `notes`, column `scheduled_inspections.notes`); only the
|
||||
/// user-facing wording is "Instructions".
|
||||
let instructions: String?
|
||||
/// Inspection this schedule is a planned follow-up of (phase45), or nil for
|
||||
/// an ordinary schedule. Passed to `StartInspectionView` as `parentServerId`
|
||||
/// so the run lands as a linked re-inspection — the whole point of
|
||||
/// "Schedule Follow-up".
|
||||
let parentServerId: Int?
|
||||
|
||||
init(_ schedule: LocalScheduledInspection) {
|
||||
self.id = schedule.serverId
|
||||
self.templateServerId = schedule.templateServerId
|
||||
self.facilityServerId = schedule.facilityServerId
|
||||
self.instructions = schedule.instructions
|
||||
self.parentServerId = schedule.parentInspectionServerId
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared row
|
||||
|
||||
struct ScheduledRow: View {
|
||||
@@ -60,6 +103,24 @@ struct ScheduledRow: View {
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
// Instructions preview — so the inspector can see there is
|
||||
// something to read before committing to the tap. Truncated to
|
||||
// one line; the full text is shown on the start screen and
|
||||
// again above the form itself.
|
||||
if let instructions = schedule.instructions {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
Text(instructions)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
@@ -81,18 +142,33 @@ struct ScheduledInspectionsCard: View {
|
||||
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
|
||||
private var scheduled: [LocalScheduledInspection]
|
||||
|
||||
@State private var startTarget: LocalScheduledInspection? = nil
|
||||
/// Rows still awaiting action. Filtered in Swift rather than in the @Query
|
||||
/// predicate, per CLAUDE.md rule 3. `fulfilledLocally` is set at submit and
|
||||
/// cleared by the next pull, so a completed schedule leaves the card at
|
||||
/// once and reappears only when the server says it is due again.
|
||||
private var visible: [LocalScheduledInspection] {
|
||||
scheduled.filter { !$0.fulfilledLocally }
|
||||
}
|
||||
|
||||
/// Tap handler. The `.fullScreenCover` deliberately lives in the PARENT
|
||||
/// (`DashboardStatsView`, on its always-present ScrollView) rather than here:
|
||||
/// this card self-hides, and submitting the last scheduled inspection removes
|
||||
/// the final row while the cover is still on screen. A cover owned by a view
|
||||
/// that disappears is torn down with it, yanking the form away from the
|
||||
/// inspector mid-submit. Keeping the card purely presentational also keeps
|
||||
/// the empty case a true `EmptyView`, so the dashboard stack adds no spacing.
|
||||
let onStart: (ScheduledStartTarget) -> Void
|
||||
|
||||
var body: some View {
|
||||
if !scheduled.isEmpty {
|
||||
if !visible.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("SCHEDULED")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
.tracking(1)
|
||||
|
||||
ForEach(scheduled) { s in
|
||||
Button { startTarget = s } label: {
|
||||
ForEach(visible) { s in
|
||||
Button { onStart(ScheduledStartTarget(s)) } label: {
|
||||
ScheduledRow(schedule: s)
|
||||
.padding(12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
@@ -101,13 +177,6 @@ struct ScheduledInspectionsCard: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
// Cover attached to the stable VStack root (mirrors DraftResumeBanner).
|
||||
.fullScreenCover(item: $startTarget) { s in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: s.templateServerId,
|
||||
preFillFacilityId: s.facilityServerId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,41 @@ struct StartInspectionView: View {
|
||||
var parentServerId: Int? = nil
|
||||
var parentLocalId: String? = nil
|
||||
|
||||
/// The director's note explaining what the follow-up should address, set when
|
||||
/// the inspector taps a row in the FOLLOW-UP REQUESTED card. Passed as a plain
|
||||
/// String rather than read from SwiftData here, for the same reason
|
||||
/// FollowUpStartTarget exists — the cached request row is invalidated at
|
||||
/// submit while this flow is still on screen. Nil for a re-inspection the
|
||||
/// inspector started themselves from CompletedInspectionView.
|
||||
var preFillFollowUpNote: String? = nil
|
||||
|
||||
/// The flagged inspection's answers, JSON-encoded, used to pre-fill this
|
||||
/// re-inspection when the parent `LocalInspection` is not on this device.
|
||||
///
|
||||
/// The local-parent lookup in `startInspection()` covers the
|
||||
/// CompletedInspectionView path, where the inspector is re-inspecting
|
||||
/// something they just finished on this iPad. It does **not** cover a
|
||||
/// follow-up raised on the web: that parent synced long ago and is often
|
||||
/// absent locally, so the lookup found nothing and the form came up blank —
|
||||
/// where the web pre-fills it. Cached at pull time on
|
||||
/// `LocalFollowUpRequest`, so this works offline too. Nil for every other
|
||||
/// start path.
|
||||
var preFillParentFormDataJSON: String? = nil
|
||||
|
||||
// ── Scheduled inspection launch ───────────────────────────────────────
|
||||
/// Server ID of the ScheduledInspection this run fulfils, passed when the
|
||||
/// inspector taps Start on a scheduled row. Carried onto the LocalInspection
|
||||
/// so submitInspection() can send it; without it the server cannot fulfil
|
||||
/// the schedule and the "Scheduled" banner never clears.
|
||||
var preFillScheduleId: Int? = nil
|
||||
|
||||
/// Instructions the manager attached to this schedule ("Instructions" in the
|
||||
/// UI; still `notes` on the wire and in the DB). Passed as a plain String
|
||||
/// rather than read from SwiftData here, for the same reason
|
||||
/// ScheduledStartTarget exists — the cached schedule row is deleted at
|
||||
/// submit while this flow is still on screen.
|
||||
var preFillScheduleInstructions: String? = nil
|
||||
|
||||
// ── Derived lists ─────────────────────────────────────────────────────
|
||||
|
||||
/// Facilities this inspector may actually start work at.
|
||||
@@ -89,6 +124,25 @@ struct StartInspectionView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// ── Instructions from the schedule ─────────────────────────
|
||||
// Shown first: this is the reason the inspector was sent here,
|
||||
// and it may change what they carry in with them. Repeated
|
||||
// above the form itself in ExecuteInspectionView.
|
||||
if let instructions = preFillScheduleInstructions,
|
||||
!instructions.isEmpty {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Instructions", systemImage: "info.circle.fill")
|
||||
.font(.callout.bold())
|
||||
.foregroundStyle(.blue)
|
||||
Text(instructions)
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Re-inspection notice ───────────────────────────────────
|
||||
if parentServerId != nil {
|
||||
Section {
|
||||
@@ -101,6 +155,16 @@ struct StartInspectionView: View {
|
||||
Text("This will be linked to inspection #\(parentServerId!).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
// What the director actually asked for. Shown
|
||||
// here rather than in its own section so it
|
||||
// reads as part of the request, and repeated in
|
||||
// full because the card truncates it to a line.
|
||||
if let note = preFillFollowUpNote, !note.isEmpty {
|
||||
Text(note)
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
@@ -277,23 +341,38 @@ struct StartInspectionView: View {
|
||||
// Link to parent if this is a re-inspection
|
||||
inspection.parentServerId = parentServerId
|
||||
inspection.parentLocalId = parentLocalId
|
||||
// Link to the schedule if launched from a scheduled row
|
||||
inspection.scheduledInspectionServerId = preFillScheduleId
|
||||
|
||||
// ── Pre-fill from parent (mirrors web app behaviour) ───────────────
|
||||
// Copy non-scoring field values from the parent inspection so the
|
||||
// inspector doesn't re-enter static data. Scoring fields (rating,
|
||||
// pass_fail) and media fields (image, signature) are always left blank
|
||||
// so every scoreable item must be re-evaluated fresh.
|
||||
if let parentId = parentServerId {
|
||||
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||
if let parent = allInspections.first(where: { $0.serverId == parentId }),
|
||||
!parent.formData.isEmpty {
|
||||
if parentServerId != nil {
|
||||
// Prefer the local parent (the CompletedInspectionView path, where
|
||||
// the inspector just finished it on this iPad); otherwise fall back
|
||||
// to the snapshot cached on the follow-up request. A follow-up
|
||||
// raised on the web has usually synced and been dropped locally, so
|
||||
// without the fallback this whole block silently no-opped and the
|
||||
// form came up blank — the bug this fixes.
|
||||
let parentData = resolvedParentFormData()
|
||||
|
||||
// Fetch the template schema to identify field types.
|
||||
// Split into two statements — avoids Xcode 26 #Predicate
|
||||
// ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
|
||||
let allTemplates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||||
let tid = templateId
|
||||
let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? []
|
||||
// Fetch the template schema to identify field types.
|
||||
// Split into two statements — avoids Xcode 26 #Predicate
|
||||
// ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
|
||||
let allTemplates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||||
let tid = templateId
|
||||
let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? []
|
||||
|
||||
// The schema is what identifies which fields must NOT be carried
|
||||
// over, so without it there is no safe prefill: an empty exclude set
|
||||
// would copy *everything*, including the parent's `image` paths and
|
||||
// its ratings — attaching the previous inspection's photos as this
|
||||
// one's evidence and pre-answering the scoreable items. Copy nothing
|
||||
// instead. (Unreachable in practice: the template was chosen from
|
||||
// the local picker, so it is cached — this is a guard, not a case.)
|
||||
if !parentData.isEmpty, !schema.isEmpty {
|
||||
|
||||
// Build the set of field IDs that must NOT be carried over
|
||||
let excludeTypes: Set<String> = ["rating", "pass_fail", "image", "signature"]
|
||||
@@ -306,7 +385,6 @@ struct StartInspectionView: View {
|
||||
}
|
||||
|
||||
// Copy all parent values except excluded fields
|
||||
let parentData = parent.formData
|
||||
var prefilled: [String: Any] = [:]
|
||||
for (key, value) in parentData {
|
||||
if !excludeIds.contains(key) {
|
||||
@@ -325,4 +403,34 @@ struct StartInspectionView: View {
|
||||
createdInspection = inspection
|
||||
navigateToExecution = true
|
||||
}
|
||||
|
||||
/// The parent inspection's answers to pre-fill from, or `[:]` when there are
|
||||
/// none to carry.
|
||||
///
|
||||
/// Two sources, in order:
|
||||
/// 1. The local `LocalInspection` with a matching `serverId` — the
|
||||
/// re-inspection-from-history path, where the parent is on this device
|
||||
/// and is the freshest copy.
|
||||
/// 2. `preFillParentFormDataJSON`, snapshotted from the server at pull
|
||||
/// time — the follow-up-request path, where the parent has synced and
|
||||
/// is typically no longer local.
|
||||
///
|
||||
/// Source 1 is checked first but only wins when it actually holds values, so
|
||||
/// a stray empty local shell can't shadow a good server snapshot.
|
||||
private func resolvedParentFormData() -> [String: Any] {
|
||||
if let parentId = parentServerId {
|
||||
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||
if let parent = allInspections.first(where: { $0.serverId == parentId }) {
|
||||
let localData = parent.formData
|
||||
if !localData.isEmpty { return localData }
|
||||
}
|
||||
}
|
||||
|
||||
guard let json = preFillParentFormDataJSON,
|
||||
let data = json.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return [:] }
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,11 +350,32 @@ struct HistoryDetailView: View {
|
||||
let inspection: APIInspectionSummary
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
@EnvironmentObject private var auth: AuthManager
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@State private var showReInspect = false
|
||||
@State private var showMailCompose = false
|
||||
@State private var isGeneratingPDF = false
|
||||
@State private var generatedPDFData: Data? = nil
|
||||
|
||||
// ── Schedule Follow-up (phase45) ──────────────────────────────────────
|
||||
@State private var showScheduleSheet = false
|
||||
/// Defaults to tomorrow: the point of this action is to plan the follow-up
|
||||
/// for another day. Today is still selectable — the server allows it.
|
||||
@State private var followUpDate = Calendar.current.date(
|
||||
byAdding: .day, value: 1, to: Date()
|
||||
) ?? Date()
|
||||
@State private var followUpNotes = ""
|
||||
@State private var isSchedulingFollowUp = false
|
||||
@State private var scheduleError: String? = nil
|
||||
@State private var scheduleConfirmation: String? = nil
|
||||
|
||||
/// Auditors are read-only everywhere else and the API rejects them (403),
|
||||
/// so the two action buttons are hidden rather than shown failing.
|
||||
private var canStartFollowUp: Bool {
|
||||
["admin", "director", "inspector", "project_manager"]
|
||||
.contains(auth.currentUserRole)
|
||||
}
|
||||
|
||||
// Local SwiftData copy — used only for follow-up sync-back.
|
||||
// Form data and schema come from the server response directly so
|
||||
// History works even after app reinstall or on a different device.
|
||||
@@ -463,6 +484,34 @@ struct HistoryDetailView: View {
|
||||
.navigationTitle(inspection.templateName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
// ── Re-inspect now ────────────────────────────────────────────
|
||||
// The immediate half of the follow-up pair. Opens the same linked
|
||||
// re-inspection flow the follow-up banner has always used, but
|
||||
// without waiting to be asked for one.
|
||||
if canStartFollowUp {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showReInspect = true
|
||||
} label: {
|
||||
Label("Re-inspect Now", systemImage: "arrow.uturn.right.circle")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Schedule follow-up ────────────────────────────────────
|
||||
// The deferred half. Needs the network: it creates a schedule
|
||||
// server-side rather than a local record, so unlike starting an
|
||||
// inspection it cannot be queued offline.
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
scheduleError = nil
|
||||
showScheduleSheet = true
|
||||
} label: {
|
||||
Label("Schedule Follow-up", systemImage: "calendar.badge.plus")
|
||||
}
|
||||
.disabled(!sync.isOnline)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
Task { await prepareAndShowMail() }
|
||||
@@ -476,16 +525,34 @@ struct HistoryDetailView: View {
|
||||
.disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showScheduleSheet) { scheduleFollowUpSheet }
|
||||
// Confirmation of a successful schedule. An alert rather than an inline
|
||||
// banner because the sheet has already dismissed by this point.
|
||||
.alert("Follow-up Scheduled",
|
||||
isPresented: Binding(get: { scheduleConfirmation != nil },
|
||||
set: { if !$0 { scheduleConfirmation = nil } })) {
|
||||
Button("OK") { scheduleConfirmation = nil }
|
||||
} message: {
|
||||
Text(scheduleConfirmation ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
loadLocalData()
|
||||
syncFollowUpToLocalCopy()
|
||||
}
|
||||
.sheet(isPresented: $showReInspect) {
|
||||
// Full-screen, not a sheet: every inspection-start flow is full-screen
|
||||
// (rule 66), and this one is now reachable from the toolbar on any
|
||||
// completed inspection rather than only the follow-up banner.
|
||||
.fullScreenCover(isPresented: $showReInspect) {
|
||||
StartInspectionView(
|
||||
preFillTemplateId: inspection.templateId,
|
||||
preFillFacilityId: inspection.facilityId,
|
||||
parentServerId: inspection.id,
|
||||
parentLocalId: inspection.mobileLocalId
|
||||
parentLocalId: inspection.mobileLocalId,
|
||||
// History is served from the API, so this inspection is often
|
||||
// not on this device at all and the local-parent lookup finds
|
||||
// nothing — the form would open blank (rule 79). The answers are
|
||||
// already in this very response, so pass them straight through.
|
||||
preFillParentFormDataJSON: parentFormDataJSON
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showMailCompose) {
|
||||
@@ -501,6 +568,138 @@ struct HistoryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// This inspection's answers, JSON-encoded for `StartInspectionView`'s
|
||||
/// parent prefill. Raw values, not the flattened `formValues`, so an array
|
||||
/// field survives as an array (rule 79).
|
||||
private var parentFormDataJSON: String {
|
||||
let raw = inspection.formDataRaw.mapValues(\.anyValue)
|
||||
guard JSONSerialization.isValidJSONObject(raw),
|
||||
let data = try? JSONSerialization.data(withJSONObject: raw),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
else { return "{}" }
|
||||
return str
|
||||
}
|
||||
|
||||
// ── Schedule Follow-up sheet (phase45) ────────────────────────────────
|
||||
|
||||
/// Date + note picker for planning a follow-up re-inspection.
|
||||
///
|
||||
/// Only the date and an optional note are collected: the server derives
|
||||
/// facility, template and assignee from the parent inspection, so there is
|
||||
/// nothing else for the inspector to get wrong.
|
||||
private var scheduleFollowUpSheet: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Text(inspection.templateName)
|
||||
.font(.callout.bold())
|
||||
Text(inspection.facilityName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} header: {
|
||||
Text("Follow-up of Inspection #\(inspection.id)")
|
||||
}
|
||||
|
||||
Section {
|
||||
DatePicker(
|
||||
"Due Date",
|
||||
selection: $followUpDate,
|
||||
in: Date()..., // the server rejects a past date
|
||||
displayedComponents: .date
|
||||
)
|
||||
.datePickerStyle(.graphical)
|
||||
} header: {
|
||||
Text("When")
|
||||
} footer: {
|
||||
Text("The follow-up appears in Scheduled on this date, "
|
||||
+ "assigned to the inspector who did the original.")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(
|
||||
"What should the follow-up address?",
|
||||
text: $followUpNotes,
|
||||
axis: .vertical
|
||||
)
|
||||
.lineLimit(3...6)
|
||||
} header: {
|
||||
Text("Instructions (optional)")
|
||||
}
|
||||
|
||||
if let err = scheduleError {
|
||||
Section {
|
||||
Label(err, systemImage: "exclamationmark.triangle")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Schedule Follow-up")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showScheduleSheet = false }
|
||||
.disabled(isSchedulingFollowUp)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button {
|
||||
Task { await submitScheduledFollowUp() }
|
||||
} label: {
|
||||
if isSchedulingFollowUp {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Schedule")
|
||||
}
|
||||
}
|
||||
.disabled(isSchedulingFollowUp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the follow-up schedule on the server, then refresh so it appears
|
||||
/// in the Scheduled lists without waiting for the next timed sync.
|
||||
///
|
||||
/// Online-only by nature: this writes a server-side plan, not a local
|
||||
/// record, so there is nothing meaningful to queue offline — the button is
|
||||
/// disabled when offline and this reports any failure inline rather than
|
||||
/// dismissing as if it had worked.
|
||||
private func submitScheduledFollowUp() async {
|
||||
isSchedulingFollowUp = true
|
||||
scheduleError = nil
|
||||
|
||||
let due = Self.dueDateFormatter.string(from: followUpDate)
|
||||
do {
|
||||
_ = try await APIClient.shared.createScheduledFollowUp(
|
||||
parentInspectionId: inspection.id,
|
||||
dueDate: due,
|
||||
notes: followUpNotes
|
||||
)
|
||||
// Pull the new schedule straight into the Scheduled section.
|
||||
await sync.pullScheduledInspections(context: context)
|
||||
|
||||
isSchedulingFollowUp = false
|
||||
showScheduleSheet = false
|
||||
followUpNotes = ""
|
||||
scheduleConfirmation =
|
||||
"A follow-up re-inspection of \(inspection.facilityName) is scheduled for "
|
||||
+ followUpDate.formatted(date: .abbreviated, time: .omitted) + "."
|
||||
} catch {
|
||||
isSchedulingFollowUp = false
|
||||
scheduleError = (error as? APIError)?.localizedDescription
|
||||
?? "Could not schedule the follow-up. Check your connection and try again."
|
||||
}
|
||||
}
|
||||
|
||||
/// `yyyy-MM-dd` for the API's `due_date`. Fixed POSIX locale so a non-
|
||||
/// Gregorian device calendar cannot emit a date the server can't parse.
|
||||
private static let dueDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Generates the PDF (fetching any server photos over the network),
|
||||
/// then presents the mail compose sheet with it attached.
|
||||
/// Photo fetches happen here, off the synchronous PDF drawing pass.
|
||||
@@ -668,6 +867,8 @@ struct ReadOnlyGridFormView: View {
|
||||
let schema: [[String: Any]]
|
||||
let formValues: [String: String]
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
|
||||
// ── Field visibility filtering ────────────────────────────────────────
|
||||
|
||||
// A row group: all visible fields that share the same original `row`.
|
||||
@@ -767,9 +968,13 @@ struct ReadOnlyGridFormView: View {
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
VStack(alignment: .leading, spacing: hSizeClass == .compact ? 10 : 3) {
|
||||
ForEach(visibleRowGroups.indices, id: \.self) { idx in
|
||||
rowView(visibleRowGroups[idx])
|
||||
if hSizeClass == .compact {
|
||||
stackedRowView(visibleRowGroups[idx])
|
||||
} else {
|
||||
rowView(visibleRowGroups[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
@@ -782,6 +987,11 @@ struct ReadOnlyGridFormView: View {
|
||||
// Render one row as a GeometryReader-based HStack so each field
|
||||
// occupies exactly (colSpan/12) of the available width, and leading
|
||||
// space before col > 1 is filled with a transparent spacer.
|
||||
//
|
||||
// On a narrow screen the same 12-column division that works on iPad
|
||||
// leaves each field a few dozen points wide inside a fixed 36 pt row, so
|
||||
// labels and values collide. Below `minGridWidth` each field gets its own
|
||||
// full-width line at its natural height instead.
|
||||
@ViewBuilder
|
||||
private func rowView(_ group: RowGroup) -> some View {
|
||||
GeometryReader { geo in
|
||||
@@ -808,6 +1018,24 @@ struct ReadOnlyGridFormView: View {
|
||||
.frame(height: rowHeight(group))
|
||||
}
|
||||
|
||||
/// Stacked equivalent of `rowView` for narrow screens: no proportional
|
||||
/// widths, no fixed row height, so nothing can overlap.
|
||||
@ViewBuilder
|
||||
private func stackedRowView(_ group: RowGroup) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(group.fields.indices, id: \.self) { i in
|
||||
let f = group.fields[i]
|
||||
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
|
||||
let value = formValues[fid] ?? ""
|
||||
let ftype = f["type"] as? String ?? "text"
|
||||
let label = f["label"] as? String ?? ""
|
||||
|
||||
ReadOnlyCellView(field: f, value: value, fieldType: ftype, label: label)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Row height: fixed 36pt for most fields; taller for section headers.
|
||||
private func rowHeight(_ group: RowGroup) -> CGFloat {
|
||||
let hasSection = group.fields.contains { ($0["type"] as? String) == "section" }
|
||||
|
||||
Reference in New Issue
Block a user