1314 lines
64 KiB
JavaScript
1314 lines
64 KiB
JavaScript
// src/pages/Cobranza.jsx
|
||
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";
|
||
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||
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:"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
|
||
const BG_ANT = "#fef3c7", BG_MES = "#d1fae5", BG_TOT = "#ede9fe";
|
||
// Versiones más oscuras para hover de cada cartera
|
||
const HV_ANT = "#fde68a", HV_MES = "#a7f3d0", HV_TOT = "#ddd6fe";
|
||
|
||
function toMonto(s){ const n = parseFloat(String(s).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
|
||
function toPct(s){ const n = parseFloat(String(s).replace("%","").trim()); return isNaN(n)?0:n; }
|
||
// Formato moneda SIN decimales (solo visualización)
|
||
function fmtMoneda(s){
|
||
if (s == null) return "";
|
||
if (!String(s).includes("S/")) return s; // no es monto, devolver tal cual
|
||
const n = toMonto(s);
|
||
return "S/ " + n.toLocaleString("es-PE", { maximumFractionDigits: 0 });
|
||
}
|
||
|
||
function MiniBar({ valStr }) {
|
||
if (!valStr || !String(valStr).includes("%")) return <span style={{color:"#cbd5e1"}}>—</span>;
|
||
const pct = toPct(valStr);
|
||
const c = colorSemaforo(pct);
|
||
const w = Math.min(Math.max(pct,0),100);
|
||
return (
|
||
<div style={{display:"flex",alignItems:"center",gap:5}}>
|
||
<span style={{fontSize:10,fontWeight:700,color:c.fg,minWidth:30,textAlign:"right"}}>{pct.toFixed(0)}%</span>
|
||
<div style={{flex:1,background:"#e2e8f0",borderRadius:4,height:5,overflow:"hidden",minWidth:30}}>
|
||
<div style={{width:`${w}%`,background:c.fill,height:"100%"}} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Cobranza() {
|
||
const hoy = new Date();
|
||
const [ano, setAno] = useState(hoy.getFullYear());
|
||
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||
const [agrupacion, setAgrupacion] = useState("SEDE");
|
||
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);
|
||
const [error, setError] = useState(null);
|
||
const [modal, setModal] = useState(null);
|
||
const [sedeMap, setSedeMap] = useState({}); // programa -> sede (sede.json)
|
||
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
|
||
const IDX = esPrograma
|
||
? { 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] };
|
||
|
||
useEffect(() => {
|
||
let activo = true;
|
||
setVistaActual(`cobranza:${ano}|${mes}|${sectorista}|${agrupacion}|${sedeFiltro}|${progFiltro}|${vencHasta}`);
|
||
const cargar = () => {
|
||
setLoading(true); setError(null);
|
||
api.cobranza(ano, mes, sectorista, agrupacion, sedeFiltro, progFiltro, vencHasta)
|
||
.then((res) => {
|
||
if (!activo) return;
|
||
setFilas(res.filas || []);
|
||
setSectoristas(res.sectoristas || ["TODOS"]);
|
||
setLoading(false);
|
||
})
|
||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||
};
|
||
cargar();
|
||
const off = onRefrescar(cargar);
|
||
return () => { activo = false; off(); };
|
||
}, [ano, mes, agrupacion, sectorista, sedeFiltro, progFiltro, vencHasta]);
|
||
|
||
// Cargar TODOS los alumnos una sola vez (para exportar y buscar) — 1 consulta cacheada
|
||
useEffect(() => {
|
||
let activo = true;
|
||
api.cobranzaDetalleTodos(ano, mes, sectorista)
|
||
.then((res) => { if (activo) setAlumnos(res.alumnos || []); })
|
||
.catch(() => { if (activo) setAlumnos([]); });
|
||
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(() => {
|
||
api.alumnosPorRetirar()
|
||
.then((r) => setPorRetirar(new Set((r.matriculas || []).map(String))))
|
||
.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;
|
||
const nombres = filas
|
||
.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL")
|
||
.map((f) => String(f[0]));
|
||
if (nombres.length === 0) return;
|
||
let activo = true;
|
||
api.clasificarSede(nombres)
|
||
.then((res) => { if (activo) setSedeMap(res.mapa || {}); })
|
||
.catch(() => { if (activo) setSedeMap({}); });
|
||
return () => { activo = false; };
|
||
}, [filas, esPrograma]);
|
||
|
||
// Filtro sede/frecuencia (solo PROGRAMA) en cliente — usa sede.json
|
||
const datosCalc = useMemo(() => {
|
||
let arr = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL");
|
||
if (esPrograma) {
|
||
if (sede !== "TODOS") {
|
||
arr = arr.filter((f) => {
|
||
const s = (sedeMap[String(f[0])] || "LIMA").toUpperCase();
|
||
return s === sede.toUpperCase();
|
||
});
|
||
}
|
||
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;
|
||
datos.forEach((f) => {
|
||
cAnt += toMonto(f[IDX.ant[0]]); obAnt += toMonto(f[IDX.ant[1]]);
|
||
cMes += toMonto(f[IDX.mes[0]]); obMes += toMonto(f[IDX.mes[1]]);
|
||
cTot += toMonto(f[IDX.tot[0]]); obTot += toMonto(f[IDX.tot[1]]);
|
||
});
|
||
return { cAnt,obAnt,cMes,obMes,cTot,obTot, saldo: cTot-obTot };
|
||
}, [datos, IDX]);
|
||
|
||
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 ? (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 (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;
|
||
if (esPrograma) {
|
||
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, sedeFiltro, progFiltro, progClasif, vencHasta]);
|
||
|
||
function grupoDe(a) {
|
||
// El programa detallado del alumno, en TODOS los filtros (Sede, Asesor, Programa).
|
||
return a.programa;
|
||
}
|
||
|
||
// Saldo por retirar (ANT/MES/TOTAL) de los alumnos marcados POR RETIRAR (según filtros)
|
||
const retiroKpi = useMemo(() => {
|
||
let sAnt=0, sMes=0;
|
||
alumnosFiltrados.forEach((a) => {
|
||
if (!porRetirar.has(String(a.matricula))) return;
|
||
sAnt += ((Number(a.cta_ant)||0) - (Number(a.cob_ant)||0));
|
||
sMes += ((Number(a.cta_cur)||0) - (Number(a.cob_cur)||0));
|
||
});
|
||
return { sAnt, sMes, sTot: sAnt + sMes };
|
||
}, [alumnosFiltrados, porRetirar]);
|
||
|
||
// Exportar el DETALLE DE ALUMNOS (rápido: usa el dataset ya cargado, sin N consultas)
|
||
async function exportarDetalle() {
|
||
setExportando(true);
|
||
try {
|
||
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();
|
||
};
|
||
const pct = (cob, cta) => cta > 0 ? cob/cta : null;
|
||
|
||
const XLSX = await import("xlsx-js-style");
|
||
const COLS = ["GRUPO","MATRÍCULA","ALUMNO","N° CUOTA","F. VENC.","CTA ANT.","COB ANT.","% ANT","CTA MES","COB MES","% MES","CTA TOT","COB TOT","% TOT","SALDO"];
|
||
|
||
// Colores suaves por cartera (igual que el dashboard): ANT ámbar, MES verde, TOT lila
|
||
const CART = { ant:"FEF3C7", mes:"D1FAE5", tot:"EDE9FE" };
|
||
const cartFill = (c) => {
|
||
if ([5,6,7].includes(c)) return CART.ant;
|
||
if ([8,9,10].includes(c)) return CART.mes;
|
||
if ([11,12,13].includes(c)) return CART.tot;
|
||
return null;
|
||
};
|
||
const headerStyle = { font:{bold:true,color:{rgb:"FFFFFF"},sz:11}, fill:{fgColor:{rgb:"1E40AF"}}, alignment:{horizontal:"center",vertical:"center"} };
|
||
const moneyFmt = '"S/" #,##0';
|
||
const pctFmt = '0%';
|
||
const dateFmt = "dd/mm/yyyy";
|
||
const cellStyle = (c) => { const f = cartFill(c); return f ? { fill:{fgColor:{rgb:f}} } : undefined; };
|
||
|
||
const ws = {};
|
||
COLS.forEach((h,c)=>{ ws[XLSX.utils.encode_cell({r:0,c})] = {v:h,t:"s",s:headerStyle}; });
|
||
|
||
base.forEach((a, ri) => {
|
||
const r = ri + 1;
|
||
const vals = [
|
||
grupoDe(a), a.matricula, a.alumno, a.num_cuota, toDate(a.fch_venc),
|
||
a.cta_ant, a.cob_ant, pct(a.cob_ant,a.cta_ant),
|
||
a.cta_cur, a.cob_cur, pct(a.cob_cur,a.cta_cur),
|
||
a.cta_tot, a.cob_tot, pct(a.cob_tot,a.cta_tot),
|
||
a.saldo,
|
||
];
|
||
vals.forEach((v, c) => {
|
||
const ref = XLSX.utils.encode_cell({r,c});
|
||
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" }; // F. VENC. (col 4) va como texto tal cual
|
||
});
|
||
});
|
||
|
||
// TOTAL GENERAL (suma de montos)
|
||
const rTot = base.length + 1;
|
||
const totalStyle = { font:{bold:true}, fill:{fgColor:{rgb:"EFF6FF"}} };
|
||
const sum = (k) => base.reduce((s,a)=>s+(Number(a[k])||0),0);
|
||
const totVals = { 5:sum("cta_ant"),6:sum("cob_ant"),8:sum("cta_cur"),9:sum("cob_cur"),11:sum("cta_tot"),12:sum("cob_tot"),14:sum("saldo") };
|
||
COLS.forEach((col,c)=>{
|
||
const ref = XLSX.utils.encode_cell({r:rTot,c});
|
||
if (c===0) ws[ref] = {v:"TOTAL GENERAL",t:"s",s:totalStyle};
|
||
else if (totVals[c]!==undefined) ws[ref] = {v:totVals[c],t:"n",z:moneyFmt,s:totalStyle};
|
||
else ws[ref] = {v:"",t:"s",s:totalStyle};
|
||
});
|
||
|
||
ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:rTot,c:COLS.length-1} });
|
||
ws["!cols"] = [{wch:26},{wch:12},{wch:28},{wch:9},{wch:12},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:12},{wch:12},{wch:8},{wch:13}];
|
||
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, "Cobranza");
|
||
const sufijo = agrupacion + (esPrograma && sede!=="TODOS" ? `_${sede}` : "") + (sectorista!=="TODOS" ? `_${sectorista}` : "");
|
||
XLSX.writeFile(wb, `cobranza_${sufijo}_${ano}_${mes}.xlsx`);
|
||
} finally {
|
||
setExportando(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<h1 className="page-title">📋 Cobranza</h1>
|
||
|
||
<Filters>
|
||
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
|
||
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v)=>setMes(+v)} />
|
||
<Select label="Agrupación" value={agrupacion} options={AGRUPACIONES} onChange={(v)=>{setAgrupacion(v);setSede("TODOS");setFrec("TODOS");setSedeFiltro("TODOS");setProgFiltro("TODOS");}} />
|
||
<Select label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} width={250} />
|
||
{esPrograma && <Select label="Sede" value={sede} options={SEDES} onChange={setSede} />}
|
||
{esPrograma && <Select label="Frecuencia" value={frec} options={FRECS} onChange={setFrec} />}
|
||
{(agrupacion==="SEDE"||agrupacion==="ASESOR") && <Select label="Sede" value={sedeFiltro} options={SEDES} onChange={(v)=>{setSedeFiltro(v);setExpandidas(new Set());}} />}
|
||
{(agrupacion==="SEDE"||agrupacion==="ASESOR") && <Select label="Programa" value={progFiltro} options={PROGRAMAS_FILTRO} onChange={(v)=>{setProgFiltro(v);setExpandidas(new Set());}} />}
|
||
<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"}
|
||
</button>
|
||
</div>
|
||
</Filters>
|
||
|
||
{loading && !datos.length ? <Loader text="Calculando cobranza..." /> :
|
||
error ? <ErrorBox msg={error} /> :
|
||
<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, overflow:"auto", maxHeight:"60vh"}}>
|
||
<table className="cob-table" {...cols.tableProps}>
|
||
<cols.ColGroup />
|
||
<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((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 (
|
||
<Fragment key={ri}>
|
||
<tr>
|
||
{renderCeldas(f)}
|
||
<td>
|
||
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
|
||
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 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===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 = 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",...st}}>
|
||
{isPct ? <MiniBar valStr={val} /> : val}
|
||
</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={modal.agrupacion || agrupacion}
|
||
progFiltro={progFiltro} sedeFiltro={sedeFiltro} vencHasta={vencHasta}
|
||
onClose={() => setModal(null)} />
|
||
)}
|
||
|
||
{buscadorAbierto && (
|
||
<ModalBuscador
|
||
alumnos={alumnosFiltrados}
|
||
agrupacion={agrupacion}
|
||
sectorista={sectorista}
|
||
onClose={() => setBuscadorAbierto(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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 = !loading && kpis.saldo >= -0.01 && kpis.saldo <= 0.01;
|
||
if (alDia) {
|
||
return (
|
||
<div style={{background:"linear-gradient(135deg,#d1fae5,#a7f3d0)",border:"2px solid #10b981",
|
||
borderRadius:14,padding:18,textAlign:"center"}}>
|
||
<div style={{fontSize:32}}>✅</div>
|
||
<div style={{fontSize:18,fontWeight:800,color:"#065f46",letterSpacing:1}}>AL DÍA</div>
|
||
<div style={{fontSize:12,color:"#047857"}}>Sin deuda pendiente</div>
|
||
</div>
|
||
);
|
||
}
|
||
const cards = [
|
||
["⚠ MES ANTERIOR","#d97706",kpis.cAnt,kpis.obAnt, rt.sAnt],
|
||
["📅 MES EN CURSO","#059669",kpis.cMes,kpis.obMes, rt.sMes],
|
||
["💰 CARTERA TOTAL","#7c3aed",kpis.cTot,kpis.obTot, rt.sTot],
|
||
];
|
||
return (
|
||
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
|
||
{cards.map(([titulo,color,cta,cob,saldoRetiro]) => {
|
||
const ratio = cta>0 ? (cob/cta*100) : 0;
|
||
const c = colorSemaforo(ratio);
|
||
const saldo = cta - cob;
|
||
return (
|
||
<div key={titulo} style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
|
||
<div style={{background:color,color:"#fff",padding:"10px 14px",fontWeight:700,fontSize:13,textAlign:"center"}}>{titulo}</div>
|
||
<div style={{padding:14}}>
|
||
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
|
||
<Row k="Saldo por Retirar" v={`S/ ${(saldoRetiro||0).toLocaleString("es-PE",{maximumFractionDigits:0})}`} color="#dc2626" />
|
||
{cta>0 && (
|
||
<div style={{marginTop:10}}>
|
||
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
|
||
<span style={{fontSize:10,fontWeight:600,color:"#64748b",textTransform:"uppercase"}}>% de Pago</span>
|
||
<span style={{fontSize:14,fontWeight:700,color:c.fg}}>{ratio.toFixed(0)}%</span>
|
||
</div>
|
||
<div style={{background:"#e2e8f0",borderRadius:6,height:8,overflow:"hidden"}}>
|
||
<div style={{width:`${Math.min(ratio,100)}%`,background:c.fill,height:"100%"}} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Row({ k, v, bold, color }) {
|
||
return (
|
||
<div style={{display:"flex",justifyContent:"space-between",fontSize:11,color:"#64748b",marginBottom:4,
|
||
...(bold?{paddingTop:6,borderTop:"1px solid #f1f5f9"}:{})}}>
|
||
<span>{k}</span>
|
||
<span style={{fontWeight:bold?700:600,color:color||"#0f172a"}}>{v}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TarjetasResumen({ r, retiro }) {
|
||
const rt = retiro || { sAnt:0, sMes:0, sTot:0 };
|
||
const cards = [
|
||
["⚠ MES ANTERIOR","#d97706",r.cAnt,r.obAnt, rt.sAnt],
|
||
["📅 MES EN CURSO","#059669",r.cMes,r.obMes, rt.sMes],
|
||
["💰 CARTERA TOTAL","#7c3aed",r.cTot,r.obTot, rt.sTot],
|
||
];
|
||
return (
|
||
<div style={{display:"grid",gridTemplateColumns:"repeat(3,1fr)",gap:14}}>
|
||
{cards.map(([titulo,color,cta,cob,saldoRetiro]) => {
|
||
const ratio = cta>0 ? (cob/cta*100) : 0;
|
||
const c = colorSemaforo(ratio);
|
||
const saldo = cta - cob;
|
||
return (
|
||
<div key={titulo} style={{background:"#fff",border:"1px solid #e2e8f0",borderRadius:12,overflow:"hidden"}}>
|
||
<div style={{background:color,color:"#fff",padding:"9px 12px",fontWeight:700,fontSize:12,textAlign:"center"}}>{titulo}</div>
|
||
<div style={{padding:12}}>
|
||
<Row k="Cta x Cobrar" v={`S/ ${cta.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||
<Row k="Cobrado" v={`S/ ${cob.toLocaleString("es-PE",{maximumFractionDigits:0})}`} />
|
||
<Row k="Saldo" v={`S/ ${saldo.toLocaleString("es-PE",{maximumFractionDigits:0})}`} bold color={saldo>0?"#dc2626":"#10b981"} />
|
||
<Row k="Saldo por Retirar" v={`S/ ${(saldoRetiro||0).toLocaleString("es-PE",{maximumFractionDigits:0})}`} color="#dc2626" />
|
||
{cta>0 && (
|
||
<div style={{marginTop:8}}>
|
||
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
|
||
<span style={{fontSize:10,fontWeight:600,color:"#64748b",textTransform:"uppercase"}}>% de Pago</span>
|
||
<span style={{fontSize:13,fontWeight:700,color:c.fg}}>{ratio.toFixed(0)}%</span>
|
||
</div>
|
||
<div style={{background:"#e2e8f0",borderRadius:6,height:7,overflow:"hidden"}}>
|
||
<div style={{width:`${Math.min(ratio,100)}%`,background:c.fill,height:"100%"}} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ModalBuscador({ alumnos, agrupacion, sectorista, onClose }) {
|
||
const [texto, setTexto] = useState("");
|
||
const [query, setQuery] = useState("");
|
||
const [porRetirar, setPorRetirar] = useState(new Set()); // matrículas POR RETIRAR
|
||
const [infoAlumno, setInfoAlumno] = useState(null); // alumno del pop-up INFO
|
||
|
||
const cargarPorRetirar = () => {
|
||
api.alumnosPorRetirar()
|
||
.then((r)=>setPorRetirar(new Set((r.matriculas||[]).map(String))))
|
||
.catch(()=>{});
|
||
};
|
||
useEffect(() => { cargarPorRetirar(); }, []);
|
||
|
||
const resultados = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return [];
|
||
return alumnos.filter((a) =>
|
||
String(a.alumno).toLowerCase().includes(q) ||
|
||
String(a.programa).toLowerCase().includes(q) ||
|
||
String(a.matricula).toLowerCase().includes(q)
|
||
);
|
||
}, [alumnos, query]);
|
||
|
||
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; // 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;
|
||
return 110;
|
||
});
|
||
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 {};
|
||
let left = 0;
|
||
for (let k = 0; k < j; k++) left += (cols.anchos[k] || 0);
|
||
const ultima = j === FIJAS - 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)" : ""),
|
||
};
|
||
};
|
||
|
||
const pctStr = (cob, cta) => cta > 0 ? `${Math.round(cob/cta*100)}%` : "";
|
||
|
||
return (
|
||
<Modal title="🔍 Buscar Alumno" onClose={onClose} width={1360}>
|
||
<div style={{display:"flex",gap:8,marginBottom:16}}>
|
||
<input
|
||
autoFocus
|
||
value={texto}
|
||
onChange={(e)=>setTexto(e.target.value)}
|
||
onKeyDown={(e)=>{ if(e.key==="Enter") setQuery(texto); }}
|
||
placeholder="Escribe nombre, programa o matrícula (ej: Aro) y presiona Enter"
|
||
style={{flex:1,padding:"10px 14px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:14}}
|
||
/>
|
||
<button className="btn btn-primary" onClick={()=>setQuery(texto)}>🔍 Buscar</button>
|
||
{query && <button className="btn btn-ghost" onClick={()=>{setTexto("");setQuery("");}}>Limpiar</button>}
|
||
</div>
|
||
|
||
{!query ? (
|
||
<div style={{color:"#94a3b8",padding:30,textAlign:"center",fontSize:14}}>
|
||
Escribe un texto y presiona Enter para buscar.
|
||
</div>
|
||
) : resultados.length === 0 ? (
|
||
<div style={{color:"#94a3b8",padding:30,textAlign:"center",fontSize:14}}>
|
||
Sin coincidencias para "{query}".
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div style={{fontSize:13,color:"#1e40af",fontWeight:600,marginBottom:8}}>
|
||
{resultados.length} alumno(s) encontrado(s)
|
||
</div>
|
||
<div className="table-wrap" style={{overflow:"auto", maxHeight:"60vh"}}>
|
||
<table className="cob-table" {...cols.tableProps}>
|
||
<cols.ColGroup />
|
||
<thead><tr>{COLS.map((c,i)=>{
|
||
const fija = i < FIJAS;
|
||
const stH = fija ? stickyCol(i,{esHeader:true,bg:"#334155"})
|
||
: { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" };
|
||
return <th key={c} style={{...stH, position:stH.position||"relative"}}>{c}<cols.Resizer index={i} /></th>;
|
||
})}</tr></thead>
|
||
<tbody>
|
||
{resultados.map((a,i)=>{
|
||
const deuda = a.saldo > 0.01;
|
||
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},
|
||
{v:a.alumno, cls:"col-name", al:"left", texto:true},
|
||
{v:a.num_cuota},
|
||
{v:a.fch_venc},
|
||
{v:fmtMoneda("S/ "+a.cta_ant), cart:"cart-ant"},
|
||
{v:fmtMoneda("S/ "+a.cob_ant), cart:"cart-ant"},
|
||
{pct:pctStr(a.cob_ant,a.cta_ant), cart:"cart-ant"},
|
||
{v:fmtMoneda("S/ "+a.cta_cur), cart:"cart-mes"},
|
||
{v:fmtMoneda("S/ "+a.cob_cur), cart:"cart-mes"},
|
||
{pct:pctStr(a.cob_cur,a.cta_cur), cart:"cart-mes"},
|
||
{v:fmtMoneda("S/ "+a.cta_tot), cart:"cart-tot"},
|
||
{v:fmtMoneda("S/ "+a.cob_tot), cart:"cart-tot"},
|
||
{pct:pctStr(a.cob_tot,a.cta_tot), cart:"cart-tot"},
|
||
{v:fmtMoneda("S/ "+a.saldo), saldo:true},
|
||
];
|
||
return (
|
||
<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()}
|
||
title={c.texto ? String(c.v) : undefined}
|
||
style={{textAlign:c.al||"center",
|
||
fontWeight:c.saldo?700:undefined,
|
||
color:c.saldo&&deuda?"#dc2626":undefined,
|
||
whiteSpace:"nowrap",
|
||
overflow:c.texto?"hidden":undefined,
|
||
textOverflow:c.texto?"ellipsis":undefined,
|
||
...stickyCol(j,{bg:bgFila})}}>
|
||
{c.pct!==undefined ? (c.pct ? <MiniBar valStr={c.pct} /> : "—") : c.v}
|
||
</td>
|
||
))}
|
||
<td style={{textAlign:"center"}}>
|
||
<button
|
||
onClick={()=>setInfoAlumno({ matricula:a.matricula, alumno:a.alumno, programa:a.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>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)}
|
||
{infoAlumno && (
|
||
<ModalInfoAlumno alumno={infoAlumno} onClose={()=>setInfoAlumno(null)}
|
||
onGuardado={cargarPorRetirar} />
|
||
)}
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
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
|
||
|
||
useEffect(() => {
|
||
let activo = true;
|
||
setLoading(true);
|
||
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, progFiltro, sedeFiltro, vencHasta]);
|
||
|
||
// 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"];
|
||
|
||
// 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);
|
||
|
||
// 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 {};
|
||
let left = 0;
|
||
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,
|
||
borderRight: "none",
|
||
boxShadow: sombraLinea + sombraCorte,
|
||
};
|
||
};
|
||
|
||
// Resumen para las 3 tarjetas (siempre visibles) — suma del detalle de alumnos
|
||
const resumen = useMemo(() => {
|
||
let cAnt=0,obAnt=0,cMes=0,obMes=0,cTot=0,obTot=0;
|
||
filas.forEach((f) => {
|
||
if (String(f[0]).toUpperCase() === "TOTAL") return;
|
||
cAnt += toMonto(f[4]); obAnt += toMonto(f[5]);
|
||
cMes += toMonto(f[7]); obMes += toMonto(f[8]);
|
||
cTot += toMonto(f[10]); obTot += toMonto(f[11]);
|
||
});
|
||
return { cAnt,obAnt,cMes,obMes,cTot,obTot };
|
||
}, [filas]);
|
||
|
||
// Conteo de alumnos: total, ya pagaron (saldo 0) y por pagar (saldo > 0)
|
||
const conteo = useMemo(() => {
|
||
let total=0, pagaron=0, deben=0;
|
||
filas.forEach((f) => {
|
||
if (String(f[0]).toUpperCase() === "TOTAL") return;
|
||
total++;
|
||
if (toMonto(f[13]) > 0.01) deben++; else pagaron++;
|
||
});
|
||
return { total, pagaron, deben };
|
||
}, [filas]);
|
||
|
||
// POR RETIRAR: cantidad de alumnos marcados y saldos por cartera (ANT, MES, TOTAL)
|
||
const retiro = useMemo(() => {
|
||
let cant=0, saldo=0, sAnt=0, sMes=0;
|
||
filas.forEach((f) => {
|
||
if (String(f[0]).toUpperCase() === "TOTAL") return;
|
||
if (porRetirar.has(String(f[0]))) {
|
||
cant++;
|
||
saldo += toMonto(f[13]);
|
||
sAnt += (toMonto(f[4]) - toMonto(f[5])); // CTA ANT - COB ANT
|
||
sMes += (toMonto(f[7]) - toMonto(f[8])); // CTA MES - COB MES
|
||
}
|
||
});
|
||
return { cant, saldo, sAnt, sMes, sTot: sAnt + sMes };
|
||
}, [filas, porRetirar]);
|
||
|
||
return (
|
||
<Modal title={`📊 Estado de Cuenta — ${grupo}`} onClose={onClose} width={1500}>
|
||
{loading ? <Loader text="Cargando estado de cuenta..." /> :
|
||
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos.</div> :
|
||
<>
|
||
<div style={{marginBottom:16}}>
|
||
<TarjetasResumen r={resumen} retiro={retiro} />
|
||
</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"}}>
|
||
👥 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,
|
||
border:"1px solid #a7f3d0"}}>
|
||
✅ Pagaron: {conteo.pagaron}
|
||
</span>
|
||
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
|
||
borderRadius:999,background:"#fffbeb",color:"#b45309",fontSize:13,fontWeight:700,
|
||
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"}}>
|
||
🚫 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>
|
||
|
||
{/* 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"}} />
|
||
Ver solo por retirar
|
||
</label>
|
||
</div>
|
||
<div className="table-wrap" style={{overflow:"auto", maxHeight:"60vh"}}>
|
||
<table className="cob-table" {...cols.tableProps}>
|
||
<cols.ColGroup />
|
||
<thead><tr>{COLS.map((c,i)=>{
|
||
const fija = i < FIJAS;
|
||
const stH = fija
|
||
? stickyCol(i,{esHeader:true,bg:"#334155"})
|
||
: { position:"sticky", top:0, zIndex:12, backgroundColor:"#334155" };
|
||
return <th key={c} style={{...stH, position:stH.position||"relative"}}>{c}<cols.Resizer index={i} /></th>;
|
||
})}</tr></thead>
|
||
<tbody>
|
||
{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)
|
||
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] || "-"; // 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"; // par/impar (i base 0)
|
||
return (
|
||
<td key={j} className={clss}
|
||
title={esTexto ? String(val) : undefined}
|
||
style={{textAlign:esTexto?"left":"center",
|
||
whiteSpace:"nowrap",
|
||
overflow:esTexto?"hidden":undefined,
|
||
textOverflow:esTexto?"ellipsis":undefined,
|
||
color:j===13&&deuda?"#dc2626":undefined,
|
||
fontWeight:j===13?700:undefined,
|
||
...stickyCol(j,{bg:bgFila})}}>
|
||
{isPct ? (val==="—"?<span style={{color:"#cbd5e1"}}>—</span>:<MiniBar valStr={val} />) : fmtMoneda(val)}
|
||
</td>
|
||
);
|
||
})}
|
||
{/* Botón INFO por alumno (estado + observaciones) */}
|
||
<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>
|
||
);
|
||
})}
|
||
</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;
|
||
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)};
|
||
const r = (cob,cta)=>cta>0?`${Math.round(cob/cta*100)}%`:"";
|
||
const celda = (j)=>{
|
||
if (j===0) return "TOTAL GENERAL";
|
||
if (j===1||j===2||j===3) return "";
|
||
if (j===6) return r(t[5],t[4]);
|
||
if (j===9) return r(t[8],t[7]);
|
||
if (j===12) return r(t[11],t[10]);
|
||
if (t[j]!==undefined) return "S/ "+t[j].toLocaleString("es-PE",{maximumFractionDigits:0});
|
||
return "";
|
||
};
|
||
return (
|
||
<tr className="total-row">
|
||
{Array.from({length:14}).map((_,j)=>{
|
||
const isPct=[6,9,12].includes(j);
|
||
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", // 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 (
|
||
<td key={j} className={j===0?"col-name":""}
|
||
style={{textAlign:j===0?"left":"center",whiteSpace:"nowrap",...st}}>
|
||
{isPct?<MiniBar valStr={val} />:val}
|
||
</td>
|
||
);
|
||
})}
|
||
<td style={{position:"sticky",bottom:0,zIndex:8,backgroundColor:"#eff6ff",boxShadow:"inset 0 1px 0 0 #cbd5e1"}}></td>
|
||
</tr>
|
||
);
|
||
})()}
|
||
</tfoot>
|
||
</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_alumno_estado).
|
||
function ModalInfoAlumno({ alumno, onClose, onGuardado }) {
|
||
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)
|
||
.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(); // refresca el tachado en la tabla
|
||
} catch {
|
||
alert("No se pudo guardar. Intenta de nuevo.");
|
||
} finally {
|
||
setGuardando(false);
|
||
}
|
||
};
|
||
|
||
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}}>
|
||
Matrícula: {alumno.matricula} · {alumno.programa}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Estado */}
|
||
<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>
|
||
|
||
{/* Observaciones */}
|
||
<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>
|
||
{/* 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>
|
||
) : 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>
|
||
|
||
{/* Acciones */}
|
||
<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>
|
||
|
||
{/* 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",
|
||
borderRadius:8,padding:"10px 14px",fontSize:13,fontWeight:700}}>
|
||
<span style={{fontSize:16}}>✅</span> Guardado correctamente.
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
);
|
||
}
|