image scanning and asset edit
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
||||
ToggleButton, Button, CircularProgress, TablePagination, Typography, Chip, Tooltip,
|
||||
} from '@mui/material';
|
||||
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 { useToastStore } from '../../store/toastStore';
|
||||
|
||||
@@ -27,6 +27,7 @@ export const MissingMedia: React.FC = () => {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [dbName, setDbName] = useState('');
|
||||
const [type, setType] = useState<'audits' | 'rectification'>('audits');
|
||||
const [missing, setMissing] = useState<'all' | MissingMediaFilter>('all');
|
||||
const [records, setRecords] = useState<MissingMediaRecord[]>([]);
|
||||
const [summary, setSummary] = useState<MissingMediaSummary | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -53,23 +54,27 @@ export const MissingMedia: React.FC = () => {
|
||||
try {
|
||||
const result = await adminService.getMissingMedia({
|
||||
dbName, type, limit: PAGE_SIZE, offset: page * PAGE_SIZE,
|
||||
...(missing !== 'all' ? { missing } : {}),
|
||||
});
|
||||
setRecords(result.records);
|
||||
setTotal(result.total);
|
||||
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 {
|
||||
useToastStore.getState().showToast('Failed to load missing-media records.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dbName, type, page]);
|
||||
}, [dbName, type, page, missing]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
setSelectedIds(new Set());
|
||||
}, [dbName, type]);
|
||||
}, [dbName, type, missing]);
|
||||
|
||||
const toggleRow = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
@@ -119,6 +124,13 @@ export const MissingMedia: React.FC = () => {
|
||||
<ToggleButton value="rectification">Rectification</ToggleButton>
|
||||
</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
|
||||
variant="contained"
|
||||
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('Image missing', summary?.missingImage, 'error')}
|
||||
{summaryChip('Video missing', summary?.missingVideo, 'warning')}
|
||||
{summaryChip('Both missing', summary?.missingBoth, 'error')}
|
||||
{summaryChip('Gave up (24h of retries)', summary?.gaveUp, 'info')}
|
||||
</Box>
|
||||
|
||||
@@ -167,7 +180,9 @@ export const MissingMedia: React.FC = () => {
|
||||
{records.length === 0 && (
|
||||
<TableRow>
|
||||
<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>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Box, Typography, Button, Chip, Switch, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Select, MenuItem, TextField, CircularProgress } from '@mui/material';
|
||||
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 { 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 SearchIcon from '@mui/icons-material/Search';
|
||||
import { activityFeedsService } from '../../api/activityFeedsService';
|
||||
import { axiosClient } from '../../api/axiosClient';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
@@ -128,6 +129,14 @@ export const AuditSession: React.FC = () => {
|
||||
const [reportMessage, setReportMessage] = useState('');
|
||||
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 handleSendReport = async () => {
|
||||
@@ -199,7 +208,8 @@ export const AuditSession: React.FC = () => {
|
||||
selectedSite, filterFromDate, filterTillDate,
|
||||
filterMinChainage, filterMaxChainage, selectedAssetIds,
|
||||
searchAnomalyId, auditStatus, isRectificationEnabled, filterPlaza,
|
||||
setDashboardCache, sortBy, sortDir, setSortBy, setSortDir
|
||||
setDashboardCache, sortBy, sortDir, setSortBy, setSortDir,
|
||||
assetTypes
|
||||
} = useFeedStore();
|
||||
|
||||
// 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 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) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -1052,7 +1109,7 @@ export const AuditSession: React.FC = () => {
|
||||
}, [currentIndex, anomalies.length, currentAnomaly, isLocked, notes, saveAuditState]);
|
||||
// Keyboard shortcuts
|
||||
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();
|
||||
|
||||
@@ -1182,7 +1239,7 @@ export const AuditSession: React.FC = () => {
|
||||
handleSkip();
|
||||
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(() => {
|
||||
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={`${latStr}${longStr ? `, ${longStr}` : ''}`} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} />
|
||||
</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
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@@ -1908,9 +1974,9 @@ export const AuditSession: React.FC = () => {
|
||||
{/* 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' } }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: '#64748b' }}>
|
||||
{(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen) ? "Shortcuts Paused" : "Keys:"}
|
||||
{(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen || isEditAssetOpen) ? "Shortcuts Paused" : "Keys:"}
|
||||
</Typography>
|
||||
{!(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen) && (
|
||||
{!(isNotesFocused || isReportDialogOpen || isShortcutsDialogOpen || isEditAssetOpen) && (
|
||||
<>
|
||||
<Box>
|
||||
<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>
|
||||
</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 */}
|
||||
<Dialog
|
||||
open={isShortcutsDialogOpen}
|
||||
|
||||
Reference in New Issue
Block a user