feat: interactive seguimiento timeline and channel evolution
Sync Superhuman Docs seguimiento/KPI pages into the portal (never passwords) and show clients a dynamic timeline plus per-channel kpi_weekly charts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
178
src/components/SuperhumanSyncPanel.tsx
Normal file
178
src/components/SuperhumanSyncPanel.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'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-800 bg-[#111827] p-5 space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white 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-700 text-gray-300 hover:text-white"
|
||||
>
|
||||
<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-300 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-300">{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-800">
|
||||
<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-900">
|
||||
<td className="py-1.5 pr-2 text-gray-300">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user