Añadir frontend/src/pages/Facturacion.jsx
This commit is contained in:
319
frontend/src/pages/Facturacion.jsx
Normal file
319
frontend/src/pages/Facturacion.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user