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

@@ -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',

View File

@@ -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<SeguimientoEntry[]>(
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=120`
),
restGet<KpiWeeklyRow[]>(
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc`
),
restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=5000`
),
]);
const e = await restGet<SeguimientoEntry[]>(
`/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: {},
};

View File

@@ -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<string, unknown> | 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<string, unknown> = {};
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,
});
}

View File

@@ -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 (
<DashboardShell
email={email}
company={company}
role={role}
isAgency={isAgency}
viewName={viewName}
enabledModules={enabledModules}
clientLogoUrl={
isAgency && viewId ? clientLogoUrl : !isAgency ? clientLogoUrl : null
}
brandLabel={brandLabel}
logoutAction={logoutAction}
>
{children}
</DashboardShell>
<DashboardProviders>
<DashboardShell
email={email}
company={company}
role={role}
isAgency={isAgency}
viewName={viewName}
enabledModules={enabledModules}
clientLogoUrl={
isAgency && viewId ? clientLogoUrl : !isAgency ? clientLogoUrl : null
}
brandLabel={brandLabel}
logoutAction={logoutAction}
>
{children}
</DashboardShell>
</DashboardProviders>
);
}

View File

@@ -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<RangePreset>('last_7d');
const [customSince, setCustomSince] = useState('');
const [customUntil, setCustomUntil] = useState('');
const { since, until, label: rangeLabel } = useDateRange();
const [tab, setTab] = useState<Tab>('resumen');
const [chartMode, setChartMode] = useState<ChartMode>('combined');
const [creatives, setCreatives] = useState<AdCreativeCard[]>([]);
@@ -327,10 +321,11 @@ export default function AdTrackerClient({
const [creativesWarning, setCreativesWarning] = useState<string | null>(null);
const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(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({
<PageHeader
eyebrow={`Ads · ${label}`}
title={tagline || 'Meta + Google Ads'}
description={`Gasto, clics, conversiones y preview de creatividades. Ventana: ${range.since} → ${range.until}.`}
/>
{/* Period presets */}
<DateRangePicker
preset={preset}
customSince={customSince || range.since}
customUntil={customUntil || range.until}
onPreset={(p) => {
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 */}

View File

@@ -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<Asset[]>([]);
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
const [driveFolder, setDriveFolder] = useState<string | null>(null);
const [driveOpenUrl, setDriveOpenUrl] = useState<string | null>(null);
const [drivePath, setDrivePath] = useState<
{ id: string; name: string }[]
>([]);
const [warning, setWarning] = useState<string | null>(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 })
<PageHeader
eyebrow="Contenido"
title="Activos"
description={`Contenido de Drive de ${tenantLabel}. Pestaña Drive por defecto; Portal son archivos subidos al Centro de Mando.`}
description={`Réplica de la carpeta Drive del cliente (${tenantLabel}) bajo Clientes. Navega carpetas como en Drive; «Abrir en Drive» para la UI nativa.`}
/>
<div className="flex gap-2">
@@ -462,49 +496,88 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
{tab === 'drive' && (
<>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-600">
<button
type="button"
onClick={() => void goDriveRoot()}
className="font-semibold text-brand-600 hover:underline"
>
{driveFolder || 'Carpeta cliente'}
</button>
{drivePath.map((p, i) => (
<span key={p.id} className="inline-flex items-center gap-2">
<span className="text-gray-400">/</span>
<button
type="button"
onClick={() => void goDriveCrumb(i)}
className="hover:underline text-gray-800"
>
{p.name}
</button>
</span>
))}
{driveOpenUrl && (
<a
href={driveOpenUrl}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1 text-brand-600 hover:underline"
>
Abrir en Google Drive <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
<p className="text-[11px] text-gray-500">
Misma carpeta enlazada en Agencia (Clientes → {driveFolder || '…'}).
Embed iframe no aplica a carpetas privadas; listamos el contenido vía API.
</p>
{loading ? (
<p className="text-sm text-gray-500">Cargando Drive…</p>
) : driveFiles.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center text-gray-400">
<HardDrive className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-base font-medium text-gray-600">
Sin archivos en Drive
Carpeta vacía o sin enlace
</p>
<p className="text-xs text-gray-500 mt-2">
Conecta marketing@ en Agencia y ejecuta auto-match por nombre.
Conecta marketing@ en Agencia y ejecuta auto-match por nombre bajo
«Clientes».
</p>
</div>
) : (
<ul className="space-y-2">
{driveFiles.map((f) => (
<li
key={f.id}
className="flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3"
>
<span className="text-[10px] font-bold tracking-wider text-gray-500 w-10">
{iconHint(f.mimeType)}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900 font-medium truncate">
{f.name}
</p>
<p className="text-[11px] text-gray-500">
{formatBytes(f.bytes)}
{f.modifiedTime
? ` · ${new Date(f.modifiedTime).toLocaleString('es-ES')}`
: ''}
</p>
</div>
<button
type="button"
onClick={() => void openDrive(f)}
className="p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-50"
title="Abrir en Drive"
>
<ExternalLink className="w-4 h-4" />
</button>
</li>
))}
{driveFiles.map((f) => {
const folder =
f.isFolder ||
f.mimeType === 'application/vnd.google-apps.folder';
return (
<li key={f.id}>
<button
type="button"
onClick={() => void openDrive(f)}
className="w-full flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3 text-left hover:border-brand-300 hover:bg-brand-50/40"
>
<span className="text-[10px] font-bold tracking-wider text-gray-500 w-10">
{folder ? 'DIR' : iconHint(f.mimeType)}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900 font-medium truncate">
{f.name}
</p>
<p className="text-[11px] text-gray-500">
{folder
? 'Carpeta'
: formatBytes(f.bytes)}
{f.modifiedTime
? ` · ${new Date(f.modifiedTime).toLocaleString('es-ES')}`
: ''}
</p>
</div>
<ExternalLink className="w-4 h-4 text-gray-400 shrink-0" />
</button>
</li>
);
})}
</ul>
)}
</>

View File

@@ -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<RangeKey>('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({
<PageHeader
eyebrow="Rendimiento"
title="CRM"
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · ${rangeHint}.`}
actions={
<div className="flex items-center gap-2">
<CalendarRange className="w-4 h-4 text-gray-500" />
<div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 bg-gray-50">
{(
[
['7d', '7d'],
['30d', '30d'],
['month', 'Mes'],
['all', 'Todo'],
] as [RangeKey, string][]
).map(([k, lbl]) => (
<button
key={k}
type="button"
onClick={() => setRange(k)}
className={`px-2.5 py-1 text-xs font-semibold rounded-md ${
range === k
? 'bg-brand-500 text-white shadow-sm'
: 'text-gray-600 hover:text-gray-900 hover:bg-white'
}`}
>
{lbl}
</button>
))}
</div>
</div>
}
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
/>
{!crmLeads.length ? (

View File

@@ -0,0 +1,12 @@
'use client';
import React from 'react';
import { DateRangeProvider } from '@/components/DateRangeContext';
export default function DashboardProviders({
children,
}: {
children: React.ReactNode;
}) {
return <DateRangeProvider>{children}</DateRangeProvider>;
}

View File

@@ -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}
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 h-14 border-b border-gray-200 bg-white/90 backdrop-blur-md px-4 md:px-8 flex items-center gap-3">
<button
type="button"
className="md:hidden rounded-lg p-2 text-gray-600 hover:bg-gray-100"
onClick={() => setOpen(true)}
aria-label="Abrir menú"
>
<Menu className="w-5 h-5" />
</button>
<header className="sticky top-0 z-30 border-b border-gray-200 bg-white/95 backdrop-blur-md px-4 md:px-8 py-2.5 space-y-2">
<div className="flex items-center gap-3 min-h-10">
<button
type="button"
className="md:hidden rounded-lg p-2 text-gray-600 hover:bg-gray-100"
onClick={() => setOpen(true)}
aria-label="Abrir menú"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex min-w-0 items-center gap-2 text-sm text-gray-500">
<span className="hidden sm:inline shrink-0">
{isAgency ? 'Contexto:' : 'Cliente / Portal:'}
</span>
<TenantSwitcher
isAgency={isAgency}
initialName={viewName || company}
/>
<div className="flex min-w-0 items-center gap-2 text-sm text-gray-500">
<span className="hidden sm:inline shrink-0">
{isAgency ? 'Contexto:' : 'Cliente / Portal:'}
</span>
<TenantSwitcher
isAgency={isAgency}
initialName={viewName || company}
/>
</div>
</div>
<DateRangePicker
compact
preset={dateRange.preset}
customSince={dateRange.customSince}
customUntil={dateRange.customUntil}
onPreset={dateRange.setPreset}
onCustomChange={dateRange.setCustom}
/>
</header>
<main className="flex-1 overflow-y-auto">

View File

@@ -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<Ctx | null>(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<RangePreset>('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 (
<DateRangeContext.Provider value={value}>
{children}
</DateRangeContext.Provider>
);
}
export function useDateRange(): Ctx {
const ctx = useContext(DateRangeContext);
if (!ctx) {
throw new Error('useDateRange must be used within DateRangeProvider');
}
return ctx;
}

View File

@@ -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<RangeKey>('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<string, { spend: number; leads: number }>();
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: <DollarSign className="w-4 h-4 text-brand-600" />,
},
{
key: 'leads',
label: 'Leads',
hint: 'Captación periodo',
label: 'Leads plataforma',
hint: 'Conversiones reportadas por ads',
format: formatNum,
icon: <Users className="w-4 h-4 text-blue-600" />,
},
{
key: 'cpl',
label: 'CPL',
hint: 'Coste por lead',
label: 'CPL ads',
hint: 'Inversión ÷ leads plataforma',
format: (v) => `${formatNum(v)} €`,
icon: <MousePointerClick className="w-4 h-4 text-violet-600" />,
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: <TrendingUp className="w-4 h-4 text-emerald-600" />,
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: <Target className="w-4 h-4 text-amber-600" />,
invert: true,
cohort: true,
},
{
key: 'revenue',
label: 'Revenue cohorte',
hint: hasCohort
? `${formatNum(periodTotals.deals)} deals won`
: 'Suma amount deals won',
format: formatEur,
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
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: <TrendingUp className="w-4 h-4 text-emerald-700" />,
cohort: true,
},
];
const dailyChart = useMemo(() => {
const byDay = new Map<string, { spend: number; leads: number }>();
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 (
<div className="space-y-8">
<PageHeader
eyebrow="Rendimiento"
title="Métricas"
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (mismos días calendario que Ad Tracker).`}
actions={
<div className="flex items-center gap-2">
<CalendarRange className="w-4 h-4 text-gray-600" />
<SegmentedControl
value={range}
onChange={setRange}
size="sm"
options={[
{ id: '7d', label: '7d' },
{ id: '30d', label: '30d' },
{ id: 'month', label: 'Mes' },
{ id: 'all', label: 'Todo' },
]}
/>
</div>
}
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (selector global del header).`}
/>
{sparse && (
@@ -323,10 +307,8 @@ export default function MetricsDashboard({
{!hasCohort && (
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2">
CAC / Revenue / ROAS a cero: no hay deals ganados con fecha de cierre
en este periodo. Se calculan con{' '}
<span className="font-semibold">deals Clientify (stage=won)</span>, 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.
</p>
)}
@@ -394,29 +376,46 @@ export default function MetricsDashboard({
})}
</div>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
<div className="flex items-center justify-between gap-2">
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-gray-900">
Cruce por canal · ads vs CRM
</h3>
<p className="text-xs text-gray-600 mt-0.5">
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{' '}
<a
href="https://mide-analytics-reports.vercel.app/como-combinamos-datos.html"
target="_blank"
rel="noreferrer"
className="text-brand-600 underline"
>
combinación de datos
</a>
): el coste vive en ads; el resultado de negocio en CRM/GA.
</p>
</div>
<p className="text-xs text-gray-600 shrink-0 font-medium">
Gap = CRM − plat
</p>
</div>
{channelInsight && (
<p className="text-xs text-gray-800 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
<strong>Resumen:</strong> más inversión en{' '}
<span className="capitalize font-semibold">
{channelInsight.top.channel}
</span>{' '}
({formatEur(channelInsight.top.spend)}). Mayor desfase ads↔CRM en{' '}
<span className="capitalize font-semibold">
{channelInsight.worstGap.channel}
</span>{' '}
(gap {channelInsight.worstGap.gap > 0 ? '+' : ''}
{formatNum(channelInsight.worstGap.gap)}).
</p>
)}
{attributionRows.length === 0 ? (
<p className="text-sm text-gray-600 py-6 text-center">
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.
</p>
) : (
<div className="overflow-x-auto">
@@ -486,49 +485,63 @@ export default function MetricsDashboard({
</table>
</div>
)}
</section>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
<div className="flex items-center justify-between text-gray-700 mb-2">
<span className="text-xs font-semibold uppercase tracking-wider">
Ads · ventana diaria
</span>
<Eye className="w-4 h-4 text-sky-600" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-1">
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-600 mb-2">
Ads · inversión / día
</h4>
{dailyChart.length === 0 ? (
<p className="text-xs text-gray-500">Sin filas diarias.</p>
) : (
<div className="flex items-end gap-0.5 h-20 w-full">
{dailyChart.map((d) => (
<div
key={`s-${d.date}`}
className="flex-1 min-w-0"
title={`${d.date}: ${formatEur(d.spend)}`}
>
<div
className="w-full rounded-t bg-brand-400/80 hover:bg-brand-500"
style={{
height: `${Math.max(4, (d.spend / maxSpend) * 100)}%`,
}}
/>
</div>
))}
</div>
)}
</div>
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-600 mb-2">
Ads · leads plat. / día
</h4>
{dailyChart.length === 0 ? (
<p className="text-xs text-gray-500">Sin filas diarias.</p>
) : (
<div className="flex items-end gap-0.5 h-20 w-full">
{dailyChart.map((d) => (
<div
key={`l-${d.date}`}
className="flex-1 min-w-0"
title={`${d.date}: ${formatNum(d.leads)} leads`}
>
<div
className="w-full rounded-t bg-sky-400/80 hover:bg-sky-500"
style={{
height: `${Math.max(4, (d.leads / Math.max(1, ...dailyChart.map((x) => x.leads))) * 100)}%`,
}}
/>
</div>
))}
</div>
)}
</div>
<p className="text-lg font-bold text-gray-900 tabular-nums">
{formatEur(daily.spend)}
</p>
<p className="text-xs text-gray-600 mt-1">
{formatNum(daily.leads)} leads · {formatNum(daily.impressions)} impr.
· {formatNum(daily.clicks)} clicks
</p>
<p className="text-xs text-gray-700 mt-2 font-medium">
Semana actual: {formatEur(week.spend)} · {formatNum(week.leads)}{' '}
leads
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5 md:col-span-2 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-700 mb-2">
Cómo leer
</p>
<ul className="text-sm text-gray-700 space-y-1.5 list-disc list-inside">
<li>
CAC / Revenue / ROAS usan deals ganados de Clientify (
<span className="font-semibold">won_at</span> en el periodo), no
leads de Meta/Google.
</li>
<li>
Gap cercano a 0 en Meta/Google = buena atribución. Canales sin
spend son orgánicos/CRM.
</li>
<li>
CPL CRM = inversión paid ÷ leads CRM del canal (más realista
cuando hay desfase con la plataforma).
</li>
</ul>
</div>
</div>
<p className="text-[10px] text-gray-500">
Barras compactas por día · periodo del header · detalle en Ads
</p>
</section>
</div>
);
}

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>

View File

@@ -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<string>;
}) {
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 (
<div className="h-[260px] flex items-center justify-center text-sm text-gray-500 border border-dashed border-gray-200 rounded-xl">
Sin serie en el rango seleccionado.
</div>
);
}
return (
<div className="relative w-full overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full min-w-[520px] h-auto"
role="img"
aria-label={`Evolución ${metric} por canal`}
>
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
const y = pad.t + innerH * (1 - t);
const val = maxY * t;
return (
<g key={t}>
<line
x1={pad.l}
x2={width - pad.r}
y1={y}
y2={y}
stroke="#1F2937"
strokeWidth={1}
/>
<text
x={pad.l - 8}
y={y + 3}
textAnchor="end"
className="fill-gray-500"
fontSize={10}
>
{formatNum(val)}
</text>
</g>
);
})}
{series.map((s) => {
const d = s.vals
.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xAt(i)} ${yAt(v)}`)
.join(' ');
return (
<g key={s.channel}>
<path d={d} fill="none" stroke={s.color} strokeWidth={2.5} />
{s.vals.map((v, i) => (
<circle
key={`${s.channel}-${weeks[i]}`}
cx={xAt(i)}
cy={yAt(v)}
r={
hover?.week === weeks[i] && hover?.channel === s.channel
? 5
: 3.5
}
fill={s.color}
className="cursor-pointer"
onMouseEnter={() =>
setHover({
week: weeks[i],
channel: s.channel,
value: v,
x: xAt(i),
y: yAt(v),
})
}
onMouseLeave={() => setHover(null)}
/>
))}
</g>
);
})}
{weeks.map((w, i) => (
<text
key={w}
x={xAt(i)}
y={height - 10}
textAnchor="middle"
className="fill-gray-500"
fontSize={9}
>
{w.slice(5)}
</text>
))}
</svg>
{hover && (
<div
className="pointer-events-none absolute z-10 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-[11px] text-gray-900 shadow-xl"
style={{
left: Math.min(hover.x + 8, 560),
top: Math.max(hover.y - 36, 8),
}}
>
<p className="font-semibold capitalize">{hover.channel}</p>
<p className="text-gray-400">{hover.week}</p>
<p>
{metric === 'spend'
? formatEur(hover.value)
: metric === 'cpl'
? `${formatNum(hover.value)} € CPL`
: `${formatNum(hover.value)} leads`}
</p>
</div>
)}
</div>
);
}
export default function SeguimientoBoard() {
const { since, until, label: rangeLabel } = useDateRange();
const [data, setData] = useState<Payload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [metric, setMetric] = useState<MetricKey>('leads');
const [activeChannels, setActiveChannels] = useState<Set<string>>(new Set());
const [openId, setOpenId] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [preset, setPreset] = useState<RangePreset>('last_30d');
const [customSince, setCustomSince] = useState('');
const [customUntil, setCustomUntil] = useState('');
const [openId, setOpenId] = useState<string | null>(null);
const [noteTitle, setNoteTitle] = useState('');
const [noteBody, setNoteBody] = useState('');
const [noteBusy, setNoteBusy] = useState(false);
const [noteMsg, setNoteMsg] = useState<string | null>(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 (
<div className="flex items-center gap-2 text-sm text-gray-400 py-16 justify-center">
<p className="text-sm text-gray-500 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Cargando seguimiento…
</div>
</p>
);
}
if (error) {
if (err) {
return (
<p className="text-sm text-red-400 border border-red-500/20 rounded-lg px-3 py-2">
{error}
<p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{err}
</p>
);
}
@@ -402,210 +183,76 @@ export default function SeguimientoBoard() {
if (data.portfolioWide) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center">
<Radio className="w-8 h-8 text-brand-500 mx-auto mb-3" />
<h2 className="text-lg font-semibold text-gray-900">Seguimiento</h2>
<p className="text-sm text-gray-400 mt-2 max-w-md mx-auto">
{data.hint ||
'Selecciona un cliente en el encabezado para ver la evolución y las notas.'}
</p>
</div>
<p className="text-sm text-gray-600 border border-gray-200 rounded-xl p-6 bg-white">
{data.hint ||
'Elige un cliente en Contexto para ver su seguimiento.'}
</p>
);
}
const cards = [
{
label: 'Inversión',
value: formatEur(periodTotals.spend),
icon: <DollarSign className="w-4 h-4 text-brand-500" />,
},
{
label: 'Leads',
value: formatNum(periodTotals.leads),
icon: <Users className="w-4 h-4 text-blue-400" />,
},
{
label: 'CPL',
value: periodTotals.cpl ? `${formatNum(periodTotals.cpl)} €` : '—',
icon: <MousePointerClick className="w-4 h-4 text-violet-400" />,
},
{
label: 'CAC',
value: periodTotals.cac ? `${formatNum(periodTotals.cac)} €` : '—',
icon: <Target className="w-4 h-4 text-amber-400" />,
},
{
label: 'Revenue',
value: formatEur(periodTotals.revenue),
icon: <TrendingUp className="w-4 h-4 text-emerald-400" />,
},
{
label: 'ROAS',
value: periodTotals.roas ? `${formatNum(periodTotals.roas)}x` : '—',
icon: <TrendingUp className="w-4 h-4 text-emerald-300" />,
},
];
return (
<div className="space-y-8">
<div className="space-y-6">
<PageHeader
eyebrow="Rendimiento"
eyebrow="Operación"
title="Seguimiento"
description={`${data.label} · mismas KPIs que Métricas, filtrables por periodo + notas del equipo`}
description={`${data.label} · notas e histórico del equipo (periodo ${rangeLabel}). Ads y KPIs están en Métricas.`}
/>
{/* Date selector */}
<section className="rounded-2xl border border-gray-200 bg-white p-4 space-y-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<CalendarRange className="w-3.5 h-3.5 text-sky-500" />
Periodo
</div>
<DateRangePicker
preset={preset}
customSince={customSince || range.since}
customUntil={customUntil || range.until}
onPreset={(p) => {
setPreset(p);
if (p !== 'custom') {
const r = resolveAdRange(p);
setCustomSince(r.since);
setCustomUntil(r.until);
}
}}
onCustomChange={(since, until) => {
setCustomSince(since);
setCustomUntil(until);
}}
/>
</section>
{/* KPI cards (Métricas-like) */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
{cards.map((c) => (
<div
key={c.label}
className="rounded-xl border border-gray-200 bg-white px-3 py-3"
>
<div className="flex items-center justify-between mb-1">
<p className="text-[10px] uppercase tracking-wider text-gray-500">
{c.label}
</p>
{c.icon}
</div>
<p className="text-xl font-bold text-gray-900 tabular-nums">{c.value}</p>
</div>
))}
</div>
{/* Channel evolution */}
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-gray-900 font-medium">
<LineIcon className="w-4 h-4 text-brand-500" />
Evolución por canal
</div>
<div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 bg-gray-50">
{(
[
['leads', 'Leads'],
['spend', 'Inversión'],
['cpl', 'CPL'],
] as [MetricKey, string][]
).map(([k, label]) => (
<button
key={k}
type="button"
onClick={() => setMetric(k)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
metric === k
? 'bg-brand-500 text-white'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{label}
</button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
{rangedChannel.channels.map((ch, i) => {
const on = activeChannels.has(ch);
const color = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
return (
<button
key={ch}
type="button"
onClick={() => toggleChannel(ch)}
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-all capitalize ${
on
? 'border-gray-600 text-gray-900 bg-gray-900'
: 'border-gray-200 text-gray-500 opacity-60'
}`}
>
<span
className="w-2 h-2 rounded-full"
style={{ background: on ? color : '#4B5563' }}
/>
{ch}
</button>
);
})}
</div>
<ChannelEvolutionChart
weeks={rangedChannel.weeks}
channels={rangedChannel.channels}
points={rangedChannel.points}
metric={metric}
activeChannels={activeChannels}
/>
</section>
{/* Notes box */}
<section className="rounded-2xl border border-gray-200 bg-white p-5 space-y-3">
<div className="flex items-center gap-2 text-gray-900 font-medium">
<MessageSquareText className="w-4 h-4 text-violet-400" />
Comentarios y notas
<MessageSquareText className="w-4 h-4 text-violet-500" />
Nueva nota
</div>
<p className="text-xs text-gray-500">
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).
</p>
<input
value={noteTitle}
onChange={(e) => 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"
/>
<textarea
value={noteBody}
onChange={(e) => setNoteBody(e.target.value)}
placeholder="Escribe aquí comentarios, notas o cualquier cosa relevante…"
className="w-full min-h-[120px] rounded-lg bg-black/30 border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-600"
placeholder="Comentarios, hipótesis, bloqueos, siguientes pasos…"
className="w-full min-h-[120px] rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900"
/>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
disabled={noteBusy || !noteBody.trim()}
onClick={saveNote}
className="inline-flex items-center gap-2 rounded-xl bg-violet-500/20 border border-violet-500/40 text-violet-200 px-4 py-2 text-sm font-semibold disabled:opacity-50"
onClick={() => void saveNote()}
className="inline-flex items-center gap-2 rounded-xl bg-brand-500 text-white px-4 py-2 text-sm font-semibold disabled:opacity-50"
>
<Send className="w-3.5 h-3.5" />
{noteBusy ? 'Guardando…' : 'Añadir al seguimiento'}
</button>
<button
type="button"
disabled={syncBusy || !data.tenantId}
onClick={() => void syncSuperhuman()}
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 disabled:opacity-50"
>
{syncBusy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Radio className="w-3.5 h-3.5 text-emerald-600" />
)}
Traer notas Superhuman
</button>
{noteMsg ? (
<p className="text-xs text-emerald-400">{noteMsg}</p>
<p className="text-xs text-emerald-700">{noteMsg}</p>
) : null}
</div>
</section>
{/* Timeline */}
<section className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 text-gray-900 font-medium">
<Radio className="w-4 h-4 text-emerald-400" />
Timeline seguimiento
<Radio className="w-4 h-4 text-emerald-500" />
Histórico
<span className="text-xs text-gray-500 font-normal">
({filteredEntries.length})
</span>
@@ -613,15 +260,15 @@ export default function SeguimientoBoard() {
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filtrar temas, CPL, notas…"
className="w-full sm:w-64 rounded-lg bg-black/30 border border-gray-200 px-3 py-1.5 text-sm text-gray-900 placeholder:text-gray-600 focus:outline-none focus:border-brand-500/50"
placeholder="Filtrar…"
className="w-full sm:w-64 rounded-lg border border-gray-200 px-3 py-1.5 text-sm"
/>
</div>
{filteredEntries.length === 0 ? (
<div className="rounded-xl border border-dashed border-gray-200 px-4 py-10 text-center text-sm text-gray-500">
Sin entradas en este periodo. Añade una nota arriba o sincroniza
Superhuman Docs desde Agencia.
Sin notas en este periodo. Añade una arriba o sincroniza Superhuman
desde Agencia (docs «seguimiento»).
</div>
) : (
<ol className="relative border-l border-gray-200 ml-3 space-y-4">
@@ -636,11 +283,11 @@ export default function SeguimientoBoard() {
: '—';
return (
<li key={e.id} className="ml-4">
<span className="absolute -left-1.5 mt-1.5 w-3 h-3 rounded-full bg-brand-500 ring-4 ring-[#0B0F19]" />
<span className="absolute -left-1.5 mt-1.5 w-3 h-3 rounded-full bg-brand-500 ring-4 ring-white" />
<button
type="button"
onClick={() => setOpenId(open ? null : e.id)}
className="w-full text-left rounded-xl border border-gray-200 bg-white hover:border-gray-200 transition-colors p-4"
className="w-full text-left rounded-xl border border-gray-200 bg-white hover:border-gray-300 p-4"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
@@ -651,12 +298,17 @@ export default function SeguimientoBoard() {
{typeLabel(e.entry_type)}
</span>
<span className="text-[11px] text-gray-500">{when}</span>
{e.source === 'superhuman' || e.browser_link ? (
<span className="text-[10px] text-gray-400">
Superhuman
</span>
) : null}
</div>
<p className="text-sm font-semibold text-gray-900">
{e.title}
</p>
{e.summary && (
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{e.summary}
</p>
)}
@@ -667,39 +319,17 @@ export default function SeguimientoBoard() {
target="_blank"
rel="noreferrer"
onClick={(ev) => ev.stopPropagation()}
className="inline-flex items-center gap-1 text-[11px] text-brand-400 hover:text-brand-700"
className="inline-flex items-center gap-1 text-[11px] text-brand-600"
>
Superhuman <ExternalLink className="w-3 h-3" />
Abrir <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
{open && e.body_md && (
<pre className="mt-3 whitespace-pre-wrap text-xs text-gray-600 bg-black/30 border border-gray-200 rounded-lg p-3 max-h-64 overflow-y-auto font-sans">
<pre className="mt-3 whitespace-pre-wrap text-xs text-gray-700 bg-gray-50 border border-gray-100 rounded-lg p-3 max-h-64 overflow-y-auto font-sans">
{e.body_md}
</pre>
)}
{open &&
e.metrics &&
(e.metrics.ventas || e.metrics.cpl) && (
<div className="mt-2 flex flex-wrap gap-2 text-[11px]">
{e.metrics.ventas != null && (
<span className="px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-300">
Ventas {e.metrics.ventas}
</span>
)}
{e.metrics.cpl != null && (
<span className="px-2 py-0.5 rounded bg-sky-500/10 text-sky-300">
CPL ≈ {formatNum(e.metrics.cpl)} €
</span>
)}
{e.metrics.cac != null && (
<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-300">
CAC ≈ {formatNum(e.metrics.cac)} €
</span>
)}
</div>
)}
</button>
</li>
);

View File

@@ -9,6 +9,9 @@ import {
Loader2,
Share2,
ExternalLink,
Users,
Eye,
Image as ImageIcon,
} from 'lucide-react';
import { PageHeader } from '@/components/ui';
@@ -17,10 +20,26 @@ type Integ = {
status: string;
external_account_id?: string | null;
label?: string | null;
last_sync_at?: string | null;
meta?: { public_url?: string; account_name?: string; warning?: string } | null;
};
type MetricsFacebook = {
name?: string;
fan_count?: number;
followers_count?: number;
link?: string;
error?: string;
};
type MetricsIg = {
username?: string;
followers_count?: number;
media_count?: number;
follows_count?: number;
error?: string;
insights?: { data?: { name?: string; values?: { value?: number }[] }[] };
};
const CHANNELS: {
id: string;
label: string;
@@ -32,6 +51,11 @@ const CHANNELS: {
{ id: 'tiktok', label: 'TikTok', icon: Share2 },
];
function n(v?: number) {
if (v == null || !Number.isFinite(v)) return '—';
return new Intl.NumberFormat('es-ES').format(v);
}
export default function SocialDashboard({
tenantLabel,
isAgency,
@@ -41,6 +65,11 @@ export default function SocialDashboard({
}) {
const [loading, setLoading] = useState(true);
const [integs, setIntegs] = useState<Integ[]>([]);
const [metrics, setMetrics] = useState<{
facebook?: MetricsFacebook;
instagram?: MetricsIg;
warning?: string;
}>({});
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
@@ -51,7 +80,10 @@ export default function SocialDashboard({
const data = await res.json().catch(() => ({}));
if (cancelled) return;
if (!res.ok) setErr(data.error || 'No se pudo cargar Social');
else setIntegs(data.integrations || []);
else {
setIntegs(data.integrations || []);
setMetrics(data.metrics || {});
}
setLoading(false);
})();
return () => {
@@ -60,13 +92,17 @@ export default function SocialDashboard({
}, []);
const by = new Map(integs.map((i) => [i.provider, i]));
const ig = metrics.instagram;
const fb = metrics.facebook;
const igReach =
ig?.insights?.data?.find((d) => d.name === 'reach')?.values?.[0]?.value;
return (
<div className="space-y-6">
<PageHeader
eyebrow={`Social · ${tenantLabel}`}
title="Redes sociales"
description="Perfiles orgánicos (FB, IG, LinkedIn, TikTok). Distinto de Ads pagados. Publicación y métricas live: siguiente fase."
description="Perfiles orgánicos y métricas de audiencia. Paid ads están en Ads / Métricas."
/>
{err && (
@@ -74,95 +110,167 @@ export default function SocialDashboard({
{err}
</p>
)}
{metrics.warning && (
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
{metrics.warning}
</p>
)}
{loading ? (
<p className="text-sm text-gray-500 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Cargando…
</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{CHANNELS.map((ch) => {
const row = by.get(ch.id);
const connected =
row?.status === 'connected' && Boolean(row.external_account_id);
const Icon = ch.icon;
const url =
row?.meta?.public_url ||
(row?.external_account_id?.startsWith('http')
? row.external_account_id
: null);
return (
<li
key={ch.id}
className="rounded-xl border border-gray-200 bg-white p-4 space-y-2"
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{
label: 'Seguidores IG',
value: n(ig?.followers_count),
icon: Users,
},
{
label: 'Publicaciones IG',
value: n(ig?.media_count),
icon: ImageIcon,
},
{
label: 'Fans / seguidores FB',
value: n(fb?.followers_count ?? fb?.fan_count),
icon: Users,
},
{
label: 'Alcance IG (día)',
value: n(igReach),
icon: Eye,
},
].map((k) => (
<div
key={k.label}
className="rounded-xl border border-gray-200 bg-white p-4"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-gray-700" />
<p className="text-sm font-semibold text-gray-900">
{ch.label}
</p>
</div>
<span
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded ${
connected
? 'bg-emerald-50 text-emerald-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{connected ? 'Conectado' : 'Sin enlazar'}
</span>
</div>
<p className="text-xs text-gray-600 truncate">
{row?.meta?.account_name ||
row?.label ||
row?.external_account_id ||
'—'}
</p>
{row?.meta?.warning && (
<p className="text-[11px] text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">
{row.meta.warning}
<div className="flex items-center justify-between text-gray-600 mb-1">
<p className="text-[10px] font-semibold uppercase tracking-wider">
{k.label}
</p>
)}
{url && (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[11px] text-brand-600 hover:underline"
>
Abrir perfil <ExternalLink className="w-3 h-3" />
</a>
)}
<k.icon className="w-3.5 h-3.5" />
</div>
<p className="text-xl font-bold text-gray-900 tabular-nums">
{k.value}
</p>
</div>
))}
</div>
{(ig?.error || fb?.error) && (
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
{ig?.error && `IG: ${ig.error} `}
{fb?.error && `FB: ${fb.error}`}
{' · '}
Suele faltar Page token o permisos de insights en el system user.
</p>
)}
<section className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-900">
Insights (social expert)
</h3>
<ul className="text-xs text-gray-700 space-y-1.5 list-disc pl-4">
<li>
Prioriza contenido que convierta a lead/venta (CTA + landings
medidas en CRM), no solo vanity metrics.
</li>
);
})}
</ul>
<li>
Si seguidores IG ≫ fans FB, concentra producción en Reels /
carruseles y usa FB como amplificación ligera.
</li>
<li>
Revisa Calendario editorial: cada pieza debería tener objetivo
(awareness / consideración / conversión).
</li>
<li>
Menciones brand: próxima fase con listening; hoy el foco es
audiencia propia + engagement on-profile.
</li>
</ul>
</section>
<ul className="grid gap-3 sm:grid-cols-2">
{CHANNELS.map((ch) => {
const row = by.get(ch.id);
const connected =
row?.status === 'connected' && Boolean(row.external_account_id);
const Icon = ch.icon;
const url =
row?.meta?.public_url ||
(row?.external_account_id?.startsWith('http')
? row.external_account_id
: null);
return (
<li
key={ch.id}
className="rounded-xl border border-gray-200 bg-white p-4 space-y-2"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-gray-700" />
<p className="text-sm font-semibold text-gray-900">
{ch.label}
</p>
</div>
<span
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded ${
connected
? 'bg-emerald-50 text-emerald-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{connected ? 'Conectado' : 'Sin enlazar'}
</span>
</div>
<p className="text-xs text-gray-600 truncate">
{row?.meta?.account_name ||
row?.label ||
row?.external_account_id ||
'—'}
</p>
{row?.meta?.warning && (
<p className="text-[11px] text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">
{row.meta.warning}
</p>
)}
{url && (
<a
href={url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[11px] text-brand-600 hover:underline"
>
Abrir perfil <ExternalLink className="w-3 h-3" />
</a>
)}
</li>
);
})}
</ul>
</>
)}
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
<p className="text-sm font-semibold text-gray-900">Próximo en Social</p>
<ul className="text-xs text-gray-600 space-y-1 list-disc pl-4">
<li>Métricas orgánicas (alcance, engagement) vía Graph API</li>
<li>Últimas publicaciones y rendimiento</li>
<li>Puente con Calendario editorial</li>
</ul>
<div className="flex flex-wrap gap-2 pt-1">
<div className="flex flex-wrap gap-2">
<Link
href="/dashboard/calendario"
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
>
Calendario editorial
</Link>
{isAgency && (
<Link
href="/dashboard/calendario"
href="/dashboard/agencia"
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
>
Calendario editorial
Emparejar en Agencia
</Link>
{isAgency && (
<Link
href="/dashboard/agencia"
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
>
Emparejar en Agencia / ficha
</Link>
)}
</div>
)}
</div>
</div>
);

View File

@@ -666,3 +666,25 @@ export type OrganicoWorkspace = {
priorities?: { seo: string[]; geo: string[] };
sectionNotes?: Partial<Record<OrganicoSectionId, string>>;
};
/** Prioridades por defecto (SEO/GEO expert) cuando el workspace está vacío. */
export const DEFAULT_EXPERT_PRIORITIES: { seo: string[]; geo: string[] } = {
seo: [
'Verificar GSC (URL-prefix vs sc-domain) y GA4 property: datos diarios en organic_daily',
'Top landings por clics GSC: title/H1/CTA alineados con intención de búsqueda',
'Cerrar gap de indexación: sitemap, noindex accidental, soft-404',
'Core Web Vitals móvil en landings de conversión (LCP < 2.5s)',
'Keyword gap vs 3 competidores: 10 URLs nuevas o ampliadas este mes',
'Enlazado interno desde hub → money pages (autoridad hacia conversión)',
'Cruzar sesiones orgánicas GA4 con leads CRM (mismo embudo que ads)',
],
geo: [
'Definir cartera de 30–50 prompts de compra/comparativa del sector',
'Medir Share of Model en ChatGPT, Gemini y AI Overviews (baseline)',
'Aumentar % de citas a web oficial (schema, autoría, datos propios)',
'FAQs citables: respuesta directa + tabla/dato en las 10 URLs clave',
'Mapear fuentes que citan a competidores y no a la marca',
'Briefs de contenido a partir de prompts donde SoM = 0',
'Re-medir mensualmente y priorizar caídas de sentimiento / menciones',
],
};