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:
@@ -1071,113 +1071,109 @@ function TicketWorkbenchContent() {
|
|||||||
</div>
|
</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 ? (
|
{selectedTicket ? (
|
||||||
<>
|
<>
|
||||||
<div className="border-b border-border bg-card/82 p-4">
|
{/* Compact header */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="border-b border-border px-4 py-3">
|
||||||
<div className="min-w-0">
|
<div className="flex items-center gap-2.5">
|
||||||
<p className="font-mono text-[11px] font-semibold text-muted-foreground">
|
<span
|
||||||
{formatTicketId(selectedTicket.id)}
|
className="h-3 w-3 shrink-0 rounded-full ring-2 ring-offset-1 ring-offset-card"
|
||||||
</p>
|
style={{
|
||||||
<h2 className="mt-1 text-sm font-semibold leading-snug text-foreground line-clamp-2">
|
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}
|
{selectedTicket.subject}
|
||||||
</h2>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-0.5 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
{queueName(queues, selectedTicket.queue_id)} · {selectedTicket.owner_id ? users.find((u) => u.id === selectedTicket.owner_id)?.username ?? "assigned" : "unassigned"}
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1">
|
{/* Status dots — visual, clickable */}
|
||||||
{statusOptions.filter((s) => s.key !== "all").slice(0, 4).map((s) => {
|
<div className="mt-3 flex items-center gap-1.5">
|
||||||
const allowed = selectedTicket.status !== s.key;
|
{["new", "open", "in_progress", "resolved", "closed"].map((s) => {
|
||||||
|
const isCurrent = selectedTicket.status === s;
|
||||||
|
const color = STATUS_META[s]?.color ?? "#71717a";
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={s.key}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!allowed}
|
disabled={isCurrent}
|
||||||
onClick={() => handleQuickStatus(selectedTicket.id, s.key)}
|
onClick={() => handleQuickStatus(selectedTicket.id, s)}
|
||||||
|
title={statusLabel(s)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded px-2 py-1 text-[11px] font-semibold transition-colors",
|
"h-3.5 w-3.5 rounded-full transition-all",
|
||||||
selectedTicket.status === s.key
|
isCurrent
|
||||||
? "bg-primary/10 text-primary"
|
? "ring-2 ring-offset-1 ring-offset-card scale-125"
|
||||||
: allowed
|
: "opacity-40 hover:opacity-80 hover:scale-110",
|
||||||
? "bg-muted/60 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
: "cursor-default text-muted-foreground/40"
|
|
||||||
)}
|
)}
|
||||||
>
|
style={{ backgroundColor: color }}
|
||||||
{s.label}
|
/>
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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 && (
|
{!selectedTicket.owner_id && (
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleQuickAssign(selectedTicket.id)}
|
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
|
Take it
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Activity feed — compact timeline */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{selectedTxs.length === 0 ? (
|
{selectedTxs.length === 0 ? (
|
||||||
<div className="p-4 text-center text-xs text-muted-foreground">
|
<div className="p-4 text-center text-xs text-muted-foreground">No activity</div>
|
||||||
No activity yet.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border/50">
|
<div className="py-1">
|
||||||
{selectedTxs.slice(0, 8).map((tx) => (
|
{selectedTxs.slice(0, 6).map((tx) => (
|
||||||
<div key={tx.id} className="px-4 py-2.5">
|
<div key={tx.id} className="flex gap-3 px-4 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative flex flex-col items-center pt-0.5">
|
||||||
<span className="text-[11px] font-semibold text-foreground">
|
<div className="h-1.5 w-1.5 rounded-full bg-border" />
|
||||||
{tx.transaction_type === "Create" ? "Created" :
|
<div className="mt-1 flex-1 w-px bg-border/30" />
|
||||||
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>
|
</div>
|
||||||
{tx.data && (tx.data as any).body && (
|
<div className="min-w-0 flex-1 pb-1">
|
||||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
<p className="text-xs text-foreground/90">
|
||||||
{(tx.data as any).body}
|
{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>
|
||||||
)}
|
{tx.data && (tx.data as any).body && (
|
||||||
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
|
<p className="mt-0.5 line-clamp-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||||
{formatDistanceToNow(new Date(tx.created_at), { addSuffix: true })}
|
{(tx.data as any).body}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-0.5 text-[10px] text-muted-foreground/50">
|
||||||
|
{formatDistanceToNow(new Date(tx.created_at), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</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 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">
|
<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/40" />
|
<ChevronRightIcon className="h-5 w-5 text-muted-foreground/30" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Select a ticket</p>
|
<p className="text-xs text-muted-foreground">Select a ticket to triage</p>
|
||||||
<p className="text-xs text-muted-foreground/60">Quick actions and activity will appear here</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
Reference in New Issue
Block a user