/* 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 ( {pts.map((p, i) => )} ); } 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 ( {items.map((it, i) => { const frac = it.v / total, a2 = a + frac * Math.PI * 2, large = frac > 0.5 ? 1 : 0; const x1 = r + r * Math.cos(a), y1 = r + r * Math.sin(a); const x2 = r + r * Math.cos(a2), y2 = r + r * Math.sin(a2); const x3 = r + inner * Math.cos(a2), y3 = r + inner * Math.sin(a2); const x4 = r + inner * Math.cos(a), y4 = r + inner * Math.sin(a); const d = `M${x1} ${y1} A${r} ${r} 0 ${large} 1 ${x2} ${y2} L${x3} ${y3} A${inner} ${inner} 0 ${large} 0 ${x4} ${y4} Z`; a = a2; 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

Costo por llamada TOTAL
{fmtMoney(g.costLlamadaTotal)}
(VSL + reconocimiento) / {g.total} agendas
Costo por llamada VSL
{fmtMoney(g.costLlamadaVSL)}
inversión VSL / {g.adsAgendas} agenda{g.adsAgendas === 1 ? "" : "s"} de Facebook ads
Costo por lead orgánico
{fmtMoney(g.cplOrganico)}
inversión en reconocimiento / {g.organicAgendas} agenda{g.organicAgendas === 1 ? "" : "s"} orgánica{g.organicAgendas === 1 ? "" : "s"}
Costo por asistencia
{fmtMoney(g.costPorShow)}
inversión total / {g.shows} asistencia{g.shows === 1 ? "" : "s"} (shows)
} {/* Operación global de closers (admin) — debajo de Facturación */} {isAdmin &&

Operación de closers · {g.total} llamadas · {closerRows.length} closers

{g.total}
Agendadas (equipo)
{g.shows}
Asistieron (equipo)
{g.won}
Cerradas (equipo)
{g.showRate}%
% asistencia global
{g.closeRate}%
% cierre global
{closerRows.map((r) => (
{r.initials}
{r.name}
{r.bookings}
agendadas
{r.shows}
asistieron
{r.won}
cerradas
{r.showRate}%
show rate
{r.closeRate}%
% cierre
))}
} {/* Agendas por fuente */}

Agendas por fuente · según UTM de Calendly

{m.sourceRows.map((r) => (
{r.label}{r.paid && ads}
{r.count} · {r.shows} shows
))}
{/* Donut de mezcla */}

Mezcla de fuentes

{m.sourceRows.map((r) => (
{r.label} {Math.round(r.count / (m.total || 1) * 100)}%
))}
{/* Tendencia */}

Agendas · últimos 14 días sincronizado con Calendly

hace 14 dhace 7 dhoy
{/* Embudo */}

Embudo de llamadas

{funnel.map((f, i) => (
{f.label}
{f.value}
{Math.round(f.value / (funnel[0].value || 1) * 100)}%
))}
); } function SpendEditor({ adSpend, onEditSpend, canEdit, total }) { const { useState: _sue } = React; const [local, setLocal] = _sue({ directa: adSpend.directa, reconocimiento: adSpend.reconocimiento }); // Sincronizar si el padre cambia por fuera React.useEffect(() => { setLocal({ directa: adSpend.directa, reconocimiento: adSpend.reconocimiento }); }, [adSpend.directa, adSpend.reconocimiento]); const handleChange = (k, val) => setLocal((l) => ({ ...l, [k]: val })); const handleBlur = (k) => { const num = Math.max(0, Number(local[k]) || 0); setLocal((l) => ({ ...l, [k]: num })); onEditSpend({ ...adSpend, [k]: num }); }; return (
Inversión en ads {canEdit ? editable : solo lectura}
{["directa", "reconocimiento"].map((k) => (
{k === "directa" ? "Agenda directa" : "Reconocimiento"} {k === "directa" ? "anuncio → VSL" : "promo orgánico"}
$ handleChange(k, e.target.value)} onBlur={() => handleBlur(k)}/>
))}
Total invertido{fmtMoney(total)}
); } function MetricCard({ span, lbl, val, suffix, sub, dir }) { return (
{lbl}
{val}{suffix && {suffix}}
{sub &&
{dir === "up" ? "↑ " : dir === "down" ? "↓ " : ""}{sub}
}
); } // ============ Próximas llamadas ================================= function CallsView({ calls, closers, onOpen }) { const { CALL_STATUS } = window.REVOLVR_DATA; const now = new Date(); const upcoming = calls .filter((c) => c.scheduledAt instanceof Date && c.scheduledAt >= new Date(now.getFullYear(), now.getMonth(), now.getDate())) .filter((c) => c.status !== "cancelada") .sort((a, b) => a.scheduledAt - b.scheduledAt); const groups = []; upcoming.forEach((c) => { const key = c.scheduledAt.toDateString(); let g = groups.find((x) => x.key === key); if (!g) { g = { key, when: c.scheduledAt, items: [] }; groups.push(g); } g.items.push(c); }); const today = new Date(); const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); const sameDay = (a, b) => a.toDateString() === b.toDateString(); const dayLabel = (d) => sameDay(d, today) ? "Hoy" : sameDay(d, tomorrow) ? "Mañana" : d.toLocaleDateString("es-ES", { weekday: "long", day: "numeric", month: "long" }); const timeLabel = (d) => d.toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit", hour12: false }); return (

