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:
@@ -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>
|
||||||
<Button
|
|
||||||
onClick={() => router.push(`/tickets/${selectedTicket.id}`)}
|
<div className="mt-3 flex flex-wrap gap-1">
|
||||||
className="mt-4 h-8 w-full bg-primary"
|
{statusOptions.filter((s) => s.key !== "all").slice(0, 4).map((s) => {
|
||||||
size="sm"
|
const allowed = selectedTicket.status !== s.key;
|
||||||
>
|
return (
|
||||||
Open ticket
|
<button
|
||||||
<ChevronRightIcon className="h-4 w-4" />
|
key={s.key}
|
||||||
</Button>
|
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
|
||||||
|
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"
|
||||||
|
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>
|
||||||
|
{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}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
|
||||||
|
{formatDistanceToNow(new Date(tx.created_at), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</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">Creator</dt>
|
|
||||||
<dd className="truncate text-foreground">{selectedTicket.creator_id}</dd>
|
|
||||||
</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">Created</dt>
|
|
||||||
<dd className="truncate text-foreground">{new Date(selectedTicket.created_at).toLocaleString()}</dd>
|
|
||||||
</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>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user