Actualizar frontend/src/pages/Retiros.jsx

This commit is contained in:
2026-09-22 10:34:36 -05:00
parent f4fc67d983
commit 632f00ff0a

View File

@@ -7,6 +7,33 @@ import { useColumnasAjustables } from "../lib/useColumnasAjustables";
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026];
// ── Ordenamiento (detecta número/monto, fecha yyyy-mm-dd o dd/mm/yyyy, o texto) ──
function _valorOrden(v) {
const s = String(v ?? "").trim();
if (s === "" || s === "—" || s === "-") return { t: "vacio", n: 0, s: "" };
const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (iso) { const [, y, m, d] = iso; return { t: "fecha", n: new Date(+y, +m - 1, +d).getTime(), s }; }
const dmy = s.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
if (dmy) { const [, d, m, y] = dmy; return { t: "fecha", n: new Date(+y, +m - 1, +d).getTime(), s }; }
const limpio = s.replace(/S\/|%|,/g, "").replace(/\s/g, "").trim();
if (limpio !== "" && !isNaN(Number(limpio))) return { t: "num", n: Number(limpio), s };
return { t: "texto", n: 0, s: s.toLowerCase() };
}
function _cmp(a, b, dir) {
const mult = dir === "asc" ? 1 : -1;
const va = _valorOrden(a), vb = _valorOrden(b);
if (va.t === "vacio" && vb.t === "vacio") return 0;
if (va.t === "vacio") return 1;
if (vb.t === "vacio") return -1;
if (va.t === "texto" || vb.t === "texto") return mult * va.s.localeCompare(vb.s, "es");
return mult * (va.n - vb.n);
}
function _flecha(activo, dir) {
if (!activo) return <span style={{ opacity: 0.3, marginLeft: 4 }}>⇅</span>;
return <span style={{ marginLeft: 4 }}>{dir === "asc" ? "▲" : "▼"}</span>;
}
// Columnas de la tabla: key, título, alineación y ancho inicial.
const COLS = [
{ k: "fch_matricula", t: "FECHA MATRÍCULA", al: "center", w: 120, fecha: true },
{ k: "asesor", t: "SECTORISTA", al: "left", w: 190, elip: true },
@@ -21,6 +48,7 @@ const COLS = [
{ k: "monto_venc_ant", t: "MONTO VENCIDOS MES ANTERIOR", al: "center", w: 150, money: true },
{ k: "monto_venc_cur", t: "MONTO VENCIDO MES EN CURSO", al: "center", w: 150, money: true },
{ k: "monto_dejo_pagar", t: "MONTO DEJO DE PAGAR", al: "center", w: 150, money: true },
// Columnas para llenar manualmente (por ahora vacías)
{ k: "n_asistencia", t: "N° ASISTENCIA", al: "center", w: 130, vacio: true },
{ k: "n_faltas", t: "N° FALTAS", al: "center", w: 90, vacio: true },
{ k: "ultimo_dia_asist", t: "ULTIMO DIA DE ASISTENCIA", al: "center", w: 210, vacio: true },
@@ -34,6 +62,7 @@ const COLS = [
{ k: "_info", t: "INFO", al: "center", w: 80, info: true },
];
// Campos editables del popup INFO (los 9 manuales)
const INFO_CAMPOS = [
{ k: "n_asistencia", t: "N° ASISTENCIA", tipo: "number" },
{ k: "n_faltas", t: "N° FALTAS", tipo: "number" },
@@ -51,9 +80,17 @@ const INFO_VACIO = () => Object.fromEntries(INFO_CAMPOS.map((c) => [c.k, ""]));
export default function Retiros() {
const hoy = new Date();
const [ano, setAno] = useState(hoy.getFullYear());
const [mes, setMes] = useState(hoy.getMonth() + 1);
const [mes, setMes] = useState(hoy.getMonth() + 1); // mes actual (sin TODOS)
const [sede, setSede] = useState("TODOS");
const [sectorista, setSectorista] = useState("TODOS");
const [ordCol, setOrdCol] = useState(null); // key de la columna ordenada
const [ordDir, setOrdDir] = useState(null);
const clickOrden = (k, esInfo) => {
if (esInfo) return;
if (ordCol !== k) { setOrdCol(k); setOrdDir("asc"); }
else if (ordDir === "asc") setOrdDir("desc");
else { setOrdCol(null); setOrdDir(null); }
};
const [filas, setFilas] = useState([]);
const [guardado, setGuardado] = useState(false); // ¿mostrando snapshot guardado?
@@ -61,13 +98,18 @@ export default function Retiros() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
// Popup INFO
const [info, setInfo] = useState(null); // fila abierta (o null)
const [form, setForm] = useState(INFO_VACIO());
const [guardando, setGuardando] = useState(false);
const [errGuardar, setErrGuardar] = useState(null);
// GUARDAR total (snapshot del mes)
const [guardandoTotal, setGuardandoTotal] = useState(false);
const [msgTotal, setMsgTotal] = useState(null);
const [confirmReemplazo, setConfirmReemplazo] = useState(false);
// Exportar
const [exportando, setExportando] = useState(false);
const abrirInfo = (f) => {
@@ -82,6 +124,7 @@ export default function Retiros() {
setGuardando(true); setErrGuardar(null);
try {
await api.retirosInfoGuardar({ matricula: info.matricula, ano, mes, ...form });
// reflejar en memoria (sin recargar -> sin egress extra)
setFilas((prev) => prev.map((f) =>
f.matricula === info.matricula ? { ...f, ...form } : f));
setInfo(null);
@@ -94,8 +137,16 @@ export default function Retiros() {
const guardarTotal = async () => {
if (guardandoTotal) return;
// Si el mes YA tiene datos guardados, abrir el pop-up de confirmación.
if (guardado) { setConfirmReemplazo(true); return; }
await _hacerGuardadoTotal();
};
const _hacerGuardadoTotal = async () => {
setConfirmReemplazo(false);
setGuardandoTotal(true); setMsgTotal(null);
try {
// Respeta solo Año y Mes: se guardan TODAS las filas del mes cargado (ignora Sede/Sectorista)
const r = await api.retirosSnapshotGuardar({ ano, mes, filas });
setMsgTotal({ err: false, txt: `Guardado: ${r.guardados} registro(s) de ${MESES[mes-1]} ${ano}.` });
} catch (e) {
@@ -105,6 +156,7 @@ export default function Retiros() {
}
};
// Cargar los alumnos POR RETIRAR de (año, mes) con sus datos + info manual de ese mes
useEffect(() => {
let activo = true;
setLoading(true); setError(null); setMsgTotal(null);
@@ -114,32 +166,42 @@ export default function Retiros() {
return () => { activo = false; };
}, [ano, mes]);
// Opciones de sectorista (de los datos cargados)
const sectoristas = useMemo(() => {
const s = [...new Set(filas.map((f) => f.asesor).filter(Boolean))].sort();
return ["TODOS", ...s];
}, [filas]);
// Opciones de sede (de los datos cargados)
const sedes = useMemo(() => {
const s = [...new Set(filas.map((f) => f.sede).filter(Boolean))].sort();
return ["TODOS", ...s];
}, [filas]);
// El backend ya trae solo el mes; aquí solo se aplican Sede y Sectorista (cliente).
const filasFiltradas = useMemo(() => {
return filas.filter((f) => {
let base = filas.filter((f) => {
if (sede !== "TODOS" && f.sede !== sede) return false;
if (sectorista !== "TODOS" && f.asesor !== sectorista) return false;
return true;
});
}, [filas, sede, sectorista]);
if (ordCol != null && ordDir) {
base = [...base].sort((a, b) => _cmp(a[ordCol], b[ordCol], ordDir));
}
return base;
}, [filas, sede, sectorista, ordCol, ordDir]);
// Fecha yyyy-mm-dd -> dd/mm/yyyy
const fmtFecha = (s) => {
if (!s) return "-";
const [y, m, d] = String(s).split("-");
return (y && m && d) ? `${d}/${m}/${y}` : s;
};
// Columnas con ancho ajustable manualmente (arrastrar el borde)
const cols = useColumnasAjustables(COLS.map((c) => c.w));
// Exportar la tabla principal a Excel (respeta TODOS los filtros: Año/Mes/Sede/Sectorista)
async function exportar() {
if (exportando) return;
const base = filasFiltradas;
@@ -147,10 +209,10 @@ export default function Retiros() {
setExportando(true);
try {
const XLSX = await import("xlsx-js-style");
const COLS_EXP = COLS.filter((c) => !c.info);
const COLS_EXP = COLS.filter((c) => !c.info); // sin la columna INFO
const headerStyle = { font:{bold:true,color:{rgb:"FFFFFF"},sz:11},
fill:{fgColor:{rgb:"1E40AF"}}, alignment:{horizontal:"center",vertical:"center"} };
const manualStyle = { fill:{fgColor:{rgb:"EEF4F1"}} };
const manualStyle = { fill:{fgColor:{rgb:"EEF4F1"}} }; // celdas manuales verde suave
const moneyFmt = '"S/" #,##0';
const ws = {};
COLS_EXP.forEach((c, ci) => { ws[XLSX.utils.encode_cell({r:0,c:ci})] = {v:c.t, t:"s", s:headerStyle}; });
@@ -163,7 +225,7 @@ export default function Retiros() {
if (c.money) {
ws[ref] = { v: Number(raw)||0, t:"n", z:moneyFmt, ...st };
} else if (c.fecha) {
ws[ref] = { v: raw ? fmtFecha(raw) : "", t:"s", ...st };
ws[ref] = { v: raw ? fmtFecha(raw) : "", t:"s", ...st }; // texto dd/mm/aaaa
} else if (c.k === "num_cuotas_pagadas" || c.k === "num_cuotas_vencidas") {
ws[ref] = { v: raw ?? 0, t:"n", ...st };
} else if (c.vacio && ["ultimo_dia_asist","fch_ult_resp","fch_envio_correo"].includes(c.k)) {
@@ -245,13 +307,15 @@ export default function Retiros() {
<thead>
<tr>
{COLS.map((c, i) => (
<th key={c.k} title={c.t}
<th key={c.k} title={c.t} onClick={() => clickOrden(c.k, c.info)}
style={{ textAlign: "center", position: "relative", whiteSpace: "nowrap",
overflow: "hidden", textOverflow: "ellipsis",
borderRight: "1px solid #cbd5e1",
cursor: c.info ? "default" : "pointer", userSelect: "none",
// Encabezado de las columnas manuales en verde
background: c.vacio ? "#7a9d8f" : undefined,
color: c.vacio ? "#fff" : undefined }}>
{c.t}<cols.Resizer index={i} />
{c.t}{!c.info && _flecha(ordCol === c.k, ordDir)}<cols.Resizer index={i} />
</th>
))}
</tr>
@@ -282,6 +346,7 @@ export default function Retiros() {
}
let val = f[c.k];
if (c.vacio) {
// Columnas manuales: muestran lo guardado (fechas en dd/mm/yyyy)
const raw = f[c.k];
if (raw === null || raw === undefined || raw === "") val = "";
else if (["ultimo_dia_asist","fch_ult_resp","fch_envio_correo"].includes(c.k))
@@ -308,6 +373,7 @@ export default function Retiros() {
color: rojo ? "#dc2626" : undefined,
borderRight: "1px solid #e2e8f0",
borderBottom: "1px solid #e2e8f0",
// Fondo verde suave para las celdas de columnas manuales
background: c.vacio ? "#eef4f1" : undefined,
}}>
{val}
@@ -321,6 +387,43 @@ export default function Retiros() {
</div>
</>}
{confirmReemplazo && (
<div onClick={() => setConfirmReemplazo(false)}
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,.55)",
backdropFilter: "blur(2px)", display: "flex", alignItems: "center",
justifyContent: "center", zIndex: 1100, padding: 16 }}>
<div onClick={(e) => e.stopPropagation()}
style={{ background: "#fff", borderRadius: 16, width: 440, maxWidth: "94vw",
overflow: "hidden", boxShadow: "0 24px 70px rgba(15,23,42,.35)",
border: "1px solid #e2e8f0" }}>
<div style={{ background: "linear-gradient(135deg, #6f9284 0%, #86a99a 100%)",
color: "#fff", padding: "18px 22px", display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: "rgba(255,255,255,.15)",
display: "flex", alignItems: "center", justifyContent: "center", fontSize: 20 }}>⚠️</div>
<div style={{ fontWeight: 700, fontSize: 16 }}>Ya existen datos guardados</div>
</div>
<div style={{ padding: "22px", fontSize: 14, color: "#334155", lineHeight: 1.6 }}>
Ya hay datos guardados de <b>{MESES[mes-1]} {ano}</b>.<br/>
Si continúas, se <b style={{ color: "#b45309" }}>reemplazarán</b> por los datos actuales de la tabla.
</div>
<div style={{ padding: "0 22px 20px", display: "flex", justifyContent: "flex-end", gap: 10 }}>
<button onClick={() => setConfirmReemplazo(false)}
style={{ padding: "9px 18px", borderRadius: 9, border: "1px solid #cbd5e1",
background: "#fff", color: "#475569", fontWeight: 600, fontSize: 13.5, cursor: "pointer" }}>
Cancelar
</button>
<button onClick={_hacerGuardadoTotal}
style={{ padding: "9px 20px", borderRadius: 9, border: "none",
background: "linear-gradient(135deg, #6f9284 0%, #86a99a 100%)",
color: "#fff", fontWeight: 700, fontSize: 13.5, cursor: "pointer",
boxShadow: "0 4px 12px rgba(111,146,132,.35)" }}>
Sí, reemplazar
</button>
</div>
</div>
</div>
)}
{info && (
<div onClick={() => setInfo(null)}
style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,.55)",
@@ -330,6 +433,7 @@ export default function Retiros() {
style={{ background: "#fff", borderRadius: 16, width: 600, maxWidth: "94vw",
maxHeight: "90vh", overflow: "hidden", display: "flex", flexDirection: "column",
boxShadow: "0 24px 70px rgba(15,23,42,.35)", border: "1px solid #e2e8f0" }}>
{/* Cabecera */}
<div style={{ background: "linear-gradient(135deg, #6f9284 0%, #86a99a 100%)",
color: "#fff", padding: "18px 22px",
display: "flex", justifyContent: "space-between", alignItems: "center" }}>
@@ -348,6 +452,7 @@ export default function Retiros() {
lineHeight: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>×</button>
</div>
{/* Formulario */}
<div style={{ padding: 22, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16,
overflow: "auto", background: "#f8fafc" }}>
{INFO_CAMPOS.map((c) => {
@@ -378,6 +483,7 @@ export default function Retiros() {
})}
</div>
{/* Pie */}
<div style={{ padding: "16px 22px", display: "flex", justifyContent: "flex-end",
alignItems: "center", gap: 10,
borderTop: "1px solid #e2e8f0", background: "#fff" }}>