// src/pages/Ventas.jsx
import { useState, useEffect, useMemo } 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 = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
const PROGRAMAS = ["TODOS", "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 = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"];
// ── 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" ? "▲" : "▼"};
}
export default function Ventas() {
const hoy = new Date();
const [ano, setAno] = useState(hoy.getFullYear());
const [mes, setMes] = useState(hoy.getMonth() + 1);
const [sede, setSede] = useState("TODOS");
const [programa, setPrograma] = useState("TODOS");
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); }
};
useEffect(() => {
let activo = true;
setVistaActual(`ventas:${ano}|${mes}|${sede}|${programa}|False`);
const cargar = () => {
setLoading(true); setError(null);
api.ventas(ano, mes, sede, programa)
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
};
cargar();
const off = onRefrescar(cargar);
return () => { activo = false; off(); };
}, [ano, mes, sede, programa]);
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
{loading ?
:
error ?
:
<>
{KPIS.map(([ico,label,val,sub]) => (
{ico}
{label}
{val}
{sub}
))}
{COLS.map((c, i) => {
const ordenable = i !== COLS.length - 1;
return (
| clickOrden(i)}>
{c}{ordenable && _flecha(ordCol === i, ordDir)}
|
);
})}
{datos.map((f, i) => (
| {f[0]} |
{f[1]} | {f[2]} |
{f[3]} | {f[4]} |
{f[5]} | {f[6]} |
{f[7]} |
|
))}
{total && (
| TOTAL GENERAL |
{total[1]} | {total[2]} |
{total[3]} | {total[4]} |
{total[5]} | {total[6]} |
{total[7]} | — |
)}
>}
{modal && (
setModal(null)} />
)}
);
}
function ModalDetalle({ vendedor, ano, mes, 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, "TODOS", "TODOS", true)
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
return () => { activo = false; };
}, [vendedor, ano, mes, tipo]);
// Columnas redimensionables (igual que las tablas principales)
const colsDet = useColumnasAjustables([200, 260, 120, 130, 120, 120, 130, 120]);
const filasOrd = useMemo(() => _ordenarFilas(filas, ordCol, ordDir), [filas, ordCol, ordDir]);
return (
{loading ? :
filas.length === 0 ? Sin registros para esta categoría.
:
{HEADERS_DETALLE.map((h, i) => (
| clickOrden(i)}>
{h}{_flecha(ordCol === i, ordDir)}
|
))}
{filasOrd.map((f, i) => (
{f.slice(0, HEADERS_DETALLE.length).map((v, j) => (
| {v} |
))}
))}
}
);
}