import { useState, useEffect, useRef, useCallback } from 'react' import { useNavigate, Link } from 'react-router-dom' import { Ticket, ChevronDown, Check, AlertCircle } from 'lucide-react' import { integrationsApi } from '@/api/integrations' import type { PSABoard, PSATicketSearchResult } from '@/types/integrations' import { cn } from '@/lib/utils' const PAGE_SIZE = 5 type Tab = 'mine' | 'unassigned' function SkeletonRows() { return (
{[0, 1, 2].map((i) => (
))}
) } interface BoardSelectorProps { boards: PSABoard[] selectedIds: number[] onChange: (ids: number[]) => void } function BoardSelector({ boards, selectedIds, onChange }: BoardSelectorProps) { const [open, setOpen] = useState(false) const ref = useRef(null) useEffect(() => { function handleClickOutside(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false) } } if (open) { document.addEventListener('mousedown', handleClickOutside) } return () => document.removeEventListener('mousedown', handleClickOutside) }, [open]) const allSelected = selectedIds.length === 0 const label = allSelected ? 'All Boards' : selectedIds.length === 1 ? (boards.find((b) => b.id === selectedIds[0])?.name ?? '1 board') : `${selectedIds.length} boards` const handleAllBoards = () => { onChange([]) } const handleToggleBoard = (id: number) => { if (selectedIds.includes(id)) { const next = selectedIds.filter((x) => x !== id) onChange(next) } else { onChange([...selectedIds, id]) } } if (boards.length === 0) return null return (
{open && (
{/* All Boards */} {boards.length > 0 && (
)} {boards.map((board) => { const checked = selectedIds.includes(board.id) return ( ) })}
)}
) } interface TicketRowProps { ticket: PSATicketSearchResult isLast: boolean onStartSession: (ticket: PSATicketSearchResult) => void } function TicketRow({ ticket, isLast, onStartSession }: TicketRowProps) { return (
{/* Left: ticket info */}
#{ticket.id} {ticket.summary}
{ticket.company_name && {ticket.company_name}} {ticket.company_name && ticket.priority_name && ( · )} {ticket.priority_name && {ticket.priority_name}}
{/* Right: status badge + action */}
{ticket.status_name && ( {ticket.status_name} )}
) } export function TicketQueue() { const navigate = useNavigate() const [hasConnection, setHasConnection] = useState(null) const [hasMemberMapping, setHasMemberMapping] = useState(null) // null = loading const [boards, setBoards] = useState([]) const [selectedBoardIds, setSelectedBoardIds] = useState([]) const [activeTab, setActiveTab] = useState('mine') const [tickets, setTickets] = useState([]) const [loading, setLoading] = useState(false) // Monotonically increasing fetch token — late responses with a stale id // are dropped so they can't overwrite the latest query's results. const latestRequestId = useRef(0) const [error, setError] = useState(null) // Check connection on mount useEffect(() => { integrationsApi.getConnection() .then((conn) => { const active = !!(conn && conn.is_active) setHasConnection(active) }) .catch(() => setHasConnection(false)) }, []) // Detect member mapping on mount useEffect(() => { integrationsApi.getMemberMappings() .then(mappings => { setHasMemberMapping(mappings.length > 0) }) .catch(() => setHasMemberMapping(false)) }, []) // Fetch boards once connection confirmed useEffect(() => { if (!hasConnection) return integrationsApi.listBoards() .then(setBoards) .catch(() => {}) // boards are optional — don't block UI }, [hasConnection]) const fetchTickets = useCallback( async (tab: Tab, boardIds: number[]) => { const params: Parameters[0] = { page: 1, page_size: PAGE_SIZE, } if (tab === 'mine') { params.assigned_to_me = true } else { params.unassigned = true } if (boardIds.length > 0) { params.board_ids = boardIds.join(',') } // Clear stale data + flip loading inside the async function so the // writes happen after the awaitable boundary — avoids the // synchronous-setState-in-effect cascade the lint rule flags. The // fetch is also wrapped in a request-id check so a stale response // can't clobber a newer query. const requestId = ++latestRequestId.current setTickets([]) setLoading(true) try { const results = await integrationsApi.searchTicketsQueue(params) if (requestId !== latestRequestId.current) return setTickets(results.items) setError(null) } catch { if (requestId !== latestRequestId.current) return setError('Failed to load tickets. Check your PSA connection.') } finally { if (requestId === latestRequestId.current) setLoading(false) } }, [], ) // Initial + reset fetch when tab or board selection changes useEffect(() => { if (!hasConnection) return if (activeTab === 'mine' && hasMemberMapping !== true) return fetchTickets(activeTab, selectedBoardIds) }, [activeTab, selectedBoardIds, hasConnection, hasMemberMapping, fetchTickets]) const handleStartSession = (ticket: PSATicketSearchResult) => { navigate('/pilot', { state: { psaTicketId: ticket.id, psaTicket: { id: ticket.id, summary: ticket.summary, company_name: ticket.company_name, board_name: ticket.board_name, status_name: ticket.status_name, priority_name: ticket.priority_name, }, }, }) } // Don't render until we know connection status if (hasConnection === null) return null // No active connection → hide entirely if (!hasConnection) return null return (
{/* Header */}

Ticket Queue

setSelectedBoardIds(ids)} />
{/* Tabs */}
{(['mine', 'unassigned'] as Tab[]).map((tab) => ( ))}
{/* Content */}
{/* Mapping prompt for "mine" tab when no member mapping configured */} {activeTab === 'mine' && hasMemberMapping === false && (

Map your PSA member {' '} to see your ticket queue.

)} {/* Error */} {error && (
{error}
)} {/* Loading skeleton */} {!error && loading && } {/* Ticket rows */} {!error && !loading && tickets.length > 0 && ( <> {tickets.map((ticket, i) => ( ))} )} {/* View all tickets link */} {tickets.length > 0 && (
View all tickets →
)} {/* Empty states */} {!error && !loading && tickets.length === 0 && (
{activeTab === 'mine' ? ( <>

No open tickets assigned to you

Make sure your member mapping is configured in Account → Integrations

) : (

No unassigned open tickets

)}
)}
) }