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 = { 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; 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; 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, 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(null); const [query, setQuery] = useState(initialUiState.query); const [filter, setFilter] = useState(initialUiState.filter); const [advancedFilters, setAdvancedFilters] = useState(initialUiState.advancedFilters); const [pageByFilter, setPageByFilter] = useState>(initialUiState.pageByFilter); const [deletingProjectId, setDeletingProjectId] = useState(null); const [tariffManagerOpen, setTariffManagerOpen] = useState(initialUiState.tariffManagerOpen); const [listManagerOpen, setListManagerOpen] = useState(initialUiState.listManagerOpen); const [timeProject, setTimeProject] = useState(null); const [reviewingBriefId, setReviewingBriefId] = useState(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 (
{/* Top bar */}
{/* Brand */}

CDC Project Management

{/* Search */}
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" />
{/* User menu — real authenticated user */}

{displayName}

{user?.email}

Cerrar sesión
{/* Tariff administration — controlled by Supabase permission */} {canManageTariffCatalog && ( )} {/* App lists administration — same trusted admin permission as Tarifario */} {canManageTariffCatalog && ( )} {/* New project button */}
{/* Filters */}

{filter === "nuevos" ? "Briefs por revisar" : filter === "no_aprobados" ? "Briefs rechazados" : "Proyectos"}

{filter === "nuevos" && (

Los briefs de CDC Brief llegan aquí antes de convertirse en proyectos Activos.

)} {filter === "no_aprobados" && (

Briefs revisados que no se incorporaron al tablero general.

)}
{( [ ["todos", "Todos"], ["abiertos", "Activos"], ["cerrados", "Cerrados"], ["nuevos", "Nuevos"], ["no_aprobados", "Rechazados"], ] as const ).map(([k, l]) => ( ))}
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" />
{!isBriefReviewView && ( )} {canViewPricingSummary && ( 0 || activeAdvancedFilterCount > 0 } canControlVisibility={canControlPricingSummary} isPublic={isPricingSummaryPublic} visibilitySaving={pricingSummaryVisibilitySaving} onToggleVisibility={() => setPricingSummaryPublicVisibility(!isPricingSummaryPublic).catch(() => undefined) } /> )}
{/* Grid */}
{(isBriefReviewView ? briefInbox.error : error) && (
{isBriefReviewView ? "No se pudieron cargar los briefs." : "No se pudieron cargar los proyectos."} {" "} {isBriefReviewView ? briefInbox.error : error}
)} {isBriefReviewView ? ( briefInbox.loading && briefInbox.items.length === 0 ? ( ) : briefInbox.items.length === 0 ? ( ) : (
{briefInbox.items.map((item) => ( void approveBrief(item.id, item.title, briefStatus === "rejected")} onReject={() => void rejectBrief(item.id, item.title)} /> ))}
) ) : loading && projects.length === 0 ? ( ) : projects.length === 0 ? ( 0 || projectFilter !== "todos" || query.trim().length > 0 || activeAdvancedFilterCount > 0 } filter={projectFilter} query={query} activeAdvancedFilterCount={activeAdvancedFilterCount} /> ) : (
{projects.map((p) => ( openEdit(p)} onTimeClick={() => setTimeProject(p)} showInternalAmount={canManageInternalPricing} /> ))}
)} {currentTotalItems > PROJECTS_PER_PAGE && ( )}
{ if (!nextOpen) setTimeProject(null); }} project={timeProject} /> {canManageTariffCatalog && ( <> )}
); } 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 (

{hasFilters ? "Total tarifado filtrado" : "Total tarifado global"}

{isPublic ? "Visible para todos" : "Privado"}

{loading ? "Calculando…" : formatCurrency(totalAmount)}

{error ? `No se pudo calcular el total: ${error}` : `${projectCount} proyecto(s) incluidos según los filtros actuales.`}

{hasFilters ? "Según filtros activos" : "Sin filtros aplicados"}
{canControlVisibility && ( )}
); } 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 (
Filtros {activeCount > 0 && ( {activeCount} )}
{activeCount > 0 && ( )}
onFilterChange("country", value)} placeholder="País: Todos" searchPlaceholder="Buscar país…" emptyText="No se encontró ese país." clearLabel="Todos" /> onFilterChange("brand", value)} placeholder="Marca: Todas" searchPlaceholder="Buscar marca…" emptyText="No se encontró esa marca." clearLabel="Todas" /> onFilterChange("client", value)} placeholder="Cliente: Todos" searchPlaceholder="Buscar cliente…" emptyText="No se encontró ese cliente." clearLabel="Todos" /> onFilterChange("cm", value)} placeholder="CM: Todos" searchPlaceholder="Buscar CM…" emptyText="No se encontró ese CM." clearLabel="Todos" />
); } 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 (

{visibleStart}-{visibleEnd} {" "} de {totalItems} {itemLabel}

{pages.map((page, index) => page === "ellipsis" ? ( ) : ( ), )}
); } function getVisiblePages(currentPage: number, totalPages: number): Array { if (totalPages <= 7) return Array.from({ length: totalPages }, (_, index) => index + 1); const pages = new Set([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>((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 (
{Array.from({ length: 8 }).map((_, index) => (
))}
); } function BriefEmptyState({ mode, query }: { mode: BriefReviewStatus; query: string }) { const hasSearch = query.trim().length > 0; const isRejected = mode === "rejected"; return (
{isRejected ? : }

{hasSearch ? "No hay briefs que coincidan con la búsqueda" : isRejected ? "No hay briefs rechazados" : "No hay briefs pendientes"}

{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."}

); } 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 (

{title}

{message}

{!hasProjects && ( )}
); }