Files
resolutionflow/frontend/src/components/dashboard/TicketQueue.tsx
Michael Chihlas 920a246d77
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 11m23s
CI / frontend (pull_request) Failing after 2m42s
CI / e2e (pull_request) Has been skipped
fix(react): remove four setState-in-effect cascades flagged by react-hooks v5
The new react-hooks lint rule "Calling setState synchronously within an
effect can trigger cascading renders" flagged real anti-patterns in
four spots. Refactored each per the rule's intent (derive during render,
or use useSyncExternalStore for external subscriptions).

1. hooks/useMediaQuery.ts — replaced the useState + useEffect pair with
   useSyncExternalStore. That's the canonical React hook for
   subscribing to external stores (matchMedia in this case) without
   mirroring into local state via an effect. Snapshot/getServerSnapshot
   pair preserves the SSR-safe behaviour.

2. components/network/nodes/DeviceNode.tsx — the prop-sync useEffect
   that copied nodeData.label into labelValue was redundant.
   labelValue is the EDIT BUFFER; while not editing, the displayed
   span now reads nodeData.label directly. The buffer is initialized
   only when an edit session starts (onDoubleClick).

3. components/network/nodes/GroupNode.tsx — same pattern, same fix.

4. components/dashboard/TicketQueue.tsx — the
   setTickets([]) + setLoading(true) + fetchTickets() chain in the
   effect was the cascade. Pushed those writes inside fetchTickets
   (after the function boundary, so they batch with the eventual
   setTickets(result)). Added a request-id ref so a slow first
   response can't overwrite a fast second one.

Frontend lint: 20 errors → 0 errors. tsc -b clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 02:33:13 -04:00

