feat: filtro de tickets por uno, varios o todos los estados
This commit is contained in:
1 parent
c702a19c1e
commit
9e3c7b8059
2 files changed
+159
-23
No files matched your search
@@ -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"
|
||||||
|
aria-expanded={statusOpen}
|
||||||
|
onClick={() => setStatusOpen((o) => !o)}
|
||||||
>
|
>
|
||||||
<option value="">Todos los estados</option>
|
<span className="text-truncate">{statusButtonLabel}</span>
|
||||||
<option value="Open">Abierto</option>
|
<i className={`bi ${statusOpen ? "bi-chevron-up" : "bi-chevron-down"} text-muted`}></i>
|
||||||
<option value="Pending">Pendiente</option>
|
</button>
|
||||||
<option value="InProgress">En Progreso</option>
|
{statusOpen && (
|
||||||
<option value="Resolved">Resuelto</option>
|
<div
|
||||||
<option value="Closed">Cerrado</option>
|
className="card shadow-sm position-absolute mt-1 p-2"
|
||||||
</select>
|
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"
|
||||||
|
|||||||
@@ -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,7 +53,14 @@ 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;
|
||||||
|
// 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));
|
query.set(key, String(value));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user