From 9e3c7b8059e2f054aae3f9b807ccfc047d14d4e1 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 13 Oct 2026 10:56:48 -0300 Subject: [PATCH] feat: filtro de tickets por uno, varios o todos los estados --- frontend/src/app/(app)/tickets/page.tsx | 173 +++++++++++++++++++++--- frontend/src/lib/api.ts | 13 +- 2 files changed, 161 insertions(+), 25 deletions(-) diff --git a/frontend/src/app/(app)/tickets/page.tsx b/frontend/src/app/(app)/tickets/page.tsx index fecadba..a72f811 100644 --- a/frontend/src/app/(app)/tickets/page.tsx +++ b/frontend/src/app/(app)/tickets/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, Suspense } from "react"; +import { useEffect, useRef, useState, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { getTickets, type Ticket } from "@/lib/api"; import { useAuth } from "@/lib/auth"; @@ -12,6 +12,48 @@ import Link from "next/link"; type View = "all" | "mine" | "assigned" | "unassigned"; +const STATUS_OPTIONS = [ + { value: "Open", label: "Abierto" }, + { value: "Pending", label: "Pendiente" }, + { value: "InProgress", label: "En Progreso" }, + { value: "Resolved", label: "Resuelto" }, + { value: "Closed", label: "Cerrado" }, +] as const; + +// Normaliza un valor de estado (URL, dashboard, backend) al canónico del filtro. +function canonStatus(v: string): string | null { + const k = v.trim().toLowerCase().replace(/_/g, "").replace(/\s+/g, ""); + switch (k) { + case "open": + case "abierto": + case "nuevo": + case "1": + return "Open"; + case "pending": + case "pendiente": + case "2": + return "Pending"; + case "inprogress": + case "enprogreso": + case "3": + return "InProgress"; + case "resolved": + case "resuelto": + case "4": + return "Resolved"; + case "closed": + case "cerrado": + case "5": + return "Closed"; + default: + return null; + } +} + +function statusLabel(v: string): string { + return STATUS_OPTIONS.find((o) => o.value === v)?.label ?? v; +} + function TicketsContent() { const router = useRouter(); const searchParams = useSearchParams(); @@ -21,20 +63,43 @@ function TicketsContent() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [view, setView] = useState("all"); - const [statusFilter, setStatusFilter] = useState(""); + // Vacío = Todos los estados. Con 1 o más = filtra por esos estados. + const [statusFilters, setStatusFilters] = useState([]); + const [statusOpen, setStatusOpen] = useState(false); + const statusRef = useRef(null); const [priorityFilter, setPriorityFilter] = useState(""); const [search, setSearch] = useState(""); const pageSize = 10; + // Cierra el desplegable de estados al hacer clic fuera. + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (statusRef.current && !statusRef.current.contains(e.target as Node)) { + setStatusOpen(false); + } + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, []); + // Filtros iniciales desde la URL (los usa el Dashboard al presionar tarjetas). + // Acepta ?status=Open, ?status=Open,Resolved y ?status=Open&status=Resolved. // Diferido (no sincrónico en el effect) para no disparar cascading renders. useEffect(() => { - const status = searchParams.get("status") ?? ""; + const raw = searchParams + .getAll("status") + .flatMap((v) => v.split(",")) + .map((v) => canonStatus(v)) + .filter((v): v is string => v !== null); + const unique = [...new Set(raw)]; + // Si vienen todos marcados equivale a Todos (sin filtro). + const normalized = + unique.length >= STATUS_OPTIONS.length ? [] : unique; const priority = searchParams.get("priority") ?? ""; const viewParam = searchParams.get("view") as View | null; const q = searchParams.get("search") ?? ""; queueMicrotask(() => { - setStatusFilter(status); + setStatusFilters(normalized); setPriorityFilter(priority); if (viewParam === "mine" || viewParam === "assigned" || viewParam === "unassigned" || viewParam === "all") { setView(viewParam); @@ -51,7 +116,7 @@ function TicketsContent() { useEffect(() => { getTickets({ - status: statusFilter || undefined, + status: statusFilters.length > 0 ? statusFilters : undefined, priority: priorityFilter || undefined, requesterId: view === "mine" || !isTeam ? (hasNumericId ? numericId : undefined) : undefined, assigneeId: @@ -73,13 +138,40 @@ function TicketsContent() { setTotal(0); }) .finally(() => setLoading(false)); - }, [statusFilter, priorityFilter, search, page, view, numericId, hasNumericId, isTeam]); + }, [statusFilters, priorityFilter, search, page, view, numericId, hasNumericId, isTeam]); const totalPages = Math.ceil(total / pageSize); - const hasActiveFilters = statusFilter !== "" || priorityFilter !== "" || view !== "all" || search !== ""; + const hasActiveFilters = statusFilters.length > 0 || priorityFilter !== "" || view !== "all" || search !== ""; + + const toggleStatus = (value: string) => { + setStatusFilters((prev) => { + const next = prev.includes(value) + ? prev.filter((v) => v !== value) + : [...prev, value]; + // Todos marcados equivale a Todos (sin filtro). + if (next.length >= STATUS_OPTIONS.length) return []; + return next; + }); + setPage(1); + setLoading(true); + }; + + const selectAllStatuses = () => { + setStatusFilters([]); + setPage(1); + setLoading(true); + }; + + const statusButtonLabel = + statusFilters.length === 0 + ? "Todos los estados" + : statusFilters.length === 1 + ? statusLabel(statusFilters[0]) + : `${statusFilters.length} estados`; const clearFilters = () => { - setStatusFilter(""); + setStatusFilters([]); + setStatusOpen(false); setPriorityFilter(""); setView("all"); setSearch(""); @@ -117,7 +209,7 @@ function TicketsContent() { Vista filtrada - {statusFilter && <> · Estado: {statusFilter}} + {statusFilters.length > 0 && <> · Estado: {statusFilters.map(statusLabel).join(", ")}} {priorityFilter && <> · Prioridad: {priorityFilter}} {view !== "all" && <> · Vista: {view === "unassigned" ? "Sin asignar" : view}} {search && <> · Búsqueda: {search}} @@ -167,19 +259,56 @@ function TicketsContent() { Filtros: - +
+ + {statusOpen && ( +
+ +
+ {STATUS_OPTIONS.map((opt) => ( + + ))} +
+ )} +