'use client';
import React, { useMemo, useState } from 'react';
import Link from 'next/link';
import {
ArrowDownRight,
ArrowUpRight,
DollarSign,
Minus,
Target,
TrendingUp,
Users,
MousePointerClick,
Eye,
CalendarRange,
} from 'lucide-react';
import {
AdDailyRow,
AttributionChannelRow,
ChannelWeekPoint,
CrmLeadRow,
DealWonRow,
Delta,
MetricTotals,
formatEur,
formatNum,
formatPct,
attributionCross,
filterDailyByDayRange,
filterDealsForRange,
filterLeadsByDayRange,
metricTotalsFromDaily,
resolveKpiDayRange,
sumWeekly,
withCohortFromDeals,
KpiWeeklyRow,
KpiRangeKey,
} from '@/lib/metrics';
import { PageHeader, SegmentedControl } from '@/components/ui';
type Card = {
key: keyof MetricTotals;
label: string;
hint: string;
format: (v: number) => string;
icon: React.ReactNode;
invert?: boolean;
/** When true and value is 0 with no deals, show em dash + empty state. */
cohort?: boolean;
};
type RangeKey = KpiRangeKey;
function DeltaTriple({
delta,
invert,
empty,
}: {
delta: Delta;
invert?: boolean;
empty?: boolean;
}) {
if (empty) {
return (
Sin cierres en el periodo — sincroniza deals Clientify
);
}
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-700'
: c.v === 0
? 'text-gray-600'
: 'text-red-600';
const Icon =
c.v === null || c.v === 0
? Minus
: good
? ArrowUpRight
: ArrowDownRight;
return (
{c.label}
{formatPct(c.v)}
);
})}
);
}
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,
month,
week,
deltas,
daily,
sparse,
weeklyRows = [],
channelPoints = [],
dailyRows = [],
crmLeads = [],
wonDeals = [],
}: {
label: string;
portfolioWide: boolean;
month: MetricTotals;
week: MetricTotals;
deltas: Record;
daily: {
spend: number;
leads: number;
impressions: number;
clicks: number;
};
sparse: boolean;
weeklyRows?: KpiWeeklyRow[];
channelPoints?: ChannelWeekPoint[];
dailyRows?: AdDailyRow[];
crmLeads?: CrmLeadRow[];
wonDeals?: DealWonRow[];
}) {
const [range, setRange] = useState('7d');
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
const rangedDaily = useMemo(() => {
if (!dailyRows.length) return [];
if (!dayWindow) return dailyRows;
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
}, [dailyRows, dayWindow]);
const rangedLeads = useMemo(() => {
if (!crmLeads.length) return [];
if (!dayWindow) return crmLeads;
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until);
}, [crmLeads, dayWindow]);
const rangedDeals = useMemo(
() =>
filterDealsForRange(
wonDeals,
dayWindow?.since ?? null,
dayWindow?.until ?? null
),
[wonDeals, dayWindow]
);
const periodTotals = useMemo(() => {
let base: MetricTotals;
if (rangedDaily.length) {
base = metricTotalsFromDaily(rangedDaily);
} else if (!dayWindow) {
base = sumWeekly(weeklyRows);
} else {
const from = dayWindow.since;
const until = dayWindow.until;
const ranged = weeklyRows.filter((r) => {
if (!r.week) return false;
const w = r.week.slice(0, 10);
return w <= until && w >= from;
});
base = ranged.length ? sumWeekly(ranged) : month;
}
return withCohortFromDeals(base, rangedDeals);
}, [rangedDaily, rangedDeals, weeklyRows, dayWindow, month]);
const attributionRows: AttributionChannelRow[] = useMemo(() => {
if (rangedDaily.length || rangedLeads.length) {
return attributionCross(rangedDaily, rangedLeads);
}
const map = new Map();
const src =
range === 'all'
? channelPoints
: channelPoints.filter((p) => {
const d = p.week;
if (!d || !dayWindow) return true;
return d >= dayWindow.since && d <= dayWindow.until;
});
for (const p of src) {
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,
}))
.sort((a, b) => b.spend - a.spend);
}, [rangedDaily, rangedLeads, channelPoints, range, dayWindow]);
const crmPeriodTotal = rangedLeads.length;
const hasCrm = crmLeads.length > 0;
const hasCohort = rangedDeals.length > 0;
const rangeHint = dayWindow
? `${dayWindow.since} → ${dayWindow.until}`
: 'todo el histórico';
const cards: Card[] = [
{
key: 'spend',
label: 'Inversión ads',
hint: 'Paid del periodo',
format: formatEur,
icon: ,
},
{
key: 'leads',
label: 'Leads',
hint: 'Captación periodo',
format: formatNum,
icon: ,
},
{
key: 'cpl',
label: 'CPL',
hint: 'Coste por lead',
format: (v) => `${formatNum(v)} €`,
icon: ,
invert: true,
},
{
key: 'cac',
label: 'CAC cohorte',
hint: hasCohort
? `Paid ÷ ${formatNum(periodTotals.deals)} cierres`
: 'Paid ÷ deals ganados Clientify',
format: (v) => `${formatNum(v)} €`,
icon: ,
invert: true,
cohort: true,
},
{
key: 'revenue',
label: 'Revenue cohorte',
hint: hasCohort
? `${formatNum(periodTotals.deals)} deals won`
: 'Suma amount deals won',
format: formatEur,
icon: ,
cohort: true,
},
{
key: 'roas',
label: 'ROAS cohorte',
hint: 'Revenue ÷ spend',
format: (v) => `${formatNum(v)}x`,
icon: ,
cohort: true,
},
];
return (
}
/>
{sparse && (
Histórico limitado: si faltan deltas o canales, el warehouse aún no
tiene semanas suficientes.
)}
{!hasCohort && (
CAC / Revenue / ROAS a cero: no hay deals ganados con fecha de cierre
en este periodo. Se calculan con{' '}
deals Clientify (stage=won) , no
solo con leads de ads.
)}
{[
{ 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
Plataforma = leads reportados por Meta/Google · CRM = atribución
Clientify (origen/UTM)
{!hasCrm && portfolioWide
? ' · elige un cliente en Contexto para ver CRM'
: ''}
Gap = CRM − plat
{attributionRows.length === 0 ? (
Sin series de canal en el periodo. Los canales Meta/Google aparecen
cuando hay filas en ad_daily para este cliente (elige uno en
Contexto).
) : (
Canal
Inversión
Leads plat
Leads CRM
Gap
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.cplPlatform
? `${formatNum(row.cplPlatform)} €`
: '—'}
{hasCrm && row.cplCrm
? `${formatNum(row.cplCrm)} €`
: '—'}
))}
)}
Ads · ventana diaria
{formatEur(daily.spend)}
{formatNum(daily.leads)} leads · {formatNum(daily.impressions)} impr.
· {formatNum(daily.clicks)} clicks
Semana actual: {formatEur(week.spend)} · {formatNum(week.leads)}{' '}
leads
Cómo leer
CAC / Revenue / ROAS usan deals ganados de Clientify (
won_at en el periodo), no
leads de Meta/Google.
Gap cercano a 0 en Meta/Google = buena atribución. Canales sin
spend son orgánicos/CRM.
CPL CRM = inversión paid ÷ leads CRM del canal (más realista
cuando hay desfase con la plataforma).
);
}