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:
@@ -10,7 +10,9 @@ import {
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET — list Drive files for active tenant folder (or ?download=fileId). */
|
||||
const FOLDER_MIME = 'application/vnd.google-apps.folder';
|
||||
|
||||
/** GET — list Drive files for tenant folder (browse with ?folderId=). */
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
@@ -28,11 +30,7 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Clients only see own tenant; agency via view cookie already in ctx
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
if (!agency) {
|
||||
// resolveActiveTenant already scopes to home tenant
|
||||
}
|
||||
void isAgencyRole(userRole(user));
|
||||
|
||||
const downloadId = req.nextUrl.searchParams.get('download')?.trim();
|
||||
if (downloadId) {
|
||||
@@ -53,30 +51,47 @@ export async function GET(req: NextRequest) {
|
||||
files: [],
|
||||
linked: false,
|
||||
warning:
|
||||
'Sin carpeta Drive enlazada. Conecta marketing@ en Agencia y ejecuta auto-match.',
|
||||
'Sin carpeta Drive enlazada. Conecta marketing@ en Agencia y ejecuta auto-match bajo «Clientes».',
|
||||
});
|
||||
}
|
||||
|
||||
const requested = req.nextUrl.searchParams.get('folderId')?.trim();
|
||||
const folderId = requested || link.folderId;
|
||||
|
||||
try {
|
||||
const files = await listFilesInFolder(link.folderId);
|
||||
return NextResponse.json({
|
||||
linked: true,
|
||||
folderId: link.folderId,
|
||||
folderName: link.folderName,
|
||||
files: files.map((f) => ({
|
||||
const files = await listFilesInFolder(folderId);
|
||||
const mapped = files
|
||||
.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
bytes: f.size ? Number(f.size) : null,
|
||||
modifiedTime: f.modifiedTime,
|
||||
webViewLink: f.webViewLink,
|
||||
isFolder: f.mimeType === FOLDER_MIME,
|
||||
source: 'drive' as const,
|
||||
})),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, 'es');
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
linked: true,
|
||||
rootFolderId: link.folderId,
|
||||
folderId,
|
||||
folderName: link.folderName,
|
||||
driveOpenUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`,
|
||||
embedPossible: false,
|
||||
embedHint:
|
||||
'Réplica vía API (misma carpeta Clientes del cliente). Embed iframe de Drive exige carpeta pública; usamos listado nativo + Abrir en Drive.',
|
||||
files: mapped,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json({
|
||||
linked: true,
|
||||
folderId: link.folderId,
|
||||
rootFolderId: link.folderId,
|
||||
folderId,
|
||||
folderName: link.folderName,
|
||||
files: [],
|
||||
warning: e instanceof Error ? e.message : 'Drive error',
|
||||
|
||||
@@ -7,14 +7,6 @@ import {
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
AdDailyRow,
|
||||
KpiWeeklyRow,
|
||||
buildChannelSeries,
|
||||
resolveWeeklyRows,
|
||||
sumDaily,
|
||||
sumWeekly,
|
||||
} from '@/lib/metrics';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -33,7 +25,7 @@ export type SeguimientoEntry = {
|
||||
source?: string;
|
||||
};
|
||||
|
||||
/** GET seguimiento timeline + metrics series for active tenant. */
|
||||
/** GET — notas + histórico Superhuman (sin series ads). */
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
@@ -51,10 +43,7 @@ export async function GET() {
|
||||
label: ctx.label,
|
||||
portfolioWide: true,
|
||||
entries: [],
|
||||
weekly: [],
|
||||
daily: [],
|
||||
channelSeries: { weeks: [], channels: [], points: [] },
|
||||
hint: 'Elige un cliente en el selector para ver su seguimiento y evolución.',
|
||||
hint: 'Elige un cliente en el selector para ver su seguimiento.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,78 +53,33 @@ export async function GET() {
|
||||
}
|
||||
|
||||
let entries: SeguimientoEntry[] = [];
|
||||
let weekly: KpiWeeklyRow[] = [];
|
||||
let daily: AdDailyRow[] = [];
|
||||
|
||||
if (agency && ctx.viaView) {
|
||||
const tid = encodeURIComponent(tenantId);
|
||||
const [e, w, d] = await Promise.all([
|
||||
restGet<SeguimientoEntry[]>(
|
||||
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=120`
|
||||
),
|
||||
restGet<KpiWeeklyRow[]>(
|
||||
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc`
|
||||
),
|
||||
restGet<AdDailyRow[]>(
|
||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=5000`
|
||||
),
|
||||
]);
|
||||
const e = await restGet<SeguimientoEntry[]>(
|
||||
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=200`
|
||||
);
|
||||
entries = e.data || [];
|
||||
weekly = w.data || [];
|
||||
daily = d.data || [];
|
||||
} else {
|
||||
const [{ data: e }, { data: w }, { data: d }] = await Promise.all([
|
||||
supabase
|
||||
const { data: e } = await supabase
|
||||
.from('seguimiento_entries')
|
||||
.select('*')
|
||||
.order('occurred_at', { ascending: false, nullsFirst: false })
|
||||
.limit(120),
|
||||
supabase.from('kpi_weekly').select('*').order('week', { ascending: true }),
|
||||
supabase
|
||||
.from('ad_daily')
|
||||
.select('*')
|
||||
.order('date', { ascending: false })
|
||||
.limit(5000),
|
||||
]);
|
||||
.limit(200);
|
||||
entries = (e as SeguimientoEntry[]) || [];
|
||||
weekly = (w as KpiWeeklyRow[]) || [];
|
||||
daily = (d as AdDailyRow[]) || [];
|
||||
}
|
||||
|
||||
weekly = resolveWeeklyRows(weekly, daily);
|
||||
const channelSeries = buildChannelSeries(weekly);
|
||||
const totalsWeekly = sumWeekly(weekly);
|
||||
const totalsDaily = sumDaily(daily);
|
||||
|
||||
return NextResponse.json({
|
||||
label: ctx.label,
|
||||
portfolioWide: false,
|
||||
tenantId,
|
||||
entries,
|
||||
weekly,
|
||||
daily,
|
||||
channelSeries,
|
||||
totals: {
|
||||
spend: totalsWeekly.spend || totalsDaily.spend,
|
||||
leads: totalsWeekly.leads || totalsDaily.leads,
|
||||
cpl: totalsWeekly.cpl || (totalsDaily.leads > 0 ? totalsDaily.spend / totalsDaily.leads : 0),
|
||||
cac: totalsWeekly.cac,
|
||||
revenue: totalsWeekly.revenue,
|
||||
roas: totalsWeekly.roas,
|
||||
impressions: totalsDaily.impressions,
|
||||
clicks: totalsDaily.clicks,
|
||||
},
|
||||
dailySummary: {
|
||||
days: daily.length,
|
||||
spend: totalsDaily.spend,
|
||||
leads: totalsDaily.leads,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST — add a manual note / comentario to seguimiento.
|
||||
* { title?, body, occurred_at? }
|
||||
* { title?, body, occurredAt? | occurred_at? }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
@@ -168,10 +112,10 @@ export async function POST(req: NextRequest) {
|
||||
typeof body.title === 'string' && body.title.trim()
|
||||
? body.title.trim().slice(0, 200)
|
||||
: `Nota · ${new Date().toLocaleDateString('es-ES')}`;
|
||||
const occurredAt =
|
||||
typeof body.occurred_at === 'string' && body.occurred_at
|
||||
? body.occurred_at
|
||||
: new Date().toISOString();
|
||||
const occurredRaw =
|
||||
(typeof body.occurredAt === 'string' && body.occurredAt) ||
|
||||
(typeof body.occurred_at === 'string' && body.occurred_at) ||
|
||||
new Date().toISOString();
|
||||
|
||||
const row = {
|
||||
tenant_id: ctx.tenantId,
|
||||
@@ -181,7 +125,7 @@ export async function POST(req: NextRequest) {
|
||||
title,
|
||||
summary: text.slice(0, 280),
|
||||
body_md: text,
|
||||
occurred_at: occurredAt,
|
||||
occurred_at: occurredRaw,
|
||||
synced_at: new Date().toISOString(),
|
||||
metrics: {},
|
||||
};
|
||||
|
||||
@@ -2,10 +2,49 @@ import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET — social channel integrations for active tenant. */
|
||||
const META = 'https://graph.facebook.com/v21.0';
|
||||
|
||||
type Integ = {
|
||||
provider: string;
|
||||
status: string;
|
||||
external_account_id?: string | null;
|
||||
label?: string | null;
|
||||
last_sync_at?: string | null;
|
||||
meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
async function fetchPageMetrics(token: string, pageId: string) {
|
||||
const fields =
|
||||
'name,fan_count,followers_count,link,picture{url},engagement';
|
||||
const r = await fetch(
|
||||
`${META}/${encodeURIComponent(pageId)}?fields=${fields}&access_token=${encodeURIComponent(token)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) return { error: (await r.text()).slice(0, 160) };
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function fetchIgMetrics(token: string, igId: string) {
|
||||
const r = await fetch(
|
||||
`${META}/${encodeURIComponent(igId)}?fields=username,name,followers_count,follows_count,media_count,profile_picture_url&access_token=${encodeURIComponent(token)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) return { error: (await r.text()).slice(0, 160) };
|
||||
const profile = await r.json();
|
||||
const ins = await fetch(
|
||||
`${META}/${encodeURIComponent(igId)}/insights?metric=reach,impressions,profile_views&period=day&access_token=${encodeURIComponent(token)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
let insights: unknown = null;
|
||||
if (ins.ok) insights = await ins.json();
|
||||
return { ...profile, insights };
|
||||
}
|
||||
|
||||
/** GET — social integrations + live Meta metrics when token allows. */
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
@@ -19,33 +58,59 @@ export async function GET() {
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json({
|
||||
integrations: [],
|
||||
metrics: {},
|
||||
warning: 'Selecciona un cliente en Contexto',
|
||||
});
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const providers = 'instagram,facebook,tiktok,linkedin';
|
||||
let integrations: Integ[] = [];
|
||||
|
||||
if (agency && ctx.viaView) {
|
||||
const tid = encodeURIComponent(ctx.tenantId);
|
||||
const r = await restGet(
|
||||
`/rest/v1/integrations?tenant_id=eq.${tid}&provider=in.(${providers})&select=provider,status,external_account_id,label,last_sync_at,meta,error_message`
|
||||
);
|
||||
return NextResponse.json({
|
||||
integrations: r.data || [],
|
||||
warning: r.error || null,
|
||||
});
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
integrations = (r.data as Integ[]) || [];
|
||||
} else {
|
||||
const { data } = await supabase
|
||||
.from('integrations')
|
||||
.select(
|
||||
'provider,status,external_account_id,label,last_sync_at,meta,error_message'
|
||||
)
|
||||
.in('provider', ['instagram', 'facebook', 'tiktok', 'linkedin']);
|
||||
integrations = (data as Integ[]) || [];
|
||||
}
|
||||
|
||||
const metrics: Record<string, unknown> = {};
|
||||
const token = await resolveMetaAccessToken();
|
||||
if (token) {
|
||||
for (const i of integrations) {
|
||||
if (i.status !== 'connected' || !i.external_account_id) continue;
|
||||
const id = i.external_account_id;
|
||||
if (id.startsWith('http')) continue;
|
||||
try {
|
||||
if (i.provider === 'facebook') {
|
||||
metrics.facebook = await fetchPageMetrics(token, id);
|
||||
}
|
||||
if (i.provider === 'instagram') {
|
||||
metrics.instagram = await fetchIgMetrics(token, id);
|
||||
}
|
||||
} catch (e) {
|
||||
metrics[i.provider] = {
|
||||
error: e instanceof Error ? e.message : 'metrics error',
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
metrics.warning =
|
||||
'Sin token Meta de agencia: perfiles enlazados pero sin métricas live.';
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
integrations: data || [],
|
||||
warning: error?.message || null,
|
||||
integrations,
|
||||
metrics,
|
||||
label: ctx.label,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import DashboardShell from '@/components/DashboardShell';
|
||||
import DashboardProviders from '@/components/DashboardProviders';
|
||||
import {
|
||||
DashboardModuleId,
|
||||
TenantSettings,
|
||||
@@ -80,6 +81,7 @@ export default async function DashboardLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardProviders>
|
||||
<DashboardShell
|
||||
email={email}
|
||||
company={company}
|
||||
@@ -95,5 +97,6 @@ export default async function DashboardLayout({
|
||||
>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
</DashboardProviders>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
RangePreset,
|
||||
normalizePlatform,
|
||||
resolveAdRange,
|
||||
} from '@/lib/ad-tracker-range';
|
||||
import { normalizePlatform } from '@/lib/ad-tracker-range';
|
||||
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
|
||||
import type { AdCreativeCard } from '@/lib/ad-creatives-meta';
|
||||
import {
|
||||
@@ -13,7 +9,7 @@ import {
|
||||
CreativeZoomModal,
|
||||
} from '@/components/AdCreativesGallery';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import DateRangePicker from '@/components/DateRangePicker';
|
||||
import { useDateRange } from '@/components/DateRangeContext';
|
||||
|
||||
type PlatformKpis = {
|
||||
spend: number;
|
||||
@@ -317,9 +313,7 @@ export default function AdTrackerClient({
|
||||
tagline?: string;
|
||||
rows: AdDailyRow[];
|
||||
}) {
|
||||
const [preset, setPreset] = useState<RangePreset>('last_7d');
|
||||
const [customSince, setCustomSince] = useState('');
|
||||
const [customUntil, setCustomUntil] = useState('');
|
||||
const { since, until, label: rangeLabel } = useDateRange();
|
||||
const [tab, setTab] = useState<Tab>('resumen');
|
||||
const [chartMode, setChartMode] = useState<ChartMode>('combined');
|
||||
const [creatives, setCreatives] = useState<AdCreativeCard[]>([]);
|
||||
@@ -327,10 +321,11 @@ export default function AdTrackerClient({
|
||||
const [creativesWarning, setCreativesWarning] = useState<string | null>(null);
|
||||
const [zoomAd, setZoomAd] = useState<AdCreativeCard | null>(null);
|
||||
|
||||
const range = useMemo(
|
||||
() => resolveAdRange(preset, { since: customSince, until: customUntil }),
|
||||
[preset, customSince, customUntil]
|
||||
);
|
||||
const range = useMemo(() => ({ since, until, label: rangeLabel }), [
|
||||
since,
|
||||
until,
|
||||
rangeLabel,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -416,26 +411,7 @@ export default function AdTrackerClient({
|
||||
<PageHeader
|
||||
eyebrow={`Ads · ${label}`}
|
||||
title={tagline || 'Meta + Google Ads'}
|
||||
description={`Gasto, clics, conversiones y preview de creatividades. Ventana: ${range.since} → ${range.until}.`}
|
||||
/>
|
||||
|
||||
{/* Period presets */}
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
customSince={customSince || range.since}
|
||||
customUntil={customUntil || range.until}
|
||||
onPreset={(p) => {
|
||||
setPreset(p);
|
||||
if (p !== 'custom') {
|
||||
const r = resolveAdRange(p);
|
||||
setCustomSince(r.since);
|
||||
setCustomUntil(r.until);
|
||||
}
|
||||
}}
|
||||
onCustomChange={(since, until) => {
|
||||
setCustomSince(since);
|
||||
setCustomUntil(until);
|
||||
}}
|
||||
description={`Gasto, clics, conversiones y creatividades. Periodo del header: ${rangeLabel}.`}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
|
||||
@@ -38,6 +38,7 @@ type DriveFile = {
|
||||
bytes?: number | null;
|
||||
modifiedTime?: string;
|
||||
webViewLink?: string;
|
||||
isFolder?: boolean;
|
||||
};
|
||||
|
||||
function formatBytes(n?: number | null) {
|
||||
@@ -89,6 +90,10 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
|
||||
const [driveFolder, setDriveFolder] = useState<string | null>(null);
|
||||
const [driveOpenUrl, setDriveOpenUrl] = useState<string | null>(null);
|
||||
const [drivePath, setDrivePath] = useState<
|
||||
{ id: string; name: string }[]
|
||||
>([]);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [folder, setFolder] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -121,11 +126,15 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDrive = useCallback(async () => {
|
||||
const res = await fetch('/api/assets/drive');
|
||||
const loadDrive = useCallback(async (folderId?: string) => {
|
||||
const qs = folderId
|
||||
? `?folderId=${encodeURIComponent(folderId)}`
|
||||
: '';
|
||||
const res = await fetch(`/api/assets/drive${qs}`);
|
||||
const data = await res.json();
|
||||
setDriveFiles(data.files || []);
|
||||
setDriveFolder(data.folderName || null);
|
||||
setDriveOpenUrl(data.driveOpenUrl || null);
|
||||
if (data.warning) setWarning(data.warning);
|
||||
else if (data.linked) setWarning(null);
|
||||
}, []);
|
||||
@@ -134,9 +143,12 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
if (tab === 'portal') await loadPortal();
|
||||
else await loadDrive();
|
||||
else {
|
||||
const current = drivePath[drivePath.length - 1]?.id;
|
||||
await loadDrive(current);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [tab, loadPortal, loadDrive]);
|
||||
}, [tab, loadPortal, loadDrive, drivePath]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -190,6 +202,13 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
}
|
||||
|
||||
async function openDrive(file: DriveFile) {
|
||||
if (file.isFolder || file.mimeType === 'application/vnd.google-apps.folder') {
|
||||
setDrivePath((prev) => [...prev, { id: file.id, name: file.name }]);
|
||||
setLoading(true);
|
||||
await loadDrive(file.id);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (file.webViewLink) {
|
||||
window.open(file.webViewLink, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
@@ -205,6 +224,21 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
window.open(data.url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
async function goDriveRoot() {
|
||||
setDrivePath([]);
|
||||
setLoading(true);
|
||||
await loadDrive();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function goDriveCrumb(index: number) {
|
||||
const next = drivePath.slice(0, index + 1);
|
||||
setDrivePath(next);
|
||||
setLoading(true);
|
||||
await loadDrive(next[next.length - 1]?.id);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm('¿Eliminar este archivo de la biblioteca?')) return;
|
||||
const res = await fetch(`/api/assets?id=${encodeURIComponent(id)}`, {
|
||||
@@ -222,7 +256,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
<PageHeader
|
||||
eyebrow="Contenido"
|
||||
title="Activos"
|
||||
description={`Contenido de Drive de ${tenantLabel}. Pestaña Drive por defecto; Portal son archivos subidos al Centro de Mando.`}
|
||||
description={`Réplica de la carpeta Drive del cliente (${tenantLabel}) bajo Clientes. Navega carpetas como en Drive; «Abrir en Drive» para la UI nativa.`}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -462,49 +496,88 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
|
||||
{tab === 'drive' && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void goDriveRoot()}
|
||||
className="font-semibold text-brand-600 hover:underline"
|
||||
>
|
||||
{driveFolder || 'Carpeta cliente'}
|
||||
</button>
|
||||
{drivePath.map((p, i) => (
|
||||
<span key={p.id} className="inline-flex items-center gap-2">
|
||||
<span className="text-gray-400">/</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void goDriveCrumb(i)}
|
||||
className="hover:underline text-gray-800"
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{driveOpenUrl && (
|
||||
<a
|
||||
href={driveOpenUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto inline-flex items-center gap-1 text-brand-600 hover:underline"
|
||||
>
|
||||
Abrir en Google Drive <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Misma carpeta enlazada en Agencia (Clientes → {driveFolder || '…'}).
|
||||
Embed iframe no aplica a carpetas privadas; listamos el contenido vía API.
|
||||
</p>
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500">Cargando Drive…</p>
|
||||
) : driveFiles.length === 0 ? (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center text-gray-400">
|
||||
<HardDrive className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-base font-medium text-gray-600">
|
||||
Sin archivos en Drive
|
||||
Carpeta vacía o sin enlace
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Conecta marketing@ en Agencia y ejecuta auto-match por nombre.
|
||||
Conecta marketing@ en Agencia y ejecuta auto-match por nombre bajo
|
||||
«Clientes».
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{driveFiles.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3"
|
||||
{driveFiles.map((f) => {
|
||||
const folder =
|
||||
f.isFolder ||
|
||||
f.mimeType === 'application/vnd.google-apps.folder';
|
||||
return (
|
||||
<li key={f.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDrive(f)}
|
||||
className="w-full flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3 text-left hover:border-brand-300 hover:bg-brand-50/40"
|
||||
>
|
||||
<span className="text-[10px] font-bold tracking-wider text-gray-500 w-10">
|
||||
{iconHint(f.mimeType)}
|
||||
{folder ? 'DIR' : iconHint(f.mimeType)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-900 font-medium truncate">
|
||||
{f.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{formatBytes(f.bytes)}
|
||||
{folder
|
||||
? 'Carpeta'
|
||||
: formatBytes(f.bytes)}
|
||||
{f.modifiedTime
|
||||
? ` · ${new Date(f.modifiedTime).toLocaleString('es-ES')}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDrive(f)}
|
||||
className="p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-50"
|
||||
title="Abrir en Drive"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<ExternalLink className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
CalendarRange,
|
||||
Users,
|
||||
UserCheck,
|
||||
Percent,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
AdDailyRow,
|
||||
CrmChannelStats,
|
||||
CrmLeadRow,
|
||||
KpiRangeKey,
|
||||
attributionCross,
|
||||
buildCrmChannelStats,
|
||||
buildCrmFunnel,
|
||||
@@ -23,11 +21,9 @@ import {
|
||||
filterLeadsByDayRange,
|
||||
formatEur,
|
||||
formatNum,
|
||||
resolveKpiDayRange,
|
||||
} from '@/lib/metrics';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
|
||||
type RangeKey = KpiRangeKey;
|
||||
import { useDateRange } from '@/components/DateRangeContext';
|
||||
|
||||
function rateClass(rate: number): string {
|
||||
if (rate >= 8) return 'text-emerald-400';
|
||||
@@ -47,21 +43,17 @@ export default function CrmDashboard({
|
||||
crmLeads?: CrmLeadRow[];
|
||||
dailyRows?: AdDailyRow[];
|
||||
}) {
|
||||
const [range, setRange] = useState<RangeKey>('all');
|
||||
|
||||
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
|
||||
const { since, until, label: rangeHint } = useDateRange();
|
||||
|
||||
const rangedLeads = useMemo(() => {
|
||||
if (!crmLeads.length) return [];
|
||||
if (!dayWindow) return crmLeads;
|
||||
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until);
|
||||
}, [crmLeads, dayWindow]);
|
||||
return filterLeadsByDayRange(crmLeads, since, until);
|
||||
}, [crmLeads, since, until]);
|
||||
|
||||
const rangedDaily = useMemo(() => {
|
||||
if (!dailyRows.length) return [];
|
||||
if (!dayWindow) return dailyRows;
|
||||
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
|
||||
}, [dailyRows, dayWindow]);
|
||||
return filterDailyByDayRange(dailyRows, since, until);
|
||||
}, [dailyRows, since, until]);
|
||||
|
||||
const funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]);
|
||||
const statusRows = useMemo(
|
||||
@@ -83,10 +75,6 @@ export default function CrmDashboard({
|
||||
return m;
|
||||
}, [attribution]);
|
||||
|
||||
const rangeHint = dayWindow
|
||||
? `${dayWindow.since} → ${dayWindow.until}`
|
||||
: 'todo el histórico CRM';
|
||||
|
||||
const paidChannels = channelRows.filter((r) =>
|
||||
['meta', 'google'].includes(r.channel)
|
||||
);
|
||||
@@ -99,35 +87,7 @@ export default function CrmDashboard({
|
||||
<PageHeader
|
||||
eyebrow="Rendimiento"
|
||||
title="CRM"
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · ${rangeHint}.`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarRange className="w-4 h-4 text-gray-500" />
|
||||
<div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 bg-gray-50">
|
||||
{(
|
||||
[
|
||||
['7d', '7d'],
|
||||
['30d', '30d'],
|
||||
['month', 'Mes'],
|
||||
['all', 'Todo'],
|
||||
] as [RangeKey, string][]
|
||||
).map(([k, lbl]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setRange(k)}
|
||||
className={`px-2.5 py-1 text-xs font-semibold rounded-md ${
|
||||
range === k
|
||||
? 'bg-brand-500 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
|
||||
/>
|
||||
|
||||
{!crmLeads.length ? (
|
||||
|
||||
12
src/components/DashboardProviders.tsx
Normal file
12
src/components/DashboardProviders.tsx
Normal 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>;
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import TenantSwitcher from '@/components/TenantSwitcher';
|
||||
import type { DashboardModuleId } from '@/lib/tenant-modules';
|
||||
import DateRangePicker from '@/components/DateRangePicker';
|
||||
import { useDateRange } from '@/components/DateRangeContext';
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
@@ -171,6 +173,7 @@ export default function DashboardShell({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = () => setOpen(false);
|
||||
const dateRange = useDateRange();
|
||||
|
||||
const visibleNav = NAV_ITEMS.filter((item) => {
|
||||
if (isAgency) return true;
|
||||
@@ -307,7 +310,8 @@ export default function DashboardShell({
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 h-14 border-b border-gray-200 bg-white/90 backdrop-blur-md px-4 md:px-8 flex items-center gap-3">
|
||||
<header className="sticky top-0 z-30 border-b border-gray-200 bg-white/95 backdrop-blur-md px-4 md:px-8 py-2.5 space-y-2">
|
||||
<div className="flex items-center gap-3 min-h-10">
|
||||
<button
|
||||
type="button"
|
||||
className="md:hidden rounded-lg p-2 text-gray-600 hover:bg-gray-100"
|
||||
@@ -326,6 +330,15 @@ export default function DashboardShell({
|
||||
initialName={viewName || company}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DateRangePicker
|
||||
compact
|
||||
preset={dateRange.preset}
|
||||
customSince={dateRange.customSince}
|
||||
customUntil={dateRange.customUntil}
|
||||
onPreset={dateRange.setPreset}
|
||||
onCustomChange={dateRange.setCustom}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
|
||||
119
src/components/DateRangeContext.tsx
Normal file
119
src/components/DateRangeContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
MousePointerClick,
|
||||
Eye,
|
||||
CalendarRange,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AdDailyRow,
|
||||
@@ -30,13 +28,12 @@ import {
|
||||
filterDealsForRange,
|
||||
filterLeadsByDayRange,
|
||||
metricTotalsFromDaily,
|
||||
resolveKpiDayRange,
|
||||
sumWeekly,
|
||||
withCohortFromDeals,
|
||||
KpiWeeklyRow,
|
||||
KpiRangeKey,
|
||||
} from '@/lib/metrics';
|
||||
import { PageHeader, SegmentedControl } from '@/components/ui';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import { useDateRange } from '@/components/DateRangeContext';
|
||||
|
||||
type Card = {
|
||||
key: keyof MetricTotals;
|
||||
@@ -45,12 +42,10 @@ type Card = {
|
||||
format: (v: number) => string;
|
||||
icon: React.ReactNode;
|
||||
invert?: boolean;
|
||||
/** When true and value is 0 with no deals, show em dash + empty state. */
|
||||
/** Business outcome from Clientify won deals. */
|
||||
cohort?: boolean;
|
||||
};
|
||||
|
||||
type RangeKey = KpiRangeKey;
|
||||
|
||||
function DeltaTriple({
|
||||
delta,
|
||||
invert,
|
||||
@@ -151,65 +146,46 @@ export default function MetricsDashboard({
|
||||
crmLeads?: CrmLeadRow[];
|
||||
wonDeals?: DealWonRow[];
|
||||
}) {
|
||||
const [range, setRange] = useState<RangeKey>('7d');
|
||||
|
||||
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
|
||||
const { since, until, label: rangeHint } = useDateRange();
|
||||
|
||||
const rangedDaily = useMemo(() => {
|
||||
if (!dailyRows.length) return [];
|
||||
if (!dayWindow) return dailyRows;
|
||||
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
|
||||
}, [dailyRows, dayWindow]);
|
||||
return filterDailyByDayRange(dailyRows, since, until);
|
||||
}, [dailyRows, since, until]);
|
||||
|
||||
const rangedLeads = useMemo(() => {
|
||||
if (!crmLeads.length) return [];
|
||||
if (!dayWindow) return crmLeads;
|
||||
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until);
|
||||
}, [crmLeads, dayWindow]);
|
||||
return filterLeadsByDayRange(crmLeads, since, until);
|
||||
}, [crmLeads, since, until]);
|
||||
|
||||
const rangedDeals = useMemo(
|
||||
() =>
|
||||
filterDealsForRange(
|
||||
wonDeals,
|
||||
dayWindow?.since ?? null,
|
||||
dayWindow?.until ?? null
|
||||
),
|
||||
[wonDeals, dayWindow]
|
||||
() => filterDealsForRange(wonDeals, since, until),
|
||||
[wonDeals, since, until]
|
||||
);
|
||||
|
||||
const periodTotals = useMemo(() => {
|
||||
let base: MetricTotals;
|
||||
if (rangedDaily.length) {
|
||||
base = metricTotalsFromDaily(rangedDaily);
|
||||
} else if (!dayWindow) {
|
||||
base = sumWeekly(weeklyRows);
|
||||
} else {
|
||||
const from = dayWindow.since;
|
||||
const until = dayWindow.until;
|
||||
const ranged = weeklyRows.filter((r) => {
|
||||
if (!r.week) return false;
|
||||
const w = r.week.slice(0, 10);
|
||||
return w <= until && w >= from;
|
||||
return w <= until && w >= since;
|
||||
});
|
||||
base = ranged.length ? sumWeekly(ranged) : month;
|
||||
}
|
||||
return withCohortFromDeals(base, rangedDeals);
|
||||
}, [rangedDaily, rangedDeals, weeklyRows, dayWindow, month]);
|
||||
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]);
|
||||
|
||||
const attributionRows: AttributionChannelRow[] = useMemo(() => {
|
||||
if (rangedDaily.length || rangedLeads.length) {
|
||||
return attributionCross(rangedDaily, rangedLeads);
|
||||
}
|
||||
const map = new Map<string, { spend: number; leads: number }>();
|
||||
const src =
|
||||
range === 'all'
|
||||
? channelPoints
|
||||
: channelPoints.filter((p) => {
|
||||
for (const p of channelPoints) {
|
||||
const d = p.week;
|
||||
if (!d || !dayWindow) return true;
|
||||
return d >= dayWindow.since && d <= dayWindow.until;
|
||||
});
|
||||
for (const p of src) {
|
||||
if (!d || d < since || d > until) continue;
|
||||
const cur = map.get(p.channel) || { spend: 0, leads: 0 };
|
||||
cur.spend += p.spend;
|
||||
cur.leads += p.leads;
|
||||
@@ -226,92 +202,100 @@ export default function MetricsDashboard({
|
||||
cplCrm: 0,
|
||||
}))
|
||||
.sort((a, b) => b.spend - a.spend);
|
||||
}, [rangedDaily, rangedLeads, channelPoints, range, dayWindow]);
|
||||
}, [rangedDaily, rangedLeads, channelPoints, since, until]);
|
||||
|
||||
const crmPeriodTotal = rangedLeads.length;
|
||||
const hasCrm = crmLeads.length > 0;
|
||||
const hasCohort = rangedDeals.length > 0;
|
||||
|
||||
const rangeHint = dayWindow
|
||||
? `${dayWindow.since} → ${dayWindow.until}`
|
||||
: 'todo el histórico';
|
||||
const cierres = periodTotals.deals;
|
||||
|
||||
const cards: Card[] = [
|
||||
{
|
||||
key: 'spend',
|
||||
label: 'Inversión ads',
|
||||
hint: 'Paid del periodo',
|
||||
hint: 'Gasto Meta + Google del periodo',
|
||||
format: formatEur,
|
||||
icon: <DollarSign className="w-4 h-4 text-brand-600" />,
|
||||
},
|
||||
{
|
||||
key: 'leads',
|
||||
label: 'Leads',
|
||||
hint: 'Captación periodo',
|
||||
label: 'Leads plataforma',
|
||||
hint: 'Conversiones reportadas por ads',
|
||||
format: formatNum,
|
||||
icon: <Users className="w-4 h-4 text-blue-600" />,
|
||||
},
|
||||
{
|
||||
key: 'cpl',
|
||||
label: 'CPL',
|
||||
hint: 'Coste por lead',
|
||||
label: 'CPL ads',
|
||||
hint: 'Inversión ÷ leads plataforma',
|
||||
format: (v) => `${formatNum(v)} €`,
|
||||
icon: <MousePointerClick className="w-4 h-4 text-violet-600" />,
|
||||
invert: true,
|
||||
},
|
||||
{
|
||||
key: 'cac',
|
||||
label: 'CAC cohorte',
|
||||
key: 'revenue',
|
||||
label: 'Ventas (CRM)',
|
||||
hint: hasCohort
|
||||
? `Paid ÷ ${formatNum(periodTotals.deals)} cierres`
|
||||
: 'Paid ÷ deals ganados Clientify',
|
||||
? `${formatNum(cierres)} deals ganados · importe Clientify`
|
||||
: 'Deals won Clientify en el periodo',
|
||||
format: formatEur,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
|
||||
cohort: true,
|
||||
},
|
||||
{
|
||||
key: 'cac',
|
||||
label: 'CAC',
|
||||
hint: hasCohort
|
||||
? `Inversión ads ÷ ${formatNum(cierres)} ventas`
|
||||
: 'Inversión ÷ deals ganados (no leads)',
|
||||
format: (v) => `${formatNum(v)} €`,
|
||||
icon: <Target className="w-4 h-4 text-amber-600" />,
|
||||
invert: true,
|
||||
cohort: true,
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
label: 'Revenue cohorte',
|
||||
hint: hasCohort
|
||||
? `${formatNum(periodTotals.deals)} deals won`
|
||||
: 'Suma amount deals won',
|
||||
format: formatEur,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
|
||||
cohort: true,
|
||||
},
|
||||
{
|
||||
key: 'roas',
|
||||
label: 'ROAS cohorte',
|
||||
hint: 'Revenue ÷ spend',
|
||||
label: 'ROAS ventas',
|
||||
hint: 'Ventas CRM ÷ inversión ads',
|
||||
format: (v) => `${formatNum(v)}x`,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
|
||||
cohort: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dailyChart = useMemo(() => {
|
||||
const byDay = new Map<string, { spend: number; leads: number }>();
|
||||
for (const r of rangedDaily) {
|
||||
const d = (r.date || '').slice(0, 10);
|
||||
if (!d) continue;
|
||||
const cur = byDay.get(d) || { spend: 0, leads: 0 };
|
||||
cur.spend += Number(r.spend) || 0;
|
||||
cur.leads += Number(r.platform_leads ?? r.conversions) || 0;
|
||||
byDay.set(d, cur);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([date, v]) => ({ date, ...v }));
|
||||
}, [rangedDaily]);
|
||||
|
||||
const maxSpend = Math.max(1, ...dailyChart.map((d) => d.spend));
|
||||
|
||||
const channelInsight = useMemo(() => {
|
||||
if (!attributionRows.length) return null;
|
||||
const paid = attributionRows.filter((r) => r.spend > 0);
|
||||
const top = paid[0] || attributionRows[0];
|
||||
const worstGap = [...attributionRows].sort(
|
||||
(a, b) => Math.abs(b.gap) - Math.abs(a.gap)
|
||||
)[0];
|
||||
return { top, worstGap };
|
||||
}, [attributionRows]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
eyebrow="Rendimiento"
|
||||
title="Métricas"
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (mismos días calendario que Ad Tracker).`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarRange className="w-4 h-4 text-gray-600" />
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
size="sm"
|
||||
options={[
|
||||
{ id: '7d', label: '7d' },
|
||||
{ id: '30d', label: '30d' },
|
||||
{ id: 'month', label: 'Mes' },
|
||||
{ id: 'all', label: 'Todo' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (selector global del header).`}
|
||||
/>
|
||||
|
||||
{sparse && (
|
||||
@@ -323,10 +307,8 @@ export default function MetricsDashboard({
|
||||
|
||||
{!hasCohort && (
|
||||
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2">
|
||||
CAC / Revenue / ROAS a cero: no hay deals ganados con fecha de cierre
|
||||
en este periodo. Se calculan con{' '}
|
||||
<span className="font-semibold">deals Clientify (stage=won)</span>, no
|
||||
solo con leads de ads.
|
||||
Ventas / CAC / ROAS vacíos: no hay deals ganados (Clientify) con
|
||||
fecha de cierre en este periodo. El gasto de ads no cuenta como venta.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -394,29 +376,46 @@ export default function MetricsDashboard({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4 shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
Cruce por canal · ads vs CRM
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
Plataforma = leads reportados por Meta/Google · CRM = atribución
|
||||
Clientify (origen/UTM)
|
||||
{!hasCrm && portfolioWide
|
||||
? ' · elige un cliente en Contexto para ver CRM'
|
||||
: ''}
|
||||
Gasto y leads de plataforma vs leads CRM (como en{' '}
|
||||
<a
|
||||
href="https://mide-analytics-reports.vercel.app/como-combinamos-datos.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand-600 underline"
|
||||
>
|
||||
combinación de datos
|
||||
</a>
|
||||
): el coste vive en ads; el resultado de negocio en CRM/GA.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 shrink-0 font-medium">
|
||||
Gap = CRM − plat
|
||||
</p>
|
||||
</div>
|
||||
{channelInsight && (
|
||||
<p className="text-xs text-gray-800 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
|
||||
<strong>Resumen:</strong> más inversión en{' '}
|
||||
<span className="capitalize font-semibold">
|
||||
{channelInsight.top.channel}
|
||||
</span>{' '}
|
||||
({formatEur(channelInsight.top.spend)}). Mayor desfase ads↔CRM en{' '}
|
||||
<span className="capitalize font-semibold">
|
||||
{channelInsight.worstGap.channel}
|
||||
</span>{' '}
|
||||
(gap {channelInsight.worstGap.gap > 0 ? '+' : ''}
|
||||
{formatNum(channelInsight.worstGap.gap)}).
|
||||
</p>
|
||||
)}
|
||||
{attributionRows.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 py-6 text-center">
|
||||
Sin series de canal en el periodo. Los canales Meta/Google aparecen
|
||||
cuando hay filas en ad_daily para este cliente (elige uno en
|
||||
Contexto).
|
||||
Sin series de canal en el periodo.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -486,49 +485,63 @@ export default function MetricsDashboard({
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between text-gray-700 mb-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||
Ads · ventana diaria
|
||||
</span>
|
||||
<Eye className="w-4 h-4 text-sky-600" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-1">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-600 mb-2">
|
||||
Ads · inversión / día
|
||||
</h4>
|
||||
{dailyChart.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">Sin filas diarias.</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-0.5 h-20 w-full">
|
||||
{dailyChart.map((d) => (
|
||||
<div
|
||||
key={`s-${d.date}`}
|
||||
className="flex-1 min-w-0"
|
||||
title={`${d.date}: ${formatEur(d.spend)}`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-brand-400/80 hover:bg-brand-500"
|
||||
style={{
|
||||
height: `${Math.max(4, (d.spend / maxSpend) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-gray-900 tabular-nums">
|
||||
{formatEur(daily.spend)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{formatNum(daily.leads)} leads · {formatNum(daily.impressions)} impr.
|
||||
· {formatNum(daily.clicks)} clicks
|
||||
</p>
|
||||
<p className="text-xs text-gray-700 mt-2 font-medium">
|
||||
Semana actual: {formatEur(week.spend)} · {formatNum(week.leads)}{' '}
|
||||
leads
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 md:col-span-2 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-700 mb-2">
|
||||
Cómo leer
|
||||
</p>
|
||||
<ul className="text-sm text-gray-700 space-y-1.5 list-disc list-inside">
|
||||
<li>
|
||||
CAC / Revenue / ROAS usan deals ganados de Clientify (
|
||||
<span className="font-semibold">won_at</span> en el periodo), no
|
||||
leads de Meta/Google.
|
||||
</li>
|
||||
<li>
|
||||
Gap cercano a 0 en Meta/Google = buena atribución. Canales sin
|
||||
spend son orgánicos/CRM.
|
||||
</li>
|
||||
<li>
|
||||
CPL CRM = inversión paid ÷ leads CRM del canal (más realista
|
||||
cuando hay desfase con la plataforma).
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<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-[10px] text-gray-500">
|
||||
Barras compactas por día · periodo del header · detalle en Ads
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
formatPct,
|
||||
groupsForPillar,
|
||||
sumOrganic,
|
||||
DEFAULT_EXPERT_PRIORITIES,
|
||||
} from '@/lib/organic';
|
||||
import { isoYesterday } from '@/lib/ad-tracker-range';
|
||||
|
||||
@@ -156,11 +157,40 @@ export default function OrganicoDashboard({
|
||||
setSectionNote(workspace.sectionNotes?.[sectionId] || '');
|
||||
const list =
|
||||
pillar === 'geo'
|
||||
? workspace.priorities?.geo || []
|
||||
: workspace.priorities?.seo || [];
|
||||
? workspace.priorities?.geo?.length
|
||||
? workspace.priorities.geo
|
||||
: DEFAULT_EXPERT_PRIORITIES.geo
|
||||
: workspace.priorities?.seo?.length
|
||||
? workspace.priorities.seo
|
||||
: DEFAULT_EXPERT_PRIORITIES.seo;
|
||||
setPrioritiesText(list.join('\n'));
|
||||
}, [sectionId, pillar, workspace]);
|
||||
|
||||
const displayPriorities = useMemo(() => {
|
||||
const saved =
|
||||
pillar === 'geo'
|
||||
? workspace.priorities?.geo
|
||||
: workspace.priorities?.seo;
|
||||
if (saved?.length) return saved;
|
||||
return pillar === 'geo'
|
||||
? DEFAULT_EXPERT_PRIORITIES.geo
|
||||
: DEFAULT_EXPERT_PRIORITIES.seo;
|
||||
}, [pillar, workspace.priorities]);
|
||||
|
||||
const seoDaily = useMemo(() => {
|
||||
return [...organic]
|
||||
.filter((r) => r.day)
|
||||
.sort((a, b) => String(a.day).localeCompare(String(b.day)))
|
||||
.slice(-28)
|
||||
.map((r) => ({
|
||||
day: String(r.day).slice(0, 10),
|
||||
clicks: Number(r.gsc_clicks) || 0,
|
||||
sessions: Number(r.ga4_organic_sessions) || 0,
|
||||
}));
|
||||
}, [organic]);
|
||||
const maxClicks = Math.max(1, ...seoDaily.map((d) => d.clicks));
|
||||
const maxSessions = Math.max(1, ...seoDaily.map((d) => d.sessions));
|
||||
|
||||
const section = useMemo(
|
||||
() => ORGANICO_SECTIONS.find((s) => s.id === sectionId)!,
|
||||
[sectionId]
|
||||
@@ -507,6 +537,7 @@ export default function OrganicoDashboard({
|
||||
|
||||
{/* Live panels by section.live */}
|
||||
{section.live === 'seo-metrics' && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Kpi label="Clics GSC" value={formatNum(totals.gscClicks)} />
|
||||
<Kpi
|
||||
@@ -538,6 +569,56 @@ export default function OrganicoDashboard({
|
||||
hint="Serie cargada"
|
||||
/>
|
||||
</div>
|
||||
{seoDaily.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
Clics GSC · 28d
|
||||
</p>
|
||||
<div className="flex items-end gap-0.5 h-16">
|
||||
{seoDaily.map((d) => (
|
||||
<div
|
||||
key={`c-${d.day}`}
|
||||
className="flex-1"
|
||||
title={`${d.day}: ${d.clicks} clics`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-emerald-400/80"
|
||||
style={{
|
||||
height: `${Math.max(3, (d.clicks / maxClicks) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
Sesiones orgánicas GA4 · 28d
|
||||
</p>
|
||||
<div className="flex items-end gap-0.5 h-16">
|
||||
{seoDaily.map((d) => (
|
||||
<div
|
||||
key={`s-${d.day}`}
|
||||
className="flex-1"
|
||||
title={`${d.day}: ${d.sessions} sesiones`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-sky-400/80"
|
||||
style={{
|
||||
height: `${Math.max(3, (d.sessions / maxSessions) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{totals.days === 0 && (
|
||||
<EmptyHint text="GSC/GA4 conectados pero sin filas en organic_daily. Lanza sync en Agencia o revisa property/URL-prefix." />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{section.live === 'geo-metrics' && latestGeo && (
|
||||
@@ -614,7 +695,27 @@ export default function OrganicoDashboard({
|
||||
)}
|
||||
|
||||
{section.live === 'geo-metrics' && !latestGeo && (
|
||||
<EmptyHint text="Sin medición GEO aún. Registra Share of Model y motores para activar el panel." />
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 space-y-2">
|
||||
<p className="text-sm font-semibold text-amber-900">
|
||||
GEO sin medición aún
|
||||
</p>
|
||||
<p className="text-xs text-amber-800">
|
||||
A diferencia de GSC/GA4, Share of Model no llega por API
|
||||
automática: registra un snapshot (prompts × motores) para
|
||||
activar el dashboard. Mientras tanto, trabaja las prioridades
|
||||
expertas de la derecha.
|
||||
</p>
|
||||
{isAgency && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCapture(true)}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-amber-800 text-white"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Registrar medición GEO
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.live === 'geo-competitors' && (
|
||||
@@ -693,27 +794,29 @@ export default function OrganicoDashboard({
|
||||
)}
|
||||
Guardar prioridades
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
const def =
|
||||
pillar === 'geo'
|
||||
? DEFAULT_EXPERT_PRIORITIES.geo
|
||||
: DEFAULT_EXPERT_PRIORITIES.seo;
|
||||
setPrioritiesText(def.join('\n'));
|
||||
}}
|
||||
className="ml-2 inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Plantilla experto
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<ul className="text-sm text-gray-800 space-y-1">
|
||||
{(pillar === 'geo'
|
||||
? workspace.priorities?.geo
|
||||
: workspace.priorities?.seo
|
||||
)?.length ? (
|
||||
(pillar === 'geo'
|
||||
? workspace.priorities!.geo
|
||||
: workspace.priorities!.seo
|
||||
).map((p) => (
|
||||
{displayPriorities.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>
|
||||
)}
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Seguimiento = notas + histórico (Superhuman + manual).
|
||||
* Ads/KPIs viven en Métricas.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ExternalLink,
|
||||
LineChart as LineIcon,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Radio,
|
||||
Send,
|
||||
CalendarRange,
|
||||
DollarSign,
|
||||
Users,
|
||||
MousePointerClick,
|
||||
Target,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AdDailyRow,
|
||||
ChannelWeekPoint,
|
||||
KpiWeeklyRow,
|
||||
formatEur,
|
||||
formatNum,
|
||||
sumDaily,
|
||||
sumWeekly,
|
||||
} from '@/lib/metrics';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import DateRangePicker from '@/components/DateRangePicker';
|
||||
import {
|
||||
RangePreset,
|
||||
resolveAdRange,
|
||||
} from '@/lib/ad-tracker-range';
|
||||
import { useDateRange } from '@/components/DateRangeContext';
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -38,12 +23,6 @@ type Entry = {
|
||||
summary?: string | null;
|
||||
body_md?: string | null;
|
||||
browser_link?: string | null;
|
||||
metrics?: {
|
||||
ventas?: number;
|
||||
cpl?: number;
|
||||
cac?: number;
|
||||
periodo?: string;
|
||||
};
|
||||
occurred_at?: string | null;
|
||||
source?: string;
|
||||
};
|
||||
@@ -51,30 +30,11 @@ type Entry = {
|
||||
type Payload = {
|
||||
label: string;
|
||||
portfolioWide?: boolean;
|
||||
tenantId?: string;
|
||||
hint?: string;
|
||||
entries: Entry[];
|
||||
weekly?: KpiWeeklyRow[];
|
||||
daily?: AdDailyRow[];
|
||||
channelSeries: {
|
||||
weeks: string[];
|
||||
channels: string[];
|
||||
points: ChannelWeekPoint[];
|
||||
};
|
||||
dailySummary?: { days: number; spend: number; leads: number };
|
||||
};
|
||||
|
||||
type MetricKey = 'leads' | 'spend' | 'cpl';
|
||||
|
||||
const CHANNEL_COLORS = [
|
||||
'#E58A3C',
|
||||
'#38BDF8',
|
||||
'#34D399',
|
||||
'#A78BFA',
|
||||
'#F472B6',
|
||||
'#FBBF24',
|
||||
'#94A3B8',
|
||||
];
|
||||
|
||||
function typeLabel(t: string) {
|
||||
if (t === 'meeting') return 'Reunión';
|
||||
if (t === 'kpis') return 'KPIs';
|
||||
@@ -83,204 +43,41 @@ function typeLabel(t: string) {
|
||||
}
|
||||
|
||||
function typeTone(t: string) {
|
||||
if (t === 'meeting') return 'bg-amber-500/15 text-amber-300 border-amber-500/30';
|
||||
if (t === 'kpis') return 'bg-sky-500/15 text-sky-300 border-sky-500/30';
|
||||
if (t === 'topic') return 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30';
|
||||
return 'bg-violet-500/15 text-violet-300 border-violet-500/30';
|
||||
if (t === 'meeting') return 'bg-amber-50 text-amber-800 border-amber-200';
|
||||
if (t === 'kpis') return 'bg-sky-50 text-sky-800 border-sky-200';
|
||||
if (t === 'topic') return 'bg-emerald-50 text-emerald-800 border-emerald-200';
|
||||
return 'bg-violet-50 text-violet-800 border-violet-200';
|
||||
}
|
||||
|
||||
function inRange(isoDate: string | undefined | null, since: string, until: string) {
|
||||
if (!isoDate) return false;
|
||||
if (!isoDate) return true; // keep undated notes visible
|
||||
const d = isoDate.slice(0, 10);
|
||||
return d >= since && d <= until;
|
||||
}
|
||||
|
||||
function ChannelEvolutionChart({
|
||||
weeks,
|
||||
channels,
|
||||
points,
|
||||
metric,
|
||||
activeChannels,
|
||||
}: {
|
||||
weeks: string[];
|
||||
channels: string[];
|
||||
points: ChannelWeekPoint[];
|
||||
metric: MetricKey;
|
||||
activeChannels: Set<string>;
|
||||
}) {
|
||||
const [hover, setHover] = useState<{
|
||||
week: string;
|
||||
channel: string;
|
||||
value: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const width = 720;
|
||||
const height = 260;
|
||||
const pad = { t: 16, r: 16, b: 36, l: 44 };
|
||||
const innerW = width - pad.l - pad.r;
|
||||
const innerH = height - pad.t - pad.b;
|
||||
|
||||
const series = useMemo(() => {
|
||||
return channels
|
||||
.filter((c) => activeChannels.has(c))
|
||||
.map((channel, i) => {
|
||||
const vals = weeks.map((week) => {
|
||||
const p = points.find((x) => x.week === week && x.channel === channel);
|
||||
return p ? Number(p[metric] || 0) : 0;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
color: CHANNEL_COLORS[i % CHANNEL_COLORS.length],
|
||||
vals,
|
||||
};
|
||||
});
|
||||
}, [channels, activeChannels, weeks, points, metric]);
|
||||
|
||||
const maxY = Math.max(1, ...series.flatMap((s) => s.vals));
|
||||
const xAt = (i: number) =>
|
||||
pad.l + (weeks.length <= 1 ? innerW / 2 : (i / (weeks.length - 1)) * innerW);
|
||||
const yAt = (v: number) => pad.t + innerH - (v / maxY) * innerH;
|
||||
|
||||
if (!weeks.length || !series.length) {
|
||||
return (
|
||||
<div className="h-[260px] flex items-center justify-center text-sm text-gray-500 border border-dashed border-gray-200 rounded-xl">
|
||||
Sin serie en el rango seleccionado.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full min-w-[520px] h-auto"
|
||||
role="img"
|
||||
aria-label={`Evolución ${metric} por canal`}
|
||||
>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
|
||||
const y = pad.t + innerH * (1 - t);
|
||||
const val = maxY * t;
|
||||
return (
|
||||
<g key={t}>
|
||||
<line
|
||||
x1={pad.l}
|
||||
x2={width - pad.r}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="#1F2937"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={pad.l - 8}
|
||||
y={y + 3}
|
||||
textAnchor="end"
|
||||
className="fill-gray-500"
|
||||
fontSize={10}
|
||||
>
|
||||
{formatNum(val)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{series.map((s) => {
|
||||
const d = s.vals
|
||||
.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xAt(i)} ${yAt(v)}`)
|
||||
.join(' ');
|
||||
return (
|
||||
<g key={s.channel}>
|
||||
<path d={d} fill="none" stroke={s.color} strokeWidth={2.5} />
|
||||
{s.vals.map((v, i) => (
|
||||
<circle
|
||||
key={`${s.channel}-${weeks[i]}`}
|
||||
cx={xAt(i)}
|
||||
cy={yAt(v)}
|
||||
r={
|
||||
hover?.week === weeks[i] && hover?.channel === s.channel
|
||||
? 5
|
||||
: 3.5
|
||||
}
|
||||
fill={s.color}
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() =>
|
||||
setHover({
|
||||
week: weeks[i],
|
||||
channel: s.channel,
|
||||
value: v,
|
||||
x: xAt(i),
|
||||
y: yAt(v),
|
||||
})
|
||||
}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{weeks.map((w, i) => (
|
||||
<text
|
||||
key={w}
|
||||
x={xAt(i)}
|
||||
y={height - 10}
|
||||
textAnchor="middle"
|
||||
className="fill-gray-500"
|
||||
fontSize={9}
|
||||
>
|
||||
{w.slice(5)}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
{hover && (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-[11px] text-gray-900 shadow-xl"
|
||||
style={{
|
||||
left: Math.min(hover.x + 8, 560),
|
||||
top: Math.max(hover.y - 36, 8),
|
||||
}}
|
||||
>
|
||||
<p className="font-semibold capitalize">{hover.channel}</p>
|
||||
<p className="text-gray-400">{hover.week}</p>
|
||||
<p>
|
||||
{metric === 'spend'
|
||||
? formatEur(hover.value)
|
||||
: metric === 'cpl'
|
||||
? `${formatNum(hover.value)} € CPL`
|
||||
: `${formatNum(hover.value)} leads`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SeguimientoBoard() {
|
||||
const { since, until, label: rangeLabel } = useDateRange();
|
||||
const [data, setData] = useState<Payload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [metric, setMetric] = useState<MetricKey>('leads');
|
||||
const [activeChannels, setActiveChannels] = useState<Set<string>>(new Set());
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [preset, setPreset] = useState<RangePreset>('last_30d');
|
||||
const [customSince, setCustomSince] = useState('');
|
||||
const [customUntil, setCustomUntil] = useState('');
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [noteTitle, setNoteTitle] = useState('');
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
const [noteBusy, setNoteBusy] = useState(false);
|
||||
const [noteMsg, setNoteMsg] = useState<string | null>(null);
|
||||
const [syncBusy, setSyncBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await fetch('/api/seguimiento', { cache: 'no-store' });
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.error || 'Error cargando seguimiento');
|
||||
setData(j);
|
||||
setActiveChannels(new Set(j.channelSeries?.channels || []));
|
||||
const res = await fetch('/api/seguimiento', { cache: 'no-store' });
|
||||
const json = (await res.json()) as Payload & { error?: string };
|
||||
if (!res.ok) throw new Error(json.error || 'Error');
|
||||
setData(json);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error');
|
||||
setErr(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -290,110 +87,94 @@ export default function SeguimientoBoard() {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const range = useMemo(
|
||||
() => resolveAdRange(preset, { since: customSince, until: customUntil }),
|
||||
[preset, customSince, customUntil]
|
||||
);
|
||||
|
||||
const rangedWeekly = useMemo(() => {
|
||||
const rows = data?.weekly || [];
|
||||
return rows.filter((r) => inRange(r.week, range.since, range.until));
|
||||
}, [data?.weekly, range.since, range.until]);
|
||||
|
||||
const rangedDaily = useMemo(() => {
|
||||
const rows = data?.daily || [];
|
||||
return rows.filter((r) => inRange(r.date, range.since, range.until));
|
||||
}, [data?.daily, range.since, range.until]);
|
||||
|
||||
const periodTotals = useMemo(() => {
|
||||
const w = sumWeekly(rangedWeekly);
|
||||
const d = sumDaily(rangedDaily);
|
||||
const spend = w.spend || d.spend;
|
||||
const leads = w.leads || d.leads;
|
||||
return {
|
||||
spend,
|
||||
leads,
|
||||
cpl: leads > 0 ? spend / leads : w.cpl,
|
||||
cac: w.cac,
|
||||
revenue: w.revenue,
|
||||
roas: w.roas,
|
||||
impressions: d.impressions,
|
||||
clicks: d.clicks,
|
||||
};
|
||||
}, [rangedWeekly, rangedDaily]);
|
||||
|
||||
const rangedChannel = useMemo(() => {
|
||||
const points = (data?.channelSeries.points || []).filter((p) =>
|
||||
inRange(p.week, range.since, range.until)
|
||||
);
|
||||
const weeks = [...new Set(points.map((p) => p.week))].sort();
|
||||
const channels = data?.channelSeries.channels || [];
|
||||
return { weeks, channels, points };
|
||||
}, [data?.channelSeries, range.since, range.until]);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const list = (data?.entries || []).filter((e) => {
|
||||
if (!e.occurred_at) return true;
|
||||
return inRange(e.occurred_at, range.since, range.until);
|
||||
});
|
||||
const entries = data?.entries || [];
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter(
|
||||
(e) =>
|
||||
e.title.toLowerCase().includes(q) ||
|
||||
(e.summary || '').toLowerCase().includes(q) ||
|
||||
(e.body_md || '').toLowerCase().includes(q)
|
||||
);
|
||||
}, [data, query, range.since, range.until]);
|
||||
|
||||
function toggleChannel(ch: string) {
|
||||
setActiveChannels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ch)) next.delete(ch);
|
||||
else next.add(ch);
|
||||
return next;
|
||||
return entries.filter((e) => {
|
||||
if (!inRange(e.occurred_at, since, until)) return false;
|
||||
if (!q) return true;
|
||||
const blob = `${e.title} ${e.summary || ''} ${e.body_md || ''}`.toLowerCase();
|
||||
return blob.includes(q);
|
||||
});
|
||||
}
|
||||
}, [data, query, since, until]);
|
||||
|
||||
async function saveNote() {
|
||||
if (!noteBody.trim()) return;
|
||||
setNoteBusy(true);
|
||||
setNoteMsg(null);
|
||||
try {
|
||||
const r = await fetch('/api/seguimiento', {
|
||||
const res = await fetch('/api/seguimiento', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: noteTitle.trim() || undefined,
|
||||
title: noteTitle.trim() || 'Nota de seguimiento',
|
||||
body: noteBody.trim(),
|
||||
occurredAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) {
|
||||
setNoteMsg(j.error || 'No se pudo guardar');
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || 'No se pudo guardar');
|
||||
setNoteTitle('');
|
||||
setNoteBody('');
|
||||
setNoteMsg('Nota añadida al seguimiento');
|
||||
setNoteMsg('Nota guardada');
|
||||
await load();
|
||||
} catch (e) {
|
||||
setNoteMsg(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setNoteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
async function syncSuperhuman() {
|
||||
if (!data?.tenantId) return;
|
||||
setSyncBusy(true);
|
||||
setNoteMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/superhuman/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tenantId: data.tenantId,
|
||||
exportBodies: true,
|
||||
maxEntries: 80,
|
||||
}),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json.error || 'Sync falló');
|
||||
const first = Array.isArray(json.results) ? json.results[0] : null;
|
||||
const n =
|
||||
typeof first?.upserted === 'number'
|
||||
? first.upserted
|
||||
: typeof json.upserted === 'number'
|
||||
? json.upserted
|
||||
: null;
|
||||
setNoteMsg(
|
||||
n != null
|
||||
? `Superhuman: ${n} entradas sincronizadas`
|
||||
: first?.status === 'unmatched'
|
||||
? 'Sin doc Superhuman emparejado por nombre'
|
||||
: 'Superhuman sincronizado'
|
||||
);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setNoteMsg(e instanceof Error ? e.message : 'Error sync');
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 py-16 justify-center">
|
||||
<p className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Cargando seguimiento…
|
||||
</div>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (err) {
|
||||
return (
|
||||
<p className="text-sm text-red-400 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
<p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
|
||||
{err}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -402,210 +183,76 @@ export default function SeguimientoBoard() {
|
||||
|
||||
if (data.portfolioWide) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center">
|
||||
<Radio className="w-8 h-8 text-brand-500 mx-auto mb-3" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Seguimiento</h2>
|
||||
<p className="text-sm text-gray-400 mt-2 max-w-md mx-auto">
|
||||
<p className="text-sm text-gray-600 border border-gray-200 rounded-xl p-6 bg-white">
|
||||
{data.hint ||
|
||||
'Selecciona un cliente en el encabezado para ver la evolución y las notas.'}
|
||||
'Elige un cliente en Contexto para ver su seguimiento.'}
|
||||
</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 (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="Rendimiento"
|
||||
eyebrow="Operación"
|
||||
title="Seguimiento"
|
||||
description={`${data.label} · mismas KPIs que Métricas, filtrables por periodo + notas del equipo`}
|
||||
description={`${data.label} · notas e histórico del equipo (periodo ${rangeLabel}). Ads y KPIs están en Métricas.`}
|
||||
/>
|
||||
|
||||
{/* Date selector */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<CalendarRange className="w-3.5 h-3.5 text-sky-500" />
|
||||
Periodo
|
||||
</div>
|
||||
<DateRangePicker
|
||||
preset={preset}
|
||||
customSince={customSince || range.since}
|
||||
customUntil={customUntil || range.until}
|
||||
onPreset={(p) => {
|
||||
setPreset(p);
|
||||
if (p !== 'custom') {
|
||||
const r = resolveAdRange(p);
|
||||
setCustomSince(r.since);
|
||||
setCustomUntil(r.until);
|
||||
}
|
||||
}}
|
||||
onCustomChange={(since, until) => {
|
||||
setCustomSince(since);
|
||||
setCustomUntil(until);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* KPI cards (Métricas-like) */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
{cards.map((c) => (
|
||||
<div
|
||||
key={c.label}
|
||||
className="rounded-xl border border-gray-200 bg-white px-3 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-[10px] uppercase tracking-wider text-gray-500">
|
||||
{c.label}
|
||||
</p>
|
||||
{c.icon}
|
||||
</div>
|
||||
<p className="text-xl font-bold text-gray-900 tabular-nums">{c.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Channel evolution */}
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-gray-900 font-medium">
|
||||
<LineIcon className="w-4 h-4 text-brand-500" />
|
||||
Evolución por canal
|
||||
</div>
|
||||
<div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 bg-gray-50">
|
||||
{(
|
||||
[
|
||||
['leads', 'Leads'],
|
||||
['spend', 'Inversión'],
|
||||
['cpl', 'CPL'],
|
||||
] as [MetricKey, string][]
|
||||
).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setMetric(k)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
||||
metric === k
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rangedChannel.channels.map((ch, i) => {
|
||||
const on = activeChannels.has(ch);
|
||||
const color = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
||||
return (
|
||||
<button
|
||||
key={ch}
|
||||
type="button"
|
||||
onClick={() => toggleChannel(ch)}
|
||||
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-all capitalize ${
|
||||
on
|
||||
? 'border-gray-600 text-gray-900 bg-gray-900'
|
||||
: 'border-gray-200 text-gray-500 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ background: on ? color : '#4B5563' }}
|
||||
/>
|
||||
{ch}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ChannelEvolutionChart
|
||||
weeks={rangedChannel.weeks}
|
||||
channels={rangedChannel.channels}
|
||||
points={rangedChannel.points}
|
||||
metric={metric}
|
||||
activeChannels={activeChannels}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Notes box */}
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 space-y-3">
|
||||
<div className="flex items-center gap-2 text-gray-900 font-medium">
|
||||
<MessageSquareText className="w-4 h-4 text-violet-400" />
|
||||
Comentarios y notas
|
||||
<MessageSquareText className="w-4 h-4 text-violet-500" />
|
||||
Nueva nota
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Añade observaciones del periodo (reunión, hipótesis, bloqueos,
|
||||
siguientes pasos…). Quedan en el timeline de seguimiento.
|
||||
Se guarda con fecha. El histórico también incluye entradas
|
||||
sincronizadas desde Superhuman Docs (docs de seguimiento).
|
||||
</p>
|
||||
<input
|
||||
value={noteTitle}
|
||||
onChange={(e) => setNoteTitle(e.target.value)}
|
||||
placeholder="Título (opcional)"
|
||||
className="w-full rounded-lg bg-black/30 border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-600"
|
||||
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900"
|
||||
/>
|
||||
<textarea
|
||||
value={noteBody}
|
||||
onChange={(e) => setNoteBody(e.target.value)}
|
||||
placeholder="Escribe aquí comentarios, notas o cualquier cosa relevante…"
|
||||
className="w-full min-h-[120px] rounded-lg bg-black/30 border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-600"
|
||||
placeholder="Comentarios, hipótesis, bloqueos, siguientes pasos…"
|
||||
className="w-full min-h-[120px] rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={noteBusy || !noteBody.trim()}
|
||||
onClick={saveNote}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-violet-500/20 border border-violet-500/40 text-violet-200 px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
onClick={() => void saveNote()}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-brand-500 text-white px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
{noteBusy ? 'Guardando…' : 'Añadir al seguimiento'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={syncBusy || !data.tenantId}
|
||||
onClick={() => void syncSuperhuman()}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{syncBusy ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Radio className="w-3.5 h-3.5 text-emerald-600" />
|
||||
)}
|
||||
Traer notas Superhuman
|
||||
</button>
|
||||
{noteMsg ? (
|
||||
<p className="text-xs text-emerald-400">{noteMsg}</p>
|
||||
<p className="text-xs text-emerald-700">{noteMsg}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Timeline */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-gray-900 font-medium">
|
||||
<Radio className="w-4 h-4 text-emerald-400" />
|
||||
Timeline seguimiento
|
||||
<Radio className="w-4 h-4 text-emerald-500" />
|
||||
Histórico
|
||||
<span className="text-xs text-gray-500 font-normal">
|
||||
({filteredEntries.length})
|
||||
</span>
|
||||
@@ -613,15 +260,15 @@ export default function SeguimientoBoard() {
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filtrar temas, CPL, notas…"
|
||||
className="w-full sm:w-64 rounded-lg bg-black/30 border border-gray-200 px-3 py-1.5 text-sm text-gray-900 placeholder:text-gray-600 focus:outline-none focus:border-brand-500/50"
|
||||
placeholder="Filtrar…"
|
||||
className="w-full sm:w-64 rounded-lg border border-gray-200 px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredEntries.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-gray-200 px-4 py-10 text-center text-sm text-gray-500">
|
||||
Sin entradas en este periodo. Añade una nota arriba o sincroniza
|
||||
Superhuman Docs desde Agencia.
|
||||
Sin notas en este periodo. Añade una arriba o sincroniza Superhuman
|
||||
desde Agencia (docs «seguimiento»).
|
||||
</div>
|
||||
) : (
|
||||
<ol className="relative border-l border-gray-200 ml-3 space-y-4">
|
||||
@@ -636,11 +283,11 @@ export default function SeguimientoBoard() {
|
||||
: '—';
|
||||
return (
|
||||
<li key={e.id} className="ml-4">
|
||||
<span className="absolute -left-1.5 mt-1.5 w-3 h-3 rounded-full bg-brand-500 ring-4 ring-[#0B0F19]" />
|
||||
<span className="absolute -left-1.5 mt-1.5 w-3 h-3 rounded-full bg-brand-500 ring-4 ring-white" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenId(open ? null : e.id)}
|
||||
className="w-full text-left rounded-xl border border-gray-200 bg-white hover:border-gray-200 transition-colors p-4"
|
||||
className="w-full text-left rounded-xl border border-gray-200 bg-white hover:border-gray-300 p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
@@ -651,12 +298,17 @@ export default function SeguimientoBoard() {
|
||||
{typeLabel(e.entry_type)}
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-500">{when}</span>
|
||||
{e.source === 'superhuman' || e.browser_link ? (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
Superhuman
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{e.title}
|
||||
</p>
|
||||
{e.summary && (
|
||||
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
|
||||
{e.summary}
|
||||
</p>
|
||||
)}
|
||||
@@ -667,39 +319,17 @@ export default function SeguimientoBoard() {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-brand-400 hover:text-brand-700"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-brand-600"
|
||||
>
|
||||
Superhuman <ExternalLink className="w-3 h-3" />
|
||||
Abrir <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && e.body_md && (
|
||||
<pre className="mt-3 whitespace-pre-wrap text-xs text-gray-600 bg-black/30 border border-gray-200 rounded-lg p-3 max-h-64 overflow-y-auto font-sans">
|
||||
<pre className="mt-3 whitespace-pre-wrap text-xs text-gray-700 bg-gray-50 border border-gray-100 rounded-lg p-3 max-h-64 overflow-y-auto font-sans">
|
||||
{e.body_md}
|
||||
</pre>
|
||||
)}
|
||||
{open &&
|
||||
e.metrics &&
|
||||
(e.metrics.ventas || e.metrics.cpl) && (
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[11px]">
|
||||
{e.metrics.ventas != null && (
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-300">
|
||||
Ventas {e.metrics.ventas}
|
||||
</span>
|
||||
)}
|
||||
{e.metrics.cpl != null && (
|
||||
<span className="px-2 py-0.5 rounded bg-sky-500/10 text-sky-300">
|
||||
CPL ≈ {formatNum(e.metrics.cpl)} €
|
||||
</span>
|
||||
)}
|
||||
{e.metrics.cac != null && (
|
||||
<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-300">
|
||||
CAC ≈ {formatNum(e.metrics.cac)} €
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
Loader2,
|
||||
Share2,
|
||||
ExternalLink,
|
||||
Users,
|
||||
Eye,
|
||||
Image as ImageIcon,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
|
||||
@@ -17,10 +20,26 @@ type Integ = {
|
||||
status: string;
|
||||
external_account_id?: string | null;
|
||||
label?: string | null;
|
||||
last_sync_at?: string | null;
|
||||
meta?: { public_url?: string; account_name?: string; warning?: string } | null;
|
||||
};
|
||||
|
||||
type MetricsFacebook = {
|
||||
name?: string;
|
||||
fan_count?: number;
|
||||
followers_count?: number;
|
||||
link?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type MetricsIg = {
|
||||
username?: string;
|
||||
followers_count?: number;
|
||||
media_count?: number;
|
||||
follows_count?: number;
|
||||
error?: string;
|
||||
insights?: { data?: { name?: string; values?: { value?: number }[] }[] };
|
||||
};
|
||||
|
||||
const CHANNELS: {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -32,6 +51,11 @@ const CHANNELS: {
|
||||
{ id: 'tiktok', label: 'TikTok', icon: Share2 },
|
||||
];
|
||||
|
||||
function n(v?: number) {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return new Intl.NumberFormat('es-ES').format(v);
|
||||
}
|
||||
|
||||
export default function SocialDashboard({
|
||||
tenantLabel,
|
||||
isAgency,
|
||||
@@ -41,6 +65,11 @@ export default function SocialDashboard({
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [integs, setIntegs] = useState<Integ[]>([]);
|
||||
const [metrics, setMetrics] = useState<{
|
||||
facebook?: MetricsFacebook;
|
||||
instagram?: MetricsIg;
|
||||
warning?: string;
|
||||
}>({});
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,7 +80,10 @@ export default function SocialDashboard({
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (cancelled) return;
|
||||
if (!res.ok) setErr(data.error || 'No se pudo cargar Social');
|
||||
else setIntegs(data.integrations || []);
|
||||
else {
|
||||
setIntegs(data.integrations || []);
|
||||
setMetrics(data.metrics || {});
|
||||
}
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => {
|
||||
@@ -60,13 +92,17 @@ export default function SocialDashboard({
|
||||
}, []);
|
||||
|
||||
const by = new Map(integs.map((i) => [i.provider, i]));
|
||||
const ig = metrics.instagram;
|
||||
const fb = metrics.facebook;
|
||||
const igReach =
|
||||
ig?.insights?.data?.find((d) => d.name === 'reach')?.values?.[0]?.value;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow={`Social · ${tenantLabel}`}
|
||||
title="Redes sociales"
|
||||
description="Perfiles orgánicos (FB, IG, LinkedIn, TikTok). Distinto de Ads pagados. Publicación y métricas live: siguiente fase."
|
||||
description="Perfiles orgánicos y métricas de audiencia. Paid ads están en Ads / Métricas."
|
||||
/>
|
||||
|
||||
{err && (
|
||||
@@ -74,12 +110,91 @@ export default function SocialDashboard({
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
{metrics.warning && (
|
||||
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
{metrics.warning}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Cargando…
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{
|
||||
label: 'Seguidores IG',
|
||||
value: n(ig?.followers_count),
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: 'Publicaciones IG',
|
||||
value: n(ig?.media_count),
|
||||
icon: ImageIcon,
|
||||
},
|
||||
{
|
||||
label: 'Fans / seguidores FB',
|
||||
value: n(fb?.followers_count ?? fb?.fan_count),
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: 'Alcance IG (día)',
|
||||
value: n(igReach),
|
||||
icon: Eye,
|
||||
},
|
||||
].map((k) => (
|
||||
<div
|
||||
key={k.label}
|
||||
className="rounded-xl border border-gray-200 bg-white p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between text-gray-600 mb-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider">
|
||||
{k.label}
|
||||
</p>
|
||||
<k.icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-gray-900 tabular-nums">
|
||||
{k.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(ig?.error || fb?.error) && (
|
||||
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
{ig?.error && `IG: ${ig.error} `}
|
||||
{fb?.error && `FB: ${fb.error}`}
|
||||
{' · '}
|
||||
Suele faltar Page token o permisos de insights en el system user.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
Insights (social expert)
|
||||
</h3>
|
||||
<ul className="text-xs text-gray-700 space-y-1.5 list-disc pl-4">
|
||||
<li>
|
||||
Prioriza contenido que convierta a lead/venta (CTA + landings
|
||||
medidas en CRM), no solo vanity metrics.
|
||||
</li>
|
||||
<li>
|
||||
Si seguidores IG ≫ fans FB, concentra producción en Reels /
|
||||
carruseles y usa FB como amplificación ligera.
|
||||
</li>
|
||||
<li>
|
||||
Revisa Calendario editorial: cada pieza debería tener objetivo
|
||||
(awareness / consideración / conversión).
|
||||
</li>
|
||||
<li>
|
||||
Menciones brand: próxima fase con listening; hoy el foco es
|
||||
audiencia propia + engagement on-profile.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
{CHANNELS.map((ch) => {
|
||||
const row = by.get(ch.id);
|
||||
@@ -138,16 +253,10 @@ export default function SocialDashboard({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 space-y-2">
|
||||
<p className="text-sm font-semibold text-gray-900">Próximo en Social</p>
|
||||
<ul className="text-xs text-gray-600 space-y-1 list-disc pl-4">
|
||||
<li>Métricas orgánicas (alcance, engagement) vía Graph API</li>
|
||||
<li>Últimas publicaciones y rendimiento</li>
|
||||
<li>Puente con Calendario editorial</li>
|
||||
</ul>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/dashboard/calendario"
|
||||
className="text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50"
|
||||
@@ -159,11 +268,10 @@ export default function SocialDashboard({
|
||||
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
|
||||
Emparejar en Agencia
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -666,3 +666,25 @@ export type OrganicoWorkspace = {
|
||||
priorities?: { seo: string[]; geo: string[] };
|
||||
sectionNotes?: Partial<Record<OrganicoSectionId, string>>;
|
||||
};
|
||||
|
||||
/** Prioridades por defecto (SEO/GEO expert) cuando el workspace está vacío. */
|
||||
export const DEFAULT_EXPERT_PRIORITIES: { seo: string[]; geo: string[] } = {
|
||||
seo: [
|
||||
'Verificar GSC (URL-prefix vs sc-domain) y GA4 property: datos diarios en organic_daily',
|
||||
'Top landings por clics GSC: title/H1/CTA alineados con intención de búsqueda',
|
||||
'Cerrar gap de indexación: sitemap, noindex accidental, soft-404',
|
||||
'Core Web Vitals móvil en landings de conversión (LCP < 2.5s)',
|
||||
'Keyword gap vs 3 competidores: 10 URLs nuevas o ampliadas este mes',
|
||||
'Enlazado interno desde hub → money pages (autoridad hacia conversión)',
|
||||
'Cruzar sesiones orgánicas GA4 con leads CRM (mismo embudo que ads)',
|
||||
],
|
||||
geo: [
|
||||
'Definir cartera de 30–50 prompts de compra/comparativa del sector',
|
||||
'Medir Share of Model en ChatGPT, Gemini y AI Overviews (baseline)',
|
||||
'Aumentar % de citas a web oficial (schema, autoría, datos propios)',
|
||||
'FAQs citables: respuesta directa + tabla/dato en las 10 URLs clave',
|
||||
'Mapear fuentes que citan a competidores y no a la marca',
|
||||
'Briefs de contenido a partir de prompts donde SoM = 0',
|
||||
'Re-medir mensualmente y priorizar caídas de sentimiento / menciones',
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user