Files
4ventures-client-portal/src/components/ClientFicha.tsx
4Ventures dd0e867108 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>
2026-09-24 10:51:21 +02:00

595 lines
20 KiB
TypeScript

'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>
);
}