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:
4Ventures
2026-10-08 09:48:48 +02:00
parent e3fe157fa5
commit 8ef21d98d7
5 changed files with 319 additions and 166 deletions

View File

@@ -5,6 +5,7 @@ import CrmDashboard from '@/components/CrmDashboard';
import CrmPortfolioBoard from '@/components/CrmPortfolioBoard'; import CrmPortfolioBoard from '@/components/CrmPortfolioBoard';
import { resolveActiveTenant } from '@/lib/tenant-context'; import { resolveActiveTenant } from '@/lib/tenant-context';
import { AdDailyRow, CrmLeadRow } from '@/lib/metrics'; import { AdDailyRow, CrmLeadRow } from '@/lib/metrics';
import { fetchWonDeals } from '@/lib/fetch-won-deals';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -145,9 +146,10 @@ export default async function CrmPage() {
} }
const tenantId = ctx.tenantId; const tenantId = ctx.tenantId;
const [crmLeads, dailyRows] = await Promise.all([ const [crmLeads, dailyRows, wonDeals] = await Promise.all([
fetchCrmLeads(tenantId, useService, false), fetchCrmLeads(tenantId, useService, false),
fetchAdDaily(tenantId, useService), fetchAdDaily(tenantId, useService),
fetchWonDeals(tenantId, useService),
]); ]);
return ( return (
@@ -156,6 +158,7 @@ export default async function CrmPage() {
portfolioWide={false} portfolioWide={false}
crmLeads={crmLeads} crmLeads={crmLeads}
dailyRows={dailyRows} dailyRows={dailyRows}
wonDeals={wonDeals}
/> />
); );
} }

View File

