feat: periodo global, métricas negocio, seguimiento notas, Drive/Social/Orgánico

Selector de fechas en header (con Personalizado) compartido en el dashboard; Métricas con Ventas/CAC/ROAS CRM, cruce canal y gráficas diarias; Seguimiento solo notas + sync Superhuman; Activos con navegación Drive; Social con métricas IG/FB; Orgánico con prioridades SEO/GEO expertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 15:45:30 +02:00
parent eb1622c88c
commit 3425364b07
15 changed files with 1067 additions and 1011 deletions

View File

@@ -29,6 +29,7 @@ import {
formatPct,
groupsForPillar,
sumOrganic,
DEFAULT_EXPERT_PRIORITIES,
} from '@/lib/organic';
import { isoYesterday } from '@/lib/ad-tracker-range';
@@ -156,11 +157,40 @@ export default function OrganicoDashboard({
setSectionNote(workspace.sectionNotes?.[sectionId] || '');
const list =
pillar === 'geo'
? workspace.priorities?.geo || []
: workspace.priorities?.seo || [];
? workspace.priorities?.geo?.length
? workspace.priorities.geo
: DEFAULT_EXPERT_PRIORITIES.geo
: workspace.priorities?.seo?.length
? workspace.priorities.seo
: DEFAULT_EXPERT_PRIORITIES.seo;
setPrioritiesText(list.join('\n'));
}, [sectionId, pillar, workspace]);
const displayPriorities = useMemo(() => {
const saved =
pillar === 'geo'
? workspace.priorities?.geo
: workspace.priorities?.seo;
if (saved?.length) return saved;
return pillar === 'geo'
? DEFAULT_EXPERT_PRIORITIES.geo
: DEFAULT_EXPERT_PRIORITIES.seo;
}, [pillar, workspace.priorities]);
const seoDaily = useMemo(() => {
return [...organic]
.filter((r) => r.day)
.sort((a, b) => String(a.day).localeCompare(String(b.day)))
.slice(-28)
.map((r) => ({
day: String(r.day).slice(0, 10),
clicks: Number(r.gsc_clicks) || 0,
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(
() => ORGANICO_SECTIONS.find((s) => s.id === sectionId)!,
[sectionId]
@@ -507,37 +537,88 @@ export default function OrganicoDashboard({
{/* Live panels by section.live */}
{section.live === 'seo-metrics' && (
<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={
totals.gscAvgPosition
? formatNum(totals.gscAvgPosition, 1)
: '—'
}
/>
<Kpi
label="Sesiones orgánicas"
value={formatNum(totals.ga4Organic)}
hint="GA4"
/>
<Kpi label="Usuarios" value={formatNum(totals.ga4Users)} />
<Kpi
label="Conversiones"
value={formatNum(totals.ga4Conversions)}
/>
<Kpi
label="Días con datos"
value={formatNum(totals.days)}
hint="Serie cargada"
/>
</div>
<>
<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={
totals.gscAvgPosition
? formatNum(totals.gscAvgPosition, 1)
: '—'
}
/>
<Kpi
label="Sesiones orgánicas"
value={formatNum(totals.ga4Organic)}
hint="GA4"
/>
<Kpi label="Usuarios" value={formatNum(totals.ga4Users)} />
<Kpi
label="Conversiones"
value={formatNum(totals.ga4Conversions)}
/>
<Kpi
label="Días con datos"
value={formatNum(totals.days)}
hint="Serie cargada"
/>
</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
</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} clics`}
>
<div
className="w-full rounded-t bg-emerald-400/80"
style={{
height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%`,
}}
/>
</div>
))}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Sesiones orgánicas GA4 · 28d
</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} sesiones`}
>
<div
className="w-full rounded-t bg-sky-400/80"
style={{
height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%`,
}}
/>
</div>
))}
</div>
</div>
</div>
)}
{totals.days === 0 && (
<EmptyHint text="GSC/GA4 conectados pero sin filas en organic_daily. Lanza sync en Agencia o revisa property/URL-prefix." />
)}
</>
)}
{section.live === 'geo-metrics' && latestGeo && (
@@ -614,7 +695,27 @@ export default function OrganicoDashboard({
)}
{section.live === 'geo-metrics' && !latestGeo && (
<EmptyHint text="Sin medición GEO aún. Registra Share of Model y motores para activar el panel." />
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 space-y-2">
<p className="text-sm font-semibold text-amber-900">
GEO sin medición aún
</p>
<p className="text-xs text-amber-800">
A diferencia de GSC/GA4, Share of Model no llega por API
automática: registra un snapshot (prompts × motores) para
activar el dashboard. Mientras tanto, trabaja las prioridades
expertas de la derecha.
</p>
{isAgency && (
<button
type="button"
onClick={() => setShowCapture(true)}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-amber-800 text-white"
>
<Plus className="w-3.5 h-3.5" />
Registrar medición GEO
</button>
)}
</div>
)}
{section.live === 'geo-competitors' && (
@@ -693,27 +794,29 @@ export default function OrganicoDashboard({
)}
Guardar prioridades
</button>
<button
type="button"
disabled={saving}
onClick={() => {
const def =
pillar === 'geo'
? DEFAULT_EXPERT_PRIORITIES.geo
: DEFAULT_EXPERT_PRIORITIES.seo;
setPrioritiesText(def.join('\n'));
}}
className="ml-2 inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50"
>
Plantilla experto
</button>
</>
) : (
<ul className="text-sm text-gray-800 space-y-1">
{(pillar === 'geo'
? workspace.priorities?.geo
: workspace.priorities?.seo
)?.length ? (
(pillar === 'geo'
? workspace.priorities!.geo
: workspace.priorities!.seo
).map((p) => (
<li key={p} className="flex gap-2">
<span className="text-brand-600">•</span>
{p}
</li>
))
) : (
<li className="text-xs text-gray-500">
La agencia definirá prioridades en esta ficha.
{displayPriorities.map((p) => (
<li key={p} className="flex gap-2">
<span className="text-brand-600">•</span>
{p}
</li>
)}
))}
</ul>
)}
</section>