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:
4Ventures
2026-09-24 10:51:21 +02:00
parent 21051afda9
commit dd0e867108
13 changed files with 1612 additions and 84 deletions

View File

@@ -40,9 +40,14 @@ const PLACEHOLDERS: Record<string, string> = {
export default function ClientIntegrationsPanel({
tenantId,
tenantName,
embedded = false,
onChanged,
}: {
tenantId: string;
tenantName: string;
/** Hide page chrome when rendered inside ClientFicha. */
embedded?: boolean;
onChanged?: () => void;
}) {
const [integrations, setIntegrations] = useState<Integ[]>([]);
const [providers, setProviders] = useState<Provider[]>([]);
@@ -80,10 +85,7 @@ export default function ClientIntegrationsPanel({
.catch(() => {});
}, [tenantId]);
async function patch(
provider: string,
body: Record<string, unknown>
) {
async function patch(provider: string, body: Record<string, unknown>) {
setSaving(provider);
await fetch('/api/admin/integrations', {
method: 'PATCH',
@@ -91,6 +93,7 @@ export default function ClientIntegrationsPanel({
body: JSON.stringify({ tenantId, provider, ...body }),
});
await load();
onChanged?.();
setSaving(null);
}
@@ -116,49 +119,52 @@ export default function ClientIntegrationsPanel({
{ id: 'email', title: 'Email / lifecycle' },
{ id: 'cms', title: 'Web / e‑commerce' },
{ id: 'files', title: 'Archivos' },
{ id: 'ops', title: 'Operaciones' },
];
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="flex items-center gap-3">
<Link
href="/dashboard/agencia"
className="text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">
{tenantName}
</h2>
<p className="text-sm text-gray-400 mt-0.5">
Hub de conexiones · asocia cuentas y decide qué entra a la
plataforma
</p>
{!embedded ? (
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="flex items-center gap-3">
<Link
href="/dashboard/agencia"
className="text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">
{tenantName}
</h2>
<p className="text-sm text-gray-600 mt-0.5">
Hub de conexiones · asocia cuentas y decide qué entra a la
plataforma
</p>
</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>
<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-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>
) : null}
{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}
</p>
)}
@@ -168,7 +174,7 @@ export default function ClientIntegrationsPanel({
if (!list.length) return null;
return (
<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}
</h3>
<ul className="space-y-2">
@@ -186,7 +192,7 @@ export default function ClientIntegrationsPanel({
className={`rounded-xl border p-4 space-y-3 ${
enabled
? '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">
@@ -195,7 +201,7 @@ export default function ClientIntegrationsPanel({
{p.label}
</p>
{folderHint && (
<p className="text-[11px] text-brand-400 mt-0.5">
<p className="text-[11px] text-brand-700 mt-0.5">
Carpeta: {folderHint}
</p>
)}
@@ -206,7 +212,7 @@ export default function ClientIntegrationsPanel({
</p>
)}
{row?.error_message && (
<p className="text-[11px] text-red-400 mt-1">
<p className="text-[11px] text-red-600 mt-1">
{row.error_message}
</p>
)}
@@ -215,13 +221,11 @@ export default function ClientIntegrationsPanel({
<button
type="button"
disabled={saving === p.id}
onClick={() =>
void patch(p.id, { enabled: !enabled })
}
className={`text-[11px] px-2.5 py-1 rounded-md border ${
onClick={() => void patch(p.id, { enabled: !enabled })}
className={`text-[11px] font-semibold px-2.5 py-1 rounded-md border ${
enabled
? 'border-emerald-500/40 text-emerald-400'
: 'border-gray-200 text-gray-500'
? 'border-emerald-300 text-emerald-700 bg-emerald-50'
: 'border-gray-200 text-gray-600'
}`}
title="Si está OFF, no cuenta en salud ni se usa"
>
@@ -239,10 +243,10 @@ export default function ClientIntegrationsPanel({
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'
? 'border-brand-500/40 text-brand-400'
: 'border-gray-200 text-gray-400'
? 'border-brand-300 text-brand-700 bg-brand-50'
: 'border-gray-200 text-gray-600'
}`}
>
{status === 'connected' ? 'Desconectar' : 'Conectar'}
@@ -251,7 +255,7 @@ export default function ClientIntegrationsPanel({
</div>
<div className="flex gap-2 items-center">
<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'}
value={accountDraft[p.id] || ''}
disabled={!enabled}
@@ -267,11 +271,12 @@ export default function ClientIntegrationsPanel({
disabled={saving === p.id || !enabled}
onClick={() =>
void patch(p.id, {
status: status === 'disconnected' ? 'pending' : status,
status:
status === 'disconnected' ? 'pending' : status,
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 ? (
<Loader2 className="w-3 h-3 animate-spin" />