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