feat(psa): ticket queue dashboard with board selector and session auto-start
Some checks failed
CI / frontend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Successful in 2s

- 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:
2026-04-15 03:20:45 +00:00
parent b18072e24b
commit 346576a730
14 changed files with 595 additions and 16 deletions

View File

@@ -222,10 +222,9 @@ docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
cd backend && pip install httpx && python -m scripts.seed_trees
# CI/CD debugging
gh run list --limit 5 # Recent CI runs
gh run view <id> --log-failed # Failed job logs
gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusion}'
# NEVER use `gh run watch` — it holds context open and burns tokens while waiting
# CI runs on Gitea (gitea.resolutionflow.com), NOT GitHub Actions — gh run list will return nothing useful
# Check CI status at: https://gitea.resolutionflow.com/chihlasm/resolutionflow/actions
# `gh` CLI is still used for GitHub Issues/PRs (mirrored repo), not for CI runs
```
### URLs
@@ -450,6 +449,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
- Large features: commit per phase with `npm run build` validation
- **Remote is Gitea, not GitHub directly:** Push to `gitea.resolutionflow.com/chihlasm/resolutionflow`. Gitea auto-mirrors to GitHub via `.gitea/workflows/mirror-to-github.yml` — never push directly to GitHub.
### After Completing Work
@@ -497,7 +497,7 @@ When a feature, fix, or significant piece of work is finished and merged/committ
## Deployment (Railway)
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
- Auto-deploys on push to `main`
- Auto-deploys via: push to Gitea → Gitea mirrors to GitHub → Railway watches GitHub `main` and deploys
- PR environments auto-created (need manual domain generation in Railway dashboard)
- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`

View File

