fix: All tickets link, redesign side panel into triage panel

- Fix sidebar All tickets link: /?view=all instead of / (avoids dashboard redirect)
- Replace useless side panel with triage command center:
  - Quick status change buttons (click to transition inline)
  - Assign to me button (appears when unassigned)
  - Mini activity feed showing last 8 transactions with type labels,
    status badges, old→new values, and comment previews
  - Relative timestamps
  - Open full view button
- Fetches ticket transactions on selection

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 14:51:51 +02:00
parent 4e285f8c4d
commit d5d6a209bd
2 changed files with 129 additions and 50 deletions

View File

@@ -17,8 +17,8 @@ import {
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards } from "@/lib/api"; import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards, getTicketTransactions, updateTicket } from "@/lib/api";
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, User } from "@/lib/types"; import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, Transaction, User } from "@/lib/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -148,6 +148,7 @@ function TicketWorkbenchContent() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedTxs, setSelectedTxs] = useState<Transaction[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<Filter[]>([]); const [filters, setFilters] = useState<Filter[]>([]);
@@ -442,7 +443,28 @@ function TicketWorkbenchContent() {
}, [clock, filters, queues, routeQueue, searchQuery, sortKey, tickets, view]); }, [clock, filters, queues, routeQueue, searchQuery, sortKey, tickets, view]);
const selectedTicket = const selectedTicket =
filteredTickets.find((ticket) => ticket.id === selectedId) ?? filteredTickets[0] ?? null; filteredTickets.find((ticket) => ticket.id === selectedId) ?? null;
// Fetch transactions when selection changes
useEffect(() => {
if (!selectedTicket) { setSelectedTxs([]); return; }
getTicketTransactions(selectedTicket.id).then(({ data }) => setSelectedTxs(data ?? []));
}, [selectedTicket?.id]);
const handleQuickStatus = async (ticketId: number, newStatus: string) => {
const { data } = await updateTicket(ticketId, { status: newStatus });
if (data) {
setTickets((prev) => prev.map((t) => (t.id === ticketId ? data.ticket : t)));
getTicketTransactions(ticketId).then(({ data: txs }) => setSelectedTxs(txs ?? []));
}
};
const handleQuickAssign = async (ticketId: number) => {
const { data } = await updateTicket(ticketId, { owner_id: users[0]?.id ?? null });
if (data) {
setTickets((prev) => prev.map((t) => (t.id === ticketId ? data.ticket : t)));
}
};
const visibleTitle = routeQueue const visibleTitle = routeQueue
? queueName(queues, routeQueue) ? queueName(queues, routeQueue)
: VIEW_LABELS[view] ?? "All tickets"; : VIEW_LABELS[view] ?? "All tickets";
@@ -776,66 +798,123 @@ function TicketWorkbenchContent() {
<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/76 backdrop-blur xl:flex xl:flex-col">
{selectedTicket ? ( {selectedTicket ? (
<> <>
<div className="border-b border-border bg-card/82 p-5"> <div className="border-b border-border bg-card/82 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="font-mono text-xs font-semibold text-muted-foreground"> <p className="font-mono text-[11px] font-semibold text-muted-foreground">
{formatTicketId(selectedTicket.id)} {formatTicketId(selectedTicket.id)}
</p> </p>
<h2 className="mt-2 text-lg font-semibold leading-snug text-foreground"> <h2 className="mt-1 text-sm font-semibold leading-snug text-foreground line-clamp-2">
{selectedTicket.subject} {selectedTicket.subject}
</h2> </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>
</div> </div>
<TicketStatusBadge status={selectedTicket.status} /> <TicketStatusBadge status={selectedTicket.status} />
</div> </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;
return (
<button
key={s.key}
type="button"
disabled={!allowed}
onClick={() => handleQuickStatus(selectedTicket.id, s.key)}
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"
)}
>
{s.label}
</button>
);
})}
</div>
<div className="mt-2 flex gap-2">
<Button <Button
onClick={() => router.push(`/tickets/${selectedTicket.id}`)} onClick={() => router.push(`/tickets/${selectedTicket.id}`)}
className="mt-4 h-8 w-full bg-primary" className="h-7 flex-1 bg-primary text-xs"
size="sm" size="sm"
> >
Open ticket Open full view
<ChevronRightIcon className="h-4 w-4" /> <ChevronRightIcon className="ml-1 h-3.5 w-3.5" />
</Button> </Button>
{!selectedTicket.owner_id && (
<Button
variant="outline"
size="sm"
onClick={() => handleQuickAssign(selectedTicket.id)}
className="h-7 text-xs"
>
Assign to me
</Button>
)}
</div>
</div> </div>
<div className="flex-1 overflow-auto p-5"> <div className="flex-1 overflow-auto">
<dl className="grid overflow-hidden rounded-md border border-border bg-background/55 text-sm"> {selectedTxs.length === 0 ? (
<div className="grid grid-cols-[104px_minmax(0,1fr)] border-b border-border px-3 py-2.5"> <div className="p-4 text-center text-xs text-muted-foreground">
<dt className="text-xs font-semibold uppercase text-muted-foreground">Queue</dt> No activity yet.
<dd className="truncate text-foreground">{queueName(queues, selectedTicket.queue_id)}</dd>
</div> </div>
<div className="grid grid-cols-[104px_minmax(0,1fr)] border-b border-border px-3 py-2.5"> ) : (
<dt className="text-xs font-semibold uppercase text-muted-foreground">Owner</dt> <div className="divide-y divide-border/50">
<dd className="truncate text-foreground">{selectedTicket.owner_id ?? "Unassigned"}</dd> {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> </div>
<div className="grid grid-cols-[104px_minmax(0,1fr)] border-b border-border px-3 py-2.5"> {tx.data && (tx.data as any).body && (
<dt className="text-xs font-semibold uppercase text-muted-foreground">Creator</dt> <p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
<dd className="truncate text-foreground">{selectedTicket.creator_id}</dd> {(tx.data as any).body}
</div> </p>
<div className="grid grid-cols-[104px_minmax(0,1fr)] border-b border-border px-3 py-2.5"> )}
<dt className="text-xs font-semibold uppercase text-muted-foreground">Created</dt> <p className="mt-0.5 text-[10px] text-muted-foreground/60">
<dd className="truncate text-foreground">{new Date(selectedTicket.created_at).toLocaleString()}</dd> {formatDistanceToNow(new Date(tx.created_at), { addSuffix: true })}
</div>
<div className="grid grid-cols-[104px_minmax(0,1fr)] px-3 py-2.5">
<dt className="text-xs font-semibold uppercase text-muted-foreground">Updated</dt>
<dd className="truncate text-foreground">{new Date(selectedTicket.updated_at).toLocaleString()}</dd>
</div>
</dl>
<div className="mt-5 rounded-md border border-border bg-accent/42 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<CheckCircle2Icon className="h-4 w-4 text-emerald-600" />
Next action
</div>
<p className="mt-2 text-sm text-muted-foreground">
Open the ticket to reply, change status, and review its transaction history.
</p> </p>
</div> </div>
))}
</div>
)}
</div> </div>
</> </>
) : ( ) : (
<div className="flex flex-1 items-center justify-center p-6 text-center text-sm text-muted-foreground"> <div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center">
Select a ticket to inspect it. <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>
<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>
</div> </div>
)} )}
</aside> </aside>

View File

@@ -162,8 +162,8 @@ function SidebarNav() {
const views = [ const views = [
{ {
label: "All tickets", label: "All tickets",
href: "/", href: "/?view=all",
param: null, param: "all",
count: counts.all, count: counts.all,
icon: LayoutGridIcon, icon: LayoutGridIcon,
}, },