/* Inventario por sitio (23-oct-2026). Hasta el 22-oct este código vivía dentro de inventario.md. Una pestaña por sitio (ICAVE, TIMSA, EIT, LCT y Querétaro): lo que hay ahí, lo que va en camino y, para el material de instalación («Por instalación» > 0), cuánto falta para instalar N unidades. «Todo» es el inventario completo. La dirección guarda la vista: ?sitio=eit&n=2. Servidor (backend/app.py): GET /api/inventario (ítems con por_sitio y en_camino, envíos abiertos, sitios) · POST /api/inventario · PATCH /api/inventario/item?id= · POST /api/inventario/movimiento (entrada, salida, ajuste, envio con sitio y destino, recepcion con envio_id) · GET /api/inventario/movimientos?item_id= · export.csv. Un envío puede salir de «fuera» (FUERA): una compra que viene en camino o material que salió antes de contarlo. Necesita tableros/kanban.js (íconos) y tableros/tablero.css; estilos propios en tableros/inventario.css. */ (function () { const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (a[p] == null) continue; if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c)); return e; }; const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }; const fmt = iso => (iso || '').replace('T', ' ').slice(0, 16); const MES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']; const fechaC = iso => { const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}:\d{2}))?/.exec(iso || ''); return m ? (+m[3]) + ' ' + MES[+m[2] - 1] + (m[4] ? ', ' + m[4] : '') : ''; }; const $ = id => document.getElementById(id); const icono = n => (window.Kanban && window.Kanban.icono ? window.Kanban.icono(n) : ''); const CAT = { tarjeta: 'Tarjeta', modulo: 'Módulo', gabinete: 'Gabinete', caja: 'Caja', fuente: 'Fuente', bateria: 'Batería', antena: 'Antena', cable: 'Cable', conector: 'Conector', 'componente-critico': 'Componente crítico', instalacion: 'Instalación', otro: 'Otro' }; const VERS = ['V5.5', 'V5.6', 'V5.7', 'v6']; const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' }; const ORDEN = ['icave', 'timsa', 'eit', 'lzc', 'qro']; const ALIAS = { lct: 'lzc', lazarocardenas: 'lzc', 'lazaro-cardenas': 'lzc', veracruz: 'icave', manzanillo: 'timsa', ensenada: 'eit', queretaro: 'qro', oficina: 'qro' }; const SITIOS_OMISION = [{ id: 'icave', corto: 'ICAVE', nombre: 'ICAVE · Veracruz' }, { id: 'timsa', corto: 'TIMSA', nombre: 'TIMSA · Manzanillo' }, { id: 'eit', corto: 'EIT', nombre: 'EIT · Ensenada' }, { id: 'lzc', corto: 'LCT', nombre: 'LCT · Lázaro Cárdenas' }, { id: 'qro', corto: 'QRO', nombre: 'Querétaro' }]; const TIPOS = [['entrada', 'Entrada — llegó material a este sitio (+)'], ['salida', 'Salida — se instaló, se usó o se dio de baja (−)'], ['ajuste', 'Conteo físico — fija lo que hay en el sitio'], ['envio', 'Envío a otro sitio — sale de aquí y queda en camino']]; let ITEMS = [], ENVIOS = [], SITIOS = SITIOS_OMISION.slice(), USER = '', vista = 'todo', N = 2, cargadoEn = 0, conSitios = true; const dlg = $('dlg'), aviso = $('aviso'), cont = $('cont'), tabs = $('inv-tabs'); const FUERA = { id: 'fuera', corto: 'fuera del inventario', nombre: 'Proveedor o fuera del inventario' }; const sitio = id => SITIOS.find(s => s.id === id) || (id === FUERA.id ? FUERA : { id, corto: String(id || '').toUpperCase(), nombre: String(id || '').toUpperCase() }); const corto = id => (id === 'qro' ? 'Querétaro' : sitio(id).corto); const enSitio = (i, s) => (i.por_sitio || {})[s] || 0; const enCamino = (i, s) => (i.en_camino || {})[s] || 0; const totalCamino = i => Object.values(i.en_camino || {}).reduce((a, b) => a + b, 0); const pl = (n, uno, varios) => n + ' ' + (n === 1 ? uno : varios); function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => (aviso.className = 'msg'), bien ? 5000 : 10000); } async function api(url, opts) { const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {})); let j = null; try { j = await r.json(); } catch (e) { /* sin JSON */ } if (!r.ok || !j || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status)); return j; } const json = (m, b) => ({ method: m, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) }); function campo(l, i, ayuda) { const d = el('div'); d.append(el('label', { text: l }), i); if (ayuda) d.append(el('div', { class: 'ayuda', text: ayuda })); return d; } Object.entries(CAT).forEach(([k, v]) => $('fCat').append(el('option', { value: k, text: v }))); VERS.forEach(v => $('fVer').append(el('option', { value: v, text: 'Aplica a: ' + v }))); function visibles() { const cat = $('fCat').value, ver = $('fVer').value, solo = $('fAlerta').checked, q = $('fBuscar').value.trim().toLowerCase(); return ITEMS.filter(i => (!cat || i.categoria === cat) && (!ver || (i.aplica_a || []).includes(ver)) && (!solo || i.alerta) && (!q || (i.codigo + ' ' + i.descripcion + ' ' + (i.proveedor || '') + ' ' + (i.ubicacion || '')).toLowerCase().includes(q))); } // ---------------------------------------------------------------- pestañas function pintarTabs() { tabs.innerHTML = ''; const b = (id, txt, n) => { const x = el('button', { type: 'button', 'aria-pressed': String(vista === id) }, txt); if (n) x.append(el('span', { class: 'n', text: ' · ' + n + ' en camino' })); x.addEventListener('click', () => ir(id)); tabs.append(x); }; b('todo', 'Todo'); ORDEN.filter(id => SITIOS.some(s => s.id === id)).forEach(id => b(id, corto(id), ENVIOS.filter(e => e.destino === id).length)); } function ir(id) { vista = id; const u = new URL(location.href); if (id === 'todo') { u.searchParams.delete('sitio'); u.searchParams.delete('n'); } else { u.searchParams.set('sitio', id); if (N !== 2) u.searchParams.set('n', N); else u.searchParams.delete('n'); } history.replaceState(null, '', u.pathname + u.search + u.hash); pintar(); } // ---------------------------------------------------------------- vista «Todo» function chipsDonde(i) { const d = el('div', { class: 'inv-donde' }); ORDEN.forEach(s => { const n = enSitio(i, s); if (n) d.append(el('span', { class: 'chip' + (n < 0 ? ' neg' : ''), title: sitio(s).nombre }, el('b', { text: corto(s) === 'Querétaro' ? 'QRO' : corto(s) }), ' ' + n)); }); const c = totalCamino(i); if (c) d.append(el('span', { class: 'chip camino', title: 'Va en camino' }, c + ' en camino')); if (!d.childNodes.length) d.append(el('span', { class: 'mini', text: '—' })); if (i.ubicacion) d.append(el('div', { class: 'mini', text: i.ubicacion })); return d; } function pintarTodo() { const vis = visibles(); $('cnt').textContent = vis.length + ' ítem(s)' + (vis.length !== ITEMS.length ? ' de ' + ITEMS.length : '') + ' · ' + vis.filter(i => i.alerta).length + ' en alerta'; if (!vis.length) { cont.append(el('div', { class: 'vacio', text: ITEMS.length ? 'Nada coincide con el filtro.' : 'Todavía no hay ítems. Crea el primero con «Nuevo ítem».' })); return; } const w = el('div', { class: 'tblwrap' }); const t = el('table', { class: 't' }); t.innerHTML = 'CódigoDescripciónAplica aTotalDónde estáMínimoKit'; vis.forEach(i => { const tr = el('tr', { class: i.alerta ? 'alerta' : '' }); const cod = el('span', { class: 'cod', text: i.codigo, title: 'Ver movimientos' }); cod.addEventListener('click', () => verMovimientos(i)); tr.append(el('td', null, cod, el('span', { class: 'cat', text: CAT[i.categoria] || i.categoria }))); const desc = el('td', null, i.descripcion); if (i.proveedor || i.codigo_proveedor) desc.append(el('div', { class: 'mini', text: [i.proveedor, i.codigo_proveedor].filter(Boolean).join(' · ') })); if (i.notas) desc.append(el('div', { class: 'mini', text: i.notas.length > 90 ? i.notas.slice(0, 89) + '…' : i.notas })); tr.append(desc); const ap = el('td'); (i.aplica_a || []).forEach(v => ap.append(el('span', { class: BADGE[v] || 'pill pend', text: v, style: 'margin-right:3px' }))); tr.append(ap); tr.append(el('td', { class: 'num stock', html: esc(i.stock) + ' ' + esc(i.unidad) + '' + (i.alerta ? ' alerta' : '') })); tr.append(el('td', null, chipsDonde(i))); tr.append(el('td', { class: 'num', text: i.minimo || '—' })); tr.append(el('td', { class: 'num', text: i.por_instalacion || '—' })); const acc = el('td', { style: 'white-space:nowrap' }); const bm = el('button', { text: 'Movimiento' }); bm.addEventListener('click', () => movimiento(i)); const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(i)); acc.append(bm, be); tr.append(acc); t.append(tr); }); w.append(t); cont.append(w); } // ---------------------------------------------------------------- vista de un sitio function filasSitio(sid) { const q = $('fBuscar').value.trim().toLowerCase(), cat = $('fCat').value; return ITEMS.map(i => { const en = enSitio(i, sid), cam = enCamino(i, sid), k = i.por_instalacion || 0, nec = k * N; return { i, en, cam, k, nec, faltaHoy: Math.max(0, nec - en), falta: Math.max(0, nec - en - cam) }; }).filter(r => (r.k > 0 || r.en !== 0 || r.cam > 0) && (!cat || r.i.categoria === cat) && (!q || (r.i.codigo + ' ' + r.i.descripcion).toLowerCase().includes(q))) .sort((a, b) => ((b.k > 0) - (a.k > 0)) || (b.falta - a.falta) || (b.faltaHoy - a.faltaHoy) || String(a.i.codigo).localeCompare(String(b.i.codigo))); } function estadoKit(r) { if (!r.k) return el('span', { class: 'mini', text: 'fuera del kit' }); if (r.falta) return el('span', { class: 'pill falla', text: 'Faltan ' + r.falta + ' ' + r.i.unidad }); if (r.faltaHoy) return el('span', { class: 'pill aviso', text: 'Completo al llegar' }); return el('span', { class: 'pill ok', text: 'Completo' }); } function pintarSitio(sid) { const s = sitio(sid), filas = filasSitio(sid), kit = filas.filter(r => r.k > 0); const llegan = ENVIOS.filter(e => e.destino === sid), salen = ENVIOS.filter(e => e.origen === sid); $('cnt').textContent = ''; // cabeza: cuántas unidades y cómo va el kit const cab = el('div', { class: 'inv-cab' }); const fN = el('input', { type: 'number', min: '1', max: '50', step: '1', value: String(N), inputmode: 'numeric', 'aria-label': 'Unidades por instalar' }); fN.addEventListener('change', () => { N = Math.min(50, Math.max(1, parseInt(fN.value, 10) || 1)); ir(vista); }); cab.append(el('h2', { text: 'Material en ' + (sid === 'qro' ? 'Querétaro' : s.nombre) })); const linea = el('div', { class: 'inv-n' }); if (sid === 'qro') linea.append(el('span', { class: 'mini', text: 'Almacén de fabricación e ingeniería: de aquí sale el material a los puertos.' })); else linea.append(el('label', null, 'Para instalar ', fN, ' ' + (N === 1 ? 'unidad' : 'unidades'))); if (kit.length) { const completos = kit.filter(r => !r.falta && !r.faltaHoy).length, faltan = kit.filter(r => r.falta).length, llegando = kit.filter(r => !r.falta && r.faltaHoy).length; linea.append(el('span', { class: 'inv-res' + (faltan ? ' mal' : (llegando ? ' casi' : ' bien')) }, sid === 'qro' ? pl(kit.length, 'ítem del kit', 'ítems del kit') : (faltan ? (faltan === 1 ? 'Falta 1 ítem' : 'Faltan ' + faltan + ' ítems') + ' del kit' : (llegando ? 'Kit completo cuando llegue lo que va en camino' : 'Kit completo')) + ' · ' + completos + ' de ' + kit.length + ' completos')); } if (sid !== 'qro') linea.append(el('a', { href: '/tableros/instalaciones/?sitio=' + encodeURIComponent(sid), text: 'Instalaciones de ' + corto(sid) })); cab.append(linea); cont.append(cab); // lo que va en camino hacia este sitio (se recibe aquí) y lo que salió de aquí if (llegan.length) { const box = el('section', { class: 'inv-camino' }); box.append(el('h3', { text: 'En camino a ' + corto(sid) })); llegan.forEach(e => { const f = el('div', { class: 'fila' }); f.append(el('span', { class: 'cant', text: e.cantidad + ' ' + (e.unidad || 'pza') }), el('span', { class: 'que' }, el('b', { text: e.codigo }), ' ' + (e.descripcion || ''), el('span', { class: 'mini', text: 'desde ' + corto(e.origen) + ' · ' + fechaC(e.cuando) + ' · ' + e.quien + (e.referencia ? ' · ' + e.referencia : '') }))); const b = el('button', { type: 'button', text: 'Recibir' }); b.addEventListener('click', () => recibir(e)); f.append(b); box.append(f); }); cont.append(box); } if (salen.length) { const box = el('section', { class: 'inv-camino sale' }); box.append(el('h3', { text: 'Salió de ' + corto(sid) + ' y va en camino' })); salen.forEach(e => box.append(el('div', { class: 'fila' }, el('span', { class: 'cant', text: e.cantidad + ' ' + (e.unidad || 'pza') }), el('span', { class: 'que' }, el('b', { text: e.codigo }), ' ' + (e.descripcion || ''), el('span', { class: 'mini', text: 'a ' + corto(e.destino) + ' · ' + fechaC(e.cuando) + ' · ' + e.quien + (e.referencia ? ' · ' + e.referencia : '') }))))); cont.append(box); } if (!filas.length) { cont.append(el('div', { class: 'vacio inv-vacio' }, el('p', { text: 'No hay material registrado en ' + corto(sid) + '.' }), el('p', { text: 'Registra el conteo físico de lo que hay (Movimiento → Conteo físico) o, si va en camino, el envío desde el sitio de donde salió. ' + 'Para ver qué falta, define el kit: en «Todo», Editar → «Por instalación».' }))); return; } if (!kit.length) cont.append(el('p', { class: 'mini inv-sin-kit', text: 'Todavía no hay kit de instalación: en «Todo», Editar → «Por instalación» (cuánto lleva una unidad instalada). Con eso esta pestaña dice qué falta.' })); const w = el('div', { class: 'tblwrap' }); const t = el('table', { class: 't inv-sitio' }); t.innerHTML = 'CódigoDescripciónEn sitioEn camino' + (sid === 'qro' ? '' : 'Por unidadPara ' + N + 'Kit') + ''; filas.forEach(r => { const tr = el('tr', { class: r.falta ? 'falta' : '' }); const cod = el('span', { class: 'cod', text: r.i.codigo, title: 'Ver movimientos' }); cod.addEventListener('click', () => verMovimientos(r.i)); tr.append(el('td', null, cod, el('span', { class: 'cat', text: CAT[r.i.categoria] || r.i.categoria }), el('span', { class: 'desc-m', text: r.i.descripcion }))); tr.append(el('td', { class: 'c-desc' }, r.i.descripcion)); tr.append(el('td', { class: 'num stock', html: esc(r.en) + ' ' + esc(r.i.unidad) + '' })); tr.append(el('td', { class: 'num c-op', text: r.cam || '—' })); if (sid !== 'qro') { tr.append(el('td', { class: 'num c-op', text: r.k || '—' })); tr.append(el('td', { class: 'num c-op', text: r.k ? r.nec : '—' })); tr.append(el('td', null, estadoKit(r))); } const acc = el('td', { class: 'c-acc', style: 'white-space:nowrap' }); const bm = el('button', { text: 'Movimiento', title: 'Movimiento de ' + r.i.codigo + ' en ' + corto(sid) }); bm.addEventListener('click', () => movimiento(r.i, { sitio: sid })); acc.append(bm); tr.append(acc); t.append(tr); }); w.append(t); cont.append(w); } function pintar() { pintarTabs(); cont.innerHTML = ''; $('fVer').style.display = $('fAlerta').parentElement.style.display = vista === 'todo' ? '' : 'none'; $('inv-a-ins').href = '/tableros/instalaciones/' + (vista !== 'todo' && vista !== 'qro' ? '?sitio=' + encodeURIComponent(vista) : ''); document.title = 'Inventario' + (vista !== 'todo' ? ' · ' + corto(vista) : '') + ' - Drogheda'; if (vista === 'todo') pintarTodo(); else pintarSitio(vista); const hora = cargadoEn ? new Date(cargadoEn).toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit', hour12: false }) : ''; $('meta').textContent = pl(ITEMS.length, 'ítem', 'ítems') + ' · ' + ITEMS.filter(i => i.alerta).length + ' en alerta' + (ENVIOS.length ? ' · ' + pl(ENVIOS.length, 'envío', 'envíos') + ' en camino' : '') + (hora ? ' · al día a las ' + hora : '') + (USER ? ' · ' + USER : ''); } async function cargar() { try { const j = await api('/api/inventario'); ITEMS = j.items || []; USER = j.user || ''; ENVIOS = j.envios || []; conSitios = Array.isArray(j.sitios); if (conSitios && j.sitios.length) SITIOS = j.sitios; cargadoEn = Date.now(); if (!conSitios && vista !== 'todo') mensaje('El servicio todavía no sabe de sitios: falta reiniciar actas-hbox en el servidor. Mientras tanto se ve el inventario completo.', false); if (!conSitios) vista = 'todo'; pintar(); } catch (e) { cont.innerHTML = ''; cont.append(el('div', { class: 'err', text: 'No se pudo cargar el inventario: ' + e.message })); $('meta').textContent = 'Error'; } } ['fCat', 'fVer', 'fAlerta'].forEach(id => $(id).addEventListener('change', pintar)); $('fBuscar').addEventListener('input', pintar); // ---------------------------------------------------------------- diálogos function abrirDialogo(titulo, cuerpoFn, botones) { dlg.innerHTML = ''; const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo })); const x = el('button', { type: 'button', 'aria-label': 'Cerrar', html: icono('cerrar') || '×' }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab); const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo); const m = el('div', { class: 'msg' }); cuerpoFn(cuerpo, m); cuerpo.append(m); const pie = el('div', { class: 'dpie' }); dlg.append(pie); const cancelar = el('button', { type: 'button', class: 'sec', text: botones ? 'Cancelar' : 'Cerrar' }); cancelar.addEventListener('click', () => dlg.close()); pie.append(cancelar); (botones || []).forEach(([txt, fn]) => { const b = el('button', { type: 'button', text: txt }); b.addEventListener('click', async () => { b.disabled = true; try { await fn(m); } catch (e) { m.className = 'msg err'; m.textContent = e.message; } b.disabled = false; }); pie.append(b); }); if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', ''); } function selItems(sel) { const s = el('select'); s.append(el('option', { value: '', text: '— elegir ítem —' })); ITEMS.forEach(i => s.append(el('option', { value: i.id, text: i.codigo + ' — ' + i.descripcion + ' (total ' + i.stock + ' ' + i.unidad + ')' }))); if (sel) s.value = sel.id; return s; } function selSitios(valor, vacio) { const s = el('select'); if (vacio) s.append(el('option', { value: '', text: '— elegir sitio —' })); ORDEN.filter(id => SITIOS.some(x => x.id === id)).forEach(id => s.append(el('option', { value: id, text: sitio(id).nombre }))); if (valor) s.value = valor; return s; } function movimiento(item, pre) { pre = pre || {}; const base = pre.sitio || (vista !== 'todo' ? vista : 'qro'); abrirDialogo('Registrar movimiento', (c) => { const fItem = selItems(item); const fTipo = el('select'); TIPOS.forEach(([v, t]) => fTipo.append(el('option', { value: v, text: t }))); if (pre.tipo) fTipo.value = pre.tipo; const fSitio = selSitios(base); const fDest = selSitios(null, true); const fCant = el('input', { type: 'number', min: '0', step: '1', inputmode: 'numeric', placeholder: '0' }); const fMot = el('input', { type: 'text', maxlength: '200', placeholder: 'llegó pedido · instalación en RTG-12 · kit para EIT · conteo físico' }); const fRef = el('input', { type: 'text', maxlength: '120', placeholder: 'HBOX5-E07-A010-R0006 · guía 1234 · OC-0042' }); const hay = el('div', { class: 'ayuda' }); const lSitio = campo('Sitio', fSitio), lDest = campo('A qué sitio va', fDest); const oFuera = el('option', { value: FUERA.id, text: FUERA.nombre }); const act = () => { const t = fTipo.value; if (t === 'envio' && !oFuera.parentNode) fSitio.append(oFuera); // solo un envío puede salir de fuera if (t !== 'envio' && oFuera.parentNode) { if (fSitio.value === FUERA.id) fSitio.value = base; oFuera.remove(); } const it = ITEMS.find(x => x.id === fItem.value), s = fSitio.value; lSitio.querySelector('label').textContent = t === 'envio' ? 'Sale de' : 'Sitio'; lDest.style.display = t === 'envio' ? '' : 'none'; if (!conSitios) { lSitio.style.display = 'none'; } if (s === FUERA.id) hay.textContent = 'Para una compra que viene en camino, o material que salió de un sitio antes de contarlo: no descuenta de ningún sitio y entra al total como «en camino» hasta que le den «Recibir».'; else hay.textContent = it ? 'En ' + corto(s) + ' hay ' + enSitio(it, s) + ' ' + it.unidad + '.' + (t === 'ajuste' ? ' Escribe lo que contaste: queda exactamente en ese número.' : (t === 'salida' || t === 'envio') ? ' No puede quedar en negativo.' : '') : ''; }; [fItem, fTipo, fSitio].forEach(x => x.addEventListener('change', act)); act(); c.append(campo('Ítem', fItem), campo('Tipo', fTipo)); const g = el('div', { class: 'dos-col' }); g.append(lSitio, lDest); c.append(g, hay); const g2 = el('div', { class: 'dos-col' }); g2.append(campo('Cantidad', fCant), campo('Referencia (unidad, guía u orden)', fRef)); c.append(g2); c.append(campo('Motivo (obligatorio)', fMot)); c._leer = () => ({ item_id: fItem.value, tipo: fTipo.value, sitio: conSitios ? fSitio.value : undefined, destino: fTipo.value === 'envio' ? fDest.value : undefined, cantidad: fCant.value === '' ? null : +fCant.value, motivo: fMot.value.trim(), referencia: fRef.value.trim() }); setTimeout(() => (item ? fCant : fItem).focus(), 50); }, [['Registrar', async () => { const d = dlg.querySelector('.dcuerpo')._leer(); if (!d.item_id) throw new Error('Elige el ítem.'); if (d.tipo === 'envio' && !d.destino) throw new Error('Elige a qué sitio va el envío.'); if (d.cantidad == null || isNaN(d.cantidad)) throw new Error('Escribe la cantidad.'); if (!d.motivo) throw new Error('Escribe el motivo.'); const j = await api('/api/inventario/movimiento', json('POST', d)); const mv = j.movimiento || {}, it = j.item || {}; dlg.close(); if (d.tipo === 'envio') mensaje('Envío registrado: ' + d.cantidad + ' ' + it.unidad + ' de ' + it.codigo + ' van en camino a ' + corto(d.destino) + '. Quien lo reciba le da «Recibir» en la pestaña de su sitio.', true); else mensaje('Movimiento registrado: en ' + corto(mv.sitio || 'qro') + ' quedan ' + (mv.en_sitio_despues != null ? mv.en_sitio_despues : it.stock) + ' ' + it.unidad + ' de ' + it.codigo + ' (total ' + it.stock + ').', true); cargar(); }]]); } function recibir(e) { abrirDialogo('Recibir envío', c => { c.append(el('p', { class: 'sub', html: '' + esc(e.cantidad) + ' ' + esc(e.unidad || 'pza') + ' de ' + esc(e.codigo) + ' — ' + esc(e.descripcion || '') + '
desde ' + esc(corto(e.origen)) + ' hacia ' + esc(corto(e.destino)) + ', enviado el ' + esc(fmt(e.cuando)) + ' por ' + esc(e.quien) + (e.referencia ? ' · ' + esc(e.referencia) : '') })); const fCant = el('input', { type: 'number', min: '0', max: String(e.cantidad), step: '1', inputmode: 'numeric', value: String(e.cantidad) }); const fMot = el('input', { type: 'text', maxlength: '200', placeholder: 'llegó completo · una caja llegó abierta…' }); const fRef = el('input', { type: 'text', maxlength: '120', placeholder: 'acta de recepción · guía' }); c.append(campo('Cantidad que llegó', fCant, 'Si llegó menos, lo que falta baja del total y queda anotado con tu nombre.'), campo('Nota (opcional)', fMot), campo('Referencia (opcional)', fRef)); c._leer = () => ({ tipo: 'recepcion', envio_id: e.id, cantidad: fCant.value === '' ? null : +fCant.value, motivo: fMot.value.trim(), referencia: fRef.value.trim() }); setTimeout(() => fCant.focus(), 50); }, [['Recibir', async () => { const d = dlg.querySelector('.dcuerpo')._leer(); if (d.cantidad == null || isNaN(d.cantidad)) throw new Error('Escribe cuánto llegó.'); const j = await api('/api/inventario/movimiento', json('POST', d)); dlg.close(); const falto = e.cantidad - d.cantidad; mensaje('Recibido en ' + corto(e.destino) + ': ' + d.cantidad + ' ' + (j.item || {}).unidad + ' de ' + e.codigo + (falto > 0 ? '. Faltaron ' + falto + ' y bajaron del total.' : '.'), true); cargar(); }]]); } function formItem(c, i) { i = i || {}; const f = {}; f.codigo = el('input', { type: 'text', maxlength: '40', value: i.codigo || '', placeholder: 'GAB-IP67 · SOP-RTG', style: 'text-transform:uppercase' }); f.descripcion = el('input', { type: 'text', maxlength: '200', value: i.descripcion || '' }); f.categoria = el('select'); Object.entries(CAT).forEach(([k, v]) => f.categoria.append(el('option', { value: k, text: v }))); f.categoria.value = i.categoria || 'otro'; f.unidad = el('select'); [['pza', 'pza (piezas)'], ['m', 'm (metros)']].forEach(([v, t]) => f.unidad.append(el('option', { value: v, text: t }))); f.unidad.value = i.unidad || 'pza'; f.aplica = el('div', { class: 'lista-chk', style: 'max-height:none;display:flex;gap:14px;flex-wrap:wrap' }); VERS.forEach(v => { const l = el('label', { style: 'margin:0' }); const ck = el('input', { type: 'checkbox', value: v }); ck.checked = (i.aplica_a || []).includes(v); l.append(ck, document.createTextNode(v)); f.aplica.append(l); }); f.minimo = el('input', { type: 'number', min: '0', step: '1', value: i.minimo || 0 }); f.por_instalacion = el('input', { type: 'number', min: '0', step: '1', value: i.por_instalacion || 0 }); f.ubicacion = el('input', { type: 'text', maxlength: '120', value: i.ubicacion || '', placeholder: 'Anaquel A2 · bodega del puerto' }); f.proveedor = el('input', { type: 'text', maxlength: '120', value: i.proveedor || '' }); f.codigo_proveedor = el('input', { type: 'text', maxlength: '60', value: i.codigo_proveedor || '', placeholder: 'LCSC C2057152 · Mouser 709-SD15B05' }); f.notas = el('textarea', { rows: '2' }); f.notas.value = i.notas || ''; const g = el('div', { class: 'dos-col' }); g.append(campo('Código (único)', f.codigo), campo('Categoría', f.categoria)); c.append(g, campo('Descripción', f.descripcion), campo('Aplica a (versión de tarjeta)', f.aplica)); const g2 = el('div', { class: 'dos-col' }); g2.append(campo('Unidad', f.unidad), campo('Por instalación (0 = no es del kit)', f.por_instalacion, 'Cuánto lleva una unidad instalada.'), campo('Mínimo del total (0 = sin alerta)', f.minimo), campo('Ubicación (anaquel, bodega)', f.ubicacion), campo('Proveedor', f.proveedor), campo('Código del proveedor', f.codigo_proveedor)); c.append(g2); if (!i.id) { f.stock_inicial = el('input', { type: 'number', min: '0', step: '1', value: '0' }); f.sitio = selSitios(vista !== 'todo' ? vista : 'qro'); const g3 = el('div', { class: 'dos-col' }); g3.append(campo('Stock inicial (queda registrado como entrada)', f.stock_inicial), campo('En qué sitio está', f.sitio)); c.append(g3); } c.append(campo('Notas', f.notas)); return () => { const d = { codigo: f.codigo.value.trim().toUpperCase(), descripcion: f.descripcion.value.trim(), categoria: f.categoria.value, unidad: f.unidad.value, aplica_a: [...f.aplica.querySelectorAll('input:checked')].map(x => x.value), minimo: +f.minimo.value || 0, por_instalacion: +f.por_instalacion.value || 0, ubicacion: f.ubicacion.value.trim(), proveedor: f.proveedor.value.trim(), codigo_proveedor: f.codigo_proveedor.value.trim(), notas: f.notas.value.trim() }; if (f.stock_inicial) { d.stock_inicial = +f.stock_inicial.value || 0; if (conSitios) d.sitio = f.sitio.value; } return d; }; } function nuevo() { let leer; abrirDialogo('Nuevo ítem', c => { leer = formItem(c); }, [['Crear ítem', async () => { const d = leer(); if (!d.codigo) throw new Error('Escribe el código.'); if (!d.descripcion) throw new Error('Escribe la descripción.'); await api('/api/inventario', json('POST', d)); dlg.close(); mensaje('Ítem ' + d.codigo + ' creado.', true); cargar(); }]]); } function editar(i) { let leer; abrirDialogo('Editar ' + i.codigo, c => { leer = formItem(c, i); const h = el('div', { class: 'hist' }); h.append(el('div', { html: 'Creado por ' + esc(i.creado_por) + ' el ' + esc(fmt(i.creado_en)) + ' · total ' + esc(i.stock) + ' ' + esc(i.unidad) + ' (se cambia con movimientos, no aquí)' })); (i.historial || []).slice().reverse().slice(0, 12).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · ' + esc(x.quien) + ' · ' + esc(x.cambio) }))); c.append(h); }, [['Guardar', async () => { const d = leer(); const cambios = {}; Object.keys(d).forEach(k => { if (JSON.stringify(d[k]) !== JSON.stringify(i[k] == null ? (Array.isArray(d[k]) ? [] : (typeof d[k] === 'number' ? 0 : '')) : i[k])) cambios[k] = d[k]; }); if (Object.keys(cambios).length) await api('/api/inventario/item?id=' + encodeURIComponent(i.id), json('PATCH', cambios)); dlg.close(); mensaje('Ítem guardado.', true); cargar(); }]]); } function tipoTexto(m) { const s = corto(m.sitio || 'qro'); if (m.tipo === 'envio') return 'envío ' + (m.sitio === FUERA.id ? 'de fuera del inventario' : s) + ' → ' + corto(m.destino) + (m.estado === 'en-camino' ? ' · en camino' : ' · recibido' + (m.recibido != null && m.recibido !== m.cantidad ? ' ' + m.recibido : '')); if (m.tipo === 'recepcion') return 'recepción en ' + s + (m.origen ? ' (de ' + corto(m.origen) + ')' : ''); if (m.tipo === 'ajuste') return 'conteo en ' + s + ' → ' + m.cantidad; return m.tipo + ' en ' + s; } async function verMovimientos(i) { abrirDialogo('Movimientos de ' + i.codigo, async c => { c.append(el('p', { class: 'sub', html: esc(i.descripcion) + ' · total ' + esc(i.stock) + ' ' + esc(i.unidad) + '' + (i.minimo ? ' · mínimo ' + esc(i.minimo) : '') })); c.append(chipsDonde(i)); const lista = el('div', { text: 'Cargando…', class: 'inv-movs' }); c.append(lista); const b = el('button', { html: icono('mas') + 'Movimiento', style: 'margin-top:10px' }); b.addEventListener('click', () => movimiento(i)); c.append(b); try { const j = await api('/api/inventario/movimientos?item_id=' + encodeURIComponent(i.id)); lista.innerHTML = ''; if (!j.total) { lista.append(el('div', { class: 'vacio', text: 'Sin movimientos. Está en 0 hasta que registres una entrada o un conteo físico.' })); return; } lista.append(el('div', { class: 'mov-fila', style: 'font-weight:700;color:var(--hg-texto-2);font-size:11.5px;text-transform:uppercase' }, el('span', { text: 'Cuándo · quién' }), el('span', { text: 'Qué y dónde' }), el('span', { text: 'Δ · queda' }), el('span', { text: 'Motivo · referencia' }))); j.movimientos.forEach(m => { const ds = m.delta_sitio != null ? m.delta_sitio : m.delta, queda = m.en_sitio_despues != null ? m.en_sitio_despues : m.stock_despues; lista.append(el('div', { class: 'mov-fila' }, el('span', { html: esc(fmt(m.cuando)) + '
' + esc(m.quien) + '' }), el('span', { text: tipoTexto(m) }), el('span', { class: 'd', html: m.tipo === 'envio' && m.sitio === FUERA.id ? '+' + esc(m.cantidad) + ' · en camino' : '' + (ds >= 0 ? '+' : '') + esc(ds) + ' · ' + esc(queda) }), el('span', { html: esc(m.motivo) + (m.referencia ? '
ref. ' + esc(m.referencia) + '' : '') }))); }); } catch (e) { lista.textContent = 'No se pudieron leer los movimientos: ' + e.message; } }); } $('bMov').addEventListener('click', () => movimiento(null)); $('bNuevo').addEventListener('click', nuevo); const qs = new URLSearchParams(location.search); const q = (qs.get('sitio') || '').trim().toLowerCase(); vista = q ? (ALIAS[q] || q) : 'todo'; if (!ORDEN.includes(vista)) vista = 'todo'; N = Math.min(50, Math.max(1, parseInt(qs.get('n'), 10) || 2)); document.addEventListener('visibilitychange', () => { if (!document.hidden && cargadoEn && Date.now() - cargadoEn > 60000) cargar(); }); cargar(); })();