diff --git a/src/api/adminService.ts b/src/api/adminService.ts index a5a8476..540bae4 100644 --- a/src/api/adminService.ts +++ b/src/api/adminService.ts @@ -71,9 +71,12 @@ export interface AuditHistoryUserCount { user_id: number; username: string; assigned: number; + // Completions within the window (Audited_on) - a throughput number that + // includes old backlog, so it CAN exceed `assigned`. completed: number; - // null when nothing was assigned in this window (they may still have - // completed backlog items - that's not "0% efficient"). + // Share of their own windowed assigned queue that is done (0-100, never + // above 100). null when nothing was assigned in this window (they may + // still have completed backlog items - that's not "0% efficient"). efficiency: number | null; } diff --git a/src/pages/admin/AuditHistory.tsx b/src/pages/admin/AuditHistory.tsx index 65df1f9..c48da43 100644 --- a/src/pages/admin/AuditHistory.tsx +++ b/src/pages/admin/AuditHistory.tsx @@ -3,6 +3,7 @@ import { Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup, ToggleButton, CircularProgress, Typography, TextField, Chip, TablePagination, + Tooltip, } from '@mui/material'; import { adminService } from '../../api/adminService'; import type { AuditHistoryUserCount, AuditHistoryRecord } from '../../api/adminService'; @@ -240,9 +241,21 @@ export const AuditHistory: React.FC = () => { User - Assigned - Completed - Efficiency + + + Assigned + + + + + Completed + + + + + Queue Done % + + diff --git a/src/pages/audit-session/AuditSession.tsx b/src/pages/audit-session/AuditSession.tsx index a64431e..a250423 100644 --- a/src/pages/audit-session/AuditSession.tsx +++ b/src/pages/audit-session/AuditSession.tsx @@ -314,6 +314,13 @@ export const AuditSession: React.FC = () => { const currentAnomaly = anomalies[currentIndex]; + // Audited records are read-only: either it arrived from the server already + // audited (IsAudited = 1), or it was completed in this session. The backend + // enforces the same rule (POST /anomaly rejects audited rows with a 409), + // so this is a UX mirror of a server-side guarantee, not the guarantee + // itself. + const isLocked = currentAnomaly?.IsAudited === 1 || itemAuditStates[currentIndex] === 'audited'; + useEffect(() => { if (currentAnomaly?.images && currentAnomaly.images.length > 0) { const selectedIdx = currentAnomaly.images.findIndex((img: any) => img.selected); @@ -455,12 +462,53 @@ export const AuditSession: React.FC = () => { // Call API 1: Save audit result (POST /anomaly) try { await activityFeedsService.updateAnomaly([auditPayload], isRectActive); - } catch (err) { - console.error('Failed to save audit:', err); - toast(`#${recordId}: saving audit failed — ${backendMsg(err)}`, 'error'); - return false; + } catch (err: any) { + // 409 = the record is already audited (locked server-side). For a true + // anomaly this is the legitimate retry path: API 1 succeeded earlier but + // anomaly processing (API 2) failed - skip re-saving and retry API 2. + // For a false audit there's nothing left to do: it's locked. + if (err?.response?.status === 409) { + if (!isAnomalyBool) { + toast(`#${recordId} is already audited — changes are locked.`, 'warning'); + return false; + } + toast(`#${recordId}: audit already saved — retrying anomaly processing…`, 'info'); + } else { + console.error('Failed to save audit:', err); + toast(`#${recordId}: saving audit failed — ${backendMsg(err)}`, 'error'); + return false; + } } + // 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)); + + // 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 @@ -475,6 +523,7 @@ export const AuditSession: React.FC = () => { : `#${recordId} saved as false audit ("${auditValueVal}")`, softDeleted ? 'warning' : 'success' ); + markLocalAudited(); return true; } @@ -498,14 +547,23 @@ export const AuditSession: React.FC = () => { } if (res?.skipped_audit_ids?.length) { toast(`#${recordId}: audit saved, but the backend skipped anomaly processing (record not found or not eligible)`, 'warning'); + markLocalAudited(); return true; } toast( `#${recordId} audited as true anomaly ("${auditValueVal}") — anomaly record ${isRectActive ? 'closed' : 'created/updated'}`, 'success' ); + markLocalAudited(); return true; - } catch (err) { + } catch (err: any) { + // 404 here means the backend's idempotency guard filtered the record + // out: its anomaly processing already ran. Nothing is wrong or lost. + if (err?.response?.status === 404) { + toast(`#${recordId} was already fully processed earlier — no changes made.`, 'info'); + markLocalAudited(); + return true; + } console.error('Anomaly processing failed:', err); toast(`#${recordId}: audit saved, but anomaly processing failed — ${backendMsg(err)}`, 'error'); return false; @@ -515,6 +573,12 @@ export const AuditSession: React.FC = () => { const handleSaveAndNext = useCallback(async () => { if (!currentAnomaly) 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'); @@ -538,7 +602,7 @@ export const AuditSession: React.FC = () => { } handleNext(); - }, [currentIndex, currentAnomaly, isAnomaly, selectedCategory, notes, plantSubCategory, notAnAnomalySubCategory, saveAuditState, handleNext]); + }, [currentIndex, currentAnomaly, isLocked, isAnomaly, selectedCategory, notes, plantSubCategory, notAnAnomalySubCategory, saveAuditState, handleNext]); // Quick Auto Advance save trigger const handleQuickSaveAndNext = useCallback(async ( @@ -549,6 +613,11 @@ export const AuditSession: React.FC = () => { ) => { if (!currentAnomaly) return; + if (isLocked) { + useToastStore.getState().showToast(`#${currentAnomaly.id} is already audited — changes are locked.`, 'info'); + return; + } + // Call the API - only mark the tile audited if the save really succeeded. const ok = await saveAuditState(anomalyVal, catVal, notes, plantSub, notAnAnomalySub); if (ok) { @@ -569,13 +638,21 @@ export const AuditSession: React.FC = () => { } else { setShowCompletionModal(true); } - }, [currentIndex, anomalies.length, currentAnomaly, notes, saveAuditState]); + }, [currentIndex, anomalies.length, currentAnomaly, isLocked, notes, saveAuditState]); // Keyboard shortcuts const handleKeyDown = useCallback((e: KeyboardEvent) => { if (isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen) return; const key = e.key.toLowerCase(); + // 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; + if (isLocked && key === 'enter') { + handleNext(); + return; + } + // Register active flash pulse setActiveKeyFlash(key); setTimeout(() => setActiveKeyFlash(null), 150); @@ -679,7 +756,7 @@ export const AuditSession: React.FC = () => { handleSkip(); break; } - }, [currentIndex, anomalies, isAnomaly, selectedCategory, notes, plantSubCategory, isQuickAudit, isNotesFocused, isReportDialogOpen, isShortcutsDialogOpen, handleQuickSaveAndNext, handleSaveAndNext, handleSkip]); + }, [currentIndex, anomalies, isLocked, isAnomaly, selectedCategory, notes, plantSubCategory, isQuickAudit, isNotesFocused, isReportDialogOpen, isShortcutsDialogOpen, handleQuickSaveAndNext, handleSaveAndNext, handleSkip, handleNext]); useEffect(() => { window.addEventListener('keydown', handleKeyDown); @@ -947,6 +1024,20 @@ export const AuditSession: React.FC = () => { /> + {/* Read-only lock banner for already-audited records */} + {isLocked && ( + + + 🔒 Already audited{currentAnomaly?.Audit_value ? ` as "${currentAnomaly.Audit_value}"` : ''} + {currentAnomaly?.Audited_on ? ` on ${new Date(currentAnomaly.Audited_on).toLocaleDateString()}` : ''} + {' '}— read-only + + + )} + {/* Anomaly/Safe Toggle */} @@ -956,6 +1047,7 @@ export const AuditSession: React.FC = () => {