Files
4ventures-client-portal/src/components/MetricsDashboard.tsx

756 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useMemo, useState } from 'react';
import Link from 'next/link';
import {
ArrowDownRight,
ArrowUpRight,
DollarSign,
Minus,
Target,
TrendingUp,
Users,
MousePointerClick,
} from 'lucide-react';
import {
AdDailyRow,
AttributionChannelRow,
ChannelWeekPoint,
CrmLeadRow,
DealWonRow,
Delta,
MetricTotals,
formatEur,
formatNum,
formatPct,
attributionCross,
buildRangeDeltas,
filterDailyByDayRange,
filterDealsForRange,
filterLeadsByDayRange,
metricTotalsFromDaily,
rangeDaySpan,
sumWeekly,
withCohortFromDeals,
KpiWeeklyRow,
} from '@/lib/metrics';
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 = {
key: keyof MetricTotals;
label: string;
hint: string;
format: (v: number) => string;
icon: React.ReactNode;
invert?: boolean;
/** Business outcome from Clientify won deals. */
cohort?: boolean;
};
function DeltaTriple({
delta,
invert,
empty,
format,
current,
showWow,
}: {
delta: Delta;
invert?: boolean;
empty?: boolean;
format: (v: number) => string;
current: number;
/** Hide WoW when selected range is longer than a week. */
showWow: boolean;
}) {
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
</p>
);
}
const cells: {
label: string;
v: number | null;
base: number | null;
}[] = [
...(showWow
? [{ label: 'WoW', v: delta.wow, base: delta.wowBase }]
: []),
{ label: 'MoM', v: delta.mom, base: delta.momBase },
{ label: 'YoY', v: delta.yoy, base: delta.yoyBase },
];
return (
<div
className={`mt-3 grid gap-1.5 ${
showWow ? 'grid-cols-3' : 'grid-cols-2'
}`}
>
{cells.map((c) => {
const good = c.v === null ? null : invert ? c.v < 0 : c.v > 0;
const color =
c.v === null
? 'text-gray-500'
: good
? 'text-emerald-700'
: c.v === 0
? 'text-gray-600'
: 'text-red-600';
const Icon =
c.v === null || c.v === 0
? Minus
: good
? ArrowUpRight
: ArrowDownRight;
const hasBase =
c.base !== null && Number.isFinite(c.base) && !(c.base === 0 && c.v === null);
const absDelta =
hasBase && c.base !== null ? current - c.base : null;
return (
<div
key={c.label}
className="rounded-md bg-gray-50 border border-gray-200 px-1.5 py-1.5 text-center"
>
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600">
{c.label}
</p>
<p
className={`text-xs font-bold inline-flex items-center justify-center gap-0.5 ${color}`}
>
<Icon className="w-3.5 h-3.5" />
{formatPct(c.v)}
</p>
{hasBase ? (
<>
<p className="text-[11px] font-semibold text-gray-800 mt-1 tabular-nums leading-tight">
prev {format(c.base ?? 0)}
</p>
{absDelta !== null && (
<p
className={`text-[10px] font-medium tabular-nums leading-tight ${color}`}
>
Δ{' '}
{absDelta > 0
? `+${format(absDelta)}`
: format(absDelta)}
</p>
)}
</>
) : (
<p className="text-[10px] text-gray-500 mt-1 leading-tight">
sin histórico
</p>
)}
</div>
);
})}
</div>
);
}
function gapClass(gap: number): string {
if (gap === 0) return 'text-gray-600';
const abs = Math.abs(gap);
if (abs <= 5) return 'text-emerald-700';
if (abs <= 25) return 'text-amber-700';
return 'text-red-600';
}
export default function MetricsDashboard({
label,
portfolioWide = false,
tenantId,
month,
week,
deltas,
daily,
sparse,
weeklyRows = [],
channelPoints = [],
dailyRows = [],
organicRows = [],
crmLeads = [],
wonDeals = [],
integrations = [],
cohortSource = 'deals',
}: {
label: string;
portfolioWide?: boolean;
tenantId?: string | null;
month: MetricTotals;
week: MetricTotals;
deltas: Record<keyof MetricTotals, Delta>;
daily: {
spend: number;
leads: number;
impressions: number;
clicks: number;
};
sparse: boolean;
weeklyRows?: KpiWeeklyRow[];
channelPoints?: ChannelWeekPoint[];
dailyRows?: AdDailyRow[];
organicRows?: OrganicDailyRow[];
crmLeads?: CrmLeadRow[];
wonDeals?: DealWonRow[];
integrations?: any[];
cohortSource?: 'deals' | 'cliente_leads' | 'none';
}) {
const { since, until, label: rangeHint } = useDateRange();
const rangedDaily = useMemo(() => {
if (!dailyRows.length) return [];
return filterDailyByDayRange(dailyRows, since, until);
}, [dailyRows, since, until]);
const rangedLeads = useMemo(() => {
if (!crmLeads.length) return [];
return filterLeadsByDayRange(crmLeads, since, until);
}, [crmLeads, since, until]);
const rangedDeals = useMemo(
() => filterDealsForRange(wonDeals, since, until),
[wonDeals, since, until]
);
const periodTotals = useMemo(() => {
let base: MetricTotals;
if (rangedDaily.length) {
base = metricTotalsFromDaily(rangedDaily);
} else {
const ranged = weeklyRows.filter((r) => {
if (!r.week) return false;
const w = r.week.slice(0, 10);
return w <= until && w >= since;
});
base = ranged.length ? sumWeekly(ranged) : month;
}
return withCohortFromDeals(base, rangedDeals);
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]);
/** Comparatives relative to the selected range (not “today”). */
const rangeDeltas = useMemo(
() => buildRangeDeltas(dailyRows, wonDeals, since, until),
[dailyRows, wonDeals, since, until]
);
const attributionRows: AttributionChannelRow[] = useMemo(() => {
if (rangedDaily.length || rangedLeads.length || rangedDeals.length) {
return attributionCross(rangedDaily, rangedLeads, rangedDeals);
}
const map = new Map<string, { spend: number; leads: number }>();
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;
map.set(p.channel, cur);
}
return [...map.entries()]
.map(([channel, v]) => ({
channel,
spend: v.spend,
platformLeads: v.leads,
crmLeads: 0,
gap: -v.leads,
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
cplCrm: 0,
ventas: 0,
importe: 0,
roas: 0,
cac: 0,
}))
.sort((a, b) => b.spend - a.spend);
}, [rangedDaily, rangedLeads, rangedDeals, channelPoints, since, until]);
const attributionTotals = useMemo(() => {
return attributionRows.reduce(
(a, r) => ({
spend: a.spend + r.spend,
platformLeads: a.platformLeads + r.platformLeads,
crmLeads: a.crmLeads + r.crmLeads,
gap: a.gap + r.gap,
ventas: a.ventas + r.ventas,
importe: a.importe + r.importe,
}),
{
spend: 0,
platformLeads: 0,
crmLeads: 0,
gap: 0,
ventas: 0,
importe: 0,
}
);
}, [attributionRows]);
const crmPeriodTotal = rangedLeads.length;
const hasCrm = crmLeads.length > 0;
const hasCohort = rangedDeals.length > 0;
const ventasCount = periodTotals.deals;
const cohortFromLeads = cohortSource === 'cliente_leads';
const revenueMissing =
hasCohort && periodTotals.revenue <= 0 && cohortFromLeads;
const showWow = rangeDaySpan(since, until) <= 7;
const cards: Card[] = [
{
key: 'spend',
label: 'Inversión ads',
hint: 'Gasto Meta + Google del periodo',
format: formatEur,
icon: <DollarSign className="w-4 h-4 text-brand-600" />,
},
{
key: 'leads',
label: 'Leads plataforma',
hint: 'Conversiones reportadas por ads',
format: formatNum,
icon: <Users className="w-4 h-4 text-blue-600" />,
},
{
key: 'cpl',
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: 'deals',
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',
format: formatNum,
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
cohort: true,
},
{
key: 'cac',
label: 'CAC',
hint: hasCohort
? `Inversión ads ÷ ${formatNum(ventasCount)} 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: '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)})`,
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];
const platTotal = attributionRows.reduce((a, r) => a + r.platformLeads, 0);
const crmTotal = attributionRows.reduce((a, r) => a + r.crmLeads, 0);
const crmPaid = paid.reduce((a, r) => a + r.crmLeads, 0);
const crmOther = crmTotal - crmPaid;
return { top, worstGap, platTotal, crmTotal, crmPaid, crmOther };
}, [attributionRows]);
return (
<div className="space-y-8">
<PageHeader
eyebrow="Rendimiento"
title="Métricas"
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (selector global del header).`}
actions={<SyncNowButton forceModule="metrics" />}
/>
{sparse && (
<p className="text-xs text-amber-800 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
Histórico limitado: si faltan deltas o canales, el warehouse aún no
tiene semanas suficientes.
</p>
)}
{!hasCohort && (
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2">
Ventas / CAC / ROAS vacíos en <strong>{rangeHint}</strong>: no hay
deals ganados ni contactos con status «cliente» en este rango
{wonDeals.length
? ` (sí hay ${formatNum(wonDeals.length)} en el histórico — prueba «30 días» o «Mes»).`
: '.'}{' '}
El gasto de ads no cuenta como venta.
</p>
)}
{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.
</p>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: 'Inversión periodo', value: formatEur(periodTotals.spend) },
{
label: 'Leads plataforma',
value: formatNum(periodTotals.leads),
},
{
label: 'Leads CRM',
value: hasCrm ? formatNum(crmPeriodTotal) : '—',
},
{
label: 'CPL CRM (paid)',
value:
hasCrm && crmPeriodTotal > 0
? `${formatNum(periodTotals.spend / crmPeriodTotal)} €`
: periodTotals.cpl
? `${formatNum(periodTotals.cpl)} €`
: '—',
},
].map((s) => (
<div
key={s.label}
className="rounded-xl border border-brand-200 bg-brand-50 px-4 py-3 shadow-sm"
>
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-700">
{s.label}
</p>
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
{s.value}
</p>
</div>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{cards.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 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">
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.
</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> plat {formatNum(channelInsight.platTotal)}{' '}
vs CRM {formatNum(channelInsight.crmTotal)} (gap{' '}
{channelInsight.crmTotal - channelInsight.platTotal > 0 ? '+' : ''}
{formatNum(channelInsight.crmTotal - channelInsight.platTotal)}). De
los CRM, {formatNum(channelInsight.crmPaid)} en canales con gasto
ads y {formatNum(channelInsight.crmOther)} en otras fuentes
(integrations, ecommerce, messenger…). Mayor desfase en{' '}
<span className="capitalize font-semibold">
{channelInsight.worstGap.channel}
</span>
.
</p>
)}
{attributionRows.length === 0 ? (
<p className="text-sm text-gray-600 py-6 text-center">
Sin series de canal en el periodo.
</p>
) : (
<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">Canal</th>
<th className="py-2 pr-3 font-semibold">Inversión</th>
<th className="py-2 pr-3 font-semibold">Leads plat</th>
<th className="py-2 pr-3 font-semibold">Leads CRM</th>
<th className="py-2 pr-3 font-semibold">Gap</th>
<th className="py-2 pr-3 font-semibold">Ventas</th>
<th className="py-2 pr-3 font-semibold">Importe</th>
<th className="py-2 pr-3 font-semibold">ROAS</th>
<th className="py-2 pr-3 font-semibold">CPL plat</th>
<th className="py-2 font-semibold">CPL CRM</th>
</tr>
</thead>
<tbody>
{attributionRows.map((row) => (
<tr
key={row.channel}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2.5 pr-3">
{row.spend > 0 ? (
<Link
href={`/dashboard/kpis/canal/${encodeURIComponent(row.channel)}`}
className="text-brand-700 hover:text-brand-600 font-semibold capitalize"
>
{row.channel}
</Link>
) : (
<span className="text-gray-700 font-semibold capitalize">
{row.channel}
</span>
)}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.spend ? formatEur(row.spend) : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{formatNum(row.platformLeads)}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{hasCrm ? formatNum(row.crmLeads) : '—'}
</td>
<td
className={`py-2.5 pr-3 font-semibold tabular-nums ${
hasCrm ? gapClass(row.gap) : 'text-gray-500'
}`}
>
{hasCrm
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
: '—'}
</td>
<td className="py-2.5 pr-3 text-gray-900 font-semibold tabular-nums">
{row.ventas > 0 ? formatNum(row.ventas) : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.importe > 0 ? formatEur(row.importe) : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.cplPlatform
? `${formatNum(row.cplPlatform)} €`
: '—'}
</td>
<td className="py-2.5 text-gray-800 tabular-nums">
{hasCrm && row.cplCrm
? `${formatNum(row.cplCrm)} €`
: '—'}
</td>
</tr>
))}
</tbody>
{attributionRows.length > 0 && (
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="py-2.5 pr-3 text-gray-900">Total</td>
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
{attributionTotals.spend
? formatEur(attributionTotals.spend)
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{formatNum(attributionTotals.platformLeads)}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{hasCrm ? formatNum(attributionTotals.crmLeads) : '—'}
</td>
<td
className={`py-2.5 pr-3 tabular-nums ${
hasCrm
? gapClass(attributionTotals.gap)
: 'text-gray-500'
}`}
>
{hasCrm
? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}`
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
{formatNum(
attributionTotals.ventas || ventasCount
)}
</td>
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
{attributionTotals.importe > 0 || periodTotals.revenue > 0
? formatEur(
attributionTotals.importe || periodTotals.revenue
)
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{attributionTotals.spend > 0 &&
(attributionTotals.importe || periodTotals.revenue) > 0
? `${formatNum(
(attributionTotals.importe ||
periodTotals.revenue) / attributionTotals.spend
)}x`
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{attributionTotals.platformLeads > 0
? `${formatNum(
attributionTotals.spend /
attributionTotals.platformLeads
)} €`
: '—'}
</td>
<td className="py-2.5 tabular-nums">
{hasCrm && attributionTotals.crmLeads > 0
? `${formatNum(
attributionTotals.spend /
attributionTotals.crmLeads
)} €`
: '—'}
</td>
</tr>
</tfoot>
)}
</table>
</div>
)}
<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 h-full flex items-end"
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) * 80)}px`,
}}
/>
</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 h-full flex items-end"
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))) *
80
)}px`,
}}
/>
</div>
))}
</div>
)}
</div>
</div>
<p className="text-[10px] text-gray-500">
Barras compactas por día · periodo del header · detalle en Ads
</p>
</section>
{/* Windsor.ai Data for blueparrotschool.com (Directly below Cruce por canal) */}
<WindsorAiSection
tenantId={tenantId}
label={label}
portfolioWide={portfolioWide}
dailyRows={dailyRows}
organicRows={organicRows}
crmLeads={crmLeads}
wonDeals={wonDeals}
integrations={integrations}
/>
</div>
);
}