"use client"; import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { ArrowDownAZIcon, CheckCircle2Icon, ChevronRightIcon, GaugeIcon, 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 } from "@/lib/api"; import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, 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"; 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 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 [searchQuery, setSearchQuery] = useState(""); const [filters, setFilters] = useState([]); const [density, setDensity] = useState("comfortable"); const [sortKey, setSortKey] = useState("updated"); // 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 [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, 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); } }); } else if (!paramViewId && viewIdFromUrl) { // User navigated away from a view — clear filters setFilters([]); setSearchQuery(""); } }, [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) ?? filteredTickets[0] ?? null; 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} ))}
{addFilterOpen && ( <>
{ setAddFilterOpen(false); setAddFilterField(null); }} />
{!addFilterField ? ( /* Step 1: choose field */ <> {customFields.map((cf) => ( ))} ) : ( /* Step 2: operator + value */
{addFilterField.startsWith("cf.") ? addFilterField.slice(3) : addFilterField}
{addFilterField === "queue" ? ( ) : addFilterField === "owner" ? ( ) : ( 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()) { const field = addFilterField; const value = addFilterValue; let valueLabel = value; setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel), }]); setAddFilterField(null); setAddFilterOpen(false); } }} /> )}
)}
)}
{error && (
{error}
)}
Ticket Subject Queue Status Updated
{filteredTickets.length === 0 ? (

No tickets in this view

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

) : ( filteredTickets.map((ticket) => { const selected = ticket.id === selectedId; return ( ); }) )}
New ticket Create a request, incident, task, or follow-up for the right queue.