feat: implement activity feed module with dynamic filtering, state management, and asset selection components

This commit is contained in:
2026-06-22 12:47:01 +05:30
parent 0a35bba378
commit 514a656fa8
11 changed files with 505 additions and 150 deletions

View File

@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-revamp</title> <title>SeekRight</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -77,5 +77,10 @@ export const activityFeedsService = {
getFalseAnomaly: async (params: any) => { getFalseAnomaly: async (params: any) => {
const response = await axiosClient.get('/api/audit/asset/get-false-Anomaly', { params }); const response = await axiosClient.get('/api/audit/asset/get-false-Anomaly', { params });
return response; return response;
},
bulkDelete: async (payload: any) => {
const response = await axiosClient.post('/api/audit/bulk/delete', payload);
return response;
} }
}; };

View File

@@ -0,0 +1,335 @@
import React, { useState, useEffect } from 'react';
import {
Dialog, DialogContent, Typography, Box, IconButton, Button,
TextField, RadioGroup, FormControlLabel, Radio, Checkbox,
CircularProgress, Select, MenuItem
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { useFeedStore } from '../../store/feedStore';
import { useAuthStore } from '../../store/authStore';
import { activityFeedsService } from '../../api/activityFeedsService';
interface BulkDeleteDialogProps {
open: boolean;
onClose: () => void;
}
const REASONS = ['Low Light', 'Bad Image', 'Wrong Algorithm', 'Out of Range'];
export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClose }) => {
const { assetTypes, selectedSite, sites } = useFeedStore();
const { user } = useAuthStore();
const [fromDate, setFromDate] = useState('');
const [tillDate, setTillDate] = useState('');
const [auditType, setAuditType] = useState('manual');
const [reason, setReason] = useState('Bad Image');
const [selectedAssets, setSelectedAssets] = useState<Set<number>>(new Set());
const [isLoading, setIsLoading] = useState(false);
const [localSite, setLocalSite] = useState<string>(selectedSite?.site_id || 'All Sites');
// Reset state when dialog opens
useEffect(() => {
if (open) {
setFromDate('');
setTillDate('');
setAuditType('manual');
setReason('Bad Image');
setSelectedAssets(new Set());
setLocalSite(selectedSite?.site_id || 'All Sites');
}
}, [open, selectedSite?.site_id]);
// Extract all asset IDs for "Select All" functionality
const allAssetIds = React.useMemo(() => {
const ids: number[] = [];
assetTypes.forEach((type: any) => {
type.assets?.forEach((a: any) => ids.push(a.asset_id));
});
return ids;
}, [assetTypes]);
const isAllSelected = selectedAssets.size > 0 && selectedAssets.size === allAssetIds.length;
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedAssets(new Set(allAssetIds));
} else {
setSelectedAssets(new Set());
}
};
const handleSelectCategory = (type: any, checked: boolean) => {
const newSelected = new Set(selectedAssets);
type.assets?.forEach((a: any) => {
if (checked) {
newSelected.add(a.asset_id);
} else {
newSelected.delete(a.asset_id);
}
});
setSelectedAssets(newSelected);
};
const handleSelectAsset = (assetId: number, checked: boolean) => {
const newSelected = new Set(selectedAssets);
if (checked) {
newSelected.add(assetId);
} else {
newSelected.delete(assetId);
}
setSelectedAssets(newSelected);
};
const handleApply = async () => {
if (!fromDate || !tillDate) {
alert("Please select both From and Till dates.");
return;
}
if (selectedAssets.size === 0) {
alert("Please select at least one asset.");
return;
}
let siteIds: number[] = [];
if (localSite === 'All Sites') {
sites.forEach((s: any) => {
if (s.site_id) {
siteIds.push(...String(s.site_id).split(',').map(id => Number(id.trim())));
}
});
} else {
siteIds = String(localSite).split(',').map(id => Number(id.trim()));
}
const payload = {
dbName: user?.db_name,
asset_id: Array.from(selectedAssets),
site_id: siteIds,
fromDate: fromDate,
toDate: tillDate,
user_id: Number(user?.user_id),
audit_value: reason,
isBulkDelete: true,
audit_type: auditType
};
try {
setIsLoading(true);
await activityFeedsService.bulkDelete(payload);
alert("Bulk delete successful!");
onClose();
} catch (err: any) {
console.error("Bulk delete failed", err);
alert("Bulk delete failed. " + (err?.response?.data?.message || err.message || ""));
} finally {
setIsLoading(false);
}
};
return (
<Dialog
open={open}
onClose={!isLoading ? onClose : undefined}
maxWidth="sm"
fullWidth
slotProps={{
paper: {
sx: {
borderRadius: 2,
bgcolor: '#0f172a',
color: '#f8fafc',
border: '1px solid #1e293b',
p: 1
}
}
}}
>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', px: 2, pt: 1 }}>
<Button
onClick={onClose}
endIcon={<CloseIcon sx={{ fontSize: 16 }} />}
sx={{ color: '#FCC320', fontSize: '0.8rem', fontWeight: 'bold' }}
disabled={isLoading}
>
CLOSE
</Button>
</Box>
<Box sx={{ px: 2, pb: 1 }}>
<Box sx={{ borderBottom: '1px solid #1e293b', mb: 2 }} />
</Box>
<DialogContent sx={{ py: 0, '&::-webkit-scrollbar': { width: '6px' }, '&::-webkit-scrollbar-thumb': { bgcolor: '#334155', borderRadius: '4px' } }}>
{/* SITE SELECTION */}
<Typography sx={{ color: '#FCC320', fontWeight: 'bold', mb: 1, fontSize: '0.9rem' }}>SITE</Typography>
<Select
value={localSite}
onChange={(e) => setLocalSite(e.target.value as string)}
size="small"
fullWidth
sx={{
mb: 3,
bgcolor: 'rgba(255,255,255,0.05)',
color: '#f8fafc',
borderRadius: 1,
'& fieldset': { borderColor: '#334155' },
'&:hover fieldset': { borderColor: '#475569' },
'&.Mui-focused fieldset': { borderColor: '#3b82f6' },
'& .MuiSelect-icon': { color: '#f8fafc' }
}}
>
<MenuItem value="All Sites">All Sites</MenuItem>
{sites.map((s: any) => (
<MenuItem key={s.site_id} value={s.site_id}>{s.site_name}</MenuItem>
))}
</Select>
{/* DATE RANGE */}
<Typography sx={{ color: '#FCC320', fontWeight: 'bold', mb: 1, fontSize: '0.9rem' }}>DATE RANGE</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 0.5 }}>From</Typography>
<TextField
type="date"
fullWidth
size="small"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: 'rgba(255,255,255,0.05)',
borderRadius: 1,
color: '#f8fafc',
'& fieldset': { borderColor: '#334155' },
'&:hover fieldset': { borderColor: '#475569' },
'&.Mui-focused fieldset': { borderColor: '#3b82f6' }
},
'& input[type="date"]::-webkit-calendar-picker-indicator': { filter: 'invert(1)', cursor: 'pointer' }
}}
/>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 0.5 }}>Till</Typography>
<TextField
type="date"
fullWidth
size="small"
value={tillDate}
onChange={(e) => setTillDate(e.target.value)}
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: 'rgba(255,255,255,0.05)',
borderRadius: 1,
color: '#f8fafc',
'& fieldset': { borderColor: '#334155' },
'&:hover fieldset': { borderColor: '#475569' },
'&.Mui-focused fieldset': { borderColor: '#3b82f6' }
},
'& input[type="date"]::-webkit-calendar-picker-indicator': { filter: 'invert(1)', cursor: 'pointer' }
}}
/>
</Box>
</Box>
{/* AUDIT TYPE */}
<Typography sx={{ color: '#FCC320', fontWeight: 'bold', mb: 0.5, fontSize: '0.9rem' }}>AUDIT TYPE</Typography>
<RadioGroup row value={auditType} onChange={(e) => setAuditType(e.target.value)} sx={{ mb: 2 }}>
<FormControlLabel value="manual" control={<Radio size="small" sx={{ color: '#64748b', '&.Mui-checked': { color: '#3b82f6' } }} />} label={<Typography variant="body2" color="#cbd5e1">manual</Typography>} />
<FormControlLabel value="auto audit" control={<Radio size="small" sx={{ color: '#64748b', '&.Mui-checked': { color: '#3b82f6' } }} />} label={<Typography variant="body2" color="#cbd5e1">auto audit</Typography>} />
</RadioGroup>
{/* ASSETS */}
<Typography sx={{ color: '#FCC320', fontWeight: 'bold', mb: 1, fontSize: '0.9rem' }}>ASSETS</Typography>
<Box sx={{ height: 250, overflowY: 'auto', border: '1px solid #1e293b', borderRadius: 1, p: 1, mb: 3, bgcolor: '#0b1121', '&::-webkit-scrollbar': { width: '6px' }, '&::-webkit-scrollbar-thumb': { bgcolor: '#334155', borderRadius: '4px' } }}>
<FormControlLabel
control={
<Checkbox
size="small"
checked={isAllSelected && allAssetIds.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
sx={{ color: '#64748b', '&.Mui-checked': { color: '#3b82f6' } }}
/>
}
label={<Typography variant="body2" fontWeight="bold" color="#f8fafc">SELECT ALL</Typography>}
sx={{ width: '100%', m: 0 }}
/>
{assetTypes.map((type: any) => {
if (!type.assets || type.assets.length === 0) return null;
const categoryAssetIds = type.assets.map((a: any) => a.asset_id);
const isCategorySelected = categoryAssetIds.every((id: number) => selectedAssets.has(id));
const isCategoryIndeterminate = !isCategorySelected && categoryAssetIds.some((id: number) => selectedAssets.has(id));
return (
<Box key={type.type_id} sx={{ ml: 1, mt: 1 }}>
<FormControlLabel
control={
<Checkbox
size="small"
checked={isCategorySelected}
indeterminate={isCategoryIndeterminate}
onChange={(e) => handleSelectCategory(type, e.target.checked)}
sx={{ color: '#64748b', '&.Mui-checked, &.MuiCheckbox-indeterminate': { color: '#3b82f6' } }}
/>
}
label={<Typography variant="body2" fontWeight="bold" color="#cbd5e1" sx={{ textTransform: 'uppercase' }}>{type.type_name.replace(/_/g, ' ')}</Typography>}
sx={{ width: '100%', m: 0 }}
/>
<Box sx={{ ml: 3, display: 'flex', flexDirection: 'column' }}>
{type.assets.map((asset: any) => (
<FormControlLabel
key={asset.asset_id}
control={
<Checkbox
size="small"
checked={selectedAssets.has(asset.asset_id)}
onChange={(e) => handleSelectAsset(asset.asset_id, e.target.checked)}
sx={{ color: '#475569', '&.Mui-checked': { color: '#3b82f6' }, py: 0.5 }}
/>
}
label={<Typography variant="body2" color="#94a3b8">{asset.asset_name.replace(/_/g, ' ')}</Typography>}
sx={{ m: 0 }}
/>
))}
</Box>
</Box>
);
})}
</Box>
{/* REASON */}
<Typography sx={{ color: '#FCC320', fontWeight: 'bold', mb: 0.5, fontSize: '0.9rem' }}>REASON</Typography>
<RadioGroup value={reason} onChange={(e) => setReason(e.target.value)} sx={{ mb: 2 }}>
{REASONS.map(r => (
<FormControlLabel
key={r}
value={r}
control={<Radio size="small" sx={{ color: '#64748b', '&.Mui-checked': { color: '#3b82f6' } }} />}
label={<Typography variant="body2" color="#cbd5e1">{r}</Typography>}
/>
))}
</RadioGroup>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1, mb: 1 }}>
<Button
variant="contained"
onClick={handleApply}
disabled={isLoading}
sx={{
bgcolor: '#3b82f6',
color: '#fff',
'&:hover': { bgcolor: '#2563eb' },
textTransform: 'none',
fontWeight: 'bold',
px: 4,
boxShadow: 'none',
'&.Mui-disabled': { bgcolor: 'rgba(59, 130, 246, 0.3)', color: 'rgba(255,255,255,0.3)' }
}}
>
{isLoading ? <CircularProgress size={24} color="inherit" /> : 'Apply'}
</Button>
</Box>
</DialogContent>
</Dialog>
);
};

