import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useShortcut } from '../../hooks/useShortcuts'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { toast } from 'sonner'; import Layout from '../../components/Layout'; import { TicketStatus } from '../../types'; import { useAuth } from '../../contexts/AuthContext'; import { useTicketsPaged, useBulkTickets, useSavedViews, useCreateSavedView, useDeleteSavedView, useUsers, } from '../../api/queries'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import TicketFilters from './TicketFilters'; import BulkActions from './BulkActions'; import TicketListItem from './TicketListItem'; 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'); 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 ?? '')); }; // 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 ( setParams(fn, { replace: true })} authUser={authUser} users={users} savedViews={savedViewsQ.data ?? []} onDeleteView={(id) => deleteView.mutate(id)} onApplyView={applyView} onSaveView={() => setSaveOpen(true)} activeFilterCount={activeCount} total={total} isFetching={isFetching} /> {selected.size > 0 && ( )} {/* 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)} /> ))}
)} {/* 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
); }