image scanning and asset edit

This commit is contained in:
2026-07-14 14:20:24 +05:30
parent 8c6c26bc8e
commit 08307119da
4 changed files with 180 additions and 12 deletions

View File

@@ -62,6 +62,16 @@ export const activityFeedsService = {
return response; return response;
}, },
// Corrects a single record's asset classification. The backend resolves
// the canonical asset_name from the central catalog and returns it.
updateAnomalyAsset: async (id: number, assetId: number, isRectificationEnabled: boolean) => {
const response = await axiosClient.put('/api/audit/anomaly/asset',
{ id, asset_id: assetId },
{ params: { isRectificationEnabled } }
);
return response as unknown as { id: number; asset_id: number; asset_name: string };
},
// Called after normal (non-rectification) true anomaly audit // Called after normal (non-rectification) true anomaly audit
updateAnomalyInDashboard: async (payload: { site_id: string, anomaly_id: number[] }) => { updateAnomalyInDashboard: async (payload: { site_id: string, anomaly_id: number[] }) => {
const response = await axiosClient.post('/api/audit/auditor/anomaly/submit', payload); const response = await axiosClient.post('/api/audit/auditor/anomaly/submit', payload);

View File

@@ -177,11 +177,16 @@ export interface MissingMediaRecord {
export interface MissingMediaSummary { export interface MissingMediaSummary {
total: number; total: number;
neverChecked: number; neverChecked: number;
// Independent, inclusive counts: missingImage/missingVideo each include
// records missing both; missingBoth is the overlap.
missingImage: number; missingImage: number;
missingVideo: number; missingVideo: number;
missingBoth: number;
gaveUp: number; gaveUp: number;
} }
export type MissingMediaFilter = 'image' | 'video' | 'both';
export interface MissingMediaResult { export interface MissingMediaResult {
records: MissingMediaRecord[]; records: MissingMediaRecord[];
total: number; total: number;
@@ -299,6 +304,7 @@ export const adminService = {
type: 'audits' | 'rectification'; type: 'audits' | 'rectification';
limit?: number; limit?: number;
offset?: number; offset?: number;
missing?: MissingMediaFilter;
}): Promise<MissingMediaResult> => { }): Promise<MissingMediaResult> => {
return (await axiosClient.get('/api/audit/admin/media-status', { params })) as unknown as MissingMediaResult; return (await axiosClient.get('/api/audit/admin/media-status', { params })) as unknown as MissingMediaResult;
}, },

View File

@@ -5,7 +5,7 @@ import {
ToggleButton, Button, CircularProgress, TablePagination, Typography, Chip, Tooltip, ToggleButton, Button, CircularProgress, TablePagination, Typography, Chip, Tooltip,
} from '@mui/material'; } from '@mui/material';
import { adminService } from '../../api/adminService'; import { adminService } from '../../api/adminService';
import type { MissingMediaRecord, MissingMediaSummary } from '../../api/adminService'; import type { MissingMediaRecord, MissingMediaSummary, MissingMediaFilter } from '../../api/adminService';
import { accountService } from '../../api/accountService'; import { accountService } from '../../api/accountService';
import { useToastStore } from '../../store/toastStore'; import { useToastStore } from '../../store/toastStore';
@@ -27,6 +27,7 @@ export const MissingMedia: React.FC = () => {
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
const [dbName, setDbName] = useState(''); const [dbName, setDbName] = useState('');
const [type, setType] = useState<'audits' | 'rectification'>('audits'); const [type, setType] = useState<'audits' | 'rectification'>('audits');
const [missing, setMissing] = useState<'all' | MissingMediaFilter>('all');
const [records, setRecords] = useState<MissingMediaRecord[]>([]); const [records, setRecords] = useState<MissingMediaRecord[]>([]);
const [summary, setSummary] = useState<MissingMediaSummary | null>(null); const [summary, setSummary] = useState<MissingMediaSummary | null>(null);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -53,23 +54,27 @@ export const MissingMedia: React.FC = () => {
try { try {
const result = await adminService.getMissingMedia({ const result = await adminService.getMissingMedia({
dbName, type, limit: PAGE_SIZE, offset: page * PAGE_SIZE, dbName, type, limit: PAGE_SIZE, offset: page * PAGE_SIZE,
...(missing !== 'all' ? { missing } : {}),
}); });
setRecords(result.records); setRecords(result.records);
setTotal(result.total); setTotal(result.total);
setSummary(result.summary); setSummary(result.summary);
// A recheck under an active filter can shrink the filtered total below
// the current page - snap back instead of stranding on an empty page.
if (page > 0 && result.total <= page * PAGE_SIZE) setPage(0);
} catch { } catch {
useToastStore.getState().showToast('Failed to load missing-media records.', 'error'); useToastStore.getState().showToast('Failed to load missing-media records.', 'error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [dbName, type, page]); }, [dbName, type, page, missing]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
useEffect(() => { useEffect(() => {
setPage(0); setPage(0);
setSelectedIds(new Set()); setSelectedIds(new Set());
}, [dbName, type]); }, [dbName, type, missing]);
const toggleRow = (id: number) => { const toggleRow = (id: number) => {
setSelectedIds((prev) => { setSelectedIds((prev) => {
@@ -119,6 +124,13 @@ export const MissingMedia: React.FC = () => {
<ToggleButton value="rectification">Rectification</ToggleButton> <ToggleButton value="rectification">Rectification</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
<ToggleButtonGroup value={missing} exclusive onChange={(_, v) => v && setMissing(v)} size="small">
<ToggleButton value="all">All</ToggleButton>
<ToggleButton value="image">Image missing</ToggleButton>
<ToggleButton value="video">Video missing</ToggleButton>
<ToggleButton value="both">Both missing</ToggleButton>
</ToggleButtonGroup>
<Button <Button
variant="contained" variant="contained"
disabled={selectedIds.size === 0 || selectedIds.size > 50 || rechecking} disabled={selectedIds.size === 0 || selectedIds.size > 50 || rechecking}
@@ -136,6 +148,7 @@ export const MissingMedia: React.FC = () => {
{summaryChip('Not checked yet', summary?.neverChecked, 'default')} {summaryChip('Not checked yet', summary?.neverChecked, 'default')}
{summaryChip('Image missing', summary?.missingImage, 'error')} {summaryChip('Image missing', summary?.missingImage, 'error')}
{summaryChip('Video missing', summary?.missingVideo, 'warning')} {summaryChip('Video missing', summary?.missingVideo, 'warning')}
{summaryChip('Both missing', summary?.missingBoth, 'error')}
{summaryChip('Gave up (24h of retries)', summary?.gaveUp, 'info')} {summaryChip('Gave up (24h of retries)', summary?.gaveUp, 'info')}
</Box> </Box>
@@ -167,7 +180,9 @@ export const MissingMedia: React.FC = () => {
{records.length === 0 && ( {records.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}> <TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}>
No pending records with missing media everything is auditable. 🎉 {missing !== 'all'
? 'No records match this filter.'
: 'No pending records with missing media — everything is auditable. 🎉'}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}

View File

@@ -1,9 +1,10 @@
import React, { useState, useEffect, useCallback, 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 } from '@mui/material'; import { Box, Typography, Button, Chip, Switch, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, 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 ZoomInIcon from '@mui/icons-material/ZoomIn';
import ZoomOutIcon from '@mui/icons-material/ZoomOut'; import ZoomOutIcon from '@mui/icons-material/ZoomOut';
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';
import { useToastStore } from '../../store/toastStore'; import { useToastStore } from '../../store/toastStore';
@@ -128,6 +129,14 @@ export const AuditSession: React.FC = () => {
const [reportMessage, setReportMessage] = useState(''); const [reportMessage, setReportMessage] = useState('');
const [reportSending, setReportSending] = useState(false); const [reportSending, setReportSending] = useState(false);
// Edit Asset: corrects a misclassified record's asset (metadata edit, not
// an audit verdict). Picker reads the startup-loaded asset catalog from
// the feed store; the backend resolves the canonical name from asset_id.
const [isEditAssetOpen, setIsEditAssetOpen] = useState(false);
const [editAssetSearch, setEditAssetSearch] = useState('');
const [editAssetId, setEditAssetId] = useState<number | null>(null);
const [editAssetSaving, setEditAssetSaving] = useState(false);
const DEVS = ['aromal', 'divya', 'Poonam', 'ravi', 'Kaushik', 'Saleth', 'Jagan', 'Yash', 'faraz_siddiqui', 'brinda.n', 'SasiVardhan', 'Shubham', 'sohail']; const DEVS = ['aromal', 'divya', 'Poonam', 'ravi', 'Kaushik', 'Saleth', 'Jagan', 'Yash', 'faraz_siddiqui', 'brinda.n', 'SasiVardhan', 'Shubham', 'sohail'];
const handleSendReport = async () => { const handleSendReport = async () => {
@@ -199,7 +208,8 @@ export const AuditSession: React.FC = () => {
selectedSite, filterFromDate, filterTillDate, selectedSite, filterFromDate, filterTillDate,
filterMinChainage, filterMaxChainage, selectedAssetIds, filterMinChainage, filterMaxChainage, selectedAssetIds,
searchAnomalyId, auditStatus, isRectificationEnabled, filterPlaza, searchAnomalyId, auditStatus, isRectificationEnabled, filterPlaza,
setDashboardCache, sortBy, sortDir, setSortBy, setSortDir setDashboardCache, sortBy, sortDir, setSortBy, setSortDir,
assetTypes
} = useFeedStore(); } = useFeedStore();
// Call this before every navigate('/dashboard') so the dashboard re-fetches fresh stats // Call this before every navigate('/dashboard') so the dashboard re-fetches fresh stats
@@ -221,6 +231,53 @@ export const AuditSession: React.FC = () => {
const activeDate = activeTab.date || ''; const activeDate = activeTab.date || '';
const isRectActive = isRectificationEnabled || activeTab.isRectificationTab; const isRectActive = isRectificationEnabled || activeTab.isRectificationTab;
// Same search-filter shape as AssetMultiSelect: filter each group's
// assets by name, drop groups that end up empty.
const filteredAssetTypes = useMemo(() => {
if (!editAssetSearch.trim()) return assetTypes;
const q = editAssetSearch.toLowerCase();
return assetTypes
.map((type: any) => ({
...type,
assets: type.assets.filter((a: any) => a.asset_name.toLowerCase().includes(q)),
}))
.filter((type: any) => type.assets.length > 0);
}, [assetTypes, editAssetSearch]);
const handleSaveAssetEdit = async () => {
if (!currentAnomaly || editAssetId === null || editAssetId === currentAnomaly.asset_id) return;
const toast = useToastStore.getState().showToast;
setEditAssetSaving(true);
try {
const res = await activityFeedsService.updateAnomalyAsset(currentAnomaly.id, editAssetId, isRectActive);
const patchOne = (a: any) =>
a.id === res.id ? { ...a, Asset: res.asset_name, asset_id: res.asset_id } : a;
// Session state first (drives the metadata chip immediately), then the
// persisted feed-tab caches - both anomalies AND anomaliesCopy, and only
// tabs of the same mode: audits and rectification ids come from
// different tables and can collide (same rule as applyAuditedLocally).
setAnomalies((prev: any[]) => prev.map(patchOne));
const feed = useFeedStore.getState();
feed.setTabs(feed.tabs.map((t: any) =>
!!t.isRectificationTab === isRectActive
? { ...t, anomalies: (t.anomalies || []).map(patchOne), anomaliesCopy: (t.anomaliesCopy || []).map(patchOne) }
: t
));
setIsEditAssetOpen(false);
setEditAssetId(null);
setEditAssetSearch('');
toast(`#${res.id}: asset changed to "${res.asset_name.replace(/_/g, ' ')}"`, 'success');
} catch (err: any) {
if (err?.response?.status === 409) {
toast(`#${currentAnomaly.id} is already audited — asset is locked.`, 'warning');
} else {
toast(`Failed to update asset — ${backendMsg(err)}`, 'error');
}
} finally {
setEditAssetSaving(false);
}
};
const loadSessionData = useCallback(async (customPageNo?: number) => { const loadSessionData = useCallback(async (customPageNo?: number) => {
try { try {
setLoading(true); setLoading(true);
@@ -1052,7 +1109,7 @@ export const AuditSession: React.FC = () => {
}, [currentIndex, anomalies.length, currentAnomaly, isLocked, notes, saveAuditState]); }, [currentIndex, anomalies.length, currentAnomaly, isLocked, notes, saveAuditState]);
// Keyboard shortcuts // Keyboard shortcuts
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen || isBulkConfirmOpen || isAuditAllOpen || bulkResult !== null) return; if (isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen || isBulkConfirmOpen || isAuditAllOpen || isEditAssetOpen || bulkResult !== null) return;
const key = e.key.toLowerCase(); const key = e.key.toLowerCase();
@@ -1182,7 +1239,7 @@ export const AuditSession: React.FC = () => {
handleSkip(); handleSkip();
break; break;
} }
}, [currentIndex, anomalies, isLocked, isAnomaly, selectedCategory, notes, plantSubCategory, isQuickAudit, isBulkMode, isNotesFocused, isReportDialogOpen, isShortcutsDialogOpen, isBulkConfirmOpen, isAuditAllOpen, bulkResult, showBatchEndPanel, stagedCount, handleNextBatch, handleLeaveSession, handleQuickSaveAndNext, handleSaveAndNext, handleSkip, handleNext]); }, [currentIndex, anomalies, isLocked, isAnomaly, selectedCategory, notes, plantSubCategory, isQuickAudit, isBulkMode, isNotesFocused, isReportDialogOpen, isShortcutsDialogOpen, isBulkConfirmOpen, isAuditAllOpen, isEditAssetOpen, bulkResult, showBatchEndPanel, stagedCount, handleNextBatch, handleLeaveSession, handleQuickSaveAndNext, handleSaveAndNext, handleSkip, handleNext]);
useEffect(() => { useEffect(() => {
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
@@ -1524,7 +1581,16 @@ export const AuditSession: React.FC = () => {
<Chip size="small" label={`Side: ${currentAnomaly?.Side || 'N/A'}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} /> <Chip size="small" label={`Side: ${currentAnomaly?.Side || 'N/A'}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} />
<Chip size="small" label={`${latStr}${longStr ? `, ${longStr}` : ''}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} /> <Chip size="small" label={`${latStr}${longStr ? `, ${longStr}` : ''}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} />
</Box> </Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button
size="small"
variant="outlined"
disabled={isLocked}
onClick={() => { setEditAssetId(null); setEditAssetSearch(''); setIsEditAssetOpen(true); }}
sx={{ borderColor: '#3b82f6', color: '#60a5fa', textTransform: 'none', fontSize: '0.75rem', borderRadius: 1.5, '&:hover': { bgcolor: 'rgba(59,130,246,0.1)', borderColor: '#3b82f6' }, '&.Mui-disabled': { borderColor: '#334155', color: '#475569' } }}
>
Edit Asset
</Button>
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"
@@ -1908,9 +1974,9 @@ export const AuditSession: React.FC = () => {
{/* Footer Shortcuts */} {/* Footer Shortcuts */}
<Box sx={{ borderTop: '1px solid #1e293b', p: 1, px: 2, display: 'flex', gap: 3, alignItems: 'center', overflowX: 'auto', '& > div': { display: 'flex', alignItems: 'center', gap: 1, whiteSpace: 'nowrap' } }}> <Box sx={{ borderTop: '1px solid #1e293b', p: 1, px: 2, display: 'flex', gap: 3, alignItems: 'center', overflowX: 'auto', '& > div': { display: 'flex', alignItems: 'center', gap: 1, whiteSpace: 'nowrap' } }}>
<Typography sx={{ fontSize: '0.75rem', color: '#64748b' }}> <Typography sx={{ fontSize: '0.75rem', color: '#64748b' }}>
{(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen) ? "Shortcuts Paused" : "Keys:"} {(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen || isEditAssetOpen) ? "Shortcuts Paused" : "Keys:"}
</Typography> </Typography>
{!(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen) && ( {!(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen || isEditAssetOpen) && (
<> <>
<Box> <Box>
<Chip label="A" size="small" sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#1e293b', color: '#cbd5e1' }} /> <Chip label="A" size="small" sx={{ height: 20, fontSize: '0.65rem', bgcolor: '#1e293b', color: '#cbd5e1' }} />
@@ -2015,6 +2081,77 @@ export const AuditSession: React.FC = () => {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Edit Asset Dialog */}
<Dialog
open={isEditAssetOpen}
onClose={() => { setIsEditAssetOpen(false); setEditAssetId(null); setEditAssetSearch(''); }}
sx={{ zIndex: 10000 }}
slotProps={{ paper: { sx: { bgcolor: '#0f172a', color: '#f8fafc', border: '1px solid #1e293b', minWidth: 400 } } }}
>
<DialogTitle sx={{ fontWeight: 'bold', pb: 1, borderBottom: '1px solid #1e293b' }}>
Edit Asset
</DialogTitle>
<DialogContent sx={{ pt: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 0.5 }}>Anomaly</Typography>
<Typography variant="body2" sx={{ color: '#f8fafc' }}>
ID #{currentAnomaly?.id} current: {currentAnomaly?.Asset?.replace(/_/gi, ' ') || 'Unknown'}
</Typography>
</Box>
<TextField
placeholder="Search assets..."
variant="outlined"
size="small"
fullWidth
autoFocus
value={editAssetSearch}
onChange={(e) => setEditAssetSearch(e.target.value)}
sx={{ bgcolor: 'rgba(255,255,255,0.05)', borderRadius: 1, input: { color: '#f8fafc' }, '& fieldset': { borderColor: '#334155' } }}
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: '#64748b' }} fontSize="small" /></InputAdornment> } }}
/>
<Box sx={{ maxHeight: 320, overflowY: 'auto', pr: 1 }}>
{filteredAssetTypes.length === 0 ? (
<Typography variant="body2" sx={{ color: '#64748b', py: 1 }}>No assets match your search.</Typography>
) : (
filteredAssetTypes.map((type: any) => (
<Box key={type.type_id ?? 'uncategorized'} sx={{ mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#60a5fa', textTransform: 'uppercase', fontSize: '0.7rem', letterSpacing: 1, py: 0.5 }}>
{type.type_name || 'Uncategorized'}
</Typography>
{type.assets.map((asset: any) => (
<ListItemButton
key={asset.asset_id}
dense
selected={editAssetId === asset.asset_id}
onClick={() => setEditAssetId(asset.asset_id)}
sx={{ color: '#f8fafc', borderRadius: 1, py: 0.5, '&:hover': { bgcolor: '#1e293b' }, '&.Mui-selected': { bgcolor: '#1e3a5f' }, '&.Mui-selected:hover': { bgcolor: '#1e3a5f' } }}
>
{asset.asset_name.replace(/_/g, ' ')}
{asset.asset_id === currentAnomaly?.asset_id && (
<Typography component="span" sx={{ ml: 1, fontSize: '0.7rem', color: '#64748b' }}>(current)</Typography>
)}
</ListItemButton>
))}
</Box>
))
)}
</Box>
</DialogContent>
<DialogActions sx={{ borderTop: '1px solid #1e293b', px: 3, py: 1.5, gap: 1 }}>
<Button onClick={() => { setIsEditAssetOpen(false); setEditAssetId(null); setEditAssetSearch(''); }} sx={{ color: '#94a3b8', textTransform: 'none' }}>
Cancel
</Button>
<Button
onClick={handleSaveAssetEdit}
variant="contained"
disabled={editAssetId === null || editAssetId === currentAnomaly?.asset_id || editAssetSaving}
sx={{ bgcolor: '#3b82f6', color: '#fff', fontWeight: 'bold', textTransform: 'none', '&:hover': { bgcolor: '#2563eb' }, '&.Mui-disabled': { bgcolor: 'rgba(59,130,246,0.3)', color: 'rgba(255,255,255,0.4)' } }}
>
{editAssetSaving ? <CircularProgress size={18} sx={{ color: '#fff' }} /> : 'Save'}
</Button>
</DialogActions>
</Dialog>
{/* Keyboard Shortcuts Dialog */} {/* Keyboard Shortcuts Dialog */}
<Dialog <Dialog
open={isShortcutsDialogOpen} open={isShortcutsDialogOpen}