From 346576a7304e2a9533c9268fa3fa5702a6e5e3af Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 15 Apr 2026 03:20:45 +0000 Subject: [PATCH] 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 --- CLAUDE.md | 10 +- backend/app/api/endpoints/integrations.py | 82 +++- backend/app/schemas/__init__.py | 2 + backend/app/schemas/psa_connection.py | 5 + backend/app/services/psa/autotask/provider.py | 4 + backend/app/services/psa/base.py | 5 + .../app/services/psa/connectwise/provider.py | 44 +- backend/app/services/psa/halopsa/provider.py | 4 + backend/app/services/psa/types.py | 6 + frontend/src/api/integrations.ts | 12 +- .../src/components/dashboard/TicketQueue.tsx | 397 ++++++++++++++++++ frontend/src/pages/FlowPilotSessionPage.tsx | 29 ++ frontend/src/pages/QuickStartPage.tsx | 6 + frontend/src/types/integrations.ts | 5 + 14 files changed, 595 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/dashboard/TicketQueue.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 18c1b03f..4cfbfce9 100644 --- a/CLAUDE.md +++ b/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 --log-failed # Failed job logs -gh run view --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 --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi - Always include `Co-Authored-By: Claude Opus 4.6 ` - 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` diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index d3fc9103..4cd08052 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -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( diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 85067a1d..d83f56d4 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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", ] diff --git a/backend/app/schemas/psa_connection.py b/backend/app/schemas/psa_connection.py index 60f881de..f0dfde42 100644 --- a/backend/app/schemas/psa_connection.py +++ b/backend/app/schemas/psa_connection.py @@ -136,3 +136,8 @@ class PsaMemberResponse(BaseModel): class AutoMatchResult(BaseModel): matched: list[PsaMemberMappingResponse] unmatched_users: int + + +class PSABoardResponse(BaseModel): + id: int + name: str diff --git a/backend/app/services/psa/autotask/provider.py b/backend/app/services/psa/autotask/provider.py index ed7ca8d8..a78fc1c1 100644 --- a/backend/app/services/psa/autotask/provider.py +++ b/backend/app/services/psa/autotask/provider.py @@ -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") diff --git a/backend/app/services/psa/base.py b/backend/app/services/psa/base.py index f2522e43..a599b064 100644 --- a/backend/app/services/psa/base.py +++ b/backend/app/services/psa/base.py @@ -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]: ... diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index 2a191c01..ca2c473d 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -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( diff --git a/backend/app/services/psa/halopsa/provider.py b/backend/app/services/psa/halopsa/provider.py index 92e4b757..4a917ed2 100644 --- a/backend/app/services/psa/halopsa/provider.py +++ b/backend/app/services/psa/halopsa/provider.py @@ -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") diff --git a/backend/app/services/psa/types.py b/backend/app/services/psa/types.py index 49338079..c051a5f7 100644 --- a/backend/app/services/psa/types.py +++ b/backend/app/services/psa/types.py @@ -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" diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts index e63687fd..b60113d0 100644 --- a/frontend/src/api/integrations.ts +++ b/frontend/src/api/integrations.ts @@ -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(`/integrations/psa/connections/${id}/test`).then(r => r.data), + listBoards: () => + apiClient.get('/integrations/psa/boards').then(r => r.data), searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) => apiClient.get('/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('/integrations/psa/tickets/search', { params }).then(r => r.data), getTicket: (id: string) => apiClient.get(`/integrations/psa/tickets/${id}`).then(r => r.data), getTicketStatuses: (ticketId: string) => diff --git a/frontend/src/components/dashboard/TicketQueue.tsx b/frontend/src/components/dashboard/TicketQueue.tsx new file mode 100644 index 00000000..da667479 --- /dev/null +++ b/frontend/src/components/dashboard/TicketQueue.tsx @@ -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 ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) +} + +interface BoardSelectorProps { + boards: PSABoard[] + selectedIds: number[] + onChange: (ids: number[]) => void +} + +function BoardSelector({ boards, selectedIds, onChange }: BoardSelectorProps) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false) + } + } + if (open) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [open]) + + const allSelected = selectedIds.length === 0 + const label = allSelected + ? 'All Boards' + : selectedIds.length === 1 + ? (boards.find((b) => b.id === selectedIds[0])?.name ?? '1 board') + : `${selectedIds.length} boards` + + const handleAllBoards = () => { + onChange([]) + } + + const handleToggleBoard = (id: number) => { + if (selectedIds.includes(id)) { + const next = selectedIds.filter((x) => x !== id) + onChange(next) + } else { + onChange([...selectedIds, id]) + } + } + + if (boards.length === 0) return null + + return ( +
+ + + {open && ( +
+ {/* All Boards */} + + + {boards.length > 0 && ( +
+ )} + + {boards.map((board) => { + const checked = selectedIds.includes(board.id) + return ( + + ) + })} +
+ )} +
+ ) +} + +interface TicketRowProps { + ticket: PSATicketSearchResult + isLast: boolean + onStartSession: (ticket: PSATicketSearchResult) => void +} + +function TicketRow({ ticket, isLast, onStartSession }: TicketRowProps) { + return ( +
+ {/* Left: ticket info */} +
+
+ + #{ticket.id} + + {ticket.summary} +
+
+ {ticket.company_name && {ticket.company_name}} + {ticket.company_name && ticket.priority_name && ( + · + )} + {ticket.priority_name && {ticket.priority_name}} +
+
+ + {/* Right: status badge + action */} +
+ {ticket.status_name && ( + + {ticket.status_name} + + )} + +
+
+ ) +} + +export function TicketQueue() { + const navigate = useNavigate() + const [hasConnection, setHasConnection] = useState(null) + const [boards, setBoards] = useState([]) + const [selectedBoardIds, setSelectedBoardIds] = useState([]) + const [activeTab, setActiveTab] = useState('mine') + const [tickets, setTickets] = useState([]) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(false) + const [loading, setLoading] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(null) + + // Check connection on mount + useEffect(() => { + integrationsApi.getConnection() + .then((conn) => { + const active = !!(conn && conn.is_active) + setHasConnection(active) + }) + .catch(() => setHasConnection(false)) + }, []) + + // 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[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 ( +
+ {/* Header */} +
+
+ +

Ticket Queue

+
+ setSelectedBoardIds(ids)} + /> +
+ + {/* Tabs */} +
+ {(['mine', 'unassigned'] as Tab[]).map((tab) => ( + + ))} +
+ + {/* Content */} +
+ {/* Error */} + {error && ( +
+ + {error} +
+ )} + + {/* Loading skeleton */} + {!error && loading && } + + {/* Ticket rows */} + {!error && !loading && tickets.length > 0 && ( + <> + {tickets.map((ticket, i) => ( + + ))} + + )} + + {/* Empty states */} + {!error && !loading && tickets.length === 0 && ( +
+ + {activeTab === 'mine' ? ( + <> +

No open tickets assigned to you

+

+ Make sure your member mapping is configured in Account → Integrations +

+ + ) : ( +

No unassigned open tickets

+ )} +
+ )} + + {/* Load more */} + {!error && !loading && hasMore && ( +
+ +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/FlowPilotSessionPage.tsx b/frontend/src/pages/FlowPilotSessionPage.tsx index 0d9dd91c..4ab4d0b6 100644 --- a/frontend/src/pages/FlowPilotSessionPage.tsx +++ b/frontend/src/pages/FlowPilotSessionPage.tsx @@ -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 diff --git a/frontend/src/pages/QuickStartPage.tsx b/frontend/src/pages/QuickStartPage.tsx index ac931e51..5c911f5a 100644 --- a/frontend/src/pages/QuickStartPage.tsx +++ b/frontend/src/pages/QuickStartPage.tsx @@ -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() {
+ {/* Ticket Queue (auto-hides if no PSA connection) */} +
+ +
+ {/* Dashboard — always visible */}
Dashboard diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts index 6f507457..7758b574 100644 --- a/frontend/src/types/integrations.ts +++ b/frontend/src/types/integrations.ts @@ -1,3 +1,8 @@ +export interface PSABoard { + id: number + name: string +} + export interface PsaConnectionResponse { id: string account_id: string