"use client"; import { useState, useEffect, use, useCallback } from "react"; import type { ReactNode } from "react"; import Link from "next/link"; import { formatDistanceToNow } from "date-fns"; import { ArrowLeftIcon, BotIcon, CheckCircle2Icon, ChevronDownIcon, CircleIcon, Clock3Icon, FileTextIcon, MessageSquareIcon, PaperclipIcon, PencilIcon, SaveIcon, SendIcon, UserRoundIcon, XIcon, } from "lucide-react"; import { getTicket, getTicketTransactions, getQueues, getLifecycles, getUsers, getQueueCustomFields, previewTicket, updateTicket, updateTicketCustomField, sendComment, } from "@/lib/api"; import type { Ticket, Transaction, Queue, Lifecycle, User, QueueCustomField, PreviewResult, UpdateResult, } from "@/lib/types"; import { Separator } from "@/components/ui/separator"; import { cn, formatTicketId } from "@/lib/utils"; const STATUS_COLORS: Record = { new: "#64748b", open: "#2563eb", in_progress: "#d97706", resolved: "#16a34a", closed: "#71717a", }; const STATUS_LABELS: Record = { new: "New", open: "Open", in_progress: "In progress", resolved: "Resolved", closed: "Closed", }; const DEFAULT_STATUSES = ["new", "open", "in_progress", "resolved", "closed"]; function getInitial(name: string): string { if (!name) return "?"; return name.charAt(0).toUpperCase(); } function getInitialColor(name: string): string { const colors = ["#2563eb", "#0f766e", "#9333ea", "#d97706", "#16a34a"]; let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } return colors[Math.abs(hash) % colors.length] ?? colors[0]; } function statusLabel(status: string) { return STATUS_LABELS[status] ?? status.replaceAll("_", " "); } function StatusBadge({ status }: { status: string }) { return ( {statusLabel(status)} ); } function userLabel(users: User[], userId: string | null) { if (!userId) return "Unassigned"; const user = users.find((item) => item.id === userId); return user?.username ?? userId; } function TransactionCard({ tx, users, customFieldLabels, }: { tx: Transaction; users: User[]; customFieldLabels: Record; }) { const isSystem = tx.transaction_type === "StatusChange" || tx.transaction_type === "SetOwner" || tx.transaction_type === "CustomFieldChange" || tx.transaction_type === "Create"; const isInternal = tx.transaction_type === "Comment"; const isMessage = tx.transaction_type === "Correspond" || isInternal; const timeAgo = formatDistanceToNow(new Date(tx.created_at), { addSuffix: true }); const body = typeof tx.data === "object" && tx.data !== null && "body" in (tx.data as Record) ? String((tx.data as Record).body) : tx.transaction_type; if (isSystem) { let message = tx.transaction_type; if (tx.transaction_type === "Create") { message = "Ticket created"; } else if (tx.transaction_type === "StatusChange") { const oldLabel = tx.old_value ? statusLabel(tx.old_value) : "?"; const newLabel = tx.new_value ? statusLabel(tx.new_value) : "?"; message = `Status changed from ${oldLabel} to ${newLabel}`; } else if (tx.transaction_type === "SetOwner") { message = tx.new_value ? `Assigned to ${userLabel(users, tx.new_value)}` : "Unassigned"; } else if (tx.transaction_type === "CustomFieldChange") { const fieldName = tx.field ? customFieldLabels[tx.field] ?? "Custom field" : "Custom field"; message = tx.new_value ? `${fieldName} set to ${tx.new_value}` : `${fieldName} cleared`; } return (
{message} {timeAgo}
); } return (
{getInitial(userLabel(users, tx.creator_id))}
{isMessage ? ( ) : ( )} {userLabel(users, tx.creator_id)} {isInternal && ( Internal )}
{timeAgo}

{body}

); } function PropertyRow({ label, value }: { label: string; value: ReactNode }) { return (
{label}
{value}
); } export default function TicketDetailPage({ params, }: { params: Promise<{ id: string }>; }) { const { id: idParam } = use(params); const id = Number(idParam); const [ticket, setTicket] = useState(null); const [transactions, setTransactions] = useState([]); const [queue, setQueue] = useState(null); const [lifecycles, setLifecycles] = useState([]); const [users, setUsers] = useState([]); const [queueFields, setQueueFields] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [fieldError, setFieldError] = useState(null); const [replyText, setReplyText] = useState(""); const [replyMode, setReplyMode] = useState<"public" | "internal">("public"); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); const [statusSelectOpen, setStatusSelectOpen] = useState(false); const [pendingStatus, setPendingStatus] = useState(null); const [preview, setPreview] = useState(null); const [previewError, setPreviewError] = useState(null); const [applyLoading, setApplyLoading] = useState(false); const [scripResults, setScripResults] = useState(null); const [editingSubject, setEditingSubject] = useState(false); const [subjectDraft, setSubjectDraft] = useState(""); const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | null>(null); const [customFieldDrafts, setCustomFieldDrafts] = useState>({}); const [customFieldSaving, setCustomFieldSaving] = useState(null); const fetchData = useCallback(async () => { setLoading(true); setError(null); const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes] = await Promise.all([ getTicket(id), getTicketTransactions(id), getQueues(), getLifecycles(), getUsers(), ]); if (ticketRes.error) { setError(ticketRes.error); } else { setTicket(ticketRes.data); setSubjectDraft(ticketRes.data?.subject ?? ""); } if (txRes.error) { setError((prev) => prev || txRes.error); } else { setTransactions(txRes.data ?? []); } if (queuesRes.data && ticketRes.data) { const matchedQueue = queuesRes.data.find((item) => item.id === ticketRes.data!.queue_id); if (matchedQueue) setQueue(matchedQueue); } if (lifecycleRes.error) { setError((prev) => prev || lifecycleRes.error); } else { setLifecycles(lifecycleRes.data ?? []); } if (ticketRes.data) { const { data, error } = await getQueueCustomFields(ticketRes.data.queue_id); if (error) { setError((prev) => prev || error); } else { setQueueFields(data ?? []); } setCustomFieldDrafts( Object.fromEntries( (ticketRes.data.custom_fields ?? []).map((value) => [value.custom_field_id, value.value]) ) ); } if (usersRes.error) { setError((prev) => prev || usersRes.error); } else { setUsers(usersRes.data ?? []); } setLoading(false); }, [id]); useEffect(() => { void Promise.resolve().then(() => fetchData()); }, [fetchData]); const handleStatusSelect = async (newStatus: string) => { setPendingStatus(newStatus); setStatusSelectOpen(false); setPreview(null); setPreviewError(null); setScripResults(null); if (newStatus === ticket?.status) return; const { data, error } = await previewTicket(id, { status: newStatus }); if (error) { setPreviewError(error); setPendingStatus(null); } else { setPreview(data); } }; const handleApplyStatus = async () => { if (!pendingStatus) return; setApplyLoading(true); setPreviewError(null); const { data, error } = await updateTicket(id, { status: pendingStatus }); setApplyLoading(false); if (error) { setPreviewError(error); } else if (data) { setTicket(data.ticket); setScripResults(data.scrip_results); setPreview(null); setPendingStatus(null); const txRes = await getTicketTransactions(id); if (txRes.data) setTransactions(txRes.data); } }; const handleCancelStatus = () => { setPendingStatus(null); setPreview(null); setPreviewError(null); setScripResults(null); }; const refreshTransactions = async () => { const txRes = await getTicketTransactions(id); if (txRes.data) setTransactions(txRes.data); }; const handleSaveSubject = async () => { const nextSubject = subjectDraft.trim(); if (!ticket || !nextSubject || nextSubject === ticket.subject || fieldSaving) { setEditingSubject(false); setSubjectDraft(ticket?.subject ?? ""); return; } setFieldSaving("subject"); setFieldError(null); const { data, error } = await updateTicket(id, { subject: nextSubject }); setFieldSaving(null); if (error) { setFieldError(error); return; } if (data) { setTicket(data.ticket); setSubjectDraft(data.ticket.subject); setEditingSubject(false); await refreshTransactions(); } }; const handleOwnerChange = async (ownerId: string) => { if (!ticket || fieldSaving) return; const nextOwnerId = ownerId || null; if (nextOwnerId === ticket.owner_id) return; setFieldSaving("owner"); setFieldError(null); const { data, error } = await updateTicket(id, { owner_id: nextOwnerId }); setFieldSaving(null); if (error) { setFieldError(error); return; } if (data) { setTicket(data.ticket); await refreshTransactions(); } }; const handleCustomFieldSave = async (fieldId: string) => { if (!ticket || customFieldSaving) return; const value = customFieldDrafts[fieldId]?.trim() ?? ""; const currentValue = ticket.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? ""; if (value === currentValue) return; const field = queueFields.find((assignment) => assignment.custom_field_id === fieldId)?.custom_field; if (value && field?.pattern) { const regex = new RegExp(field.pattern); if (!regex.test(value)) { setFieldError(`${field.name}: value does not match the required pattern.`); return; } } setCustomFieldSaving(fieldId); setFieldError(null); const { error } = await updateTicketCustomField(id, fieldId, value); setCustomFieldSaving(null); if (error) { setFieldError(error); return; } const [ticketRes, txRes] = await Promise.all([ getTicket(id), getTicketTransactions(id), ]); if (ticketRes.data) { setTicket(ticketRes.data); setCustomFieldDrafts( Object.fromEntries( (ticketRes.data.custom_fields ?? []).map((item) => [item.custom_field_id, item.value]) ) ); } if (txRes.data) setTransactions(txRes.data); }; const customFieldValue = (fieldId: string) => ticket?.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? ""; const handleSendComment = async () => { if (!replyText.trim() || sending) return; setSending(true); setSendError(null); const { error } = await sendComment(id, { body: replyText.trim(), internal: replyMode === "internal", }); setSending(false); if (error) { setSendError(error); } else { setReplyText(""); setSendError(null); const txRes = await getTicketTransactions(id); if (txRes.data) setTransactions(txRes.data); } }; if (loading) { return (
{Array.from({ length: 6 }).map((_, index) => (
))}
); } if (error && !ticket) { return (

{error}

); } if (!ticket) { return (

Ticket not found

); } const lifecycle = queue?.lifecycle_id ? lifecycles.find((item) => item.id === queue.lifecycle_id) : null; const statusOptions = lifecycle ? Array.from(new Set([ ticket.status, ...(lifecycle.definition.transitions[ticket.status] ?? []), ...(lifecycle.definition.transitions["*"] ?? []), ])) : Array.from(new Set([ticket.status, ...DEFAULT_STATUSES])); const currentStatusColor = STATUS_COLORS[ticket.status] || STATUS_COLORS.new; const currentStatusLabel = statusLabel(ticket.status); const customFieldLabels = Object.fromEntries( queueFields.map((assignment) => [ assignment.custom_field_id, assignment.custom_field?.name ?? assignment.custom_field_id, ]) ); return (
Work queue
Updated {formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}
{formatTicketId(ticket.id)} {queue?.name || ticket.queue_id}
{editingSubject ? ( <>