Actualizar frontend/src/pages/Ventas.jsx

This commit is contained in:
2026-09-23 10:29:53 -05:00
parent 2ea839c529
commit 2c395d51d8

View File

@@ -1,5 +1,5 @@
// src/pages/Ventas.jsx // src/pages/Ventas.jsx
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo, useRef } from "react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { Loader, ErrorBox, Filters, Select } from "../components/UI"; import { Loader, ErrorBox, Filters, Select } from "../components/UI";
import Modal from "../components/Modal"; 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 MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
const ANOS = [2024, 2025, 2026]; const ANOS = [2024, 2025, 2026];
const SEDES = ["TODOS", "LIMA", "AREQUIPA", "PIURA", "TRUJILLO"]; const SEDES = ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
const PROGRAMAS = ["TODOS", "SEMINARIOS", "OTROS", "TEAC", "TERC"]; 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 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 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"]; 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 <span style={{ marginLeft: 4 }}>{dir === "asc" ? "▲" : "▼"}</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() { export default function Ventas() {
const hoy = new Date(); const hoy = new Date();
const [ano, setAno] = useState(hoy.getFullYear()); const [ano, setAno] = useState(hoy.getFullYear());
const [mes, setMes] = useState(hoy.getMonth() + 1); const [meses, setMeses] = useState([hoy.getMonth() + 1]); // multi-mes; inicia con el actual
const [sede, setSede] = useState("TODOS"); const [sedes, setSedes] = useState([]); // vacío = todas
const [programa, setPrograma] = useState("TODOS"); 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 [filas, setFilas] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -77,19 +115,54 @@ export default function Ventas() {
else { setOrdCol(null); setOrdDir(null); } 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(() => { useEffect(() => {
let activo = true; 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 = () => { const cargar = () => {
setLoading(true); setError(null); setLoading(true); setError(null);
api.ventas(ano, mes, sede, programa) if (!lista.length) { setFilas([]); setLoading(false); return; }
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } }) // 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); } }); .catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
}; };
cargar(); cargar();
const off = onRefrescar(cargar); const off = onRefrescar(cargar);
return () => { activo = false; off(); }; 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 total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);
const datos = useMemo(() => { const datos = useMemo(() => {
@@ -214,10 +287,10 @@ export default function Ventas() {
<h1 className="page-title">🛒 Ventas</h1> <h1 className="page-title">🛒 Ventas</h1>
<Filters> <Filters>
<Select label="Año" value={ano} options={ANOS} onChange={(v) => setAno(+v)} /> <SelectUnico label="Año" opciones={ANOS} valor={ano} onChange={(v) => setAno(+v)} minWidth={110} />
<Select label="Mes" value={mes} options={MESES.map((m,i)=>({value:i+1,label:m}))} onChange={(v) => setMes(+v)} /> <MesesMulti label="Mes" meses={meses} onChange={setMeses} />
<Select label="Sede" value={sede} options={SEDES} onChange={setSede} /> <ListaMulti label="Sede" opciones={SEDES} seleccion={sedes} onChange={setSedes} plural="sedes" />
<Select label="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} /> <ListaMulti label="Programa" opciones={PROGRAMAS} seleccion={programas} onChange={setProgramas} plural="programas" />
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}> <div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("1° Cuota")}> <button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("1° Cuota")}>
⬇️ Exportar 1° Cuota ⬇️ Exportar 1° Cuota
@@ -346,3 +419,147 @@ function ModalDetalle({ vendedor, ano, mes, onClose }) {
</Modal> </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>
);
}