1513 lines
69 KiB
JavaScript
1513 lines
69 KiB
JavaScript
// src/pages/Comisiones.jsx
|
||
import { useState, useEffect, useMemo, useRef, Fragment } from "react";
|
||
import { api, BASE_URL } 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 = ["LIMA", "AREQUIPA", "PIURA", "TRUJILLO"];
|
||
const PROGRAMAS = ["SEMINARIOS", "OTROS", "TEAC", "TERC"];
|
||
const COLS = ["VENDEDOR","INSCRITOS PAGADOS MES EN CURSO","MONTO PAGADO MES EN CURSO","INSCRITOS PAGADOS MES PASADO","MONTO PAGADO MES PASADO","AVANCE TOTAL","OPCIONES"];
|
||
// Mostaza transparente: resalta filas de alumnos con edición (override en Supabase)
|
||
const BG_EDITADO = "rgba(217,160,28,0.14)";
|
||
|
||
// Filtro del popup → tipo de lista que ya maneja ventasDetalle
|
||
const OPCIONES_POPUP = [
|
||
{ value: "Venta P.C", label: "INSCRITOS PAGADOS MES EN CURSO" },
|
||
{ value: "Venta Pendientes", label: "INSCRITOS PAGADOS MES PASADO" },
|
||
];
|
||
|
||
function toNum(v){ const n=parseFloat(String(v).replace("S/","").replace(/,/g,"").trim()); return isNaN(n)?0:n; }
|
||
// Formatea cualquier monto a "S/ 1,234" (sin decimales)
|
||
function fmt(v){ return "S/ " + toNum(v).toLocaleString("es-PE",{maximumFractionDigits:0}); }
|
||
|
||
// ── Ordenamiento (detecta monto S/, %, fecha dd-mm-yyyy, número o texto) ──
|
||
function _valorOrden(v) {
|
||
const s = String(v ?? "").trim();
|
||
if (s === "" || s === "—" || s === "-") return { t: "vacio", n: 0, s: "" };
|
||
const mf = s.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
|
||
if (mf) { const [, d, m, y] = mf; return { t: "fecha", n: new Date(+y, +m - 1, +d).getTime(), s }; }
|
||
const limpio = s.replace(/S\/|%|,/g, "").replace(/\s/g, "").trim();
|
||
if (limpio !== "" && !isNaN(Number(limpio))) return { t: "num", n: Number(limpio), s };
|
||
return { t: "texto", n: 0, s: s.toLowerCase() };
|
||
}
|
||
function _cmp(a, b, dir) {
|
||
const mult = dir === "asc" ? 1 : -1;
|
||
const va = _valorOrden(a), vb = _valorOrden(b);
|
||
if (va.t === "vacio" && vb.t === "vacio") return 0;
|
||
if (va.t === "vacio") return 1;
|
||
if (vb.t === "vacio") return -1;
|
||
if (va.t === "texto" || vb.t === "texto") return mult * va.s.localeCompare(vb.s, "es");
|
||
return mult * (va.n - vb.n);
|
||
}
|
||
function _flecha(activo, dir) {
|
||
if (!activo) return <span style={{ opacity: 0.3, marginLeft: 4 }}>⇅</span>;
|
||
return <span style={{ marginLeft: 4 }}>{dir === "asc" ? "▲" : "▼"}</span>;
|
||
}
|
||
// True si la fecha de matrícula (dd/mm/yyyy) es >= 01/01/2025
|
||
function fechaMatDesde2025(fch){
|
||
const s = String(fch || "").trim();
|
||
const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
|
||
if (!m) return false; // sin fecha válida → excluir
|
||
const d = new Date(+m[3], +m[2]-1, +m[1]);
|
||
return d >= new Date(2025, 0, 1);
|
||
}
|
||
|
||
export default function Comisiones() {
|
||
const hoy = new Date();
|
||
const [ano, setAno] = useState(hoy.getFullYear());
|
||
const [mes, setMes] = useState(hoy.getMonth() + 1);
|
||
const [sedes, setSedes] = useState([]); // vacío = todas
|
||
const [programas, setProgramas] = useState([]); // vacío = todos
|
||
const sedesKey = useMemo(() => [...sedes].sort().join(","), [sedes]);
|
||
const programasKey = useMemo(() => [...programas].sort().join(","), [programas]);
|
||
const [filas, setFilas] = useState([]);
|
||
const [manualesAno, setManualesAno] = useState([]); // todos los del año (cache)
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [modal, setModal] = useState(null); // { vendedor, manual? }
|
||
const [modalEdit, setModalEdit] = useState(null); // { vendedor }
|
||
const [modalEditManual, setModalEditManual] = useState(null); // { nombre }
|
||
const [modalAdd, setModalAdd] = useState(false);
|
||
const [modalBuscar, setModalBuscar] = useState(false);
|
||
const [modalConfig, setModalConfig] = useState(false);
|
||
const cols = useColumnasAjustables([200, 160, 150, 160, 150, 130, 160]);
|
||
const [ordCol, setOrdCol] = useState(null);
|
||
const [ordDir, setOrdDir] = useState(null);
|
||
// Mapa columna visible -> índice real en el array de la fila.
|
||
const IDX_COM = [0, 3, 4, 5, 6, 7];
|
||
const clickOrden = (i) => {
|
||
if (i === 6) return; // columna OPCIONES no se ordena
|
||
if (ordCol !== i) { setOrdCol(i); setOrdDir("asc"); }
|
||
else if (ordDir === "asc") setOrdDir("desc");
|
||
else { setOrdCol(null); setOrdDir(null); }
|
||
};
|
||
|
||
// Carga TODOS los manuales del año (una sola vez por año) → cambio de mes instantáneo
|
||
function cargarManuales() {
|
||
api.vendedoresManualesListar(ano, 0)
|
||
.then((res) => setManualesAno(res.vendedores || []))
|
||
.catch(() => setManualesAno([]));
|
||
}
|
||
|
||
// Manuales del mes actual: filtrado en memoria (instantáneo, sin esperar red)
|
||
const manuales = useMemo(
|
||
() => manualesAno.filter((m) => Number(m.mes) === Number(mes)),
|
||
[manualesAno, mes]
|
||
);
|
||
|
||
// Tabla de ventas (depende del mes, sede y programa) — Comisiones SÍ aplica overrides
|
||
function cargarTabla() {
|
||
setLoading(true); setError(null);
|
||
api.ventas(ano, mes, sedesKey || "TODOS", programasKey || "TODOS", true)
|
||
.then((res) => { setFilas(res.filas || []); setLoading(false); })
|
||
.catch((e) => { setError(e.message); setLoading(false); });
|
||
}
|
||
|
||
useEffect(() => {
|
||
let activo = true;
|
||
setLoading(true); setError(null);
|
||
api.ventas(ano, mes, sedesKey || "TODOS", programasKey || "TODOS", true)
|
||
.then((res) => { if (activo) { setFilas(res.filas || []); setLoading(false); } })
|
||
.catch((e) => { if (activo) { setError(e.message); setLoading(false); } });
|
||
return () => { activo = false; };
|
||
}, [ano, mes, sedesKey, programasKey]);
|
||
|
||
// Cargar manuales del año cuando cambia el año (o al inicio)
|
||
useEffect(() => { cargarManuales(); }, [ano]);
|
||
|
||
const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);
|
||
const datos = useMemo(() => {
|
||
// Ocultar vendedores con AVANCE TOTAL (f[7]) en 0
|
||
let base = filas.filter((f) => f[0] !== "TOTAL GENERAL" && toNum(f[7]) > 0);
|
||
if (ordCol != null && ordDir) {
|
||
const idx = IDX_COM[ordCol];
|
||
base = [...base].sort((a, b) => _cmp(a[idx], b[idx], ordDir));
|
||
}
|
||
return base;
|
||
}, [filas, ordCol, ordDir]);
|
||
|
||
// Agrupar vendedores manuales por nombre (cada nombre = 1 fila, suma de montos)
|
||
const manualesAgrupados = useMemo(() => {
|
||
const map = {};
|
||
manuales.forEach((m) => {
|
||
if (!map[m.nombre]) map[m.nombre] = { nombre: m.nombre, totalMonto: 0, filas: [] };
|
||
map[m.nombre].totalMonto += toNum(m.monto);
|
||
map[m.nombre].filas.push(m);
|
||
});
|
||
return Object.values(map);
|
||
}, [manuales]);
|
||
|
||
// Lista de asesores del mes (automáticos de la tabla + manuales), deduped,
|
||
// excluyendo "TOTAL GENERAL". Se pasa a ModalConfig para la matriz de metas.
|
||
const asesoresLista = useMemo(() => {
|
||
const vistos = new Set();
|
||
const out = [];
|
||
const agregar = (nombre) => {
|
||
const n = String(nombre || "").trim();
|
||
if (!n || n.toUpperCase() === "TOTAL GENERAL") return;
|
||
const key = n.toUpperCase();
|
||
if (vistos.has(key)) return;
|
||
vistos.add(key); out.push(n);
|
||
};
|
||
datos.forEach((f) => agregar(f[0]));
|
||
manualesAgrupados.forEach((g) => agregar(g.nombre));
|
||
return out;
|
||
}, [datos, manualesAgrupados]);
|
||
|
||
// Los vendedores manuales pertenecen a SEDE LIMA y PROGRAMA OTROS.
|
||
// Se muestran/cuentan cuando el filtro PERMITE LIMA (sede vacía o incluye LIMA)
|
||
// Y PERMITE OTROS (programa vacío o incluye OTROS).
|
||
const mostrarManuales = useMemo(() => {
|
||
const sedeOk = sedes.length === 0 || sedes.map((s)=>String(s).toUpperCase()).includes("LIMA");
|
||
const progOk = programas.length === 0 || programas.map((p)=>String(p).toUpperCase()).includes("OTROS");
|
||
return sedeOk && progOk;
|
||
}, [sedes, programas]);
|
||
const totalAvance = useMemo(() => {
|
||
let s = total ? toNum(total[7]) : 0;
|
||
if (mostrarManuales) manuales.forEach((m) => { s += toNum(m.monto); });
|
||
return s;
|
||
}, [total, manuales, mostrarManuales]);
|
||
|
||
const C = { textAlign: "center" }; // todas las columnas centradas
|
||
|
||
return (
|
||
<div>
|
||
<h1 className="page-title">🧮 Comisiones</h1>
|
||
|
||
<Filters>
|
||
<SelectUnico label="Año" opciones={ANOS} valor={ano} onChange={(v) => setAno(+v)} minWidth={110} />
|
||
<SelectUnico label="Mes" opciones={MESES.map((m,i)=>({value:i+1,label:m}))} valor={mes} onChange={(v) => setMes(+v)} minWidth={160} />
|
||
<ListaMulti label="Sede" opciones={SEDES} seleccion={sedes} onChange={setSedes} plural="sedes" minWidth={140} />
|
||
<ListaMulti label="Programa" opciones={PROGRAMAS} seleccion={programas} onChange={setProgramas} plural="programas" minWidth={150} />
|
||
<div style={{display:"flex",gap:8,alignItems:"flex-end",marginLeft:"auto"}}>
|
||
<button className="btn btn-ghost" style={{height:38}} onClick={()=>setModalConfig(true)}>⚙️ TC y Metas</button>
|
||
<button className="btn btn-ghost" style={{height:38}} onClick={()=>setModalBuscar(true)}>🔍 Buscar Alumno</button>
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{height:38}}
|
||
onClick={() => {
|
||
const url = `${BASE_URL}/api/comisiones/export?ano=${ano}&mes=${mes}`;
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `Comisiones_${ano}_${mes}.xlsx`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
}}
|
||
>
|
||
⬇️ Exportar
|
||
</button>
|
||
<button className="btn btn-primary" style={{height:38}} onClick={()=>setModalAdd(true)}>
|
||
➕ Añadir Vendedor
|
||
</button>
|
||
</div>
|
||
</Filters>
|
||
|
||
{loading ? <Loader text="Calculando comisiones..." /> :
|
||
error ? <ErrorBox msg={error} /> :
|
||
<div className="table-wrap" style={{overflowX:"auto"}}>
|
||
<table {...cols.tableProps}>
|
||
<cols.ColGroup />
|
||
<thead><tr>{COLS.map((c, i) => {
|
||
const ordenable = i !== 6;
|
||
return (
|
||
<th key={c} onClick={()=>clickOrden(i)}
|
||
style={{position:"relative", cursor: ordenable?"pointer":"default", userSelect:"none"}}>
|
||
{c}{ordenable && _flecha(ordCol===i, ordDir)}<cols.Resizer index={i} />
|
||
</th>
|
||
);
|
||
})}</tr></thead>
|
||
<tbody>
|
||
{datos.map((f, i) => (
|
||
<tr key={i}>
|
||
<td className="col-name" style={C}>{f[0]}</td>
|
||
<td style={C}>{f[3]}</td><td style={C}>{fmt(f[4])}</td>
|
||
<td style={C}>{f[5]}</td><td style={C}>{fmt(f[6])}</td>
|
||
<td style={{...C,fontWeight:600}}>{fmt(f[7])}</td>
|
||
<td style={C}>
|
||
<div style={{display:"flex",gap:4,justifyContent:"center",flexWrap:"nowrap"}}>
|
||
<button className="btn btn-ghost" title="Ver" style={{padding:"4px 9px",fontSize:11,whiteSpace:"nowrap"}}
|
||
onClick={() => setModal({ vendedor: f[0] })}>👁️ Ver</button>
|
||
<button className="btn btn-ghost" title="Editar" style={{padding:"4px 9px",fontSize:11,whiteSpace:"nowrap"}}
|
||
onClick={() => setModalEdit({ vendedor: f[0] })}>✏️ Editar</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
|
||
{/* Vendedores manuales (sede LIMA / programa OTROS): visibles si el filtro lo permite */}
|
||
{mostrarManuales && manualesAgrupados.map((g) => (
|
||
<tr key={"m-"+g.nombre} style={{background:"#fffbeb"}}>
|
||
<td className="col-name" style={C}>{g.nombre}</td>
|
||
<td style={C}>—</td><td style={C}>—</td>
|
||
<td style={C}>—</td><td style={C}>—</td>
|
||
<td style={{...C,fontWeight:600}}>{fmt(g.totalMonto)}</td>
|
||
<td style={C}>
|
||
<div style={{display:"flex",gap:4,justifyContent:"center",flexWrap:"nowrap"}}>
|
||
<button className="btn btn-ghost" title="Ver" style={{padding:"4px 9px",fontSize:11,whiteSpace:"nowrap"}}
|
||
onClick={() => setModal({ vendedor: g.nombre, manual: g })}>👁️ Ver</button>
|
||
<button className="btn btn-ghost" title="Editar" style={{padding:"4px 9px",fontSize:11,whiteSpace:"nowrap"}}
|
||
onClick={() => setModalEditManual({ nombre: g.nombre })}>✏️ Editar</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
|
||
{total && (
|
||
<tr className="total-row">
|
||
<td className="col-name" style={C}>TOTAL GENERAL</td>
|
||
<td style={C}>{total[3]}</td><td style={C}>{fmt(total[4])}</td>
|
||
<td style={C}>{total[5]}</td><td style={C}>{fmt(total[6])}</td>
|
||
<td style={C}>{fmt(totalAvance)}</td><td style={C}>—</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>}
|
||
|
||
{modal && (
|
||
<ModalDetalle vendedor={modal.vendedor} manual={modal.manual} ano={ano} mes={mes}
|
||
sede={sedesKey || "TODOS"} programa={programasKey || "TODOS"}
|
||
onClose={() => setModal(null)}
|
||
onEliminar={() => { setModal(null); cargarManuales(); }} />
|
||
)}
|
||
|
||
{modalEdit && (
|
||
<ModalEditar vendedor={modalEdit.vendedor} ano={ano} mes={mes}
|
||
sede={sedesKey || "TODOS"} programa={programasKey || "TODOS"}
|
||
onClose={() => setModalEdit(null)} onGuardado={cargarTabla} />
|
||
)}
|
||
|
||
{modalEditManual && (
|
||
<ModalEditarManual nombre={modalEditManual.nombre} ano={ano} mes={mes}
|
||
filasActuales={manuales.filter((m)=>m.nombre===modalEditManual.nombre)}
|
||
onClose={() => setModalEditManual(null)}
|
||
onCambio={cargarManuales} />
|
||
)}
|
||
|
||
{modalConfig && (
|
||
<ModalConfig ano={ano} mes={mes} asesores={asesoresLista} onClose={() => setModalConfig(false)} onGuardado={cargarTabla} />
|
||
)}
|
||
|
||
{modalBuscar && (
|
||
<ModalBuscar ano={ano} mes={mes} onClose={() => setModalBuscar(false)} onGuardado={cargarTabla} />
|
||
)}
|
||
|
||
{modalAdd && (
|
||
<ModalAgregar ano={ano} mes={mes}
|
||
onClose={() => setModalAdd(false)}
|
||
onGuardado={() => { setModalAdd(false); cargarManuales(); }} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Celda editable con validación según tipo (fecha / dinero / numero / texto)
|
||
function CeldaEdit({ tipo, valor, onChange }) {
|
||
const baseStyle = { width:"100%", minWidth:90, padding:"5px 6px", border:"1px solid #cbd5e1",
|
||
borderRadius:6, fontSize:12, textAlign:"center", boxSizing:"border-box" };
|
||
|
||
// Convierte dd/mm/yyyy → yyyy-mm-dd (para input date)
|
||
function aISO(v) {
|
||
const s = String(v || "").trim();
|
||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0,10);
|
||
const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
|
||
if (m) return `${m[3]}-${m[2].padStart(2,"0")}-${m[1].padStart(2,"0")}`;
|
||
return "";
|
||
}
|
||
// Convierte yyyy-mm-dd → dd/mm/yyyy (para guardar)
|
||
function aDMY(iso) {
|
||
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||
return m ? `${m[3]}/${m[2]}/${m[1]}` : iso;
|
||
}
|
||
|
||
if (tipo === "fecha") {
|
||
return (
|
||
<input type="date" value={aISO(valor)}
|
||
onChange={(e)=>onChange(e.target.value ? aDMY(e.target.value) : "")}
|
||
style={baseStyle} />
|
||
);
|
||
}
|
||
|
||
if (tipo === "dinero") {
|
||
const num = toNum(valor);
|
||
const display = (valor === "" || valor === "-" || valor == null) ? "" : "S/ " + num.toLocaleString("es-PE",{maximumFractionDigits:0});
|
||
return (
|
||
<input value={display}
|
||
onChange={(e)=>{
|
||
// Solo dígitos: "5500" → guarda "S/ 5500"
|
||
const limpio = e.target.value.replace(/[^\d]/g, "");
|
||
onChange(limpio === "" ? "" : "S/ " + parseInt(limpio,10).toLocaleString("es-PE"));
|
||
}}
|
||
placeholder="S/ 0" style={baseStyle} />
|
||
);
|
||
}
|
||
|
||
if (tipo === "numero") {
|
||
return (
|
||
<input value={valor ?? ""}
|
||
onChange={(e)=>onChange(e.target.value.replace(/[^\d-]/g, ""))}
|
||
style={baseStyle} />
|
||
);
|
||
}
|
||
|
||
// texto
|
||
return <input value={valor ?? ""} onChange={(e)=>onChange(e.target.value)} style={baseStyle} />;
|
||
}
|
||
|
||
function ModalEditar({ vendedor, ano, mes, sede = "TODOS", programa = "TODOS", onClose, onGuardado }) {
|
||
const [tipo, setTipo] = useState(OPCIONES_POPUP[0].value);
|
||
const [filas, setFilas] = useState([]);
|
||
const [edits, setEdits] = useState({}); // { num_matricula: { idx: valor } }
|
||
const [loading, setLoading] = useState(true);
|
||
const [guardando, setGuardando] = useState(false);
|
||
const [msg, setMsg] = useState(null);
|
||
const [confirmar, setConfirmar] = useState(null); // matrícula a restaurar
|
||
|
||
function cargar() {
|
||
setLoading(true);
|
||
api.ventasDetalle(vendedor, ano, mes, tipo, sede, programa)
|
||
.then((res) => {
|
||
const soloAlumnos = (res.filas || []).filter((f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL");
|
||
setFilas(soloAlumnos);
|
||
setEdits({});
|
||
setLoading(false);
|
||
})
|
||
.catch(() => { setFilas([]); setLoading(false); });
|
||
}
|
||
useEffect(() => { cargar(); }, [vendedor, ano, mes, tipo, sede, programa]);
|
||
|
||
// Columnas editables: etiqueta + índice en la fila + clave en tabla override
|
||
const CAMPOS = [
|
||
{ label: "F. MATRÍCULA", idx: 2, col: "fch_matricula", tipo: "fecha" },
|
||
{ label: "INVERSIÓN NETA", idx: 7, col: "inversion_neta", tipo: "dinero" },
|
||
{ label: "SALDO MATRÍCULA", idx: 10, col: "saldo_matricula", tipo: "dinero" },
|
||
{ label: "SALDO 1° CUOTA", idx: 11, col: "saldo_cuota1", tipo: "dinero" },
|
||
{ label: "FECHA INICIO", idx: 9, col: "fecha_inicio", tipo: "fecha" },
|
||
{ label: "FECHA CANCELACIÓN 1", idx: 3, col: "fecha_cancelacion1", tipo: "fecha" },
|
||
{ label: "PROMEDIO CUOTA", idx: 5, col: "promedio_cuota", tipo: "dinero" },
|
||
{ label: "DÍAS ANTICIPACIÓN", idx: 12, col: "dias_anticipacion", tipo: "numero" },
|
||
{ label: "VALOR CUOTA ADICIONAL", idx: 13, col: "valor_cuota_adicional", tipo: "dinero" },
|
||
{ label: "SEDE", idx: 14, col: "tipo_programa", tipo: "texto" },
|
||
];
|
||
|
||
function valorActual(f, idx) {
|
||
const mat = String(f[15]);
|
||
if (edits[mat] && edits[mat][idx] !== undefined) return edits[mat][idx];
|
||
return f[idx] ?? "";
|
||
}
|
||
function cambiar(mat, idx, valor) {
|
||
setEdits((prev) => ({ ...prev, [mat]: { ...(prev[mat]||{}), [idx]: valor } }));
|
||
}
|
||
|
||
async function guardar() {
|
||
setGuardando(true); setMsg(null);
|
||
try {
|
||
const registros = Object.keys(edits).map((mat) => {
|
||
const r = { num_matricula: mat };
|
||
CAMPOS.forEach((c) => {
|
||
const e = edits[mat][c.idx];
|
||
// Campo borrado a propósito → marcador "__VACIO__" (fuerza vacío en el override)
|
||
if (e !== undefined) r[c.col] = (String(e).trim() === "") ? "__VACIO__" : String(e);
|
||
});
|
||
return r;
|
||
});
|
||
if (registros.length === 0) { setMsg({ok:false,txt:"No hay cambios para guardar."}); setGuardando(false); return; }
|
||
await api.comisionesOverrideGuardar(registros);
|
||
setMsg({ ok:true, txt:"✅ Cambios guardados." });
|
||
cargar();
|
||
if (onGuardado) onGuardado(); // recarga la tabla principal
|
||
} catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); }
|
||
setGuardando(false);
|
||
}
|
||
|
||
async function restaurar(mat) {
|
||
try { await api.comisionesOverrideRestaurar(mat); cargar(); if (onGuardado) onGuardado(); }
|
||
catch (e) { setMsg({ ok:false, txt:"No se pudo restaurar: " + e.message }); }
|
||
}
|
||
|
||
const th = { background:"#334155", color:"#f1f5f9", padding:8, fontSize:10, fontWeight:600, whiteSpace:"nowrap", textAlign:"center", position:"sticky", top:0, borderRight:"1px solid #475569" };
|
||
|
||
// Columnas redimensionables: ALUMNO + PROGRAMA + CAMPOS + ACCIÓN
|
||
const colsEdit = useColumnasAjustables([200, 220, ...CAMPOS.map(()=>130), 80]);
|
||
|
||
return (
|
||
<Modal title={`✏️ Editar — ${vendedor}`} onClose={onClose} width={1700}>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Select label="Mostrar" value={tipo} options={OPCIONES_POPUP} onChange={setTipo} />
|
||
</div>
|
||
{loading ? <Loader text="Cargando..." /> :
|
||
filas.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin registros.</div> :
|
||
<div className="table-wrap" style={{maxHeight:"60vh",overflow:"auto"}}>
|
||
<table {...colsEdit.tableProps}>
|
||
<colsEdit.ColGroup />
|
||
<thead>
|
||
<tr>
|
||
<th style={{...th,position:"sticky"}}>ALUMNO<colsEdit.Resizer index={0} /></th>
|
||
<th style={{...th,position:"sticky"}}>PROGRAMA<colsEdit.Resizer index={1} /></th>
|
||
{CAMPOS.map((c,ci)=><th key={c.col} style={{...th,position:"sticky"}}>{c.label}<colsEdit.Resizer index={2+ci} /></th>)}
|
||
<th style={{...th,position:"sticky"}}>ACCIÓN<colsEdit.Resizer index={2+CAMPOS.length} /></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filas.map((f, i) => {
|
||
const mat = String(f[15]);
|
||
return (
|
||
<tr key={i} style={String(f[17])==="1" ? {background:BG_EDITADO} : undefined}>
|
||
<td className="col-name" style={{textAlign:"center",whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{f[1]}</td>
|
||
<td className="col-name" style={{textAlign:"center",whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis"}}>{f[8]}</td>
|
||
{CAMPOS.map((c) => (
|
||
<td key={c.col} style={{textAlign:"center"}}>
|
||
<CeldaEdit tipo={c.tipo} valor={valorActual(f, c.idx)}
|
||
onChange={(v)=>cambiar(mat, c.idx, v)} />
|
||
</td>
|
||
))}
|
||
<td style={{textAlign:"center"}}>
|
||
<button title="Restaurar a valores originales"
|
||
style={{border:"none",background:"transparent",cursor:"pointer",fontSize:20,lineHeight:1}}
|
||
onClick={()=>setConfirmar(mat)}>↺</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>}
|
||
|
||
{msg && (
|
||
<div style={{marginTop:12,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:14}}>
|
||
<button className="btn btn-primary" disabled={guardando} onClick={guardar}>
|
||
{guardando ? "Guardando..." : "💾 Guardar"}
|
||
</button>
|
||
<button className="btn btn-ghost" onClick={onClose}>Cerrar</button>
|
||
</div>
|
||
|
||
{confirmar && (
|
||
<ConfirmPopup
|
||
mensaje="¿Restaurar este alumno a sus valores originales? Se borrarán los cambios guardados."
|
||
onCancelar={() => setConfirmar(null)}
|
||
onConfirmar={async () => { await restaurar(confirmar); setConfirmar(null); }}
|
||
/>
|
||
)}
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// Editar vendedor manual = BUSCADOR DE FACTURAS. Busca por deudor o N° comprobante,
|
||
// muestra las facturas y las que marques (con su monto ya en soles) se guardan como
|
||
// filas del vendedor (suman en la tabla principal y en "Ver").
|
||
function ModalEditarManual({ nombre, ano, mes, filasActuales, onClose, onCambio }) {
|
||
const [guardando, setGuardando] = useState(false);
|
||
const [msg, setMsg] = useState(null);
|
||
const [facturas, setFacturas] = useState([]);
|
||
const [tcMes, setTcMes] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [q, setQ] = useState("");
|
||
const [manualOpen, setManualOpen] = useState(false);
|
||
// num_comprobante -> factura seleccionada (para guardar). Inicia con lo ya guardado.
|
||
const [sel, setSel] = useState(() => {
|
||
const m = {};
|
||
let man = 1;
|
||
(filasActuales || []).forEach((r) => {
|
||
const c = String(r.num_comprobante || "").trim();
|
||
const key = c || ("MANUAL_" + (man++)); // sin comprobante → fila manual
|
||
m[key] = {
|
||
num_comprobante: c, dsc_deudor: r.descripcion || "",
|
||
fch_emision: r.fch_emision || "", monto_soles: toNum(r.monto),
|
||
cod_estado: c ? "" : "MANUAL", cod_moneda: "", imp_subtotal: toNum(r.monto), _yaGuardada: true,
|
||
};
|
||
});
|
||
return m;
|
||
});
|
||
|
||
useEffect(() => {
|
||
let activo = true;
|
||
setLoading(true);
|
||
api.facturasBuscar(ano, mes)
|
||
.then((res) => { if (activo) { setFacturas(res.facturas || []); setTcMes(res.tc_mes || null); setLoading(false); } })
|
||
.catch(() => { if (activo) { setFacturas([]); setLoading(false); } });
|
||
return () => { activo = false; };
|
||
}, [ano, mes]);
|
||
|
||
const norm = (s) => String(s || "").toLowerCase();
|
||
const filtradas = useMemo(() => {
|
||
const t = norm(q).trim();
|
||
const base = facturas;
|
||
if (!t) return base.slice(0, 80);
|
||
return base.filter((f) =>
|
||
norm(f.dsc_deudor).includes(t) || norm(f.num_comprobante).includes(t)
|
||
).slice(0, 300);
|
||
}, [facturas, q]);
|
||
|
||
function toggle(f) {
|
||
const c = String(f.num_comprobante || "").trim();
|
||
if (!c) return;
|
||
setSel((p) => {
|
||
const n = { ...p };
|
||
if (n[c]) delete n[c]; else n[c] = f;
|
||
return n;
|
||
});
|
||
}
|
||
|
||
// Agrega un registro MANUAL (sin num_comprobante). Si es DOL, convierte a soles
|
||
// con el TC del mes (o el TC que el usuario escriba). Clave única "MANUAL_n".
|
||
function agregarManual({ dsc_deudor, fch_emision, cod_moneda, subtotal, tc }) {
|
||
const sub = toNum(subtotal);
|
||
let montoSoles = sub;
|
||
if (String(cod_moneda).toUpperCase() === "DOL") {
|
||
const t = toNum(tc) > 0 ? toNum(tc) : (toNum(tcMes) > 0 ? toNum(tcMes) : 1);
|
||
montoSoles = sub * t;
|
||
}
|
||
setSel((p) => {
|
||
let i = 1;
|
||
while (p["MANUAL_" + i]) i++;
|
||
return { ...p, ["MANUAL_" + i]: {
|
||
num_comprobante: "", dsc_deudor: dsc_deudor || "", fch_emision: fch_emision || "",
|
||
cod_estado: "MANUAL", cod_moneda: String(cod_moneda || "SOL").toUpperCase(),
|
||
imp_subtotal: sub, monto_soles: Math.round(montoSoles * 100) / 100,
|
||
} };
|
||
});
|
||
setManualOpen(false);
|
||
}
|
||
|
||
const simboloMoneda = (m) => String(m).toUpperCase() === "DOL" ? "$" : "S/";
|
||
const mesDe = (fch) => {
|
||
const s = String(fch || "");
|
||
if (s.includes("-")) return s.split("-")[1] || "";
|
||
if (s.includes("/")) return s.split("/")[1] || "";
|
||
return "";
|
||
};
|
||
|
||
const totalSel = useMemo(
|
||
() => Object.values(sel).reduce((a, f) => a + toNum(f.monto_soles), 0),
|
||
[sel]);
|
||
|
||
async function guardar() {
|
||
setGuardando(true); setMsg(null);
|
||
try {
|
||
const filas = Object.values(sel).map((f) => ({
|
||
descripcion: f.dsc_deudor || "",
|
||
fch_emision: f.fch_emision || "",
|
||
monto: toNum(f.monto_soles),
|
||
num_comprobante: String(f.num_comprobante || "").trim(),
|
||
}));
|
||
await api.vendedoresManualesLote({ nombre, ano, mes, filas });
|
||
onCambio();
|
||
onClose();
|
||
} catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); setGuardando(false); }
|
||
}
|
||
|
||
const thf = { background:"#334155", color:"#f1f5f9", padding:"8px 8px", fontSize:11,
|
||
fontWeight:700, textAlign:"center", whiteSpace:"nowrap", position:"sticky", top:0 };
|
||
const tdf = { padding:"7px 8px", fontSize:12, borderBottom:"1px solid #f1f5f9", whiteSpace:"nowrap" };
|
||
|
||
return (
|
||
<Modal title={`🔍 Facturas — ${nombre}`} onClose={onClose} width={1000}>
|
||
<div style={{ display:"flex", gap:10, alignItems:"center" }}>
|
||
<input autoFocus value={q} onChange={(e)=>setQ(e.target.value)}
|
||
placeholder="Buscar por deudor o N° de comprobante…"
|
||
style={{ flex:1, padding:"11px 14px", border:"1px solid #cbd5e1",
|
||
borderRadius:10, fontSize:14, boxSizing:"border-box", outline:"none" }} />
|
||
<button className="btn btn-ghost" style={{whiteSpace:"nowrap"}} onClick={()=>setManualOpen(true)}>
|
||
➕ Agregar manual
|
||
</button>
|
||
</div>
|
||
|
||
{manualOpen && (
|
||
<ModalAgregarManualFactura
|
||
tcMes={tcMes}
|
||
onAgregar={agregarManual}
|
||
onClose={()=>setManualOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
<div style={{ marginTop:12, maxHeight:"52vh", overflow:"auto",
|
||
border:"1px solid #e2e8f0", borderRadius:10 }}>
|
||
{loading ? (
|
||
<div style={{ padding:26, textAlign:"center", color:"#94a3b8" }}>Cargando facturas…</div>
|
||
) : (
|
||
<table style={{ width:"100%", borderCollapse:"collapse" }}>
|
||
<thead><tr>
|
||
<th style={{...thf, width:36}}></th>
|
||
<th style={thf}>MES</th>
|
||
<th style={thf}>FECHA EMISIÓN</th>
|
||
<th style={thf}>N° FACTURA</th>
|
||
<th style={{...thf, textAlign:"left"}}>DEUDOR</th>
|
||
<th style={thf}>ESTADO</th>
|
||
<th style={thf}>MONEDA</th>
|
||
<th style={thf}>SUBTOTAL</th>
|
||
<th style={thf}>MONTO (S/)</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
{filtradas.length === 0 && (
|
||
<tr><td colSpan={9} style={{ padding:22, textAlign:"center", color:"#94a3b8" }}>
|
||
{q ? "Sin resultados." : "Escribe para buscar."}
|
||
</td></tr>
|
||
)}
|
||
{filtradas.map((f, i) => {
|
||
const c = String(f.num_comprobante || "").trim();
|
||
const on = !!sel[c];
|
||
return (
|
||
<tr key={i} style={on ? { background:"#eff6ff" } : undefined}>
|
||
<td style={{...tdf, textAlign:"center"}}>
|
||
<input type="checkbox" checked={on} onChange={()=>toggle(f)} style={{cursor:"pointer"}} />
|
||
</td>
|
||
<td style={{...tdf, textAlign:"center"}}>{mesDe(f.fch_emision)}</td>
|
||
<td style={{...tdf, textAlign:"center"}}>{f.fch_emision}</td>
|
||
<td style={{...tdf, textAlign:"center"}}>{f.num_comprobante}</td>
|
||
<td style={{...tdf, textAlign:"left", maxWidth:260, overflow:"hidden", textOverflow:"ellipsis"}}>{f.dsc_deudor}</td>
|
||
<td style={{...tdf, textAlign:"center"}}>{f.cod_estado}</td>
|
||
<td style={{...tdf, textAlign:"center"}}>{simboloMoneda(f.cod_moneda)}</td>
|
||
<td style={{...tdf, textAlign:"right"}}>{simboloMoneda(f.cod_moneda)} {toNum(f.imp_subtotal).toLocaleString("es-PE",{maximumFractionDigits:0})}</td>
|
||
<td style={{...tdf, textAlign:"right", fontWeight:700, color:"#1e3a5f"}}>S/ {toNum(f.monto_soles).toLocaleString("es-PE",{maximumFractionDigits:0})}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
{msg && (
|
||
<div style={{marginTop:12,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:14,alignItems:"center"}}>
|
||
<span style={{fontSize:13,fontWeight:600,color:"#334155",marginRight:"auto"}}>
|
||
Seleccionadas: <b>{Object.keys(sel).length}</b> · Total: <b>S/ {totalSel.toLocaleString("es-PE",{maximumFractionDigits:0})}</b>
|
||
</span>
|
||
<button className="btn btn-primary" disabled={guardando} onClick={guardar}>
|
||
{guardando ? "Guardando..." : "💾 Guardar"}
|
||
</button>
|
||
<button className="btn btn-ghost" onClick={onClose}>Cerrar</button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// Pop-up para agregar un registro MANUAL (como un comprobante, pero sin N°).
|
||
// Campos: Deudor, Fecha emisión, Moneda, Subtotal. Si DOL, se convierte a soles.
|
||
function ModalAgregarManualFactura({ tcMes, onAgregar, onClose }) {
|
||
const [deudor, setDeudor] = useState("");
|
||
const [fecha, setFecha] = useState("");
|
||
const [moneda, setMoneda] = useState("SOL");
|
||
const [subtotal, setSubtotal] = useState("");
|
||
const [tc, setTc] = useState(tcMes ? String(tcMes) : "");
|
||
const [err, setErr] = useState("");
|
||
|
||
const esDol = moneda === "DOL";
|
||
const montoPrev = (() => {
|
||
const s = toNum(subtotal);
|
||
if (!esDol) return s;
|
||
const t = toNum(tc) > 0 ? toNum(tc) : (toNum(tcMes) > 0 ? toNum(tcMes) : 1);
|
||
return s * t;
|
||
})();
|
||
|
||
function agregar() {
|
||
if (!deudor.trim()) { setErr("El deudor es obligatorio."); return; }
|
||
if (toNum(subtotal) <= 0) { setErr("El subtotal debe ser mayor a 0."); return; }
|
||
onAgregar({ dsc_deudor: deudor.trim(), fch_emision: fecha, cod_moneda: moneda, subtotal, tc });
|
||
}
|
||
|
||
const inp = { width:"100%", padding:"9px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, boxSizing:"border-box" };
|
||
|
||
return (
|
||
<div onClick={onClose}
|
||
style={{ position:"fixed", inset:0, background:"rgba(15,23,42,.55)", zIndex:1100,
|
||
display:"flex", alignItems:"center", justifyContent:"center", padding:20 }}>
|
||
<div onClick={(e)=>e.stopPropagation()}
|
||
style={{ background:"#fff", borderRadius:14, width:"min(440px,95vw)", padding:20,
|
||
boxShadow:"0 20px 60px rgba(0,0,0,.3)" }}>
|
||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
|
||
<h3 style={{margin:0,fontSize:16,fontWeight:800,color:"#1e3a5f"}}>➕ Agregar manual</h3>
|
||
<button onClick={onClose} style={{border:"none",background:"#f1f5f9",borderRadius:8,width:30,height:30,cursor:"pointer",fontSize:15,color:"#475569"}}>×</button>
|
||
</div>
|
||
<div style={{display:"flex",flexDirection:"column",gap:11}}>
|
||
<div>
|
||
<label style={lbl}>Deudor</label>
|
||
<input value={deudor} onChange={(e)=>setDeudor(e.target.value)} style={inp} placeholder="Empresa / nombre" autoFocus />
|
||
</div>
|
||
<div>
|
||
<label style={lbl}>Fecha emisión</label>
|
||
<input type="date" value={fecha} onChange={(e)=>setFecha(e.target.value)} style={inp} />
|
||
</div>
|
||
<div style={{display:"flex",gap:10}}>
|
||
<div style={{flex:1}}>
|
||
<label style={lbl}>Moneda</label>
|
||
<select value={moneda} onChange={(e)=>setMoneda(e.target.value)} style={inp}>
|
||
<option value="SOL">SOL (S/)</option>
|
||
<option value="DOL">DÓLARES ($)</option>
|
||
</select>
|
||
</div>
|
||
<div style={{flex:1}}>
|
||
<label style={lbl}>Subtotal</label>
|
||
<input type="number" value={subtotal} onChange={(e)=>setSubtotal(e.target.value)} style={inp} placeholder="0" />
|
||
</div>
|
||
</div>
|
||
{esDol && (
|
||
<div>
|
||
<label style={lbl}>TC (tipo de cambio)</label>
|
||
<input type="number" value={tc} onChange={(e)=>setTc(e.target.value)} style={inp}
|
||
placeholder={tcMes ? `TC del mes: ${tcMes}` : "TC"} />
|
||
</div>
|
||
)}
|
||
<div style={{fontSize:13,color:"#334155",fontWeight:600}}>
|
||
Monto en soles: <b style={{color:"#1e3a5f"}}>S/ {Math.round(montoPrev).toLocaleString("es-PE")}</b>
|
||
</div>
|
||
{err && <div style={{background:"#fee2e2",color:"#991b1b",padding:8,borderRadius:8,fontSize:12.5}}>{err}</div>}
|
||
<div style={{display:"flex",gap:10,marginTop:4}}>
|
||
<button className="btn btn-primary" onClick={agregar}>Agregar</button>
|
||
<button className="btn btn-ghost" onClick={onClose}>Cancelar</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ModalConfig({ ano, mes, asesores = [], onClose, onGuardado }) {
|
||
// Grupos de la matriz de metas por asesor.
|
||
const GRUPOS = ["PT", "SEMINARIOS", "OTROS"];
|
||
const vacio = () => ({ meta:"", bajo:"", sobre:"" });
|
||
const [tc, setTc] = useState(""); // IMP_TC único del mes (se mantiene igual)
|
||
const [matriz, setMatriz] = useState({}); // { [asesor]: { PT:{meta,bajo,sobre}, SEMINARIOS:{...}, OTROS:{...} } }
|
||
const [jefe, setJefe] = useState(vacio()); // JEFE COMERCIAL (grupo TOTAL): una sola meta
|
||
const [loading, setLoading] = useState(true);
|
||
const [guardando, setGuardando] = useState(false);
|
||
const [msg, setMsg] = useState(null);
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
Promise.all([
|
||
api.comisionesConfig(ano, mes).catch(() => ({ config: [] })),
|
||
api.comisionesMetas(ano, mes).catch(() => ({ metas: [] })),
|
||
]).then(([cfg, met]) => {
|
||
// TC: tomar el primero que tenga valor (es único por mes)
|
||
const tcGuardado = (cfg.config || []).find((x) => +x.imp_tc > 0);
|
||
setTc(tcGuardado ? String(tcGuardado.imp_tc) : "");
|
||
|
||
const guardadas = met.metas || [];
|
||
const buscar = (asesor, grupo) =>
|
||
guardadas.find((x) => x.asesor === asesor && x.grupo === grupo);
|
||
const toStr = (g) => g ? {
|
||
meta: g.meta != null ? String(g.meta) : "",
|
||
bajo: g.pct_bajo_meta != null ? String(g.pct_bajo_meta) : "",
|
||
sobre: g.pct_sobre_meta != null ? String(g.pct_sobre_meta) : "",
|
||
} : vacio();
|
||
|
||
const m = {};
|
||
asesores.forEach((a) => {
|
||
m[a] = {};
|
||
GRUPOS.forEach((grp) => { m[a][grp] = toStr(buscar(a, grp)); });
|
||
});
|
||
setMatriz(m);
|
||
setJefe(toStr(buscar("JEFE COMERCIAL", "TOTAL")));
|
||
setLoading(false);
|
||
});
|
||
}, [ano, mes, asesores.join("|")]);
|
||
|
||
function cambiar(asesor, grupo, campo, val) {
|
||
setMatriz((prev) => ({
|
||
...prev,
|
||
[asesor]: { ...prev[asesor], [grupo]: { ...(prev[asesor]?.[grupo] || vacio()), [campo]: val } },
|
||
}));
|
||
}
|
||
function cambiarJefe(campo, val) {
|
||
setJefe((prev) => ({ ...prev, [campo]: val }));
|
||
}
|
||
|
||
async function guardar() {
|
||
setGuardando(true); setMsg(null);
|
||
try {
|
||
// (a) Guardar el TC como siempre (en comisiones_config_mensual).
|
||
await api.comisionesConfigGuardar({
|
||
ano, mes,
|
||
filas: [{ tipo_programa: "TC", imp_tc: toNum(tc), meta: 0, comision: 0 }],
|
||
});
|
||
|
||
// (b) Armar filas de metas por asesor y grupo (solo las que tengan algún valor).
|
||
const filas = [];
|
||
const tieneValor = (c) => c && (c.meta !== "" || c.bajo !== "" || c.sobre !== "");
|
||
asesores.forEach((a) => {
|
||
GRUPOS.forEach((grp) => {
|
||
const c = matriz[a]?.[grp];
|
||
if (tieneValor(c)) {
|
||
filas.push({ asesor: a, grupo: grp,
|
||
meta: toNum(c.meta), pct_bajo_meta: toNum(c.bajo), pct_sobre_meta: toNum(c.sobre) });
|
||
}
|
||
});
|
||
});
|
||
if (tieneValor(jefe)) {
|
||
filas.push({ asesor: "JEFE COMERCIAL", grupo: "TOTAL",
|
||
meta: toNum(jefe.meta), pct_bajo_meta: toNum(jefe.bajo), pct_sobre_meta: toNum(jefe.sobre) });
|
||
}
|
||
await api.comisionesMetasGuardar({ ano, mes, filas });
|
||
|
||
if (onGuardado) onGuardado(); // recarga la tabla principal
|
||
onClose();
|
||
} catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); setGuardando(false); }
|
||
}
|
||
|
||
const inp = { width:"100%", padding:"6px 6px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:12.5, textAlign:"center", boxSizing:"border-box" };
|
||
const MESES_N = ["ENERO","FEBRERO","MARZO","ABRIL","MAYO","JUNIO","JULIO","AGOSTO","SEPTIEMBRE","OCTUBRE","NOVIEMBRE","DICIEMBRE"];
|
||
|
||
// Estilos de la matriz
|
||
const HDR_GRP = { background:"#B4C6E7", color:"#1e293b", fontWeight:700, fontSize:12.5, textAlign:"center", padding:"7px 6px", border:"1px solid #93a7cf" };
|
||
const HDR_SUB = { background:"#DCE6F5", color:"#334155", fontWeight:600, fontSize:11.5, textAlign:"center", padding:"6px 5px", border:"1px solid #c3d2ea", whiteSpace:"nowrap" };
|
||
const TD = { border:"1px solid #e2e8f0", padding:3, background:"#fff" };
|
||
const TD_NAME = { border:"1px solid #e2e8f0", padding:"6px 10px", fontWeight:600, fontSize:12.5, color:"#1e293b", background:"#f8fafc", whiteSpace:"nowrap", position:"sticky", left:0, zIndex:1 };
|
||
const SUBS = [["meta","META"],["bajo","% <META"],["sobre","% >META"]];
|
||
|
||
return (
|
||
<Modal title="⚙️ Configuración Mensual — TC y Metas por Asesor" onClose={onClose} width={1120}>
|
||
<div style={{fontSize:13,color:"#475569",marginBottom:16}}>
|
||
Configuración para <b>{MESES_N[mes-1]} {ano}</b>.
|
||
</div>
|
||
{loading ? <Loader text="Cargando configuración..." /> : <>
|
||
{/* IMP_TC único del mes (SIN CAMBIOS) */}
|
||
<div style={{display:"flex",alignItems:"center",gap:12,marginBottom:18,padding:"14px 16px",
|
||
background:"#f0f9ff",borderRadius:10,border:"1px solid #bae6fd"}}>
|
||
<label style={{fontSize:14,fontWeight:600,color:"#0c4a6e"}}>IMP_TC del mes:</label>
|
||
<input type="number" step="0.01" value={tc} placeholder="ej. 3.40"
|
||
onChange={(e)=>setTc(e.target.value)}
|
||
style={{...inp,maxWidth:160}} />
|
||
<span style={{fontSize:12,color:"#64748b"}}>Se usará para convertir dólares este mes. Si lo dejas vacío, usa el TC de la base.</span>
|
||
</div>
|
||
|
||
{/* MATRIZ DE METAS POR ASESOR */}
|
||
<div style={{fontSize:13,fontWeight:700,color:"#1e293b",margin:"4px 0 8px"}}>Metas por asesor</div>
|
||
{asesores.length === 0 ? (
|
||
<div style={{color:"#94a3b8",padding:16,textAlign:"center",border:"1px dashed #cbd5e1",borderRadius:8}}>
|
||
No hay asesores en este mes.
|
||
</div>
|
||
) : (
|
||
<div style={{overflowX:"auto",border:"1px solid #e2e8f0",borderRadius:10}}>
|
||
<table style={{borderCollapse:"collapse",width:"100%",minWidth:900}}>
|
||
<colgroup>
|
||
<col style={{width:150}} />
|
||
{GRUPOS.map((grp) => (
|
||
<Fragment key={"cg"+grp}>
|
||
<col style={{width:110}} /> {/* META más ancha */}
|
||
<col style={{width:72}} /> {/* % <META */}
|
||
<col style={{width:72}} /> {/* % >META */}
|
||
</Fragment>
|
||
))}
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th rowSpan={2} style={{...HDR_GRP, textAlign:"left", position:"sticky", left:0, zIndex:2}}>ASESOR</th>
|
||
{GRUPOS.map((grp) => (
|
||
<th key={grp} colSpan={3} style={HDR_GRP}>{grp}</th>
|
||
))}
|
||
</tr>
|
||
<tr>
|
||
{GRUPOS.map((grp) => SUBS.map(([k, lbl]) => (
|
||
<th key={grp + k} style={HDR_SUB}>{lbl}</th>
|
||
)))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{asesores.map((a) => (
|
||
<tr key={a}>
|
||
<td style={TD_NAME}>{a}</td>
|
||
{GRUPOS.map((grp) => SUBS.map(([k]) => {
|
||
const esPct = (k === "bajo" || k === "sobre");
|
||
return (
|
||
<td key={grp + k} style={TD}>
|
||
<div style={{display:"flex",alignItems:"center",gap:3}}>
|
||
<input type="number" step="0.01"
|
||
value={matriz[a]?.[grp]?.[k] ?? ""}
|
||
placeholder={esPct ? "" : "0"}
|
||
onChange={(e)=>cambiar(a, grp, k, e.target.value)}
|
||
style={inp} />
|
||
{esPct && <span style={{fontSize:11,color:"#94a3b8"}}>%</span>}
|
||
</div>
|
||
</td>
|
||
);
|
||
}))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* JEFE COMERCIAL — una sola meta (grupo TOTAL) */}
|
||
<div style={{marginTop:18}}>
|
||
<div style={{fontSize:13,fontWeight:700,color:"#7c2d12",margin:"0 0 8px"}}>Jefe Comercial</div>
|
||
<div style={{display:"flex",alignItems:"center",gap:14,padding:"14px 16px",
|
||
background:"#fff7ed",borderRadius:10,border:"1px solid #fed7aa"}}>
|
||
<div style={{fontSize:13,fontWeight:700,color:"#7c2d12",minWidth:130}}>JEFE COMERCIAL</div>
|
||
{SUBS.map(([k, lbl]) => {
|
||
const esPct = (k === "bajo" || k === "sobre");
|
||
return (
|
||
<div key={k} style={{display:"flex",flexDirection:"column",gap:4}}>
|
||
<label style={{fontSize:11,fontWeight:600,color:"#9a3412",textAlign:"center"}}>{lbl}</label>
|
||
<div style={{display:"flex",alignItems:"center",gap:4}}>
|
||
<input type="number" step="0.01" value={jefe[k]} placeholder={esPct ? "" : "0"}
|
||
onChange={(e)=>cambiarJefe(k, e.target.value)}
|
||
style={{...inp, width:esPct ? 90 : 130}} />
|
||
{esPct && <span style={{fontSize:12,color:"#9a3412",fontWeight:600}}>%</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</>}
|
||
{msg && (
|
||
<div style={{marginTop:12,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 ModalBuscar({ ano, mes, onClose, onGuardado }) {
|
||
const [todos, setTodos] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [q, setQ] = useState(""); // lo que se escribe
|
||
const [qAplicado, setQAplicado] = useState(""); // lo que se busca (con debounce)
|
||
const [modo, setModo] = useState("VER"); // VER | EDITAR
|
||
const [edits, setEdits] = useState({}); // { num_matricula: { idx: valor } }
|
||
const [guardando, setGuardando] = useState(false);
|
||
const [msg, setMsg] = useState(null);
|
||
const [confirmar, setConfirmar] = useState(null); // matrícula a restaurar
|
||
const [ordPos, setOrdPos] = useState(null);
|
||
const [ordDir, setOrdDir] = useState(null);
|
||
const clickOrden = (pos) => {
|
||
if (ordPos !== pos) { setOrdPos(pos); setOrdDir("asc"); }
|
||
else if (ordDir === "asc") setOrdDir("desc");
|
||
else { setOrdPos(null); setOrdDir(null); }
|
||
};
|
||
|
||
function cargar() {
|
||
setLoading(true);
|
||
api.comisionesDetalleTodos(ano, mes)
|
||
.then((res) => { setTodos(res.filas || []); setLoading(false); })
|
||
.catch(() => { setTodos([]); setLoading(false); });
|
||
}
|
||
useEffect(() => { cargar(); }, [ano, mes]);
|
||
|
||
async function restaurar(mat) {
|
||
try {
|
||
await api.comisionesOverrideRestaurar(mat);
|
||
setEdits((p) => { const c = {...p}; delete c[mat]; return c; });
|
||
cargar();
|
||
if (onGuardado) onGuardado(); // recarga la tabla principal
|
||
setMsg({ ok:true, txt:"✅ Alumno restaurado a sus valores originales." });
|
||
} catch (e) { setMsg({ ok:false, txt:"No se pudo restaurar: " + e.message }); }
|
||
}
|
||
|
||
// Debounce: aplica la búsqueda 250ms después de dejar de escribir (evita lag)
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setQAplicado(q.trim().toLowerCase()), 250);
|
||
return () => clearTimeout(t);
|
||
}, [q]);
|
||
|
||
// Filtrado en memoria, limitado a 100 resultados para no congelar el render
|
||
const resultados = useMemo(() => {
|
||
if (!qAplicado) return [];
|
||
const out = [];
|
||
for (let i = 0; i < todos.length && out.length < 100; i++) {
|
||
const f = todos[i];
|
||
if (String(f[1]).toLowerCase().includes(qAplicado) ||
|
||
String(f[8]).toLowerCase().includes(qAplicado) ||
|
||
String(f[0]).toLowerCase().includes(qAplicado)) {
|
||
out.push(f);
|
||
}
|
||
}
|
||
return out;
|
||
}, [qAplicado, todos]);
|
||
|
||
const HEADERS = ["VENDEDOR","ALUMNO","PROGRAMA","F. MATRÍCULA","INVERSIÓN NETA","SALDO MATRÍCULA","SALDO 1° CUOTA","FECHA INICIO","FECHA CANCELACIÓN 1","PROMEDIO CUOTA","DÍAS ANTICIPACIÓN","VALOR CUOTA ADICIONAL","SEDE"];
|
||
const ORDEN = [0, 1, 8, 2, 7, 10, 11, 9, 3, 5, 12, 13, 14];
|
||
const COL_MONTO = new Set([4, 5, 6, 9, 11]);
|
||
// Campos editables (mismos que el Editar normal): idx de fila → tipo
|
||
const EDITABLES = { 2:"fecha", 7:"dinero", 10:"dinero", 11:"dinero", 9:"fecha", 3:"fecha", 5:"dinero", 12:"numero", 13:"dinero", 14:"texto" };
|
||
|
||
// Orden por columna — solo en modo VER (en EDITAR se mantiene el orden original).
|
||
const resultadosOrd = useMemo(() => {
|
||
if (modo !== "VER" || ordPos == null || !ordDir) return resultados;
|
||
const idx = ORDEN[ordPos];
|
||
return [...resultados].sort((a, b) => _cmp(a[idx], b[idx], ordDir));
|
||
}, [resultados, modo, ordPos, ordDir]);
|
||
|
||
// Anchos iniciales por columna (VENDEDOR/ALUMNO/PROGRAMA más anchas); +ACCIÓN en modo EDITAR
|
||
const anchosBuscar = [180, 220, 240, ...Array(HEADERS.length-3).fill(130), ...(modo==="EDITAR" ? [80] : [])];
|
||
const colsBuscar = useColumnasAjustables(anchosBuscar);
|
||
|
||
function setEdit(mat, idx, val) {
|
||
setEdits((p) => ({ ...p, [mat]: { ...(p[mat]||{}), [idx]: val } }));
|
||
}
|
||
|
||
async function guardar() {
|
||
setGuardando(true); setMsg(null);
|
||
try {
|
||
const registros = Object.entries(edits).map(([mat, campos]) => {
|
||
const r = { num_matricula: mat };
|
||
Object.entries(campos).forEach(([idx, val]) => {
|
||
const map = {2:"fch_matricula",7:"inversion_neta",10:"saldo_matricula",11:"saldo_cuota1",9:"fecha_inicio",3:"fecha_cancelacion1",5:"promedio_cuota",12:"dias_anticipacion",13:"valor_cuota_adicional",14:"tipo_programa"};
|
||
// Campo borrado a propósito → marcador "__VACIO__"
|
||
if (map[idx]) r[map[idx]] = (String(val).trim() === "") ? "__VACIO__" : val;
|
||
});
|
||
return r;
|
||
});
|
||
await api.comisionesOverrideGuardar(registros);
|
||
setEdits({});
|
||
if (onGuardado) onGuardado();
|
||
cargar(); // recargar la tabla del modal para ver los valores nuevos
|
||
setMsg({ ok:true, txt:"✅ Cambios guardados." });
|
||
setGuardando(false);
|
||
} catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); setGuardando(false); }
|
||
}
|
||
|
||
return (
|
||
<Modal title="🔍 Buscar Alumno" onClose={onClose} width={1600}>
|
||
<div style={{display:"flex",gap:12,alignItems:"center",marginBottom:14}}>
|
||
<input autoFocus value={q} onChange={(e)=>setQ(e.target.value)}
|
||
placeholder="Escribe nombre de alumno, programa o vendedor..."
|
||
style={{flex:1,padding:"11px 14px",border:"1px solid #cbd5e1",borderRadius:10,fontSize:14,boxSizing:"border-box"}} />
|
||
{/* Switch VER / EDITAR */}
|
||
<div style={{display:"flex",border:"1px solid #cbd5e1",borderRadius:8,overflow:"hidden"}}>
|
||
{["VER","EDITAR"].map((m) => (
|
||
<button key={m} onClick={()=>setModo(m)}
|
||
style={{padding:"9px 18px",border:"none",cursor:"pointer",fontSize:13,fontWeight:600,
|
||
background: modo===m ? "#2563eb":"#fff", color: modo===m ? "#fff":"#475569"}}>{m}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? <Loader text="Cargando alumnos..." /> :
|
||
!qAplicado ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Escribe para buscar.</div> :
|
||
resultados.length === 0 ? <div style={{color:"#94a3b8",padding:20,textAlign:"center"}}>Sin coincidencias.</div> :
|
||
<div className="table-wrap" style={{maxHeight:"55vh",overflow:"auto"}}>
|
||
<div style={{fontSize:13,color:"#1e40af",fontWeight:600,marginBottom:8}}>
|
||
{resultados.length}{resultados.length===100?"+":""} resultado(s) {modo==="EDITAR" && "— modo edición"}
|
||
</div>
|
||
<table {...colsBuscar.tableProps}>
|
||
<colsBuscar.ColGroup />
|
||
<thead><tr>
|
||
{HEADERS.map((h,hi)=>(
|
||
<th key={h} onClick={()=> modo==="VER" && clickOrden(hi)}
|
||
style={{textAlign:"center",position:"relative", cursor: modo==="VER"?"pointer":"default", userSelect:"none"}}>
|
||
{h}{modo==="VER" && _flecha(ordPos===hi, ordDir)}<colsBuscar.Resizer index={hi} />
|
||
</th>
|
||
))}
|
||
{modo==="EDITAR" && <th style={{textAlign:"center",position:"relative"}}>ACCIÓN<colsBuscar.Resizer index={HEADERS.length} /></th>}
|
||
</tr></thead>
|
||
<tbody>
|
||
{resultadosOrd.map((f, i) => {
|
||
const mat = f[15];
|
||
return (
|
||
<tr key={i} style={String(f[17])==="1" ? {background:BG_EDITADO} : undefined}>
|
||
{ORDEN.map((idx, pos) => {
|
||
const esTexto = pos===0||pos===1||pos===2;
|
||
const editable = modo==="EDITAR" && EDITABLES[idx] && idx!==0 && idx!==1 && idx!==8;
|
||
const valActual = (edits[mat] && edits[mat][idx] !== undefined) ? edits[mat][idx] : (f[idx] ?? "");
|
||
return (
|
||
<td key={pos} className={esTexto?"col-name":""}
|
||
style={{textAlign:"center",
|
||
whiteSpace: esTexto?"nowrap":undefined, overflow:esTexto?"hidden":undefined, textOverflow:esTexto?"ellipsis":undefined}}>
|
||
{editable
|
||
? <CeldaEdit tipo={EDITABLES[idx]} valor={valActual}
|
||
onChange={(v)=>setEdit(mat, idx, v)} />
|
||
: (COL_MONTO.has(pos) ? fmt(f[idx]) : (f[idx] ?? ""))}
|
||
</td>
|
||
);
|
||
})}
|
||
{modo==="EDITAR" && (
|
||
<td style={{textAlign:"center"}}>
|
||
<button title="Restaurar a valores originales (borra el override de este alumno)"
|
||
style={{border:"none",background:"transparent",cursor:"pointer",fontSize:20,lineHeight:1}}
|
||
onClick={()=>setConfirmar(mat)}>↺</button>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
);})}
|
||
</tbody>
|
||
</table>
|
||
</div>}
|
||
|
||
{msg && (
|
||
<div style={{marginTop:12,padding:10,borderRadius:8,fontSize:13,
|
||
background: msg.ok ? "#d1fae5":"#fee2e2", color: msg.ok ? "#065f46":"#991b1b"}}>{msg.txt}</div>
|
||
)}
|
||
{modo==="EDITAR" && (
|
||
<div style={{display:"flex",gap:10,marginTop:14}}>
|
||
<button className="btn btn-primary" disabled={guardando || Object.keys(edits).length===0} onClick={guardar}>
|
||
{guardando ? "Guardando..." : "💾 Guardar cambios"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{confirmar && (
|
||
<ConfirmPopup
|
||
mensaje="¿Restaurar este alumno a sus valores originales? Se borrarán los cambios guardados en Supabase."
|
||
onCancelar={() => setConfirmar(null)}
|
||
onConfirmar={async () => { await restaurar(confirmar); setConfirmar(null); }}
|
||
/>
|
||
)}
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function ModalAgregar({ ano, mes, onClose, onGuardado }) {
|
||
const [nombre, setNombre] = useState("");
|
||
const [guardando, setGuardando] = useState(false);
|
||
const [msg, setMsg] = useState(null);
|
||
|
||
async function guardar() {
|
||
if (!nombre.trim()) { setMsg("El nombre es obligatorio."); return; }
|
||
setGuardando(true); setMsg(null);
|
||
try {
|
||
// Solo se crea el vendedor (nombre). La descripción, fecha y monto se
|
||
// agregan luego desde el botón "Editar".
|
||
await api.vendedoresManualesCrear({
|
||
nombre: nombre.trim(), descripcion: "", fch_emision: "", monto: 0, ano, mes,
|
||
});
|
||
onGuardado();
|
||
} catch (e) {
|
||
setMsg("Error al guardar: " + e.message);
|
||
setGuardando(false);
|
||
}
|
||
}
|
||
|
||
const inp = { width:"100%", padding:"9px 12px", border:"1px solid #cbd5e1", borderRadius:8, fontSize:13, boxSizing:"border-box" };
|
||
|
||
return (
|
||
<Modal title="➕ Añadir Vendedor" onClose={onClose} width={460}>
|
||
<div style={{ display:"flex", flexDirection:"column", gap:12 }}>
|
||
<div>
|
||
<label style={lbl}>Nombre</label>
|
||
<input value={nombre} onChange={(e)=>setNombre(e.target.value)}
|
||
onKeyDown={(e)=>{ if(e.key==="Enter" && !guardando) guardar(); }}
|
||
style={inp} placeholder="Nombre del vendedor" autoFocus />
|
||
</div>
|
||
{msg && <div style={{background:"#fee2e2",color:"#991b1b",padding:8,borderRadius:8,fontSize:13}}>{msg}</div>}
|
||
<div style={{ display:"flex", gap:10, marginTop:4 }}>
|
||
<button className="btn btn-primary" disabled={guardando} onClick={guardar}>
|
||
{guardando ? "Guardando..." : "💾 Guardar"}
|
||
</button>
|
||
<button className="btn btn-ghost" onClick={onClose}>Cancelar</button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
const lbl = { fontSize:12, fontWeight:600, color:"#475569", display:"block", marginBottom:5 };
|
||
|
||
function ModalDetalle({ vendedor, manual, ano, mes, sede = "TODOS", programa = "TODOS", onClose, onEliminar }) {
|
||
const [tipo, setTipo] = useState(OPCIONES_POPUP[0].value);
|
||
const [filas, setFilas] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [eliminando, setEliminando] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (manual) { setLoading(false); return; } // manual no consulta detalle
|
||
let activo = true;
|
||
setLoading(true);
|
||
api.ventasDetalle(vendedor, ano, mes, tipo, sede, programa)
|
||
.then((res) => {
|
||
if (!activo) return;
|
||
const soloAlumnos = (res.filas || []).filter(
|
||
(f) => String(f[0]).toUpperCase() !== "TOTAL GENERAL" && fechaMatDesde2025(f[2])
|
||
);
|
||
setFilas(soloAlumnos);
|
||
setLoading(false);
|
||
})
|
||
.catch(() => { if (activo) { setFilas([]); setLoading(false); } });
|
||
return () => { activo = false; };
|
||
}, [vendedor, ano, mes, tipo, manual, sede, programa]);
|
||
|
||
async function eliminar() {
|
||
if (!confirm(`¿Eliminar al vendedor "${manual.nombre}"?`)) return;
|
||
setEliminando(true);
|
||
try { await api.vendedoresManualesEliminar(manual.id); onEliminar(); }
|
||
catch (e) { alert("No se pudo eliminar: " + e.message); setEliminando(false); }
|
||
}
|
||
|
||
// ── Vista para vendedor MANUAL ──
|
||
if (manual) {
|
||
const filasM = manual.filas || [manual];
|
||
const totalM = filasM.reduce((s,r)=>s+toNum(r.monto),0);
|
||
return (
|
||
<Modal title={`🧮 ${manual.nombre}`} onClose={onClose} width={760}>
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th style={{textAlign:"center"}}>VENDEDOR</th>
|
||
<th style={{textAlign:"center"}}>DESCRIPCIÓN</th>
|
||
<th style={{textAlign:"center"}}>FECHA EMISIÓN</th>
|
||
<th style={{textAlign:"center"}}>MONTO</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
{filasM.map((r) => (
|
||
<tr key={r.id}>
|
||
<td className="col-name" style={{textAlign:"center"}}>{r.nombre}</td>
|
||
<td style={{textAlign:"center"}}>{r.descripcion || "—"}</td>
|
||
<td style={{textAlign:"center"}}>{r.fch_emision || "—"}</td>
|
||
<td style={{textAlign:"center"}}>{fmt(r.monto)}</td>
|
||
</tr>
|
||
))}
|
||
<tr className="total-row">
|
||
<td className="col-name" style={{textAlign:"center"}}>TOTAL GENERAL</td>
|
||
<td></td><td></td>
|
||
<td style={{textAlign:"center"}}>{fmt(totalM)}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div style={{display:"flex",justifyContent:"flex-end",marginTop:14}}>
|
||
<button className="btn btn-ghost" onClick={onClose}>Cerrar</button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// Orden solicitado de columnas
|
||
// VENDEDOR quitado de este pop-up (Ver). ORDEN mapea posición→índice real de la fila.
|
||
const HEADERS = ["ALUMNO","PROGRAMA","F. MATRÍCULA","INVERSIÓN NETA","SALDO MATRÍCULA","SALDO 1° CUOTA","FECHA INICIO","FECHA CANCELACIÓN 1","PROMEDIO CUOTA","DÍAS ANTICIPACIÓN","VALOR CUOTA ADICIONAL","SEDE"];
|
||
const ORDEN = [1, 8, 2, 7, 10, 11, 9, 3, 5, 12, 13, 14];
|
||
// COL_MONTO por POSICIÓN visible (INVERSIÓN NETA, SALDO MAT, SALDO 1°, PROMEDIO, VALOR ADIC)
|
||
const COL_MONTO = new Set([3, 4, 5, 8, 10]);
|
||
|
||
// Anchos iniciales por columna (px) — el usuario los puede arrastrar
|
||
const ANCHOS_INI = [240, 280, 110, 130, 130, 130, 110, 140, 120, 120, 150, 130];
|
||
const [anchos, setAnchos] = useState(ANCHOS_INI);
|
||
|
||
function iniciarResize(e, i) {
|
||
e.preventDefault();
|
||
const xInicial = e.clientX;
|
||
const anchoInicial = anchos[i];
|
||
function onMove(ev) {
|
||
const nuevo = Math.max(60, anchoInicial + (ev.clientX - xInicial));
|
||
setAnchos((prev) => { const c = [...prev]; c[i] = nuevo; return c; });
|
||
}
|
||
function onUp() {
|
||
document.removeEventListener("mousemove", onMove);
|
||
document.removeEventListener("mouseup", onUp);
|
||
}
|
||
document.addEventListener("mousemove", onMove);
|
||
document.addEventListener("mouseup", onUp);
|
||
}
|
||
|
||
const totalInv = useMemo(() => filas.reduce((s,f)=>s+toNum(f[7]),0), [filas]);
|
||
|
||
// Orden por columna (usa el índice real ORDEN[pos]).
|
||
const [ordPos, setOrdPos] = useState(null);
|
||
const [ordDir, setOrdDir] = useState(null);
|
||
const clickOrden = (pos) => {
|
||
if (ordPos !== pos) { setOrdPos(pos); setOrdDir("asc"); }
|
||
else if (ordDir === "asc") setOrdDir("desc");
|
||
else { setOrdPos(null); setOrdDir(null); }
|
||
};
|
||
const filasOrd = useMemo(() => {
|
||
if (ordPos == null || !ordDir) return filas;
|
||
const idx = ORDEN[ordPos];
|
||
return [...filas].sort((a, b) => _cmp(a[idx], b[idx], ordDir));
|
||
}, [filas, ordPos, ordDir]);
|
||
|
||
return (
|
||
<Modal title={`🧮 ${vendedor}`} onClose={onClose} width={1600}>
|
||
<div style={{ marginBottom: 14 }}>
|
||
<Select label="Mostrar" value={tipo} options={OPCIONES_POPUP} 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 style={{tableLayout:"fixed", borderCollapse:"collapse", width:anchos.reduce((a,b)=>a+b,0)}}>
|
||
<colgroup>
|
||
{HEADERS.map((h, i) => <col key={h} style={{width:anchos[i]}} />)}
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
{HEADERS.map((h, i) => (
|
||
<th key={h} onClick={()=>clickOrden(i)}
|
||
style={{position:"relative", overflow:"hidden", cursor:"pointer", userSelect:"none",
|
||
whiteSpace:"nowrap", textOverflow:"ellipsis"}}>
|
||
{h}{_flecha(ordPos===i, ordDir)}
|
||
<span onMouseDown={(e)=>{e.stopPropagation(); iniciarResize(e, i);}}
|
||
className="col-resizer"
|
||
style={{position:"absolute", top:0, right:0, width:8, height:"100%",
|
||
cursor:"col-resize", userSelect:"none", zIndex:5}} />
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filasOrd.map((f, i) => (
|
||
<tr key={i} style={String(f[17])==="1" ? {background:BG_EDITADO} : undefined}>
|
||
{ORDEN.map((idx, pos) => {
|
||
const esTexto = pos === 0 || pos === 1 || pos === 2;
|
||
const val = COL_MONTO.has(pos) ? fmt(f[idx]) : (f[idx] ?? "");
|
||
return (
|
||
<td key={pos}
|
||
className={esTexto ? "col-name" : ""}
|
||
style={{
|
||
textAlign: "center",
|
||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||
}}>
|
||
{val}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
<tr className="total-row">
|
||
{HEADERS.map((_, pos) => {
|
||
const base = { textAlign:"center", background:"#eff6ff", fontWeight:700,
|
||
color:"#1e40af", borderTop:"2px solid #bfdbfe" };
|
||
if (pos === 0) return <td key={pos} style={base}>TOTAL GENERAL ({filas.length})</td>;
|
||
if (pos === 3) return <td key={pos} style={base}>{fmt(totalInv)}</td>;
|
||
return <td key={pos} style={base}></td>;
|
||
})}
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>}
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function Dato({ k, v }) {
|
||
return (
|
||
<div style={{display:"flex",justifyContent:"space-between",padding:"8px 0",borderBottom:"1px solid #f1f5f9"}}>
|
||
<span style={{fontSize:13,color:"#64748b",fontWeight:600}}>{k}</span>
|
||
<span style={{fontSize:13,color:"#0f172a"}}>{v}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Popup pequeño de confirmación (reemplaza el confirm() feo del navegador)
|
||
function ConfirmPopup({ mensaje, onConfirmar, onCancelar }) {
|
||
return (
|
||
<div onClick={onCancelar} style={{
|
||
position:"fixed", inset:0, background:"rgba(15,23,42,0.55)",
|
||
display:"flex", alignItems:"center", justifyContent:"center", zIndex:2000, padding:20,
|
||
}}>
|
||
<div onClick={(e)=>e.stopPropagation()} style={{
|
||
background:"#fff", borderRadius:14, maxWidth:380, width:"100%",
|
||
boxShadow:"0 20px 60px rgba(0,0,0,0.3)", padding:24,
|
||
}}>
|
||
<div style={{fontSize:32, textAlign:"center", marginBottom:10}}>↺</div>
|
||
<p style={{fontSize:14, color:"#334155", textAlign:"center", marginBottom:20, lineHeight:1.5}}>{mensaje}</p>
|
||
<div style={{display:"flex", gap:10, justifyContent:"center"}}>
|
||
<button className="btn btn-primary" onClick={onConfirmar}>Sí, restaurar</button>
|
||
<button className="btn btn-ghost" onClick={onCancelar}>Cancelar</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Desplegable de UNA sola opción con diseño bonito. Opciones: strings o {value,label}.
|
||
function SelectUnico({ label, opciones, valor, onChange, minWidth = 150 }) {
|
||
const [abierto, setAbierto] = useState(false);
|
||
const ref = useRef(null);
|
||
const opts = opciones.map((o) => (typeof o === "object" ? o : { value: o, label: String(o) }));
|
||
const actual = opts.find((o) => String(o.value) === String(valor));
|
||
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 style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||
{actual ? actual.label : "—"}
|
||
</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" }}>
|
||
{opts.map((o) => {
|
||
const on = String(o.value) === String(valor);
|
||
return (
|
||
<div key={o.value} onClick={() => { onChange(o.value); 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.label}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Filtro multi-selección con casillas (Sede, Programa). Vacío = todos.
|
||
function ListaMulti({ label, opciones, seleccion, onChange, plural = "opciones", 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 texto = seleccion.length === 0 ? "— Elegir —"
|
||
: seleccion.length === 1 ? 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.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)} />
|
||
{o}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|