"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) && (
setExpanded((v) => !v)}
>
{expanded ? (
<> Mostrar solo el último mensaje>
) : (
<> Ver mensaje completo>
)}
)}
);
}
if (!hasMore) {
return
;
}
return (
{expanded ? (
) : (
)}
setExpanded((v) => !v)}
>
{expanded ? (
<> Mostrar solo el último mensaje>
) : (
<> Ver mensaje completo>
)}
);
}
const { preview, hasMore } = extractLatestMessage(body);
if (!hasMore) {
return {body}
;
}
return (
{expanded ? body : preview}
setExpanded((v) => !v)}
>
{expanded ? (
<> Mostrar solo el último mensaje>
) : (
<> Ver mensaje completo>
)}
);
}
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) */}
{
setStatusOpen((o) => !o);
setAssignOpen(false);
}}
>
Cambiar Estado
{statusOpen && (
<>
setStatusOpen(false)}
/>
{STATUS_OPTIONS.map((s) => (
handleStatusChange(s.value)}
>
{s.label}
))}
>
)}
{/* Assign (dropdown controlado por React: sin bootstrap.js) */}
{
setAssignOpen((o) => !o);
setStatusOpen(false);
}}
>
Asignar
{assignOpen && (
<>
setAssignOpen(false)}
/>
Departamento
{groups.length === 0 && (
Sin departamentos
)}
{groups.map((g) => (
handleAssignDepartment(g.id)}
>
{g.name}
))}
Agente
handleAssignAgent(null)}>
Sin asignar
{team.map((m) => (
handleAssignAgent(m.idUsuario)}
>
{m.nombres} {m.apPaterno}
))}
>
)}
)}
{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) => (
{
downloadAttachment(a).catch(() => setError("No se pudo descargar el adjunto"));
}}
>
{a.filename} ({formatFileSize(a.size)})
))}
{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 */}
{/* 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"}
);
}