diff --git a/package-lock.json b/package-lock.json index d623ca0..929f43d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1093,9 +1093,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1113,9 +1110,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1133,9 +1127,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1153,9 +1144,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1173,9 +1161,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1193,9 +1178,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3158,9 +3140,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3182,9 +3161,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3206,9 +3182,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3230,9 +3203,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/pages/audit-session/AuditSession.tsx b/src/pages/audit-session/AuditSession.tsx index a250423..6c7db29 100644 --- a/src/pages/audit-session/AuditSession.tsx +++ b/src/pages/audit-session/AuditSession.tsx @@ -36,6 +36,47 @@ const ANOMALY_CATEGORIES = [ { id: 10, value: "occlusion", label: "Occlusion", color: "#6366f1", key: "0" } ]; +// The backend soft-deletes (deleted = 1) for these exact Audit_values - +// mirror of the list in anomalyModel.update. Note the UI's "low light +// condition" does NOT match the backend's "low light", so from this +// screen only "duplicate" soft-deletes. +const SOFT_DELETE_AUDIT_VALUES = ['duplicate', 'comparison error', 'low light', 'far asset', 'no detection']; + +type PlantSub = 'in grown' | 'out grown'; +type NotAnAnomalySub = 'comparison error' | 'low light' | 'far asset' | 'no detection' | 'image mismatch'; + +// A verdict staged in Bulk Mode - held in memory until Bulk Submit. +interface StagedVerdict { + isAnomaly: boolean; + category: number; + notes: string; + plantSub: PlantSub | null; + notAnAnomalySub: NotAnAnomalySub | null; +} + +// Prefer the backend's own message so error toasts say WHY it failed. +const backendMsg = (err: any) => + err?.response?.data?.message || err?.response?.data?.error || err?.message || 'Unknown error'; + +// Same Audit_value resolution the single-record save uses: category value, +// overridden by the nested sub-category for category 1 (Plant / Not an Anomaly). +const computeAuditValue = ( + isAnomalyBool: boolean, + catVal: number | null, + plantSub?: string | null, + notAnAnomalySub?: string | null +): string => { + const categories = isAnomalyBool ? ANOMALY_CATEGORIES : SAFE_CATEGORIES; + const matchedCategory = categories.find(c => c.id === catVal); + let auditValueVal = matchedCategory ? matchedCategory.value : ''; + if (isAnomalyBool && catVal === 1 && plantSub) { + auditValueVal = plantSub; + } else if (!isAnomalyBool && catVal === 1 && notAnAnomalySub) { + auditValueVal = notAnAnomalySub; + } + return auditValueVal; +}; + export const AuditSession: React.FC = () => { const navigate = useNavigate(); const { user } = useAuthStore(); @@ -52,14 +93,35 @@ export const AuditSession: React.FC = () => { const [notAnAnomalySubCategory, setNotAnAnomalySubCategory] = useState<'comparison error' | 'low light' | 'far asset' | 'no detection' | 'image mismatch' | null>(null); // Premium Audit UX states - const [itemAuditStates, setItemAuditStates] = useState<('pending' | 'audited' | 'skipped')[]>([]); + const [itemAuditStates, setItemAuditStates] = useState<('pending' | 'audited' | 'skipped' | 'staged')[]>([]); const [isQuickAudit, setIsQuickAudit] = useState(() => { const cached = localStorage.getItem('isQuickAudit'); return cached === null ? true : cached === 'true'; }); + + // Bulk Mode: verdicts are staged locally (keyed by record id) instead of + // hitting the APIs one by one, then submitted together via Bulk Submit. + // Staged work lives only in memory - a refresh/crash loses it (accepted + // trade-off; a beforeunload warning guards the refresh case). + const [isBulkMode, setIsBulkMode] = useState(() => localStorage.getItem('isBulkAudit') === 'true'); + const [stagedAudits, setStagedAudits] = useState>({}); + const [isBulkConfirmOpen, setIsBulkConfirmOpen] = useState(false); + const [isBulkSubmitting, setIsBulkSubmitting] = useState(false); + const [bulkResult, setBulkResult] = useState<{ succeeded: number; failures: { id: number; reason: string }[] } | null>(null); + const stagedCount = Object.keys(stagedAudits).length; + + // "Audit All": pick one verdict in a dialog and stage it onto every + // non-locked record in the session at once. aa* = the dialog's own form + // state, independent of the per-record form. + const [isAuditAllOpen, setIsAuditAllOpen] = useState(false); + const [aaIsAnomaly, setAaIsAnomaly] = useState(true); + const [aaCategory, setAaCategory] = useState(null); + const [aaPlantSub, setAaPlantSub] = useState(null); + const [aaNotAnAnomalySub, setAaNotAnAnomalySub] = useState(null); + const [aaNotes, setAaNotes] = useState(''); const [activeKeyFlash, setActiveKeyFlash] = useState(null); const [isNotesFocused, setIsNotesFocused] = useState(false); - const [showCompletionModal, setShowCompletionModal] = useState(false); + const [showBatchEndPanel, setShowBatchEndPanel] = useState(false); const [isShortcutsDialogOpen, setIsShortcutsDialogOpen] = useState(false); const [isReportDialogOpen, setIsReportDialogOpen] = useState(false); const [reportDev, setReportDev] = useState(''); @@ -193,10 +255,11 @@ export const AuditSession: React.FC = () => { setIsAnomaly(null); setPlantSubCategory(null); setNotAnAnomalySubCategory(null); + setStagedAudits({}); setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending')); } else { useToastStore.getState().showToast('No more anomalies left in the feed.', 'info'); - setShowCompletionModal(false); + setShowBatchEndPanel(false); navigate('/activity-feeds'); } } catch (err) { @@ -208,7 +271,7 @@ export const AuditSession: React.FC = () => { const handleLoadNextFeedPage = useCallback(async () => { try { - setShowCompletionModal(false); + setShowBatchEndPanel(false); setLoading(true); const nextPage = (activeTab.page || 0) + 1; const selectedSiteId = selectedSite?.site_id ?? ''; @@ -249,6 +312,7 @@ export const AuditSession: React.FC = () => { setNotAnAnomalySubCategory(null); setMasterZoom(1); setAnomalyZoom(1); + setStagedAudits({}); setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending')); } else { useToastStore.getState().showToast('No more anomalies left in the feed.', 'info'); @@ -384,42 +448,88 @@ export const AuditSession: React.FC = () => { return items; }, [currentAnomaly]); - const handleNext = () => { - if (currentIndex < anomalies.length - 1) { - setCurrentIndex(prev => prev + 1); + // Single navigation entry point (arrows, Enter-advance, progress-tile + // clicks). If the target record has a staged verdict, pre-fill the form + // with it so it can be reviewed/overwritten before Bulk Submit. + const goToIndex = (idx: number) => { + if (idx < 0 || idx >= anomalies.length) return; + const staged = anomalies[idx] ? stagedAudits[anomalies[idx].id] : undefined; + setCurrentIndex(idx); + if (staged) { + setIsAnomaly(staged.isAnomaly); + setSelectedCategory(staged.category); + setNotes(staged.notes); + setPlantSubCategory(staged.plantSub); + setNotAnAnomalySubCategory(staged.notAnAnomalySub); + } else { setSelectedCategory(null); setNotes(''); setIsAnomaly(null); setPlantSubCategory(null); setNotAnAnomalySubCategory(null); - setMasterZoom(1); - setAnomalyZoom(1); + } + setMasterZoom(1); + setAnomalyZoom(1); + }; + + const handleNext = () => { + if (currentIndex < anomalies.length - 1) { + goToIndex(currentIndex + 1); } else { - setShowCompletionModal(true); + setShowBatchEndPanel(true); } }; const handlePrev = () => { - if (currentIndex > 0) { - setCurrentIndex(prev => prev - 1); - setSelectedCategory(null); - setNotes(''); - setIsAnomaly(null); - setPlantSubCategory(null); - setNotAnAnomalySubCategory(null); - setMasterZoom(1); - setAnomalyZoom(1); - } + goToIndex(currentIndex - 1); }; const handleSkip = () => { setItemAuditStates(prev => { + // Skipping past a record must not demote a verdict already staged on it. + if (prev[currentIndex] === 'staged') return prev; const next = [...prev]; next[currentIndex] = 'skipped'; return next; }); handleNext(); - }; const saveAuditState = useCallback(async ( + }; + + // Sync the in-memory copies with what the backend just wrote, for one or + // many records at once. Without this the lock banner shows the record's OLD + // Audited_on - the ingest pipeline pre-stamps that column on unaudited + // rows, so it can be months in the past - instead of the NOW() the save + // just set. Also purges the records from the cached PENDING feed tabs - the + // feeds table renders from this cache, and a stale entry let users re-open + // an already-audited record from the table. Only tabs of the same mode: + // audits and rectification ids come from different tables and can collide. + // Also drops the cached dashboard stats so they refetch. + const applyAuditedLocally = (records: { id: number; isAnomaly: boolean; auditValue: string; comments: string }[]) => { + if (records.length === 0) return; + const nowIso = new Date().toISOString(); + const byId = new Map(records.map(r => [r.id, r])); + setAnomalies((prev: any[]) => prev.map((a) => { + const r = byId.get(a.id); + return r + ? { ...a, IsAudited: 1, Audit_status: r.isAnomaly ? 1 : 0, Audit_value: r.auditValue, Comments: r.comments, Audited_on: nowIso } + : a; + })); + + const feed = useFeedStore.getState(); + feed.setTabs(feed.tabs.map((t: any) => + (t.date === '' || t.date === 'reviewTab') && !!t.isRectificationTab === isRectActive + ? { + ...t, + anomalies: (t.anomalies || []).filter((a: any) => !byId.has(a.id)), + anomaliesCopy: (t.anomaliesCopy || []).filter((a: any) => !byId.has(a.id)), + totalAnomalyCount: Math.max(0, (t.totalAnomalyCount || 0) - records.length), + } + : t + )); + feed.setDashboardCache({ ...feed.dashboardCache, stats: null }); + }; + + const saveAuditState = useCallback(async ( anomalyVal: boolean | null, catVal: number | null, notesVal: string, @@ -429,20 +539,9 @@ export const AuditSession: React.FC = () => { if (!currentAnomaly || !user) return false; const recordId = currentAnomaly.id; const toast = useToastStore.getState().showToast; - // Prefer the backend's own message so the toast says WHY it failed. - const backendMsg = (err: any) => - err?.response?.data?.message || err?.response?.data?.error || err?.message || 'Unknown error'; const isAnomalyBool = anomalyVal ?? true; - const categories = isAnomalyBool ? ANOMALY_CATEGORIES : SAFE_CATEGORIES; - const matchedCategory = categories.find(c => c.id === catVal); - - let auditValueVal = matchedCategory ? matchedCategory.value : ''; - if (isAnomalyBool && catVal === 1 && plantSub) { - auditValueVal = plantSub; - } else if (!isAnomalyBool && catVal === 1 && notAnAnomalySub) { - auditValueVal = notAnAnomalySub; - } + const auditValueVal = computeAuditValue(isAnomalyBool, catVal, plantSub, notAnAnomalySub); // Body matches exactly what the API expects (dbName + isRectificationEnabled go in URL params via axiosClient) const auditPayload = { @@ -480,40 +579,9 @@ export const AuditSession: React.FC = () => { } } - // Sync the in-memory copy with what the backend just wrote. Without this - // the lock banner shows the record's OLD Audited_on - the ingest pipeline - // pre-stamps that column on unaudited rows, so it can be months in the - // past - instead of the NOW() the save just set. - const markLocalAudited = () => { - const nowIso = new Date().toISOString(); - setAnomalies((prev: any[]) => prev.map((a) => a.id === recordId - ? { ...a, IsAudited: 1, Audit_status: isAnomalyBool ? 1 : 0, Audit_value: auditValueVal, Comments: notesVal || '', Audited_on: nowIso } - : a)); + const markLocalAudited = () => + applyAuditedLocally([{ id: recordId, isAnomaly: isAnomalyBool, auditValue: auditValueVal, comments: notesVal || '' }]); - // Purge the record from the cached PENDING feed tabs too - the feeds - // table renders from this cache, and a stale entry let users re-open an - // already-audited record from the table. Only tabs of the same mode: - // audits and rectification ids come from different tables and can - // collide. Also drop the cached dashboard stats so they refetch. - const feed = useFeedStore.getState(); - feed.setTabs(feed.tabs.map((t: any) => - (t.date === '' || t.date === 'reviewTab') && !!t.isRectificationTab === isRectActive - ? { - ...t, - anomalies: (t.anomalies || []).filter((a: any) => a.id !== recordId), - anomaliesCopy: (t.anomaliesCopy || []).filter((a: any) => a.id !== recordId), - totalAnomalyCount: Math.max(0, (t.totalAnomalyCount || 0) - 1), - } - : t - )); - feed.setDashboardCache({ ...feed.dashboardCache, stats: null }); - }; - - // The backend soft-deletes (deleted = 1) for these exact Audit_values - - // mirror of the list in anomalyModel.update. Note the UI's "low light - // condition" does NOT match the backend's "low light", so from this - // screen only "duplicate" soft-deletes. - const SOFT_DELETE_AUDIT_VALUES = ['duplicate', 'comparison error', 'low light', 'far asset', 'no detection']; const softDeleted = SOFT_DELETE_AUDIT_VALUES.includes(auditValueVal); if (!isAnomalyBool) { @@ -570,25 +638,343 @@ export const AuditSession: React.FC = () => { } }, [currentAnomaly, user, isRectActive]); + // Build the exact POST /anomaly body the single-record flow sends. + const buildAuditPayload = (rec: any, v: StagedVerdict) => ({ + Audit_value: computeAuditValue(v.isAnomaly, v.category, v.plantSub, v.notAnAnomalySub), + Comments: v.notes || '', + Audit_status: v.isAnomaly, // boolean true/false, not 1/0 + Frame_Test: rec.Frame_Test || '', + Frame_Master: rec.Frame_Master || '', + Chainage: rec.Chainage || '', + Pos: rec.Pos || '', + id: rec.id, + road: rec.road || 'mcw', + Algorithm: rec.Algorithm || '', + user_id: user?.user_id || 0, + }); + + const successOf = (e: { rec: any; v: StagedVerdict }) => ({ + id: e.rec.id as number, + isAnomaly: e.v.isAnomaly, + auditValue: computeAuditValue(e.v.isAnomaly, e.v.category, e.v.plantSub, e.v.notAnAnomalySub), + comments: e.v.notes || '', + }); + + const handleBulkSubmit = async () => { + setIsBulkConfirmOpen(false); + if (isBulkSubmitting) return; + const toast = useToastStore.getState().showToast; + const entries = anomalies + .filter(a => stagedAudits[a.id]) + .map(a => ({ rec: a, v: stagedAudits[a.id] })); + if (entries.length === 0) return; + setIsBulkSubmitting(true); + + const failures: { id: number; reason: string }[] = []; + const succeeded: { id: number; isAnomaly: boolean; auditValue: string; comments: string }[] = []; + + // Phase 1: save every staged verdict in ONE POST /anomaly call. The + // backend settles each record independently and returns per-record + // outcomes, so one locked/failing record can't sink the batch. + let apiResults: { id: number; ok: boolean; locked?: boolean; error?: string }[]; + try { + const res: any = await activityFeedsService.updateAnomaly( + entries.map(e => buildAuditPayload(e.rec, e.v)), + isRectActive + ); + apiResults = res?.results || []; + } catch (err: any) { + // 409/500 still carry per-record results when the whole batch failed; + // anything without them (e.g. network) means nothing was saved. + const bodyResults = err?.response?.data?.results; + if (Array.isArray(bodyResults)) { + apiResults = bodyResults; + } else { + toast(`Bulk submit failed — nothing was saved. ${backendMsg(err)}`, 'error'); + setIsBulkSubmitting(false); + return; + } + } + const resultById = new Map(apiResults.map((r: any) => [Number(r.id), r])); + + // Classify phase-1 outcomes. True anomalies whose audit saved now - or + // had saved earlier ("locked", the same 409 retry path the single-record + // flow uses) - continue to anomaly processing; safe verdicts are final. + const toProcessBySite = new Map(); + for (const e of entries) { + const r = resultById.get(Number(e.rec.id)); + const ok = r?.ok === true; + const locked = r?.locked === true; + if (e.v.isAnomaly) { + if (ok || locked) { + const key = String(e.rec.site_id); + if (!toProcessBySite.has(key)) toProcessBySite.set(key, []); + toProcessBySite.get(key)!.push(e); + } else { + failures.push({ id: e.rec.id, reason: r?.error || 'saving audit failed' }); + } + } else if (ok) { + succeeded.push(successOf(e)); + } else { + failures.push({ id: e.rec.id, reason: locked ? 'already audited — locked' : (r?.error || 'saving audit failed') }); + } + } + + // Phase 2: anomaly processing, one call per site - the endpoints take a + // single site_id plus an id array, and a session's feed can span sites. + for (const [siteKey, group] of toProcessBySite) { + const ids = group.map(e => e.rec.id); + try { + const res: any = isRectActive + ? await activityFeedsService.markAnomalyCompleted({ anomaly_id: ids, site_id: group[0].rec.site_id }) + : await activityFeedsService.updateAnomalyInDashboard({ site_id: siteKey, anomaly_id: ids }); + const errIds = new Set((res?.error_audit_ids || []).map(Number)); + for (const e of group) { + if (errIds.has(Number(e.rec.id))) { + failures.push({ id: e.rec.id, reason: 'backend error while processing the anomaly (details logged on the record\'s "errors" column)' }); + } else { + // skipped_audit_ids = the idempotency guard: audit saved and + // processing already ran earlier. Same as the single flow, that + // counts as done. + succeeded.push(successOf(e)); + } + } + } catch (err: any) { + if (err?.response?.status === 404) { + // Guard filtered out the entire group: all were already processed. + group.forEach(e => succeeded.push(successOf(e))); + } else { + group.forEach(e => failures.push({ id: e.rec.id, reason: `audit saved, but anomaly processing failed — ${backendMsg(err)}` })); + } + } + } + + // Phase 3: apply successes locally; failures STAY STAGED so a retry only + // re-sends what actually failed. + applyAuditedLocally(succeeded); + const successIds = new Set(succeeded.map(r => r.id)); + setItemAuditStates(prev => prev.map((s, i) => + anomalies[i] && successIds.has(anomalies[i].id) ? 'audited' : s + )); + setStagedAudits(prev => { + const next = { ...prev }; + successIds.forEach(id => delete next[id]); + return next; + }); + setBulkResult({ succeeded: succeeded.length, failures }); + setIsBulkSubmitting(false); + }; + + // Counts for the Bulk Submit confirm dialog. + const stagedSummary = React.useMemo(() => { + let anomalyCnt = 0, safeCnt = 0, softDeleteCnt = 0; + Object.values(stagedAudits).forEach(v => { + if (v.isAnomaly) anomalyCnt++; else safeCnt++; + if (SOFT_DELETE_AUDIT_VALUES.includes(computeAuditValue(v.isAnomaly, v.category, v.plantSub, v.notAnAnomalySub))) { + softDeleteCnt++; + } + }); + return { anomalies: anomalyCnt, safe: safeCnt, softDeletes: softDeleteCnt }; + }, [stagedAudits]); + + const handleToggleBulkMode = (val: boolean) => { + if (!val && stagedCount > 0) { + const ok = window.confirm(`You have ${stagedCount} staged audit(s) that haven't been submitted. Turning Bulk Mode off discards them. Continue?`); + if (!ok) return; + setStagedAudits({}); + setItemAuditStates(prev => prev.map(s => (s === 'staged' ? 'pending' : s))); + } + setIsBulkMode(val); + localStorage.setItem('isBulkAudit', String(val)); + }; + + // Records "Audit All" would touch: everything except locked ones (already + // audited server-side or completed earlier this session). + const auditAllTargetCount = anomalies.reduce((n, a, i) => + n + ((a.IsAudited === 1 || itemAuditStates[i] === 'audited') ? 0 : 1), 0); + + const isAuditAllVerdictValid = + aaCategory !== null && + (aaCategory !== 1 || (aaIsAnomaly ? aaPlantSub !== null : aaNotAnAnomalySub !== null)); + + const openAuditAllDialog = () => { + setAaIsAnomaly(true); + setAaCategory(null); + setAaPlantSub(null); + setAaNotAnAnomalySub(null); + setAaNotes(''); + setIsAuditAllOpen(true); + }; + + const handleAuditAllApply = () => { + if (!isAuditAllVerdictValid || aaCategory === null) return; + const verdict: StagedVerdict = { + isAnomaly: aaIsAnomaly, + category: aaCategory, + notes: aaNotes, + plantSub: aaIsAnomaly ? aaPlantSub : null, + notAnAnomalySub: !aaIsAnomaly ? aaNotAnAnomalySub : null, + }; + + const nextStaged = { ...stagedAudits }; + const nextStates = [...itemAuditStates]; + let stagedNow = 0; + anomalies.forEach((a, i) => { + if (a.IsAudited === 1 || itemAuditStates[i] === 'audited') return; + nextStaged[a.id] = { ...verdict }; + nextStates[i] = 'staged'; + stagedNow++; + }); + setStagedAudits(nextStaged); + setItemAuditStates(nextStates); + + // Keep the visible per-record form in sync when the current record was + // part of the sweep, so what's on screen matches what got staged. + const cur = anomalies[currentIndex]; + if (cur && nextStaged[cur.id] && !(cur.IsAudited === 1 || itemAuditStates[currentIndex] === 'audited')) { + setIsAnomaly(verdict.isAnomaly); + setSelectedCategory(verdict.category); + setNotes(verdict.notes); + setPlantSubCategory(verdict.plantSub); + setNotAnAnomalySubCategory(verdict.notAnAnomalySub); + } + + setIsAuditAllOpen(false); + useToastStore.getState().showToast( + `Staged ${stagedNow} record${stagedNow === 1 ? '' : 's'} as "${computeAuditValue(verdict.isAnomaly, verdict.category, verdict.plantSub, verdict.notAnAnomalySub)}" — review any tile, then press Bulk Submit.`, + 'success' + ); + }; + + // Staged verdicts live only in browser memory: warn before leaving. + const confirmDiscardStaged = () => + stagedCount === 0 || window.confirm(`You have ${stagedCount} staged audit(s) that haven't been submitted. Leave and discard them?`); + + useEffect(() => { + if (stagedCount === 0) return; + const onBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ''; + }; + window.addEventListener('beforeunload', onBeforeUnload); + return () => window.removeEventListener('beforeunload', onBeforeUnload); + }, [stagedCount]); + + // End-of-batch panel actions. Deliberately keyboard-light: navigation + // (next batch / go back / leave) can ride the keys the session already + // uses, but anything that commits many records at once (Bulk Submit, + // Audit All) is mouse-only, everywhere. + const auditedCount = itemAuditStates.filter(s => s === 'audited').length; + const skippedCount = itemAuditStates.filter(s => s === 'skipped').length; + const isFeedSession = !!(location.state as any)?.useExistingFeed; + + const handleNextBatch = async () => { + if (!confirmDiscardStaged()) return; + if (isFeedSession) { + handleLoadNextFeedPage(); + } else { + setShowBatchEndPanel(false); + const nextPage = sessionPageNo + 1; + setSessionPageNo(nextPage); + await loadSessionData(nextPage); + } + }; + + const handleLeaveSession = () => { + if (!confirmDiscardStaged()) return; + setShowBatchEndPanel(false); + if (!isFeedSession) invalidateDashboardCache(); + navigate(isFeedSession ? '/activity-feeds' : '/dashboard'); + }; + + const handleReviewSkipped = () => { + const firstSkipped = itemAuditStates.findIndex(s => s === 'skipped'); + setShowBatchEndPanel(false); + goToIndex(firstSkipped !== -1 ? firstSkipped : anomalies.length - 1); + }; + + // Category 1 nests a sub-category (Plant / Not an Anomaly) - a verdict + // without it is incomplete, both for immediate save and for staging. + const validateSubCategory = (): boolean => { + if (selectedCategory !== 1) return true; + if ((isAnomaly === null || isAnomaly === true) && !plantSubCategory) { + useToastStore.getState().showToast('Please select a feature before submitting.', 'warning'); + return false; + } + if (isAnomaly === false && !notAnAnomalySubCategory) { + useToastStore.getState().showToast('Please select a feature before submitting.', 'warning'); + return false; + } + return true; + }; + + // Bulk Mode: hold the verdict locally instead of calling the APIs. + // Re-staging an already-staged record overwrites its verdict. + const handleStageAndNext = () => { + if (!currentAnomaly) return; + if (isLocked) { + useToastStore.getState().showToast(`#${currentAnomaly.id} is already audited — changes are locked.`, 'info'); + handleNext(); + return; + } + if (selectedCategory === null) { + handleSkip(); + return; + } + if (!validateSubCategory()) return; + + setStagedAudits(prev => ({ + ...prev, + [currentAnomaly.id]: { + isAnomaly: isAnomaly ?? true, + category: selectedCategory, + notes, + plantSub: plantSubCategory, + notAnAnomalySub: notAnAnomalySubCategory, + }, + })); + setItemAuditStates(prev => { + const next = [...prev]; + next[currentIndex] = 'staged'; + return next; + }); + handleNext(); + }; + + const handleUnstage = () => { + if (!currentAnomaly || !stagedAudits[currentAnomaly.id]) return; + setStagedAudits(prev => { + const next = { ...prev }; + delete next[currentAnomaly.id]; + return next; + }); + setItemAuditStates(prev => { + const next = [...prev]; + next[currentIndex] = 'pending'; + return next; + }); + setSelectedCategory(null); + setNotes(''); + setIsAnomaly(null); + setPlantSubCategory(null); + setNotAnAnomalySubCategory(null); + }; + const handleSaveAndNext = useCallback(async () => { if (!currentAnomaly) return; + if (isBulkMode) { + handleStageAndNext(); + return; + } + if (isLocked) { useToastStore.getState().showToast(`#${currentAnomaly.id} is already audited — changes are locked.`, 'info'); handleNext(); return; } - if (selectedCategory === 1) { - if ((isAnomaly === null || isAnomaly === true) && !plantSubCategory) { - useToastStore.getState().showToast('Please select a feature before submitting.', 'warning'); - return; - } - if (isAnomaly === false && !notAnAnomalySubCategory) { - useToastStore.getState().showToast('Please select a feature before submitting.', 'warning'); - return; - } - } + if (!validateSubCategory()) return; // Call the API - only mark the tile audited if the save really succeeded, // so a red toast + still-pending tile point at exactly the failed item. @@ -602,7 +988,8 @@ export const AuditSession: React.FC = () => { } handleNext(); - }, [currentIndex, currentAnomaly, isLocked, isAnomaly, selectedCategory, notes, plantSubCategory, notAnAnomalySubCategory, saveAuditState, handleNext]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentIndex, currentAnomaly, isLocked, isBulkMode, isAnomaly, selectedCategory, notes, plantSubCategory, notAnAnomalySubCategory, saveAuditState, handleNext, handleStageAndNext]); // Quick Auto Advance save trigger const handleQuickSaveAndNext = useCallback(async ( @@ -636,15 +1023,30 @@ export const AuditSession: React.FC = () => { setPlantSubCategory(null); setNotAnAnomalySubCategory(null); } else { - setShowCompletionModal(true); + setShowBatchEndPanel(true); } }, [currentIndex, anomalies.length, currentAnomaly, isLocked, notes, saveAuditState]); // Keyboard shortcuts const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen) return; + if (isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen || isBulkConfirmOpen || isAuditAllOpen || bulkResult !== null) return; const key = e.key.toLowerCase(); + // End-of-batch panel: navigation keys only. Enter loads the next batch + // ONLY when nothing is staged - Bulk Submit is mouse-only because it + // commits many records at once. + if (showBatchEndPanel) { + if (key === 'arrowleft') { + setShowBatchEndPanel(false); + goToIndex(anomalies.length - 1); + } else if (key === 'enter' && stagedCount === 0) { + handleNextBatch(); + } else if (key === 'escape') { + handleLeaveSession(); + } + return; + } + // Audited records are read-only: navigation keys still work, everything // that would change the audit result is ignored. if (isLocked && !['arrowright', 'arrowleft', 'tab', 'enter'].includes(key)) return; @@ -690,13 +1092,13 @@ export const AuditSession: React.FC = () => { if (isAnomaly === true && selectedCategory === 1) { if (key === '1') { setPlantSubCategory('in grown'); - if (isQuickAudit) { + if (isQuickAudit && !isBulkMode) { setTimeout(() => handleQuickSaveAndNext(true, 1, 'in grown', null), 120); } break; } else if (key === '2') { setPlantSubCategory('out grown'); - if (isQuickAudit) { + if (isQuickAudit && !isBulkMode) { setTimeout(() => handleQuickSaveAndNext(true, 1, 'out grown', null), 120); } break; @@ -715,7 +1117,7 @@ export const AuditSession: React.FC = () => { const subVal = subMap[key]; if (subVal) { setNotAnAnomalySubCategory(subVal); - if (isQuickAudit) { + if (isQuickAudit && !isBulkMode) { setTimeout(() => handleQuickSaveAndNext(false, 1, null, subVal), 120); } break; @@ -733,7 +1135,7 @@ export const AuditSession: React.FC = () => { break; } - if (isQuickAudit) { + if (isQuickAudit && !isBulkMode) { setTimeout(() => handleQuickSaveAndNext(currentIsAnomaly, catId), 120); } break; @@ -756,7 +1158,7 @@ export const AuditSession: React.FC = () => { handleSkip(); break; } - }, [currentIndex, anomalies, isLocked, isAnomaly, selectedCategory, notes, plantSubCategory, isQuickAudit, isNotesFocused, isReportDialogOpen, isShortcutsDialogOpen, handleQuickSaveAndNext, handleSaveAndNext, handleSkip, handleNext]); + }, [currentIndex, anomalies, isLocked, isAnomaly, selectedCategory, notes, plantSubCategory, isQuickAudit, isBulkMode, isNotesFocused, isReportDialogOpen, isShortcutsDialogOpen, isBulkConfirmOpen, isAuditAllOpen, bulkResult, showBatchEndPanel, stagedCount, handleNextBatch, handleLeaveSession, handleQuickSaveAndNext, handleSaveAndNext, handleSkip, handleNext]); useEffect(() => { window.addEventListener('keydown', handleKeyDown); @@ -814,6 +1216,7 @@ export const AuditSession: React.FC = () => { + + Click any violet tile above to review a staged verdict first. + + + )} + + + + + {skippedCount > 0 && ( + + )} + + + + + + + ) : ( {/* Left Side: Images & Overlay */} @@ -1007,21 +1491,41 @@ export const AuditSession: React.FC = () => { {/* Right Side: Sidebar */} - {/* Quick-Audit Auto-Advance Toggle */} - - - Quick Audit - Auto-advances on keypress + {/* Quick-Audit Auto-Advance + Bulk Mode Toggles */} + + + + Quick Audit + + {isBulkMode ? 'Off while Bulk Mode is on' : 'Auto-advances on keypress'} + + + handleToggleQuickAudit(e.target.checked)} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: '#3b82f6' }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { backgroundColor: '#3b82f6' } + }} + /> + + + + Bulk Mode + Stage all, submit once + + handleToggleBulkMode(e.target.checked)} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: '#8b5cf6' }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { backgroundColor: '#8b5cf6' } + }} + /> - handleToggleQuickAudit(e.target.checked)} - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { color: '#3b82f6' }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { backgroundColor: '#3b82f6' } - }} - /> {/* Read-only lock banner for already-audited records */} @@ -1107,7 +1611,7 @@ export const AuditSession: React.FC = () => { return; } - if (isQuickAudit) { + if (isQuickAudit && !isBulkMode) { setTimeout(() => handleQuickSaveAndNext(currentIsAnom, cat.id), 120); } }} @@ -1142,7 +1646,7 @@ export const AuditSession: React.FC = () => { disabled={isLocked} onClick={async () => { setPlantSubCategory(sub.val as any); - if (isQuickAudit) { + if (isQuickAudit && !isBulkMode) { setTimeout(() => handleQuickSaveAndNext(true, 1, sub.val, null), 120); } }} @@ -1181,7 +1685,7 @@ export const AuditSession: React.FC = () => { disabled={isLocked} onClick={async () => { setNotAnAnomalySubCategory(sub.val as any); - if (isQuickAudit) { + if (isQuickAudit && !isBulkMode) { setTimeout(() => handleQuickSaveAndNext(false, 1, null, sub.val), 120); } }} @@ -1237,6 +1741,60 @@ export const AuditSession: React.FC = () => { {/* Actions */} + {isBulkMode && currentAnomaly && stagedAudits[currentAnomaly.id] && ( + + + Staged as "{computeAuditValue( + stagedAudits[currentAnomaly.id].isAnomaly, + stagedAudits[currentAnomaly.id].category, + stagedAudits[currentAnomaly.id].plantSub, + stagedAudits[currentAnomaly.id].notAnAnomalySub + )}" + + + + )} + {isBulkMode && ( + + )} + {isBulkMode && ( + + )} + + + + {/* Category */} + + {(aaIsAnomaly ? ANOMALY_CATEGORIES : SAFE_CATEGORIES).map(cat => ( + + ))} + + + {/* Nested sub-category for category 1 (Plant / Not an Anomaly) */} + {aaCategory === 1 && ( + + {(aaIsAnomaly + ? [{ val: 'in grown', label: 'In Grown' }, { val: 'out grown', label: 'Out Grown' }] + : [ + { val: 'comparison error', label: 'Comparison Error' }, + { val: 'low light', label: 'Low Light' }, + { val: 'far asset', label: 'Far Asset' }, + { val: 'no detection', label: 'No Detection' }, + { val: 'image mismatch', label: 'Image Mismatch' }, + ] + ).map(sub => { + const selected = aaIsAnomaly ? aaPlantSub === sub.val : aaNotAnAnomalySub === sub.val; + return ( + + ); + })} + + )} + + {/* Notes (applied to every record) */} + setAaNotes(e.target.value)} + sx={{ + '& .MuiOutlinedInput-root': { + color: '#f8fafc', + '& fieldset': { borderColor: '#334155' }, + '&:hover fieldset': { borderColor: '#475569' }, + '&.Mui-focused fieldset': { borderColor: '#8b5cf6' }, + }, + '& .MuiInputBase-input::placeholder': { color: '#64748b', opacity: 1 }, + }} + /> + + {isAuditAllVerdictValid && SOFT_DELETE_AUDIT_VALUES.includes(computeAuditValue(aaIsAnomaly, aaCategory, aaPlantSub, aaNotAnAnomalySub)) && ( + + ⚠ This verdict soft-deletes every record it's applied to. + + )} + + + + + + + + {/* Bulk Submit Confirmation */} + setIsBulkConfirmOpen(false)} + sx={{ zIndex: 10000 }} + slotProps={{ paper: { sx: { bgcolor: '#0f172a', color: '#f8fafc', border: '1px solid #1e293b', minWidth: 400 } } }} + > + + Submit {stagedCount} staged audit{stagedCount === 1 ? '' : 's'}? + + + + {stagedSummary.anomalies} marked as anomaly, {stagedSummary.safe} marked safe. + + {stagedSummary.softDeletes > 0 && ( + + ⚠ {stagedSummary.softDeletes} record{stagedSummary.softDeletes === 1 ? '' : 's'} will be soft-deleted (e.g. "duplicate"). + + )} + + Audited records are locked and can't be changed afterwards. Records that fail stay staged for retry. - - {(() => { - const navState = location.state as any; - const isFeedSession = !!navState?.useExistingFeed; - const feedPageSize = activeTab?.rowsPerPage || 20; - return ( - <> - - - - ); - })()} + + + + + + + {/* Bulk Submit Result Summary */} + setBulkResult(null)} + sx={{ zIndex: 10000 }} + slotProps={{ paper: { sx: { bgcolor: '#0f172a', color: '#f8fafc', border: '1px solid #1e293b', minWidth: 440, maxWidth: 560 } } }} + > + + {bulkResult?.failures.length ? 'Bulk submit finished with failures' : 'Bulk submit complete 🎉'} + + + + ✓ {bulkResult?.succeeded ?? 0} record{(bulkResult?.succeeded ?? 0) === 1 ? '' : 's'} submitted successfully. + + {!!bulkResult?.failures.length && ( + <> + + ✗ {bulkResult.failures.length} failed — kept staged so you can retry: + + + {bulkResult.failures.map(f => ( + + #{f.id} — {f.reason} + + ))} + + + )} + + + {!!bulkResult?.failures.length && ( + + )} +