232 lines
9.6 KiB
TypeScript
232 lines
9.6 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
|
Paper, Checkbox, FormControl, InputLabel, Select, MenuItem, ToggleButtonGroup,
|
|
ToggleButton, Button, CircularProgress, TablePagination, Typography, Chip, Tooltip,
|
|
} from '@mui/material';
|
|
import { adminService } from '../../api/adminService';
|
|
import type { MissingMediaRecord, MissingMediaSummary, MissingMediaFilter } from '../../api/adminService';
|
|
import { accountService } from '../../api/accountService';
|
|
import { useToastStore } from '../../store/toastStore';
|
|
|
|
interface Organization {
|
|
org_id: string;
|
|
org_name: string;
|
|
db_name: string;
|
|
}
|
|
|
|
const PAGE_SIZE = 25;
|
|
|
|
const missingColor = (missing: string): 'default' | 'warning' | 'error' => {
|
|
if (missing === 'not checked yet') return 'default';
|
|
if (missing === 'video') return 'warning';
|
|
return 'error'; // image or image + video - definitely unauditable
|
|
};
|
|
|
|
export const MissingMedia: React.FC = () => {
|
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
|
const [dbName, setDbName] = useState('');
|
|
const [type, setType] = useState<'audits' | 'rectification'>('audits');
|
|
const [missing, setMissing] = useState<'all' | MissingMediaFilter>('all');
|
|
const [records, setRecords] = useState<MissingMediaRecord[]>([]);
|
|
const [summary, setSummary] = useState<MissingMediaSummary | null>(null);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(0);
|
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
const [loading, setLoading] = useState(false);
|
|
const [rechecking, setRechecking] = useState(false);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const orgs = ((await accountService.getOrganizations()) as any) || [];
|
|
setOrganizations(orgs);
|
|
if (orgs.length > 0) setDbName(orgs[0].db_name);
|
|
} catch {
|
|
useToastStore.getState().showToast('Failed to load organizations.', 'error');
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!dbName) return;
|
|
setLoading(true);
|
|
try {
|
|
const result = await adminService.getMissingMedia({
|
|
dbName, type, limit: PAGE_SIZE, offset: page * PAGE_SIZE,
|
|
...(missing !== 'all' ? { missing } : {}),
|
|
});
|
|
setRecords(result.records);
|
|
setTotal(result.total);
|
|
setSummary(result.summary);
|
|
// A recheck under an active filter can shrink the filtered total below
|
|
// the current page - snap back instead of stranding on an empty page.
|
|
if (page > 0 && result.total <= page * PAGE_SIZE) setPage(0);
|
|
} catch {
|
|
useToastStore.getState().showToast('Failed to load missing-media records.', 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [dbName, type, page, missing]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
useEffect(() => {
|
|
setPage(0);
|
|
setSelectedIds(new Set());
|
|
}, [dbName, type, missing]);
|
|
|
|
const toggleRow = (id: number) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id); else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const toggleAll = () => {
|
|
setSelectedIds(selectedIds.size === records.length ? new Set() : new Set(records.map((r) => r.id)));
|
|
};
|
|
|
|
const handleRecheck = async (ids: number[]) => {
|
|
setRechecking(true);
|
|
try {
|
|
const result = await adminService.recheckMedia({ dbName, type, ids });
|
|
useToastStore.getState().showToast(
|
|
`Rechecked ${result.checked}: ${result.ready} now ready, ${result.missing} still missing${result.skipped ? `, ${result.skipped} unreachable` : ''}.`,
|
|
result.ready > 0 ? 'success' : 'info'
|
|
);
|
|
setSelectedIds(new Set());
|
|
load();
|
|
} catch (err: any) {
|
|
useToastStore.getState().showToast(err?.response?.data?.message || 'Recheck failed.', 'error');
|
|
} finally {
|
|
setRechecking(false);
|
|
}
|
|
};
|
|
|
|
const summaryChip = (label: string, value: number | undefined, color: 'default' | 'warning' | 'error' | 'info') => (
|
|
<Chip label={`${label}: ${value ?? '…'}`} color={color === 'default' ? undefined : color} variant="outlined" />
|
|
);
|
|
|
|
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={(_, v) => v && setType(v)} size="small">
|
|
<ToggleButton value="audits">Audits</ToggleButton>
|
|
<ToggleButton value="rectification">Rectification</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
|
|
<ToggleButtonGroup value={missing} exclusive onChange={(_, v) => v && setMissing(v)} size="small">
|
|
<ToggleButton value="all">All</ToggleButton>
|
|
<ToggleButton value="image">Image missing</ToggleButton>
|
|
<ToggleButton value="video">Video missing</ToggleButton>
|
|
<ToggleButton value="both">Both missing</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
|
|
<Button
|
|
variant="contained"
|
|
disabled={selectedIds.size === 0 || selectedIds.size > 50 || rechecking}
|
|
onClick={() => handleRecheck(Array.from(selectedIds))}
|
|
>
|
|
{rechecking ? <CircularProgress size={20} sx={{ color: 'white' }} /> : `Recheck selected (${selectedIds.size})`}
|
|
</Button>
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
max 50 at a time — the 15-min cron keeps checking the rest automatically
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap' }}>
|
|
{summaryChip('Hidden from auditing', summary?.total, 'error')}
|
|
{summaryChip('Not checked yet', summary?.neverChecked, 'default')}
|
|
{summaryChip('Image missing', summary?.missingImage, 'error')}
|
|
{summaryChip('Video missing', summary?.missingVideo, 'warning')}
|
|
{summaryChip('Both missing', summary?.missingBoth, 'error')}
|
|
{summaryChip('Gave up (24h of retries)', summary?.gaveUp, 'info')}
|
|
</Box>
|
|
|
|
{loading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>
|
|
) : (
|
|
<TableContainer component={Paper}>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell padding="checkbox">
|
|
<Checkbox
|
|
checked={records.length > 0 && selectedIds.size === records.length}
|
|
indeterminate={selectedIds.size > 0 && selectedIds.size < records.length}
|
|
onChange={toggleAll}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>Record ID</TableCell>
|
|
<TableCell>Asset</TableCell>
|
|
<TableCell>Plaza</TableCell>
|
|
<TableCell>Site</TableCell>
|
|
<TableCell>Video Date</TableCell>
|
|
<TableCell>Missing</TableCell>
|
|
<TableCell align="right">Checks</TableCell>
|
|
<TableCell>Last Checked</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{records.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}>
|
|
{missing !== 'all'
|
|
? 'No records match this filter.'
|
|
: 'No pending records with missing media — everything is auditable. 🎉'}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{records.map((r) => (
|
|
<TableRow key={r.id} selected={selectedIds.has(r.id)}>
|
|
<TableCell padding="checkbox">
|
|
<Checkbox checked={selectedIds.has(r.id)} onChange={() => toggleRow(r.id)} />
|
|
</TableCell>
|
|
<TableCell>{r.id}</TableCell>
|
|
<TableCell>{r.Asset || '—'}</TableCell>
|
|
<TableCell>{r.Plaza || '—'}</TableCell>
|
|
<TableCell>{r.site_id}</TableCell>
|
|
<TableCell>{r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}</TableCell>
|
|
<TableCell>
|
|
<Chip size="small" label={r.missing} color={missingColor(r.missing)} />
|
|
{!r.has_video_declared && (
|
|
<Tooltip title="Record declares no video, so only the image gates it">
|
|
<Chip size="small" label="no video declared" variant="outlined" sx={{ ml: 0.5 }} />
|
|
</Tooltip>
|
|
)}
|
|
</TableCell>
|
|
<TableCell align="right">
|
|
{r.media_check_count >= 96 ? (
|
|
<Tooltip title="Gave up after ~24h of retries — use Recheck to resume">
|
|
<Chip size="small" label={r.media_check_count} color="warning" />
|
|
</Tooltip>
|
|
) : r.media_check_count}
|
|
</TableCell>
|
|
<TableCell>{r.media_checked_at ? new Date(r.media_checked_at).toLocaleString() : 'never'}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
<TablePagination
|
|
component="div"
|
|
count={total}
|
|
page={page}
|
|
onPageChange={(_, newPage) => setPage(newPage)}
|
|
rowsPerPage={PAGE_SIZE}
|
|
rowsPerPageOptions={[PAGE_SIZE]}
|
|
/>
|
|
</TableContainer>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|