diff --git a/frontend/src/pages/Rentabilidad.jsx b/frontend/src/pages/Rentabilidad.jsx index f38a2f4..794734a 100644 --- a/frontend/src/pages/Rentabilidad.jsx +++ b/frontend/src/pages/Rentabilidad.jsx @@ -17,6 +17,30 @@ const BG_INI = "#eef4ff", BG_ACT = "#edfff6"; function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; } +// ── Ordenamiento (detecta monto S/, %, fecha dd-mm-yyyy, número o texto) ── +function _valorOrden(v) { + const s = String(v ?? "").trim(); + if (s === "" || s === "—" || s === "-") return { t: "vacio", n: 0, s: "" }; + 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 }; } + 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() }; +} +function _cmp(a, b, dir) { + const mult = dir === "asc" ? 1 : -1; + const va = _valorOrden(a), vb = _valorOrden(b); + if (va.t === "vacio" && vb.t === "vacio") return 0; + if (va.t === "vacio") return 1; + 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); +} +function _flecha(activo, dir) { + if (!activo) return ⇅; + return {dir === "asc" ? "▲" : "▼"}; +} + export default function Rentabilidad() { const hoy = new Date(); const [ano, setAno] = useState(hoy.getFullYear()); @@ -30,6 +54,14 @@ export default function Rentabilidad() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [modalVer, setModalVer] = useState(null); + const [ordCol, setOrdCol] = useState(null); // índice REAL de columna + const [ordDir, setOrdDir] = useState(null); + const clickOrden = (i) => { + if (i === 13) return; // columna OPC. no se ordena + if (ordCol !== i) { setOrdCol(i); setOrdDir("asc"); } + else if (ordDir === "asc") setOrdDir("desc"); + else { setOrdCol(null); setOrdDir(null); } + }; // Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado const IDX_INSCRITOS = [2, 3, 4]; const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i); @@ -456,7 +488,11 @@ export default function Rentabilidad() { return () => { activo = false; off(); }; }, [ano, mesesKey, sede, programa]); - const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]); + const datos = useMemo(() => { + let base = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"); + if (ordCol != null && ordDir) base = [...base].sort((a,b)=>_cmp(a[ordCol], b[ordCol], ordDir)); + return base; + }, [filas, ordCol, ordDir]); // TOTAL GENERAL recalculado en frontend (varios meses juntos). Suma columnas // sumables, recalcula MB% (=1-costo/venta), y promedia cuota/desc de las filas con valor. const total = useMemo(() => { @@ -529,7 +565,15 @@ export default function Rentabilidad() {
- {COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=>)} + {COLS.map((c,i)=>({c,i})).filter((o)=>!ocultar(o.i)).map((o, vi)=>{ + const ordenable = o.i !== 13; + return ( + + ); + })} {datos.map((f, ri) => ( @@ -696,6 +740,13 @@ function MesesMulti({ label, meses, onChange }) { function ModalVerDetalle({ programa, ano, mes, resumen, onClose }) { 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.rentabilidadDetalle(programa, ano, mes) @@ -706,6 +757,15 @@ function ModalVerDetalle({ programa, ano, mes, resumen, onClose }) { const COLS_DET = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","VALOR CUOTA","PROM. DESC. E.","VALOR VENTA","ESTADO"]; + // Ordena dejando la fila TOTAL GENERAL siempre al final. + const filasOrd = useMemo(() => { + if (ordCol == null || !ordDir) return filas; + const tot = filas.filter((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"); + const rest = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"); + rest.sort((a,b)=>_cmp(a[ordCol], b[ordCol], ordDir)); + return [...rest, ...tot]; + }, [filas, ordCol, ordDir]); + return ( {resumen && ( @@ -720,9 +780,14 @@ function ModalVerDetalle({ programa, ano, mes, resumen, onClose }) { filas.length===0 ?
Sin alumnos.
:
{c}
clickOrden(o.i)} + style={{position:"sticky",top:0,zIndex:2, cursor: ordenable?"pointer":"default", userSelect:"none"}}> + {o.c}{ordenable && _flecha(ordCol===o.i, ordDir)} +
- {COLS_DET.map((c)=>)} + {COLS_DET.map((c,i)=>( + + ))} - {filas.map((f,i)=>{ + {filasOrd.map((f,i)=>{ const esTot = String(f[0]).toUpperCase()==="TOTAL GENERAL"; const stTot = esTot ? {position:"sticky",bottom:0,zIndex:9, backgroundColor:"#dbeafe",fontWeight:700,color:"#1e3a5f",
{c}
clickOrden(i)} + style={{whiteSpace:"nowrap", position:"sticky", top:0, zIndex:2, cursor:"pointer", userSelect:"none"}}> + {c}{_flecha(ordCol===i, ordDir)} +