From fd2bd9582d1082041828b180473dc372161ea36c Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 13 Oct 2026 17:52:55 +0200 Subject: [PATCH] Let agency configure each client's left-nav modules from the ficha. Checkboxes match the sidebar (incl. Social sub-items), with a live preview and route guard so clients cannot open disabled sections. Co-authored-by: Cursor --- src/app/dashboard/layout.tsx | 5 + src/components/ClientFicha.tsx | 178 +++++++++++++++++++-------- src/components/DashboardShell.tsx | 44 ++++--- src/components/ModuleAccessGuard.tsx | 65 ++++++++++ src/lib/tenant-modules.ts | 67 ++++++++-- 5 files changed, 278 insertions(+), 81 deletions(-) create mode 100644 src/components/ModuleAccessGuard.tsx diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index b12da69..5976592 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -13,6 +13,7 @@ import { } from '@/lib/agency'; import DashboardShell from '@/components/DashboardShell'; import DashboardProviders from '@/components/DashboardProviders'; +import ModuleAccessGuard from '@/components/ModuleAccessGuard'; import { DashboardModuleId, TenantSettings, @@ -82,6 +83,10 @@ export default async function DashboardLayout({ return ( + @@ -523,70 +526,145 @@ export default function ClientFicha({ {tab === 'modulos' && (
-
+
- Módulos del dash cliente + Menú del portal cliente
-

- Qué ve el cliente en el menú. Sugerencia según Meta / Google / - RRSS / CRM conectados. +

+ Marca o desmarca qué ve este cliente en el menú izquierdo. La + agencia sigue viendo todo el menú al operar; el filtro solo + aplica a usuarios con rol cliente.

