/* 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(); })();