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

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