Actualizar frontend/src/pages/Cobranza.jsx

This commit is contained in:
2026-08-26 09:47:21 -05:00
parent c50ee402f4
commit e5f0754493

View File

@@ -458,6 +458,13 @@ function TarjetasResumen({ r }) {
function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
const [texto, setTexto] = useState("");
const [query, setQuery] = useState("");
const [porRetirar, setPorRetirar] = useState(new Set());
useEffect(() => {
api.alumnosPorRetirar()
.then((r)=>setPorRetirar(new Set((r.matriculas||[]).map(String))))
.catch(()=>{});
}, []);
const resultados = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -508,6 +515,7 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
<tbody>
{resultados.map((a,i)=>{
const deuda = a.saldo > 0.01;
const retira = porRetirar.has(String(a.matricula));
const grp = agrupacion==="SEDE" ? a.sede : (agrupacion==="ASESOR" ? sectorista : a.programa);
const celdas = [
{v:grp, cls:"col-name", al:"left"},
@@ -527,7 +535,9 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
{v:fmtMoneda("S/ "+a.saldo), al:"right", saldo:true},
];
return (
<tr key={i} className={deuda?"row-deuda":""}>
<tr key={i} className={deuda?"row-deuda":""}
style={retira ? { textDecoration:"line-through", textDecorationColor:"#dc2626",
textDecorationThickness:"2px" } : undefined}>
{celdas.map((c,j)=>(
<td key={j} className={((c.cls||"")+" "+(c.cart||"")).trim()}
style={{textAlign:c.al||"center",
@@ -551,6 +561,9 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) {
const [filas, setFilas] = useState([]);
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 [infoAlumno, setInfoAlumno] = useState(null); // alumno del pop-up INFO
useEffect(() => {
let activo = true;
@@ -561,10 +574,18 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
return () => { activo = false; };
}, [grupo, ano, mes, sectorista, agrupacion]);
const COLS = ["PROGRAMA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"];
// Cargar (y recargar) las matrículas marcadas POR RETIRAR para tachar sus filas
const cargarPorRetirar = () => {
api.alumnosPorRetirar()
.then((r) => setPorRetirar(new Set((r.matriculas || []).map(String))))
.catch(() => {});
};
useEffect(() => { cargarPorRetirar(); }, []);
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"];
// Columnas redimensionables (igual que la tabla principal): PROGRAMA y ALUMNO más anchas
const anchosDet = COLS.map((h, i) => i === 0 ? 240 : (i === 1 ? 220 : (h.startsWith("%") ? 80 : 110)));
const anchosDet = COLS.map((h, i) => i === 0 ? 240 : (i === 1 ? 220 : (h==="INFO" ? 70 : h.startsWith("%") ? 80 : 110)));
const cols = useColumnasAjustables(anchosDet);
// Resumen para las 3 tarjetas (siempre visibles) — suma del detalle de alumnos
@@ -590,6 +611,16 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
return { total, pagaron, deben };
}, [filas]);
// POR RETIRAR: cantidad de alumnos marcados y suma de su saldo
const retiro = useMemo(() => {
let cant=0, saldo=0;
filas.forEach((f) => {
if (String(f[0]).toUpperCase() === "TOTAL") return;
if (porRetirar.has(String(f[0]))) { cant++; saldo += toMonto(f[13]); }
});
return { cant, saldo };
}, [filas, porRetirar]);
return (
<Modal title={`📊 Estado de Cuenta — ${grupo}`} onClose={onClose} width={1500}>
{loading ? <Loader text="Cargando estado de cuenta..." /> :
@@ -599,11 +630,11 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
<TarjetasResumen r={resumen} />
</div>
{/* Etiquetas de conteo de alumnos */}
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap"}}>
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap",alignItems:"center"}}>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700,
border:"1px solid #bfdbfe"}}>
👥 Total: {conteo.total} alumno{conteo.total!==1?"s":""}
👥 Total: {conteo.total}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#ecfdf5",color:"#047857",fontSize:13,fontWeight:700,
@@ -611,17 +642,38 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
Pagaron: {conteo.pagaron}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fef2f2",color:"#b91c1c",fontSize:13,fontWeight:700,
border:"1px solid #fecaca"}}>
borderRadius:999,background:"#fffbeb",color:"#b45309",fontSize:13,fontWeight:700,
border:"1px solid #fde68a"}}>
Por pagar: {conteo.deben}
</span>
<span style={{width:1,height:26,background:"#e2e8f0",margin:"0 4px"}} />
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fef2f2",color:"#dc2626",fontSize:13,fontWeight:700,
border:"1px solid #fecaca"}}>
🚫 Por retirar: {retiro.cant}
</span>
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:"#fef2f2",color:"#dc2626",fontSize:13,fontWeight:700,
border:"1px solid #fecaca"}}>
💸 Saldo: S/ {retiro.saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}
</span>
<label style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
borderRadius:999,background:soloRetirar?"#fee2e2":"#f8fafc",marginLeft:"auto",
color:soloRetirar?"#b91c1c":"#64748b",fontSize:13,fontWeight:700,cursor:"pointer",
border:`1px solid ${soloRetirar?"#fca5a5":"#e2e8f0"}`}}>
<input type="checkbox" checked={soloRetirar}
onChange={(e)=>setSoloRetirar(e.target.checked)} style={{cursor:"pointer"}} />
Ver solo por retirar
</label>
</div>
<div className="table-wrap" style={{overflowX:"auto"}}>
<table className="cob-table" {...cols.tableProps}>
<cols.ColGroup />
<thead><tr>{COLS.map((c,i)=><th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
<tbody>
{filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL").map((f, i) => {
{filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL")
.filter((f)=> !soloRetirar || porRetirar.has(String(f[0])))
.map((f, i) => {
const saldo = toMonto(f[13]);
const deuda = saldo > 0.01;
// Regla "-": si %ANT vacío → CTA/COB ANT a "—" (igual MES y TOT)
@@ -629,8 +681,11 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
const mesVacio = !String(f[9]||"").trim() || ["-","—"].includes(String(f[9]).trim());
const totVacio = !String(f[12]||"").trim() || ["-","—"].includes(String(f[12]).trim());
const programa = f[14] || "-"; // programa del alumno (extra del backend)
const retira = porRetirar.has(String(f[0])); // alumno POR RETIRAR
return (
<tr key={i} className={deuda?"row-deuda":""}>
<tr key={i} className={deuda?"row-deuda":""}
style={retira ? { textDecoration:"line-through", textDecorationColor:"#dc2626",
textDecorationThickness:"2px" } : undefined}>
{f.slice(0, 14).map((v, j) => {
// Columna 0: mostrar PROGRAMA en vez de la matrícula
let val = (j===0) ? programa : v;
@@ -638,7 +693,6 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
if ((j===7||j===8||j===9) && mesVacio) val = "—";
if ((j===10||j===11||j===12) && totVacio) val = "—";
const isPct = [6,9,12].includes(j);
const isMonto = String(val).includes("S/");
let cart = "";
if ([4,5,6].includes(j)) cart = "cart-ant";
else if ([7,8,9].includes(j)) cart = "cart-mes";
@@ -648,7 +702,7 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
return (
<td key={j} className={clss}
title={esTexto ? String(val) : undefined}
style={{textAlign:esTexto?"left":(isMonto?"right":"center"),
style={{textAlign:esTexto?"left":"center",
whiteSpace:"nowrap",
overflow:esTexto?"hidden":undefined,
textOverflow:esTexto?"ellipsis":undefined,
@@ -658,12 +712,22 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
</td>
);
})}
<td style={{textAlign:"center"}}>
<button
onClick={()=>setInfoAlumno({ matricula:f[0], alumno:f[1], programa:programa })}
style={{padding:"3px 10px",fontSize:11,fontWeight:700,cursor:"pointer",
border:"1px solid #cbd5e1",borderRadius:6,background:"#f8fafc",color:"#334155"}}
title="Ver/editar estado y observaciones">
INFO
</button>
</td>
</tr>
);
})}
{(() => {
// TOTAL GENERAL del modal — solo alumnos visibles (respeta la sede filtrada)
const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL");
// TOTAL GENERAL del modal — solo alumnos visibles (respeta sede y filtro POR RETIRAR)
const alumnos = filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL")
.filter((f)=> !soloRetirar || porRetirar.has(String(f[0])));
if (alumnos.length===0) return null;
const sum = (j)=>alumnos.reduce((s,f)=>s+toMonto(f[j]),0);
const t = {4:sum(4),5:sum(5),7:sum(7),8:sum(8),10:sum(10),11:sum(11),13:sum(13)};
@@ -684,11 +748,12 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
const val=celda(j);
return (
<td key={j} className={j===0?"col-name":""}
style={{textAlign:j===0?"left":(String(val).includes("S/")?"right":"center"),whiteSpace:"nowrap"}}>
style={{textAlign:j===0?"left":"center",whiteSpace:"nowrap"}}>
{isPct?<MiniBar valStr={val} />:val}
</td>
);
})}
<td></td>{/* columna INFO en la fila TOTAL */}
</tr>
);
})()}
@@ -696,6 +761,136 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
</table>
</div>
</>}
{infoAlumno && (
<ModalInfoAlumno alumno={infoAlumno} onClose={()=>setInfoAlumno(null)}
onGuardado={cargarPorRetirar} />
)}
</Modal>
);
}
// Pop-up de INFO por alumno: cambiar estado (EN CURSO / POR RETIRAR) y observaciones.
// Carga y guarda en Supabase (tabla cobranza_estado_alumno).
function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
const [estado, setEstado] = useState("EN CURSO");
const [nuevaObs, setNuevaObs] = useState("");
const [observaciones, setObservaciones] = useState([]);
const [cargando, setCargando] = useState(true);
const [guardando, setGuardando] = useState(false);
const [guardado, setGuardado] = useState(false);
useEffect(() => {
let activo = true;
api.alumnoEstado(alumno.matricula)
.then((r)=>{ if(activo){ setEstado(r.estado||"EN CURSO"); setObservaciones(r.observaciones||[]); } })
.catch(()=>{})
.finally(()=>{ if(activo) setCargando(false); });
return ()=>{ activo=false; };
}, [alumno.matricula]);
const agregarObs = () => {
const t = nuevaObs.trim();
if (!t) return;
const fecha = new Date().toLocaleString("es-PE", { dateStyle:"short", timeStyle:"short" });
setObservaciones((prev)=>[...prev, { texto:t, fecha }]);
setNuevaObs("");
setGuardado(false);
};
const guardar = async () => {
setGuardando(true); setGuardado(false);
try {
await api.alumnoEstadoGuardar(alumno.matricula, estado, observaciones);
setGuardado(true);
if (onGuardado) onGuardado();
} catch {
alert("No se pudo guardar. Intenta de nuevo.");
} finally {
setGuardando(false);
}
};
return (
<Modal title={` Información del alumno`} onClose={onClose} width={560}>
<div style={{marginBottom:16}}>
<div style={{fontSize:15,fontWeight:800,color:"#0f172a"}}>{alumno.alumno}</div>
<div style={{fontSize:12,color:"#64748b",marginTop:2}}>
Matrícula: {alumno.matricula} · {alumno.programa}
</div>
</div>
<div style={{marginBottom:18}}>
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
Estado del alumno
</label>
<select value={estado} onChange={(e)=>{setEstado(e.target.value); setGuardado(false);}}
style={{width:"100%",padding:"8px 10px",fontSize:14,borderRadius:8,
border:"1px solid #cbd5e1",background:"#fff",
color: estado==="POR RETIRAR" ? "#b91c1c" : "#047857", fontWeight:700}}>
<option value="EN CURSO">EN CURSO</option>
<option value="POR RETIRAR">POR RETIRAR</option>
</select>
</div>
<div style={{marginBottom:14}}>
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
Observaciones
</label>
<div style={{display:"flex",gap:8}}>
<input value={nuevaObs} onChange={(e)=>setNuevaObs(e.target.value)}
onKeyDown={(e)=>{ if(e.key==="Enter") agregarObs(); }}
placeholder="Escribe una observación…"
style={{flex:1,padding:"8px 10px",fontSize:13,borderRadius:8,border:"1px solid #cbd5e1"}} />
<button onClick={agregarObs}
style={{padding:"8px 14px",fontSize:13,fontWeight:700,cursor:"pointer",
border:"none",borderRadius:8,background:"#1e40af",color:"#fff"}}>
Agregar
</button>
</div>
<div style={{marginTop:10,display:"flex",flexDirection:"column",gap:6,maxHeight:180,overflowY:"auto"}}>
{observaciones.length===0 ? (
<div style={{fontSize:12,color:"#94a3b8",padding:"6px 2px"}}>Sin observaciones.</div>
) : observaciones.map((o,i)=>(
<div key={i} style={{background:"#f8fafc",border:"1px solid #eef2f7",borderRadius:8,
padding:"8px 10px",display:"flex",alignItems:"flex-start",gap:8}}>
<div style={{flex:1}}>
<div style={{fontSize:13,color:"#334155"}}>{o.texto}</div>
<div style={{fontSize:10,color:"#94a3b8",marginTop:2}}>{o.fecha}</div>
</div>
<button onClick={()=>{ setObservaciones(prev=>prev.filter((_,k)=>k!==i)); setGuardado(false); }}
title="Eliminar observación (se aplica al guardar)"
style={{border:"none",background:"transparent",color:"#dc2626",cursor:"pointer",
fontSize:16,fontWeight:800,lineHeight:1,padding:"0 2px"}}>
×
</button>
</div>
))}
</div>
</div>
<div style={{display:"flex",justifyContent:"flex-end",alignItems:"center",gap:10,marginTop:8,
borderTop:"1px solid #f1f5f9",paddingTop:12}}>
<button onClick={onClose}
style={{padding:"8px 16px",fontSize:13,fontWeight:700,cursor:"pointer",
border:"1px solid #cbd5e1",borderRadius:8,background:"#fff",color:"#334155"}}>
Cerrar
</button>
<button onClick={guardar} disabled={guardando||cargando}
style={{padding:"8px 18px",fontSize:13,fontWeight:700,
cursor:(guardando||cargando)?"not-allowed":"pointer",
border:"none",borderRadius:8,background:"#1e40af",color:"#fff",
opacity:(guardando||cargando)?.7:1}}>
{guardando ? "Guardando…" : "Guardar"}
</button>
</div>
{guardado && (
<div style={{marginTop:12,display:"flex",alignItems:"center",gap:8,
background:"#ecfdf5",border:"1px solid #a7f3d0",color:"#047857",
borderRadius:8,padding:"10px 14px",fontSize:13,fontWeight:700}}>
<span style={{fontSize:16}}></span> Guardado correctamente.
</div>
)}
</Modal>
);
}