feat: pestañas Mensajes/Correos, ocultar leídas, solicitud inicial, login al expirar, fusión de tickets (UI)
This commit is contained in:
1 parent
f88d79e2e8
commit
ce3fa29b69
4 files changed
+243
-50
No files matched your search
@@ -75,7 +75,7 @@ push a `main` → webhook Gitea → build + up en `.4`
|
|||||||
Base: `NEXT_PUBLIC_API_URL/api/helpdesk`
|
Base: `NEXT_PUBLIC_API_URL/api/helpdesk`
|
||||||
|
|
||||||
- `POST /login {idUsuario, password}` · `GET /me`
|
- `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|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 /kb/published` · `GET /kb/search?q=` · `GET /kb/categories`
|
||||||
- `GET /stats`
|
- `GET /stats`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
getTicket,
|
getTicket,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getTicketEmails,
|
getTicketEmails,
|
||||||
addComment,
|
addComment,
|
||||||
updateTicketStatus,
|
updateTicketStatus,
|
||||||
|
mergeTickets,
|
||||||
assignTicket,
|
assignTicket,
|
||||||
getGroups,
|
getGroups,
|
||||||
getTeam,
|
getTeam,
|
||||||
@@ -26,6 +27,8 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import BadgeStatus from "@/components/BadgeStatus";
|
import BadgeStatus from "@/components/BadgeStatus";
|
||||||
import BadgePriority from "@/components/BadgePriority";
|
import BadgePriority from "@/components/BadgePriority";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { notify } from "@/lib/notify";
|
||||||
import { formatDateTime } from "@/lib/format";
|
import { formatDateTime } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
extractLatestMessage,
|
extractLatestMessage,
|
||||||
@@ -192,6 +195,7 @@ function EmailBody({ body, isHtml }: { body: string; isHtml: boolean }) {
|
|||||||
|
|
||||||
export default function TicketDetailPage() {
|
export default function TicketDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isTeam =
|
const isTeam =
|
||||||
@@ -216,6 +220,11 @@ export default function TicketDetailPage() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
const [assignOpen, setAssignOpen] = 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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -243,6 +252,19 @@ export default function TicketDetailPage() {
|
|||||||
if (updated) setTicket(updated);
|
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 MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
const handleAddComment = async (e: React.FormEvent) => {
|
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) => {
|
const handleAssignAgent = async (idUsuario: string | null) => {
|
||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
setAssignOpen(false);
|
setAssignOpen(false);
|
||||||
@@ -484,6 +538,15 @@ export default function TicketDetailPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -499,27 +562,127 @@ export default function TicketDetailPage() {
|
|||||||
{/* Conversación estilo chat: equipo a la derecha, solicitante a la izquierda */}
|
{/* Conversación estilo chat: equipo a la derecha, solicitante a la izquierda */}
|
||||||
<div className="col-lg-8">
|
<div className="col-lg-8">
|
||||||
<div className="card mb-4">
|
<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>
|
<i className="bi bi-chat-dots" style={{ color: "var(--primary)" }}></i>
|
||||||
Conversación ({buildThread(ticket, comments, sentEmails, receivedEmails).length})
|
<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">
|
<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-left me-2">Solicitante</span>
|
||||||
<span className="chat-legend chat-legend-right">Equipo</span>
|
<span className="chat-legend chat-legend-right">Equipo</span>
|
||||||
</small>
|
</small>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body chat-thread" style={{ maxHeight: "600px", overflowY: "auto" }}>
|
<div className="card-body chat-thread" style={{ maxHeight: "600px", overflowY: "auto" }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const thread = buildThread(ticket, comments, sentEmails, receivedEmails);
|
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 (
|
return (
|
||||||
|
<>
|
||||||
|
{emailItems.length === 0 ? (
|
||||||
<div className="text-center text-muted py-4">
|
<div className="text-center text-muted py-4">
|
||||||
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
|
<i className="bi bi-envelope fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i>
|
||||||
Sin conversación aún
|
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>
|
</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) => {
|
return (
|
||||||
if (m.kind === "comment") {
|
<>
|
||||||
|
{/* 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">
|
||||||
|
<><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>
|
||||||
|
</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";
|
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
|
||||||
return (
|
return (
|
||||||
<div key={m.key} className={`comment ${alignClass} ${m.isInternal ? "comment-internal" : ""}`}>
|
<div key={m.key} className={`comment ${alignClass} ${m.isInternal ? "comment-internal" : ""}`}>
|
||||||
@@ -557,48 +720,11 @@ export default function TicketDetailPage() {
|
|||||||
</div>
|
</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}`}>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -747,6 +873,35 @@ export default function TicketDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,7 @@ export default function NotificationBell() {
|
|||||||
const [unread, setUnread] = useState(0);
|
const [unread, setUnread] = useState(0);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
|
const [showRead, setShowRead] = useState(false);
|
||||||
const [markingAll, setMarkingAll] = useState(false);
|
const [markingAll, setMarkingAll] = useState(false);
|
||||||
const seenIds = useRef<Set<number>>(new Set());
|
const seenIds = useRef<Set<number>>(new Set());
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
@@ -114,7 +115,10 @@ export default function NotificationBell() {
|
|||||||
}, [fetchAll]);
|
}, [fetchAll]);
|
||||||
|
|
||||||
const modules = [...new Set(items.map((n) => n.module).filter(Boolean))];
|
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) => {
|
const handleOpenItem = (n: AppNotification) => {
|
||||||
if (!n.isRead) {
|
if (!n.isRead) {
|
||||||
@@ -221,11 +225,23 @@ export default function NotificationBell() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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">
|
<div className="notification-list">
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<div className="text-center text-muted py-4" style={{ fontSize: "0.85rem" }}>
|
<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>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
visible.map((n) => {
|
visible.map((n) => {
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000";
|
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>(
|
async function fetchApi<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {}
|
options: RequestInit = {}
|
||||||
@@ -18,6 +29,8 @@ async function fetchApi<T>(
|
|||||||
|
|
||||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
|
||||||
|
if (res.status === 401) handleUnauthorized(path);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json().catch(() => ({}));
|
const errorData = await res.json().catch(() => ({}));
|
||||||
throw new Error(errorData.message || `API Error: ${res.status}`);
|
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) {
|
export async function setTicketArchived(id: number, archived: boolean) {
|
||||||
return fetchApi<void>(`/api/helpdesk/ticket/${id}/archive`, {
|
return fetchApi<void>(`/api/helpdesk/ticket/${id}/archive`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -167,6 +187,7 @@ export async function uploadTicketAttachments(ticketId: number, files: File[]) {
|
|||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
body: form,
|
body: form,
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) handleUnauthorized("attachments");
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json().catch(() => ({}));
|
const errorData = await res.json().catch(() => ({}));
|
||||||
throw new Error(errorData.message || `API Error: ${res.status}`);
|
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}`, {
|
const res = await fetch(`${API_BASE}/api/helpdesk/attachments/${attachment.id}`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) handleUnauthorized("attachments");
|
||||||
if (!res.ok) throw new Error(`No se pudo descargar (${res.status})`);
|
if (!res.ok) throw new Error(`No se pudo descargar (${res.status})`);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|||||||
Reference in new issue
Block a user