"use client"; import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { useAuth } from "@/lib/auth"; import { getTicket, getComments, getTicketEmails, addComment, updateTicketStatus, assignTicket, getGroups, getTeam, type Ticket, type Comment, type Group, type TeamMember, type TicketSentEmail, type TicketReceivedEmail, } from "@/lib/api"; import BadgeStatus from "@/components/BadgeStatus"; import BadgePriority from "@/components/BadgePriority"; import { formatDateTime } from "@/lib/format"; import Link from "next/link"; const STATUS_OPTIONS = [ { value: "open", label: "Abierto" }, { value: "pending", label: "Pendiente" }, { value: "in_progress", label: "En Progreso" }, { value: "resolved", label: "Resuelto" }, { value: "closed", label: "Cerrado" }, ]; // Hilo único estilo Freshdesk: comentarios + correos enviados desde // soporte + respuestas recibidas, ordenados cronológicamente. Las // respuestas por email que ya generaron comentario se muestran una // sola vez (versión email, con asunto y cuerpo). type ThreadItem = | { kind: "comment"; key: string; createdAt: string; userName?: string; content: string; isInternal: boolean } | { kind: "sent"; key: string; createdAt: string; who: string; subject: string; body?: string; fallback: string } | { kind: "received"; key: string; createdAt: string; who: string; subject: string; body?: string; status: string; error?: string }; function buildThread( comments: Comment[], sent: TicketSentEmail[], received: TicketReceivedEmail[] ): ThreadItem[] { const linked = new Set( received.map((r) => r.commentId).filter((v): v is number => typeof v === "number") ); const items: ThreadItem[] = [ ...comments .filter((c) => !linked.has(c.id)) .map((c): ThreadItem => ({ kind: "comment", key: `comment-${c.id}`, createdAt: c.createdAt, userName: c.userName, content: c.content, isInternal: c.isInternal, })), ...sent.map((e): ThreadItem => ({ kind: "sent", key: `sent-${e.id}`, createdAt: e.createdAt, who: e.recipient, subject: e.subject, body: e.body || undefined, fallback: e.template, })), ...received.map((e, i): ThreadItem => ({ kind: "received", key: `recv-${i}-${e.createdAt}`, createdAt: e.createdAt, who: e.authorEmail, subject: e.subject, body: e.body || undefined, status: e.status, error: e.error, })), ]; return items.sort((a, b) => +new Date(a.createdAt) - +new Date(b.createdAt)); } export default function TicketDetailPage() { const params = useParams(); const id = Number(params.id); const { user } = useAuth(); const isTeam = user?.role === "admin" || user?.role === "agent" || user?.role === "supervisor"; const [ticket, setTicket] = useState(null); const [comments, setComments] = useState([]); const [sentEmails, setSentEmails] = useState([]); const [receivedEmails, setReceivedEmails] = useState([]); const [groups, setGroups] = useState([]); const [team, setTeam] = useState([]); const [loading, setLoading] = useState(true); const [comment, setComment] = useState(""); const [isInternal, setIsInternal] = useState(false); const [submitting, setSubmitting] = useState(false); const [acting, setActing] = useState(false); const [error, setError] = useState(""); const [statusOpen, setStatusOpen] = useState(false); const [assignOpen, setAssignOpen] = useState(false); useEffect(() => { Promise.all([ getTicket(id).catch(() => null), getComments(id).catch(() => []), getTicketEmails(id).catch(() => ({ sent: [], received: [] })), getGroups().catch(() => []), getTeam().catch(() => []), ]) .then(([ticketData, commentsData, emailsData, groupsData, teamData]) => { setTicket(ticketData); setComments(commentsData); setSentEmails(emailsData.sent ?? []); setReceivedEmails(emailsData.received ?? []); setGroups(groupsData); setTeam(teamData); }) .finally(() => setLoading(false)); }, [id]); const refreshTicket = async () => { const updated = await getTicket(id).catch(() => null); if (updated) setTicket(updated); }; const handleAddComment = async (e: React.FormEvent) => { e.preventDefault(); if (!comment.trim()) return; setSubmitting(true); try { await addComment(id, comment, isInternal); const updated = await getComments(id).catch(() => []); setComments(updated); setComment(""); setIsInternal(false); } catch { // ignore } finally { setSubmitting(false); } }; const handleStatusChange = async (newStatus: string) => { setStatusOpen(false); setError(""); setActing(true); try { await updateTicketStatus(id, newStatus); await refreshTicket(); } catch (err: unknown) { setError(err instanceof Error ? err.message : "No se pudo cambiar el estado"); } finally { setActing(false); } }; const handleAssignDepartment = async (groupId: number) => { if (!ticket) return; setAssignOpen(false); setError(""); setActing(true); try { await assignTicket(id, ticket.assigneeId ?? 0, groupId); await refreshTicket(); } catch (err: unknown) { setError(err instanceof Error ? err.message : "No se pudo asignar el departamento"); } finally { setActing(false); } }; const handleAssignAgent = async (idUsuario: string | null) => { if (!ticket) return; setAssignOpen(false); setError(""); setActing(true); try { await assignTicket( id, idUsuario === null ? 0 : Number(idUsuario), ticket.groupId ?? undefined ); await refreshTicket(); } catch (err: unknown) { setError(err instanceof Error ? err.message : "No se pudo asignar el agente"); } finally { setActing(false); } }; if (loading) { return (
); } if (!ticket) { return (
Ticket no encontrado
Volver a tickets
); } return (
{/* Ticket Header */}
{ticket.ticketNumber}

{ticket.title}

{isTeam && (
{/* Status Change (dropdown controlado por React: sin bootstrap.js) */}
{statusOpen && ( <>
setStatusOpen(false)} />
    {STATUS_OPTIONS.map((s) => (
  • ))}
)}
{/* Assign (dropdown controlado por React: sin bootstrap.js) */}
{assignOpen && ( <>
setAssignOpen(false)} />
  • Departamento
  • {groups.length === 0 && (
  • Sin departamentos
  • )} {groups.map((g) => (
  • ))}

  • Agente
  • {team.map((m) => (
  • ))}
)}
)}
{error && (
{error}
)}
{/* Conversación estilo Freshdesk: comentarios + hilo de correos */}
Conversación ({buildThread(comments, sentEmails, receivedEmails).length})
{(() => { const thread = buildThread(comments, sentEmails, receivedEmails); if (thread.length === 0) { return (
Sin conversación aún
); } return thread.map((m) => { if (m.kind === "comment") { return (
{m.userName || "Usuario"} {m.isInternal && ( Nota Interna )}
{m.content}
{formatDateTime(m.createdAt)}
); } const isSent = m.kind === "sent"; return (
{isSent ? `Soporte → ${m.who}` : `De: ${m.who}`}
{m.subject}
{isSent ? ( m.body ? (
) : (
Plantilla: {m.fallback}
) ) : m.body ? (
{m.body}
) : (
{m.status === "inserted" ? "Agregado como comentario" : `Omitido: ${m.error || m.status}`}
)}
{formatDateTime(m.createdAt)}
); }); })()} Las respuestas con etiqueta HD-{String(id).padStart(6, "0")} en el asunto llegan a este hilo (ingesta cada 5 min).
{/* Comment Form */}
setIsInternal(e.target.checked)} />
{/* Ticket Info Sidebar */}
Información del Ticket
Solicitante {ticket.requesterName || "N/A"}
Email {ticket.requesterEmail || "N/A"}
Asignado a {ticket.assigneeName || "Sin asignar"}
Grupo {ticket.groupName || "N/A"}
Creado {formatDateTime(ticket.createdAt)}
Actualizado {formatDateTime(ticket.updatedAt)}
{/* Description */}
Descripción

{ticket.description || "Sin descripción"}

); }