feat: KPIs con totales absolutos, backfill ads y ventas en cruce canal
Comparativas WoW/MoM/YoY muestran prev/Δ además del %; sync ads admite histórico desde 2025; tabla ads↔CRM añade cierres, ventas y ROAS. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,7 +16,7 @@ type Integ = {
|
||||
meta?: { folder_name?: string; match_score?: number } | null;
|
||||
};
|
||||
|
||||
type Provider = { id: string; label: string; group: string };
|
||||
type Provider = { id: string; label: string; group: string; live?: boolean };
|
||||
|
||||
const PLACEHOLDERS: Record<string, string> = {
|
||||
meta_ads: 'act_… (Ad Account ID)',
|
||||
@@ -219,6 +219,11 @@ export default function ClientIntegrationsPanel({
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{p.label}
|
||||
{p.live === false && (
|
||||
<span className="ml-2 text-[10px] font-normal text-gray-500">
|
||||
(sin conector API aún)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{folderHint && (
|
||||
<p className="text-[11px] text-brand-700 mt-0.5">
|
||||
|
||||
@@ -299,7 +299,7 @@ export default function CreativesPipeline() {
|
||||
setDraft((d) => ({ ...d, brand_name: e.target.value }))
|
||||
}
|
||||
className={inputCls}
|
||||
placeholder="Agua Miia"
|
||||
placeholder="Marca del cliente"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Canal">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
UserCheck,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
AdDailyRow,
|
||||
CrmChannelStats,
|
||||
CrmDateLens,
|
||||
CrmLeadRow,
|
||||
attributionCross,
|
||||
buildCrmChannelStats,
|
||||
@@ -32,6 +33,15 @@ function rateClass(rate: number): string {
|
||||
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,
|
||||
@@ -44,11 +54,19 @@ export default function CrmDashboard({
|
||||
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 [];
|
||||
return filterLeadsByDayRange(crmLeads, since, until);
|
||||
}, [crmLeads, since, until]);
|
||||
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 [];
|
||||
@@ -90,6 +108,43 @@ export default function CrmDashboard({
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
|
||||
/>
|
||||
|
||||
<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
|
||||
@@ -97,7 +152,6 @@ export default function CrmDashboard({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* North star */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
{[
|
||||
{
|
||||
@@ -118,12 +172,12 @@ export default function CrmDashboard({
|
||||
{
|
||||
label: 'En oportunidad',
|
||||
value: formatNum(funnel.enOportunidad),
|
||||
icon: <Briefcase className="w-4 h-4 text-sky-400" />,
|
||||
icon: <Briefcase className="w-4 h-4 text-violet-400" />,
|
||||
},
|
||||
{
|
||||
label: 'Lead frío*',
|
||||
label: 'Lead frío',
|
||||
value: formatNum(funnel.leadFrio),
|
||||
icon: <Flame className="w-4 h-4 text-amber-400" />,
|
||||
icon: <Flame className="w-4 h-4 text-sky-400" />,
|
||||
},
|
||||
{
|
||||
label: 'Lead perdido',
|
||||
@@ -133,65 +187,96 @@ export default function CrmDashboard({
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-3"
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between text-gray-500 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-wider">{s.label}</p>
|
||||
<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">{s.value}</p>
|
||||
<p className="text-xl font-bold text-gray-900 tabular-nums">
|
||||
{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">
|
||||
<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-[11px] text-gray-500 mt-0.5">
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
Estado Clientify = <span className="text-gray-400">cliente</span>{' '}
|
||||
(ganado). Paid arriba; resto de atribución abajo.
|
||||
(ganado). Ads spend del periodo es referencia.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{channelRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-6 text-center">
|
||||
Sin canales en el periodo.
|
||||
{!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 space-y-6">
|
||||
<ChannelTable
|
||||
title="Paid (Meta / Google)"
|
||||
rows={paidChannels}
|
||||
spendByChannel={spendByChannel}
|
||||
/>
|
||||
<ChannelTable
|
||||
title="Resto de canales CRM"
|
||||
rows={otherChannels}
|
||||
spendByChannel={spendByChannel}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<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-xs">
|
||||
<thead className="text-gray-500 border-b border-gray-200">
|
||||
<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">Estado</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
||||
<th className="py-2 font-medium">% del total</th>
|
||||
<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>
|
||||
@@ -200,13 +285,11 @@ export default function CrmDashboard({
|
||||
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">
|
||||
<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 text-gray-800">
|
||||
<td className="py-2.5 tabular-nums text-gray-600">
|
||||
{row.pct.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
@@ -216,91 +299,6 @@ export default function CrmDashboard({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
isoYesterday,
|
||||
} from '@/lib/ad-tracker-range';
|
||||
|
||||
const STORAGE_KEY = '4v_global_date_range_v1';
|
||||
const STORAGE_KEY = '4v_global_date_range_v2';
|
||||
|
||||
type Stored = {
|
||||
preset: RangePreset;
|
||||
@@ -38,24 +38,24 @@ const DateRangeContext = createContext<Ctx | null>(null);
|
||||
function loadStored(): Stored {
|
||||
const y = isoYesterday();
|
||||
if (typeof window === 'undefined') {
|
||||
return { preset: 'last_7d', customSince: y, customUntil: y };
|
||||
return { preset: 'last_30d', customSince: y, customUntil: y };
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { preset: 'last_7d', customSince: y, customUntil: y };
|
||||
if (!raw) return { preset: 'last_30d', customSince: y, customUntil: y };
|
||||
const j = JSON.parse(raw) as Stored;
|
||||
return {
|
||||
preset: j.preset || 'last_7d',
|
||||
preset: j.preset || 'last_30d',
|
||||
customSince: j.customSince || y,
|
||||
customUntil: j.customUntil || y,
|
||||
};
|
||||
} catch {
|
||||
return { preset: 'last_7d', customSince: y, customUntil: y };
|
||||
return { preset: 'last_30d', customSince: y, customUntil: y };
|
||||
}
|
||||
}
|
||||
|
||||
export function DateRangeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [preset, setPresetState] = useState<RangePreset>('last_7d');
|
||||
const [preset, setPresetState] = useState<RangePreset>('last_30d');
|
||||
const [customSince, setCustomSince] = useState('');
|
||||
const [customUntil, setCustomUntil] = useState('');
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
formatNum,
|
||||
formatPct,
|
||||
attributionCross,
|
||||
buildRangeDeltas,
|
||||
filterDailyByDayRange,
|
||||
filterDealsForRange,
|
||||
filterLeadsByDayRange,
|
||||
@@ -50,22 +51,31 @@ function DeltaTriple({
|
||||
delta,
|
||||
invert,
|
||||
empty,
|
||||
format,
|
||||
current,
|
||||
}: {
|
||||
delta: Delta;
|
||||
invert?: boolean;
|
||||
empty?: boolean;
|
||||
format: (v: number) => string;
|
||||
current: number;
|
||||
}) {
|
||||
if (empty) {
|
||||
return (
|
||||
<p className="mt-3 text-[11px] text-gray-500 border border-dashed border-gray-200 rounded-md px-2 py-2 text-center">
|
||||
Sin cierres en el periodo — sincroniza deals Clientify
|
||||
Sin cierres en este rango — amplía el periodo o revisa deals ganados en
|
||||
Clientify
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const cells: { label: string; v: number | null }[] = [
|
||||
{ label: 'WoW', v: delta.wow },
|
||||
{ label: 'MoM', v: delta.mom },
|
||||
{ label: 'YoY', v: delta.yoy },
|
||||
const cells: {
|
||||
label: string;
|
||||
v: number | null;
|
||||
base: number | null;
|
||||
}[] = [
|
||||
{ label: 'WoW', v: delta.wow, base: delta.wowBase },
|
||||
{ label: 'MoM', v: delta.mom, base: delta.momBase },
|
||||
{ label: 'YoY', v: delta.yoy, base: delta.yoyBase },
|
||||
];
|
||||
return (
|
||||
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
||||
@@ -85,6 +95,9 @@ function DeltaTriple({
|
||||
: good
|
||||
? ArrowUpRight
|
||||
: ArrowDownRight;
|
||||
const hasBase = c.base !== null && !(c.base === 0 && c.v === null);
|
||||
const absDelta =
|
||||
hasBase && c.base !== null ? current - c.base : null;
|
||||
return (
|
||||
<div
|
||||
key={c.label}
|
||||
@@ -99,6 +112,27 @@ function DeltaTriple({
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{formatPct(c.v)}
|
||||
</p>
|
||||
{hasBase ? (
|
||||
<>
|
||||
<p className="text-[11px] font-semibold text-gray-800 mt-1 tabular-nums leading-tight">
|
||||
prev {format(c.base ?? 0)}
|
||||
</p>
|
||||
{absDelta !== null && (
|
||||
<p
|
||||
className={`text-[10px] font-medium tabular-nums leading-tight ${color}`}
|
||||
>
|
||||
Δ{' '}
|
||||
{absDelta > 0
|
||||
? `+${format(absDelta)}`
|
||||
: format(absDelta)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[10px] text-gray-500 mt-1 leading-tight">
|
||||
sin histórico
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -127,6 +161,7 @@ export default function MetricsDashboard({
|
||||
dailyRows = [],
|
||||
crmLeads = [],
|
||||
wonDeals = [],
|
||||
cohortSource = 'deals',
|
||||
}: {
|
||||
label: string;
|
||||
portfolioWide: boolean;
|
||||
@@ -145,6 +180,7 @@ export default function MetricsDashboard({
|
||||
dailyRows?: AdDailyRow[];
|
||||
crmLeads?: CrmLeadRow[];
|
||||
wonDeals?: DealWonRow[];
|
||||
cohortSource?: 'deals' | 'cliente_leads' | 'none';
|
||||
}) {
|
||||
const { since, until, label: rangeHint } = useDateRange();
|
||||
|
||||
@@ -178,9 +214,15 @@ export default function MetricsDashboard({
|
||||
return withCohortFromDeals(base, rangedDeals);
|
||||
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]);
|
||||
|
||||
/** Comparatives relative to the selected range (not “today”). */
|
||||
const rangeDeltas = useMemo(
|
||||
() => buildRangeDeltas(dailyRows, wonDeals, since, until),
|
||||
[dailyRows, wonDeals, since, until]
|
||||
);
|
||||
|
||||
const attributionRows: AttributionChannelRow[] = useMemo(() => {
|
||||
if (rangedDaily.length || rangedLeads.length) {
|
||||
return attributionCross(rangedDaily, rangedLeads);
|
||||
if (rangedDaily.length || rangedLeads.length || rangedDeals.length) {
|
||||
return attributionCross(rangedDaily, rangedLeads, rangedDeals);
|
||||
}
|
||||
const map = new Map<string, { spend: number; leads: number }>();
|
||||
for (const p of channelPoints) {
|
||||
@@ -200,14 +242,42 @@ export default function MetricsDashboard({
|
||||
gap: -v.leads,
|
||||
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
|
||||
cplCrm: 0,
|
||||
cierres: 0,
|
||||
ventas: 0,
|
||||
roas: 0,
|
||||
cac: 0,
|
||||
}))
|
||||
.sort((a, b) => b.spend - a.spend);
|
||||
}, [rangedDaily, rangedLeads, channelPoints, since, until]);
|
||||
}, [rangedDaily, rangedLeads, rangedDeals, channelPoints, since, until]);
|
||||
|
||||
const attributionTotals = useMemo(() => {
|
||||
return attributionRows.reduce(
|
||||
(a, r) => ({
|
||||
spend: a.spend + r.spend,
|
||||
platformLeads: a.platformLeads + r.platformLeads,
|
||||
crmLeads: a.crmLeads + r.crmLeads,
|
||||
gap: a.gap + r.gap,
|
||||
cierres: a.cierres + r.cierres,
|
||||
ventas: a.ventas + r.ventas,
|
||||
}),
|
||||
{
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
gap: 0,
|
||||
cierres: 0,
|
||||
ventas: 0,
|
||||
}
|
||||
);
|
||||
}, [attributionRows]);
|
||||
|
||||
const crmPeriodTotal = rangedLeads.length;
|
||||
const hasCrm = crmLeads.length > 0;
|
||||
const hasCohort = rangedDeals.length > 0;
|
||||
const cierres = periodTotals.deals;
|
||||
const cohortFromLeads = cohortSource === 'cliente_leads';
|
||||
const revenueMissing =
|
||||
hasCohort && periodTotals.revenue <= 0 && cohortFromLeads;
|
||||
|
||||
const cards: Card[] = [
|
||||
{
|
||||
@@ -236,8 +306,10 @@ export default function MetricsDashboard({
|
||||
key: 'revenue',
|
||||
label: 'Ventas (CRM)',
|
||||
hint: hasCohort
|
||||
? `${formatNum(cierres)} deals ganados · importe Clientify`
|
||||
: 'Deals won Clientify en el periodo',
|
||||
? cohortFromLeads
|
||||
? `${formatNum(cierres)} clientes Clientify · importe no en webhook`
|
||||
: `${formatNum(cierres)} deals ganados · importe Clientify`
|
||||
: 'Deals won / status cliente Clientify en el periodo',
|
||||
format: formatEur,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
|
||||
cohort: true,
|
||||
@@ -246,7 +318,7 @@ export default function MetricsDashboard({
|
||||
key: 'cac',
|
||||
label: 'CAC',
|
||||
hint: hasCohort
|
||||
? `Inversión ads ÷ ${formatNum(cierres)} ventas`
|
||||
? `Inversión ads ÷ ${formatNum(cierres)} ${cohortFromLeads ? 'clientes' : 'ventas'}`
|
||||
: 'Inversión ÷ deals ganados (no leads)',
|
||||
format: (v) => `${formatNum(v)} €`,
|
||||
icon: <Target className="w-4 h-4 text-amber-600" />,
|
||||
@@ -256,7 +328,9 @@ export default function MetricsDashboard({
|
||||
{
|
||||
key: 'roas',
|
||||
label: 'ROAS ventas',
|
||||
hint: 'Ventas CRM ÷ inversión ads',
|
||||
hint: revenueMissing
|
||||
? 'Falta importe en CRM (API BPS) para ROAS'
|
||||
: `Importe deals ganados Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`,
|
||||
format: (v) => `${formatNum(v)}x`,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
|
||||
cohort: true,
|
||||
@@ -287,7 +361,11 @@ export default function MetricsDashboard({
|
||||
const worstGap = [...attributionRows].sort(
|
||||
(a, b) => Math.abs(b.gap) - Math.abs(a.gap)
|
||||
)[0];
|
||||
return { top, worstGap };
|
||||
const platTotal = attributionRows.reduce((a, r) => a + r.platformLeads, 0);
|
||||
const crmTotal = attributionRows.reduce((a, r) => a + r.crmLeads, 0);
|
||||
const crmPaid = paid.reduce((a, r) => a + r.crmLeads, 0);
|
||||
const crmOther = crmTotal - crmPaid;
|
||||
return { top, worstGap, platTotal, crmTotal, crmPaid, crmOther };
|
||||
}, [attributionRows]);
|
||||
|
||||
return (
|
||||
@@ -307,8 +385,20 @@ export default function MetricsDashboard({
|
||||
|
||||
{!hasCohort && (
|
||||
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2">
|
||||
Ventas / CAC / ROAS vacíos: no hay deals ganados (Clientify) con
|
||||
fecha de cierre en este periodo. El gasto de ads no cuenta como venta.
|
||||
Ventas / CAC / ROAS vacíos en <strong>{rangeHint}</strong>: no hay
|
||||
deals ganados ni contactos con status «cliente» en este rango
|
||||
{wonDeals.length
|
||||
? ` (sí hay ${formatNum(wonDeals.length)} en el histórico — prueba «30 días» o «Mes»).`
|
||||
: '.'}{' '}
|
||||
El gasto de ads no cuenta como venta.
|
||||
</p>
|
||||
)}
|
||||
{cohortFromLeads && hasCohort && (
|
||||
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
|
||||
Cohorte desde contactos Clientify status «cliente» (no hay deals
|
||||
ganados sincronizados). Importe € incompleto hasta conectar la API
|
||||
Clientify de este tenant. Meta pixel reporta compras en el periodo;
|
||||
el CRM marca clientes sin amount en el webhook.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -350,7 +440,13 @@ export default function MetricsDashboard({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{cards.map((c) => {
|
||||
const emptyCohort = Boolean(c.cohort) && !hasCohort;
|
||||
const value = emptyCohort ? '—' : c.format(periodTotals[c.key]);
|
||||
const showClientesCount =
|
||||
c.key === 'revenue' && revenueMissing && !emptyCohort;
|
||||
const value = emptyCohort
|
||||
? '—'
|
||||
: showClientesCount
|
||||
? `${formatNum(cierres)} clientes`
|
||||
: c.format(periodTotals[c.key]);
|
||||
return (
|
||||
<div
|
||||
key={c.key}
|
||||
@@ -367,9 +463,11 @@ export default function MetricsDashboard({
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">{c.hint}</p>
|
||||
<DeltaTriple
|
||||
delta={deltas[c.key]}
|
||||
delta={rangeDeltas[c.key] ?? deltas[c.key]}
|
||||
invert={c.invert}
|
||||
empty={emptyCohort}
|
||||
format={c.format}
|
||||
current={periodTotals[c.key]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -383,16 +481,9 @@ export default function MetricsDashboard({
|
||||
Cruce por canal · ads vs CRM
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
Gasto y leads de plataforma vs leads CRM (como en{' '}
|
||||
<a
|
||||
href="https://mide-analytics-reports.vercel.app/como-combinamos-datos.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand-600 underline"
|
||||
>
|
||||
combinación de datos
|
||||
</a>
|
||||
): el coste vive en ads; el resultado de negocio en CRM/GA.
|
||||
Gasto y conversiones de ads vs altas / cierres / ventas CRM. Las
|
||||
ventas de deals sin fuente de contacto aparecen en{' '}
|
||||
<span className="font-medium">sin-atribuir</span>.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 shrink-0 font-medium">
|
||||
@@ -401,16 +492,17 @@ export default function MetricsDashboard({
|
||||
</div>
|
||||
{channelInsight && (
|
||||
<p className="text-xs text-gray-800 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
|
||||
<strong>Resumen:</strong> más inversión en{' '}
|
||||
<span className="capitalize font-semibold">
|
||||
{channelInsight.top.channel}
|
||||
</span>{' '}
|
||||
({formatEur(channelInsight.top.spend)}). Mayor desfase ads↔CRM en{' '}
|
||||
<strong>Resumen:</strong> plat {formatNum(channelInsight.platTotal)}{' '}
|
||||
vs CRM {formatNum(channelInsight.crmTotal)} (gap{' '}
|
||||
{channelInsight.crmTotal - channelInsight.platTotal > 0 ? '+' : ''}
|
||||
{formatNum(channelInsight.crmTotal - channelInsight.platTotal)}). De
|
||||
los CRM, {formatNum(channelInsight.crmPaid)} en canales con gasto
|
||||
ads y {formatNum(channelInsight.crmOther)} en otras fuentes
|
||||
(integrations, ecommerce, messenger…). Mayor desfase en{' '}
|
||||
<span className="capitalize font-semibold">
|
||||
{channelInsight.worstGap.channel}
|
||||
</span>{' '}
|
||||
(gap {channelInsight.worstGap.gap > 0 ? '+' : ''}
|
||||
{formatNum(channelInsight.worstGap.gap)}).
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{attributionRows.length === 0 ? (
|
||||
@@ -427,6 +519,9 @@ export default function MetricsDashboard({
|
||||
<th className="py-2 pr-3 font-semibold">Leads plat</th>
|
||||
<th className="py-2 pr-3 font-semibold">Leads CRM</th>
|
||||
<th className="py-2 pr-3 font-semibold">Gap</th>
|
||||
<th className="py-2 pr-3 font-semibold">Cierres</th>
|
||||
<th className="py-2 pr-3 font-semibold">Ventas</th>
|
||||
<th className="py-2 pr-3 font-semibold">ROAS</th>
|
||||
<th className="py-2 pr-3 font-semibold">CPL plat</th>
|
||||
<th className="py-2 font-semibold">CPL CRM</th>
|
||||
</tr>
|
||||
@@ -469,6 +564,15 @@ export default function MetricsDashboard({
|
||||
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{hasCrm ? formatNum(row.cierres) : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-900 font-semibold tabular-nums">
|
||||
{row.ventas > 0 ? formatEur(row.ventas) : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{row.cplPlatform
|
||||
? `${formatNum(row.cplPlatform)} €`
|
||||
@@ -482,6 +586,70 @@ export default function MetricsDashboard({
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{attributionRows.length > 0 && (
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td className="py-2.5 pr-3 text-gray-900">Total</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
||||
{attributionTotals.spend
|
||||
? formatEur(attributionTotals.spend)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{formatNum(attributionTotals.platformLeads)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{hasCrm ? formatNum(attributionTotals.crmLeads) : '—'}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2.5 pr-3 tabular-nums ${
|
||||
hasCrm
|
||||
? gapClass(attributionTotals.gap)
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{hasCrm
|
||||
? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{hasCrm ? formatNum(attributionTotals.cierres) : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
||||
{attributionTotals.ventas > 0 || periodTotals.revenue > 0
|
||||
? formatEur(
|
||||
attributionTotals.ventas || periodTotals.revenue
|
||||
)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{attributionTotals.spend > 0 &&
|
||||
(attributionTotals.ventas || periodTotals.revenue) > 0
|
||||
? `${formatNum(
|
||||
(attributionTotals.ventas ||
|
||||
periodTotals.revenue) / attributionTotals.spend
|
||||
)}x`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{attributionTotals.platformLeads > 0
|
||||
? `${formatNum(
|
||||
attributionTotals.spend /
|
||||
attributionTotals.platformLeads
|
||||
)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 tabular-nums">
|
||||
{hasCrm && attributionTotals.crmLeads > 0
|
||||
? `${formatNum(
|
||||
attributionTotals.spend /
|
||||
attributionTotals.crmLeads
|
||||
)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
@@ -498,13 +666,13 @@ export default function MetricsDashboard({
|
||||
{dailyChart.map((d) => (
|
||||
<div
|
||||
key={`s-${d.date}`}
|
||||
className="flex-1 min-w-0"
|
||||
className="flex-1 min-w-0 h-full flex items-end"
|
||||
title={`${d.date}: ${formatEur(d.spend)}`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-brand-400/80 hover:bg-brand-500"
|
||||
style={{
|
||||
height: `${Math.max(4, (d.spend / maxSpend) * 100)}%`,
|
||||
height: `${Math.max(4, (d.spend / maxSpend) * 80)}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -523,13 +691,18 @@ export default function MetricsDashboard({
|
||||
{dailyChart.map((d) => (
|
||||
<div
|
||||
key={`l-${d.date}`}
|
||||
className="flex-1 min-w-0"
|
||||
className="flex-1 min-w-0 h-full flex items-end"
|
||||
title={`${d.date}: ${formatNum(d.leads)} leads`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-sky-400/80 hover:bg-sky-500"
|
||||
style={{
|
||||
height: `${Math.max(4, (d.leads / Math.max(1, ...dailyChart.map((x) => x.leads))) * 100)}%`,
|
||||
height: `${Math.max(
|
||||
4,
|
||||
(d.leads /
|
||||
Math.max(1, ...dailyChart.map((x) => x.leads))) *
|
||||
80
|
||||
)}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user