Ship monthly goals, calendar creative zoom, and legal pages.

Editable targets vs actual, pipeline review-only with client reject notes, and published pieces opening from the calendar day.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 15:17:15 +02:00
parent d411de0086
commit 965a58540e
15 changed files with 2150 additions and 438 deletions

View File

@@ -1,11 +1,18 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Calculator, TrendingUp, ShieldCheck, Sparkles } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { Calculator, ShieldCheck, Sparkles, TrendingUp } from 'lucide-react';
import { HelpDetails, SectionHead } from '@/components/dash-ui';
type SimBand = {
revenue?: number;
roas?: number;
leads?: number;
deals?: number;
};
type SimPayload = {
tenant_label?: string;
hint?: string;
target_channel?: string;
baseline?: {
historical_cpl?: number;
@@ -13,27 +20,11 @@ type SimPayload = {
avg_ticket?: number;
sample_spend?: number;
sample_leads?: number;
sample_deals?: number;
source?: string;
};
conservative_p10?: {
revenue?: number;
roas?: number;
leads?: number;
deals?: number;
};
expected_p50?: {
revenue?: number;
roas?: number;
leads?: number;
deals?: number;
};
optimistic_p90?: {
revenue?: number;
roas?: number;
leads?: number;
deals?: number;
};
conservative_p10?: SimBand;
expected_p50?: SimBand;
optimistic_p90?: SimBand;
error?: string;
};
@@ -43,6 +34,13 @@ const CHANNELS = [
{ id: 'blended', label: 'Blended' },
] as const;
function euro(v: number) {
return Number(v || 0).toLocaleString('es-ES', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
export default function BudgetSimulator({
tenantLabel,
}: {
@@ -50,33 +48,37 @@ export default function BudgetSimulator({
}) {
const [spend, setSpend] = useState<number>(1000);
const [channel, setChannel] = useState<string>('blended');
const [simulation, setSimulation] = useState<SimPayload | null>(null);
const [byChannel, setByChannel] = useState<Record<string, SimPayload>>({});
const [loading, setLoading] = useState<boolean>(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
const timer = setTimeout(() => {
void fetchSimulation(spend, channel);
}, 200);
void fetchAll(spend);
}, 220);
return () => clearTimeout(timer);
}, [spend, channel]);
}, [spend]);
async function fetchSimulation(amount: number, ch: string) {
async function fetchAll(amount: number) {
setLoading(true);
setErr(null);
try {
const res = await fetch('/api/simulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spend: amount, channel: ch }),
});
const data = (await res.json()) as SimPayload;
if (!res.ok) {
setErr(data.error || 'Error al simular');
setSimulation(null);
return;
}
setSimulation(data);
const entries = await Promise.all(
CHANNELS.map(async (ch) => {
const res = await fetch('/api/simulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spend: amount, channel: ch.id }),
});
const data = (await res.json()) as SimPayload;
return [ch.id, res.ok ? data : { error: data.error }] as const;
})
);
const next: Record<string, SimPayload> = {};
for (const [id, data] of entries) next[id] = data;
const firstErr = entries.find(([, d]) => d.error)?.[1].error;
if (firstErr && !next.blended?.expected_p50) setErr(firstErr);
setByChannel(next);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error');
} finally {
@@ -84,28 +86,21 @@ export default function BudgetSimulator({
}
}
const simulation = byChannel[channel] || null;
const p10 = simulation?.conservative_p10 || {};
const p50 = simulation?.expected_p50 || {};
const p90 = simulation?.optimistic_p90 || {};
const baseline = simulation?.baseline || {};
return (
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-gray-200 pb-4">
<div>
<div className="flex items-center gap-2">
<Calculator className="w-5 h-5 text-brand-500" />
<h3 className="text-lg font-bold text-gray-900">
Simulador «¿Qué pasa si…?»
</h3>
</div>
<p className="text-xs text-gray-400 mt-1 max-w-xl">
Proyección con CPL, tasa de cierre y ticket medio de{' '}
<strong className="text-gray-600">
{simulation?.tenant_label || tenantLabel || 'este cliente'}
</strong>
. Sin TikTok (aún sin campañas activas).
</p>
<div className="bg-white border border-gray-200 rounded-2xl p-6 space-y-6">
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 border-b border-gray-200 pb-4">
<div className="min-w-0 flex-1 [&>div]:mb-0">
<SectionHead
icon={<Calculator className="w-4 h-4" />}
title="Simulador «¿Qué pasa si…?»"
hint={`Si inviertes este presupuesto solo en el canal elegido, con el CPL histórico de ${simulation?.tenant_label || tenantLabel || 'este cliente'}. No es un split Meta+Google.`}
/>
</div>
<div className="flex items-center gap-2">
{CHANNELS.map((ch) => (
@@ -113,9 +108,9 @@ export default function BudgetSimulator({
key={ch.id}
type="button"
onClick={() => setChannel(ch.id)}
className={`px-3 py-1 rounded-lg text-xs font-semibold uppercase transition-all ${
className={`px-3 py-1.5 rounded-lg text-xs font-semibold uppercase transition-all ${
channel === ch.id
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20'
? 'bg-brand-600 text-white'
: 'bg-gray-100 text-gray-600 hover:text-gray-900'
}`}
>
@@ -130,7 +125,7 @@ export default function BudgetSimulator({
<label className="text-xs font-medium text-gray-600">
Inversión adicional proyectada
</label>
<span className="text-lg font-extrabold text-brand-400">
<span className="text-lg font-extrabold text-brand-600">
{spend.toLocaleString('es-ES')} €
{loading ? (
<span className="text-[10px] text-gray-500 ml-2 font-normal">
@@ -157,130 +152,177 @@ export default function BudgetSimulator({
</div>
{err ? (
<p className="text-xs text-red-400 border border-red-500/20 rounded-lg px-3 py-2">
<p className="text-xs text-red-500 border border-red-200 rounded-lg px-3 py-2">
{err}
</p>
) : null}
<div className="flex flex-wrap items-center gap-3 bg-gray-50 p-3 rounded-lg border border-gray-200 text-xs text-gray-600">
<span className="text-gray-400">Baseline del cliente:</span>
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-800">
<span className="text-gray-400">Baseline {CHANNELS.find((c) => c.id === channel)?.label}:</span>
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-800">
CPL:{' '}
<strong>{Number(baseline.historical_cpl || 0).toFixed(2)} €</strong>
</span>
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-800">
Cierre:{' '}
<strong>{baseline.conversion_rate_pct || 0}%</strong>
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-800">
Cierre: <strong>{baseline.conversion_rate_pct || 0}%</strong>
</span>
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-800">
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-800">
Ticket:{' '}
<strong>
{Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} €
</strong>
</span>
{baseline.sample_spend != null ? (
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-400">
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-500">
Muestra: {Number(baseline.sample_spend).toLocaleString('es-ES')} € ·{' '}
{baseline.sample_leads || 0} leads
</span>
) : null}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
<div className="bg-white border border-gray-200 rounded-xl p-4 flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-gray-400">
Conservador (p10)
</span>
<ShieldCheck className="w-4 h-4 text-amber-400" />
</div>
<p className="text-2xl font-extrabold text-gray-900">
+
{Number(p10.revenue || 0).toLocaleString('es-ES', {
minimumFractionDigits: 2,
})}{' '}
€
</p>
<p className="text-xs text-emerald-400 font-semibold mt-1">
ROAS {p10.roas || 0}x
</p>
</div>
<div className="mt-4 pt-3 border-t border-gray-200 text-xs text-gray-400 space-y-1">
<div className="flex justify-between">
<span>Leads:</span>
<strong className="text-gray-800">{p10.leads || 0}</strong>
</div>
<div className="flex justify-between">
<span>Ventas:</span>
<strong className="text-gray-800">{p10.deals || 0}</strong>
</div>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-[11px] uppercase tracking-wider text-gray-500">
<tr>
<th className="text-left font-semibold px-3 py-2">Canal</th>
<th className="text-right font-semibold px-3 py-2">CPL hist.</th>
<th className="text-right font-semibold px-3 py-2">Leads p50</th>
<th className="text-right font-semibold px-3 py-2">Ingresos p50</th>
</tr>
</thead>
<tbody>
{CHANNELS.map((ch) => {
const row = byChannel[ch.id];
const active = channel === ch.id;
return (
<tr
key={ch.id}
className={`border-t border-gray-100 ${
active ? 'bg-brand-50/70' : ''
}`}
>
<td className="px-3 py-2 font-semibold text-gray-900">
{ch.label}
{active ? (
<span className="ml-2 text-[10px] uppercase text-brand-700">
activo
</span>
) : null}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{Number(row?.baseline?.historical_cpl || 0).toFixed(2)} €
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{row?.expected_p50?.leads || 0}
</td>
<td className="px-3 py-2 text-right tabular-nums font-semibold text-gray-900">
+{euro(Number(row?.expected_p50?.revenue || 0))} €
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="bg-gradient-to-b from-brand-950/40 to-[#0B0F19] border-2 border-brand-500/60 rounded-xl p-4 flex flex-col justify-between relative shadow-lg shadow-brand-900/20">
<div className="absolute -top-2.5 right-3 bg-brand-600 text-white text-[9px] uppercase tracking-wider font-bold px-2 py-0.5 rounded-full">
Más probable
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-brand-700">
Esperado (p50)
</span>
<Sparkles className="w-4 h-4 text-brand-400" />
</div>
<p className="text-2xl font-extrabold text-gray-900">
+
{Number(p50.revenue || 0).toLocaleString('es-ES', {
minimumFractionDigits: 2,
})}{' '}
€
</p>
<p className="text-xs text-brand-400 font-semibold mt-1">
ROAS {p50.roas || 0}x
</p>
</div>
<div className="mt-4 pt-3 border-t border-brand-800/40 text-xs text-gray-600 space-y-1">
<div className="flex justify-between">
<span>Leads:</span>
<strong className="text-gray-900">{p50.leads || 0}</strong>
</div>
<div className="flex justify-between">
<span>Ventas:</span>
<strong className="text-gray-900">{p50.deals || 0}</strong>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-1">
<BandCard
title="Conservador (p10)"
icon={<ShieldCheck className="w-4 h-4 text-amber-500" />}
band={p10}
highlight={false}
/>
<BandCard
title="Esperado (p50)"
icon={<Sparkles className="w-4 h-4 text-brand-500" />}
band={p50}
highlight
/>
<BandCard
title="Optimista (p90)"
icon={<TrendingUp className="w-4 h-4 text-emerald-500" />}
band={p90}
highlight={false}
/>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4 flex flex-col justify-between">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-gray-400">
Optimista (p90)
</span>
<TrendingUp className="w-4 h-4 text-emerald-400" />
</div>
<p className="text-2xl font-extrabold text-gray-900">
+
{Number(p90.revenue || 0).toLocaleString('es-ES', {
minimumFractionDigits: 2,
})}{' '}
€
</p>
<p className="text-xs text-emerald-400 font-semibold mt-1">
ROAS {p90.roas || 0}x
</p>
</div>
<div className="mt-4 pt-3 border-t border-gray-200 text-xs text-gray-400 space-y-1">
<div className="flex justify-between">
<span>Leads:</span>
<strong className="text-gray-800">{p90.leads || 0}</strong>
</div>
<div className="flex justify-between">
<span>Ventas:</span>
<strong className="text-gray-800">{p90.deals || 0}</strong>
</div>
</div>
<HelpDetails title="Por qué Meta + Google no suma el Blended">
<p>
Cada pestaña simula invertir <strong>todo</strong> este presupuesto en
un solo canal. No es «cómo se reparte el blended». Por eso 1.000 € en
Meta + 1.000 € en Google serían 2.000 €, no el blended de 1.000 €.
</p>
<p>
El blended usa el <strong>CPL ponderado</strong> (gasto total ÷ leads
totales), no la media de las dos proyecciones. Si Google es más caro
por lead, blended queda entre Meta y Google, más cerca del canal con
más volumen histórico.
</p>
<p>
Cierre y ticket salen de <code>kpi_weekly</code> del canal. Si un
canal no tiene semanas propias, hereda el cierre del tenant (mismo
ticket, CPL distinto).
</p>
</HelpDetails>
</div>
);
}
function BandCard({
title,
icon,
band,
highlight,
}: {
title: string;
icon: React.ReactNode;
band: SimBand;
highlight: boolean;
}) {
return (
<div
className={`rounded-xl p-4 flex flex-col justify-between border ${
highlight
? 'border-2 border-brand-500 bg-gradient-to-b from-brand-50 to-white relative shadow-sm'
: 'border-gray-200 bg-white'
}`}
>
{highlight ? (
<div className="absolute -top-2.5 right-3 bg-brand-600 text-white text-[9px] uppercase tracking-wider font-bold px-2 py-0.5 rounded-full">
Más probable
</div>
) : null}
<div>
<div className="flex items-center justify-between mb-2">
<span
className={`text-xs font-semibold ${
highlight ? 'text-brand-700' : 'text-gray-500'
}`}
>
{title}
</span>
{icon}
</div>
<p className="text-2xl font-extrabold text-gray-900 tabular-nums">
+
{Number(band.revenue || 0).toLocaleString('es-ES', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}{' '}
€
</p>
<p className="text-xs text-brand-700 font-semibold mt-1">
ROAS {band.roas || 0}x
</p>
</div>
<div className="mt-4 pt-3 border-t border-gray-200 text-xs text-gray-500 space-y-1">
<div className="flex justify-between">
<span>Leads:</span>
<strong className="text-gray-800">{band.leads || 0}</strong>
</div>
<div className="flex justify-between">
<span>Ventas:</span>
<strong className="text-gray-800">{band.deals || 0}</strong>
</div>
</div>
</div>

View File

@@ -0,0 +1,282 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import {
ChevronLeft,
ChevronRight,
ExternalLink,
Eye,
Heart,
Instagram,
MessageCircle,
Repeat2,
X,
} from 'lucide-react';
import {
instagramUrlFromNotes,
pipelineMediaUrl,
slidesOf,
type PipelineCalendarRow,
normPermalink,
} from '@/lib/pipeline-calendar';
export type SocialLite = {
permalink?: string | null;
createdAt?: string | null;
likes?: number;
comments?: number;
shares?: number;
reach?: number | null;
impressions?: number | null;
mediaUrl?: string | null;
thumbnailUrl?: string | null;
};
function Metric({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-2.5">
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-500 inline-flex items-center gap-1">
{icon}
{label}
</p>
<p className="text-lg font-semibold tabular-nums text-gray-900 mt-0.5">
{value}
</p>
</div>
);
}
export default function CalendarDayZoom({
day,
items,
socialPosts,
onClose,
}: {
day: string;
items: PipelineCalendarRow[];
socialPosts?: SocialLite[];
onClose: () => void;
}) {
const [idx, setIdx] = useState(0);
const [slide, setSlide] = useState(0);
const item = items[Math.min(idx, items.length - 1)] || null;
useEffect(() => {
setIdx(0);
setSlide(0);
}, [day, items.length]);
const images = useMemo(() => {
if (!item) return [];
const all = [item.cover_path, ...slidesOf(item.slide_paths)].filter(
Boolean
) as string[];
return all;
}, [item]);
const permalink = item ? instagramUrlFromNotes(item.notes) : null;
const metrics = useMemo(() => {
if (!item) return null;
const key = normPermalink(permalink);
const date = (item.created_at || item.publish_date || '').slice(0, 10);
return (
(socialPosts || []).find((p) => {
if (key && normPermalink(p.permalink) === key) return true;
if (date && (p.createdAt || '').slice(0, 10) === date) return true;
return false;
}) || null
);
}, [item, permalink, socialPosts]);
const currentPath = images[slide] || item?.cover_path || null;
const currentUrl =
pipelineMediaUrl(currentPath) ||
metrics?.mediaUrl ||
metrics?.thumbnailUrl ||
null;
const dayLabel = new Date(`${day}T12:00:00`).toLocaleDateString('es-ES', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="flex max-h-[92vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl md:flex-row"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-1 flex-col bg-gray-950 p-4 min-h-[280px]">
<div className="relative flex flex-1 items-center justify-center overflow-hidden rounded-lg bg-black min-h-[260px]">
{currentUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={currentUrl}
alt={item?.title || ''}
className="max-h-[62vh] max-w-full object-contain"
/>
) : (
<p className="text-sm text-gray-500">Sin preview de esta pieza</p>
)}
{images.length > 1 ? (
<>
<button
type="button"
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-1.5 text-white"
onClick={() =>
setSlide((s) => (s > 0 ? s - 1 : images.length - 1))
}
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-1.5 text-white"
onClick={() =>
setSlide((s) => (s < images.length - 1 ? s + 1 : 0))
}
>
<ChevronRight className="h-5 w-5" />
</button>
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-2 py-0.5 text-[11px] text-white">
{slide + 1} / {images.length}
</span>
</>
) : null}
</div>
{items.length > 1 ? (
<div className="mt-3 flex gap-2 overflow-x-auto">
{items.map((it, i) => {
const thumb = pipelineMediaUrl(it.cover_path);
return (
<button
key={it.id}
type="button"
onClick={() => {
setIdx(i);
setSlide(0);
}}
className={`relative h-16 w-12 shrink-0 overflow-hidden rounded-md border-2 ${
i === idx
? 'border-brand-500'
: 'border-transparent opacity-70 hover:opacity-100'
}`}
>
{thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={thumb}
alt=""
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full items-center justify-center bg-gray-800 text-[9px] text-white">
{i + 1}
</span>
)}
</button>
);
})}
</div>
) : null}
</div>
<div className="flex w-full flex-col md:w-[22rem] border-t md:border-t-0 md:border-l border-gray-100 overflow-y-auto">
<div className="flex items-start justify-between gap-2 p-5 pb-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-700">
{dayLabel}
</p>
<h3 className="mt-1 text-base font-bold text-gray-900 leading-snug">
{item?.title || 'Publicación'}
</h3>
<p className="mt-1 text-xs text-gray-500">
{(item?.format || 'post').toString()} ·{' '}
{items.length > 1
? `${idx + 1} de ${items.length} este día`
: 'Creatividad real (pipeline)'}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full p-1 text-gray-400 hover:bg-gray-100"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="px-5 grid grid-cols-2 gap-2">
<Metric
icon={<Heart className="h-3 w-3" />}
label="Likes"
value={String(metrics?.likes ?? '—')}
/>
<Metric
icon={<MessageCircle className="h-3 w-3" />}
label="Comentarios"
value={String(metrics?.comments ?? '—')}
/>
<Metric
icon={<Eye className="h-3 w-3" />}
label="Alcance"
value={
metrics?.reach != null
? Number(metrics.reach).toLocaleString('es-ES')
: '—'
}
/>
<Metric
icon={<Repeat2 className="h-3 w-3" />}
label="Impresiones"
value={
metrics?.impressions != null
? Number(metrics.impressions).toLocaleString('es-ES')
: '—'
}
/>
</div>
<div className="p-5 space-y-3 flex-1">
{permalink ? (
<a
href={permalink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-full items-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 via-pink-600 to-amber-500 px-3 py-2 text-xs font-bold text-white"
>
<Instagram className="h-4 w-4" />
Ver en Instagram
<ExternalLink className="h-3 w-3 ml-auto" />
</a>
) : null}
{item?.caption ? (
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
{item.caption}
</div>
) : null}
{!metrics ? (
<p className="text-[11px] text-gray-400">
Métricas en vivo cuando hay match con el post de Instagram
(likes, comentarios, alcance).
</p>
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -16,6 +16,8 @@ import {
Filter,
} from 'lucide-react';
import { PageHeader, Surface } from '@/components/ui';
import { clientNoteFromNotes } from '@/lib/pipeline-calendar';
import Link from 'next/link';
type PipelineItem = {
id: string;
@@ -108,6 +110,8 @@ export default function CreativePipelineBoard() {
const [selectedMonth, setSelectedMonth] = useState<string>('all');
const [selectedFormat, setSelectedFormat] = useState<string>('all');
const [previewItem, setPreviewItem] = useState<PipelineItem | null>(null);
const [rejectItem, setRejectItem] = useState<PipelineItem | null>(null);
const [rejectNote, setRejectNote] = useState('');
async function load() {
setLoading(true);
@@ -129,6 +133,7 @@ export default function CreativePipelineBoard() {
const monthCounts = useMemo(() => {
const map = new Map<string, number>();
for (const it of items) {
if (it.status === 'publicado') continue;
const key = it.month || 'sin-mes';
map.set(key, (map.get(key) || 0) + 1);
}
@@ -138,6 +143,7 @@ export default function CreativePipelineBoard() {
const months = useMemo(() => {
const set = new Set<string>();
for (const it of items) {
if (it.status === 'publicado') continue;
if (it.month) set.add(it.month);
}
return Array.from(set).sort();
@@ -145,6 +151,7 @@ export default function CreativePipelineBoard() {
const filteredItems = useMemo(() => {
return items.filter((it) => {
if (it.status === 'publicado') return false;
if (selectedMonth !== 'all' && (it.month || 'sin-mes') !== selectedMonth) return false;
if (selectedFormat !== 'all' && it.format !== selectedFormat) return false;
return true;
@@ -181,14 +188,14 @@ export default function CreativePipelineBoard() {
await load();
}
async function setStatus(id: string, status: string) {
async function setStatus(id: string, status: string, notes?: string) {
setBusyId(id);
setErr(null);
try {
const res = await fetch('/api/creative-pipeline', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status }),
body: JSON.stringify({ id, status, ...(notes ? { notes } : {}) }),
});
const data = await res.json();
if (!res.ok) {
@@ -196,7 +203,11 @@ export default function CreativePipelineBoard() {
return;
}
if (previewItem && previewItem.id === id) {
setPreviewItem({ ...previewItem, status });
setPreviewItem({
...previewItem,
status,
notes: notes || previewItem.notes,
});
}
await load();
} finally {
@@ -246,7 +257,7 @@ export default function CreativePipelineBoard() {
<PageHeader
eyebrow="Producción editorial"
title="Panel de Creatividades"
description="Planificación, diseño y archivo histórico sincronizado con Instagram."
description="Solo revisión: propuesta, diseño y OK del cliente. Lo publicado se ve en el calendario, día a día."
actions={
isAgency ? (
<button
@@ -269,13 +280,20 @@ export default function CreativePipelineBoard() {
{err}
</div>
) : null}
<p className="mb-4 text-xs text-gray-500">
Piezas ya publicadas:{' '}
<Link href="/dashboard/calendario" className="font-semibold text-brand-700 hover:underline">
ábrelas en el calendario, clic en el día
</Link>
.
</p>
{/* Barra de Filtros por Mes y Formato */}
<div className="mb-6 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Meses ({items.length} piezas):
Meses ({items.filter((i) => i.status !== 'publicado').length} en revisión):
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
@@ -416,7 +434,14 @@ export default function CreativePipelineBoard() {
item={it}
isAgency={isAgency}
busy={busyId === it.id}
onStatus={(s) => setStatus(it.id, s)}
onStatus={(s) => {
if (s === 'rechazado' && !isAgency) {
setRejectItem(it);
setRejectNote('');
return;
}
void setStatus(it.id, s);
}}
onDelete={() => removeItem(it.id)}
onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)}
onPreview={() => setPreviewItem(it)}
@@ -435,9 +460,68 @@ export default function CreativePipelineBoard() {
isAgency={isAgency}
busy={busyId === previewItem.id}
onClose={() => setPreviewItem(null)}
onStatus={(s) => setStatus(previewItem.id, s)}
onStatus={(s) => {
if (s === 'rechazado' && !isAgency) {
setRejectItem(previewItem);
setRejectNote('');
return;
}
void setStatus(previewItem.id, s);
}}
/>
)}
{rejectItem ? (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
onClick={() => setRejectItem(null)}
>
<form
className="w-full max-w-md rounded-2xl bg-white p-5 shadow-xl space-y-3"
onClick={(e) => e.stopPropagation()}
onSubmit={(e) => {
e.preventDefault();
if (!rejectNote.trim()) return;
const id = rejectItem.id;
void setStatus(id, 'rechazado', rejectNote.trim()).then(() => {
setRejectItem(null);
setRejectNote('');
});
}}
>
<h3 className="text-sm font-bold text-gray-900">
No aprobar · {rejectItem.title}
</h3>
<p className="text-xs text-gray-500">
Deja una nota para que 4Ventures sepa qué hay que cambiar.
</p>
<textarea
required
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
rows={4}
className="w-full rounded-xl border border-gray-200 px-3 py-2 text-sm"
placeholder="Ej. Cambia el titular, el logo se ve pequeño…"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setRejectItem(null)}
className="rounded-lg border border-gray-200 px-3 py-2 text-xs font-semibold"
>
Cancelar
</button>
<button
type="submit"
disabled={!rejectNote.trim() || busyId === rejectItem.id}
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50"
>
Enviar nota y rechazar
</button>
</div>
</form>
</div>
) : null}
</div>
);
}
@@ -617,6 +701,12 @@ function PieceCard({
)}
</div>
{item.status === 'rechazado' && clientNoteFromNotes(item.notes) ? (
<p className="text-[11px] text-red-800 bg-red-50 border border-red-100 rounded-lg px-2 py-1.5">
Nota del cliente: {clientNoteFromNotes(item.notes)}
</p>
) : null}
<div className="mt-auto pt-2">
{isAgency ? (
<select
@@ -820,6 +910,12 @@ function DetailModal({
</div>
</div>
)}
{item.status === 'rechazado' && clientNoteFromNotes(item.notes) ? (
<div className="rounded-xl border border-red-200 bg-red-50 p-3 text-xs text-red-900">
<p className="font-semibold mb-1">Nota del cliente</p>
{clientNoteFromNotes(item.notes)}
</div>
) : null}
</div>
<div className="mt-6 border-t border-gray-100 pt-4">

View File

@@ -0,0 +1,615 @@
'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
CalendarDays,
Check,
ChevronLeft,
ChevronRight,
Pencil,
Target,
Trophy,
Wallet,
} from 'lucide-react';
import {
HelpDetails,
HeroBand,
HeroStat,
SectionHead,
SparkBars,
} from '@/components/dash-ui';
type Tone = 'ahead' | 'on_track' | 'behind';
type Payload = {
tenant_label?: string;
month: string;
as_of: string;
days_in_month: number;
days_elapsed: number;
days_remaining: number;
pace_pct: number;
has_targets: boolean;
targets: { spend: number; leads: number; clients: number; revenue: number };
actual: {
spend: number;
leads: number;
clients: number;
revenue: number;
deals: number;
};
expected: {
spend: number;
leads: number;
clients: number;
revenue: number;
};
status: {
spend: Tone;
leads: Tone;
clients: Tone;
revenue: Tone;
};
platforms: { meta: number; google: number; other: number };
series: { date: string; spend: number; crmLeads: number }[];
cumulative: {
date: string;
spend: number;
spendPace: number;
leads: number;
leadsPace: number;
}[];
error?: string;
};
const TONE: Record<Tone, { label: string; className: string }> = {
ahead: {
label: 'Por delante',
className: 'bg-emerald-50 text-emerald-800 border-emerald-200',
},
on_track: {
label: 'En ritmo',
className: 'bg-sky-50 text-sky-800 border-sky-200',
},
behind: {
label: 'Por detrás',
className: 'bg-amber-50 text-amber-900 border-amber-200',
},
};
function euro(v: number, digits = 0) {
return `${Number(v || 0).toLocaleString('es-ES', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})} €`;
}
function shiftMonth(ym: string, delta: number): string {
const [y, m] = ym.split('-').map(Number);
const d = new Date(Date.UTC(y, m - 1 + delta, 1));
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
}
function monthTitle(ym: string) {
const [y, m] = ym.split('-').map(Number);
const label = new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString('es-ES', {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
return label.charAt(0).toUpperCase() + label.slice(1);
}
function DualLine({
rows,
aKey,
bKey,
aLabel,
bLabel,
format,
}: {
rows: { date: string; [k: string]: string | number }[];
aKey: string;
bKey: string;
aLabel: string;
bLabel: string;
format: (v: number) => string;
}) {
if (!rows.length) {
return (
<p className="text-xs text-gray-500 py-10 text-center">
Aún no hay datos de este mes.
</p>
);
}
const w = 640;
const h = 180;
const pad = { t: 12, r: 12, b: 22, l: 8 };
const vals = rows.flatMap((r) => [Number(r[aKey] || 0), Number(r[bKey] || 0)]);
const max = Math.max(1, ...vals);
const innerW = w - pad.l - pad.r;
const innerH = h - pad.t - pad.b;
const x = (i: number) =>
pad.l + (rows.length === 1 ? innerW / 2 : (i / (rows.length - 1)) * innerW);
const y = (v: number) => pad.t + innerH - (v / max) * innerH;
const path = (key: string) =>
rows
.map((r, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(Number(r[key] || 0)).toFixed(1)}`)
.join(' ');
const area = `${path(aKey)} L ${x(rows.length - 1).toFixed(1)} ${y(0).toFixed(1)} L ${x(0).toFixed(1)} ${y(0).toFixed(1)} Z`;
const first = rows[0]?.date;
const last = rows[rows.length - 1]?.date;
const fmtDay = (d?: string) =>
d
? new Date(`${d}T00:00:00`).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
})
: '';
return (
<div>
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-44" role="img">
<path d={area} fill="rgb(249 115 22 / 0.12)" />
<path
d={path(aKey)}
fill="none"
stroke="#ea580c"
strokeWidth="2.4"
strokeLinecap="round"
/>
<path
d={path(bKey)}
fill="none"
stroke="#94a3b8"
strokeWidth="2"
strokeDasharray="5 4"
strokeLinecap="round"
/>
</svg>
<div className="flex items-center justify-between text-[10px] text-gray-500 tabular-nums">
<span>{fmtDay(first)}</span>
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-4 rounded-full bg-orange-600" />
{aLabel} {format(Number(rows[rows.length - 1]?.[aKey] || 0))}
</span>
<span className="inline-flex items-center gap-1">
<span className="h-px w-4 border-t-2 border-dashed border-slate-400" />
{bLabel} {format(Number(rows[rows.length - 1]?.[bKey] || 0))}
</span>
</div>
<span>{fmtDay(last)}</span>
</div>
</div>
);
}
function Chip({ tone }: { tone: Tone }) {
const t = TONE[tone];
return (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${t.className}`}
>
{t.label}
</span>
);
}
function Field({
label,
suffix,
value,
onChange,
}: {
label: string;
suffix?: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<label className="block">
<span className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">
{label}
</span>
<div className="mt-1.5 flex items-center rounded-xl border border-gray-200 bg-white focus-within:border-brand-400 focus-within:ring-2 focus-within:ring-brand-100">
<input
type="number"
min="0"
step="any"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-xl bg-transparent px-3 py-2.5 text-sm font-semibold tabular-nums text-gray-900 outline-none"
/>
{suffix ? (
<span className="pr-3 text-xs text-gray-400">{suffix}</span>
) : null}
</div>
</label>
);
}
export default function ObjetivosDashboard({
tenantLabel,
}: {
tenantLabel?: string;
}) {
const nowYm = useMemo(() => {
const s = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Madrid',
year: 'numeric',
month: '2-digit',
}).format(new Date());
return s;
}, []);
const [month, setMonth] = useState(nowYm);
const [data, setData] = useState<Payload | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
spend_target: '',
leads_target: '',
clients_target: '',
revenue_target: '',
});
const load = useCallback(async (ym: string) => {
setLoading(true);
setErr(null);
try {
const res = await fetch(`/api/targets?month=${encodeURIComponent(ym)}`);
const json = (await res.json()) as Payload;
if (!res.ok) {
setErr(json.error || 'No se pudieron cargar los objetivos');
setData(null);
return;
}
setData(json);
setForm({
spend_target: String(json.targets.spend || ''),
leads_target: String(json.targets.leads || ''),
clients_target: String(json.targets.clients || ''),
revenue_target: String(json.targets.revenue || ''),
});
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(month);
}, [month, load]);
async function save(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setErr(null);
try {
const res = await fetch('/api/targets', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
month,
spend_target: Number(form.spend_target || 0),
leads_target: Number(form.leads_target || 0),
clients_target: Number(form.clients_target || 0),
revenue_target: Number(form.revenue_target || 0),
}),
});
const json = await res.json();
if (!res.ok) {
setErr(json.error || 'No se pudo guardar');
return;
}
setEditing(false);
await load(month);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error al guardar');
} finally {
setSaving(false);
}
}
const t = data?.targets;
const a = data?.actual;
const e = data?.expected;
const st = data?.status;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex items-center rounded-xl border border-gray-200 bg-white shadow-sm">
<button
type="button"
onClick={() => setMonth((m) => shiftMonth(m, -1))}
className="p-2 text-gray-500 hover:text-gray-900"
aria-label="Mes anterior"
>
<ChevronLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-2 px-3 py-1.5 min-w-[10rem] justify-center">
<CalendarDays className="h-4 w-4 text-brand-600" />
<span className="text-sm font-semibold text-gray-900">
{monthTitle(month)}
</span>
</div>
<button
type="button"
onClick={() => setMonth((m) => shiftMonth(m, 1))}
className="p-2 text-gray-500 hover:text-gray-900"
aria-label="Mes siguiente"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
<button
type="button"
onClick={() => setEditing((v) => !v)}
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:border-brand-300 hover:text-brand-800"
>
<Pencil className="h-4 w-4" />
{editing ? 'Cerrar editor' : 'Editar objetivos'}
</button>
</div>
{err ? (
<p className="text-sm text-red-600 border border-red-200 bg-red-50 rounded-xl px-3 py-2">
{err}
</p>
) : null}
{editing ? (
<form
onSubmit={save}
className="rounded-2xl border border-brand-200 bg-brand-50/40 p-5 space-y-4"
>
<SectionHead
icon={<Pencil className="h-4 w-4" />}
title="Fijar metas del mes"
hint={`Se comparan con el real de ${tenantLabel || data?.tenant_label || 'este cliente'} a día de hoy. El ritmo esperado es lineal (${data?.days_elapsed || 0}/${data?.days_in_month || 30} días).`}
/>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Field
label="Presupuesto ads"
suffix="€"
value={form.spend_target}
onChange={(v) => setForm((f) => ({ ...f, spend_target: v }))}
/>
<Field
label="Leads"
value={form.leads_target}
onChange={(v) => setForm((f) => ({ ...f, leads_target: v }))}
/>
<Field
label="Clientes"
value={form.clients_target}
onChange={(v) => setForm((f) => ({ ...f, clients_target: v }))}
/>
<Field
label="Facturación"
suffix="€"
value={form.revenue_target}
onChange={(v) => setForm((f) => ({ ...f, revenue_target: v }))}
/>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={saving}
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
>
<Check className="h-4 w-4" />
{saving ? 'Guardando…' : 'Guardar y comparar'}
</button>
<p className="text-xs text-gray-500">
0 = sin meta en esa métrica (no entra en el ritmo).
</p>
</div>
</form>
) : null}
<HeroBand
eyebrow={
loading
? 'Cargando mes…'
: `Día ${data?.days_elapsed || 0} de ${data?.days_in_month || 0} · ritmo lineal ${data?.pace_pct || 0}%`
}
>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<HeroStat
label="Presupuesto ads"
value={euro(a?.spend || 0)}
chip={
t?.spend ? (
<span className="text-xs text-gray-500">
Meta {euro(t.spend)} · ritmo {euro(e?.spend || 0)}{' '}
{st?.spend ? <Chip tone={st.spend} /> : null}
</span>
) : (
<span className="text-xs text-gray-400">Sin meta de gasto</span>
)
}
/>
<HeroStat
label="Leads CRM"
value={String(a?.leads || 0)}
chip={
t?.leads ? (
<span className="text-xs text-gray-500">
Meta {t.leads} · ritmo {Math.round(e?.leads || 0)}{' '}
{st?.leads ? <Chip tone={st.leads} /> : null}
</span>
) : (
<span className="text-xs text-gray-400">Sin meta de leads</span>
)
}
/>
<HeroStat
label="Clientes"
value={String(a?.clients || 0)}
chip={
t?.clients ? (
<span className="text-xs text-gray-500">
Meta {t.clients} · ritmo {Math.round(e?.clients || 0)}{' '}
{st?.clients ? <Chip tone={st.clients} /> : null}
</span>
) : (
<span className="text-xs text-gray-400">
Sin meta de clientes
</span>
)
}
/>
<HeroStat
label="Facturación"
value={euro(a?.revenue || 0)}
chip={
t?.revenue ? (
<span className="text-xs text-gray-500">
Meta {euro(t.revenue)} · ritmo {euro(e?.revenue || 0)}{' '}
{st?.revenue ? <Chip tone={st.revenue} /> : null}
</span>
) : (
<span className="text-xs text-gray-400">
Sin meta de facturación
</span>
)
}
/>
</div>
</HeroBand>
{!data?.has_targets && !loading ? (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white px-5 py-8 text-center">
<Target className="mx-auto h-6 w-6 text-brand-600" />
<p className="mt-2 text-sm font-semibold text-gray-900">
Aún no hay objetivos este mes
</p>
<p className="mt-1 text-xs text-gray-500 max-w-md mx-auto">
Define presupuesto, leads, clientes y facturación. En cuanto
guardes, verás el real contra el ritmo esperado y las curvas
acumuladas.
</p>
<button
type="button"
onClick={() => setEditing(true)}
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white"
>
<Pencil className="h-4 w-4" />
Definir objetivos
</button>
</div>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
<section className="rounded-2xl border border-gray-200 bg-white p-5">
<SectionHead
icon={<Wallet className="h-4 w-4" />}
title="Gasto acumulado vs ritmo"
hint="Línea continua = gastado. Discontinua = si el presupuesto se consume linealmente."
/>
<DualLine
rows={data?.cumulative || []}
aKey="spend"
bKey="spendPace"
aLabel="Real"
bLabel="Ritmo"
format={(v) => euro(v)}
/>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5">
<SectionHead
icon={<Trophy className="h-4 w-4" />}
title="Leads CRM acumulados vs ritmo"
hint="Altas del CRM en el mes. Si no hay meta de leads, el ritmo queda en 0."
/>
<DualLine
rows={data?.cumulative || []}
aKey="leads"
bKey="leadsPace"
aLabel="Real"
bLabel="Ritmo"
format={(v) => String(Math.round(v))}
/>
</section>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<section className="rounded-2xl border border-gray-200 bg-white p-5">
<SectionHead
icon={<Wallet className="h-4 w-4" />}
title="Gasto diario"
hint="Barras de ad_daily. Útil para ver picos, no el ritmo."
/>
<SparkBars
points={(data?.series || []).map((d) => ({
date: d.date,
value: d.spend,
}))}
color="bg-orange-500"
formatValue={(v) => euro(v)}
/>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5 space-y-4">
<SectionHead
icon={<Target className="h-4 w-4" />}
title="Mix de inversión"
hint="Cómo se reparte el gasto real del mes (no es el mix del simulador)."
/>
{['meta', 'google', 'other'].map((k) => {
const val = Number(
data?.platforms?.[k as 'meta' | 'google' | 'other'] || 0
);
const max = Math.max(
1,
data?.actual.spend || 1
);
const label =
k === 'meta' ? 'Meta' : k === 'google' ? 'Google' : 'Otros';
return (
<div key={k}>
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span>{label}</span>
<span className="tabular-nums font-semibold text-gray-900">
{euro(val)}
</span>
</div>
<div className="h-2 rounded-full bg-gray-100">
<div
className={`h-2 rounded-full ${
k === 'meta'
? 'bg-sky-500'
: k === 'google'
? 'bg-emerald-500'
: 'bg-gray-400'
}`}
style={{ width: `${Math.max(2, (val / max) * 100)}%` }}
/>
</div>
</div>
);
})}
</section>
</div>
<HelpDetails title="Cómo se calcula el avance">
<p>
<strong>Presupuesto</strong> = suma de <code>ad_daily.spend</code> del
mes. <strong>Leads</strong> = altas CRM (Clientify / GoHighLevel).{' '}
<strong>Clientes</strong> = leads que pasaron a cliente en el mes.{' '}
<strong>Facturación</strong> = deals ganados (<code>won_at</code>) con
importe.
</p>
<p>
El ritmo esperado es lineal: meta × (día del mes / días del mes).
«Por delante / en ritmo / por detrás» usa un umbral del 8% sobre ese
ritmo, no sobre el 100% del mes (aún no ha terminado).
</p>
</HelpDetails>
</div>
);
}

View File

@@ -12,6 +12,14 @@ import {
X,
ExternalLink,
} from 'lucide-react';
import CalendarDayZoom, {
type SocialLite,
} from '@/components/CalendarDayZoom';
import {
pipelineMediaUrl,
pipelinePublishDay,
type PipelineCalendarRow,
} from '@/lib/pipeline-calendar';
export type RrssPiece = {
id: string;
@@ -31,6 +39,9 @@ export type RrssPiece = {
kind?: string;
color?: string;
slides?: string[];
pipeline_id?: string;
cover_path?: string | null;
slide_paths?: string[];
};
export type RrssMonthPlan = {
@@ -120,6 +131,21 @@ function DualBadge({ dual }: { dual?: Dual }) {
);
}
function pieceToRow(p: RrssPiece): PipelineCalendarRow {
return {
id: p.pipeline_id || p.id,
title: p.title,
format: p.format,
status: p.status,
cover_path: p.cover_path || null,
slide_paths: p.slide_paths || p.slides || [],
caption: p.body || null,
notes: p.meta_line || null,
created_at: p.date,
publish_date: p.date,
};
}
export default function RrssMonthView({
plan,
tenantLabel,
@@ -134,6 +160,9 @@ export default function RrssMonthView({
const pieces = plan.pieces || [];
const [platform, setPlatform] = useState<'all' | string>(initialPlatform);
const [selected, setSelected] = useState<RrssPiece | null>(null);
const [dayZoom, setDayZoom] = useState<string | null>(null);
const [pipeItems, setPipeItems] = useState<PipelineCalendarRow[]>([]);
const [socialPosts, setSocialPosts] = useState<SocialLite[]>([]);
const [dualMap, setDualMap] = useState<Record<string, Dual>>({});
const [comments, setComments] = useState<Comment[]>([]);
const [commentText, setCommentText] = useState('');
@@ -172,6 +201,32 @@ export default function RrssMonthView({
refreshDual();
}, [refreshDual]);
useEffect(() => {
const ym = plan.month;
void (async () => {
try {
const r = await fetch(
`/api/creative-pipeline?month=${encodeURIComponent(ym)}`
);
const j = await r.json();
setPipeItems((j.items || []) as PipelineCalendarRow[]);
} catch {
setPipeItems([]);
}
})();
void (async () => {
try {
const r = await fetch(
`/api/social?since=${encodeURIComponent(ym + '-01')}&until=${encodeURIComponent(ym + '-31')}`
);
const j = await r.json();
setSocialPosts((j.posts || []) as SocialLite[]);
} catch {
setSocialPosts([]);
}
})();
}, [plan.month]);
useEffect(() => {
if (!selected || !canCollaborate) {
setComments([]);
@@ -253,6 +308,31 @@ export default function RrssMonthView({
: dualMap[selected.id].client
: false;
function rowsForDay(key: string): PipelineCalendarRow[] {
const fromPipe = pipeItems.filter((it) => pipelinePublishDay(it) === key);
const fromPlan = (byDate.get(key) || [])
.map((p) => pieceToRow(p))
.filter((r) => r.cover_path || r.caption || r.notes);
const seen = new Set<string>();
const out: PipelineCalendarRow[] = [];
for (const r of [...fromPipe, ...fromPlan]) {
const k = r.id;
if (seen.has(k)) continue;
seen.add(k);
out.push(r);
}
return out;
}
function openDay(key: string, piece?: RrssPiece) {
const rows = rowsForDay(key);
if (rows.length) {
setDayZoom(key);
return;
}
if (piece) setSelected(piece);
}
return (
<div className="space-y-6">
{/* Hub crumbs + network filters */}
@@ -457,8 +537,8 @@ export default function RrssMonthView({
El mes de un vistazo
</h3>
<p className="text-xs text-gray-500 mb-4">
Cada tarjeta de color es una publicación; clic para ver copy, aprobar y
comentar.
Clic en el día publicado para ver la creatividad real (preview +
métricas). El pipeline es solo para revisión.
</p>
<div className="grid grid-cols-7 gap-1.5 mb-1.5">
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d) => (
@@ -481,33 +561,51 @@ export default function RrssMonthView({
const day = i + 1;
const key = `${plan.month}-${String(day).padStart(2, '0')}`;
const dayPieces = byDate.get(key) || [];
const pipeDay = pipeItems.filter(
(it) => pipelinePublishDay(it) === key
);
const cover = pipeDay[0]
? pipelineMediaUrl(pipeDay[0].cover_path)
: dayPieces.find((p) => p.cover_path)
? pipelineMediaUrl(
dayPieces.find((p) => p.cover_path)!.cover_path!
)
: null;
return (
<div
<button
key={key}
className="min-h-[88px] rounded-lg border border-gray-200 bg-white p-1.5"
type="button"
onClick={() => openDay(key, dayPieces[0])}
className="min-h-[88px] rounded-lg border border-gray-200 bg-white p-1.5 text-left hover:border-brand-400 hover:shadow-sm overflow-hidden"
>
<p className="text-[11px] font-bold text-gray-600 mb-1">{day}</p>
{cover ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={cover}
alt=""
className="mb-1 h-10 w-full rounded object-cover"
/>
) : null}
<div className="space-y-1">
{dayPieces.slice(0, 3).map((p) => (
<button
{dayPieces.slice(0, cover ? 1 : 3).map((p) => (
<span
key={p.id}
type="button"
onClick={() => setSelected(p)}
className="w-full text-left rounded-md px-1.5 py-1 text-[9px] font-semibold leading-tight text-gray-900"
className="block w-full rounded-md px-1.5 py-1 text-[9px] font-semibold leading-tight text-gray-900"
style={{ background: colorFor(p) }}
title={p.title}
>
{p.platform === 'linkedin' ? 'LI' : 'IG'} ·{' '}
{p.title.replace(/^[^·]+·\s*/, '').slice(0, 42)}
</button>
</span>
))}
{dayPieces.length > 3 && (
{dayPieces.length > (cover ? 1 : 3) && (
<p className="text-[9px] text-gray-500">
+{dayPieces.length - 3}
+{dayPieces.length - (cover ? 1 : 3)}
</p>
)}
</div>
</div>
</button>
);
})}
</div>
@@ -522,7 +620,7 @@ export default function RrssMonthView({
<button
key={p.id}
type="button"
onClick={() => setSelected(p)}
onClick={() => openDay(p.date.slice(0, 10), p)}
className="w-full text-left rounded-xl border border-gray-200 bg-white p-4 hover:border-gray-200"
>
<div className="flex flex-wrap items-start justify-between gap-2">
@@ -573,6 +671,15 @@ export default function RrssMonthView({
))}
</section>
{dayZoom ? (
<CalendarDayZoom
day={dayZoom}
items={rowsForDay(dayZoom)}
socialPosts={socialPosts}
onClose={() => setDayZoom(null)}
/>
) : null}
{selected && (
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white border border-gray-200 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">