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
@@ -3,13 +3,23 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import StatsCard from "@/components/StatsCard";
|
import StatsCard from "@/components/StatsCard";
|
||||||
import { getTicketStats, type DashboardStats } from "@/lib/api";
|
import { getTicketStats, type DashboardStats } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const canView = user?.role === "admin" || user?.role === "supervisor";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (authLoading) return;
|
||||||
|
if (!canView) {
|
||||||
|
// Diferido para no disparar cascading renders (igual que AuthProvider).
|
||||||
|
queueMicrotask(() => setLoading(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
getTicketStats()
|
getTicketStats()
|
||||||
.then(setStats)
|
.then(setStats)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -25,9 +35,9 @@ export default function DashboardPage() {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, [authLoading, canView]);
|
||||||
|
|
||||||
if (loading) {
|
if (authLoading || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="loading-spinner">
|
<div className="loading-spinner">
|
||||||
<div className="spinner-border text-primary" role="status"></div>
|
<div className="spinner-border text-primary" role="status"></div>
|
||||||
@@ -35,6 +45,25 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Solo administradores (todas las áreas) y supervisores (solo su área).
|
||||||
|
if (!canView) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<Link href="/tickets" className="btn btn-primary">
|
||||||
|
<i className="bi bi-ticket-detailed me-2"></i>
|
||||||
|
Ir a Tickets
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="alert alert-warning" role="alert">
|
||||||
|
<i className="bi bi-shield-lock me-2"></i>
|
||||||
|
El Dashboard está disponible solo para administradores y supervisores.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const maxDaily = Math.max(...(stats?.dailyCounts?.map((d) => d.count) || [1]));
|
const maxDaily = Math.max(...(stats?.dailyCounts?.map((d) => d.count) || [1]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,25 +76,44 @@ export default function DashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{user?.role === "supervisor" ? (
|
||||||
|
<div className="alert alert-info py-2" role="alert">
|
||||||
|
<i className="bi bi-diagram-3 me-2"></i>
|
||||||
|
Mostrando solo tu área
|
||||||
|
{user?.departmentName ? (
|
||||||
|
<>
|
||||||
|
: <strong>{user.departmentName}</strong>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
" asignada"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="alert alert-light border py-2" role="alert">
|
||||||
|
<i className="bi bi-globe me-2"></i>
|
||||||
|
Vista de administrador: todas las áreas. Presiona una tarjeta para ver los tickets filtrados.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Cards: cada tarjeta filtra la lista de tickets según lo presionado */}
|
||||||
<div className="row g-3 mb-4">
|
<div className="row g-3 mb-4">
|
||||||
<div className="col-6 col-lg">
|
<div className="col-6 col-lg">
|
||||||
<StatsCard icon="bi-ticket-detailed" value={stats?.totalTickets || 0} label="Total Tickets" color="#00285d" />
|
<StatsCard icon="bi-ticket-detailed" value={stats?.totalTickets || 0} label="Total Tickets" color="#00285d" href="/tickets" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6 col-lg">
|
<div className="col-6 col-lg">
|
||||||
<StatsCard icon="bi-folder2-open" value={stats?.openTickets || 0} label="Abiertos" color="#198754" />
|
<StatsCard icon="bi-folder2-open" value={stats?.openTickets || 0} label="Abiertos" color="#198754" href="/tickets?status=Open" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6 col-lg">
|
<div className="col-6 col-lg">
|
||||||
<StatsCard icon="bi-clock-history" value={stats?.inProgressTickets || 0} label="En Progreso" color="#ffc107" />
|
<StatsCard icon="bi-clock-history" value={stats?.inProgressTickets || 0} label="En Progreso" color="#ffc107" href="/tickets?status=InProgress" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6 col-lg">
|
<div className="col-6 col-lg">
|
||||||
<StatsCard icon="bi-check-circle" value={stats?.resolvedTickets || 0} label="Resueltos" color="#0d6efd" />
|
<StatsCard icon="bi-check-circle" value={stats?.resolvedTickets || 0} label="Resueltos" color="#0d6efd" href="/tickets?status=Resolved" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6 col-lg">
|
<div className="col-6 col-lg">
|
||||||
<StatsCard icon="bi-exclamation-triangle" value={stats?.criticalTickets || 0} label="Críticos" color="#dc3545" />
|
<StatsCard icon="bi-exclamation-triangle" value={stats?.criticalTickets || 0} label="Críticos" color="#dc3545" href="/tickets?priority=Critical" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6 col-lg">
|
<div className="col-6 col-lg">
|
||||||
<StatsCard icon="bi-person-dash" value={stats?.unassignedTickets || 0} label="Sin Asignar" color="#6f42c1" />
|
<StatsCard icon="bi-person-dash" value={stats?.unassignedTickets || 0} label="Sin Asignar" color="#6f42c1" href="/tickets?view=unassigned" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import {
|
|||||||
import BadgeStatus from "@/components/BadgeStatus";
|
import BadgeStatus from "@/components/BadgeStatus";
|
||||||
import BadgePriority from "@/components/BadgePriority";
|
import BadgePriority from "@/components/BadgePriority";
|
||||||
import { formatDateTime } from "@/lib/format";
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
extractLatestMessage,
|
||||||
|
extractLatestHtml,
|
||||||
|
stripHtmlToText,
|
||||||
|
cleanReplyForSend,
|
||||||
|
} from "@/lib/emailThread";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
@@ -32,16 +38,24 @@ const STATUS_OPTIONS = [
|
|||||||
{ value: "closed", label: "Cerrado" },
|
{ 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
|
// soporte + respuestas recibidas, ordenados cronológicamente. Las
|
||||||
// respuestas por email que ya generaron comentario se muestran una
|
// respuestas por email que ya generaron comentario se muestran una
|
||||||
// sola vez (versión email, con asunto y cuerpo).
|
// 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 =
|
type ThreadItem =
|
||||||
| { kind: "comment"; key: string; createdAt: string; userName?: string; content: string; isInternal: boolean }
|
| { 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 }
|
| { 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 };
|
| { 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(
|
function buildThread(
|
||||||
|
ticket: Ticket | null,
|
||||||
comments: Comment[],
|
comments: Comment[],
|
||||||
sent: TicketSentEmail[],
|
sent: TicketSentEmail[],
|
||||||
received: TicketReceivedEmail[]
|
received: TicketReceivedEmail[]
|
||||||
@@ -56,9 +70,11 @@ function buildThread(
|
|||||||
kind: "comment",
|
kind: "comment",
|
||||||
key: `comment-${c.id}`,
|
key: `comment-${c.id}`,
|
||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
|
userId: c.userId,
|
||||||
userName: c.userName,
|
userName: c.userName,
|
||||||
content: c.content,
|
content: c.content,
|
||||||
isInternal: c.isInternal,
|
isInternal: c.isInternal,
|
||||||
|
side: isRequesterComment(c.userId, ticket) ? "customer" : "team",
|
||||||
})),
|
})),
|
||||||
...sent.map((e): ThreadItem => ({
|
...sent.map((e): ThreadItem => ({
|
||||||
kind: "sent",
|
kind: "sent",
|
||||||
@@ -68,6 +84,7 @@ function buildThread(
|
|||||||
subject: e.subject,
|
subject: e.subject,
|
||||||
body: e.body || undefined,
|
body: e.body || undefined,
|
||||||
fallback: e.template,
|
fallback: e.template,
|
||||||
|
side: "team",
|
||||||
})),
|
})),
|
||||||
...received.map((e, i): ThreadItem => ({
|
...received.map((e, i): ThreadItem => ({
|
||||||
kind: "received",
|
kind: "received",
|
||||||
@@ -78,11 +95,95 @@ function buildThread(
|
|||||||
body: e.body || undefined,
|
body: e.body || undefined,
|
||||||
status: e.status,
|
status: e.status,
|
||||||
error: e.error,
|
error: e.error,
|
||||||
|
side: "customer",
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
return items.sort((a, b) => +new Date(a.createdAt) - +new Date(b.createdAt));
|
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() {
|
export default function TicketDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
@@ -132,15 +233,25 @@ export default function TicketDetailPage() {
|
|||||||
const handleAddComment = async (e: React.FormEvent) => {
|
const handleAddComment = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!comment.trim()) return;
|
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);
|
setSubmitting(true);
|
||||||
|
setError("");
|
||||||
try {
|
try {
|
||||||
await addComment(id, comment, isInternal);
|
await addComment(id, cleaned, isInternal);
|
||||||
const updated = await getComments(id).catch(() => []);
|
const [updated, emails] = await Promise.all([
|
||||||
|
getComments(id).catch(() => []),
|
||||||
|
getTicketEmails(id).catch(() => ({ sent: [], received: [] })),
|
||||||
|
]);
|
||||||
setComments(updated);
|
setComments(updated);
|
||||||
|
setSentEmails(emails.sent ?? []);
|
||||||
|
setReceivedEmails(emails.received ?? []);
|
||||||
setComment("");
|
setComment("");
|
||||||
setIsInternal(false);
|
setIsInternal(false);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
setError("No se pudo enviar el mensaje");
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -338,16 +449,20 @@ export default function TicketDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="row g-4">
|
<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="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>
|
||||||
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>
|
||||||
<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) {
|
if (thread.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center text-muted py-4">
|
<div className="text-center text-muted py-4">
|
||||||
@@ -358,26 +473,32 @@ export default function TicketDetailPage() {
|
|||||||
}
|
}
|
||||||
return thread.map((m) => {
|
return thread.map((m) => {
|
||||||
if (m.kind === "comment") {
|
if (m.kind === "comment") {
|
||||||
|
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
|
||||||
return (
|
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-bubble">
|
||||||
<div className="comment-author">
|
<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 && (
|
{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">{m.content}</div>
|
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
|
||||||
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
|
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const isSent = m.kind === "sent";
|
const isSent = m.kind === "sent";
|
||||||
|
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
|
||||||
return (
|
return (
|
||||||
<div key={m.key} className="comment comment-agent">
|
<div key={m.key} className={`comment ${alignClass}`}>
|
||||||
<div className="comment-bubble">
|
<div className="comment-bubble">
|
||||||
<div className="comment-author">
|
<div className="comment-author">
|
||||||
<span
|
<span
|
||||||
@@ -391,15 +512,12 @@ export default function TicketDetailPage() {
|
|||||||
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
|
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
|
||||||
{isSent ? (
|
{isSent ? (
|
||||||
m.body ? (
|
m.body ? (
|
||||||
<div
|
<EmailBody body={m.body} isHtml={/<[a-z][\s\S]*>/i.test(m.body)} />
|
||||||
className="comment-text"
|
|
||||||
dangerouslySetInnerHTML={{ __html: m.body }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="comment-text text-muted">Plantilla: {m.fallback}</div>
|
<div className="comment-text text-muted">Plantilla: {m.fallback}</div>
|
||||||
)
|
)
|
||||||
) : m.body ? (
|
) : 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">
|
<div className="comment-text text-muted">
|
||||||
{m.status === "inserted"
|
{m.status === "inserted"
|
||||||
@@ -415,7 +533,8 @@ export default function TicketDetailPage() {
|
|||||||
})()}
|
})()}
|
||||||
<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
|
||||||
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>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,11 +546,18 @@ export default function TicketDetailPage() {
|
|||||||
<textarea
|
<textarea
|
||||||
className="form-control"
|
className="form-control"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Escribe tu comentario..."
|
placeholder={isInternal ? "Nota interna (solo equipo)..." : "Escribe tu respuesta al solicitante..."}
|
||||||
value={comment}
|
value={comment}
|
||||||
onChange={(e) => setComment(e.target.value)}
|
onChange={(e) => setComment(e.target.value)}
|
||||||
required
|
required
|
||||||
></textarea>
|
></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>
|
||||||
<div className="d-flex align-items-center justify-content-between">
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
<div className="form-check">
|
<div className="form-check">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, Suspense } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { getTickets, type Ticket } from "@/lib/api";
|
import { getTickets, type Ticket } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
import BadgeStatus from "@/components/BadgeStatus";
|
import BadgeStatus from "@/components/BadgeStatus";
|
||||||
@@ -10,19 +10,40 @@ import PaginationComponent from "@/components/Pagination";
|
|||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function TicketsPage() {
|
type View = "all" | "mine" | "assigned" | "unassigned";
|
||||||
|
|
||||||
|
function TicketsContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [view, setView] = useState<"all" | "mine" | "assigned" | "unassigned">("all");
|
const [view, setView] = useState<View>("all");
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
const [priorityFilter, setPriorityFilter] = useState("");
|
const [priorityFilter, setPriorityFilter] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
|
// Filtros iniciales desde la URL (los usa el Dashboard al presionar tarjetas).
|
||||||
|
// Diferido (no sincrónico en el effect) para no disparar cascading renders.
|
||||||
|
useEffect(() => {
|
||||||
|
const status = searchParams.get("status") ?? "";
|
||||||
|
const priority = searchParams.get("priority") ?? "";
|
||||||
|
const viewParam = searchParams.get("view") as View | null;
|
||||||
|
const q = searchParams.get("search") ?? "";
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setStatusFilter(status);
|
||||||
|
setPriorityFilter(priority);
|
||||||
|
if (viewParam === "mine" || viewParam === "assigned" || viewParam === "unassigned" || viewParam === "all") {
|
||||||
|
setView(viewParam);
|
||||||
|
}
|
||||||
|
setSearch(q);
|
||||||
|
setPage(1);
|
||||||
|
});
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const numericId = parseInt(user?.idUsuario ?? "", 10);
|
const numericId = parseInt(user?.idUsuario ?? "", 10);
|
||||||
const hasNumericId = !isNaN(numericId);
|
const hasNumericId = !isNaN(numericId);
|
||||||
const isTeam =
|
const isTeam =
|
||||||
@@ -55,6 +76,17 @@ export default function TicketsPage() {
|
|||||||
}, [statusFilter, priorityFilter, search, page, view, numericId, hasNumericId, isTeam]);
|
}, [statusFilter, priorityFilter, search, page, view, numericId, hasNumericId, isTeam]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
const hasActiveFilters = statusFilter !== "" || priorityFilter !== "" || view !== "all" || search !== "";
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setStatusFilter("");
|
||||||
|
setPriorityFilter("");
|
||||||
|
setView("all");
|
||||||
|
setSearch("");
|
||||||
|
setPage(1);
|
||||||
|
setLoading(true);
|
||||||
|
router.replace("/tickets");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -80,6 +112,23 @@ export default function TicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="alert alert-info py-2 d-flex align-items-center justify-content-between" role="alert">
|
||||||
|
<span>
|
||||||
|
<i className="bi bi-funnel-fill me-2"></i>
|
||||||
|
Vista filtrada
|
||||||
|
{statusFilter && <> · Estado: <strong>{statusFilter}</strong></>}
|
||||||
|
{priorityFilter && <> · Prioridad: <strong>{priorityFilter}</strong></>}
|
||||||
|
{view !== "all" && <> · Vista: <strong>{view === "unassigned" ? "Sin asignar" : view}</strong></>}
|
||||||
|
{search && <> · Búsqueda: <strong>{search}</strong></>}
|
||||||
|
{" "}(desde el Dashboard)
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn btn-sm btn-outline-secondary ms-2" onClick={clearFilters}>
|
||||||
|
Limpiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
{isTeam ? (
|
{isTeam ? (
|
||||||
<div className="filter-bar mb-3">
|
<div className="filter-bar mb-3">
|
||||||
@@ -224,3 +273,11 @@ export default function TicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function TicketsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="loading-spinner"><div className="spinner-border text-primary" role="status"></div></div>}>
|
||||||
|
<TicketsContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -789,10 +789,87 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comment-internal .comment-bubble {
|
.comment-internal .comment-bubble {
|
||||||
background: #fff3cd;
|
background: #fff3cd !important;
|
||||||
|
color: var(--text-dark) !important;
|
||||||
border-left: 4px solid #ffc107;
|
border-left: 4px solid #ffc107;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-thread .comment-bubble .comment-text a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Equipo (derecha, azul) hereda blanco; solicitante (izquierda, gris)
|
||||||
|
usa texto oscuro; el toggle de correo hereda el color de la burbuja */
|
||||||
|
.comment-agent .comment-bubble .email-toggle {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-agent .comment-bubble .email-toggle:hover {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-customer .comment-bubble .email-toggle {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-customer .comment-bubble .email-toggle:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-internal .comment-bubble .email-toggle {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-legend {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-legend::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 4px;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-legend-left::before {
|
||||||
|
background: #dee2e6;
|
||||||
|
border: 1px solid #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-legend-right::before {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard: tarjetas clicables que filtran tickets */
|
||||||
|
.stats-card-anchor {
|
||||||
|
display: block;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-anchor:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card-link:hover {
|
||||||
|
border: 1px solid rgba(0, 40, 93, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.comment-author {
|
.comment-author {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ export default function Sidebar() {
|
|||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/", icon: "bi-speedometer2", label: "Dashboard" },
|
// Dashboard solo para administradores (todas las áreas) y
|
||||||
|
// supervisores (solo su área).
|
||||||
|
...((user?.role === "admin" || user?.role === "supervisor")
|
||||||
|
? [{ href: "/", icon: "bi-speedometer2", label: "Dashboard" }]
|
||||||
|
: []),
|
||||||
{ href: "/tickets", icon: "bi-ticket-detailed", label: "Tickets" },
|
{ href: "/tickets", icon: "bi-ticket-detailed", label: "Tickets" },
|
||||||
{ href: "/knowledge-base", icon: "bi-journal-bookmark", label: "Knowledge Base" },
|
{ href: "/knowledge-base", icon: "bi-journal-bookmark", label: "Knowledge Base" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
interface StatsCardProps {
|
interface StatsCardProps {
|
||||||
icon: string;
|
icon: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
label: string;
|
label: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
href?: string;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatsCard({ icon, value, label, color = "#00285d" }: StatsCardProps) {
|
export default function StatsCard({ icon, value, label, color = "#00285d", href, title }: StatsCardProps) {
|
||||||
return (
|
const card = (
|
||||||
<div className="stats-card">
|
<div className={`stats-card ${href ? "stats-card-link" : ""}`} title={title ?? (href ? `Ver tickets: ${label}` : undefined)}>
|
||||||
<div className="d-flex align-items-center gap-3">
|
<div className="d-flex align-items-center gap-3">
|
||||||
<div className="stats-icon" style={{ backgroundColor: `${color}15`, color }}>
|
<div className="stats-icon" style={{ backgroundColor: `${color}15`, color }}>
|
||||||
<i className={`bi ${icon}`}></i>
|
<i className={`bi ${icon}`}></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="stats-value">{value}</div>
|
<div className="stats-value">{value}</div>
|
||||||
<div className="stats-label">{label}</div>
|
<div className="stats-label">
|
||||||
|
{label}
|
||||||
|
{href && <i className="bi bi-arrow-right-circle ms-1" aria-hidden></i>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="text-decoration-none stats-card-anchor">
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return card;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Utilidades para mostrar y enviar solo el mensaje actual de un correo,
|
||||||
|
// sin el historial citado (respuestas encadenadas, forwards, firmas).
|
||||||
|
//
|
||||||
|
// Se usa en:
|
||||||
|
// - Hilo del ticket: correos enviados/recibidos muestran solo el último
|
||||||
|
// mensaje con botón "Ver mensaje completo".
|
||||||
|
// - Respuesta del agente: antes de POST /comments se limpia el texto para
|
||||||
|
// enviar/guardar solo lo escrito (revisión futura de casos).
|
||||||
|
|
||||||
|
const QUOTE_SPLIT_PATTERNS: RegExp[] = [
|
||||||
|
// "On Mon, 1 Jan 2024 at 10:00, Juan <j@x.cl> wrote:"
|
||||||
|
/^\s*On\s.+wrote:\s*$/i,
|
||||||
|
// "El 1 ene 2024, Juan <j@x.cl> escribió:"
|
||||||
|
/^\s*El\s.+escribi[óo]:\s*$/i,
|
||||||
|
// Outlook / Gmail headers del mensaje original
|
||||||
|
/^\s*-{2,}\s*Mensaje original\s*-{2,}\s*$/i,
|
||||||
|
/^\s*-{2,}\s*Original Message\s*-{2,}\s*$/i,
|
||||||
|
/^\s*-{2,}\s*Forwarded message\s*-{2,}\s*$/i,
|
||||||
|
/^\s*-{2,}\s*Mensaje reenviado\s*-{2,}\s*$/i,
|
||||||
|
/^\s*_{2,}\s*$/,
|
||||||
|
// Cabeceras típicas citadas
|
||||||
|
/^\s*De:\s+.+$/i,
|
||||||
|
/^\s*From:\s+.+$/i,
|
||||||
|
/^\s*Enviado:\s+.+$/i,
|
||||||
|
/^\s*Sent:\s+.+$/i,
|
||||||
|
/^\s*Para:\s+.+$/i,
|
||||||
|
/^\s*To:\s+.+$/i,
|
||||||
|
/^\s*Asunto:\s+.+$/i,
|
||||||
|
/^\s*Subject:\s+.+$/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_PREVIEW_CHARS = 800;
|
||||||
|
|
||||||
|
export function stripHtmlToText(html: string): string {
|
||||||
|
if (typeof window !== "undefined" && typeof DOMParser !== "undefined") {
|
||||||
|
try {
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
// Elimina bloques típicos de historial citado antes de extraer texto
|
||||||
|
doc
|
||||||
|
.querySelectorAll(
|
||||||
|
"blockquote, .gmail_quote, .gmail_attr, .moz-cite-prefix, #is_reply, .quoted, [data-quoted]"
|
||||||
|
)
|
||||||
|
.forEach((el) => el.remove());
|
||||||
|
// <br> y párrafos como saltos de línea
|
||||||
|
doc.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
|
||||||
|
doc.querySelectorAll("p, div, li, tr").forEach((el) => {
|
||||||
|
el.prepend("\n");
|
||||||
|
});
|
||||||
|
return (doc.body.textContent || "").replace(/\n{3,}/g, "\n\n").trim();
|
||||||
|
} catch {
|
||||||
|
// cae al regex de abajo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/(p|div|li|tr|h[1-6])>/gi, "\n")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quita firmas comunes tipo "-- \nJuan" al final. */
|
||||||
|
function stripSignature(lines: string[]): string[] {
|
||||||
|
const idx = lines.findIndex((l) => l.trim() === "--" || l.trim() === "—");
|
||||||
|
if (idx >= 0 && idx > Math.max(0, lines.length - 8)) {
|
||||||
|
return lines.slice(0, idx);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrae el último mensaje (actual) de un cuerpo de correo con historial.
|
||||||
|
* Corta en la primera línea que parezca inicio de cita y descarta líneas
|
||||||
|
* que empiezan con ">". Si no hay cita detectable, recorta por longitud.
|
||||||
|
*/
|
||||||
|
export function extractLatestMessage(body: string): {
|
||||||
|
preview: string;
|
||||||
|
hasMore: boolean;
|
||||||
|
} {
|
||||||
|
const text = (body || "").replace(/\r\n/g, "\n");
|
||||||
|
if (!text.trim()) return { preview: "", hasMore: false };
|
||||||
|
|
||||||
|
const lines = stripSignature(text.split("\n"));
|
||||||
|
const kept: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
// Línea citada estilo ">"
|
||||||
|
if (/^>/.test(trimmed) || /^>/.test(trimmed)) break;
|
||||||
|
// Inicio de bloque citado ("On ... wrote:", "De:", "----- Mensaje original -----", ...)
|
||||||
|
if (QUOTE_SPLIT_PATTERNS.some((re) => re.test(line))) break;
|
||||||
|
kept.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let preview = kept.join("\n").trim();
|
||||||
|
const hadQuoteCut = kept.length < lines.length;
|
||||||
|
|
||||||
|
// Sin corte por cita pero texto larguísimo: recorte por longitud
|
||||||
|
if (!hadQuoteCut && preview.length > MAX_PREVIEW_CHARS) {
|
||||||
|
const cut = preview.slice(0, MAX_PREVIEW_CHARS);
|
||||||
|
const lastSpace = cut.lastIndexOf(" ");
|
||||||
|
preview = (lastSpace > 400 ? cut.slice(0, lastSpace) : cut).trim() + "…";
|
||||||
|
return { preview, hasMore: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preview) {
|
||||||
|
// Todo parecía cita: muestra un recorte del original
|
||||||
|
const fallback = text.trim().slice(0, MAX_PREVIEW_CHARS).trim();
|
||||||
|
return {
|
||||||
|
preview: fallback.length < text.trim().length ? fallback + "…" : fallback,
|
||||||
|
hasMore: fallback.length < text.trim().length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { preview, hasMore: hadQuoteCut || preview.length < text.trim().length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia HTML de correo enviado dejando solo el mensaje actual:
|
||||||
|
* elimina <blockquote> y contenedores de cita conocidos. Devuelve HTML.
|
||||||
|
*/
|
||||||
|
export function extractLatestHtml(html: string): { previewHtml: string; hasMore: boolean } {
|
||||||
|
if (typeof window === "undefined" || typeof DOMParser === "undefined") {
|
||||||
|
const text = stripHtmlToText(html);
|
||||||
|
const { preview, hasMore } = extractLatestMessage(text);
|
||||||
|
// Sin DOM no podemos recortar HTML con fidelidad: mostramos texto plano
|
||||||
|
return { previewHtml: "", hasMore: hasMore || preview.length < text.length };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
const quoted = doc.querySelectorAll(
|
||||||
|
"blockquote, .gmail_quote, .gmail_attr, .moz-cite-prefix, #is_reply, .quoted, [data-quoted]"
|
||||||
|
);
|
||||||
|
const hasMore = quoted.length > 0;
|
||||||
|
quoted.forEach((el) => el.remove());
|
||||||
|
// También corta marcadores textuales dentro del HTML ("On ... wrote:", etc.)
|
||||||
|
const text = (doc.body.textContent || "").replace(/\r\n/g, "\n");
|
||||||
|
const { hasMore: textCut } = extractLatestMessage(text);
|
||||||
|
const previewHtml = doc.body.innerHTML.trim();
|
||||||
|
return { previewHtml, hasMore: hasMore || textCut };
|
||||||
|
} catch {
|
||||||
|
return { previewHtml: html, hasMore: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia lo que el agente escribió antes de enviar/guardar:
|
||||||
|
* quita historial pegado y líneas citadas. Solo va el mensaje nuevo.
|
||||||
|
*/
|
||||||
|
export function cleanReplyForSend(text: string): string {
|
||||||
|
const { preview } = extractLatestMessage(text || "");
|
||||||
|
return preview.trim();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user