Show global and per-channel CAC in CRM and Métricas.
Reuse won deals + ad spend for every tenant: CAC column in the metrics channel table, and a CRM hero + channel breakdown with the same formula. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import CrmDashboard from '@/components/CrmDashboard';
|
||||
import CrmPortfolioBoard from '@/components/CrmPortfolioBoard';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { AdDailyRow, CrmLeadRow } from '@/lib/metrics';
|
||||
import { fetchWonDeals } from '@/lib/fetch-won-deals';
|
||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -145,9 +146,10 @@ export default async function CrmPage() {
|
||||
}
|
||||
|
||||
const tenantId = ctx.tenantId;
|
||||
const [crmLeads, dailyRows] = await Promise.all([
|
||||
const [crmLeads, dailyRows, wonDeals] = await Promise.all([
|
||||
fetchCrmLeads(tenantId, useService, false),
|
||||
fetchAdDaily(tenantId, useService),
|
||||
fetchWonDeals(tenantId, useService),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -156,6 +158,7 @@ export default async function CrmPage() {
|
||||
portfolioWide={false}
|
||||
crmLeads={crmLeads}
|
||||
dailyRows={dailyRows}
|
||||
wonDeals={wonDeals}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
adDailyLookbackSince,
|
||||
fetchAdDailyPaged,
|
||||
} from '@/lib/fetch-ad-daily';
|
||||
import { fetchWonDeals } from '@/lib/fetch-won-deals';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -89,128 +90,6 @@ async function fetchCrmLeads(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Won deals for cohort CAC / revenue / ROAS. */
|
||||
async function fetchWonDeals(
|
||||
tenantId: string | null,
|
||||
useServiceRole: boolean
|
||||
): Promise<DealWonRow[]> {
|
||||
const select =
|
||||
'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name,source,name,lead_id';
|
||||
const selectFallback =
|
||||
'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name,lead_id';
|
||||
const pageSize = 1000;
|
||||
const out: DealWonRow[] = [];
|
||||
|
||||
if (useServiceRole) {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (!headers || !base) return [];
|
||||
const tidFilter = tenantId
|
||||
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||
: '';
|
||||
let useSelect = select;
|
||||
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/deals?select=${useSelect}${tidFilter}&stage=eq.won&order=won_at.desc.nullslast&limit=${pageSize}&offset=${offset}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) {
|
||||
if (useSelect !== selectFallback && offset === 0) {
|
||||
useSelect = selectFallback;
|
||||
offset = -pageSize;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const chunk = (await r.json()) as DealWonRow[];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
}
|
||||
} else {
|
||||
const supabase = await createClient();
|
||||
let useSelect = select;
|
||||
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||
const { data, error } = await supabase
|
||||
.from('deals')
|
||||
.select(useSelect)
|
||||
.eq('stage', 'won')
|
||||
.order('won_at', { ascending: false, nullsFirst: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
if (error && useSelect !== selectFallback && offset === 0) {
|
||||
useSelect = selectFallback;
|
||||
offset = -pageSize;
|
||||
continue;
|
||||
}
|
||||
if (error) break;
|
||||
const chunk = (data as DealWonRow[]) || [];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
}
|
||||
}
|
||||
|
||||
return enrichDealSources(out, tenantId, useServiceRole);
|
||||
}
|
||||
|
||||
/** Fill deal.source from linked lead when missing. */
|
||||
async function enrichDealSources(
|
||||
deals: DealWonRow[],
|
||||
tenantId: string | null,
|
||||
useServiceRole: boolean
|
||||
): Promise<DealWonRow[]> {
|
||||
const need = deals.filter((d) => !d.source && d.lead_id);
|
||||
if (!need.length) {
|
||||
return deals.map((d) => ({
|
||||
...d,
|
||||
source:
|
||||
d.source ||
|
||||
(/^order\s+\d+/i.test(d.name || '') ? 'ecommerce' : d.source) ||
|
||||
null,
|
||||
}));
|
||||
}
|
||||
const leadIds = [...new Set(need.map((d) => d.lead_id!).filter(Boolean))];
|
||||
const leadSource = new Map<string, string>();
|
||||
|
||||
if (useServiceRole) {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (headers && base) {
|
||||
for (let i = 0; i < leadIds.length; i += 200) {
|
||||
const chunk = leadIds.slice(i, i + 200);
|
||||
const tidFilter = tenantId
|
||||
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||
: '';
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/leads?select=id,source&id=in.(${chunk.map(encodeURIComponent).join(',')})${tidFilter}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) continue;
|
||||
const rows = (await r.json()) as { id: string; source?: string }[];
|
||||
for (const row of rows) {
|
||||
if (row.source) leadSource.set(row.id, row.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const supabase = await createClient();
|
||||
for (let i = 0; i < leadIds.length; i += 200) {
|
||||
const chunk = leadIds.slice(i, i + 200);
|
||||
const { data } = await supabase
|
||||
.from('leads')
|
||||
.select('id,source')
|
||||
.in('id', chunk);
|
||||
for (const row of (data as { id: string; source?: string }[]) || []) {
|
||||
if (row.source) leadSource.set(row.id, row.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deals.map((d) => {
|
||||
let source = d.source || (d.lead_id ? leadSource.get(d.lead_id) : null) || null;
|
||||
if (!source && /^order\s+\d+/i.test(d.name || '')) source = 'ecommerce';
|
||||
return { ...d, source };
|
||||
});
|
||||
}
|
||||
|
||||
/** Paginate organic & web daily metrics (GSC + GA4) for active tenant / portfolio. */
|
||||
async function fetchOrganicDaily(
|
||||
tenantId: string | null,
|
||||
|
||||
@@ -7,19 +7,23 @@ import {
|
||||
Briefcase,
|
||||
GitCompare,
|
||||
ListTree,
|
||||
Target,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AdDailyRow,
|
||||
CrmChannelStats,
|
||||
CrmDateLens,
|
||||
CrmLeadRow,
|
||||
DealWonRow,
|
||||
attributionCross,
|
||||
buildCrmChannelStats,
|
||||
buildCrmFunnel,
|
||||
buildCrmStatusBreakdown,
|
||||
filterDailyByDayRange,
|
||||
filterDealsForRange,
|
||||
filterLeadsByDayRange,
|
||||
formatEur,
|
||||
formatEurCents,
|
||||
formatNum,
|
||||
} from '@/lib/metrics';
|
||||
import { PageHeader, SegmentedControl, Surface } from '@/components/ui';
|
||||
@@ -56,11 +60,13 @@ export default function CrmDashboard({
|
||||
portfolioWide,
|
||||
crmLeads = [],
|
||||
dailyRows = [],
|
||||
wonDeals = [],
|
||||
}: {
|
||||
label: string;
|
||||
portfolioWide: boolean;
|
||||
crmLeads?: CrmLeadRow[];
|
||||
dailyRows?: AdDailyRow[];
|
||||
wonDeals?: DealWonRow[];
|
||||
}) {
|
||||
const { since, until, label: rangeHint } = useDateRange();
|
||||
const [lens, setLens] = useState<CrmDateLens>('created');
|
||||
@@ -82,6 +88,11 @@ export default function CrmDashboard({
|
||||
return filterDailyByDayRange(dailyRows, since, until);
|
||||
}, [dailyRows, since, until]);
|
||||
|
||||
const rangedDeals = useMemo(
|
||||
() => filterDealsForRange(wonDeals, since, until),
|
||||
[wonDeals, since, until]
|
||||
);
|
||||
|
||||
const funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]);
|
||||
const statusRows = useMemo(
|
||||
() => buildCrmStatusBreakdown(rangedLeads),
|
||||
@@ -92,13 +103,23 @@ export default function CrmDashboard({
|
||||
[rangedLeads]
|
||||
);
|
||||
const attribution = useMemo(
|
||||
() => attributionCross(rangedDaily, rangedLeads),
|
||||
[rangedDaily, rangedLeads]
|
||||
() => attributionCross(rangedDaily, rangedLeads, rangedDeals),
|
||||
[rangedDaily, rangedLeads, rangedDeals]
|
||||
);
|
||||
|
||||
const spendByChannel = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const r of attribution) m.set(r.channel, r.spend);
|
||||
const spendTotal = useMemo(
|
||||
() => attribution.reduce((a, r) => a + r.spend, 0),
|
||||
[attribution]
|
||||
);
|
||||
const ventasTotal = rangedDeals.length;
|
||||
const cacGlobal =
|
||||
ventasTotal > 0 && spendTotal > 0 ? spendTotal / ventasTotal : 0;
|
||||
|
||||
const cacByChannel = useMemo(() => {
|
||||
const m = new Map<string, { cac: number; spend: number; ventas: number }>();
|
||||
for (const r of attribution) {
|
||||
m.set(r.channel, { cac: r.cac, spend: r.spend, ventas: r.ventas });
|
||||
}
|
||||
return m;
|
||||
}, [attribution]);
|
||||
|
||||
@@ -108,6 +129,12 @@ export default function CrmDashboard({
|
||||
const otherChannels = channelRows.filter(
|
||||
(r) => !['meta', 'google'].includes(r.channel)
|
||||
);
|
||||
// Channels with ads spend / ventas but maybe no CRM lead row in this lens
|
||||
const extraCacChannels = attribution.filter(
|
||||
(r) =>
|
||||
(r.spend > 0 || r.ventas > 0) &&
|
||||
!channelRows.some((c) => c.channel === r.channel)
|
||||
);
|
||||
const leadMax = Math.max(1, ...channelRows.map((r) => r.leads));
|
||||
const statusMax = Math.max(1, ...statusRows.map((r) => r.count));
|
||||
|
||||
@@ -139,8 +166,9 @@ export default function CrmDashboard({
|
||||
<HelpDetails title="Cómo leer este CRM">
|
||||
<p>{LENS_HELP[lens]}</p>
|
||||
<p>
|
||||
El % a cliente es del CRM, no de ads. La inversión por canal es solo
|
||||
referencia del periodo.
|
||||
El % a cliente es del CRM, no de ads. El{' '}
|
||||
<strong>CAC</strong> = inversión ads del periodo ÷ ventas ganadas
|
||||
(deals won) del periodo; por canal usa la atribución del deal.
|
||||
</p>
|
||||
</HelpDetails>
|
||||
|
||||
@@ -160,7 +188,7 @@ export default function CrmDashboard({
|
||||
) : null}
|
||||
|
||||
<HeroBand>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
<HeroStat label="Leads" value={formatNum(funnel.total)} />
|
||||
<HeroStat
|
||||
label="Clientes"
|
||||
@@ -175,6 +203,17 @@ export default function CrmDashboard({
|
||||
label="% a cliente"
|
||||
value={`${funnel.clienteRate.toFixed(1)}%`}
|
||||
/>
|
||||
<HeroStat
|
||||
label="CAC global"
|
||||
value={cacGlobal > 0 ? formatEurCents(cacGlobal) : '—'}
|
||||
chip={
|
||||
<span className="text-[11px] text-gray-500">
|
||||
{ventasTotal > 0
|
||||
? `${formatNum(ventasTotal)} ventas · ${formatEur(spendTotal)} ads`
|
||||
: 'Sin ventas won en el periodo'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<HeroStat
|
||||
label="Perdidos"
|
||||
value={formatNum(funnel.leadPerdido)}
|
||||
@@ -210,12 +249,88 @@ export default function CrmDashboard({
|
||||
|
||||
<section>
|
||||
<SectionHead
|
||||
icon={<GitCompare className="w-4 h-4" />}
|
||||
title="% a cliente por canal"
|
||||
hint="Estado ganado en el CRM. Ads spend del periodo es referencia."
|
||||
icon={<Target className="w-4 h-4" />}
|
||||
title="CAC por canal"
|
||||
hint="Inversión ads ÷ ventas won atribuidas al canal (mismo criterio que Métricas)."
|
||||
/>
|
||||
<Surface padded={false}>
|
||||
{!channelRows.length ? (
|
||||
{!attribution.some((r) => r.spend > 0 || r.ventas > 0) ? (
|
||||
<p className="text-sm text-gray-500 py-10 text-center">
|
||||
Sin inversión ads ni ventas won en el periodo.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className={tableHeadClass()}>
|
||||
<tr>
|
||||
<th className="py-3 px-4 font-semibold">Canal</th>
|
||||
<th className="py-3 pr-4 font-semibold">Inversión</th>
|
||||
<th className="py-3 pr-4 font-semibold">Ventas</th>
|
||||
<th className="py-3 pr-4 font-semibold">CAC</th>
|
||||
<th className="py-3 pr-4 font-semibold">Importe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attribution
|
||||
.filter((r) => r.spend > 0 || r.ventas > 0)
|
||||
.map((row) => (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-100 hover:bg-brand-50/40"
|
||||
>
|
||||
<td className="py-3 px-4 font-semibold capitalize text-gray-800">
|
||||
{row.channel}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-800">
|
||||
{row.spend > 0 ? formatEur(row.spend) : '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-800">
|
||||
{row.ventas > 0 ? formatNum(row.ventas) : '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums font-semibold text-gray-900">
|
||||
{row.cac > 0 ? formatEurCents(row.cac) : '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-700">
|
||||
{row.importe > 0 ? formatEur(row.importe) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="bg-gray-50 font-semibold border-t border-gray-200">
|
||||
<td className="py-3 px-4 text-gray-900">Total</td>
|
||||
<td className="py-3 pr-4 tabular-nums">
|
||||
{spendTotal > 0 ? formatEur(spendTotal) : '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums">
|
||||
{ventasTotal > 0 ? formatNum(ventasTotal) : '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-900">
|
||||
{cacGlobal > 0 ? formatEurCents(cacGlobal) : '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums">
|
||||
{attribution.reduce((a, r) => a + r.importe, 0) > 0
|
||||
? formatEur(
|
||||
attribution.reduce((a, r) => a + r.importe, 0)
|
||||
)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHead
|
||||
icon={<GitCompare className="w-4 h-4" />}
|
||||
title="% a cliente por canal"
|
||||
hint="Estado ganado en el CRM. Ads spend y CAC del periodo son referencia."
|
||||
/>
|
||||
<Surface padded={false}>
|
||||
{!channelRows.length && !extraCacChannels.length ? (
|
||||
<p className="text-sm text-gray-500 py-10 text-center">
|
||||
Sin leads en esta lente/periodo.
|
||||
</p>
|
||||
@@ -231,41 +346,50 @@ export default function CrmDashboard({
|
||||
<th className="py-3 pr-4 font-semibold">Clientes</th>
|
||||
<th className="py-3 pr-4 font-semibold">% a cliente</th>
|
||||
<th className="py-3 pr-4 font-semibold">Inversión ads</th>
|
||||
<th className="py-3 pr-4 font-semibold">CAC</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...paidChannels, ...otherChannels].map((row) => (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-100 hover:bg-brand-50/40"
|
||||
>
|
||||
<td className="py-3 px-4 font-semibold capitalize text-gray-800">
|
||||
{row.channel}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<ShareBar
|
||||
value={row.leads}
|
||||
max={leadMax}
|
||||
label={formatNum(row.leads)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-800">
|
||||
{formatNum(row.cliente)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-3 pr-4 font-semibold tabular-nums ${rateClass(
|
||||
row.clienteRate
|
||||
)}`}
|
||||
{[...paidChannels, ...otherChannels].map((row) => {
|
||||
const cacRow = cacByChannel.get(row.channel);
|
||||
return (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-100 hover:bg-brand-50/40"
|
||||
>
|
||||
{row.clienteRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-700">
|
||||
{spendByChannel.get(row.channel)
|
||||
? formatEur(spendByChannel.get(row.channel)!)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<td className="py-3 px-4 font-semibold capitalize text-gray-800">
|
||||
{row.channel}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<ShareBar
|
||||
value={row.leads}
|
||||
max={leadMax}
|
||||
label={formatNum(row.leads)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-800">
|
||||
{formatNum(row.cliente)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-3 pr-4 font-semibold tabular-nums ${rateClass(
|
||||
row.clienteRate
|
||||
)}`}
|
||||
>
|
||||
{row.clienteRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-700">
|
||||
{cacRow && cacRow.spend > 0
|
||||
? formatEur(cacRow.spend)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums font-semibold text-gray-900">
|
||||
{cacRow && cacRow.cac > 0
|
||||
? formatEurCents(cacRow.cac)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -889,6 +889,7 @@ export default function MetricsDashboard({
|
||||
<th className="py-3 pr-4 font-semibold">CRM</th>
|
||||
<th className="py-3 pr-4 font-semibold">Gap</th>
|
||||
<th className="py-3 pr-4 font-semibold">Ventas</th>
|
||||
<th className="py-3 pr-4 font-semibold">CAC</th>
|
||||
<th className="py-3 pr-4 font-semibold">Importe</th>
|
||||
<th className="py-3 pr-4 font-semibold">ROAS</th>
|
||||
<th className="py-3 pr-4 font-semibold">CPL plat</th>
|
||||
@@ -962,6 +963,9 @@ export default function MetricsDashboard({
|
||||
<td className="py-3 pr-4 tabular-nums font-semibold text-gray-900">
|
||||
{row.ventas > 0 ? formatNum(row.ventas) : '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-800">
|
||||
{row.cac > 0 ? formatEurCents(row.cac) : '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-800">
|
||||
{row.importe > 0 ? formatEur(row.importe) : '—'}
|
||||
</td>
|
||||
@@ -1013,6 +1017,17 @@ export default function MetricsDashboard({
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-900">
|
||||
{formatNum(attributionTotals.ventas || ventasCount)}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-900">
|
||||
{(attributionTotals.ventas || ventasCount) > 0 &&
|
||||
attributionTotals.spend > 0
|
||||
? formatEurCents(
|
||||
attributionTotals.spend /
|
||||
(attributionTotals.ventas || ventasCount)
|
||||
)
|
||||
: periodTotals.cac > 0
|
||||
? formatEurCents(periodTotals.cac)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums text-gray-900">
|
||||
{attributionTotals.importe > 0 ||
|
||||
periodTotals.revenue > 0
|
||||
|
||||
132
src/lib/fetch-won-deals.ts
Normal file
132
src/lib/fetch-won-deals.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/** Shared won-deal fetch for Métricas / CRM (tenant-scoped CAC). */
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import type { DealWonRow } from '@/lib/metrics';
|
||||
|
||||
/** Fill deal.source from linked lead when missing. */
|
||||
async function enrichDealSources(
|
||||
deals: DealWonRow[],
|
||||
tenantId: string | null,
|
||||
useServiceRole: boolean
|
||||
): Promise<DealWonRow[]> {
|
||||
const need = deals.filter((d) => !d.source && d.lead_id);
|
||||
if (!need.length) {
|
||||
return deals.map((d) => ({
|
||||
...d,
|
||||
source:
|
||||
d.source ||
|
||||
(/^order\s+\d+/i.test(d.name || '') ? 'ecommerce' : d.source) ||
|
||||
null,
|
||||
}));
|
||||
}
|
||||
const leadIds = [...new Set(need.map((d) => d.lead_id!).filter(Boolean))];
|
||||
const leadSource = new Map<string, string>();
|
||||
|
||||
if (useServiceRole) {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (headers && base) {
|
||||
for (let i = 0; i < leadIds.length; i += 200) {
|
||||
const chunk = leadIds.slice(i, i + 200);
|
||||
const tidFilter = tenantId
|
||||
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||
: '';
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/leads?select=id,source&id=in.(${chunk
|
||||
.map(encodeURIComponent)
|
||||
.join(',')})${tidFilter}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) continue;
|
||||
const rows = (await r.json()) as { id: string; source?: string }[];
|
||||
for (const row of rows) {
|
||||
if (row.source) leadSource.set(row.id, row.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const supabase = await createClient();
|
||||
for (let i = 0; i < leadIds.length; i += 200) {
|
||||
const chunk = leadIds.slice(i, i + 200);
|
||||
const { data } = await supabase
|
||||
.from('leads')
|
||||
.select('id,source')
|
||||
.in('id', chunk);
|
||||
for (const row of (data as { id: string; source?: string }[]) || []) {
|
||||
if (row.source) leadSource.set(row.id, row.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deals.map((d) => {
|
||||
let source =
|
||||
d.source || (d.lead_id ? leadSource.get(d.lead_id) : null) || null;
|
||||
if (!source && /^order\s+\d+/i.test(d.name || '')) source = 'ecommerce';
|
||||
return { ...d, source };
|
||||
});
|
||||
}
|
||||
|
||||
/** Won deals for cohort CAC / revenue / ROAS. Always filter by tenant when provided. */
|
||||
export async function fetchWonDeals(
|
||||
tenantId: string | null,
|
||||
useServiceRole: boolean
|
||||
): Promise<DealWonRow[]> {
|
||||
const select =
|
||||
'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name,source,name,lead_id';
|
||||
const selectFallback =
|
||||
'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name,lead_id';
|
||||
const pageSize = 1000;
|
||||
const out: DealWonRow[] = [];
|
||||
|
||||
if (useServiceRole) {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (!headers || !base) return [];
|
||||
const tidFilter = tenantId
|
||||
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||
: '';
|
||||
let useSelect = select;
|
||||
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/deals?select=${useSelect}${tidFilter}&stage=eq.won&order=won_at.desc.nullslast&limit=${pageSize}&offset=${offset}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) {
|
||||
if (useSelect !== selectFallback && offset === 0) {
|
||||
useSelect = selectFallback;
|
||||
offset = -pageSize;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const chunk = (await r.json()) as DealWonRow[];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
}
|
||||
} else {
|
||||
const supabase = await createClient();
|
||||
let useSelect = select;
|
||||
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||
let q = supabase
|
||||
.from('deals')
|
||||
.select(useSelect)
|
||||
.eq('stage', 'won')
|
||||
.order('won_at', { ascending: false, nullsFirst: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
if (tenantId) q = q.eq('tenant_id', tenantId);
|
||||
const { data, error } = await q;
|
||||
if (error && useSelect !== selectFallback && offset === 0) {
|
||||
useSelect = selectFallback;
|
||||
offset = -pageSize;
|
||||
continue;
|
||||
}
|
||||
if (error) break;
|
||||
const chunk = (data as DealWonRow[]) || [];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
}
|
||||
}
|
||||
|
||||
return enrichDealSources(out, tenantId, useServiceRole);
|
||||
}
|
||||
Reference in New Issue
Block a user