feat: agency portfolio with per-client integrations hub
Admin home lists tenants, alerts, and connection health; header selector enables superadmin context; Meta/GA4/GSC/GTM/CRM/email slots live per tenant (manual status until OAuth). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
223
src/components/AgencyPortfolioBoard.tsx
Normal file
223
src/components/AgencyPortfolioBoard.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
CircleDashed,
|
||||
ExternalLink,
|
||||
Plug,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
type PortfolioRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
stats: {
|
||||
connected: number;
|
||||
errors: number;
|
||||
pending: number;
|
||||
totalProviders: number;
|
||||
health: 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;
|
||||
};
|
||||
|
||||
function healthBadge(h: string) {
|
||||
if (h === 'ok')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-emerald-400">
|
||||
<CheckCircle2 className="w-3 h-3" /> OK
|
||||
</span>
|
||||
);
|
||||
if (h === 'critical')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-red-400">
|
||||
<XCircle className="w-3 h-3" /> Error
|
||||
</span>
|
||||
);
|
||||
if (h === 'warning')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-amber-400">
|
||||
<AlertTriangle className="w-3 h-3" /> Pendiente
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-gray-500">
|
||||
<CircleDashed className="w-3 h-3" /> Sin conectar
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgencyPortfolioBoard() {
|
||||
const [portfolio, setPortfolio] = useState<PortfolioRow[]>([]);
|
||||
const [alerts, setAlerts] = useState<AlertRow[]>([]);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/portfolio');
|
||||
const data = await res.json();
|
||||
setPortfolio(data.portfolio || []);
|
||||
setAlerts((data.alerts || []).filter((a: AlertRow) => !a.read_at));
|
||||
setWarning(data.warning || null);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
async function enterTenant(id: string) {
|
||||
await fetch('/api/admin/active-tenant', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenantId: id }),
|
||||
});
|
||||
window.location.href = '/dashboard/kpis';
|
||||
}
|
||||
|
||||
async function dismiss(id: string) {
|
||||
await fetch('/api/admin/alerts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, markRead: true }),
|
||||
});
|
||||
setAlerts((prev) => prev.filter((a) => a.id !== id));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-gray-500">Cargando portafolio…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{warning && (
|
||||
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||
{warning}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{alerts.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Bell className="w-4 h-4 text-amber-400" />
|
||||
Alertas ({alerts.length})
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{alerts.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className={`flex items-start justify-between gap-3 rounded-lg border px-4 py-3 ${
|
||||
a.severity === 'critical'
|
||||
? 'border-red-500/30 bg-red-500/5'
|
||||
: a.severity === 'warning'
|
||||
? 'border-amber-500/30 bg-amber-500/5'
|
||||
: 'border-gray-800 bg-[#111827]'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm text-white font-medium">{a.title}</p>
|
||||
{a.body && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">{a.body}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{a.href && (
|
||||
<Link
|
||||
href={a.href}
|
||||
className="text-xs text-brand-400 hover:underline"
|
||||
>
|
||||
Ver
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(a.id)}
|
||||
className="text-[10px] uppercase tracking-wider text-gray-500 hover:text-gray-300"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Plug className="w-4 h-4 text-brand-400" />
|
||||
Portafolio · estado de conexiones
|
||||
</h3>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Meta · Google Ads · GA4 · GSC · GTM · CRM · Email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{portfolio.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">
|
||||
No hay clientes. Usa «Dar de Alta Cliente» abajo.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{portfolio.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="rounded-xl border border-gray-800 bg-[#111827] p-4 flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{t.name}</p>
|
||||
<p className="text-[11px] text-gray-500">{t.slug}</p>
|
||||
</div>
|
||||
{healthBadge(t.stats.health)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
{t.stats.connected}/{t.stats.totalProviders} conectadas
|
||||
{t.stats.errors > 0 && (
|
||||
<span className="text-red-400">
|
||||
{' '}
|
||||
· {t.stats.errors} error
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => enterTenant(t.id)}
|
||||
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-brand-500/90 hover:bg-brand-500 text-white"
|
||||
>
|
||||
Entrar (superadmin)
|
||||
</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-700 text-gray-300 hover:border-gray-500 hover:text-white"
|
||||
>
|
||||
Integraciones
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user