hbox-docs/docs/tableros/compras.js

265 lines
14 KiB
JavaScript

/* Compras: las órdenes de compra del cotizador dentro del centro de mando (28-oct-2026).
Una pestaña por sitio (ICAVE, TIMSA, EIT, LCT, Querétaro) y «Todo». Cada OC es una tarjeta con sus renglones:
qué se pidió, a qué ítem del inventario corresponde, qué va en camino y qué llegó. Dos acciones: «Ligar» un
renglón que vino sin ítem y «Recibir» con lo que llegó de verdad.
Servidor (backend/compras.py): GET /api/compras · POST /api/compras/ligar · POST /api/compras/recibir.
Las OC entran solas por /api/integracion/cotizador/evento, firmadas por el cotizador: esta página nunca las crea.
Aquí NO hay montos: el dinero vive en el cotizador (D-27-4).
Necesita tableros/kanban.js (íconos) y tableros/tablero.css; estilos propios en tableros/compras.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 $ = id => document.getElementById(id);
const MES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'];
const fecha = iso => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso || ''); return m ? (+m[3]) + ' ' + MES[+m[2] - 1] : ''; };
const num = n => (Math.round((+n || 0) * 100) / 100).toLocaleString('es-MX');
const hoy = () => new Date().toISOString().slice(0, 10);
const ESTADOS = {
autorizada: ['Autorizada', 'e-aut'], pagada: ['Pagada', 'e-pag'], 'en-camino': ['En camino', 'e-cam'],
'recibida-parcial': ['Recibida en parte', 'e-par'], recibida: ['Recibida', 'e-rec'], cancelada: ['Cancelada', 'e-can']
};
const ABIERTAS = ['autorizada', 'pagada', 'en-camino', 'recibida-parcial'];
const ORDEN = ['icave', 'timsa', 'eit', 'lzc', 'qro'];
let OC = [], ITEMS = [], SITIOS = [], USER = '', INTEG = true, vista = 'todo';
const dlg = $('dlg'), aviso = $('aviso'), cont = $('cont'), tabs = $('cmp-tabs');
const corto = id => { const s = SITIOS.find(x => x.id === id); return id === 'qro' ? 'Querétaro' : (s ? s.corto : String(id || '').toUpperCase()); };
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(ESTADOS).forEach(([k, v]) => $('fEstado').append(el('option', { value: k, text: v[0] })));
// ---------------------------------------------------------------- filtros y pestañas
const faltaRecibir = o => ABIERTAS.includes(o.estado);
function visibles() {
const e = $('fEstado').value, q = $('fBuscar').value.trim().toLowerCase(), abiertas = $('fAbiertas').checked;
return OC.filter(o => (vista === 'todo' || o.sitio_portal === vista)
&& (!e || o.estado === e) && (!abiertas || faltaRecibir(o))
&& (!q || (o.folio + ' ' + (o.proveedor || '') + ' ' + (o.req_folio || '') + ' ' + (o.guia || '') + ' '
+ (o.renglones || []).map(r => r.descripcion + ' ' + (r.item_codigo || '')).join(' ')).toLowerCase().includes(q)));
}
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 }));
x.onclick = () => { vista = id; guardarURL(); pintar(); };
tabs.append(x);
};
b('todo', 'Todo', OC.filter(faltaRecibir).length);
ORDEN.filter(id => SITIOS.some(s => s.id === id)).forEach(id =>
b(id, corto(id), OC.filter(o => o.sitio_portal === id && faltaRecibir(o)).length));
}
function guardarURL() {
const u = new URL(location.href);
if (vista === 'todo') u.searchParams.delete('sitio'); else u.searchParams.set('sitio', vista);
history.replaceState(null, '', u);
}
// ---------------------------------------------------------------- pintar
function tarjeta(o) {
const [etq, cls] = ESTADOS[o.estado] || [o.estado, ''];
const c = el('article', { class: 'cmp-oc' + (o.estado === 'cancelada' ? ' cancelada' : '') });
const cab = el('header', { class: 'cmp-cab' });
cab.append(el('h2', {}, o.folio), el('span', { class: 'pill ' + cls, text: etq }));
if (o.sitio_portal) cab.append(el('span', { class: 'chip', text: corto(o.sitio_portal) }));
const venc = o.fecha_entrega && o.fecha_entrega < hoy() && faltaRecibir(o);
if (o.fecha_entrega) cab.append(el('span', { class: 'chip' + (venc ? ' tarde' : ''), text: (venc ? 'debió llegar ' : 'llega ') + fecha(o.fecha_entrega) }));
c.append(cab);
const meta = el('div', { class: 'cmp-meta' });
const d = (k, v) => { if (v) meta.append(el('span', {}, el('b', { text: k + ': ' }), String(v))); };
d('Proveedor', o.proveedor); d('Requisición', o.req_folio); d('Pidió', o.solicitante);
d('Paquetería', [o.paqueteria, o.guia].filter(Boolean).join(' '));
if (o.tarea_id) meta.append(el('a', { href: '/tableros/pendientes/?buscar=' + encodeURIComponent(o.folio), text: 'ver su pendiente' }));
c.append(meta);
const t = el('table', { class: 't cmp-ren' });
t.append(el('thead', {}, el('tr', {}, el('th', { text: '#' }), el('th', { text: 'Material' }),
el('th', { class: 'num', text: 'Pedido' }), el('th', { text: 'Ítem del inventario' }), el('th', { class: 'num', text: 'Llegó' }))));
const tb = el('tbody');
(o.renglones || []).forEach(r => {
const tr = el('tr', { class: r.recibido_en ? 'recibido' : '' });
tr.append(el('td', { class: 'num', text: r.n }));
const td = el('td', {}, el('span', { class: 'desc', text: r.descripcion || '—' }));
if (r.nota) td.append(el('div', { class: 'mini aviso-t', text: r.nota }));
tr.append(td);
tr.append(el('td', { class: 'num', text: num(r.cantidad) + ' ' + (r.unidad || '') }));
const tdI = el('td');
if (r.item_codigo) {
tdI.append(el('code', { text: r.item_codigo }));
if (r.envio_id && !r.recibido_en) tdI.append(el('span', { class: 'mini', text: ' en camino' }));
} else if (r.recibido_en) {
tdI.append(el('span', { class: 'mini', text: 'sin ítem (no se cuenta)' }));
} else {
const b = el('button', { class: 'sec cmp-ligar', type: 'button' }, 'Ligar…');
b.onclick = () => ligar(o, r);
tdI.append(b);
}
tr.append(tdI);
tr.append(el('td', { class: 'num' }, r.recibido_en ? num(r.recibido) : el('span', { class: 'mini', text: '—' })));
tb.append(tr);
});
t.append(tb);
c.append(t);
if (faltaRecibir(o)) {
const acc = el('div', { class: 'cmp-acc' });
const b = el('button', { type: 'button' }, 'Recibir…');
b.onclick = () => recibir(o);
acc.append(b);
const falta = (o.renglones || []).filter(r => !r.recibido_en).length;
if (falta) acc.append(el('span', { class: 'mini', text: falta === 1 ? 'falta 1 renglón por recibir' : 'faltan ' + falta + ' renglones por recibir' }));
c.append(acc);
} else if (o.recepcion_en) {
c.append(el('div', { class: 'cmp-acc mini', text: 'Recibida por ' + (o.recepcion_por || '—') + ' el ' + fecha(o.recepcion_en) }));
}
return c;
}
function pintar() {
pintarTabs();
cont.innerHTML = '';
const v = visibles();
$('cnt').textContent = v.length + (v.length === 1 ? ' orden' : ' órdenes');
if (!INTEG) {
cont.append(el('div', { class: 'info cmp-apagado' },
el('b', { text: 'La conexión con el cotizador todavía no está encendida. ' }),
'Falta el secreto compartido en el servidor (COTIZADOR_SECRETO) y en el cotizador (PORTAL_SECRETO). ',
'Mientras tanto no van a entrar órdenes nuevas.'));
}
if (!v.length) {
cont.append(el('div', { class: 'tb-vacio' },
el('p', { text: OC.length ? 'Ninguna orden con estos filtros.' : 'Todavía no llega ninguna orden de compra del cotizador.' }),
el('p', { class: 'mini', text: 'Las órdenes las levanta el cotizador: aquí solo se reciben.' })));
return;
}
v.sort((a, b) => (faltaRecibir(b) - faltaRecibir(a)) || String(a.fecha_entrega || '9999').localeCompare(String(b.fecha_entrega || '9999')) || a.folio.localeCompare(b.folio));
v.forEach(o => cont.append(tarjeta(o)));
}
// ---------------------------------------------------------------- ligar
function ligar(o, r) {
dlg.innerHTML = '';
const f = el('form', { method: 'dialog', class: 'form' });
f.append(el('h2', {}, 'Ligar el renglón ' + r.n + ' de ' + o.folio));
f.append(el('p', { class: 'mini', text: '«' + (r.descripcion || '') + '» — ' + num(r.cantidad) + ' ' + (r.unidad || '') }));
const sel = el('select', { id: 'lgItem', required: 'required' });
sel.append(el('option', { value: '', text: '— elige el ítem del inventario —' }));
ITEMS.forEach(i => sel.append(el('option', { value: i.codigo, text: i.codigo + ' · ' + i.descripcion + ' (' + i.unidad + ')' })));
const sug = ITEMS.find(i => (i.descripcion || '').toLowerCase() === String(r.descripcion || '').toLowerCase());
if (sug) sel.value = sug.codigo;
f.append(campo('Ítem', sel, 'Al ligarlo, si la orden ya va en camino, el material aparece en camino al sitio en ese momento.'));
const err = el('div', { class: 'msg' });
f.append(err);
const acc = el('div', { class: 'form-acc' });
const cancelar = el('button', { type: 'button', class: 'sec' }, 'Cancelar');
cancelar.onclick = () => dlg.close();
acc.append(cancelar, el('button', { type: 'submit' }, 'Ligar'));
f.append(acc);
f.onsubmit = async ev => {
ev.preventDefault();
try {
await api('/api/compras/ligar', json('POST', { oc: o.folio, n: r.n, item: sel.value }));
dlg.close(); mensaje('Renglón ' + r.n + ' de ' + o.folio + ' ligado a ' + sel.value + '.', true); cargar();
} catch (e) { err.className = 'msg err'; err.textContent = e.message; }
};
dlg.append(f); dlg.showModal();
}
// ---------------------------------------------------------------- recibir
function recibir(o) {
dlg.innerHTML = '';
const f = el('form', { method: 'dialog', class: 'form' });
f.append(el('h2', {}, 'Recibir ' + o.folio + ' en ' + corto(o.sitio_portal)));
f.append(el('p', { class: 'mini', text: 'Anota lo que llegó DE VERDAD. Si llegó menos, déjalo en lo que llegó: la orden queda recibida en parte y su pendiente sigue abierto.' }));
const t = el('table', { class: 't' });
t.append(el('thead', {}, el('tr', {}, el('th', { text: '#' }), el('th', { text: 'Material' }),
el('th', { class: 'num', text: 'Pedido' }), el('th', { class: 'num', text: 'Llegó' }))));
const tb = el('tbody');
const inputs = [];
(o.renglones || []).forEach(r => {
const tr = el('tr');
tr.append(el('td', { class: 'num', text: r.n }), el('td', { text: r.descripcion || '—' }),
el('td', { class: 'num', text: num(r.cantidad) + ' ' + (r.unidad || '') }));
if (r.recibido_en) {
tr.append(el('td', { class: 'num mini', text: 'ya: ' + num(r.recibido) }));
} else {
const i = el('input', { type: 'number', min: '0', step: '1', max: String(r.cantidad), value: String(r.cantidad), 'data-n': r.n });
inputs.push(i);
tr.append(el('td', { class: 'num' }, i));
}
tb.append(tr);
});
t.append(tb); f.append(t);
if (!inputs.length) f.append(el('p', { class: 'mini', text: 'Todos los renglones ya se recibieron.' }));
const err = el('div', { class: 'msg' });
f.append(err);
const acc = el('div', { class: 'form-acc' });
const cancelar = el('button', { type: 'button', class: 'sec' }, 'Cancelar');
cancelar.onclick = () => dlg.close();
acc.append(cancelar);
if (inputs.length) acc.append(el('button', { type: 'submit' }, 'Recibir'));
f.append(acc);
f.onsubmit = async ev => {
ev.preventDefault();
const renglones = inputs.map(i => ({ n: +i.dataset.n, recibido: +i.value }));
try {
const j = await api('/api/compras/recibir', json('POST', { oc: o.folio, renglones }));
dlg.close();
const e = (j.orden || {}).estado;
mensaje(e === 'recibida' ? o.folio + ' recibida completa: su pendiente se cerró.'
: o.folio + ' recibida en parte: su pendiente sigue abierto con lo que falta.', true);
if ((j.avisos || []).length) mensaje(j.avisos.join(' '), false);
cargar();
} catch (e) { err.className = 'msg err'; err.textContent = e.message; }
};
dlg.append(f); dlg.showModal();
}
// ---------------------------------------------------------------- cargar
async function cargar() {
try {
const j = await api('/api/compras');
OC = j.ordenes || []; ITEMS = j.items || []; SITIOS = j.sitios || []; USER = j.user || '';
INTEG = j.integracion !== false;
const abiertas = OC.filter(faltaRecibir).length;
$('meta').textContent = OC.length + ' órdenes · ' + abiertas + ' por recibir'
+ (USER ? ' · ' + USER : '');
pintar();
} catch (e) {
$('meta').textContent = 'no se pudo cargar';
mensaje('No se pudo cargar Compras: ' + e.message, false);
}
}
const u = new URL(location.href);
const s = (u.searchParams.get('sitio') || '').toLowerCase();
if (s) vista = { lct: 'lzc', ver: 'icave', zlo: 'timsa', ese: 'eit', veracruz: 'icave', manzanillo: 'timsa', ensenada: 'eit', queretaro: 'qro' }[s] || s;
['fEstado', 'fBuscar', 'fAbiertas'].forEach(id => { $(id).oninput = pintar; $(id).onchange = pintar; });
dlg.addEventListener('click', ev => { if (ev.target === dlg) dlg.close(); });
cargar();
})();