fix: cohort CAC/ROAS desde deals Clientify y más contraste en dashboards
Los totales de Métricas usaban solo ad_daily (cohort a 0). Ahora suman deals won; selectores y tipografía ganan contraste sobre fondo blanco. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -84,7 +84,7 @@ export default async function AgencyPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-gray-400 font-medium">Clientes / Tenants</span>
|
||||
<span className="text-xs text-gray-600 font-semibold">Clientes / Tenants</span>
|
||||
<Building2 className="w-4 h-4 text-brand-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900">{tenants.length}</p>
|
||||
@@ -93,7 +93,7 @@ export default async function AgencyPage() {
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-gray-400 font-medium">Inversión Gestionada</span>
|
||||
<span className="text-xs text-gray-600 font-semibold">Inversión Gestionada</span>
|
||||
<DollarSign className="w-4 h-4 text-blue-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900">
|
||||
@@ -104,7 +104,7 @@ export default async function AgencyPage() {
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-gray-400 font-medium">Leads Captados</span>
|
||||
<span className="text-xs text-gray-600 font-semibold">Leads Captados</span>
|
||||
<Users className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900">{totalLeads}</p>
|
||||
@@ -113,7 +113,7 @@ export default async function AgencyPage() {
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-gray-400 font-medium">ROAS Blended Agencia</span>
|
||||
<span className="text-xs text-gray-600 font-semibold">ROAS Blended Agencia</span>
|
||||
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-gray-900">{blendedRoas}x</p>
|
||||
|
||||
@@ -212,7 +212,7 @@ export default function AsistentePage() {
|
||||
<div className="px-6 py-2.5 bg-white/80 border-t border-gray-200 flex gap-2 overflow-x-auto text-xs text-gray-600">
|
||||
<button
|
||||
onClick={() => sendMessage('¿Qué puedo preguntar?')}
|
||||
className="px-3 py-1.5 rounded-full bg-brand-500/20 text-brand-400 hover:bg-brand-500/30 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-brand-500/40"
|
||||
className="px-3 py-1.5 rounded-full bg-brand-50 text-brand-700 hover:bg-brand-100 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-brand-200 font-medium"
|
||||
>
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
❓ ¿Qué puedo preguntar?
|
||||
|
||||
@@ -257,7 +257,9 @@ export default function CalendarClient({
|
||||
type="button"
|
||||
onClick={() => setView(k)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md ${
|
||||
view === k ? 'bg-brand-500/20 text-brand-300' : 'text-gray-400'
|
||||
view === k
|
||||
? 'bg-brand-500 text-white shadow-sm font-semibold'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -324,7 +326,7 @@ export default function CalendarClient({
|
||||
: p === 'instagram'
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white'
|
||||
: `${meta?.color || 'bg-gray-700'} text-white`
|
||||
: 'bg-gray-100 text-gray-600 hover:text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{meta && <meta.Icon className="w-3.5 h-3.5" />}
|
||||
@@ -411,7 +413,7 @@ export default function CalendarClient({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPost(null)}
|
||||
className="text-gray-400 hover:text-gray-900 p-1 rounded-lg hover:bg-gray-50"
|
||||
className="text-gray-600 hover:text-gray-900 p-1 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -429,7 +431,7 @@ export default function CalendarClient({
|
||||
{selectedPost.hook}
|
||||
</Block>
|
||||
<Block label="Copy">{selectedPost.body}</Block>
|
||||
<Block label="CTA" className="text-brand-300">
|
||||
<Block label="CTA" className="text-brand-700">
|
||||
{selectedPost.cta}
|
||||
</Block>
|
||||
<Block label="Hashtags" className="text-blue-400 font-mono text-xs">
|
||||
@@ -593,7 +595,7 @@ function PostCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="text-xs text-brand-400 hover:text-brand-300 font-semibold inline-flex items-center gap-1"
|
||||
className="text-xs text-brand-400 hover:text-brand-700 font-semibold inline-flex items-center gap-1"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" /> Ver / comentar
|
||||
</button>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default async function CanalDashPage({
|
||||
<div>
|
||||
<Link
|
||||
href="/dashboard/kpis"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-900 mb-3"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-gray-900 mb-3"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" /> Volver a métricas
|
||||
</Link>
|
||||
|
||||
@@ -6,9 +6,11 @@ import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import {
|
||||
AdDailyRow,
|
||||
CrmLeadRow,
|
||||
DealWonRow,
|
||||
KpiWeeklyRow,
|
||||
buildChannelSeries,
|
||||
buildComparisons,
|
||||
mergeDealsIntoWeekly,
|
||||
resolveWeeklyRows,
|
||||
sumDaily,
|
||||
sumWeekly,
|
||||
@@ -46,7 +48,6 @@ async function fetchCrmLeads(
|
||||
return out;
|
||||
}
|
||||
|
||||
// Client path: RLS already scopes tenant
|
||||
const supabase = await createClient();
|
||||
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||
const { data } = await supabase
|
||||
@@ -62,6 +63,50 @@ 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';
|
||||
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)}`
|
||||
: '';
|
||||
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/deals?select=${select}${tidFilter}&stage=eq.won&order=won_at.desc.nullslast&limit=${pageSize}&offset=${offset}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) break;
|
||||
const chunk = (await r.json()) as DealWonRow[];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||
const { data } = await supabase
|
||||
.from('deals')
|
||||
.select(select)
|
||||
.eq('stage', 'won')
|
||||
.order('won_at', { ascending: false, nullsFirst: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
const chunk = (data as DealWonRow[]) || [];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default async function KpisPage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
@@ -75,9 +120,9 @@ export default async function KpisPage() {
|
||||
let weekly: KpiWeeklyRow[] = [];
|
||||
let daily: AdDailyRow[] = [];
|
||||
let crmLeads: CrmLeadRow[] = [];
|
||||
let wonDeals: DealWonRow[] = [];
|
||||
|
||||
if (agency && (ctx.portfolioWide || ctx.viaView)) {
|
||||
// Service role for portfolio / zoom (bypass single-tenant RLS)
|
||||
if (ctx.portfolioWide) {
|
||||
const w = await restGet<KpiWeeklyRow[]>(
|
||||
'/rest/v1/kpi_weekly?select=*&order=week.desc'
|
||||
@@ -87,8 +132,8 @@ export default async function KpisPage() {
|
||||
);
|
||||
weekly = w.data || [];
|
||||
daily = d.data || [];
|
||||
// Attribution cross is clearest per-tenant; skip portfolio dump.
|
||||
crmLeads = [];
|
||||
wonDeals = await fetchWonDeals(null, true);
|
||||
} else if (ctx.tenantId) {
|
||||
const tid = encodeURIComponent(ctx.tenantId);
|
||||
const w = await restGet<KpiWeeklyRow[]>(
|
||||
@@ -100,6 +145,7 @@ export default async function KpisPage() {
|
||||
weekly = w.data || [];
|
||||
daily = d.data || [];
|
||||
crmLeads = await fetchCrmLeads(ctx.tenantId, true);
|
||||
wonDeals = await fetchWonDeals(ctx.tenantId, true);
|
||||
}
|
||||
} else {
|
||||
const { data: w } = await supabase
|
||||
@@ -114,15 +160,16 @@ export default async function KpisPage() {
|
||||
weekly = (w as KpiWeeklyRow[]) || [];
|
||||
daily = (d as AdDailyRow[]) || [];
|
||||
crmLeads = await fetchCrmLeads(ctx.tenantId, false);
|
||||
wonDeals = await fetchWonDeals(ctx.tenantId, false);
|
||||
}
|
||||
|
||||
weekly = resolveWeeklyRows(weekly, daily);
|
||||
weekly = mergeDealsIntoWeekly(resolveWeeklyRows(weekly, daily), wonDeals);
|
||||
|
||||
const comparisons = buildComparisons(weekly);
|
||||
const month =
|
||||
comparisons.currentMonth.spend > 0 || comparisons.currentMonth.leads > 0
|
||||
? comparisons.currentMonth
|
||||
: sumWeekly(weekly); // fallback: all available weeks if current month empty
|
||||
: sumWeekly(weekly);
|
||||
const dailyTotals = sumDaily(daily);
|
||||
const sparse = weekly.length < 4;
|
||||
const channelSeries = buildChannelSeries(weekly);
|
||||
@@ -140,6 +187,7 @@ export default async function KpisPage() {
|
||||
channelPoints={channelSeries.points}
|
||||
dailyRows={daily}
|
||||
crmLeads={crmLeads}
|
||||
wonDeals={wonDeals}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ function CreativeCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoom}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-brand-500/40 bg-brand-500/10 px-2.5 py-1 text-[11px] font-medium text-brand-300 hover:bg-brand-500/20"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-brand-500/40 bg-brand-500/10 px-2.5 py-1 text-[11px] font-medium text-brand-700 hover:bg-brand-500/20"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" /> Zoom
|
||||
</button>
|
||||
@@ -297,7 +297,7 @@ function CreativeCard({
|
||||
</div>
|
||||
) : null}
|
||||
{ad.cta ? (
|
||||
<p className="text-[10px] text-brand-300 uppercase tracking-wide">
|
||||
<p className="text-[10px] text-brand-700 uppercase tracking-wide">
|
||||
CTA · {ad.cta}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -423,7 +423,7 @@ export function CreativeZoomModal({
|
||||
</p>
|
||||
{ad.cta ? (
|
||||
<p>
|
||||
CTA: <span className="text-brand-300">{ad.cta}</span>
|
||||
CTA: <span className="text-brand-700">{ad.cta}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{ad.businessName ? (
|
||||
|
||||
@@ -252,7 +252,7 @@ export default function AgencyPortfolioBoard() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg border border-gray-200 text-gray-400 hover:text-gray-900"
|
||||
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
{showArchived ? (
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
@@ -354,7 +354,7 @@ export default function AgencyPortfolioBoard() {
|
||||
type="button"
|
||||
disabled={busyId === t.id}
|
||||
onClick={() => void setFollowed(t.id, archived)}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border border-gray-200 text-gray-400 hover:text-gray-900"
|
||||
className="text-xs px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:text-gray-900"
|
||||
title={
|
||||
archived
|
||||
? 'Volver a seguir en el portafolio'
|
||||
|
||||
@@ -440,7 +440,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(a.id)}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-gray-900 hover:bg-gray-50"
|
||||
className="p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-50"
|
||||
title="Descargar"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
@@ -498,7 +498,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDrive(f)}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-gray-900 hover:bg-gray-50"
|
||||
className="p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-50"
|
||||
title="Abrir en Drive"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
@@ -526,7 +526,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailId(null)}
|
||||
className="text-xs text-gray-400 hover:text-gray-900"
|
||||
className="text-xs text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function BudgetSimulator({
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold uppercase transition-all ${
|
||||
channel === ch.id
|
||||
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20'
|
||||
: 'bg-gray-100 text-gray-400 hover:text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{ch.label}
|
||||
@@ -224,7 +224,7 @@ export default function BudgetSimulator({
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-brand-300">
|
||||
<span className="text-xs font-semibold text-brand-700">
|
||||
Esperado (p50)
|
||||
</span>
|
||||
<Sparkles className="w-4 h-4 text-brand-400" />
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function ClientIntegrationsPanel({
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/dashboard/agencia"
|
||||
className="text-gray-400 hover:text-gray-900"
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
|
||||
@@ -233,7 +233,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-900 p-1 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
className="text-gray-600 hover:text-gray-900 p-1 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -409,7 +409,7 @@ Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="px-4 py-2 rounded-lg text-xs font-semibold text-gray-400 hover:text-gray-900 hover:bg-gray-50 transition-all cursor-pointer"
|
||||
className="px-4 py-2 rounded-lg text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition-all cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function CrmDashboard({
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarRange className="w-4 h-4 text-gray-500" />
|
||||
<div className="flex gap-1 rounded-lg border border-white/[0.08] p-0.5 bg-gray-50">
|
||||
<div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 bg-gray-50">
|
||||
{(
|
||||
[
|
||||
['7d', '7d'],
|
||||
@@ -116,10 +116,10 @@ export default function CrmDashboard({
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setRange(k)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md ${
|
||||
className={`px-2.5 py-1 text-xs font-semibold rounded-md ${
|
||||
range === k
|
||||
? 'bg-brand-500/20 text-brand-300'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
? 'bg-brand-500 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
{lbl}
|
||||
@@ -131,7 +131,7 @@ export default function CrmDashboard({
|
||||
/>
|
||||
|
||||
{!crmLeads.length ? (
|
||||
<p className="text-sm text-amber-400/90 border border-amber-500/20 rounded-lg px-4 py-3">
|
||||
<p className="text-sm text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-4 py-3">
|
||||
Sin leads Clientify en este contexto. Importa el histórico o activa el
|
||||
webhook (`?tenant=…`). En portafolio, elige un cliente en Contexto.
|
||||
</p>
|
||||
@@ -238,7 +238,7 @@ export default function CrmDashboard({
|
||||
{statusRows.map((row) => (
|
||||
<tr
|
||||
key={row.status}
|
||||
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-2.5 pr-3 text-gray-800 capitalize">
|
||||
{row.status}
|
||||
@@ -308,9 +308,9 @@ function ChannelTable({
|
||||
return (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-2.5 pr-3 text-brand-300 font-semibold capitalize">
|
||||
<td className="py-2.5 pr-3 text-brand-700 font-semibold capitalize">
|
||||
{row.channel}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
|
||||
@@ -102,8 +102,8 @@ export default function CrmPortfolioBoard({
|
||||
onClick={() => setRange(k)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md ${
|
||||
range === k
|
||||
? 'bg-brand-500/20 text-brand-300'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
? 'bg-brand-500 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
{lbl}
|
||||
@@ -137,7 +137,7 @@ export default function CrmPortfolioBoard({
|
||||
{byTenant.map((t) => (
|
||||
<tr
|
||||
key={t.tenantId}
|
||||
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-2.5 pr-3 text-gray-900 font-semibold">
|
||||
{t.name}
|
||||
@@ -216,9 +216,9 @@ export default function CrmPortfolioBoard({
|
||||
{t.channels.slice(0, 12).map((row) => (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-2 pr-3 text-brand-300 font-semibold capitalize">
|
||||
<td className="py-2 pr-3 text-brand-700 font-semibold capitalize">
|
||||
{row.channel}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-gray-800">
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
AttributionChannelRow,
|
||||
ChannelWeekPoint,
|
||||
CrmLeadRow,
|
||||
DealWonRow,
|
||||
Delta,
|
||||
MetricTotals,
|
||||
formatEur,
|
||||
@@ -26,14 +27,16 @@ import {
|
||||
formatPct,
|
||||
attributionCross,
|
||||
filterDailyByDayRange,
|
||||
filterDealsForRange,
|
||||
filterLeadsByDayRange,
|
||||
metricTotalsFromDaily,
|
||||
resolveKpiDayRange,
|
||||
sumWeekly,
|
||||
withCohortFromDeals,
|
||||
KpiWeeklyRow,
|
||||
KpiRangeKey,
|
||||
} from '@/lib/metrics';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import { PageHeader, SegmentedControl } from '@/components/ui';
|
||||
|
||||
type Card = {
|
||||
key: keyof MetricTotals;
|
||||
@@ -42,11 +45,28 @@ type Card = {
|
||||
format: (v: number) => string;
|
||||
icon: React.ReactNode;
|
||||
invert?: boolean;
|
||||
/** When true and value is 0 with no deals, show em dash + empty state. */
|
||||
cohort?: boolean;
|
||||
};
|
||||
|
||||
type RangeKey = KpiRangeKey;
|
||||
|
||||
function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
|
||||
function DeltaTriple({
|
||||
delta,
|
||||
invert,
|
||||
empty,
|
||||
}: {
|
||||
delta: Delta;
|
||||
invert?: boolean;
|
||||
empty?: boolean;
|
||||
}) {
|
||||
if (empty) {
|
||||
return (
|
||||
<p className="mt-3 text-[11px] text-gray-500 border border-dashed border-gray-200 rounded-md px-2 py-2 text-center">
|
||||
Sin cierres en el periodo — sincroniza deals Clientify
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const cells: { label: string; v: number | null }[] = [
|
||||
{ label: 'WoW', v: delta.wow },
|
||||
{ label: 'MoM', v: delta.mom },
|
||||
@@ -55,16 +75,15 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
|
||||
return (
|
||||
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
||||
{cells.map((c) => {
|
||||
const good =
|
||||
c.v === null ? null : invert ? c.v < 0 : c.v > 0;
|
||||
const good = c.v === null ? null : invert ? c.v < 0 : c.v > 0;
|
||||
const color =
|
||||
c.v === null
|
||||
? 'text-gray-500'
|
||||
: good
|
||||
? 'text-emerald-400'
|
||||
? 'text-emerald-700'
|
||||
: c.v === 0
|
||||
? 'text-gray-400'
|
||||
: 'text-red-400';
|
||||
? 'text-gray-600'
|
||||
: 'text-red-600';
|
||||
const Icon =
|
||||
c.v === null || c.v === 0
|
||||
? Minus
|
||||
@@ -74,15 +93,15 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
key={c.label}
|
||||
className="rounded-md bg-gray-50 border border-gray-200 px-1.5 py-1 text-center"
|
||||
className="rounded-md bg-gray-50 border border-gray-200 px-1.5 py-1.5 text-center"
|
||||
>
|
||||
<p className="text-[9px] uppercase tracking-wider text-gray-500">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600">
|
||||
{c.label}
|
||||
</p>
|
||||
<p
|
||||
className={`text-[11px] font-semibold inline-flex items-center justify-center gap-0.5 ${color}`}
|
||||
className={`text-xs font-bold inline-flex items-center justify-center gap-0.5 ${color}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{formatPct(c.v)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -93,11 +112,11 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
|
||||
}
|
||||
|
||||
function gapClass(gap: number): string {
|
||||
if (gap === 0) return 'text-gray-400';
|
||||
if (gap === 0) return 'text-gray-600';
|
||||
const abs = Math.abs(gap);
|
||||
if (abs <= 5) return 'text-emerald-400';
|
||||
if (abs <= 25) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
if (abs <= 5) return 'text-emerald-700';
|
||||
if (abs <= 25) return 'text-amber-700';
|
||||
return 'text-red-600';
|
||||
}
|
||||
|
||||
export default function MetricsDashboard({
|
||||
@@ -112,6 +131,7 @@ export default function MetricsDashboard({
|
||||
channelPoints = [],
|
||||
dailyRows = [],
|
||||
crmLeads = [],
|
||||
wonDeals = [],
|
||||
}: {
|
||||
label: string;
|
||||
portfolioWide: boolean;
|
||||
@@ -127,10 +147,9 @@ export default function MetricsDashboard({
|
||||
sparse: boolean;
|
||||
weeklyRows?: KpiWeeklyRow[];
|
||||
channelPoints?: ChannelWeekPoint[];
|
||||
/** Raw ad_daily — period totals/matrix use calendar days (same as Ad Tracker). */
|
||||
dailyRows?: AdDailyRow[];
|
||||
/** Clientify leads for CRM vs platform attribution. */
|
||||
crmLeads?: CrmLeadRow[];
|
||||
wonDeals?: DealWonRow[];
|
||||
}) {
|
||||
const [range, setRange] = useState<RangeKey>('7d');
|
||||
|
||||
@@ -148,25 +167,39 @@ export default function MetricsDashboard({
|
||||
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until);
|
||||
}, [crmLeads, dayWindow]);
|
||||
|
||||
const rangedDeals = useMemo(
|
||||
() =>
|
||||
filterDealsForRange(
|
||||
wonDeals,
|
||||
dayWindow?.since ?? null,
|
||||
dayWindow?.until ?? null
|
||||
),
|
||||
[wonDeals, dayWindow]
|
||||
);
|
||||
|
||||
const periodTotals = useMemo(() => {
|
||||
if (rangedDaily.length) return metricTotalsFromDaily(rangedDaily);
|
||||
// Fallback when no ad_daily: approximate from weekly (legacy)
|
||||
if (!dayWindow) return sumWeekly(weeklyRows);
|
||||
const from = dayWindow.since;
|
||||
const until = dayWindow.until;
|
||||
const ranged = weeklyRows.filter((r) => {
|
||||
if (!r.week) return false;
|
||||
const w = r.week.slice(0, 10);
|
||||
return w <= until && w >= from;
|
||||
});
|
||||
return ranged.length ? sumWeekly(ranged) : month;
|
||||
}, [rangedDaily, weeklyRows, dayWindow, month]);
|
||||
let base: MetricTotals;
|
||||
if (rangedDaily.length) {
|
||||
base = metricTotalsFromDaily(rangedDaily);
|
||||
} else if (!dayWindow) {
|
||||
base = sumWeekly(weeklyRows);
|
||||
} else {
|
||||
const from = dayWindow.since;
|
||||
const until = dayWindow.until;
|
||||
const ranged = weeklyRows.filter((r) => {
|
||||
if (!r.week) return false;
|
||||
const w = r.week.slice(0, 10);
|
||||
return w <= until && w >= from;
|
||||
});
|
||||
base = ranged.length ? sumWeekly(ranged) : month;
|
||||
}
|
||||
return withCohortFromDeals(base, rangedDeals);
|
||||
}, [rangedDaily, rangedDeals, weeklyRows, dayWindow, month]);
|
||||
|
||||
const attributionRows: AttributionChannelRow[] = useMemo(() => {
|
||||
if (rangedDaily.length || rangedLeads.length) {
|
||||
return attributionCross(rangedDaily, rangedLeads);
|
||||
}
|
||||
// Fallback: weekly series without CRM cross
|
||||
const map = new Map<string, { spend: number; leads: number }>();
|
||||
const src =
|
||||
range === 'all'
|
||||
@@ -197,6 +230,7 @@ export default function MetricsDashboard({
|
||||
|
||||
const crmPeriodTotal = rangedLeads.length;
|
||||
const hasCrm = crmLeads.length > 0;
|
||||
const hasCohort = rangedDeals.length > 0;
|
||||
|
||||
const rangeHint = dayWindow
|
||||
? `${dayWindow.since} → ${dayWindow.until}`
|
||||
@@ -208,44 +242,51 @@ export default function MetricsDashboard({
|
||||
label: 'Inversión ads',
|
||||
hint: 'Paid del periodo',
|
||||
format: formatEur,
|
||||
icon: <DollarSign className="w-4 h-4 text-brand-500" />,
|
||||
icon: <DollarSign className="w-4 h-4 text-brand-600" />,
|
||||
},
|
||||
{
|
||||
key: 'leads',
|
||||
label: 'Leads',
|
||||
hint: 'Captación periodo',
|
||||
format: formatNum,
|
||||
icon: <Users className="w-4 h-4 text-blue-400" />,
|
||||
icon: <Users className="w-4 h-4 text-blue-600" />,
|
||||
},
|
||||
{
|
||||
key: 'cpl',
|
||||
label: 'CPL',
|
||||
hint: 'Coste por lead',
|
||||
format: (v) => `${formatNum(v)} €`,
|
||||
icon: <MousePointerClick className="w-4 h-4 text-violet-400" />,
|
||||
icon: <MousePointerClick className="w-4 h-4 text-violet-600" />,
|
||||
invert: true,
|
||||
},
|
||||
{
|
||||
key: 'cac',
|
||||
label: 'CAC cohorte',
|
||||
hint: 'Paid / cierres (parcial v1)',
|
||||
hint: hasCohort
|
||||
? `Paid ÷ ${formatNum(periodTotals.deals)} cierres`
|
||||
: 'Paid ÷ deals ganados Clientify',
|
||||
format: (v) => `${formatNum(v)} €`,
|
||||
icon: <Target className="w-4 h-4 text-amber-400" />,
|
||||
icon: <Target className="w-4 h-4 text-amber-600" />,
|
||||
invert: true,
|
||||
cohort: true,
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
label: 'Revenue cohorte',
|
||||
hint: 'Deals won atribuidos',
|
||||
hint: hasCohort
|
||||
? `${formatNum(periodTotals.deals)} deals won`
|
||||
: 'Suma amount deals won',
|
||||
format: formatEur,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-400" />,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
|
||||
cohort: true,
|
||||
},
|
||||
{
|
||||
key: 'roas',
|
||||
label: 'ROAS cohorte',
|
||||
hint: 'Revenue / spend',
|
||||
hint: 'Revenue ÷ spend',
|
||||
format: (v) => `${formatNum(v)}x`,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-300" />,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
|
||||
cohort: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -257,42 +298,38 @@ export default function MetricsDashboard({
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (mismos días calendario que Ad Tracker).`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarRange className="w-4 h-4 text-gray-500" />
|
||||
<div className="flex gap-1 rounded-lg border border-white/[0.08] p-0.5 bg-gray-50">
|
||||
{(
|
||||
[
|
||||
['7d', '7d'],
|
||||
['30d', '30d'],
|
||||
['month', 'Mes'],
|
||||
['all', 'Todo'],
|
||||
] as [RangeKey, string][]
|
||||
).map(([k, lbl]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setRange(k)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md ${
|
||||
range === k
|
||||
? 'bg-brand-500/20 text-brand-300'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CalendarRange className="w-4 h-4 text-gray-600" />
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
size="sm"
|
||||
options={[
|
||||
{ id: '7d', label: '7d' },
|
||||
{ id: '30d', label: '30d' },
|
||||
{ id: 'month', label: 'Mes' },
|
||||
{ id: 'all', label: 'Todo' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{sparse && (
|
||||
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||
<p className="text-xs text-amber-800 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
|
||||
Histórico limitado: si faltan deltas o canales, el warehouse aún no
|
||||
tiene semanas suficientes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* North Star strip */}
|
||||
{!hasCohort && (
|
||||
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2">
|
||||
CAC / Revenue / ROAS a cero: no hay deals ganados con fecha de cierre
|
||||
en este periodo. Se calculan con{' '}
|
||||
<span className="font-semibold">deals Clientify (stage=won)</span>, no
|
||||
solo con leads de ads.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Inversión periodo', value: formatEur(periodTotals.spend) },
|
||||
@@ -316,45 +353,54 @@ export default function MetricsDashboard({
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="rounded-xl border border-brand-500/20 bg-brand-500/5 px-4 py-3"
|
||||
className="rounded-xl border border-brand-200 bg-brand-50 px-4 py-3 shadow-sm"
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-wider text-brand-400/80">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-700">
|
||||
{s.label}
|
||||
</p>
|
||||
<p className="text-xl font-bold text-gray-900 mt-1">{s.value}</p>
|
||||
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
|
||||
{s.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{cards.map((c) => (
|
||||
<div
|
||||
key={c.key}
|
||||
className="bg-white border border-gray-200 rounded-xl p-5"
|
||||
>
|
||||
<div className="flex items-center justify-between text-gray-400 mb-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider">
|
||||
{c.label}
|
||||
</span>
|
||||
{c.icon}
|
||||
{cards.map((c) => {
|
||||
const emptyCohort = Boolean(c.cohort) && !hasCohort;
|
||||
const value = emptyCohort ? '—' : c.format(periodTotals[c.key]);
|
||||
return (
|
||||
<div
|
||||
key={c.key}
|
||||
className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between text-gray-700 mb-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||
{c.label}
|
||||
</span>
|
||||
{c.icon}
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900 tabular-nums">
|
||||
{value}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">{c.hint}</p>
|
||||
<DeltaTriple
|
||||
delta={deltas[c.key]}
|
||||
invert={c.invert}
|
||||
empty={emptyCohort}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{c.format(periodTotals[c.key])}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500 mt-1">{c.hint}</p>
|
||||
<DeltaTriple delta={deltas[c.key]} invert={c.invert} />
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Channel attribution cross */}
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
Cruce por canal · ads vs CRM
|
||||
</h3>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
Plataforma = leads reportados por Meta/Google · CRM = atribución
|
||||
Clientify (origen/UTM)
|
||||
{!hasCrm && portfolioWide
|
||||
@@ -362,61 +408,61 @@ export default function MetricsDashboard({
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 shrink-0">
|
||||
<p className="text-xs text-gray-600 shrink-0 font-medium">
|
||||
Gap = CRM − plat
|
||||
</p>
|
||||
</div>
|
||||
{attributionRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-6 text-center">
|
||||
<p className="text-sm text-gray-600 py-6 text-center">
|
||||
Sin series de canal en el periodo. Los canales Meta/Google aparecen
|
||||
cuando hay filas en ad_daily para este cliente (elige uno en
|
||||
Contexto).
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-gray-500 border-b border-gray-200">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-gray-600 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">Canal</th>
|
||||
<th className="py-2 pr-3 font-medium">Inversión</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads plat</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads CRM</th>
|
||||
<th className="py-2 pr-3 font-medium">Gap</th>
|
||||
<th className="py-2 pr-3 font-medium">CPL plat</th>
|
||||
<th className="py-2 font-medium">CPL CRM</th>
|
||||
<th className="py-2 pr-3 font-semibold">Canal</th>
|
||||
<th className="py-2 pr-3 font-semibold">Inversión</th>
|
||||
<th className="py-2 pr-3 font-semibold">Leads plat</th>
|
||||
<th className="py-2 pr-3 font-semibold">Leads CRM</th>
|
||||
<th className="py-2 pr-3 font-semibold">Gap</th>
|
||||
<th className="py-2 pr-3 font-semibold">CPL plat</th>
|
||||
<th className="py-2 font-semibold">CPL CRM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attributionRows.map((row) => (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-900 hover:bg-white/[0.02]"
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-2.5 pr-3">
|
||||
{row.spend > 0 ? (
|
||||
<Link
|
||||
href={`/dashboard/kpis/canal/${encodeURIComponent(row.channel)}`}
|
||||
className="text-brand-400 hover:text-brand-300 font-semibold capitalize"
|
||||
className="text-brand-700 hover:text-brand-600 font-semibold capitalize"
|
||||
>
|
||||
{row.channel}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-gray-600 font-semibold capitalize">
|
||||
<span className="text-gray-700 font-semibold capitalize">
|
||||
{row.channel}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{row.spend ? formatEur(row.spend) : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{formatNum(row.platformLeads)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{hasCrm ? formatNum(row.crmLeads) : '—'}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2.5 pr-3 font-medium ${
|
||||
className={`py-2.5 pr-3 font-semibold tabular-nums ${
|
||||
hasCrm ? gapClass(row.gap) : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
@@ -424,12 +470,12 @@ export default function MetricsDashboard({
|
||||
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{row.cplPlatform
|
||||
? `${formatNum(row.cplPlatform)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-800">
|
||||
<td className="py-2.5 text-gray-800 tabular-nums">
|
||||
{hasCrm && row.cplCrm
|
||||
? `${formatNum(row.cplCrm)} €`
|
||||
: '—'}
|
||||
@@ -440,52 +486,45 @@ export default function MetricsDashboard({
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 pt-2">
|
||||
{['GEO', 'CRM close %', 'LTV'].map((soon) => (
|
||||
<div
|
||||
key={soon}
|
||||
className="rounded-lg border border-dashed border-gray-200 px-3 py-2 text-[11px] text-gray-500"
|
||||
>
|
||||
{soon} · fase 2 (stub)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between text-gray-400 mb-2">
|
||||
<span className="text-xs uppercase tracking-wider">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between text-gray-700 mb-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||
Ads · ventana diaria
|
||||
</span>
|
||||
<Eye className="w-4 h-4 text-sky-400" />
|
||||
<Eye className="w-4 h-4 text-sky-600" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-gray-900">{formatEur(daily.spend)}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-lg font-bold text-gray-900 tabular-nums">
|
||||
{formatEur(daily.spend)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{formatNum(daily.leads)} leads · {formatNum(daily.impressions)} impr.
|
||||
· {formatNum(daily.clicks)} clicks
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-600 mt-2">
|
||||
<p className="text-xs text-gray-700 mt-2 font-medium">
|
||||
Semana actual: {formatEur(week.spend)} · {formatNum(week.leads)}{' '}
|
||||
leads
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wider text-gray-500 mb-2">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 md:col-span-2 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-700 mb-2">
|
||||
Cómo leer
|
||||
</p>
|
||||
<ul className="text-xs text-gray-400 space-y-1 list-disc list-inside">
|
||||
<ul className="text-sm text-gray-700 space-y-1.5 list-disc list-inside">
|
||||
<li>
|
||||
El selector filtra el mismo calendario que Ad Tracker; el cruce
|
||||
compara leads de la plataforma ads vs atribución Clientify.
|
||||
CAC / Revenue / ROAS usan deals ganados de Clientify (
|
||||
<span className="font-semibold">won_at</span> en el periodo), no
|
||||
leads de Meta/Google.
|
||||
</li>
|
||||
<li>
|
||||
Gap cercano a 0 en Meta/Google = buena atribución. Canales sin
|
||||
spend (whatsapp, manual…) son orgánicos/CRM.
|
||||
spend son orgánicos/CRM.
|
||||
</li>
|
||||
<li>
|
||||
CPL CRM usa inversión paid ÷ leads CRM del canal (más realista
|
||||
que el CPL de la plataforma cuando hay desfase).
|
||||
CPL CRM = inversión paid ÷ leads CRM del canal (más realista
|
||||
cuando hay desfase con la plataforma).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -167,7 +167,7 @@ export default function RrssArchiveYear({
|
||||
<div>
|
||||
<Link
|
||||
href="/dashboard/calendario?view=hub"
|
||||
className="text-xs text-gray-400 hover:text-gray-900"
|
||||
className="text-xs text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
← Hub RRSS
|
||||
</Link>
|
||||
|
||||
@@ -587,7 +587,7 @@ export default function RrssMonthView({
|
||||
{selected.body}
|
||||
</div>
|
||||
{selected.cta && (
|
||||
<p className="text-xs text-brand-300">👉 {selected.cta}</p>
|
||||
<p className="text-xs text-brand-700">👉 {selected.cta}</p>
|
||||
)}
|
||||
{selected.hashtags && (
|
||||
<p className="text-xs text-sky-400 font-mono">{selected.hashtags}</p>
|
||||
|
||||
@@ -518,8 +518,8 @@ export default function SeguimientoBoard() {
|
||||
onClick={() => setMetric(k)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
||||
metric === k
|
||||
? 'bg-brand-500/20 text-brand-400'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -667,7 +667,7 @@ export default function SeguimientoBoard() {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-brand-400 hover:text-brand-300"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-brand-400 hover:text-brand-700"
|
||||
>
|
||||
Superhuman <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function SuperhumanSyncPanel() {
|
||||
type="button"
|
||||
onClick={runSync}
|
||||
disabled={loading || syncing}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-brand-500/20 text-brand-300 border border-brand-500/30 hover:bg-brand-500/30"
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg bg-brand-500/20 text-brand-700 border border-brand-500/30 hover:bg-brand-500/30"
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
@@ -146,7 +146,7 @@ export default function SuperhumanSyncPanel() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{matches.slice(0, 20).map((m) => (
|
||||
<tr key={m.tenantName || m.docId} className="border-b border-gray-900">
|
||||
<tr key={m.tenantName || m.docId} className="border-b border-gray-100">
|
||||
<td className="py-1.5 pr-2 text-gray-600">
|
||||
{m.tenantName || '—'}
|
||||
</td>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function PageHeader({
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="text-sm text-gray-500 max-w-2xl leading-relaxed">
|
||||
<p className="text-sm text-gray-600 max-w-2xl leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -85,10 +85,43 @@ export function EmptyState({
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 px-6 py-10 text-center">
|
||||
<p className="text-sm font-medium text-gray-800">{title}</p>
|
||||
{description ? (
|
||||
<p className="text-xs text-gray-500 mt-1.5 max-w-md mx-auto leading-relaxed">
|
||||
<p className="text-xs text-gray-600 mt-1.5 max-w-md mx-auto leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Selector de periodo / pestañas — contraste alto sobre fondo blanco. */
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
size = 'md',
|
||||
}: {
|
||||
value: T;
|
||||
options: { id: T; label: string }[];
|
||||
onChange: (id: T) => void;
|
||||
size?: 'sm' | 'md';
|
||||
}) {
|
||||
const pad = size === 'sm' ? 'px-2.5 py-1 text-xs' : 'px-3 py-1.5 text-sm';
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 bg-gray-100">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => onChange(o.id)}
|
||||
className={`${pad} rounded-md font-semibold transition-colors ${
|
||||
value === o.id
|
||||
? 'bg-brand-500 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -402,6 +402,83 @@ export function metricTotalsFromDaily(rows: AdDailyRow[]): MetricTotals {
|
||||
};
|
||||
}
|
||||
|
||||
/** Won Clientify deals — source of truth for cohort CAC / revenue / ROAS. */
|
||||
export type DealWonRow = {
|
||||
amount?: number | null;
|
||||
won_at?: string | null;
|
||||
stage?: string | null;
|
||||
tenant_id?: string | null;
|
||||
};
|
||||
|
||||
export function filterDealsForRange(
|
||||
deals: DealWonRow[],
|
||||
since: string | null,
|
||||
until: string | null
|
||||
): DealWonRow[] {
|
||||
const won = deals.filter((d) => !d.stage || d.stage === 'won');
|
||||
if (!since || !until) return won;
|
||||
return won.filter((d) => {
|
||||
const day = (d.won_at || '').slice(0, 10);
|
||||
if (!day) return false;
|
||||
return day >= since && day <= until;
|
||||
});
|
||||
}
|
||||
|
||||
/** Overlay CRM won deals onto ad spend/leads totals. */
|
||||
export function withCohortFromDeals(
|
||||
base: MetricTotals,
|
||||
deals: DealWonRow[]
|
||||
): MetricTotals {
|
||||
const dealCount = deals.length;
|
||||
const revenue = deals.reduce((a, d) => a + n(d.amount), 0);
|
||||
return {
|
||||
...base,
|
||||
deals: dealCount,
|
||||
revenue,
|
||||
cac: dealCount > 0 ? base.spend / dealCount : 0,
|
||||
roas: base.spend > 0 ? revenue / base.spend : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject won-deal cohort into weekly rows so WoW/MoM/YoY include CRM closes.
|
||||
* Ads sync leaves cohort_* at 0; without this, CAC/ROAS deltas stay empty.
|
||||
*/
|
||||
export function mergeDealsIntoWeekly(
|
||||
weekly: KpiWeeklyRow[],
|
||||
deals: DealWonRow[]
|
||||
): KpiWeeklyRow[] {
|
||||
const byWeek = new Map<string, { deals: number; revenue: number }>();
|
||||
for (const d of deals) {
|
||||
if (d.stage && d.stage !== 'won') continue;
|
||||
const day = (d.won_at || '').slice(0, 10);
|
||||
if (!day) continue;
|
||||
const dt = new Date(`${day}T00:00:00Z`);
|
||||
if (Number.isNaN(dt.getTime())) continue;
|
||||
const week = isoDate(weekStart(dt));
|
||||
const cur = byWeek.get(week) || { deals: 0, revenue: 0 };
|
||||
cur.deals += 1;
|
||||
cur.revenue += n(d.amount);
|
||||
byWeek.set(week, cur);
|
||||
}
|
||||
if (!byWeek.size) return weekly;
|
||||
|
||||
const out = weekly.map((r) => ({ ...r }));
|
||||
for (const [week, v] of byWeek) {
|
||||
out.push({
|
||||
week: `${week}T00:00:00+00:00`,
|
||||
channel: 'crm',
|
||||
total_spend: 0,
|
||||
total_leads: 0,
|
||||
cohort_deals_won: v.deals,
|
||||
cohort_revenue_won: Math.round(v.revenue * 100) / 100,
|
||||
cac_cohort: 0,
|
||||
roas_cohort: 0,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** CRM lead row used for channel attribution cross. */
|
||||
export type CrmLeadRow = {
|
||||
source?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user