diff --git a/frontend/src/pages/Cobranza.jsx b/frontend/src/pages/Cobranza.jsx
index 05cedc2..62ee409 100644
--- a/frontend/src/pages/Cobranza.jsx
+++ b/frontend/src/pages/Cobranza.jsx
@@ -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() {
setAno(+v)} />
({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
- {setAgrupacion(v);setSede("TODOS");setFrec("TODOS");}} />
-
+ {setAgrupacion(v);setSede("TODOS");setFrec("TODOS");setSedeFiltro("TODOS");setProgFiltro("TODOS");}} />
+
{esPrograma && }
{esPrograma && }
+ {(agrupacion==="SEDE"||agrupacion==="ASESOR") && {setSedeFiltro(v);setExpandidas(new Set());}} />}
+ {(agrupacion==="SEDE"||agrupacion==="ASESOR") && {setProgFiltro(v);setExpandidas(new Set());}} />}
+
+ setCalAbierto((v)=>!v)}
+ title={vencHasta!=="TODOS" ? `Hasta ${vencHasta}` : "Filtrar por F. Vencimiento"}>
+ 📅{vencHasta!=="TODOS" ? ` ${vencHasta}` : ""}
+
+ {calAbierto && { setVencHasta(f); setCalAbierto(false); }}
+ onClose={()=>setCalAbierto(false)} />}
+
setBuscadorAbierto(true)}>🔍 Buscar
{exportando ? "Exportando..." : "⬇️ Exportar"}
@@ -278,91 +431,147 @@ export default function Cobranza() {
- {loading ? :
+ {loading && !datos.length ? :
error ? :
- <>
-
+
+
-
+
- {headers.map((h, i)=>{h} )}
+ {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 {h} ;
+ })}
- {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 (
+
+ {puedeExpandir && (
+ 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 ? "−" : "+"}
+
+ )}
+ {isPct ? (val==="—" ? — : ) : fmtMoneda(val)}
+
+ );
+ });
+ };
+
return (
-
- {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 (
-
- {isPct ? (val==="—" ? — : ) : fmtMoneda(val)}
-
- );
- })}
+
+
+ {renderCeldas(f)}
setModal({ grupo: f[0] })}>👁️ Ver
+ onClick={() => setModal({ grupo: fRaw[0] })}>👁️ Ver
+ {abiertaSede && programasDeSede(fRaw[0]).map((fp, pi) => (
+
+ {renderCeldas(fp, {indent:true, bg:"#f8fafc"})}
+
+ setModal({ grupo: fp[0], agrupacion: "PROGRAMA" })}>👁️ Ver
+
+
+ ))}
+
);
})}
{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 (
{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
+ style={{textAlign: ci===0?"left":"center",whiteSpace:"nowrap",...st}}>
{isPct ? : val}
);
})}
- —
+ —
);
})()}
- >}
+
}
{modal && (
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 (
+
+ {/* Encabezado (fijo, sin navegación) */}
+
+ {MESN[mIdx]} {ano}
+
+ {/* Días de la semana */}
+
+ {/* Celdas */}
+
+ {celdas.map((d,i)=>(
+
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||""}
+
+ ))}
+
+ {/* Pie: limpiar filtro */}
+ {sel && sel!=="TODOS" && (
+
+ onSelect("TODOS")}
+ style={{border:"none",background:"#fef2f2",color:"#dc2626",fontSize:12,fontWeight:700,
+ padding:"6px 14px",borderRadius:8,cursor:"pointer"}}>Quitar filtro de fecha
+
+ )}
+
+ );
+}
+
+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 (
{
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 }) {
{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 })
{/* Etiquetas de conteo de alumnos */}
+ {/* Grupo 1: conteo general */}
@@ -738,7 +1027,11 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
border:"1px solid #fde68a"}}>
⏳ Por pagar: {conteo.deben}
+
+ {/* Divisor */}
+
+ {/* Grupo 2: POR RETIRAR */}
@@ -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})}
+
+ {/* Checkbox alineado a la derecha */}
setSoloRetirar(e.target.checked)} style={{cursor:"pointer"}} />
+ onChange={(e)=>setSoloRetirar(e.target.checked)}
+ style={{cursor:"pointer"}} />
Ver solo por retirar
@@ -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 (
{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 (
);
})}
+ {/* Botón INFO por alumno (estado + observaciones) */}
setInfoAlumno({ matricula:f[0], alumno:f[1], programa:programa })}
@@ -825,6 +1125,7 @@ function ModalEstadoCuenta({ grupo, ano, mes, sectorista, agrupacion, onClose })
{(() => {
+ // 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 {
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 (
+ {/* Datos del alumno */}
{alumno.alumno}
@@ -927,6 +1231,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
+ {/* Estado */}
Estado del alumno
@@ -940,6 +1245,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
+ {/* Observaciones */}
Observaciones
@@ -955,6 +1261,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
Agregar
+ {/* Lista de observaciones agregadas (local) */}
{observaciones.length===0 ? (
Sin observaciones.
@@ -976,6 +1283,7 @@ function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
+ {/* Acciones */}
+ {/* Mensaje de confirmación (se mantiene visible) */}
{guardado && (