diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 8c9dbcc..c453af4 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -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(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 (
@@ -35,6 +45,25 @@ export default function DashboardPage() { ); } + // Solo administradores (todas las áreas) y supervisores (solo su área). + if (!canView) { + return ( +
+
+

Dashboard

+ + + Ir a Tickets + +
+
+ + El Dashboard está disponible solo para administradores y supervisores. +
+
+ ); + } + const maxDaily = Math.max(...(stats?.dailyCounts?.map((d) => d.count) || [1])); return ( @@ -47,25 +76,44 @@ export default function DashboardPage() {
- {/* Stats Cards */} + {user?.role === "supervisor" ? ( +
+ + Mostrando solo tu área + {user?.departmentName ? ( + <> + : {user.departmentName} + + ) : ( + " asignada" + )} +
+ ) : ( +
+ + Vista de administrador: todas las áreas. Presiona una tarjeta para ver los tickets filtrados. +
+ )} + + {/* Stats Cards: cada tarjeta filtra la lista de tickets según lo presionado */}
- +
- +
- +
- +
- +
- +
diff --git a/frontend/src/app/(app)/tickets/[id]/page.tsx b/frontend/src/app/(app)/tickets/[id]/page.tsx index 6086b8c..2eaa47c 100644 --- a/frontend/src/app/(app)/tickets/[id]/page.tsx +++ b/frontend/src/app/(app)/tickets/[id]/page.tsx @@ -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 ( +
+
+ {expanded ? previewText : preview} +
+ {(hasMore || expanded) && ( + + )} +
+ ); + } + if (!hasMore) { + return
; + } + return ( +
+ {expanded ? ( +
+ ) : ( +
+ )} + +
+ ); + } + + const { preview, hasMore } = extractLatestMessage(body); + if (!hasMore) { + return
{body}
; + } + return ( +
+
+ {expanded ? body : preview} +
+ +
+ ); +} + 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() { )}
- {/* Conversación estilo Freshdesk: comentarios + hilo de correos */} + {/* Conversación estilo chat: equipo a la derecha, solicitante a la izquierda */}
- Conversación ({buildThread(comments, sentEmails, receivedEmails).length}) + Conversación ({buildThread(ticket, comments, sentEmails, receivedEmails).length}) + + Solicitante + Equipo +
-
+
{(() => { - const thread = buildThread(comments, sentEmails, receivedEmails); + const thread = buildThread(ticket, comments, sentEmails, receivedEmails); if (thread.length === 0) { return (
@@ -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 ( -
+
- {m.userName || "Usuario"} + {m.side === "team" ? ( + <>{m.userName || "Soporte"} + ) : ( + <>{m.userName || "Solicitante"} + )} {m.isInternal && ( Nota Interna )}
-
{m.content}
+
{m.content}
{formatDateTime(m.createdAt)}
); } const isSent = m.kind === "sent"; + const alignClass = m.side === "team" ? "comment-agent" : "comment-customer"; return ( -
+
{m.subject}
{isSent ? ( m.body ? ( -
+ /i.test(m.body)} /> ) : (
Plantilla: {m.fallback}
) ) : m.body ? ( -
{m.body}
+ ) : (
{m.status === "inserted" @@ -415,7 +533,8 @@ export default function TicketDetailPage() { })()} 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.
@@ -427,11 +546,18 @@ export default function TicketDetailPage() { + {!isInternal && isTeam && ( + + + Respuesta pública: se envía por correo al solicitante solo el mensaje + escrito (sin historial) y queda guardada para revisar el caso en el futuro. + + )}
diff --git a/frontend/src/app/(app)/tickets/page.tsx b/frontend/src/app/(app)/tickets/page.tsx index f1df11d..fecadba 100644 --- a/frontend/src/app/(app)/tickets/page.tsx +++ b/frontend/src/app/(app)/tickets/page.tsx @@ -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([]); 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("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 (
@@ -80,6 +112,23 @@ export default function TicketsPage() {
)} + {hasActiveFilters && ( +
+ + + Vista filtrada + {statusFilter && <> · Estado: {statusFilter}} + {priorityFilter && <> · Prioridad: {priorityFilter}} + {view !== "all" && <> · Vista: {view === "unassigned" ? "Sin asignar" : view}} + {search && <> · Búsqueda: {search}} + {" "}(desde el Dashboard) + + +
+ )} + {/* Filters */} {isTeam ? (
@@ -224,3 +273,11 @@ export default function TicketsPage() {
); } + +export default function TicketsPage() { + return ( +
}> + + + ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5bcbb94..9ecfe67 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -789,10 +789,87 @@ body { } .comment-internal .comment-bubble { - background: #fff3cd; + background: #fff3cd !important; + color: var(--text-dark) !important; 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 { font-size: 0.75rem; font-weight: 600; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a4c31fc..f9173b2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -9,7 +9,11 @@ export default function Sidebar() { const { user, logout } = useAuth(); 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: "/knowledge-base", icon: "bi-journal-bookmark", label: "Knowledge Base" }, ]; diff --git a/frontend/src/components/StatsCard.tsx b/frontend/src/components/StatsCard.tsx index e034497..384e559 100644 --- a/frontend/src/components/StatsCard.tsx +++ b/frontend/src/components/StatsCard.tsx @@ -1,22 +1,37 @@ +import Link from "next/link"; + interface StatsCardProps { icon: string; value: string | number; label: string; color?: string; + href?: string; + title?: string; } -export default function StatsCard({ icon, value, label, color = "#00285d" }: StatsCardProps) { - return ( -
+export default function StatsCard({ icon, value, label, color = "#00285d", href, title }: StatsCardProps) { + const card = ( +
{value}
-
{label}
+
+ {label} + {href && } +
); + if (href) { + return ( + + {card} + + ); + } + return card; } diff --git a/frontend/src/lib/emailThread.ts b/frontend/src/lib/emailThread.ts new file mode 100644 index 0000000..74b883b --- /dev/null +++ b/frontend/src/lib/emailThread.ts @@ -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 wrote:" + /^\s*On\s.+wrote:\s*$/i, + // "El 1 ene 2024, Juan 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()); + //
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(//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
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(); +}