Files
resolutionflow/frontend/src/pages/TicketsPage.tsx
Michael Chihlas 04ff2ea301
Some checks failed
Mirror to GitHub / mirror (push) Successful in 3s
CI / backend (pull_request) Failing after 17m32s
CI / frontend (pull_request) Failing after 48s
CI / e2e (pull_request) Has been skipped
fix(tickets): refresh status and resources in detail panel after update
Status update was returning only new_status (string) and the parent list's
onStatusUpdated only set status_name. The <select> was bound to status_id,
which never changed — so it visually reverted to the old status even though
the PATCH succeeded.

- Backend: include new_status_id in the status-update response.
- Panel: own currentStatusId/currentStatusName state so the select reflects
  the change immediately and survives stale parent snapshots.
- Parent list: update status_id on both the row and selectedTicket so the
  list row stays in sync when the panel stays open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 21:28:48 +00:00

260 lines
11 KiB
TypeScript

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>
)
}