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:
@@ -16,14 +16,17 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AdDailyRow,
|
||||
AttributionChannelRow,
|
||||
ChannelWeekPoint,
|
||||
CrmLeadRow,
|
||||
Delta,
|
||||
MetricTotals,
|
||||
formatEur,
|
||||
formatNum,
|
||||
formatPct,
|
||||
channelTotalsFromDaily,
|
||||
attributionCross,
|
||||
filterDailyByDayRange,
|
||||
filterLeadsByDayRange,
|
||||
metricTotalsFromDaily,
|
||||
resolveKpiDayRange,
|
||||
sumWeekly,
|
||||
@@ -88,6 +91,14 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function gapClass(gap: number): string {
|
||||
if (gap === 0) return 'text-gray-400';
|
||||
const abs = Math.abs(gap);
|
||||
if (abs <= 5) return 'text-emerald-400';
|
||||
if (abs <= 25) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
|
||||
export default function MetricsDashboard({
|
||||
label,
|
||||
portfolioWide,
|
||||
@@ -99,6 +110,7 @@ export default function MetricsDashboard({
|
||||
weeklyRows = [],
|
||||
channelPoints = [],
|
||||
dailyRows = [],
|
||||
crmLeads = [],
|
||||
}: {
|
||||
label: string;
|
||||
portfolioWide: boolean;
|
||||
@@ -116,6 +128,8 @@ export default function MetricsDashboard({
|
||||
channelPoints?: ChannelWeekPoint[];
|
||||
/** Raw ad_daily — period totals/matrix use calendar days (same as Ad Tracker). */
|
||||
dailyRows?: AdDailyRow[];
|
||||
/** Clientify leads for CRM vs platform attribution. */
|
||||
crmLeads?: CrmLeadRow[];
|
||||
}) {
|
||||
const [range, setRange] = useState<RangeKey>('7d');
|
||||
|
||||
@@ -127,6 +141,12 @@ export default function MetricsDashboard({
|
||||
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
|
||||
}, [dailyRows, dayWindow]);
|
||||
|
||||
const rangedLeads = useMemo(() => {
|
||||
if (!crmLeads.length) return [];
|
||||
if (!dayWindow) return crmLeads;
|
||||
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until);
|
||||
}, [crmLeads, dayWindow]);
|
||||
|
||||
const periodTotals = useMemo(() => {
|
||||
if (rangedDaily.length) return metricTotalsFromDaily(rangedDaily);
|
||||
// Fallback when no ad_daily: approximate from weekly (legacy)
|
||||
@@ -141,12 +161,12 @@ export default function MetricsDashboard({
|
||||
return ranged.length ? sumWeekly(ranged) : month;
|
||||
}, [rangedDaily, weeklyRows, dayWindow, month]);
|
||||
|
||||
const channelRows = useMemo(() => {
|
||||
if (rangedDaily.length) return channelTotalsFromDaily(rangedDaily);
|
||||
const map = new Map<
|
||||
string,
|
||||
{ spend: number; leads: number; deals: number; revenue: number }
|
||||
>();
|
||||
const attributionRows: AttributionChannelRow[] = useMemo(() => {
|
||||
if (rangedDaily.length || rangedLeads.length) {
|
||||
return attributionCross(rangedDaily, rangedLeads);
|
||||
}
|
||||
// Fallback: weekly series without CRM cross
|
||||
const map = new Map<string, { spend: number; leads: number }>();
|
||||
const src =
|
||||
range === 'all'
|
||||
? channelPoints
|
||||
@@ -156,27 +176,26 @@ export default function MetricsDashboard({
|
||||
return d >= dayWindow.since && d <= dayWindow.until;
|
||||
});
|
||||
for (const p of src) {
|
||||
const cur = map.get(p.channel) || {
|
||||
spend: 0,
|
||||
leads: 0,
|
||||
deals: 0,
|
||||
revenue: 0,
|
||||
};
|
||||
const cur = map.get(p.channel) || { spend: 0, leads: 0 };
|
||||
cur.spend += p.spend;
|
||||
cur.leads += p.leads;
|
||||
cur.deals += p.deals;
|
||||
cur.revenue += p.revenue;
|
||||
map.set(p.channel, cur);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([channel, v]) => ({
|
||||
channel,
|
||||
...v,
|
||||
cpl: v.leads > 0 ? v.spend / v.leads : 0,
|
||||
roas: v.spend > 0 ? v.revenue / v.spend : 0,
|
||||
spend: v.spend,
|
||||
platformLeads: v.leads,
|
||||
crmLeads: 0,
|
||||
gap: -v.leads,
|
||||
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
|
||||
cplCrm: 0,
|
||||
}))
|
||||
.sort((a, b) => b.spend - a.spend);
|
||||
}, [rangedDaily, channelPoints, range, dayWindow]);
|
||||
}, [rangedDaily, rangedLeads, channelPoints, range, dayWindow]);
|
||||
|
||||
const crmPeriodTotal = rangedLeads.length;
|
||||
const hasCrm = crmLeads.length > 0;
|
||||
|
||||
const rangeHint = dayWindow
|
||||
? `${dayWindow.since} → ${dayWindow.until}`
|
||||
@@ -283,18 +302,22 @@ export default function MetricsDashboard({
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Inversión periodo', value: formatEur(periodTotals.spend) },
|
||||
{ label: 'Leads', value: formatNum(periodTotals.leads) },
|
||||
{
|
||||
label: 'CAC (paid)',
|
||||
value: periodTotals.cac
|
||||
? `${formatNum(periodTotals.cac)} €`
|
||||
: '—',
|
||||
label: 'Leads plataforma',
|
||||
value: formatNum(periodTotals.leads),
|
||||
},
|
||||
{
|
||||
label: 'ROAS cohorte',
|
||||
value: periodTotals.roas
|
||||
? `${formatNum(periodTotals.roas)}x`
|
||||
: '—',
|
||||
label: 'Leads CRM',
|
||||
value: hasCrm ? formatNum(crmPeriodTotal) : '—',
|
||||
},
|
||||
{
|
||||
label: 'CPL CRM (paid)',
|
||||
value:
|
||||
hasCrm && crmPeriodTotal > 0
|
||||
? `${formatNum(periodTotals.spend / crmPeriodTotal)} €`
|
||||
: periodTotals.cpl
|
||||
? `${formatNum(periodTotals.cpl)} €`
|
||||
: '—',
|
||||
},
|
||||
].map((s) => (
|
||||
<div
|
||||
@@ -330,17 +353,26 @@ export default function MetricsDashboard({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Channel matrix */}
|
||||
{/* Channel attribution cross */}
|
||||
<section className="bg-[#111827] border border-gray-800 rounded-xl p-5 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Matriz por canal
|
||||
</h3>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Clic → dash secundario del canal
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Cruce por canal · ads vs CRM
|
||||
</h3>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
Plataforma = leads reportados por Meta/Google · CRM = atribución
|
||||
Clientify (origen/UTM)
|
||||
{!hasCrm && portfolioWide
|
||||
? ' · elige un cliente en Contexto para ver CRM'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 shrink-0">
|
||||
Gap = CRM − plat
|
||||
</p>
|
||||
</div>
|
||||
{channelRows.length === 0 ? (
|
||||
{attributionRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-6 text-center">
|
||||
Sin series de canal en el periodo. Los canales Meta/Google aparecen
|
||||
cuando hay filas en ad_daily para este cliente (elige uno en
|
||||
@@ -353,40 +385,60 @@ export default function MetricsDashboard({
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">Canal</th>
|
||||
<th className="py-2 pr-3 font-medium">Inversión</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
||||
<th className="py-2 pr-3 font-medium">CPL</th>
|
||||
<th className="py-2 pr-3 font-medium">Revenue</th>
|
||||
<th className="py-2 font-medium">ROAS</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads plat</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads CRM</th>
|
||||
<th className="py-2 pr-3 font-medium">Gap</th>
|
||||
<th className="py-2 pr-3 font-medium">CPL plat</th>
|
||||
<th className="py-2 font-medium">CPL CRM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{channelRows.map((row) => (
|
||||
{attributionRows.map((row) => (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||
>
|
||||
<td className="py-2.5 pr-3">
|
||||
<Link
|
||||
href={`/dashboard/kpis/canal/${encodeURIComponent(row.channel)}`}
|
||||
className="text-brand-400 hover:text-brand-300 font-semibold capitalize"
|
||||
>
|
||||
{row.channel}
|
||||
</Link>
|
||||
{row.spend > 0 ? (
|
||||
<Link
|
||||
href={`/dashboard/kpis/canal/${encodeURIComponent(row.channel)}`}
|
||||
className="text-brand-400 hover:text-brand-300 font-semibold capitalize"
|
||||
>
|
||||
{row.channel}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-gray-300 font-semibold capitalize">
|
||||
{row.channel}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{formatEur(row.spend)}
|
||||
{row.spend ? formatEur(row.spend) : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{formatNum(row.leads)}
|
||||
{formatNum(row.platformLeads)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{row.cpl ? `${formatNum(row.cpl)} €` : '—'}
|
||||
{hasCrm ? formatNum(row.crmLeads) : '—'}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2.5 pr-3 font-medium ${
|
||||
hasCrm ? gapClass(row.gap) : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{hasCrm
|
||||
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-200">
|
||||
{formatEur(row.revenue)}
|
||||
{row.cplPlatform
|
||||
? `${formatNum(row.cplPlatform)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-200">
|
||||
{row.roas ? `${formatNum(row.roas)}x` : '—'}
|
||||
{hasCrm && row.cplCrm
|
||||
? `${formatNum(row.cplCrm)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -430,13 +482,17 @@ export default function MetricsDashboard({
|
||||
</p>
|
||||
<ul className="text-xs text-gray-400 space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
El selector de fechas filtra inversión/leads del periodo; WoW/MoM/YoY
|
||||
siguen siendo comparativas de calendario.
|
||||
El selector filtra el mismo calendario que Ad Tracker; el cruce
|
||||
compara leads de la plataforma ads vs atribución Clientify.
|
||||
</li>
|
||||
<li>
|
||||
CAC unificado con fee/tools y cierre CRM llega en v2.
|
||||
Gap cercano a 0 en Meta/Google = buena atribución. Canales sin
|
||||
spend (whatsapp, manual…) son orgánicos/CRM.
|
||||
</li>
|
||||
<li>
|
||||
CPL CRM usa inversión paid ÷ leads CRM del canal (más realista
|
||||
que el CPL de la plataforma cuando hay desfase).
|
||||
</li>
|
||||
<li>GEO / LTV aparecen como stubs hasta tener pipeline de datos.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user