Add CW security roles reference docs and PSA ticket management plan. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
100 KiB
PSA Ticket Management Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add PSA ticket management to ResolutionFlow — a dedicated Tickets page, an updated TicketQueue dashboard widget, and spin-off ticket creation from ResolutionAssist sessions.
Architecture: Dedicated ticket_service.py wraps PSA provider mutations; new normalized DTOs in psa_tickets.py; search_tickets updated to return paginated results via parallel CW count fetch. Frontend uses URL params for filter/pagination state, TicketDetailPanel hydrates via existing getTicketContext + new listResources endpoint.
Tech Stack: FastAPI, SQLAlchemy async, Pydantic v2, Anthropic SDK (AI parse), React 19, TypeScript, Tailwind v4, React Router v7 useSearchParams, Lucide icons.
Phase 1 — Backend: Provider Foundations
Task 1: Add PaginatedTicketResult type + update provider base
Files:
-
Modify:
backend/app/services/psa/types.py -
Modify:
backend/app/services/psa/base.py -
Step 1: Add PaginatedTicketResult to types.py
# backend/app/services/psa/types.py — add after PSABoard class
from dataclasses import dataclass
@dataclass
class PaginatedTicketResult:
items: list["PSATicket"]
total: int
page: int
page_size: int
- Step 2: Update search_tickets signature in base.py
# backend/app/services/psa/base.py
from .types import (
ConnectionTestResult,
PSATicket,
PaginatedTicketResult, # add
PSANote,
PSAStatus,
PSACompany,
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
)
# Change the search_tickets abstract method:
@abstractmethod
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
...
- Step 3: Commit
git add backend/app/services/psa/types.py backend/app/services/psa/base.py
git commit -m "feat(psa): add PaginatedTicketResult type, update provider search_tickets signature"
Task 2: Add new abstract provider methods
Files:
-
Modify:
backend/app/services/psa/types.py -
Modify:
backend/app/services/psa/base.py -
Step 1: Add PSAResource + PSACreatedTicket types to types.py
# backend/app/services/psa/types.py — add after PaginatedTicketResult
class PSAResource(BaseModel):
member_id: int
member_name: str
member_identifier: str
is_rf_user: bool = False
class PSACreatedTicket(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResource] = []
class TicketCreatePayload(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
- Step 2: Add 4 abstract methods to base.py
# backend/app/services/psa/base.py — add these after get_ticket_configurations
@abstractmethod
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
...
@abstractmethod
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
...
@abstractmethod
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
...
@abstractmethod
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
...
Also add list_priorities for the full-form dropdown:
@abstractmethod
async def list_priorities(self) -> list[dict]:
...
- Step 3: Update base.py imports
from .types import (
ConnectionTestResult,
PSATicket,
PaginatedTicketResult,
PSANote,
PSAStatus,
PSACompany,
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
)
- Step 4: Commit
git add backend/app/services/psa/types.py backend/app/services/psa/base.py
git commit -m "feat(psa): add PSAResource, TicketCreatePayload types and abstract provider methods"
Task 3: Implement new CW provider methods
Files:
-
Modify:
backend/app/services/psa/connectwise/provider.py -
Step 1: Update CW search_tickets to return PaginatedTicketResult
In ConnectWiseProvider.search_tickets(), replace the final return [...] block with a parallel count fetch:
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
page_size = filters.get("page_size", 10)
page = filters.get("page", 1)
params: dict = {
"fields": "id,summary,company,board,status,priority,closedFlag",
"orderBy": "priority/sort asc,dateEntered desc",
"pageSize": page_size,
"page": page,
}
conditions: list[str] = []
if query:
conditions.append(f"summary contains '{query}'")
if filters.get("board_id"):
conditions.append(f"board/id = {filters['board_id']}")
if filters.get("status_id"):
conditions.append(f"status/id = {filters['status_id']}")
if not filters.get("include_closed", False):
conditions.append("closedFlag = false")
if filters.get("member_identifier") is not None:
conditions.append(f"resources contains '{filters['member_identifier']}'")
if filters.get("unassigned", False):
conditions.append("resources = null")
board_ids: list[int] = filters.get("board_ids") or []
if board_ids:
board_list = ", ".join(str(bid) for bid in board_ids)
conditions.append(f"board/id in ({board_list})")
condition_str = " and ".join(conditions) if conditions else ""
if condition_str:
params["conditions"] = condition_str
count_params: dict = {}
if condition_str:
count_params["conditions"] = condition_str
# Fire page fetch + count in parallel
data, count_data = await asyncio.gather(
self.client.get("/service/tickets", params=params),
self.client.get("/service/tickets/count", params=count_params),
)
items = [self._map_ticket(t) for t in (data if isinstance(data, list) else [])]
total = count_data.get("count", len(items)) if isinstance(count_data, dict) else len(items)
return PaginatedTicketResult(items=items, total=total, page=page, page_size=page_size)
- Step 2: Add update import in provider.py
from app.services.psa.types import (
ConnectionTestResult,
PSATicket,
PaginatedTicketResult,
PSANote,
PSAStatus,
PSACompany,
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
)
- Step 3: Update _map_ticket to expose company_id and board_id
@staticmethod
def _map_ticket(data: dict) -> PSATicket:
company = data.get("company") or {}
board = data.get("board") or {}
status = data.get("status") or {}
priority = data.get("priority") or {}
return PSATicket(
id=str(data.get("id", "")),
summary=data.get("summary", ""),
company_name=company.get("name"),
company_id=str(company.get("id")) if company.get("id") else None,
board_name=board.get("name"),
board_id=board.get("id"),
status_name=status.get("name"),
status_id=status.get("id"),
priority_name=priority.get("name"),
priority_id=priority.get("id"),
closed=data.get("closedFlag", False),
)
- Step 4: Implement list_resources
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
data = await self.client.get(f"/service/tickets/{ticket_id}/members")
results = []
for m in (data if isinstance(data, list) else []):
member = m.get("member") or {}
results.append(PSAResource(
member_id=member.get("id", 0),
member_name=member.get("name", ""),
member_identifier=member.get("identifier", ""),
))
return results
- Step 5: Implement add_resource
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
data = await self.client.post(
f"/service/tickets/{ticket_id}/members",
json={"member": {"id": member_id}},
)
member = (data.get("member") or {}) if isinstance(data, dict) else {}
return PSAResource(
member_id=member.get("id", member_id),
member_name=member.get("name", ""),
member_identifier=member.get("identifier", ""),
)
- Step 6: Implement remove_resource
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
# CW DELETE /service/tickets/{id}/members requires the member record id,
# not the member id. Fetch the list first to find the record id.
members_data = await self.client.get(f"/service/tickets/{ticket_id}/members")
record_id = None
for m in (members_data if isinstance(members_data, list) else []):
if (m.get("member") or {}).get("id") == member_id:
record_id = m.get("id")
break
if record_id is None:
return # Already not assigned — idempotent
await self.client.delete(f"/service/tickets/{ticket_id}/members/{record_id}")
- Step 7: Implement create_ticket
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
body: dict = {
"summary": payload.summary,
"board": {"id": payload.board_id},
"company": {"id": payload.company_id},
"status": {"id": payload.status_id},
"priority": {"id": payload.priority_id},
}
if payload.description:
body["initialDescription"] = payload.description
if payload.assigned_member_id:
body["owner"] = {"id": payload.assigned_member_id}
data = await self.client.post("/service/tickets", json=body)
# Fetch resources for the created ticket
ticket_id = data.get("id")
resources: list[PSAResource] = []
if ticket_id and payload.assigned_member_id:
try:
resources = await self.list_resources(ticket_id)
except Exception:
pass
company = data.get("company") or {}
board = data.get("board") or {}
status = data.get("status") or {}
priority = data.get("priority") or {}
return PSACreatedTicket(
id=ticket_id or 0,
summary=data.get("summary", payload.summary),
board_name=board.get("name", ""),
status_name=status.get("name", ""),
priority_name=priority.get("name", ""),
company_name=company.get("name", ""),
resources=resources,
)
- Step 8: Implement list_priorities
async def list_priorities(self) -> list[dict]:
data = await self.client.get("/service/priorities", params={"pageSize": 50})
return [
{"id": p.get("id"), "name": p.get("name")}
for p in (data if isinstance(data, list) else [])
]
- Step 9: Commit
git add backend/app/services/psa/connectwise/provider.py
git commit -m "feat(psa): implement list/add/remove resources, create_ticket, paginated search in CW provider"
Task 4: Update PSATicketInfo schema + add psa_tickets.py schemas
Files:
-
Modify:
backend/app/schemas/psa_connection.py -
Create:
backend/app/schemas/psa_tickets.py -
Step 1: Add company_id and board_id to PSATicketInfo in psa_connection.py
The existing PSATicketInfo equivalent for API responses is PSATicketSearchResult. Add company_id/board_id fields and a new PSATicketInfoFull for the get_ticket endpoint:
# backend/app/schemas/psa_connection.py — update PSATicketSearchResult
class PSATicketSearchResult(BaseModel):
id: str
summary: str
company_name: str | None = None
company_id: str | None = None # add
board_name: str | None = None
board_id: int | None = None # add
status_name: str | None = None
status_id: int | None = None # add
priority_name: str | None = None
priority_id: int | None = None # add
closed: bool = False
- Step 2: Create psa_tickets.py with all new schemas
# backend/app/schemas/psa_tickets.py
"""Normalized DTOs for ticket management endpoints."""
from __future__ import annotations
from pydantic import BaseModel
class PSAResourceSchema(BaseModel):
member_id: int
member_name: str
member_identifier: str
is_rf_user: bool = False
class PSATicketCreatedSchema(BaseModel):
id: int
summary: str
board_name: str
status_name: str
priority_name: str
company_name: str
resources: list[PSAResourceSchema] = []
class PSATicketStatusUpdateSchema(BaseModel):
ticket_id: int
previous_status: str
new_status: str
class TicketCreatePayloadSchema(BaseModel):
summary: str
company_id: int
board_id: int
status_id: int
priority_id: int
description: str | None = None
assigned_member_id: int | None = None
class TicketListResponseSchema(BaseModel):
items: list # list[PSATicketSearchResult] — imported in endpoints
total: int
page: int
page_size: int
class AiParseRequestSchema(BaseModel):
prompt: str
class AiParseResponseSchema(BaseModel):
summary: str | None = None
company_id: int | None = None
board_id: int | None = None
priority_id: int | None = None
status_id: int | None = None
assigned_member_id: int | None = None
description: str | None = None
missing_fields: list[str] = []
warnings: list[str] = []
class PSAPrioritySchema(BaseModel):
id: int
name: str
- Step 3: Commit
git add backend/app/schemas/psa_connection.py backend/app/schemas/psa_tickets.py
git commit -m "feat(psa): expand PSATicketSearchResult with IDs, add psa_tickets.py schemas"
Phase 2 — Backend: ticket_service.py + Endpoints
Task 5: Create ticket_service.py
Files:
-
Create:
backend/app/services/ticket_service.py -
Step 1: Write ticket_service.py
# backend/app/services/ticket_service.py
"""Ticket mutation service — wraps PSA provider, resolves is_rf_user flag."""
from __future__ import annotations
import logging
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.psa_connection import PsaConnection
from app.models.psa_member_mapping import PsaMemberMapping
from app.schemas.psa_tickets import (
PSAResourceSchema,
PSATicketCreatedSchema,
PSATicketStatusUpdateSchema,
)
from app.services.psa.registry import get_provider_for_account
from app.services.psa.types import TicketCreatePayload
logger = logging.getLogger(__name__)
async def _get_mapped_member_ids(account_id: UUID, db: AsyncSession) -> set[int]:
"""Return set of external_member_id ints that are mapped to RF users."""
conn_result = await db.execute(
select(PsaConnection).where(PsaConnection.account_id == account_id)
)
conn = conn_result.scalar_one_or_none()
if not conn:
return set()
mappings = await db.execute(
select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id)
)
return {int(m.external_member_id) for m in mappings.scalars().all() if m.external_member_id}
async def list_resources(
account_id: UUID, ticket_id: int, db: AsyncSession
) -> list[PSAResourceSchema]:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
resources = await provider.list_resources(ticket_id)
return [
PSAResourceSchema(
member_id=r.member_id,
member_name=r.member_name,
member_identifier=r.member_identifier,
is_rf_user=r.member_id in mapped_ids,
)
for r in resources
]
async def add_resource(
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
) -> PSAResourceSchema:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
resource = await provider.add_resource(ticket_id, member_id)
return PSAResourceSchema(
member_id=resource.member_id,
member_name=resource.member_name,
member_identifier=resource.member_identifier,
is_rf_user=resource.member_id in mapped_ids,
)
async def remove_resource(
account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession
) -> None:
provider = await get_provider_for_account(account_id, db)
await provider.remove_resource(ticket_id, member_id)
async def update_status(
account_id: UUID, ticket_id: int, status_id: int, db: AsyncSession
) -> PSATicketStatusUpdateSchema:
provider = await get_provider_for_account(account_id, db)
# get current status before updating
ticket = await provider.get_ticket(str(ticket_id))
previous_status = ticket.status_name or ""
await provider.update_ticket_status(str(ticket_id), status_id)
# get new status name from statuses list
statuses = await provider.get_ticket_statuses(ticket.board_id or 0)
new_status = next((s.name for s in statuses if s.id == status_id), str(status_id))
return PSATicketStatusUpdateSchema(
ticket_id=ticket_id,
previous_status=previous_status,
new_status=new_status,
)
async def create_ticket(
account_id: UUID, payload: TicketCreatePayload, db: AsyncSession
) -> PSATicketCreatedSchema:
provider = await get_provider_for_account(account_id, db)
mapped_ids = await _get_mapped_member_ids(account_id, db)
result = await provider.create_ticket(payload)
return PSATicketCreatedSchema(
id=result.id,
summary=result.summary,
board_name=result.board_name,
status_name=result.status_name,
priority_name=result.priority_name,
company_name=result.company_name,
resources=[
PSAResourceSchema(
member_id=r.member_id,
member_name=r.member_name,
member_identifier=r.member_identifier,
is_rf_user=r.member_id in mapped_ids,
)
for r in result.resources
],
)
- Step 2: Commit
git add backend/app/services/ticket_service.py
git commit -m "feat(psa): add ticket_service.py with list/add/remove resource, update_status, create_ticket"
Task 6: Update search endpoint + add new ticket endpoints
Files:
-
Modify:
backend/app/api/endpoints/integrations.py -
Step 1: Add new imports at top of integrations.py
from app.schemas.psa_tickets import (
PSAResourceSchema,
PSATicketCreatedSchema,
PSATicketStatusUpdateSchema,
TicketCreatePayloadSchema,
AiParseRequestSchema,
AiParseResponseSchema,
PSAPrioritySchema,
)
import app.services.ticket_service as ticket_svc
- Step 2: Update search_tickets endpoint to return paginated response
Replace the existing @router.get("/tickets/search", ...) endpoint with:
@router.get("/tickets/search")
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 = "",
priority: str | None = None,
company_id: int | None = None,
page: int = 1,
page_size: int = 25,
):
"""Search ConnectWise tickets — returns paginated TicketListResponse."""
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
member_identifier: 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 not mapping:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
try:
_provider = await get_provider_for_account(current_user.account_id, db)
cw_members = await _provider.list_members()
matched = next((m for m in cw_members if m.id == mapping.external_member_id), None)
if matched:
member_identifier = matched.identifier
else:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
except PSAError:
return {"items": [], "total": 0, "page": page, "page_size": page_size}
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)
result = await provider.search_tickets(
query,
board_id=board_id,
status_id=status_id,
include_closed=include_closed,
member_identifier=member_identifier,
unassigned=unassigned,
board_ids=parsed_board_ids,
company_id=company_id,
page=page,
page_size=page_size,
)
items = [
PSATicketSearchResult(
id=t.id,
summary=t.summary,
company_name=t.company_name,
company_id=t.company_id,
board_name=t.board_name,
board_id=t.board_id,
status_name=t.status_name,
status_id=t.status_id,
priority_name=t.priority_name,
priority_id=t.priority_id,
closed=t.closed,
)
for t in result.items
]
return {"items": items, "total": result.total, "page": result.page, "page_size": result.page_size}
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
- Step 3: Add POST /tickets endpoint
@router.post("/tickets", response_model=PSATicketCreatedSchema, status_code=201)
async def create_ticket(
data: TicketCreatePayloadSchema,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create a new PSA ticket."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
from app.services.psa.types import TicketCreatePayload
try:
return await ticket_svc.create_ticket(
current_user.account_id,
TicketCreatePayload(**data.model_dump()),
db,
)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
- Step 4: Add PATCH /tickets/{id}/status endpoint
@router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema)
async def update_ticket_status_endpoint(
ticket_id: int,
status_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update a ticket's status."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.update_status(current_user.account_id, ticket_id, status_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
- Step 5: Add resource CRUD endpoints
@router.get("/tickets/{ticket_id}/resources", response_model=list[PSAResourceSchema])
async def list_ticket_resources(
ticket_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.list_resources(current_user.account_id, ticket_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/tickets/{ticket_id}/resources", response_model=PSAResourceSchema, status_code=201)
async def add_ticket_resource(
ticket_id: int,
member_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
return await ticket_svc.add_resource(current_user.account_id, ticket_id, member_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.delete("/tickets/{ticket_id}/resources/{member_id}", status_code=204)
async def remove_ticket_resource(
ticket_id: int,
member_id: int,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
if not current_user.account_id:
raise HTTPException(status_code=400, detail="User has no account")
from app.services.psa.exceptions import PSAError
try:
await ticket_svc.remove_resource(current_user.account_id, ticket_id, member_id, db)
except PSAError as e:
raise HTTPException(status_code=502, detail=str(e))
- Step 6: Add GET /priorities endpoint
@router.get("/priorities", response_model=list[PSAPrioritySchema])
async def list_priorities(
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""List PSA priority levels for ticket creation form."""
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)
raw = await provider.list_priorities()
return [PSAPrioritySchema(id=p["id"], name=p["name"]) for p in raw if p.get("id")]
except PSAError:
return []
- Step 7: Commit
git add backend/app/api/endpoints/integrations.py
git commit -m "feat(psa): update search endpoint for pagination, add create/status/resource/priority endpoints"
Task 7: Add AI parse endpoint
Files:
-
Modify:
backend/app/api/endpoints/integrations.py -
Step 1: Add ai-parse endpoint
@router.post("/tickets/ai-parse", response_model=AiParseResponseSchema)
async def ai_parse_ticket(
data: AiParseRequestSchema,
current_user: Annotated[User, Depends(require_engineer_or_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Parse natural language into a ticket pre-fill payload using Claude."""
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
from app.core.config import settings
import anthropic
import json
# Fetch boards + members for context (both cached)
boards = []
members = []
try:
provider = await get_provider_for_account(current_user.account_id, db)
boards = await provider.list_boards()
members = await provider.list_members()
except PSAError:
pass
boards_list = [{"id": b.id, "name": b.name} for b in boards]
members_list = [{"id": m.id, "name": m.name, "identifier": m.identifier} for m in members]
system_prompt = """You are a ticket triage assistant for an MSP help desk.
Extract structured ticket information from the engineer's natural language description.
Return ONLY valid JSON matching this exact schema — no other text:
{
"summary": "short one-line ticket title or null",
"board_id": "integer matching one of the provided boards or null",
"priority_name": "one of: Critical, High, Medium, Low, or null",
"description": "expanded description or null",
"assignee_identifier": "member identifier string from the provided members list or null",
"warnings": ["list of strings explaining what could not be resolved"]
}"""
user_msg = f"""Available boards: {json.dumps(boards_list)}
Available members: {json.dumps(members_list[:50])}
Engineer's description: {data.prompt}"""
missing_fields: list[str] = []
warnings: list[str] = []
response_data = AiParseResponseSchema()
try:
client = anthropic.AsyncAnthropic(
api_key=settings.ANTHROPIC_API_KEY,
max_retries=1,
)
msg = await client.messages.create(
model=settings.get_model_for_action("default"),
max_tokens=512,
system=system_prompt,
messages=[{"role": "user", "content": user_msg}],
)
raw = msg.content[0].text.strip()
# Strip markdown fences if present
if raw.startswith("```"):
import re
raw = re.sub(r'^```(?:json)?\s*', '', raw)
raw = re.sub(r'\s*```$', '', raw.strip())
parsed = json.loads(raw)
response_data.summary = parsed.get("summary")
response_data.description = parsed.get("description")
warnings = parsed.get("warnings", [])
# Resolve board_id
if parsed.get("board_id"):
board_match = next((b for b in boards if b.id == int(parsed["board_id"])), None)
if board_match:
response_data.board_id = board_match.id
else:
missing_fields.append("board_id")
warnings.append(f"Board ID {parsed['board_id']} not found")
else:
missing_fields.append("board_id")
# Resolve assignee
if parsed.get("assignee_identifier"):
member = next((m for m in members if m.identifier == parsed["assignee_identifier"]), None)
if member:
response_data.assigned_member_id = int(member.id)
else:
warnings.append(f"Member '{parsed['assignee_identifier']}' not found")
# Priority/status always need manual selection — they're board-dependent
missing_fields.extend(["status_id", "priority_id", "company_id"])
except Exception as e:
logger.warning("AI parse failed: %s", e)
missing_fields = ["summary", "board_id", "status_id", "priority_id", "company_id"]
warnings = ["AI parsing failed — please fill in manually"]
response_data.missing_fields = missing_fields
response_data.warnings = warnings
return response_data
- Step 2: Commit
git add backend/app/api/endpoints/integrations.py
git commit -m "feat(psa): add AI ticket parse endpoint"
Task 8: Update assistant_chat_service.py system prompt
Files:
-
Modify:
backend/app/services/assistant_chat_service.py -
Step 1: Add spin-off ticket rule to ASSISTANT_SYSTEM_PROMPT
Find the end of ASSISTANT_SYSTEM_PROMPT in assistant_chat_service.py and append this section before the closing """:
# Add as the last section of ASSISTANT_SYSTEM_PROMPT, before the closing triple-quote:
## SPIN-OFF TICKET CREATION
When you identify a second distinct issue that is clearly separate from the primary topic \
of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \
Use this sparingly — only when the issue is genuinely independent, not for every tangential mention.
Format:
[ACTIONS]
[
{
"label": "Create ticket: <brief issue title>",
"command": "create_spin_off_ticket",
"description": "<one sentence description of the separate issue>"
}
]
[/ACTIONS]
- Step 2: Write a backend test for the new endpoints
# backend/tests/test_psa_tickets.py
"""Routing and auth tests for new ticket management endpoints."""
import pytest
@pytest.mark.asyncio
async def test_create_ticket_requires_auth(client):
"""POST /tickets returns 401 without auth."""
response = await client.post(
"/api/v1/integrations/psa/tickets",
json={
"summary": "Test", "company_id": 1, "board_id": 1,
"status_id": 1, "priority_id": 1
},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_resources_requires_auth(client):
response = await client.get("/api/v1/integrations/psa/tickets/1/resources")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_search_tickets_returns_paginated_shape(client, auth_headers):
"""search endpoint returns TicketListResponse shape when no PSA connected."""
response = await client.get(
"/api/v1/integrations/psa/tickets/search",
headers=auth_headers,
)
# No PSA connection → 400
assert response.status_code in (200, 400, 502)
if response.status_code == 200:
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
@pytest.mark.asyncio
async def test_update_status_requires_auth(client):
response = await client.patch(
"/api/v1/integrations/psa/tickets/1/status?status_id=5"
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_ai_parse_requires_auth(client):
response = await client.post(
"/api/v1/integrations/psa/tickets/ai-parse",
json={"prompt": "New ticket for Acme"},
)
assert response.status_code == 401
- Step 3: Run tests
cd backend && pytest tests/test_psa_tickets.py -v --override-ini="addopts="
Expected: all pass.
- Step 4: Commit
git add backend/app/services/assistant_chat_service.py backend/tests/test_psa_tickets.py
git commit -m "feat(psa): add spin-off ticket system prompt rule, backend routing tests"
Phase 3 — Frontend: Types + API Client
Task 9: Create types/tickets.ts + update types/integrations.ts
Files:
-
Create:
frontend/src/types/tickets.ts -
Modify:
frontend/src/types/integrations.ts -
Step 1: Create types/tickets.ts
// frontend/src/types/tickets.ts
import type { PSATicketSearchResult } from '@/types/integrations'
export interface TicketFilters {
search: string
board_id: number | null
status_id: number | null
priority: string | null
company_id: number | null
assigned: 'me' | 'unassigned' | 'all' | number
include_closed: boolean
}
export const DEFAULT_TICKET_FILTERS: TicketFilters = {
search: '',
board_id: null,
status_id: null,
priority: null,
company_id: null,
assigned: 'all',
include_closed: false,
}
export interface TicketCreationPayload {
summary: string
company_id: number | null
board_id: number | null
status_id: number | null
priority_id: number | null
description: string
assigned_member_id: number | null
}
export interface AiParseResponse {
summary: string | null
company_id: number | null
board_id: number | null
priority_id: number | null
status_id: number | null
assigned_member_id: number | null
description: string | null
missing_fields: string[]
warnings: string[]
}
export interface PSAResource {
member_id: number
member_name: string
member_identifier: string
is_rf_user: boolean
}
export interface PSATicketCreated {
id: number
summary: string
board_name: string
status_name: string
priority_name: string
company_name: string
resources: PSAResource[]
}
export interface PSATicketStatusUpdate {
ticket_id: number
previous_status: string
new_status: string
}
export interface TicketListResponse {
items: PSATicketSearchResult[]
total: number
page: number
page_size: number
}
export interface PSAPriority {
id: number
name: string
}
- Step 2: Update PSATicketSearchResult and PSATicketInfo in types/integrations.ts
// frontend/src/types/integrations.ts — update these interfaces
export interface PSATicketInfo {
id: string
summary: string
company_name: string | null
company_id: number | null // add
board_name: string | null
board_id: number | null // add
status_name: string | null
status_id: number | null // add
priority_name: string | null
priority_id: number | null // add
}
export interface PSATicketSearchResult {
id: string
summary: string
company_name: string | null
company_id: string | null // add
board_name: string | null
board_id: number | null // add
status_name: string | null
status_id: number | null // add
priority_name: string | null
priority_id: number | null // add
closed: boolean
}
- Step 3: Export new types from types/index.ts (if it exists)
grep -n "tickets" /home/coder/root-workspace/resolutionflow/frontend/src/types/index.ts
If types/index.ts exists and exports from other type files, add:
export * from './tickets'
- Step 4: Commit
git add frontend/src/types/tickets.ts frontend/src/types/integrations.ts
git commit -m "feat(tickets): add tickets types, expand PSATicketSearchResult/PSATicketInfo with IDs"
Task 10: Create api/tickets.ts + update api/integrations.ts
Files:
-
Create:
frontend/src/api/tickets.ts -
Modify:
frontend/src/api/integrations.ts -
Step 1: Create api/tickets.ts
// frontend/src/api/tickets.ts
import { apiClient } from './client'
import type {
PSAResource,
PSATicketCreated,
PSATicketStatusUpdate,
TicketCreationPayload,
AiParseResponse,
TicketListResponse,
PSAPriority,
} from '@/types/tickets'
export const ticketsApi = {
listResources: (ticketId: number): Promise<PSAResource[]> =>
apiClient.get<PSAResource[]>(`/integrations/psa/tickets/${ticketId}/resources`).then(r => r.data),
addResource: (ticketId: number, memberId: number): Promise<PSAResource> =>
apiClient.post<PSAResource>(`/integrations/psa/tickets/${ticketId}/resources?member_id=${memberId}`).then(r => r.data),
removeResource: (ticketId: number, memberId: number): Promise<void> =>
apiClient.delete(`/integrations/psa/tickets/${ticketId}/resources/${memberId}`).then(() => undefined),
updateStatus: (ticketId: number, statusId: number): Promise<PSATicketStatusUpdate> =>
apiClient.patch<PSATicketStatusUpdate>(`/integrations/psa/tickets/${ticketId}/status?status_id=${statusId}`).then(r => r.data),
createTicket: (payload: TicketCreationPayload): Promise<PSATicketCreated> =>
apiClient.post<PSATicketCreated>('/integrations/psa/tickets', payload).then(r => r.data),
aiParse: (prompt: string): Promise<AiParseResponse> =>
apiClient.post<AiParseResponse>('/integrations/psa/tickets/ai-parse', { prompt }).then(r => r.data),
listPriorities: (): Promise<PSAPriority[]> =>
apiClient.get<PSAPriority[]>('/integrations/psa/priorities').then(r => r.data),
searchTickets: (params: {
query?: string
board_id?: number | null
status_id?: number | null
include_closed?: boolean
assigned_to_me?: boolean
unassigned?: boolean
board_ids?: string
priority?: string | null
company_id?: number | null
page?: number
page_size?: number
}): Promise<TicketListResponse> =>
apiClient.get<TicketListResponse>('/integrations/psa/tickets/search', { params }).then(r => r.data),
}
- Step 2: Update return types in api/integrations.ts
// frontend/src/api/integrations.ts — update these two methods:
import type { TicketListResponse } from '@/types/tickets'
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) =>
apiClient.get<TicketListResponse>('/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<TicketListResponse>('/integrations/psa/tickets/search', { params }).then(r => r.data),
- Step 3: Update TicketQueue.tsx and TicketPickerModal.tsx to read .items
In frontend/src/components/dashboard/TicketQueue.tsx, find every place that uses the result of searchTicketsQueue() or searchTickets() as an array and change it to read .items:
grep -n "searchTicketsQueue\|searchTickets" /home/coder/root-workspace/resolutionflow/frontend/src/components/dashboard/TicketQueue.tsx
Change any setTickets(data) or similar to setTickets(data.items).
In frontend/src/components/session/TicketPickerModal.tsx:
grep -n "searchTickets" /home/coder/root-workspace/resolutionflow/frontend/src/components/session/TicketPickerModal.tsx
Change any usage from array result to .items access.
- Step 4: Commit
git add frontend/src/api/tickets.ts frontend/src/api/integrations.ts \
frontend/src/components/dashboard/TicketQueue.tsx \
frontend/src/components/session/TicketPickerModal.tsx
git commit -m "feat(tickets): add tickets API client, update integrations API for paginated search"
Phase 4 — Tickets Page
Task 11: Create TicketFilterBar.tsx
Files:
-
Create:
frontend/src/components/tickets/TicketFilterBar.tsx -
Step 1: Create the config-driven filter bar
// frontend/src/components/tickets/TicketFilterBar.tsx
import { Search, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TicketFilters } from '@/types/tickets'
import type { PSABoard, PSATicketStatusItem } from '@/types/integrations'
import type { PSAPriority } from '@/types/tickets'
interface TicketFilterBarProps {
filters: TicketFilters
onChange: (updated: Partial<TicketFilters>) => void
boards: PSABoard[]
statuses: PSATicketStatusItem[]
priorities: PSAPriority[]
members: { id: number; name: string }[]
total: number
page: number
pageSize: number
onPageChange: (page: number) => void
loading: boolean
}
export function TicketFilterBar({
filters, onChange, boards, statuses, priorities, members,
total, page, pageSize, onPageChange, loading,
}: TicketFilterBarProps) {
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
const hasNext = page * pageSize < total
const hasPrev = page > 1
return (
<div className="space-y-3">
{/* Filter row */}
<div className="flex flex-wrap gap-2 items-center">
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<input
className="bg-input border border-default rounded-[5px] pl-8 pr-3 py-1.5 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none w-48"
placeholder="Search tickets..."
value={filters.search}
onChange={e => onChange({ search: e.target.value })}
/>
</div>
{/* Assignment */}
<select
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
value={typeof filters.assigned === 'number' ? String(filters.assigned) : filters.assigned}
onChange={e => {
const v = e.target.value
onChange({ assigned: v === 'me' || v === 'unassigned' || v === 'all' ? v : Number(v) })
}}
>
<option value="all">All Tickets</option>
<option value="me">My Tickets</option>
<option value="unassigned">Unassigned</option>
{members.map(m => (
<option key={m.id} value={String(m.id)}>{m.name}</option>
))}
</select>
{/* Board */}
<select
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
value={filters.board_id ?? ''}
onChange={e => onChange({ board_id: e.target.value ? Number(e.target.value) : null })}
>
<option value="">All Boards</option>
{boards.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
{/* Status */}
<select
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
value={filters.status_id ?? ''}
onChange={e => onChange({ status_id: e.target.value ? Number(e.target.value) : null })}
>
<option value="">All Statuses</option>
{statuses.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
{/* Priority */}
<select
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
value={filters.priority ?? ''}
onChange={e => onChange({ priority: e.target.value || null })}
>
<option value="">All Priorities</option>
{priorities.map(p => <option key={p.id} value={p.name}>{p.name}</option>)}
</select>
{/* Include closed */}
<label className="flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
className="accent-accent"
checked={filters.include_closed}
onChange={e => onChange({ include_closed: e.target.checked })}
/>
Include closed
</label>
{/* Clear filters */}
{(filters.search || filters.board_id || filters.status_id || filters.priority || filters.assigned !== 'all' || filters.include_closed) && (
<button
onClick={() => onChange({ search: '', board_id: null, status_id: null, priority: null, assigned: 'all', include_closed: false })}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
>
<X className="w-3 h-3" /> Clear
</button>
)}
</div>
{/* Pagination row */}
{total > 0 && (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{loading ? 'Loading…' : `Showing ${start}–${end} of ${total} tickets`}
</span>
<div className="flex gap-1">
<button
disabled={!hasPrev}
onClick={() => onPageChange(page - 1)}
className={cn(
'px-2 py-1 rounded border text-xs transition-colors',
hasPrev
? 'border-default text-primary hover:border-hover'
: 'border-default text-muted-foreground opacity-40 cursor-not-allowed'
)}
>
Prev
</button>
<button
disabled={!hasNext}
onClick={() => onPageChange(page + 1)}
className={cn(
'px-2 py-1 rounded border text-xs transition-colors',
hasNext
? 'border-default text-primary hover:border-hover'
: 'border-default text-muted-foreground opacity-40 cursor-not-allowed'
)}
>
Next
</button>
</div>
</div>
)}
</div>
)
}
- Step 2: Commit
git add frontend/src/components/tickets/TicketFilterBar.tsx
git commit -m "feat(tickets): add config-driven TicketFilterBar with pagination controls"
Task 12: Create TicketListRow.tsx
Files:
-
Create:
frontend/src/components/tickets/TicketListRow.tsx -
Step 1: Create compact row component
// frontend/src/components/tickets/TicketListRow.tsx
import { cn } from '@/lib/utils'
import type { PSATicketSearchResult } from '@/types/integrations'
interface TicketListRowProps {
ticket: PSATicketSearchResult
selected: boolean
onClick: () => void
}
const PRIORITY_STYLES: Record<string, string> = {
Critical: 'text-danger',
High: 'text-danger',
Medium: 'text-warning',
Low: 'text-muted-foreground',
}
const STATUS_STYLES: Record<string, { bg: string; text: string }> = {
New: { bg: 'bg-accent/10', text: 'text-accent' },
'In Progress': { bg: 'bg-warning/10', text: 'text-warning' },
Waiting: { bg: 'bg-success/10', text: 'text-success' },
Resolved: { bg: 'bg-muted/10', text: 'text-muted-foreground' },
}
function statusStyle(name: string | null) {
if (!name) return { bg: 'bg-elevated', text: 'text-muted-foreground' }
return STATUS_STYLES[name] ?? { bg: 'bg-elevated', text: 'text-muted-foreground' }
}
export function TicketListRow({ ticket, selected, onClick }: TicketListRowProps) {
const { bg, text } = statusStyle(ticket.status_name)
const priorityClass = PRIORITY_STYLES[ticket.priority_name ?? ''] ?? 'text-muted-foreground'
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={e => e.key === 'Enter' && onClick()}
className={cn(
'flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-default text-sm',
selected ? 'bg-accent/5' : 'hover:bg-elevated'
)}
>
{/* ID */}
<span className="w-12 shrink-0 text-accent text-xs font-mono">#{ticket.id}</span>
{/* Summary */}
<span className="flex-1 truncate text-primary font-medium">{ticket.summary}</span>
{/* Company */}
<span className="w-32 shrink-0 truncate text-muted-foreground text-xs hidden md:block">
{ticket.company_name ?? '—'}
</span>
{/* Board */}
<span className="w-28 shrink-0 truncate text-muted-foreground text-xs hidden lg:block">
{ticket.board_name ?? '—'}
</span>
{/* Status badge */}
<span className={cn('shrink-0 px-1.5 py-0.5 rounded text-[11px] font-medium', bg, text)}>
{ticket.status_name ?? '—'}
</span>
{/* Priority */}
<span className={cn('w-14 shrink-0 text-xs text-right', priorityClass)}>
{ticket.priority_name ?? '—'}
</span>
</div>
)
}
- Step 2: Commit
git add frontend/src/components/tickets/TicketListRow.tsx
git commit -m "feat(tickets): add compact TicketListRow component"
Task 13: Create TicketsPage.tsx
Files:
-
Create:
frontend/src/pages/TicketsPage.tsx -
Step 1: Create TicketsPage with URL-param filter state
// frontend/src/pages/TicketsPage.tsx
import { useEffect, useState, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Plus, Ticket } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { TicketFilterBar } from '@/components/tickets/TicketFilterBar'
import { TicketListRow } from '@/components/tickets/TicketListRow'
import { TicketDetailPanel } from '@/components/tickets/TicketDetailPanel'
import { NewTicketModal } from '@/components/tickets/NewTicketModal'
import { integrationsApi } from '@/api/integrations'
import { ticketsApi } from '@/api/tickets'
import type { PSATicketSearchResult, PSABoard, PSATicketStatusItem } from '@/types/integrations'
import type { TicketFilters, PSAPriority } from '@/types/tickets'
import { DEFAULT_TICKET_FILTERS } from '@/types/tickets'
const PAGE_SIZE = 25
function filtersFromParams(params: URLSearchParams): TicketFilters & { page: number } {
const assigned = params.get('assigned') ?? 'all'
return {
search: params.get('search') ?? '',
board_id: params.get('board') ? Number(params.get('board')) : null,
status_id: params.get('status') ? Number(params.get('status')) : null,
priority: params.get('priority') ?? null,
company_id: params.get('company') ? Number(params.get('company')) : null,
assigned: (assigned === 'me' || assigned === 'unassigned' || assigned === 'all')
? assigned
: Number(assigned),
include_closed: params.get('closed') === 'true',
page: params.get('page') ? Number(params.get('page')) : 1,
}
}
export default function TicketsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const { page, ...filters } = filtersFromParams(searchParams)
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [boards, setBoards] = useState<PSABoard[]>([])
const [statuses, setStatuses] = useState<PSATicketStatusItem[]>([])
const [priorities, setPriorities] = useState<PSAPriority[]>([])
const [members, setMembers] = useState<{ id: number; name: string }[]>([])
const [selectedTicket, setSelectedTicket] = useState<PSATicketSearchResult | null>(null)
const [showNewTicket, setShowNewTicket] = useState(false)
// Load filter option data once
useEffect(() => {
integrationsApi.listBoards().then(setBoards).catch(() => {})
ticketsApi.listPriorities().then(setPriorities).catch(() => {})
integrationsApi.listMembers()
.then(ms => setMembers(ms.map(m => ({ id: Number(m.id), name: m.name }))))
.catch(() => {})
}, [])
// Load statuses when board changes
useEffect(() => {
if (filters.board_id) {
integrationsApi.getTicketStatuses(String(filters.board_id))
.then(setStatuses).catch(() => {})
} else {
setStatuses([])
}
}, [filters.board_id])
// Fetch tickets on filter/page change
const fetchTickets = useCallback(async () => {
setLoading(true)
try {
const result = await ticketsApi.searchTickets({
query: filters.search || undefined,
board_id: filters.board_id ?? undefined,
status_id: filters.status_id ?? undefined,
include_closed: filters.include_closed,
assigned_to_me: filters.assigned === 'me',
unassigned: filters.assigned === 'unassigned',
priority: filters.priority ?? undefined,
company_id: filters.company_id ?? undefined,
page,
page_size: PAGE_SIZE,
})
setTickets(result.items)
setTotal(result.total)
} catch {
setTickets([])
setTotal(0)
} finally {
setLoading(false)
}
}, [filters.search, filters.board_id, filters.status_id, filters.include_closed,
filters.assigned, filters.priority, filters.company_id, page])
useEffect(() => { fetchTickets() }, [fetchTickets])
function updateFilters(updated: Partial<TicketFilters>) {
const next = new URLSearchParams(searchParams)
if ('search' in updated) updated.search ? next.set('search', updated.search!) : next.delete('search')
if ('board_id' in updated) updated.board_id ? next.set('board', String(updated.board_id)) : next.delete('board')
if ('status_id' in updated) updated.status_id ? next.set('status', String(updated.status_id)) : next.delete('status')
if ('priority' in updated) updated.priority ? next.set('priority', updated.priority!) : next.delete('priority')
if ('company_id' in updated) updated.company_id ? next.set('company', String(updated.company_id)) : next.delete('company')
if ('assigned' in updated) {
const a = updated.assigned
a === 'all' ? next.delete('assigned') : next.set('assigned', String(a))
}
if ('include_closed' in updated) updated.include_closed ? next.set('closed', 'true') : next.delete('closed')
next.delete('page') // reset to 1 on filter change
setSearchParams(next)
}
function updatePage(p: number) {
const next = new URLSearchParams(searchParams)
p === 1 ? next.delete('page') : next.set('page', String(p))
setSearchParams(next)
}
return (
<div className="flex flex-col h-full overflow-hidden">
<PageMeta title="Tickets — ResolutionFlow" />
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-default shrink-0">
<div className="flex items-center gap-2">
<Ticket className="w-5 h-5 text-muted-foreground" />
<h1 className="font-heading text-xl font-bold text-heading">Tickets</h1>
</div>
<button
onClick={() => setShowNewTicket(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent text-white text-sm font-medium rounded-[5px] hover:bg-accent/90 transition-colors"
>
<Plus className="w-4 h-4" /> New Ticket
</button>
</div>
{/* Filters */}
<div className="px-6 py-3 border-b border-default shrink-0">
<TicketFilterBar
filters={filters}
onChange={updateFilters}
boards={boards}
statuses={statuses}
priorities={priorities}
members={members}
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={updatePage}
loading={loading}
/>
</div>
{/* List + Detail Panel */}
<div className="flex flex-1 overflow-hidden">
{/* Ticket list */}
<div className={`flex flex-col overflow-y-auto ${selectedTicket ? 'w-1/2' : 'w-full'} transition-all`}>
{loading && tickets.length === 0 && (
<div className="flex items-center justify-center py-16 text-muted-foreground text-sm">
Loading tickets…
</div>
)}
{!loading && tickets.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground text-sm gap-2">
<Ticket className="w-8 h-8 opacity-30" />
No tickets match your filters
</div>
)}
{tickets.map(t => (
<TicketListRow
key={t.id}
ticket={t}
selected={selectedTicket?.id === t.id}
onClick={() => setSelectedTicket(t)}
/>
))}
</div>
{/* Detail panel */}
{selectedTicket && (
<div className="w-1/2 border-l border-default overflow-y-auto">
<TicketDetailPanel
ticket={selectedTicket}
onClose={() => setSelectedTicket(null)}
onStatusUpdated={(ticketId, newStatus) => {
setTickets(prev => prev.map(t =>
t.id === String(ticketId) ? { ...t, status_name: newStatus } : t
))
}}
/>
</div>
)}
</div>
{/* New Ticket Modal */}
{showNewTicket && (
<NewTicketModal
defaultTab="quick"
onClose={() => setShowNewTicket(false)}
onCreated={() => { setShowNewTicket(false); fetchTickets() }}
/>
)}
</div>
)
}
- Step 2: Commit
git add frontend/src/pages/TicketsPage.tsx
git commit -m "feat(tickets): add TicketsPage with URL-param filter state and slide-out detail"
Task 14: Add route + sidebar nav
Files:
-
Modify:
frontend/src/router.tsx -
Modify:
frontend/src/components/layout/Sidebar.tsx -
Step 1: Add TicketsPage to router.tsx
After the existing lazy imports, add:
const TicketsPage = lazyWithRetry(() => import('@/pages/TicketsPage'))
Inside the protected route children array (alongside other routes like /sessions), add:
{ path: 'tickets', element: <Suspense fallback={<PageLoader />}><TicketsPage /></Suspense> },
- Step 2: Add Tickets nav item to Sidebar.tsx
In Sidebar.tsx, find the railGroups array. Add a Tickets entry in the RESOLVE section. Find the History entry:
{
href: '/sessions', icon: History, label: 'History', shortLabel: 'History',
...
},
Add after it:
{
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
matchPaths: ['/tickets'],
},
Also add Ticket to the Lucide imports at the top of Sidebar.tsx.
- Step 3: Commit
git add frontend/src/router.tsx frontend/src/components/layout/Sidebar.tsx
git commit -m "feat(tickets): add /tickets route and sidebar nav item"
Phase 5 — Ticket Detail Panel
Task 15: Create detail subcomponents
Files:
-
Create:
frontend/src/components/tickets/detail/TicketDetailHeader.tsx -
Create:
frontend/src/components/tickets/detail/TicketNotesFeed.tsx -
Create:
frontend/src/components/tickets/detail/TicketAddNote.tsx -
Create:
frontend/src/components/tickets/detail/TicketConfigs.tsx -
Create:
frontend/src/components/tickets/detail/TicketRelated.tsx -
Step 1: Create TicketDetailHeader.tsx
// frontend/src/components/tickets/detail/TicketDetailHeader.tsx
import { ExternalLink } from 'lucide-react'
import type { PSATicketSearchResult } from '@/types/integrations'
import type { PSATicketStatusUpdate } from '@/types/tickets'
import type { PSATicketStatusItem } from '@/types/integrations'
import { ticketsApi } from '@/api/tickets'
import { toast } from '@/lib/toast'
import { useState } from 'react'
interface Props {
ticket: PSATicketSearchResult
statuses: PSATicketStatusItem[]
onStatusUpdated: (ticketId: number, newStatus: string) => void
}
export function TicketDetailHeader({ ticket, statuses, onStatusUpdated }: Props) {
const [updating, setUpdating] = useState(false)
async function handleStatusChange(statusId: number) {
if (!ticket.id) return
setUpdating(true)
try {
const result: PSATicketStatusUpdate = await ticketsApi.updateStatus(Number(ticket.id), statusId)
onStatusUpdated(result.ticket_id, result.new_status)
toast.success(`Status updated to ${result.new_status}`)
} catch {
toast.error('Failed to update status')
} finally {
setUpdating(false)
}
}
return (
<div className="p-4 border-b border-default space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-accent text-xs font-mono">#{ticket.id}</span>
{ticket.board_name && (
<span className="text-xs text-muted-foreground">{ticket.board_name}</span>
)}
</div>
<h2 className="font-heading font-semibold text-heading text-base leading-snug">
{ticket.summary}
</h2>
{ticket.company_name && (
<p className="text-sm text-muted-foreground mt-0.5">{ticket.company_name}</p>
)}
</div>
</div>
{/* Status + Priority */}
<div className="flex items-center gap-2 flex-wrap">
{statuses.length > 0 ? (
<select
disabled={updating}
value={ticket.status_id ?? ''}
onChange={e => handleStatusChange(Number(e.target.value))}
className="bg-input border border-default rounded-[5px] px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
>
{statuses.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
) : (
ticket.status_name && (
<span className="px-2 py-0.5 bg-elevated rounded text-xs text-muted-foreground">
{ticket.status_name}
</span>
)
)}
{ticket.priority_name && (
<span className="px-2 py-0.5 bg-elevated rounded text-xs text-muted-foreground">
{ticket.priority_name}
</span>
)}
</div>
</div>
)
}
- Step 2: Create TicketNotesFeed.tsx
// frontend/src/components/tickets/detail/TicketNotesFeed.tsx
import type { TicketNote } from '@/api/psaContext'
interface Props { notes: TicketNote[] }
export function TicketNotesFeed({ notes }: Props) {
if (notes.length === 0) {
return <p className="text-xs text-muted-foreground px-4 py-3">No notes yet.</p>
}
return (
<div className="divide-y divide-default">
{notes.map((note, i) => (
<div key={i} className="px-4 py-3 space-y-1">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{note.member ?? 'Unknown'}</span>
<span>{new Date(note.date_created).toLocaleDateString()}</span>
</div>
{note.internal_analysis_flag && (
<span className="text-[10px] uppercase tracking-wider text-warning">Internal</span>
)}
<p className="text-sm text-primary whitespace-pre-wrap">{note.text}</p>
</div>
))}
</div>
)
}
- Step 3: Create TicketAddNote.tsx
// frontend/src/components/tickets/detail/TicketAddNote.tsx
import { useState } from 'react'
import { sessionPsaApi } from '@/api/integrations'
import { toast } from '@/lib/toast'
interface Props {
ticketId: string
sessionId?: string
onPosted: () => void
}
export function TicketAddNote({ ticketId, sessionId, onPosted }: Props) {
const [text, setText] = useState('')
const [noteType, setNoteType] = useState<'internal_analysis' | 'description'>('internal_analysis')
const [posting, setPosting] = useState(false)
// Note posting requires a session link — if no session, show info
if (!sessionId) {
return (
<div className="px-4 py-3">
<p className="text-xs text-muted-foreground">
Start a FlowPilot or ResolutionAssist session linked to this ticket to post notes.
</p>
</div>
)
}
async function handlePost() {
if (!text.trim()) return
setPosting(true)
try {
await sessionPsaApi.postToTicket(sessionId!, {
note_type: noteType,
update_status_id: undefined,
})
setText('')
toast.success('Note posted to ticket')
onPosted()
} catch {
toast.error('Failed to post note')
} finally {
setPosting(false)
}
}
return (
<div className="px-4 py-3 space-y-2">
<select
className="bg-input border border-default rounded-[5px] px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
value={noteType}
onChange={e => setNoteType(e.target.value as 'internal_analysis' | 'description')}
>
<option value="internal_analysis">Internal Analysis</option>
<option value="description">Description</option>
</select>
<textarea
className="w-full bg-input border border-default rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none resize-none"
rows={3}
placeholder="Add a note to this ticket…"
value={text}
onChange={e => setText(e.target.value)}
/>
<button
disabled={!text.trim() || posting}
onClick={handlePost}
className="px-3 py-1.5 bg-accent text-white text-xs font-medium rounded-[5px] hover:bg-accent/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{posting ? 'Posting…' : 'Post Note'}
</button>
</div>
)
}
- Step 4: Create TicketConfigs.tsx
// frontend/src/components/tickets/detail/TicketConfigs.tsx
import type { ConfigItemInfo } from '@/api/psaContext'
interface Props { configs: ConfigItemInfo[] }
export function TicketConfigs({ configs }: Props) {
if (configs.length === 0) return null
return (
<div className="px-4 py-3 space-y-2">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
Configurations
</h4>
{configs.map((c, i) => (
<div key={i} className="bg-elevated rounded border border-default p-2 text-xs space-y-0.5">
<div className="font-medium text-primary">{c.device_identifier}</div>
<div className="text-muted-foreground">
{[c.type, c.os_type, c.ip_address].filter(Boolean).join(' · ')}
</div>
</div>
))}
</div>
)
}
- Step 5: Create TicketRelated.tsx
// frontend/src/components/tickets/detail/TicketRelated.tsx
import type { RelatedTicket } from '@/api/psaContext'
interface Props {
related: RelatedTicket[]
onSelect: (id: number) => void
}
export function TicketRelated({ related, onSelect }: Props) {
if (related.length === 0) return null
return (
<div className="px-4 py-3 space-y-2">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
Related Tickets
</h4>
{related.map(r => (
<button
key={r.id}
onClick={() => onSelect(r.id)}
className="w-full text-left bg-elevated rounded border border-default p-2 hover:border-hover transition-colors text-xs space-y-0.5"
>
<div className="flex items-center justify-between">
<span className="text-accent font-mono">#{r.id}</span>
<span className="text-muted-foreground">{r.status}</span>
</div>
<div className="text-primary truncate">{r.summary}</div>
</button>
))}
</div>
)
}
- Step 6: Commit
git add frontend/src/components/tickets/detail/
git commit -m "feat(tickets): add ticket detail subcomponents (header, notes, configs, related)"
Task 16: Create TicketResourceManager.tsx + TicketDetailPanel.tsx
Files:
-
Create:
frontend/src/components/tickets/detail/TicketResourceManager.tsx -
Create:
frontend/src/components/tickets/TicketDetailPanel.tsx -
Step 1: Create TicketResourceManager.tsx
// frontend/src/components/tickets/detail/TicketResourceManager.tsx
import { useState } from 'react'
import { UserPlus, X, User } from 'lucide-react'
import { ticketsApi } from '@/api/tickets'
import { toast } from '@/lib/toast'
import type { PSAResource } from '@/types/tickets'
import type { PsaMemberResponse } from '@/types/integrations'
import { cn } from '@/lib/utils'
interface Props {
ticketId: number
resources: PSAResource[]
allMembers: PsaMemberResponse[]
onChanged: () => void
}
export function TicketResourceManager({ ticketId, resources, allMembers, onChanged }: Props) {
const [adding, setAdding] = useState(false)
const [selectedMemberId, setSelectedMemberId] = useState<string>('')
const [busy, setBusy] = useState<number | null>(null)
async function handleAdd() {
if (!selectedMemberId) return
setBusy(Number(selectedMemberId))
try {
await ticketsApi.addResource(ticketId, Number(selectedMemberId))
toast.success('Resource added')
setAdding(false)
setSelectedMemberId('')
onChanged()
} catch {
toast.error('Failed to add resource')
} finally {
setBusy(null)
}
}
async function handleRemove(memberId: number) {
setBusy(memberId)
try {
await ticketsApi.removeResource(ticketId, memberId)
toast.success('Resource removed')
onChanged()
} catch {
toast.error('Failed to remove resource')
} finally {
setBusy(null)
}
}
const assignedIds = new Set(resources.map(r => r.member_id))
return (
<div className="px-4 py-3 space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
Resources
</h4>
<button
onClick={() => setAdding(!adding)}
className="flex items-center gap-1 text-xs text-accent hover:text-accent/80 transition-colors"
>
<UserPlus className="w-3.5 h-3.5" /> Assign
</button>
</div>
{/* Add member selector */}
{adding && (
<div className="flex gap-2">
<select
className="flex-1 bg-input border border-default rounded-[5px] px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
value={selectedMemberId}
onChange={e => setSelectedMemberId(e.target.value)}
>
<option value="">Select member…</option>
{allMembers
.filter(m => !assignedIds.has(Number(m.id)))
.map(m => (
<option key={m.id} value={m.id}>
{m.name} {/* is_rf_user badge handled below */}
</option>
))}
</select>
<button
onClick={handleAdd}
disabled={!selectedMemberId || busy !== null}
className="px-2 py-1 bg-accent text-white text-xs rounded-[5px] disabled:opacity-40"
>
Add
</button>
</div>
)}
{/* Current resources */}
{resources.length === 0 ? (
<p className="text-xs text-muted-foreground">No resources assigned.</p>
) : (
<div className="space-y-1">
{resources.map(r => (
<div key={r.member_id} className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-primary">
<User className="w-3 h-3 text-muted-foreground" />
{r.member_name}
{r.is_rf_user && (
<span className="px-1 py-0.5 bg-accent/10 text-accent rounded text-[10px] font-medium">
RF
</span>
)}
</div>
<button
onClick={() => handleRemove(r.member_id)}
disabled={busy === r.member_id}
className={cn(
'text-muted-foreground hover:text-danger transition-colors',
busy === r.member_id && 'opacity-40'
)}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)
}
- Step 2: Create TicketDetailPanel.tsx
// frontend/src/components/tickets/TicketDetailPanel.tsx
import { useEffect, useState } from 'react'
import { X, Loader2 } from 'lucide-react'
import { psaContextApi } from '@/api/psaContext'
import { integrationsApi } from '@/api/integrations'
import { ticketsApi } from '@/api/tickets'
import { TicketDetailHeader } from './detail/TicketDetailHeader'
import { TicketResourceManager } from './detail/TicketResourceManager'
import { TicketNotesFeed } from './detail/TicketNotesFeed'
import { TicketAddNote } from './detail/TicketAddNote'
import { TicketConfigs } from './detail/TicketConfigs'
import { TicketRelated } from './detail/TicketRelated'
import type { PSATicketSearchResult, PSATicketStatusItem, PsaMemberResponse } from '@/types/integrations'
import type { PSAResource } from '@/types/tickets'
import type { TicketContext } from '@/api/psaContext'
interface Props {
ticket: PSATicketSearchResult
onClose: () => void
onStatusUpdated?: (ticketId: number, newStatus: string) => void
}
export function TicketDetailPanel({ ticket, onClose, onStatusUpdated }: Props) {
const [context, setContext] = useState<TicketContext | null>(null)
const [resources, setResources] = useState<PSAResource[]>([])
const [allMembers, setAllMembers] = useState<PsaMemberResponse[]>([])
const [statuses, setStatuses] = useState<PSATicketStatusItem[]>([])
const [contextLoading, setContextLoading] = useState(true)
const [resourcesLoading, setResourcesLoading] = useState(true)
const ticketIdNum = Number(ticket.id)
function loadResources() {
ticketsApi.listResources(ticketIdNum)
.then(setResources)
.catch(() => {})
.finally(() => setResourcesLoading(false))
}
useEffect(() => {
setContextLoading(true)
setResourcesLoading(true)
setContext(null)
setResources([])
// Parallel: context + resources + members + statuses
Promise.all([
psaContextApi.getTicketContext(ticketIdNum)
.then(setContext)
.finally(() => setContextLoading(false)),
ticketsApi.listResources(ticketIdNum)
.then(setResources)
.finally(() => setResourcesLoading(false)),
integrationsApi.listMembers().then(setAllMembers).catch(() => {}),
ticket.board_id
? integrationsApi.getTicketStatuses(String(ticket.board_id)).then(setStatuses).catch(() => {})
: Promise.resolve(),
])
}, [ticket.id])
function Skeleton() {
return (
<div className="px-4 py-3 space-y-2 animate-pulse">
<div className="h-3 w-3/4 bg-elevated rounded" />
<div className="h-3 w-1/2 bg-elevated rounded" />
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Panel header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-default shrink-0">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">
Ticket Detail
</span>
<button onClick={onClose} className="text-muted-foreground hover:text-primary transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto divide-y divide-default">
{/* Header — uses list row data immediately, no wait */}
<TicketDetailHeader
ticket={ticket}
statuses={statuses}
onStatusUpdated={(id, status) => {
onStatusUpdated?.(id, status)
}}
/>
{/* Contact info */}
{contextLoading ? (
<Skeleton />
) : context?.contact ? (
<div className="px-4 py-3 space-y-0.5">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold mb-1">
Contact
</h4>
<p className="text-sm text-primary">{context.contact.name}</p>
{context.contact.email && (
<p className="text-xs text-muted-foreground">{context.contact.email}</p>
)}
{context.contact.phone && (
<p className="text-xs text-muted-foreground">{context.contact.phone}</p>
)}
</div>
) : null}
{/* Resources */}
{resourcesLoading ? (
<Skeleton />
) : (
<TicketResourceManager
ticketId={ticketIdNum}
resources={resources}
allMembers={allMembers}
onChanged={loadResources}
/>
)}
{/* Notes */}
{contextLoading ? (
<Skeleton />
) : (
<div>
<div className="px-4 pt-3 pb-1">
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
Notes
</h4>
</div>
<TicketNotesFeed notes={context?.notes ?? []} />
<TicketAddNote ticketId={ticket.id} onPosted={() => {
psaContextApi.getTicketContext(ticketIdNum).then(setContext).catch(() => {})
}} />
</div>
)}
{/* Configs */}
{!contextLoading && context?.configurations && context.configurations.length > 0 && (
<TicketConfigs configs={context.configurations} />
)}
{/* Related */}
{!contextLoading && context?.related_tickets && context.related_tickets.length > 0 && (
<TicketRelated
related={context.related_tickets}
onSelect={(id) => {
// Navigate to the related ticket by updating parent selection
// Parent handles this by looking it up in the list
window.dispatchEvent(new CustomEvent('select-ticket', { detail: id }))
}}
/>
)}
</div>
</div>
)
}
- Step 3: Commit
git add frontend/src/components/tickets/detail/TicketResourceManager.tsx \
frontend/src/components/tickets/TicketDetailPanel.tsx
git commit -m "feat(tickets): add TicketResourceManager and TicketDetailPanel with optimistic hydration"
Phase 6 — New Ticket Modal
Task 17: Create AiTicketParseForm.tsx + NewTicketModal.tsx
Files:
-
Create:
frontend/src/components/tickets/AiTicketParseForm.tsx -
Create:
frontend/src/components/tickets/NewTicketModal.tsx -
Step 1: Create AiTicketParseForm.tsx
// frontend/src/components/tickets/AiTicketParseForm.tsx
import { useState } from 'react'
import { Sparkles, Loader2 } from 'lucide-react'
import { ticketsApi } from '@/api/tickets'
import type { AiParseResponse, TicketCreationPayload } from '@/types/tickets'
interface Props {
initialHint?: string
onParsed: (values: Partial<TicketCreationPayload>, parseResponse: AiParseResponse) => void
}
export function AiTicketParseForm({ initialHint = '', onParsed }: Props) {
const [prompt, setPrompt] = useState(initialHint)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleParse() {
if (!prompt.trim()) return
setLoading(true)
setError(null)
try {
const result = await ticketsApi.aiParse(prompt)
const values: Partial<TicketCreationPayload> = {
summary: result.summary ?? undefined,
company_id: result.company_id,
board_id: result.board_id,
status_id: result.status_id,
priority_id: result.priority_id,
assigned_member_id: result.assigned_member_id,
description: result.description ?? undefined,
}
onParsed(values, result)
} catch {
setError('AI parsing failed. Please try again or use the full form.')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Describe the ticket in plain language — who, what, which client, and priority.
</p>
<textarea
className="w-full bg-input border border-default rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none resize-none"
rows={4}
placeholder="e.g. Create a high priority ticket for Acme Corp — Outlook not syncing for jsmith, assign to me"
value={prompt}
onChange={e => setPrompt(e.target.value)}
/>
{error && <p className="text-xs text-danger">{error}</p>}
<button
onClick={handleParse}
disabled={!prompt.trim() || loading}
className="flex items-center gap-1.5 px-4 py-2 bg-accent text-white text-sm font-medium rounded-[5px] hover:bg-accent/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{loading ? 'Parsing…' : 'Parse with AI'}
</button>
</div>
)
}
- Step 2: Create NewTicketModal.tsx
// frontend/src/components/tickets/NewTicketModal.tsx
import { useState, useEffect } from 'react'
import { X, AlertCircle } from 'lucide-react'
import { ticketsApi } from '@/api/tickets'
import { integrationsApi } from '@/api/integrations'
import { AiTicketParseForm } from './AiTicketParseForm'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { TicketCreationPayload, AiParseResponse, PSAPriority } from '@/types/tickets'
import type { PSABoard, PSATicketStatusItem } from '@/types/integrations'
interface Props {
defaultTab?: 'quick' | 'manual'
initialValues?: Partial<TicketCreationPayload>
summaryHint?: string
onClose: () => void
onCreated: (ticketId: number, summary: string) => void
}
const EMPTY_DRAFT: TicketCreationPayload = {
summary: '',
company_id: null,
board_id: null,
status_id: null,
priority_id: null,
description: '',
assigned_member_id: null,
}
export function NewTicketModal({ defaultTab = 'quick', initialValues, summaryHint, onClose, onCreated }: Props) {
const [tab, setTab] = useState<'quick' | 'manual'>(defaultTab)
const [draft, setDraft] = useState<TicketCreationPayload>({ ...EMPTY_DRAFT, ...initialValues })
const [missingFields, setMissingFields] = useState<string[]>([])
const [warnings, setWarnings] = useState<string[]>([])
const [boards, setBoards] = useState<PSABoard[]>([])
const [statuses, setStatuses] = useState<PSATicketStatusItem[]>([])
const [priorities, setPriorities] = useState<PSAPriority[]>([])
const [submitting, setSubmitting] = useState(false)
const [parsed, setParsed] = useState(false)
useEffect(() => {
integrationsApi.listBoards().then(setBoards).catch(() => {})
ticketsApi.listPriorities().then(setPriorities).catch(() => {})
}, [])
useEffect(() => {
if (draft.board_id) {
integrationsApi.getTicketStatuses(String(draft.board_id))
.then(setStatuses).catch(() => {})
} else {
setStatuses([])
}
}, [draft.board_id])
function handleParsed(values: Partial<TicketCreationPayload>, result: AiParseResponse) {
setDraft(prev => ({ ...prev, ...values }))
setMissingFields(result.missing_fields)
setWarnings(result.warnings)
setParsed(true)
}
function updateDraft(field: keyof TicketCreationPayload, value: unknown) {
setDraft(prev => ({ ...prev, [field]: value }))
setMissingFields(prev => prev.filter(f => f !== field))
}
async function handleSubmit() {
if (!draft.summary.trim() || !draft.company_id || !draft.board_id || !draft.status_id || !draft.priority_id) {
toast.warning('Please fill in all required fields')
return
}
setSubmitting(true)
try {
const result = await ticketsApi.createTicket(draft)
toast.success(`Ticket #${result.id} created in ConnectWise`)
onCreated(result.id, result.summary)
} catch {
toast.error('Failed to create ticket')
} finally {
setSubmitting(false)
}
}
const requiredMissing = (f: string) => missingFields.includes(f)
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 bg-card border border-default rounded-lg w-full max-w-lg max-h-[90vh] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-default shrink-0">
<h2 className="font-heading font-semibold text-heading">New Ticket</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-primary transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-default shrink-0">
{(['quick', 'manual'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'flex-1 py-2.5 text-sm font-medium transition-colors',
tab === t
? 'text-accent border-b-2 border-accent'
: 'text-muted-foreground hover:text-primary'
)}
>
{t === 'quick' ? 'Quick Create (AI)' : 'Full Form'}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{/* Warnings */}
{warnings.length > 0 && (
<div className="flex gap-2 bg-warning/10 border border-warning/30 rounded p-3">
<AlertCircle className="w-4 h-4 text-warning shrink-0 mt-0.5" />
<ul className="text-xs text-warning space-y-0.5">
{warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
{/* Quick Create tab */}
{tab === 'quick' && !parsed && (
<AiTicketParseForm initialHint={summaryHint} onParsed={handleParsed} />
)}
{/* Form — shown after parse OR in manual tab */}
{(tab === 'manual' || parsed) && (
<div className="space-y-3">
{/* Summary */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Summary *
</label>
<input
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:outline-none focus:border-accent',
requiredMissing('summary') ? 'border-warning' : 'border-default'
)}
placeholder="Short ticket title"
value={draft.summary}
onChange={e => updateDraft('summary', e.target.value)}
/>
</div>
{/* Board */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Board *
</label>
<select
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary focus:outline-none focus:border-accent',
requiredMissing('board_id') ? 'border-warning' : 'border-default'
)}
value={draft.board_id ?? ''}
onChange={e => updateDraft('board_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">Select board…</option>
{boards.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
</div>
{/* Status */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Status *
</label>
<select
disabled={statuses.length === 0}
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary focus:outline-none focus:border-accent disabled:opacity-50',
requiredMissing('status_id') ? 'border-warning' : 'border-default'
)}
value={draft.status_id ?? ''}
onChange={e => updateDraft('status_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">{draft.board_id ? 'Select status…' : 'Select board first'}</option>
{statuses.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
{/* Priority */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Priority *
</label>
<select
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary focus:outline-none focus:border-accent',
requiredMissing('priority_id') ? 'border-warning' : 'border-default'
)}
value={draft.priority_id ?? ''}
onChange={e => updateDraft('priority_id', e.target.value ? Number(e.target.value) : null)}
>
<option value="">Select priority…</option>
{priorities.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{/* Company ID (numeric — engineer enters manually for now) */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Company ID *
</label>
<input
type="number"
className={cn(
'w-full bg-input border rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:outline-none focus:border-accent',
requiredMissing('company_id') ? 'border-warning' : 'border-default'
)}
placeholder="ConnectWise company ID"
value={draft.company_id ?? ''}
onChange={e => updateDraft('company_id', e.target.value ? Number(e.target.value) : null)}
/>
</div>
{/* Description */}
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider font-semibold block mb-1">
Description
</label>
<textarea
className="w-full bg-input border border-default rounded-[5px] px-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:outline-none focus:border-accent resize-none"
rows={3}
placeholder="Detailed description…"
value={draft.description}
onChange={e => updateDraft('description', e.target.value)}
/>
</div>
</div>
)}
</div>
{/* Footer */}
{(tab === 'manual' || parsed) && (
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-default shrink-0">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-muted-foreground hover:text-primary transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className="px-4 py-2 bg-accent text-white text-sm font-medium rounded-[5px] hover:bg-accent/90 disabled:opacity-40 transition-colors"
>
{submitting ? 'Creating…' : 'Create Ticket'}
</button>
</div>
)}
</div>
</div>
)
}
- Step 3: Commit
git add frontend/src/components/tickets/AiTicketParseForm.tsx \
frontend/src/components/tickets/NewTicketModal.tsx
git commit -m "feat(tickets): add AiTicketParseForm and NewTicketModal with two-tab creation flow"
Phase 7 — ResolutionAssist Integration
Task 18: Update AssistantChatPage.tsx
Files:
-
Modify:
frontend/src/pages/AssistantChatPage.tsx -
Step 1: Add linkedTicket state
Find const [activePsaTicketId, setActivePsaTicketId] = useState<string | null>(null) (line 76) and add below it:
const [linkedTicket, setLinkedTicket] = useState<PSATicketInfo | null>(null)
Add PSATicketInfo to the existing integrations type imports.
- Step 2: Populate linkedTicket when activePsaTicketId is set
Find where setActivePsaTicketId(detail.psa_ticket_id) is called (line 241) and add:
setActivePsaTicketId(detail.psa_ticket_id)
// Fetch full ticket info (includes company_id and board_id) when a ticket is linked
if (detail.psa_ticket_id) {
integrationsApi.getTicket(detail.psa_ticket_id)
.then(setLinkedTicket)
.catch(() => {})
} else {
setLinkedTicket(null)
}
- Step 3: Add showNewTicket state and New Ticket button
Add state:
const [showNewTicket, setShowNewTicket] = useState(false)
const [spinOffHint, setSpinOffHint] = useState<string | undefined>(undefined)
Find the ResolutionAssist session header area (near the activePsaTicketId usage and updateLabel at line 687) and add a "New Ticket" button:
{/* New Ticket button — visible when PSA connected */}
{activePsaTicketId && (
<button
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground border border-default rounded-[5px] hover:border-hover hover:text-primary transition-colors"
>
<Plus className="w-3 h-3" /> New Ticket
</button>
)}
Add Plus to the Lucide imports.
- Step 4: Handle create_spin_off_ticket action in TaskLane action renderer
Find where actions are rendered in AssistantChatPage.tsx — the TaskLane section. Actions currently render generically. Add a handler for create_spin_off_ticket:
In the action button click handler (where action.command is used), add:
if (action.command === 'create_spin_off_ticket') {
setSpinOffHint(action.description || action.label)
setShowNewTicket(true)
return
}
- Step 5: Render NewTicketModal at bottom of AssistantChatPage
At the bottom of the JSX return, before the closing </div>, add:
{showNewTicket && (
<NewTicketModal
defaultTab={spinOffHint ? 'quick' : 'manual'}
summaryHint={spinOffHint}
initialValues={linkedTicket ? {
company_id: linkedTicket.company_id ?? undefined,
board_id: linkedTicket.board_id ?? undefined,
} : undefined}
onClose={() => setShowNewTicket(false)}
onCreated={(ticketId, summary) => {
setShowNewTicket(false)
toast.success(`Ticket #${ticketId} created: ${summary}`)
// Remove create_spin_off_ticket action from active actions
setActiveActions(prev => prev.filter(a => a.command !== 'create_spin_off_ticket'))
}}
/>
)}
Import NewTicketModal from @/components/tickets/NewTicketModal.
- Step 6: Verify tsc compiles cleanly
cd frontend && npx tsc -b 2>&1 | head -30
Expected: no errors.
- Step 7: Commit
git add frontend/src/pages/AssistantChatPage.tsx
git commit -m "feat(tickets): add spin-off ticket creation in ResolutionAssist — state, action handler, modal"
Phase 8 — Dashboard TicketQueue Update
Task 19: Update TicketQueue.tsx
Files:
-
Modify:
frontend/src/components/dashboard/TicketQueue.tsx -
Step 1: Add member-mapping detection
At the top of the TicketQueue component body, add:
const [hasMemberMapping, setHasMemberMapping] = useState<boolean | null>(null) // null = loading
useEffect(() => {
integrationsApi.getMemberMappings()
.then(mappings => {
// Check if current user has a mapping
setHasMemberMapping(mappings.length > 0)
})
.catch(() => setHasMemberMapping(false))
}, [])
Import getMemberMappings from integrationsApi (already available).
- Step 2: Replace existing "mine" tab fetch with 5-item capped queue
The current TicketQueue fetches via searchTicketsQueue. Update it to:
- Only fetch when
hasMemberMapping === true - Use
page_size: 5 - Read
.itemsfrom the response (after the return type update from Task 10) - Show the "no mapping" prompt when
hasMemberMapping === false
Find the fetch effect for the "mine" tab and update:
useEffect(() => {
if (!connection || hasMemberMapping === null) return
if (hasMemberMapping === false) return // show prompt instead
// ... existing fetch logic, but ensure page_size: 5 and read .items
integrationsApi.searchTicketsQueue({
assigned_to_me: true,
include_closed: false,
page_size: 5,
board_ids: selectedBoardIds.join(','),
}).then(result => {
setTickets(result.items) // was: setTickets(result)
}).catch(() => setTickets([]))
}, [connection, hasMemberMapping, selectedBoardIds])
- Step 3: Add mapping prompt state render
Find where the component returns JSX for the "mine" tab and add a state for hasMemberMapping === false:
{activeTab === 'mine' && hasMemberMapping === false && (
<div className="px-5 py-6 text-center">
<p className="text-sm text-muted-foreground">
<Link to="/account/integrations" className="text-accent hover:underline">
Map your PSA member
</Link>{' '}
to see your ticket queue.
</p>
</div>
)}
- Step 4: Add "View All Tickets" link
At the bottom of the ticket list (before pagination or after the last ticket row), add:
{tickets.length > 0 && (
<div className="px-5 py-3 border-t border-default">
<Link
to="/tickets?assigned=me"
className="text-xs text-accent hover:text-accent/80 transition-colors"
>
View all tickets →
</Link>
</div>
)}
Import Link from react-router-dom if not already imported.
- Step 5: Verify tsc
cd frontend && npx tsc -b 2>&1 | head -30
Expected: no errors.
- Step 6: Commit
git add frontend/src/components/dashboard/TicketQueue.tsx
git commit -m "feat(tickets): update TicketQueue with mapping detection, 5-item cap, View All link"
Phase 9 — Final validation
Task 20: Build check + smoke test
- Step 1: Run backend tests
cd backend && pytest tests/test_psa_tickets.py tests/test_psa_connections.py -v --override-ini="addopts="
Expected: all pass.
- Step 2: Run frontend build
cd frontend && npx tsc -b && echo "TypeScript OK"
Expected: TypeScript OK with no errors.
- Step 3: Manual smoke test checklist
With backend + frontend running (uvicorn app.main:app --reload + npm run dev):
- Navigate to
/tickets— page loads, filter bar visible, ticket list empty (no PSA connection is fine) - Click "New Ticket" — modal opens with two tabs
- In Quick Create tab, type a description and click "Parse with AI" — form fills in
- Switch to Full Form tab — fields all present
- Go to dashboard — TicketQueue renders without errors
- Open ResolutionAssist — "New Ticket" button visible in header when ticket linked
- Step 4: Final commit
git add -A
git commit -m "feat(psa): PSA ticket management complete — Tickets page, detail panel, creation modal, RA integration"