feat: conversacion unificada estilo Freshdesk en ticket (comentarios + correos con cuerpo)
This commit is contained in:
1 parent
38e21d20dd
commit
01173d973e
2 files changed
+109
-80
No files matched your search
@@ -32,6 +32,57 @@ const STATUS_OPTIONS = [
|
|||||||
{ value: "closed", label: "Cerrado" },
|
{ 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() {
|
export default function TicketDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
@@ -287,109 +338,84 @@ export default function TicketDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="row g-4">
|
<div className="row g-4">
|
||||||
{/* Comments Timeline */}
|
{/* Conversación estilo Freshdesk: comentarios + hilo de correos */}
|
||||||
<div className="col-lg-8">
|
<div className="col-lg-8">
|
||||||
<div className="card mb-4">
|
<div className="card mb-4">
|
||||||
<div className="card-header d-flex align-items-center gap-2">
|
<div className="card-header d-flex align-items-center gap-2">
|
||||||
<i className="bi bi-chat-dots" style={{ color: "var(--primary)" }}></i>
|
<i className="bi bi-chat-dots" style={{ color: "var(--primary)" }}></i>
|
||||||
Comentarios ({comments.length})
|
Conversación ({buildThread(comments, sentEmails, receivedEmails).length})
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body" style={{ maxHeight: "500px", overflowY: "auto" }}>
|
<div className="card-body" style={{ maxHeight: "600px", overflowY: "auto" }}>
|
||||||
{comments.length > 0 ? (
|
{(() => {
|
||||||
comments.map((c) => (
|
const thread = buildThread(comments, sentEmails, receivedEmails);
|
||||||
<div
|
if (thread.length === 0) {
|
||||||
key={c.id}
|
return (
|
||||||
className={`comment comment-agent ${c.isInternal ? "comment-internal" : ""}`}
|
<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") {
|
||||||
|
return (
|
||||||
|
<div key={m.key} className={`comment comment-agent ${m.isInternal ? "comment-internal" : ""}`}>
|
||||||
<div className="comment-bubble">
|
<div className="comment-bubble">
|
||||||
<div className="comment-author">
|
<div className="comment-author">
|
||||||
{c.userName || "Usuario"}
|
{m.userName || "Usuario"}
|
||||||
{c.isInternal && (
|
{m.isInternal && (
|
||||||
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}>
|
<span className="badge bg-warning text-dark ms-2" style={{ fontSize: "0.65rem" }}>
|
||||||
Nota Interna
|
Nota Interna
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="comment-text">{c.content}</div>
|
<div className="comment-text">{m.content}</div>
|
||||||
<div className="comment-time">
|
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
|
||||||
{formatDateTime(c.createdAt)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))
|
}
|
||||||
) : (
|
const isSent = m.kind === "sent";
|
||||||
<div className="text-center text-muted py-4">
|
return (
|
||||||
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
|
<div key={m.key} className="comment comment-agent">
|
||||||
No hay comentarios aún
|
<div className="comment-bubble">
|
||||||
</div>
|
<div className="comment-author">
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Historial de correos: enviados desde soporte + respuestas
|
|
||||||
recibidas, integrados al ticket (hilo email <-> ticket) */}
|
|
||||||
<div className="card mb-4">
|
|
||||||
<div className="card-header d-flex align-items-center gap-2">
|
|
||||||
<i className="bi bi-envelope-at" style={{ color: "var(--primary)" }}></i>
|
|
||||||
Historial de correos ({sentEmails.length + receivedEmails.length})
|
|
||||||
</div>
|
|
||||||
<div className="card-body" style={{ maxHeight: "400px", overflowY: "auto" }}>
|
|
||||||
{sentEmails.length === 0 && receivedEmails.length === 0 ? (
|
|
||||||
<div className="text-center text-muted py-4">
|
|
||||||
<i className="bi bi-envelope fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
|
|
||||||
Sin correos asociados a este ticket
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
[...sentEmails.map((e) => ({
|
|
||||||
key: `sent-${e.id}`,
|
|
||||||
dir: "sent" as const,
|
|
||||||
who: e.recipient,
|
|
||||||
subject: e.subject,
|
|
||||||
extra: e.template,
|
|
||||||
status: e.status,
|
|
||||||
error: e.error,
|
|
||||||
createdAt: e.createdAt,
|
|
||||||
})), ...receivedEmails.map((e, i) => ({
|
|
||||||
key: `recv-${i}-${e.createdAt}`,
|
|
||||||
dir: "received" as const,
|
|
||||||
who: e.authorEmail,
|
|
||||||
subject: e.subject,
|
|
||||||
extra: e.status === "inserted" ? "Agregado como comentario" : (e.error || e.status),
|
|
||||||
status: e.status,
|
|
||||||
error: e.error,
|
|
||||||
createdAt: e.createdAt,
|
|
||||||
}))]
|
|
||||||
.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))
|
|
||||||
.map((m) => (
|
|
||||||
<div key={m.key} className="d-flex gap-2 mb-3">
|
|
||||||
<span
|
<span
|
||||||
className={`badge ${m.dir === "sent" ? "bg-primary" : "bg-success"} align-self-start mt-1`}
|
className={`badge ${isSent ? "bg-primary" : "bg-success"} me-2`}
|
||||||
title={m.dir === "sent" ? "Enviado desde soporte" : "Respuesta recibida"}
|
title={isSent ? "Enviado desde soporte" : "Respuesta recibida por correo"}
|
||||||
>
|
>
|
||||||
<i className={`bi ${m.dir === "sent" ? "bi-send" : "bi-inbox"}`}></i>
|
<i className={`bi ${isSent ? "bi-send" : "bi-inbox"}`}></i>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-grow-1">
|
{isSent ? `Soporte → ${m.who}` : `De: ${m.who}`}
|
||||||
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>
|
</div>
|
||||||
{m.dir === "sent" ? "Para: " : "De: "}{m.who}
|
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
|
||||||
|
{isSent ? (
|
||||||
|
m.body ? (
|
||||||
|
<div
|
||||||
|
className="comment-text"
|
||||||
|
dangerouslySetInnerHTML={{ __html: 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>
|
||||||
|
) : (
|
||||||
|
<div className="comment-text text-muted">
|
||||||
|
{m.status === "inserted"
|
||||||
|
? "Agregado como comentario"
|
||||||
|
: `Omitido: ${m.error || m.status}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
|
|
||||||
<div className="d-flex gap-2 align-items-center flex-wrap">
|
|
||||||
<small className="text-muted">{formatDateTime(m.createdAt)}</small>
|
|
||||||
<small className="text-muted">·</small>
|
|
||||||
<small className="text-muted">{m.extra}</small>
|
|
||||||
{m.status !== "sent" && m.status !== "inserted" && (
|
|
||||||
<span className="badge bg-warning text-dark" title={m.error || ""}>
|
|
||||||
{m.status}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))
|
});
|
||||||
)}
|
})()}
|
||||||
<small className="text-muted d-block mt-2">
|
<small className="text-muted d-block mt-2">
|
||||||
Las respuestas con etiqueta HD-{String(id).padStart(6, "0")} en el asunto
|
Las respuestas con etiqueta HD-{String(id).padStart(6, "0")} en el asunto
|
||||||
se agregan solas como comentarios (ingesta cada 5 min).
|
llegan a este hilo (ingesta cada 5 min).
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export interface TicketSentEmail {
|
|||||||
subject: string;
|
subject: string;
|
||||||
status: string;
|
status: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
body?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +150,8 @@ export interface TicketReceivedEmail {
|
|||||||
subject: string;
|
subject: string;
|
||||||
status: string;
|
status: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
body?: string;
|
||||||
|
commentId?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user