/* 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ódigo | Descripción | Aplica a | Total | Dónde está | Mínimo | Kit | |
';
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ódigo | Descripción | En sitio | En camino | ' +
(sid === 'qro' ? '' : 'Por unidad | Para ' + 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();
})();