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

3076 lines
100 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```python
# 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**
```python
# 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**
```bash
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**
```python
# 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**
```python
# 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:
```python
@abstractmethod
async def list_priorities(self) -> list[dict]:
...
```
- [ ] **Step 3: Update base.py imports**
```python
from .types import (
ConnectionTestResult,
PSATicket,
PaginatedTicketResult,
PSANote,
PSAStatus,
PSACompany,
PSAMember,
PSAConfiguration,
PSATimeEntry,
PSABoard,
PSAResource,
PSACreatedTicket,
TicketCreatePayload,
)
```
- [ ] **Step 4: Commit**
```bash
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:
```python
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**
```python
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**
```python
@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**
```python
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**
```python
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**
```python
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**
```python
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**
```python
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**
```bash
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:
```python
# 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**
```python
# 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**
```bash
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**
```python
# 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**
```bash
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**
```python
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:
```python
@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**
```python
@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**
```python
@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**
```python
@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**
```python
@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**
```bash
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**
```python
@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**
```bash
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 `"""`:
```python
# 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**
```python
# 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**
```bash
cd backend && pytest tests/test_psa_tickets.py -v --override-ini="addopts="
```
Expected: all pass.
- [ ] **Step 4: Commit**
```bash
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**
```typescript
// 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**
```typescript
// 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)**
```bash
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:
```typescript
export * from './tickets'
```
- [ ] **Step 4: Commit**
```bash
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**
```typescript
// 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**
```typescript
// 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`:
```bash
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`:
```bash
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**
```bash
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**
```tsx
// 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**
```bash
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**
```tsx
// 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**
```bash
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**
```tsx
// 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**
```bash
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:
```typescript
const TicketsPage = lazyWithRetry(() => import('@/pages/TicketsPage'))
```
Inside the protected route children array (alongside other routes like `/sessions`), add:
```typescript
{ 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:
```typescript
{
href: '/sessions', icon: History, label: 'History', shortLabel: 'History',
...
},
```
Add after it:
```typescript
{
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**
```bash
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**
```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**
```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**
```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**
```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**
```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**
```bash
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**
```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**
```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**
```bash
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**
```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**
```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**
```bash
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:
```typescript
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:
```typescript
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:
```typescript
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:
```tsx
{/* 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:
```typescript
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:
```tsx
{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**
```bash
cd frontend && npx tsc -b 2>&1 | head -30
```
Expected: no errors.
- [ ] **Step 7: Commit**
```bash
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:
```typescript
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:
```typescript
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`:
```tsx
{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:
```tsx
{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**
```bash
cd frontend && npx tsc -b 2>&1 | head -30
```
Expected: no errors.
- [ ] **Step 6: Commit**
```bash
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**
```bash
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**
```bash
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**
```bash
git add -A
git commit -m "feat(psa): PSA ticket management complete — Tickets page, detail panel, creation modal, RA integration"
```