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:
4Ventures
2026-09-22 21:34:21 +02:00
parent 6607a02bff
commit 64156383ca
14 changed files with 1754 additions and 1 deletions

View 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),
},
});
}