Files
dashboard-completo/frontend/src/pages/Ventas.jsx

600 lines
26 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}