// ============ AcademIA8 · Consola — Costos y precio (COSTOS-1) ============
// P1 (parámetros globales, versionados) + P2/P3 (cálculo y resultado por
// curso) + P5 (confirmación con candado, escribe CURSO#{id}/COMERCIAL) +
// camino de curso gratuito (decisión 11, nunca pasa por el candado).
// Reemplaza el prototipo client-side anterior (sin backend, sin persistencia,
// modelo de costo distinto): ahora el precio confirmado viaja al ítem real
// que lee el funnel de pago (sección 6 del documento de diseño).
// P4 (pisar el precio a mano) queda diferido a COSTOS-1B — el backend ya
// acepta el override (`precio_usd_override`), pero esta pantalla todavía no
// expone el campo.
function fmtUSD(n) {
return n == null ? "—" : "$" + Number(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtARS(n) {
return n == null ? "—" : "$" + Math.round(Number(n)).toLocaleString("es-AR");
}
function fmtPct(n) {
return n == null ? "—" : (Number(n) * 100).toFixed(1) + "%";
}
function CostosResult({ label, value, accent }) {
return (
);
}
// ---------------- Editores de listas dinámicas ----------------
function ImpuestosEditor({ lineas, onChange }) {
const total = lineas.reduce((s, l) => s + (Number(l.alicuota) || 0), 0);
function set(i, campo, valor) {
const next = lineas.slice();
next[i] = { ...next[i], [campo]: valor };
onChange(next);
}
function add() {
onChange([...lineas, { concepto: "", alicuota: 0 }]);
}
function del(i) {
onChange(lineas.filter((_, idx) => idx !== i));
}
return (
{lineas.map((l, i) => (
set(i, "concepto", e.target.value)} placeholder="Concepto (ej. IVA)" style={{ flex: 1 }} />
set(i, "alicuota", Number(e.target.value))} style={{ width: 90 }} />
del(i)}>
))}
Agregar línea
);
}
function FijosEditor({ fijos, onChange }) {
function set(i, campo, valor) {
const next = fijos.slice();
next[i] = { ...next[i], [campo]: valor };
onChange(next);
}
function add() {
onChange([...fijos, { concepto: "", monto_usd: 0 }]);
}
function del(i) {
onChange(fijos.filter((_, idx) => idx !== i));
}
return (
{fijos.map((f, i) => (
set(i, "concepto", e.target.value)} placeholder="Concepto (ej. marketing)" style={{ flex: 1 }} />
set(i, "monto_usd", Number(e.target.value))} style={{ width: 110 }} />
del(i)}>
))}
Agregar línea
);
}
// ---------------- P1: Parámetros globales ----------------
function ParametrosGlobalesTab() {
const [actual, setActual] = useState(undefined); // undefined = cargando
const [form, setForm] = useState(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
async function cargar() {
try {
const p = await A8Api.getParametrosCostos();
setActual(p || null);
setForm(
p
? {
masa_salarial_usd: p.masa_salarial_usd,
porcentaje_academia8: p.porcentaje_academia8 * 100,
plataforma_usd: p.plataforma_usd,
base_alumnos: p.base_alumnos,
tc_referencia: p.tc_referencia,
impuestos: p.impuestos,
}
: {
masa_salarial_usd: 0,
porcentaje_academia8: 35,
plataforma_usd: 0,
base_alumnos: 200,
tc_referencia: 1000,
impuestos: [{ concepto: "carga total (ajustar con el contador)", alicuota: 0.38 }],
}
);
} catch (e) {
setErr(e.message);
}
}
useEffect(() => { cargar(); }, []);
if (!form) return Cargando parámetros…
;
async function guardar() {
setBusy(true);
setErr(null);
try {
await A8Api.setParametrosCostos({
masa_salarial_usd: Number(form.masa_salarial_usd),
porcentaje_academia8: Number(form.porcentaje_academia8) / 100,
plataforma_usd: Number(form.plataforma_usd),
base_alumnos: Number(form.base_alumnos),
tc_referencia: Number(form.tc_referencia),
impuestos: form.impuestos,
});
await cargar();
} catch (e) {
setErr(e.message);
} finally {
setBusy(false);
}
}
const baseAlumnos = Number(form.base_alumnos) || 0;
const tasaPlataforma = baseAlumnos > 0 ? Number(form.plataforma_usd) / baseAlumnos : 0;
const tasaSueldos = baseAlumnos > 0 ? (Number(form.masa_salarial_usd) * Number(form.porcentaje_academia8)) / 100 / baseAlumnos : 0;
return (
Tasas resultantes por alumno / mes
Plataforma
{fmtUSD(tasaPlataforma)}
Sueldos
{fmtUSD(tasaSueldos)}
Total por alumno / mes
{fmtUSD(tasaPlataforma + tasaSueldos)}
Esto es lo que consume cada curso, multiplicado por su duración en meses (pestaña Curso).
);
}
// ---------------- P2/P3/P5: Curso ----------------
function MedidoLine({ medido, onUse }) {
if (!medido) return null;
const esP90 = medido.costo_llm_p90_por_alumno != null;
const valor = esP90 ? medido.costo_llm_p90_por_alumno : medido.costo_llm_promedio_por_alumno;
if (valor == null) return null;
return (
Medido{esP90 ? " (p90)" : ""}: {fmtUSD(valor)} por alumno (al {new Date(medido.fecha_corte).toLocaleDateString("es-AR")}, {medido.alumnos_con_actividad} alumnos)
{!esP90 && medición sin p90 — regenerá el reporte }
onUse(valor)}>Usar medido
);
}
function EstadoComercial({ estado }) {
if (!estado || !estado.categoria) {
return Este curso todavía no tiene un precio confirmado ni está marcado como gratuito.
;
}
if (estado.categoria === "gratuito") {
return Curso gratuito — no genera venta{estado.motivo_gratuito ? `: ${estado.motivo_gratuito}` : ""}
;
}
return (
Precio vigente
{fmtARS(estado.precio_ars)} ({fmtUSD(estado.precio_usd)})
Vigencia
{estado.vigente_desde} → {estado.vigente_hasta}
Venta habilitada
{estado.venta_habilitada ? "Sí" : `No — ${estado.motivo_no_habilitada}`}
);
}
function GratuitoModal({ onClose, onConfirm, busy }) {
const [motivo, setMotivo] = useState("");
return (
Cancelar
onConfirm(motivo)}>{busy ? "Guardando…" : "Confirmar"}
>
}
>
setMotivo(e.target.value)} placeholder="Ej. curso de captación" />
);
}
function CursoCostosTab() {
const [cursos, setCursos] = useState([]);
const [cursoId, setCursoId] = useState("");
const [estado, setEstado] = useState(null);
const [medido, setMedido] = useState(null);
const [form, setForm] = useState({
nombre_comercial: "",
duracion_meses: 2,
n_estimado: 60,
margen: 40,
fijos: [{ concepto: "marketing", monto_usd: 0 }],
c2_valor: 0,
c2_es_override: false,
motivo_override_c2: "",
});
const [resultado, setResultado] = useState(null);
const [motivoOverride, setMotivoOverride] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
const [gratuitoModal, setGratuitoModal] = useState(false);
useEffect(() => {
A8Api.listCourses().then(setCursos).catch(() => {});
fetch("costos-medidos.json", { cache: "no-store" }).then((r) => (r.ok ? r.json() : null)).then(setMedido).catch(() => {});
}, []);
useEffect(() => {
setResultado(null);
setErr(null);
if (!cursoId) { setEstado(null); return; }
A8Api.getComercialCurso(cursoId).then(setEstado).catch(() => setEstado(null));
}, [cursoId]);
function setF(k, v) { setForm((f) => ({ ...f, [k]: v })); }
function calculoInput() {
return {
duracion_meses: Number(form.duracion_meses),
n_estimado: Number(form.n_estimado),
fijos: form.fijos.map((f) => ({ concepto: f.concepto, monto_usd: Number(f.monto_usd) })),
c2_valor: Number(form.c2_valor),
c2_es_override: form.c2_es_override,
motivo_override_c2: form.motivo_override_c2 || null,
margen: Number(form.margen) / 100,
};
}
async function calcular() {
setBusy(true);
setErr(null);
setResultado(null);
try {
setResultado(await A8Api.calcularCostos(cursoId, calculoInput()));
} catch (e) {
setErr(e.message);
} finally {
setBusy(false);
}
}
async function confirmar() {
setBusy(true);
setErr(null);
try {
const r = await A8Api.confirmarPrecioCurso(cursoId, {
...calculoInput(),
nombre_comercial: form.nombre_comercial,
motivo_override: motivoOverride || null,
});
setEstado(r);
setResultado(null);
setMotivoOverride("");
} catch (e) {
setErr(e.message);
} finally {
setBusy(false);
}
}
async function marcarGratuito(motivo) {
setBusy(true);
setErr(null);
try {
const r = await A8Api.marcarCursoGratuito(cursoId, motivo || null);
setEstado(r);
setGratuitoModal(false);
} catch (e) {
setErr(e.message);
} finally {
setBusy(false);
}
}
const esGratuito = estado && estado.categoria === "gratuito";
return (
Curso
setCursoId(e.target.value)}>
Elegí un curso…
{cursos.map((c) => ({c.titulo || c.curso_id} ))}
{cursoId &&
}
{cursoId && !esGratuito && (
setGratuitoModal(true)} style={{ marginTop: 10 }}>
Marcar como gratuito (captación / interno)
)}
{err &&
{err}
}
{cursoId && !esGratuito && (
<>
Resultado
{!resultado &&
Calculá para ver el precio sugerido y el punto de equilibrio.
}
{resultado && (
<>
Margen efectivo
{fmtPct(resultado.margen_efectivo)}
Punto de equilibrio
{resultado.n_equilibrio == null ? "∞" : resultado.n_equilibrio} alumnos
Precio = {fmtUSD(resultado.verificacion.precio)}
Impuestos = {fmtUSD(resultado.verificacion.impuestos)}
Neto = {fmtUSD(resultado.verificacion.neto)}
Costo = {fmtUSD(resultado.verificacion.costo)}
Utilidad = {fmtUSD(resultado.verificacion.utilidad)}
Sensibilidad
{resultado.tabla_sensibilidad.map((p) => (
{p.n} alumnos {fmtUSD(p.resultado_usd)}
))}
{resultado.candado_activado && (
<>
>
)}
{err &&
{err}
}
{busy ? "Confirmando…" : "Confirmar precio"}
>
)}
>
)}
{gratuitoModal &&
setGratuitoModal(false)} onConfirm={marcarGratuito} busy={busy} />}
);
}
// ---------------- Módulo raíz ----------------
function CostosModule() {
const [tab, setTab] = useState("curso");
return (
Consumo y precio
Costos y precio
Calculadora por curso (COSTOS-1). El precio confirmado nunca se recalcula solo: cada confirmación queda versionada, y es el mismo dato que el funnel de pago va a leer.
setTab("curso")}>Curso
setTab("parametros")}>Parámetros globales
{tab === "curso" ?
:
}
);
}
Object.assign(window, { CostosModule });