Align CRM, Ads, Social and Organic dashboards with the metrics visual system.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 13:12:27 +02:00
parent 4d662430c0
commit dfb44d240e
5 changed files with 743 additions and 552 deletions

View File

@@ -2,12 +2,11 @@
import React, { useMemo, useState } from 'react';
import {
Users,
UserCheck,
Percent,
Flame,
UserX,
Briefcase,
GitCompare,
ListTree,
} from 'lucide-react';
import {
AdDailyRow,
@@ -23,24 +22,33 @@ import {
formatEur,
formatNum,
} from '@/lib/metrics';
import { PageHeader } from '@/components/ui';
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-400';
if (rate >= 3) return 'text-amber-400';
if (rate > 0) return 'text-orange-400';
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 Clientify cae en el periodo del header.',
'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 (sql/leads_status_timing.sql).',
'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 en el warehouse.',
'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora.',
};
export default function CrmDashboard({
@@ -100,206 +108,214 @@ export default function CrmDashboard({
const otherChannels = channelRows.filter(
(r) => !['meta', 'google'].includes(r.channel)
);
const leadMax = Math.max(1, ...channelRows.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' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
description={`${label}${portfolioWide ? ' · portafolio' : ''} · ${rangeHint}`}
actions={<SyncNowButton forceModule="crm" />}
/>
<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
<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>
<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>
<SegmentedControl
value={lens}
onChange={setLens}
size="sm"
options={[
{ id: 'created', label: 'Altas' },
{ id: 'status', label: 'Conversiones' },
{ id: 'snapshot', label: 'Estado actual' },
]}
/>
</div>
<p className="text-xs text-gray-600 -mt-4">{LENS_HELP[lens]}</p>
<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.
</p>
</HelpDetails>
{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 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-300 bg-amber-50 rounded-lg px-4 py-3">
Sin leads Clientify en este contexto. Importa el histórico o activa el
webhook (`?tenant=…`). En portafolio, elige un cliente en Contexto.
<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}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
{[
{
label: 'Leads CRM',
value: formatNum(funnel.total),
icon: <Users className="w-4 h-4 text-blue-400" />,
},
{
label: 'Clientes (ganado)',
value: formatNum(funnel.cliente),
icon: <UserCheck className="w-4 h-4 text-emerald-400" />,
},
{
label: '% a cliente',
value: `${funnel.clienteRate.toFixed(1)}%`,
icon: <Percent className="w-4 h-4 text-brand-400" />,
},
{
label: 'En oportunidad',
value: formatNum(funnel.enOportunidad),
icon: <Briefcase className="w-4 h-4 text-violet-400" />,
},
{
label: 'Lead frío',
value: formatNum(funnel.leadFrio),
icon: <Flame className="w-4 h-4 text-sky-400" />,
},
{
label: 'Lead perdido',
value: formatNum(funnel.leadPerdido),
icon: <UserX className="w-4 h-4 text-red-400" />,
},
].map((s) => (
<div
key={s.label}
className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm"
>
<div className="flex items-center justify-between text-gray-600 mb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider">
{s.label}
<HeroBand>
<div className="grid grid-cols-2 lg:grid-cols-4 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>
{s.icon}
</div>
<p className="text-xl font-bold text-gray-900 tabular-nums">
{s.value}
</p>
</div>
))}
}
/>
<HeroStat
label="% a cliente"
value={`${funnel.clienteRate.toFixed(1)}%`}
/>
<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 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-xs text-gray-600 mt-0.5">
Estado Clientify = <span className="text-gray-400">cliente</span>{' '}
(ganado). Ads spend del periodo es referencia.
</p>
</div>
{!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">
<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>
<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."
/>
<Surface padded={false}>
{!channelRows.length ? (
<p className="text-sm text-gray-500 py-10 text-center">
Sin leads 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>
</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>
</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
)}`}
>
{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>
))}
</tbody>
</table>
</div>
)}
</Surface>
</section>
<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-sm">
<thead className="text-gray-600 border-b border-gray-200">
<tr>
<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>
{statusRows.map((row) => (
<tr
key={row.status}
className="border-b border-gray-100 hover:bg-gray-50"
>
<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 tabular-nums text-gray-600">
{row.pct.toFixed(1)}%
</td>
<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>
))}
</tbody>
</table>
</div>
)}
</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>
);