From 7ddf82f93f65f46d51657c5344cf9631292ff262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gjermund=20H=C3=B8s=C3=B8ien=20Wiggen?= Date: Tue, 9 Jun 2026 14:57:34 +0200 Subject: [PATCH] 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 --- web/src/app/page.tsx | 249 +++++++++++++++++++++++++++++++++++-------- web/src/lib/api.ts | 2 +- 2 files changed, 205 insertions(+), 46 deletions(-) diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index f698e7c..6abb790 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -8,6 +8,7 @@ import { CheckCircle2Icon, ChevronRightIcon, GaugeIcon, + LayoutGridIcon, LayoutListIcon, PlusIcon, RefreshCwIcon, @@ -47,6 +48,27 @@ const VIEW_LABELS: Record = { 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.") @@ -152,8 +174,11 @@ function TicketWorkbenchContent() { const [searchQuery, setSearchQuery] = useState(""); const [filters, setFilters] = useState([]); + const [columns, setColumns] = useState(defaultColumns); const [density, setDensity] = useState("comfortable"); const [sortKey, setSortKey] = useState("updated"); + const [resizingCol, setResizingCol] = useState(null); + const [colPickerOpen, setColPickerOpen] = useState(false); // Saved views const [savedViewsList, setSavedViewsList] = useState([]); @@ -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() { )} +
+ + {colPickerOpen && ( + <> +
setColPickerOpen(false)} /> +
+ {ALL_COLUMNS.map((col) => { + const isVisible = columns.find((c) => c.key === col.key)?.visible ?? col.visible; + return ( + + ); + })} +
+ + )} +
{/* Row 2: status quick-filter pills */} @@ -745,52 +836,118 @@ function TicketWorkbenchContent() { ) : ( - filteredTickets.map((ticket) => { - const selected = ticket.id === selectedId; - return ( - - ); - }) + style={{ width: columns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 96), minWidth: "100%" }} + > + {columns.filter((c) => c.visible).map((col) => { + switch (col.key) { + case "id": + return ( + + {formatTicketId(ticket.id)} + + ); + case "subject": + return ( + + + {ticket.subject} + + {density === "comfortable" && ( + + {ownerName ?? "Unassigned"} + + Created {relativeTime(ticket.created_at)} + + )} + + ); + case "status": + return ( + + + + ); + case "queue": + return ( + + {queueName(queues, ticket.queue_id)} + + ); + case "owner": + return ( + + {ownerName ?? "—"} + + ); + case "created": + return ( + + {relativeTime(ticket.created_at)} + + ); + case "updated": + return ( + + {relativeTime(ticket.updated_at)} + + ); + default: + return ; + } + })} + + + + + ); + })} + )} @@ -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]); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 65f3145..9dd0714 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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("/views", { method: "POST", body: JSON.stringify(data) });