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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calculator, TrendingUp, ShieldCheck, Sparkles } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Calculator, ShieldCheck, Sparkles, TrendingUp } from 'lucide-react';
|
||||
import { HelpDetails, SectionHead } from '@/components/dash-ui';
|
||||
|
||||
type SimBand = {
|
||||
revenue?: number;
|
||||
roas?: number;
|
||||
leads?: number;
|
||||
deals?: number;
|
||||
};
|
||||
|
||||
type SimPayload = {
|
||||
tenant_label?: string;
|
||||
hint?: string;
|
||||
target_channel?: string;
|
||||
baseline?: {
|
||||
historical_cpl?: number;
|
||||
@@ -13,27 +20,11 @@ type SimPayload = {
|
||||
avg_ticket?: number;
|
||||
sample_spend?: number;
|
||||
sample_leads?: number;
|
||||
sample_deals?: number;
|
||||
source?: string;
|
||||
};
|
||||
conservative_p10?: {
|
||||
revenue?: number;
|
||||
roas?: number;
|
||||
leads?: number;
|
||||
deals?: number;
|
||||
};
|
||||
expected_p50?: {
|
||||
revenue?: number;
|
||||
roas?: number;
|
||||
leads?: number;
|
||||
deals?: number;
|
||||
};
|
||||
optimistic_p90?: {
|
||||
revenue?: number;
|
||||
roas?: number;
|
||||
leads?: number;
|
||||
deals?: number;
|
||||
};
|
||||
conservative_p10?: SimBand;
|
||||
expected_p50?: SimBand;
|
||||
optimistic_p90?: SimBand;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -43,6 +34,13 @@ const CHANNELS = [
|
||||
{ id: 'blended', label: 'Blended' },
|
||||
] as const;
|
||||
|
||||
function euro(v: number) {
|
||||
return Number(v || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export default function BudgetSimulator({
|
||||
tenantLabel,
|
||||
}: {
|
||||
@@ -50,33 +48,37 @@ export default function BudgetSimulator({
|
||||
}) {
|
||||
const [spend, setSpend] = useState<number>(1000);
|
||||
const [channel, setChannel] = useState<string>('blended');
|
||||
const [simulation, setSimulation] = useState<SimPayload | null>(null);
|
||||
const [byChannel, setByChannel] = useState<Record<string, SimPayload>>({});
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
void fetchSimulation(spend, channel);
|
||||
}, 200);
|
||||
void fetchAll(spend);
|
||||
}, 220);
|
||||
return () => clearTimeout(timer);
|
||||
}, [spend, channel]);
|
||||
}, [spend]);
|
||||
|
||||
async function fetchSimulation(amount: number, ch: string) {
|
||||
async function fetchAll(amount: number) {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch('/api/simulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ spend: amount, channel: ch }),
|
||||
});
|
||||
const data = (await res.json()) as SimPayload;
|
||||
if (!res.ok) {
|
||||
setErr(data.error || 'Error al simular');
|
||||
setSimulation(null);
|
||||
return;
|
||||
}
|
||||
setSimulation(data);
|
||||
const entries = await Promise.all(
|
||||
CHANNELS.map(async (ch) => {
|
||||
const res = await fetch('/api/simulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ spend: amount, channel: ch.id }),
|
||||
});
|
||||
const data = (await res.json()) as SimPayload;
|
||||
return [ch.id, res.ok ? data : { error: data.error }] as const;
|
||||
})
|
||||
);
|
||||
const next: Record<string, SimPayload> = {};
|
||||
for (const [id, data] of entries) next[id] = data;
|
||||
const firstErr = entries.find(([, d]) => d.error)?.[1].error;
|
||||
if (firstErr && !next.blended?.expected_p50) setErr(firstErr);
|
||||
setByChannel(next);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
@@ -84,28 +86,21 @@ export default function BudgetSimulator({
|
||||
}
|
||||
}
|
||||
|
||||
const simulation = byChannel[channel] || null;
|
||||
const p10 = simulation?.conservative_p10 || {};
|
||||
const p50 = simulation?.expected_p50 || {};
|
||||
const p90 = simulation?.optimistic_p90 || {};
|
||||
const baseline = simulation?.baseline || {};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-gray-200 pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="w-5 h-5 text-brand-500" />
|
||||
<h3 className="text-lg font-bold text-gray-900">
|
||||
Simulador «¿Qué pasa si…?»
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1 max-w-xl">
|
||||
Proyección con CPL, tasa de cierre y ticket medio de{' '}
|
||||
<strong className="text-gray-600">
|
||||
{simulation?.tenant_label || tenantLabel || 'este cliente'}
|
||||
</strong>
|
||||
. Sin TikTok (aún sin campañas activas).
|
||||
</p>
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-6 space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 border-b border-gray-200 pb-4">
|
||||
<div className="min-w-0 flex-1 [&>div]:mb-0">
|
||||
<SectionHead
|
||||
icon={<Calculator className="w-4 h-4" />}
|
||||
title="Simulador «¿Qué pasa si…?»"
|
||||
hint={`Si inviertes este presupuesto solo en el canal elegido, con el CPL histórico de ${simulation?.tenant_label || tenantLabel || 'este cliente'}. No es un split Meta+Google.`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{CHANNELS.map((ch) => (
|
||||
@@ -113,9 +108,9 @@ export default function BudgetSimulator({
|
||||
key={ch.id}
|
||||
type="button"
|
||||
onClick={() => setChannel(ch.id)}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold uppercase transition-all ${
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold uppercase transition-all ${
|
||||
channel === ch.id
|
||||
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
@@ -130,7 +125,7 @@ export default function BudgetSimulator({
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Inversión adicional proyectada
|
||||
</label>
|
||||
<span className="text-lg font-extrabold text-brand-400">
|
||||
<span className="text-lg font-extrabold text-brand-600">
|
||||
{spend.toLocaleString('es-ES')} €
|
||||
{loading ? (
|
||||
<span className="text-[10px] text-gray-500 ml-2 font-normal">
|
||||
@@ -157,130 +152,177 @@ export default function BudgetSimulator({
|
||||
</div>
|
||||
|
||||
{err ? (
|
||||
<p className="text-xs text-red-400 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
<p className="text-xs text-red-500 border border-red-200 rounded-lg px-3 py-2">
|
||||
{err}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 bg-gray-50 p-3 rounded-lg border border-gray-200 text-xs text-gray-600">
|
||||
<span className="text-gray-400">Baseline del cliente:</span>
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-800">
|
||||
<span className="text-gray-400">Baseline {CHANNELS.find((c) => c.id === channel)?.label}:</span>
|
||||
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-800">
|
||||
CPL:{' '}
|
||||
<strong>{Number(baseline.historical_cpl || 0).toFixed(2)} €</strong>
|
||||
</span>
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-800">
|
||||
Cierre:{' '}
|
||||
<strong>{baseline.conversion_rate_pct || 0}%</strong>
|
||||
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-800">
|
||||
Cierre: <strong>{baseline.conversion_rate_pct || 0}%</strong>
|
||||
</span>
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-800">
|
||||
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-800">
|
||||
Ticket:{' '}
|
||||
<strong>
|
||||
{Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} €
|
||||
</strong>
|
||||
</span>
|
||||
{baseline.sample_spend != null ? (
|
||||
<span className="bg-gray-100 px-2 py-0.5 rounded text-gray-400">
|
||||
<span className="bg-white border border-gray-200 px-2 py-0.5 rounded text-gray-500">
|
||||
Muestra: {Number(baseline.sample_spend).toLocaleString('es-ES')} € ·{' '}
|
||||
{baseline.sample_leads || 0} leads
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-gray-400">
|
||||
Conservador (p10)
|
||||
</span>
|
||||
<ShieldCheck className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900">
|
||||
+
|
||||
{Number(p10.revenue || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 2,
|
||||
})}{' '}
|
||||
€
|
||||
</p>
|
||||
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
||||
ROAS {p10.roas || 0}x
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-200 text-xs text-gray-400 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Leads:</span>
|
||||
<strong className="text-gray-800">{p10.leads || 0}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Ventas:</span>
|
||||
<strong className="text-gray-800">{p10.deals || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-[11px] uppercase tracking-wider text-gray-500">
|
||||
<tr>
|
||||
<th className="text-left font-semibold px-3 py-2">Canal</th>
|
||||
<th className="text-right font-semibold px-3 py-2">CPL hist.</th>
|
||||
<th className="text-right font-semibold px-3 py-2">Leads p50</th>
|
||||
<th className="text-right font-semibold px-3 py-2">Ingresos p50</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CHANNELS.map((ch) => {
|
||||
const row = byChannel[ch.id];
|
||||
const active = channel === ch.id;
|
||||
return (
|
||||
<tr
|
||||
key={ch.id}
|
||||
className={`border-t border-gray-100 ${
|
||||
active ? 'bg-brand-50/70' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 font-semibold text-gray-900">
|
||||
{ch.label}
|
||||
{active ? (
|
||||
<span className="ml-2 text-[10px] uppercase text-brand-700">
|
||||
activo
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{Number(row?.baseline?.historical_cpl || 0).toFixed(2)} €
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{row?.expected_p50?.leads || 0}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-semibold text-gray-900">
|
||||
+{euro(Number(row?.expected_p50?.revenue || 0))} €
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-b from-brand-950/40 to-[#0B0F19] border-2 border-brand-500/60 rounded-xl p-4 flex flex-col justify-between relative shadow-lg shadow-brand-900/20">
|
||||
<div className="absolute -top-2.5 right-3 bg-brand-600 text-white text-[9px] uppercase tracking-wider font-bold px-2 py-0.5 rounded-full">
|
||||
Más probable
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-brand-700">
|
||||
Esperado (p50)
|
||||
</span>
|
||||
<Sparkles className="w-4 h-4 text-brand-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900">
|
||||
+
|
||||
{Number(p50.revenue || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 2,
|
||||
})}{' '}
|
||||
€
|
||||
</p>
|
||||
<p className="text-xs text-brand-400 font-semibold mt-1">
|
||||
ROAS {p50.roas || 0}x
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-brand-800/40 text-xs text-gray-600 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Leads:</span>
|
||||
<strong className="text-gray-900">{p50.leads || 0}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Ventas:</span>
|
||||
<strong className="text-gray-900">{p50.deals || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-1">
|
||||
<BandCard
|
||||
title="Conservador (p10)"
|
||||
icon={<ShieldCheck className="w-4 h-4 text-amber-500" />}
|
||||
band={p10}
|
||||
highlight={false}
|
||||
/>
|
||||
<BandCard
|
||||
title="Esperado (p50)"
|
||||
icon={<Sparkles className="w-4 h-4 text-brand-500" />}
|
||||
band={p50}
|
||||
highlight
|
||||
/>
|
||||
<BandCard
|
||||
title="Optimista (p90)"
|
||||
icon={<TrendingUp className="w-4 h-4 text-emerald-500" />}
|
||||
band={p90}
|
||||
highlight={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-gray-400">
|
||||
Optimista (p90)
|
||||
</span>
|
||||
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900">
|
||||
+
|
||||
{Number(p90.revenue || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 2,
|
||||
})}{' '}
|
||||
€
|
||||
</p>
|
||||
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
||||
ROAS {p90.roas || 0}x
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-200 text-xs text-gray-400 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Leads:</span>
|
||||
<strong className="text-gray-800">{p90.leads || 0}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Ventas:</span>
|
||||
<strong className="text-gray-800">{p90.deals || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<HelpDetails title="Por qué Meta + Google no suma el Blended">
|
||||
<p>
|
||||
Cada pestaña simula invertir <strong>todo</strong> este presupuesto en
|
||||
un solo canal. No es «cómo se reparte el blended». Por eso 1.000 € en
|
||||
Meta + 1.000 € en Google serían 2.000 €, no el blended de 1.000 €.
|
||||
</p>
|
||||
<p>
|
||||
El blended usa el <strong>CPL ponderado</strong> (gasto total ÷ leads
|
||||
totales), no la media de las dos proyecciones. Si Google es más caro
|
||||
por lead, blended queda entre Meta y Google, más cerca del canal con
|
||||
más volumen histórico.
|
||||
</p>
|
||||
<p>
|
||||
Cierre y ticket salen de <code>kpi_weekly</code> del canal. Si un
|
||||
canal no tiene semanas propias, hereda el cierre del tenant (mismo
|
||||
ticket, CPL distinto).
|
||||
</p>
|
||||
</HelpDetails>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BandCard({
|
||||
title,
|
||||
icon,
|
||||
band,
|
||||
highlight,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
band: SimBand;
|
||||
highlight: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl p-4 flex flex-col justify-between border ${
|
||||
highlight
|
||||
? 'border-2 border-brand-500 bg-gradient-to-b from-brand-50 to-white relative shadow-sm'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
{highlight ? (
|
||||
<div className="absolute -top-2.5 right-3 bg-brand-600 text-white text-[9px] uppercase tracking-wider font-bold px-2 py-0.5 rounded-full">
|
||||
Más probable
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span
|
||||
className={`text-xs font-semibold ${
|
||||
highlight ? 'text-brand-700' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900 tabular-nums">
|
||||
+
|
||||
{Number(band.revenue || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})}{' '}
|
||||
€
|
||||
</p>
|
||||
<p className="text-xs text-brand-700 font-semibold mt-1">
|
||||
ROAS {band.roas || 0}x
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-200 text-xs text-gray-500 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Leads:</span>
|
||||
<strong className="text-gray-800">{band.leads || 0}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Ventas:</span>
|
||||
<strong className="text-gray-800">{band.deals || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
282
src/components/CalendarDayZoom.tsx
Normal file
282
src/components/CalendarDayZoom.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Heart,
|
||||
Instagram,
|
||||
MessageCircle,
|
||||
Repeat2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
instagramUrlFromNotes,
|
||||
pipelineMediaUrl,
|
||||
slidesOf,
|
||||
type PipelineCalendarRow,
|
||||
normPermalink,
|
||||
} from '@/lib/pipeline-calendar';
|
||||
|
||||
export type SocialLite = {
|
||||
permalink?: string | null;
|
||||
createdAt?: string | null;
|
||||
likes?: number;
|
||||
comments?: number;
|
||||
shares?: number;
|
||||
reach?: number | null;
|
||||
impressions?: number | null;
|
||||
mediaUrl?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
};
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-2.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-500 inline-flex items-center gap-1">
|
||||
{icon}
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-lg font-semibold tabular-nums text-gray-900 mt-0.5">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CalendarDayZoom({
|
||||
day,
|
||||
items,
|
||||
socialPosts,
|
||||
onClose,
|
||||
}: {
|
||||
day: string;
|
||||
items: PipelineCalendarRow[];
|
||||
socialPosts?: SocialLite[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [slide, setSlide] = useState(0);
|
||||
const item = items[Math.min(idx, items.length - 1)] || null;
|
||||
|
||||
useEffect(() => {
|
||||
setIdx(0);
|
||||
setSlide(0);
|
||||
}, [day, items.length]);
|
||||
|
||||
const images = useMemo(() => {
|
||||
if (!item) return [];
|
||||
const all = [item.cover_path, ...slidesOf(item.slide_paths)].filter(
|
||||
Boolean
|
||||
) as string[];
|
||||
return all;
|
||||
}, [item]);
|
||||
|
||||
const permalink = item ? instagramUrlFromNotes(item.notes) : null;
|
||||
const metrics = useMemo(() => {
|
||||
if (!item) return null;
|
||||
const key = normPermalink(permalink);
|
||||
const date = (item.created_at || item.publish_date || '').slice(0, 10);
|
||||
return (
|
||||
(socialPosts || []).find((p) => {
|
||||
if (key && normPermalink(p.permalink) === key) return true;
|
||||
if (date && (p.createdAt || '').slice(0, 10) === date) return true;
|
||||
return false;
|
||||
}) || null
|
||||
);
|
||||
}, [item, permalink, socialPosts]);
|
||||
|
||||
const currentPath = images[slide] || item?.cover_path || null;
|
||||
const currentUrl =
|
||||
pipelineMediaUrl(currentPath) ||
|
||||
metrics?.mediaUrl ||
|
||||
metrics?.thumbnailUrl ||
|
||||
null;
|
||||
|
||||
const dayLabel = new Date(`${day}T12:00:00`).toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[92vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl md:flex-row"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-1 flex-col bg-gray-950 p-4 min-h-[280px]">
|
||||
<div className="relative flex flex-1 items-center justify-center overflow-hidden rounded-lg bg-black min-h-[260px]">
|
||||
{currentUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={currentUrl}
|
||||
alt={item?.title || ''}
|
||||
className="max-h-[62vh] max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Sin preview de esta pieza</p>
|
||||
)}
|
||||
{images.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-1.5 text-white"
|
||||
onClick={() =>
|
||||
setSlide((s) => (s > 0 ? s - 1 : images.length - 1))
|
||||
}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/60 p-1.5 text-white"
|
||||
onClick={() =>
|
||||
setSlide((s) => (s < images.length - 1 ? s + 1 : 0))
|
||||
}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-2 py-0.5 text-[11px] text-white">
|
||||
{slide + 1} / {images.length}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{items.length > 1 ? (
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto">
|
||||
{items.map((it, i) => {
|
||||
const thumb = pipelineMediaUrl(it.cover_path);
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIdx(i);
|
||||
setSlide(0);
|
||||
}}
|
||||
className={`relative h-16 w-12 shrink-0 overflow-hidden rounded-md border-2 ${
|
||||
i === idx
|
||||
? 'border-brand-500'
|
||||
: 'border-transparent opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{thumb ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={thumb}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-full items-center justify-center bg-gray-800 text-[9px] text-white">
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col md:w-[22rem] border-t md:border-t-0 md:border-l border-gray-100 overflow-y-auto">
|
||||
<div className="flex items-start justify-between gap-2 p-5 pb-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-700">
|
||||
{dayLabel}
|
||||
</p>
|
||||
<h3 className="mt-1 text-base font-bold text-gray-900 leading-snug">
|
||||
{item?.title || 'Publicación'}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{(item?.format || 'post').toString()} ·{' '}
|
||||
{items.length > 1
|
||||
? `${idx + 1} de ${items.length} este día`
|
||||
: 'Creatividad real (pipeline)'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 text-gray-400 hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 grid grid-cols-2 gap-2">
|
||||
<Metric
|
||||
icon={<Heart className="h-3 w-3" />}
|
||||
label="Likes"
|
||||
value={String(metrics?.likes ?? '—')}
|
||||
/>
|
||||
<Metric
|
||||
icon={<MessageCircle className="h-3 w-3" />}
|
||||
label="Comentarios"
|
||||
value={String(metrics?.comments ?? '—')}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Eye className="h-3 w-3" />}
|
||||
label="Alcance"
|
||||
value={
|
||||
metrics?.reach != null
|
||||
? Number(metrics.reach).toLocaleString('es-ES')
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Repeat2 className="h-3 w-3" />}
|
||||
label="Impresiones"
|
||||
value={
|
||||
metrics?.impressions != null
|
||||
? Number(metrics.impressions).toLocaleString('es-ES')
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-3 flex-1">
|
||||
{permalink ? (
|
||||
<a
|
||||
href={permalink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-full items-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 via-pink-600 to-amber-500 px-3 py-2 text-xs font-bold text-white"
|
||||
>
|
||||
<Instagram className="h-4 w-4" />
|
||||
Ver en Instagram
|
||||
<ExternalLink className="h-3 w-3 ml-auto" />
|
||||
</a>
|
||||
) : null}
|
||||
{item?.caption ? (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
|
||||
{item.caption}
|
||||
</div>
|
||||
) : null}
|
||||
{!metrics ? (
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Métricas en vivo cuando hay match con el post de Instagram
|
||||
(likes, comentarios, alcance).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader, Surface } from '@/components/ui';
|
||||
import { clientNoteFromNotes } from '@/lib/pipeline-calendar';
|
||||
import Link from 'next/link';
|
||||
|
||||
type PipelineItem = {
|
||||
id: string;
|
||||
@@ -108,6 +110,8 @@ export default function CreativePipelineBoard() {
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>('all');
|
||||
const [selectedFormat, setSelectedFormat] = useState<string>('all');
|
||||
const [previewItem, setPreviewItem] = useState<PipelineItem | null>(null);
|
||||
const [rejectItem, setRejectItem] = useState<PipelineItem | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState('');
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@@ -129,6 +133,7 @@ export default function CreativePipelineBoard() {
|
||||
const monthCounts = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const it of items) {
|
||||
if (it.status === 'publicado') continue;
|
||||
const key = it.month || 'sin-mes';
|
||||
map.set(key, (map.get(key) || 0) + 1);
|
||||
}
|
||||
@@ -138,6 +143,7 @@ export default function CreativePipelineBoard() {
|
||||
const months = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const it of items) {
|
||||
if (it.status === 'publicado') continue;
|
||||
if (it.month) set.add(it.month);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
@@ -145,6 +151,7 @@ export default function CreativePipelineBoard() {
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return items.filter((it) => {
|
||||
if (it.status === 'publicado') return false;
|
||||
if (selectedMonth !== 'all' && (it.month || 'sin-mes') !== selectedMonth) return false;
|
||||
if (selectedFormat !== 'all' && it.format !== selectedFormat) return false;
|
||||
return true;
|
||||
@@ -181,14 +188,14 @@ export default function CreativePipelineBoard() {
|
||||
await load();
|
||||
}
|
||||
|
||||
async function setStatus(id: string, status: string) {
|
||||
async function setStatus(id: string, status: string, notes?: string) {
|
||||
setBusyId(id);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch('/api/creative-pipeline', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, status }),
|
||||
body: JSON.stringify({ id, status, ...(notes ? { notes } : {}) }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
@@ -196,7 +203,11 @@ export default function CreativePipelineBoard() {
|
||||
return;
|
||||
}
|
||||
if (previewItem && previewItem.id === id) {
|
||||
setPreviewItem({ ...previewItem, status });
|
||||
setPreviewItem({
|
||||
...previewItem,
|
||||
status,
|
||||
notes: notes || previewItem.notes,
|
||||
});
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
@@ -246,7 +257,7 @@ export default function CreativePipelineBoard() {
|
||||
<PageHeader
|
||||
eyebrow="Producción editorial"
|
||||
title="Panel de Creatividades"
|
||||
description="Planificación, diseño y archivo histórico sincronizado con Instagram."
|
||||
description="Solo revisión: propuesta, diseño y OK del cliente. Lo publicado se ve en el calendario, día a día."
|
||||
actions={
|
||||
isAgency ? (
|
||||
<button
|
||||
@@ -269,13 +280,20 @@ export default function CreativePipelineBoard() {
|
||||
{err}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="mb-4 text-xs text-gray-500">
|
||||
Piezas ya publicadas:{' '}
|
||||
<Link href="/dashboard/calendario" className="font-semibold text-brand-700 hover:underline">
|
||||
ábrelas en el calendario, clic en el día
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
{/* Barra de Filtros por Mes y Formato */}
|
||||
<div className="mb-6 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Meses ({items.length} piezas):
|
||||
Meses ({items.filter((i) => i.status !== 'publicado').length} en revisión):
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
@@ -416,7 +434,14 @@ export default function CreativePipelineBoard() {
|
||||
item={it}
|
||||
isAgency={isAgency}
|
||||
busy={busyId === it.id}
|
||||
onStatus={(s) => setStatus(it.id, s)}
|
||||
onStatus={(s) => {
|
||||
if (s === 'rechazado' && !isAgency) {
|
||||
setRejectItem(it);
|
||||
setRejectNote('');
|
||||
return;
|
||||
}
|
||||
void setStatus(it.id, s);
|
||||
}}
|
||||
onDelete={() => removeItem(it.id)}
|
||||
onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)}
|
||||
onPreview={() => setPreviewItem(it)}
|
||||
@@ -435,9 +460,68 @@ export default function CreativePipelineBoard() {
|
||||
isAgency={isAgency}
|
||||
busy={busyId === previewItem.id}
|
||||
onClose={() => setPreviewItem(null)}
|
||||
onStatus={(s) => setStatus(previewItem.id, s)}
|
||||
onStatus={(s) => {
|
||||
if (s === 'rechazado' && !isAgency) {
|
||||
setRejectItem(previewItem);
|
||||
setRejectNote('');
|
||||
return;
|
||||
}
|
||||
void setStatus(previewItem.id, s);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rejectItem ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={() => setRejectItem(null)}
|
||||
>
|
||||
<form
|
||||
className="w-full max-w-md rounded-2xl bg-white p-5 shadow-xl space-y-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!rejectNote.trim()) return;
|
||||
const id = rejectItem.id;
|
||||
void setStatus(id, 'rechazado', rejectNote.trim()).then(() => {
|
||||
setRejectItem(null);
|
||||
setRejectNote('');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-bold text-gray-900">
|
||||
No aprobar · {rejectItem.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Deja una nota para que 4Ventures sepa qué hay que cambiar.
|
||||
</p>
|
||||
<textarea
|
||||
required
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-xl border border-gray-200 px-3 py-2 text-sm"
|
||||
placeholder="Ej. Cambia el titular, el logo se ve pequeño…"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRejectItem(null)}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 text-xs font-semibold"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!rejectNote.trim() || busyId === rejectItem.id}
|
||||
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
Enviar nota y rechazar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -617,6 +701,12 @@ function PieceCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.status === 'rechazado' && clientNoteFromNotes(item.notes) ? (
|
||||
<p className="text-[11px] text-red-800 bg-red-50 border border-red-100 rounded-lg px-2 py-1.5">
|
||||
Nota del cliente: {clientNoteFromNotes(item.notes)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
{isAgency ? (
|
||||
<select
|
||||
@@ -820,6 +910,12 @@ function DetailModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.status === 'rechazado' && clientNoteFromNotes(item.notes) ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-3 text-xs text-red-900">
|
||||
<p className="font-semibold mb-1">Nota del cliente</p>
|
||||
{clientNoteFromNotes(item.notes)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-gray-100 pt-4">
|
||||
|
||||
615
src/components/ObjetivosDashboard.tsx
Normal file
615
src/components/ObjetivosDashboard.tsx
Normal file
@@ -0,0 +1,615 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Target,
|
||||
Trophy,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
HelpDetails,
|
||||
HeroBand,
|
||||
HeroStat,
|
||||
SectionHead,
|
||||
SparkBars,
|
||||
} from '@/components/dash-ui';
|
||||
|
||||
type Tone = 'ahead' | 'on_track' | 'behind';
|
||||
|
||||
type Payload = {
|
||||
tenant_label?: string;
|
||||
month: string;
|
||||
as_of: string;
|
||||
days_in_month: number;
|
||||
days_elapsed: number;
|
||||
days_remaining: number;
|
||||
pace_pct: number;
|
||||
has_targets: boolean;
|
||||
targets: { spend: number; leads: number; clients: number; revenue: number };
|
||||
actual: {
|
||||
spend: number;
|
||||
leads: number;
|
||||
clients: number;
|
||||
revenue: number;
|
||||
deals: number;
|
||||
};
|
||||
expected: {
|
||||
spend: number;
|
||||
leads: number;
|
||||
clients: number;
|
||||
revenue: number;
|
||||
};
|
||||
status: {
|
||||
spend: Tone;
|
||||
leads: Tone;
|
||||
clients: Tone;
|
||||
revenue: Tone;
|
||||
};
|
||||
platforms: { meta: number; google: number; other: number };
|
||||
series: { date: string; spend: number; crmLeads: number }[];
|
||||
cumulative: {
|
||||
date: string;
|
||||
spend: number;
|
||||
spendPace: number;
|
||||
leads: number;
|
||||
leadsPace: number;
|
||||
}[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const TONE: Record<Tone, { label: string; className: string }> = {
|
||||
ahead: {
|
||||
label: 'Por delante',
|
||||
className: 'bg-emerald-50 text-emerald-800 border-emerald-200',
|
||||
},
|
||||
on_track: {
|
||||
label: 'En ritmo',
|
||||
className: 'bg-sky-50 text-sky-800 border-sky-200',
|
||||
},
|
||||
behind: {
|
||||
label: 'Por detrás',
|
||||
className: 'bg-amber-50 text-amber-900 border-amber-200',
|
||||
},
|
||||
};
|
||||
|
||||
function euro(v: number, digits = 0) {
|
||||
return `${Number(v || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
})} €`;
|
||||
}
|
||||
|
||||
function shiftMonth(ym: string, delta: number): string {
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
const d = new Date(Date.UTC(y, m - 1 + delta, 1));
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function monthTitle(ym: string) {
|
||||
const [y, m] = ym.split('-').map(Number);
|
||||
const label = new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString('es-ES', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
|
||||
function DualLine({
|
||||
rows,
|
||||
aKey,
|
||||
bKey,
|
||||
aLabel,
|
||||
bLabel,
|
||||
format,
|
||||
}: {
|
||||
rows: { date: string; [k: string]: string | number }[];
|
||||
aKey: string;
|
||||
bKey: string;
|
||||
aLabel: string;
|
||||
bLabel: string;
|
||||
format: (v: number) => string;
|
||||
}) {
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<p className="text-xs text-gray-500 py-10 text-center">
|
||||
Aún no hay datos de este mes.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const w = 640;
|
||||
const h = 180;
|
||||
const pad = { t: 12, r: 12, b: 22, l: 8 };
|
||||
const vals = rows.flatMap((r) => [Number(r[aKey] || 0), Number(r[bKey] || 0)]);
|
||||
const max = Math.max(1, ...vals);
|
||||
const innerW = w - pad.l - pad.r;
|
||||
const innerH = h - pad.t - pad.b;
|
||||
const x = (i: number) =>
|
||||
pad.l + (rows.length === 1 ? innerW / 2 : (i / (rows.length - 1)) * innerW);
|
||||
const y = (v: number) => pad.t + innerH - (v / max) * innerH;
|
||||
const path = (key: string) =>
|
||||
rows
|
||||
.map((r, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(Number(r[key] || 0)).toFixed(1)}`)
|
||||
.join(' ');
|
||||
const area = `${path(aKey)} L ${x(rows.length - 1).toFixed(1)} ${y(0).toFixed(1)} L ${x(0).toFixed(1)} ${y(0).toFixed(1)} Z`;
|
||||
const first = rows[0]?.date;
|
||||
const last = rows[rows.length - 1]?.date;
|
||||
const fmtDay = (d?: string) =>
|
||||
d
|
||||
? new Date(`${d}T00:00:00`).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="w-full h-44" role="img">
|
||||
<path d={area} fill="rgb(249 115 22 / 0.12)" />
|
||||
<path
|
||||
d={path(aKey)}
|
||||
fill="none"
|
||||
stroke="#ea580c"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d={path(bKey)}
|
||||
fill="none"
|
||||
stroke="#94a3b8"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5 4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex items-center justify-between text-[10px] text-gray-500 tabular-nums">
|
||||
<span>{fmtDay(first)}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="h-1.5 w-4 rounded-full bg-orange-600" />
|
||||
{aLabel} {format(Number(rows[rows.length - 1]?.[aKey] || 0))}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="h-px w-4 border-t-2 border-dashed border-slate-400" />
|
||||
{bLabel} {format(Number(rows[rows.length - 1]?.[bKey] || 0))}
|
||||
</span>
|
||||
</div>
|
||||
<span>{fmtDay(last)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ tone }: { tone: Tone }) {
|
||||
const t = TONE[tone];
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${t.className}`}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
suffix,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
suffix?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
{label}
|
||||
</span>
|
||||
<div className="mt-1.5 flex items-center rounded-xl border border-gray-200 bg-white focus-within:border-brand-400 focus-within:ring-2 focus-within:ring-brand-100">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-xl bg-transparent px-3 py-2.5 text-sm font-semibold tabular-nums text-gray-900 outline-none"
|
||||
/>
|
||||
{suffix ? (
|
||||
<span className="pr-3 text-xs text-gray-400">{suffix}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ObjetivosDashboard({
|
||||
tenantLabel,
|
||||
}: {
|
||||
tenantLabel?: string;
|
||||
}) {
|
||||
const nowYm = useMemo(() => {
|
||||
const s = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Madrid',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
}).format(new Date());
|
||||
return s;
|
||||
}, []);
|
||||
const [month, setMonth] = useState(nowYm);
|
||||
const [data, setData] = useState<Payload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
spend_target: '',
|
||||
leads_target: '',
|
||||
clients_target: '',
|
||||
revenue_target: '',
|
||||
});
|
||||
|
||||
const load = useCallback(async (ym: string) => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch(`/api/targets?month=${encodeURIComponent(ym)}`);
|
||||
const json = (await res.json()) as Payload;
|
||||
if (!res.ok) {
|
||||
setErr(json.error || 'No se pudieron cargar los objetivos');
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
setData(json);
|
||||
setForm({
|
||||
spend_target: String(json.targets.spend || ''),
|
||||
leads_target: String(json.targets.leads || ''),
|
||||
clients_target: String(json.targets.clients || ''),
|
||||
revenue_target: String(json.targets.revenue || ''),
|
||||
});
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load(month);
|
||||
}, [month, load]);
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch('/api/targets', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
month,
|
||||
spend_target: Number(form.spend_target || 0),
|
||||
leads_target: Number(form.leads_target || 0),
|
||||
clients_target: Number(form.clients_target || 0),
|
||||
revenue_target: Number(form.revenue_target || 0),
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setErr(json.error || 'No se pudo guardar');
|
||||
return;
|
||||
}
|
||||
setEditing(false);
|
||||
await load(month);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const t = data?.targets;
|
||||
const a = data?.actual;
|
||||
const e = data?.expected;
|
||||
const st = data?.status;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="inline-flex items-center rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth((m) => shiftMonth(m, -1))}
|
||||
className="p-2 text-gray-500 hover:text-gray-900"
|
||||
aria-label="Mes anterior"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 min-w-[10rem] justify-center">
|
||||
<CalendarDays className="h-4 w-4 text-brand-600" />
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{monthTitle(month)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
||||
className="p-2 text-gray-500 hover:text-gray-900"
|
||||
aria-label="Mes siguiente"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing((v) => !v)}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:border-brand-300 hover:text-brand-800"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
{editing ? 'Cerrar editor' : 'Editar objetivos'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err ? (
|
||||
<p className="text-sm text-red-600 border border-red-200 bg-red-50 rounded-xl px-3 py-2">
|
||||
{err}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{editing ? (
|
||||
<form
|
||||
onSubmit={save}
|
||||
className="rounded-2xl border border-brand-200 bg-brand-50/40 p-5 space-y-4"
|
||||
>
|
||||
<SectionHead
|
||||
icon={<Pencil className="h-4 w-4" />}
|
||||
title="Fijar metas del mes"
|
||||
hint={`Se comparan con el real de ${tenantLabel || data?.tenant_label || 'este cliente'} a día de hoy. El ritmo esperado es lineal (${data?.days_elapsed || 0}/${data?.days_in_month || 30} días).`}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field
|
||||
label="Presupuesto ads"
|
||||
suffix="€"
|
||||
value={form.spend_target}
|
||||
onChange={(v) => setForm((f) => ({ ...f, spend_target: v }))}
|
||||
/>
|
||||
<Field
|
||||
label="Leads"
|
||||
value={form.leads_target}
|
||||
onChange={(v) => setForm((f) => ({ ...f, leads_target: v }))}
|
||||
/>
|
||||
<Field
|
||||
label="Clientes"
|
||||
value={form.clients_target}
|
||||
onChange={(v) => setForm((f) => ({ ...f, clients_target: v }))}
|
||||
/>
|
||||
<Field
|
||||
label="Facturación"
|
||||
suffix="€"
|
||||
value={form.revenue_target}
|
||||
onChange={(v) => setForm((f) => ({ ...f, revenue_target: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
{saving ? 'Guardando…' : 'Guardar y comparar'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500">
|
||||
0 = sin meta en esa métrica (no entra en el ritmo).
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<HeroBand
|
||||
eyebrow={
|
||||
loading
|
||||
? 'Cargando mes…'
|
||||
: `Día ${data?.days_elapsed || 0} de ${data?.days_in_month || 0} · ritmo lineal ${data?.pace_pct || 0}%`
|
||||
}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<HeroStat
|
||||
label="Presupuesto ads"
|
||||
value={euro(a?.spend || 0)}
|
||||
chip={
|
||||
t?.spend ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
Meta {euro(t.spend)} · ritmo {euro(e?.spend || 0)}{' '}
|
||||
{st?.spend ? <Chip tone={st.spend} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Sin meta de gasto</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HeroStat
|
||||
label="Leads CRM"
|
||||
value={String(a?.leads || 0)}
|
||||
chip={
|
||||
t?.leads ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
Meta {t.leads} · ritmo {Math.round(e?.leads || 0)}{' '}
|
||||
{st?.leads ? <Chip tone={st.leads} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Sin meta de leads</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HeroStat
|
||||
label="Clientes"
|
||||
value={String(a?.clients || 0)}
|
||||
chip={
|
||||
t?.clients ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
Meta {t.clients} · ritmo {Math.round(e?.clients || 0)}{' '}
|
||||
{st?.clients ? <Chip tone={st.clients} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">
|
||||
Sin meta de clientes
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HeroStat
|
||||
label="Facturación"
|
||||
value={euro(a?.revenue || 0)}
|
||||
chip={
|
||||
t?.revenue ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
Meta {euro(t.revenue)} · ritmo {euro(e?.revenue || 0)}{' '}
|
||||
{st?.revenue ? <Chip tone={st.revenue} /> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">
|
||||
Sin meta de facturación
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</HeroBand>
|
||||
|
||||
{!data?.has_targets && !loading ? (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-white px-5 py-8 text-center">
|
||||
<Target className="mx-auto h-6 w-6 text-brand-600" />
|
||||
<p className="mt-2 text-sm font-semibold text-gray-900">
|
||||
Aún no hay objetivos este mes
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 max-w-md mx-auto">
|
||||
Define presupuesto, leads, clientes y facturación. En cuanto
|
||||
guardes, verás el real contra el ritmo esperado y las curvas
|
||||
acumuladas.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Definir objetivos
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<SectionHead
|
||||
icon={<Wallet className="h-4 w-4" />}
|
||||
title="Gasto acumulado vs ritmo"
|
||||
hint="Línea continua = gastado. Discontinua = si el presupuesto se consume linealmente."
|
||||
/>
|
||||
<DualLine
|
||||
rows={data?.cumulative || []}
|
||||
aKey="spend"
|
||||
bKey="spendPace"
|
||||
aLabel="Real"
|
||||
bLabel="Ritmo"
|
||||
format={(v) => euro(v)}
|
||||
/>
|
||||
</section>
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<SectionHead
|
||||
icon={<Trophy className="h-4 w-4" />}
|
||||
title="Leads CRM acumulados vs ritmo"
|
||||
hint="Altas del CRM en el mes. Si no hay meta de leads, el ritmo queda en 0."
|
||||
/>
|
||||
<DualLine
|
||||
rows={data?.cumulative || []}
|
||||
aKey="leads"
|
||||
bKey="leadsPace"
|
||||
aLabel="Real"
|
||||
bLabel="Ritmo"
|
||||
format={(v) => String(Math.round(v))}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5">
|
||||
<SectionHead
|
||||
icon={<Wallet className="h-4 w-4" />}
|
||||
title="Gasto diario"
|
||||
hint="Barras de ad_daily. Útil para ver picos, no el ritmo."
|
||||
/>
|
||||
<SparkBars
|
||||
points={(data?.series || []).map((d) => ({
|
||||
date: d.date,
|
||||
value: d.spend,
|
||||
}))}
|
||||
color="bg-orange-500"
|
||||
formatValue={(v) => euro(v)}
|
||||
/>
|
||||
</section>
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 space-y-4">
|
||||
<SectionHead
|
||||
icon={<Target className="h-4 w-4" />}
|
||||
title="Mix de inversión"
|
||||
hint="Cómo se reparte el gasto real del mes (no es el mix del simulador)."
|
||||
/>
|
||||
{['meta', 'google', 'other'].map((k) => {
|
||||
const val = Number(
|
||||
data?.platforms?.[k as 'meta' | 'google' | 'other'] || 0
|
||||
);
|
||||
const max = Math.max(
|
||||
1,
|
||||
data?.actual.spend || 1
|
||||
);
|
||||
const label =
|
||||
k === 'meta' ? 'Meta' : k === 'google' ? 'Google' : 'Otros';
|
||||
return (
|
||||
<div key={k}>
|
||||
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
||||
<span>{label}</span>
|
||||
<span className="tabular-nums font-semibold text-gray-900">
|
||||
{euro(val)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-100">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
k === 'meta'
|
||||
? 'bg-sky-500'
|
||||
: k === 'google'
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-gray-400'
|
||||
}`}
|
||||
style={{ width: `${Math.max(2, (val / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<HelpDetails title="Cómo se calcula el avance">
|
||||
<p>
|
||||
<strong>Presupuesto</strong> = suma de <code>ad_daily.spend</code> del
|
||||
mes. <strong>Leads</strong> = altas CRM (Clientify / GoHighLevel).{' '}
|
||||
<strong>Clientes</strong> = leads que pasaron a cliente en el mes.{' '}
|
||||
<strong>Facturación</strong> = deals ganados (<code>won_at</code>) con
|
||||
importe.
|
||||
</p>
|
||||
<p>
|
||||
El ritmo esperado es lineal: meta × (día del mes / días del mes).
|
||||
«Por delante / en ritmo / por detrás» usa un umbral del 8% sobre ese
|
||||
ritmo, no sobre el 100% del mes (aún no ha terminado).
|
||||
</p>
|
||||
</HelpDetails>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
X,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import CalendarDayZoom, {
|
||||
type SocialLite,
|
||||
} from '@/components/CalendarDayZoom';
|
||||
import {
|
||||
pipelineMediaUrl,
|
||||
pipelinePublishDay,
|
||||
type PipelineCalendarRow,
|
||||
} from '@/lib/pipeline-calendar';
|
||||
|
||||
export type RrssPiece = {
|
||||
id: string;
|
||||
@@ -31,6 +39,9 @@ export type RrssPiece = {
|
||||
kind?: string;
|
||||
color?: string;
|
||||
slides?: string[];
|
||||
pipeline_id?: string;
|
||||
cover_path?: string | null;
|
||||
slide_paths?: string[];
|
||||
};
|
||||
|
||||
export type RrssMonthPlan = {
|
||||
@@ -120,6 +131,21 @@ function DualBadge({ dual }: { dual?: Dual }) {
|
||||
);
|
||||
}
|
||||
|
||||
function pieceToRow(p: RrssPiece): PipelineCalendarRow {
|
||||
return {
|
||||
id: p.pipeline_id || p.id,
|
||||
title: p.title,
|
||||
format: p.format,
|
||||
status: p.status,
|
||||
cover_path: p.cover_path || null,
|
||||
slide_paths: p.slide_paths || p.slides || [],
|
||||
caption: p.body || null,
|
||||
notes: p.meta_line || null,
|
||||
created_at: p.date,
|
||||
publish_date: p.date,
|
||||
};
|
||||
}
|
||||
|
||||
export default function RrssMonthView({
|
||||
plan,
|
||||
tenantLabel,
|
||||
@@ -134,6 +160,9 @@ export default function RrssMonthView({
|
||||
const pieces = plan.pieces || [];
|
||||
const [platform, setPlatform] = useState<'all' | string>(initialPlatform);
|
||||
const [selected, setSelected] = useState<RrssPiece | null>(null);
|
||||
const [dayZoom, setDayZoom] = useState<string | null>(null);
|
||||
const [pipeItems, setPipeItems] = useState<PipelineCalendarRow[]>([]);
|
||||
const [socialPosts, setSocialPosts] = useState<SocialLite[]>([]);
|
||||
const [dualMap, setDualMap] = useState<Record<string, Dual>>({});
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
@@ -172,6 +201,32 @@ export default function RrssMonthView({
|
||||
refreshDual();
|
||||
}, [refreshDual]);
|
||||
|
||||
useEffect(() => {
|
||||
const ym = plan.month;
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/creative-pipeline?month=${encodeURIComponent(ym)}`
|
||||
);
|
||||
const j = await r.json();
|
||||
setPipeItems((j.items || []) as PipelineCalendarRow[]);
|
||||
} catch {
|
||||
setPipeItems([]);
|
||||
}
|
||||
})();
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/social?since=${encodeURIComponent(ym + '-01')}&until=${encodeURIComponent(ym + '-31')}`
|
||||
);
|
||||
const j = await r.json();
|
||||
setSocialPosts((j.posts || []) as SocialLite[]);
|
||||
} catch {
|
||||
setSocialPosts([]);
|
||||
}
|
||||
})();
|
||||
}, [plan.month]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected || !canCollaborate) {
|
||||
setComments([]);
|
||||
@@ -253,6 +308,31 @@ export default function RrssMonthView({
|
||||
: dualMap[selected.id].client
|
||||
: false;
|
||||
|
||||
function rowsForDay(key: string): PipelineCalendarRow[] {
|
||||
const fromPipe = pipeItems.filter((it) => pipelinePublishDay(it) === key);
|
||||
const fromPlan = (byDate.get(key) || [])
|
||||
.map((p) => pieceToRow(p))
|
||||
.filter((r) => r.cover_path || r.caption || r.notes);
|
||||
const seen = new Set<string>();
|
||||
const out: PipelineCalendarRow[] = [];
|
||||
for (const r of [...fromPipe, ...fromPlan]) {
|
||||
const k = r.id;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function openDay(key: string, piece?: RrssPiece) {
|
||||
const rows = rowsForDay(key);
|
||||
if (rows.length) {
|
||||
setDayZoom(key);
|
||||
return;
|
||||
}
|
||||
if (piece) setSelected(piece);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hub crumbs + network filters */}
|
||||
@@ -457,8 +537,8 @@ export default function RrssMonthView({
|
||||
El mes de un vistazo
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Cada tarjeta de color es una publicación; clic para ver copy, aprobar y
|
||||
comentar.
|
||||
Clic en el día publicado para ver la creatividad real (preview +
|
||||
métricas). El pipeline es solo para revisión.
|
||||
</p>
|
||||
<div className="grid grid-cols-7 gap-1.5 mb-1.5">
|
||||
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d) => (
|
||||
@@ -481,33 +561,51 @@ export default function RrssMonthView({
|
||||
const day = i + 1;
|
||||
const key = `${plan.month}-${String(day).padStart(2, '0')}`;
|
||||
const dayPieces = byDate.get(key) || [];
|
||||
const pipeDay = pipeItems.filter(
|
||||
(it) => pipelinePublishDay(it) === key
|
||||
);
|
||||
const cover = pipeDay[0]
|
||||
? pipelineMediaUrl(pipeDay[0].cover_path)
|
||||
: dayPieces.find((p) => p.cover_path)
|
||||
? pipelineMediaUrl(
|
||||
dayPieces.find((p) => p.cover_path)!.cover_path!
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={key}
|
||||
className="min-h-[88px] rounded-lg border border-gray-200 bg-white p-1.5"
|
||||
type="button"
|
||||
onClick={() => openDay(key, dayPieces[0])}
|
||||
className="min-h-[88px] rounded-lg border border-gray-200 bg-white p-1.5 text-left hover:border-brand-400 hover:shadow-sm overflow-hidden"
|
||||
>
|
||||
<p className="text-[11px] font-bold text-gray-600 mb-1">{day}</p>
|
||||
{cover ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
className="mb-1 h-10 w-full rounded object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
{dayPieces.slice(0, 3).map((p) => (
|
||||
<button
|
||||
{dayPieces.slice(0, cover ? 1 : 3).map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(p)}
|
||||
className="w-full text-left rounded-md px-1.5 py-1 text-[9px] font-semibold leading-tight text-gray-900"
|
||||
className="block w-full rounded-md px-1.5 py-1 text-[9px] font-semibold leading-tight text-gray-900"
|
||||
style={{ background: colorFor(p) }}
|
||||
title={p.title}
|
||||
>
|
||||
{p.platform === 'linkedin' ? 'LI' : 'IG'} ·{' '}
|
||||
{p.title.replace(/^[^·]+·\s*/, '').slice(0, 42)}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{dayPieces.length > 3 && (
|
||||
{dayPieces.length > (cover ? 1 : 3) && (
|
||||
<p className="text-[9px] text-gray-500">
|
||||
+{dayPieces.length - 3}
|
||||
+{dayPieces.length - (cover ? 1 : 3)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -522,7 +620,7 @@ export default function RrssMonthView({
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(p)}
|
||||
onClick={() => openDay(p.date.slice(0, 10), p)}
|
||||
className="w-full text-left rounded-xl border border-gray-200 bg-white p-4 hover:border-gray-200"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
@@ -573,6 +671,15 @@ export default function RrssMonthView({
|
||||
))}
|
||||
</section>
|
||||
|
||||
{dayZoom ? (
|
||||
<CalendarDayZoom
|
||||
day={dayZoom}
|
||||
items={rowsForDay(dayZoom)}
|
||||
socialPosts={socialPosts}
|
||||
onClose={() => setDayZoom(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selected && (
|
||||
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="bg-white border border-gray-200 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
|
||||
183
src/lib/pipeline-calendar.ts
Normal file
183
src/lib/pipeline-calendar.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/** Link creative_pipeline rows to a calendar day. */
|
||||
|
||||
export type PipelineCalendarRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
format?: string | null;
|
||||
month?: string | null;
|
||||
status?: string | null;
|
||||
cover_path?: string | null;
|
||||
slide_paths?: string[] | unknown;
|
||||
caption?: string | null;
|
||||
notes?: string | null;
|
||||
created_at?: string | null;
|
||||
publish_date?: string | null;
|
||||
external_ref?: string | null;
|
||||
};
|
||||
|
||||
export function instagramUrlFromNotes(notes?: string | null): string | null {
|
||||
if (!notes) return null;
|
||||
const m = notes.match(/https?:\/\/(?:www\.)?instagram\.com\S+/i);
|
||||
return m ? m[0].replace(/[),.;]+$/, '') : null;
|
||||
}
|
||||
|
||||
export function clientNoteFromNotes(notes?: string | null): string | null {
|
||||
if (!notes) return null;
|
||||
const m = notes.match(/CLIENT_NOTE:\s*([^\n]+)/i);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
export function slidesOf(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === 'string');
|
||||
}
|
||||
|
||||
/** Day a published pipeline piece belongs on the calendar. */
|
||||
export function pipelinePublishDay(it: PipelineCalendarRow): string | null {
|
||||
if (it.publish_date && /^\d{4}-\d{2}-\d{2}/.test(it.publish_date)) {
|
||||
return it.publish_date.slice(0, 10);
|
||||
}
|
||||
const st = (it.status || '').toLowerCase();
|
||||
const ref = (it.external_ref || '').toLowerCase();
|
||||
if (st === 'publicado' || ref.startsWith('ig-')) {
|
||||
const d = (it.created_at || '').slice(0, 10);
|
||||
return d || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pipelineMediaUrl(path: string | null | undefined): string | null {
|
||||
if (!path) return null;
|
||||
return `/api/creative-pipeline/media?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
export function normPermalink(u?: string | null): string {
|
||||
return (u || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
.split('?')[0]
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const MONTH_LABELS = [
|
||||
'Enero',
|
||||
'Febrero',
|
||||
'Marzo',
|
||||
'Abril',
|
||||
'Mayo',
|
||||
'Junio',
|
||||
'Julio',
|
||||
'Agosto',
|
||||
'Septiembre',
|
||||
'Octubre',
|
||||
'Noviembre',
|
||||
'Diciembre',
|
||||
];
|
||||
|
||||
export type CalendarPieceLike = {
|
||||
id: string;
|
||||
date: string;
|
||||
title: string;
|
||||
platform?: string;
|
||||
format?: string;
|
||||
body?: string;
|
||||
status?: string;
|
||||
meta_line?: string;
|
||||
pipeline_id?: string;
|
||||
cover_path?: string | null;
|
||||
slide_paths?: string[];
|
||||
};
|
||||
|
||||
export type CalendarPlanLike = {
|
||||
kind?: string;
|
||||
month: string;
|
||||
label: string;
|
||||
hero: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
lede: string;
|
||||
chips?: string[];
|
||||
};
|
||||
pieces: CalendarPieceLike[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function monthLabel(ym: string) {
|
||||
const m = Number(ym.slice(5, 7)) - 1;
|
||||
const y = ym.slice(0, 4);
|
||||
return `${MONTH_LABELS[m] || ym} ${y}`;
|
||||
}
|
||||
|
||||
/** Overlay published pipeline creatives onto RRSS month plans. */
|
||||
export function mergePipelineIntoPlans<T extends CalendarPlanLike>(
|
||||
plans: T[],
|
||||
items: PipelineCalendarRow[]
|
||||
): T[] {
|
||||
const out = plans.map((p) => ({
|
||||
...p,
|
||||
pieces: [...(p.pieces || [])],
|
||||
}));
|
||||
const byMonth = new Map(out.map((p) => [p.month, p]));
|
||||
|
||||
for (const it of items) {
|
||||
const day = pipelinePublishDay(it);
|
||||
if (!day) continue;
|
||||
const ym = day.slice(0, 7);
|
||||
let plan = byMonth.get(ym);
|
||||
if (!plan) {
|
||||
const label = monthLabel(ym);
|
||||
plan = {
|
||||
kind: 'rrss_month',
|
||||
month: ym,
|
||||
label,
|
||||
hero: {
|
||||
eyebrow: '4VENTURES · PUBLICADO',
|
||||
title: label,
|
||||
lede: 'Piezas reales del pipeline en el día en que se publicaron. Clic en el día para zoom (preview + métricas).',
|
||||
chips: ['Pipeline', 'Instagram'],
|
||||
},
|
||||
pieces: [],
|
||||
} as T;
|
||||
out.push(plan);
|
||||
byMonth.set(ym, plan);
|
||||
}
|
||||
const ig = instagramUrlFromNotes(it.notes);
|
||||
const igKey = normPermalink(ig);
|
||||
const existing = plan.pieces.find((p) => {
|
||||
if ((p.date || '').slice(0, 10) !== day) return false;
|
||||
if (igKey && normPermalink(p.meta_line) === igKey) return true;
|
||||
const a = (p.title || '').toLowerCase().slice(0, 28);
|
||||
const b = (it.title || '').toLowerCase().slice(0, 28);
|
||||
return a && b && (a.includes(b) || b.includes(a));
|
||||
});
|
||||
const slides = slidesOf(it.slide_paths);
|
||||
if (existing) {
|
||||
existing.pipeline_id = it.id;
|
||||
existing.cover_path = it.cover_path || existing.cover_path;
|
||||
existing.slide_paths = slides.length ? slides : existing.slide_paths;
|
||||
existing.body = existing.body || it.caption || undefined;
|
||||
existing.meta_line = existing.meta_line || (ig ? `Instagram: ${ig}` : undefined);
|
||||
existing.status = existing.status || 'published';
|
||||
} else {
|
||||
plan.pieces.push({
|
||||
id: `pipe-${it.id}`,
|
||||
pipeline_id: it.id,
|
||||
date: day,
|
||||
platform: 'instagram',
|
||||
format: it.format || 'post',
|
||||
title: it.title,
|
||||
body: it.caption || undefined,
|
||||
status: 'published',
|
||||
meta_line: ig ? `Instagram: ${ig}` : undefined,
|
||||
cover_path: it.cover_path,
|
||||
slide_paths: slides,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out.sort((a, b) => a.month.localeCompare(b.month));
|
||||
for (const p of out) {
|
||||
p.pieces.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ function n(v: unknown): number {
|
||||
function matchChannel(platform: string | undefined, ch: SimChannel): boolean {
|
||||
if (ch === 'blended') return true;
|
||||
const p = normalizePlatform(platform);
|
||||
if (ch === 'meta') return p === 'meta' || p === 'other';
|
||||
if (ch === 'meta') return p === 'meta';
|
||||
if (ch === 'google') return p === 'google';
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user