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 = () => {