Actualizar frontend/src/pages/Cobranza.jsx

This commit is contained in:
2026-10-06 13:30:36 -05:00
parent 957b9e5cb3
commit 419c07fd00

View File

@@ -262,10 +262,11 @@ export default function Cobranza() {
.catch(() => {}); .catch(() => {});
}, [ano, mes]); }, [ano, mes]);
// En agrupación SEDE: cargar la vista "Por Programa" (para poder expandir cada sede // Cargar SIEMPRE la vista "Por Programa" (filas con FREC/N° CUOTA/VENCIMIENTO).
// y ver sus programas con FREC/N° CUOTA/VENCIMIENTO). Se filtra por sede al expandir. // En SEDE sirve para expandir cada sede; además el GRÁFICO de vencimientos la usa
// en cualquier agrupación (necesita la columna VENCIMIENTO que solo trae Por Programa).
useEffect(() => { useEffect(() => {
if (agrupacion !== "SEDE") { setFilasPrograma([]); setExpandidas(new Set()); return; } if (agrupacion !== "SEDE") setExpandidas(new Set());
let activo = true; let activo = true;
api.cobranza(ano, mes, sectorista, "PROGRAMA", sedeFiltro, progFiltro, vencHasta, frecFiltro) api.cobranza(ano, mes, sectorista, "PROGRAMA", sedeFiltro, progFiltro, vencHasta, frecFiltro)
.then((res) => { .then((res) => {
@@ -731,6 +732,13 @@ export default function Cobranza() {
</div> </div>
</div>} </div>}
{/* Gráfico de columnas agrupadas por rango de día de vencimiento (mes del filtro).
Usa alumnosFiltrados (respeta Sectorista/Frecuencia/Sede/Programa). No afecta
ninguna tabla. Barras: CTA x COB EN CURSO, COBRADO EN CURSO, RETIROS, DIFERENCIA. */}
{!loading && !error && (
<GraficoVencimientos filas={esPrograma ? datosCalc : filasPrograma} ano={ano} mes={mes} />
)}
{modal && ( {modal && (
<ModalEstadoCuenta grupo={modal.grupo} ano={ano} mes={mes} <ModalEstadoCuenta grupo={modal.grupo} ano={ano} mes={mes}
sectorista={modal.sectorista || sectorista} agrupacion={modal.agrupacion || agrupacion} sectorista={modal.sectorista || sectorista} agrupacion={modal.agrupacion || agrupacion}
@@ -751,6 +759,269 @@ export default function Cobranza() {
); );
} }
// ── Gráfico de columnas agrupadas por rango de día de VENCIMIENTO ──────────
// 4 grupos (1-10, 11-20, 21-25, 26-fin de mes) SOLO del mes/año del filtro.
// Cada grupo: 4 barras sumadas de los alumnos cuya fch_venc cae en ese rango:
// CTA x COB EN CURSO (cta_cur), COBRADO EN CURSO (cob_cur),
// RETIROS (= (cta_cur-cob_cur) de los POR RETIRAR del grupo),
// DIFERENCIA (= (cta_cur-cob_cur) del grupo − RETIROS del grupo).
// SVG puro (sin librerías). No afecta ninguna tabla; respeta los filtros (alumnos
// ya viene filtrado por Sectorista/Frecuencia/Sede/Programa).
function GraficoVencimientos({ filas, ano, mes }) {
// Filas Por Programa, layout fijo:
// [0]=prog [3]=VENC [4]=CTA MES ANT [5]=COB MES ANT [7]=CTA EN CURSO [8]=COB EN CURSO
// [13]=SALDO [14]=SALDO POR RETIRAR. RETIROS=saldo-saldoRet; DIFERENCIA=(cta_tot-cob_tot)-retiros.
const IX = { venc: 3, ctaAnt: 4, cobAnt: 5, ctaCur: 7, cobCur: 8, saldo: 13, saldoRet: 14 };
// Paleta sobria y minimalista (tonos apagados, nada saturado)
const SERIES = [
{ k: "cta", label: "CTA x COB en curso", color: "#64748b" }, // gris azulado
{ k: "cob", label: "Cobrado en curso", color: "#5b8a72" }, // verde apagado
{ k: "dif", label: "Retiros", color: "#9a93bf" }, // 3ª barra
{ k: "ret", label: "Diferencia", color: "#b98a8a" }, // 4ª barra
];
const MESES_N = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const finMes = new Date(Number(ano), Number(mes), 0).getDate();
// Grupo 0 = VENCIDO (mes anterior). Grupos 1-4 = en curso por día de vencimiento.
const GRUPOS = [
{ label: "VENCIDO", vencido: true },
{ label: "1 – 10", lo: 1, hi: 10 },
{ label: "11 – 20", lo: 11, hi: 20 },
{ label: "21 – 25", lo: 21, hi: 25 },
{ label: `26 – ${finMes}`, lo: 26, hi: finMes },
];
// "S/ 1,234" o "1234" -> número
const toMonto = (v) => {
const n = parseFloat(String(v ?? "").replace(/S\/|,|\s/g, ""));
return isNaN(n) ? 0 : n;
};
// dd/mm/yyyy -> día (número), o null
const diaVenc = (s) => {
const p = String(s || "").split("/").map(Number);
return (p.length === 3 && p[0]) ? p[0] : null;
};
const data = useMemo(() => {
const g = GRUPOS.map(() => ({ cta: 0, cob: 0, ret: 0, dif: 0, n: 0 }));
// Índices de grupos en curso (1..4). El 0 es VENCIDO.
const idxCurso = GRUPOS.map((grp, i) => i).filter((i) => !GRUPOS[i].vencido);
(filas || []).forEach((f) => {
if (!Array.isArray(f) || String(f[0]).toUpperCase().includes("TOTAL")) return;
const ctaAnt = toMonto(f[IX.ctaAnt]); // CTA x COB MES ANTERIOR
const cobAnt = toMonto(f[IX.cobAnt]); // COB MES ANTERIOR
const ctaCur = toMonto(f[IX.ctaCur]); // CTA x COB EN CURSO
const cobCur = toMonto(f[IX.cobCur]); // COB EN CURSO
// RETIROS y DIFERENCIA TOTALES del programa, EXACTO como la tabla:
// RETIROS = SALDO − SALDO POR RETIRAR
// DIFERENCIA = SALDO POR RETIRAR
const saldo = toMonto(f[IX.saldo]);
const saldoRet = toMonto(f[IX.saldoRet]);
const retirosTot = saldo - saldoRet; // 3ª barra: RETIROS (24,297)
const difTot = saldoRet; // 4ª barra: DIFERENCIA (15,786)
// ── Grupo VENCIDO (mes anterior) ──
if (ctaAnt !== 0 || cobAnt !== 0) {
g[0].cta += ctaAnt;
g[0].cob += cobAnt;
g[0].n += 1;
}
// ── Grupos en curso: por día de vencimiento ──
if (ctaCur !== 0 || cobCur !== 0) {
const d = diaVenc(f[IX.venc]);
let gi = d != null ? GRUPOS.findIndex((grp) => !grp.vencido && d >= grp.lo && d <= grp.hi) : -1;
if (gi < 0) gi = GRUPOS.length - 1; // sin día → último grupo en curso
g[gi].cta += ctaCur;
g[gi].cob += cobCur;
g[gi].n += 1;
}
// ── RETIROS y DIFERENCIA totales: se reparten en curso (por su cta_cur) y el
// resto va a VENCIDO, para que la suma de los 5 grupos calce con el total. ──
// Proporción: si hay en-curso, la parte proporcional al en-curso va al grupo de
// su día; el remanente (vencido) va al grupo VENCIDO.
const totCta = (ctaAnt + ctaCur) || 1;
const propCur = ctaCur / totCta; // fracción del en-curso
const retCur = retirosTot * propCur;
const difCur = difTot * propCur;
const retAnt = retirosTot - retCur; // remanente → VENCIDO
const difAnt = difTot - difCur;
// sumar al grupo en curso correspondiente
if (ctaCur !== 0 || cobCur !== 0) {
const d = diaVenc(f[IX.venc]);
let gi = d != null ? GRUPOS.findIndex((grp) => !grp.vencido && d >= grp.lo && d <= grp.hi) : -1;
if (gi < 0) gi = GRUPOS.length - 1;
g[gi].ret += retCur;
g[gi].dif += difCur;
} else {
// sin en-curso: todo retiros/dif van a VENCIDO
g[0].ret += retirosTot;
g[0].dif += difTot;
}
// remanente al VENCIDO (cuando sí hubo en-curso)
if (ctaCur !== 0 || cobCur !== 0) {
g[0].ret += retAnt;
g[0].dif += difAnt;
}
});
return g;
}, [filas, ano, mes]);
const maxVal = Math.max(1, ...data.flatMap((d) => SERIES.map((s) => Math.abs(d[s.k]))));
// Techo ajustado: un poco por encima del máximo (≈8% de aire), redondeado a un
// paso "bonito" cercano. Así la barra más alta usa casi todo el alto (no se
// dispara a 200k cuando el máximo es 115k).
const nice = (v) => {
const objetivo = v * 1.05; // solo 5% de aire sobre el máximo
const pow = Math.pow(10, Math.floor(Math.log10(objetivo)));
// pasos finos (cada 0.1) para que el techo quede muy pegado al valor real
const n = objetivo / pow;
const step = Math.ceil(n * 10) / 10; // redondea hacia arriba al 0.1
return step * pow;
};
const techo = nice(maxVal);
const W = 860, H = 300, padL = 56, padR = 20, padT = 14, padB = 46;
const plotW = W - padL - padR, plotH = H - padT - padB;
const gW = plotW / GRUPOS.length;
const bW = (gW * 0.56) / SERIES.length;
const y0 = padT + plotH;
const yScale = (v) => (Math.abs(v) / techo) * plotH;
const fmtMoneda = (v) => "S/ " + Math.round(v).toLocaleString("es-PE");
const fmtEje = (v) => {
const n = Math.round(v);
if (Math.abs(n) >= 1000000) return (n / 1000000).toFixed(n % 1000000 ? 1 : 0) + "M";
if (Math.abs(n) >= 1000) return (n / 1000).toFixed(0) + "k";
return String(n);
};
const hayDatos = data.some((d) => SERIES.some((s) => d[s.k] !== 0));
const [hover, setHover] = useState(null);
return (
<div style={{ position: "relative", marginTop: 18, background: "#fff",
border: "1px solid #eef1f5", borderRadius: 14, padding: "18px 20px" }}>
{/* Encabezado minimalista */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: 10, marginBottom: 14 }}>
<div>
<div style={{ fontSize: 14.5, fontWeight: 600, color: "#1e293b" }}>
Cobranza por rango de vencimiento
</div>
<div style={{ fontSize: 11.5, color: "#a0aec0", marginTop: 2 }}>
{MESES_N[Number(mes)-1]} {ano} · por día de vencimiento (mes en curso)
</div>
</div>
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", alignItems: "center" }}>
{SERIES.map((s) => (
<div key={s.k} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11.5, color: "#64748b" }}>
<span style={{ width: 9, height: 9, borderRadius: 2, background: s.color, display: "inline-block" }} />
{s.label}
</div>
))}
</div>
</div>
{!hayDatos ? (
<div style={{ color: "#a0aec0", fontSize: 13, padding: "44px 0", textAlign: "center" }}>
No hay cuotas en curso con los filtros actuales.
</div>
) : (
<div style={{ overflowX: "auto" }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", minWidth: 640, height: "auto", display: "block" }}>
{/* Grid horizontal suave */}
{[0, 0.25, 0.5, 0.75, 1].map((t, i) => {
const y = y0 - t * plotH;
return (
<g key={i}>
{i !== 0 && <line x1={padL} y1={y} x2={W - padR} y2={y} stroke="#f1f4f8" strokeWidth="1" />}
<text x={padL - 9} y={y + 3.5} textAnchor="end" fontSize="10" fill="#b4bcc8">
{fmtEje(t * techo)}
</text>
</g>
);
})}
{/* Línea vertical que separa VENCIDO (grupo 0) de los grupos en curso */}
<line x1={padL + gW} y1={padT - 2} x2={padL + gW} y2={y0}
stroke="#dbe1ea" strokeWidth="1" strokeDasharray="4 4" />
{/* Banda sutil al pasar el mouse */}
{GRUPOS.map((grp, gi) => {
const bandX = padL + gi * gW;
const activo = hover === gi;
return (
<rect key={"band"+gi} x={bandX + 2} y={padT} width={gW - 4} height={plotH}
fill={activo ? "#f7f9fb" : "transparent"} rx="6"
onMouseEnter={() => setHover(gi)} onMouseLeave={() => setHover(null)} />
);
})}
{/* Barras planas, finas, minimalistas */}
{GRUPOS.map((grp, gi) => {
const gx = padL + gi * gW + (gW * 0.22);
return (
<g key={gi} style={{ pointerEvents: "none" }}>
{SERIES.map((s, si) => {
const v = data[gi][s.k];
const h = Math.max(v > 0 ? 2 : 0, yScale(v)); // mínimo visible si hay valor
const x = gx + si * bW;
const y = y0 - h;
const dim = hover != null && hover !== gi;
return (
<rect key={s.k} x={x} y={y} width={Math.max(3, bW - 4)} height={h}
fill={s.color} rx="2.5"
style={{ opacity: dim ? 0.3 : 0.9, transition: "opacity .15s" }} />
);
})}
<text x={padL + gi * gW + gW / 2} y={y0 + 20} textAnchor="middle"
fontSize="12" fontWeight="600" fill="#475569">{grp.label}</text>
<text x={padL + gi * gW + gW / 2} y={y0 + 35} textAnchor="middle"
fontSize="9.5" fill="#b4bcc8">{data[gi].n} cuota{data[gi].n === 1 ? "" : "s"}</text>
</g>
);
})}
<line x1={padL} y1={y0} x2={W - padR} y2={y0} stroke="#e2e8f0" strokeWidth="1" />
</svg>
</div>
)}
{/* Tooltip minimalista (claro, sobrio) */}
{hover != null && hayDatos && (() => {
const d = data[hover];
const leftPct = ((padL + hover * gW + gW / 2) / W) * 100;
return (
<div style={{ position: "absolute", left: `${leftPct}%`, top: 64,
transform: "translateX(-50%)", pointerEvents: "none", zIndex: 5,
background: "#fff", color: "#1e293b", borderRadius: 10,
padding: "11px 13px", minWidth: 196,
boxShadow: "0 6px 24px rgba(15,23,42,.12)", border: "1px solid #eef1f5" }}>
<div style={{ fontSize: 12, fontWeight: 600, color: "#1e293b" }}>
{GRUPOS[hover].vencido ? "Vencido (mes anterior)" : `Vencen del ${GRUPOS[hover].label}`}
</div>
<div style={{ fontSize: 10.5, color: "#a0aec0", marginBottom: 8 }}>
{d.n} cuota{d.n === 1 ? "" : "s"}
</div>
{SERIES.map((s) => (
<div key={s.k} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, padding: "2.5px 0" }}>
<span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11.5, color: "#64748b" }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: s.color }} />
{s.label}
</span>
<span style={{ fontSize: 11.5, fontWeight: 600, color: "#1e293b", fontVariantNumeric: "tabular-nums" }}>
{fmtMoneda(d[s.k])}
</span>
</div>
))}
</div>
);
})()}
</div>
);
}
// Mini-calendario. Muestra el mes/año del filtro, fijo. Al elegir un día filtra // 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. // por F. Vencimiento (<= ese día). Para ver todo, elegir el último día del mes.
function MiniCalendario({ ano, mes, sel, onSelect, onClose }) { function MiniCalendario({ ano, mes, sel, onSelect, onClose }) {