323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import React, { useMemo, useState } from 'react';
|
|
import {
|
|
Flame,
|
|
UserX,
|
|
Briefcase,
|
|
GitCompare,
|
|
ListTree,
|
|
} from 'lucide-react';
|
|
import {
|
|
AdDailyRow,
|
|
CrmChannelStats,
|
|
CrmDateLens,
|
|
CrmLeadRow,
|
|
attributionCross,
|
|
buildCrmChannelStats,
|
|
buildCrmFunnel,
|
|
buildCrmStatusBreakdown,
|
|
filterDailyByDayRange,
|
|
filterLeadsByDayRange,
|
|
formatEur,
|
|
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 = [],
|
|
}: {
|
|
label: string;
|
|
portfolioWide: boolean;
|
|
crmLeads?: CrmLeadRow[];
|
|
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 [];
|
|
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 funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]);
|
|
const statusRows = useMemo(
|
|
() => buildCrmStatusBreakdown(rangedLeads),
|
|
[rangedLeads]
|
|
);
|
|
const channelRows: CrmChannelStats[] = useMemo(
|
|
() => buildCrmChannelStats(rangedLeads),
|
|
[rangedLeads]
|
|
);
|
|
const attribution = useMemo(
|
|
() => attributionCross(rangedDaily, rangedLeads),
|
|
[rangedDaily, rangedLeads]
|
|
);
|
|
|
|
const spendByChannel = useMemo(() => {
|
|
const m = new Map<string, number>();
|
|
for (const r of attribution) m.set(r.channel, r.spend);
|
|
return m;
|
|
}, [attribution]);
|
|
|
|
const paidChannels = channelRows.filter((r) =>
|
|
['meta', 'google'].includes(r.channel)
|
|
);
|
|
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' : ''} · ${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. 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-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-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>
|
|
}
|
|
/>
|
|
<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>
|
|
<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>
|
|
</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>
|
|
<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>
|
|
);
|
|
}
|