From 3425364b076dd392efb3c0842cc04548c1cc8c76 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 24 Sep 2026 15:45:30 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20periodo=20global,=20m=C3=A9tricas=20neg?= =?UTF-8?q?ocio,=20seguimiento=20notas,=20Drive/Social/Org=C3=A1nico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/api/assets/drive/route.ts | 45 +- src/app/api/seguimiento/route.ts | 88 +--- src/app/api/social/route.ts | 91 +++- src/app/dashboard/layout.tsx | 33 +- src/components/AdTrackerClient.tsx | 42 +- src/components/AssetsLibrary.tsx | 145 ++++-- src/components/CrmDashboard.tsx | 56 +-- src/components/DashboardProviders.tsx | 12 + src/components/DashboardShell.tsx | 47 +- src/components/DateRangeContext.tsx | 119 +++++ src/components/MetricsDashboard.tsx | 285 ++++++------ src/components/OrganicoDashboard.tsx | 205 ++++++--- src/components/SeguimientoBoard.tsx | 626 ++++++-------------------- src/components/SocialDashboard.tsx | 262 +++++++---- src/lib/organic.ts | 22 + 15 files changed, 1067 insertions(+), 1011 deletions(-) create mode 100644 src/components/DashboardProviders.tsx create mode 100644 src/components/DateRangeContext.tsx diff --git a/src/app/api/assets/drive/route.ts b/src/app/api/assets/drive/route.ts index 1d20886..f814ef5 100644 --- a/src/app/api/assets/drive/route.ts +++ b/src/app/api/assets/drive/route.ts @@ -10,7 +10,9 @@ import { export const dynamic = 'force-dynamic'; -/** GET — list Drive files for active tenant folder (or ?download=fileId). */ +const FOLDER_MIME = 'application/vnd.google-apps.folder'; + +/** GET — list Drive files for tenant folder (browse with ?folderId=). */ export async function GET(req: NextRequest) { const supabase = await createClient(); const { @@ -28,11 +30,7 @@ export async function GET(req: NextRequest) { ); } - // Clients only see own tenant; agency via view cookie already in ctx - const agency = isAgencyRole(userRole(user)); - if (!agency) { - // resolveActiveTenant already scopes to home tenant - } + void isAgencyRole(userRole(user)); const downloadId = req.nextUrl.searchParams.get('download')?.trim(); if (downloadId) { @@ -53,30 +51,47 @@ export async function GET(req: NextRequest) { files: [], linked: false, warning: - 'Sin carpeta Drive enlazada. Conecta marketing@ en Agencia y ejecuta auto-match.', + 'Sin carpeta Drive enlazada. Conecta marketing@ en Agencia y ejecuta auto-match bajo «Clientes».', }); } + const requested = req.nextUrl.searchParams.get('folderId')?.trim(); + const folderId = requested || link.folderId; + try { - const files = await listFilesInFolder(link.folderId); - return NextResponse.json({ - linked: true, - folderId: link.folderId, - folderName: link.folderName, - files: files.map((f) => ({ + const files = await listFilesInFolder(folderId); + const mapped = files + .map((f) => ({ id: f.id, name: f.name, mimeType: f.mimeType, bytes: f.size ? Number(f.size) : null, modifiedTime: f.modifiedTime, webViewLink: f.webViewLink, + isFolder: f.mimeType === FOLDER_MIME, source: 'drive' as const, - })), + })) + .sort((a, b) => { + if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; + return a.name.localeCompare(b.name, 'es'); + }); + + return NextResponse.json({ + linked: true, + rootFolderId: link.folderId, + folderId, + folderName: link.folderName, + driveOpenUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`, + embedPossible: false, + embedHint: + 'Réplica vía API (misma carpeta Clientes del cliente). Embed iframe de Drive exige carpeta pública; usamos listado nativo + Abrir en Drive.', + files: mapped, }); } catch (e) { return NextResponse.json({ linked: true, - folderId: link.folderId, + rootFolderId: link.folderId, + folderId, folderName: link.folderName, files: [], warning: e instanceof Error ? e.message : 'Drive error', diff --git a/src/app/api/seguimiento/route.ts b/src/app/api/seguimiento/route.ts index 3051e24..60eb984 100644 --- a/src/app/api/seguimiento/route.ts +++ b/src/app/api/seguimiento/route.ts @@ -7,14 +7,6 @@ import { supabaseUrl, userRole, } from '@/lib/agency'; -import { - AdDailyRow, - KpiWeeklyRow, - buildChannelSeries, - resolveWeeklyRows, - sumDaily, - sumWeekly, -} from '@/lib/metrics'; import { randomUUID } from 'crypto'; export const dynamic = 'force-dynamic'; @@ -33,7 +25,7 @@ export type SeguimientoEntry = { source?: string; }; -/** GET seguimiento timeline + metrics series for active tenant. */ +/** GET — notas + histórico Superhuman (sin series ads). */ export async function GET() { const supabase = await createClient(); const { @@ -51,10 +43,7 @@ export async function GET() { label: ctx.label, portfolioWide: true, entries: [], - weekly: [], - daily: [], - channelSeries: { weeks: [], channels: [], points: [] }, - hint: 'Elige un cliente en el selector para ver su seguimiento y evolución.', + hint: 'Elige un cliente en el selector para ver su seguimiento.', }); } @@ -64,78 +53,33 @@ export async function GET() { } let entries: SeguimientoEntry[] = []; - let weekly: KpiWeeklyRow[] = []; - let daily: AdDailyRow[] = []; if (agency && ctx.viaView) { const tid = encodeURIComponent(tenantId); - const [e, w, d] = await Promise.all([ - restGet( - `/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=120` - ), - restGet( - `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc` - ), - restGet( - `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=5000` - ), - ]); + const e = await restGet( + `/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=200` + ); entries = e.data || []; - weekly = w.data || []; - daily = d.data || []; } else { - const [{ data: e }, { data: w }, { data: d }] = await Promise.all([ - supabase - .from('seguimiento_entries') - .select('*') - .order('occurred_at', { ascending: false, nullsFirst: false }) - .limit(120), - supabase.from('kpi_weekly').select('*').order('week', { ascending: true }), - supabase - .from('ad_daily') - .select('*') - .order('date', { ascending: false }) - .limit(5000), - ]); + const { data: e } = await supabase + .from('seguimiento_entries') + .select('*') + .order('occurred_at', { ascending: false, nullsFirst: false }) + .limit(200); entries = (e as SeguimientoEntry[]) || []; - weekly = (w as KpiWeeklyRow[]) || []; - daily = (d as AdDailyRow[]) || []; } - weekly = resolveWeeklyRows(weekly, daily); - const channelSeries = buildChannelSeries(weekly); - const totalsWeekly = sumWeekly(weekly); - const totalsDaily = sumDaily(daily); - return NextResponse.json({ label: ctx.label, portfolioWide: false, tenantId, entries, - weekly, - daily, - channelSeries, - totals: { - spend: totalsWeekly.spend || totalsDaily.spend, - leads: totalsWeekly.leads || totalsDaily.leads, - cpl: totalsWeekly.cpl || (totalsDaily.leads > 0 ? totalsDaily.spend / totalsDaily.leads : 0), - cac: totalsWeekly.cac, - revenue: totalsWeekly.revenue, - roas: totalsWeekly.roas, - impressions: totalsDaily.impressions, - clicks: totalsDaily.clicks, - }, - dailySummary: { - days: daily.length, - spend: totalsDaily.spend, - leads: totalsDaily.leads, - }, }); } /** * POST — add a manual note / comentario to seguimiento. - * { title?, body, occurred_at? } + * { title?, body, occurredAt? | occurred_at? } */ export async function POST(req: NextRequest) { const supabase = await createClient(); @@ -168,10 +112,10 @@ export async function POST(req: NextRequest) { typeof body.title === 'string' && body.title.trim() ? body.title.trim().slice(0, 200) : `Nota · ${new Date().toLocaleDateString('es-ES')}`; - const occurredAt = - typeof body.occurred_at === 'string' && body.occurred_at - ? body.occurred_at - : new Date().toISOString(); + const occurredRaw = + (typeof body.occurredAt === 'string' && body.occurredAt) || + (typeof body.occurred_at === 'string' && body.occurred_at) || + new Date().toISOString(); const row = { tenant_id: ctx.tenantId, @@ -181,7 +125,7 @@ export async function POST(req: NextRequest) { title, summary: text.slice(0, 280), body_md: text, - occurred_at: occurredAt, + occurred_at: occurredRaw, synced_at: new Date().toISOString(), metrics: {}, }; diff --git a/src/app/api/social/route.ts b/src/app/api/social/route.ts index 9abff26..ea21bfe 100644 --- a/src/app/api/social/route.ts +++ b/src/app/api/social/route.ts @@ -2,10 +2,49 @@ import { NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { isAgencyRole, userRole } from '@/lib/agency'; +import { resolveMetaAccessToken } from '@/lib/ads-discovery'; export const dynamic = 'force-dynamic'; -/** GET — social channel integrations for active tenant. */ +const META = 'https://graph.facebook.com/v21.0'; + +type Integ = { + provider: string; + status: string; + external_account_id?: string | null; + label?: string | null; + last_sync_at?: string | null; + meta?: Record | null; +}; + +async function fetchPageMetrics(token: string, pageId: string) { + const fields = + 'name,fan_count,followers_count,link,picture{url},engagement'; + const r = await fetch( + `${META}/${encodeURIComponent(pageId)}?fields=${fields}&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + if (!r.ok) return { error: (await r.text()).slice(0, 160) }; + return r.json(); +} + +async function fetchIgMetrics(token: string, igId: string) { + const r = await fetch( + `${META}/${encodeURIComponent(igId)}?fields=username,name,followers_count,follows_count,media_count,profile_picture_url&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + if (!r.ok) return { error: (await r.text()).slice(0, 160) }; + const profile = await r.json(); + const ins = await fetch( + `${META}/${encodeURIComponent(igId)}/insights?metric=reach,impressions,profile_views&period=day&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + let insights: unknown = null; + if (ins.ok) insights = await ins.json(); + return { ...profile, insights }; +} + +/** GET — social integrations + live Meta metrics when token allows. */ export async function GET() { const supabase = await createClient(); const { @@ -19,33 +58,59 @@ export async function GET() { if (!ctx.tenantId || ctx.portfolioWide) { return NextResponse.json({ integrations: [], + metrics: {}, warning: 'Selecciona un cliente en Contexto', }); } const agency = isAgencyRole(userRole(user)); const providers = 'instagram,facebook,tiktok,linkedin'; + let integrations: Integ[] = []; if (agency && ctx.viaView) { const tid = encodeURIComponent(ctx.tenantId); const r = await restGet( `/rest/v1/integrations?tenant_id=eq.${tid}&provider=in.(${providers})&select=provider,status,external_account_id,label,last_sync_at,meta,error_message` ); - return NextResponse.json({ - integrations: r.data || [], - warning: r.error || null, - }); + integrations = (r.data as Integ[]) || []; + } else { + const { data } = await supabase + .from('integrations') + .select( + 'provider,status,external_account_id,label,last_sync_at,meta,error_message' + ) + .in('provider', ['instagram', 'facebook', 'tiktok', 'linkedin']); + integrations = (data as Integ[]) || []; } - const { data, error } = await supabase - .from('integrations') - .select( - 'provider,status,external_account_id,label,last_sync_at,meta,error_message' - ) - .in('provider', ['instagram', 'facebook', 'tiktok', 'linkedin']); + const metrics: Record = {}; + const token = await resolveMetaAccessToken(); + if (token) { + for (const i of integrations) { + if (i.status !== 'connected' || !i.external_account_id) continue; + const id = i.external_account_id; + if (id.startsWith('http')) continue; + try { + if (i.provider === 'facebook') { + metrics.facebook = await fetchPageMetrics(token, id); + } + if (i.provider === 'instagram') { + metrics.instagram = await fetchIgMetrics(token, id); + } + } catch (e) { + metrics[i.provider] = { + error: e instanceof Error ? e.message : 'metrics error', + }; + } + } + } else { + metrics.warning = + 'Sin token Meta de agencia: perfiles enlazados pero sin métricas live.'; + } return NextResponse.json({ - integrations: data || [], - warning: error?.message || null, + integrations, + metrics, + label: ctx.label, }); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index d1c4ff4..b12da69 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -12,6 +12,7 @@ import { userRole, } from '@/lib/agency'; import DashboardShell from '@/components/DashboardShell'; +import DashboardProviders from '@/components/DashboardProviders'; import { DashboardModuleId, TenantSettings, @@ -80,20 +81,22 @@ export default async function DashboardLayout({ } return ( - - {children} - + + + {children} + + ); } diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx index 8e0e414..115a406 100644 --- a/src/components/AdTrackerClient.tsx +++ b/src/components/AdTrackerClient.tsx @@ -1,11 +1,7 @@ 'use client'; import React, { useEffect, useMemo, useState } from 'react'; -import { - RangePreset, - normalizePlatform, - resolveAdRange, -} from '@/lib/ad-tracker-range'; +import { normalizePlatform } from '@/lib/ad-tracker-range'; import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics'; import type { AdCreativeCard } from '@/lib/ad-creatives-meta'; import { @@ -13,7 +9,7 @@ import { CreativeZoomModal, } from '@/components/AdCreativesGallery'; import { PageHeader } from '@/components/ui'; -import DateRangePicker from '@/components/DateRangePicker'; +import { useDateRange } from '@/components/DateRangeContext'; type PlatformKpis = { spend: number; @@ -317,9 +313,7 @@ export default function AdTrackerClient({ tagline?: string; rows: AdDailyRow[]; }) { - const [preset, setPreset] = useState('last_7d'); - const [customSince, setCustomSince] = useState(''); - const [customUntil, setCustomUntil] = useState(''); + const { since, until, label: rangeLabel } = useDateRange(); const [tab, setTab] = useState('resumen'); const [chartMode, setChartMode] = useState('combined'); const [creatives, setCreatives] = useState([]); @@ -327,10 +321,11 @@ export default function AdTrackerClient({ const [creativesWarning, setCreativesWarning] = useState(null); const [zoomAd, setZoomAd] = useState(null); - const range = useMemo( - () => resolveAdRange(preset, { since: customSince, until: customUntil }), - [preset, customSince, customUntil] - ); + const range = useMemo(() => ({ since, until, label: rangeLabel }), [ + since, + until, + rangeLabel, + ]); useEffect(() => { let cancelled = false; @@ -416,26 +411,7 @@ export default function AdTrackerClient({ - - {/* Period presets */} - { - setPreset(p); - if (p !== 'custom') { - const r = resolveAdRange(p); - setCustomSince(r.since); - setCustomUntil(r.until); - } - }} - onCustomChange={(since, until) => { - setCustomSince(since); - setCustomUntil(until); - }} + description={`Gasto, clics, conversiones y creatividades. Periodo del header: ${rangeLabel}.`} /> {/* Tabs */} diff --git a/src/components/AssetsLibrary.tsx b/src/components/AssetsLibrary.tsx index 33d7f2b..4191bde 100644 --- a/src/components/AssetsLibrary.tsx +++ b/src/components/AssetsLibrary.tsx @@ -38,6 +38,7 @@ type DriveFile = { bytes?: number | null; modifiedTime?: string; webViewLink?: string; + isFolder?: boolean; }; function formatBytes(n?: number | null) { @@ -89,6 +90,10 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) const [assets, setAssets] = useState([]); const [driveFiles, setDriveFiles] = useState([]); const [driveFolder, setDriveFolder] = useState(null); + const [driveOpenUrl, setDriveOpenUrl] = useState(null); + const [drivePath, setDrivePath] = useState< + { id: string; name: string }[] + >([]); const [warning, setWarning] = useState(null); const [folder, setFolder] = useState(''); const [loading, setLoading] = useState(true); @@ -121,11 +126,15 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) } }, []); - const loadDrive = useCallback(async () => { - const res = await fetch('/api/assets/drive'); + const loadDrive = useCallback(async (folderId?: string) => { + const qs = folderId + ? `?folderId=${encodeURIComponent(folderId)}` + : ''; + const res = await fetch(`/api/assets/drive${qs}`); const data = await res.json(); setDriveFiles(data.files || []); setDriveFolder(data.folderName || null); + setDriveOpenUrl(data.driveOpenUrl || null); if (data.warning) setWarning(data.warning); else if (data.linked) setWarning(null); }, []); @@ -134,9 +143,12 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) setLoading(true); setErr(null); if (tab === 'portal') await loadPortal(); - else await loadDrive(); + else { + const current = drivePath[drivePath.length - 1]?.id; + await loadDrive(current); + } setLoading(false); - }, [tab, loadPortal, loadDrive]); + }, [tab, loadPortal, loadDrive, drivePath]); useEffect(() => { void load(); @@ -190,6 +202,13 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) } async function openDrive(file: DriveFile) { + if (file.isFolder || file.mimeType === 'application/vnd.google-apps.folder') { + setDrivePath((prev) => [...prev, { id: file.id, name: file.name }]); + setLoading(true); + await loadDrive(file.id); + setLoading(false); + return; + } if (file.webViewLink) { window.open(file.webViewLink, '_blank', 'noopener,noreferrer'); return; @@ -205,6 +224,21 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) window.open(data.url, '_blank', 'noopener,noreferrer'); } + async function goDriveRoot() { + setDrivePath([]); + setLoading(true); + await loadDrive(); + setLoading(false); + } + + async function goDriveCrumb(index: number) { + const next = drivePath.slice(0, index + 1); + setDrivePath(next); + setLoading(true); + await loadDrive(next[next.length - 1]?.id); + setLoading(false); + } + async function remove(id: string) { if (!confirm('¿Eliminar este archivo de la biblioteca?')) return; const res = await fetch(`/api/assets?id=${encodeURIComponent(id)}`, { @@ -222,7 +256,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
@@ -462,49 +496,88 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) {tab === 'drive' && ( <> +
+ + {drivePath.map((p, i) => ( + + / + + + ))} + {driveOpenUrl && ( + + Abrir en Google Drive + + )} +
+

+ Misma carpeta enlazada en Agencia (Clientes → {driveFolder || '…'}). + Embed iframe no aplica a carpetas privadas; listamos el contenido vía API. +

{loading ? (

Cargando Drive…

) : driveFiles.length === 0 ? (

- Sin archivos en Drive + Carpeta vacía o sin enlace

- Conecta marketing@ en Agencia y ejecuta auto-match por nombre. + Conecta marketing@ en Agencia y ejecuta auto-match por nombre bajo + «Clientes».

) : (
    - {driveFiles.map((f) => ( -
  • - - {iconHint(f.mimeType)} - -
    -

    - {f.name} -

    -

    - {formatBytes(f.bytes)} - {f.modifiedTime - ? ` · ${new Date(f.modifiedTime).toLocaleString('es-ES')}` - : ''} -

    -
    - -
  • - ))} + {driveFiles.map((f) => { + const folder = + f.isFolder || + f.mimeType === 'application/vnd.google-apps.folder'; + return ( +
  • + +
  • + ); + })}
)} diff --git a/src/components/CrmDashboard.tsx b/src/components/CrmDashboard.tsx index 1b36be8..84f04eb 100644 --- a/src/components/CrmDashboard.tsx +++ b/src/components/CrmDashboard.tsx @@ -1,8 +1,7 @@ 'use client'; -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; import { - CalendarRange, Users, UserCheck, Percent, @@ -14,7 +13,6 @@ import { AdDailyRow, CrmChannelStats, CrmLeadRow, - KpiRangeKey, attributionCross, buildCrmChannelStats, buildCrmFunnel, @@ -23,11 +21,9 @@ import { filterLeadsByDayRange, formatEur, formatNum, - resolveKpiDayRange, } from '@/lib/metrics'; import { PageHeader } from '@/components/ui'; - -type RangeKey = KpiRangeKey; +import { useDateRange } from '@/components/DateRangeContext'; function rateClass(rate: number): string { if (rate >= 8) return 'text-emerald-400'; @@ -47,21 +43,17 @@ export default function CrmDashboard({ crmLeads?: CrmLeadRow[]; dailyRows?: AdDailyRow[]; }) { - const [range, setRange] = useState('all'); - - const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]); + const { since, until, label: rangeHint } = useDateRange(); const rangedLeads = useMemo(() => { if (!crmLeads.length) return []; - if (!dayWindow) return crmLeads; - return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until); - }, [crmLeads, dayWindow]); + return filterLeadsByDayRange(crmLeads, since, until); + }, [crmLeads, since, until]); const rangedDaily = useMemo(() => { if (!dailyRows.length) return []; - if (!dayWindow) return dailyRows; - return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until); - }, [dailyRows, dayWindow]); + return filterDailyByDayRange(dailyRows, since, until); + }, [dailyRows, since, until]); const funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]); const statusRows = useMemo( @@ -83,10 +75,6 @@ export default function CrmDashboard({ return m; }, [attribution]); - const rangeHint = dayWindow - ? `${dayWindow.since} → ${dayWindow.until}` - : 'todo el histórico CRM'; - const paidChannels = channelRows.filter((r) => ['meta', 'google'].includes(r.channel) ); @@ -99,35 +87,7 @@ export default function CrmDashboard({ - -
- {( - [ - ['7d', '7d'], - ['30d', '30d'], - ['month', 'Mes'], - ['all', 'Todo'], - ] as [RangeKey, string][] - ).map(([k, lbl]) => ( - - ))} -
-
- } + description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`} /> {!crmLeads.length ? ( diff --git a/src/components/DashboardProviders.tsx b/src/components/DashboardProviders.tsx new file mode 100644 index 0000000..d6428ae --- /dev/null +++ b/src/components/DashboardProviders.tsx @@ -0,0 +1,12 @@ +'use client'; + +import React from 'react'; +import { DateRangeProvider } from '@/components/DateRangeContext'; + +export default function DashboardProviders({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx index 7600f36..e0cee42 100644 --- a/src/components/DashboardShell.tsx +++ b/src/components/DashboardShell.tsx @@ -24,6 +24,8 @@ import { } from 'lucide-react'; import TenantSwitcher from '@/components/TenantSwitcher'; import type { DashboardModuleId } from '@/lib/tenant-modules'; +import DateRangePicker from '@/components/DateRangePicker'; +import { useDateRange } from '@/components/DateRangeContext'; type NavItem = { href: string; @@ -171,6 +173,7 @@ export default function DashboardShell({ }) { const [open, setOpen] = useState(false); const close = () => setOpen(false); + const dateRange = useDateRange(); const visibleNav = NAV_ITEMS.filter((item) => { if (isAgency) return true; @@ -307,25 +310,35 @@ export default function DashboardShell({ ) : null}
-
- +
+
+ -
- - {isAgency ? 'Contexto:' : 'Cliente / Portal:'} - - +
+ + {isAgency ? 'Contexto:' : 'Cliente / Portal:'} + + +
+
diff --git a/src/components/DateRangeContext.tsx b/src/components/DateRangeContext.tsx new file mode 100644 index 0000000..b28aab0 --- /dev/null +++ b/src/components/DateRangeContext.tsx @@ -0,0 +1,119 @@ +'use client'; + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { + RangePreset, + resolveAdRange, + isoYesterday, +} from '@/lib/ad-tracker-range'; + +const STORAGE_KEY = '4v_global_date_range_v1'; + +type Stored = { + preset: RangePreset; + customSince: string; + customUntil: string; +}; + +type Ctx = { + preset: RangePreset; + customSince: string; + customUntil: string; + since: string; + until: string; + label: string; + setPreset: (p: RangePreset) => void; + setCustom: (since: string, until: string) => void; +}; + +const DateRangeContext = createContext(null); + +function loadStored(): Stored { + const y = isoYesterday(); + if (typeof window === 'undefined') { + return { preset: 'last_7d', customSince: y, customUntil: y }; + } + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { preset: 'last_7d', customSince: y, customUntil: y }; + const j = JSON.parse(raw) as Stored; + return { + preset: j.preset || 'last_7d', + customSince: j.customSince || y, + customUntil: j.customUntil || y, + }; + } catch { + return { preset: 'last_7d', customSince: y, customUntil: y }; + } +} + +export function DateRangeProvider({ children }: { children: React.ReactNode }) { + const [preset, setPresetState] = useState('last_7d'); + const [customSince, setCustomSince] = useState(''); + const [customUntil, setCustomUntil] = useState(''); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + const s = loadStored(); + setPresetState(s.preset); + setCustomSince(s.customSince); + setCustomUntil(s.customUntil); + setHydrated(true); + }, []); + + useEffect(() => { + if (!hydrated) return; + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ preset, customSince, customUntil }) + ); + }, [preset, customSince, customUntil, hydrated]); + + const range = useMemo( + () => + resolveAdRange(preset, { + since: customSince, + until: customUntil, + }), + [preset, customSince, customUntil] + ); + + const setPreset = useCallback((p: RangePreset) => setPresetState(p), []); + const setCustom = useCallback((since: string, until: string) => { + setCustomSince(since); + setCustomUntil(until); + setPresetState('custom'); + }, []); + + const value: Ctx = { + preset, + customSince: customSince || range.since, + customUntil: customUntil || range.until, + since: range.since, + until: range.until, + label: range.label, + setPreset, + setCustom, + }; + + return ( + + {children} + + ); +} + +export function useDateRange(): Ctx { + const ctx = useContext(DateRangeContext); + if (!ctx) { + throw new Error('useDateRange must be used within DateRangeProvider'); + } + return ctx; +} diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index 279b81c..5376bb1 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -11,8 +11,6 @@ import { TrendingUp, Users, MousePointerClick, - Eye, - CalendarRange, } from 'lucide-react'; import { AdDailyRow, @@ -30,13 +28,12 @@ import { filterDealsForRange, filterLeadsByDayRange, metricTotalsFromDaily, - resolveKpiDayRange, sumWeekly, withCohortFromDeals, KpiWeeklyRow, - KpiRangeKey, } from '@/lib/metrics'; -import { PageHeader, SegmentedControl } from '@/components/ui'; +import { PageHeader } from '@/components/ui'; +import { useDateRange } from '@/components/DateRangeContext'; type Card = { key: keyof MetricTotals; @@ -45,12 +42,10 @@ type Card = { format: (v: number) => string; icon: React.ReactNode; invert?: boolean; - /** When true and value is 0 with no deals, show em dash + empty state. */ + /** Business outcome from Clientify won deals. */ cohort?: boolean; }; -type RangeKey = KpiRangeKey; - function DeltaTriple({ delta, invert, @@ -151,65 +146,46 @@ export default function MetricsDashboard({ crmLeads?: CrmLeadRow[]; wonDeals?: DealWonRow[]; }) { - const [range, setRange] = useState('7d'); - - const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]); + const { since, until, label: rangeHint } = useDateRange(); const rangedDaily = useMemo(() => { if (!dailyRows.length) return []; - if (!dayWindow) return dailyRows; - return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until); - }, [dailyRows, dayWindow]); + return filterDailyByDayRange(dailyRows, since, until); + }, [dailyRows, since, until]); const rangedLeads = useMemo(() => { if (!crmLeads.length) return []; - if (!dayWindow) return crmLeads; - return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until); - }, [crmLeads, dayWindow]); + return filterLeadsByDayRange(crmLeads, since, until); + }, [crmLeads, since, until]); const rangedDeals = useMemo( - () => - filterDealsForRange( - wonDeals, - dayWindow?.since ?? null, - dayWindow?.until ?? null - ), - [wonDeals, dayWindow] + () => filterDealsForRange(wonDeals, since, until), + [wonDeals, since, until] ); const periodTotals = useMemo(() => { let base: MetricTotals; if (rangedDaily.length) { base = metricTotalsFromDaily(rangedDaily); - } else if (!dayWindow) { - base = sumWeekly(weeklyRows); } else { - const from = dayWindow.since; - const until = dayWindow.until; const ranged = weeklyRows.filter((r) => { if (!r.week) return false; const w = r.week.slice(0, 10); - return w <= until && w >= from; + return w <= until && w >= since; }); base = ranged.length ? sumWeekly(ranged) : month; } return withCohortFromDeals(base, rangedDeals); - }, [rangedDaily, rangedDeals, weeklyRows, dayWindow, month]); + }, [rangedDaily, rangedDeals, weeklyRows, since, until, month]); const attributionRows: AttributionChannelRow[] = useMemo(() => { if (rangedDaily.length || rangedLeads.length) { return attributionCross(rangedDaily, rangedLeads); } const map = new Map(); - const src = - range === 'all' - ? channelPoints - : channelPoints.filter((p) => { - const d = p.week; - if (!d || !dayWindow) return true; - return d >= dayWindow.since && d <= dayWindow.until; - }); - for (const p of src) { + for (const p of channelPoints) { + const d = p.week; + if (!d || d < since || d > until) continue; const cur = map.get(p.channel) || { spend: 0, leads: 0 }; cur.spend += p.spend; cur.leads += p.leads; @@ -226,92 +202,100 @@ export default function MetricsDashboard({ cplCrm: 0, })) .sort((a, b) => b.spend - a.spend); - }, [rangedDaily, rangedLeads, channelPoints, range, dayWindow]); + }, [rangedDaily, rangedLeads, channelPoints, since, until]); const crmPeriodTotal = rangedLeads.length; const hasCrm = crmLeads.length > 0; const hasCohort = rangedDeals.length > 0; - - const rangeHint = dayWindow - ? `${dayWindow.since} → ${dayWindow.until}` - : 'todo el histórico'; + const cierres = periodTotals.deals; const cards: Card[] = [ { key: 'spend', label: 'Inversión ads', - hint: 'Paid del periodo', + hint: 'Gasto Meta + Google del periodo', format: formatEur, icon: , }, { key: 'leads', - label: 'Leads', - hint: 'Captación periodo', + label: 'Leads plataforma', + hint: 'Conversiones reportadas por ads', format: formatNum, icon: , }, { key: 'cpl', - label: 'CPL', - hint: 'Coste por lead', + label: 'CPL ads', + hint: 'Inversión ÷ leads plataforma', format: (v) => `${formatNum(v)} €`, icon: , invert: true, }, { - key: 'cac', - label: 'CAC cohorte', + key: 'revenue', + label: 'Ventas (CRM)', hint: hasCohort - ? `Paid ÷ ${formatNum(periodTotals.deals)} cierres` - : 'Paid ÷ deals ganados Clientify', + ? `${formatNum(cierres)} deals ganados · importe Clientify` + : 'Deals won Clientify en el periodo', + format: formatEur, + icon: , + cohort: true, + }, + { + key: 'cac', + label: 'CAC', + hint: hasCohort + ? `Inversión ads ÷ ${formatNum(cierres)} ventas` + : 'Inversión ÷ deals ganados (no leads)', format: (v) => `${formatNum(v)} €`, icon: , invert: true, cohort: true, }, - { - key: 'revenue', - label: 'Revenue cohorte', - hint: hasCohort - ? `${formatNum(periodTotals.deals)} deals won` - : 'Suma amount deals won', - format: formatEur, - icon: , - cohort: true, - }, { key: 'roas', - label: 'ROAS cohorte', - hint: 'Revenue ÷ spend', + label: 'ROAS ventas', + hint: 'Ventas CRM ÷ inversión ads', format: (v) => `${formatNum(v)}x`, icon: , cohort: true, }, ]; + const dailyChart = useMemo(() => { + const byDay = new Map(); + for (const r of rangedDaily) { + const d = (r.date || '').slice(0, 10); + if (!d) continue; + const cur = byDay.get(d) || { spend: 0, leads: 0 }; + cur.spend += Number(r.spend) || 0; + cur.leads += Number(r.platform_leads ?? r.conversions) || 0; + byDay.set(d, cur); + } + return [...byDay.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([date, v]) => ({ date, ...v })); + }, [rangedDaily]); + + const maxSpend = Math.max(1, ...dailyChart.map((d) => d.spend)); + + const channelInsight = useMemo(() => { + if (!attributionRows.length) return null; + const paid = attributionRows.filter((r) => r.spend > 0); + const top = paid[0] || attributionRows[0]; + const worstGap = [...attributionRows].sort( + (a, b) => Math.abs(b.gap) - Math.abs(a.gap) + )[0]; + return { top, worstGap }; + }, [attributionRows]); + return (
- - -
- } + description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (selector global del header).`} /> {sparse && ( @@ -323,10 +307,8 @@ export default function MetricsDashboard({ {!hasCohort && (

- CAC / Revenue / ROAS a cero: no hay deals ganados con fecha de cierre - en este periodo. Se calculan con{' '} - deals Clientify (stage=won), no - solo con leads de ads. + Ventas / CAC / ROAS vacíos: no hay deals ganados (Clientify) con + fecha de cierre en este periodo. El gasto de ads no cuenta como venta.

)} @@ -394,29 +376,46 @@ export default function MetricsDashboard({ })}
-
-
+
+

Cruce por canal · ads vs CRM

- Plataforma = leads reportados por Meta/Google · CRM = atribución - Clientify (origen/UTM) - {!hasCrm && portfolioWide - ? ' · elige un cliente en Contexto para ver CRM' - : ''} + Gasto y leads de plataforma vs leads CRM (como en{' '} + + combinación de datos + + ): el coste vive en ads; el resultado de negocio en CRM/GA.

Gap = CRM − plat

+ {channelInsight && ( +

+ Resumen: más inversión en{' '} + + {channelInsight.top.channel} + {' '} + ({formatEur(channelInsight.top.spend)}). Mayor desfase ads↔CRM en{' '} + + {channelInsight.worstGap.channel} + {' '} + (gap {channelInsight.worstGap.gap > 0 ? '+' : ''} + {formatNum(channelInsight.worstGap.gap)}). +

+ )} {attributionRows.length === 0 ? (

- Sin series de canal en el periodo. Los canales Meta/Google aparecen - cuando hay filas en ad_daily para este cliente (elige uno en - Contexto). + Sin series de canal en el periodo.

) : (
@@ -486,49 +485,63 @@ export default function MetricsDashboard({
)} -
-
-
-
- - Ads · ventana diaria - - +
+
+

+ Ads · inversión / día +

+ {dailyChart.length === 0 ? ( +

Sin filas diarias.

+ ) : ( +
+ {dailyChart.map((d) => ( +
+
+
+ ))} +
+ )} +
+
+

+ Ads · leads plat. / día +

+ {dailyChart.length === 0 ? ( +

Sin filas diarias.

+ ) : ( +
+ {dailyChart.map((d) => ( +
+
x.leads))) * 100)}%`, + }} + /> +
+ ))} +
+ )}
-

- {formatEur(daily.spend)} -

-

- {formatNum(daily.leads)} leads · {formatNum(daily.impressions)} impr. - · {formatNum(daily.clicks)} clicks -

-

- Semana actual: {formatEur(week.spend)} · {formatNum(week.leads)}{' '} - leads -

-
-

- Cómo leer -

-
    -
  • - CAC / Revenue / ROAS usan deals ganados de Clientify ( - won_at en el periodo), no - leads de Meta/Google. -
  • -
  • - Gap cercano a 0 en Meta/Google = buena atribución. Canales sin - spend son orgánicos/CRM. -
  • -
  • - CPL CRM = inversión paid ÷ leads CRM del canal (más realista - cuando hay desfase con la plataforma). -
  • -
-
-
+

+ Barras compactas por día · periodo del header · detalle en Ads +

+
); } diff --git a/src/components/OrganicoDashboard.tsx b/src/components/OrganicoDashboard.tsx index 4f8c38e..d236cf1 100644 --- a/src/components/OrganicoDashboard.tsx +++ b/src/components/OrganicoDashboard.tsx @@ -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' && ( -
- - - - - - - - -
+ <> +
+ + + + + + + + +
+ {seoDaily.length > 0 && ( +
+
+

+ Clics GSC · 28d +

+
+ {seoDaily.map((d) => ( +
+
+
+ ))} +
+
+
+

+ Sesiones orgánicas GA4 · 28d +

+
+ {seoDaily.map((d) => ( +
+
+
+ ))} +
+
+
+ )} + {totals.days === 0 && ( + + )} + )} {section.live === 'geo-metrics' && latestGeo && ( @@ -614,7 +695,27 @@ export default function OrganicoDashboard({ )} {section.live === 'geo-metrics' && !latestGeo && ( - +
+

+ GEO sin medición aún +

+

+ 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. +

+ {isAgency && ( + + )} +
)} {section.live === 'geo-competitors' && ( @@ -693,27 +794,29 @@ export default function OrganicoDashboard({ )} Guardar prioridades + ) : (
    - {(pillar === 'geo' - ? workspace.priorities?.geo - : workspace.priorities?.seo - )?.length ? ( - (pillar === 'geo' - ? workspace.priorities!.geo - : workspace.priorities!.seo - ).map((p) => ( -
  • - • - {p} -
  • - )) - ) : ( -
  • - La agencia definirá prioridades en esta ficha. + {displayPriorities.map((p) => ( +
  • + • + {p}
  • - )} + ))}
)} diff --git a/src/components/SeguimientoBoard.tsx b/src/components/SeguimientoBoard.tsx index 4428da4..420a493 100644 --- a/src/components/SeguimientoBoard.tsx +++ b/src/components/SeguimientoBoard.tsx @@ -1,35 +1,20 @@ 'use client'; +/** + * Seguimiento = notas + histórico (Superhuman + manual). + * Ads/KPIs viven en Métricas. + */ + import React, { useEffect, useMemo, useState } from 'react'; import { ExternalLink, - LineChart as LineIcon, Loader2, MessageSquareText, Radio, Send, - CalendarRange, - DollarSign, - Users, - MousePointerClick, - Target, - TrendingUp, } from 'lucide-react'; -import { - AdDailyRow, - ChannelWeekPoint, - KpiWeeklyRow, - formatEur, - formatNum, - sumDaily, - sumWeekly, -} from '@/lib/metrics'; import { PageHeader } from '@/components/ui'; -import DateRangePicker from '@/components/DateRangePicker'; -import { - RangePreset, - resolveAdRange, -} from '@/lib/ad-tracker-range'; +import { useDateRange } from '@/components/DateRangeContext'; type Entry = { id: string; @@ -38,12 +23,6 @@ type Entry = { summary?: string | null; body_md?: string | null; browser_link?: string | null; - metrics?: { - ventas?: number; - cpl?: number; - cac?: number; - periodo?: string; - }; occurred_at?: string | null; source?: string; }; @@ -51,30 +30,11 @@ type Entry = { type Payload = { label: string; portfolioWide?: boolean; + tenantId?: string; hint?: string; entries: Entry[]; - weekly?: KpiWeeklyRow[]; - daily?: AdDailyRow[]; - channelSeries: { - weeks: string[]; - channels: string[]; - points: ChannelWeekPoint[]; - }; - dailySummary?: { days: number; spend: number; leads: number }; }; -type MetricKey = 'leads' | 'spend' | 'cpl'; - -const CHANNEL_COLORS = [ - '#E58A3C', - '#38BDF8', - '#34D399', - '#A78BFA', - '#F472B6', - '#FBBF24', - '#94A3B8', -]; - function typeLabel(t: string) { if (t === 'meeting') return 'Reunión'; if (t === 'kpis') return 'KPIs'; @@ -83,204 +43,41 @@ function typeLabel(t: string) { } function typeTone(t: string) { - if (t === 'meeting') return 'bg-amber-500/15 text-amber-300 border-amber-500/30'; - if (t === 'kpis') return 'bg-sky-500/15 text-sky-300 border-sky-500/30'; - if (t === 'topic') return 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30'; - return 'bg-violet-500/15 text-violet-300 border-violet-500/30'; + if (t === 'meeting') return 'bg-amber-50 text-amber-800 border-amber-200'; + if (t === 'kpis') return 'bg-sky-50 text-sky-800 border-sky-200'; + if (t === 'topic') return 'bg-emerald-50 text-emerald-800 border-emerald-200'; + return 'bg-violet-50 text-violet-800 border-violet-200'; } function inRange(isoDate: string | undefined | null, since: string, until: string) { - if (!isoDate) return false; + if (!isoDate) return true; // keep undated notes visible const d = isoDate.slice(0, 10); return d >= since && d <= until; } -function ChannelEvolutionChart({ - weeks, - channels, - points, - metric, - activeChannels, -}: { - weeks: string[]; - channels: string[]; - points: ChannelWeekPoint[]; - metric: MetricKey; - activeChannels: Set; -}) { - const [hover, setHover] = useState<{ - week: string; - channel: string; - value: number; - x: number; - y: number; - } | null>(null); - - const width = 720; - const height = 260; - const pad = { t: 16, r: 16, b: 36, l: 44 }; - const innerW = width - pad.l - pad.r; - const innerH = height - pad.t - pad.b; - - const series = useMemo(() => { - return channels - .filter((c) => activeChannels.has(c)) - .map((channel, i) => { - const vals = weeks.map((week) => { - const p = points.find((x) => x.week === week && x.channel === channel); - return p ? Number(p[metric] || 0) : 0; - }); - return { - channel, - color: CHANNEL_COLORS[i % CHANNEL_COLORS.length], - vals, - }; - }); - }, [channels, activeChannels, weeks, points, metric]); - - const maxY = Math.max(1, ...series.flatMap((s) => s.vals)); - const xAt = (i: number) => - pad.l + (weeks.length <= 1 ? innerW / 2 : (i / (weeks.length - 1)) * innerW); - const yAt = (v: number) => pad.t + innerH - (v / maxY) * innerH; - - if (!weeks.length || !series.length) { - return ( -
- Sin serie en el rango seleccionado. -
- ); - } - - return ( -
- - {[0, 0.25, 0.5, 0.75, 1].map((t) => { - const y = pad.t + innerH * (1 - t); - const val = maxY * t; - return ( - - - - {formatNum(val)} - - - ); - })} - {series.map((s) => { - const d = s.vals - .map((v, i) => `${i === 0 ? 'M' : 'L'} ${xAt(i)} ${yAt(v)}`) - .join(' '); - return ( - - - {s.vals.map((v, i) => ( - - setHover({ - week: weeks[i], - channel: s.channel, - value: v, - x: xAt(i), - y: yAt(v), - }) - } - onMouseLeave={() => setHover(null)} - /> - ))} - - ); - })} - {weeks.map((w, i) => ( - - {w.slice(5)} - - ))} - - {hover && ( -
-

{hover.channel}

-

{hover.week}

-

- {metric === 'spend' - ? formatEur(hover.value) - : metric === 'cpl' - ? `${formatNum(hover.value)} € CPL` - : `${formatNum(hover.value)} leads`} -

-
- )} -
- ); -} - export default function SeguimientoBoard() { + const { since, until, label: rangeLabel } = useDateRange(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [metric, setMetric] = useState('leads'); - const [activeChannels, setActiveChannels] = useState>(new Set()); - const [openId, setOpenId] = useState(null); + const [err, setErr] = useState(null); const [query, setQuery] = useState(''); - const [preset, setPreset] = useState('last_30d'); - const [customSince, setCustomSince] = useState(''); - const [customUntil, setCustomUntil] = useState(''); + const [openId, setOpenId] = useState(null); const [noteTitle, setNoteTitle] = useState(''); const [noteBody, setNoteBody] = useState(''); const [noteBusy, setNoteBusy] = useState(false); const [noteMsg, setNoteMsg] = useState(null); + const [syncBusy, setSyncBusy] = useState(false); async function load() { setLoading(true); - setError(null); + setErr(null); try { - const r = await fetch('/api/seguimiento', { cache: 'no-store' }); - const j = await r.json(); - if (!r.ok) throw new Error(j.error || 'Error cargando seguimiento'); - setData(j); - setActiveChannels(new Set(j.channelSeries?.channels || [])); + const res = await fetch('/api/seguimiento', { cache: 'no-store' }); + const json = (await res.json()) as Payload & { error?: string }; + if (!res.ok) throw new Error(json.error || 'Error'); + setData(json); } catch (e) { - setError(e instanceof Error ? e.message : 'Error'); + setErr(e instanceof Error ? e.message : 'Error'); } finally { setLoading(false); } @@ -290,110 +87,94 @@ export default function SeguimientoBoard() { void load(); }, []); - const range = useMemo( - () => resolveAdRange(preset, { since: customSince, until: customUntil }), - [preset, customSince, customUntil] - ); - - const rangedWeekly = useMemo(() => { - const rows = data?.weekly || []; - return rows.filter((r) => inRange(r.week, range.since, range.until)); - }, [data?.weekly, range.since, range.until]); - - const rangedDaily = useMemo(() => { - const rows = data?.daily || []; - return rows.filter((r) => inRange(r.date, range.since, range.until)); - }, [data?.daily, range.since, range.until]); - - const periodTotals = useMemo(() => { - const w = sumWeekly(rangedWeekly); - const d = sumDaily(rangedDaily); - const spend = w.spend || d.spend; - const leads = w.leads || d.leads; - return { - spend, - leads, - cpl: leads > 0 ? spend / leads : w.cpl, - cac: w.cac, - revenue: w.revenue, - roas: w.roas, - impressions: d.impressions, - clicks: d.clicks, - }; - }, [rangedWeekly, rangedDaily]); - - const rangedChannel = useMemo(() => { - const points = (data?.channelSeries.points || []).filter((p) => - inRange(p.week, range.since, range.until) - ); - const weeks = [...new Set(points.map((p) => p.week))].sort(); - const channels = data?.channelSeries.channels || []; - return { weeks, channels, points }; - }, [data?.channelSeries, range.since, range.until]); - const filteredEntries = useMemo(() => { - const list = (data?.entries || []).filter((e) => { - if (!e.occurred_at) return true; - return inRange(e.occurred_at, range.since, range.until); - }); + const entries = data?.entries || []; const q = query.trim().toLowerCase(); - if (!q) return list; - return list.filter( - (e) => - e.title.toLowerCase().includes(q) || - (e.summary || '').toLowerCase().includes(q) || - (e.body_md || '').toLowerCase().includes(q) - ); - }, [data, query, range.since, range.until]); - - function toggleChannel(ch: string) { - setActiveChannels((prev) => { - const next = new Set(prev); - if (next.has(ch)) next.delete(ch); - else next.add(ch); - return next; + return entries.filter((e) => { + if (!inRange(e.occurred_at, since, until)) return false; + if (!q) return true; + const blob = `${e.title} ${e.summary || ''} ${e.body_md || ''}`.toLowerCase(); + return blob.includes(q); }); - } + }, [data, query, since, until]); async function saveNote() { if (!noteBody.trim()) return; setNoteBusy(true); setNoteMsg(null); try { - const r = await fetch('/api/seguimiento', { + const res = await fetch('/api/seguimiento', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - title: noteTitle.trim() || undefined, + title: noteTitle.trim() || 'Nota de seguimiento', body: noteBody.trim(), + occurredAt: new Date().toISOString(), }), }); - const j = await r.json(); - if (!r.ok) { - setNoteMsg(j.error || 'No se pudo guardar'); - return; - } + const json = await res.json(); + if (!res.ok) throw new Error(json.error || 'No se pudo guardar'); setNoteTitle(''); setNoteBody(''); - setNoteMsg('Nota añadida al seguimiento'); + setNoteMsg('Nota guardada'); await load(); + } catch (e) { + setNoteMsg(e instanceof Error ? e.message : 'Error'); } finally { setNoteBusy(false); } } - if (loading) { + async function syncSuperhuman() { + if (!data?.tenantId) return; + setSyncBusy(true); + setNoteMsg(null); + try { + const res = await fetch('/api/admin/superhuman/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tenantId: data.tenantId, + exportBodies: true, + maxEntries: 80, + }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json.error || 'Sync falló'); + const first = Array.isArray(json.results) ? json.results[0] : null; + const n = + typeof first?.upserted === 'number' + ? first.upserted + : typeof json.upserted === 'number' + ? json.upserted + : null; + setNoteMsg( + n != null + ? `Superhuman: ${n} entradas sincronizadas` + : first?.status === 'unmatched' + ? 'Sin doc Superhuman emparejado por nombre' + : 'Superhuman sincronizado' + ); + await load(); + } catch (e) { + setNoteMsg(e instanceof Error ? e.message : 'Error sync'); + } finally { + setSyncBusy(false); + } + } + + if (loading && !data) { return ( -
+

Cargando seguimiento… -

+

); } - if (error) { + if (err) { return ( -

- {error} +

+ {err}

); } @@ -402,210 +183,76 @@ export default function SeguimientoBoard() { if (data.portfolioWide) { return ( -
- -

Seguimiento

-

- {data.hint || - 'Selecciona un cliente en el encabezado para ver la evolución y las notas.'} -

-
+

+ {data.hint || + 'Elige un cliente en Contexto para ver su seguimiento.'} +

); } - const cards = [ - { - label: 'Inversión', - value: formatEur(periodTotals.spend), - icon: , - }, - { - label: 'Leads', - value: formatNum(periodTotals.leads), - icon: , - }, - { - label: 'CPL', - value: periodTotals.cpl ? `${formatNum(periodTotals.cpl)} €` : '—', - icon: , - }, - { - label: 'CAC', - value: periodTotals.cac ? `${formatNum(periodTotals.cac)} €` : '—', - icon: , - }, - { - label: 'Revenue', - value: formatEur(periodTotals.revenue), - icon: , - }, - { - label: 'ROAS', - value: periodTotals.roas ? `${formatNum(periodTotals.roas)}x` : '—', - icon: , - }, - ]; - return ( -
+
- {/* Date selector */} -
-
- - Periodo -
- { - setPreset(p); - if (p !== 'custom') { - const r = resolveAdRange(p); - setCustomSince(r.since); - setCustomUntil(r.until); - } - }} - onCustomChange={(since, until) => { - setCustomSince(since); - setCustomUntil(until); - }} - /> -
- - {/* KPI cards (Métricas-like) */} -
- {cards.map((c) => ( -
-
-

- {c.label} -

- {c.icon} -
-

{c.value}

-
- ))} -
- - {/* Channel evolution */} -
-
-
- - Evolución por canal -
-
- {( - [ - ['leads', 'Leads'], - ['spend', 'Inversión'], - ['cpl', 'CPL'], - ] as [MetricKey, string][] - ).map(([k, label]) => ( - - ))} -
-
- -
- {rangedChannel.channels.map((ch, i) => { - const on = activeChannels.has(ch); - const color = CHANNEL_COLORS[i % CHANNEL_COLORS.length]; - return ( - - ); - })} -
- - -
- - {/* Notes box */}
- - Comentarios y notas + + Nueva nota

- Añade observaciones del periodo (reunión, hipótesis, bloqueos, - siguientes pasos…). Quedan en el timeline de seguimiento. + Se guarda con fecha. El histórico también incluye entradas + sincronizadas desde Superhuman Docs (docs de seguimiento).

setNoteTitle(e.target.value)} placeholder="Título (opcional)" - className="w-full rounded-lg bg-black/30 border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-600" + className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900" />