'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 (
Sin cierres en este rango — amplía el periodo o revisa deals ganados en
Clientify
);
}
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 (
{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 (
{c.label}
{formatPct(c.v)}
{hasBase ? (
<>
prev {format(c.base ?? 0)}
{absDelta !== null && (
Δ{' '}
{absDelta > 0
? `+${format(absDelta)}`
: format(absDelta)}
)}
>
) : (
sin histórico
)}
);
})}
);
}
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;
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();
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: ,
},
{
key: 'leads',
label: 'Leads plataforma',
hint: 'Conversiones reportadas por ads',
format: formatNum,
icon: ,
},
{
key: 'cpl',
label: 'CPL ads',
hint: 'Inversión ÷ leads plataforma',
format: (v) => `${formatNum(v)} €`,
icon: ,
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: ,
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: ,
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: ,
cohort: true,
},
];
const dailyChart = useMemo(() => {
const byDay = new Map();
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 (
}
/>
{sparse && (
Histórico limitado: si faltan deltas o canales, el warehouse aún no
tiene semanas suficientes.
)}
{!hasCohort && (
Ventas / CAC / ROAS vacíos en {rangeHint} : 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.
)}
{cohortFromLeads && hasCohort && (
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.
)}
{[
{ 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) => (
))}
{cards.map((c) => {
const emptyCohort = Boolean(c.cohort) && !hasCohort;
const value = emptyCohort
? '—'
: c.format(periodTotals[c.key]);
return (
{c.label}
{c.icon}
{value}
{c.hint}
);
})}
Cruce por canal · ads vs CRM
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.
Gap = CRM − plat
{channelInsight && (
Resumen: 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{' '}
{channelInsight.worstGap.channel}
.
)}
{attributionRows.length === 0 ? (
Sin series de canal en el periodo.
) : (
Canal
Inversión
Leads plat
Leads CRM
Gap
Ventas
Importe
ROAS
CPL plat
CPL CRM
{attributionRows.map((row) => (
{row.spend > 0 ? (
{row.channel}
) : (
{row.channel}
)}
{row.spend ? formatEur(row.spend) : '—'}
{formatNum(row.platformLeads)}
{hasCrm ? formatNum(row.crmLeads) : '—'}
{hasCrm
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
: '—'}
{row.ventas > 0 ? formatNum(row.ventas) : '—'}
{row.importe > 0 ? formatEur(row.importe) : '—'}
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
{row.cplPlatform
? `${formatNum(row.cplPlatform)} €`
: '—'}
{hasCrm && row.cplCrm
? `${formatNum(row.cplCrm)} €`
: '—'}
))}
{attributionRows.length > 0 && (
Total
{attributionTotals.spend
? formatEur(attributionTotals.spend)
: '—'}
{formatNum(attributionTotals.platformLeads)}
{hasCrm ? formatNum(attributionTotals.crmLeads) : '—'}
{hasCrm
? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}`
: '—'}
{formatNum(
attributionTotals.ventas || ventasCount
)}
{attributionTotals.importe > 0 || periodTotals.revenue > 0
? formatEur(
attributionTotals.importe || periodTotals.revenue
)
: '—'}
{attributionTotals.spend > 0 &&
(attributionTotals.importe || periodTotals.revenue) > 0
? `${formatNum(
(attributionTotals.importe ||
periodTotals.revenue) / attributionTotals.spend
)}x`
: '—'}
{attributionTotals.platformLeads > 0
? `${formatNum(
attributionTotals.spend /
attributionTotals.platformLeads
)} €`
: '—'}
{hasCrm && attributionTotals.crmLeads > 0
? `${formatNum(
attributionTotals.spend /
attributionTotals.crmLeads
)} €`
: '—'}
)}
)}
Ads · inversión / día
{dailyChart.length === 0 ? (
Sin filas diarias.
) : (
{dailyChart.map((d) => (
))}
)}
Ads · leads plat. / día
{dailyChart.length === 0 ? (
Sin filas diarias.
) : (
{dailyChart.map((d) => (
x.leads))) *
80
)}px`,
}}
/>
))}
)}
Barras compactas por día · periodo del header · detalle en Ads
{/* Windsor.ai Data for blueparrotschool.com (Directly below Cruce por canal) */}
);
}