06/22 Fix Medium impact items
This commit is contained in:
@@ -115,13 +115,22 @@ actor APIClient {
|
|||||||
func fetchInspectionHistory(
|
func fetchInspectionHistory(
|
||||||
limit: Int = 50,
|
limit: Int = 50,
|
||||||
offset: Int = 0,
|
offset: Int = 0,
|
||||||
facilityId: Int? = nil
|
facilityId: Int? = nil,
|
||||||
|
fromDate: Date? = nil,
|
||||||
|
toDate: Date? = nil
|
||||||
) async throws -> InspectionHistoryResponseData {
|
) async throws -> InspectionHistoryResponseData {
|
||||||
var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed"
|
var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed"
|
||||||
if let fid = facilityId { ep += "&facility_id=\(fid)" }
|
if let fid = facilityId { ep += "&facility_id=\(fid)" }
|
||||||
|
if let d = fromDate { ep += "&from_date=\(Self.apiDateFmt.string(from: d))" }
|
||||||
|
if let d = toDate { ep += "&to_date=\(Self.apiDateFmt.string(from: d))" }
|
||||||
return try await request(ep)
|
return try await request(ep)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let apiDateFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
// ── Photo Upload ──────────────────────────────────────────────────────
|
// ── Photo Upload ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func uploadPhoto(localPath: String, entityType: String, retrying: Bool = false) async throws -> String {
|
func uploadPhoto(localPath: String, entityType: String, retrying: Bool = false) async throws -> String {
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ class SyncManager: ObservableObject {
|
|||||||
// Fetch dashboard KPIs — best-effort, non-fatal on failure.
|
// Fetch dashboard KPIs — best-effort, non-fatal on failure.
|
||||||
await fetchDashboardStats()
|
await fetchDashboardStats()
|
||||||
|
|
||||||
|
// Periodically remove local photo files that are no longer needed.
|
||||||
|
// Runs at most once per hour to avoid repeated FileManager calls on
|
||||||
|
// every 60-second sync cycle.
|
||||||
|
cleanupOrphanedPhotos(context: context)
|
||||||
|
|
||||||
updatePendingCount(context: context)
|
updatePendingCount(context: context)
|
||||||
lastSyncAt = Date()
|
lastSyncAt = Date()
|
||||||
}
|
}
|
||||||
@@ -512,6 +517,71 @@ class SyncManager: ObservableObject {
|
|||||||
|
|
||||||
// ── Pending Count ─────────────────────────────────────────────────────
|
// ── Pending Count ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Orphaned Photo Cleanup ────────────────────────────────────────────
|
||||||
|
// Removes local JPEG files from Documents/JQCPhotos/ that are no longer
|
||||||
|
// referenced by any LocalInspection, LocalIssue, or PendingPhoto record.
|
||||||
|
// Once an inspection or issue is fully synced its local photos are no
|
||||||
|
// longer needed — the server holds the canonical copies. Without this,
|
||||||
|
// weeks of inspections accumulate hundreds of MBs of orphaned files.
|
||||||
|
//
|
||||||
|
// Throttled to once per hour via UserDefaults to avoid redundant
|
||||||
|
// FileManager enumeration on every 60-second sync cycle.
|
||||||
|
|
||||||
|
private static let lastCleanupKey = "jqc.photoCleanup.lastRunAt"
|
||||||
|
private static let cleanupInterval: TimeInterval = 3600 // 1 hour
|
||||||
|
|
||||||
|
private func cleanupOrphanedPhotos(context: ModelContext) {
|
||||||
|
let last = UserDefaults.standard.object(forKey: Self.lastCleanupKey) as? Date
|
||||||
|
guard last == nil || Date().timeIntervalSince(last!) >= Self.cleanupInterval else { return }
|
||||||
|
UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey)
|
||||||
|
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
||||||
|
let photosDir = docsDir.appendingPathComponent("JQCPhotos")
|
||||||
|
guard let diskFiles = try? fm.contentsOfDirectory(
|
||||||
|
at: photosDir, includingPropertiesForKeys: nil
|
||||||
|
) else { return }
|
||||||
|
|
||||||
|
// Collect all local paths that are still in use.
|
||||||
|
var referencedPaths = Set<String>()
|
||||||
|
|
||||||
|
// PendingPhoto — not yet uploaded
|
||||||
|
if let pendingPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) {
|
||||||
|
for p in pendingPhotos where p.uploadStatus != "uploaded" {
|
||||||
|
referencedPaths.insert(p.localFilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LocalInspection — draft photos (formData values starting with "local://")
|
||||||
|
if let inspections = try? context.fetch(FetchDescriptor<LocalInspection>()) {
|
||||||
|
for insp in inspections where insp.status == "draft" {
|
||||||
|
for val in insp.formData.values {
|
||||||
|
if let s = val as? String, s.hasPrefix("local://") {
|
||||||
|
referencedPaths.insert(String(s.dropFirst("local://".count)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LocalIssue — unsync'd issue photos
|
||||||
|
if let issues = try? context.fetch(FetchDescriptor<LocalIssue>()) {
|
||||||
|
for issue in issues where issue.syncStatus != "synced" {
|
||||||
|
for path in issue.photoLocalPaths { referencedPaths.insert(path) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete any disk file not in referencedPaths
|
||||||
|
var deletedCount = 0
|
||||||
|
for fileURL in diskFiles {
|
||||||
|
let path = fileURL.path
|
||||||
|
if !referencedPaths.contains(path) {
|
||||||
|
try? fm.removeItem(at: fileURL)
|
||||||
|
deletedCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deletedCount > 0 {
|
||||||
|
print("[JQC] Sync | cleanupOrphanedPhotos | removed \(deletedCount) file(s)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func updatePendingCount(context: ModelContext) {
|
func updatePendingCount(context: ModelContext) {
|
||||||
let inspCount = (try? context.fetch(FetchDescriptor<LocalInspection>()))?
|
let inspCount = (try? context.fetch(FetchDescriptor<LocalInspection>()))?
|
||||||
.filter { $0.syncStatus == "pending" }.count ?? 0
|
.filter { $0.syncStatus == "pending" }.count ?? 0
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ private let kImgJPEGQuality: CGFloat = 0.55
|
|||||||
/// kImgJPEGQuality, returning a fresh UIImage built from the compressed
|
/// kImgJPEGQuality, returning a fresh UIImage built from the compressed
|
||||||
/// bytes. Applied to every photo before it's embedded in the PDF — this is
|
/// bytes. Applied to every photo before it's embedded in the PDF — this is
|
||||||
/// the main lever for keeping file size minimal.
|
/// the main lever for keeping file size minimal.
|
||||||
private func compress(_ image: UIImage) -> UIImage? {
|
/// nonisolated: called from inside a TaskGroup (concurrent), not @MainActor.
|
||||||
|
private nonisolated func compress(_ image: UIImage) -> UIImage? {
|
||||||
let size = image.size
|
let size = image.size
|
||||||
guard size.width > 0, size.height > 0 else { return nil }
|
guard size.width > 0, size.height > 0 else { return nil }
|
||||||
let scale = min(kImgMaxPx / size.width, kImgMaxPx / size.height, 1.0)
|
let scale = min(kImgMaxPx / size.width, kImgMaxPx / size.height, 1.0)
|
||||||
|
|||||||
@@ -256,10 +256,25 @@ struct DashboardStatsView: View {
|
|||||||
|
|
||||||
@EnvironmentObject private var sync: SyncManager
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
|
||||||
|
// Draft inspections — shown as a resume banner at the top of the dashboard
|
||||||
|
// so the inspector never has to hunt through My Inspections to find an
|
||||||
|
// in-progress form they left open.
|
||||||
|
@Query(
|
||||||
|
filter: #Predicate<LocalInspection> { $0.status == "draft" },
|
||||||
|
sort: \LocalInspection.lastModifiedAt,
|
||||||
|
order: .reverse
|
||||||
|
) private var draftInspections: [LocalInspection]
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
|
||||||
|
// ── Draft Resume Banner ────────────────────────────────────
|
||||||
|
if !draftInspections.isEmpty {
|
||||||
|
DraftResumeBanner(drafts: draftInspections, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
if let stats = sync.dashboardStats {
|
if let stats = sync.dashboardStats {
|
||||||
// ── Today ──────────────────────────────────────────────
|
// ── Today ──────────────────────────────────────────────
|
||||||
statsSection(title: "Today") {
|
statsSection(title: "Today") {
|
||||||
@@ -446,6 +461,83 @@ struct DashboardStatsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Draft Resume Banner
|
||||||
|
// Shown on the dashboard when the inspector has one or more in-progress
|
||||||
|
// (draft) inspections. Tapping a draft opens ExecuteInspectionView as a
|
||||||
|
// full-screen sheet — avoids cross-NavigationStack linking since the
|
||||||
|
// dashboard and My Inspections stacks are independent.
|
||||||
|
|
||||||
|
struct DraftResumeBanner: View {
|
||||||
|
let drafts: [LocalInspection]
|
||||||
|
let context: ModelContext
|
||||||
|
@State private var selectedDraft: LocalInspection? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label(drafts.count == 1
|
||||||
|
? "Inspection in progress"
|
||||||
|
: "\(drafts.count) inspections in progress",
|
||||||
|
systemImage: "pencil.and.list.clipboard")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
ForEach(drafts) { draft in
|
||||||
|
Button {
|
||||||
|
selectedDraft = draft
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(templateName(for: draft))
|
||||||
|
.font(.callout.bold())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(facilityName(for: draft))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
Text("Last saved \(draft.lastModifiedAt.formatted(.relative(presentation: .named)))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.white.opacity(0.70))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Label("Resume", systemImage: "play.fill")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
||||||
|
.background(Color.white.opacity(0.25))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Color.white.opacity(0.12))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color.blue.gradient)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.fullScreenCover(item: $selectedDraft) { draft in
|
||||||
|
// Wrap in NavigationStack so ExecuteInspectionView's toolbar
|
||||||
|
// and dismiss work correctly when presented as a sheet.
|
||||||
|
NavigationStack {
|
||||||
|
ExecuteInspectionView(inspection: draft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func templateName(for inspection: LocalInspection) -> String {
|
||||||
|
let id = inspection.templateServerId
|
||||||
|
return (try? context.fetch(
|
||||||
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first?.name) ?? "Inspection"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func facilityName(for inspection: LocalInspection) -> String {
|
||||||
|
let id = inspection.facilityServerId
|
||||||
|
let all = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
|
||||||
|
return all.first(where: { $0.serverId == id })?.name ?? "Unknown Facility"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - My Inspections
|
// MARK: - My Inspections
|
||||||
|
|
||||||
struct MyInspectionsView: View {
|
struct MyInspectionsView: View {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ struct ExecuteInspectionView: View {
|
|||||||
@State private var showValidationAlert = false
|
@State private var showValidationAlert = false
|
||||||
@State private var missingFields: [String] = []
|
@State private var missingFields: [String] = []
|
||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
|
@State private var autoSavedAt: Date? = nil // drives the auto-save toast
|
||||||
@State private var isSubmitting = false
|
@State private var isSubmitting = false
|
||||||
@State private var submitResult: SubmitResult?
|
@State private var submitResult: SubmitResult?
|
||||||
|
|
||||||
@@ -100,6 +101,10 @@ struct ExecuteInspectionView: View {
|
|||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try? await Task.sleep(for: .seconds(autoSaveInterval))
|
try? await Task.sleep(for: .seconds(autoSaveInterval))
|
||||||
saveDraft()
|
saveDraft()
|
||||||
|
// Show "Draft saved" toast and dismiss after 2 seconds
|
||||||
|
withAnimation { autoSavedAt = Date() }
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
withAnimation { autoSavedAt = nil }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showFlagIssue) {
|
.sheet(isPresented: $showFlagIssue) {
|
||||||
@@ -149,7 +154,25 @@ struct ExecuteInspectionView: View {
|
|||||||
.zIndex(10)
|
.zIndex(10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Auto-save toast — bottom of screen, fades out after 2 seconds
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if autoSavedAt != nil {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Draft saved")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
||||||
|
.background(.regularMaterial, in: Capsule())
|
||||||
|
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
.zIndex(9)
|
||||||
|
}
|
||||||
|
}
|
||||||
.animation(.spring(duration: 0.35), value: submitResult != nil)
|
.animation(.spring(duration: 0.35), value: submitResult != nil)
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: autoSavedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Form Content ──────────────────────────────────────────────────────
|
// ── Form Content ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ struct InspectionHistoryView: View {
|
|||||||
private let limit = 30
|
private let limit = 30
|
||||||
|
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
|
@State private var showFilterSheet = false
|
||||||
|
@State private var filterFromDate: Date? = nil
|
||||||
|
@State private var filterToDate: Date? = nil
|
||||||
|
// Draft values edited in the sheet before applying
|
||||||
|
@State private var draftFromDate: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
|
||||||
|
@State private var draftToDate: Date = Date()
|
||||||
|
@State private var draftFromEnabled = false
|
||||||
|
@State private var draftToEnabled = false
|
||||||
|
|
||||||
|
private var hasActiveFilter: Bool { filterFromDate != nil || filterToDate != nil }
|
||||||
|
|
||||||
/// Date formatter for search matching — formats to e.g. "Jun 19, 2026 4:55 PM"
|
/// Date formatter for search matching — formats to e.g. "Jun 19, 2026 4:55 PM"
|
||||||
/// so partial strings like "jun", "2026", "19" all match.
|
/// so partial strings like "jun", "2026", "19" all match.
|
||||||
@@ -121,6 +131,39 @@ struct InspectionHistoryView: View {
|
|||||||
.navigationTitle("Inspection History")
|
.navigationTitle("Inspection History")
|
||||||
.searchable(text: $searchText,
|
.searchable(text: $searchText,
|
||||||
prompt: "Search ID, date, facility, area…")
|
prompt: "Search ID, date, facility, area…")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showFilterSheet = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: hasActiveFilter
|
||||||
|
? "line.3.horizontal.decrease.circle.fill"
|
||||||
|
: "line.3.horizontal.decrease.circle")
|
||||||
|
.foregroundStyle(hasActiveFilter ? .orange : .primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showFilterSheet) {
|
||||||
|
DateFilterSheet(
|
||||||
|
fromEnabled: $draftFromEnabled,
|
||||||
|
fromDate: $draftFromDate,
|
||||||
|
toEnabled: $draftToEnabled,
|
||||||
|
toDate: $draftToDate
|
||||||
|
) {
|
||||||
|
// Apply — update active filters and reload from server
|
||||||
|
filterFromDate = draftFromEnabled ? draftFromDate : nil
|
||||||
|
filterToDate = draftToEnabled ? draftToDate : nil
|
||||||
|
showFilterSheet = false
|
||||||
|
Task { await load(reset: true) }
|
||||||
|
} onClear: {
|
||||||
|
draftFromEnabled = false
|
||||||
|
draftToEnabled = false
|
||||||
|
filterFromDate = nil
|
||||||
|
filterToDate = nil
|
||||||
|
showFilterSheet = false
|
||||||
|
Task { await load(reset: true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
if sync.isOnline {
|
if sync.isOnline {
|
||||||
await load(reset: true)
|
await load(reset: true)
|
||||||
@@ -141,7 +184,10 @@ struct InspectionHistoryView: View {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let result = try await APIClient.shared.fetchInspectionHistory(
|
let result = try await APIClient.shared.fetchInspectionHistory(
|
||||||
limit: limit, offset: reset ? 0 : offset
|
limit: limit,
|
||||||
|
offset: reset ? 0 : offset,
|
||||||
|
fromDate: filterFromDate,
|
||||||
|
toDate: filterToDate
|
||||||
)
|
)
|
||||||
if reset {
|
if reset {
|
||||||
inspections = result.inspections
|
inspections = result.inspections
|
||||||
@@ -160,6 +206,55 @@ struct InspectionHistoryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Date Filter Sheet
|
||||||
|
|
||||||
|
struct DateFilterSheet: View {
|
||||||
|
@Binding var fromEnabled: Bool
|
||||||
|
@Binding var fromDate: Date
|
||||||
|
@Binding var toEnabled: Bool
|
||||||
|
@Binding var toDate: Date
|
||||||
|
let onApply: () -> Void
|
||||||
|
let onClear: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("From Date") {
|
||||||
|
Toggle("Filter by start date", isOn: $fromEnabled)
|
||||||
|
if fromEnabled {
|
||||||
|
DatePicker("From", selection: $fromDate,
|
||||||
|
displayedComponents: .date)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("To Date") {
|
||||||
|
Toggle("Filter by end date", isOn: $toEnabled)
|
||||||
|
if toEnabled {
|
||||||
|
DatePicker("To", selection: $toDate,
|
||||||
|
displayedComponents: .date)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button("Clear Filters", role: .destructive, action: onClear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Date Filter")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { onClear() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Apply", action: onApply)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - History Row
|
// MARK: - History Row
|
||||||
|
|
||||||
struct HistoryRowView: View {
|
struct HistoryRowView: View {
|
||||||
|
|||||||
Reference in New Issue
Block a user