feat: implement admin audit history, session management, and dashboard modules with supporting API service layer

This commit is contained in:
2026-07-13 12:22:46 +05:30
parent e5c2b047d7
commit 98eb91af71
4 changed files with 135 additions and 18 deletions

View File

@@ -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;
}

View File

@@ -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 = () => {
<TableHead>
<TableRow>
<TableCell>User</TableCell>
<TableCell align="right">Assigned</TableCell>
<TableCell align="right">Completed</TableCell>
<TableCell align="right">Efficiency</TableCell>
<TableCell align="right">
<Tooltip title="Records assigned to the user whose video date falls in the selected range">
<span>Assigned</span>
</Tooltip>
</TableCell>
<TableCell align="right">
<Tooltip title="Records the user completed within the selected range - includes backlog whose video arrived earlier, so this can exceed Assigned">
<span>Completed</span>
</Tooltip>
</TableCell>
<TableCell align="right">
<Tooltip title="Share of the user's own Assigned queue that is done - never exceeds 100%">
<span>Queue Done %</span>
</Tooltip>
</TableCell>
</TableRow>
</TableHead>
<TableBody>

View File

@@ -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 = () => {
/>
</Box>
{/* Read-only lock banner for already-audited records */}
{isLocked && (
<Box sx={{
p: 1.5, borderRadius: 2, bgcolor: 'rgba(234, 179, 8, 0.08)',
border: '1px solid #713f12', display: 'flex', alignItems: 'center', gap: 1,
}}>
<Typography sx={{ fontSize: '0.8rem', color: '#eab308', fontWeight: 'bold' }}>
🔒 Already audited{currentAnomaly?.Audit_value ? ` as "${currentAnomaly.Audit_value}"` : ''}
{currentAnomaly?.Audited_on ? ` on ${new Date(currentAnomaly.Audited_on).toLocaleDateString()}` : ''}
{' '} read-only
</Typography>
</Box>
)}
{/* Anomaly/Safe Toggle */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
@@ -956,6 +1047,7 @@ export const AuditSession: React.FC = () => {
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
fullWidth
disabled={isLocked}
variant={isAnomaly === true ? "contained" : "outlined"}
color={isAnomaly === true ? "error" : "inherit"}
onClick={() => setIsAnomaly(true)}
@@ -973,6 +1065,7 @@ export const AuditSession: React.FC = () => {
</Button>
<Button
fullWidth
disabled={isLocked}
variant={isAnomaly === false ? "contained" : "outlined"}
color={isAnomaly === false ? "success" : "inherit"}
onClick={() => {
@@ -1001,6 +1094,7 @@ export const AuditSession: React.FC = () => {
{(isAnomaly === false ? SAFE_CATEGORIES : ANOMALY_CATEGORIES).map(cat => (
<React.Fragment key={cat.id}>
<Button
disabled={isLocked}
onClick={() => {
setSelectedCategory(cat.id);
const currentIsAnom = isAnomaly === null ? true : isAnomaly;
@@ -1045,6 +1139,7 @@ export const AuditSession: React.FC = () => {
].map(sub => (
<Button
key={sub.val}
disabled={isLocked}
onClick={async () => {
setPlantSubCategory(sub.val as any);
if (isQuickAudit) {
@@ -1083,6 +1178,7 @@ export const AuditSession: React.FC = () => {
].map(sub => (
<Button
key={sub.val}
disabled={isLocked}
onClick={async () => {
setNotAnAnomalySubCategory(sub.val as any);
if (isQuickAudit) {
@@ -1117,7 +1213,8 @@ export const AuditSession: React.FC = () => {
<Typography sx={{ fontSize: '0.75rem', fontWeight: 'bold', color: '#94a3b8', letterSpacing: 1, mb: 1 }}>NOTES</Typography>
<Box
component="textarea"
value={notes}
value={isLocked ? (currentAnomaly?.Comments || '') : notes}
disabled={isLocked}
onChange={(e) => setNotes(e.target.value)}
onFocus={() => setIsNotesFocused(true)}
onBlur={() => setIsNotesFocused(false)}
@@ -1142,8 +1239,8 @@ export const AuditSession: React.FC = () => {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
variant="contained"
color={selectedCategory !== null ? "primary" : "inherit"}
onClick={selectedCategory !== null ? handleSaveAndNext : handleSkip}
color={!isLocked && selectedCategory !== null ? "primary" : "inherit"}
onClick={isLocked ? handleNext : selectedCategory !== null ? handleSaveAndNext : handleSkip}
sx={{
borderRadius: 2,
py: 1.5,
@@ -1159,7 +1256,7 @@ export const AuditSession: React.FC = () => {
'&:hover': { bgcolor: selectedCategory !== null ? '' : '#334155' }
}}
>
{selectedCategory !== null ? "Save & Next" : "Next (Skip)"} <Typography component="span" sx={{ fontSize: '0.75rem', opacity: 0.7 }}>Enter</Typography>
{isLocked ? "Next (Read-only)" : selectedCategory !== null ? "Save & Next" : "Next (Skip)"} <Typography component="span" sx={{ fontSize: '0.75rem', opacity: 0.7 }}>Enter</Typography>
</Button>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button

View File

@@ -139,7 +139,11 @@ export const Dashboard: React.FC = () => {
);
return (
<Box sx={{ width: '100%', maxWidth: '100vw', display: 'flex', flexDirection: 'column' }}>
// MainLayout's <main> is height:100vh + overflow:hidden (by design - each
// page owns its own scroll region; see AdminConsole.tsx for the same fix).
// The batches list can be far taller than the viewport, so this page needs
// its own bounded scrollable container.
<Box sx={{ width: '100%', maxWidth: '100vw', display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)', overflowY: 'auto' }}>
<TopFilterBar />
<Box sx={{ px: 2, pt: 2, pb: 4, width: '100%' }}>
<Box sx={{ width: '100%' }}>