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>
179 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|