Files
dashboard-completo/frontend/src/pages/Comisiones.jsx

1513 lines
69 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}