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:
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user