"use client"; import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useRouter, useSearchParams } from "next/navigation"; import { ArrowDownAZIcon, CheckCircle2Icon, ChevronRightIcon, DownloadIcon, 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, updateTicket, batchUpdateTickets, getTeams } from "@/lib/api"; import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Team, Ticket, 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"; import { SearchableSelect } from "@/components/searchable-select"; import { LayoutBuilder, type SubtitleEntry } from "@/components/layout-builder"; 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; } const ALL_FIELDS: ColumnConfig[] = [ { key: "id", label: "ID", width: 100 }, { key: "subject", label: "Subject", width: 400 }, { key: "status", label: "Status", width: 120 }, { key: "queue", label: "Queue", width: 140 }, { key: "owner", label: "Owner", width: 130 }, { key: "created", label: "Created", width: 130 }, { key: "updated", label: "Updated", width: 130 }, { key: "team", label: "Team", width: 130 }, ]; const DEFAULT_ROW1 = ["id", "subject", "status"]; const DEFAULT_ROW2 = ["queue", "owner"]; 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 getSubtitleValue(key: string, ticket: Ticket, context: { users: User[]; queues: Queue[]; teamsList: Team[] }): string | null { if (key === "subject") return null; if (key === "id") return formatTicketId(ticket.id); if (key === "status") return statusLabel(ticket.status); if (key === "queue") return context.queues.find((q) => q.id === ticket.queue_id)?.name ?? ticket.queue_id.slice(0, 8); if (key === "owner") return ticket.owner_id ? (context.users.find((u) => u.id === ticket.owner_id)?.username ?? "assigned") : "Unassigned"; if (key === "team") return context.teamsList.find((t) => t.id === ticket.team_id)?.name ?? null; if (key === "created") return relativeTime(ticket.created_at); if (key === "updated") return relativeTime(ticket.updated_at); if (key.startsWith("cf.")) { const cfKey = key.slice(3); return ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value ?? null; } return null; } 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 [teamsList, setTeamsList] = useState([]); const [customFields, setCustomFields] = useState([]); const [clock, setClock] = useState(0); const [initialLoad, setInitialLoad] = useState(true); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [batchIds, setBatchIds] = useState>(new Set()); const [batchSaving, setBatchSaving] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); const searchRef = useRef(searchQuery); searchRef.current = searchQuery; // Debounce search: update debouncedQuery 300ms after user stops typing useEffect(() => { const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300); return () => clearTimeout(timer); }, [searchQuery]); const [filters, setFilters] = useState([]); const [row1Keys, setRow1Keys] = useState(() => { if (typeof window === "undefined") return DEFAULT_ROW1; try { const stored = localStorage.getItem(LS_KEY); if (stored) { const parsed = JSON.parse(stored); if (parsed.row1) return parsed.row1 as string[]; // Migrate old format if (Array.isArray(parsed)) { return parsed.filter((c: any) => c.visible !== false && c.display !== "subtitle" && c.display !== "hidden").map((c: any) => c.key); } } } catch { /* ignore */ } return DEFAULT_ROW1; }); const [row2Entries, setRow2Entries] = useState(() => { if (typeof window === "undefined") return DEFAULT_ROW2.map((k) => ({ key: k, under: k })); try { const stored = localStorage.getItem(LS_KEY); if (stored) { const parsed = JSON.parse(stored); if (parsed.row2Entries) return parsed.row2Entries as SubtitleEntry[]; if (parsed.row2 && Array.isArray(parsed.row2)) { // Migrate: old flat keys → entries with self as under if (typeof parsed.row2[0] === "string") return parsed.row2.map((k: string) => ({ key: k, under: k })); return parsed.row2 as SubtitleEntry[]; } } } catch { /* ignore */ } return DEFAULT_ROW2.map((k) => ({ key: k, under: k })); }); const [density, setDensity] = useState("comfortable"); const [sortKey, setSortKey] = useState("updated"); const [resizingCol, setResizingCol] = useState(null); const [colWidths, setColWidths] = useState>({}); const [colPickerOpen, setColPickerOpen] = useState(false); // Persist layout to localStorage useEffect(() => { try { localStorage.setItem(LS_KEY, JSON.stringify({ row1: row1Keys, row2Entries })); } catch { /* ignore */ } }, [row1Keys, row2Entries]); // Build available fields: base + custom fields const allFields = useMemo(() => { const cfFields: ColumnConfig[] = customFields .filter((cf) => cf.key) .map((cf) => ({ key: `cf.${cf.key}`, label: cf.name, width: 140 })); return [...ALL_FIELDS, ...cfFields]; }, [customFields]); const fieldByKey = useMemo(() => { const map = new Map(); for (const f of allFields) map.set(f.key, f); return map; }, [allFields]); const row1Fields = row1Keys.map((k) => fieldByKey.get(k)).filter(Boolean) as ColumnConfig[]; const row2EntriesResolved = row2Entries.filter((e) => fieldByKey.has(e.key)); // Group subtitle entries by which column they sit under const subsByColumn = new Map(); for (const e of row2EntriesResolved) { const list = subsByColumn.get(e.under) ?? []; list.push(e); subsByColumn.set(e.under, list); } const colWidth = (key: string, fallback: number) => colWidths[key] ?? fallback; // 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); const [addFilterOperator, setAddFilterOperator] = useState("is"); const [addFilterValue, setAddFilterValue] = useState(""); const [filterPopoverPos, setFilterPopoverPos] = useState({ left: 0, top: 0 }); const addFilterBtnRef = useRef(null); 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 apiSubject = filters.find((f) => f.field === "subject"); const apiCreated = filters.find((f) => f.field === "created"); const apiUpdated = filters.find((f) => f.field === "updated"); 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, teamsRes] = await Promise.all([ getTickets({ q: debouncedQuery.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, subject: apiSubject ? `${apiSubject.operator}:${apiSubject.value}` : undefined, created: apiCreated ? `${apiCreated.operator}:${apiCreated.value}` : undefined, updated: apiUpdated ? `${apiUpdated.operator}:${apiUpdated.value}` : undefined, }), getQueues(), getUsers(), getCustomFields(), getLifecycles(), getTeams(), ]); 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 (teamsRes?.error) { setError((current) => current ?? teamsRes.error); } else if (teamsRes?.data) { setTeamsList(teamsRes.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); setInitialLoad(false); setRefreshing(false); setClock(fetchedAt); }, [filters, newQueueId, routeQueue, debouncedQuery] ); 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) { // Load row1/row2 from saved view columns if available, else fall back to default const cols = view.columns as any[]; const r1 = cols.filter((c: any) => c.display !== "subtitle" && c.visible !== false).map((c: any) => c.key); const r2 = cols.filter((c: any) => c.display === "subtitle").map((c: any) => c.key); if (r1.length > 0) setRow1Keys(r1); if (r2.length > 0) setRow2Entries(r2.map((k: string) => ({ key: k, under: k }))); } } }); } else if (!paramViewId && viewIdFromUrl) { // User navigated away from a view — clear filters and reset columns setFilters([]); setSearchQuery(""); setRow1Keys(DEFAULT_ROW1); setRow2Entries(DEFAULT_ROW2.map((k) => ({ key: k, under: k }))); } }, [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 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; return true; }) .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, sortKey, tickets, view]); 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))); } }; 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 handleBatchAction = async (update: { status?: string; owner_id?: string | null; team_id?: string | null }) => { setBatchSaving(true); const ids = Array.from(batchIds); const { data, error } = await batchUpdateTickets({ ticket_ids: ids, ...update }); setBatchSaving(false); if (!error && data) { const failed = data.results.filter((r) => !r.ok); if (failed.length > 0) { setError(`${failed.length} of ${ids.length} tickets failed to update`); } } else if (error) { setError(error); } 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 leftField = fieldByKey.get(leftKey); const rightField = rightKey ? fieldByKey.get(rightKey) : null; const leftStart = colWidths[leftKey] ?? leftField?.width ?? 140; const rightStart = rightField ? (colWidths[rightField.key] ?? rightField.width) : 140; const onMove = (ev: MouseEvent) => { const delta = ev.clientX - startX; const newLeft = Math.max(50, Math.min(800, leftStart + delta)); const newRight = rightField ? Math.max(50, Math.min(800, rightStart - delta)) : undefined; setColWidths((prev) => { const next = { ...prev, [leftKey]: newLeft }; if (rightField && newRight !== undefined) next[rightField.key] = newRight; return next; }); }; const onUp = () => { setResizingCol(null); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; 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 (initialLoad && loading) return ; return (
Work queue

