import { useEffect, useRef, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useShortcut } from '../hooks/useShortcuts'; import { format, formatDistanceToNow } from 'date-fns'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Pencil, Trash2, Send, X, Check, MessageSquare, ClipboardList, FileText, ArrowLeft, ChevronDown, ChevronRight, } from 'lucide-react'; import { toast } from 'sonner'; import Layout from '../components/Layout'; import Modal from '../components/Modal'; import SeverityBadge from '../components/SeverityBadge'; import StatusBadge from '../components/StatusBadge'; import CTISelect from '../components/CTISelect'; import Avatar from '../components/Avatar'; import MentionTextarea from '../components/MentionTextarea'; import { injectMentionLinks } from '../lib/mentions'; import { TicketStatus } from '../types'; import { useAuth } from '../contexts/AuthContext'; import { useTicket, useTicketAudit, useUpdateTicket, useDeleteTicket, useUsers, useAddComment, useDeleteComment, } from '../api/queries'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; type Tab = 'overview' | 'comments' | 'audit'; const SEVERITY_OPTIONS = [ { value: 1, label: 'SEV 1 — Critical' }, { value: 2, label: 'SEV 2 — High' }, { value: 3, label: 'SEV 3 — Medium' }, { value: 4, label: 'SEV 4 — Low' }, { value: 5, label: 'SEV 5 — Minimal' }, ]; const AUDIT_LABELS: Record = { CREATED: 'created this ticket', STATUS_CHANGED: 'changed status', ASSIGNEE_CHANGED: 'changed assignee', SEVERITY_CHANGED: 'changed severity', REROUTED: 'rerouted ticket', TITLE_CHANGED: 'updated title', OVERVIEW_CHANGED: 'updated overview', COMMENT_ADDED: 'added a comment', COMMENT_DELETED: 'deleted a comment', }; const AUDIT_COLORS: Record = { CREATED: 'bg-green-500', STATUS_CHANGED: 'bg-blue-500', ASSIGNEE_CHANGED: 'bg-purple-500', SEVERITY_CHANGED: 'bg-orange-500', REROUTED: 'bg-cyan-500', TITLE_CHANGED: 'bg-gray-500', OVERVIEW_CHANGED: 'bg-gray-500', COMMENT_ADDED: 'bg-gray-500', COMMENT_DELETED: 'bg-red-500', }; const COMMENT_ACTIONS = new Set(['COMMENT_ADDED', 'COMMENT_DELETED']); export default function TicketDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { user: authUser } = useAuth(); const [tab, setTab] = useState('overview'); const [editing, setEditing] = useState(false); const [reroutingCTI, setReroutingCTI] = useState(false); const [commentBody, setCommentBody] = useState(''); const [preview, setPreview] = useState(false); const [expandedLogs, setExpandedLogs] = useState>(new Set()); const [deleteOpen, setDeleteOpen] = useState(false); const commentTextareaRef = useRef(null); const [editForm, setEditForm] = useState({ title: '', overview: '' }); const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' }); const [statusOpen, setStatusOpen] = useState(false); const [severityOpen, setSeverityOpen] = useState(false); const [assigneeOpen, setAssigneeOpen] = useState(false); const [expandedDates, setExpandedDates] = useState>(new Set()); const [expandedCommentDates, setExpandedCommentDates] = useState>(new Set()); // Draft autosave const draftKey = id ? `comment-draft:${id}` : null; useEffect(() => { if (!draftKey) return; const saved = localStorage.getItem(draftKey); if (saved) setCommentBody(saved); }, [draftKey]); useEffect(() => { if (!draftKey) return; if (commentBody) localStorage.setItem(draftKey, commentBody); else localStorage.removeItem(draftKey); }, [commentBody, draftKey]); const { data: ticket, isLoading } = useTicket(id); const { data: users = [] } = useUsers(); const { data: auditLogs = [] } = useTicketAudit(id, tab === 'audit'); const updateTicket = useUpdateTicket(); const deleteTicketMutation = useDeleteTicket(); const addComment = useAddComment(id); const deleteCommentMutation = useDeleteComment(id); const toggleDate = (key: string) => setExpandedDates((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); const toggleCommentDate = (commentId: string) => setExpandedCommentDates((prev) => { const next = new Set(prev); if (next.has(commentId)) next.delete(commentId); else next.add(commentId); return next; }); const isAdmin = authUser?.role === 'ADMIN'; const patch = async (payload: Record) => { if (!ticket) return; await updateTicket.mutateAsync({ id: ticket.displayId, data: payload }); }; const startEdit = () => { if (!ticket) return; setEditForm({ title: ticket.title, overview: ticket.overview }); setEditing(true); setTab('overview'); }; const saveEdit = async () => { await patch({ title: editForm.title, overview: editForm.overview }); setEditing(false); }; const startReroute = () => { if (!ticket) return; setPendingCTI({ categoryId: ticket.categoryId, typeId: ticket.typeId, itemId: ticket.itemId }); setReroutingCTI(true); }; const saveReroute = async () => { await patch(pendingCTI); setReroutingCTI(false); }; const confirmDeleteTicket = async () => { if (!ticket) return; await deleteTicketMutation.mutateAsync(ticket.displayId); toast.success('Ticket deleted'); navigate('/tickets'); }; const submitComment = async (e: React.FormEvent) => { e.preventDefault(); if (!ticket || !commentBody.trim()) return; await addComment.mutateAsync(commentBody.trim()); setCommentBody(''); setPreview(false); if (draftKey) localStorage.removeItem(draftKey); }; const handleDeleteComment = async (commentId: string) => { await deleteCommentMutation.mutateAsync(commentId); }; useShortcut('e', (e) => { if (!ticket || editing) return; e.preventDefault(); startEdit(); }, [ticket, editing]); useShortcut('r', (e) => { if (!ticket) return; e.preventDefault(); setTab('comments'); setPreview(false); setTimeout(() => commentTextareaRef.current?.focus(), 40); }, [ticket]); const toggleLog = (logId: string) => { setExpandedLogs((prev) => { const next = new Set(prev); if (next.has(logId)) next.delete(logId); else next.add(logId); return next; }); }; if (isLoading) { return (
Loading...
); } if (!ticket) { return (
Ticket not found
); } const commentCount = ticket.comments?.length ?? 0; const agentUsers = users; const statusOptions: { value: TicketStatus; label: string }[] = [ { value: 'OPEN', label: 'Open' }, { value: 'IN_PROGRESS', label: 'In Progress' }, { value: 'RESOLVED', label: 'Resolved' }, ...(isAdmin ? [{ value: 'CLOSED' as TicketStatus, label: 'Closed' }] : []), ]; return ( {/* Back link */}
{/* ── Main content ── */}
{/* Title card */}
{ticket.displayId} {ticket.category.name} › {ticket.type.name} › {ticket.item.name}
{editing ? ( setEditForm((f) => ({ ...f, title: e.target.value }))} className="w-full text-2xl font-bold text-gray-100 bg-transparent border-0 border-b-2 border-indigo-500 focus:outline-none pb-1" autoFocus /> ) : (

{ticket.title}

)}
{/* Tabs + content */}
{/* Tab bar */}
{( [ { key: 'overview', icon: FileText, label: 'Overview' }, { key: 'comments', icon: MessageSquare, label: `Comments${commentCount > 0 ? ` (${commentCount})` : ''}`, }, { key: 'audit', icon: ClipboardList, label: 'Audit Log' }, ] as const ).map(({ key, icon: Icon, label }) => ( ))}
{/* ── Overview ── */} {tab === 'overview' && (
{editing ? (