407 lines
14 KiB
TypeScript

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 (
<div className="space-y-0">
{[0, 1, 2].map((i) => (
<div
key={i}
className="flex items-center gap-4 px-5 py-3.5"
style={{ borderBottom: '1px solid var(--color-border-default)' }}
>
<div className="flex-1 space-y-2">
<div className="h-3 w-1/3 rounded bg-[rgba(255,255,255,0.06)] animate-pulse" />
<div className="h-3 w-2/3 rounded bg-[rgba(255,255,255,0.04)] animate-pulse" />
<div className="h-2.5 w-1/4 rounded bg-[rgba(255,255,255,0.03)] animate-pulse" />
</div>
<div className="h-6 w-16 rounded bg-[rgba(255,255,255,0.04)] animate-pulse shrink-0" />
<div className="h-7 w-24 rounded bg-[rgba(255,255,255,0.04)] animate-pulse shrink-0" />
</div>
))}
</div>
)
}
interface BoardSelectorProps {
boards: PSABoard[]
selectedIds: number[]
onChange: (ids: number[]) => void
}
function BoardSelector({ boards, selectedIds, onChange }: BoardSelectorProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 rounded-lg border border-[rgba(255,255,255,0.08)] bg-[rgba(255,255,255,0.04)] px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.14)] transition-colors"
>
{label}
<ChevronDown size={12} className={cn('transition-transform', open && 'rotate-180')} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-52 rounded-lg border border-[rgba(255,255,255,0.1)] bg-card shadow-lg py-1">
{/* All Boards */}
<button
onClick={handleAllBoards}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<span
className={cn(
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border',
allSelected
? 'border-accent bg-accent'
: 'border-[rgba(255,255,255,0.2)] bg-transparent',
)}
>
{allSelected && <Check size={9} className="text-white" />}
</span>
All Boards
</button>
{boards.length > 0 && (
<div className="my-1" style={{ borderTop: '1px solid var(--color-border-default)' }} />
)}
{boards.map((board) => {
const checked = selectedIds.includes(board.id)
return (
<button
key={board.id}
onClick={() => handleToggleBoard(board.id)}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<span
className={cn(
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border',
checked
? 'border-accent bg-accent'
: 'border-[rgba(255,255,255,0.2)] bg-transparent',
)}
>
{checked && <Check size={9} className="text-white" />}
</span>
<span className="truncate">{board.name}</span>
</button>
)
})}
</div>
)}
</div>
)
}
interface TicketRowProps {
ticket: PSATicketSearchResult
isLast: boolean
onStartSession: (ticket: PSATicketSearchResult) => void
}
function TicketRow({ ticket, isLast, onStartSession }: TicketRowProps) {
return (
<div
className="flex items-center gap-3 px-5 py-3.5"
style={{ borderBottom: isLast ? undefined : '1px solid var(--color-border-default)' }}
>
{/* Left: ticket info */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 mb-0.5">
<span className="font-mono text-xs font-semibold text-accent shrink-0">
#{ticket.id}
</span>
<span className="text-sm text-foreground truncate">{ticket.summary}</span>
</div>
<div className="flex items-center gap-1.5 text-[0.6875rem] text-muted-foreground">
{ticket.company_name && <span className="truncate">{ticket.company_name}</span>}
{ticket.company_name && ticket.priority_name && (
<span className="shrink-0">&middot;</span>
)}
{ticket.priority_name && <span className="shrink-0">{ticket.priority_name}</span>}
</div>
</div>
{/* Right: status badge + action */}
<div className="flex items-center gap-2 shrink-0">
{ticket.status_name && (
<span className="hidden sm:inline-flex items-center rounded-md border border-[rgba(255,255,255,0.08)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[0.625rem] text-muted-foreground">
{ticket.status_name}
</span>
)}
<button
onClick={() => onStartSession(ticket)}
className="flex items-center gap-1.5 rounded-lg border border-accent/30 bg-accent-dim px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 hover:border-accent/50 transition-colors"
>
<Ticket size={11} />
Start Session
</button>
</div>
</div>
)
}
export function TicketQueue() {
const navigate = useNavigate()
const [hasConnection, setHasConnection] = useState<boolean | null>(null)
const [hasMemberMapping, setHasMemberMapping] = useState<boolean | null>(null) // null = loading
const [boards, setBoards] = useState<PSABoard[]>([])
const [selectedBoardIds, setSelectedBoardIds] = useState<number[]>([])
const [activeTab, setActiveTab] = useState<Tab>('mine')
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
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<string | null>(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<typeof integrationsApi.searchTicketsQueue>[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 (
<div className="card-flat overflow-hidden">
{/* Header */}
<div
className="flex items-center justify-between px-5 py-3"
style={{ borderBottom: '1px solid var(--color-border-default)' }}
>
<div className="flex items-center gap-2">
<Ticket size={14} className="text-accent" />
<h3 className="font-heading text-sm font-bold text-foreground">Ticket Queue</h3>
</div>
<BoardSelector
boards={boards}
selectedIds={selectedBoardIds}
onChange={(ids) => setSelectedBoardIds(ids)}
/>
</div>
{/* Tabs */}
<div
className="flex"
style={{ borderBottom: '1px solid var(--color-border-default)' }}
>
{(['mine', 'unassigned'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
'px-5 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px',
activeTab === tab
? 'border-accent text-accent'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{tab === 'mine' ? 'My Tickets' : 'Unassigned'}
</button>
))}
</div>
{/* Content */}
<div>
{/* Mapping prompt for "mine" tab when no member mapping configured */}
{activeTab === 'mine' && hasMemberMapping === false && (
<div className="px-5 py-6 text-center">
<p className="text-sm text-muted-foreground">
<Link to="/account/integrations" className="text-accent hover:underline">
Map your PSA member
</Link>{' '}
to see your ticket queue.
</p>
</div>
)}
{/* Error */}
{error && (
<div className="flex items-center gap-2 px-5 py-4 text-sm text-danger">
<AlertCircle size={14} className="shrink-0" />
<span>{error}</span>
</div>
)}
{/* Loading skeleton */}
{!error && loading && <SkeletonRows />}
{/* Ticket rows */}
{!error && !loading && tickets.length > 0 && (
<>
{tickets.map((ticket, i) => (
<TicketRow
key={ticket.id}
ticket={ticket}
isLast={i === tickets.length - 1}
onStartSession={handleStartSession}
/>
))}
</>
)}
{/* View all tickets link */}
{tickets.length > 0 && (
<div className="px-5 py-3 border-t border-default">
<Link
to="/tickets?assigned=me"
className="text-xs text-accent hover:text-accent/80 transition-colors"
>
View all tickets
</Link>
</div>
)}
{/* Empty states */}
{!error && !loading && tickets.length === 0 && (
<div className="px-5 py-8 text-center">
<Ticket size={24} className="mx-auto mb-2 text-muted-foreground/40" />
{activeTab === 'mine' ? (
<>
<p className="text-sm text-muted-foreground">No open tickets assigned to you</p>
<p className="mt-1 text-[0.6875rem] text-muted-foreground/60">
Make sure your member mapping is configured in Account &rarr; Integrations
</p>
</>
) : (
<p className="text-sm text-muted-foreground">No unassigned open tickets</p>
)}
</div>
)}
</div>
</div>
)
}