feat: filtro de tickets por uno, varios o todos los estados
This commit is contained in:
1 parent
c702a19c1e
commit
9e3c7b8059
2 files changed
+161
-25
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user