// src/pages/Ventas.jsx import { useState, useEffect, useMemo, useRef } from "react"; import { api } from "../lib/api"; import { Loader, ErrorBox, Filters, Select } from "../components/UI"; import Modal from "../components/Modal"; import { useColumnasAjustables } from "../lib/useColumnasAjustables"; import { setVistaActual, onRefrescar } from "../lib/vistaActual"; const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; const ANOS = [2024, 2025, 2026]; const SEDES = ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; const PROGRAMAS = ["SEMINARIOS", "OTROS", "TEAC", "TERC"]; const COLS = ["VENDEDOR","INSCRITOS","VENTA TOTAL","INSCRITOS P.C DEL MES","VENTA P.C DEL MES","INSCRITOS MES ANTERIOR","VENTA MES ANTERIOR","AVANCE P.C TOTAL","OPC."]; const TIPOS = ["Venta Inscritos", "Venta P.C", "Venta Pendientes"]; const HEADERS_DETALLE = ["ALUMNO","PROGRAMA","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"]; // Índice en la fila del backend para cada columna del detalle (arriba). // Fila: [0]vend [1]alumno [2]f.mat [3]f.canc [4]tipo [5]cuota [6]desc [7]inv [8]programa const IDX_DETALLE = [1, 8, 2, 3, 4, 5, 6, 7]; // ── Ordenamiento de tablas (detecta número/soles, fecha dd-mm-yyyy o texto) ── function _valorOrden(v) { const s = String(v ?? "").trim(); if (s === "" || s === "—" || s === "-") return { t: "vacio", n: 0, s: "" }; // Fecha dd-mm-yyyy o dd/mm/yyyy const mf = s.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/); if (mf) { const [, d, m, y] = mf; return { t: "fecha", n: new Date(+y, +m - 1, +d).getTime(), s }; } // Número / soles / porcentaje (quita S/, comas, %, espacios) const limpio = s.replace(/S\/|%|,/g, "").replace(/\s/g, "").trim(); if (limpio !== "" && !isNaN(Number(limpio))) { return { t: "num", n: Number(limpio), s }; } return { t: "texto", n: 0, s: s.toLowerCase() }; } // Ordena filas (arrays) por la columna idx. dir: "asc" | "desc". Vacíos al final. function _ordenarFilas(filas, idx, dir) { if (idx == null || !dir) return filas; const mult = dir === "asc" ? 1 : -1; return [...filas].sort((a, b) => { const va = _valorOrden(a[idx]); const vb = _valorOrden(b[idx]); if (va.t === "vacio" && vb.t === "vacio") return 0; if (va.t === "vacio") return 1; // vacíos siempre al final if (vb.t === "vacio") return -1; if (va.t === "texto" || vb.t === "texto") { return mult * va.s.localeCompare(vb.s, "es"); } return mult * (va.n - vb.n); }); } // Flecha del encabezado según el estado de orden de esa columna. function _flecha(activo, dir) { if (!activo) return ⇅; return {dir === "asc" ? "▲" : "▼"}; } // Junta varias respuestas (una por mes) sumando por vendedor las columnas 1..7. // Mantiene el mismo formato "S/ x" para montos y número para cantidades. function _juntarMeses(listasFilas) { const _n = (v) => { const x = parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(x)?0:x; }; const _esMonto = (v) => String(v).includes("S/"); const _fmt = (n, monto) => monto ? `S/ ${Math.round(n).toLocaleString("es-PE")}` : String(Math.round(n)); const acc = {}; // vendedor -> array acumulado const orden = []; listasFilas.forEach((filas) => { (filas || []).forEach((f) => { if (String(f[0]).toUpperCase() === "TOTAL GENERAL") return; const v = f[0]; if (!acc[v]) { acc[v] = [v, 0,0,0,0,0,0,0]; orden.push(v); acc[v]._monto = f.map(_esMonto); } for (let i = 1; i <= 7; i++) acc[v][i] = _n(acc[v][i]) + _n(f[i]); }); }); const out = orden.map((v) => { const a = acc[v]; const mon = a._monto || []; const fila = [v]; for (let i = 1; i <= 7; i++) fila.push(_fmt(a[i], mon[i])); return fila; }); // TOTAL GENERAL sumando todos los vendedores. if (out.length) { const tot = ["TOTAL GENERAL"]; const monRef = (acc[orden[0]]._monto) || []; for (let i = 1; i <= 7; i++) { const s = out.reduce((sum, f) => sum + _n(f[i]), 0); tot.push(_fmt(s, monRef[i])); } out.push(tot); } return out; } export default function Ventas() { const hoy = new Date(); const [ano, setAno] = useState(hoy.getFullYear()); const [meses, setMeses] = useState([hoy.getMonth() + 1]); // multi-mes; inicia con el actual const [sedes, setSedes] = useState([]); // vacío = todas const [programas, setProgramas] = useState([]); // vacío = todos const mesesKey = useMemo(() => [...meses].sort((a,b)=>a-b).join(","), [meses]); const sedesKey = useMemo(() => [...sedes].sort().join(","), [sedes]); const programasKey = useMemo(() => [...programas].sort().join(","), [programas]); const [filas, setFilas] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [modal, setModal] = useState(null); // { vendedor } const [ordCol, setOrdCol] = useState(null); // índice de columna ordenada const [ordDir, setOrdDir] = useState(null); // "asc" | "desc" const cols = useColumnasAjustables([200, 100, 130, 120, 130, 120, 130, 140, 100]); // Click en encabezado: alterna asc -> desc -> sin orden. const clickOrden = (i) => { if (i === COLS.length - 1) return; // columna OPC. no se ordena if (ordCol !== i) { setOrdCol(i); setOrdDir("asc"); } else if (ordDir === "asc") setOrdDir("desc"); else { setOrdCol(null); setOrdDir(null); } }; // 'mes' para modales/exportar = el menor mes marcado (o el actual si no hay). const mes = meses.length ? Math.min(...meses) : (hoy.getMonth() + 1); useEffect(() => { let activo = true; const lista = mesesKey ? mesesKey.split(",").map(Number) : []; const sedeArg = sedesKey || "TODOS"; const progArg = programasKey || "TODOS"; setVistaActual(`ventas:${ano}|${mesesKey}|${sedeArg}|${progArg}|False`); const cargar = () => { setLoading(true); setError(null); if (!lista.length) { setFilas([]); setLoading(false); return; } // Una llamada por cada mes; se juntan sumando por vendedor (sin tocar la lógica del backend). Promise.all(lista.map((m) => api.ventas(ano, m, sedeArg, progArg))) .then((resArr) => { if (!activo) return; if (lista.length === 1) { setFilas(resArr[0].filas || []); setLoading(false); return; } setFilas(_juntarMeses(resArr.map((r) => r.filas || []))); setLoading(false); }) .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); }; cargar(); const off = onRefrescar(cargar); return () => { activo = false; off(); }; }, [ano, mesesKey, sedesKey, programasKey]); // Precarga en 2º plano: al elegir año/mes, calienta el caché del backend con cada // sede y cada programa por separado, para que al marcar el filtro ya esté listo // (sin "cargando"). No bloquea la UI; los resultados se descartan (solo cachean). useEffect(() => { const lista = mesesKey ? mesesKey.split(",").map(Number) : []; if (!lista.length) return; let cancelado = false; const combos = []; lista.forEach((m) => { SEDES.forEach((s) => combos.push([m, s, "TODOS"])); PROGRAMAS.forEach((p) => combos.push([m, "TODOS", p])); }); // Dispara de a poco para no saturar el backend. (async () => { for (const [m, s, p] of combos) { if (cancelado) return; try { await api.ventas(ano, m, s, p); } catch (e) { /* ignora */ } } })(); return () => { cancelado = true; }; }, [ano, mesesKey]); const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]); const datos = useMemo(() => { const base = filas.filter((f) => f[0] !== "TOTAL GENERAL"); return _ordenarFilas(base, ordCol, ordDir); }, [filas, ordCol, ordDir]); const KPIS = total ? [ ["👤","Vendedores", datos.length, "activos este mes"], ["🎓","Total Inscritos", total[1], "matriculados"], ["💵","Venta Total", total[2], "inversión neta"], ["✅","Venta P.C del Mes", total[4], "pagos completos"], ["🚀","Avance P.C Total", total[7], "P.C + Pendientes"], ] : []; // Exportar saldos por cuota — Excel con formato (anchos, encabezado de color, // fechas reales, montos numéricos en soles y fila TOTAL GENERAL). async function exportarCuota(tipoCuota) { const toNum = (v) => { const n = parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }; // Convierte "dd-mm-yyyy" o "dd/mm/yyyy" a Date; si no puede, devuelve el texto original const toDate = (v) => { if (!v) return ""; const s = String(v).trim().replace(/\//g,"-"); const p = s.split("-"); if (p.length === 3) { let [d,m,y] = p; if (y.length === 4) return new Date(+y, +m-1, +d); } return String(v); }; let res; try { res = await api.saldoPendiente(tipoCuota); } catch (e) { alert("No se pudo obtener los datos de saldos: " + e.message); return; } const datosS = res.datos || []; const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`; const filtrados = datosS.filter((d) => toNum(d[colSaldo]) > 0.01); if (filtrados.length === 0) { alert(`No hay saldos pendientes para ${tipoCuota}.`); return; } const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"]; const COL_MONTO = ["SALDO MAT.", colSaldo, "INV. NETA"]; const COL_FECHA = ["F. INICIO","F. MATR.","VENCIMIENTO"]; const XLSX = await import("xlsx-js-style"); // Construir matriz de celdas (AOA) con tipos correctos const headerStyle = { font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 }, fill: { fgColor: { rgb: "1E40AF" } }, alignment: { horizontal: "center", vertical: "center" }, }; const moneyFmt = '"S/" #,##0'; const dateFmt = "dd/mm/yyyy"; const ws = {}; const range = { s: { r: 0, c: 0 }, e: { r: filtrados.length + 1, c: COLS.length - 1 } }; // Encabezado COLS.forEach((h, c) => { const ref = XLSX.utils.encode_cell({ r: 0, c }); ws[ref] = { v: h, t: "s", s: headerStyle }; }); // Filas de datos filtrados.forEach((d, ri) => { KEYS.forEach((k, c) => { const ref = XLSX.utils.encode_cell({ r: ri + 1, c }); const col = COLS[c]; if (COL_MONTO.includes(col)) { ws[ref] = { v: toNum(d[k]), t: "n", z: moneyFmt }; } else if (COL_FECHA.includes(col)) { const dt = toDate(d[k]); if (dt instanceof Date) ws[ref] = { v: dt, t: "d", z: dateFmt }; else ws[ref] = { v: dt, t: "s" }; } else { ws[ref] = { v: d[k] ?? "", t: "s" }; } }); }); // Fila TOTAL GENERAL: promedio en SALDO MAT. y SALDO CUOTA, suma en INV. NETA const rTot = filtrados.length + 1; const sum = (k) => filtrados.reduce((s,d)=>s+toNum(d[k]),0); const prom = (k) => filtrados.length ? sum(k)/filtrados.length : 0; const totalStyle = { font: { bold: true }, fill: { fgColor: { rgb: "EFF6FF" } } }; COLS.forEach((col, c) => { const ref = XLSX.utils.encode_cell({ r: rTot, c }); if (c === 0) ws[ref] = { v: "TOTAL GENERAL", t: "s", s: totalStyle }; else if (col === "SALDO MAT.") ws[ref] = { v: prom("SALDO MAT."), t: "n", z: moneyFmt, s: totalStyle }; else if (col === colSaldo) ws[ref] = { v: prom(colSaldo), t: "n", z: moneyFmt, s: totalStyle }; else if (col === "INV. NETA") ws[ref] = { v: sum("INV. NETA"), t: "n", z: moneyFmt, s: totalStyle }; else ws[ref] = { v: "", t: "s", s: totalStyle }; }); ws["!ref"] = XLSX.utils.encode_range(range); ws["!cols"] = [ { wch: 12 }, // MATRÍCULA { wch: 22 }, // VENDEDOR { wch: 28 }, // ALUMNO { wch: 32 }, // PROGRAMA { wch: 12 }, // F. INICIO { wch: 12 }, // F. MATR. { wch: 13 }, // SALDO MAT. { wch: 15 }, // SALDO CUOTA { wch: 13 }, // VENCIMIENTO { wch: 13 }, // INV. NETA ]; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "Saldos"); const nombre = tipoCuota.replace("°","").replace(/\s/g,"_"); XLSX.writeFile(wb, `saldos_${nombre}.xlsx`); } return (

