Files
4ventures-client-portal/src/components/SeguimientoBoard.tsx
4Ventures dd50251de2 feat: editor enriquecido en notas de Seguimiento
Barra tipo Docs (negrita, color, resaltado, listas, enlaces, tamaño) al crear notas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:35:42 +02:00

360 lines
13 KiB
TypeScript

'use client';
/**
* Seguimiento = notas + histórico (Superhuman + manual).
* Ads/KPIs viven en Métricas.
*/
import React, { useEffect, useMemo, useState } from 'react';
import {
ExternalLink,
Loader2,
MessageSquareText,
Radio,
Send,
} from 'lucide-react';
import { PageHeader } from '@/components/ui';
import NoteRichEditor, { richTextHasContent } from '@/components/NoteRichEditor';
type Entry = {
id: string;
entry_type: string;
title: string;
summary?: string | null;
body_md?: string | null;
browser_link?: string | null;
occurred_at?: string | null;
source?: string;
};
type Payload = {
label: string;
portfolioWide?: boolean;
tenantId?: string;
hint?: string;
entries: Entry[];
};
function typeLabel(t: string) {
if (t === 'meeting') return 'Reunión';
if (t === 'kpis') return 'KPIs';
if (t === 'topic') return 'Tema';
return 'Nota';
}
function typeTone(t: string) {
if (t === 'meeting') return 'bg-amber-50 text-amber-800 border-amber-200';
if (t === 'kpis') return 'bg-sky-50 text-sky-800 border-sky-200';
if (t === 'topic') return 'bg-emerald-50 text-emerald-800 border-emerald-200';
return 'bg-violet-50 text-violet-800 border-violet-200';
}
function looksLikeHtml(s: string) {
return /<\/?(?:ul|ol|li|p|a|strong|b|em|i|u|br|span|font|div|h[1-6])\b/i.test(
s
);
}
function escapeHtml(s: string) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export default function SeguimientoBoard() {
const [data, setData] = useState<Payload | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [openId, setOpenId] = useState<string | null>(null);
const [noteTitle, setNoteTitle] = useState('');
const [noteBody, setNoteBody] = useState('');
const [noteBusy, setNoteBusy] = useState(false);
const [noteMsg, setNoteMsg] = useState<string | null>(null);
const [syncBusy, setSyncBusy] = useState(false);
async function load() {
setLoading(true);
setErr(null);
try {
const res = await fetch('/api/seguimiento', { cache: 'no-store' });
const json = (await res.json()) as Payload & { error?: string };
if (!res.ok) throw new Error(json.error || 'Error');
setData(json);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, []);
const filteredEntries = useMemo(() => {
const entries = data?.entries || [];
const q = query.trim().toLowerCase();
// Histórico completo (más reciente → más antiguo). El periodo del header
// no oculta notas: el doc Superhuman/Drive acumula todos los días.
return entries.filter((e) => {
if (!q) return true;
const blob = `${e.title} ${e.summary || ''} ${e.body_md || ''}`.toLowerCase();
return blob.includes(q);
});
}, [data, query]);
async function saveNote() {
if (!richTextHasContent(noteBody)) return;
setNoteBusy(true);
setNoteMsg(null);
try {
const res = await fetch('/api/seguimiento', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: noteTitle.trim() || 'Nota de seguimiento',
body: noteBody,
occurredAt: new Date().toISOString(),
}),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || 'No se pudo guardar');
setNoteTitle('');
setNoteBody('');
setNoteMsg('Nota guardada');
await load();
} catch (e) {
setNoteMsg(e instanceof Error ? e.message : 'Error');
} finally {
setNoteBusy(false);
}
}
async function syncSuperhuman() {
if (!data?.tenantId) return;
setSyncBusy(true);
setNoteMsg(null);
try {
const res = await fetch('/api/admin/superhuman/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenantId: data.tenantId,
exportBodies: true,
maxEntries: 120,
}),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || 'Sync falló');
const first = Array.isArray(json.results) ? json.results[0] : null;
const n =
typeof first?.upserted === 'number'
? first.upserted
: typeof json.upserted === 'number'
? json.upserted
: null;
setNoteMsg(
n != null
? `Superhuman: ${n} entradas sincronizadas`
: first?.status === 'unmatched'
? 'Sin doc Superhuman emparejado por nombre'
: 'Superhuman sincronizado'
);
await load();
} catch (e) {
setNoteMsg(e instanceof Error ? e.message : 'Error sync');
} finally {
setSyncBusy(false);
}
}
if (loading && !data) {
return (
<p className="text-sm text-gray-500 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Cargando seguimiento…
</p>
);
}
if (err) {
return (
<p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{err}
</p>
);
}
if (!data) return null;
if (data.portfolioWide) {
return (
<p className="text-sm text-gray-600 border border-gray-200 rounded-xl p-6 bg-white">
{data.hint ||
'Elige un cliente en Contexto para ver su seguimiento.'}
</p>
);
}
return (
<div className="space-y-6">
<PageHeader
eyebrow="Operación"
title="Seguimiento"
description={`${data.label} · histórico de notas (más reciente primero). El periodo del header no filtra este timeline.`}
/>
<section className="rounded-2xl border border-gray-200 bg-white p-5 space-y-3">
<div className="flex items-center gap-2 text-gray-900 font-medium">
<MessageSquareText className="w-4 h-4 text-violet-500" />
Nueva nota
</div>
<p className="text-xs text-gray-500">
Formato enriquecido (negrita, color, resaltado, listas, enlaces). El
histórico también incluye entradas de Superhuman / Google Doc.
</p>
<input
value={noteTitle}
onChange={(e) => setNoteTitle(e.target.value)}
placeholder="Título (opcional)"
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900"
/>
<NoteRichEditor
value={noteBody}
onChange={setNoteBody}
placeholder="Comentarios, hipótesis, bloqueos, siguientes pasos…"
minHeight="160px"
/>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
disabled={noteBusy || !richTextHasContent(noteBody)}
onClick={() => void saveNote()}
className="inline-flex items-center gap-2 rounded-xl bg-brand-500 text-white px-4 py-2 text-sm font-semibold disabled:opacity-50"
>
<Send className="w-3.5 h-3.5" />
{noteBusy ? 'Guardando…' : 'Añadir al seguimiento'}
</button>
<button
type="button"
disabled={syncBusy || !data.tenantId}
onClick={() => void syncSuperhuman()}
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 disabled:opacity-50"
>
{syncBusy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Radio className="w-3.5 h-3.5 text-emerald-600" />
)}
Traer notas Superhuman
</button>
{noteMsg ? (
<p className="text-xs text-emerald-700">{noteMsg}</p>
) : null}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 text-gray-900 font-medium">
<Radio className="w-4 h-4 text-emerald-500" />
Histórico
<span className="text-xs text-gray-500 font-normal">
({filteredEntries.length})
</span>
</div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filtrar…"
className="w-full sm:w-64 rounded-lg border border-gray-200 px-3 py-1.5 text-sm"
/>
</div>
{filteredEntries.length === 0 ? (
<div className="rounded-xl border border-dashed border-gray-200 px-4 py-10 text-center text-sm text-gray-500">
Sin notas en este periodo. Añade una arriba o sincroniza Superhuman
desde Agencia (docs «seguimiento»).
</div>
) : (
<ol className="relative border-l border-gray-200 ml-3 space-y-4">
{filteredEntries.map((e) => {
const open = openId === e.id;
const when = e.occurred_at
? new Date(e.occurred_at).toLocaleDateString('es-ES', {
day: '2-digit',
month: 'short',
year: 'numeric',
})
: '—';
return (
<li key={e.id} className="ml-4">
<span className="absolute -left-1.5 mt-1.5 w-3 h-3 rounded-full bg-brand-500 ring-4 ring-white" />
<button
type="button"
onClick={() => setOpenId(open ? null : e.id)}
className="w-full text-left rounded-xl border border-gray-200 bg-white hover:border-gray-300 p-4"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="flex flex-wrap items-center gap-2 mb-1">
<span
className={`text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border ${typeTone(e.entry_type)}`}
>
{typeLabel(e.entry_type)}
</span>
<span className="text-[11px] text-gray-500">{when}</span>
{e.source === 'superhuman' || e.browser_link ? (
<span className="text-[10px] text-gray-400">
Superhuman
</span>
) : null}
</div>
<p className="text-sm font-semibold text-gray-900">
{e.title}
</p>
{e.summary && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{e.summary}
</p>
)}
</div>
{e.browser_link && (
<a
href={e.browser_link}
target="_blank"
rel="noreferrer"
onClick={(ev) => ev.stopPropagation()}
className="inline-flex items-center gap-1 text-[11px] text-brand-600"
>
Abrir <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
{open && e.body_md && (
<div
className="mt-3 text-sm text-gray-800 bg-gray-50 border border-gray-100 rounded-lg p-4 max-h-[36rem] overflow-y-auto [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5 [&_a]:text-brand-600 [&_a]:underline [&_strong]:font-semibold [&_b]:font-semibold [&_em]:italic [&_i]:italic [&_u]:underline [&_p]:my-1.5"
onClick={(ev) => {
const t = ev.target as HTMLElement;
if (t.closest('a')) ev.stopPropagation();
}}
dangerouslySetInnerHTML={{
__html: looksLikeHtml(e.body_md)
? e.body_md
: `<pre class="whitespace-pre-wrap font-sans m-0 text-xs">${escapeHtml(e.body_md)}</pre>`,
}}
/>
)}
</button>
</li>
);
})}
</ol>
)}
</section>
</div>
);
}