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:
138
src/app/dashboard/crm/page.tsx
Normal file
138
src/app/dashboard/crm/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user