feat: implement activity feed module with dynamic filtering, state management, and asset selection components
This commit is contained in:
@@ -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
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
335
src/components/common/BulkDeleteDialog.tsx
Normal file
335
src/components/common/BulkDeleteDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user