under review records ui and be

This commit is contained in:
2026-07-15 11:14:25 +05:30
parent 08307119da
commit c911311596
4 changed files with 310 additions and 23 deletions

View File

@@ -193,6 +193,28 @@ export interface MissingMediaResult {
summary: MissingMediaSummary;
}
// A pending record quarantined by Report to Dev (under_review = 1), with
// its latest dev ticket merged in (null if the ticket predates ticket
// logging or was raised outside this org).
export interface UnderReviewRecord {
id: number;
Asset: string | null;
Plaza: string | null;
Chainage: string | null;
Side: string | null;
video_date: string | null;
updated_user_name: string | null;
Frame_Test: string | null;
ticket: {
anomaly_id: number;
dev_handle: string;
reporter: string | null;
created_at: string;
git_status: 'pending' | 'success' | 'failed';
git_issue_url: string | null;
} | null;
}
export const adminService = {
getUsers: async (): Promise<AdminUser[]> => {
return (await axiosClient.get('/api/audit/admin/users')) as unknown as AdminUser[];
@@ -309,6 +331,30 @@ export const adminService = {
return (await axiosClient.get('/api/audit/admin/media-status', { params })) as unknown as MissingMediaResult;
},
// Pending records quarantined by Report to Dev (under_review = 1) -
// hidden from auditors until released here.
getUnderReviewRecords: async (params: {
dbName: string;
type: 'audits' | 'rectification';
limit?: number;
offset?: number;
}): Promise<{ records: UnderReviewRecord[]; total: number }> => {
return (await axiosClient.get('/api/audit/admin/under-review', { params })) as unknown as {
records: UnderReviewRecord[]; total: number;
};
},
// Put up to 50 quarantined records back into the auditors' pending feed.
releaseUnderReview: async (payload: {
dbName: string;
type: 'audits' | 'rectification';
ids: number[];
}): Promise<{ released: number }> => {
return (await axiosClient.post('/api/audit/admin/under-review/release', payload)) as unknown as {
released: number;
};
},
// Immediate CDN re-verify for up to 50 records (also resets the cron's
// give-up counter for them).
recheckMedia: async (payload: {

View File

@@ -7,6 +7,7 @@ import { Allocation } from './Allocation';
import { AuditHistory } from './AuditHistory';
import { DevTickets } from './DevTickets';
import { MissingMedia } from './MissingMedia';
import { UnderReview } from './UnderReview';
export const AdminConsole: React.FC = () => {
const [tab, setTab] = useState(0);
@@ -26,6 +27,7 @@ export const AdminConsole: React.FC = () => {
<Tab label="Audit & Rectification History" />
<Tab label="Dev Tickets" />
<Tab label="Missing Media" />
<Tab label="Under Review" />
</Tabs>
{tab === 0 && <ClientsOverview />}
@@ -35,6 +37,7 @@ export const AdminConsole: React.FC = () => {
{tab === 4 && <AuditHistory />}
{tab === 5 && <DevTickets />}
{tab === 6 && <MissingMedia />}
{tab === 7 && <UnderReview />}
</Box>
);
};

View File

@@ -0,0 +1,209 @@
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, Link, Tooltip,
} from '@mui/material';
import { adminService } from '../../api/adminService';
import type { UnderReviewRecord } 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 gitChipColor = (status: string): 'default' | 'success' | 'error' => {
if (status === 'success') return 'success';
if (status === 'failed') return 'error';
return 'default';
};
export const UnderReview: React.FC = () => {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [dbName, setDbName] = useState('');
const [type, setType] = useState<'audits' | 'rectification'>('audits');
const [records, setRecords] = useState<UnderReviewRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(false);
const [releasing, setReleasing] = 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.getUnderReviewRecords({
dbName, type, limit: PAGE_SIZE, offset: page * PAGE_SIZE,
});
setRecords(result.records);
setTotal(result.total);
// Releasing can shrink the total below the current page - snap back.
if (page > 0 && result.total <= page * PAGE_SIZE) setPage(0);
} catch {
useToastStore.getState().showToast('Failed to load under-review 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 handleRelease = async (ids: number[]) => {
setReleasing(true);
try {
const result = await adminService.releaseUnderReview({ dbName, type, ids });
useToastStore.getState().showToast(
`Released ${result.released} record(s) back to the auditors' feed.`,
result.released > 0 ? 'success' : 'info'
);
setSelectedIds(new Set());
load();
} catch (err: any) {
useToastStore.getState().showToast(err?.response?.data?.message || 'Release failed.', 'error');
} finally {
setReleasing(false);
}
};
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 || releasing}
onClick={() => handleRelease(Array.from(selectedIds))}
>
{releasing ? <CircularProgress size={20} sx={{ color: 'white' }} /> : `Release selected (${selectedIds.size})`}
</Button>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
records reported to a dev are hidden from auditors until released here (max 50 at a time)
</Typography>
</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>Chainage</TableCell>
<TableCell>Side</TableCell>
<TableCell>Video Date</TableCell>
<TableCell>Reported By</TableCell>
<TableCell>Dev</TableCell>
<TableCell>Reported On</TableCell>
<TableCell>Git Issue</TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.length === 0 && (
<TableRow>
<TableCell colSpan={11} align="center" sx={{ color: 'text.secondary' }}>
No records under review nothing is currently reported to the devs.
</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?.replace(/_/g, ' ') || '—'}</TableCell>
<TableCell>{r.Plaza || '—'}</TableCell>
<TableCell>{r.Chainage || '—'}</TableCell>
<TableCell>{r.Side || '—'}</TableCell>
<TableCell>{r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'}</TableCell>
<TableCell>{r.ticket?.reporter || r.updated_user_name || '—'}</TableCell>
<TableCell>{r.ticket ? `@${r.ticket.dev_handle}` : '—'}</TableCell>
<TableCell>{r.ticket?.created_at ? new Date(r.ticket.created_at).toLocaleString() : '—'}</TableCell>
<TableCell>
{r.ticket ? (
r.ticket.git_issue_url ? (
<Link href={r.ticket.git_issue_url} target="_blank" rel="noopener" underline="hover">
<Chip size="small" label={r.ticket.git_status} color={gitChipColor(r.ticket.git_status)} clickable />
</Link>
) : (
<Chip size="small" label={r.ticket.git_status} color={gitChipColor(r.ticket.git_status)} />
)
) : (
<Tooltip title="No dev ticket found for this record in this org">
<Chip size="small" label="no ticket" variant="outlined" />
</Tooltip>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(_, newPage) => setPage(newPage)}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
/>
</TableContainer>
)}
</Box>
);
};

View File

@@ -181,7 +181,7 @@ export const AuditSession: React.FC = () => {
reportMessage.trim(),
].join('\n');
await axiosClient.post('/api/audit/report', {
const res: any = await axiosClient.post('/api/audit/report', {
channel: `@${reportDev}`,
text: context,
anomalyId: a?.id,
@@ -189,11 +189,31 @@ export const AuditSession: React.FC = () => {
plaza: a?.Plaza || null,
devHandle: reportDev,
reporter: user?.username || user?.email || null,
isRectificationEnabled: isRectActive,
});
setIsReportDialogOpen(false);
setReportDev('');
setReportMessage('');
if (res?.ticket?.underReview && a?.id != null) {
// The backend quarantined the record (under_review = 1): remove it
// from the session and the pending caches - auditors must not see
// it again until an admin releases it from the Under Review tab.
const reportedId = a.id;
const removedIdx = anomalies.findIndex((x: any) => x.id === reportedId);
const remaining = anomalies.filter((x: any) => x.id !== reportedId);
setAnomalies(remaining);
// itemAuditStates is index-aligned with anomalies - splice in lockstep.
setItemAuditStates(prev => prev.filter((_, i) => i !== removedIdx));
purgePendingCaches([reportedId]);
if (remaining.length === 0) {
setShowBatchEndPanel(true);
} else {
goToIndex(Math.min(removedIdx, remaining.length - 1), remaining);
}
useToastStore.getState().showToast(`#${reportedId} reported to @${reportDev} — moved to Under Review (admin console)`, 'success');
} else {
useToastStore.getState().showToast(`Message sent to @${reportDev}`, 'success');
}
} catch (err) {
console.error('Failed to send report:', err);
useToastStore.getState().showToast('Failed to send message. Please try again.', 'error');
@@ -512,9 +532,12 @@ export const AuditSession: React.FC = () => {
// Single navigation entry point (arrows, Enter-advance, progress-tile
// clicks). If the target record has a staged verdict, pre-fill the form
// with it so it can be reviewed/overwritten before Bulk Submit.
const goToIndex = (idx: number) => {
if (idx < 0 || idx >= anomalies.length) return;
const staged = anomalies[idx] ? stagedAudits[anomalies[idx].id] : undefined;
// `list` defaults to the current session array; pass the post-removal
// array when navigating right after deleting a record from state (state
// updates haven't flushed yet, so the closure still sees the old array).
const goToIndex = (idx: number, list: any[] = anomalies) => {
if (idx < 0 || idx >= list.length) return;
const staged = list[idx] ? stagedAudits[list[idx].id] : undefined;
setCurrentIndex(idx);
if (staged) {
setIsAnomaly(staged.isAnomaly);
@@ -556,15 +579,33 @@ export const AuditSession: React.FC = () => {
handleNext();
};
// Remove records from the cached PENDING feed tabs (the feeds table
// renders from this cache - a stale entry lets users re-open a record
// that has left the pending queue), decrement the tab count, and drop the
// cached dashboard stats so they refetch. Only tabs of the same mode:
// audits and rectification ids come from different tables and can collide.
const purgePendingCaches = (ids: number[]) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
const feed = useFeedStore.getState();
feed.setTabs(feed.tabs.map((t: any) =>
(t.date === '' || t.date === 'reviewTab') && !!t.isRectificationTab === isRectActive
? {
...t,
anomalies: (t.anomalies || []).filter((a: any) => !idSet.has(a.id)),
anomaliesCopy: (t.anomaliesCopy || []).filter((a: any) => !idSet.has(a.id)),
totalAnomalyCount: Math.max(0, (t.totalAnomalyCount || 0) - ids.length),
}
: t
));
feed.setDashboardCache({ ...feed.dashboardCache, stats: null });
};
// Sync the in-memory copies with what the backend just wrote, for one or
// many records at once. Without this the lock banner shows the record's OLD
// Audited_on - the ingest pipeline pre-stamps that column on unaudited
// rows, so it can be months in the past - instead of the NOW() the save
// just set. Also purges the records from the cached PENDING feed tabs - the
// feeds table renders from this cache, and a stale entry let users re-open
// an already-audited record from the table. Only tabs of the same mode:
// audits and rectification ids come from different tables and can collide.
// Also drops the cached dashboard stats so they refetch.
// just set.
const applyAuditedLocally = (records: { id: number; isAnomaly: boolean; auditValue: string; comments: string }[]) => {
if (records.length === 0) return;
const nowIso = new Date().toISOString();
@@ -575,19 +616,7 @@ export const AuditSession: React.FC = () => {
? { ...a, IsAudited: 1, Audit_status: r.isAnomaly ? 1 : 0, Audit_value: r.auditValue, Comments: r.comments, Audited_on: nowIso }
: a;
}));
const feed = useFeedStore.getState();
feed.setTabs(feed.tabs.map((t: any) =>
(t.date === '' || t.date === 'reviewTab') && !!t.isRectificationTab === isRectActive
? {
...t,
anomalies: (t.anomalies || []).filter((a: any) => !byId.has(a.id)),
anomaliesCopy: (t.anomaliesCopy || []).filter((a: any) => !byId.has(a.id)),
totalAnomalyCount: Math.max(0, (t.totalAnomalyCount || 0) - records.length),
}
: t
));
feed.setDashboardCache({ ...feed.dashboardCache, stats: null });
purgePendingCaches(records.map(r => r.id));
};
const saveAuditState = useCallback(async (