/* Página de inicio de Drogheda (21-oct-2026). Llena docs/index.md con: - docs/datos/proyecto.json: fase, semáforo, ruta a producción, próximas fechas, sitios con su color (lo actualiza Claude o Leo); - /api/hoy: el enfoque de la semana (lo escriben Leo y Adriana con «Editar»); - /api/tareas (tablero), /api/actividad, /api/plan + /api/actas (unidades ensambladas), /api/equipo y /api/sitio/unidades; - /datos/documentos.json: los documentos con fecha (lo genera hooks/documentos.py al construir). Si algo del servidor no responde, esa parte lo dice y el resto de la página sigue. */ (function () { 'use strict'; const MES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']; const MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre']; const DIAS = ['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado']; const AREAS = ['direccion', 'operaciones', 'layout', 'pruebas', 'fabricacion', 'campo', 'ingenieria', 'software', 'produccion', 'asistente']; const BADGE = { 'V5.5': 'v55', 'V5.6': 'v56', 'V5.7': 'v57', 'v6': 'v6' }; const PLAN_RESPALDO = { pedido_total: 570, versiones: [ { version: 'V5.6', objetivo: 113, serie_prefijo: 'V56' }, { version: 'V5.7', objetivo: 327, serie_prefijo: 'V57' }, { version: 'v6', objetivo: 130, serie_prefijo: 'V6' }] }; const LAPIZ = 'M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16zM13.5 6.5l4 4'; const DOC = 'M14 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5zM14 3.5v5h5M9 13h6M9 16.5h4'; const $ = id => document.getElementById(id); const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (p === 'text') e.textContent = a[p]; else if (p === 'html') e.innerHTML = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(typeof c === 'string' ? document.createTextNode(c) : c)); return e; }; const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const svg = (d, cls) => ''; function hoyISO(delta) { const d = new Date(Date.now() + (delta || 0) * 86400000); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 10); } const fechaCorta = f => { const m = String(f || '').match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? (+m[3]) + ' ' + MES[+m[2] - 1] : ''; }; const plural = (n, uno, varios) => n + ' ' + (n === 1 ? uno : varios); const esPrueba = s => String(s || '').toUpperCase().includes('PRUEBA'); async function json(url, opts) { const r = await fetch(url, Object.assign({ cache: 'no-store', credentials: 'same-origin' }, opts || {})); let j = null; try { j = await r.json(); } catch (e) { /* sin JSON */ } if (!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ('HTTP ' + r.status)); return j; } function falla(cont, que, err) { cont.innerHTML = ''; cont.append(el('p', { class: 'vacio', text: 'No se pudo cargar ' + que + (err && err.message ? ' (' + err.message + ')' : '') + '.' })); } // texto del enfoque: **negritas**, `código` y [ligas](/ruta/ o https://…); lo demás se escapa function enLinea(s) { return esc(s) .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\[([^\]]+)\]\(((?:https?:\/\/|\/)[^)\s]*)\)/g, '$1'); } const equipo = () => (window.Kanban && window.Kanban.equipo) || {}; const nombreCorto = id => (window.Kanban && window.Kanban.nombreCorto) ? window.Kanban.nombreCorto(id) : ((equipo()[id] || {}).nombre || id || '').split(' ')[0]; const lista = xs => xs.length <= 1 ? (xs[0] || '') : xs.slice(0, -1).join(', ') + ' y ' + xs[xs.length - 1]; const avatares = ids => { const g = el('span', { class: 'avs' }); (ids || []).forEach(id => g.append(window.Kanban.avatar(id))); return g; }; // ---------------------------------------------------------------- semáforo del proyecto (22-oct) const COLOR = { rojo: 'Rojo', amarillo: 'Amarillo', verde: 'Verde' }; function pintarSemaforo(P) { const S = P.semaforo, sec = $('in-semaforo'); if (!sec || !S || !COLOR[S.color]) return; sec.className = 'in-semaforo sem-' + S.color; sec.innerHTML = ''; const luz = el('div', { class: 'in-sem-luz', role: 'img', 'aria-label': 'Semáforo en ' + COLOR[S.color].toLowerCase() }); ['rojo', 'amarillo', 'verde'].forEach(c => luz.append(el('span', { class: 'l-' + c + (c === S.color ? ' on' : '') }))); const c = el('div', { class: 'in-sem-c' }); const cab = el('div', { class: 'in-sem-cab' }); cab.append(el('span', { text: 'Semáforo del proyecto' })); if (S.actualizado) cab.append(el('span', { class: 'in-gris', text: (S.quien ? S.quien + ', ' : '') + fechaLarga(S.actualizado) })); c.append(cab, el('h2', { id: 'sem-h', text: S.titulo || COLOR[S.color] })); if (S.texto) c.append(el('p', { class: 'in-sem-texto', html: enLinea(S.texto) })); if ((S.causas || []).length) { const ul = el('ul', { class: 'in-sem-causas' }); S.causas.forEach(x => ul.append(el('li', { html: enLinea(x) }))); c.append(ul); } if (S.destraba) c.append(el('p', { class: 'in-sem-destraba', html: 'Qué lo destraba: ' + enLinea(S.destraba) })); sec.append(luz, c); sec.hidden = false; } // ---------------------------------------------------------------- arriba: fase, pedido, hito, equipo function pintarFase(P) { const d = $('in-fase'); d.innerHTML = ''; d.append(el('span', { class: 'in-chip', text: P.fase.nombre }), ' ', el('span', { class: 'in-gris', text: P.fase.texto })); } function pintarPedido(P, plan, actas) { const d = $('in-pedido'); d.innerHTML = ''; const vs = (plan && plan.versiones && plan.versiones.length) ? plan.versiones : PLAN_RESPALDO.versiones; const total = (plan && plan.pedido_total) || vs.reduce((n, v) => n + (v.objetivo || 0), 0); const ens = (actas || []).filter(a => a.tipo === 'ensamble' && a.resultado !== 'FALLA' && !esPrueba(a.serie)); const hechas = v => { const pref = String(v.serie_prefijo || '').toUpperCase(); return pref ? ens.filter(a => String((a.componentes || {}).einar || '').toUpperCase().startsWith(pref + '-')).length : 0; }; const cuenta = vs.reduce((n, v) => n + hechas(v), 0); const linea = el('div', { class: 'in-pedido-l' }); linea.append(el('b', { text: total + ' HBOX' }), ' ', el('span', { class: 'in-gris', text: (actas ? plural(cuenta, 'ensamblada', 'ensambladas') + '; ' : '') + (P.pedido_nota || '') })); const barra = el('div', { class: 'in-barra', role: 'img', 'aria-label': vs.map(v => v.objetivo + ' ' + v.version).join(', ') }); const leyenda = el('div', { class: 'in-barra-l' }); vs.forEach(v => { const cls = BADGE[v.version] || 'v4'; const seg = el('span', { class: 'in-seg s-' + cls, style: 'flex:' + (v.objetivo || 1) }); const pct = v.objetivo ? Math.min(100, Math.round(100 * hechas(v) / v.objetivo)) : 0; seg.append(el('span', { style: 'width:' + pct + '%' })); barra.append(seg); leyenda.append(el('span', { class: 's-' + cls, style: 'flex:' + (v.objetivo || 1), text: v.objetivo + ' ' + v.version })); }); d.append(linea, barra, leyenda); d.append(el('a', { class: 'in-mini', href: '/tableros/produccion/', text: 'Avance contra el plan' })); } function hitosVigentes(P) { const hoy = hoyISO(); return (P.hitos || []).filter(h => !h.fecha || (h.fin || h.fecha) >= hoy) .sort((a, b) => ((a.fecha ? 1 : 0) - (b.fecha ? 1 : 0)) || String(a.fecha).localeCompare(String(b.fecha))); } function pintarHito(P) { const d = $('in-hito'); d.innerHTML = ''; const h = hitosVigentes(P)[0]; if (!h) { d.append(el('span', { class: 'vacio', text: 'Sin hitos por delante.' })); return; } d.append(el('span', { text: h.texto }), ' ', el('span', { class: 'in-gris', text: h.cuando })); } function pintarEquipo(P) { const d = $('in-equipo'); d.innerHTML = ''; const activos = Object.entries(equipo()).filter(([, m]) => m.activo !== false && m.nombre) .sort((a, b) => ((AREAS.indexOf(a[1].area) + 1 || 99) - (AREAS.indexOf(b[1].area) + 1 || 99)) || a[1].nombre.localeCompare(b[1].nombre)); const caras = el('a', { class: 'avs in-caras', href: '/tableros/equipo/', title: 'Equipo: ' + activos.map(([, m]) => m.nombre).join(', ') }); activos.slice(0, 6).forEach(([id]) => caras.append(window.Kanban.avatar(id))); if (activos.length > 6) caras.append(el('span', { class: 'av libre', text: '+' + (activos.length - 6) })); d.append(caras); if (P.gestion && P.gestion.texto) d.append(' ', el('span', { class: 'in-gris', text: P.gestion.texto })); } // ---------------------------------------------------------------- enfoque de la semana const ORDEN_EDITORES = ['leo', 'adriana']; function fechaLarga(iso) { const f = String(iso || '').slice(0, 10); if (!f) return ''; const d = new Date(f + 'T12:00:00'); return DIAS[d.getDay()] + ' ' + d.getDate() + ' de ' + MESES[d.getMonth()]; } function cuandoEditado(iso) { const f = String(iso || '').slice(0, 10), h = String(iso || '').slice(11, 16); if (f === hoyISO()) return 'hoy a las ' + h; if (f === hoyISO(-1)) return 'ayer a las ' + h; return 'el ' + fechaLarga(iso) + (h ? ' a las ' + h : ''); } function pintarEnfoque(P, r) { const c = document.querySelector('#in-enfoque .in-enfoque-c'); c.innerHTML = ''; const h = r && r.enfoque, e = h || P.enfoque_inicial || { titulo: 'Sin enfoque escrito todavía.', puntos: [] }; const editores = ((r && r.editores) || ORDEN_EDITORES).slice().sort((a, b) => ((ORDEN_EDITORES.indexOf(a) + 1 || 9) - (ORDEN_EDITORES.indexOf(b) + 1 || 9))); const cab = el('div', { class: 'in-enfoque-cab' }); cab.append(el('span', { class: 'in-enfoque-cuando', text: 'Enfoque de la semana' + (h ? '. Escrito el ' + fechaLarga(h.editado_en) : '') })); if (r && r.puede_editar) { const b = el('button', { type: 'button', class: 'sec in-editar', html: svg(LAPIZ) + 'Editar' }); b.addEventListener('click', () => editarEnfoque(P, r, e)); cab.append(b); } c.append(cab, el('h2', { id: 'enfoque-h', text: e.titulo })); if ((e.puntos || []).length) { const ul = el('ul'); e.puntos.forEach(p => ul.append(el('li', { html: enLinea(p) }))); c.append(ul); } const pie = el('p', { class: 'in-enfoque-pie' }); if (h) pie.textContent = 'Lo escriben ' + lista(editores.map(nombreCorto)) + '. Editado ' + cuandoEditado(h.editado_en) + ' por ' + nombreCorto(h.editado_por) + '.'; else if (P.enfoque_inicial && P.enfoque_inicial.nota) pie.innerHTML = enLinea(P.enfoque_inicial.nota); if (!r) pie.append(el('span', { class: 'in-gris', text: ' (El servidor no respondió: este es el texto inicial.)' })); c.append(pie); } function editarEnfoque(P, r, e) { const c = document.querySelector('#in-enfoque .in-enfoque-c'); c.innerHTML = ''; const f = el('form', { class: 'in-enfoque-ed' }); const tit = el('input', { type: 'text', maxlength: '160', id: 'enf-tit', required: '' }); tit.value = e.titulo || ''; const pts = el('textarea', { rows: '6', id: 'enf-pts' }); pts.value = (e.puntos || []).join('\n'); const msg = el('div', { class: 'msg', role: 'status' }); const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' }); const guardar = el('button', { type: 'submit', text: 'Guardar' }); f.append(el('div', { class: 'in-enfoque-cab' }, el('span', { class: 'in-enfoque-cuando', text: 'Editar el enfoque de la semana' })), el('label', { for: 'enf-tit', text: 'Frase de la semana (una línea)' }), tit, el('label', { for: 'enf-pts', text: 'Puntos: uno por línea, hasta 8' }), pts, el('p', { class: 'ayuda', html: 'Se puede usar **negritas**, `código` y [ligas](/hardware/…/). Se guarda con tu nombre y la hora; lo anterior queda en el historial del servidor.' }), msg, el('div', { class: 'in-enfoque-botones' }, cancelar, guardar)); c.append(f); tit.focus(); cancelar.addEventListener('click', () => pintarEnfoque(P, r)); f.addEventListener('submit', async ev => { ev.preventDefault(); const d = { titulo: tit.value.trim(), puntos: pts.value.split('\n').map(x => x.trim()).filter(Boolean) }; if (!d.titulo) { msg.className = 'msg err'; msg.textContent = 'Escribe la frase de la semana.'; return; } if (d.puntos.length > 8) { msg.className = 'msg err'; msg.textContent = 'Son ' + d.puntos.length + ' puntos; deja 8 o menos.'; return; } guardar.disabled = true; try { const j = await json('/api/hoy', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d) }); r = Object.assign({}, r, { enfoque: j.enfoque }); pintarEnfoque(P, r); } catch (err) { msg.className = 'msg err'; msg.textContent = 'No se guardó: ' + err.message; guardar.disabled = false; } }); } // ---------------------------------------------------------------- próximas fechas function pintarFechas(P, tareas) { const cont = $('in-hitos'); cont.innerHTML = ''; const hoy = hoyISO(), tope = hoyISO(10); const filas = hitosVigentes(P).map(h => ({ fecha: h.fecha, cuando: h.cuando, texto: h.texto, quien: h.quien || [], hito: true })); (tareas || []).forEach(t => { if (t.estado === 'hecho' || !t.fecha_limite || t.fecha_limite < hoy || t.fecha_limite > tope) return; filas.push({ fecha: t.fecha_limite, cuando: t.fecha_limite === hoy ? 'Hoy' : fechaCorta(t.fecha_limite), texto: t.titulo, quien: window.Kanban.idsResp(t), id: t.id }); }); filas.sort((a, b) => ((a.fecha ? 1 : 0) - (b.fecha ? 1 : 0)) || String(a.fecha).localeCompare(String(b.fecha))); if (!filas.length) { cont.append(el('p', { class: 'vacio', text: 'Nada con fecha en los próximos 10 días.' })); return; } const ol = el('ol', { class: 'in-fechas' }); filas.slice(0, 7).forEach(x => { const li = el('li', { class: x.hito ? 'es-hito' : 'es-pendiente' }); li.append(el('span', { class: 'in-f' + (x.cuando === 'Hoy' ? ' hoy' : '') + (x.fecha ? '' : ' sin'), text: x.cuando })); li.append(x.id ? el('a', { class: 'in-t', href: '/tableros/pendientes/?id=' + encodeURIComponent(x.id), text: x.texto }) : el('span', { class: 'in-t', text: x.texto })); li.append(avatares(x.quien)); ol.append(li); }); cont.append(ol); } // ---------------------------------------------------------------- documentos recientes async function pintarDocs() { const cont = $('in-docs'); try { const j = await json('/datos/documentos.json'); cont.innerHTML = ''; const docs = (j.documentos || []).slice(0, 6); if (!docs.length) { cont.append(el('p', { class: 'vacio', text: 'Sin documentos con fecha.' })); return; } const ol = el('ol', { class: 'in-docs' }); docs.forEach(d => ol.append(el('li', null, el('a', { href: d.url, html: svg(DOC) + '' + esc(d.titulo) + '' }), el('span', { class: 'in-f', text: fechaCorta(d.fecha) })))); cont.append(ol); } catch (err) { falla(cont, 'la lista de documentos', err); } } // ---------------------------------------------------------------- dónde estamos async function pintarMapa(P) { const cont = $('in-mapa'), ul = $('in-sitios'); try { const r = await fetch('/img/mexico.svg', { cache: 'force-cache' }); if (!r.ok) throw new Error('HTTP ' + r.status); const doc = new DOMParser().parseFromString(await r.text(), 'image/svg+xml'); const mapa = doc.documentElement; if (mapa.nodeName.toLowerCase() !== 'svg') throw new Error('mapa inválido'); mapa.removeAttribute('width'); mapa.removeAttribute('height'); mapa.setAttribute('role', 'img'); mapa.setAttribute('aria-label', 'Mapa de México con los puertos y la oficina de Querétaro'); cont.innerHTML = ''; cont.append(document.importNode(mapa, true)); } catch (err) { cont.classList.add('sin-mapa'); } // cada sitio con su color (rojo / amarillo / verde) y liga a sus pendientes (22-oct) const aPend = id => '/tableros/pendientes/?sitio=' + encodeURIComponent(id); const est = s => COLOR[s.estado] ? ' est-' + s.estado : ''; const pin = (s, oficina) => { const p = el(s.id ? 'a' : 'span', { class: 'in-pin' + (oficina ? ' oficina' : '') + est(s) + ' et-' + (s.etiqueta || 'der'), style: 'left:' + s.x + '%;top:' + s.y + '%' }); if (s.id) { p.setAttribute('href', aPend(s.id)); p.setAttribute('title', s.nombre + ' · ' + s.ciudad + (COLOR[s.estado] ? ' — ' + COLOR[s.estado].toLowerCase() + (s.nota ? ': ' + s.nota : '') : '') + '. Ver sus pendientes'); } p.append(el('span', { class: 'in-pin-p', 'aria-hidden': 'true' }), el('span', { class: 'in-pin-t', text: s.nombre })); return p; }; (P.sitios || []).forEach(s => cont.append(pin(s))); if (P.oficina) cont.append(pin(Object.assign({ etiqueta: 'arriba' }, P.oficina), true)); ul.innerHTML = ''; const fila = (s, oficina) => { const li = el('li', { class: est(s).trim() }); const a = el('a', { href: s.id ? aPend(s.id) : '#', title: 'Pendientes de ' + s.nombre }); a.append(el('span', { class: 'in-sit-luz', 'aria-hidden': 'true' }), el('b', { text: oficina ? s.ciudad : s.nombre }), ' ' + (oficina ? 'fabricación e ingeniería' : s.ciudad)); if (COLOR[s.estado]) a.append(el('span', { class: 'vh', text: ' (' + COLOR[s.estado].toLowerCase() + ')' })); const estado = el('span', { class: 'in-gris', text: s.nota || '' }); li.append(a, estado); if (s.meta) li.append(el('a', { class: 'in-sit-meta', href: '/tableros/instalaciones/?sitio=' + encodeURIComponent(s.id), title: 'Instalaciones de ' + s.nombre, text: 'Meta: ' + s.meta + ' · ver instalaciones' })); ul.append(li); return estado; }; // Conteos por sitio: los mismos del tablero de Instalaciones (solo las probadas cuentan para la meta). // Si el servicio todavía no tiene /api/instalaciones (falta reiniciar actas-hbox), se usa el conteo anterior. const porSitio = json('/api/instalaciones').then(j => j.resumen || {}).catch(() => null); const conteoAnterior = (s, estado) => json('/api/sitio/unidades?id=' + encodeURIComponent(s.id)).then(j => { const n = j.total_unidades || 0, inst = (j.unidades || []).filter(u => u.instalada).length; const partes = []; if (s.nota) partes.push(s.nota); partes.push(n ? plural(n, 'unidad con actas', 'unidades con actas') + ', ' + (inst ? plural(inst, 'instalada', 'instaladas') : 'ninguna instalada') : 'sin unidades registradas'); estado.textContent = partes.join(' · '); }); (P.sitios || []).forEach(s => { const estado = fila(s); porSitio.then(R => { if (!R) return conteoAnterior(s, estado); const r = R[s.id] || {}; const partes = []; if (s.nota) partes.push(s.nota); if (r.unidades) { let t = plural(r.instaladas || 0, 'instalada', 'instaladas') + ', ' + plural(r.probadas || 0, 'probada', 'probadas'); if (r.falla) t += ', ' + plural(r.falla, 'con falla', 'con falla'); partes.push(t); } else partes.push('sin unidades registradas'); estado.textContent = partes.join(' · '); }).catch(() => { estado.textContent = s.nota || ''; }); }); if (P.oficina && P.oficina.id) fila(P.oficina, true); } // ---------------------------------------------------------------- arranque async function iniciar() { if (!$('inicio') || !window.Kanban) return; const K = window.Kanban; let P; try { P = await json('/datos/proyecto.json'); } catch (err) { falla($('in-ruta'), 'el estado del proyecto', err); return; } pintarSemaforo(P); pintarFase(P); pintarHito(P); pintarFechas(P, []); window.Ruta.montar($('in-ruta'), P, { enlace: t => '/tableros/ingenieria/#' + t.clave }); // tablero de pendientes (extracto) y lo que depende de él K.montar($('in-kb'), { filtros: 'min', vistas: true, compacto: true, limite: { otros: 4, hecho: 3 }, limitePersona: 2, enlaceMas: '/tableros/pendientes/', nuevoPorColumna: false, barraEn: $('in-pend-acc'), onCargado: tareas => pintarFechas(P, tareas), onPintado: (vis, info) => { const ab = vis.filter(t => t.estado !== 'hecho').length, ve = vis.filter(t => t.vencida).length; $('in-resumen').textContent = plural(ab, 'abierto', 'abiertos') + (ve ? ', ' + plural(ve, 'vencido', 'vencidos') : '') + (info.filtro.mios ? ', solo los tuyos' : ''); } }); K.cargarEquipo().then(() => pintarEquipo(P)); // el enfoque espera al equipo para escribir los nombres («Lo escriben Leo y Adriana») Promise.all([json('/api/hoy').catch(() => null), K.cargarEquipo().catch(() => null)]).then(([r]) => pintarEnfoque(P, r)); Promise.all([json('/api/plan').catch(() => null), json('/api/actas').catch(() => null)]) .then(([p, a]) => pintarPedido(P, p && p.plan, a ? (a.actas || []) : null)); window.Actividad.montar($('in-actividad'), { dias: 7, limite: 6, corto: 46, mas: '/bitacora/#actividad' }); pintarDocs(); pintarMapa(P); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', iniciar); else iniciar(); })();