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:
@@ -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,52 +836,118 @@ function TicketWorkbenchContent() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
filteredTickets.map((ticket) => {
|
||||
const selected = ticket.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={ticket.id}
|
||||
type="button"
|
||||
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",
|
||||
selected
|
||||
? "bg-accent/80 shadow-[inset_3px_0_0_var(--primary)]"
|
||||
: "hover:bg-accent/45"
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-xs font-semibold text-muted-foreground">
|
||||
{formatTicketId(ticket.id)}
|
||||
</span>
|
||||
<span className="min-w-0 pr-4">
|
||||
<span className="block truncate text-sm font-semibold text-foreground">
|
||||
{ticket.subject}
|
||||
<>
|
||||
{/* 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>
|
||||
{density === "comfortable" && (
|
||||
<span className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{ticket.owner_id ? "Assigned" : "Unassigned"}
|
||||
<span className="h-1 w-1 rounded-full bg-border" />
|
||||
Created {relativeTime(ticket.created_at)}
|
||||
</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}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(ticket.id)}
|
||||
onDoubleClick={() => router.push(`/tickets/${ticket.id}`)}
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-muted-foreground">
|
||||
{queueName(queues, ticket.queue_id)}
|
||||
</span>
|
||||
<span>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{relativeTime(ticket.updated_at)}
|
||||
</span>
|
||||
<span className="flex justify-end text-muted-foreground">
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
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-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>
|
||||
);
|
||||
case "status":
|
||||
return (
|
||||
<span key={col.key} className="shrink-0 px-3" style={{ width: col.width }}>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</span>
|
||||
);
|
||||
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>
|
||||
);
|
||||
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]);
|
||||
|
||||
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user