{"id":3677,"date":"2025-10-28T21:22:23","date_gmt":"2025-10-28T21:22:23","guid":{"rendered":"https:\/\/gestamineria.cl\/?page_id=3677"},"modified":"2025-10-28T21:22:23","modified_gmt":"2025-10-28T21:22:23","slug":"3677-2","status":"publish","type":"page","link":"https:\/\/gestamineria.cl\/en\/3677-2\/","title":{"rendered":""},"content":{"rendered":"<p><?php\n\/**\n * Plugin Name: Panel Interactivo Remuneraciones Gesta\n * Description: Shortcode [gesta_remu_panel] para visualizar en WordPress un panel estilo Power BI con filtros por Centro de Costo y rango de \"Sueldo L\u00edquido a Pago\" desde un Excel (XLSX).\n * Version: 1.0.0\n * Author: ChatGPT (para ROX)\n *\/\n\nif (!defined('ABSPATH')) { exit; }\n\n\/\/ --- Enqueue de librer\u00edas y estilos ---\nadd_action('wp_enqueue_scripts', function(){\n  \/\/ Chart.js (para gr\u00e1ficos)\n  wp_register_script('chartjs', 'https:\/\/cdn.jsdelivr.net\/npm\/chart.js', [], null, true);\n  \/\/ SheetJS (para leer XLSX en el navegador)\n  wp_register_script('sheetjs', 'https:\/\/cdn.jsdelivr.net\/npm\/xlsx\/dist\/xlsx.full.min.js', [], null, true);\n});\n\n\/\/ --- Shortcode ---\n\/\/ Uso b\u00e1sico: [gesta_remu_panel]\n\/\/ Con archivo subido a la Librer\u00eda de Medios: [gesta_remu_panel file_url=\"https:\/\/tusitio\/wp-content\/uploads\/OCTUBRE-2025.xlsx\" sheet=\"OCTUBRE\"]\nadd_shortcode('gesta_remu_panel', function($atts){\n  $a = shortcode_atts([\n    'file_url' => &#8221;,      \/\/ URL del XLSX (ideal: mismo dominio para evitar CORS)<br \/>\n    &#8216;sheet&#8217;    => &#8216;OCTUBRE&#8217;, \/\/ Nombre de hoja a leer<br \/>\n    &#8216;title&#8217;    => &#8216;Remuneraciones Gesta Miner\u00eda Mes Octubre 2025&#8217;<br \/>\n  ], $atts);<\/p>\n<p>  wp_enqueue_script(&#8216;chartjs&#8217;);<br \/>\n  wp_enqueue_script(&#8216;sheetjs&#8217;);<\/p>\n<p>  ob_start();<br \/>\n  ?><\/p>\n<style>\n    \/* Paleta: blanco, azul, rojo *\/\n    .gesta-wrap{background:#ffffff;color:#0c1a2b;border:1px solid #e6eaf0;border-radius:18px;padding:18px;box-shadow:0 6px 20px rgba(0,0,0,.06);}\n    .gesta-title{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-bottom:14px}\n    .gesta-title h2{margin:0;font-size:1.35rem;font-weight:800;color:#0d47a1}\n    .gesta-badge{background:#e53935;color:#fff;font-weight:700;border-radius:999px;padding:6px 12px}\n    .gesta-controls{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:10px 0 18px}\n    .gesta-card{background:#f7f9fc;border:1px solid #e7edf6;border-radius:16px;padding:12px}\n    .gesta-label{font-size:.85rem;color:#394b63;margin-bottom:6px;font-weight:600}\n    .gesta-select, .gesta-range{width:100%;border:1px solid #d6dfeb;border-radius:10px;padding:10px;background:#fff}\n    .gesta-grid{display:grid;grid-template-columns:repeat(4, minmax(0,1fr));gap:12px;margin:6px 0 18px}\n    .gesta-kpi{background:#0d47a1;color:#fff;border-radius:16px;padding:14px}\n    .gesta-kpi h4{margin:0 0 6px;font-size:.9rem;font-weight:700;opacity:.85}\n    .gesta-kpi .val{font-size:1.15rem;font-weight:800}\n    .gesta-kpi.alt{background:#e53935}\n    .gesta-actions{display:flex;gap:10px;flex-wrap:wrap;margin:4px 0 10px}\n    .gesta-btn{appearance:none;border:0;border-radius:12px;padding:10px 14px;font-weight:700;cursor:pointer}\n    .gesta-btn.primary{background:#0d47a1;color:#fff}\n    .gesta-btn.ghost{background:#fff;border:1px solid #d6dfeb;color:#0d47a1}\n    .gesta-footer{font-size:.85rem;color:#6a7a92;margin-top:8px}\n    .gesta-chart{background:#fff;border:1px solid #e7edf6;border-radius:16px;padding:10px}\n    .gesta-note{font-size:.8rem;color:#7a8799;margin-top:6px}\n    @media (max-width: 980px){.gesta-grid{grid-template-columns:repeat(2, minmax(0,1fr));}.gesta-controls{grid-template-columns:1fr}}\n  <\/style>\n<div class=\"gesta-wrap\" data-file-url=\"<?php echo esc_attr($a['file_url']); ?>&#8221; data-sheet=&#8221;<?php echo esc_attr($a['sheet']); ?>&#8220;><\/p><div class=\"gesta-title\">\n<h2><?php echo esc_html($a['title']); ?><\/h2>\n<p>      <span class=\"gesta-badge\">Panel Ejecutivo<\/span>\n    <\/div>\n<div class=\"gesta-grid\">\n<div class=\"gesta-kpi\">\n<h4>Dotaci\u00f3n<\/h4>\n<div class=\"val\" id=\"kpi-dotacion\">\u2013<\/div>\n<\/div>\n<div class=\"gesta-kpi alt\">\n<h4>Total L\u00edquido a Pago<\/h4>\n<div class=\"val\" id=\"kpi-total\">\u2013<\/div>\n<\/div>\n<div class=\"gesta-kpi\">\n<h4>Promedio L\u00edquido<\/h4>\n<div class=\"val\" id=\"kpi-prom\">\u2013<\/div>\n<\/div>\n<div class=\"gesta-kpi\">\n<h4>Mediana L\u00edquido<\/h4>\n<div class=\"val\" id=\"kpi-med\">\u2013<\/div>\n<\/div><\/div>\n<div class=\"gesta-controls\">\n<div class=\"gesta-card\">\n<div class=\"gesta-label\">Centro de Costo (multi\u2011selecci\u00f3n)<\/div>\n<p>        <select class=\"gesta-select\" id=\"f-centro\" multiple size=\"6\" aria-label=\"Filtrar por Centro de Costo\"><\/select><\/p>\n<div class=\"gesta-note\">Sost\u00e9n Ctrl (Windows) o \u2318 (Mac) para seleccionar varios.<\/div>\n<\/p><\/div>\n<div class=\"gesta-card\">\n<div class=\"gesta-label\">Filtrar por Sueldo L\u00edquido a Pago (rango)<\/div>\n<p>        <input type=\"range\" class=\"gesta-range\" id=\"f-min\" min=\"0\" max=\"0\" value=\"0\" step=\"1000\" \/><br \/>\n        <input type=\"range\" class=\"gesta-range\" id=\"f-max\" min=\"0\" max=\"0\" value=\"0\" step=\"1000\" \/><\/p>\n<div class=\"gesta-note\">M\u00edn: <span id=\"t-min\">\u2013<\/span> | M\u00e1x: <span id=\"t-max\">\u2013<\/span><\/div>\n<\/p><\/div>\n<\/p><\/div>\n<div class=\"gesta-actions\">\n      <button class=\"gesta-btn primary\" id=\"btn-aplicar\">Aplicar filtros<\/button><br \/>\n      <button class=\"gesta-btn ghost\" id=\"btn-reset\">Restablecer<\/button><br \/>\n      <button class=\"gesta-btn ghost\" id=\"btn-csv\">Exportar CSV (filtrado)<\/button><br \/>\n      <button class=\"gesta-btn ghost\" id=\"btn-png\">Descargar gr\u00e1fico PNG<\/button>\n    <\/div>\n<div class=\"gesta-chart\">\n      <canvas id=\"chart-centros\" height=\"120\"><\/canvas>\n    <\/div>\n<p>    <?php if(empty($a['file_url'])): ?><\/p>\n<div class=\"gesta-card\" style=\"margin-top:12px\">\n<div class=\"gesta-label\">Cargar archivo XLSX (opcional si no define file_url en el shortcode)<\/div>\n<p>      <input type=\"file\" id=\"file-picker\" accept=\".xlsx,.xls\" \/>\n    <\/div>\n<p>    <?php endif; ?><\/p>\n<div class=\"gesta-footer\">Fuente: Libro de Remuneraciones (Hoja \u201c<?php echo esc_html($a['sheet']); ?>\u201d). Campos esperados: <em>Centro Costo<\/em>, <em>Sueldo Liquido A Pago<\/em>. Opcionales: <em>Rut del Trabajador<\/em>, <em>Nombre<\/em>, <em>\u00c1rea<\/em>.<\/div>\n<\/p><\/div>\n<p>  <script>\n  (function(){\n    const wrap   = document.currentScript.previousElementSibling;\n    const fUrl   = wrap.getAttribute('data-file-url');\n    const sheet  = wrap.getAttribute('data-sheet') || 'OCTUBRE';<\/p>\n<p>    \/\/ UI refs\n    const selCentro = wrap.querySelector('#f-centro');\n    const rMin      = wrap.querySelector('#f-min');\n    const rMax      = wrap.querySelector('#f-max');\n    const tMin      = wrap.querySelector('#t-min');\n    const tMax      = wrap.querySelector('#t-max');\n    const btnApply  = wrap.querySelector('#btn-aplicar');\n    const btnReset  = wrap.querySelector('#btn-reset');\n    const btnCSV    = wrap.querySelector('#btn-csv');\n    const btnPNG    = wrap.querySelector('#btn-png');<\/p>\n<p>    const kDot  = wrap.querySelector('#kpi-dotacion');\n    const kTot  = wrap.querySelector('#kpi-total');\n    const kProm = wrap.querySelector('#kpi-prom');\n    const kMed  = wrap.querySelector('#kpi-med');<\/p>\n<p>    const ctx   = wrap.querySelector('#chart-centros');\n    let chart;<\/p>\n<p>    \/\/ Helpers\n    const money = n => n === null || isNaN(n) ? '\u2013' : new Intl.NumberFormat('es-CL', { style:'currency', currency:'CLP', maximumFractionDigits:0 }).format(n);<\/p>\n<p>    function cleanseNumber(v){\n      if (v === null || v === undefined) return null;\n      if (typeof v === 'number') return v;\n      let s = String(v).replace(\/\\s+\/g,'').replace(\/\\.\/g,'').replace(\/\\$\/g,'').replace(\/\\$\/g,'').replace(\/\\$\/g,'');\n      s = s.replace(\/\\$|CLP\/gi,'').replace(\/,\/g,'.');\n      s = s.replace(\/[^0-9.-]\/g,'');\n      const num = parseFloat(s);\n      return isNaN(num) ? null : num;\n    }<\/p>\n<p>    function by(arr, key){\n      return arr.reduce((acc, row) => {\n        const k = row[key] ?? 'Sin dato';\n        acc[k] = acc[k] || [];\n        acc[k].push(row);\n        return acc;\n      }, {});\n    }<\/p>\n<p>    function median(values){\n      if(!values.length) return 0;\n      const s = [...values].sort((a,b)=>a-b);\n      const mid = Math.floor(s.length\/2);\n      return s.length % 2 ? s[mid] : (s[mid-1] + s[mid]) \/ 2;\n    }<\/p>\n<p>    \/\/ Estado\n    let raw = [];\n    let filtered = [];\n    let colCentro = null;\n    let colLiquido = null;<\/p>\n<p>    \/\/ Lectura XLSX\n    async function loadXLSXFromURL(url){\n      const res = await fetch(url);\n      if(!res.ok) throw new Error('No se pudo cargar el archivo. Revisa CORS o la URL.');\n      const ab = await res.arrayBuffer();\n      return XLSX.read(ab, { type: 'array' });\n    }\n    function loadXLSXFromFile(file){\n      return new Promise((resolve, reject)=>{\n        const reader = new FileReader();\n        reader.onload = e => {\n          try{ resolve(XLSX.read(e.target.result, { type: 'array' })); }\n          catch(err){ reject(err); }\n        };\n        reader.onerror = reject;\n        reader.readAsArrayBuffer(file);\n      });\n    }<\/p>\n<p>    function autodetectColumns(headers){\n      \/\/ Prioriza nombres exactos y variantes comunes\n      const hmap = headers.reduce((acc,h)=>{ acc[h] = h; return acc; }, {});\n      const find = (alts) => alts.find(a => headers.includes(a));<\/p>\n<p>      const centro = find(['Centro Costo','Centro de Costo','Centro Costo Nombre','Centro Negocio','Centro de Negocio','\u00c1rea','Area']) || headers.find(h => \/centro.*costo|centro.*negocio|\u00e1rea|area\/i.test(h)) || headers[0];\n      const liqui  = find(['Sueldo Liquido A Pago','Sueldo L\u00edquido A Pago','L\u00edquido a Pago','Liquido a Pago','Liquido Depurado']) || headers.find(h => \/li(c|q)uido.*pago|depur\/i.test(h));\n      return { centro, liqui };\n    }<\/p>\n<p>    function normalizeRows(rows){\n      return rows.map(r => {\n        const out = { ...r };\n        if (colLiquido && out[colLiquido] !== undefined) out[colLiquido] = cleanseNumber(out[colLiquido]);\n        return out;\n      });\n    }<\/p>\n<p>    function fillFilters(){\n      const centros = [...new Set(raw.map(r => r[colCentro] ?? 'Sin dato'))].sort((a,b)=>String(a).localeCompare(String(b),'es'));\n      selCentro.innerHTML = '';\n      centros.forEach(c => {\n        const opt = document.createElement('option');\n        opt.value = c; opt.textContent = c; selCentro.appendChild(opt);\n      });<\/p>\n<p>      const liquidos = raw.map(r => r[colLiquido]).filter(v => typeof v === 'number');\n      const min = Math.min(...liquidos), max = Math.max(...liquidos);\n      rMin.min = min; rMin.max = max; rMin.value = min; tMin.textContent = money(min);\n      rMax.min = min; rMax.max = max; rMax.value = max; tMax.textContent = money(max);\n    }<\/p>\n<p>    function applyFilters(){\n      const selected = Array.from(selCentro.selectedOptions).map(o=>o.value);\n      const minV = parseFloat(rMin.value), maxV = parseFloat(rMax.value);\n      filtered = raw.filter(r => {\n        const c = r[colCentro] ?? 'Sin dato';\n        const l = typeof r[colLiquido] === 'number' ? r[colLiquido] : null;\n        const okCentro = !selected.length || selected.includes(c);\n        const okLiqui  = (l !== null) && l >= minV && l <= maxV;\n        return okCentro &#038;&#038; okLiqui;\n      });\n      updateKPIsAndChart();\n    }\n\n    function updateKPIsAndChart(){\n      const liquidos = filtered.map(r => r[colLiquido]).filter(v => typeof v === 'number');\n      const dot = filtered.length;\n      const tot = liquidos.reduce((a,b)=>a+b,0);\n      const prom = liquidos.length ? tot\/liquidos.length : 0;\n      const med = median(liquidos);\n      kDot.textContent  = new Intl.NumberFormat('es-CL').format(dot);\n      kTot.textContent  = money(tot);\n      kProm.textContent = money(prom);\n      kMed.textContent  = money(med);<\/p>\n<p>      \/\/ Group by centro\n      const groups = by(filtered, colCentro);\n      const labels = Object.keys(groups).sort((a,b)=>String(a).localeCompare(String(b),'es'));\n      const data = labels.map(k => groups[k].reduce((a,r)=> a + (r[colLiquido]||0), 0));<\/p>\n<p>      const ds = {\n        label: 'Total L\u00edquido a Pago por Centro de Costo',\n        data,\n        borderColor: '#e53935', \/\/ rojo acento\n        backgroundColor: 'rgba(13,71,161,0.15)', \/\/ azul transl\u00facido\n        borderWidth: 2,\n      };<\/p>\n<p>      if (chart) chart.destroy();\n      chart = new Chart(ctx, {\n        type: 'bar',\n        data: { labels, datasets: [ds] },\n        options: {\n          responsive:true,\n          maintainAspectRatio:false,\n          plugins: {\n            legend: { display:true, labels: { color:'#0c1a2b', font:{ weight:'700' } } },\n            tooltip: { callbacks: { label: (c)=> `${money(c.parsed.y)}` } }\n          },\n          scales: {\n            x: { ticks: { color:'#394b63' }, grid:{ display:false } },\n            y: { ticks: { color:'#394b63', callback:(v)=> new Intl.NumberFormat('es-CL').format(v) }, grid:{ color:'rgba(13,71,161,.08)' } }\n          }\n        }\n      });\n    }<\/p>\n<p>    function exportCSV(){\n      if(!filtered.length){ alert('No hay datos filtrados para exportar.'); return; }\n      const headers = Object.keys(filtered[0]);\n      const rows = filtered.map(r => headers.map(h => r[h] ?? ''));\n      const csv = [headers.join(','), ...rows.map(rr => rr.map(v=>`\"${String(v).replace(\/\"\/g,'\\\"')}\"`).join(','))].join('\\n');\n      const blob = new Blob([csv], {type:'text\/csv;charset=utf-8;'});\n      const url = URL.createObjectURL(blob);\n      const a = document.createElement('a');\n      a.href = url; a.download = 'remuneraciones_filtrado.csv'; a.click();\n      URL.revokeObjectURL(url);\n    }<\/p>\n<p>    function downloadPNG(){\n      if (!chart) return;\n      const a = document.createElement('a');\n      a.href = chart.toBase64Image();\n      a.download = 'liquido_por_centro.png';\n      a.click();\n    }<\/p>\n<p>    \/\/ Eventos\n    btnApply.addEventListener('click', applyFilters);\n    btnReset.addEventListener('click', ()=>{ selCentro.selectedIndex = -1; rMin.value = rMin.min; rMax.value = rMax.max; tMin.textContent = money(parseFloat(rMin.value)); tMax.textContent = money(parseFloat(rMax.value)); applyFilters(); });\n    btnCSV.addEventListener('click', exportCSV);\n    btnPNG.addEventListener('click', downloadPNG);\n    rMin.addEventListener('input', ()=> tMin.textContent = money(parseFloat(rMin.value)) );\n    rMax.addEventListener('input', ()=> tMax.textContent = money(parseFloat(rMax.value)) );<\/p>\n<p>    const picker = wrap.querySelector('#file-picker');\n    if (picker) {\n      picker.addEventListener('change', async (e)=>{\n        const file = e.target.files[0];\n        if(!file) return;\n        const wb = await loadXLSXFromFile(file);\n        bootFromWorkbook(wb);\n      });\n    }<\/p>\n<p>    async function boot(){\n      try{\n        if (fUrl) {\n          const wb = await loadXLSXFromURL(fUrl);\n          bootFromWorkbook(wb);\n        }\n      }catch(err){\n        console.error(err);\n        alert('Error cargando el archivo: ' + err.message);\n      }\n    }<\/p>\n<p>    function bootFromWorkbook(wb){\n      const ws = wb.Sheets[sheet] || wb.Sheets[wb.SheetNames[0]];\n      const rows = XLSX.utils.sheet_to_json(ws, { defval: null });\n      if (!rows.length){ alert('La hoja seleccionada no tiene datos.'); return; }\n      const headers = Object.keys(rows[0]);\n      const auto = autodetectColumns(headers);\n      colCentro = auto.centro; colLiquido = auto.liqui;<\/p>\n<p>      raw = normalizeRows(rows).filter(r => r[colLiquido] !== null);\n      fillFilters();\n      filtered = [...raw];\n      updateKPIsAndChart();\n    }<\/p>\n<p>    \/\/ Arranque si hay URL definida en el shortcode\n    if (fUrl) boot();\n  })();\n  <\/script><br \/>\n  <?php\n  return ob_get_clean();\n});\n\n\n<\/p>","protected":false},"excerpt":{"rendered":"","protected":false},"author":49,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"footnotes":""},"class_list":["post-3677","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/gestamineria.cl\/en\/wp-json\/wp\/v2\/pages\/3677","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/gestamineria.cl\/en\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/gestamineria.cl\/en\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/gestamineria.cl\/en\/wp-json\/wp\/v2\/users\/49"}],"replies":[{"embeddable":true,"href":"https:\/\/gestamineria.cl\/en\/wp-json\/wp\/v2\/comments?post=3677"}],"version-history":[{"count":2,"href":"https:\/\/gestamineria.cl\/en\/wp-json\/wp\/v2\/pages\/3677\/revisions"}],"predecessor-version":[{"id":3679,"href":"https:\/\/gestamineria.cl\/en\/wp-json\/wp\/v2\/pages\/3677\/revisions\/3679"}],"wp:attachment":[{"href":"https:\/\/gestamineria.cl\/en\/wp-json\/wp\/v2\/media?parent=3677"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}