feat: pestaña CRM con % a cliente por canal y cruce ads vs Clientify
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
350
src/components/CrmDashboard.tsx
Normal file
350
src/components/CrmDashboard.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
CalendarRange,
|
||||
Users,
|
||||
UserCheck,
|
||||
Percent,
|
||||
Flame,
|
||||
UserX,
|
||||
Briefcase,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AdDailyRow,
|
||||
CrmChannelStats,
|
||||
CrmLeadRow,
|
||||
KpiRangeKey,
|
||||
attributionCross,
|
||||
buildCrmChannelStats,
|
||||
buildCrmFunnel,
|
||||
buildCrmStatusBreakdown,
|
||||
filterDailyByDayRange,
|
||||
filterLeadsByDayRange,
|
||||
formatEur,
|
||||
formatNum,
|
||||
resolveKpiDayRange,
|
||||
} from '@/lib/metrics';
|
||||
|
||||
type RangeKey = KpiRangeKey;
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
export default function CrmDashboard({
|
||||
label,
|
||||
portfolioWide,
|
||||
crmLeads = [],
|
||||
dailyRows = [],
|
||||
}: {
|
||||
label: string;
|
||||
portfolioWide: boolean;
|
||||
crmLeads?: CrmLeadRow[];
|
||||
dailyRows?: AdDailyRow[];
|
||||
}) {
|
||||
const [range, setRange] = useState<RangeKey>('all');
|
||||
|
||||
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
|
||||
|
||||
const rangedLeads = useMemo(() => {
|
||||
if (!crmLeads.length) return [];
|
||||
if (!dayWindow) return crmLeads;
|
||||
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until);
|
||||
}, [crmLeads, dayWindow]);
|
||||
|
||||
const rangedDaily = useMemo(() => {
|
||||
if (!dailyRows.length) return [];
|
||||
if (!dayWindow) return dailyRows;
|
||||
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
|
||||
}, [dailyRows, dayWindow]);
|
||||
|
||||
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 rangeHint = dayWindow
|
||||
? `${dayWindow.since} → ${dayWindow.until}`
|
||||
: 'todo el histórico CRM';
|
||||
|
||||
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">
|
||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||
CRM · Clientify
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{label}
|
||||
{portfolioWide ? ' · portafolio' : ' · zoom cliente'}. Funnel y % a
|
||||
cliente (ganado) por canal · {rangeHint}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarRange className="w-4 h-4 text-gray-500" />
|
||||
<div className="flex gap-1 rounded-lg border border-gray-800 p-0.5 bg-black/20">
|
||||
{(
|
||||
[
|
||||
['7d', '7d'],
|
||||
['30d', '30d'],
|
||||
['month', 'Mes'],
|
||||
['all', 'Todo'],
|
||||
] as [RangeKey, string][]
|
||||
).map(([k, lbl]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setRange(k)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md ${
|
||||
range === k
|
||||
? 'bg-brand-500/20 text-brand-300'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!crmLeads.length ? (
|
||||
<p className="text-sm text-amber-400/90 border border-amber-500/20 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}
|
||||
|
||||
{/* North star */}
|
||||
<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-sky-400" />,
|
||||
},
|
||||
{
|
||||
label: 'Lead frío*',
|
||||
value: formatNum(funnel.leadFrio),
|
||||
icon: <Flame className="w-4 h-4 text-amber-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-800 bg-[#111827] px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between text-gray-500 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-wider">{s.label}</p>
|
||||
{s.icon}
|
||||
</div>
|
||||
<p className="text-xl font-bold text-white">{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-600 -mt-5">
|
||||
*Frío incluye templado / caliente / cold-lead.
|
||||
</p>
|
||||
|
||||
{/* % cliente por canal — paid first */}
|
||||
<section className="bg-[#111827] border border-gray-800 rounded-xl p-5 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
% a cliente por canal
|
||||
</h3>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
Estado Clientify = <span className="text-gray-400">cliente</span>{' '}
|
||||
(ganado). Paid arriba; resto de atribución abajo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{channelRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-6 text-center">
|
||||
Sin canales en el periodo.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto space-y-6">
|
||||
<ChannelTable
|
||||
title="Paid (Meta / Google)"
|
||||
rows={paidChannels}
|
||||
spendByChannel={spendByChannel}
|
||||
/>
|
||||
<ChannelTable
|
||||
title="Resto de canales CRM"
|
||||
rows={otherChannels}
|
||||
spendByChannel={spendByChannel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Funnel statuses */}
|
||||
<section className="bg-[#111827] border border-gray-800 rounded-xl p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-white">Funnel por estado</h3>
|
||||
{statusRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-4 text-center">Sin datos.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-gray-500 border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">Estado</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
||||
<th className="py-2 font-medium">% del total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statusRows.map((row) => (
|
||||
<tr
|
||||
key={row.status}
|
||||
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||
>
|
||||
<td className="py-2.5 pr-3 text-gray-200 capitalize">
|
||||
{row.status}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{formatNum(row.count)}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-200">
|
||||
{row.pct.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="text-[11px] text-gray-600">
|
||||
La inversión por canal (si hay ads) es solo referencia; el % a cliente es
|
||||
100% CRM (Clientify).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelTable({
|
||||
title,
|
||||
rows,
|
||||
spendByChannel,
|
||||
}: {
|
||||
title: string;
|
||||
rows: CrmChannelStats[];
|
||||
spendByChannel: Map<string, number>;
|
||||
}) {
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">Sin filas.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
{title}
|
||||
</p>
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-gray-500 border-b border-gray-800">
|
||||
<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 pr-3 font-medium">En oportunidad</th>
|
||||
<th className="py-2 pr-3 font-medium">Frío*</th>
|
||||
<th className="py-2 pr-3 font-medium">Perdido</th>
|
||||
<th className="py-2 font-medium">Spend ads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const spend = spendByChannel.get(row.channel) || 0;
|
||||
return (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||
>
|
||||
<td className="py-2.5 pr-3 text-brand-300 font-semibold capitalize">
|
||||
{row.channel}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{formatNum(row.leads)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-emerald-300 font-medium">
|
||||
{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 pr-3 text-gray-200">
|
||||
{formatNum(row.enOportunidad)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{formatNum(row.leadFrio)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{formatNum(row.leadPerdido)}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-400">
|
||||
{spend ? formatEur(spend) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user