From dfb44d240e3967be2d81613a58740bf74133f919 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 6 Oct 2026 13:12:27 +0200 Subject: [PATCH] Align CRM, Ads, Social and Organic dashboards with the metrics visual system. Co-authored-by: Cursor --- src/components/AdTrackerClient.tsx | 300 +++++++++++---------- src/components/CrmDashboard.tsx | 384 ++++++++++++++------------- src/components/OrganicoDashboard.tsx | 182 +++++++------ src/components/SocialDashboard.tsx | 232 +++++++--------- src/components/dash-ui.tsx | 197 ++++++++++++++ 5 files changed, 743 insertions(+), 552 deletions(-) create mode 100644 src/components/dash-ui.tsx diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx index 48fb654..e7cf638 100644 --- a/src/components/AdTrackerClient.tsx +++ b/src/components/AdTrackerClient.tsx @@ -8,9 +8,17 @@ import { CreativesGallery, CreativeZoomModal, } from '@/components/AdCreativesGallery'; -import { PageHeader } from '@/components/ui'; +import { PageHeader, SegmentedControl } from '@/components/ui'; import SyncNowButton from '@/components/SyncNowButton'; import { useDateRange } from '@/components/DateRangeContext'; +import { + HelpDetails, + HeroBand, + HeroStat, + ShareBar, + StatMini, + tableHeadClass, +} from '@/components/dash-ui'; type PlatformKpis = { spend: number; @@ -439,21 +447,6 @@ function SparkBars({ ); } -function KpiTile({ - label, - value, -}: { - label: string; - value: string; -}) { - return ( -
-

{label}

-

{value}

-
- ); -} - function CampaignTable({ title, rows, @@ -463,44 +456,57 @@ function CampaignTable({ rows: CampaignRow[]; empty: string; }) { + const spendMax = Math.max(1, ...rows.map((r) => r.spend)); return ( -
-
-

- Campañas activas +

+
+

+ Campañas

-

{title}

+

+ {title} +

{rows.length === 0 ? ( -

{empty}

+

{empty}

) : (
- + - - - - - + + + + + {rows.slice(0, 25).map((r) => ( - - + - - - - @@ -684,86 +690,78 @@ export default function AdTrackerClient({ ]; return ( -
+
} /> - {/* Tabs */} -
- {( - [ - { id: 'resumen', label: 'Resumen' }, - { id: 'meta', label: 'Meta Ads' }, - { id: 'google', label: 'Google Ads' }, - ] as const - ).map((t) => ( - - ))} -
+ {tab === 'resumen' && (
-
-

- Resumen de cuenta -

-

- Meta + Google, juntos -

-

- Gasto, impresiones, clics y conversiones sumados. CTR, CPC y CPL - se recalculan sobre el total (no se suman). Ventana:{' '} - {range.since} → {range.until}. -

-
+ +
+ + + + +
+
-
- - +

+ Gasto, impresiones, clics y conversiones se suman entre Meta y + Google. CTR, CPC y CPL se recalculan sobre el total (no se + suman porcentajes). +

+ + +
+ - - - + - - - +
-
+
@@ -773,17 +771,21 @@ export default function AdTrackerClient({ title={`Google ${googleShare.toFixed(0)}%`} />
+

+ Reparto de inversión · Meta {metaShare.toFixed(0)}% · Google{' '} + {googleShare.toFixed(0)}% +

- ))} -
+
-

{LENS_HELP[lens]}

+ + +

{LENS_HELP[lens]}

+

+ El % a cliente es del CRM, no de ads. La inversión por canal es solo + referencia del periodo. +

+
+ {lens === 'status' && !hasStatusTiming ? ( -

- Aún no hay status_changed_at en - warehouse — aplicando filtro por alta. Ejecuta{' '} - sql/leads_status_timing.sql en - Supabase y deja que los webhooks actualicen contactos. +

+ Aún no hay fecha de cambio de status en warehouse — filtrando por + alta. Ejecuta sql/leads_status_timing.sql{' '} + y deja que los webhooks actualicen contactos.

) : null} {!crmLeads.length ? ( -

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

+ Sin leads CRM en este contexto. Importa el histórico o activa el + webhook. En portafolio, elige un cliente en Contexto.

) : null} -
- {[ - { - label: 'Leads CRM', - value: formatNum(funnel.total), - icon: , - }, - { - label: 'Clientes (ganado)', - value: formatNum(funnel.cliente), - icon: , - }, - { - label: '% a cliente', - value: `${funnel.clienteRate.toFixed(1)}%`, - icon: , - }, - { - label: 'En oportunidad', - value: formatNum(funnel.enOportunidad), - icon: , - }, - { - label: 'Lead frío', - value: formatNum(funnel.leadFrio), - icon: , - }, - { - label: 'Lead perdido', - value: formatNum(funnel.leadPerdido), - icon: , - }, - ].map((s) => ( -
-
- - {s.label} + +
+ + + {funnel.enOportunidad} en oportunidad - {s.icon} -
-

- {s.value} -

-
- ))} + } + /> + + + {formatNum(funnel.leadFrio)} fríos + + } + /> +
+ + +
+ } + /> + } + /> + } + />
-
-
-

- % a cliente por canal -

-

- Estado Clientify = cliente{' '} - (ganado). Ads spend del periodo es referencia. -

-
- {!channelRows.length ? ( -

- Sin leads en esta lente/periodo. -

- ) : ( -
-
CampañaGastoClicsConv.CPLCampañaGastoClicsConv.CPL
+
{r.name} - {formatEur(r.spend)} + + + {formatNum(r.clicks)} + {formatNum(r.conversions)} + {r.cpl != null ? formatEurCents(r.cpl) : '—'}
- - - - - - - - - - - {[...paidChannels, ...otherChannels].map((row) => ( - - - - - - +
+ } + title="% a cliente por canal" + hint="Estado ganado en el CRM. Ads spend del periodo es referencia." + /> + + {!channelRows.length ? ( +

+ Sin leads en esta lente/periodo. +

+ ) : ( +
+
CanalLeadsClientes% a clienteInversión ads
- {row.channel} - - {formatNum(row.leads)} - - {formatNum(row.cliente)} - - {row.clienteRate.toFixed(1)}% - - {spendByChannel.get(row.channel) - ? formatEur(spendByChannel.get(row.channel)!) - : '—'} -
+ + + + + + + - ))} - -
Canal + Leads + Clientes% a clienteInversión ads
-
- )} -

- La inversión por canal (si hay ads) es solo referencia; el % a cliente - es CRM. Lente activa: {lens}. -

+ + + {[...paidChannels, ...otherChannels].map((row) => ( + + + {row.channel} + + + + + + {formatNum(row.cliente)} + + + {row.clienteRate.toFixed(1)}% + + + {spendByChannel.get(row.channel) + ? formatEur(spendByChannel.get(row.channel)!) + : '—'} + + + ))} + + +
+ )} +
-
-

Desglose de status

- {!statusRows.length ? ( -

Sin datos.

- ) : ( -
- - - - - - - - - - {statusRows.map((row) => ( - - - - +
+ } + title="Desglose de status" + hint="Distribución de contactos en el warehouse con la lente activa" + /> + + {!statusRows.length ? ( +

Sin datos.

+ ) : ( +
+
StatusN%
{row.status} - {formatNum(row.count)} - - {row.pct.toFixed(1)}% -
+ + + + + - ))} - -
StatusN%
-
- )} + + + {statusRows.map((row) => ( + + {row.status} + + + + + {row.pct.toFixed(1)}% + + + ))} + + + + )} +
); diff --git a/src/components/OrganicoDashboard.tsx b/src/components/OrganicoDashboard.tsx index 9d6da40..af27a21 100644 --- a/src/components/OrganicoDashboard.tsx +++ b/src/components/OrganicoDashboard.tsx @@ -33,8 +33,15 @@ import { ShieldAlert, Sparkles, } from 'lucide-react'; -import { PageHeader, SegmentedControl } from '@/components/ui'; +import { PageHeader, SegmentedControl, Surface } from '@/components/ui'; import SyncNowButton from '@/components/SyncNowButton'; +import { + HelpDetails, + HeroBand, + HeroStat, + SparkBars, + StatMini, +} from '@/components/dash-ui'; type Props = { label: string; @@ -55,32 +62,13 @@ function Kpi({ label, value, hint, - accent, }: { label: string; value: string; hint?: string; accent?: boolean; }) { - return ( -
-

- {label} -

-

{value}

- {hint ?

{hint}

: null} -
- ); + return ; } export default function OrganicoDashboard({ @@ -212,8 +200,6 @@ export default function OrganicoDashboard({ sessions: Number(r.ga4_organic_sessions) || 0, })); }, [organic]); - const maxClicks = Math.max(1, ...seoDaily.map((d) => d.clicks)); - const maxSessions = Math.max(1, ...seoDaily.map((d) => d.sessions)); const section = useMemo( () => @@ -431,9 +417,9 @@ export default function OrganicoDashboard({ return (
@@ -462,6 +448,13 @@ export default function OrganicoDashboard({

)} + +

+ Search Console son clics e impresiones en Google. GA4 son sesiones y + conversiones del sitio. No se suman a los leads de anuncios. +

+
+ {/* Connections */}
{[ @@ -576,10 +569,27 @@ export default function OrganicoDashboard({ {/* Paneles SEO por sección */} {section.id === 'seo-overview' && ( <> + +
+ + + + +
+
- - - - {seoDaily.length > 0 && (
-
-

- Clics GSC · 28d + +

+ Clics GSC

-
- {seoDaily.map((d) => ( -
-
-
- ))} -
-
-
-

- Sesiones orgánicas GA4 · 28d + ({ + date: d.day, + value: d.clicks, + }))} + color="bg-emerald-500 hover:bg-emerald-600" + formatValue={(v) => formatNum(v)} + /> + + +

+ Sesiones orgánicas GA4

-
- {seoDaily.map((d) => ( -
-
-
- ))} -
-
+ ({ + date: d.day, + value: d.sessions, + }))} + color="bg-sky-500 hover:bg-sky-600" + formatValue={(v) => formatNum(v)} + /> +
)} {totals.days === 0 && ( @@ -741,36 +742,32 @@ export default function OrganicoDashboard({
{seoDaily.length > 0 ? (
-
-

+ +

Clics · evolución

-
- {seoDaily.map((d) => ( -
-
-
- ))} -
-
-
-

+ ({ + date: d.day, + value: d.clicks, + }))} + color="bg-emerald-500 hover:bg-emerald-600" + formatValue={(v) => formatNum(v)} + /> + + +

Sesiones orgánicas · evolución

-
- {seoDaily.map((d) => ( -
-
-
- ))} -
-
+ ({ + date: d.day, + value: d.sessions, + }))} + color="bg-sky-500 hover:bg-sky-600" + formatValue={(v) => formatNum(v)} + /> +
) : ( @@ -1244,15 +1241,14 @@ function SeoTables({ function SectionHero({ section }: { section: OrganicoSection }) { return ( -
-

- {section.group} -

-

{section.label}

+ +

+ {section.label} +

{section.blurb}

-
+ ); } diff --git a/src/components/SocialDashboard.tsx b/src/components/SocialDashboard.tsx index a02883c..1f93561 100644 --- a/src/components/SocialDashboard.tsx +++ b/src/components/SocialDashboard.tsx @@ -9,16 +9,23 @@ import { Loader2, Share2, ExternalLink, - Users, Eye, - Image as ImageIcon, Heart, MessageCircle, Repeat2, + BarChart3, } from 'lucide-react'; import { PageHeader } from '@/components/ui'; import SyncNowButton from '@/components/SyncNowButton'; import { useDateRange } from '@/components/DateRangeContext'; +import { + HelpDetails, + HeroBand, + HeroStat, + SectionHead, + SparkBars, + StatMini, +} from '@/components/dash-ui'; type Integ = { provider: string; @@ -258,46 +265,6 @@ function fmtDate(iso?: string | null) { } } -function shortDay(day: string) { - const d = day.slice(5); // MM-DD - return d.replace('-', '/'); -} - -function MiniBars({ - series, - color = 'bg-brand-400/80', - empty = 'Sin serie en el periodo', -}: { - series?: DayPoint[]; - color?: string; - empty?: string; -}) { - const pts = series || []; - if (!pts.length) { - return

{empty}

; - } - const max = Math.max(1, ...pts.map((p) => p.value)); - return ( -
- {pts.map((p) => { - const pct = Math.max(6, Math.round((p.value / max) * 100)); - return ( -
-
-
- ); - })} -
- ); -} - function seriesDelta(series?: DayPoint[]): { abs: number; pct: number | null; @@ -326,38 +293,38 @@ function ChartCard({ }) { const delta = seriesDelta(series); return ( -
+
-

+

{title}

- {hint ?

{hint}

: null} + {hint ?

{hint}

: null}
-

{n(total)}

+

+ {n(total)} +

{delta ? (

{delta.up ? '↑' : '↓'} {n(Math.abs(delta.abs))} {delta.pct != null ? ` (${delta.pct > 0 ? '+' : ''}${delta.pct.toFixed(0)}%)` : ''} - inicio→fin

) : null}
- - {series && series.length > 1 ? ( -
- {shortDay(series[0].day)} - {shortDay(series[series.length - 1].day)} -
- ) : null} + ({ date: p.day, value: p.value }))} + color={color} + formatValue={(v) => n(v)} + empty="Sin serie en el periodo" + />
); } @@ -410,66 +377,43 @@ export default function SocialDashboard({ [posts] ); - const kpiCards = [ + const kpiIg = [ { - label: 'Seguidores IG (global)', - value: n(ig?.followers_count), - icon: Users, - }, - { - label: 'Publicaciones IG (global)', + label: 'Publicaciones IG', value: n(ig?.media_count), - icon: ImageIcon, - }, - { - label: 'Alcance IG (periodo)', - value: n(ig?.igInsights?.reach), - icon: Eye, + hint: 'Stock del perfil', }, { label: 'Visualizaciones IG', value: n(ig?.igInsights?.impressions), - icon: Eye, + hint: 'Periodo', }, { - label: 'Visitas perfil IG', + label: 'Visitas perfil', value: n(ig?.igInsights?.profileViews), - icon: Users, - }, - { - label: 'Interacciones IG', - value: n(ig?.igInsights?.interactions ?? ig?.igInsights?.accountsEngaged), - icon: Heart, + hint: 'Instagram', }, { label: 'Clics web IG', value: n(ig?.igInsights?.websiteClicks), - icon: ExternalLink, + hint: 'Desde el perfil', }, { - label: 'Δ seguidores IG', + label: 'Δ seguidores', value: n(ig?.igInsights?.followerDelta), - icon: Users, - }, - { - label: 'Fans FB (global)', - value: n(fb?.followers_count ?? fb?.fan_count ?? fb?.pageInsights?.fans), - icon: Users, - }, - { - label: 'Alcance FB (periodo)', - value: n(fb?.pageInsights?.reach), - icon: Eye, + hint: 'Cambio en el periodo', }, + ]; + const kpiFb = [ { label: 'Visualizaciones FB', value: n(fb?.pageInsights?.impressions), - icon: Eye, + hint: 'Periodo', }, { - label: 'Engagement posts FB', + label: 'Engagement posts', value: n(fb?.pageInsights?.postEngagements), - icon: Heart, + hint: 'Facebook', }, ]; @@ -567,11 +511,11 @@ export default function SocialDashboard({ ]; return ( -
+
} /> @@ -592,22 +536,55 @@ export default function SocialDashboard({

) : ( <> -
- {kpiCards.map((k) => ( -
-
-

- {k.label} -

- -
-

- {k.value} -

-
+ +
+ + + + + +
+
+ + +

+ Los totales de alcance e interacciones son del periodo del + header. Seguidores y fans son stock actual del perfil. +

+

+ En los gráficos, la flecha es el cambio del primer al último día + (no es un MoM). +

+
+ +
+ {kpiIg.map((k) => ( + + ))} +
+
+ {kpiFb.map((k) => ( + ))}
@@ -631,16 +608,12 @@ export default function SocialDashboard({

)} -
-
-

- Evolución diaria · {rangeLabel} -

-

- Barras = valor diario. Flecha = cambio del primer al último día - del periodo. Totales arriba a la derecha. -

-
+
+ } + title="Evolución diaria" + hint={`${rangeLabel} · barras = valor diario · flecha = primer→último día`} + />
{chartBlocks.map((c) => ( @@ -648,15 +621,12 @@ export default function SocialDashboard({
-
-
-

- Publicaciones Instagram -

-

- Solo IG (feed, reels y carruseles). Sin posts de Facebook. -

-
+
+ } + title="Publicaciones Instagram" + hint="Feed, reels y carruseles. Sin posts de Facebook." + /> {visiblePosts.length === 0 ? (

diff --git a/src/components/dash-ui.tsx b/src/components/dash-ui.tsx new file mode 100644 index 0000000..3a0ee77 --- /dev/null +++ b/src/components/dash-ui.tsx @@ -0,0 +1,197 @@ +'use client'; + +import React from 'react'; +import { Info } from 'lucide-react'; + +/** Banda de resumen del periodo — misma que Métricas. */ +export function HeroBand({ + eyebrow = 'Resumen del periodo', + children, +}: { + eyebrow?: string; + children: React.ReactNode; +}) { + return ( +

+

+ {eyebrow} +

+ {children} +
+ ); +} + +export function HeroStat({ + label, + value, + chip, +}: { + label: string; + value: string; + chip?: React.ReactNode; +}) { + return ( +
+

+ {label} +

+

+ {value} +

+ {chip ?
{chip}
: null} +
+ ); +} + +export function SectionHead({ + icon, + title, + hint, +}: { + icon: React.ReactNode; + title: string; + hint?: string; +}) { + return ( +
+ + {icon} + +
+

+ {title} +

+ {hint ?

{hint}

: null} +
+
+ ); +} + +export function StatMini({ + label, + value, + hint, + icon, +}: { + label: string; + value: string; + hint?: string; + icon?: React.ReactNode; +}) { + return ( +
+

+ {icon} + {label} +

+

+ {value} +

+ {hint ?

{hint}

: null} +
+ ); +} + +export function HelpDetails({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+ + + {title} + + mostrar + ocultar + + +
+ {children} +
+
+ ); +} + +export function SparkBars({ + points, + color, + formatValue, + empty = 'Sin filas diarias.', +}: { + points: { date: string; value: number }[]; + color: string; + formatValue: (v: number) => string; + empty?: string; +}) { + if (points.length === 0) { + return

{empty}

; + } + const max = Math.max(1, ...points.map((p) => p.value)); + const first = points[0]?.date; + const last = points[points.length - 1]?.date; + const fmtDay = (d?: string) => + d + ? new Date(`${d}T00:00:00`).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'short', + }) + : ''; + + return ( +
+
+ {formatValue(max)} + máx. +
+
+ {points.map((d) => ( +
+
+
+ ))} +
+
+ {fmtDay(first)} + {fmtDay(last)} +
+
+ ); +} + +export function tableHeadClass() { + return 'border-b border-gray-200 bg-gray-50/80 text-[11px] uppercase tracking-wider text-gray-500'; +} + +export function ShareBar({ + value, + max, + label, +}: { + value: number; + max: number; + label: string; +}) { + if (!value) return —; + return ( +
+ {label} +
+
+
+
+ ); +}