feat: persist columns to localStorage, custom fields as columns

- Column config saves to localStorage on every change
- Load from localStorage on mount (survive reloads without saved view)
- Custom fields appear as column options in picker
- Custom field values render in ticket rows
- Backend now always includes custom_fields in GET /tickets response

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 21:51:16 +02:00
parent 7ddf82f93f
commit 38a82ad0d8
2 changed files with 105 additions and 8 deletions

View File

@@ -65,10 +65,24 @@ const ALL_COLUMNS: ColumnConfig[] = [
{ key: "updated", label: "Updated", width: 130, visible: false },
];
function defaultColumns(): ColumnConfig[] {
return ALL_COLUMNS.map((c) => ({ ...c }));
function baseColumns(): ColumnConfig[] {
return [
{ 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 baseColumns().map((c) => ({ ...c }));
}
const LS_KEY = "tessera_columns";
interface Filter {
id: string;
field: string; // "status" | "queue" | "owner" | custom field key ("cf.<key>")
@@ -174,12 +188,47 @@ function TicketWorkbenchContent() {
const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<Filter[]>([]);
const [columns, setColumns] = useState<ColumnConfig[]>(defaultColumns);
const [columns, setColumns] = useState<ColumnConfig[]>(() => {
if (typeof window === "undefined") return defaultColumns();
try {
const stored = localStorage.getItem(LS_KEY);
if (stored) return JSON.parse(stored) as ColumnConfig[];
} catch { /* ignore */ }
return 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);
// Persist columns to localStorage
useEffect(() => {
try { localStorage.setItem(LS_KEY, JSON.stringify(columns)); } catch { /* ignore */ }
}, [columns]);
// Build available columns: base + custom fields
const availableColumns = useMemo(() => {
const base = baseColumns();
const cfCols: ColumnConfig[] = customFields
.filter((cf) => cf.key)
.map((cf) => ({
key: `cf.${cf.key}`,
label: cf.name,
width: 140,
visible: columns.find((c) => c.key === `cf.${cf.key}`)?.visible ?? false,
}));
// Merge with current visibility state
const merged = base.map((bc) => {
const current = columns.find((c) => c.key === bc.key);
return current ?? bc;
});
for (const cf of cfCols) {
const current = columns.find((c) => c.key === cf.key);
merged.push(current ?? cf);
}
return merged;
}, [customFields, columns]);
// Saved views
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
const [viewIdFromUrl, setViewIdFromUrl] = useState<string | null>(null);
@@ -691,7 +740,7 @@ function TicketWorkbenchContent() {
<>
<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) => {
{availableColumns.map((col) => {
const isVisible = columns.find((c) => c.key === col.key)?.visible ?? col.visible;
return (
<button
@@ -842,9 +891,9 @@ function TicketWorkbenchContent() {
"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%" }}
style={{ width: availableColumns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 96), minWidth: "100%" }}
>
{columns.filter((c) => c.visible).map((col) => (
{availableColumns.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"
@@ -882,9 +931,18 @@ function TicketWorkbenchContent() {
? "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%" }}
style={{ width: availableColumns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 96), minWidth: "100%" }}
>
{columns.filter((c) => c.visible).map((col) => {
{availableColumns.filter((c) => c.visible).map((col) => {
if (col.key.startsWith("cf.")) {
const cfKey = col.key.slice(3);
const cfValue = ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value;
return (
<span key={col.key} className="shrink-0 truncate px-3 text-sm text-foreground" style={{ width: col.width }}>
{cfValue ?? "—"}
</span>
);
}
switch (col.key) {
case "id":
return (