diff --git a/.env.production b/.env.production
index 6d0c216..f9729d8 100644
--- a/.env.production
+++ b/.env.production
@@ -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
diff --git a/src/pages/activity-feeds/AnomalyTable.tsx b/src/pages/activity-feeds/AnomalyTable.tsx
index 807eee6..8029cb0 100644
--- a/src/pages/activity-feeds/AnomalyTable.tsx
+++ b/src/pages/activity-feeds/AnomalyTable.tsx
@@ -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 (
+
+ {label}
+
+ );
+};
+
const SKELETON_COLS = 11;
export const AnomalyTable: React.FC = () => {
@@ -116,20 +133,10 @@ export const AnomalyTable: React.FC = () => {
{assetName}
{anomaly.Side || 'N/A'}
-
-
-
-
+
+
- {anomaly.Features || 'N/A'}
+ {anomaly.Audit_value || '—'}
{anomaly.Comments || '-'}
);
diff --git a/src/pages/audit-session/AuditSession.tsx b/src/pages/audit-session/AuditSession.tsx
index e3415e7..0ad2b3c 100644
--- a/src/pages/audit-session/AuditSession.tsx
+++ b/src/pages/audit-session/AuditSession.tsx
@@ -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 (isQuickAudit && !isBulkMode) {
- setTimeout(() => handleQuickSaveAndNext(false, catId), 120);
+ if (isAnomaly === false) {
+ const c = RECT_SAFE_CATEGORIES.find(x => x.key === key);
+ if (c) {
+ setSelectedCategory(c.id);
+ if (isQuickAudit && !isBulkMode) {
+ 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 = () => {
MASTER IMAGE
-
-
- setMasterZoom(z => z + 0.5)} sx={{ bgcolor: 'rgba(0,0,0,0.5)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }}>
-
-
- 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)' } }}>
-
-
-
+
{masterImageSrc ? (
-
{
- if (!e.currentTarget.src.includes('master_image_not_available.png')) {
- e.currentTarget.src = '/assets/images/master_image_not_available.png';
- }
- }}
- />
+
) : (
No Master Image
)}
@@ -1650,40 +1644,20 @@ export const AuditSession: React.FC = () => {
ANOMALY IMAGE
{currentAnomaly?.date_of_audit || currentAnomaly?.Created_on}
-
-
- setAnomalyZoom(z => z + 0.5)} sx={{ bgcolor: 'rgba(0,0,0,0.5)', color: 'white', '&:hover': { bgcolor: 'rgba(0,0,0,0.7)' } }}>
-
-
- 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)' } }}>
-
-
-
+
{mediaItems.length > 0 ? (
<>
{mediaItems[carouselIndex]?.type === 'video' ? (
) : (
-
{
- 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 = () => {
); })()}
@@ -2151,7 +2125,7 @@ export const AuditSession: React.FC = () => {
Safe
-
+
Category
diff --git a/src/pages/audit-session/ZoomableImage.tsx b/src/pages/audit-session/ZoomableImage.tsx
new file mode 100644
index 0000000..a785c5a
--- /dev/null
+++ b/src/pages/audit-session/ZoomableImage.tsx
@@ -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 = ({ src, alt, fallback }) => {
+ const wrapRef = useRef(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 (
+ 1 ? 'move' : 'zoom-in',
+ }}
+ >
+
{
+ if (!e.currentTarget.src.includes(fallback)) {
+ e.currentTarget.src = fallback;
+ }
+ }}
+ />
+
+ 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)' } }}
+ >
+
+
+ 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)' } }}
+ >
+
+
+
+ {zoom > 1 && (
+
+ {zoom.toFixed(1)}×
+
+ )}
+
+ );
+};