feat: add admin service, console page, and missing media monitoring module
This commit is contained in:
@@ -159,6 +159,35 @@ export interface ClientsOverviewResult {
|
|||||||
clients: ClientOverviewOrg[];
|
clients: ClientOverviewOrg[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MissingMediaRecord {
|
||||||
|
id: number;
|
||||||
|
Asset: string | null;
|
||||||
|
Plaza: string | null;
|
||||||
|
site_id: number;
|
||||||
|
video_date: string | null;
|
||||||
|
frame_available: boolean | null;
|
||||||
|
video_available: boolean | null;
|
||||||
|
has_video_declared: boolean;
|
||||||
|
media_checked_at: string | null;
|
||||||
|
media_check_count: number;
|
||||||
|
// human-readable blocker: "not checked yet" | "image" | "video" | "image + video"
|
||||||
|
missing: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissingMediaSummary {
|
||||||
|
total: number;
|
||||||
|
neverChecked: number;
|
||||||
|
missingImage: number;
|
||||||
|
missingVideo: number;
|
||||||
|
gaveUp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissingMediaResult {
|
||||||
|
records: MissingMediaRecord[];
|
||||||
|
total: number;
|
||||||
|
summary: MissingMediaSummary;
|
||||||
|
}
|
||||||
|
|
||||||
export const adminService = {
|
export const adminService = {
|
||||||
getUsers: async (): Promise<AdminUser[]> => {
|
getUsers: async (): Promise<AdminUser[]> => {
|
||||||
return (await axiosClient.get('/api/audit/admin/users')) as unknown as AdminUser[];
|
return (await axiosClient.get('/api/audit/admin/users')) as unknown as AdminUser[];
|
||||||
@@ -263,6 +292,29 @@ export const adminService = {
|
|||||||
return result.states;
|
return result.states;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Pending records hidden from all auditing/allocation surfaces because
|
||||||
|
// their media isn't confirmed on the CDN yet.
|
||||||
|
getMissingMedia: async (params: {
|
||||||
|
dbName: string;
|
||||||
|
type: 'audits' | 'rectification';
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<MissingMediaResult> => {
|
||||||
|
return (await axiosClient.get('/api/audit/admin/media-status', { params })) as unknown as MissingMediaResult;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Immediate CDN re-verify for up to 50 records (also resets the cron's
|
||||||
|
// give-up counter for them).
|
||||||
|
recheckMedia: async (payload: {
|
||||||
|
dbName: string;
|
||||||
|
type: 'audits' | 'rectification';
|
||||||
|
ids: number[];
|
||||||
|
}): Promise<{ checked: number; ready: number; missing: number; skipped: number }> => {
|
||||||
|
return (await axiosClient.post('/api/audit/admin/media-status/recheck', payload)) as unknown as {
|
||||||
|
checked: number; ready: number; missing: number; skipped: number;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
getClientsOverview: async (params: {
|
getClientsOverview: async (params: {
|
||||||
type: 'audits' | 'rectification';
|
type: 'audits' | 'rectification';
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { LoginHistory } from './LoginHistory';
|
|||||||
import { Allocation } from './Allocation';
|
import { Allocation } from './Allocation';
|
||||||
import { AuditHistory } from './AuditHistory';
|
import { AuditHistory } from './AuditHistory';
|
||||||
import { DevTickets } from './DevTickets';
|
import { DevTickets } from './DevTickets';
|
||||||
|
import { MissingMedia } from './MissingMedia';
|
||||||
|
|
||||||
export const AdminConsole: React.FC = () => {
|
export const AdminConsole: React.FC = () => {
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
@@ -24,6 +25,7 @@ export const AdminConsole: React.FC = () => {
|
|||||||
<Tab label="Allocation" />
|
<Tab label="Allocation" />
|
||||||
<Tab label="Audit & Rectification History" />
|
<Tab label="Audit & Rectification History" />
|
||||||
<Tab label="Dev Tickets" />
|
<Tab label="Dev Tickets" />
|
||||||
|
<Tab label="Missing Media" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{tab === 0 && <ClientsOverview />}
|
{tab === 0 && <ClientsOverview />}
|
||||||
@@ -32,6 +34,7 @@ export const AdminConsole: React.FC = () => {
|
|||||||
{tab === 3 && <Allocation />}
|
{tab === 3 && <Allocation />}
|
||||||
{tab === 4 && <AuditHistory />}
|
{tab === 4 && <AuditHistory />}
|
||||||
{tab === 5 && <DevTickets />}
|
{tab === 5 && <DevTickets />}
|
||||||
|
{tab === 6 && <MissingMedia />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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