Files
helpdesk/frontend/src/app/(app)/tickets/[id]/page.tsx
T

753 lines
29 KiB
TypeScript

"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 (
<div>
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>
{expanded ? previewText : preview}
</div>
{(hasMore || expanded) && (
<button
type="button"
className="btn btn-link btn-sm p-0 mt-1 email-toggle"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? (
<><i className="bi bi-chevron-up me-1"></i>Mostrar solo el último mensaje</>
) : (
<><i className="bi bi-chevron-down me-1"></i>Ver mensaje completo</>
)}
</button>
)}
</div>
);
}
if (!hasMore) {
return <div className="comment-text" dangerouslySetInnerHTML={{ __html: previewHtml }} />;
}
return (
<div>
{expanded ? (
<div className="comment-text" dangerouslySetInnerHTML={{ __html: body }} />
) : (
<div className="comment-text" dangerouslySetInnerHTML={{ __html: previewHtml }} />
)}
<button
type="button"
className="btn btn-link btn-sm p-0 mt-1 email-toggle"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? (
<><i className="bi bi-chevron-up me-1"></i>Mostrar solo el último mensaje</>
) : (
<><i className="bi bi-chevron-down me-1"></i>Ver mensaje completo</>
)}
</button>
</div>
);
}
const { preview, hasMore } = extractLatestMessage(body);
if (!hasMore) {
return <div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{body}</div>;
}
return (
<div>
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>
{expanded ? body : preview}
</div>
<button
type="button"
className="btn btn-link btn-sm p-0 mt-1 email-toggle"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? (
<><i className="bi bi-chevron-up me-1"></i>Mostrar solo el último mensaje</>
) : (
<><i className="bi bi-chevron-down me-1"></i>Ver mensaje completo</>
)}
</button>
</div>
);
}
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<Ticket | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [sentEmails, setSentEmails] = useState<TicketSentEmail[]>([]);
const [receivedEmails, setReceivedEmails] = useState<TicketReceivedEmail[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [team, setTeam] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true);
const [comment, setComment] = useState("");
const [isInternal, setIsInternal] = useState(false);
const [cc, setCc] = useState("");
const [files, setFiles] = useState<File[]>([]);
// Ids ya subidos pendientes de vincular (si el envío del comentario falló).
const [pendingIds, setPendingIds] = useState<number[]>([]);
const [ticketFiles, setTicketFiles] = useState<TicketAttachment[]>([]);
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 (
<div className="loading-spinner">
<div className="spinner-border text-primary" role="status"></div>
</div>
);
}
if (!ticket) {
return (
<div className="empty-state">
<i className="bi bi-exclamation-triangle d-block"></i>
<h5>Ticket no encontrado</h5>
<Link href="/tickets" className="btn btn-primary mt-3">Volver a tickets</Link>
</div>
);
}
return (
<div>
{/* Ticket Header */}
<div className="ticket-header">
<div>
<div className="ticket-number mb-1">
<Link href="/tickets" className="text-decoration-none me-2">
<i className="bi bi-arrow-left"></i>
</Link>
{ticket.ticketNumber}
</div>
<h3 className="ticket-subject">{ticket.title}</h3>
<div className="d-flex gap-2 mt-2">
<BadgeStatus status={ticket.status} />
<BadgePriority priority={ticket.priority} />
{ticket.archived && (
<span className="badge bg-secondary" title="Ticket archivado">
<i className="bi bi-archive me-1"></i>Archivado
</span>
)}
</div>
</div>
{isTeam && (
<div className="d-flex gap-2">
{/* Status Change (dropdown controlado por React: sin bootstrap.js) */}
<div className="position-relative">
<button
className="btn btn-outline-primary btn-sm"
disabled={acting}
onClick={() => {
setStatusOpen((o) => !o);
setAssignOpen(false);
}}
>
<i className="bi bi-arrow-repeat me-1"></i>Cambiar Estado
</button>
{statusOpen && (
<>
<div
className="position-fixed top-0 start-0 w-100 h-100"
style={{ zIndex: 1000 }}
onClick={() => setStatusOpen(false)}
/>
<ul className="dropdown-menu show position-absolute end-0" style={{ zIndex: 1001 }}>
{STATUS_OPTIONS.map((s) => (
<li key={s.value}>
<button
className={`dropdown-item ${ticket.status === s.value ? "active" : ""}`}
onClick={() => handleStatusChange(s.value)}
>
{s.label}
</button>
</li>
))}
</ul>
</>
)}
</div>
{/* Assign (dropdown controlado por React: sin bootstrap.js) */}
<div className="position-relative">
<button
className="btn btn-outline-primary btn-sm"
disabled={acting}
onClick={() => {
setAssignOpen((o) => !o);
setStatusOpen(false);
}}
>
<i className="bi bi-person-plus me-1"></i>Asignar
</button>
{assignOpen && (
<>
<div
className="position-fixed top-0 start-0 w-100 h-100"
style={{ zIndex: 1000 }}
onClick={() => setAssignOpen(false)}
/>
<ul className="dropdown-menu show position-absolute end-0" style={{ zIndex: 1001, minWidth: "220px" }}>
<li>
<h6 className="dropdown-header">Departamento</h6>
</li>
{groups.length === 0 && (
<li><span className="dropdown-item-text text-muted">Sin departamentos</span></li>
)}
{groups.map((g) => (
<li key={g.id}>
<button
className={`dropdown-item ${ticket.groupId === g.id ? "active" : ""}`}
onClick={() => handleAssignDepartment(g.id)}
>
<i className="bi bi-diagram-3 me-2"></i>{g.name}
</button>
</li>
))}
<li><hr className="dropdown-divider" /></li>
<li>
<h6 className="dropdown-header">Agente</h6>
</li>
<li>
<button className="dropdown-item" onClick={() => handleAssignAgent(null)}>
<i className="bi bi-person-dash me-2"></i>Sin asignar
</button>
</li>
{team.map((m) => (
<li key={m.idUsuario}>
<button
className="dropdown-item"
onClick={() => handleAssignAgent(m.idUsuario)}
>
<i className="bi bi-person me-2"></i>
{m.nombres} {m.apPaterno}
</button>
</li>
))}
</ul>
</>
)}
</div>
</div>
)}
</div>
{error && (
<div className="alert alert-danger py-2" role="alert">
<i className="bi bi-exclamation-circle me-2"></i>
{error}
</div>
)}
<div className="row g-4">
{/* Conversación estilo chat: equipo a la derecha, solicitante a la izquierda */}
<div className="col-lg-8">
<div className="card mb-4">
<div className="card-header d-flex align-items-center gap-2">
<i className="bi bi-chat-dots" style={{ color: "var(--primary)" }}></i>
Conversación ({buildThread(ticket, comments, sentEmails, receivedEmails).length})
<small className="text-muted fw-normal ms-auto d-none d-md-inline">
<span className="chat-legend chat-legend-left me-2">Solicitante</span>
<span className="chat-legend chat-legend-right">Equipo</span>
</small>
</div>
<div className="card-body chat-thread" style={{ maxHeight: "600px", overflowY: "auto" }}>
{(() => {
const thread = buildThread(ticket, comments, sentEmails, receivedEmails);
if (thread.length === 0) {
return (
<div className="text-center text-muted py-4">
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
Sin conversación aún
</div>
);
}
return thread.map((m) => {
if (m.kind === "comment") {
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
return (
<div key={m.key} className={`comment ${alignClass} ${m.isInternal ? "comment-internal" : ""}`}>
<div className="comment-bubble">
<div className="comment-author">
{m.side === "team" ? (
<><i className="bi bi-headset me-1"></i>{m.userName || "Soporte"}</>
) : (
<><i className="bi bi-person me-1"></i>{m.userName || "Solicitante"}</>
)}
{m.isInternal && (
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}>
Nota Interna
</span>
)}
</div>
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
{ticketFiles.filter((a) => a.commentId === m.id).map((a) => (
<button
key={a.id}
type="button"
className="badge bg-light text-dark border d-flex align-items-center gap-1 mt-1"
style={{ fontSize: "0.72rem", cursor: "pointer" }}
title={`Descargar ${a.filename} (${formatFileSize(a.size)})`}
onClick={() => {
downloadAttachment(a).catch(() => setError("No se pudo descargar el adjunto"));
}}
>
<i className="bi bi-paperclip"></i>
{a.filename} ({formatFileSize(a.size)})
<i className="bi bi-download"></i>
</button>
))}
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
</div>
</div>
);
}
const isSent = m.kind === "sent";
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
return (
<div key={m.key} className={`comment ${alignClass}`}>
<div className="comment-bubble">
<div className="comment-author">
<span
className={`badge ${isSent ? "bg-primary" : "bg-success"} me-2`}
title={isSent ? "Enviado desde soporte" : "Respuesta recibida por correo"}
>
<i className={`bi ${isSent ? "bi-send" : "bi-inbox"}`}></i>
</span>
{isSent ? `Soporte → ${m.who}` : `De: ${m.who}`}
</div>
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
{isSent ? (
m.body ? (
<EmailBody body={m.body} isHtml={/<[a-z][\s\S]*>/i.test(m.body)} />
) : (
<div className="comment-text text-muted">Plantilla: {m.fallback}</div>
)
) : m.body ? (
<EmailBody body={m.body} isHtml={false} />
) : (
<div className="comment-text text-muted">
{m.status === "inserted"
? "Agregado como comentario"
: `Omitido: ${m.error || m.status}`}
</div>
)}
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
</div>
</div>
);
});
})()}
<small className="text-muted d-block mt-2">
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.
</small>
</div>
</div>
{/* Comment Form */}
<div className="comment-form">
<form onSubmit={handleAddComment}>
<div className="mb-3">
<textarea
className="form-control"
rows={3}
placeholder={isInternal ? "Nota interna (solo equipo)..." : "Escribe tu respuesta al solicitante..."}
value={comment}
onChange={(e) => setComment(e.target.value)}
required
></textarea>
{!isInternal && isTeam && (
<small className="text-muted d-block mt-1">
<i className="bi bi-envelope me-1"></i>
Respuesta pública: se envía por correo al solicitante <strong>solo el mensaje
escrito</strong> (sin historial) y queda guardada para revisar el caso en el futuro.
</small>
)}
</div>
{!isInternal && (
<div className="mb-3">
<label className="form-label" style={{ fontSize: "0.85rem" }}>
<i className="bi bi-people me-1"></i>
CC (copia por correo, separados por coma)
</label>
<input
type="text"
className="form-control form-control-sm"
placeholder="jefe@norteamericano.cl, otro@norteamericano.cl"
value={cc}
onChange={(e) => setCc(e.target.value)}
/>
</div>
)}
<div className="mb-3">
<label className="btn btn-outline-secondary btn-sm" style={{ cursor: "pointer" }}>
<i className="bi bi-paperclip me-1"></i>
Adjuntar archivos (máx. 10 MB c/u)
<input
type="file"
multiple
hidden
onChange={(e) => {
const picked = Array.from(e.target.files ?? []);
setFiles((prev) => [...prev, ...picked].slice(0, 5));
e.target.value = "";
}}
/>
</label>
{files.length > 0 && (
<div className="d-flex flex-wrap gap-2 mt-2">
{files.map((f, i) => (
<span key={`${f.name}-${i}`} className="badge bg-light text-dark border d-flex align-items-center gap-1" style={{ fontSize: "0.75rem" }}>
<i className="bi bi-file-earmark"></i>
{f.name} ({formatFileSize(f.size)})
<button
type="button"
className="btn-close btn-close-sm"
style={{ fontSize: "0.6rem" }}
aria-label="Quitar archivo"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
/>
</span>
))}
</div>
)}
{pendingIds.length > 0 && (
<small className="text-warning d-block mt-1">
<i className="bi bi-exclamation-triangle me-1"></i>
{pendingIds.length} archivo(s) ya subido(s): se vincularán al enviar.
</small>
)}
</div>
<div className="d-flex align-items-center justify-content-between">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="internalCheck"
checked={isInternal}
onChange={(e) => setIsInternal(e.target.checked)}
/>
<label className="form-check-label" htmlFor="internalCheck" style={{ fontSize: "0.85rem" }}>
<i className="bi bi-eye-slash me-1"></i>
Nota interna (solo agentes)
</label>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={!comment.trim() || submitting}
>
{submitting ? (
<span className="spinner-border spinner-border-sm" role="status"></span>
) : (
<>
<i className="bi bi-send me-2"></i>Enviar
</>
)}
</button>
</div>
</form>
</div>
</div>
{/* Ticket Info Sidebar */}
<div className="col-lg-4">
<div className="ticket-info-card">
<h6>Información del Ticket</h6>
<div className="ticket-info-row">
<span className="label">Solicitante</span>
<span className="value">{ticket.requesterName || "N/A"}</span>
</div>
<div className="ticket-info-row">
<span className="label">Email</span>
<span className="value" style={{ fontSize: "0.8rem" }}>{ticket.requesterEmail || "N/A"}</span>
</div>
<div className="ticket-info-row">
<span className="label">Asignado a</span>
<span className="value">{ticket.assigneeName || "Sin asignar"}</span>
</div>
<div className="ticket-info-row">
<span className="label">Grupo</span>
<span className="value">{ticket.groupName || "N/A"}</span>
</div>
<div className="ticket-info-row">
<span className="label">Creado</span>
<span className="value">{formatDateTime(ticket.createdAt)}</span>
</div>
<div className="ticket-info-row">
<span className="label">Actualizado</span>
<span className="value">{formatDateTime(ticket.updatedAt)}</span>
</div>
</div>
{/* Description */}
<div className="ticket-info-card">
<h6>Descripción</h6>
<p style={{ fontSize: "0.875rem", lineHeight: 1.6, marginBottom: 0 }}>
{ticket.description || "Sin descripción"}
</p>
</div>
</div>
</div>
</div>
);
}