Actualizar frontend/src/pages/Ventas.jsx
This commit is contained in:
@@ -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 <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 [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() {
|
||||
<h1 className="page-title">🛒 Ventas</h1>
|
||||
|
||||
<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="Programa" value={programa} options={PROGRAMAS} onChange={setPrograma} />
|
||||
<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
|
||||
@@ -346,3 +419,147 @@ function ModalDetalle({ vendedor, ano, mes, onClose }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user