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