Show global and per-channel CAC in CRM and Métricas.

Reuse won deals + ad spend for every tenant: CAC column in the metrics channel table, and a CRM hero + channel breakdown with the same formula.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-08 09:48:48 +02:00
parent e3fe157fa5
commit 8ef21d98d7
5 changed files with 319 additions and 166 deletions

View File

@@ -7,19 +7,23 @@ import {
Briefcase,
GitCompare,
ListTree,
Target,
} from 'lucide-react';
import {
AdDailyRow,
CrmChannelStats,
CrmDateLens,
CrmLeadRow,
DealWonRow,
attributionCross,
buildCrmChannelStats,
buildCrmFunnel,
buildCrmStatusBreakdown,
filterDailyByDayRange,
filterDealsForRange,
filterLeadsByDayRange,
formatEur,
formatEurCents,
formatNum,
} from '@/lib/metrics';
import { PageHeader, SegmentedControl, Surface } from '@/components/ui';
@@ -56,11 +60,13 @@ export default function CrmDashboard({
portfolioWide,
crmLeads = [],
dailyRows = [],
wonDeals = [],
}: {
label: string;
portfolioWide: boolean;
crmLeads?: CrmLeadRow[];
dailyRows?: AdDailyRow[];
wonDeals?: DealWonRow[];
}) {
const { since, until, label: rangeHint } = useDateRange();
const [lens, setLens] = useState<CrmDateLens>('created');
@@ -82,6 +88,11 @@ export default function CrmDashboard({
return filterDailyByDayRange(dailyRows, since, until);
}, [dailyRows, since, until]);
const rangedDeals = useMemo(
() => filterDealsForRange(wonDeals, since, until),
[wonDeals, since, until]
);
const funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]);
const statusRows = useMemo(
() => buildCrmStatusBreakdown(rangedLeads),
@@ -92,13 +103,23 @@ export default function CrmDashboard({
[rangedLeads]
);
const attribution = useMemo(
() => attributionCross(rangedDaily, rangedLeads),
[rangedDaily, rangedLeads]
() => attributionCross(rangedDaily, rangedLeads, rangedDeals),
[rangedDaily, rangedLeads, rangedDeals]
);
const spendByChannel = useMemo(() => {
const m = new Map<string, number>();
for (const r of attribution) m.set(r.channel, r.spend);
const spendTotal = useMemo(
() => attribution.reduce((a, r) => a + r.spend, 0),
[attribution]
);
const ventasTotal = rangedDeals.length;
const cacGlobal =
ventasTotal > 0 && spendTotal > 0 ? spendTotal / ventasTotal : 0;
const cacByChannel = useMemo(() => {
const m = new Map<string, { cac: number; spend: number; ventas: number }>();
for (const r of attribution) {
m.set(r.channel, { cac: r.cac, spend: r.spend, ventas: r.ventas });
}
return m;
}, [attribution]);
@@ -108,6 +129,12 @@ export default function CrmDashboard({
const otherChannels = channelRows.filter(
(r) => !['meta', 'google'].includes(r.channel)
);
// Channels with ads spend / ventas but maybe no CRM lead row in this lens
const extraCacChannels = attribution.filter(
(r) =>
(r.spend > 0 || r.ventas > 0) &&
!channelRows.some((c) => c.channel === r.channel)
);
const leadMax = Math.max(1, ...channelRows.map((r) => r.leads));
const statusMax = Math.max(1, ...statusRows.map((r) => r.count));
@@ -139,8 +166,9 @@ export default function CrmDashboard({
<HelpDetails title="Cómo leer este CRM">
<p>{LENS_HELP[lens]}</p>
<p>
El % a cliente es del CRM, no de ads. La inversión por canal es solo
referencia del periodo.
El % a cliente es del CRM, no de ads. El{' '}
<strong>CAC</strong> = inversión ads del periodo ÷ ventas ganadas
(deals won) del periodo; por canal usa la atribución del deal.
</p>
</HelpDetails>
@@ -160,7 +188,7 @@ export default function CrmDashboard({
) : null}
<HeroBand>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 md:gap-6">
<HeroStat label="Leads" value={formatNum(funnel.total)} />
<HeroStat
label="Clientes"
@@ -175,6 +203,17 @@ export default function CrmDashboard({
label="% a cliente"
value={`${funnel.clienteRate.toFixed(1)}%`}
/>
<HeroStat
label="CAC global"
value={cacGlobal > 0 ? formatEurCents(cacGlobal) : '—'}
chip={
<span className="text-[11px] text-gray-500">
{ventasTotal > 0
? `${formatNum(ventasTotal)} ventas · ${formatEur(spendTotal)} ads`
: 'Sin ventas won en el periodo'}
</span>
}
/>
<HeroStat
label="Perdidos"
value={formatNum(funnel.leadPerdido)}
@@ -210,12 +249,88 @@ export default function CrmDashboard({
<section>
<SectionHead
icon={<GitCompare className="w-4 h-4" />}
title="% a cliente por canal"
hint="Estado ganado en el CRM. Ads spend del periodo es referencia."
icon={<Target className="w-4 h-4" />}
title="CAC por canal"
hint="Inversión ads ÷ ventas won atribuidas al canal (mismo criterio que Métricas)."
/>
<Surface padded={false}>
{!channelRows.length ? (
{!attribution.some((r) => r.spend > 0 || r.ventas > 0) ? (
<p className="text-sm text-gray-500 py-10 text-center">
Sin inversión ads ni ventas won en el periodo.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className={tableHeadClass()}>
<tr>
<th className="py-3 px-4 font-semibold">Canal</th>
<th className="py-3 pr-4 font-semibold">Inversión</th>
<th className="py-3 pr-4 font-semibold">Ventas</th>
<th className="py-3 pr-4 font-semibold">CAC</th>
<th className="py-3 pr-4 font-semibold">Importe</th>
</tr>
</thead>
<tbody>
{attribution
.filter((r) => r.spend > 0 || r.ventas > 0)
.map((row) => (
<tr
key={row.channel}
className="border-b border-gray-100 hover:bg-brand-50/40"
>
<td className="py-3 px-4 font-semibold capitalize text-gray-800">
{row.channel}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{row.spend > 0 ? formatEur(row.spend) : '—'}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{row.ventas > 0 ? formatNum(row.ventas) : '—'}
</td>
<td className="py-3 pr-4 tabular-nums font-semibold text-gray-900">
{row.cac > 0 ? formatEurCents(row.cac) : '—'}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-700">
{row.importe > 0 ? formatEur(row.importe) : '—'}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-gray-50 font-semibold border-t border-gray-200">
<td className="py-3 px-4 text-gray-900">Total</td>
<td className="py-3 pr-4 tabular-nums">
{spendTotal > 0 ? formatEur(spendTotal) : '—'}
</td>
<td className="py-3 pr-4 tabular-nums">
{ventasTotal > 0 ? formatNum(ventasTotal) : '—'}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-900">
{cacGlobal > 0 ? formatEurCents(cacGlobal) : '—'}
</td>
<td className="py-3 pr-4 tabular-nums">
{attribution.reduce((a, r) => a + r.importe, 0) > 0
? formatEur(
attribution.reduce((a, r) => a + r.importe, 0)
)
: '—'}
</td>
</tr>
</tfoot>
</table>
</div>
)}
</Surface>
</section>
<section>
<SectionHead
icon={<GitCompare className="w-4 h-4" />}
title="% a cliente por canal"
hint="Estado ganado en el CRM. Ads spend y CAC del periodo son referencia."
/>
<Surface padded={false}>
{!channelRows.length && !extraCacChannels.length ? (
<p className="text-sm text-gray-500 py-10 text-center">
Sin leads en esta lente/periodo.
</p>
@@ -231,41 +346,50 @@ export default function CrmDashboard({
<th className="py-3 pr-4 font-semibold">Clientes</th>
<th className="py-3 pr-4 font-semibold">% a cliente</th>
<th className="py-3 pr-4 font-semibold">Inversión ads</th>
<th className="py-3 pr-4 font-semibold">CAC</th>
</tr>
</thead>
<tbody>
{[...paidChannels, ...otherChannels].map((row) => (
<tr
key={row.channel}
className="border-b border-gray-100 hover:bg-brand-50/40"
>
<td className="py-3 px-4 font-semibold capitalize text-gray-800">
{row.channel}
</td>
<td className="py-3 pr-4">
<ShareBar
value={row.leads}
max={leadMax}
label={formatNum(row.leads)}
/>
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{formatNum(row.cliente)}
</td>
<td
className={`py-3 pr-4 font-semibold tabular-nums ${rateClass(
row.clienteRate
)}`}
{[...paidChannels, ...otherChannels].map((row) => {
const cacRow = cacByChannel.get(row.channel);
return (
<tr
key={row.channel}
className="border-b border-gray-100 hover:bg-brand-50/40"
>
{row.clienteRate.toFixed(1)}%
</td>
<td className="py-3 pr-4 tabular-nums text-gray-700">
{spendByChannel.get(row.channel)
? formatEur(spendByChannel.get(row.channel)!)
: '—'}
</td>
</tr>
))}
<td className="py-3 px-4 font-semibold capitalize text-gray-800">
{row.channel}
</td>
<td className="py-3 pr-4">
<ShareBar
value={row.leads}
max={leadMax}
label={formatNum(row.leads)}
/>
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{formatNum(row.cliente)}
</td>
<td
className={`py-3 pr-4 font-semibold tabular-nums ${rateClass(
row.clienteRate
)}`}
>
{row.clienteRate.toFixed(1)}%
</td>
<td className="py-3 pr-4 tabular-nums text-gray-700">
{cacRow && cacRow.spend > 0
? formatEur(cacRow.spend)
: '—'}
</td>
<td className="py-3 pr-4 tabular-nums font-semibold text-gray-900">
{cacRow && cacRow.cac > 0
? formatEurCents(cacRow.cac)
: '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>

View File

@@ -889,6 +889,7 @@ export default function MetricsDashboard({
<th className="py-3 pr-4 font-semibold">CRM</th>
<th className="py-3 pr-4 font-semibold">Gap</th>
<th className="py-3 pr-4 font-semibold">Ventas</th>
<th className="py-3 pr-4 font-semibold">CAC</th>
<th className="py-3 pr-4 font-semibold">Importe</th>
<th className="py-3 pr-4 font-semibold">ROAS</th>
<th className="py-3 pr-4 font-semibold">CPL plat</th>
@@ -962,6 +963,9 @@ export default function MetricsDashboard({
<td className="py-3 pr-4 tabular-nums font-semibold text-gray-900">
{row.ventas > 0 ? formatNum(row.ventas) : '—'}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{row.cac > 0 ? formatEurCents(row.cac) : '—'}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{row.importe > 0 ? formatEur(row.importe) : '—'}
</td>
@@ -1013,6 +1017,17 @@ export default function MetricsDashboard({
<td className="py-3 pr-4 tabular-nums text-gray-900">
{formatNum(attributionTotals.ventas || ventasCount)}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-900">
{(attributionTotals.ventas || ventasCount) > 0 &&
attributionTotals.spend > 0
? formatEurCents(
attributionTotals.spend /
(attributionTotals.ventas || ventasCount)
)
: periodTotals.cac > 0
? formatEurCents(periodTotals.cac)
: '—'}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-900">
{attributionTotals.importe > 0 ||
periodTotals.revenue > 0