dev tickets and issues raised

This commit is contained in:
2026-07-15 14:24:26 +05:30
parent c911311596
commit c872566e83
15 changed files with 489 additions and 419 deletions

View File

@@ -6,9 +6,9 @@ export const activityFeedsService = {
return response; return response;
}, },
getHistoryDates: async (isRectificationEnabled: boolean, siteIds?: string) => { getHistoryDates: async (isRectificationEnabled: boolean, siteIds?: string, auditedByMe = false) => {
const response = await axiosClient.get('/api/audit/anomaly/get_history_dates', { const response = await axiosClient.get('/api/audit/anomaly/get_history_dates', {
params: { isRectificationEnabled, siteIds: siteIds || '' } params: { isRectificationEnabled, siteIds: siteIds || '', auditedByMe }
}); });
return response; return response;
}, },

View File

@@ -126,6 +126,43 @@ export interface DevTicket {
retry_count: number; retry_count: number;
last_retry_at: string | null; last_retry_at: string | null;
created_at: string; created_at: string;
// Which org table the record lives in; null on legacy tickets.
record_type: 'audits' | 'rectification' | null;
// Unified resolution stamp - set by the admin Resolve button or by the
// git-state cron when the Gitea issue is closed.
resolved_at: string | null;
resolved_by: string | null;
// Live record state, attached by the list endpoint.
record_status: 'under_review' | 'resolved' | 'audited' | 'deleted' | 'unknown';
}
// Full record row as returned by /admin/dev-tickets/record-details.
export interface TicketRecordDetails {
ticket: DevTicket;
recordType: 'audits' | 'rectification';
record: {
id: number;
Asset: string | null;
asset_id: number | null;
Plaza: string | null;
Chainage: string | null;
Side: string | null;
Pos: string | null;
video_date: string | null;
Created_on: string | null;
Frame_Test: string | null;
Frame_Master: string | null;
Extra_Image: string | null;
video_url: string | null;
Comments: string | null;
IsAudited: number;
Audit_status: number | null;
Audit_value: string | null;
under_review: number | null;
deleted: number;
road?: string | null;
updated_user_name: string | null;
};
} }
export interface DevTicketsResult { export interface DevTicketsResult {
@@ -193,27 +230,6 @@ export interface MissingMediaResult {
summary: MissingMediaSummary; 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 = { export const adminService = {
getUsers: async (): Promise<AdminUser[]> => { getUsers: async (): Promise<AdminUser[]> => {
@@ -331,28 +347,19 @@ export const adminService = {
return (await axiosClient.get('/api/audit/admin/media-status', { params })) as unknown as MissingMediaResult; return (await axiosClient.get('/api/audit/admin/media-status', { params })) as unknown as MissingMediaResult;
}, },
// Pending records quarantined by Report to Dev (under_review = 1) - // Unified resolve: returns the quarantined record to the auditors' feed
// hidden from auditors until released here. // and (best-effort) closes the linked Gitea issue.
getUnderReviewRecords: async (params: { resolveTicket: async (ticketId: number): Promise<{ recordReleased: boolean; issueClosed: boolean }> => {
dbName: string; return (await axiosClient.post('/api/audit/admin/dev-tickets/resolve', { ticketId })) as unknown as {
type: 'audits' | 'rectification'; recordReleased: boolean; issueClosed: boolean;
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. // Complete record + ticket details for the admin popup.
releaseUnderReview: async (payload: { getTicketRecordDetails: async (ticketId: number): Promise<TicketRecordDetails> => {
dbName: string; return (await axiosClient.get('/api/audit/admin/dev-tickets/record-details', {
type: 'audits' | 'rectification'; params: { ticketId },
ids: number[]; })) as unknown as TicketRecordDetails;
}): 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 // Immediate CDN re-verify for up to 50 records (also resets the cron's

View File

@@ -7,6 +7,7 @@ import {
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import { useFeedStore } from '../../store/feedStore'; import { useFeedStore } from '../../store/feedStore';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useToastStore } from '../../store/toastStore';
import { activityFeedsService } from '../../api/activityFeedsService'; import { activityFeedsService } from '../../api/activityFeedsService';
interface BulkDeleteDialogProps { interface BulkDeleteDialogProps {
@@ -17,8 +18,9 @@ interface BulkDeleteDialogProps {
const REASONS = ['Low Light', 'Bad Image', 'Wrong Algorithm', 'Out of Range']; const REASONS = ['Low Light', 'Bad Image', 'Wrong Algorithm', 'Out of Range'];
export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClose }) => { export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClose }) => {
const { assetTypes, selectedSite, sites } = useFeedStore(); const { assetTypes, selectedSite, sites, isRectificationEnabled, tabs, selectedTabIndex, updateTab, dashboardCache, setDashboardCache } = useFeedStore();
const { user } = useAuthStore(); const { user } = useAuthStore();
const showToast = useToastStore((s) => s.showToast);
const [fromDate, setFromDate] = useState(''); const [fromDate, setFromDate] = useState('');
const [tillDate, setTillDate] = useState(''); const [tillDate, setTillDate] = useState('');
@@ -83,11 +85,11 @@ export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClos
const handleApply = async () => { const handleApply = async () => {
if (!fromDate || !tillDate) { if (!fromDate || !tillDate) {
alert("Please select both From and Till dates."); showToast("Please select both From and Till dates.", "warning");
return; return;
} }
if (selectedAssets.size === 0) { if (selectedAssets.size === 0) {
alert("Please select at least one asset."); showToast("Please select at least one asset.", "warning");
return; return;
} }
@@ -108,20 +110,33 @@ export const BulkDeleteDialog: React.FC<BulkDeleteDialogProps> = ({ open, onClos
site_id: siteIds, site_id: siteIds,
fromDate: fromDate, fromDate: fromDate,
toDate: tillDate, toDate: tillDate,
user_id: Number(user?.user_id),
audit_value: reason, audit_value: reason,
isBulkDelete: true, isBulkDelete: true,
audit_type: auditType audit_type: auditType,
isRectificationEnabled,
}; };
try { try {
setIsLoading(true); setIsLoading(true);
await activityFeedsService.bulkDelete(payload); const res: any = await activityFeedsService.bulkDelete(payload);
alert("Bulk delete successful!"); const affected = res?.data?.affectedRows ?? res?.data?.rowCount;
// Drop the deleted records from view: clear the active tab's cache so
// GlobalFeed refetches (its guard refetches when anomalies is empty),
// and invalidate dashboard stats.
if (selectedTabIndex >= 0 && tabs[selectedTabIndex]) {
updateTab(selectedTabIndex, { anomalies: [], anomaliesCopy: [], filters: {} });
}
setDashboardCache({ ...dashboardCache, stats: null });
showToast(
typeof affected === 'number'
? `Bulk delete successful — ${affected} record(s) removed.`
: 'Bulk delete successful.',
'success'
);
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
console.error("Bulk delete failed", err); console.error("Bulk delete failed", err);
alert("Bulk delete failed. " + (err?.response?.data?.message || err.message || "")); showToast("Bulk delete failed. " + (err?.response?.data?.message || err.message || ""), "error");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -118,18 +118,21 @@ export const TopFilterBar: React.FC = () => {
const [loadingHistory, setLoadingHistory] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false);
const [loadingRectHistory, setLoadingRectHistory] = useState(false); const [loadingRectHistory, setLoadingRectHistory] = useState(false);
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false); const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false);
const [selectedHistoryDate, setSelectedHistoryDate] = useState('2026-06-12'); // One dialog serves both History surfaces; the mode decides which table
const [historyDropdownValue, setHistoryDropdownValue] = useState(''); // (audits vs rectification) the tab it opens will query.
const [historyDialogMode, setHistoryDialogMode] = useState<'audits' | 'rect'>('rect');
const [historyFrom, setHistoryFrom] = useState('');
const [historyTill, setHistoryTill] = useState('');
// Fetch normal history dates (isRectificationEnabled=false) for the History dropdown // Fetch normal history dates (isRectificationEnabled=false) for the audits-mode History dialog chips
// Rectification history (isRectificationEnabled=true) is fetched separately by the RECTIFICATION HISTORY button // Rectification dates (isRectificationEnabled=true) are fetched when the dialog opens in rect mode
useEffect(() => { useEffect(() => {
if (!selectedSite || selectedSite.site_id === 'All Sites') return; if (!selectedSite || selectedSite.site_id === 'All Sites') return;
const fetchDates = async () => { const fetchDates = async () => {
setLoadingHistory(true); setLoadingHistory(true);
try { try {
const siteIds = String(selectedSite.site_id); const siteIds = String(selectedSite.site_id);
const dates: any = await activityFeedsService.getHistoryDates(false, siteIds); const dates: any = await activityFeedsService.getHistoryDates(false, siteIds, true);
setHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || [])); setHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
} catch (err) { } catch (err) {
console.error("Failed to load history dates", err); console.error("Failed to load history dates", err);
@@ -140,50 +143,41 @@ export const TopFilterBar: React.FC = () => {
fetchDates(); fetchDates();
}, [selectedSite?.site_id]); // eslint-disable-line react-hooks/exhaustive-deps }, [selectedSite?.site_id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleHistoryClick = async () => { const openHistoryDialog = async (mode: 'audits' | 'rect') => {
setHistoryDialogMode(mode);
setHistoryFrom('');
setHistoryTill('');
setIsHistoryDialogOpen(true); setIsHistoryDialogOpen(true);
if (!selectedSite || selectedSite.site_id === 'All Sites') return; if (mode === 'rect') {
setLoadingRectHistory(true); if (!selectedSite || selectedSite.site_id === 'All Sites') return;
try { setLoadingRectHistory(true);
const siteIds = String(selectedSite.site_id); try {
const dates: any = await activityFeedsService.getHistoryDates(true, siteIds); const siteIds = String(selectedSite.site_id);
setRectHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || [])); const dates: any = await activityFeedsService.getHistoryDates(true, siteIds, true);
} catch (err) { setRectHistoryDates(Array.isArray(dates) ? dates : (dates?.responseData || []));
console.error("Failed to load rectification history dates", err); } catch (err) {
} finally { console.error("Failed to load rectification history dates", err);
setLoadingRectHistory(false); } finally {
setLoadingRectHistory(false);
}
} }
// 'audits' chips come from the historyDates effect (refetched per site).
}; };
// Used by RECTIFICATION HISTORY dialog — isRectificationTab: true → fetches with isRectificationEnabled=true // Opens (or focuses) a History tab: records audited BY the logged-in user
const handleSelectDate = (date: string) => { // (auditedByMe) on one date or a From-Till range. The range is encoded in
const existingIndex = tabs.findIndex(t => t.date === date && t.isRectificationTab); // the tab's `date` as "from_till" - the backend parses it; single day =
// plain date. Audits and rectification stay separate via isRectificationTab.
const openHistoryTab = (isRect: boolean, from: string, till: string) => {
const dateKey = from === till ? from : `${from}_${till}`;
const existingIndex = tabs.findIndex(t => t.date === dateKey && !!t.isRectificationTab === isRect);
if (existingIndex !== -1) { if (existingIndex !== -1) {
setSelectedTabIndex(existingIndex); setSelectedTabIndex(existingIndex);
} else { } else {
addTab({ addTab({
date: date, date: dateKey,
isRectificationTab: true, isRectificationTab: isRect,
totalAnomalyCount: 0, auditedByMe: true,
anomalies: [],
anomaliesCopy: [],
selectedAnomalyIndex: 0,
filters: {},
page: 0,
rowsPerPage: 20
});
}
};
// Used by History dropdown — isRectificationTab: false → fetches with isRectificationEnabled=false
const handleHistoryDateSelect = (date: string) => {
const existingIndex = tabs.findIndex(t => t.date === date && !t.isRectificationTab);
if (existingIndex !== -1) {
setSelectedTabIndex(existingIndex);
} else {
addTab({
date: date,
isRectificationTab: false,
totalAnomalyCount: 0, totalAnomalyCount: 0,
anomalies: [], anomalies: [],
anomaliesCopy: [], anomaliesCopy: [],
@@ -196,8 +190,12 @@ export const TopFilterBar: React.FC = () => {
}; };
const handleViewHistory = () => { const handleViewHistory = () => {
if (!selectedHistoryDate) return; if (!historyFrom || !historyTill) return;
handleSelectDate(selectedHistoryDate); // Tolerate a reversed range instead of erroring.
const [from, till] = historyFrom <= historyTill
? [historyFrom, historyTill]
: [historyTill, historyFrom];
openHistoryTab(historyDialogMode === 'rect', from, till);
setIsHistoryDialogOpen(false); setIsHistoryDialogOpen(false);
}; };
@@ -238,14 +236,6 @@ export const TopFilterBar: React.FC = () => {
> >
FILTERS FILTERS
</Button> </Button>
<Button
variant="contained"
startIcon={<HistoryIcon />}
onClick={handleHistoryClick}
sx={{ bgcolor: 'rgba(255,255,255,0.1)', color: '#fff', '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, borderRadius: 2, textTransform: 'none', fontWeight: 'bold', boxShadow: 'none' }}
>
RECTIFICATION HISTORY
</Button>
<Button <Button
variant="outlined" variant="outlined"
onClick={() => setIsBulkDeleteDialogOpen(true)} onClick={() => setIsBulkDeleteDialogOpen(true)}
@@ -254,46 +244,20 @@ export const TopFilterBar: React.FC = () => {
BULK DELETE BULK DELETE
</Button> </Button>
{/* History dropdown — only visible when a specific site is selected */} {/* History — one button for both modes: follows the active mode
{localSiteId !== 'All Sites' && <FormControl size="small" sx={{ bgcolor: 'rgba(255,255,255,0.1)', borderRadius: 2, minWidth: 160 }}> (audits vs rectification) so the dialog and the tab it opens query
<Select the right table. Only visible when a specific site is selected
value={historyDropdownValue} (the audited-dates lookup is per-site). */}
displayEmpty {localSiteId !== 'All Sites' && (
onChange={(e) => { <Button
const date = e.target.value as string; variant="contained"
if (date) { startIcon={<HistoryIcon />}
handleHistoryDateSelect(date); onClick={() => openHistoryDialog(isRectActive ? 'rect' : 'audits')}
setHistoryDropdownValue(''); sx={{ bgcolor: 'rgba(255,255,255,0.1)', color: '#fff', '&:hover': { bgcolor: 'rgba(255,255,255,0.2)' }, borderRadius: 2, textTransform: 'none', fontWeight: 'bold', boxShadow: 'none' }}
}
}}
renderValue={(val) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{loadingHistory && <CircularProgress size={12} sx={{ color: '#fff' }} />}
<span>{val || 'History'}</span>
</Box>
)}
sx={{ '& .MuiSelect-select': { py: 1, px: 2 }, border: 'none', '& fieldset': { border: 'none' }, fontWeight: 'bold', color: '#fff', '& .MuiSvgIcon-root': { color: '#fff' } }}
MenuProps={{
slotProps: {
paper: {
sx: { bgcolor: '#0f172a', color: '#f8fafc', border: '1px solid #1e293b', maxHeight: 300 }
}
}
}}
> >
<MenuItem disabled value=""> HISTORY
<em style={{ color: '#64748b' }}>{loadingHistory ? 'Loading...' : historyDates.length === 0 ? 'No dates available' : 'Select a date'}</em> </Button>
</MenuItem> )}
{historyDates.map((d) => {
const dateStr = d.audited_on ? d.audited_on.split(' ')[0] : d.audited_on;
return (
<MenuItem key={dateStr} value={dateStr} sx={{ '&:hover': { bgcolor: '#1e293b' } }}>
{dateStr}
</MenuItem>
);
})}
</Select>
</FormControl>}
<Dialog <Dialog
open={isHistoryDialogOpen} open={isHistoryDialogOpen}
@@ -311,58 +275,66 @@ export const TopFilterBar: React.FC = () => {
} }
}} }}
> >
<DialogTitle sx={{ fontWeight: 'bold', pb: 1 }}>Rectification History</DialogTitle> <DialogTitle sx={{ fontWeight: 'bold', pb: 1 }}>
{historyDialogMode === 'rect' ? 'Rectification History' : 'Audit History'}
</DialogTitle>
<DialogContent sx={{ py: 1 }}> <DialogContent sx={{ py: 1 }}>
<Typography variant="body2" sx={{ color: '#94a3b8', mb: 2 }}> <Typography variant="body2" sx={{ color: '#94a3b8', mb: 2 }}>
Enter a date (YYYY-MM-DD) or pick one from the list to view historical rectification records. Pick a date or a FromTill range to view the {historyDialogMode === 'rect' ? 'rectification' : 'audit'} records you audited. A quick-select chip fills both fields with one day.
</Typography> </Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', mb: 1 }}>History Date</Typography> <Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<TextField {([['From', historyFrom, setHistoryFrom], ['Till', historyTill, setHistoryTill]] as const).map(([label, value, setter]) => (
type="date" <Box key={label} sx={{ flex: 1 }}>
value={selectedHistoryDate} <Typography variant="body2" sx={{ fontWeight: 'bold', color: '#94a3b8', mb: 1 }}>{label}</Typography>
onChange={(e) => setSelectedHistoryDate(e.target.value)} <TextField
fullWidth type="date"
sx={{ value={value}
mb: 3, onChange={(e) => setter(e.target.value)}
'& .MuiOutlinedInput-root': { fullWidth
color: '#f8fafc', sx={{
'& fieldset': { borderColor: '#334155' }, '& .MuiOutlinedInput-root': {
'&:hover fieldset': { borderColor: '#475569' }, color: '#f8fafc',
'&.Mui-focused fieldset': { borderColor: '#3b82f6' }, '& fieldset': { borderColor: '#334155' },
}, '&:hover fieldset': { borderColor: '#475569' },
'& input[type="date"]::-webkit-calendar-picker-indicator': { '&.Mui-focused fieldset': { borderColor: '#3b82f6' },
filter: 'invert(1)', },
cursor: 'pointer', '& input[type="date"]::-webkit-calendar-picker-indicator': {
} filter: 'invert(1)',
}} cursor: 'pointer',
/> }
}}
/>
</Box>
))}
</Box>
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 1, fontWeight: 'bold' }}> <Typography variant="caption" sx={{ color: '#94a3b8', display: 'block', mb: 1, fontWeight: 'bold' }}>
Quick Select (Audited Dates) Quick Select (Dates You Audited)
</Typography> </Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, maxHeight: 150, overflowY: 'auto', p: 0.5, border: '1px solid #1e293b', borderRadius: 1, bgcolor: '#0b1121' }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, maxHeight: 150, overflowY: 'auto', p: 0.5, border: '1px solid #1e293b', borderRadius: 1, bgcolor: '#0b1121' }}>
{loadingRectHistory ? ( {(historyDialogMode === 'rect' ? loadingRectHistory : loadingHistory) ? (
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'center', py: 2 }}> <Box sx={{ width: '100%', display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={20} color="inherit" /> <CircularProgress size={20} color="inherit" />
</Box> </Box>
) : rectHistoryDates.length === 0 ? ( ) : (historyDialogMode === 'rect' ? rectHistoryDates : historyDates).length === 0 ? (
<Typography variant="body2" sx={{ color: '#64748b', p: 1 }}>No dates found</Typography> <Typography variant="body2" sx={{ color: '#64748b', p: 1 }}>No dates found</Typography>
) : ( ) : (
rectHistoryDates.map((d) => { (historyDialogMode === 'rect' ? rectHistoryDates : historyDates).map((d) => {
const dateStr = d.audited_on ? d.audited_on.split(' ')[0] : ''; const dateStr = d.audited_on ? d.audited_on.split(' ')[0] : '';
const selected = historyFrom === dateStr && historyTill === dateStr;
return ( return (
<Chip <Chip
key={d.audited_on} key={d.audited_on}
label={dateStr || d.audited_on} label={dateStr || d.audited_on}
onClick={() => setSelectedHistoryDate(dateStr || d.audited_on)} onClick={() => { setHistoryFrom(dateStr); setHistoryTill(dateStr); }}
sx={{ sx={{
bgcolor: selectedHistoryDate === (dateStr || d.audited_on) ? '#3b82f6' : '#1e293b', bgcolor: selected ? '#3b82f6' : '#1e293b',
color: '#f8fafc', color: '#f8fafc',
'&:hover': { bgcolor: '#334155' }, '&:hover': { bgcolor: '#334155' },
borderRadius: 1, borderRadius: 1,
cursor: 'pointer', cursor: 'pointer',
border: '1px solid transparent', border: '1px solid transparent',
borderColor: selectedHistoryDate === (dateStr || d.audited_on) ? '#60a5fa' : 'transparent', borderColor: selected ? '#60a5fa' : 'transparent',
}} }}
/> />
); );
@@ -371,20 +343,20 @@ export const TopFilterBar: React.FC = () => {
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2 }}>
<Button <Button
onClick={() => setIsHistoryDialogOpen(false)} onClick={() => setIsHistoryDialogOpen(false)}
sx={{ color: '#94a3b8', textTransform: 'none', fontWeight: 'bold' }} sx={{ color: '#94a3b8', textTransform: 'none', fontWeight: 'bold' }}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleViewHistory} onClick={handleViewHistory}
variant="contained" variant="contained"
disabled={!selectedHistoryDate} disabled={!historyFrom || !historyTill}
sx={{ sx={{
bgcolor: '#3b82f6', bgcolor: '#3b82f6',
color: '#fff', color: '#fff',
textTransform: 'none', textTransform: 'none',
fontWeight: 'bold', fontWeight: 'bold',
'&:hover': { bgcolor: '#2563eb' }, '&:hover': { bgcolor: '#2563eb' },
'&.Mui-disabled': { bgcolor: 'rgba(59, 130, 246, 0.3)', color: 'rgba(255,255,255,0.3)' } '&.Mui-disabled': { bgcolor: 'rgba(59, 130, 246, 0.3)', color: 'rgba(255,255,255,0.3)' }

14
src/constants/media.ts Normal file
View File

@@ -0,0 +1,14 @@
// CDN prefixes for record media. These values are duplicated inline in
// AuditSession.tsx and PreviewPanel.tsx (pre-existing) - new code should
// import from here instead of adding more copies.
export const MASTER_IMAGE_PREFIX_AUDITS = 'https://auditor-master-images.seekright.com/SeekRight/';
export const MASTER_IMAGE_PREFIX_RECT = 'https://sr-img.seekright.com/';
export const TEST_IMAGE_PREFIX = 'https://dashboard-images.seekright.com/SeekRight/';
export const masterImageUrl = (frameMaster: string | null | undefined, isRectification: boolean) =>
frameMaster
? `${isRectification ? MASTER_IMAGE_PREFIX_RECT : MASTER_IMAGE_PREFIX_AUDITS}${frameMaster}`
: null;
export const testImageUrl = (frameTest: string | null | undefined) =>
frameTest ? `${TEST_IMAGE_PREFIX}${frameTest}` : null;

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Slider, Checkbox, Skeleton } from '@mui/material'; import { Box, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Slider, Skeleton } from '@mui/material';
import { useFeedStore } from '../../store/feedStore'; import { useFeedStore } from '../../store/feedStore';
const SKELETON_COLS = 13; const SKELETON_COLS = 11;
export const AnomalyTable: React.FC = () => { export const AnomalyTable: React.FC = () => {
const { tabs, selectedTabIndex, updateTab, filterPlaza, isRectificationEnabled, isFeedLoading } = useFeedStore(); const { tabs, selectedTabIndex, updateTab, filterPlaza, isRectificationEnabled, isFeedLoading } = useFeedStore();
@@ -61,8 +61,6 @@ export const AnomalyTable: React.FC = () => {
<TableCell sx={{ width: '10%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>ANOMALY<br/>True/False</TableCell> <TableCell sx={{ width: '10%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>ANOMALY<br/>True/False</TableCell>
<TableCell sx={{ width: '6%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>FEATURES</TableCell> <TableCell sx={{ width: '6%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>FEATURES</TableCell>
<TableCell sx={{ width: '6%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{(activeTab?.date === '' && isRectificationEnabled) ? 'ISSUE' : 'COMMENTS'}</TableCell> <TableCell sx={{ width: '6%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{(activeTab?.date === '' && isRectificationEnabled) ? 'ISSUE' : 'COMMENTS'}</TableCell>
<TableCell sx={{ width: '5%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', textAlign: 'center', whiteSpace: 'nowrap' }}>CHECKED</TableCell>
<TableCell sx={{ width: '8%', fontWeight: 'bold', bgcolor: 'background.paper', color: 'text.primary', borderBottom: '1px solid rgba(255,255,255,0.1)', verticalAlign: 'middle', textAlign: 'center', whiteSpace: 'nowrap' }}>BULK AUDIT</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@@ -133,22 +131,6 @@ export const AnomalyTable: React.FC = () => {
</TableCell> </TableCell>
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Features || 'N/A'}</TableCell> <TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Features || 'N/A'}</TableCell>
<TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Comments || '-'}</TableCell> <TableCell sx={{ width: '6%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', whiteSpace: 'nowrap' }}>{anomaly.Comments || '-'}</TableCell>
<TableCell sx={{ width: '5%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', textAlign: 'center' }}>
<Checkbox size="small" checked={!!anomaly.IsAudited} />
</TableCell>
<TableCell sx={{ width: '8%', borderBottom: '1px solid rgba(255,255,255,0.05)', verticalAlign: 'middle', textAlign: 'center' }}>
<Checkbox
size="small"
checked={!!anomaly.isSelectedForBulkAudit}
onChange={(e) => {
e.stopPropagation(); // prevent row click selection
anomaly.isSelectedForBulkAudit = e.target.checked;
// Trigger a re-render of the current active tab
const updatedAnomalies = [...rawAnomalies];
updateTab(selectedTabIndex, { anomalies: updatedAnomalies });
}}
/>
</TableCell>
</TableRow> </TableRow>
); );
})} })}

View File

@@ -182,10 +182,11 @@ export const FeedFilters: React.FC = () => {
// Build the correct filter key that will be calculated in GlobalFeed // Build the correct filter key that will be calculated in GlobalFeed
// Must mirror GlobalFeed's filterKey format exactly or the cache // Must mirror GlobalFeed's filterKey format exactly or the cache
// check there misses and double-fetches. // check there misses and double-fetches.
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${draftFromDate}|${draftTillDate}|${draftMinChainage}|${draftMaxChainage}|${assetIdsKey}|${draftSortBy}|${draftSortDir}`; const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${activeTab.auditedByMe ?? false}|${draftFromDate}|${draftTillDate}|${draftMinChainage}|${draftMaxChainage}|${assetIdsKey}|${draftSortBy}|${draftSortDir}`;
const historyRes: any = await activityFeedsService.getHistory({ const historyRes: any = await activityFeedsService.getHistory({
date: activeTab.date, date: activeTab.date,
auditedByMe: activeTab.auditedByMe || false,
fromDate: draftFromDate, fromDate: draftFromDate,
tillDate: draftTillDate, tillDate: draftTillDate,
pageNo: 0, pageNo: 0,

View File

@@ -39,7 +39,7 @@ export const FeedTabs: React.FC = () => {
}} }}
label={ label={
<Box sx={{ display: 'flex', alignItems: 'center' }}> <Box sx={{ display: 'flex', alignItems: 'center' }}>
{tab.date === '' ? 'Unaudited' : tab.date} {tab.date === '' ? 'Unaudited' : tab.date.replace('_', ' ')}
{index !== 0 && ( // Don't allow closing the first tab usually {index !== 0 && ( // Don't allow closing the first tab usually
<IconButton size="small" onClick={(e) => { e.stopPropagation(); closeTab(index); }} sx={{ ml: 1, color: 'inherit', p: 0.5 }}> <IconButton size="small" onClick={(e) => { e.stopPropagation(); closeTab(index); }} sx={{ ml: 1, color: 'inherit', p: 0.5 }}>
<CloseIcon fontSize="small" /> <CloseIcon fontSize="small" />

View File

@@ -61,7 +61,7 @@ export const GlobalFeed: React.FC = () => {
const assetIdsKey = selectedAssetIds.join(','); const assetIdsKey = selectedAssetIds.join(',');
// Build a key for the current filter state on the active tab // Build a key for the current filter state on the active tab
const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${filterFromDate}|${filterTillDate}|${filterMinChainage}|${filterMaxChainage}|${assetIdsKey}|${sortBy}|${sortDir}`; const filterKey = `${selectedSite?.site_id ?? ''}|${auditStatus}|${searchAnomalyId}|${filterPlaza}|${isRectActive}|${activeTab.date}|${activeTab.auditedByMe ?? false}|${filterFromDate}|${filterTillDate}|${filterMinChainage}|${filterMaxChainage}|${assetIdsKey}|${sortBy}|${sortDir}`;
// CACHE HIT: This exact tab and filter combo was already fetched // CACHE HIT: This exact tab and filter combo was already fetched
if (filterKey === activeTab.filters?.lastFetchedFilterKey && activeTab.anomalies && activeTab.anomalies.length > 0) { if (filterKey === activeTab.filters?.lastFetchedFilterKey && activeTab.anomalies && activeTab.anomalies.length > 0) {
@@ -76,6 +76,7 @@ export const GlobalFeed: React.FC = () => {
try { try {
const historyRes: any = await activityFeedsService.getHistory({ const historyRes: any = await activityFeedsService.getHistory({
date: activeTab.date || '', date: activeTab.date || '',
auditedByMe: activeTab.auditedByMe || false,
pageNo: 0, pageNo: 0,
pageSize: 20, pageSize: 20,
siteIds: siteIdParam, siteIds: siteIdParam,
@@ -132,6 +133,7 @@ export const GlobalFeed: React.FC = () => {
try { try {
const historyRes: any = await activityFeedsService.getHistory({ const historyRes: any = await activityFeedsService.getHistory({
date: activeTab?.date || '', date: activeTab?.date || '',
auditedByMe: activeTab?.auditedByMe || false,
pageNo: newPage * rowsPerPage, pageNo: newPage * rowsPerPage,
pageSize: rowsPerPage, pageSize: rowsPerPage,
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''), siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),
@@ -174,6 +176,7 @@ export const GlobalFeed: React.FC = () => {
try { try {
const historyRes: any = await activityFeedsService.getHistory({ const historyRes: any = await activityFeedsService.getHistory({
date: activeTab?.date || '', date: activeTab?.date || '',
auditedByMe: activeTab?.auditedByMe || false,
pageNo: newPage * newRowsPerPage, pageNo: newPage * newRowsPerPage,
pageSize: newRowsPerPage, pageSize: newRowsPerPage,
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''), siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),

View File

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

View File

@@ -2,11 +2,12 @@ import React, { useEffect, useState, useCallback } from 'react';
import { import {
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
Paper, TextField, FormControl, InputLabel, Select, MenuItem, CircularProgress, Paper, TextField, FormControl, InputLabel, Select, MenuItem, CircularProgress,
Chip, TablePagination, Link, Tooltip, Chip, TablePagination, Link, Tooltip, Button,
} from '@mui/material'; } from '@mui/material';
import { adminService } from '../../api/adminService'; import { adminService } from '../../api/adminService';
import type { DevTicket } from '../../api/adminService'; import type { DevTicket } from '../../api/adminService';
import { useToastStore } from '../../store/toastStore'; import { useToastStore } from '../../store/toastStore';
import { RecordDetailsDialog } from './RecordDetailsDialog';
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -16,6 +17,30 @@ const gitStatusColor = (status: DevTicket['git_status']): 'success' | 'warning'
return 'warning'; return 'warning';
}; };
// One unified lifecycle: a ticket is Resolved (from the UI or by closing
// the Gitea issue), or its record is still Under Review / already handled.
const statusChip = (t: DevTicket) => {
if (t.resolved_at) {
return (
<Tooltip title={`Resolved by ${t.resolved_by || '—'} · ${new Date(t.resolved_at).toLocaleString()}`}>
<Chip size="small" label="Resolved" color="success" />
</Tooltip>
);
}
switch (t.record_status) {
case 'under_review': return <Chip size="small" label="Under Review" color="warning" />;
case 'resolved': return <Chip size="small" label="Back in feed" color="success" variant="outlined" />;
case 'audited': return <Chip size="small" label="Audited" color="info" />;
case 'deleted': return <Chip size="small" label="Deleted" color="default" />;
default:
return (
<Tooltip title="Record not found or its org DB is unreachable">
<Chip size="small" label="Unknown" variant="outlined" />
</Tooltip>
);
}
};
const truncate = (text: string, max = 80) => (text.length > max ? `${text.slice(0, max)}` : text); const truncate = (text: string, max = 80) => (text.length > max ? `${text.slice(0, max)}` : text);
export const DevTickets: React.FC = () => { export const DevTickets: React.FC = () => {
@@ -45,6 +70,8 @@ export const DevTickets: React.FC = () => {
// afterward, so this is fetched separately from the main list. // afterward, so this is fetched separately from the main list.
const [issueStates, setIssueStates] = useState<Record<number, 'open' | 'closed' | null>>({}); const [issueStates, setIssueStates] = useState<Record<number, 'open' | 'closed' | null>>({});
const [issueStatesLoading, setIssueStatesLoading] = useState(false); const [issueStatesLoading, setIssueStatesLoading] = useState(false);
const [resolvingId, setResolvingId] = useState<number | null>(null);
const [detailsTicket, setDetailsTicket] = useState<DevTicket | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -96,6 +123,24 @@ export const DevTickets: React.FC = () => {
.finally(() => setIssueStatesLoading(false)); .finally(() => setIssueStatesLoading(false));
}, [records]); }, [records]);
const handleResolve = async (t: DevTicket) => {
setResolvingId(t.id);
try {
const res = await adminService.resolveTicket(t.id);
useToastStore.getState().showToast(
res.recordReleased
? `Resolved — record #${t.anomaly_id} returned to the feed${res.issueClosed ? ', git issue closed' : ''}.`
: `Resolved — record was already handled${res.issueClosed ? '; git issue closed' : ''}.`,
'success'
);
load();
} catch (err: any) {
useToastStore.getState().showToast(err?.response?.data?.message || 'Failed to resolve ticket.', 'error');
} finally {
setResolvingId(null);
}
};
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
@@ -141,6 +186,7 @@ export const DevTickets: React.FC = () => {
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Anomaly ID</TableCell> <TableCell>Anomaly ID</TableCell>
<TableCell>Org</TableCell>
<TableCell>Asset / Plaza</TableCell> <TableCell>Asset / Plaza</TableCell>
<TableCell>Dev</TableCell> <TableCell>Dev</TableCell>
<TableCell>Reporter</TableCell> <TableCell>Reporter</TableCell>
@@ -148,13 +194,15 @@ export const DevTickets: React.FC = () => {
<TableCell>RocketChat</TableCell> <TableCell>RocketChat</TableCell>
<TableCell>Git</TableCell> <TableCell>Git</TableCell>
<TableCell>Issue status</TableCell> <TableCell>Issue status</TableCell>
<TableCell>Status</TableCell>
<TableCell>Created</TableCell> <TableCell>Created</TableCell>
<TableCell align="right">Action</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{records.length === 0 && ( {records.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}> <TableCell colSpan={12} align="center" sx={{ color: 'text.secondary' }}>
No dev tickets found. No dev tickets found.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -162,8 +210,14 @@ export const DevTickets: React.FC = () => {
{records.map((t) => { {records.map((t) => {
const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined; const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined;
return ( return (
<TableRow key={t.id}> <TableRow
key={t.id}
hover
onClick={() => setDetailsTicket(t)}
sx={{ cursor: 'pointer' }}
>
<TableCell>{t.anomaly_id}</TableCell> <TableCell>{t.anomaly_id}</TableCell>
<TableCell>{t.org_db_name || '—'}</TableCell>
<TableCell>{[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'}</TableCell> <TableCell>{[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'}</TableCell>
<TableCell>@{t.dev_handle}</TableCell> <TableCell>@{t.dev_handle}</TableCell>
<TableCell>{t.reporter || '—'}</TableCell> <TableCell>{t.reporter || '—'}</TableCell>
@@ -184,7 +238,7 @@ export const DevTickets: React.FC = () => {
</Tooltip> </Tooltip>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell onClick={(e) => e.stopPropagation()}>
{t.git_issue_url ? ( {t.git_issue_url ? (
<Link href={t.git_issue_url} target="_blank" rel="noopener noreferrer"> <Link href={t.git_issue_url} target="_blank" rel="noopener noreferrer">
<Chip size="small" label={`#${t.git_issue_number}`} color={gitStatusColor(t.git_status)} clickable /> <Chip size="small" label={`#${t.git_issue_number}`} color={gitStatusColor(t.git_status)} clickable />
@@ -213,7 +267,18 @@ export const DevTickets: React.FC = () => {
</Tooltip> </Tooltip>
)} )}
</TableCell> </TableCell>
<TableCell>{statusChip(t)}</TableCell>
<TableCell>{new Date(t.created_at).toLocaleString()}</TableCell> <TableCell>{new Date(t.created_at).toLocaleString()}</TableCell>
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
<Button
size="small"
variant="outlined"
disabled={!!t.resolved_at || t.record_status !== 'under_review' || resolvingId === t.id}
onClick={() => handleResolve(t)}
>
{resolvingId === t.id ? <CircularProgress size={16} /> : 'Resolve'}
</Button>
</TableCell>
</TableRow> </TableRow>
); );
})} })}
@@ -229,6 +294,12 @@ export const DevTickets: React.FC = () => {
/> />
</TableContainer> </TableContainer>
)} )}
<RecordDetailsDialog
ticket={detailsTicket}
liveIssueState={detailsTicket ? issueStates[detailsTicket.id] : undefined}
onClose={() => setDetailsTicket(null)}
/>
</Box> </Box>
); );
}; };

View File

@@ -0,0 +1,212 @@
import React, { useEffect, useState } from 'react';
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button, Box,
Typography, Chip, CircularProgress, Link, Divider, IconButton,
} from '@mui/material';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { adminService } from '../../api/adminService';
import type { DevTicket, TicketRecordDetails } from '../../api/adminService';
import { masterImageUrl, testImageUrl, TEST_IMAGE_PREFIX } from '../../constants/media';
interface RecordDetailsDialogProps {
ticket: DevTicket | null; // null = closed
liveIssueState?: 'open' | 'closed' | null;
onClose: () => void;
}
const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<Box sx={{ minWidth: 140 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>{label}</Typography>
<Typography variant="body2">{children ?? '—'}</Typography>
</Box>
);
const MediaImage: React.FC<{ label: string; src: string | null }> = ({ label, src }) => {
const [failed, setFailed] = useState(false);
useEffect(() => { setFailed(false); }, [src]);
return (
<Box sx={{ flex: 1, minWidth: 240 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>{label}</Typography>
{src && !failed ? (
<Box component="img" src={src} onError={() => setFailed(true)}
sx={{ width: '100%', maxHeight: 260, objectFit: 'contain', borderRadius: 1, bgcolor: 'rgba(0,0,0,0.2)' }} />
) : (
<Box sx={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 1, border: '1px dashed', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>{src ? 'Image unavailable' : 'No image'}</Typography>
</Box>
)}
</Box>
);
};
interface MediaItem {
kind: 'image' | 'video';
src: string;
label: string;
}
// Right-hand pane: the anomaly's test-side media (test image, extra images,
// video) as a carousel. The <video> unmounts on navigation, so playback
// stops automatically when moving to another item.
const MediaCarousel: React.FC<{ items: MediaItem[] }> = ({ items }) => {
const [index, setIndex] = useState(0);
const [failed, setFailed] = useState(false);
useEffect(() => { setIndex(0); }, [items.length]);
useEffect(() => { setFailed(false); }, [index]);
if (items.length === 0) {
return (
<Box sx={{ flex: 1, minWidth: 240 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>Anomaly media</Typography>
<Box sx={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 1, border: '1px dashed', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>No media</Typography>
</Box>
</Box>
);
}
const item = items[Math.min(index, items.length - 1)];
return (
<Box sx={{ flex: 1, minWidth: 240 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>{item.label}</Typography>
{item.kind === 'video' ? (
<Box component="video" key={item.src} src={item.src} controls
sx={{ width: '100%', maxHeight: 260, borderRadius: 1, bgcolor: 'rgba(0,0,0,0.2)' }} />
) : failed ? (
<Box sx={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 1, border: '1px dashed', borderColor: 'divider' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>Image unavailable</Typography>
</Box>
) : (
<Box component="img" key={item.src} src={item.src} onError={() => setFailed(true)}
sx={{ width: '100%', maxHeight: 260, objectFit: 'contain', borderRadius: 1, bgcolor: 'rgba(0,0,0,0.2)' }} />
)}
{items.length > 1 && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1, mt: 0.5 }}>
<IconButton size="small" disabled={index === 0} onClick={() => setIndex((i) => i - 1)}>
<ChevronLeftIcon fontSize="small" />
</IconButton>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{index + 1} / {items.length}</Typography>
<IconButton size="small" disabled={index === items.length - 1} onClick={() => setIndex((i) => i + 1)}>
<ChevronRightIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
);
};
export const RecordDetailsDialog: React.FC<RecordDetailsDialogProps> = ({ ticket, liveIssueState, onClose }) => {
const [details, setDetails] = useState<TicketRecordDetails | null>(null);
const [recordGone, setRecordGone] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!ticket) return;
setDetails(null);
setRecordGone(false);
setLoading(true);
adminService.getTicketRecordDetails(ticket.id)
.then(setDetails)
.catch((err: any) => {
if (err?.response?.status === 404) setRecordGone(true);
})
.finally(() => setLoading(false));
}, [ticket?.id]); // eslint-disable-line react-hooks/exhaustive-deps
if (!ticket) return null;
const rec = details?.record;
const isRect = details?.recordType === 'rectification';
return (
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle sx={{ pb: 1 }}>
Record #{ticket.anomaly_id}
{ticket.org_db_name && <Chip size="small" label={ticket.org_db_name} sx={{ ml: 1 }} variant="outlined" />}
{details && <Chip size="small" label={details.recordType} sx={{ ml: 1 }} variant="outlined" />}
{ticket.resolved_at
? <Chip size="small" label="Resolved" color="success" sx={{ ml: 1 }} />
: rec?.under_review === 1 && <Chip size="small" label="Under Review" color="warning" sx={{ ml: 1 }} />}
</DialogTitle>
<DialogContent dividers>
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>}
{recordGone && (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
The record no longer exists in the org database (hard-deleted or the table was recreated). Ticket details below.
</Typography>
)}
{rec && (
<>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5, mb: 2 }}>
<Field label="Asset">{rec.Asset?.replace(/_/g, ' ')}</Field>
<Field label="Plaza">{rec.Plaza}</Field>
<Field label="Chainage">{rec.Chainage}</Field>
<Field label="Side">{rec.Side}</Field>
<Field label="Position">{rec.Pos}</Field>
{isRect && <Field label="Road">{rec.road}</Field>}
<Field label="Video date">{rec.video_date ? new Date(rec.video_date).toLocaleString() : null}</Field>
<Field label="Created">{rec.Created_on ? new Date(rec.Created_on).toLocaleString() : null}</Field>
<Field label="Audit state">
{rec.deleted === 1 ? 'Deleted' : rec.IsAudited === 1 ? `Audited (${rec.Audit_value || '—'})` : 'Pending'}
</Field>
<Field label="Comments">{rec.Comments}</Field>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 1 }}>
<MediaImage label="Master image" src={masterImageUrl(rec.Frame_Master, isRect)} />
{/* Carousel holds just the anomaly image + playable video;
extra images render as a static row below. */}
<MediaCarousel
items={[
...(testImageUrl(rec.Frame_Test)
? [{ kind: 'image' as const, src: testImageUrl(rec.Frame_Test)!, label: 'Anomaly image' }]
: []),
...(testImageUrl(rec.video_url)
? [{ kind: 'video' as const, src: testImageUrl(rec.video_url)!, label: 'Video' }]
: []),
]}
/>
</Box>
{rec.Extra_Image && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 1 }}>
{rec.Extra_Image.split(',')
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 4)
.map((src, i) => (
<MediaImage key={i} label={`Extra image ${i + 1}`} src={`${TEST_IMAGE_PREFIX}${src}`} />
))}
</Box>
)}
</>
)}
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ mb: 1 }}>Dev ticket</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5, mb: 1.5 }}>
<Field label="Dev">@{ticket.dev_handle}</Field>
<Field label="Reporter">{ticket.reporter}</Field>
<Field label="Reported on">{new Date(ticket.created_at).toLocaleString()}</Field>
<Field label="RocketChat">{ticket.rocketchat_sent ? 'Sent' : ticket.rocketchat_error || 'Not sent'}</Field>
<Field label="Git issue">
{ticket.git_issue_url ? (
<Link href={ticket.git_issue_url} target="_blank" rel="noopener noreferrer">
#{ticket.git_issue_number}{liveIssueState ? ` (${liveIssueState})` : ''}
</Link>
) : ticket.git_status}
</Field>
{ticket.resolved_at && (
<Field label="Resolved">{`${ticket.resolved_by || '—'} · ${new Date(ticket.resolved_at).toLocaleString()}`}</Field>
)}
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>Message</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', maxHeight: 180, overflowY: 'auto' }}>
{ticket.message_text}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -1,209 +0,0 @@
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

@@ -210,7 +210,7 @@ export const AuditSession: React.FC = () => {
} else { } else {
goToIndex(Math.min(removedIdx, remaining.length - 1), remaining); goToIndex(Math.min(removedIdx, remaining.length - 1), remaining);
} }
useToastStore.getState().showToast(`#${reportedId} reported to @${reportDev}moved to Under Review (admin console)`, 'success'); useToastStore.getState().showToast(`#${reportedId} reported to @${reportDev}hidden from the feed until the dev ticket is resolved`, 'success');
} else { } else {
useToastStore.getState().showToast(`Message sent to @${reportDev}`, 'success'); useToastStore.getState().showToast(`Message sent to @${reportDev}`, 'success');
} }
@@ -307,6 +307,7 @@ export const AuditSession: React.FC = () => {
const res: any = await activityFeedsService.getHistory({ const res: any = await activityFeedsService.getHistory({
date: activeDate, date: activeDate,
auditedByMe: activeTab.auditedByMe || false,
pageNo: targetPage * 50, pageNo: targetPage * 50,
pageSize: 50, pageSize: 50,
sortByDateAsc: false, // must match dashboard sort so firstPendingIndex page alignment is correct sortByDateAsc: false, // must match dashboard sort so firstPendingIndex page alignment is correct
@@ -359,6 +360,7 @@ export const AuditSession: React.FC = () => {
const res: any = await activityFeedsService.getHistory({ const res: any = await activityFeedsService.getHistory({
date: activeDate, date: activeDate,
auditedByMe: activeTab.auditedByMe || false,
pageNo: nextPage * feedPageSize, pageNo: nextPage * feedPageSize,
pageSize: feedPageSize, pageSize: feedPageSize,
sortBy, sortBy,

View File

@@ -9,8 +9,11 @@ export type FeedSortBy = '' | 'id' | 'date' | 'chainage' | 'latlong';
export type FeedSortDir = 'asc' | 'desc'; export type FeedSortDir = 'asc' | 'desc';
export interface FeedTab { export interface FeedTab {
// '' = pending tab; 'YYYY-MM-DD' or 'YYYY-MM-DD_YYYY-MM-DD' (range) = history tab
date: string; date: string;
isRectificationTab: boolean; isRectificationTab: boolean;
// History tabs only: show records audited BY the logged-in user
auditedByMe?: boolean;
totalAnomalyCount: number; totalAnomalyCount: number;
anomalies: any[]; anomalies: any[];
anomaliesCopy: any[]; anomaliesCopy: any[];