fix: cohort CAC/ROAS desde deals Clientify y más contraste en dashboards

Los totales de Métricas usaban solo ad_daily (cohort a 0). Ahora suman
deals won; selectores y tipografía ganan contraste sobre fondo blanco.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 10:40:12 +02:00
parent 8fda2996b6
commit 21051afda9
20 changed files with 383 additions and 184 deletions

View File

@@ -84,7 +84,7 @@ export default async function AgencyPage() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-gray-400 font-medium">Clientes / Tenants</span>
<span className="text-xs text-gray-600 font-semibold">Clientes / Tenants</span>
<Building2 className="w-4 h-4 text-brand-400" />
</div>
<p className="text-2xl font-extrabold text-gray-900">{tenants.length}</p>
@@ -93,7 +93,7 @@ export default async function AgencyPage() {
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-gray-400 font-medium">Inversión Gestionada</span>
<span className="text-xs text-gray-600 font-semibold">Inversión Gestionada</span>
<DollarSign className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-extrabold text-gray-900">
@@ -104,7 +104,7 @@ export default async function AgencyPage() {
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-gray-400 font-medium">Leads Captados</span>
<span className="text-xs text-gray-600 font-semibold">Leads Captados</span>
<Users className="w-4 h-4 text-purple-400" />
</div>
<p className="text-2xl font-extrabold text-gray-900">{totalLeads}</p>
@@ -113,7 +113,7 @@ export default async function AgencyPage() {
<div className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-gray-400 font-medium">ROAS Blended Agencia</span>
<span className="text-xs text-gray-600 font-semibold">ROAS Blended Agencia</span>
<TrendingUp className="w-4 h-4 text-emerald-400" />
</div>
<p className="text-2xl font-extrabold text-gray-900">{blendedRoas}x</p>

View File

@@ -212,7 +212,7 @@ export default function AsistentePage() {
<div className="px-6 py-2.5 bg-white/80 border-t border-gray-200 flex gap-2 overflow-x-auto text-xs text-gray-600">
<button
onClick={() => sendMessage('¿Qué puedo preguntar?')}
className="px-3 py-1.5 rounded-full bg-brand-500/20 text-brand-400 hover:bg-brand-500/30 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-brand-500/40"
className="px-3 py-1.5 rounded-full bg-brand-50 text-brand-700 hover:bg-brand-100 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-brand-200 font-medium"
>
<HelpCircle className="w-3.5 h-3.5" />
❓ ¿Qué puedo preguntar?

View File

@@ -257,7 +257,9 @@ export default function CalendarClient({
type="button"
onClick={() => setView(k)}
className={`px-2.5 py-1 text-xs rounded-md ${
view === k ? 'bg-brand-500/20 text-brand-300' : 'text-gray-400'
view === k
? 'bg-brand-500 text-white shadow-sm font-semibold'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{label}
@@ -324,7 +326,7 @@ export default function CalendarClient({
: p === 'instagram'
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white'
: `${meta?.color || 'bg-gray-700'} text-white`
: 'bg-gray-100 text-gray-600 hover:text-white'
: 'bg-gray-100 text-gray-600 hover:text-gray-900'
}`}
>
{meta && <meta.Icon className="w-3.5 h-3.5" />}
@@ -411,7 +413,7 @@ export default function CalendarClient({
<button
type="button"
onClick={() => setSelectedPost(null)}
className="text-gray-400 hover:text-gray-900 p-1 rounded-lg hover:bg-gray-50"
className="text-gray-600 hover:text-gray-900 p-1 rounded-lg hover:bg-gray-50"
>
<X className="w-5 h-5" />
</button>
@@ -429,7 +431,7 @@ export default function CalendarClient({
{selectedPost.hook}
</Block>
<Block label="Copy">{selectedPost.body}</Block>
<Block label="CTA" className="text-brand-300">
<Block label="CTA" className="text-brand-700">
{selectedPost.cta}
</Block>
<Block label="Hashtags" className="text-blue-400 font-mono text-xs">
@@ -593,7 +595,7 @@ function PostCard({
<button
type="button"
onClick={onOpen}
className="text-xs text-brand-400 hover:text-brand-300 font-semibold inline-flex items-center gap-1"
className="text-xs text-brand-400 hover:text-brand-700 font-semibold inline-flex items-center gap-1"
>
<Eye className="w-3.5 h-3.5" /> Ver / comentar
</button>

View File

@@ -82,7 +82,7 @@ export default async function CanalDashPage({
<div>
<Link
href="/dashboard/kpis"
className="inline-flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-900 mb-3"
className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-gray-900 mb-3"
>
<ArrowLeft className="w-3.5 h-3.5" /> Volver a métricas
</Link>

View File

@@ -6,9 +6,11 @@ import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import {
AdDailyRow,
CrmLeadRow,
DealWonRow,
KpiWeeklyRow,
buildChannelSeries,
buildComparisons,
mergeDealsIntoWeekly,
resolveWeeklyRows,
sumDaily,
sumWeekly,
@@ -46,7 +48,6 @@ async function fetchCrmLeads(
return out;
}
// Client path: RLS already scopes tenant
const supabase = await createClient();
for (let offset = 0; offset < 50000; offset += pageSize) {
const { data } = await supabase
@@ -62,6 +63,50 @@ async function fetchCrmLeads(
return out;
}
/** Won deals for cohort CAC / revenue / ROAS. */
async function fetchWonDeals(
tenantId: string | null,
useServiceRole: boolean
): Promise<DealWonRow[]> {
const select = 'amount,won_at,stage,tenant_id';
const pageSize = 1000;
const out: DealWonRow[] = [];
if (useServiceRole) {
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) return [];
const tidFilter = tenantId
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
: '';
for (let offset = 0; offset < 50000; offset += pageSize) {
const r = await fetch(
`${base}/rest/v1/deals?select=${select}${tidFilter}&stage=eq.won&order=won_at.desc.nullslast&limit=${pageSize}&offset=${offset}`,
{ headers, cache: 'no-store' }
);
if (!r.ok) break;
const chunk = (await r.json()) as DealWonRow[];
out.push(...chunk);
if (chunk.length < pageSize) break;
}
return out;
}
const supabase = await createClient();
for (let offset = 0; offset < 50000; offset += pageSize) {
const { data } = await supabase
.from('deals')
.select(select)
.eq('stage', 'won')
.order('won_at', { ascending: false, nullsFirst: false })
.range(offset, offset + pageSize - 1);
const chunk = (data as DealWonRow[]) || [];
out.push(...chunk);
if (chunk.length < pageSize) break;
}
return out;
}
export default async function KpisPage() {
const supabase = await createClient();
const {
@@ -75,9 +120,9 @@ export default async function KpisPage() {
let weekly: KpiWeeklyRow[] = [];
let daily: AdDailyRow[] = [];
let crmLeads: CrmLeadRow[] = [];
let wonDeals: DealWonRow[] = [];
if (agency && (ctx.portfolioWide || ctx.viaView)) {
// Service role for portfolio / zoom (bypass single-tenant RLS)
if (ctx.portfolioWide) {
const w = await restGet<KpiWeeklyRow[]>(
'/rest/v1/kpi_weekly?select=*&order=week.desc'
@@ -87,8 +132,8 @@ export default async function KpisPage() {
);
weekly = w.data || [];
daily = d.data || [];
// Attribution cross is clearest per-tenant; skip portfolio dump.
crmLeads = [];
wonDeals = await fetchWonDeals(null, true);
} else if (ctx.tenantId) {
const tid = encodeURIComponent(ctx.tenantId);
const w = await restGet<KpiWeeklyRow[]>(
@@ -100,6 +145,7 @@ export default async function KpisPage() {
weekly = w.data || [];
daily = d.data || [];
crmLeads = await fetchCrmLeads(ctx.tenantId, true);
wonDeals = await fetchWonDeals(ctx.tenantId, true);
}
} else {
const { data: w } = await supabase
@@ -114,15 +160,16 @@ export default async function KpisPage() {
weekly = (w as KpiWeeklyRow[]) || [];
daily = (d as AdDailyRow[]) || [];
crmLeads = await fetchCrmLeads(ctx.tenantId, false);
wonDeals = await fetchWonDeals(ctx.tenantId, false);
}
weekly = resolveWeeklyRows(weekly, daily);
weekly = mergeDealsIntoWeekly(resolveWeeklyRows(weekly, daily), wonDeals);
const comparisons = buildComparisons(weekly);
const month =
comparisons.currentMonth.spend > 0 || comparisons.currentMonth.leads > 0
? comparisons.currentMonth
: sumWeekly(weekly); // fallback: all available weeks if current month empty
: sumWeekly(weekly);
const dailyTotals = sumDaily(daily);
const sparse = weekly.length < 4;
const channelSeries = buildChannelSeries(weekly);
@@ -140,6 +187,7 @@ export default async function KpisPage() {
channelPoints={channelSeries.points}
dailyRows={daily}
crmLeads={crmLeads}
wonDeals={wonDeals}
/>
);
}

View File

@@ -222,7 +222,7 @@ function CreativeCard({
<button
type="button"
onClick={onZoom}
className="inline-flex items-center gap-1 rounded-lg border border-brand-500/40 bg-brand-500/10 px-2.5 py-1 text-[11px] font-medium text-brand-300 hover:bg-brand-500/20"
className="inline-flex items-center gap-1 rounded-lg border border-brand-500/40 bg-brand-500/10 px-2.5 py-1 text-[11px] font-medium text-brand-700 hover:bg-brand-500/20"
>
<Maximize2 className="w-3.5 h-3.5" /> Zoom
</button>
@@ -297,7 +297,7 @@ function CreativeCard({
</div>
) : null}
{ad.cta ? (
<p className="text-[10px] text-brand-300 uppercase tracking-wide">
<p className="text-[10px] text-brand-700 uppercase tracking-wide">
CTA · {ad.cta}
</p>
) : null}
@@ -423,7 +423,7 @@ export function CreativeZoomModal({
</p>
{ad.cta ? (
<p>
CTA: <span className="text-brand-300">{ad.cta}</span>
CTA: <span className="text-brand-700">{ad.cta}</span>
</p>
) : null}
{ad.businessName ? (

View File

@@ -252,7 +252,7 @@ export default function AgencyPortfolioBoard() {
<button
type="button"
onClick={() => setShowArchived((v) => !v)}
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg border border-gray-200 text-gray-400 hover:text-gray-900"
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:text-gray-900"
>
{showArchived ? (
<Eye className="w-3.5 h-3.5" />
@@ -354,7 +354,7 @@ export default function AgencyPortfolioBoard() {
type="button"
disabled={busyId === t.id}
onClick={() => void setFollowed(t.id, archived)}
className="text-xs px-3 py-1.5 rounded-lg border border-gray-200 text-gray-400 hover:text-gray-900"
className="text-xs px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:text-gray-900"
title={
archived
? 'Volver a seguir en el portafolio'

View File

@@ -440,7 +440,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
<button
type="button"
onClick={() => download(a.id)}
className="p-2 rounded-lg text-gray-400 hover:text-gray-900 hover:bg-gray-50"
className="p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-50"
title="Descargar"
>
<Download className="w-4 h-4" />
@@ -498,7 +498,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
<button
type="button"
onClick={() => void openDrive(f)}
className="p-2 rounded-lg text-gray-400 hover:text-gray-900 hover:bg-gray-50"
className="p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-50"
title="Abrir en Drive"
>
<ExternalLink className="w-4 h-4" />
@@ -526,7 +526,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
<button
type="button"
onClick={() => setDetailId(null)}
className="text-xs text-gray-400 hover:text-gray-900"
className="text-xs text-gray-600 hover:text-gray-900"
>
Cerrar
</button>

View File

@@ -116,7 +116,7 @@ export default function BudgetSimulator({
className={`px-3 py-1 rounded-lg text-xs font-semibold uppercase transition-all ${
channel === ch.id
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20'
: 'bg-gray-100 text-gray-400 hover:text-white'
: 'bg-gray-100 text-gray-600 hover:text-gray-900'
}`}
>
{ch.label}
@@ -224,7 +224,7 @@ export default function BudgetSimulator({
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-brand-300">
<span className="text-xs font-semibold text-brand-700">
Esperado (p50)
</span>
<Sparkles className="w-4 h-4 text-brand-400" />

View File

@@ -124,7 +124,7 @@ export default function ClientIntegrationsPanel({
<div className="flex items-center gap-3">
<Link
href="/dashboard/agencia"
className="text-gray-400 hover:text-gray-900"
className="text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4" />
</Link>

View File

@@ -233,7 +233,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`
</div>
<button
onClick={() => setIsOpen(false)}
className="text-gray-400 hover:text-gray-900 p-1 rounded-lg hover:bg-gray-100 transition-colors"
className="text-gray-600 hover:text-gray-900 p-1 rounded-lg hover:bg-gray-100 transition-colors"
>
<X className="w-5 h-5" />
</button>
@@ -409,7 +409,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`
<button
type="button"
onClick={() => setIsOpen(false)}
className="px-4 py-2 rounded-lg text-xs font-semibold text-gray-400 hover:text-gray-900 hover:bg-gray-50 transition-all cursor-pointer"
className="px-4 py-2 rounded-lg text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition-all cursor-pointer"
>
Cancelar
</button>

View File

@@ -103,7 +103,7 @@ export default function CrmDashboard({
actions={
<div className="flex items-center gap-2">
<CalendarRange className="w-4 h-4 text-gray-500" />
<div className="flex gap-1 rounded-lg border border-white/[0.08] p-0.5 bg-gray-50">
<div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 bg-gray-50">
{(
[
['7d', '7d'],
@@ -116,10 +116,10 @@ export default function CrmDashboard({
key={k}
type="button"
onClick={() => setRange(k)}
className={`px-2.5 py-1 text-xs rounded-md ${
className={`px-2.5 py-1 text-xs font-semibold rounded-md ${
range === k
? 'bg-brand-500/20 text-brand-300'
: 'text-gray-400 hover:text-white'
? 'bg-brand-500 text-white shadow-sm'
: 'text-gray-600 hover:text-gray-900 hover:bg-white'
}`}
>
{lbl}
@@ -131,7 +131,7 @@ export default function CrmDashboard({
/>
{!crmLeads.length ? (
<p className="text-sm text-amber-400/90 border border-amber-500/20 rounded-lg px-4 py-3">
<p className="text-sm text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-4 py-3">
Sin leads Clientify en este contexto. Importa el histórico o activa el
webhook (`?tenant=…`). En portafolio, elige un cliente en Contexto.
</p>
@@ -238,7 +238,7 @@ export default function CrmDashboard({
{statusRows.map((row) => (
<tr
key={row.status}
className="border-b border-gray-900 hover:bg-white/[0.02]"
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2.5 pr-3 text-gray-800 capitalize">
{row.status}
@@ -308,9 +308,9 @@ function ChannelTable({
return (
<tr
key={row.channel}
className="border-b border-gray-900 hover:bg-white/[0.02]"
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2.5 pr-3 text-brand-300 font-semibold capitalize">
<td className="py-2.5 pr-3 text-brand-700 font-semibold capitalize">
{row.channel}
</td>
<td className="py-2.5 pr-3 text-gray-800">

View File

@@ -102,8 +102,8 @@ export default function CrmPortfolioBoard({
onClick={() => setRange(k)}
className={`px-2.5 py-1 text-xs rounded-md ${
range === k
? 'bg-brand-500/20 text-brand-300'
: 'text-gray-400 hover:text-white'
? 'bg-brand-500 text-white shadow-sm'
: 'text-gray-600 hover:text-gray-900 hover:bg-white'
}`}
>
{lbl}
@@ -137,7 +137,7 @@ export default function CrmPortfolioBoard({
{byTenant.map((t) => (
<tr
key={t.tenantId}
className="border-b border-gray-900 hover:bg-white/[0.02]"
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2.5 pr-3 text-gray-900 font-semibold">
{t.name}
@@ -216,9 +216,9 @@ export default function CrmPortfolioBoard({
{t.channels.slice(0, 12).map((row) => (
<tr
key={row.channel}
className="border-b border-gray-900 hover:bg-white/[0.02]"
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2 pr-3 text-brand-300 font-semibold capitalize">
<td className="py-2 pr-3 text-brand-700 font-semibold capitalize">
{row.channel}
</td>
<td className="py-2 pr-3 text-gray-800">

View File

@@ -19,6 +19,7 @@ import {
AttributionChannelRow,
ChannelWeekPoint,
CrmLeadRow,
DealWonRow,
Delta,
MetricTotals,
formatEur,
@@ -26,14 +27,16 @@ import {
formatPct,
attributionCross,
filterDailyByDayRange,
filterDealsForRange,
filterLeadsByDayRange,
metricTotalsFromDaily,
resolveKpiDayRange,
sumWeekly,
withCohortFromDeals,
KpiWeeklyRow,
KpiRangeKey,
} from '@/lib/metrics';
import { PageHeader } from '@/components/ui';
import { PageHeader, SegmentedControl } from '@/components/ui';
type Card = {
key: keyof MetricTotals;
@@ -42,11 +45,28 @@ 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. */
cohort?: boolean;
};
type RangeKey = KpiRangeKey;
function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
function DeltaTriple({
delta,
invert,
empty,
}: {
delta: Delta;
invert?: boolean;
empty?: 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 el periodo — sincroniza deals Clientify
</p>
);
}
const cells: { label: string; v: number | null }[] = [
{ label: 'WoW', v: delta.wow },
{ label: 'MoM', v: delta.mom },
@@ -55,16 +75,15 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
return (
<div className="mt-3 grid grid-cols-3 gap-1.5">
{cells.map((c) => {
const good =
c.v === null ? null : invert ? c.v < 0 : c.v > 0;
const good = c.v === null ? null : invert ? c.v < 0 : c.v > 0;
const color =
c.v === null
? 'text-gray-500'
: good
? 'text-emerald-400'
? 'text-emerald-700'
: c.v === 0
? 'text-gray-400'
: 'text-red-400';
? 'text-gray-600'
: 'text-red-600';
const Icon =
c.v === null || c.v === 0
? Minus
@@ -74,15 +93,15 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
return (
<div
key={c.label}
className="rounded-md bg-gray-50 border border-gray-200 px-1.5 py-1 text-center"
className="rounded-md bg-gray-50 border border-gray-200 px-1.5 py-1.5 text-center"
>
<p className="text-[9px] uppercase tracking-wider text-gray-500">
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600">
{c.label}
</p>
<p
className={`text-[11px] font-semibold inline-flex items-center justify-center gap-0.5 ${color}`}
className={`text-xs font-bold inline-flex items-center justify-center gap-0.5 ${color}`}
>
<Icon className="w-3 h-3" />
<Icon className="w-3.5 h-3.5" />
{formatPct(c.v)}
</p>
</div>
@@ -93,11 +112,11 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
}
function gapClass(gap: number): string {
if (gap === 0) return 'text-gray-400';
if (gap === 0) return 'text-gray-600';
const abs = Math.abs(gap);
if (abs <= 5) return 'text-emerald-400';
if (abs <= 25) return 'text-amber-400';
return 'text-red-400';
if (abs <= 5) return 'text-emerald-700';
if (abs <= 25) return 'text-amber-700';
return 'text-red-600';
}
export default function MetricsDashboard({
@@ -112,6 +131,7 @@ export default function MetricsDashboard({
channelPoints = [],
dailyRows = [],
crmLeads = [],
wonDeals = [],
}: {
label: string;
portfolioWide: boolean;
@@ -127,10 +147,9 @@ export default function MetricsDashboard({
sparse: boolean;
weeklyRows?: KpiWeeklyRow[];
channelPoints?: ChannelWeekPoint[];
/** Raw ad_daily — period totals/matrix use calendar days (same as Ad Tracker). */
dailyRows?: AdDailyRow[];
/** Clientify leads for CRM vs platform attribution. */
crmLeads?: CrmLeadRow[];
wonDeals?: DealWonRow[];
}) {
const [range, setRange] = useState<RangeKey>('7d');
@@ -148,25 +167,39 @@ export default function MetricsDashboard({
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until);
}, [crmLeads, dayWindow]);
const rangedDeals = useMemo(
() =>
filterDealsForRange(
wonDeals,
dayWindow?.since ?? null,
dayWindow?.until ?? null
),
[wonDeals, dayWindow]
);
const periodTotals = useMemo(() => {
if (rangedDaily.length) return metricTotalsFromDaily(rangedDaily);
// Fallback when no ad_daily: approximate from weekly (legacy)
if (!dayWindow) return sumWeekly(weeklyRows);
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 ranged.length ? sumWeekly(ranged) : month;
}, [rangedDaily, weeklyRows, dayWindow, month]);
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;
});
base = ranged.length ? sumWeekly(ranged) : month;
}
return withCohortFromDeals(base, rangedDeals);
}, [rangedDaily, rangedDeals, weeklyRows, dayWindow, month]);
const attributionRows: AttributionChannelRow[] = useMemo(() => {
if (rangedDaily.length || rangedLeads.length) {
return attributionCross(rangedDaily, rangedLeads);
}
// Fallback: weekly series without CRM cross
const map = new Map<string, { spend: number; leads: number }>();
const src =
range === 'all'
@@ -197,6 +230,7 @@ export default function MetricsDashboard({
const crmPeriodTotal = rangedLeads.length;
const hasCrm = crmLeads.length > 0;
const hasCohort = rangedDeals.length > 0;
const rangeHint = dayWindow
? `${dayWindow.since} → ${dayWindow.until}`
@@ -208,44 +242,51 @@ export default function MetricsDashboard({
label: 'Inversión ads',
hint: 'Paid del periodo',
format: formatEur,
icon: <DollarSign className="w-4 h-4 text-brand-500" />,
icon: <DollarSign className="w-4 h-4 text-brand-600" />,
},
{
key: 'leads',
label: 'Leads',
hint: 'Captación periodo',
format: formatNum,
icon: <Users className="w-4 h-4 text-blue-400" />,
icon: <Users className="w-4 h-4 text-blue-600" />,
},
{
key: 'cpl',
label: 'CPL',
hint: 'Coste por lead',
format: (v) => `${formatNum(v)} €`,
icon: <MousePointerClick className="w-4 h-4 text-violet-400" />,
icon: <MousePointerClick className="w-4 h-4 text-violet-600" />,
invert: true,
},
{
key: 'cac',
label: 'CAC cohorte',
hint: 'Paid / cierres (parcial v1)',
hint: hasCohort
? `Paid ÷ ${formatNum(periodTotals.deals)} cierres`
: 'Paid ÷ deals ganados Clientify',
format: (v) => `${formatNum(v)} €`,
icon: <Target className="w-4 h-4 text-amber-400" />,
icon: <Target className="w-4 h-4 text-amber-600" />,
invert: true,
cohort: true,
},
{
key: 'revenue',
label: 'Revenue cohorte',
hint: 'Deals won atribuidos',
hint: hasCohort
? `${formatNum(periodTotals.deals)} deals won`
: 'Suma amount deals won',
format: formatEur,
icon: <TrendingUp className="w-4 h-4 text-emerald-400" />,
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
cohort: true,
},
{
key: 'roas',
label: 'ROAS cohorte',
hint: 'Revenue / spend',
hint: 'Revenue ÷ spend',
format: (v) => `${formatNum(v)}x`,
icon: <TrendingUp className="w-4 h-4 text-emerald-300" />,
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
cohort: true,
},
];
@@ -257,42 +298,38 @@ export default function MetricsDashboard({
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-500" />
<div className="flex gap-1 rounded-lg border border-white/[0.08] p-0.5 bg-gray-50">
{(
[
['7d', '7d'],
['30d', '30d'],
['month', 'Mes'],
['all', 'Todo'],
] as [RangeKey, string][]
).map(([k, lbl]) => (
<button
key={k}
type="button"
onClick={() => setRange(k)}
className={`px-2.5 py-1 text-xs rounded-md ${
range === k
? 'bg-brand-500/20 text-brand-300'
: 'text-gray-400 hover:text-white'
}`}
>
{lbl}
</button>
))}
</div>
<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>
}
/>
{sparse && (
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
<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>
)}
{/* North Star strip */}
{!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.
</p>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: 'Inversión periodo', value: formatEur(periodTotals.spend) },
@@ -316,45 +353,54 @@ export default function MetricsDashboard({
].map((s) => (
<div
key={s.label}
className="rounded-xl border border-brand-500/20 bg-brand-500/5 px-4 py-3"
className="rounded-xl border border-brand-200 bg-brand-50 px-4 py-3 shadow-sm"
>
<p className="text-[10px] uppercase tracking-wider text-brand-400/80">
<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">{s.value}</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) => (
<div
key={c.key}
className="bg-white border border-gray-200 rounded-xl p-5"
>
<div className="flex items-center justify-between text-gray-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">
{c.label}
</span>
{c.icon}
{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={deltas[c.key]}
invert={c.invert}
empty={emptyCohort}
/>
</div>
<p className="text-2xl font-bold text-gray-900">
{c.format(periodTotals[c.key])}
</p>
<p className="text-[11px] text-gray-500 mt-1">{c.hint}</p>
<DeltaTriple delta={deltas[c.key]} invert={c.invert} />
</div>
))}
);
})}
</div>
{/* Channel attribution cross */}
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
<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">
<div>
<h3 className="text-sm font-semibold text-gray-900">
Cruce por canal · ads vs CRM
</h3>
<p className="text-[11px] text-gray-500 mt-0.5">
<p className="text-xs text-gray-600 mt-0.5">
Plataforma = leads reportados por Meta/Google · CRM = atribución
Clientify (origen/UTM)
{!hasCrm && portfolioWide
@@ -362,61 +408,61 @@ export default function MetricsDashboard({
: ''}
</p>
</div>
<p className="text-[11px] text-gray-500 shrink-0">
<p className="text-xs text-gray-600 shrink-0 font-medium">
Gap = CRM − plat
</p>
</div>
{attributionRows.length === 0 ? (
<p className="text-xs text-gray-500 py-6 text-center">
<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).
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="text-gray-500 border-b border-gray-200">
<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-medium">Canal</th>
<th className="py-2 pr-3 font-medium">Inversión</th>
<th className="py-2 pr-3 font-medium">Leads plat</th>
<th className="py-2 pr-3 font-medium">Leads CRM</th>
<th className="py-2 pr-3 font-medium">Gap</th>
<th className="py-2 pr-3 font-medium">CPL plat</th>
<th className="py-2 font-medium">CPL CRM</th>
<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">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-900 hover:bg-white/[0.02]"
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-400 hover:text-brand-300 font-semibold capitalize"
className="text-brand-700 hover:text-brand-600 font-semibold capitalize"
>
{row.channel}
</Link>
) : (
<span className="text-gray-600 font-semibold capitalize">
<span className="text-gray-700 font-semibold capitalize">
{row.channel}
</span>
)}
</td>
<td className="py-2.5 pr-3 text-gray-800">
<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">
<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">
<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-medium ${
className={`py-2.5 pr-3 font-semibold tabular-nums ${
hasCrm ? gapClass(row.gap) : 'text-gray-500'
}`}
>
@@ -424,12 +470,12 @@ export default function MetricsDashboard({
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
: '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800">
<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">
<td className="py-2.5 text-gray-800 tabular-nums">
{hasCrm && row.cplCrm
? `${formatNum(row.cplCrm)} €`
: '—'}
@@ -440,52 +486,45 @@ export default function MetricsDashboard({
</table>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 pt-2">
{['GEO', 'CRM close %', 'LTV'].map((soon) => (
<div
key={soon}
className="rounded-lg border border-dashed border-gray-200 px-3 py-2 text-[11px] text-gray-500"
>
{soon} · fase 2 (stub)
</div>
))}
</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">
<div className="flex items-center justify-between text-gray-400 mb-2">
<span className="text-xs uppercase tracking-wider">
<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-400" />
<Eye className="w-4 h-4 text-sky-600" />
</div>
<p className="text-lg font-bold text-gray-900">{formatEur(daily.spend)}</p>
<p className="text-xs text-gray-500 mt-1">
<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-[11px] text-gray-600 mt-2">
<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">
<p className="text-xs uppercase tracking-wider text-gray-500 mb-2">
<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-xs text-gray-400 space-y-1 list-disc list-inside">
<ul className="text-sm text-gray-700 space-y-1.5 list-disc list-inside">
<li>
El selector filtra el mismo calendario que Ad Tracker; el cruce
compara leads de la plataforma ads vs atribución Clientify.
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 (whatsapp, manual…) son orgánicos/CRM.
spend son orgánicos/CRM.
</li>
<li>
CPL CRM usa inversión paid ÷ leads CRM del canal (más realista
que el CPL de la plataforma cuando hay desfase).
CPL CRM = inversión paid ÷ leads CRM del canal (más realista
cuando hay desfase con la plataforma).
</li>
</ul>
</div>

View File

@@ -167,7 +167,7 @@ export default function RrssArchiveYear({
<div>
<Link
href="/dashboard/calendario?view=hub"
className="text-xs text-gray-400 hover:text-gray-900"
className="text-xs text-gray-600 hover:text-gray-900"
>
← Hub RRSS
</Link>

View File

@@ -587,7 +587,7 @@ export default function RrssMonthView({
{selected.body}
</div>
{selected.cta && (
<p className="text-xs text-brand-300">👉 {selected.cta}</p>
<p className="text-xs text-brand-700">👉 {selected.cta}</p>
)}
{selected.hashtags && (
<p className="text-xs text-sky-400 font-mono">{selected.hashtags}</p>

View File

@@ -518,8 +518,8 @@ export default function SeguimientoBoard() {
onClick={() => setMetric(k)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
metric === k
? 'bg-brand-500/20 text-brand-400'
: 'text-gray-400 hover:text-white'
? 'bg-brand-500 text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{label}
@@ -667,7 +667,7 @@ export default function SeguimientoBoard() {
target="_blank"
rel="noreferrer"
onClick={(ev) => ev.stopPropagation()}
className="inline-flex items-center gap-1 text-[11px] text-brand-400 hover:text-brand-300"
className="inline-flex items-center gap-1 text-[11px] text-brand-400 hover:text-brand-700"
>
Superhuman <ExternalLink className="w-3 h-3" />
</a>

View File

@@ -99,7 +99,7 @@ export default function SuperhumanSyncPanel() {
type="button"
onClick={runSync}
disabled={loading || syncing}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-brand-500/20 text-brand-300 border border-brand-500/30 hover:bg-brand-500/30"
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-brand-500/20 text-brand-700 border border-brand-500/30 hover:bg-brand-500/30"
>
{syncing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -146,7 +146,7 @@ export default function SuperhumanSyncPanel() {
</thead>
<tbody>
{matches.slice(0, 20).map((m) => (
<tr key={m.tenantName || m.docId} className="border-b border-gray-900">
<tr key={m.tenantName || m.docId} className="border-b border-gray-100">
<td className="py-1.5 pr-2 text-gray-600">
{m.tenantName || '—'}
</td>

View File

@@ -24,7 +24,7 @@ export function PageHeader({
{title}
</h1>
{description ? (
<p className="text-sm text-gray-500 max-w-2xl leading-relaxed">
<p className="text-sm text-gray-600 max-w-2xl leading-relaxed">
{description}
</p>
) : null}
@@ -85,10 +85,43 @@ export function EmptyState({
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 px-6 py-10 text-center">
<p className="text-sm font-medium text-gray-800">{title}</p>
{description ? (
<p className="text-xs text-gray-500 mt-1.5 max-w-md mx-auto leading-relaxed">
<p className="text-xs text-gray-600 mt-1.5 max-w-md mx-auto leading-relaxed">
{description}
</p>
) : null}
</div>
);
}
/** Selector de periodo / pestañas — contraste alto sobre fondo blanco. */
export function SegmentedControl<T extends string>({
value,
options,
onChange,
size = 'md',
}: {
value: T;
options: { id: T; label: string }[];
onChange: (id: T) => void;
size?: 'sm' | 'md';
}) {
const pad = size === 'sm' ? 'px-2.5 py-1 text-xs' : 'px-3 py-1.5 text-sm';
return (
<div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 bg-gray-100">
{options.map((o) => (
<button
key={o.id}
type="button"
onClick={() => onChange(o.id)}
className={`${pad} rounded-md font-semibold transition-colors ${
value === o.id
? 'bg-brand-500 text-white shadow-sm'
: 'text-gray-600 hover:text-gray-900 hover:bg-white'
}`}
>
{o.label}
</button>
))}
</div>
);
}

View File

@@ -402,6 +402,83 @@ export function metricTotalsFromDaily(rows: AdDailyRow[]): MetricTotals {
};
}
/** Won Clientify deals — source of truth for cohort CAC / revenue / ROAS. */
export type DealWonRow = {
amount?: number | null;
won_at?: string | null;
stage?: string | null;
tenant_id?: string | null;
};
export function filterDealsForRange(
deals: DealWonRow[],
since: string | null,
until: string | null
): DealWonRow[] {
const won = deals.filter((d) => !d.stage || d.stage === 'won');
if (!since || !until) return won;
return won.filter((d) => {
const day = (d.won_at || '').slice(0, 10);
if (!day) return false;
return day >= since && day <= until;
});
}
/** Overlay CRM won deals onto ad spend/leads totals. */
export function withCohortFromDeals(
base: MetricTotals,
deals: DealWonRow[]
): MetricTotals {
const dealCount = deals.length;
const revenue = deals.reduce((a, d) => a + n(d.amount), 0);
return {
...base,
deals: dealCount,
revenue,
cac: dealCount > 0 ? base.spend / dealCount : 0,
roas: base.spend > 0 ? revenue / base.spend : 0,
};
}
/**
* Inject won-deal cohort into weekly rows so WoW/MoM/YoY include CRM closes.
* Ads sync leaves cohort_* at 0; without this, CAC/ROAS deltas stay empty.
*/
export function mergeDealsIntoWeekly(
weekly: KpiWeeklyRow[],
deals: DealWonRow[]
): KpiWeeklyRow[] {
const byWeek = new Map<string, { deals: number; revenue: number }>();
for (const d of deals) {
if (d.stage && d.stage !== 'won') continue;
const day = (d.won_at || '').slice(0, 10);
if (!day) continue;
const dt = new Date(`${day}T00:00:00Z`);
if (Number.isNaN(dt.getTime())) continue;
const week = isoDate(weekStart(dt));
const cur = byWeek.get(week) || { deals: 0, revenue: 0 };
cur.deals += 1;
cur.revenue += n(d.amount);
byWeek.set(week, cur);
}
if (!byWeek.size) return weekly;
const out = weekly.map((r) => ({ ...r }));
for (const [week, v] of byWeek) {
out.push({
week: `${week}T00:00:00+00:00`,
channel: 'crm',
total_spend: 0,
total_leads: 0,
cohort_deals_won: v.deals,
cohort_revenue_won: Math.round(v.revenue * 100) / 100,
cac_cohort: 0,
roas_cohort: 0,
});
}
return out;
}
/** CRM lead row used for channel attribution cross. */
export type CrmLeadRow = {
source?: string | null;