diff --git a/sql/targets_budget.sql b/sql/targets_budget.sql index 6e00272..570299b 100644 --- a/sql/targets_budget.sql +++ b/sql/targets_budget.sql @@ -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); diff --git a/src/app/api/creative-pipeline/route.ts b/src/app/api/creative-pipeline/route.ts index 40d1767..a0671c1 100644 --- a/src/app/api/creative-pipeline/route.ts +++ b/src/app/api/creative-pipeline/route.ts @@ -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; diff --git a/src/app/api/targets/route.ts b/src/app/api/targets/route.ts new file mode 100644 index 0000000..051e396 --- /dev/null +++ b/src/app/api/targets/route.ts @@ -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> +): Promise { + if (agencyView) { + const tid = encodeURIComponent(tenantId); + const t = await restGet( + `/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( + `/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( + `/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( + `/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(); + const leadsAdsByDay = new Map(); + 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(); + 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( + `/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; + 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 = { + 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( + `/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 }); +} diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index 6164b74..f5ac35a 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -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( + `/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[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/ diff --git a/src/app/dashboard/creatives/page.tsx b/src/app/dashboard/creatives/page.tsx index b4ba898..3f2eb02 100644 --- a/src/app/dashboard/creatives/page.tsx +++ b/src/app/dashboard/creatives/page.tsx @@ -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 ; + return ; } diff --git a/src/app/dashboard/estrategia/page.tsx b/src/app/dashboard/estrategia/page.tsx index 5078d39..e2d0e8c 100644 --- a/src/app/dashboard/estrategia/page.tsx +++ b/src/app/dashboard/estrategia/page.tsx @@ -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 ( -
-
- {label} - - {p}% - -
-
-
-
-

- {actual.toLocaleString('es-ES')} - {unit} / {target.toLocaleString('es-ES')} - {unit} -

-
- ); -} - 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( - `/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( - `/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 (
{ctx.portfolioWide ? ( ) : ( -
- {/* Presupuesto */} -
-
- -

- Objetivos de presupuesto (ppto) -

-
-

- Inversión ads planificada vs gasto real del mes (ad_daily). -

- {spendTarget > 0 ? ( - - ) : ( -
- Sin spend_target para este - mes en targets. Gasto - actual: {spendActual.toLocaleString('es-ES')} €. -
- )} -
-
-

Ppto

-

- {spendTarget.toLocaleString('es-ES')} € -

-
-
-

Gastado

-

- {spendActual.toLocaleString('es-ES')} € -

-
-
-
- - {/* Consecución */} -
-
- -

- Objetivos de consecución -

-
-

- Metas de negocio del mes vs resultado real (RPC targets_vs_actual). -

- - - -
- - Cumplimiento facturación:{' '} - {pct(revenueActual, revenueTarget) || - Math.round(Number(targetData.revenue_achievement_pct) || 0)} - % -
-
-
+ <> + + + )} - - {!ctx.portfolioWide ? ( - - ) : null}
); } diff --git a/src/app/legal/aviso/page.tsx b/src/app/legal/aviso/page.tsx new file mode 100644 index 0000000..ca58cd0 --- /dev/null +++ b/src/app/legal/aviso/page.tsx @@ -0,0 +1,35 @@ +export const metadata = { + title: 'Términos del servicio — 4Ventures', + robots: 'index,follow', +}; + +export default function TermsPage() { + return ( +
+

4Ventures

+

Términos del servicio

+

Última actualización: 5 de octubre de 2026

+
+

+ El Centro de Mando de 4Ventures ( + + app.4ventures.es + + ) 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. +

+

+ 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. +

+

+ Contacto:{' '} + + marketing@4ventures.es + +

+
+
+ ); +} diff --git a/src/app/legal/privacidad/page.tsx b/src/app/legal/privacidad/page.tsx new file mode 100644 index 0000000..028214b --- /dev/null +++ b/src/app/legal/privacidad/page.tsx @@ -0,0 +1,84 @@ +export const metadata = { + title: 'Política de privacidad — 4Ventures', + robots: 'index,follow', +}; + +export default function PrivacyPage() { + return ( +
+

4Ventures

+

Política de privacidad

+

Última actualización: 5 de octubre de 2026

+ +
+

+ 4Ventures (“nosotros”) opera el sitio web{' '} + + https://4ventures.es + {' '} + y el Centro de Mando{' '} + + https://app.4ventures.es + + . Esta política de privacidad describe qué datos personales tratamos, con qué + finalidad y cuáles son tus derechos. +

+ +
+

Responsable

+

+ 4Ventures · Contacto:{' '} + + marketing@4ventures.es + +

+
+ +
+

Datos que recopilamos

+
    +
  • Datos de cuenta: nombre, email y credenciales de acceso al portal.
  • +
  • + Datos de clientes de la agencia: métricas de anuncios, CRM, analítica y + archivos que el operador conecta (Google, Meta, Clientify). +
  • +
  • + Datos técnicos: cookies de sesión, IP y registros de seguridad necesarios + para autenticar y proteger el servicio. +
  • +
+
+ +
+

Cómo usamos los datos

+

+ 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. +

+
+ +
+

Conservación y seguridad

+

+ 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). +

+
+ +
+

Tus derechos

+

+ Puedes solicitar acceso, rectificación, supresión u oposición escribiendo a{' '} + + marketing@4ventures.es + + . +

+
+
+
+ ); +} diff --git a/src/components/BudgetSimulator.tsx b/src/components/BudgetSimulator.tsx index a291cd7..88e8be0 100644 --- a/src/components/BudgetSimulator.tsx +++ b/src/components/BudgetSimulator.tsx @@ -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(1000); const [channel, setChannel] = useState('blended'); - const [simulation, setSimulation] = useState(null); + const [byChannel, setByChannel] = useState>({}); const [loading, setLoading] = useState(false); const [err, setErr] = useState(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 = {}; + 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 ( -
-
-
-
- -

- Simulador «¿Qué pasa si…?» -

-
-

- Proyección con CPL, tasa de cierre y ticket medio de{' '} - - {simulation?.tenant_label || tenantLabel || 'este cliente'} - - . Sin TikTok (aún sin campañas activas). -

+
+
+
+ } + 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.`} + />
{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({ - + {spend.toLocaleString('es-ES')} € {loading ? ( @@ -157,130 +152,177 @@ export default function BudgetSimulator({
{err ? ( -

+

{err}

) : null}
- Baseline del cliente: - + Baseline {CHANNELS.find((c) => c.id === channel)?.label}: + CPL:{' '} {Number(baseline.historical_cpl || 0).toFixed(2)} € - - Cierre:{' '} - {baseline.conversion_rate_pct || 0}% + + Cierre: {baseline.conversion_rate_pct || 0}% - + Ticket:{' '} {Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} € {baseline.sample_spend != null ? ( - + Muestra: {Number(baseline.sample_spend).toLocaleString('es-ES')} € ·{' '} {baseline.sample_leads || 0} leads ) : null}
-
-
-
-
- - Conservador (p10) - - -
-

- + - {Number(p10.revenue || 0).toLocaleString('es-ES', { - minimumFractionDigits: 2, - })}{' '} - € -

-

- ROAS {p10.roas || 0}x -

-
-
-
- Leads: - {p10.leads || 0} -
-
- Ventas: - {p10.deals || 0} -
-
-
+
+ + + + + + + + + + + {CHANNELS.map((ch) => { + const row = byChannel[ch.id]; + const active = channel === ch.id; + return ( + + + + + + + ); + })} + +
CanalCPL hist.Leads p50Ingresos p50
+ {ch.label} + {active ? ( + + activo + + ) : null} + + {Number(row?.baseline?.historical_cpl || 0).toFixed(2)} € + + {row?.expected_p50?.leads || 0} + + +{euro(Number(row?.expected_p50?.revenue || 0))} € +
+
-
-
- Más probable -
-
-
- - Esperado (p50) - - -
-

- + - {Number(p50.revenue || 0).toLocaleString('es-ES', { - minimumFractionDigits: 2, - })}{' '} - € -

-

- ROAS {p50.roas || 0}x -

-
-
-
- Leads: - {p50.leads || 0} -
-
- Ventas: - {p50.deals || 0} -
-
-
+
+ } + band={p10} + highlight={false} + /> + } + band={p50} + highlight + /> + } + band={p90} + highlight={false} + /> +
-
-
-
- - Optimista (p90) - - -
-

- + - {Number(p90.revenue || 0).toLocaleString('es-ES', { - minimumFractionDigits: 2, - })}{' '} - € -

-

- ROAS {p90.roas || 0}x -

-
-
-
- Leads: - {p90.leads || 0} -
-
- Ventas: - {p90.deals || 0} -
-
+ +

+ Cada pestaña simula invertir todo 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 €. +

+

+ El blended usa el CPL ponderado (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. +

+

+ Cierre y ticket salen de kpi_weekly del canal. Si un + canal no tiene semanas propias, hereda el cierre del tenant (mismo + ticket, CPL distinto). +

+
+
+ ); +} + +function BandCard({ + title, + icon, + band, + highlight, +}: { + title: string; + icon: React.ReactNode; + band: SimBand; + highlight: boolean; +}) { + return ( +
+ {highlight ? ( +
+ Más probable +
+ ) : null} +
+
+ + {title} + + {icon} +
+

+ + + {Number(band.revenue || 0).toLocaleString('es-ES', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })}{' '} + € +

+

+ ROAS {band.roas || 0}x +

+
+
+
+ Leads: + {band.leads || 0} +
+
+ Ventas: + {band.deals || 0}
diff --git a/src/components/CalendarDayZoom.tsx b/src/components/CalendarDayZoom.tsx new file mode 100644 index 0000000..3e32240 --- /dev/null +++ b/src/components/CalendarDayZoom.tsx @@ -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 ( +
+

+ {icon} + {label} +

+

+ {value} +

+
+ ); +} + +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 ( +
+
e.stopPropagation()} + > +
+
+ {currentUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {item?.title + ) : ( +

Sin preview de esta pieza

+ )} + {images.length > 1 ? ( + <> + + + + {slide + 1} / {images.length} + + + ) : null} +
+ {items.length > 1 ? ( +
+ {items.map((it, i) => { + const thumb = pipelineMediaUrl(it.cover_path); + return ( + + ); + })} +
+ ) : null} +
+ +
+
+
+

+ {dayLabel} +

+

+ {item?.title || 'Publicación'} +

+

+ {(item?.format || 'post').toString()} ·{' '} + {items.length > 1 + ? `${idx + 1} de ${items.length} este día` + : 'Creatividad real (pipeline)'} +

+
+ +
+ +
+ } + label="Likes" + value={String(metrics?.likes ?? '—')} + /> + } + label="Comentarios" + value={String(metrics?.comments ?? '—')} + /> + } + label="Alcance" + value={ + metrics?.reach != null + ? Number(metrics.reach).toLocaleString('es-ES') + : '—' + } + /> + } + label="Impresiones" + value={ + metrics?.impressions != null + ? Number(metrics.impressions).toLocaleString('es-ES') + : '—' + } + /> +
+ +
+ {permalink ? ( + + + Ver en Instagram + + + ) : null} + {item?.caption ? ( +
+ {item.caption} +
+ ) : null} + {!metrics ? ( +

+ Métricas en vivo cuando hay match con el post de Instagram + (likes, comentarios, alcance). +

+ ) : null} +
+
+
+
+ ); +} diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index 72394f3..6977671 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -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('all'); const [selectedFormat, setSelectedFormat] = useState('all'); const [previewItem, setPreviewItem] = useState(null); + const [rejectItem, setRejectItem] = useState(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(); 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(); 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() { ) : null} +

+ Piezas ya publicadas:{' '} + + ábrelas en el calendario, clic en el día + + . +

{/* Barra de Filtros por Mes y Formato */}
- Meses ({items.length} piezas): + Meses ({items.filter((i) => i.status !== 'publicado').length} en revisión):
@@ -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 ? ( +
setRejectItem(null)} + > +
e.stopPropagation()} + onSubmit={(e) => { + e.preventDefault(); + if (!rejectNote.trim()) return; + const id = rejectItem.id; + void setStatus(id, 'rechazado', rejectNote.trim()).then(() => { + setRejectItem(null); + setRejectNote(''); + }); + }} + > +

+ No aprobar · {rejectItem.title} +

+

+ Deja una nota para que 4Ventures sepa qué hay que cambiar. +

+