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 <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-13 17:52:55 +02:00
parent d3e383dd08
commit fd2bd9582d
5 changed files with 278 additions and 81 deletions

View File

@@ -13,6 +13,7 @@ import {
} from '@/lib/agency'; } from '@/lib/agency';
import DashboardShell from '@/components/DashboardShell'; import DashboardShell from '@/components/DashboardShell';
import DashboardProviders from '@/components/DashboardProviders'; import DashboardProviders from '@/components/DashboardProviders';
import ModuleAccessGuard from '@/components/ModuleAccessGuard';
import { import {
DashboardModuleId, DashboardModuleId,
TenantSettings, TenantSettings,
@@ -82,6 +83,10 @@ export default async function DashboardLayout({
return ( return (
<DashboardProviders> <DashboardProviders>
<ModuleAccessGuard
isAgency={isAgency}
enabledModules={enabledModules}
/>
<DashboardShell <DashboardShell
email={email} email={email}
company={company} company={company}

View File

@@ -20,13 +20,16 @@ import ClientIntegrationsPanel from '@/components/ClientIntegrationsPanel';
import ClientCreativeStylePanel from '@/components/ClientCreativeStylePanel'; import ClientCreativeStylePanel from '@/components/ClientCreativeStylePanel';
import { PageHeader, SegmentedControl } from '@/components/ui'; import { PageHeader, SegmentedControl } from '@/components/ui';
import { import {
DASHBOARD_MODULES,
DashboardModuleId, DashboardModuleId,
TenantModuleMap, TenantModuleMap,
TenantSettings, TenantSettings,
childDashboardModules,
defaultModulesAllOn,
resolveTenantModules, resolveTenantModules,
suggestModulesFromIntegrations, suggestModulesFromIntegrations,
topLevelDashboardModules,
} from '@/lib/tenant-modules'; } from '@/lib/tenant-modules';
import { previewMenuLabels } from '@/components/ModuleAccessGuard';
type Tab = 'general' | 'estilo' | 'conexiones' | 'accesos' | 'modulos'; type Tab = 'general' | 'estilo' | 'conexiones' | 'accesos' | 'modulos';
@@ -308,7 +311,7 @@ export default function ClientFicha({
{ id: 'estilo', label: 'Estilo Creativo (IA & Ads)' }, { id: 'estilo', label: 'Estilo Creativo (IA & Ads)' },
{ id: 'conexiones', label: 'Conexiones' }, { id: 'conexiones', label: 'Conexiones' },
{ id: 'accesos', label: 'Accesos' }, { id: 'accesos', label: 'Accesos' },
{ id: 'modulos', label: 'Módulos' }, { id: 'modulos', label: 'Menú cliente' },
]} ]}
/> />
@@ -523,70 +526,145 @@ export default function ClientFicha({
{tab === 'modulos' && ( {tab === 'modulos' && (
<section className="space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <section className="space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div className="flex flex-col lg:flex-row lg:items-start justify-between gap-4">
<div> <div>
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900"> <div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
<Shield className="w-4 h-4 text-brand-600" /> <Shield className="w-4 h-4 text-brand-600" />
Módulos del dash cliente Menú del portal cliente
</div> </div>
<p className="text-xs text-gray-600 mt-1"> <p className="text-xs text-gray-600 mt-1 max-w-xl">
Qué ve el cliente en el menú. Sugerencia según Meta / Google / Marca o desmarca qué ve este cliente en el menú izquierdo. La
RRSS / CRM conectados. agencia sigue viendo todo el menú al operar; el filtro solo
aplica a usuarios con rol cliente.
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2 shrink-0">
<button
type="button"
onClick={() => setModules(defaultModulesAllOn())}
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50"
>
Marcar todo
</button>
<button
type="button"
onClick={() => {
const off = defaultModulesAllOn();
for (const k of Object.keys(off) as DashboardModuleId[]) {
off[k] = k === 'kpis';
}
setModules(off);
}}
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50"
>
Solo Métricas
</button>
<button <button
type="button" type="button"
onClick={applySuggested} onClick={applySuggested}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-brand-200 text-brand-700 bg-brand-50 hover:bg-brand-100" className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-brand-200 text-brand-700 bg-brand-50 hover:bg-brand-100"
> >
<Sparkles className="w-3.5 h-3.5" /> <Sparkles className="w-3.5 h-3.5" />
Autocompletar por conexiones Por conexiones
</button> </button>
</div> </div>
</div>
<ul className="space-y-2"> <ul className="space-y-2">
{DASHBOARD_MODULES.map((m) => { {topLevelDashboardModules().map((m) => {
const on = modules[m.id] !== false; const on = modules[m.id] !== false;
const children = childDashboardModules(m.id);
return ( return (
<li <li key={m.id} className="space-y-1.5">
key={m.id} <label
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 ${ className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 cursor-pointer ${
on on
? 'border-gray-200 bg-white' ? 'border-gray-200 bg-white'
: 'border-gray-100 bg-gray-50 opacity-80' : 'border-gray-100 bg-gray-50 opacity-80'
}`} }`}
> >
<div> <div className="min-w-0">
<p className="text-sm font-semibold text-gray-900"> <p className="text-sm font-semibold text-gray-900">
{m.label} {m.label}
</p> </p>
<p className="text-[11px] text-gray-600">{m.hint}</p> <p className="text-[11px] text-gray-600">{m.hint}</p>
{m.providers.length ? (
<p className="text-[10px] text-gray-500 mt-0.5">
Suele ir con: {m.providers.join(', ')}
</p>
) : null}
</div> </div>
<button <input
type="button" type="checkbox"
role="switch" className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 shrink-0"
aria-checked={on} checked={on}
onClick={() => onChange={() => {
setModules((prev) => ({ ...prev, [m.id]: !on })) setModules((prev) => {
const next = { ...prev, [m.id]: !on };
if (on) {
for (const c of children) next[c.id] = false;
} }
className={`relative h-7 w-12 rounded-full transition-colors ${ return next;
on ? 'bg-brand-500' : 'bg-gray-300' });
}}
/>
</label>
{children.length > 0 && on
? children.map((c) => {
const childOn = modules[c.id] !== false;
return (
<label
key={c.id}
className={`ml-6 flex items-center justify-between gap-3 rounded-xl border px-4 py-2.5 cursor-pointer ${
childOn
? 'border-gray-200 bg-white'
: 'border-gray-100 bg-gray-50 opacity-80'
}`} }`}
> >
<span <div className="min-w-0">
className={`absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform ${ <p className="text-xs font-semibold text-gray-900">
on ? 'translate-x-5' : '' ↳ {c.label}
}`} </p>
<p className="text-[10px] text-gray-600">
{c.hint}
</p>
</div>
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 shrink-0"
checked={childOn}
onChange={() =>
setModules((prev) => ({
...prev,
[c.id]: !childOn,
}))
}
/> />
</button> </label>
);
})
: null}
</li> </li>
); );
})} })}
</ul> </ul>
<div className="rounded-xl border border-dashed border-brand-200 bg-brand-50/40 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-brand-800 mb-1.5">
Vista previa · menú del cliente
</p>
<ul className="flex flex-wrap gap-1.5">
{previewMenuLabels(modules).map((label) => (
<li
key={label}
className="rounded-md border border-brand-100 bg-white px-2 py-0.5 text-[11px] text-gray-800"
>
{label}
</li>
))}
</ul>
{!previewMenuLabels(modules).length ? (
<p className="text-xs text-amber-700">
Ningún módulo activo — el cliente no verá entradas en el menú.
</p>
) : null}
</div>
<button <button
type="button" type="button"
disabled={saving} disabled={saving}
@@ -598,7 +676,7 @@ export default function ClientFicha({
) : ( ) : (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
)} )}
Guardar módulos Guardar menú del cliente
</button> </button>
</section> </section>
)} )}

View File

@@ -180,11 +180,13 @@ export default function DashboardShell({
const dateRange = useDateRange(); const dateRange = useDateRange();
const pathname = usePathname(); const pathname = usePathname();
const visibleNav = NAV_ITEMS.filter((item) => { const moduleOn = (id: DashboardModuleId) => {
if (isAgency) return true; if (isAgency) return true;
if (!enabledModules) 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 showClientBrand = Boolean(clientLogoUrl);
const title = showClientBrand ? brandLabel || company : '4VENTURES'; const title = showClientBrand ? brandLabel || company : '4VENTURES';
@@ -249,6 +251,7 @@ export default function DashboardShell({
<NavLink item={item} onNavigate={close} /> <NavLink item={item} onNavigate={close} />
{underSocial ? ( {underSocial ? (
<> <>
{moduleOn('calendario') ? (
<Link <Link
href="/dashboard/calendario" href="/dashboard/calendario"
onClick={close} onClick={close}
@@ -257,6 +260,8 @@ export default function DashboardShell({
<CalendarDays className="w-3.5 h-3.5 shrink-0 text-pink-500" /> <CalendarDays className="w-3.5 h-3.5 shrink-0 text-pink-500" />
Calendario editorial Calendario editorial
</Link> </Link>
) : null}
{moduleOn('pipeline') ? (
<Link <Link
href="/dashboard/creatives" href="/dashboard/creatives"
onClick={close} onClick={close}
@@ -265,6 +270,7 @@ export default function DashboardShell({
<Images className="w-3.5 h-3.5 shrink-0 text-fuchsia-500" /> <Images className="w-3.5 h-3.5 shrink-0 text-fuchsia-500" />
Pipeline RRSS Pipeline RRSS
</Link> </Link>
) : null}
</> </>
) : null} ) : null}
</div> </div>

View File

@@ -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<DashboardModuleId, boolean> | 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<DashboardModuleId, boolean>
): 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;
}

View File

@@ -7,6 +7,7 @@ export type DashboardModuleId =
| 'estrategia' | 'estrategia'
| 'marca' | 'marca'
| 'calendario' | 'calendario'
| 'pipeline'
| 'ad-tracker' | 'ad-tracker'
| 'social' | 'social'
| 'organico' | 'organico'
@@ -21,6 +22,8 @@ export type DashboardModuleDef = {
/** Integration providers that typically unlock this module. */ /** Integration providers that typically unlock this module. */
providers: string[]; providers: string[];
hint: 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[] = [ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
@@ -59,13 +62,6 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
providers: [], providers: [],
hint: 'Brandbook: voz, oferta, ads, LinkedIn, blog', 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', id: 'ad-tracker',
href: '/dashboard/ad-tracker', href: '/dashboard/ad-tracker',
@@ -80,6 +76,22 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
providers: ['instagram', 'facebook', 'tiktok', 'linkedin'], providers: ['instagram', 'facebook', 'tiktok', 'linkedin'],
hint: 'RRSS orgánicas: perfiles, alcance y contenido', 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', id: 'organico',
href: '/dashboard/organico', href: '/dashboard/organico',
@@ -148,9 +160,39 @@ export function suggestModulesFromIntegrations(
} }
// Always keep Métricas if any paid/analytics, else still on as home // Always keep Métricas if any paid/analytics, else still on as home
if (!out.kpis) out.kpis = true; 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; 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( export function resolveTenantModules(
settings: TenantSettings | null | undefined, settings: TenantSettings | null | undefined,
integrations: { provider: string; status?: string; enabled?: boolean }[] integrations: { provider: string; status?: string; enabled?: boolean }[]
@@ -172,9 +214,10 @@ export function isModuleEnabled(
href: string href: string
): boolean { ): boolean {
if (!modules) return true; if (!modules) return true;
const def = DASHBOARD_MODULES.find( const id = moduleIdForPath(href);
(m) => href === m.href || href.startsWith(m.href + '/') if (!id) return true;
); if (modules[id] === false) return false;
if (!def) return true; const def = DASHBOARD_MODULES.find((m) => m.id === id);
return modules[def.id] !== false; if (def?.parentId && modules[def.parentId] === false) return false;
return true;
} }