- +
+ + + +
+
    - {DASHBOARD_MODULES.map((m) => { + {topLevelDashboardModules().map((m) => { const on = modules[m.id] !== false; + const children = childDashboardModules(m.id); return ( -
  • -
    -

    - {m.label} -

    -

    {m.hint}

    - {m.providers.length ? ( -

    - Suele ir con: {m.providers.join(', ')} -

    - ) : null} -
    -
+ { + setModules((prev) => { + const next = { ...prev, [m.id]: !on }; + if (on) { + for (const c of children) next[c.id] = false; + } + return next; + }); + }} /> - + + {children.length > 0 && on + ? children.map((c) => { + const childOn = modules[c.id] !== false; + return ( + + ); + }) + : null} ); })} + +
+

+ Vista previa · menú del cliente +

+
    + {previewMenuLabels(modules).map((label) => ( +
  • + {label} +
  • + ))} +
+ {!previewMenuLabels(modules).length ? ( +

+ Ningún módulo activo — el cliente no verá entradas en el menú. +

+ ) : null} +
+
)} diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx index 005cace..12d284b 100644 --- a/src/components/DashboardShell.tsx +++ b/src/components/DashboardShell.tsx @@ -180,11 +180,13 @@ export default function DashboardShell({ const dateRange = useDateRange(); const pathname = usePathname(); - const visibleNav = NAV_ITEMS.filter((item) => { + const moduleOn = (id: DashboardModuleId) => { if (isAgency) return true; if (!enabledModules) return true; - return enabledModules[item.moduleId] !== false; - }); + return enabledModules[id] !== false; + }; + + const visibleNav = NAV_ITEMS.filter((item) => moduleOn(item.moduleId)); const showClientBrand = Boolean(clientLogoUrl); const title = showClientBrand ? brandLabel || company : '4VENTURES'; @@ -249,22 +251,26 @@ export default function DashboardShell({ {underSocial ? ( <> - - - Calendario editorial - - - - Pipeline RRSS - + {moduleOn('calendario') ? ( + + + Calendario editorial + + ) : null} + {moduleOn('pipeline') ? ( + + + Pipeline RRSS + + ) : null} ) : null} diff --git a/src/components/ModuleAccessGuard.tsx b/src/components/ModuleAccessGuard.tsx new file mode 100644 index 0000000..706bb53 --- /dev/null +++ b/src/components/ModuleAccessGuard.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useEffect } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import type { DashboardModuleId } from '@/lib/tenant-modules'; +import { + DASHBOARD_MODULES, + isModuleEnabled, + topLevelDashboardModules, +} from '@/lib/tenant-modules'; + +/** + * Redirects client users away from dashboard routes whose module is off. + * Agency is never blocked (enabledModules is null for agency). + */ +export default function ModuleAccessGuard({ + enabledModules, + isAgency, +}: { + enabledModules?: Record | null; + isAgency: boolean; +}) { + const pathname = usePathname(); + const router = useRouter(); + + useEffect(() => { + if (isAgency || !enabledModules || !pathname?.startsWith('/dashboard')) { + return; + } + // Agency OS / account routes always allowed for agency only — clients don't hit them + if ( + pathname.startsWith('/dashboard/agencia') || + pathname.startsWith('/dashboard/account') + ) { + return; + } + if (isModuleEnabled(enabledModules, pathname)) return; + + const fallback = + topLevelDashboardModules().find((m) => enabledModules[m.id] !== false) + ?.href || '/dashboard/kpis'; + if (fallback !== pathname) { + router.replace(fallback); + } + }, [pathname, enabledModules, isAgency, router]); + + return null; +} + +/** Labels for preview lists (includes nested). */ +export function previewMenuLabels( + modules: Record +): string[] { + const labels: string[] = []; + for (const m of DASHBOARD_MODULES) { + if (m.parentId) continue; + if (modules[m.id] === false) continue; + labels.push(m.label); + for (const child of DASHBOARD_MODULES.filter((c) => c.parentId === m.id)) { + if (modules[child.id] === false) continue; + labels.push(`↳ ${child.label}`); + } + } + return labels; +} diff --git a/src/lib/tenant-modules.ts b/src/lib/tenant-modules.ts index 62e8781..4ffe5c6 100644 --- a/src/lib/tenant-modules.ts +++ b/src/lib/tenant-modules.ts @@ -7,6 +7,7 @@ export type DashboardModuleId = | 'estrategia' | 'marca' | 'calendario' + | 'pipeline' | 'ad-tracker' | 'social' | 'organico' @@ -21,6 +22,8 @@ export type DashboardModuleDef = { /** Integration providers that typically unlock this module. */ providers: string[]; hint: string; + /** Shown nested under this parent in the client-menu editor (not a top-level nav row). */ + parentId?: DashboardModuleId; }; export const DASHBOARD_MODULES: DashboardModuleDef[] = [ @@ -59,13 +62,6 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [ providers: [], hint: 'Brandbook: voz, oferta, ads, LinkedIn, blog', }, - { - id: 'calendario', - href: '/dashboard/calendario', - label: 'Calendario Editorial', - providers: ['instagram', 'facebook', 'tiktok', 'linkedin'], - hint: 'RRSS / contenido', - }, { id: 'ad-tracker', href: '/dashboard/ad-tracker', @@ -80,6 +76,22 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [ providers: ['instagram', 'facebook', 'tiktok', 'linkedin'], hint: 'RRSS orgánicas: perfiles, alcance y contenido', }, + { + id: 'calendario', + href: '/dashboard/calendario', + label: 'Calendario editorial', + providers: ['instagram', 'facebook', 'tiktok', 'linkedin'], + hint: 'Submenú de Social · planificación RRSS', + parentId: 'social', + }, + { + id: 'pipeline', + href: '/dashboard/creatives', + label: 'Pipeline RRSS', + providers: ['instagram', 'facebook', 'tiktok', 'linkedin', 'google_drive'], + hint: 'Submenú de Social · piezas orgánicas a publicar', + parentId: 'social', + }, { id: 'organico', href: '/dashboard/organico', @@ -148,9 +160,39 @@ export function suggestModulesFromIntegrations( } // Always keep Métricas if any paid/analytics, else still on as home if (!out.kpis) out.kpis = true; + // Nested Social tools follow Social when suggested + if (out.social) { + if (out.calendario === undefined) out.calendario = true; + if (out.pipeline === undefined) out.pipeline = true; + } return out; } +/** Top-level nav modules (exclude nested sub-items). */ +export function topLevelDashboardModules(): DashboardModuleDef[] { + return DASHBOARD_MODULES.filter((m) => !m.parentId); +} + +/** Nested toggles under a parent (e.g. Social). */ +export function childDashboardModules( + parentId: DashboardModuleId +): DashboardModuleDef[] { + return DASHBOARD_MODULES.filter((m) => m.parentId === parentId); +} + +/** Resolve module id for a dashboard pathname (for access guards). */ +export function moduleIdForPath(pathname: string): DashboardModuleId | null { + const path = pathname.split('?')[0] || pathname; + // Longer hrefs first so /dashboard/creatives wins over /dashboard + const ranked = [...DASHBOARD_MODULES].sort( + (a, b) => b.href.length - a.href.length + ); + for (const m of ranked) { + if (path === m.href || path.startsWith(m.href + '/')) return m.id; + } + return null; +} + export function resolveTenantModules( settings: TenantSettings | null | undefined, integrations: { provider: string; status?: string; enabled?: boolean }[] @@ -172,9 +214,10 @@ export function isModuleEnabled( href: string ): boolean { if (!modules) return true; - const def = DASHBOARD_MODULES.find( - (m) => href === m.href || href.startsWith(m.href + '/') - ); - if (!def) return true; - return modules[def.id] !== false; + const id = moduleIdForPath(href); + if (!id) return true; + if (modules[id] === false) return false; + const def = DASHBOARD_MODULES.find((m) => m.id === id); + if (def?.parentId && modules[def.parentId] === false) return false; + return true; }