feat: customizable, resizable columns on ticket list, saved per view

- Column picker dropdown (grid icon next to sort/density)
  - Check/uncheck columns: ID, Subject, Status, Queue, Owner, Created, Updated
  - Subject column auto-expands (flex), others have fixed width
- Column resize handles: drag right edge of any column header
  - Min 50px, max 800px, body gets select-none during drag
- Columns persist with saved views (columns jsonb field)
- Reset to defaults when navigating away from a saved view
- Sticky column header row with muted background

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 14:57:34 +02:00
parent d5d6a209bd
commit 7ddf82f93f
2 changed files with 205 additions and 46 deletions

View File

@@ -8,6 +8,7 @@ import {
CheckCircle2Icon, CheckCircle2Icon,
ChevronRightIcon, ChevronRightIcon,
GaugeIcon, GaugeIcon,
LayoutGridIcon,
LayoutListIcon, LayoutListIcon,
PlusIcon, PlusIcon,
RefreshCwIcon, RefreshCwIcon,
@@ -47,6 +48,27 @@ const VIEW_LABELS: Record<string, string> = {
type Density = "comfortable" | "compact"; type Density = "comfortable" | "compact";
type SortKey = "updated" | "created" | "id"; type SortKey = "updated" | "created" | "id";
interface ColumnConfig {
key: string;
label: string;
width: number; // px
visible: boolean;
}
const ALL_COLUMNS: ColumnConfig[] = [
{ key: "id", label: "ID", width: 100, visible: true },
{ key: "subject", label: "Subject", width: 320, visible: true },
{ key: "status", label: "Status", width: 120, visible: true },
{ key: "queue", label: "Queue", width: 140, visible: true },
{ key: "owner", label: "Owner", width: 130, visible: true },
{ key: "created", label: "Created", width: 130, visible: false },
{ key: "updated", label: "Updated", width: 130, visible: false },
];
function defaultColumns(): ColumnConfig[] {
return ALL_COLUMNS.map((c) => ({ ...c }));
}
interface Filter { interface Filter {
id: string; id: string;
field: string; // "status" | "queue" | "owner" | custom field key ("cf.<key>") field: string; // "status" | "queue" | "owner" | custom field key ("cf.<key>")
@@ -152,8 +174,11 @@ function TicketWorkbenchContent() {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<Filter[]>([]); const [filters, setFilters] = useState<Filter[]>([]);
const [columns, setColumns] = useState<ColumnConfig[]>(defaultColumns);
const [density, setDensity] = useState<Density>("comfortable"); const [density, setDensity] = useState<Density>("comfortable");
const [sortKey, setSortKey] = useState<SortKey>("updated"); const [sortKey, setSortKey] = useState<SortKey>("updated");
const [resizingCol, setResizingCol] = useState<string | null>(null);
const [colPickerOpen, setColPickerOpen] = useState(false);
// Saved views // Saved views
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]); const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
@@ -345,12 +370,16 @@ function TicketWorkbenchContent() {
})) }))
); );
if (view.sort_key) setSortKey(view.sort_key as SortKey); if (view.sort_key) setSortKey(view.sort_key as SortKey);
if (view.columns && Array.isArray(view.columns) && view.columns.length > 0) {
setColumns(view.columns as ColumnConfig[]);
}
} }
}); });
} else if (!paramViewId && viewIdFromUrl) { } else if (!paramViewId && viewIdFromUrl) {
// User navigated away from a view — clear filters // User navigated away from a view — clear filters and reset columns
setFilters([]); setFilters([]);
setSearchQuery(""); setSearchQuery("");
setColumns(defaultColumns());
} }
}, [searchParams]); }, [searchParams]);
@@ -465,6 +494,31 @@ function TicketWorkbenchContent() {
setTickets((prev) => prev.map((t) => (t.id === ticketId ? data.ticket : t))); setTickets((prev) => prev.map((t) => (t.id === ticketId ? data.ticket : t)));
} }
}; };
const handleColumnResize = (colKey: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setResizingCol(colKey);
const startX = e.clientX;
const col = columns.find((c) => c.key === colKey);
const startWidth = col?.width ?? 120;
const onMove = (ev: MouseEvent) => {
const newWidth = Math.max(50, Math.min(800, startWidth + (ev.clientX - startX)));
setColumns((prev) =>
prev.map((c) => (c.key === colKey ? { ...c, width: newWidth } : c))
);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.classList.remove("select-none");
setResizingCol(null);
};
document.body.classList.add("select-none");
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
const visibleTitle = routeQueue const visibleTitle = routeQueue
? queueName(queues, routeQueue) ? queueName(queues, routeQueue)
: VIEW_LABELS[view] ?? "All tickets"; : VIEW_LABELS[view] ?? "All tickets";
@@ -624,6 +678,43 @@ function TicketWorkbenchContent() {
<SlidersHorizontalIcon className="h-4 w-4" /> <SlidersHorizontalIcon className="h-4 w-4" />
)} )}
</button> </button>
<div className="relative">
<button
type="button"
onClick={() => setColPickerOpen((v) => !v)}
className="inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-card/90 px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
title="Choose columns"
>
<LayoutGridIcon className="h-4 w-4" />
</button>
{colPickerOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setColPickerOpen(false)} />
<div className="absolute right-0 top-full z-50 mt-1 w-48 rounded-md border border-border bg-card p-1 shadow-lg">
{ALL_COLUMNS.map((col) => {
const isVisible = columns.find((c) => c.key === col.key)?.visible ?? col.visible;
return (
<button
key={col.key}
type="button"
onClick={() => {
setColumns((prev) =>
prev.map((c) => c.key === col.key ? { ...c, visible: !c.visible } : c)
);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs text-foreground hover:bg-accent"
>
<span className={cn("text-xs", isVisible ? "text-primary" : "text-muted-foreground/30")}>
{isVisible ? "✓" : "—"}
</span>
{col.label}
</button>
);
})}
</div>
</>
)}
</div>
</div> </div>
{/* Row 2: status quick-filter pills */} {/* Row 2: status quick-filter pills */}
@@ -745,8 +836,39 @@ function TicketWorkbenchContent() {
</Button> </Button>
</div> </div>
) : ( ) : (
filteredTickets.map((ticket) => { <>
{/* Column header */}
<div className={cn(
"sticky top-0 z-10 flex border-b border-border bg-muted/70",
density === "compact" ? "min-h-8" : "min-h-9"
)}
style={{ width: columns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 96), minWidth: "100%" }}
>
{columns.filter((c) => c.visible).map((col) => (
<div
key={col.key}
className="relative flex shrink-0 items-center gap-1 border-r border-border/60 px-3 last:border-r-0"
style={{ width: col.key === "subject" ? undefined : col.width, flex: col.key === "subject" ? 1 : undefined, minWidth: col.key === "subject" ? 200 : undefined }}
>
<span className="text-[11px] font-semibold uppercase text-muted-foreground truncate">
{col.label}
</span>
{/* Resize handle */}
<div
className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary/40"
onMouseDown={(e) => handleColumnResize(col.key, e)}
/>
</div>
))}
<div className="w-12 shrink-0" />
</div>
{filteredTickets.map((ticket) => {
const selected = ticket.id === selectedId; const selected = ticket.id === selectedId;
const ownerName = ticket.owner_id
? users.find((u) => u.id === ticket.owner_id)?.username ?? "assigned"
: null;
return ( return (
<button <button
key={ticket.id} key={ticket.id}
@@ -754,43 +876,78 @@ function TicketWorkbenchContent() {
onClick={() => setSelectedId(ticket.id)} onClick={() => setSelectedId(ticket.id)}
onDoubleClick={() => router.push(`/tickets/${ticket.id}`)} onDoubleClick={() => router.push(`/tickets/${ticket.id}`)}
className={cn( className={cn(
"grid w-full grid-cols-[112px_minmax(280px,1fr)_150px_132px_116px_40px] items-center border-b border-border/80 px-5 text-left transition-colors", "flex items-center border-b border-border/80 text-left transition-colors",
density === "compact" ? "min-h-11" : "min-h-16", density === "compact" ? "min-h-9" : "min-h-12",
selected selected
? "bg-accent/80 shadow-[inset_3px_0_0_var(--primary)]" ? "bg-accent/80 shadow-[inset_3px_0_0_var(--primary)]"
: "hover:bg-accent/45" : "hover:bg-accent/45"
)} )}
style={{ width: columns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 96), minWidth: "100%" }}
> >
<span className="font-mono text-xs font-semibold text-muted-foreground"> {columns.filter((c) => c.visible).map((col) => {
switch (col.key) {
case "id":
return (
<span key={col.key} className="shrink-0 px-3 font-mono text-xs font-semibold text-muted-foreground" style={{ width: col.width }}>
{formatTicketId(ticket.id)} {formatTicketId(ticket.id)}
</span> </span>
<span className="min-w-0 pr-4"> );
case "subject":
return (
<span key={col.key} className="min-w-0 flex-1 px-3" style={{ minWidth: 200 }}>
<span className="block truncate text-sm font-semibold text-foreground"> <span className="block truncate text-sm font-semibold text-foreground">
{ticket.subject} {ticket.subject}
</span> </span>
{density === "comfortable" && ( {density === "comfortable" && (
<span className="mt-1 flex items-center gap-2 text-xs text-muted-foreground"> <span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
{ticket.owner_id ? "Assigned" : "Unassigned"} {ownerName ?? "Unassigned"}
<span className="h-1 w-1 rounded-full bg-border" /> <span className="h-1 w-1 rounded-full bg-border" />
Created {relativeTime(ticket.created_at)} Created {relativeTime(ticket.created_at)}
</span> </span>
)} )}
</span> </span>
<span className="truncate text-sm font-medium text-muted-foreground"> );
{queueName(queues, ticket.queue_id)} case "status":
</span> return (
<span> <span key={col.key} className="shrink-0 px-3" style={{ width: col.width }}>
<TicketStatusBadge status={ticket.status} /> <TicketStatusBadge status={ticket.status} />
</span> </span>
<span className="text-xs text-muted-foreground"> );
case "queue":
return (
<span key={col.key} className="shrink-0 truncate px-3 text-sm font-medium text-muted-foreground" style={{ width: col.width }}>
{queueName(queues, ticket.queue_id)}
</span>
);
case "owner":
return (
<span key={col.key} className="shrink-0 truncate px-3 text-sm text-foreground" style={{ width: col.width }}>
{ownerName ?? "—"}
</span>
);
case "created":
return (
<span key={col.key} className="shrink-0 px-3 text-xs text-muted-foreground" style={{ width: col.width }}>
{relativeTime(ticket.created_at)}
</span>
);
case "updated":
return (
<span key={col.key} className="shrink-0 px-3 text-xs text-muted-foreground" style={{ width: col.width }}>
{relativeTime(ticket.updated_at)} {relativeTime(ticket.updated_at)}
</span> </span>
<span className="flex justify-end text-muted-foreground"> );
default:
return <span key={col.key} className="shrink-0 px-3" style={{ width: col.width }} />;
}
})}
<span className="flex w-12 shrink-0 justify-end px-2 text-muted-foreground">
<ChevronRightIcon className="h-4 w-4" /> <ChevronRightIcon className="h-4 w-4" />
</span> </span>
</button> </button>
); );
}) })}
</>
)} )}
</div> </div>
</section> </section>
@@ -1076,6 +1233,7 @@ function TicketWorkbenchContent() {
name: saveViewName.trim(), name: saveViewName.trim(),
filters: storedFilters, filters: storedFilters,
sort_key: sortKey, sort_key: sortKey,
columns,
}); });
if (!error && data) { if (!error && data) {
setSavedViewsList((prev) => [...prev, data]); setSavedViewsList((prev) => [...prev, data]);
@@ -1107,6 +1265,7 @@ function TicketWorkbenchContent() {
name: saveViewName.trim(), name: saveViewName.trim(),
filters: storedFilters, filters: storedFilters,
sort_key: sortKey, sort_key: sortKey,
columns,
}); });
if (!error && data) { if (!error && data) {
setSavedViewsList((prev) => [...prev, data]); setSavedViewsList((prev) => [...prev, data]);

View File

@@ -268,7 +268,7 @@ export async function createView(data: {
name: string; name: string;
filters: { field: string; operator: string; value: string }[]; filters: { field: string; operator: string; value: string }[];
sort_key?: string; sort_key?: string;
columns?: unknown[]; columns?: { key: string; label: string; width: number; visible: boolean }[];
is_public?: boolean; is_public?: boolean;
}): Promise<{ data: SavedView | null; error: string | null }> { }): Promise<{ data: SavedView | null; error: string | null }> {
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) }); return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });