366 lines
15 KiB
TypeScript
366 lines
15 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
|
Paper, FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup,
|
|
ToggleButton, CircularProgress, Typography, TextField, Chip, TablePagination,
|
|
Tooltip,
|
|
} from '@mui/material';
|
|
import { adminService } from '../../api/adminService';
|
|
import type { AuditHistoryUserCount, AuditHistoryRecord } from '../../api/adminService';
|
|
import { accountService } from '../../api/accountService';
|
|
import { useToastStore } from '../../store/toastStore';
|
|
|
|
interface Organization {
|
|
org_id: string;
|
|
org_name: string;
|
|
db_name: string;
|
|
}
|
|
|
|
type Preset = 'today' | 'overall' | 'custom_day' | 'custom_range';
|
|
|
|
const startOfDay = (dateStr: string) => `${dateStr}T00:00:00`;
|
|
const endOfDay = (dateStr: string) => `${dateStr}T23:59:59`;
|
|
const todayStr = () => new Date().toISOString().split('T')[0];
|
|
const RECORDS_PAGE_SIZE = 25;
|
|
|
|
const efficiencyColor = (value: number): 'success' | 'warning' | 'error' => {
|
|
if (value >= 75) return 'success';
|
|
if (value >= 40) return 'warning';
|
|
return 'error';
|
|
};
|
|
|
|
// "Assigned" is video_date-based (work whose video arrived in this window)
|
|
// and "Completed" is Audited_on-based (work actually finished in this
|
|
// window) - two independent measures, not two views of the same row set. A
|
|
// user can complete backlog items today with nothing newly assigned today.
|
|
|
|
const SummaryCard: React.FC<{ label: string; value: string; color?: string }> = ({ label, value, color }) => (
|
|
<Paper sx={{ p: 2, minWidth: 160 }}>
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{label}</Typography>
|
|
<Typography variant="h5" sx={{ fontWeight: 'bold', color }}>{value}</Typography>
|
|
</Paper>
|
|
);
|
|
|
|
export const AuditHistory: React.FC = () => {
|
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
|
const [dbName, setDbName] = useState('');
|
|
const [type, setType] = useState<'audits' | 'rectification'>('audits');
|
|
const [preset, setPreset] = useState<Preset>('today');
|
|
const [customDay, setCustomDay] = useState(todayStr());
|
|
const [rangeFrom, setRangeFrom] = useState(todayStr());
|
|
const [rangeTo, setRangeTo] = useState(todayStr());
|
|
|
|
const [total, setTotal] = useState(0);
|
|
const [audited, setAudited] = useState(0);
|
|
const [notAudited, setNotAudited] = useState(0);
|
|
const [byUser, setByUser] = useState<AuditHistoryUserCount[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Drill-down: which user's individual records are currently shown below.
|
|
const [drillUser, setDrillUser] = useState<AuditHistoryUserCount | null>(null);
|
|
const [drillRecords, setDrillRecords] = useState<AuditHistoryRecord[]>([]);
|
|
const [drillTotal, setDrillTotal] = useState(0);
|
|
const [drillPage, setDrillPage] = useState(0);
|
|
const [drillLoading, setDrillLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const orgsRes = await accountService.getOrganizations();
|
|
const orgs = (orgsRes as any) || [];
|
|
setOrganizations(orgs);
|
|
if (orgs.length > 0) setDbName(orgs[0].db_name);
|
|
} catch (err) {
|
|
useToastStore.getState().showToast('Failed to load organizations.', 'error');
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
const { from, to } = (() => {
|
|
switch (preset) {
|
|
case 'today':
|
|
return { from: startOfDay(todayStr()), to: endOfDay(todayStr()) };
|
|
case 'custom_day':
|
|
return { from: startOfDay(customDay), to: endOfDay(customDay) };
|
|
case 'custom_range':
|
|
// Both fields default to today, so it's easy to end up with an
|
|
// inverted range while adjusting them one at a time (e.g. picking a
|
|
// past "To" before touching "From") - that would silently match zero
|
|
// rows instead of erroring, so normalize the order here.
|
|
return rangeFrom <= rangeTo
|
|
? { from: startOfDay(rangeFrom), to: endOfDay(rangeTo) }
|
|
: { from: startOfDay(rangeTo), to: endOfDay(rangeFrom) };
|
|
case 'overall':
|
|
default:
|
|
return { from: undefined, to: undefined };
|
|
}
|
|
})();
|
|
|
|
const load = useCallback(async () => {
|
|
if (!dbName) return;
|
|
setLoading(true);
|
|
try {
|
|
const result = await adminService.getAuditHistorySummary({ dbName, type, from, to });
|
|
setTotal(result.total);
|
|
setAudited(result.audited);
|
|
setNotAudited(result.notAudited);
|
|
setByUser(result.byUser);
|
|
} catch (err) {
|
|
useToastStore.getState().showToast('Failed to load audit history.', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [dbName, type, from, to]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
// Changing org/type/date invalidates whatever drill-down was open.
|
|
setDrillUser(null);
|
|
}, [load]);
|
|
|
|
const loadDrillRecords = useCallback(async (user: AuditHistoryUserCount, pageIndex: number) => {
|
|
setDrillLoading(true);
|
|
try {
|
|
const result = await adminService.getAuditHistoryUserRecords({
|
|
dbName, type, userId: user.user_id, from, to,
|
|
limit: RECORDS_PAGE_SIZE, offset: pageIndex * RECORDS_PAGE_SIZE,
|
|
});
|
|
setDrillRecords(result.records);
|
|
setDrillTotal(result.total);
|
|
} catch (err) {
|
|
useToastStore.getState().showToast('Failed to load user records.', 'error');
|
|
} finally {
|
|
setDrillLoading(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [dbName, type, from, to]);
|
|
|
|
const handleRowClick = (user: AuditHistoryUserCount) => {
|
|
if (drillUser?.user_id === user.user_id) {
|
|
setDrillUser(null);
|
|
return;
|
|
}
|
|
setDrillUser(user);
|
|
setDrillPage(0);
|
|
loadDrillRecords(user, 0);
|
|
};
|
|
|
|
const handleDrillPageChange = (newPage: number) => {
|
|
setDrillPage(newPage);
|
|
if (drillUser) loadDrillRecords(drillUser, newPage);
|
|
};
|
|
|
|
const completionRate = total > 0 ? Math.round((audited / total) * 1000) / 10 : 0;
|
|
|
|
return (
|
|
<Box>
|
|
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
|
<InputLabel>Organization</InputLabel>
|
|
<Select label="Organization" value={dbName} onChange={(e) => setDbName(e.target.value)}>
|
|
{organizations.map((o) => <MenuItem key={o.org_id} value={o.db_name}>{o.org_name}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<ToggleButtonGroup
|
|
value={type}
|
|
exclusive
|
|
onChange={(_, value) => value && setType(value)}
|
|
size="small"
|
|
>
|
|
<ToggleButton value="audits">Audits</ToggleButton>
|
|
<ToggleButton value="rectification">Rectification</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
|
|
<ToggleButtonGroup
|
|
value={preset}
|
|
exclusive
|
|
onChange={(_, value) => value && setPreset(value)}
|
|
size="small"
|
|
>
|
|
<ToggleButton value="today">Today</ToggleButton>
|
|
<ToggleButton value="overall">Overall</ToggleButton>
|
|
<ToggleButton value="custom_day">Custom day</ToggleButton>
|
|
<ToggleButton value="custom_range">Custom range</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center' }}>
|
|
{preset === 'custom_day' && (
|
|
<TextField
|
|
label="Day"
|
|
type="date"
|
|
size="small"
|
|
value={customDay}
|
|
onChange={(e) => setCustomDay(e.target.value)}
|
|
slotProps={{ inputLabel: { shrink: true } }}
|
|
/>
|
|
)}
|
|
{preset === 'custom_range' && (
|
|
<>
|
|
<TextField
|
|
label="From"
|
|
type="date"
|
|
size="small"
|
|
value={rangeFrom}
|
|
onChange={(e) => setRangeFrom(e.target.value)}
|
|
slotProps={{ inputLabel: { shrink: true } }}
|
|
/>
|
|
<TextField
|
|
label="To"
|
|
type="date"
|
|
size="small"
|
|
value={rangeTo}
|
|
onChange={(e) => setRangeTo(e.target.value)}
|
|
slotProps={{ inputLabel: { shrink: true } }}
|
|
/>
|
|
</>
|
|
)}
|
|
</Box>
|
|
|
|
{loading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
|
) : (
|
|
<>
|
|
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap' }}>
|
|
<SummaryCard label="Total in range" value={total.toLocaleString()} />
|
|
<SummaryCard label="Audited" value={audited.toLocaleString()} color="#22c55e" />
|
|
<SummaryCard label="Not audited" value={notAudited.toLocaleString()} color="#f59e0b" />
|
|
<SummaryCard label="Completion rate" value={`${completionRate}%`} />
|
|
</Box>
|
|
|
|
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>
|
|
Per-user activity — click a row for their individual records
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ mb: 1, display: 'block', color: 'text.secondary' }}>
|
|
Assigned = video arrived in this window · Completed = audited in this window (may include older backlog)
|
|
</Typography>
|
|
<TableContainer component={Paper} sx={{ mb: drillUser ? 3 : 0 }}>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>User</TableCell>
|
|
<TableCell align="right">
|
|
<Tooltip title="Records assigned to the user whose video date falls in the selected range">
|
|
<span>Assigned</span>
|
|
</Tooltip>
|
|
</TableCell>
|
|
<TableCell align="right">
|
|
<Tooltip title="Records the user completed within the selected range - includes backlog whose video arrived earlier, so this can exceed Assigned">
|
|
<span>Completed</span>
|
|
</Tooltip>
|
|
</TableCell>
|
|
<TableCell align="right">
|
|
<Tooltip title="Share of the user's own Assigned queue that is done - never exceeds 100%">
|
|
<span>Queue Done %</span>
|
|
</Tooltip>
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{byUser.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={4} align="center" sx={{ color: 'text.secondary' }}>
|
|
No activity in this range.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{byUser.map((u) => (
|
|
<TableRow
|
|
key={u.user_id}
|
|
hover
|
|
selected={drillUser?.user_id === u.user_id}
|
|
onClick={() => handleRowClick(u)}
|
|
sx={{ cursor: 'pointer' }}
|
|
>
|
|
<TableCell>{u.username}</TableCell>
|
|
<TableCell align="right">{u.assigned.toLocaleString()}</TableCell>
|
|
<TableCell align="right">{u.completed.toLocaleString()}</TableCell>
|
|
<TableCell align="right">
|
|
{u.efficiency === null ? (
|
|
<Chip size="small" label="N/A" variant="outlined" />
|
|
) : (
|
|
<Chip size="small" label={`${u.efficiency}%`} color={efficiencyColor(u.efficiency)} />
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
|
|
{drillUser && (
|
|
<>
|
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
|
Records assigned to {drillUser.username}
|
|
</Typography>
|
|
{drillLoading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
|
) : (
|
|
<TableContainer component={Paper}>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Anomaly ID</TableCell>
|
|
<TableCell>Asset</TableCell>
|
|
<TableCell>Plaza</TableCell>
|
|
<TableCell>Chainage</TableCell>
|
|
<TableCell>Video Date</TableCell>
|
|
<TableCell>Status</TableCell>
|
|
<TableCell>Audited On</TableCell>
|
|
<TableCell>Audited By</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{drillRecords.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={8} align="center" sx={{ color: 'text.secondary' }}>
|
|
No records found.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{drillRecords.map((r) => (
|
|
<TableRow key={r.id}>
|
|
<TableCell>{r.id}</TableCell>
|
|
<TableCell>{r.Asset || '—'}</TableCell>
|
|
<TableCell>{r.Plaza || '—'}</TableCell>
|
|
<TableCell>{r.Chainage || '—'}</TableCell>
|
|
<TableCell>{r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}</TableCell>
|
|
<TableCell>
|
|
{r.IsAudited ? (
|
|
<Chip size="small" label="Audited" color="success" />
|
|
) : (
|
|
<Chip size="small" label="Pending" color="default" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell>{r.Audited_on ? new Date(r.Audited_on).toLocaleString() : '—'}</TableCell>
|
|
<TableCell>
|
|
{r.audited_username
|
|
? r.audited_username === drillUser.username
|
|
? r.audited_username
|
|
: `${r.audited_username} (reassigned)`
|
|
: '—'}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
<TablePagination
|
|
component="div"
|
|
count={drillTotal}
|
|
page={drillPage}
|
|
onPageChange={(_, newPage) => handleDrillPageChange(newPage)}
|
|
rowsPerPage={RECORDS_PAGE_SIZE}
|
|
rowsPerPageOptions={[RECORDS_PAGE_SIZE]}
|
|
/>
|
|
</TableContainer>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|