756 lines
26 KiB
TypeScript
756 lines
26 KiB
TypeScript
'use client';
|
||
|
||
import React, { useMemo, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import {
|
||
ArrowDownRight,
|
||
ArrowUpRight,
|
||
DollarSign,
|
||
Minus,
|
||
Target,
|
||
TrendingUp,
|
||
Users,
|
||
MousePointerClick,
|
||
} from 'lucide-react';
|
||
import {
|
||
AdDailyRow,
|
||
AttributionChannelRow,
|
||
ChannelWeekPoint,
|
||
CrmLeadRow,
|
||
DealWonRow,
|
||
Delta,
|
||
MetricTotals,
|
||
formatEur,
|
||
formatNum,
|
||
formatPct,
|
||
attributionCross,
|
||
buildRangeDeltas,
|
||
filterDailyByDayRange,
|
||
filterDealsForRange,
|
||
filterLeadsByDayRange,
|
||
metricTotalsFromDaily,
|
||
rangeDaySpan,
|
||
sumWeekly,
|
||
withCohortFromDeals,
|
||
KpiWeeklyRow,
|
||
} from '@/lib/metrics';
|
||
import { PageHeader } from '@/components/ui';
|
||
import SyncNowButton from '@/components/SyncNowButton';
|
||
import { useDateRange } from '@/components/DateRangeContext';
|
||
import WindsorAiSection from '@/components/WindsorAiSection';
|
||
import { OrganicDailyRow } from '@/lib/organic';
|
||
|
||
type Card = {
|
||
key: keyof MetricTotals;
|
||
label: string;
|
||
hint: string;
|
||
format: (v: number) => string;
|
||
icon: React.ReactNode;
|
||
invert?: boolean;
|
||
/** Business outcome from Clientify won deals. */
|
||
cohort?: boolean;
|
||
};
|
||
|
||
function DeltaTriple({
|
||
delta,
|
||
invert,
|
||
empty,
|
||
format,
|
||
current,
|
||
showWow,
|
||
}: {
|
||
delta: Delta;
|
||
invert?: boolean;
|
||
empty?: boolean;
|
||
format: (v: number) => string;
|
||
current: number;
|
||
/** Hide WoW when selected range is longer than a week. */
|
||
showWow: boolean;
|
||
}) {
|
||
if (empty) {
|
||
return (
|
||
<p className="mt-3 text-[11px] text-gray-500 border border-dashed border-gray-200 rounded-md px-2 py-2 text-center">
|
||
Sin cierres en este rango — amplía el periodo o revisa deals ganados en
|
||
Clientify
|
||
</p>
|
||
);
|
||
}
|
||
const cells: {
|
||
label: string;
|
||
v: number | null;
|
||
base: number | null;
|
||
}[] = [
|
||
...(showWow
|
||
? [{ label: 'WoW', v: delta.wow, base: delta.wowBase }]
|
||
: []),
|
||
{ label: 'MoM', v: delta.mom, base: delta.momBase },
|
||
{ label: 'YoY', v: delta.yoy, base: delta.yoyBase },
|
||
];
|
||
return (
|
||
<div
|
||
className={`mt-3 grid gap-1.5 ${
|
||
showWow ? 'grid-cols-3' : 'grid-cols-2'
|
||
}`}
|
||
>
|
||
{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-700'
|
||
: c.v === 0
|
||
? 'text-gray-600'
|
||
: 'text-red-600';
|
||
const Icon =
|
||
c.v === null || c.v === 0
|
||
? Minus
|
||
: good
|
||
? ArrowUpRight
|
||
: ArrowDownRight;
|
||
const hasBase =
|
||
c.base !== null && Number.isFinite(c.base) && !(c.base === 0 && c.v === null);
|
||
const absDelta =
|
||
hasBase && c.base !== null ? current - c.base : null;
|
||
return (
|
||
<div
|
||
key={c.label}
|
||
className="rounded-md bg-gray-50 border border-gray-200 px-1.5 py-1.5 text-center"
|
||
>
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600">
|
||
{c.label}
|
||
</p>
|
||
<p
|
||
className={`text-xs font-bold inline-flex items-center justify-center gap-0.5 ${color}`}
|
||
>
|
||
<Icon className="w-3.5 h-3.5" />
|
||
{formatPct(c.v)}
|
||
</p>
|
||
{hasBase ? (
|
||
<>
|
||
<p className="text-[11px] font-semibold text-gray-800 mt-1 tabular-nums leading-tight">
|
||
prev {format(c.base ?? 0)}
|
||
</p>
|
||
{absDelta !== null && (
|
||
<p
|
||
className={`text-[10px] font-medium tabular-nums leading-tight ${color}`}
|
||
>
|
||
Δ{' '}
|
||
{absDelta > 0
|
||
? `+${format(absDelta)}`
|
||
: format(absDelta)}
|
||
</p>
|
||
)}
|
||
</>
|
||
) : (
|
||
<p className="text-[10px] text-gray-500 mt-1 leading-tight">
|
||
sin histórico
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function gapClass(gap: number): string {
|
||
if (gap === 0) return 'text-gray-600';
|
||
const abs = Math.abs(gap);
|
||
if (abs <= 5) return 'text-emerald-700';
|
||
if (abs <= 25) return 'text-amber-700';
|
||
return 'text-red-600';
|
||
}
|
||
|
||
export default function MetricsDashboard({
|
||
label,
|
||
portfolioWide = false,
|
||
tenantId,
|
||
month,
|
||
week,
|
||
deltas,
|
||
daily,
|
||
sparse,
|
||
weeklyRows = [],
|
||
channelPoints = [],
|
||
dailyRows = [],
|
||
organicRows = [],
|
||
crmLeads = [],
|
||
wonDeals = [],
|
||
integrations = [],
|
||
cohortSource = 'deals',
|
||
}: {
|
||
label: string;
|
||
portfolioWide?: boolean;
|
||
tenantId?: string | null;
|
||
month: MetricTotals;
|
||
week: MetricTotals;
|
||
deltas: Record<keyof MetricTotals, Delta>;
|
||
daily: {
|
||
spend: number;
|
||
leads: number;
|
||
impressions: number;
|
||
clicks: number;
|
||
};
|
||
sparse: boolean;
|
||
weeklyRows?: KpiWeeklyRow[];
|
||
channelPoints?: ChannelWeekPoint[];
|
||
dailyRows?: AdDailyRow[];
|
||
organicRows?: OrganicDailyRow[];
|
||
crmLeads?: CrmLeadRow[];
|
||
wonDeals?: DealWonRow[];
|
||
integrations?: any[];
|
||
cohortSource?: 'deals' | 'cliente_leads' | 'none';
|
||
}) {
|
||
const { since, until, label: rangeHint } = useDateRange();
|
||
|
||
const rangedDaily = useMemo(() => {
|
||
if (!dailyRows.length) return [];
|
||
return filterDailyByDayRange(dailyRows, since, until);
|
||
}, [dailyRows, since, until]);
|
||
|
||
const rangedLeads = useMemo(() => {
|
||
if (!crmLeads.length) return [];
|
||
return filterLeadsByDayRange(crmLeads, since, until);
|
||
}, [crmLeads, since, until]);
|
||
|
||
const rangedDeals = useMemo(
|
||
() => filterDealsForRange(wonDeals, since, until),
|
||
[wonDeals, since, until]
|
||
);
|
||
|
||
const periodTotals = useMemo(() => {
|
||
let base: MetricTotals;
|
||
if (rangedDaily.length) {
|
||
base = metricTotalsFromDaily(rangedDaily);
|
||
} else {
|
||
const ranged = weeklyRows.filter((r) => {
|
||
if (!r.week) return false;
|
||
const w = r.week.slice(0, 10);
|
||
return w <= until && w >= since;
|
||
});
|
||
base = ranged.length ? sumWeekly(ranged) : month;
|
||
}
|
||
return withCohortFromDeals(base, rangedDeals);
|
||
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]);
|
||
|
||
/** Comparatives relative to the selected range (not “today”). */
|
||
const rangeDeltas = useMemo(
|
||
() => buildRangeDeltas(dailyRows, wonDeals, since, until),
|
||
[dailyRows, wonDeals, since, until]
|
||
);
|
||
|
||
const attributionRows: AttributionChannelRow[] = useMemo(() => {
|
||
if (rangedDaily.length || rangedLeads.length || rangedDeals.length) {
|
||
return attributionCross(rangedDaily, rangedLeads, rangedDeals);
|
||
}
|
||
const map = new Map<string, { spend: number; leads: number }>();
|
||
for (const p of channelPoints) {
|
||
const d = p.week;
|
||
if (!d || d < since || d > until) continue;
|
||
const cur = map.get(p.channel) || { spend: 0, leads: 0 };
|
||
cur.spend += p.spend;
|
||
cur.leads += p.leads;
|
||
map.set(p.channel, cur);
|
||
}
|
||
return [...map.entries()]
|
||
.map(([channel, v]) => ({
|
||
channel,
|
||
spend: v.spend,
|
||
platformLeads: v.leads,
|
||
crmLeads: 0,
|
||
gap: -v.leads,
|
||
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
|
||
cplCrm: 0,
|
||
ventas: 0,
|
||
importe: 0,
|
||
roas: 0,
|
||
cac: 0,
|
||
}))
|
||
.sort((a, b) => b.spend - a.spend);
|
||
}, [rangedDaily, rangedLeads, rangedDeals, channelPoints, since, until]);
|
||
|
||
const attributionTotals = useMemo(() => {
|
||
return attributionRows.reduce(
|
||
(a, r) => ({
|
||
spend: a.spend + r.spend,
|
||
platformLeads: a.platformLeads + r.platformLeads,
|
||
crmLeads: a.crmLeads + r.crmLeads,
|
||
gap: a.gap + r.gap,
|
||
ventas: a.ventas + r.ventas,
|
||
importe: a.importe + r.importe,
|
||
}),
|
||
{
|
||
spend: 0,
|
||
platformLeads: 0,
|
||
crmLeads: 0,
|
||
gap: 0,
|
||
ventas: 0,
|
||
importe: 0,
|
||
}
|
||
);
|
||
}, [attributionRows]);
|
||
|
||
const crmPeriodTotal = rangedLeads.length;
|
||
const hasCrm = crmLeads.length > 0;
|
||
const hasCohort = rangedDeals.length > 0;
|
||
const ventasCount = periodTotals.deals;
|
||
const cohortFromLeads = cohortSource === 'cliente_leads';
|
||
const revenueMissing =
|
||
hasCohort && periodTotals.revenue <= 0 && cohortFromLeads;
|
||
const showWow = rangeDaySpan(since, until) <= 7;
|
||
|
||
const cards: Card[] = [
|
||
{
|
||
key: 'spend',
|
||
label: 'Inversión ads',
|
||
hint: 'Gasto Meta + Google del periodo',
|
||
format: formatEur,
|
||
icon: <DollarSign className="w-4 h-4 text-brand-600" />,
|
||
},
|
||
{
|
||
key: 'leads',
|
||
label: 'Leads plataforma',
|
||
hint: 'Conversiones reportadas por ads',
|
||
format: formatNum,
|
||
icon: <Users className="w-4 h-4 text-blue-600" />,
|
||
},
|
||
{
|
||
key: 'cpl',
|
||
label: 'CPL ads',
|
||
hint: 'Inversión ÷ leads plataforma',
|
||
format: (v) => `${formatNum(v)} €`,
|
||
icon: <MousePointerClick className="w-4 h-4 text-violet-600" />,
|
||
invert: true,
|
||
},
|
||
{
|
||
key: 'deals',
|
||
label: 'Ventas (CRM)',
|
||
hint: hasCohort
|
||
? cohortFromLeads
|
||
? 'Clientes Clientify en el periodo (sin importe en webhook)'
|
||
: `Deals ganados Clientify · importe ${formatEur(periodTotals.revenue)}`
|
||
: 'Deals won Clientify en el periodo',
|
||
format: formatNum,
|
||
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
|
||
cohort: true,
|
||
},
|
||
{
|
||
key: 'cac',
|
||
label: 'CAC',
|
||
hint: hasCohort
|
||
? `Inversión ads ÷ ${formatNum(ventasCount)} ventas`
|
||
: 'Inversión ÷ deals ganados (no leads)',
|
||
format: (v) => `${formatNum(v)} €`,
|
||
icon: <Target className="w-4 h-4 text-amber-600" />,
|
||
invert: true,
|
||
cohort: true,
|
||
},
|
||
{
|
||
key: 'roas',
|
||
label: 'ROAS ventas',
|
||
hint: revenueMissing
|
||
? 'Falta importe en CRM (API BPS) para ROAS'
|
||
: `Importe Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`,
|
||
format: (v) => `${formatNum(v)}x`,
|
||
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
|
||
cohort: true,
|
||
},
|
||
];
|
||
|
||
const dailyChart = useMemo(() => {
|
||
const byDay = new Map<string, { spend: number; leads: number }>();
|
||
for (const r of rangedDaily) {
|
||
const d = (r.date || '').slice(0, 10);
|
||
if (!d) continue;
|
||
const cur = byDay.get(d) || { spend: 0, leads: 0 };
|
||
cur.spend += Number(r.spend) || 0;
|
||
cur.leads += Number(r.platform_leads ?? r.conversions) || 0;
|
||
byDay.set(d, cur);
|
||
}
|
||
return [...byDay.entries()]
|
||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||
.map(([date, v]) => ({ date, ...v }));
|
||
}, [rangedDaily]);
|
||
|
||
const maxSpend = Math.max(1, ...dailyChart.map((d) => d.spend));
|
||
|
||
const channelInsight = useMemo(() => {
|
||
if (!attributionRows.length) return null;
|
||
const paid = attributionRows.filter((r) => r.spend > 0);
|
||
const top = paid[0] || attributionRows[0];
|
||
const worstGap = [...attributionRows].sort(
|
||
(a, b) => Math.abs(b.gap) - Math.abs(a.gap)
|
||
)[0];
|
||
const platTotal = attributionRows.reduce((a, r) => a + r.platformLeads, 0);
|
||
const crmTotal = attributionRows.reduce((a, r) => a + r.crmLeads, 0);
|
||
const crmPaid = paid.reduce((a, r) => a + r.crmLeads, 0);
|
||
const crmOther = crmTotal - crmPaid;
|
||
return { top, worstGap, platTotal, crmTotal, crmPaid, crmOther };
|
||
}, [attributionRows]);
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
<PageHeader
|
||
eyebrow="Rendimiento"
|
||
title="Métricas"
|
||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (selector global del header).`}
|
||
actions={<SyncNowButton forceModule="metrics" />}
|
||
/>
|
||
|
||
{sparse && (
|
||
<p className="text-xs text-amber-800 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
|
||
Histórico limitado: si faltan deltas o canales, el warehouse aún no
|
||
tiene semanas suficientes.
|
||
</p>
|
||
)}
|
||
|
||
{!hasCohort && (
|
||
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2">
|
||
Ventas / CAC / ROAS vacíos en <strong>{rangeHint}</strong>: no hay
|
||
deals ganados ni contactos con status «cliente» en este rango
|
||
{wonDeals.length
|
||
? ` (sí hay ${formatNum(wonDeals.length)} en el histórico — prueba «30 días» o «Mes»).`
|
||
: '.'}{' '}
|
||
El gasto de ads no cuenta como venta.
|
||
</p>
|
||
)}
|
||
{cohortFromLeads && hasCohort && (
|
||
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
|
||
Cohorte desde contactos Clientify status «cliente» (no hay deals
|
||
ganados sincronizados). Importe € incompleto hasta conectar la API
|
||
Clientify de este tenant. Meta pixel reporta compras en el periodo;
|
||
el CRM marca clientes sin amount en el webhook.
|
||
</p>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
{[
|
||
{ label: 'Inversión periodo', value: formatEur(periodTotals.spend) },
|
||
{
|
||
label: 'Leads plataforma',
|
||
value: formatNum(periodTotals.leads),
|
||
},
|
||
{
|
||
label: 'Leads CRM',
|
||
value: hasCrm ? formatNum(crmPeriodTotal) : '—',
|
||
},
|
||
{
|
||
label: 'CPL CRM (paid)',
|
||
value:
|
||
hasCrm && crmPeriodTotal > 0
|
||
? `${formatNum(periodTotals.spend / crmPeriodTotal)} €`
|
||
: periodTotals.cpl
|
||
? `${formatNum(periodTotals.cpl)} €`
|
||
: '—',
|
||
},
|
||
].map((s) => (
|
||
<div
|
||
key={s.label}
|
||
className="rounded-xl border border-brand-200 bg-brand-50 px-4 py-3 shadow-sm"
|
||
>
|
||
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-700">
|
||
{s.label}
|
||
</p>
|
||
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
|
||
{s.value}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||
{cards.map((c) => {
|
||
const emptyCohort = Boolean(c.cohort) && !hasCohort;
|
||
const value = emptyCohort
|
||
? '—'
|
||
: c.format(periodTotals[c.key]);
|
||
return (
|
||
<div
|
||
key={c.key}
|
||
className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm"
|
||
>
|
||
<div className="flex items-center justify-between text-gray-700 mb-2">
|
||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||
{c.label}
|
||
</span>
|
||
{c.icon}
|
||
</div>
|
||
<p className="text-2xl font-bold text-gray-900 tabular-nums">
|
||
{value}
|
||
</p>
|
||
<p className="text-xs text-gray-600 mt-1">{c.hint}</p>
|
||
<DeltaTriple
|
||
delta={rangeDeltas[c.key] ?? deltas[c.key]}
|
||
invert={c.invert}
|
||
empty={emptyCohort}
|
||
format={c.format}
|
||
current={periodTotals[c.key]}
|
||
showWow={showWow}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4 shadow-sm">
|
||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-gray-900">
|
||
Cruce por canal · ads vs CRM
|
||
</h3>
|
||
<p className="text-xs text-gray-600 mt-0.5">
|
||
Leads plat = conversiones ads. Leads CRM = altas Clientify por
|
||
fuente. Ventas = nº deals ganados; Importe = amount Clientify.
|
||
Ventas = deals ganados Online (importe {'>'} 0), como el board
|
||
Clientify. Fuente del contacto → canal (meta / google /
|
||
ecommerce…). Sin fuente → sin-atribuir.
|
||
</p>
|
||
</div>
|
||
<p className="text-xs text-gray-600 shrink-0 font-medium">
|
||
Gap = CRM − plat
|
||
</p>
|
||
</div>
|
||
{channelInsight && (
|
||
<p className="text-xs text-gray-800 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
|
||
<strong>Resumen:</strong> plat {formatNum(channelInsight.platTotal)}{' '}
|
||
vs CRM {formatNum(channelInsight.crmTotal)} (gap{' '}
|
||
{channelInsight.crmTotal - channelInsight.platTotal > 0 ? '+' : ''}
|
||
{formatNum(channelInsight.crmTotal - channelInsight.platTotal)}). De
|
||
los CRM, {formatNum(channelInsight.crmPaid)} en canales con gasto
|
||
ads y {formatNum(channelInsight.crmOther)} en otras fuentes
|
||
(integrations, ecommerce, messenger…). Mayor desfase en{' '}
|
||
<span className="capitalize font-semibold">
|
||
{channelInsight.worstGap.channel}
|
||
</span>
|
||
.
|
||
</p>
|
||
)}
|
||
{attributionRows.length === 0 ? (
|
||
<p className="text-sm text-gray-600 py-6 text-center">
|
||
Sin series de canal en el periodo.
|
||
</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left text-sm">
|
||
<thead className="text-gray-600 border-b border-gray-200">
|
||
<tr>
|
||
<th className="py-2 pr-3 font-semibold">Canal</th>
|
||
<th className="py-2 pr-3 font-semibold">Inversión</th>
|
||
<th className="py-2 pr-3 font-semibold">Leads plat</th>
|
||
<th className="py-2 pr-3 font-semibold">Leads CRM</th>
|
||
<th className="py-2 pr-3 font-semibold">Gap</th>
|
||
<th className="py-2 pr-3 font-semibold">Ventas</th>
|
||
<th className="py-2 pr-3 font-semibold">Importe</th>
|
||
<th className="py-2 pr-3 font-semibold">ROAS</th>
|
||
<th className="py-2 pr-3 font-semibold">CPL plat</th>
|
||
<th className="py-2 font-semibold">CPL CRM</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{attributionRows.map((row) => (
|
||
<tr
|
||
key={row.channel}
|
||
className="border-b border-gray-100 hover:bg-gray-50"
|
||
>
|
||
<td className="py-2.5 pr-3">
|
||
{row.spend > 0 ? (
|
||
<Link
|
||
href={`/dashboard/kpis/canal/${encodeURIComponent(row.channel)}`}
|
||
className="text-brand-700 hover:text-brand-600 font-semibold capitalize"
|
||
>
|
||
{row.channel}
|
||
</Link>
|
||
) : (
|
||
<span className="text-gray-700 font-semibold capitalize">
|
||
{row.channel}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||
{row.spend ? formatEur(row.spend) : '—'}
|
||
</td>
|
||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||
{formatNum(row.platformLeads)}
|
||
</td>
|
||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||
{hasCrm ? formatNum(row.crmLeads) : '—'}
|
||
</td>
|
||
<td
|
||
className={`py-2.5 pr-3 font-semibold tabular-nums ${
|
||
hasCrm ? gapClass(row.gap) : 'text-gray-500'
|
||
}`}
|
||
>
|
||
{hasCrm
|
||
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
|
||
: '—'}
|
||
</td>
|
||
<td className="py-2.5 pr-3 text-gray-900 font-semibold tabular-nums">
|
||
{row.ventas > 0 ? formatNum(row.ventas) : '—'}
|
||
</td>
|
||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||
{row.importe > 0 ? formatEur(row.importe) : '—'}
|
||
</td>
|
||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
|
||
</td>
|
||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||
{row.cplPlatform
|
||
? `${formatNum(row.cplPlatform)} €`
|
||
: '—'}
|
||
</td>
|
||
<td className="py-2.5 text-gray-800 tabular-nums">
|
||
{hasCrm && row.cplCrm
|
||
? `${formatNum(row.cplCrm)} €`
|
||
: '—'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
{attributionRows.length > 0 && (
|
||
<tfoot>
|
||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||
<td className="py-2.5 pr-3 text-gray-900">Total</td>
|
||
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
||
{attributionTotals.spend
|
||
? formatEur(attributionTotals.spend)
|
||
: '—'}
|
||
</td>
|
||
<td className="py-2.5 pr-3 tabular-nums">
|
||
{formatNum(attributionTotals.platformLeads)}
|
||
</td>
|
||
<td className="py-2.5 pr-3 tabular-nums">
|
||
{hasCrm ? formatNum(attributionTotals.crmLeads) : '—'}
|
||
</td>
|
||
<td
|
||
className={`py-2.5 pr-3 tabular-nums ${
|
||
hasCrm
|
||
? gapClass(attributionTotals.gap)
|
||
: 'text-gray-500'
|
||
}`}
|
||
>
|
||
{hasCrm
|
||
? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}`
|
||
: '—'}
|
||
</td>
|
||
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
||
{formatNum(
|
||
attributionTotals.ventas || ventasCount
|
||
)}
|
||
</td>
|
||
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
||
{attributionTotals.importe > 0 || periodTotals.revenue > 0
|
||
? formatEur(
|
||
attributionTotals.importe || periodTotals.revenue
|
||
)
|
||
: '—'}
|
||
</td>
|
||
<td className="py-2.5 pr-3 tabular-nums">
|
||
{attributionTotals.spend > 0 &&
|
||
(attributionTotals.importe || periodTotals.revenue) > 0
|
||
? `${formatNum(
|
||
(attributionTotals.importe ||
|
||
periodTotals.revenue) / attributionTotals.spend
|
||
)}x`
|
||
: '—'}
|
||
</td>
|
||
<td className="py-2.5 pr-3 tabular-nums">
|
||
{attributionTotals.platformLeads > 0
|
||
? `${formatNum(
|
||
attributionTotals.spend /
|
||
attributionTotals.platformLeads
|
||
)} €`
|
||
: '—'}
|
||
</td>
|
||
<td className="py-2.5 tabular-nums">
|
||
{hasCrm && attributionTotals.crmLeads > 0
|
||
? `${formatNum(
|
||
attributionTotals.spend /
|
||
attributionTotals.crmLeads
|
||
)} €`
|
||
: '—'}
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
)}
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-1">
|
||
<div>
|
||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-600 mb-2">
|
||
Ads · inversión / día
|
||
</h4>
|
||
{dailyChart.length === 0 ? (
|
||
<p className="text-xs text-gray-500">Sin filas diarias.</p>
|
||
) : (
|
||
<div className="flex items-end gap-0.5 h-20 w-full">
|
||
{dailyChart.map((d) => (
|
||
<div
|
||
key={`s-${d.date}`}
|
||
className="flex-1 min-w-0 h-full flex items-end"
|
||
title={`${d.date}: ${formatEur(d.spend)}`}
|
||
>
|
||
<div
|
||
className="w-full rounded-t bg-brand-400/80 hover:bg-brand-500"
|
||
style={{
|
||
height: `${Math.max(4, (d.spend / maxSpend) * 80)}px`,
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-600 mb-2">
|
||
Ads · leads plat. / día
|
||
</h4>
|
||
{dailyChart.length === 0 ? (
|
||
<p className="text-xs text-gray-500">Sin filas diarias.</p>
|
||
) : (
|
||
<div className="flex items-end gap-0.5 h-20 w-full">
|
||
{dailyChart.map((d) => (
|
||
<div
|
||
key={`l-${d.date}`}
|
||
className="flex-1 min-w-0 h-full flex items-end"
|
||
title={`${d.date}: ${formatNum(d.leads)} leads`}
|
||
>
|
||
<div
|
||
className="w-full rounded-t bg-sky-400/80 hover:bg-sky-500"
|
||
style={{
|
||
height: `${Math.max(
|
||
4,
|
||
(d.leads /
|
||
Math.max(1, ...dailyChart.map((x) => x.leads))) *
|
||
80
|
||
)}px`,
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<p className="text-[10px] text-gray-500">
|
||
Barras compactas por día · periodo del header · detalle en Ads
|
||
</p>
|
||
</section>
|
||
|
||
{/* Windsor.ai Data for blueparrotschool.com (Directly below Cruce por canal) */}
|
||
<WindsorAiSection
|
||
tenantId={tenantId}
|
||
label={label}
|
||
portfolioWide={portfolioWide}
|
||
dailyRows={dailyRows}
|
||
organicRows={organicRows}
|
||
crmLeads={crmLeads}
|
||
wonDeals={wonDeals}
|
||
integrations={integrations}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|