feat: KPIs con totales absolutos, backfill ads y ventas en cruce canal

Comparativas WoW/MoM/YoY muestran prev/Δ además del %; sync ads admite
histórico desde 2025; tabla ads↔CRM añade cierres, ventas y ROAS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-28 10:23:36 +02:00
parent dd50251de2
commit cf27eba74d
30 changed files with 2367 additions and 514 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import {
Users,
UserCheck,
@@ -12,6 +12,7 @@ import {
import {
AdDailyRow,
CrmChannelStats,
CrmDateLens,
CrmLeadRow,
attributionCross,
buildCrmChannelStats,
@@ -32,6 +33,15 @@ function rateClass(rate: number): string {
return 'text-gray-500';
}
const LENS_HELP: Record<CrmDateLens, string> = {
created:
'Altas: leads cuya fecha de creación en Clientify cae en el periodo del header.',
status:
'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at (sql/leads_status_timing.sql).',
snapshot:
'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora en el warehouse.',
};
export default function CrmDashboard({
label,
portfolioWide,
@@ -44,11 +54,19 @@ export default function CrmDashboard({
dailyRows?: AdDailyRow[];
}) {
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 [];
return filterLeadsByDayRange(crmLeads, since, until);
}, [crmLeads, since, until]);
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 [];
@@ -90,6 +108,43 @@ export default function CrmDashboard({
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
/>
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-600 shrink-0">
Lente CRM
</p>
<div className="flex flex-wrap gap-1.5">
{(
[
['created', 'Altas en periodo'],
['status', 'Conversiones en periodo'],
['snapshot', 'Estado actual'],
] as const
).map(([id, lab]) => (
<button
key={id}
type="button"
onClick={() => setLens(id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-colors ${
lens === id
? 'bg-brand-500 text-white border-brand-500'
: 'bg-white text-gray-700 border-gray-200 hover:border-brand-300'
}`}
>
{lab}
</button>
))}
</div>
</div>
<p className="text-xs text-gray-600 -mt-4">{LENS_HELP[lens]}</p>
{lens === 'status' && !hasStatusTiming ? (
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
Aún no hay <code className="font-mono">status_changed_at</code> en
warehouse — aplicando filtro por alta. Ejecuta{' '}
<code className="font-mono">sql/leads_status_timing.sql</code> en
Supabase y deja que los webhooks actualicen contactos.
</p>
) : null}
{!crmLeads.length ? (
<p className="text-sm text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-4 py-3">
Sin leads Clientify en este contexto. Importa el histórico o activa el
@@ -97,7 +152,6 @@ export default function CrmDashboard({
</p>
) : null}
{/* North star */}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
{[
{
@@ -118,12 +172,12 @@ export default function CrmDashboard({
{
label: 'En oportunidad',
value: formatNum(funnel.enOportunidad),
icon: <Briefcase className="w-4 h-4 text-sky-400" />,
icon: <Briefcase className="w-4 h-4 text-violet-400" />,
},
{
label: 'Lead frío*',
label: 'Lead frío',
value: formatNum(funnel.leadFrio),
icon: <Flame className="w-4 h-4 text-amber-400" />,
icon: <Flame className="w-4 h-4 text-sky-400" />,
},
{
label: 'Lead perdido',
@@ -133,65 +187,96 @@ export default function CrmDashboard({
].map((s) => (
<div
key={s.label}
className="rounded-xl border border-gray-200 bg-white px-4 py-3"
className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm"
>
<div className="flex items-center justify-between text-gray-500 mb-1">
<p className="text-[10px] uppercase tracking-wider">{s.label}</p>
<div className="flex items-center justify-between text-gray-600 mb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider">
{s.label}
</span>
{s.icon}
</div>
<p className="text-xl font-bold text-gray-900">{s.value}</p>
<p className="text-xl font-bold text-gray-900 tabular-nums">
{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-white border border-gray-200 rounded-xl p-5 space-y-3">
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
<div>
<h3 className="text-sm font-semibold text-gray-900">
% a cliente por canal
</h3>
<p className="text-[11px] text-gray-500 mt-0.5">
<p className="text-xs text-gray-600 mt-0.5">
Estado Clientify = <span className="text-gray-400">cliente</span>{' '}
(ganado). Paid arriba; resto de atribución abajo.
(ganado). Ads spend del periodo es referencia.
</p>
</div>
{channelRows.length === 0 ? (
<p className="text-xs text-gray-500 py-6 text-center">
Sin canales en el periodo.
{!channelRows.length ? (
<p className="text-sm text-gray-500 py-4 text-center">
Sin leads en esta lente/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 className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-gray-600 border-b border-gray-200">
<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 font-medium">Inversión ads</th>
</tr>
</thead>
<tbody>
{[...paidChannels, ...otherChannels].map((row) => (
<tr
key={row.channel}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2.5 pr-3 font-semibold capitalize text-gray-800">
{row.channel}
</td>
<td className="py-2.5 pr-3 tabular-nums text-gray-800">
{formatNum(row.leads)}
</td>
<td className="py-2.5 pr-3 tabular-nums text-gray-800">
{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 tabular-nums text-gray-700">
{spendByChannel.get(row.channel)
? formatEur(spendByChannel.get(row.channel)!)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-[10px] text-gray-500">
La inversión por canal (si hay ads) es solo referencia; el % a cliente
es CRM. Lente activa: {lens}.
</p>
</section>
{/* Funnel statuses */}
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-gray-900">Funnel por estado</h3>
{statusRows.length === 0 ? (
<p className="text-xs text-gray-500 py-4 text-center">Sin datos.</p>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
<h3 className="text-sm font-semibold text-gray-900">Desglose de status</h3>
{!statusRows.length ? (
<p className="text-sm 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-200">
<table className="w-full text-left text-sm">
<thead className="text-gray-600 border-b border-gray-200">
<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>
<th className="py-2 pr-3 font-medium">Status</th>
<th className="py-2 pr-3 font-medium">N</th>
<th className="py-2 font-medium">%</th>
</tr>
</thead>
<tbody>
@@ -200,13 +285,11 @@ export default function CrmDashboard({
key={row.status}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2.5 pr-3 text-gray-800 capitalize">
{row.status}
</td>
<td className="py-2.5 pr-3 text-gray-800">
<td className="py-2.5 pr-3 text-gray-800">{row.status}</td>
<td className="py-2.5 pr-3 tabular-nums">
{formatNum(row.count)}
</td>
<td className="py-2.5 text-gray-800">
<td className="py-2.5 tabular-nums text-gray-600">
{row.pct.toFixed(1)}%
</td>
</tr>
@@ -216,91 +299,6 @@ export default function CrmDashboard({
</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-200">
<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-100 hover:bg-gray-50"
>
<td className="py-2.5 pr-3 text-brand-700 font-semibold capitalize">
{row.channel}
</td>
<td className="py-2.5 pr-3 text-gray-800">
{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-800">
{formatNum(row.enOportunidad)}
</td>
<td className="py-2.5 pr-3 text-gray-800">
{formatNum(row.leadFrio)}
</td>
<td className="py-2.5 pr-3 text-gray-800">
{formatNum(row.leadPerdido)}
</td>
<td className="py-2.5 text-gray-400">
{spend ? formatEur(spend) : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}