hbox-docs/docs/tableros/produccion.md

233 lines
17 KiB
Markdown

---
title: "Producción"
description: "El pedido de 570 HBOX: plan contra lo real, embudo por etapa, bloqueos e inventario."
template: tablero.html
hide:
- toc
---
<div class="tb tb-produccion">
<header class="tb-cab">
<div class="tb-cab__t"><h1>Producción</h1><span class="meta" id="meta">Cargando…</span></div>
<div class="tb-cab__acc"><button type="button" class="sec" id="refrescar"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4"/></svg>Actualizar</button></div>
</header>
<details class="info tb-como">
<summary>Cómo se usa</summary>
<ol>
<li>Arriba: el <b>plan</b> (cuántas unidades de cada versión y para cuándo) contra lo <b>real</b>, que sale solo de las actas enviadas. Si una unidad no tiene acta, aquí no existe.</li>
<li>El <b>embudo</b> cuenta series distintas por etapa: tarjeta probada → módulos → integración → ensamble → prueba final → configuración → envío → instalación. Toca una etapa para ir a su acta.</li>
<li><b>Bloqueos</b>: pendientes en Bloqueado de las pistas Producción, Mecánica y Compras. Resuélvelos en el <a href="/tableros/pendientes/">tablero de pendientes</a>.</li>
<li><b>Alertas de inventario</b>: ítems por debajo del mínimo. Registra las entradas al llegar el material.</li>
<li>Solo el administrador edita el plan (botón «Editar plan»). Las fechas son objetivo, no promesa.</li>
</ol>
</details>
<div id="cont"></div>
<dialog id="dlgPlan"></dialog>
</div>
<script>
(function () {
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 esPrueba = s => String(s || '').toUpperCase().includes('PRUEBA');
const esUnidad = s => /^HBOX/i.test(String(s || ''));
const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' };
const FORM = { 'prueba-v5': '/formularios/v5.html', 'prueba-v4': '/formularios/v4.html', 'modulo-ard': '/formularios/ardusimple.html', 'modulo-4g': '/formularios/modem4g.html',
'integracion': '/formularios/integracion.html', 'ensamble': '/formularios/ensamble.html', 'reflasheo': '/formularios/reflasheo.html',
'prueba-ethernet-serie': '/formularios/ethernet-serie.html', 'envio': '/formularios/envio.html', 'instalacion-campo': '/formularios/instalacion.html' };
const PISTAS = { produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', ingenieria: 'Ingeniería', campo: 'Campo' };
const cont = document.getElementById('cont'), meta = document.getElementById('meta');
let PLAN = null, ADMIN = false, EQUIPO = {};
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;
}
function seriesEtapa(et, actas) {
const s = new Set();
actas.forEach(a => {
if (!et.tipos.includes(a.tipo) || esPrueba(a.serie)) return;
if (et.ok && a.resultado === 'FALLA') return;
if (et.solo_unidades && !esUnidad(a.serie)) return;
s.add(a.serie);
});
return s;
}
function render(plan, actas, series, danos, bloqueos, inv) {
cont.innerHTML = '';
const reales = actas.filter(a => !esPrueba(a.serie));
const ens = reales.filter(a => a.tipo === 'ensamble' && a.resultado !== 'FALLA');
const unidades = new Set(ens.map(a => a.serie));
const enviadas = new Set(reales.filter(a => a.tipo === 'envio' && esUnidad(a.serie)).map(a => a.serie));
const instaladas = new Set(reales.filter(a => a.tipo === 'instalacion-campo' && a.resultado !== 'FALLA').map(a => a.serie));
const total = plan.versiones.reduce((n, v) => n + (v.objetivo || 0), 0);
// ---- KPIs
const kp = el('div', { class: 'kpis' });
const kpi = (n, l, cls) => kp.append(el('div', { class: 'kpi ' + (cls || '') }, el('div', { class: 'n', text: String(n) }), el('div', { class: 'l', text: l })));
kpi(plan.pedido_total || total, 'Pedido total');
kpi(unidades.size, 'Unidades HBOX ensambladas (acta OK)', 'bien');
kpi(enviadas.size, 'Unidades enviadas');
kpi(instaladas.size, 'Instaladas en sitio');
kpi(danos.length, 'Series dañadas o de baja', danos.length ? 'mal' : '');
kpi(bloqueos.length, 'Bloqueos abiertos', bloqueos.length ? 'mal' : '');
kpi(inv.length, 'Alertas de inventario', inv.length ? 'mal' : '');
cont.append(kp);
// ---- plan vs real por versión
const b1 = el('section', { class: 'bloque' });
const cab = el('div', { style: 'display:flex;justify-content:space-between;gap:10px;align-items:baseline;flex-wrap:wrap' });
cab.append(el('h2', { text: 'Plan por versión vs. real' }));
if (ADMIN) { const b = el('button', { class: 'sec', text: 'Editar plan', style: 'padding:6px 12px' }); b.addEventListener('click', editarPlan); cab.append(b); }
b1.append(cab);
b1.append(el('p', { class: 'sub', html: esc(plan.nota || '') + (plan.actualizado_en ? ' · plan actualizado por <b>' + esc(plan.actualizado_por) + '</b> el ' + esc(fmt(plan.actualizado_en)) : (plan._default ? ' · <b>plan por omisión</b>: aún no se ha guardado (siembra o «Editar plan»)' : '')) }));
const grid = el('div', { style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px' });
plan.versiones.forEach(v => {
const pref = (v.serie_prefijo || '').toUpperCase();
const hechas = pref ? ens.filter(a => String((a.componentes || {}).einar || '').toUpperCase().startsWith(pref + '-')).length : 0;
const pct = v.objetivo ? Math.min(100, Math.round(100 * hechas / v.objetivo)) : 0;
const c = el('div', { class: 'etapa', style: 'text-align:left' });
c.append(el('div', { style: 'display:flex;justify-content:space-between;align-items:center' }, el('b', { text: 'EinarBoard ' + v.version }), el('span', { class: BADGE[v.version] || 'pill pend', text: v.version })));
c.append(el('div', { class: 'n', style: 'font-size:22px;margin-top:4px', html: hechas + ' <span style="font-size:13px;color:var(--tinta2)">/ ' + v.objetivo + ' unidades</span>' }));
const pr = el('div', { class: 'prog' }); pr.append(el('span', { style: 'width:' + pct + '%' })); c.append(pr);
c.append(el('div', { class: 'obj', html: '<span>' + pct + ' %</span><span>' + (v.fecha ? 'objetivo ' + esc(v.fecha) : 'sin fecha objetivo') + '</span>' }));
if (v.nota) c.append(el('div', { class: 'l', style: 'text-align:left;margin-top:5px', text: v.nota }));
if (pref) c.append(el('div', { class: 'f', style: 'text-align:left', text: 'cuenta ensambles OK cuya tarjeta sea ' + pref + '-nn' }));
grid.append(c);
});
// lote V5.5 actual (fuera del pedido)
const v55 = ens.filter(a => /^V5-\d/i.test(String((a.componentes || {}).einar || ''))).length;
const c55 = el('div', { class: 'etapa', style: 'text-align:left;opacity:.9' });
c55.append(el('div', { style: 'display:flex;justify-content:space-between;align-items:center' }, el('b', { text: 'Lote actual V5.5' }), el('span', { class: 'badge-v55', text: 'V5.5' })));
c55.append(el('div', { class: 'n', style: 'font-size:22px;margin-top:4px', html: v55 + ' <span style="font-size:13px;color:var(--tinta2)">unidades ensambladas</span>' }));
c55.append(el('div', { class: 'l', style: 'text-align:left;margin-top:5px', text: 'Tarjetas V5-nn (15 fabricadas el 22-ago). No forman parte del pedido de 570; se cuentan aparte.' }));
grid.append(c55);
b1.append(grid);
if (total !== (plan.pedido_total || total)) b1.append(el('p', { class: 'sub', style: 'margin-top:8px;color:var(--falla)', text: 'Aviso: la suma de objetivos (' + total + ') no coincide con el pedido total (' + plan.pedido_total + ').' }));
cont.append(b1);
// ---- embudo
const b2 = el('section', { class: 'bloque' });
b2.append(el('h2', { text: 'Embudo de producción (series distintas con acta)' }), el('p', { class: 'sub', text: 'Cada etapa cuenta las series que ya tienen acta de ese tipo. Las tres primeras son por pieza (tarjeta, módulo, pareja); de ensamble en adelante, por unidad HBOX. Toca una etapa para abrir su acta.' }));
const emb = el('div', { class: 'embudo' });
(plan.etapas || []).forEach(et => {
const s = seriesEtapa(et, reales);
const a = el('a', { class: 'etapa' + (s.size ? '' : ' sin'), href: FORM[et.tipos[0]] || '/actas/', style: 'text-decoration:none;color:inherit' });
a.append(el('div', { class: 'n', text: String(s.size) }), el('div', { class: 'l', text: et.nombre }));
a.append(el('div', { class: 'f', text: et.nota || ('acta: ' + et.tipos.join(', ')) }));
emb.append(a);
});
b2.append(emb);
cont.append(b2);
// ---- bloqueos + inventario
const dos = el('div', { style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px' });
const b3 = el('section', { class: 'bloque bloqueos', style: 'margin:0' });
b3.append(el('h2', { text: 'Bloqueos (Producción · Mecánica · Compras)' }), el('p', { class: 'sub', text: 'Pendientes en la columna Bloqueado. Quien lo destrabe, que lo mueva.' }));
if (!bloqueos.length) b3.append(el('div', { class: 'vacio', text: 'Sin bloqueos registrados.' }));
bloqueos.forEach(t => {
const c = el('div', { class: 'card p-' + t.pista + (t.vencida ? ' vencida' : '') });
const sup = el('div', { class: 'sup' }); sup.append(el('span', { class: 'pista-p' }), el('span', { text: PISTAS[t.pista] || t.pista }));
if (BADGE[t.version_tarjeta]) sup.append(el('span', { class: BADGE[t.version_tarjeta] + ' ver-t', text: t.version_tarjeta }));
c.append(sup);
c.append(el('div', { class: 'tit', html: '<a href="/tableros/pendientes/?id=' + encodeURIComponent(t.id) + '">' + esc(t.titulo) + '</a>' }));
if (t.descripcion) c.append(el('div', { style: 'font-size:13px;color:var(--tinta2)', text: t.descripcion.length > 160 ? t.descripcion.slice(0, 159) + '…' : t.descripcion }));
const pie = el('div', { class: 'pie' });
pie.append(t.responsable ? Kanban.avatares(t) : el('span', { class: 'vacio', text: 'Sin responsable' }));
const fd = Kanban.fechaDe(t); if (fd) pie.append(el('span', { class: 'fecha ' + fd.cls, text: fd.texto }));
c.append(pie);
b3.append(c);
});
dos.append(b3);
const b4 = el('section', { class: 'bloque', style: 'margin:0' });
b4.append(el('h2', { text: 'Alertas de inventario' }), el('p', { class: 'sub', html: 'Ítems con stock por debajo del mínimo. <a href="/tableros/inventario/">Abrir inventario →</a>' }));
if (!inv.length) b4.append(el('div', { class: 'vacio', text: 'Sin alertas: todo por encima del mínimo (o sin mínimo definido).' }));
else {
const t = el('table', { class: 't' }); t.innerHTML = '<tr><th>Código</th><th>Descripción</th><th>Stock</th><th>Mínimo</th></tr>';
inv.forEach(i => { const tr = el('tr'); tr.innerHTML = '<td><b>' + esc(i.codigo) + '</b></td><td>' + esc(i.descripcion) + '</td><td style="color:var(--falla);font-weight:700">' + esc(i.stock) + ' ' + esc(i.unidad) + '</td><td>' + esc(i.minimo) + '</td>'; t.append(tr); });
b4.append(el('div', { class: 'tblwrap' }, t));
}
dos.append(b4);
cont.append(dos);
// ---- daños
if (danos.length) {
const b5 = el('section', { class: 'bloque' });
b5.append(el('h2', { text: 'Series con marca de daño o baja' }), el('p', { class: 'sub', text: 'Lo último registrado en un acta de daño manda. «Reparada» levanta la marca.' }));
const t = el('table', { class: 't' }); t.innerHTML = '<tr><th>Serie</th><th>Marca</th><th>Qué pasó</th><th>Disposición</th><th>Fecha</th></tr>';
danos.forEach(d => { const a = d.acta || {}; const tr = el('tr'); tr.innerHTML = '<td><a href="/tableros/unidad/?serie=' + encodeURIComponent(d.serie) + '"><b>' + esc(d.serie) + '</b></a></td><td><span class="pill ' + (d.marca === 'BAJA' ? 'falla' : 'aviso') + '">' + esc(d.marca === 'BAJA' ? 'BAJA' : 'DAÑADA') + '</span></td><td>' + esc(a.clasificacion || '') + (a.resumen ? ' — ' + esc(a.resumen) : '') + '</td><td>' + esc(a.accion || '') + '</td><td>' + esc(a.fecha || fmt(a.recibido)) + '</td>'; t.append(tr); });
b5.append(el('div', { class: 'tblwrap' }, t));
cont.append(b5);
}
}
// ---- edición del plan (solo admin)
function editarPlan() {
const dlg = document.getElementById('dlgPlan');
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: 'Editar plan de producción' }));
const x = el('button', { type: 'button', 'aria-label': 'Cerrar', html: Kanban.icono('cerrar') }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
const fTot = el('input', { type: 'number', min: '0', value: PLAN.pedido_total || 0 });
const fNota = el('input', { type: 'text', value: PLAN.nota || '', maxlength: '500' });
const d1 = el('div', { class: 'dos-col' }); d1.append(campo('Pedido total (unidades)', fTot), campo('Nota', fNota)); cuerpo.append(d1);
cuerpo.append(el('label', { text: 'Versiones: objetivo, nota, prefijo de serie de la tarjeta (para contar ensambles) y fecha objetivo' }));
const filas = el('div');
function fila(v) {
const f = el('div', { style: 'display:grid;grid-template-columns:70px 80px 1fr 70px 140px auto;gap:6px;margin-bottom:6px;align-items:center' });
f.append(el('input', { type: 'text', value: v.version || '', placeholder: 'V5.6', maxlength: '10' }),
el('input', { type: 'number', min: '0', value: v.objetivo || 0 }),
el('input', { type: 'text', value: v.nota || '', placeholder: 'nota' }),
el('input', { type: 'text', value: v.serie_prefijo || '', placeholder: 'V56', maxlength: '10' }),
el('input', { type: 'date', value: v.fecha || '' }));
const q = el('button', { type: 'button', class: 'sec', 'aria-label': 'Quitar la versión', html: Kanban.icono('cerrar'), style: 'padding:6px 9px' }); q.addEventListener('click', () => f.remove()); f.append(q);
filas.append(f);
}
(PLAN.versiones || []).forEach(fila); cuerpo.append(filas);
const add = el('button', { type: 'button', class: 'sec', html: Kanban.icono('mas') + 'Versión', style: 'padding:6px 10px;font-size:13px' }); add.addEventListener('click', () => fila({})); cuerpo.append(add);
cuerpo.append(el('p', { class: 'sub', style: 'margin-top:10px', text: 'El embudo (etapas) se conserva como está; se edita por API si hace falta.' }));
const m = el('div', { class: 'msg' }); cuerpo.append(m);
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' }); cancelar.addEventListener('click', () => dlg.close());
const guardar = el('button', { type: 'button', text: 'Guardar plan' });
guardar.addEventListener('click', async () => {
const versiones = [...filas.children].map(f => ({ version: f.children[0].value.trim(), objetivo: +f.children[1].value || 0, nota: f.children[2].value.trim(), serie_prefijo: f.children[3].value.trim(), fecha: f.children[4].value || null })).filter(v => v.version);
if (!versiones.length) { m.className = 'msg err'; m.textContent = 'Deja al menos una versión.'; return; }
guardar.disabled = true;
try {
await api('/api/plan', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pedido_total: +fTot.value || 0, nota: fNota.value.trim(), versiones }) });
dlg.close(); cargar();
} catch (e) { m.className = 'msg err'; m.textContent = e.message; }
guardar.disabled = false;
});
pie.append(cancelar, guardar);
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
}
function campo(l, i) { const d = el('div'); d.append(el('label', { text: l }), i); return d; }
async function cargar() {
meta.textContent = 'Actualizando…';
try {
const [p, a, s, d, t, i] = await Promise.all([
api('/api/plan'), api('/api/actas'), api('/api/series').catch(() => ({ series: [] })), api('/api/danos').catch(() => ({ series: [] })),
api('/api/tareas?estado=bloqueado').catch(() => ({ tareas: [] })), api('/api/inventario?alertas=1').catch(() => ({ items: [] }))]);
PLAN = p.plan; ADMIN = !!p.admin; EQUIPO = Kanban.usarEquipo(t.equipo);
const bloqueos = (t.tareas || []).filter(x => ['produccion', 'mecanica', 'compras'].includes(x.pista));
render(PLAN, a.actas || [], s.series || [], d.series || [], bloqueos, i.items || []);
meta.textContent = 'Actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }) + ' · ' + (a.total || 0) + ' actas · ' + (s.series || []).length + ' series con registro';
} catch (e) { cont.innerHTML = ''; cont.append(el('div', { class: 'err', text: 'No se pudo cargar el tablero: ' + e.message })); meta.textContent = 'Error'; }
}
document.getElementById('refrescar').addEventListener('click', cargar);
cargar();
setInterval(cargar, 60000);
})();
</script>