From 21051afda95410d0becb6d113c5cc430ec67be47 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 24 Sep 2026 10:40:12 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20cohort=20CAC/ROAS=20desde=20deals=20Clie?= =?UTF-8?q?ntify=20y=20m=C3=A1s=20contraste=20en=20dashboards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/dashboard/agencia/page.tsx | 8 +- src/app/dashboard/asistente/page.tsx | 2 +- .../dashboard/calendario/CalendarClient.tsx | 12 +- .../dashboard/kpis/canal/[channel]/page.tsx | 2 +- src/app/dashboard/kpis/page.tsx | 58 +++- src/components/AdCreativesGallery.tsx | 6 +- src/components/AgencyPortfolioBoard.tsx | 4 +- src/components/AssetsLibrary.tsx | 6 +- src/components/BudgetSimulator.tsx | 4 +- src/components/ClientIntegrationsPanel.tsx | 2 +- src/components/ClientManagement.tsx | 4 +- src/components/CrmDashboard.tsx | 16 +- src/components/CrmPortfolioBoard.tsx | 10 +- src/components/MetricsDashboard.tsx | 305 ++++++++++-------- src/components/RrssArchiveYear.tsx | 2 +- src/components/RrssMonthView.tsx | 2 +- src/components/SeguimientoBoard.tsx | 6 +- src/components/SuperhumanSyncPanel.tsx | 4 +- src/components/ui.tsx | 37 ++- src/lib/metrics.ts | 77 +++++ 20 files changed, 383 insertions(+), 184 deletions(-) diff --git a/src/app/dashboard/agencia/page.tsx b/src/app/dashboard/agencia/page.tsx index 9173639..9693bbd 100644 --- a/src/app/dashboard/agencia/page.tsx +++ b/src/app/dashboard/agencia/page.tsx @@ -84,7 +84,7 @@ export default async function AgencyPage() {
- Clientes / Tenants + Clientes / Tenants

{tenants.length}

@@ -93,7 +93,7 @@ export default async function AgencyPage() {
- Inversión Gestionada + Inversión Gestionada

@@ -104,7 +104,7 @@ export default async function AgencyPage() {

- Leads Captados + Leads Captados

{totalLeads}

@@ -113,7 +113,7 @@ export default async function AgencyPage() {
- ROAS Blended Agencia + ROAS Blended Agencia

{blendedRoas}x

diff --git a/src/app/dashboard/asistente/page.tsx b/src/app/dashboard/asistente/page.tsx index 0b7197c..a23f358 100644 --- a/src/app/dashboard/asistente/page.tsx +++ b/src/app/dashboard/asistente/page.tsx @@ -212,7 +212,7 @@ export default function AsistentePage() {
@@ -429,7 +431,7 @@ export default function CalendarClient({ {selectedPost.hook} {selectedPost.body} - + {selectedPost.cta} @@ -593,7 +595,7 @@ function PostCard({ diff --git a/src/app/dashboard/kpis/canal/[channel]/page.tsx b/src/app/dashboard/kpis/canal/[channel]/page.tsx index 8f3971a..34837ab 100644 --- a/src/app/dashboard/kpis/canal/[channel]/page.tsx +++ b/src/app/dashboard/kpis/canal/[channel]/page.tsx @@ -82,7 +82,7 @@ export default async function CanalDashPage({
Volver a métricas diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index 0752b6c..d609994 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -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 { + 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( '/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( @@ -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} /> ); } diff --git a/src/components/AdCreativesGallery.tsx b/src/components/AdCreativesGallery.tsx index 6798378..4c724f5 100644 --- a/src/components/AdCreativesGallery.tsx +++ b/src/components/AdCreativesGallery.tsx @@ -222,7 +222,7 @@ function CreativeCard({ @@ -297,7 +297,7 @@ function CreativeCard({
) : null} {ad.cta ? ( -

+

CTA · {ad.cta}

) : null} @@ -423,7 +423,7 @@ export function CreativeZoomModal({

{ad.cta ? (

- CTA: {ad.cta} + CTA: {ad.cta}

) : null} {ad.businessName ? ( diff --git a/src/components/AgencyPortfolioBoard.tsx b/src/components/AgencyPortfolioBoard.tsx index 8e34e68..65662d6 100644 --- a/src/components/AgencyPortfolioBoard.tsx +++ b/src/components/AgencyPortfolioBoard.tsx @@ -252,7 +252,7 @@ export default function AgencyPortfolioBoard() { diff --git a/src/components/BudgetSimulator.tsx b/src/components/BudgetSimulator.tsx index 56d58e2..a291cd7 100644 --- a/src/components/BudgetSimulator.tsx +++ b/src/components/BudgetSimulator.tsx @@ -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({
- + Esperado (p50) diff --git a/src/components/ClientIntegrationsPanel.tsx b/src/components/ClientIntegrationsPanel.tsx index 6e79f14..81571af 100644 --- a/src/components/ClientIntegrationsPanel.tsx +++ b/src/components/ClientIntegrationsPanel.tsx @@ -124,7 +124,7 @@ export default function ClientIntegrationsPanel({
diff --git a/src/components/ClientManagement.tsx b/src/components/ClientManagement.tsx index c8d4532..4e16c5f 100644 --- a/src/components/ClientManagement.tsx +++ b/src/components/ClientManagement.tsx @@ -233,7 +233,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`
@@ -409,7 +409,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.` diff --git a/src/components/CrmDashboard.tsx b/src/components/CrmDashboard.tsx index 2a195bb..1b36be8 100644 --- a/src/components/CrmDashboard.tsx +++ b/src/components/CrmDashboard.tsx @@ -103,7 +103,7 @@ export default function CrmDashboard({ actions={
-
+
{( [ ['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 ? ( -

+

Sin leads Clientify en este contexto. Importa el histórico o activa el webhook (`?tenant=…`). En portafolio, elige un cliente en Contexto.

@@ -238,7 +238,7 @@ export default function CrmDashboard({ {statusRows.map((row) => ( {row.status} @@ -308,9 +308,9 @@ function ChannelTable({ return ( - + {row.channel} diff --git a/src/components/CrmPortfolioBoard.tsx b/src/components/CrmPortfolioBoard.tsx index ba94cf8..3cd8e5c 100644 --- a/src/components/CrmPortfolioBoard.tsx +++ b/src/components/CrmPortfolioBoard.tsx @@ -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) => ( {t.name} @@ -216,9 +216,9 @@ export default function CrmPortfolioBoard({ {t.channels.slice(0, 12).map((row) => ( - + {row.channel} diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index 9455508..279b81c 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -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 ( +

+ Sin cierres en el periodo — sincroniza deals Clientify +

+ ); + } 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 (
{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 (
-

+

{c.label}

- + {formatPct(c.v)}

@@ -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('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(); 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: , + icon: , }, { key: 'leads', label: 'Leads', hint: 'Captación periodo', format: formatNum, - icon: , + icon: , }, { key: 'cpl', label: 'CPL', hint: 'Coste por lead', format: (v) => `${formatNum(v)} €`, - icon: , + icon: , 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: , + icon: , 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: , + icon: , + cohort: true, }, { key: 'roas', label: 'ROAS cohorte', - hint: 'Revenue / spend', + hint: 'Revenue ÷ spend', format: (v) => `${formatNum(v)}x`, - icon: , + icon: , + cohort: true, }, ]; @@ -257,42 +298,38 @@ export default function MetricsDashboard({ description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (mismos días calendario que Ad Tracker).`} actions={
- -
- {( - [ - ['7d', '7d'], - ['30d', '30d'], - ['month', 'Mes'], - ['all', 'Todo'], - ] as [RangeKey, string][] - ).map(([k, lbl]) => ( - - ))} -
+ +
} /> {sparse && ( -

+

Histórico limitado: si faltan deltas o canales, el warehouse aún no tiene semanas suficientes.

)} - {/* North Star strip */} + {!hasCohort && ( +

+ CAC / Revenue / ROAS a cero: no hay deals ganados con fecha de cierre + en este periodo. Se calculan con{' '} + deals Clientify (stage=won), no + solo con leads de ads. +

+ )} +
{[ { label: 'Inversión periodo', value: formatEur(periodTotals.spend) }, @@ -316,45 +353,54 @@ export default function MetricsDashboard({ ].map((s) => (
-

+

{s.label}

-

{s.value}

+

+ {s.value} +

))}
- {cards.map((c) => ( -
-
- - {c.label} - - {c.icon} + {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.format(periodTotals[c.key])} -

-

{c.hint}

- -
- ))} + ); + })}
- {/* Channel attribution cross */} -
+

Cruce por canal · ads vs CRM

-

+

Plataforma = leads reportados por Meta/Google · CRM = atribución Clientify (origen/UTM) {!hasCrm && portfolioWide @@ -362,61 +408,61 @@ export default function MetricsDashboard({ : ''}

-

+

Gap = CRM − plat

{attributionRows.length === 0 ? ( -

+

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).

) : (
- - +
+ - - - - - - - + + + + + + + {attributionRows.map((row) => ( - - - - -
CanalInversiónLeads platLeads CRMGapCPL platCPL CRMCanalInversiónLeads platLeads CRMGapCPL platCPL CRM
{row.spend > 0 ? ( {row.channel} ) : ( - + {row.channel} )} + {row.spend ? formatEur(row.spend) : '—'} + {formatNum(row.platformLeads)} + {hasCrm ? formatNum(row.crmLeads) : '—'} @@ -424,12 +470,12 @@ export default function MetricsDashboard({ ? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}` : '—'} + {row.cplPlatform ? `${formatNum(row.cplPlatform)} €` : '—'} + {hasCrm && row.cplCrm ? `${formatNum(row.cplCrm)} €` : '—'} @@ -440,52 +486,45 @@ export default function MetricsDashboard({
)} -
- {['GEO', 'CRM close %', 'LTV'].map((soon) => ( -
- {soon} · fase 2 (stub) -
- ))} -
-
-
- +
+
+ Ads · ventana diaria - +
-

{formatEur(daily.spend)}

-

+

+ {formatEur(daily.spend)} +

+

{formatNum(daily.leads)} leads · {formatNum(daily.impressions)} impr. · {formatNum(daily.clicks)} clicks

-

+

Semana actual: {formatEur(week.spend)} · {formatNum(week.leads)}{' '} leads

-
-

+

+

Cómo leer

-
    +
    • - 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 ( + won_at en el periodo), no + leads de Meta/Google.
    • Gap cercano a 0 en Meta/Google = buena atribución. Canales sin - spend (whatsapp, manual…) son orgánicos/CRM. + spend son orgánicos/CRM.
    • - 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).
diff --git a/src/components/RrssArchiveYear.tsx b/src/components/RrssArchiveYear.tsx index 9ded0b9..c969705 100644 --- a/src/components/RrssArchiveYear.tsx +++ b/src/components/RrssArchiveYear.tsx @@ -167,7 +167,7 @@ export default function RrssArchiveYear({
← Hub RRSS diff --git a/src/components/RrssMonthView.tsx b/src/components/RrssMonthView.tsx index 0a3c8c4..7c832ca 100644 --- a/src/components/RrssMonthView.tsx +++ b/src/components/RrssMonthView.tsx @@ -587,7 +587,7 @@ export default function RrssMonthView({ {selected.body}
{selected.cta && ( -

👉 {selected.cta}

+

👉 {selected.cta}

)} {selected.hashtags && (

{selected.hashtags}

diff --git a/src/components/SeguimientoBoard.tsx b/src/components/SeguimientoBoard.tsx index d532739..4428da4 100644 --- a/src/components/SeguimientoBoard.tsx +++ b/src/components/SeguimientoBoard.tsx @@ -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 diff --git a/src/components/SuperhumanSyncPanel.tsx b/src/components/SuperhumanSyncPanel.tsx index 4e5061a..9000898 100644 --- a/src/components/SuperhumanSyncPanel.tsx +++ b/src/components/SuperhumanSyncPanel.tsx @@ -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 ? ( @@ -146,7 +146,7 @@ export default function SuperhumanSyncPanel() { {matches.slice(0, 20).map((m) => ( - + {m.tenantName || '—'} diff --git a/src/components/ui.tsx b/src/components/ui.tsx index 7848d3e..4f9510d 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -24,7 +24,7 @@ export function PageHeader({ {title} {description ? ( -

+

{description}

) : null} @@ -85,10 +85,43 @@ export function EmptyState({

{title}

{description ? ( -

+

{description}

) : null}
); } + +/** Selector de periodo / pestañas — contraste alto sobre fondo blanco. */ +export function SegmentedControl({ + 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 ( +
+ {options.map((o) => ( + + ))} +
+ ); +} diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 2253595..b2b6258 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -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(); + 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;