265 lines
14 KiB
JavaScript
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();
|
|
})();
|