fix: KPIs — ocultar WoW >7d, YoY con histórico completo, ventas en nº
Pagina ad_daily (~420d) para que YoY no quede vacío; Ventas muestra conteo de deals (importe Clientify en hint/columna Importe). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,6 +20,55 @@ import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agenc
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** Enough history for YoY on ad-level Meta rows (can be dense). */
|
||||||
|
const AD_DAILY_LOOKBACK_DAYS = 420;
|
||||||
|
const AD_DAILY_PAGE = 1000;
|
||||||
|
|
||||||
|
async function fetchAdDaily(
|
||||||
|
tenantId: string | null,
|
||||||
|
useServiceRole: boolean
|
||||||
|
): Promise<AdDailyRow[]> {
|
||||||
|
const since = new Date();
|
||||||
|
since.setUTCDate(since.getUTCDate() - AD_DAILY_LOOKBACK_DAYS);
|
||||||
|
const sinceStr = since.toISOString().slice(0, 10);
|
||||||
|
const out: AdDailyRow[] = [];
|
||||||
|
|
||||||
|
if (useServiceRole) {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) return [];
|
||||||
|
const tidFilter = tenantId
|
||||||
|
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||||
|
: '';
|
||||||
|
for (let offset = 0; offset < 200000; offset += AD_DAILY_PAGE) {
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/ad_daily?select=*&date=gte.${sinceStr}${tidFilter}&order=date.desc&limit=${AD_DAILY_PAGE}&offset=${offset}`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) break;
|
||||||
|
const chunk = (await r.json()) as AdDailyRow[];
|
||||||
|
out.push(...chunk);
|
||||||
|
if (chunk.length < AD_DAILY_PAGE) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
for (let offset = 0; offset < 200000; offset += AD_DAILY_PAGE) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('ad_daily')
|
||||||
|
.select('*')
|
||||||
|
.gte('date', sinceStr)
|
||||||
|
.order('date', { ascending: false })
|
||||||
|
.range(offset, offset + AD_DAILY_PAGE - 1);
|
||||||
|
if (error) break;
|
||||||
|
const chunk = (data as AdDailyRow[]) || [];
|
||||||
|
out.push(...chunk);
|
||||||
|
if (chunk.length < AD_DAILY_PAGE) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Paginate CRM leads (source + created_at + status) for attribution + cohort. */
|
/** Paginate CRM leads (source + created_at + status) for attribution + cohort. */
|
||||||
async function fetchCrmLeads(
|
async function fetchCrmLeads(
|
||||||
tenantId: string | null,
|
tenantId: string | null,
|
||||||
@@ -148,11 +197,8 @@ export default async function KpisPage() {
|
|||||||
const w = await restGet<KpiWeeklyRow[]>(
|
const w = await restGet<KpiWeeklyRow[]>(
|
||||||
'/rest/v1/kpi_weekly?select=*&order=week.desc'
|
'/rest/v1/kpi_weekly?select=*&order=week.desc'
|
||||||
);
|
);
|
||||||
const d = await restGet<AdDailyRow[]>(
|
|
||||||
'/rest/v1/ad_daily?select=*&order=date.desc&limit=5000'
|
|
||||||
);
|
|
||||||
weekly = w.data || [];
|
weekly = w.data || [];
|
||||||
daily = d.data || [];
|
daily = await fetchAdDaily(null, true);
|
||||||
crmLeads = [];
|
crmLeads = [];
|
||||||
wonDeals = await fetchWonDeals(null, true);
|
wonDeals = await fetchWonDeals(null, true);
|
||||||
} else if (ctx.tenantId) {
|
} else if (ctx.tenantId) {
|
||||||
@@ -160,11 +206,8 @@ export default async function KpisPage() {
|
|||||||
const w = await restGet<KpiWeeklyRow[]>(
|
const w = await restGet<KpiWeeklyRow[]>(
|
||||||
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc`
|
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc`
|
||||||
);
|
);
|
||||||
const d = await restGet<AdDailyRow[]>(
|
|
||||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=5000`
|
|
||||||
);
|
|
||||||
weekly = w.data || [];
|
weekly = w.data || [];
|
||||||
daily = d.data || [];
|
daily = await fetchAdDaily(ctx.tenantId, true);
|
||||||
crmLeads = await fetchCrmLeads(ctx.tenantId, true);
|
crmLeads = await fetchCrmLeads(ctx.tenantId, true);
|
||||||
wonDeals = await fetchWonDeals(ctx.tenantId, true);
|
wonDeals = await fetchWonDeals(ctx.tenantId, true);
|
||||||
}
|
}
|
||||||
@@ -173,13 +216,8 @@ export default async function KpisPage() {
|
|||||||
.from('kpi_weekly')
|
.from('kpi_weekly')
|
||||||
.select('*')
|
.select('*')
|
||||||
.order('week', { ascending: false });
|
.order('week', { ascending: false });
|
||||||
const { data: d } = await supabase
|
|
||||||
.from('ad_daily')
|
|
||||||
.select('*')
|
|
||||||
.order('date', { ascending: false })
|
|
||||||
.limit(5000);
|
|
||||||
weekly = (w as KpiWeeklyRow[]) || [];
|
weekly = (w as KpiWeeklyRow[]) || [];
|
||||||
daily = (d as AdDailyRow[]) || [];
|
daily = await fetchAdDaily(ctx.tenantId, false);
|
||||||
crmLeads = await fetchCrmLeads(ctx.tenantId, false);
|
crmLeads = await fetchCrmLeads(ctx.tenantId, false);
|
||||||
wonDeals = await fetchWonDeals(ctx.tenantId, false);
|
wonDeals = await fetchWonDeals(ctx.tenantId, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
filterDealsForRange,
|
filterDealsForRange,
|
||||||
filterLeadsByDayRange,
|
filterLeadsByDayRange,
|
||||||
metricTotalsFromDaily,
|
metricTotalsFromDaily,
|
||||||
|
rangeDaySpan,
|
||||||
sumWeekly,
|
sumWeekly,
|
||||||
withCohortFromDeals,
|
withCohortFromDeals,
|
||||||
KpiWeeklyRow,
|
KpiWeeklyRow,
|
||||||
@@ -53,12 +54,15 @@ function DeltaTriple({
|
|||||||
empty,
|
empty,
|
||||||
format,
|
format,
|
||||||
current,
|
current,
|
||||||
|
showWow,
|
||||||
}: {
|
}: {
|
||||||
delta: Delta;
|
delta: Delta;
|
||||||
invert?: boolean;
|
invert?: boolean;
|
||||||
empty?: boolean;
|
empty?: boolean;
|
||||||
format: (v: number) => string;
|
format: (v: number) => string;
|
||||||
current: number;
|
current: number;
|
||||||
|
/** Hide WoW when selected range is longer than a week. */
|
||||||
|
showWow: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (empty) {
|
if (empty) {
|
||||||
return (
|
return (
|
||||||
@@ -73,12 +77,18 @@ function DeltaTriple({
|
|||||||
v: number | null;
|
v: number | null;
|
||||||
base: number | null;
|
base: number | null;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ label: 'WoW', v: delta.wow, base: delta.wowBase },
|
...(showWow
|
||||||
|
? [{ label: 'WoW', v: delta.wow, base: delta.wowBase }]
|
||||||
|
: []),
|
||||||
{ label: 'MoM', v: delta.mom, base: delta.momBase },
|
{ label: 'MoM', v: delta.mom, base: delta.momBase },
|
||||||
{ label: 'YoY', v: delta.yoy, base: delta.yoyBase },
|
{ label: 'YoY', v: delta.yoy, base: delta.yoyBase },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
<div
|
||||||
|
className={`mt-3 grid gap-1.5 ${
|
||||||
|
showWow ? 'grid-cols-3' : 'grid-cols-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{cells.map((c) => {
|
{cells.map((c) => {
|
||||||
const good = c.v === null ? null : invert ? c.v < 0 : c.v > 0;
|
const good = c.v === null ? null : invert ? c.v < 0 : c.v > 0;
|
||||||
const color =
|
const color =
|
||||||
@@ -95,7 +105,8 @@ function DeltaTriple({
|
|||||||
: good
|
: good
|
||||||
? ArrowUpRight
|
? ArrowUpRight
|
||||||
: ArrowDownRight;
|
: ArrowDownRight;
|
||||||
const hasBase = c.base !== null && !(c.base === 0 && c.v === null);
|
const hasBase =
|
||||||
|
c.base !== null && Number.isFinite(c.base) && !(c.base === 0 && c.v === null);
|
||||||
const absDelta =
|
const absDelta =
|
||||||
hasBase && c.base !== null ? current - c.base : null;
|
hasBase && c.base !== null ? current - c.base : null;
|
||||||
return (
|
return (
|
||||||
@@ -242,8 +253,8 @@ export default function MetricsDashboard({
|
|||||||
gap: -v.leads,
|
gap: -v.leads,
|
||||||
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
|
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
|
||||||
cplCrm: 0,
|
cplCrm: 0,
|
||||||
cierres: 0,
|
|
||||||
ventas: 0,
|
ventas: 0,
|
||||||
|
importe: 0,
|
||||||
roas: 0,
|
roas: 0,
|
||||||
cac: 0,
|
cac: 0,
|
||||||
}))
|
}))
|
||||||
@@ -257,16 +268,16 @@ export default function MetricsDashboard({
|
|||||||
platformLeads: a.platformLeads + r.platformLeads,
|
platformLeads: a.platformLeads + r.platformLeads,
|
||||||
crmLeads: a.crmLeads + r.crmLeads,
|
crmLeads: a.crmLeads + r.crmLeads,
|
||||||
gap: a.gap + r.gap,
|
gap: a.gap + r.gap,
|
||||||
cierres: a.cierres + r.cierres,
|
|
||||||
ventas: a.ventas + r.ventas,
|
ventas: a.ventas + r.ventas,
|
||||||
|
importe: a.importe + r.importe,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
spend: 0,
|
spend: 0,
|
||||||
platformLeads: 0,
|
platformLeads: 0,
|
||||||
crmLeads: 0,
|
crmLeads: 0,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
cierres: 0,
|
|
||||||
ventas: 0,
|
ventas: 0,
|
||||||
|
importe: 0,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [attributionRows]);
|
}, [attributionRows]);
|
||||||
@@ -274,10 +285,11 @@ export default function MetricsDashboard({
|
|||||||
const crmPeriodTotal = rangedLeads.length;
|
const crmPeriodTotal = rangedLeads.length;
|
||||||
const hasCrm = crmLeads.length > 0;
|
const hasCrm = crmLeads.length > 0;
|
||||||
const hasCohort = rangedDeals.length > 0;
|
const hasCohort = rangedDeals.length > 0;
|
||||||
const cierres = periodTotals.deals;
|
const ventasCount = periodTotals.deals;
|
||||||
const cohortFromLeads = cohortSource === 'cliente_leads';
|
const cohortFromLeads = cohortSource === 'cliente_leads';
|
||||||
const revenueMissing =
|
const revenueMissing =
|
||||||
hasCohort && periodTotals.revenue <= 0 && cohortFromLeads;
|
hasCohort && periodTotals.revenue <= 0 && cohortFromLeads;
|
||||||
|
const showWow = rangeDaySpan(since, until) <= 7;
|
||||||
|
|
||||||
const cards: Card[] = [
|
const cards: Card[] = [
|
||||||
{
|
{
|
||||||
@@ -303,14 +315,14 @@ export default function MetricsDashboard({
|
|||||||
invert: true,
|
invert: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'revenue',
|
key: 'deals',
|
||||||
label: 'Ventas (CRM)',
|
label: 'Ventas (CRM)',
|
||||||
hint: hasCohort
|
hint: hasCohort
|
||||||
? cohortFromLeads
|
? cohortFromLeads
|
||||||
? `${formatNum(cierres)} clientes Clientify · importe no en webhook`
|
? 'Clientes Clientify en el periodo (sin importe en webhook)'
|
||||||
: `${formatNum(cierres)} deals ganados · importe Clientify`
|
: `Deals ganados Clientify · importe ${formatEur(periodTotals.revenue)}`
|
||||||
: 'Deals won / status cliente Clientify en el periodo',
|
: 'Deals won Clientify en el periodo',
|
||||||
format: formatEur,
|
format: formatNum,
|
||||||
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
|
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
|
||||||
cohort: true,
|
cohort: true,
|
||||||
},
|
},
|
||||||
@@ -318,7 +330,7 @@ export default function MetricsDashboard({
|
|||||||
key: 'cac',
|
key: 'cac',
|
||||||
label: 'CAC',
|
label: 'CAC',
|
||||||
hint: hasCohort
|
hint: hasCohort
|
||||||
? `Inversión ads ÷ ${formatNum(cierres)} ${cohortFromLeads ? 'clientes' : 'ventas'}`
|
? `Inversión ads ÷ ${formatNum(ventasCount)} ventas`
|
||||||
: 'Inversión ÷ deals ganados (no leads)',
|
: 'Inversión ÷ deals ganados (no leads)',
|
||||||
format: (v) => `${formatNum(v)} €`,
|
format: (v) => `${formatNum(v)} €`,
|
||||||
icon: <Target className="w-4 h-4 text-amber-600" />,
|
icon: <Target className="w-4 h-4 text-amber-600" />,
|
||||||
@@ -330,7 +342,7 @@ export default function MetricsDashboard({
|
|||||||
label: 'ROAS ventas',
|
label: 'ROAS ventas',
|
||||||
hint: revenueMissing
|
hint: revenueMissing
|
||||||
? 'Falta importe en CRM (API BPS) para ROAS'
|
? 'Falta importe en CRM (API BPS) para ROAS'
|
||||||
: `Importe deals ganados Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`,
|
: `Importe Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`,
|
||||||
format: (v) => `${formatNum(v)}x`,
|
format: (v) => `${formatNum(v)}x`,
|
||||||
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
|
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
|
||||||
cohort: true,
|
cohort: true,
|
||||||
@@ -440,13 +452,9 @@ export default function MetricsDashboard({
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{cards.map((c) => {
|
{cards.map((c) => {
|
||||||
const emptyCohort = Boolean(c.cohort) && !hasCohort;
|
const emptyCohort = Boolean(c.cohort) && !hasCohort;
|
||||||
const showClientesCount =
|
|
||||||
c.key === 'revenue' && revenueMissing && !emptyCohort;
|
|
||||||
const value = emptyCohort
|
const value = emptyCohort
|
||||||
? '—'
|
? '—'
|
||||||
: showClientesCount
|
: c.format(periodTotals[c.key]);
|
||||||
? `${formatNum(cierres)} clientes`
|
|
||||||
: c.format(periodTotals[c.key]);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={c.key}
|
key={c.key}
|
||||||
@@ -468,6 +476,7 @@ export default function MetricsDashboard({
|
|||||||
empty={emptyCohort}
|
empty={emptyCohort}
|
||||||
format={c.format}
|
format={c.format}
|
||||||
current={periodTotals[c.key]}
|
current={periodTotals[c.key]}
|
||||||
|
showWow={showWow}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -481,9 +490,9 @@ export default function MetricsDashboard({
|
|||||||
Cruce por canal · ads vs CRM
|
Cruce por canal · ads vs CRM
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-600 mt-0.5">
|
<p className="text-xs text-gray-600 mt-0.5">
|
||||||
Gasto y conversiones de ads vs altas / cierres / ventas CRM. Las
|
Leads plat = conversiones ads. Leads CRM = altas Clientify por
|
||||||
ventas de deals sin fuente de contacto aparecen en{' '}
|
fuente. Ventas = nº deals ganados; Importe = amount Clientify.
|
||||||
<span className="font-medium">sin-atribuir</span>.
|
Sin fuente de contacto → canal sin-atribuir.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600 shrink-0 font-medium">
|
<p className="text-xs text-gray-600 shrink-0 font-medium">
|
||||||
@@ -519,8 +528,8 @@ export default function MetricsDashboard({
|
|||||||
<th className="py-2 pr-3 font-semibold">Leads plat</th>
|
<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">Leads CRM</th>
|
||||||
<th className="py-2 pr-3 font-semibold">Gap</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">Ventas</th>
|
||||||
|
<th className="py-2 pr-3 font-semibold">Importe</th>
|
||||||
<th className="py-2 pr-3 font-semibold">ROAS</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 pr-3 font-semibold">CPL plat</th>
|
||||||
<th className="py-2 font-semibold">CPL CRM</th>
|
<th className="py-2 font-semibold">CPL CRM</th>
|
||||||
@@ -564,11 +573,11 @@ export default function MetricsDashboard({
|
|||||||
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
|
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</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">
|
<td className="py-2.5 pr-3 text-gray-900 font-semibold tabular-nums">
|
||||||
{row.ventas > 0 ? formatEur(row.ventas) : '—'}
|
{row.ventas > 0 ? formatNum(row.ventas) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||||
|
{row.importe > 0 ? formatEur(row.importe) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||||
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
|
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
|
||||||
@@ -612,21 +621,23 @@ export default function MetricsDashboard({
|
|||||||
? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}`
|
? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}`
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 pr-3 tabular-nums">
|
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
||||||
{hasCrm ? formatNum(attributionTotals.cierres) : '—'}
|
{formatNum(
|
||||||
|
attributionTotals.ventas || ventasCount
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
||||||
{attributionTotals.ventas > 0 || periodTotals.revenue > 0
|
{attributionTotals.importe > 0 || periodTotals.revenue > 0
|
||||||
? formatEur(
|
? formatEur(
|
||||||
attributionTotals.ventas || periodTotals.revenue
|
attributionTotals.importe || periodTotals.revenue
|
||||||
)
|
)
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 pr-3 tabular-nums">
|
<td className="py-2.5 pr-3 tabular-nums">
|
||||||
{attributionTotals.spend > 0 &&
|
{attributionTotals.spend > 0 &&
|
||||||
(attributionTotals.ventas || periodTotals.revenue) > 0
|
(attributionTotals.importe || periodTotals.revenue) > 0
|
||||||
? `${formatNum(
|
? `${formatNum(
|
||||||
(attributionTotals.ventas ||
|
(attributionTotals.importe ||
|
||||||
periodTotals.revenue) / attributionTotals.spend
|
periodTotals.revenue) / attributionTotals.spend
|
||||||
)}x`
|
)}x`
|
||||||
: '—'}
|
: '—'}
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export function totalsForDayRange(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* WoW / MoM / YoY relative to the selected date range (not “today”).
|
* WoW / MoM / YoY relative to the selected date range (not “today”).
|
||||||
* - WoW: previous window of the same length
|
* - WoW: previous window of the same length (only meaningful if span ≤ 7d)
|
||||||
* - MoM: previous calendar month if the range is a full month; else prior equal-length window
|
* - MoM: previous calendar month if the range is a full month; else prior equal-length window
|
||||||
* - YoY: same calendar dates one year earlier
|
* - YoY: same calendar dates one year earlier
|
||||||
*/
|
*/
|
||||||
@@ -273,10 +273,11 @@ export function buildRangeDeltas(
|
|||||||
const deltas = {} as Record<keyof MetricTotals, Delta>;
|
const deltas = {} as Record<keyof MetricTotals, Delta>;
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
deltas[k] = {
|
deltas[k] = {
|
||||||
wow: pctChange(cur[k], wow[k]),
|
// WoW only when the selected window is ≤ 7 days
|
||||||
|
wow: span <= 7 ? pctChange(cur[k], wow[k]) : null,
|
||||||
mom: pctChange(cur[k], mom[k]),
|
mom: pctChange(cur[k], mom[k]),
|
||||||
yoy: pctChange(cur[k], yoy[k]),
|
yoy: pctChange(cur[k], yoy[k]),
|
||||||
wowBase: wow[k],
|
wowBase: span <= 7 ? wow[k] : null,
|
||||||
momBase: mom[k],
|
momBase: mom[k],
|
||||||
yoyBase: yoy[k],
|
yoyBase: yoy[k],
|
||||||
};
|
};
|
||||||
@@ -284,6 +285,11 @@ export function buildRangeDeltas(
|
|||||||
return deltas;
|
return deltas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Day count of an inclusive ISO range (for UI comparative rules). */
|
||||||
|
export function rangeDaySpan(since: string, until: string): number {
|
||||||
|
return inclusiveDaySpan(since, until);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatEur(v: number): string {
|
export function formatEur(v: number): string {
|
||||||
return `${v.toLocaleString('es-ES', {
|
return `${v.toLocaleString('es-ES', {
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
@@ -836,20 +842,20 @@ export type AttributionChannelRow = {
|
|||||||
gap: number;
|
gap: number;
|
||||||
cplPlatform: number;
|
cplPlatform: number;
|
||||||
cplCrm: number;
|
cplCrm: number;
|
||||||
/** Contacts marked cliente (by CRM source) in the period. */
|
/** Won deals count (Clientify stage=won) attributed to channel. */
|
||||||
cierres: number;
|
|
||||||
/** Won-deal revenue attributed to channel (or sin-atribuir). */
|
|
||||||
ventas: number;
|
ventas: number;
|
||||||
/** Ventas ÷ inversión (0 if no spend). */
|
/** Won-deal revenue (€) attributed to channel (or sin-atribuir). */
|
||||||
|
importe: number;
|
||||||
|
/** Importe ÷ inversión (0 if no spend). */
|
||||||
roas: number;
|
roas: number;
|
||||||
/** Inversión ÷ cierres (0 if no cierres). */
|
/** Inversión ÷ ventas (0 if no ventas). */
|
||||||
cac: number;
|
cac: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify).
|
* Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify).
|
||||||
* Gap = CRM − platform (positive ⇒ CRM claims more than the ad UI).
|
* Gap = CRM − platform (positive ⇒ CRM claims more than the ad UI).
|
||||||
* Ventas come from won deals; without deal.source they land in `sin-atribuir`.
|
* Ventas/importe come from won deals; without deal.source they land in `sin-atribuir`.
|
||||||
*/
|
*/
|
||||||
export function attributionCross(
|
export function attributionCross(
|
||||||
daily: AdDailyRow[],
|
daily: AdDailyRow[],
|
||||||
@@ -862,8 +868,8 @@ export function attributionCross(
|
|||||||
spend: number;
|
spend: number;
|
||||||
platformLeads: number;
|
platformLeads: number;
|
||||||
crmLeads: number;
|
crmLeads: number;
|
||||||
cierres: number;
|
|
||||||
ventas: number;
|
ventas: number;
|
||||||
|
importe: number;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
@@ -874,8 +880,8 @@ export function attributionCross(
|
|||||||
spend: 0,
|
spend: 0,
|
||||||
platformLeads: 0,
|
platformLeads: 0,
|
||||||
crmLeads: 0,
|
crmLeads: 0,
|
||||||
cierres: 0,
|
|
||||||
ventas: 0,
|
ventas: 0,
|
||||||
|
importe: 0,
|
||||||
};
|
};
|
||||||
map.set(channel, cur);
|
map.set(channel, cur);
|
||||||
}
|
}
|
||||||
@@ -893,7 +899,6 @@ export function attributionCross(
|
|||||||
const channel = normalizeLeadChannel(r.source);
|
const channel = normalizeLeadChannel(r.source);
|
||||||
const cur = ensure(channel);
|
const cur = ensure(channel);
|
||||||
cur.crmLeads += 1;
|
cur.crmLeads += 1;
|
||||||
if (isClienteStatus(r.status)) cur.cierres += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const d of deals) {
|
for (const d of deals) {
|
||||||
@@ -901,7 +906,8 @@ export function attributionCross(
|
|||||||
const raw = (d.source || '').trim();
|
const raw = (d.source || '').trim();
|
||||||
const channel = raw ? normalizeLeadChannel(raw) : 'sin-atribuir';
|
const channel = raw ? normalizeLeadChannel(raw) : 'sin-atribuir';
|
||||||
const cur = ensure(channel);
|
const cur = ensure(channel);
|
||||||
cur.ventas += n(d.amount);
|
cur.ventas += 1;
|
||||||
|
cur.importe += n(d.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...map.entries()]
|
return [...map.entries()]
|
||||||
@@ -913,15 +919,15 @@ export function attributionCross(
|
|||||||
gap: v.crmLeads - v.platformLeads,
|
gap: v.crmLeads - v.platformLeads,
|
||||||
cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0,
|
cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0,
|
||||||
cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0,
|
cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0,
|
||||||
cierres: v.cierres,
|
|
||||||
ventas: v.ventas,
|
ventas: v.ventas,
|
||||||
roas: v.spend > 0 ? v.ventas / v.spend : 0,
|
importe: v.importe,
|
||||||
cac: v.cierres > 0 ? v.spend / v.cierres : 0,
|
roas: v.spend > 0 ? v.importe / v.spend : 0,
|
||||||
|
cac: v.ventas > 0 ? v.spend / v.ventas : 0,
|
||||||
}))
|
}))
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
b.spend - a.spend ||
|
b.spend - a.spend ||
|
||||||
b.ventas - a.ventas ||
|
b.importe - a.importe ||
|
||||||
b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads)
|
b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user