feat: finalizar Tablero CDC con multipais, Fulgencio y tarifario
This commit is contained in:
+227
-46
@@ -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.";
|
||||
|
||||
Reference in New Issue
Block a user