diff --git a/frontend/src/pages/Retiros.jsx b/frontend/src/pages/Retiros.jsx new file mode 100644 index 0000000..3445899 --- /dev/null +++ b/frontend/src/pages/Retiros.jsx @@ -0,0 +1,407 @@ +// src/pages/Retiros.jsx +import { useState, useEffect, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters, Select } from "../components/UI"; +import { useColumnasAjustables } from "../lib/useColumnasAjustables"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; + +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 }, + { k: "vendedor", t: "ASESOR COMERCIAL", al: "left", w: 190, elip: true }, + { k: "programa", t: "PROGRAMA", al: "left", w: 260, elip: true }, + { k: "cod_detalle", t: "COD. DETALLE", al: "center", w: 100 }, + { k: "fch_inicio", t: "FECHA INICIO", al: "center", w: 110, fecha: true }, + { k: "alumno", t: "ALUMNO", al: "left", w: 220, elip: true }, + { k: "num_cuotas_pagadas", t: "N° CUOTAS PAGADAS", al: "center", w: 110 }, + { k: "num_cuotas_vencidas", t: "N° CUOTAS VENCIDAS", al: "center", w: 110 }, + { k: "fch_vencimiento", t: "FECHA DE VENCIMIENTO", al: "center", w: 140, fecha: true }, + { 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 }, + { 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 }, + { k: "hasta_sesion", t: "ÚLTIMA SESIÓN DE ASISTENCIA", al: "center", w: 230, vacio: true }, + { k: "fch_ult_resp", t: "FECHA DE ULTIMA RESPUESTA DE ALUMNO", al: "center", w: 280, vacio: true }, + { k: "fch_envio_correo", t: "FECHA DE ENVIO DE CORREO", al: "center", w: 192, vacio: true }, + { k: "retiro_wsp_class", t: "¿SE RETIRÓ DE WHATSAPP Y CLASSROOM?", al: "center", w: 288, vacio: true }, + { k: "motivo_retiro", t: "MOTIVO DE RETIRO", al: "left", w: 360, vacio: true, elip: true }, + { k: "detalle_retiro", t: "DETALLE DE RETIRO", al: "left", w: 400, vacio: true, elip: true }, + { k: "fch_retiro", t: "FECHA DE RETIRO", al: "center", w: 130, fecha: true }, + { k: "_info", t: "INFO", al: "center", w: 80, info: true }, +]; + +const INFO_CAMPOS = [ + { k: "n_asistencia", t: "N° ASISTENCIA", tipo: "number" }, + { k: "n_faltas", t: "N° FALTAS", tipo: "number" }, + { k: "ultimo_dia_asist", t: "ÚLTIMO DÍA DE ASISTENCIA", tipo: "date" }, + { k: "hasta_sesion", t: "ÚLTIMA SESIÓN DE ASISTENCIA", tipo: "text" }, + { k: "fch_ult_resp", t: "FECHA DE ÚLTIMA RESPUESTA DE ALUMNO", tipo: "date" }, + { k: "fch_envio_correo", t: "FECHA DE ENVÍO DE CORREO", tipo: "date" }, + { k: "retiro_wsp_class", t: "¿SE RETIRÓ DE GRUPO WHATSAPP Y CLASSROOM?", tipo: "sino" }, + { k: "motivo_retiro", t: "MOTIVO DE RETIRO", tipo: "text" }, + { k: "detalle_retiro", t: "DETALLE DE RETIRO", tipo: "textarea" }, +]; + +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 [sede, setSede] = useState("TODOS"); + const [sectorista, setSectorista] = useState("TODOS"); + + const [filas, setFilas] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [info, setInfo] = useState(null); + const [form, setForm] = useState(INFO_VACIO()); + const [guardando, setGuardando] = useState(false); + const [errGuardar, setErrGuardar] = useState(null); + + const [guardandoTotal, setGuardandoTotal] = useState(false); + const [msgTotal, setMsgTotal] = useState(null); + const [exportando, setExportando] = useState(false); + + const abrirInfo = (f) => { + setErrGuardar(null); + setForm({ ...INFO_VACIO(), ...Object.fromEntries(INFO_CAMPOS.map((c) => [c.k, f[c.k] ?? ""])) }); + setInfo(f); + }; + const setCampo = (k, v) => setForm((prev) => ({ ...prev, [k]: v })); + + const guardarInfo = async () => { + if (!info || guardando) return; + setGuardando(true); setErrGuardar(null); + try { + await api.retirosInfoGuardar({ matricula: info.matricula, ano, mes, ...form }); + setFilas((prev) => prev.map((f) => + f.matricula === info.matricula ? { ...f, ...form } : f)); + setInfo(null); + } catch (e) { + setErrGuardar(e.message || "No se pudo guardar"); + } finally { + setGuardando(false); + } + }; + + const guardarTotal = async () => { + if (guardandoTotal) return; + setGuardandoTotal(true); setMsgTotal(null); + try { + const r = await api.retirosSnapshotGuardar({ ano, mes, filas }); + setMsgTotal({ err: false, txt: `Guardado: ${r.guardados} registro(s) de ${MESES[mes-1]} ${ano}.` }); + } catch (e) { + setMsgTotal({ err: true, txt: e.message || "No se pudo guardar" }); + } finally { + setGuardandoTotal(false); + } + }; + + useEffect(() => { + let activo = true; + setLoading(true); setError(null); setMsgTotal(null); + api.retiros(ano, mes) + .then((r) => { if (activo) { setFilas(r.filas || []); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mes]); + + const sectoristas = useMemo(() => { + const s = [...new Set(filas.map((f) => f.asesor).filter(Boolean))].sort(); + return ["TODOS", ...s]; + }, [filas]); + + const sedes = useMemo(() => { + const s = [...new Set(filas.map((f) => f.sede).filter(Boolean))].sort(); + return ["TODOS", ...s]; + }, [filas]); + + const filasFiltradas = useMemo(() => { + return filas.filter((f) => { + if (sede !== "TODOS" && f.sede !== sede) return false; + if (sectorista !== "TODOS" && f.asesor !== sectorista) return false; + return true; + }); + }, [filas, sede, sectorista]); + + const fmtFecha = (s) => { + if (!s) return "-"; + const [y, m, d] = String(s).split("-"); + return (y && m && d) ? `${d}/${m}/${y}` : s; + }; + + const cols = useColumnasAjustables(COLS.map((c) => c.w)); + + async function exportar() { + if (exportando) return; + const base = filasFiltradas; + if (!base.length) { alert("No hay datos para exportar con los filtros actuales."); return; } + setExportando(true); + try { + const XLSX = await import("xlsx-js-style"); + const COLS_EXP = COLS.filter((c) => !c.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 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}; }); + base.forEach((f, ri) => { + const r = ri + 1; + COLS_EXP.forEach((c, ci) => { + const ref = XLSX.utils.encode_cell({r, c:ci}); + const st = c.vacio ? { s: manualStyle } : {}; + const raw = f[c.k]; + 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 }; + } 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)) { + ws[ref] = { v: raw ? fmtFecha(raw) : "", t:"s", ...st }; + } else { + ws[ref] = { v: (raw ?? "") === "" ? "" : String(raw), t:"s", ...st }; + } + }); + }); + ws["!ref"] = XLSX.utils.encode_range({ s:{r:0,c:0}, e:{r:base.length, c:COLS_EXP.length-1} }); + ws["!cols"] = COLS_EXP.map((c) => ({ wch: Math.max(10, Math.round(c.w/7)) })); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Retiros"); + const suf = `${MESES[mes-1]}_${ano}` + (sede!=="TODOS"?`_${sede}`:"") + (sectorista!=="TODOS"?`_${sectorista}`:""); + XLSX.writeFile(wb, `retiros_${suf}.xlsx`); + } finally { + setExportando(false); + } + } + + return ( +
+

