|
|
|
|
@@ -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,11 +462,52 @@ export const AuditSession: React.FC = () => {
|
|
|
|
|
// Call API 1: Save audit result (POST /anomaly)
|
|
|
|
|
try {
|
|
|
|
|
await activityFeedsService.updateAnomaly([auditPayload], isRectActive);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
} 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
|
|
|
|
|
@@ -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
|
|
|
|
|
|