feat: ficha por cliente en portafolio (conexiones, accesos, logo, módulos)
Cada tenant tiene una ficha con plataformas, invitaciones por email, logo de marca y toggles de módulos visibles en el portal cliente. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
91
scripts/dokploy_apply_tenant_ficha.py
Normal file
91
scripts/dokploy_apply_tenant_ficha.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply sql/tenant_ficha.sql via Dokploy supabase-db terminal."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||||
|
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||||
|
SQL = ROOT / "sql" / "tenant_ficha.sql"
|
||||||
|
BASE = "https://dokploy.4ventures.es"
|
||||||
|
|
||||||
|
|
||||||
|
def env(key: str) -> str:
|
||||||
|
v = os.environ.get(key, "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
p = BPS / ".env"
|
||||||
|
if not p.is_file():
|
||||||
|
return ""
|
||||||
|
for line in p.read_text().splitlines():
|
||||||
|
if line.startswith(f"{key}="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||||
|
password = env("DOKPLOY_PASSWORD")
|
||||||
|
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||||
|
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||||
|
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
if "/dashboard" not in page.url:
|
||||||
|
page.locator('input[type="email"], input[name="email"]').first.fill(email)
|
||||||
|
page.locator('input[type="password"]').first.fill(password)
|
||||||
|
page.locator('button[type="submit"]').first.click()
|
||||||
|
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
|
||||||
|
print("login ok", flush=True)
|
||||||
|
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||||
|
"supabase-db"
|
||||||
|
)
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
page.get_by_text("Terminal", exact=True).click()
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
if page.locator("button", has_text="Bash").count():
|
||||||
|
page.locator("button", has_text="Bash").first.click()
|
||||||
|
page.wait_for_timeout(2800)
|
||||||
|
if page.locator("textarea.xterm-helper-textarea").count():
|
||||||
|
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||||
|
|
||||||
|
def run(cmd: str, wait: int = 800) -> None:
|
||||||
|
page.keyboard.type(cmd, delay=1)
|
||||||
|
page.keyboard.press("Enter")
|
||||||
|
page.wait_for_timeout(wait)
|
||||||
|
|
||||||
|
run("rm -f /tmp/tf.b64 /tmp/tf.sql; echo READY", 1200)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/tf.b64", 400)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/tf.b64 > /tmp/tf.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/tf.sql; echo EXIT:$?",
|
||||||
|
8000,
|
||||||
|
)
|
||||||
|
text = page.evaluate(
|
||||||
|
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||||
|
)
|
||||||
|
browser.close()
|
||||||
|
ok = "EXIT:0" in text
|
||||||
|
print("SQL_OK", ok, flush=True)
|
||||||
|
return 0 if ok else 6
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
13
sql/tenant_ficha.sql
Normal file
13
sql/tenant_ficha.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Per-tenant ficha: logo + dashboard module settings
|
||||||
|
-- Idempotent.
|
||||||
|
|
||||||
|
alter table public.tenants
|
||||||
|
add column if not exists logo_url text;
|
||||||
|
|
||||||
|
alter table public.tenants
|
||||||
|
add column if not exists settings jsonb not null default '{}'::jsonb;
|
||||||
|
|
||||||
|
comment on column public.tenants.logo_url is
|
||||||
|
'Client logo for portal shell (URL or storage path under tenant-assets).';
|
||||||
|
comment on column public.tenants.settings is
|
||||||
|
'JSON: { modules: { kpis, crm, seguimiento, estrategia, calendario, ad-tracker, activos, creatives, asistente }, branding: {} }';
|
||||||
132
src/app/api/admin/tenants/logo/route.ts
Normal file
132
src/app/api/admin/tenants/logo/route.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import {
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
|
import {
|
||||||
|
ASSETS_BUCKET,
|
||||||
|
ensureAssetsBucket,
|
||||||
|
sanitizeFileName,
|
||||||
|
serviceSupabase,
|
||||||
|
} from '@/lib/assets';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const MAX_LOGO = 2 * 1024 * 1024; // 2 MB
|
||||||
|
const ALLOWED = new Set([
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/webp',
|
||||||
|
'image/svg+xml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function requireAgency() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||||
|
}
|
||||||
|
if (!isAgencyRole(userRole(user))) {
|
||||||
|
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (!headers) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST multipart: tenantId + file — upload logo and set tenants.logo_url */
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const gate = await requireAgency();
|
||||||
|
if ('error' in gate && gate.error) return gate.error;
|
||||||
|
const { headers } = gate as {
|
||||||
|
headers: { apikey: string; Authorization: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const tenantId = String(form.get('tenantId') || '').trim();
|
||||||
|
const file = form.get('file');
|
||||||
|
if (!tenantId || !(file instanceof File)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'tenantId y file requeridos' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (file.size > MAX_LOGO) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Logo máximo 2 MB' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mime = file.type || 'application/octet-stream';
|
||||||
|
if (!ALLOWED.has(mime)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Usa PNG, JPG, WEBP o SVG' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketOk = await ensureAssetsBucket();
|
||||||
|
if (!bucketOk.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: bucketOk.error || 'bucket' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sb = serviceSupabase();
|
||||||
|
if (!sb) {
|
||||||
|
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = sanitizeFileName(file.name || 'logo.png');
|
||||||
|
const path = `${tenantId}/branding/${randomUUID()}_${safe}`;
|
||||||
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
const { error: upErr } = await sb.storage
|
||||||
|
.from(ASSETS_BUCKET)
|
||||||
|
.upload(path, buf, { contentType: mime, upsert: true });
|
||||||
|
if (upErr) {
|
||||||
|
return NextResponse.json({ error: upErr.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store storage path; served via /api/branding/logo
|
||||||
|
const logoUrl = `storage:${path}`;
|
||||||
|
const patch = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ logo_url: logoUrl }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!patch.ok) {
|
||||||
|
const msg = await patch.text();
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: msg.includes('logo_url')
|
||||||
|
? 'Aplicar sql/tenant_ficha.sql'
|
||||||
|
: msg.slice(0, 300),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
logoUrl,
|
||||||
|
serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
supabaseUrl,
|
supabaseUrl,
|
||||||
userRole,
|
userRole,
|
||||||
} from '@/lib/agency';
|
} from '@/lib/agency';
|
||||||
|
import type { TenantSettings } from '@/lib/tenant-modules';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -29,7 +30,74 @@ async function requireAgency() {
|
|||||||
return { headers };
|
return { headers };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PATCH — follow/unfollow client or set notes. */
|
/** GET ?tenantId= — full ficha payload */
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const gate = await requireAgency();
|
||||||
|
if ('error' in gate && gate.error) return gate.error;
|
||||||
|
const { headers } = gate as {
|
||||||
|
headers: { apikey: string; Authorization: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const tenantId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = supabaseUrl();
|
||||||
|
const [tRes, iRes] = await Promise.all([
|
||||||
|
fetch(
|
||||||
|
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=id,name,slug,logo_url,settings,agency_followed,agency_notes,created_at`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=provider,status,enabled,external_account_id,label,last_sync_at,error_message,meta`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!tRes.ok) {
|
||||||
|
const msg = await tRes.text();
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: msg.includes('logo_url') || msg.includes('settings')
|
||||||
|
? 'Aplicar sql/tenant_ficha.sql'
|
||||||
|
: msg.slice(0, 300),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenants = await tRes.json();
|
||||||
|
const tenant = tenants[0];
|
||||||
|
if (!tenant) {
|
||||||
|
return NextResponse.json({ error: 'Cliente no encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let integrations: unknown[] = [];
|
||||||
|
let integWarning: string | null = null;
|
||||||
|
if (iRes.ok) {
|
||||||
|
integrations = await iRes.json();
|
||||||
|
} else {
|
||||||
|
integWarning = (await iRes.text()).slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tenant: {
|
||||||
|
id: tenant.id,
|
||||||
|
name: tenant.name,
|
||||||
|
slug: tenant.slug,
|
||||||
|
logoUrl: tenant.logo_url || null,
|
||||||
|
settings: (tenant.settings || {}) as TenantSettings,
|
||||||
|
followed: tenant.agency_followed !== false,
|
||||||
|
notes: tenant.agency_notes || '',
|
||||||
|
createdAt: tenant.created_at,
|
||||||
|
},
|
||||||
|
integrations,
|
||||||
|
warning: integWarning,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH — follow/notes + ficha fields (name, logoUrl, settings). */
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
const gate = await requireAgency();
|
const gate = await requireAgency();
|
||||||
if ('error' in gate && gate.error) return gate.error;
|
if ('error' in gate && gate.error) return gate.error;
|
||||||
@@ -51,9 +119,22 @@ export async function PATCH(req: NextRequest) {
|
|||||||
if (typeof body.notes === 'string') {
|
if (typeof body.notes === 'string') {
|
||||||
patch.agency_notes = body.notes.trim() || null;
|
patch.agency_notes = body.notes.trim() || null;
|
||||||
}
|
}
|
||||||
|
if (typeof body.name === 'string' && body.name.trim()) {
|
||||||
|
patch.name = body.name.trim();
|
||||||
|
}
|
||||||
|
if (typeof body.logoUrl === 'string') {
|
||||||
|
patch.logo_url = body.logoUrl.trim() || null;
|
||||||
|
}
|
||||||
|
if (body.logoUrl === null) {
|
||||||
|
patch.logo_url = null;
|
||||||
|
}
|
||||||
|
if (body.settings && typeof body.settings === 'object') {
|
||||||
|
patch.settings = body.settings as TenantSettings;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Object.keys(patch).length) {
|
if (!Object.keys(patch).length) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'followed o notes requeridos' },
|
{ error: 'Nada que actualizar' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,9 +155,12 @@ export async function PATCH(req: NextRequest) {
|
|||||||
const msg = await r.text();
|
const msg = await r.text();
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: msg.includes('agency_followed')
|
error:
|
||||||
? 'Aplicar sql/agency_control.sql'
|
msg.includes('logo_url') || msg.includes('settings')
|
||||||
: msg.slice(0, 300),
|
? 'Aplicar sql/tenant_ficha.sql en Supabase'
|
||||||
|
: msg.includes('agency_followed')
|
||||||
|
? 'Aplicar sql/agency_control.sql'
|
||||||
|
: msg.slice(0, 300),
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|||||||
257
src/app/api/admin/tenants/users/route.ts
Normal file
257
src/app/api/admin/tenants/users/route.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { createClient as createServiceClient } from '@supabase/supabase-js';
|
||||||
|
import {
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
async function requireAgency() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||||
|
}
|
||||||
|
if (!isAgencyRole(userRole(user))) {
|
||||||
|
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||||
|
if (!key) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const admin = createServiceClient(supabaseUrl(), key, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
return { admin, headers: serviceHeaders()! };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tenantOf(u: {
|
||||||
|
app_metadata?: Record<string, unknown>;
|
||||||
|
user_metadata?: Record<string, unknown>;
|
||||||
|
}): string | null {
|
||||||
|
return (
|
||||||
|
(u.app_metadata?.tenant_id as string) ||
|
||||||
|
(u.user_metadata?.tenant_id as string) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET ?tenantId= — users with access to this client portal. */
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const gate = await requireAgency();
|
||||||
|
if ('error' in gate && gate.error) return gate.error;
|
||||||
|
const { admin } = gate as Awaited<ReturnType<typeof requireAgency>> & {
|
||||||
|
admin: ReturnType<typeof createServiceClient>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tenantId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const users: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastSignIn: string | null;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
let page = 1;
|
||||||
|
for (;;) {
|
||||||
|
const { data, error } = await admin.auth.admin.listUsers({
|
||||||
|
page,
|
||||||
|
perPage: 200,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
for (const u of data.users) {
|
||||||
|
if (tenantOf(u) !== tenantId) continue;
|
||||||
|
users.push({
|
||||||
|
id: u.id,
|
||||||
|
email: u.email || '',
|
||||||
|
role:
|
||||||
|
(u.app_metadata?.role as string) ||
|
||||||
|
(u.user_metadata?.role as string) ||
|
||||||
|
'client',
|
||||||
|
createdAt: u.created_at,
|
||||||
|
lastSignIn: u.last_sign_in_at || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.users.length < 200) break;
|
||||||
|
page += 1;
|
||||||
|
if (page > 20) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
users.sort((a, b) => a.email.localeCompare(b.email));
|
||||||
|
return NextResponse.json({ users });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST — invite email to client portal view.
|
||||||
|
* Body: { tenantId, email, companyName? }
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const gate = await requireAgency();
|
||||||
|
if ('error' in gate && gate.error) return gate.error;
|
||||||
|
const { admin, headers } = gate as Awaited<
|
||||||
|
ReturnType<typeof requireAgency>
|
||||||
|
> & {
|
||||||
|
admin: ReturnType<typeof createServiceClient>;
|
||||||
|
headers: { apikey: string; Authorization: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const tenantId =
|
||||||
|
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
|
||||||
|
const email =
|
||||||
|
typeof body.email === 'string' ? body.email.toLowerCase().trim() : '';
|
||||||
|
if (!tenantId || !email || !email.includes('@')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'tenantId y email válidos requeridos' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let company =
|
||||||
|
typeof body.companyName === 'string' ? body.companyName.trim() : '';
|
||||||
|
if (!company) {
|
||||||
|
const tr = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=name`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (tr.ok) {
|
||||||
|
const rows = await tr.json();
|
||||||
|
company = rows[0]?.name || 'Cliente';
|
||||||
|
} else {
|
||||||
|
company = 'Cliente';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const site =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL?.trim() || 'https://app.4ventures.es';
|
||||||
|
|
||||||
|
const { data: invited, error: inviteErr } =
|
||||||
|
await admin.auth.admin.inviteUserByEmail(email, {
|
||||||
|
redirectTo: `${site}/login`,
|
||||||
|
data: {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
role: 'client',
|
||||||
|
company,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inviteErr) {
|
||||||
|
// Fallback: create user with temp password if invite not configured
|
||||||
|
if (
|
||||||
|
inviteErr.message.toLowerCase().includes('sending') ||
|
||||||
|
inviteErr.message.toLowerCase().includes('smtp') ||
|
||||||
|
inviteErr.message.toLowerCase().includes('email')
|
||||||
|
) {
|
||||||
|
const temp =
|
||||||
|
typeof body.password === 'string' && body.password.length >= 8
|
||||||
|
? body.password
|
||||||
|
: `4V-${Math.random().toString(36).slice(2, 10)}!aA1`;
|
||||||
|
const { data: created, error: createErr } =
|
||||||
|
await admin.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password: temp,
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
role: 'client',
|
||||||
|
company,
|
||||||
|
},
|
||||||
|
app_metadata: {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
role: 'client',
|
||||||
|
company,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (createErr) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: createErr.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: created.user?.id,
|
||||||
|
email,
|
||||||
|
role: 'client',
|
||||||
|
},
|
||||||
|
invited: false,
|
||||||
|
temporaryPassword: temp,
|
||||||
|
hint: 'SMTP no configurado: se creó usuario con contraseña temporal. Compártela al cliente.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: inviteErr.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = invited.user?.id;
|
||||||
|
if (uid) {
|
||||||
|
await admin.auth.admin.updateUserById(uid, {
|
||||||
|
app_metadata: {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
role: 'client',
|
||||||
|
company,
|
||||||
|
},
|
||||||
|
user_metadata: {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
role: 'client',
|
||||||
|
company,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: { id: uid, email, role: 'client' },
|
||||||
|
invited: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE — remove portal access (delete auth user). Body: { tenantId, userId } */
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const gate = await requireAgency();
|
||||||
|
if ('error' in gate && gate.error) return gate.error;
|
||||||
|
const { admin } = gate as Awaited<ReturnType<typeof requireAgency>> & {
|
||||||
|
admin: ReturnType<typeof createServiceClient>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const tenantId =
|
||||||
|
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
|
||||||
|
const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
|
||||||
|
if (!tenantId || !userId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'tenantId y userId requeridos' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await admin.auth.admin.getUserById(userId);
|
||||||
|
if (error || !data.user) {
|
||||||
|
return NextResponse.json({ error: 'Usuario no encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (tenantOf(data.user) !== tenantId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'El usuario no pertenece a este cliente' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const del = await admin.auth.admin.deleteUser(userId);
|
||||||
|
if (del.error) {
|
||||||
|
return NextResponse.json({ error: del.error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
97
src/app/api/branding/logo/route.ts
Normal file
97
src/app/api/branding/logo/route.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import {
|
||||||
|
homeTenantId,
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
VIEW_TENANT_COOKIE,
|
||||||
|
} from '@/lib/agency';
|
||||||
|
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET ?tenantId= — stream client logo (agency any tenant; client only own).
|
||||||
|
* logo_url may be https://… or storage:{path}.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
|
||||||
|
const paramId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
|
||||||
|
const ownId = homeTenantId(user);
|
||||||
|
|
||||||
|
let tenantId = paramId || viewId || ownId || '';
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: 'sin tenant' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!agency && tenantId !== ownId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (!headers) {
|
||||||
|
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tr = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=logo_url`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!tr.ok) {
|
||||||
|
return NextResponse.json({ error: 'tenant' }, { status: 404 });
|
||||||
|
}
|
||||||
|
const rows = await tr.json();
|
||||||
|
const logoUrl: string | null = rows[0]?.logo_url || null;
|
||||||
|
if (!logoUrl) {
|
||||||
|
return NextResponse.json({ error: 'sin logo' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoUrl.startsWith('http://') || logoUrl.startsWith('https://')) {
|
||||||
|
return NextResponse.redirect(logoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = logoUrl.startsWith('storage:')
|
||||||
|
? logoUrl.slice('storage:'.length)
|
||||||
|
: logoUrl;
|
||||||
|
|
||||||
|
const sb = serviceSupabase();
|
||||||
|
if (!sb) {
|
||||||
|
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||||
|
}
|
||||||
|
const { data, error } = await sb.storage.from(ASSETS_BUCKET).download(path);
|
||||||
|
if (error || !data) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error?.message || 'not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await data.arrayBuffer());
|
||||||
|
const ext = path.split('.').pop()?.toLowerCase();
|
||||||
|
const type =
|
||||||
|
ext === 'png'
|
||||||
|
? 'image/png'
|
||||||
|
: ext === 'webp'
|
||||||
|
? 'image/webp'
|
||||||
|
: ext === 'svg'
|
||||||
|
? 'image/svg+xml'
|
||||||
|
: 'image/jpeg';
|
||||||
|
return new NextResponse(buf, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': type,
|
||||||
|
'Cache-Control': 'private, max-age=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ClientIntegrationsPanel from '@/components/ClientIntegrationsPanel';
|
import ClientFicha from '@/components/ClientFicha';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function ClienteIntegrationsPage({
|
export default async function ClienteFichaPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -37,5 +37,5 @@ export default async function ClienteIntegrationsPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ClientIntegrationsPanel tenantId={id} tenantName={name} />;
|
return <ClientFicha tenantId={id} initialName={name} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,21 @@ import React from 'react';
|
|||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { logoutAction } from '@/app/auth/actions';
|
import { logoutAction } from '@/app/auth/actions';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { VIEW_TENANT_NAME_COOKIE } from '@/lib/agency';
|
import {
|
||||||
|
VIEW_TENANT_COOKIE,
|
||||||
|
VIEW_TENANT_NAME_COOKIE,
|
||||||
|
homeTenantId,
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
import DashboardShell from '@/components/DashboardShell';
|
import DashboardShell from '@/components/DashboardShell';
|
||||||
|
import {
|
||||||
|
DashboardModuleId,
|
||||||
|
TenantSettings,
|
||||||
|
resolveTenantModules,
|
||||||
|
} from '@/lib/tenant-modules';
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -15,13 +28,56 @@ export default async function DashboardLayout({
|
|||||||
data: { user },
|
data: { user },
|
||||||
} = await supabase.auth.getUser();
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
const role =
|
const role = userRole(user);
|
||||||
user?.user_metadata?.role || user?.app_metadata?.role || 'client';
|
const company =
|
||||||
const company = user?.user_metadata?.company || '4Ventures Central';
|
(user?.user_metadata?.company as string) ||
|
||||||
|
(user?.app_metadata?.company as string) ||
|
||||||
|
'4Ventures Central';
|
||||||
const email = user?.email || 'usuario@4ventures.es';
|
const email = user?.email || 'usuario@4ventures.es';
|
||||||
const isAgency = role === 'admin' || role === 'team';
|
const isAgency = isAgencyRole(role);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value;
|
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value;
|
||||||
|
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
|
||||||
|
const ownId = homeTenantId(user);
|
||||||
|
|
||||||
|
let enabledModules: Record<DashboardModuleId, boolean> | null = null;
|
||||||
|
let clientLogoUrl: string | null = null;
|
||||||
|
let brandLabel = company;
|
||||||
|
|
||||||
|
// Client users (and agency zoomed into a client for branding): load ficha settings
|
||||||
|
const brandingTenantId = isAgency ? viewId : ownId;
|
||||||
|
if (brandingTenantId) {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (headers) {
|
||||||
|
const [tRes, iRes] = await Promise.all([
|
||||||
|
fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(brandingTenantId)}&select=name,logo_url,settings`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(brandingTenantId)}&select=provider,status,enabled`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (tRes.ok) {
|
||||||
|
const rows = await tRes.json();
|
||||||
|
const t = rows[0];
|
||||||
|
if (t) {
|
||||||
|
brandLabel = t.name || brandLabel;
|
||||||
|
if (t.logo_url) {
|
||||||
|
clientLogoUrl = `/api/branding/logo?tenantId=${encodeURIComponent(brandingTenantId)}`;
|
||||||
|
}
|
||||||
|
const integrations = iRes.ok ? await iRes.json() : [];
|
||||||
|
if (!isAgency) {
|
||||||
|
enabledModules = resolveTenantModules(
|
||||||
|
(t.settings || {}) as TenantSettings,
|
||||||
|
integrations || []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell
|
<DashboardShell
|
||||||
@@ -30,6 +86,11 @@ export default async function DashboardLayout({
|
|||||||
role={role}
|
role={role}
|
||||||
isAgency={isAgency}
|
isAgency={isAgency}
|
||||||
viewName={viewName}
|
viewName={viewName}
|
||||||
|
enabledModules={enabledModules}
|
||||||
|
clientLogoUrl={
|
||||||
|
isAgency && viewId ? clientLogoUrl : !isAgency ? clientLogoUrl : null
|
||||||
|
}
|
||||||
|
brandLabel={brandLabel}
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -345,9 +345,9 @@ export default function AgencyPortfolioBoard() {
|
|||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/agencia/cliente/${t.id}`}
|
href={`/dashboard/agencia/cliente/${t.id}`}
|
||||||
className="inline-flex items-center gap-1 text-xs font-medium px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:border-gray-500 hover:text-gray-900"
|
className="inline-flex items-center gap-1 text-xs font-medium px-3 py-1.5 rounded-lg border border-gray-200 text-gray-700 hover:border-brand-400 hover:text-brand-700 hover:bg-brand-50"
|
||||||
>
|
>
|
||||||
Conexiones
|
Ficha
|
||||||
<ExternalLink className="w-3 h-3" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
|
|||||||
594
src/components/ClientFicha.tsx
Normal file
594
src/components/ClientFicha.tsx
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Building2,
|
||||||
|
Cable,
|
||||||
|
Check,
|
||||||
|
ImageIcon,
|
||||||
|
Loader2,
|
||||||
|
Power,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import ClientIntegrationsPanel from '@/components/ClientIntegrationsPanel';
|
||||||
|
import { PageHeader, SegmentedControl } from '@/components/ui';
|
||||||
|
import {
|
||||||
|
DASHBOARD_MODULES,
|
||||||
|
DashboardModuleId,
|
||||||
|
TenantModuleMap,
|
||||||
|
TenantSettings,
|
||||||
|
resolveTenantModules,
|
||||||
|
suggestModulesFromIntegrations,
|
||||||
|
} from '@/lib/tenant-modules';
|
||||||
|
|
||||||
|
type Tab = 'general' | 'conexiones' | 'accesos' | 'modulos';
|
||||||
|
|
||||||
|
type Integ = {
|
||||||
|
provider: string;
|
||||||
|
status?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PortalUser = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastSignIn: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ClientFicha({
|
||||||
|
tenantId,
|
||||||
|
initialName,
|
||||||
|
}: {
|
||||||
|
tenantId: string;
|
||||||
|
initialName: string;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<Tab>('general');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [name, setName] = useState(initialName);
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [followed, setFollowed] = useState(true);
|
||||||
|
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||||
|
const [settings, setSettings] = useState<TenantSettings>({});
|
||||||
|
const [integrations, setIntegrations] = useState<Integ[]>([]);
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<PortalUser[]>([]);
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
|
const [inviteBusy, setInviteBusy] = useState(false);
|
||||||
|
const [tempPassword, setTempPassword] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [modules, setModules] = useState<Record<DashboardModuleId, boolean>>(
|
||||||
|
() => resolveTenantModules(null, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const logoSrc = logoUrl
|
||||||
|
? `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}&t=${encodeURIComponent(logoUrl)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const [fichaRes, usersRes] = await Promise.all([
|
||||||
|
fetch(`/api/admin/tenants?tenantId=${encodeURIComponent(tenantId)}`),
|
||||||
|
fetch(
|
||||||
|
`/api/admin/tenants/users?tenantId=${encodeURIComponent(tenantId)}`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const ficha = await fichaRes.json();
|
||||||
|
if (!fichaRes.ok) throw new Error(ficha.error || 'Error al cargar ficha');
|
||||||
|
setName(ficha.tenant.name || initialName);
|
||||||
|
setSlug(ficha.tenant.slug || '');
|
||||||
|
setNotes(ficha.tenant.notes || '');
|
||||||
|
setFollowed(ficha.tenant.followed !== false);
|
||||||
|
setLogoUrl(ficha.tenant.logoUrl || null);
|
||||||
|
setSettings(ficha.tenant.settings || {});
|
||||||
|
setIntegrations(ficha.integrations || []);
|
||||||
|
setModules(
|
||||||
|
resolveTenantModules(
|
||||||
|
ficha.tenant.settings || {},
|
||||||
|
ficha.integrations || []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (usersRes.ok) {
|
||||||
|
const u = await usersRes.json();
|
||||||
|
setUsers(u.users || []);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [tenantId, initialName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function patchTenant(body: Record<string, unknown>) {
|
||||||
|
setSaving(true);
|
||||||
|
setMsg(null);
|
||||||
|
setErr(null);
|
||||||
|
const res = await fetch('/api/admin/tenants', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tenantId, ...body }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setSaving(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(data.error || 'No se pudo guardar');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setMsg('Guardado');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGeneral() {
|
||||||
|
const ok = await patchTenant({ name, notes, followed });
|
||||||
|
if (ok) await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveModules() {
|
||||||
|
const next: TenantSettings = {
|
||||||
|
...settings,
|
||||||
|
modules: modules as TenantModuleMap,
|
||||||
|
modulesLocked: true,
|
||||||
|
};
|
||||||
|
const ok = await patchTenant({ settings: next });
|
||||||
|
if (ok) {
|
||||||
|
setSettings(next);
|
||||||
|
setMsg('Módulos del portal cliente actualizados');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySuggested() {
|
||||||
|
setModules(suggestModulesFromIntegrations(integrations));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLogoFile(file: File | null) {
|
||||||
|
if (!file) return;
|
||||||
|
setSaving(true);
|
||||||
|
setErr(null);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('tenantId', tenantId);
|
||||||
|
fd.set('file', file);
|
||||||
|
const res = await fetch('/api/admin/tenants/logo', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setSaving(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(data.error || 'Error al subir logo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLogoUrl(data.logoUrl);
|
||||||
|
setMsg('Logo actualizado — se muestra en la vista del cliente');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearLogo() {
|
||||||
|
const ok = await patchTenant({ logoUrl: null });
|
||||||
|
if (ok) setLogoUrl(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invite() {
|
||||||
|
if (!inviteEmail.includes('@')) return;
|
||||||
|
setInviteBusy(true);
|
||||||
|
setTempPassword(null);
|
||||||
|
setErr(null);
|
||||||
|
const res = await fetch('/api/admin/tenants/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenantId,
|
||||||
|
email: inviteEmail,
|
||||||
|
companyName: name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setInviteBusy(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(data.error || 'No se pudo dar acceso');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.temporaryPassword) {
|
||||||
|
setTempPassword(data.temporaryPassword);
|
||||||
|
setMsg(data.hint || 'Usuario creado con contraseña temporal');
|
||||||
|
} else {
|
||||||
|
setMsg(`Invitación enviada a ${inviteEmail}`);
|
||||||
|
}
|
||||||
|
setInviteEmail('');
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser(userId: string) {
|
||||||
|
if (!confirm('¿Quitar acceso de este usuario al portal?')) return;
|
||||||
|
const res = await fetch('/api/admin/tenants/users', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tenantId, userId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setErr(data.error || 'Error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterAsClient() {
|
||||||
|
await fetch('/api/admin/active-tenant', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tenantId }),
|
||||||
|
});
|
||||||
|
window.location.href = '/dashboard/kpis';
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedCount = useMemo(
|
||||||
|
() =>
|
||||||
|
integrations.filter(
|
||||||
|
(i) => i.enabled !== false && i.status === 'connected'
|
||||||
|
).length,
|
||||||
|
[integrations]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 py-16 justify-center">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Cargando ficha…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Portafolio · Ficha"
|
||||||
|
title={name}
|
||||||
|
description={`${slug || 'sin-slug'} · ${connectedCount} plataformas conectadas. Configura conexiones, accesos, logo y módulos visibles en la vista del cliente.`}
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/agencia"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
Portafolio
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void enterAsClient()}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-brand-500 text-white hover:bg-brand-600"
|
||||||
|
>
|
||||||
|
Entrar como cliente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(msg || err) && (
|
||||||
|
<p
|
||||||
|
className={`text-sm rounded-lg px-3 py-2 border ${
|
||||||
|
err
|
||||||
|
? 'bg-red-50 border-red-200 text-red-800'
|
||||||
|
: 'bg-emerald-50 border-emerald-200 text-emerald-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{err || msg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SegmentedControl
|
||||||
|
value={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
options={[
|
||||||
|
{ id: 'general', label: 'General' },
|
||||||
|
{ id: 'conexiones', label: 'Conexiones' },
|
||||||
|
{ id: 'accesos', label: 'Accesos' },
|
||||||
|
{ id: 'modulos', label: 'Módulos' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tab === 'general' && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<section className="lg:col-span-2 space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||||
|
<Building2 className="w-4 h-4 text-brand-600" />
|
||||||
|
Identidad
|
||||||
|
</div>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs font-semibold text-gray-600">
|
||||||
|
Nombre comercial
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs font-semibold text-gray-600">Slug</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-500 bg-gray-50"
|
||||||
|
value={slug}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs font-semibold text-gray-600">
|
||||||
|
Notas internas (agencia)
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 min-h-[88px]"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Brief, contactos, particularidades…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void saveGeneral()}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-semibold px-4 py-2 rounded-lg bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void patchTenant({ followed: !followed }).then((ok) => ok && setFollowed(!followed))}
|
||||||
|
className={`inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-2 rounded-lg border ${
|
||||||
|
followed
|
||||||
|
? 'border-emerald-300 text-emerald-700 bg-emerald-50'
|
||||||
|
: 'border-gray-200 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
{followed ? 'Activo en portafolio' : 'Archivado'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||||
|
<ImageIcon className="w-4 h-4 text-brand-600" />
|
||||||
|
Logo en vista cliente
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center h-28 rounded-xl border border-dashed border-gray-300 bg-gray-50 overflow-hidden">
|
||||||
|
{logoSrc ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={logoSrc}
|
||||||
|
alt={name}
|
||||||
|
className="max-h-24 max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500 px-4 text-center">
|
||||||
|
Sin logo — se muestra el de 4Ventures
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="sr-only">Subir logo</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
|
className="block w-full text-xs text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-1.5 file:text-xs file:font-semibold file:text-brand-700"
|
||||||
|
onChange={(e) => void onLogoFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{logoUrl ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void clearLogo()}
|
||||||
|
className="text-xs text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
Quitar logo
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<p className="text-[11px] text-gray-600 leading-relaxed">
|
||||||
|
PNG/JPG/WEBP/SVG · máx. 2 MB. Aparece en el menú lateral cuando el
|
||||||
|
cliente (o tú en su contexto) abre el portal.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'conexiones' && (
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-1 shadow-sm">
|
||||||
|
<div className="px-4 pt-4 flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||||
|
<Cable className="w-4 h-4 text-brand-600" />
|
||||||
|
Plataformas
|
||||||
|
</div>
|
||||||
|
<div className="p-4 pt-2">
|
||||||
|
<ClientIntegrationsPanel
|
||||||
|
tenantId={tenantId}
|
||||||
|
tenantName={name}
|
||||||
|
embedded
|
||||||
|
onChanged={() => void load()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'accesos' && (
|
||||||
|
<section className="space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||||
|
<Users className="w-4 h-4 text-brand-600" />
|
||||||
|
Accesos a la vista cliente
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Correos con rol <span className="font-semibold">client</span> ligados
|
||||||
|
a este tenant. Pueden entrar a app.4ventures.es y ver solo los
|
||||||
|
módulos que actives.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="email@cliente.com"
|
||||||
|
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={inviteBusy || !inviteEmail.includes('@')}
|
||||||
|
onClick={() => void invite()}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold px-4 py-2 rounded-lg bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{inviteBusy ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Dar acceso
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tempPassword ? (
|
||||||
|
<p className="text-xs rounded-lg bg-amber-50 border border-amber-200 text-amber-900 px-3 py-2">
|
||||||
|
Contraseña temporal:{' '}
|
||||||
|
<code className="font-mono font-bold">{tempPassword}</code> —
|
||||||
|
compártela al cliente (SMTP no disponible).
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<ul className="divide-y divide-gray-100 border border-gray-200 rounded-xl overflow-hidden">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<li className="px-4 py-8 text-center text-sm text-gray-500">
|
||||||
|
Nadie tiene acceso aún. Añade el primer email.
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
users.map((u) => (
|
||||||
|
<li
|
||||||
|
key={u.id}
|
||||||
|
className="flex items-center justify-between gap-3 px-4 py-3 bg-white hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||||
|
{u.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
{u.role} · creado{' '}
|
||||||
|
{new Date(u.createdAt).toLocaleDateString('es-ES')}
|
||||||
|
{u.lastSignIn
|
||||||
|
? ` · último login ${new Date(u.lastSignIn).toLocaleDateString('es-ES')}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void removeUser(u.id)}
|
||||||
|
className="p-2 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50"
|
||||||
|
title="Quitar acceso"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'modulos' && (
|
||||||
|
<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>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||||
|
<Shield className="w-4 h-4 text-brand-600" />
|
||||||
|
Módulos del dash cliente
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Qué ve el cliente en el menú. Sugerencia según Meta / Google /
|
||||||
|
RRSS / CRM conectados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
Autocompletar por conexiones
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{DASHBOARD_MODULES.map((m) => {
|
||||||
|
const on = modules[m.id] !== false;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={m.id}
|
||||||
|
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 ${
|
||||||
|
on
|
||||||
|
? 'border-gray-200 bg-white'
|
||||||
|
: 'border-gray-100 bg-gray-50 opacity-80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900">
|
||||||
|
{m.label}
|
||||||
|
</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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={on}
|
||||||
|
onClick={() =>
|
||||||
|
setModules((prev) => ({ ...prev, [m.id]: !on }))
|
||||||
|
}
|
||||||
|
className={`relative h-7 w-12 rounded-full transition-colors ${
|
||||||
|
on ? 'bg-brand-500' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform ${
|
||||||
|
on ? 'translate-x-5' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void saveModules()}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-semibold px-4 py-2 rounded-lg bg-brand-500 text-white hover:bg-brand-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Guardar módulos
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,9 +40,14 @@ const PLACEHOLDERS: Record<string, string> = {
|
|||||||
export default function ClientIntegrationsPanel({
|
export default function ClientIntegrationsPanel({
|
||||||
tenantId,
|
tenantId,
|
||||||
tenantName,
|
tenantName,
|
||||||
|
embedded = false,
|
||||||
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
tenantName: string;
|
tenantName: string;
|
||||||
|
/** Hide page chrome when rendered inside ClientFicha. */
|
||||||
|
embedded?: boolean;
|
||||||
|
onChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [integrations, setIntegrations] = useState<Integ[]>([]);
|
const [integrations, setIntegrations] = useState<Integ[]>([]);
|
||||||
const [providers, setProviders] = useState<Provider[]>([]);
|
const [providers, setProviders] = useState<Provider[]>([]);
|
||||||
@@ -80,10 +85,7 @@ export default function ClientIntegrationsPanel({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [tenantId]);
|
}, [tenantId]);
|
||||||
|
|
||||||
async function patch(
|
async function patch(provider: string, body: Record<string, unknown>) {
|
||||||
provider: string,
|
|
||||||
body: Record<string, unknown>
|
|
||||||
) {
|
|
||||||
setSaving(provider);
|
setSaving(provider);
|
||||||
await fetch('/api/admin/integrations', {
|
await fetch('/api/admin/integrations', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -91,6 +93,7 @@ export default function ClientIntegrationsPanel({
|
|||||||
body: JSON.stringify({ tenantId, provider, ...body }),
|
body: JSON.stringify({ tenantId, provider, ...body }),
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
|
onChanged?.();
|
||||||
setSaving(null);
|
setSaving(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,49 +119,52 @@ export default function ClientIntegrationsPanel({
|
|||||||
{ id: 'email', title: 'Email / lifecycle' },
|
{ id: 'email', title: 'Email / lifecycle' },
|
||||||
{ id: 'cms', title: 'Web / e‑commerce' },
|
{ id: 'cms', title: 'Web / e‑commerce' },
|
||||||
{ id: 'files', title: 'Archivos' },
|
{ id: 'files', title: 'Archivos' },
|
||||||
|
{ id: 'ops', title: 'Operaciones' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
{!embedded ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||||
<Link
|
<div className="flex items-center gap-3">
|
||||||
href="/dashboard/agencia"
|
<Link
|
||||||
className="text-gray-600 hover:text-gray-900"
|
href="/dashboard/agencia"
|
||||||
>
|
className="text-gray-600 hover:text-gray-900"
|
||||||
<ArrowLeft className="w-4 h-4" />
|
>
|
||||||
</Link>
|
<ArrowLeft className="w-4 h-4" />
|
||||||
<div>
|
</Link>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">
|
<div>
|
||||||
{tenantName}
|
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">
|
||||||
</h2>
|
{tenantName}
|
||||||
<p className="text-sm text-gray-400 mt-0.5">
|
</h2>
|
||||||
Hub de conexiones · asocia cuentas y decide qué entra a la
|
<p className="text-sm text-gray-600 mt-0.5">
|
||||||
plataforma
|
Hub de conexiones · asocia cuentas y decide qué entra a la
|
||||||
</p>
|
plataforma
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={followBusy}
|
||||||
|
onClick={() => void toggleFollow()}
|
||||||
|
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${
|
||||||
|
followed
|
||||||
|
? 'border-emerald-300 text-emerald-700 bg-emerald-50'
|
||||||
|
: 'border-gray-200 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{followBusy ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Power className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
{followed ? 'Cliente activo en portafolio' : 'Archivado'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
) : null}
|
||||||
type="button"
|
|
||||||
disabled={followBusy}
|
|
||||||
onClick={() => void toggleFollow()}
|
|
||||||
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${
|
|
||||||
followed
|
|
||||||
? 'border-emerald-500/40 text-emerald-400'
|
|
||||||
: 'border-gray-200 text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{followBusy ? (
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Power className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
{followed ? 'Cliente activo en portafolio' : 'Archivado'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{warning && (
|
{warning && (
|
||||||
<p className="text-xs text-amber-400 border border-amber-500/20 rounded-lg px-3 py-2">
|
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
|
||||||
{warning}
|
{warning}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -168,7 +174,7 @@ export default function ClientIntegrationsPanel({
|
|||||||
if (!list.length) return null;
|
if (!list.length) return null;
|
||||||
return (
|
return (
|
||||||
<section key={g.id} className="space-y-2">
|
<section key={g.id} className="space-y-2">
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-500">
|
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-600">
|
||||||
{g.title}
|
{g.title}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
@@ -186,7 +192,7 @@ export default function ClientIntegrationsPanel({
|
|||||||
className={`rounded-xl border p-4 space-y-3 ${
|
className={`rounded-xl border p-4 space-y-3 ${
|
||||||
enabled
|
enabled
|
||||||
? 'border-gray-200 bg-white'
|
? 'border-gray-200 bg-white'
|
||||||
: 'border-gray-200/50 bg-[#0d121c] opacity-70'
|
: 'border-gray-200 bg-gray-50 opacity-80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
@@ -195,7 +201,7 @@ export default function ClientIntegrationsPanel({
|
|||||||
{p.label}
|
{p.label}
|
||||||
</p>
|
</p>
|
||||||
{folderHint && (
|
{folderHint && (
|
||||||
<p className="text-[11px] text-brand-400 mt-0.5">
|
<p className="text-[11px] text-brand-700 mt-0.5">
|
||||||
Carpeta: {folderHint}
|
Carpeta: {folderHint}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -206,7 +212,7 @@ export default function ClientIntegrationsPanel({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{row?.error_message && (
|
{row?.error_message && (
|
||||||
<p className="text-[11px] text-red-400 mt-1">
|
<p className="text-[11px] text-red-600 mt-1">
|
||||||
{row.error_message}
|
{row.error_message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -215,13 +221,11 @@ export default function ClientIntegrationsPanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={saving === p.id}
|
disabled={saving === p.id}
|
||||||
onClick={() =>
|
onClick={() => void patch(p.id, { enabled: !enabled })}
|
||||||
void patch(p.id, { enabled: !enabled })
|
className={`text-[11px] font-semibold px-2.5 py-1 rounded-md border ${
|
||||||
}
|
|
||||||
className={`text-[11px] px-2.5 py-1 rounded-md border ${
|
|
||||||
enabled
|
enabled
|
||||||
? 'border-emerald-500/40 text-emerald-400'
|
? 'border-emerald-300 text-emerald-700 bg-emerald-50'
|
||||||
: 'border-gray-200 text-gray-500'
|
: 'border-gray-200 text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
title="Si está OFF, no cuenta en salud ni se usa"
|
title="Si está OFF, no cuenta en salud ni se usa"
|
||||||
>
|
>
|
||||||
@@ -239,10 +243,10 @@ export default function ClientIntegrationsPanel({
|
|||||||
externalAccountId: accountDraft[p.id] || '',
|
externalAccountId: accountDraft[p.id] || '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={`text-[11px] px-2.5 py-1 rounded-md border ${
|
className={`text-[11px] font-semibold px-2.5 py-1 rounded-md border ${
|
||||||
status === 'connected'
|
status === 'connected'
|
||||||
? 'border-brand-500/40 text-brand-400'
|
? 'border-brand-300 text-brand-700 bg-brand-50'
|
||||||
: 'border-gray-200 text-gray-400'
|
: 'border-gray-200 text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{status === 'connected' ? 'Desconectar' : 'Conectar'}
|
{status === 'connected' ? 'Desconectar' : 'Conectar'}
|
||||||
@@ -251,7 +255,7 @@ export default function ClientIntegrationsPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<input
|
<input
|
||||||
className="flex-1 text-xs rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-gray-900 placeholder:text-gray-600"
|
className="flex-1 text-xs rounded-lg bg-white border border-gray-200 px-3 py-2 text-gray-900 placeholder:text-gray-400"
|
||||||
placeholder={PLACEHOLDERS[p.id] || 'Account ID'}
|
placeholder={PLACEHOLDERS[p.id] || 'Account ID'}
|
||||||
value={accountDraft[p.id] || ''}
|
value={accountDraft[p.id] || ''}
|
||||||
disabled={!enabled}
|
disabled={!enabled}
|
||||||
@@ -267,11 +271,12 @@ export default function ClientIntegrationsPanel({
|
|||||||
disabled={saving === p.id || !enabled}
|
disabled={saving === p.id || !enabled}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void patch(p.id, {
|
void patch(p.id, {
|
||||||
status: status === 'disconnected' ? 'pending' : status,
|
status:
|
||||||
|
status === 'disconnected' ? 'pending' : status,
|
||||||
externalAccountId: accountDraft[p.id] || '',
|
externalAccountId: accountDraft[p.id] || '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="text-xs px-3 py-2 rounded-lg border border-gray-200 text-gray-600 hover:text-gray-900 hover:border-gray-500 inline-flex items-center gap-1"
|
className="text-xs font-semibold px-3 py-2 rounded-lg border border-gray-200 text-gray-700 hover:text-gray-900 hover:bg-gray-50 inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{saving === p.id ? (
|
{saving === p.id ? (
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import TenantSwitcher from '@/components/TenantSwitcher';
|
import TenantSwitcher from '@/components/TenantSwitcher';
|
||||||
|
import type { DashboardModuleId } from '@/lib/tenant-modules';
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
iconClass: string;
|
iconClass: string;
|
||||||
|
moduleId: DashboardModuleId;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
@@ -35,54 +37,63 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
label: 'Métricas',
|
label: 'Métricas',
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
iconClass: 'text-brand-500',
|
iconClass: 'text-brand-500',
|
||||||
|
moduleId: 'kpis',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/crm',
|
href: '/dashboard/crm',
|
||||||
label: 'CRM',
|
label: 'CRM',
|
||||||
icon: Contact,
|
icon: Contact,
|
||||||
iconClass: 'text-teal-500',
|
iconClass: 'text-teal-500',
|
||||||
|
moduleId: 'crm',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/seguimiento',
|
href: '/dashboard/seguimiento',
|
||||||
label: 'Seguimiento',
|
label: 'Seguimiento',
|
||||||
icon: Radio,
|
icon: Radio,
|
||||||
iconClass: 'text-sky-500',
|
iconClass: 'text-sky-500',
|
||||||
|
moduleId: 'seguimiento',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/estrategia',
|
href: '/dashboard/estrategia',
|
||||||
label: 'Objetivos del Mes',
|
label: 'Objetivos del Mes',
|
||||||
icon: Target,
|
icon: Target,
|
||||||
iconClass: 'text-emerald-500',
|
iconClass: 'text-emerald-500',
|
||||||
|
moduleId: 'estrategia',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/calendario',
|
href: '/dashboard/calendario',
|
||||||
label: 'Calendario Editorial',
|
label: 'Calendario Editorial',
|
||||||
icon: CalendarDays,
|
icon: CalendarDays,
|
||||||
iconClass: 'text-pink-500',
|
iconClass: 'text-pink-500',
|
||||||
|
moduleId: 'calendario',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/ad-tracker',
|
href: '/dashboard/ad-tracker',
|
||||||
label: 'Ad Tracker',
|
label: 'Ad Tracker',
|
||||||
icon: Megaphone,
|
icon: Megaphone,
|
||||||
iconClass: 'text-violet-500',
|
iconClass: 'text-violet-500',
|
||||||
|
moduleId: 'ad-tracker',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/activos',
|
href: '/dashboard/activos',
|
||||||
label: 'Biblioteca de Activos',
|
label: 'Biblioteca de Activos',
|
||||||
icon: FolderGit2,
|
icon: FolderGit2,
|
||||||
iconClass: 'text-blue-500',
|
iconClass: 'text-blue-500',
|
||||||
|
moduleId: 'activos',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/creatives',
|
href: '/dashboard/creatives',
|
||||||
label: 'Pipeline creatividades',
|
label: 'Pipeline creatividades',
|
||||||
icon: FolderGit2,
|
icon: FolderGit2,
|
||||||
iconClass: 'text-orange-500',
|
iconClass: 'text-orange-500',
|
||||||
|
moduleId: 'creatives',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/dashboard/asistente',
|
href: '/dashboard/asistente',
|
||||||
label: 'Asistente IA Financiero',
|
label: 'Asistente IA Financiero',
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
iconClass: 'text-purple-500',
|
iconClass: 'text-purple-500',
|
||||||
|
moduleId: 'asistente',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -96,7 +107,7 @@ function NavLink({
|
|||||||
item,
|
item,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: {
|
}: {
|
||||||
item: NavItem;
|
item: NavItem | (Omit<NavItem, 'moduleId'> & { moduleId?: string });
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -125,6 +136,9 @@ export default function DashboardShell({
|
|||||||
role,
|
role,
|
||||||
isAgency,
|
isAgency,
|
||||||
viewName,
|
viewName,
|
||||||
|
enabledModules,
|
||||||
|
clientLogoUrl,
|
||||||
|
brandLabel,
|
||||||
logoutAction,
|
logoutAction,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -133,28 +147,57 @@ export default function DashboardShell({
|
|||||||
role: string;
|
role: string;
|
||||||
isAgency: boolean;
|
isAgency: boolean;
|
||||||
viewName?: string;
|
viewName?: string;
|
||||||
|
/** null = show all (agency default). */
|
||||||
|
enabledModules?: Record<DashboardModuleId, boolean> | null;
|
||||||
|
clientLogoUrl?: string | null;
|
||||||
|
brandLabel?: string;
|
||||||
logoutAction: () => void | Promise<void>;
|
logoutAction: () => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const close = () => setOpen(false);
|
const close = () => setOpen(false);
|
||||||
|
|
||||||
|
const visibleNav = NAV_ITEMS.filter((item) => {
|
||||||
|
if (isAgency) return true;
|
||||||
|
if (!enabledModules) return true;
|
||||||
|
return enabledModules[item.moduleId] !== false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showClientBrand = Boolean(clientLogoUrl);
|
||||||
|
const title = showClientBrand ? brandLabel || company : '4VENTURES';
|
||||||
|
const subtitle = showClientBrand
|
||||||
|
? isAgency
|
||||||
|
? 'Vista cliente'
|
||||||
|
: 'Portal cliente'
|
||||||
|
: 'Centro de Mando';
|
||||||
|
|
||||||
const sidebar = (
|
const sidebar = (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center gap-3 px-2 py-3 border-b border-gray-200 mb-5">
|
<div className="flex items-center gap-3 px-2 py-3 border-b border-gray-200 mb-5">
|
||||||
<Image
|
{clientLogoUrl ? (
|
||||||
src="/logo-4ventures.png"
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
alt="4Ventures"
|
<img
|
||||||
width={40}
|
src={clientLogoUrl}
|
||||||
height={40}
|
alt={title}
|
||||||
className="rounded-lg object-cover shrink-0"
|
width={40}
|
||||||
priority
|
height={40}
|
||||||
/>
|
className="rounded-lg object-contain shrink-0 w-10 h-10 bg-white border border-gray-100"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src="/logo-4ventures.png"
|
||||||
|
alt="4Ventures"
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="rounded-lg object-cover shrink-0"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-bold text-sm tracking-wide text-gray-900 leading-tight">
|
<p className="font-bold text-sm tracking-wide text-gray-900 leading-tight truncate">
|
||||||
4VENTURES
|
{title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 font-medium leading-tight">
|
<p className="text-xs text-gray-500 font-medium leading-tight">
|
||||||
Centro de Mando
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -168,13 +211,13 @@ export default function DashboardShell({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto space-y-1.5 pr-1">
|
<nav className="flex-1 overflow-y-auto space-y-1.5 pr-1">
|
||||||
{NAV_ITEMS.map((item) => (
|
{visibleNav.map((item) => (
|
||||||
<NavLink key={item.href} item={item} onNavigate={close} />
|
<NavLink key={item.href} item={item} onNavigate={close} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isAgency ? (
|
{isAgency ? (
|
||||||
<div className="pt-4 mt-4 border-t border-gray-200">
|
<div className="pt-4 mt-4 border-t border-gray-200">
|
||||||
<span className="px-3 text-[10px] font-bold uppercase tracking-wider text-gray-400">
|
<span className="px-3 text-[10px] font-bold uppercase tracking-wider text-gray-500">
|
||||||
Agencia OS
|
Agencia OS
|
||||||
</span>
|
</span>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
|
|||||||
151
src/lib/tenant-modules.ts
Normal file
151
src/lib/tenant-modules.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/** Dashboard modules toggled per tenant (client portal nav). */
|
||||||
|
|
||||||
|
export type DashboardModuleId =
|
||||||
|
| 'kpis'
|
||||||
|
| 'crm'
|
||||||
|
| 'seguimiento'
|
||||||
|
| 'estrategia'
|
||||||
|
| 'calendario'
|
||||||
|
| 'ad-tracker'
|
||||||
|
| 'activos'
|
||||||
|
| 'creatives'
|
||||||
|
| 'asistente';
|
||||||
|
|
||||||
|
export type DashboardModuleDef = {
|
||||||
|
id: DashboardModuleId;
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
/** Integration providers that typically unlock this module. */
|
||||||
|
providers: string[];
|
||||||
|
hint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DASHBOARD_MODULES: DashboardModuleDef[] = [
|
||||||
|
{
|
||||||
|
id: 'kpis',
|
||||||
|
href: '/dashboard/kpis',
|
||||||
|
label: 'Métricas',
|
||||||
|
providers: ['meta_ads', 'google_ads', 'ga4'],
|
||||||
|
hint: 'KPIs de inversión y leads',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crm',
|
||||||
|
href: '/dashboard/crm',
|
||||||
|
label: 'CRM',
|
||||||
|
providers: ['clientify'],
|
||||||
|
hint: 'Clientify / leads',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'seguimiento',
|
||||||
|
href: '/dashboard/seguimiento',
|
||||||
|
label: 'Seguimiento',
|
||||||
|
providers: ['superhuman_docs'],
|
||||||
|
hint: 'Actas y seguimiento operativo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'estrategia',
|
||||||
|
href: '/dashboard/estrategia',
|
||||||
|
label: 'Objetivos del Mes',
|
||||||
|
providers: [],
|
||||||
|
hint: 'Targets y presupuesto',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'calendario',
|
||||||
|
href: '/dashboard/calendario',
|
||||||
|
label: 'Calendario Editorial',
|
||||||
|
providers: ['instagram', 'facebook', 'tiktok', 'linkedin'],
|
||||||
|
hint: 'RRSS / contenido',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ad-tracker',
|
||||||
|
href: '/dashboard/ad-tracker',
|
||||||
|
label: 'Ad Tracker',
|
||||||
|
providers: ['meta_ads', 'google_ads'],
|
||||||
|
hint: 'Creatividades Meta / Google',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'activos',
|
||||||
|
href: '/dashboard/activos',
|
||||||
|
label: 'Biblioteca de Activos',
|
||||||
|
providers: ['google_drive'],
|
||||||
|
hint: 'Drive y archivos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'creatives',
|
||||||
|
href: '/dashboard/creatives',
|
||||||
|
label: 'Pipeline creatividades',
|
||||||
|
providers: ['google_drive', 'meta_ads', 'google_ads'],
|
||||||
|
hint: 'Flujo creativo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'asistente',
|
||||||
|
href: '/dashboard/asistente',
|
||||||
|
label: 'Asistente IA Financiero',
|
||||||
|
providers: [],
|
||||||
|
hint: 'Consultas sobre cifras',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export type TenantModuleMap = Partial<Record<DashboardModuleId, boolean>>;
|
||||||
|
|
||||||
|
export type TenantSettings = {
|
||||||
|
modules?: TenantModuleMap;
|
||||||
|
/** When true, modules were explicitly saved by agency (don't auto-overwrite). */
|
||||||
|
modulesLocked?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function defaultModulesAllOn(): Record<DashboardModuleId, boolean> {
|
||||||
|
const out = {} as Record<DashboardModuleId, boolean>;
|
||||||
|
for (const m of DASHBOARD_MODULES) out[m.id] = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggest modules from connected + enabled integrations. */
|
||||||
|
export function suggestModulesFromIntegrations(
|
||||||
|
integrations: { provider: string; status?: string; enabled?: boolean }[]
|
||||||
|
): Record<DashboardModuleId, boolean> {
|
||||||
|
const connected = new Set(
|
||||||
|
integrations
|
||||||
|
.filter((i) => i.enabled !== false && i.status === 'connected')
|
||||||
|
.map((i) => i.provider)
|
||||||
|
);
|
||||||
|
const out = {} as Record<DashboardModuleId, boolean>;
|
||||||
|
for (const m of DASHBOARD_MODULES) {
|
||||||
|
if (!m.providers.length) {
|
||||||
|
out[m.id] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out[m.id] = m.providers.some((p) => connected.has(p));
|
||||||
|
}
|
||||||
|
// Always keep Métricas if any paid/analytics, else still on as home
|
||||||
|
if (!out.kpis) out.kpis = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTenantModules(
|
||||||
|
settings: TenantSettings | null | undefined,
|
||||||
|
integrations: { provider: string; status?: string; enabled?: boolean }[]
|
||||||
|
): Record<DashboardModuleId, boolean> {
|
||||||
|
if (settings?.modulesLocked && settings.modules) {
|
||||||
|
return { ...defaultModulesAllOn(), ...settings.modules };
|
||||||
|
}
|
||||||
|
if (settings?.modules && Object.keys(settings.modules).length > 0) {
|
||||||
|
return { ...defaultModulesAllOn(), ...settings.modules };
|
||||||
|
}
|
||||||
|
if (integrations.length) {
|
||||||
|
return suggestModulesFromIntegrations(integrations);
|
||||||
|
}
|
||||||
|
return defaultModulesAllOn();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModuleEnabled(
|
||||||
|
modules: Record<DashboardModuleId, boolean> | null | undefined,
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user