Align CRM, Ads, Social and Organic dashboards with the metrics visual system.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 13:12:27 +02:00
parent 4d662430c0
commit dfb44d240e
5 changed files with 743 additions and 552 deletions

View File

@@ -8,9 +8,17 @@ import {
CreativesGallery, CreativesGallery,
CreativeZoomModal, CreativeZoomModal,
} from '@/components/AdCreativesGallery'; } from '@/components/AdCreativesGallery';
import { PageHeader } from '@/components/ui'; import { PageHeader, SegmentedControl } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton'; import SyncNowButton from '@/components/SyncNowButton';
import { useDateRange } from '@/components/DateRangeContext'; import { useDateRange } from '@/components/DateRangeContext';
import {
HelpDetails,
HeroBand,
HeroStat,
ShareBar,
StatMini,
tableHeadClass,
} from '@/components/dash-ui';
type PlatformKpis = { type PlatformKpis = {
spend: number; spend: number;
@@ -439,21 +447,6 @@ function SparkBars({
); );
} }
function KpiTile({
label,
value,
}: {
label: string;
value: string;
}) {
return (
<div className="rounded-xl border border-gray-200 bg-white px-3 py-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500">{label}</p>
<p className="mt-1 text-xl font-bold text-gray-900 tabular-nums">{value}</p>
</div>
);
}
function CampaignTable({ function CampaignTable({
title, title,
rows, rows,
@@ -463,44 +456,57 @@ function CampaignTable({
rows: CampaignRow[]; rows: CampaignRow[];
empty: string; empty: string;
}) { }) {
const spendMax = Math.max(1, ...rows.map((r) => r.spend));
return ( return (
<section className="rounded-2xl border border-gray-200 bg-white overflow-hidden"> <section className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
<div className="px-4 py-3 border-b border-gray-200"> <div className="px-4 py-3 border-b border-gray-100">
<p className="text-[10px] uppercase tracking-wider text-gray-500"> <p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
Campañas activas Campañas
</p> </p>
<h3 className="text-lg font-bold text-gray-900">{title}</h3> <h3 className="text-base font-semibold text-gray-900 tracking-tight">
{title}
</h3>
</div> </div>
{rows.length === 0 ? ( {rows.length === 0 ? (
<p className="px-4 py-8 text-sm text-gray-500">{empty}</p> <p className="px-4 py-10 text-sm text-gray-500 text-center">{empty}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-left text-sm"> <table className="min-w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50"> <thead className={tableHeadClass()}>
<tr> <tr>
<th className="px-4 py-2 font-medium">Campaña</th> <th className="px-4 py-3 font-semibold">Campaña</th>
<th className="px-3 py-2 font-medium">Gasto</th> <th className="px-3 py-3 font-semibold min-w-[150px]">Gasto</th>
<th className="px-3 py-2 font-medium">Clics</th> <th className="px-3 py-3 font-semibold">Clics</th>
<th className="px-3 py-2 font-medium">Conv.</th> <th className="px-3 py-3 font-semibold">Conv.</th>
<th className="px-3 py-2 font-medium">CPL</th> <th className="px-3 py-3 font-semibold">CPL</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.slice(0, 25).map((r) => ( {rows.slice(0, 25).map((r) => (
<tr key={r.name} className="border-t border-gray-200"> <tr
<td className="px-4 py-2.5 text-gray-800 max-w-[220px] truncate"> key={r.name}
className="border-t border-gray-100 hover:bg-brand-50/40"
>
<td
className="px-4 py-2.5 text-gray-800 max-w-[240px] truncate"
title={r.name}
>
{r.name} {r.name}
</td> </td>
<td className="px-3 py-2.5 text-gray-900 tabular-nums"> <td className="px-3 py-2.5">
{formatEur(r.spend)} <ShareBar
value={r.spend}
max={spendMax}
label={formatEur(r.spend)}
/>
</td> </td>
<td className="px-3 py-2.5 text-gray-600 tabular-nums"> <td className="px-3 py-2.5 text-gray-700 tabular-nums">
{formatNum(r.clicks)} {formatNum(r.clicks)}
</td> </td>
<td className="px-3 py-2.5 text-gray-600 tabular-nums"> <td className="px-3 py-2.5 text-gray-700 tabular-nums">
{formatNum(r.conversions)} {formatNum(r.conversions)}
</td> </td>
<td className="px-3 py-2.5 text-gray-600 tabular-nums"> <td className="px-3 py-2.5 text-gray-700 tabular-nums">
{r.cpl != null ? formatEurCents(r.cpl) : '—'} {r.cpl != null ? formatEurCents(r.cpl) : '—'}
</td> </td>
</tr> </tr>
@@ -684,86 +690,78 @@ export default function AdTrackerClient({
]; ];
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<PageHeader <PageHeader
eyebrow={`Ads · ${label}`} eyebrow="Publicidad"
title={tagline || 'Meta + Google Ads'} title={tagline || 'Ads'}
description={`Gasto, clics, conversiones y creatividades. Periodo del header: ${rangeLabel}.`} description={`${label} · Meta + Google · ${rangeLabel}`}
actions={<SyncNowButton forceModule="ads" />} actions={<SyncNowButton forceModule="ads" />}
/> />
{/* Tabs */} <SegmentedControl
<div className="flex flex-wrap gap-1.5"> value={tab}
{( onChange={setTab}
[ size="sm"
options={[
{ id: 'resumen', label: 'Resumen' }, { id: 'resumen', label: 'Resumen' },
{ id: 'meta', label: 'Meta Ads' }, { id: 'meta', label: 'Meta Ads' },
{ id: 'google', label: 'Google Ads' }, { id: 'google', label: 'Google Ads' },
] as const ]}
).map((t) => ( />
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
tab === t.id
? 'bg-gray-900 text-white'
: 'bg-gray-100 text-gray-600'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'resumen' && ( {tab === 'resumen' && (
<div className="space-y-6"> <div className="space-y-6">
<section className="space-y-4"> <section className="space-y-4">
<div> <HeroBand>
<p className="text-[10px] uppercase tracking-wider text-gray-500"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
Resumen de cuenta <HeroStat label="Inversión" value={formatEur(combined.spend)} />
</p> <HeroStat
<h3 className="text-xl font-bold text-gray-900 mt-1">
Meta + Google, juntos
</h3>
<p className="text-xs text-gray-500 mt-1 max-w-3xl">
Gasto, impresiones, clics y conversiones sumados. CTR, CPC y CPL
se recalculan sobre el total (no se suman). Ventana:{' '}
{range.since} → {range.until}.
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<KpiTile label="Gasto" value={formatEur(combined.spend)} />
<KpiTile
label="Impresiones"
value={formatNum(combined.impressions)}
/>
<KpiTile label="Clics" value={formatNum(combined.clicks)} />
<KpiTile
label="Conversiones" label="Conversiones"
value={formatNum(combined.conversions)} value={formatNum(combined.conversions)}
/> />
<KpiTile <HeroStat
label="CTR" label="CPL"
value={ value={
combined.ctr != null ? `${combined.ctr.toFixed(2)}%` : '—' combined.cpl != null ? formatEurCents(combined.cpl) : '—'
} }
/> />
<KpiTile <HeroStat
label="CTR"
value={
combined.ctr != null
? `${combined.ctr.toFixed(2)}%`
: '—'
}
/>
</div>
</HeroBand>
<HelpDetails title="Cómo leer Ads">
<p>
Gasto, impresiones, clics y conversiones se suman entre Meta y
Google. CTR, CPC y CPL se recalculan sobre el total (no se
suman porcentajes).
</p>
</HelpDetails>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatMini
label="Impresiones"
value={formatNum(combined.impressions)}
/>
<StatMini label="Clics" value={formatNum(combined.clicks)} />
<StatMini
label="CPC" label="CPC"
value={combined.cpc != null ? formatEurCents(combined.cpc) : '—'} value={
combined.cpc != null ? formatEurCents(combined.cpc) : '—'
}
/> />
<KpiTile <StatMini label="Ads activos" value={formatNum(combined.ads)} />
label="CPL"
value={combined.cpl != null ? formatEurCents(combined.cpl) : '—'}
/>
<KpiTile label="Ads" value={formatNum(combined.ads)} />
</div> </div>
<div className="h-2.5 rounded-full overflow-hidden border border-gray-200 bg-gray-900 flex"> <div className="h-2.5 rounded-full overflow-hidden border border-gray-200 bg-gray-100 flex">
<div <div
className="bg-[#7C66B8]" className="bg-brand-500"
style={{ width: `${metaShare}%` }} style={{ width: `${metaShare}%` }}
title={`Meta ${metaShare.toFixed(0)}%`} title={`Meta ${metaShare.toFixed(0)}%`}
/> />
@@ -773,17 +771,21 @@ export default function AdTrackerClient({
title={`Google ${googleShare.toFixed(0)}%`} title={`Google ${googleShare.toFixed(0)}%`}
/> />
</div> </div>
<p className="text-[11px] text-gray-500 -mt-2">
Reparto de inversión · Meta {metaShare.toFixed(0)}% · Google{' '}
{googleShare.toFixed(0)}%
</p>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<button <button
type="button" type="button"
onClick={() => setTab('meta')} onClick={() => setTab('meta')}
className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-[#7C66B8]/50" className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-brand-400 hover:bg-brand-50/40 transition-colors"
> >
<p className="text-[11px] uppercase tracking-wider text-[#a78bfa]"> <p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-brand-700">
Meta Ads Meta Ads
</p> </p>
<p className="text-xl font-bold text-gray-900 mt-1"> <p className="text-xl font-semibold text-gray-900 mt-1 tabular-nums tracking-tight">
{formatEur(meta.spend)} {formatEur(meta.spend)}
</p> </p>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
@@ -796,10 +798,10 @@ export default function AdTrackerClient({
onClick={() => setTab('google')} onClick={() => setTab('google')}
className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-sky-500/50" className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-sky-500/50"
> >
<p className="text-[11px] uppercase tracking-wider text-sky-400"> <p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-sky-700">
Google Ads Google Ads
</p> </p>
<p className="text-xl font-bold text-gray-900 mt-1"> <p className="text-xl font-semibold text-gray-900 mt-1 tabular-nums tracking-tight">
{formatEur(google.spend)} {formatEur(google.spend)}
</p> </p>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
@@ -895,22 +897,18 @@ export default function AdTrackerClient({
{tab === 'meta' && ( {tab === 'meta' && (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2"> <HeroBand eyebrow="Meta Ads">
<KpiTile label="Gasto" value={formatEur(meta.spend)} /> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
<KpiTile <HeroStat label="Inversión" value={formatEur(meta.spend)} />
label="Impresiones" <HeroStat
value={formatNum(meta.impressions)}
/>
<KpiTile label="Clics (todos)" value={formatNum(metaClicksAll)} />
<KpiTile
label="Clics (enlace)"
value={formatNum(metaClicksLink)}
/>
<KpiTile
label="Conversiones" label="Conversiones"
value={formatNum(meta.conversions)} value={formatNum(meta.conversions)}
/> />
<KpiTile <HeroStat
label="CPL"
value={meta.cpl != null ? formatEurCents(meta.cpl) : '—'}
/>
<HeroStat
label="CTR" label="CTR"
value={ value={
meta.impressions > 0 meta.impressions > 0
@@ -918,19 +916,27 @@ export default function AdTrackerClient({
: '—' : '—'
} }
/> />
<KpiTile </div>
</HeroBand>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<StatMini
label="Impresiones"
value={formatNum(meta.impressions)}
/>
<StatMini label="Clics (todos)" value={formatNum(metaClicksAll)} />
<StatMini
label="Clics (enlace)"
value={formatNum(metaClicksLink)}
/>
<StatMini
label="CPC (todos)" label="CPC (todos)"
value={metaCpcAll != null ? formatEurCents(metaCpcAll) : '—'} value={metaCpcAll != null ? formatEurCents(metaCpcAll) : '—'}
/> />
<KpiTile <StatMini
label="CPC (enlace)" label="CPC (enlace)"
value={metaCpcLink != null ? formatEurCents(metaCpcLink) : '—'} value={metaCpcLink != null ? formatEurCents(metaCpcLink) : '—'}
/> />
<KpiTile <StatMini label="Ads activos" value={formatNum(meta.ads)} />
label="CPL"
value={meta.cpl != null ? formatEurCents(meta.cpl) : '—'}
/>
<KpiTile label="Ads activos" value={formatNum(meta.ads)} />
</div> </div>
<CreativesGallery <CreativesGallery
platform="meta" platform="meta"
@@ -965,30 +971,36 @@ export default function AdTrackerClient({
{tab === 'google' && ( {tab === 'google' && (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2"> <HeroBand eyebrow="Google Ads">
<KpiTile label="Gasto" value={formatEur(google.spend)} /> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
<KpiTile <HeroStat label="Inversión" value={formatEur(google.spend)} />
label="Impresiones" <HeroStat
value={formatNum(google.impressions)}
/>
<KpiTile label="Clics" value={formatNum(google.clicks)} />
<KpiTile
label="Conversiones" label="Conversiones"
value={formatNum(google.conversions)} value={formatNum(google.conversions)}
/> />
<KpiTile <HeroStat
label="CTR"
value={google.ctr != null ? `${google.ctr.toFixed(2)}%` : '—'}
/>
<KpiTile
label="CPC"
value={google.cpc != null ? formatEurCents(google.cpc) : '—'}
/>
<KpiTile
label="CPL" label="CPL"
value={google.cpl != null ? formatEurCents(google.cpl) : '—'} value={google.cpl != null ? formatEurCents(google.cpl) : '—'}
/> />
<KpiTile label="Ads activos" value={formatNum(google.ads)} /> <HeroStat
label="CTR"
value={
google.ctr != null ? `${google.ctr.toFixed(2)}%` : '—'
}
/>
</div>
</HeroBand>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatMini
label="Impresiones"
value={formatNum(google.impressions)}
/>
<StatMini label="Clics" value={formatNum(google.clicks)} />
<StatMini
label="CPC"
value={google.cpc != null ? formatEurCents(google.cpc) : '—'}
/>
<StatMini label="Ads activos" value={formatNum(google.ads)} />
</div> </div>
<CreativesGallery <CreativesGallery
platform="google" platform="google"

View File

@@ -2,12 +2,11 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { import {
Users,
UserCheck,
Percent,
Flame, Flame,
UserX, UserX,
Briefcase, Briefcase,
GitCompare,
ListTree,
} from 'lucide-react'; } from 'lucide-react';
import { import {
AdDailyRow, AdDailyRow,
@@ -23,24 +22,33 @@ import {
formatEur, formatEur,
formatNum, formatNum,
} from '@/lib/metrics'; } from '@/lib/metrics';
import { PageHeader } from '@/components/ui'; import { PageHeader, SegmentedControl, Surface } from '@/components/ui';
import { useDateRange } from '@/components/DateRangeContext'; import { useDateRange } from '@/components/DateRangeContext';
import SyncNowButton from '@/components/SyncNowButton'; import SyncNowButton from '@/components/SyncNowButton';
import {
HelpDetails,
HeroBand,
HeroStat,
SectionHead,
ShareBar,
StatMini,
tableHeadClass,
} from '@/components/dash-ui';
function rateClass(rate: number): string { function rateClass(rate: number): string {
if (rate >= 8) return 'text-emerald-400'; if (rate >= 8) return 'text-emerald-800';
if (rate >= 3) return 'text-amber-400'; if (rate >= 3) return 'text-amber-800';
if (rate > 0) return 'text-orange-400'; if (rate > 0) return 'text-orange-800';
return 'text-gray-500'; return 'text-gray-500';
} }
const LENS_HELP: Record<CrmDateLens, string> = { const LENS_HELP: Record<CrmDateLens, string> = {
created: created:
'Altas: leads cuya fecha de creación en Clientify cae en el periodo del header.', 'Altas: leads cuya fecha de creación en el CRM cae en el periodo del header.',
status: status:
'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at (sql/leads_status_timing.sql).', 'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at.',
snapshot: snapshot:
'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora en el warehouse.', 'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora.',
}; };
export default function CrmDashboard({ export default function CrmDashboard({
@@ -100,157 +108,158 @@ export default function CrmDashboard({
const otherChannels = channelRows.filter( const otherChannels = channelRows.filter(
(r) => !['meta', 'google'].includes(r.channel) (r) => !['meta', 'google'].includes(r.channel)
); );
const leadMax = Math.max(1, ...channelRows.map((r) => r.leads));
const statusMax = Math.max(1, ...statusRows.map((r) => r.count));
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<PageHeader <PageHeader
eyebrow="Rendimiento" eyebrow="Rendimiento"
title="CRM" title="CRM"
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`} description={`${label}${portfolioWide ? ' · portafolio' : ''} · ${rangeHint}`}
actions={<SyncNowButton forceModule="crm" />} actions={<SyncNowButton forceModule="crm" />}
/> />
<div className="flex flex-col sm:flex-row sm:items-center gap-2"> <div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-600 shrink-0"> <p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 shrink-0">
Lente CRM Lente
</p> </p>
<div className="flex flex-wrap gap-1.5"> <SegmentedControl
{( value={lens}
[ onChange={setLens}
['created', 'Altas en periodo'], size="sm"
['status', 'Conversiones en periodo'], options={[
['snapshot', 'Estado actual'], { id: 'created', label: 'Altas' },
] as const { id: 'status', label: 'Conversiones' },
).map(([id, lab]) => ( { id: 'snapshot', label: 'Estado actual' },
<button ]}
key={id} />
type="button"
onClick={() => setLens(id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-colors ${
lens === id
? 'bg-brand-500 text-white border-brand-500'
: 'bg-white text-gray-700 border-gray-200 hover:border-brand-300'
}`}
>
{lab}
</button>
))}
</div> </div>
</div>
<p className="text-xs text-gray-600 -mt-4">{LENS_HELP[lens]}</p> <HelpDetails title="Cómo leer este CRM">
<p>{LENS_HELP[lens]}</p>
<p>
El % a cliente es del CRM, no de ads. La inversión por canal es solo
referencia del periodo.
</p>
</HelpDetails>
{lens === 'status' && !hasStatusTiming ? ( {lens === 'status' && !hasStatusTiming ? (
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2"> <p className="text-xs text-amber-900 border border-amber-200 bg-amber-50 rounded-xl px-3 py-2">
Aún no hay <code className="font-mono">status_changed_at</code> en Aún no hay fecha de cambio de status en warehouse — filtrando por
warehouse — aplicando filtro por alta. Ejecuta{' '} alta. Ejecuta <code className="font-mono">sql/leads_status_timing.sql</code>{' '}
<code className="font-mono">sql/leads_status_timing.sql</code> en y deja que los webhooks actualicen contactos.
Supabase y deja que los webhooks actualicen contactos.
</p> </p>
) : null} ) : null}
{!crmLeads.length ? ( {!crmLeads.length ? (
<p className="text-sm text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-4 py-3"> <p className="text-sm text-amber-900 border border-amber-200 bg-amber-50 rounded-xl px-4 py-3">
Sin leads Clientify en este contexto. Importa el histórico o activa el Sin leads CRM en este contexto. Importa el histórico o activa el
webhook (`?tenant=…`). En portafolio, elige un cliente en Contexto. webhook. En portafolio, elige un cliente en Contexto.
</p> </p>
) : null} ) : null}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3"> <HeroBand>
{[ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
{ <HeroStat label="Leads" value={formatNum(funnel.total)} />
label: 'Leads CRM', <HeroStat
value: formatNum(funnel.total), label="Clientes"
icon: <Users className="w-4 h-4 text-blue-400" />, value={formatNum(funnel.cliente)}
}, chip={
{ <span className="text-[11px] text-gray-500">
label: 'Clientes (ganado)', {funnel.enOportunidad} en oportunidad
value: formatNum(funnel.cliente),
icon: <UserCheck className="w-4 h-4 text-emerald-400" />,
},
{
label: '% a cliente',
value: `${funnel.clienteRate.toFixed(1)}%`,
icon: <Percent className="w-4 h-4 text-brand-400" />,
},
{
label: 'En oportunidad',
value: formatNum(funnel.enOportunidad),
icon: <Briefcase className="w-4 h-4 text-violet-400" />,
},
{
label: 'Lead frío',
value: formatNum(funnel.leadFrio),
icon: <Flame className="w-4 h-4 text-sky-400" />,
},
{
label: 'Lead perdido',
value: formatNum(funnel.leadPerdido),
icon: <UserX className="w-4 h-4 text-red-400" />,
},
].map((s) => (
<div
key={s.label}
className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm"
>
<div className="flex items-center justify-between text-gray-600 mb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider">
{s.label}
</span> </span>
{s.icon} }
/>
<HeroStat
label="% a cliente"
value={`${funnel.clienteRate.toFixed(1)}%`}
/>
<HeroStat
label="Perdidos"
value={formatNum(funnel.leadPerdido)}
chip={
<span className="text-[11px] text-gray-500">
{formatNum(funnel.leadFrio)} fríos
</span>
}
/>
</div> </div>
<p className="text-xl font-bold text-gray-900 tabular-nums"> </HeroBand>
{s.value}
</p> <div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
</div> <StatMini
))} label="En oportunidad"
value={formatNum(funnel.enOportunidad)}
hint="Pipeline abierto"
icon={<Briefcase className="w-3.5 h-3.5" />}
/>
<StatMini
label="Lead frío"
value={formatNum(funnel.leadFrio)}
hint="Sin avanzar"
icon={<Flame className="w-3.5 h-3.5" />}
/>
<StatMini
label="Lead perdido"
value={formatNum(funnel.leadPerdido)}
hint="Cerrado en pérdida"
icon={<UserX className="w-3.5 h-3.5" />}
/>
</div> </div>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm"> <section>
<div> <SectionHead
<h3 className="text-sm font-semibold text-gray-900"> icon={<GitCompare className="w-4 h-4" />}
% a cliente por canal title="% a cliente por canal"
</h3> hint="Estado ganado en el CRM. Ads spend del periodo es referencia."
<p className="text-xs text-gray-600 mt-0.5"> />
Estado Clientify = <span className="text-gray-400">cliente</span>{' '} <Surface padded={false}>
(ganado). Ads spend del periodo es referencia.
</p>
</div>
{!channelRows.length ? ( {!channelRows.length ? (
<p className="text-sm text-gray-500 py-4 text-center"> <p className="text-sm text-gray-500 py-10 text-center">
Sin leads en esta lente/periodo. Sin leads en esta lente/periodo.
</p> </p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="text-gray-600 border-b border-gray-200"> <thead className={tableHeadClass()}>
<tr> <tr>
<th className="py-2 pr-3 font-medium">Canal</th> <th className="py-3 px-4 font-semibold">Canal</th>
<th className="py-2 pr-3 font-medium">Leads</th> <th className="py-3 pr-4 font-semibold min-w-[160px]">
<th className="py-2 pr-3 font-medium">Clientes</th> Leads
<th className="py-2 pr-3 font-medium">% a cliente</th> </th>
<th className="py-2 font-medium">Inversión ads</th> <th className="py-3 pr-4 font-semibold">Clientes</th>
<th className="py-3 pr-4 font-semibold">% a cliente</th>
<th className="py-3 pr-4 font-semibold">Inversión ads</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{[...paidChannels, ...otherChannels].map((row) => ( {[...paidChannels, ...otherChannels].map((row) => (
<tr <tr
key={row.channel} key={row.channel}
className="border-b border-gray-100 hover:bg-gray-50" className="border-b border-gray-100 hover:bg-brand-50/40"
> >
<td className="py-2.5 pr-3 font-semibold capitalize text-gray-800"> <td className="py-3 px-4 font-semibold capitalize text-gray-800">
{row.channel} {row.channel}
</td> </td>
<td className="py-2.5 pr-3 tabular-nums text-gray-800"> <td className="py-3 pr-4">
{formatNum(row.leads)} <ShareBar
value={row.leads}
max={leadMax}
label={formatNum(row.leads)}
/>
</td> </td>
<td className="py-2.5 pr-3 tabular-nums text-gray-800"> <td className="py-3 pr-4 tabular-nums text-gray-800">
{formatNum(row.cliente)} {formatNum(row.cliente)}
</td> </td>
<td <td
className={`py-2.5 pr-3 font-semibold ${rateClass(row.clienteRate)}`} className={`py-3 pr-4 font-semibold tabular-nums ${rateClass(
row.clienteRate
)}`}
> >
{row.clienteRate.toFixed(1)}% {row.clienteRate.toFixed(1)}%
</td> </td>
<td className="py-2.5 tabular-nums text-gray-700"> <td className="py-3 pr-4 tabular-nums text-gray-700">
{spendByChannel.get(row.channel) {spendByChannel.get(row.channel)
? formatEur(spendByChannel.get(row.channel)!) ? formatEur(spendByChannel.get(row.channel)!)
: '—'} : '—'}
@@ -261,37 +270,43 @@ export default function CrmDashboard({
</table> </table>
</div> </div>
)} )}
<p className="text-[10px] text-gray-500"> </Surface>
La inversión por canal (si hay ads) es solo referencia; el % a cliente
es CRM. Lente activa: {lens}.
</p>
</section> </section>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm"> <section>
<h3 className="text-sm font-semibold text-gray-900">Desglose de status</h3> <SectionHead
icon={<ListTree className="w-4 h-4" />}
title="Desglose de status"
hint="Distribución de contactos en el warehouse con la lente activa"
/>
<Surface padded={false}>
{!statusRows.length ? ( {!statusRows.length ? (
<p className="text-sm text-gray-500 py-4 text-center">Sin datos.</p> <p className="text-sm text-gray-500 py-10 text-center">Sin datos.</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="text-gray-600 border-b border-gray-200"> <thead className={tableHeadClass()}>
<tr> <tr>
<th className="py-2 pr-3 font-medium">Status</th> <th className="py-3 px-4 font-semibold">Status</th>
<th className="py-2 pr-3 font-medium">N</th> <th className="py-3 pr-4 font-semibold min-w-[160px]">N</th>
<th className="py-2 font-medium">%</th> <th className="py-3 pr-4 font-semibold">%</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{statusRows.map((row) => ( {statusRows.map((row) => (
<tr <tr
key={row.status} key={row.status}
className="border-b border-gray-100 hover:bg-gray-50" className="border-b border-gray-100 hover:bg-brand-50/40"
> >
<td className="py-2.5 pr-3 text-gray-800">{row.status}</td> <td className="py-3 px-4 text-gray-800">{row.status}</td>
<td className="py-2.5 pr-3 tabular-nums"> <td className="py-3 pr-4">
{formatNum(row.count)} <ShareBar
value={row.count}
max={statusMax}
label={formatNum(row.count)}
/>
</td> </td>
<td className="py-2.5 tabular-nums text-gray-600"> <td className="py-3 pr-4 tabular-nums text-gray-600">
{row.pct.toFixed(1)}% {row.pct.toFixed(1)}%
</td> </td>
</tr> </tr>
@@ -300,6 +315,7 @@ export default function CrmDashboard({
</table> </table>
</div> </div>
)} )}
</Surface>
</section> </section>
</div> </div>
); );

View File

@@ -33,8 +33,15 @@ import {
ShieldAlert, ShieldAlert,
Sparkles, Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader, SegmentedControl } from '@/components/ui'; import { PageHeader, SegmentedControl, Surface } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton'; import SyncNowButton from '@/components/SyncNowButton';
import {
HelpDetails,
HeroBand,
HeroStat,
SparkBars,
StatMini,
} from '@/components/dash-ui';
type Props = { type Props = {
label: string; label: string;
@@ -55,32 +62,13 @@ function Kpi({
label, label,
value, value,
hint, hint,
accent,
}: { }: {
label: string; label: string;
value: string; value: string;
hint?: string; hint?: string;
accent?: boolean; accent?: boolean;
}) { }) {
return ( return <StatMini label={label} value={value} hint={hint} />;
<div
className={`rounded-xl border px-4 py-3 shadow-sm ${
accent
? 'border-brand-200 bg-brand-50'
: 'border-gray-200 bg-white'
}`}
>
<p
className={`text-[11px] font-semibold uppercase tracking-wider ${
accent ? 'text-brand-700' : 'text-gray-600'
}`}
>
{label}
</p>
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">{value}</p>
{hint ? <p className="text-[11px] text-gray-500 mt-0.5">{hint}</p> : null}
</div>
);
} }
export default function OrganicoDashboard({ export default function OrganicoDashboard({
@@ -212,8 +200,6 @@ export default function OrganicoDashboard({
sessions: Number(r.ga4_organic_sessions) || 0, sessions: Number(r.ga4_organic_sessions) || 0,
})); }));
}, [organic]); }, [organic]);
const maxClicks = Math.max(1, ...seoDaily.map((d) => d.clicks));
const maxSessions = Math.max(1, ...seoDaily.map((d) => d.sessions));
const section = useMemo( const section = useMemo(
() => () =>
@@ -431,9 +417,9 @@ export default function OrganicoDashboard({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
eyebrow="Crecimiento · Suite" eyebrow="Crecimiento"
title="Orgánico" title="Orgánico"
description={`${label} · Search Console y Analytics. GEO (visibilidad en IA) llega después.`} description={`${label} · Search Console y Analytics · GEO próximamente`}
actions={ actions={
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<SyncNowButton forceModule="organico" /> <SyncNowButton forceModule="organico" />
@@ -462,6 +448,13 @@ export default function OrganicoDashboard({
</p> </p>
)} )}
<HelpDetails title="Cómo leer Orgánico">
<p>
Search Console son clics e impresiones en Google. GA4 son sesiones y
conversiones del sitio. No se suman a los leads de anuncios.
</p>
</HelpDetails>
{/* Connections */} {/* Connections */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{[ {[
@@ -576,10 +569,27 @@ export default function OrganicoDashboard({
{/* Paneles SEO por sección */} {/* Paneles SEO por sección */}
{section.id === 'seo-overview' && ( {section.id === 'seo-overview' && (
<> <>
<HeroBand>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
<HeroStat
label="Clics GSC"
value={formatNum(totals.gscClicks)}
/>
<HeroStat
label="Impresiones"
value={formatNum(totals.gscImpressions)}
/>
<HeroStat
label="CTR"
value={formatPct(totals.gscCtr)}
/>
<HeroStat
label="Sesiones orgánicas"
value={formatNum(totals.ga4Organic)}
/>
</div>
</HeroBand>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Kpi label="Clics GSC" value={formatNum(totals.gscClicks)} />
<Kpi label="Impresiones" value={formatNum(totals.gscImpressions)} />
<Kpi label="CTR" value={formatPct(totals.gscCtr)} />
<Kpi <Kpi
label="Posición media" label="Posición media"
value={ value={
@@ -588,11 +598,6 @@ export default function OrganicoDashboard({
: '—' : '—'
} }
/> />
<Kpi
label="Sesiones orgánicas"
value={formatNum(totals.ga4Organic)}
hint="GA4"
/>
<Kpi label="Usuarios" value={formatNum(totals.ga4Users)} /> <Kpi label="Usuarios" value={formatNum(totals.ga4Users)} />
<Kpi <Kpi
label="Conversiones" label="Conversiones"
@@ -606,36 +611,32 @@ export default function OrganicoDashboard({
</div> </div>
{seoDaily.length > 0 && ( {seoDaily.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="rounded-xl border border-gray-200 bg-white p-3"> <Surface>
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2"> <p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
Clics GSC · 28d Clics GSC
</p> </p>
<div className="flex items-end gap-0.5 h-16"> <SparkBars
{seoDaily.map((d) => ( points={seoDaily.map((d) => ({
<div key={`c-${d.day}`} className="flex-1" title={`${d.day}: ${d.clicks}`}> date: d.day,
<div value: d.clicks,
className="w-full rounded-t bg-emerald-400/80" }))}
style={{ height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%` }} color="bg-emerald-500 hover:bg-emerald-600"
formatValue={(v) => formatNum(v)}
/> />
</div> </Surface>
))} <Surface>
</div> <p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
</div> Sesiones orgánicas GA4
<div className="rounded-xl border border-gray-200 bg-white p-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Sesiones orgánicas GA4 · 28d
</p> </p>
<div className="flex items-end gap-0.5 h-16"> <SparkBars
{seoDaily.map((d) => ( points={seoDaily.map((d) => ({
<div key={`s-${d.day}`} className="flex-1" title={`${d.day}: ${d.sessions}`}> date: d.day,
<div value: d.sessions,
className="w-full rounded-t bg-sky-400/80" }))}
style={{ height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%` }} color="bg-sky-500 hover:bg-sky-600"
formatValue={(v) => formatNum(v)}
/> />
</div> </Surface>
))}
</div>
</div>
</div> </div>
)} )}
{totals.days === 0 && ( {totals.days === 0 && (
@@ -741,36 +742,32 @@ export default function OrganicoDashboard({
</div> </div>
{seoDaily.length > 0 ? ( {seoDaily.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="rounded-xl border border-gray-200 bg-white p-3"> <Surface>
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2"> <p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
Clics · evolución Clics · evolución
</p> </p>
<div className="flex items-end gap-0.5 h-24"> <SparkBars
{seoDaily.map((d) => ( points={seoDaily.map((d) => ({
<div key={`t-${d.day}`} className="flex-1" title={`${d.day}: ${d.clicks}`}> date: d.day,
<div value: d.clicks,
className="w-full rounded-t bg-emerald-500/80" }))}
style={{ height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%` }} color="bg-emerald-500 hover:bg-emerald-600"
formatValue={(v) => formatNum(v)}
/> />
</div> </Surface>
))} <Surface>
</div> <p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
</div>
<div className="rounded-xl border border-gray-200 bg-white p-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Sesiones orgánicas · evolución Sesiones orgánicas · evolución
</p> </p>
<div className="flex items-end gap-0.5 h-24"> <SparkBars
{seoDaily.map((d) => ( points={seoDaily.map((d) => ({
<div key={`u-${d.day}`} className="flex-1" title={`${d.day}: ${d.sessions}`}> date: d.day,
<div value: d.sessions,
className="w-full rounded-t bg-sky-500/80" }))}
style={{ height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%` }} color="bg-sky-500 hover:bg-sky-600"
formatValue={(v) => formatNum(v)}
/> />
</div> </Surface>
))}
</div>
</div>
</div> </div>
) : ( ) : (
<EmptyHint text="Sin serie diaria todavía." /> <EmptyHint text="Sin serie diaria todavía." />
@@ -1244,15 +1241,14 @@ function SeoTables({
function SectionHero({ section }: { section: OrganicoSection }) { function SectionHero({ section }: { section: OrganicoSection }) {
return ( return (
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <HeroBand eyebrow={section.group}>
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500"> <h2 className="text-lg font-semibold text-gray-900 tracking-tight">
{section.group} {section.label}
</p> </h2>
<h2 className="text-lg font-bold text-gray-900 mt-1">{section.label}</h2>
<p className="text-sm text-gray-600 mt-2 leading-relaxed max-w-3xl"> <p className="text-sm text-gray-600 mt-2 leading-relaxed max-w-3xl">
{section.blurb} {section.blurb}
</p> </p>
</div> </HeroBand>
); );
} }

View File

@@ -9,16 +9,23 @@ import {
Loader2, Loader2,
Share2, Share2,
ExternalLink, ExternalLink,
Users,
Eye, Eye,
Image as ImageIcon,
Heart, Heart,
MessageCircle, MessageCircle,
Repeat2, Repeat2,
BarChart3,
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader } from '@/components/ui'; import { PageHeader } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton'; import SyncNowButton from '@/components/SyncNowButton';
import { useDateRange } from '@/components/DateRangeContext'; import { useDateRange } from '@/components/DateRangeContext';
import {
HelpDetails,
HeroBand,
HeroStat,
SectionHead,
SparkBars,
StatMini,
} from '@/components/dash-ui';
type Integ = { type Integ = {
provider: string; 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 <p className="text-xs text-gray-500 py-6 text-center">{empty}</p>;
}
const max = Math.max(1, ...pts.map((p) => p.value));
return (
<div className="flex items-end gap-0.5 h-28 w-full">
{pts.map((p) => {
const pct = Math.max(6, Math.round((p.value / max) * 100));
return (
<div
key={p.day}
className="flex-1 min-w-0 h-full flex flex-col justify-end"
title={`${p.day}: ${n(p.value)}`}
>
<div
className={`w-full rounded-t ${color}`}
style={{ height: `${pct}%` }}
/>
</div>
);
})}
</div>
);
}
function seriesDelta(series?: DayPoint[]): { function seriesDelta(series?: DayPoint[]): {
abs: number; abs: number;
pct: number | null; pct: number | null;
@@ -326,38 +293,38 @@ function ChartCard({
}) { }) {
const delta = seriesDelta(series); const delta = seriesDelta(series);
return ( return (
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-2"> <div className="rounded-xl border border-gray-200 bg-white p-4 space-y-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div> <div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600"> <p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
{title} {title}
</p> </p>
{hint ? <p className="text-[10px] text-gray-400 mt-0.5">{hint}</p> : null} {hint ? <p className="text-xs text-gray-400 mt-0.5">{hint}</p> : null}
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-lg font-bold text-gray-900 tabular-nums">{n(total)}</p> <p className="text-xl font-semibold text-gray-900 tabular-nums tracking-tight">
{n(total)}
</p>
{delta ? ( {delta ? (
<p <p
className={`text-[10px] font-semibold tabular-nums ${ className={`text-[11px] font-semibold tabular-nums ${
delta.up ? 'text-emerald-600' : 'text-rose-600' delta.up ? 'text-emerald-700' : 'text-red-700'
}`} }`}
> >
{delta.up ? '↑' : '↓'} {n(Math.abs(delta.abs))} {delta.up ? '↑' : '↓'} {n(Math.abs(delta.abs))}
{delta.pct != null {delta.pct != null
? ` (${delta.pct > 0 ? '+' : ''}${delta.pct.toFixed(0)}%)` ? ` (${delta.pct > 0 ? '+' : ''}${delta.pct.toFixed(0)}%)`
: ''} : ''}
<span className="text-gray-400 font-normal"> inicio→fin</span>
</p> </p>
) : null} ) : null}
</div> </div>
</div> </div>
<MiniBars series={series} color={color} /> <SparkBars
{series && series.length > 1 ? ( points={(series || []).map((p) => ({ date: p.day, value: p.value }))}
<div className="flex justify-between text-[9px] text-gray-400 tabular-nums"> color={color}
<span>{shortDay(series[0].day)}</span> formatValue={(v) => n(v)}
<span>{shortDay(series[series.length - 1].day)}</span> empty="Sin serie en el periodo"
</div> />
) : null}
</div> </div>
); );
} }
@@ -410,66 +377,43 @@ export default function SocialDashboard({
[posts] [posts]
); );
const kpiCards = [ const kpiIg = [
{ {
label: 'Seguidores IG (global)', label: 'Publicaciones IG',
value: n(ig?.followers_count),
icon: Users,
},
{
label: 'Publicaciones IG (global)',
value: n(ig?.media_count), value: n(ig?.media_count),
icon: ImageIcon, hint: 'Stock del perfil',
},
{
label: 'Alcance IG (periodo)',
value: n(ig?.igInsights?.reach),
icon: Eye,
}, },
{ {
label: 'Visualizaciones IG', label: 'Visualizaciones IG',
value: n(ig?.igInsights?.impressions), value: n(ig?.igInsights?.impressions),
icon: Eye, hint: 'Periodo',
}, },
{ {
label: 'Visitas perfil IG', label: 'Visitas perfil',
value: n(ig?.igInsights?.profileViews), value: n(ig?.igInsights?.profileViews),
icon: Users, hint: 'Instagram',
},
{
label: 'Interacciones IG',
value: n(ig?.igInsights?.interactions ?? ig?.igInsights?.accountsEngaged),
icon: Heart,
}, },
{ {
label: 'Clics web IG', label: 'Clics web IG',
value: n(ig?.igInsights?.websiteClicks), value: n(ig?.igInsights?.websiteClicks),
icon: ExternalLink, hint: 'Desde el perfil',
}, },
{ {
label: 'Δ seguidores IG', label: 'Δ seguidores',
value: n(ig?.igInsights?.followerDelta), value: n(ig?.igInsights?.followerDelta),
icon: Users, hint: 'Cambio en el periodo',
},
{
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,
}, },
];
const kpiFb = [
{ {
label: 'Visualizaciones FB', label: 'Visualizaciones FB',
value: n(fb?.pageInsights?.impressions), value: n(fb?.pageInsights?.impressions),
icon: Eye, hint: 'Periodo',
}, },
{ {
label: 'Engagement posts FB', label: 'Engagement posts',
value: n(fb?.pageInsights?.postEngagements), value: n(fb?.pageInsights?.postEngagements),
icon: Heart, hint: 'Facebook',
}, },
]; ];
@@ -567,11 +511,11 @@ export default function SocialDashboard({
]; ];
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<PageHeader <PageHeader
eyebrow={`Social · ${tenantLabel}`} eyebrow="Social"
title="Redes sociales" title="Redes sociales"
description={`Analytics orgánicos IG/FB · periodo ${rangeLabel}`} description={`${tenantLabel} · Instagram y Facebook · ${rangeLabel}`}
actions={<SyncNowButton forceModule="social" />} actions={<SyncNowButton forceModule="social" />}
/> />
@@ -592,22 +536,55 @@ export default function SocialDashboard({
</p> </p>
) : ( ) : (
<> <>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> <HeroBand>
{kpiCards.map((k) => ( <div className="grid grid-cols-2 lg:grid-cols-5 gap-4 md:gap-6">
<div <HeroStat
key={k.label} label="Seguidores IG"
className="rounded-xl border border-gray-200 bg-white p-4" value={n(ig?.followers_count)}
> />
<div className="flex items-center justify-between text-gray-600 mb-1"> <HeroStat
<p className="text-[10px] font-semibold uppercase tracking-wider"> label="Alcance IG"
{k.label} value={n(ig?.igInsights?.reach)}
</p> />
<k.icon className="w-3.5 h-3.5" /> <HeroStat
label="Interacciones IG"
value={n(
ig?.igInsights?.interactions ??
ig?.igInsights?.accountsEngaged
)}
/>
<HeroStat
label="Fans FB"
value={n(
fb?.followers_count ?? fb?.fan_count ?? fb?.pageInsights?.fans
)}
/>
<HeroStat
label="Alcance FB"
value={n(fb?.pageInsights?.reach)}
/>
</div> </div>
<p className="text-xl font-bold text-gray-900 tabular-nums"> </HeroBand>
{k.value}
<HelpDetails title="Cómo leer Social">
<p>
Los totales de alcance e interacciones son del periodo del
header. Seguidores y fans son stock actual del perfil.
</p> </p>
<p>
En los gráficos, la flecha es el cambio del primer al último día
(no es un MoM).
</p>
</HelpDetails>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
{kpiIg.map((k) => (
<StatMini key={k.label} label={k.label} value={k.value} hint={k.hint} />
))}
</div> </div>
<div className="grid grid-cols-2 gap-3">
{kpiFb.map((k) => (
<StatMini key={k.label} label={k.label} value={k.value} hint={k.hint} />
))} ))}
</div> </div>
@@ -631,16 +608,12 @@ export default function SocialDashboard({
</p> </p>
)} )}
<section className="space-y-3"> <section>
<div> <SectionHead
<h3 className="text-sm font-semibold text-gray-900"> icon={<BarChart3 className="w-4 h-4" />}
Evolución diaria · {rangeLabel} title="Evolución diaria"
</h3> hint={`${rangeLabel} · barras = valor diario · flecha = primer→último día`}
<p className="text-xs text-gray-500 mt-0.5"> />
Barras = valor diario. Flecha = cambio del primer al último día
del periodo. Totales arriba a la derecha.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{chartBlocks.map((c) => ( {chartBlocks.map((c) => (
<ChartCard key={c.title} {...c} /> <ChartCard key={c.title} {...c} />
@@ -648,15 +621,12 @@ export default function SocialDashboard({
</div> </div>
</section> </section>
<section className="space-y-3"> <section>
<div> <SectionHead
<h3 className="text-sm font-semibold text-gray-900"> icon={<Instagram className="w-4 h-4" />}
Publicaciones Instagram title="Publicaciones Instagram"
</h3> hint="Feed, reels y carruseles. Sin posts de Facebook."
<p className="text-xs text-gray-500 mt-0.5"> />
Solo IG (feed, reels y carruseles). Sin posts de Facebook.
</p>
</div>
{visiblePosts.length === 0 ? ( {visiblePosts.length === 0 ? (
<p className="text-sm text-gray-500 border border-dashed border-gray-200 rounded-xl px-4 py-10 text-center"> <p className="text-sm text-gray-500 border border-dashed border-gray-200 rounded-xl px-4 py-10 text-center">

197
src/components/dash-ui.tsx Normal file
View File

@@ -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 (
<div className="rounded-2xl border border-gray-200 bg-gradient-to-br from-white via-white to-brand-50/70 p-4 md:p-6 shadow-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-brand-700 mb-4">
{eyebrow}
</p>
{children}
</div>
);
}
export function HeroStat({
label,
value,
chip,
}: {
label: string;
value: string;
chip?: React.ReactNode;
}) {
return (
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
{label}
</p>
<p className="mt-1.5 text-2xl md:text-3xl font-semibold tracking-tight text-gray-900 tabular-nums leading-none">
{value}
</p>
{chip ? <div className="mt-2">{chip}</div> : null}
</div>
);
}
export function SectionHead({
icon,
title,
hint,
}: {
icon: React.ReactNode;
title: string;
hint?: string;
}) {
return (
<div className="flex items-start gap-3 mb-4">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-700 border border-brand-200/60">
{icon}
</span>
<div>
<h2 className="text-base font-semibold text-gray-900 tracking-tight">
{title}
</h2>
{hint ? <p className="text-xs text-gray-500 mt-0.5">{hint}</p> : null}
</div>
</div>
);
}
export function StatMini({
label,
value,
hint,
icon,
}: {
label: string;
value: string;
hint?: string;
icon?: React.ReactNode;
}) {
return (
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3.5">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 inline-flex items-center gap-1.5">
{icon}
{label}
</p>
<p className="text-2xl font-semibold text-gray-900 mt-1.5 tabular-nums tracking-tight">
{value}
</p>
{hint ? <p className="text-xs text-gray-500 mt-1">{hint}</p> : null}
</div>
);
}
export function HelpDetails({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<details className="group rounded-xl border border-gray-200 bg-white">
<summary className="cursor-pointer list-none flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-gray-600 hover:text-gray-900">
<Info className="w-3.5 h-3.5 text-brand-600" />
{title}
<span className="ml-auto text-gray-400">
<span className="group-open:hidden">mostrar</span>
<span className="hidden group-open:inline">ocultar</span>
</span>
</summary>
<div className="px-4 pb-3 text-xs text-gray-600 space-y-1.5 border-t border-gray-100 pt-2">
{children}
</div>
</details>
);
}
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 <p className="text-xs text-gray-500 py-8 text-center">{empty}</p>;
}
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 (
<div>
<div className="flex items-end justify-between text-[10px] text-gray-500 mb-1 tabular-nums">
<span>{formatValue(max)}</span>
<span>máx.</span>
</div>
<div className="flex items-end gap-px h-28 w-full rounded-lg bg-gray-50 px-1 pt-2 pb-0 border border-gray-100">
{points.map((d) => (
<div
key={d.date}
className="flex-1 min-w-0 h-full flex items-end"
title={`${d.date}: ${formatValue(d.value)}`}
>
<div
className={`w-full rounded-t ${color}`}
style={{ height: `${Math.max(6, (d.value / max) * 100)}%` }}
/>
</div>
))}
</div>
<div className="flex justify-between text-[10px] text-gray-500 mt-1.5 tabular-nums">
<span>{fmtDay(first)}</span>
<span>{fmtDay(last)}</span>
</div>
</div>
);
}
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 <span className="text-gray-400">—</span>;
return (
<div className="flex items-center gap-2 min-w-[140px]">
<span className="tabular-nums text-gray-900 w-[4.5rem]">{label}</span>
<div className="h-1.5 flex-1 rounded-full bg-gray-100">
<div
className="h-1.5 rounded-full bg-brand-500"
style={{ width: `${Math.max(4, (value / Math.max(1, max)) * 100)}%` }}
/>
</div>
</div>
);
}