Align CRM, Ads, Social and Organic dashboards with the metrics visual system.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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">
|
||||
{(
|
||||
[
|
||||
<SegmentedControl
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
size="sm"
|
||||
options={[
|
||||
{ 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>
|
||||
]}
|
||||
/>
|
||||
|
||||
{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>
|
||||
|
||||
<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
|
||||
<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)}
|
||||
/>
|
||||
<KpiTile
|
||||
label="CTR"
|
||||
<HeroStat
|
||||
label="CPL"
|
||||
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"
|
||||
value={combined.cpc != null ? formatEurCents(combined.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,22 +897,18 @@ 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
|
||||
label="Impresiones"
|
||||
value={formatNum(meta.impressions)}
|
||||
/>
|
||||
<KpiTile label="Clics (todos)" value={formatNum(metaClicksAll)} />
|
||||
<KpiTile
|
||||
label="Clics (enlace)"
|
||||
value={formatNum(metaClicksLink)}
|
||||
/>
|
||||
<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)}
|
||||
/>
|
||||
<KpiTile
|
||||
<HeroStat
|
||||
label="CPL"
|
||||
value={meta.cpl != null ? formatEurCents(meta.cpl) : '—'}
|
||||
/>
|
||||
<HeroStat
|
||||
label="CTR"
|
||||
value={
|
||||
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)"
|
||||
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
|
||||
label="Impresiones"
|
||||
value={formatNum(google.impressions)}
|
||||
/>
|
||||
<KpiTile label="Clics" value={formatNum(google.clicks)} />
|
||||
<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)}
|
||||
/>
|
||||
<KpiTile
|
||||
label="CTR"
|
||||
value={google.ctr != null ? `${google.ctr.toFixed(2)}%` : '—'}
|
||||
/>
|
||||
<KpiTile
|
||||
label="CPC"
|
||||
value={google.cpc != null ? formatEurCents(google.cpc) : '—'}
|
||||
/>
|
||||
<KpiTile
|
||||
<HeroStat
|
||||
label="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>
|
||||
<CreativesGallery
|
||||
platform="google"
|
||||
|
||||
@@ -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,157 +108,158 @@ 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>
|
||||
))}
|
||||
<SegmentedControl
|
||||
value={lens}
|
||||
onChange={setLens}
|
||||
size="sm"
|
||||
options={[
|
||||
{ id: 'created', label: 'Altas' },
|
||||
{ id: 'status', label: 'Conversiones' },
|
||||
{ id: 'snapshot', label: 'Estado actual' },
|
||||
]}
|
||||
/>
|
||||
</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 ? (
|
||||
<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}
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<p className="text-xl font-bold text-gray-900 tabular-nums">
|
||||
{s.value}
|
||||
</p>
|
||||
</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>
|
||||
<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-4 text-center">
|
||||
<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="text-gray-600 border-b border-gray-200">
|
||||
<thead className={tableHeadClass()}>
|
||||
<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>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...paidChannels, ...otherChannels].map((row) => (
|
||||
<tr
|
||||
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}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums text-gray-800">
|
||||
{formatNum(row.leads)}
|
||||
<td className="py-3 pr-4">
|
||||
<ShareBar
|
||||
value={row.leads}
|
||||
max={leadMax}
|
||||
label={formatNum(row.leads)}
|
||||
/>
|
||||
</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)}
|
||||
</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)}%
|
||||
</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)
|
||||
? formatEur(spendByChannel.get(row.channel)!)
|
||||
: '—'}
|
||||
@@ -261,37 +270,43 @@ export default function CrmDashboard({
|
||||
</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>
|
||||
</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>
|
||||
<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-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">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-gray-600 border-b border-gray-200">
|
||||
<thead className={tableHeadClass()}>
|
||||
<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>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statusRows.map((row) => (
|
||||
<tr
|
||||
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-2.5 pr-3 tabular-nums">
|
||||
{formatNum(row.count)}
|
||||
<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-2.5 tabular-nums text-gray-600">
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-600">
|
||||
{row.pct.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
@@ -300,6 +315,7 @@ export default function CrmDashboard({
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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)}%` }}
|
||||
<SparkBars
|
||||
points={seoDaily.map((d) => ({
|
||||
date: d.day,
|
||||
value: d.clicks,
|
||||
}))}
|
||||
color="bg-emerald-500 hover:bg-emerald-600"
|
||||
formatValue={(v) => formatNum(v)}
|
||||
/>
|
||||
</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
|
||||
</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)}%` }}
|
||||
<SparkBars
|
||||
points={seoDaily.map((d) => ({
|
||||
date: d.day,
|
||||
value: d.sessions,
|
||||
}))}
|
||||
color="bg-sky-500 hover:bg-sky-600"
|
||||
formatValue={(v) => formatNum(v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</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)}%` }}
|
||||
<SparkBars
|
||||
points={seoDaily.map((d) => ({
|
||||
date: d.day,
|
||||
value: d.clicks,
|
||||
}))}
|
||||
color="bg-emerald-500 hover:bg-emerald-600"
|
||||
formatValue={(v) => formatNum(v)}
|
||||
/>
|
||||
</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">
|
||||
</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)}%` }}
|
||||
<SparkBars
|
||||
points={seoDaily.map((d) => ({
|
||||
date: d.day,
|
||||
value: d.sessions,
|
||||
}))}
|
||||
color="bg-sky-500 hover:bg-sky-600"
|
||||
formatValue={(v) => formatNum(v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
<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>
|
||||
<p className="text-xl font-bold text-gray-900 tabular-nums">
|
||||
{k.value}
|
||||
</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
197
src/components/dash-ui.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user