diff --git a/src/api/adminService.ts b/src/api/adminService.ts index 540bae4..f65a58e 100644 --- a/src/api/adminService.ts +++ b/src/api/adminService.ts @@ -159,6 +159,35 @@ export interface ClientsOverviewResult { 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 = { getUsers: async (): Promise => { return (await axiosClient.get('/api/audit/admin/users')) as unknown as AdminUser[]; @@ -263,6 +292,29 @@ export const adminService = { 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 => { + 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: { type: 'audits' | 'rectification'; dateFrom?: string; diff --git a/src/pages/admin/AdminConsole.tsx b/src/pages/admin/AdminConsole.tsx index 42c8ca3..e6a8a49 100644 --- a/src/pages/admin/AdminConsole.tsx +++ b/src/pages/admin/AdminConsole.tsx @@ -6,6 +6,7 @@ import { LoginHistory } from './LoginHistory'; import { Allocation } from './Allocation'; import { AuditHistory } from './AuditHistory'; import { DevTickets } from './DevTickets'; +import { MissingMedia } from './MissingMedia'; export const AdminConsole: React.FC = () => { const [tab, setTab] = useState(0); @@ -24,6 +25,7 @@ export const AdminConsole: React.FC = () => { + {tab === 0 && } @@ -32,6 +34,7 @@ export const AdminConsole: React.FC = () => { {tab === 3 && } {tab === 4 && } {tab === 5 && } + {tab === 6 && } ); }; diff --git a/src/pages/admin/MissingMedia.tsx b/src/pages/admin/MissingMedia.tsx new file mode 100644 index 0000000..378a1ee --- /dev/null +++ b/src/pages/admin/MissingMedia.tsx @@ -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([]); + const [dbName, setDbName] = useState(''); + const [type, setType] = useState<'audits' | 'rectification'>('audits'); + const [records, setRecords] = useState([]); + const [summary, setSummary] = useState(null); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [selectedIds, setSelectedIds] = useState>(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') => ( + + ); + + return ( + + + + Organization + + + + v && setType(v)} size="small"> + Audits + Rectification + + + + + max 50 at a time — the 15-min cron keeps checking the rest automatically + + + + + {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')} + + + {loading ? ( + + ) : ( + + + + + + 0 && selectedIds.size === records.length} + indeterminate={selectedIds.size > 0 && selectedIds.size < records.length} + onChange={toggleAll} + /> + + Record ID + Asset + Plaza + Site + Video Date + Missing + Checks + Last Checked + + + + {records.length === 0 && ( + + + No pending records with missing media — everything is auditable. 🎉 + + + )} + {records.map((r) => ( + + + toggleRow(r.id)} /> + + {r.id} + {r.Asset || '—'} + {r.Plaza || '—'} + {r.site_id} + {r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'} + + + {!r.has_video_declared && ( + + + + )} + + + {r.media_check_count >= 96 ? ( + + + + ) : r.media_check_count} + + {r.media_checked_at ? new Date(r.media_checked_at).toLocaleString() : 'never'} + + ))} + +
+ setPage(newPage)} + rowsPerPage={PAGE_SIZE} + rowsPerPageOptions={[PAGE_SIZE]} + /> +
+ )} +
+ ); +};