audit session: zoomable images, 0-9 categories, prefilled notes, Next != Skip
- images: scroll-wheel zoom toward the cursor + mouse-follow pan on both master and anomaly panes (new ZoomableImage); video keeps native controls - categories renumbered 0-9 on both lists (first item = 0) for display and keyboard shortcut, matching the legacy UI; keyboard resolves by key, not id - NOTES prefills from the record's existing Comment so the algorithm comment is visible/editable and no longer wiped on save - Enter/primary button is plain Next (record stays pending); only Skip/Tab marks Skipped, and Skipped records are excluded from Audit All feed table: FEATURES shows Audit_value; verdict pill replaces slider - FEATURES column now renders the record's Audit_value (was a nonexistent field showing N/A) - ANOMALY True/False column: colored pill (green True / red False / neutral when not yet audited) instead of the broken slider; gated on IsAudited so pending rows read neutral
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
# VITE_AUDIT_API_URL=https://node-audit.seekright.com
|
# VITE_AUDIT_API_URL=https://node-audit.seekright.com
|
||||||
VITE_AUDIT_API_URL=https://psb7wrm5-7514.inc1.devtunnels.ms
|
VITE_AUDIT_API_URL=https://ms31kqw1-7514.inc1.devtunnels.ms
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Slider, Skeleton } from '@mui/material';
|
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Skeleton } from '@mui/material';
|
||||||
import { useFeedStore } from '../../store/feedStore';
|
import { useFeedStore } from '../../store/feedStore';
|
||||||
|
|
||||||
|
// Anomaly verdict. Audit_status is only a real verdict once a human has
|
||||||
|
// audited the record (IsAudited = 1) - on pending rows it holds the
|
||||||
|
// pipeline's pre-audit guess, so it must be ignored. Green True (1 → real
|
||||||
|
// anomaly), red False (0 → safe), neutral when not yet audited.
|
||||||
|
const VerdictPill: React.FC<{ isAudited: any; status: any }> = ({ isAudited, status }) => {
|
||||||
|
const decided = Number(isAudited) === 1;
|
||||||
|
const isTrue = decided && Number(status) === 1;
|
||||||
|
const label = !decided ? '—' : isTrue ? 'True' : 'False';
|
||||||
|
const color = !decided ? '#64748b' : isTrue ? '#22c55e' : '#ef4444';
|
||||||
|
const bg = !decided ? 'rgba(100,116,139,0.12)' : isTrue ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)';
|
||||||
|
return (
|
||||||
|
<Box component="span" sx={{ display: 'inline-block', px: 1.25, py: 0.25, borderRadius: 1, fontSize: '0.72rem', fontWeight: 700, letterSpacing: 0.5, color, bgcolor: bg, border: `1px solid ${color}` }}>
|
||||||
|
{label}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const SKELETON_COLS = 11;
|
const SKELETON_COLS = 11;
|
||||||
|
|
||||||
export const AnomalyTable: React.FC = () => {
|
export const AnomalyTable: React.FC = () => {
|
||||||
@@ -116,20 +133,10 @@ export const AnomalyTable: React.FC = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle' }}>{assetName}</TableCell>
|
<TableCell sx={{ borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle' }}>{assetName}</TableCell>
|
||||||
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Side || 'N/A'}</TableCell>
|
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Side || 'N/A'}</TableCell>
|
||||||
<TableCell sx={{ width: '10%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle' }}>
|
<TableCell sx={{ width: '10%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', textAlign: 'center' }}>
|
||||||
<Box sx={{ width: '100%', minWidth: 60 }}>
|
<VerdictPill isAudited={anomaly.IsAudited} status={anomaly.Audit_status} />
|
||||||
<Slider
|
|
||||||
size="small"
|
|
||||||
value={anomaly.Audit_status || anomaly.anomaly_status_slider || 50}
|
|
||||||
step={100}
|
|
||||||
marks
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
sx={{ color: '#ff4d4f' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Features || 'N/A'}</TableCell>
|
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Audit_value || '—'}</TableCell>
|
||||||
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Comments || '-'}</TableCell>
|
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Comments || '-'}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Box, Typography, Button, Chip, Switch, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Select, MenuItem, TextField, CircularProgress, InputAdornment, ListItemButton } from '@mui/material';
|
import { Box, Typography, Button, Chip, Switch, Dialog, DialogTitle, DialogContent, DialogActions, Select, MenuItem, TextField, CircularProgress, InputAdornment, ListItemButton } from '@mui/material';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
import { ZoomableImage } from './ZoomableImage';
|
||||||
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||||
import { axiosClient } from '../../api/axiosClient';
|
import { axiosClient } from '../../api/axiosClient';
|
||||||
@@ -11,30 +10,33 @@ import { useToastStore } from '../../store/toastStore';
|
|||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { useFeedStore } from '../../store/feedStore';
|
import { useFeedStore } from '../../store/feedStore';
|
||||||
|
|
||||||
|
// key = displayed badge AND keyboard shortcut (0-9, first item = 0, matching
|
||||||
|
// the legacy UI). id stays the stable internal identity (1-10) that drives
|
||||||
|
// selection and the id-1 sub-menu logic - id and key deliberately diverge.
|
||||||
const SAFE_CATEGORIES = [
|
const SAFE_CATEGORIES = [
|
||||||
{ id: 1, value: "not an anomaly", label: "Not an Anomaly", color: "#10b981", key: "1" },
|
{ id: 1, value: "not an anomaly", label: "Not an Anomaly", color: "#10b981", key: "0" },
|
||||||
{ id: 2, value: "vehicle occlusion", label: "Vehicle Occlusion", color: "#3b82f6", key: "2" },
|
{ id: 2, value: "vehicle occlusion", label: "Vehicle Occlusion", color: "#3b82f6", key: "1" },
|
||||||
{ id: 3, value: "low light condition", label: "Low Light Condition", color: "#6366f1", key: "3" },
|
{ id: 3, value: "low light condition", label: "Low Light Condition", color: "#6366f1", key: "2" },
|
||||||
{ id: 4, value: "duplicate", label: "Duplicate", color: "#a855f7", key: "4" },
|
{ id: 4, value: "duplicate", label: "Duplicate", color: "#a855f7", key: "3" },
|
||||||
{ id: 5, value: "gps issue", label: "GPS Issue", color: "#ec4899", key: "5" },
|
{ id: 5, value: "gps issue", label: "GPS Issue", color: "#ec4899", key: "4" },
|
||||||
{ id: 6, value: "mismatch", label: "Mismatch", color: "#f43f5e", key: "6" },
|
{ id: 6, value: "mismatch", label: "Mismatch", color: "#f43f5e", key: "5" },
|
||||||
{ id: 7, value: "bad image", label: "Bad Image", color: "#f97316", key: "7" },
|
{ id: 7, value: "bad image", label: "Bad Image", color: "#f97316", key: "6" },
|
||||||
{ id: 8, value: "patch", label: "Patch", color: "#eab308", key: "8" },
|
{ id: 8, value: "patch", label: "Patch", color: "#eab308", key: "7" },
|
||||||
{ id: 9, value: "out of range", label: "Out of Range", color: "#84cc16", key: "9" },
|
{ id: 9, value: "out of range", label: "Out of Range", color: "#84cc16", key: "8" },
|
||||||
{ id: 10, value: "false others", label: "False Others", color: "#64748b", key: "0" }
|
{ id: 10, value: "false others", label: "False Others", color: "#64748b", key: "9" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const ANOMALY_CATEGORIES = [
|
const ANOMALY_CATEGORIES = [
|
||||||
{ id: 1, value: "plant", label: "Plant", color: "#10b981", key: "1" },
|
{ id: 1, value: "plant", label: "Plant", color: "#10b981", key: "0" },
|
||||||
{ id: 2, value: "bent", label: "Bent", color: "#3b82f6", key: "2" },
|
{ id: 2, value: "bent", label: "Bent", color: "#3b82f6", key: "1" },
|
||||||
{ id: 3, value: "broken", label: "Broken", color: "#ef4444", key: "3" },
|
{ id: 3, value: "broken", label: "Broken", color: "#ef4444", key: "2" },
|
||||||
{ id: 4, value: "missing", label: "Missing", color: "#f97316", key: "4" },
|
{ id: 4, value: "missing", label: "Missing", color: "#f97316", key: "3" },
|
||||||
{ id: 5, value: "plant overgrown", label: "Plant Overgrown", color: "#84cc16", key: "5" },
|
{ id: 5, value: "plant overgrown", label: "Plant Overgrown", color: "#84cc16", key: "4" },
|
||||||
{ id: 6, value: "paint worn off", label: "Paint Worn Off", color: "#eab308", key: "6" },
|
{ id: 6, value: "paint worn off", label: "Paint Worn Off", color: "#eab308", key: "5" },
|
||||||
{ id: 7, value: "dirt", label: "Dirt", color: "#78350f", key: "7" },
|
{ id: 7, value: "dirt", label: "Dirt", color: "#78350f", key: "6" },
|
||||||
{ id: 8, value: "not working", label: "Not Working", color: "#ec4899", key: "8" },
|
{ id: 8, value: "not working", label: "Not Working", color: "#ec4899", key: "7" },
|
||||||
{ id: 9, value: "others", label: "Others", color: "#64748b", key: "9" },
|
{ id: 9, value: "others", label: "Others", color: "#64748b", key: "8" },
|
||||||
{ id: 10, value: "occlusion", label: "Occlusion", color: "#6366f1", key: "0" }
|
{ id: 10, value: "occlusion", label: "Occlusion", color: "#6366f1", key: "9" }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Rectification FALSE verdicts - the only two the legacy flow accepts.
|
// Rectification FALSE verdicts - the only two the legacy flow accepts.
|
||||||
@@ -260,8 +262,6 @@ export const AuditSession: React.FC = () => {
|
|||||||
const [sessionPageNo, setSessionPageNo] = useState(() => location.state?.startPage || 0);
|
const [sessionPageNo, setSessionPageNo] = useState(() => location.state?.startPage || 0);
|
||||||
const hasInitializedSession = useRef(false);
|
const hasInitializedSession = useRef(false);
|
||||||
|
|
||||||
const [masterZoom, setMasterZoom] = useState(1);
|
|
||||||
const [anomalyZoom, setAnomalyZoom] = useState(1);
|
|
||||||
|
|
||||||
const activeTab = tabs[selectedTabIndex] || { date: '', isRectificationTab: false, page: 0, rowsPerPage: 20 };
|
const activeTab = tabs[selectedTabIndex] || { date: '', isRectificationTab: false, page: 0, rowsPerPage: 20 };
|
||||||
const activeDate = activeTab.date || '';
|
const activeDate = activeTab.date || '';
|
||||||
@@ -347,7 +347,7 @@ export const AuditSession: React.FC = () => {
|
|||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setCarouselIndex(0);
|
setCarouselIndex(0);
|
||||||
setSelectedCategory(null);
|
setSelectedCategory(null);
|
||||||
setNotes('');
|
setNotes(res.anomalies[0]?.Comments || '');
|
||||||
setIsAnomaly(null);
|
setIsAnomaly(null);
|
||||||
setPlantSubCategory(null);
|
setPlantSubCategory(null);
|
||||||
setNotAnAnomalySubCategory(null);
|
setNotAnAnomalySubCategory(null);
|
||||||
@@ -405,12 +405,10 @@ export const AuditSession: React.FC = () => {
|
|||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setCarouselIndex(0);
|
setCarouselIndex(0);
|
||||||
setSelectedCategory(null);
|
setSelectedCategory(null);
|
||||||
setNotes('');
|
setNotes(res.anomalies[0]?.Comments || '');
|
||||||
setIsAnomaly(null);
|
setIsAnomaly(null);
|
||||||
setPlantSubCategory(null);
|
setPlantSubCategory(null);
|
||||||
setNotAnAnomalySubCategory(null);
|
setNotAnAnomalySubCategory(null);
|
||||||
setMasterZoom(1);
|
|
||||||
setAnomalyZoom(1);
|
|
||||||
setStagedAudits({});
|
setStagedAudits({});
|
||||||
setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending'));
|
setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending'));
|
||||||
} else {
|
} else {
|
||||||
@@ -453,7 +451,7 @@ export const AuditSession: React.FC = () => {
|
|||||||
setCurrentIndex(location.state.startIndex || 0);
|
setCurrentIndex(location.state.startIndex || 0);
|
||||||
setCarouselIndex(0);
|
setCarouselIndex(0);
|
||||||
setSelectedCategory(null);
|
setSelectedCategory(null);
|
||||||
setNotes('');
|
setNotes(location.state.anomalies[location.state.startIndex || 0]?.Comments || '');
|
||||||
setIsAnomaly(null);
|
setIsAnomaly(null);
|
||||||
setPlantSubCategory(null);
|
setPlantSubCategory(null);
|
||||||
setNotAnAnomalySubCategory(null);
|
setNotAnAnomalySubCategory(null);
|
||||||
@@ -484,6 +482,19 @@ export const AuditSession: React.FC = () => {
|
|||||||
// itself.
|
// itself.
|
||||||
const isLocked = currentAnomaly?.IsAudited === 1 || itemAuditStates[currentIndex] === 'audited';
|
const isLocked = currentAnomaly?.IsAudited === 1 || itemAuditStates[currentIndex] === 'audited';
|
||||||
|
|
||||||
|
// Seed the NOTES box with the record's existing Comment (the algorithm's
|
||||||
|
// "Type: … Area …") whenever the shown record changes, so it's visible and
|
||||||
|
// editable. Keyed on the record id so it doesn't clobber the auditor's
|
||||||
|
// typing (that doesn't change the id). Staged records keep their staged
|
||||||
|
// note; locked records render Comments directly via the textarea, so both
|
||||||
|
// are skipped here.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentAnomaly || isLocked) return;
|
||||||
|
if (stagedAudits[currentAnomaly.id]) return;
|
||||||
|
setNotes(currentAnomaly.Comments || '');
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentAnomaly?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentAnomaly?.images && currentAnomaly.images.length > 0) {
|
if (currentAnomaly?.images && currentAnomaly.images.length > 0) {
|
||||||
const selectedIdx = currentAnomaly.images.findIndex((img: any) => img.selected);
|
const selectedIdx = currentAnomaly.images.findIndex((img: any) => img.selected);
|
||||||
@@ -491,8 +502,6 @@ export const AuditSession: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
setCarouselIndex(0);
|
setCarouselIndex(0);
|
||||||
}
|
}
|
||||||
setMasterZoom(1);
|
|
||||||
setAnomalyZoom(1);
|
|
||||||
}, [currentAnomaly]);
|
}, [currentAnomaly]);
|
||||||
|
|
||||||
let masterImageSrc = '';
|
let masterImageSrc = '';
|
||||||
@@ -565,13 +574,11 @@ export const AuditSession: React.FC = () => {
|
|||||||
setNotAnAnomalySubCategory(staged.notAnAnomalySub);
|
setNotAnAnomalySubCategory(staged.notAnAnomalySub);
|
||||||
} else {
|
} else {
|
||||||
setSelectedCategory(null);
|
setSelectedCategory(null);
|
||||||
setNotes('');
|
setNotes(list[idx]?.Comments || '');
|
||||||
setIsAnomaly(null);
|
setIsAnomaly(null);
|
||||||
setPlantSubCategory(null);
|
setPlantSubCategory(null);
|
||||||
setNotAnAnomalySubCategory(null);
|
setNotAnAnomalySubCategory(null);
|
||||||
}
|
}
|
||||||
setMasterZoom(1);
|
|
||||||
setAnomalyZoom(1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
@@ -979,9 +986,10 @@ export const AuditSession: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Records "Audit All" would touch: everything except locked ones (already
|
// Records "Audit All" would touch: everything except locked ones (already
|
||||||
// audited server-side or completed earlier this session).
|
// audited server-side or completed earlier this session) and records the
|
||||||
|
// auditor intentionally Skipped.
|
||||||
const auditAllTargetCount = anomalies.reduce((n, a, i) =>
|
const auditAllTargetCount = anomalies.reduce((n, a, i) =>
|
||||||
n + ((a.IsAudited === 1 || itemAuditStates[i] === 'audited') ? 0 : 1), 0);
|
n + ((a.IsAudited === 1 || itemAuditStates[i] === 'audited' || itemAuditStates[i] === 'skipped') ? 0 : 1), 0);
|
||||||
|
|
||||||
const isAuditAllVerdictValid = isRectActive
|
const isAuditAllVerdictValid = isRectActive
|
||||||
? (aaIsAnomaly || aaCategory !== null)
|
? (aaIsAnomaly || aaCategory !== null)
|
||||||
@@ -1012,7 +1020,8 @@ export const AuditSession: React.FC = () => {
|
|||||||
const nextStates = [...itemAuditStates];
|
const nextStates = [...itemAuditStates];
|
||||||
let stagedNow = 0;
|
let stagedNow = 0;
|
||||||
anomalies.forEach((a, i) => {
|
anomalies.forEach((a, i) => {
|
||||||
if (a.IsAudited === 1 || itemAuditStates[i] === 'audited') return;
|
// Skipped records were intentionally passed over - don't bulk-stage them.
|
||||||
|
if (a.IsAudited === 1 || itemAuditStates[i] === 'audited' || itemAuditStates[i] === 'skipped') return;
|
||||||
nextStaged[a.id] = { ...verdict };
|
nextStaged[a.id] = { ...verdict };
|
||||||
nextStates[i] = 'staged';
|
nextStates[i] = 'staged';
|
||||||
stagedNow++;
|
stagedNow++;
|
||||||
@@ -1023,7 +1032,7 @@ export const AuditSession: React.FC = () => {
|
|||||||
// Keep the visible per-record form in sync when the current record was
|
// 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.
|
// part of the sweep, so what's on screen matches what got staged.
|
||||||
const cur = anomalies[currentIndex];
|
const cur = anomalies[currentIndex];
|
||||||
if (cur && nextStaged[cur.id] && !(cur.IsAudited === 1 || itemAuditStates[currentIndex] === 'audited')) {
|
if (cur && nextStaged[cur.id] && !(cur.IsAudited === 1 || itemAuditStates[currentIndex] === 'audited' || itemAuditStates[currentIndex] === 'skipped')) {
|
||||||
setIsAnomaly(verdict.isAnomaly);
|
setIsAnomaly(verdict.isAnomaly);
|
||||||
setSelectedCategory(verdict.category);
|
setSelectedCategory(verdict.category);
|
||||||
setNotes(verdict.notes);
|
setNotes(verdict.notes);
|
||||||
@@ -1168,7 +1177,7 @@ export const AuditSession: React.FC = () => {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setSelectedCategory(null);
|
setSelectedCategory(null);
|
||||||
setNotes('');
|
setNotes(currentAnomaly?.Comments || '');
|
||||||
setIsAnomaly(null);
|
setIsAnomaly(null);
|
||||||
setPlantSubCategory(null);
|
setPlantSubCategory(null);
|
||||||
setNotAnAnomalySubCategory(null);
|
setNotAnAnomalySubCategory(null);
|
||||||
@@ -1232,7 +1241,7 @@ export const AuditSession: React.FC = () => {
|
|||||||
if (currentIndex < anomalies.length - 1) {
|
if (currentIndex < anomalies.length - 1) {
|
||||||
setCurrentIndex(prev => prev + 1);
|
setCurrentIndex(prev => prev + 1);
|
||||||
setSelectedCategory(null);
|
setSelectedCategory(null);
|
||||||
setNotes('');
|
setNotes(anomalies[currentIndex + 1]?.Comments || '');
|
||||||
setIsAnomaly(null);
|
setIsAnomaly(null);
|
||||||
setPlantSubCategory(null);
|
setPlantSubCategory(null);
|
||||||
setNotAnAnomalySubCategory(null);
|
setNotAnAnomalySubCategory(null);
|
||||||
@@ -1300,26 +1309,25 @@ export const AuditSession: React.FC = () => {
|
|||||||
case '8':
|
case '8':
|
||||||
case '9':
|
case '9':
|
||||||
case '0': {
|
case '0': {
|
||||||
const keyMap: { [key: string]: number } = {
|
|
||||||
'1': 1, '2': 2, '3': 3, '4': 4, '5': 5,
|
|
||||||
'6': 6, '7': 7, '8': 8, '9': 9, '0': 10
|
|
||||||
};
|
|
||||||
const catId = keyMap[key];
|
|
||||||
|
|
||||||
// Rectification: TRUE has no categories (digits ignored); FALSE only
|
// Rectification: TRUE has no categories (digits ignored); FALSE only
|
||||||
// accepts 1/2 (true others / false others). Nested sub-category
|
// accepts its two options. Nested sub-category handling below never
|
||||||
// handling below never applies in rect mode.
|
// applies in rect mode.
|
||||||
if (isRectActive) {
|
if (isRectActive) {
|
||||||
if (isAnomaly === false && (key === '1' || key === '2')) {
|
if (isAnomaly === false) {
|
||||||
setSelectedCategory(catId);
|
const c = RECT_SAFE_CATEGORIES.find(x => x.key === key);
|
||||||
|
if (c) {
|
||||||
|
setSelectedCategory(c.id);
|
||||||
if (isQuickAudit && !isBulkMode) {
|
if (isQuickAudit && !isBulkMode) {
|
||||||
setTimeout(() => handleQuickSaveAndNext(false, catId), 120);
|
setTimeout(() => handleQuickSaveAndNext(false, c.id), 120);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special nested case: Plant is already active (under Anomaly)
|
// Special nested case: Plant is already active (under Anomaly).
|
||||||
|
// Sub-menus keep literal keys 1/2 and take precedence over the
|
||||||
|
// general category lookup below.
|
||||||
if (isAnomaly === true && selectedCategory === 1) {
|
if (isAnomaly === true && selectedCategory === 1) {
|
||||||
if (key === '1') {
|
if (key === '1') {
|
||||||
setPlantSubCategory('in grown');
|
setPlantSubCategory('in grown');
|
||||||
@@ -1355,13 +1363,20 @@ export const AuditSession: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// General category select: resolve the pressed key to a category via
|
||||||
|
// the active list's `key` (0-9), since id and key now diverge.
|
||||||
|
const activeList = isAnomaly === false ? SAFE_CATEGORIES : ANOMALY_CATEGORIES;
|
||||||
|
const matched = activeList.find(x => x.key === key);
|
||||||
|
if (!matched) break;
|
||||||
|
const catId = matched.id;
|
||||||
|
|
||||||
const currentIsAnomaly = isAnomaly === null ? true : isAnomaly;
|
const currentIsAnomaly = isAnomaly === null ? true : isAnomaly;
|
||||||
if (isAnomaly === null) {
|
if (isAnomaly === null) {
|
||||||
setIsAnomaly(true);
|
setIsAnomaly(true);
|
||||||
}
|
}
|
||||||
setSelectedCategory(catId);
|
setSelectedCategory(catId);
|
||||||
|
|
||||||
// If selecting Plant (Anomaly 1) or Not an Anomaly (Safe 1), do not auto-advance; wait for nested sub-options
|
// Plant (Anomaly id 1) / Not an Anomaly (Safe id 1): do not auto-advance; wait for nested sub-options
|
||||||
if (catId === 1) {
|
if (catId === 1) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1375,7 +1390,9 @@ export const AuditSession: React.FC = () => {
|
|||||||
if (selectedCategory !== null || (isRectActive && isAnomaly === true)) {
|
if (selectedCategory !== null || (isRectActive && isAnomaly === true)) {
|
||||||
handleSaveAndNext();
|
handleSaveAndNext();
|
||||||
} else {
|
} else {
|
||||||
handleSkip();
|
// No verdict: plain Next (advance without marking Skipped - only the
|
||||||
|
// explicit Skip button / Tab marks a record Skipped).
|
||||||
|
handleNext();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'arrowright':
|
case 'arrowright':
|
||||||
@@ -1612,32 +1629,9 @@ export const AuditSession: React.FC = () => {
|
|||||||
<Box sx={{ px: 2, py: 1, borderBottom: '1px solid #1e293b', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box sx={{ px: 2, py: 1, borderBottom: '1px solid #1e293b', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 'bold', color: '#94a3b8', letterSpacing: 1 }}>MASTER IMAGE</Typography>
|
<Typography sx={{ fontSize: '0.75rem', fontWeight: 'bold', color: '#94a3b8', letterSpacing: 1 }}>MASTER IMAGE</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: 1, position: 'relative', overflow: 'auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||||
<Box sx={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 1, zIndex: 10 }}>
|
|
||||||
<IconButton size="small" onClick={() => setMasterZoom(z => z + 0.5)} sx={{ bgcolor: 'rgba(0,0,0,0.5)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }}>
|
|
||||||
<ZoomInIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton size="small" onClick={() => setMasterZoom(z => Math.max(1, z - 0.5))} sx={{ bgcolor: 'rgba(0,0,0,0.5)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }}>
|
|
||||||
<ZoomOutIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
{masterImageSrc ? (
|
{masterImageSrc ? (
|
||||||
<img
|
<ZoomableImage src={masterImageSrc} alt="Master" fallback="/assets/images/master_image_not_available.png" />
|
||||||
src={masterImageSrc}
|
|
||||||
alt="Master"
|
|
||||||
style={{
|
|
||||||
width: masterZoom === 1 ? '100%' : `${masterZoom * 100}%`,
|
|
||||||
height: masterZoom === 1 ? '100%' : 'auto',
|
|
||||||
objectFit: masterZoom === 1 ? 'contain' : 'cover',
|
|
||||||
transition: 'width 0.2s ease, height 0.2s ease',
|
|
||||||
transformOrigin: 'top left'
|
|
||||||
}}
|
|
||||||
onError={(e) => {
|
|
||||||
if (!e.currentTarget.src.includes('master_image_not_available.png')) {
|
|
||||||
e.currentTarget.src = '/assets/images/master_image_not_available.png';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#334155' }}>No Master Image</Box>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#334155' }}>No Master Image</Box>
|
||||||
)}
|
)}
|
||||||
@@ -1650,40 +1644,20 @@ export const AuditSession: React.FC = () => {
|
|||||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 'bold', color: '#60a5fa', letterSpacing: 1 }}>ANOMALY IMAGE</Typography>
|
<Typography sx={{ fontSize: '0.75rem', fontWeight: 'bold', color: '#60a5fa', letterSpacing: 1 }}>ANOMALY IMAGE</Typography>
|
||||||
<Typography sx={{ fontSize: '0.75rem', color: '#93c5fd' }}>{currentAnomaly?.date_of_audit || currentAnomaly?.Created_on}</Typography>
|
<Typography sx={{ fontSize: '0.75rem', color: '#93c5fd' }}>{currentAnomaly?.date_of_audit || currentAnomaly?.Created_on}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: 1, position: 'relative', overflow: 'auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Box sx={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 1, zIndex: 10 }}>
|
|
||||||
<IconButton size="small" onClick={() => setAnomalyZoom(z => z + 0.5)} sx={{ bgcolor: 'rgba(0,0,0,0.5)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }}>
|
|
||||||
<ZoomInIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton size="small" onClick={() => setAnomalyZoom(z => Math.max(1, z - 0.5))} sx={{ bgcolor: 'rgba(0,0,0,0.5)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }}>
|
|
||||||
<ZoomOutIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
{mediaItems.length > 0 ? (
|
{mediaItems.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{mediaItems[carouselIndex]?.type === 'video' ? (
|
{mediaItems[carouselIndex]?.type === 'video' ? (
|
||||||
<video src={mediaItems[carouselIndex]?.url} controls autoPlay loop style={{
|
<video src={mediaItems[carouselIndex]?.url} controls autoPlay loop style={{
|
||||||
width: anomalyZoom === 1 ? '100%' : `${anomalyZoom * 100}%`,
|
width: '100%',
|
||||||
height: anomalyZoom === 1 ? '100%' : 'auto',
|
height: '100%',
|
||||||
objectFit: anomalyZoom === 1 ? 'contain' : 'cover',
|
objectFit: 'contain'
|
||||||
transition: 'width 0.2s ease, height 0.2s ease'
|
|
||||||
}} />
|
}} />
|
||||||
) : (
|
) : (
|
||||||
<img
|
<ZoomableImage
|
||||||
src={mediaItems[carouselIndex]?.url}
|
src={mediaItems[carouselIndex]?.url}
|
||||||
alt="Anomaly"
|
alt="Anomaly"
|
||||||
style={{
|
fallback="/assets/images/anomaly-placeholder.png"
|
||||||
width: anomalyZoom === 1 ? '100%' : `${anomalyZoom * 100}%`,
|
|
||||||
height: anomalyZoom === 1 ? '100%' : 'auto',
|
|
||||||
objectFit: anomalyZoom === 1 ? 'contain' : 'cover',
|
|
||||||
transition: 'width 0.2s ease, height 0.2s ease',
|
|
||||||
transformOrigin: 'top left'
|
|
||||||
}}
|
|
||||||
onError={(e) => {
|
|
||||||
if (!e.currentTarget.src.includes('anomaly-placeholder.png')) {
|
|
||||||
e.currentTarget.src = '/assets/images/anomaly-placeholder.png';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2076,7 +2050,7 @@ export const AuditSession: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color={!isLocked && saveReady ? "primary" : "inherit"}
|
color={!isLocked && saveReady ? "primary" : "inherit"}
|
||||||
onClick={isLocked ? handleNext : saveReady ? handleSaveAndNext : handleSkip}
|
onClick={isLocked ? handleNext : saveReady ? handleSaveAndNext : handleNext}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
py: 1.5,
|
py: 1.5,
|
||||||
@@ -2092,7 +2066,7 @@ export const AuditSession: React.FC = () => {
|
|||||||
'&:hover': { bgcolor: saveReady ? '' : '#334155' }
|
'&:hover': { bgcolor: saveReady ? '' : '#334155' }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLocked ? "Next (Read-only)" : saveReady ? (isBulkMode ? "Stage & Next" : "Save & Next") : "Next (Skip)"} <Typography component="span" sx={{ fontSize: '0.75rem', opacity: 0.7 }}>Enter</Typography>
|
{isLocked ? "Next (Read-only)" : saveReady ? (isBulkMode ? "Stage & Next" : "Save & Next") : "Next"} <Typography component="span" sx={{ fontSize: '0.75rem', opacity: 0.7 }}>Enter</Typography>
|
||||||
</Button>
|
</Button>
|
||||||
); })()}
|
); })()}
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
@@ -2151,7 +2125,7 @@ export const AuditSession: React.FC = () => {
|
|||||||
<Typography sx={{ fontSize: '0.75rem', color: '#94a3b8' }}>Safe</Typography>
|
<Typography sx={{ fontSize: '0.75rem', color: '#94a3b8' }}>Safe</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Chip label={isRectActive ? "1-2" : "1-5"} size="small" sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#1e293b', color: '#cbd5e1' }} />
|
<Chip label={isRectActive ? "1-2" : "0-9"} size="small" sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#1e293b', color: '#cbd5e1' }} />
|
||||||
<Typography sx={{ fontSize: '0.75rem', color: '#94a3b8' }}>Category</Typography>
|
<Typography sx={{ fontSize: '0.75rem', color: '#94a3b8' }}>Category</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
130
src/pages/audit-session/ZoomableImage.tsx
Normal file
130
src/pages/audit-session/ZoomableImage.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Box, IconButton } from '@mui/material';
|
||||||
|
import ZoomInIcon from '@mui/icons-material/ZoomIn';
|
||||||
|
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
|
||||||
|
|
||||||
|
const MIN = 1;
|
||||||
|
const MAX = 6;
|
||||||
|
const WHEEL_STEP = 0.3;
|
||||||
|
const BUTTON_STEP = 0.5;
|
||||||
|
|
||||||
|
interface ZoomableImageProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
fallback: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspection zoom: scroll wheel zooms toward the cursor and, while zoomed,
|
||||||
|
// moving the mouse pans the view (the transform-origin tracks the pointer).
|
||||||
|
// Fills its parent; the parent should be position:relative + overflow:hidden.
|
||||||
|
export const ZoomableImage: React.FC<ZoomableImageProps> = ({ src, alt, fallback }) => {
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [origin, setOrigin] = useState({ x: 50, y: 50 });
|
||||||
|
|
||||||
|
// Reset when the image itself changes (navigating records / carousel).
|
||||||
|
useEffect(() => {
|
||||||
|
setZoom(1);
|
||||||
|
setOrigin({ x: 50, y: 50 });
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
// Native, non-passive wheel listener - React's onWheel is passive, so
|
||||||
|
// preventDefault (to stop the page scrolling while zooming) is ignored there.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = wrapRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setOrigin({
|
||||||
|
x: ((e.clientX - rect.left) / rect.width) * 100,
|
||||||
|
y: ((e.clientY - rect.top) / rect.height) * 100,
|
||||||
|
});
|
||||||
|
setZoom((z) => Math.min(MAX, Math.max(MIN, +(z + (e.deltaY < 0 ? WHEEL_STEP : -WHEEL_STEP)).toFixed(2))));
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMove = (e: React.MouseEvent) => {
|
||||||
|
if (zoom === 1) return; // no panning at fit scale
|
||||||
|
const el = wrapRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setOrigin({
|
||||||
|
x: ((e.clientX - rect.left) / rect.width) * 100,
|
||||||
|
y: ((e.clientY - rect.top) / rect.height) * 100,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={wrapRef}
|
||||||
|
onMouseMove={handleMove}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: zoom > 1 ? 'move' : 'zoom-in',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
transform: `scale(${zoom})`,
|
||||||
|
transformOrigin: `${origin.x}% ${origin.y}%`,
|
||||||
|
transition: 'transform 0.08s ease-out',
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
if (!e.currentTarget.src.includes(fallback)) {
|
||||||
|
e.currentTarget.src = fallback;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 1, zIndex: 10 }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setZoom((z) => Math.min(MAX, +(z + BUTTON_STEP).toFixed(2)))}
|
||||||
|
sx={{ bgcolor: 'rgba(0,0,0,0.5)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }}
|
||||||
|
>
|
||||||
|
<ZoomInIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setZoom((z) => Math.max(MIN, +(z - BUTTON_STEP).toFixed(2)))}
|
||||||
|
sx={{ bgcolor: 'rgba(0,0,0,0.5)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }}
|
||||||
|
>
|
||||||
|
<ZoomOutIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
{zoom > 1 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 8,
|
||||||
|
right: 8,
|
||||||
|
px: 1,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: 'rgba(0,0,0,0.6)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
zIndex: 10,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{zoom.toFixed(1)}×
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user