diff --git a/frontend/src/pages/Ventas.jsx b/frontend/src/pages/Ventas.jsx index 886240f..c0f1487 100644 --- a/frontend/src/pages/Ventas.jsx +++ b/frontend/src/pages/Ventas.jsx @@ -1,5 +1,5 @@ // src/pages/Ventas.jsx -import { useState, useEffect, useMemo } from "react"; +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"; @@ -8,8 +8,8 @@ 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 = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; -const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; +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 = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"]; @@ -55,12 +55,50 @@ function _flecha(activo, dir) { return {dir === "asc" ? "▲" : "▼"}; } +// 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 [mes, setMes] = useState(hoy.getMonth() + 1); - const [sede, setSede] = useState("TODOS"); - const [programa, setPrograma] = useState("TODOS"); + 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); @@ -77,19 +115,54 @@ export default function Ventas() { 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; - setVistaActual(`ventas:${ano}|${mes}|${sede}|${programa}|False`); + 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); - api.ventas(ano, mes, sede, programa) - .then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) + 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, mes, sede, programa]); + }, [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(() => { @@ -214,10 +287,10 @@ export default function Ventas() {

🛒 Ventas

- ({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> - + setAno(+v)} minWidth={110} /> + + +
+ {abierto && ( +
+ {opciones.map((o) => { + const on = seleccion.includes(o); + return ( + + ); + })} +
+ )} +
+ ); +} + +// 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 ( +
+ + + {abierto && ( +
+ {opciones.map((o) => { + const on = String(o) === String(valor); + return ( +
{ 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} +
+ ); + })} +
+ )} +
+ ); +} + +// 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 ( +
+ + + {abierto && ( +
+ {MESES.map((m, i) => { + const val = i + 1; + const on = meses.includes(val); + return ( + + ); + })} +
+ )} +
+ ); +}