From d7b1fe6645eb025902ac6af92b33523bf1e5c8f1 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Thu, 16 Apr 2026 03:24:18 +0000 Subject: [PATCH] feat(tickets): add TicketResourceManager and full TicketDetailPanel with optimistic hydration Co-Authored-By: Claude Sonnet 4.6 --- .../components/tickets/TicketDetailPanel.tsx | 169 +++++++++++++++++- .../tickets/detail/TicketResourceManager.tsx | 121 +++++++++++++ 2 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/tickets/detail/TicketResourceManager.tsx diff --git a/frontend/src/components/tickets/TicketDetailPanel.tsx b/frontend/src/components/tickets/TicketDetailPanel.tsx index c691559e..42125910 100644 --- a/frontend/src/components/tickets/TicketDetailPanel.tsx +++ b/frontend/src/components/tickets/TicketDetailPanel.tsx @@ -1,12 +1,173 @@ -// Placeholder — full implementation in Task 16 -import type { PSATicketSearchResult } from '@/types/integrations' +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) => void + onSelectRelated?: (ticketId: number) => void } -export function TicketDetailPanel(_props: Props) { - return
Loading ticket details…
+function Skeleton() { + return ( +
+
+
+
+ ) +} + +export function TicketDetailPanel({ ticket, onClose, onStatusUpdated, onSelectRelated }: Props) { + const [context, setContext] = useState(null) + const [resources, setResources] = useState([]) + const [allMembers, setAllMembers] = useState([]) + const [statuses, setStatuses] = useState([]) + const [contextLoading, setContextLoading] = useState(true) + const [resourcesLoading, setResourcesLoading] = useState(true) + + const ticketIdNum = Number(ticket.id) + + const loadResources = useCallback(() => { + ticketsApi.listResources(ticketIdNum) + .then(setResources) + .catch(() => {}) + }, [ticketIdNum]) + + useEffect(() => { + setContextLoading(true) + setResourcesLoading(true) + setContext(null) + setResources([]) + setStatuses([]) + + 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) + }) + }, [ticket.id, ticketIdNum]) + + function handleStatusUpdated(ticketId: number, newStatus: string) { + onStatusUpdated?.(ticketId, newStatus) + } + + return ( +
+ {/* Panel header */} +
+ + Ticket Detail + + +
+ + {/* Scrollable body */} +
+ {/* Header with status selector — optimistic, no loading gate */} + + + {/* Resources */} + {resourcesLoading ? ( + + ) : ( + + )} + + {/* Notes */} +
+
+

+ Notes +

+
+ {contextLoading ? ( + + ) : ( + + )} +
+ + {/* Add note */} + { + // Re-fetch context to refresh notes + psaContextApi.getTicketContext(ticketIdNum) + .then(setContext) + .catch(() => {}) + }} + /> + + {/* Configurations */} +
+
+

+ Configurations +

+
+ {contextLoading ? ( + + ) : ( + + )} +
+ + {/* Related tickets */} +
+
+

+ Related Tickets +

+
+ {contextLoading ? ( + + ) : ( + onSelectRelated?.(ticketId)} + /> + )} +
+
+
+ ) } diff --git a/frontend/src/components/tickets/detail/TicketResourceManager.tsx b/frontend/src/components/tickets/detail/TicketResourceManager.tsx new file mode 100644 index 00000000..b68bdde7 --- /dev/null +++ b/frontend/src/components/tickets/detail/TicketResourceManager.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' +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('') + const [busy, setBusy] = useState(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 { + toast.error('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 { + toast.error('Failed to remove resource') + } finally { + setBusy(null) + } + } + + const assignedIds = new Set(resources.map(r => r.member_id)) + + return ( +
+
+

+ Resources +

+ +
+ + {adding && ( +
+ + +
+ )} + + {resources.length === 0 ? ( +

No resources assigned.

+ ) : ( +
+ {resources.map(r => ( +
+
+ + {r.member_name} + {r.is_rf_user && ( + + RF + + )} +
+ +
+ ))} +
+ )} +
+ ) +}