Añadir frontend/src/pages/Retiros.jsx

This commit is contained in:
2026-09-02 11:24:02 -05:00
parent 97b8b0639c
commit 37c54f108a

View File

@@ -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 (
<div>
<h1 className="page-title">🚪 Retiros</h1>
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v)=>setAno(+v)} />
<Select label="Mes" value={mes}
options={MESES.map((m,i)=>({value:i+1,label:m}))}
onChange={(v)=>setMes(+v)} />
<Select label="Sede" value={sede} options={sedes} onChange={setSede} />
<Select label="Sectorista" value={sectorista} options={sectoristas} onChange={setSectorista} width={250} />
</Filters>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{msgTotal && (
<span style={{ fontSize: 12.5, fontWeight: 600,
color: msgTotal.err ? "#dc2626" : "#15803d" }}>
{msgTotal.txt}
</span>
)}
<button onClick={exportar} disabled={exportando || !filasFiltradas.length}
title="Exportar la tabla actual a Excel (respeta los filtros)"
style={{ padding: "9px 18px", borderRadius: 9, border: "1px solid #cbd5e1",
background: "#fff", color: "#334155", fontWeight: 700, fontSize: 13.5,
cursor: (exportando || !filasFiltradas.length) ? "default" : "pointer",
opacity: (exportando || !filasFiltradas.length) ? .6 : 1, whiteSpace: "nowrap" }}>
{exportando ? "Exportando…" : "⬇️ Exportar"}
</button>
<button onClick={guardarTotal} disabled={guardandoTotal}
title="Guarda todas las columnas del mes seleccionado en Supabase"
style={{ padding: "9px 20px", borderRadius: 9, border: "none",
background: "linear-gradient(135deg, #6f9284 0%, #86a99a 100%)",
color: "#fff", fontWeight: 700, fontSize: 13.5,
cursor: guardandoTotal ? "default" : "pointer",
opacity: guardandoTotal ? .7 : 1,
boxShadow: "0 4px 12px rgba(111,146,132,.35)", whiteSpace: "nowrap" }}>
{guardandoTotal ? "Guardando…" : "💾 GUARDAR"}
</button>
</div>
</div>
{loading ? <Loader text="Cargando retiros..." /> :
error ? <ErrorBox msg={error} /> :
<>
<div style={{ fontSize: 13, color: "#1e40af", fontWeight: 600, margin: "10px 0 8px" }}>
{filasFiltradas.length} alumno(s) por retirar
</div>
<div className="table-wrap" style={{ overflow: "auto", maxHeight: "65vh" }}>
<table className="cob-table" {...cols.tableProps}>
<cols.ColGroup />
<thead>
<tr>
{COLS.map((c, i) => (
<th key={c.k} title={c.t}
style={{ textAlign: "center", position: "relative", whiteSpace: "nowrap",
overflow: "hidden", textOverflow: "ellipsis",
borderRight: "1px solid #cbd5e1",
background: c.vacio ? "#7a9d8f" : undefined,
color: c.vacio ? "#fff" : undefined }}>
{c.t}<cols.Resizer index={i} />
</th>
))}
</tr>
</thead>
<tbody>
{filasFiltradas.length === 0 && (
<tr><td colSpan={COLS.length} style={{ textAlign: "center", color: "#94a3b8", padding: 24 }}>
No hay alumnos por retirar con estos filtros.
</td></tr>
)}
{filasFiltradas.map((f, i) => (
<tr key={f.matricula + i}>
{COLS.map((c) => {
if (c.info) {
return (
<td key={c.k} style={{ textAlign: "center", borderRight: "1px solid #e2e8f0", borderBottom: "1px solid #e2e8f0" }}>
<button onClick={() => abrirInfo(f)}
onMouseEnter={(e) => { e.currentTarget.style.background = "#f8fafc"; e.currentTarget.style.borderColor = "#94a3b8"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "#fff"; e.currentTarget.style.borderColor = "#cbd5e1"; }}
style={{ background: "#fff", color: "#334155", border: "1px solid #cbd5e1",
borderRadius: 6, padding: "4px 12px", fontSize: 11.5,
fontWeight: 700, letterSpacing: .3, cursor: "pointer",
transition: "background .15s, border-color .15s" }}>
INFO
</button>
</td>
);
}
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 (
<td key={c.k}
title={String(val ?? "")}
style={{
textAlign: c.al,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: bold ? 700 : undefined,
color: rojo ? "#dc2626" : undefined,
borderRight: "1px solid #e2e8f0",
borderBottom: "1px solid #e2e8f0",
background: c.vacio ? "#eef4f1" : undefined,
}}>
{val}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</>}
{info && (
<div onClick={() => 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 }}>
<div onClick={(e) => 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" }}>
<div style={{ background: "linear-gradient(135deg, #6f9284 0%, #86a99a 100%)",
color: "#fff", padding: "18px 22px",
display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ 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>
<div style={{ fontWeight: 700, fontSize: 16, letterSpacing: .2 }}>Información de retiro</div>
<div style={{ fontSize: 12.5, opacity: .85, marginTop: 2 }}>{info.alumno || info.matricula}</div>
</div>
</div>
<button onClick={() => setInfo(null)}
style={{ background: "rgba(255,255,255,.12)", color: "#fff", border: "none",
width: 32, height: 32, borderRadius: 8, fontSize: 20, cursor: "pointer",
lineHeight: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>×</button>
</div>
<div style={{ padding: 22, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16,
overflow: "auto", background: "#f8fafc" }}>
{INFO_CAMPOS.map((c) => {
const full = c.tipo === "textarea" || c.k === "hasta_sesion" || c.k === "motivo_retiro";
return (
<div key={c.k} style={{ gridColumn: full ? "1 / -1" : "auto" }}>
<label style={{ display: "block", fontSize: 11.5, fontWeight: 700,
color: "#475569", marginBottom: 6, textTransform: "uppercase",
letterSpacing: .3 }}>{c.t}</label>
{c.tipo === "sino" ? (
<select value={form[c.k]} onChange={(e) => setCampo(c.k, e.target.value)}
onFocus={onFoco} onBlur={offFoco} style={inpStyle}>
<option value="">—</option>
<option value="SI">SI</option>
<option value="NO">NO</option>
</select>
) : c.tipo === "textarea" ? (
<textarea value={form[c.k]} onChange={(e) => setCampo(c.k, e.target.value)}
onFocus={onFoco} onBlur={offFoco}
rows={3} style={{ ...inpStyle, resize: "vertical" }} />
) : (
<input type={c.tipo} value={form[c.k]}
onChange={(e) => setCampo(c.k, e.target.value)}
onFocus={onFoco} onBlur={offFoco} style={inpStyle} />
)}
</div>
);
})}
</div>
<div style={{ padding: "16px 22px", display: "flex", justifyContent: "flex-end",
alignItems: "center", gap: 10,
borderTop: "1px solid #e2e8f0", background: "#fff" }}>
{errGuardar && (
<span style={{ color: "#dc2626", fontSize: 12.5, fontWeight: 600, marginRight: "auto" }}>
{errGuardar}
</span>
)}
<button onClick={() => setInfo(null)} disabled={guardando}
style={{ padding: "9px 18px", borderRadius: 9, border: "1px solid #cbd5e1",
background: "#fff", color: "#475569", fontWeight: 600, fontSize: 13.5,
cursor: guardando ? "default" : "pointer", opacity: guardando ? .6 : 1 }}>
Cancelar
</button>
<button onClick={guardarInfo} disabled={guardando}
style={{ padding: "9px 20px", borderRadius: 9, border: "none",
background: "linear-gradient(135deg, #6f9284 0%, #86a99a 100%)",
color: "#fff", fontWeight: 700, fontSize: 13.5,
cursor: guardando ? "default" : "pointer", opacity: guardando ? .7 : 1,
boxShadow: "0 4px 12px rgba(111,146,132,.35)" }}>
{guardando ? "Guardando…" : "Guardar"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
const inpStyle = {
width: "100%", padding: "9px 11px", border: "1px solid #cbd5e1",
borderRadius: 9, fontSize: 13.5, boxSizing: "border-box", outline: "none",
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"; };