// ============ 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 (
{value}
{label}
); } // ---------------- 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 }} />
))}
); } 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 }} />
))}
); } // ---------------- 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 (
Parámetros globales
{actual && (
Versión vigente: {actual.version} · cargada por {actual.creado_por} el {new Date(actual.creado_en).toLocaleString("es-AR")}
)} setForm({ ...form, masa_salarial_usd: e.target.value })} />
setForm({ ...form, porcentaje_academia8: e.target.value })} />%
setForm({ ...form, plataforma_usd: e.target.value })} /> setForm({ ...form, base_alumnos: e.target.value })} /> setForm({ ...form, tc_referencia: e.target.value })} /> setForm({ ...form, impuestos })} /> {err &&
{err}
} {busy ? "Guardando…" : "Guardar nueva versión"}
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}
); } 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)})
Versión
{estado.version}
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
{cursoId && } {cursoId && !esGratuito && ( setGratuitoModal(true)} style={{ marginTop: 10 }}> Marcar como gratuito (captación / interno) )} {err &&
{err}
}
{cursoId && !esGratuito && ( <>
Cálculo
setF("nombre_comercial", e.target.value)} /> setF("duracion_meses", e.target.value)} /> setF("n_estimado", e.target.value)} /> setF("fijos", fijos)} /> setF("c2_valor", e.target.value)} /> setF("c2_valor", v)} /> {form.c2_es_override && ( setF("motivo_override_c2", e.target.value)} style={{ marginTop: 6 }} /> )}
setF("margen", e.target.value)} />%
{busy ? "Calculando…" : "Calcular"}
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 && ( <>