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>
260 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|