Files
4ventures-client-portal/src/components/SuperhumanSyncPanel.tsx
4Ventures 21051afda9 fix: cohort CAC/ROAS desde deals Clientify y más contraste en dashboards
Los totales de Métricas usaban solo ad_daily (cohort a 0). Ahora suman
deals won; selectores y tipografía ganan contraste sobre fondo blanco.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 10:40:12 +02:00

179 lines
5.8 KiB
TypeScript

'use client';
import React, { useEffect, useState } from 'react';
import { Loader2, RefreshCw, FileText } from 'lucide-react';
type Match = {
docId: string;
docName: string;
tenantId: string | null;
tenantName: string | null;
score: number;
status: string;
};
type SyncResult = {
tenantName: string;
status: string;
upserted: number;
docName: string | null;
error?: string;
};
export default function SuperhumanSyncPanel() {
const [matches, setMatches] = useState<Match[]>([]);
const [docCount, setDocCount] = useState(0);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [results, setResults] = useState<SyncResult[]>([]);
async function loadStatus() {
setLoading(true);
setMessage(null);
try {
const r = await fetch('/api/admin/superhuman/sync', { cache: 'no-store' });
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo leer Superhuman');
setMatches(j.matches || []);
setDocCount(j.docCount || 0);
} catch (e) {
setMessage(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
}
useEffect(() => {
loadStatus();
}, []);
async function runSync() {
setSyncing(true);
setMessage(null);
try {
const r = await fetch('/api/admin/superhuman/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exportBodies: true, maxEntries: 20 }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'Sync falló');
setResults(j.results || []);
setMessage(
`Sincronizados ${j.synced}/${(j.results || []).length} tenants (sin contraseñas).`
);
await loadStatus();
} catch (e) {
setMessage(e instanceof Error ? e.message : 'Error');
} finally {
setSyncing(false);
}
}
const matched = matches.filter((m) => m.status === 'matched').length;
return (
<div className="rounded-xl border border-gray-200 bg-white p-5 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<FileText className="w-4 h-4 text-brand-500" />
Superhuman Docs → Seguimiento
</h3>
<p className="text-xs text-gray-500 mt-1">
Importa páginas de Seguimiento/KPIs por cliente. Excluye CONTRASEÑAS.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={loadStatus}
disabled={loading || syncing}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:text-gray-900"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
Estado
</button>
<button
type="button"
onClick={runSync}
disabled={loading || syncing}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-brand-500/20 text-brand-700 border border-brand-500/30 hover:bg-brand-500/30"
>
{syncing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
Sync ahora
</button>
</div>
</div>
<p className="text-[11px] text-gray-500">
{docCount} docs · {matched} matched a tenants del portal
</p>
{message && (
<p className="text-xs text-amber-300/90 border border-amber-500/20 rounded-lg px-3 py-2">
{message}
</p>
)}
{results.length > 0 && (
<ul className="text-xs text-gray-400 space-y-1 max-h-40 overflow-y-auto">
{results.map((r) => (
<li key={r.tenantName + r.status}>
<span className="text-gray-600">{r.tenantName}</span> · {r.status}
{r.docName ? ` · ${r.docName}` : ''}
{r.upserted ? ` · ${r.upserted} entradas` : ''}
{r.error ? ` · ${r.error}` : ''}
</li>
))}
</ul>
)}
{!loading && matches.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-[11px] text-left">
<thead className="text-gray-500 border-b border-gray-200">
<tr>
<th className="py-1.5 pr-2 font-medium">Tenant</th>
<th className="py-1.5 pr-2 font-medium">Doc</th>
<th className="py-1.5 font-medium">Match</th>
</tr>
</thead>
<tbody>
{matches.slice(0, 20).map((m) => (
<tr key={m.tenantName || m.docId} className="border-b border-gray-100">
<td className="py-1.5 pr-2 text-gray-600">
{m.tenantName || '—'}
</td>
<td className="py-1.5 pr-2 text-gray-400 truncate max-w-[220px]">
{m.docName || '—'}
</td>
<td className="py-1.5">
<span
className={
m.status === 'matched'
? 'text-emerald-400'
: m.status === 'ambiguous'
? 'text-amber-400'
: 'text-gray-500'
}
>
{m.status}
{m.score ? ` ${m.score}` : ''}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}