Compare commits

...
14 Commits
Author SHA1 Message Date
Nurfog 01ceb8c619 feat(ticket): restaurar tarjeta Descripción del lateral 2026-11-02 16:42:33 -03:00
Nurfog a2bcab9282 feat(ticket): quitar icono duplicado junto a pestañas Mensajes/Correos 2026-11-02 16:41:35 -03:00
Nurfog 318ac083e9 feat(ticket): quitar tarjeta Descripción duplicada del lateral 2026-11-02 16:40:12 -03:00
Nurfog b2112eb390 feat(respuesta): elegir marca, el botón ejecuta 2026-11-02 16:32:10 -03:00
Nurfog 7a3d146892 feat(respuesta): botón único de envío con opción visible 2026-11-02 16:27:57 -03:00
Nurfog b1710da477 feat(respuesta): botón Enviar con opciones (enviar, enviar y resuelto, enviar y cerrado) 2026-11-02 16:15:16 -03:00
Nurfog ce3fa29b69 feat: pestañas Mensajes/Correos, ocultar leídas, solicitud inicial, login al expirar, fusión de tickets (UI) 2026-11-02 15:00:22 -03:00
Nurfog f88d79e2e8 feat(respuesta): CC y adjuntos (10MB) con descarga en el hilo 2026-10-22 13:16:29 -03:00
Nurfog dd1e1a231a feat(tickets): sin botón archivar, Archivo visible a todos, dashboard coherente 2026-10-22 12:23:59 -03:00
Nurfog eace5fbcd7 fix(login): -X solo en usuario, contraseña intacta 2026-10-22 11:45:03 -03:00
Nurfog 4a84271f44 fix(login): quitar -X también del campo usuario 2026-10-22 11:44:18 -03:00
Nurfog 24dcb6d770 feat(tickets): filtro Todos/Activos/Archivados + botón archivar 2026-10-22 11:19:25 -03:00
Nurfog f536f11dfd feat(login): tolerar -X del RUT en clave, se quita al ingresar 2026-10-22 10:34:51 -03:00
Nurfog 580ecff258 fix: mostrar error de carga en vez de tabla vacía silenciosa 2026-10-22 10:12:01 -03:00
7 changed files with 645 additions and 106 deletions

No files matched your search

