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