600 lines
26 KiB
JavaScript
600 lines
26 KiB
JavaScript
// src/pages/Ventas.jsx
|
||
import { useState, useEffect, useMemo, useRef } from "react";
|
||
import { api } from "../lib/api";
|
||
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||
import Modal from "../components/Modal";
|
||
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
|
||
|
||
const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||
const ANOS = [2024, 2025, 2026];
|
||
const SEDES = ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
|
||
const PROGRAMAS = ["SEMINARIOS", "OTROS", "TEAC", "TERC"];
|
||
const COLS = ["VENDEDOR","INSCRITOS","VENTA TOTAL","INSCRITOS P.C DEL MES","VENTA P.C DEL MES","INSCRITOS MES ANTERIOR","VENTA MES ANTERIOR","AVANCE P.C TOTAL","OPC."];
|
||
const TIPOS = ["Venta Inscritos", "Venta P.C", "Venta Pendientes"];
|
||
const HEADERS_DETALLE = ["ALUMNO","PROGRAMA","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"];
|
||
// Índice en la fila del backend para cada columna del detalle (arriba).
|
||
// Fila: [0]vend [1]alumno [2]f.mat [3]f.canc [4]tipo [5]cuota [6]desc [7]inv [8]programa
|
||
const IDX_DETALLE = [1, 8, 2, 3, 4, 5, 6, 7];
|
||
|
||
// ── Ordenamiento de tablas (detecta número/soles, fecha dd-mm-yyyy o texto) ──
|
||
function _valorOrden(v) {
|
||
const s = String(v ?? "").trim();
|
||
if (s === "" || s === "—" || s === "-") return { t: "vacio", n: 0, s: "" };
|
||
// Fecha dd-mm-yyyy o dd/mm/yyyy
|
||
const mf = s.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
|
||
if (mf) {
|
||
const [, d, m, y] = mf;
|
||
return { t: "fecha", n: new Date(+y, +m - 1, +d).getTime(), s };
|
||
}
|
||
// Número / soles / porcentaje (quita S/, comas, %, espacios)
|
||
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() };
|
||
}
|
||
|
||
// Ordena filas (arrays) por la columna idx. dir: "asc" | "desc". Vacíos al final.
|
||
function _ordenarFilas(filas, idx, dir) {
|
||
if (idx == null || !dir) return filas;
|
||
const mult = dir === "asc" ? 1 : -1;
|
||
return [...filas].sort((a, b) => {
|
||
const va = _valorOrden(a[idx]);
|
||
const vb = _valorOrden(b[idx]);
|
||
if (va.t === "vacio" && vb.t === "vacio") return 0;
|
||
if (va.t === "vacio") return 1; // vacíos siempre al final
|
||
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);
|
||
});
|
||
}
|
||
|
||
// Flecha del encabezado según el estado de orden de esa columna.
|
||
function _flecha(activo, dir) {
|
||
if (!activo) return <span style={{ opacity: 0.3, marginLeft: 4 }}>⇅</span>;
|
||
return <span style={{ marginLeft: 4 }}>{dir === "asc" ? "▲" : "▼"}</span>;
|
||
}
|
||
|
||
// Junta varias respuestas (una por mes) sumando por vendedor las columnas 1..7.
|
||
// Mantiene el mismo formato "S/ x" para montos y número para cantidades.
|
||
function _juntarMeses(listasFilas) {
|
||
const _n = (v) => { const x = parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(x)?0:x; };
|
||
const _esMonto = (v) => String(v).includes("S/");
|
||
const _fmt = (n, monto) => monto ? `S/ ${Math.round(n).toLocaleString("es-PE")}` : String(Math.round(n));
|
||
const acc = {}; // vendedor -> array acumulado
|
||
const orden = [];
|
||
listasFilas.forEach((filas) => {
|
||
(filas || []).forEach((f) => {
|
||
if (String(f[0]).toUpperCase() === "TOTAL GENERAL") return;
|
||
const v = f[0];
|
||
if (!acc[v]) { acc[v] = [v, 0,0,0,0,0,0,0]; orden.push(v); acc[v]._monto = f.map(_esMonto); }
|
||
for (let i = 1; i <= 7; i++) acc[v][i] = _n(acc[v][i]) + _n(f[i]);
|
||
});
|
||
});
|
||
const out = orden.map((v) => {
|
||
const a = acc[v]; const mon = a._monto || [];
|
||
const fila = [v];
|
||
for (let i = 1; i <= 7; i++) fila.push(_fmt(a[i], mon[i]));
|
||
return fila;
|
||
});
|
||
// TOTAL GENERAL sumando todos los vendedores.
|
||
if (out.length) {
|
||
const tot = ["TOTAL GENERAL"];
|
||
const monRef = (acc[orden[0]]._monto) || [];
|
||
for (let i = 1; i <= 7; i++) {
|
||
const s = out.reduce((sum, f) => sum + _n(f[i]), 0);
|
||
tot.push(_fmt(s, monRef[i]));
|
||
}
|
||
out.push(tot);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export default function Ventas() {
|
||
const hoy = new Date();
|
||
const [ano, setAno] = useState(hoy.getFullYear());
|
||
const [meses, setMeses] = useState([hoy.getMonth() + 1]); // multi-mes; inicia con el actual
|
||
const [sedes, setSedes] = useState([]); // vacío = todas
|
||
const [programas, setProgramas] = useState([]); // vacío = todos
|
||
const mesesKey = useMemo(() => [...meses].sort((a,b)=>a-b).join(","), [meses]);
|
||
const sedesKey = useMemo(() => [...sedes].sort().join(","), [sedes]);
|
||
const programasKey = useMemo(() => [...programas].sort().join(","), [programas]);
|
||
const [filas, setFilas] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [modal, setModal] = useState(null); // { vendedor }
|
||
const [ordCol, setOrdCol] = useState(null); // índice de columna ordenada
|
||
const [ordDir, setOrdDir] = useState(null); // "asc" | "desc"
|
||
const cols = useColumnasAjustables([200, 100, 130, 120, 130, 120, 130, 140, 100]);
|
||
|
||
// Click en encabezado: alterna asc -> desc -> sin orden.
|
||
const clickOrden = (i) => {
|
||
if (i === COLS.length - 1) return; // columna OPC. no se ordena
|
||
if (ordCol !== i) { setOrdCol(i); setOrdDir("asc"); }
|
||
else if (ordDir === "asc") setOrdDir("desc");
|
||
else { setOrdCol(null); setOrdDir(null); }
|
||
};
|
||
|
||
// 'mes' para modales/exportar = el menor mes marcado (o el actual si no hay).
|
||
const mes = meses.length ? Math.min(...meses) : (hoy.getMonth() + 1);
|
||
|
||
useEffect(() => {
|
||
let activo = true;
|
||
const lista = mesesKey ? mesesKey.split(",").map(Number) : [];
|
||
const sedeArg = sedesKey || "TODOS";
|
||
const progArg = programasKey || "TODOS";
|
||
setVistaActual(`ventas:${ano}|${mesesKey}|${sedeArg}|${progArg}|False`);
|
||
const cargar = () => {
|
||
setLoading(true); setError(null);
|
||
if (!lista.length) { setFilas([]); setLoading(false); return; }
|
||
// Una llamada por cada mes; se juntan sumando por vendedor (sin tocar la lógica del backend).
|
||
Promise.all(lista.map((m) => api.ventas(ano, m, sedeArg, progArg)))
|
||
.then((resArr) => {
|
||
if (!activo) return;
|
||
if (lista.length === 1) { setFilas(resArr[0].filas || []); setLoading(false); return; }
|
||
setFilas(_juntarMeses(resArr.map((r) => r.filas || [])));
|
||
setLoading(false);
|
||
})
|
||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||
};
|
||
cargar();
|
||
const off = onRefrescar(cargar);
|
||
return () => { activo = false; off(); };
|
||
}, [ano, mesesKey, sedesKey, programasKey]);
|
||
|
||
// Precarga en 2º plano: al elegir año/mes, calienta el caché del backend con cada
|
||
// sede y cada programa por separado, para que al marcar el filtro ya esté listo
|
||
// (sin "cargando"). No bloquea la UI; los resultados se descartan (solo cachean).
|
||
useEffect(() => {
|
||
const lista = mesesKey ? mesesKey.split(",").map(Number) : [];
|
||
if (!lista.length) return;
|
||
let cancelado = false;
|
||
const combos = [];
|
||
lista.forEach((m) => {
|
||
SEDES.forEach((s) => combos.push([m, s, "TODOS"]));
|
||
PROGRAMAS.forEach((p) => combos.push([m, "TODOS", p]));
|
||
});
|
||
// Dispara de a poco para no saturar el backend.
|
||
(async () => {
|
||
for (const [m, s, p] of combos) {
|
||
if (cancelado) return;
|
||
try { await api.ventas(ano, m, s, p); } catch (e) { /* ignora */ }
|
||
}
|
||
})();
|
||
return () => { cancelado = true; };
|
||
}, [ano, mesesKey]);
|
||
|
||
const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);
|
||
const datos = useMemo(() => {
|
||
const base = filas.filter((f) => f[0] !== "TOTAL GENERAL");
|
||
return _ordenarFilas(base, ordCol, ordDir);
|
||
}, [filas, ordCol, ordDir]);
|
||
|
||
const KPIS = total ? [
|
||
["👤","Vendedores", datos.length, "activos este mes"],
|
||
["🎓","Total Inscritos", total[1], "matriculados"],
|
||
["💵","Venta Total", total[2], "inversión neta"],
|
||
["✅","Venta P.C del Mes", total[4], "pagos completos"],
|
||
["🚀","Avance P.C Total", total[7], "P.C + Pendientes"],
|
||
] : [];
|
||
|
||
// Exportar saldos por cuota — Excel con formato (anchos, encabezado de color,
|
||
// fechas reales, montos numéricos en soles y fila TOTAL GENERAL).
|
||
async function exportarCuota(tipoCuota) {
|
||
const toNum = (v) => { const n = parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; };
|
||
// Convierte "dd-mm-yyyy" o "dd/mm/yyyy" a Date; si no puede, devuelve el texto original
|
||
const toDate = (v) => {
|
||
if (!v) return "";
|
||
const s = String(v).trim().replace(/\//g,"-");
|
||
const p = s.split("-");
|
||
if (p.length === 3) {
|
||
let [d,m,y] = p;
|
||
if (y.length === 4) return new Date(+y, +m-1, +d);
|
||
}
|
||
return String(v);
|
||
};
|
||
|
||
let res;
|
||
try {
|
||
res = await api.saldoPendiente(tipoCuota);
|
||
} catch (e) {
|
||
alert("No se pudo obtener los datos de saldos: " + e.message);
|
||
return;
|
||
}
|
||
const datosS = res.datos || [];
|
||
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
|
||
const filtrados = datosS.filter((d) => toNum(d[colSaldo]) > 0.01);
|
||
if (filtrados.length === 0) { alert(`No hay saldos pendientes para ${tipoCuota}.`); return; }
|
||
|
||
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||
const COL_MONTO = ["SALDO MAT.", colSaldo, "INV. NETA"];
|
||
const COL_FECHA = ["F. INICIO","F. MATR.","VENCIMIENTO"];
|
||
|
||
const XLSX = await import("xlsx-js-style");
|
||
|
||
// Construir matriz de celdas (AOA) con tipos correctos
|
||
const headerStyle = {
|
||
font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 },
|
||
fill: { fgColor: { rgb: "1E40AF" } },
|
||
alignment: { horizontal: "center", vertical: "center" },
|
||
};
|
||
const moneyFmt = '"S/" #,##0';
|
||
const dateFmt = "dd/mm/yyyy";
|
||
|
||
const ws = {};
|
||
const range = { s: { r: 0, c: 0 }, e: { r: filtrados.length + 1, c: COLS.length - 1 } };
|
||
|
||
// Encabezado
|
||
COLS.forEach((h, c) => {
|
||
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||
ws[ref] = { v: h, t: "s", s: headerStyle };
|
||
});
|
||
|
||
// Filas de datos
|
||
filtrados.forEach((d, ri) => {
|
||
KEYS.forEach((k, c) => {
|
||
const ref = XLSX.utils.encode_cell({ r: ri + 1, c });
|
||
const col = COLS[c];
|
||
if (COL_MONTO.includes(col)) {
|
||
ws[ref] = { v: toNum(d[k]), t: "n", z: moneyFmt };
|
||
} else if (COL_FECHA.includes(col)) {
|
||
const dt = toDate(d[k]);
|
||
if (dt instanceof Date) ws[ref] = { v: dt, t: "d", z: dateFmt };
|
||
else ws[ref] = { v: dt, t: "s" };
|
||
} else {
|
||
ws[ref] = { v: d[k] ?? "", t: "s" };
|
||
}
|
||
});
|
||
});
|
||
|
||
// Fila TOTAL GENERAL: promedio en SALDO MAT. y SALDO CUOTA, suma en INV. NETA
|
||
const rTot = filtrados.length + 1;
|
||
const sum = (k) => filtrados.reduce((s,d)=>s+toNum(d[k]),0);
|
||
const prom = (k) => filtrados.length ? sum(k)/filtrados.length : 0;
|
||
const totalStyle = { font: { bold: true }, fill: { fgColor: { rgb: "EFF6FF" } } };
|
||
COLS.forEach((col, c) => {
|
||
const ref = XLSX.utils.encode_cell({ r: rTot, c });
|
||
if (c === 0) ws[ref] = { v: "TOTAL GENERAL", t: "s", s: totalStyle };
|
||
else if (col === "SALDO MAT.") ws[ref] = { v: prom("SALDO MAT."), t: "n", z: moneyFmt, s: totalStyle };
|
||
else if (col === colSaldo) ws[ref] = { v: prom(colSaldo), t: "n", z: moneyFmt, s: totalStyle };
|
||
else if (col === "INV. NETA") ws[ref] = { v: sum("INV. NETA"), t: "n", z: moneyFmt, s: totalStyle };
|
||
else ws[ref] = { v: "", t: "s", s: totalStyle };
|
||
});
|
||
|
||
ws["!ref"] = XLSX.utils.encode_range(range);
|
||
ws["!cols"] = [
|
||
{ wch: 12 }, // MATRÍCULA
|
||
{ wch: 22 }, // VENDEDOR
|
||
{ wch: 28 }, // ALUMNO
|
||
{ wch: 32 }, // PROGRAMA
|
||
{ wch: 12 }, // F. INICIO
|
||
{ wch: 12 }, // F. MATR.
|
||
{ wch: 13 }, // SALDO MAT.
|
||
{ wch: 15 }, // SALDO CUOTA
|
||
{ wch: 13 }, // VENCIMIENTO
|
||
{ wch: 13 }, // INV. NETA
|
||
];
|
||
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, "Saldos");
|
||
const nombre = tipoCuota.replace("°","").replace(/\s/g,"_");
|
||
XLSX.writeFile(wb, `saldos_${nombre}.xlsx`);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<h1 className="page-title">🛒 Ventas</h1>
|
||
|
||
<Filters>
|
||
<SelectUnico label="Año" opciones={ANOS} valor={ano} onChange={(v) => setAno(+v)} minWidth={110} />
|
||
<MesesMulti label="Mes" meses={meses} onChange={setMeses} />
|
||
<ListaMulti label="Sede" opciones={SEDES} seleccion={sedes} onChange={setSedes} plural="sedes" />
|
||
<ListaMulti label="Programa" opciones={PROGRAMAS} seleccion={programas} onChange={setProgramas} plural="programas" />
|
||
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
|
||
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("1° Cuota")}>
|
||
⬇️ Exportar 1° Cuota
|
||
</button>
|
||
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("2° Cuota")}>
|
||
⬇️ Exportar 2° Cuota
|
||
</button>
|
||
</div>
|
||
</Filters>
|
||
|
||
{loading ? <Loader text="Calculando ventas..." /> :
|
||
error ? <ErrorBox msg={error} /> :
|
||
<>
|
||
<div className="kpis" style={{ gridTemplateColumns: "repeat(5,1fr)" }}>
|
||
{KPIS.map(([ico,label,val,sub]) => (
|
||
<div className="kpi" key={label}>
|
||
<div className="ico">{ico}</div>
|
||
<div className="label" style={{fontSize:12}}>{label}</div>
|
||
<div className="value" style={{fontSize:20}}>{val}</div>
|
||
<div className="sub">{sub}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||
<table {...cols.tableProps}>
|
||
<cols.ColGroup />
|
||
<thead><tr>{COLS.map((c, i) => {
|
||
const ordenable = i !== COLS.length - 1;
|
||
return (
|
||
<th key={c} style={{position:"relative", cursor: ordenable ? "pointer" : "default", userSelect:"none"}}
|
||
onClick={() => clickOrden(i)}>
|
||
{c}{ordenable && _flecha(ordCol === i, ordDir)}<cols.Resizer index={i} />
|
||
</th>
|
||
);
|
||
})}</tr></thead>
|
||
<tbody>
|
||
{datos.map((f, i) => (
|
||
<tr key={i}>
|
||
<td className="col-name">{f[0]}</td>
|
||
<td>{f[1]}</td><td>{f[2]}</td>
|
||
<td>{f[3]}</td><td>{f[4]}</td>
|
||
<td>{f[5]}</td><td>{f[6]}</td>
|
||
<td style={{fontWeight:600}}>{f[7]}</td>
|
||
<td>
|
||
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
|
||
onClick={() => setModal({ vendedor: f[0] })}>👁️ Ver</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{total && (
|
||
<tr className="total-row">
|
||
<td className="col-name">TOTAL GENERAL</td>
|
||
<td>{total[1]}</td><td>{total[2]}</td>
|
||
<td>{total[3]}</td><td>{total[4]}</td>
|
||
<td>{total[5]}</td><td>{total[6]}</td>
|
||
<td>{total[7]}</td><td>—</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>}
|
||
|
||
{modal && (
|
||
<ModalDetalle vendedor={modal.vendedor} ano={ano} mes={mes}
|
||
sede={sedesKey || "TODOS"} programa={programasKey || "TODOS"}
|
||
onClose={() => setModal(null)} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ModalDetalle({ vendedor, ano, mes, sede = "TODOS", programa = "TODOS", onClose }) {
|
||
const [tipo, setTipo] = useState(TIPOS[0]);
|
||
const [filas, setFilas] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [ordCol, setOrdCol] = useState(null);
|
||
const [ordDir, setOrdDir] = useState(null);
|
||
|
||
const clickOrden = (i) => {
|
||
if (ordCol !== i) { setOrdCol(i); setOrdDir("asc"); }
|
||
else if (ordDir === "asc") setOrdDir("desc");
|
||
else { setOrdCol(null); setOrdDir(null); }
|
||
};
|
||
|
||
useEffect(() => {
|
||
let activo = true;
|
||
setLoading(true);
|
||
api.ventasDetalle(vendedor, ano, mes, tipo, sede, programa, true)
|
||
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
|
||
return () => { activo = false; };
|
||
}, [vendedor, ano, mes, tipo, sede, programa]);
|
||
|
||
// Columnas redimensionables (igual que las tablas principales)
|
||
const colsDet = useColumnasAjustables([200, 260, 120, 130, 120, 120, 130, 120]);
|
||
const filasOrd = useMemo(() => {
|
||
const _n = (v) => { const n = parseFloat(String(v ?? "").replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; };
|
||
const _m = (v) => "S/ " + Math.round(v).toLocaleString("es-PE");
|
||
// Ignorar el TOTAL que venga del backend y recalcularlo siempre desde los alumnos
|
||
// (al filtrar por sede/programa el backend NO envía la fila TOTAL).
|
||
const alumnos = filas.filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL");
|
||
const ordenadas = _ordenarFilas(alumnos, ordCol == null ? null : IDX_DETALLE[ordCol], ordDir);
|
||
if (alumnos.length === 0) return ordenadas;
|
||
// Fila TOTAL: INV.NETA[7] y DESC[6] suman; CUOTA PROM[5] promedio de los que tienen valor.
|
||
let sInv = 0, sDesc = 0, sCuota = 0, nCuota = 0;
|
||
alumnos.forEach((f) => {
|
||
sInv += _n(f[7]); sDesc += _n(f[6]);
|
||
const c = _n(f[5]); if (c > 0) { sCuota += c; nCuota += 1; }
|
||
});
|
||
const total = [];
|
||
total[0] = "TOTAL GENERAL";
|
||
total[5] = nCuota ? _m(sCuota / nCuota) : "";
|
||
total[6] = sDesc ? _m(sDesc) : "";
|
||
total[7] = _m(sInv);
|
||
return [...ordenadas, total];
|
||
}, [filas, ordCol, ordDir]);
|
||
|
||
return (
|
||
<Modal title={`👤 ${vendedor}`} onClose={onClose} width={1500}>
|
||
<div style={{ marginBottom: 14 }}>
|
||
<Select label="Tipo de venta" value={tipo} options={TIPOS} onChange={setTipo} />
|
||
</div>
|
||
{loading ? <Loader text="Cargando detalle..." /> :
|
||
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin registros para esta categoría.</div> :
|
||
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||
<table {...colsDet.tableProps}>
|
||
<colsDet.ColGroup />
|
||
<thead><tr>{HEADERS_DETALLE.map((h, i) => (
|
||
<th key={h} style={{position:"relative", whiteSpace:"nowrap", cursor:"pointer", userSelect:"none"}}
|
||
onClick={() => clickOrden(i)}>
|
||
{h}{_flecha(ordCol === i, ordDir)}<colsDet.Resizer index={i} />
|
||
</th>
|
||
))}</tr></thead>
|
||
<tbody>
|
||
{filasOrd.map((f, i) => {
|
||
const esTotal = String(f[0]).toUpperCase() === "TOTAL GENERAL";
|
||
return (
|
||
<tr key={i}>
|
||
{IDX_DETALLE.map((idx, j) => {
|
||
const base = { textAlign:"center", whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis" };
|
||
if (esTotal) {
|
||
const st = { ...base, background:"#eff6ff", fontWeight:700, color:"#1e40af", borderTop:"2px solid #bfdbfe" };
|
||
return <td key={j} style={st}>{j === 0 ? "TOTAL GENERAL" : f[idx]}</td>;
|
||
}
|
||
return (
|
||
<td key={j} className={j === 0 ? "col-name" : ""} style={base}>{f[idx]}</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>}
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// Filtro multi-selección con casillas (Sede, Programa). Vacío = todos.
|
||
function ListaMulti({ label, opciones, seleccion, onChange, plural = "opciones", minWidth = 150 }) {
|
||
const [abierto, setAbierto] = useState(false);
|
||
const ref = useRef(null);
|
||
useEffect(() => {
|
||
const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); };
|
||
document.addEventListener("mousedown", fuera);
|
||
return () => document.removeEventListener("mousedown", fuera);
|
||
}, []);
|
||
const toggle = (v) => {
|
||
const set = new Set(seleccion);
|
||
set.has(v) ? set.delete(v) : set.add(v);
|
||
onChange([...set]);
|
||
};
|
||
const texto = seleccion.length === 0 ? "— Elegir —"
|
||
: seleccion.length === 1 ? seleccion[0]
|
||
: `${seleccion.length} ${plural}`;
|
||
return (
|
||
<div className="filter-group" ref={ref} style={{ position: "relative", minWidth }}>
|
||
<label>{label}</label>
|
||
<button type="button" onClick={() => setAbierto((v) => !v)}
|
||
style={{ height: 38, width: "100%", textAlign: "left", padding: "0 12px",
|
||
border: "1px solid #cbd5e1", borderRadius: 8, background: "#fff",
|
||
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center",
|
||
justifyContent: "space-between", gap: 8 }}>
|
||
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{texto}</span>
|
||
<span style={{ color: "#94a3b8", fontSize: 11 }}>▼</span>
|
||
</button>
|
||
{abierto && (
|
||
<div style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||
background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8,
|
||
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", padding: 6,
|
||
maxHeight: 300, overflowY: "auto" }}>
|
||
{opciones.map((o) => {
|
||
const on = seleccion.includes(o);
|
||
return (
|
||
<label key={o} style={{ display: "flex", alignItems: "center", gap: 8,
|
||
padding: "6px 8px", borderRadius: 6, cursor: "pointer", fontSize: 13,
|
||
background: on ? "#eff6ff" : "transparent" }}>
|
||
<input type="checkbox" checked={on} onChange={() => toggle(o)} />
|
||
{o}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Desplegable de UNA sola opción (Año), con el mismo estilo bonito.
|
||
function SelectUnico({ label, opciones, valor, onChange, minWidth = 150 }) {
|
||
const [abierto, setAbierto] = useState(false);
|
||
const ref = useRef(null);
|
||
useEffect(() => {
|
||
const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); };
|
||
document.addEventListener("mousedown", fuera);
|
||
return () => document.removeEventListener("mousedown", fuera);
|
||
}, []);
|
||
return (
|
||
<div className="filter-group" ref={ref} style={{ position: "relative", minWidth }}>
|
||
<label>{label}</label>
|
||
<button type="button" onClick={() => setAbierto((v) => !v)}
|
||
style={{ height: 38, width: "100%", textAlign: "left", padding: "0 12px",
|
||
border: "1px solid #cbd5e1", borderRadius: 8, background: "#fff",
|
||
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center",
|
||
justifyContent: "space-between", gap: 8 }}>
|
||
<span>{valor}</span>
|
||
<span style={{ color: "#94a3b8", fontSize: 11 }}>▼</span>
|
||
</button>
|
||
{abierto && (
|
||
<div style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||
background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8,
|
||
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", padding: 6, maxHeight: 300, overflowY: "auto" }}>
|
||
{opciones.map((o) => {
|
||
const on = String(o) === String(valor);
|
||
return (
|
||
<div key={o} onClick={() => { onChange(o); setAbierto(false); }}
|
||
style={{ padding: "8px 10px", borderRadius: 6, cursor: "pointer", fontSize: 13,
|
||
fontWeight: on ? 700 : 500, color: on ? "#1e3a5f" : "#334155",
|
||
background: on ? "#eff6ff" : "transparent" }}
|
||
onMouseEnter={(e) => { if (!on) e.currentTarget.style.background = "#f8fafc"; }}
|
||
onMouseLeave={(e) => { if (!on) e.currentTarget.style.background = "transparent"; }}>
|
||
{o}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Filtro de MES con casillas (multi). meses = array de 1..12.
|
||
function MesesMulti({ label, meses, onChange, minWidth = 190 }) {
|
||
const [abierto, setAbierto] = useState(false);
|
||
const ref = useRef(null);
|
||
useEffect(() => {
|
||
const fuera = (e) => { if (ref.current && !ref.current.contains(e.target)) setAbierto(false); };
|
||
document.addEventListener("mousedown", fuera);
|
||
return () => document.removeEventListener("mousedown", fuera);
|
||
}, []);
|
||
const toggle = (m) => {
|
||
const set = new Set(meses);
|
||
set.has(m) ? set.delete(m) : set.add(m);
|
||
onChange([...set].sort((a, b) => a - b));
|
||
};
|
||
const texto = meses.length === 0 ? "— Elegir —"
|
||
: meses.length === 1 ? MESES[meses[0] - 1]
|
||
: meses.length === 12 ? "TODOS"
|
||
: `${meses.length} meses`;
|
||
return (
|
||
<div className="filter-group" ref={ref} style={{ position: "relative", minWidth }}>
|
||
<label>{label}</label>
|
||
<button type="button" onClick={() => setAbierto((v) => !v)}
|
||
style={{ height: 38, width: "100%", textAlign: "left", padding: "0 12px",
|
||
border: "1px solid #cbd5e1", borderRadius: 8, background: "#fff",
|
||
cursor: "pointer", fontSize: 13, display: "flex", alignItems: "center",
|
||
justifyContent: "space-between", gap: 8 }}>
|
||
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{texto}</span>
|
||
<span style={{ color: "#94a3b8", fontSize: 11 }}>▼</span>
|
||
</button>
|
||
{abierto && (
|
||
<div style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||
background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8,
|
||
boxShadow: "0 8px 24px rgba(0,0,0,0.12)", padding: 6, maxHeight: 300, overflowY: "auto" }}>
|
||
{MESES.map((m, i) => {
|
||
const val = i + 1;
|
||
const on = meses.includes(val);
|
||
return (
|
||
<label key={m} style={{ display: "flex", alignItems: "center", gap: 8,
|
||
padding: "6px 8px", borderRadius: 6, cursor: "pointer", fontSize: 13,
|
||
background: on ? "#eff6ff" : "transparent" }}>
|
||
<input type="checkbox" checked={on} onChange={() => toggle(val)} />
|
||
{m}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|