From c0caafa9e3d2655e4fe50c2b9db97926f40dffff Mon Sep 17 00:00:00 2001 From: Administrator Date: Fri, 9 Oct 2026 13:28:38 -0500 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20frontend/src/pages/Facturacion.js?= =?UTF-8?q?x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Facturacion.jsx | 319 +++++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 frontend/src/pages/Facturacion.jsx diff --git a/frontend/src/pages/Facturacion.jsx b/frontend/src/pages/Facturacion.jsx new file mode 100644 index 0000000..7102284 --- /dev/null +++ b/frontend/src/pages/Facturacion.jsx @@ -0,0 +1,319 @@ +// src/pages/Facturacion.jsx +// Facturación: Ingresos − Notas de Crédito = Neto, por Sede → Tipo (expandible). +import { useState, useEffect, useRef, useMemo } from "react"; +import { api } from "../lib/api"; +import { Loader, ErrorBox, Filters } from "../components/UI"; + +const MESES = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"]; +const ANOS = [2024, 2025, 2026]; + +const fmt = (n) => (n || 0).toLocaleString("es-PE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +export default function Facturacion() { + const hoy = new Date(); + const [ano, setAno] = useState(hoy.getFullYear()); + const [meses, setMeses] = useState([hoy.getMonth() + 1]); // multi-mes + const [cuotas, setCuotas] = useState([]); // multi-cuota ([] = todas) + const [cuotasOpc, setCuotasOpc] = useState([]); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [abiertos, setAbiertos] = useState({}); // sede -> bool + + const mesesKey = useMemo(() => [...meses].sort((a, b) => a - b).join(","), [meses]); + const cuotasKey = useMemo(() => [...cuotas].join(","), [cuotas]); + + useEffect(() => { + api.facturacionCuotas().then((r) => setCuotasOpc(r.cuotas || [])).catch(() => {}); + }, []); + + useEffect(() => { + let activo = true; + setLoading(true); setError(""); + const mesArg = meses.length ? mesesKey : "TODOS"; + const cuoArg = cuotas.length ? cuotasKey : "TODOS"; + api.facturacion(ano, mesArg, cuoArg) + .then((r) => { if (activo) { setData(r); setLoading(false); } }) + .catch((e) => { if (activo) { setError(e.message); setLoading(false); } }); + return () => { activo = false; }; + }, [ano, mesesKey, cuotasKey]); + + const toggle = (sede) => setAbiertos((p) => ({ ...p, [sede]: !p[sede] })); + + const th = { position: "sticky", top: 0, background: "#1e3a5f", color: "#fff", + padding: "11px 14px", fontSize: 13, fontWeight: 600, zIndex: 10, textAlign: "center" }; + const tdNum = { padding: "9px 14px", fontSize: 13, textAlign: "center", fontVariantNumeric: "tabular-nums" }; + + return ( +
+

🧾 Facturación

+ + + setAno(+v)} minWidth={120} /> + + `Cuota ${c}`} minWidth={170} /> + + + {loading ? + : error ? + : ( +
+ + + + + + + + + + + {(data?.filas || []).map((f) => ( + toggle(f.sede)} tdNum={tdNum} /> + ))} + {(data?.filas || []).length === 0 && ( + + )} + + + + + + + + + +
Sede / TipoIngresosNotas de CréditoNeto
Sin datos.
TOTAL{fmt(data?.total?.ingresos)}{fmt(data?.total?.notas_credito)}{fmt(data?.total?.neto)}
+
+ )} +
+ ); +} + +function Caja({ open }) { + return ( + + {open ? "−" : "+"} + + ); +} + +function FilaSede({ f, open, onToggle, tdNum }) { + const [tiposAbiertos, setTiposAbiertos] = useState({}); + const toggleTipo = (tipo) => setTiposAbiertos((p) => ({ ...p, [tipo]: !p[tipo] })); + return ( + <> + e.currentTarget.style.background = "#f8fafc"} + onMouseLeave={(e) => e.currentTarget.style.background = "#fff"}> + + {f.sede} + + {fmt(f.ingresos)} + {fmt(f.notas_credito)} + {fmt(f.neto)} + + {open && (f.tipos || []).map((t) => { + const tOpen = !!tiposAbiertos[t.tipo]; + return ( + toggleTipo(t.tipo)} tdNum={tdNum} /> + ); + })} + + ); +} + +function FragmentoTipo({ t, open, onToggle, tdNum }) { + return ( + <> + e.currentTarget.style.background = "#f1f5f9"} + onMouseLeave={(e) => e.currentTarget.style.background = "#fbfcfe"}> + + {t.tipo} + + {fmt(t.ingresos)} + {fmt(t.notas_credito)} + {fmt(t.neto)} + + {open && (t.programas || []).map((p) => ( + + ))} + + ); +} + +function FragmentoPrograma({ p, tdNum }) { + const [open, setOpen] = useState(false); + return ( + <> + setOpen((v) => !v)} + style={{ cursor: "pointer", background: "#fff", borderBottom: "1px solid #f4f6f9" }} + onMouseEnter={(e) => e.currentTarget.style.background = "#f8fafc"} + onMouseLeave={(e) => e.currentTarget.style.background = "#fff"}> + + {p.programa} + + {fmt(p.ingresos)} + {fmt(p.notas_credito)} + {fmt(p.neto)} + + {open && (p.comprobantes || []).map((c) => ( + + {c.comprobante} + {fmt(c.ingresos)} + {fmt(c.notas_credito)} + {fmt(c.neto)} + + ))} + + ); +} + +// ── Filtros con el mismo diseño de los otros apartados ────────────────────── +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} +
+ ); + })} +
+ )} +
+ ); +} + +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 ( + + ); + })} +
+ )} +
+ ); +} + +function ListaMulti({ label, opciones, seleccion, onChange, plural = "opciones", fmtItem, 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 etiqueta = (o) => (fmtItem ? fmtItem(o) : o); + const texto = seleccion.length === 0 ? "TODAS" + : seleccion.length === 1 ? etiqueta(seleccion[0]) + : `${seleccion.length} ${plural}`; + return ( +
+ + + {abierto && ( +
+ {opciones.length === 0 &&
Sin cuotas
} + {opciones.map((o) => { + const on = seleccion.includes(o); + return ( + + ); + })} +
+ )} +
+ ); +}