feat: finalizar Tablero CDC con multipais, Fulgencio y tarifario

This commit is contained in:
Isaac_Aracena
2026-06-16 14:23:30 -04:00
parent 8c3d18814d
commit 1fe2ffba80
17 changed files with 3903 additions and 638 deletions
+227 -46
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import {
ChevronDown,
ChevronLeft,
@@ -9,6 +9,8 @@ import {
Plus,
RefreshCw,
Search,
SlidersHorizontal,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -22,11 +24,28 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ProjectCard } from "@/components/board/ProjectCard";
import { ProjectDialog } from "@/components/board/ProjectDialog";
import { SearchableSelect } from "@/components/board/SearchableSelect";
import { useProjects, type Project } from "@/lib/store";
import { useAppLists } from "@/lib/appLists";
import { useAuth } from "@/context/AuthContext";
import { dedupeOptions } from "@/lib/optionUtils";
/** Extract up-to-2-letter initials from a display name or email */
const PROJECTS_PER_PAGE = 12;
const PROJECTS_PER_PAGE = 24;
type AdvancedFilters = {
country: string;
brand: string;
client: string;
cm: string;
};
const EMPTY_ADVANCED_FILTERS: AdvancedFilters = {
country: "",
brand: "",
client: "",
cm: "",
};
function initials(name: string | null | undefined, email: string | null | undefined): string {
if (name) {
@@ -39,15 +58,11 @@ function initials(name: string | null | undefined, email: string | null | undefi
}
export default function BoardPage() {
const { projects, loading, error, refresh, remove } = useProjects();
const { user, isGerardo, logout } = useAuth();
const projectsTopRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Project | null>(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<"todos" | "abiertos" | "cerrados">("todos");
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(EMPTY_ADVANCED_FILTERS);
const [pageByFilter, setPageByFilter] = useState({
todos: 1,
abiertos: 1,
@@ -55,32 +70,59 @@ export default function BoardPage() {
});
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const orderedProjects = [...projects].sort((a, b) => b.createdAt - a.createdAt);
return orderedProjects.filter((p) => {
if (filter === "abiertos" && p.status) return false;
if (filter === "cerrados" && !p.status) return false;
if (!q) return true;
return [p.nombre, p.cliente, p.marca, p.bu, p.solicitante]
.join(" ")
.toLowerCase()
.includes(q);
});
}, [projects, query, filter]);
const currentPage = pageByFilter[filter];
const totalPages = Math.max(1, Math.ceil(filtered.length / PROJECTS_PER_PAGE));
const { lists } = useAppLists();
const { projects, totalProjects, filterOptions, loading, error, refresh, remove } = useProjects({
tab: filter,
search: query,
page: currentPage,
pageSize: PROJECTS_PER_PAGE,
country: advancedFilters.country,
brand: advancedFilters.brand,
client: advancedFilters.client,
cm: advancedFilters.cm,
});
const { user, isGerardo, logout } = useAuth();
const cmListOptions = Object.values(lists.buCm);
const countryFilterOptions = dedupeOptions([
...lists.bus,
...filterOptions.countries,
advancedFilters.country,
]);
const brandFilterOptions = dedupeOptions([
...lists.marcas,
...filterOptions.brands,
advancedFilters.brand,
]);
const clientFilterOptions = dedupeOptions([
...lists.clientes,
...filterOptions.clients,
advancedFilters.client,
]);
const cmFilterOptions = dedupeOptions([
...cmListOptions,
...filterOptions.cms,
advancedFilters.cm,
]);
const activeAdvancedFilterCount = Object.values(advancedFilters).filter(Boolean).length;
const totalPages = Math.max(1, Math.ceil(totalProjects / PROJECTS_PER_PAGE));
const safePage = Math.min(currentPage, totalPages);
const pageStart = (safePage - 1) * PROJECTS_PER_PAGE;
const paginatedProjects = filtered.slice(pageStart, pageStart + PROJECTS_PER_PAGE);
const visibleStart = filtered.length === 0 ? 0 : pageStart + 1;
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, filtered.length);
const visibleStart = totalProjects === 0 ? 0 : pageStart + 1;
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, totalProjects);
useEffect(() => {
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
}, [query, filter]);
}, [
query,
filter,
advancedFilters.country,
advancedFilters.brand,
advancedFilters.client,
advancedFilters.cm,
]);
useEffect(() => {
if (currentPage > totalPages) {
@@ -90,11 +132,19 @@ export default function BoardPage() {
const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), totalPages);
setPageByFilter((prev) => ({ ...prev, [filter]: nextPage }));
window.requestAnimationFrame(() => {
projectsTopRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
if (nextPage === currentPage) return;
setPageByFilter((prev) => ({ ...prev, [filter]: nextPage }));
window.scrollTo({ top: 0, behavior: "smooth" });
};
const setAdvancedFilter = (key: keyof AdvancedFilters, value: string) => {
setAdvancedFilters((current) => ({ ...current, [key]: value }));
};
const clearAdvancedFilters = () => {
setAdvancedFilters(EMPTY_ADVANCED_FILTERS);
};
const openNew = () => {
@@ -150,7 +200,7 @@ export default function BoardPage() {
</div>
<div className="leading-tight">
<h1 className="text-[15px] font-semibold tracking-tight">Tablero CDC</h1>
<p className="text-[11px] text-muted-foreground">Aprobaciones de Proyectos</p>
<p className="text-[11px] text-muted-foreground">Flujo de Proyectos</p>
</div>
</div>
@@ -220,10 +270,10 @@ export default function BoardPage() {
</header>
{/* Filters */}
<div ref={projectsTopRef} className="max-w-[1400px] mx-auto px-6 pt-6 pb-3 space-y-3">
<div className="max-w-[1400px] mx-auto px-6 pt-6 pb-3 space-y-3">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h2 className="text-2xl font-semibold tracking-tight">Proyectos</h2>
<h2 className="pl-3 text-2xl font-semibold tracking-tight">Proyectos</h2>
</div>
<div className="flex items-center gap-2">
@@ -260,6 +310,17 @@ export default function BoardPage() {
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
/>
</div>
<AdvancedProjectFilters
filters={advancedFilters}
activeCount={activeAdvancedFilterCount}
countryOptions={countryFilterOptions}
brandOptions={brandFilterOptions}
clientOptions={clientFilterOptions}
cmOptions={cmFilterOptions}
onFilterChange={setAdvancedFilter}
onClear={clearAdvancedFilters}
/>
</div>
{/* Grid */}
@@ -280,27 +341,34 @@ export default function BoardPage() {
{loading && projects.length === 0 ? (
<LoadingState />
) : filtered.length === 0 ? (
<EmptyState onNew={openNew} hasProjects={projects.length > 0} filter={filter} query={query} />
) : projects.length === 0 ? (
<EmptyState
onNew={openNew}
hasProjects={
totalProjects > 0 ||
filter !== "todos" ||
query.trim().length > 0 ||
activeAdvancedFilterCount > 0
}
filter={filter}
query={query}
activeAdvancedFilterCount={activeAdvancedFilterCount}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{paginatedProjects.map((p) => (
<ProjectCard
key={p.id}
project={p}
onClick={() => openEdit(p)}
/>
{projects.map((p) => (
<ProjectCard key={p.id} project={p} onClick={() => openEdit(p)} />
))}
</div>
)}
{filtered.length > PROJECTS_PER_PAGE && (
{totalProjects > PROJECTS_PER_PAGE && (
<PaginationControls
currentPage={safePage}
totalPages={totalPages}
visibleStart={visibleStart}
visibleEnd={visibleEnd}
totalItems={filtered.length}
totalItems={totalProjects}
onPageChange={goToPage}
/>
)}
@@ -317,6 +385,110 @@ export default function BoardPage() {
);
}
function AdvancedProjectFilters({
filters,
activeCount,
countryOptions,
brandOptions,
clientOptions,
cmOptions,
onFilterChange,
onClear,
}: {
filters: AdvancedFilters;
activeCount: number;
countryOptions: string[];
brandOptions: string[];
clientOptions: string[];
cmOptions: string[];
onFilterChange: (key: keyof AdvancedFilters, value: string) => void;
onClear: () => void;
}) {
return (
<div className="rounded-2xl border border-border bg-card/70 px-3.5 py-3 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex items-center justify-between gap-3 lg:w-auto lg:min-w-[120px] lg:justify-start">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<SlidersHorizontal className="h-4 w-4" />
</span>
<span>Filtros</span>
{activeCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
{activeCount}
</span>
)}
</div>
{activeCount > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClear}
className="h-8 rounded-full px-2.5 text-xs text-muted-foreground hover:text-foreground lg:hidden"
>
<X className="mr-1 h-3.5 w-3.5" />
Limpiar
</Button>
)}
</div>
<div className="grid flex-1 grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
<SearchableSelect
value={filters.country}
options={countryOptions}
onValueChange={(value) => onFilterChange("country", value)}
placeholder="País: Todos"
searchPlaceholder="Buscar país…"
emptyText="No se encontró ese país."
clearLabel="Todos"
/>
<SearchableSelect
value={filters.brand}
options={brandOptions}
onValueChange={(value) => onFilterChange("brand", value)}
placeholder="Marca: Todas"
searchPlaceholder="Buscar marca…"
emptyText="No se encontró esa marca."
clearLabel="Todas"
/>
<SearchableSelect
value={filters.client}
options={clientOptions}
onValueChange={(value) => onFilterChange("client", value)}
placeholder="Cliente: Todos"
searchPlaceholder="Buscar cliente…"
emptyText="No se encontró ese cliente."
clearLabel="Todos"
/>
<SearchableSelect
value={filters.cm}
options={cmOptions}
onValueChange={(value) => onFilterChange("cm", value)}
placeholder="CM: Todos"
searchPlaceholder="Buscar CM…"
emptyText="No se encontró ese CM."
clearLabel="Todos"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClear}
disabled={activeCount === 0}
className="hidden h-9 rounded-full px-3 text-xs text-muted-foreground hover:text-foreground lg:inline-flex"
>
<X className="mr-1 h-3.5 w-3.5" />
Limpiar
</Button>
</div>
</div>
);
}
function PaginationControls({
currentPage,
totalPages,
@@ -453,26 +625,35 @@ function EmptyState({
hasProjects,
filter,
query,
activeAdvancedFilterCount,
}: {
onNew: () => void;
hasProjects: boolean;
filter: "todos" | "abiertos" | "cerrados";
query: string;
activeAdvancedFilterCount: number;
}) {
const hasSearch = query.trim().length > 0;
const hasAdvancedFilters = activeAdvancedFilterCount > 0;
let title = "Tu tablero está vacío";
let message = "Crea tu primer proyecto para que el equipo pueda darle seguimiento.";
if (hasProjects && hasSearch) {
if (hasProjects && hasSearch && hasAdvancedFilters) {
title = "Nada coincide con la búsqueda y filtros";
message = "Intenta cambiar la búsqueda, limpiar filtros o seleccionar otras opciones.";
} else if (hasProjects && hasSearch) {
title = "Nada coincide con la búsqueda";
message = "Intenta cambiar la búsqueda o borrar el texto escrito.";
} else if (hasProjects && hasAdvancedFilters) {
title = "Nada coincide con los filtros";
message = "Intenta limpiar filtros o seleccionar otras opciones.";
} else if (hasProjects && filter === "abiertos") {
title = "No hay proyectos activos";
message = "Cuando se cree un proyecto nuevo, aparecerá en esta pestaña.";
} else if (hasProjects && filter === "cerrados") {
title = "No hay proyectos cerrados";
message = "Cuando Gerardo asigne un estatus a un proyecto, aparecerá aquí.";
message = "Cuando el Director Creativo asigne un estatus a un proyecto, aparecerá aquí.";
} else if (hasProjects) {
title = "Nada coincide con el filtro";
message = "Intenta quitar el filtro o cambiar la búsqueda.";