Actualizar frontend/src/pages/Cobranza.jsx
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// src/pages/Cobranza.jsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useMemo, useRef, Fragment } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, Filters, Select, colorSemaforo } from "../components/UI";
|
||||
import Modal from "../components/Modal";
|
||||
@@ -8,9 +8,10 @@ import { setVistaActual, onRefrescar } from "../lib/vistaActual";
|
||||
|
||||
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||||
const ANOS = [2024, 2025, 2026];
|
||||
const AGRUPACIONES = [{value:"SEDE",label:"Por Sede"},{value:"PROGRAMA",label:"Por Programa"},{value:"ASESOR",label:"Por Asesor"}];
|
||||
const AGRUPACIONES = [{value:"SEDE",label:"Por Sede"},{value:"ASESOR",label:"Por Asesor"}];
|
||||
const SEDES = ["TODOS","LIMA","AREQUIPA","PIURA","TRUJILLO"];
|
||||
const FRECS = ["TODOS","DOM","SAB","NOC","MAN","VIR","TAR"];
|
||||
const PROGRAMAS_FILTRO = ["TODOS","TEAC","TERC","SEMINARIOS","OTROS"];
|
||||
|
||||
// Colores por cartera — diferenciados y legibles (sin azul, reservado para TOTAL)
|
||||
// ANTERIOR = ámbar suave | MES = verde menta | TOTAL = lila/violeta suave
|
||||
@@ -51,6 +52,9 @@ export default function Cobranza() {
|
||||
const [sectorista, setSectorista] = useState("TODOS");
|
||||
const [sede, setSede] = useState("TODOS");
|
||||
const [frec, setFrec] = useState("TODOS");
|
||||
// Filtros extra para la vista "Por Sede" (usan JSON REPLACE_META)
|
||||
const [sedeFiltro, setSedeFiltro] = useState("TODOS");
|
||||
const [progFiltro, setProgFiltro] = useState("TODOS");
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [sectoristas, setSectoristas] = useState(["TODOS"]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -60,6 +64,12 @@ export default function Cobranza() {
|
||||
const [exportando, setExportando] = useState(false);
|
||||
const [alumnos, setAlumnos] = useState([]); // todos los alumnos (1 sola consulta)
|
||||
const [buscadorAbierto, setBuscadorAbierto] = useState(false);
|
||||
const [calAbierto, setCalAbierto] = useState(false);
|
||||
const [vencHasta, setVencHasta] = useState("TODOS"); // filtro F. Vencimiento (dd/mm/yyyy)
|
||||
// Expansión sede -> programas (solo en agrupación SEDE)
|
||||
const [filasPrograma, setFilasPrograma] = useState([]); // vista "Por Programa" (para expandir)
|
||||
const [sedeMapPrograma, setSedeMapPrograma] = useState({}); // programa -> sede
|
||||
const [expandidas, setExpandidas] = useState(new Set()); // sedes abiertas
|
||||
|
||||
const esPrograma = agrupacion === "PROGRAMA";
|
||||
// índices según agrupación
|
||||
@@ -69,10 +79,10 @@ export default function Cobranza() {
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setVistaActual(`cobranza:${ano}|${mes}|${sectorista}|${agrupacion}`);
|
||||
setVistaActual(`cobranza:${ano}|${mes}|${sectorista}|${agrupacion}|${sedeFiltro}|${progFiltro}|${vencHasta}`);
|
||||
const cargar = () => {
|
||||
setLoading(true); setError(null);
|
||||
api.cobranza(ano, mes, sectorista, agrupacion)
|
||||
api.cobranza(ano, mes, sectorista, agrupacion, sedeFiltro, progFiltro, vencHasta)
|
||||
.then((res) => {
|
||||
if (!activo) return;
|
||||
setFilas(res.filas || []);
|
||||
@@ -84,7 +94,7 @@ export default function Cobranza() {
|
||||
cargar();
|
||||
const off = onRefrescar(cargar);
|
||||
return () => { activo = false; off(); };
|
||||
}, [ano, mes, agrupacion, sectorista]);
|
||||
}, [ano, mes, agrupacion, sectorista, sedeFiltro, progFiltro, vencHasta]);
|
||||
|
||||
// Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada
|
||||
useEffect(() => {
|
||||
@@ -95,6 +105,19 @@ export default function Cobranza() {
|
||||
return () => { activo = false; };
|
||||
}, [ano, mes, sectorista]);
|
||||
|
||||
// Clasificación (sede/filtro) de los programas de los alumnos, para filtrar
|
||||
// las tarjetas "Saldo por Retirar" y el buscador por Sede/Programa (REPLACE_META).
|
||||
const [progClasif, setProgClasif] = useState({});
|
||||
useEffect(() => {
|
||||
const nombres = [...new Set(alumnos.map((a)=>String(a.programa)).filter(Boolean))];
|
||||
if (!nombres.length) { setProgClasif({}); return; }
|
||||
let activo = true;
|
||||
api.clasificarFull(nombres)
|
||||
.then((r)=>{ if(activo) setProgClasif(r.mapa || {}); })
|
||||
.catch(()=>{ if(activo) setProgClasif({}); });
|
||||
return () => { activo = false; };
|
||||
}, [alumnos]);
|
||||
|
||||
// Cargar matrículas POR RETIRAR (para el "Saldo por Retirar" de las tarjetas)
|
||||
const [porRetirar, setPorRetirar] = useState(new Set());
|
||||
useEffect(() => {
|
||||
@@ -103,6 +126,28 @@ export default function Cobranza() {
|
||||
.catch(() => {});
|
||||
}, [ano, mes]);
|
||||
|
||||
// En agrupación SEDE: cargar la vista "Por Programa" (para poder expandir cada sede
|
||||
// y ver sus programas con FREC/N° CUOTA/VENCIMIENTO). Se filtra por sede al expandir.
|
||||
useEffect(() => {
|
||||
if (agrupacion !== "SEDE") { setFilasPrograma([]); setExpandidas(new Set()); return; }
|
||||
let activo = true;
|
||||
api.cobranza(ano, mes, sectorista, "PROGRAMA", sedeFiltro, progFiltro, vencHasta)
|
||||
.then((res) => {
|
||||
if (!activo) return;
|
||||
const fp = (res.filas || []).filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL");
|
||||
setFilasPrograma(fp);
|
||||
// clasificar cada programa a su SEDE y FILTRO (TEAC/TERC/...) via REPLACE_META
|
||||
const nombres = fp.map((f)=>String(f[0]));
|
||||
if (nombres.length) {
|
||||
api.clasificarFull(nombres)
|
||||
.then((r)=>{ if(activo) setSedeMapPrograma(r.mapa || {}); })
|
||||
.catch(()=>{ if(activo) setSedeMapPrograma({}); });
|
||||
}
|
||||
})
|
||||
.catch(() => { if (activo) setFilasPrograma([]); });
|
||||
return () => { activo = false; };
|
||||
}, [agrupacion, ano, mes, sectorista, sedeFiltro, progFiltro, vencHasta]);
|
||||
|
||||
// Cargar clasificación de sede (sede.json) para los programas visibles
|
||||
useEffect(() => {
|
||||
if (!esPrograma) return;
|
||||
@@ -118,7 +163,7 @@ export default function Cobranza() {
|
||||
}, [filas, esPrograma]);
|
||||
|
||||
// Filtro sede/frecuencia (solo PROGRAMA) en cliente — usa sede.json
|
||||
const datos = useMemo(() => {
|
||||
const datosCalc = useMemo(() => {
|
||||
let arr = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL");
|
||||
if (esPrograma) {
|
||||
if (sede !== "TODOS") {
|
||||
@@ -128,10 +173,20 @@ export default function Cobranza() {
|
||||
});
|
||||
}
|
||||
if (frec !== "TODOS") arr = arr.filter((f) => String(f[1]).toUpperCase() === frec);
|
||||
return arr;
|
||||
}
|
||||
|
||||
// Vista POR SEDE / POR ASESOR: el backend ya devuelve las filas filtradas por
|
||||
// Sede y Programa (JSON REPLACE_META), así que se muestran tal cual.
|
||||
return arr;
|
||||
}, [filas, esPrograma, sede, frec, sedeMap]);
|
||||
|
||||
// Mientras carga, mostrar la ÚLTIMA vista renderizada (congelada) hasta que llegue
|
||||
// la respuesta nueva. Así al filtrar solo se opaca lo anterior, sin recalcular a 0.
|
||||
const datosViewRef = useRef([]);
|
||||
if (!loading) datosViewRef.current = datosCalc;
|
||||
const datos = loading ? datosViewRef.current : datosCalc;
|
||||
|
||||
// KPIs: 3 tarjetas (recalculadas con datos filtrados)
|
||||
const kpis = useMemo(() => {
|
||||
let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0;
|
||||
@@ -143,21 +198,81 @@ export default function Cobranza() {
|
||||
return { cAnt,obAnt,cMes,obMes,cTot,obTot, saldo: cTot-obTot };
|
||||
}, [datos, IDX]);
|
||||
|
||||
const headers = esPrograma
|
||||
? ["PROGRAMA","FREC.","N° CUOTA","VENCIMIENTO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."]
|
||||
: ["GRUPO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."];
|
||||
const esAsesor = agrupacion === "ASESOR";
|
||||
// Las 3 columnas extra (FREC/N°CUOTA/VENC) SOLO se muestran en SEDE cuando hay
|
||||
// al menos una sede expandida, o siempre en PROGRAMA. En ASESOR nunca.
|
||||
const conColsExtra = esPrograma || (!esAsesor && expandidas.size > 0);
|
||||
const headers = !conColsExtra
|
||||
? ["GRUPO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."]
|
||||
: [(esPrograma?"PROGRAMA":"GRUPO"),"FREC.","N° CUOTA","VENCIMIENTO","CTA X COB. MES ANT.","COB. MES ANT.","% ANT","CTA X COB EN CURSO","COB. EN CURSO","% MES","CTA X COB TOTAL","COB. TOTAL","% TOTAL","SALDO","OPC."];
|
||||
|
||||
// Anchos para columnas redimensionables (1ª columna más ancha, resto estándar)
|
||||
const anchosCob = headers.map((h, i) => i === 0 ? (esPrograma ? 280 : 200) : (h === "FREC." || h.startsWith("%") || h === "OPC." ? 80 : 120));
|
||||
const anchosCob = headers.map((h, i) => i === 0 ? (conColsExtra ? 280 : 200) : (h === "FREC." || h.startsWith("%") || h === "OPC." ? 80 : 120));
|
||||
const cols = useColumnasAjustables(anchosCob);
|
||||
|
||||
// Índices de cartera según el layout de columnas de la tabla actual.
|
||||
const LAY = conColsExtra
|
||||
? { saldo:13, ant:[4,5,6], mes:[7,8,9], tot:[10,11,12] }
|
||||
: { saldo:10, ant:[1,2,3], mes:[4,5,6], tot:[7,8,9] };
|
||||
|
||||
function cartClase(ci) {
|
||||
if (IDX.ant.includes(ci)) return "cart-ant";
|
||||
if (IDX.mes.includes(ci)) return "cart-mes";
|
||||
if (IDX.tot.includes(ci)) return "cart-tot";
|
||||
if (LAY.ant.includes(ci)) return "cart-ant";
|
||||
if (LAY.mes.includes(ci)) return "cart-mes";
|
||||
if (LAY.tot.includes(ci)) return "cart-tot";
|
||||
return "";
|
||||
}
|
||||
|
||||
// Columnas fijas (sticky) de la tabla principal:
|
||||
// - sin expandir: solo GRUPO (1)
|
||||
// - expandido: GRUPO, FREC., N° CUOTA, VENCIMIENTO (4)
|
||||
const FIJAS_MAIN = conColsExtra ? 4 : 1;
|
||||
const stickyMain = (ci, { esHeader = false, bg = "#fff" } = {}) => {
|
||||
if (ci >= FIJAS_MAIN) return {};
|
||||
let left = 0;
|
||||
for (let k = 0; k < ci; k++) left += (cols.anchos[k] || 0);
|
||||
const ultima = ci === FIJAS_MAIN - 1;
|
||||
const lineaColor = esHeader ? "#475569" : "#e2e8f0";
|
||||
return {
|
||||
position: "sticky", left, zIndex: esHeader ? 20 : 10, backgroundColor: bg,
|
||||
borderRight: "none",
|
||||
boxShadow: `inset -1px 0 0 0 ${lineaColor}` + (ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : ""),
|
||||
};
|
||||
};
|
||||
|
||||
// Normaliza una fila de SEDE al layout activo. Si conColsExtra (hay expansión),
|
||||
// inserta FREC/N°CUOTA/VENC vacías tras el grupo. Si no, la deja tal cual.
|
||||
const filaLayout = (f) => {
|
||||
if (esPrograma) return f; // ya viene con las 15 columnas
|
||||
if (esAsesor || !conColsExtra) return f; // layout simple (12 cols)
|
||||
// SEDE con expansión: [grupo, "","","", cta_ant,cob_ant,%ant, cta_mes,... saldo]
|
||||
return [f[0], "", "", "", f[1],f[2],f[3], f[4],f[5],f[6], f[7],f[8],f[9], f[10]];
|
||||
};
|
||||
|
||||
// Expandir/contraer una sede (agrupación SEDE)
|
||||
const toggleSede = (s) => {
|
||||
const key = String(s).toUpperCase();
|
||||
setExpandidas((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.has(key) ? n.delete(key) : n.add(key);
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
// sede/filtro de un programa (desde REPLACE_META). sedeMapPrograma[nombre]={sede,filtro}
|
||||
const infoProg = (nombre) => sedeMapPrograma[String(nombre)] || {sede:"LIMA", filtro:"OTROS"};
|
||||
|
||||
// Programas (filas de la vista Por Programa) que pertenecen a una sede,
|
||||
// aplicando el filtro Programa (TEAC/TERC/...) si está activo.
|
||||
const programasDeSede = (s) => {
|
||||
const key = String(s).toUpperCase();
|
||||
return filasPrograma.filter((fp) => {
|
||||
const ip = infoProg(fp[0]);
|
||||
if (String(ip.sede).toUpperCase() !== key) return false;
|
||||
if (progFiltro !== "TODOS" && String(ip.filtro).toUpperCase() !== progFiltro) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// Alumnos visibles según filtros activos (mismo dataset que Ver / Estado de Cuenta)
|
||||
const alumnosFiltrados = useMemo(() => {
|
||||
let arr = alumnos;
|
||||
@@ -165,8 +280,30 @@ export default function Cobranza() {
|
||||
if (sede !== "TODOS") arr = arr.filter((a) => String(a.sede).toUpperCase() === sede.toUpperCase());
|
||||
if (frec !== "TODOS") arr = arr.filter((a) => String(a.frecuencia).toUpperCase() === frec);
|
||||
}
|
||||
// Filtros Sede / Programa (REPLACE_META) — aplican en Por Sede y Por Asesor
|
||||
if (sedeFiltro !== "TODOS") {
|
||||
arr = arr.filter((a) => {
|
||||
const s = (progClasif[String(a.programa)]?.sede || a.sede || "LIMA");
|
||||
return String(s).toUpperCase() === sedeFiltro.toUpperCase();
|
||||
});
|
||||
}
|
||||
if (progFiltro !== "TODOS") {
|
||||
arr = arr.filter((a) => String(progClasif[String(a.programa)]?.filtro || "").toUpperCase() === progFiltro);
|
||||
}
|
||||
// Filtro por F. Vencimiento (<=). Cuotas sin fecha se incluyen siempre.
|
||||
if (vencHasta !== "TODOS") {
|
||||
const toTs = (s) => {
|
||||
const [dd,mm,yy] = String(s).split("/").map(Number);
|
||||
return (dd&&mm&&yy) ? new Date(yy,mm-1,dd).getTime() : null;
|
||||
};
|
||||
const corte = toTs(vencHasta);
|
||||
if (corte != null) arr = arr.filter((a) => {
|
||||
const t = toTs(a.fch_venc);
|
||||
return t == null || t <= corte; // sin fecha => siempre
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}, [alumnos, esPrograma, sede, frec]);
|
||||
}, [alumnos, esPrograma, sede, frec, sedeFiltro, progFiltro, progClasif, vencHasta]);
|
||||
|
||||
function grupoDe(a) {
|
||||
// El programa detallado del alumno, en TODOS los filtros (Sede, Asesor, Programa).
|
||||
@@ -191,6 +328,8 @@ export default function Cobranza() {
|
||||
const base = alumnosFiltrados;
|
||||
if (base.length === 0) { alert("No hay alumnos para exportar con los filtros actuales."); return; }
|
||||
|
||||
// Fecha como TEXTO tal cual (dd/mm/yyyy). No convertir a Date: al escribir
|
||||
// un Date en Excel se corre un día por zona horaria (UTC-5).
|
||||
const toDate = (v) => {
|
||||
if (!v || v === "-") return "";
|
||||
return String(v).trim();
|
||||
@@ -231,7 +370,7 @@ export default function Cobranza() {
|
||||
const st = cellStyle(c);
|
||||
if ([5,6,8,9,11,12,14].includes(c)) ws[ref] = { v:Number(v)||0, t:"n", z:moneyFmt, ...(st?{s:st}:{}) };
|
||||
else if ([7,10,13].includes(c)) ws[ref] = (v===null) ? { v:"-", t:"s", ...(st?{s:st}:{}) } : { v:Number(v), t:"n", z:pctFmt, ...(st?{s:st}:{}) };
|
||||
else ws[ref] = { v: v ?? "", t:"s" };
|
||||
else ws[ref] = { v: v ?? "", t:"s" }; // F. VENC. (col 4) va como texto tal cual
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,11 +405,25 @@ export default function Cobranza() {
|
||||
<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");}} />
|
||||
<Select label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} />
|
||||
<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());}} />}
|
||||
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
|
||||
<div style={{position:"relative"}}>
|
||||
<button className="btn btn-ghost"
|
||||
style={{height:38,padding:"0 12px",
|
||||
...(vencHasta!=="TODOS"?{background:"#eff6ff",color:"#1e40af",fontWeight:700}:{})}}
|
||||
onClick={()=>setCalAbierto((v)=>!v)}
|
||||
title={vencHasta!=="TODOS" ? `Hasta ${vencHasta}` : "Filtrar por F. Vencimiento"}>
|
||||
📅{vencHasta!=="TODOS" ? ` ${vencHasta}` : ""}
|
||||
</button>
|
||||
{calAbierto && <MiniCalendario ano={ano} mes={mes} sel={vencHasta}
|
||||
onSelect={(f)=>{ setVencHasta(f); setCalAbierto(false); }}
|
||||
onClose={()=>setCalAbierto(false)} />}
|
||||
</div>
|
||||
<button className="btn btn-ghost" style={{height:38}} onClick={()=>setBuscadorAbierto(true)}>🔍 Buscar</button>
|
||||
<button className="btn btn-primary" style={{height:38}} disabled={exportando || !alumnosFiltrados.length} onClick={exportarDetalle}>
|
||||
{exportando ? "Exportando..." : "⬇️ Exportar"}
|
||||
@@ -278,91 +431,147 @@ export default function Cobranza() {
|
||||
</div>
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Calculando cobranza..." /> :
|
||||
{loading && !datos.length ? <Loader text="Calculando cobranza..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
<Tarjetas kpis={kpis} retiro={retiroKpi} />
|
||||
<div style={{opacity: loading ? 0.45 : 1, pointerEvents: loading ? "none" : "auto", transition:"opacity .15s"}}>
|
||||
<Tarjetas kpis={kpis} retiro={retiroKpi} loading={loading} />
|
||||
|
||||
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
|
||||
<div className="table-wrap" style={{marginTop:16, overflow:"auto", maxHeight:"60vh"}}>
|
||||
<table className="cob-table" {...cols.tableProps}>
|
||||
<cols.ColGroup />
|
||||
<thead><tr>{headers.map((h, i)=><th key={h} style={{position:"relative"}}>{h}<cols.Resizer index={i} /></th>)}</tr></thead>
|
||||
<thead><tr>{headers.map((h, i)=>{
|
||||
const st = stickyMain(i, {esHeader:true, bg:"#334155"});
|
||||
const fija = i < FIJAS_MAIN;
|
||||
// Header siempre pegado arriba (top:0); columnas fijas además a la izquierda.
|
||||
const stH = fija
|
||||
? {...st, top:0, zIndex:30}
|
||||
: {position:"sticky", top:0, zIndex:22, backgroundColor:"#334155"};
|
||||
return <th key={h} style={{...stH, position: stH.position || "relative"}}>{h}<cols.Resizer index={i} /></th>;
|
||||
})}</tr></thead>
|
||||
<tbody>
|
||||
{datos.map((f, ri) => {
|
||||
// En la tabla principal SIEMPRE son agrupaciones (Sede/Programa/Asesor),
|
||||
// nunca alumnos individuales → NO aplicar rojo por deuda aquí.
|
||||
// Regla "-": si % del grupo está vacío, CTA y COB del grupo → "-"
|
||||
const antVacio = !String(f[IDX.ant[2]]||"").trim() || ["-","—"].includes(String(f[IDX.ant[2]]).trim());
|
||||
const mesVacio = !String(f[IDX.mes[2]]||"").trim() || ["-","—"].includes(String(f[IDX.mes[2]]).trim());
|
||||
const totVacio = !String(f[IDX.tot[2]]||"").trim() || ["-","—"].includes(String(f[IDX.tot[2]]).trim());
|
||||
{datos.map((fRaw, ri) => {
|
||||
const f = filaLayout(fRaw); // layout normalizado (15 o 12 cols)
|
||||
const nCols = headers.length - 1; // sin OPC
|
||||
const sedeKey = String(f[0]).toUpperCase();
|
||||
const abiertaSede = !esPrograma && !esAsesor && expandidas.has(sedeKey);
|
||||
|
||||
const renderCeldas = (fila, {indent=false, bg="#fff"}={}) => {
|
||||
const av = !String(fila[LAY.ant[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.ant[2]]).trim());
|
||||
const mv = !String(fila[LAY.mes[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.mes[2]]).trim());
|
||||
const tv = !String(fila[LAY.tot[2]]||"").trim() || ["-","—"].includes(String(fila[LAY.tot[2]]).trim());
|
||||
return fila.slice(0, nCols).map((v, ci) => {
|
||||
const isPct = LAY.ant[2]===ci || LAY.mes[2]===ci || LAY.tot[2]===ci;
|
||||
let val = v;
|
||||
if ((ci===LAY.ant[0]||ci===LAY.ant[1]||ci===LAY.ant[2]) && av) val = "—";
|
||||
if ((ci===LAY.mes[0]||ci===LAY.mes[1]||ci===LAY.mes[2]) && mv) val = "—";
|
||||
if ((ci===LAY.tot[0]||ci===LAY.tot[1]||ci===LAY.tot[2]) && tv) val = "—";
|
||||
const cartCls = cartClase(ci);
|
||||
const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:"");
|
||||
const puedeExpandir = (!esPrograma && !esAsesor && ci===0 && !indent);
|
||||
const abierta = puedeExpandir && expandidas.has(String(fila[0]).toUpperCase());
|
||||
return (
|
||||
<td key={ci} className={clss.trim()}
|
||||
style={{textAlign: ci===0?"left":"center",
|
||||
whiteSpace: "nowrap",
|
||||
paddingLeft: (ci===0 && indent)?28:undefined,
|
||||
overflow: ci===0?"hidden":undefined,
|
||||
textOverflow: ci===0?"ellipsis":undefined,
|
||||
color: indent?"#475569":undefined,
|
||||
...stickyMain(ci, {bg})}}>
|
||||
{puedeExpandir && (
|
||||
<span onClick={()=>toggleSede(String(fila[0]))}
|
||||
style={{cursor:"pointer",color:"#1e40af",fontWeight:800,
|
||||
marginRight:8,userSelect:"none",fontSize:14,
|
||||
display:"inline-block",width:12,textAlign:"center",
|
||||
transition:"transform .15s ease"}}>
|
||||
{abierta ? "−" : "+"}
|
||||
</span>
|
||||
)}
|
||||
{isPct ? (val==="—" ? <span style={{color:"#cbd5e1"}}>—</span> : <MiniBar valStr={val} />) : fmtMoneda(val)}
|
||||
</td>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<tr key={ri}>
|
||||
{f.slice(0, headers.length-1).map((v, ci) => {
|
||||
const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci;
|
||||
let val = v;
|
||||
if ((ci===IDX.ant[0]||ci===IDX.ant[1]||ci===IDX.ant[2]) && antVacio) val = "—";
|
||||
if ((ci===IDX.mes[0]||ci===IDX.mes[1]||ci===IDX.mes[2]) && mesVacio) val = "—";
|
||||
if ((ci===IDX.tot[0]||ci===IDX.tot[1]||ci===IDX.tot[2]) && totVacio) val = "—";
|
||||
const cartCls = cartClase(ci);
|
||||
const clss = (ci===0?"col-name":"") + (cartCls?` ${cartCls}`:"");
|
||||
return (
|
||||
<td key={ci} className={clss.trim()}
|
||||
style={{textAlign: ci===0?"left":"center",
|
||||
minWidth: ci===0?(esPrograma?300:200):undefined,
|
||||
maxWidth: ci===0?500:undefined,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: ci===0?"hidden":undefined,
|
||||
textOverflow: ci===0?"ellipsis":undefined}}>
|
||||
{isPct ? (val==="—" ? <span style={{color:"#cbd5e1"}}>—</span> : <MiniBar valStr={val} />) : fmtMoneda(val)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<Fragment key={ri}>
|
||||
<tr>
|
||||
{renderCeldas(f)}
|
||||
<td>
|
||||
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
|
||||
onClick={() => setModal({ grupo: f[0] })}>👁️ Ver</button>
|
||||
onClick={() => setModal({ grupo: fRaw[0] })}>👁️ Ver</button>
|
||||
</td>
|
||||
</tr>
|
||||
{abiertaSede && programasDeSede(fRaw[0]).map((fp, pi) => (
|
||||
<tr key={"p"+pi} className="fila-prog" style={{background:"#f8fafc"}}>
|
||||
{renderCeldas(fp, {indent:true, bg:"#f8fafc"})}
|
||||
<td>
|
||||
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:10}}
|
||||
onClick={()=>setModal({ grupo: fp[0], agrupacion: "PROGRAMA" })}>👁️ Ver</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{datos.length > 0 && (() => {
|
||||
// Fila TOTAL GENERAL — suma de los datos visibles (respeta filtros)
|
||||
const t = {};
|
||||
[IDX.ant[0],IDX.ant[1],IDX.mes[0],IDX.mes[1],IDX.tot[0],IDX.tot[1],IDX.saldo]
|
||||
.forEach((ci)=>{ t[ci] = datos.reduce((s,f)=>s+toMonto(f[ci]),0); });
|
||||
// Fila TOTAL GENERAL — suma de los datos CRUDOS (IDX), render en layout LAY.
|
||||
const suma = (idx)=> datos.reduce((s,f)=>s+toMonto(f[idx]),0);
|
||||
const T = { cAnt:suma(IDX.ant[0]), oAnt:suma(IDX.ant[1]),
|
||||
cMes:suma(IDX.mes[0]), oMes:suma(IDX.mes[1]),
|
||||
cTot:suma(IDX.tot[0]), oTot:suma(IDX.tot[1]),
|
||||
saldo:suma(IDX.saldo) };
|
||||
const ratio = (cob,cta)=> cta>0 ? `${Math.round(cob/cta*100)}%` : "";
|
||||
const money = (n)=> "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0});
|
||||
// valor por columna del layout LAY
|
||||
const cell = (ci) => {
|
||||
if (ci===0) return "TOTAL GENERAL";
|
||||
if (ci===IDX.ant[2]) return ratio(t[IDX.ant[1]],t[IDX.ant[0]]);
|
||||
if (ci===IDX.mes[2]) return ratio(t[IDX.mes[1]],t[IDX.mes[0]]);
|
||||
if (ci===IDX.tot[2]) return ratio(t[IDX.tot[1]],t[IDX.tot[0]]);
|
||||
if (t[ci]!==undefined) return "S/ " + t[ci].toLocaleString("es-PE",{maximumFractionDigits:0});
|
||||
if (ci===LAY.ant[0]) return money(T.cAnt);
|
||||
if (ci===LAY.ant[1]) return money(T.oAnt);
|
||||
if (ci===LAY.ant[2]) return ratio(T.oAnt,T.cAnt);
|
||||
if (ci===LAY.mes[0]) return money(T.cMes);
|
||||
if (ci===LAY.mes[1]) return money(T.oMes);
|
||||
if (ci===LAY.mes[2]) return ratio(T.oMes,T.cMes);
|
||||
if (ci===LAY.tot[0]) return money(T.cTot);
|
||||
if (ci===LAY.tot[1]) return money(T.oTot);
|
||||
if (ci===LAY.tot[2]) return ratio(T.oTot,T.cTot);
|
||||
if (ci===LAY.saldo) return money(T.saldo);
|
||||
return "";
|
||||
};
|
||||
return (
|
||||
<tr className="total-row">
|
||||
{Array.from({length: headers.length-1}).map((_,ci)=>{
|
||||
const isPct = IDX.ant[2]===ci || IDX.mes[2]===ci || IDX.tot[2]===ci;
|
||||
const isPct = LAY.ant[2]===ci || LAY.mes[2]===ci || LAY.tot[2]===ci;
|
||||
const val = cell(ci);
|
||||
const fija = ci < FIJAS_MAIN;
|
||||
let left = 0; for (let k=0;k<ci;k++) left += (cols.anchos[k]||0);
|
||||
// Fila TOTAL pegada abajo (bottom:0); columnas fijas además a la izquierda.
|
||||
const st = fija
|
||||
? { position:"sticky", bottom:0, left, zIndex:15, backgroundColor:"#eff6ff",
|
||||
boxShadow:`inset -1px 0 0 0 #cbd5e1${ci===FIJAS_MAIN-1?", 3px 0 5px -2px rgba(0,0,0,0.18)":""}, inset 0 1px 0 0 #cbd5e1` }
|
||||
: { position:"sticky", bottom:0, zIndex:9, backgroundColor:"#eff6ff",
|
||||
boxShadow:"inset 0 1px 0 0 #cbd5e1" };
|
||||
return (
|
||||
<td key={ci} className={ci===0?"col-name":""}
|
||||
style={{textAlign: ci===0?"left":"center",
|
||||
whiteSpace:"nowrap"}}>
|
||||
style={{textAlign: ci===0?"left":"center",whiteSpace:"nowrap",...st}}>
|
||||
{isPct ? <MiniBar valStr={val} /> : val}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td>—</td>
|
||||
<td style={{position:"sticky",bottom:0,zIndex:9,backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}>—</td>
|
||||
</tr>
|
||||
);
|
||||
})()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
</div>}
|
||||
|
||||
{modal && (
|
||||
<ModalEstadoCuenta grupo={modal.grupo} ano={ano} mes={mes}
|
||||
sectorista={sectorista} agrupacion={agrupacion} onClose={() => setModal(null)} />
|
||||
sectorista={sectorista} agrupacion={modal.agrupacion || agrupacion}
|
||||
progFiltro={progFiltro} sedeFiltro={sedeFiltro} vencHasta={vencHasta}
|
||||
onClose={() => setModal(null)} />
|
||||
)}
|
||||
|
||||
{buscadorAbierto && (
|
||||
@@ -377,9 +586,79 @@ export default function Cobranza() {
|
||||
);
|
||||
}
|
||||
|
||||
function Tarjetas({ kpis, retiro }) {
|
||||
// Mini-calendario. Muestra el mes/año del filtro, fijo. Al elegir un día filtra
|
||||
// por F. Vencimiento (<= ese día). Para ver todo, elegir el último día del mes.
|
||||
function MiniCalendario({ ano, mes, sel, onSelect, onClose }) {
|
||||
const hoy = new Date();
|
||||
const mIdx = (mes - 1); // el filtro usa mes 1..12; Date usa 0..11
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
|
||||
document.addEventListener("mousedown", fuera);
|
||||
return () => document.removeEventListener("mousedown", fuera);
|
||||
}, [onClose]);
|
||||
|
||||
const MESN = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
|
||||
const DIAS = ["Lu","Ma","Mi","Ju","Vi","Sá","Do"];
|
||||
const primerDia = new Date(ano, mIdx, 1).getDay(); // 0=Dom
|
||||
const offset = (primerDia + 6) % 7; // lunes primero
|
||||
const diasMes = new Date(ano, mIdx + 1, 0).getDate();
|
||||
const celdas = [...Array(offset).fill(null), ...Array.from({length:diasMes},(_,i)=>i+1)];
|
||||
// Día seleccionado (si sel es dd/mm/yyyy de este mes/año)
|
||||
const selDia = (() => {
|
||||
if (!sel || sel === "TODOS") return null;
|
||||
const [dd,mm,yy] = String(sel).split("/").map(Number);
|
||||
return (mm===mes && yy===ano) ? dd : null;
|
||||
})();
|
||||
const fmt = (d) => `${String(d).padStart(2,"0")}/${String(mes).padStart(2,"0")}/${ano}`;
|
||||
const mismaFecha = (d) => d && selDia===d;
|
||||
const esHoy = (d) => d && d===hoy.getDate() && mIdx===hoy.getMonth() && ano===hoy.getFullYear();
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{position:"absolute",top:46,right:0,zIndex:50,width:280,
|
||||
background:"#fff",border:"1px solid #e2e8f0",borderRadius:14,
|
||||
boxShadow:"0 12px 30px -8px rgba(15,23,42,0.25)",padding:14,userSelect:"none"}}>
|
||||
{/* Encabezado (fijo, sin navegación) */}
|
||||
<div style={{textAlign:"center",fontWeight:800,fontSize:14,color:"#0f172a",marginBottom:12}}>
|
||||
{MESN[mIdx]} {ano}
|
||||
</div>
|
||||
{/* Días de la semana */}
|
||||
<div style={{display:"grid",gridTemplateColumns:"repeat(7,1fr)",gap:2,marginBottom:4}}>
|
||||
{DIAS.map((d)=><div key={d} style={{textAlign:"center",fontSize:11,fontWeight:700,color:"#94a3b8",padding:"4px 0"}}>{d}</div>)}
|
||||
</div>
|
||||
{/* Celdas */}
|
||||
<div style={{display:"grid",gridTemplateColumns:"repeat(7,1fr)",gap:2}}>
|
||||
{celdas.map((d,i)=>(
|
||||
<div key={i}
|
||||
onClick={()=>d && onSelect(fmt(d))}
|
||||
style={{textAlign:"center",padding:"7px 0",fontSize:13,borderRadius:8,
|
||||
cursor:d?"pointer":"default",fontWeight:mismaFecha(d)?800:500,
|
||||
color:mismaFecha(d)?"#fff":(esHoy(d)?"#1e40af":"#334155"),
|
||||
background:mismaFecha(d)?"#1e40af":(esHoy(d)?"#eff6ff":"transparent"),
|
||||
border:esHoy(d)&&!mismaFecha(d)?"1px solid #bfdbfe":"1px solid transparent",
|
||||
transition:"background .12s"}}
|
||||
onMouseEnter={(e)=>{ if(d&&!mismaFecha(d)) e.currentTarget.style.background="#f1f5f9"; }}
|
||||
onMouseLeave={(e)=>{ if(d&&!mismaFecha(d)) e.currentTarget.style.background=esHoy(d)?"#eff6ff":"transparent"; }}>
|
||||
{d||""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Pie: limpiar filtro */}
|
||||
{sel && sel!=="TODOS" && (
|
||||
<div style={{marginTop:12,paddingTop:10,borderTop:"1px solid #f1f5f9",textAlign:"center"}}>
|
||||
<button onClick={()=>onSelect("TODOS")}
|
||||
style={{border:"none",background:"#fef2f2",color:"#dc2626",fontSize:12,fontWeight:700,
|
||||
padding:"6px 14px",borderRadius:8,cursor:"pointer"}}>Quitar filtro de fecha</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tarjetas({ kpis, retiro, loading }) {
|
||||
const rt = retiro || { sAnt:0, sMes:0, sTot:0 };
|
||||
const alDia = kpis.saldo <= 0.01;
|
||||
const alDia = !loading && kpis.saldo >= -0.01 && kpis.saldo <= 0.01;
|
||||
if (alDia) {
|
||||
return (
|
||||
<div style={{background:"linear-gradient(135deg,#d1fae5,#a7f3d0)",border:"2px solid #10b981",
|
||||
@@ -481,8 +760,8 @@ function TarjetasResumen({ r, retiro }) {
|
||||
function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
|
||||
const [texto, setTexto] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [porRetirar, setPorRetirar] = useState(new Set());
|
||||
const [infoAlumno, setInfoAlumno] = useState(null);
|
||||
const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR
|
||||
const [infoAlumno, setInfoAlumno] = useState(null); // alumno del pop-up INFO
|
||||
|
||||
const cargarPorRetirar = () => {
|
||||
api.alumnosPorRetirar()
|
||||
@@ -504,9 +783,10 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
|
||||
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"];
|
||||
|
||||
// Anchos iniciales — columnas redimensionables manualmente.
|
||||
const anchosBusc = COLS.map((h,i) => {
|
||||
if (i===0) return 250;
|
||||
if (i===1) return 250;
|
||||
if (i===0) return 250; // PROGRAMA
|
||||
if (i===1) return 250; // ALUMNO
|
||||
if (h==="N° CUOTA" || h==="F. VENC.") return 100;
|
||||
if (h==="INFO") return 70;
|
||||
if (h.startsWith("%")) return 80;
|
||||
@@ -514,6 +794,7 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
|
||||
});
|
||||
const cols = useColumnasAjustables(anchosBusc);
|
||||
|
||||
// Congelar las 4 primeras columnas (PROGRAMA, ALUMNO, N° CUOTA, F. VENC.).
|
||||
const FIJAS = 4;
|
||||
const stickyCol = (j, { esHeader = false, bg = "#fff" } = {}) => {
|
||||
if (j >= FIJAS) return {};
|
||||
@@ -570,7 +851,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 retira = porRetirar.has(String(a.matricula)); // POR RETIRAR
|
||||
const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff";
|
||||
const celdas = [
|
||||
{v:a.programa, cls:"col-name", al:"left", texto:true},
|
||||
@@ -630,21 +911,20 @@ function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose }) {
|
||||
function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, progFiltro = "TODOS", sedeFiltro = "TODOS", vencHasta = "TODOS", 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;
|
||||
setLoading(true);
|
||||
api.cobranzaDetalle(grupo, ano, mes, sectorista, agrupacion)
|
||||
api.cobranzaDetalle(grupo, ano, mes, sectorista, agrupacion, progFiltro, sedeFiltro, vencHasta)
|
||||
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
|
||||
return () => { activo = false; };
|
||||
}, [grupo, ano, mes, sectorista, agrupacion]);
|
||||
}, [grupo, ano, mes, sectorista, agrupacion, progFiltro, sedeFiltro, vencHasta]);
|
||||
|
||||
// Cargar (y recargar) las matrículas marcadas POR RETIRAR para tachar sus filas
|
||||
const cargarPorRetirar = () => {
|
||||
@@ -656,6 +936,9 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
|
||||
|
||||
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"];
|
||||
|
||||
// Alumno seleccionado para el pop-up de INFO (estado + observaciones)
|
||||
const [infoAlumno, setInfoAlumno] = useState(null);
|
||||
|
||||
// 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==="INFO" ? 70 : h.startsWith("%") ? 80 : 110)));
|
||||
const cols = useColumnasAjustables(anchosDet);
|
||||
@@ -668,10 +951,15 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
|
||||
for (let k = 0; k < j; k++) left += (cols.anchos[k] || 0);
|
||||
const ultima = j === FIJAS - 1;
|
||||
const lineaColor = esHeader ? "#475569" : "#e2e8f0";
|
||||
// Usamos box-shadow (no border) para la línea divisoria: el box-shadow SÍ tapa
|
||||
// por completo lo que pasa por detrás al hacer scroll (el border dejaba ver el color).
|
||||
const sombraLinea = `inset -1px 0 0 0 ${lineaColor}`;
|
||||
const sombraCorte = ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : "";
|
||||
return {
|
||||
position: "sticky", left, zIndex: esHeader ? 20 : 10, backgroundColor: bg,
|
||||
position: "sticky", left, zIndex: esHeader ? 20 : 10,
|
||||
backgroundColor: bg,
|
||||
borderRight: "none",
|
||||
boxShadow: `inset -1px 0 0 0 ${lineaColor}` + (ultima ? ", 3px 0 5px -2px rgba(0,0,0,0.18)" : ""),
|
||||
boxShadow: sombraLinea + sombraCorte,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -723,6 +1011,7 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
|
||||
</div>
|
||||
{/* Etiquetas de conteo de alumnos */}
|
||||
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap",alignItems:"center"}}>
|
||||
{/* Grupo 1: conteo general */}
|
||||
<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"}}>
|
||||
@@ -738,7 +1027,11 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
|
||||
border:"1px solid #fde68a"}}>
|
||||
⏳ Por pagar: {conteo.deben}
|
||||
</span>
|
||||
|
||||
{/* Divisor */}
|
||||
<span style={{width:1,height:26,background:"#e2e8f0",margin:"0 4px"}} />
|
||||
|
||||
{/* Grupo 2: POR RETIRAR */}
|
||||
<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"}}>
|
||||
@@ -749,12 +1042,15 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
|
||||
border:"1px solid #fecaca"}}>
|
||||
💸 Saldo: S/ {retiro.saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}
|
||||
</span>
|
||||
|
||||
{/* Checkbox alineado a la derecha */}
|
||||
<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"}} />
|
||||
onChange={(e)=>setSoloRetirar(e.target.checked)}
|
||||
style={{cursor:"pointer"}} />
|
||||
Ver solo por retirar
|
||||
</label>
|
||||
</div>
|
||||
@@ -774,28 +1070,31 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
|
||||
.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)
|
||||
const antVacio = !String(f[6]||"").trim() || ["-","—"].includes(String(f[6]).trim());
|
||||
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] || "-";
|
||||
const retira = porRetirar.has(String(f[0]));
|
||||
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":""}
|
||||
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;
|
||||
if ((j===4||j===5||j===6) && antVacio) val = "—";
|
||||
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";
|
||||
else if ([10,11,12].includes(j)) cart = "cart-tot";
|
||||
const clss = (((j===0||j===1)?"col-name":"") + (cart?` ${cart}`:"")).trim();
|
||||
const esTexto = (j===0||j===1);
|
||||
const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff";
|
||||
const bgFila = (i % 2 === 1) ? "#f8fafc" : "#fff"; // par/impar (i base 0)
|
||||
return (
|
||||
<td key={j} className={clss}
|
||||
title={esTexto ? String(val) : undefined}
|
||||
@@ -810,6 +1109,7 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
{/* Botón INFO por alumno (estado + observaciones) */}
|
||||
<td style={{textAlign:"center"}}>
|
||||
<button
|
||||
onClick={()=>setInfoAlumno({ matricula:f[0], alumno:f[1], programa:programa })}
|
||||
@@ -825,6 +1125,7 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{(() => {
|
||||
// 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;
|
||||
@@ -847,11 +1148,12 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
|
||||
const val=celda(j);
|
||||
const fija = j < FIJAS;
|
||||
let left = 0; for (let k=0;k<j;k++) left += (cols.anchos[k]||0);
|
||||
// Sticky abajo (bottom:0) SIEMPRE; y las 4 primeras tambien a la izquierda.
|
||||
const st = {
|
||||
position:"sticky", bottom:0,
|
||||
zIndex: fija ? 15 : 8,
|
||||
backgroundColor:"#eff6ff",
|
||||
padding:"11px 8px",
|
||||
padding:"11px 8px", // misma altura que las filas normales
|
||||
...(fija ? { left, boxShadow:`inset -1px 0 0 0 #cbd5e1${j===FIJAS-1?", 3px 0 5px -2px rgba(0,0,0,0.18)":""}, inset 0 1px 0 0 #cbd5e1` } : { boxShadow:"inset 0 1px 0 0 #cbd5e1" }),
|
||||
};
|
||||
return (
|
||||
@@ -878,15 +1180,16 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
|
||||
}
|
||||
|
||||
// Pop-up de INFO por alumno: cambiar estado (EN CURSO / POR RETIRAR) y observaciones.
|
||||
// Carga y guarda en Supabase (tabla cobranza_estado_alumno).
|
||||
// Carga y guarda en Supabase (tabla cobranza_alumno_estado).
|
||||
function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
|
||||
const [estado, setEstado] = useState("EN CURSO");
|
||||
const [estado, setEstado] = useState("EN CURSO"); // EN CURSO | POR RETIRAR
|
||||
const [nuevaObs, setNuevaObs] = useState("");
|
||||
const [observaciones, setObservaciones] = useState([]);
|
||||
const [cargando, setCargando] = useState(true);
|
||||
const [guardando, setGuardando] = useState(false);
|
||||
const [guardado, setGuardado] = useState(false);
|
||||
|
||||
// Cargar estado/observaciones actuales del alumno al abrir
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
api.alumnoEstado(alumno.matricula)
|
||||
@@ -910,7 +1213,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
|
||||
try {
|
||||
await api.alumnoEstadoGuardar(alumno.matricula, estado, observaciones);
|
||||
setGuardado(true);
|
||||
if (onGuardado) onGuardado();
|
||||
if (onGuardado) onGuardado(); // refresca el tachado en la tabla
|
||||
} catch {
|
||||
alert("No se pudo guardar. Intenta de nuevo.");
|
||||
} finally {
|
||||
@@ -920,6 +1223,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
|
||||
|
||||
return (
|
||||
<Modal title={`ℹ️ Información del alumno`} onClose={onClose} width={560}>
|
||||
{/* Datos del alumno */}
|
||||
<div style={{marginBottom:16}}>
|
||||
<div style={{fontSize:15,fontWeight:800,color:"#0f172a"}}>{alumno.alumno}</div>
|
||||
<div style={{fontSize:12,color:"#64748b",marginTop:2}}>
|
||||
@@ -927,6 +1231,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estado */}
|
||||
<div style={{marginBottom:18}}>
|
||||
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
|
||||
Estado del alumno
|
||||
@@ -940,6 +1245,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Observaciones */}
|
||||
<div style={{marginBottom:14}}>
|
||||
<label style={{fontSize:12,fontWeight:700,color:"#334155",display:"block",marginBottom:6}}>
|
||||
Observaciones
|
||||
@@ -955,6 +1261,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
|
||||
Agregar
|
||||
</button>
|
||||
</div>
|
||||
{/* Lista de observaciones agregadas (local) */}
|
||||
<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>
|
||||
@@ -976,6 +1283,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Acciones */}
|
||||
<div style={{display:"flex",justifyContent:"flex-end",alignItems:"center",gap:10,marginTop:8,
|
||||
borderTop:"1px solid #f1f5f9",paddingTop:12}}>
|
||||
<button onClick={onClose}
|
||||
@@ -992,6 +1300,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mensaje de confirmación (se mantiene visible) */}
|
||||
{guardado && (
|
||||
<div style={{marginTop:12,display:"flex",alignItems:"center",gap:8,
|
||||
background:"#ecfdf5",border:"1px solid #a7f3d0",color:"#047857",
|
||||
|
||||
Reference in New Issue
Block a user