import { useEffect, useState, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { Plus, Ticket, AlertTriangle } from 'lucide-react' import axios from 'axios' import { TicketFilterBar } from '@/components/tickets/TicketFilterBar' import { TicketListRow } from '@/components/tickets/TicketListRow' import { TicketDetailPanel } from '@/components/tickets/TicketDetailPanel' import { NewTicketModal } from '@/components/tickets/NewTicketModal' import { integrationsApi } from '@/api/integrations' import { ticketsApi } from '@/api/tickets' import type { PSATicketSearchResult, PSABoard, PSATicketStatusItem } from '@/types/integrations' import type { TicketFilters, PSAPriority } from '@/types/tickets' import { DEFAULT_TICKET_FILTERS } from '@/types/tickets' const PAGE_SIZE = 25 function filtersFromParams(params: URLSearchParams): TicketFilters & { page: number } { const assigned = params.get('assigned') ?? 'all' return { ...DEFAULT_TICKET_FILTERS, search: params.get('search') ?? '', board_id: params.get('board') ? Number(params.get('board')) : null, status_id: params.get('status') ? Number(params.get('status')) : null, priority: params.get('priority') ?? null, company_id: params.get('company') ? Number(params.get('company')) : null, assigned: (assigned === 'me' || assigned === 'unassigned' || assigned === 'all') ? assigned : Number(assigned), include_closed: params.get('closed') === 'true', page: params.get('page') ? Number(params.get('page')) : 1, } } export default function TicketsPage() { const [searchParams, setSearchParams] = useSearchParams() const { page, ...filters } = filtersFromParams(searchParams) const [tickets, setTickets] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(false) const [psaError, setPsaError] = useState(null) const [boards, setBoards] = useState([]) const [statuses, setStatuses] = useState([]) const [priorities, setPriorities] = useState([]) const [members, setMembers] = useState<{ id: number; name: string }[]>([]) const [selectedTicket, setSelectedTicket] = useState(null) const [showNewTicket, setShowNewTicket] = useState(false) // Load filter option data once useEffect(() => { integrationsApi.listBoards().then(setBoards).catch(() => {}) ticketsApi.listPriorities().then(setPriorities).catch(() => {}) integrationsApi.listMembers() .then(ms => setMembers(ms.map(m => ({ id: Number(m.id), name: m.name })))) .catch(() => {}) }, []) // Load statuses when board changes useEffect(() => { if (filters.board_id) { integrationsApi.getBoardStatuses(filters.board_id) .then(setStatuses).catch(() => {}) } else { setStatuses([]) } }, [filters.board_id]) // Fetch tickets on filter/page change const fetchTickets = useCallback(async () => { setLoading(true) setPsaError(null) try { const result = await ticketsApi.searchTickets({ query: filters.search || undefined, board_id: filters.board_id ?? undefined, status_id: filters.status_id ?? undefined, include_closed: filters.include_closed, assigned_to_me: filters.assigned === 'me', unassigned: filters.assigned === 'unassigned', priority: filters.priority ?? undefined, company_id: filters.company_id ?? undefined, page, page_size: PAGE_SIZE, }) setTickets(result.items) setTotal(result.total) // If the boards API returned empty (CW permissions), derive available boards from ticket data setBoards(prev => { if (prev.length > 0) return prev const seen = new Map() result.items.forEach(t => { if (t.board_id && t.board_name) seen.set(t.board_id, t.board_name) }) return seen.size > 0 ? Array.from(seen, ([id, name]) => ({ id, name })) : prev }) } catch (err: unknown) { setTickets([]) setTotal(0) if (axios.isAxiosError(err)) { const status = err.response?.status const detail = (err.response?.data as { detail?: string })?.detail ?? '' if (status === 502 && detail.toLowerCase().includes('permission')) { setPsaError('ConnectWise returned a permissions error. Check that the API member\'s security role has Service Tickets → Inquire → ALL and System → Table Setup → Inquire → ALL.') } else if (status === 502) { setPsaError('ConnectWise is unavailable or returned an error. Check your integration settings.') } else { setPsaError('Failed to load tickets.') } } } finally { setLoading(false) } }, [filters.search, filters.board_id, filters.status_id, filters.include_closed, filters.assigned, filters.priority, filters.company_id, page]) useEffect(() => { fetchTickets() }, [fetchTickets]) function updateFilters(updated: Partial) { const next = new URLSearchParams(searchParams) if ('search' in updated) updated.search ? next.set('search', updated.search!) : next.delete('search') if ('board_id' in updated) updated.board_id ? next.set('board', String(updated.board_id)) : next.delete('board') if ('status_id' in updated) updated.status_id ? next.set('status', String(updated.status_id)) : next.delete('status') if ('priority' in updated) updated.priority ? next.set('priority', updated.priority!) : next.delete('priority') if ('company_id' in updated) updated.company_id ? next.set('company', String(updated.company_id)) : next.delete('company') if ('assigned' in updated) { const a = updated.assigned a === 'all' ? next.delete('assigned') : next.set('assigned', String(a)) } if ('include_closed' in updated) updated.include_closed ? next.set('closed', 'true') : next.delete('closed') next.delete('page') // reset to 1 on filter change setSearchParams(next) } function updatePage(p: number) { const next = new URLSearchParams(searchParams) p === 1 ? next.delete('page') : next.set('page', String(p)) setSearchParams(next) } return (
{/* Header */}

Tickets

{/* Filters */}
{/* List + Detail Panel */}
{/* Ticket list */}
{loading && tickets.length === 0 && (
Loading tickets…
)} {!loading && psaError && (
{psaError}
)} {!loading && !psaError && tickets.length === 0 && (
No tickets match your filters
)} {tickets.map(t => ( setSelectedTicket(t)} /> ))}
{/* Detail panel */} {selectedTicket && (
setSelectedTicket(null)} onStatusUpdated={(ticketId, newStatus) => { setTickets(prev => prev.map(t => t.id === String(ticketId) ? { ...t, status_name: newStatus } : t )) }} />
)}
{/* New Ticket Modal */} {showNewTicket && ( setShowNewTicket(false)} onCreated={() => { setShowNewTicket(false); fetchTickets() }} /> )}
) }