From ce3fa29b69212fc63c00a2012a61c5eb72002410 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Mon, 2 Nov 2026 15:00:22 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20pesta=C3=B1as=20Mensajes/Correos,=20ocu?= =?UTF-8?q?ltar=20le=C3=ADdas,=20solicitud=20inicial,=20login=20al=20expir?= =?UTF-8?q?ar,=20fusi=C3=B3n=20de=20tickets=20(UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- frontend/src/app/(app)/tickets/[id]/page.tsx | 327 ++++++++++++++----- frontend/src/components/NotificationBell.tsx | 20 +- frontend/src/lib/api.ts | 22 ++ 4 files changed, 282 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 300a48d..00c049e 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ push a `main` → webhook Gitea → build + up en `.4` Base: `NEXT_PUBLIC_API_URL/api/helpdesk` - `POST /login {idUsuario, password}` · `GET /me` -- `GET /tickets?...` (con `archived=false` solo activos [default UI], `true` solo archivados, ausente todos) · `GET /ticket/{id}` · `POST /tickets` · `PUT /ticket/{id}` (+ `/status`, `/assign`, `/archive`) · `DELETE /ticket/{id}` +- `GET /tickets?...` (con `archived=false` solo activos [default UI], `true` solo archivados, ausente todos) · `GET /ticket/{id}` · `POST /tickets` · `PUT /ticket/{id}` (+ `/status`, `/assign`, `/archive`) · `POST /ticket/{id}/merge {sourceId}` (mueve historial y cierra origen) · `DELETE /ticket/{id}` - `GET|POST /comments/{ticketId}` (POST acepta `cc` y `attachmentIds`, solo públicas) · `POST /ticket/{id}/attachments` (multipart, máx 5 × 10 MB) · `GET /ticket/{id}/attachments` · `GET /attachments/{fileId}` (descarga) - `GET /kb/published` · `GET /kb/search?q=` · `GET /kb/categories` - `GET /stats` diff --git a/frontend/src/app/(app)/tickets/[id]/page.tsx b/frontend/src/app/(app)/tickets/[id]/page.tsx index 22d1419..a52beb9 100644 --- a/frontend/src/app/(app)/tickets/[id]/page.tsx +++ b/frontend/src/app/(app)/tickets/[id]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth"; import { getTicket, @@ -9,6 +9,7 @@ import { getTicketEmails, addComment, updateTicketStatus, + mergeTickets, assignTicket, getGroups, getTeam, @@ -26,6 +27,8 @@ import { } from "@/lib/api"; import BadgeStatus from "@/components/BadgeStatus"; import BadgePriority from "@/components/BadgePriority"; +import Modal from "@/components/Modal"; +import { notify } from "@/lib/notify"; import { formatDateTime } from "@/lib/format"; import { extractLatestMessage, @@ -192,6 +195,7 @@ function EmailBody({ body, isHtml }: { body: string; isHtml: boolean }) { export default function TicketDetailPage() { const params = useParams(); + const router = useRouter(); const id = Number(params.id); const { user } = useAuth(); const isTeam = @@ -216,6 +220,11 @@ export default function TicketDetailPage() { const [error, setError] = useState(""); const [statusOpen, setStatusOpen] = useState(false); const [assignOpen, setAssignOpen] = useState(false); + const [tab, setTab] = useState<"mensajes" | "correos">("mensajes"); + const [mergeOpen, setMergeOpen] = useState(false); + const [mergeTarget, setMergeTarget] = useState(""); + const [mergeError, setMergeError] = useState(""); + const [merging, setMerging] = useState(false); useEffect(() => { Promise.all([ @@ -243,6 +252,19 @@ export default function TicketDetailPage() { if (updated) setTicket(updated); }; + // Aviso al llegar desde una fusión (?merged=N): toast una vez y limpia la URL. + useEffect(() => { + const q = new URLSearchParams(window.location.search); + if (q.get("merged") !== null) { + const n = Number(q.get("merged") || 0); + notify.success( + "Tickets fusionados", + n > 0 ? `Se movieron ${n} elemento(s) a este ticket.` : undefined + ); + window.history.replaceState(null, "", window.location.pathname); + } + }, []); + const MAX_FILE_BYTES = 10 * 1024 * 1024; const handleAddComment = async (e: React.FormEvent) => { @@ -328,6 +350,38 @@ export default function TicketDetailPage() { } }; + const openMerge = () => { + setMergeTarget(""); + setMergeError(""); + setStatusOpen(false); + setAssignOpen(false); + setMergeOpen(true); + }; + + const handleMerge = async () => { + // Acepta "HD-000123" o "123". + const targetId = Number(String(mergeTarget).replace(/\D/g, "")); + if (!targetId) { + setMergeError("Ingresa el número del ticket destino (ej: HD-000123)"); + return; + } + if (targetId === id) { + setMergeError("Origen y destino deben ser distintos"); + return; + } + setMerging(true); + setMergeError(""); + try { + const res = await mergeTickets(targetId, id); + setMergeOpen(false); + router.push(`/tickets/${targetId}?merged=${res.merged.comments + res.merged.attachments}`); + } catch (err: unknown) { + setMergeError(err instanceof Error ? err.message : "No se pudo fusionar"); + } finally { + setMerging(false); + } + }; + const handleAssignAgent = async (idUsuario: string | null) => { if (!ticket) return; setAssignOpen(false); @@ -484,6 +538,15 @@ export default function TicketDetailPage() { )} + {/* Fusionar este ticket en otro (mueve historial y cierra este) */} + )} @@ -499,106 +562,169 @@ export default function TicketDetailPage() { {/* Conversación estilo chat: equipo a la derecha, solicitante a la izquierda */}
-
+
- Conversación ({buildThread(ticket, comments, sentEmails, receivedEmails).length}) - - Solicitante - Equipo - +
    +
  • + +
  • +
  • + +
  • +
+ {tab === "mensajes" && ( + + Solicitante + Equipo + + )}
{(() => { const thread = buildThread(ticket, comments, sentEmails, receivedEmails); - if (thread.length === 0) { + const messageItems = thread.filter( + (m): m is Extract => m.kind === "comment" + ); + const emailItems = thread.filter( + (m): m is Exclude => m.kind !== "comment" + ); + if (tab === "correos") { return ( -
- - Sin conversación aún -
+ <> + {emailItems.length === 0 ? ( +
+ + Sin correos en este ticket +
+ ) : ( + emailItems.map((m) => { + const isSent = m.kind === "sent"; + const alignClass = m.side === "team" ? "comment-agent" : "comment-customer"; + return ( +
+
+
+ + + + {isSent ? `Soporte → ${m.who}` : `De: ${m.who}`} +
+
{m.subject}
+ {isSent ? ( + m.body ? ( + /i.test(m.body)} /> + ) : ( +
Plantilla: {m.fallback}
+ ) + ) : m.body ? ( + + ) : ( +
+ {m.status === "inserted" + ? "Agregado como comentario" + : `Omitido: ${m.error || m.status}`} +
+ )} +
{formatDateTime(m.createdAt)}
+
+
+ ); + }) + )} + + Las respuestas con etiqueta HD-{String(id).padStart(6, "0")} en el asunto + llegan a este hilo (ingesta cada 5 min). Los correos muestran solo el + último mensaje; usa “Ver mensaje completo” para el historial. + + ); } - return thread.map((m) => { - if (m.kind === "comment") { - const alignClass = m.side === "team" ? "comment-agent" : "comment-customer"; - return ( -
-
-
- {m.side === "team" ? ( - <>{m.userName || "Soporte"} - ) : ( - <>{m.userName || "Solicitante"} - )} - {m.isInternal && ( - - Nota Interna - - )} -
-
{m.content}
- {ticketFiles.filter((a) => a.commentId === m.id).map((a) => ( - - ))} -
{formatDateTime(m.createdAt)}
-
-
- ); - } - const isSent = m.kind === "sent"; - const alignClass = m.side === "team" ? "comment-agent" : "comment-customer"; - return ( -
+ return ( + <> + {/* Lo pedido en el ticket como primer mensaje de la conversación */} +
- - + <>{ticket.requesterName || "Solicitante"} + + Solicitud inicial - {isSent ? `Soporte → ${m.who}` : `De: ${m.who}`}
-
{m.subject}
- {isSent ? ( - m.body ? ( - /i.test(m.body)} /> - ) : ( -
Plantilla: {m.fallback}
- ) - ) : m.body ? ( - - ) : ( -
- {m.status === "inserted" - ? "Agregado como comentario" - : `Omitido: ${m.error || m.status}`} -
- )} -
{formatDateTime(m.createdAt)}
+
{ticket.title}
+
{ticket.description || "Sin descripción"}
+
{formatDateTime(ticket.createdAt)}
- ); - }); + {messageItems.length === 0 ? ( +
+ + Sin respuestas aún +
+ ) : ( + messageItems.map((m) => { + const alignClass = m.side === "team" ? "comment-agent" : "comment-customer"; + return ( +
+
+
+ {m.side === "team" ? ( + <>{m.userName || "Soporte"} + ) : ( + <>{m.userName || "Solicitante"} + )} + {m.isInternal && ( + + Nota Interna + + )} +
+
{m.content}
+ {ticketFiles.filter((a) => a.commentId === m.id).map((a) => ( + + ))} +
{formatDateTime(m.createdAt)}
+
+
+ ); + }) + )} + + ); })()} - - Las respuestas con etiqueta HD-{String(id).padStart(6, "0")} en el asunto - llegan a este hilo (ingesta cada 5 min). Los correos muestran solo el - último mensaje; usa “Ver mensaje completo” para el historial. -
@@ -747,6 +873,35 @@ export default function TicketDetailPage() {
+ + setMergeOpen(false)} + onSubmit={handleMerge} + submitLabel="Fusionar" + submitting={merging} + > +

