feat: add admin service, console page, and missing media monitoring module
This commit is contained in:
216
src/pages/admin/MissingMedia.tsx
Normal file
216
src/pages/admin/MissingMedia.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
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 } 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 [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,
|
||||
});
|
||||
setRecords(result.records);
|
||||
setTotal(result.total);
|
||||
setSummary(result.summary);
|
||||
} catch {
|
||||
useToastStore.getState().showToast('Failed to load missing-media records.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dbName, type, page]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
setSelectedIds(new Set());
|
||||
}, [dbName, type]);
|
||||
|
||||
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>
|
||||
|
||||
<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('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' }}>
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user