{visibleTitle}

{filters.length > 0 && ( )}
{/* Row 1: search + sort/density */}
setSearchQuery(event.target.value)} placeholder="Search tickets, comments, custom fields..." 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} ))}
{filters.length > 0 && ( )}
{error && (
{error}
)}
{filteredTickets.length === 0 ? (

No tickets in this view

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

) : ( <> {batchIds.size > 0 && (
{batchIds.size} selected
)} {/* Table layout for consistent column alignment */}
{/* Column header */}
{row1Fields.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}
))}
{/* Subtitle header — labels for each row2 field under its matching column */} {row2EntriesResolved.length > 0 && (
{row1Fields.map((col) => { const subsHere = subsByColumn.get(col.key) ?? []; const orphans = col.key === "subject" ? row2EntriesResolved.filter((e) => !row1Fields.some((rf) => rf.key === e.under)) : []; if (subsHere.length > 0 || orphans.length > 0) { return (
{subsHere.map((e) => { const f = fieldByKey.get(e.key); return {f?.label ?? e.key}; })} {orphans.map((e) => { const f = fieldByKey.get(e.key); return {f?.label ?? e.key}; })}
); } return
; })}
)} {filteredTickets.map((ticket) => { const selected = false; const ownerName = ticket.owner_id ? users.find((u) => u.id === ticket.owner_id)?.username ?? "assigned" : null; return (
router.push(`/tickets/${ticket.id}`)} onDoubleClick={() => router.push(`/tickets/${ticket.id}`)} onKeyDown={(e) => { if (e.key === "Enter") router.push(`/tickets/${ticket.id}`); }} className={cn( "group 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 w-3 rounded border-border accent-primary opacity-0 group-hover:opacity-100 transition-opacity" /> {(ticket as any).sla_breached && ( )} {!(ticket as any).sla_breached && (ticket as any).sla_resolution_deadline && ( )}
{row1Fields.map((col) => { const cellStyle = { display: "table-cell" as const, width: colWidth(col.key, 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; const cfSubs = subsByColumn.get(col.key) ?? []; return (
{cfValue ?? "—"} {density === "comfortable" && cfSubs.map((e) =>
{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}
)}
); } switch (col.key) { case "id": { const idSubs = subsByColumn.get("id") ?? []; return (
{formatTicketId(ticket.id)} {density === "comfortable" && idSubs.map((e) =>
{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}
)}
); } case "subject": { // Subtitle fields that don't have a matching row1 column // Subtitle under subject + orphans (under column not in row1) const subsHere = row2EntriesResolved.filter((e) => e.under === "subject" || !row1Fields.some((rf) => rf.key === e.under) ); const subParts: string[] = []; const ctx = { users, queues, teamsList }; for (const e of subsHere) { const v = getSubtitleValue(e.key, ticket, ctx); if (v) subParts.push(v); } return (
{ticket.subject} {density === "comfortable" && subParts.length > 0 && (
{subParts.map((part, i) => ( {i > 0 && } {part} ))}
)}
); } case "status": const statusSubs = subsByColumn.get("status") ?? []; return (
{density === "comfortable" && statusSubs.map((e) => (
{getSubtitleValue(e.key, ticket, { users, queues, teamsList })}
))}
); case "queue": { const subs = subsByColumn.get("queue") ?? []; return (
{queueName(queues, ticket.queue_id)} {density === "comfortable" && subs.map((e) => (
{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}
))}
); } case "owner": { const subs = subsByColumn.get("owner") ?? []; return (
{ownerName ?? "—"} {density === "comfortable" && subs.map((e) => (
{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}
))}
); } case "created": { const subs = subsByColumn.get("created") ?? []; return (
{relativeTime(ticket.created_at)} {density === "comfortable" && subs.map((e) => (
{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}
))}
); } case "updated": { const subs = subsByColumn.get("updated") ?? []; return (
{relativeTime(ticket.updated_at)} {density === "comfortable" && subs.map((e) => (
{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}
))}
); } case "team": { const subs = subsByColumn.get("team") ?? []; return (
{teamsList.find((t) => t.id === ticket.team_id)?.name ?? "—"} {density === "comfortable" && subs.map((e) => (
{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}
))}
); } default: return
; } })}
); })}
)}
New ticket Create a request, incident, task, or follow-up for the right queue.