From c911311596aa92662ab29f15b261f1cb0f9ea47a Mon Sep 17 00:00:00 2001 From: kaushik Date: Wed, 15 Jul 2026 11:14:25 +0530 Subject: [PATCH] under review records ui and be --- src/api/adminService.ts | 46 +++++ src/pages/admin/AdminConsole.tsx | 3 + src/pages/admin/UnderReview.tsx | 209 +++++++++++++++++++++++ src/pages/audit-session/AuditSession.tsx | 75 +++++--- 4 files changed, 310 insertions(+), 23 deletions(-) create mode 100644 src/pages/admin/UnderReview.tsx diff --git a/src/api/adminService.ts b/src/api/adminService.ts index cd1a985..3e7827e 100644 --- a/src/api/adminService.ts +++ b/src/api/adminService.ts @@ -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 => { 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: { diff --git a/src/pages/admin/AdminConsole.tsx b/src/pages/admin/AdminConsole.tsx index e6a8a49..8c4faf5 100644 --- a/src/pages/admin/AdminConsole.tsx +++ b/src/pages/admin/AdminConsole.tsx @@ -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 === 0 && } @@ -35,6 +37,7 @@ export const AdminConsole: React.FC = () => { {tab === 4 && } {tab === 5 && } {tab === 6 && } + {tab === 7 && } ); }; diff --git a/src/pages/admin/UnderReview.tsx b/src/pages/admin/UnderReview.tsx new file mode 100644 index 0000000..1e0f8cc --- /dev/null +++ b/src/pages/admin/UnderReview.tsx @@ -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([]); + const [dbName, setDbName] = useState(''); + const [type, setType] = useState<'audits' | 'rectification'>('audits'); + const [records, setRecords] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [selectedIds, setSelectedIds] = useState>(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 ( + + + + Organization + + + + v && setType(v)} size="small"> + Audits + Rectification + + + + + records reported to a dev are hidden from auditors until released here (max 50 at a time) + + + + {loading ? ( + + ) : ( + + + + + + 0 && selectedIds.size === records.length} + indeterminate={selectedIds.size > 0 && selectedIds.size < records.length} + onChange={toggleAll} + /> + + Record ID + Asset + Plaza + Chainage + Side + Video Date + Reported By + Dev + Reported On + Git Issue + + + + {records.length === 0 && ( + + + No records under review — nothing is currently reported to the devs. + + + )} + {records.map((r) => ( + + + toggleRow(r.id)} /> + + {r.id} + {r.Asset?.replace(/_/g, ' ') || '—'} + {r.Plaza || '—'} + {r.Chainage || '—'} + {r.Side || '—'} + {r.video_date ? new Date(r.video_date).toLocaleDateString() : '—'} + {r.ticket?.reporter || r.updated_user_name || '—'} + {r.ticket ? `@${r.ticket.dev_handle}` : '—'} + {r.ticket?.created_at ? new Date(r.ticket.created_at).toLocaleString() : '—'} + + {r.ticket ? ( + r.ticket.git_issue_url ? ( + + + + ) : ( + + ) + ) : ( + + + + )} + + + ))} + +
+ setPage(newPage)} + rowsPerPage={PAGE_SIZE} + rowsPerPageOptions={[PAGE_SIZE]} + /> +
+ )} +
+ ); +}; diff --git a/src/pages/audit-session/AuditSession.tsx b/src/pages/audit-session/AuditSession.tsx index c3cece0..e01cbd8 100644 --- a/src/pages/audit-session/AuditSession.tsx +++ b/src/pages/audit-session/AuditSession.tsx @@ -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(''); - useToastStore.getState().showToast(`Message sent to @${reportDev}`, 'success'); + 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 (