/* global React, IconRefresh, IconCalendar, IconStar, Chip, SourceBadge, shortWhen */
// Analítica (agendas por fuente vía UTM, costo por agendada/asistencia,
// show-rate, embudo, desempeño por closer) + lista de próximas llamadas.
const { useState: _us, useMemo: _um } = React;
function fmtMoney(n) {
return "$" + Math.round(n).toLocaleString("en-US");
}
// Presets de periodo para la barra de fecha de la analítica
const ANALYTICS_PERIODS = [
{ id: "7d", label: "7 días", days: 7 },
{ id: "14d", label: "14 días", days: 14 },
{ id: "30d", label: "30 días", days: 30 },
{ id: "90d", label: "90 días", days: 90 },
];
function presetRange(days) {
const t = new Date(); t.setHours(0, 0, 0, 0);
return { from: new Date(t.getTime() - days * 86400000), to: t };
}
function _sameDayV(a, b) { return a && b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); }
function matchAnalyticsPeriod(range) {
if (!range || !range.from || !range.to) return null;
for (const p of ANALYTICS_PERIODS) { const r = presetRange(p.days); if (_sameDayV(range.from, r.from) && _sameDayV(range.to, r.to)) return p.id; }
return null;
}
// ---- Sparkline ---------------------------------------------------
function Sparkline({ data, w = 280, h = 60, accent }) {
const max = Math.max(...data), min = Math.min(...data), r = max - min || 1;
const step = w / (data.length - 1);
const pts = data.map((d, i) => [i * step, h - 6 - ((d - min) / r) * (h - 16)]);
const path = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ");
const area = path + ` L${w} ${h} L0 ${h} Z`;
return (
);
}
function Donut({ items, size = 150, hole = 0.64 }) {
const total = items.reduce((s, x) => s + x.v, 0) || 1;
let a = -Math.PI / 2;
const r = size / 2, inner = r * hole;
return (
);
}
function AnalyticsView({ calls, allCalls, accent, isAdmin, closers, adSpend, onEditSpend, canEditSpend, filters, setFilters }) {
const { SOURCES, PAID_SOURCES, BOOKINGS_14D } = window.REVOLVR_DATA;
const TOTAL_SPEND = adSpend.directa + adSpend.reconocimiento;
// Filtro por fecha (sobre la fecha de agendado / createdAt)
const range = filters && filters.dateRange;
const inRange = (c) => {
if (!range || (!range.from && !range.to)) return true;
const d = c.createdAt;
if (range.from && d < range.from) return false;
if (range.to) { const end = new Date(range.to); end.setHours(23, 59, 59, 999); if (d > end) return false; }
return true;
};
const fc = calls.filter(inRange);
const fAll = (allCalls || calls).filter(inRange);
// --- Métricas del alcance visible (las del closer, o las que mira el admin)
const m = _um(() => {
const total = fc.length;
const shows = fc.filter((c) => c.status === "asistio").length;
const noshows = fc.filter((c) => c.status === "noshow").length;
const won = fc.filter((c) => c.stage === "ganada").length;
const proposalPlus = fc.filter((c) => ["propuesta", "ganada", "perdida"].includes(c.stage)).length;
const bySource = {};
Object.keys(SOURCES).forEach((k) => bySource[k] = { count: 0, shows: 0 });
fc.forEach((c) => {
const k = c.utm.source;
if (!bySource[k]) bySource[k] = { count: 0, shows: 0 };
bySource[k].count++;
if (c.status === "asistio") bySource[k].shows++;
});
const sourceRows = Object.entries(bySource)
.map(([k, v]) => ({ k, ...v, label: SOURCES[k]?.label || k, color: SOURCES[k]?.color || "#94a3b8", paid: PAID_SOURCES.includes(k) }))
.filter((r) => r.count > 0)
.sort((a, b) => b.count - a.count);
return {
total, shows, noshows, won, proposalPlus, sourceRows,
showRate: total ? Math.round((shows / total) * 100) : 0,
noShowRate: total ? Math.round((noshows / total) * 100) : 0,
closeRate: total ? Math.round((won / total) * 100) : 0,
};
}, [fc, TOTAL_SPEND]);
// --- Métricas de TODA la operación (solo admin: costos + operación global)
const g = _um(() => {
const src = fAll;
const total = src.length;
const shows = src.filter((c) => c.status === "asistio").length;
const noshows = src.filter((c) => c.status === "noshow").length;
const wonCalls = src.filter((c) => c.stage === "ganada");
const won = wonCalls.length;
const adsAgendas = src.filter((c) => PAID_SOURCES.includes(c.utm.source)).length;
const organicAgendas = total - adsAgendas;
let facturado = 0, cobrado = 0;
wonCalls.forEach((c) => {
if (!c.sale) return;
facturado += Number(c.sale.total || 0);
cobrado += c.sale.tipo === "total" ? Number(c.sale.total || 0) : Number(c.sale.pagado || 0);
});
return {
total, shows, noshows, won, adsAgendas, organicAgendas,
facturado, cobrado, pendiente: Math.max(0, facturado - cobrado),
showRate: total ? Math.round((shows / total) * 100) : 0,
closeRate: total ? Math.round((won / total) * 100) : 0,
costLlamadaTotal: total ? TOTAL_SPEND / total : 0,
costLlamadaVSL: adsAgendas ? adSpend.directa / adsAgendas : 0,
cplOrganico: organicAgendas ? adSpend.reconocimiento / organicAgendas : 0,
costPorShow: shows ? TOTAL_SPEND / shows : 0,
cac: won ? TOTAL_SPEND / won : 0,
roas: TOTAL_SPEND ? facturado / TOTAL_SPEND : 0,
};
}, [fAll, TOTAL_SPEND, adSpend.directa]);
const maxSource = Math.max(1, ...m.sourceRows.map((r) => r.count));
const donutItems = m.sourceRows.map((r) => ({ v: r.count, color: r.color }));
const closerRows = _um(() => {
if (!isAdmin) return [];
const src = fAll;
return closers.map((cl) => {
const cc = src.filter((c) => c.closerId === cl.id);
const shows = cc.filter((c) => c.status === "asistio").length;
const won = cc.filter((c) => c.stage === "ganada").length;
return { ...cl, bookings: cc.length, shows, won,
showRate: cc.length ? Math.round(shows / cc.length * 100) : 0,
closeRate: cc.length ? Math.round(won / cc.length * 100) : 0 };
}).sort((a, b) => b.bookings - a.bookings);
}, [fAll, isAdmin, closers]);
const funnel = [
{ label: "Agendadas", value: m.total },
{ label: "Asistieron", value: m.shows },
{ label: "En propuesta+", value: m.proposalPlus },
{ label: "Cerradas", value: m.won },
];
const fMax = Math.max(1, ...funnel.map((f) => f.value));
return (
{setFilters &&
Periodo:
{ANALYTICS_PERIODS.map((p) => {
const active = matchAnalyticsPeriod(range) === p.id;
return setFilters({ ...filters, dateRange: active ? null : presetRange(p.days) })}>{p.label};
})}
setFilters({ ...filters, dateRange: r })}/>
{fc.length} de {calls.length} agendas en el periodo
}
{/* Inversión en ads — horizontal, debajo del periodo (SOLO admin) */}
{isAdmin &&
}
{/* Facturación y adquisición (SOLO admin) — arriba de las métricas */}
{isAdmin &&
Facturación y adquisición · {g.won} clientes cerrados
{fmtMoney(g.facturado)}
Total facturado
{fmtMoney(g.cobrado)}
Total cobrado
{fmtMoney(g.pendiente)}
Saldo pendiente
{fmtMoney(g.cac)}
CAC (costo por cliente)
{g.roas.toFixed(1)}x
ROAS (facturado / ads)
}
{/* Métricas operativas del alcance visible (closer y admin) */}
= 50 ? "up" : "down"}/>
{/* Costos por llamada (SOLO admin) */}
{isAdmin &&
Costos por llamada · toda la operación · según inversión en ads
Usa esta URL y API Key en n8n para enviar leads al CRM automáticamente
{/* ── BLOQUE 2: Variables disponibles ── */}
Campos disponibles para n8n
Usa estas claves exactas en el nodo Set de n8n para mapear
los datos de Calendly al CRM
Clave
Etiqueta
Grupo
Requerido
{FIELDS.map((f) => (
{f.key}
{f.label}
{f.group}
{f.req
? Sí
: No}
))}
💡 Los campos form.* son dinámicos. Ejemplo:{" "}
form.facturacion, form.equipo,{" "}
form.industria. Tú defines el nombre en n8n
y el CRM los muestra tal como llegan. El closer se asigna
automáticamente — no necesitas enviarlo desde n8n.
{/* ── BLOQUE 3: Asignación round-robin ── */}
Asignación automática de llamadas
Las llamadas entran en rotación entre los closers activos.
Desactiva un closer para excluirlo de la rotación.