feat: add TicketContextPanel component with accordion sections
Glass-card panel showing ticket summary, status/priority/SLA, and accordion sections for Client, Contact, Devices, Notes, and Related tickets. Matches design system with font-label labels and ice-cyan accents. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
242
frontend/src/components/session/TicketContextPanel.tsx
Normal file
242
frontend/src/components/session/TicketContextPanel.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Ticket,
|
||||
Building2,
|
||||
UserCircle,
|
||||
Monitor,
|
||||
MessageSquare,
|
||||
Link2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TicketContext } from '@/api/psaContext'
|
||||
|
||||
interface TicketContextPanelProps {
|
||||
context: TicketContext | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
interface AccordionSectionProps {
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
count?: number
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function AccordionSection({ label, icon, count, children }: AccordionSectionProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="border-t border-[rgba(255,255,255,0.06)]">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[rgba(255,255,255,0.03)] transition-colors"
|
||||
>
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="flex-1 text-xs font-medium text-foreground">{label}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[0.6rem] font-label text-primary">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-3 pb-3 pt-1">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TicketContextPanel({ context, loading, error, onRefresh }: TicketContextPanelProps) {
|
||||
return (
|
||||
<div className="glass-card-static overflow-hidden rounded-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 bg-primary/5 px-3 py-2.5">
|
||||
<Ticket className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="flex-1 font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary">
|
||||
Ticket Context
|
||||
</span>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="Refresh ticket context"
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', loading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && !context && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !loading && (
|
||||
<div className="flex items-start gap-2 px-3 py-3">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-rose-400" />
|
||||
<p className="text-xs text-rose-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context content */}
|
||||
{context && !loading && (
|
||||
<>
|
||||
{/* Compact summary */}
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-label text-xs font-medium text-primary">#{context.ticket.id}</span>
|
||||
<span className="flex-1 truncate text-xs text-foreground">{context.ticket.summary}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
<span className="rounded-md bg-card px-1.5 py-0.5 font-label text-[0.6rem] text-muted-foreground border border-[rgba(255,255,255,0.06)]">
|
||||
{context.ticket.status}
|
||||
</span>
|
||||
<span className="rounded-md bg-card px-1.5 py-0.5 font-label text-[0.6rem] text-muted-foreground border border-[rgba(255,255,255,0.06)]">
|
||||
{context.ticket.priority}
|
||||
</span>
|
||||
{context.ticket.sla && (
|
||||
<span className="rounded-md bg-amber-400/10 px-1.5 py-0.5 font-label text-[0.6rem] text-amber-400 border border-amber-400/20">
|
||||
SLA: {context.ticket.sla}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[0.6875rem] text-muted-foreground">{context.company.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Client */}
|
||||
<AccordionSection label="Client" icon={<Building2 className="h-3.5 w-3.5" />}>
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="font-medium text-foreground">{context.company.name}</p>
|
||||
{context.company.type && (
|
||||
<p className="text-muted-foreground">Type: {context.company.type}</p>
|
||||
)}
|
||||
{context.company.territory && (
|
||||
<p className="text-muted-foreground">Territory: {context.company.territory}</p>
|
||||
)}
|
||||
{context.company.site && (
|
||||
<p className="text-muted-foreground">Site: {context.company.site}</p>
|
||||
)}
|
||||
{context.company.address && (
|
||||
<p className="text-muted-foreground">{context.company.address}</p>
|
||||
)}
|
||||
{context.company.phone && (
|
||||
<p className="text-muted-foreground">{context.company.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{/* Contact */}
|
||||
{context.contact && (
|
||||
<AccordionSection label="Contact" icon={<UserCircle className="h-3.5 w-3.5" />}>
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="font-medium text-foreground">{context.contact.name}</p>
|
||||
{context.contact.title && (
|
||||
<p className="text-muted-foreground">{context.contact.title}</p>
|
||||
)}
|
||||
{context.contact.email && (
|
||||
<p className="text-muted-foreground">{context.contact.email}</p>
|
||||
)}
|
||||
{context.contact.phone && (
|
||||
<p className="text-muted-foreground">{context.contact.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
||||
{/* Devices */}
|
||||
{context.configurations.length > 0 && (
|
||||
<AccordionSection
|
||||
label="Devices"
|
||||
icon={<Monitor className="h-3.5 w-3.5" />}
|
||||
count={context.configurations.length}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{context.configurations.map((cfg, i) => (
|
||||
<div key={i} className="rounded-md border border-[rgba(255,255,255,0.06)] bg-card p-2">
|
||||
<p className="text-xs font-medium text-foreground">{cfg.device_identifier}</p>
|
||||
<div className="mt-0.5 space-y-0.5 text-[0.6875rem] text-muted-foreground">
|
||||
{cfg.type && <p>Type: {cfg.type}</p>}
|
||||
{cfg.os_type && <p>OS: {cfg.os_type}</p>}
|
||||
{cfg.ip_address && <p>IP: {cfg.ip_address}</p>}
|
||||
{cfg.serial_number && <p>S/N: {cfg.serial_number}</p>}
|
||||
{cfg.model_number && <p>Model: {cfg.model_number}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{context.notes.length > 0 && (
|
||||
<AccordionSection
|
||||
label="Notes"
|
||||
icon={<MessageSquare className="h-3.5 w-3.5" />}
|
||||
count={context.notes.length}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{context.notes.map((note, i) => (
|
||||
<div key={i} className="rounded-md border border-[rgba(255,255,255,0.06)] bg-card p-2">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
{note.member && (
|
||||
<span className="text-[0.6rem] font-label text-muted-foreground">{note.member}</span>
|
||||
)}
|
||||
<span className="ml-auto text-[0.6rem] font-label text-muted-foreground">
|
||||
{new Date(note.date_created).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-[0.6875rem] text-foreground line-clamp-4">
|
||||
{note.text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
||||
{/* Related Tickets */}
|
||||
{context.related_tickets.length > 0 && (
|
||||
<AccordionSection
|
||||
label="Related"
|
||||
icon={<Link2 className="h-3.5 w-3.5" />}
|
||||
count={context.related_tickets.length}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
{context.related_tickets.map((rt) => (
|
||||
<div
|
||||
key={rt.id}
|
||||
className="rounded-md border border-[rgba(255,255,255,0.06)] bg-card px-2 py-1.5"
|
||||
>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="font-label text-[0.6rem] text-primary">#{rt.id}</span>
|
||||
<span className="flex-1 truncate text-[0.6875rem] text-foreground">{rt.summary}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex gap-1">
|
||||
<span className="text-[0.6rem] text-muted-foreground">{rt.status}</span>
|
||||
<span className="text-[0.6rem] text-muted-foreground">·</span>
|
||||
<span className="text-[0.6rem] text-muted-foreground">{rt.priority}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export { ContinuationModal, type DescendantNode } from './ContinuationModal'
|
||||
export { ForkTreeModal } from './ForkTreeModal'
|
||||
export { ScratchpadSidebar } from './ScratchpadSidebar'
|
||||
export { SessionOutcomeModal } from './SessionOutcomeModal'
|
||||
export { TicketContextPanel } from './TicketContextPanel'
|
||||
|
||||
Reference in New Issue
Block a user