242 lines
18 KiB
HTML
242 lines
18 KiB
HTML
<!doctype html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Inventario de materiales — HBOX</title>
|
||
<link rel="stylesheet" href="../tableros/tablero.css">
|
||
<style>
|
||
table.t td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||
table.t th.num{text-align:right}
|
||
table.t tr.alerta td.num.stock{color:var(--falla);font-weight:700}
|
||
table.t td .cod{font-weight:700;cursor:pointer;color:var(--naranja)}
|
||
table.t td .cat{font-size:11px;color:var(--tinta2);display:block}
|
||
table.t td button{padding:5px 9px;font-size:12.5px;margin:1px 2px 1px 0}
|
||
.acciones{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 12px}
|
||
.acciones .cnt{color:var(--tinta2);font-size:13px;margin-left:auto}
|
||
.mov-fila{display:grid;grid-template-columns:120px 70px 70px 1fr;gap:6px 10px;padding:6px 0;border-bottom:1px dashed var(--linea);font-size:13px;align-items:baseline}
|
||
.mov-fila .d{font-variant-numeric:tabular-nums}
|
||
.mov-fila .pos{color:var(--ok);font-weight:700} .mov-fila .neg{color:var(--falla);font-weight:700}
|
||
@media(max-width:560px){.mov-fila{grid-template-columns:1fr 1fr}}
|
||
.ayuda{font-size:12.5px;color:var(--tinta2);margin-top:3px}
|
||
.mini{font-size:12.5px;color:var(--tinta2)}
|
||
@media(max-width:760px){#cont table.t{min-width:780px}} /* en teléfono: desliza de lado, no aplastes columnas */
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<header class="top">
|
||
<div><h1>Inventario de materiales</h1><div class="meta" id="meta">Cargando…</div></div>
|
||
<nav>
|
||
<a href="../">← Documentación</a>
|
||
<a href="../tableros/produccion.html">Producción</a>
|
||
<a href="../tableros/kanban.html">Pendientes</a>
|
||
<a href="./">Política de inventario</a>
|
||
</nav>
|
||
</header>
|
||
|
||
<details class="como">
|
||
<summary>Cómo se usa</summary>
|
||
<ol>
|
||
<li>El <b>stock no se escribe a mano</b>: es la suma de los movimientos. Llegó material → <b>Entrada</b>. Se usó en un ensamble o se envió → <b>Salida</b> con la unidad o el acta como referencia.</li>
|
||
<li>Contaste y no cuadra → <b>Ajuste</b> con el conteo físico; el sistema calcula la diferencia y la deja registrada con tu nombre.</li>
|
||
<li>Toca el <b>código</b> para ver los movimientos de ese ítem. Cada uno dice quién, cuándo, cuánto y por qué.</li>
|
||
<li><b>Alerta</b> (rojo) = stock por debajo del mínimo. El mínimo lo pone quien compra; el tablero de producción lo repite.</li>
|
||
<li><b>Nuevo ítem</b> solo para cosas que se cuentan: tarjetas, módulos, gabinetes, cajas, fuentes, packs, antenas, cables y los componentes de entrega larga. Nada de resistencias.</li>
|
||
<li><b>Exportar CSV</b> baja la tabla para Excel. Nada se borra: si un ítem ya no aplica, ponle «(obsoleto)» en la descripción.</li>
|
||
</ol>
|
||
</details>
|
||
|
||
<div class="acciones">
|
||
<select id="fCat"><option value="">Todas las categorías</option></select>
|
||
<select id="fVer"><option value="">Aplica a: todas</option></select>
|
||
<label style="margin:0;display:flex;gap:6px;align-items:center;font-weight:500"><input type="checkbox" id="fAlerta" style="width:auto"> solo alertas</label>
|
||
<input type="search" id="fBuscar" placeholder="Buscar código o descripción…" style="flex:1;min-width:150px">
|
||
<button id="bMov">+ Movimiento</button>
|
||
<button id="bNuevo" class="sec">+ Nuevo ítem</button>
|
||
<a href="/api/inventario/export.csv" class="sec" style="padding:8px 12px;border:1px solid var(--linea);border-radius:9px;text-decoration:none;font-size:14px;font-weight:600;background:var(--tarjeta);color:var(--tinta2)">⬇ Exportar CSV</a>
|
||
<span class="cnt" id="cnt"></span>
|
||
</div>
|
||
<div class="msg" id="aviso"></div>
|
||
<div id="cont" class="tblwrap"></div>
|
||
</div>
|
||
|
||
<dialog id="dlg"></dialog>
|
||
<script>
|
||
const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { 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(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 $ = id => document.getElementById(id);
|
||
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', 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' };
|
||
let ITEMS = [], ADMIN = false, USER = '';
|
||
const dlg = $('dlg'), aviso = $('aviso');
|
||
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 4000 : 9000); }
|
||
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) {} 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)));
|
||
}
|
||
function pintar() {
|
||
const cont = $('cont'); cont.innerHTML = '';
|
||
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» o corre la siembra.' })); return; }
|
||
const t = el('table', { class: 't' });
|
||
t.innerHTML = '<tr><th>Código</th><th>Descripción</th><th>Aplica a</th><th class="num">Stock</th><th class="num">Mínimo</th><th>Ubicación</th><th>Proveedor</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 })));
|
||
tr.append(el('td', null, document.createTextNode(i.descripcion), i.notas ? el('div', { class: 'mini', text: i.notas.length > 90 ? i.notas.slice(0, 89) + '…' : i.notas }) : null));
|
||
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', { class: 'num', text: i.minimo || '—' }));
|
||
tr.append(el('td', { text: i.ubicacion || '—' }));
|
||
tr.append(el('td', { html: esc(i.proveedor || '—') + (i.codigo_proveedor ? '<div class="mini">' + esc(i.codigo_proveedor) + '</div>' : '') }));
|
||
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);
|
||
});
|
||
cont.append(t);
|
||
}
|
||
async function cargar() {
|
||
$('meta').textContent = 'Actualizando…';
|
||
try {
|
||
const j = await api('/api/inventario');
|
||
ITEMS = j.items || []; ADMIN = !!j.admin; USER = j.user || '';
|
||
pintar();
|
||
$('meta').textContent = ITEMS.length + ' ítems · ' + (j.alertas || 0) + ' en alerta · actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }) + (USER ? ' · ' + USER : '');
|
||
} 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', text: '✕' }); 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 + ' (' + i.stock + ' ' + i.unidad + ')' })));
|
||
if (sel) s.value = sel.id; return s;
|
||
}
|
||
function movimiento(item) {
|
||
abrirDialogo('Registrar movimiento', (c) => {
|
||
const fItem = selItems(item);
|
||
const fTipo = el('select'); [['entrada', 'Entrada — llegó material (+)'], ['salida', 'Salida — se usó o se envió (−)'], ['ajuste', 'Ajuste — conteo físico (fija el stock)']].forEach(([v, t]) => fTipo.append(el('option', { value: v, text: t })));
|
||
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 Mouser · ensamble · envío a ICAVE · conteo físico' });
|
||
const fRef = el('input', { type: 'text', maxlength: '120', placeholder: 'HBOX5-E07-A010-R0006 · OC-0042 · acta de envío' });
|
||
const ayudaCant = el('div', { class: 'ayuda', text: 'Cantidad que entra.' });
|
||
fTipo.addEventListener('change', () => { ayudaCant.textContent = fTipo.value === 'entrada' ? 'Cantidad que entra.' : fTipo.value === 'salida' ? 'Cantidad que sale. No puede dejar el stock en negativo.' : 'Lo que contaste físicamente: el stock quedará exactamente en ese número.'; });
|
||
c.append(campo('Ítem', fItem), campo('Tipo', fTipo));
|
||
const g = el('div', { class: 'dos-col' }); const cc = campo('Cantidad', fCant); cc.append(ayudaCant); g.append(cc, campo('Referencia (unidad, acta u orden)', fRef)); c.append(g);
|
||
c.append(campo('Motivo (obligatorio)', fMot));
|
||
c._leer = () => ({ item_id: fItem.value, tipo: fTipo.value, cantidad: fCant.value === '' ? null : +fCant.value, motivo: fMot.value.trim(), referencia: fRef.value.trim() });
|
||
setTimeout(() => (item ? fCant : fItem).focus(), 50);
|
||
}, [['Registrar', async (m) => {
|
||
const d = dlg.querySelector('.dcuerpo')._leer();
|
||
if (!d.item_id) throw new Error('Elige el ítem.');
|
||
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));
|
||
dlg.close(); mensaje('Movimiento registrado: ' + j.item.codigo + ' ahora tiene ' + j.item.stock + ' ' + j.item.unidad + '.', true); cargar();
|
||
}]]);
|
||
}
|
||
function formItem(c, i) {
|
||
i = i || {};
|
||
const f = {};
|
||
f.codigo = el('input', { type: 'text', maxlength: '40', value: i.codigo || '', placeholder: 'EB-V56 · SD-15B-05', 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.ubicacion = el('input', { type: 'text', maxlength: '120', value: i.ubicacion || '', placeholder: 'Anaquel A2 · taller Veracruz' });
|
||
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('Mínimo (0 = sin alerta)', f.minimo), campo('Ubicación', 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' }); c.append(campo('Stock inicial (queda registrado como entrada)', f.stock_inicial)); }
|
||
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, 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;
|
||
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)) + ' · stock actual <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();
|
||
}]]);
|
||
}
|
||
async function verMovimientos(i) {
|
||
abrirDialogo('Movimientos de ' + i.codigo, async c => {
|
||
c.append(el('p', { class: 'sub', html: esc(i.descripcion) + ' · stock <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b>' + (i.minimo ? ' · mínimo ' + esc(i.minimo) : '') }));
|
||
const lista = el('div', { text: 'Cargando…' }); c.append(lista);
|
||
const b = el('button', { text: '+ 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. El stock es 0 hasta que registres una entrada o un ajuste.' })); return; }
|
||
lista.append(el('div', { class: 'mov-fila', style: 'font-weight:700;color:var(--tinta2);font-size:11.5px;text-transform:uppercase' }, el('span', { text: 'Cuándo · quién' }), el('span', { text: 'Tipo' }), el('span', { text: 'Δ · stock' }), el('span', { text: 'Motivo · referencia' })));
|
||
j.movimientos.forEach(m => {
|
||
lista.append(el('div', { class: 'mov-fila' },
|
||
el('span', { html: esc(fmt(m.cuando)) + '<br><b>' + esc(m.quien) + '</b>' }),
|
||
el('span', { text: m.tipo + (m.tipo === 'ajuste' ? ' → ' + m.cantidad : '') }),
|
||
el('span', { class: 'd', html: '<span class="' + (m.delta >= 0 ? 'pos' : 'neg') + '">' + (m.delta >= 0 ? '+' : '') + esc(m.delta) + '</span> · ' + esc(m.stock_despues) }),
|
||
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);
|
||
cargar();
|
||
</script>
|
||
</body>
|
||
</html>
|