🛒 Ventas

setAno(+v)} minWidth={110} />
{loading ? : error ? : <>
{KPIS.map(([ico,label,val,sub]) => (
{ico}
{label}
{val}
{sub}
))}
{COLS.map((c, i) => { const ordenable = i !== COLS.length - 1; return ( ); })} {datos.map((f, i) => ( ))} {total && ( )}
clickOrden(i)}> {c}{ordenable && _flecha(ordCol === i, ordDir)}
{f[0]} {f[1]}{f[2]} {f[3]}{f[4]} {f[5]}{f[6]} {f[7]}
TOTAL GENERAL {total[1]}{total[2]} {total[3]}{total[4]} {total[5]}{total[6]} {total[7]}—
} {modal && ( setModal(null)} /> )}
); } function ModalDetalle({ vendedor, ano, mes, sede = "TODOS", programa = "TODOS", onClose }) { const [tipo, setTipo] = useState(TIPOS[0]); const [filas, setFilas] = useState([]); const [loading, setLoading] = useState(true); const [ordCol, setOrdCol] = useState(null); const [ordDir, setOrdDir] = useState(null); const clickOrden = (i) => { if (ordCol !== i) { setOrdCol(i); setOrdDir("asc"); } else if (ordDir === "asc") setOrdDir("desc"); else { setOrdCol(null); setOrdDir(null); } }; useEffect(() => { let activo = true; setLoading(true); api.ventasDetalle(vendedor, ano, mes, tipo, sede, programa, true) .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) .catch(() => { if (activo) { setFilas([]); setLoading(false); } }); return () => { activo = false; }; }, [vendedor, ano, mes, tipo, sede, programa]); // Columnas redimensionables (igual que las tablas principales) const colsDet = useColumnasAjustables([200, 260, 120, 130, 120, 120, 130, 120]); const filasOrd = useMemo(() => { const _n = (v) => { const n = parseFloat(String(v ?? "").replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }; const _m = (v) => "S/ " + Math.round(v).toLocaleString("es-PE"); // Ignorar el TOTAL que venga del backend y recalcularlo siempre desde los alumnos // (al filtrar por sede/programa el backend NO envía la fila TOTAL). const alumnos = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL"); const ordenadas = _ordenarFilas(alumnos, ordCol == null ? null : IDX_DETALLE[ordCol], ordDir); if (alumnos.length === 0) return ordenadas; // Fila TOTAL: INV.NETA[7] y DESC[6] suman; CUOTA PROM[5] promedio de los que tienen valor. let sInv = 0, sDesc = 0, sCuota = 0, nCuota = 0; alumnos.forEach((f) => { sInv += _n(f[7]); sDesc += _n(f[6]); const c = _n(f[5]); if (c > 0) { sCuota += c; nCuota += 1; } }); const total = []; total[0] = "TOTAL GENERAL"; total[5] = nCuota ? _m(sCuota / nCuota) : ""; total[6] = sDesc ? _m(sDesc) : ""; total[7] = _m(sInv); return [...ordenadas, total]; }, [filas, ordCol, ordDir]); return (
toggle(o)} /> {o} ); })}
)} ); } // Desplegable de UNA sola opción (Año), con el mismo estilo bonito. function SelectUnico({ label, opciones, valor, onChange, minWidth = 150 }) { const [abierto, setAbierto] = useState(false); const ref = useRef(null); useEffect(() => { const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }; document.addEventListener("mousedown", fuera); return () => document.removeEventListener("mousedown", fuera); }, []); return (
{abierto && (
{opciones.map((o) => { const on = String(o) === String(valor); return (
{ onChange(o); setAbierto(false); }} style={{ padding: "8px 10px", borderRadius: 6, cursor: "pointer", fontSize: 13, fontWeight: on ? 700 : 500, color: on ? "#1e3a5f" : "#334155", background: on ? "#eff6ff" : "transparent" }} onMouseEnter={(e) => { if (!on) e.currentTarget.style.background = "#f8fafc"; }} onMouseLeave={(e) => { if (!on) e.currentTarget.style.background = "transparent"; }}> {o}
); })}
)}
); } // Filtro de MES con casillas (multi). meses = array de 1..12. function MesesMulti({ label, meses, onChange, minWidth = 190 }) { const [abierto, setAbierto] = useState(false); const ref = useRef(null); useEffect(() => { const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); }; document.addEventListener("mousedown", fuera); return () => document.removeEventListener("mousedown", fuera); }, []); const toggle = (m) => { const set = new Set(meses); set.has(m) ? set.delete(m) : set.add(m); onChange([...set].sort((a, b) => a - b)); }; const texto = meses.length === 0 ? "— Elegir —" : meses.length === 1 ? MESES[meses[0] - 1] : meses.length === 12 ? "TODOS" : `${meses.length} meses`; return (
{abierto && (
{MESES.map((m, i) => { const val = i + 1; const on = meses.includes(val); return ( ); })}
)}
); }