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:
10
CLAUDE.md
10
CLAUDE.md
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -136,3 +136,8 @@ class PsaMemberResponse(BaseModel):
|
||||
class AutoMatchResult(BaseModel):
|
||||
matched: list[PsaMemberMappingResponse]
|
||||
unmatched_users: int
|
||||
|
||||
|
||||
class PSABoardResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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]:
|
||||
...
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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