feat: actualizar dist final con CDC Brief y reaprobacion de rechazados

This commit is contained in:
2026-08-13 10:22:18 -04:00
parent 2e02753ea9
commit 7e41b2f26a
112 changed files with 482 additions and 25603 deletions
-813
View File
@@ -1,813 +0,0 @@
import React, { useEffect, useState } from "react";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
AlertCircle,
DollarSign,
Eye,
EyeOff,
LayoutGrid,
LogOut,
Plus,
RefreshCw,
Search,
SlidersHorizontal,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
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 { useProjectPricingSummary, useProjects, type Project } from "@/lib/store";
import { usePricingSummaryVisibility } from "@/lib/pricingSummaryVisibility";
import { useAppLists } from "@/lib/appLists";
import { useAuth } from "@/context/AuthContext";
import { dedupeOptions } from "@/lib/optionUtils";
import { GLMLogo } from "@/components/GLMLogo";
/** Extract up-to-2-letter initials from a display name or email */
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) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
if (email) return email.slice(0, 2).toUpperCase();
return "?";
}
export default function BoardPage() {
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,
cerrados: 1,
});
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const currentPage = pageByFilter[filter];
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, canControlPricingSummary, logout } = useAuth();
const pricingSummaryParams = {
tab: filter,
search: query,
page: 1,
pageSize: PROJECTS_PER_PAGE,
country: advancedFilters.country,
brand: advancedFilters.brand,
client: advancedFilters.client,
cm: advancedFilters.cm,
} as const;
const {
isPublic: isPricingSummaryPublic,
loading: pricingSummaryVisibilityLoading,
saving: pricingSummaryVisibilitySaving,
error: pricingSummaryVisibilityError,
setPublicVisibility: setPricingSummaryPublicVisibility,
} = usePricingSummaryVisibility(Boolean(user));
const canViewPricingSummary = canControlPricingSummary || isPricingSummaryPublic;
const {
summary: pricingSummary,
loading: pricingSummaryLoading,
error: pricingSummaryError,
} = useProjectPricingSummary(pricingSummaryParams, canViewPricingSummary);
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 visibleStart = totalProjects === 0 ? 0 : pageStart + 1;
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, totalProjects);
useEffect(() => {
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
}, [
query,
filter,
advancedFilters.country,
advancedFilters.brand,
advancedFilters.client,
advancedFilters.cm,
]);
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);
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 = () => {
setEditing(null);
setOpen(true);
};
const openEdit = (p: Project) => {
setEditing(p);
setOpen(true);
};
const deleteProject = async (project: Project) => {
const confirmed = window.confirm(
`¿Seguro que deseas eliminar el proyecto "${project.nombre || "Sin título"}"?\n\nEsta acción eliminará el proyecto del tablero y del Google Sheet.`,
);
if (!confirmed) return;
setDeletingProjectId(project.id);
try {
await remove(project.id);
if (editing?.id === project.id) {
setOpen(false);
setEditing(null);
}
} catch (err) {
console.error("Error eliminando proyecto:", err);
const message =
err instanceof Error
? err.message
: "No se pudo eliminar el proyecto. Revisa n8n, Supabase o permisos.";
window.alert(message);
} finally {
setDeletingProjectId(null);
}
};
const displayName = user?.displayName ?? user?.email ?? "Usuario";
const userRole = isGerardo ? "Jefe CDC" : "Usuario CDC";
const userInitials = initials(user?.displayName, user?.email);
return (
<div className="min-h-screen bg-background">
{/* Top bar */}
<header className="sticky top-0 z-30 bg-background/85 backdrop-blur-md border-b border-border">
<div className="max-w-[1400px] mx-auto px-6 py-3.5 flex items-center gap-4">
{/* Brand */}
<div className="flex items-center gap-4">
<GLMLogo size="sm" />
<div className="h-7 w-px bg-border" />
<h1 className="text-[15px] font-semibold tracking-tight text-[#4F758B]">Tablero CDC</h1>
</div>
{/* Search */}
<div className="flex-1 max-w-md ml-4 relative hidden md:block">
<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 className="flex-1 md:flex-none" />
{/* User menu — real authenticated user */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 h-9 px-2.5">
<Avatar className="w-7 h-7">
{user?.photoURL && (
<AvatarImage
src={user.photoURL}
alt={displayName}
referrerPolicy="no-referrer"
/>
)}
<AvatarFallback
className={
isGerardo
? "bg-primary text-primary-foreground text-xs"
: "bg-accent text-accent-foreground text-xs"
}
>
{userInitials}
</AvatarFallback>
</Avatar>
<div className="text-left leading-tight hidden sm:block">
<div className="text-xs font-medium">{displayName}</div>
<div className="text-[10px] text-muted-foreground">{userRole}</div>
</div>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<div className="px-3 py-2">
<p className="text-xs font-medium truncate">{displayName}</p>
<p className="text-[10px] text-muted-foreground truncate">{user?.email}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={logout}
className="gap-2 text-destructive focus:text-destructive focus:bg-destructive/10 cursor-pointer"
>
<LogOut className="w-3.5 h-3.5" />
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* New project button */}
<Button onClick={openNew} className="gap-1.5 h-9 rounded-full px-4 shadow-sm">
<Plus className="w-4 h-4" /> Nuevo
</Button>
</div>
</header>
{/* Filters */}
<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="pl-3 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="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>
<AdvancedProjectFilters
filters={advancedFilters}
activeCount={activeAdvancedFilterCount}
countryOptions={countryFilterOptions}
brandOptions={brandFilterOptions}
clientOptions={clientFilterOptions}
cmOptions={cmFilterOptions}
onFilterChange={setAdvancedFilter}
onClear={clearAdvancedFilters}
/>
{canViewPricingSummary && (
<PricingSummaryCard
totalAmount={pricingSummary.totalAmount}
projectCount={pricingSummary.projectCount}
loading={pricingSummaryLoading || pricingSummaryVisibilityLoading}
error={pricingSummaryError || pricingSummaryVisibilityError}
hasFilters={
filter !== "todos" || query.trim().length > 0 || activeAdvancedFilterCount > 0
}
canControlVisibility={canControlPricingSummary}
isPublic={isPricingSummaryPublic}
visibilitySaving={pricingSummaryVisibilitySaving}
onToggleVisibility={() =>
setPricingSummaryPublicVisibility(!isPricingSummaryPublic).catch(() => undefined)
}
/>
)}
</div>
{/* Grid */}
<main className="max-w-[1400px] mx-auto px-6 pb-16">
{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 />
) : 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">
{projects.map((p) => (
<ProjectCard key={p.id} project={p} onClick={() => openEdit(p)} />
))}
</div>
)}
{totalProjects > PROJECTS_PER_PAGE && (
<PaginationControls
currentPage={safePage}
totalPages={totalPages}
visibleStart={visibleStart}
visibleEnd={visibleEnd}
totalItems={totalProjects}
onPageChange={goToPage}
/>
)}
</main>
<ProjectDialog
open={open}
onOpenChange={setOpen}
project={editing}
onDelete={deleteProject}
deleting={!!editing && deletingProjectId === editing.id}
/>
</div>
);
}
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(value || 0);
}
function PricingSummaryCard({
totalAmount,
projectCount,
loading,
error,
hasFilters,
canControlVisibility,
isPublic,
visibilitySaving,
onToggleVisibility,
}: {
totalAmount: number;
projectCount: number;
loading: boolean;
error: string | null;
hasFilters: boolean;
canControlVisibility: boolean;
isPublic: boolean;
visibilitySaving: boolean;
onToggleVisibility: () => void;
}) {
return (
<div className="rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<DollarSign className="h-4 w-4" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{hasFilters ? "Total tarifado filtrado" : "Total tarifado global"}
</p>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${
isPublic
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-primary/20 bg-card text-primary"
}`}
>
{isPublic ? "Visible para todos" : "Privado"}
</span>
</div>
<p className="mt-0.5 text-2xl font-semibold tracking-tight text-foreground">
{loading ? "Calculando…" : formatCurrency(totalAmount)}
</p>
<p className="text-xs text-muted-foreground">
{error
? `No se pudo calcular el total: ${error}`
: `${projectCount} proyecto(s) incluidos según los filtros actuales.`}
</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
<div className="rounded-full border border-primary/20 bg-card px-3 py-1 text-xs font-medium text-primary">
{hasFilters ? "Según filtros activos" : "Sin filtros aplicados"}
</div>
{canControlVisibility && (
<Button
type="button"
variant={isPublic ? "secondary" : "outline"}
size="sm"
className="gap-1.5 rounded-full"
disabled={visibilitySaving}
onClick={onToggleVisibility}
title={
isPublic
? "Ocultar este total para los demás usuarios"
: "Mostrar este total temporalmente a todos los usuarios"
}
>
{isPublic ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{visibilitySaving
? "Actualizando…"
: isPublic
? "Ocultar a todos"
: "Mostrar a todos"}
</Button>
)}
</div>
</div>
</div>
);
}
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,
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,
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 && 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 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.";
}
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">{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
</Button>
)}
</div>
);
}