diff --git a/frontend/src/pages/Cobranza.jsx b/frontend/src/pages/Cobranza.jsx index c2532bc..09ca90d 100644 --- a/frontend/src/pages/Cobranza.jsx +++ b/frontend/src/pages/Cobranza.jsx @@ -262,10 +262,11 @@ 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. + // Cargar SIEMPRE la vista "Por Programa" (filas con FREC/N° CUOTA/VENCIMIENTO). + // 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(() => { - if (agrupacion !== "SEDE") { setFilasPrograma([]); setExpandidas(new Set()); return; } + if (agrupacion !== "SEDE") setExpandidas(new Set()); let activo = true; api.cobranza(ano, mes, sectorista, "PROGRAMA", sedeFiltro, progFiltro, vencHasta, frecFiltro) .then((res) => { @@ -731,6 +732,13 @@ export default function Cobranza() { } + {/* 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 && ( + + )} + {modal && ( 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 ( +
+ {/* Encabezado minimalista */} +
+
+
+ Cobranza por rango de vencimiento +
+
+ {MESES_N[Number(mes)-1]} {ano} · por día de vencimiento (mes en curso) +
+
+
+ {SERIES.map((s) => ( +
+ + {s.label} +
+ ))} +
+
+ + {!hayDatos ? ( +
+ No hay cuotas en curso con los filtros actuales. +
+ ) : ( +
+ + {/* Grid horizontal suave */} + {[0, 0.25, 0.5, 0.75, 1].map((t, i) => { + const y = y0 - t * plotH; + return ( + + {i !== 0 && } + + {fmtEje(t * techo)} + + + ); + })} + + {/* Línea vertical que separa VENCIDO (grupo 0) de los grupos en curso */} + + + {/* Banda sutil al pasar el mouse */} + {GRUPOS.map((grp, gi) => { + const bandX = padL + gi * gW; + const activo = hover === gi; + return ( + setHover(gi)} onMouseLeave={() => setHover(null)} /> + ); + })} + + {/* Barras planas, finas, minimalistas */} + {GRUPOS.map((grp, gi) => { + const gx = padL + gi * gW + (gW * 0.22); + return ( + + {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 ( + + ); + })} + {grp.label} + {data[gi].n} cuota{data[gi].n === 1 ? "" : "s"} + + ); + })} + + + +
+ )} + + {/* Tooltip minimalista (claro, sobrio) */} + {hover != null && hayDatos && (() => { + const d = data[hover]; + const leftPct = ((padL + hover * gW + gW / 2) / W) * 100; + return ( +
+
+ {GRUPOS[hover].vencido ? "Vencido (mes anterior)" : `Vencen del ${GRUPOS[hover].label}`} +
+
+ {d.n} cuota{d.n === 1 ? "" : "s"} +
+ {SERIES.map((s) => ( +
+ + + {s.label} + + + {fmtMoneda(d[s.k])} + +
+ ))} +
+ ); + })()} +
+ ); +} + // 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 }) {