Files
tessera/web/src/app/page.tsx
Gjermund Høsøien Wiggen dd747946ea fix: wider resize handles, table minWidth instead of width 100%
- table-layout: fixed with minWidth instead of width:100% to avoid
  browser recalculating explicit column widths
- Wider resize handles (w-3, 12px) for easier grabbing
- Better visibility on hover

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:18:49 +02:00

1552 lines
65 KiB
TypeScript

"use client";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useRouter, useSearchParams } from "next/navigation";
import {
ArrowDownAZIcon,
CheckCircle2Icon,
ChevronRightIcon,
GaugeIcon,
LayoutGridIcon,
LayoutListIcon,
PlusIcon,
RefreshCwIcon,
SaveIcon,
SearchIcon,
SlidersHorizontalIcon,
XIcon,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards, getTicketTransactions, updateTicket } from "@/lib/api";
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, Transaction, User } from "@/lib/types";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn, formatTicketId } from "@/lib/utils";
const STATUS_META: Record<string, { label: string; color: string; tone: string }> = {
new: { label: "New", color: "#64748b", tone: "bg-slate-500/10 text-slate-700 dark:text-slate-300" },
open: { label: "Open", color: "#2563eb", tone: "bg-blue-500/10 text-blue-700 dark:text-blue-300" },
in_progress: { label: "In progress", color: "#d97706", tone: "bg-amber-500/10 text-amber-700 dark:text-amber-300" },
resolved: { label: "Resolved", color: "#16a34a", tone: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" },
closed: { label: "Closed", color: "#71717a", tone: "bg-zinc-500/10 text-zinc-700 dark:text-zinc-300" },
};
const VIEW_LABELS: Record<string, string> = {
my: "My tickets",
unassigned: "Unassigned",
recent: "Recently updated",
};
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 baseColumns(): ColumnConfig[] {
return [
{ key: "id", label: "ID", width: 100, visible: true },
{ key: "subject", label: "Subject", width: 400, 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>")
operator: string; // "is" | "is_not"
value: string;
label: string; // human-readable label for the chip
}
function buildFilterLabel(field: string, operator: string, valueLabel: string): string {
const fieldLabel = field.startsWith("cf.") ? field.slice(3) : field;
const op = operator === "is_not" ? "is not" : "is";
return `${fieldLabel} ${op} ${valueLabel}`;
}
function statusLabel(status: string) {
return STATUS_META[status]?.label ?? status.replaceAll("_", " ");
}
function queueName(queues: Queue[], queueId: string) {
return queues.find((queue) => queue.id === queueId)?.name ?? queueId.slice(0, 8);
}
function relativeTime(value: string) {
return formatDistanceToNow(new Date(value), { addSuffix: true });
}
function TicketStatusBadge({ status }: { status: string }) {
const meta = STATUS_META[status];
return (
<span
className={cn(
"inline-flex h-6 items-center gap-1.5 rounded px-2 text-xs font-semibold capitalize",
meta?.tone ?? "bg-muted text-muted-foreground"
)}
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta?.color ?? "#71717a" }}
/>
{statusLabel(status)}
</span>
);
}
function StatPill({
label,
value,
}: {
label: string;
value: number;
}) {
return (
<span className="inline-flex h-6 items-center gap-1.5 rounded border border-border/80 bg-card/80 px-2 text-[11px] text-muted-foreground shadow-sm">
<span className="font-semibold tabular-nums text-foreground">{value}</span>
{label}
</span>
);
}
function SkeletonWorkbench() {
return (
<div className="flex h-full flex-col">
<div className="border-b border-border px-5 py-4">
<div className="h-7 w-48 animate-pulse rounded bg-muted" />
<div className="mt-3 h-9 w-full max-w-xl animate-pulse rounded bg-muted" />
</div>
<div className="grid flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="overflow-hidden">
{Array.from({ length: 12 }).map((_, index) => (
<div key={index} className="flex items-center gap-4 border-b border-border px-5 py-4">
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-2/5 animate-pulse rounded bg-muted" />
<div className="h-3 w-3/5 animate-pulse rounded bg-muted" />
</div>
<div className="h-6 w-20 animate-pulse rounded bg-muted" />
</div>
))}
</div>
<div className="hidden border-l border-border p-5 xl:block">
<div className="h-40 animate-pulse rounded-md bg-muted" />
</div>
</div>
</div>
);
}
function TicketWorkbenchContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [queues, setQueues] = useState<Queue[]>([]);
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [clock, setClock] = useState(0);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedTxs, setSelectedTxs] = useState<Transaction[]>([]);
const [batchIds, setBatchIds] = useState<Set<number>>(new Set());
const [batchSaving, setBatchSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<Filter[]>([]);
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);
const [saveViewOpen, setSaveViewOpen] = useState(false);
const [saveViewName, setSaveViewName] = useState("");
const [addFilterOpen, setAddFilterOpen] = useState(false);
const [addFilterField, setAddFilterField] = useState<string | null>(null); // which field type is selected
const [addFilterOperator, setAddFilterOperator] = useState("is");
const [addFilterValue, setAddFilterValue] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [newSubject, setNewSubject] = useState("");
const [newQueueId, setNewQueueId] = useState("");
const [newDescription, setNewDescription] = useState("");
const [newQueueFields, setNewQueueFields] = useState<QueueCustomField[]>([]);
const [newCustomFieldValues, setNewCustomFieldValues] = useState<Record<string, string>>({});
const [newFieldsLoading, setNewFieldsLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const view = searchParams.get("view") ?? "all";
const routeQueue = searchParams.get("queue") ?? "";
const fetchData = useCallback(
async (background = false) => {
if (background) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
const fetchedAt = Date.now();
const activeQueue = routeQueue;
const apiStatus = filters.find((f) => f.field === "status")?.value;
const apiOwner = filters.find((f) => f.field === "owner")?.value;
const apiQueue = filters.find((f) => f.field === "queue")?.value;
const customFieldFilters: Record<string, string> = {};
for (const f of filters) {
if (f.field.startsWith("cf.")) {
customFieldFilters[f.field.slice(3)] = f.value;
}
}
const routeTeamId = searchParams.get("team_id") ?? "";
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([
getTickets({
q: searchQuery.trim() || undefined,
status: apiStatus || undefined,
queue_id: activeQueue || apiQueue || undefined,
owner_id: apiOwner || undefined,
team_id: routeTeamId || undefined,
custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined,
}),
getQueues(),
getUsers(),
getCustomFields(),
getLifecycles(),
]);
if (ticketsRes.error) {
setError(ticketsRes.error);
setTickets([]);
} else {
setTickets(ticketsRes.data ?? []);
}
if (queuesRes.error) {
setError((current) => current ?? queuesRes.error);
} else {
const queueData = queuesRes.data ?? [];
setQueues(queueData);
if (!newQueueId && queueData.length > 0) {
setNewQueueId(queueData[0].id);
}
}
if (usersRes.error) {
setError((current) => current ?? usersRes.error);
} else {
setUsers(usersRes.data ?? []);
}
if (fieldsRes.error) {
setError((current) => current ?? fieldsRes.error);
} else {
setCustomFields(fieldsRes.data ?? []);
}
if (lifecycleRes.error) {
setError((current) => current ?? lifecycleRes.error);
} else {
setLifecycles(lifecycleRes.data ?? []);
}
setLoading(false);
setRefreshing(false);
setClock(fetchedAt);
},
[filters, newQueueId, routeQueue, searchQuery]
);
useEffect(() => {
void Promise.resolve().then(() => fetchData());
}, [fetchData]);
// Redirect to default dashboard if one exists and no params set
useEffect(() => {
if (searchParams.toString()) return;
getDashboards().then(({ data }) => {
const def = data?.find((d) => d.is_default);
if (def) router.replace(`/dashboards/${def.id}`);
});
}, [searchParams]);
useEffect(() => {
if (searchParams.get("new") === "true") {
queueMicrotask(() => setDialogOpen(true));
const url = new URL(window.location.href);
url.searchParams.delete("new");
window.history.replaceState({}, "", url.toString());
}
}, [searchParams]);
useEffect(() => {
if (!newQueueId) {
queueMicrotask(() => {
setNewQueueFields([]);
setNewCustomFieldValues({});
});
return;
}
let cancelled = false;
queueMicrotask(() => {
if (!cancelled) setNewFieldsLoading(true);
});
void getQueueCustomFields(newQueueId).then(({ data, error }) => {
if (cancelled) return;
setNewFieldsLoading(false);
if (error) {
setCreateError(error);
setNewQueueFields([]);
setNewCustomFieldValues({});
return;
}
const assignments = data ?? [];
setNewQueueFields(assignments);
setNewCustomFieldValues((current) => {
const next: Record<string, string> = {};
for (const assignment of assignments) {
next[assignment.custom_field_id] = current[assignment.custom_field_id] ?? "";
}
return next;
});
});
return () => {
cancelled = true;
};
}, [newQueueId]);
// Load saved views list
useEffect(() => {
getViews().then(({ data }) => {
if (data) setSavedViewsList(data);
});
}, [clock]);
// Load view from URL param
useEffect(() => {
const paramViewId = searchParams.get("view_id");
setViewIdFromUrl(paramViewId);
if (paramViewId) {
getViews().then(({ data }) => {
const view = data?.find((v) => v.id === paramViewId);
if (view?.filters && Array.isArray(view.filters)) {
setSearchQuery("");
setFilters(
(view.filters as { field: string; operator: string; value: string }[])
.filter((f) => f.field && f.value)
.map((f) => ({
id: crypto.randomUUID(),
field: f.field,
operator: f.operator || "is",
value: f.value,
label: buildFilterLabel(f.field, f.operator || "is", f.value),
}))
);
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 and reset columns
setFilters([]);
setSearchQuery("");
setColumns(defaultColumns());
}
}, [searchParams]);
const statusOptions = useMemo(() => {
const queueFilterValue = filters.find((f) => f.field === "queue")?.value;
const selectedFilterQueueId = routeQueue || queueFilterValue || "";
const selectedFilterQueue = selectedFilterQueueId
? queues.find((queue) => queue.id === selectedFilterQueueId)
: null;
const selectedFilterLifecycle = selectedFilterQueue?.lifecycle_id
? lifecycles.find((lifecycle) => lifecycle.id === selectedFilterQueue.lifecycle_id)
: null;
const lifecycleStatuses = selectedFilterLifecycle
? [
...selectedFilterLifecycle.definition.statuses.initial,
...selectedFilterLifecycle.definition.statuses.active,
...selectedFilterLifecycle.definition.statuses.inactive,
]
: lifecycles.flatMap((lifecycle) => [
...lifecycle.definition.statuses.initial,
...lifecycle.definition.statuses.active,
...lifecycle.definition.statuses.inactive,
]);
return [
{ key: "all", label: "All" },
...Array.from(new Set([...lifecycleStatuses, ...tickets.map((ticket) => ticket.status)]))
.filter(Boolean)
.map((status) => ({ key: status, label: statusLabel(status) })),
];
}, [filters, lifecycles, queues, routeQueue, tickets]);
const inactiveStatuses = useMemo(
() => new Set(
lifecycles.length > 0
? lifecycles.flatMap((lifecycle) => lifecycle.definition.statuses.inactive)
: ["resolved", "closed"]
),
[lifecycles]
);
const metrics = useMemo(() => {
const now = clock || 0;
const day = 24 * 60 * 60 * 1000;
return {
total: tickets.length,
active: tickets.filter((ticket) => !inactiveStatuses.has(ticket.status)).length,
unassigned: tickets.filter((ticket) => !ticket.owner_id).length,
stale: tickets.filter((ticket) => now && now - new Date(ticket.updated_at).getTime() > 3 * day).length,
recent: tickets.filter((ticket) => now && now - new Date(ticket.updated_at).getTime() < day).length,
};
}, [clock, inactiveStatuses, tickets]);
const filteredTickets = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
const now = clock || 0;
const queue = routeQueue;
const statusFilterValue = filters.find((f) => f.field === "status")?.value;
const queueFilterValue = filters.find((f) => f.field === "queue")?.value;
return tickets
.filter((ticket) => {
if (view === "my") {
const myOwner = searchParams.get("owner");
if (myOwner && ticket.owner_id !== myOwner) return false;
if (!myOwner && !ticket.owner_id) return false;
}
if (view === "unassigned" && ticket.owner_id) return false;
if (view === "recent") {
const week = 7 * 24 * 60 * 60 * 1000;
if (!now || now - new Date(ticket.updated_at).getTime() > week) return false;
}
if (statusFilterValue && ticket.status !== statusFilterValue) return false;
if (queueFilterValue && ticket.queue_id !== queueFilterValue) return false;
if (queue && ticket.queue_id !== queue) return false;
if (!query) return true;
return (
ticket.subject.toLowerCase().includes(query) ||
formatTicketId(ticket.id).toLowerCase().includes(query) ||
statusLabel(ticket.status).toLowerCase().includes(query) ||
queueName(queues, ticket.queue_id).toLowerCase().includes(query)
);
})
.sort((a, b) => {
if (sortKey === "id") return b.id - a.id;
const aDate = sortKey === "created" ? a.created_at : a.updated_at;
const bDate = sortKey === "created" ? b.created_at : b.updated_at;
return new Date(bDate).getTime() - new Date(aDate).getTime();
});
}, [clock, filters, queues, routeQueue, searchQuery, sortKey, tickets, view]);
const selectedTicket =
filteredTickets.find((ticket) => ticket.id === selectedId) ?? null;
// Fetch transactions when selection changes
useEffect(() => {
if (!selectedTicket) { setSelectedTxs([]); return; }
getTicketTransactions(selectedTicket.id).then(({ data }) => setSelectedTxs(data ?? []));
}, [selectedTicket?.id]);
const handleQuickStatus = async (ticketId: number, newStatus: string) => {
const { data } = await updateTicket(ticketId, { status: newStatus });
if (data) {
setTickets((prev) => prev.map((t) => (t.id === ticketId ? data.ticket : t)));
getTicketTransactions(ticketId).then(({ data: txs }) => setSelectedTxs(txs ?? []));
}
};
const handleQuickAssign = async (ticketId: number) => {
const { data } = await updateTicket(ticketId, { owner_id: users[0]?.id ?? null });
if (data) {
setTickets((prev) => prev.map((t) => (t.id === ticketId ? data.ticket : t)));
}
};
const handleBatchStatus = async (newStatus: string) => {
setBatchSaving(true);
for (const id of batchIds) {
await updateTicket(id, { status: newStatus });
}
setBatchSaving(false);
setBatchIds(new Set());
await fetchData();
};
const handleBatchAssign = async () => {
const me = users[0]?.id;
if (!me) return;
setBatchSaving(true);
for (const id of batchIds) {
await updateTicket(id, { owner_id: me });
}
setBatchSaving(false);
setBatchIds(new Set());
await fetchData();
};
const toggleBatchId = (id: number) => {
setBatchIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const handleColumnResize = (leftKey: string, rightKey: string | null, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setResizingCol(leftKey);
const startX = e.clientX;
const leftCol = columns.find((c) => c.key === leftKey);
const rightCol = rightKey ? columns.find((c) => c.key === rightKey) : null;
const leftStart = leftCol?.width ?? 140;
const rightStart = rightCol?.width ?? 140;
const onMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX;
const newLeft = Math.max(50, Math.min(800, leftStart + delta));
const newRight = rightCol ? Math.max(50, Math.min(800, rightStart - delta)) : undefined;
setColumns((prev) =>
prev.map((c) => {
if (c.key === leftKey) return { ...c, width: newLeft };
if (rightCol && c.key === rightCol.key) return { ...c, width: newRight! };
return 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";
const newTicketFields = newQueueFields
.map((assignment) => assignment.custom_field)
.filter((field): field is CustomField => Boolean(field));
const selectedNewQueue = queues.find((queue) => queue.id === newQueueId);
const selectedNewLifecycle = selectedNewQueue?.lifecycle_id
? lifecycles.find((lifecycle) => lifecycle.id === selectedNewQueue.lifecycle_id)
: null;
const newTicketInitialStatus = selectedNewLifecycle?.definition.statuses.initial[0] ?? "new";
const hasQueryFilters = searchQuery.trim() || filters.length > 0;
const clearQueryFilters = () => {
setSearchQuery("");
setFilters([]);
if (routeQueue) router.push("/");
};
const handleCreate = async () => {
if (!newSubject.trim() || !newQueueId) return;
for (const field of newTicketFields) {
const value = (newCustomFieldValues[field.id] ?? "").trim();
if (value && field.pattern) {
const regex = new RegExp(field.pattern);
if (!regex.test(value)) {
setCreateError(`${field.name}: value does not match the required pattern.`);
return;
}
}
}
setSubmitting(true);
setCreateError(null);
const { data, error } = await createTicket({
subject: newSubject.trim(),
queue_id: newQueueId,
description: newDescription.trim() || undefined,
custom_fields: Object.fromEntries(
Object.entries(newCustomFieldValues)
.map(([fieldId, value]) => [fieldId, value.trim()])
.filter(([, value]) => value)
),
});
setSubmitting(false);
if (error) {
setCreateError(error);
return;
}
setDialogOpen(false);
setNewSubject("");
setNewDescription("");
setNewCustomFieldValues({});
if (data) router.push(`/tickets/${data.ticket.id}`);
};
if (loading) return <SkeletonWorkbench />;
return (
<div className="flex h-full flex-col bg-background/80">
<header className="shrink-0 border-b border-border bg-card/82 backdrop-blur">
<div className="flex flex-col gap-3 px-5 py-4 lg:px-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-muted-foreground">
<GaugeIcon className="h-3.5 w-3.5" />
Work queue
</div>
<h1 className="mt-1 truncate text-2xl font-semibold text-foreground">
{visibleTitle}
</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fetchData(true)}
disabled={refreshing}
className="h-8 border-border/80 bg-card/70"
>
<RefreshCwIcon className={cn("h-4 w-4", refreshing && "animate-spin")} />
Refresh
</Button>
{filters.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={() => {
setSaveViewName("");
setSaveViewOpen(true);
}}
className="h-8 border-border/80 bg-card/70"
>
<SaveIcon className="h-4 w-4" />
Save view
</Button>
)}
<Button size="sm" onClick={() => setDialogOpen(true)} className="h-8 bg-primary shadow-sm">
<PlusIcon className="h-4 w-4" />
New ticket
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<StatPill label="total" value={metrics.total} />
<StatPill label="active" value={metrics.active} />
<StatPill label="unassigned" value={metrics.unassigned} />
<StatPill label="updated today" value={metrics.recent} />
<StatPill label="needs review" value={metrics.stale} />
</div>
<div className="flex flex-col gap-2">
{/* Row 1: search + sort/density */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search subject, ticket ID, queue, or status"
className="h-9 w-full rounded-md border border-input bg-card/90 pl-9 pr-3 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
/>
</div>
{hasQueryFilters && (
<Button
type="button"
variant="outline"
size="sm"
onClick={clearQueryFilters}
className="h-9 shrink-0 border-border/80 bg-card/70"
>
Clear
</Button>
)}
<button
type="button"
onClick={() => setSortKey(sortKey === "updated" ? "created" : sortKey === "created" ? "id" : "updated")}
className="inline-flex h-9 items-center gap-2 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 shrink-0"
title="Change sort"
>
<ArrowDownAZIcon className="h-4 w-4" />
{sortKey === "updated" ? "Updated" : sortKey === "created" ? "Created" : "ID"}
</button>
<button
type="button"
onClick={() => setDensity(density === "comfortable" ? "compact" : "comfortable")}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
title="Toggle density"
>
{density === "comfortable" ? (
<LayoutListIcon className="h-4 w-4" />
) : (
<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>
</div>
</div>
{/* Row 2: status quick-filter pills */}
<div className="flex min-w-0 items-center gap-1 overflow-x-auto rounded-md border border-border bg-muted/55 p-1 shadow-sm">
{statusOptions.map((opt) => {
const isActive = opt.key === "all"
? !filters.some((f) => f.field === "status")
: filters.some((f) => f.field === "status" && f.value === opt.key);
return (
<button
key={opt.key}
type="button"
onClick={() => {
if (opt.key === "all") {
setFilters((prev) => prev.filter((f) => f.field !== "status"));
} else {
setFilters((prev) => {
const rest = prev.filter((f) => f.field !== "status");
if (isActive) return rest;
return [...rest, {
id: crypto.randomUUID(),
field: "status",
operator: "is",
value: opt.key,
label: buildFilterLabel("status", "is", opt.label),
}];
});
}
}}
className={cn(
"h-7 whitespace-nowrap rounded px-2.5 text-xs font-semibold transition-colors",
isActive
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
{opt.label}
</button>
);
})}
</div>
{/* Row 3: active filter chips + add filter */}
<div className="flex flex-wrap items-center gap-1.5">
{filters
.filter((f) => f.field !== "status")
.map((f) => (
<span
key={f.id}
className="inline-flex h-7 items-center gap-1.5 rounded border border-border bg-accent/60 px-2 text-xs font-medium text-foreground"
>
{f.label}
<button
type="button"
onClick={() => setFilters((prev) => prev.filter((x) => x.id !== f.id))}
className="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
>
<XIcon className="h-3 w-3" />
</button>
</span>
))}
<div>
<button
type="button"
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
const popover = document.getElementById("add-filter-popover");
if (popover) {
popover.style.left = `${rect.left}px`;
popover.style.top = `${rect.bottom + 4}px`;
}
setAddFilterOpen((prev) => !prev);
}}
className="inline-flex h-7 items-center gap-1 rounded border border-dashed border-border px-2 text-xs font-medium text-muted-foreground hover:border-ring hover:text-foreground transition-colors"
>
<PlusIcon className="h-3 w-3" />
Add filter
</button>
</div>
</div>
</div>
</div>
</header>
{error && (
<div className="flex items-center justify-between shrink-0 border-b border-destructive/20 bg-destructive/10 px-5 py-2 text-sm text-destructive">
<span>{error}</span>
<button type="button" onClick={() => fetchData()} className="ml-3 text-xs font-medium underline hover:no-underline">
Retry
</button>
</div>
)}
<div className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_372px]">
<section className="min-w-0 overflow-auto bg-card/48">
<div className="min-w-[760px]">
{filteredTickets.length === 0 ? (
<div className="flex min-h-80 flex-col items-center justify-center px-5 text-center">
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-md border border-border bg-background">
<SearchIcon className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground">No tickets in this view</p>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Adjust the filters or create a new ticket for this queue.
</p>
<Button onClick={() => setDialogOpen(true)} className="mt-4 h-8" size="sm">
<PlusIcon className="h-4 w-4" />
New ticket
</Button>
</div>
) : (
<>
{/* Table layout for consistent column alignment */}
<div style={{ display: "table", tableLayout: "fixed", minWidth: "100%" }}>
{/* Column header */}
<div className={cn(
"sticky top-0 z-10 border-b border-border bg-muted/50",
density === "compact" ? "min-h-7" : "min-h-8"
)} style={{ display: "table-row" }}>
<div style={{ display: "table-cell", width: 36 }} />
{availableColumns.filter((c) => c.visible).map((col, idx, arr) => (
<div
key={col.key}
className="relative border-r border-border/60 px-3 align-middle last:border-r-0"
style={{ display: "table-cell", width: col.width }}
>
{/* Resize handle: drags the boundary, resizes column to the LEFT */}
{idx > 0 && (
<div
className="absolute left-0 top-0 h-full w-3 -ml-1.5 cursor-col-resize hover:bg-primary/30 z-10"
onMouseDown={(e) => handleColumnResize(arr[idx - 1].key, col.key, e)}
/>
)}
<span className="text-[10px] font-semibold uppercase text-muted-foreground/60 truncate block">
{col.label}
</span>
</div>
))}
<div style={{ display: "table-cell", width: 48 }} />
</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 (
<div
key={ticket.id}
role="button"
tabIndex={0}
onClick={() => setSelectedId(ticket.id)}
onDoubleClick={() => router.push(`/tickets/${ticket.id}`)}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/tickets/${ticket.id}`); }}
className={cn(
"cursor-pointer border-b border-border/80",
density === "compact" ? "" : "",
selected
? "bg-accent/80 shadow-[inset_3px_0_0_var(--primary)]"
: "hover:bg-accent/45"
)}
style={{ display: "table-row" }}
>
<div className="flex items-center justify-center" style={{ display: "table-cell", width: 36, verticalAlign: "middle" }} onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={batchIds.has(ticket.id)}
onChange={() => toggleBatchId(ticket.id)}
className="h-3.5 w-3.5 rounded border-border accent-primary"
/>
</div>
{availableColumns.filter((c) => c.visible).map((col) => {
const cellStyle = {
display: "table-cell" as const,
width: col.width,
verticalAlign: "middle" as const,
padding: density === "compact" ? "4px 12px" : "8px 12px",
};
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 (
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
{cfValue ?? "—"}
</div>
);
}
switch (col.key) {
case "id":
return (
<div key={col.key} className="font-mono text-xs font-semibold text-muted-foreground" style={cellStyle}>
{formatTicketId(ticket.id)}
</div>
);
case "subject":
return (
<div key={col.key} className="min-w-[200px]" style={cellStyle}>
<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>
)}
</div>
);
case "status":
return (
<div key={col.key} style={cellStyle}>
<TicketStatusBadge status={ticket.status} />
</div>
);
case "queue":
return (
<div key={col.key} className="truncate text-sm font-medium text-muted-foreground" style={cellStyle}>
{queueName(queues, ticket.queue_id)}
</div>
);
case "owner":
return (
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
{ownerName ?? "—"}
</div>
);
case "created":
return (
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
{relativeTime(ticket.created_at)}
</div>
);
case "updated":
return (
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
{relativeTime(ticket.updated_at)}
</div>
);
default:
return <div key={col.key} style={cellStyle} />;
}
})}
<div className="flex justify-end px-2 text-muted-foreground" style={{ display: "table-cell", width: 48, verticalAlign: "middle" }}>
<ChevronRightIcon className="h-4 w-4" />
</div>
</div>
);
})}
</div>
</>
)}
</div>
</section>
{/* Floating batch action bar */}
{batchIds.size > 0 && (
<div className="sticky bottom-0 z-20 flex items-center gap-3 border-t border-border bg-card/95 px-5 py-3 shadow-lg backdrop-blur">
<span className="text-sm font-semibold tabular-nums text-foreground">
{batchIds.size} selected
</span>
<button type="button" onClick={() => setBatchIds(new Set())} className="text-xs text-muted-foreground hover:text-foreground">
Clear
</button>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted-foreground">Status:</span>
{statusOptions.filter((s) => s.key !== "all").slice(0, 5).map((s) => (
<button
key={s.key}
type="button"
disabled={batchSaving}
onClick={() => handleBatchStatus(s.key)}
className="rounded bg-muted/60 px-2.5 py-1 text-xs font-semibold text-foreground hover:bg-accent disabled:opacity-50"
>
{s.label}
</button>
))}
<div className="mx-2 h-5 w-px bg-border" />
<button
type="button"
disabled={batchSaving}
onClick={handleBatchAssign}
className="rounded bg-primary px-3 py-1 text-xs font-semibold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{batchSaving ? "Saving..." : "Assign to me"}
</button>
</div>
</div>
)}
<aside className="hidden min-h-0 border-l border-border bg-card/76 backdrop-blur xl:flex xl:flex-col">
{selectedTicket ? (
<>
<div className="border-b border-border bg-card/82 p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-mono text-[11px] font-semibold text-muted-foreground">
{formatTicketId(selectedTicket.id)}
</p>
<h2 className="mt-1 text-sm font-semibold leading-snug text-foreground line-clamp-2">
{selectedTicket.subject}
</h2>
<p className="mt-1 text-xs text-muted-foreground">
{queueName(queues, selectedTicket.queue_id)} · {selectedTicket.owner_id ? users.find((u) => u.id === selectedTicket.owner_id)?.username ?? "assigned" : "unassigned"}
</p>
</div>
<TicketStatusBadge status={selectedTicket.status} />
</div>
<div className="mt-3 flex flex-wrap gap-1">
{statusOptions.filter((s) => s.key !== "all").slice(0, 4).map((s) => {
const allowed = selectedTicket.status !== s.key;
return (
<button
key={s.key}
type="button"
disabled={!allowed}
onClick={() => handleQuickStatus(selectedTicket.id, s.key)}
className={cn(
"rounded px-2 py-1 text-[11px] font-semibold transition-colors",
selectedTicket.status === s.key
? "bg-primary/10 text-primary"
: allowed
? "bg-muted/60 text-muted-foreground hover:bg-accent hover:text-foreground"
: "cursor-default text-muted-foreground/40"
)}
>
{s.label}
</button>
);
})}
</div>
<div className="mt-2 flex gap-2">
<Button
onClick={() => router.push(`/tickets/${selectedTicket.id}`)}
className="h-7 flex-1 bg-primary text-xs"
size="sm"
>
Open full view
<ChevronRightIcon className="ml-1 h-3.5 w-3.5" />
</Button>
{!selectedTicket.owner_id && (
<Button
variant="outline"
size="sm"
onClick={() => handleQuickAssign(selectedTicket.id)}
className="h-7 text-xs"
>
Assign to me
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-auto">
{selectedTxs.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
No activity yet.
</div>
) : (
<div className="divide-y divide-border/50">
{selectedTxs.slice(0, 8).map((tx) => (
<div key={tx.id} className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold text-foreground">
{tx.transaction_type === "Create" ? "Created" :
tx.transaction_type === "StatusChange" ? "Status changed" :
tx.transaction_type === "SetOwner" ? "Owner changed" :
tx.transaction_type === "Comment" ? "Comment" :
tx.transaction_type === "Correspond" ? "Reply" :
tx.transaction_type === "SetTeam" ? "Team changed" :
tx.transaction_type === "CustomFieldChange" ? `${tx.field} set` :
tx.transaction_type}
</span>
{tx.transaction_type === "StatusChange" && tx.new_value && (
<span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold",
STATUS_META[tx.new_value]?.tone ?? "bg-muted text-muted-foreground"
)}>
{statusLabel(tx.new_value)}
</span>
)}
{tx.old_value && tx.new_value && tx.transaction_type !== "StatusChange" && (
<span className="text-[10px] text-muted-foreground">
{tx.old_value} {tx.new_value}
</span>
)}
</div>
{tx.data && (tx.data as any).body && (
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{(tx.data as any).body}
</p>
)}
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
{formatDistanceToNow(new Date(tx.created_at), { addSuffix: true })}
</p>
</div>
))}
</div>
)}
</div>
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/50">
<ChevronRightIcon className="h-5 w-5 text-muted-foreground/40" />
</div>
<p className="text-sm text-muted-foreground">Select a ticket</p>
<p className="text-xs text-muted-foreground/60">Quick actions and activity will appear here</p>
</div>
)}
</aside>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-2xl" showCloseButton>
<DialogHeader>
<DialogTitle>New ticket</DialogTitle>
<DialogDescription>
Create a request, incident, task, or follow-up for the right queue.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<label className="grid gap-1.5 text-sm font-medium">
Subject
<input
value={newSubject}
onChange={(event) => setNewSubject(event.target.value)}
placeholder="Short summary"
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-normal outline-none focus:border-ring"
autoFocus
/>
</label>
<label className="grid gap-1.5 text-sm font-medium">
Queue
<select
value={newQueueId}
onChange={(event) => {
setNewQueueId(event.target.value);
setCreateError(null);
}}
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-normal outline-none focus:border-ring"
>
<option value="" disabled>
Select queue
</option>
{queues.map((queue) => (
<option key={queue.id} value={queue.id}>
{queue.name}
</option>
))}
</select>
<span className="text-xs font-normal text-muted-foreground">
Starts as <span className="font-mono text-foreground">{newTicketInitialStatus}</span>
{selectedNewLifecycle ? ` from ${selectedNewLifecycle.name}` : ""}
</span>
</label>
<label className="grid gap-1.5 text-sm font-medium">
Description
<textarea
value={newDescription}
onChange={(event) => setNewDescription(event.target.value)}
placeholder="Optional context"
rows={4}
className="resize-none rounded-md border border-input bg-background px-3 py-2 text-sm font-normal outline-none focus:border-ring"
/>
</label>
{(newFieldsLoading || newTicketFields.length > 0) && (
<div className="rounded-md border border-border bg-muted/25">
<div className="border-b border-border px-3 py-2">
<div className="text-sm font-semibold text-foreground">Queue fields</div>
<div className="mt-0.5 text-xs text-muted-foreground">
Metadata attached to this ticket from the selected queue.
</div>
</div>
<div className="grid gap-3 p-3">
{newFieldsLoading ? (
<div className="h-9 animate-pulse rounded-md bg-muted" />
) : (
newTicketFields.map((field) => {
const options = Array.isArray(field.values)
? field.values.map((value) => String(value))
: [];
return (
<label key={field.id} className="grid gap-1.5 text-sm font-medium">
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate">{field.name}</span>
<span className="shrink-0 font-mono text-[11px] font-normal text-muted-foreground">{field.key}</span>
</span>
{options.length > 0 ? (
<select
value={newCustomFieldValues[field.id] ?? ""}
onChange={(event) => setNewCustomFieldValues((current) => ({
...current,
[field.id]: event.target.value,
}))}
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-normal outline-none focus:border-ring"
>
<option value="">No value</option>
{options.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
) : (
<input
value={newCustomFieldValues[field.id] ?? ""}
onChange={(event) => setNewCustomFieldValues((current) => ({
...current,
[field.id]: event.target.value,
}))}
placeholder={field.pattern ? `Pattern: ${field.pattern}` : "Optional value"}
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-normal outline-none focus:border-ring"
/>
)}
{field.pattern && (
<span className="text-[11px] text-muted-foreground">
Must match: <code className="rounded bg-muted px-1 font-mono">{field.pattern}</code>
</span>
)}
</label>
);
})
)}
</div>
</div>
)}
</div>
{createError && <p className="text-sm text-destructive">{createError}</p>}
<DialogFooter showCloseButton={false}>
<Button
onClick={handleCreate}
disabled={!newSubject.trim() || !newQueueId || submitting}
>
{submitting ? "Creating..." : "Create ticket"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={saveViewOpen} onOpenChange={setSaveViewOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save view</DialogTitle>
<DialogDescription>
Save the current filters as a named view. It will appear in your sidebar.
</DialogDescription>
</DialogHeader>
<input
value={saveViewName}
onChange={(event) => setSaveViewName(event.target.value)}
placeholder="View name"
className="h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
autoFocus
onKeyDown={async (event) => {
if (event.key === "Enter" && saveViewName.trim()) {
const storedFilters = filters.map((f) => ({
field: f.field,
operator: f.operator,
value: f.value,
}));
const { data, error } = await createView({
name: saveViewName.trim(),
filters: storedFilters,
sort_key: sortKey,
columns,
});
if (!error && data) {
setSavedViewsList((prev) => [...prev, data]);
setSaveViewOpen(false);
setSaveViewName("");
}
}
}}
/>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setSaveViewOpen(false)}
>
Cancel
</Button>
<Button
size="sm"
disabled={!saveViewName.trim()}
onClick={async () => {
if (!saveViewName.trim()) return;
const storedFilters = filters.map((f) => ({
field: f.field,
operator: f.operator,
value: f.value,
}));
const { data, error } = await createView({
name: saveViewName.trim(),
filters: storedFilters,
sort_key: sortKey,
columns,
});
if (!error && data) {
setSavedViewsList((prev) => [...prev, data]);
setSaveViewOpen(false);
setSaveViewName("");
}
}}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{typeof document !== "undefined" && addFilterOpen && createPortal(
<>
<div
className="fixed inset-0 z-[9998]"
onClick={() => {
setAddFilterOpen(false);
setAddFilterField(null);
}}
/>
<div
id="add-filter-popover"
className="fixed z-[9999] w-52 rounded-md border border-border bg-popover p-1 shadow-lg"
>
{!addFilterField ? (
<>
<button
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
if (!filters.find((f) => f.field === "queue")) {
setAddFilterField("queue");
setAddFilterOperator("is");
setAddFilterValue("");
} else {
setAddFilterOpen(false);
}
}}
>Queue</button>
<button
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
if (!filters.find((f) => f.field === "owner")) {
setAddFilterField("owner");
setAddFilterOperator("is");
setAddFilterValue("");
} else {
setAddFilterOpen(false);
}
}}
>Owner</button>
{customFields.map((cf) => (
<button
key={`cf-portal-${cf.id}`}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
setAddFilterField(`cf.${cf.key}`);
setAddFilterOperator("is");
setAddFilterValue("");
}}
>{cf.name}</button>
))}
</>
) : (
<div className="space-y-2 p-1">
<div className="flex items-center gap-1 text-xs">
<button type="button" onClick={() => setAddFilterField(null)} className="text-muted-foreground hover:text-foreground"></button>
<span className="font-medium text-foreground">{addFilterField.startsWith("cf.") ? addFilterField.slice(3) : addFilterField}</span>
</div>
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
<option value="is">is</option>
<option value="is_not">is not</option>
</select>
{addFilterField === "queue" ? (
<select value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
<option value="">Select queue...</option>
{queues.map((q) => (<option key={q.id} value={q.id}>{q.name}</option>))}
</select>
) : addFilterField === "owner" ? (
<select value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
<option value="">Select owner...</option>
<option value="unassigned">Unassigned</option>
{users.map((u) => (<option key={u.id} value={u.id}>{u.username}</option>))}
</select>
) : (
<input value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} placeholder="Value" className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"
onKeyDown={(e) => {
if (e.key === "Enter" && addFilterValue.trim()) {
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field: addFilterField, operator: addFilterOperator, value: addFilterValue, label: buildFilterLabel(addFilterField, addFilterOperator, addFilterValue) }]);
setAddFilterField(null);
setAddFilterOpen(false);
}
}}
/>
)}
<div className="flex items-center justify-end gap-1 pt-1">
<button type="button" onClick={() => { setAddFilterField(null); setAddFilterOpen(false); }} className="rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground">Cancel</button>
<button type="button" disabled={!addFilterValue.trim()}
onClick={() => {
if (!addFilterValue.trim()) return;
const field = addFilterField;
const value = addFilterValue;
let valueLabel = value;
if (field === "queue") valueLabel = queues.find((q) => q.id === value)?.name ?? value;
else if (field === "owner") valueLabel = value === "unassigned" ? "Unassigned" : users.find((u) => u.id === value)?.username ?? value;
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]);
setAddFilterField(null);
setAddFilterOpen(false);
}}
className="rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>Apply</button>
</div>
</div>
)}
</div>
</>,
document.body
)}
{typeof document !== "undefined" && colPickerOpen && createPortal(
<>
<div className="fixed inset-0 z-[9998]" onClick={() => setColPickerOpen(false)} />
<div className="fixed z-[9999] w-48 rounded-md border border-border bg-card p-1 shadow-lg"
style={{ left: "calc(100% - 220px)", top: "72px" }}
>
{availableColumns.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>
</>,
document.body
)}
</div>
);
}
export default function TicketWorkbenchPage() {
return (
<Suspense fallback={<SkeletonWorkbench />}>
<TicketWorkbenchContent />
</Suspense>
);
}