"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, uploadTicketAttachments, getTicketAttachments, downloadAttachment, formatFileSize, type Ticket, type Comment, type Group, type TeamMember, type TicketAttachment, type TicketSentEmail, type TicketReceivedEmail, } from "@/lib/api"; import BadgeStatus from "@/components/BadgeStatus"; import BadgePriority from "@/components/BadgePriority"; import { formatDateTime } from "@/lib/format"; import { extractLatestMessage, extractLatestHtml, stripHtmlToText, cleanReplyForSend, } from "@/lib/emailThread"; 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 chat: 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). // Lado del chat: equipo (agentes/admin/supervisor) a la derecha, // solicitante a la izquierda, como en un chat. type ThreadItem = | { kind: "comment"; key: string; id: number; createdAt: string; userId: string; userName?: string; content: string; isInternal: boolean; side: "team" | "customer" } | { kind: "sent"; key: string; createdAt: string; who: string; subject: string; body?: string; fallback: string; side: "team" } | { kind: "received"; key: string; createdAt: string; who: string; subject: string; body?: string; status: string; error?: string; side: "customer" }; function isRequesterComment(userId: string, ticket: Ticket | null): boolean { if (!ticket) return false; return String(userId ?? "") !== "" && String(userId) === String(ticket.requesterId); } function buildThread( ticket: Ticket | null, 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}`, id: c.id, createdAt: c.createdAt, userId: c.userId, userName: c.userName, content: c.content, isInternal: c.isInternal, side: isRequesterComment(c.userId, ticket) ? "customer" : "team", })), ...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, side: "team", })), ...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, side: "customer", })), ]; return items.sort((a, b) => +new Date(a.createdAt) - +new Date(b.createdAt)); } // Muestra solo el último mensaje del correo (sin historial citado) con // opción de expandir al mensaje completo. Para enviados (HTML) recorta // blockquotes/citas; para recibidos (texto) corta en "On ... wrote:", // "De:", "----- Mensaje original -----", líneas ">" y firmas. function EmailBody({ body, isHtml }: { body: string; isHtml: boolean }) { const [expanded, setExpanded] = useState(false); if (isHtml) { const { previewHtml, hasMore } = extractLatestHtml(body); const previewText = stripHtmlToText(body); const { preview } = extractLatestMessage(previewText); // Sin DOM o sin recorte útil: muestra texto del último mensaje if (!previewHtml) { return (
{expanded ? previewText : preview}
{(hasMore || expanded) && ( )}
); } if (!hasMore) { return
; } return (
{expanded ? (
) : (
)}
); } const { preview, hasMore } = extractLatestMessage(body); if (!hasMore) { return
{body}
; } return (
{expanded ? body : preview}
); } 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 [cc, setCc] = useState(""); const [files, setFiles] = useState([]); // Ids ya subidos pendientes de vincular (si el envío del comentario falló). const [pendingIds, setPendingIds] = useState([]); const [ticketFiles, setTicketFiles] = useState([]); 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(() => []), getTicketAttachments(id).catch(() => []), ]) .then(([ticketData, commentsData, emailsData, groupsData, teamData, filesData]) => { setTicket(ticketData); setComments(commentsData); setSentEmails(emailsData.sent ?? []); setReceivedEmails(emailsData.received ?? []); setGroups(groupsData); setTeam(teamData); setTicketFiles(filesData ?? []); }) .finally(() => setLoading(false)); }, [id]); const refreshTicket = async () => { const updated = await getTicket(id).catch(() => null); if (updated) setTicket(updated); }; const MAX_FILE_BYTES = 10 * 1024 * 1024; const handleAddComment = async (e: React.FormEvent) => { e.preventDefault(); if (!comment.trim()) return; // Solo se envía/guarda el mensaje escrito, sin historial pegado o // citado (se usa para revisar casos en el futuro). const cleaned = cleanReplyForSend(comment); if (!cleaned) return; if (files.length > 5) { setError("Máximo 5 archivos por respuesta"); return; } const tooBig = files.find((f) => f.size > MAX_FILE_BYTES); if (tooBig) { setError(`${tooBig.name}: supera los 10 MB`); return; } setSubmitting(true); setError(""); let newIds: number[] = []; try { // 1) Subir adjuntos (si hay) y 2) publicar el comentario con CC e ids. if (files.length > 0) { const up = await uploadTicketAttachments(id, files); newIds = up.files.map((f) => f.id); } const ids = [...pendingIds, ...newIds]; await addComment(id, cleaned, isInternal, isInternal ? undefined : cc.trim() || undefined, ids.length > 0 ? ids : undefined); const [updated, emails, atts] = await Promise.all([ getComments(id).catch(() => []), getTicketEmails(id).catch(() => ({ sent: [], received: [] })), getTicketAttachments(id).catch(() => []), ]); setComments(updated); setSentEmails(emails.sent ?? []); setReceivedEmails(emails.received ?? []); setTicketFiles(atts ?? []); setComment(""); setIsInternal(false); setCc(""); setFiles([]); setPendingIds([]); } catch (err: unknown) { // Si la subida salió pero el comentario falló, los ids quedan // pendientes y se vinculan en el próximo envío. if (newIds.length > 0) { setPendingIds((prev) => [...new Set([...prev, ...newIds])]); setFiles([]); } setError(err instanceof Error ? err.message : "No se pudo enviar el mensaje"); } 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}

{ticket.archived && ( Archivado )}
{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 chat: equipo a la derecha, solicitante a la izquierda */}
Conversación ({buildThread(ticket, comments, sentEmails, receivedEmails).length}) Solicitante Equipo
{(() => { const thread = buildThread(ticket, comments, sentEmails, receivedEmails); if (thread.length === 0) { return (
Sin conversación aún
); } return thread.map((m) => { if (m.kind === "comment") { const alignClass = m.side === "team" ? "comment-agent" : "comment-customer"; return (
{m.side === "team" ? ( <>{m.userName || "Soporte"} ) : ( <>{m.userName || "Solicitante"} )} {m.isInternal && ( Nota Interna )}
{m.content}
{ticketFiles.filter((a) => a.commentId === m.id).map((a) => ( ))}
{formatDateTime(m.createdAt)}
); } const isSent = m.kind === "sent"; const alignClass = m.side === "team" ? "comment-agent" : "comment-customer"; return (
{isSent ? `Soporte → ${m.who}` : `De: ${m.who}`}
{m.subject}
{isSent ? ( m.body ? ( /i.test(m.body)} /> ) : (
Plantilla: {m.fallback}
) ) : 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). Los correos muestran solo el último mensaje; usa “Ver mensaje completo” para el historial.
{/* Comment Form */}
{!isInternal && isTeam && ( Respuesta pública: se envía por correo al solicitante solo el mensaje escrito (sin historial) y queda guardada para revisar el caso en el futuro. )}
{!isInternal && (
setCc(e.target.value)} />
)}
{files.length > 0 && (
{files.map((f, i) => ( {f.name} ({formatFileSize(f.size)})
)} {pendingIds.length > 0 && ( {pendingIds.length} archivo(s) ya subido(s): se vincularán al enviar. )}
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"}

); }