feat: chat izq/der en tickets, correos con ultimo mensaje, dashboard filtrable solo admin/supervisor

This commit is contained in:
Nurfog committed 2026-09-29 16:15:53 -03:00
1 parent 01173d973e
commit 8b59680588
7 files changed
+526 -41

No files matched your search

+57 -9
View File
@@ -3,13 +3,23 @@
import { useEffect, useState } from "react";
import StatsCard from "@/components/StatsCard";
import { getTicketStats, type DashboardStats } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
export default function DashboardPage() {
const { user, isLoading: authLoading } = useAuth();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const canView = user?.role === "admin" || user?.role === "supervisor";
useEffect(() => {
if (authLoading) return;
if (!canView) {
// Diferido para no disparar cascading renders (igual que AuthProvider).
queueMicrotask(() => setLoading(false));
return;
}
getTicketStats()
.then(setStats)
.catch(() => {
@@ -25,9 +35,9 @@ export default function DashboardPage() {
});
})
.finally(() => setLoading(false));
}, []);
}, [authLoading, canView]);
if (loading) {
if (authLoading || loading) {
return (
<div className="loading-spinner">
<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]));
return (
@@ -47,25 +76,44 @@ export default function DashboardPage() {
</Link>
</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="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 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 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 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 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 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>
+148 -22
View File
@@ -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">
+61 -4
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { getTickets, type Ticket } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import BadgeStatus from "@/components/BadgeStatus";
@@ -10,19 +10,40 @@ import PaginationComponent from "@/components/Pagination";
import { formatDate } from "@/lib/format";
import Link from "next/link";
export default function TicketsPage() {
type View = "all" | "mine" | "assigned" | "unassigned";
function TicketsContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { user } = useAuth();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
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 [priorityFilter, setPriorityFilter] = useState("");
const [search, setSearch] = useState("");
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 hasNumericId = !isNaN(numericId);
const isTeam =
@@ -55,6 +76,17 @@ export default function TicketsPage() {
}, [statusFilter, priorityFilter, search, page, view, numericId, hasNumericId, isTeam]);
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 (
<div>
@@ -80,6 +112,23 @@ export default function TicketsPage() {
</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 */}
{isTeam ? (
<div className="filter-bar mb-3">
@@ -224,3 +273,11 @@ export default function TicketsPage() {
</div>
);
}
export default function TicketsPage() {
return (
<Suspense fallback={<div className="loading-spinner"><div className="spinner-border text-primary" role="status"></div></div>}>
<TicketsContent />
</Suspense>
);
}