feat: pestaña CRM con % a cliente por canal y cruce ads vs Clientify

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 17:33:41 +02:00
parent f436276c3e
commit be45df300b
8 changed files with 1157 additions and 57 deletions

View File

@@ -0,0 +1,350 @@
'use client';
import React, { useMemo, useState } from 'react';
import {
CalendarRange,
Users,
UserCheck,
Percent,
Flame,
UserX,
Briefcase,
} from 'lucide-react';
import {
AdDailyRow,
CrmChannelStats,
CrmLeadRow,
KpiRangeKey,
attributionCross,
buildCrmChannelStats,
buildCrmFunnel,
buildCrmStatusBreakdown,
filterDailyByDayRange,
filterLeadsByDayRange,
formatEur,
formatNum,
resolveKpiDayRange,
} from '@/lib/metrics';
type RangeKey = KpiRangeKey;
function rateClass(rate: number): string {
if (rate >= 8) return 'text-emerald-400';
if (rate >= 3) return 'text-amber-400';
if (rate > 0) return 'text-orange-400';
return 'text-gray-500';
}
export default function CrmDashboard({
label,
portfolioWide,
crmLeads = [],
dailyRows = [],
}: {
label: string;
portfolioWide: boolean;
crmLeads?: CrmLeadRow[];
dailyRows?: AdDailyRow[];
}) {
const [range, setRange] = useState<RangeKey>('all');
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
const rangedLeads = useMemo(() => {
if (!crmLeads.length) return [];
if (!dayWindow) return crmLeads;
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until);
}, [crmLeads, dayWindow]);
const rangedDaily = useMemo(() => {
if (!dailyRows.length) return [];
if (!dayWindow) return dailyRows;
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
}, [dailyRows, dayWindow]);
const funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]);
const statusRows = useMemo(
() => buildCrmStatusBreakdown(rangedLeads),
[rangedLeads]
);
const channelRows: CrmChannelStats[] = useMemo(
() => buildCrmChannelStats(rangedLeads),
[rangedLeads]
);
const attribution = useMemo(
() => attributionCross(rangedDaily, rangedLeads),
[rangedDaily, rangedLeads]
);
const spendByChannel = useMemo(() => {
const m = new Map<string, number>();
for (const r of attribution) m.set(r.channel, r.spend);
return m;
}, [attribution]);
const rangeHint = dayWindow
? `${dayWindow.since} → ${dayWindow.until}`
: 'todo el histórico CRM';
const paidChannels = channelRows.filter((r) =>
['meta', 'google'].includes(r.channel)
);
const otherChannels = channelRows.filter(
(r) => !['meta', 'google'].includes(r.channel)
);
return (
<div className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">
CRM · Clientify
</h2>
<p className="text-sm text-gray-400 mt-1">
{label}
{portfolioWide ? ' · portafolio' : ' · zoom cliente'}. Funnel y % a
cliente (ganado) por canal · {rangeHint}.
</p>
</div>
<div className="flex items-center gap-2">
<CalendarRange className="w-4 h-4 text-gray-500" />
<div className="flex gap-1 rounded-lg border border-gray-800 p-0.5 bg-black/20">
{(
[
['7d', '7d'],
['30d', '30d'],
['month', 'Mes'],
['all', 'Todo'],
] as [RangeKey, string][]
).map(([k, lbl]) => (
<button
key={k}
type="button"
onClick={() => setRange(k)}
className={`px-2.5 py-1 text-xs rounded-md ${
range === k
? 'bg-brand-500/20 text-brand-300'
: 'text-gray-400 hover:text-white'
}`}
>
{lbl}
</button>
))}
</div>
</div>
</div>
{!crmLeads.length ? (
<p className="text-sm text-amber-400/90 border border-amber-500/20 rounded-lg px-4 py-3">
Sin leads Clientify en este contexto. Importa el histórico o activa el
webhook (`?tenant=…`). En portafolio, elige un cliente en Contexto.
</p>
) : null}
{/* North star */}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
{[
{
label: 'Leads CRM',
value: formatNum(funnel.total),
icon: <Users className="w-4 h-4 text-blue-400" />,
},
{
label: 'Clientes (ganado)',
value: formatNum(funnel.cliente),
icon: <UserCheck className="w-4 h-4 text-emerald-400" />,
},
{
label: '% a cliente',
value: `${funnel.clienteRate.toFixed(1)}%`,
icon: <Percent className="w-4 h-4 text-brand-400" />,
},
{
label: 'En oportunidad',
value: formatNum(funnel.enOportunidad),
icon: <Briefcase className="w-4 h-4 text-sky-400" />,
},
{
label: 'Lead frío*',
value: formatNum(funnel.leadFrio),
icon: <Flame className="w-4 h-4 text-amber-400" />,
},
{
label: 'Lead perdido',
value: formatNum(funnel.leadPerdido),
icon: <UserX className="w-4 h-4 text-red-400" />,
},
].map((s) => (
<div
key={s.label}
className="rounded-xl border border-gray-800 bg-[#111827] px-4 py-3"
>
<div className="flex items-center justify-between text-gray-500 mb-1">
<p className="text-[10px] uppercase tracking-wider">{s.label}</p>
{s.icon}
</div>
<p className="text-xl font-bold text-white">{s.value}</p>
</div>
))}
</div>
<p className="text-[11px] text-gray-600 -mt-5">
*Frío incluye templado / caliente / cold-lead.
</p>
{/* % cliente por canal — paid first */}
<section className="bg-[#111827] border border-gray-800 rounded-xl p-5 space-y-3">
<div>
<h3 className="text-sm font-semibold text-white">
% a cliente por canal
</h3>
<p className="text-[11px] text-gray-500 mt-0.5">
Estado Clientify = <span className="text-gray-400">cliente</span>{' '}
(ganado). Paid arriba; resto de atribución abajo.
</p>
</div>
{channelRows.length === 0 ? (
<p className="text-xs text-gray-500 py-6 text-center">
Sin canales en el periodo.
</p>
) : (
<div className="overflow-x-auto space-y-6">
<ChannelTable
title="Paid (Meta / Google)"
rows={paidChannels}
spendByChannel={spendByChannel}
/>
<ChannelTable
title="Resto de canales CRM"
rows={otherChannels}
spendByChannel={spendByChannel}
/>
</div>
)}
</section>
{/* Funnel statuses */}
<section className="bg-[#111827] border border-gray-800 rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-white">Funnel por estado</h3>
{statusRows.length === 0 ? (
<p className="text-xs text-gray-500 py-4 text-center">Sin datos.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="text-gray-500 border-b border-gray-800">
<tr>
<th className="py-2 pr-3 font-medium">Estado</th>
<th className="py-2 pr-3 font-medium">Leads</th>
<th className="py-2 font-medium">% del total</th>
</tr>
</thead>
<tbody>
{statusRows.map((row) => (
<tr
key={row.status}
className="border-b border-gray-900 hover:bg-white/[0.02]"
>
<td className="py-2.5 pr-3 text-gray-200 capitalize">
{row.status}
</td>
<td className="py-2.5 pr-3 text-gray-200">
{formatNum(row.count)}
</td>
<td className="py-2.5 text-gray-200">
{row.pct.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="text-[11px] text-gray-600">
La inversión por canal (si hay ads) es solo referencia; el % a cliente es
100% CRM (Clientify).
</p>
</div>
);
}
function ChannelTable({
title,
rows,
spendByChannel,
}: {
title: string;
rows: CrmChannelStats[];
spendByChannel: Map<string, number>;
}) {
if (!rows.length) {
return (
<div>
<p className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
{title}
</p>
<p className="text-xs text-gray-600">Sin filas.</p>
</div>
);
}
return (
<div>
<p className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
{title}
</p>
<table className="w-full text-left text-xs">
<thead className="text-gray-500 border-b border-gray-800">
<tr>
<th className="py-2 pr-3 font-medium">Canal</th>
<th className="py-2 pr-3 font-medium">Leads</th>
<th className="py-2 pr-3 font-medium">Clientes</th>
<th className="py-2 pr-3 font-medium">% a cliente</th>
<th className="py-2 pr-3 font-medium">En oportunidad</th>
<th className="py-2 pr-3 font-medium">Frío*</th>
<th className="py-2 pr-3 font-medium">Perdido</th>
<th className="py-2 font-medium">Spend ads</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const spend = spendByChannel.get(row.channel) || 0;
return (
<tr
key={row.channel}
className="border-b border-gray-900 hover:bg-white/[0.02]"
>
<td className="py-2.5 pr-3 text-brand-300 font-semibold capitalize">
{row.channel}
</td>
<td className="py-2.5 pr-3 text-gray-200">
{formatNum(row.leads)}
</td>
<td className="py-2.5 pr-3 text-emerald-300 font-medium">
{formatNum(row.cliente)}
</td>
<td
className={`py-2.5 pr-3 font-semibold ${rateClass(row.clienteRate)}`}
>
{row.clienteRate.toFixed(1)}%
</td>
<td className="py-2.5 pr-3 text-gray-200">
{formatNum(row.enOportunidad)}
</td>
<td className="py-2.5 pr-3 text-gray-200">
{formatNum(row.leadFrio)}
</td>
<td className="py-2.5 pr-3 text-gray-200">
{formatNum(row.leadPerdido)}
</td>
<td className="py-2.5 text-gray-400">
{spend ? formatEur(spend) : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,254 @@
'use client';
import React, { useMemo, useState } from 'react';
import {
CalendarRange,
Percent,
UserCheck,
Users,
} from 'lucide-react';
import {
CrmLeadRow,
KpiRangeKey,
buildCrmChannelStats,
buildCrmFunnel,
filterLeadsByDayRange,
formatNum,
resolveKpiDayRange,
} from '@/lib/metrics';
type RangeKey = KpiRangeKey;
function rateClass(rate: number): string {
if (rate >= 8) return 'text-emerald-400';
if (rate >= 3) return 'text-amber-400';
if (rate > 0) return 'text-orange-400';
return 'text-gray-500';
}
export default function CrmPortfolioBoard({
leads,
tenantNames,
}: {
leads: CrmLeadRow[];
tenantNames: Record<string, string>;
}) {
const [range, setRange] = useState<RangeKey>('all');
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
const ranged = useMemo(() => {
if (!leads.length) return [];
if (!dayWindow) return leads;
return filterLeadsByDayRange(leads, dayWindow.since, dayWindow.until);
}, [leads, dayWindow]);
const byTenant = useMemo(() => {
const map = new Map<string, CrmLeadRow[]>();
for (const r of ranged) {
const tid = r.tenant_id || 'unknown';
const cur = map.get(tid) || [];
cur.push(r);
map.set(tid, cur);
}
return [...map.entries()]
.map(([tenantId, rows]) => {
const funnel = buildCrmFunnel(rows);
const channels = buildCrmChannelStats(rows);
const meta = channels.find((c) => c.channel === 'meta');
const google = channels.find((c) => c.channel === 'google');
return {
tenantId,
name: tenantNames[tenantId] || tenantId.slice(0, 8),
funnel,
channels,
meta,
google,
};
})
.filter((t) => t.funnel.total > 0)
.sort((a, b) => b.funnel.cliente - a.funnel.cliente);
}, [ranged, tenantNames]);
const rangeHint = dayWindow
? `${dayWindow.since} → ${dayWindow.until}`
: 'todo el histórico';
return (
<div className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">
CRM · Portafolio
</h2>
<p className="text-sm text-gray-400 mt-1">
% a cliente (ganado) por canal y cliente · {rangeHint}. Zoom un
cliente en Contexto para el detalle completo.
</p>
</div>
<div className="flex items-center gap-2">
<CalendarRange className="w-4 h-4 text-gray-500" />
<div className="flex gap-1 rounded-lg border border-gray-800 p-0.5 bg-black/20">
{(
[
['7d', '7d'],
['30d', '30d'],
['month', 'Mes'],
['all', 'Todo'],
] as [RangeKey, string][]
).map(([k, lbl]) => (
<button
key={k}
type="button"
onClick={() => setRange(k)}
className={`px-2.5 py-1 text-xs rounded-md ${
range === k
? 'bg-brand-500/20 text-brand-300'
: 'text-gray-400 hover:text-white'
}`}
>
{lbl}
</button>
))}
</div>
</div>
</div>
{!byTenant.length ? (
<p className="text-sm text-amber-400/90 border border-amber-500/20 rounded-lg px-4 py-3">
Sin leads Clientify en el portafolio todavía.
</p>
) : null}
<div className="overflow-x-auto bg-[#111827] border border-gray-800 rounded-xl p-5">
<table className="w-full text-left text-xs">
<thead className="text-gray-500 border-b border-gray-800">
<tr>
<th className="py-2 pr-3 font-medium">Cliente</th>
<th className="py-2 pr-3 font-medium">Leads</th>
<th className="py-2 pr-3 font-medium">Clientes</th>
<th className="py-2 pr-3 font-medium">% global</th>
<th className="py-2 pr-3 font-medium">Meta leads</th>
<th className="py-2 pr-3 font-medium">Meta %→cli</th>
<th className="py-2 pr-3 font-medium">Google leads</th>
<th className="py-2 font-medium">Google %→cli</th>
</tr>
</thead>
<tbody>
{byTenant.map((t) => (
<tr
key={t.tenantId}
className="border-b border-gray-900 hover:bg-white/[0.02]"
>
<td className="py-2.5 pr-3 text-white font-semibold">
{t.name}
</td>
<td className="py-2.5 pr-3 text-gray-200">
{formatNum(t.funnel.total)}
</td>
<td className="py-2.5 pr-3 text-emerald-300">
{formatNum(t.funnel.cliente)}
</td>
<td
className={`py-2.5 pr-3 font-semibold ${rateClass(t.funnel.clienteRate)}`}
>
{t.funnel.clienteRate.toFixed(1)}%
</td>
<td className="py-2.5 pr-3 text-gray-200">
{t.meta ? formatNum(t.meta.leads) : '—'}
</td>
<td
className={`py-2.5 pr-3 font-semibold ${rateClass(t.meta?.clienteRate || 0)}`}
>
{t.meta ? `${t.meta.clienteRate.toFixed(1)}%` : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-200">
{t.google ? formatNum(t.google.leads) : '—'}
</td>
<td
className={`py-2.5 font-semibold ${rateClass(t.google?.clienteRate || 0)}`}
>
{t.google ? `${t.google.clienteRate.toFixed(1)}%` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Expanded channel matrix per tenant */}
<div className="space-y-4">
{byTenant.map((t) => (
<section
key={`ch-${t.tenantId}`}
className="bg-[#111827] border border-gray-800 rounded-xl p-5"
>
<div className="flex items-center justify-between gap-2 mb-3">
<h3 className="text-sm font-semibold text-white">{t.name}</h3>
<div className="flex items-center gap-3 text-[11px] text-gray-500">
<span className="inline-flex items-center gap-1">
<Users className="w-3 h-3" />
{formatNum(t.funnel.total)}
</span>
<span className="inline-flex items-center gap-1 text-emerald-400/80">
<UserCheck className="w-3 h-3" />
{formatNum(t.funnel.cliente)}
</span>
<span className="inline-flex items-center gap-1 text-brand-400/80">
<Percent className="w-3 h-3" />
{t.funnel.clienteRate.toFixed(1)}%
</span>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="text-gray-500 border-b border-gray-800">
<tr>
<th className="py-2 pr-3 font-medium">Canal</th>
<th className="py-2 pr-3 font-medium">Leads</th>
<th className="py-2 pr-3 font-medium">Clientes</th>
<th className="py-2 pr-3 font-medium">% a cliente</th>
<th className="py-2 pr-3 font-medium">Oportunidad</th>
<th className="py-2 pr-3 font-medium">Frío*</th>
<th className="py-2 font-medium">Perdido</th>
</tr>
</thead>
<tbody>
{t.channels.slice(0, 12).map((row) => (
<tr
key={row.channel}
className="border-b border-gray-900 hover:bg-white/[0.02]"
>
<td className="py-2 pr-3 text-brand-300 font-semibold capitalize">
{row.channel}
</td>
<td className="py-2 pr-3 text-gray-200">
{formatNum(row.leads)}
</td>
<td className="py-2 pr-3 text-emerald-300">
{formatNum(row.cliente)}
</td>
<td
className={`py-2 pr-3 font-semibold ${rateClass(row.clienteRate)}`}
>
{row.clienteRate.toFixed(1)}%
</td>
<td className="py-2 pr-3 text-gray-200">
{formatNum(row.enOportunidad)}
</td>
<td className="py-2 pr-3 text-gray-200">
{formatNum(row.leadFrio)}
</td>
<td className="py-2 text-gray-200">
{formatNum(row.leadPerdido)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</div>
</div>
);
}

View File

@@ -16,14 +16,17 @@ import {
} from 'lucide-react';
import {
AdDailyRow,
AttributionChannelRow,
ChannelWeekPoint,
CrmLeadRow,
Delta,
MetricTotals,
formatEur,
formatNum,
formatPct,
channelTotalsFromDaily,
attributionCross,
filterDailyByDayRange,
filterLeadsByDayRange,
metricTotalsFromDaily,
resolveKpiDayRange,
sumWeekly,
@@ -88,6 +91,14 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
);
}
function gapClass(gap: number): string {
if (gap === 0) return 'text-gray-400';
const abs = Math.abs(gap);
if (abs <= 5) return 'text-emerald-400';
if (abs <= 25) return 'text-amber-400';
return 'text-red-400';
}
export default function MetricsDashboard({
label,
portfolioWide,
@@ -99,6 +110,7 @@ export default function MetricsDashboard({
weeklyRows = [],
channelPoints = [],
dailyRows = [],
crmLeads = [],
}: {
label: string;
portfolioWide: boolean;
@@ -116,6 +128,8 @@ export default function MetricsDashboard({
channelPoints?: ChannelWeekPoint[];
/** Raw ad_daily — period totals/matrix use calendar days (same as Ad Tracker). */
dailyRows?: AdDailyRow[];
/** Clientify leads for CRM vs platform attribution. */
crmLeads?: CrmLeadRow[];
}) {
const [range, setRange] = useState<RangeKey>('7d');
@@ -127,6 +141,12 @@ export default function MetricsDashboard({
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 periodTotals = useMemo(() => {
if (rangedDaily.length) return metricTotalsFromDaily(rangedDaily);
// Fallback when no ad_daily: approximate from weekly (legacy)
@@ -141,12 +161,12 @@ export default function MetricsDashboard({
return ranged.length ? sumWeekly(ranged) : month;
}, [rangedDaily, weeklyRows, dayWindow, month]);
const channelRows = useMemo(() => {
if (rangedDaily.length) return channelTotalsFromDaily(rangedDaily);
const map = new Map<
string,
{ spend: number; leads: number; deals: number; revenue: number }
>();
const attributionRows: AttributionChannelRow[] = useMemo(() => {
if (rangedDaily.length || rangedLeads.length) {
return attributionCross(rangedDaily, rangedLeads);
}
// Fallback: weekly series without CRM cross
const map = new Map<string, { spend: number; leads: number }>();
const src =
range === 'all'
? channelPoints
@@ -156,27 +176,26 @@ export default function MetricsDashboard({
return d >= dayWindow.since && d <= dayWindow.until;
});
for (const p of src) {
const cur = map.get(p.channel) || {
spend: 0,
leads: 0,
deals: 0,
revenue: 0,
};
const cur = map.get(p.channel) || { spend: 0, leads: 0 };
cur.spend += p.spend;
cur.leads += p.leads;
cur.deals += p.deals;
cur.revenue += p.revenue;
map.set(p.channel, cur);
}
return [...map.entries()]
.map(([channel, v]) => ({
channel,
...v,
cpl: v.leads > 0 ? v.spend / v.leads : 0,
roas: v.spend > 0 ? v.revenue / v.spend : 0,
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, channelPoints, range, dayWindow]);
}, [rangedDaily, rangedLeads, channelPoints, range, dayWindow]);
const crmPeriodTotal = rangedLeads.length;
const hasCrm = crmLeads.length > 0;
const rangeHint = dayWindow
? `${dayWindow.since} → ${dayWindow.until}`
@@ -283,18 +302,22 @@ export default function MetricsDashboard({
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: 'Inversión periodo', value: formatEur(periodTotals.spend) },
{ label: 'Leads', value: formatNum(periodTotals.leads) },
{
label: 'CAC (paid)',
value: periodTotals.cac
? `${formatNum(periodTotals.cac)} €`
: '—',
label: 'Leads plataforma',
value: formatNum(periodTotals.leads),
},
{
label: 'ROAS cohorte',
value: periodTotals.roas
? `${formatNum(periodTotals.roas)}x`
: '—',
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
@@ -330,17 +353,26 @@ export default function MetricsDashboard({
))}
</div>
{/* Channel matrix */}
{/* Channel attribution cross */}
<section className="bg-[#111827] border border-gray-800 rounded-xl p-5 space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-white">
Matriz por canal
</h3>
<p className="text-[11px] text-gray-500">
Clic → dash secundario del canal
<div>
<h3 className="text-sm font-semibold text-white">
Cruce por canal · ads vs CRM
</h3>
<p className="text-[11px] text-gray-500 mt-0.5">
Plataforma = leads reportados por Meta/Google · CRM = atribución
Clientify (origen/UTM)
{!hasCrm && portfolioWide
? ' · elige un cliente en Contexto para ver CRM'
: ''}
</p>
</div>
<p className="text-[11px] text-gray-500 shrink-0">
Gap = CRM − plat
</p>
</div>
{channelRows.length === 0 ? (
{attributionRows.length === 0 ? (
<p className="text-xs text-gray-500 py-6 text-center">
Sin series de canal en el periodo. Los canales Meta/Google aparecen
cuando hay filas en ad_daily para este cliente (elige uno en
@@ -353,40 +385,60 @@ export default function MetricsDashboard({
<tr>
<th className="py-2 pr-3 font-medium">Canal</th>
<th className="py-2 pr-3 font-medium">Inversión</th>
<th className="py-2 pr-3 font-medium">Leads</th>
<th className="py-2 pr-3 font-medium">CPL</th>
<th className="py-2 pr-3 font-medium">Revenue</th>
<th className="py-2 font-medium">ROAS</th>
<th className="py-2 pr-3 font-medium">Leads plat</th>
<th className="py-2 pr-3 font-medium">Leads CRM</th>
<th className="py-2 pr-3 font-medium">Gap</th>
<th className="py-2 pr-3 font-medium">CPL plat</th>
<th className="py-2 font-medium">CPL CRM</th>
</tr>
</thead>
<tbody>
{channelRows.map((row) => (
{attributionRows.map((row) => (
<tr
key={row.channel}
className="border-b border-gray-900 hover:bg-white/[0.02]"
>
<td className="py-2.5 pr-3">
<Link
href={`/dashboard/kpis/canal/${encodeURIComponent(row.channel)}`}
className="text-brand-400 hover:text-brand-300 font-semibold capitalize"
>
{row.channel}
</Link>
{row.spend > 0 ? (
<Link
href={`/dashboard/kpis/canal/${encodeURIComponent(row.channel)}`}
className="text-brand-400 hover:text-brand-300 font-semibold capitalize"
>
{row.channel}
</Link>
) : (
<span className="text-gray-300 font-semibold capitalize">
{row.channel}
</span>
)}
</td>
<td className="py-2.5 pr-3 text-gray-200">
{formatEur(row.spend)}
{row.spend ? formatEur(row.spend) : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-200">
{formatNum(row.leads)}
{formatNum(row.platformLeads)}
</td>
<td className="py-2.5 pr-3 text-gray-200">
{row.cpl ? `${formatNum(row.cpl)} €` : '—'}
{hasCrm ? formatNum(row.crmLeads) : '—'}
</td>
<td
className={`py-2.5 pr-3 font-medium ${
hasCrm ? gapClass(row.gap) : 'text-gray-500'
}`}
>
{hasCrm
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
: '—'}
</td>
<td className="py-2.5 pr-3 text-gray-200">
{formatEur(row.revenue)}
{row.cplPlatform
? `${formatNum(row.cplPlatform)} €`
: '—'}
</td>
<td className="py-2.5 text-gray-200">
{row.roas ? `${formatNum(row.roas)}x` : '—'}
{hasCrm && row.cplCrm
? `${formatNum(row.cplCrm)} €`
: '—'}
</td>
</tr>
))}
@@ -430,13 +482,17 @@ export default function MetricsDashboard({
</p>
<ul className="text-xs text-gray-400 space-y-1 list-disc list-inside">
<li>
El selector de fechas filtra inversión/leads del periodo; WoW/MoM/YoY
siguen siendo comparativas de calendario.
El selector filtra el mismo calendario que Ad Tracker; el cruce
compara leads de la plataforma ads vs atribución Clientify.
</li>
<li>
CAC unificado con fee/tools y cierre CRM llega en v2.
Gap cercano a 0 en Meta/Google = buena atribución. Canales sin
spend (whatsapp, manual…) son orgánicos/CRM.
</li>
<li>
CPL CRM usa inversión paid ÷ leads CRM del canal (más realista
que el CPL de la plataforma cuando hay desfase).
</li>
<li>GEO / LTV aparecen como stubs hasta tener pipeline de datos.</li>
</ul>
</div>
</div>