feat(tickets): add TicketResourceManager and full TicketDetailPanel with optimistic hydration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <div>Loading ticket details…</div>
|
||||
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)
|
||||
|
||||
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 (
|
||||
<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}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
121
frontend/src/components/tickets/detail/TicketResourceManager.tsx
Normal file
121
frontend/src/components/tickets/detail/TicketResourceManager.tsx
Normal file
@@ -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<string>('')
|
||||
const [busy, setBusy] = useState<number | null>(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 (
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
|
||||
Resources
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setAdding(!adding)}
|
||||
className="flex items-center gap-1 text-xs text-accent hover:text-accent/80 transition-colors"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" /> Assign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="flex-1 bg-input border border-default rounded-[5px] px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
|
||||
value={selectedMemberId}
|
||||
onChange={e => setSelectedMemberId(e.target.value)}
|
||||
>
|
||||
<option value="">Select member…</option>
|
||||
{allMembers
|
||||
.filter(m => !assignedIds.has(Number(m.id)))
|
||||
.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!selectedMemberId || busy !== null}
|
||||
className="px-2 py-1 bg-accent text-white text-xs rounded-[5px] disabled:opacity-40"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resources.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No resources assigned.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{resources.map(r => (
|
||||
<div key={r.member_id} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs text-primary">
|
||||
<User className="w-3 h-3 text-muted-foreground" />
|
||||
{r.member_name}
|
||||
{r.is_rf_user && (
|
||||
<span className="px-1 py-0.5 bg-accent/10 text-accent rounded text-[10px] font-medium">
|
||||
RF
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(r.member_id)}
|
||||
disabled={busy === r.member_id}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-danger transition-colors',
|
||||
busy === r.member_id && 'opacity-40'
|
||||
)}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user