// 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 ⇅; return {dir === "asc" ? "▲" : "▼"}; } // 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 (

🧮 Comisiones

setAno(+v)} minWidth={110} /> ({value:i+1,label:m}))} valor={mes} onChange={(v) => setMes(+v)} minWidth={160} />
{loading ? : error ? :
{COLS.map((c, i) => { const ordenable = i !== 6; return ( ); })} {datos.map((f, i) => ( ))} {/* Vendedores manuales (sede LIMA / programa OTROS): visibles si el filtro lo permite */} {mostrarManuales && manualesAgrupados.map((g) => ( ))} {total && ( )}
clickOrden(i)} style={{position:"relative", cursor: ordenable?"pointer":"default", userSelect:"none"}}> {c}{ordenable && _flecha(ordCol===i, ordDir)}
{f[0]} {f[3]}{fmt(f[4])} {f[5]}{fmt(f[6])} {fmt(f[7])}
{g.nombre} —— —— {fmt(g.totalMonto)}
TOTAL GENERAL {total[3]}{fmt(total[4])} {total[5]}{fmt(total[6])} {fmt(totalAvance)}—
} {modal && ( setModal(null)} onEliminar={() => { setModal(null); cargarManuales(); }} /> )} {modalEdit && ( setModalEdit(null)} onGuardado={cargarTabla} /> )} {modalEditManual && ( m.nombre===modalEditManual.nombre)} onClose={() => setModalEditManual(null)} onCambio={cargarManuales} /> )} {modalConfig && ( setModalConfig(false)} onGuardado={cargarTabla} /> )} {modalBuscar && ( setModalBuscar(false)} onGuardado={cargarTabla} /> )} {modalAdd && ( setModalAdd(false)} onGuardado={() => { setModalAdd(false); cargarManuales(); }} /> )}
); } // 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 ( 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 ( { // 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 ( onChange(e.target.value.replace(/[^\d-]/g, ""))} style={baseStyle} /> ); } // texto return 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 (
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" }} />
{manualOpen && ( setManualOpen(false)} /> )}
{loading ? (
Cargando facturas…
) : ( {filtradas.length === 0 && ( )} {filtradas.map((f, i) => { const c = String(f.num_comprobante || "").trim(); const on = !!sel[c]; return ( ); })}
MES FECHA EMISIÓN N° FACTURA DEUDOR ESTADO MONEDA SUBTOTAL MONTO (S/)
{q ? "Sin resultados." : "Escribe para buscar."}
toggle(f)} style={{cursor:"pointer"}} /> {mesDe(f.fch_emision)} {f.fch_emision} {f.num_comprobante} {f.dsc_deudor} {f.cod_estado} {simboloMoneda(f.cod_moneda)} {simboloMoneda(f.cod_moneda)} {toNum(f.imp_subtotal).toLocaleString("es-PE",{maximumFractionDigits:0})} S/ {toNum(f.monto_soles).toLocaleString("es-PE",{maximumFractionDigits:0})}
)}
{msg && (
{msg.txt}
)}
Seleccionadas: {Object.keys(sel).length} · Total: S/ {totalSel.toLocaleString("es-PE",{maximumFractionDigits:0})}
); } // 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 (
e.stopPropagation()} style={{ background:"#fff", borderRadius:14, width:"min(440px,95vw)", padding:20, boxShadow:"0 20px 60px rgba(0,0,0,.3)" }}>

➕ Agregar manual

setDeudor(e.target.value)} style={inp} placeholder="Empresa / nombre" autoFocus />
setFecha(e.target.value)} style={inp} />
setSubtotal(e.target.value)} style={inp} placeholder="0" />
{esDol && (
setTc(e.target.value)} style={inp} placeholder={tcMes ? `TC del mes: ${tcMes}` : "TC"} />
)}
Monto en soles: S/ {Math.round(montoPrev).toLocaleString("es-PE")}
{err &&
{err}
}
); } 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"]]; return (
Configuración para {MESES_N[mes-1]} {ano}.
{loading ? : <> {/* IMP_TC único del mes (SIN CAMBIOS) */}
setTc(e.target.value)} style={{...inp,maxWidth:160}} /> Se usará para convertir dólares este mes. Si lo dejas vacío, usa el TC de la base.
{/* MATRIZ DE METAS POR ASESOR */}
Metas por asesor
{asesores.length === 0 ? (
No hay asesores en este mes.
) : (
{GRUPOS.map((grp) => ( {/* META más ancha */} {/* % {/* % >META */} ))} {GRUPOS.map((grp) => ( ))} {GRUPOS.map((grp) => SUBS.map(([k, lbl]) => ( )))} {asesores.map((a) => ( {GRUPOS.map((grp) => SUBS.map(([k]) => { const esPct = (k === "bajo" || k === "sobre"); return ( ); }))} ))}
ASESOR{grp}
{lbl}
{a}
cambiar(a, grp, k, e.target.value)} style={inp} /> {esPct && %}
)} {/* JEFE COMERCIAL — una sola meta (grupo TOTAL) */}
Jefe Comercial
JEFE COMERCIAL
{SUBS.map(([k, lbl]) => { const esPct = (k === "bajo" || k === "sobre"); return (
cambiarJefe(k, e.target.value)} style={{...inp, width:esPct ? 90 : 130}} /> {esPct && %}
); })}
} {msg && (
{msg.txt}
)}
); } 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 (
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 */}
{["VER","EDITAR"].map((m) => ( ))}
{loading ? : !qAplicado ?
Escribe para buscar.
: resultados.length === 0 ?
Sin coincidencias.
:
{resultados.length}{resultados.length===100?"+":""} resultado(s) {modo==="EDITAR" && "— modo edición"}
{HEADERS.map((h,hi)=>( ))} {modo==="EDITAR" && } {resultadosOrd.map((f, i) => { const mat = f[15]; return ( {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 ( ); })} {modo==="EDITAR" && ( )} );})}
modo==="VER" && clickOrden(hi)} style={{textAlign:"center",position:"relative", cursor: modo==="VER"?"pointer":"default", userSelect:"none"}}> {h}{modo==="VER" && _flecha(ordPos===hi, ordDir)} ACCIÓN
{editable ? setEdit(mat, idx, v)} /> : (COL_MONTO.has(pos) ? fmt(f[idx]) : (f[idx] ?? ""))}
} {msg && (
{msg.txt}
)} {modo==="EDITAR" && (
)} {confirmar && ( setConfirmar(null)} onConfirmar={async () => { await restaurar(confirmar); setConfirmar(null); }} /> )}
); } 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 (
setNombre(e.target.value)} onKeyDown={(e)=>{ if(e.key==="Enter" && !guardando) guardar(); }} style={inp} placeholder="Nombre del vendedor" autoFocus />
{msg &&
{msg}
}
); } 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 (
{filasM.map((r) => ( ))}
VENDEDOR DESCRIPCIÓN FECHA EMISIÓN MONTO
{r.nombre} {r.descripcion || "—"} {r.fch_emision || "—"} {fmt(r.monto)}
TOTAL GENERAL {fmt(totalM)}
); } // 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 (
toggle(o)} /> {o} ); })}
)} ); }