Selector de fechas en header (con Personalizado) compartido en el dashboard; Métricas con Ventas/CAC/ROAS CRM, cruce canal y gráficas diarias; Seguimiento solo notas + sync Superhuman; Activos con navegación Drive; Social con métricas IG/FB; Orgánico con prioridades SEO/GEO expertas. Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
9.7 KiB
TypeScript
307 lines
9.7 KiB
TypeScript
'use client';
|
|
|
|
import React, { useMemo } from 'react';
|
|
import {
|
|
Users,
|
|
UserCheck,
|
|
Percent,
|
|
Flame,
|
|
UserX,
|
|
Briefcase,
|
|
} from 'lucide-react';
|
|
import {
|
|
AdDailyRow,
|
|
CrmChannelStats,
|
|
CrmLeadRow,
|
|
attributionCross,
|
|
buildCrmChannelStats,
|
|
buildCrmFunnel,
|
|
buildCrmStatusBreakdown,
|
|
filterDailyByDayRange,
|
|
filterLeadsByDayRange,
|
|
formatEur,
|
|
formatNum,
|
|
} from '@/lib/metrics';
|
|
import { PageHeader } from '@/components/ui';
|
|
import { useDateRange } from '@/components/DateRangeContext';
|
|
|
|
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 { since, until, label: rangeHint } = useDateRange();
|
|
|
|
const rangedLeads = useMemo(() => {
|
|
if (!crmLeads.length) return [];
|
|
return filterLeadsByDayRange(crmLeads, since, until);
|
|
}, [crmLeads, since, until]);
|
|
|
|
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).`}
|
|
/>
|
|
|
|
{!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}
|
|
|
|
{/* 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-200 bg-white 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-gray-900">{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-white border border-gray-200 rounded-xl p-5 space-y-3">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-gray-900">
|
|
% 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-white border border-gray-200 rounded-xl p-5 space-y-3">
|
|
<h3 className="text-sm font-semibold text-gray-900">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-200">
|
|
<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-100 hover:bg-gray-50"
|
|
>
|
|
<td className="py-2.5 pr-3 text-gray-800 capitalize">
|
|
{row.status}
|
|
</td>
|
|
<td className="py-2.5 pr-3 text-gray-800">
|
|
{formatNum(row.count)}
|
|
</td>
|
|
<td className="py-2.5 text-gray-800">
|
|
{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-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 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-100 hover:bg-gray-50"
|
|
>
|
|
<td className="py-2.5 pr-3 text-brand-700 font-semibold capitalize">
|
|
{row.channel}
|
|
</td>
|
|
<td className="py-2.5 pr-3 text-gray-800">
|
|
{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-800">
|
|
{formatNum(row.enOportunidad)}
|
|
</td>
|
|
<td className="py-2.5 pr-3 text-gray-800">
|
|
{formatNum(row.leadFrio)}
|
|
</td>
|
|
<td className="py-2.5 pr-3 text-gray-800">
|
|
{formatNum(row.leadPerdido)}
|
|
</td>
|
|
<td className="py-2.5 text-gray-400">
|
|
{spend ? formatEur(spend) : '—'}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|