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:
@@ -33,6 +33,7 @@ MAP={
|
|||||||
'2508925045':'a5a250b4-e79a-4cb3-bfa1-4f9a6d908a03',
|
'2508925045':'a5a250b4-e79a-4cb3-bfa1-4f9a6d908a03',
|
||||||
'4418211066':'48919ad2-6af0-4ef0-96a3-fd9ca0a46cd2',
|
'4418211066':'48919ad2-6af0-4ef0-96a3-fd9ca0a46cd2',
|
||||||
'1323363601':'0b3c5fc0-6901-4caf-b26a-b6a0201d8541',
|
'1323363601':'0b3c5fc0-6901-4caf-b26a-b6a0201d8541',
|
||||||
|
'1064441284':'8bec977a-6032-4759-aa7f-b5042081f450', # BPS / Blue Parrot School
|
||||||
}
|
}
|
||||||
|
|
||||||
def call(method, path, body=None):
|
def call(method, path, body=None):
|
||||||
|
|||||||
138
src/app/dashboard/crm/page.tsx
Normal file
138
src/app/dashboard/crm/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import CrmDashboard from '@/components/CrmDashboard';
|
||||||
|
import CrmPortfolioBoard from '@/components/CrmPortfolioBoard';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { AdDailyRow, CrmLeadRow } from '@/lib/metrics';
|
||||||
|
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
async function fetchCrmLeads(
|
||||||
|
tenantId: string | null,
|
||||||
|
useServiceRole: boolean,
|
||||||
|
includeTenantId: boolean
|
||||||
|
): Promise<CrmLeadRow[]> {
|
||||||
|
const select = includeTenantId
|
||||||
|
? 'tenant_id,source,created_at,status'
|
||||||
|
: 'source,created_at,status';
|
||||||
|
const pageSize = 1000;
|
||||||
|
const out: CrmLeadRow[] = [];
|
||||||
|
|
||||||
|
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 < 80000; offset += pageSize) {
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) break;
|
||||||
|
const chunk = (await r.json()) as CrmLeadRow[];
|
||||||
|
out.push(...chunk);
|
||||||
|
if (chunk.length < pageSize) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
for (let offset = 0; offset < 80000; offset += pageSize) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('leads')
|
||||||
|
.select(select)
|
||||||
|
.eq('crm', 'clientify')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(offset, offset + pageSize - 1);
|
||||||
|
const chunk = (data as CrmLeadRow[]) || [];
|
||||||
|
out.push(...chunk);
|
||||||
|
if (chunk.length < pageSize) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAdDaily(
|
||||||
|
tenantId: string | null,
|
||||||
|
useServiceRole: boolean
|
||||||
|
): Promise<AdDailyRow[]> {
|
||||||
|
if (useServiceRole) {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) return [];
|
||||||
|
const tidFilter = tenantId
|
||||||
|
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||||
|
: '';
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/ad_daily?select=date,platform,spend,platform_leads${tidFilter}&order=date.desc&limit=5000`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) return [];
|
||||||
|
return (await r.json()) as AdDailyRow[];
|
||||||
|
}
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('ad_daily')
|
||||||
|
.select('date,platform,spend,platform_leads')
|
||||||
|
.order('date', { ascending: false })
|
||||||
|
.limit(5000);
|
||||||
|
return (data as AdDailyRow[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTenantNames(): Promise<Record<string, string>> {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) return {};
|
||||||
|
const r = await fetch(`${base}/rest/v1/tenants?select=id,name,slug`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!r.ok) return {};
|
||||||
|
const rows = (await r.json()) as { id: string; name: string; slug: string }[];
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const t of rows) map[t.id] = t.name || t.slug;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CrmPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
const useService = agency && (ctx.portfolioWide || ctx.viaView);
|
||||||
|
|
||||||
|
if (agency && ctx.portfolioWide) {
|
||||||
|
const [leads, names] = await Promise.all([
|
||||||
|
fetchCrmLeads(null, true, true),
|
||||||
|
fetchTenantNames(),
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<CrmPortfolioBoard
|
||||||
|
leads={leads}
|
||||||
|
tenantNames={names}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = ctx.tenantId;
|
||||||
|
const [crmLeads, dailyRows] = await Promise.all([
|
||||||
|
fetchCrmLeads(tenantId, useService, false),
|
||||||
|
fetchAdDaily(tenantId, useService),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CrmDashboard
|
||||||
|
label={ctx.label}
|
||||||
|
portfolioWide={false}
|
||||||
|
crmLeads={crmLeads}
|
||||||
|
dailyRows={dailyRows}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import MetricsDashboard from '@/components/MetricsDashboard';
|
|||||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||||
import {
|
import {
|
||||||
AdDailyRow,
|
AdDailyRow,
|
||||||
|
CrmLeadRow,
|
||||||
KpiWeeklyRow,
|
KpiWeeklyRow,
|
||||||
buildChannelSeries,
|
buildChannelSeries,
|
||||||
buildComparisons,
|
buildComparisons,
|
||||||
@@ -12,10 +13,55 @@ import {
|
|||||||
sumDaily,
|
sumDaily,
|
||||||
sumWeekly,
|
sumWeekly,
|
||||||
} from '@/lib/metrics';
|
} from '@/lib/metrics';
|
||||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** Paginate CRM leads (source + created_at) for attribution cross. */
|
||||||
|
async function fetchCrmLeads(
|
||||||
|
tenantId: string | null,
|
||||||
|
useServiceRole: boolean
|
||||||
|
): Promise<CrmLeadRow[]> {
|
||||||
|
const select = 'source,created_at';
|
||||||
|
const pageSize = 1000;
|
||||||
|
const out: CrmLeadRow[] = [];
|
||||||
|
|
||||||
|
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 < 50000; offset += pageSize) {
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) break;
|
||||||
|
const chunk = (await r.json()) as CrmLeadRow[];
|
||||||
|
out.push(...chunk);
|
||||||
|
if (chunk.length < pageSize) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client path: RLS already scopes tenant
|
||||||
|
const supabase = await createClient();
|
||||||
|
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('leads')
|
||||||
|
.select(select)
|
||||||
|
.eq('crm', 'clientify')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(offset, offset + pageSize - 1);
|
||||||
|
const chunk = (data as CrmLeadRow[]) || [];
|
||||||
|
out.push(...chunk);
|
||||||
|
if (chunk.length < pageSize) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function KpisPage() {
|
export default async function KpisPage() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
@@ -28,6 +74,7 @@ export default async function KpisPage() {
|
|||||||
|
|
||||||
let weekly: KpiWeeklyRow[] = [];
|
let weekly: KpiWeeklyRow[] = [];
|
||||||
let daily: AdDailyRow[] = [];
|
let daily: AdDailyRow[] = [];
|
||||||
|
let crmLeads: CrmLeadRow[] = [];
|
||||||
|
|
||||||
if (agency && (ctx.portfolioWide || ctx.viaView)) {
|
if (agency && (ctx.portfolioWide || ctx.viaView)) {
|
||||||
// Service role for portfolio / zoom (bypass single-tenant RLS)
|
// Service role for portfolio / zoom (bypass single-tenant RLS)
|
||||||
@@ -40,6 +87,8 @@ export default async function KpisPage() {
|
|||||||
);
|
);
|
||||||
weekly = w.data || [];
|
weekly = w.data || [];
|
||||||
daily = d.data || [];
|
daily = d.data || [];
|
||||||
|
// Attribution cross is clearest per-tenant; skip portfolio dump.
|
||||||
|
crmLeads = [];
|
||||||
} else if (ctx.tenantId) {
|
} else if (ctx.tenantId) {
|
||||||
const tid = encodeURIComponent(ctx.tenantId);
|
const tid = encodeURIComponent(ctx.tenantId);
|
||||||
const w = await restGet<KpiWeeklyRow[]>(
|
const w = await restGet<KpiWeeklyRow[]>(
|
||||||
@@ -50,6 +99,7 @@ export default async function KpisPage() {
|
|||||||
);
|
);
|
||||||
weekly = w.data || [];
|
weekly = w.data || [];
|
||||||
daily = d.data || [];
|
daily = d.data || [];
|
||||||
|
crmLeads = await fetchCrmLeads(ctx.tenantId, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const { data: w } = await supabase
|
const { data: w } = await supabase
|
||||||
@@ -63,6 +113,7 @@ export default async function KpisPage() {
|
|||||||
.limit(5000);
|
.limit(5000);
|
||||||
weekly = (w as KpiWeeklyRow[]) || [];
|
weekly = (w as KpiWeeklyRow[]) || [];
|
||||||
daily = (d as AdDailyRow[]) || [];
|
daily = (d as AdDailyRow[]) || [];
|
||||||
|
crmLeads = await fetchCrmLeads(ctx.tenantId, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
weekly = resolveWeeklyRows(weekly, daily);
|
weekly = resolveWeeklyRows(weekly, daily);
|
||||||
@@ -88,6 +139,7 @@ export default async function KpisPage() {
|
|||||||
weeklyRows={weekly}
|
weeklyRows={weekly}
|
||||||
channelPoints={channelSeries.points}
|
channelPoints={channelSeries.points}
|
||||||
dailyRows={daily}
|
dailyRows={daily}
|
||||||
|
crmLeads={crmLeads}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
UserCheck,
|
UserCheck,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Radio,
|
Radio,
|
||||||
Megaphone
|
Megaphone,
|
||||||
|
Contact
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { logoutAction } from '@/app/auth/actions';
|
import { logoutAction } from '@/app/auth/actions';
|
||||||
@@ -57,6 +58,13 @@ export default async function DashboardLayout({
|
|||||||
<BarChart3 className="w-4 h-4 text-brand-500" />
|
<BarChart3 className="w-4 h-4 text-brand-500" />
|
||||||
Métricas
|
Métricas
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/crm"
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
||||||
|
>
|
||||||
|
<Contact className="w-4 h-4 text-teal-400" />
|
||||||
|
CRM
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/seguimiento"
|
href="/dashboard/seguimiento"
|
||||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
src/components/CrmPortfolioBoard.tsx
Normal file
254
src/components/CrmPortfolioBoard.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
CalendarRange,
|
||||||
|
Percent,
|
||||||
|
UserCheck,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
CrmLeadRow,
|
||||||
|
KpiRangeKey,
|
||||||
|
buildCrmChannelStats,
|
||||||
|
buildCrmFunnel,
|
||||||
|
filterLeadsByDayRange,
|
||||||
|
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 CrmPortfolioBoard({
|
||||||
|
leads,
|
||||||
|
tenantNames,
|
||||||
|
}: {
|
||||||
|
leads: CrmLeadRow[];
|
||||||
|
tenantNames: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
const [range, setRange] = useState<RangeKey>('all');
|
||||||
|
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
|
||||||
|
|
||||||
|
const ranged = useMemo(() => {
|
||||||
|
if (!leads.length) return [];
|
||||||
|
if (!dayWindow) return leads;
|
||||||
|
return filterLeadsByDayRange(leads, dayWindow.since, dayWindow.until);
|
||||||
|
}, [leads, dayWindow]);
|
||||||
|
|
||||||
|
const byTenant = useMemo(() => {
|
||||||
|
const map = new Map<string, CrmLeadRow[]>();
|
||||||
|
for (const r of ranged) {
|
||||||
|
const tid = r.tenant_id || 'unknown';
|
||||||
|
const cur = map.get(tid) || [];
|
||||||
|
cur.push(r);
|
||||||
|
map.set(tid, cur);
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.map(([tenantId, rows]) => {
|
||||||
|
const funnel = buildCrmFunnel(rows);
|
||||||
|
const channels = buildCrmChannelStats(rows);
|
||||||
|
const meta = channels.find((c) => c.channel === 'meta');
|
||||||
|
const google = channels.find((c) => c.channel === 'google');
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
name: tenantNames[tenantId] || tenantId.slice(0, 8),
|
||||||
|
funnel,
|
||||||
|
channels,
|
||||||
|
meta,
|
||||||
|
google,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((t) => t.funnel.total > 0)
|
||||||
|
.sort((a, b) => b.funnel.cliente - a.funnel.cliente);
|
||||||
|
}, [ranged, tenantNames]);
|
||||||
|
|
||||||
|
const rangeHint = dayWindow
|
||||||
|
? `${dayWindow.since} → ${dayWindow.until}`
|
||||||
|
: 'todo el histórico';
|
||||||
|
|
||||||
|
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 · Portafolio
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
% a cliente (ganado) por canal y cliente · {rangeHint}. Zoom un
|
||||||
|
cliente en Contexto para el detalle completo.
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{!byTenant.length ? (
|
||||||
|
<p className="text-sm text-amber-400/90 border border-amber-500/20 rounded-lg px-4 py-3">
|
||||||
|
Sin leads Clientify en el portafolio todavía.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto bg-[#111827] border border-gray-800 rounded-xl p-5">
|
||||||
|
<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">Cliente</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">% global</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">Meta leads</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">Meta %→cli</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">Google leads</th>
|
||||||
|
<th className="py-2 font-medium">Google %→cli</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{byTenant.map((t) => (
|
||||||
|
<tr
|
||||||
|
key={t.tenantId}
|
||||||
|
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||||
|
>
|
||||||
|
<td className="py-2.5 pr-3 text-white font-semibold">
|
||||||
|
{t.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-gray-200">
|
||||||
|
{formatNum(t.funnel.total)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-emerald-300">
|
||||||
|
{formatNum(t.funnel.cliente)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`py-2.5 pr-3 font-semibold ${rateClass(t.funnel.clienteRate)}`}
|
||||||
|
>
|
||||||
|
{t.funnel.clienteRate.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-gray-200">
|
||||||
|
{t.meta ? formatNum(t.meta.leads) : '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`py-2.5 pr-3 font-semibold ${rateClass(t.meta?.clienteRate || 0)}`}
|
||||||
|
>
|
||||||
|
{t.meta ? `${t.meta.clienteRate.toFixed(1)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 pr-3 text-gray-200">
|
||||||
|
{t.google ? formatNum(t.google.leads) : '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`py-2.5 font-semibold ${rateClass(t.google?.clienteRate || 0)}`}
|
||||||
|
>
|
||||||
|
{t.google ? `${t.google.clienteRate.toFixed(1)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded channel matrix per tenant */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{byTenant.map((t) => (
|
||||||
|
<section
|
||||||
|
key={`ch-${t.tenantId}`}
|
||||||
|
className="bg-[#111827] border border-gray-800 rounded-xl p-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-white">{t.name}</h3>
|
||||||
|
<div className="flex items-center gap-3 text-[11px] text-gray-500">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
{formatNum(t.funnel.total)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 text-emerald-400/80">
|
||||||
|
<UserCheck className="w-3 h-3" />
|
||||||
|
{formatNum(t.funnel.cliente)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 text-brand-400/80">
|
||||||
|
<Percent className="w-3 h-3" />
|
||||||
|
{t.funnel.clienteRate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">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">Oportunidad</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">Frío*</th>
|
||||||
|
<th className="py-2 font-medium">Perdido</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{t.channels.slice(0, 12).map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.channel}
|
||||||
|
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||||
|
>
|
||||||
|
<td className="py-2 pr-3 text-brand-300 font-semibold capitalize">
|
||||||
|
{row.channel}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-gray-200">
|
||||||
|
{formatNum(row.leads)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-emerald-300">
|
||||||
|
{formatNum(row.cliente)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`py-2 pr-3 font-semibold ${rateClass(row.clienteRate)}`}
|
||||||
|
>
|
||||||
|
{row.clienteRate.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-gray-200">
|
||||||
|
{formatNum(row.enOportunidad)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-gray-200">
|
||||||
|
{formatNum(row.leadFrio)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-gray-200">
|
||||||
|
{formatNum(row.leadPerdido)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,14 +16,17 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AdDailyRow,
|
AdDailyRow,
|
||||||
|
AttributionChannelRow,
|
||||||
ChannelWeekPoint,
|
ChannelWeekPoint,
|
||||||
|
CrmLeadRow,
|
||||||
Delta,
|
Delta,
|
||||||
MetricTotals,
|
MetricTotals,
|
||||||
formatEur,
|
formatEur,
|
||||||
formatNum,
|
formatNum,
|
||||||
formatPct,
|
formatPct,
|
||||||
channelTotalsFromDaily,
|
attributionCross,
|
||||||
filterDailyByDayRange,
|
filterDailyByDayRange,
|
||||||
|
filterLeadsByDayRange,
|
||||||
metricTotalsFromDaily,
|
metricTotalsFromDaily,
|
||||||
resolveKpiDayRange,
|
resolveKpiDayRange,
|
||||||
sumWeekly,
|
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({
|
export default function MetricsDashboard({
|
||||||
label,
|
label,
|
||||||
portfolioWide,
|
portfolioWide,
|
||||||
@@ -99,6 +110,7 @@ export default function MetricsDashboard({
|
|||||||
weeklyRows = [],
|
weeklyRows = [],
|
||||||
channelPoints = [],
|
channelPoints = [],
|
||||||
dailyRows = [],
|
dailyRows = [],
|
||||||
|
crmLeads = [],
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
portfolioWide: boolean;
|
portfolioWide: boolean;
|
||||||
@@ -116,6 +128,8 @@ export default function MetricsDashboard({
|
|||||||
channelPoints?: ChannelWeekPoint[];
|
channelPoints?: ChannelWeekPoint[];
|
||||||
/** Raw ad_daily — period totals/matrix use calendar days (same as Ad Tracker). */
|
/** Raw ad_daily — period totals/matrix use calendar days (same as Ad Tracker). */
|
||||||
dailyRows?: AdDailyRow[];
|
dailyRows?: AdDailyRow[];
|
||||||
|
/** Clientify leads for CRM vs platform attribution. */
|
||||||
|
crmLeads?: CrmLeadRow[];
|
||||||
}) {
|
}) {
|
||||||
const [range, setRange] = useState<RangeKey>('7d');
|
const [range, setRange] = useState<RangeKey>('7d');
|
||||||
|
|
||||||
@@ -127,6 +141,12 @@ export default function MetricsDashboard({
|
|||||||
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
|
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
|
||||||
}, [dailyRows, dayWindow]);
|
}, [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(() => {
|
const periodTotals = useMemo(() => {
|
||||||
if (rangedDaily.length) return metricTotalsFromDaily(rangedDaily);
|
if (rangedDaily.length) return metricTotalsFromDaily(rangedDaily);
|
||||||
// Fallback when no ad_daily: approximate from weekly (legacy)
|
// Fallback when no ad_daily: approximate from weekly (legacy)
|
||||||
@@ -141,12 +161,12 @@ export default function MetricsDashboard({
|
|||||||
return ranged.length ? sumWeekly(ranged) : month;
|
return ranged.length ? sumWeekly(ranged) : month;
|
||||||
}, [rangedDaily, weeklyRows, dayWindow, month]);
|
}, [rangedDaily, weeklyRows, dayWindow, month]);
|
||||||
|
|
||||||
const channelRows = useMemo(() => {
|
const attributionRows: AttributionChannelRow[] = useMemo(() => {
|
||||||
if (rangedDaily.length) return channelTotalsFromDaily(rangedDaily);
|
if (rangedDaily.length || rangedLeads.length) {
|
||||||
const map = new Map<
|
return attributionCross(rangedDaily, rangedLeads);
|
||||||
string,
|
}
|
||||||
{ spend: number; leads: number; deals: number; revenue: number }
|
// Fallback: weekly series without CRM cross
|
||||||
>();
|
const map = new Map<string, { spend: number; leads: number }>();
|
||||||
const src =
|
const src =
|
||||||
range === 'all'
|
range === 'all'
|
||||||
? channelPoints
|
? channelPoints
|
||||||
@@ -156,27 +176,26 @@ export default function MetricsDashboard({
|
|||||||
return d >= dayWindow.since && d <= dayWindow.until;
|
return d >= dayWindow.since && d <= dayWindow.until;
|
||||||
});
|
});
|
||||||
for (const p of src) {
|
for (const p of src) {
|
||||||
const cur = map.get(p.channel) || {
|
const cur = map.get(p.channel) || { spend: 0, leads: 0 };
|
||||||
spend: 0,
|
|
||||||
leads: 0,
|
|
||||||
deals: 0,
|
|
||||||
revenue: 0,
|
|
||||||
};
|
|
||||||
cur.spend += p.spend;
|
cur.spend += p.spend;
|
||||||
cur.leads += p.leads;
|
cur.leads += p.leads;
|
||||||
cur.deals += p.deals;
|
|
||||||
cur.revenue += p.revenue;
|
|
||||||
map.set(p.channel, cur);
|
map.set(p.channel, cur);
|
||||||
}
|
}
|
||||||
return [...map.entries()]
|
return [...map.entries()]
|
||||||
.map(([channel, v]) => ({
|
.map(([channel, v]) => ({
|
||||||
channel,
|
channel,
|
||||||
...v,
|
spend: v.spend,
|
||||||
cpl: v.leads > 0 ? v.spend / v.leads : 0,
|
platformLeads: v.leads,
|
||||||
roas: v.spend > 0 ? v.revenue / v.spend : 0,
|
crmLeads: 0,
|
||||||
|
gap: -v.leads,
|
||||||
|
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
|
||||||
|
cplCrm: 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.spend - a.spend);
|
.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
|
const rangeHint = dayWindow
|
||||||
? `${dayWindow.since} → ${dayWindow.until}`
|
? `${dayWindow.since} → ${dayWindow.until}`
|
||||||
@@ -283,18 +302,22 @@ export default function MetricsDashboard({
|
|||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: 'Inversión periodo', value: formatEur(periodTotals.spend) },
|
{ label: 'Inversión periodo', value: formatEur(periodTotals.spend) },
|
||||||
{ label: 'Leads', value: formatNum(periodTotals.leads) },
|
|
||||||
{
|
{
|
||||||
label: 'CAC (paid)',
|
label: 'Leads plataforma',
|
||||||
value: periodTotals.cac
|
value: formatNum(periodTotals.leads),
|
||||||
? `${formatNum(periodTotals.cac)} €`
|
|
||||||
: '—',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'ROAS cohorte',
|
label: 'Leads CRM',
|
||||||
value: periodTotals.roas
|
value: hasCrm ? formatNum(crmPeriodTotal) : '—',
|
||||||
? `${formatNum(periodTotals.roas)}x`
|
},
|
||||||
: '—',
|
{
|
||||||
|
label: 'CPL CRM (paid)',
|
||||||
|
value:
|
||||||
|
hasCrm && crmPeriodTotal > 0
|
||||||
|
? `${formatNum(periodTotals.spend / crmPeriodTotal)} €`
|
||||||
|
: periodTotals.cpl
|
||||||
|
? `${formatNum(periodTotals.cpl)} €`
|
||||||
|
: '—',
|
||||||
},
|
},
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div
|
<div
|
||||||
@@ -330,17 +353,26 @@ export default function MetricsDashboard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel matrix */}
|
{/* Channel attribution cross */}
|
||||||
<section className="bg-[#111827] border border-gray-800 rounded-xl p-5 space-y-3">
|
<section className="bg-[#111827] border border-gray-800 rounded-xl p-5 space-y-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-semibold text-white">
|
<div>
|
||||||
Matriz por canal
|
<h3 className="text-sm font-semibold text-white">
|
||||||
</h3>
|
Cruce por canal · ads vs CRM
|
||||||
<p className="text-[11px] text-gray-500">
|
</h3>
|
||||||
Clic → dash secundario del canal
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{channelRows.length === 0 ? (
|
{attributionRows.length === 0 ? (
|
||||||
<p className="text-xs text-gray-500 py-6 text-center">
|
<p className="text-xs text-gray-500 py-6 text-center">
|
||||||
Sin series de canal en el periodo. Los canales Meta/Google aparecen
|
Sin series de canal en el periodo. Los canales Meta/Google aparecen
|
||||||
cuando hay filas en ad_daily para este cliente (elige uno en
|
cuando hay filas en ad_daily para este cliente (elige uno en
|
||||||
@@ -353,40 +385,60 @@ export default function MetricsDashboard({
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="py-2 pr-3 font-medium">Canal</th>
|
<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">Inversión</th>
|
||||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
<th className="py-2 pr-3 font-medium">Leads plat</th>
|
||||||
<th className="py-2 pr-3 font-medium">CPL</th>
|
<th className="py-2 pr-3 font-medium">Leads CRM</th>
|
||||||
<th className="py-2 pr-3 font-medium">Revenue</th>
|
<th className="py-2 pr-3 font-medium">Gap</th>
|
||||||
<th className="py-2 font-medium">ROAS</th>
|
<th className="py-2 pr-3 font-medium">CPL plat</th>
|
||||||
|
<th className="py-2 font-medium">CPL CRM</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{channelRows.map((row) => (
|
{attributionRows.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.channel}
|
key={row.channel}
|
||||||
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||||
>
|
>
|
||||||
<td className="py-2.5 pr-3">
|
<td className="py-2.5 pr-3">
|
||||||
<Link
|
{row.spend > 0 ? (
|
||||||
href={`/dashboard/kpis/canal/${encodeURIComponent(row.channel)}`}
|
<Link
|
||||||
className="text-brand-400 hover:text-brand-300 font-semibold capitalize"
|
href={`/dashboard/kpis/canal/${encodeURIComponent(row.channel)}`}
|
||||||
>
|
className="text-brand-400 hover:text-brand-300 font-semibold capitalize"
|
||||||
{row.channel}
|
>
|
||||||
</Link>
|
{row.channel}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-300 font-semibold capitalize">
|
||||||
|
{row.channel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 pr-3 text-gray-200">
|
<td className="py-2.5 pr-3 text-gray-200">
|
||||||
{formatEur(row.spend)}
|
{row.spend ? formatEur(row.spend) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 pr-3 text-gray-200">
|
<td className="py-2.5 pr-3 text-gray-200">
|
||||||
{formatNum(row.leads)}
|
{formatNum(row.platformLeads)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 pr-3 text-gray-200">
|
<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>
|
||||||
<td className="py-2.5 pr-3 text-gray-200">
|
<td className="py-2.5 pr-3 text-gray-200">
|
||||||
{formatEur(row.revenue)}
|
{row.cplPlatform
|
||||||
|
? `${formatNum(row.cplPlatform)} €`
|
||||||
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 text-gray-200">
|
<td className="py-2.5 text-gray-200">
|
||||||
{row.roas ? `${formatNum(row.roas)}x` : '—'}
|
{hasCrm && row.cplCrm
|
||||||
|
? `${formatNum(row.cplCrm)} €`
|
||||||
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -430,13 +482,17 @@ export default function MetricsDashboard({
|
|||||||
</p>
|
</p>
|
||||||
<ul className="text-xs text-gray-400 space-y-1 list-disc list-inside">
|
<ul className="text-xs text-gray-400 space-y-1 list-disc list-inside">
|
||||||
<li>
|
<li>
|
||||||
El selector de fechas filtra inversión/leads del periodo; WoW/MoM/YoY
|
El selector filtra el mismo calendario que Ad Tracker; el cruce
|
||||||
siguen siendo comparativas de calendario.
|
compara leads de la plataforma ads vs atribución Clientify.
|
||||||
</li>
|
</li>
|
||||||
<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>
|
||||||
<li>GEO / LTV aparecen como stubs hasta tener pipeline de datos.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -393,3 +393,244 @@ export function metricTotalsFromDaily(rows: AdDailyRow[]): MetricTotals {
|
|||||||
cpl: leads > 0 ? spend / leads : 0,
|
cpl: leads > 0 ? spend / leads : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** CRM lead row used for channel attribution cross. */
|
||||||
|
export type CrmLeadRow = {
|
||||||
|
source?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
tenant_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map Clientify / UTM sources onto paid channels when possible. */
|
||||||
|
export function normalizeLeadChannel(source?: string | null): string {
|
||||||
|
const s = (source || '').toLowerCase().trim();
|
||||||
|
if (!s) return 'otros';
|
||||||
|
if (
|
||||||
|
s === 'meta' ||
|
||||||
|
s === 'facebook' ||
|
||||||
|
s === 'instagram' ||
|
||||||
|
s === 'fb' ||
|
||||||
|
s === 'facebook-leads' ||
|
||||||
|
s.includes('facebook') ||
|
||||||
|
s.includes('instagram')
|
||||||
|
) {
|
||||||
|
return 'meta';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
s === 'google' ||
|
||||||
|
s === 'google-ads' ||
|
||||||
|
s === 'google_ads' ||
|
||||||
|
s.startsWith('google') ||
|
||||||
|
s.includes('google ads') ||
|
||||||
|
s.includes('google-ads')
|
||||||
|
) {
|
||||||
|
return 'google';
|
||||||
|
}
|
||||||
|
if (s === 'inbox_messenger' || s === 'messenger') return 'messenger';
|
||||||
|
if (s.startsWith('integrations')) return 'integrations-api';
|
||||||
|
if (s === 'whatsapp') return 'whatsapp';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLeadStatus(status?: string | null): string {
|
||||||
|
const s = (status || '').toLowerCase().trim();
|
||||||
|
if (!s) return 'sin-estado';
|
||||||
|
if (s === 'cold-lead') return 'lead frío';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClienteStatus(status?: string | null): boolean {
|
||||||
|
return normalizeLeadStatus(status) === 'cliente';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CrmFunnelTotals = {
|
||||||
|
total: number;
|
||||||
|
cliente: number;
|
||||||
|
enOportunidad: number;
|
||||||
|
leadFrio: number;
|
||||||
|
leadPerdido: number;
|
||||||
|
other: number;
|
||||||
|
clienteRate: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CrmChannelStats = {
|
||||||
|
channel: string;
|
||||||
|
leads: number;
|
||||||
|
cliente: number;
|
||||||
|
enOportunidad: number;
|
||||||
|
leadFrio: number;
|
||||||
|
leadPerdido: number;
|
||||||
|
other: number;
|
||||||
|
/** % leads del canal que son cliente (0–100). */
|
||||||
|
clienteRate: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CrmStatusRow = {
|
||||||
|
status: string;
|
||||||
|
count: number;
|
||||||
|
pct: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function bucketStatus(status: string): keyof Omit<CrmFunnelTotals, 'total' | 'clienteRate'> | 'cliente' {
|
||||||
|
if (status === 'cliente') return 'cliente';
|
||||||
|
if (status === 'en oportunidad') return 'enOportunidad';
|
||||||
|
if (
|
||||||
|
status === 'lead frío' ||
|
||||||
|
status === 'lead caliente' ||
|
||||||
|
status === 'lead templado'
|
||||||
|
) {
|
||||||
|
return 'leadFrio';
|
||||||
|
}
|
||||||
|
if (status === 'lead perdido' || status === 'cliente perdido') {
|
||||||
|
return 'leadPerdido';
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCrmFunnel(leads: CrmLeadRow[]): CrmFunnelTotals {
|
||||||
|
const t: CrmFunnelTotals = {
|
||||||
|
total: leads.length,
|
||||||
|
cliente: 0,
|
||||||
|
enOportunidad: 0,
|
||||||
|
leadFrio: 0,
|
||||||
|
leadPerdido: 0,
|
||||||
|
other: 0,
|
||||||
|
clienteRate: 0,
|
||||||
|
};
|
||||||
|
for (const r of leads) {
|
||||||
|
const b = bucketStatus(normalizeLeadStatus(r.status));
|
||||||
|
t[b] += 1;
|
||||||
|
}
|
||||||
|
t.clienteRate = t.total > 0 ? (100 * t.cliente) / t.total : 0;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCrmStatusBreakdown(leads: CrmLeadRow[]): CrmStatusRow[] {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const r of leads) {
|
||||||
|
const st = normalizeLeadStatus(r.status);
|
||||||
|
map.set(st, (map.get(st) || 0) + 1);
|
||||||
|
}
|
||||||
|
const total = leads.length || 1;
|
||||||
|
return [...map.entries()]
|
||||||
|
.map(([status, count]) => ({
|
||||||
|
status,
|
||||||
|
count,
|
||||||
|
pct: (100 * count) / total,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** % cliente (ganado) por canal de atribución CRM. */
|
||||||
|
export function buildCrmChannelStats(leads: CrmLeadRow[]): CrmChannelStats[] {
|
||||||
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
leads: number;
|
||||||
|
cliente: number;
|
||||||
|
enOportunidad: number;
|
||||||
|
leadFrio: number;
|
||||||
|
leadPerdido: number;
|
||||||
|
other: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const r of leads) {
|
||||||
|
const channel = normalizeLeadChannel(r.source);
|
||||||
|
const cur = map.get(channel) || {
|
||||||
|
leads: 0,
|
||||||
|
cliente: 0,
|
||||||
|
enOportunidad: 0,
|
||||||
|
leadFrio: 0,
|
||||||
|
leadPerdido: 0,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
cur.leads += 1;
|
||||||
|
const b = bucketStatus(normalizeLeadStatus(r.status));
|
||||||
|
cur[b] += 1;
|
||||||
|
map.set(channel, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.entries()]
|
||||||
|
.map(([channel, v]) => ({
|
||||||
|
channel,
|
||||||
|
...v,
|
||||||
|
clienteRate: v.leads > 0 ? (100 * v.cliente) / v.leads : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.cliente - a.cliente || b.leads - a.leads);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterLeadsByDayRange(
|
||||||
|
rows: CrmLeadRow[],
|
||||||
|
since: string,
|
||||||
|
until: string
|
||||||
|
): CrmLeadRow[] {
|
||||||
|
return rows.filter((r) => {
|
||||||
|
const d = (r.created_at || '').slice(0, 10);
|
||||||
|
return d >= since && d <= until;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttributionChannelRow = {
|
||||||
|
channel: string;
|
||||||
|
spend: number;
|
||||||
|
platformLeads: number;
|
||||||
|
crmLeads: number;
|
||||||
|
gap: number;
|
||||||
|
cplPlatform: number;
|
||||||
|
cplCrm: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify).
|
||||||
|
* Gap = CRM − platform (positive ⇒ CRM claims more than the ad UI).
|
||||||
|
*/
|
||||||
|
export function attributionCross(
|
||||||
|
daily: AdDailyRow[],
|
||||||
|
leads: CrmLeadRow[]
|
||||||
|
): AttributionChannelRow[] {
|
||||||
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
{ spend: number; platformLeads: number; crmLeads: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const r of daily) {
|
||||||
|
const channel = (r.platform || 'otros').toLowerCase();
|
||||||
|
const cur = map.get(channel) || {
|
||||||
|
spend: 0,
|
||||||
|
platformLeads: 0,
|
||||||
|
crmLeads: 0,
|
||||||
|
};
|
||||||
|
cur.spend += n(r.spend);
|
||||||
|
cur.platformLeads += n(r.platform_leads ?? r.conversions);
|
||||||
|
map.set(channel, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of leads) {
|
||||||
|
const channel = normalizeLeadChannel(r.source);
|
||||||
|
const cur = map.get(channel) || {
|
||||||
|
spend: 0,
|
||||||
|
platformLeads: 0,
|
||||||
|
crmLeads: 0,
|
||||||
|
};
|
||||||
|
cur.crmLeads += 1;
|
||||||
|
map.set(channel, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.entries()]
|
||||||
|
.map(([channel, v]) => ({
|
||||||
|
channel,
|
||||||
|
spend: v.spend,
|
||||||
|
platformLeads: v.platformLeads,
|
||||||
|
crmLeads: v.crmLeads,
|
||||||
|
gap: v.crmLeads - v.platformLeads,
|
||||||
|
cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0,
|
||||||
|
cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0,
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.spend - a.spend ||
|
||||||
|
b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user