feat(tickets): filtro Todos/Activos/Archivados + botón archivar

This commit is contained in:
Nurfog committed 2026-10-22 11:19:25 -03:00
1 parent f536f11dfd
commit 24dcb6d770
4 files changed
+103 -4

No files matched your search

@@ -9,6 +9,7 @@ import {
getTicketEmails,
addComment,
updateTicketStatus,
setTicketArchived,
assignTicket,
getGroups,
getTeam,
@@ -286,6 +287,24 @@ export default function TicketDetailPage() {
}
};
const handleArchiveToggle = async () => {
if (!ticket) return;
const toArchived = !ticket.archived;
if (toArchived && !window.confirm("¿Archivar este ticket? Saldrá de la vista de activos, conservando su estado.")) {
return;
}
setError("");
setActing(true);
try {
await setTicketArchived(id, toArchived);
await refreshTicket();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "No se pudo cambiar el archivado");
} finally {
setActing(false);
}
};
const handleAssignAgent = async (idUsuario: string | null) => {
if (!ticket) return;
setAssignOpen(false);
@@ -338,6 +357,11 @@ export default function TicketDetailPage() {
<div className="d-flex gap-2 mt-2">
<BadgeStatus status={ticket.status} />
<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>
{isTeam && (
@@ -437,6 +461,17 @@ export default function TicketDetailPage() {
</>
)}
</div>
{/* Archivar / Desarchivar (conserva el estado del ticket) */}
<button
className={`btn btn-sm ${ticket.archived ? "btn-outline-secondary" : "btn-outline-dark"}`}
disabled={acting}
title={ticket.archived ? "Volver a la vista de activos" : "Sacar de la vista de activos"}
onClick={handleArchiveToggle}
>
<i className={`bi ${ticket.archived ? "bi-box-arrow-up" : "bi-archive"} me-1`}></i>
{ticket.archived ? "Desarchivar" : "Archivar"}
</button>
</div>
)}
</div>
+57 -3
View File
@@ -12,6 +12,19 @@ import Link from "next/link";
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 = [
{ value: "Open", label: "Abierto" },
{ value: "Pending", label: "Pendiente" },
@@ -64,6 +77,7 @@ function TicketsContent() {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
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.
const [statusFilters, setStatusFilters] = useState<string[]>([]);
const [statusOpen, setStatusOpen] = useState(false);
@@ -98,6 +112,7 @@ function TicketsContent() {
unique.length >= STATUS_OPTIONS.length ? [] : unique;
const priority = searchParams.get("priority") ?? "";
const viewParam = searchParams.get("view") as View | null;
const archiveParam = parseArchiveScope(searchParams.get("archived"));
const q = searchParams.get("search") ?? "";
queueMicrotask(() => {
setStatusFilters(normalized);
@@ -105,6 +120,8 @@ function TicketsContent() {
if (viewParam === "mine" || viewParam === "assigned" || viewParam === "unassigned" || viewParam === "all") {
setView(viewParam);
}
// Sin ?archived en la URL el default es solo activos.
setArchiveScope(archiveParam ?? "active");
setSearch(q);
setPage(1);
});
@@ -134,6 +151,7 @@ function TicketsContent() {
: view === "unassigned"
? 0
: undefined,
archived: archiveScope === "all" ? undefined : archiveScope === "archived",
search: search || undefined,
page,
pageSize,
@@ -148,10 +166,14 @@ function TicketsContent() {
setLoadError(err instanceof Error ? err.message : "Error al cargar tickets");
})
.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 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) => {
setStatusFilters((prev) => {
@@ -182,6 +204,7 @@ function TicketsContent() {
setStatusOpen(false);
setPriorityFilter("");
setView("all");
setArchiveScope("active");
setSearch("");
setPage(1);
router.replace("/tickets");
@@ -226,6 +249,7 @@ function TicketsContent() {
{statusFilters.length > 0 && <> · Estado: <strong>{statusFilters.map(statusLabel).join(", ")}</strong></>}
{priorityFilter && <> · Prioridad: <strong>{priorityFilter}</strong></>}
{view !== "all" && <> · Vista: <strong>{view === "unassigned" ? "Sin asignar" : view}</strong></>}
{archiveScope !== "active" && <> · Archivo: <strong>{archiveScopeLabel(archiveScope)}</strong></>}
{search && <> · Búsqueda: <strong>{search}</strong></>}
{" "}(desde el Dashboard)
</span>
@@ -238,6 +262,7 @@ function TicketsContent() {
{/* Filters */}
{isTeam ? (
<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">
{(
[
@@ -260,6 +285,28 @@ function TicketsContent() {
</button>
))}
</div>
<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-3">
@@ -392,7 +439,14 @@ function TicketsContent() {
<div className="fw-medium">{ticket.title}</div>
</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>{ticket.assigneeName || <span className="text-muted">Sin asignar</span>}</td>
<td className="text-muted" style={{ fontSize: "0.85rem" }}>