feat: chat izq/der en tickets, correos con ultimo mensaje, dashboard filtrable solo admin/supervisor
This commit is contained in:
1 parent
01173d973e
commit
8b59680588
7 files changed
+526
-41
No files matched your search
@@ -22,6 +22,12 @@ import {
|
||||
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 = [
|
||||
@@ -32,16 +38,24 @@ const STATUS_OPTIONS = [
|
||||
{ value: "closed", label: "Cerrado" },
|
||||
];
|
||||
|
||||
// Hilo único estilo Freshdesk: comentarios + correos enviados desde
|
||||
// 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; 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 };
|
||||
| { kind: "comment"; key: string; 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[]
|
||||
@@ -56,9 +70,11 @@ function buildThread(
|
||||
kind: "comment",
|
||||
key: `comment-${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",
|
||||
@@ -68,6 +84,7 @@ function buildThread(
|
||||
subject: e.subject,
|
||||
body: e.body || undefined,
|
||||
fallback: e.template,
|
||||
side: "team",
|
||||
})),
|
||||
...received.map((e, i): ThreadItem => ({
|
||||
kind: "received",
|
||||
@@ -78,11 +95,95 @@ function buildThread(
|
||||
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);
|
||||
@@ -132,15 +233,25 @@ export default function TicketDetailPage() {
|
||||
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;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
await addComment(id, comment, isInternal);
|
||||
const updated = await getComments(id).catch(() => []);
|
||||
await addComment(id, cleaned, isInternal);
|
||||
const [updated, emails] = await Promise.all([
|
||||
getComments(id).catch(() => []),
|
||||
getTicketEmails(id).catch(() => ({ sent: [], received: [] })),
|
||||
]);
|
||||
setComments(updated);
|
||||
setSentEmails(emails.sent ?? []);
|
||||
setReceivedEmails(emails.received ?? []);
|
||||
setComment("");
|
||||
setIsInternal(false);
|
||||
} catch {
|
||||
// ignore
|
||||
setError("No se pudo enviar el mensaje");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -338,16 +449,20 @@ export default function TicketDetailPage() {
|
||||
)}
|
||||
|
||||
<div className="row g-4">
|
||||
{/* Conversación estilo Freshdesk: comentarios + hilo de correos */}
|
||||
{/* 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(comments, sentEmails, receivedEmails).length})
|
||||
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" style={{ maxHeight: "600px", overflowY: "auto" }}>
|
||||
<div className="card-body chat-thread" style={{ maxHeight: "600px", overflowY: "auto" }}>
|
||||
{(() => {
|
||||
const thread = buildThread(comments, sentEmails, receivedEmails);
|
||||
const thread = buildThread(ticket, comments, sentEmails, receivedEmails);
|
||||
if (thread.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted py-4">
|
||||
@@ -358,26 +473,32 @@ export default function TicketDetailPage() {
|
||||
}
|
||||
return thread.map((m) => {
|
||||
if (m.kind === "comment") {
|
||||
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
|
||||
return (
|
||||
<div key={m.key} className={`comment comment-agent ${m.isInternal ? "comment-internal" : ""}`}>
|
||||
<div key={m.key} className={`comment ${alignClass} ${m.isInternal ? "comment-internal" : ""}`}>
|
||||
<div className="comment-bubble">
|
||||
<div className="comment-author">
|
||||
{m.userName || "Usuario"}
|
||||
{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">{m.content}</div>
|
||||
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
|
||||
<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 comment-agent">
|
||||
<div key={m.key} className={`comment ${alignClass}`}>
|
||||
<div className="comment-bubble">
|
||||
<div className="comment-author">
|
||||
<span
|
||||
@@ -391,15 +512,12 @@ export default function TicketDetailPage() {
|
||||
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
|
||||
{isSent ? (
|
||||
m.body ? (
|
||||
<div
|
||||
className="comment-text"
|
||||
dangerouslySetInnerHTML={{ __html: 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 ? (
|
||||
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{m.body}</div>
|
||||
<EmailBody body={m.body} isHtml={false} />
|
||||
) : (
|
||||
<div className="comment-text text-muted">
|
||||
{m.status === "inserted"
|
||||
@@ -415,7 +533,8 @@ export default function TicketDetailPage() {
|
||||
})()}
|
||||
<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).
|
||||
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>
|
||||
@@ -427,11 +546,18 @@ export default function TicketDetailPage() {
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={3}
|
||||
placeholder="Escribe tu comentario..."
|
||||
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>
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="form-check">
|
||||
|
||||
Reference in new issue
Block a user