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'; 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) { export async function GET(req: NextRequest) {
const supabase = await createClient(); const supabase = await createClient();
const { const {
@@ -28,11 +30,7 @@ export async function GET(req: NextRequest) {
); );
} }
// Clients only see own tenant; agency via view cookie already in ctx void isAgencyRole(userRole(user));
const agency = isAgencyRole(userRole(user));
if (!agency) {
// resolveActiveTenant already scopes to home tenant
}
const downloadId = req.nextUrl.searchParams.get('download')?.trim(); const downloadId = req.nextUrl.searchParams.get('download')?.trim();
if (downloadId) { if (downloadId) {
@@ -53,30 +51,47 @@ export async function GET(req: NextRequest) {
files: [], files: [],
linked: false, linked: false,
warning: 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 { try {
const files = await listFilesInFolder(link.folderId); const files = await listFilesInFolder(folderId);
return NextResponse.json({ const mapped = files
linked: true, .map((f) => ({
folderId: link.folderId,
folderName: link.folderName,
files: files.map((f) => ({
id: f.id, id: f.id,
name: f.name, name: f.name,
mimeType: f.mimeType, mimeType: f.mimeType,
bytes: f.size ? Number(f.size) : null, bytes: f.size ? Number(f.size) : null,
modifiedTime: f.modifiedTime, modifiedTime: f.modifiedTime,
webViewLink: f.webViewLink, webViewLink: f.webViewLink,
isFolder: f.mimeType === FOLDER_MIME,
source: 'drive' as const, 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) { } catch (e) {
return NextResponse.json({ return NextResponse.json({
linked: true, linked: true,
folderId: link.folderId, rootFolderId: link.folderId,
folderId,
folderName: link.folderName, folderName: link.folderName,
files: [], files: [],
warning: e instanceof Error ? e.message : 'Drive error', warning: e instanceof Error ? e.message : 'Drive error',

View File

@@ -7,14 +7,6 @@ import {
supabaseUrl, supabaseUrl,
userRole, userRole,
} from '@/lib/agency'; } from '@/lib/agency';
import {
AdDailyRow,
KpiWeeklyRow,
buildChannelSeries,
resolveWeeklyRows,
sumDaily,
sumWeekly,
} from '@/lib/metrics';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -33,7 +25,7 @@ export type SeguimientoEntry = {
source?: string; source?: string;
}; };
/** GET seguimiento timeline + metrics series for active tenant. */ /** GET — notas + histórico Superhuman (sin series ads). */
export async function GET() { export async function GET() {
const supabase = await createClient(); const supabase = await createClient();
const { const {
@@ -51,10 +43,7 @@ export async function GET() {
label: ctx.label, label: ctx.label,
portfolioWide: true, portfolioWide: true,
entries: [], entries: [],
weekly: [], hint: 'Elige un cliente en el selector para ver su seguimiento.',
daily: [],
channelSeries: { weeks: [], channels: [], points: [] },
hint: 'Elige un cliente en el selector para ver su seguimiento y evolución.',
}); });
} }
@@ -64,78 +53,33 @@ export async function GET() {
} }
let entries: SeguimientoEntry[] = []; let entries: SeguimientoEntry[] = [];
let weekly: KpiWeeklyRow[] = [];
let daily: AdDailyRow[] = [];
if (agency && ctx.viaView) { if (agency && ctx.viaView) {
const tid = encodeURIComponent(tenantId); const tid = encodeURIComponent(tenantId);
const [e, w, d] = await Promise.all([ const e = await restGet<SeguimientoEntry[]>(
restGet<SeguimientoEntry[]>( `/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=200`
`/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`
),
]);
entries = e.data || []; entries = e.data || [];
weekly = w.data || [];
daily = d.data || [];
} else { } else {
const [{ data: e }, { data: w }, { data: d }] = await Promise.all([ const { data: e } = await supabase
supabase .from('seguimiento_entries')
.from('seguimiento_entries') .select('*')
.select('*') .order('occurred_at', { ascending: false, nullsFirst: false })
.order('occurred_at', { ascending: false, nullsFirst: false }) .limit(200);
.limit(120),
supabase.from('kpi_weekly').select('*').order('week', { ascending: true }),
supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(5000),
]);
entries = (e as SeguimientoEntry[]) || []; 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({ return NextResponse.json({
label: ctx.label, label: ctx.label,
portfolioWide: false, portfolioWide: false,
tenantId, tenantId,
entries, 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. * POST — add a manual note / comentario to seguimiento.
* { title?, body, occurred_at? } * { title?, body, occurredAt? | occurred_at? }
*/ */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const supabase = await createClient(); const supabase = await createClient();
@@ -168,10 +112,10 @@ export async function POST(req: NextRequest) {
typeof body.title === 'string' && body.title.trim() typeof body.title === 'string' && body.title.trim()
? body.title.trim().slice(0, 200) ? body.title.trim().slice(0, 200)
: `Nota · ${new Date().toLocaleDateString('es-ES')}`; : `Nota · ${new Date().toLocaleDateString('es-ES')}`;
const occurredAt = const occurredRaw =
typeof body.occurred_at === 'string' && body.occurred_at (typeof body.occurredAt === 'string' && body.occurredAt) ||
? body.occurred_at (typeof body.occurred_at === 'string' && body.occurred_at) ||
: new Date().toISOString(); new Date().toISOString();
const row = { const row = {
tenant_id: ctx.tenantId, tenant_id: ctx.tenantId,
@@ -181,7 +125,7 @@ export async function POST(req: NextRequest) {
title, title,
summary: text.slice(0, 280), summary: text.slice(0, 280),
body_md: text, body_md: text,
occurred_at: occurredAt, occurred_at: occurredRaw,
synced_at: new Date().toISOString(), synced_at: new Date().toISOString(),
metrics: {}, metrics: {},
}; };

View File

@@ -2,10 +2,49 @@ import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency'; import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
export const dynamic = 'force-dynamic'; 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() { export async function GET() {
const supabase = await createClient(); const supabase = await createClient();
const { const {
@@ -19,33 +58,59 @@ export async function GET() {
if (!ctx.tenantId || ctx.portfolioWide) { if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json({ return NextResponse.json({
integrations: [], integrations: [],
metrics: {},
warning: 'Selecciona un cliente en Contexto', warning: 'Selecciona un cliente en Contexto',
}); });
} }
const agency = isAgencyRole(userRole(user)); const agency = isAgencyRole(userRole(user));
const providers = 'instagram,facebook,tiktok,linkedin'; const providers = 'instagram,facebook,tiktok,linkedin';
let integrations: Integ[] = [];
if (agency && ctx.viaView) { if (agency && ctx.viaView) {
const tid = encodeURIComponent(ctx.tenantId); const tid = encodeURIComponent(ctx.tenantId);
const r = await restGet( 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` `/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 as Integ[]) || [];
integrations: r.data || [], } else {
warning: r.error || null, 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 const metrics: Record<string, unknown> = {};
.from('integrations') const token = await resolveMetaAccessToken();
.select( if (token) {
'provider,status,external_account_id,label,last_sync_at,meta,error_message' for (const i of integrations) {
) if (i.status !== 'connected' || !i.external_account_id) continue;
.in('provider', ['instagram', 'facebook', 'tiktok', 'linkedin']); 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({ return NextResponse.json({
integrations: data || [], integrations,
warning: error?.message || null, metrics,
label: ctx.label,
}); });
} }

View File

@@ -12,6 +12,7 @@ import {
userRole, userRole,
} from '@/lib/agency'; } from '@/lib/agency';
import DashboardShell from '@/components/DashboardShell'; import DashboardShell from '@/components/DashboardShell';
import DashboardProviders from '@/components/DashboardProviders';
import { import {
DashboardModuleId, DashboardModuleId,
TenantSettings, TenantSettings,
@@ -80,20 +81,22 @@ export default async function DashboardLayout({
} }
return ( return (
<DashboardShell <DashboardProviders>
email={email} <DashboardShell
company={company} email={email}
role={role} company={company}
isAgency={isAgency} role={role}
viewName={viewName} isAgency={isAgency}
enabledModules={enabledModules} viewName={viewName}
clientLogoUrl={ enabledModules={enabledModules}
isAgency && viewId ? clientLogoUrl : !isAgency ? clientLogoUrl : null clientLogoUrl={
} isAgency && viewId ? clientLogoUrl : !isAgency ? clientLogoUrl : null
brandLabel={brandLabel} }
logoutAction={logoutAction} brandLabel={brandLabel}
> logoutAction={logoutAction}
{children} >
</DashboardShell> {children}
</DashboardShell>
</DashboardProviders>
); );
} }

View File

@@ -1,11 +1,7 @@
'use client'; 'use client';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import { normalizePlatform } from '@/lib/ad-tracker-range';
RangePreset,
normalizePlatform,
resolveAdRange,
} from '@/lib/ad-tracker-range';
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics'; import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
import type { AdCreativeCard } from '@/lib/ad-creatives-meta'; import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
import { import {
@@ -13,7 +9,7 @@ import {
CreativeZoomModal, CreativeZoomModal,
} from '@/components/AdCreativesGallery'; } from '@/components/AdCreativesGallery';
import { PageHeader } from '@/components/ui'; import { PageHeader } from '@/components/ui';
import DateRangePicker from '@/components/DateRangePicker'; import { useDateRange } from '@/components/DateRangeContext';
type PlatformKpis = { type PlatformKpis = {
spend: number; spend: number;
@@ -317,9 +313,7 @@ export default function AdTrackerClient({
tagline?: string; tagline?: string;
rows: AdDailyRow[]; rows: AdDailyRow[];
}) { }) {
const [preset, setPreset] = useState<RangePreset>('last_7d'); const { since, until, label: rangeLabel } = useDateRange();
const [customSince, setCustomSince] = useState('');
const [customUntil, setCustomUntil] = useState('');
const [tab, setTab] = useState<Tab>('resumen'); const [tab, setTab] = useState<Tab>('resumen');
const [chartMode, setChartMode] = useState<ChartMode>('combined'); const [chartMode, setChartMode] = useState<ChartMode>('combined');
const [creatives, setCreatives] = useState<AdCreativeCard[]>([]); const [creatives, setCreatives] = useState<AdCreativeCard[]>([]);
@@ -327,10 +321,11 @@ export default function AdTrackerClient({
const [creativesWarning, setCreativesWarning] = useState<string | null>(null); const [creativesWarning, setCreativesWarning] = useState<string | null>(null);
const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null); const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null);
const range = useMemo( const range = useMemo(() => ({ since, until, label: rangeLabel }), [
() => resolveAdRange(preset, { since: customSince, until: customUntil }), since,
[preset, customSince, customUntil] until,
); rangeLabel,
]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -416,26 +411,7 @@ export default function AdTrackerClient({
<PageHeader <PageHeader
eyebrow={`Ads · ${label}`} eyebrow={`Ads · ${label}`}
title={tagline || 'Meta + Google Ads'} title={tagline || 'Meta + Google Ads'}
description={`Gasto, clics, conversiones y preview de creatividades. Ventana: ${range.since} → ${range.until}.`} description={`Gasto, clics, conversiones y creatividades. Periodo del header: ${rangeLabel}.`}
/>
{/* 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);
}}
/> />
{/* Tabs */} {/* Tabs */}

View File

@@ -38,6 +38,7 @@ type DriveFile = {
bytes?: number | null; bytes?: number | null;
modifiedTime?: string; modifiedTime?: string;
webViewLink?: string; webViewLink?: string;
isFolder?: boolean;
}; };
function formatBytes(n?: number | null) { function formatBytes(n?: number | null) {
@@ -89,6 +90,10 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]); const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
const [driveFolder, setDriveFolder] = useState<string | null>(null); 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 [warning, setWarning] = useState<string | null>(null);
const [folder, setFolder] = useState(''); const [folder, setFolder] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -121,11 +126,15 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
} }
}, []); }, []);
const loadDrive = useCallback(async () => { const loadDrive = useCallback(async (folderId?: string) => {
const res = await fetch('/api/assets/drive'); const qs = folderId
? `?folderId=${encodeURIComponent(folderId)}`
: '';
const res = await fetch(`/api/assets/drive${qs}`);
const data = await res.json(); const data = await res.json();
setDriveFiles(data.files || []); setDriveFiles(data.files || []);
setDriveFolder(data.folderName || null); setDriveFolder(data.folderName || null);
setDriveOpenUrl(data.driveOpenUrl || null);
if (data.warning) setWarning(data.warning); if (data.warning) setWarning(data.warning);
else if (data.linked) setWarning(null); else if (data.linked) setWarning(null);
}, []); }, []);
@@ -134,9 +143,12 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
setLoading(true); setLoading(true);
setErr(null); setErr(null);
if (tab === 'portal') await loadPortal(); if (tab === 'portal') await loadPortal();
else await loadDrive(); else {
const current = drivePath[drivePath.length - 1]?.id;
await loadDrive(current);
}
setLoading(false); setLoading(false);
}, [tab, loadPortal, loadDrive]); }, [tab, loadPortal, loadDrive, drivePath]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -190,6 +202,13 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
} }
async function openDrive(file: DriveFile) { 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) { if (file.webViewLink) {
window.open(file.webViewLink, '_blank', 'noopener,noreferrer'); window.open(file.webViewLink, '_blank', 'noopener,noreferrer');
return; return;
@@ -205,6 +224,21 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
window.open(data.url, '_blank', 'noopener,noreferrer'); 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) { async function remove(id: string) {
if (!confirm('¿Eliminar este archivo de la biblioteca?')) return; if (!confirm('¿Eliminar este archivo de la biblioteca?')) return;
const res = await fetch(`/api/assets?id=${encodeURIComponent(id)}`, { const res = await fetch(`/api/assets?id=${encodeURIComponent(id)}`, {
@@ -222,7 +256,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
<PageHeader <PageHeader
eyebrow="Contenido" eyebrow="Contenido"
title="Activos" 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"> <div className="flex gap-2">
@@ -462,49 +496,88 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
{tab === 'drive' && ( {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 ? ( {loading ? (
<p className="text-sm text-gray-500">Cargando Drive…</p> <p className="text-sm text-gray-500">Cargando Drive…</p>
) : driveFiles.length === 0 ? ( ) : driveFiles.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center text-gray-400"> <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" /> <HardDrive className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-base font-medium text-gray-600"> <p className="text-base font-medium text-gray-600">
Sin archivos en Drive Carpeta vacía o sin enlace
</p> </p>
<p className="text-xs text-gray-500 mt-2"> <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> </p>
</div> </div>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{driveFiles.map((f) => ( {driveFiles.map((f) => {
<li const folder =
key={f.id} f.isFolder ||
className="flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3" f.mimeType === 'application/vnd.google-apps.folder';
> return (
<span className="text-[10px] font-bold tracking-wider text-gray-500 w-10"> <li key={f.id}>
{iconHint(f.mimeType)} <button
</span> type="button"
<div className="flex-1 min-w-0"> onClick={() => void openDrive(f)}
<p className="text-sm text-gray-900 font-medium truncate"> 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"
{f.name} >
</p> <span className="text-[10px] font-bold tracking-wider text-gray-500 w-10">
<p className="text-[11px] text-gray-500"> {folder ? 'DIR' : iconHint(f.mimeType)}
{formatBytes(f.bytes)} </span>
{f.modifiedTime <div className="flex-1 min-w-0">
? ` · ${new Date(f.modifiedTime).toLocaleString('es-ES')}` <p className="text-sm text-gray-900 font-medium truncate">
: ''} {f.name}
</p> </p>
</div> <p className="text-[11px] text-gray-500">
<button {folder
type="button" ? 'Carpeta'
onClick={() => void openDrive(f)} : formatBytes(f.bytes)}
className="p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-50" {f.modifiedTime
title="Abrir en Drive" ? ` · ${new Date(f.modifiedTime).toLocaleString('es-ES')}`
> : ''}
<ExternalLink className="w-4 h-4" /> </p>
</button> </div>
</li> <ExternalLink className="w-4 h-4 text-gray-400 shrink-0" />
))} </button>
</li>
);
})}
</ul> </ul>
)} )}
</> </>

View File

@@ -1,8 +1,7 @@
'use client'; 'use client';
import React, { useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import { import {
CalendarRange,
Users, Users,
UserCheck, UserCheck,
Percent, Percent,
@@ -14,7 +13,6 @@ import {
AdDailyRow, AdDailyRow,
CrmChannelStats, CrmChannelStats,
CrmLeadRow, CrmLeadRow,
KpiRangeKey,
attributionCross, attributionCross,
buildCrmChannelStats, buildCrmChannelStats,
buildCrmFunnel, buildCrmFunnel,
@@ -23,11 +21,9 @@ import {
filterLeadsByDayRange, filterLeadsByDayRange,
formatEur, formatEur,
formatNum, formatNum,
resolveKpiDayRange,
} from '@/lib/metrics'; } from '@/lib/metrics';
import { PageHeader } from '@/components/ui'; import { PageHeader } from '@/components/ui';
import { useDateRange } from '@/components/DateRangeContext';
type RangeKey = KpiRangeKey;
function rateClass(rate: number): string { function rateClass(rate: number): string {
if (rate >= 8) return 'text-emerald-400'; if (rate >= 8) return 'text-emerald-400';
@@ -47,21 +43,17 @@ export default function CrmDashboard({
crmLeads?: CrmLeadRow[]; crmLeads?: CrmLeadRow[];
dailyRows?: AdDailyRow[]; dailyRows?: AdDailyRow[];
}) { }) {
const [range, setRange] = useState<RangeKey>('all'); const { since, until, label: rangeHint } = useDateRange();
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
const rangedLeads = useMemo(() => { const rangedLeads = useMemo(() => {
if (!crmLeads.length) return []; if (!crmLeads.length) return [];
if (!dayWindow) return crmLeads; return filterLeadsByDayRange(crmLeads, since, until);
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until); }, [crmLeads, since, until]);
}, [crmLeads, dayWindow]);
const rangedDaily = useMemo(() => { const rangedDaily = useMemo(() => {
if (!dailyRows.length) return []; if (!dailyRows.length) return [];
if (!dayWindow) return dailyRows; return filterDailyByDayRange(dailyRows, since, until);
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until); }, [dailyRows, since, until]);
}, [dailyRows, dayWindow]);
const funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]); const funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]);
const statusRows = useMemo( const statusRows = useMemo(
@@ -83,10 +75,6 @@ export default function CrmDashboard({
return m; return m;
}, [attribution]); }, [attribution]);
const rangeHint = dayWindow
? `${dayWindow.since} → ${dayWindow.until}`
: 'todo el histórico CRM';
const paidChannels = channelRows.filter((r) => const paidChannels = channelRows.filter((r) =>
['meta', 'google'].includes(r.channel) ['meta', 'google'].includes(r.channel)
); );
@@ -99,35 +87,7 @@ export default function CrmDashboard({
<PageHeader <PageHeader
eyebrow="Rendimiento" eyebrow="Rendimiento"
title="CRM" title="CRM"
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · ${rangeHint}.`} description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
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>
}
/> />
{!crmLeads.length ? ( {!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'; } from 'lucide-react';
import TenantSwitcher from '@/components/TenantSwitcher'; import TenantSwitcher from '@/components/TenantSwitcher';
import type { DashboardModuleId } from '@/lib/tenant-modules'; import type { DashboardModuleId } from '@/lib/tenant-modules';
import DateRangePicker from '@/components/DateRangePicker';
import { useDateRange } from '@/components/DateRangeContext';
type NavItem = { type NavItem = {
href: string; href: string;
@@ -171,6 +173,7 @@ export default function DashboardShell({
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const close = () => setOpen(false); const close = () => setOpen(false);
const dateRange = useDateRange();
const visibleNav = NAV_ITEMS.filter((item) => { const visibleNav = NAV_ITEMS.filter((item) => {
if (isAgency) return true; if (isAgency) return true;
@@ -307,25 +310,35 @@ export default function DashboardShell({
) : null} ) : null}
<div className="flex min-w-0 flex-1 flex-col"> <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"> <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">
<button <div className="flex items-center gap-3 min-h-10">
type="button" <button
className="md:hidden rounded-lg p-2 text-gray-600 hover:bg-gray-100" type="button"
onClick={() => setOpen(true)} className="md:hidden rounded-lg p-2 text-gray-600 hover:bg-gray-100"
aria-label="Abrir menú" onClick={() => setOpen(true)}
> aria-label="Abrir menú"
<Menu className="w-5 h-5" /> >
</button> <Menu className="w-5 h-5" />
</button>
<div className="flex min-w-0 items-center gap-2 text-sm text-gray-500"> <div className="flex min-w-0 items-center gap-2 text-sm text-gray-500">
<span className="hidden sm:inline shrink-0"> <span className="hidden sm:inline shrink-0">
{isAgency ? 'Contexto:' : 'Cliente / Portal:'} {isAgency ? 'Contexto:' : 'Cliente / Portal:'}
</span> </span>
<TenantSwitcher <TenantSwitcher
isAgency={isAgency} isAgency={isAgency}
initialName={viewName || company} initialName={viewName || company}
/> />
</div>
</div> </div>
<DateRangePicker
compact
preset={dateRange.preset}
customSince={dateRange.customSince}
customUntil={dateRange.customUntil}
onPreset={dateRange.setPreset}
onCustomChange={dateRange.setCustom}
/>
</header> </header>
<main className="flex-1 overflow-y-auto"> <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, TrendingUp,
Users, Users,
MousePointerClick, MousePointerClick,
Eye,
CalendarRange,
} from 'lucide-react'; } from 'lucide-react';
import { import {
AdDailyRow, AdDailyRow,
@@ -30,13 +28,12 @@ import {
filterDealsForRange, filterDealsForRange,
filterLeadsByDayRange, filterLeadsByDayRange,
metricTotalsFromDaily, metricTotalsFromDaily,
resolveKpiDayRange,
sumWeekly, sumWeekly,
withCohortFromDeals, withCohortFromDeals,
KpiWeeklyRow, KpiWeeklyRow,
KpiRangeKey,
} from '@/lib/metrics'; } from '@/lib/metrics';
import { PageHeader, SegmentedControl } from '@/components/ui'; import { PageHeader } from '@/components/ui';
import { useDateRange } from '@/components/DateRangeContext';
type Card = { type Card = {
key: keyof MetricTotals; key: keyof MetricTotals;
@@ -45,12 +42,10 @@ type Card = {
format: (v: number) => string; format: (v: number) => string;
icon: React.ReactNode; icon: React.ReactNode;
invert?: boolean; invert?: boolean;
/** When true and value is 0 with no deals, show em dash + empty state. */ /** Business outcome from Clientify won deals. */
cohort?: boolean; cohort?: boolean;
}; };
type RangeKey = KpiRangeKey;
function DeltaTriple({ function DeltaTriple({
delta, delta,
invert, invert,
@@ -151,65 +146,46 @@ export default function MetricsDashboard({
crmLeads?: CrmLeadRow[]; crmLeads?: CrmLeadRow[];
wonDeals?: DealWonRow[]; wonDeals?: DealWonRow[];
}) { }) {
const [range, setRange] = useState<RangeKey>('7d'); const { since, until, label: rangeHint } = useDateRange();
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
const rangedDaily = useMemo(() => { const rangedDaily = useMemo(() => {
if (!dailyRows.length) return []; if (!dailyRows.length) return [];
if (!dayWindow) return dailyRows; return filterDailyByDayRange(dailyRows, since, until);
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until); }, [dailyRows, since, until]);
}, [dailyRows, dayWindow]);
const rangedLeads = useMemo(() => { const rangedLeads = useMemo(() => {
if (!crmLeads.length) return []; if (!crmLeads.length) return [];
if (!dayWindow) return crmLeads; return filterLeadsByDayRange(crmLeads, since, until);
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until); }, [crmLeads, since, until]);
}, [crmLeads, dayWindow]);
const rangedDeals = useMemo( const rangedDeals = useMemo(
() => () => filterDealsForRange(wonDeals, since, until),
filterDealsForRange( [wonDeals, since, until]
wonDeals,
dayWindow?.since ?? null,
dayWindow?.until ?? null
),
[wonDeals, dayWindow]
); );
const periodTotals = useMemo(() => { const periodTotals = useMemo(() => {
let base: MetricTotals; let base: MetricTotals;
if (rangedDaily.length) { if (rangedDaily.length) {
base = metricTotalsFromDaily(rangedDaily); base = metricTotalsFromDaily(rangedDaily);
} else if (!dayWindow) {
base = sumWeekly(weeklyRows);
} else { } else {
const from = dayWindow.since;
const until = dayWindow.until;
const ranged = weeklyRows.filter((r) => { const ranged = weeklyRows.filter((r) => {
if (!r.week) return false; if (!r.week) return false;
const w = r.week.slice(0, 10); const w = r.week.slice(0, 10);
return w <= until && w >= from; return w <= until && w >= since;
}); });
base = ranged.length ? sumWeekly(ranged) : month; base = ranged.length ? sumWeekly(ranged) : month;
} }
return withCohortFromDeals(base, rangedDeals); return withCohortFromDeals(base, rangedDeals);
}, [rangedDaily, rangedDeals, weeklyRows, dayWindow, month]); }, [rangedDaily, rangedDeals, weeklyRows, since, until, month]);
const attributionRows: AttributionChannelRow[] = useMemo(() => { const attributionRows: AttributionChannelRow[] = useMemo(() => {
if (rangedDaily.length || rangedLeads.length) { if (rangedDaily.length || rangedLeads.length) {
return attributionCross(rangedDaily, rangedLeads); return attributionCross(rangedDaily, rangedLeads);
} }
const map = new Map<string, { spend: number; leads: number }>(); const map = new Map<string, { spend: number; leads: number }>();
const src = for (const p of channelPoints) {
range === 'all' const d = p.week;
? channelPoints if (!d || d < since || d > until) continue;
: channelPoints.filter((p) => {
const d = p.week;
if (!d || !dayWindow) return true;
return d >= dayWindow.since && d <= dayWindow.until;
});
for (const p of src) {
const cur = map.get(p.channel) || { spend: 0, leads: 0 }; const cur = map.get(p.channel) || { spend: 0, leads: 0 };
cur.spend += p.spend; cur.spend += p.spend;
cur.leads += p.leads; cur.leads += p.leads;
@@ -226,92 +202,100 @@ export default function MetricsDashboard({
cplCrm: 0, cplCrm: 0,
})) }))
.sort((a, b) => b.spend - a.spend); .sort((a, b) => b.spend - a.spend);
}, [rangedDaily, rangedLeads, channelPoints, range, dayWindow]); }, [rangedDaily, rangedLeads, channelPoints, since, until]);
const crmPeriodTotal = rangedLeads.length; const crmPeriodTotal = rangedLeads.length;
const hasCrm = crmLeads.length > 0; const hasCrm = crmLeads.length > 0;
const hasCohort = rangedDeals.length > 0; const hasCohort = rangedDeals.length > 0;
const cierres = periodTotals.deals;
const rangeHint = dayWindow
? `${dayWindow.since} → ${dayWindow.until}`
: 'todo el histórico';
const cards: Card[] = [ const cards: Card[] = [
{ {
key: 'spend', key: 'spend',
label: 'Inversión ads', label: 'Inversión ads',
hint: 'Paid del periodo', hint: 'Gasto Meta + Google del periodo',
format: formatEur, format: formatEur,
icon: <DollarSign className="w-4 h-4 text-brand-600" />, icon: <DollarSign className="w-4 h-4 text-brand-600" />,
}, },
{ {
key: 'leads', key: 'leads',
label: 'Leads', label: 'Leads plataforma',
hint: 'Captación periodo', hint: 'Conversiones reportadas por ads',
format: formatNum, format: formatNum,
icon: <Users className="w-4 h-4 text-blue-600" />, icon: <Users className="w-4 h-4 text-blue-600" />,
}, },
{ {
key: 'cpl', key: 'cpl',
label: 'CPL', label: 'CPL ads',
hint: 'Coste por lead', hint: 'Inversión ÷ leads plataforma',
format: (v) => `${formatNum(v)} €`, format: (v) => `${formatNum(v)} €`,
icon: <MousePointerClick className="w-4 h-4 text-violet-600" />, icon: <MousePointerClick className="w-4 h-4 text-violet-600" />,
invert: true, invert: true,
}, },
{ {
key: 'cac', key: 'revenue',
label: 'CAC cohorte', label: 'Ventas (CRM)',
hint: hasCohort hint: hasCohort
? `Paid ÷ ${formatNum(periodTotals.deals)} cierres` ? `${formatNum(cierres)} deals ganados · importe Clientify`
: 'Paid ÷ deals ganados 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)} €`, format: (v) => `${formatNum(v)} €`,
icon: <Target className="w-4 h-4 text-amber-600" />, icon: <Target className="w-4 h-4 text-amber-600" />,
invert: true, invert: true,
cohort: 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', key: 'roas',
label: 'ROAS cohorte', label: 'ROAS ventas',
hint: 'Revenue ÷ spend', hint: 'Ventas CRM ÷ inversión ads',
format: (v) => `${formatNum(v)}x`, format: (v) => `${formatNum(v)}x`,
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />, icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
cohort: true, 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
<PageHeader <PageHeader
eyebrow="Rendimiento" eyebrow="Rendimiento"
title="Métricas" title="Métricas"
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (mismos días calendario que Ad Tracker).`} description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (selector global del header).`}
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>
}
/> />
{sparse && ( {sparse && (
@@ -323,10 +307,8 @@ export default function MetricsDashboard({
{!hasCohort && ( {!hasCohort && (
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2"> <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 Ventas / CAC / ROAS vacíos: no hay deals ganados (Clientify) con
en este periodo. Se calculan con{' '} fecha de cierre en este periodo. El gasto de ads no cuenta como venta.
<span className="font-semibold">deals Clientify (stage=won)</span>, no
solo con leads de ads.
</p> </p>
)} )}
@@ -394,29 +376,46 @@ export default function MetricsDashboard({
})} })}
</div> </div>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm"> <section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4 shadow-sm">
<div className="flex items-center justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
<div> <div>
<h3 className="text-sm font-semibold text-gray-900"> <h3 className="text-sm font-semibold text-gray-900">
Cruce por canal · ads vs CRM Cruce por canal · ads vs CRM
</h3> </h3>
<p className="text-xs text-gray-600 mt-0.5"> <p className="text-xs text-gray-600 mt-0.5">
Plataforma = leads reportados por Meta/Google · CRM = atribución Gasto y leads de plataforma vs leads CRM (como en{' '}
Clientify (origen/UTM) <a
{!hasCrm && portfolioWide href="https://mide-analytics-reports.vercel.app/como-combinamos-datos.html"
? ' · elige un cliente en Contexto para ver CRM' 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> </p>
</div> </div>
<p className="text-xs text-gray-600 shrink-0 font-medium"> <p className="text-xs text-gray-600 shrink-0 font-medium">
Gap = CRM − plat Gap = CRM − plat
</p> </p>
</div> </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 ? ( {attributionRows.length === 0 ? (
<p className="text-sm text-gray-600 py-6 text-center"> <p className="text-sm text-gray-600 py-6 text-center">
Sin series de canal en el periodo. Los canales Meta/Google aparecen Sin series de canal en el periodo.
cuando hay filas en ad_daily para este cliente (elige uno en
Contexto).
</p> </p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -486,49 +485,63 @@ export default function MetricsDashboard({
</table> </table>
</div> </div>
)} )}
</section>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-1">
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm"> <div>
<div className="flex items-center justify-between text-gray-700 mb-2"> <h4 className="text-xs font-semibold uppercase tracking-wider text-gray-600 mb-2">
<span className="text-xs font-semibold uppercase tracking-wider"> Ads · inversión / día
Ads · ventana diaria </h4>
</span> {dailyChart.length === 0 ? (
<Eye className="w-4 h-4 text-sky-600" /> <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> </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>
<div className="bg-white border border-gray-200 rounded-xl p-5 md:col-span-2 shadow-sm"> <p className="text-[10px] text-gray-500">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-700 mb-2"> Barras compactas por día · periodo del header · detalle en Ads
Cómo leer </p>
</p> </section>
<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>
</div> </div>
); );
} }

View File

@@ -29,6 +29,7 @@ import {
formatPct, formatPct,
groupsForPillar, groupsForPillar,
sumOrganic, sumOrganic,
DEFAULT_EXPERT_PRIORITIES,
} from '@/lib/organic'; } from '@/lib/organic';
import { isoYesterday } from '@/lib/ad-tracker-range'; import { isoYesterday } from '@/lib/ad-tracker-range';
@@ -156,11 +157,40 @@ export default function OrganicoDashboard({
setSectionNote(workspace.sectionNotes?.[sectionId] || ''); setSectionNote(workspace.sectionNotes?.[sectionId] || '');
const list = const list =
pillar === 'geo' pillar === 'geo'
? workspace.priorities?.geo || [] ? workspace.priorities?.geo?.length
: workspace.priorities?.seo || []; ? workspace.priorities.geo
: DEFAULT_EXPERT_PRIORITIES.geo
: workspace.priorities?.seo?.length
? workspace.priorities.seo
: DEFAULT_EXPERT_PRIORITIES.seo;
setPrioritiesText(list.join('\n')); setPrioritiesText(list.join('\n'));
}, [sectionId, pillar, workspace]); }, [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( const section = useMemo(
() => ORGANICO_SECTIONS.find((s) => s.id === sectionId)!, () => ORGANICO_SECTIONS.find((s) => s.id === sectionId)!,
[sectionId] [sectionId]
@@ -507,37 +537,88 @@ export default function OrganicoDashboard({
{/* Live panels by section.live */} {/* Live panels by section.live */}
{section.live === 'seo-metrics' && ( {section.live === 'seo-metrics' && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> <>
<Kpi label="Clics GSC" value={formatNum(totals.gscClicks)} /> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Kpi <Kpi label="Clics GSC" value={formatNum(totals.gscClicks)} />
label="Impresiones" <Kpi
value={formatNum(totals.gscImpressions)} label="Impresiones"
/> value={formatNum(totals.gscImpressions)}
<Kpi label="CTR" value={formatPct(totals.gscCtr)} /> />
<Kpi <Kpi label="CTR" value={formatPct(totals.gscCtr)} />
label="Posición media" <Kpi
value={ label="Posición media"
totals.gscAvgPosition value={
? formatNum(totals.gscAvgPosition, 1) totals.gscAvgPosition
: '—' ? formatNum(totals.gscAvgPosition, 1)
} : '—'
/> }
<Kpi />
label="Sesiones orgánicas" <Kpi
value={formatNum(totals.ga4Organic)} label="Sesiones orgánicas"
hint="GA4" value={formatNum(totals.ga4Organic)}
/> hint="GA4"
<Kpi label="Usuarios" value={formatNum(totals.ga4Users)} /> />
<Kpi <Kpi label="Usuarios" value={formatNum(totals.ga4Users)} />
label="Conversiones" <Kpi
value={formatNum(totals.ga4Conversions)} label="Conversiones"
/> value={formatNum(totals.ga4Conversions)}
<Kpi />
label="Días con datos" <Kpi
value={formatNum(totals.days)} label="Días con datos"
hint="Serie cargada" value={formatNum(totals.days)}
/> hint="Serie cargada"
</div> />
</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 && ( {section.live === 'geo-metrics' && latestGeo && (
@@ -614,7 +695,27 @@ export default function OrganicoDashboard({
)} )}
{section.live === 'geo-metrics' && !latestGeo && ( {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' && ( {section.live === 'geo-competitors' && (
@@ -693,27 +794,29 @@ export default function OrganicoDashboard({
)} )}
Guardar prioridades Guardar prioridades
</button> </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"> <ul className="text-sm text-gray-800 space-y-1">
{(pillar === 'geo' {displayPriorities.map((p) => (
? workspace.priorities?.geo <li key={p} className="flex gap-2">
: workspace.priorities?.seo <span className="text-brand-600">•</span>
)?.length ? ( {p}
(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.
</li> </li>
)} ))}
</ul> </ul>
)} )}
</section> </section>

View File

@@ -1,35 +1,20 @@
'use client'; 'use client';
/**
* Seguimiento = notas + histórico (Superhuman + manual).
* Ads/KPIs viven en Métricas.
*/
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
ExternalLink, ExternalLink,
LineChart as LineIcon,
Loader2, Loader2,
MessageSquareText, MessageSquareText,
Radio, Radio,
Send, Send,
CalendarRange,
DollarSign,
Users,
MousePointerClick,
Target,
TrendingUp,
} from 'lucide-react'; } from 'lucide-react';
import {
AdDailyRow,
ChannelWeekPoint,
KpiWeeklyRow,
formatEur,
formatNum,
sumDaily,
sumWeekly,
} from '@/lib/metrics';
import { PageHeader } from '@/components/ui'; import { PageHeader } from '@/components/ui';
import DateRangePicker from '@/components/DateRangePicker'; import { useDateRange } from '@/components/DateRangeContext';
import {
RangePreset,
resolveAdRange,
} from '@/lib/ad-tracker-range';
type Entry = { type Entry = {
id: string; id: string;
@@ -38,12 +23,6 @@ type Entry = {
summary?: string | null; summary?: string | null;
body_md?: string | null; body_md?: string | null;
browser_link?: string | null; browser_link?: string | null;
metrics?: {
ventas?: number;
cpl?: number;
cac?: number;
periodo?: string;
};
occurred_at?: string | null; occurred_at?: string | null;
source?: string; source?: string;
}; };
@@ -51,30 +30,11 @@ type Entry = {
type Payload = { type Payload = {
label: string; label: string;
portfolioWide?: boolean; portfolioWide?: boolean;
tenantId?: string;
hint?: string; hint?: string;
entries: Entry[]; 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) { function typeLabel(t: string) {
if (t === 'meeting') return 'Reunión'; if (t === 'meeting') return 'Reunión';
if (t === 'kpis') return 'KPIs'; if (t === 'kpis') return 'KPIs';
@@ -83,204 +43,41 @@ function typeLabel(t: string) {
} }
function typeTone(t: string) { function typeTone(t: string) {
if (t === 'meeting') return 'bg-amber-500/15 text-amber-300 border-amber-500/30'; if (t === 'meeting') return 'bg-amber-50 text-amber-800 border-amber-200';
if (t === 'kpis') return 'bg-sky-500/15 text-sky-300 border-sky-500/30'; if (t === 'kpis') return 'bg-sky-50 text-sky-800 border-sky-200';
if (t === 'topic') return 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30'; if (t === 'topic') return 'bg-emerald-50 text-emerald-800 border-emerald-200';
return 'bg-violet-500/15 text-violet-300 border-violet-500/30'; return 'bg-violet-50 text-violet-800 border-violet-200';
} }
function inRange(isoDate: string | undefined | null, since: string, until: string) { 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); const d = isoDate.slice(0, 10);
return d >= since && d <= until; 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() { export default function SeguimientoBoard() {
const { since, until, label: rangeLabel } = useDateRange();
const [data, setData] = useState<Payload | null>(null); const [data, setData] = useState<Payload | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [err, setErr] = 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 [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [preset, setPreset] = useState<RangePreset>('last_30d'); const [openId, setOpenId] = useState<string | null>(null);
const [customSince, setCustomSince] = useState('');
const [customUntil, setCustomUntil] = useState('');
const [noteTitle, setNoteTitle] = useState(''); const [noteTitle, setNoteTitle] = useState('');
const [noteBody, setNoteBody] = useState(''); const [noteBody, setNoteBody] = useState('');
const [noteBusy, setNoteBusy] = useState(false); const [noteBusy, setNoteBusy] = useState(false);
const [noteMsg, setNoteMsg] = useState<string | null>(null); const [noteMsg, setNoteMsg] = useState<string | null>(null);
const [syncBusy, setSyncBusy] = useState(false);
async function load() { async function load() {
setLoading(true); setLoading(true);
setError(null); setErr(null);
try { try {
const r = await fetch('/api/seguimiento', { cache: 'no-store' }); const res = await fetch('/api/seguimiento', { cache: 'no-store' });
const j = await r.json(); const json = (await res.json()) as Payload & { error?: string };
if (!r.ok) throw new Error(j.error || 'Error cargando seguimiento'); if (!res.ok) throw new Error(json.error || 'Error');
setData(j); setData(json);
setActiveChannels(new Set(j.channelSeries?.channels || []));
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Error'); setErr(e instanceof Error ? e.message : 'Error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -290,110 +87,94 @@ export default function SeguimientoBoard() {
void load(); 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 filteredEntries = useMemo(() => {
const list = (data?.entries || []).filter((e) => { const entries = data?.entries || [];
if (!e.occurred_at) return true;
return inRange(e.occurred_at, range.since, range.until);
});
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return list; return entries.filter((e) => {
return list.filter( if (!inRange(e.occurred_at, since, until)) return false;
(e) => if (!q) return true;
e.title.toLowerCase().includes(q) || const blob = `${e.title} ${e.summary || ''} ${e.body_md || ''}`.toLowerCase();
(e.summary || '').toLowerCase().includes(q) || return blob.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;
}); });
} }, [data, query, since, until]);
async function saveNote() { async function saveNote() {
if (!noteBody.trim()) return; if (!noteBody.trim()) return;
setNoteBusy(true); setNoteBusy(true);
setNoteMsg(null); setNoteMsg(null);
try { try {
const r = await fetch('/api/seguimiento', { const res = await fetch('/api/seguimiento', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title: noteTitle.trim() || undefined, title: noteTitle.trim() || 'Nota de seguimiento',
body: noteBody.trim(), body: noteBody.trim(),
occurredAt: new Date().toISOString(),
}), }),
}); });
const j = await r.json(); const json = await res.json();
if (!r.ok) { if (!res.ok) throw new Error(json.error || 'No se pudo guardar');
setNoteMsg(j.error || 'No se pudo guardar');
return;
}
setNoteTitle(''); setNoteTitle('');
setNoteBody(''); setNoteBody('');
setNoteMsg('Nota añadida al seguimiento'); setNoteMsg('Nota guardada');
await load(); await load();
} catch (e) {
setNoteMsg(e instanceof Error ? e.message : 'Error');
} finally { } finally {
setNoteBusy(false); 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 ( 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… <Loader2 className="w-4 h-4 animate-spin" /> Cargando seguimiento…
</div> </p>
); );
} }
if (error) { if (err) {
return ( return (
<p className="text-sm text-red-400 border border-red-500/20 rounded-lg px-3 py-2"> <p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{error} {err}
</p> </p>
); );
} }
@@ -402,210 +183,76 @@ export default function SeguimientoBoard() {
if (data.portfolioWide) { if (data.portfolioWide) {
return ( return (
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center"> <p className="text-sm text-gray-600 border border-gray-200 rounded-xl p-6 bg-white">
<Radio className="w-8 h-8 text-brand-500 mx-auto mb-3" /> {data.hint ||
<h2 className="text-lg font-semibold text-gray-900">Seguimiento</h2> 'Elige un cliente en Contexto para ver su seguimiento.'}
<p className="text-sm text-gray-400 mt-2 max-w-md mx-auto"> </p>
{data.hint ||
'Selecciona un cliente en el encabezado para ver la evolución y las notas.'}
</p>
</div>
); );
} }
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 ( return (
<div className="space-y-8"> <div className="space-y-6">
<PageHeader <PageHeader
eyebrow="Rendimiento" eyebrow="Operación"
title="Seguimiento" 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"> <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"> <div className="flex items-center gap-2 text-gray-900 font-medium">
<MessageSquareText className="w-4 h-4 text-violet-400" /> <MessageSquareText className="w-4 h-4 text-violet-500" />
Comentarios y notas Nueva nota
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Añade observaciones del periodo (reunión, hipótesis, bloqueos, Se guarda con fecha. El histórico también incluye entradas
siguientes pasos…). Quedan en el timeline de seguimiento. sincronizadas desde Superhuman Docs (docs de seguimiento).
</p> </p>
<input <input
value={noteTitle} value={noteTitle}
onChange={(e) => setNoteTitle(e.target.value)} onChange={(e) => setNoteTitle(e.target.value)}
placeholder="Título (opcional)" 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 <textarea
value={noteBody} value={noteBody}
onChange={(e) => setNoteBody(e.target.value)} onChange={(e) => setNoteBody(e.target.value)}
placeholder="Escribe aquí comentarios, notas o cualquier cosa relevante…" placeholder="Comentarios, hipótesis, bloqueos, siguientes pasos…"
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" 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"> <div className="flex flex-wrap items-center gap-3">
<button <button
type="button" type="button"
disabled={noteBusy || !noteBody.trim()} disabled={noteBusy || !noteBody.trim()}
onClick={saveNote} onClick={() => void 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" 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" /> <Send className="w-3.5 h-3.5" />
{noteBusy ? 'Guardando…' : 'Añadir al seguimiento'} {noteBusy ? 'Guardando…' : 'Añadir al seguimiento'}
</button> </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 ? ( {noteMsg ? (
<p className="text-xs text-emerald-400">{noteMsg}</p> <p className="text-xs text-emerald-700">{noteMsg}</p>
) : null} ) : null}
</div> </div>
</section> </section>
{/* Timeline */}
<section className="space-y-4"> <section className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <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"> <div className="flex items-center gap-2 text-gray-900 font-medium">
<Radio className="w-4 h-4 text-emerald-400" /> <Radio className="w-4 h-4 text-emerald-500" />
Timeline seguimiento Histórico
<span className="text-xs text-gray-500 font-normal"> <span className="text-xs text-gray-500 font-normal">
({filteredEntries.length}) ({filteredEntries.length})
</span> </span>
@@ -613,15 +260,15 @@ export default function SeguimientoBoard() {
<input <input
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Filtrar temas, CPL, notas…" placeholder="Filtrar…"
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" className="w-full sm:w-64 rounded-lg border border-gray-200 px-3 py-1.5 text-sm"
/> />
</div> </div>
{filteredEntries.length === 0 ? ( {filteredEntries.length === 0 ? (
<div className="rounded-xl border border-dashed border-gray-200 px-4 py-10 text-center text-sm text-gray-500"> <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 Sin notas en este periodo. Añade una arriba o sincroniza Superhuman
Superhuman Docs desde Agencia. desde Agencia (docs «seguimiento»).
</div> </div>
) : ( ) : (
<ol className="relative border-l border-gray-200 ml-3 space-y-4"> <ol className="relative border-l border-gray-200 ml-3 space-y-4">
@@ -636,11 +283,11 @@ export default function SeguimientoBoard() {
: '—'; : '—';
return ( return (
<li key={e.id} className="ml-4"> <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 <button
type="button" type="button"
onClick={() => setOpenId(open ? null : e.id)} 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 className="flex flex-wrap items-start justify-between gap-2">
<div> <div>
@@ -651,12 +298,17 @@ export default function SeguimientoBoard() {
{typeLabel(e.entry_type)} {typeLabel(e.entry_type)}
</span> </span>
<span className="text-[11px] text-gray-500">{when}</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> </div>
<p className="text-sm font-semibold text-gray-900"> <p className="text-sm font-semibold text-gray-900">
{e.title} {e.title}
</p> </p>
{e.summary && ( {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} {e.summary}
</p> </p>
)} )}
@@ -667,39 +319,17 @@ export default function SeguimientoBoard() {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={(ev) => ev.stopPropagation()} 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> </a>
)} )}
</div> </div>
{open && e.body_md && ( {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} {e.body_md}
</pre> </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> </button>
</li> </li>
); );

View File

@@ -9,6 +9,9 @@ import {
Loader2, Loader2,
Share2, Share2,
ExternalLink, ExternalLink,
Users,
Eye,
Image as ImageIcon,
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader } from '@/components/ui'; import { PageHeader } from '@/components/ui';
@@ -17,10 +20,26 @@ type Integ = {
status: string; status: string;
external_account_id?: string | null; external_account_id?: string | null;
label?: string | null; label?: string | null;
last_sync_at?: string | null;
meta?: { public_url?: string; account_name?: string; warning?: 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: { const CHANNELS: {
id: string; id: string;
label: string; label: string;
@@ -32,6 +51,11 @@ const CHANNELS: {
{ id: 'tiktok', label: 'TikTok', icon: Share2 }, { 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({ export default function SocialDashboard({
tenantLabel, tenantLabel,
isAgency, isAgency,
@@ -41,6 +65,11 @@ export default function SocialDashboard({
}) { }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [integs, setIntegs] = useState<Integ[]>([]); const [integs, setIntegs] = useState<Integ[]>([]);
const [metrics, setMetrics] = useState<{
facebook?: MetricsFacebook;
instagram?: MetricsIg;
warning?: string;
}>({});
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -51,7 +80,10 @@ export default function SocialDashboard({
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (cancelled) return; if (cancelled) return;
if (!res.ok) setErr(data.error || 'No se pudo cargar Social'); if (!res.ok) setErr(data.error || 'No se pudo cargar Social');
else setIntegs(data.integrations || []); else {
setIntegs(data.integrations || []);
setMetrics(data.metrics || {});
}
setLoading(false); setLoading(false);
})(); })();
return () => { return () => {
@@ -60,13 +92,17 @@ export default function SocialDashboard({
}, []); }, []);
const by = new Map(integs.map((i) => [i.provider, i])); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
eyebrow={`Social · ${tenantLabel}`} eyebrow={`Social · ${tenantLabel}`}
title="Redes sociales" 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 && ( {err && (
@@ -74,95 +110,167 @@ export default function SocialDashboard({
{err} {err}
</p> </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 ? ( {loading ? (
<p className="text-sm text-gray-500 flex items-center gap-2"> <p className="text-sm text-gray-500 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Cargando… <Loader2 className="w-4 h-4 animate-spin" /> Cargando…
</p> </p>
) : ( ) : (
<ul className="grid gap-3 sm:grid-cols-2"> <>
{CHANNELS.map((ch) => { <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
const row = by.get(ch.id); {[
const connected = {
row?.status === 'connected' && Boolean(row.external_account_id); label: 'Seguidores IG',
const Icon = ch.icon; value: n(ig?.followers_count),
const url = icon: Users,
row?.meta?.public_url || },
(row?.external_account_id?.startsWith('http') {
? row.external_account_id label: 'Publicaciones IG',
: null); value: n(ig?.media_count),
return ( icon: ImageIcon,
<li },
key={ch.id} {
className="rounded-xl border border-gray-200 bg-white p-4 space-y-2" 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 justify-between text-gray-600 mb-1">
<div className="flex items-center gap-2"> <p className="text-[10px] font-semibold uppercase tracking-wider">
<Icon className="w-4 h-4 text-gray-700" /> {k.label}
<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> </p>
)} <k.icon className="w-3.5 h-3.5" />
{url && ( </div>
<a <p className="text-xl font-bold text-gray-900 tabular-nums">
href={url} {k.value}
target="_blank" </p>
rel="noreferrer" </div>
className="inline-flex items-center gap-1 text-[11px] text-brand-600 hover:underline" ))}
> </div>
Abrir perfil <ExternalLink className="w-3 h-3" />
</a> {(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> </li>
); <li>
})} Si seguidores IG ≫ fans FB, concentra producción en Reels /
</ul> 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"> <div className="flex flex-wrap gap-2">
<p className="text-sm font-semibold text-gray-900">Próximo en Social</p> <Link
<ul className="text-xs text-gray-600 space-y-1 list-disc pl-4"> href="/dashboard/calendario"
<li>Métricas orgánicas (alcance, engagement) vía Graph API</li> className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
<li>Últimas publicaciones y rendimiento</li> >
<li>Puente con Calendario editorial</li> Calendario editorial
</ul> </Link>
<div className="flex flex-wrap gap-2 pt-1"> {isAgency && (
<Link <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" 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> </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>
</div> </div>
); );

View File

@@ -666,3 +666,25 @@ export type OrganicoWorkspace = {
priorities?: { seo: string[]; geo: string[] }; priorities?: { seo: string[]; geo: string[] };
sectionNotes?: Partial<Record<OrganicoSectionId, 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',
],
};