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

@@ -0,0 +1,138 @@
import React from 'react';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import CrmDashboard from '@/components/CrmDashboard';
import CrmPortfolioBoard from '@/components/CrmPortfolioBoard';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { AdDailyRow, CrmLeadRow } from '@/lib/metrics';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
export const dynamic = 'force-dynamic';
async function fetchCrmLeads(
tenantId: string | null,
useServiceRole: boolean,
includeTenantId: boolean
): Promise<CrmLeadRow[]> {
const select = includeTenantId
? 'tenant_id,source,created_at,status'
: 'source,created_at,status';
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 < 80000; 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;
}
const supabase = await createClient();
for (let offset = 0; offset < 80000; 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;
}
async function fetchAdDaily(
tenantId: string | null,
useServiceRole: boolean
): Promise<AdDailyRow[]> {
if (useServiceRole) {
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) return [];
const tidFilter = tenantId
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
: '';
const r = await fetch(
`${base}/rest/v1/ad_daily?select=date,platform,spend,platform_leads${tidFilter}&order=date.desc&limit=5000`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return [];
return (await r.json()) as AdDailyRow[];
}
const supabase = await createClient();
const { data } = await supabase
.from('ad_daily')
.select('date,platform,spend,platform_leads')
.order('date', { ascending: false })
.limit(5000);
return (data as AdDailyRow[]) || [];
}
async function fetchTenantNames(): Promise<Record<string, string>> {
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) return {};
const r = await fetch(`${base}/rest/v1/tenants?select=id,name,slug`, {
headers,
cache: 'no-store',
});
if (!r.ok) return {};
const rows = (await r.json()) as { id: string; name: string; slug: string }[];
const map: Record<string, string> = {};
for (const t of rows) map[t.id] = t.name || t.slug;
return map;
}
export default async function CrmPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
const useService = agency && (ctx.portfolioWide || ctx.viaView);
if (agency && ctx.portfolioWide) {
const [leads, names] = await Promise.all([
fetchCrmLeads(null, true, true),
fetchTenantNames(),
]);
return (
<CrmPortfolioBoard
leads={leads}
tenantNames={names}
/>
);
}
const tenantId = ctx.tenantId;
const [crmLeads, dailyRows] = await Promise.all([
fetchCrmLeads(tenantId, useService, false),
fetchAdDaily(tenantId, useService),
]);
return (
<CrmDashboard
label={ctx.label}
portfolioWide={false}
crmLeads={crmLeads}
dailyRows={dailyRows}
/>
);
}

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}
/>
);
}

View File

@@ -11,7 +11,8 @@ import {
UserCheck,
CalendarDays,
Radio,
Megaphone
Megaphone,
Contact
} from 'lucide-react';
import { createClient } from '@/lib/supabase/server';
import { logoutAction } from '@/app/auth/actions';
@@ -57,6 +58,13 @@ export default async function DashboardLayout({
<BarChart3 className="w-4 h-4 text-brand-500" />
Métricas
</Link>
<Link
href="/dashboard/crm"
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"
>
<Contact className="w-4 h-4 text-teal-400" />
CRM
</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"