+ Los mensajes, adjuntos y correos de este ticket se moverán al destino + y este ticket se cerrará. Esta acción no se puede deshacer. +

+ + setMergeTarget(e.target.value)} + autoFocus + /> + {mergeError && ( +
+ + {mergeError} +
+ )} +
); } diff --git a/frontend/src/components/NotificationBell.tsx b/frontend/src/components/NotificationBell.tsx index 2f7f060..d332e7b 100644 --- a/frontend/src/components/NotificationBell.tsx +++ b/frontend/src/components/NotificationBell.tsx @@ -49,6 +49,7 @@ export default function NotificationBell() { const [unread, setUnread] = useState(0); const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); + const [showRead, setShowRead] = useState(false); const [markingAll, setMarkingAll] = useState(false); const seenIds = useRef>(new Set()); const initialized = useRef(false); @@ -114,7 +115,10 @@ export default function NotificationBell() { }, [fetchAll]); const modules = [...new Set(items.map((n) => n.module).filter(Boolean))]; - const visible = filter ? items.filter((n) => n.module === filter) : items; + // Las leídas ya no se muestran (solo con el interruptor "Ver leídas"). + const visible = items.filter( + (n) => (!filter || n.module === filter) && (showRead || !n.isRead) + ); const handleOpenItem = (n: AppNotification) => { if (!n.isRead) { @@ -221,11 +225,23 @@ export default function NotificationBell() { })} )} +
+ setShowRead(e.target.checked)} + /> + +
{visible.length === 0 ? (
- Sin notificaciones + {showRead ? "Sin notificaciones" : "Sin notificaciones sin leer"}
) : ( visible.map((n) => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 879c210..4c7d511 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,16 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000"; +// Sesión expirada o inválida: limpia el token y lleva al login. +// Se omite en el propio login (ahí el 401 es "credenciales inválidas") +// y si ya estamos en /login para no recargar en bucle. +function handleUnauthorized(path: string) { + if (typeof window === "undefined") return; + if (path.includes("/login")) return; + if (window.location.pathname === "/login") return; + localStorage.removeItem("helpdesk_token"); + window.location.href = "/login"; +} + async function fetchApi( path: string, options: RequestInit = {} @@ -18,6 +29,8 @@ async function fetchApi( const res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + if (res.status === 401) handleUnauthorized(path); + if (!res.ok) { const errorData = await res.json().catch(() => ({})); throw new Error(errorData.message || `API Error: ${res.status}`); @@ -110,6 +123,13 @@ export async function updateTicketStatus(id: number, status: string) { }); } +export async function mergeTickets(targetId: number, sourceId: number) { + return fetchApi<{ ticket: Ticket; merged: { comments: number; attachments: number; inbound: number; sourceId: number } }>( + `/api/helpdesk/ticket/${targetId}/merge`, + { method: "POST", body: JSON.stringify({ sourceId }) } + ); +} + export async function setTicketArchived(id: number, archived: boolean) { return fetchApi(`/api/helpdesk/ticket/${id}/archive`, { method: "PUT", @@ -167,6 +187,7 @@ export async function uploadTicketAttachments(ticketId: number, files: File[]) { headers: token ? { Authorization: `Bearer ${token}` } : {}, body: form, }); + if (res.status === 401) handleUnauthorized("attachments"); if (!res.ok) { const errorData = await res.json().catch(() => ({})); throw new Error(errorData.message || `API Error: ${res.status}`); @@ -184,6 +205,7 @@ export async function downloadAttachment(attachment: TicketAttachment) { const res = await fetch(`${API_BASE}/api/helpdesk/attachments/${attachment.id}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); + if (res.status === 401) handleUnauthorized("attachments"); if (!res.ok) throw new Error(`No se pudo descargar (${res.status})`); const blob = await res.blob(); const url = URL.createObjectURL(blob);