feat: periodo global, métricas negocio, seguimiento notas, Drive/Social/Orgánico

Selector de fechas en header (con Personalizado) compartido en el dashboard; Métricas con Ventas/CAC/ROAS CRM, cruce canal y gráficas diarias; Seguimiento solo notas + sync Superhuman; Activos con navegación Drive; Social con métricas IG/FB; Orgánico con prioridades SEO/GEO expertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 15:45:30 +02:00
parent eb1622c88c
commit 3425364b07
15 changed files with 1067 additions and 1011 deletions

View File

@@ -11,8 +11,6 @@ import {
TrendingUp,
Users,
MousePointerClick,
Eye,
CalendarRange,
} from 'lucide-react';
import {
AdDailyRow,
@@ -30,13 +28,12 @@ import {
filterDealsForRange,
filterLeadsByDayRange,
metricTotalsFromDaily,
resolveKpiDayRange,
sumWeekly,
withCohortFromDeals,
KpiWeeklyRow,
KpiRangeKey,
} from '@/lib/metrics';
import { PageHeader, SegmentedControl } from '@/components/ui';
import { PageHeader } from '@/components/ui';
import { useDateRange } from '@/components/DateRangeContext';
type Card = {
key: keyof MetricTotals;
@@ -45,12 +42,10 @@ type Card = {
format: (v: number) => string;
icon: React.ReactNode;
invert?: boolean;
/** When true and value is 0 with no deals, show em dash + empty state. */
/** Business outcome from Clientify won deals. */
cohort?: boolean;
};
type RangeKey = KpiRangeKey;
function DeltaTriple({
delta,
invert,
@@ -151,65 +146,46 @@ export default function MetricsDashboard({
crmLeads?: CrmLeadRow[];
wonDeals?: DealWonRow[];
}) {
const [range, setRange] = useState<RangeKey>('7d');
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
const { since, until, label: rangeHint } = useDateRange();
const rangedDaily = useMemo(() => {
if (!dailyRows.length) return [];
if (!dayWindow) return dailyRows;
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
}, [dailyRows, dayWindow]);
return filterDailyByDayRange(dailyRows, since, until);
}, [dailyRows, since, until]);
const rangedLeads = useMemo(() => {
if (!crmLeads.length) return [];
if (!dayWindow) return crmLeads;
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until);
}, [crmLeads, dayWindow]);
return filterLeadsByDayRange(crmLeads, since, until);
}, [crmLeads, since, until]);
const rangedDeals = useMemo(
() =>
filterDealsForRange(
wonDeals,
dayWindow?.since ?? null,
dayWindow?.until ?? null
),
[wonDeals, dayWindow]
() => filterDealsForRange(wonDeals, since, until),
[wonDeals, since, until]
);
const periodTotals = useMemo(() => {
let base: MetricTotals;
if (rangedDaily.length) {
base = metricTotalsFromDaily(rangedDaily);
} else if (!dayWindow) {
base = sumWeekly(weeklyRows);
} else {
const from = dayWindow.since;
const until = dayWindow.until;
const ranged = weeklyRows.filter((r) => {
if (!r.week) return false;
const w = r.week.slice(0, 10);
return w <= until && w >= from;
return w <= until && w >= since;
});
base = ranged.length ? sumWeekly(ranged) : month;
}
return withCohortFromDeals(base, rangedDeals);
}, [rangedDaily, rangedDeals, weeklyRows, dayWindow, month]);
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]);
const attributionRows: AttributionChannelRow[] = useMemo(() => {
if (rangedDaily.length || rangedLeads.length) {
return attributionCross(rangedDaily, rangedLeads);
}
const map = new Map<string, { spend: number; leads: number }>();
const src =
range === 'all'
? channelPoints
: channelPoints.filter((p) => {
const d = p.week;
if (!d || !dayWindow) return true;
return d >= dayWindow.since && d <= dayWindow.until;
});
for (const p of src) {
for (const p of channelPoints) {
const d = p.week;
if (!d || d < since || d > until) continue;
const cur = map.get(p.channel) || { spend: 0, leads: 0 };
cur.spend += p.spend;
cur.leads += p.leads;
@@ -226,92 +202,100 @@ export default function MetricsDashboard({
cplCrm: 0,
}))
.sort((a, b) => b.spend - a.spend);
}, [rangedDaily, rangedLeads, channelPoints, range, dayWindow]);
}, [rangedDaily, rangedLeads, channelPoints, since, until]);
const crmPeriodTotal = rangedLeads.length;
const hasCrm = crmLeads.length > 0;
const hasCohort = rangedDeals.length > 0;
const rangeHint = dayWindow
? `${dayWindow.since} → ${dayWindow.until}`
: 'todo el histórico';
const cierres = periodTotals.deals;
const cards: Card[] = [
{
key: 'spend',
label: 'Inversión ads',
hint: 'Paid del periodo',
hint: 'Gasto Meta + Google del periodo',
format: formatEur,
icon: <DollarSign className="w-4 h-4 text-brand-600" />,
},
{
key: 'leads',
label: 'Leads',
hint: 'Captación periodo',
label: 'Leads plataforma',
hint: 'Conversiones reportadas por ads',
format: formatNum,
icon: <Users className="w-4 h-4 text-blue-600" />,
},
{
key: 'cpl',
label: 'CPL',
hint: 'Coste por lead',
label: 'CPL ads',
hint: 'Inversión ÷ leads plataforma',
format: (v) => `${formatNum(v)} €`,
icon: <MousePointerClick className="w-4 h-4 text-violet-600" />,
invert: true,
},
{
key: 'cac',
label: 'CAC cohorte',
key: 'revenue',
label: 'Ventas (CRM)',
hint: hasCohort
? `Paid ÷ ${formatNum(periodTotals.deals)} cierres`
: 'Paid ÷ deals ganados Clientify',
? `${formatNum(cierres)} deals ganados · importe Clientify`
: 'Deals won Clientify en el periodo',
format: formatEur,
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
cohort: true,
},
{
key: 'cac',
label: 'CAC',
hint: hasCohort
? `Inversión ads ÷ ${formatNum(cierres)} ventas`
: 'Inversión ÷ deals ganados (no leads)',
format: (v) => `${formatNum(v)} €`,
icon: <Target className="w-4 h-4 text-amber-600" />,
invert: true,
cohort: true,
},
{
key: 'revenue',
label: 'Revenue cohorte',
hint: hasCohort
? `${formatNum(periodTotals.deals)} deals won`
: 'Suma amount deals won',
format: formatEur,
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
cohort: true,
},
{
key: 'roas',
label: 'ROAS cohorte',
hint: 'Revenue ÷ spend',
label: 'ROAS ventas',
hint: 'Ventas CRM ÷ inversión ads',
format: (v) => `${formatNum(v)}x`,
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
cohort: true,
},
];
const dailyChart = useMemo(() => {
const byDay = new Map<string, { spend: number; leads: number }>();
for (const r of rangedDaily) {
const d = (r.date || '').slice(0, 10);
if (!d) continue;
const cur = byDay.get(d) || { spend: 0, leads: 0 };
cur.spend += Number(r.spend) || 0;
cur.leads += Number(r.platform_leads ?? r.conversions) || 0;
byDay.set(d, cur);
}
return [...byDay.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([date, v]) => ({ date, ...v }));
}, [rangedDaily]);
const maxSpend = Math.max(1, ...dailyChart.map((d) => d.spend));
const channelInsight = useMemo(() => {
if (!attributionRows.length) return null;
const paid = attributionRows.filter((r) => r.spend > 0);
const top = paid[0] || attributionRows[0];
const worstGap = [...attributionRows].sort(
(a, b) => Math.abs(b.gap) - Math.abs(a.gap)
)[0];
return { top, worstGap };
}, [attributionRows]);
return (
<div className="space-y-8">
<PageHeader
eyebrow="Rendimiento"
title="Métricas"
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (mismos días calendario que Ad Tracker).`}
actions={
<div className="flex items-center gap-2">
<CalendarRange className="w-4 h-4 text-gray-600" />
<SegmentedControl
value={range}
onChange={setRange}
size="sm"
options={[
{ id: '7d', label: '7d' },
{ id: '30d', label: '30d' },
{ id: 'month', label: 'Mes' },
{ id: 'all', label: 'Todo' },
]}
/>
</div>
}
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (selector global del header).`}
/>
{sparse && (
@@ -323,10 +307,8 @@ export default function MetricsDashboard({
{!hasCohort && (
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2">
CAC / Revenue / ROAS a cero: no hay deals ganados con fecha de cierre
en este periodo. Se calculan con{' '}
<span className="font-semibold">deals Clientify (stage=won)</span>, no
solo con leads de ads.
Ventas / CAC / ROAS vacíos: no hay deals ganados (Clientify) con
fecha de cierre en este periodo. El gasto de ads no cuenta como venta.
</p>
)}
@@ -394,29 +376,46 @@ export default function MetricsDashboard({
})}
</div>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<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
</h3>
<p className="text-xs text-gray-600 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'
: ''}
Gasto y leads de plataforma vs leads CRM (como en{' '}
<a
href="https://mide-analytics-reports.vercel.app/como-combinamos-datos.html"
target="_blank"
rel="noreferrer"
className="text-brand-600 underline"
>
combinación de datos
</a>
): el coste vive en ads; el resultado de negocio en CRM/GA.
</p>
</div>
<p className="text-xs text-gray-600 shrink-0 font-medium">
Gap = CRM − plat
</p>
</div>
{channelInsight && (
<p className="text-xs text-gray-800 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
<strong>Resumen:</strong> más inversión en{' '}
<span className="capitalize font-semibold">
{channelInsight.top.channel}
</span>{' '}
({formatEur(channelInsight.top.spend)}). Mayor desfase ads↔CRM en{' '}
<span className="capitalize font-semibold">
{channelInsight.worstGap.channel}
</span>{' '}
(gap {channelInsight.worstGap.gap > 0 ? '+' : ''}
{formatNum(channelInsight.worstGap.gap)}).
</p>
)}
{attributionRows.length === 0 ? (
<p className="text-sm text-gray-600 py-6 text-center">
Sin series de canal en el periodo. Los canales Meta/Google aparecen
cuando hay filas en ad_daily para este cliente (elige uno en
Contexto).
Sin series de canal en el periodo.
</p>
) : (
<div className="overflow-x-auto">
@@ -486,49 +485,63 @@ export default function MetricsDashboard({
</table>
</div>
)}
</section>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div 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">
Ads · ventana diaria
</span>
<Eye className="w-4 h-4 text-sky-600" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-1">
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-600 mb-2">
Ads · inversión / día
</h4>
{dailyChart.length === 0 ? (
<p className="text-xs text-gray-500">Sin filas diarias.</p>
) : (
<div className="flex items-end gap-0.5 h-20 w-full">
{dailyChart.map((d) => (
<div
key={`s-${d.date}`}
className="flex-1 min-w-0"
title={`${d.date}: ${formatEur(d.spend)}`}
>
<div
className="w-full rounded-t bg-brand-400/80 hover:bg-brand-500"
style={{
height: `${Math.max(4, (d.spend / maxSpend) * 100)}%`,
}}
/>
</div>
))}
</div>
)}
</div>
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-600 mb-2">
Ads · leads plat. / día
</h4>
{dailyChart.length === 0 ? (
<p className="text-xs text-gray-500">Sin filas diarias.</p>
) : (
<div className="flex items-end gap-0.5 h-20 w-full">
{dailyChart.map((d) => (
<div
key={`l-${d.date}`}
className="flex-1 min-w-0"
title={`${d.date}: ${formatNum(d.leads)} leads`}
>
<div
className="w-full rounded-t bg-sky-400/80 hover:bg-sky-500"
style={{
height: `${Math.max(4, (d.leads / Math.max(1, ...dailyChart.map((x) => x.leads))) * 100)}%`,
}}
/>
</div>
))}
</div>
)}
</div>
<p className="text-lg font-bold text-gray-900 tabular-nums">
{formatEur(daily.spend)}
</p>
<p className="text-xs text-gray-600 mt-1">
{formatNum(daily.leads)} leads · {formatNum(daily.impressions)} impr.
· {formatNum(daily.clicks)} clicks
</p>
<p className="text-xs text-gray-700 mt-2 font-medium">
Semana actual: {formatEur(week.spend)} · {formatNum(week.leads)}{' '}
leads
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5 md:col-span-2 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-700 mb-2">
Cómo leer
</p>
<ul className="text-sm text-gray-700 space-y-1.5 list-disc list-inside">
<li>
CAC / Revenue / ROAS usan deals ganados de Clientify (
<span className="font-semibold">won_at</span> en el periodo), no
leads de Meta/Google.
</li>
<li>
Gap cercano a 0 en Meta/Google = buena atribución. Canales sin
spend son orgánicos/CRM.
</li>
<li>
CPL CRM = inversión paid ÷ leads CRM del canal (más realista
cuando hay desfase con la plataforma).
</li>
</ul>
</div>
</div>
<p className="text-[10px] text-gray-500">
Barras compactas por día · periodo del header · detalle en Ads
</p>
</section>
</div>
);
}