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,
ChevronRightIcon,
GaugeIcon,
LayoutGridIcon,
LayoutListIcon,
PlusIcon,
RefreshCwIcon,
@@ -47,6 +48,27 @@ const VIEW_LABELS: Record<string, string> = {
type Density = "comfortable" | "compact";
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 {
id: string;
field: string; // "status" | "queue" | "owner" | custom field key ("cf.<key>")
@@ -152,8 +174,11 @@ function TicketWorkbenchContent() {
const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<Filter[]>([]);
const [columns, setColumns] = useState<ColumnConfig[]>(defaultColumns);
const [density, setDensity] = useState<Density>("comfortable");
const [sortKey, setSortKey] = useState<SortKey>("updated");
const [resizingCol, setResizingCol] = useState<string | null>(null);
const [colPickerOpen, setColPickerOpen] = useState(false);
// Saved views
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
@@ -345,12 +370,16 @@ function TicketWorkbenchContent() {
}))
);
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) {
// User navigated away from a view — clear filters
// User navigated away from a view — clear filters and reset columns
setFilters([]);
setSearchQuery("");
setColumns(defaultColumns());
}
}, [searchParams]);
@@ -465,6 +494,31 @@ function TicketWorkbenchContent() {
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
? queueName(queues, routeQueue)
: VIEW_LABELS[view] ?? "All tickets";
@@ -624,6 +678,43 @@ function TicketWorkbenchContent() {
<SlidersHorizontalIcon className="h-4 w-4" />
)}
</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>
{/* Row 2: status quick-filter pills */}
@@ -745,8 +836,39 @@ function TicketWorkbenchContent() {
</Button>
</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 ownerName = ticket.owner_id
? users.find((u) => u.id === ticket.owner_id)?.username ?? "assigned"
: null;
return (
<button
key={ticket.id}
@@ -754,43 +876,78 @@ function TicketWorkbenchContent() {
onClick={() => setSelectedId(ticket.id)}
onDoubleClick={() => router.push(`/tickets/${ticket.id}`)}
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",
density === "compact" ? "min-h-11" : "min-h-16",
"flex items-center border-b border-border/80 text-left transition-colors",
density === "compact" ? "min-h-9" : "min-h-12",
selected
? "bg-accent/80 shadow-[inset_3px_0_0_var(--primary)]"
: "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)}
</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">
{ticket.subject}
</span>
{density === "comfortable" && (
<span className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
{ticket.owner_id ? "Assigned" : "Unassigned"}
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
{ownerName ?? "Unassigned"}
<span className="h-1 w-1 rounded-full bg-border" />
Created {relativeTime(ticket.created_at)}
</span>
)}
</span>
<span className="truncate text-sm font-medium text-muted-foreground">
{queueName(queues, ticket.queue_id)}
</span>
<span>
);
case "status":
return (
<span key={col.key} className="shrink-0 px-3" style={{ width: col.width }}>
<TicketStatusBadge status={ticket.status} />
</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)}
</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" />
</span>
</button>
);
})
})}
</>
)}
</div>
</section>
@@ -1076,6 +1233,7 @@ function TicketWorkbenchContent() {
name: saveViewName.trim(),
filters: storedFilters,
sort_key: sortKey,
columns,
});
if (!error && data) {
setSavedViewsList((prev) => [...prev, data]);
@@ -1107,6 +1265,7 @@ function TicketWorkbenchContent() {
name: saveViewName.trim(),
filters: storedFilters,
sort_key: sortKey,
columns,
});
if (!error && data) {
setSavedViewsList((prev) => [...prev, data]);

View File

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