feat: subir versión final Tablero CDC
This commit is contained in:
+283
-53
@@ -1,5 +1,15 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Plus, Search, LayoutGrid, ChevronDown, LogOut } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
LayoutGrid,
|
||||
LogOut,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -16,6 +26,8 @@ import { useProjects, type Project } from "@/lib/store";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
/** Extract up-to-2-letter initials from a display name or email */
|
||||
const PROJECTS_PER_PAGE = 12;
|
||||
|
||||
function initials(name: string | null | undefined, email: string | null | undefined): string {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
@@ -27,17 +39,26 @@ function initials(name: string | null | undefined, email: string | null | undefi
|
||||
}
|
||||
|
||||
export default function BoardPage() {
|
||||
const { projects } = useProjects();
|
||||
const { projects, loading, error, refresh } = 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 [pageByFilter, setPageByFilter] = useState({
|
||||
todos: 1,
|
||||
abiertos: 1,
|
||||
cerrados: 1,
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return projects.filter((p) => {
|
||||
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;
|
||||
@@ -48,6 +69,33 @@ export default function BoardPage() {
|
||||
});
|
||||
}, [projects, query, filter]);
|
||||
|
||||
const currentPage = pageByFilter[filter];
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / 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);
|
||||
|
||||
useEffect(() => {
|
||||
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
|
||||
}, [query, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) {
|
||||
setPageByFilter((prev) => ({ ...prev, [filter]: totalPages }));
|
||||
}
|
||||
}, [currentPage, filter, totalPages]);
|
||||
|
||||
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" });
|
||||
});
|
||||
};
|
||||
|
||||
const openNew = () => {
|
||||
setEditing(null);
|
||||
setOpen(true);
|
||||
@@ -143,58 +191,86 @@ export default function BoardPage() {
|
||||
</header>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="max-w-[1400px] mx-auto px-6 pt-6 pb-3 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Proyectos</h2>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{filtered.length} de {projects.length}{" "}
|
||||
{projects.length === 1 ? "proyecto" : "proyectos"}
|
||||
</p>
|
||||
<div ref={projectsTopRef} 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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex bg-muted/60 rounded-full p-1 text-xs">
|
||||
{(
|
||||
[
|
||||
["todos", "Todos"],
|
||||
["abiertos", "Activos"],
|
||||
["cerrados", "Cerrados"],
|
||||
] as const
|
||||
).map(([k, l]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setFilter(k)}
|
||||
className={`px-3.5 py-1.5 rounded-full transition-colors font-medium ${
|
||||
filter === k
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex bg-muted/60 rounded-full p-1 text-xs">
|
||||
{(
|
||||
[
|
||||
["todos", "Todos"],
|
||||
["abiertos", "Activos"],
|
||||
["cerrados", "Cerrados"],
|
||||
] as const
|
||||
).map(([k, l]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setFilter(k)}
|
||||
className={`px-3.5 py-1.5 rounded-full transition-colors font-medium ${
|
||||
filter === k
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="relative md:hidden">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar proyecto, cliente, marca…"
|
||||
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<main className="max-w-[1400px] mx-auto px-6 pb-16">
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState onNew={openNew} hasProjects={projects.length > 0} />
|
||||
{error && (
|
||||
<div className="mb-4 flex flex-col gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
<span className="font-medium">No se pudieron cargar los proyectos.</span> {error}
|
||||
</span>
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => void refresh()} className="gap-1.5">
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && projects.length === 0 ? (
|
||||
<LoadingState />
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState onNew={openNew} hasProjects={projects.length > 0} filter={filter} query={query} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filtered.map((p) => (
|
||||
{paginatedProjects.map((p) => (
|
||||
<ProjectCard key={p.id} project={p} onClick={() => openEdit(p)} />
|
||||
))}
|
||||
{/* Add tile */}
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="min-h-[230px] rounded-2xl border-2 border-dashed border-border hover:border-primary/50 hover:bg-primary/5 transition-colors flex flex-col items-center justify-center gap-2 text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<Plus className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">Nuevo proyecto</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length > PROJECTS_PER_PAGE && (
|
||||
<PaginationControls
|
||||
currentPage={safePage}
|
||||
totalPages={totalPages}
|
||||
visibleStart={visibleStart}
|
||||
visibleEnd={visibleEnd}
|
||||
totalItems={filtered.length}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<ProjectDialog open={open} onOpenChange={setOpen} project={editing} />
|
||||
@@ -202,20 +278,174 @@ export default function BoardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onNew, hasProjects }: { onNew: () => void; hasProjects: boolean }) {
|
||||
function PaginationControls({
|
||||
currentPage,
|
||||
totalPages,
|
||||
visibleStart,
|
||||
visibleEnd,
|
||||
totalItems,
|
||||
onPageChange,
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
visibleStart: number;
|
||||
visibleEnd: number;
|
||||
totalItems: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
const pages = getVisiblePages(currentPage, totalPages);
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center justify-between gap-3 rounded-2xl border border-border bg-card/70 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{visibleStart}-{visibleEnd}
|
||||
</span>{" "}
|
||||
de <span className="font-medium text-foreground">{totalItems}</span> proyectos
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 rounded-full px-2.5"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Anterior</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{pages.map((page, index) =>
|
||||
page === "ellipsis" ? (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className="flex h-8 w-8 items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
key={page}
|
||||
type="button"
|
||||
variant={page === currentPage ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-full p-0 text-xs"
|
||||
onClick={() => onPageChange(page)}
|
||||
aria-current={page === currentPage ? "page" : undefined}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 rounded-full px-2.5"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<span className="hidden sm:inline">Siguiente</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getVisiblePages(currentPage: number, totalPages: number): Array<number | "ellipsis"> {
|
||||
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, index) => index + 1);
|
||||
|
||||
const pages = new Set<number>([1, totalPages, currentPage]);
|
||||
|
||||
if (currentPage > 1) pages.add(currentPage - 1);
|
||||
if (currentPage < totalPages) pages.add(currentPage + 1);
|
||||
if (currentPage <= 3) {
|
||||
pages.add(2);
|
||||
pages.add(3);
|
||||
pages.add(4);
|
||||
}
|
||||
if (currentPage >= totalPages - 2) {
|
||||
pages.add(totalPages - 3);
|
||||
pages.add(totalPages - 2);
|
||||
pages.add(totalPages - 1);
|
||||
}
|
||||
|
||||
const sortedPages = Array.from(pages)
|
||||
.filter((page) => page >= 1 && page <= totalPages)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
return sortedPages.reduce<Array<number | "ellipsis">>((acc, page) => {
|
||||
const previous = acc[acc.length - 1];
|
||||
if (typeof previous === "number" && page - previous > 1) acc.push("ellipsis");
|
||||
acc.push(page);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="min-h-[230px] overflow-hidden rounded-2xl border border-border bg-card"
|
||||
>
|
||||
<div className="h-14 bg-muted animate-pulse" />
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="h-3 w-3/4 rounded-full bg-muted animate-pulse" />
|
||||
<div className="h-3 w-2/3 rounded-full bg-muted animate-pulse" />
|
||||
<div className="h-3 w-1/2 rounded-full bg-muted animate-pulse" />
|
||||
<div className="mt-6 h-8 rounded-full bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
onNew,
|
||||
hasProjects,
|
||||
filter,
|
||||
query,
|
||||
}: {
|
||||
onNew: () => void;
|
||||
hasProjects: boolean;
|
||||
filter: "todos" | "abiertos" | "cerrados";
|
||||
query: string;
|
||||
}) {
|
||||
const hasSearch = query.trim().length > 0;
|
||||
|
||||
let title = "Tu tablero está vacío";
|
||||
let message = "Crea tu primer proyecto para que el equipo pueda darle seguimiento.";
|
||||
|
||||
if (hasProjects && hasSearch) {
|
||||
title = "Nada coincide con la búsqueda";
|
||||
message = "Intenta cambiar la búsqueda o borrar el texto escrito.";
|
||||
} 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í.";
|
||||
} else if (hasProjects) {
|
||||
title = "Nada coincide con el filtro";
|
||||
message = "Intenta quitar el filtro o cambiar la búsqueda.";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center py-24 rounded-2xl border border-dashed border-border bg-card/40">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary/10 mx-auto flex items-center justify-center mb-4">
|
||||
<LayoutGrid className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{hasProjects ? "Nada coincide con el filtro" : "Tu tablero está vacío"}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">
|
||||
{hasProjects
|
||||
? "Probá quitar el filtro o cambiar la búsqueda."
|
||||
: "Creá tu primer proyecto. Cuando entre un brief nuevo, abrís una caja con su nombre y ya todo el equipo lo ve."}
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">{message}</p>
|
||||
{!hasProjects && (
|
||||
<Button onClick={onNew} className="mt-5 gap-1.5 rounded-full">
|
||||
<Plus className="w-4 h-4" /> Crear proyecto
|
||||
|
||||
Reference in New Issue
Block a user