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"; "use client";
import { useEffect, useState, Suspense } from "react"; import { useEffect, useRef, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { getTickets, type Ticket } from "@/lib/api"; import { getTickets, type Ticket } from "@/lib/api";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
@@ -12,6 +12,48 @@ import Link from "next/link";
type View = "all" | "mine" | "assigned" | "unassigned"; 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() { function TicketsContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -21,20 +63,43 @@ function TicketsContent() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [view, setView] = useState<View>("all"); 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 [priorityFilter, setPriorityFilter] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const pageSize = 10; 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). // 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. // Diferido (no sincrónico en el effect) para no disparar cascading renders.
useEffect(() => { 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 priority = searchParams.get("priority") ?? "";
const viewParam = searchParams.get("view") as View | null; const viewParam = searchParams.get("view") as View | null;
const q = searchParams.get("search") ?? ""; const q = searchParams.get("search") ?? "";
queueMicrotask(() => { queueMicrotask(() => {
setStatusFilter(status); setStatusFilters(normalized);
setPriorityFilter(priority); setPriorityFilter(priority);
if (viewParam === "mine" || viewParam === "assigned" || viewParam === "unassigned" || viewParam === "all") { if (viewParam === "mine" || viewParam === "assigned" || viewParam === "unassigned" || viewParam === "all") {
setView(viewParam); setView(viewParam);
@@ -51,7 +116,7 @@ function TicketsContent() {
useEffect(() => { useEffect(() => {
getTickets({ getTickets({
status: statusFilter || undefined, status: statusFilters.length > 0 ? statusFilters : undefined,
priority: priorityFilter || undefined, priority: priorityFilter || undefined,
requesterId: view === "mine" || !isTeam ? (hasNumericId ? numericId : undefined) : undefined, requesterId: view === "mine" || !isTeam ? (hasNumericId ? numericId : undefined) : undefined,
assigneeId: assigneeId:
@@ -73,13 +138,40 @@ function TicketsContent() {
setTotal(0); setTotal(0);
}) })
.finally(() => setLoading(false)); .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 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 = () => { const clearFilters = () => {
setStatusFilter(""); setStatusFilters([]);
setStatusOpen(false);
setPriorityFilter(""); setPriorityFilter("");
setView("all"); setView("all");
setSearch(""); setSearch("");
@@ -117,7 +209,7 @@ function TicketsContent() {
<span> <span>
<i className="bi bi-funnel-fill me-2"></i> <i className="bi bi-funnel-fill me-2"></i>
Vista filtrada Vista filtrada
{statusFilter && <> · Estado: <strong>{statusFilter}</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></>}
{search && <> · Búsqueda: <strong>{search}</strong></>} {search && <> · Búsqueda: <strong>{search}</strong></>}
@@ -167,19 +259,56 @@ function TicketsContent() {
<span className="fw-semibold" style={{ fontSize: "0.85rem" }}>Filtros:</span> <span className="fw-semibold" style={{ fontSize: "0.85rem" }}>Filtros:</span>
</div> </div>
<select <div className="position-relative" ref={statusRef}>
className="form-select" <button
style={{ width: "auto" }} type="button"
value={statusFilter} className="form-select d-flex align-items-center justify-content-between gap-2"
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); setLoading(true); }} style={{ width: "auto", minWidth: "190px", textAlign: "left" }}
> aria-haspopup="listbox"
<option value="">Todos los estados</option> aria-expanded={statusOpen}
<option value="Open">Abierto</option> onClick={() => setStatusOpen((o) => !o)}
<option value="Pending">Pendiente</option> >
<option value="InProgress">En Progreso</option> <span className="text-truncate">{statusButtonLabel}</span>
<option value="Resolved">Resuelto</option> <i className={`bi ${statusOpen ? "bi-chevron-up" : "bi-chevron-down"} text-muted`}></i>
<option value="Closed">Cerrado</option> </button>
</select> {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 <select
className="form-select" className="form-select"
+10 -3
View File
@@ -41,7 +41,7 @@ export async function getMe() {
// ==================== Tickets ==================== // ==================== Tickets ====================
export async function getTickets(params?: { export async function getTickets(params?: {
status?: string; status?: string | string[];
priority?: string; priority?: string;
assigneeId?: number; assigneeId?: number;
requesterId?: number; requesterId?: number;
@@ -53,8 +53,15 @@ export async function getTickets(params?: {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (params) { if (params) {
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "" && value !== null) if (value === undefined || value === "" || value === null) return;
query.set(key, String(value)); // El filtro de estados acepta uno o varios: se envía como lista
// separada por comas (ej: status=Open,Resolved). Vacío = Todos.
if (Array.isArray(value)) {
if (value.length === 0) return;
query.set(key, value.join(","));
return;
}
query.set(key, String(value));
}); });
} }
return fetchApi<TicketListResult>(`/api/helpdesk/tickets?${query.toString()}`); return fetchApi<TicketListResult>(`/api/helpdesk/tickets?${query.toString()}`);