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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user