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:
4Ventures
2026-09-23 09:33:22 +02:00
parent a7704567bd
commit 0d286dc66c
12 changed files with 1295 additions and 150 deletions

View File

@@ -28,6 +28,12 @@ export type CreativeFormat =
| 'story'
| 'rsa'
| 'pmax'
| 'pmax_landscape'
| 'pmax_square'
| 'pmax_portrait'
| 'pmax_vertical'
| 'pmax_logo'
| 'youtube'
| 'other';
export type CreativePreviewData = {
@@ -350,13 +356,45 @@ function GoogleSearchPreview({ data }: { data: CreativePreviewData }) {
);
}
function GoogleDisplayPreview({ data }: { data: CreativePreviewData }) {
function GoogleDisplayPreview({
data,
aspect = 'landscape',
}: {
data: CreativePreviewData;
aspect?: 'landscape' | 'square' | 'portrait' | 'vertical' | 'logo';
}) {
const ratio =
aspect === 'square'
? 'aspect-square'
: aspect === 'portrait'
? 'aspect-[4/5]'
: aspect === 'vertical'
? 'aspect-[9/16]'
: aspect === 'logo'
? 'aspect-square max-w-[140px] mx-auto'
: 'aspect-[1.91/1]';
const label =
aspect === 'square'
? '1:1 · cuadrado'
: aspect === 'portrait'
? '4:5 · portrait'
: aspect === 'vertical'
? '9:16 · vertical'
: aspect === 'logo'
? 'Logo'
: '1.91:1 · landscape';
return (
<div className="rounded-2xl border border-gray-700 bg-[#111827] overflow-hidden">
<p className="text-[10px] font-bold uppercase tracking-wider text-sky-400 px-3 py-2 border-b border-gray-800">
Google Ads · display / PMax
Google Ads · Performance Max · {label}
</p>
<MediaBlock data={data} />
<div className={`${ratio} overflow-hidden bg-gray-900`}>
<MediaBlock
data={data}
tall={aspect === 'vertical' || aspect === 'portrait'}
/>
</div>
<div className="p-3 bg-white text-gray-900">
<p className="text-sm font-bold line-clamp-2">
{data.headline || data.title}
@@ -370,6 +408,30 @@ function GoogleDisplayPreview({ data }: { data: CreativePreviewData }) {
);
}
function YouTubePreview({ data }: { data: CreativePreviewData }) {
return (
<div className="rounded-2xl border border-gray-700 bg-[#111827] overflow-hidden">
<p className="text-[10px] font-bold uppercase tracking-wider text-red-400 px-3 py-2 border-b border-gray-800">
Google Ads · YouTube
</p>
<div className="aspect-video relative bg-black">
<MediaBlock data={data} tall />
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<Play className="w-12 h-12 text-white/80" />
</div>
</div>
<div className="p-3 bg-white text-gray-900">
<p className="text-sm font-bold line-clamp-2">
{data.headline || data.title}
</p>
<p className="text-[11px] text-gray-600 line-clamp-2 mt-1">
{data.description || data.primary_text}
</p>
</div>
</div>
);
}
/** Renders a placement mock like Ad Tracker phone / Google RSA cards. */
export default function CreativePlacementPreview({
data,
@@ -384,13 +446,31 @@ export default function CreativePlacementPreview({
let body: React.ReactNode;
if (channel === 'google_ads' || format === 'rsa') {
body =
format === 'pmax' || placement === 'display' ? (
<GoogleDisplayPreview data={data} />
) : (
<GoogleSearchPreview data={data} />
);
if (format === 'youtube') {
body = <YouTubePreview data={data} />;
} else if (
format === 'pmax_landscape' ||
format === 'pmax_square' ||
format === 'pmax_portrait' ||
format === 'pmax_vertical' ||
format === 'pmax_logo' ||
format === 'pmax' ||
(channel === 'google_ads' &&
(placement === 'display' || format.startsWith('pmax')))
) {
const aspect =
format === 'pmax_square' || format === 'pmax_logo'
? format === 'pmax_logo'
? 'logo'
: 'square'
: format === 'pmax_portrait'
? 'portrait'
: format === 'pmax_vertical'
? 'vertical'
: 'landscape';
body = <GoogleDisplayPreview data={data} aspect={aspect} />;
} else if (channel === 'google_ads' || format === 'rsa') {
body = <GoogleSearchPreview data={data} />;
} else if (
channel === 'stories' ||
placement === 'stories' ||
@@ -443,8 +523,14 @@ export const FORMAT_OPTIONS: { id: CreativeFormat; label: string }[] = [
{ id: 'carousel', label: 'Carrusel' },
{ id: 'reel', label: 'Reel' },
{ id: 'story', label: 'Story' },
{ id: 'rsa', label: 'RSA (búsqueda)' },
{ id: 'pmax', label: 'PMax / Display' },
{ id: 'rsa', label: 'Google RSA (búsqueda)' },
{ id: 'pmax', label: 'PMax (genérico)' },
{ id: 'pmax_landscape', label: 'PMax landscape 1.91:1' },
{ id: 'pmax_square', label: 'PMax cuadrado 1:1' },
{ id: 'pmax_portrait', label: 'PMax portrait 4:5' },
{ id: 'pmax_vertical', label: 'PMax vertical 9:16' },
{ id: 'pmax_logo', label: 'PMax logo' },
{ id: 'youtube', label: 'YouTube / vídeo in-stream' },
{ id: 'other', label: 'Otro' },
];
@@ -454,5 +540,9 @@ export const PLACEMENT_OPTIONS = [
{ id: 'reels', label: 'Reels' },
{ id: 'search', label: 'Búsqueda' },
{ id: 'display', label: 'Display' },
{ id: 'youtube', label: 'YouTube' },
{ id: 'discover', label: 'Discover' },
{ id: 'gmail', label: 'Gmail' },
{ id: 'maps', label: 'Maps' },
{ id: 'other', label: 'Otro' },
];

View File

@@ -7,6 +7,7 @@ import {
Trash2,
X,
Eye,
Upload,
} from 'lucide-react';
import CreativePlacementPreview, {
CHANNEL_OPTIONS,
@@ -74,6 +75,8 @@ export default function CreativesPipeline() {
const [selected, setSelected] = useState<Creative | null>(null);
const [showForm, setShowForm] = useState(false);
const [filter, setFilter] = useState<'all' | string>('all');
const [uploading, setUploading] = useState(false);
const [uploadMsg, setUploadMsg] = useState<string | null>(null);
async function load() {
setLoading(true);
@@ -105,6 +108,47 @@ export default function CreativesPipeline() {
return creatives.filter((c) => c.status === filter);
}, [creatives, filter]);
async function uploadFile(
file: File,
creativeId?: string
): Promise<string | null> {
setUploading(true);
setUploadMsg(null);
setErr(null);
try {
const fd = new FormData();
fd.append('file', file);
if (creativeId) fd.append('creativeId', creativeId);
if (draft.title) fd.append('title', draft.title);
const res = await fetch('/api/creatives/upload', {
method: 'POST',
body: fd,
});
const data = await res.json();
if (!res.ok) {
setErr(data.error ?? 'Error al subir');
return null;
}
const url = data.media_url || data.creative?.media_url || null;
if (url) {
setDraft((d) => ({
...d,
media_url: url,
title: d.title || data.creative?.title || d.title,
format:
file.type.startsWith('video/') && !d.format.startsWith('pmax')
? 'video'
: d.format,
}));
setUploadMsg('Archivo subido · preview actualizada');
}
await load();
return data.creative?.id || creativeId || null;
} finally {
setUploading(false);
}
}
async function create(e: FormEvent) {
e.preventDefault();
setErr(null);
@@ -358,7 +402,7 @@ export default function CreativesPipeline() {
setDraft((d) => ({ ...d, media_url: e.target.value }))
}
className={inputCls}
placeholder="https://… o /media/…"
placeholder="https://… o /api/creatives/media?id=…"
/>
</Field>
<Field label="Landing">
@@ -372,6 +416,32 @@ export default function CreativesPipeline() {
/>
</Field>
</div>
<div className="rounded-xl border border-dashed border-violet-500/40 bg-violet-500/5 p-4">
<p className="text-[11px] uppercase tracking-wider text-violet-300 font-semibold mb-2 flex items-center gap-1.5">
<Upload className="w-3.5 h-3.5" /> Subir creativo (sin URL)
</p>
<input
type="file"
accept="image/*,video/mp4,video/webm,video/quicktime"
disabled={uploading}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void uploadFile(f);
e.target.value = '';
}}
className="block w-full text-xs text-gray-300 file:mr-3 file:rounded-lg file:border-0 file:bg-violet-500/20 file:px-3 file:py-1.5 file:text-violet-200 file:text-xs file:font-semibold"
/>
<p className="text-[10px] text-gray-500 mt-2">
JPG, PNG, WebP, GIF o MP4/WebM. Se guarda en el storage del
tenant y alimenta la preview.
</p>
{uploading ? (
<p className="text-xs text-violet-300 mt-2">Subiendo…</p>
) : null}
{uploadMsg ? (
<p className="text-xs text-emerald-400 mt-2">{uploadMsg}</p>
) : null}
</div>
<div className="flex gap-2 pt-1">
<button
type="submit"
@@ -638,6 +708,33 @@ export default function CreativesPipeline() {
/>
</Field>
</div>
<div className="rounded-xl border border-dashed border-violet-500/30 p-3">
<p className="text-[10px] uppercase text-violet-300 mb-2">
Reemplazar archivo
</p>
<input
type="file"
accept="image/*,video/mp4,video/webm"
disabled={uploading || busyId === selected.id}
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f || !selected) return;
const id = await uploadFile(f, selected.id);
if (id) {
setSelected((prev) =>
prev
? {
...prev,
media_url: `/api/creatives/media?id=${encodeURIComponent(prev.id)}&t=${Date.now()}`,
}
: prev
);
}
e.target.value = '';
}}
className="block w-full text-xs text-gray-300 file:mr-3 file:rounded-lg file:border-0 file:bg-violet-500/20 file:px-3 file:py-1.5 file:text-violet-200"
/>
</div>
</div>
</div>
<div className="p-4 border-t border-gray-800 bg-[#111827] flex flex-wrap justify-between gap-2">

View File

@@ -0,0 +1,218 @@
import React from 'react';
import Link from 'next/link';
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
const MONTH_NAMES = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
function colorFor(piece: RrssPiece): string {
if (piece.color) return piece.color;
if (piece.platform === 'linkedin') return '#7C66B8';
if ((piece.format || '').toLowerCase().includes('stor')) return '#374151';
const hay = `${piece.pillar || ''} ${piece.meta_line || ''} ${piece.title}`.toLowerCase();
if (hay.includes('familia') || hay.includes('bienestar')) return '#E08E2E';
if (hay.includes('sostenib')) return '#0F9D8F';
if (hay.includes('b2b') || hay.includes('hosteler')) return '#7C66B8';
if (hay.includes('prueba') || hay.includes('reseña')) return '#8A93A6';
return '#2071cf';
}
function MiniMonth({
year,
monthIndex,
plan,
}: {
year: number;
monthIndex: number; // 0-11
plan?: RrssMonthPlan;
}) {
const monthKey = `${year}-${String(monthIndex + 1).padStart(2, '0')}`;
const first = new Date(Date.UTC(year, monthIndex, 1));
const daysInMonth = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
const startPad = (first.getUTCDay() + 6) % 7;
const live = Boolean(plan);
const byDate = new Map<string, RrssPiece[]>();
for (const p of plan?.pieces || []) {
const k = p.date.slice(0, 10);
(byDate.get(k) || byDate.set(k, []).get(k)!).push(p);
}
const headerCls = live
? 'flex items-center justify-between gap-2 bg-gradient-to-r from-[#1a2a63] to-[#2071CF] text-white px-3.5 py-2.5'
: 'flex items-center justify-between gap-2 bg-[#9aa6c0] text-white px-3.5 py-2.5';
const body = (
<>
<div className={headerCls}>
<span className="text-[13px] font-extrabold tracking-wider uppercase">
{MONTH_NAMES[monthIndex]}
</span>
<em className="not-italic text-[10px] font-bold tracking-wider uppercase px-2 py-1 rounded-full bg-white/20">
{live ? 'Publicado' : 'Pronto'}
</em>
</div>
<div className="grid grid-cols-7 gap-0.5 px-2.5 pt-3">
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d, i) => (
<span
key={d}
className={`text-center text-[10px] font-bold ${
i >= 5 ? 'text-sky-400' : 'text-gray-500'
}`}
>
{d}
</span>
))}
</div>
<div className="grid grid-cols-7 gap-0.5 px-2.5 pb-2.5 pt-1">
{Array.from({ length: startPad }).map((_, i) => (
<span key={`e-${i}`} className="aspect-square invisible" />
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const key = `${monthKey}-${String(day).padStart(2, '0')}`;
const hits = byDate.get(key) || [];
const weekend =
new Date(Date.UTC(year, monthIndex, day)).getUTCDay() % 6 === 0;
const bg = hits[0] ? colorFor(hits[0]) : undefined;
return (
<span
key={key}
title={hits.map((h) => h.title).join(' · ') || undefined}
className={`aspect-square flex items-center justify-center text-[11px] font-bold rounded-full ${
weekend && !bg ? 'text-sky-400' : 'text-gray-300'
}`}
style={
bg
? {
background: bg,
color: '#fff',
boxShadow: 'inset 0 0 0 1px rgba(255,255,255,.35)',
}
: undefined
}
>
{day}
</span>
);
})}
</div>
<div className="mt-auto px-3.5 py-3 text-xs border-t border-gray-800 text-gray-500">
{live
? `${plan!.pieces.length} piezas · abrir mes`
: 'Pendiente de publicar'}
</div>
</>
);
const className = `rounded-2xl border overflow-hidden flex flex-col min-h-full transition-all ${
live
? 'border-gray-700 bg-[#111827] hover:border-sky-500/50 hover:-translate-y-0.5 shadow-lg shadow-sky-900/10'
: 'border-gray-800/80 bg-[#0D121F] opacity-80'
}`;
if (live) {
return (
<Link
href={`/dashboard/calendario?view=month&month=${monthKey}`}
className={className}
>
{body}
</Link>
);
}
return (
<div className={className} aria-disabled>
{body}
</div>
);
}
/** Year archive cover like /agua-miia/rrss/ig-fb/ */
export default function RrssArchiveYear({
year = 2026,
plans,
tenantLabel,
agencyPublish,
}: {
year?: number;
plans: RrssMonthPlan[];
tenantLabel: string;
agencyPublish?: React.ReactNode;
}) {
const byMonth = new Map(plans.map((p) => [p.month, p]));
const legend = plans[0]?.legend || [
{ label: 'Educativo / serie', color: '#2071cf' },
{ label: 'Familia / bienestar', color: '#E08E2E' },
{ label: 'Sostenibilidad', color: '#0F9D8F' },
{ label: 'B2B / LinkedIn', color: '#7C66B8' },
{ label: 'Stories', color: '#374151' },
];
return (
<div className="space-y-7">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<Link
href="/dashboard/calendario?view=hub"
className="text-xs text-gray-400 hover:text-white"
>
← Hub RRSS
</Link>
<h2 className="text-3xl font-extrabold text-white tracking-tight mt-2">
Calendario IG / FB {year}
</h2>
<p className="text-sm text-gray-400 mt-2 max-w-xl leading-relaxed">
Archivo mensual de contenidos. Los días redondeados y coloreados
marcan el tipo de publicación (misma paleta que en cada mes).
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="inline-flex items-center gap-2 rounded-full border border-gray-700 bg-[#111827] px-4 py-2.5 text-sm font-bold text-white">
<span className="w-2 h-2 rounded-full bg-sky-400" />
Año {year} · {tenantLabel} × 4Ventures
</div>
{agencyPublish}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 12 }).map((_, i) => {
const key = `${year}-${String(i + 1).padStart(2, '0')}`;
return (
<MiniMonth
key={key}
year={year}
monthIndex={i}
plan={byMonth.get(key)}
/>
);
})}
</div>
<div className="flex flex-wrap gap-4 text-xs text-gray-400">
{legend.map((l) => (
<span key={l.label} className="inline-flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ background: l.color }}
/>
{l.label}
</span>
))}
</div>
</div>
);
}

View File

@@ -145,9 +145,13 @@ export default function RrssMonthView({
return ['all', ...[...set].sort()];
}, [pieces]);
const filtered = pieces.filter(
(p) => platform === 'all' || p.platform === platform
);
const filtered = pieces.filter((p) => {
if (platform === 'all') return true;
if (platform === 'instagram') {
return p.platform === 'instagram' || p.platform === 'facebook';
}
return p.platform === platform;
});
const ids = pieces.map((p) => p.id);
@@ -250,9 +254,9 @@ export default function RrssMonthView({
return (
<div className="space-y-6">
{/* Hub crumbs */}
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-400">
<div>
{/* Hub crumbs + network filters */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-gray-400">
<Link href="/dashboard/calendario" className="hover:text-white">
RRSS
</Link>
@@ -268,6 +272,42 @@ export default function RrssMonthView({
</span>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setPlatform('all')}
className={`inline-flex items-center justify-center min-h-[44px] px-5 rounded-xl text-sm font-bold ${
platform === 'all'
? 'bg-white text-gray-900'
: 'bg-gray-800/80 text-gray-300 hover:bg-gray-800'
}`}
>
Todas
</button>
<button
type="button"
onClick={() => setPlatform('instagram')}
className={`inline-flex items-center justify-center min-h-[44px] px-5 rounded-xl text-sm font-bold ${
platform === 'instagram' || platform === 'facebook'
? 'bg-[#16204A] text-white ring-2 ring-sky-400/40'
: 'bg-[#16204A]/80 text-white/90 hover:bg-[#1a2a63]'
}`}
>
<Instagram className="w-4 h-4 mr-2" /> IG / FB
</button>
<button
type="button"
onClick={() => setPlatform('linkedin')}
className={`inline-flex items-center justify-center min-h-[44px] px-5 rounded-xl text-sm font-bold ${
platform === 'linkedin'
? 'bg-[#2071CF] text-white ring-2 ring-sky-300/50'
: 'bg-[#2071CF]/80 text-white/90 hover:bg-[#1a5fb8]'
}`}
>
<Linkedin className="w-4 h-4 mr-2" /> LinkedIn
</button>
</div>
{/* Hero */}
<section className="relative overflow-hidden rounded-2xl border border-gray-800 bg-gradient-to-br from-[#16204A] via-[#111827] to-[#0B0F19] p-6 md:p-8">
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-300/90">
@@ -291,7 +331,7 @@ export default function RrssMonthView({
</div>
</section>
{/* Network tabs */}
{/* Network tabs (secondary) */}
<div className="flex flex-wrap gap-1.5">
{platforms.map((p) => (
<button

View File

@@ -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>
);