View File

@@ -5,6 +5,7 @@ import HistoryIcon from '@mui/icons-material/History';
import { useFeedStore } from '../../store/feedStore'; import { useFeedStore } from '../../store/feedStore';
import { FeedFilters } from '../../pages/activity-feeds/FeedFilters'; import { FeedFilters } from '../../pages/activity-feeds/FeedFilters';
import { activityFeedsService } from '../../api/activityFeedsService'; import { activityFeedsService } from '../../api/activityFeedsService';
import { BulkDeleteDialog } from './BulkDeleteDialog';
const DEBOUNCE_MS = 600; // wait 600ms after user stops changing before committing to store const DEBOUNCE_MS = 600; // wait 600ms after user stops changing before committing to store
@@ -112,6 +113,7 @@ export const TopFilterBar: React.FC = () => {
}; };
const [historyDates, setHistoryDates] = useState<any[]>([]); const [historyDates, setHistoryDates] = useState<any[]>([]);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [loadingHistory, setLoadingHistory] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false);
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false); const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false);
const [selectedHistoryDate, setSelectedHistoryDate] = useState('2026-06-12'); const [selectedHistoryDate, setSelectedHistoryDate] = useState('2026-06-12');
@@ -199,6 +201,13 @@ export const TopFilterBar: React.FC = () => {
> >
RECTIFICATION HISTORY RECTIFICATION HISTORY
</Button> </Button>
<Button
variant="outlined"
onClick={() => setIsBulkDeleteDialogOpen(true)}
sx={{ borderColor: '#FCC320', color: '#FCC320', '&:hover': { bgcolor: 'rgba(252, 195, 32, 0.1)', borderColor: '#FCC320' }, borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
>
BULK DELETE
</Button>
<Dialog <Dialog
open={isHistoryDialogOpen} open={isHistoryDialogOpen}
onClose={() => setIsHistoryDialogOpen(false)} onClose={() => setIsHistoryDialogOpen(false)}
@@ -486,6 +495,7 @@ export const TopFilterBar: React.FC = () => {
)} )}
<FeedFilters /> <FeedFilters />
<BulkDeleteDialog open={isBulkDeleteDialogOpen} onClose={() => setIsBulkDeleteDialogOpen(false)} />
</> </>
); );
}; };

