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
|
cd backend && pip install httpx && python -m scripts.seed_trees
|
||||||
|
|
||||||
# CI/CD debugging
|
# CI/CD debugging
|
||||||
gh run list --limit 5 # Recent CI runs
|
# CI runs on Gitea (gitea.resolutionflow.com), NOT GitHub Actions — gh run list will return nothing useful
|
||||||
gh run view <id> --log-failed # Failed job logs
|
# Check CI status at: https://gitea.resolutionflow.com/chihlasm/resolutionflow/actions
|
||||||
gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusion}'
|
# `gh` CLI is still used for GitHub Issues/PRs (mirrored repo), not for CI runs
|
||||||
# NEVER use `gh run watch` — it holds context open and burns tokens while waiting
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### URLs
|
### 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 include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
|
||||||
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
|
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
|
||||||
- Large features: commit per phase with `npm run build` validation
|
- 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
|
### After Completing Work
|
||||||
|
|
||||||
@@ -497,7 +497,7 @@ When a feature, fix, or significant piece of work is finished and merged/committ
|
|||||||
## Deployment (Railway)
|
## Deployment (Railway)
|
||||||
|
|
||||||
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)
|
- **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 environments auto-created (need manual domain generation in Railway dashboard)
|
||||||
- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service
|
- PR envs need `VITE_API_URL` set with `https://` prefix on frontend service
|
||||||
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
|
- `ALLOW_RAILWAY_ORIGINS=true` enables CORS for `*.up.railway.app`
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.schemas.psa_connection import (
|
|||||||
PsaMemberMappingSaveRequest,
|
PsaMemberMappingSaveRequest,
|
||||||
PsaMemberResponse,
|
PsaMemberResponse,
|
||||||
AutoMatchResult,
|
AutoMatchResult,
|
||||||
|
PSABoardResponse,
|
||||||
)
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.psa.encryption import (
|
from app.services.psa.encryption import (
|
||||||
@@ -345,26 +346,91 @@ async def update_flowpilot_settings(
|
|||||||
# ── ticket / status / company endpoints ──────────────────────────
|
# ── ticket / status / company endpoints ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
|
@router.get("/boards", response_model=list[PSABoardResponse])
|
||||||
async def search_tickets(
|
async def list_boards(
|
||||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
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:
|
if not current_user.account_id:
|
||||||
raise HTTPException(status_code=400, detail="User has no account")
|
raise HTTPException(status_code=400, detail="User has no account")
|
||||||
|
|
||||||
from app.services.psa.registry import get_provider_for_account
|
from app.services.psa.registry import get_provider_for_account
|
||||||
from app.services.psa.exceptions import PSAError
|
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:
|
try:
|
||||||
provider = await get_provider_for_account(current_user.account_id, db)
|
provider = await get_provider_for_account(current_user.account_id, db)
|
||||||
tickets = await provider.search_tickets(
|
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 [
|
return [
|
||||||
PSATicketSearchResult(
|
PSATicketSearchResult(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .psa_connection import (
|
|||||||
PSATicketSearchResult, PSATicketStatusItem,
|
PSATicketSearchResult, PSATicketStatusItem,
|
||||||
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
|
PsaPostRequest, PsaPostResponse, PsaPreviewResponse, PsaPostLogResponse,
|
||||||
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
|
PsaMemberMappingResponse, PsaMemberMappingSaveRequest, PsaMemberResponse, AutoMatchResult,
|
||||||
|
PSABoardResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -50,4 +51,5 @@ __all__ = [
|
|||||||
"PSATicketSearchResult", "PSATicketStatusItem",
|
"PSATicketSearchResult", "PSATicketStatusItem",
|
||||||
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
|
"PsaPostRequest", "PsaPostResponse", "PsaPreviewResponse", "PsaPostLogResponse",
|
||||||
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
|
"PsaMemberMappingResponse", "PsaMemberMappingSaveRequest", "PsaMemberResponse", "AutoMatchResult",
|
||||||
|
"PSABoardResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -136,3 +136,8 @@ class PsaMemberResponse(BaseModel):
|
|||||||
class AutoMatchResult(BaseModel):
|
class AutoMatchResult(BaseModel):
|
||||||
matched: list[PsaMemberMappingResponse]
|
matched: list[PsaMemberMappingResponse]
|
||||||
unmatched_users: int
|
unmatched_users: int
|
||||||
|
|
||||||
|
|
||||||
|
class PSABoardResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.services.psa.types import (
|
|||||||
PSAMember,
|
PSAMember,
|
||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
|
PSABoard,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +59,9 @@ class AutotaskProvider(PSAProvider):
|
|||||||
async def list_members(self) -> list[PSAMember]:
|
async def list_members(self) -> list[PSAMember]:
|
||||||
raise NotImplementedError("Autotask integration coming soon")
|
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]:
|
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||||
raise NotImplementedError("Autotask integration coming soon")
|
raise NotImplementedError("Autotask integration coming soon")
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .types import (
|
|||||||
PSAMember,
|
PSAMember,
|
||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
|
PSABoard,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +65,10 @@ class PSAProvider(ABC):
|
|||||||
async def list_members(self) -> list[PSAMember]:
|
async def list_members(self) -> list[PSAMember]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_boards(self) -> list[PSABoard]:
|
||||||
|
...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.services.psa.types import (
|
|||||||
PSAMember,
|
PSAMember,
|
||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
|
PSABoard,
|
||||||
)
|
)
|
||||||
from .client import ConnectWiseClient
|
from .client import ConnectWiseClient
|
||||||
|
|
||||||
@@ -55,11 +56,16 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
return self._map_ticket(data)
|
return self._map_ticket(data)
|
||||||
|
|
||||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
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 = {
|
params: dict = {
|
||||||
"fields": "id,summary,company,board,status,priority,closedFlag",
|
"fields": "id,summary,company,board,status,priority,closedFlag",
|
||||||
"orderBy": "id desc",
|
"orderBy": "id desc",
|
||||||
"pageSize": 25,
|
"pageSize": page_size,
|
||||||
|
"page": page,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build CW condition query
|
# Build CW condition query
|
||||||
@@ -72,6 +78,14 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
conditions.append(f"status/id = {filters['status_id']}")
|
conditions.append(f"status/id = {filters['status_id']}")
|
||||||
if not filters.get("include_closed", False):
|
if not filters.get("include_closed", False):
|
||||||
conditions.append("closedFlag = 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:
|
if conditions:
|
||||||
params["conditions"] = " and ".join(conditions)
|
params["conditions"] = " and ".join(conditions)
|
||||||
@@ -270,6 +284,32 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
psa_cache.set(cache_key, result, ttl_seconds=900)
|
psa_cache.set(cache_key, result, ttl_seconds=900)
|
||||||
return result
|
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 ────────────────────────────────────────────────
|
# ── Ticket Context ────────────────────────────────────────────────
|
||||||
|
|
||||||
async def get_ticket_context(
|
async def get_ticket_context(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.services.psa.types import (
|
|||||||
PSAMember,
|
PSAMember,
|
||||||
PSAConfiguration,
|
PSAConfiguration,
|
||||||
PSATimeEntry,
|
PSATimeEntry,
|
||||||
|
PSABoard,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +59,9 @@ class HaloPSAProvider(PSAProvider):
|
|||||||
async def list_members(self) -> list[PSAMember]:
|
async def list_members(self) -> list[PSAMember]:
|
||||||
raise NotImplementedError("Halo PSA integration coming soon")
|
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]:
|
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
|
||||||
raise NotImplementedError("Halo PSA integration coming soon")
|
raise NotImplementedError("Halo PSA integration coming soon")
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ class PSATimeEntry(BaseModel):
|
|||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PSABoard(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
inactive: bool = False
|
||||||
|
|
||||||
|
|
||||||
class NoteType:
|
class NoteType:
|
||||||
INTERNAL_ANALYSIS = "internal_analysis"
|
INTERNAL_ANALYSIS = "internal_analysis"
|
||||||
RESOLUTION = "resolution"
|
RESOLUTION = "resolution"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
|
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 = {
|
export const integrationsApi = {
|
||||||
getConnection: () =>
|
getConnection: () =>
|
||||||
@@ -13,8 +13,18 @@ export const integrationsApi = {
|
|||||||
apiClient.delete(`/integrations/psa/connections/${id}`),
|
apiClient.delete(`/integrations/psa/connections/${id}`),
|
||||||
testConnection: (id: string) =>
|
testConnection: (id: string) =>
|
||||||
apiClient.post<PsaConnectionTestResponse>(`/integrations/psa/connections/${id}/test`).then(r => r.data),
|
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 }) =>
|
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) =>
|
||||||
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
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) =>
|
getTicket: (id: string) =>
|
||||||
apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data),
|
apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data),
|
||||||
getTicketStatuses: (ticketId: string) =>
|
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 { HandoffModal } from '@/components/session/HandoffModal'
|
||||||
import { handoffsApi } from '@/api/handoffs'
|
import { handoffsApi } from '@/api/handoffs'
|
||||||
import { aiSessionsApi } from '@/api'
|
import { aiSessionsApi } from '@/api'
|
||||||
|
import { integrationsApi } from '@/api/integrations'
|
||||||
|
import type { PSATicketInfo } from '@/types/integrations'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
export default function FlowPilotSessionPage() {
|
export default function FlowPilotSessionPage() {
|
||||||
@@ -17,10 +19,13 @@ export default function FlowPilotSessionPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const prefill = (location.state as { prefill?: string } | null)?.prefill || ''
|
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 isPickup = searchParams.get('pickup') === 'true'
|
||||||
const fp = useFlowPilotSession()
|
const fp = useFlowPilotSession()
|
||||||
const branching = useBranching()
|
const branching = useBranching()
|
||||||
const prefillHandledRef = useRef(false)
|
const prefillHandledRef = useRef(false)
|
||||||
|
const psaTicketHandledRef = useRef(false)
|
||||||
const [showOverflow, setShowOverflow] = useState(false)
|
const [showOverflow, setShowOverflow] = useState(false)
|
||||||
const [showResolve, setShowResolve] = useState(false)
|
const [showResolve, setShowResolve] = useState(false)
|
||||||
const [showEscalate, setShowEscalate] = 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 } })
|
fp.startSession({ intake_type: 'free_text', intake_content: { text: prefill } })
|
||||||
}
|
}
|
||||||
}, [prefill, sessionId, fp.session, fp.isLoading]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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)
|
const [pickingUp, setPickingUp] = useState(false)
|
||||||
|
|
||||||
// Load existing session if ID in URL
|
// Load existing session if ID in URL
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAuthStore } from '@/store/authStore'
|
|||||||
import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
|
import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
|
||||||
import { PendingEscalations } from '@/components/dashboard/PendingEscalations'
|
import { PendingEscalations } from '@/components/dashboard/PendingEscalations'
|
||||||
import { ActiveFlowPilotSessions } from '@/components/dashboard/ActiveFlowPilotSessions'
|
import { ActiveFlowPilotSessions } from '@/components/dashboard/ActiveFlowPilotSessions'
|
||||||
|
import { TicketQueue } from '@/components/dashboard/TicketQueue'
|
||||||
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
||||||
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
||||||
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
||||||
@@ -59,6 +60,11 @@ export function QuickStartPage() {
|
|||||||
<ActiveFlowPilotSessions />
|
<ActiveFlowPilotSessions />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ticket Queue (auto-hides if no PSA connection) */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<TicketQueue />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Dashboard — always visible */}
|
{/* Dashboard — always visible */}
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<SectionLabel>Dashboard</SectionLabel>
|
<SectionLabel>Dashboard</SectionLabel>
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
export interface PSABoard {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PsaConnectionResponse {
|
export interface PsaConnectionResponse {
|
||||||
id: string
|
id: string
|
||||||
account_id: string
|
account_id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user