hbox-docs/docs/tableros/inventario.js
Leopoldo Rodríguez 02521e14f4 Inventario por sitio: pestañas, envíos en camino y kit por instalación
Una pestaña por sitio (ICAVE, TIMSA, EIT, LCT, Querétaro): lo que hay, lo que va en camino
y lo que falta del kit para instalar. Movimientos con sitio, envío/recepción, origen «fuera».
Lote 2026-10-23_inventario-sitios.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X4CAujMuib2tDqt9JRg6X
2026-10-23 13:33:51 -06:00

447 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 = '<tr><th>Código</th><th>Descripción</th><th>Aplica a</th><th class="num">Total</th><th>Dónde está</th><th class="num">Mínimo</th><th class="num" title="Cuánto lleva una unidad instalada">Kit</th><th></th></tr>';
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) + ' <span class="mini">' + esc(i.unidad) + '</span>' + (i.alerta ? ' <span class="pill falla">alerta</span>' : '') }));
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 = '<tr><th>Código</th><th class="c-desc">Descripción</th><th class="num">En sitio</th><th class="num c-op">En camino</th>' +
(sid === 'qro' ? '' : '<th class="num c-op" title="Cuánto lleva una unidad instalada">Por unidad</th><th class="num c-op">Para ' + N + '</th><th>Kit</th>') + '<th class="c-acc"></th></tr>';
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) + ' <span class="mini">' + esc(r.i.unidad) + '</span>' }));
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: '<b>' + esc(e.cantidad) + ' ' + esc(e.unidad || 'pza') + '</b> de ' + esc(e.codigo) + ' — ' + esc(e.descripcion || '') +
'<br>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: '<b>Creado</b> por ' + esc(i.creado_por) + ' el ' + esc(fmt(i.creado_en)) + ' · total <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b> (se cambia con movimientos, no aquí)' }));
(i.historial || []).slice().reverse().slice(0, 12).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + 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 <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b>' + (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)) + '<br><b>' + esc(m.quien) + '</b>' }),
el('span', { text: tipoTexto(m) }),
el('span', { class: 'd', html: m.tipo === 'envio' && m.sitio === FUERA.id ? '<span class="pos">+' + esc(m.cantidad) + '</span> · en camino'
: '<span class="' + (ds >= 0 ? 'pos' : 'neg') + '">' + (ds >= 0 ? '+' : '') + esc(ds) + '</span> · ' + esc(queda) }),
el('span', { html: esc(m.motivo) + (m.referencia ? '<br><span class="mini">ref. ' + esc(m.referencia) + '</span>' : '') })));
});
} 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();
})();