redesign: triage panel — compact, visual, actionable

- Colored status dot with glow ring in header (at-a-glance)
- Status change via colored dots (click circles, not text pills)
- Subject + ID + queue + owner in one compact line
- Open full view is now a subtle chevron button, not a giant CTA
- Take it button appears inline when unassigned
- Activity feed as a timeline with connecting line and dots
- Shorter transaction labels with inline values

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 22:25:17 +02:00
parent ed5d96a74b
commit 1f308b4342

View File

@@ -1071,113 +1071,109 @@ function TicketWorkbenchContent() {
</div>
)}
<aside className="hidden min-h-0 border-l border-border bg-card/76 backdrop-blur xl:flex xl:flex-col">
<aside className="hidden min-h-0 border-l border-border bg-card/82 xl:flex xl:flex-col">
{selectedTicket ? (
<>
<div className="border-b border-border bg-card/82 p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-mono text-[11px] font-semibold text-muted-foreground">
{formatTicketId(selectedTicket.id)}
</p>
<h2 className="mt-1 text-sm font-semibold leading-snug text-foreground line-clamp-2">
{/* Compact header */}
<div className="border-b border-border px-4 py-3">
<div className="flex items-center gap-2.5">
<span
className="h-3 w-3 shrink-0 rounded-full ring-2 ring-offset-1 ring-offset-card"
style={{
backgroundColor: STATUS_META[selectedTicket.status]?.color ?? "#71717a",
boxShadow: `0 0 0 2px color-mix(in srgb, ${STATUS_META[selectedTicket.status]?.color ?? "#71717a"} 20%, transparent)`,
}}
/>
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold leading-tight text-foreground line-clamp-2">
{selectedTicket.subject}
</h2>
<p className="mt-1 text-xs text-muted-foreground">
{queueName(queues, selectedTicket.queue_id)} · {selectedTicket.owner_id ? users.find((u) => u.id === selectedTicket.owner_id)?.username ?? "assigned" : "unassigned"}
</p>
<p className="mt-0.5 flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="font-mono">{formatTicketId(selectedTicket.id)}</span>
<span>·</span>
<span>{queueName(queues, selectedTicket.queue_id)}</span>
{selectedTicket.owner_id && (
<>
<span>·</span>
<span>{users.find((u) => u.id === selectedTicket.owner_id)?.username}</span>
</>
)}
</p>
</div>
<TicketStatusBadge status={selectedTicket.status} />
<button
onClick={() => router.push(`/tickets/${selectedTicket.id}`)}
className="shrink-0 rounded p-1 text-muted-foreground/50 hover:bg-accent hover:text-foreground"
title="Open full view"
>
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{statusOptions.filter((s) => s.key !== "all").slice(0, 4).map((s) => {
const allowed = selectedTicket.status !== s.key;
{/* Status dots — visual, clickable */}
<div className="mt-3 flex items-center gap-1.5">
{["new", "open", "in_progress", "resolved", "closed"].map((s) => {
const isCurrent = selectedTicket.status === s;
const color = STATUS_META[s]?.color ?? "#71717a";
return (
<button
key={s.key}
key={s}
type="button"
disabled={!allowed}
onClick={() => handleQuickStatus(selectedTicket.id, s.key)}
disabled={isCurrent}
onClick={() => handleQuickStatus(selectedTicket.id, s)}
title={statusLabel(s)}
className={cn(
"rounded px-2 py-1 text-[11px] font-semibold transition-colors",
selectedTicket.status === s.key
? "bg-primary/10 text-primary"
: allowed
? "bg-muted/60 text-muted-foreground hover:bg-accent hover:text-foreground"
: "cursor-default text-muted-foreground/40"
"h-3.5 w-3.5 rounded-full transition-all",
isCurrent
? "ring-2 ring-offset-1 ring-offset-card scale-125"
: "opacity-40 hover:opacity-80 hover:scale-110",
)}
>
{s.label}
</button>
style={{ backgroundColor: color }}
/>
);
})}
</div>
<div className="mt-2 flex gap-2">
<Button
onClick={() => router.push(`/tickets/${selectedTicket.id}`)}
className="h-7 flex-1 bg-primary text-xs"
size="sm"
>
Open full view
<ChevronRightIcon className="ml-1 h-3.5 w-3.5" />
</Button>
{!selectedTicket.owner_id && (
<Button
variant="outline"
size="sm"
<button
onClick={() => handleQuickAssign(selectedTicket.id)}
className="h-7 text-xs"
className="ml-auto rounded border border-border px-2 py-0.5 text-[10px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Assign to me
</Button>
Take it
</button>
)}
</div>
</div>
{/* Activity feed — compact timeline */}
<div className="flex-1 overflow-auto">
{selectedTxs.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
No activity yet.
</div>
<div className="p-4 text-center text-xs text-muted-foreground">No activity</div>
) : (
<div className="divide-y divide-border/50">
{selectedTxs.slice(0, 8).map((tx) => (
<div key={tx.id} className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold text-foreground">
{tx.transaction_type === "Create" ? "Created" :
tx.transaction_type === "StatusChange" ? "Status changed" :
tx.transaction_type === "SetOwner" ? "Owner changed" :
tx.transaction_type === "Comment" ? "Comment" :
tx.transaction_type === "Correspond" ? "Reply" :
tx.transaction_type === "SetTeam" ? "Team changed" :
tx.transaction_type === "CustomFieldChange" ? `${tx.field} set` :
tx.transaction_type}
</span>
{tx.transaction_type === "StatusChange" && tx.new_value && (
<span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold",
STATUS_META[tx.new_value]?.tone ?? "bg-muted text-muted-foreground"
)}>
{statusLabel(tx.new_value)}
</span>
)}
{tx.old_value && tx.new_value && tx.transaction_type !== "StatusChange" && (
<span className="text-[10px] text-muted-foreground">
{tx.old_value} {tx.new_value}
</span>
)}
<div className="py-1">
{selectedTxs.slice(0, 6).map((tx) => (
<div key={tx.id} className="flex gap-3 px-4 py-2">
<div className="relative flex flex-col items-center pt-0.5">
<div className="h-1.5 w-1.5 rounded-full bg-border" />
<div className="mt-1 flex-1 w-px bg-border/30" />
</div>
{tx.data && (tx.data as any).body && (
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{(tx.data as any).body}
<div className="min-w-0 flex-1 pb-1">
<p className="text-xs text-foreground/90">
{tx.transaction_type === "Create" ? "Created" :
tx.transaction_type === "StatusChange" ? `Status → ${tx.new_value ? statusLabel(tx.new_value) : "?"}` :
tx.transaction_type === "SetOwner" ? `Owner → ${tx.new_value ? users.find((u) => u.id === tx.new_value)?.username ?? "assigned" : "unassigned"}` :
tx.transaction_type === "Correspond" ? "Reply" :
tx.transaction_type === "Comment" ? "Internal note" :
tx.transaction_type === "SetTeam" ? `Team changed` :
tx.transaction_type === "CustomFieldChange" ? `${tx.field}${tx.new_value}` :
tx.transaction_type}
</p>
)}
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
{formatDistanceToNow(new Date(tx.created_at), { addSuffix: true })}
</p>
{tx.data && (tx.data as any).body && (
<p className="mt-0.5 line-clamp-2 text-[11px] leading-relaxed text-muted-foreground">
{(tx.data as any).body}
</p>
)}
<p className="mt-0.5 text-[10px] text-muted-foreground/50">
{formatDistanceToNow(new Date(tx.created_at), { addSuffix: true })}
</p>
</div>
</div>
))}
</div>
@@ -1186,11 +1182,10 @@ function TicketWorkbenchContent() {
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/50">
<ChevronRightIcon className="h-5 w-5 text-muted-foreground/40" />
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/30">
<ChevronRightIcon className="h-5 w-5 text-muted-foreground/30" />
</div>
<p className="text-sm text-muted-foreground">Select a ticket</p>
<p className="text-xs text-muted-foreground/60">Quick actions and activity will appear here</p>
<p className="text-xs text-muted-foreground">Select a ticket to triage</p>
</div>
)}
</aside>