Files
resolutionflow/frontend/src/components/tickets/TicketDetailPanel.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

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