Google (and other channels with ads but no CRM-sourced leads) now appear with 0 leads so investment and CAC stay visible. Co-authored-by: Cursor <cursoragent@cursor.com>
472 lines
17 KiB
TypeScript
472 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import React, { useMemo, useState } from 'react';
|
|
import {
|
|
Flame,
|
|
UserX,
|
|
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';
|
|
import { useDateRange } from '@/components/DateRangeContext';
|
|
import SyncNowButton from '@/components/SyncNowButton';
|
|
import {
|
|
HelpDetails,
|
|
HeroBand,
|
|
HeroStat,
|
|
SectionHead,
|
|
ShareBar,
|
|
StatMini,
|
|
tableHeadClass,
|
|
} from '@/components/dash-ui';
|
|
|
|
function rateClass(rate: number): string {
|
|
if (rate >= 8) return 'text-emerald-800';
|
|
if (rate >= 3) return 'text-amber-800';
|
|
if (rate > 0) return 'text-orange-800';
|
|
return 'text-gray-500';
|
|
}
|
|
|
|
const LENS_HELP: Record<CrmDateLens, string> = {
|
|
created:
|
|
'Altas: leads cuya fecha de creación en el CRM cae en el periodo del header.',
|
|
status:
|
|
'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at.',
|
|
snapshot:
|
|
'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora.',
|
|
};
|
|
|
|
export default function CrmDashboard({
|
|
label,
|
|
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');
|
|
|
|
const hasStatusTiming = useMemo(
|
|
() => crmLeads.some((l) => Boolean(l.status_changed_at || l.updated_at)),
|
|
[crmLeads]
|
|
);
|
|
|
|
const rangedLeads = useMemo(() => {
|
|
if (!crmLeads.length) return [];
|
|
const effective: CrmDateLens =
|
|
lens === 'status' && !hasStatusTiming ? 'created' : lens;
|
|
return filterLeadsByDayRange(crmLeads, since, until, effective);
|
|
}, [crmLeads, since, until, lens, hasStatusTiming]);
|
|
|
|
const rangedDaily = useMemo(() => {
|
|
if (!dailyRows.length) return [];
|
|
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),
|
|
[rangedLeads]
|
|
);
|
|
const channelRows: CrmChannelStats[] = useMemo(
|
|
() => buildCrmChannelStats(rangedLeads),
|
|
[rangedLeads]
|
|
);
|
|
const attribution = useMemo(
|
|
() => attributionCross(rangedDaily, rangedLeads, rangedDeals),
|
|
[rangedDaily, rangedLeads, rangedDeals]
|
|
);
|
|
|
|
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]);
|
|
|
|
// Include paid channels that have ad spend/ventas even with 0 CRM leads
|
|
// (e.g. Google Ads spend but no leads sourced as "google" in Clientify).
|
|
const channelRowsWithAds: CrmChannelStats[] = useMemo(() => {
|
|
const byCh = new Map(channelRows.map((r) => [r.channel, r]));
|
|
for (const r of attribution) {
|
|
if (r.spend <= 0 && r.ventas <= 0) continue;
|
|
if (byCh.has(r.channel)) continue;
|
|
byCh.set(r.channel, {
|
|
channel: r.channel,
|
|
leads: 0,
|
|
cliente: 0,
|
|
enOportunidad: 0,
|
|
leadFrio: 0,
|
|
leadPerdido: 0,
|
|
other: 0,
|
|
clienteRate: 0,
|
|
});
|
|
}
|
|
const all = [...byCh.values()];
|
|
const paid = all
|
|
.filter((r) => ['meta', 'google'].includes(r.channel))
|
|
.sort((a, b) => {
|
|
const sa = cacByChannel.get(a.channel)?.spend || 0;
|
|
const sb = cacByChannel.get(b.channel)?.spend || 0;
|
|
return sb - sa || b.leads - a.leads;
|
|
});
|
|
const other = all
|
|
.filter((r) => !['meta', 'google'].includes(r.channel))
|
|
.sort((a, b) => b.cliente - a.cliente || b.leads - a.leads);
|
|
return [...paid, ...other];
|
|
}, [channelRows, attribution, cacByChannel]);
|
|
|
|
const leadMax = Math.max(
|
|
1,
|
|
...channelRowsWithAds.map((r) => r.leads)
|
|
);
|
|
const statusMax = Math.max(1, ...statusRows.map((r) => r.count));
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<PageHeader
|
|
eyebrow="Rendimiento"
|
|
title="CRM"
|
|
description={`${label}${portfolioWide ? ' · portafolio' : ''} · ${rangeHint}`}
|
|
actions={<SyncNowButton forceModule="crm" />}
|
|
/>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 shrink-0">
|
|
Lente
|
|
</p>
|
|
<SegmentedControl
|
|
value={lens}
|
|
onChange={setLens}
|
|
size="sm"
|
|
options={[
|
|
{ id: 'created', label: 'Altas' },
|
|
{ id: 'status', label: 'Conversiones' },
|
|
{ id: 'snapshot', label: 'Estado actual' },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
<HelpDetails title="Cómo leer este CRM">
|
|
<p>{LENS_HELP[lens]}</p>
|
|
<p>
|
|
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>
|
|
|
|
{lens === 'status' && !hasStatusTiming ? (
|
|
<p className="text-xs text-amber-900 border border-amber-200 bg-amber-50 rounded-xl px-3 py-2">
|
|
Aún no hay fecha de cambio de status en warehouse — filtrando por
|
|
alta. Ejecuta <code className="font-mono">sql/leads_status_timing.sql</code>{' '}
|
|
y deja que los webhooks actualicen contactos.
|
|
</p>
|
|
) : null}
|
|
|
|
{!crmLeads.length ? (
|
|
<p className="text-sm text-amber-900 border border-amber-200 bg-amber-50 rounded-xl px-4 py-3">
|
|
Sin leads CRM en este contexto. Importa el histórico o activa el
|
|
webhook. En portafolio, elige un cliente en Contexto.
|
|
</p>
|
|
) : null}
|
|
|
|
<HeroBand>
|
|
<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"
|
|
value={formatNum(funnel.cliente)}
|
|
chip={
|
|
<span className="text-[11px] text-gray-500">
|
|
{funnel.enOportunidad} en oportunidad
|
|
</span>
|
|
}
|
|
/>
|
|
<HeroStat
|
|
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)}
|
|
chip={
|
|
<span className="text-[11px] text-gray-500">
|
|
{formatNum(funnel.leadFrio)} fríos
|
|
</span>
|
|
}
|
|
/>
|
|
</div>
|
|
</HeroBand>
|
|
|
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
|
<StatMini
|
|
label="En oportunidad"
|
|
value={formatNum(funnel.enOportunidad)}
|
|
hint="Pipeline abierto"
|
|
icon={<Briefcase className="w-3.5 h-3.5" />}
|
|
/>
|
|
<StatMini
|
|
label="Lead frío"
|
|
value={formatNum(funnel.leadFrio)}
|
|
hint="Sin avanzar"
|
|
icon={<Flame className="w-3.5 h-3.5" />}
|
|
/>
|
|
<StatMini
|
|
label="Lead perdido"
|
|
value={formatNum(funnel.leadPerdido)}
|
|
hint="Cerrado en pérdida"
|
|
icon={<UserX className="w-3.5 h-3.5" />}
|
|
/>
|
|
</div>
|
|
|
|
<section>
|
|
<SectionHead
|
|
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}>
|
|
{!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}>
|
|
{!channelRowsWithAds.length ? (
|
|
<p className="text-sm text-gray-500 py-10 text-center">
|
|
Sin leads ni gasto ads en esta lente/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 min-w-[160px]">
|
|
Leads
|
|
</th>
|
|
<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>
|
|
{channelRowsWithAds.map((row) => {
|
|
const cacRow = cacByChannel.get(row.channel);
|
|
return (
|
|
<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
|
|
)}`}
|
|
>
|
|
{row.leads > 0
|
|
? `${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>
|
|
)}
|
|
</Surface>
|
|
</section>
|
|
|
|
<section>
|
|
<SectionHead
|
|
icon={<ListTree className="w-4 h-4" />}
|
|
title="Desglose de status"
|
|
hint="Distribución de contactos en el warehouse con la lente activa"
|
|
/>
|
|
<Surface padded={false}>
|
|
{!statusRows.length ? (
|
|
<p className="text-sm text-gray-500 py-10 text-center">Sin datos.</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">Status</th>
|
|
<th className="py-3 pr-4 font-semibold min-w-[160px]">N</th>
|
|
<th className="py-3 pr-4 font-semibold">%</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{statusRows.map((row) => (
|
|
<tr
|
|
key={row.status}
|
|
className="border-b border-gray-100 hover:bg-brand-50/40"
|
|
>
|
|
<td className="py-3 px-4 text-gray-800">{row.status}</td>
|
|
<td className="py-3 pr-4">
|
|
<ShareBar
|
|
value={row.count}
|
|
max={statusMax}
|
|
label={formatNum(row.count)}
|
|
/>
|
|
</td>
|
|
<td className="py-3 pr-4 tabular-nums text-gray-600">
|
|
{row.pct.toFixed(1)}%
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Surface>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|