Present metrics as one guided scorecard, not two dashboards.

Split ads, CRM and web into numbered blocks, drop the Windsor second panel, and keep Google campaigns as a single extra table.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 12:11:50 +02:00
parent 0ad89e88e5
commit ed52bf99c4

View File

@@ -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 (
<p className="mt-3 text-[11px] text-gray-500 border border-dashed border-gray-200 rounded-md px-2 py-2 text-center">
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
</p>
);
}
@@ -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: <TrendingUp className="w-4 h-4 text-emerald-600" />,
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: <TrendingUp className="w-4 h-4 text-emerald-700" />,
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 (
<div className="space-y-8">
<PageHeader
eyebrow="Rendimiento"
title="Métricas"
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (selector global del header).`}
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Un solo panel para el periodo ${rangeHint}.`}
actions={<SyncNowButton forceModule="metrics" />}
/>
@@ -442,19 +502,43 @@ export default function MetricsDashboard({
)}
{cohortFromLeads && hasCohort && (
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
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.
</p>
)}
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-700 space-y-1.5">
<p className="font-semibold text-gray-900 text-[11px] uppercase tracking-wider">
Cómo leer este panel
</p>
<p>
<strong>Publicidad:</strong> lo que Meta y Google dicen (gasto,
conversiones, CPL). No es el listado de clientes.
</p>
<p>
<strong>{crmName}:</strong> 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).
</p>
<p>
<strong>Web y SEO:</strong> visitas y clics orgánicos (Search Console
y Analytics). No se suman a los leads de anuncios.
</p>
</div>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold text-gray-900">
1. Publicidad
</h2>
<p className="text-xs text-gray-600 mt-0.5">
Inversión y conversiones de las plataformas de ads · {rangeHint}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{cards.map((c) => {
{adsCards.map((c) => {
const emptyCohort = Boolean(c.cohort) && !hasCohort;
const value = emptyCohort
? '—'
: c.format(periodTotals[c.key]);
const value = emptyCohort ? '—' : c.format(periodTotals[c.key]);
return (
<div
key={c.key}
@@ -482,7 +566,18 @@ export default function MetricsDashboard({
);
})}
</div>
</section>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold text-gray-900">
2. Negocio ({crmName})
</h2>
<p className="text-xs text-gray-600 mt-0.5">
Altas y ventas del CRM · no uses estos números como si fueran los de
ads
</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm">
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-600">
@@ -492,7 +587,7 @@ export default function MetricsDashboard({
{hasCrm ? formatNum(crmPeriodTotal) : '—'}
</p>
<p className="text-xs text-gray-500 mt-1">
Altas Clientify en el periodo (no es el mismo número que ads)
Altas en {crmName} en el periodo
</p>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm">
@@ -505,12 +600,12 @@ export default function MetricsDashboard({
: '—'}
</p>
<p className="text-xs text-gray-500 mt-1">
Inversión ads ÷ leads CRM (distinto del CPL ads)
Inversión ads ÷ leads CRM
</p>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm">
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-600">
Gap plat vs CRM
Gap ads vs CRM
</p>
<p
className={`text-xl font-bold mt-1 tabular-nums ${
@@ -522,9 +617,51 @@ export default function MetricsDashboard({
: '—'}
</p>
<p className="text-xs text-gray-500 mt-1">
CRM − leads plataforma · detalle por canal abajo
CRM − conversiones ads · detalle por canal abajo
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{outcomeCards.map((c) => {
const emptyCohort = Boolean(c.cohort) && !hasCohort;
const value = emptyCohort ? '—' : c.format(periodTotals[c.key]);
return (
<div
key={c.key}
className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm"
>
<div className="flex items-center justify-between text-gray-700 mb-2">
<span className="text-xs font-semibold uppercase tracking-wider">
{c.label}
</span>
{c.icon}
</div>
<p className="text-2xl font-bold text-gray-900 tabular-nums">
{value}
</p>
<p className="text-xs text-gray-600 mt-1">{c.hint}</p>
<DeltaTriple
delta={rangeDeltas[c.key] ?? deltas[c.key]}
invert={c.invert}
empty={emptyCohort}
format={c.format}
current={periodTotals[c.key]}
showWow={showWow}
/>
</div>
);
})}
</div>
</section>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold text-gray-900">3. Web y SEO</h2>
<p className="text-xs text-gray-600 mt-0.5">
Tráfico del sitio · mismo periodo · no es publicidad
</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm">
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-600 inline-flex items-center gap-1">
<Search className="w-3.5 h-3.5" /> Clics SEO
@@ -532,7 +669,16 @@ export default function MetricsDashboard({
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
{formatNum(organicPeriod.gscClicks)}
</p>
<p className="text-xs text-gray-500 mt-1">Search Console · mismo periodo</p>
<p className="text-xs text-gray-500 mt-1">Search Console</p>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm">
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-600">
Impresiones SEO
</p>
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
{formatNum(organicPeriod.gscImpressions)}
</p>
<p className="text-xs text-gray-500 mt-1">Apariciones en Google</p>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm">
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-600 inline-flex items-center gap-1">
@@ -541,35 +687,32 @@ export default function MetricsDashboard({
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
{formatNum(organicPeriod.ga4Sessions)}
</p>
<p className="text-xs text-gray-500 mt-1">
GA4 · {formatNum(organicPeriod.ga4Conversions)} conv. web
</p>
<p className="text-xs text-gray-500 mt-1">Analytics (GA4)</p>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm">
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-600">
Cuota orgánica
Conv. web
</p>
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
{formatPct(organicPeriod.organicShare)}
{formatNum(organicPeriod.ga4Conversions)}
</p>
<p className="text-xs text-gray-500 mt-1">
Conv. GA4 / (leads ads + conv. web)
Eventos GA4 · cuota {formatPct(organicPeriod.organicShare)}
</p>
</div>
</div>
</section>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-gray-900">
Cruce por canal · ads vs CRM
4. Cruce por canal · ads vs {crmName}
</h3>
<p className="text-xs text-gray-600 mt-0.5">
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.
</p>
</div>
<p className="text-xs text-gray-600 shrink-0 font-medium">
@@ -800,21 +943,62 @@ export default function MetricsDashboard({
</div>
</div>
<p className="text-[10px] text-gray-500">
Barras compactas por día · periodo del header · detalle en Ads
Barras por día · mismo periodo del header
</p>
</section>
<WindsorAiSection
embedded
tenantId={tenantId}
label={label}
portfolioWide={portfolioWide}
dailyRows={dailyRows}
organicRows={organicRows}
crmLeads={crmLeads}
wonDeals={wonDeals}
integrations={integrations}
/>
{googleCampaigns.length > 0 && (
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
<div>
<h3 className="text-sm font-semibold text-gray-900">
5. Campañas Google Ads
</h3>
<p className="text-xs text-gray-600 mt-0.5">
Desglose de la inversión Google del periodo. Meta va en Ads /
creatividades. No vuelve a contar el scorecard de arriba.
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-gray-600 border-b border-gray-200">
<tr>
<th className="py-2 pr-3 font-semibold">Campaña</th>
<th className="py-2 pr-3 font-semibold">Red</th>
<th className="py-2 pr-3 font-semibold">Inversión</th>
<th className="py-2 pr-3 font-semibold">Clics</th>
<th className="py-2 pr-3 font-semibold">Conv.</th>
<th className="py-2 font-semibold">CPL</th>
</tr>
</thead>
<tbody>
{googleCampaigns.slice(0, 12).map((c) => (
<tr
key={c.name}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2 pr-3 font-medium text-gray-900 max-w-[280px] truncate" title={c.name}>
{c.name}
</td>
<td className="py-2 pr-3 text-gray-600">{c.kind}</td>
<td className="py-2 pr-3 tabular-nums">{formatEur(c.spend)}</td>
<td className="py-2 pr-3 tabular-nums">{formatNum(c.clicks)}</td>
<td className="py-2 pr-3 tabular-nums">{formatNum(c.leads)}</td>
<td className="py-2 tabular-nums">
{c.cpl > 0 ? `${formatNum(c.cpl)} €` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{googleCampaigns.length > 12 && (
<p className="text-[11px] text-gray-500">
Mostrando 12 de {googleCampaigns.length} campañas (las de más
gasto). El resto está en Ads.
</p>
)}
</section>
)}
</div>
);
}