import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useShortcut } from '../hooks/useShortcuts'; import { ChevronLeft, ChevronRight, Trash2, Save } from 'lucide-react'; import { SEVERITY_BG } from '../lib/severityColors'; import { formatDistanceToNow } from 'date-fns'; import { toast } from 'sonner'; import Layout from '../components/Layout'; import SeverityBadge from '../components/SeverityBadge'; import StatusBadge from '../components/StatusBadge'; import Avatar from '../components/Avatar'; import CTISelect from '../components/CTISelect'; import { TicketStatus } from '../types'; import { useAuth } from '../contexts/AuthContext'; import { useTicketsPaged, useBulkTickets, useSavedViews, useCreateSavedView, useDeleteSavedView, useUsers, } from '../api/queries'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [ { value: '', label: 'All' }, { value: 'OPEN', label: 'Open' }, { value: 'IN_PROGRESS', label: 'In progress' }, { value: 'RESOLVED', label: 'Resolved' }, { value: 'CLOSED', label: 'Closed' }, ]; const PAGE_SIZE = 25; const BULK_LIMIT = 500; export default function Tickets() { const [params, setParams] = useSearchParams(); const navigate = useNavigate(); const { user: authUser } = useAuth(); const { data: users = [] } = useUsers(); const status = (params.get('status') ?? '') as TicketStatus | ''; const severity = params.get('severity') ?? ''; const assigneeId = params.get('assigneeId') ?? ''; const categoryId = params.get('categoryId') ?? ''; const typeId = params.get('typeId') ?? ''; const itemId = params.get('itemId') ?? ''; const search = params.get('search') ?? ''; const page = Math.max(1, Number(params.get('page') ?? '1')); const [searchInput, setSearchInput] = useState(search); const [selected, setSelected] = useState>(new Set()); const [cursor, setCursor] = useState(-1); const [saveOpen, setSaveOpen] = useState(false); const [newViewName, setNewViewName] = useState(''); const [confirmBulk, setConfirmBulk] = useState< | { kind: 'close' | 'setSeverity' | 'reassign'; value?: unknown; label: string } | null >(null); useEffect(() => { const t = setTimeout(() => { if (search !== searchInput) { updateParam('search', searchInput); updateParam('page', '1'); } }, 250); return () => clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchInput]); const updateParam = (key: string, value: string | null) => { setParams( (prev) => { const next = new URLSearchParams(prev); if (value === null || value === '') next.delete(key); else next.set(key, value); if (key !== 'page') next.delete('page'); // reset page on filter change return next; }, { replace: true }, ); }; const queryParams = { status: status || undefined, severity: severity ? Number(severity) : undefined, assigneeId: assigneeId || undefined, categoryId: categoryId || undefined, typeId: typeId || undefined, itemId: itemId || undefined, search: search || undefined, page, pageSize: PAGE_SIZE, }; const { data, isLoading, isFetching } = useTicketsPaged(queryParams); const tickets = data?.data ?? []; const total = data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const savedViewsQ = useSavedViews(); const createView = useCreateSavedView(); const deleteView = useDeleteSavedView(); const bulk = useBulkTickets(); const allVisible = tickets.length > 0 && tickets.every((t) => selected.has(t.id)); const someVisible = tickets.some((t) => selected.has(t.id)); const toggleAll = () => { setSelected((prev) => { const next = new Set(prev); if (allVisible) tickets.forEach((t) => next.delete(t.id)); else tickets.forEach((t) => next.add(t.id)); return next; }); }; const toggleOne = (id: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const clearSelected = () => setSelected(new Set()); const currentFilters = useMemo( () => ({ status: status || undefined, severity: severity ? Number(severity) : undefined, assigneeId: assigneeId || undefined, categoryId: categoryId || undefined, typeId: typeId || undefined, itemId: itemId || undefined, search: search || undefined, }), [status, severity, assigneeId, categoryId, typeId, itemId, search], ); const runBulk = async (payload: { action: string; value?: unknown }) => { const ids = Array.from(selected).slice(0, BULK_LIMIT); try { const res = (await bulk.mutateAsync({ ids, ...payload })) as { updated?: number }; toast.success(`Updated ${res.updated ?? ids.length} ticket${ids.length === 1 ? '' : 's'}`); clearSelected(); } catch (e) { toast.error((e as Error).message || 'Bulk action failed'); } finally { setConfirmBulk(null); } }; const activeCount = Object.values(currentFilters).filter(Boolean).length; const handleSaveView = async () => { if (!newViewName.trim()) return; try { await createView.mutateAsync({ name: newViewName.trim(), filters: currentFilters, }); toast.success('Saved view'); setNewViewName(''); setSaveOpen(false); } catch (e) { toast.error((e as Error).message || 'Failed to save view'); } }; const applyView = (filters: Record) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') next.set(k, String(v)); }); setParams(next, { replace: true }); setSearchInput(String(filters.search ?? '')); }; const agentUsers = users; // Keyboard navigation useEffect(() => { setCursor((c) => (c >= tickets.length ? tickets.length - 1 : c)); }, [tickets.length]); useShortcut('j', (e) => { if (tickets.length === 0) return; e.preventDefault(); setCursor((c) => Math.min(tickets.length - 1, c < 0 ? 0 : c + 1)); }, [tickets.length]); useShortcut('k', (e) => { if (tickets.length === 0) return; e.preventDefault(); setCursor((c) => Math.max(0, c < 0 ? 0 : c - 1)); }, [tickets.length]); useShortcut('enter', (e) => { if (cursor < 0 || cursor >= tickets.length) return; e.preventDefault(); navigate(`/${tickets[cursor].displayId}`); }, [cursor, tickets]); useShortcut('x', (e) => { if (cursor < 0 || cursor >= tickets.length) return; e.preventDefault(); toggleOne(tickets[cursor].id); }, [cursor, tickets]); return ( {/* Status tabs */}
{STATUS_TABS.map((tab) => ( ))}
{/* Filter bar */}
setSearchInput(e.target.value)} placeholder="Search title, overview, ID…" className="flex-1 min-w-48 max-w-xs px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
{ setParams( (prev) => { const next = new URLSearchParams(prev); next.delete('page'); if (cti.categoryId) next.set('categoryId', cti.categoryId); else next.delete('categoryId'); if (cti.typeId) next.set('typeId', cti.typeId); else next.delete('typeId'); if (cti.itemId) next.set('itemId', cti.itemId); else next.delete('itemId'); return next; }, { replace: true }, ); }} />
{/* Saved views */} Saved views {savedViewsQ.data && savedViewsQ.data.length > 0 ? ( savedViewsQ.data.map((v) => (
)) ) : (

No saved views yet

)} setSaveOpen(true)} className="gap-2" > Save current filters
{isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`}
{/* Bulk bar */} {selected.size > 0 && (
{selected.size} selected
setConfirmBulk({ kind: 'reassign', value: null, label: 'Unassign', }) } > Unassigned {agentUsers.map((u) => ( setConfirmBulk({ kind: 'reassign', value: u.id, label: `Assign to ${u.displayName}`, }) } > {u.displayName} ))} {[1, 2, 3, 4, 5].map((s) => ( setConfirmBulk({ kind: 'setSeverity', value: s, label: `Set severity SEV ${s}`, }) } > SEV {s} ))}
)} {/* Ticket list */} {isLoading ? (
Loading…
) : tickets.length === 0 ? (
No tickets found
) : (
{ if (el) el.indeterminate = !allVisible && someVisible; }} onChange={toggleAll} className="cursor-pointer" /> {tickets.length} of {total}
    {tickets.map((ticket, idx) => (
  • toggleOne(ticket.id)} aria-label={`Select ${ticket.displayId}`} className="cursor-pointer flex-shrink-0" />
    {ticket.title} {ticket.displayId}
    opened {formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true, })}{' '} by {ticket.createdBy.displayName} · {ticket.category.name} › {ticket.type.name} › {ticket.item.name} {ticket.assignee && ( · assigned {ticket.assignee.displayName} )} · {ticket._count?.comments ?? 0} comments
    {ticket.assignee ? ( ) : null}
  • ))}
)} {/* Pagination */} {totalPages > 1 && (
Page {page} of {totalPages}
)} {/* Save view dialog */} Save current filters
setNewViewName(e.target.value)} placeholder="View name" className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" onKeyDown={(e) => { if (e.key === 'Enter') handleSaveView(); }} />
{/* Confirm bulk action */} !o && setConfirmBulk(null)}> {confirmBulk?.label} This will affect {selected.size} ticket{selected.size === 1 ? '' : 's'}. Cancel { if (!confirmBulk) return; if (confirmBulk.kind === 'close') runBulk({ action: 'close' }); else runBulk({ action: confirmBulk.kind, value: confirmBulk.value, }); }} > Confirm ); }