Barra tipo Docs (negrita, color, resaltado, listas, enlaces, tamaño) al crear notas. Co-authored-by: Cursor <cursoragent@cursor.com>
360 lines
13 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|