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, 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; const gitStatusColor = (status: DevTicket['git_status']): 'success' | 'warning' | 'error' => { if (status === 'success') return 'success'; if (status === 'failed') return 'error'; 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 ( ); } switch (t.record_status) { case 'under_review': return ; case 'resolved': return ; case 'audited': return ; case 'deleted': return ; default: return ( ); } }; const truncate = (text: string, max = 80) => (text.length > max ? `${text.slice(0, max)}…` : text); export const DevTickets: React.FC = () => { const [search, setSearch] = useState(''); const [gitStatus, setGitStatus] = useState<'' | 'pending' | 'success' | 'failed'>(''); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const [records, setRecords] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); const [loading, setLoading] = useState(false); // dateTo is a bare date; created_at is a full timestamp, and both fields // are independently editable, so normalize the order and extend "to" to // end-of-day - see Allocation.tsx/AuditHistory.tsx for the same fix after // hitting this exact bug there (an inverted range silently matches zero // rows instead of erroring). const [effectiveDateFrom, effectiveDateTo] = (() => { if (!dateFrom || !dateTo) return [dateFrom || undefined, dateTo ? `${dateTo}T23:59:59` : undefined]; return dateFrom <= dateTo ? [dateFrom, `${dateTo}T23:59:59`] : [dateTo, `${dateFrom}T23:59:59`]; })(); // Live Gitea issue state (open/closed) - git_status only ever records // whether we succeeded in *filing* the issue, never what happened to it // afterward, so this is fetched separately from the main list. const [issueStates, setIssueStates] = useState>({}); const [issueStatesLoading, setIssueStatesLoading] = useState(false); const [resolvingId, setResolvingId] = useState(null); const [detailsTicket, setDetailsTicket] = useState(null); const load = useCallback(async () => { setLoading(true); try { const result = await adminService.getDevTickets({ search: search || undefined, gitStatus: gitStatus || undefined, dateFrom: effectiveDateFrom, dateTo: effectiveDateTo, limit: PAGE_SIZE, offset: page * PAGE_SIZE, }); setRecords(result.records); setTotal(result.total); } catch (err) { useToastStore.getState().showToast('Failed to load dev tickets.', 'error'); } finally { setLoading(false); } }, [search, gitStatus, effectiveDateFrom, effectiveDateTo, page]); useEffect(() => { const timeout = setTimeout(load, 300); return () => clearTimeout(timeout); // eslint-disable-next-line react-hooks/exhaustive-deps }, [search, gitStatus, effectiveDateFrom, effectiveDateTo, page]); useEffect(() => { setPage(0); }, [search, gitStatus, effectiveDateFrom, effectiveDateTo]); useEffect(() => { // Keyed by ticket id, not issue number - the backend resolves each // ticket's own stored git_issue_url, so this stays correct even for // tickets filed before a repo migration. const ticketIds = records .filter((t) => t.git_status === 'success' && t.git_issue_number != null) .map((t) => t.id); if (ticketIds.length === 0) { setIssueStates({}); return; } setIssueStatesLoading(true); adminService.getIssueStates(ticketIds) .then(setIssueStates) .catch(() => useToastStore.getState().showToast('Failed to check live Gitea issue status.', 'error')) .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 ( setSearch(e.target.value)} sx={{ minWidth: 300 }} /> Git status setDateFrom(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} /> setDateTo(e.target.value)} slotProps={{ inputLabel: { shrink: true } }} /> {loading ? ( ) : ( Anomaly ID Org Asset / Plaza Dev Reporter Message RocketChat Git Issue status Status Created Action {records.length === 0 && ( No dev tickets found. )} {records.map((t) => { const liveState = t.git_status === 'success' && t.git_issue_number != null ? issueStates[t.id] : undefined; return ( setDetailsTicket(t)} sx={{ cursor: 'pointer' }} > {t.anomaly_id} {t.org_db_name || '—'} {[t.asset, t.plaza].filter(Boolean).join(' / ') || '—'} @{t.dev_handle} {t.reporter || '—'} {truncate(t.message_text)} {t.duplicate_of_id && ( )} {t.rocketchat_sent ? ( ) : ( )} e.stopPropagation()}> {t.git_issue_url ? ( ) : ( )} {t.retry_count > 0 && ( )} {t.git_status !== 'success' ? ( '—' ) : issueStatesLoading && liveState === undefined ? ( ) : liveState === 'closed' ? ( ) : liveState === 'open' ? ( ) : ( )} {statusChip(t)} {new Date(t.created_at).toLocaleString()} e.stopPropagation()}> ); })}
setPage(newPage)} rowsPerPage={PAGE_SIZE} rowsPerPageOptions={[PAGE_SIZE]} />
)} setDetailsTicket(null)} />
); };