feat: pestañas Mensajes/Correos, ocultar leídas, solicitud inicial, login al expirar, fusión de tickets (UI)

This commit is contained in:
Nurfog committed 2026-11-02 15:00:22 -03:00
1 parent f88d79e2e8
commit ce3fa29b69
4 files changed
+282 -89

No files matched your search

+1 -1
View File
@@ -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`
+241 -86
View File
@@ -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() {
</>
)}
</div>
{/* Fusionar este ticket en otro (mueve historial y cierra este) */}
<button
className="btn btn-outline-secondary btn-sm"
disabled={acting}
title="Fusionar en otro ticket"
onClick={openMerge}
>
<i className="bi bi-intersect me-1"></i>Fusionar
</button>
</div>
)}
</div>
@@ -499,106 +562,169 @@ export default function TicketDetailPage() {
{/* 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">
<div className="card-header d-flex align-items-center gap-2 flex-wrap">
<i className="bi bi-chat-dots" style={{ color: "var(--primary)" }}></i>
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>
<ul className="nav nav-tabs card-header-tabs border-0" role="tablist">
<li className="nav-item" role="presentation">
<button
type="button"
role="tab"
aria-selected={tab === "mensajes"}
className={`nav-link ${tab === "mensajes" ? "active" : ""}`}
onClick={() => setTab("mensajes")}
>
<i className="bi bi-chat-dots me-1"></i>
Mensajes ({buildThread(ticket, comments, sentEmails, receivedEmails).filter((m) => m.kind === "comment").length + 1})
</button>
</li>
<li className="nav-item" role="presentation">
<button
type="button"
role="tab"
aria-selected={tab === "correos"}
className={`nav-link ${tab === "correos" ? "active" : ""}`}
onClick={() => setTab("correos")}
>
<i className="bi bi-envelope me-1"></i>
Correos ({buildThread(ticket, comments, sentEmails, receivedEmails).filter((m) => m.kind !== "comment").length})
</button>
</li>
</ul>
{tab === "mensajes" && (
<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 chat-thread" style={{ maxHeight: "600px", overflowY: "auto" }}>
{(() => {
const thread = buildThread(ticket, comments, sentEmails, receivedEmails);
if (thread.length === 0) {
const messageItems = thread.filter(
(m): m is Extract<ThreadItem, { kind: "comment" }> => m.kind === "comment"
);
const emailItems = thread.filter(
(m): m is Exclude<ThreadItem, { kind: "comment" }> => m.kind !== "comment"
);
if (tab === "correos") {
return (
<div className="text-center text-muted py-4">
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
Sin conversación aún
</div>
<>
{emailItems.length === 0 ? (
<div className="text-center text-muted py-4">
<i className="bi bi-envelope fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
Sin correos en este ticket
</div>
) : (
emailItems.map((m) => {
const isSent = m.kind === "sent";
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
return (
<div key={m.key} className={`comment ${alignClass}`}>
<div className="comment-bubble">
<div className="comment-author">
<span
className={`badge ${isSent ? "bg-primary" : "bg-success"} me-2`}
title={isSent ? "Enviado desde soporte" : "Respuesta recibida por correo"}
>
<i className={`bi ${isSent ? "bi-send" : "bi-inbox"}`}></i>
</span>
{isSent ? `Soporte → ${m.who}` : `De: ${m.who}`}
</div>
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
{isSent ? (
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 ? (
<EmailBody body={m.body} isHtml={false} />
) : (
<div className="comment-text text-muted">
{m.status === "inserted"
? "Agregado como comentario"
: `Omitido: ${m.error || m.status}`}
</div>
)}
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
</div>
</div>
);
})
)}
<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). Los correos muestran solo el
último mensaje; usa “Ver mensaje completo” para el historial.
</small>
</>
);
}
return thread.map((m) => {
if (m.kind === "comment") {
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
return (
<div key={m.key} className={`comment ${alignClass} ${m.isInternal ? "comment-internal" : ""}`}>
<div className="comment-bubble">
<div className="comment-author">
{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" style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
{ticketFiles.filter((a) => a.commentId === m.id).map((a) => (
<button
key={a.id}
type="button"
className="badge bg-light text-dark border d-flex align-items-center gap-1 mt-1"
style={{ fontSize: "0.72rem", cursor: "pointer" }}
title={`Descargar ${a.filename} (${formatFileSize(a.size)})`}
onClick={() => {
downloadAttachment(a).catch(() => setError("No se pudo descargar el adjunto"));
}}
>
<i className="bi bi-paperclip"></i>
{a.filename} ({formatFileSize(a.size)})
<i className="bi bi-download"></i>
</button>
))}
<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 ${alignClass}`}>
return (
<>
{/* Lo pedido en el ticket como primer mensaje de la conversación */}
<div className="comment comment-customer">
<div className="comment-bubble">
<div className="comment-author">
<span
className={`badge ${isSent ? "bg-primary" : "bg-success"} me-2`}
title={isSent ? "Enviado desde soporte" : "Respuesta recibida por correo"}
>
<i className={`bi ${isSent ? "bi-send" : "bi-inbox"}`}></i>
<><i className="bi bi-person me-1"></i>{ticket.requesterName || "Solicitante"}</>
<span className="badge bg-primary ms-2" style={{ fontSize: "0.65rem" }}>
Solicitud inicial
</span>
{isSent ? `Soporte → ${m.who}` : `De: ${m.who}`}
</div>
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{m.subject}</div>
{isSent ? (
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 ? (
<EmailBody body={m.body} isHtml={false} />
) : (
<div className="comment-text text-muted">
{m.status === "inserted"
? "Agregado como comentario"
: `Omitido: ${m.error || m.status}`}
</div>
)}
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{ticket.title}</div>
<div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{ticket.description || "Sin descripción"}</div>
<div className="comment-time">{formatDateTime(ticket.createdAt)}</div>
</div>
</div>
);
});
{messageItems.length === 0 ? (
<div className="text-center text-muted py-4">
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
Sin respuestas aún
</div>
) : (
messageItems.map((m) => {
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
return (
<div key={m.key} className={`comment ${alignClass} ${m.isInternal ? "comment-internal" : ""}`}>
<div className="comment-bubble">
<div className="comment-author">
{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" style={{ whiteSpace: "pre-wrap" }}>{m.content}</div>
{ticketFiles.filter((a) => a.commentId === m.id).map((a) => (
<button
key={a.id}
type="button"
className="badge bg-light text-dark border d-flex align-items-center gap-1 mt-1"
style={{ fontSize: "0.72rem", cursor: "pointer" }}
title={`Descargar ${a.filename} (${formatFileSize(a.size)})`}
onClick={() => {
downloadAttachment(a).catch(() => setError("No se pudo descargar el adjunto"));
}}
>
<i className="bi bi-paperclip"></i>
{a.filename} ({formatFileSize(a.size)})
<i className="bi bi-download"></i>
</button>
))}
<div className="comment-time">{formatDateTime(m.createdAt)}</div>
</div>
</div>
);
})
)}
</>
);
})()}
<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). Los correos muestran solo el
último mensaje; usa “Ver mensaje completo” para el historial.
</small>
</div>
</div>
@@ -747,6 +873,35 @@ export default function TicketDetailPage() {
</div>
</div>
</div>
<Modal
show={mergeOpen}
title={`Fusionar ${ticket.ticketNumber} en otro ticket`}
onClose={() => setMergeOpen(false)}
onSubmit={handleMerge}
submitLabel="Fusionar"
submitting={merging}
>
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
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.
</p>
<label className="form-label">Ticket destino</label>
<input
type="text"
className="form-control"
placeholder="HD-000123"
value={mergeTarget}
onChange={(e) => setMergeTarget(e.target.value)}
autoFocus
/>
{mergeError && (
<div className="alert alert-danger py-2 mt-3 mb-0" role="alert">
<i className="bi bi-exclamation-circle me-2"></i>
{mergeError}
</div>
)}
</Modal>
</div>
);
}
+18 -2
View File
@@ -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<Set<number>>(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() {
})}
</div>
)}
<div className="form-check d-flex align-items-center gap-2 px-3 py-2 border-top">
<input
className="form-check-input m-0"
type="checkbox"
id="notif-show-read"
checked={showRead}
onChange={(e) => setShowRead(e.target.checked)}
/>
<label className="form-check-label text-muted" htmlFor="notif-show-read" style={{ fontSize: "0.8rem" }}>
Ver leídas
</label>
</div>
<div className="notification-list">
{visible.length === 0 ? (
<div className="text-center text-muted py-4" style={{ fontSize: "0.85rem" }}>
<i className="bi bi-bell-slash fs-4 d-block mb-2" style={{ opacity: 0.4 }}></i>
Sin notificaciones
{showRead ? "Sin notificaciones" : "Sin notificaciones sin leer"}
</div>
) : (
visible.map((n) => {
+22
View File
@@ -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<T>(
path: string,
options: RequestInit = {}
@@ -18,6 +29,8 @@ async function fetchApi<T>(
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<void>(`/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);