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:
4Ventures
2026-09-23 17:33:41 +02:00
parent f436276c3e
commit be45df300b
8 changed files with 1157 additions and 57 deletions

View File

@@ -393,3 +393,244 @@ export function metricTotalsFromDaily(rows: AdDailyRow[]): MetricTotals {
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)
);
}