Actualizar docker-compose.yml con sintaxis YAML corregida y puerto 80 para frontend
This commit is contained in:
105
frontend/src/pages/Asesores.jsx
Normal file
105
frontend/src/pages/Asesores.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
// src/pages/Asesores.jsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox } from "../components/UI";
|
||||
|
||||
const ESTADO_INFO = {
|
||||
online: { txt: "En Línea", color: "#10b981", bg: "#d1fae5", fg: "#065f46" },
|
||||
busy: { txt: "Ocupado", color: "#f59e0b", bg: "#fef9c3", fg: "#854d0e" },
|
||||
offline:{ txt: "Fuera de Línea", color: "#94a3b8", bg: "#f1f5f9", fg: "#475569" },
|
||||
};
|
||||
|
||||
export default function Asesores() {
|
||||
const [agentes, setAgentes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [cambiando, setCambiando] = useState(null);
|
||||
|
||||
const cargar = useCallback(() => {
|
||||
setLoading(true); setError(null);
|
||||
api.asesores()
|
||||
.then((res)=>{ setAgentes(res.agentes||[]); setLoading(false); })
|
||||
.catch((e)=>{ setError(e.message); setLoading(false); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { cargar(); }, [cargar]);
|
||||
|
||||
async function toggle(ag) {
|
||||
const online = ag.availability_status !== "online";
|
||||
setCambiando(ag.id);
|
||||
try {
|
||||
await api.asesorEstado(ag.id, online);
|
||||
setAgentes((prev)=>prev.map((a)=>a.id===ag.id ? {...a, availability_status: online?"online":"offline"} : a));
|
||||
} catch (e) { /* noop */ }
|
||||
setCambiando(null);
|
||||
}
|
||||
|
||||
async function todos(online) {
|
||||
setCambiando("all");
|
||||
for (const ag of agentes) {
|
||||
try { await api.asesorEstado(ag.id, online); } catch (e) {}
|
||||
}
|
||||
setAgentes((prev)=>prev.map((a)=>({...a, availability_status: online?"online":"offline"})));
|
||||
setCambiando(null);
|
||||
}
|
||||
|
||||
const total = agentes.length;
|
||||
const enLinea = agentes.filter((a)=>a.availability_status==="online").length;
|
||||
const ocupados = agentes.filter((a)=>a.availability_status==="busy").length;
|
||||
const offline = total - enLinea - ocupados;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">👥 Asesores</h1>
|
||||
|
||||
{loading ? <Loader text="Cargando asesores de Chatwoot..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
<div className="kpis" style={{gridTemplateColumns:"repeat(4,1fr)"}}>
|
||||
<div className="kpi"><div className="ico">👥</div><div className="label">Total Asesores</div><div className="value">{total}</div></div>
|
||||
<div className="kpi" style={{background:"#d1fae5"}}><div className="ico">🟢</div><div className="label">En Línea</div><div className="value" style={{color:"#065f46"}}>{enLinea}</div></div>
|
||||
<div className="kpi" style={{background:"#fef9c3"}}><div className="ico">🟡</div><div className="label">Ocupados</div><div className="value" style={{color:"#854d0e"}}>{ocupados}</div></div>
|
||||
<div className="kpi" style={{background:"#fee2e2"}}><div className="ico">🔴</div><div className="label">Fuera de Línea</div><div className="value" style={{color:"#991b1b"}}>{offline}</div></div>
|
||||
</div>
|
||||
|
||||
<div style={{display:"flex",gap:10,marginBottom:16}}>
|
||||
<button className="btn btn-primary" disabled={cambiando==="all"} onClick={()=>todos(true)}>🟢 Encender todos</button>
|
||||
<button className="btn btn-ghost" disabled={cambiando==="all"} onClick={()=>todos(false)}>🔴 Apagar todos</button>
|
||||
<button className="btn btn-ghost" title="Refrescar" style={{fontSize:18}} onClick={cargar}>🔄</button>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>ASESOR</th><th>EMAIL</th><th>ROL</th><th>ESTADO</th><th>ACCIÓN</th></tr></thead>
|
||||
<tbody>
|
||||
{agentes.map((ag)=>{
|
||||
const est = ESTADO_INFO[ag.availability_status] || ESTADO_INFO.offline;
|
||||
const isOnline = ag.availability_status==="online";
|
||||
return (
|
||||
<tr key={ag.id}>
|
||||
<td className="col-name">{ag.name || ag.available_name || "—"}</td>
|
||||
<td>{ag.email || "—"}</td>
|
||||
<td>{ag.role || "agent"}</td>
|
||||
<td>
|
||||
<span style={{background:est.bg,color:est.fg,padding:"3px 10px",borderRadius:20,
|
||||
fontSize:11,fontWeight:700}}>● {est.txt}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn" disabled={cambiando===ag.id}
|
||||
style={{padding:"5px 12px",fontSize:11,
|
||||
background:isOnline?"#fee2e2":"#d1fae5",
|
||||
color:isOnline?"#991b1b":"#065f46"}}
|
||||
onClick={()=>toggle(ag)}>
|
||||
{cambiando===ag.id ? "..." : (isOnline?"Apagar":"Encender")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1313
frontend/src/pages/Cobranza.jsx
Normal file
1313
frontend/src/pages/Cobranza.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1004
frontend/src/pages/Comisiones.jsx
Normal file
1004
frontend/src/pages/Comisiones.jsx
Normal file
File diff suppressed because it is too large
Load Diff
82
frontend/src/pages/Login.jsx
Normal file
82
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
// src/pages/Login.jsx
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "../lib/auth";
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [cargando, setCargando] = useState(false);
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setCargando(true);
|
||||
const res = await login(email.trim(), password);
|
||||
if (!res.ok) setError(res.msg);
|
||||
setCargando(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)", padding: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
background: "#fff", borderRadius: 16, padding: "40px 36px", width: "100%", maxWidth: 400,
|
||||
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||
}}>
|
||||
<div style={{ textAlign: "center", marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 38, marginBottom: 8 }}>❄️</div>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 800, color: "#0f172a" }}>Escuela de Refrigeración</h1>
|
||||
<p style={{ fontSize: 13, color: "#64748b", marginTop: 4 }}>Dashboard — Inicia sesión</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
|
||||
Correo
|
||||
</label>
|
||||
<input
|
||||
type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="usuario@escuela.com" required autoFocus
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: "#475569", display: "block", marginBottom: 6 }}>
|
||||
Contraseña
|
||||
</label>
|
||||
<input
|
||||
type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••" required
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: "#fee2e2", border: "1px solid #fca5a5", color: "#991b1b",
|
||||
padding: "10px 12px", borderRadius: 8, fontSize: 13, marginBottom: 16,
|
||||
}}>⚠️ {error}</div>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={cargando} style={{
|
||||
width: "100%", padding: "12px", border: "none", borderRadius: 10,
|
||||
background: cargando ? "#94a3b8" : "#2563eb", color: "#fff", fontSize: 15, fontWeight: 600,
|
||||
cursor: cargando ? "default" : "pointer",
|
||||
}}>
|
||||
{cargando ? "Ingresando..." : "Iniciar sesión"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
width: "100%", padding: "11px 14px", border: "1px solid #cbd5e1", borderRadius: 10,
|
||||
fontSize: 14, outline: "none", boxSizing: "border-box",
|
||||
};
|
||||
307
frontend/src/pages/Ocupabilidad.jsx
Normal file
307
frontend/src/pages/Ocupabilidad.jsx
Normal file
@@ -0,0 +1,307 @@
|
||||
// src/pages/Ocupabilidad.jsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, ProgressBar, Filters, Select } from "../components/UI";
|
||||
import { useColumnasAjustables } from "../lib/useColumnasAjustables";
|
||||
import Modal from "../components/Modal";
|
||||
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
|
||||
|
||||
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","DÍAS PARA INICIO","TOTAL INSCRITOS","RETIRADOS","INSCRITOS EN CURSO","META INSCRITOS","AVANCE INSCRITOS","INSCRITOS MES","INSCRITOS P.C","INSCRITOS REFRIPERU","INSCRITOS CONTINUIDAD","OPC."];
|
||||
function toNum(v) { const n = parseFloat(String(v).replace("%","").trim()); return isNaN(n) ? 0 : n; }
|
||||
|
||||
export default function Ocupabilidad() {
|
||||
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 [mostrarRepro, setMostrarRepro] = useState(true);
|
||||
const [datos, setDatos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [modalCurso, setModalCurso] = useState(null);
|
||||
const cols = useColumnasAjustables([280, 110, 120, 110, 100, 120, 110, 120, 100, 100, 130, 140, 80]);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setVistaActual(`ocupabilidad:${ano}|${mes}|${sede}|${programa}`);
|
||||
const cargar = () => {
|
||||
setLoading(true); setError(null);
|
||||
api.ocupabilidad(ano, mes, sede, programa)
|
||||
.then((res) => { if (activo) { setDatos(res.datos || []); setLoading(false); } })
|
||||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||
};
|
||||
cargar();
|
||||
const off = onRefrescar(cargar);
|
||||
return () => { activo = false; off(); };
|
||||
}, [ano, mes, sede, programa]);
|
||||
|
||||
// Toggle "Mostrar Reprogramados": si está apagado, ocultar filas de inicio
|
||||
const datosVisibles = useMemo(() => {
|
||||
if (mostrarRepro) return datos;
|
||||
return datos.filter((d) => String(d.dias_para_inicio).trim().toUpperCase() !== "REPROGRAMADO");
|
||||
}, [datos, mostrarRepro]);
|
||||
|
||||
const kpis = useMemo(() => {
|
||||
if (!datosVisibles.length) return null;
|
||||
const esRepro = (d) => String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO";
|
||||
// Las filas REPROGRAMADO (inicios) no entran en las métricas (igual que escritorio)
|
||||
const validos = datosVisibles.filter((d) => !esRepro(d));
|
||||
const insc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Activos) || 0), 0);
|
||||
const totalInsc = validos.reduce((s, d) => s + (parseInt(d.Inscritos_Totales) || 0), 0);
|
||||
const retirados = validos.reduce((s, d) => s + (parseInt(d.Retirados ?? d.Inscritos_Retirados) || 0), 0);
|
||||
const meta = validos.reduce((s, d) => s + (parseInt(d.Meta_Curso) || 0), 0);
|
||||
return {
|
||||
cursos: validos.length, // solo programados (sin reprogramados)
|
||||
reprogramados: datosVisibles.filter(esRepro).length, // inicios rojos (REPROGRAMADO)
|
||||
inscritos: totalInsc, // columna TOTAL INSCRITOS
|
||||
meta,
|
||||
avance: meta > 0 ? (totalInsc / meta * 100) : 0,
|
||||
desercion: totalInsc > 0 ? (retirados / totalInsc * 100) : 0,
|
||||
sobreMeta: validos.filter((d) => (parseInt(d.Inscritos_Totales)||0) >= (parseInt(d.Meta_Curso)||1)).length,
|
||||
pc: validos.reduce((s, d) => s + (parseInt(d.Inscritos_PC) || 0), 0),
|
||||
};
|
||||
}, [datosVisibles]);
|
||||
|
||||
const totales = useMemo(() => {
|
||||
const t = { mes:0, total:0, pc:0, retirados:0, activos:0, refri:0, cont:0, meta:0 };
|
||||
datosVisibles.forEach((d) => {
|
||||
t.mes += parseInt(d.Inscritos_Mes)||0;
|
||||
t.total += parseInt(d.Inscritos_Totales)||0;
|
||||
t.pc += parseInt(d.Inscritos_PC)||0;
|
||||
t.retirados += parseInt(d.Retirados ?? d.Inscritos_Retirados)||0;
|
||||
t.activos += parseInt(d.Inscritos_Activos)||0;
|
||||
t.refri += parseInt(d.Descuento)||0;
|
||||
t.cont += parseInt(d.Inscritos_Continuidad)||0;
|
||||
t.meta += parseInt(d.Meta_Curso)||0;
|
||||
});
|
||||
return t;
|
||||
}, [datosVisibles]);
|
||||
|
||||
const KPI_CARDS = kpis ? [
|
||||
["📚","Cursos Activos", kpis.cursos, "programas este mes"],
|
||||
["🔁","Cursos Reprogramados", kpis.reprogramados, "inicios reprogramados"],
|
||||
["👥","Total Inscritos", kpis.inscritos, "alumnos en curso"],
|
||||
["🎯","Meta Total", kpis.meta, "inscritos objetivo"],
|
||||
["📈","Avance Inscritos", `${kpis.avance.toFixed(1)}%`, "total inscritos / meta"],
|
||||
["📉","Deserción", `${kpis.desercion.toFixed(1)}%`, "retirados / total inscritos"],
|
||||
] : [];
|
||||
|
||||
async function exportarExcel() {
|
||||
if (!datosVisibles.length) return;
|
||||
// Cargar SheetJS desde CDN solo cuando se necesita
|
||||
const XLSX = await import("xlsx");
|
||||
const filas = datosVisibles.map((d) => ({
|
||||
"PROGRAMA": d.programa_frecuencia || d.dsc_programa || "",
|
||||
"FECHA INICIO": d.fch_inicio || "",
|
||||
"DÍAS PARA INICIO": d.dias_para_inicio ?? "",
|
||||
"TOTAL INSCRITOS": parseInt(d.Inscritos_Totales)||0,
|
||||
"RETIRADOS": parseInt(d.Retirados ?? d.Inscritos_Retirados)||0,
|
||||
"INSCRITOS EN CURSO": parseInt(d.Inscritos_Activos)||0,
|
||||
"META INSCRITOS": parseInt(d.Meta_Curso)||0,
|
||||
"AVANCE INSCRITOS": d.Avance_Inscritos || "",
|
||||
"INSCRITOS MES": parseInt(d.Inscritos_Mes)||0,
|
||||
"INSCRITOS P.C": parseInt(d.Inscritos_PC)||0,
|
||||
"INSCRITOS REFRIPERU": parseInt(d.Descuento)||0,
|
||||
"INSCRITOS CONTINUIDAD": parseInt(d.Inscritos_Continuidad)||0,
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(filas);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Ocupabilidad");
|
||||
XLSX.writeFile(wb, `cursos_${ano}_${mes}.xlsx`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">📊 Ocupabilidad</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">
|
||||
<label>Reprogramados</label>
|
||||
<label style={{display:"flex",alignItems:"center",gap:6,height:38,cursor:"pointer",fontSize:13}}>
|
||||
<input type="checkbox" checked={mostrarRepro} onChange={(e)=>setMostrarRepro(e.target.checked)} />
|
||||
Mostrar
|
||||
</label>
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{height:38,marginLeft:"auto"}} onClick={exportarExcel} disabled={!datosVisibles.length}>
|
||||
⬇️ Exportar Excel
|
||||
</button>
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Calculando ocupabilidad..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
<div className="kpis">
|
||||
{KPI_CARDS.map(([ico,label,val,sub]) => (
|
||||
<div className="kpi" key={label}>
|
||||
<div className="ico">{ico}</div>
|
||||
<div className="label" style={{fontSize:15}}>{label}</div>
|
||||
<div className="value">{val}</div>
|
||||
<div className="sub">{sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||
<table {...cols.tableProps}>
|
||||
<cols.ColGroup />
|
||||
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
|
||||
<tbody>
|
||||
{datosVisibles.map((d, i) => {
|
||||
const prog = d.programa_frecuencia || d.dsc_programa || "";
|
||||
const dias = parseInt(d.dias_para_inicio);
|
||||
const pct = toNum(d.Avance_Inscritos);
|
||||
// Inicio reprogramado (existe en Supabase pero no en SQL este mes) → fila roja
|
||||
const esInicio = String(d.dias_para_inicio).trim().toUpperCase() === "REPROGRAMADO";
|
||||
const cel = (v) => esInicio ? "-" : v;
|
||||
return (
|
||||
<tr key={i} className={esInicio ? "row-inicio" : ""}>
|
||||
<td className="col-name" style={{minWidth:300,maxWidth:480,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{prog}</td>
|
||||
<td>{d.fch_inicio || ""}</td>
|
||||
<td style={!esInicio && dias < 0 ? {color:"#dc2626",fontWeight:700} : {}}>{d.dias_para_inicio ?? ""}</td>
|
||||
<td>{cel(parseInt(d.Inscritos_Totales)||0)}</td>
|
||||
<td>{cel(parseInt(d.Retirados ?? d.Inscritos_Retirados)||0)}</td>
|
||||
<td>{cel(parseInt(d.Inscritos_Activos)||0)}</td>
|
||||
<td>{cel(parseInt(d.Meta_Curso)||0)}</td>
|
||||
<td>{esInicio ? "-" : <ProgressBar pct={pct} />}</td>
|
||||
<td>{cel(parseInt(d.Inscritos_Mes)||0)}</td>
|
||||
<td>{cel(parseInt(d.Inscritos_PC)||0)}</td>
|
||||
<td>{cel(parseInt(d.Descuento)||0)}</td>
|
||||
<td>{cel(parseInt(d.Inscritos_Continuidad)||0)}</td>
|
||||
<td>
|
||||
{esInicio ? "-" : (
|
||||
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
|
||||
onClick={() => setModalCurso({ num_indice: d.num_indice, programa: prog })}>
|
||||
👁️ Ver
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{datosVisibles.length > 0 && (
|
||||
<tr className="total-row">
|
||||
<td className="col-name">TOTAL GENERAL</td>
|
||||
<td>—</td><td>—</td>
|
||||
<td>{totales.total}</td>
|
||||
<td>{totales.retirados}</td>
|
||||
<td>{totales.activos}</td>
|
||||
<td>{totales.meta}</td>
|
||||
<td><ProgressBar pct={totales.meta>0 ? totales.total/totales.meta*100 : 0} /></td>
|
||||
<td>{totales.mes}</td><td>{totales.pc}</td><td>{totales.refri}</td><td>{totales.cont}</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{modalCurso && (
|
||||
<ModalAlumnosCurso
|
||||
numIndice={modalCurso.num_indice}
|
||||
programa={modalCurso.programa}
|
||||
onClose={() => setModalCurso(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalAlumnosCurso({ numIndice, programa, onClose }) {
|
||||
const [alumnos, setAlumnos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setLoading(true);
|
||||
api.ocupabilidadAlumnos(numIndice)
|
||||
.then((res) => { if (activo) { setAlumnos(res.alumnos || []); setLoading(false); } })
|
||||
.catch(() => { if (activo) { setAlumnos([]); setLoading(false); } });
|
||||
return () => { activo = false; };
|
||||
}, [numIndice]);
|
||||
|
||||
const colorEstado = (e) => {
|
||||
if (e === "ALU") return { bg:"#ecfdf5", fg:"#047857", bd:"#a7f3d0" };
|
||||
if (e === "PRE") return { bg:"#eff6ff", fg:"#1e40af", bd:"#bfdbfe" };
|
||||
if (e === "RET") return { bg:"#fef2f2", fg:"#b91c1c", bd:"#fecaca" };
|
||||
return { bg:"#f1f5f9", fg:"#475569", bd:"#e2e8f0" };
|
||||
};
|
||||
|
||||
const conteo = useMemo(() => {
|
||||
const c = {};
|
||||
alumnos.forEach((a) => { c[a.estado] = (c[a.estado] || 0) + 1; });
|
||||
return c;
|
||||
}, [alumnos]);
|
||||
|
||||
return (
|
||||
<Modal title={`👥 Alumnos Inscritos — ${programa}`} onClose={onClose} width={1050}>
|
||||
{loading ? <Loader text="Cargando alumnos..." /> :
|
||||
alumnos.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin alumnos.</div> :
|
||||
<>
|
||||
<div style={{display:"flex",gap:10,marginBottom:14,flexWrap:"wrap"}}>
|
||||
<span style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
|
||||
borderRadius:999,background:"#eff6ff",color:"#1e40af",fontSize:13,fontWeight:700,
|
||||
border:"1px solid #bfdbfe"}}>
|
||||
👥 Total: {alumnos.length} alumno{alumnos.length!==1?"s":""}
|
||||
</span>
|
||||
{["ALU","PRE","RET"].map((e)=> conteo[e] ? (
|
||||
<span key={e} style={{display:"inline-flex",alignItems:"center",gap:6,padding:"6px 14px",
|
||||
borderRadius:999,background:colorEstado(e).bg,color:colorEstado(e).fg,fontSize:13,fontWeight:700,
|
||||
border:`1px solid ${colorEstado(e).bd}`}}>
|
||||
{e}: {conteo[e]}
|
||||
</span>
|
||||
) : null)}
|
||||
</div>
|
||||
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||
<table className="cob-table" style={{minWidth:900}}>
|
||||
<thead><tr>
|
||||
<th style={{textAlign:"left"}}>ALUMNO</th>
|
||||
<th>ESTADO</th>
|
||||
<th style={{textAlign:"left"}}>CURSO ANTERIOR (TEAC/TERC)</th>
|
||||
<th>ESTADO ANTERIOR</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{alumnos.map((a, i) => {
|
||||
const c = colorEstado(a.estado);
|
||||
const tieneAnt = a.curso_anterior && a.curso_anterior !== "-";
|
||||
const cAnt = colorEstado(a.estado_anterior);
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="col-name" style={{textAlign:"left"}}>{a.alumno}</td>
|
||||
<td style={{textAlign:"center"}}>
|
||||
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
|
||||
background:c.bg,color:c.fg,fontWeight:700,fontSize:12,border:`1px solid ${c.bd}`}}>
|
||||
{a.estado}
|
||||
</span>
|
||||
</td>
|
||||
<td className="col-name" style={{textAlign:"left",color:tieneAnt?undefined:"#cbd5e1"}}>
|
||||
{a.curso_anterior || "-"}
|
||||
</td>
|
||||
<td style={{textAlign:"center"}}>
|
||||
{tieneAnt ? (
|
||||
<span style={{display:"inline-block",padding:"2px 10px",borderRadius:999,
|
||||
background:cAnt.bg,color:cAnt.fg,fontWeight:700,fontSize:12,border:`1px solid ${cAnt.bd}`}}>
|
||||
{a.estado_anterior}
|
||||
</span>
|
||||
) : <span style={{color:"#cbd5e1"}}>-</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
276
frontend/src/pages/Rentabilidad.jsx
Normal file
276
frontend/src/pages/Rentabilidad.jsx
Normal file
@@ -0,0 +1,276 @@
|
||||
// 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";
|
||||
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
|
||||
|
||||
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;
|
||||
setVistaActual(`rentabilidad:${ano}|${mes}|${sede}|${programa}`);
|
||||
const cargarSeguro = () => {
|
||||
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); } });
|
||||
};
|
||||
cargarSeguro();
|
||||
const off = onRefrescar(cargarSeguro);
|
||||
return () => { activo = false; off(); };
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
96
frontend/src/pages/SaldoPendiente.jsx
Normal file
96
frontend/src/pages/SaldoPendiente.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
// src/pages/SaldoPendiente.jsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||
|
||||
const CUOTAS = ["1° Cuota","2° Cuota","3° Cuota","4° Cuota","5° Cuota"];
|
||||
|
||||
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
|
||||
function fmt(v){ const n=toNum(v); return "S/ " + n.toLocaleString("es-PE",{maximumFractionDigits:0}); }
|
||||
|
||||
export default function SaldoPendiente() {
|
||||
const [tipoCuota, setTipoCuota] = useState("1° Cuota");
|
||||
const [datos, setDatos] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [vendedor, setVendedor] = useState("TODOS");
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setLoading(true); setError(null);
|
||||
api.saldoPendiente(tipoCuota)
|
||||
.then((res)=>{ if(activo){setDatos(res.datos||[]);setLoading(false);} })
|
||||
.catch((e)=>{ if(activo){setError(e.message);setLoading(false);} });
|
||||
return ()=>{activo=false;};
|
||||
}, [tipoCuota]);
|
||||
|
||||
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
|
||||
const vendedores = useMemo(() => {
|
||||
const set = new Set(datos.map((d)=>String(d.VENDEDOR||"").trim()).filter(Boolean));
|
||||
return ["TODOS", ...Array.from(set).sort()];
|
||||
}, [datos]);
|
||||
|
||||
const filtrados = useMemo(() => {
|
||||
let arr = datos.filter((d)=>toNum(d[colSaldo])>0.01);
|
||||
if (vendedor!=="TODOS") arr = arr.filter((d)=>String(d.VENDEDOR||"").trim().toUpperCase()===vendedor.toUpperCase());
|
||||
return arr;
|
||||
}, [datos, vendedor, colSaldo]);
|
||||
|
||||
const totalSaldo = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d[colSaldo]),0),[filtrados,colSaldo]);
|
||||
const totalMat = useMemo(()=>filtrados.reduce((s,d)=>s+toNum(d["SALDO MAT."]),0),[filtrados]);
|
||||
|
||||
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">⏳ Saldo Pendiente</h1>
|
||||
|
||||
<Filters>
|
||||
<Select label="Tipo de cuota" value={tipoCuota} options={CUOTAS} onChange={setTipoCuota} />
|
||||
<Select label="Vendedor" value={vendedor} options={vendedores} onChange={setVendedor} />
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Calculando saldos..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
<div className="kpis" style={{gridTemplateColumns:"repeat(3,1fr)"}}>
|
||||
<div className="kpi"><div className="ico">👥</div><div className="label">Alumnos con Saldo</div><div className="value">{filtrados.length}</div></div>
|
||||
<div className="kpi"><div className="ico">⏳</div><div className="label">Saldo {tipoCuota}</div><div className="value" style={{fontSize:20}}>{fmt(totalSaldo)}</div></div>
|
||||
<div className="kpi"><div className="ico">📋</div><div className="label">Saldo Matrícula</div><div className="value" style={{fontSize:20}}>{fmt(totalMat)}</div></div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap" style={{marginTop:16}}>
|
||||
<table style={{minWidth:1100}}>
|
||||
<thead><tr>{COLS.map((c)=><th key={c}>{c}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{filtrados.map((d,i)=>(
|
||||
<tr key={i}>
|
||||
{KEYS.map((k,j)=>{
|
||||
const isMonto = k.includes("SALDO")||k.includes("INV");
|
||||
return (
|
||||
<td key={j} className={k==="ALUMNO"||k==="PROGRAMA"?"col-name":""}
|
||||
style={{textAlign:isMonto?"right":(k==="ALUMNO"||k==="PROGRAMA"?"left":"center"),
|
||||
fontWeight:k===colSaldo?700:undefined,
|
||||
color:k===colSaldo?"#dc2626":undefined}}>
|
||||
{isMonto ? fmt(d[k]) : (d[k] ?? "")}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{filtrados.length>0 && (
|
||||
<tr className="total-row">
|
||||
<td colSpan={6} style={{textAlign:"left"}}>TOTAL GENERAL ({filtrados.length})</td>
|
||||
<td style={{textAlign:"right"}}>{fmt(totalMat)}</td>
|
||||
<td style={{textAlign:"right"}}>{fmt(totalSaldo)}</td>
|
||||
<td>—</td><td>—</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
frontend/src/pages/Usuarios.jsx
Normal file
130
frontend/src/pages/Usuarios.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
// src/pages/Usuarios.jsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { Loader, ErrorBox, Filters, Select } from "../components/UI";
|
||||
|
||||
const ROLES = ["ADMINISTRADOR", "VENTAS", "COBRANZA"];
|
||||
|
||||
export default function Usuarios() {
|
||||
const [usuarios, setUsuarios] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// form nuevo usuario
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [nombre, setNombre] = useState("");
|
||||
const [rol, setRol] = useState("COBRANZA");
|
||||
const [creando, setCreando] = useState(false);
|
||||
const [msg, setMsg] = useState(null);
|
||||
|
||||
const cargar = useCallback(() => {
|
||||
setLoading(true); setError(null);
|
||||
api.usuariosListar()
|
||||
.then((res) => { setUsuarios(res.usuarios || []); setLoading(false); })
|
||||
.catch((e) => { setError(e.message); setLoading(false); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { cargar(); }, [cargar]);
|
||||
|
||||
async function crear() {
|
||||
setMsg(null);
|
||||
if (!email.trim() || !password.trim()) { setMsg({ ok:false, txt:"Correo y contraseña son obligatorios." }); return; }
|
||||
setCreando(true);
|
||||
try {
|
||||
await api.usuariosCrear(email.trim(), password, nombre.trim(), rol);
|
||||
setMsg({ ok:true, txt:`Usuario ${email} creado como ${rol}.` });
|
||||
setEmail(""); setPassword(""); setNombre(""); setRol("COBRANZA");
|
||||
cargar();
|
||||
} catch (e) {
|
||||
setMsg({ ok:false, txt:"Error: " + e.message });
|
||||
}
|
||||
setCreando(false);
|
||||
}
|
||||
|
||||
async function cambiarRol(id, nuevoRol) {
|
||||
try { await api.usuariosActualizarRol(id, nuevoRol); cargar(); }
|
||||
catch (e) { alert("No se pudo cambiar el rol: " + e.message); }
|
||||
}
|
||||
|
||||
async function eliminar(id, correo) {
|
||||
if (!confirm(`¿Eliminar al usuario ${correo}? Esta acción no se puede deshacer.`)) return;
|
||||
try { await api.usuariosEliminar(id); cargar(); }
|
||||
catch (e) { alert("No se pudo eliminar: " + e.message); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">🔐 Usuarios</h1>
|
||||
|
||||
{/* Crear nuevo usuario */}
|
||||
<div style={{ background:"#fff", border:"1px solid #e2e8f0", borderRadius:12, padding:18, marginBottom:20 }}>
|
||||
<h3 style={{ fontSize:15, fontWeight:700, color:"#0f172a", marginBottom:14 }}>➕ Crear nuevo usuario</h3>
|
||||
<div style={{ display:"flex", gap:12, flexWrap:"wrap", alignItems:"flex-end" }}>
|
||||
<Campo label="Correo">
|
||||
<input value={email} onChange={(e)=>setEmail(e.target.value)} placeholder="usuario@escuela.com" style={inp} />
|
||||
</Campo>
|
||||
<Campo label="Contraseña">
|
||||
<input type="text" value={password} onChange={(e)=>setPassword(e.target.value)} placeholder="mín. 6 caracteres" style={inp} />
|
||||
</Campo>
|
||||
<Campo label="Nombre">
|
||||
<input value={nombre} onChange={(e)=>setNombre(e.target.value)} placeholder="Nombre completo" style={inp} />
|
||||
</Campo>
|
||||
<Campo label="Rol">
|
||||
<Select value={rol} options={ROLES} onChange={setRol} />
|
||||
</Campo>
|
||||
<button className="btn btn-primary" style={{height:38}} disabled={creando} onClick={crear}>
|
||||
{creando ? "Creando..." : "Crear usuario"}
|
||||
</button>
|
||||
</div>
|
||||
{msg && (
|
||||
<div style={{ marginTop:12, padding:10, borderRadius:8, fontSize:13,
|
||||
background: msg.ok ? "#d1fae5" : "#fee2e2", color: msg.ok ? "#065f46" : "#991b1b" }}>
|
||||
{msg.ok ? "✅" : "⚠️"} {msg.txt}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista de usuarios */}
|
||||
{loading ? <Loader text="Cargando usuarios..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>CORREO</th><th>NOMBRE</th><th>ROL</th><th>ACCIONES</th></tr></thead>
|
||||
<tbody>
|
||||
{usuarios.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td className="col-name">{u.email}</td>
|
||||
<td>{u.nombre || "—"}</td>
|
||||
<td>
|
||||
<select value={u.rol} onChange={(e)=>cambiarRol(u.id, e.target.value)}
|
||||
style={{ padding:"5px 8px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:12 }}>
|
||||
{ROLES.map((r)=><option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn" style={{ padding:"4px 10px", fontSize:11, background:"#fee2e2", color:"#991b1b" }}
|
||||
onClick={()=>eliminar(u.id, u.email)}>🗑️ Eliminar</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{usuarios.length === 0 && (
|
||||
<tr><td colSpan={4} style={{ padding:20, textAlign:"center", color:"#94a3b8" }}>Sin usuarios.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Campo({ label, children }) {
|
||||
return (
|
||||
<div className="filter-group">
|
||||
<label>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inp = { padding:"8px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, minWidth:180 };
|
||||
272
frontend/src/pages/Ventas.jsx
Normal file
272
frontend/src/pages/Ventas.jsx
Normal file
@@ -0,0 +1,272 @@
|
||||
// src/pages/Ventas.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";
|
||||
import { setVistaActual, onRefrescar } from "../lib/vistaActual";
|
||||
|
||||
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 = ["VENDEDOR","INSCRITOS","VENTA TOTAL","INSCRITOS P.C DEL MES","VENTA P.C DEL MES","INSCRITOS MES ANTERIOR","VENTA MES ANTERIOR","AVANCE P.C TOTAL","OPC."];
|
||||
const TIPOS = ["Venta Inscritos", "Venta P.C", "Venta Pendientes"];
|
||||
const HEADERS_DETALLE = ["VENDEDOR","ALUMNO","F. MATRÍCULA","F. CANCELACIÓN","TIPO","CUOTA PROM.","DESC. ESPECIAL","INV. NETA"];
|
||||
|
||||
export default function Ventas() {
|
||||
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 [filas, setFilas] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [modal, setModal] = useState(null); // { vendedor }
|
||||
const cols = useColumnasAjustables([200, 100, 130, 120, 130, 120, 130, 140, 100]);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setVistaActual(`ventas:${ano}|${mes}|${sede}|${programa}|False`);
|
||||
const cargar = () => {
|
||||
setLoading(true); setError(null);
|
||||
api.ventas(ano, mes, sede, programa)
|
||||
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||||
};
|
||||
cargar();
|
||||
const off = onRefrescar(cargar);
|
||||
return () => { activo = false; off(); };
|
||||
}, [ano, mes, sede, programa]);
|
||||
|
||||
const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);
|
||||
const datos = useMemo(() => filas.filter((f) => f[0] !== "TOTAL GENERAL"), [filas]);
|
||||
|
||||
const KPIS = total ? [
|
||||
["👤","Vendedores", datos.length, "activos este mes"],
|
||||
["🎓","Total Inscritos", total[1], "matriculados"],
|
||||
["💵","Venta Total", total[2], "inversión neta"],
|
||||
["✅","Venta P.C del Mes", total[4], "pagos completos"],
|
||||
["🚀","Avance P.C Total", total[7], "P.C + Pendientes"],
|
||||
] : [];
|
||||
|
||||
// Exportar saldos por cuota — Excel con formato (anchos, encabezado de color,
|
||||
// fechas reales, montos numéricos en soles y fila TOTAL GENERAL).
|
||||
async function exportarCuota(tipoCuota) {
|
||||
const toNum = (v) => { const n = parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; };
|
||||
// Convierte "dd-mm-yyyy" o "dd/mm/yyyy" a Date; si no puede, devuelve el texto original
|
||||
const toDate = (v) => {
|
||||
if (!v) return "";
|
||||
const s = String(v).trim().replace(/\//g,"-");
|
||||
const p = s.split("-");
|
||||
if (p.length === 3) {
|
||||
let [d,m,y] = p;
|
||||
if (y.length === 4) return new Date(+y, +m-1, +d);
|
||||
}
|
||||
return String(v);
|
||||
};
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await api.saldoPendiente(tipoCuota);
|
||||
} catch (e) {
|
||||
alert("No se pudo obtener los datos de saldos: " + e.message);
|
||||
return;
|
||||
}
|
||||
const datosS = res.datos || [];
|
||||
const colSaldo = `SALDO ${tipoCuota.toUpperCase()}`;
|
||||
const filtrados = datosS.filter((d) => toNum(d[colSaldo]) > 0.01);
|
||||
if (filtrados.length === 0) { alert(`No hay saldos pendientes para ${tipoCuota}.`); return; }
|
||||
|
||||
const COLS = ["MATRÍCULA","VENDEDOR","ALUMNO","PROGRAMA","F. INICIO","F. MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||
const KEYS = ["MATRICULA","VENDEDOR","ALUMNO","PROGRAMA","FECHA INICIO","FECHA MATR.","SALDO MAT.",colSaldo,"VENCIMIENTO","INV. NETA"];
|
||||
const COL_MONTO = ["SALDO MAT.", colSaldo, "INV. NETA"];
|
||||
const COL_FECHA = ["F. INICIO","F. MATR.","VENCIMIENTO"];
|
||||
|
||||
const XLSX = await import("xlsx-js-style");
|
||||
|
||||
// Construir matriz de celdas (AOA) con tipos correctos
|
||||
const headerStyle = {
|
||||
font: { bold: true, color: { rgb: "FFFFFF" }, sz: 11 },
|
||||
fill: { fgColor: { rgb: "1E40AF" } },
|
||||
alignment: { horizontal: "center", vertical: "center" },
|
||||
};
|
||||
const moneyFmt = '"S/" #,##0';
|
||||
const dateFmt = "dd/mm/yyyy";
|
||||
|
||||
const ws = {};
|
||||
const range = { s: { r: 0, c: 0 }, e: { r: filtrados.length + 1, c: COLS.length - 1 } };
|
||||
|
||||
// Encabezado
|
||||
COLS.forEach((h, c) => {
|
||||
const ref = XLSX.utils.encode_cell({ r: 0, c });
|
||||
ws[ref] = { v: h, t: "s", s: headerStyle };
|
||||
});
|
||||
|
||||
// Filas de datos
|
||||
filtrados.forEach((d, ri) => {
|
||||
KEYS.forEach((k, c) => {
|
||||
const ref = XLSX.utils.encode_cell({ r: ri + 1, c });
|
||||
const col = COLS[c];
|
||||
if (COL_MONTO.includes(col)) {
|
||||
ws[ref] = { v: toNum(d[k]), t: "n", z: moneyFmt };
|
||||
} else if (COL_FECHA.includes(col)) {
|
||||
const dt = toDate(d[k]);
|
||||
if (dt instanceof Date) ws[ref] = { v: dt, t: "d", z: dateFmt };
|
||||
else ws[ref] = { v: dt, t: "s" };
|
||||
} else {
|
||||
ws[ref] = { v: d[k] ?? "", t: "s" };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fila TOTAL GENERAL: promedio en SALDO MAT. y SALDO CUOTA, suma en INV. NETA
|
||||
const rTot = filtrados.length + 1;
|
||||
const sum = (k) => filtrados.reduce((s,d)=>s+toNum(d[k]),0);
|
||||
const prom = (k) => filtrados.length ? sum(k)/filtrados.length : 0;
|
||||
const totalStyle = { font: { bold: true }, fill: { fgColor: { rgb: "EFF6FF" } } };
|
||||
COLS.forEach((col, c) => {
|
||||
const ref = XLSX.utils.encode_cell({ r: rTot, c });
|
||||
if (c === 0) ws[ref] = { v: "TOTAL GENERAL", t: "s", s: totalStyle };
|
||||
else if (col === "SALDO MAT.") ws[ref] = { v: prom("SALDO MAT."), t: "n", z: moneyFmt, s: totalStyle };
|
||||
else if (col === colSaldo) ws[ref] = { v: prom(colSaldo), t: "n", z: moneyFmt, s: totalStyle };
|
||||
else if (col === "INV. NETA") ws[ref] = { v: sum("INV. NETA"), t: "n", z: moneyFmt, s: totalStyle };
|
||||
else ws[ref] = { v: "", t: "s", s: totalStyle };
|
||||
});
|
||||
|
||||
ws["!ref"] = XLSX.utils.encode_range(range);
|
||||
ws["!cols"] = [
|
||||
{ wch: 12 }, // MATRÍCULA
|
||||
{ wch: 22 }, // VENDEDOR
|
||||
{ wch: 28 }, // ALUMNO
|
||||
{ wch: 32 }, // PROGRAMA
|
||||
{ wch: 12 }, // F. INICIO
|
||||
{ wch: 12 }, // F. MATR.
|
||||
{ wch: 13 }, // SALDO MAT.
|
||||
{ wch: 15 }, // SALDO CUOTA
|
||||
{ wch: 13 }, // VENCIMIENTO
|
||||
{ wch: 13 }, // INV. NETA
|
||||
];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Saldos");
|
||||
const nombre = tipoCuota.replace("°","").replace(/\s/g,"_");
|
||||
XLSX.writeFile(wb, `saldos_${nombre}.xlsx`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">🛒 Ventas</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 style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
|
||||
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("1° Cuota")}>
|
||||
⬇️ Exportar 1° Cuota
|
||||
</button>
|
||||
<button className="btn btn-primary" style={{height:38}} onClick={() => exportarCuota("2° Cuota")}>
|
||||
⬇️ Exportar 2° Cuota
|
||||
</button>
|
||||
</div>
|
||||
</Filters>
|
||||
|
||||
{loading ? <Loader text="Calculando ventas..." /> :
|
||||
error ? <ErrorBox msg={error} /> :
|
||||
<>
|
||||
<div className="kpis" style={{ gridTemplateColumns: "repeat(5,1fr)" }}>
|
||||
{KPIS.map(([ico,label,val,sub]) => (
|
||||
<div className="kpi" key={label}>
|
||||
<div className="ico">{ico}</div>
|
||||
<div className="label" style={{fontSize:15}}>{label}</div>
|
||||
<div className="value" style={{fontSize:20}}>{val}</div>
|
||||
<div className="sub">{sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||
<table {...cols.tableProps}>
|
||||
<cols.ColGroup />
|
||||
<thead><tr>{COLS.map((c, i) => <th key={c} style={{position:"relative"}}>{c}<cols.Resizer index={i} /></th>)}</tr></thead>
|
||||
<tbody>
|
||||
{datos.map((f, i) => (
|
||||
<tr key={i}>
|
||||
<td className="col-name">{f[0]}</td>
|
||||
<td>{f[1]}</td><td>{f[2]}</td>
|
||||
<td>{f[3]}</td><td>{f[4]}</td>
|
||||
<td>{f[5]}</td><td>{f[6]}</td>
|
||||
<td style={{fontWeight:600}}>{f[7]}</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost" style={{padding:"4px 10px",fontSize:11}}
|
||||
onClick={() => setModal({ vendedor: f[0] })}>👁️ Ver</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{total && (
|
||||
<tr className="total-row">
|
||||
<td className="col-name">TOTAL GENERAL</td>
|
||||
<td>{total[1]}</td><td>{total[2]}</td>
|
||||
<td>{total[3]}</td><td>{total[4]}</td>
|
||||
<td>{total[5]}</td><td>{total[6]}</td>
|
||||
<td>{total[7]}</td><td>—</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{modal && (
|
||||
<ModalDetalle vendedor={modal.vendedor} ano={ano} mes={mes} onClose={() => setModal(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalDetalle({ vendedor, ano, mes, onClose }) {
|
||||
const [tipo, setTipo] = useState(TIPOS[0]);
|
||||
const [filas, setFilas] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true;
|
||||
setLoading(true);
|
||||
api.ventasDetalle(vendedor, ano, mes, tipo)
|
||||
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||||
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
|
||||
return () => { activo = false; };
|
||||
}, [vendedor, ano, mes, tipo]);
|
||||
|
||||
// Columnas redimensionables (igual que las tablas principales)
|
||||
const colsDet = useColumnasAjustables([200, 260, 120, 130, 120, 120, 130, 120]);
|
||||
|
||||
return (
|
||||
<Modal title={`👤 ${vendedor}`} onClose={onClose} width={1500}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<Select label="Tipo de venta" value={tipo} options={TIPOS} onChange={setTipo} />
|
||||
</div>
|
||||
{loading ? <Loader text="Cargando detalle..." /> :
|
||||
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin registros para esta categoría.</div> :
|
||||
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||||
<table {...colsDet.tableProps}>
|
||||
<colsDet.ColGroup />
|
||||
<thead><tr>{HEADERS_DETALLE.map((h, i) => <th key={h} style={{position:"relative", whiteSpace:"nowrap"}}>{h}<colsDet.Resizer index={i} /></th>)}</tr></thead>
|
||||
<tbody>
|
||||
{filas.map((f, i) => (
|
||||
<tr key={i}>
|
||||
{f.slice(0, HEADERS_DETALLE.length).map((v, j) => (
|
||||
<td key={j} className={j === 1 ? "col-name" : ""}
|
||||
style={{textAlign:"center", whiteSpace:"nowrap", overflow:"hidden", textOverflow:"ellipsis"}}>{v}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user