From 632f00ff0afaff5cb9936d103f599c3fc86eafae Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 22 Sep 2026 10:34:36 -0500 Subject: [PATCH] Actualizar frontend/src/pages/Retiros.jsx --- frontend/src/pages/Retiros.jsx | 126 ++++++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/Retiros.jsx b/frontend/src/pages/Retiros.jsx index e44078f..1cdf8b6 100644 --- a/frontend/src/pages/Retiros.jsx +++ b/frontend/src/pages/Retiros.jsx @@ -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 ⇅; + return {dir === "asc" ? "▲" : "▼"}; +} + +// 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() { {COLS.map((c, i) => ( - 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} + {c.t}{!c.info && _flecha(ordCol === c.k, ordDir)} ))} @@ -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() { } + {confirmReemplazo && ( +
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 }}> +
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" }}> +
+
⚠️
+
Ya existen datos guardados
+
+
+ Ya hay datos guardados de {MESES[mes-1]} {ano}.
+ Si continúas, se reemplazarán por los datos actuales de la tabla. +
+
+ + +
+
+
+ )} + {info && (
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 */}
@@ -348,6 +452,7 @@ export default function Retiros() { lineHeight: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>×
+ {/* Formulario */}
{INFO_CAMPOS.map((c) => { @@ -378,6 +483,7 @@ export default function Retiros() { })}
+ {/* Pie */}
@@ -414,4 +520,4 @@ const inpStyle = { background: "#fff", transition: "border-color .15s, box-shadow .15s", }; const onFoco = (e) => { e.target.style.borderColor = "#7a9d8f"; e.target.style.boxShadow = "0 0 0 3px rgba(122,157,143,.18)"; }; -const offFoco = (e) => { e.target.style.borderColor = "#cbd5e1"; e.target.style.boxShadow = "none"; }; \ No newline at end of file +const offFoco = (e) => { e.target.style.borderColor = "#cbd5e1"; e.target.style.boxShadow = "none"; };