271 lines
12 KiB
JavaScript
271 lines
12 KiB
JavaScript
// 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>
|
||
);
|
||
}
|