Files
Auditor-Portal-Revamp/src/pages/admin/MissingMedia.tsx
2026-07-14 14:20:24 +05:30

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>
);
};