Align CRM, Ads, Social and Organic dashboards with the metrics visual system.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user