diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index 8b325b8..fd0e027 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -39,7 +39,6 @@ import { import { PageHeader } from '@/components/ui'; import SyncNowButton from '@/components/SyncNowButton'; import { useDateRange } from '@/components/DateRangeContext'; -import WindsorAiSection from '@/components/WindsorAiSection'; import { OrganicDailyRow } from '@/lib/organic'; type Card = { @@ -72,8 +71,8 @@ function DeltaTriple({ if (empty) { return (

- Sin cierres en este rango — amplía el periodo o revisa deals ganados en - Clientify + Sin cierres en este rango — amplía el periodo o revisa oportunidades + ganadas en el CRM

); } @@ -308,6 +307,10 @@ export default function MetricsDashboard({ return d >= since && d <= until; }); const gscClicks = rows.reduce((a, r) => a + (Number(r.gsc_clicks) || 0), 0); + const gscImpressions = rows.reduce( + (a, r) => a + (Number(r.gsc_impressions) || 0), + 0 + ); const ga4Sessions = rows.reduce( (a, r) => a + (Number(r.ga4_sessions) || 0), 0 @@ -319,6 +322,7 @@ export default function MetricsDashboard({ const denom = periodTotals.leads + ga4Conversions; return { gscClicks, + gscImpressions, ga4Sessions, ga4Conversions, organicShare: denom > 0 ? (ga4Conversions / denom) * 100 : 0, @@ -353,9 +357,9 @@ export default function MetricsDashboard({ label: 'Ventas (CRM)', hint: hasCohort ? cohortFromLeads - ? 'Clientes Clientify en el periodo (sin importe en webhook)' - : `Deals ganados Clientify · importe ${formatEur(periodTotals.revenue)}` - : 'Deals won Clientify en el periodo', + ? 'Contactos marcados cliente en el CRM (sin importe)' + : `Oportunidades ganadas · importe ${formatEur(periodTotals.revenue)}` + : 'Oportunidades ganadas en el CRM en el periodo', format: formatNum, icon: , cohort: true, @@ -375,8 +379,8 @@ export default function MetricsDashboard({ key: 'roas', label: 'ROAS ventas', hint: revenueMissing - ? 'Falta importe en CRM (API BPS) para ROAS' - : `Importe Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`, + ? 'Falta importe en el CRM para calcular ROAS' + : `Importe CRM (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`, format: (v) => `${formatNum(v)}x`, icon: , cohort: true, @@ -414,12 +418,68 @@ export default function MetricsDashboard({ return { top, worstGap, platTotal, crmTotal, crmPaid, crmOther }; }, [attributionRows]); + const crmName = useMemo(() => { + const rows = (integrations || []) as { provider?: string; status?: string }[]; + const ghl = rows.find( + (i) => i.provider === 'gohighlevel' && i.status === 'connected' + ); + const cfy = rows.find( + (i) => i.provider === 'clientify' && i.status === 'connected' + ); + if (ghl) return 'GoHighLevel'; + if (cfy) return 'Clientify'; + return 'CRM'; + }, [integrations]); + + const googleCampaigns = useMemo(() => { + const byCampaign = new Map< + string, + { spend: number; impressions: number; clicks: number; leads: number } + >(); + for (const r of rangedDaily) { + if (r.platform !== 'google') continue; + const name = r.campaign_name || r.campaign || 'Campaña sin nombre'; + const cur = byCampaign.get(name) || { + spend: 0, + impressions: 0, + clicks: 0, + leads: 0, + }; + cur.spend += Number(r.spend) || 0; + cur.impressions += Number(r.impressions) || 0; + cur.clicks += Number(r.clicks) || 0; + cur.leads += Number(r.platform_leads ?? r.conversions) || 0; + byCampaign.set(name, cur); + } + const totalSpend = [...byCampaign.values()].reduce((a, b) => a + b.spend, 0); + return [...byCampaign.entries()] + .map(([name, v]) => ({ + name, + ...v, + cpl: v.leads > 0 ? v.spend / v.leads : 0, + share: totalSpend > 0 ? (v.spend / totalSpend) * 100 : 0, + kind: /pmax|performance/i.test(name) + ? 'Performance Max' + : /search|brand/i.test(name) + ? 'Búsqueda' + : 'Otra red', + })) + .sort((a, b) => b.spend - a.spend); + }, [rangedDaily]); + + const adsCards = cards.filter((c) => + ['spend', 'leads', 'cpl'].includes(c.key) + ); + const outcomeCards = cards.filter((c) => + ['deals', 'cac', 'roas'].includes(c.key) + ); + return (
} /> @@ -442,48 +502,83 @@ export default function MetricsDashboard({ )} {cohortFromLeads && hasCohort && (

- Cohorte desde contactos Clientify status «cliente» (no hay deals - ganados sincronizados). Importe € incompleto hasta conectar la API - Clientify de este tenant. Meta pixel reporta compras en el periodo; - el CRM marca clientes sin amount en el webhook. + Cohorte desde contactos del CRM con status «cliente» (no hay oportunidades + ganadas sincronizadas). El importe puede ir incompleto.

)} -
- {cards.map((c) => { - const emptyCohort = Boolean(c.cohort) && !hasCohort; - const value = emptyCohort - ? '—' - : c.format(periodTotals[c.key]); - return ( -
-
- - {c.label} - - {c.icon} -
-

- {value} -

-

{c.hint}

- -
- ); - })} +
+

+ Cómo leer este panel +

+

+ Publicidad: lo que Meta y Google dicen (gasto, + conversiones, CPL). No es el listado de clientes. +

+

+ {crmName}: personas y ventas reales en el CRM. Un + lead de ads y un alta CRM casi nunca coinciden 1 a 1 (el gap lo + explica). +

+

+ Web y SEO: visitas y clics orgánicos (Search Console + y Analytics). No se suman a los leads de anuncios. +

-
+
+
+

+ 1. Publicidad +

+

+ Inversión y conversiones de las plataformas de ads · {rangeHint} +

+
+
+ {adsCards.map((c) => { + const emptyCohort = Boolean(c.cohort) && !hasCohort; + const value = emptyCohort ? '—' : c.format(periodTotals[c.key]); + return ( +
+
+ + {c.label} + + {c.icon} +
+

+ {value} +

+

{c.hint}

+ +
+ ); + })} +
+
+ +
+
+

+ 2. Negocio ({crmName}) +

+

+ Altas y ventas del CRM · no uses estos números como si fueran los de + ads +

+
+

Leads CRM @@ -492,7 +587,7 @@ export default function MetricsDashboard({ {hasCrm ? formatNum(crmPeriodTotal) : '—'}

- Altas Clientify en el periodo (no es el mismo número que ads) + Altas en {crmName} en el periodo

@@ -505,12 +600,12 @@ export default function MetricsDashboard({ : '—'}

- Inversión ads ÷ leads CRM (distinto del CPL ads) + Inversión ads ÷ leads CRM

- Gap plat vs CRM + Gap ads vs CRM

- CRM − leads plataforma · detalle por canal abajo + CRM − conversiones ads · detalle por canal abajo

+
+
+ {outcomeCards.map((c) => { + const emptyCohort = Boolean(c.cohort) && !hasCohort; + const value = emptyCohort ? '—' : c.format(periodTotals[c.key]); + return ( +
+
+ + {c.label} + + {c.icon} +
+

+ {value} +

+

{c.hint}

+ +
+ ); + })} +
+
+ +
+
+

3. Web y SEO

+

+ Tráfico del sitio · mismo periodo · no es publicidad +

+
+

Clics SEO @@ -532,7 +669,16 @@ export default function MetricsDashboard({

{formatNum(organicPeriod.gscClicks)}

-

Search Console · mismo periodo

+

Search Console

+
+
+

+ Impresiones SEO +

+

+ {formatNum(organicPeriod.gscImpressions)} +

+

Apariciones en Google

@@ -541,35 +687,32 @@ export default function MetricsDashboard({

{formatNum(organicPeriod.ga4Sessions)}

-

- GA4 · {formatNum(organicPeriod.ga4Conversions)} conv. web -

+

Analytics (GA4)

- Cuota orgánica + Conv. web

- {formatPct(organicPeriod.organicShare)} + {formatNum(organicPeriod.ga4Conversions)}

- Conv. GA4 / (leads ads + conv. web) + Eventos GA4 · cuota {formatPct(organicPeriod.organicShare)}

-
+
+

- Cruce por canal · ads vs CRM + 4. Cruce por canal · ads vs {crmName}

- Leads plat = conversiones ads. Leads CRM = altas Clientify por - fuente. Ventas = nº deals ganados; Importe = amount Clientify. - Ventas = deals ganados Online (importe {'>'} 0), como el board - Clientify. Fuente del contacto → canal (meta / google / - ecommerce…). Sin fuente → sin-atribuir. + Leads plat = conversiones que reporta el anuncio. Leads CRM = + altas en {crmName} según la fuente del contacto. Ventas = + oportunidades ganadas (importe {'>'} 0). Gap = CRM − plat.

@@ -800,21 +943,62 @@ export default function MetricsDashboard({

- Barras compactas por día · periodo del header · detalle en Ads + Barras por día · mismo periodo del header

- + {googleCampaigns.length > 0 && ( +
+
+

+ 5. Campañas Google Ads +

+

+ Desglose de la inversión Google del periodo. Meta va en Ads / + creatividades. No vuelve a contar el scorecard de arriba. +

+
+
+ + + + + + + + + + + + + {googleCampaigns.slice(0, 12).map((c) => ( + + + + + + + + + ))} + +
CampañaRedInversiónClicsConv.CPL
+ {c.name} + {c.kind}{formatEur(c.spend)}{formatNum(c.clicks)}{formatNum(c.leads)} + {c.cpl > 0 ? `${formatNum(c.cpl)} €` : '—'} +
+
+ {googleCampaigns.length > 12 && ( +

+ Mostrando 12 de {googleCampaigns.length} campañas (las de más + gasto). El resto está en Ads. +

+ )} +
+ )}
); }