View File

@@ -1,9 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <App />,
<App />
</StrictMode>,
) )

View File

@@ -44,8 +44,8 @@ export const DbSelectionDialog: React.FC<DbSelectionDialogProps> = ({ open, onCl
</Box> </Box>
) : ( ) : (
<List> <List>
{organizations.map((org) => ( {organizations.map((org, index) => (
<ListItem disablePadding key={org.org_id || org.org_name}> <ListItem disablePadding key={`${org.org_id || org.org_name}-${index}`}>
<ListItemButton onClick={() => onClose(org)}> <ListItemButton onClick={() => onClose(org)}>
<ListItemText primary={org.org_name} /> <ListItemText primary={org.org_name} />
</ListItemButton> </ListItemButton>

View File

@@ -219,8 +219,9 @@ export const FeedFilters: React.FC = () => {
onClose={() => setIsFiltersModalOpen(false)} onClose={() => setIsFiltersModalOpen(false)}
maxWidth="lg" maxWidth="lg"
fullWidth fullWidth
// Zero transition — opens instantly keepMounted // pre-renders in background — no mount delay on open
transitionDuration={0} transitionDuration={0}
disableScrollLock // prevents layout shift stutter
slotProps={{ slotProps={{
paper: { paper: {
sx: { sx: {

View File

@@ -25,7 +25,7 @@ export const GlobalFeed: React.FC = () => {
} }
const initTabs = async () => { const initTabs = async () => {
// Add default Unaudited tab if empty and fetch data // Add default Unaudited tab if empty and trigger second effect
if (useFeedStore.getState().tabs.length === 0) { if (useFeedStore.getState().tabs.length === 0) {
addTab({ addTab({
date: '', // Unaudited date: '', // Unaudited
@@ -38,39 +38,6 @@ export const GlobalFeed: React.FC = () => {
page: 0, page: 0,
rowsPerPage: 20 rowsPerPage: 20
}); });
const currentSite = useFeedStore.getState().selectedSite;
const siteIdParam = (!currentSite || currentSite?.site_id === 'All Sites') ? '' : String(currentSite.site_id || '');
const assetIdsParam = useFeedStore.getState().selectedAssetIds.join(',');
try {
const historyRes: any = await activityFeedsService.getHistory({
date: '',
pageNo: 0,
pageSize: 20,
siteIds: siteIdParam,
auditStatus: auditStatus,
isTrueFalse: isRectificationEnabled ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
auto_audit: isRectificationEnabled ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
isRectificationEnabled: isRectificationEnabled,
assetsIds: assetIdsParam,
fromDate: useFeedStore.getState().filterFromDate,
tillDate: useFeedStore.getState().filterTillDate,
fromChainage: useFeedStore.getState().filterMinChainage,
toChainage: useFeedStore.getState().filterMaxChainage,
anomalyIds: searchAnomalyId,
plaza: filterPlaza
});
if (historyRes && historyRes.anomalies) {
updateTab(0, {
anomalies: historyRes.anomalies,
anomaliesCopy: historyRes.anomalies,
totalAnomalyCount: historyRes.count || historyRes.anomalies.length
});
}
} catch (historyErr) {
console.error("[GlobalFeed] Failed to load history", historyErr);
}
} }
}; };

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Typography, Button, Chip, Switch, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'; import { Box, Typography, Button, Chip, Switch, Dialog, DialogTitle, DialogContent, DialogActions, IconButton } 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 ZoomOutIcon from '@mui/icons-material/ZoomOut';
import { activityFeedsService } from '../../api/activityFeedsService'; import { activityFeedsService } from '../../api/activityFeedsService';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useFeedStore } from '../../store/feedStore'; import { useFeedStore } from '../../store/feedStore';
@@ -62,24 +64,41 @@ export const AuditSession: React.FC = () => {
tabs, selectedTabIndex, updateTab, tabs, selectedTabIndex, updateTab,
selectedSite, filterFromDate, filterTillDate, selectedSite, filterFromDate, filterTillDate,
filterMinChainage, filterMaxChainage, selectedAssetIds, filterMinChainage, filterMaxChainage, selectedAssetIds,
searchAnomalyId, auditStatus, isRectificationEnabled, filterPlaza searchAnomalyId, auditStatus, isRectificationEnabled, filterPlaza,
setDashboardCache
} = useFeedStore(); } = useFeedStore();
// Call this before every navigate('/dashboard') so the dashboard re-fetches fresh stats
const invalidateDashboardCache = () => {
setDashboardCache({
siteId: '__unset__', auditStatus: '__unset__', fromDate: '__unset__',
tillDate: '__unset__', assetIds: '__unset__', minChainage: '__unset__',
maxChainage: '__unset__', searchAnomalyId: '__unset__', filterPlaza: '__unset__',
isRectificationEnabled: false, date: '', stats: null,
});
};
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 activeTab = tabs[selectedTabIndex] || { date: '', isRectificationTab: false, page: 0, rowsPerPage: 20 };
const activeDate = activeTab.date || ''; const activeDate = activeTab.date || '';
const isRectActive = isRectificationEnabled || activeTab.isRectificationTab; const isRectActive = isRectificationEnabled || activeTab.isRectificationTab;
const loadSessionData = useCallback(async () => { const loadSessionData = useCallback(async (customPageNo?: number) => {
try { try {
setLoading(true); setLoading(true);
const selectedSiteId = selectedSite?.site_id ?? ''; const selectedSiteId = selectedSite?.site_id ?? '';
const assetIdsKey = selectedAssetIds.join(','); const assetIdsKey = selectedAssetIds.join(',');
const isReviewMode = location.state?.isReviewMode; const targetPage = typeof customPageNo === 'number' ? customPageNo : sessionPageNo;
const res: any = await activityFeedsService.getHistory({ const res: any = await activityFeedsService.getHistory({
date: activeDate, date: activeDate,
pageNo: 0, pageNo: targetPage * 50,
pageSize: 50, pageSize: 50,
sortByDateAsc: false, // must match dashboard sort so firstPendingIndex page alignment is correct
isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'), isTrueFalse: isRectActive ? 'both' : (auditStatus === 'False Audits' ? 'true' : 'both'),
auditStatus: auditStatus, auditStatus: auditStatus,
auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')), auto_audit: isRectActive ? '' : (auditStatus === 'Auto Audits' ? 'true' : (auditStatus === 'Manual' ? 'false' : '')),
@@ -104,17 +123,20 @@ export const AuditSession: React.FC = () => {
setNotAnAnomalySubCategory(null); setNotAnAnomalySubCategory(null);
setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending')); setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending'));
} else { } else {
setLoading(false); alert("No more anomalies left in the feed.");
setShowCompletionModal(false);
navigate('/activity-feeds');
} }
} catch (err) { } catch (err) {
console.error('Failed to load session anomalies:', err); console.error('Failed to load session anomalies:', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, searchAnomalyId, activeDate, isRectActive, location.state]); }, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, searchAnomalyId, activeDate, isRectActive, location.state, filterPlaza, sessionPageNo, navigate]);
const handleLoadNextFeedPage = useCallback(async () => { const handleLoadNextFeedPage = useCallback(async () => {
try { try {
setShowCompletionModal(false);
setLoading(true); setLoading(true);
const nextPage = (activeTab.page || 0) + 1; const nextPage = (activeTab.page || 0) + 1;
const selectedSiteId = selectedSite?.site_id ?? ''; const selectedSiteId = selectedSite?.site_id ?? '';
@@ -154,6 +176,8 @@ export const AuditSession: React.FC = () => {
setIsAnomaly(null); setIsAnomaly(null);
setPlantSubCategory(null); setPlantSubCategory(null);
setNotAnAnomalySubCategory(null); setNotAnAnomalySubCategory(null);
setMasterZoom(1);
setAnomalyZoom(1);
setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending')); setItemAuditStates(res.anomalies.map((a: any) => a.IsAudited === 1 ? 'audited' : 'pending'));
} else { } else {
alert("No more anomalies left in the feed."); alert("No more anomalies left in the feed.");
@@ -167,6 +191,7 @@ export const AuditSession: React.FC = () => {
}, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, searchAnomalyId, activeDate, isRectActive, location.state, activeTab, selectedTabIndex, updateTab, navigate]); }, [selectedSite, selectedAssetIds, auditStatus, isRectificationEnabled, filterFromDate, filterTillDate, filterMinChainage, filterMaxChainage, searchAnomalyId, activeDate, isRectActive, location.state, activeTab, selectedTabIndex, updateTab, navigate]);
useEffect(() => { useEffect(() => {
if (hasInitializedSession.current) return;
const navState = location.state as any; const navState = location.state as any;
if (navState?.useExistingFeed) { if (navState?.useExistingFeed) {
const activeTab = tabs[selectedTabIndex]; const activeTab = tabs[selectedTabIndex];
@@ -188,6 +213,7 @@ export const AuditSession: React.FC = () => {
} else { } else {
setLoading(false); setLoading(false);
} }
hasInitializedSession.current = true;
} else if (location.state && location.state.anomalies) { } else if (location.state && location.state.anomalies) {
setAnomalies(location.state.anomalies); setAnomalies(location.state.anomalies);
setCurrentIndex(location.state.startIndex || 0); setCurrentIndex(location.state.startIndex || 0);
@@ -203,8 +229,10 @@ export const AuditSession: React.FC = () => {
} else { } else {
setLoading(false); setLoading(false);
} }
hasInitializedSession.current = true;
} else { } else {
loadSessionData(); loadSessionData();
hasInitializedSession.current = true;
} }
}, [location.state, tabs, selectedTabIndex, loadSessionData, filterPlaza]); }, [location.state, tabs, selectedTabIndex, loadSessionData, filterPlaza]);
@@ -222,6 +250,8 @@ export const AuditSession: React.FC = () => {
} else { } else {
setCarouselIndex(0); setCarouselIndex(0);
} }
setMasterZoom(1);
setAnomalyZoom(1);
}, [currentAnomaly]); }, [currentAnomaly]);
let masterImageSrc = ''; let masterImageSrc = '';
@@ -238,16 +268,24 @@ export const AuditSession: React.FC = () => {
} }
if (currentAnomaly.Pos) { if (currentAnomaly.Pos) {
const match = String(currentAnomaly.Pos).match(/([NS][0-9.]+)([EW][0-9.]+)/i); const matchWithComma = String(currentAnomaly.Pos).match(/([NS])\s*([0-9.]+)[,\s]*([EW])\s*([0-9.]+)/i);
if (match) { if (matchWithComma) {
latStr = match[1]; const latVal = parseFloat(matchWithComma[2]).toFixed(5);
longStr = match[2]; const longVal = parseFloat(matchWithComma[4]).toFixed(5);
latStr = `${matchWithComma[1].toUpperCase()} ${latVal}`;
longStr = `${matchWithComma[3].toUpperCase()} ${longVal}`;
} else {
const matchOld = String(currentAnomaly.Pos).match(/([NS][0-9.]+)([EW][0-9.]+)/i);
if (matchOld) {
latStr = matchOld[1];
longStr = matchOld[2];
} else { } else {
latStr = currentAnomaly.Pos; latStr = currentAnomaly.Pos;
longStr = ''; longStr = '';
} }
} }
} }
}
const mediaItems: { type: 'image'|'video', url: string }[] = React.useMemo(() => { const mediaItems: { type: 'image'|'video', url: string }[] = React.useMemo(() => {
if (!currentAnomaly) return []; if (!currentAnomaly) return [];
@@ -276,6 +314,8 @@ export const AuditSession: React.FC = () => {
setIsAnomaly(null); setIsAnomaly(null);
setPlantSubCategory(null); setPlantSubCategory(null);
setNotAnAnomalySubCategory(null); setNotAnAnomalySubCategory(null);
setMasterZoom(1);
setAnomalyZoom(1);
} else { } else {
setShowCompletionModal(true); setShowCompletionModal(true);
} }
@@ -289,6 +329,8 @@ export const AuditSession: React.FC = () => {
setIsAnomaly(null); setIsAnomaly(null);
setPlantSubCategory(null); setPlantSubCategory(null);
setNotAnAnomalySubCategory(null); setNotAnAnomalySubCategory(null);
setMasterZoom(1);
setAnomalyZoom(1);
} }
}; };
@@ -499,8 +541,14 @@ export const AuditSession: React.FC = () => {
break; break;
} }
case 'enter': case 'enter':
case 'arrowright': if (selectedCategory !== null) {
handleSaveAndNext(); handleSaveAndNext();
} else {
handleSkip();
}
break;
case 'arrowright':
handleNext();
break; break;
case 'arrowleft': case 'arrowleft':
handlePrev(); handlePrev();
@@ -536,6 +584,7 @@ export const AuditSession: React.FC = () => {
<Typography sx={{ mb: 2 }}>No unaudited anomalies found to audit.</Typography> <Typography sx={{ mb: 2 }}>No unaudited anomalies found to audit.</Typography>
<Button variant="contained" onClick={() => { <Button variant="contained" onClick={() => {
const navState = location.state as any; const navState = location.state as any;
if (!navState?.useExistingFeed) invalidateDashboardCache();
navigate(navState?.useExistingFeed ? '/activity-feeds' : '/dashboard'); navigate(navState?.useExistingFeed ? '/activity-feeds' : '/dashboard');
}}>Back</Button> }}>Back</Button>
</Box> </Box>
@@ -568,6 +617,7 @@ export const AuditSession: React.FC = () => {
startIcon={<ArrowBackIcon fontSize="small" />} startIcon={<ArrowBackIcon fontSize="small" />}
onClick={() => { onClick={() => {
const navState = location.state as any; const navState = location.state as any;
if (!navState?.useExistingFeed) invalidateDashboardCache();
navigate(navState?.useExistingFeed ? '/activity-feeds' : '/dashboard'); navigate(navState?.useExistingFeed ? '/activity-feeds' : '/dashboard');
}} }}
sx={{ color: '#94a3b8', textTransform: 'none', minWidth: 'auto', p: 0, '&:hover': { color: 'white', bgcolor: 'transparent' } }} sx={{ color: '#94a3b8', textTransform: 'none', minWidth: 'auto', p: 0, '&:hover': { color: 'white', bgcolor: 'transparent' } }}
@@ -624,12 +674,26 @@ 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' }}> <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>
{masterImageSrc ? ( {masterImageSrc ? (
<img <img
src={masterImageSrc} src={masterImageSrc}
alt="Master" alt="Master"
style={{ width: '100%', height: '100%', objectFit: 'contain' }} 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) => { onError={(e) => {
e.currentTarget.onerror = null; e.currentTarget.onerror = null;
e.currentTarget.src = '/assets/images/master_image_not_available.png'; e.currentTarget.src = '/assets/images/master_image_not_available.png';
@@ -647,16 +711,35 @@ 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' }}> <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>
{mediaItems.length > 0 ? ( {mediaItems.length > 0 ? (
<> <>
{mediaItems[carouselIndex]?.type === 'video' ? ( {mediaItems[carouselIndex]?.type === 'video' ? (
<video src={mediaItems[carouselIndex]?.url} controls autoPlay loop style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> <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'
}} />
) : ( ) : (
<img <img
src={mediaItems[carouselIndex]?.url} src={mediaItems[carouselIndex]?.url}
alt="Anomaly" alt="Anomaly"
style={{ width: '100%', height: '100%', objectFit: 'contain' }} 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) => { onError={(e) => {
e.currentTarget.onerror = null; e.currentTarget.onerror = null;
e.currentTarget.src = '/assets/images/anomaly-placeholder.png'; e.currentTarget.src = '/assets/images/anomaly-placeholder.png';
@@ -685,15 +768,29 @@ export const AuditSession: React.FC = () => {
</Box> </Box>
</Box> </Box>
{/* Bottom Bar: Location Overlay */} {/* Bottom Bar: Metadata Overlay */}
<Box sx={{ bgcolor: '#0f172a', borderRadius: 2, border: '1px solid #1e293b', p: 2, display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ bgcolor: '#0f172a', borderRadius: 2, border: '1px solid #1e293b', p: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ width: 12, height: 12, borderRadius: '50%', bgcolor: '#ef4444' }} /> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography sx={{ fontWeight: 'bold', fontSize: '0.9rem' }}>{currentAnomaly?.Plaza || 'Unknown Location'}</Typography> <Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: '#ef4444', boxShadow: '0 0 8px #ef4444' }} />
<Typography sx={{ fontSize: '0.8rem', color: '#94a3b8' }}> <Typography sx={{ fontWeight: 'bold', fontSize: '1.1rem', color: '#f8fafc', letterSpacing: 0.5 }}>
{latStr}, {longStr} ID #{currentAnomaly?.id || currentAnomaly?.anomaly_id || 'N/A'} {currentAnomaly?.Plaza || 'Unknown Location'}
</Typography> </Typography>
</Box> </Box>
<Chip
label={`ID #${currentAnomaly?.id || currentAnomaly?.anomaly_id || 'N/A'}`}
size="small"
sx={{ bgcolor: 'rgba(59, 130, 246, 0.15)', color: '#60a5fa', fontWeight: 'bold', border: '1px solid rgba(59, 130, 246, 0.3)' }}
/>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Chip size="small" label={currentAnomaly?.Asset?.replace(/_/gi, ' ') || 'Unknown Asset'} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5, fontWeight: 'medium' }} />
<Chip size="small" label={currentAnomaly?.date_of_audit || currentAnomaly?.Created_on ? new Date(currentAnomaly?.date_of_audit || currentAnomaly?.Created_on).toLocaleString() : 'No Date'} sx={{ bgcolor: '#1e293b', color: '#e2e8f0', borderRadius: 1.5 }} />
<Chip size="small" label={`CH: ${currentAnomaly?.Chainage || '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 }} />
</Box>
</Box> </Box>
</Box> </Box>
@@ -912,8 +1009,8 @@ export const AuditSession: React.FC = () => {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button <Button
variant="contained" variant="contained"
color="primary" color={selectedCategory !== null ? "primary" : "inherit"}
onClick={handleSaveAndNext} onClick={selectedCategory !== null ? handleSaveAndNext : handleSkip}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
py: 1.5, py: 1.5,
@@ -923,10 +1020,13 @@ export const AuditSession: React.FC = () => {
justifyContent: 'space-between', justifyContent: 'space-between',
px: 3, px: 3,
transition: 'all 0.15s ease', transition: 'all 0.15s ease',
border: (activeKeyFlash === 'enter' || activeKeyFlash === 'arrowright') ? '2px solid #3b82f6' : 'none' bgcolor: selectedCategory !== null ? '' : '#1e293b',
color: selectedCategory !== null ? '' : '#94a3b8',
border: (activeKeyFlash === 'enter') ? (selectedCategory !== null ? '2px solid #3b82f6' : '2px solid #64748b') : 'none',
'&:hover': { bgcolor: selectedCategory !== null ? '' : '#334155' }
}} }}
> >
Save & Next <Typography component="span" sx={{ fontSize: '0.75rem', opacity: 0.7 }}>Enter / </Typography> {selectedCategory !== null ? "Save & Next" : "Next (Skip)"} <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 }}>
<Button <Button
@@ -1010,6 +1110,7 @@ export const AuditSession: React.FC = () => {
<Dialog <Dialog
open={showCompletionModal} open={showCompletionModal}
onClose={() => setShowCompletionModal(false)} onClose={() => setShowCompletionModal(false)}
sx={{ zIndex: 10000 }}
slotProps={{ slotProps={{
paper: { paper: {
sx: { sx: {
@@ -1057,7 +1158,9 @@ export const AuditSession: React.FC = () => {
variant="contained" variant="contained"
onClick={isFeedSession ? handleLoadNextFeedPage : async () => { onClick={isFeedSession ? handleLoadNextFeedPage : async () => {
setShowCompletionModal(false); setShowCompletionModal(false);
await loadSessionData(); const nextPage = sessionPageNo + 1;
setSessionPageNo(nextPage);
await loadSessionData(nextPage);
}} }}
sx={{ bgcolor: '#3b82f6', '&:hover': { bgcolor: '#2563eb' }, textTransform: 'none', fontWeight: 'bold', py: 1.2, borderRadius: 2 }} sx={{ bgcolor: '#3b82f6', '&:hover': { bgcolor: '#2563eb' }, textTransform: 'none', fontWeight: 'bold', py: 1.2, borderRadius: 2 }}
> >
@@ -1068,6 +1171,7 @@ export const AuditSession: React.FC = () => {
variant="outlined" variant="outlined"
onClick={() => { onClick={() => {
setShowCompletionModal(false); setShowCompletionModal(false);
if (!isFeedSession) invalidateDashboardCache();
navigate(isFeedSession ? '/activity-feeds' : '/dashboard'); navigate(isFeedSession ? '/activity-feeds' : '/dashboard');
}} }}
sx={{ borderColor: '#334155', color: '#94a3b8', '&:hover': { borderColor: '#475569', bgcolor: 'rgba(255,255,255,0.03)' }, textTransform: 'none', py: 1.2, borderRadius: 2 }} sx={{ borderColor: '#334155', color: '#94a3b8', '&:hover': { borderColor: '#475569', bgcolor: 'rgba(255,255,255,0.03)' }, textTransform: 'none', py: 1.2, borderRadius: 2 }}

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Box, Typography, Button, Paper, LinearProgress } from '@mui/material'; import { Box, Typography, Button, Paper } from '@mui/material';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { activityFeedsService } from '../../api/activityFeedsService'; import { activityFeedsService } from '../../api/activityFeedsService';
import { TopFilterBar } from '../../components/common/TopFilterBar'; import { TopFilterBar } from '../../components/common/TopFilterBar';
import { useFeedStore } from '../../store/feedStore'; import { useFeedStore } from '../../store/feedStore';
@@ -78,6 +77,7 @@ export const Dashboard: React.FC = () => {
batchAudited: anomalies.filter((a: any) => a.IsAudited === 1).length, batchAudited: anomalies.filter((a: any) => a.IsAudited === 1).length,
batchPending: anomalies.filter((a: any) => a.IsAudited === 0).length, batchPending: anomalies.filter((a: any) => a.IsAudited === 0).length,
batchAnomaliesFound: anomalies.filter((a: any) => a.Audit_status === 1).length, batchAnomaliesFound: anomalies.filter((a: any) => a.Audit_status === 1).length,
firstPendingIndex: anomalies.findIndex((a: any) => a.IsAudited === 0)
}; };
setStats(newStats); setStats(newStats);
// Persist to store cache so re-mounting the Dashboard skips this fetch // Persist to store cache so re-mounting the Dashboard skips this fetch
@@ -169,82 +169,19 @@ export const Dashboard: React.FC = () => {
</Paper> </Paper>
</Box> </Box>
{/* Active Batch Progress */}
<Paper sx={{ p: 3, bgcolor: '#0f172a', border: '1px solid #1e293b', borderRadius: 2, mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', color: '#f8fafc' }}>Active Batch Progress</Typography>
<Typography variant="body2" sx={{ color: '#94a3b8' }}>{stats.batchAudited} / {stats.batchTotal} completed</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progressPercent}
sx={{
height: 10,
borderRadius: 5,
bgcolor: '#1e293b',
'& .MuiLinearProgress-bar': { bgcolor: '#3b82f6', borderRadius: 5 }
}}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
<Typography variant="caption" sx={{ color: '#64748b' }}>{progressPercent}% complete</Typography>
<Typography variant="caption" sx={{ color: '#64748b' }}>~{Math.ceil(stats.batchPending / 50)} sessions remaining at 50/session</Typography>
</Box>
</Paper>
{/* Session Mechanics Explanation Box */}
<Paper sx={{
p: 3,
bgcolor: 'rgba(30, 41, 59, 0.4)',
border: '1px solid rgba(148, 163, 184, 0.1)',
borderRadius: 3,
mb: 3,
backdropFilter: 'blur(8px)'
}}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold', color: '#60a5fa', mb: 2, textTransform: 'uppercase', letterSpacing: 0.5 }}>
Audit Workflow & Session Mechanics
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr 1fr' }, gap: 2.5, mb: 1 }}>
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 0.5, fontWeight: 'bold' }}>
1. GLOBAL SITE POOL
</Typography>
<Typography variant="body2" sx={{ color: '#f8fafc' }}>
There are <b style={{ color: '#3b82f6' }}>{loading ? '...' : stats.globalTotal.toLocaleString()}</b> total items matching your active filter criteria on this site.
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 0.5, fontWeight: 'bold' }}>
2. ACTIVE WORK BATCH
</Typography>
<Typography variant="body2" sx={{ color: '#f8fafc' }}>
An active queue of up to <b style={{ color: '#10b981' }}>{loading ? '...' : stats.batchTotal}</b> items is loaded into your immediate workspace for auditing.
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 0.5, fontWeight: 'bold' }}>
3. BITE-SIZED SESSIONS (50 items)
</Typography>
<Typography variant="body2" sx={{ color: '#f8fafc' }}>
To keep image downloads fast and prevent browser lag, you audit in sessions of <b>50 items</b>.
You have <b style={{ color: '#facc15' }}>~{loading ? '...' : Math.ceil(stats.batchPending / 50)} sessions</b> remaining.
</Typography>
</Box>
</Box>
</Paper>
{/* Warning Box */}
<Paper sx={{ p: 2, px: 3, bgcolor: '#450a0a', border: '1px solid #7f1d1d', borderRadius: 2, mb: 4, display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningAmberIcon sx={{ color: '#ef4444', fontSize: 20 }} />
<Typography variant="body1" sx={{ color: '#f8fafc' }}>
<Typography component="span" sx={{ color: '#ef4444', fontWeight: 'bold' }}>Priority items</Typography> will appear first in the session queue
</Typography>
</Paper>
{/* Start Button */} {/* Start Button */}
<Button <Button
variant="contained" variant="contained"
fullWidth fullWidth
onClick={() => navigate('/audit-session', { state: { isReviewMode: stats.batchPending === 0 } })} onClick={() => navigate('/audit-session', {
state: {
isReviewMode: stats.batchPending === 0,
startPage: stats.firstPendingIndex !== undefined && stats.firstPendingIndex !== -1 ? Math.floor(stats.firstPendingIndex / 50) : 0
}
})}
disabled={stats.batchTotal === 0} disabled={stats.batchTotal === 0}
sx={{ sx={{
py: 2, py: 2,
@@ -264,10 +201,9 @@ export const Dashboard: React.FC = () => {
: `Start Session — Audit Next ${Math.min(stats.batchPending, 50)} Items`} : `Start Session — Audit Next ${Math.min(stats.batchPending, 50)} Items`}
</Button> </Button>
{/* Footer links */} {/* Footer */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 2 }}> <Box sx={{ mt: 2 }}>
<Typography variant="caption" sx={{ color: '#475569' }}>Keyboard shortcuts available inside the audit view</Typography> <Typography variant="caption" sx={{ color: '#475569' }}>Keyboard shortcuts available inside the audit view</Typography>
<Typography variant="caption" sx={{ color: '#64748b', textDecoration: 'underline', cursor: 'pointer', '&:hover': { color: '#f8fafc' } }}>Reset demo data</Typography>
</Box> </Box>
</Box> </Box>