feat: periodo global, métricas negocio, seguimiento notas, Drive/Social/Orgánico

Selector de fechas en header (con Personalizado) compartido en el dashboard; Métricas con Ventas/CAC/ROAS CRM, cruce canal y gráficas diarias; Seguimiento solo notas + sync Superhuman; Activos con navegación Drive; Social con métricas IG/FB; Orgánico con prioridades SEO/GEO expertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 15:45:30 +02:00
parent eb1622c88c
commit 3425364b07
15 changed files with 1067 additions and 1011 deletions

View File

@@ -1,35 +1,20 @@
'use client';
/**
* Seguimiento = notas + histórico (Superhuman + manual).
* Ads/KPIs viven en Métricas.
*/
import React, { useEffect, useMemo, useState } from 'react';
import {
ExternalLink,
LineChart as LineIcon,
Loader2,
MessageSquareText,
Radio,
Send,
CalendarRange,
DollarSign,
Users,
MousePointerClick,
Target,
TrendingUp,
} from 'lucide-react';
import {
AdDailyRow,
ChannelWeekPoint,
KpiWeeklyRow,
formatEur,
formatNum,
sumDaily,
sumWeekly,
} from '@/lib/metrics';
import { PageHeader } from '@/components/ui';
import DateRangePicker from '@/components/DateRangePicker';
import {
RangePreset,
resolveAdRange,
} from '@/lib/ad-tracker-range';
import { useDateRange } from '@/components/DateRangeContext';
type Entry = {
id: string;
@@ -38,12 +23,6 @@ type Entry = {
summary?: string | null;
body_md?: string | null;
browser_link?: string | null;
metrics?: {
ventas?: number;
cpl?: number;
cac?: number;
periodo?: string;
};
occurred_at?: string | null;
source?: string;
};
@@ -51,30 +30,11 @@ type Entry = {
type Payload = {
label: string;
portfolioWide?: boolean;
tenantId?: string;
hint?: string;
entries: Entry[];
weekly?: KpiWeeklyRow[];
daily?: AdDailyRow[];
channelSeries: {
weeks: string[];
channels: string[];
points: ChannelWeekPoint[];
};
dailySummary?: { days: number; spend: number; leads: number };
};
type MetricKey = 'leads' | 'spend' | 'cpl';
const CHANNEL_COLORS = [
'#E58A3C',
'#38BDF8',
'#34D399',
'#A78BFA',
'#F472B6',
'#FBBF24',
'#94A3B8',
];
function typeLabel(t: string) {
if (t === 'meeting') return 'Reunión';
if (t === 'kpis') return 'KPIs';
@@ -83,204 +43,41 @@ function typeLabel(t: string) {
}
function typeTone(t: string) {
if (t === 'meeting') return 'bg-amber-500/15 text-amber-300 border-amber-500/30';
if (t === 'kpis') return 'bg-sky-500/15 text-sky-300 border-sky-500/30';
if (t === 'topic') return 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30';
return 'bg-violet-500/15 text-violet-300 border-violet-500/30';
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 inRange(isoDate: string | undefined | null, since: string, until: string) {
if (!isoDate) return false;
if (!isoDate) return true; // keep undated notes visible
const d = isoDate.slice(0, 10);
return d >= since && d <= until;
}
function ChannelEvolutionChart({
weeks,
channels,
points,
metric,
activeChannels,
}: {
weeks: string[];
channels: string[];
points: ChannelWeekPoint[];
metric: MetricKey;
activeChannels: Set<string>;
}) {
const [hover, setHover] = useState<{
week: string;
channel: string;
value: number;
x: number;
y: number;
} | null>(null);
const width = 720;
const height = 260;
const pad = { t: 16, r: 16, b: 36, l: 44 };
const innerW = width - pad.l - pad.r;
const innerH = height - pad.t - pad.b;
const series = useMemo(() => {
return channels
.filter((c) => activeChannels.has(c))
.map((channel, i) => {
const vals = weeks.map((week) => {
const p = points.find((x) => x.week === week && x.channel === channel);
return p ? Number(p[metric] || 0) : 0;
});
return {
channel,
color: CHANNEL_COLORS[i % CHANNEL_COLORS.length],
vals,
};
});
}, [channels, activeChannels, weeks, points, metric]);
const maxY = Math.max(1, ...series.flatMap((s) => s.vals));
const xAt = (i: number) =>
pad.l + (weeks.length <= 1 ? innerW / 2 : (i / (weeks.length - 1)) * innerW);
const yAt = (v: number) => pad.t + innerH - (v / maxY) * innerH;
if (!weeks.length || !series.length) {
return (
<div className="h-[260px] flex items-center justify-center text-sm text-gray-500 border border-dashed border-gray-200 rounded-xl">
Sin serie en el rango seleccionado.
</div>
);
}
return (
<div className="relative w-full overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full min-w-[520px] h-auto"
role="img"
aria-label={`Evolución ${metric} por canal`}
>
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
const y = pad.t + innerH * (1 - t);
const val = maxY * t;
return (
<g key={t}>
<line
x1={pad.l}
x2={width - pad.r}
y1={y}
y2={y}
stroke="#1F2937"
strokeWidth={1}
/>
<text
x={pad.l - 8}
y={y + 3}
textAnchor="end"
className="fill-gray-500"
fontSize={10}
>
{formatNum(val)}
</text>
</g>
);
})}
{series.map((s) => {
const d = s.vals
.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xAt(i)} ${yAt(v)}`)
.join(' ');
return (
<g key={s.channel}>
<path d={d} fill="none" stroke={s.color} strokeWidth={2.5} />
{s.vals.map((v, i) => (
<circle
key={`${s.channel}-${weeks[i]}`}
cx={xAt(i)}
cy={yAt(v)}
r={
hover?.week === weeks[i] && hover?.channel === s.channel
? 5
: 3.5
}
fill={s.color}
className="cursor-pointer"
onMouseEnter={() =>
setHover({
week: weeks[i],
channel: s.channel,
value: v,
x: xAt(i),
y: yAt(v),
})
}
onMouseLeave={() => setHover(null)}
/>
))}
</g>
);
})}
{weeks.map((w, i) => (
<text
key={w}
x={xAt(i)}
y={height - 10}
textAnchor="middle"
className="fill-gray-500"
fontSize={9}
>
{w.slice(5)}
</text>
))}
</svg>
{hover && (
<div
className="pointer-events-none absolute z-10 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-[11px] text-gray-900 shadow-xl"
style={{
left: Math.min(hover.x + 8, 560),
top: Math.max(hover.y - 36, 8),
}}
>
<p className="font-semibold capitalize">{hover.channel}</p>
<p className="text-gray-400">{hover.week}</p>
<p>
{metric === 'spend'
? formatEur(hover.value)
: metric === 'cpl'
? `${formatNum(hover.value)} € CPL`
: `${formatNum(hover.value)} leads`}
</p>
</div>
)}
</div>
);
}
export default function SeguimientoBoard() {
const { since, until, label: rangeLabel } = useDateRange();
const [data, setData] = useState<Payload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [metric, setMetric] = useState<MetricKey>('leads');
const [activeChannels, setActiveChannels] = useState<Set<string>>(new Set());
const [openId, setOpenId] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [preset, setPreset] = useState<RangePreset>('last_30d');
const [customSince, setCustomSince] = useState('');
const [customUntil, setCustomUntil] = 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);
setError(null);
setErr(null);
try {
const r = await fetch('/api/seguimiento', { cache: 'no-store' });
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'Error cargando seguimiento');
setData(j);
setActiveChannels(new Set(j.channelSeries?.channels || []));
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) {
setError(e instanceof Error ? e.message : 'Error');
setErr(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
@@ -290,110 +87,94 @@ export default function SeguimientoBoard() {
void load();
}, []);
const range = useMemo(
() => resolveAdRange(preset, { since: customSince, until: customUntil }),
[preset, customSince, customUntil]
);
const rangedWeekly = useMemo(() => {
const rows = data?.weekly || [];
return rows.filter((r) => inRange(r.week, range.since, range.until));
}, [data?.weekly, range.since, range.until]);
const rangedDaily = useMemo(() => {
const rows = data?.daily || [];
return rows.filter((r) => inRange(r.date, range.since, range.until));
}, [data?.daily, range.since, range.until]);
const periodTotals = useMemo(() => {
const w = sumWeekly(rangedWeekly);
const d = sumDaily(rangedDaily);
const spend = w.spend || d.spend;
const leads = w.leads || d.leads;
return {
spend,
leads,
cpl: leads > 0 ? spend / leads : w.cpl,
cac: w.cac,
revenue: w.revenue,
roas: w.roas,
impressions: d.impressions,
clicks: d.clicks,
};
}, [rangedWeekly, rangedDaily]);
const rangedChannel = useMemo(() => {
const points = (data?.channelSeries.points || []).filter((p) =>
inRange(p.week, range.since, range.until)
);
const weeks = [...new Set(points.map((p) => p.week))].sort();
const channels = data?.channelSeries.channels || [];
return { weeks, channels, points };
}, [data?.channelSeries, range.since, range.until]);
const filteredEntries = useMemo(() => {
const list = (data?.entries || []).filter((e) => {
if (!e.occurred_at) return true;
return inRange(e.occurred_at, range.since, range.until);
});
const entries = data?.entries || [];
const q = query.trim().toLowerCase();
if (!q) return list;
return list.filter(
(e) =>
e.title.toLowerCase().includes(q) ||
(e.summary || '').toLowerCase().includes(q) ||
(e.body_md || '').toLowerCase().includes(q)
);
}, [data, query, range.since, range.until]);
function toggleChannel(ch: string) {
setActiveChannels((prev) => {
const next = new Set(prev);
if (next.has(ch)) next.delete(ch);
else next.add(ch);
return next;
return entries.filter((e) => {
if (!inRange(e.occurred_at, since, until)) return false;
if (!q) return true;
const blob = `${e.title} ${e.summary || ''} ${e.body_md || ''}`.toLowerCase();
return blob.includes(q);
});
}
}, [data, query, since, until]);
async function saveNote() {
if (!noteBody.trim()) return;
setNoteBusy(true);
setNoteMsg(null);
try {
const r = await fetch('/api/seguimiento', {
const res = await fetch('/api/seguimiento', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: noteTitle.trim() || undefined,
title: noteTitle.trim() || 'Nota de seguimiento',
body: noteBody.trim(),
occurredAt: new Date().toISOString(),
}),
});
const j = await r.json();
if (!r.ok) {
setNoteMsg(j.error || 'No se pudo guardar');
return;
}
const json = await res.json();
if (!res.ok) throw new Error(json.error || 'No se pudo guardar');
setNoteTitle('');
setNoteBody('');
setNoteMsg('Nota añadida al seguimiento');
setNoteMsg('Nota guardada');
await load();
} catch (e) {
setNoteMsg(e instanceof Error ? e.message : 'Error');
} finally {
setNoteBusy(false);
}
}
if (loading) {
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: 80,
}),
});
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 (
<div className="flex items-center gap-2 text-sm text-gray-400 py-16 justify-center">
<p className="text-sm text-gray-500 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Cargando seguimiento…
</div>
</p>
);
}
if (error) {
if (err) {
return (
<p className="text-sm text-red-400 border border-red-500/20 rounded-lg px-3 py-2">
{error}
<p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{err}
</p>
);
}
@@ -402,210 +183,76 @@ export default function SeguimientoBoard() {
if (data.portfolioWide) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center">
<Radio className="w-8 h-8 text-brand-500 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-gray-900">Seguimiento</h2>
<p className="text-sm text-gray-400 mt-2 max-w-md mx-auto">
{data.hint ||
'Selecciona un cliente en el encabezado para ver la evolución y las notas.'}
</p>
</div>
<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>
);
}
const cards = [
{
label: 'Inversión',
value: formatEur(periodTotals.spend),
icon: <DollarSign className="w-4 h-4 text-brand-500" />,
},
{
label: 'Leads',
value: formatNum(periodTotals.leads),
icon: <Users className="w-4 h-4 text-blue-400" />,
},
{
label: 'CPL',
value: periodTotals.cpl ? `${formatNum(periodTotals.cpl)} €` : '—',
icon: <MousePointerClick className="w-4 h-4 text-violet-400" />,
},
{
label: 'CAC',
value: periodTotals.cac ? `${formatNum(periodTotals.cac)} €` : '—',
icon: <Target className="w-4 h-4 text-amber-400" />,
},
{
label: 'Revenue',
value: formatEur(periodTotals.revenue),
icon: <TrendingUp className="w-4 h-4 text-emerald-400" />,
},
{
label: 'ROAS',
value: periodTotals.roas ? `${formatNum(periodTotals.roas)}x` : '—',
icon: <TrendingUp className="w-4 h-4 text-emerald-300" />,
},
];
return (
<div className="space-y-8">
<div className="space-y-6">
<PageHeader
eyebrow="Rendimiento"
eyebrow="Operación"
title="Seguimiento"
description={`${data.label} · mismas KPIs que Métricas, filtrables por periodo + notas del equipo`}
description={`${data.label} · notas e histórico del equipo (periodo ${rangeLabel}). Ads y KPIs están en Métricas.`}
/>
{/* Date selector */}
<section className="rounded-2xl border border-gray-200 bg-white p-4 space-y-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<CalendarRange className="w-3.5 h-3.5 text-sky-500" />
Periodo
</div>
<DateRangePicker
preset={preset}
customSince={customSince || range.since}
customUntil={customUntil || range.until}
onPreset={(p) => {
setPreset(p);
if (p !== 'custom') {
const r = resolveAdRange(p);
setCustomSince(r.since);
setCustomUntil(r.until);
}
}}
onCustomChange={(since, until) => {
setCustomSince(since);
setCustomUntil(until);
}}
/>
</section>
{/* KPI cards (Métricas-like) */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
{cards.map((c) => (
<div
key={c.label}
className="rounded-xl border border-gray-200 bg-white px-3 py-3"
>
<div className="flex items-center justify-between mb-1">
<p className="text-[10px] uppercase tracking-wider text-gray-500">
{c.label}
</p>
{c.icon}
</div>
<p className="text-xl font-bold text-gray-900 tabular-nums">{c.value}</p>
</div>
))}
</div>
{/* Channel evolution */}
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-gray-900 font-medium">
<LineIcon className="w-4 h-4 text-brand-500" />
Evolución por canal
</div>
<div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 bg-gray-50">
{(
[
['leads', 'Leads'],
['spend', 'Inversión'],
['cpl', 'CPL'],
] as [MetricKey, string][]
).map(([k, label]) => (
<button
key={k}
type="button"
onClick={() => setMetric(k)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
metric === k
? 'bg-brand-500 text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{label}
</button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
{rangedChannel.channels.map((ch, i) => {
const on = activeChannels.has(ch);
const color = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
return (
<button
key={ch}
type="button"
onClick={() => toggleChannel(ch)}
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-all capitalize ${
on
? 'border-gray-600 text-gray-900 bg-gray-900'
: 'border-gray-200 text-gray-500 opacity-60'
}`}
>
<span
className="w-2 h-2 rounded-full"
style={{ background: on ? color : '#4B5563' }}
/>
{ch}
</button>
);
})}
</div>
<ChannelEvolutionChart
weeks={rangedChannel.weeks}
channels={rangedChannel.channels}
points={rangedChannel.points}
metric={metric}
activeChannels={activeChannels}
/>
</section>
{/* Notes box */}
<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-400" />
Comentarios y notas
<MessageSquareText className="w-4 h-4 text-violet-500" />
Nueva nota
</div>
<p className="text-xs text-gray-500">
Añade observaciones del periodo (reunión, hipótesis, bloqueos,
siguientes pasos…). Quedan en el timeline de seguimiento.
Se guarda con fecha. El histórico también incluye entradas
sincronizadas desde Superhuman Docs (docs de seguimiento).
</p>
<input
value={noteTitle}
onChange={(e) => setNoteTitle(e.target.value)}
placeholder="Título (opcional)"
className="w-full rounded-lg bg-black/30 border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-600"
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900"
/>
<textarea
value={noteBody}
onChange={(e) => setNoteBody(e.target.value)}
placeholder="Escribe aquí comentarios, notas o cualquier cosa relevante…"
className="w-full min-h-[120px] rounded-lg bg-black/30 border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-600"
placeholder="Comentarios, hipótesis, bloqueos, siguientes pasos…"
className="w-full min-h-[120px] rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900"
/>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
disabled={noteBusy || !noteBody.trim()}
onClick={saveNote}
className="inline-flex items-center gap-2 rounded-xl bg-violet-500/20 border border-violet-500/40 text-violet-200 px-4 py-2 text-sm font-semibold disabled:opacity-50"
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-400">{noteMsg}</p>
<p className="text-xs text-emerald-700">{noteMsg}</p>
) : null}
</div>
</section>
{/* Timeline */}
<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-400" />
Timeline seguimiento
<Radio className="w-4 h-4 text-emerald-500" />
Histórico
<span className="text-xs text-gray-500 font-normal">
({filteredEntries.length})
</span>
@@ -613,15 +260,15 @@ export default function SeguimientoBoard() {
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filtrar temas, CPL, notas…"
className="w-full sm:w-64 rounded-lg bg-black/30 border border-gray-200 px-3 py-1.5 text-sm text-gray-900 placeholder:text-gray-600 focus:outline-none focus:border-brand-500/50"
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 entradas en este periodo. Añade una nota arriba o sincroniza
Superhuman Docs desde Agencia.
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">
@@ -636,11 +283,11 @@ export default function SeguimientoBoard() {
: '—';
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-[#0B0F19]" />
<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-200 transition-colors p-4"
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>
@@ -651,12 +298,17 @@ export default function SeguimientoBoard() {
{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-400 mt-1 line-clamp-2">
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{e.summary}
</p>
)}
@@ -667,39 +319,17 @@ export default function SeguimientoBoard() {
target="_blank"
rel="noreferrer"
onClick={(ev) => ev.stopPropagation()}
className="inline-flex items-center gap-1 text-[11px] text-brand-400 hover:text-brand-700"
className="inline-flex items-center gap-1 text-[11px] text-brand-600"
>
Superhuman <ExternalLink className="w-3 h-3" />
Abrir <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
{open && e.body_md && (
<pre className="mt-3 whitespace-pre-wrap text-xs text-gray-600 bg-black/30 border border-gray-200 rounded-lg p-3 max-h-64 overflow-y-auto font-sans">
<pre className="mt-3 whitespace-pre-wrap text-xs text-gray-700 bg-gray-50 border border-gray-100 rounded-lg p-3 max-h-64 overflow-y-auto font-sans">
{e.body_md}
</pre>
)}
{open &&
e.metrics &&
(e.metrics.ventas || e.metrics.cpl) && (
<div className="mt-2 flex flex-wrap gap-2 text-[11px]">
{e.metrics.ventas != null && (
<span className="px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-300">
Ventas {e.metrics.ventas}
</span>
)}
{e.metrics.cpl != null && (
<span className="px-2 py-0.5 rounded bg-sky-500/10 text-sky-300">
CPL ≈ {formatNum(e.metrics.cpl)} €
</span>
)}
{e.metrics.cac != null && (
<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-300">
CAC ≈ {formatNum(e.metrics.cac)} €
</span>
)}
</div>
)}
</button>
</li>
);