feat: pestaña CRM con % a cliente por canal y cruce ads vs Clientify

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 17:33:41 +02:00
parent f436276c3e
commit be45df300b
8 changed files with 1157 additions and 57 deletions

View File

@@ -5,6 +5,7 @@ import MetricsDashboard from '@/components/MetricsDashboard';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import {
AdDailyRow,
CrmLeadRow,
KpiWeeklyRow,
buildChannelSeries,
buildComparisons,
@@ -12,10 +13,55 @@ import {
sumDaily,
sumWeekly,
} from '@/lib/metrics';
import { isAgencyRole, userRole } from '@/lib/agency';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
export const dynamic = 'force-dynamic';
/** Paginate CRM leads (source + created_at) for attribution cross. */
async function fetchCrmLeads(
tenantId: string | null,
useServiceRole: boolean
): Promise<CrmLeadRow[]> {
const select = 'source,created_at';
const pageSize = 1000;
const out: CrmLeadRow[] = [];
if (useServiceRole) {
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) return [];
const tidFilter = tenantId
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
: '';
for (let offset = 0; offset < 50000; offset += pageSize) {
const r = await fetch(
`${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
{ headers, cache: 'no-store' }
);
if (!r.ok) break;
const chunk = (await r.json()) as CrmLeadRow[];
out.push(...chunk);
if (chunk.length < pageSize) break;
}
return out;
}
// Client path: RLS already scopes tenant
const supabase = await createClient();
for (let offset = 0; offset < 50000; offset += pageSize) {
const { data } = await supabase
.from('leads')
.select(select)
.eq('crm', 'clientify')
.order('created_at', { ascending: false })
.range(offset, offset + pageSize - 1);
const chunk = (data as CrmLeadRow[]) || [];
out.push(...chunk);
if (chunk.length < pageSize) break;
}
return out;
}
export default async function KpisPage() {
const supabase = await createClient();
const {
@@ -28,6 +74,7 @@ export default async function KpisPage() {
let weekly: KpiWeeklyRow[] = [];
let daily: AdDailyRow[] = [];
let crmLeads: CrmLeadRow[] = [];
if (agency && (ctx.portfolioWide || ctx.viaView)) {
// Service role for portfolio / zoom (bypass single-tenant RLS)
@@ -40,6 +87,8 @@ export default async function KpisPage() {
);
weekly = w.data || [];
daily = d.data || [];
// Attribution cross is clearest per-tenant; skip portfolio dump.
crmLeads = [];
} else if (ctx.tenantId) {
const tid = encodeURIComponent(ctx.tenantId);
const w = await restGet<KpiWeeklyRow[]>(
@@ -50,6 +99,7 @@ export default async function KpisPage() {
);
weekly = w.data || [];
daily = d.data || [];
crmLeads = await fetchCrmLeads(ctx.tenantId, true);
}
} else {
const { data: w } = await supabase
@@ -63,6 +113,7 @@ export default async function KpisPage() {
.limit(5000);
weekly = (w as KpiWeeklyRow[]) || [];
daily = (d as AdDailyRow[]) || [];
crmLeads = await fetchCrmLeads(ctx.tenantId, false);
}
weekly = resolveWeeklyRows(weekly, daily);
@@ -88,6 +139,7 @@ export default async function KpisPage() {
weeklyRows={weekly}
channelPoints={channelSeries.points}
dailyRows={daily}
crmLeads={crmLeads}
/>
);
}