feat: filtro de tickets por uno, varios o todos los estados

This commit is contained in:
Nurfog committed 2026-10-13 10:56:48 -03:00
1 parent c702a19c1e
commit 9e3c7b8059
2 files changed
+161 -25

No files matched your search

+151 -22
View File
@@ -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<View>("all");
const [statusFilter, setStatusFilter] = useState("");
// Vacío = Todos los estados. Con 1 o más = filtra por esos estados.
const [statusFilters, setStatusFilters] = useState<string[]>([]);
const [statusOpen, setStatusOpen] = useState(false);
const statusRef = useRef<HTMLDivElement>(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() {
<span>
<i className="bi bi-funnel-fill me-2"></i>
Vista filtrada
{statusFilter && <> · Estado: <strong>{statusFilter}</strong></>}
{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></>}
{search && <> · Búsqueda: <strong>{search}</strong></>}
@@ -167,19 +259,56 @@ function TicketsContent() {
<span className="fw-semibold" style={{ fontSize: "0.85rem" }}>Filtros:</span>
</div>
<select
className="form-select"
style={{ width: "auto" }}
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); setLoading(true); }}
>
<option value="">Todos los estados</option>
<option value="Open">Abierto</option>
<option value="Pending">Pendiente</option>
<option value="InProgress">En Progreso</option>
<option value="Resolved">Resuelto</option>
<option value="Closed">Cerrado</option>
</select>
<div className="position-relative" ref={statusRef}>
<button
type="button"
className="form-select d-flex align-items-center justify-content-between gap-2"
style={{ width: "auto", minWidth: "190px", textAlign: "left" }}
aria-haspopup="listbox"
aria-expanded={statusOpen}
onClick={() => setStatusOpen((o) => !o)}
>
<span className="text-truncate">{statusButtonLabel}</span>
<i className={`bi ${statusOpen ? "bi-chevron-up" : "bi-chevron-down"} text-muted`}></i>
</button>
{statusOpen && (
<div
className="card shadow-sm position-absolute mt-1 p-2"
role="listbox"
aria-label="Filtrar por estado"
style={{ zIndex: 30, minWidth: "220px" }}
>
<label
className="d-flex align-items-center gap-2 px-2 py-1 rounded fw-semibold"
style={{ cursor: "pointer", fontSize: "0.85rem" }}
>
<input
type="checkbox"
className="form-check-input m-0"
checked={statusFilters.length === 0}
onChange={selectAllStatuses}
/>
Todos
</label>
<hr className="my-1" />
{STATUS_OPTIONS.map((opt) => (
<label
key={opt.value}
className="d-flex align-items-center gap-2 px-2 py-1 rounded"
style={{ cursor: "pointer", fontSize: "0.85rem" }}
>
<input
type="checkbox"
className="form-check-input m-0"
checked={statusFilters.includes(opt.value)}
onChange={() => toggleStatus(opt.value)}
/>
{opt.label}
</label>
))}
</div>
)}
</div>
<select
className="form-select"