Actualizar frontend/src/pages/Cobranza.jsx
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user