+2 -2
View File
@@ -75,8 +75,8 @@ 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?...` · `GET /ticket/{id}` · `POST /tickets` · `PUT /ticket/{id}` (+ `/status`, `/assign`) · `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}` - `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`
- `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin) - `GET|POST /groups` · `PUT|DELETE /groups/{id}` (admin)
+2 -2
View File
@@ -83,7 +83,7 @@ export default function DashboardPage() {
{/* Stats Cards: cada tarjeta filtra la lista de tickets según lo presionado */} {/* Stats Cards: cada tarjeta filtra la lista de tickets según lo presionado */}
<div className="row g-3 mb-4"> <div className="row g-3 mb-4">
<div className="col-6 col-lg"> <div className="col-6 col-lg">
<StatsCard icon="bi-ticket-detailed" value={stats?.totalTickets || 0} label="Total Tickets" color="#00285d" href="/tickets" /> <StatsCard icon="bi-ticket-detailed" value={stats?.totalTickets || 0} label="Total Tickets" color="#00285d" href="/tickets?archived=all" />
</div> </div>
<div className="col-6 col-lg"> <div className="col-6 col-lg">
<StatsCard icon="bi-folder2-open" value={stats?.openTickets || 0} label="Abiertos" color="#198754" href="/tickets?status=Open" /> <StatsCard icon="bi-folder2-open" value={stats?.openTickets || 0} label="Abiertos" color="#198754" href="/tickets?status=Open" />
@@ -92,7 +92,7 @@ export default function DashboardPage() {
<StatsCard icon="bi-clock-history" value={stats?.inProgressTickets || 0} label="En Progreso" color="#ffc107" href="/tickets?status=InProgress" /> <StatsCard icon="bi-clock-history" value={stats?.inProgressTickets || 0} label="En Progreso" color="#ffc107" href="/tickets?status=InProgress" />
</div> </div>
<div className="col-6 col-lg"> <div className="col-6 col-lg">
<StatsCard icon="bi-check-circle" value={stats?.resolvedTickets || 0} label="Resueltos" color="#0d6efd" href="/tickets?status=Resolved" /> <StatsCard icon="bi-check-circle" value={stats?.resolvedTickets || 0} label="Resueltos" color="#0d6efd" href="/tickets?status=Resolved&archived=all" />
</div> </div>
<div className="col-6 col-lg"> <div className="col-6 col-lg">
<StatsCard icon="bi-exclamation-triangle" value={stats?.criticalTickets || 0} label="Críticos" color="#dc3545" href="/tickets?priority=Critical" /> <StatsCard icon="bi-exclamation-triangle" value={stats?.criticalTickets || 0} label="Críticos" color="#dc3545" href="/tickets?priority=Critical" />
+451 -92
View File
@@ -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,18 +9,26 @@ import {
getTicketEmails, getTicketEmails,
addComment, addComment,
updateTicketStatus, updateTicketStatus,
mergeTickets,
assignTicket, assignTicket,
getGroups, getGroups,
getTeam, getTeam,
uploadTicketAttachments,
getTicketAttachments,
downloadAttachment,
formatFileSize,
type Ticket, type Ticket,
type Comment, type Comment,
type Group, type Group,
type TeamMember, type TeamMember,
type TicketAttachment,
type TicketSentEmail, type TicketSentEmail,
type TicketReceivedEmail, type TicketReceivedEmail,
} 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,
@@ -45,7 +53,7 @@ const STATUS_OPTIONS = [
// Lado del chat: equipo (agentes/admin/supervisor) a la derecha, // Lado del chat: equipo (agentes/admin/supervisor) a la derecha,
// solicitante a la izquierda, como en un chat. // solicitante a la izquierda, como en un chat.
type ThreadItem = type ThreadItem =
| { kind: "comment"; key: string; createdAt: string; userId: string; userName?: string; content: string; isInternal: boolean; side: "team" | "customer" } | { kind: "comment"; key: string; id: number; 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: "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" }; | { kind: "received"; key: string; createdAt: string; who: string; subject: string; body?: string; status: string; error?: string; side: "customer" };
@@ -69,6 +77,7 @@ function buildThread(
.map((c): ThreadItem => ({ .map((c): ThreadItem => ({
kind: "comment", kind: "comment",
key: `comment-${c.id}`, key: `comment-${c.id}`,
id: c.id,
createdAt: c.createdAt, createdAt: c.createdAt,
userId: c.userId, userId: c.userId,
userName: c.userName, userName: c.userName,
@@ -186,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 =
@@ -200,11 +210,41 @@ export default function TicketDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [isInternal, setIsInternal] = useState(false); const [isInternal, setIsInternal] = useState(false);
const [cc, setCc] = useState("");
const [files, setFiles] = useState<File[]>([]);
// Ids ya subidos pendientes de vincular (si el envío del comentario falló).
const [pendingIds, setPendingIds] = useState<number[]>([]);
const [ticketFiles, setTicketFiles] = useState<TicketAttachment[]>([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
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);
const [sendMenuOpen, setSendMenuOpen] = useState(false);
const [sendAction, setSendAction] = useState<"send" | "resolved" | "closed">("send");
const SEND_ACTIONS = {
send: { label: "Enviar", icon: "bi-send", hint: "Solo publica el mensaje" },
resolved: { label: "Enviar y resuelto", icon: "bi-check-circle", hint: "Publica y marca resuelto" },
closed: { label: "Enviar y cerrado", icon: "bi-archive", hint: "Publica y archiva el ticket" },
} as const;
const handleAddComment = (e: React.FormEvent) => {
e.preventDefault();
setSendMenuOpen(false);
void submitComment(sendAction === "send" ? undefined : sendAction);
};
const chooseSendAction = (action: "send" | "resolved" | "closed") => {
// Solo marca la opción; se ejecuta al presionar el botón.
setSendAction(action);
setSendMenuOpen(false);
};
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -213,14 +253,16 @@ export default function TicketDetailPage() {
getTicketEmails(id).catch(() => ({ sent: [], received: [] })), getTicketEmails(id).catch(() => ({ sent: [], received: [] })),
getGroups().catch(() => []), getGroups().catch(() => []),
getTeam().catch(() => []), getTeam().catch(() => []),
getTicketAttachments(id).catch(() => []),
]) ])
.then(([ticketData, commentsData, emailsData, groupsData, teamData]) => { .then(([ticketData, commentsData, emailsData, groupsData, teamData, filesData]) => {
setTicket(ticketData); setTicket(ticketData);
setComments(commentsData); setComments(commentsData);
setSentEmails(emailsData.sent ?? []); setSentEmails(emailsData.sent ?? []);
setReceivedEmails(emailsData.received ?? []); setReceivedEmails(emailsData.received ?? []);
setGroups(groupsData); setGroups(groupsData);
setTeam(teamData); setTeam(teamData);
setTicketFiles(filesData ?? []);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
@@ -230,28 +272,79 @@ export default function TicketDetailPage() {
if (updated) setTicket(updated); if (updated) setTicket(updated);
}; };
const handleAddComment = async (e: React.FormEvent) => { // Aviso al llegar desde una fusión (?merged=N): toast una vez y limpia la URL.
e.preventDefault(); 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;
// Envía el comentario y, opcionalmente, resuelve o cierra el ticket.
const submitComment = async (after?: "resolved" | "closed") => {
if (!comment.trim()) return; if (!comment.trim()) return;
// Solo se envía/guarda el mensaje escrito, sin historial pegado o // Solo se envía/guarda el mensaje escrito, sin historial pegado o
// citado (se usa para revisar casos en el futuro). // citado (se usa para revisar casos en el futuro).
const cleaned = cleanReplyForSend(comment); const cleaned = cleanReplyForSend(comment);
if (!cleaned) return; if (!cleaned) return;
if (files.length > 5) {
setError("Máximo 5 archivos por respuesta");
return;
}
const tooBig = files.find((f) => f.size > MAX_FILE_BYTES);
if (tooBig) {
setError(`${tooBig.name}: supera los 10 MB`);
return;
}
setSubmitting(true); setSubmitting(true);
setError(""); setError("");
let newIds: number[] = [];
try { try {
await addComment(id, cleaned, isInternal); // 1) Subir adjuntos (si hay) y 2) publicar el comentario con CC e ids.
const [updated, emails] = await Promise.all([ if (files.length > 0) {
const up = await uploadTicketAttachments(id, files);
newIds = up.files.map((f) => f.id);
}
const ids = [...pendingIds, ...newIds];
await addComment(id, cleaned, isInternal, isInternal ? undefined : cc.trim() || undefined, ids.length > 0 ? ids : undefined);
const [updated, emails, atts] = await Promise.all([
getComments(id).catch(() => []), getComments(id).catch(() => []),
getTicketEmails(id).catch(() => ({ sent: [], received: [] })), getTicketEmails(id).catch(() => ({ sent: [], received: [] })),
getTicketAttachments(id).catch(() => []),
]); ]);
setComments(updated); setComments(updated);
setSentEmails(emails.sent ?? []); setSentEmails(emails.sent ?? []);
setReceivedEmails(emails.received ?? []); setReceivedEmails(emails.received ?? []);
setTicketFiles(atts ?? []);
setComment(""); setComment("");
setIsInternal(false); setIsInternal(false);
} catch { setCc("");
setError("No se pudo enviar el mensaje"); setFiles([]);
setPendingIds([]);
if (after) {
try {
await updateTicketStatus(id, after);
await refreshTicket();
notify.success(after === "resolved" ? "Ticket resuelto" : "Ticket cerrado");
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "El mensaje se envió, pero no se pudo cambiar el estado");
}
}
} catch (err: unknown) {
// Si la subida salió pero el comentario falló, los ids quedan
// pendientes y se vinculan en el próximo envío.
if (newIds.length > 0) {
setPendingIds((prev) => [...new Set([...prev, ...newIds])]);
setFiles([]);
}
setError(err instanceof Error ? err.message : "No se pudo enviar el mensaje");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -286,6 +379,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);
@@ -338,6 +463,11 @@ export default function TicketDetailPage() {
<div className="d-flex gap-2 mt-2"> <div className="d-flex gap-2 mt-2">
<BadgeStatus status={ticket.status} /> <BadgeStatus status={ticket.status} />
<BadgePriority priority={ticket.priority} /> <BadgePriority priority={ticket.priority} />
{ticket.archived && (
<span className="badge bg-secondary" title="Ticket archivado">
<i className="bi bi-archive me-1"></i>Archivado
</span>
)}
</div> </div>
</div> </div>
{isTeam && ( {isTeam && (
@@ -437,6 +567,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>
@@ -452,90 +591,168 @@ 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> <ul className="nav nav-tabs card-header-tabs border-0" role="tablist">
Conversación ({buildThread(ticket, comments, sentEmails, receivedEmails).length}) <li className="nav-item" role="presentation">
<small className="text-muted fw-normal ms-auto d-none d-md-inline"> <button
<span className="chat-legend chat-legend-left me-2">Solicitante</span> type="button"
<span className="chat-legend chat-legend-right">Equipo</span> role="tab"
</small> 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>
<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 (
<div className="text-center text-muted py-4"> <>
<i className="bi bi-chat fs-3 d-block mb-2" style={{ opacity: 0.3 }}></i> {emailItems.length === 0 ? (
Sin conversación aún <div className="text-center text-muted py-4">
</div> <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) => { return (
if (m.kind === "comment") { <>
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer"; {/* Lo pedido en el ticket como primer mensaje de la conversación */}
return ( <div className="comment comment-customer">
<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>
<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}`}>
<div className="comment-bubble"> <div className="comment-bubble">
<div className="comment-author"> <div className="comment-author">
<span <><i className="bi bi-person me-1"></i>{ticket.requesterName || "Solicitante"}</>
className={`badge ${isSent ? "bg-primary" : "bg-success"} me-2`} <span className="badge bg-primary ms-2" style={{ fontSize: "0.65rem" }}>
title={isSent ? "Enviado desde soporte" : "Respuesta recibida por correo"} Solicitud inicial
>
<i className={`bi ${isSent ? "bi-send" : "bi-inbox"}`}></i>
</span> </span>
{isSent ? `Soporte → ${m.who}` : `De: ${m.who}`}
</div> </div>
<div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{m.subject}</div> <div className="fw-semibold" style={{ fontSize: "0.85rem" }}>{ticket.title}</div>
{isSent ? ( <div className="comment-text" style={{ whiteSpace: "pre-wrap" }}>{ticket.description || "Sin descripción"}</div>
m.body ? ( <div className="comment-time">{formatDateTime(ticket.createdAt)}</div>
<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> </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>
</div> </div>
@@ -559,6 +776,60 @@ export default function TicketDetailPage() {
</small> </small>
)} )}
</div> </div>
{!isInternal && (
<div className="mb-3">
<label className="form-label" style={{ fontSize: "0.85rem" }}>
<i className="bi bi-people me-1"></i>
CC (copia por correo, separados por coma)
</label>
<input
type="text"
className="form-control form-control-sm"
placeholder="jefe@norteamericano.cl, otro@norteamericano.cl"
value={cc}
onChange={(e) => setCc(e.target.value)}
/>
</div>
)}
<div className="mb-3">
<label className="btn btn-outline-secondary btn-sm" style={{ cursor: "pointer" }}>
<i className="bi bi-paperclip me-1"></i>
Adjuntar archivos (máx. 10 MB c/u)
<input
type="file"
multiple
hidden
onChange={(e) => {
const picked = Array.from(e.target.files ?? []);
setFiles((prev) => [...prev, ...picked].slice(0, 5));
e.target.value = "";
}}
/>
</label>
{files.length > 0 && (
<div className="d-flex flex-wrap gap-2 mt-2">
{files.map((f, i) => (
<span key={`${f.name}-${i}`} className="badge bg-light text-dark border d-flex align-items-center gap-1" style={{ fontSize: "0.75rem" }}>
<i className="bi bi-file-earmark"></i>
{f.name} ({formatFileSize(f.size)})
<button
type="button"
className="btn-close btn-close-sm"
style={{ fontSize: "0.6rem" }}
aria-label="Quitar archivo"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
/>
</span>
))}
</div>
)}
{pendingIds.length > 0 && (
<small className="text-warning d-block mt-1">
<i className="bi bi-exclamation-triangle me-1"></i>
{pendingIds.length} archivo(s) ya subido(s): se vincularán al enviar.
</small>
)}
</div>
<div className="d-flex align-items-center justify-content-between"> <div className="d-flex align-items-center justify-content-between">
<div className="form-check"> <div className="form-check">
<input <input
@@ -573,19 +844,78 @@ export default function TicketDetailPage() {
Nota interna (solo agentes) Nota interna (solo agentes)
</label> </label>
</div> </div>
<button {isTeam ? (
type="submit" <div className="btn-group" role="group" aria-label="Opciones de envío">
className="btn btn-primary" <button
disabled={!comment.trim() || submitting} type="button"
> className="btn btn-primary"
{submitting ? ( disabled={!comment.trim() || submitting}
<span className="spinner-border spinner-border-sm" role="status"></span> title={`Enviar con: ${SEND_ACTIONS[sendAction].label}`}
) : ( onClick={() => void submitComment(sendAction === "send" ? undefined : sendAction)}
<> >
<i className="bi bi-send me-2"></i>Enviar {submitting ? (
</> <span className="spinner-border spinner-border-sm" role="status"></span>
)} ) : (
</button> <>
<i className={`bi ${SEND_ACTIONS[sendAction].icon} me-2`}></i>
{SEND_ACTIONS[sendAction].label}
</>
)}
</button>
<button
type="button"
className="btn btn-primary dropdown-toggle dropdown-toggle-split"
disabled={!comment.trim() || submitting}
aria-label="Elegir modo de envío"
aria-expanded={sendMenuOpen}
onClick={() => setSendMenuOpen((o) => !o)}
>
<span className="visually-hidden">Elegir</span>
</button>
{sendMenuOpen && (
<>
<div
className="position-fixed top-0 start-0 w-100 h-100"
style={{ zIndex: 1000 }}
onClick={() => setSendMenuOpen(false)}
/>
<ul className="dropdown-menu show position-absolute end-0 bottom-100 mb-1" style={{ zIndex: 1001, minWidth: "230px" }}>
{(Object.keys(SEND_ACTIONS) as Array<keyof typeof SEND_ACTIONS>).map((key) => (
<li key={key}>
<button
type="button"
className={`dropdown-item ${sendAction === key ? "active" : ""}`}
disabled={submitting}
onClick={() => chooseSendAction(key)}
>
<i className={`bi ${SEND_ACTIONS[key].icon} me-2`}></i>
{SEND_ACTIONS[key].label}
{sendAction === key && <i className="bi bi-check-lg ms-2"></i>}
<small className="d-block text-muted">
{SEND_ACTIONS[key].hint}{key === "send" && !ticket?.assigneeId ? " (te lo asigna si está sin asignar)" : ""}
</small>
</button>
</li>
))}
</ul>
</>
)}
</div>
) : (
<button
type="submit"
className="btn btn-primary"
disabled={!comment.trim() || submitting}
>
{submitting ? (
<span className="spinner-border spinner-border-sm" role="status"></span>
) : (
<>
<i className="bi bi-send me-2"></i>Enviar
</>
)}
</button>
)}
</div> </div>
</form> </form>
</div> </div>
@@ -630,6 +960,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>
); );
} }
+74 -5
View File
@@ -12,6 +12,19 @@ import Link from "next/link";
type View = "all" | "mine" | "assigned" | "unassigned"; type View = "all" | "mine" | "assigned" | "unassigned";
// Alcance de archivado: por defecto solo activos. "all" = todos,
// "active" = no archivados, "archived" = solo archivados.
type ArchiveScope = "all" | "active" | "archived";
function parseArchiveScope(v: string | null): ArchiveScope | null {
if (v === null) return null;
const k = v.trim().toLowerCase();
if (k === "all" || k === "todos") return "all";
if (k === "active" || k === "activos" || k === "false" || k === "0") return "active";
if (k === "archived" || k === "archivados" || k === "true" || k === "1") return "archived";
return null;
}
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ value: "Open", label: "Abierto" }, { value: "Open", label: "Abierto" },
{ value: "Pending", label: "Pendiente" }, { value: "Pending", label: "Pendiente" },
@@ -62,7 +75,9 @@ function TicketsContent() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [view, setView] = useState<View>("all"); const [view, setView] = useState<View>("all");
const [archiveScope, setArchiveScope] = useState<ArchiveScope>("active");
// Vacío = Todos los estados. Con 1 o más = filtra por esos estados. // Vacío = Todos los estados. Con 1 o más = filtra por esos estados.
const [statusFilters, setStatusFilters] = useState<string[]>([]); const [statusFilters, setStatusFilters] = useState<string[]>([]);
const [statusOpen, setStatusOpen] = useState(false); const [statusOpen, setStatusOpen] = useState(false);
@@ -97,6 +112,7 @@ function TicketsContent() {
unique.length >= STATUS_OPTIONS.length ? [] : unique; unique.length >= STATUS_OPTIONS.length ? [] : unique;
const priority = searchParams.get("priority") ?? ""; const priority = searchParams.get("priority") ?? "";
const viewParam = searchParams.get("view") as View | null; const viewParam = searchParams.get("view") as View | null;
const archiveParam = parseArchiveScope(searchParams.get("archived"));
const q = searchParams.get("search") ?? ""; const q = searchParams.get("search") ?? "";
queueMicrotask(() => { queueMicrotask(() => {
setStatusFilters(normalized); setStatusFilters(normalized);
@@ -104,6 +120,8 @@ function TicketsContent() {
if (viewParam === "mine" || viewParam === "assigned" || viewParam === "unassigned" || viewParam === "all") { if (viewParam === "mine" || viewParam === "assigned" || viewParam === "unassigned" || viewParam === "all") {
setView(viewParam); setView(viewParam);
} }
// Sin ?archived en la URL el default es solo activos.
setArchiveScope(archiveParam ?? "active");
setSearch(q); setSearch(q);
setPage(1); setPage(1);
}); });
@@ -119,7 +137,10 @@ function TicketsContent() {
// fetch en curso que lo apaga en .finally(). Poner setLoading(true) // fetch en curso que lo apaga en .finally(). Poner setLoading(true)
// en un onClick sin cambio real de estado dejaba spinner eterno. // en un onClick sin cambio real de estado dejaba spinner eterno.
// Diferido como el efecto de URL para no disparar cascading renders. // Diferido como el efecto de URL para no disparar cascading renders.
queueMicrotask(() => setLoading(true)); queueMicrotask(() => {
setLoading(true);
setLoadError("");
});
getTickets({ getTickets({
status: statusFilters.length > 0 ? statusFilters : undefined, status: statusFilters.length > 0 ? statusFilters : undefined,
priority: priorityFilter || undefined, priority: priorityFilter || undefined,
@@ -130,6 +151,7 @@ function TicketsContent() {
: view === "unassigned" : view === "unassigned"
? 0 ? 0
: undefined, : undefined,
archived: archiveScope === "all" ? undefined : archiveScope === "archived",
search: search || undefined, search: search || undefined,
page, page,
pageSize, pageSize,
@@ -138,15 +160,20 @@ function TicketsContent() {
setTickets(data.items || []); setTickets(data.items || []);
setTotal(data.total || 0); setTotal(data.total || 0);
}) })
.catch(() => { .catch((err) => {
setTickets([]); setTickets([]);
setTotal(0); setTotal(0);
setLoadError(err instanceof Error ? err.message : "Error al cargar tickets");
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [statusFilters, priorityFilter, search, page, view, numericId, hasNumericId, isTeam]); }, [statusFilters, priorityFilter, search, page, view, archiveScope, numericId, hasNumericId, isTeam]);
const totalPages = Math.ceil(total / pageSize); const totalPages = Math.ceil(total / pageSize);
const hasActiveFilters = statusFilters.length > 0 || priorityFilter !== "" || view !== "all" || search !== ""; const hasActiveFilters = statusFilters.length > 0 || priorityFilter !== "" || view !== "all" || search !== "" || archiveScope !== "active";
function archiveScopeLabel(s: ArchiveScope): string {
return s === "all" ? "Todos" : s === "archived" ? "Archivados" : "Activos";
}
const toggleStatus = (value: string) => { const toggleStatus = (value: string) => {
setStatusFilters((prev) => { setStatusFilters((prev) => {
@@ -177,6 +204,7 @@ function TicketsContent() {
setStatusOpen(false); setStatusOpen(false);
setPriorityFilter(""); setPriorityFilter("");
setView("all"); setView("all");
setArchiveScope("active");
setSearch(""); setSearch("");
setPage(1); setPage(1);
router.replace("/tickets"); router.replace("/tickets");
@@ -206,6 +234,13 @@ function TicketsContent() {
</div> </div>
)} )}
{loadError && (
<div className="alert alert-danger py-2" role="alert">
<i className="bi bi-exclamation-triangle me-2"></i>
{loadError}
</div>
)}
{hasActiveFilters && ( {hasActiveFilters && (
<div className="alert alert-info py-2 d-flex align-items-center justify-content-between" role="alert"> <div className="alert alert-info py-2 d-flex align-items-center justify-content-between" role="alert">
<span> <span>
@@ -214,6 +249,7 @@ function TicketsContent() {
{statusFilters.length > 0 && <> · Estado: <strong>{statusFilters.map(statusLabel).join(", ")}</strong></>} {statusFilters.length > 0 && <> · Estado: <strong>{statusFilters.map(statusLabel).join(", ")}</strong></>}
{priorityFilter && <> · Prioridad: <strong>{priorityFilter}</strong></>} {priorityFilter && <> · Prioridad: <strong>{priorityFilter}</strong></>}
{view !== "all" && <> · Vista: <strong>{view === "unassigned" ? "Sin asignar" : view}</strong></>} {view !== "all" && <> · Vista: <strong>{view === "unassigned" ? "Sin asignar" : view}</strong></>}
{archiveScope !== "active" && <> · Archivo: <strong>{archiveScopeLabel(archiveScope)}</strong></>}
{search && <> · Búsqueda: <strong>{search}</strong></>} {search && <> · Búsqueda: <strong>{search}</strong></>}
{" "}(desde el Dashboard) {" "}(desde el Dashboard)
</span> </span>
@@ -226,6 +262,7 @@ function TicketsContent() {
{/* Filters */} {/* Filters */}
{isTeam ? ( {isTeam ? (
<div className="filter-bar mb-3"> <div className="filter-bar mb-3">
<span className="text-muted" style={{ fontSize: "0.8rem" }}>Vista:</span>
<div className="btn-group" role="group" aria-label="Vista"> <div className="btn-group" role="group" aria-label="Vista">
{( {(
[ [
@@ -254,6 +291,31 @@ function TicketsContent() {
<span className="badge bg-primary">Mis tickets</span> <span className="badge bg-primary">Mis tickets</span>
</div> </div>
)} )}
{/* Archivo: visible para todos (solicitantes también ven sus cerrados) */}
<div className="filter-bar mb-3">
<span className="text-muted" style={{ fontSize: "0.8rem" }}>Archivo:</span>
<div className="btn-group" role="group" aria-label="Archivo">
{(
[
["all", "Todos"],
["active", "Activos"],
["archived", "Archivados"],
] as const
).map(([v, label]) => (
<button
key={v}
type="button"
className={`btn btn-sm ${archiveScope === v ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => {
setArchiveScope(v);
setPage(1);
}}
>
{label}
</button>
))}
</div>
</div>
<div className="filter-bar mb-4"> <div className="filter-bar mb-4">
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<i className="bi bi-funnel text-muted"></i> <i className="bi bi-funnel text-muted"></i>
@@ -380,7 +442,14 @@ function TicketsContent() {
<div className="fw-medium">{ticket.title}</div> <div className="fw-medium">{ticket.title}</div>
</td> </td>
<td>{ticket.requesterName || "N/A"}</td> <td>{ticket.requesterName || "N/A"}</td>
<td><BadgeStatus status={ticket.status} /></td> <td>
<BadgeStatus status={ticket.status} />
{ticket.archived && (
<span className="badge bg-secondary ms-1" title="Ticket archivado">
<i className="bi bi-archive me-1"></i>Archivado
</span>
)}
</td>
<td><BadgePriority priority={ticket.priority} /></td> <td><BadgePriority priority={ticket.priority} /></td>
<td>{ticket.assigneeName || <span className="text-muted">Sin asignar</span>}</td> <td>{ticket.assigneeName || <span className="text-muted">Sin asignar</span>}</td>
<td className="text-muted" style={{ fontSize: "0.85rem" }}> <td className="text-muted" style={{ fontSize: "0.85rem" }}>
+6 -1
View File
@@ -17,7 +17,9 @@ export default function LoginPage() {
setError(""); setError("");
setLoading(true); setLoading(true);
try { try {
await login(idUsuario, password); // El usuario es el RUT sin verificador. Se tolera que escriban
// el "-X" final y se quita al ingresar. La contraseña va tal cual.
await login(idUsuario.trim().replace(/-[0-9kK]$/, ""), password);
router.push("/"); router.push("/");
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Credenciales inválidas"); setError(err instanceof Error ? err.message : "Credenciales inválidas");
@@ -84,6 +86,9 @@ export default function LoginPage() {
required required
autoFocus autoFocus
/> />
<div className="form-text">
Tu RUT sin dígito verificador (ej: 14399848). Si escribes el -X final, se ignora.
</div>
</div> </div>
<div className="mb-4"> <div className="mb-4">
+18 -2
View File
@@ -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) => {
+92 -2
View File
@@ -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}`);
@@ -46,6 +59,7 @@ export async function getTickets(params?: {
assigneeId?: number; assigneeId?: number;
requesterId?: number; requesterId?: number;
groupId?: number; groupId?: number;
archived?: boolean;
search?: string; search?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
@@ -109,6 +123,20 @@ 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",
body: JSON.stringify({ archived }),
});
}
export async function assignTicket(id: number, assigneeId: number, groupId?: number) { export async function assignTicket(id: number, assigneeId: number, groupId?: number) {
return fetchApi<void>(`/api/helpdesk/ticket/${id}/assign`, { return fetchApi<void>(`/api/helpdesk/ticket/${id}/assign`, {
method: "PUT", method: "PUT",
@@ -128,14 +156,74 @@ export async function getComments(ticketId: number) {
export async function addComment( export async function addComment(
ticketId: number, ticketId: number,
content: string, content: string,
isInternal: boolean = false isInternal: boolean = false,
cc?: string,
attachmentIds?: number[]
) { ) {
return fetchApi<{ id: number }>(`/api/helpdesk/comments/${ticketId}`, { return fetchApi<{ id: number }>(`/api/helpdesk/comments/${ticketId}`, {
method: "POST", method: "POST",
body: JSON.stringify({ content, isInternal }), body: JSON.stringify({ content, isInternal, cc, attachmentIds }),
}); });
} }
// ==================== Adjuntos del ticket ====================
export interface TicketAttachment {
id: number;
commentId?: number | null;
filename: string;
contentType: string;
size: number;
uploadedBy: string;
createdAt: string;
}
export async function uploadTicketAttachments(ticketId: number, files: File[]) {
const token =
typeof window !== "undefined" ? localStorage.getItem("helpdesk_token") : null;
const form = new FormData();
files.forEach((f) => form.append("files", f));
const res = await fetch(`${API_BASE}/api/helpdesk/ticket/${ticketId}/attachments`, {
method: "POST",
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}`);
}
return (await res.json()) as { files: { id: number; filename: string; size: number }[] };
}
export async function getTicketAttachments(ticketId: number) {
return fetchApi<TicketAttachment[]>(`/api/helpdesk/ticket/${ticketId}/attachments`);
}
export async function downloadAttachment(attachment: TicketAttachment) {
const token =
typeof window !== "undefined" ? localStorage.getItem("helpdesk_token") : null;
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);
const a = document.createElement("a");
a.href = url;
a.download = attachment.filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ==================== Historial de correos del ticket ==================== // ==================== Historial de correos del ticket ====================
// Integración email <-> ticket: enviados desde soporte (mail.log) + // Integración email <-> ticket: enviados desde soporte (mail.log) +
// respuestas recibidas (helpdesk.email_inbound). Visible para quien // respuestas recibidas (helpdesk.email_inbound). Visible para quien
@@ -226,6 +314,8 @@ export interface Ticket {
firstResponseAt?: string; firstResponseAt?: string;
resolvedAt?: string; resolvedAt?: string;
closedAt?: string; closedAt?: string;
archived: boolean;
archivedAt?: string | null;
} }
export interface TicketListResult { export interface TicketListResult {