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,
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 (
<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({
title,
rows,
@@ -463,44 +456,57 @@ function CampaignTable({
rows: CampaignRow[];
empty: string;
}) {
const spendMax = Math.max(1, ...rows.map((r) => r.spend));
return (
<section className="rounded-2xl border border-gray-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200">
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Campañas activas
<section className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
<div className="px-4 py-3 border-b border-gray-100">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
Campañas
</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>
{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">
<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>
<th className="px-4 py-2 font-medium">Campaña</th>
<th className="px-3 py-2 font-medium">Gasto</th>
<th className="px-3 py-2 font-medium">Clics</th>
<th className="px-3 py-2 font-medium">Conv.</th>
<th className="px-3 py-2 font-medium">CPL</th>
<th className="px-4 py-3 font-semibold">Campaña</th>
<th className="px-3 py-3 font-semibold min-w-[150px]">Gasto</th>
<th className="px-3 py-3 font-semibold">Clics</th>
<th className="px-3 py-3 font-semibold">Conv.</th>
<th className="px-3 py-3 font-semibold">CPL</th>
</tr>
</thead>
<tbody>
{rows.slice(0, 25).map((r) => (
<tr key={r.name} className="border-t border-gray-200">
<td className="px-4 py-2.5 text-gray-800 max-w-[220px] truncate">
<tr
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}
</td>
<td className="px-3 py-2.5 text-gray-900 tabular-nums">
{formatEur(r.spend)}
<td className="px-3 py-2.5">
<ShareBar
value={r.spend}
max={spendMax}
label={formatEur(r.spend)}
/>
</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)}
</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)}
</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) : '—'}
</td>
</tr>
@@ -684,86 +690,78 @@ export default function AdTrackerClient({
];
return (
<div className="space-y-6">
<div className="space-y-8">
<PageHeader
eyebrow={`Ads · ${label}`}
title={tagline || 'Meta + Google Ads'}
description={`Gasto, clics, conversiones y creatividades. Periodo del header: ${rangeLabel}.`}
eyebrow="Publicidad"
title={tagline || 'Ads'}
description={`${label} · Meta + Google · ${rangeLabel}`}
actions={<SyncNowButton forceModule="ads" />}
/>
{/* Tabs */}
<div className="flex flex-wrap gap-1.5">
{(
[
{ id: 'resumen', label: 'Resumen' },
{ id: 'meta', label: 'Meta 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>
<SegmentedControl
value={tab}
onChange={setTab}
size="sm"
options={[
{ id: 'resumen', label: 'Resumen' },
{ id: 'meta', label: 'Meta Ads' },
{ id: 'google', label: 'Google Ads' },
]}
/>
{tab === 'resumen' && (
<div className="space-y-6">
<section className="space-y-4">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Resumen de cuenta
</p>
<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>
<HeroBand>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
<HeroStat label="Inversión" value={formatEur(combined.spend)} />
<HeroStat
label="Conversiones"
value={formatNum(combined.conversions)}
/>
<HeroStat
label="CPL"
value={
combined.cpl != null ? formatEurCents(combined.cpl) : '—'
}
/>
<HeroStat
label="CTR"
value={
combined.ctr != null
? `${combined.ctr.toFixed(2)}%`
: '—'
}
/>
</div>
</HeroBand>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<KpiTile label="Gasto" value={formatEur(combined.spend)} />
<KpiTile
<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)}
/>
<KpiTile label="Clics" value={formatNum(combined.clicks)} />
<KpiTile
label="Conversiones"
value={formatNum(combined.conversions)}
/>
<KpiTile
label="CTR"
<StatMini label="Clics" value={formatNum(combined.clicks)} />
<StatMini
label="CPC"
value={
combined.ctr != null ? `${combined.ctr.toFixed(2)}%` : '—'
combined.cpc != null ? formatEurCents(combined.cpc) : '—'
}
/>
<KpiTile
label="CPC"
value={combined.cpc != null ? formatEurCents(combined.cpc) : '—'}
/>
<KpiTile
label="CPL"
value={combined.cpl != null ? formatEurCents(combined.cpl) : '—'}
/>
<KpiTile label="Ads" value={formatNum(combined.ads)} />
<StatMini label="Ads activos" value={formatNum(combined.ads)} />
</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
className="bg-[#7C66B8]"
className="bg-brand-500"
style={{ width: `${metaShare}%` }}
title={`Meta ${metaShare.toFixed(0)}%`}
/>
@@ -773,17 +771,21 @@ export default function AdTrackerClient({
title={`Google ${googleShare.toFixed(0)}%`}
/>
</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">
<button
type="button"
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
</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)}
</p>
<p className="text-xs text-gray-500 mt-1">
@@ -796,10 +798,10 @@ export default function AdTrackerClient({
onClick={() => setTab('google')}
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
</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)}
</p>
<p className="text-xs text-gray-500 mt-1">
@@ -895,42 +897,46 @@ export default function AdTrackerClient({
{tab === 'meta' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<KpiTile label="Gasto" value={formatEur(meta.spend)} />
<KpiTile
<HeroBand eyebrow="Meta Ads">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
<HeroStat label="Inversión" value={formatEur(meta.spend)} />
<HeroStat
label="Conversiones"
value={formatNum(meta.conversions)}
/>
<HeroStat
label="CPL"
value={meta.cpl != null ? formatEurCents(meta.cpl) : '—'}
/>
<HeroStat
label="CTR"
value={
meta.impressions > 0
? `${((100 * metaClicksAll) / meta.impressions).toFixed(2)}%`
: '—'
}
/>
</div>
</HeroBand>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<StatMini
label="Impresiones"
value={formatNum(meta.impressions)}
/>
<KpiTile label="Clics (todos)" value={formatNum(metaClicksAll)} />
<KpiTile
<StatMini label="Clics (todos)" value={formatNum(metaClicksAll)} />
<StatMini
label="Clics (enlace)"
value={formatNum(metaClicksLink)}
/>
<KpiTile
label="Conversiones"
value={formatNum(meta.conversions)}
/>
<KpiTile
label="CTR"
value={
meta.impressions > 0
? `${((100 * metaClicksAll) / meta.impressions).toFixed(2)}%`
: '—'
}
/>
<KpiTile
<StatMini
label="CPC (todos)"
value={metaCpcAll != null ? formatEurCents(metaCpcAll) : '—'}
/>
<KpiTile
<StatMini
label="CPC (enlace)"
value={metaCpcLink != null ? formatEurCents(metaCpcLink) : '—'}
/>
<KpiTile
label="CPL"
value={meta.cpl != null ? formatEurCents(meta.cpl) : '—'}
/>
<KpiTile label="Ads activos" value={formatNum(meta.ads)} />
<StatMini label="Ads activos" value={formatNum(meta.ads)} />
</div>
<CreativesGallery
platform="meta"
@@ -965,30 +971,36 @@ export default function AdTrackerClient({
{tab === 'google' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<KpiTile label="Gasto" value={formatEur(google.spend)} />
<KpiTile
<HeroBand eyebrow="Google Ads">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
<HeroStat label="Inversión" value={formatEur(google.spend)} />
<HeroStat
label="Conversiones"
value={formatNum(google.conversions)}
/>
<HeroStat
label="CPL"
value={google.cpl != null ? formatEurCents(google.cpl) : '—'}
/>
<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)}
/>
<KpiTile label="Clics" value={formatNum(google.clicks)} />
<KpiTile
label="Conversiones"
value={formatNum(google.conversions)}
/>
<KpiTile
label="CTR"
value={google.ctr != null ? `${google.ctr.toFixed(2)}%` : '—'}
/>
<KpiTile
<StatMini label="Clics" value={formatNum(google.clicks)} />
<StatMini
label="CPC"
value={google.cpc != null ? formatEurCents(google.cpc) : '—'}
/>
<KpiTile
label="CPL"
value={google.cpl != null ? formatEurCents(google.cpl) : '—'}
/>
<KpiTile label="Ads activos" value={formatNum(google.ads)} />
<StatMini label="Ads activos" value={formatNum(google.ads)} />
</div>
<CreativesGallery
platform="google"

View File

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

View File

@@ -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 (
<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>
);
return <StatMini label={label} value={value} hint={hint} />;
}
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 (
<div className="space-y-5">
<PageHeader
eyebrow="Crecimiento · Suite"
eyebrow="Crecimiento"
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={
<div className="flex flex-wrap items-center gap-2">
<SyncNowButton forceModule="organico" />
@@ -462,6 +448,13 @@ export default function OrganicoDashboard({
</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 */}
<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 */}
{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">
<Kpi label="Clics GSC" value={formatNum(totals.gscClicks)} />
<Kpi label="Impresiones" value={formatNum(totals.gscImpressions)} />
<Kpi label="CTR" value={formatPct(totals.gscCtr)} />
<Kpi
label="Posición media"
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="Conversiones"
@@ -606,36 +611,32 @@ export default function OrganicoDashboard({
</div>
{seoDaily.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="rounded-xl border border-gray-200 bg-white p-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Clics GSC · 28d
<Surface>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
Clics GSC
</p>
<div className="flex items-end gap-0.5 h-16">
{seoDaily.map((d) => (
<div key={`c-${d.day}`} className="flex-1" title={`${d.day}: ${d.clicks}`}>
<div
className="w-full rounded-t bg-emerald-400/80"
style={{ height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%` }}
/>
</div>
))}
</div>
</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 GA4 · 28d
<SparkBars
points={seoDaily.map((d) => ({
date: d.day,
value: d.clicks,
}))}
color="bg-emerald-500 hover:bg-emerald-600"
formatValue={(v) => formatNum(v)}
/>
</Surface>
<Surface>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
Sesiones orgánicas GA4
</p>
<div className="flex items-end gap-0.5 h-16">
{seoDaily.map((d) => (
<div key={`s-${d.day}`} className="flex-1" title={`${d.day}: ${d.sessions}`}>
<div
className="w-full rounded-t bg-sky-400/80"
style={{ height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%` }}
/>
</div>
))}
</div>
</div>
<SparkBars
points={seoDaily.map((d) => ({
date: d.day,
value: d.sessions,
}))}
color="bg-sky-500 hover:bg-sky-600"
formatValue={(v) => formatNum(v)}
/>
</Surface>
</div>
)}
{totals.days === 0 && (
@@ -741,36 +742,32 @@ export default function OrganicoDashboard({
</div>
{seoDaily.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="rounded-xl border border-gray-200 bg-white p-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
<Surface>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
Clics · evolución
</p>
<div className="flex items-end gap-0.5 h-24">
{seoDaily.map((d) => (
<div key={`t-${d.day}`} className="flex-1" title={`${d.day}: ${d.clicks}`}>
<div
className="w-full rounded-t bg-emerald-500/80"
style={{ height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%` }}
/>
</div>
))}
</div>
</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">
<SparkBars
points={seoDaily.map((d) => ({
date: d.day,
value: d.clicks,
}))}
color="bg-emerald-500 hover:bg-emerald-600"
formatValue={(v) => formatNum(v)}
/>
</Surface>
<Surface>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
Sesiones orgánicas · evolución
</p>
<div className="flex items-end gap-0.5 h-24">
{seoDaily.map((d) => (
<div key={`u-${d.day}`} className="flex-1" title={`${d.day}: ${d.sessions}`}>
<div
className="w-full rounded-t bg-sky-500/80"
style={{ height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%` }}
/>
</div>
))}
</div>
</div>
<SparkBars
points={seoDaily.map((d) => ({
date: d.day,
value: d.sessions,
}))}
color="bg-sky-500 hover:bg-sky-600"
formatValue={(v) => formatNum(v)}
/>
</Surface>
</div>
) : (
<EmptyHint text="Sin serie diaria todavía." />
@@ -1244,15 +1241,14 @@ function SeoTables({
function SectionHero({ section }: { section: OrganicoSection }) {
return (
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">
{section.group}
</p>
<h2 className="text-lg font-bold text-gray-900 mt-1">{section.label}</h2>
<HeroBand eyebrow={section.group}>
<h2 className="text-lg font-semibold text-gray-900 tracking-tight">
{section.label}
</h2>
<p className="text-sm text-gray-600 mt-2 leading-relaxed max-w-3xl">
{section.blurb}
</p>
</div>
</HeroBand>
);
}

View File

@@ -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 <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[]): {
abs: number;
pct: number | null;
@@ -326,38 +293,38 @@ function ChartCard({
}) {
const delta = seriesDelta(series);
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>
<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}
</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 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 ? (
<p
className={`text-[10px] font-semibold tabular-nums ${
delta.up ? 'text-emerald-600' : 'text-rose-600'
className={`text-[11px] font-semibold tabular-nums ${
delta.up ? 'text-emerald-700' : 'text-red-700'
}`}
>
{delta.up ? '↑' : '↓'} {n(Math.abs(delta.abs))}
{delta.pct != null
? ` (${delta.pct > 0 ? '+' : ''}${delta.pct.toFixed(0)}%)`
: ''}
<span className="text-gray-400 font-normal"> inicio→fin</span>
</p>
) : null}
</div>
</div>
<MiniBars series={series} color={color} />
{series && series.length > 1 ? (
<div className="flex justify-between text-[9px] text-gray-400 tabular-nums">
<span>{shortDay(series[0].day)}</span>
<span>{shortDay(series[series.length - 1].day)}</span>
</div>
) : null}
<SparkBars
points={(series || []).map((p) => ({ date: p.day, value: p.value }))}
color={color}
formatValue={(v) => n(v)}
empty="Sin serie en el periodo"
/>
</div>
);
}
@@ -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 (
<div className="space-y-6">
<div className="space-y-8">
<PageHeader
eyebrow={`Social · ${tenantLabel}`}
eyebrow="Social"
title="Redes sociales"
description={`Analytics orgánicos IG/FB · periodo ${rangeLabel}`}
description={`${tenantLabel} · Instagram y Facebook · ${rangeLabel}`}
actions={<SyncNowButton forceModule="social" />}
/>
@@ -592,22 +536,55 @@ export default function SocialDashboard({
</p>
) : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{kpiCards.map((k) => (
<div
key={k.label}
className="rounded-xl border border-gray-200 bg-white p-4"
>
<div className="flex items-center justify-between text-gray-600 mb-1">
<p className="text-[10px] font-semibold uppercase tracking-wider">
{k.label}
</p>
<k.icon className="w-3.5 h-3.5" />
</div>
<p className="text-xl font-bold text-gray-900 tabular-nums">
{k.value}
</p>
</div>
<HeroBand>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 md:gap-6">
<HeroStat
label="Seguidores IG"
value={n(ig?.followers_count)}
/>
<HeroStat
label="Alcance IG"
value={n(ig?.igInsights?.reach)}
/>
<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>
</HeroBand>
<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>
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 className="grid grid-cols-2 gap-3">
{kpiFb.map((k) => (
<StatMini key={k.label} label={k.label} value={k.value} hint={k.hint} />
))}
</div>
@@ -631,16 +608,12 @@ export default function SocialDashboard({
</p>
)}
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold text-gray-900">
Evolución diaria · {rangeLabel}
</h3>
<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>
<section>
<SectionHead
icon={<BarChart3 className="w-4 h-4" />}
title="Evolución diaria"
hint={`${rangeLabel} · barras = valor diario · flecha = primer→último día`}
/>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{chartBlocks.map((c) => (
<ChartCard key={c.title} {...c} />
@@ -648,15 +621,12 @@ export default function SocialDashboard({
</div>
</section>
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold text-gray-900">
Publicaciones Instagram
</h3>
<p className="text-xs text-gray-500 mt-0.5">
Solo IG (feed, reels y carruseles). Sin posts de Facebook.
</p>
</div>
<section>
<SectionHead
icon={<Instagram className="w-4 h-4" />}
title="Publicaciones Instagram"
hint="Feed, reels y carruseles. Sin posts de Facebook."
/>
{visiblePosts.length === 0 ? (
<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>
);
}