feat(psa): ticket queue dashboard with board selector and session auto-start
- Add PSABoard type + list_boards() to CW provider (cached 1h) - Extend search_tickets with assigned_to_me, unassigned, board_ids, page, page_size - New GET /integrations/psa/boards endpoint - New TicketQueue dashboard component: My Tickets / Unassigned tabs, multi-select board filter, Load more pagination, Start Session per ticket - Add TicketQueue to QuickStartPage after active sessions - FlowPilotSessionPage auto-starts with ticket context when navigated from TicketQueue (psaTicketId + psaTicket in location.state) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { apiClient } from './client'
|
||||
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
|
||||
import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
|
||||
import type { PSABoard, TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
|
||||
|
||||
export const integrationsApi = {
|
||||
getConnection: () =>
|
||||
@@ -13,8 +13,18 @@ export const integrationsApi = {
|
||||
apiClient.delete(`/integrations/psa/connections/${id}`),
|
||||
testConnection: (id: string) =>
|
||||
apiClient.post<PsaConnectionTestResponse>(`/integrations/psa/connections/${id}/test`).then(r => r.data),
|
||||
listBoards: () =>
|
||||
apiClient.get<PSABoard[]>('/integrations/psa/boards').then(r => r.data),
|
||||
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) =>
|
||||
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
||||
searchTicketsQueue: (params: {
|
||||
assigned_to_me?: boolean
|
||||
unassigned?: boolean
|
||||
board_ids?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) =>
|
||||
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
||||
getTicket: (id: string) =>
|
||||
apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data),
|
||||
getTicketStatuses: (ticketId: string) =>
|
||||
|
||||
397
frontend/src/components/dashboard/TicketQueue.tsx
Normal file
397
frontend/src/components/dashboard/TicketQueue.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Ticket, ChevronDown, Check, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import type { PSABoard, PSATicketSearchResult } from '@/types/integrations'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
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">·</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 [boards, setBoards] = useState<PSABoard[]>([])
|
||||
const [selectedBoardIds, setSelectedBoardIds] = useState<number[]>([])
|
||||
const [activeTab, setActiveTab] = useState<Tab>('mine')
|
||||
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
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))
|
||||
}, [])
|
||||
|
||||
// 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[], pageNum: number, append: boolean) => {
|
||||
const params: Parameters<typeof integrationsApi.searchTicketsQueue>[0] = {
|
||||
page: pageNum,
|
||||
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(',')
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await integrationsApi.searchTicketsQueue(params)
|
||||
if (append) {
|
||||
setTickets((prev) => [...prev, ...results])
|
||||
} else {
|
||||
setTickets(results)
|
||||
}
|
||||
setHasMore(results.length === PAGE_SIZE)
|
||||
setError(null)
|
||||
} catch {
|
||||
setError('Failed to load tickets. Check your PSA connection.')
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Initial + reset fetch when tab or board selection changes
|
||||
useEffect(() => {
|
||||
if (!hasConnection) return
|
||||
setPage(1)
|
||||
setTickets([])
|
||||
setHasMore(false)
|
||||
setLoading(true)
|
||||
fetchTickets(activeTab, selectedBoardIds, 1, false).finally(() => setLoading(false))
|
||||
}, [activeTab, selectedBoardIds, hasConnection, fetchTickets])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
setLoadingMore(true)
|
||||
await fetchTickets(activeTab, selectedBoardIds, nextPage, true)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
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>
|
||||
{/* 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 && !hasMore}
|
||||
onStartSession={handleStartSession}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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 → Integrations
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No unassigned open tickets</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{!error && !loading && hasMore && (
|
||||
<div
|
||||
className="px-5 py-3"
|
||||
style={{ borderTop: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-[rgba(255,255,255,0.08)] bg-transparent py-2 text-xs text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.14)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load more'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import { HandoffModal } from '@/components/session/HandoffModal'
|
||||
import { handoffsApi } from '@/api/handoffs'
|
||||
import { aiSessionsApi } from '@/api'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import type { PSATicketInfo } from '@/types/integrations'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export default function FlowPilotSessionPage() {
|
||||
@@ -17,10 +19,13 @@ export default function FlowPilotSessionPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const prefill = (location.state as { prefill?: string } | null)?.prefill || ''
|
||||
const psaTicketId = (location.state as any)?.psaTicketId as string | undefined
|
||||
const psaTicket = (location.state as any)?.psaTicket as PSATicketInfo | undefined
|
||||
const isPickup = searchParams.get('pickup') === 'true'
|
||||
const fp = useFlowPilotSession()
|
||||
const branching = useBranching()
|
||||
const prefillHandledRef = useRef(false)
|
||||
const psaTicketHandledRef = useRef(false)
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
const [showResolve, setShowResolve] = useState(false)
|
||||
const [showEscalate, setShowEscalate] = useState(false)
|
||||
@@ -44,6 +49,30 @@ export default function FlowPilotSessionPage() {
|
||||
fp.startSession({ intake_type: 'free_text', intake_content: { text: prefill } })
|
||||
}
|
||||
}, [prefill, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Auto-start when navigating from TicketQueue with a PSA ticket
|
||||
useEffect(() => {
|
||||
if (psaTicketId && psaTicket && !psaTicketHandledRef.current && !sessionId && !fp.session && !fp.isLoading) {
|
||||
psaTicketHandledRef.current = true
|
||||
integrationsApi.getConnection().then((conn) => {
|
||||
if (conn?.id) {
|
||||
fp.startSession({
|
||||
intake_type: 'psa_ticket',
|
||||
intake_content: {
|
||||
ticket_data: {
|
||||
summary: psaTicket.summary,
|
||||
company: psaTicket.company_name,
|
||||
priority: psaTicket.priority_name,
|
||||
},
|
||||
},
|
||||
psa_ticket_id: psaTicketId,
|
||||
psa_connection_id: conn.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [psaTicketId, psaTicket, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const [pickingUp, setPickingUp] = useState(false)
|
||||
|
||||
// Load existing session if ID in URL
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuthStore } from '@/store/authStore'
|
||||
import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
|
||||
import { PendingEscalations } from '@/components/dashboard/PendingEscalations'
|
||||
import { ActiveFlowPilotSessions } from '@/components/dashboard/ActiveFlowPilotSessions'
|
||||
import { TicketQueue } from '@/components/dashboard/TicketQueue'
|
||||
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
||||
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
||||
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
||||
@@ -59,6 +60,11 @@ export function QuickStartPage() {
|
||||
<ActiveFlowPilotSessions />
|
||||
</div>
|
||||
|
||||
{/* Ticket Queue (auto-hides if no PSA connection) */}
|
||||
<div className="mt-8">
|
||||
<TicketQueue />
|
||||
</div>
|
||||
|
||||
{/* Dashboard — always visible */}
|
||||
<div className="mt-10">
|
||||
<SectionLabel>Dashboard</SectionLabel>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export interface PSABoard {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface PsaConnectionResponse {
|
||||
id: string
|
||||
account_id: string
|
||||
|
||||
Reference in New Issue
Block a user