Scan to open on your phone
Point a camera at the QR code - it opens this exact paste, no app needed.
https://codepastes.com/zzgt7czsasb295vksin-tituloPlain Text
<!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&family=Fraunces:wght@600;700&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