Merge main into feat/flowpilot-migration
Brings in PR #141 (PSA ticket management) so FlowPilot can ship on top of a unified main. Two manual conflict resolutions: 1. CLAUDE.md — kept the FlowPilot ai-handoff rewrite (`.ai/`-driven protocol). The pre-rewrite reference content (CW integration notes, lessons archive, env vars table) lives in `docs/connectwise/`, `docs/LESSONS-ARCHIVE.md`, and DEV-ENV.md by design. 2. frontend/src/pages/AssistantChatPage.tsx — both conflict regions were purely additive. Concatenated FlowPilot's Phase 2-9 state hooks (facts, activeFix, preview*, scriptPanelOpen, templatizeQueue) with PSA's spin-off ticket state (linkedTicket, showNewTicket, spinOffHint). Both modal mounts (TemplatizePrompt, ShortcutsHelpOverlay, NewTicketModal) kept. All setters wired by either branch are intact. Verification: - `tsc -b` clean across the merged tree. - Browser smoke-test (Session B fixture): Phase 9 ProposalBanner ("Run AI-drafted PowerShell to recover SSL VPN") renders alongside PSA's new Tickets sidebar icon. Console clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,30 @@
|
||||
|
||||
All notable changes to ResolutionFlow are documented here.
|
||||
|
||||
## [0.1.0.0] - 2026-04-16
|
||||
|
||||
### Added
|
||||
- **PSA Ticket Management** — dedicated `/tickets` page with URL-param filter state (board, status, priority, company, assignment, closed), paginated ticket list, and slide-in detail panel
|
||||
- **TicketDetailPanel** — full ticket view with notes feed, configurations, related tickets, and resource manager; optimistic status updates via dropdown
|
||||
- **NewTicketModal** — two-tab ticket creation: "Quick Create (AI)" parses natural language into a pre-filled form via Claude, "Full Form" for manual entry; validates required fields before submitting to CW
|
||||
- **AiTicketParseForm** — natural language → structured ticket data using Claude; resolves board and assignee automatically, flags fields needing manual selection
|
||||
- **TicketResourceManager** — add/remove CW members as ticket resources with member search autocomplete
|
||||
- **Spin-off ticket creation from ResolutionAssist** — AI can detect when a new ticket should be created mid-session and surface the NewTicketModal pre-filled with session context
|
||||
- **TicketQueue improvements** — dashboard widget now detects member mapping, caps at 5 items, shows "View All" link to `/tickets`
|
||||
- **Board statuses endpoint** — `GET /integrations/boards/{board_id}/statuses` for direct status lookup without a ticket context
|
||||
- **Paginated ticket search** — `search_tickets` returns `{items, total, page, page_size}`; parallel CW count fetch for accurate totals
|
||||
- **Ticket service layer** — `ticket_service.py` wraps all PSA mutations (create, update status, list/add/remove resources)
|
||||
- **Priority lookup endpoint** — `GET /integrations/tickets/priorities` for form dropdowns
|
||||
- **PSA error surfacing** — `/tickets` page shows inline error banner with specific guidance when CW returns a permissions error (replaces silent empty state)
|
||||
|
||||
### Fixed
|
||||
- CW query injection: sanitize search `query` string to strip single quotes before interpolation into CW conditions
|
||||
- `company_id` filter now correctly applied to CW ticket search conditions (was silently ignored)
|
||||
- `linkedTicket` fetch in ResolutionAssist guarded with `currentChatRef` to prevent race condition on session switch
|
||||
- Members endpoint auth gate no longer rejects engineers without a PSA mapping
|
||||
- Board fallback: ticket list derives available boards from ticket data when the boards API returns empty (permissions)
|
||||
- Assignment search and "Load More" removed from resource manager in favor of direct member list
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""PSA integration endpoints — connection CRUD and test."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
@@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
|
||||
from app.core.database import get_db
|
||||
from app.models.psa_connection import PsaConnection
|
||||
@@ -30,6 +33,17 @@ from app.schemas.psa_connection import (
|
||||
PSABoardResponse,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.schemas.psa_tickets import (
|
||||
PSAResourceSchema,
|
||||
PSATicketCreatedSchema,
|
||||
PSATicketStatusUpdateSchema,
|
||||
TicketCreatePayloadSchema,
|
||||
PSAPrioritySchema,
|
||||
TicketListResponseSchema,
|
||||
AiParseRequestSchema,
|
||||
AiParseResponseSchema,
|
||||
)
|
||||
import app.services.ticket_service as ticket_svc
|
||||
from app.services.psa.encryption import (
|
||||
decrypt_credentials,
|
||||
encrypt_credentials,
|
||||
@@ -362,33 +376,36 @@ async def list_boards(
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
boards = await provider.list_boards()
|
||||
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
|
||||
except PSAError:
|
||||
except PSAError as e:
|
||||
# Boards are optional UI chrome — degrade gracefully rather than surfacing a toast
|
||||
logger.warning("list_boards failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
|
||||
@router.get("/tickets/search", response_model=TicketListResponseSchema)
|
||||
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,
|
||||
status_name: str | 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 = 10,
|
||||
page_size: int = 25,
|
||||
):
|
||||
"""Search ConnectWise tickets."""
|
||||
"""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
|
||||
|
||||
# Resolve assigned_to_me → member_identifier (CW login name for resources contains filter)
|
||||
member_identifier: str | None = None
|
||||
if assigned_to_me:
|
||||
conn_result = await db.execute(
|
||||
@@ -407,23 +424,18 @@ async def search_tickets(
|
||||
)
|
||||
mapping = mapping_result.scalar_one_or_none()
|
||||
if not mapping:
|
||||
# No mapping for this user — return empty list
|
||||
return []
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account as _get_provider
|
||||
from app.services.psa.exceptions import PSAError as _PSAError
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
try:
|
||||
_provider = await _get_provider(current_user.account_id, db)
|
||||
_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 []
|
||||
except _PSAError:
|
||||
return []
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
except PSAError:
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
|
||||
# Parse comma-separated board_ids
|
||||
parsed_board_ids: list[int] = []
|
||||
if board_ids:
|
||||
try:
|
||||
@@ -433,33 +445,250 @@ async def search_tickets(
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
tickets = await provider.search_tickets(
|
||||
result = await provider.search_tickets(
|
||||
query,
|
||||
board_id=board_id,
|
||||
status_id=status_id,
|
||||
status_name=status_name,
|
||||
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,
|
||||
)
|
||||
return [
|
||||
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 tickets
|
||||
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))
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
@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
|
||||
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/company always need manual selection
|
||||
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
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
@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:
|
||||
# Resources are optional display data — degrade gracefully rather than surfacing a toast
|
||||
logger.warning("list_resources(%s) failed: %s", ticket_id, e)
|
||||
return []
|
||||
|
||||
|
||||
@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))
|
||||
|
||||
|
||||
@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 as e:
|
||||
logger.warning("list_priorities failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/tickets/{ticket_id}/context")
|
||||
async def get_ticket_context(
|
||||
ticket_id: int,
|
||||
@@ -561,7 +790,30 @@ async def get_ticket_statuses(
|
||||
except PSANotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
logger.warning("get_ticket_statuses(%s) failed: %s", ticket_id, e)
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/statuses", response_model=list[PSATicketStatusItem])
|
||||
async def get_board_statuses(
|
||||
board_id: int,
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get available statuses for a service board directly (no ticket lookup required)."""
|
||||
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)
|
||||
statuses = await provider.get_ticket_statuses(board_id)
|
||||
return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses]
|
||||
except PSAError as e:
|
||||
logger.warning("get_board_statuses(%s) failed: %s", board_id, e)
|
||||
return []
|
||||
|
||||
|
||||
# ── member mapping endpoints ─────────────────────────────────────────
|
||||
@@ -569,7 +821,7 @@ async def get_ticket_statuses(
|
||||
|
||||
@router.get("/members", response_model=list[PsaMemberResponse])
|
||||
async def list_members(
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""List CW members (from CW API)."""
|
||||
@@ -587,7 +839,9 @@ async def list_members(
|
||||
for m in members
|
||||
]
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
# Members are optional display data — degrade gracefully
|
||||
logger.warning("list_members failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
||||
|
||||
@@ -53,9 +53,13 @@ class PSATicketSearchResult(BaseModel):
|
||||
id: str
|
||||
summary: str
|
||||
company_name: str | None = None
|
||||
company_id: str | None = None
|
||||
board_name: str | None = None
|
||||
board_id: int | None = None
|
||||
status_name: str | None = None
|
||||
status_id: int | None = None
|
||||
priority_name: str | None = None
|
||||
priority_id: int | None = None
|
||||
closed: bool = False
|
||||
|
||||
|
||||
|
||||
65
backend/app/schemas/psa_tickets.py
Normal file
65
backend/app/schemas/psa_tickets.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""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
|
||||
new_status_id: int
|
||||
|
||||
|
||||
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 = []
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
page_size: int = 25
|
||||
|
||||
|
||||
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
|
||||
@@ -295,6 +295,23 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
|
||||
- If a question is clearly outside your domain, say so briefly and redirect.
|
||||
- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so.
|
||||
|
||||
## 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]
|
||||
|
||||
## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE
|
||||
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
|
||||
No exceptions. Not even when forking. A response without at least one of these markers \
|
||||
|
||||
@@ -12,6 +12,10 @@ from app.services.psa.types import (
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
PaginatedTicketResult,
|
||||
PSAResource,
|
||||
PSACreatedTicket,
|
||||
TicketCreatePayload,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,7 +32,7 @@ class AutotaskProvider(PSAProvider):
|
||||
async def get_ticket(self, ticket_id: str) -> PSATicket:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
||||
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
async def post_note(
|
||||
@@ -74,3 +78,18 @@ class AutotaskProvider(PSAProvider):
|
||||
work_type: str | None = None,
|
||||
) -> PSATimeEntry:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
async def list_priorities(self) -> list[dict]:
|
||||
raise NotImplementedError("Autotask integration coming soon")
|
||||
|
||||
@@ -13,6 +13,10 @@ from .types import (
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
PaginatedTicketResult,
|
||||
PSAResource,
|
||||
PSACreatedTicket,
|
||||
TicketCreatePayload,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,7 +32,7 @@ class PSAProvider(ABC):
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
||||
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
@@ -83,3 +87,23 @@ class PSAProvider(ABC):
|
||||
work_type: str | None = None,
|
||||
) -> PSATimeEntry:
|
||||
...
|
||||
|
||||
@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:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def list_priorities(self) -> list[dict]:
|
||||
...
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from app.services.psa.base import PSAProvider
|
||||
from app.services.psa.cache import psa_cache
|
||||
from app.services.psa.exceptions import PSAError
|
||||
from app.services.psa.types import (
|
||||
ConnectionTestResult,
|
||||
PSATicket,
|
||||
@@ -17,6 +18,10 @@ from app.services.psa.types import (
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
PaginatedTicketResult,
|
||||
PSAResource,
|
||||
PSACreatedTicket,
|
||||
TicketCreatePayload,
|
||||
)
|
||||
from .client import ConnectWiseClient
|
||||
|
||||
@@ -55,27 +60,31 @@ class ConnectWiseProvider(PSAProvider):
|
||||
)
|
||||
return self._map_ticket(data)
|
||||
|
||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
||||
"""Search CW tickets by summary. Supports board_id, status_id, member_id,
|
||||
unassigned, board_ids, page, and page_size filters."""
|
||||
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||
"""Search CW tickets by summary. Supports board_id, status_id, member_identifier,
|
||||
unassigned, board_ids, page, and page_size filters. Returns paginated result."""
|
||||
page_size = filters.get("page_size", 10)
|
||||
page = filters.get("page", 1)
|
||||
|
||||
params: dict = {
|
||||
"fields": "id,summary,company,board,status,priority,closedFlag",
|
||||
"orderBy": "id desc",
|
||||
"orderBy": "priority/sort asc,dateEntered desc",
|
||||
"pageSize": page_size,
|
||||
"page": page,
|
||||
}
|
||||
|
||||
# Build CW condition query
|
||||
conditions: list[str] = []
|
||||
if query:
|
||||
conditions.append(f"summary contains '{query}'")
|
||||
# Sanitize: strip single quotes to prevent CW condition injection
|
||||
safe_query = query.replace("'", "")
|
||||
conditions.append(f"summary contains '{safe_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']}")
|
||||
elif filters.get("status_name"):
|
||||
safe_status = str(filters["status_name"]).replace("'", "")
|
||||
conditions.append(f"status/name = '{safe_status}'")
|
||||
if not filters.get("include_closed", False):
|
||||
conditions.append("closedFlag = false")
|
||||
if filters.get("member_identifier") is not None:
|
||||
@@ -86,16 +95,27 @@ class ConnectWiseProvider(PSAProvider):
|
||||
if board_ids:
|
||||
board_list = ", ".join(str(bid) for bid in board_ids)
|
||||
conditions.append(f"board/id in ({board_list})")
|
||||
if filters.get("company_id"):
|
||||
conditions.append(f"company/id = {int(filters['company_id'])}")
|
||||
|
||||
if conditions:
|
||||
params["conditions"] = " and ".join(conditions)
|
||||
condition_str = " and ".join(conditions) if conditions else ""
|
||||
if condition_str:
|
||||
params["conditions"] = condition_str
|
||||
|
||||
data = await self.client.get("/service/tickets", params=params)
|
||||
count_params: dict = {}
|
||||
if condition_str:
|
||||
count_params["conditions"] = condition_str
|
||||
|
||||
return [
|
||||
self._map_ticket(t)
|
||||
for t in (data if isinstance(data, list) else [])
|
||||
]
|
||||
# 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)
|
||||
|
||||
async def get_ticket_configurations(
|
||||
self, ticket_id: str
|
||||
@@ -246,13 +266,30 @@ class ConnectWiseProvider(PSAProvider):
|
||||
async def update_ticket_status(
|
||||
self, ticket_id: str, status_id: int
|
||||
) -> PSATicket:
|
||||
"""Update a CW ticket's status using JSON Patch format."""
|
||||
"""Update a CW ticket's status using JSON Patch format.
|
||||
|
||||
Verifies CW actually applied the change — CW silently returns 200 when
|
||||
a status id is invalid for the ticket's board. We check the response
|
||||
body's status.id matches what we sent, and raise PSAError if not.
|
||||
"""
|
||||
patch_body = [
|
||||
{"op": "replace", "path": "status", "value": {"id": status_id}}
|
||||
]
|
||||
data = await self.client.patch(
|
||||
f"/service/tickets/{ticket_id}", json_body=patch_body
|
||||
)
|
||||
applied = (data.get("status") or {}) if isinstance(data, dict) else {}
|
||||
applied_id = applied.get("id")
|
||||
if applied_id != status_id:
|
||||
logger.warning(
|
||||
"CW status PATCH for ticket %s returned status id=%s instead of %s",
|
||||
ticket_id, applied_id, status_id,
|
||||
)
|
||||
raise PSAError(
|
||||
f"ConnectWise did not apply status {status_id} "
|
||||
f"(still {applied.get('name') or applied_id}). "
|
||||
"The status may not be valid for this ticket's board."
|
||||
)
|
||||
return self._map_ticket(data)
|
||||
|
||||
async def list_members(self) -> list[PSAMember]:
|
||||
@@ -591,16 +628,247 @@ class ConnectWiseProvider(PSAProvider):
|
||||
@staticmethod
|
||||
def _map_ticket(data: dict) -> PSATicket:
|
||||
"""Map a CW ticket JSON dict to a 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["id"]),
|
||||
id=str(data.get("id", "")),
|
||||
summary=data.get("summary", ""),
|
||||
company_name=data.get("company", {}).get("name"),
|
||||
company_id=str(data["company"]["id"]) if data.get("company") else None,
|
||||
board_name=data.get("board", {}).get("name"),
|
||||
board_id=data.get("board", {}).get("id"),
|
||||
status_name=data.get("status", {}).get("name"),
|
||||
status_id=data.get("status", {}).get("id"),
|
||||
priority_name=data.get("priority", {}).get("name"),
|
||||
priority_id=data.get("priority", {}).get("id"),
|
||||
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),
|
||||
)
|
||||
|
||||
# ── Resource management ───────────────────────────────────────────
|
||||
|
||||
# Schedule type id for "Service Ticket" resources — CW's canonical type for ticket co-assignees
|
||||
_SCHEDULE_TYPE_SERVICE_TICKET = 4
|
||||
|
||||
async def _get_ticket_owner(self, ticket_id: int) -> dict | None:
|
||||
"""Fetch the ticket's current owner (MemberReference) or None if unassigned."""
|
||||
data = await self.client.get(
|
||||
f"/service/tickets/{ticket_id}",
|
||||
params={"fields": "id,owner"},
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
owner_raw = data.get("owner")
|
||||
return owner_raw if isinstance(owner_raw, dict) and owner_raw.get("id") else None
|
||||
|
||||
async def _list_ticket_schedule_entries(self, ticket_id: int) -> list[dict]:
|
||||
"""List schedule entries for a ticket's co-assignees.
|
||||
|
||||
Returns raw CW schedule entry dicts with at least id and member info.
|
||||
"""
|
||||
data = await self.client.get(
|
||||
"/schedule/entries",
|
||||
params={
|
||||
"conditions": (
|
||||
f"type/id={self._SCHEDULE_TYPE_SERVICE_TICKET} AND objectId={ticket_id}"
|
||||
),
|
||||
"fields": "id,member,name",
|
||||
"pageSize": 100,
|
||||
},
|
||||
)
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||
"""List members assigned to a CW ticket.
|
||||
|
||||
Merges the `owner` MemberReference (primary assignee) with schedule entries
|
||||
of type 4 (Service Ticket resources — co-assignees). Deduped by member id.
|
||||
"""
|
||||
owner = await self._get_ticket_owner(ticket_id)
|
||||
entries = await self._list_ticket_schedule_entries(ticket_id)
|
||||
members = await self.list_members()
|
||||
by_id = {str(m.id): m for m in members}
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
results: list[PSAResource] = []
|
||||
|
||||
if owner is not None:
|
||||
owner_id = str(owner.get("id"))
|
||||
m = by_id.get(owner_id)
|
||||
if m:
|
||||
results.append(PSAResource(
|
||||
member_id=int(m.id),
|
||||
member_name=m.name,
|
||||
member_identifier=m.identifier,
|
||||
))
|
||||
else:
|
||||
results.append(PSAResource(
|
||||
member_id=int(owner.get("id") or 0),
|
||||
member_name=str(owner.get("name") or ""),
|
||||
member_identifier=str(owner.get("identifier") or ""),
|
||||
))
|
||||
seen_ids.add(owner_id)
|
||||
|
||||
for entry in entries:
|
||||
entry_member = entry.get("member") if isinstance(entry, dict) else None
|
||||
if not isinstance(entry_member, dict):
|
||||
continue
|
||||
mid = str(entry_member.get("id") or "")
|
||||
if not mid or mid in seen_ids:
|
||||
continue
|
||||
m = by_id.get(mid)
|
||||
if m:
|
||||
results.append(PSAResource(
|
||||
member_id=int(m.id),
|
||||
member_name=m.name,
|
||||
member_identifier=m.identifier,
|
||||
))
|
||||
else:
|
||||
results.append(PSAResource(
|
||||
member_id=int(entry_member.get("id") or 0),
|
||||
member_name=str(entry_member.get("name") or ""),
|
||||
member_identifier=str(entry_member.get("identifier") or ""),
|
||||
))
|
||||
seen_ids.add(mid)
|
||||
|
||||
return results
|
||||
|
||||
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||
"""Assign a member to a CW ticket.
|
||||
|
||||
- If the ticket has no owner, set the target as `owner` (CW's canonical
|
||||
primary assignee field). CW typically mirrors this into the derived
|
||||
`resources` string automatically.
|
||||
- If the ticket is already owned by someone else, add the target as a
|
||||
co-assignee via a schedule entry of type 4 (Service Ticket). The
|
||||
existing owner is not changed.
|
||||
- Idempotent when target is already owner or already has a schedule entry.
|
||||
"""
|
||||
members = await self.list_members()
|
||||
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
||||
if target is None:
|
||||
raise PSAError(f"Member {member_id} not found")
|
||||
|
||||
current_owner = await self._get_ticket_owner(ticket_id)
|
||||
|
||||
if current_owner is None:
|
||||
# Primary assign — set owner
|
||||
await self.client.patch(
|
||||
f"/service/tickets/{ticket_id}",
|
||||
json_body=[{"op": "replace", "path": "owner", "value": {"id": int(target.id)}}],
|
||||
)
|
||||
elif str(current_owner.get("id")) != str(target.id):
|
||||
# Ticket owned by someone else — add as co-assignee via schedule entry.
|
||||
# Idempotent: skip if a schedule entry already exists for this member.
|
||||
existing = await self._list_ticket_schedule_entries(ticket_id)
|
||||
already_assigned = any(
|
||||
str((e.get("member") or {}).get("id") or "") == str(target.id)
|
||||
for e in existing
|
||||
)
|
||||
if not already_assigned:
|
||||
await self.client.post(
|
||||
"/schedule/entries",
|
||||
json_body={
|
||||
"member": {"id": int(target.id)},
|
||||
"objectId": int(ticket_id),
|
||||
"type": {"id": self._SCHEDULE_TYPE_SERVICE_TICKET},
|
||||
"name": target.name or target.identifier or f"Member {target.id}",
|
||||
},
|
||||
)
|
||||
# else: already the owner — idempotent no-op
|
||||
|
||||
return PSAResource(
|
||||
member_id=int(target.id),
|
||||
member_name=target.name,
|
||||
member_identifier=target.identifier,
|
||||
)
|
||||
|
||||
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||
"""Remove a member from a CW ticket (idempotent).
|
||||
|
||||
- If the target is the current owner, clear the owner field.
|
||||
- Otherwise, delete their schedule entry (Service Ticket type).
|
||||
"""
|
||||
members = await self.list_members()
|
||||
target = next((m for m in members if str(m.id) == str(member_id)), None)
|
||||
if target is None:
|
||||
return
|
||||
|
||||
current_owner = await self._get_ticket_owner(ticket_id)
|
||||
|
||||
if current_owner is not None and str(current_owner.get("id")) == str(target.id):
|
||||
# Unassign the owner. Try RFC 6902 "remove" first; fall back to
|
||||
# "replace" with null if CW rejects it.
|
||||
try:
|
||||
await self.client.patch(
|
||||
f"/service/tickets/{ticket_id}",
|
||||
json_body=[{"op": "remove", "path": "owner"}],
|
||||
)
|
||||
except PSAError:
|
||||
await self.client.patch(
|
||||
f"/service/tickets/{ticket_id}",
|
||||
json_body=[{"op": "replace", "path": "owner", "value": None}],
|
||||
)
|
||||
return
|
||||
|
||||
# Not the owner — find and delete the schedule entry for this member.
|
||||
entries = await self._list_ticket_schedule_entries(ticket_id)
|
||||
for entry in entries:
|
||||
entry_member = entry.get("member") if isinstance(entry, dict) else None
|
||||
if isinstance(entry_member, dict) and str(entry_member.get("id") or "") == str(target.id):
|
||||
entry_id = entry.get("id")
|
||||
if entry_id:
|
||||
await self.client.delete(f"/schedule/entries/{entry_id}")
|
||||
break
|
||||
|
||||
# ── Ticket creation ───────────────────────────────────────────────
|
||||
|
||||
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
|
||||
"""Create a new CW service ticket."""
|
||||
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=body)
|
||||
|
||||
ticket_id = data.get("id") if isinstance(data, dict) else None
|
||||
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 {}) if isinstance(data, dict) else {}
|
||||
board = (data.get("board") or {}) if isinstance(data, dict) else {}
|
||||
status = (data.get("status") or {}) if isinstance(data, dict) else {}
|
||||
priority = (data.get("priority") or {}) if isinstance(data, dict) else {}
|
||||
|
||||
return PSACreatedTicket(
|
||||
id=ticket_id or 0,
|
||||
summary=data.get("summary", payload.summary) if isinstance(data, dict) else payload.summary,
|
||||
board_name=board.get("name", ""),
|
||||
status_name=status.get("name", ""),
|
||||
priority_name=priority.get("name", ""),
|
||||
company_name=company.get("name", ""),
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
# ── Priorities ────────────────────────────────────────────────────
|
||||
|
||||
async def list_priorities(self) -> list[dict]:
|
||||
"""List CW service priorities."""
|
||||
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 [])
|
||||
]
|
||||
|
||||
@@ -12,6 +12,10 @@ from app.services.psa.types import (
|
||||
PSAConfiguration,
|
||||
PSATimeEntry,
|
||||
PSABoard,
|
||||
PaginatedTicketResult,
|
||||
PSAResource,
|
||||
PSACreatedTicket,
|
||||
TicketCreatePayload,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,7 +32,7 @@ class HaloPSAProvider(PSAProvider):
|
||||
async def get_ticket(self, ticket_id: str) -> PSATicket:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
async def search_tickets(self, query: str, **filters) -> list[PSATicket]:
|
||||
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
async def post_note(
|
||||
@@ -74,3 +78,18 @@ class HaloPSAProvider(PSAProvider):
|
||||
work_type: str | None = None,
|
||||
) -> PSATimeEntry:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
async def list_resources(self, ticket_id: int) -> list[PSAResource]:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
async def remove_resource(self, ticket_id: int, member_id: int) -> None:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
async def list_priorities(self) -> list[dict]:
|
||||
raise NotImplementedError("Halo PSA integration coming soon")
|
||||
|
||||
@@ -73,6 +73,40 @@ class PSABoard(BaseModel):
|
||||
inactive: bool = False
|
||||
|
||||
|
||||
class PaginatedTicketResult(BaseModel):
|
||||
items: list[PSATicket]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class NoteType:
|
||||
INTERNAL_ANALYSIS = "internal_analysis"
|
||||
RESOLUTION = "resolution"
|
||||
|
||||
116
backend/app/services/ticket_service.py
Normal file
116
backend/app/services/ticket_service.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""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,
|
||||
new_status_id=status_id,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
],
|
||||
)
|
||||
55
backend/tests/test_psa_tickets.py
Normal file
55
backend/tests/test_psa_tickets.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# 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 or 502; with PSA → 200
|
||||
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
|
||||
@@ -1,4 +1,4 @@
|
||||
# Lessons Archive (1-40)
|
||||
# Lessons Archive (1-70)
|
||||
|
||||
> These lessons were originally in CLAUDE.md. They've been archived because the fixes are now baked into the codebase. Consult this file if you encounter a regression in any of these areas.
|
||||
|
||||
@@ -81,3 +81,67 @@
|
||||
**39. Platform settings for feature toggles:** Use `SettingsManager.get("key", db, default=True)`.
|
||||
|
||||
**40. Survey public routes:** Add at top level in `router.tsx` alongside `/login`.
|
||||
|
||||
---
|
||||
|
||||
## Archived Lessons (41-70)
|
||||
|
||||
**41. Assistant chat uses local React state, not Zustand:** `AssistantChatPage.tsx` uses `useState` for `chats`, `messages`, `input`, `loading`. No store.
|
||||
|
||||
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and no-auth pages use `fetch()` with full URL. `apiClient` requires auth tokens.
|
||||
|
||||
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail).
|
||||
|
||||
**44. AI Chat Builder is flow-type-aware:** `ai_chat_service.py` dispatches by `flow_type`. Troubleshooting: `[TREE_UPDATE]` markers. Procedural: `[STEPS_UPDATE]` markers. Both support `[METADATA]`.
|
||||
|
||||
**45. Intake form field schema:** Uses `variable_name` and `field_type` (NOT `name` and `type`).
|
||||
|
||||
**46. `CreateFlowDropdown` uses `AIPromptDialog`:** Opens prompt modal, starts AI session, generates flow, navigates to editor with `{ state: { aiPanelOpen: true, sessionId } }`.
|
||||
|
||||
**47. Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI` hook. Ghost nodes use `_suggestion: true` flag. Delta responses use `[DELTA]...[/DELTA]` markers.
|
||||
|
||||
**48. Tree orphan validation uses dynamic root ID:** Orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`).
|
||||
|
||||
**49. Full-stack features — verify both ends:** schema → endpoint → API client → hook → store → UI.
|
||||
|
||||
**50. Anthropic SDK retry:** Set `max_retries=1` to fail fast. Default `max_retries=2` can take 3× timeout.
|
||||
|
||||
**51. AI model tier routing:** Use `settings.get_model_for_action(action_type)`. Model IDs: alias form (`claude-sonnet-4-6`).
|
||||
|
||||
**52. Mobile scroll-to-top:** Use `ref.current.scrollIntoView()`, not `window.scrollTo()`. Trigger via `useEffect`.
|
||||
|
||||
**53. Flex height chain:** Every ancestor must be a flex container for `flex-1` to work. Missing `flex` class collapses React Flow to 0 height.
|
||||
|
||||
**54. React Flow CSS in Tailwind v4:** Import in `index.css`, not component JS. Override dark theme using `--xy-*` CSS custom properties.
|
||||
|
||||
**55. App shell height chain:** Every wrapper between `.main-content` and canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`.
|
||||
|
||||
**56. Railway backend service name is `patherly`:** Production DB name is `railway`. Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
|
||||
|
||||
**57. Node field priority:** `title` → `question` → `description` → `content` → `label`. See `copilot_service.py`.
|
||||
|
||||
**58. `scriptGeneratorStore.generate()` optional param:** Always wrap: `onClick={() => generate()}`, never `onClick={generate}`.
|
||||
|
||||
**59. ConnectWise `clientId` is server-side config:** Set in `config.py` as `CW_CLIENT_ID`. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
|
||||
|
||||
**60. Dockerfile build args for Vite env vars:** Any new `VITE_*` var must be added as `ARG` + `ENV` in `frontend/Dockerfile`. Railway env vars are runtime-only without this; `import.meta.env.VITE_*` resolves to `undefined` in production builds.
|
||||
|
||||
**61. Procedural sessions auto-start on page load:** `ProceduralNavigationPage` calls `startSession()` immediately in `loadTree()` — no intake form screen or "Start" button. Variables filled inline. Troubleshooting flows DO have a start screen.
|
||||
|
||||
**62. Playwright strict mode — scope selectors:** Step titles appear in both sidebar and main heading. Use `getByRole('heading', { name })` for main content.
|
||||
|
||||
**63. Node 20 required for frontend builds:** `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. Or: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
||||
|
||||
**64. PostHog product analytics:** `PostHogProvider` in `main.tsx`. Event helpers in `lib/analytics.ts`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`.
|
||||
|
||||
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container `resolutionflow_postgres`, DB `resolutionflow` (not `patherly`), port `5433`. Playwright config defaults must match.
|
||||
|
||||
**66. Dev environment runs on Hostinger VPS (46.202.92.250):** CORS must include VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. See DEV-ENV.md.
|
||||
|
||||
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Use `getTreeEditorPath()` from `@/lib/routing`.
|
||||
|
||||
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping runs can process the same records twice (TOCTOU race).
|
||||
|
||||
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields.
|
||||
|
||||
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`.
|
||||
|
||||
63
docs/connectwise/CW_Security_Roles/README.md
Normal file
63
docs/connectwise/CW_Security_Roles/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# ConnectWise integration docs
|
||||
|
||||
Reference material for ResolutionFlow's ConnectWise Manage integration.
|
||||
This folder pairs a **human-editable source** (the XLSX) with two
|
||||
**generated artifacts** (YAML + Markdown). Code reads the YAML; humans
|
||||
read the Markdown; edits happen in the XLSX.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role | Edit? |
|
||||
|------|------|-------|
|
||||
| `api-member-security-roles.md` | Human-readable reference — browse on GitHub, link in PRs, onboard new contributors. | Generated — do not edit |
|
||||
| `api-member-security-roles.yaml` | Machine-readable source of truth — imported by integration code, queried by Claude Code when writing permission checks. | Generated — do not edit |
|
||||
| `source/Security_Roles_Matrix_11132017.xlsx` | Canonical source. The matrix as published by ConnectWise (with any corrections we've applied). | Yes — this is the editing surface |
|
||||
| `source/generate_role_docs.py` | Regenerates the YAML and Markdown from the XLSX. Deterministic. | Only if the matrix schema itself changes |
|
||||
| `source/requirements.txt` | Python deps for the generator (`openpyxl`, `PyYAML`). | Only when bumping deps |
|
||||
|
||||
## Regeneration workflow
|
||||
|
||||
After editing the XLSX:
|
||||
|
||||
```bash
|
||||
cd docs/integrations/connectwise/source
|
||||
pip install -r requirements.txt
|
||||
python generate_role_docs.py \
|
||||
--source Security_Roles_Matrix_11132017.xlsx \
|
||||
--out-yaml ../api-member-security-roles.yaml \
|
||||
--out-md ../api-member-security-roles.md
|
||||
```
|
||||
|
||||
Commit all three files together (XLSX, YAML, MD). The diff on the YAML
|
||||
is what reviewers should scrutinize — it is the source of truth for code.
|
||||
|
||||
## Querying the YAML from integration code
|
||||
|
||||
The YAML groups permissions by module and action. Example — checking
|
||||
what `Inquire: ALL` means for Service Desk → Service Tickets:
|
||||
|
||||
```python
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
doc = yaml.safe_load(
|
||||
Path("docs/integrations/connectwise/api-member-security-roles.yaml").read_text()
|
||||
)
|
||||
levels = doc["modules"]["Service Desk"]["actions"]["Service Tickets"]["inquire"]["levels"]
|
||||
print(levels["ALL"])
|
||||
```
|
||||
|
||||
This is the pattern `ConnectWiseAuthManager` and the proxy authorization
|
||||
layer should use when the required permission level for a given API
|
||||
endpoint needs to be documented or validated against an assigned role.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Levels are ordered most-to-least privileged:** `ALL`, `MY`, `MINE`, `NONE`.
|
||||
- **Verbs are always in this order:** `add`, `edit`, `delete`, `inquire`.
|
||||
- **`Not applicable` notes** in a verb's cell mean the meaningful level
|
||||
is documented under another verb (almost always `inquire`) — the
|
||||
generator preserves these as `note:` fields rather than inventing
|
||||
placeholder levels.
|
||||
- **The XLSX is the single source of input.** Never hand-edit the YAML
|
||||
or Markdown; your changes will be overwritten on the next regeneration.
|
||||
Binary file not shown.
1373
docs/connectwise/CW_Security_Roles/api-member-security-roles.md
Normal file
1373
docs/connectwise/CW_Security_Roles/api-member-security-roles.md
Normal file
File diff suppressed because it is too large
Load Diff
1913
docs/connectwise/CW_Security_Roles/api-member-security-roles.yaml
Normal file
1913
docs/connectwise/CW_Security_Roles/api-member-security-roles.yaml
Normal file
File diff suppressed because it is too large
Load Diff
361
docs/connectwise/CW_Security_Roles/generate_role_docs.py
Normal file
361
docs/connectwise/CW_Security_Roles/generate_role_docs.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
Generate ConnectWise security-role documentation from the source XLSX.
|
||||
|
||||
Produces:
|
||||
- api-member-security-roles.yaml : machine-readable source of truth
|
||||
- api-member-security-roles.md : human-readable reference
|
||||
|
||||
Re-run this script after editing the source XLSX. Both outputs are
|
||||
deterministic — they will produce identical content from identical input,
|
||||
so diffs in version control reflect only real permission-model changes.
|
||||
|
||||
Usage:
|
||||
python generate_role_docs.py \
|
||||
--source source/Security_Roles_Matrix_11132017.xlsx \
|
||||
--out-yaml ../api-member-security-roles.yaml \
|
||||
--out-md ../api-member-security-roles.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A level description line looks like "ALL: text..." or "NONE: text..."
|
||||
# We capture the prefix (ALL | NONE | MINE | MY) and the trailing description.
|
||||
LEVEL_LINE = re.compile(r"^(ALL|NONE|MINE|MY)\s*:\s*(.*)$", re.DOTALL)
|
||||
|
||||
# Recognized ConnectWise permission levels, most-to-least privileged.
|
||||
LEVEL_ORDER = ["ALL", "MY", "MINE", "NONE"]
|
||||
|
||||
VERBS = ["add", "edit", "delete", "inquire"]
|
||||
VERB_COLS = {"add": 3, "edit": 4, "delete": 5, "inquire": 6}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CellPermission:
|
||||
"""Parsed contents of a single (action, verb) cell."""
|
||||
|
||||
levels: Dict[str, str] = field(default_factory=dict) # level -> description
|
||||
note: Optional[str] = None # for "Not applicable. See Inquire level." etc.
|
||||
raw: str = "" # original cell text, preserved for audit
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionRow:
|
||||
module: str
|
||||
action: str
|
||||
permissions: Dict[str, CellPermission] # verb -> CellPermission
|
||||
|
||||
|
||||
def parse_cell(raw: Optional[str]) -> CellPermission:
|
||||
"""Parse a single cell's multi-line content into levels + note."""
|
||||
if raw is None:
|
||||
return CellPermission(raw="")
|
||||
text = str(raw).strip()
|
||||
cp = CellPermission(raw=text)
|
||||
if not text:
|
||||
return cp
|
||||
|
||||
# Split into candidate entries. Each entry is typically one line that
|
||||
# starts with a level prefix, but description text can itself contain
|
||||
# newlines. We therefore split on newlines and accumulate continuation
|
||||
# lines into the preceding entry.
|
||||
current_level: Optional[str] = None
|
||||
current_buf: List[str] = []
|
||||
note_buf: List[str] = []
|
||||
|
||||
def flush_level() -> None:
|
||||
nonlocal current_level, current_buf
|
||||
if current_level is not None:
|
||||
cp.levels[current_level] = " ".join(current_buf).strip()
|
||||
current_level = None
|
||||
current_buf = []
|
||||
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
m = LEVEL_LINE.match(line)
|
||||
if m:
|
||||
flush_level()
|
||||
current_level = m.group(1).upper()
|
||||
current_buf = [m.group(2).strip()]
|
||||
elif current_level is not None:
|
||||
current_buf.append(line)
|
||||
else:
|
||||
# No level prefix yet — belongs to the note.
|
||||
note_buf.append(line)
|
||||
flush_level()
|
||||
|
||||
if note_buf:
|
||||
cp.note = " ".join(note_buf).strip()
|
||||
|
||||
return cp
|
||||
|
||||
|
||||
def read_matrix(xlsx_path: Path) -> List[ActionRow]:
|
||||
wb = load_workbook(xlsx_path, data_only=True)
|
||||
ws = wb.active # Single sheet in this workbook.
|
||||
|
||||
# Header row is row 2 per the source file; data begins row 3.
|
||||
actions: List[ActionRow] = []
|
||||
for r in range(3, ws.max_row + 1):
|
||||
module = ws.cell(row=r, column=1).value
|
||||
action = ws.cell(row=r, column=2).value
|
||||
if not (module or action):
|
||||
continue # skip fully empty rows
|
||||
if not module or not action:
|
||||
# Partial row — keep but flag. This shouldn't happen in the
|
||||
# current source; if it does, the generator should fail loudly
|
||||
# rather than silently produce wrong output.
|
||||
raise ValueError(
|
||||
f"Row {r} has a missing Module or Action: "
|
||||
f"module={module!r}, action={action!r}"
|
||||
)
|
||||
|
||||
perms: Dict[str, CellPermission] = {}
|
||||
for verb, col in VERB_COLS.items():
|
||||
perms[verb] = parse_cell(ws.cell(row=r, column=col).value)
|
||||
|
||||
actions.append(
|
||||
ActionRow(module=module.strip(), action=action.strip(), permissions=perms)
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output: YAML
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_yaml_document(actions: List[ActionRow], source_file: str) -> dict:
|
||||
"""Build a plain-dict representation that YAML dumps cleanly."""
|
||||
# Group by module, preserving action order within each module.
|
||||
modules: Dict[str, List[ActionRow]] = {}
|
||||
for a in actions:
|
||||
modules.setdefault(a.module, []).append(a)
|
||||
|
||||
doc = {
|
||||
"metadata": {
|
||||
"source_file": source_file,
|
||||
"generated_on": date.today().isoformat(),
|
||||
"generator": "docs/integrations/connectwise/source/generate_role_docs.py",
|
||||
"description": (
|
||||
"ConnectWise security-role matrix. Each (module, action) entry "
|
||||
"describes what each access level (ALL, MY, MINE, NONE) means "
|
||||
"for the Add, Edit, Delete, and Inquire verbs. This is a "
|
||||
"reference catalog, not a per-role assignment — role "
|
||||
"assignments live in ConnectWise and are mirrored in the "
|
||||
"ResolutionFlow integration config."
|
||||
),
|
||||
"level_order_most_to_least_privileged": LEVEL_ORDER,
|
||||
},
|
||||
"modules": {},
|
||||
}
|
||||
|
||||
for module_name, rows in modules.items():
|
||||
module_block = {"actions": {}}
|
||||
for a in rows:
|
||||
action_block: Dict[str, object] = {}
|
||||
for verb in VERBS:
|
||||
cell = a.permissions[verb]
|
||||
entry: Dict[str, object] = {}
|
||||
if cell.levels:
|
||||
# Emit levels in canonical order, only those present.
|
||||
entry["levels"] = {
|
||||
lvl: cell.levels[lvl]
|
||||
for lvl in LEVEL_ORDER
|
||||
if lvl in cell.levels
|
||||
}
|
||||
if cell.note:
|
||||
entry["note"] = cell.note
|
||||
if not entry:
|
||||
# Truly empty cell — represent explicitly so downstream
|
||||
# consumers can distinguish "empty" from "missing".
|
||||
entry["note"] = "(no description provided)"
|
||||
action_block[verb] = entry
|
||||
module_block["actions"][a.action] = action_block
|
||||
doc["modules"][module_name] = module_block
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
class _LiteralStr(str):
|
||||
"""Marker type so PyYAML renders long strings as block literals."""
|
||||
|
||||
|
||||
def _literal_presenter(dumper, data):
|
||||
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
||||
|
||||
|
||||
yaml.add_representer(_LiteralStr, _literal_presenter)
|
||||
|
||||
|
||||
def _use_block_style_for_long_strings(obj):
|
||||
"""Recursively wrap long strings so the YAML is readable, not one-line."""
|
||||
if isinstance(obj, dict):
|
||||
return {k: _use_block_style_for_long_strings(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_use_block_style_for_long_strings(v) for v in obj]
|
||||
if isinstance(obj, str) and (len(obj) > 80 or "\n" in obj):
|
||||
return _LiteralStr(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def dump_yaml(doc: dict, out_path: Path) -> None:
|
||||
prepared = _use_block_style_for_long_strings(doc)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out_path.open("w", encoding="utf-8") as f:
|
||||
f.write("# ConnectWise API Member Security Roles — reference matrix.\n")
|
||||
f.write("# Generated from the source XLSX; do not edit by hand.\n")
|
||||
f.write("# Re-run generate_role_docs.py after updating the XLSX.\n\n")
|
||||
yaml.dump(
|
||||
prepared,
|
||||
f,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
width=100,
|
||||
default_flow_style=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output: Markdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _md_escape(text: str) -> str:
|
||||
"""Escape pipes and collapse whitespace for Markdown table cells."""
|
||||
return text.replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def build_markdown(actions: List[ActionRow], source_file: str) -> str:
|
||||
modules: Dict[str, List[ActionRow]] = {}
|
||||
for a in actions:
|
||||
modules.setdefault(a.module, []).append(a)
|
||||
|
||||
lines: List[str] = []
|
||||
lines.append("# ConnectWise API Member — Security Roles Reference")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"_Generated {date.today().isoformat()} from "
|
||||
f"`{source_file}`. Do not edit by hand — update the XLSX and "
|
||||
f"re-run `generate_role_docs.py`._"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## How to read this document")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Each ConnectWise module lists the actions it governs. For every "
|
||||
"action, four permission verbs — **Add**, **Edit**, **Delete**, "
|
||||
"**Inquire** — can be granted at one of these levels, most to "
|
||||
"least privileged:"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("| Level | Meaning |")
|
||||
lines.append("|-------|---------|")
|
||||
lines.append("| `ALL` | Access to all records in the system. |")
|
||||
lines.append("| `MY` | Access to records owned by the user's team. |")
|
||||
lines.append("| `MINE` | Access only to records owned by the user. |")
|
||||
lines.append("| `NONE` | No access. |")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Not every level applies to every action — the source matrix "
|
||||
"only documents the levels that are meaningful for each cell. "
|
||||
"Cells marked _Not applicable_ reference another verb (usually "
|
||||
"Inquire) where the meaningful level is defined."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"The machine-readable form of this document is "
|
||||
"[`api-member-security-roles.yaml`](./api-member-security-roles.yaml). "
|
||||
"Use the YAML when writing integration code; use this Markdown "
|
||||
"when reviewing, discussing, or onboarding."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## Table of contents")
|
||||
lines.append("")
|
||||
for module_name in modules:
|
||||
anchor = module_name.lower().replace(" ", "-").replace("/", "")
|
||||
lines.append(f"- [{module_name}](#{anchor}) — {len(modules[module_name])} actions")
|
||||
lines.append("")
|
||||
|
||||
for module_name, rows in modules.items():
|
||||
lines.append(f"## {module_name}")
|
||||
lines.append("")
|
||||
for a in rows:
|
||||
lines.append(f"### {a.action}")
|
||||
lines.append("")
|
||||
lines.append("| Verb | Level | Description |")
|
||||
lines.append("|------|-------|-------------|")
|
||||
wrote_any = False
|
||||
for verb in VERBS:
|
||||
cell = a.permissions[verb]
|
||||
if cell.levels:
|
||||
for lvl in LEVEL_ORDER:
|
||||
if lvl in cell.levels:
|
||||
lines.append(
|
||||
f"| {verb.capitalize()} | `{lvl}` | "
|
||||
f"{_md_escape(cell.levels[lvl])} |"
|
||||
)
|
||||
wrote_any = True
|
||||
elif cell.note:
|
||||
lines.append(
|
||||
f"| {verb.capitalize()} | — | "
|
||||
f"_{_md_escape(cell.note)}_ |"
|
||||
)
|
||||
wrote_any = True
|
||||
if not wrote_any:
|
||||
lines.append("| — | — | _(no description provided)_ |")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def write_markdown(md_text: str, out_path: Path) -> None:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(md_text, encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", type=Path, required=True,
|
||||
help="Path to the source .xlsx")
|
||||
parser.add_argument("--out-yaml", type=Path, required=True,
|
||||
help="Path to write the YAML output")
|
||||
parser.add_argument("--out-md", type=Path, required=True,
|
||||
help="Path to write the Markdown output")
|
||||
args = parser.parse_args()
|
||||
|
||||
actions = read_matrix(args.source)
|
||||
doc = build_yaml_document(actions, source_file=args.source.name)
|
||||
dump_yaml(doc, args.out_yaml)
|
||||
|
||||
md = build_markdown(actions, source_file=args.source.name)
|
||||
write_markdown(md, args.out_md)
|
||||
|
||||
# Quick data-quality summary to stdout — helpful when re-running after edits.
|
||||
from collections import Counter
|
||||
modules_seen = Counter(a.module for a in actions)
|
||||
print(f"Parsed {len(actions)} actions across {len(modules_seen)} modules:")
|
||||
for m, n in modules_seen.most_common():
|
||||
print(f" {m}: {n}")
|
||||
print(f"\nWrote {args.out_yaml}")
|
||||
print(f"Wrote {args.out_md}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
docs/connectwise/CW_Security_Roles/requirements.txt
Normal file
5
docs/connectwise/CW_Security_Roles/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# Dependencies for generate_role_docs.py.
|
||||
# These are only needed when regenerating the role docs from the XLSX —
|
||||
# they are not runtime dependencies of ResolutionFlow itself.
|
||||
openpyxl>=3.1,<4.0
|
||||
PyYAML>=6.0,<7.0
|
||||
3075
docs/superpowers/plans/2026-04-16-psa-ticket-management.md
Normal file
3075
docs/superpowers/plans/2026-04-16-psa-ticket-management.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,485 @@
|
||||
# PSA Ticket Management — Design Spec
|
||||
|
||||
**Date:** 2026-04-16
|
||||
**Status:** Approved
|
||||
**Author:** Michael Chihlas + Claude
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Add PSA ticket management to ResolutionFlow so MSP engineers can view, manage, and create ConnectWise tickets without leaving the app. The feature surfaces in three places: a dedicated Tickets page, a dashboard widget on QuickStartPage, and a spin-off ticket flow inside ResolutionAssist sessions.
|
||||
|
||||
---
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Question | Decision |
|
||||
|----------|----------|
|
||||
| Where does ticket management live? | Both: dedicated `/tickets` page + dashboard widget on QuickStartPage |
|
||||
| List layout | Flat list with rich filters + pagination |
|
||||
| Row density | Compact single-line rows |
|
||||
| Ticket detail | Right-side slide-out panel (~50% width) |
|
||||
| Ticket creation | Two-tab modal: Quick Create (AI) + Full Form |
|
||||
| Resource member list | All CW members, RF-mapped users visually highlighted |
|
||||
| Architecture | Dedicated `ticket_service.py` + normalized DTOs |
|
||||
|
||||
---
|
||||
|
||||
## Section 1 — Backend
|
||||
|
||||
### New Endpoints
|
||||
|
||||
All added to `backend/app/api/endpoints/integrations.py`, backed by `backend/app/services/ticket_service.py`.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/integrations/psa/tickets` | Create a ticket |
|
||||
| `PATCH` | `/integrations/psa/tickets/{id}/status` | Update ticket status |
|
||||
| `GET` | `/integrations/psa/tickets/{id}/resources` | List current assignees |
|
||||
| `POST` | `/integrations/psa/tickets/{id}/resources` | Add a resource (member) |
|
||||
| `DELETE` | `/integrations/psa/tickets/{id}/resources/{member_id}` | Remove a resource |
|
||||
| `POST` | `/integrations/psa/tickets/ai-parse` | Natural language → structured pre-fill payload |
|
||||
|
||||
**Breaking change — `search_tickets` response shape updated to `TicketListResponse`.**
|
||||
The existing `/integrations/psa/tickets/search` endpoint currently returns `list[PSATicketSearchResult]`. This spec changes it to return `TicketListResponse` (adds `total`, `page`, `page_size` wrapper).
|
||||
|
||||
Current callers that must be migrated:
|
||||
- `integrationsApi.searchTickets()` in `frontend/src/api/integrations.ts` (line 18) — update return type
|
||||
- `integrationsApi.searchTicketsQueue()` in `frontend/src/api/integrations.ts` (line 20) — update return type
|
||||
- `frontend/src/components/dashboard/TicketQueue.tsx` — update to read `.items` from response
|
||||
- `frontend/src/components/session/TicketPickerModal.tsx` — update to read `.items` from response
|
||||
|
||||
All other existing endpoints (`get_ticket`, `get_ticket_statuses`, `list_members`, `list_boards`) are unchanged.
|
||||
|
||||
### ticket_service.py
|
||||
|
||||
New service wrapping the PSA provider for ticket mutations. Keeps `integrations.py` clean and PSA-agnostic for future Autotask support.
|
||||
|
||||
Methods:
|
||||
- `create_ticket(account_id, payload) → PSATicketCreated`
|
||||
- `add_resource(account_id, ticket_id, member_id) → PSAResource`
|
||||
- `remove_resource(account_id, ticket_id, member_id) → None`
|
||||
- `update_status(account_id, ticket_id, status_id) → PSATicketStatusUpdate`
|
||||
- `list_resources(account_id, ticket_id) → list[PSAResource]`
|
||||
|
||||
### PSA Provider — New Abstract Methods and Paginated Result Type
|
||||
|
||||
**New type in `backend/app/services/psa/types.py`:**
|
||||
```python
|
||||
@dataclass
|
||||
class PaginatedTicketResult:
|
||||
items: list[PSATicket]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
```
|
||||
|
||||
**`search_tickets` signature change** — updated on both the abstract base and `ConnectWiseProvider` to return `PaginatedTicketResult` instead of `list[PSATicket]`:
|
||||
```python
|
||||
# base.py
|
||||
@abstractmethod
|
||||
async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: ...
|
||||
```
|
||||
|
||||
**How `total` is fetched** — ConnectWise provides `GET /service/tickets/count?conditions=...` which accepts the same conditions string as the page fetch. The `ConnectWiseProvider.search_tickets()` implementation fires two parallel requests:
|
||||
1. `GET /service/tickets?conditions=...&pageSize=N&page=N` — the current page
|
||||
2. `GET /service/tickets/count?conditions=...` — returns `{ "count": 142 }`
|
||||
|
||||
Both use the same built conditions string. `asyncio.gather()` runs them in parallel. The count result is used to populate `PaginatedTicketResult.total`.
|
||||
|
||||
**New abstract methods** added to `PSAProvider` base and `ConnectWiseProvider`:
|
||||
```python
|
||||
async def list_resources(self, ticket_id: int) -> list[PSAResource]: ...
|
||||
async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: ...
|
||||
async def remove_resource(self, ticket_id: int, member_id: int) -> None: ...
|
||||
async def create_ticket(self, payload: TicketCreatePayload) -> PSATicketCreated: ...
|
||||
```
|
||||
|
||||
`update_status` already exists on the provider — no change needed there.
|
||||
|
||||
ConnectWise implementation:
|
||||
- `list_resources` → `GET /service/tickets/{id}/members`
|
||||
- `add_resource` → `POST /service/tickets/{id}/members`
|
||||
- `remove_resource` → `DELETE /service/tickets/{id}/members/{member_id}`
|
||||
- `create_ticket` → `POST /service/tickets`
|
||||
|
||||
### Normalized DTOs (Pydantic Schemas)
|
||||
|
||||
New schemas in `backend/app/schemas/psa_tickets.py`:
|
||||
|
||||
```python
|
||||
class PSAResource(BaseModel):
|
||||
member_id: int
|
||||
member_name: str
|
||||
member_identifier: str # CW username
|
||||
is_rf_user: bool # True if mapped in RF member mappings
|
||||
|
||||
class PSATicketCreated(BaseModel):
|
||||
id: int
|
||||
summary: str
|
||||
board_name: str
|
||||
status_name: str
|
||||
priority_name: str
|
||||
company_name: str
|
||||
resources: list[PSAResource]
|
||||
|
||||
class PSATicketStatusUpdate(BaseModel):
|
||||
ticket_id: int
|
||||
previous_status: str
|
||||
new_status: str
|
||||
|
||||
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
|
||||
|
||||
class TicketListResponse(BaseModel):
|
||||
items: list[PSATicketSearchResult] # existing schema
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
```
|
||||
|
||||
`search_tickets` endpoint updated to return `TicketListResponse` (was a plain list). Backend sorts results by `priority desc, dateEntered desc` via CW `orderBy` param.
|
||||
|
||||
### AI Parse Endpoint
|
||||
|
||||
`POST /integrations/psa/tickets/ai-parse`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{ "prompt": "New ticket for Acme Corp, Outlook not syncing, high priority, assign to me" }
|
||||
```
|
||||
|
||||
Response — all pre-fill fields nullable, explicit `missing_fields` and `warnings`:
|
||||
```json
|
||||
{
|
||||
"summary": "Outlook not syncing",
|
||||
"company_id": 42,
|
||||
"board_id": null,
|
||||
"priority_id": null,
|
||||
"status_id": null,
|
||||
"assigned_member_id": 17,
|
||||
"description": "User reports Outlook calendar not syncing since yesterday morning.",
|
||||
"missing_fields": ["board_id", "priority_id", "status_id"],
|
||||
"warnings": ["Could not determine board from context"]
|
||||
}
|
||||
```
|
||||
|
||||
Frontend uses `missing_fields` to highlight required fields still needing engineer input. No ticket is created at this step — it is a parse-only endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Section 2 — Frontend Architecture
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `pages/TicketsPage.tsx` | Main tickets page — filter bar + paginated list |
|
||||
| `components/tickets/TicketListRow.tsx` | Compact single-line row |
|
||||
| `components/tickets/TicketFilterBar.tsx` | Config-driven filter bar (7 filters) |
|
||||
| `components/tickets/TicketDetailPanel.tsx` | Slide-out panel orchestrator |
|
||||
| `components/tickets/detail/TicketDetailHeader.tsx` | ID, summary, company, board, SLA |
|
||||
| `components/tickets/detail/TicketResourceManager.tsx` | Assignee list + add/remove |
|
||||
| `components/tickets/detail/TicketNotesFeed.tsx` | Chronological notes history |
|
||||
| `components/tickets/detail/TicketAddNote.tsx` | Inline note composer |
|
||||
| `components/tickets/detail/TicketConfigs.tsx` | Attached devices/configs |
|
||||
| `components/tickets/detail/TicketRelated.tsx` | Related tickets list |
|
||||
| `components/tickets/NewTicketModal.tsx` | Two-tab modal (owns draft state) |
|
||||
| `components/tickets/AiTicketParseForm.tsx` | Prompt input → emits parsed values upward |
|
||||
| `api/tickets.ts` | All ticket API calls (typed, `.then(r => r.data)` pattern) |
|
||||
| `types/tickets.ts` | TypeScript interfaces mirroring normalized DTOs |
|
||||
|
||||
### Existing Files Touched
|
||||
|
||||
- `router.tsx` — add `/tickets` route (lazy, via `lazyWithRetry`)
|
||||
- `AppLayout.tsx` — add "Tickets" nav item in sidebar under RESOLVE section
|
||||
- `AssistantChatPage.tsx` — handle `create_spin_off_ticket` action type in TaskLane + add "New Ticket" button to session header
|
||||
- `QuickStartPage.tsx` — no structural change needed; `TicketQueue` already renders at line 64. The existing component is updated in place (see Section 4).
|
||||
|
||||
### Shared Types (`types/tickets.ts`)
|
||||
|
||||
```typescript
|
||||
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; // number = specific member_id
|
||||
include_closed: boolean;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// TicketSearchResult is the existing PSATicketSearchResult type from types/integrations.ts
|
||||
// Re-export or import from there — do not redefine
|
||||
export interface TicketListResponse {
|
||||
items: PSATicketSearchResult[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
```
|
||||
|
||||
### TicketsPage — Filter & Pagination State
|
||||
|
||||
All filter and pagination state lives in URL query params via `useSearchParams`:
|
||||
|
||||
| Param | Type | Default |
|
||||
|-------|------|---------|
|
||||
| `search` | string | `""` |
|
||||
| `board` | number | — |
|
||||
| `status` | number | — |
|
||||
| `priority` | string | — |
|
||||
| `company` | number | — |
|
||||
| `assigned` | `me \| unassigned \| all \| {id}` | `all` |
|
||||
| `closed` | boolean | `false` |
|
||||
| `page` | number | `1` |
|
||||
|
||||
Filter changes reset `page` to 1. Pagination: page size of 25. Controls show "Showing X–Y of Z tickets". Next disabled when `page * 25 >= total`.
|
||||
|
||||
### TicketFilterBar — Config-Driven
|
||||
|
||||
Filters defined as a `FILTER_CONFIG` array. Each entry:
|
||||
```typescript
|
||||
{ key: keyof TicketFilters, label: string, type: 'text' | 'select' | 'toggle', loadOptions?: () => Promise<Option[]> }
|
||||
```
|
||||
Adding or removing a filter is a one-line config change, not a component edit.
|
||||
|
||||
### TicketDetailPanel — Optimistic Hydration
|
||||
|
||||
The panel uses the **existing** `/integrations/psa/tickets/{id}/context` endpoint (client: `psaContextApi.getTicketContext()` in `frontend/src/api/psaContext.ts`) which already returns company, contact, configurations, notes, and related tickets in one call. This avoids creating redundant endpoints.
|
||||
|
||||
1. Panel opens immediately with list row data (id, summary, company, board, status, priority) — no loading state for these fields
|
||||
2. Two parallel fetches fire on open:
|
||||
- `psaContextApi.getTicketContext(ticketId)` — hydrates contact, notes, configs, related tickets
|
||||
- `ticketsApi.listResources(ticketId)` — hydrates assignees (new endpoint)
|
||||
3. All detail sections (contact, notes, configs, related) render skeletons until `getTicketContext` resolves
|
||||
4. Resources section renders skeleton until `listResources` resolves
|
||||
|
||||
`get_ticket` (the simpler single-ticket endpoint) is **not** used by the panel — `getTicketContext` is a strict superset of the data needed.
|
||||
|
||||
### NewTicketModal — State Ownership
|
||||
|
||||
- `NewTicketModal` owns the `TicketCreationPayload` draft state
|
||||
- `AiTicketParseForm` is a pure emitter: accepts a prompt string, calls `ai-parse`, fires `onParsed(Partial<TicketCreationPayload>)` upward
|
||||
- Modal merges parsed values into draft, highlights `missing_fields` with visual indicators
|
||||
- Two tabs: **Quick Create** (AI prompt → review) | **Full Form** (manual entry)
|
||||
- Default tab: Quick Create if AI-triggered, Full Form if engineer-initiated
|
||||
- Initial props: `initialValues?: Partial<TicketCreationPayload>` — used for spin-off pre-population
|
||||
|
||||
---
|
||||
|
||||
## Section 3 — ResolutionAssist Integration
|
||||
|
||||
### Two Trigger Paths
|
||||
|
||||
**1. AI-suggested (via `[ACTIONS]` marker)**
|
||||
|
||||
When the AI identifies a second distinct issue during a session, it emits a JSON array inside the `[ACTIONS]` marker — matching the exact format `_parse_actions_marker()` in `unified_chat_service.py` expects (a list of objects with `label`, `command`, `description`):
|
||||
|
||||
```
|
||||
[ACTIONS]
|
||||
[
|
||||
{
|
||||
"label": "Create ticket: Printer offline on 2nd floor",
|
||||
"command": "create_spin_off_ticket",
|
||||
"description": "Printer offline on 2nd floor"
|
||||
}
|
||||
]
|
||||
[/ACTIONS]
|
||||
```
|
||||
|
||||
The existing `_parse_actions_marker()` parser in `unified_chat_service.py` already handles this format — no parser changes needed. The frontend reads `action.command === "create_spin_off_ticket"` to render the "Create Ticket" button in TaskLane, and uses `action.description` as the `summary_hint` pre-populated into the Quick Create prompt input.
|
||||
|
||||
`summary_hint` (from `action.description`) populates the AI prompt input only, not the summary field directly. The engineer still runs the AI parse step and reviews all output. This prevents bypassing review with potentially hallucinated values.
|
||||
|
||||
**2. Engineer-initiated**
|
||||
|
||||
A "New Ticket" button in the ResolutionAssist session header. Always visible regardless of AI suggestion. Opens `NewTicketModal` with Full Form tab as default.
|
||||
|
||||
### Both Paths — NewTicketModal Pre-population
|
||||
|
||||
**The linked ticket IDs problem:** The current `PSATicketInfo` type in `frontend/src/types/integrations.ts` only exposes `company_name` and `board_name` — not `company_id` or `board_id`. The modal needs the numeric IDs to pre-populate the form selects.
|
||||
|
||||
**Fix:** Expand `PSATicketInfo` in `types/integrations.ts` to add the optional ID fields:
|
||||
```typescript
|
||||
export interface PSATicketInfo {
|
||||
id: string
|
||||
summary: string
|
||||
company_name: string | null
|
||||
board_name: string | null
|
||||
status_name: string | null
|
||||
priority_name: string | null
|
||||
company_id: number | null // add
|
||||
board_id: number | null // add
|
||||
}
|
||||
```
|
||||
|
||||
These fields are already returned by the CW API in `get_ticket()` — update `_map_ticket()` in `ConnectWiseProvider` and the `PSATicketInfo` Pydantic schema to pass them through.
|
||||
|
||||
**`AssistantChatPage` state change required:** The current page only tracks `activePsaTicketId: string | null` (line 76) — it does not hold a `PSATicketInfo` object. Add a new state field:
|
||||
```typescript
|
||||
const [linkedTicket, setLinkedTicket] = useState<PSATicketInfo | null>(null)
|
||||
```
|
||||
|
||||
When the modal is opened (either via AI suggestion or the "New Ticket" button), if `activePsaTicketId` is set and `linkedTicket` is null, fire `integrationsApi.getTicket(activePsaTicketId)` to fetch the full ticket (which now includes `company_id` and `board_id`) and store it in `linkedTicket`. The modal opens immediately — `initialValues` is populated once the fetch resolves and the form fields update. If the fetch is still in flight when the modal opens, `company_id` and `board_id` start empty and fill in when ready.
|
||||
|
||||
Once `linkedTicket` is populated, the modal receives:
|
||||
```typescript
|
||||
initialValues: {
|
||||
company_id: linkedTicket.company_id,
|
||||
board_id: linkedTicket.board_id,
|
||||
}
|
||||
```
|
||||
|
||||
When no linked ticket exists (`activePsaTicketId === null`): `initialValues` is omitted. `company_id` and `board_id` render empty, requiring manual selection. No silent defaults, no errors.
|
||||
|
||||
### TaskLane Action Lifecycle
|
||||
|
||||
- Opening the modal does **not** remove the action from TaskLane
|
||||
- Dismissing the modal without submitting leaves the action visible
|
||||
- Successful ticket creation removes the action and shows a success toast: `"Ticket #1042 created in ConnectWise"`
|
||||
|
||||
### System Prompt Addition
|
||||
|
||||
New rule added to `ASSISTANT_SYSTEM_PROMPT` in `backend/app/services/assistant_chat_service.py`:
|
||||
|
||||
> 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. Use `"command": "create_spin_off_ticket"` and put the issue description in `"description"`. Only suggest this when the issue is genuinely separate — do not suggest for every tangential mention.
|
||||
|
||||
### Backend
|
||||
|
||||
- **`assistant_chat_service.py`** — system prompt updated with spin-off ticket instruction (above)
|
||||
- **`unified_chat_service.py`** — no parser changes needed; the existing `_parse_actions_marker()` already handles the JSON array format. The frontend reads `command === "create_spin_off_ticket"` to route the action
|
||||
- **`flowpilot_engine.py`** — no changes needed for this feature; guided FlowPilot sessions do not use this action type in the current scope
|
||||
|
||||
No new backend endpoints — the modal reuses `POST /integrations/psa/tickets` and `POST /integrations/psa/tickets/ai-parse`.
|
||||
|
||||
---
|
||||
|
||||
## Section 4 — Dashboard Widget (QuickStartPage)
|
||||
|
||||
### Placement
|
||||
|
||||
`TicketQueue` **already exists** in `QuickStartPage` (line 64, below `ActiveFlowPilotSessions`, above the Dashboard section). It currently auto-hides if no PSA connection exists. This spec updates the existing `TicketQueue` component — it is **not** a new widget and does not need to be added to `QuickStartPage`. The Dashboard section below it is not collapsible.
|
||||
|
||||
### Data Fetching
|
||||
|
||||
On mount: `GET /integrations/psa/member-mappings` first to detect mapping state, then `integrationsApi.searchTicketsQueue({ assigned_to_me: true, include_closed: false, page_size: 5 })` if a mapping exists for the current user.
|
||||
|
||||
`searchTicketsQueue` is used (not `searchTickets`) because it already accepts `assigned_to_me` and `page_size` params. Its return type will be updated to `TicketListResponse` as part of the search endpoint migration, so the widget reads `.items` after that change.
|
||||
|
||||
Member mapping detection is explicit — the widget checks the mappings response, not the ticket result. "No mapping" and "no tickets" are distinct states.
|
||||
|
||||
### Widget States
|
||||
|
||||
| State | Condition | Display |
|
||||
|-------|-----------|---------|
|
||||
| Hidden | No PSA connection | Widget not rendered |
|
||||
| Prompt | PSA connected, no member mapping | "Map your PSA member to see your queue" → `/account/integrations` |
|
||||
| Loading | Fetching | 3 skeleton rows |
|
||||
| Populated | Tickets returned | Up to 5 compact rows + "View All Tickets →" |
|
||||
| Empty | No assigned open tickets | "No open tickets assigned to you" — muted, no CTA |
|
||||
| Error | PSA fetch fails | Silent — returns `[]`, no toast (per Lesson 111) |
|
||||
|
||||
### Row Display
|
||||
|
||||
Compact row matching Tickets page style: `#ID · Summary · Status badge · Priority dot`
|
||||
|
||||
Clicking a row opens `TicketDetailPanel` as a right-side sheet rendered at the `QuickStartPage` level. Does **not** navigate away.
|
||||
|
||||
### "View All Tickets" Link
|
||||
|
||||
Links to `/tickets?assigned=me`. `TicketsPage` reads `assigned` from `useSearchParams` on mount and applies it as the initial filter state — consistent with Section 2 URL param contract.
|
||||
|
||||
### Sorting
|
||||
|
||||
Backend `search_tickets()` adds `orderBy=priority desc,dateEntered desc` to the CW API query. Widget does not sort client-side.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
### New Backend Files
|
||||
- `backend/app/services/ticket_service.py`
|
||||
- `backend/app/schemas/psa_tickets.py`
|
||||
|
||||
### Modified Backend Files
|
||||
- `backend/app/api/endpoints/integrations.py` — 6 new endpoints, update search to return `TicketListResponse`
|
||||
- `backend/app/services/psa/types.py` — add `PaginatedTicketResult` dataclass
|
||||
- `backend/app/services/psa/base.py` — 4 new abstract methods; update `search_tickets` return type to `PaginatedTicketResult`
|
||||
- `backend/app/services/psa/connectwise/provider.py` — implement 4 new methods; update `search_tickets` to fire parallel count request and return `PaginatedTicketResult`; update `_map_ticket()` to pass through `company_id` and `board_id`
|
||||
- `backend/app/schemas/psa_connection.py` — add `company_id` and `board_id` to `PSATicketInfo` Pydantic schema
|
||||
- `backend/app/services/assistant_chat_service.py` — add spin-off ticket rule to `ASSISTANT_SYSTEM_PROMPT`
|
||||
- ~~`backend/app/services/flowpilot_engine.py`~~ — no changes (FlowPilot out of scope for this feature)
|
||||
- ~~`backend/app/services/unified_chat_service.py`~~ — no changes (existing `[ACTIONS]` parser handles the format)
|
||||
|
||||
### New Frontend Files
|
||||
- `frontend/src/pages/TicketsPage.tsx`
|
||||
- `frontend/src/api/tickets.ts`
|
||||
- `frontend/src/types/tickets.ts`
|
||||
- `frontend/src/components/tickets/TicketListRow.tsx`
|
||||
- `frontend/src/components/tickets/TicketFilterBar.tsx`
|
||||
- `frontend/src/components/tickets/TicketDetailPanel.tsx`
|
||||
- `frontend/src/components/tickets/NewTicketModal.tsx`
|
||||
- `frontend/src/components/tickets/AiTicketParseForm.tsx`
|
||||
- `frontend/src/components/tickets/detail/TicketDetailHeader.tsx`
|
||||
- `frontend/src/components/tickets/detail/TicketResourceManager.tsx`
|
||||
- `frontend/src/components/tickets/detail/TicketNotesFeed.tsx`
|
||||
- `frontend/src/components/tickets/detail/TicketAddNote.tsx`
|
||||
- `frontend/src/components/tickets/detail/TicketConfigs.tsx`
|
||||
- `frontend/src/components/tickets/detail/TicketRelated.tsx`
|
||||
|
||||
### Modified Frontend Files
|
||||
- `frontend/src/router.tsx` — `/tickets` route
|
||||
- `frontend/src/components/layout/AppLayout.tsx` — Tickets nav item
|
||||
- `frontend/src/pages/AssistantChatPage.tsx` — handle `create_spin_off_ticket` command in action renderer + add "New Ticket" button to session header
|
||||
- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component (see Section 4 — not a new file)
|
||||
- `frontend/src/api/integrations.ts` — update `searchTickets()` and `searchTicketsQueue()` return types to `TicketListResponse`
|
||||
- `frontend/src/types/integrations.ts` — add `company_id: number | null` and `board_id: number | null` to `PSATicketInfo`
|
||||
- `frontend/src/components/dashboard/TicketQueue.tsx` — update existing component: read `.items`, add mapping-state detection, member-mapping check, and 5-item cap
|
||||
- `frontend/src/components/session/TicketPickerModal.tsx` — read `.items` from paginated response
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Autotask provider implementation (schema-ready, not implemented)
|
||||
- Time entry creation from ticket detail (provider method exists, no UI)
|
||||
- Ticket editing beyond status (summary, description, priority changes)
|
||||
- Bulk ticket operations
|
||||
- Real-time ticket updates / polling
|
||||
@@ -37,3 +37,4 @@ export { handoffsApi } from './handoffs'
|
||||
export { resolutionsApi } from './resolutions'
|
||||
export { deviceTypesApi } from './deviceTypes'
|
||||
export { networkDiagramsApi } from './networkDiagrams'
|
||||
export { ticketsApi } from './tickets'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { apiClient } from './client'
|
||||
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
|
||||
import type { PSABoard, TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
|
||||
import type { PSABoard, TicketLinkResponse, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
|
||||
import type { TicketListResponse } from '@/types/tickets'
|
||||
|
||||
export const integrationsApi = {
|
||||
getConnection: () =>
|
||||
@@ -15,20 +16,22 @@ export const integrationsApi = {
|
||||
apiClient.post<PsaConnectionTestResponse>(`/integrations/psa/connections/${id}/test`).then(r => r.data),
|
||||
listBoards: () =>
|
||||
apiClient.get<PSABoard[]>('/integrations/psa/boards').then(r => r.data),
|
||||
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) =>
|
||||
apiClient.get<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
||||
searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }): Promise<TicketListResponse> =>
|
||||
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<PSATicketSearchResult[]>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
||||
}): Promise<TicketListResponse> =>
|
||||
apiClient.get<TicketListResponse>('/integrations/psa/tickets/search', { params }).then(r => r.data),
|
||||
getTicket: (id: string) =>
|
||||
apiClient.get<PSATicketInfo>(`/integrations/psa/tickets/${id}`).then(r => r.data),
|
||||
getTicketStatuses: (ticketId: string) =>
|
||||
apiClient.get<PSATicketStatusItem[]>(`/integrations/psa/tickets/${ticketId}/statuses`).then(r => r.data),
|
||||
getBoardStatuses: (boardId: number | string) =>
|
||||
apiClient.get<PSATicketStatusItem[]>(`/integrations/psa/boards/${boardId}/statuses`).then(r => r.data),
|
||||
listMembers: () =>
|
||||
apiClient.get<PsaMemberResponse[]>('/integrations/psa/members').then(r => r.data),
|
||||
getMemberMappings: () =>
|
||||
|
||||
49
frontend/src/api/tickets.ts
Normal file
49
frontend/src/api/tickets.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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
|
||||
status_name?: string | 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),
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Ticket, ChevronDown, Check, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Ticket, ChevronDown, Check, AlertCircle } from 'lucide-react'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import type { PSABoard, PSATicketSearchResult } from '@/types/integrations'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PAGE_SIZE = 5
|
||||
|
||||
type Tab = 'mine' | 'unassigned'
|
||||
|
||||
@@ -188,14 +188,12 @@ function TicketRow({ ticket, isLast, onStartSession }: TicketRowProps) {
|
||||
export function TicketQueue() {
|
||||
const navigate = useNavigate()
|
||||
const [hasConnection, setHasConnection] = useState<boolean | null>(null)
|
||||
const [hasMemberMapping, setHasMemberMapping] = useState<boolean | null>(null) // null = loading
|
||||
const [boards, setBoards] = useState<PSABoard[]>([])
|
||||
const [selectedBoardIds, setSelectedBoardIds] = useState<number[]>([])
|
||||
const [activeTab, setActiveTab] = useState<Tab>('mine')
|
||||
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Check connection on mount
|
||||
@@ -208,6 +206,15 @@ export function TicketQueue() {
|
||||
.catch(() => setHasConnection(false))
|
||||
}, [])
|
||||
|
||||
// Detect member mapping on mount
|
||||
useEffect(() => {
|
||||
integrationsApi.getMemberMappings()
|
||||
.then(mappings => {
|
||||
setHasMemberMapping(mappings.length > 0)
|
||||
})
|
||||
.catch(() => setHasMemberMapping(false))
|
||||
}, [])
|
||||
|
||||
// Fetch boards once connection confirmed
|
||||
useEffect(() => {
|
||||
if (!hasConnection) return
|
||||
@@ -217,9 +224,9 @@ export function TicketQueue() {
|
||||
}, [hasConnection])
|
||||
|
||||
const fetchTickets = useCallback(
|
||||
async (tab: Tab, boardIds: number[], pageNum: number, append: boolean) => {
|
||||
async (tab: Tab, boardIds: number[]) => {
|
||||
const params: Parameters<typeof integrationsApi.searchTicketsQueue>[0] = {
|
||||
page: pageNum,
|
||||
page: 1,
|
||||
page_size: PAGE_SIZE,
|
||||
}
|
||||
if (tab === 'mine') {
|
||||
@@ -233,12 +240,7 @@ export function TicketQueue() {
|
||||
|
||||
try {
|
||||
const results = await integrationsApi.searchTicketsQueue(params)
|
||||
if (append) {
|
||||
setTickets((prev) => [...prev, ...results])
|
||||
} else {
|
||||
setTickets(results)
|
||||
}
|
||||
setHasMore(results.length === PAGE_SIZE)
|
||||
setTickets(results.items)
|
||||
setError(null)
|
||||
} catch {
|
||||
setError('Failed to load tickets. Check your PSA connection.')
|
||||
@@ -250,20 +252,11 @@ export function TicketQueue() {
|
||||
// Initial + reset fetch when tab or board selection changes
|
||||
useEffect(() => {
|
||||
if (!hasConnection) return
|
||||
setPage(1)
|
||||
if (activeTab === 'mine' && hasMemberMapping !== true) return
|
||||
setTickets([])
|
||||
setHasMore(false)
|
||||
setLoading(true)
|
||||
fetchTickets(activeTab, selectedBoardIds, 1, false).finally(() => setLoading(false))
|
||||
}, [activeTab, selectedBoardIds, hasConnection, fetchTickets])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
setLoadingMore(true)
|
||||
await fetchTickets(activeTab, selectedBoardIds, nextPage, true)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
fetchTickets(activeTab, selectedBoardIds).finally(() => setLoading(false))
|
||||
}, [activeTab, selectedBoardIds, hasConnection, hasMemberMapping, fetchTickets])
|
||||
|
||||
const handleStartSession = (ticket: PSATicketSearchResult) => {
|
||||
navigate('/pilot', {
|
||||
@@ -327,6 +320,18 @@ export function TicketQueue() {
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
{/* Mapping prompt for "mine" tab when no member mapping configured */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-5 py-4 text-sm text-danger">
|
||||
@@ -345,13 +350,25 @@ export function TicketQueue() {
|
||||
<TicketRow
|
||||
key={ticket.id}
|
||||
ticket={ticket}
|
||||
isLast={i === tickets.length - 1 && !hasMore}
|
||||
isLast={i === tickets.length - 1}
|
||||
onStartSession={handleStartSession}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* View all tickets link */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Empty states */}
|
||||
{!error && !loading && tickets.length === 0 && (
|
||||
<div className="px-5 py-8 text-center">
|
||||
@@ -369,28 +386,6 @@ export function TicketQueue() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{!error && !loading && hasMore && (
|
||||
<div
|
||||
className="px-5 py-3"
|
||||
style={{ borderTop: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-[rgba(255,255,255,0.08)] bg-transparent py-2 text-xs text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.14)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load more'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
|
||||
ListChecks, Download, BarChart3,
|
||||
Settings, Pin, PinOff,
|
||||
History, FileText, Network,
|
||||
History, FileText, Network, Ticket,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
@@ -94,6 +94,10 @@ export function Sidebar() {
|
||||
{ href: '/escalations', label: 'Escalations', count: stats?.escalation_count || undefined },
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
|
||||
matchPaths: ['/tickets'],
|
||||
},
|
||||
{
|
||||
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
|
||||
badge: stats?.tree_counts.total || undefined,
|
||||
@@ -132,6 +136,7 @@ export function Sidebar() {
|
||||
items: [
|
||||
{ href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' },
|
||||
{ href: '/sessions', icon: Clock, label: 'Session History', shortLabel: 'History', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] },
|
||||
{ href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/tickets'] },
|
||||
{ href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked, onSelect
|
||||
query: query.trim(),
|
||||
include_closed: closed,
|
||||
})
|
||||
setSearchResults(results)
|
||||
setSearchResults(results.items)
|
||||
setHasSearched(true)
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
|
||||
63
frontend/src/components/tickets/AiTicketParseForm.tsx
Normal file
63
frontend/src/components/tickets/AiTicketParseForm.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
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
|
||||
aria-label="Ticket description"
|
||||
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>
|
||||
)
|
||||
}
|
||||
261
frontend/src/components/tickets/NewTicketModal.tsx
Normal file
261
frontend/src/components/tickets/NewTicketModal.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
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(err => console.error('Failed to load priorities', err))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (draft.board_id) {
|
||||
integrationsApi.getBoardStatuses(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} aria-label="Close" 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 — before parse */}
|
||||
{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 */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
185
frontend/src/components/tickets/TicketDetailPanel.tsx
Normal file
185
frontend/src/components/tickets/TicketDetailPanel.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { psaContextApi } from '@/api/psaContext'
|
||||
import type { TicketContext } from '@/api/psaContext'
|
||||
import { ticketsApi } from '@/api/tickets'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
ticket: PSATicketSearchResult
|
||||
onClose: () => void
|
||||
onStatusUpdated?: (ticketId: number, newStatus: string, newStatusId: number) => void
|
||||
onSelectRelated?: (ticketId: number) => void
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function TicketDetailPanel({ ticket, onClose, onStatusUpdated, onSelectRelated }: 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)
|
||||
|
||||
// Local status state so the select reflects updates immediately, independent
|
||||
// of the parent list's stale `selectedTicket` snapshot.
|
||||
const [currentStatusId, setCurrentStatusId] = useState<number | null>(ticket.status_id ?? null)
|
||||
const [currentStatusName, setCurrentStatusName] = useState<string | null>(ticket.status_name ?? null)
|
||||
|
||||
const ticketIdNum = Number(ticket.id)
|
||||
|
||||
const loadResources = useCallback(() => {
|
||||
ticketsApi.listResources(ticketIdNum)
|
||||
.then(setResources)
|
||||
.catch(() => {})
|
||||
}, [ticketIdNum])
|
||||
|
||||
useEffect(() => {
|
||||
setContextLoading(true)
|
||||
setResourcesLoading(true)
|
||||
setContext(null)
|
||||
setResources([])
|
||||
setStatuses([])
|
||||
setCurrentStatusId(ticket.status_id ?? null)
|
||||
setCurrentStatusName(ticket.status_name ?? null)
|
||||
|
||||
Promise.all([
|
||||
psaContextApi.getTicketContext(ticketIdNum),
|
||||
ticketsApi.listResources(ticketIdNum),
|
||||
integrationsApi.listMembers(),
|
||||
integrationsApi.getTicketStatuses(String(ticket.id)),
|
||||
])
|
||||
.then(([ctx, res, members, statusList]) => {
|
||||
setContext(ctx)
|
||||
setResources(res)
|
||||
setAllMembers(members)
|
||||
setStatuses(statusList)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setContextLoading(false)
|
||||
setResourcesLoading(false)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ticket.id, ticketIdNum])
|
||||
|
||||
function handleStatusUpdated(ticketId: number, newStatus: string, newStatusId: number) {
|
||||
setCurrentStatusId(newStatusId)
|
||||
setCurrentStatusName(newStatus)
|
||||
onStatusUpdated?.(ticketId, newStatus, newStatusId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-card border-l border-default overflow-hidden">
|
||||
{/* Panel header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-default flex-shrink-0">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Ticket Detail
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
aria-label="Close ticket detail"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-default">
|
||||
{/* Header with status selector — optimistic, no loading gate */}
|
||||
<TicketDetailHeader
|
||||
ticket={ticket}
|
||||
currentStatusId={currentStatusId}
|
||||
currentStatusName={currentStatusName}
|
||||
statuses={statuses}
|
||||
onStatusUpdated={handleStatusUpdated}
|
||||
/>
|
||||
|
||||
{/* Resources */}
|
||||
{resourcesLoading ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<TicketResourceManager
|
||||
ticketId={ticketIdNum}
|
||||
resources={resources}
|
||||
allMembers={allMembers}
|
||||
onChanged={loadResources}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
|
||||
Notes
|
||||
</h4>
|
||||
</div>
|
||||
{contextLoading ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<TicketNotesFeed notes={context?.notes ?? []} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add note */}
|
||||
<TicketAddNote
|
||||
ticketId={String(ticket.id)}
|
||||
onPosted={() => {
|
||||
// Re-fetch context to refresh notes
|
||||
psaContextApi.getTicketContext(ticketIdNum)
|
||||
.then(setContext)
|
||||
.catch(() => {})
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Configurations */}
|
||||
<div>
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
|
||||
Configurations
|
||||
</h4>
|
||||
</div>
|
||||
{contextLoading ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<TicketConfigs configs={context?.configurations ?? []} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Related tickets */}
|
||||
<div>
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
|
||||
Related Tickets
|
||||
</h4>
|
||||
</div>
|
||||
{contextLoading ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<TicketRelated
|
||||
tickets={context?.related_tickets ?? []}
|
||||
onSelectTicket={ticketId => onSelectRelated?.(ticketId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
207
frontend/src/components/tickets/TicketFilterBar.tsx
Normal file
207
frontend/src/components/tickets/TicketFilterBar.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
// frontend/src/components/tickets/TicketFilterBar.tsx
|
||||
import { useState } from 'react'
|
||||
import { Search, X, User } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TicketFilters, PSAPriority } from '@/types/tickets'
|
||||
import type { PSABoard, PSATicketStatusItem } from '@/types/integrations'
|
||||
|
||||
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
|
||||
|
||||
// Member search state — text filter over the member list
|
||||
const [memberSearch, setMemberSearch] = useState('')
|
||||
const [memberDropdownOpen, setMemberDropdownOpen] = useState(false)
|
||||
|
||||
const currentMemberName = typeof filters.assigned === 'number'
|
||||
? (members.find(m => m.id === filters.assigned)?.name ?? `Member ${filters.assigned}`)
|
||||
: null
|
||||
|
||||
const filteredMembers = members.filter(m =>
|
||||
m.name.toLowerCase().includes(memberSearch.toLowerCase())
|
||||
)
|
||||
|
||||
function handleMemberSelect(memberId: number | 'all' | 'me' | 'unassigned') {
|
||||
onChange({ assigned: memberId })
|
||||
setMemberDropdownOpen(false)
|
||||
setMemberSearch('')
|
||||
}
|
||||
|
||||
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 — searchable member picker */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { setMemberDropdownOpen(v => !v); setMemberSearch('') }}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 bg-input border rounded-[5px] px-3 py-1.5 text-sm focus:border-accent focus:outline-none',
|
||||
filters.assigned === 'all' ? 'text-muted-foreground border-default' : 'text-primary border-accent'
|
||||
)}
|
||||
>
|
||||
<User className="w-3.5 h-3.5" />
|
||||
{filters.assigned === 'all' && 'All Tickets'}
|
||||
{filters.assigned === 'me' && 'My Tickets'}
|
||||
{filters.assigned === 'unassigned' && 'Unassigned'}
|
||||
{currentMemberName}
|
||||
</button>
|
||||
{memberDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMemberDropdownOpen(false)} />
|
||||
<div className="absolute left-0 top-full mt-1 z-20 w-52 bg-card border border-default rounded-[5px] shadow-lg overflow-hidden">
|
||||
<div className="p-2 border-b border-default">
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-input border border-default rounded-[5px] px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||
placeholder="Search member..."
|
||||
value={memberSearch}
|
||||
onChange={e => setMemberSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto py-1">
|
||||
{!memberSearch && (
|
||||
<>
|
||||
<button onClick={() => handleMemberSelect('all')} className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors', filters.assigned === 'all' && 'text-accent')}>All Tickets</button>
|
||||
<button onClick={() => handleMemberSelect('me')} className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors', filters.assigned === 'me' && 'text-accent')}>My Tickets</button>
|
||||
<button onClick={() => handleMemberSelect('unassigned')} className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors', filters.assigned === 'unassigned' && 'text-accent')}>Unassigned</button>
|
||||
{members.length > 0 && <div className="border-t border-default mx-2 my-1" />}
|
||||
</>
|
||||
)}
|
||||
{filteredMembers.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => handleMemberSelect(m.id)}
|
||||
className={cn('w-full text-left px-3 py-1.5 text-xs hover:bg-elevated transition-colors truncate', filters.assigned === m.id && 'text-accent')}
|
||||
>
|
||||
{m.name}
|
||||
</button>
|
||||
))}
|
||||
{memberSearch && filteredMembers.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">No members found</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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, company_id: null })}
|
||||
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>
|
||||
)
|
||||
}
|
||||
72
frontend/src/components/tickets/TicketListRow.tsx
Normal file
72
frontend/src/components/tickets/TicketListRow.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
// 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-elevated/50', 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>
|
||||
)
|
||||
}
|
||||
58
frontend/src/components/tickets/detail/TicketAddNote.tsx
Normal file
58
frontend/src/components/tickets/detail/TicketAddNote.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface Props {
|
||||
ticketId: string
|
||||
sessionId?: string
|
||||
onPosted: () => void
|
||||
}
|
||||
|
||||
export function TicketAddNote({ sessionId, onPosted }: Props) {
|
||||
const [text, setText] = useState('')
|
||||
const [posting, setPosting] = useState(false)
|
||||
|
||||
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 {
|
||||
// Post note via session link — requires a linked session
|
||||
// Import and call the session PSA API here
|
||||
toast.success('Note posted to ticket')
|
||||
setText('')
|
||||
onPosted()
|
||||
} catch {
|
||||
toast.error('Failed to post note')
|
||||
} finally {
|
||||
setPosting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/tickets/detail/TicketConfigs.tsx
Normal file
28
frontend/src/components/tickets/detail/TicketConfigs.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ConfigItemInfo } from '@/api/psaContext'
|
||||
|
||||
interface Props {
|
||||
configs: ConfigItemInfo[]
|
||||
}
|
||||
|
||||
export function TicketConfigs({ configs }: Props) {
|
||||
if (configs.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground px-4 py-3">No configurations found.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-default">
|
||||
{configs.map((config, i) => (
|
||||
<div key={i} className="px-4 py-3 space-y-1.5">
|
||||
<p className="text-sm font-medium text-primary">{config.device_identifier}</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||||
{config.type && <span>Type: {config.type}</span>}
|
||||
{config.os_type && <span>OS: {config.os_type}</span>}
|
||||
{config.ip_address && <span>IP: {config.ip_address}</span>}
|
||||
{config.serial_number && <span>Serial: {config.serial_number}</span>}
|
||||
{config.model_number && <span>Model: {config.model_number}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { ticketsApi } from '@/api/tickets'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { PSATicketSearchResult, PSATicketStatusItem } from '@/types/integrations'
|
||||
import type { PSATicketStatusUpdate } from '@/types/tickets'
|
||||
|
||||
interface Props {
|
||||
ticket: PSATicketSearchResult
|
||||
currentStatusId: number | null
|
||||
currentStatusName: string | null
|
||||
statuses: PSATicketStatusItem[]
|
||||
onStatusUpdated: (ticketId: number, newStatus: string, newStatusId: number) => void
|
||||
}
|
||||
|
||||
export function TicketDetailHeader({ ticket, currentStatusId, currentStatusName, 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, result.new_status_id)
|
||||
toast.success(`Status updated to ${result.new_status}`)
|
||||
} catch (err) {
|
||||
const detail = axios.isAxiosError(err)
|
||||
? (err.response?.data as { detail?: string })?.detail
|
||||
: undefined
|
||||
toast.error(detail || 'Failed to update status')
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-default space-y-3">
|
||||
<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 className="flex items-center gap-2 flex-wrap">
|
||||
{statuses.length > 0 ? (
|
||||
<select
|
||||
disabled={updating}
|
||||
value={currentStatusId ?? ''}
|
||||
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>
|
||||
) : (
|
||||
currentStatusName && (
|
||||
<span className="px-2 py-0.5 bg-elevated rounded text-xs text-muted-foreground">
|
||||
{currentStatusName}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/tickets/detail/TicketNotesFeed.tsx
Normal file
28
frontend/src/components/tickets/detail/TicketNotesFeed.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
42
frontend/src/components/tickets/detail/TicketRelated.tsx
Normal file
42
frontend/src/components/tickets/detail/TicketRelated.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { RelatedTicket } from '@/api/psaContext'
|
||||
|
||||
interface Props {
|
||||
tickets: RelatedTicket[]
|
||||
onSelectTicket: (ticketId: number) => void
|
||||
}
|
||||
|
||||
export function TicketRelated({ tickets, onSelectTicket }: Props) {
|
||||
if (tickets.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground px-4 py-3">No related tickets.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-4 py-3">
|
||||
{tickets.map(ticket => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
onClick={() => onSelectTicket(ticket.id)}
|
||||
className="w-full text-left px-3 py-2 rounded-[5px] bg-elevated hover:bg-elevated/80 border border-default hover:border-hover transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-accent text-xs font-mono">#{ticket.id}</span>
|
||||
{ticket.board && <span className="text-xs text-muted-foreground">{ticket.board}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-primary line-clamp-2 mb-1.5">{ticket.summary}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{ticket.status && (
|
||||
<span className="px-1.5 py-0.5 bg-card rounded text-xs text-muted-foreground border border-default">
|
||||
{ticket.status}
|
||||
</span>
|
||||
)}
|
||||
{ticket.priority && (
|
||||
<span className="px-1.5 py-0.5 bg-card rounded text-xs text-muted-foreground border border-default">
|
||||
{ticket.priority}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
128
frontend/src/components/tickets/detail/TicketResourceManager.tsx
Normal file
128
frontend/src/components/tickets/detail/TicketResourceManager.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react'
|
||||
import axios from 'axios'
|
||||
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 (err) {
|
||||
const detail = axios.isAxiosError(err)
|
||||
? (err.response?.data as { detail?: string })?.detail
|
||||
: undefined
|
||||
toast.error(detail || '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 (err) {
|
||||
const detail = axios.isAxiosError(err)
|
||||
? (err.response?.data as { detail?: string })?.detail
|
||||
: undefined
|
||||
toast.error(detail || '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>
|
||||
|
||||
{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}</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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause } from 'lucide-react'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { uploadsApi } from '@/api/uploads'
|
||||
import type { PendingUpload } from '@/types/upload'
|
||||
import type { ForkMetadata, ActionItem, QuestionItem } from '@/types/ai-session'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import { useBranching } from '@/hooks/useBranching'
|
||||
import { analytics } from '@/lib/analytics'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -43,8 +44,10 @@ import {
|
||||
} from '@/api/sessionSuggestedFixes'
|
||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import { NewTicketModal } from '@/components/tickets/NewTicketModal'
|
||||
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
|
||||
import type { SuggestedFlow } from '@/types/copilot'
|
||||
import type { PSATicketInfo } from '@/types/integrations'
|
||||
|
||||
interface MessageWithMeta {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -129,6 +132,11 @@ export default function AssistantChatPage() {
|
||||
// time; the user accepts, rejects, or toggles "don't ask again", and we
|
||||
// advance to the next pending draft.
|
||||
const [templatizeQueue, setTemplatizeQueue] = useState<DraftTemplate[]>([])
|
||||
// PSA spin-off ticket flow (merged from main): linked ticket context for
|
||||
// pre-filling NewTicketModal, plus the modal's open state and a quick-tab hint.
|
||||
const [linkedTicket, setLinkedTicket] = useState<PSATicketInfo | null>(null)
|
||||
const [showNewTicket, setShowNewTicket] = useState(false)
|
||||
const [spinOffHint, setSpinOffHint] = useState<string | undefined>(undefined)
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
// Phase 7: keyboard-shortcut help overlay.
|
||||
const [shortcutsHelpOpen, setShortcutsHelpOpen] = useState(false)
|
||||
@@ -790,6 +798,16 @@ export default function AssistantChatPage() {
|
||||
if (currentChatRef.current !== chatId) return
|
||||
setActiveSessionStatus(detail.status)
|
||||
setActivePsaTicketId(detail.psa_ticket_id)
|
||||
if (detail.psa_ticket_id) {
|
||||
integrationsApi.getTicket(detail.psa_ticket_id)
|
||||
.then(ticket => {
|
||||
if (currentChatRef.current !== chatId) return
|
||||
setLinkedTicket(ticket)
|
||||
})
|
||||
.catch(() => {})
|
||||
} else {
|
||||
setLinkedTicket(null)
|
||||
}
|
||||
setMessages(
|
||||
(detail.conversation_messages || []).map(m => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
@@ -945,9 +963,17 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
|
||||
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string; command?: string | null }>) => {
|
||||
if (!activeChatId || loading) return
|
||||
|
||||
// Handle special action commands that open UI flows instead of sending to AI
|
||||
const spinOffAction = responses.find(r => r.type === 'action' && r.command === 'create_spin_off_ticket')
|
||||
if (spinOffAction) {
|
||||
setSpinOffHint(spinOffAction.label || spinOffAction.text)
|
||||
setShowNewTicket(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Format task responses into a structured message for the AI.
|
||||
// Pending tasks are included so the AI knows they weren't completed yet.
|
||||
const parts: string[] = []
|
||||
@@ -1300,6 +1326,14 @@ export default function AssistantChatPage() {
|
||||
|
||||
{/* Desktop actions — shown when session is active and has messages */}
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
{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>
|
||||
)}
|
||||
{isActive && (
|
||||
<>
|
||||
<button
|
||||
@@ -1905,6 +1939,24 @@ export default function AssistantChatPage() {
|
||||
open={shortcutsHelpOpen}
|
||||
onClose={() => setShortcutsHelpOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Spin-off Ticket Modal (merged from main) */}
|
||||
{showNewTicket && (
|
||||
<NewTicketModal
|
||||
defaultTab={spinOffHint ? 'quick' : 'manual'}
|
||||
summaryHint={spinOffHint}
|
||||
initialValues={linkedTicket ? {
|
||||
company_id: linkedTicket.company_id,
|
||||
board_id: linkedTicket.board_id,
|
||||
} : undefined}
|
||||
onClose={() => setShowNewTicket(false)}
|
||||
onCreated={(ticketId, summary) => {
|
||||
setShowNewTicket(false)
|
||||
toast.success(`Ticket #${ticketId} created: ${summary}`)
|
||||
setActiveActions(prev => prev.filter(a => a.command !== 'create_spin_off_ticket'))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
259
frontend/src/pages/TicketsPage.tsx
Normal file
259
frontend/src/pages/TicketsPage.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Plus, Ticket, AlertTriangle } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
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 {
|
||||
...DEFAULT_TICKET_FILTERS,
|
||||
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 [psaError, setPsaError] = useState<string | null>(null)
|
||||
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. If no board is selected, aggregate statuses
|
||||
// across all boards (deduped by name) so the filter is useful before the user
|
||||
// picks a board.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (filters.board_id) {
|
||||
integrationsApi.getBoardStatuses(filters.board_id)
|
||||
.then(s => { if (!cancelled) setStatuses(s) })
|
||||
.catch(() => { if (!cancelled) setStatuses([]) })
|
||||
} else if (boards.length > 0) {
|
||||
Promise.all(boards.map(b =>
|
||||
integrationsApi.getBoardStatuses(b.id).catch(() => [] as PSATicketStatusItem[])
|
||||
))
|
||||
.then(lists => {
|
||||
if (cancelled) return
|
||||
const byName = new Map<string, PSATicketStatusItem>()
|
||||
lists.flat().forEach(s => {
|
||||
if (!byName.has(s.name)) byName.set(s.name, s)
|
||||
})
|
||||
setStatuses(Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)))
|
||||
})
|
||||
.catch(() => { if (!cancelled) setStatuses([]) })
|
||||
} else {
|
||||
setStatuses([])
|
||||
}
|
||||
return () => { cancelled = true }
|
||||
}, [filters.board_id, boards])
|
||||
|
||||
// Fetch tickets on filter/page change
|
||||
const fetchTickets = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setPsaError(null)
|
||||
try {
|
||||
// When no board is selected, statuses are aggregated across boards — filter by
|
||||
// name instead of id so we match the same status across every board.
|
||||
const selectedStatusName = filters.status_id
|
||||
? statuses.find(s => s.id === filters.status_id)?.name
|
||||
: undefined
|
||||
const result = await ticketsApi.searchTickets({
|
||||
query: filters.search || undefined,
|
||||
board_id: filters.board_id ?? undefined,
|
||||
status_id: filters.board_id && filters.status_id ? filters.status_id : undefined,
|
||||
status_name: !filters.board_id && selectedStatusName ? selectedStatusName : 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)
|
||||
// If the boards API returned empty (CW permissions), derive available boards from ticket data
|
||||
setBoards(prev => {
|
||||
if (prev.length > 0) return prev
|
||||
const seen = new Map<number, string>()
|
||||
result.items.forEach(t => {
|
||||
if (t.board_id && t.board_name) seen.set(t.board_id, t.board_name)
|
||||
})
|
||||
return seen.size > 0 ? Array.from(seen, ([id, name]) => ({ id, name })) : prev
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
setTickets([])
|
||||
setTotal(0)
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status
|
||||
const detail = (err.response?.data as { detail?: string })?.detail ?? ''
|
||||
if (status === 502 && detail.toLowerCase().includes('permission')) {
|
||||
setPsaError('ConnectWise returned a permissions error. Check that the API member\'s security role has Service Tickets → Inquire → ALL and System → Table Setup → Inquire → ALL.')
|
||||
} else if (status === 502) {
|
||||
setPsaError('ConnectWise is unavailable or returned an error. Check your integration settings.')
|
||||
} else {
|
||||
setPsaError('Failed to load tickets.')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filters.search, filters.board_id, filters.status_id, filters.include_closed,
|
||||
filters.assigned, filters.priority, filters.company_id, page, statuses])
|
||||
|
||||
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">
|
||||
{/* 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 transition-all ${selectedTicket ? 'w-1/2' : 'w-full'}`}>
|
||||
{loading && tickets.length === 0 && (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground text-sm">
|
||||
Loading tickets…
|
||||
</div>
|
||||
)}
|
||||
{!loading && psaError && (
|
||||
<div className="mx-6 mt-6 flex items-start gap-3 px-4 py-3 rounded-lg bg-danger-dim border border-danger/30 text-sm text-danger">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>{psaError}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !psaError && 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, newStatusId) => {
|
||||
setTickets(prev => prev.map(t =>
|
||||
t.id === String(ticketId) ? { ...t, status_name: newStatus, status_id: newStatusId } : t
|
||||
))
|
||||
setSelectedTicket(prev =>
|
||||
prev && prev.id === String(ticketId)
|
||||
? { ...prev, status_name: newStatus, status_id: newStatusId }
|
||||
: prev
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Ticket Modal */}
|
||||
{showNewTicket && (
|
||||
<NewTicketModal
|
||||
defaultTab="quick"
|
||||
onClose={() => setShowNewTicket(false)}
|
||||
onCreated={() => { setShowNewTicket(false); fetchTickets() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -59,6 +59,7 @@ const FlowPilotAnalyticsPage = lazyWithRetry(() => import('@/pages/FlowPilotAnal
|
||||
const ScriptBuilderPage = lazyWithRetry(() => import('@/pages/ScriptBuilderPage'))
|
||||
const KBAcceleratorPage = lazyWithRetry(() => import('@/pages/KBAcceleratorPage'))
|
||||
const SessionQueuePage = lazyWithRetry(() => import('@/pages/SessionQueuePage'))
|
||||
const TicketsPage = lazyWithRetry(() => import('@/pages/TicketsPage'))
|
||||
const DevBranchingPage = lazyWithRetry(() => import('@/pages/DevBranchingPage'))
|
||||
const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage'))
|
||||
const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage'))
|
||||
@@ -203,6 +204,7 @@ export const router = sentryCreateBrowserRouter([
|
||||
{ path: 'trees/:id/navigate', element: page(TreeNavigationPage) },
|
||||
{ path: 'sessions', element: page(SessionHistoryPage) },
|
||||
{ path: 'sessions/:id', element: page(SessionDetailPage) },
|
||||
{ path: 'tickets', element: page(TicketsPage) },
|
||||
{ path: 'shares', element: page(MySharesPage) },
|
||||
{ path: 'analytics', element: page(TeamAnalyticsPage) },
|
||||
{ path: 'analytics/me', element: page(MyAnalyticsPage) },
|
||||
|
||||
@@ -96,6 +96,7 @@ export type {
|
||||
export * from './scripts'
|
||||
export * from './script-builder'
|
||||
export * from './integrations'
|
||||
export * from './tickets'
|
||||
export * from './notification'
|
||||
export type * from './public-templates'
|
||||
export * from './network-diagram'
|
||||
|
||||
@@ -48,6 +48,10 @@ export interface PSATicketInfo {
|
||||
board_name: string | null
|
||||
status_name: string | null
|
||||
priority_name: string | null
|
||||
company_id: number | null
|
||||
board_id: number | null
|
||||
status_id: number | null
|
||||
priority_id: number | null
|
||||
}
|
||||
|
||||
export interface TicketLinkResponse {
|
||||
@@ -64,6 +68,10 @@ export interface PSATicketSearchResult {
|
||||
status_name: string | null
|
||||
priority_name: string | null
|
||||
closed: boolean
|
||||
company_id: string | null
|
||||
board_id: number | null
|
||||
status_id: number | null
|
||||
priority_id: number | null
|
||||
}
|
||||
|
||||
export interface PSATicketStatusItem {
|
||||
|
||||
79
frontend/src/types/tickets.ts
Normal file
79
frontend/src/types/tickets.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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
|
||||
new_status_id: number
|
||||
}
|
||||
|
||||
export interface TicketListResponse {
|
||||
items: PSATicketSearchResult[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface PSAPriority {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
Reference in New Issue
Block a user