255 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|