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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user