Añadir frontend/src/pages/Facturacion.jsx

This commit is contained in:
2026-10-09 13:28:38 -05:00
parent 7052d8bc90
commit c0caafa9e3

View File

@@ -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 (
<div style={{ padding: "18px 24px" }}>
<h2 style={{ margin: "0 0 14px", color: "#0f172a" }}>🧾 Facturación</h2>
<Filters>
<SelectUnico label="Año" opciones={ANOS} valor={ano} onChange={(v) => setAno(+v)} minWidth={120} />
<MesesMulti label="Mes" meses={meses} onChange={setMeses} />
<ListaMulti label="Cuota" opciones={cuotasOpc} seleccion={cuotas} onChange={setCuotas} plural="cuotas" fmtItem={(c) => `Cuota ${c}`} minWidth={170} />
</Filters>
{loading ? <Loader text="Cargando facturación..." />
: error ? <ErrorBox msg={error} />
: (
<div style={{ marginTop: 16, border: "1px solid #e2e8f0", borderRadius: 12, overflow: "auto", maxHeight: "72vh" }}>
<table style={{ width: "100%", borderCollapse: "separate", borderSpacing: 0 }}>
<thead>
<tr>
<th style={{ ...th, borderTopLeftRadius: 12 }}>Sede / Tipo</th>
<th style={th}>Ingresos</th>
<th style={th}>Notas de Crédito</th>
<th style={{ ...th, borderTopRightRadius: 12 }}>Neto</th>
</tr>
</thead>
<tbody>
{(data?.filas || []).map((f) => (
<FilaSede key={f.sede} f={f} open={!!abiertos[f.sede]} onToggle={() => toggle(f.sede)} tdNum={tdNum} />
))}
{(data?.filas || []).length === 0 && (
<tr><td colSpan={4} style={{ padding: 30, textAlign: "center", color: "#94a3b8" }}>Sin datos.</td></tr>
)}
</tbody>
<tfoot>
<tr style={{ background: "#eef4fb", fontWeight: 700 }}>
<td style={{ padding: "11px 14px", fontSize: 13, textAlign: "center" }}>TOTAL</td>
<td style={tdNum}>{fmt(data?.total?.ingresos)}</td>
<td style={tdNum}>{fmt(data?.total?.notas_credito)}</td>
<td style={tdNum}>{fmt(data?.total?.neto)}</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
);
}
function Caja({ open }) {
return (
<span style={{ display: "inline-flex", width: 18, height: 18, borderRadius: 4,
border: "1px solid #cbd5e1", alignItems: "center", justifyContent: "center",
marginRight: 10, fontSize: 13, color: "#475569", background: "#f1f5f9" }}>
{open ? "−" : "+"}
</span>
);
}
function FilaSede({ f, open, onToggle, tdNum }) {
const [tiposAbiertos, setTiposAbiertos] = useState({});
const toggleTipo = (tipo) => setTiposAbiertos((p) => ({ ...p, [tipo]: !p[tipo] }));
return (
<>
<tr onClick={onToggle}
style={{ cursor: "pointer", background: "#fff", borderBottom: "1px solid #eef2f7" }}
onMouseEnter={(e) => e.currentTarget.style.background = "#f8fafc"}
onMouseLeave={(e) => e.currentTarget.style.background = "#fff"}>
<td style={{ padding: "10px 14px", fontSize: 13.5, fontWeight: 700, color: "#1e293b", textAlign: "left" }}>
<Caja open={open} />{f.sede}
</td>
<td style={{ ...tdNum, fontWeight: 700 }}>{fmt(f.ingresos)}</td>
<td style={{ ...tdNum, fontWeight: 700 }}>{fmt(f.notas_credito)}</td>
<td style={{ ...tdNum, fontWeight: 700 }}>{fmt(f.neto)}</td>
</tr>
{open && (f.tipos || []).map((t) => {
const tOpen = !!tiposAbiertos[t.tipo];
return (
<FragmentoTipo key={f.sede + "-" + t.tipo} t={t} open={tOpen}
onToggle={() => toggleTipo(t.tipo)} tdNum={tdNum} />
);
})}
</>
);
}
function FragmentoTipo({ t, open, onToggle, tdNum }) {
return (
<>
<tr onClick={onToggle}
style={{ cursor: "pointer", background: "#fbfcfe", borderBottom: "1px solid #f1f5f9" }}
onMouseEnter={(e) => e.currentTarget.style.background = "#f1f5f9"}
onMouseLeave={(e) => e.currentTarget.style.background = "#fbfcfe"}>
<td style={{ padding: "8px 14px 8px 40px", fontSize: 13, fontWeight: 600, color: "#334155", textAlign: "left" }}>
<Caja open={open} />{t.tipo}
</td>
<td style={{ ...tdNum, fontWeight: 600 }}>{fmt(t.ingresos)}</td>
<td style={{ ...tdNum, fontWeight: 600 }}>{fmt(t.notas_credito)}</td>
<td style={{ ...tdNum, fontWeight: 600 }}>{fmt(t.neto)}</td>
</tr>
{open && (t.programas || []).map((p) => (
<FragmentoPrograma key={t.tipo + "-" + p.programa} p={p} tdNum={tdNum} />
))}
</>
);
}
function FragmentoPrograma({ p, tdNum }) {
const [open, setOpen] = useState(false);
return (
<>
<tr onClick={() => 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"}>
<td style={{ padding: "7px 14px 7px 76px", fontSize: 12.5, color: "#475569", textAlign: "left" }}>
<Caja open={open} />{p.programa}
</td>
<td style={tdNum}>{fmt(p.ingresos)}</td>
<td style={tdNum}>{fmt(p.notas_credito)}</td>
<td style={tdNum}>{fmt(p.neto)}</td>
</tr>
{open && (p.comprobantes || []).map((c) => (
<tr key={p.programa + "-" + c.comprobante} style={{ background: "#fcfdff", borderBottom: "1px solid #f6f8fb" }}>
<td style={{ padding: "6px 14px 6px 108px", fontSize: 12, color: "#94a3b8", textAlign: "left" }}>{c.comprobante}</td>
<td style={tdNum}>{fmt(c.ingresos)}</td>
<td style={tdNum}>{fmt(c.notas_credito)}</td>
<td style={tdNum}>{fmt(c.neto)}</td>
</tr>
))}
</>
);
}
// ── 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 (
<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>
);
}
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>
);
}
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 (
<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.length === 0 && <div style={{ padding: 8, color: "#94a3b8", fontSize: 13 }}>Sin cuotas</div>}
{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)} />
{etiqueta(o)}
</label>
);
})}
</div>
)}
</div>
);
}