feat: interactive seguimiento timeline and channel evolution
Sync Superhuman Docs seguimiento/KPI pages into the portal (never passwords) and show clients a dynamic timeline plus per-channel kpi_weekly charts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,3 +26,7 @@ GOOGLE_ADS_REFRESH_TOKEN=
|
|||||||
# GOOGLE_ADS_CLIENT_ID=
|
# GOOGLE_ADS_CLIENT_ID=
|
||||||
# GOOGLE_ADS_CLIENT_SECRET=
|
# GOOGLE_ADS_CLIENT_SECRET=
|
||||||
# GOOGLE_ADS_LOGIN_CUSTOMER_ID=
|
# GOOGLE_ADS_LOGIN_CUSTOMER_ID=
|
||||||
|
|
||||||
|
# Superhuman Docs (optional)
|
||||||
|
# SUPERHUMAN_DOCS_API_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Health: `GET /api/health`
|
|||||||
|------|-----|
|
|------|-----|
|
||||||
| `/login` | Supabase Auth |
|
| `/login` | Supabase Auth |
|
||||||
| `/dashboard/kpis` | KPIs |
|
| `/dashboard/kpis` | KPIs |
|
||||||
|
| `/dashboard/seguimiento` | Timeline Superhuman + evolución canales |
|
||||||
| `/dashboard/estrategia` | Targets + simulador |
|
| `/dashboard/estrategia` | Targets + simulador |
|
||||||
| `/dashboard/activos` | Docs aprobados (RLS) |
|
| `/dashboard/activos` | Docs aprobados (RLS) |
|
||||||
| `/dashboard/creatives` | Creatividades (en curso) |
|
| `/dashboard/creatives` | Creatividades (en curso) |
|
||||||
|
|||||||
92
scripts/dokploy_apply_seguimiento.py
Normal file
92
scripts/dokploy_apply_seguimiento.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply sql/seguimiento.sql via Dokploy supabase-db terminal."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||||
|
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||||
|
SQL = ROOT / "sql" / "seguimiento.sql"
|
||||||
|
BASE = "https://dokploy.4ventures.es"
|
||||||
|
|
||||||
|
|
||||||
|
def env(key: str) -> str:
|
||||||
|
v = os.environ.get(key, "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
p = BPS / ".env"
|
||||||
|
if not p.is_file():
|
||||||
|
return ""
|
||||||
|
for line in p.read_text().splitlines():
|
||||||
|
if line.startswith(f"{key}="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||||
|
password = env("DOKPLOY_PASSWORD")
|
||||||
|
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||||
|
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||||
|
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
if "/dashboard" not in page.url:
|
||||||
|
page.locator('input[type="email"], input[name="email"]').first.fill(email)
|
||||||
|
page.locator('input[type="password"]').first.fill(password)
|
||||||
|
page.locator('button[type="submit"]').first.click()
|
||||||
|
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
|
||||||
|
print("login ok", flush=True)
|
||||||
|
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||||
|
"supabase-db"
|
||||||
|
)
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
page.get_by_text("Terminal", exact=True).click()
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
if page.locator("button", has_text="Bash").count():
|
||||||
|
page.locator("button", has_text="Bash").first.click()
|
||||||
|
page.wait_for_timeout(2800)
|
||||||
|
if page.locator("textarea.xterm-helper-textarea").count():
|
||||||
|
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||||
|
|
||||||
|
def run(cmd: str, wait: int = 800) -> None:
|
||||||
|
page.keyboard.type(cmd, delay=1)
|
||||||
|
page.keyboard.press("Enter")
|
||||||
|
page.wait_for_timeout(wait)
|
||||||
|
|
||||||
|
run("rm -f /tmp/seg.b64 /tmp/seg.sql; echo READY", 1200)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/seg.b64", 400)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/seg.b64 > /tmp/seg.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/seg.sql; echo EXIT:$?",
|
||||||
|
14000,
|
||||||
|
)
|
||||||
|
text = page.evaluate(
|
||||||
|
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||||
|
)
|
||||||
|
browser.close()
|
||||||
|
ok = "EXIT:0" in text
|
||||||
|
print("SQL_OK", ok, flush=True)
|
||||||
|
print(text[-600:], flush=True)
|
||||||
|
return 0 if ok else 6
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
87
sql/seguimiento.sql
Normal file
87
sql/seguimiento.sql
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
-- Seguimiento: Superhuman Docs timeline + integration slot
|
||||||
|
-- Idempotent. Never stores passwords.
|
||||||
|
|
||||||
|
alter table public.integrations drop constraint if exists integrations_provider_check;
|
||||||
|
alter table public.integrations
|
||||||
|
add constraint integrations_provider_check
|
||||||
|
check (provider in (
|
||||||
|
'meta_ads',
|
||||||
|
'google_ads',
|
||||||
|
'ga4',
|
||||||
|
'search_console',
|
||||||
|
'tag_manager',
|
||||||
|
'clientify',
|
||||||
|
'klaviyo',
|
||||||
|
'mailchimp',
|
||||||
|
'google_drive',
|
||||||
|
'wordpress',
|
||||||
|
'prestashop',
|
||||||
|
'shopify',
|
||||||
|
'instagram',
|
||||||
|
'facebook',
|
||||||
|
'tiktok',
|
||||||
|
'linkedin',
|
||||||
|
'superhuman_docs'
|
||||||
|
));
|
||||||
|
|
||||||
|
insert into public.integrations (tenant_id, provider, status, label)
|
||||||
|
select t.id, 'superhuman_docs', 'disconnected', 'Superhuman Docs'
|
||||||
|
from public.tenants t
|
||||||
|
on conflict (tenant_id, provider) do nothing;
|
||||||
|
|
||||||
|
create table if not exists public.seguimiento_entries (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||||
|
source text not null default 'superhuman'
|
||||||
|
check (source in ('superhuman', 'manual')),
|
||||||
|
entry_type text not null default 'topic'
|
||||||
|
check (entry_type in ('meeting', 'topic', 'kpis', 'note')),
|
||||||
|
external_id text not null,
|
||||||
|
title text not null,
|
||||||
|
summary text,
|
||||||
|
body_md text,
|
||||||
|
browser_link text,
|
||||||
|
metrics jsonb not null default '{}'::jsonb,
|
||||||
|
occurred_at timestamptz,
|
||||||
|
synced_at timestamptz not null default now(),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (tenant_id, source, external_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists seguimiento_entries_tenant_occurred_idx
|
||||||
|
on public.seguimiento_entries (tenant_id, occurred_at desc nulls last);
|
||||||
|
|
||||||
|
create index if not exists seguimiento_entries_tenant_synced_idx
|
||||||
|
on public.seguimiento_entries (tenant_id, synced_at desc);
|
||||||
|
|
||||||
|
alter table public.seguimiento_entries enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists seguimiento_entries_tenant_select on public.seguimiento_entries;
|
||||||
|
drop policy if exists seguimiento_entries_tenant_write on public.seguimiento_entries;
|
||||||
|
|
||||||
|
create policy seguimiento_entries_tenant_select on public.seguimiento_entries
|
||||||
|
for select using (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Clients read-only via RLS; agency writes with service role.
|
||||||
|
create policy seguimiento_entries_tenant_write on public.seguimiento_entries
|
||||||
|
for all using (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with check (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table public.seguimiento_entries is
|
||||||
|
'Client-facing seguimiento timeline (Superhuman Docs). No passwords.';
|
||||||
233
src/app/api/admin/superhuman/sync/route.ts
Normal file
233
src/app/api/admin/superhuman/sync/route.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import {
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
|
import {
|
||||||
|
SUPERHUMAN_PROVIDER,
|
||||||
|
bestDocForTenant,
|
||||||
|
buildSeguimientoRowsForDoc,
|
||||||
|
listDocs,
|
||||||
|
matchDocsToTenants,
|
||||||
|
patchSuperhumanIntegration,
|
||||||
|
superhumanConfigured,
|
||||||
|
upsertSeguimientoRows,
|
||||||
|
} from '@/lib/superhuman-docs';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
async function requireAgency() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user || !isAgencyRole(userRole(user))) {
|
||||||
|
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
if (!superhumanConfigured()) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'Falta SUPERHUMAN_DOCS_API_KEY en el entorno' },
|
||||||
|
{ status: 503 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (!headers) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { headers, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET — match status of Superhuman docs ↔ tenants. */
|
||||||
|
export async function GET() {
|
||||||
|
const gate = await requireAgency();
|
||||||
|
if ('error' in gate && gate.error) return gate.error;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = (gate as { headers: { apikey: string; Authorization: string } })
|
||||||
|
.headers;
|
||||||
|
const [docs, tenantsRes] = await Promise.all([
|
||||||
|
listDocs(),
|
||||||
|
fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const tenants = tenantsRes.ok
|
||||||
|
? ((await tenantsRes.json()) as { id: string; name: string; slug: string }[])
|
||||||
|
: [];
|
||||||
|
const matches = matchDocsToTenants(tenants, docs);
|
||||||
|
const integRes = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/integrations?provider=eq.${SUPERHUMAN_PROVIDER}&select=tenant_id,status,external_account_id,label,last_sync_at,meta,error_message`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
const integrations = integRes.ok ? await integRes.json() : [];
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
configured: true,
|
||||||
|
docCount: docs.length,
|
||||||
|
matches,
|
||||||
|
integrations,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : 'sync status failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST — sync seguimiento pages into portal.
|
||||||
|
* body: { tenantId?: string, exportBodies?: boolean, maxEntries?: number }
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const gate = await requireAgency();
|
||||||
|
if ('error' in gate && gate.error) return gate.error;
|
||||||
|
const headers = (gate as { headers: { apikey: string; Authorization: string } })
|
||||||
|
.headers;
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const onlyTenant: string | undefined = body.tenantId
|
||||||
|
? String(body.tenantId)
|
||||||
|
: undefined;
|
||||||
|
const exportBodies = body.exportBodies !== false;
|
||||||
|
const maxEntries = Math.min(Number(body.maxEntries) || 24, 40);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const docs = await listDocs();
|
||||||
|
const tRes = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!tRes.ok) {
|
||||||
|
return NextResponse.json({ error: await tRes.text() }, { status: 500 });
|
||||||
|
}
|
||||||
|
let tenants = (await tRes.json()) as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}[];
|
||||||
|
if (onlyTenant) {
|
||||||
|
tenants = tenants.filter((t) => t.id === onlyTenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: {
|
||||||
|
tenantId: string;
|
||||||
|
tenantName: string;
|
||||||
|
docId: string | null;
|
||||||
|
docName: string | null;
|
||||||
|
upserted: number;
|
||||||
|
status: string;
|
||||||
|
error?: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
let docId: string | null = null;
|
||||||
|
let docName: string | null = null;
|
||||||
|
|
||||||
|
// Prefer already-linked integration
|
||||||
|
const integUrl = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${SUPERHUMAN_PROVIDER}&select=external_account_id,meta,label`;
|
||||||
|
const integR = await fetch(integUrl, { headers, cache: 'no-store' });
|
||||||
|
const integRows = integR.ok
|
||||||
|
? ((await integR.json()) as {
|
||||||
|
external_account_id?: string | null;
|
||||||
|
meta?: { doc_name?: string };
|
||||||
|
}[])
|
||||||
|
: [];
|
||||||
|
if (integRows[0]?.external_account_id) {
|
||||||
|
docId = integRows[0].external_account_id;
|
||||||
|
docName =
|
||||||
|
integRows[0].meta?.doc_name ||
|
||||||
|
docs.find((d) => d.id === docId)?.name ||
|
||||||
|
null;
|
||||||
|
} else {
|
||||||
|
const hit = bestDocForTenant(tenant, docs);
|
||||||
|
if (hit) {
|
||||||
|
docId = hit.doc.id;
|
||||||
|
docName = hit.doc.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!docId) {
|
||||||
|
await patchSuperhumanIntegration(tenant.id, {
|
||||||
|
status: 'disconnected',
|
||||||
|
error_message: 'Sin doc Superhuman matched por nombre',
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
tenantName: tenant.name,
|
||||||
|
docId: null,
|
||||||
|
docName: null,
|
||||||
|
upserted: 0,
|
||||||
|
status: 'unmatched',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await buildSeguimientoRowsForDoc({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
docId,
|
||||||
|
maxEntries,
|
||||||
|
exportBodies,
|
||||||
|
});
|
||||||
|
const upserted = await upsertSeguimientoRows(rows);
|
||||||
|
await patchSuperhumanIntegration(tenant.id, {
|
||||||
|
status: 'connected',
|
||||||
|
external_account_id: docId,
|
||||||
|
label: docName || 'Superhuman Docs',
|
||||||
|
last_sync_at: new Date().toISOString(),
|
||||||
|
error_message: null,
|
||||||
|
meta: {
|
||||||
|
doc_name: docName,
|
||||||
|
entries: upserted,
|
||||||
|
synced_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
tenantName: tenant.name,
|
||||||
|
docId,
|
||||||
|
docName,
|
||||||
|
upserted,
|
||||||
|
status: 'synced',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'sync failed';
|
||||||
|
await patchSuperhumanIntegration(tenant.id, {
|
||||||
|
status: 'error',
|
||||||
|
external_account_id: docId,
|
||||||
|
error_message: msg.slice(0, 400),
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
tenantName: tenant.name,
|
||||||
|
docId,
|
||||||
|
docName,
|
||||||
|
upserted: 0,
|
||||||
|
status: 'error',
|
||||||
|
error: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
results,
|
||||||
|
synced: results.filter((r) => r.status === 'synced').length,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : 'sync failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/app/api/seguimiento/route.ts
Normal file
107
src/app/api/seguimiento/route.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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 {
|
||||||
|
AdDailyRow,
|
||||||
|
KpiWeeklyRow,
|
||||||
|
buildChannelSeries,
|
||||||
|
} from '@/lib/metrics';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export type SeguimientoEntry = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
entry_type: string;
|
||||||
|
title: string;
|
||||||
|
summary?: string | null;
|
||||||
|
body_md?: string | null;
|
||||||
|
browser_link?: string | null;
|
||||||
|
metrics?: Record<string, unknown>;
|
||||||
|
occurred_at?: string | null;
|
||||||
|
synced_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** GET seguimiento timeline + channel evolution for active tenant. */
|
||||||
|
export async function GET() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
|
||||||
|
if (agency && ctx.portfolioWide) {
|
||||||
|
return NextResponse.json({
|
||||||
|
label: ctx.label,
|
||||||
|
portfolioWide: true,
|
||||||
|
entries: [],
|
||||||
|
channelSeries: { weeks: [], channels: [], points: [] },
|
||||||
|
hint: 'Elige un cliente en el selector para ver su seguimiento y evolución de canales.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = ctx.tenantId;
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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=80`
|
||||||
|
),
|
||||||
|
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.asc&limit=400`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
entries = e.data || [];
|
||||||
|
weekly = w.data || [];
|
||||||
|
daily = d.data || [];
|
||||||
|
} else {
|
||||||
|
const [{ data: e }, { data: w }, { data: d }] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from('seguimiento_entries')
|
||||||
|
.select('*')
|
||||||
|
.order('occurred_at', { ascending: false, nullsFirst: false })
|
||||||
|
.limit(80),
|
||||||
|
supabase.from('kpi_weekly').select('*').order('week', { ascending: true }),
|
||||||
|
supabase
|
||||||
|
.from('ad_daily')
|
||||||
|
.select('*')
|
||||||
|
.order('date', { ascending: true })
|
||||||
|
.limit(400),
|
||||||
|
]);
|
||||||
|
entries = (e as SeguimientoEntry[]) || [];
|
||||||
|
weekly = (w as KpiWeeklyRow[]) || [];
|
||||||
|
daily = (d as AdDailyRow[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelSeries = buildChannelSeries(weekly);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
label: ctx.label,
|
||||||
|
portfolioWide: false,
|
||||||
|
tenantId,
|
||||||
|
entries,
|
||||||
|
channelSeries,
|
||||||
|
dailySummary: {
|
||||||
|
days: daily.length,
|
||||||
|
spend: daily.reduce((a, r) => a + Number(r.spend || 0), 0),
|
||||||
|
leads: daily.reduce((a, r) => a + Number(r.platform_leads || 0), 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
LogOut,
|
LogOut,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
CalendarDays
|
CalendarDays,
|
||||||
|
Radio
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { logoutAction } from '@/app/auth/actions';
|
import { logoutAction } from '@/app/auth/actions';
|
||||||
@@ -55,6 +56,13 @@ export default async function DashboardLayout({
|
|||||||
<BarChart3 className="w-4 h-4 text-brand-500" />
|
<BarChart3 className="w-4 h-4 text-brand-500" />
|
||||||
Métricas
|
Métricas
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/seguimiento"
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
||||||
|
>
|
||||||
|
<Radio className="w-4 h-4 text-sky-400" />
|
||||||
|
Seguimiento
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/estrategia"
|
href="/dashboard/estrategia"
|
||||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
|
||||||
|
|||||||
16
src/app/dashboard/seguimiento/page.tsx
Normal file
16
src/app/dashboard/seguimiento/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import SeguimientoBoard from '@/components/SeguimientoBoard';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function SeguimientoPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
|
||||||
|
return <SeguimientoBoard />;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import DriveConnectPanel from '@/components/DriveConnectPanel';
|
import DriveConnectPanel from '@/components/DriveConnectPanel';
|
||||||
import AdsDiscoverPanel from '@/components/AdsDiscoverPanel';
|
import AdsDiscoverPanel from '@/components/AdsDiscoverPanel';
|
||||||
|
import SuperhumanSyncPanel from '@/components/SuperhumanSyncPanel';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bell,
|
Bell,
|
||||||
@@ -181,6 +182,7 @@ export default function AgencyPortfolioBoard() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<DriveConnectPanel />
|
<DriveConnectPanel />
|
||||||
<AdsDiscoverPanel />
|
<AdsDiscoverPanel />
|
||||||
|
<SuperhumanSyncPanel />
|
||||||
|
|
||||||
{warning && (
|
{warning && (
|
||||||
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||||
|
|||||||
511
src/components/SeguimientoBoard.tsx
Normal file
511
src/components/SeguimientoBoard.tsx
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
LineChart as LineIcon,
|
||||||
|
Loader2,
|
||||||
|
MessageSquareText,
|
||||||
|
Radio,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
ChannelWeekPoint,
|
||||||
|
formatEur,
|
||||||
|
formatNum,
|
||||||
|
} from '@/lib/metrics';
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
id: string;
|
||||||
|
entry_type: string;
|
||||||
|
title: string;
|
||||||
|
summary?: string | null;
|
||||||
|
body_md?: string | null;
|
||||||
|
browser_link?: string | null;
|
||||||
|
metrics?: {
|
||||||
|
ventas?: number;
|
||||||
|
cpl?: number;
|
||||||
|
cac?: number;
|
||||||
|
periodo?: string;
|
||||||
|
};
|
||||||
|
occurred_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Payload = {
|
||||||
|
label: string;
|
||||||
|
portfolioWide?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
entries: Entry[];
|
||||||
|
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';
|
||||||
|
if (t === 'topic') return 'Tema';
|
||||||
|
return 'Nota';
|
||||||
|
}
|
||||||
|
|
||||||
|
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-gray-500/15 text-gray-300 border-gray-500/30';
|
||||||
|
}
|
||||||
|
|
||||||
|
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-800 rounded-xl">
|
||||||
|
Aún no hay series de canal en kpi_weekly para este cliente.
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
{metric === 'spend' || metric === 'cpl'
|
||||||
|
? formatNum(val)
|
||||||
|
: 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-700 bg-[#0D121F] px-2.5 py-1.5 text-[11px] text-white 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 [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 [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(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');
|
||||||
|
if (!cancelled) {
|
||||||
|
setData(j);
|
||||||
|
setActiveChannels(new Set(j.channelSeries?.channels || []));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
const list = 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]);
|
||||||
|
|
||||||
|
function toggleChannel(ch: string) {
|
||||||
|
setActiveChannels((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(ch)) next.delete(ch);
|
||||||
|
else next.add(ch);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400 py-16 justify-center">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Cargando seguimiento…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-red-400 border border-red-500/20 rounded-lg px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
if (data.portfolioWide) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-800 bg-[#111827] p-8 text-center">
|
||||||
|
<Radio className="w-8 h-8 text-brand-500 mx-auto mb-3" />
|
||||||
|
<h2 className="text-lg font-semibold text-white">Seguimiento por cliente</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-2 max-w-md mx-auto">
|
||||||
|
{data.hint ||
|
||||||
|
'Selecciona un cliente en el encabezado para ver la evolución reunión a reunión y por canales.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { channelSeries, dailySummary } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||||
|
Seguimiento & evolución
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{data.label} · timeline Superhuman + series de canal (kpi_weekly)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{dailySummary && dailySummary.days > 0 && (
|
||||||
|
<div className="text-[11px] text-gray-500 bg-gray-900/50 border border-gray-800 rounded-lg px-3 py-2">
|
||||||
|
Ads recientes · {formatEur(dailySummary.spend)} ·{' '}
|
||||||
|
{formatNum(dailySummary.leads)} leads · {dailySummary.days} días
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel evolution */}
|
||||||
|
<section className="bg-[#111827] border border-gray-800 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-white 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-800 p-0.5 bg-black/20">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['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/20 text-brand-400'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{channelSeries.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-white bg-gray-900'
|
||||||
|
: 'border-gray-800 text-gray-500 opacity-60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ background: on ? color : '#4B5563' }}
|
||||||
|
/>
|
||||||
|
{ch}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChannelEvolutionChart
|
||||||
|
weeks={channelSeries.weeks}
|
||||||
|
channels={channelSeries.channels}
|
||||||
|
points={channelSeries.points}
|
||||||
|
metric={metric}
|
||||||
|
activeChannels={activeChannels}
|
||||||
|
/>
|
||||||
|
</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-white font-medium">
|
||||||
|
<MessageSquareText className="w-4 h-4 text-emerald-400" />
|
||||||
|
Timeline reunión a reunión
|
||||||
|
<span className="text-xs text-gray-500 font-normal">
|
||||||
|
({filteredEntries.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Filtrar temas, CPL, Meta…"
|
||||||
|
className="w-full sm:w-64 rounded-lg bg-black/30 border border-gray-800 px-3 py-1.5 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-brand-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredEntries.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-gray-800 px-4 py-10 text-center text-sm text-gray-500">
|
||||||
|
Sin entradas aún. Desde Agencia → sincroniza Superhuman Docs para este
|
||||||
|
cliente (páginas de Seguimiento; nunca contraseñas).
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol className="relative border-l border-gray-800 ml-3 space-y-4">
|
||||||
|
{filteredEntries.map((e) => {
|
||||||
|
const open = openId === e.id;
|
||||||
|
const when = e.occurred_at
|
||||||
|
? new Date(e.occurred_at).toLocaleDateString('es-ES', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: '—';
|
||||||
|
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]" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenId(open ? null : e.id)}
|
||||||
|
className="w-full text-left rounded-xl border border-gray-800 bg-[#111827] hover:border-gray-700 transition-colors p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border ${typeTone(e.entry_type)}`}
|
||||||
|
>
|
||||||
|
{typeLabel(e.entry_type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-gray-500">{when}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-white">{e.title}</p>
|
||||||
|
{e.summary && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
||||||
|
{e.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{e.browser_link && (
|
||||||
|
<a
|
||||||
|
href={e.browser_link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-brand-400 hover:text-brand-300"
|
||||||
|
>
|
||||||
|
Superhuman <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && e.body_md && (
|
||||||
|
<pre className="mt-3 whitespace-pre-wrap text-xs text-gray-300 bg-black/30 border border-gray-800 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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/components/SuperhumanSyncPanel.tsx
Normal file
178
src/components/SuperhumanSyncPanel.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, RefreshCw, FileText } from 'lucide-react';
|
||||||
|
|
||||||
|
type Match = {
|
||||||
|
docId: string;
|
||||||
|
docName: string;
|
||||||
|
tenantId: string | null;
|
||||||
|
tenantName: string | null;
|
||||||
|
score: number;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SyncResult = {
|
||||||
|
tenantName: string;
|
||||||
|
status: string;
|
||||||
|
upserted: number;
|
||||||
|
docName: string | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SuperhumanSyncPanel() {
|
||||||
|
const [matches, setMatches] = useState<Match[]>([]);
|
||||||
|
const [docCount, setDocCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [results, setResults] = useState<SyncResult[]>([]);
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
setLoading(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/admin/superhuman/sync', { cache: 'no-store' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j.error || 'No se pudo leer Superhuman');
|
||||||
|
setMatches(j.matches || []);
|
||||||
|
setDocCount(j.docCount || 0);
|
||||||
|
} catch (e) {
|
||||||
|
setMessage(e instanceof Error ? e.message : 'Error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function runSync() {
|
||||||
|
setSyncing(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/admin/superhuman/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ exportBodies: true, maxEntries: 20 }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!r.ok) throw new Error(j.error || 'Sync falló');
|
||||||
|
setResults(j.results || []);
|
||||||
|
setMessage(
|
||||||
|
`Sincronizados ${j.synced}/${(j.results || []).length} tenants (sin contraseñas).`
|
||||||
|
);
|
||||||
|
await loadStatus();
|
||||||
|
} catch (e) {
|
||||||
|
setMessage(e instanceof Error ? e.message : 'Error');
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = matches.filter((m) => m.status === 'matched').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-800 bg-[#111827] p-5 space-y-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-brand-500" />
|
||||||
|
Superhuman Docs → Seguimiento
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Importa páginas de Seguimiento/KPIs por cliente. Excluye CONTRASEÑAS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadStatus}
|
||||||
|
disabled={loading || syncing}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-gray-700 text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Estado
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runSync}
|
||||||
|
disabled={loading || syncing}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-brand-500/20 text-brand-300 border border-brand-500/30 hover:bg-brand-500/30"
|
||||||
|
>
|
||||||
|
{syncing ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Sync ahora
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
{docCount} docs · {matched} matched a tenants del portal
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<p className="text-xs text-amber-300/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<ul className="text-xs text-gray-400 space-y-1 max-h-40 overflow-y-auto">
|
||||||
|
{results.map((r) => (
|
||||||
|
<li key={r.tenantName + r.status}>
|
||||||
|
<span className="text-gray-300">{r.tenantName}</span> · {r.status}
|
||||||
|
{r.docName ? ` · ${r.docName}` : ''}
|
||||||
|
{r.upserted ? ` · ${r.upserted} entradas` : ''}
|
||||||
|
{r.error ? ` · ${r.error}` : ''}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && matches.length > 0 && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[11px] text-left">
|
||||||
|
<thead className="text-gray-500 border-b border-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1.5 pr-2 font-medium">Tenant</th>
|
||||||
|
<th className="py-1.5 pr-2 font-medium">Doc</th>
|
||||||
|
<th className="py-1.5 font-medium">Match</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{matches.slice(0, 20).map((m) => (
|
||||||
|
<tr key={m.tenantName || m.docId} className="border-b border-gray-900">
|
||||||
|
<td className="py-1.5 pr-2 text-gray-300">
|
||||||
|
{m.tenantName || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-2 text-gray-400 truncate max-w-[220px]">
|
||||||
|
{m.docName || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
m.status === 'matched'
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: m.status === 'ambiguous'
|
||||||
|
? 'text-amber-400'
|
||||||
|
: 'text-gray-500'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{m.status}
|
||||||
|
{m.score ? ` ${m.score}` : ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export const INTEGRATION_PROVIDERS = [
|
|||||||
{ id: 'facebook', label: 'Facebook Page', group: 'social' },
|
{ id: 'facebook', label: 'Facebook Page', group: 'social' },
|
||||||
{ id: 'tiktok', label: 'TikTok', group: 'social' },
|
{ id: 'tiktok', label: 'TikTok', group: 'social' },
|
||||||
{ id: 'linkedin', label: 'LinkedIn', group: 'social' },
|
{ id: 'linkedin', label: 'LinkedIn', group: 'social' },
|
||||||
|
{ id: 'superhuman_docs', label: 'Superhuman Docs', group: 'ops' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type IntegrationProviderId =
|
export type IntegrationProviderId =
|
||||||
|
|||||||
@@ -186,4 +186,66 @@ export function formatNum(v: number): string {
|
|||||||
return v.toLocaleString('es-ES', { maximumFractionDigits: 1 });
|
return v.toLocaleString('es-ES', { maximumFractionDigits: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChannelWeekPoint = {
|
||||||
|
week: string; // YYYY-MM-DD
|
||||||
|
channel: string;
|
||||||
|
spend: number;
|
||||||
|
leads: number;
|
||||||
|
deals: number;
|
||||||
|
revenue: number;
|
||||||
|
cpl: number;
|
||||||
|
cac: number;
|
||||||
|
roas: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Collapse kpi_weekly rows into per-channel weekly series for charts. */
|
||||||
|
export function buildChannelSeries(rows: KpiWeeklyRow[]): {
|
||||||
|
weeks: string[];
|
||||||
|
channels: string[];
|
||||||
|
points: ChannelWeekPoint[];
|
||||||
|
} {
|
||||||
|
const map = new Map<string, ChannelWeekPoint>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const d = parseWeek(r.week);
|
||||||
|
if (!d) continue;
|
||||||
|
const week = isoDate(weekStart(d));
|
||||||
|
const channel = (r.channel || 'otros').toLowerCase();
|
||||||
|
const key = `${week}|${channel}`;
|
||||||
|
const spend = n(r.total_spend);
|
||||||
|
const leads = n(r.total_leads);
|
||||||
|
const deals = n(r.cohort_deals_won);
|
||||||
|
const revenue = n(r.cohort_revenue_won);
|
||||||
|
const prev = map.get(key);
|
||||||
|
if (prev) {
|
||||||
|
prev.spend += spend;
|
||||||
|
prev.leads += leads;
|
||||||
|
prev.deals += deals;
|
||||||
|
prev.revenue += revenue;
|
||||||
|
prev.cpl = prev.leads > 0 ? prev.spend / prev.leads : 0;
|
||||||
|
prev.cac = prev.deals > 0 ? prev.spend / prev.deals : 0;
|
||||||
|
prev.roas = prev.spend > 0 ? prev.revenue / prev.spend : 0;
|
||||||
|
} else {
|
||||||
|
map.set(key, {
|
||||||
|
week,
|
||||||
|
channel,
|
||||||
|
spend,
|
||||||
|
leads,
|
||||||
|
deals,
|
||||||
|
revenue,
|
||||||
|
cpl: leads > 0 ? spend / leads : 0,
|
||||||
|
cac: deals > 0 ? spend / deals : 0,
|
||||||
|
roas: spend > 0 ? revenue / spend : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const points = [...map.values()].sort((a, b) =>
|
||||||
|
a.week === b.week
|
||||||
|
? a.channel.localeCompare(b.channel)
|
||||||
|
: a.week.localeCompare(b.week)
|
||||||
|
);
|
||||||
|
const weeks = [...new Set(points.map((p) => p.week))].sort();
|
||||||
|
const channels = [...new Set(points.map((p) => p.channel))].sort();
|
||||||
|
return { weeks, channels, points };
|
||||||
|
}
|
||||||
|
|
||||||
export { isoDate, weekStart };
|
export { isoDate, weekStart };
|
||||||
|
|||||||
451
src/lib/superhuman-docs.ts
Normal file
451
src/lib/superhuman-docs.ts
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
/** Superhuman Docs (= Coda) client: match docs↔tenants, sync seguimiento pages. */
|
||||||
|
|
||||||
|
import { gunzipSync } from 'zlib';
|
||||||
|
import { matchAccountsToTenants, normalizeName, scoreNameToTenant } from '@/lib/match';
|
||||||
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
|
|
||||||
|
export const SUPERHUMAN_PROVIDER = 'superhuman_docs' as const;
|
||||||
|
export const SUPERHUMAN_API_BASE = 'https://docs.superhuman.com/apis/v1';
|
||||||
|
|
||||||
|
const PASSWORD_RE = /contrase|password|secreto|credential/i;
|
||||||
|
const SEGUIMIENTO_RE = /seguim/i;
|
||||||
|
const KPI_RE = /^kpi|kpis\b/i;
|
||||||
|
|
||||||
|
export type ShDoc = { id: string; name: string; browserLink?: string };
|
||||||
|
export type ShPage = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subtitle?: string;
|
||||||
|
browserLink?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
parent?: { id: string; name?: string } | null;
|
||||||
|
children?: { id: string; name?: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedMetrics = {
|
||||||
|
ventas?: number;
|
||||||
|
cpl?: number;
|
||||||
|
cac?: number;
|
||||||
|
leads?: number;
|
||||||
|
spend?: number;
|
||||||
|
periodo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeguimientoSyncRow = {
|
||||||
|
tenant_id: string;
|
||||||
|
source: 'superhuman';
|
||||||
|
entry_type: 'meeting' | 'topic' | 'kpis' | 'note';
|
||||||
|
external_id: string;
|
||||||
|
title: string;
|
||||||
|
summary: string | null;
|
||||||
|
body_md: string | null;
|
||||||
|
browser_link: string | null;
|
||||||
|
metrics: ParsedMetrics;
|
||||||
|
occurred_at: string | null;
|
||||||
|
synced_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function apiKey(): string | null {
|
||||||
|
return process.env.SUPERHUMAN_DOCS_API_KEY?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function superhumanConfigured(): boolean {
|
||||||
|
return Boolean(apiKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shFetch<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit & { query?: Record<string, string> }
|
||||||
|
): Promise<T> {
|
||||||
|
const key = apiKey();
|
||||||
|
if (!key) throw new Error('Falta SUPERHUMAN_DOCS_API_KEY');
|
||||||
|
const q = init?.query
|
||||||
|
? '?' + new URLSearchParams(init.query).toString()
|
||||||
|
: '';
|
||||||
|
const r = await fetch(`${SUPERHUMAN_API_BASE}${path}${q}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(init?.headers || {}),
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`Superhuman ${r.status}: ${(await r.text()).slice(0, 240)}`);
|
||||||
|
}
|
||||||
|
return (await r.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDocs(): Promise<ShDoc[]> {
|
||||||
|
const out: ShDoc[] = [];
|
||||||
|
let pageToken: string | undefined;
|
||||||
|
do {
|
||||||
|
const query: Record<string, string> = { limit: '50' };
|
||||||
|
if (pageToken) query.pageToken = pageToken;
|
||||||
|
const data = await shFetch<{
|
||||||
|
items?: ShDoc[];
|
||||||
|
nextPageToken?: string;
|
||||||
|
}>('/docs', { query });
|
||||||
|
out.push(...(data.items || []));
|
||||||
|
pageToken = data.nextPageToken;
|
||||||
|
} while (pageToken);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPages(docId: string): Promise<ShPage[]> {
|
||||||
|
const out: ShPage[] = [];
|
||||||
|
let pageToken: string | undefined;
|
||||||
|
do {
|
||||||
|
const query: Record<string, string> = { limit: '100' };
|
||||||
|
if (pageToken) query.pageToken = pageToken;
|
||||||
|
const data = await shFetch<{
|
||||||
|
items?: ShPage[];
|
||||||
|
nextPageToken?: string;
|
||||||
|
}>(`/docs/${encodeURIComponent(docId)}/pages`, { query });
|
||||||
|
out.push(...(data.items || []));
|
||||||
|
pageToken = data.nextPageToken;
|
||||||
|
} while (pageToken);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip "01." / numbering prefix used in client doc titles. */
|
||||||
|
export function cleanClientDocName(name: string): string {
|
||||||
|
return name.replace(/^\s*\d+\.\s*/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPasswordSensitiveName(...names: (string | undefined | null)[]): boolean {
|
||||||
|
return names.some((n) => n && PASSWORD_RE.test(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMetricsFromMarkdown(md: string): ParsedMetrics {
|
||||||
|
const metrics: ParsedMetrics = {};
|
||||||
|
const periodo = md.match(/\*\*Periodo\*\*:\s*([^\n*]+)/i);
|
||||||
|
if (periodo) metrics.periodo = periodo[1].trim();
|
||||||
|
|
||||||
|
const ventas = md.match(/\*\*Ventas:\s*([\d.,]+)\*\*/i) || md.match(/Ventas:\s*([\d.,]+)/i);
|
||||||
|
if (ventas) metrics.ventas = Number(String(ventas[1]).replace(',', '.'));
|
||||||
|
|
||||||
|
const cpls = [...md.matchAll(/CPL:\s*([\d.,]+)\s*€?/gi)].map((m) =>
|
||||||
|
Number(String(m[1]).replace(',', '.'))
|
||||||
|
);
|
||||||
|
if (cpls.length) {
|
||||||
|
metrics.cpl = cpls.reduce((a, b) => a + b, 0) / cpls.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacs = [...md.matchAll(/CAC:\s*([\d.,]+)\s*€?/gi)].map((m) =>
|
||||||
|
Number(String(m[1]).replace(',', '.'))
|
||||||
|
);
|
||||||
|
if (cacs.length) {
|
||||||
|
metrics.cac = cacs.reduce((a, b) => a + b, 0) / cacs.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leads = md.match(/Leads?:\s*([\d.,]+)/i);
|
||||||
|
if (leads) metrics.leads = Number(String(leads[1]).replace(',', '.'));
|
||||||
|
|
||||||
|
const spend = md.match(/Inversi[oó]n:\s*([\d.,]+)\s*€?/i);
|
||||||
|
if (spend) metrics.spend = Number(String(spend[1]).replace(',', '.'));
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPageMarkdown(
|
||||||
|
docId: string,
|
||||||
|
pageId: string,
|
||||||
|
timeoutMs = 25000
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const start = await shFetch<{ id: string; href?: string; status?: string }>(
|
||||||
|
`/docs/${encodeURIComponent(docId)}/pages/${encodeURIComponent(pageId)}/export`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ outputFormat: 'markdown' }) }
|
||||||
|
);
|
||||||
|
const statusUrl =
|
||||||
|
start.href ||
|
||||||
|
`${SUPERHUMAN_API_BASE}/docs/${encodeURIComponent(docId)}/pages/${encodeURIComponent(pageId)}/export/${start.id}`;
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < timeoutMs) {
|
||||||
|
const key = apiKey()!;
|
||||||
|
const r = await fetch(statusUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const st = (await r.json()) as {
|
||||||
|
status?: string;
|
||||||
|
downloadLink?: string;
|
||||||
|
};
|
||||||
|
if (st.status === 'complete' && st.downloadLink) {
|
||||||
|
const dl = await fetch(st.downloadLink, {
|
||||||
|
headers: { 'Accept-Encoding': 'identity' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const buf = Buffer.from(await dl.arrayBuffer());
|
||||||
|
const text =
|
||||||
|
buf[0] === 0x1f && buf[1] === 0x8b
|
||||||
|
? gunzipSync(buf).toString('utf8')
|
||||||
|
: buf.toString('utf8');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
if (st.status === 'failed' || st.status === 'error') return null;
|
||||||
|
await new Promise((res) => setTimeout(res, 900));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function excerpt(md: string, max = 1400): string {
|
||||||
|
const clean = md.replace(/\r\n/g, '\n').trim();
|
||||||
|
if (clean.length <= max) return clean;
|
||||||
|
return clean.slice(0, max).trimEnd() + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyEntry(title: string, underSeguimiento: boolean): SeguimientoSyncRow['entry_type'] {
|
||||||
|
if (KPI_RE.test(title)) return 'kpis';
|
||||||
|
if (/reuni[oó]n|kick\s*off|seguimiento semanal/i.test(title)) return 'meeting';
|
||||||
|
if (underSeguimiento) return 'topic';
|
||||||
|
return 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageAncestors(
|
||||||
|
page: ShPage,
|
||||||
|
byId: Map<string, ShPage>
|
||||||
|
): ShPage[] {
|
||||||
|
const chain: ShPage[] = [];
|
||||||
|
let cur: ShPage | undefined = page;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (cur?.parent?.id && !seen.has(cur.parent.id)) {
|
||||||
|
seen.add(cur.parent.id);
|
||||||
|
const p = byId.get(cur.parent.id);
|
||||||
|
if (!p) break;
|
||||||
|
chain.push(p);
|
||||||
|
cur = p;
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect seguimiento / KPI pages safe for client portal. */
|
||||||
|
export function selectClientFacingPages(pages: ShPage[]): {
|
||||||
|
roots: ShPage[];
|
||||||
|
entries: { page: ShPage; underSeguimiento: boolean }[];
|
||||||
|
} {
|
||||||
|
const byId = new Map(pages.map((p) => [p.id, p]));
|
||||||
|
const roots = pages.filter(
|
||||||
|
(p) => SEGUIMIENTO_RE.test(p.name) || KPI_RE.test(p.name)
|
||||||
|
);
|
||||||
|
const rootIds = new Set(roots.map((r) => r.id));
|
||||||
|
const entries: { page: ShPage; underSeguimiento: boolean }[] = [];
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
if (isPasswordSensitiveName(page.name)) continue;
|
||||||
|
const ancestors = pageAncestors(page, byId);
|
||||||
|
if (ancestors.some((a) => isPasswordSensitiveName(a.name))) continue;
|
||||||
|
|
||||||
|
const underSeg = ancestors.some((a) => rootIds.has(a.id) && SEGUIMIENTO_RE.test(a.name));
|
||||||
|
const isRoot = rootIds.has(page.id);
|
||||||
|
const underKpi = ancestors.some((a) => rootIds.has(a.id) && KPI_RE.test(a.name));
|
||||||
|
|
||||||
|
if (isRoot || underSeg || (underKpi && page.parent)) {
|
||||||
|
// Skip deep password children already filtered; skip empty password roots
|
||||||
|
if (isPasswordSensitiveName(page.name)) continue;
|
||||||
|
entries.push({
|
||||||
|
page,
|
||||||
|
underSeguimiento: underSeg || SEGUIMIENTO_RE.test(page.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer freshest first; cap later at sync
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const ta = Date.parse(a.page.updatedAt || a.page.createdAt || '') || 0;
|
||||||
|
const tb = Date.parse(b.page.updatedAt || b.page.createdAt || '') || 0;
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { roots, entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocMatchResult = {
|
||||||
|
docId: string;
|
||||||
|
docName: string;
|
||||||
|
tenantId: string | null;
|
||||||
|
tenantName: string | null;
|
||||||
|
score: number;
|
||||||
|
status: 'matched' | 'ambiguous' | 'unmatched';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function matchDocsToTenants(
|
||||||
|
tenants: { id: string; name: string; slug: string }[],
|
||||||
|
docs: ShDoc[]
|
||||||
|
): DocMatchResult[] {
|
||||||
|
const accounts = docs.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: cleanClientDocName(d.name),
|
||||||
|
}));
|
||||||
|
const matched = matchAccountsToTenants(tenants, accounts, {
|
||||||
|
threshold: 50,
|
||||||
|
ambiguousGap: 8,
|
||||||
|
});
|
||||||
|
return matched.map((m) => ({
|
||||||
|
docId: m.accountId || '',
|
||||||
|
docName:
|
||||||
|
docs.find((d) => d.id === m.accountId)?.name || m.accountName || '',
|
||||||
|
tenantId: m.status === 'matched' ? m.tenantId : null,
|
||||||
|
tenantName: m.status === 'matched' ? m.tenantName : null,
|
||||||
|
score: m.score,
|
||||||
|
status: m.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best single-doc match for one tenant (manual zoom). */
|
||||||
|
export function bestDocForTenant(
|
||||||
|
tenant: { name: string; slug: string },
|
||||||
|
docs: ShDoc[]
|
||||||
|
): { doc: ShDoc; score: number } | null {
|
||||||
|
let best: { doc: ShDoc; score: number } | null = null;
|
||||||
|
for (const d of docs) {
|
||||||
|
const score = scoreNameToTenant(
|
||||||
|
cleanClientDocName(d.name),
|
||||||
|
tenant.name,
|
||||||
|
tenant.slug
|
||||||
|
);
|
||||||
|
if (!best || score > best.score) best = { doc: d, score };
|
||||||
|
}
|
||||||
|
if (!best || best.score < 50) return null;
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildSeguimientoRowsForDoc(opts: {
|
||||||
|
tenantId: string;
|
||||||
|
docId: string;
|
||||||
|
maxEntries?: number;
|
||||||
|
exportBodies?: boolean;
|
||||||
|
}): Promise<SeguimientoSyncRow[]> {
|
||||||
|
const maxEntries = opts.maxEntries ?? 30;
|
||||||
|
const exportBodies = opts.exportBodies !== false;
|
||||||
|
const pages = await listPages(opts.docId);
|
||||||
|
const { entries } = selectClientFacingPages(pages);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const rows: SeguimientoSyncRow[] = [];
|
||||||
|
|
||||||
|
for (const { page, underSeguimiento } of entries.slice(0, maxEntries)) {
|
||||||
|
let body: string | null = null;
|
||||||
|
let metrics: ParsedMetrics = {};
|
||||||
|
if (exportBodies && !SEGUIMIENTO_RE.test(page.name)) {
|
||||||
|
// Export leaf/topic pages (skip huge root containers)
|
||||||
|
body = await exportPageMarkdown(opts.docId, page.id);
|
||||||
|
if (body) {
|
||||||
|
metrics = parseMetricsFromMarkdown(body);
|
||||||
|
body = excerpt(body);
|
||||||
|
}
|
||||||
|
} else if (exportBodies && KPI_RE.test(page.name)) {
|
||||||
|
body = await exportPageMarkdown(opts.docId, page.id);
|
||||||
|
if (body) {
|
||||||
|
metrics = parseMetricsFromMarkdown(body);
|
||||||
|
body = excerpt(body, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryBits = [
|
||||||
|
page.subtitle?.trim(),
|
||||||
|
metrics.periodo ? `Periodo: ${metrics.periodo}` : null,
|
||||||
|
metrics.ventas != null ? `Ventas: ${metrics.ventas}` : null,
|
||||||
|
metrics.cpl != null ? `CPL≈${metrics.cpl.toFixed(1)}€` : null,
|
||||||
|
metrics.cac != null ? `CAC≈${metrics.cac.toFixed(0)}€` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
tenant_id: opts.tenantId,
|
||||||
|
source: 'superhuman',
|
||||||
|
entry_type: classifyEntry(page.name, underSeguimiento),
|
||||||
|
external_id: page.id,
|
||||||
|
title: page.name,
|
||||||
|
summary: summaryBits.length ? summaryBits.join(' · ') : null,
|
||||||
|
body_md: body,
|
||||||
|
browser_link: page.browserLink || null,
|
||||||
|
metrics,
|
||||||
|
occurred_at: page.updatedAt || page.createdAt || null,
|
||||||
|
synced_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertSeguimientoRows(
|
||||||
|
rows: SeguimientoSyncRow[]
|
||||||
|
): Promise<number> {
|
||||||
|
if (!rows.length) return 0;
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (!headers) throw new Error('no service role');
|
||||||
|
const r = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/seguimiento_entries?on_conflict=tenant_id,source,external_id`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(rows),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`upsert seguimiento: ${await r.text()}`);
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchSuperhumanIntegration(
|
||||||
|
tenantId: string,
|
||||||
|
patch: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (!headers) throw new Error('no service role');
|
||||||
|
const url = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${SUPERHUMAN_PROVIDER}`;
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ...patch, updated_at: new Date().toISOString() }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
// Ensure row exists then retry
|
||||||
|
await fetch(`${supabaseUrl()}/rest/v1/integrations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'resolution=ignore-duplicates,return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
provider: SUPERHUMAN_PROVIDER,
|
||||||
|
status: 'disconnected',
|
||||||
|
label: 'Superhuman Docs',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const r2 = await fetch(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ...patch, updated_at: new Date().toISOString() }),
|
||||||
|
});
|
||||||
|
if (!r2.ok) throw new Error(await r2.text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function docDisplayHint(name: string): string {
|
||||||
|
return normalizeName(cleanClientDocName(name));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user