Solucionado submodulos y subiendo codigo real
This commit is contained in:
270
frontend/src/pages/Rentabilidad.jsx
Normal file
270
frontend/src/pages/Rentabilidad.jsx
Normal file
@@ -0,0 +1,270 @@
|
||||
// src/pages/Rentabilidad.jsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||
import Modal from "../components/Modal";
|
||||
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||
|
||||
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 COLS = ["PROGRAMA","FECHA INICIO","TOTAL","RETIRADOS","EN CURSO","CUOTA PROM.","PROM. DESC. E.","V. VENTA INICIAL","V. COSTO INICIAL","M.B. INICIAL %","V. VENTA ACTUAL","V. COSTO ACTUAL","M.B. ACTUAL %","OPC."];
|
||||
// índices: inicial = 7,8,9 | actual = 10,11,12
|
||||
const BG_INI = "#eef4ff", BG_ACT = "#edfff6";
|
||||
|
||||
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").replace("%","").trim()); return isNaN(n)?0:n; }
|
||||
|
||||
export default function Rentabilidad() {
|
||||
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 [mostrarInscritos, setMostrarInscritos] = useState(true); // TOTAL, RETIRADOS, EN CURSO
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [modalVer, setModalVer] = useState(null);
|
||||
// Índices de columnas TOTAL(2), RETIRADOS(3), EN CURSO(4) — se ocultan si el check está apagado
|
||||
const IDX_INSCRITOS = [2, 3, 4];
|
||||
const ocultar = (i) => !mostrarInscritos && IDX_INSCRITOS.includes(i);
|
||||
const ANCHOS_BASE = [260, 110, 80, 90, 90, 110, 120, 120, 120, 110, 120, 120, 110, 80];
|
||||
const anchosVisibles = ANCHOS_BASE.filter((_, i) => !ocultar(i));
|
||||
const cols = useColumnasAjustables(anchosVisibles);
|
||||
const [modalCostos, setModalCostos] = useState(null);
|
||||
|
||||
function cargar() {
|
||||
setLoading(true); setError(null);
|
||||
api.rentabilidad(ano, mes, sede, programa)
|
||||
.then((res) => { setFilas(res.filas || []); setLoading(false); })
|
||||
.catch((e) => { setError(e.message); setLoading(false); });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setLoading(true); setError(null);
|
||||
api.rentabilidad(ano, mes, sede, programa)
|
||||
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||
return () => { activo = false; };
|
||||
}, [ano, mes, sede, programa]);
|
||||
|
||||
const datos = useMemo(() => filas.filter((f)=>String(f[0]).toUpperCase()!=="TOTAL GENERAL"), [filas]);
|
||||
const total = useMemo(() => filas.find((f)=>String(f[0]).toUpperCase()==="TOTAL GENERAL"), [filas]);
|
||||
|
||||
const kpis = useMemo(() => {
|
||||
if (!total) return null;
|
||||
return {
|
||||
programas: datos.length,
|
||||
enCurso: total[4],
|
||||
ventaAct: total[10],
|
||||
costoAct: total[11],
|
||||
mbAct: total[12],
|
||||
};
|
||||
}, [datos, total]);
|
||||
|
||||
function bgCol(ci){ if([7,8,9].includes(ci))return BG_INI; if([10,11,12].includes(ci))return BG_ACT; return ""; }
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">📈 Rentabilidad</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} />
|
||||
<div className="filter-group" style={{marginLeft:"auto"}}>
|
||||
<label>Inscritos</label>
|
||||
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
|
||||
<input type="checkbox" checked={mostrarInscritos} onChange={(e)=>setMostrarInscritos(e.target.checked)} />
|
||||
Total / Retirados / En curso
|
||||
</label>
|
||||
</div>
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Calculando rentabilidad..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
{kpis && (
|
||||
<div className="kpis" style={{gridTemplateColumns:"repeat(5,1fr)"}}>
|
||||
{[["📚","Programas",kpis.programas,"este mes"],
|
||||
["🎓","En Curso",kpis.enCurso,"alumnos activos"],
|
||||
["💵","Venta Actual",kpis.ventaAct,"ingreso total"],
|
||||
["💸","Costo Actual",kpis.costoAct,"gasto total"],
|
||||
["📊","M.B. Actual",kpis.mbAct,"margen bruto"]].map(([ico,label,val,sub])=>(
|
||||
<div className="kpi" key={label}>
|
||||
<div className="ico">{ico}</div><div className="label">{label}</div>
|
||||
<div className="value" style={{fontSize:18}}>{val}</div><div className="sub">{sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-wrap" style={{marginTop:16, overflowX:"auto"}}>
|
||||
<table {...cols.tableProps}>
|
||||
<cols.ColGroup />
|
||||
<thead><tr>{COLS.filter((_,i)=>!ocultar(i)).map((c, vi)=><th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={vi} /></th>)}</tr></thead>
|
||||
<tbody>
|
||||
{datos.map((f, ri) => (
|
||||
<tr key={ri}>
|
||||
{f.slice(0,13).map((v,ci)=>{
|
||||
if (ocultar(ci)) return null;
|
||||
const isPct = ci===9||ci===12;
|
||||
return (
|
||||
<td key={ci} className={ci===0?"col-name":""}
|
||||
style={{background:bgCol(ci)||undefined,
|
||||
textAlign: ci===0?"left":"center",
|
||||
fontWeight:isPct?700:undefined,
|
||||
minWidth:ci===0?260:undefined}}>{v}</td>
|
||||
);
|
||||
})}
|
||||
<td style={{whiteSpace:"nowrap"}}>
|
||||
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11,marginRight:4}}
|
||||
onClick={()=>setModalVer({programa:f[0]})}>👁️</button>
|
||||
<button className="btn btn-ghost" style={{padding:"3px 8px",fontSize:11}}
|
||||
onClick={()=>setModalCostos({programa:f[0]})}>✏️</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{total && (
|
||||
<tr className="total-row">
|
||||
{total.slice(0,13).map((v,ci)=>(
|
||||
ocultar(ci) ? null :
|
||||
<td key={ci} className={ci===0?"col-name":""}
|
||||
style={{textAlign:ci===0?"left":"center"}}>{v}</td>
|
||||
))}
|
||||
<td>—</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{modalVer && <ModalVerDetalle programa={modalVer.programa} ano={ano} mes={mes} onClose={()=>setModalVer(null)} />}
|
||||
{modalCostos && <ModalCostos programa={modalCostos.programa} ano={ano} mes={mes} onClose={()=>setModalCostos(null)} onGuardado={cargar} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalVerDetalle({ programa, ano, mes, onClose }) {
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
let activo = true; setLoading(true);
|
||||
api.rentabilidadDetalle(programa, ano, mes)
|
||||
.then((res)=>{ if(activo){setFilas(res.filas||[]);setLoading(false);} })
|
||||
.catch(()=>{ if(activo){setFilas([]);setLoading(false);} });
|
||||
return ()=>{activo=false;};
|
||||
}, [programa, ano, mes]);
|
||||
|
||||
const COLS_DET = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","VALOR CUOTA","PROM. DESC. E.","VALOR VENTA"];
|
||||
|
||||
return (
|
||||
<Modal title={`👁️ Detalle — ${programa}`} onClose={onClose} width={1000}>
|
||||
{loading ? <Loader text="Cargando detalle..." /> :
|
||||
filas.length===0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr>{COLS_DET.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{filas.map((f,i)=>(
|
||||
<tr key={i}>
|
||||
{f.map((v,j)=>(
|
||||
<td key={j} className={j===1?"col-name":""} style={{textAlign:j>=5?"right":(j===1?"left":"center")}}>{v}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalCostos({ programa, ano, mes, onClose, onGuardado }) {
|
||||
const [costos, setCostos] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [guardando, setGuardando] = useState(false);
|
||||
const [msg, setMsg] = useState(null);
|
||||
// [keyGuardar, etiqueta, prefijoBackend]
|
||||
const CAMPOS = [["epp","COSTO EPP","epp"],["certificado","COSTO CERTIFICADO","cert"],["docente","COSTO DOCENTE","doc"],["marketing","COSTO MARKETING","mkt"],["consumibles","COSTO CONSUMIBLES","cons"]];
|
||||
|
||||
const [ini, setIni] = useState({});
|
||||
const [act, setAct] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true; setLoading(true);
|
||||
api.rentabilidadCostos(programa, ano, mes)
|
||||
.then((res)=>{
|
||||
if(!activo) return;
|
||||
const c = res.costos || {};
|
||||
setCostos(c);
|
||||
const gi={}, ga={};
|
||||
CAMPOS.forEach(([k,,bp])=>{ gi[k]=+(c[`${bp}_inicial`]||0); ga[k]=+(c[`${bp}_actual`]||0); });
|
||||
setIni(gi); setAct(ga); setLoading(false);
|
||||
})
|
||||
.catch(()=>{ if(activo){setCostos(null);setLoading(false);} });
|
||||
return ()=>{activo=false;};
|
||||
}, [programa, ano, mes]);
|
||||
|
||||
const totIni = Object.values(ini).reduce((a,b)=>a+(+b||0),0);
|
||||
const totAct = Object.values(act).reduce((a,b)=>a+(+b||0),0);
|
||||
|
||||
async function guardar() {
|
||||
setGuardando(true); setMsg(null);
|
||||
try {
|
||||
await api.guardarCostos(costos.num_indice, ini, act);
|
||||
if (onGuardado) onGuardado(); // recarga la tabla principal
|
||||
onClose(); // cierra el modal
|
||||
} catch (e) {
|
||||
setMsg({ ok:false, txt:"❌ Error al guardar. Verifica Supabase." });
|
||||
setGuardando(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={`✏️ Editar Costos — ${programa}`} onClose={onClose} width={760}>
|
||||
{loading ? <Loader text="Cargando costos..." /> :
|
||||
!costos ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin datos de costos.</div> :
|
||||
<>
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:16}}>
|
||||
<Columna titulo="INICIAL" color="#1e3a5f" campos={CAMPOS} valores={ini} setVal={setIni} total={totIni} />
|
||||
<Columna titulo="ACTUAL" color="#1a5f4a" campos={CAMPOS} valores={act} setVal={setAct} total={totAct} />
|
||||
</div>
|
||||
{msg && <div style={{marginTop:14,padding:10,borderRadius:8,fontSize:13,
|
||||
background:msg.ok?"#d1fae5":"#fee2e2",color:msg.ok?"#065f46":"#991b1b"}}>{msg.txt}</div>}
|
||||
<div style={{display:"flex",gap:10,marginTop:16}}>
|
||||
<button className="btn btn-primary" disabled={guardando} onClick={guardar}>
|
||||
{guardando ? "Guardando..." : "💾 Guardar"}
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={onClose}>Cerrar</button>
|
||||
</div>
|
||||
</>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Columna({ titulo, color, campos, valores, setVal, total }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{background:color,color:"#fff",borderRadius:8,padding:"8px 12px",
|
||||
marginBottom:10,fontWeight:600,fontSize:12,textAlign:"center"}}>{titulo}</div>
|
||||
{campos.map(([k,label])=>(
|
||||
<div key={k} style={{marginBottom:8}}>
|
||||
<label style={{fontSize:11,fontWeight:600,color:"#64748b",display:"block",marginBottom:3}}>{label}</label>
|
||||
<input type="number" value={valores[k]??0} min="0" step="100"
|
||||
onChange={(e)=>setVal({...valores,[k]:+e.target.value})}
|
||||
style={{width:"100%",padding:"7px 10px",border:"1px solid #cbd5e1",borderRadius:8,fontSize:13}} />
|
||||
</div>
|
||||
))}
|
||||
<div style={{background:"#f1f5f9",borderRadius:8,padding:10,textAlign:"center",
|
||||
fontWeight:700,color,marginTop:8}}>
|
||||
TOTAL: S/ {total.toLocaleString("es-PE",{maximumFractionDigits:0})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user