`;
}
function rebuildMov(){
const q=gv("q").toLowerCase(), u=gv("u"), r=gv("r"), c=gv("c"), s=gv("s"), t=gv("t");
const hasFilter=q||u||r||c||s||t;
const empty=document.getElementById(cid+"_mvEmpty");
const wrap=document.getElementById(cid+"_mvWrap");
if(!hasFilter){
// C: solo muestra cuando hay filtro activo
if(empty) empty.style.display="flex";
if(wrap) wrap.style.display="none";
document.getElementById(cid+"_mvInfo").textContent="Aplica un filtro para ver movimientos";
document.getElementById(cid+"_mvTot").textContent="";
_vRows=[];
return;
}
if(empty) empty.style.display="none";
if(wrap) wrap.style.display="block";
_vRows=(ACTIVE||FULL.rows).filter(row=>{
if(allowedUPGs&&fUPG&&!allowedUPGs.includes(normUPG(row[fUPG]))) return false;
if(u&&fUPG&&String(row[fUPG]??"").trim()!==u) return false;
if(r&&fRub&&String(row[fRub]??"").trim()!==r) return false;
if(c&&fCta){const rowCta=String(row[fCta]??"").trim(); const code=c.split(" - ")[0].trim(); if(rowCta!==c&&!rowCta.startsWith(code+" ")&&rowCta!==code) return false;}
if(s&&fSub&&String(row[fSub]??"").trim()!==s) return false;
if(t&&fTipo&&String(row[fTipo]??"").trim()!==t) return false;
if(q){const d=fDesc?String(row[fDesc]??"").toLowerCase():"",l=fLote?String(row[fLote]??"").toLowerCase():"",ct=fCta?String(row[fCta]??"").toLowerCase():"";if(!d.includes(q)&&!l.includes(q)&&!ct.includes(q)) return false;}
return true;
});
// Total acumulado (rápido, sin DOM)
const _fValTot=H.find(h=>/^valor$|^monto$|^importe$/i.test(h.trim()));
_vTot=_vRows.reduce((s,row)=>{const v=_fValTot?toNum(row[_fValTot]):null;const x=rowMetric(row);return s+(v!==null?v:(x.ing-x.gas));},0);
const ts=_vTot>=0?"+":"-";
document.getElementById(cid+"_mvInfo").textContent=`${_vRows.length.toLocaleString()} registros`;
document.getElementById(cid+"_mvTot").textContent=`Total: ${ts}${fmt(Math.abs(_vTot))}`;
renderVisible();
}
// ---- Tabs ----
function swEpTab(id){
document.getElementById(cid+"_tEje").className="ep-tbtn"+(id==="eje"?" on":"");
document.getElementById(cid+"_tMov").className="ep-tbtn"+(id==="mov"?" on":"");
document.getElementById(cid+"_tabEje").className="ep-tp"+(id==="eje"?" on":"");
document.getElementById(cid+"_tabMov").className="ep-tp"+(id==="mov"?" on":"");
}
// ---- Dropdown meses ----
const mesBtn=document.getElementById(cid+"_mesBtn"), mesPanel=document.getElementById(cid+"_panel"), mesArr=document.getElementById(cid+"_mesArr"), mesLbl=document.getElementById(cid+"_mesLbl");
mesBtn.addEventListener("click",e=>{e.stopPropagation();const o=mesPanel.style.display==="block";mesPanel.style.display=o?"none":"block";mesArr.textContent=o?"▾":"▴";});
document.addEventListener("click",()=>{mesPanel.style.display="none";mesArr.textContent="▾";});
mesPanel.addEventListener("click",e=>e.stopPropagation());
// Recalcula árboles (presupuesto sigue siendo anual; solo la ejecución se filtra por los meses elegidos) y refresca KPIs + tabla
function applyBudMonthFilter(){
if(budSelMes.size>=budMonths.length){
treeUPG=buildBudExec();
} else {
const prevRows=FULL.rows;
FULL.rows=FULL.rows.filter(r=>{const mk=monthKey(r[DATE_COL]);return mk&&budSelMes.has(mk);});
treeUPG=buildBudExec();
FULL.rows=prevRows;
}
if(allowedUPGs) treeUPG=treeUPG.filter(u=>allowedUPGs.includes(u.upg));
treeGen=buildGeneralTree(treeUPG);
let tP=0,tE=0; for(const u of treeUPG){tP+=u.P;tE+=u.E;}
const tp=tP?tE/tP*100:0, tw=Math.min(tp,100).toFixed(1);
const gcc=tp>100?"#A4262C":tp>80?"#C75A00":"#0078D4";
document.getElementById(cid+"_kP").textContent=fmt(tP);
document.getElementById(cid+"_kE").textContent=fmt(tE);
document.getElementById(cid+"_kS").textContent=fmt(tP-tE);
document.getElementById(cid+"_kBar").style.width=tw+"%";
document.getElementById(cid+"_kBar").style.background=gcc;
document.getElementById(cid+"_kPct").textContent=tp.toFixed(1)+"%";
document.getElementById(cid+"_kPct").style.color=gcc;
document.getElementById(cid+"_badge").textContent=tp.toFixed(1)+"% ejecutado";
document.getElementById(cid+"_ftP").textContent=fmt(tP);
document.getElementById(cid+"_ftE").textContent=fmt(tE);
document.getElementById(cid+"_ftS").textContent=fmt(tP-tE);
document.getElementById(cid+"_ftBar").style.width=tw+"%";
document.getElementById(cid+"_ftPct").textContent=tp.toFixed(1)+"%";
rebuildTree(BUD_VIEW);
}
document.getElementById(cid+"_selAll").addEventListener("click",()=>{document.querySelectorAll("."+cid+"_mc").forEach(c=>c.checked=true);budSelMes=new Set(budMonths.map(m=>m.k));updMesLbl();applyBudMonthFilter();});
document.querySelectorAll("."+cid+"_mc").forEach(c=>c.addEventListener("change",()=>{budSelMes=new Set([...document.querySelectorAll("."+cid+"_mc")].filter(x=>x.checked).map(x=>x.getAttribute("data-k")));updMesLbl();applyBudMonthFilter();}));
function updMesLbl(){const n=budSelMes.size,tot=budMonths.length;mesLbl.textContent=n===0?"Sin filtro":n===tot?"Todos los meses":n===1?budMonths.find(m=>budSelMes.has(m.k))?.label||"1 mes":n+" meses";}
// ---- Toggle vista ----
const bGen=document.getElementById(cid+"_bGen"), bUPG=document.getElementById(cid+"_bUPG");
function setView(v){
BUD_VIEW=v;
bGen.style.cssText=`border:none;font:${v==="general"?"700":"500"} 11px Inter;padding:4px 12px;border-radius:4px;cursor:pointer;background:${v==="general"?"#fff":"transparent"};color:${v==="general"?"#0F3460":"rgba(255,255,255,.65)"}`;
bUPG.style.cssText=`border:none;font:${v==="upg"?"700":"500"} 11px Inter;padding:4px 12px;border-radius:4px;cursor:pointer;background:${v==="upg"?"#fff":"transparent"};color:${v==="upg"?"#0F3460":"rgba(255,255,255,.65)"}`;
rebuildTree(v);
}
bGen.addEventListener("click",()=>setView("general"));
bUPG.addEventListener("click",()=>setView("upg"));
// ---- Filtros de movimientos ----
document.getElementById(cid+"_q").addEventListener("input",rebuildMov);
card.querySelectorAll(".mv-fsel").forEach(s=>s.addEventListener("change",rebuildMov));
document.addEventListener("asinet:filterchange",rebuildMov);
// Scroll virtualizado
setTimeout(()=>{
const wrap=document.getElementById(cid+"_mvWrap");
if(wrap) wrap.addEventListener("scroll",()=>renderVisible(),{passive:true});
},300);
document.getElementById(cid+"_clr").addEventListener("click",()=>{
card.querySelectorAll(".mv-fsel").forEach(s=>{if(s.tagName==="SELECT")s.value="";});
const subHid=document.getElementById(cid+"_s");
if(subHid) subHid.dispatchEvent(new Event("_reset"));
document.getElementById(cid+"_q").value="";
document.getElementById(cid+"_badges").innerHTML="";
rebuildMov();
});
// ---- Tabs click ----
document.getElementById(cid+"_tEje").addEventListener("click",()=>swEpTab("eje"));
document.getElementById(cid+"_tMov").addEventListener("click",()=>{swEpTab("mov");rebuildMov();});
setView(BUD_VIEW);
// Movimientos se renderizan solo al abrir la pestaña
}
function renderCuentaCompare(main, title, capNote){
renderBudgetTree(main, title+(capNote?" · "+capNote:""));
}
function pctBar(p){
const pc=Math.max(0,Math.min(150,p)); const color=p>100?"#c0392b":(p>85?"#B8901F":"#1a7f4b");
return `
${isFinite(p)?p.toFixed(1):"—"}%`;
}
async function renderPresupuesto(main){
const isAdmin=CURRENT_USER&&CURRENT_USER.role==="admin";
const restricted=CURRENT_USER&&CURRENT_USER.role!=="admin"&&Array.isArray(CURRENT_USER.upgs)&&!CURRENT_USER.upgs.includes("*");
if(BUDGET===null){ try{ const r=await fetch("/api/budget"); const d=await r.json(); BUDGET=d.budget||null; }catch(_){} }
// --- Usuario restringido: mismo comparativo completo, pero acotado a sus UPGs ---
if(restricted){
if(!BUDGET||!BUDGET.rows||!BUDGET.rows.length){ emptyState(main,"Aún no hay presupuesto asignado a tu unidad. Consulta con el administrador."); return; }
renderPresupuestoConTabs(main, "Ejecución vs Presupuesto de Gastos — "+CURRENT_USER.upgs.join(", "), CURRENT_USER.upgs);
return;
}
// --- Usuario NO admin sin restricción de UPG: mismo comparativo completo, sin acotar ---
if(!isAdmin){
if(!BUDGET||!BUDGET.rows||!BUDGET.rows.length){ emptyState(main,"Aún no hay presupuesto asignado a tu unidad. Pide a un administrador que lo cargue."); return; }
renderPresupuestoConTabs(main, "Ejecución vs Presupuesto de Gastos", null);
return;
}
// --- Admin: comparativos completos + editor ---
if(BUDGET){
// Gráfico Power BI: barras horizontales Presupuesto vs Ejecutado por UPG
renderPresupuestoConTabs(main, "Ejecución vs Presupuesto de Gastos");
}
// --- Editor (solo administradores ven y editan el grid) ---
if(!isAdmin){ if(!BUDGET) emptyState(main,"Aún no hay presupuesto cargado. Pide a un administrador que lo cargue."); return; }
const ed=document.createElement("div"); ed.className="vis-card"; ed.style.marginTop="14px"; main.appendChild(ed);
if(!BUDGET){
ed.innerHTML=`
Cargar presupuesto
${isAdmin?"Copia tu tabla desde Excel (CUENTA, SUBCUENTA y una columna por centro de costos) y pégala aquí:":"Aún no hay presupuesto cargado. Pide a un administrador que lo cargue."}
${isAdmin?`
`:""}`;
if(isAdmin) ed.querySelector("#budParse").onclick=()=>{ const b=parseBudgetPaste(ed.querySelector("#budPaste").value); const msg=ed.querySelector("#budMsg");
if(!b){ msg.style.color="#c0392b"; msg.textContent="No se pudo leer. Copia desde Excel incluyendo la fila de encabezados."; return; }
BUDGET=b; navTo("presupuesto"); };
return;
}
const upgOpts=["","SALUD","PSICOLOGIA","EDUCACION","EMPRESARIALES","INGENIERIA","SALUD PUBLICA","TEOLOGIA","EPG"];
let mapRow=BUDGET.cols.map(c=>`
${esc(c)}
`).join("");
const mapStrip = isAdmin ? `
Asignar unidad a cada centro (solo para accesos restringidos):${BUDGET.cols.map(c=>``).join("")}
` : "";
let body="";
BUDGET.rows.forEach((r,i)=>{
body+=`
${esc(r.cuenta)}
${esc(r.sub)}
`+r.vals.map((v,j)=>`
${isAdmin?``:(v?fmt(v):"")}
`).join("")+`
`;
});
ed.innerHTML=`
Presupuesto (editable) · ${BUDGET.ts?("guardado "+new Date(BUDGET.ts).toLocaleString("es-PE")):"sin guardar"}${BUDGET.by?(" por "+esc(BUDGET.by)):""}
Asigna a cada centro de costos su unidad (fila de selección) para el comparativo.${isAdmin?" Edita las celdas y pulsa Guardar.":""}
${mapStrip}
CUENTA
SUBC.
${mapRow}
${body}
${isAdmin?``:""}
`;
if(isAdmin){
ed.querySelectorAll(".bud-map").forEach(s=>s.onchange=()=>{ BUDGET.map[s.getAttribute("data-c")]=s.value; });
ed.querySelectorAll(".bud-cell").forEach(inp=>inp.onchange=()=>{ const i=+inp.getAttribute("data-i"), j=+inp.getAttribute("data-j"); const v=toNum(inp.value); BUDGET.rows[i].vals[j]=v===null?0:v; });
ed.querySelector("#budSave").onclick=async()=>{ const msg=ed.querySelector("#budMsg2");
try{ const r=await fetch("/api/budget",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(BUDGET)}); const d=await r.json();
if(r.ok){ msg.style.color="#1a7f4b"; msg.textContent="Presupuesto guardado."; BUDGET.ts=d.ts; navTo("presupuesto"); }
else { msg.style.color="#c0392b"; msg.textContent=d.error||"No se pudo guardar."; } }catch(_){ msg.textContent="Error de conexión."; } };
ed.querySelector("#budReset").onclick=()=>{ if(confirm("¿Reemplazar el presupuesto actual pegando uno nuevo?")){ BUDGET=false; navTo("presupuesto"); } };
}
if(BUDGET===false) BUDGET=null;
}
let PREV_VIEW=null;
function navTo(view){
if(!SCHEMA) return;
// Si veníamos de "Estados Financieros", su filtro de meses/unidades quedó aplicado en ACTIVE (variable global).
// Al salir de esa vista hay que restaurarlo, para que no contamine el filtro de meses de otras páginas
// (ej. Ejecución Presupuestal → Movimientos detalle).
if(PREV_VIEW==="estados" && view!=="estados" && ACTIVE!==FULL.rows){
ACTIVE=FULL.rows; analyze(ACTIVE,FULL.headers);
}
PREV_VIEW=view;
CURRENT_VIEW=view;
const TITLES={inicio:"Inicio",dashboard:"Dashboard Ejecutivo",estados:"Estados Financieros",ejecucion:"Ejecución Presupuestal",gastos:"Desglose de Gastos",upg:"Análisis por UPG",presupuesto:"Ejecución Presupuestal",movimientos:"Movimientos",reportes:"Reportes Guardados",usuarios:"Usuarios",config:"Configuración"};
const pt=document.getElementById("pageTitle"); if(pt) pt.textContent=TITLES[view]||"Ejecución Presupuestal";
document.querySelectorAll(".nav-item").forEach(b=>b.classList.toggle("active", b.dataset.view===view));
const kpisEl=document.getElementById("kpis");
if(kpisEl) kpisEl.style.display=(view==="presupuesto"||view==="estados")?"none":"";
const mb=document.getElementById("monthBar"); if(mb) mb.style.display = (["dashboard","ejecucion","gastos","upg"].includes(view) && ALLMONTHS.length) ? "flex" : "none";
const main=document.getElementById("mainView"); main.innerHTML="";
const fin=SCHEMA.financial, upg=upgCol(), rub=rubroCol(), prim=primMeasure();
try{
if(view==="inicio"){
secTitle(main,"Bienvenido","Consulta al Tesorero Asistente en lenguaje natural, o explora las secciones del panel.");
if(fin && upg) renderChart(main,{type:"bar",groupBy:upg,measure:"Resultado",title:"Resultado por "+upg});
else if(upg) renderChart(main,{type:"bar",groupBy:upg,measure:prim,title:"Resumen por "+upg});
}
else if(view==="dashboard"){
secTitle(main,"Dashboard Ejecutivo");
const g=visGrid(main);
if(upg) renderChart(g,{type:"bar",groupBy:upg,measures:fin?["Ingresos","Gastos","Resultado"]:[prim],title:(fin?"Ingresos, Gastos y Resultado":prim)+" por "+upg});
if(DATE_COL) renderChart(g,{type:"line",groupBy:"Mes",measures:fin?["Ingresos","Gastos"]:[prim],title:"Tendencia mensual"});
if(upg) renderTable(main,{groupBy:upg,measures:fin?["Ingresos","Gastos","Resultado"]:[prim],percentOf:fin?"Resultado":undefined,title:"Resumen por "+upg});
}
else if(view==="estados"){
document.getElementById("kpis").style.display="none";
renderEstadoRFToggle(main);
if(DATE_COL) renderChart(main,{type:"line",groupBy:"Mes",measures:fin?["Ingresos","Gastos","Resultado"]:[prim],title:"Evolución mensual"});
}
else if(view==="ejecucion"){
secTitle(main,"Ejecución Presupuestal","Ejecución del gasto por periodo y por tipo.");
const g=visGrid(main);
if(DATE_COL) renderChart(g,{type:"bar",groupBy:"Mes",measures:fin?["Gastos"]:[prim],title:"Ejecución mensual"});
if(rub) renderChart(g,{type:"bar",groupBy:rub,measure:fin?"Gastos":prim,title:"Distribución por "+rub});
if(rub) renderTable(main,{groupBy:rub,measures:fin?["Gastos"]:[prim],percentOf:fin?"Gastos":undefined,title:"Ejecución por "+rub});
}
else if(view==="gastos"){
secTitle(main,"Desglose de Gastos","Haz clic en una categoría para filtrar todo el tablero.");
const wrap=document.createElement("div");wrap.className="vis-card";
wrap.innerHTML=`
Desglose interactivo
`;
main.appendChild(wrap); buildDesglose();
if(rub) renderChart(main,{type:"bar",groupBy:rub,measure:fin?"Gastos":prim,title:"Gasto por "+rub});
}
else if(view==="upg"){
secTitle(main,"Análisis por UPG");
if(!upg){ emptyState(main,"No se encontró una dimensión de UPG en los datos."); }
else{
const g=visGrid(main);
renderChart(g,{type:"bar",groupBy:upg,measure:fin?"Resultado":prim,title:(fin?"Resultado":prim)+" por "+upg});
renderChart(g,{type:"bar",groupBy:upg,measure:fin?"Gastos":prim,title:"Participación por "+upg});
renderTable(main,{groupBy:upg,measures:fin?["Ingresos","Gastos","Resultado"]:[prim],percentOf:fin?"Resultado":undefined,title:"Resumen por "+upg});
}
}
else if(view==="reportes"){
secTitle(main,"Reportes Guardados","Tus análisis guardados en la nube, accesibles desde cualquier equipo.");
loadReports(main);
}
else if(view==="usuarios"){
secTitle(main,"Usuarios","Crea usuarios, asigna su rol y las UPG que pueden ver. Las contraseñas se guardan cifradas.");
renderUsers(main);
}
else if(view==="presupuesto"){
document.getElementById("kpis").style.display="none";
renderPresupuesto(main);
}
else if(view==="config"){
secTitle(main,"Configuración");
const c=document.createElement("div");c.className="ai-card";
c.innerHTML=`
⚙️ Información del tablero
Origen de datos: Google Sheet publicado (solo lectura).\n${esc(document.getElementById("lastUpd").textContent||"")}\nRegistros cargados: ${SCHEMA.N.toLocaleString("es-PE")}.\n\nEl comportamiento del Tesorero Asistente (tono, conocimiento de la EPG y formato) se ajusta en el archivo _worker.js, en el bloque “CONFIGURACIÓN DEL CONSULTOR”.
`;
main.appendChild(c);
}
}catch(e){ emptyState(main,"No se pudo construir esta vista con los datos actuales."); }
}
// ---- Cross-filter estilo Power BI ----
function toggleFilter(col,val){ if(FILTER[col]===val) delete FILTER[col]; else FILTER[col]=val; refresh(); }
function applyFilter(){
const keys=Object.keys(FILTER);
let rows = keys.length ? FULL.rows.filter(r=>keys.every(c=>String(r[c]??"").trim()===FILTER[c])) : FULL.rows;
if(MONTHSEL && MONTHSEL.size){ rows = rows.filter(r=>{const mk=monthKey(r[DATE_COL]); return mk && MONTHSEL.has(mk);}); }
ACTIVE = rows;
}
function buildMonthBar(){
const bar=document.getElementById("monthBar"); if(!bar) return;
if(!ALLMONTHS.length){ bar.innerHTML=""; return; }
const sel = MONTHSEL || new Set(ALLMONTHS.map(m=>m.k));
let h=`Filtrar meses:`;
h+=``;
for(const m of ALLMONTHS) h+=``;
bar.innerHTML=h;
bar.querySelectorAll("input").forEach(inp=>inp.addEventListener("change",e=>{
const k=inp.getAttribute("data-k");
let s = MONTHSEL ? new Set(MONTHSEL) : new Set(ALLMONTHS.map(m=>m.k));
if(k==="__all"){ s = e.target.checked ? new Set(ALLMONTHS.map(m=>m.k)) : new Set(); }
else { if(e.target.checked) s.add(k); else s.delete(k); }
MONTHSEL = (s.size>=ALLMONTHS.length) ? null : s;
refresh(); buildMonthBar();
}));
}
function refresh(){
applyFilter();
analyze(ACTIVE.length?ACTIVE:FULL.rows, FULL.headers);
render();
if(CURRENT_VIEW && CURRENT_VIEW!=="copiloto") navTo(CURRENT_VIEW);
document.dispatchEvent(new CustomEvent("asinet:filterchange"));
}
function renderFilterBar(){
const bar=document.getElementById("filterBar"); const keys=Object.keys(FILTER);
if(!keys.length){ bar.innerHTML=""; return; }
bar.innerHTML=keys.map(c=>`${esc(c)}: ${esc(FILTER[c])} ✕`).join("")
+``;
bar.querySelectorAll(".fchip").forEach(ch=>ch.querySelector(".x").onclick=()=>{delete FILTER[ch.dataset.col];refresh();});
bar.querySelector(".fclear").onclick=()=>{FILTER={};refresh();};
}
// ---- Cálculo de gráficos en el navegador (sobre datos completos) ----
function matchVal(cell,op,val){
const a=String(cell??"").trim().toLowerCase(), b=String(val??"").trim().toLowerCase();
if(op==="contains") return a.includes(b);
return a===b;
}
function runSpec(spec){
const byMonth=/^(mes|month|mensual)$/i.test(String(spec.groupBy||"").trim()) || (DATE_COL && spec.groupBy===DATE_COL);
if(!byMonth && !SCHEMA.headers.includes(spec.groupBy)) return {error:`No existe la columna "${spec.groupBy}".`};
if(byMonth && !DATE_COL) return {error:"No hay columna de fecha para agrupar por mes."};
const isCount=spec.agg==="count";
let mk=null, gcol=null;
if(SCHEMA.financial){
mk=isCount?null:normMeasure(spec.measure);
if(!isCount && !mk) return {error:`Medida "${spec.measure}" no válida. Usa Resultado, Ingresos o Gastos.`};
} else if(!isCount){
gcol = SCHEMA.numCols.includes(spec.measure) ? spec.measure : (SCHEMA.gmeasure||null);
if(!gcol) return {error:`Sin medida numérica; usa agg:"count".`};
}
let rows=FULL.rows;
if(Array.isArray(spec.filters)) for(const f of spec.filters){ if(SCHEMA.headers.includes(f.col)) rows=rows.filter(r=>matchVal(r[f.col],f.op||"=",f.val)); }
const g={};
for(const r of rows){
const k = byMonth ? (monthKey(r[DATE_COL])||"(sin fecha)") : (String(r[spec.groupBy]??"(vacío)").trim()||"(vacío)");
if(!g[k])g[k]={ing:0,gas:0,sum:0,count:0};
if(SCHEMA.financial){const m=rowMetric(r);g[k].ing+=m.ing;g[k].gas+=m.gas;}
else if(gcol){g[k].sum+=toNum(r[gcol])||0;}
g[k].count++;
}
let arr=Object.entries(g).map(([k,o])=>{
let val = isCount ? o.count : (SCHEMA.financial ? (mk==="ing"?o.ing:mk==="gas"?o.gas:o.ing-o.gas) : o.sum);
return {k,val};
});
if(byMonth){ arr.sort((a,b)=> a.kb.k?1:0); arr=arr.map(a=>({k:monthLabel(a.k),val:a.val})); }
else if(spec.sort==="asc") arr.sort((a,b)=>a.val-b.val);
else if(spec.sort!=="none") arr.sort((a,b)=>Math.abs(b.val)-Math.abs(a.val));
if(spec.limit && !byMonth) arr=arr.slice(0,spec.limit);
return {labels:arr.map(a=>a.k),values:arr.map(a=>Math.round(a.val*100)/100)};
}
// ---- Motor de visualización (sobre datos completos) ----
if(window.ChartDataLabels) Chart.register(window.ChartDataLabels);
function hexA(hex,a){const n=hex.replace("#","");return `rgba(${parseInt(n.substr(0,2),16)},${parseInt(n.substr(2,2),16)},${parseInt(n.substr(4,2),16)},${a})`;}
function fmtMaybe(v){ return (SCHEMA.financial||SCHEMA.gmeasure)?fmt(v):Number(v).toLocaleString("es-PE"); }
function trimNum(x){ return x.toFixed(2).replace(/\.?0+$/,""); }
function compactNum(v){ const a=Math.abs(v), s=v<0?"-":""; if(a>=1e6) return s+trimNum(a/1e6)+" M"; if(a>=1e3) return s+trimNum(a/1e3)+" K"; return s+Math.round(a).toLocaleString("es-PE"); }
function fmtK(v){ return (SCHEMA&&SCHEMA.financial)?"S/ "+compactNum(v):compactNum(v); }
function toExcel(excel,name){
try{
if(!window.XLSX||!excel||!excel.columns) return;
const aoa=[excel.columns, ...excel.rows];
const ws=XLSX.utils.aoa_to_sheet(aoa);
const wb=XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, String(excel.sheet||"Datos").replace(/[\\\/\?\*\[\]:]/g,"").slice(0,28)||"Datos");
XLSX.writeFile(wb, (name||"datos").replace(/\s+/g,"_")+".xlsx");
}catch(e){}
}
function visNote(c,msg){const w=document.createElement("div");w.className="loading-state";w.textContent="⚠ "+msg;c.appendChild(w);}
function validGroup(gb){ return isMonthGroup(gb) || (DATE_COL&&gb===DATE_COL) || SCHEMA.headers.includes(gb); }
function dimName(gb){ return (isMonthGroup(gb)||(DATE_COL&&gb===DATE_COL))?"Mes":gb; }
function aggregate(groupBy, filters){
const byMonth=isMonthGroup(groupBy) || (DATE_COL && groupBy===DATE_COL);
let rows=ACTIVE||FULL.rows;
if(Array.isArray(filters)) for(const f of filters){ if(SCHEMA.headers.includes(f.col)) rows=rows.filter(r=>matchVal(r[f.col],f.op||"=",f.val)); }
const g={};
for(const r of rows){
const k = byMonth ? (monthKey(r[DATE_COL])||"(sin fecha)") : (String(r[groupBy]??"(vacío)").trim()||"(vacío)");
if(!g[k]) g[k]={k,ing:0,gas:0,count:0,sums:{}};
if(SCHEMA.financial){const m=rowMetric(r); g[k].ing+=m.ing; g[k].gas+=m.gas;}
else { for(const c of SCHEMA.numCols){ const v=toNum(r[c]); if(v!==null) g[k].sums[c]=(g[k].sums[c]||0)+v; } }
g[k].count++;
}
return {byMonth, rows:Object.values(g)};
}
function measureVal(o, mname){
if(!mname || /^(count|conteo|registros)$/i.test(mname)) return o.count;
if(SCHEMA.financial){ const mk=normMeasure(mname); return mk==="ing"?o.ing:mk==="gas"?o.gas:o.ing-o.gas; }
if(SCHEMA.numCols.includes(mname)) return o.sums[mname]||0;
return SCHEMA.gmeasure ? (o.sums[SCHEMA.gmeasure]||0) : o.count;
}
function grandTotal(measure, filters){
let rows=ACTIVE||FULL.rows;
if(Array.isArray(filters)) for(const f of filters){ if(SCHEMA.headers.includes(f.col)) rows=rows.filter(r=>matchVal(r[f.col],f.op||"=",f.val)); }
const o={ing:0,gas:0,count:0,sums:{}};
for(const r of rows){ if(SCHEMA.financial){const m=rowMetric(r);o.ing+=m.ing;o.gas+=m.gas;} else {for(const c of SCHEMA.numCols){const v=toNum(r[c]);if(v!==null)o.sums[c]=(o.sums[c]||0)+v;}} o.count++; }
return measureVal(o, measure);
}
function buildSeries(spec){
const {byMonth, rows}=aggregate(spec.groupBy, spec.filters);
let arr=rows;
const measures=(Array.isArray(spec.measures)&&spec.measures.length)?spec.measures:[spec.measure||(SCHEMA.financial?"Resultado":(SCHEMA.gmeasure||"count"))];
const primary=measures[0];
if(byMonth) arr.sort((a,b)=>a.kb.k?1:0);
else if(spec.sort==="asc") arr.sort((a,b)=>measureVal(a,primary)-measureVal(b,primary));
else if(spec.sort!=="none") arr.sort((a,b)=>Math.abs(measureVal(b,primary))-Math.abs(measureVal(a,primary)));
if(spec.limit && !byMonth) arr=arr.slice(0,spec.limit);
return {byMonth, labels:arr.map(o=>byMonth?monthLabel(o.k):o.k), series:measures.map(m=>({name:m,values:arr.map(o=>Math.round(measureVal(o,m)*100)/100)}))};
}
function renderChart(container,spec,noDL){
if(!validGroup(spec.groupBy)) return visNote(container,`No existe la columna "${spec.groupBy}".`);
let {labels,series,byMonth}=buildSeries(spec);
if(!byMonth){
const keep=labels.map((_,i)=>series.some(s=>Math.round((s.values[i]||0)*100)!==0));
if(keep.some(k=>!k)){ labels=labels.filter((_,i)=>keep[i]); series=series.map(s=>({name:s.name,values:s.values.filter((_,i)=>keep[i])})); }
}
if(!labels.length) return visNote(container,"No hay datos con monto para ese gráfico. Revisa el filtro o la medida, o pídelo de otra forma.");
let type=spec.type||"bar", area=false;
if(type==="area"){type="line";area=true;}
if(!["bar","line","pie","doughnut"].includes(type)) type="bar";
const pie=type==="pie"||type==="doughnut";
const combo=!!spec.combo&&!pie;
const stacked=!!spec.stacked&&!pie&&!combo;
const few=labels.length<=14;
// Power BI colores
const GRID="rgba(0,0,0,.06)", AX="#605E5C", LBLCOL="#252423";
const fmtLbl=v=>{if(!v&&v!==0)return"";const a=Math.abs(v),sg=v<0?"-":"";if(a>=1e6)return sg+(a/1e6).toFixed(1)+"M";if(a>=1e3)return sg+(a/1e3).toFixed(0)+"K";return sg+Math.round(a);};
let datasets;
if(pie){
datasets=[{label:series[0].name,data:series[0].values.map(v=>Math.abs(v||0)),
backgroundColor:labels.map((_,i)=>PALETTE[i%PALETTE.length]),
borderColor:"#fff",borderWidth:3,hoverOffset:10,hoverBorderWidth:3}];
} else {
datasets=series.map((s,i)=>{
const c=PALETTE[i%PALETTE.length];
const isLine=(type==="line")||(combo&&i>0);
if(isLine) return {label:s.name,data:s.values,type:"line",
borderColor:c,borderWidth:2.5,
backgroundColor:area?c+"22":"transparent",
fill:area,tension:.4,
pointRadius:4,pointHoverRadius:7,
pointBackgroundColor:"#fff",pointBorderColor:c,pointBorderWidth:2.5};
return {label:s.name,data:s.values,type:"bar",
backgroundColor:s.values.map(v=>(v||0)<0?c+"bb":c),
borderColor:"transparent",borderWidth:0,
borderRadius:{topLeft:4,topRight:4,bottomLeft:0,bottomRight:0},
hoverBackgroundColor:c+"dd"};
});
}
const block=document.createElement("div");block.className="vis-block";container.appendChild(block);
const box=document.createElement("div");box.className="chart-box";
const cv=document.createElement("canvas");cv.setAttribute("role","img");cv.setAttribute("aria-label",spec.title||"gráfico financiero EPG");
box.appendChild(cv);block.appendChild(box);
new Chart(cv,{type:pie?type:"bar",data:{labels,datasets},options:{
responsive:true,maintainAspectRatio:false,
animation:{duration:700,easing:"easeOutCubic"},
devicePixelRatio:Math.max(window.devicePixelRatio||1,2),
layout:{padding:{top:pie?8:32,right:16,bottom:4,left:4}},
plugins:{
legend:{display:pie||series.length>1,
position:pie?"bottom":"top",
labels:{color:"#605E5C",font:{size:12,family:"Inter"},
padding:20,usePointStyle:true,pointStyleWidth:10,
boxHeight:10}},
title:{display:!!spec.title,text:spec.title,
color:"#252423",font:{size:14,weight:"600",family:"Inter"},
padding:{bottom:16},align:"start"},
tooltip:{
backgroundColor:"#fff",titleColor:"#252423",bodyColor:"#605E5C",
borderColor:"#e0dedd",borderWidth:1,padding:12,cornerRadius:8,
boxShadow:"0 4px 12px rgba(0,0,0,.15)",
titleFont:{size:13,weight:"700",family:"Inter"},
bodyFont:{size:13,family:"Inter"},
callbacks:{
label:c=>{const v=c.raw,a=Math.abs(v||0),sg=(v||0)<0?"-":"";
return ` ${c.dataset.label||c.label}: ${sg}S/ ${a.toLocaleString("en-US",{minimumFractionDigits:2})}`;},
labelColor:c=>({borderColor:"transparent",backgroundColor:PALETTE[c.datasetIndex%PALETTE.length],borderRadius:3})
}
},
datalabels:{
display:ctx=>{const v=ctx.dataset.data[ctx.dataIndex];if(!v&&v!==0)return false;
if(pie){const tot=ctx.dataset.data.reduce((s,x)=>s+Math.abs(x||0),0);return tot?Math.abs(v)/tot>=0.04:false;}
return few;},
formatter:v=>fmtLbl(v),
color:ctx=>pie?"#fff":LBLCOL,
font:{size:12,weight:"700",family:"Inter"},
anchor:pie?"center":"end",
align:pie?"center":ctx=>(ctx.dataset.data[ctx.dataIndex]||0)>=0?"top":"bottom",
offset:3,clamp:true,
backgroundColor:ctx=>pie?null:"rgba(255,255,255,.85)",
borderRadius:3,padding:{top:2,bottom:2,left:5,right:5}
}
},
scales:pie?{}:{
x:{stacked,
ticks:{color:AX,font:{size:11,family:"Inter"},maxRotation:labels.length>9?40:0},
grid:{display:false},border:{display:false}},
y:{stacked,
ticks:{color:AX,font:{size:11,family:"Inter"},callback:v=>fmtLbl(v)},
grid:{color:GRID,lineWidth:1,drawBorder:false},border:{display:false}}
}
}});
const nt=document.createElement("div");nt.className="chart-note";nt.textContent="M = Millones de soles · K = Miles de soles";block.appendChild(nt);
if(!noDL){
const excel={columns:[dimName(spec.groupBy),...series.map(s=>s.name)],rows:labels.map((lab,i)=>[lab,...series.map(s=>s.values[i])]),sheet:spec.title||"Datos"};
addDL(block,{canvas:cv,name:spec.title||"grafico",excel});
}
}
function renderTable(container,spec){
if(!validGroup(spec.groupBy)) return visNote(container,`No existe la columna "${spec.groupBy}".`);
let measures=(Array.isArray(spec.measures)&&spec.measures.length)?spec.measures:(SCHEMA.financial?["Ingresos","Gastos","Resultado"]:[SCHEMA.gmeasure||"count"]);
const {byMonth,rows}=aggregate(spec.groupBy, spec.filters);
const sortM=(spec.sort&&measures.includes(spec.sort))?spec.sort:measures[measures.length-1];
let arr=rows.slice();
if(byMonth) arr.sort((a,b)=>a.kMath.abs(measureVal(b,sortM))-Math.abs(measureVal(a,sortM)));
if(!byMonth) arr=arr.filter(o=>measures.some(m=>Math.round(measureVal(o,m)*100)!==0));
if(spec.limit && !byMonth) arr=arr.slice(0,spec.limit);
if(!arr.length) return visNote(container,"No hay datos con monto para esa tabla. Revisa el filtro o la medida, o pídelo de otra forma.");
const totals={}; measures.forEach(m=>totals[m]=arr.reduce((s,o)=>s+measureVal(o,m),0));
const pct=(spec.percentOf&&measures.includes(spec.percentOf))?spec.percentOf:null;
const card=document.createElement("div");card.className="vis-card";
let h=spec.title?`