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