Ship CRM health, cohort CAC, GHL/Shopify sync, and agency alerts.

Add WooCommerce health monitoring, 60d cohort CAC in CRM and portfolio,
incremental GHL upsert, Shopify orders→deals, and daily health-alert cron.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-08 12:28:38 +02:00
parent 4baf256750
commit 71110915af
19 changed files with 1177 additions and 76 deletions

View File

@@ -34,6 +34,11 @@ type PortfolioRow = {
agency_followed?: boolean;
agency_notes?: string | null;
byProvider?: Record<string, IntegCell | undefined>;
economics?: {
spend30: number;
deals30: number;
cac30: number;
};
stats: {
connected: number;
errors: number;
@@ -120,6 +125,7 @@ export default function AgencyPortfolioBoard() {
const [providers, setProviders] = useState<Provider[]>([]);
const [alerts, setAlerts] = useState<AlertRow[]>([]);
const [warning, setWarning] = useState<string | null>(null);
const [portfolioCac30, setPortfolioCac30] = useState(0);
const [loading, setLoading] = useState(true);
const [showArchived, setShowArchived] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
@@ -132,6 +138,7 @@ export default function AgencyPortfolioBoard() {
setProviders(data.providers || []);
setAlerts((data.alerts || []).filter((a: AlertRow) => !a.read_at));
setWarning(data.warning || null);
setPortfolioCac30(Number(data.portfolioCac30 || 0));
setLoading(false);
}
@@ -192,6 +199,19 @@ export default function AgencyPortfolioBoard() {
</p>
)}
{portfolioCac30 > 0 && (
<p className="text-xs text-gray-600 border border-gray-200 rounded-lg px-3 py-2 bg-white">
CAC portafolio 30d (ponderado por spend):{' '}
<span className="font-semibold text-gray-900 tabular-nums">
{portfolioCac30.toLocaleString('es-ES', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
})}
</span>
</p>
)}
{alerts.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
@@ -310,6 +330,13 @@ export default function AgencyPortfolioBoard() {
<p className="text-[11px] text-gray-500 truncate">
{t.slug} · {t.stats.connected}/
{t.stats.totalProviders} conectadas
{t.economics && t.economics.deals30 > 0
? ` · CAC 30d ${t.economics.cac30.toLocaleString('es-ES', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
})} (${t.economics.deals30} ventas)`
: ''}
</p>
</div>

View File

@@ -31,7 +31,10 @@ const PLACEHOLDERS: Record<string, string> = {
google_drive: 'URL de carpeta Drive o folder id',
wordpress: 'https://sitio.com + Application Password',
prestashop: 'https://tienda.com + API key',
shopify: 'mitienda.myshopify.com + Admin API token',
shopify:
'Shop (mitienda.myshopify.com) en ID externo + Admin API token en meta.access_token',
woocommerce:
'Slot de salud Woo→CRM. Pedidos llegan vía Clientify; API Woo directa = siguiente fase',
instagram: '@handle o Instagram Business Account ID',
facebook: 'Page ID / facebook.com/…',
tiktok: '@handle o Advertiser / Business ID',

View File

@@ -19,6 +19,8 @@ import {
buildCrmChannelStats,
buildCrmFunnel,
buildCrmStatusBreakdown,
DEFAULT_COHORT_WINDOW_DAYS,
cohortWonDeals,
filterDailyByDayRange,
filterDealsForRange,
filterLeadsByDayRange,
@@ -115,6 +117,21 @@ export default function CrmDashboard({
const cacGlobal =
ventasTotal > 0 && spendTotal > 0 ? spendTotal / ventasTotal : 0;
const cohortDeals = useMemo(() => {
if (!since || !until) return [];
return cohortWonDeals({
leads: crmLeads,
deals: wonDeals,
since,
until,
windowDays: DEFAULT_COHORT_WINDOW_DAYS,
});
}, [crmLeads, wonDeals, since, until]);
const cacCohort =
cohortDeals.length > 0 && spendTotal > 0
? spendTotal / cohortDeals.length
: 0;
const cacByChannel = useMemo(() => {
const m = new Map<
string,
@@ -210,9 +227,15 @@ export default function CrmDashboard({
source=google (hay que mapear la fuente en el CRM).
</p>
<p>
<strong>Ventas / CAC</strong> usan deals won del CRM. Un pedido
WooCommerce suele entrar como <em>ecommerce</em>, aunque el clic haya
sido de Google Brand — sin UTM/gclid no se reasigna a Google.
<strong>Ventas / CAC caja</strong> = deals won con <em>won_at</em> en
el periodo. Un pedido WooCommerce suele entrar como{' '}
<em>ecommerce</em> sin UTM/gclid.
</p>
<p>
<strong>CAC cohort ({DEFAULT_COHORT_WINDOW_DAYS}d)</strong> = inversión
del periodo ÷ leads creados en el periodo que cerraron en ≤
{DEFAULT_COHORT_WINDOW_DAYS} días. Mejor para leer adquisición del
mes.
</p>
</HelpDetails>
@@ -232,7 +255,7 @@ export default function CrmDashboard({
) : null}
<HeroBand>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 md:gap-6">
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 md:gap-6">
<HeroStat label="Leads" value={formatNum(funnel.total)} />
<HeroStat
label="Clientes"
@@ -248,7 +271,7 @@ export default function CrmDashboard({
value={`${funnel.clienteRate.toFixed(1)}%`}
/>
<HeroStat
label="CAC global"
label="CAC caja"
value={cacGlobal > 0 ? formatEurCents(cacGlobal) : '—'}
chip={
<span className="text-[11px] text-gray-500">
@@ -258,6 +281,17 @@ export default function CrmDashboard({
</span>
}
/>
<HeroStat
label={`CAC cohort ${DEFAULT_COHORT_WINDOW_DAYS}d`}
value={cacCohort > 0 ? formatEurCents(cacCohort) : '—'}
chip={
<span className="text-[11px] text-gray-500">
{cohortDeals.length > 0
? `${formatNum(cohortDeals.length)} cierres de altas del periodo`
: 'Sin cierres cohort (falta lead_id o ventana)'}
</span>
}
/>
<HeroStat
label="Perdidos"
value={formatNum(funnel.leadPerdido)}