"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 = { 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 = { 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.") 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 ( {statusLabel(status)} ); } function StatPill({ label, value, }: { label: string; value: number; }) { return ( {value} {label} ); } function SkeletonWorkbench() { return (
{Array.from({ length: 12 }).map((_, index) => (
))}
); } function TicketWorkbenchContent() { const router = useRouter(); const searchParams = useSearchParams(); const [tickets, setTickets] = useState([]); const [queues, setQueues] = useState([]); const [lifecycles, setLifecycles] = useState([]); const [users, setUsers] = useState([]); const [customFields, setCustomFields] = useState([]); const [clock, setClock] = useState(0); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [selectedId, setSelectedId] = useState(null); const [selectedTxs, setSelectedTxs] = useState([]); const [batchIds, setBatchIds] = useState>(new Set()); const [batchSaving, setBatchSaving] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [filters, setFilters] = useState([]); const [columns, setColumns] = useState(() => { 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("comfortable"); const [sortKey, setSortKey] = useState("updated"); const [resizingCol, setResizingCol] = useState(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([]); const [viewIdFromUrl, setViewIdFromUrl] = useState(null); const [saveViewOpen, setSaveViewOpen] = useState(false); const [saveViewName, setSaveViewName] = useState(""); const [addFilterOpen, setAddFilterOpen] = useState(false); const [addFilterField, setAddFilterField] = useState(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([]); const [newCustomFieldValues, setNewCustomFieldValues] = useState>({}); const [newFieldsLoading, setNewFieldsLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [createError, setCreateError] = useState(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 = {}; 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 = {}; 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 ; return (
Work queue

{visibleTitle}

{filters.length > 0 && ( )}
{/* Row 1: search + sort/density */}
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" />
{hasQueryFilters && ( )}
{/* Row 2: status quick-filter pills */}
{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 ( ); })}
{/* Row 3: active filter chips + add filter */}
{filters .filter((f) => f.field !== "status") .map((f) => ( {f.label} ))}
{error && (
{error}
)}
{filteredTickets.length === 0 ? (

No tickets in this view

Adjust the filters or create a new ticket for this queue.

) : ( <> {/* Table layout for consistent column alignment */}
{/* Column header */}
{availableColumns.filter((c) => c.visible).map((col, idx, arr) => (
{/* Resize handle: drags the boundary, resizes column to the LEFT */} {idx > 0 && (
handleColumnResize(arr[idx - 1].key, col.key, e)} /> )} {col.label}
))}
{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 (
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" }} >
e.stopPropagation()}> toggleBatchId(ticket.id)} className="h-3.5 w-3.5 rounded border-border accent-primary" />
{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 (
{cfValue ?? "—"}
); } 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
; } })}
); })}
)}
{/* Floating batch action bar */} {batchIds.size > 0 && (
{batchIds.size} selected
Status: {statusOptions.filter((s) => s.key !== "all").slice(0, 5).map((s) => ( ))}
)}
New ticket Create a request, incident, task, or follow-up for the right queue.