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 { useCallback, useEffect, useState } from 'react'
|
||||||
import type { PSATicketSearchResult } from '@/types/integrations'
|
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 {
|
interface Props {
|
||||||
ticket: PSATicketSearchResult
|
ticket: PSATicketSearchResult
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onStatusUpdated?: (ticketId: number, newStatus: string) => void
|
onStatusUpdated?: (ticketId: number, newStatus: string) => void
|
||||||
|
onSelectRelated?: (ticketId: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketDetailPanel(_props: Props) {
|
function Skeleton() {
|
||||||
return <div>Loading ticket details…</div>
|
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