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:
@@ -345,9 +345,9 @@ export default function AgencyPortfolioBoard() {
|
||||
</button>
|
||||
<Link
|
||||
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" />
|
||||
</Link>
|
||||
<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({
|
||||
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" />
|
||||
|
||||
@@ -21,12 +21,14 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import TenantSwitcher from '@/components/TenantSwitcher';
|
||||
import type { DashboardModuleId } from '@/lib/tenant-modules';
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
iconClass: string;
|
||||
moduleId: DashboardModuleId;
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
@@ -35,54 +37,63 @@ const NAV_ITEMS: NavItem[] = [
|
||||
label: 'Métricas',
|
||||
icon: BarChart3,
|
||||
iconClass: 'text-brand-500',
|
||||
moduleId: 'kpis',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/crm',
|
||||
label: 'CRM',
|
||||
icon: Contact,
|
||||
iconClass: 'text-teal-500',
|
||||
moduleId: 'crm',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/seguimiento',
|
||||
label: 'Seguimiento',
|
||||
icon: Radio,
|
||||
iconClass: 'text-sky-500',
|
||||
moduleId: 'seguimiento',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/estrategia',
|
||||
label: 'Objetivos del Mes',
|
||||
icon: Target,
|
||||
iconClass: 'text-emerald-500',
|
||||
moduleId: 'estrategia',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/calendario',
|
||||
label: 'Calendario Editorial',
|
||||
icon: CalendarDays,
|
||||
iconClass: 'text-pink-500',
|
||||
moduleId: 'calendario',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/ad-tracker',
|
||||
label: 'Ad Tracker',
|
||||
icon: Megaphone,
|
||||
iconClass: 'text-violet-500',
|
||||
moduleId: 'ad-tracker',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/activos',
|
||||
label: 'Biblioteca de Activos',
|
||||
icon: FolderGit2,
|
||||
iconClass: 'text-blue-500',
|
||||
moduleId: 'activos',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/creatives',
|
||||
label: 'Pipeline creatividades',
|
||||
icon: FolderGit2,
|
||||
iconClass: 'text-orange-500',
|
||||
moduleId: 'creatives',
|
||||
},
|
||||
{
|
||||
href: '/dashboard/asistente',
|
||||
label: 'Asistente IA Financiero',
|
||||
icon: Bot,
|
||||
iconClass: 'text-purple-500',
|
||||
moduleId: 'asistente',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -96,7 +107,7 @@ function NavLink({
|
||||
item,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: NavItem;
|
||||
item: NavItem | (Omit<NavItem, 'moduleId'> & { moduleId?: string });
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
@@ -125,6 +136,9 @@ export default function DashboardShell({
|
||||
role,
|
||||
isAgency,
|
||||
viewName,
|
||||
enabledModules,
|
||||
clientLogoUrl,
|
||||
brandLabel,
|
||||
logoutAction,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
@@ -133,28 +147,57 @@ export default function DashboardShell({
|
||||
role: string;
|
||||
isAgency: boolean;
|
||||
viewName?: string;
|
||||
/** null = show all (agency default). */
|
||||
enabledModules?: Record<DashboardModuleId, boolean> | null;
|
||||
clientLogoUrl?: string | null;
|
||||
brandLabel?: string;
|
||||
logoutAction: () => void | Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(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 = (
|
||||
<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">
|
||||
<Image
|
||||
src="/logo-4ventures.png"
|
||||
alt="4Ventures"
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-lg object-cover shrink-0"
|
||||
priority
|
||||
/>
|
||||
{clientLogoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={clientLogoUrl}
|
||||
alt={title}
|
||||
width={40}
|
||||
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">
|
||||
<p className="font-bold text-sm tracking-wide text-gray-900 leading-tight">
|
||||
4VENTURES
|
||||
<p className="font-bold text-sm tracking-wide text-gray-900 leading-tight truncate">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 font-medium leading-tight">
|
||||
Centro de Mando
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -168,13 +211,13 @@ export default function DashboardShell({
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
))}
|
||||
|
||||
{isAgency ? (
|
||||
<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
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
|
||||
Reference in New Issue
Block a user