diff --git a/sql/creatives_delete_policy.sql b/sql/creatives_delete_policy.sql new file mode 100644 index 0000000..3439fc5 --- /dev/null +++ b/sql/creatives_delete_policy.sql @@ -0,0 +1,11 @@ +-- Allow tenant-scoped delete on creatives (clients); agency uses service role. +alter table public.creatives enable row level security; + +drop policy if exists creatives_tenant_delete on public.creatives; +create policy creatives_tenant_delete on public.creatives + for delete using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); diff --git a/src/app/api/creatives/route.ts b/src/app/api/creatives/route.ts index 5f70376..04840c7 100644 --- a/src/app/api/creatives/route.ts +++ b/src/app/api/creatives/route.ts @@ -140,3 +140,94 @@ export async function POST(req: NextRequest) { return NextResponse.json({ creative: data }, { status: 201 }); } + +const ALLOWED = new Set(['draft', 'in_review', 'approved', 'rejected']); + +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 body = await req.json().catch(() => ({})); + const id = typeof body.id === 'string' ? body.id.trim() : ''; + const status = typeof body.status === 'string' ? body.status.trim() : ''; + if (!id || !ALLOWED.has(status)) { + return NextResponse.json({ error: 'id y status válidos requeridos' }, { status: 400 }); + } + + const agency = isAgencyRole(userRole(user)); + const headers = serviceHeaders(); + + if (agency && headers) { + const r = await fetch( + `${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ + status, + updated_at: new Date().toISOString(), + }), + } + ); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + const data = await r.json(); + return NextResponse.json({ creative: data[0] || data }); + } + + const { data, error } = await supabase + .from('creatives') + .update({ status, updated_at: new Date().toISOString() }) + .eq('id', id) + .select('id,title,status') + .single(); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + return NextResponse.json({ creative: data }); +} + +export async function DELETE(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const id = req.nextUrl.searchParams.get('id')?.trim() || ''; + if (!id) { + return NextResponse.json({ error: 'id requerido' }, { status: 400 }); + } + + const agency = isAgencyRole(userRole(user)); + const headers = serviceHeaders(); + + if (agency && headers) { + const r = await fetch( + `${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`, + { method: 'DELETE', headers } + ); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + return NextResponse.json({ ok: true }); + } + + const { error } = await supabase.from('creatives').delete().eq('id', id); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/dashboard/activos/page.tsx b/src/app/dashboard/activos/page.tsx index 15ab22b..3549fe9 100644 --- a/src/app/dashboard/activos/page.tsx +++ b/src/app/dashboard/activos/page.tsx @@ -1,93 +1,116 @@ import React from 'react'; import { FileText, CheckCircle2 } from 'lucide-react'; import { createClient } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; +import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; +import { isAgencyRole, userRole } from '@/lib/agency'; -export const revalidate = 0; +export const dynamic = 'force-dynamic'; + +type Doc = { + id: string; + type?: string; + title?: string; + content?: string; + version?: number; + created_at?: string; + status?: string; +}; export default async function ActivosPage() { const supabase = await createClient(); const { data: { user }, } = await supabase.auth.getUser(); + if (!user) redirect('/login'); - let documents: Array<{ - id: string; - type?: string; - title?: string; - content?: string; - version?: number; - created_at?: string; - status?: string; - }> = []; + const ctx = await resolveActiveTenant(user); + const agency = isAgencyRole(userRole(user)); + let documents: Doc[] = []; - if (user) { - // Relies on RLS: only documents for the caller's tenant + if (ctx.portfolioWide) { + documents = []; + } else if (agency && ctx.viaView && ctx.tenantId) { + const tid = encodeURIComponent(ctx.tenantId); + const r = await restGet( + `/rest/v1/documents?tenant_id=eq.${tid}&status=eq.approved&select=id,type,title,content,version,created_at,status&order=created_at.desc` + ); + documents = r.data || []; + } else { const { data, error } = await supabase .from('documents') .select('id,type,title,content,version,created_at,status') .eq('status', 'approved') .order('created_at', { ascending: false }); - if (error) { - console.error('Error fetching documents:', error.message); - } else { - documents = data ?? []; - } + if (error) console.error('documents', error.message); + else documents = data ?? []; } return (

- Biblioteca de Activos & Entregables + Biblioteca de activos

-

- Documentos, copys, calendarios y briefs producidos por los agentes de IA - y aprobados por el equipo en Slack. Filtrado por tenant (RLS). +

+ Entregables aprobados del cliente ({ctx.label}): briefs, audits, + copys, calendarios exportados. Fuente:{' '} + documents con status=approved (agentes → + Slack → OK). Distinto del pipeline de creatividades ads.

-
- {documents.length === 0 ? ( -
- -

- No hay activos aprobados todavía. -

-

- Los activos generados por los agentes aparecerán aquí tras ser - validados en Slack. -

-
- ) : ( - documents.map((doc) => ( -
-
-
- - {doc.type} - - - Aprobado + {ctx.portfolioWide ? ( +

+ Selecciona un cliente en Contexto para ver su biblioteca. +

+ ) : ( +
+ {documents.length === 0 ? ( +
+ +

+ No hay activos aprobados todavía. +

+

+ Aparecerán aquí tras validación en Slack / ops. +

+
+ ) : ( + documents.map((doc) => ( +
+
+
+ + {doc.type} + + + Aprobado + +
+

+ {doc.title} +

+

+ {doc.content} +

+
+
+ Versión v{doc.version} + + {doc.created_at + ? new Date(doc.created_at).toLocaleDateString('es-ES') + : '—'}
-

{doc.title}

-

{doc.content}

-
- Versión v{doc.version} - - {doc.created_at - ? new Date(doc.created_at).toLocaleDateString('es-ES') - : '—'} - -
-
- )) - )} -
+ )) + )} +
+ )}
); } diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index e76fb3a..7b2e488 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -2,65 +2,81 @@ import React from 'react'; import { createClient } from '@/lib/supabase/server'; import CalendarClient from './CalendarClient'; import { CalendarDays } from 'lucide-react'; +import { redirect } from 'next/navigation'; +import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; +import { isAgencyRole, userRole } from '@/lib/agency'; -export const revalidate = 0; +export const dynamic = 'force-dynamic'; export default async function CalendarioPage() { const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login'); - const tenantId = user?.app_metadata?.tenant_id || user?.user_metadata?.tenant_id || null; - const companyName = user?.user_metadata?.company || '4Ventures'; + const ctx = await resolveActiveTenant(user); + const agency = isAgencyRole(userRole(user)); - let calendarDocs: any[] = []; - if (tenantId) { - try { - const { data, error } = await supabase - .from('documents') - .select('*') - .eq('type', 'calendar') - .eq('tenant_id', tenantId) - .order('created_at', { ascending: false }); + let calendarDocs: Array<{ title?: string; content?: unknown }> = []; - if (!error && data) { - calendarDocs = data; - } - } catch (e) { - console.error('Error fetching calendar documents:', e); - } + if (ctx.portfolioWide) { + // no-op — prompt to select client + } else if (agency && ctx.viaView && ctx.tenantId) { + const tid = encodeURIComponent(ctx.tenantId); + const r = await restGet( + `/rest/v1/documents?tenant_id=eq.${tid}&type=eq.calendar&select=*&order=created_at.desc` + ); + calendarDocs = r.data || []; + } else if (ctx.tenantId) { + const { data } = await supabase + .from('documents') + .select('*') + .eq('type', 'calendar') + .eq('tenant_id', ctx.tenantId) + .order('created_at', { ascending: false }); + calendarDocs = data || []; } - // Parse posts from calendar document - let posts: any[] = []; + let posts: unknown[] = []; if (calendarDocs.length > 0) { try { const raw = calendarDocs[0].content; - if (typeof raw === 'string') { - posts = JSON.parse(raw); - } else if (Array.isArray(raw)) { - posts = raw; - } - } catch (err) { - console.error('Error parsing calendar JSON:', err); + if (typeof raw === 'string') posts = JSON.parse(raw); + else if (Array.isArray(raw)) posts = raw; + } catch { + posts = []; } } return (
- {/* Header */}
- Planificación de Redes Sociales · Agente #07 + RRSS · plan editorial

- Calendario Editorial Mensual — {companyName} + Calendario editorial — {ctx.label}

-

- Estrategia omnicanal de publicaciones para Instagram, Facebook y LinkedIn producida por los agentes de IA de 4Ventures. +

+ Plan de publicaciones (IG / FB / LinkedIn) guardado en{' '} + documents con type=calendar, generado por + agentes / ops y ligado al tenant. No es el calendario de Google Ads.

- + {ctx.portfolioWide ? ( +

+ Selecciona un cliente en Contexto para ver su calendario editorial. +

+ ) : ( + + )}
); } diff --git a/src/app/dashboard/creatives/page.tsx b/src/app/dashboard/creatives/page.tsx index d3f07b0..005ef19 100644 --- a/src/app/dashboard/creatives/page.tsx +++ b/src/app/dashboard/creatives/page.tsx @@ -1,10 +1,11 @@ /** - * Creatividades multi-tenant — lista vía /api/creatives (RLS). + * Pipeline de creatividades publicitarias del cliente en contexto. + * No es la librería de assets (documentos) ni el sync live de Meta — es la cola de aprobación. */ 'use client'; import React, { FormEvent, useEffect, useState } from 'react'; -import { Image as ImageIcon, Plus } from 'lucide-react'; +import { Image as ImageIcon, Plus, Trash2 } from 'lucide-react'; type Creative = { id: string; @@ -13,12 +14,20 @@ type Creative = { created_at?: string; }; +const STATUSES = [ + { id: 'draft', label: 'Borrador' }, + { id: 'in_review', label: 'En review' }, + { id: 'approved', label: 'Aprobado' }, + { id: 'rejected', label: 'Rechazado' }, +] as const; + export default function CreativesPage() { const [creatives, setCreatives] = useState([]); const [title, setTitle] = useState(''); const [warning, setWarning] = useState(null); const [err, setErr] = useState(null); const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(null); async function load() { setLoading(true); @@ -50,15 +59,43 @@ export default function CreativesPage() { await load(); } + async function setStatus(id: string, status: string) { + setBusyId(id); + setErr(null); + const res = await fetch('/api/creatives', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, status }), + }); + const data = await res.json(); + if (!res.ok) setErr(data.error ?? 'No se pudo actualizar'); + await load(); + setBusyId(null); + } + + async function remove(id: string) { + if (!confirm('¿Borrar esta creatividad?')) return; + setBusyId(id); + setErr(null); + const res = await fetch(`/api/creatives?id=${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) setErr(data.error ?? 'No se pudo borrar'); + await load(); + setBusyId(null); + } + return (

- Creatividades + Pipeline de creatividades

-

- Borradores del cliente en contexto (selector arriba). Con superadmin - activo, ves/creas en ese tenant. +

+ Cola de aprobación de piezas ads del cliente en contexto (selector arriba): + borrador → review → aprobado. No sustituye Meta Ads Manager; el sync + automático de anuncios live llegará con la integración Meta.

@@ -71,12 +108,14 @@ export default function CreativesPage() {
- + setTitle(e.target.value)} - placeholder="Video Meta · testimonial" + placeholder="Video Meta · testimonial ES" required />
@@ -84,7 +123,7 @@ export default function CreativesPage() { type="submit" className="inline-flex items-center gap-2 rounded-lg bg-brand-500/90 hover:bg-brand-500 px-4 py-2 text-sm font-medium text-white" > - Crear borrador + Añadir @@ -93,22 +132,59 @@ export default function CreativesPage() { ) : creatives.length === 0 ? (
-

Sin creatividades aún

+

Pipeline vacío

+

+ Úsalo para piezas en producción creativa por cliente. La biblioteca + de activos es para documentos/briefs aprobados; esto es el flow de + ads. +

) : ( -
    - {creatives.map((c) => ( -
  • - {c.title} - - {c.status} - -
  • - ))} -
+
+ {STATUSES.map((col) => { + const items = creatives.filter((c) => c.status === col.id); + return ( +
+

+ {col.label} ({items.length}) +

+
    + {items.map((c) => ( +
  • +

    {c.title}

    +
    + + +
    +
  • + ))} +
+
+ ); + })} +
)}
); diff --git a/src/app/dashboard/estrategia/page.tsx b/src/app/dashboard/estrategia/page.tsx index 42f6cdc..9dc34fa 100644 --- a/src/app/dashboard/estrategia/page.tsx +++ b/src/app/dashboard/estrategia/page.tsx @@ -3,8 +3,20 @@ import { Target } from 'lucide-react'; import BudgetSimulator from '@/components/BudgetSimulator'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; +import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; +import { isAgencyRole, userRole } from '@/lib/agency'; -export const revalidate = 0; +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; +}; export default async function EstrategiaPage() { const supabase = await createClient(); @@ -13,21 +25,12 @@ export default async function EstrategiaPage() { } = await supabase.auth.getUser(); if (!user) redirect('/login'); - const tenantId = - user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null; - + const ctx = await resolveActiveTenant(user); + const agency = isAgencyRole(userRole(user)); const now = new Date(); const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; - let targetData: { - revenue_target: number; - revenue_actual: number; - revenue_achievement_pct: number; - leads_target: number; - leads_actual: number; - clients_target: number; - clients_actual: number; - } = { + let targetData: TargetRow = { revenue_target: 0, revenue_actual: 0, revenue_achievement_pct: 0, @@ -36,58 +39,99 @@ export default async function EstrategiaPage() { clients_target: 0, clients_actual: 0, }; + let sourceNote = + 'Origen: tabla targets + RPC targets_vs_actual (metas cargadas en Postgres por tenant/mes).'; - if (tenantId) { - const { data, error } = await supabase.rpc('targets_vs_actual', { - p_month: currentMonth, - p_tenant_id: tenantId, - }); - if (error) { - // Some deployments only take p_month (tenant from JWT) - 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 { - console.error('targets_vs_actual', error.message); + const tenantForRpc = ctx.portfolioWide ? null : ctx.tenantId; + + if (tenantForRpc) { + if (agency && ctx.viaView) { + // Prefer raw targets row via service role; actuals may still need RPC with user JWT + const tid = encodeURIComponent(tenantForRpc); + const t = await restGet< + { revenue_target: number; clients_target: number; leads_target: number }[] + >( + `/rest/v1/targets?tenant_id=eq.${tid}&month=eq.${currentMonth}&select=revenue_target,clients_target,leads_target` + ); + if (t.data?.[0]) { + targetData = { + ...targetData, + revenue_target: t.data[0].revenue_target, + clients_target: t.data[0].clients_target, + leads_target: t.data[0].leads_target, + }; + } + 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] }; + } + } 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] }; } - } else if (Array.isArray(data) && data[0]) { - targetData = { ...targetData, ...data[0] }; } + } else if (ctx.portfolioWide) { + sourceNote = + 'Vista global: selecciona un cliente en el selector de contexto para ver sus objetivos del mes.'; } - const revenuePct = Math.min(100, Math.round(targetData.revenue_achievement_pct || 0)); + const revenuePct = Math.min( + 100, + Math.round(Number(targetData.revenue_achievement_pct) || 0) + ); return (

- Estrategia de Crecimiento & Objetivos + Objetivos del mes

-

- Metas del mes vs real · scoped al tenant del usuario. -

+

{ctx.label}

+

{sourceNote}

-
-
- - Facturación {revenuePct}% -
-
-
-
-

- {Number(targetData.revenue_actual).toLocaleString('es-ES')} € /{' '} - {Number(targetData.revenue_target).toLocaleString('es-ES')} € · leads{' '} - {targetData.leads_actual}/{targetData.leads_target} · clientes{' '} - {targetData.clients_actual}/{targetData.clients_target} + {ctx.portfolioWide ? ( +

+ Elige un cliente arriba (Contexto) para ver facturación / leads / + clientes objetivo vs real de ese tenant.

-
+ ) : ( +
+
+ + + Facturación {revenuePct}% + +
+
+
+
+

+ {Number(targetData.revenue_actual || 0).toLocaleString('es-ES')} € /{' '} + {Number(targetData.revenue_target || 0).toLocaleString('es-ES')} € · + leads {targetData.leads_actual || 0}/{targetData.leads_target || 0} · + clientes {targetData.clients_actual || 0}/ + {targetData.clients_target || 0} +

+
+ )}
diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index 175da7a..accfb11 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -1,109 +1,85 @@ import React from 'react'; -import { - TrendingUp, - Users, - DollarSign, - Target, -} from 'lucide-react'; import { createClient } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; +import MetricsDashboard from '@/components/MetricsDashboard'; +import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; +import { + AdDailyRow, + KpiWeeklyRow, + buildComparisons, + sumDaily, + sumWeekly, +} from '@/lib/metrics'; +import { isAgencyRole, userRole } from '@/lib/agency'; -export const revalidate = 0; - -type KpiRow = { - total_spend?: number; - total_leads?: number; - cohort_revenue_won?: number; - cohort_deals_won?: number; - cash_cac?: number; - cohort_cac?: number; - channel?: string; - week?: string; -}; +export const dynamic = 'force-dynamic'; export default async function KpisPage() { const supabase = await createClient(); const { data: { user }, } = await supabase.auth.getUser(); + if (!user) redirect('/login'); - let kpis: KpiRow[] = []; - if (user) { - // RLS must scope by tenant; never use service role here - const { data, error } = await supabase.from('kpi_weekly').select('*'); - if (error) console.error('kpi_weekly', error.message); - else kpis = (data as KpiRow[]) ?? []; + const ctx = await resolveActiveTenant(user); + const agency = isAgencyRole(userRole(user)); + + let weekly: KpiWeeklyRow[] = []; + let daily: AdDailyRow[] = []; + + if (agency && (ctx.portfolioWide || ctx.viaView)) { + // Service role for portfolio / zoom (bypass single-tenant RLS) + if (ctx.portfolioWide) { + const w = await restGet( + '/rest/v1/kpi_weekly?select=*&order=week.desc' + ); + const d = await restGet( + '/rest/v1/ad_daily?select=*&order=date.desc&limit=500' + ); + weekly = w.data || []; + daily = d.data || []; + } else if (ctx.tenantId) { + const tid = encodeURIComponent(ctx.tenantId); + const w = await restGet( + `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc` + ); + const d = await restGet( + `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=500` + ); + weekly = w.data || []; + daily = d.data || []; + } + } else { + const { data: w } = await supabase + .from('kpi_weekly') + .select('*') + .order('week', { ascending: false }); + const { data: d } = await supabase + .from('ad_daily') + .select('*') + .order('date', { ascending: false }) + .limit(500); + weekly = (w as KpiWeeklyRow[]) || []; + daily = (d as AdDailyRow[]) || []; } - const totalSpend = kpis.reduce((acc, row) => acc + Number(row.total_spend || 0), 0); - const totalLeads = kpis.reduce((acc, row) => acc + Number(row.total_leads || 0), 0); - const totalRevenue = kpis.reduce( - (acc, row) => acc + Number(row.cohort_revenue_won || 0), - 0 - ); - const dealsWon = kpis.reduce( - (acc, row) => acc + Number(row.cohort_deals_won || 0), - 0 - ); - const avgCac = dealsWon > 0 ? (totalSpend / dealsWon).toFixed(2) : '0.00'; - const overallRoas = totalSpend > 0 ? (totalRevenue / totalSpend).toFixed(2) : '0.00'; + const comparisons = buildComparisons(weekly); + const month = + comparisons.currentMonth.spend > 0 || comparisons.currentMonth.leads > 0 + ? comparisons.currentMonth + : sumWeekly(weekly); // fallback: all available weeks if current month empty + const dailyTotals = sumDaily(daily); + const sparse = weekly.length < 4; return ( -
-
-

- Rendimiento & Métricas de Adquisición -

-

- Modelo: Cohorte vs caja · datos - filtrados por tenant (RLS). -

-
- -
-
-
- - Inversión en Ads - - -
-

- {totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} € -

-
-
-
- Leads - -
-

{totalLeads}

-
-
-
- - CAC Cohorte - - -
-

{avgCac} €

-
-
-
- - ROAS Cohorte - - -
-

{overallRoas}x

-
-
- - {kpis.length === 0 && ( -

- Sin filas en kpi_weekly para este tenant. Revisar pipeline - n8n/Windsor y políticas RLS. -

- )} -
+ ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 947cd14..7cbc37b 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -53,7 +53,7 @@ export default async function DashboardLayout({ className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-200 hover:text-white hover:bg-gray-800/60 transition-all" > - Métricas & CAC / ROAS + Métricas - Creatividades + Pipeline creatividades string; + icon: React.ReactNode; + invert?: boolean; // lower is better +}; + +function DeltaTriple({ + delta, + invert, +}: { + delta: Delta; + invert?: boolean; +}) { + const cells: { label: string; v: number | null }[] = [ + { label: 'WoW', v: delta.wow }, + { label: 'MoM', v: delta.mom }, + { label: 'YoY', v: delta.yoy }, + ]; + return ( +
+ {cells.map((c) => { + const good = + c.v === null + ? null + : invert + ? c.v < 0 + : c.v > 0; + const color = + c.v === null + ? 'text-gray-500' + : good + ? 'text-emerald-400' + : c.v === 0 + ? 'text-gray-400' + : 'text-red-400'; + const Icon = + c.v === null || c.v === 0 + ? Minus + : good + ? ArrowUpRight + : ArrowDownRight; + return ( +
+

+ {c.label} +

+

+ + {formatPct(c.v)} +

+
+ ); + })} +
+ ); +} + +export default function MetricsDashboard({ + label, + portfolioWide, + month, + week, + deltas, + daily, + sparse, +}: { + label: string; + portfolioWide: boolean; + month: MetricTotals; + week: MetricTotals; + deltas: Record; + daily: { + spend: number; + leads: number; + impressions: number; + clicks: number; + }; + sparse: boolean; +}) { + const cards: Card[] = [ + { + key: 'spend', + label: 'Inversión ads', + hint: 'Mes en curso (kpi_weekly)', + format: formatEur, + icon: , + }, + { + key: 'leads', + label: 'Leads', + hint: 'Cohorte · mes', + format: formatNum, + icon: , + }, + { + key: 'cpl', + label: 'CPL', + hint: 'Coste por lead · mes', + format: (v) => `${formatNum(v)} €`, + icon: , + invert: true, + }, + { + key: 'cac', + label: 'CAC cohorte', + hint: 'Spend / clientes ganados', + format: (v) => `${formatNum(v)} €`, + icon: , + invert: true, + }, + { + key: 'revenue', + label: 'Revenue cohorte', + hint: 'Deals won atribuidos', + format: formatEur, + icon: , + }, + { + key: 'roas', + label: 'ROAS cohorte', + hint: 'Revenue / spend', + format: (v) => `${formatNum(v)}x`, + icon: , + }, + ]; + + return ( +
+
+
+

+ Dashboard de métricas +

+

+ {label} + {portfolioWide + ? ' · agrega todos los tenants (vista agencia)' + : ' · zoom al cliente del selector'} + . Comparativas WoW / MoM / YoY sobre kpi_weekly. +

+
+
+ Semana actual spend {formatEur(week.spend)} · leads {formatNum(week.leads)} +
+
+ + {sparse && ( +

+ Histórico limitado en warehouse: si WoW/MoM/YoY salen «—», falta + pipeline Windsor/n8n o semanas previas / año anterior. +

+ )} + +
+ {cards.map((c) => ( +
+
+ + {c.label} + + {c.icon} +
+

+ {c.format(month[c.key])} +

+

{c.hint}

+ +
+ ))} +
+ +
+
+
+ Ads · 30d (ad_daily) + +
+

+ {formatEur(daily.spend)} +

+

+ {formatNum(daily.leads)} leads plataforma ·{' '} + {formatNum(daily.impressions)} impr. · {formatNum(daily.clicks)}{' '} + clicks +

+
+
+

+ Cómo leer las deltas +

+
    +
  • + WoW: semana actual vs + anterior +
  • +
  • + MoM: mes actual vs mes + previo +
  • +
  • + YoY: mismo mes vs hace + un año +
  • +
  • + En CPL/CAC, bajar es bueno (flecha verde si el % es negativo) +
  • +
+
+
+
+ ); +} diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts new file mode 100644 index 0000000..320be26 --- /dev/null +++ b/src/lib/metrics.ts @@ -0,0 +1,189 @@ +/** KPI aggregation + WoW / MoM / YoY helpers. */ + +export type KpiWeeklyRow = { + tenant_id?: string; + week?: string; + channel?: string; + total_leads?: number; + cohort_deals_won?: number; + cohort_revenue_won?: number; + total_spend?: number; + cac_cohort?: number; + roas_cohort?: number; +}; + +export type AdDailyRow = { + date?: string; + spend?: number; + platform_leads?: number; + impressions?: number; + clicks?: number; +}; + +export type MetricTotals = { + spend: number; + leads: number; + deals: number; + revenue: number; + cac: number; + roas: number; + cpl: number; +}; + +export type Delta = { + wow: number | null; + mom: number | null; + yoy: number | null; +}; + +function n(v: unknown): number { + const x = Number(v); + return Number.isFinite(x) ? x : 0; +} + +export function sumWeekly(rows: KpiWeeklyRow[]): MetricTotals { + const spend = rows.reduce((a, r) => a + n(r.total_spend), 0); + const leads = rows.reduce((a, r) => a + n(r.total_leads), 0); + const deals = rows.reduce((a, r) => a + n(r.cohort_deals_won), 0); + const revenue = rows.reduce((a, r) => a + n(r.cohort_revenue_won), 0); + return { + spend, + leads, + deals, + revenue, + cac: deals > 0 ? spend / deals : 0, + roas: spend > 0 ? revenue / spend : 0, + cpl: leads > 0 ? spend / leads : 0, + }; +} + +export function sumDaily(rows: AdDailyRow[]): { + spend: number; + leads: number; + impressions: number; + clicks: number; +} { + return { + spend: rows.reduce((a, r) => a + n(r.spend), 0), + leads: rows.reduce((a, r) => a + n(r.platform_leads), 0), + impressions: rows.reduce((a, r) => a + n(r.impressions), 0), + clicks: rows.reduce((a, r) => a + n(r.clicks), 0), + }; +} + +function weekStart(d: Date): Date { + const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + const day = x.getUTCDay(); // 0 Sun + const diff = day === 0 ? -6 : 1 - day; // Monday + x.setUTCDate(x.getUTCDate() + diff); + x.setUTCHours(0, 0, 0, 0); + return x; +} + +function isoDate(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function parseWeek(w?: string): Date | null { + if (!w) return null; + const d = new Date(w); + return Number.isNaN(d.getTime()) ? null : d; +} + +function pctChange(current: number, previous: number): number | null { + if (previous === 0) { + if (current === 0) return 0; + return null; // can't compute meaningfully + } + return ((current - previous) / Math.abs(previous)) * 100; +} + +function filterWeeks( + rows: KpiWeeklyRow[], + from: Date, + toExclusive: Date +): KpiWeeklyRow[] { + return rows.filter((r) => { + const d = parseWeek(r.week); + if (!d) return false; + return d >= from && d < toExclusive; + }); +} + +/** Build period totals from weekly rows for WoW / MoM / YoY. */ +export function buildComparisons(rows: KpiWeeklyRow[]): { + currentWeek: MetricTotals; + prevWeek: MetricTotals; + currentMonth: MetricTotals; + prevMonth: MetricTotals; + currentMonthLastYear: MetricTotals; + deltas: Record; +} { + const now = new Date(); + const thisWeek = weekStart(now); + const prevWeekStart = new Date(thisWeek); + prevWeekStart.setUTCDate(prevWeekStart.getUTCDate() - 7); + const nextWeek = new Date(thisWeek); + nextWeek.setUTCDate(nextWeek.getUTCDate() + 7); + + const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)); + const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)); + const yoyMonthStart = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), 1)); + const yoyMonthEnd = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1)); + + const currentWeek = sumWeekly(filterWeeks(rows, thisWeek, nextWeek)); + const prevWeek = sumWeekly(filterWeeks(rows, prevWeekStart, thisWeek)); + const currentMonth = sumWeekly(filterWeeks(rows, monthStart, nextMonth)); + const prevMonth = sumWeekly(filterWeeks(rows, prevMonthStart, monthStart)); + const currentMonthLastYear = sumWeekly( + filterWeeks(rows, yoyMonthStart, yoyMonthEnd) + ); + + const keys: (keyof MetricTotals)[] = [ + 'spend', + 'leads', + 'deals', + 'revenue', + 'cac', + 'roas', + 'cpl', + ]; + const deltas = {} as Record; + for (const k of keys) { + // For CAC/CPL lower is better — still show raw % change; UI can note it + deltas[k] = { + wow: pctChange(currentWeek[k], prevWeek[k]), + mom: pctChange(currentMonth[k], prevMonth[k]), + yoy: pctChange(currentMonth[k], currentMonthLastYear[k]), + }; + } + + return { + currentWeek, + prevWeek, + currentMonth, + prevMonth, + currentMonthLastYear, + deltas, + }; +} + +export function formatEur(v: number): string { + return `${v.toLocaleString('es-ES', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })} €`; +} + +export function formatPct(v: number | null): string { + if (v === null || Number.isNaN(v)) return '—'; + const sign = v > 0 ? '+' : ''; + return `${sign}${v.toFixed(1)}%`; +} + +export function formatNum(v: number): string { + return v.toLocaleString('es-ES', { maximumFractionDigits: 1 }); +} + +export { isoDate, weekStart }; diff --git a/src/lib/tenant-context.ts b/src/lib/tenant-context.ts new file mode 100644 index 0000000..eeb5420 --- /dev/null +++ b/src/lib/tenant-context.ts @@ -0,0 +1,70 @@ +import { cookies } from 'next/headers'; +import { + VIEW_TENANT_COOKIE, + VIEW_TENANT_NAME_COOKIE, + homeTenantId, + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; + +export type ResolvedTenant = { + tenantId: string | null; + /** true = agency zoomed into one client */ + viaView: boolean; + /** true = agency with no client selected → all tenants */ + portfolioWide: boolean; + label: string; +}; + +export async function resolveActiveTenant(user: { + app_metadata?: Record; + user_metadata?: Record; +} | null): Promise { + const role = userRole(user); + const cookieStore = await cookies(); + const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null; + const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value || null; + + if (isAgencyRole(role) && viewId) { + return { + tenantId: viewId, + viaView: true, + portfolioWide: false, + label: viewName || 'Cliente', + }; + } + if (isAgencyRole(role) && !viewId) { + return { + tenantId: null, + viaView: false, + portfolioWide: true, + label: 'Portafolio (todos los clientes)', + }; + } + return { + tenantId: homeTenantId(user), + viaView: false, + portfolioWide: false, + label: + (user?.user_metadata?.company as string) || + (user?.app_metadata?.company as string) || + 'Cliente', + }; +} + +export async function restGet( + path: string +): Promise<{ ok: boolean; data: T | null; error?: string }> { + const headers = serviceHeaders(); + if (!headers) return { ok: false, data: null, error: 'no service role' }; + const r = await fetch(`${supabaseUrl()}${path}`, { + headers, + cache: 'no-store', + }); + if (!r.ok) { + return { ok: false, data: null, error: await r.text() }; + } + return { ok: true, data: (await r.json()) as T }; +}