Add CW security roles reference docs and PSA ticket management plan. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3076 lines
100 KiB
Markdown
3076 lines
100 KiB
Markdown
# 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"
|
||
```
|