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:
@@ -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;
|
||||
|
||||
467
src/app/api/targets/route.ts
Normal file
467
src/app/api/targets/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
35
src/app/legal/aviso/page.tsx
Normal file
35
src/app/legal/aviso/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/app/legal/privacidad/page.tsx
Normal file
84
src/app/legal/privacidad/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user