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>
186 lines
6.2 KiB
TypeScript
186 lines
6.2 KiB
TypeScript
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>
|
|
)
|
|
}
|