dev tickets and issues raised
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
|
||||
const SKELETON_COLS = 13;
|
||||
const SKELETON_COLS = 11;
|
||||
|
||||
export const AnomalyTable: React.FC = () => {
|
||||
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: '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: '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>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -133,22 +131,6 @@ export const AnomalyTable: React.FC = () => {
|
||||
</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: '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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -182,10 +182,11 @@ export const FeedFilters: React.FC = () => {
|
||||
// Build the correct filter key that will be calculated in GlobalFeed
|
||||
// Must mirror GlobalFeed's filterKey format exactly or the cache
|
||||
// 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({
|
||||
date: activeTab.date,
|
||||
auditedByMe: activeTab.auditedByMe || false,
|
||||
fromDate: draftFromDate,
|
||||
tillDate: draftTillDate,
|
||||
pageNo: 0,
|
||||
|
||||
@@ -39,7 +39,7 @@ export const FeedTabs: React.FC = () => {
|
||||
}}
|
||||
label={
|
||||
<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
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); closeTab(index); }} sx={{ ml: 1, color: 'inherit', p: 0.5 }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
|
||||
@@ -61,7 +61,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
const assetIdsKey = selectedAssetIds.join(',');
|
||||
|
||||
// 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
|
||||
if (filterKey === activeTab.filters?.lastFetchedFilterKey && activeTab.anomalies && activeTab.anomalies.length > 0) {
|
||||
@@ -76,6 +76,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
try {
|
||||
const historyRes: any = await activityFeedsService.getHistory({
|
||||
date: activeTab.date || '',
|
||||
auditedByMe: activeTab.auditedByMe || false,
|
||||
pageNo: 0,
|
||||
pageSize: 20,
|
||||
siteIds: siteIdParam,
|
||||
@@ -132,6 +133,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
try {
|
||||
const historyRes: any = await activityFeedsService.getHistory({
|
||||
date: activeTab?.date || '',
|
||||
auditedByMe: activeTab?.auditedByMe || false,
|
||||
pageNo: newPage * rowsPerPage,
|
||||
pageSize: rowsPerPage,
|
||||
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),
|
||||
@@ -174,6 +176,7 @@ export const GlobalFeed: React.FC = () => {
|
||||
try {
|
||||
const historyRes: any = await activityFeedsService.getHistory({
|
||||
date: activeTab?.date || '',
|
||||
auditedByMe: activeTab?.auditedByMe || false,
|
||||
pageNo: newPage * newRowsPerPage,
|
||||
pageSize: newRowsPerPage,
|
||||
siteIds: selectedSite?.site_id === 'All Sites' ? '' : (selectedSite?.site_id || ''),
|
||||
|
||||
@@ -7,7 +7,6 @@ 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);
|
||||
@@ -27,7 +26,6 @@ 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 />}
|
||||
@@ -37,7 +35,6 @@ export const AdminConsole: React.FC = () => {
|
||||
{tab === 4 && <AuditHistory />}
|
||||
{tab === 5 && <DevTickets />}
|
||||
{tab === 6 && <MissingMedia />}
|
||||
{tab === 7 && <UnderReview />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,11 +2,12 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Paper, TextField, FormControl, InputLabel, Select, MenuItem, CircularProgress,
|
||||
Chip, TablePagination, Link, Tooltip,
|
||||
Chip, TablePagination, Link, Tooltip, Button,
|
||||
} from '@mui/material';
|
||||
import { adminService } from '../../api/adminService';
|
||||
import type { DevTicket } from '../../api/adminService';
|
||||
import { useToastStore } from '../../store/toastStore';
|
||||
import { RecordDetailsDialog } from './RecordDetailsDialog';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -16,6 +17,30 @@ const gitStatusColor = (status: DevTicket['git_status']): 'success' | '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);
|
||||
|
||||
export const DevTickets: React.FC = () => {
|
||||
@@ -45,6 +70,8 @@ export const DevTickets: React.FC = () => {
|
||||
// afterward, so this is fetched separately from the main list.
|
||||
const [issueStates, setIssueStates] = useState<Record<number, 'open' | 'closed' | null>>({});
|
||||
const [issueStatesLoading, setIssueStatesLoading] = useState(false);
|
||||
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||||
const [detailsTicket, setDetailsTicket] = useState<DevTicket | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -96,6 +123,24 @@ export const DevTickets: React.FC = () => {
|
||||
.finally(() => setIssueStatesLoading(false));
|
||||
}, [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 (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
@@ -141,6 +186,7 @@ export const DevTickets: React.FC = () => {
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Anomaly ID</TableCell>
|
||||
<TableCell>Org</TableCell>
|
||||
<TableCell>Asset / Plaza</TableCell>
|
||||
<TableCell>Dev</TableCell>
|
||||
<TableCell>Reporter</TableCell>
|
||||
@@ -148,13 +194,15 @@ export const DevTickets: React.FC = () => {
|
||||
<TableCell>RocketChat</TableCell>
|
||||
<TableCell>Git</TableCell>
|
||||
<TableCell>Issue status</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Action</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{records.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} align="center" sx={{ color: 'text.secondary' }}>
|
||||
<TableCell colSpan={12} align="center" sx={{ color: 'text.secondary' }}>
|
||||
No dev tickets found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -162,8 +210,14 @@ export const DevTickets: React.FC = () => {
|
||||
{records.map((t) => {
|
||||
const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined;
|
||||
return (
|
||||
<TableRow key={t.id}>
|
||||
<TableRow
|
||||
key={t.id}
|
||||
hover
|
||||
onClick={() => setDetailsTicket(t)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
>
|
||||
<TableCell>{t.anomaly_id}</TableCell>
|
||||
<TableCell>{t.org_db_name || '—'}</TableCell>
|
||||
<TableCell>{[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'}</TableCell>
|
||||
<TableCell>@{t.dev_handle}</TableCell>
|
||||
<TableCell>{t.reporter || '—'}</TableCell>
|
||||
@@ -184,7 +238,7 @@ export const DevTickets: React.FC = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{t.git_issue_url ? (
|
||||
<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 />
|
||||
@@ -213,7 +267,18 @@ export const DevTickets: React.FC = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{statusChip(t)}</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>
|
||||
);
|
||||
})}
|
||||
@@ -229,6 +294,12 @@ export const DevTickets: React.FC = () => {
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<RecordDetailsDialog
|
||||
ticket={detailsTicket}
|
||||
liveIssueState={detailsTicket ? issueStates[detailsTicket.id] : undefined}
|
||||
onClose={() => setDetailsTicket(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
212
src/pages/admin/RecordDetailsDialog.tsx
Normal file
212
src/pages/admin/RecordDetailsDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -210,7 +210,7 @@ export const AuditSession: React.FC = () => {
|
||||
} else {
|
||||
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 {
|
||||
useToastStore.getState().showToast(`Message sent to @${reportDev}`, 'success');
|
||||
}
|
||||
@@ -307,6 +307,7 @@ export const AuditSession: React.FC = () => {
|
||||
|
||||
const res: any = await activityFeedsService.getHistory({
|
||||
date: activeDate,
|
||||
auditedByMe: activeTab.auditedByMe || false,
|
||||
pageNo: targetPage * 50,
|
||||
pageSize: 50,
|
||||
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({
|
||||
date: activeDate,
|
||||
auditedByMe: activeTab.auditedByMe || false,
|
||||
pageNo: nextPage * feedPageSize,
|
||||
pageSize: feedPageSize,
|
||||
sortBy,
|
||||
|
||||
Reference in New Issue
Block a user