Actualizar frontend/src/pages/Cobranza.jsx
This commit is contained in:
@@ -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 }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user