Actualizar frontend/src/pages/Comisiones.jsx

This commit is contained in:
2026-10-05 16:57:32 -05:00
parent 1e0aa7afaf
commit 3883598112

View File

@@ -1,6 +1,6 @@
// src/pages/Comisiones.jsx
import { useState, useEffect, useMemo, useRef } from "react";
import { api } from "../lib/api";
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";
@@ -120,7 +120,8 @@ export default function Comisiones() {
const total = useMemo(() => filas.find((f) => f[0] === "TOTAL GENERAL"), [filas]);
const datos = useMemo(() => {
let base = filas.filter((f) => f[0] !== "TOTAL GENERAL");
// 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));
@@ -139,15 +140,36 @@ export default function Comisiones() {
return Object.values(map);
}, [manuales]);
// Total general de AVANCE TOTAL = ventas + montos manuales
// (los manuales solo cuentan cuando NO hay filtro de sede/programa)
// Los manuales solo cuentan cuando NO hay filtro de sede ni programa (todos).
const sinFiltroSedeProg = sedes.length === 0 && programas.length === 0;
// 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 (sinFiltroSedeProg) manuales.forEach((m) => { s += toNum(m.monto); });
if (mostrarManuales) manuales.forEach((m) => { s += toNum(m.monto); });
return s;
}, [total, manuales, sinFiltroSedeProg]);
}, [total, manuales, mostrarManuales]);
const C = { textAlign: "center" }; // todas las columnas centradas
@@ -163,6 +185,21 @@ export default function Comisiones() {
<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>
@@ -201,8 +238,8 @@ export default function Comisiones() {
</tr>
))}
{/* Asesores manuales: solo cuando NO hay filtro de sede/programa */}
{sinFiltroSedeProg && manualesAgrupados.map((g) => (
{/* 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>
@@ -252,7 +289,7 @@ export default function Comisiones() {
)}
{modalConfig && (
<ModalConfig ano={ano} mes={mes} onClose={() => setModalConfig(false)} onGuardado={cargarTabla} />
<ModalConfig ano={ano} mes={mes} asesores={asesoresLista} onClose={() => setModalConfig(false)} onGuardado={cargarTabla} />
)}
{modalBuscar && (
@@ -462,86 +499,190 @@ function ModalEditar({ vendedor, ano, mes, sede = "TODOS", programa = "TODOS", o
);
}
// 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 }) {
// Todas las filas son locales hasta Guardar (se manda todo en lote)
const [rows, setRows] = useState([]);
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(() => {
const inicial = (filasActuales || []).map((r) => ({
descripcion: r.descripcion || "", fch_emision: r.fch_emision || "",
monto: String(toNum(r.monto)),
}));
// Siempre dejar al menos una fila para escribir
setRows(inicial.length ? inicial : [{ descripcion:"", fch_emision:"", monto:"" }]);
}, [filasActuales]);
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]);
function cambiarRow(i, campo, valor) {
setRows((prev) => prev.map((r, idx) => idx === i ? { ...r, [campo]: valor } : r));
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;
});
}
function quitarFila(i) {
setRows((prev) => prev.filter((_, idx) => idx !== i));
}
function agregarFila() {
setRows((prev) => [...prev, { descripcion:"", fch_emision:"", monto:"" }]);
// 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 {
// Solo filas con algún dato
const filas = rows
.filter((r) => r.descripcion.trim() || r.fch_emision || toNum(r.monto) > 0)
.map((r) => ({ descripcion: r.descripcion.trim(), fch_emision: r.fch_emision, monto: toNum(r.monto) }));
// Una sola llamada: reemplaza todo (si filas vacío → elimina el vendedor)
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(); // cerrar tras guardar
onClose();
} catch (e) { setMsg({ ok:false, txt:"Error: " + e.message }); setGuardando(false); }
}
const inp = { width:"100%", padding:"6px 8px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:12, textAlign:"center", boxSizing:"border-box" };
const ce = { textAlign:"center" };
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={`✏️ Editar — ${nombre}`} onClose={onClose} width={820}>
<div className="table-wrap">
<table>
<thead><tr>
<th style={ce}>VENDEDOR</th><th style={ce}>DESCRIPCIÓN</th>
<th style={ce}>FECHA EMISIÓN</th><th style={ce}>MONTO (S/)</th><th style={ce}>ACCIÓN</th>
</tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td className="col-name" style={ce}>{nombre}</td>
<td><input value={r.descripcion} placeholder="Descripción"
onChange={(e)=>cambiarRow(i,"descripcion",e.target.value)} style={inp} /></td>
<td><input type="date" value={r.fch_emision}
onChange={(e)=>cambiarRow(i,"fch_emision",e.target.value)} style={inp} /></td>
<td><input type="number" value={r.monto} placeholder="0"
onChange={(e)=>cambiarRow(i,"monto",e.target.value)} style={inp} /></td>
<td style={ce}>
<button title="Quitar fila"
style={{border:"none",background:"transparent",cursor:"pointer",color:"#dc2626",fontSize:18,fontWeight:700,lineHeight:1}}
onClick={()=>quitarFila(i)}>✕</button>
</td>
</tr>
))}
</tbody>
</table>
<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>
<button className="btn btn-ghost" style={{marginTop:10,fontSize:12}} onClick={agregarFila}>
➕ Agregar fila
</button>
{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}}>
<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>
@@ -551,59 +692,186 @@ function ModalEditarManual({ nombre, ano, mes, filasActuales, onClose, onCambio
);
}
function ModalConfig({ ano, mes, onClose, onGuardado }) {
const TIPOS = ["TEAC", "TERC", "SEMINARIOS", "OTROS"];
const [tc, setTc] = useState(""); // IMP_TC único del mes
const [filas, setFilas] = useState([]); // META y COMISIÓN por tipo
// 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);
api.comisionesConfig(ano, mes)
.then((res) => {
const guardadas = res.config || [];
// TC: tomar el primero que tenga valor (es único por mes)
const tcGuardado = guardadas.find((x) => +x.imp_tc > 0);
setTc(tcGuardado ? String(tcGuardado.imp_tc) : "");
const arr = TIPOS.map((t) => {
const g = guardadas.find((x) => x.tipo_programa === t) || {};
return { tipo_programa: t, meta: g.meta ?? "", comision: g.comision ?? "" };
});
setFilas(arr); setLoading(false);
})
.catch(() => { setTc(""); setFilas(TIPOS.map((t)=>({tipo_programa:t,meta:"",comision:""}))); setLoading(false); });
}, [ano, mes]);
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) : "");
function cambiar(i, campo, val) {
setFilas((prev) => prev.map((f, idx) => idx === i ? { ...f, [campo]: val } : f));
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 {
// El mismo TC del mes se guarda en todas las filas (es único por mes)
const payload = filas.map((f) => ({
tipo_programa: f.tipo_programa,
imp_tc: toNum(tc), meta: toNum(f.meta), comision: toNum(f.comision),
}));
await api.comisionesConfigGuardar({ ano, mes, filas: payload });
// (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:"7px 9px", border:"1px solid #cbd5e1", borderRadius:6, fontSize:13, textAlign:"center", boxSizing:"border-box" };
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, Meta y Comisión" onClose={onClose} width={680}>
<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 */}
{/* 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>
@@ -613,33 +881,92 @@ function ModalConfig({ ano, mes, onClose, onGuardado }) {
<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>
{/* META y COMISIÓN por tipo de programa */}
<div className="table-wrap">
<table>
<thead><tr>
<th style={{textAlign:"center"}}>TIPO PROGRAMA</th>
<th style={{textAlign:"center"}}>META (S/)</th>
<th style={{textAlign:"center"}}>COMISIÓN (%)</th>
</tr></thead>
{/* 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>
{filas.map((f, i) => (
<tr key={f.tipo_programa}>
<td className="col-name" style={{textAlign:"center",fontWeight:600}}>{f.tipo_programa}</td>
<td><input type="number" value={f.meta} placeholder="0"
onChange={(e)=>cambiar(i,"meta",e.target.value)} style={inp} /></td>
<td><input type="number" step="0.01" value={f.comision} placeholder="0"
onChange={(e)=>cambiar(i,"comision",e.target.value)} style={inp} /></td>
{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:14}}>
<div style={{display:"flex",gap:10,marginTop:16}}>
<button className="btn btn-primary" disabled={guardando} onClick={guardar}>
{guardando ? "Guardando..." : "💾 Guardar"}
</button>
@@ -742,6 +1069,7 @@ function ModalBuscar({ ano, mes, onClose, onGuardado }) {
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); }
@@ -839,9 +1167,6 @@ function ModalBuscar({ ano, mes, onClose, onGuardado }) {
function ModalAgregar({ ano, mes, onClose, onGuardado }) {
const [nombre, setNombre] = useState("");
const [descripcion, setDescripcion] = useState("");
const [fchEmision, setFchEmision] = useState("");
const [monto, setMonto] = useState("");
const [guardando, setGuardando] = useState(false);
const [msg, setMsg] = useState(null);
@@ -849,9 +1174,10 @@ function ModalAgregar({ ano, mes, onClose, onGuardado }) {
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: descripcion.trim(),
fch_emision: fchEmision, monto: toNum(monto), ano, mes,
nombre: nombre.trim(), descripcion: "", fch_emision: "", monto: 0, ano, mes,
});
onGuardado();
} catch (e) {
@@ -867,19 +1193,9 @@ function ModalAgregar({ ano, mes, onClose, onGuardado }) {
<div style={{ display:"flex", flexDirection:"column", gap:12 }}>
<div>
<label style={lbl}>Nombre</label>
<input value={nombre} onChange={(e)=>setNombre(e.target.value)} style={inp} placeholder="Nombre del vendedor" autoFocus />
</div>
<div>
<label style={lbl}>Descripción</label>
<input value={descripcion} onChange={(e)=>setDescripcion(e.target.value)} style={inp} placeholder="Descripción" />
</div>
<div>
<label style={lbl}>Fecha emisión</label>
<input type="date" value={fchEmision} onChange={(e)=>setFchEmision(e.target.value)} style={inp} />
</div>
<div>
<label style={lbl}>Monto</label>
<input type="number" value={monto} onChange={(e)=>setMonto(e.target.value)} style={inp} placeholder="0" />
<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 }}>
@@ -964,12 +1280,14 @@ function ModalDetalle({ vendedor, manual, ano, mes, sede = "TODOS", programa = "
}
// Orden solicitado de columnas
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]);
// 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 = [180, 240, 280, 110, 130, 130, 130, 110, 140, 120, 120, 150, 130];
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) {
@@ -1051,10 +1369,13 @@ function ModalDetalle({ vendedor, manual, ano, mes, sede = "TODOS", programa = "
</tr>
))}
<tr className="total-row">
<td className="col-name" style={{textAlign:"center"}}>TOTAL GENERAL ({filas.length})</td>
<td></td><td></td><td></td>
<td style={{textAlign:"center"}}>{fmt(totalInv)}</td>
<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>
{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>