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

@@ -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>
);
})}

View File

@@ -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,

View File

@@ -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" />

View File

@@ -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 || ''),

View File

@@ -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>
);
};

View File

@@ -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>
);
};

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 {
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,