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