El CRM solo vivía de webhooks; ahora hay pull Clientify, cron daily-sync incluye CRM, y cada módulo de Agencia tiene sync manual. Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import React, { useMemo, useState } from 'react';
|
|
import {
|
|
Users,
|
|
UserCheck,
|
|
Percent,
|
|
Flame,
|
|
UserX,
|
|
Briefcase,
|
|
} from 'lucide-react';
|
|
import {
|
|
AdDailyRow,
|
|
CrmChannelStats,
|
|
CrmDateLens,
|
|
CrmLeadRow,
|
|
attributionCross,
|
|
buildCrmChannelStats,
|
|
buildCrmFunnel,
|
|
buildCrmStatusBreakdown,
|
|
filterDailyByDayRange,
|
|
filterLeadsByDayRange,
|
|
formatEur,
|
|
formatNum,
|
|
} from '@/lib/metrics';
|
|
import { PageHeader } from '@/components/ui';
|
|
import { useDateRange } from '@/components/DateRangeContext';
|
|
import SyncNowButton from '@/components/SyncNowButton';
|
|
|
|
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';
|
|
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,
|
|
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)
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<PageHeader
|
|
eyebrow="Rendimiento"
|
|
title="CRM"
|
|
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
|
|
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
|
|
</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
|
|
webhook (`?tenant=…`). 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}
|
|
</span>
|
|
{s.icon}
|
|
</div>
|
|
<p className="text-xl font-bold text-gray-900 tabular-nums">
|
|
{s.value}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</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>
|
|
</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>
|
|
|
|
<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>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|