Files
Auditor-Portal-Revamp/src/pages/admin/AnomalyBrowser.tsx

255 lines
12 KiB
TypeScript

import React, { useEffect, useState, useCallback } from 'react';
import {
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
Paper, FormControl, InputLabel, Select, MenuItem, CircularProgress,
Typography, TextField, Chip, TablePagination,
} from '@mui/material';
import { adminService } from '../../api/adminService';
import type { AnomalyRecordRow } from '../../api/adminService';
import { accountService } from '../../api/accountService';
import { activityFeedsService } from '../../api/activityFeedsService';
import { AssetMultiSelect } from '../../components/common/AssetMultiSelect';
import { parseSites } from '../../components/common/parseSites';
import { useToastStore } from '../../store/toastStore';
interface Organization {
org_id: string;
org_name: string;
db_name: string;
}
const PAGE_SIZE = 25;
// Status chip colors: semantic, matching how these states read in the product.
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
Anomaly: { bg: 'rgba(234, 179, 8, 0.15)', fg: '#facc15' },
Under_Inspection: { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' },
Manual_Close: { bg: 'rgba(168, 85, 247, 0.15)', fg: '#c084fc' },
Completed: { bg: 'rgba(16, 185, 129, 0.15)', fg: '#34d399' },
Ignored: { bg: 'rgba(100, 116, 139, 0.2)', fg: '#94a3b8' },
Deleted: { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' },
};
// Admin browser over a per-org work-order table, shared by TWO tabs:
// table="anomaly" (default) - live records: Anomaly/Under_Inspection/
// Manual_Close/Completed/Ignored
// table="complete" - the history table /close copies rows into; its status
// vocabulary is only Completed/Deleted (server-driven, so
// the Status dropdown always matches the chosen table).
// Filters: status / site / assets / frequency / completed_on window /
// ai_rejected. Backed by GET /admin/anomaly-records (orgDb param - the axios
// interceptor owns dbName).
interface AnomalyBrowserProps {
table?: 'anomaly' | 'complete';
}
export const AnomalyBrowser: React.FC<AnomalyBrowserProps> = ({ table = 'anomaly' }) => {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [orgDb, setOrgDb] = useState('');
// Sites of the selected org, GROUPED by display name - paired directions
// share a name ("454,455" is one picker entry), so value = comma id list.
const [siteOptions, setSiteOptions] = useState<{ label: string; ids: string }[]>([]);
const [siteIds, setSiteIds] = useState('');
const [frequencyGt, setFrequencyGt] = useState('');
// Default view per table: the state each tab is actually opened for.
const [status, setStatus] = useState(table === 'complete' ? '' : 'Manual_Close');
const [statuses, setStatuses] = useState<string[]>(
table === 'complete' ? ['Completed', 'Deleted'] : ['Anomaly', 'Under_Inspection', 'Manual_Close', 'Completed', 'Ignored'],
);
const [assetIds, setAssetIds] = useState<number[]>([]);
const [completedFrom, setCompletedFrom] = useState('');
const [completedTo, setCompletedTo] = useState('');
const [aiRejected, setAiRejected] = useState('');
const [rows, setRows] = useState<AnomalyRecordRow[]>([]);
const [count, setCount] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
(async () => {
try {
const orgs = ((await accountService.getOrganizations()) as any) || [];
setOrganizations(orgs);
if (orgs.length > 0) setOrgDb(orgs[0].db_name);
} catch {
useToastStore.getState().showToast('Failed to load organizations.', 'error');
}
})();
}, []);
// Load the selected org's sites whenever the org changes. /Master/site's
// response is deeply nested and groups paired directions under one name -
// parseSites handles both (value = comma-joined ids, the "454,455" rule).
useEffect(() => {
setSiteIds('');
setSiteOptions([]);
const org = organizations.find((o) => o.db_name === orgDb);
if (!org) return;
(async () => {
try {
const res = await activityFeedsService.getOrganizationSites(String(org.org_id));
setSiteOptions(parseSites(res).map((s) => ({ label: String(s.site_name), ids: String(s.site_id) })));
} catch {
// site filter just stays empty - listing still works without it
}
})();
}, [orgDb, organizations]);
// Same inverted-range normalization as the feed filters; "to" extended to
// end of day so a single-day range matches its whole day.
const [effFrom, effTo] = (() => {
if (!completedFrom || !completedTo) {
return [completedFrom || undefined, completedTo ? `${completedTo}T23:59:59` : undefined];
}
return completedFrom <= completedTo
? [completedFrom, `${completedTo}T23:59:59`]
: [completedTo, `${completedFrom}T23:59:59`];
})();
const fetchRows = useCallback(async () => {
if (!orgDb) return;
setLoading(true);
try {
const res = await adminService.getAnomalyRecords({
orgDb,
table,
status: status || undefined,
assetIds,
siteIds: siteIds || undefined,
frequencyGt: frequencyGt || undefined,
completedFrom: effFrom,
completedTo: effTo,
aiRejected: aiRejected || undefined,
pageNo: page,
pageSize: PAGE_SIZE,
});
setRows(res.rows);
setCount(res.count);
if (res.statuses?.length) setStatuses(res.statuses);
} catch {
useToastStore.getState().showToast('Failed to load anomaly records.', 'error');
} finally {
setLoading(false);
}
}, [orgDb, table, status, assetIds, siteIds, frequencyGt, effFrom, effTo, aiRejected, page]);
useEffect(() => { fetchRows(); }, [fetchRows]);
// Any filter change goes back to page 0 so the pager can't point past the result.
useEffect(() => { setPage(0); }, [orgDb, status, assetIds, siteIds, frequencyGt, effFrom, effTo, aiRejected]);
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString() : '—');
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={orgDb} onChange={(e) => setOrgDb(e.target.value)}>
{organizations.map((o) => <MenuItem key={o.org_id} value={o.db_name}>{o.org_name}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 170 }}>
<InputLabel>Status</InputLabel>
<Select label="Status" value={status} onChange={(e) => setStatus(e.target.value)}>
<MenuItem value="">All statuses</MenuItem>
{statuses.map((s) => <MenuItem key={s} value={s}>{s.replace(/_/g, ' ')}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Site</InputLabel>
<Select label="Site" value={siteIds} onChange={(e) => setSiteIds(e.target.value)}>
<MenuItem value="">All sites</MenuItem>
{siteOptions.map((o) => <MenuItem key={o.ids} value={o.ids}>{o.label}</MenuItem>)}
</Select>
</FormControl>
<AssetMultiSelect value={assetIds} onChange={setAssetIds} />
<TextField
size="small" label="Frequency >" placeholder="Any" value={frequencyGt}
onChange={(e) => setFrequencyGt(e.target.value.replace(/[^0-9]/g, ''))}
slotProps={{ inputLabel: { shrink: true }, htmlInput: { inputMode: 'numeric', pattern: '[0-9]*' } }}
sx={{ width: 110 }}
/>
<TextField size="small" type="date" label="Completed from" value={completedFrom}
onChange={(e) => setCompletedFrom(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} />
<TextField size="small" type="date" label="Completed to" value={completedTo}
onChange={(e) => setCompletedTo(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} />
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>AI Rejected</InputLabel>
<Select label="AI Rejected" value={aiRejected} onChange={(e) => setAiRejected(e.target.value)}>
<MenuItem value="">Any</MenuItem>
<MenuItem value="true">Yes</MenuItem>
<MenuItem value="false">No</MenuItem>
</Select>
</FormControl>
{loading && <CircularProgress size={20} />}
<Typography variant="body2" sx={{ color: '#94a3b8', ml: 'auto' }}>
{count.toLocaleString()} record{count === 1 ? '' : 's'}
</Typography>
</Box>
<TableContainer component={Paper} sx={{ bgcolor: '#0f172a', border: '1px solid #1e293b' }}>
<Table size="small">
<TableHead>
<TableRow>
{['ID', 'Site', 'Asset', 'Status', 'Chainage', 'Freq', 'AI Rejected', 'Manual Close', 'Completed On', 'AI Completed On', 'Master ID'].map((h) => (
<TableCell key={h} sx={{ color: '#94a3b8', borderColor: '#1e293b', fontWeight: 'bold', whiteSpace: 'nowrap' }}>{h}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.length === 0 && !loading ? (
<TableRow><TableCell colSpan={11} sx={{ color: '#64748b', borderColor: '#1e293b', textAlign: 'center', py: 6 }}>
No records match these filters
</TableCell></TableRow>
) : rows.map((r) => {
const st = STATUS_STYLE[r.status] || STATUS_STYLE.Ignored;
return (
<TableRow key={r.anomaly_id} hover>
<TableCell sx={{ color: '#60a5fa', borderColor: '#1e293b', fontWeight: 'bold' }}>#{r.anomaly_id}</TableCell>
<TableCell sx={{ color: '#e2e8f0', borderColor: '#1e293b' }}>{r.site_name}</TableCell>
<TableCell sx={{ color: '#e2e8f0', borderColor: '#1e293b' }}>{r.asset_name?.replace(/_/g, ' ')}</TableCell>
<TableCell sx={{ borderColor: '#1e293b' }}>
<Chip size="small" label={r.status?.replace(/_/g, ' ')} sx={{ bgcolor: st.bg, color: st.fg, fontWeight: 'bold' }} />
</TableCell>
<TableCell sx={{ color: '#cbd5e1', borderColor: '#1e293b' }}>{r.chainage ?? '—'}</TableCell>
<TableCell sx={{ color: '#cbd5e1', borderColor: '#1e293b' }}>{r.frequency ?? '—'}</TableCell>
<TableCell sx={{ borderColor: '#1e293b' }}>
{r.ai_rejected
? <Chip size="small" label="Yes" sx={{ bgcolor: 'rgba(239,68,68,0.15)', color: '#f87171', fontWeight: 'bold' }} />
: <Typography component="span" sx={{ color: '#475569', fontSize: '0.8rem' }}>No</Typography>}
</TableCell>
<TableCell sx={{ color: '#cbd5e1', borderColor: '#1e293b' }}>
{r.manual_close === null || r.manual_close === undefined ? '—' : String(r.manual_close)}
</TableCell>
<TableCell sx={{ color: '#cbd5e1', borderColor: '#1e293b', whiteSpace: 'nowrap' }}>{fmt(r.completed_on)}</TableCell>
<TableCell sx={{ color: r.ai_completed_on ? '#c084fc' : '#475569', borderColor: '#1e293b', whiteSpace: 'nowrap' }}>{fmt(r.ai_completed_on)}</TableCell>
<TableCell sx={{ color: '#64748b', borderColor: '#1e293b', maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={r.master_id || ''}>
{r.master_id ?? '—'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<TablePagination
component="div"
count={count}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
sx={{ color: '#94a3b8', borderTop: '1px solid #1e293b' }}
/>
</TableContainer>
</Box>
);
};