Files
resolutionflow/docs/superpowers/plans/2026-04-16-psa-ticket-management.md
Michael Chihlas bea34229d6
Some checks failed
Mirror to GitHub / mirror (push) Successful in 4s
CI / backend (pull_request) Failing after 18m54s
CI / frontend (pull_request) Failing after 47s
CI / e2e (pull_request) Has been skipped
chore: bump version and changelog (v0.1.0.0)
Add CW security roles reference docs and PSA ticket management plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:44:03 +00:00

100 KiB
Raw Blame History

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:

  1. Only fetch when hasMemberMapping === true
  2. Use page_size: 5
  3. Read .items from the response (after the return type update from Task 10)
  4. 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):

  1. Navigate to /tickets — page loads, filter bar visible, ticket list empty (no PSA connection is fine)
  2. Click "New Ticket" — modal opens with two tabs
  3. In Quick Create tab, type a description and click "Parse with AI" — form fills in
  4. Switch to Full Form tab — fields all present
  5. Go to dashboard — TicketQueue renders without errors
  6. 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"