Actualizar frontend/src/pages/Cobranza.jsx

This commit is contained in:
2026-09-22 10:32:12 -05:00
parent f07c7e000b
commit f4fc67d983

View File

@@ -19,8 +19,81 @@ const BG_ANT = "#fef3c7", BG_MES = "#d1fae5", BG_TOT = "#ede9fe";
// Versiones más oscuras para hover de cada cartera
const HV_ANT = "#fde68a", HV_MES = "#a7f3d0", HV_TOT = "#ddd6fe";
// Select de una opción con desplegable custom (mismo estilo que Ocupabilidad/Rentabilidad).
function SelectBonito({ label, value, options, onChange, minWidth = 150 }) {
const [abierto, setAbierto] = useState(false);
const ref = useRef(null);
const opts = options.map((o) => (typeof o === "object" ? o : { value: o, label: String(o) }));
const actual = opts.find((o) => String(o.value) === String(value));
useEffect(() => {
const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); };
document.addEventListener("mousedown", fuera);
return () => document.removeEventListener("mousedown", fuera);
}, []);
return (
<div className="filter-group" ref={ref} style={{ position: "relative", minWidth }}>
<label>{label}</label>
<button type="button" onClick={() => setAbierto((v) => !v)}
style={{ height: 38, width: "100%", textAlign: "left", padding: "0 12px",
border: "1px solid #cbd5e1", borderRadius: 8, background: "#fff",
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center",
justifyContent: "space-between", gap: 8 }}>
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{actual ? actual.label : "—"}
</span>
<span style={{ color: "#94a3b8", fontSize: 11, transition: "transform .15s",
transform: abierto ? "rotate(180deg)" : "none" }}>▼</span>
</button>
{abierto && (
<div style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4,
background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", padding: 6,
maxHeight: 300, overflowY: "auto" }}>
{opts.map((o) => {
const on = String(o.value) === String(value);
return (
<div key={o.value} onClick={() => { onChange(o.value); 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.label}
</div>
);
})}
</div>
)}
</div>
);
}
function toMonto(s){ const n = parseFloat(String(s).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
function toPct(s){ const n = parseFloat(String(s).replace("%","").trim()); return isNaN(n)?0:n; }
// ── Ordenamiento de tablas (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 <span style={{ opacity: 0.3, marginLeft: 4 }}>⇅</span>;
return <span style={{ marginLeft: 4 }}>{dir === "asc" ? "▲" : "▼"}</span>;
}
// Formato moneda SIN decimales (solo visualización)
function fmtMoneda(s){
if (s == null) return "";
@@ -419,14 +492,14 @@ export default function Cobranza() {
<h1 className="page-title">📋 Cobranza</h1>
<Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
<Select label="Agrupación" value={agrupacion} options={AGRUPACIONES} onChange={(v)=>{setAgrupacion(v);setSede("TODOS");setFrec("TODOS");setSedeFiltro("TODOS");setProgFiltro("TODOS");}} />
<Select label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} width={250} />
{esPrograma && <Select label="Sede" value={sede} options={SEDES} onChange={setSede} />}
{esPrograma && <Select label="Frecuencia" value={frec} options={FRECS} onChange={setFrec} />}
{(agrupacion==="SEDE"||agrupacion==="ASESOR") && <Select label="Sede" value={sedeFiltro} options={SEDES} onChange={(v)=>{setSedeFiltro(v);setExpandidas(new Set());}} />}
{(agrupacion==="SEDE"||agrupacion==="ASESOR") && <Select label="Programa" value={progFiltro} options={PROGRAMAS_FILTRO} onChange={(v)=>{setProgFiltro(v);setExpandidas(new Set());}} />}
<SelectBonito label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} minWidth={110} />
<SelectBonito label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} minWidth={150} />
<SelectBonito label="Agrupación" value={agrupacion} options={AGRUPACIONES} onChange={(v)=>{setAgrupacion(v);setSede("TODOS");setFrec("TODOS");setSedeFiltro("TODOS");setProgFiltro("TODOS");}} minWidth={150} />
<SelectBonito label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} minWidth={250} />
{esPrograma && <SelectBonito label="Sede" value={sede} options={SEDES} onChange={setSede} minWidth={140} />}
{esPrograma && <SelectBonito label="Frecuencia" value={frec} options={FRECS} onChange={setFrec} minWidth={140} />}
{(agrupacion==="SEDE"||agrupacion==="ASESOR") && <SelectBonito label="Sede" value={sedeFiltro} options={SEDES} onChange={(v)=>{setSedeFiltro(v);setExpandidas(new Set());}} minWidth={140} />}
{(agrupacion==="SEDE"||agrupacion==="ASESOR") && <SelectBonito label="Programa" value={progFiltro} options={PROGRAMAS_FILTRO} onChange={(v)=>{setProgFiltro(v);setExpandidas(new Set());}} minWidth={150} />}
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
<div style={{position:"relative"}}>
<button className="btn btn-ghost"
@@ -785,6 +858,29 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, ano, mes, onClose }) {
const [query, setQuery] = useState("");
const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR
const [infoAlumno, setInfoAlumno] = useState(null); // alumno del pop-up INFO
const [ordCol, setOrdCol] = useState(null);
const [ordDir, setOrdDir] = useState(null);
const clickOrden = (i) => {
if (i === 14) return; // columna INFO no se ordena
if (ordCol !== i) { setOrdCol(i); setOrdDir("asc"); }
else if (ordDir === "asc") setOrdDir("desc");
else { setOrdCol(null); setOrdDir(null); }
};
// Valor de cada columna (por índice) para ordenar, sacado del objeto alumno.
const _valBusc = (a, i) => {
switch (i) {
case 0: return a.programa; case 1: return a.alumno;
case 2: return a.num_cuota; case 3: return a.fch_venc;
case 4: return a.cta_ant; case 5: return a.cob_ant;
case 6: return a.cta_ant > 0 ? a.cob_ant / a.cta_ant : 0;
case 7: return a.cta_cur; case 8: return a.cob_cur;
case 9: return a.cta_cur > 0 ? a.cob_cur / a.cta_cur : 0;
case 10: return a.cta_tot; case 11: return a.cob_tot;
case 12: return a.cta_tot > 0 ? a.cob_tot / a.cta_tot : 0;
case 13: return a.saldo;
default: return "";
}
};
const cargarPorRetirar = () => {
api.alumnosPorRetirar(ano, mes)
@@ -796,12 +892,16 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, ano, mes, onClose }) {
const resultados = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return alumnos.filter((a) =>
let base = alumnos.filter((a) =>
String(a.alumno).toLowerCase().includes(q) ||
String(a.programa).toLowerCase().includes(q) ||
String(a.matricula).toLowerCase().includes(q)
);
}, [alumnos, query]);
if (ordCol != null && ordDir) {
base = [...base].sort((x, y) => _cmp(_valBusc(x, ordCol), _valBusc(y, ordCol), ordDir));
}
return base;
}, [alumnos, query, ordCol, ordDir]);
const COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.",
"CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO","INFO"];
@@ -869,7 +969,10 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, ano, mes, onClose }) {
const fija = i < FIJAS;
const stH = fija ? stickyCol(i,{esHeader:true,bg:"#334155"})
: { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" };
return <th key={c} style={{...stH, position:stH.position||"relative"}}>{c}<cols.Resizer index={i} /></th>;
const ordenable = i !== 14;
return <th key={c} onClick={()=>clickOrden(i)}
style={{...stH, position:stH.position||"relative", cursor: ordenable?"pointer":"default", userSelect:"none"}}>
{c}{ordenable && _flecha(ordCol===i, ordDir)}<cols.Resizer index={i} /></th>;
})}</tr></thead>
<tbody>
{resultados.map((a,i)=>{
@@ -939,6 +1042,22 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, progFiltro
const [loading, setLoading] = useState(true);
const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR
const [soloRetirar, setSoloRetirar] = useState(false); // filtro: ver solo POR RETIRAR
const [ordCol, setOrdCol] = useState(null);
const [ordDir, setOrdDir] = useState(null);
const clickOrden = (i) => {
if (i === 14) return; // columna INFO no se ordena
if (ordCol !== i) { setOrdCol(i); setOrdDir("asc"); }
else if (ordDir === "asc") setOrdDir("desc");
else { setOrdCol(null); setOrdDir(null); }
};
// Valor por columna: col 0 (PROGRAMA) usa f[14]; el resto f[i]; % (6,9,12) numérico.
const _valDet = (f, i) => {
if (i === 0) return f[14] || "";
if (i === 6) return toPct(f[6]);
if (i === 9) return toPct(f[9]);
if (i === 12) return toPct(f[12]);
return f[i];
};
useEffect(() => {
let activo = true;
@@ -949,7 +1068,7 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, progFiltro
return () => { activo = false; };
}, [grupo, ano, mes, sectorista, agrupacion, progFiltro, sedeFiltro, vencHasta]);
// Cargar (y recargar) las matrículas marcadas POR RETIRAR para tachar sus filas
// Cargar (y recargar) las matrículas marcadas POR RETIRAR (de este mes) para tachar sus filas
const cargarPorRetirar = () => {
api.alumnosPorRetirar(ano, mes)
.then((r) => setPorRetirar(new Set((r.matriculas || []).map(String))))
@@ -1085,11 +1204,15 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, progFiltro
const stH = fija
? stickyCol(i,{esHeader:true,bg:"#334155"})
: { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" };
return <th key={c} style={{...stH, position:stH.position||"relative"}}>{c}<cols.Resizer index={i} /></th>;
const ordenable = i !== 14;
return <th key={c} onClick={()=>clickOrden(i)}
style={{...stH, position:stH.position||"relative", cursor: ordenable?"pointer":"default", userSelect:"none"}}>
{c}{ordenable && _flecha(ordCol===i, ordDir)}<cols.Resizer index={i} /></th>;
})}</tr></thead>
<tbody>
{filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL")
.filter((f)=> !soloRetirar || porRetirar.has(String(f[0])))
.sort((a,b)=> (ordCol!=null && ordDir) ? _cmp(_valDet(a,ordCol), _valDet(b,ordCol), ordDir) : 0)
.map((f, i) => {
const saldo = toMonto(f[13]);
const deuda = saldo > 0.01;
@@ -1212,7 +1335,7 @@ function ModalInfoAlumno({ alumno, ano, mes, onClose, onGuardado }) {
const [guardando, setGuardando] = useState(false);
const [guardado, setGuardado] = useState(false);
// Cargar estado/observaciones actuales del alumno al abrir
// Cargar estado/observaciones del alumno para ESTE mes/año al abrir
useEffect(() => {
let activo = true;
api.alumnoEstado(alumno.matricula, ano, mes)