Ejecución Presupuestal

Última actualización: —

Cargando…
${fTipo?``:""}
${fFecha?``:""} ${fLote?``:""} ${fUPG?``:""} ${fRub?``:""} ${fCta?``:""} ${fSub?``:""} ${fFun?``:""} ${fDesc?``:""} ${fTipo?``:""}
FechaNro LoteUPGRubroCuentaSubcuentaFunciónDescripciónMontoTipo
`; function getV(el){return document.getElementById(mvId+"_"+el)?.value||"";} function doFilter(){ const q=getV("q").toLowerCase(); const upg=getV("upg"), rub=getV("rub"), cta=getV("cta"), sub=getV("sub"), tipo=getV("tipo"); return (ACTIVE||FULL.rows).filter(r=>{ if(upg&&fUPG&&String(r[fUPG]??"").trim()!==upg) return false; if(rub&&fRub&&String(r[fRub]??"").trim()!==rub) return false; if(cta&&fCta&&String(r[fCta]??"").trim()!==cta) return false; if(sub&&fSub&&String(r[fSub]??"").trim()!==sub) return false; if(tipo&&fTipo&&String(r[fTipo]??"").trim()!==tipo) return false; if(q){ const desc=fDesc?String(r[fDesc]??"").toLowerCase():""; const lot=fLote?String(r[fLote]??"").toLowerCase():""; const ct=fCta?String(r[fCta]??"").toLowerCase():""; const sb=fSub?String(r[fSub]??"").toLowerCase():""; if(!desc.includes(q)&&!lot.includes(q)&&!ct.includes(q)&&!sb.includes(q)) return false; } return true; }); } function rebuildMov(){ const rows=doFilter(); const MAX=FULL.rows.length; const body=document.getElementById(mvId+"_body"); body.innerHTML=""; let tot=0; for(const r of rows.slice(0,MAX)){ const rawVal=fVal?toNum(r[fVal]):null; const x=rowMetric(r); const sv2=rawVal!==null?rawVal:(x.ing-x.gas); tot+=sv2; const isIng=sv2>=0; const monto=Math.abs(sv2); const tr=document.createElement("tr"); tr.innerHTML= (fFecha?`${r[fFecha]||""}`:"")+ (fLote?`${r[fLote]||""}`:"")+ (fUPG?`${esc(String(r[fUPG]||""))}`:"")+ (fRub?`${esc(String(r[fRub]||""))}`:"")+ (fCta?`${esc(String(r[fCta]||""))}`:"")+ (fSub?`${esc(String(r[fSub]||"—"))}`:"")+ (fFun?`${esc(String(r[fFun]||""))}`:"")+ (fDesc?`${esc(String(r[fDesc]||""))}`:"")+ `${isIng?"+":"-"}${fmt(monto)}`+ (fTipo?`${esc(String(r[fTipo]||""))}`:""); body.appendChild(tr); } const totSign=tot>=0?"+":"-"; document.getElementById(mvId+"_info").textContent=`Mostrando ${Math.min(rows.length,MAX)} de ${rows.length} registros${""}`; document.getElementById(mvId+"_total").textContent=`Total: ${totSign}${fmt(Math.abs(tot))}`; document.getElementById(mvId+"_badge").textContent=rows.length.toLocaleString()+" registros"; } card.querySelector("#"+mvId+"_q").addEventListener("input",rebuildMov); card.querySelectorAll(".mv-fsel").forEach(s=>s.addEventListener("change",rebuildMov)); const _subHidGen=document.getElementById(mvId+"_sub"); if(_subHidGen) _subHidGen.addEventListener("change",rebuildMov); document.addEventListener("asinet:filterchange",rebuildMov); rebuildMov(); } // ===== Ejecución Presupuestal con Tabs + Drill-down ===== let EP_EXP={upg:new Set(),rub:new Set(),cta:new Set()}; let EP_MV_ID=null; function renderPresupuestoConTabs(main, title, allowedUPGs){ let treeUPG=buildBudExec(); if(allowedUPGs) treeUPG=treeUPG.filter(u=>allowedUPGs.includes(u.upg)); let treeGen=buildGeneralTree(treeUPG); let totP=0,totE=0; for(const u of treeUPG){totP+=u.P;totE+=u.E;} const totPct=totP?totE/totP*100:0, gc=totPct>100?"#A4262C":totPct>80?"#C75A00":"#0078D4"; const w=Math.min(totPct,100).toFixed(1); const cid="ep_"+Date.now(); EP_MV_ID=cid; // Meses disponibles para el filtro const budMonths=(SCHEMA.months||[]).map(m=>({k:m.k,label:m.label})); let budSelMes=new Set(budMonths.map(m=>m.k)); const mesCkHtml=budMonths.map((m,i)=>``).join(""); // Columnas de movimientos const H=FULL.headers; const fFecha=H.find(h=>/^fecha$/i.test(h.trim()))||DATE_COL; const fLote=H.find(h=>/lote|nro|asiento/i.test(h)&&!/subcuenta|cuenta/i.test(h)); const fUPG=findCol("UPG")||upgCol(); const fRub=findCol("Rubro"); const fCta=findCol("Cuenta"); const fSub=findCol("Subcuenta"); const fFun=H.find(h=>/funci[oó]n/i.test(h)); const fDesc=H.find(h=>/descrip|glosa|concepto/i.test(h)); const uniq=(col)=>[...new Set(FULL.rows.map(r=>String(r[col]??"").trim()).filter(Boolean))].sort(); const upgs=fUPG?uniq(fUPG).filter(u=>!allowedUPGs||allowedUPGs.includes(normUPG(u))):[], rubs=fRub?uniq(fRub):[], ctas=fCta?uniq(fCta).slice(0,300):[], subs=fSub?uniq(fSub).slice(0,300):[]; const fTipo=H.find(h=>/^tipo$/i.test(h.trim())); const card=document.createElement("div"); card.className="vis-card pbi-card"; main.appendChild(card); card.innerHTML=`
⬡ ${esc(title)}
${totPct.toFixed(1)}% ejecutado
Presupuesto
${fmt(totP)}
Ejecutado
${fmt(totE)}
Saldo disponible
${fmt(totP-totE)}
Avance global
${totPct.toFixed(1)}%
Nivel — clic para desglosar y filtrar PresupuestoEjecutado Saldo% Ejecución
Total${fmt(totP)}${fmt(totE)} ${fmt(totP-totE)}
${totPct.toFixed(1)}%
💡 Haz clic en cualquier fila para filtrar los movimientos detalle abajo
Expande una UPG → Rubro → Cuenta para ver el desglose por subcuenta
${fUPG?``:""} ${fRub?``:""} ${fCta?``:""} ${fSub?`
Todas las Subcuentas
${subs.map(v=>`
${esc(v)}
`).join("")}
`:""} ${fTipo?``:""}
🔍
Selecciona un filtro para ver los movimientos
Haz clic en una UPG, rubro o cuenta del árbol arriba, o usa los filtros de arriba
Aplica un filtro para ver movimientos
`; // ---- Lógica del árbol ---- const expGen=new Set(); function pbiRowTree(label,P,E,indent,key,hasCh,expSet,onClick){ const open=expSet.has(key), saldo=P-E, pct=P?E/P*100:null; const padLeft=12+indent*18; const rowCls=indent===0?"pbi-row-upg":indent===1?"pbi-row-rub":indent===2?"pbi-row-cta":"pbi-row-sub"; return ` ${hasCh?`${open?"▾":"▸"}`:``}${esc(label)} ${fmt(P)}${fmt(E)} ${fmt(saldo)}${pbiBarCell(pct)}`; } function rebuildTree(v){ const tree=v==="general"?treeGen:treeUPG; let rows=""; if(v==="general"){ for(const rb of treeGen){ const kr="g:"+rb.rub; rows+=pbiRowTree(rb.rub,rb.P,rb.E,0,kr,rb.ctas.length>0,expGen,"grub|"+rb.rub); if(expGen.has(kr)) for(const ct of rb.ctas){ const kc="g:"+rb.rub+"||"+ct.cta; const ctLabel=ct.name?(ct.cta+" - "+ct.name):ct.cta; const rsubs=ct.subs.filter(s=>s.sub&&s.sub!=="(sin subcuenta)"); rows+=pbiRowTree(ctLabel,ct.P,ct.E,1,kc,rsubs.length>0,expGen,"gcta|"+rb.rub+"|"+ct.cta); if(expGen.has(kc)) for(const sb of rsubs) rows+=pbiRowTree(sb.sub,sb.P,sb.E,2,"gs:"+sb.sub,false,expGen,"gsub|"+rb.rub+"|"+ct.cta+"|"+sb.sub); } } } else { for(const u of treeUPG){ const ku="u:"+u.upg; rows+=pbiRowTree(u.upg,u.P,u.E,0,ku,u.rubs.length>0,EP_EXP.upg,"upg|"+u.upg); if(EP_EXP.upg.has(ku)) for(const rb of u.rubs){ const kr="r:"+u.upg+"||"+rb.rub; rows+=pbiRowTree(rb.rub,rb.P,rb.E,1,kr,rb.ctas.length>0,EP_EXP.rub,"rub|"+u.upg+"|"+rb.rub); if(EP_EXP.rub.has(kr)) for(const ct of rb.ctas){ const kc="c:"+u.upg+"||"+rb.rub+"||"+ct.cta; const ctLabel=ct.name?(ct.cta+" - "+ct.name):ct.cta; const rsubs=ct.subs.filter(s=>s.sub&&s.sub!=="(sin subcuenta)"); rows+=pbiRowTree(ctLabel,ct.P,ct.E,2,kc,rsubs.length>0,EP_EXP.cta,"cta|"+u.upg+"|"+rb.rub+"|"+ct.cta); if(EP_EXP.cta.has(kc)) for(const sb of rsubs) rows+=pbiRowTree(sb.sub,sb.P,sb.E,3,"s:"+sb.sub,false,new Set(),"sub|"+u.upg+"|"+rb.rub+"|"+ct.cta+"|"+sb.sub); } } } } document.getElementById(cid+"_tBody").innerHTML=rows; document.getElementById(cid+"_tBody").querySelectorAll("tr[data-onclick]").forEach(tr=>{ tr.addEventListener("click",()=>{ const parts=tr.getAttribute("data-onclick").split("|"); const lvl=parts[0],k=tr.getAttribute("data-k"); // Toggle expand if(lvl==="upg"){EP_EXP.upg.has(k)?EP_EXP.upg.delete(k):EP_EXP.upg.add(k);} else if(lvl==="rub"){EP_EXP.rub.has(k)?EP_EXP.rub.delete(k):EP_EXP.rub.add(k);} else if(lvl==="cta"){EP_EXP.cta.has(k)?EP_EXP.cta.delete(k):EP_EXP.cta.add(k);} else if(lvl==="grub"){expGen.has(k)?expGen.delete(k):expGen.add(k);} else if(lvl==="gcta"){expGen.has(k)?expGen.delete(k):expGen.add(k);} rebuildTree(BUD_VIEW); // Filtrar movimientos acumulativamente — distinguir vista general vs por UPG (sin mover el scroll) let _upg="",_rub="",_cta="",_sub=""; if(lvl==="upg") {_upg=parts[1];} else if(lvl==="rub") {_upg=parts[1];_rub=parts[2];} else if(lvl==="cta") {_upg=parts[1];_rub=parts[2];_cta=parts[3];} else if(lvl==="sub") {_upg=parts[1];_rub=parts[2];_cta=parts[3];_sub=parts[4];} else if(lvl==="grub") {_rub=parts[1];} else if(lvl==="gcta") {_rub=parts[1];_cta=parts[2];} else if(lvl==="gsub") {_rub=parts[1];_cta=parts[2];_sub=parts[3];} setMvFilter(lvl,_upg,_rub,_cta,_sub); swEpTab("mov"); }); }); } // ---- Filtro de movimientos ---- function gv(id){return document.getElementById(cid+"_"+id)?.value||"";} function setMvFilter(lvl,upg,rub,cta,sub){ if(fUPG){const el=document.getElementById(cid+"_u");if(el)el.value=upg||"";} if(fRub){const el=document.getElementById(cid+"_r");if(el)el.value=rub||"";} if(fCta&&cta){ const el=document.getElementById(cid+"_c"); if(el){ // Buscar opción exacta o que empiece con el código const code=cta.split(" - ")[0].trim(); const opt=[...el.options].find(o=>o.value===cta||o.value.startsWith(code+" ")||o.value===code); if(opt) el.value=opt.value; else el.value=""; } } else if(fCta){const el=document.getElementById(cid+"_c");if(el)el.value="";} if(fSub){const el=document.getElementById(cid+"_s");if(el)el.value=sub||"";} rebuildMov(); // Badges const items=[]; if(upg) items.push({lbl:"UPG",val:upg}); if(rub) items.push({lbl:"Rubro",val:rub}); if(cta) items.push({lbl:"Cuenta",val:cta.split(" - ")[0]}); if(sub) items.push({lbl:"Subcuenta",val:sub}); const br=document.getElementById(cid+"_badges"); br.innerHTML=items.map(i=>`🔍 ${i.lbl}: ${i.val}`).join("")+(items.length?``:""); } // ---- Virtualización del DOM (estilo Power BI) ---- const ROW_H=34; // altura fija por fila en px let _vRows=[], _vScroll=0, _vTot=0; function rowHtml(row){ const x=rowMetric(row); const fValLocal=H.find(h=>/^valor$|^monto$|^importe$/i.test(h.trim())); const rawVal2=fValLocal?toNum(row[fValLocal]):null; const sv3=rawVal2!==null?rawVal2:(x.ing||-(x.gas)||0); const isIng=sv3>=0, monto=Math.abs(sv3); return ``+ (fFecha?`${esc(String(row[fFecha]||""))}`:"")+ (fLote?`${esc(String(row[fLote]||""))}`:"")+ (fUPG?`${esc(String(row[fUPG]||""))}`:"")+ (fRub?`${esc(String(row[fRub]||""))}`:"")+ (fCta?`${esc(String(row[fCta]||""))}`:"")+ (fSub?`${esc(String(row[fSub]||"—"))}`:"")+ (fFun?`${esc(String(row[fFun]||""))}`:"")+ (fDesc?`${esc(String(row[fDesc]||""))}`:"")+ `${isIng?"+":"-"}${fmt(monto)}`+ (fTipo?`${esc(String(row[fTipo]||""))}`:"")+ ``; } function renderVisible(){ const wrap=document.getElementById(cid+"_mvWrap"); if(!wrap) return; const h=wrap.clientHeight||400, scroll=wrap.scrollTop; const start=Math.max(0,Math.floor(scroll/ROW_H)-5); const end=Math.min(_vRows.length,start+Math.ceil(h/ROW_H)+10); const tbody=document.getElementById(cid+"_mvBody"); if(!tbody) return; tbody.innerHTML=``+ _vRows.slice(start,end).map(r=>rowHtml(r)).join("")+ ``; } 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}
${mapRow}${body}
CUENTASUBC.
${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?`
${esc(spec.title)}
`:""; h+=``+measures.map(m=>``).join("")+(pct?``:"")+``; for(const o of arr){ h+=``+measures.map(m=>``).join(""); if(pct){const tot=totals[pct]||1;h+=``;} h+=``; } h+=``+measures.map(m=>``).join("")+(pct?``:"")+`
${esc(byMonth?"Mes":spec.groupBy)}${esc(m)}%
${esc(byMonth?monthLabel(o.k):o.k)}${fmtMaybe(measureVal(o,m))}${(measureVal(o,pct)/tot*100).toFixed(1)}%
Total${fmtMaybe(totals[m])}100%
`; const block=document.createElement("div");block.className="vis-block";container.appendChild(block); card.innerHTML=h; block.appendChild(card); const exCols=[byMonth?"Mes":spec.groupBy,...measures].concat(pct?["% "+pct]:[]); const exRows=arr.map(o=>[byMonth?monthLabel(o.k):o.k,...measures.map(m=>measureVal(o,m)),...(pct?[+(measureVal(o,pct)/(totals[pct]||1)*100).toFixed(1)]:[])]); exRows.push(["Total",...measures.map(m=>totals[m]),...(pct?[100]:[])]); addDL(block,{el:card,name:spec.title||"tabla",excel:{columns:exCols,rows:exRows,sheet:spec.title||"Tabla"}}); } function renderReport(container,spec){ const card=document.createElement("div");card.className="vis-card"; let h=`
${esc(spec.title||"Reporte")}
`; const kpis=Array.isArray(spec.kpis)?spec.kpis.slice(0,4):[]; if(kpis.length){ h+=`
`+kpis.map(k=>`
${esc(k.label||k.measure)}
${fmtMaybe(grandTotal(k.measure||k.label,k.filters))}
`).join("")+`
`; } const block=document.createElement("div");block.className="vis-block";container.appendChild(block); card.innerHTML=h; block.appendChild(card); let excel=null; if(spec.chart && validGroup(spec.chart.groupBy)){ renderChart(card, spec.chart, true); const bs=buildSeries(spec.chart); excel={columns:[dimName(spec.chart.groupBy),...bs.series.map(s=>s.name)], rows:bs.labels.map((lab,i)=>[lab,...bs.series.map(s=>s.values[i])]), sheet:spec.title||"Reporte"}; } addDL(block,{el:card,name:spec.title||"reporte",excel}); } // ---- Matriz cruzada (pivote): filas × columnas, una medida ---- function isMonthDim(name){ return isMonthGroup(name) || (DATE_COL && name===DATE_COL); } function pivotKind(measure){ if(SCHEMA.financial) return normMeasure(measure)||"gas"; // ing|gas|res if(SCHEMA.numCols.includes(measure)) return measure; return SCHEMA.gmeasure || null; // null => conteo } function cellVal(o,kind){ if(!o) return 0; if(SCHEMA.financial) return kind==="ing"?o.ing:kind==="gas"?o.gas:o.ing-o.gas; if(kind && o.sums) return o.sums[kind]||0; return o.count; } function pivotData(spec){ const rowsDim=spec.rows, colsDim=spec.cols; const rowMonth=isMonthDim(rowsDim), colMonth=isMonthDim(colsDim); if(!rowMonth && !SCHEMA.headers.includes(rowsDim)) return {error:`No existe la columna "${rowsDim}".`}; if(!colMonth && !SCHEMA.headers.includes(colsDim)) return {error:`No existe la columna "${colsDim}".`}; if((rowMonth&&colMonth)||rowsDim===colsDim) return {error:"Filas y columnas deben ser dimensiones distintas."}; const kind=pivotKind(spec.measure); let rows=ACTIVE||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 rk=r=> rowMonth ? (monthKey(r[DATE_COL])||"(sin fecha)") : (String(r[rowsDim]??"(vacío)").trim()||"(vacío)"); const ck=r=> colMonth ? (monthKey(r[DATE_COL])||"(sin fecha)") : (String(r[colsDim]??"(vacío)").trim()||"(vacío)"); const grid={}, colAgg={}; for(const r of rows){ const a=rk(r), b=ck(r); if(!grid[a]) grid[a]={}; if(!grid[a][b]) grid[a][b]={ing:0,gas:0,count:0,sums:{}}; if(!colAgg[b]) colAgg[b]={ing:0,gas:0,count:0,sums:{}}; const A=grid[a][b], C=colAgg[b]; if(SCHEMA.financial){ const m=rowMetric(r); A.ing+=m.ing;A.gas+=m.gas; C.ing+=m.ing;C.gas+=m.gas; } else { for(const c of SCHEMA.numCols){ const v=toNum(r[c]); if(v!==null){ A.sums[c]=(A.sums[c]||0)+v; C.sums[c]=(C.sums[c]||0)+v; } } } A.count++; C.count++; } let colKeys=Object.keys(colAgg); if(colMonth) colKeys.sort((a,b)=>ab?1:0); else colKeys.sort((a,b)=>Math.abs(cellVal(colAgg[b],kind))-Math.abs(cellVal(colAgg[a],kind))); const colLabels0=colKeys.map(k=>colMonth?monthLabel(k):k); let colLabels=colLabels0; const rowTot=a=>{ let t=0; for(const b of colKeys){ if(grid[a][b]) t+=cellVal(grid[a][b],kind); } return t; }; let rowKeys=Object.keys(grid); if(rowMonth) rowKeys.sort((a,b)=>ab?1:0); else if(spec.sortRows==="asc") rowKeys.sort((a,b)=>rowTot(a)-rowTot(b)); else if(spec.sortRows!=="none") rowKeys.sort((a,b)=>Math.abs(rowTot(b))-Math.abs(rowTot(a))); if(spec.limitRows && !rowMonth) rowKeys=rowKeys.slice(0,spec.limitRows); let rowsArr=rowKeys.map(a=>({label:rowMonth?monthLabel(a):a, cells:colKeys.map(b=>cellVal(grid[a][b],kind)), total:rowTot(a)})); let colTotals=colKeys.map(b=>cellVal(colAgg[b],kind)); const nz=v=>Math.round((v||0)*100)!==0; if(!rowMonth) rowsArr=rowsArr.filter(r=>r.cells.some(nz)); // ocultar filas sin monto if(!colMonth){ // ocultar columnas sin monto const keep=colLabels.map((_,ci)=>rowsArr.some(r=>nz(r.cells[ci]))); if(keep.some(k=>!k)){ const idx=keep.map((k,i)=>k?i:-1).filter(i=>i>=0); colLabels=idx.map(i=>colLabels[i]); colTotals=idx.map(i=>colTotals[i]); rowsArr=rowsArr.map(r=>({label:r.label,cells:idx.map(i=>r.cells[i]),total:idx.reduce((s,i)=>s+r.cells[i],0)})); } } const grand=colTotals.reduce((s,v)=>s+v,0); return {rowsArr,colLabels,colTotals,grand,rowName:rowMonth?"Mes":rowsDim,colName:colMonth?"Mes":colsDim}; } function renderPivot(container,spec){ const d=pivotData(spec); if(d.error) return visNote(container,d.error); if(!d.rowsArr.length||!d.colLabels.length) return visNote(container,"No hay datos con monto para esa matriz. Revisa el filtro o prueba con otra dimensión/medida."); const rnd=v=>Math.round(v*100)/100; const block=document.createElement("div");block.className="vis-block";container.appendChild(block); const card=document.createElement("div");card.className="vis-card"; let h=spec.title?`
${esc(spec.title)}
`:""; h+=`
`+d.colLabels.map(c=>``).join("")+``; for(const r of d.rowsArr){ h+=``+r.cells.map(v=>``).join("")+``; } h+=``+d.colTotals.map(v=>``).join("")+`
${esc(d.rowName)}${esc(c)}Total
${esc(r.label)}${fmtMaybe(rnd(v))}${fmtMaybe(rnd(r.total))}
Total${fmtMaybe(rnd(v))}${fmtMaybe(rnd(d.grand))}
`; card.innerHTML=h; block.appendChild(card); const exCols=[d.rowName,...d.colLabels,"Total"]; const exRows=d.rowsArr.map(r=>[r.label,...r.cells.map(rnd),rnd(r.total)]); exRows.push(["Total",...d.colTotals.map(rnd),rnd(d.grand)]); addDL(block,{el:card,name:spec.title||"matriz",excel:{columns:exCols,rows:exRows,sheet:spec.title||"Matriz"}}); } function addDL(container,{canvas,el,name,excel}){ const bar=document.createElement("div");bar.className="dl-bar"; const png=document.createElement("button");png.className="dl-btn";png.textContent="⬇ PNG"; png.onclick=async()=>{ try{ let url; if(canvas){ url=canvas.toDataURL("image/png"); } else { const c=await html2canvas(el,{backgroundColor:"#ffffff",scale:2}); url=c.toDataURL("image/png"); } const a=document.createElement("a");a.href=url;a.download=(name||"export").replace(/\s+/g,"_")+".png";a.click(); }catch(e){} }; bar.appendChild(png); if(el){ const pdf=document.createElement("button");pdf.className="dl-btn";pdf.textContent="⬇ PDF"; pdf.onclick=async()=>{ try{ const c=await html2canvas(el,{backgroundColor:"#ffffff",scale:2}); const img=c.toDataURL("image/png"); const {jsPDF}=window.jspdf||{}; if(!jsPDF) return; const doc=new jsPDF({orientation:c.width>c.height?"l":"p",unit:"px",format:[c.width,c.height]}); doc.addImage(img,"PNG",0,0,c.width,c.height); doc.save((name||"export").replace(/\s+/g,"_")+".pdf"); }catch(e){} }; bar.appendChild(pdf); } if(excel&&excel.columns){ const xls=document.createElement("button");xls.className="dl-btn";xls.textContent="⬇ Excel"; xls.onclick=()=>toExcel(excel,name); bar.appendChild(xls); } container.appendChild(bar); } // ---- Copiloto vía /api/chat (Groq, key oculta en Cloudflare) ---- const input=document.getElementById("q"),sendBtn=document.getElementById("send"); let history=[]; function paintText(el,text){el.innerHTML=esc(text).replace(/\*\*(.+?)\*\*/g,"$1");} // ===== Motor LOCAL: resuelve búsquedas/filtros/listados sin IA ===== function accentless(s){ return String(s??"").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g,""); } function findHeaderByHint(hint){ const h=accentless(hint).trim(); if(!h||!SCHEMA||!SCHEMA.headers) return null; for(const head of SCHEMA.headers){ const hh=accentless(head); if(hh===h||hh.includes(h)||(h.length>=3&&h.includes(hh))) return head; } if(/descrip|glosa|concepto|detalle/.test(h)) return SCHEMA.headers.find(x=>/descrip|glosa|concepto|detalle/i.test(x))||null; return null; } function descCol(){ return (SCHEMA&&SCHEMA.headers&&SCHEMA.headers.find(x=>/descrip|glosa|concepto|detalle/i.test(x)))||null; } // Devuelve {type:"search",term,col} si la consulta es una búsqueda simple; si no, null (→ IA) function parseLocalQuery(text){ if(!FULL||!FULL.rows||!FULL.rows.length||!SCHEMA) return null; const t=String(text).toLowerCase(); const VERB="(?:contenga[n]?|contiene[n]?|tenga[n]?|tengan|incluya[n]?|diga[n]?|que digan?|busca(?:r)?|filtra(?:r)?|encuentra|encontrar|lista(?:r)?|muestra(?:me)?)"; let term=null,colHint=null; let m=t.match(new RegExp(VERB+"\\s+[\"']?([^\"']+?)[\"']?\\s+en\\s+(?:la\\s+|el\\s+|columna\\s+)?(.+)$")); if(m){ term=m[1]; colHint=m[2]; } else { const m2=t.match(new RegExp(VERB+"\\s+[\"']?([^\"']+?)[\"']?\\s*$")); if(m2){ term=m2[1]; colHint="descripcion"; } } if(!term) return null; term=term.trim().replace(/[.;,]+$/,"").replace(/^(todo[s]?\s+lo[s]?\s+que\s+|los?\s+que\s+|aquello\s+que\s+)/,"").trim(); if(term.length<2) return null; const col=findHeaderByHint(colHint)||descCol(); if(!col) return null; return {type:"search",term,col}; } function renderLocalResult(text,parsed){ CURRENT_VIEW="copiloto"; document.querySelectorAll(".nav-item").forEach(b=>b.classList.remove("active")); (function(){const pt=document.getElementById("pageTitle");if(pt)pt.textContent="Tesorero Asistente";})(); const main=document.getElementById("mainView"); main.innerHTML=""; const q=document.createElement("div");q.className="query-echo";q.textContent="“"+text+"”";main.appendChild(q); const col=parsed.col, needle=parsed.term.toLowerCase(); const base=(ACTIVE&&ACTIVE.length)?ACTIVE:FULL.rows; const matched=base.filter(r=>String(r[col]??"").toLowerCase().includes(needle)); if(!matched.length){ const c=document.createElement("div");c.className="ai-card"; c.innerHTML=`
No se encontraron registros con “${esc(parsed.term)}” en ${esc(col)}.
`; main.appendChild(c); return; } let ing=0,gas=0; for(const r of matched){const m=rowMetric(r);ing+=m.ing;gas+=m.gas;} const card=document.createElement("div");card.className="ai-card"; const kp=SCHEMA.financial ? `${matched.length} registro(s) · Gastos ${fmt(gas)} · Ingresos ${fmt(ing)} · Resultado ${fmt(ing-gas)}` : `${matched.length} registro(s) encontrado(s)`; card.innerHTML=`
🔎 Búsqueda local: “${esc(parsed.term)}” en ${esc(col)}
${kp}
`; main.appendChild(card); const vis=visGrid(main); renderTable(vis,{title:`Detalle: “${parsed.term}” en ${col}`,groupBy:col, filters:[{col,op:"contains",val:parsed.term}], measures:SCHEMA.financial?["Ingresos","Gastos","Resultado"]:[SCHEMA.gmeasure||"count"], percentOf:SCHEMA.financial?"Gastos":undefined,sort:SCHEMA.financial?"Gastos":undefined}); } function isBusyError(data,status){ const s=(String((data&&data.error)||"")+" "+String((data&&data.detail)||"")).toLowerCase(); return status===429||status===503||status===502||/\b(429|503|502)\b|high demand|overload|unavailable|exhaust|quota|rate.?limit|temporar|ocupado|sobrecarg/.test(s); } function busyCard(main){ const c=document.createElement("div");c.className="ai-card"; c.innerHTML=`
El asistente está temporalmente ocupado. Intenta nuevamente en unos segundos.
`; main.appendChild(c); } async function ask(text){ if(!text.trim()||input.disabled) return; // 1) Consultas simples (búsqueda/filtro/listado) → se resuelven LOCAL, sin IA const local=parseLocalQuery(text); if(local){ input.value=""; renderLocalResult(text,local); input.focus(); return; } history.push({role:"user",content:text}); input.value="";input.disabled=true;sendBtn.disabled=true; CURRENT_VIEW="copiloto"; document.querySelectorAll(".nav-item").forEach(b=>b.classList.remove("active")); (function(){const pt=document.getElementById("pageTitle");if(pt)pt.textContent="Tesorero Asistente";})(); const main=document.getElementById("mainView"); main.innerHTML=`
Analizando tu consulta…
`; try{ const r=await fetch("/api/chat",{method:"POST",headers:{"content-type":"application/json"}, body:JSON.stringify({digest:DIGEST,messages:history})}); let data; try{ data=await r.json(); }catch(_){ data={error:"__invalid__",__status:r.status}; } // 2) Errores de disponibilidad/cuota → mensaje amable (+ respaldo local si aplica) if(!r.ok || (data&&data.error)){ if(isBusyError(data,r.status)){ history.pop(); main.innerHTML=""; const fb=parseLocalQuery(text); if(fb){ renderLocalResult(text,fb); } else { const q=document.createElement("div");q.className="query-echo";q.textContent="“"+text+"”";main.appendChild(q); busyCard(main); } input.disabled=false;sendBtn.disabled=false;input.focus(); return; } if(data.error==="__invalid__") data.error="El servidor no devolvió una respuesta válida (estado "+(data.__status||r.status)+"). Revisa que _worker.js esté desplegado y la API key configurada en Cloudflare."; } let reply=data.reply||data.error||"No pude responder."; if(data.detail) reply+=" — "+String(data.detail); history.push({role:"assistant",content:reply}); const fullReply=reply; const blocks=[]; reply=reply.replace(/```(chart|table|report|pivot)\s*([\s\S]*?)```/g,(mm,kind,body)=>{try{blocks.push({kind,spec:JSON.parse(body.trim())});}catch(e){}return"";}).trim(); main.innerHTML=""; const q=document.createElement("div");q.className="query-echo";q.textContent="“"+text+"”";main.appendChild(q); if(reply){ const card=document.createElement("div");card.className="ai-card"; card.innerHTML=`
🤖 Resumen ejecutivo
`; const body=document.createElement("div");body.className="ai-card-body";paintText(body,reply);card.appendChild(body); main.appendChild(card); } if(blocks.length){ const vis=visGrid(main); for(const b of blocks){ if(b.kind==="chart")renderChart(vis,b.spec); else if(b.kind==="table")renderTable(vis,b.spec); else if(b.kind==="report")renderReport(vis,b.spec); else if(b.kind==="pivot")renderPivot(vis,b.spec); } } if(!reply && !blocks.length) emptyState(main,"Sin respuesta del agente."); else { const sb=document.createElement("div");sb.className="dl-row"; const btn=document.createElement("button");btn.className="dl-btn";btn.textContent="💾 Guardar reporte"; btn.onclick=async()=>{ btn.disabled=true;btn.textContent="Guardando…"; const title=((reply.match(/\*\*(.+?)\*\*/)||[])[1]||text).slice(0,120); try{ const r=await fetch("/api/reports",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({title,query:text,reply:fullReply})}); btn.textContent = r.ok ? "✓ Guardado en Reportes" : "Error al guardar"; if(!r.ok) btn.disabled=false; }catch(_){ btn.textContent="Error al guardar"; btn.disabled=false; } }; sb.appendChild(btn); main.appendChild(sb); } }catch(e){ main.innerHTML="";const q=document.createElement("div");q.className="query-echo";q.textContent="“"+text+"”";main.appendChild(q);busyCard(main); } input.disabled=false;sendBtn.disabled=false;input.focus(); } sendBtn.onclick=()=>ask(input.value); input.addEventListener("keydown",e=>{if(e.key==="Enter")ask(input.value);}); function wireNav(){ document.querySelectorAll(".nav-item").forEach(b=>{ b.disabled=false; b.onclick=()=>navTo(b.dataset.view); }); } function suggestions(){ const cat=Object.keys(SCHEMA.breakdowns)[0]||(SCHEMA.catCols[0]&&SCHEMA.catCols[0].h)||"categoría"; let qs; if(SCHEMA.financial){ qs=["Estado de resultados",`Gasto por ${cat}`,DATE_COL?"Tendencia mensual":`Ingresos vs Gastos por ${cat}`,"Gasto de personal"]; } else { qs=["Resume los datos",`Top 5 por ${cat}`,DATE_COL?"Tendencia mensual":`Distribución por ${cat}`]; } const box=document.getElementById("suggest"); box.innerHTML=qs.filter(Boolean).map(q=>``).join(""); box.querySelectorAll("button").forEach(b=>b.onclick=()=>ask(b.textContent)); } function setDataUpdated(parsed,headerUpd){ const el=document.getElementById("lastUpd"); if(!el) return; const p=n=>String(n).padStart(2,"0"); const fmtD=d=>`${p(d.getDate())}/${p(d.getMonth()+1)}/${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`; // 1) Lo más exacto: una columna de timestamp en el propio Sheet (ej. "ActualizacionDatos") const tsCol=(parsed.meta.fields||[]).find(h=>/actualiz|fecha.?carga|fecha.?corte|updated|timestamp|marca.?temporal/i.test(h)); if(tsCol){ for(const r of parsed.data){ const v=String(r[tsCol]??"").trim(); if(v){ const d=new Date(v); el.textContent="Datos actualizados: "+(isNaN(d.getTime())?v:fmtD(d)); return; } } } // 2) Cabecera del Sheet publicado (aproximado: hora en que Google regeneró el CSV) if(headerUpd){ const d=new Date(headerUpd); if(!isNaN(d.getTime())){ el.textContent="Datos actualizados (aprox.): "+fmtD(d); return; } } el.textContent="Datos actualizados: —"; } let CURRENT_USER=null; async function startApp(){ try{ const res=await fetch("/api/data"); if(!res.ok) throw new Error("http"); const upd=res.headers.get("x-updated"); const csv=await res.text(); const parsed=Papa.parse(csv,{header:true,skipEmptyLines:true}); if(!parsed.data.length) throw new Error("empty"); FULL={rows:parsed.data,headers:parsed.meta.fields}; setDataUpdated(parsed,upd); // Excluir filas de resumen/totales: una transacción real tiene "Función" // (centro de costos). Las filas Total Débito/Crédito/Saldo Neto no la tienen. const funcCol=FULL.headers.find(h=>/funci[oó]n/i.test(h)); const firstCol=FULL.headers[0]; FULL.rows=FULL.rows.filter(r=>{ if(funcCol && !String(r[funcCol]??"").trim()) return false; if(/^(total|saldo|result|sub\s*total|neto)/i.test(String(r[firstCol]??"").trim())) return false; return true; }); if(!FULL.rows.length) throw new Error("empty"); // UPG → nombre corto (unifica variantes de texto en toda la app) const upgColRaw=FULL.headers.find(h=>/^upg$/i.test(h.trim())) || FULL.headers.find(h=>/upg/i.test(h)); if(upgColRaw) for(const r of FULL.rows){ r[upgColRaw]=normUPG(r[upgColRaw]); } ACTIVE=FULL.rows; analyze(FULL.rows,FULL.headers); ALLMONTHS=(SCHEMA.months||[]).map(m=>({k:m.k,label:m.label})); buildMonthBar(); render();suggestions();wireNav(); loadBudget(); document.getElementById("dot").classList.add("live"); document.getElementById("statusTxt").textContent=`${SCHEMA.N.toLocaleString("es-PE")} registros`; input.disabled=false;sendBtn.disabled=false; navTo("dashboard"); }catch(e){ document.getElementById("statusTxt").textContent="No se pudo cargar el Sheet"; document.getElementById("mainView").innerHTML='
No se pudo leer /api/data. Revisa que el Sheet siga publicado y que la función esté desplegada.
'; } } // ===== Autenticación (frontend) ===== function showLogin(msg){ let ov=document.getElementById("loginOverlay"); if(!ov){ ov=document.createElement("div"); ov.id="loginOverlay"; ov.className="login-overlay"; document.body.appendChild(ov); } ov.innerHTML=``; ov.style.display="flex"; const f=document.getElementById("loginForm"); f.onsubmit=async(e)=>{ e.preventDefault(); const u=document.getElementById("lgUser").value.trim(), p=document.getElementById("lgPass").value; const btn=f.querySelector(".login-btn"); btn.disabled=true; btn.textContent="Ingresando…"; document.getElementById("lgMsg").textContent=""; try{ const r=await fetch("/api/login",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username:u,password:p})}); const d=await r.json(); if(r.ok){ ov.remove(); CURRENT_USER=d.user; await startApp(); renderAccountUI(); } else { document.getElementById("lgMsg").textContent=d.error||"No se pudo ingresar."; btn.disabled=false; btn.textContent="Ingresar"; } }catch(_){ document.getElementById("lgMsg").textContent="Error de conexión."; btn.disabled=false; btn.textContent="Ingresar"; } }; } async function authGate(){ try{ const r=await fetch("/api/me"); if(r.ok){ const d=await r.json(); CURRENT_USER=d.user; await startApp(); renderAccountUI(); return; } }catch(_){} showLogin(); } function renderAccountUI(){ const side=document.querySelector(".sidebar"); if(!side||!CURRENT_USER) return; let box=document.getElementById("acctBox"); if(!box){ box=document.createElement("div"); box.id="acctBox"; box.className="acct-box"; side.appendChild(box); } box.innerHTML=`
👤 ${esc(CURRENT_USER.u)}
${CURRENT_USER.role==="admin"?"Administrador":"Usuario"}
`; document.getElementById("acctPw").onclick=changePasswordDialog; document.getElementById("acctOut").onclick=async()=>{ try{await fetch("/api/logout",{method:"POST"});}catch(_){} location.reload(); }; if(CURRENT_USER.role==="admin" && !document.querySelector('.nav-item[data-view="usuarios"]')){ const nav=document.querySelector(".nav"); if(nav){ const b=document.createElement("button"); b.className="nav-item"; b.dataset.view="usuarios"; b.title="Usuarios"; b.innerHTML='👥Usuarios'; b.onclick=()=>navTo("usuarios"); nav.appendChild(b); } } } function changePasswordDialog(){ const ov=document.createElement("div"); ov.className="login-overlay"; document.body.appendChild(ov); ov.innerHTML=``; ov.style.display="flex"; document.getElementById("cpCancel").onclick=()=>ov.remove(); document.getElementById("cpForm").onsubmit=async(e)=>{ e.preventDefault(); const cur=document.getElementById("cpCur").value, next=document.getElementById("cpNew").value, msg=document.getElementById("cpMsg"); try{ const r=await fetch("/api/change-password",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({current:cur,next})}); const d=await r.json(); if(r.ok){ msg.style.color="#1a7f4b"; msg.textContent="Contraseña actualizada."; setTimeout(()=>ov.remove(),900); } else { msg.style.color="#c0392b"; msg.textContent=d.error||"No se pudo cambiar."; } }catch(_){ msg.textContent="Error de conexión."; } }; } async function renderUsers(main){ const units=[...new Set(FULL.rows.map(r=>String(r[upgCol()]??"").trim()).filter(Boolean))].sort(); const box=document.createElement("div"); box.className="vis-card"; main.appendChild(box); box.innerHTML=`
Nuevo / editar usuario
UPG permitidas (déjalo en "Todas" para ver todo):
${units.map(u=>``).join("")}
`; const allBox=box.querySelector("#uAll"), upgBoxes=[...box.querySelectorAll(".uUpg")]; allBox.onchange=()=>{ upgBoxes.forEach(b=>{b.disabled=allBox.checked; if(allBox.checked)b.checked=false;}); }; box.querySelector("#uSave").onclick=async()=>{ const username=box.querySelector("#uUser").value.trim(), password=box.querySelector("#uPass").value.trim(), role=box.querySelector("#uRole").value; const upgs = allBox.checked ? ["*"] : upgBoxes.filter(b=>b.checked).map(b=>b.value); const msg=box.querySelector("#uMsg"); if(!allBox.checked && !upgs.length){ msg.style.color="#c0392b"; msg.textContent="Elige al menos una UPG o marca 'Todas'."; return; } try{ const r=await fetch("/api/users",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({username,password,role,upgs})}); const d=await r.json(); if(r.ok){ msg.style.color="#1a7f4b"; msg.textContent="Usuario guardado."; loadUserList(list); box.querySelector("#uUser").value="";box.querySelector("#uPass").value=""; } else { msg.style.color="#c0392b"; msg.textContent=d.error||"No se pudo guardar."; } }catch(_){ msg.textContent="Error de conexión."; } }; const list=document.createElement("div"); list.className="rep-list"; list.style.marginTop="16px"; main.appendChild(list); loadUserList(list); } async function loadUserList(list){ list.innerHTML=`
Cargando usuarios…
`; try{ const r=await fetch("/api/users"); const d=await r.json(); if(!r.ok){ list.innerHTML=`
${esc(d.error||"Error")}
`; return; } if(!d.users.length){ list.innerHTML=""; return; } list.innerHTML=""; for(const u of d.users){ const item=document.createElement("div"); item.className="rep-item"; const scope = (u.upgs&&u.upgs.includes("*"))?"Todas las UPG":(u.upgs||[]).join(", "); item.innerHTML=`
👤 ${esc(u.u)} · ${u.role==="admin"?"Administrador":"Usuario"}
${esc(scope)}
`; const acts=document.createElement("div"); acts.className="rep-acts"; const del=document.createElement("button"); del.className="dl-btn danger"; del.textContent="Eliminar"; del.onclick=async()=>{ if(!confirm("¿Eliminar el usuario "+u.u+"?"))return; del.disabled=true; try{ const rr=await fetch("/api/users?u="+encodeURIComponent(u.u),{method:"DELETE"}); if(rr.ok) item.remove(); else del.disabled=false; }catch(_){del.disabled=false;} }; acts.appendChild(del); item.appendChild(acts); list.appendChild(item); } }catch(_){ list.innerHTML=`
No se pudieron cargar los usuarios.
`; } } authGate();