1179 lines
42 KiB
TypeScript
1179 lines
42 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import {
|
|
ChevronDown,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
AlertCircle,
|
|
BadgeDollarSign,
|
|
DollarSign,
|
|
Eye,
|
|
EyeOff,
|
|
LayoutGrid,
|
|
ListChecks,
|
|
Inbox,
|
|
FileX2,
|
|
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 { ProjectTimeDialog } from "@/components/board/ProjectTimeDialog";
|
|
import { BriefReviewCard } from "@/components/board/BriefReviewCard";
|
|
import { SearchableSelect } from "@/components/board/SearchableSelect";
|
|
import {
|
|
useProjectPricingSummary,
|
|
useProjects,
|
|
type Project,
|
|
type ProjectTab,
|
|
} from "@/lib/store";
|
|
import { useBriefInbox, useBriefUnreadCount, type BriefReviewStatus } from "@/lib/briefInbox";
|
|
import { usePricingSummaryVisibility } from "@/lib/pricingSummaryVisibility";
|
|
import { useAppLists } from "@/lib/appLists";
|
|
import { useAuth } from "@/context/AuthContext";
|
|
import { dedupeOptions } from "@/lib/optionUtils";
|
|
import { expandCountryOptions, expandMultiValueOptions } from "@/lib/filterNormalization";
|
|
import { GLMLogo } from "@/components/GLMLogo";
|
|
import { TariffManagerDialog } from "@/components/tariff/TariffManagerDialog";
|
|
import { ListManagerDialog } from "@/components/lists/ListManagerDialog";
|
|
|
|
/** Extract up-to-2-letter initials from a display name or email */
|
|
const PROJECTS_PER_PAGE = 24;
|
|
|
|
type BoardView = "nuevos" | ProjectTab | "no_aprobados";
|
|
|
|
type AdvancedFilters = {
|
|
country: string;
|
|
brand: string;
|
|
client: string;
|
|
cm: string;
|
|
};
|
|
|
|
const EMPTY_ADVANCED_FILTERS: AdvancedFilters = {
|
|
country: "",
|
|
brand: "",
|
|
client: "",
|
|
cm: "",
|
|
};
|
|
|
|
const DEFAULT_PAGE_BY_FILTER: Record<BoardView, number> = {
|
|
nuevos: 1,
|
|
todos: 1,
|
|
abiertos: 1,
|
|
cerrados: 1,
|
|
no_aprobados: 1,
|
|
};
|
|
|
|
const BOARD_UI_STATE_KEY = "tablero-cdc:ui-state:v1";
|
|
|
|
type PersistedBoardUiState = {
|
|
query: string;
|
|
filter: BoardView;
|
|
advancedFilters: AdvancedFilters;
|
|
pageByFilter: Record<BoardView, number>;
|
|
tariffManagerOpen: boolean;
|
|
listManagerOpen: boolean;
|
|
scrollY: number;
|
|
};
|
|
|
|
function loadBoardUiState(): PersistedBoardUiState {
|
|
const fallback: PersistedBoardUiState = {
|
|
query: "",
|
|
filter: "todos",
|
|
advancedFilters: EMPTY_ADVANCED_FILTERS,
|
|
pageByFilter: DEFAULT_PAGE_BY_FILTER,
|
|
tariffManagerOpen: false,
|
|
listManagerOpen: false,
|
|
scrollY: 0,
|
|
};
|
|
|
|
if (typeof window === "undefined") return fallback;
|
|
|
|
try {
|
|
const raw = window.localStorage.getItem(BOARD_UI_STATE_KEY);
|
|
if (!raw) return fallback;
|
|
const parsed = JSON.parse(raw) as Partial<PersistedBoardUiState>;
|
|
const validFilters: BoardView[] = ["nuevos", "todos", "abiertos", "cerrados", "no_aprobados"];
|
|
const nextFilter = validFilters.includes(parsed.filter as BoardView)
|
|
? (parsed.filter as BoardView)
|
|
: fallback.filter;
|
|
|
|
return {
|
|
query: typeof parsed.query === "string" ? parsed.query : fallback.query,
|
|
filter: nextFilter,
|
|
advancedFilters: {
|
|
country: typeof parsed.advancedFilters?.country === "string" ? parsed.advancedFilters.country : "",
|
|
brand: typeof parsed.advancedFilters?.brand === "string" ? parsed.advancedFilters.brand : "",
|
|
client: typeof parsed.advancedFilters?.client === "string" ? parsed.advancedFilters.client : "",
|
|
cm: typeof parsed.advancedFilters?.cm === "string" ? parsed.advancedFilters.cm : "",
|
|
},
|
|
pageByFilter: Object.fromEntries(
|
|
Object.entries({ ...DEFAULT_PAGE_BY_FILTER, ...(parsed.pageByFilter || {}) }).map(([key, value]) => [
|
|
key,
|
|
Math.max(1, Math.floor(Number(value) || 1)),
|
|
]),
|
|
) as Record<BoardView, number>,
|
|
tariffManagerOpen: parsed.tariffManagerOpen === true,
|
|
listManagerOpen: parsed.listManagerOpen === true,
|
|
scrollY: Number.isFinite(Number(parsed.scrollY)) ? Math.max(0, Number(parsed.scrollY)) : 0,
|
|
};
|
|
} catch (error) {
|
|
console.warn("No se pudo restaurar la vista anterior del Tablero CDC:", error);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
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 [initialUiState] = useState(loadBoardUiState);
|
|
const hasMountedFilterReset = useRef(false);
|
|
const hasRestoredScroll = useRef(initialUiState.scrollY <= 0);
|
|
const [open, setOpen] = useState(false);
|
|
const [editing, setEditing] = useState<Project | null>(null);
|
|
const [query, setQuery] = useState(initialUiState.query);
|
|
const [filter, setFilter] = useState<BoardView>(initialUiState.filter);
|
|
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(initialUiState.advancedFilters);
|
|
const [pageByFilter, setPageByFilter] = useState<Record<BoardView, number>>(initialUiState.pageByFilter);
|
|
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
|
const [tariffManagerOpen, setTariffManagerOpen] = useState(initialUiState.tariffManagerOpen);
|
|
const [listManagerOpen, setListManagerOpen] = useState(initialUiState.listManagerOpen);
|
|
const [timeProject, setTimeProject] = useState<Project | null>(null);
|
|
const [reviewingBriefId, setReviewingBriefId] = useState<string | null>(null);
|
|
|
|
const isBriefReviewView = filter === "nuevos" || filter === "no_aprobados";
|
|
const briefStatus: BriefReviewStatus = filter === "no_aprobados" ? "rejected" : "new";
|
|
const projectFilter: ProjectTab = isBriefReviewView ? "todos" : filter;
|
|
const currentPage = pageByFilter[filter];
|
|
const { lists } = useAppLists();
|
|
const { projects, totalProjects, filterOptions, loading, error, refresh, remove } = useProjects({
|
|
tab: projectFilter,
|
|
search: isBriefReviewView ? "" : query,
|
|
page: isBriefReviewView ? pageByFilter[projectFilter] : currentPage,
|
|
pageSize: PROJECTS_PER_PAGE,
|
|
country: isBriefReviewView ? "" : advancedFilters.country,
|
|
brand: isBriefReviewView ? "" : advancedFilters.brand,
|
|
client: isBriefReviewView ? "" : advancedFilters.client,
|
|
cm: isBriefReviewView ? "" : advancedFilters.cm,
|
|
});
|
|
const briefInbox = useBriefInbox({
|
|
status: briefStatus,
|
|
search: query,
|
|
page: currentPage,
|
|
pageSize: PROJECTS_PER_PAGE,
|
|
enabled: isBriefReviewView,
|
|
});
|
|
const {
|
|
user,
|
|
isGerardo,
|
|
canManageInternalPricing,
|
|
canControlPricingSummary,
|
|
canManageTariffCatalog,
|
|
logout,
|
|
} = useAuth();
|
|
const briefUnread = useBriefUnreadCount(Boolean(user));
|
|
const pricingSummaryParams = {
|
|
tab: projectFilter,
|
|
search: isBriefReviewView ? "" : query,
|
|
page: 1,
|
|
pageSize: PROJECTS_PER_PAGE,
|
|
country: isBriefReviewView ? "" : advancedFilters.country,
|
|
brand: isBriefReviewView ? "" : advancedFilters.brand,
|
|
client: isBriefReviewView ? "" : advancedFilters.client,
|
|
cm: isBriefReviewView ? "" : advancedFilters.cm,
|
|
} as const;
|
|
const {
|
|
isPublic: isPricingSummaryPublic,
|
|
loading: pricingSummaryVisibilityLoading,
|
|
saving: pricingSummaryVisibilitySaving,
|
|
error: pricingSummaryVisibilityError,
|
|
setPublicVisibility: setPricingSummaryPublicVisibility,
|
|
} = usePricingSummaryVisibility(Boolean(user));
|
|
const canViewPricingSummary =
|
|
!isBriefReviewView && (canControlPricingSummary || isPricingSummaryPublic);
|
|
const {
|
|
summary: pricingSummary,
|
|
loading: pricingSummaryLoading,
|
|
error: pricingSummaryError,
|
|
} = useProjectPricingSummary(pricingSummaryParams, canViewPricingSummary);
|
|
|
|
const cmListOptions = Object.values(lists.buCm);
|
|
const countryFilterOptions = dedupeOptions([
|
|
...lists.bus,
|
|
...expandCountryOptions(filterOptions.countries, lists.bus),
|
|
advancedFilters.country,
|
|
]);
|
|
const brandFilterOptions = dedupeOptions([
|
|
...lists.marcas,
|
|
...expandMultiValueOptions(filterOptions.brands, lists.marcas),
|
|
advancedFilters.brand,
|
|
]);
|
|
const clientFilterOptions = dedupeOptions([
|
|
...lists.clientes,
|
|
...expandMultiValueOptions(filterOptions.clients, lists.clientes),
|
|
advancedFilters.client,
|
|
]);
|
|
const cmFilterOptions = dedupeOptions([
|
|
...cmListOptions,
|
|
...expandMultiValueOptions(filterOptions.cms, cmListOptions),
|
|
advancedFilters.cm,
|
|
]);
|
|
const activeAdvancedFilterCount = Object.values(advancedFilters).filter(Boolean).length;
|
|
|
|
const currentTotalItems = isBriefReviewView ? briefInbox.totalItems : totalProjects;
|
|
const totalPages = Math.max(1, Math.ceil(currentTotalItems / PROJECTS_PER_PAGE));
|
|
const safePage = Math.min(currentPage, totalPages);
|
|
const pageStart = (safePage - 1) * PROJECTS_PER_PAGE;
|
|
const visibleStart = currentTotalItems === 0 ? 0 : pageStart + 1;
|
|
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, currentTotalItems);
|
|
|
|
useEffect(() => {
|
|
if (!hasMountedFilterReset.current) {
|
|
hasMountedFilterReset.current = true;
|
|
return;
|
|
}
|
|
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
|
|
}, [
|
|
query,
|
|
advancedFilters.country,
|
|
advancedFilters.brand,
|
|
advancedFilters.client,
|
|
advancedFilters.cm,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const saveUiState = () => {
|
|
try {
|
|
const nextState: PersistedBoardUiState = {
|
|
query,
|
|
filter,
|
|
advancedFilters,
|
|
pageByFilter,
|
|
tariffManagerOpen,
|
|
listManagerOpen,
|
|
scrollY: hasRestoredScroll.current ? window.scrollY : initialUiState.scrollY,
|
|
};
|
|
window.localStorage.setItem(BOARD_UI_STATE_KEY, JSON.stringify(nextState));
|
|
} catch (error) {
|
|
console.warn("No se pudo guardar la vista del Tablero CDC:", error);
|
|
}
|
|
};
|
|
|
|
saveUiState();
|
|
const handlePageHide = () => saveUiState();
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === "hidden") saveUiState();
|
|
};
|
|
|
|
window.addEventListener("pagehide", handlePageHide);
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
return () => {
|
|
window.removeEventListener("pagehide", handlePageHide);
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
};
|
|
}, [query, filter, advancedFilters, pageByFilter, tariffManagerOpen, listManagerOpen, initialUiState.scrollY]);
|
|
|
|
useEffect(() => {
|
|
const savedScrollY = initialUiState.scrollY;
|
|
const currentViewLoading = isBriefReviewView ? briefInbox.loading : loading;
|
|
if (savedScrollY <= 0 || currentViewLoading || hasRestoredScroll.current) return;
|
|
|
|
const timer = window.setTimeout(() => {
|
|
window.scrollTo({ top: savedScrollY, behavior: "auto" });
|
|
hasRestoredScroll.current = true;
|
|
}, 120);
|
|
|
|
return () => window.clearTimeout(timer);
|
|
}, [initialUiState.scrollY, isBriefReviewView, briefInbox.loading, loading]);
|
|
|
|
useEffect(() => {
|
|
if (currentPage > totalPages) {
|
|
setPageByFilter((prev) => ({ ...prev, [filter]: totalPages }));
|
|
}
|
|
}, [currentPage, filter, totalPages]);
|
|
|
|
// Entering "Nuevos" clears only this user's pending notification. If a new
|
|
// brief arrives while this view is open, Realtime increments unreadCount and
|
|
// this effect immediately marks that currently visible queue as seen again.
|
|
// Other teammates keep their own independent unread state.
|
|
useEffect(() => {
|
|
if (filter !== "nuevos" || !user || briefUnread.unreadCount <= 0) return;
|
|
|
|
void briefUnread.markAllSeen().catch((markError) => {
|
|
console.error("Error marcando briefs como vistos:", markError);
|
|
});
|
|
}, [filter, user?.id, briefUnread.unreadCount, briefUnread.markAllSeen]);
|
|
|
|
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 approveBrief = async (briefId: string, title: string, fromRejected = false) => {
|
|
const confirmed = window.confirm(
|
|
fromRejected
|
|
? `¿Aprobar el brief rechazado "${title || "Sin título"}"?\n\nSe recuperará de Rechazados, se creará como proyecto Activo y aparecerá en Todos y Activos.`
|
|
: `¿Aprobar el brief "${title || "Sin título"}"?\n\nSe creará como proyecto Activo y aparecerá en Todos y Activos.`,
|
|
);
|
|
|
|
if (!confirmed || reviewingBriefId) return;
|
|
|
|
setReviewingBriefId(briefId);
|
|
|
|
try {
|
|
const result = await briefInbox.approve(briefId);
|
|
await refresh();
|
|
|
|
if (result.sheetSyncStatus === "synced") {
|
|
window.alert("Brief aprobado. El proyecto quedó Activo y sincronizado con Google Sheet.");
|
|
} else {
|
|
window.alert(result.sheetSyncMessage);
|
|
}
|
|
} catch (reviewError) {
|
|
console.error("Error aprobando brief:", reviewError);
|
|
window.alert(
|
|
reviewError instanceof Error
|
|
? reviewError.message
|
|
: "No se pudo aprobar el brief. Revisa Supabase e intenta de nuevo.",
|
|
);
|
|
} finally {
|
|
setReviewingBriefId(null);
|
|
}
|
|
};
|
|
|
|
const rejectBrief = async (briefId: string, title: string) => {
|
|
const confirmed = window.confirm(
|
|
`¿Mover el brief "${title || "Sin título"}" a Rechazados?\n\nNo se creará ningún proyecto en el tablero general.`,
|
|
);
|
|
|
|
if (!confirmed || reviewingBriefId) return;
|
|
|
|
setReviewingBriefId(briefId);
|
|
|
|
try {
|
|
await briefInbox.reject(briefId);
|
|
} catch (reviewError) {
|
|
console.error("Error marcando brief como rechazado:", reviewError);
|
|
window.alert(
|
|
reviewError instanceof Error
|
|
? reviewError.message
|
|
: "No se pudo mover el brief a Rechazados. Revisa Supabase e intenta de nuevo.",
|
|
);
|
|
} finally {
|
|
setReviewingBriefId(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]">CDC Project Management</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={isBriefReviewView ? "Buscar brief, cliente, marca…" : "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>
|
|
|
|
{/* Tariff administration — controlled by Supabase permission */}
|
|
{canManageTariffCatalog && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setTariffManagerOpen(true)}
|
|
className="gap-1.5 h-9 rounded-full px-4 shadow-sm"
|
|
>
|
|
<BadgeDollarSign className="w-4 h-4" /> Tarifario
|
|
</Button>
|
|
)}
|
|
|
|
{/* App lists administration — same trusted admin permission as Tarifario */}
|
|
{canManageTariffCatalog && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setListManagerOpen(true)}
|
|
className="gap-1.5 h-9 rounded-full px-4 shadow-sm"
|
|
>
|
|
<ListChecks className="w-4 h-4" /> Listas
|
|
</Button>
|
|
)}
|
|
|
|
{/* 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="text-2xl font-semibold tracking-tight">
|
|
{filter === "nuevos"
|
|
? "Briefs por revisar"
|
|
: filter === "no_aprobados"
|
|
? "Briefs rechazados"
|
|
: "Proyectos"}
|
|
</h2>
|
|
{filter === "nuevos" && (
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Los briefs de CDC Brief llegan aquí antes de convertirse en proyectos Activos.
|
|
</p>
|
|
)}
|
|
{filter === "no_aprobados" && (
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Briefs revisados que no se incorporaron al tablero general.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="inline-flex flex-wrap bg-muted/60 rounded-full p-1 text-xs">
|
|
{(
|
|
[
|
|
["todos", "Todos"],
|
|
["abiertos", "Activos"],
|
|
["cerrados", "Cerrados"],
|
|
["nuevos", "Nuevos"],
|
|
["no_aprobados", "Rechazados"],
|
|
] as const
|
|
).map(([k, l]) => (
|
|
<button
|
|
key={k}
|
|
onClick={() => setFilter(k)}
|
|
className={`inline-flex items-center gap-1.5 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"
|
|
}`}
|
|
>
|
|
<span>{l}</span>
|
|
{k === "nuevos" && briefUnread.unreadCount > 0 && (
|
|
<span
|
|
className="inline-flex min-w-[18px] h-[18px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white shadow-sm"
|
|
aria-label={`${briefUnread.unreadCount} briefs nuevos sin revisar`}
|
|
>
|
|
{briefUnread.unreadCount > 99 ? "99+" : briefUnread.unreadCount}
|
|
</span>
|
|
)}
|
|
</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={isBriefReviewView ? "Buscar brief, cliente, marca…" : "Buscar proyecto, cliente, marca…"}
|
|
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
|
|
/>
|
|
</div>
|
|
|
|
{!isBriefReviewView && (
|
|
<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">
|
|
{(isBriefReviewView ? briefInbox.error : 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">
|
|
{isBriefReviewView
|
|
? "No se pudieron cargar los briefs."
|
|
: "No se pudieron cargar los proyectos."}
|
|
</span>{" "}
|
|
{isBriefReviewView ? briefInbox.error : error}
|
|
</span>
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => void (isBriefReviewView ? briefInbox.refresh() : refresh())}
|
|
className="gap-1.5"
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" /> Reintentar
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{isBriefReviewView ? (
|
|
briefInbox.loading && briefInbox.items.length === 0 ? (
|
|
<LoadingState />
|
|
) : briefInbox.items.length === 0 ? (
|
|
<BriefEmptyState mode={briefStatus} query={query} />
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
|
{briefInbox.items.map((item) => (
|
|
<BriefReviewCard
|
|
key={item.id}
|
|
item={item}
|
|
mode={briefStatus}
|
|
busy={reviewingBriefId === item.id}
|
|
onApprove={() => void approveBrief(item.id, item.title, briefStatus === "rejected")}
|
|
onReject={() => void rejectBrief(item.id, item.title)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
) : loading && projects.length === 0 ? (
|
|
<LoadingState />
|
|
) : projects.length === 0 ? (
|
|
<EmptyState
|
|
onNew={openNew}
|
|
hasProjects={
|
|
totalProjects > 0 ||
|
|
projectFilter !== "todos" ||
|
|
query.trim().length > 0 ||
|
|
activeAdvancedFilterCount > 0
|
|
}
|
|
filter={projectFilter}
|
|
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)}
|
|
onTimeClick={() => setTimeProject(p)}
|
|
showInternalAmount={canManageInternalPricing}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{currentTotalItems > PROJECTS_PER_PAGE && (
|
|
<PaginationControls
|
|
currentPage={safePage}
|
|
totalPages={totalPages}
|
|
visibleStart={visibleStart}
|
|
visibleEnd={visibleEnd}
|
|
totalItems={currentTotalItems}
|
|
itemLabel={isBriefReviewView ? "briefs" : "proyectos"}
|
|
onPageChange={goToPage}
|
|
/>
|
|
)}
|
|
</main>
|
|
|
|
<ProjectDialog
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
project={editing}
|
|
onDelete={deleteProject}
|
|
deleting={!!editing && deletingProjectId === editing.id}
|
|
/>
|
|
|
|
<ProjectTimeDialog
|
|
open={!!timeProject}
|
|
onOpenChange={(nextOpen) => {
|
|
if (!nextOpen) setTimeProject(null);
|
|
}}
|
|
project={timeProject}
|
|
/>
|
|
|
|
{canManageTariffCatalog && (
|
|
<>
|
|
<TariffManagerDialog
|
|
open={tariffManagerOpen}
|
|
onOpenChange={setTariffManagerOpen}
|
|
/>
|
|
<ListManagerDialog
|
|
open={listManagerOpen}
|
|
onOpenChange={setListManagerOpen}
|
|
/>
|
|
</>
|
|
)}
|
|
</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,
|
|
itemLabel = "proyectos",
|
|
onPageChange,
|
|
}: {
|
|
currentPage: number;
|
|
totalPages: number;
|
|
visibleStart: number;
|
|
visibleEnd: number;
|
|
totalItems: number;
|
|
itemLabel?: string;
|
|
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> {itemLabel}
|
|
</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 BriefEmptyState({ mode, query }: { mode: BriefReviewStatus; query: string }) {
|
|
const hasSearch = query.trim().length > 0;
|
|
const isRejected = mode === "rejected";
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-dashed border-border bg-card/40 py-24 text-center">
|
|
<div
|
|
className={`mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl ${
|
|
isRejected ? "bg-red-50 text-red-600" : "bg-amber-50 text-amber-700"
|
|
}`}
|
|
>
|
|
{isRejected ? <FileX2 className="h-6 w-6" /> : <Inbox className="h-6 w-6" />}
|
|
</div>
|
|
<h3 className="text-lg font-semibold">
|
|
{hasSearch
|
|
? "No hay briefs que coincidan con la búsqueda"
|
|
: isRejected
|
|
? "No hay briefs rechazados"
|
|
: "No hay briefs pendientes"}
|
|
</h3>
|
|
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
|
|
{hasSearch
|
|
? "Prueba con otro nombre, cliente, marca, país o solicitante."
|
|
: isRejected
|
|
? "Los briefs que el equipo decida no convertir en proyecto aparecerán aquí."
|
|
: "Cuando CDC Brief genere un nuevo brief, aparecerá aquí automáticamente para revisión."}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({
|
|
onNew,
|
|
hasProjects,
|
|
filter,
|
|
query,
|
|
activeAdvancedFilterCount,
|
|
}: {
|
|
onNew: () => void;
|
|
hasProjects: boolean;
|
|
filter: ProjectTab;
|
|
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>
|
|
);
|
|
}
|