From c4c527072f7903c9cd35a47a425c4cac009b6425 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 22 Sep 2026 18:47:29 +0200 Subject: [PATCH] feat: agency control panel for per-client connections Matrix view of Meta/Google/Drive/Clientify, follow/archive clients, and per-channel ON/OFF with account IDs in the client hub. Co-authored-by: Cursor --- docs/OPERATOR.md | 1 + scripts/dokploy_apply_agency_control.py | 91 ++++++++ sql/agency_control.sql | 16 ++ src/app/api/admin/integrations/route.ts | 24 +- src/app/api/admin/portfolio/route.ts | 106 ++++++--- src/app/api/admin/tenants/route.ts | 86 +++++++ src/app/dashboard/agencia/page.tsx | 3 +- src/components/AgencyPortfolioBoard.tsx | 253 +++++++++++++++++---- src/components/ClientIntegrationsPanel.tsx | 193 ++++++++++++---- src/components/TenantSwitcher.tsx | 4 +- 10 files changed, 637 insertions(+), 140 deletions(-) create mode 100644 scripts/dokploy_apply_agency_control.py create mode 100644 sql/agency_control.sql create mode 100644 src/app/api/admin/tenants/route.ts diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index 6c908d8..abe8c8a 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -9,6 +9,7 @@ - Re-aplicar RLS `agent_runs` si hace falta: `sql/agent_runs_rls.sql` (idempotente) - Biblioteca de activos (archivos): `sql/asset_files.sql` + bucket privado `tenant-assets` (máx. 80 MB/archivo). UI: `/dashboard/activos`. Apply: `python3 scripts/dokploy_apply_asset_files.py` - Google Drive (agencia): `sql/google_drive.sql`. OAuth marketing@ → auto-match carpetas por nombre de tenant. Env Dokploy: `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `NEXT_PUBLIC_APP_URL`. Redirect: `https://app.4ventures.es/api/admin/drive/callback`. UI: Agencia → Conectar Drive. +- Panel control clientes: `sql/agency_control.sql` (`agency_followed` + `integrations.enabled`). Matriz en Agencia + hub por cliente. ## Login diff --git a/scripts/dokploy_apply_agency_control.py b/scripts/dokploy_apply_agency_control.py new file mode 100644 index 0000000..b5ea542 --- /dev/null +++ b/scripts/dokploy_apply_agency_control.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Apply sql/agency_control.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" / "agency_control.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/ac.b64 /tmp/ac.sql; echo READY", 1200) + for i, ch in enumerate(chunks): + op = ">" if i == 0 else ">>" + run(f"printf '%s' '{ch}' {op} /tmp/ac.b64", 400) + run( + "base64 -d /tmp/ac.b64 > /tmp/ac.sql && " + "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/ac.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()) diff --git a/sql/agency_control.sql b/sql/agency_control.sql new file mode 100644 index 0000000..900f0cd --- /dev/null +++ b/sql/agency_control.sql @@ -0,0 +1,16 @@ +-- Agency control: follow/archive clients + enable/disable channels +-- Idempotent. + +alter table public.tenants + add column if not exists agency_followed boolean not null default true; + +alter table public.tenants + add column if not exists agency_notes text; + +alter table public.integrations + add column if not exists enabled boolean not null default true; + +comment on column public.tenants.agency_followed is + 'Agency portfolio: false = archived / not actively managed in Centro de Mando'; +comment on column public.integrations.enabled is + 'Agency wants this channel for the client (false = deliberately off)'; diff --git a/src/app/api/admin/integrations/route.ts b/src/app/api/admin/integrations/route.ts index 78ae97b..5916a53 100644 --- a/src/app/api/admin/integrations/route.ts +++ b/src/app/api/admin/integrations/route.ts @@ -111,32 +111,43 @@ export async function PATCH(req: NextRequest) { typeof body.label === 'string' ? body.label.trim() : undefined; const errorMessage = typeof body.errorMessage === 'string' ? body.errorMessage.trim() : undefined; + const enabled = + typeof body.enabled === 'boolean' ? body.enabled : undefined; - if (!tenantId || !provider || !status) { + if (!tenantId || !provider) { return NextResponse.json( - { error: 'tenantId, provider y status requeridos' }, + { error: 'tenantId y provider requeridos' }, { status: 400 } ); } - if ( - !['disconnected', 'pending', 'connected', 'error'].includes(status) - ) { + if (status && !['disconnected', 'pending', 'connected', 'error'].includes(status)) { return NextResponse.json({ error: 'status inválido' }, { status: 400 }); } const patch: Record = { - status, updated_at: new Date().toISOString(), }; + if (status) patch.status = status; if (externalAccountId !== undefined) { patch.external_account_id = externalAccountId || null; } if (label !== undefined) patch.label = label || null; if (errorMessage !== undefined) patch.error_message = errorMessage || null; + if (enabled !== undefined) patch.enabled = enabled; if (status === 'connected') { patch.last_sync_at = new Date().toISOString(); patch.error_message = null; } + if (status === 'disconnected') { + // keep external id for re-link unless cleared + } + + if (!status && enabled === undefined && externalAccountId === undefined) { + return NextResponse.json( + { error: 'status, enabled o externalAccountId requeridos' }, + { status: 400 } + ); + } const base = supabaseUrl(); const url = `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${encodeURIComponent(provider)}`; @@ -162,6 +173,7 @@ export async function PATCH(req: NextRequest) { body: JSON.stringify({ tenant_id: tenantId, provider, + status: status || 'disconnected', ...patch, }), }); diff --git a/src/app/api/admin/portfolio/route.ts b/src/app/api/admin/portfolio/route.ts index 8a028dd..1bd3915 100644 --- a/src/app/api/admin/portfolio/route.ts +++ b/src/app/api/admin/portfolio/route.ts @@ -33,12 +33,15 @@ export async function GET() { const base = supabaseUrl(); const [tenantsRes, integRes, alertsRes] = await Promise.all([ - fetch(`${base}/rest/v1/tenants?select=id,name,slug,created_at&order=name.asc`, { - headers, - cache: 'no-store', - }), fetch( - `${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message`, + `${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`, + { + headers, + cache: 'no-store', + } + ), + fetch( + `${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`, { headers, cache: 'no-store' } ), fetch( @@ -47,17 +50,35 @@ export async function GET() { ), ]); - const tenants = tenantsRes.ok ? await tenantsRes.json() : []; - const integrations = integRes.ok ? await integRes.json() : []; + let tenants = tenantsRes.ok ? await tenantsRes.json() : []; + // Fallback if agency_followed column missing + if (!tenantsRes.ok) { + const again = await fetch( + `${base}/rest/v1/tenants?select=id,name,slug,created_at&order=name.asc`, + { headers, cache: 'no-store' } + ); + tenants = again.ok ? await again.json() : []; + } + + let integrations: unknown[] = []; + let integMissing = false; + if (integRes.ok) { + integrations = await integRes.json(); + } else { + const msg = await integRes.text(); + integMissing = msg.toLowerCase().includes('does not exist'); + } const alerts = alertsRes.ok ? await alertsRes.json() : []; - const integMissing = - !integRes.ok && - (await integRes.text().catch(() => '')).toLowerCase().includes('does not exist'); type Integ = { tenant_id: string; provider: string; status: string; + label?: string | null; + external_account_id?: string | null; + enabled?: boolean; + error_message?: string | null; + meta?: Record | null; }; const byTenant = new Map(); for (const row of integrations as Integ[]) { @@ -66,32 +87,47 @@ export async function GET() { byTenant.set(row.tenant_id, list); } - const portfolio = (tenants as { id: string; name: string; slug: string; created_at: string }[]).map( - (t) => { - const rows = byTenant.get(t.id) || []; - const connected = rows.filter((r) => r.status === 'connected').length; - const errors = rows.filter((r) => r.status === 'error').length; - const pending = rows.filter((r) => r.status === 'pending').length; - return { - ...t, - integrations: rows, - stats: { - connected, - errors, - pending, - totalProviders: INTEGRATION_PROVIDERS.length, - health: - errors > 0 - ? 'critical' - : connected === 0 - ? 'empty' - : pending > 0 - ? 'warning' - : 'ok', - }, - }; + const portfolio = ( + tenants as { + id: string; + name: string; + slug: string; + created_at: string; + agency_followed?: boolean; + agency_notes?: string | null; + }[] + ).map((t) => { + const rows = byTenant.get(t.id) || []; + const active = rows.filter((r) => r.enabled !== false); + const connected = active.filter((r) => r.status === 'connected').length; + const errors = active.filter((r) => r.status === 'error').length; + const pending = active.filter((r) => r.status === 'pending').length; + const byProvider: Record = {}; + for (const p of INTEGRATION_PROVIDERS) { + byProvider[p.id] = rows.find((r) => r.provider === p.id); } - ); + return { + ...t, + agency_followed: t.agency_followed !== false, + integrations: rows, + byProvider, + stats: { + connected, + errors, + pending, + enabledChannels: active.length, + totalProviders: INTEGRATION_PROVIDERS.length, + health: + errors > 0 + ? 'critical' + : connected === 0 + ? 'empty' + : pending > 0 + ? 'warning' + : 'ok', + }, + }; + }); return NextResponse.json({ portfolio, diff --git a/src/app/api/admin/tenants/route.ts b/src/app/api/admin/tenants/route.ts new file mode 100644 index 0000000..e8ca657 --- /dev/null +++ b/src/app/api/admin/tenants/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +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 headers = serviceHeaders(); + if (!headers) { + return { + error: NextResponse.json({ error: 'no service role' }, { status: 503 }), + }; + } + return { headers }; +} + +/** PATCH — follow/unfollow client or set notes. */ +export async function PATCH(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 body = await req.json().catch(() => ({})); + const tenantId = + typeof body.tenantId === 'string' ? body.tenantId.trim() : ''; + if (!tenantId) { + return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 }); + } + + const patch: Record = {}; + if (typeof body.followed === 'boolean') { + patch.agency_followed = body.followed; + } + if (typeof body.notes === 'string') { + patch.agency_notes = body.notes.trim() || null; + } + if (!Object.keys(patch).length) { + return NextResponse.json( + { error: 'followed o notes requeridos' }, + { status: 400 } + ); + } + + const r = await fetch( + `${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(patch), + } + ); + if (!r.ok) { + const msg = await r.text(); + return NextResponse.json( + { + error: msg.includes('agency_followed') + ? 'Aplicar sql/agency_control.sql' + : msg.slice(0, 300), + }, + { status: 500 } + ); + } + const rows = await r.json(); + return NextResponse.json({ tenant: rows[0] || rows }); +} diff --git a/src/app/dashboard/agencia/page.tsx b/src/app/dashboard/agencia/page.tsx index 00975fe..f657f19 100644 --- a/src/app/dashboard/agencia/page.tsx +++ b/src/app/dashboard/agencia/page.tsx @@ -77,7 +77,8 @@ export default async function AgencyPage() {

