feat: upload creativos, PMax formats, portada IG/FB y seguimiento con notas
Permite subir media sin URL; previews Google PMax por ratio; calendario anual estilo Miia con filtros IG/FB y LinkedIn; seguimiento con selector de fechas, KPIs y cuadro de notas. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,12 +7,28 @@ import {
|
||||
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 {
|
||||
AD_TRACKER_PRESETS,
|
||||
RangePreset,
|
||||
resolveAdRange,
|
||||
} from '@/lib/ad-tracker-range';
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -28,6 +44,7 @@ type Entry = {
|
||||
periodo?: string;
|
||||
};
|
||||
occurred_at?: string | null;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
type Payload = {
|
||||
@@ -35,6 +52,8 @@ type Payload = {
|
||||
portfolioWide?: boolean;
|
||||
hint?: string;
|
||||
entries: Entry[];
|
||||
weekly?: KpiWeeklyRow[];
|
||||
daily?: AdDailyRow[];
|
||||
channelSeries: {
|
||||
weeks: string[];
|
||||
channels: string[];
|
||||
@@ -66,7 +85,13 @@ 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-gray-500/15 text-gray-300 border-gray-500/30';
|
||||
return 'bg-violet-500/15 text-violet-300 border-violet-500/30';
|
||||
}
|
||||
|
||||
function inRange(isoDate: string | undefined | null, since: string, until: string) {
|
||||
if (!isoDate) return false;
|
||||
const d = isoDate.slice(0, 10);
|
||||
return d >= since && d <= until;
|
||||
}
|
||||
|
||||
function ChannelEvolutionChart({
|
||||
@@ -113,7 +138,6 @@ function ChannelEvolutionChart({
|
||||
}, [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;
|
||||
@@ -121,7 +145,7 @@ function ChannelEvolutionChart({
|
||||
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-800 rounded-xl">
|
||||
Aún no hay series de canal en kpi_weekly para este cliente.
|
||||
Sin serie en el rango seleccionado.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -154,14 +178,11 @@ function ChannelEvolutionChart({
|
||||
className="fill-gray-500"
|
||||
fontSize={10}
|
||||
>
|
||||
{metric === 'spend' || metric === 'cpl'
|
||||
? formatNum(val)
|
||||
: formatNum(val)}
|
||||
{formatNum(val)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{series.map((s) => {
|
||||
const d = s.vals
|
||||
.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xAt(i)} ${yAt(v)}`)
|
||||
@@ -174,7 +195,11 @@ function ChannelEvolutionChart({
|
||||
key={`${s.channel}-${weeks[i]}`}
|
||||
cx={xAt(i)}
|
||||
cy={yAt(v)}
|
||||
r={hover?.week === weeks[i] && hover?.channel === s.channel ? 5 : 3.5}
|
||||
r={
|
||||
hover?.week === weeks[i] && hover?.channel === s.channel
|
||||
? 5
|
||||
: 3.5
|
||||
}
|
||||
fill={s.color}
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() =>
|
||||
@@ -192,7 +217,6 @@ function ChannelEvolutionChart({
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{weeks.map((w, i) => (
|
||||
<text
|
||||
key={w}
|
||||
@@ -206,7 +230,6 @@ function ChannelEvolutionChart({
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{hover && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 rounded-lg border border-gray-700 bg-[#0D121F] px-2.5 py-1.5 text-[11px] text-white shadow-xl"
|
||||
@@ -238,35 +261,75 @@ export default function SeguimientoBoard() {
|
||||
const [activeChannels, setActiveChannels] = useState<Set<string>>(new Set());
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [preset, setPreset] = useState<RangePreset>('last_30d');
|
||||
const [noteTitle, setNoteTitle] = useState('');
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
const [noteBusy, setNoteBusy] = useState(false);
|
||||
const [noteMsg, setNoteMsg] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(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 || []));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(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');
|
||||
if (!cancelled) {
|
||||
setData(j);
|
||||
setActiveChannels(new Set(j.channelSeries?.channels || []));
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const range = useMemo(() => resolveAdRange(preset), [preset]);
|
||||
|
||||
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 || [];
|
||||
const list = (data?.entries || []).filter((e) => {
|
||||
if (!e.occurred_at) return true;
|
||||
return inRange(e.occurred_at, range.since, range.until);
|
||||
});
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter(
|
||||
@@ -275,7 +338,7 @@ export default function SeguimientoBoard() {
|
||||
(e.summary || '').toLowerCase().includes(q) ||
|
||||
(e.body_md || '').toLowerCase().includes(q)
|
||||
);
|
||||
}, [data, query]);
|
||||
}, [data, query, range.since, range.until]);
|
||||
|
||||
function toggleChannel(ch: string) {
|
||||
setActiveChannels((prev) => {
|
||||
@@ -286,6 +349,33 @@ export default function SeguimientoBoard() {
|
||||
});
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
if (!noteBody.trim()) return;
|
||||
setNoteBusy(true);
|
||||
setNoteMsg(null);
|
||||
try {
|
||||
const r = await fetch('/api/seguimiento', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: noteTitle.trim() || undefined,
|
||||
body: noteBody.trim(),
|
||||
}),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) {
|
||||
setNoteMsg(j.error || 'No se pudo guardar');
|
||||
return;
|
||||
}
|
||||
setNoteTitle('');
|
||||
setNoteBody('');
|
||||
setNoteMsg('Nota añadida al seguimiento');
|
||||
await load();
|
||||
} finally {
|
||||
setNoteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 py-16 justify-center">
|
||||
@@ -308,34 +398,106 @@ export default function SeguimientoBoard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-800 bg-[#111827] p-8 text-center">
|
||||
<Radio className="w-8 h-8 text-brand-500 mx-auto mb-3" />
|
||||
<h2 className="text-lg font-semibold text-white">Seguimiento por cliente</h2>
|
||||
<h2 className="text-lg font-semibold text-white">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 reunión a reunión y por canales.'}
|
||||
'Selecciona un cliente en el encabezado para ver la evolución y las notas.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { channelSeries, dailySummary } = data;
|
||||
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="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||
Seguimiento & evolución
|
||||
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-400">
|
||||
Seguimiento
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white mt-1">
|
||||
Evolución de métricas
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{data.label} · timeline Superhuman + series de canal (kpi_weekly)
|
||||
{data.label} · mismas KPIs que Métricas, filtrables por periodo +
|
||||
notas del equipo
|
||||
</p>
|
||||
</div>
|
||||
{dailySummary && dailySummary.days > 0 && (
|
||||
<div className="text-[11px] text-gray-500 bg-gray-900/50 border border-gray-800 rounded-lg px-3 py-2">
|
||||
Ads recientes · {formatEur(dailySummary.spend)} ·{' '}
|
||||
{formatNum(dailySummary.leads)} leads · {dailySummary.days} días
|
||||
</div>
|
||||
|
||||
{/* Date selector */}
|
||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<CalendarRange className="w-3.5 h-3.5 text-sky-400" />
|
||||
Periodo
|
||||
<span className="text-gray-500">· {range.label}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{AD_TRACKER_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setPreset(p.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
|
||||
preset === p.id
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-gray-800/70 text-gray-300 hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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-800 bg-[#0D121F] 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-white tabular-nums">{c.value}</p>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Channel evolution */}
|
||||
@@ -370,7 +532,7 @@ export default function SeguimientoBoard() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{channelSeries.channels.map((ch, i) => {
|
||||
{rangedChannel.channels.map((ch, i) => {
|
||||
const on = activeChannels.has(ch);
|
||||
const color = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
||||
return (
|
||||
@@ -395,20 +557,58 @@ export default function SeguimientoBoard() {
|
||||
</div>
|
||||
|
||||
<ChannelEvolutionChart
|
||||
weeks={channelSeries.weeks}
|
||||
channels={channelSeries.channels}
|
||||
points={channelSeries.points}
|
||||
weeks={rangedChannel.weeks}
|
||||
channels={rangedChannel.channels}
|
||||
points={rangedChannel.points}
|
||||
metric={metric}
|
||||
activeChannels={activeChannels}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Notes box */}
|
||||
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-5 space-y-3">
|
||||
<div className="flex items-center gap-2 text-white font-medium">
|
||||
<MessageSquareText className="w-4 h-4 text-violet-400" />
|
||||
Comentarios y notas
|
||||
</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.
|
||||
</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-800 px-3 py-2 text-sm text-white placeholder:text-gray-600"
|
||||
/>
|
||||
<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-800 px-3 py-2 text-sm text-white placeholder:text-gray-600"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
{noteBusy ? 'Guardando…' : 'Añadir al seguimiento'}
|
||||
</button>
|
||||
{noteMsg ? (
|
||||
<p className="text-xs text-emerald-400">{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-white font-medium">
|
||||
<MessageSquareText className="w-4 h-4 text-emerald-400" />
|
||||
Timeline reunión a reunión
|
||||
<Radio className="w-4 h-4 text-emerald-400" />
|
||||
Timeline seguimiento
|
||||
<span className="text-xs text-gray-500 font-normal">
|
||||
({filteredEntries.length})
|
||||
</span>
|
||||
@@ -416,15 +616,15 @@ export default function SeguimientoBoard() {
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filtrar temas, CPL, Meta…"
|
||||
placeholder="Filtrar temas, CPL, notas…"
|
||||
className="w-full sm:w-64 rounded-lg bg-black/30 border border-gray-800 px-3 py-1.5 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-brand-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredEntries.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-gray-800 px-4 py-10 text-center text-sm text-gray-500">
|
||||
Sin entradas aún. Desde Agencia → sincroniza Superhuman Docs para este
|
||||
cliente (páginas de Seguimiento; nunca contraseñas).
|
||||
Sin entradas en este periodo. Añade una nota arriba o sincroniza
|
||||
Superhuman Docs desde Agencia.
|
||||
</div>
|
||||
) : (
|
||||
<ol className="relative border-l border-gray-800 ml-3 space-y-4">
|
||||
@@ -455,7 +655,9 @@ export default function SeguimientoBoard() {
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-500">{when}</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-white">{e.title}</p>
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{e.title}
|
||||
</p>
|
||||
{e.summary && (
|
||||
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||
{e.summary}
|
||||
@@ -480,25 +682,27 @@ export default function SeguimientoBoard() {
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user