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:
2026-07-21 16:04:31 +05:30
parent ee25bb6065
commit c7c3d46c62
4 changed files with 236 additions and 125 deletions

View File

@@ -1,2 +1,2 @@
# 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

View File

@@ -1,7 +1,24 @@
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';
// 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;
export const AnomalyTable: React.FC = () => {
@@ -116,20 +133,10 @@ export const AnomalyTable: React.FC = () => {
</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: '10%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle' }}>
<Box sx={{ width: '100%', minWidth: 60 }}>
<Slider
size="small"
value={anomaly.Audit_status || anomaly.anomaly_status_slider || 50}
step={100}
marks
min={0}
max={100}
sx={{ color: '#ff4d4f' }}
/>
</Box>
<TableCell sx={{ width: '10%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', textAlign: 'center' }}>
<VerdictPill isAudited={anomaly.IsAudited} status={anomaly.Audit_status} />
</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>
</TableRow>
);

View File

@@ -1,9 +1,8 @@
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 ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
import { ZoomableImage } from './ZoomableImage';
import SearchIcon from '@mui/icons-material/Search';
import { activityFeedsService } from '../../api/activityFeedsService';
import { axiosClient } from '../../api/axiosClient';
@@ -11,30 +10,33 @@ import { useToastStore } from '../../store/toastStore';
import { useAuthStore } from '../../store/authStore';
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 = [
{ id: 1, value: "not an anomaly", label: "Not an Anomaly", color: "#10b981", key: "1" },
{ id: 2, value: "vehicle occlusion", label: "Vehicle Occlusion", color: "#3b82f6", key: "2" },
{ id: 3, value: "low light condition", label: "Low Light Condition", color: "#6366f1", key: "3" },
{ id: 4, value: "duplicate", label: "Duplicate", color: "#a855f7", key: "4" },
{ id: 5, value: "gps issue", label: "GPS Issue", color: "#ec4899", key: "5" },
{ id: 6, value: "mismatch", label: "Mismatch", color: "#f43f5e", key: "6" },
{ id: 7, value: "bad image", label: "Bad Image", color: "#f97316", key: "7" },
{ id: 8, value: "patch", label: "Patch", color: "#eab308", key: "8" },
{ id: 9, value: "out of range", label: "Out of Range", color: "#84cc16", key: "9" },
{ id: 10, value: "false others", label: "False Others", color: "#64748b", key: "0" }
{ 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: "1" },
{ id: 3, value: "low light condition", label: "Low Light Condition", color: "#6366f1", key: "2" },
{ id: 4, value: "duplicate", label: "Duplicate", color: "#a855f7", key: "3" },
{ id: 5, value: "gps issue", label: "GPS Issue", color: "#ec4899", key: "4" },
{ id: 6, value: "mismatch", label: "Mismatch", color: "#f43f5e", key: "5" },
{ id: 7, value: "bad image", label: "Bad Image", color: "#f97316", key: "6" },
{ id: 8, value: "patch", label: "Patch", color: "#eab308", key: "7" },
{ 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: "9" }
];
const ANOMALY_CATEGORIES = [
{ id: 1, value: "plant", label: "Plant", color: "#10b981", key: "1" },
{ id: 2, value: "bent", label: "Bent", color: "#3b82f6", key: "2" },
{ id: 3, value: "broken", label: "Broken", color: "#ef4444", key: "3" },
{ id: 4, value: "missing", label: "Missing", color: "#f97316", key: "4" },
{ id: 5, value: "plant overgrown", label: "Plant Overgrown", color: "#84cc16", key: "5" },
{ id: 6, value: "paint worn off", label: "Paint Worn Off", color: "#eab308", key: "6" },
{ id: 7, value: "dirt", label: "Dirt", color: "#78350f", key: "7" },
{ id: 8, value: "not working", label: "Not Working", color: "#ec4899", key: "8" },
{ id: 9, value: "others", label: "Others", color: "#64748b", key: "9" },
{ id: 10, value: "occlusion", label: "Occlusion", color: "#6366f1", key: "0" }
{ id: 1, value: "plant", label: "Plant", color: "#10b981", key: "0" },
{ id: 2, value: "bent", label: "Bent", color: "#3b82f6", key: "1" },
{ id: 3, value: "broken", label: "Broken", color: "#ef4444", key: "2" },
{ id: 4, value: "missing", label: "Missing", color: "#f97316", key: "3" },
{ 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: "5" },
{ id: 7, value: "dirt", label: "Dirt", color: "#78350f", key: "6" },
{ id: 8, value: "not working", label: "Not Working", color: "#ec4899", key: "7" },
{ id: 9, value: "others", label: "Others", color: "#64748b", key: "8" },
{ id: 10, value: "occlusion", label: "Occlusion", color: "#6366f1", key: "9" }
];
// 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 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 activeDate = activeTab.date || '';
@@ -347,7 +347,7 @@ export const AuditSession: React.FC = () => {
setCurrentIndex(0);
setCarouselIndex(0);
setSelectedCategory(null);
setNotes('');
setNotes(res.anomalies[0]?.Comments || '');
setIsAnomaly(null);
setPlantSubCategory(null);
setNotAnAnomalySubCategory(null);
@@ -405,12 +405,10 @@ export const AuditSession: React.FC = () => {
setCurrentIndex(0);
setCarouselIndex(0);
setSelectedCategory(null);
setNotes('');
setNotes(res.anomalies[0]?.Comments || '');
setIsAnomaly(null);
setPlantSubCategory(null);
setNotAnAnomalySubCategory(null);
setMasterZoom(1);
setAnomalyZoom(1);
setStagedAudits({});
setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending'));
} else {
@@ -453,7 +451,7 @@ export const AuditSession: React.FC = () => {
setCurrentIndex(location.state.startIndex || 0);
setCarouselIndex(0);
setSelectedCategory(null);
setNotes('');
setNotes(location.state.anomalies[location.state.startIndex || 0]?.Comments || '');
setIsAnomaly(null);
setPlantSubCategory(null);
setNotAnAnomalySubCategory(null);
@@ -484,6 +482,19 @@ export const AuditSession: React.FC = () => {
// itself.
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(() => {
if (currentAnomaly?.images && currentAnomaly.images.length > 0) {
const selectedIdx = currentAnomaly.images.findIndex((img: any) => img.selected);
@@ -491,8 +502,6 @@ export const AuditSession: React.FC = () => {
} else {
setCarouselIndex(0);
}
setMasterZoom(1);
setAnomalyZoom(1);
}, [currentAnomaly]);
let masterImageSrc = '';
@@ -565,13 +574,11 @@ export const AuditSession: React.FC = () => {
setNotAnAnomalySubCategory(staged.notAnAnomalySub);
} else {
setSelectedCategory(null);
setNotes('');
setNotes(list[idx]?.Comments || '');
setIsAnomaly(null);
setPlantSubCategory(null);
setNotAnAnomalySubCategory(null);
}
setMasterZoom(1);
setAnomalyZoom(1);
};
const handleNext = () => {
@@ -979,9 +986,10 @@ export const AuditSession: React.FC = () => {
};
// 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) =>
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
? (aaIsAnomaly || aaCategory !== null)
@@ -1012,7 +1020,8 @@ export const AuditSession: React.FC = () => {
const nextStates = [...itemAuditStates];
let stagedNow = 0;
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 };
nextStates[i] = 'staged';
stagedNow++;
@@ -1023,7 +1032,7 @@ export const AuditSession: React.FC = () => {
// 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.
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);
setSelectedCategory(verdict.category);
setNotes(verdict.notes);
@@ -1168,7 +1177,7 @@ export const AuditSession: React.FC = () => {
return next;
});
setSelectedCategory(null);
setNotes('');
setNotes(currentAnomaly?.Comments || '');
setIsAnomaly(null);
setPlantSubCategory(null);
setNotAnAnomalySubCategory(null);
@@ -1232,7 +1241,7 @@ export const AuditSession: React.FC = () => {
if (currentIndex < anomalies.length - 1) {
setCurrentIndex(prev => prev + 1);
setSelectedCategory(null);
setNotes('');
setNotes(anomalies[currentIndex + 1]?.Comments || '');
setIsAnomaly(null);
setPlantSubCategory(null);
setNotAnAnomalySubCategory(null);
@@ -1300,26 +1309,25 @@ export const AuditSession: React.FC = () => {
case '8':
case '9':
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
// accepts 1/2 (true others / false others). Nested sub-category
// handling below never applies in rect mode.
// accepts its two options. Nested sub-category handling below never
// applies in rect mode.
if (isRectActive) {
if (isAnomaly === false && (key === '1' || key === '2')) {
setSelectedCategory(catId);
if (isAnomaly === false) {
const c = RECT_SAFE_CATEGORIES.find(x => x.key === key);
if (c) {
setSelectedCategory(c.id);
if (isQuickAudit && !isBulkMode) {
setTimeout(() => handleQuickSaveAndNext(false, catId), 120);
setTimeout(() => handleQuickSaveAndNext(false, c.id), 120);
}
}
}
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 (key === '1') {
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;
if (isAnomaly === null) {
setIsAnomaly(true);
}
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) {
break;
}
@@ -1375,7 +1390,9 @@ export const AuditSession: React.FC = () => {
if (selectedCategory !== null || (isRectActive && isAnomaly === true)) {
handleSaveAndNext();
} else {
handleSkip();
// No verdict: plain Next (advance without marking Skipped - only the
// explicit Skip button / Tab marks a record Skipped).
handleNext();
}
break;
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' }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 'bold', color: '#94a3b8', letterSpacing: 1 }}>MASTER IMAGE</Typography>
</Box>
<Box sx={{ flex: 1, position: 'relative', overflow: 'auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<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>
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
{masterImageSrc ? (
<img
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';
}
}}
/>
<ZoomableImage src={masterImageSrc} alt="Master" fallback="/assets/images/master_image_not_available.png" />
) : (
<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', color: '#93c5fd' }}>{currentAnomaly?.date_of_audit || currentAnomaly?.Created_on}</Typography>
</Box>
<Box sx={{ flex: 1, position: 'relative', overflow: 'auto', 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>
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{mediaItems.length > 0 ? (
<>
{mediaItems[carouselIndex]?.type === 'video' ? (
<video src={mediaItems[carouselIndex]?.url} controls autoPlay loop style={{
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'
width: '100%',
height: '100%',
objectFit: 'contain'
}} />
) : (
<img
<ZoomableImage
src={mediaItems[carouselIndex]?.url}
alt="Anomaly"
style={{
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';
}
}}
fallback="/assets/images/anomaly-placeholder.png"
/>
)}
@@ -2076,7 +2050,7 @@ export const AuditSession: React.FC = () => {
<Button
variant="contained"
color={!isLocked && saveReady ? "primary" : "inherit"}
onClick={isLocked ? handleNext : saveReady ? handleSaveAndNext : handleSkip}
onClick={isLocked ? handleNext : saveReady ? handleSaveAndNext : handleNext}
sx={{
borderRadius: 2,
py: 1.5,
@@ -2092,7 +2066,7 @@ export const AuditSession: React.FC = () => {
'&: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>
); })()}
<Box sx={{ display: 'flex', gap: 1 }}>
@@ -2151,7 +2125,7 @@ export const AuditSession: React.FC = () => {
<Typography sx={{ fontSize: '0.75rem', color: '#94a3b8' }}>Safe</Typography>
</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>
</Box>
<Box>

View 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>
);
};