@@ -27,6 +27,7 @@ from app.schemas.psa_connection import (
PsaMemberMappingSaveRequest,
PsaMemberResponse,
AutoMatchResult,
PSABoardResponse,
)
from app.core.config import settings
from app.services.psa.encryption import (
@@ -345,26 +346,91 @@ async def update_flowpilot_settings(
# ── ticket / status / company endpoints ──────────────────────────
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
async def search_tickets(
@router.get("/boards", response_model=list[PSABoardResponse])
async def list_boards(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
query: str = "",
board_id: int | None = None,
status_id: int | None = None,
include_closed: bool = False,
):
"""Search ConnectWise tickets."""
"""List PSA service boards."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
try:
provider = await get_provider_for_account(current_user.account_id, db)
boards = await provider.list_boards()
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
async def search_tickets(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
query: str = "",
board_id: int | None = None,
status_id: int | None = None,
include_closed: bool = False,
assigned_to_me: bool = False,
unassigned: bool = False,
board_ids: str = "",
page: int = 1,
page_size: int = 10,
):
"""Search ConnectWise tickets."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.registry import get_provider_for_account
from app.services.psa.exceptions import PSAError
# Resolve assigned_to_me → member_id
member_id: str | None = None
if assigned_to_me:
conn_result = await db.execute(
select(PsaConnection).where(
PsaConnection.account_id == current_user.account_id,
PsaConnection.is_active.is_(True),
)
)
conn = conn_result.scalar_one_or_none()
if conn:
mapping_result = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == conn.id,
PsaMemberMapping.user_id == current_user.id,
)
)
mapping = mapping_result.scalar_one_or_none()
if mapping:
member_id = mapping.external_member_id
else:
# No mapping for this user — return empty list
return []
# Parse comma-separated board_ids
parsed_board_ids: list[int] = []
if board_ids:
try:
parsed_board_ids = [int(bid.strip()) for bid in board_ids.split(",") if bid.strip()]
except ValueError:
raise HTTPException(status_code=400, detail="board_ids must be comma-separated integers")
try:
provider = await get_provider_for_account(current_user.account_id, db)
tickets = await provider.search_tickets(
query, board_id=board_id, status_id=status_id, include_closed=include_closed
query,
board_id=board_id,
status_id=status_id,
include_closed=include_closed,
member_id=member_id,
unassigned=unassigned,
board_ids=parsed_board_ids,
page=page,
page_size=page_size,
)
return [
PSATicketSearchResult(

View File

@@ -20,6 +20,7 @@ from .psa_connection import (
PSATicketSearchResult, PSATicketStatusItem,
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
PSABoardResponse,
)
__all__ = [
@@ -50,4 +51,5 @@ __all__ = [
"PSATicketSearchResult", "PSATicketStatusItem",
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
"PSABoardResponse",
]

View File

@@ -136,3 +136,8 @@ class PsaMemberResponse(BaseModel):
class AutoMatchResult(BaseModel):
matched: list[PsaMemberMappingResponse]
unmatched_users: int
class PSABoardResponse(BaseModel):
id: int
name: str

View File

@@ -11,6 +11,7 @@ from app.services.psa.types import (
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
)
@@ -58,6 +59,9 @@ class AutotaskProvider(PSAProvider):
async def list_members(self) -> list[PSAMember]:
raise NotImplementedError("Autotask integration coming soon")
async def list_boards(self) -> list[PSABoard]:
raise NotImplementedError("list_boards not implemented for this provider")
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
raise NotImplementedError("Autotask integration coming soon")

View File

@@ -12,6 +12,7 @@ from .types import (
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
)
@@ -64,6 +65,10 @@ class PSAProvider(ABC):
async def list_members(self) -> list[PSAMember]:
...
@abstractmethod
async def list_boards(self) -> list[PSABoard]:
...
@abstractmethod
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
...

View File

@@ -16,6 +16,7 @@ from app.services.psa.types import (
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
)
from .client import ConnectWiseClient
@@ -55,11 +56,16 @@ class ConnectWiseProvider(PSAProvider):
return self._map_ticket(data)
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
"""Search CW tickets by summary. Supports board_id and status_id filters."""
"""Search CW tickets by summary. Supports board_id, status_id, member_id,
unassigned, board_ids, page, and page_size filters."""
page_size = filters.get("page_size", 10)
page = filters.get("page", 1)
params: dict = {
"fields": "id,summary,company,board,status,priority,closedFlag",
"orderBy": "id desc",
"pageSize": 25,
"pageSize": page_size,
"page": page,
}
# Build CW condition query
@@ -72,6 +78,14 @@ class ConnectWiseProvider(PSAProvider):
conditions.append(f"status/id = {filters['status_id']}")
if not filters.get("include_closed", False):
conditions.append("closedFlag = false")
if filters.get("member_id") is not None:
conditions.append(f"resources/member/id = {filters['member_id']}")
if filters.get("unassigned", False):
conditions.append("resources = null")
board_ids: list[int] = filters.get("board_ids") or []
if board_ids:
board_cond = " or ".join(f"board/id = {bid}" for bid in board_ids)
conditions.append(f"({board_cond})")
if conditions:
params["conditions"] = " and ".join(conditions)
@@ -270,6 +284,32 @@ class ConnectWiseProvider(PSAProvider):
psa_cache.set(cache_key, result, ttl_seconds=900)
return result
async def list_boards(self) -> list[PSABoard]:
"""List active CW service boards (cached 1 hour)."""
cache_key = "boards"
cached = psa_cache.get(cache_key)
if cached is not None:
return cached
data = await self.client.get(
"/service/boards",
params={
"fields": "id,name,inactiveFlag",
"conditions": "inactiveFlag = false",
"pageSize": 100,
},
)
result = [
PSABoard(
id=b["id"],
name=b["name"],
inactive=b.get("inactiveFlag", False),
)
for b in (data if isinstance(data, list) else [])
]
psa_cache.set(cache_key, result, ttl_seconds=3600)
return result
# ── Ticket Context ────────────────────────────────────────────────
async def get_ticket_context(

View File

@@ -11,6 +11,7 @@ from app.services.psa.types import (
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
)
@@ -58,6 +59,9 @@ class HaloPSAProvider(PSAProvider):
async def list_members(self) -> list[PSAMember]:
raise NotImplementedError("Halo PSA integration coming soon")
async def list_boards(self) -> list[PSABoard]:
raise NotImplementedError("list_boards not implemented for this provider")
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
raise NotImplementedError("Halo PSA integration coming soon")

View File

@@ -67,6 +67,12 @@ class PSATimeEntry(BaseModel):
created_at: str | None = None
class PSABoard(BaseModel):
id: int
name: str
inactive: bool = False
class NoteType:
INTERNAL_ANALYSIS = "internal_analysis"
RESOLUTION = "resolution"

View File

@@ -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) =>

View 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">&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 [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 &rarr; 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>
)
}

View File

@@ -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

View File

@@ -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>

View File

@@ -1,3 +1,8 @@
export interface PSABoard {
id: number
name: string
}
export interface PsaConnectionResponse {
id: string
account_id: string