Centro de Mando 4Ventures

- Portafolio multi-cliente · integraciones (Meta, MCC, GA4, GSC, GTM, CRM, email) · alertas · modo superadmin. + Portafolio multi-cliente · control de conexiones (Meta, Google, Drive, + Clientify…) · seguir/archivar clientes · modo superadmin.

diff --git a/src/components/AgencyPortfolioBoard.tsx b/src/components/AgencyPortfolioBoard.tsx index 6936471..e6456b9 100644 --- a/src/components/AgencyPortfolioBoard.tsx +++ b/src/components/AgencyPortfolioBoard.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import DriveConnectPanel from '@/components/DriveConnectPanel'; import { @@ -8,15 +8,29 @@ import { Bell, CheckCircle2, CircleDashed, + Eye, + EyeOff, ExternalLink, Plug, XCircle, } from 'lucide-react'; +type IntegCell = { + provider: string; + status: string; + enabled?: boolean; + external_account_id?: string | null; + label?: string | null; + meta?: { folder_name?: string } | null; +}; + type PortfolioRow = { id: string; name: string; slug: string; + agency_followed?: boolean; + agency_notes?: string | null; + byProvider?: Record; stats: { connected: number; errors: number; @@ -26,17 +40,34 @@ type PortfolioRow = { }; }; +type Provider = { id: string; label: string; group: string }; + type AlertRow = { id: string; title: string; body?: string | null; severity: string; - tenant_id?: string | null; href?: string | null; created_at: string; read_at?: string | null; }; +const MATRIX_PROVIDERS = [ + 'meta_ads', + 'google_ads', + 'ga4', + 'clientify', + 'google_drive', +] as const; + +const SHORT: Record = { + meta_ads: 'Meta', + google_ads: 'GAds', + ga4: 'GA4', + clientify: 'CRM', + google_drive: 'Drive', +}; + function healthBadge(h: string) { if (h === 'ok') return ( @@ -63,17 +94,39 @@ function healthBadge(h: string) { ); } +function cellTone(row?: IntegCell) { + if (!row || row.enabled === false) + return 'border-gray-800 text-gray-600 bg-transparent'; + if (row.status === 'connected') + return 'border-emerald-500/40 text-emerald-400 bg-emerald-500/10'; + if (row.status === 'error') + return 'border-red-500/40 text-red-400 bg-red-500/10'; + if (row.status === 'pending') + return 'border-amber-500/40 text-amber-400 bg-amber-500/10'; + return 'border-gray-700 text-gray-500'; +} + +function cellTitle(row?: IntegCell) { + if (!row || row.enabled === false) return 'Canal apagado'; + const id = row.external_account_id || row.meta?.folder_name || row.label || ''; + return `${row.status}${id ? ` · ${id}` : ''}`; +} + export default function AgencyPortfolioBoard() { const [portfolio, setPortfolio] = useState([]); + const [providers, setProviders] = useState([]); const [alerts, setAlerts] = useState([]); const [warning, setWarning] = useState(null); const [loading, setLoading] = useState(true); + const [showArchived, setShowArchived] = useState(false); + const [busyId, setBusyId] = useState(null); async function load() { setLoading(true); const res = await fetch('/api/admin/portfolio'); const data = await res.json(); setPortfolio(data.portfolio || []); + setProviders(data.providers || []); setAlerts((data.alerts || []).filter((a: AlertRow) => !a.read_at)); setWarning(data.warning || null); setLoading(false); @@ -83,6 +136,13 @@ export default function AgencyPortfolioBoard() { void load(); }, []); + const visible = useMemo(() => { + if (showArchived) return portfolio; + return portfolio.filter((t) => t.agency_followed !== false); + }, [portfolio, showArchived]); + + const archivedCount = portfolio.filter((t) => t.agency_followed === false).length; + async function enterTenant(id: string) { await fetch('/api/admin/active-tenant', { method: 'POST', @@ -92,6 +152,17 @@ export default function AgencyPortfolioBoard() { window.location.href = '/dashboard/kpis'; } + async function setFollowed(id: string, followed: boolean) { + setBusyId(id); + await fetch('/api/admin/tenants', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tenantId: id, followed }), + }); + await load(); + setBusyId(null); + } + async function dismiss(id: string) { await fetch('/api/admin/alerts', { method: 'POST', @@ -163,63 +234,145 @@ export default function AgencyPortfolioBoard() { )}
-
-

- - Portafolio · estado de conexiones -

-

- Meta · Google Ads · GA4 · GSC · GTM · CRM · Email · Drive -

+
+
+

+ + Panel de control · clientes y conexiones +

+

+ Cada cliente con su Meta, Google Ads, GA4, Clientify y Drive. + Activa/apaga canales en Integraciones. +

+
+
- {portfolio.length === 0 ? ( + {/* Matrix header */} +
+ Cliente + {MATRIX_PROVIDERS.map((p) => ( + + {SHORT[p]} + + ))} + Acciones +
+ + {visible.length === 0 ? (

- No hay clientes. Usa «Dar de Alta Cliente» abajo. + No hay clientes activos. Usa «Dar de Alta Cliente» abajo o muestra + archivados.

) : ( -
- {portfolio.map((t) => ( -
-
-
-

{t.name}

-

{t.slug}

+
+ {visible.map((t) => { + const archived = t.agency_followed === false; + return ( +
+
+
+
+

+ {t.name} +

+ {healthBadge(t.stats.health)} + {archived && ( + + Archivado + + )} +
+

+ {t.slug} · {t.stats.connected}/ + {t.stats.totalProviders} conectadas +

+
+ + {MATRIX_PROVIDERS.map((pid) => { + const cell = t.byProvider?.[pid]; + return ( +
+ + {SHORT[pid]} + + {cell?.enabled === false + ? 'OFF' + : cell?.status === 'connected' + ? 'ON' + : cell?.status === 'error' + ? 'ERR' + : cell?.status === 'pending' + ? '…' + : '—'} +
+ ); + })} + +
+ + + Conexiones + + + +
- {healthBadge(t.stats.health)}
-

- {t.stats.connected}/{t.stats.totalProviders} conectadas - {t.stats.errors > 0 && ( - - {' '} - · {t.stats.errors} error - - )} -

-
- - - Integraciones - - -
-
- ))} + ); + })}
)} + + {providers.length > 0 && ( +

+ Canales disponibles:{' '} + {providers.map((p) => p.label).join(' · ')} +

+ )}
); diff --git a/src/components/ClientIntegrationsPanel.tsx b/src/components/ClientIntegrationsPanel.tsx index d9a4e1c..26b21e3 100644 --- a/src/components/ClientIntegrationsPanel.tsx +++ b/src/components/ClientIntegrationsPanel.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import Link from 'next/link'; -import { ArrowLeft, Loader2 } from 'lucide-react'; +import { ArrowLeft, Loader2, Power } from 'lucide-react'; type Integ = { id?: string; @@ -12,16 +12,23 @@ type Integ = { external_account_id?: string | null; error_message?: string | null; last_sync_at?: string | null; + enabled?: boolean; + meta?: { folder_name?: string; match_score?: number } | null; }; type Provider = { id: string; label: string; group: string }; -const STATUS_OPTS = [ - { value: 'disconnected', label: 'Desconectado' }, - { value: 'pending', label: 'Pendiente' }, - { value: 'connected', label: 'Conectado' }, - { value: 'error', label: 'Error' }, -] as const; +const PLACEHOLDERS: Record = { + meta_ads: 'act_… (Ad Account ID)', + google_ads: 'Customer ID MCC / cuenta', + ga4: 'G-… o Property ID', + search_console: 'sc-domain:ejemplo.com', + tag_manager: 'GTM-…', + clientify: 'ID / workspace Clientify', + klaviyo: 'Account / list ID', + mailchimp: 'Audience ID', + google_drive: 'Folder ID (auto-match o pegar)', +}; export default function ClientIntegrationsPanel({ tenantId, @@ -35,6 +42,8 @@ export default function ClientIntegrationsPanel({ const [warning, setWarning] = useState(null); const [saving, setSaving] = useState(null); const [accountDraft, setAccountDraft] = useState>({}); + const [followed, setFollowed] = useState(true); + const [followBusy, setFollowBusy] = useState(false); async function load() { const res = await fetch( @@ -53,24 +62,43 @@ export default function ClientIntegrationsPanel({ useEffect(() => { void load(); + fetch('/api/admin/portfolio') + .then((r) => r.json()) + .then((d) => { + const row = (d.portfolio || []).find( + (t: { id: string }) => t.id === tenantId + ); + if (row) setFollowed(row.agency_followed !== false); + }) + .catch(() => {}); }, [tenantId]); - async function save(provider: string, status: string) { + async function patch( + provider: string, + body: Record + ) { setSaving(provider); await fetch('/api/admin/integrations', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tenantId, - provider, - status, - externalAccountId: accountDraft[provider] || '', - }), + body: JSON.stringify({ tenantId, provider, ...body }), }); await load(); setSaving(null); } + async function toggleFollow() { + setFollowBusy(true); + const next = !followed; + await fetch('/api/admin/tenants', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tenantId, followed: next }), + }); + setFollowed(next); + setFollowBusy(false); + } + const byProvider = new Map(integrations.map((i) => [i.provider, i])); const groups = [ @@ -83,22 +111,41 @@ export default function ClientIntegrationsPanel({ return (
-
- - - -
-

- {tenantName} -

-

- Integraciones del cliente · marca estado y account id (OAuth live en - siguiente fase) -

+
+
+ + + +
+

+ {tenantName} +

+

+ Hub de conexiones · asocia cuentas y decide qué entra a la + plataforma +

+
+
{warning && ( @@ -119,16 +166,30 @@ export default function ClientIntegrationsPanel({ {list.map((p) => { const row = byProvider.get(p.id); const status = row?.status || 'disconnected'; + const enabled = row?.enabled !== false; + const folderHint = + p.id === 'google_drive' + ? row?.meta?.folder_name || row?.label + : null; return (
  • -
    +

    {p.label}

    + {folderHint && ( +

    + Carpeta: {folderHint} +

    + )} {row?.last_sync_at && (

    Último sync{' '} @@ -141,24 +202,50 @@ export default function ClientIntegrationsPanel({

    )}
    - +
    + + +
    setAccountDraft((d) => ({ ...d, @@ -168,17 +255,29 @@ export default function ClientIntegrationsPanel({ />
    + {p.id === 'google_drive' && ( +

    + Preferible: Agencia → Conectar Drive → Re-emparejar + (asigna carpeta por nombre). Aquí puedes pegar un Folder + ID manual. +

    + )}
  • ); })} diff --git a/src/components/TenantSwitcher.tsx b/src/components/TenantSwitcher.tsx index ffcb836..2fb6ccb 100644 --- a/src/components/TenantSwitcher.tsx +++ b/src/components/TenantSwitcher.tsx @@ -31,7 +31,9 @@ export default function TenantSwitcher({ fetch('/api/admin/portfolio') .then((r) => r.json()) .then((d) => { - const list = (d.portfolio || []).map( + const list = (d.portfolio || []) + .filter((t: { agency_followed?: boolean }) => t.agency_followed !== false) + .map( (t: { id: string; name: string; slug: string }) => ({ id: t.id, name: t.name,