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

@@ -11,3 +11,6 @@ comment on column public.targets.spend_target is
'Presupuesto ads objetivo del mes (ppto)';
comment on column public.targets.spend_actual is
'Gasto ads real del mes (opcional; si null se calcula de ad_daily)';
create unique index if not exists targets_tenant_month_uidx
on public.targets (tenant_id, month);

View File

@@ -189,7 +189,7 @@ export async function PATCH(req: NextRequest) {
const curRes = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
id
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status`,
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status,notes`,
{ headers: headers || undefined, cache: 'no-store' }
);
const curRows = curRes.ok ? await curRes.json() : [];
@@ -215,6 +215,20 @@ export async function PATCH(req: NextRequest) {
);
}
clean.status = next;
if (next === 'rechazado') {
const note = str(body.notes, 2000);
if (!note) {
return NextResponse.json(
{ error: 'Añade una nota explicando qué hay que cambiar.' },
{ status: 400 }
);
}
const prev = str(curRows?.[0]?.notes, 2000) || '';
const line = `CLIENT_NOTE: ${note}`;
clean.notes = prev.includes(line)
? prev
: [line, prev].filter(Boolean).join('\n');
}
}
if (next === 'aprobado') {
clean.approved_by = user.id;

View File

@@ -0,0 +1,467 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import { AdDailyRow, DealWonRow, isClienteStatus } from '@/lib/metrics';
import { normalizePlatform } from '@/lib/ad-tracker-range';
export const dynamic = 'force-dynamic';
type TargetRow = {
id?: string;
tenant_id?: string;
month?: string;
spend_target?: number | null;
leads_target?: number | null;
clients_target?: number | null;
revenue_target?: number | null;
};
type CrmMini = {
created_at?: string | null;
status?: string | null;
status_changed_at?: string | null;
updated_at?: string | null;
};
function madridYmd(d = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Madrid',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
}
function parseMonth(raw: string | null): { y: number; m: number; first: string } {
const today = madridYmd();
const fallback = today.slice(0, 7);
const key = (raw || fallback).slice(0, 7);
const [ys, ms] = key.split('-');
const y = Number(ys);
const m = Number(ms);
if (!y || !m || m < 1 || m > 12) {
return parseMonth(fallback);
}
const mm = String(m).padStart(2, '0');
return { y, m, first: `${y}-${mm}-01` };
}
function lastDayOfMonth(y: number, m: number): string {
const d = new Date(Date.UTC(y, m, 0)).getUTCDate();
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
}
function daysInMonth(y: number, m: number): number {
return new Date(Date.UTC(y, m, 0)).getUTCDate();
}
function n(v: unknown): number {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
}
function eachDay(since: string, until: string): string[] {
const out: string[] = [];
let cur = since;
while (cur <= until) {
out.push(cur);
const dt = new Date(`${cur}T12:00:00Z`);
dt.setUTCDate(dt.getUTCDate() + 1);
cur = dt.toISOString().slice(0, 10);
}
return out;
}
async function loadTargetRow(
tenantId: string,
monthFirst: string,
agencyView: boolean,
supabase: Awaited<ReturnType<typeof createClient>>
): Promise<TargetRow | null> {
if (agencyView) {
const tid = encodeURIComponent(tenantId);
const t = await restGet<TargetRow[]>(
`/rest/v1/targets?tenant_id=eq.${tid}&month=eq.${monthFirst}&select=id,tenant_id,month,spend_target,leads_target,clients_target,revenue_target`
);
return t.data?.[0] || null;
}
const { data } = await supabase
.from('targets')
.select(
'id,tenant_id,month,spend_target,leads_target,clients_target,revenue_target'
)
.eq('month', monthFirst)
.maybeSingle();
return (data as TargetRow) || null;
}
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const month = parseMonth(req.nextUrl.searchParams.get('month'));
const since = month.first;
const until = lastDayOfMonth(month.y, month.m);
const today = madridYmd();
const asOf = today < since ? since : today > until ? until : today;
const dim = daysInMonth(month.y, month.m);
const elapsed = Number(asOf.slice(8, 10));
const remaining = Math.max(0, dim - elapsed);
const paceRatio = dim > 0 ? elapsed / dim : 0;
const agency = isAgencyRole(userRole(user));
const agencyView = agency && ctx.viaView;
const tid = ctx.tenantId;
const [targetRow, dailyRes, leadsRes, dealsRes] = await Promise.all([
loadTargetRow(tid, since, agencyView, supabase),
agencyView
? restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tid)}&date=gte.${since}&date=lte.${until}&select=date,spend,platform_leads,conversions,platform,clicks`
)
: supabase
.from('ad_daily')
.select('date,spend,platform_leads,conversions,platform,clicks')
.gte('date', since)
.lte('date', until)
.then((r) => ({ data: (r.data as AdDailyRow[]) || [] })),
agencyView
? restGet<CrmMini[]>(
`/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tid)}&crm=in.(clientify,gohighlevel)&select=created_at,status,status_changed_at,updated_at&created_at=gte.${since}&created_at=lte.${until}T23:59:59&limit=5000`
)
: supabase
.from('leads')
.select('created_at,status,status_changed_at,updated_at')
.in('crm', ['clientify', 'gohighlevel'])
.gte('created_at', since)
.lte('created_at', `${until}T23:59:59`)
.limit(5000)
.then((r) => ({ data: (r.data as CrmMini[]) || [] })),
agencyView
? restGet<DealWonRow[]>(
`/rest/v1/deals?tenant_id=eq.${encodeURIComponent(tid)}&stage=eq.won&won_at=gte.${since}&won_at=lte.${until}T23:59:59&select=amount,won_at,stage&limit=2000`
)
: supabase
.from('deals')
.select('amount,won_at,stage')
.eq('stage', 'won')
.gte('won_at', since)
.lte('won_at', `${until}T23:59:59`)
.limit(2000)
.then((r) => ({ data: (r.data as DealWonRow[]) || [] })),
]);
const daily = (dailyRes.data || []) as AdDailyRow[];
const leadsCreated = (leadsRes.data || []) as CrmMini[];
const won = (dealsRes.data || []) as DealWonRow[];
const spendByDay = new Map<string, number>();
const leadsAdsByDay = new Map<string, number>();
let spendMeta = 0;
let spendGoogle = 0;
let spendOther = 0;
for (const r of daily) {
const day = String(r.date || '').slice(0, 10);
if (!day) continue;
const spend = n(r.spend);
spendByDay.set(day, (spendByDay.get(day) || 0) + spend);
leadsAdsByDay.set(
day,
(leadsAdsByDay.get(day) || 0) + n(r.platform_leads ?? r.conversions)
);
const p = normalizePlatform(r.platform);
if (p === 'meta') spendMeta += spend;
else if (p === 'google') spendGoogle += spend;
else spendOther += spend;
}
const crmLeadsByDay = new Map<string, number>();
for (const l of leadsCreated) {
const day = String(l.created_at || '').slice(0, 10);
if (!day) continue;
crmLeadsByDay.set(day, (crmLeadsByDay.get(day) || 0) + 1);
}
let clients = 0;
if (agencyView) {
const extra = await restGet<CrmMini[]>(
`/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tid)}&crm=in.(clientify,gohighlevel)&select=status,status_changed_at,updated_at,created_at&limit=5000`
);
for (const l of extra.data || []) {
if (!isClienteStatus(l.status)) continue;
const day = String(
l.status_changed_at || l.updated_at || l.created_at || ''
).slice(0, 10);
if (day >= since && day <= until) clients += 1;
}
} else {
const { data: allLeads } = await supabase
.from('leads')
.select('status,status_changed_at,updated_at,created_at')
.in('crm', ['clientify', 'gohighlevel'])
.limit(5000);
for (const l of (allLeads || []) as CrmMini[]) {
if (!isClienteStatus(l.status)) continue;
const day = String(
l.status_changed_at || l.updated_at || l.created_at || ''
).slice(0, 10);
if (day >= since && day <= until) clients += 1;
}
}
const revenue = won.reduce((a, d) => a + n(d.amount), 0);
const deals = won.length;
const spendActual = [...spendByDay.values()].reduce((a, v) => a + v, 0);
const leadsActual = leadsCreated.length;
const targets = {
spend: n(targetRow?.spend_target),
leads: n(targetRow?.leads_target),
clients: n(targetRow?.clients_target),
revenue: n(targetRow?.revenue_target),
};
const series = eachDay(since, asOf).map((date, i) => {
const dayIdx = i + 1;
const spend = spendByDay.get(date) || 0;
const crmLeads = crmLeadsByDay.get(date) || 0;
const adsLeads = leadsAdsByDay.get(date) || 0;
return {
date,
spend,
crmLeads,
adsLeads,
spendPace: targets.spend > 0 ? (targets.spend / dim) * dayIdx : 0,
leadsPace: targets.leads > 0 ? (targets.leads / dim) * dayIdx : 0,
};
});
let cumSpend = 0;
let cumLeads = 0;
const cumulative = series.map((d) => {
cumSpend += d.spend;
cumLeads += d.crmLeads;
return {
date: d.date,
spend: Math.round(cumSpend * 100) / 100,
spendPace: Math.round(d.spendPace * 100) / 100,
leads: cumLeads,
leadsPace: Math.round(d.leadsPace * 10) / 10,
};
});
const expected = {
spend: targets.spend * paceRatio,
leads: targets.leads * paceRatio,
clients: targets.clients * paceRatio,
revenue: targets.revenue * paceRatio,
};
function gap(actual: number, pace: number) {
if (!pace) return actual > 0 ? 1 : 0;
return (actual - pace) / pace;
}
function tone(g: number): 'ahead' | 'on_track' | 'behind' {
if (g >= 0.08) return 'ahead';
if (g <= -0.08) return 'behind';
return 'on_track';
}
return NextResponse.json({
tenant_label: ctx.label,
month: `${month.y}-${String(month.m).padStart(2, '0')}`,
month_first: since,
until,
as_of: asOf,
days_in_month: dim,
days_elapsed: elapsed,
days_remaining: remaining,
pace_pct: Math.round(paceRatio * 100),
has_targets:
targets.spend > 0 ||
targets.leads > 0 ||
targets.clients > 0 ||
targets.revenue > 0,
targets,
actual: {
spend: Math.round(spendActual * 100) / 100,
leads: leadsActual,
clients,
revenue: Math.round(revenue * 100) / 100,
deals,
},
expected: {
spend: Math.round(expected.spend * 100) / 100,
leads: Math.round(expected.leads * 10) / 10,
clients: Math.round(expected.clients * 10) / 10,
revenue: Math.round(expected.revenue * 100) / 100,
},
status: {
spend: tone(gap(spendActual, expected.spend)),
leads: tone(gap(leadsActual, expected.leads)),
clients: tone(gap(clients, expected.clients)),
revenue: tone(gap(revenue, expected.revenue)),
},
platforms: {
meta: Math.round(spendMeta * 100) / 100,
google: Math.round(spendGoogle * 100) / 100,
other: Math.round(spendOther * 100) / 100,
},
series,
cumulative,
});
}
export async function PATCH(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const month = parseMonth(
typeof body.month === 'string' ? body.month : null
);
function num(key: string): number | null {
if (!(key in body)) return null;
const v = Number(body[key]);
if (!Number.isFinite(v) || v < 0) return 0;
return Math.round(v * 100) / 100;
}
const patch: Record<string, unknown> = {
tenant_id: ctx.tenantId,
month: month.first,
};
const spend = num('spend_target');
const leads = num('leads_target');
const clients = num('clients_target');
const revenue = num('revenue_target');
if (spend != null) patch.spend_target = spend;
if (leads != null) patch.leads_target = leads;
if (clients != null) patch.clients_target = clients;
if (revenue != null) patch.revenue_target = revenue;
if (
spend == null &&
leads == null &&
clients == null &&
revenue == null
) {
return NextResponse.json(
{ error: 'Indica al menos un objetivo.' },
{ status: 400 }
);
}
const agency = isAgencyRole(userRole(user));
const headers = serviceHeaders();
const base = supabaseUrl();
if (agency && headers && base) {
const r = await fetch(
`${base}/rest/v1/targets?on_conflict=tenant_id,month`,
{
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=representation',
},
body: JSON.stringify(patch),
}
);
if (!r.ok) {
const existing = await restGet<TargetRow[]>(
`/rest/v1/targets?tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&month=eq.${month.first}&select=id`
);
const id = existing.data?.[0]?.id;
if (id) {
const u = await fetch(
`${base}/rest/v1/targets?id=eq.${encodeURIComponent(id)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(patch),
}
);
if (!u.ok) {
return NextResponse.json({ error: await u.text() }, { status: 500 });
}
} else {
const ins = await fetch(`${base}/rest/v1/targets`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(patch),
});
if (!ins.ok) {
return NextResponse.json({ error: await ins.text() }, { status: 500 });
}
}
}
return NextResponse.json({ ok: true, month: month.first });
}
const existing = await supabase
.from('targets')
.select('id')
.eq('month', month.first)
.maybeSingle();
if (existing.data?.id) {
const { error } = await supabase
.from('targets')
.update(patch)
.eq('id', existing.data.id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
} else {
const { error } = await supabase.from('targets').insert(patch);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
return NextResponse.json({ ok: true, month: month.first });
}

View File

@@ -10,6 +10,10 @@ import RrssArchiveYear from '@/components/RrssArchiveYear';
import octPlan from '@/data/rrss-octubre-2026.json';
import { Instagram, Linkedin } from 'lucide-react';
import { PageHeader } from '@/components/ui';
import {
mergePipelineIntoPlans,
type PipelineCalendarRow,
} from '@/lib/pipeline-calendar';
export const dynamic = 'force-dynamic';
@@ -98,6 +102,30 @@ export default async function CalendarioPage({
plans.push(octPlan as unknown as RrssMonthPlan);
}
if (ctx.tenantId) {
let pipe: PipelineCalendarRow[] = [];
if (agency && ctx.viaView) {
const r = await restGet<PipelineCalendarRow[]>(
`/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at&order=created_at.asc&limit=2000`
);
pipe = r.data || [];
} else {
const { data } = await supabase
.from('creative_pipeline')
.select(
'id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at'
)
.order('created_at', { ascending: true })
.limit(2000);
pipe = (data as PipelineCalendarRow[]) || [];
}
const merged = mergePipelineIntoPlans(
plans as unknown as Parameters<typeof mergePipelineIntoPlans>[0],
pipe
);
plans.splice(0, plans.length, ...(merged as unknown as RrssMonthPlan[]));
}
const monthParam = sp.month || plans[0]?.month;
const active = plans.find((p) => p.month === monthParam) || plans[0];
// Portada = archivo anual estilo /rrss/ig-fb/

View File

@@ -1,11 +1,11 @@
/**
* Pipeline de creatividades — cola de aprobación con preview de placement
* (Meta/Google Ads + RRSS), estilo Ad Tracker.
* Pipeline de creatividades — cola de revisión (no archivo publicado).
* Lo publicado se abre en el calendario, día a día.
*/
'use client';
import CreativesPipeline from '@/components/CreativesPipeline';
import CreativePipelineBoard from '@/components/CreativePipelineBoard';
export default function CreativesPage() {
return <CreativesPipeline />;
return <CreativePipelineBoard />;
}

View File

@@ -1,74 +1,13 @@
import React from 'react';
import { Target, Wallet, Trophy } from 'lucide-react';
import BudgetSimulator from '@/components/BudgetSimulator';
import ObjetivosDashboard from '@/components/ObjetivosDashboard';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import { AdDailyRow } from '@/lib/metrics';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { EmptyState, PageHeader } from '@/components/ui';
export const dynamic = 'force-dynamic';
type TargetRow = {
revenue_target?: number;
revenue_actual?: number;
revenue_achievement_pct?: number;
leads_target?: number;
leads_actual?: number;
clients_target?: number;
clients_actual?: number;
spend_target?: number;
spend_actual?: number;
};
function pct(actual: number, target: number): number {
if (!target || target <= 0) return 0;
return Math.min(100, Math.round((actual / target) * 100));
}
function ProgressBar({
label,
actual,
target,
unit = '',
tone = 'brand',
}: {
label: string;
actual: number;
target: number;
unit?: string;
tone?: 'brand' | 'sky' | 'emerald' | 'amber';
}) {
const p = pct(actual, target);
const bar =
tone === 'sky'
? 'bg-sky-500'
: tone === 'emerald'
? 'bg-emerald-500'
: tone === 'amber'
? 'bg-amber-500'
: 'bg-brand-500';
return (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600 font-medium">{label}</span>
<span className="text-gray-900 font-semibold tabular-nums">
{p}%
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className={`h-full ${bar}`} style={{ width: `${p}%` }} />
</div>
<p className="text-xs text-gray-500 tabular-nums">
{actual.toLocaleString('es-ES')}
{unit} / {target.toLocaleString('es-ES')}
{unit}
</p>
</div>
);
}
export default async function EstrategiaPage() {
const supabase = await createClient();
const {
@@ -77,209 +16,26 @@ export default async function EstrategiaPage() {
if (!user) redirect('/login');
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const currentMonth = `${y}-${m}-01`;
const monthPrefix = `${y}-${m}`;
let targetData: TargetRow = {
revenue_target: 0,
revenue_actual: 0,
revenue_achievement_pct: 0,
leads_target: 0,
leads_actual: 0,
clients_target: 0,
clients_actual: 0,
spend_target: 0,
spend_actual: 0,
};
let sourceNote =
'Objetivos de presupuesto (ppto ads) y de consecución (leads / clientes / facturación) por tenant y mes.';
const tenantForRpc = ctx.portfolioWide ? null : ctx.tenantId;
if (tenantForRpc) {
if (agency && ctx.viaView) {
const tid = encodeURIComponent(tenantForRpc);
const t = await restGet<TargetRow[]>(
`/rest/v1/targets?tenant_id=eq.${tid}&month=eq.${currentMonth}&select=revenue_target,clients_target,leads_target,spend_target,spend_actual`
);
if (t.data?.[0]) {
targetData = { ...targetData, ...t.data[0] };
}
const { data, error } = await supabase.rpc('targets_vs_actual', {
p_month: currentMonth,
p_tenant_id: tenantForRpc,
});
if (!error && Array.isArray(data) && data[0]) {
targetData = { ...targetData, ...data[0] };
}
// Actual spend from ad_daily this month
const d = await restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${monthPrefix}-01&date=lte.${monthPrefix}-31&select=spend,date`
);
const spendSum = (d.data || []).reduce(
(a, r) => a + Number(r.spend || 0),
0
);
if (spendSum > 0 || !targetData.spend_actual) {
targetData.spend_actual = spendSum;
}
} else {
const { data, error } = await supabase.rpc('targets_vs_actual', {
p_month: currentMonth,
p_tenant_id: tenantForRpc,
});
if (error) {
const fallback = await supabase.rpc('targets_vs_actual', {
p_month: currentMonth,
});
if (!fallback.error && Array.isArray(fallback.data) && fallback.data[0]) {
targetData = { ...targetData, ...fallback.data[0] };
}
} else if (Array.isArray(data) && data[0]) {
targetData = { ...targetData, ...data[0] };
}
const { data: trow } = await supabase
.from('targets')
.select(
'revenue_target,clients_target,leads_target,spend_target,spend_actual'
)
.eq('month', currentMonth)
.maybeSingle();
if (trow) targetData = { ...targetData, ...trow };
const { data: daily } = await supabase
.from('ad_daily')
.select('spend')
.gte('date', `${monthPrefix}-01`)
.lte('date', `${monthPrefix}-31`);
const spendSum = (daily || []).reduce(
(a, r) => a + Number((r as { spend?: number }).spend || 0),
0
);
if (spendSum > 0 || !targetData.spend_actual) {
targetData.spend_actual = spendSum;
}
}
} else if (ctx.portfolioWide) {
sourceNote =
'Vista global: selecciona un cliente en Contexto para ver ppto y consecución del mes.';
}
const spendTarget = Number(targetData.spend_target || 0);
const spendActual = Number(targetData.spend_actual || 0);
const leadsTarget = Number(targetData.leads_target || 0);
const leadsActual = Number(targetData.leads_actual || 0);
const clientsTarget = Number(targetData.clients_target || 0);
const clientsActual = Number(targetData.clients_actual || 0);
const revenueTarget = Number(targetData.revenue_target || 0);
const revenueActual = Number(targetData.revenue_actual || 0);
return (
<div className="space-y-8">
<PageHeader
eyebrow="Contenido"
title="Objetivos del mes"
description={`${ctx.label}${sourceNote ? ` · ${sourceNote}` : ''}`}
description={`${ctx.label} · Metas editables vs real (gasto ads, leads, clientes y facturación).`}
/>
{ctx.portfolioWide ? (
<EmptyState
title="Elige un cliente"
description="Selecciona un tenant en Contexto para ver presupuesto y consecución de objetivos."
description="Selecciona un tenant en Contexto para fijar presupuesto y ver el avance del mes."
/>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{/* Presupuesto */}
<section className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div className="flex items-center gap-2 text-amber-300">
<Wallet className="w-4 h-4" />
<h3 className="text-sm font-bold uppercase tracking-wider">
Objetivos de presupuesto (ppto)
</h3>
</div>
<p className="text-xs text-gray-500">
Inversión ads planificada vs gasto real del mes (ad_daily).
</p>
{spendTarget > 0 ? (
<ProgressBar
label="Presupuesto ads"
actual={spendActual}
target={spendTarget}
unit=" €"
tone="amber"
/>
) : (
<div className="rounded-lg border border-dashed border-gray-200 px-3 py-4 text-xs text-gray-500">
Sin <code className="text-gray-400">spend_target</code> para este
mes en <code className="text-gray-400">targets</code>. Gasto
actual: {spendActual.toLocaleString('es-ES')} €.
</div>
)}
<div className="grid grid-cols-2 gap-3 pt-1">
<div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2">
<p className="text-[10px] uppercase text-gray-500">Ppto</p>
<p className="text-lg font-bold text-gray-900 tabular-nums">
{spendTarget.toLocaleString('es-ES')} €
</p>
</div>
<div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2">
<p className="text-[10px] uppercase text-gray-500">Gastado</p>
<p className="text-lg font-bold text-gray-900 tabular-nums">
{spendActual.toLocaleString('es-ES')} €
</p>
</div>
</div>
</section>
{/* Consecución */}
<section className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div className="flex items-center gap-2 text-emerald-300">
<Trophy className="w-4 h-4" />
<h3 className="text-sm font-bold uppercase tracking-wider">
Objetivos de consecución
</h3>
</div>
<p className="text-xs text-gray-500">
Metas de negocio del mes vs resultado real (RPC targets_vs_actual).
</p>
<ProgressBar
label="Leads"
actual={leadsActual}
target={leadsTarget}
tone="sky"
/>
<ProgressBar
label="Clientes"
actual={clientsActual}
target={clientsTarget}
tone="emerald"
/>
<ProgressBar
label="Facturación"
actual={revenueActual}
target={revenueTarget}
unit=" €"
tone="brand"
/>
<div className="flex items-center gap-2 text-[11px] text-gray-500 pt-1">
<Target className="w-3.5 h-3.5 text-brand-400" />
Cumplimiento facturación:{' '}
{pct(revenueActual, revenueTarget) ||
Math.round(Number(targetData.revenue_achievement_pct) || 0)}
%
</div>
</section>
</div>
<>
<ObjetivosDashboard tenantLabel={ctx.label} />
<BudgetSimulator tenantLabel={ctx.label} />
</>
)}
{!ctx.portfolioWide ? (
<BudgetSimulator tenantLabel={ctx.label} />
) : null}
</div>
);
}

View File

@@ -0,0 +1,35 @@
export const metadata = {
title: 'Términos del servicio — 4Ventures',
robots: 'index,follow',
};
export default function TermsPage() {
return (
<main className="mx-auto max-w-3xl px-6 py-12 text-zinc-800">
<p className="text-sm text-zinc-500">4Ventures</p>
<h1 className="mt-2 text-3xl font-semibold">Términos del servicio</h1>
<p className="mt-2 text-sm text-zinc-500">Última actualización: 5 de octubre de 2026</p>
<div className="mt-8 space-y-6 text-[15px] leading-7">
<p>
El Centro de Mando de 4Ventures (
<a className="underline" href="https://app.4ventures.es">
app.4ventures.es
</a>
) es una herramienta interna de la agencia para gestionar clientes, métricas y
operaciones. El uso está limitado a personal autorizado de 4Ventures y a
usuarios cliente invitados por la agencia.
</p>
<p>
Al iniciar sesión aceptas usar el servicio de forma lícita, no compartir
credenciales y tratar la información de cada cliente de forma confidencial.
</p>
<p>
Contacto:{' '}
<a className="underline" href="mailto:marketing@4ventures.es">
marketing@4ventures.es
</a>
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1,84 @@
export const metadata = {
title: 'Política de privacidad — 4Ventures',
robots: 'index,follow',
};
export default function PrivacyPage() {
return (
<main className="mx-auto max-w-3xl px-6 py-12 text-zinc-800">
<p className="text-sm text-zinc-500">4Ventures</p>
<h1 className="mt-2 text-3xl font-semibold">Política de privacidad</h1>
<p className="mt-2 text-sm text-zinc-500">Última actualización: 5 de octubre de 2026</p>
<div className="mt-8 space-y-6 text-[15px] leading-7">
<p>
4Ventures (“nosotros”) opera el sitio web{' '}
<a className="underline" href="https://4ventures.es">
https://4ventures.es
</a>{' '}
y el Centro de Mando{' '}
<a className="underline" href="https://app.4ventures.es">
https://app.4ventures.es
</a>
. Esta política de privacidad describe qué datos personales tratamos, con qué
finalidad y cuáles son tus derechos.
</p>
<section>
<h2 className="text-xl font-semibold">Responsable</h2>
<p className="mt-2">
4Ventures · Contacto:{' '}
<a className="underline" href="mailto:marketing@4ventures.es">
marketing@4ventures.es
</a>
</p>
</section>
<section>
<h2 className="text-xl font-semibold">Datos que recopilamos</h2>
<ul className="mt-2 list-disc pl-5">
<li>Datos de cuenta: nombre, email y credenciales de acceso al portal.</li>
<li>
Datos de clientes de la agencia: métricas de anuncios, CRM, analítica y
archivos que el operador conecta (Google, Meta, Clientify).
</li>
<li>
Datos técnicos: cookies de sesión, IP y registros de seguridad necesarios
para autenticar y proteger el servicio.
</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold">Cómo usamos los datos</h2>
<p className="mt-2">
Usamos los datos para autenticar usuarios, mostrar dashboards internos,
sincronizar plataformas publicitarias y CRM, y cumplir obligaciones legales.
No vendemos datos personales. El acceso a Google y Meta se realiza mediante
OAuth; solo el equipo de 4Ventures usa esas conexiones.
</p>
</section>
<section>
<h2 className="text-xl font-semibold">Conservación y seguridad</h2>
<p className="mt-2">
Conservamos los datos mientras exista la relación de servicio o la cuenta
esté activa. Aplicamos controles de acceso, cifrado en tránsito (HTTPS) y
aislamiento por cliente (tenant).
</p>
</section>
<section>
<h2 className="text-xl font-semibold">Tus derechos</h2>
<p className="mt-2">
Puedes solicitar acceso, rectificación, supresión u oposición escribiendo a{' '}
<a className="underline" href="mailto:marketing@4ventures.es">
marketing@4ventures.es
</a>
.
</p>
</section>
</div>
</main>
);
}

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">

View File

@@ -0,0 +1,183 @@
/** Link creative_pipeline rows to a calendar day. */
export type PipelineCalendarRow = {
id: string;
title: string;
format?: string | null;
month?: string | null;
status?: string | null;
cover_path?: string | null;
slide_paths?: string[] | unknown;
caption?: string | null;
notes?: string | null;
created_at?: string | null;
publish_date?: string | null;
external_ref?: string | null;
};
export function instagramUrlFromNotes(notes?: string | null): string | null {
if (!notes) return null;
const m = notes.match(/https?:\/\/(?:www\.)?instagram\.com\S+/i);
return m ? m[0].replace(/[),.;]+$/, '') : null;
}
export function clientNoteFromNotes(notes?: string | null): string | null {
if (!notes) return null;
const m = notes.match(/CLIENT_NOTE:\s*([^\n]+)/i);
return m ? m[1].trim() : null;
}
export function slidesOf(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === 'string');
}
/** Day a published pipeline piece belongs on the calendar. */
export function pipelinePublishDay(it: PipelineCalendarRow): string | null {
if (it.publish_date && /^\d{4}-\d{2}-\d{2}/.test(it.publish_date)) {
return it.publish_date.slice(0, 10);
}
const st = (it.status || '').toLowerCase();
const ref = (it.external_ref || '').toLowerCase();
if (st === 'publicado' || ref.startsWith('ig-')) {
const d = (it.created_at || '').slice(0, 10);
return d || null;
}
return null;
}
export function pipelineMediaUrl(path: string | null | undefined): string | null {
if (!path) return null;
return `/api/creative-pipeline/media?path=${encodeURIComponent(path)}`;
}
export function normPermalink(u?: string | null): string {
return (u || '')
.trim()
.replace(/\/+$/, '')
.split('?')[0]
.toLowerCase();
}
const MONTH_LABELS = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
export type CalendarPieceLike = {
id: string;
date: string;
title: string;
platform?: string;
format?: string;
body?: string;
status?: string;
meta_line?: string;
pipeline_id?: string;
cover_path?: string | null;
slide_paths?: string[];
};
export type CalendarPlanLike = {
kind?: string;
month: string;
label: string;
hero: {
eyebrow?: string;
title: string;
lede: string;
chips?: string[];
};
pieces: CalendarPieceLike[];
[key: string]: unknown;
};
function monthLabel(ym: string) {
const m = Number(ym.slice(5, 7)) - 1;
const y = ym.slice(0, 4);
return `${MONTH_LABELS[m] || ym} ${y}`;
}
/** Overlay published pipeline creatives onto RRSS month plans. */
export function mergePipelineIntoPlans<T extends CalendarPlanLike>(
plans: T[],
items: PipelineCalendarRow[]
): T[] {
const out = plans.map((p) => ({
...p,
pieces: [...(p.pieces || [])],
}));
const byMonth = new Map(out.map((p) => [p.month, p]));
for (const it of items) {
const day = pipelinePublishDay(it);
if (!day) continue;
const ym = day.slice(0, 7);
let plan = byMonth.get(ym);
if (!plan) {
const label = monthLabel(ym);
plan = {
kind: 'rrss_month',
month: ym,
label,
hero: {
eyebrow: '4VENTURES · PUBLICADO',
title: label,
lede: 'Piezas reales del pipeline en el día en que se publicaron. Clic en el día para zoom (preview + métricas).',
chips: ['Pipeline', 'Instagram'],
},
pieces: [],
} as T;
out.push(plan);
byMonth.set(ym, plan);
}
const ig = instagramUrlFromNotes(it.notes);
const igKey = normPermalink(ig);
const existing = plan.pieces.find((p) => {
if ((p.date || '').slice(0, 10) !== day) return false;
if (igKey && normPermalink(p.meta_line) === igKey) return true;
const a = (p.title || '').toLowerCase().slice(0, 28);
const b = (it.title || '').toLowerCase().slice(0, 28);
return a && b && (a.includes(b) || b.includes(a));
});
const slides = slidesOf(it.slide_paths);
if (existing) {
existing.pipeline_id = it.id;
existing.cover_path = it.cover_path || existing.cover_path;
existing.slide_paths = slides.length ? slides : existing.slide_paths;
existing.body = existing.body || it.caption || undefined;
existing.meta_line = existing.meta_line || (ig ? `Instagram: ${ig}` : undefined);
existing.status = existing.status || 'published';
} else {
plan.pieces.push({
id: `pipe-${it.id}`,
pipeline_id: it.id,
date: day,
platform: 'instagram',
format: it.format || 'post',
title: it.title,
body: it.caption || undefined,
status: 'published',
meta_line: ig ? `Instagram: ${ig}` : undefined,
cover_path: it.cover_path,
slide_paths: slides,
});
}
}
out.sort((a, b) => a.month.localeCompare(b.month));
for (const p of out) {
p.pieces.sort((a, b) => a.date.localeCompare(b.date));
}
return out;
}

View File

@@ -40,7 +40,7 @@ function n(v: unknown): number {
function matchChannel(platform: string | undefined, ch: SimChannel): boolean {
if (ch === 'blended') return true;
const p = normalizePlatform(platform);
if (ch === 'meta') return p === 'meta' || p === 'other';
if (ch === 'meta') return p === 'meta';
if (ch === 'google') return p === 'google';
return false;
}