@@ -22,6 +22,7 @@ import {
adDailyLookbackSince, adDailyLookbackSince,
fetchAdDailyPaged, fetchAdDailyPaged,
} from '@/lib/fetch-ad-daily'; } from '@/lib/fetch-ad-daily';
import { fetchWonDeals } from '@/lib/fetch-won-deals';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -89,128 +90,6 @@ async function fetchCrmLeads(
return out; 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. */ /** Paginate organic & web daily metrics (GSC + GA4) for active tenant / portfolio. */
async function fetchOrganicDaily( async function fetchOrganicDaily(
tenantId: string | null, tenantId: string | null,

View File

@@ -7,19 +7,23 @@ import {
Briefcase, Briefcase,
GitCompare, GitCompare,
ListTree, ListTree,
Target,
} from 'lucide-react'; } from 'lucide-react';
import { import {
AdDailyRow, AdDailyRow,
CrmChannelStats, CrmChannelStats,
CrmDateLens, CrmDateLens,
CrmLeadRow, CrmLeadRow,
DealWonRow,
attributionCross, attributionCross,
buildCrmChannelStats, buildCrmChannelStats,
buildCrmFunnel, buildCrmFunnel,
buildCrmStatusBreakdown, buildCrmStatusBreakdown,
filterDailyByDayRange, filterDailyByDayRange,
filterDealsForRange,
filterLeadsByDayRange, filterLeadsByDayRange,
formatEur, formatEur,
formatEurCents,
formatNum, formatNum,
} from '@/lib/metrics'; } from '@/lib/metrics';
import { PageHeader, SegmentedControl, Surface } from '@/components/ui'; import { PageHeader, SegmentedControl, Surface } from '@/components/ui';
@@ -56,11 +60,13 @@ export default function CrmDashboard({
portfolioWide, portfolioWide,
crmLeads = [], crmLeads = [],
dailyRows = [], dailyRows = [],
wonDeals = [],
}: { }: {
label: string; label: string;
portfolioWide: boolean; portfolioWide: boolean;
crmLeads?: CrmLeadRow[]; crmLeads?: CrmLeadRow[];
dailyRows?: AdDailyRow[]; dailyRows?: AdDailyRow[];
wonDeals?: DealWonRow[];
}) { }) {
const { since, until, label: rangeHint } = useDateRange(); const { since, until, label: rangeHint } = useDateRange();
const [lens, setLens] = useState<CrmDateLens>('created'); const [lens, setLens] = useState<CrmDateLens>('created');
@@ -82,6 +88,11 @@ export default function CrmDashboard({
return filterDailyByDayRange(dailyRows, since, until); return filterDailyByDayRange(dailyRows, since, until);
}, [dailyRows, since, until]); }, [dailyRows, since, until]);
const rangedDeals = useMemo(
() => filterDealsForRange(wonDeals, since, until),
[wonDeals, since, until]
);
const funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]); const funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]);
const statusRows = useMemo( const statusRows = useMemo(
() => buildCrmStatusBreakdown(rangedLeads), () => buildCrmStatusBreakdown(rangedLeads),
@@ -92,13 +103,23 @@ export default function CrmDashboard({
[rangedLeads] [rangedLeads]
); );
const attribution = useMemo( const attribution = useMemo(
() => attributionCross(rangedDaily, rangedLeads), () => attributionCross(rangedDaily, rangedLeads, rangedDeals),
[rangedDaily, rangedLeads] [rangedDaily, rangedLeads, rangedDeals]
); );
const spendByChannel = useMemo(() => { const spendTotal = useMemo(
const m = new Map<string, number>(); () => attribution.reduce((a, r) => a + r.spend, 0),
for (const r of attribution) m.set(r.channel, r.spend); [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; return m;
}, [attribution]); }, [attribution]);
@@ -108,6 +129,12 @@ export default function CrmDashboard({
const otherChannels = channelRows.filter( const otherChannels = channelRows.filter(
(r) => !['meta', 'google'].includes(r.channel) (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 leadMax = Math.max(1, ...channelRows.map((r) => r.leads));
const statusMax = Math.max(1, ...statusRows.map((r) => r.count)); 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"> <HelpDetails title="Cómo leer este CRM">
<p>{LENS_HELP[lens]}</p> <p>{LENS_HELP[lens]}</p>
<p> <p>
El % a cliente es del CRM, no de ads. La inversión por canal es solo El % a cliente es del CRM, no de ads. El{' '}
referencia del periodo. <strong>CAC</strong> = inversión ads del periodo ÷ ventas ganadas
(deals won) del periodo; por canal usa la atribución del deal.
</p> </p>
</HelpDetails> </HelpDetails>
@@ -160,7 +188,7 @@ export default function CrmDashboard({
) : null} ) : null}
<HeroBand> <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="Leads" value={formatNum(funnel.total)} />
<HeroStat <HeroStat
label="Clientes" label="Clientes"
@@ -175,6 +203,17 @@ export default function CrmDashboard({
label="% a cliente" label="% a cliente"
value={`${funnel.clienteRate.toFixed(1)}%`} 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 <HeroStat
label="Perdidos" label="Perdidos"
value={formatNum(funnel.leadPerdido)} value={formatNum(funnel.leadPerdido)}
@@ -210,12 +249,88 @@ export default function CrmDashboard({
<section> <section>
<SectionHead <SectionHead
icon={<GitCompare className="w-4 h-4" />} icon={<Target className="w-4 h-4" />}
title="% a cliente por canal" title="CAC por canal"
hint="Estado ganado en el CRM. Ads spend del periodo es referencia." hint="Inversión ads ÷ ventas won atribuidas al canal (mismo criterio que Métricas)."
/> />
<Surface padded={false}> <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"> <p className="text-sm text-gray-500 py-10 text-center">
Sin leads en esta lente/periodo. Sin leads en esta lente/periodo.
</p> </p>
@@ -231,10 +346,13 @@ export default function CrmDashboard({
<th className="py-3 pr-4 font-semibold">Clientes</th> <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">% a cliente</th>
<th className="py-3 pr-4 font-semibold">Inversión ads</th> <th className="py-3 pr-4 font-semibold">Inversión ads</th>
<th className="py-3 pr-4 font-semibold">CAC</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{[...paidChannels, ...otherChannels].map((row) => ( {[...paidChannels, ...otherChannels].map((row) => {
const cacRow = cacByChannel.get(row.channel);
return (
<tr <tr
key={row.channel} key={row.channel}
className="border-b border-gray-100 hover:bg-brand-50/40" className="border-b border-gray-100 hover:bg-brand-50/40"
@@ -260,12 +378,18 @@ export default function CrmDashboard({
{row.clienteRate.toFixed(1)}% {row.clienteRate.toFixed(1)}%
</td> </td>
<td className="py-3 pr-4 tabular-nums text-gray-700"> <td className="py-3 pr-4 tabular-nums text-gray-700">
{spendByChannel.get(row.channel) {cacRow && cacRow.spend > 0
? formatEur(spendByChannel.get(row.channel)!) ? 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> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -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">CRM</th>
<th className="py-3 pr-4 font-semibold">Gap</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">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">Importe</th>
<th className="py-3 pr-4 font-semibold">ROAS</th> <th className="py-3 pr-4 font-semibold">ROAS</th>
<th className="py-3 pr-4 font-semibold">CPL plat</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"> <td className="py-3 pr-4 tabular-nums font-semibold text-gray-900">
{row.ventas > 0 ? formatNum(row.ventas) : '—'} {row.ventas > 0 ? formatNum(row.ventas) : '—'}
</td> </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"> <td className="py-3 pr-4 tabular-nums text-gray-800">
{row.importe > 0 ? formatEur(row.importe) : '—'} {row.importe > 0 ? formatEur(row.importe) : '—'}
</td> </td>
@@ -1013,6 +1017,17 @@ export default function MetricsDashboard({
<td className="py-3 pr-4 tabular-nums text-gray-900"> <td className="py-3 pr-4 tabular-nums text-gray-900">
{formatNum(attributionTotals.ventas || ventasCount)} {formatNum(attributionTotals.ventas || ventasCount)}
</td> </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"> <td className="py-3 pr-4 tabular-nums text-gray-900">
{attributionTotals.importe > 0 || {attributionTotals.importe > 0 ||
periodTotals.revenue > 0 periodTotals.revenue > 0

132
src/lib/fetch-won-deals.ts Normal file
View 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);
}