Próximas llamadas

{upcoming.length} agendadas desde hoy
{upcoming.length === 0 ?
Sin llamadas próximas
Las nuevas citas de Calendly aparecerán aquí automáticamente.
:
{groups.map((g) =>
{dayLabel(g.when)} {g.items.length} llamada{g.items.length === 1 ? "" : "s"}
{g.items.map((c) => { const closer = closers.find((x) => x.id === c.closerId); const st = CALL_STATUS[c.status] || {}; const initials = c.name.split(" ").map((s) => s[0]).slice(0, 2).join(""); return (
onOpen(c.id)}>
{timeLabel(c.scheduledAt)}
{c.duration} min
{initials}
{c.name}{st.label}
{c.eventType} · {c.form.industria}
"{c.form.objetivo}"
{closer && {closer.name}}
e.stopPropagation()}>
); })}
)}
}
); } // ============ Integraciones (solo admin) ======================= function IntegracionesView({ closers, pushToast }) { const [apiKeyVisible, setApiKeyVisible] = _us(false); const [closerActivo, setCloserActivo] = _us( () => Object.fromEntries(closers.map((c) => [c.id, true])) ); const WEBHOOK_URL = "https://gcbbosnfrpygymbnwayt.supabase.co/functions/v1/webhook-inbound"; const API_KEY = "rvlr_25447f6c3a37733f19c12c163130f4c3cdd251eea7624089"; const copyToClipboard = (text, label) => { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).catch(() => {}); } if (pushToast) pushToast(label + " copiado al portapapeles"); else alert(label + " copiado"); }; const closersActivos = closers.filter((c) => closerActivo[c.id]); const FIELDS = [ { key: "name", label: "Nombre completo", group: "Lead", req: true }, { key: "email", label: "Email", group: "Lead", req: true }, { key: "phone", label: "Teléfono", group: "Lead", req: false }, { key: "location", label: "Ciudad / País", group: "Lead", req: false }, { key: "timezone", label: "Zona horaria", group: "Lead", req: false }, { key: "event_type", label: "Tipo de evento", group: "Llamada", req: false }, { key: "duration", label: "Duración (minutos)", group: "Llamada", req: false }, { key: "scheduled_at", label: "Fecha y hora", group: "Llamada", req: true }, { key: "meeting_link", label: "Link de reunión", group: "Llamada", req: false }, { key: "value", label: "Valor estimado (USD)", group: "Negocio", req: false }, { key: "utm_source", label: "UTM Source", group: "Marketing", req: false }, { key: "utm_medium", label: "UTM Medium", group: "Marketing", req: false }, { key: "utm_campaign", label: "UTM Campaign", group: "Marketing", req: false }, { key: "form.NOMBRE", label: "Campo dinámico del formulario", group: "Formulario", req: false }, ]; return (
{/* ── BLOQUE 1: Webhook ── */}
Webhook de entrada
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
{FIELDS.map((f) => ( ))}
Clave Etiqueta Grupo Requerido
{f.key} {f.label} {f.group} {f.req ? : 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.
{closers.map((c) => { const activo = closerActivo[c.id]; const orden = closersActivos.findIndex((x) => x.id === c.id); return (
{c.initials}
{c.name}
{activo && orden >= 0 && {orden + 1}°}
); })}
Rotación activa: {closersActivos.map((c) => c.name.split(" ")[0]).join(" → ") || "Ningún closer activo"} {closersActivos.length > 0 && " → (vuelve al primero)"}
{/* ── BLOQUE 4: Logs ── */}
Actividad reciente
Últimos leads recibidos desde n8n
Sin actividad todavía
Los eventos aparecerán aquí cuando n8n envíe el primer lead
); } Object.assign(window, { AnalyticsView, CallsView, IntegracionesView });