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,
|
CreativesGallery,
|
||||||
CreativeZoomModal,
|
CreativeZoomModal,
|
||||||
} from '@/components/AdCreativesGallery';
|
} from '@/components/AdCreativesGallery';
|
||||||
import { PageHeader } from '@/components/ui';
|
import { PageHeader, SegmentedControl } from '@/components/ui';
|
||||||
import SyncNowButton from '@/components/SyncNowButton';
|
import SyncNowButton from '@/components/SyncNowButton';
|
||||||
import { useDateRange } from '@/components/DateRangeContext';
|
import { useDateRange } from '@/components/DateRangeContext';
|
||||||
|
import {
|
||||||
|
HelpDetails,
|
||||||
|
HeroBand,
|
||||||
|
HeroStat,
|
||||||
|
ShareBar,
|
||||||
|
StatMini,
|
||||||
|
tableHeadClass,
|
||||||
|
} from '@/components/dash-ui';
|
||||||
|
|
||||||
type PlatformKpis = {
|
type PlatformKpis = {
|
||||||
spend: number;
|
spend: number;
|
||||||
@@ -439,21 +447,6 @@ function SparkBars({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiTile({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-white px-3 py-3">
|
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500">{label}</p>
|
|
||||||
<p className="mt-1 text-xl font-bold text-gray-900 tabular-nums">{value}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CampaignTable({
|
function CampaignTable({
|
||||||
title,
|
title,
|
||||||
rows,
|
rows,
|
||||||
@@ -463,44 +456,57 @@ function CampaignTable({
|
|||||||
rows: CampaignRow[];
|
rows: CampaignRow[];
|
||||||
empty: string;
|
empty: string;
|
||||||
}) {
|
}) {
|
||||||
|
const spendMax = Math.max(1, ...rows.map((r) => r.spend));
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-gray-200 bg-white overflow-hidden">
|
<section className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
||||||
<div className="px-4 py-3 border-b border-gray-200">
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
|
||||||
Campañas activas
|
Campañas
|
||||||
</p>
|
</p>
|
||||||
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
|
<h3 className="text-base font-semibold text-gray-900 tracking-tight">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<p className="px-4 py-8 text-sm text-gray-500">{empty}</p>
|
<p className="px-4 py-10 text-sm text-gray-500 text-center">{empty}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-left text-sm">
|
<table className="min-w-full text-left text-sm">
|
||||||
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-gray-50">
|
<thead className={tableHeadClass()}>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-medium">Campaña</th>
|
<th className="px-4 py-3 font-semibold">Campaña</th>
|
||||||
<th className="px-3 py-2 font-medium">Gasto</th>
|
<th className="px-3 py-3 font-semibold min-w-[150px]">Gasto</th>
|
||||||
<th className="px-3 py-2 font-medium">Clics</th>
|
<th className="px-3 py-3 font-semibold">Clics</th>
|
||||||
<th className="px-3 py-2 font-medium">Conv.</th>
|
<th className="px-3 py-3 font-semibold">Conv.</th>
|
||||||
<th className="px-3 py-2 font-medium">CPL</th>
|
<th className="px-3 py-3 font-semibold">CPL</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.slice(0, 25).map((r) => (
|
{rows.slice(0, 25).map((r) => (
|
||||||
<tr key={r.name} className="border-t border-gray-200">
|
<tr
|
||||||
<td className="px-4 py-2.5 text-gray-800 max-w-[220px] truncate">
|
key={r.name}
|
||||||
|
className="border-t border-gray-100 hover:bg-brand-50/40"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="px-4 py-2.5 text-gray-800 max-w-[240px] truncate"
|
||||||
|
title={r.name}
|
||||||
|
>
|
||||||
{r.name}
|
{r.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 text-gray-900 tabular-nums">
|
<td className="px-3 py-2.5">
|
||||||
{formatEur(r.spend)}
|
<ShareBar
|
||||||
|
value={r.spend}
|
||||||
|
max={spendMax}
|
||||||
|
label={formatEur(r.spend)}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 text-gray-600 tabular-nums">
|
<td className="px-3 py-2.5 text-gray-700 tabular-nums">
|
||||||
{formatNum(r.clicks)}
|
{formatNum(r.clicks)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 text-gray-600 tabular-nums">
|
<td className="px-3 py-2.5 text-gray-700 tabular-nums">
|
||||||
{formatNum(r.conversions)}
|
{formatNum(r.conversions)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 text-gray-600 tabular-nums">
|
<td className="px-3 py-2.5 text-gray-700 tabular-nums">
|
||||||
{r.cpl != null ? formatEurCents(r.cpl) : '—'}
|
{r.cpl != null ? formatEurCents(r.cpl) : '—'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -684,86 +690,78 @@ export default function AdTrackerClient({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow={`Ads · ${label}`}
|
eyebrow="Publicidad"
|
||||||
title={tagline || 'Meta + Google Ads'}
|
title={tagline || 'Ads'}
|
||||||
description={`Gasto, clics, conversiones y creatividades. Periodo del header: ${rangeLabel}.`}
|
description={`${label} · Meta + Google · ${rangeLabel}`}
|
||||||
actions={<SyncNowButton forceModule="ads" />}
|
actions={<SyncNowButton forceModule="ads" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tabs */}
|
<SegmentedControl
|
||||||
<div className="flex flex-wrap gap-1.5">
|
value={tab}
|
||||||
{(
|
onChange={setTab}
|
||||||
[
|
size="sm"
|
||||||
|
options={[
|
||||||
{ id: 'resumen', label: 'Resumen' },
|
{ id: 'resumen', label: 'Resumen' },
|
||||||
{ id: 'meta', label: 'Meta Ads' },
|
{ id: 'meta', label: 'Meta Ads' },
|
||||||
{ id: 'google', label: 'Google Ads' },
|
{ id: 'google', label: 'Google Ads' },
|
||||||
] as const
|
]}
|
||||||
).map((t) => (
|
/>
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTab(t.id)}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
|
|
||||||
tab === t.id
|
|
||||||
? 'bg-gray-900 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === 'resumen' && (
|
{tab === 'resumen' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div>
|
<HeroBand>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||||
Resumen de cuenta
|
<HeroStat label="Inversión" value={formatEur(combined.spend)} />
|
||||||
</p>
|
<HeroStat
|
||||||
<h3 className="text-xl font-bold text-gray-900 mt-1">
|
|
||||||
Meta + Google, juntos
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500 mt-1 max-w-3xl">
|
|
||||||
Gasto, impresiones, clics y conversiones sumados. CTR, CPC y CPL
|
|
||||||
se recalculan sobre el total (no se suman). Ventana:{' '}
|
|
||||||
{range.since} → {range.until}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
|
||||||
<KpiTile label="Gasto" value={formatEur(combined.spend)} />
|
|
||||||
<KpiTile
|
|
||||||
label="Impresiones"
|
|
||||||
value={formatNum(combined.impressions)}
|
|
||||||
/>
|
|
||||||
<KpiTile label="Clics" value={formatNum(combined.clicks)} />
|
|
||||||
<KpiTile
|
|
||||||
label="Conversiones"
|
label="Conversiones"
|
||||||
value={formatNum(combined.conversions)}
|
value={formatNum(combined.conversions)}
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
<HeroStat
|
||||||
label="CTR"
|
label="CPL"
|
||||||
value={
|
value={
|
||||||
combined.ctr != null ? `${combined.ctr.toFixed(2)}%` : '—'
|
combined.cpl != null ? formatEurCents(combined.cpl) : '—'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
<HeroStat
|
||||||
|
label="CTR"
|
||||||
|
value={
|
||||||
|
combined.ctr != null
|
||||||
|
? `${combined.ctr.toFixed(2)}%`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HeroBand>
|
||||||
|
|
||||||
|
<HelpDetails title="Cómo leer Ads">
|
||||||
|
<p>
|
||||||
|
Gasto, impresiones, clics y conversiones se suman entre Meta y
|
||||||
|
Google. CTR, CPC y CPL se recalculan sobre el total (no se
|
||||||
|
suman porcentajes).
|
||||||
|
</p>
|
||||||
|
</HelpDetails>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<StatMini
|
||||||
|
label="Impresiones"
|
||||||
|
value={formatNum(combined.impressions)}
|
||||||
|
/>
|
||||||
|
<StatMini label="Clics" value={formatNum(combined.clicks)} />
|
||||||
|
<StatMini
|
||||||
label="CPC"
|
label="CPC"
|
||||||
value={combined.cpc != null ? formatEurCents(combined.cpc) : '—'}
|
value={
|
||||||
|
combined.cpc != null ? formatEurCents(combined.cpc) : '—'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
<StatMini label="Ads activos" value={formatNum(combined.ads)} />
|
||||||
label="CPL"
|
|
||||||
value={combined.cpl != null ? formatEurCents(combined.cpl) : '—'}
|
|
||||||
/>
|
|
||||||
<KpiTile label="Ads" value={formatNum(combined.ads)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-2.5 rounded-full overflow-hidden border border-gray-200 bg-gray-900 flex">
|
<div className="h-2.5 rounded-full overflow-hidden border border-gray-200 bg-gray-100 flex">
|
||||||
<div
|
<div
|
||||||
className="bg-[#7C66B8]"
|
className="bg-brand-500"
|
||||||
style={{ width: `${metaShare}%` }}
|
style={{ width: `${metaShare}%` }}
|
||||||
title={`Meta ${metaShare.toFixed(0)}%`}
|
title={`Meta ${metaShare.toFixed(0)}%`}
|
||||||
/>
|
/>
|
||||||
@@ -773,17 +771,21 @@ export default function AdTrackerClient({
|
|||||||
title={`Google ${googleShare.toFixed(0)}%`}
|
title={`Google ${googleShare.toFixed(0)}%`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-500 -mt-2">
|
||||||
|
Reparto de inversión · Meta {metaShare.toFixed(0)}% · Google{' '}
|
||||||
|
{googleShare.toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab('meta')}
|
onClick={() => setTab('meta')}
|
||||||
className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-[#7C66B8]/50"
|
className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-brand-400 hover:bg-brand-50/40 transition-colors"
|
||||||
>
|
>
|
||||||
<p className="text-[11px] uppercase tracking-wider text-[#a78bfa]">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-brand-700">
|
||||||
Meta Ads
|
Meta Ads
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xl font-bold text-gray-900 mt-1">
|
<p className="text-xl font-semibold text-gray-900 mt-1 tabular-nums tracking-tight">
|
||||||
{formatEur(meta.spend)}
|
{formatEur(meta.spend)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
@@ -796,10 +798,10 @@ export default function AdTrackerClient({
|
|||||||
onClick={() => setTab('google')}
|
onClick={() => setTab('google')}
|
||||||
className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-sky-500/50"
|
className="rounded-xl border border-gray-200 bg-white p-4 text-left hover:border-sky-500/50"
|
||||||
>
|
>
|
||||||
<p className="text-[11px] uppercase tracking-wider text-sky-400">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-sky-700">
|
||||||
Google Ads
|
Google Ads
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xl font-bold text-gray-900 mt-1">
|
<p className="text-xl font-semibold text-gray-900 mt-1 tabular-nums tracking-tight">
|
||||||
{formatEur(google.spend)}
|
{formatEur(google.spend)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
@@ -895,22 +897,18 @@ export default function AdTrackerClient({
|
|||||||
|
|
||||||
{tab === 'meta' && (
|
{tab === 'meta' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
<HeroBand eyebrow="Meta Ads">
|
||||||
<KpiTile label="Gasto" value={formatEur(meta.spend)} />
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||||
<KpiTile
|
<HeroStat label="Inversión" value={formatEur(meta.spend)} />
|
||||||
label="Impresiones"
|
<HeroStat
|
||||||
value={formatNum(meta.impressions)}
|
|
||||||
/>
|
|
||||||
<KpiTile label="Clics (todos)" value={formatNum(metaClicksAll)} />
|
|
||||||
<KpiTile
|
|
||||||
label="Clics (enlace)"
|
|
||||||
value={formatNum(metaClicksLink)}
|
|
||||||
/>
|
|
||||||
<KpiTile
|
|
||||||
label="Conversiones"
|
label="Conversiones"
|
||||||
value={formatNum(meta.conversions)}
|
value={formatNum(meta.conversions)}
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
<HeroStat
|
||||||
|
label="CPL"
|
||||||
|
value={meta.cpl != null ? formatEurCents(meta.cpl) : '—'}
|
||||||
|
/>
|
||||||
|
<HeroStat
|
||||||
label="CTR"
|
label="CTR"
|
||||||
value={
|
value={
|
||||||
meta.impressions > 0
|
meta.impressions > 0
|
||||||
@@ -918,19 +916,27 @@ export default function AdTrackerClient({
|
|||||||
: '—'
|
: '—'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
</div>
|
||||||
|
</HeroBand>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
<StatMini
|
||||||
|
label="Impresiones"
|
||||||
|
value={formatNum(meta.impressions)}
|
||||||
|
/>
|
||||||
|
<StatMini label="Clics (todos)" value={formatNum(metaClicksAll)} />
|
||||||
|
<StatMini
|
||||||
|
label="Clics (enlace)"
|
||||||
|
value={formatNum(metaClicksLink)}
|
||||||
|
/>
|
||||||
|
<StatMini
|
||||||
label="CPC (todos)"
|
label="CPC (todos)"
|
||||||
value={metaCpcAll != null ? formatEurCents(metaCpcAll) : '—'}
|
value={metaCpcAll != null ? formatEurCents(metaCpcAll) : '—'}
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
<StatMini
|
||||||
label="CPC (enlace)"
|
label="CPC (enlace)"
|
||||||
value={metaCpcLink != null ? formatEurCents(metaCpcLink) : '—'}
|
value={metaCpcLink != null ? formatEurCents(metaCpcLink) : '—'}
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
<StatMini label="Ads activos" value={formatNum(meta.ads)} />
|
||||||
label="CPL"
|
|
||||||
value={meta.cpl != null ? formatEurCents(meta.cpl) : '—'}
|
|
||||||
/>
|
|
||||||
<KpiTile label="Ads activos" value={formatNum(meta.ads)} />
|
|
||||||
</div>
|
</div>
|
||||||
<CreativesGallery
|
<CreativesGallery
|
||||||
platform="meta"
|
platform="meta"
|
||||||
@@ -965,30 +971,36 @@ export default function AdTrackerClient({
|
|||||||
|
|
||||||
{tab === 'google' && (
|
{tab === 'google' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
<HeroBand eyebrow="Google Ads">
|
||||||
<KpiTile label="Gasto" value={formatEur(google.spend)} />
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||||
<KpiTile
|
<HeroStat label="Inversión" value={formatEur(google.spend)} />
|
||||||
label="Impresiones"
|
<HeroStat
|
||||||
value={formatNum(google.impressions)}
|
|
||||||
/>
|
|
||||||
<KpiTile label="Clics" value={formatNum(google.clicks)} />
|
|
||||||
<KpiTile
|
|
||||||
label="Conversiones"
|
label="Conversiones"
|
||||||
value={formatNum(google.conversions)}
|
value={formatNum(google.conversions)}
|
||||||
/>
|
/>
|
||||||
<KpiTile
|
<HeroStat
|
||||||
label="CTR"
|
|
||||||
value={google.ctr != null ? `${google.ctr.toFixed(2)}%` : '—'}
|
|
||||||
/>
|
|
||||||
<KpiTile
|
|
||||||
label="CPC"
|
|
||||||
value={google.cpc != null ? formatEurCents(google.cpc) : '—'}
|
|
||||||
/>
|
|
||||||
<KpiTile
|
|
||||||
label="CPL"
|
label="CPL"
|
||||||
value={google.cpl != null ? formatEurCents(google.cpl) : '—'}
|
value={google.cpl != null ? formatEurCents(google.cpl) : '—'}
|
||||||
/>
|
/>
|
||||||
<KpiTile label="Ads activos" value={formatNum(google.ads)} />
|
<HeroStat
|
||||||
|
label="CTR"
|
||||||
|
value={
|
||||||
|
google.ctr != null ? `${google.ctr.toFixed(2)}%` : '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HeroBand>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<StatMini
|
||||||
|
label="Impresiones"
|
||||||
|
value={formatNum(google.impressions)}
|
||||||
|
/>
|
||||||
|
<StatMini label="Clics" value={formatNum(google.clicks)} />
|
||||||
|
<StatMini
|
||||||
|
label="CPC"
|
||||||
|
value={google.cpc != null ? formatEurCents(google.cpc) : '—'}
|
||||||
|
/>
|
||||||
|
<StatMini label="Ads activos" value={formatNum(google.ads)} />
|
||||||
</div>
|
</div>
|
||||||
<CreativesGallery
|
<CreativesGallery
|
||||||
platform="google"
|
platform="google"
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Users,
|
|
||||||
UserCheck,
|
|
||||||
Percent,
|
|
||||||
Flame,
|
Flame,
|
||||||
UserX,
|
UserX,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
GitCompare,
|
||||||
|
ListTree,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AdDailyRow,
|
AdDailyRow,
|
||||||
@@ -23,24 +22,33 @@ import {
|
|||||||
formatEur,
|
formatEur,
|
||||||
formatNum,
|
formatNum,
|
||||||
} from '@/lib/metrics';
|
} from '@/lib/metrics';
|
||||||
import { PageHeader } from '@/components/ui';
|
import { PageHeader, SegmentedControl, Surface } from '@/components/ui';
|
||||||
import { useDateRange } from '@/components/DateRangeContext';
|
import { useDateRange } from '@/components/DateRangeContext';
|
||||||
import SyncNowButton from '@/components/SyncNowButton';
|
import SyncNowButton from '@/components/SyncNowButton';
|
||||||
|
import {
|
||||||
|
HelpDetails,
|
||||||
|
HeroBand,
|
||||||
|
HeroStat,
|
||||||
|
SectionHead,
|
||||||
|
ShareBar,
|
||||||
|
StatMini,
|
||||||
|
tableHeadClass,
|
||||||
|
} from '@/components/dash-ui';
|
||||||
|
|
||||||
function rateClass(rate: number): string {
|
function rateClass(rate: number): string {
|
||||||
if (rate >= 8) return 'text-emerald-400';
|
if (rate >= 8) return 'text-emerald-800';
|
||||||
if (rate >= 3) return 'text-amber-400';
|
if (rate >= 3) return 'text-amber-800';
|
||||||
if (rate > 0) return 'text-orange-400';
|
if (rate > 0) return 'text-orange-800';
|
||||||
return 'text-gray-500';
|
return 'text-gray-500';
|
||||||
}
|
}
|
||||||
|
|
||||||
const LENS_HELP: Record<CrmDateLens, string> = {
|
const LENS_HELP: Record<CrmDateLens, string> = {
|
||||||
created:
|
created:
|
||||||
'Altas: leads cuya fecha de creación en Clientify cae en el periodo del header.',
|
'Altas: leads cuya fecha de creación en el CRM cae en el periodo del header.',
|
||||||
status:
|
status:
|
||||||
'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at (sql/leads_status_timing.sql).',
|
'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at.',
|
||||||
snapshot:
|
snapshot:
|
||||||
'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora en el warehouse.',
|
'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CrmDashboard({
|
export default function CrmDashboard({
|
||||||
@@ -100,157 +108,158 @@ export default function CrmDashboard({
|
|||||||
const otherChannels = channelRows.filter(
|
const otherChannels = channelRows.filter(
|
||||||
(r) => !['meta', 'google'].includes(r.channel)
|
(r) => !['meta', 'google'].includes(r.channel)
|
||||||
);
|
);
|
||||||
|
const leadMax = Math.max(1, ...channelRows.map((r) => r.leads));
|
||||||
|
const statusMax = Math.max(1, ...statusRows.map((r) => r.count));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Rendimiento"
|
eyebrow="Rendimiento"
|
||||||
title="CRM"
|
title="CRM"
|
||||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
|
description={`${label}${portfolioWide ? ' · portafolio' : ''} · ${rangeHint}`}
|
||||||
actions={<SyncNowButton forceModule="crm" />}
|
actions={<SyncNowButton forceModule="crm" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-600 shrink-0">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 shrink-0">
|
||||||
Lente CRM
|
Lente
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<SegmentedControl
|
||||||
{(
|
value={lens}
|
||||||
[
|
onChange={setLens}
|
||||||
['created', 'Altas en periodo'],
|
size="sm"
|
||||||
['status', 'Conversiones en periodo'],
|
options={[
|
||||||
['snapshot', 'Estado actual'],
|
{ id: 'created', label: 'Altas' },
|
||||||
] as const
|
{ id: 'status', label: 'Conversiones' },
|
||||||
).map(([id, lab]) => (
|
{ id: 'snapshot', label: 'Estado actual' },
|
||||||
<button
|
]}
|
||||||
key={id}
|
/>
|
||||||
type="button"
|
|
||||||
onClick={() => setLens(id)}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-colors ${
|
|
||||||
lens === id
|
|
||||||
? 'bg-brand-500 text-white border-brand-500'
|
|
||||||
: 'bg-white text-gray-700 border-gray-200 hover:border-brand-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{lab}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-600 -mt-4">{LENS_HELP[lens]}</p>
|
<HelpDetails title="Cómo leer este CRM">
|
||||||
|
<p>{LENS_HELP[lens]}</p>
|
||||||
|
<p>
|
||||||
|
El % a cliente es del CRM, no de ads. La inversión por canal es solo
|
||||||
|
referencia del periodo.
|
||||||
|
</p>
|
||||||
|
</HelpDetails>
|
||||||
|
|
||||||
{lens === 'status' && !hasStatusTiming ? (
|
{lens === 'status' && !hasStatusTiming ? (
|
||||||
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
|
<p className="text-xs text-amber-900 border border-amber-200 bg-amber-50 rounded-xl px-3 py-2">
|
||||||
Aún no hay <code className="font-mono">status_changed_at</code> en
|
Aún no hay fecha de cambio de status en warehouse — filtrando por
|
||||||
warehouse — aplicando filtro por alta. Ejecuta{' '}
|
alta. Ejecuta <code className="font-mono">sql/leads_status_timing.sql</code>{' '}
|
||||||
<code className="font-mono">sql/leads_status_timing.sql</code> en
|
y deja que los webhooks actualicen contactos.
|
||||||
Supabase y deja que los webhooks actualicen contactos.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!crmLeads.length ? (
|
{!crmLeads.length ? (
|
||||||
<p className="text-sm text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-4 py-3">
|
<p className="text-sm text-amber-900 border border-amber-200 bg-amber-50 rounded-xl px-4 py-3">
|
||||||
Sin leads Clientify en este contexto. Importa el histórico o activa el
|
Sin leads CRM en este contexto. Importa el histórico o activa el
|
||||||
webhook (`?tenant=…`). En portafolio, elige un cliente en Contexto.
|
webhook. En portafolio, elige un cliente en Contexto.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
|
<HeroBand>
|
||||||
{[
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||||
{
|
<HeroStat label="Leads" value={formatNum(funnel.total)} />
|
||||||
label: 'Leads CRM',
|
<HeroStat
|
||||||
value: formatNum(funnel.total),
|
label="Clientes"
|
||||||
icon: <Users className="w-4 h-4 text-blue-400" />,
|
value={formatNum(funnel.cliente)}
|
||||||
},
|
chip={
|
||||||
{
|
<span className="text-[11px] text-gray-500">
|
||||||
label: 'Clientes (ganado)',
|
{funnel.enOportunidad} en oportunidad
|
||||||
value: formatNum(funnel.cliente),
|
|
||||||
icon: <UserCheck className="w-4 h-4 text-emerald-400" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '% a cliente',
|
|
||||||
value: `${funnel.clienteRate.toFixed(1)}%`,
|
|
||||||
icon: <Percent className="w-4 h-4 text-brand-400" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'En oportunidad',
|
|
||||||
value: formatNum(funnel.enOportunidad),
|
|
||||||
icon: <Briefcase className="w-4 h-4 text-violet-400" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Lead frío',
|
|
||||||
value: formatNum(funnel.leadFrio),
|
|
||||||
icon: <Flame className="w-4 h-4 text-sky-400" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Lead perdido',
|
|
||||||
value: formatNum(funnel.leadPerdido),
|
|
||||||
icon: <UserX className="w-4 h-4 text-red-400" />,
|
|
||||||
},
|
|
||||||
].map((s) => (
|
|
||||||
<div
|
|
||||||
key={s.label}
|
|
||||||
className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between text-gray-600 mb-1">
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider">
|
|
||||||
{s.label}
|
|
||||||
</span>
|
</span>
|
||||||
{s.icon}
|
}
|
||||||
|
/>
|
||||||
|
<HeroStat
|
||||||
|
label="% a cliente"
|
||||||
|
value={`${funnel.clienteRate.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<HeroStat
|
||||||
|
label="Perdidos"
|
||||||
|
value={formatNum(funnel.leadPerdido)}
|
||||||
|
chip={
|
||||||
|
<span className="text-[11px] text-gray-500">
|
||||||
|
{formatNum(funnel.leadFrio)} fríos
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-bold text-gray-900 tabular-nums">
|
</HeroBand>
|
||||||
{s.value}
|
|
||||||
</p>
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
</div>
|
<StatMini
|
||||||
))}
|
label="En oportunidad"
|
||||||
|
value={formatNum(funnel.enOportunidad)}
|
||||||
|
hint="Pipeline abierto"
|
||||||
|
icon={<Briefcase className="w-3.5 h-3.5" />}
|
||||||
|
/>
|
||||||
|
<StatMini
|
||||||
|
label="Lead frío"
|
||||||
|
value={formatNum(funnel.leadFrio)}
|
||||||
|
hint="Sin avanzar"
|
||||||
|
icon={<Flame className="w-3.5 h-3.5" />}
|
||||||
|
/>
|
||||||
|
<StatMini
|
||||||
|
label="Lead perdido"
|
||||||
|
value={formatNum(funnel.leadPerdido)}
|
||||||
|
hint="Cerrado en pérdida"
|
||||||
|
icon={<UserX className="w-3.5 h-3.5" />}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
|
<section>
|
||||||
<div>
|
<SectionHead
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
icon={<GitCompare className="w-4 h-4" />}
|
||||||
% a cliente por canal
|
title="% a cliente por canal"
|
||||||
</h3>
|
hint="Estado ganado en el CRM. Ads spend del periodo es referencia."
|
||||||
<p className="text-xs text-gray-600 mt-0.5">
|
/>
|
||||||
Estado Clientify = <span className="text-gray-400">cliente</span>{' '}
|
<Surface padded={false}>
|
||||||
(ganado). Ads spend del periodo es referencia.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{!channelRows.length ? (
|
{!channelRows.length ? (
|
||||||
<p className="text-sm text-gray-500 py-4 text-center">
|
<p className="text-sm text-gray-500 py-10 text-center">
|
||||||
Sin leads en esta lente/periodo.
|
Sin leads en esta lente/periodo.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="text-gray-600 border-b border-gray-200">
|
<thead className={tableHeadClass()}>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="py-2 pr-3 font-medium">Canal</th>
|
<th className="py-3 px-4 font-semibold">Canal</th>
|
||||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
<th className="py-3 pr-4 font-semibold min-w-[160px]">
|
||||||
<th className="py-2 pr-3 font-medium">Clientes</th>
|
Leads
|
||||||
<th className="py-2 pr-3 font-medium">% a cliente</th>
|
</th>
|
||||||
<th className="py-2 font-medium">Inversión ads</th>
|
<th className="py-3 pr-4 font-semibold">Clientes</th>
|
||||||
|
<th className="py-3 pr-4 font-semibold">% a cliente</th>
|
||||||
|
<th className="py-3 pr-4 font-semibold">Inversión ads</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{[...paidChannels, ...otherChannels].map((row) => (
|
{[...paidChannels, ...otherChannels].map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.channel}
|
key={row.channel}
|
||||||
className="border-b border-gray-100 hover:bg-gray-50"
|
className="border-b border-gray-100 hover:bg-brand-50/40"
|
||||||
>
|
>
|
||||||
<td className="py-2.5 pr-3 font-semibold capitalize text-gray-800">
|
<td className="py-3 px-4 font-semibold capitalize text-gray-800">
|
||||||
{row.channel}
|
{row.channel}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 pr-3 tabular-nums text-gray-800">
|
<td className="py-3 pr-4">
|
||||||
{formatNum(row.leads)}
|
<ShareBar
|
||||||
|
value={row.leads}
|
||||||
|
max={leadMax}
|
||||||
|
label={formatNum(row.leads)}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 pr-3 tabular-nums text-gray-800">
|
<td className="py-3 pr-4 tabular-nums text-gray-800">
|
||||||
{formatNum(row.cliente)}
|
{formatNum(row.cliente)}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`py-2.5 pr-3 font-semibold ${rateClass(row.clienteRate)}`}
|
className={`py-3 pr-4 font-semibold tabular-nums ${rateClass(
|
||||||
|
row.clienteRate
|
||||||
|
)}`}
|
||||||
>
|
>
|
||||||
{row.clienteRate.toFixed(1)}%
|
{row.clienteRate.toFixed(1)}%
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 tabular-nums text-gray-700">
|
<td className="py-3 pr-4 tabular-nums text-gray-700">
|
||||||
{spendByChannel.get(row.channel)
|
{spendByChannel.get(row.channel)
|
||||||
? formatEur(spendByChannel.get(row.channel)!)
|
? formatEur(spendByChannel.get(row.channel)!)
|
||||||
: '—'}
|
: '—'}
|
||||||
@@ -261,37 +270,43 @@ export default function CrmDashboard({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-gray-500">
|
</Surface>
|
||||||
La inversión por canal (si hay ads) es solo referencia; el % a cliente
|
|
||||||
es CRM. Lente activa: {lens}.
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900">Desglose de status</h3>
|
<SectionHead
|
||||||
|
icon={<ListTree className="w-4 h-4" />}
|
||||||
|
title="Desglose de status"
|
||||||
|
hint="Distribución de contactos en el warehouse con la lente activa"
|
||||||
|
/>
|
||||||
|
<Surface padded={false}>
|
||||||
{!statusRows.length ? (
|
{!statusRows.length ? (
|
||||||
<p className="text-sm text-gray-500 py-4 text-center">Sin datos.</p>
|
<p className="text-sm text-gray-500 py-10 text-center">Sin datos.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="text-gray-600 border-b border-gray-200">
|
<thead className={tableHeadClass()}>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="py-2 pr-3 font-medium">Status</th>
|
<th className="py-3 px-4 font-semibold">Status</th>
|
||||||
<th className="py-2 pr-3 font-medium">N</th>
|
<th className="py-3 pr-4 font-semibold min-w-[160px]">N</th>
|
||||||
<th className="py-2 font-medium">%</th>
|
<th className="py-3 pr-4 font-semibold">%</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{statusRows.map((row) => (
|
{statusRows.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.status}
|
key={row.status}
|
||||||
className="border-b border-gray-100 hover:bg-gray-50"
|
className="border-b border-gray-100 hover:bg-brand-50/40"
|
||||||
>
|
>
|
||||||
<td className="py-2.5 pr-3 text-gray-800">{row.status}</td>
|
<td className="py-3 px-4 text-gray-800">{row.status}</td>
|
||||||
<td className="py-2.5 pr-3 tabular-nums">
|
<td className="py-3 pr-4">
|
||||||
{formatNum(row.count)}
|
<ShareBar
|
||||||
|
value={row.count}
|
||||||
|
max={statusMax}
|
||||||
|
label={formatNum(row.count)}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 tabular-nums text-gray-600">
|
<td className="py-3 pr-4 tabular-nums text-gray-600">
|
||||||
{row.pct.toFixed(1)}%
|
{row.pct.toFixed(1)}%
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -300,6 +315,7 @@ export default function CrmDashboard({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Surface>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,8 +33,15 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader, SegmentedControl } from '@/components/ui';
|
import { PageHeader, SegmentedControl, Surface } from '@/components/ui';
|
||||||
import SyncNowButton from '@/components/SyncNowButton';
|
import SyncNowButton from '@/components/SyncNowButton';
|
||||||
|
import {
|
||||||
|
HelpDetails,
|
||||||
|
HeroBand,
|
||||||
|
HeroStat,
|
||||||
|
SparkBars,
|
||||||
|
StatMini,
|
||||||
|
} from '@/components/dash-ui';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -55,32 +62,13 @@ function Kpi({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
hint,
|
hint,
|
||||||
accent,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
accent?: boolean;
|
accent?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <StatMini label={label} value={value} hint={hint} />;
|
||||||
<div
|
|
||||||
className={`rounded-xl border px-4 py-3 shadow-sm ${
|
|
||||||
accent
|
|
||||||
? 'border-brand-200 bg-brand-50'
|
|
||||||
: 'border-gray-200 bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className={`text-[11px] font-semibold uppercase tracking-wider ${
|
|
||||||
accent ? 'text-brand-700' : 'text-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">{value}</p>
|
|
||||||
{hint ? <p className="text-[11px] text-gray-500 mt-0.5">{hint}</p> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OrganicoDashboard({
|
export default function OrganicoDashboard({
|
||||||
@@ -212,8 +200,6 @@ export default function OrganicoDashboard({
|
|||||||
sessions: Number(r.ga4_organic_sessions) || 0,
|
sessions: Number(r.ga4_organic_sessions) || 0,
|
||||||
}));
|
}));
|
||||||
}, [organic]);
|
}, [organic]);
|
||||||
const maxClicks = Math.max(1, ...seoDaily.map((d) => d.clicks));
|
|
||||||
const maxSessions = Math.max(1, ...seoDaily.map((d) => d.sessions));
|
|
||||||
|
|
||||||
const section = useMemo(
|
const section = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -431,9 +417,9 @@ export default function OrganicoDashboard({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Crecimiento · Suite"
|
eyebrow="Crecimiento"
|
||||||
title="Orgánico"
|
title="Orgánico"
|
||||||
description={`${label} · Search Console y Analytics. GEO (visibilidad en IA) llega después.`}
|
description={`${label} · Search Console y Analytics · GEO próximamente`}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<SyncNowButton forceModule="organico" />
|
<SyncNowButton forceModule="organico" />
|
||||||
@@ -462,6 +448,13 @@ export default function OrganicoDashboard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<HelpDetails title="Cómo leer Orgánico">
|
||||||
|
<p>
|
||||||
|
Search Console son clics e impresiones en Google. GA4 son sesiones y
|
||||||
|
conversiones del sitio. No se suman a los leads de anuncios.
|
||||||
|
</p>
|
||||||
|
</HelpDetails>
|
||||||
|
|
||||||
{/* Connections */}
|
{/* Connections */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
{[
|
{[
|
||||||
@@ -576,10 +569,27 @@ export default function OrganicoDashboard({
|
|||||||
{/* Paneles SEO por sección */}
|
{/* Paneles SEO por sección */}
|
||||||
{section.id === 'seo-overview' && (
|
{section.id === 'seo-overview' && (
|
||||||
<>
|
<>
|
||||||
|
<HeroBand>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||||
|
<HeroStat
|
||||||
|
label="Clics GSC"
|
||||||
|
value={formatNum(totals.gscClicks)}
|
||||||
|
/>
|
||||||
|
<HeroStat
|
||||||
|
label="Impresiones"
|
||||||
|
value={formatNum(totals.gscImpressions)}
|
||||||
|
/>
|
||||||
|
<HeroStat
|
||||||
|
label="CTR"
|
||||||
|
value={formatPct(totals.gscCtr)}
|
||||||
|
/>
|
||||||
|
<HeroStat
|
||||||
|
label="Sesiones orgánicas"
|
||||||
|
value={formatNum(totals.ga4Organic)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HeroBand>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<Kpi label="Clics GSC" value={formatNum(totals.gscClicks)} />
|
|
||||||
<Kpi label="Impresiones" value={formatNum(totals.gscImpressions)} />
|
|
||||||
<Kpi label="CTR" value={formatPct(totals.gscCtr)} />
|
|
||||||
<Kpi
|
<Kpi
|
||||||
label="Posición media"
|
label="Posición media"
|
||||||
value={
|
value={
|
||||||
@@ -588,11 +598,6 @@ export default function OrganicoDashboard({
|
|||||||
: '—'
|
: '—'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Kpi
|
|
||||||
label="Sesiones orgánicas"
|
|
||||||
value={formatNum(totals.ga4Organic)}
|
|
||||||
hint="GA4"
|
|
||||||
/>
|
|
||||||
<Kpi label="Usuarios" value={formatNum(totals.ga4Users)} />
|
<Kpi label="Usuarios" value={formatNum(totals.ga4Users)} />
|
||||||
<Kpi
|
<Kpi
|
||||||
label="Conversiones"
|
label="Conversiones"
|
||||||
@@ -606,36 +611,32 @@ export default function OrganicoDashboard({
|
|||||||
</div>
|
</div>
|
||||||
{seoDaily.length > 0 && (
|
{seoDaily.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
<Surface>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
|
||||||
Clics GSC · 28d
|
Clics GSC
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-end gap-0.5 h-16">
|
<SparkBars
|
||||||
{seoDaily.map((d) => (
|
points={seoDaily.map((d) => ({
|
||||||
<div key={`c-${d.day}`} className="flex-1" title={`${d.day}: ${d.clicks}`}>
|
date: d.day,
|
||||||
<div
|
value: d.clicks,
|
||||||
className="w-full rounded-t bg-emerald-400/80"
|
}))}
|
||||||
style={{ height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%` }}
|
color="bg-emerald-500 hover:bg-emerald-600"
|
||||||
|
formatValue={(v) => formatNum(v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Surface>
|
||||||
))}
|
<Surface>
|
||||||
</div>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
|
||||||
</div>
|
Sesiones orgánicas GA4
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
|
||||||
Sesiones orgánicas GA4 · 28d
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-end gap-0.5 h-16">
|
<SparkBars
|
||||||
{seoDaily.map((d) => (
|
points={seoDaily.map((d) => ({
|
||||||
<div key={`s-${d.day}`} className="flex-1" title={`${d.day}: ${d.sessions}`}>
|
date: d.day,
|
||||||
<div
|
value: d.sessions,
|
||||||
className="w-full rounded-t bg-sky-400/80"
|
}))}
|
||||||
style={{ height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%` }}
|
color="bg-sky-500 hover:bg-sky-600"
|
||||||
|
formatValue={(v) => formatNum(v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Surface>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{totals.days === 0 && (
|
{totals.days === 0 && (
|
||||||
@@ -741,36 +742,32 @@ export default function OrganicoDashboard({
|
|||||||
</div>
|
</div>
|
||||||
{seoDaily.length > 0 ? (
|
{seoDaily.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
<Surface>
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
|
||||||
Clics · evolución
|
Clics · evolución
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-end gap-0.5 h-24">
|
<SparkBars
|
||||||
{seoDaily.map((d) => (
|
points={seoDaily.map((d) => ({
|
||||||
<div key={`t-${d.day}`} className="flex-1" title={`${d.day}: ${d.clicks}`}>
|
date: d.day,
|
||||||
<div
|
value: d.clicks,
|
||||||
className="w-full rounded-t bg-emerald-500/80"
|
}))}
|
||||||
style={{ height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%` }}
|
color="bg-emerald-500 hover:bg-emerald-600"
|
||||||
|
formatValue={(v) => formatNum(v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Surface>
|
||||||
))}
|
<Surface>
|
||||||
</div>
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
|
||||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
|
||||||
Sesiones orgánicas · evolución
|
Sesiones orgánicas · evolución
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-end gap-0.5 h-24">
|
<SparkBars
|
||||||
{seoDaily.map((d) => (
|
points={seoDaily.map((d) => ({
|
||||||
<div key={`u-${d.day}`} className="flex-1" title={`${d.day}: ${d.sessions}`}>
|
date: d.day,
|
||||||
<div
|
value: d.sessions,
|
||||||
className="w-full rounded-t bg-sky-500/80"
|
}))}
|
||||||
style={{ height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%` }}
|
color="bg-sky-500 hover:bg-sky-600"
|
||||||
|
formatValue={(v) => formatNum(v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Surface>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyHint text="Sin serie diaria todavía." />
|
<EmptyHint text="Sin serie diaria todavía." />
|
||||||
@@ -1244,15 +1241,14 @@ function SeoTables({
|
|||||||
|
|
||||||
function SectionHero({ section }: { section: OrganicoSection }) {
|
function SectionHero({ section }: { section: OrganicoSection }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
<HeroBand eyebrow={section.group}>
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">
|
<h2 className="text-lg font-semibold text-gray-900 tracking-tight">
|
||||||
{section.group}
|
{section.label}
|
||||||
</p>
|
</h2>
|
||||||
<h2 className="text-lg font-bold text-gray-900 mt-1">{section.label}</h2>
|
|
||||||
<p className="text-sm text-gray-600 mt-2 leading-relaxed max-w-3xl">
|
<p className="text-sm text-gray-600 mt-2 leading-relaxed max-w-3xl">
|
||||||
{section.blurb}
|
{section.blurb}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</HeroBand>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,23 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Share2,
|
Share2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Users,
|
|
||||||
Eye,
|
Eye,
|
||||||
Image as ImageIcon,
|
|
||||||
Heart,
|
Heart,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Repeat2,
|
Repeat2,
|
||||||
|
BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader } from '@/components/ui';
|
import { PageHeader } from '@/components/ui';
|
||||||
import SyncNowButton from '@/components/SyncNowButton';
|
import SyncNowButton from '@/components/SyncNowButton';
|
||||||
import { useDateRange } from '@/components/DateRangeContext';
|
import { useDateRange } from '@/components/DateRangeContext';
|
||||||
|
import {
|
||||||
|
HelpDetails,
|
||||||
|
HeroBand,
|
||||||
|
HeroStat,
|
||||||
|
SectionHead,
|
||||||
|
SparkBars,
|
||||||
|
StatMini,
|
||||||
|
} from '@/components/dash-ui';
|
||||||
|
|
||||||
type Integ = {
|
type Integ = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -258,46 +265,6 @@ function fmtDate(iso?: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortDay(day: string) {
|
|
||||||
const d = day.slice(5); // MM-DD
|
|
||||||
return d.replace('-', '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiniBars({
|
|
||||||
series,
|
|
||||||
color = 'bg-brand-400/80',
|
|
||||||
empty = 'Sin serie en el periodo',
|
|
||||||
}: {
|
|
||||||
series?: DayPoint[];
|
|
||||||
color?: string;
|
|
||||||
empty?: string;
|
|
||||||
}) {
|
|
||||||
const pts = series || [];
|
|
||||||
if (!pts.length) {
|
|
||||||
return <p className="text-xs text-gray-500 py-6 text-center">{empty}</p>;
|
|
||||||
}
|
|
||||||
const max = Math.max(1, ...pts.map((p) => p.value));
|
|
||||||
return (
|
|
||||||
<div className="flex items-end gap-0.5 h-28 w-full">
|
|
||||||
{pts.map((p) => {
|
|
||||||
const pct = Math.max(6, Math.round((p.value / max) * 100));
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={p.day}
|
|
||||||
className="flex-1 min-w-0 h-full flex flex-col justify-end"
|
|
||||||
title={`${p.day}: ${n(p.value)}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`w-full rounded-t ${color}`}
|
|
||||||
style={{ height: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function seriesDelta(series?: DayPoint[]): {
|
function seriesDelta(series?: DayPoint[]): {
|
||||||
abs: number;
|
abs: number;
|
||||||
pct: number | null;
|
pct: number | null;
|
||||||
@@ -326,38 +293,38 @@ function ChartCard({
|
|||||||
}) {
|
}) {
|
||||||
const delta = seriesDelta(series);
|
const delta = seriesDelta(series);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
|
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
{hint ? <p className="text-[10px] text-gray-400 mt-0.5">{hint}</p> : null}
|
{hint ? <p className="text-xs text-gray-400 mt-0.5">{hint}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-lg font-bold text-gray-900 tabular-nums">{n(total)}</p>
|
<p className="text-xl font-semibold text-gray-900 tabular-nums tracking-tight">
|
||||||
|
{n(total)}
|
||||||
|
</p>
|
||||||
{delta ? (
|
{delta ? (
|
||||||
<p
|
<p
|
||||||
className={`text-[10px] font-semibold tabular-nums ${
|
className={`text-[11px] font-semibold tabular-nums ${
|
||||||
delta.up ? 'text-emerald-600' : 'text-rose-600'
|
delta.up ? 'text-emerald-700' : 'text-red-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{delta.up ? '↑' : '↓'} {n(Math.abs(delta.abs))}
|
{delta.up ? '↑' : '↓'} {n(Math.abs(delta.abs))}
|
||||||
{delta.pct != null
|
{delta.pct != null
|
||||||
? ` (${delta.pct > 0 ? '+' : ''}${delta.pct.toFixed(0)}%)`
|
? ` (${delta.pct > 0 ? '+' : ''}${delta.pct.toFixed(0)}%)`
|
||||||
: ''}
|
: ''}
|
||||||
<span className="text-gray-400 font-normal"> inicio→fin</span>
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MiniBars series={series} color={color} />
|
<SparkBars
|
||||||
{series && series.length > 1 ? (
|
points={(series || []).map((p) => ({ date: p.day, value: p.value }))}
|
||||||
<div className="flex justify-between text-[9px] text-gray-400 tabular-nums">
|
color={color}
|
||||||
<span>{shortDay(series[0].day)}</span>
|
formatValue={(v) => n(v)}
|
||||||
<span>{shortDay(series[series.length - 1].day)}</span>
|
empty="Sin serie en el periodo"
|
||||||
</div>
|
/>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -410,66 +377,43 @@ export default function SocialDashboard({
|
|||||||
[posts]
|
[posts]
|
||||||
);
|
);
|
||||||
|
|
||||||
const kpiCards = [
|
const kpiIg = [
|
||||||
{
|
{
|
||||||
label: 'Seguidores IG (global)',
|
label: 'Publicaciones IG',
|
||||||
value: n(ig?.followers_count),
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Publicaciones IG (global)',
|
|
||||||
value: n(ig?.media_count),
|
value: n(ig?.media_count),
|
||||||
icon: ImageIcon,
|
hint: 'Stock del perfil',
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Alcance IG (periodo)',
|
|
||||||
value: n(ig?.igInsights?.reach),
|
|
||||||
icon: Eye,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Visualizaciones IG',
|
label: 'Visualizaciones IG',
|
||||||
value: n(ig?.igInsights?.impressions),
|
value: n(ig?.igInsights?.impressions),
|
||||||
icon: Eye,
|
hint: 'Periodo',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Visitas perfil IG',
|
label: 'Visitas perfil',
|
||||||
value: n(ig?.igInsights?.profileViews),
|
value: n(ig?.igInsights?.profileViews),
|
||||||
icon: Users,
|
hint: 'Instagram',
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Interacciones IG',
|
|
||||||
value: n(ig?.igInsights?.interactions ?? ig?.igInsights?.accountsEngaged),
|
|
||||||
icon: Heart,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Clics web IG',
|
label: 'Clics web IG',
|
||||||
value: n(ig?.igInsights?.websiteClicks),
|
value: n(ig?.igInsights?.websiteClicks),
|
||||||
icon: ExternalLink,
|
hint: 'Desde el perfil',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Δ seguidores IG',
|
label: 'Δ seguidores',
|
||||||
value: n(ig?.igInsights?.followerDelta),
|
value: n(ig?.igInsights?.followerDelta),
|
||||||
icon: Users,
|
hint: 'Cambio en el periodo',
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Fans FB (global)',
|
|
||||||
value: n(fb?.followers_count ?? fb?.fan_count ?? fb?.pageInsights?.fans),
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Alcance FB (periodo)',
|
|
||||||
value: n(fb?.pageInsights?.reach),
|
|
||||||
icon: Eye,
|
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
const kpiFb = [
|
||||||
{
|
{
|
||||||
label: 'Visualizaciones FB',
|
label: 'Visualizaciones FB',
|
||||||
value: n(fb?.pageInsights?.impressions),
|
value: n(fb?.pageInsights?.impressions),
|
||||||
icon: Eye,
|
hint: 'Periodo',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Engagement posts FB',
|
label: 'Engagement posts',
|
||||||
value: n(fb?.pageInsights?.postEngagements),
|
value: n(fb?.pageInsights?.postEngagements),
|
||||||
icon: Heart,
|
hint: 'Facebook',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -567,11 +511,11 @@ export default function SocialDashboard({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow={`Social · ${tenantLabel}`}
|
eyebrow="Social"
|
||||||
title="Redes sociales"
|
title="Redes sociales"
|
||||||
description={`Analytics orgánicos IG/FB · periodo ${rangeLabel}`}
|
description={`${tenantLabel} · Instagram y Facebook · ${rangeLabel}`}
|
||||||
actions={<SyncNowButton forceModule="social" />}
|
actions={<SyncNowButton forceModule="social" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -592,22 +536,55 @@ export default function SocialDashboard({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<HeroBand>
|
||||||
{kpiCards.map((k) => (
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 md:gap-6">
|
||||||
<div
|
<HeroStat
|
||||||
key={k.label}
|
label="Seguidores IG"
|
||||||
className="rounded-xl border border-gray-200 bg-white p-4"
|
value={n(ig?.followers_count)}
|
||||||
>
|
/>
|
||||||
<div className="flex items-center justify-between text-gray-600 mb-1">
|
<HeroStat
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wider">
|
label="Alcance IG"
|
||||||
{k.label}
|
value={n(ig?.igInsights?.reach)}
|
||||||
</p>
|
/>
|
||||||
<k.icon className="w-3.5 h-3.5" />
|
<HeroStat
|
||||||
|
label="Interacciones IG"
|
||||||
|
value={n(
|
||||||
|
ig?.igInsights?.interactions ??
|
||||||
|
ig?.igInsights?.accountsEngaged
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<HeroStat
|
||||||
|
label="Fans FB"
|
||||||
|
value={n(
|
||||||
|
fb?.followers_count ?? fb?.fan_count ?? fb?.pageInsights?.fans
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<HeroStat
|
||||||
|
label="Alcance FB"
|
||||||
|
value={n(fb?.pageInsights?.reach)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-bold text-gray-900 tabular-nums">
|
</HeroBand>
|
||||||
{k.value}
|
|
||||||
|
<HelpDetails title="Cómo leer Social">
|
||||||
|
<p>
|
||||||
|
Los totales de alcance e interacciones son del periodo del
|
||||||
|
header. Seguidores y fans son stock actual del perfil.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
En los gráficos, la flecha es el cambio del primer al último día
|
||||||
|
(no es un MoM).
|
||||||
|
</p>
|
||||||
|
</HelpDetails>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||||
|
{kpiIg.map((k) => (
|
||||||
|
<StatMini key={k.label} label={k.label} value={k.value} hint={k.hint} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{kpiFb.map((k) => (
|
||||||
|
<StatMini key={k.label} label={k.label} value={k.value} hint={k.hint} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -631,16 +608,12 @@ export default function SocialDashboard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section>
|
||||||
<div>
|
<SectionHead
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
icon={<BarChart3 className="w-4 h-4" />}
|
||||||
Evolución diaria · {rangeLabel}
|
title="Evolución diaria"
|
||||||
</h3>
|
hint={`${rangeLabel} · barras = valor diario · flecha = primer→último día`}
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
/>
|
||||||
Barras = valor diario. Flecha = cambio del primer al último día
|
|
||||||
del periodo. Totales arriba a la derecha.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||||
{chartBlocks.map((c) => (
|
{chartBlocks.map((c) => (
|
||||||
<ChartCard key={c.title} {...c} />
|
<ChartCard key={c.title} {...c} />
|
||||||
@@ -648,15 +621,12 @@ export default function SocialDashboard({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section>
|
||||||
<div>
|
<SectionHead
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
icon={<Instagram className="w-4 h-4" />}
|
||||||
Publicaciones Instagram
|
title="Publicaciones Instagram"
|
||||||
</h3>
|
hint="Feed, reels y carruseles. Sin posts de Facebook."
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
/>
|
||||||
Solo IG (feed, reels y carruseles). Sin posts de Facebook.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{visiblePosts.length === 0 ? (
|
{visiblePosts.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 border border-dashed border-gray-200 rounded-xl px-4 py-10 text-center">
|
<p className="text-sm text-gray-500 border border-dashed border-gray-200 rounded-xl px-4 py-10 text-center">
|
||||||
|
|||||||
197
src/components/dash-ui.tsx
Normal file
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