🚪 Retiros

+ +
+ + ({value:i+1,label:m}))} + onChange={(v)=>setMes(+v)} /> + + +
+ {msgTotal && ( + + {msgTotal.txt} + + )} + + +
+
+ + {loading ? : + error ? : + <> +
+ {filasFiltradas.length} alumno(s) por retirar +
+
+ + + + + {COLS.map((c, i) => ( + + ))} + + + + {filasFiltradas.length === 0 && ( + + )} + {filasFiltradas.map((f, i) => ( + + {COLS.map((c) => { + if (c.info) { + return ( + + ); + } + let val = f[c.k]; + if (c.vacio) { + 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)) + val = fmtFecha(raw); + else val = raw; + } + else if (c.fecha) val = f[c.k] ? fmtFecha(f[c.k]) : "-"; + else if (c.money) val = "S/ " + Number(f[c.k] || 0).toLocaleString("es-PE", { maximumFractionDigits: 0 }); + else if (c.k === "num_cuotas_pagadas" || c.k === "num_cuotas_vencidas") val = f[c.k] ?? 0; + else val = val || "-"; + const rojo = (c.k === "num_cuotas_vencidas" && (f.num_cuotas_vencidas ?? 0) > 0) + || (c.k === "fch_vencimiento" && f.fch_vencimiento) + || (c.money && Number(f[c.k] || 0) > 0); + const bold = c.k === "num_cuotas_pagadas" || c.k === "num_cuotas_vencidas" || c.money; + return ( + + ); + })} + + ))} + +
+ {c.t} +
+ No hay alumnos por retirar con estos filtros. +
+ + + {val} +
+
+ } + + {info && ( +
setInfo(null)} + style={{ position: "fixed", inset: 0, background: "rgba(15,23,42,.55)", + backdropFilter: "blur(2px)", display: "flex", alignItems: "center", + justifyContent: "center", zIndex: 1000, padding: 16 }}> +
e.stopPropagation()} + 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" }}> +
+
+
📝
+
+
Información de retiro
+
{info.alumno || info.matricula}
+
+
+ +
+ +
+ {INFO_CAMPOS.map((c) => { + const full = c.tipo === "textarea" || c.k === "hasta_sesion" || c.k === "motivo_retiro"; + return ( +
+ + {c.tipo === "sino" ? ( + + ) : c.tipo === "textarea" ? ( +