Files
4ventures-client-portal/src/components/CrmDashboard.tsx

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