Files
4ventures-client-portal/src/components/CrmDashboard.tsx
4Ventures b53161dc4d Include paid ad channels with spend in CRM % a cliente table.
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>
2026-10-08 10:11:30 +02:00

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>
);
}