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) */}
+
+ Fusionar
+
)}
@@ -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
-
+
+
+ setTab("mensajes")}
+ >
+
+ Mensajes ({buildThread(ticket, comments, sentEmails, receivedEmails).filter((m) => m.kind === "comment").length + 1})
+
+
+
+ setTab("correos")}
+ >
+
+ Correos ({buildThread(ticket, comments, sentEmails, receivedEmails).filter((m) => m.kind !== "comment").length})
+
+
+
+ {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) => (
-
{
- downloadAttachment(a).catch(() => setError("No se pudo descargar el adjunto"));
- }}
- >
-
- {a.filename} ({formatFileSize(a.size)})
-
-
- ))}
-
{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) => (
+
{
+ downloadAttachment(a).catch(() => setError("No se pudo descargar el adjunto"));
+ }}
+ >
+
+ {a.filename} ({formatFileSize(a.size)})
+
+
+ ))}
+
{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.
+
+ Ticket destino
+ 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)}
+ />
+
+ Ver leídas
+
+
{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);