Compare commits
14
Commits
148d8b2550
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01ceb8c619 | ||
|
|
a2bcab9282 | ||
|
|
318ac083e9 | ||
|
|
b2112eb390 | ||
|
|
7a3d146892 | ||
|
|
b1710da477 | ||
|
|
ce3fa29b69 | ||
|
|
f88d79e2e8 | ||
|
|
dd1e1a231a | ||
|
|
eace5fbcd7 | ||
|
|
4a84271f44 | ||
|
|
24dcb6d770 | ||
|
|
f536f11dfd | ||
|
|
580ecff258 |
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,49 +591,59 @@ 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">
|
||||||
|
<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>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
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"}</>
|
emailItems.map((m) => {
|
||||||
)}
|
|
||||||
{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 isSent = m.kind === "sent";
|
||||||
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
|
const alignClass = m.side === "team" ? "comment-agent" : "comment-customer";
|
||||||
return (
|
return (
|
||||||
@@ -529,13 +678,81 @@ export default function TicketDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
})
|
||||||
})()}
|
)}
|
||||||
<small className="text-muted d-block mt-2">
|
<small className="text-muted d-block mt-2">
|
||||||
Las respuestas con etiqueta HD-{String(id).padStart(6, "0")} en el asunto
|
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
|
llegan a este hilo (ingesta cada 5 min). Los correos muestran solo el
|
||||||
último mensaje; usa “Ver mensaje completo” para el historial.
|
último mensaje; usa “Ver mensaje completo” para el historial.
|
||||||
</small>
|
</small>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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">
|
||||||
|
<><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";
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</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,6 +844,64 @@ export default function TicketDetailPage() {
|
|||||||
Nota interna (solo agentes)
|
Nota interna (solo agentes)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{isTeam ? (
|
||||||
|
<div className="btn-group" role="group" aria-label="Opciones de envío">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!comment.trim() || submitting}
|
||||||
|
title={`Enviar con: ${SEND_ACTIONS[sendAction].label}`}
|
||||||
|
onClick={() => void submitComment(sendAction === "send" ? undefined : sendAction)}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<span className="spinner-border spinner-border-sm" role="status"></span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
@@ -586,6 +915,7 @@ export default function TicketDetailPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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" }}>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user