Mundial26

Pasted Sep 30, 2026, 3:03 AM1 file659 lines

Scan to open on your phone

Point a camera at the QR code - it opens this exact paste, no app needed.

https://codepastes.com/zzgt7czsasb295vk
sin-titulo
<!doctype html>
<html lang="es-MX">
 <head><script>window["__codeletBootstrap__"]=JSON.parse('{"A":"B","B":"20260929-23-b55d911","C":{"Abril Fatface":"YACgEZbkUVE,0","Alfa Slab One":"YACgEYS9sJU,0","Anton":"YACgEcYqQ-A,0","Archivo":"YACgEZulLoA,0","Arial Narrow":"YAGyDvJ_4Ts,0","Arial Rounded MT Bold":"YAFcfoaHu-s,0","Arial":"YAGyDvJ_4Ts,0","Avenir Next":"YAFcfmNq6UY,0","Baloo 2":"YADK30bC6HQ,0","Bebas Neue":"YACgESME5ew,0","Bricolage Grotesque":"YAFyMcdwzpc,0","Broken Glyph":"YADZ-exUkv0,0","Canva Sans":"YAFdJjTk5UU,1","Caveat":"YALBs2ploWQ,0","Comic Sans MS":"YAFcfvtOAUY,1","Cormorant Garamond":"YAFdJhX-538,0","Courier New":"YAGzXiGs0_8,0","Courier Prime":"YAD1aFjegN8,0","DM Sans":"YAD1aU3sLnI,0","DM Serif Display":"YAD1aYG82rc,0","Forum":"YACgEcnnqB4,0","Fraunces":"YAFdJpU7Kkk,0","Georgia":"YAGzXkO0pEM,0","Ghost Mono":"YAHQutsX00Q,0","Hanken Grotesk":"YAFcfhwp99E,0","Helvetica Neue":"YAFcf6CtJfI,0","Helvetica":"YAFcf6CtJfI,0","IBM Plex Mono":"YAFdJkmvhGI,0","Impact":"YAFcfnjI7Vk,0","Instrument Serif":"YAHFeOnZNEk,0","Inter":"YAFdJvSyp_k,3","Iowan Old Style":"YAGNIFa8j9o,0","Jacques Francois":"YADK3zbC6RM,0","JetBrains Mono":"YAFdJksXcAk,0","Josefin Sans":"YAFdJjvw9Ps,0","Lato":"YAFdJuFCnaw,0","Libre Baskerville":"YACgEUFdPdA,0","Lobster":"YAD8SnSjSFI,0","Lora":"YACgEXvxf8Q,0","Merriweather":"YACgEXvHxxs,0","Montserrat":"YAFdtQi73Xs,0","Nunito":"YAFdJoNRMmU,1","Oleo Script":"YACgEQQ14jI,0","Oswald":"YACgEQY10lw,0","Pacifico":"YADLjORQ1u4,0","Phantom Sans":"YAFdJvgJ5TU,0","Playfair Display":"YAFdJhem5V8,1","Plus Jakarta Sans":"YAGvf3_u0_Q,0","Poppins":"YAFdJjbTu24,1","Press Start 2P":"YAFyGr-8pmQ,0","Quicksand":"YAFdJpYtCxE,1","Raleway":"YAFdJhmxbVQ,1","Roboto Slab":"YAFdJswIKAg,0","Rockwell":"YAFcfyVZnN0,0","Rye":"YACgEdQP21k,0","Segoe UI":"YAHNdRD1Klw,0","Source Sans 3":"YAG4lO1Mj10,0","Source Serif 4":"YACgEfdj394,0","Space Grotesk":"YADZ-dYEmhg,0","Space Mono":"YADW1-vpsoc,0","Times New Roman":"YAGzXW3gftg,0","Times":"YAGzXW3gftg,0","Trebuchet MS":"YAFcfrglpVA,0","UV Display":"YAEdVfSSpYo,0","UV Sans":"YAFdJvgJ5TU,0","Ubuntu":"YACgERDU--Q,0","Verdana":"YAGzXTctYtQ,0","Work Sans":"YAGXhLOKv44,0","Yellowtail":"YACgEYG4kG4,0","ui-monospace":"YAD1aFjegN8,0","ui-sans-serif":"YACkoD1yZN0,0"}}');</script><script src="/_sdk/6542d56fbfe7a3eb.telemetry_sdk.js" integrity="sha512-ECdM0nK7EkwDuOqho2qxgyRQXyBHk9hZqRKMBr+Xsu6zjeHlt+qkO2N2YRiHN7Kg4e1fEHDhjIp4bGJYVypv1g=="></script>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Control de Gastos</title>
  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&amp;family=Fraunces:wght@600;700&amp;display=swap" rel="stylesheet">
  <style>
    :root{--ink:#17352e;--muted:#61736b;--line:#dce2d6;--green:#195c46}
    *{box-sizing:border-box}
    body{width:100%;font-family:"DM Sans",sans-serif;color:var(--ink)}
    h1,h2{font-family:"Fraunces",serif}
    .app-shell{width:100%;min-height:100%;background:radial-gradient(circle at 95% 0%,#e1ecd9,transparent 34%),#f4f5ed}
    .field{width:100%;border:1px solid #d5ded2;border-radius:12px;padding:11px 13px;background:#fff;color:var(--ink)}
    button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #d89740;outline-offset:2px}
    button:disabled{opacity:.6;cursor:wait}
    .table-scroll{overflow-x:auto}
    table{min-width:850px;width:100%;border-collapse:collapse}
    th{text-align:left;font-size:11px;letter-spacing:.11em;text-transform:uppercase;color:#748178;padding:16px;background:#f7f8f2}
    td{padding:15px 16px;border-top:1px solid #edf0e8;vertical-align:middle}
    .drawer{width:min(100%,480px);height:100%;overflow:auto}
    .shade{background:rgba(18,40,31,.45)}
    .status-select{max-width:170px;border-radius:999px;padding:7px 10px;font-size:12px;font-weight:700}
    .status-select[data-status="Ya liquidado"]{background:#e1f1e7;color:#246d46}
    .status-select[data-status="Falta por pagar"]{background:#fff0d5;color:#92610c}
    .status-select[data-status="No aceptado"]{background:#fce5e0;color:#aa4234}
    .status-select[data-status="En proceso"]{background:#e6ecf6;color:#47628e}
    .view-tab{white-space:nowrap;border:1px solid #dce2d6;background:#fff;color:#195c46;border-radius:12px 12px 0 0;padding:12px 17px;font-weight:700}
    .view-tab[aria-selected="true"]{background:#195c46;color:white;border-color:#195c46}
    .reveal{animation:rise .5s both}
    @keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
    @media(prefers-reduced-motion:reduce){.reveal{animation:none}}
  </style>
  <script src="https://cdn.tailwindcss.com/3.4.17" type="text/javascript"></script>
  <script src="https://cdn.jsdelivr.net/npm/lucide@0.577.0/dist/umd/lucide.min.js" type="text/javascript"></script>
  <script src="/_sdk/6c2ecc939521f244.data_sdk.js" type="text/javascript" integrity="sha512-gRx8s+XsZDN6mSIQu2sivLZysbV0WHhb5kXNUWlgVb5lCMU1foJYukyMEDPv8Tnoocb8iwqB4XdRrWc7oEGzIQ=="></script>
  <script src="/_sdk/6e4b845d05dd79e4.editing_sdk.js" type="text/javascript" integrity="sha512-nfDAoBO2Sn6o6rLYgdnzgUs0nZfi4v1NV7M2ioocHZ6oj+ZnIEUmJGLGqFu2dnCue3Bjs5kWMae4qsFt05LfIA=="></script>
  <script src="/_sdk/ddcab3e45959c817.resizing_sdk.js" type="text/javascript" integrity="sha512-VAviUKfZVzugWBSeULYjSljTX+nb84wc6Zzbx5GQaQgh6X2ISSjXi8eIiCDGdwDh10esJMY0+QFQgUy9NcrTlQ=="></script>
 </head>
 <body data-template-id="__page-root" class="w-full">
  <div class="app-shell">
   <header class="border-b border-[#dce2d6]">
    <div class="max-w-7xl mx-auto px-5 sm:px-8 py-5 flex flex-wrap items-center justify-between gap-4">
     <div class="flex items-center gap-3">
      <div aria-hidden="true" class="w-10 h-10 rounded-xl bg-[#195c46] text-white grid place-items-center font-bold">
       ↗
      </div>
      <div>
       <p data-template-id="brand-kicker" class="canva-text uppercase tracking-[.2em] font-bold"></p>
       <h1 data-template-id="page-title" class="canva-text font-bold leading-tight"></h1>
      </div>
     </div><button id="open-drawer" type="button" data-template-id="new-expense-button" class="canva-button rounded-xl px-5 py-3 font-bold shadow-sm hover:brightness-110"></button>
    </div>
   </header>
   <main class="max-w-7xl mx-auto px-5 sm:px-8 py-9">
    <div class="reveal">
     <p data-template-id="eyebrow" class="canva-text uppercase tracking-[.2em] font-bold mb-2"></p>
     <h2 data-template-id="overview-title" class="canva-text font-semibold mb-2"></h2>
     <p data-template-id="overview-subtitle" class="canva-text mb-7"></p>
    </div>
    <section aria-label="Indicadores de gastos" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4 mb-9">
     <article data-template-id="kpi-total-card" class="canva-card rounded-2xl p-5 border border-[#dce2d6] reveal">
      <p data-template-id="kpi-total-label" class="canva-text uppercase tracking-wider font-bold mb-4"></p>
      <p id="kpi-total" class="text-[27px] font-bold tracking-tight" aria-live="polite">$0.00</p>
      <div class="w-9 h-1 bg-[#195c46] rounded mt-5"></div>
     </article>
     <article data-template-id="kpi-progress-card" class="canva-card rounded-2xl p-5 border border-[#dce2d6] reveal">
      <p data-template-id="kpi-progress-label" class="canva-text uppercase tracking-wider font-bold mb-4"></p>
      <p id="kpi-progress" class="text-[27px] font-bold tracking-tight" aria-live="polite">$0.00</p>
      <div class="w-9 h-1 bg-[#6b86b5] rounded mt-5"></div>
     </article>
     <article data-template-id="kpi-paid-card" class="canva-card rounded-2xl p-5 border border-[#dce2d6] reveal">
      <p data-template-id="kpi-paid-label" class="canva-text uppercase tracking-wider font-bold mb-4"></p>
      <p id="kpi-paid" class="text-[27px] font-bold tracking-tight" aria-live="polite">$0.00</p>
      <div class="w-9 h-1 bg-[#4d9b72] rounded mt-5"></div>
     </article>
     <article data-template-id="kpi-due-card" class="canva-card rounded-2xl p-5 border border-[#dce2d6] reveal">
      <p data-template-id="kpi-due-label" class="canva-text uppercase tracking-wider font-bold mb-4"></p>
      <p id="kpi-due" class="text-[27px] font-bold tracking-tight" aria-live="polite">$0.00</p>
      <div class="w-9 h-1 bg-[#d89740] rounded mt-5"></div>
     </article>
     <article data-template-id="kpi-rejected-card" class="canva-card rounded-2xl p-5 border border-[#dce2d6] reveal">
      <p data-template-id="kpi-rejected-label" class="canva-text uppercase tracking-wider font-bold mb-4"></p>
      <p id="kpi-rejected" class="text-[27px] font-bold tracking-tight" aria-live="polite">$0.00</p>
      <div class="w-9 h-1 bg-[#cb6c54] rounded mt-5"></div>
     </article>
    </section>
    <nav aria-label="Vistas de gastos" class="flex items-end gap-2 overflow-x-auto">
     <div id="view-tabs" role="tablist" aria-label="Vistas de gastos" class="flex items-end gap-2"></div><button id="new-tab" type="button" data-template-id="new-tab-button" class="canva-button view-tab shrink-0"></button>
    </nav>
    <section data-template-id="records-panel" class="canva-panel rounded-b-[22px] rounded-tr-[22px] border border-[#dce2d6] shadow-sm overflow-hidden">
     <div class="p-5 sm:px-7 flex flex-wrap justify-between gap-4 items-end">
      <div>
       <h3 data-template-id="table-title" class="canva-text font-bold"></h3>
       <p id="row-count" class="text-sm text-[#61736b]" aria-live="polite"></p>
      </div>
      <div class="flex flex-wrap gap-2 items-end">
       <div>
        <label for="filter-status" data-template-id="filter-status-label" class="canva-text block mb-1 font-semibold"></label> <select id="filter-status" class="field min-w-[160px]"> <option value="all" data-template-id="filter-status-all"></option> <option value="En proceso" data-template-id="status-filter-progress"></option> <option value="Ya liquidado" data-template-id="status-filter-paid"></option> <option value="Falta por pagar" data-template-id="status-filter-due"></option> <option value="No aceptado" data-template-id="status-filter-rejected"></option> </select>
       </div><button id="export" type="button" data-template-id="export-button" class="canva-button px-4 py-3 rounded-xl font-bold border border-[#bed0c3] hover:bg-[#eaf1e8]"></button> <button id="refresh-data" type="button" data-template-id="refresh-data-button" class="canva-button px-4 py-3 rounded-xl font-bold border border-[#bed0c3] hover:bg-[#eaf1e8]"></button>
      </div>
     </div>
     <div class="table-scroll">
      <table>
       <thead>
        <tr>
         <th data-template-id="column-person" class="canva-text"></th>
         <th data-template-id="column-month" class="canva-text"></th>
         <th data-template-id="column-description" class="canva-text"></th>
         <th data-template-id="column-amount" class="canva-text"></th>
         <th data-template-id="column-status" class="canva-text"></th>
         <th data-template-id="column-evidence" class="canva-text"></th>
         <th data-template-id="column-actions" class="canva-text"></th>
        </tr>
       </thead>
       <tbody id="expense-body"></tbody>
      </table>
     </div>
     <p id="empty" data-template-id="empty-message" class="canva-text text-center py-12 border-t border-[#edf0e8]"></p>
    </section>
    <div id="sync-line" class="hidden mt-4 text-sm text-[#61736b]" role="status" aria-live="polite">
     <span id="sync-text"></span> <button id="retry" type="button" data-template-id="retry-button" class="canva-button underline font-bold ml-2"></button>
    </div>
   </main>
   <div id="overlay" class="hidden fixed inset-0 z-20 shade">
    <aside class="drawer absolute right-0 top-0 bg-[#f8f9f3] shadow-2xl p-6 sm:p-8" aria-label="Nuevo gasto" aria-modal="true" role="dialog">
     <div class="flex items-start justify-between gap-4 mb-7">
      <div>
       <p data-template-id="drawer-kicker" class="canva-text uppercase tracking-[.18em] font-bold mb-2"></p>
       <h2 data-template-id="drawer-title" class="canva-text font-semibold"></h2>
      </div><button id="close-drawer" type="button" aria-label="Cerrar panel" class="rounded-full p-2 hover:bg-[#e5ebe1]">✕</button>
     </div>
     <form id="expense-form" class="space-y-4">
      <div><label for="person" data-template-id="person-label" class="canva-text block font-semibold mb-2"></label><input id="person" list="person-options" class="field" required maxlength="100" autocomplete="off"><datalist id="person-options"></datalist>
      </div>
      <div id="expense-items" class="space-y-4"></div><button id="add-expense-item" type="button" data-template-id="add-expense-item-button" class="canva-button w-full rounded-xl py-3 font-bold border border-[#bed0c3]"></button> <button id="save-button" type="submit" data-template-id="save-button" class="canva-button w-full rounded-xl py-3 font-bold hover:brightness-110"></button>
     </form>
    </aside>
   </div>
   <div id="tab-delete-overlay" class="hidden fixed inset-0 z-40 shade grid place-items-center p-4">
    <section role="dialog" aria-modal="true" aria-labelledby="tab-delete-title" class="w-full max-w-md bg-[#f8f9f3] rounded-2xl p-6 shadow-2xl">
     <h2 id="tab-delete-title" data-template-id="tab-delete-heading" class="canva-text font-semibold mb-3"></h2>
     <p id="tab-delete-message" data-template-id="tab-delete-message" class="canva-text mb-6"></p>
     <div class="flex justify-end gap-3">
      <button id="cancel-tab-delete" type="button" data-template-id="cancel-tab-delete-button" class="canva-button px-4 py-2 rounded-xl border border-[#bed0c3]"></button> <button id="confirm-tab-delete" type="button" data-template-id="confirm-tab-delete-button" class="canva-button px-4 py-2 rounded-xl font-bold"></button>
     </div>
    </section>
   </div>
   <div id="tab-overlay" class="hidden fixed inset-0 z-30 shade grid place-items-center p-4">
    <section role="dialog" aria-modal="true" aria-labelledby="tab-dialog-title" class="w-full max-w-md bg-[#f8f9f3] rounded-2xl p-6 shadow-2xl">
     <h2 id="tab-dialog-title" data-template-id="tab-dialog-heading" class="canva-text font-semibold mb-5"></h2>
     <form id="tab-form" class="space-y-4">
      <div><label for="tab-name" data-template-id="tab-name-label" class="canva-text block font-semibold mb-2"></label><input id="tab-name" class="field" maxlength="40" required>
      </div>
      <fieldset>
       <legend data-template-id="tab-people-legend" class="canva-text font-semibold mb-2"></legend>
       <div id="tab-people" class="max-h-44 overflow-auto space-y-2"></div>
       <p data-template-id="tab-people-help" class="canva-text mt-2"></p>
      </fieldset>
      <div class="flex justify-end gap-3">
       <button id="cancel-tab" type="button" data-template-id="cancel-tab-button" class="canva-button px-4 py-2 rounded-xl border border-[#bed0c3]"></button> <button type="submit" data-template-id="save-tab-button" class="canva-button px-4 py-2 rounded-xl font-bold"></button>
      </div>
     </form>
    </section>
   </div>
  </div>
  <template id="dynamic-tab-template">
   <div class="flex items-center">
    <button type="button" role="tab" aria-controls="records-panel-content" aria-selected="false" data-template-id="dynamic-tab" class="canva-tabs view-tab"><span data-template-id="dynamic-tab-label" class="canva-text"></span></button><button type="button" data-template-id="dynamic-tab-close" class="canva-button hidden rounded-full px-2 py-1 -ml-3 mr-1 text-xs" aria-label="Eliminar pestaña">✕</button>
   </div>
  </template>
  <template id="expense-row-template">
   <tr data-template-id="expense-row" class="canva-table">
    <td data-template-id="row-person" class="canva-text font-bold"></td>
    <td data-template-id="row-date" class="canva-text"></td>
    <td data-template-id="row-description" class="canva-text"></td>
    <td data-template-id="row-amount" class="canva-text font-bold whitespace-nowrap"></td>
    <td><label class="sr-only" for="row-status-placeholder">Estado del gasto</label><select data-template-id="row-status" class="canva-tag status-select"> <option value="En proceso">En proceso</option><option value="Ya liquidado">Ya liquidado</option> <option value="Falta por pagar">Falta por pagar</option><option value="No aceptado">No aceptado</option> </select></td>
    <td><a data-action="evidence" data-template-id="row-evidence-link" class="canva-link underline hidden" target="_blank" rel="noopener noreferrer"></a><span data-template-id="row-evidence-unavailable" class="canva-text"></span></td>
    <td class="whitespace-nowrap"><button type="button" data-action="delete" data-template-id="row-delete" class="canva-button rounded-lg px-3 py-2 font-bold"></button> <button type="button" data-action="confirm" data-template-id="row-confirm" class="canva-button hidden rounded-lg px-3 py-2 font-bold"></button> <button type="button" data-action="cancel" data-template-id="row-cancel" class="canva-button hidden rounded-lg px-3 py-2"></button></td>
   </tr>
  </template>
  <template id="expense-item-template">
   <fieldset data-template-id="expense-item" class="canva-card space-y-3 rounded-xl border border-[#dce2d6] p-4">
    <legend class="font-bold"></legend>
    <div><label data-template-id="date-label" class="canva-text block font-semibold mb-2"></label><input name="date" type="date" class="field" required>
    </div>
    <div><label data-template-id="description-label" class="canva-text block font-semibold mb-2"></label><input name="description" class="field" maxlength="240" required>
    </div>
    <div><label data-template-id="amount-label" class="canva-text block font-semibold mb-2"></label><input name="amount" type="number" min="0.01" step="0.01" class="field" required>
    </div>
    <div><label data-template-id="status-label" class="canva-text block font-semibold mb-2"></label><select name="status" class="field"> <option value="En proceso" data-template-id="form-status-progress"></option> <option value="Ya liquidado" data-template-id="form-status-paid"></option> <option value="Falta por pagar" data-template-id="form-status-due"></option> <option value="No aceptado" data-template-id="form-status-rejected"></option> </select>
    </div>
    <div><label data-template-id="evidence-label" class="canva-text block font-semibold mb-2"></label><input name="evidence" type="file" accept="image/*,application/pdf" class="field">
     <p data-template-id="evidence-help" class="canva-text mt-1"></p>
    </div><button type="button" data-action="remove-item" data-template-id="remove-item-button" class="canva-button underline font-bold"></button>
   </fieldset>
  </template>
  <script>
(() => {
  const $ = id => document.getElementById(id);
  const KEY = 'control-gastos-unificado-v1';
  const VIEWS_KEY = 'control-gastos-vistas-v1';
  const STATUSES = ['En proceso','Ya liquidado','Falta por pagar','No aceptado'];
  const money = new Intl.NumberFormat('es-MX',{style:'currency',currency:'MXN'});
  const dateFormat = new Intl.DateTimeFormat('es-MX',{day:'numeric',month:'long',year:'numeric',timeZone:'UTC'});
  let expenses = [], backendRecords = [], customViews = [], activeView = 'all';
  let ready = false, firstRead = false, loadTimer, itemCounter = 0;
  let writeChain = Promise.resolve(), lastWrite = 0;
  const pending = new Map(), deletedIds = new Set(), rowNodes = new Map(), fileUrls = new Map();
  const inFlightCreates = new Set();

  const uid = () => crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
  function readJSON(key,fallback) { try { const value=JSON.parse(localStorage.getItem(key)||'null'); return value ?? fallback; } catch (_) { return fallback; } }
  function saveLocal() {
    try {
      localStorage.setItem(KEY,JSON.stringify(expenses.map(({fileUrl,...item})=>item)));
      localStorage.setItem(VIEWS_KEY,JSON.stringify(customViews));
    } catch (_) { syncMessage('No hay espacio para conservar los cambios en este navegador.'); }
  }
  function syncMessage(text) { $('sync-text').textContent=text; $('sync-line').classList.remove('hidden'); }
  function sheetFields(item) {
    return {colaborador:item.colaborador,mes:item.fecha,descripcion:item.descripcion,cantidad:item.cantidad,estado:item.estado,evidencia_ref:item.evidencia_ref||''};
  }
  function signature(r) {
    return [r.colaborador,r.mes,r.descripcion,r.cantidad,r.estado,r.evidencia_ref].map(v=>String(v??'')).join('\u001f');
  }
  function people() {
    return [...new Set(expenses.map(x=>x.colaborador).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'es'));
  }
  function viewItems() {
    if(activeView==='all') return expenses;
    if(activeView==='pair') return expenses.filter(x=>['erika','lalo'].includes(x.colaborador.trim().toLocaleLowerCase('es')));
    if(activeView.startsWith('person:')) return expenses.filter(x=>x.colaborador===activeView.slice(7));
    const view=customViews.find(x=>x.id===activeView);
    return view ? expenses.filter(x=>view.people.includes(x.colaborador)) : expenses;
  }
  function visibleRows() {
    return viewItems().filter(x=>$('filter-status').value==='all'||x.estado===$('filter-status').value);
  }
  function renderTabs() {
    const options=$('person-options');
    const tabPeople=customViews.flatMap(view=>view.people||[]);
    const collaboratorOptions=[...new Set(['Erika y Lalo',...people(),...tabPeople])].sort((a,b)=>a==='Erika y Lalo'?-1:b==='Erika y Lalo'?1:a.localeCompare(b,'es'));
    options.replaceChildren(...collaboratorOptions.map(name=>{const option=document.createElement('option');option.value=name;return option;}));
    const definitions=[
      {id:'all',label:'Gastos totales'}, {id:'pair',label:'Erika y Lalo'},
      ...people().map(name=>({id:'person:'+name,label:name})),
      ...customViews.map(view=>({id:view.id,label:view.name}))
    ];
    if(!definitions.some(x=>x.id===activeView)) activeView='all';
    const host=$('view-tabs');
    const wanted=new Set(definitions.map(x=>x.id));
    for(const tab of [...host.children]) if(!wanted.has(tab.dataset.view)) tab.remove();
    for(const definition of definitions) {
      let wrap=[...host.children].find(x=>x.dataset.view===definition.id);
      if(!wrap) {
        wrap=$('dynamic-tab-template').content.firstElementChild.cloneNode(true);
        wrap.dataset.view=definition.id;
        wrap.querySelector('[data-template-id="dynamic-tab"]').dataset.view=definition.id;
      }
      const tab=wrap.querySelector('[data-template-id="dynamic-tab"]');
      const close=wrap.querySelector('[data-template-id="dynamic-tab-close"]');
      tab.querySelector('[data-template-id="dynamic-tab-label"]').textContent=definition.label;
      tab.setAttribute('aria-selected',String(activeView===definition.id));
      close.classList.toggle('hidden',definition.id==='all');
      host.append(wrap);
    }
  }
  function renderKpis() {
    const sums={total:0,progress:0,paid:0,due:0,rejected:0};
    const keys={'En proceso':'progress','Ya liquidado':'paid','Falta por pagar':'due','No aceptado':'rejected'};
    for(const item of viewItems()) {
      const amount=Number(item.cantidad)||0;
      sums.total+=amount;
      if(keys[item.estado]) sums[keys[item.estado]]+=amount;
    }
    for(const [key,value] of Object.entries(sums)) $('kpi-'+key).textContent=money.format(value);
  }
  function putText(row,id,value) {
    const el=row.querySelector(`[data-template-id="${id}"]`);
    if(el.textContent!==value) el.textContent=value;
  }
  function renderRow(row,item) {
    row.dataset.id=item.id;
    putText(row,'row-person',item.colaborador);
    putText(row,'row-date',/^\d{4}-\d{2}-\d{2}$/.test(item.fecha)
      ?dateFormat.format(new Date(item.fecha+'T12:00:00Z')):item.fecha);
    putText(row,'row-description',item.descripcion);
    putText(row,'row-amount',money.format(item.cantidad));
    const select=row.querySelector('[data-template-id="row-status"]');
    if(document.activeElement!==select) select.value=item.estado;
    select.dataset.status=item.estado;
    const link=row.querySelector('[data-template-id="row-evidence-link"]');
    const unavailable=row.querySelector('[data-template-id="row-evidence-unavailable"]');
    const url=fileUrls.get(item.id);
    link.classList.toggle('hidden',!url);
    unavailable.classList.toggle('hidden',!!url);
    if(url) { link.href=url;link.textContent=item.evidencia_ref; }
    else unavailable.textContent=item.evidencia_ref ? item.evidencia_ref+' · archivo no disponible en esta sesión' : '—';
  }
  function render() {
    renderTabs();
    renderKpis();
    const items=visibleRows(), wanted=new Set(items.map(x=>x.id)), body=$('expense-body');
    for(const item of items) {
      let row=rowNodes.get(item.id);
      if(!row) {
        row=$('expense-row-template').content.firstElementChild.cloneNode(true);
        rowNodes.set(item.id,row);
        const select=row.querySelector('[data-template-id="row-status"]');
        select.id='row-status-'+item.id;
        row.querySelector('label').htmlFor=select.id;
      }
      renderRow(row,item);
      body.append(row);
    }
    for(const [id,row] of rowNodes) if(!wanted.has(id)) {row.remove();rowNodes.delete(id);}
    $('empty').classList.toggle('hidden',items.length>0);
    $('row-count').textContent=`${items.length} ${items.length===1?'registro visible':'registros visibles'}`;
  }
  function queueWrite(work) {
    const job=writeChain.catch(()=>{}).then(async()=>{
      const wait=Math.max(0,550-(Date.now()-lastWrite));
      if(wait) await new Promise(resolve=>setTimeout(resolve,wait));
      lastWrite=Date.now();
      return work();
    });
    writeChain=job.catch(()=>{});
    return job;
  }
  function reconcile(data) {
    backendRecords=data;
    const byBackend=new Map(expenses.filter(x=>x.backendId).map(x=>[String(x.backendId),x]));
    const unlinked=expenses.filter(x=>!x.backendId && !x.createFailed && !inFlightCreates.has(x.id));
    const consumed=new Set();
    for(const record of data) {
      const backendId=String(record.__backendId);
      if(deletedIds.has(backendId)) continue;
      const existing=byBackend.get(backendId);
      if(existing) {
        if(!pending.has(existing.id)) Object.assign(existing,{
          colaborador:record.colaborador,fecha:record.mes,descripcion:record.descripcion,
          cantidad:Number(record.cantidad)||0,estado:record.estado,evidencia_ref:record.evidencia_ref||''
        });
      } else {
        const match=unlinked.find(x=>!consumed.has(x.id)&&signature(sheetFields(x))===signature(record));
        if(match) {match.backendId=backendId;consumed.add(match.id);}
        else expenses.push({
          id:'sheet-'+backendId,backendId,colaborador:record.colaborador,fecha:record.mes,
          descripcion:record.descripcion,cantidad:Number(record.cantidad)||0,
          estado:record.estado,evidencia_ref:record.evidencia_ref||''
        });
      }
    }
    saveLocal();render();
  }
  const handler={onDataChanged(data) {
    clearTimeout(loadTimer);
    firstRead=true;
    reconcile(data);
  }};
  async function initialize() {
    clearTimeout(loadTimer);
    firstRead=false;
    loadTimer=setTimeout(()=>{
      if(!firstRead) syncMessage('No se pudo confirmar la lectura de la hoja de Canva. Puedes volver a consultar; los cambios locales siguen visibles.');
    },8000);
    try {
      const result=await window.dataSdk.init(handler);
      ready=result.isOk;
      if(!ready) {
        clearTimeout(loadTimer);
        syncMessage('No se pudo conectar con la hoja de Canva. Puedes volver a consultar.');
      }
    } catch (_) {
      ready=false;
      clearTimeout(loadTimer);
      syncMessage('No se pudo conectar con la hoja de Canva. Puedes volver a consultar.');
    }
  }
  function drawer(open) {
    $('overlay').classList.toggle('hidden',!open);
    (open?$('person'):$('open-drawer')).focus();
  }
  function addItem() {
    const block=$('expense-item-template').content.firstElementChild.cloneNode(true);
    const n=++itemCounter;
    for(const input of block.querySelectorAll('input,select')) {
      input.id='item-'+n+'-'+input.name;
      const label=input.previousElementSibling;
      if(label?.tagName==='LABEL') label.htmlFor=input.id;
    }
    block.querySelector('[name="date"]').value=new Date().toLocaleDateString('en-CA');
    $('expense-items').append(block);
    numberItems();
  }
  function numberItems() {
    [...$('expense-items').children].forEach((el,i)=>{
      el.querySelector('legend').textContent=`Gasto ${i+1}`;
      el.querySelector('[data-action="remove-item"]').classList.toggle('hidden',$('expense-items').children.length===1);
    });
  }
  function setConfirm(row,active) {
    row.querySelector('[data-action="delete"]').classList.toggle('hidden',active);
    row.querySelector('[data-action="confirm"]').classList.toggle('hidden',!active);
    row.querySelector('[data-action="cancel"]').classList.toggle('hidden',!active);
  }
  function changeStatus(select) {
    const item=expenses.find(x=>x.id===select.closest('tr').dataset.id);
    if(!item || !STATUSES.includes(select.value)) return;
    item.estado=select.value;
    pending.set(item.id,{estado:item.estado});
    saveLocal();render();
    if(!ready || !item.backendId) {
      syncMessage('Cambio visible en este navegador; no se ha confirmado en la hoja de Canva.');
      return;
    }
    queueWrite(async()=>{
      const edit=pending.get(item.id), current=expenses.find(x=>x.id===item.id);
      if(!edit || !current) return;
      const sent={...edit};
      const record=backendRecords.find(r=>String(r.__backendId)===String(current.backendId));
      if(!record) {
        pending.delete(item.id);
        syncMessage('No se pudo confirmar un cambio de estado en la hoja de Canva.');
        return;
      }
      try {
        const result=await window.dataSdk.update({...record,estado:sent.estado});
        if(!result.isOk) throw new Error();
        if(pending.get(item.id)?.estado===sent.estado) pending.delete(item.id);
      } catch (_) {
        pending.delete(item.id);
        syncMessage('No se pudo confirmar un cambio de estado en la hoja de Canva. Consulta los datos antes de intentarlo de nuevo.');
      }
    });
  }
  async function deleteExpense(row,button) {
    const item=expenses.find(x=>x.id===row.dataset.id);
    if(!item) return;
    button.disabled=true;
    const record=backendRecords.find(r=>String(r.__backendId)===String(item.backendId));
    if(item.backendId) deletedIds.add(String(item.backendId));
    expenses=expenses.filter(x=>x.id!==item.id);
    pending.delete(item.id);
    const url=fileUrls.get(item.id);
    if(url) {URL.revokeObjectURL(url);fileUrls.delete(item.id);}
    saveLocal();render();
    try {
      if(!ready || !record) {
        if(item.backendId) syncMessage('El gasto se quitó de esta vista local, pero no se pudo confirmar su eliminación en la hoja de Canva.');
        return;
      }
      const result=await queueWrite(()=>window.dataSdk.delete(record));
      if(!result.isOk) syncMessage('El gasto se quitó de esta vista local, pero no se pudo confirmar su eliminación en la hoja de Canva. Consulta los datos antes de intentarlo de nuevo.');
    } catch (_) {
      syncMessage('El gasto se quitó de esta vista local, pero no se pudo confirmar su eliminación en la hoja de Canva. Consulta los datos antes de intentarlo de nuevo.');
    } finally {button.disabled=false;}
  }
  async function saveExpenses(event) {
    event.preventDefault();
    const button=event.submitter||$('save-button');
    const colaborador=$('person').value.trim();
    const blocks=[...$('expense-items').children];
    if(!colaborador||!blocks.length) return;
    const items=blocks.map(block=>{
      const file=block.querySelector('[name="evidence"]').files[0];
      return {
        id:uid(),colaborador,fecha:block.querySelector('[name="date"]').value,
        descripcion:block.querySelector('[name="description"]').value.trim(),
        cantidad:Number(block.querySelector('[name="amount"]').value),
        estado:block.querySelector('[name="status"]').value,
        evidencia_ref:file?.name||'',file
      };
    });
    if(items.some(x=>!x.fecha||!x.descripcion||!Number.isFinite(x.cantidad)||x.cantidad<=0||!STATUSES.includes(x.estado)||
      (x.file&&!['application/pdf'].includes(x.file.type)&&!x.file.type.startsWith('image/')))) {
      syncMessage('Revisa las partidas y selecciona únicamente archivos PDF o imágenes.');
      return;
    }
    if(backendRecords.length+items.length>999) {
      syncMessage('La hoja de Canva admite como máximo 999 registros.');
      return;
    }
    button.disabled=true;
    for(const item of items) {
      if(item.file) fileUrls.set(item.id,URL.createObjectURL(item.file));
      delete item.file;
      expenses.push(item);
      inFlightCreates.add(item.id);
    }
    saveLocal();render();
    $('expense-form').reset();
    $('expense-items').replaceChildren();
    addItem();drawer(false);
    try {
      if(!ready) {
        for(const item of items) {inFlightCreates.delete(item.id);item.createFailed=true;}
        syncMessage('Los gastos están visibles en este navegador, pero no se pudo confirmar su registro en la hoja de Canva.');
        return;
      }
      for(const item of items) {
        try {
          const result=await queueWrite(()=>window.dataSdk.create(sheetFields(item)));
          if(!result.isOk) throw new Error();
        } catch (_) {
          item.createFailed=true;
          syncMessage('No se pudo confirmar el guardado de todas las partidas en la hoja de Canva. Consulta los datos antes de intentarlo de nuevo.');
        } finally {
          inFlightCreates.delete(item.id);
        }
      }
    } finally {button.disabled=false;}
  }
  let pendingTabDelete=null;
  function openTabDeleteDialog(viewId) {
    pendingTabDelete=viewId;
    const definition=viewId.startsWith('person:') ? {label:viewId.slice(7)} : customViews.find(view=>view.id===viewId);
    if(!definition) return;
    $('tab-delete-message').textContent=`¿Estás seguro de que deseas eliminar la pestaña '${definition.label||definition.name}' y todos los gastos asociados a esta vista?`;
    $('tab-delete-overlay').classList.remove('hidden');
    $('confirm-tab-delete').focus();
  }
  function closeTabDeleteDialog() {
    pendingTabDelete=null;
    $('tab-delete-overlay').classList.add('hidden');
  }
  async function confirmTabDelete() {
    const viewId=pendingTabDelete;
    if(!viewId) return;
    const button=$('confirm-tab-delete');
    button.disabled=true;
    try {
      if(viewId.startsWith('person:')) await deletePersonTab(viewId.slice(7));
      else {
        customViews=customViews.filter(view=>view.id!==viewId);
        activeView='all';
        saveLocal();render();
      }
      closeTabDeleteDialog();
    } finally { button.disabled=false; }
  }
  async function deletePersonTab(name) {
    const removed=expenses.filter(x=>x.colaborador===name);
    activeView='all';
    for(const item of removed) {
      const record=backendRecords.find(r=>String(r.__backendId)===String(item.backendId));
      if(record && ready) { deletedIds.add(String(item.backendId)); await queueWrite(()=>window.dataSdk.delete(record)); }
      const url=fileUrls.get(item.id); if(url) URL.revokeObjectURL(url);
    }
    expenses=expenses.filter(x=>x.colaborador!==name);
    saveLocal(); render();
  }
  function openTabDialog() {
    const list=$('tab-people');
    list.replaceChildren();
    for(const name of people()) {
      const label=document.createElement('label');
      label.className='flex items-center gap-2';
      const check=document.createElement('input');
      check.type='checkbox';check.value=name;check.className='accent-[#195c46]';
      label.append(check,document.createTextNode(name));
      list.append(label);
    }
    $('tab-overlay').classList.remove('hidden');
    $('tab-name').focus();
  }
  function closeTabDialog() {
    $('tab-overlay').classList.add('hidden');
    $('new-tab').focus();
  }
  document.addEventListener('DOMContentLoaded',()=>{
    const stored=readJSON(KEY,[]);
    expenses=Array.isArray(stored)?stored.filter(x=>x&&typeof x.id==='string'):[];
    const views=readJSON(VIEWS_KEY,[]);
    customViews=Array.isArray(views)?views.filter(x=>x&&typeof x.id==='string'&&Array.isArray(x.people)):[];
    render();addItem();initialize();

    $('open-drawer').addEventListener('click',()=>drawer(true));
    $('close-drawer').addEventListener('click',()=>drawer(false));
    $('overlay').addEventListener('click',e=>{if(e.target===$('overlay')) drawer(false);});
    document.addEventListener('keydown',e=>{
      if(e.key==='Escape') {
        if(!$('tab-overlay').classList.contains('hidden')) closeTabDialog();
        else if(!$('overlay').classList.contains('hidden')) drawer(false);
      }
    });
    $('add-expense-item').addEventListener('click',addItem);
    $('expense-items').addEventListener('click',e=>{
      if(e.target.closest('[data-action="remove-item"]')&&$('expense-items').children.length>1) {
        e.target.closest('fieldset').remove();numberItems();
      }
    });
    $('expense-form').addEventListener('submit',saveExpenses);
    $('view-tabs').addEventListener('click',e=>{
      const close=e.target.closest('[data-template-id="dynamic-tab-close"]');
      if(close) {
        openTabDeleteDialog(close.parentElement.dataset.view);
        return;
      }
      const tab=e.target.closest('[data-view]');
      if(!tab) return;
      activeView=tab.dataset.view;
      render();
    });
    $('new-tab').addEventListener('click',openTabDialog);
    $('cancel-tab').addEventListener('click',closeTabDialog);
    $('tab-overlay').addEventListener('click',e=>{if(e.target===$('tab-overlay')) closeTabDialog();});
    $('cancel-tab-delete').addEventListener('click',closeTabDeleteDialog);
    $('confirm-tab-delete').addEventListener('click',confirmTabDelete);
    $('tab-delete-overlay').addEventListener('click',e=>{if(e.target===$('tab-delete-overlay')) closeTabDeleteDialog();});
    $('tab-form').addEventListener('submit',e=>{
      e.preventDefault();
      const name=$('tab-name').value.trim();
      const selected=[...$('tab-people').querySelectorAll('input:checked')].map(x=>x.value);
      if(!name) return;
      const view={id:'custom:'+uid(),name,people:selected};
      customViews.push(view);
      activeView=view.id;
      saveLocal();render();
      $('tab-form').reset();
      closeTabDialog();
    });
    $('filter-status').addEventListener('change',render);
    $('expense-body').addEventListener('change',e=>{
      const select=e.target.closest('[data-template-id="row-status"]');
      if(select) changeStatus(select);
    });
    $('expense-body').addEventListener('click',e=>{
      const button=e.target.closest('[data-action]');
      if(!button) return;
      const row=button.closest('tr');
      if(button.dataset.action==='delete') setConfirm(row,true);
      if(button.dataset.action==='cancel') setConfirm(row,false);
      if(button.dataset.action==='confirm') deleteExpense(row,button);
    });
    $('refresh-data').addEventListener('click',async e=>{
      const button=e.currentTarget;button.disabled=true;
      try {await initialize();}
      finally {button.disabled=false;}
    });
    $('retry').addEventListener('click',initialize);
    $('export').addEventListener('click',()=>{
      const quote=value=>'"'+String(value??'').replace(/"/g,'""')+'"';
      const lines=[['Colaborador','Fecha','Descripción','Cantidad (MXN)','Estado','Referencia de evidencia'],
        ...visibleRows().map(x=>[x.colaborador,x.fecha,x.descripcion,x.cantidad,x.estado,x.evidencia_ref])];
      const csv='\uFEFF'+lines.map(line=>line.map(quote).join(',')).join('\r\n');
      const url=URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8'}));
      const link=document.createElement('a');
      link.href=url;link.download='gastos-visibles.csv';
      document.body.append(link);link.click();link.remove();
      setTimeout(()=>URL.revokeObjectURL(url),1000);
    });
  });
})();
</script>
 </body>
</html>

40,152 chars · codepastes.com