auto-sync 2026-10-20 18:00

This commit is contained in:
Leopoldo Rodríguez 2026-10-20 18:00:17 -06:00
parent cca4727414
commit eb435642c9
115 changed files with 3943 additions and 2423 deletions

View File

@ -42,7 +42,7 @@ El acta de ensamble no deja elegir un módulo sin aprobar, sin integración o ya
| Acta | Qué registra | Procedimiento |
|---|---|---|
| [Instalación en campo](../formularios/instalacion.html) | Una por unidad y vehículo: sitio, equipo, alimentación 24 V, antenas, RTK FIX, señal 4G, fotos | [Checklist de puesta en marcha](../procedimientos/v5-checklist-puesta-en-marcha.md) · [Sitios](../sitios/index.md) |
| [Diagnóstico en campo](../formularios/diagnostico.html) | Una por visita; muestra las visitas anteriores y admite fotos desde el teléfono | [Diagnóstico en campo](../diagnostico/procedimiento-diagnostico-campo.md) · [Tablero de campo](../diagnostico/tablero-campo.html) |
| [Diagnóstico en campo](../formularios/diagnostico.html) | Una por visita; muestra las visitas anteriores y admite fotos desde el teléfono | [Diagnóstico en campo](../diagnostico/procedimiento-diagnostico-campo.md) · [Tablero de campo](../tableros/campo.md) |
| [Envío](../formularios/envio.html) · [Recepción](../formularios/recepcion.html) | El tramo entre el banco y el puerto | [Ciclo de vida](../procedimientos/ciclo-de-vida.md) |
| [Daño o incidente](../formularios/dano.html) | Cualquier serie; la marca en todos los formularios. «Reparada» la levanta, «Baja» la hace permanente | [Ciclo de vida](../procedimientos/ciclo-de-vida.md#dano-o-incidente) |
@ -51,7 +51,7 @@ El acta de ensamble no deja elegir un módulo sin aprobar, sin integración o ya
1. Abre el formulario y elige la serie (o los módulos, en integración y ensamble).
2. Llena cada sección conforme avanzas. Se guarda solo en el navegador: al reabrir con la misma serie recuperas lo escrito.
3. Marca el resultado (Aprobada / Falla), escribe tu nombre y confirma que seguiste el procedimiento.
4. Pulsa Enviar acta firmada. Queda en el servidor y en el [tablero](../tablero.html).
4. Pulsa Enviar acta firmada. Queda en el servidor y en el [tablero](../tableros/actas.md).
La serie PRUEBA sirve para practicar: no cuenta y se puede reenviar.

View File

@ -4,21 +4,22 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Administración — HBOX</title>
<script src="js/tema.js"></script>
<link rel="stylesheet" href="formularios/acta.css">
<style>
table{border-collapse:collapse;width:100%;font-size:14px;margin-top:6px}
th{text-align:left;color:var(--tinta2);font-size:11.5px;text-transform:uppercase;letter-spacing:.05em;padding:6px 8px 6px 0;border-bottom:1px solid var(--tinta2)}
td{padding:7px 8px 7px 0;border-bottom:1px solid var(--linea)}
.del{background:#fff;color:var(--falla);border:1px solid var(--falla);border-radius:8px;padding:5px 10px;font-size:13px;cursor:pointer}
.del{background:transparent;color:var(--falla);border:1px solid var(--falla);border-radius:8px;padding:5px 10px;font-size:13px;cursor:pointer}
.bulk{background:var(--falla);color:#fff;border:0;border-radius:9px;padding:9px 14px;font-size:13.5px;cursor:pointer;margin:4px 8px 4px 0}
.ver{color:var(--plane);text-decoration:none;font-weight:600}
.aviso{background:var(--crit-soft);border-left:3px solid var(--crit);padding:10px 14px;border-radius:0 8px 8px 0;margin:10px 0}
.aviso{background:var(--crit-soft);border:1px solid var(--crit);padding:10px 14px;border-radius:8px;margin:10px 0}
</style>
</head>
<body>
<div class="wrap">
<header class="top"><h1>Administración de actas</h1><p id="sub">Solo para el administrador.</p></header>
<div style="margin:2px 0 10px;display:flex;gap:16px;flex-wrap:wrap"><a class="volver" href="./tablero.html">← Tablero</a><a class="volver" href="tableros/equipo.html">Usuarios y equipo →</a></div>
<div style="margin:2px 0 10px;display:flex;gap:16px;flex-wrap:wrap"><a class="volver" href="tableros/actas/">← Estado de actas</a><a class="volver" href="tableros/equipo/">Usuarios y equipo →</a></div>
<div id="cont"><p>Comprobando permisos…</p></div>
</div>
<script>

65
docs/bitacora.md Normal file
View File

@ -0,0 +1,65 @@
---
title: "Bitácora"
description: Los documentos del proyecto por fecha, del más nuevo al más viejo.
---
# Bitácora
Los documentos con fecha, del más nuevo al más viejo. El menú lateral muestra solo lo vigente; aquí queda todo, incluido lo superado. Lo que pasa cada día en el tablero (quién movió qué) se verá aquí cuando el servidor lo publique; por ahora vive en el historial de cada [pendiente](tableros/pendientes.md).
!!! info "Cómo se agrega un documento"
Arriba, con su fecha, una línea que diga qué decide o qué cambia, y la liga. En `mkdocs.yml` va en *Tarjetas y hardware → Auditorías y pasadas*; cuando otro lo supere, pasa a *Historia → Tarjetas superadas* y aquí se marca «superado».
## Octubre de 2026
**19 oct (tarde)**
: [V5.6: qué es crítico y qué se puede quedar](hardware/v56-clasificacion-19oct.md). Un solo crítico, nuevo: C129 en VSYS de U6. L7 y lo heredado de la V5.5 se quedan para el piloto y se miden en el banco.
**19 oct**
: [Re-auditoría de V5.6, V5.7 y v6](hardware/re-auditoria-19oct.md), sobre la pasada 3 de Michelle. V5.6 casi lista; V5.7 con un bloqueante (`/SYS5V` por In2); v6 sin avance en sus 8 bloqueantes.
: [Pasada 4: cierre de la V5.6 y la V5.7](hardware/v56-v57-layout-pasada-4.md). Instrucciones para Michelle; parte A (V5.6) y parte B (V5.7).
: [Plan de fines de semana de la v6, rev. 1.1](hardware/v6-plan-fines-de-semana.md). Recorrido una semana: 24–25 oct y 31 oct–1 nov; piloto de 20 el lunes 2 de noviembre.
**16 oct**
: [Auditoría de producción de la v6](hardware/v6-auditoria-produccion-16oct.md). No lista: potencia por capas internas, RF del módem por In1, sin barrenos.
: [Auditoría de producción de V5.6, V5.7 y v6](hardware/auditoria-produccion-16oct.md). Requisitos uno por uno y la prueba de RS1 con TP1.
: [Pasada 3 de V5.6 y V5.7](hardware/v56-v57-layout-pasada-3.md) (superada por la pasada 4).
**14 oct**
: [V5.7: colocación y ruteo](hardware/v57-colocacion-ruteo.md) (superado por la pasada 3).
**13 oct**
: [ECO V5.7: fuente de 24 V a 5 V a bordo](hardware/v57-eco-fuente.md), v0.2. Entrada de 9 a 32 V sin la SD-15B.
: [Pasada 2 de la V5.6](hardware/v56-layout-pasada-2.md) (superada por la pasada 3).
: [Auditoría del layout de la V5.6](hardware/v56-auditoria-layout-13oct.md), en inglés (superada por la del 16 oct).
: [Guía de layout de la fuente de la V5.7](hardware/v57-layout-guia.md) (superada: la fuente ya está colocada).
**12 oct**
: [Pasada 1 de la V5.6](hardware/v56-layout-pasada-1.md) (superada por la pasada 2).
**7 oct**
: [V4 sin NMEA: riel SW_5V y arranque del F9P](procedimientos/v4-prueba-riel-sw5v-f9p.md). Procedimiento de banco con el mapa de puntos de medición.
**1 oct**
: [Hoja de datos de la V5.6](hardware/v56-datasheet.md).
## Septiembre de 2026
**29 sep**
: [Pinout de la V5.6](hardware/v56-pinout.md) y [reglas de layout, del esquemático a la PCB](hardware/v56-layout-instrucciones.md).
: [v6: próximos pasos para ordenar](hardware/v6-proximos-pasos.md) (superado el 16 oct).
: [Guía del ingeniero](guia-ingeniero.md): reglas del sitio y cómo se trabaja.
**28 sep**
: [Auditoría completa de la v6](hardware/v6-auditoria-completa.md) ([en inglés](hardware/v6-full-audit.md)), con su [plan de corrección](hardware/v6-plan-correccion.md) y el [mapa de pines del MCU](hardware/v6-pinmap.md).
: Pasadas de la v6: [1, cambios al esquemático](hardware/v6-cambios-esquematico.md); [2, puertos y sin nueva versión](hardware/v6-puertos-sin-nueva-version.md); [3, revisión final](hardware/v6-revision-final.md); [resumen de las tres](hardware/v6-cambios-resumen.md).
: [Cambios al esquemático de la V5.5 a la V5.6](hardware/v56-cambios-esquematico.md).
: [v6 contra las lecciones de la V5.5](hardware/v6-revision-lecciones-v55.md) (superada).
**27 sep**
: [ECO V5.6: requisitos R1 a R5](hardware/v56-eco.md).
**25 sep**
: [Auditoría pre-fabricación de la v6](hardware/v6-auditoria-prefab.md) ([en inglés](hardware/v6-prefab-audit.md)) (superada por la del 28 sep).
Lo anterior a septiembre está en la [historia del proyecto](historia/index.md).

View File

@ -10,10 +10,10 @@ Unidad **en la máquina o en camino**: instalación en el puerto, falla en sitio
| Sitio | Puerto | Página |
|---|---|---|
| ICAVE | Veracruz | [Sitio ICAVE](../sitios/sitio.html?id=icave) |
| LZC | Lázaro Cárdenas | [Sitio LZC](../sitios/sitio.html?id=lzc) |
| EIT | Ensenada | [Sitio EIT](../sitios/sitio.html?id=eit) |
| TIMSA | Manzanillo | [Sitio TIMSA](../sitios/sitio.html?id=timsa) |
| ICAVE | Veracruz | [Sitio ICAVE](../tableros/sitio.md?id=icave) |
| LZC | Lázaro Cárdenas | [Sitio LZC](../tableros/sitio.md?id=lzc) |
| EIT | Ensenada | [Sitio EIT](../tableros/sitio.md?id=eit) |
| TIMSA | Manzanillo | [Sitio TIMSA](../tableros/sitio.md?id=timsa) |
Cada sitio muestra sus unidades instaladas y sus actas de instalación y diagnóstico. Dar de alta un sitio nuevo: [Sitios](../sitios/index.md) (solo administradores). Las páginas de sitio las publica el otro agente (WP5); si una liga no abre todavía, usa el [índice de sitios](../sitios/index.md).
@ -27,7 +27,7 @@ Cada sitio muestra sus unidades instaladas y sus actas de instalación y diagnó
| Llega una unidad al puerto o al taller | [Ciclo de vida §7](../procedimientos/ciclo-de-vida.md#7-recepcion-e-inspeccion) | [Recepción e inspección](../formularios/recepcion.html) |
| Algo se dañó (cualquier serie) | [Ciclo de vida: daño](../procedimientos/ciclo-de-vida.md#dano-o-incidente) | [Daño o incidente](../formularios/dano.html) |
El checklist impreso se sube al [expediente](../unidad.html) junto con el acta de instalación. El acta de instalación es nueva (oct-2026): mientras no esté publicada, el checklist impreso sigue siendo el registro.
El checklist impreso se sube al [expediente](../tableros/unidad.md) junto con el acta de instalación. El acta de instalación es nueva (oct-2026): mientras no esté publicada, el checklist impreso sigue siendo el registro.
## Lo que hay que saber en campo
@ -38,8 +38,8 @@ El checklist impreso se sube al [expediente](../unidad.html) junto con el acta d
## Tableros y expediente
- [Expediente de una unidad](../unidad.html): cada acta, foto y log por serie, incluida la cadena de campo (envío → instalación → diagnóstico).
- [Tablero de campo](../diagnostico/tablero-campo.html): todas las visitas de diagnóstico.
- [Expediente de una unidad](../tableros/unidad.md): cada acta, foto y log por serie, incluida la cadena de campo (envío → instalación → diagnóstico).
- [Tablero de campo](../tableros/campo.md): todas las visitas de diagnóstico.
- [Sitios](../sitios/index.md): unidades por puerto.
- [Índice de actas](../actas/index.md): cómo se llenan.
- Para el cliente: [manual del cliente](../manuales/manual-cliente.md) · [PDF de instalación y operación](../manuales/Drogheda-Manual-Instalacion-Operacion_v2.0.pdf)

View File

@ -4,16 +4,17 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mi cuenta — HBOX</title>
<script src="js/tema.js"></script>
<link rel="stylesheet" href="formularios/acta.css">
<style>
.campo{margin:12px 0}
.quien{background:var(--naranja-suave);border:1px solid #ffd8a8;border-radius:10px;
.quien{background:var(--hg-plano);border:1px solid var(--linea);border-radius:10px;
padding:12px 14px;margin-bottom:14px;font-size:15px}
.quien b{font-size:17px}
.fuerza{height:6px;border-radius:3px;background:var(--linea);margin-top:6px;overflow:hidden}
.fuerza i{display:block;height:100%;width:0;transition:width .2s,background .2s}
.pista{font-size:12.5px;color:var(--tinta2);margin-top:5px}
.admin{border:1px solid var(--linea);border-left:4px solid var(--naranja)}
.admin{border:1px solid var(--linea)}
button.enviar{width:100%;margin-top:6px}
.ver{font-size:13px;color:var(--naranja);cursor:pointer;user-select:none;margin-top:6px;display:inline-block}
</style>
@ -47,7 +48,7 @@
<div class="campo">
<label for="confirma">Repite la contraseña nueva</label>
<input type="password" id="confirma" autocomplete="new-password">
<span class="ver" id="ver">👁 Ver lo que escribí</span>
<span class="ver" id="ver">Ver lo que escribí</span>
</div>
<button class="enviar" id="guardar">Guardar contraseña nueva</button>
<div class="msg" id="msg"></div>
@ -72,7 +73,7 @@
<h2>Si olvidaste tu contraseña</h2>
<p class="sub" style="margin:0">
No hay recuperación por correo: pídele a Leo que te la restablezca desde esta misma página.
Si todavía no tienes acceso, pídeselo a Leo (Administración → <a href="tableros/equipo.html">Usuarios y equipo</a>).
Si todavía no tienes acceso, pídeselo a Leo (Administración → <a href="tableros/equipo/">Usuarios y equipo</a>).
</p>
</section>
</div>
@ -94,7 +95,7 @@ function aviso(el, texto, bien) {
yo = j.user || '';
$('quien').innerHTML = yo
? 'Entraste como <b>' + yo + '</b>' + (j.admin ? ' · administrador' : '')
: '⚠ No puedo identificarte. Vuelve a entrar al sitio y recarga esta página.';
: 'No puedo identificarte. Vuelve a entrar al sitio y recarga esta página.';
if (j.admin) {
$('bloqueAdmin').style.display = '';
try {
@ -130,7 +131,7 @@ $('nueva').addEventListener('input', () => {
$('ver').addEventListener('click', () => {
const t = $('nueva').type === 'password' ? 'text' : 'password';
$('nueva').type = t; $('confirma').type = t; $('actual').type = t;
$('ver').textContent = t === 'text' ? '🙈 Ocultar' : '👁 Ver lo que escribí';
$('ver').textContent = t === 'text' ? 'Ocultar' : 'Ver lo que escribí';
});
const AVISO_SESION =
@ -148,7 +149,7 @@ $('guardar').addEventListener('click', async () => {
});
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
aviso($('msg'), '✓ <b>Contraseña cambiada.</b>' + AVISO_SESION, true);
aviso($('msg'), '<b>Contraseña cambiada.</b>' + AVISO_SESION, true);
$('actual').value = $('nueva').value = $('confirma').value = '';
$('barra').style.width = '0';
} catch (e) {
@ -169,7 +170,7 @@ $('resetear').addEventListener('click', async () => {
});
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
aviso($('msgR'), '✓ Contraseña de <b>' + u + '</b> restablecida. Pásasela en persona o por '
aviso($('msgR'), 'Contraseña de <b>' + u + '</b> restablecida. Pásasela en persona o por '
+ 'mensaje directo, y dile que la cambie aquí mismo cuando entre.'
+ (u === yo ? AVISO_SESION : ''), true);
$('nuevaR').value = '';

View File

@ -15,7 +15,7 @@ version: "2.3"
| Módem 4G NTRIP Master | Parámetros `$$$AS+…` | Terminal serie por el USB XBEE | [Módem 4G](#parametros-del-modem-4g-ntrip-master) |
| simpleRTK2B Pro (ZED-F9P) | Configuración u-center y firmware u-blox | u-center por el USB POWER+GPS | [ZED-F9P](#firmware-y-configuracion-del-simplertk2b-pro-zed-f9p) |
Cada carga se registra en el [acta de reflasheo](../formularios/reflasheo.html) y queda en el [expediente](../unidad.html) con las capturas de antes y después.
Cada carga se registra en el [acta de reflasheo](../formularios/reflasheo.html) y queda en el [expediente](../tableros/unidad.md) con las capturas de antes y después.
## Verifica el SHA-256

View File

@ -152,7 +152,7 @@ Elige lo que observaste, no lo que supones. "No está claro" es una respuesta v
## 8. Después de enviar el acta
- Queda en el servidor con tu nombre, hora y fotos, y aparece en el [tablero de campo](tablero-campo.html).
- Queda en el servidor con tu nombre, hora y fotos, y aparece en el [tablero de campo](../tableros/campo.md).
- Quien abra el acta de esa unidad verá tu visita antes de empezar.
- Sin señal, el acta se guarda sola en el teléfono; fotos y envío necesitan datos. Si falla, reintenta desde el mismo formulario.
- Unidad desmontada: sigue en [diagnóstico en banco](../procedimientos/diagnostico-banco.md). Tarjeta: [manual V3](../manuales/manual-tarjeta-V3.md).

View File

@ -1,200 +1,18 @@
<!doctype html>
<!-- Desde el 20-oct-2026 este tablero vive dentro del sitio: docs/tableros/campo.md → /tableros/campo/
Este archivo solo redirige (conserva ?serie=, ?id=, ?pista=… y #…) para que las ligas viejas
guardadas en pendientes y actas sigan sirviendo. No se edita: los cambios van en tableros/campo.md. -->
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tablero de campo — diagnósticos HBOX</title>
<style>
:root{
--naranja:#e8590c; --tinta:#1d1c19; --tinta2:#5a564d; --linea:#e6e1d6;
--papel:#faf8f4; --tarjeta:#fff; --superf:#fcfcfb;
--ok:#2f9e44; --ok-bg:#ebfbee; --falla:#e03131; --falla-bg:#fff5f5;
--pend:#adb5bd; --pend-bg:#f4f2ee; --info:#1971c2; --info-bg:#e7f1fb;
}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){
--papel:#191b1e;--tinta:#e6e1d6;--tinta2:#a09a8d;--linea:#33363b;--tarjeta:#1f2226;--superf:#1b1d20;
--ok:#69db7c;--ok-bg:#1b2a1f;--falla:#ff8787;--falla-bg:#2c1d1d;--pend:#5c636a;--pend-bg:#26282c;--info:#74c0fc;--info-bg:#182734;}}
*{box-sizing:border-box}
body{margin:0;background:var(--papel);color:var(--tinta);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.wrap{max-width:1020px;margin:0 auto;padding:18px 16px 70px}
header.top{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;
background:#2C3C5B;color:#fff;padding:16px;border-radius:12px;margin-bottom:16px}
header.top h1{margin:0;font-size:20px}
header.top .meta{color:#d7d2c8;font-size:13px}
a.volver{color:#fff;font-size:13px;text-decoration:none;opacity:.85}
button{background:var(--naranja);color:#fff;border:0;border-radius:9px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:18px}
.kpi{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:14px 16px}
.kpi .n{font-size:30px;font-weight:700;line-height:1}
.kpi .l{color:var(--tinta2);font-size:13px;margin-top:4px}
section.bloque{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:16px;margin:14px 0}
section.bloque h2{margin:0 0 2px;font-size:17px}
.resumen{color:var(--tinta2);font-size:13.5px;margin:0 0 12px}
.unidad{border:1px solid var(--linea);border-radius:11px;padding:12px 14px;margin:10px 0;background:var(--superf)}
.unidad .cab{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;justify-content:space-between}
.unidad .s{font-weight:700;font-size:15px}
.unidad .m{color:var(--tinta2);font-size:13px}
.pill{font-size:11.5px;font-weight:700;padding:3px 10px;border-radius:99px;white-space:nowrap}
.pill.ok{background:var(--ok-bg);color:var(--ok)}
.pill.falla{background:var(--falla-bg);color:var(--falla)}
.pill.pend{background:var(--pend-bg);color:var(--tinta2)}
.pill.info{background:var(--info-bg);color:var(--info)}
.visita{border-top:1px solid var(--linea);margin-top:10px;padding-top:10px;font-size:13.5px}
.visita .f{color:var(--tinta2);font-size:12.5px}
.visita .cl{font-weight:700}
.fotos{display:flex;flex-wrap:wrap;gap:7px;margin-top:8px}
.logs{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
.logs a{font-size:12.5px;font-weight:600;color:var(--info);text-decoration:none;
border:1px solid var(--linea);border-radius:7px;padding:4px 9px;background:var(--superf)}
.fotos a{display:block;width:74px;height:74px;border-radius:8px;overflow:hidden;border:1px solid var(--linea)}
.fotos img{width:100%;height:100%;object-fit:cover;display:block;background:var(--pend-bg)}
details{margin-top:8px}
summary{cursor:pointer;color:var(--naranja);font-size:13px;font-weight:600}
table.det{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px}
table.det td{padding:5px 8px 5px 0;border-bottom:1px solid var(--linea);vertical-align:top}
table.det td.k{color:var(--tinta2);width:45%}
.vacio{color:var(--tinta2);font-size:13.5px;font-style:italic}
.err{background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla);padding:10px 14px;border-radius:10px}
</style>
<meta name="robots" content="noindex">
<title>Visitas de campo - Drogheda</title>
<script>location.replace('/tableros/campo/' + location.search + location.hash);</script>
<meta http-equiv="refresh" content="0; url=/tableros/campo/">
<style>body{margin:0;padding:24px;background:#0E1524;color:#E7ECF5;font:16px/1.5 system-ui,sans-serif}a{color:#7FE9D8}</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Tablero de campo — diagnósticos HBOX</h1><div class="meta" id="meta">Cargando…</div></div>
<div style="display:flex;gap:12px;align-items:center">
<a class="volver" href="../">← Documentación</a>
<a class="volver" href="../formularios/diagnostico.html">Nueva acta de diagnóstico</a>
<button id="refrescar">Actualizar</button>
</div>
</header>
<div id="cont"></div>
</div>
<script src="../formularios/flota.js"></script>
<script>
const TIPOS = ['diagnostico-campo','diagnostico-banco'];
const TIPO = 'diagnostico-campo';
const cont = document.getElementById('cont');
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=>e.append(c)); return e;};
const fecha = s => (s||'').replace('T',' ').slice(0,16);
function pill(v){
const t = String(v.accion||'');
if (/^Instalada y reportando/.test(t)) return ['ok','Operando'];
if (/^Instalada/.test(t)) return ['falla','Instalada sin resolver'];
if (/Querétaro/.test(t)) return ['info','En camino a QRO'];
if (/taller/.test(t)) return ['info','En taller'];
if (/Reemplazada/.test(t)) return ['ok','Reemplazada'];
return ['pend', v.resultado || '—'];
}
async function detalle(tipo, serie, archivo, caja){
caja.textContent = 'Cargando…';
try{
const r = await fetch(`/api/acta?tipo=${encodeURIComponent(tipo)}&serie=${encodeURIComponent(serie)}&archivo=${encodeURIComponent(archivo)}`);
const j = await r.json();
if(!j.exists) throw new Error('no encontrada');
const t = el('table',{class:'det'});
const res = j.acta.respuestas || {};
Object.keys(res).forEach(k=>{
const v = res[k]; if(v.val===''||v.val===false||v.val==null) return;
t.append(el('tr',null, el('td',{class:'k',text:(v.lbl||k).replace(/<[^>]+>/g,'')}),
el('td',{text: v.val===true ? '✓' : String(v.val)})));
});
caja.innerHTML=''; caja.append(t);
}catch(e){ caja.textContent = 'No se pudo cargar el detalle.'; }
}
function render(actas){
cont.innerHTML='';
const porUnidad = {};
actas.filter(a=>TIPOS.includes(a.tipo)).forEach(a=>{ (porUnidad[a.serie]=porUnidad[a.serie]||[]).push(a); });
const flota = (window.FLOTA||[]).slice();
Object.keys(porUnidad).forEach(s=>{ if(!flota.some(u=>u.serie===s)) flota.push({serie:s, etiqueta:s, maquina:'(fuera del registro de flota)', terminal:'Sin clasificar'}); });
const visitas = actas.filter(a=>TIPOS.includes(a.tipo));
const conVisita = Object.keys(porUnidad).length;
const operando = flota.filter(u=>{const v=(porUnidad[u.serie]||[])[0]; return v && pill(v)[0]==='ok';}).length;
const kp = el('div',{class:'kpis'});
[[flota.length,'Unidades en el registro'],[visitas.length,'Diagnósticos recibidos'],
[conVisita,'Unidades ya diagnosticadas'],[operando,'Reportadas como operando']]
.forEach(([n,l])=>kp.append(el('div',{class:'kpi'}, el('div',{class:'n',text:String(n)}), el('div',{class:'l',text:l}))));
cont.append(kp);
const grupos = {};
flota.forEach(u=>{ (grupos[u.terminal||'Sin clasificar']=grupos[u.terminal||'Sin clasificar']||[]).push(u); });
Object.keys(grupos).sort().forEach(g=>{
const b = el('section',{class:'bloque'});
b.append(el('h2',{text:g}));
b.append(el('p',{class:'resumen',text:grupos[g].length+' unidad(es) registradas en esta terminal.'}));
grupos[g].forEach(u=>{
const vs = (porUnidad[u.serie]||[]);
const box = el('div',{class:'unidad'});
const cab = el('div',{class:'cab'});
cab.append(el('div',null,
el('div',{class:'s'}, el('a',{href:'../unidad.html?serie='+encodeURIComponent(u.serie),
style:'color:inherit;text-decoration:none;border-bottom:1px dotted currentColor',
title:'Ver el expediente completo',text:u.serie})),
el('div',{class:'m',text:(u.maquina||'')+(u.nota?' · '+u.nota:'')})));
const [cls,txt] = vs.length ? pill(vs[0]) : ['pend','Sin diagnóstico'];
if(DANOS[u.serie]) cab.append(el('span',{class:'pill falla',text:DANOS[u.serie].marca==='BAJA'?'⛔ BAJA':'⚠ DAÑADA'}));
cab.append(el('span',{class:'pill '+cls,text:txt}));
box.append(cab);
if(!vs.length) box.append(el('div',{class:'visita vacio',text:'Todavía no hay ningún diagnóstico registrado para esta unidad.'}));
vs.forEach(v=>{
const d = el('div',{class:'visita'});
d.append(el('div',{class:'f',text:(v.tipo==='diagnostico-banco'?'🔬 banco · ':'🚜 campo · ')+fecha(v.recibido)+' · '+(v.tecnico||'—')+(v.motivo?' · '+v.motivo:'')}));
if(v.clasificacion) d.append(el('div',{class:'cl',text:v.clasificacion}));
if(v.accion) d.append(el('div',{text:'→ '+v.accion}));
if(v.fotos && v.fotos.length){
const f = el('div',{class:'fotos'});
v.fotos.forEach(r=>{
const a = el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(r),target:'_blank'});
a.append(el('img',{src:'/api/adjunto?ruta='+encodeURIComponent(r),loading:'lazy'}));
f.append(a);
});
d.append(f);
}
if(v.archivos && v.archivos.length){
const g = el('div',{class:'logs'});
v.archivos.forEach(a=>{
const x = el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(a.ruta),target:'_blank',
text:'📄 '+(a.nombre||'captura')});
g.append(x);
});
d.append(g);
}
const det = el('details');
det.append(el('summary',{text:'Ver todas las respuestas'}));
const caja = el('div'); det.append(caja);
det.addEventListener('toggle',()=>{ if(det.open && !caja.dataset.listo){ caja.dataset.listo='1'; detalle(v.tipo, v.serie, v.archivo_n, caja); } });
d.append(det);
box.append(d);
});
b.append(box);
});
cont.append(b);
});
}
let DANOS = {};
async function cargar(){
document.getElementById('meta').textContent = 'Cargando…';
try{
try{ const d=await (await fetch('/api/danos',{cache:'no-store'})).json(); DANOS={}; (d.series||[]).forEach(x=>DANOS[x.serie]=x); }catch(e){}
const r = await fetch('/api/actas',{cache:'no-store'});
const j = await r.json();
render(j.actas||[]);
document.getElementById('meta').textContent = 'Actualizado ' + new Date().toLocaleString('es-MX',{dateStyle:'short',timeStyle:'short'});
}catch(e){
cont.innerHTML=''; cont.append(el('div',{class:'err',text:'No se pudo leer el servidor de actas: '+e.message}));
document.getElementById('meta').textContent = 'Error';
}
}
document.getElementById('refrescar').addEventListener('click',cargar);
cargar();
</script>
<p>Este tablero ahora está dentro del sitio: <a href="/tableros/campo/">Visitas de campo</a>.</p>
</body>
</html>

View File

@ -0,0 +1,93 @@
Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono) AtkinsonHyperlegibleMono-Italic[wght].ttf: Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,93 @@
Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next) AtkinsonHyperlegibleNext-Italic[wght].ttf: Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,93 @@
Copyright 2020 The League Spartan Project Authors (https://github.com/theleagueof/league-spartan)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,75 +1,112 @@
/* Estilo compartido de las actas HBOX — pensado para tablet/laptop en el banco */
:root{
--naranja:#e8590c; --naranja-suave:#fff1e8; --tinta:#1d1c19; --tinta2:#5a564d;
--linea:#e2ddd2; --papel:#faf8f4; --tarjeta:#ffffff; --ok:#2f9e44; --falla:#e03131;
--ok-bg:#ebfbee; --falla-bg:#fff5f5; --aviso:#f08c00; --aviso-bg:#fff9db;
/* Estilo compartido de las actas HBOX — pensado para tablet/laptop en el banco y en el patio.
Colores y letras: stylesheets/helgen.css (oscuro por omisión; claro si la persona lo eligió:
js/tema.js pone <html data-tema="claro"> y agrega el botón de modo al encabezado). */
@import url("../stylesheets/helgen.css");
:root {
/* nombres de siempre, ahora sobre los tokens de Helgen (los usan acta.js y las páginas) */
--naranja: var(--hg-liga);
--naranja-suave: var(--hg-acento-suave);
--tinta: var(--hg-texto);
--tinta2: var(--hg-texto-2);
--linea: var(--hg-linea);
--papel: var(--hg-fondo);
--tarjeta: var(--hg-plano);
--ok: var(--hg-hecho);
--falla: var(--hg-bloqueado);
--ok-bg: var(--hg-hecho-bg);
--falla-bg: var(--hg-bloqueado-bg);
--aviso: var(--hg-aviso);
--aviso-bg: var(--hg-aviso-bg);
--plane: var(--hg-liga);
--crit: var(--hg-bloqueado);
--crit-soft: var(--hg-bloqueado-bg);
}
*{box-sizing:border-box}
body{margin:0;background:var(--papel);color:var(--tinta);
font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%}
body{margin:0;background:var(--hg-fondo);color:var(--hg-texto);
font:16px/1.5 var(--hg-fuente);-webkit-text-size-adjust:100%;
-webkit-font-smoothing:antialiased}
a{color:var(--hg-liga)}
:focus-visible{outline:2px solid var(--hg-foco);outline-offset:2px}
code{font-family:var(--hg-fuente-mono);font-size:.9em;background:var(--hg-elevado);border-radius:4px;padding:0 .3em}
.wrap{max-width:820px;margin:0 auto;padding:16px 16px 120px}
header.top{background:#2C3C5B;color:#fff;padding:16px;border-radius:12px;margin-bottom:16px}
header.top h1{margin:0;font-size:20px}
header.top p{margin:4px 0 0;color:#d7d2c8;font-size:14px}
.borrador{background:var(--aviso-bg);border:1px solid var(--aviso);color:#a5620a;
header.top{position:relative;background:var(--hg-barra);color:var(--hg-texto);border:1px solid var(--hg-linea);
padding:16px 58px 16px 16px;border-radius:12px;margin-bottom:16px}
html[data-tema="claro"] header.top{background:var(--hg-indigo);border-color:var(--hg-indigo);color:#fff}
header.top h1{margin:0;font-family:var(--hg-fuente-titulo);font-weight:600;font-size:23px;line-height:1.15;letter-spacing:0}
header.top p{margin:6px 0 0;color:var(--hg-texto-2);font-size:14px}
html[data-tema="claro"] header.top p{color:rgba(255,255,255,.78)}
/* botón de modo claro/oscuro (js/tema.js) */
.hg-modo{position:absolute;top:12px;right:12px;width:34px;height:34px;display:grid;place-items:center;
border-radius:8px;border:1px solid var(--hg-linea-2);background:transparent;color:var(--hg-texto-2);cursor:pointer;padding:0}
.hg-modo:hover{color:var(--hg-texto-fuerte);background:var(--hg-elevado)}
html[data-tema="claro"] .hg-modo{border-color:rgba(255,255,255,.35);color:rgba(255,255,255,.85)}
html[data-tema="claro"] .hg-modo:hover{background:rgba(255,255,255,.12);color:#fff}
.borrador{background:var(--aviso-bg);border:1px solid color-mix(in srgb,var(--aviso) 45%,transparent);color:var(--aviso);
padding:10px 14px;border-radius:10px;margin-bottom:16px;font-size:14px}
.datos{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:8px}
@media(max-width:560px){.datos{grid-template-columns:1fr}}
label{display:block;font-weight:600;font-size:14px;margin-bottom:4px}
input[type=text],input[type=number],select,textarea{
width:100%;padding:11px 12px;border:1px solid var(--linea);border-radius:9px;
font-size:16px;background:#fff;color:var(--tinta)}
input:focus,select:focus,textarea:focus{outline:2px solid var(--naranja);border-color:var(--naranja)}
section.paso{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;
label{display:block;font-weight:600;font-size:14px;margin-bottom:5px;color:var(--hg-texto-2)}
input[type=text],input[type=number],input[type=password],input[type=email],input[type=tel],select,textarea{
width:100%;padding:11px 12px;border:1px solid var(--hg-linea-2);border-radius:9px;
font:inherit;font-size:16px;background:var(--hg-plano);color:var(--hg-texto)}
input::placeholder,textarea::placeholder{color:var(--hg-texto-3)}
input:focus,select:focus,textarea:focus{outline:2px solid var(--hg-foco);outline-offset:1px;border-color:transparent}
section.paso{background:var(--hg-plano-2);border:1px solid var(--hg-linea);border-radius:12px;
padding:14px 16px;margin:14px 0}
section.paso h2{margin:0 0 4px;font-size:17px;color:var(--naranja)}
section.paso .sub{color:var(--tinta2);font-size:13.5px;margin:0 0 10px}
.item{padding:10px 0;border-top:1px solid var(--linea)}
section.paso h2{margin:0 0 4px;font-family:var(--hg-fuente-titulo);font-weight:600;font-size:19px;line-height:1.2;color:var(--hg-texto-fuerte)}
section.paso .sub{color:var(--hg-texto-2);font-size:14px;margin:0 0 10px}
.item{padding:10px 0;border-top:1px solid var(--hg-linea)}
.item:first-of-type{border-top:0}
.chk{display:flex;gap:12px;align-items:flex-start;cursor:pointer}
.chk input{width:26px;height:26px;margin:1px 0 0;flex:0 0 auto;accent-color:var(--naranja)}
.chk input{width:26px;height:26px;margin:1px 0 0;flex:0 0 auto;accent-color:var(--hg-teal)}
.chk .txt{flex:1}
.medida{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center}
.medida .txt{flex:1 1 100%;font-weight:600}
.medida .esp{flex:1 1 100%;color:var(--tinta2);font-size:13px;margin-top:-2px}
.medida .esp{flex:1 1 100%;color:var(--hg-texto-2);font-size:13px;margin-top:-2px}
.medida input{max-width:150px}
.medida .u{color:var(--tinta2)}
.nota-item{color:var(--tinta2);font-size:14px}
.aviso{background:var(--aviso-bg);border-left:3px solid var(--aviso);padding:8px 12px;
border-radius:0 8px 8px 0;font-size:13.5px;margin:6px 0}
.firma{background:var(--naranja-suave);border:1px solid #ffd8a8}
.medida .u{color:var(--hg-texto-2)}
.nota-item{color:var(--hg-texto-2);font-size:14px}
.aviso{background:var(--aviso-bg);border:1px solid color-mix(in srgb,var(--aviso) 35%,transparent);padding:8px 12px;
border-radius:8px;font-size:13.5px;margin:6px 0;color:var(--hg-texto)}
.firma{background:var(--hg-plano)}
.resultado{display:flex;gap:10px;margin-top:6px}
.resultado label{flex:1;border:2px solid var(--linea);border-radius:10px;padding:12px;
text-align:center;cursor:pointer;font-weight:700;margin:0}
.resultado label{flex:1;border:2px solid var(--hg-linea-2);border-radius:10px;padding:12px;
text-align:center;cursor:pointer;font-weight:700;margin:0;color:var(--hg-texto)}
.resultado input{position:absolute;opacity:0}
.resultado label:has(input:checked).ok{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}
.resultado label:has(input:checked).falla{border-color:var(--falla);background:var(--falla-bg);color:var(--falla)}
.barra{position:fixed;left:0;right:0;bottom:0;background:#fff;border-top:1px solid var(--linea);
.resultado label:has(input:focus-visible){outline:2px solid var(--hg-foco);outline-offset:2px}
.barra{position:fixed;left:0;right:0;bottom:0;background:var(--hg-barra);border-top:1px solid var(--hg-linea);
padding:12px 16px;display:flex;gap:12px;align-items:center;justify-content:space-between}
.barra .estado{font-size:13px;color:var(--tinta2)}
button.enviar{background:var(--naranja);color:#fff;border:0;border-radius:10px;
padding:13px 22px;font-size:16px;font-weight:700;cursor:pointer}
.barra .estado{font-size:13px;color:var(--hg-texto-2)}
button.enviar{background:var(--hg-acento);color:var(--hg-sobre-acento);border:0;border-radius:10px;
padding:13px 22px;font:inherit;font-size:16px;font-weight:700;cursor:pointer}
button.enviar:hover:not(:disabled){filter:brightness(1.07)}
button.enviar:disabled{opacity:.5;cursor:not-allowed}
.msg{padding:12px 14px;border-radius:10px;margin-top:12px;font-weight:600;display:none}
.msg.ok{display:block;background:var(--ok-bg);color:var(--ok);border:1px solid var(--ok)}
.msg.err{display:block;background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla)}
.msg.ok{display:block;background:var(--ok-bg);color:var(--ok);border:1px solid color-mix(in srgb,var(--ok) 40%,transparent)}
.msg.err{display:block;background:var(--falla-bg);color:var(--falla);border:1px solid color-mix(in srgb,var(--falla) 45%,transparent)}
.enviada{opacity:.6;pointer-events:none}
a.volver{color:var(--naranja);font-size:14px;text-decoration:none}
button.limpiar{background:#fff;color:var(--tinta2);border:1px solid var(--linea);
border-radius:10px;padding:13px 16px;font-size:15px;cursor:pointer}
button.limpiar:hover{border-color:var(--naranja);color:var(--naranja)}
a.volver{color:var(--hg-liga);font-size:14px;text-decoration:none}
a.volver:hover{text-decoration:underline}
button.limpiar{background:transparent;color:var(--hg-texto-2);border:1px solid var(--hg-linea-2);
border-radius:10px;padding:13px 16px;font:inherit;font-size:15px;cursor:pointer}
button.limpiar:hover{border-color:var(--hg-linea-2);background:var(--hg-elevado);color:var(--hg-texto-fuerte)}
/* ---- actas compuestas (ensamble / integración): estado de cada módulo ---- */
.datos{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
.datos .serie-calc{grid-column:1 / -1;background:var(--naranja-suave);border:1px solid #ffd8a8;border-radius:10px;padding:10px 12px}
.datos .serie-calc input{font-weight:700;font-size:20px;letter-spacing:.04em;background:#fff}
.badge{display:inline-block;margin-top:6px;font-size:13px;font-weight:700;padding:3px 10px;border-radius:99px;background:#f1efe9;color:var(--tinta2)}
.datos .serie-calc{grid-column:1 / -1;background:var(--hg-plano);border:1px solid var(--hg-linea);border-radius:10px;padding:10px 12px}
.datos .serie-calc input{font-family:var(--hg-fuente-mono);font-weight:600;font-size:20px;letter-spacing:.04em;background:var(--hg-plano-2)}
.badge{display:inline-block;margin-top:6px;font-size:13px;font-weight:600;padding:3px 10px;border-radius:6px;background:var(--hg-pend-bg);color:var(--hg-texto-2)}
.badge.ok{background:var(--ok-bg);color:var(--ok)}
.badge.falla{background:var(--falla-bg);color:var(--falla)}
.badge.pend{background:#f1efe9;color:var(--tinta2)}
.badge.pend{background:var(--hg-pend-bg);color:var(--hg-texto-2)}
.radios{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:4px}
.radios .rad{font-weight:500;display:flex;align-items:center;gap:6px;cursor:pointer}
.radios input{width:22px;height:22px;accent-color:var(--naranja)}
.radios .rad{font-weight:500;display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--hg-texto)}
.radios input{width:22px;height:22px;accent-color:var(--hg-teal)}
/* ================= actas de campo (diagnóstico / instalación) ================= */
@ -77,41 +114,42 @@ button.limpiar:hover{border-color:var(--naranja);color:var(--naranja)}
.oculto{display:none !important}
/* la unidad elegida: máquina y terminal, para que el técnico confirme que abrió la correcta */
.ficha-unidad:not(:empty){margin-top:8px;padding:8px 10px;background:var(--naranja-suave);
border:1px solid #ffd8a8;border-radius:8px;font-size:13.5px;line-height:1.35}
.ficha-unidad:not(:empty){margin-top:8px;padding:8px 10px;background:var(--hg-plano);
border:1px solid var(--hg-linea);border-radius:8px;font-size:13.5px;line-height:1.35}
/* historial de visitas anteriores a la misma unidad */
.hist{margin:0 0 14px;padding:12px 14px;background:#eef3f8;border:1px solid #c9dcec;
border-radius:10px;font-size:14px;color:#1b3b57}
.hist{margin:0 0 14px;padding:12px 14px;background:var(--hg-info-bg);border:1px solid color-mix(in srgb,var(--hg-info) 30%,transparent);
border-radius:10px;font-size:14px;color:var(--hg-texto)}
.hist:empty{display:none}
.hist-t{margin-bottom:6px}
.hist-r{padding:4px 0;border-top:1px solid #d6e4f0}
.hist-r{padding:4px 0;border-top:1px solid color-mix(in srgb,var(--hg-info) 22%,transparent)}
/* fotos */
.item.foto .txt{display:block;font-weight:600;margin-bottom:8px}
button.btn-foto{width:100%;background:#fff;color:var(--naranja);border:2px dashed var(--naranja);
border-radius:12px;padding:16px;font-size:16px;font-weight:700;cursor:pointer}
button.btn-foto:active{background:var(--naranja-suave)}
button.btn-foto{width:100%;background:transparent;color:var(--hg-liga);border:2px dashed var(--hg-linea-2);
border-radius:12px;padding:16px;font:inherit;font-size:16px;font-weight:700;cursor:pointer}
button.btn-foto:hover{border-color:var(--hg-liga)}
button.btn-foto:active{background:var(--hg-acento-suave)}
.miniaturas{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}
.miniaturas:empty{margin-top:0}
.mini{width:104px;border:1px solid var(--linea);border-radius:10px;padding:6px;background:#fff;
.mini{width:104px;border:1px solid var(--hg-linea);border-radius:10px;padding:6px;background:var(--hg-plano);
display:flex;flex-direction:column;gap:4px;align-items:center}
.mini img{width:90px;height:90px;object-fit:cover;border-radius:6px;background:#eee}
.mini img{width:90px;height:90px;object-fit:cover;border-radius:6px;background:var(--hg-pend-bg)}
.mini .est{font-size:11px;font-weight:700;text-align:center;line-height:1.2}
.mini.ok{border-color:var(--ok)} .mini.ok .est{color:var(--ok)}
.mini.err{border-color:var(--falla)} .mini.err .est{color:var(--falla)}
.mini.subiendo .est{color:var(--tinta2)}
button.mini-btn{background:#fff;border:1px solid var(--linea);border-radius:6px;
font-size:11px;padding:3px 7px;cursor:pointer;color:var(--tinta2)}
.mini.ok{border-color:color-mix(in srgb,var(--ok) 55%,transparent)} .mini.ok .est{color:var(--ok)}
.mini.err{border-color:color-mix(in srgb,var(--falla) 55%,transparent)} .mini.err .est{color:var(--falla)}
.mini.subiendo .est{color:var(--hg-texto-2)}
button.mini-btn{background:transparent;border:1px solid var(--hg-linea-2);border-radius:6px;
font:inherit;font-size:11px;padding:3px 7px;cursor:pointer;color:var(--hg-texto-2)}
/* aviso de seguridad: más fuerte que un 'aviso' normal */
.peligro{background:var(--falla-bg);border:2px solid var(--falla);color:#a01a1a;
.peligro{background:var(--falla-bg);border:2px solid var(--falla);color:var(--hg-texto);
padding:12px 14px;border-radius:10px;margin:10px 0;font-size:14px;line-height:1.45}
.peligro b{color:var(--falla)}
/* en el patio: con guantes, sol y prisa — todo más grande */
@media(max-width:560px){
body{font:17px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
body{font-size:17px;line-height:1.55}
.wrap{padding:12px 12px 130px}
.chk input{width:30px;height:30px}
.medida input{max-width:none;flex:1 1 140px;font-size:17px;padding:13px 12px}
@ -124,24 +162,32 @@ button.mini-btn{background:#fff;border:1px solid var(--linea);border-radius:6px;
/* archivos de log adjuntos (capturas por USB) */
.mini.doc{width:auto;min-width:160px;max-width:260px;flex-direction:row;flex-wrap:wrap;
align-items:center;gap:6px;padding:8px 10px}
.mini.doc .ico{font-size:18px}
.mini.doc .nom{font-size:12.5px;font-weight:600;word-break:break-all;flex:1 1 90px}
.mini.doc .ico{font-size:12px;font-weight:700;color:var(--hg-texto-3)}
.mini.doc .nom{font-family:var(--hg-fuente-mono);font-size:12.5px;font-weight:500;word-break:break-all;flex:1 1 90px}
.mini.doc .est{font-size:11.5px;flex:0 0 auto}
input[type=date]{width:100%;padding:11px 12px;border:1px solid var(--linea);border-radius:9px;
font-size:16px;background:#fff;color:var(--tinta)}
input[type=date]{width:100%;padding:11px 12px;border:1px solid var(--hg-linea-2);border-radius:9px;
font:inherit;font-size:16px;background:var(--hg-plano);color:var(--hg-texto)}
/* marca de daño / baja: se muestra en cualquier acta al elegir la serie */
.marca{display:none;margin:0 0 14px;padding:12px 14px;border-radius:10px;font-size:14.5px;line-height:1.45}
.marca.danada{display:block;background:var(--aviso-bg);border:2px solid var(--aviso);color:#7a4a00}
.marca.baja{display:block;background:var(--falla-bg);border:2px solid var(--falla);color:#8a1a1a}
.marca.danada{display:block;background:var(--aviso-bg);border:2px solid var(--aviso);color:var(--hg-texto)}
.marca.baja{display:block;background:var(--falla-bg);border:2px solid var(--falla);color:var(--hg-texto)}
.marca a{color:inherit;font-weight:700}
/* tabla de registro (tipo 'tabla') */
.tabla-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;margin-top:6px}
.tabla{border-collapse:collapse;font-size:13px;min-width:100%}
.tabla th,.tabla td{border:1px solid #ddd;padding:4px 5px;text-align:left;vertical-align:middle;white-space:nowrap}
.tabla thead th{background:#f3f3f3;font-weight:600;font-size:12px}
.tabla tbody th{background:#fafafa;font-weight:600;font-variant-numeric:tabular-nums}
.tabla input,.tabla select{width:100%;min-width:64px;font-size:14px;padding:6px 6px;border:1px solid #ccc;border-radius:6px;box-sizing:border-box}
.tabla th,.tabla td{border:1px solid var(--hg-linea);padding:4px 5px;text-align:left;vertical-align:middle;white-space:nowrap}
.tabla thead th{background:var(--hg-plano);color:var(--hg-texto-2);font-weight:600;font-size:12px}
.tabla tbody th{background:var(--hg-plano-2);font-weight:600;font-variant-numeric:tabular-nums}
.tabla input,.tabla select{width:100%;min-width:64px;font-size:14px;padding:6px 6px;border:1px solid var(--hg-linea-2);border-radius:6px;box-sizing:border-box;background:var(--hg-plano);color:var(--hg-texto)}
.tabla input[inputmode=decimal]{max-width:90px}
.enviada .tabla input,.enviada .tabla select{background:#f6f6f6}
.enviada .tabla input,.enviada .tabla select{background:var(--hg-plano-2)}
@media print{
body{background:#fff;color:#000}
header.top{background:#fff !important;color:#000 !important;border-color:#000}
header.top p{color:#333 !important}
.hg-modo,.barra{display:none !important}
section.paso{background:#fff;border-color:#999}
}

View File

@ -57,7 +57,7 @@
function opciones(sel, prefijo, desde, hasta, pad) {
sel.append($('option', { value: '', text: '— elegir —' }));
sel.append($('option', { value: prefijo + '-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
sel.append($('option', { value: prefijo + '-PRUEBA', text: 'PRUEBA (no cuenta)' }));
for (let i = desde; i <= hasta; i++) {
const s = prefijo + '-' + String(i).padStart(pad, '0');
sel.append($('option', { value: s, text: s }));
@ -81,7 +81,7 @@
wrap.append(nav);
if (cfg.borrador)
wrap.append($('div', { class: 'borrador', html: '⚠ <b>BORRADOR</b> — este formulario aún debe revisarse con ingeniería antes de usarlo como registro oficial.' }));
wrap.append($('div', { class: 'borrador', html: '<b>BORRADOR</b> — este formulario aún debe revisarse con ingeniería antes de usarlo como registro oficial.' }));
if (cfg.intro) wrap.append($('div', { class: 'nota-item', style: 'margin:0 0 10px', html: cfg.intro }));
// ---------------- cabecera: serie (simple o compuesta) + técnico ----------------
@ -108,9 +108,9 @@
serieSel.append(og);
});
if (!cfg.sinEnsambladas) cargarEnsambladas();
if (!cfg.sinEnsambladas) serieSel.insertBefore($('option', { value: '__HBOX__', text: '🧩 Unidad HBOX completa — elegir sus 3 piezas' }), serieSel.children[1] || null);
serieSel.append($('option', { value: '__OTRA__', text: '➕ Otra unidad (no está en la lista)' }));
serieSel.append($('option', { value: 'CAMPO-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
if (!cfg.sinEnsambladas) serieSel.insertBefore($('option', { value: '__HBOX__', text: 'Unidad HBOX completa: elegir sus 3 piezas' }), serieSel.children[1] || null);
serieSel.append($('option', { value: '__OTRA__', text: 'Otra unidad (no está en la lista)' }));
serieSel.append($('option', { value: 'CAMPO-PRUEBA', text: 'PRUEBA (no cuenta)' }));
// Unidad HBOX completa armada a mano: tarjeta + ArduSimple + módem → misma serie que el acta de ensamble.
[['einar', 'Tarjeta EinarBoard', 'V5', 2], ['ard', 'ArduSimple', 'ARD', 3], ['modem', 'Módem 4G', 'RTK', 4]].forEach(([k, t, pre, pad]) => {
const sel = $('select'); opciones(sel, pre, 1, 30, pad); armarSel[k] = sel;
@ -178,10 +178,10 @@
marcaBox.className = 'marca ' + (baja ? 'baja' : 'danada');
marcaBox.innerHTML = avisos.map(({ s, j }) => {
const a = j.acta || {};
return (j.marca === 'BAJA' ? '⛔ <b>' + s + ' está dada de BAJA</b>' : '⚠ <b>' + s + ' está marcada como DAÑADA</b>')
return (j.marca === 'BAJA' ? '<b>' + s + ' está dada de BAJA</b>' : '<b>' + s + ' está marcada como DAÑADA</b>')
+ (a.fecha ? ' desde el ' + a.fecha : '') + (a.clasificacion ? ' — ' + a.clasificacion : '')
+ (a.resumen ? '<br>' + a.resumen : '')
+ '<br><a href="../unidad.html?serie=' + encodeURIComponent(s) + '">Ver su expediente</a>'
+ '<br><a href="../tableros/unidad/?serie=' + encodeURIComponent(s) + '">Ver su expediente</a>'
+ (j.marca === 'BAJA' ? '. No debe usarse ni instalarse.' : '. Revisa el acta de daño antes de continuar.');
}).join('<hr style="border:0;border-top:1px solid currentColor;opacity:.3;margin:8px 0">');
}
@ -344,7 +344,7 @@
box.append($('span', { class: 'txt', html: (it.req ? '<b>Archivo obligatorio</b> — ' : 'Archivo — ') + it.txt }));
if (it.ayuda) box.append($('div', { class: 'nota-item', html: it.ayuda }));
const inp = $('input', { type: 'file', accept: '.txt,.log,.csv,.nmea,.ubx,.json,.cfg,text/plain', multiple: 'multiple', style: 'display:none' });
const btn = $('button', { type: 'button', class: 'btn-foto', text: '📄 Subir archivo de captura' });
const btn = $('button', { type: 'button', class: 'btn-foto', text: 'Subir archivo de captura' });
const tiras = $('div', { class: 'miniaturas', 'data-fotos': id });
const marca = $('input', { type: 'hidden', 'data-id': id, 'data-lbl': 'Archivos: ' + String(it.txt || '').replace(/<[^>]+>/g, ''), value: '' });
btn.addEventListener('click', () => inp.click());
@ -355,7 +355,7 @@
box.append($('span', { class: 'txt', html: (it.req ? '<b>Foto obligatoria</b> — ' : 'Foto — ') + it.txt }));
if (it.ayuda) box.append($('div', { class: 'nota-item', html: it.ayuda }));
const inp = $('input', { type: 'file', accept: 'image/*', capture: 'environment', multiple: 'multiple', style: 'display:none' });
const btn = $('button', { type: 'button', class: 'btn-foto', text: '📷 Tomar o elegir foto' });
const btn = $('button', { type: 'button', class: 'btn-foto', text: 'Tomar o elegir foto' });
const tiras = $('div', { class: 'miniaturas', 'data-fotos': id });
const marca = $('input', { type: 'hidden', 'data-id': id, 'data-lbl': 'Fotos: ' + String(it.txt || '').replace(/<[^>]+>/g, ''), value: '' });
btn.addEventListener('click', () => inp.click());
@ -413,7 +413,7 @@
if (!t) return;
const li = $('div', { class: 'mini ok' });
li.append($('img', { src: '/api/adjunto?ruta=' + encodeURIComponent(f.ruta) }),
$('span', { class: 'est', text: '✓ guardada' }));
$('span', { class: 'est', text: 'Guardada' }));
if (marca) li.append(quitarFoto(f, li, marca));
t.append(li);
});
@ -423,9 +423,9 @@
const t = wrap.querySelector(`.miniaturas[data-fotos="${a.campo}"]`);
if (!t) return;
const li = $('div', { class: 'mini doc ok' });
li.append($('span', { class: 'ico', text: '📄' }),
li.append($('span', { class: 'ico', text: 'Log' }),
$('span', { class: 'nom', text: String(a.nombre || '').slice(0, 28) }),
$('span', { class: 'est', text: '✓ guardado' }));
$('span', { class: 'est', text: 'Guardado' }));
t.append(li);
});
}
@ -471,7 +471,7 @@
borrar.style.display = admin ? '' : 'none';
banner.className = 'msg ok';
const f = (a._recibido || '').replace('T', ' ').slice(0, 16);
banner.innerHTML = '✓ <b>Acta ya enviada</b> por <b>' + (a.tecnico || '—') + '</b>'
banner.innerHTML = '<b>Acta ya enviada</b> por <b>' + (a.tecnico || '—') + '</b>'
+ (f ? ' el ' + f : '') + '. Registro final'
+ (a.resultado ? ' — resultado <b>' + a.resultado + '</b>' : '') + '.'
+ (admin ? '' : ' Para rehacerla, pide a Leo que la borre.');
@ -521,7 +521,7 @@
}
function quitarFoto(f, li, marca) {
const b = $('button', { type: 'button', class: 'mini-btn', text: '✕ quitar' });
const b = $('button', { type: 'button', class: 'mini-btn', text: 'Quitar' });
b.addEventListener('click', () => {
fotos = fotos.filter(x => x !== f); li.remove();
marca.value = fotos.filter(x => x.campo === f.campo).length || '';
@ -538,7 +538,7 @@
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
const f = j.fotos[0]; f.campo = campo; fotos.push(f);
li.className = 'mini ok'; est.textContent = '✓ guardada';
li.className = 'mini ok'; est.textContent = 'Guardada';
li.append(quitarFoto(f, li, marca));
marca.value = fotos.filter(x => x.campo === campo).length;
guardarLocal(); aplicarCondiciones();
@ -557,7 +557,7 @@
if (!serieActual()) { fail('Primero elige la unidad: los archivos se guardan con su número de serie.'); return; }
for (const file of Array.from(lista).slice(0, 10)) {
const li = $('div', { class: 'mini doc subiendo' });
li.append($('span', { class: 'ico', text: '📄' }),
li.append($('span', { class: 'ico', text: 'Log' }),
$('span', { class: 'nom', text: file.name.slice(0, 28) }));
const est = $('span', { class: 'est', text: 'subiendo…' }); li.append(est); tiras.append(li);
const fd = new FormData();
@ -568,8 +568,8 @@
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
const a = j.archivos[0]; a.campo = campo; archivos.push(a);
li.className = 'mini doc ok'; est.textContent = '✓ ' + kb(a.bytes);
const q = $('button', { type: 'button', class: 'mini-btn', text: '✕ quitar' });
li.className = 'mini doc ok'; est.textContent = kb(a.bytes);
const q = $('button', { type: 'button', class: 'mini-btn', text: 'Quitar' });
q.addEventListener('click', () => {
archivos = archivos.filter(x => x !== a); li.remove();
marca.value = archivos.filter(x => x.campo === campo).length || '';
@ -609,7 +609,7 @@
const j = await r.json();
if (!j.total) { hist.innerHTML = '🆕 <b>Primera visita</b> registrada para esta unidad.'; return; }
hist.innerHTML = '';
hist.append($('div', { class: 'hist-t', html: '📋 <b>' + j.total + ' visita(s) anterior(es)</b> a esta unidad — léelas antes de empezar:' }));
hist.append($('div', { class: 'hist-t', html: '<b>' + j.total + ' visita(s) anterior(es)</b> a esta unidad — léelas antes de empezar:' }));
j.visitas.slice(0, 6).forEach(v => {
const f = (v.recibido || '').replace('T', ' ').slice(0, 16);
hist.append($('div', { class: 'hist-r', html:
@ -626,8 +626,8 @@
let verif = {}; // clave -> 'OK' | 'FALLA' | 'SIN_ACTA' | 'PRUEBA' | 'EN_USO'
let usados = null; // { clave: { valor: serieUnidad } } — componentes ya asignados en otras actas de este tipo
function setBadge(el, st, extra) {
const map = { OK: ['ok', '✓ Aprobado'], FALLA: ['falla', '✕ FALLA'], SIN_ACTA: ['falla', '✕ Sin acta'],
PRUEBA: ['pend', '⚙ prueba'], EN_USO: ['falla', '⚠ ya en ' + (extra || 'otra unidad')], CONSULTANDO: ['pend', '…'] };
const map = { OK: ['ok', 'Aprobado'], FALLA: ['falla', 'Falla'], SIN_ACTA: ['falla', 'Sin acta'],
PRUEBA: ['pend', 'Prueba'], EN_USO: ['falla', 'Ya en ' + (extra || 'otra unidad')], CONSULTANDO: ['pend', '…'] };
const m = map[st] || ['pend', '—'];
el.className = 'badge ' + m[0]; el.textContent = m[1];
}
@ -745,7 +745,7 @@
cargarLocal(); aplicarCondiciones();
if (compuesta && cfg.exigirAprobados && !todoAprobado()) {
banner.className = 'msg err';
banner.innerHTML = '⛔ <b>No se puede ensamblar con esta selección.</b> Todos los módulos deben estar <b>aprobados</b> en su acta y no estar ya asignados a otra unidad. Revisa los indicadores de arriba.';
banner.innerHTML = '<b>No se puede ensamblar con esta selección.</b> Todos los módulos deben estar <b>aprobados</b> en su acta y no estar ya asignados a otra unidad. Revisa los indicadores de arriba.';
}
}
}
@ -805,10 +805,10 @@
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
try { localStorage.removeItem(key()); } catch (e) {}
msg.className = 'msg ok';
msg.innerHTML = '✓ Acta enviada y registrada como <b>' + d.serie + '</b>.'
msg.innerHTML = 'Acta enviada y registrada como <b>' + d.serie + '</b>.'
+ (compuesta && !esPrueba(d.serie) ? '<br>Etiqueta de la unidad: <b style="font-size:18px;letter-spacing:.04em">' + d.serie + '</b>' : '');
if (cfg.repetible) {
msg.innerHTML = '✓ <b>Registro enviado</b> para <b>' + d.serie + '</b>'
msg.innerHTML = '<b>Registro enviado</b> para <b>' + d.serie + '</b>'
+ (d.fotos && d.fotos.length ? ' con ' + d.fotos.length + ' foto(s)' : '')
+ '.<br>Ingeniería ya puede verlo. Puedes cerrar o elegir otra unidad.';
btn.disabled = false; btn.textContent = 'Enviar acta firmada';

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de módulo — simpleRTK2B Pro (RTK)</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de diagnóstico en banco — HBOX</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de daño o incidente — HBOX</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de diagnóstico en campo — HBOX</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de ensamble — unidad HBOX v5</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de envío — HBOX</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prueba de banco — Ethernet y puertos serie (V5)</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,9 +4,10 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de instalación en campo — HBOX</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
<style>
details.como{background:#eef3f8;border:1px solid #c9dcec;border-radius:10px;padding:8px 14px;margin:0 0 12px;font-size:14px;color:#1b3b57}
details.como{background:var(--hg-plano);border:1px solid var(--hg-linea);border-radius:10px;padding:8px 14px;margin:0 0 12px;font-size:14px;color:var(--hg-texto)}
details.como summary{cursor:pointer;font-weight:700}
details.como ol{margin:8px 0 4px;padding-left:20px} details.como li{margin:3px 0}
</style>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de integración — RTK + 4G</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de módulo — 4G NTRIP Master</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prueba de banco — módem 4G en batería</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de recepción e inspección — HBOX</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de reflasheo y configuración — HBOX</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de prueba — EinarBoard V4 (fw 0.6.0)</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>
@ -55,7 +56,7 @@ Acta.render({
+ '<a href="../procedimientos/v4-prueba-fw-0.5.6/">V4 — preparación y prueba con 0.6.0</a>. '
+ 'Graba la consola desde el primer minuto; al final se sube completa. '
+ 'Si una etapa no pasa, marca «me detengo»: lo que sigue se oculta, llenas el Cierre y envías como <b>FALLA</b>. '
+ 'Sin número de serie: el siguiente V4 libre del <a href="../tablero.html">tablero</a>. La serie PRUEBA sirve para practicar.',
+ 'Sin número de serie: el siguiente V4 libre del <a href="../tableros/actas/">tablero de actas</a>. La serie PRUEBA sirve para practicar.',
secciones: [
{ titulo: '1 · Datos',

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de prueba — EinarBoard V5</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
</head>
<body>

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ver acta — HBOX</title>
<script src="../js/tema.js"></script>
<link rel="stylesheet" href="acta.css">
<style>
.doc{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:18px}
@ -25,7 +26,7 @@
<div class="wrap">
<header class="top"><h1 id="titulo">Acta</h1><p id="sub">Cargando…</p></header>
<div style="display:flex;justify-content:space-between;margin:2px 0 10px">
<a class="volver" href="../tablero.html">← Tablero</a>
<a class="volver" href="../tableros/actas/">← Estado de actas</a>
<a class="volver noprint" href="#" onclick="window.print();return false">Imprimir / PDF</a>
</div>
<div id="cont"></div>
@ -66,14 +67,14 @@ function render(a){
if(sec!==curSec){ if(curSec!==-1) h+='</div>'; h+='<div class="sec"><h3>Sección '+(sec+1)+'</h3>'; curSec=sec; }
const it=R[k]; const val=it.val;
let vista;
if(val===true) vista='<span class="si">✓ Sí</span>';
if(val===true) vista='<span class="si">Sí</span>';
else if(val===false) vista='<span class="no">— No marcado</span>';
else vista = val==='' ? '<span class="no">—</span>' : esc(val);
h+='<div class="row"><div class="t">'+ (it.lbl||k) +'</div><div class="a">'+vista+'</div></div>';
});
if(curSec!==-1) h+='</div>';
h += '<div class="sec"><div class="row"><div class="t">Conformidad del técnico</div><div class="a">'
+ (a.conforme?'<span class="si">✓ Confirmado</span>':'<span class="no">—</span>') + '</div></div></div>';
+ (a.conforme?'<span class="si">Confirmado</span>':'<span class="no">—</span>') + '</div></div></div>';
h += '</div>';
cont.innerHTML = h;
}

View File

@ -4,7 +4,7 @@ title: "Tarjetas y hardware"
# Tarjetas y hardware
Estado de cada versión de la EinarBoard al **19-oct-2026**, con sus documentos vigentes, los históricos y el siguiente paso. ¿No sabes qué tarjeta tienes? [Manuales por tarjeta](../manuales/index.md) te la identifica por serigrafía, conectores y LEDs. Estado vivo (tareas, decisiones, responsables): [Tablero de ingeniería](../tableros/ingenieria.html).
Estado de cada versión de la EinarBoard al **19-oct-2026**, con sus documentos vigentes, los históricos y el siguiente paso. ¿No sabes qué tarjeta tienes? [Manuales por tarjeta](../manuales/index.md) te la identifica por serigrafía, conectores y LEDs. Estado vivo (tareas, decisiones, responsables): [Tablero de ingeniería](../tableros/ingenieria.md).
!!! danger "Re-auditoría del 19-oct: todavía no se pide ninguna, pero a la V5.6 le falta una hora"
- **V5.6 — casi lista.** La pasada 3 de Michelle cerró lo grande: `/BAT_PLUS` fuera de capas internas, `/3V3_MODEM` a 1.0 mm, 4 vías en el EP de U7, sin cobre suelto, y el sensado Kelvin de RS1 sigue intacto. T30 ya está aplicado (`7650b96`).
@ -147,7 +147,7 @@ El sensado de RS1 ya está bien (hay que conservarlo). T25 y T28 se volvieron a
| Qué trae la V5.7 y por qué | [ECO V5.7](v57-eco-fuente.md) |
| Qué falta para pedir la v6 | [Auditoría v6 (16-oct)](v6-auditoria-produccion-16oct.md) · [Plan de corrección](v6-plan-correccion.md) |
| Qué se hace cada fin de semana en la v6 | [Plan de fines de semana](v6-plan-fines-de-semana.md) |
| Cuántas de cada versión y cuándo | [Tablero de producción](../tableros/produccion.html) |
| Cuántas de cada versión y cuándo | [Tablero de producción](../tableros/produccion.md) |
| El binario correcto | [Descargas](../descargas/index.md) |
| KiCad o el repositorio | [Guía Git](../git/guia-git-es.md) |
| Cómo llegamos aquí | [Historia del proyecto](../historia/index.md) |

View File

@ -104,7 +104,7 @@ Firmware 0.6.3 a banco (1-oct). Se revisa el bus I²C de V5.6/v6 (5-oct), se dia
| 12-oct | Instrucciones a Michelle para el layout final V5.6 (U26, sin P12). Borrador v0.1 de la ECO V5.7. | [Layout pasada 1](../hardware/v56-layout-pasada-1.md) — **superada** por la pasada 2 · `…2026-10-12_Drogheda_EinarBoard-V5.7_ECO-Fuente-a-bordo_ES_v0.1.md` — **superado** por v0.2 | V5.6 / V5.7 |
| 13-oct | **Auditoría del layout de Michelle (`d71098d`): NO LISTA** — `/BAT_PLUS` por In1 a 0.5 mm (bloqueante); 18 piezas movidas (decisión: se quedan); MAIN5V/SYS5V/SW3 por capas internas desde V5.5. Pasada 2 con las correcciones. | [Auditoría 13-oct (inglés)](../hardware/v56-auditoria-layout-13oct.md) · [Layout pasada 2](../hardware/v56-layout-pasada-2.md) | V5.6 |
| 13-oct | **V5.7 = V5.6 + fuente 24 V → 5 V a bordo** (U25 TPS54360B del bloque v6), sin SD-15B: esquemático aplicado (ERC 0), F2 fusible SMD 3 A, fuera U2 y P2, JP1 puenteado; proyecto KiCad separado `hbox-watchdog-v57`. Leo coloca la fuente; Michelle corrige V5.6 y después toma V5.7. | [ECO V5.7 v0.2](../hardware/v57-eco-fuente.md) · [Guía de layout V5.7](../hardware/v57-layout-guia.md) | V5.7 |
| 13-oct | **Pedido de 570 HBOX:** 113 × V5.6 (con SD-15B) · 327 × V5.7 · 130 × v6 (+ reemplazos futuros en v6). Paquetes de fabricación V5.6/V5.7/v6 para cotizar. Caja plástica del HBOX: el cable Ethernet no cabe (cambio por definir). Sitios: ICAVE, LZC, EIT, TIMSA. | notas del proyecto (13-oct) · [Tablero de producción](../tableros/produccion.html) · [Kanban](../tableros/kanban.html) | V5.6 / V5.7 / v6 |
| 13-oct | **Pedido de 570 HBOX:** 113 × V5.6 (con SD-15B) · 327 × V5.7 · 130 × v6 (+ reemplazos futuros en v6). Paquetes de fabricación V5.6/V5.7/v6 para cotizar. Caja plástica del HBOX: el cable Ethernet no cabe (cambio por definir). Sitios: ICAVE, LZC, EIT, TIMSA. | notas del proyecto (13-oct) · [Tablero de producción](../tableros/produccion.md) · [Kanban](../tableros/pendientes.md) | V5.6 / V5.7 / v6 |
| 13-oct | Reorganización de este sitio por propósito; tableros, inventario y sitios. | esta página | — |
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 201.9"><title>Helgen</title><path fill="#E7ECF5" fill-rule="evenodd" d="M449.2 198.7C431.6 196.7 418.2 188.6 412.8 176.7C411.8 174.5 411.8 174.5 423.6 168.6C436.2 162.5 435.6 162.6 437.5 165.0C444.4 174.1 461.6 176.4 471.7 169.5C477.3 165.8 479.4 161.1 479.4 152.9C479.4 148.0 479.4 148.0 476.2 150.2C466.3 157.2 445.2 157.9 432.8 151.7C403.0 136.7 401.3 89.9 430.0 71.8C443.4 63.4 463.2 64.2 476.0 73.6C479.2 76.0 479.4 75.9 479.4 72.6C479.4 68.2 478.0 68.6 494.2 68.6C505.8 68.6 508.3 68.7 508.6 69.1C509.3 70.0 508.9 161.2 508.2 164.9C503.2 189.1 480.4 202.2 449.2 198.7ZM79.0 178.1C35.8 173.8 4.3 138.6 2.9 93.2C1.0 28.3 63.4 -16.4 118.4 10.5C171.1 36.4 182.8 111.0 140.9 154.0C124.6 170.8 100.1 180.3 79.0 178.1ZM95.3 166.3C137.8 159.3 164.8 112.7 152.3 68.1C139.8 23.6 93.8 1.2 54.9 20.8C14.7 40.9 1.2 96.0 27.0 135.1C42.6 158.7 68.9 170.7 95.3 166.3ZM323.6 155.6C313.8 153.8 309.7 152.2 303.0 147.8C279.2 131.9 276.2 99.2 296.7 79.4C309.0 67.4 325.5 63.4 343.9 67.7C358.2 71.1 369.4 81.6 373.4 95.5C375.0 101.2 376.0 113.5 374.9 114.6C374.6 114.9 367.4 115.0 344.6 115.0C327.7 115.0 314.4 115.2 314.2 115.3C313.2 115.9 313.9 120.4 315.3 123.3C318.4 129.4 324.4 132.5 333.3 132.4C341.0 132.3 344.1 131.1 349.7 126.0C351.3 124.6 352.7 123.5 352.9 123.5C353.6 123.5 372.9 133.4 373.1 133.9C373.4 134.7 372.4 136.4 369.8 139.6C363.0 147.7 354.0 152.8 341.9 155.0C339.0 155.5 325.7 156.0 323.6 155.6ZM549.3 155.3C512.9 148.7 498.7 105.4 524.5 79.8C534.2 70.2 544.0 66.3 558.6 66.3C586.5 66.3 603.6 83.3 603.6 110.9C603.6 113.0 603.5 114.3 603.2 114.6C602.9 114.9 595.6 115.0 572.8 115.0C539.1 115.0 541.5 114.8 541.9 117.6C543.2 126.6 549.4 131.9 559.5 132.5C567.4 133.0 572.3 131.2 578.2 125.8C581.3 123.0 579.8 122.6 591.8 128.7C602.9 134.3 602.5 133.8 599.5 137.8C592.0 147.7 581.7 153.4 568.2 155.3C563.2 156.0 553.2 156.0 549.3 155.3ZM175.7 153.5C175.3 153.4 174.8 153.1 174.6 152.9C173.8 152.3 173.9 17.8 174.7 17.2C175.4 16.6 204.4 16.5 205.0 17.0C205.1 17.2 205.3 29.4 205.4 44.1L205.6 70.9L227.4 70.9L249.2 70.9L249.3 44.3C249.4 20.1 249.5 17.6 250.0 17.2C250.9 16.4 279.9 16.5 280.4 17.2C280.6 17.6 280.7 43.3 280.7 85.1C280.7 127.0 280.6 152.7 280.4 153.0C279.9 153.8 250.9 153.8 250.0 153.1C249.5 152.6 249.4 150.2 249.3 125.8L249.2 99.0L227.4 99.0L205.6 99.0L205.4 126.0C205.3 140.8 205.1 153.1 205.0 153.2C204.6 153.6 176.9 153.8 175.7 153.5ZM378.8 153.4L378.0 153.2L377.9 82.5C377.8 30.6 377.9 11.7 378.2 11.4C378.7 10.7 405.9 10.7 406.6 11.3C407.2 12.0 407.2 152.9 406.5 153.3C406.0 153.6 380.0 153.8 378.8 153.4ZM607.1 153.4L606.3 153.2L606.3 111.0L606.3 68.8L620.3 68.7C630.1 68.6 634.5 68.7 634.8 69.0C635.1 69.2 635.3 70.5 635.4 72.9C635.5 77.1 635.6 77.2 638.1 75.1C647.2 67.3 656.3 64.7 668.0 66.7C683.8 69.3 694.2 78.2 696.7 91.0C697.5 95.4 697.4 152.9 696.6 153.3C695.2 153.8 668.7 153.6 668.3 153.0C668.1 152.7 668.0 142.9 668.0 128.2C668.0 101.8 668.0 101.5 666.0 97.5C661.2 87.6 648.7 86.1 640.4 94.4C635.2 99.6 635.3 99.4 635.3 128.7C635.3 147.7 635.2 153.0 634.8 153.3C634.3 153.6 608.3 153.8 607.1 153.4ZM465.4 130.5C481.7 127.1 485.6 103.2 471.3 94.2C466.5 91.1 459.1 90.2 453.7 92.1C435.8 98.3 436.4 125.4 454.5 130.1C457.5 130.9 462.6 131.1 465.4 130.5ZM50.7 110.8C42.7 107.9 37.4 102.3 35.5 94.5C33.9 88.0 33.9 72.5 35.5 62.7C36.4 57.2 36.2 57.2 41.2 62.1C45.8 66.6 50.4 69.9 56.6 73.4C71.5 81.6 75.7 89.9 70.4 101.0C67.0 108.3 57.2 113.1 50.7 110.8ZM113.8 111.3C107.7 109.8 101.7 104.3 99.7 98.3C96.4 88.5 101.0 80.9 114.3 73.5C121.0 69.8 125.5 66.6 129.8 62.2C132.1 59.9 134.1 58.0 134.3 58.0C137.0 58.0 137.8 88.4 135.2 96.4C132.3 105.6 121.5 113.1 113.8 111.3ZM57.3 101.3C60.5 99.8 62.6 96.8 62.9 93.0C63.3 87.9 61.6 85.9 53.6 82.0C50.7 80.6 47.2 78.7 45.9 77.9C41.8 75.4 41.7 75.6 42.0 82.8C42.5 96.8 49.3 104.9 57.3 101.3ZM121.8 100.8C126.7 98.0 129.4 90.7 129.4 80.7C129.5 75.6 129.3 75.5 125.4 77.9C124.1 78.7 120.5 80.7 117.4 82.1C109.7 85.9 108.3 87.4 108.3 92.0C108.3 99.6 115.7 104.4 121.8 100.8ZM345.9 98.4C347.2 97.4 344.8 91.9 341.9 89.2C334.6 82.4 319.9 84.5 316.0 92.9C314.6 95.9 314.3 97.8 315.0 98.4C315.9 99.1 345.0 99.1 345.9 98.4ZM574.0 98.5C575.3 97.7 573.4 92.6 570.9 89.9C563.9 82.3 548.4 84.1 544.2 93.0C542.8 96.0 542.5 97.8 543.3 98.4C544.1 98.9 573.2 99.1 574.0 98.5Z"/></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 201.9"><title>Helgen</title><path fill="#2C3C5B" fill-rule="evenodd" d="M449.2 198.7C431.6 196.7 418.2 188.6 412.8 176.7C411.8 174.5 411.8 174.5 423.6 168.6C436.2 162.5 435.6 162.6 437.5 165.0C444.4 174.1 461.6 176.4 471.7 169.5C477.3 165.8 479.4 161.1 479.4 152.9C479.4 148.0 479.4 148.0 476.2 150.2C466.3 157.2 445.2 157.9 432.8 151.7C403.0 136.7 401.3 89.9 430.0 71.8C443.4 63.4 463.2 64.2 476.0 73.6C479.2 76.0 479.4 75.9 479.4 72.6C479.4 68.2 478.0 68.6 494.2 68.6C505.8 68.6 508.3 68.7 508.6 69.1C509.3 70.0 508.9 161.2 508.2 164.9C503.2 189.1 480.4 202.2 449.2 198.7ZM79.0 178.1C35.8 173.8 4.3 138.6 2.9 93.2C1.0 28.3 63.4 -16.4 118.4 10.5C171.1 36.4 182.8 111.0 140.9 154.0C124.6 170.8 100.1 180.3 79.0 178.1ZM95.3 166.3C137.8 159.3 164.8 112.7 152.3 68.1C139.8 23.6 93.8 1.2 54.9 20.8C14.7 40.9 1.2 96.0 27.0 135.1C42.6 158.7 68.9 170.7 95.3 166.3ZM323.6 155.6C313.8 153.8 309.7 152.2 303.0 147.8C279.2 131.9 276.2 99.2 296.7 79.4C309.0 67.4 325.5 63.4 343.9 67.7C358.2 71.1 369.4 81.6 373.4 95.5C375.0 101.2 376.0 113.5 374.9 114.6C374.6 114.9 367.4 115.0 344.6 115.0C327.7 115.0 314.4 115.2 314.2 115.3C313.2 115.9 313.9 120.4 315.3 123.3C318.4 129.4 324.4 132.5 333.3 132.4C341.0 132.3 344.1 131.1 349.7 126.0C351.3 124.6 352.7 123.5 352.9 123.5C353.6 123.5 372.9 133.4 373.1 133.9C373.4 134.7 372.4 136.4 369.8 139.6C363.0 147.7 354.0 152.8 341.9 155.0C339.0 155.5 325.7 156.0 323.6 155.6ZM549.3 155.3C512.9 148.7 498.7 105.4 524.5 79.8C534.2 70.2 544.0 66.3 558.6 66.3C586.5 66.3 603.6 83.3 603.6 110.9C603.6 113.0 603.5 114.3 603.2 114.6C602.9 114.9 595.6 115.0 572.8 115.0C539.1 115.0 541.5 114.8 541.9 117.6C543.2 126.6 549.4 131.9 559.5 132.5C567.4 133.0 572.3 131.2 578.2 125.8C581.3 123.0 579.8 122.6 591.8 128.7C602.9 134.3 602.5 133.8 599.5 137.8C592.0 147.7 581.7 153.4 568.2 155.3C563.2 156.0 553.2 156.0 549.3 155.3ZM175.7 153.5C175.3 153.4 174.8 153.1 174.6 152.9C173.8 152.3 173.9 17.8 174.7 17.2C175.4 16.6 204.4 16.5 205.0 17.0C205.1 17.2 205.3 29.4 205.4 44.1L205.6 70.9L227.4 70.9L249.2 70.9L249.3 44.3C249.4 20.1 249.5 17.6 250.0 17.2C250.9 16.4 279.9 16.5 280.4 17.2C280.6 17.6 280.7 43.3 280.7 85.1C280.7 127.0 280.6 152.7 280.4 153.0C279.9 153.8 250.9 153.8 250.0 153.1C249.5 152.6 249.4 150.2 249.3 125.8L249.2 99.0L227.4 99.0L205.6 99.0L205.4 126.0C205.3 140.8 205.1 153.1 205.0 153.2C204.6 153.6 176.9 153.8 175.7 153.5ZM378.8 153.4L378.0 153.2L377.9 82.5C377.8 30.6 377.9 11.7 378.2 11.4C378.7 10.7 405.9 10.7 406.6 11.3C407.2 12.0 407.2 152.9 406.5 153.3C406.0 153.6 380.0 153.8 378.8 153.4ZM607.1 153.4L606.3 153.2L606.3 111.0L606.3 68.8L620.3 68.7C630.1 68.6 634.5 68.7 634.8 69.0C635.1 69.2 635.3 70.5 635.4 72.9C635.5 77.1 635.6 77.2 638.1 75.1C647.2 67.3 656.3 64.7 668.0 66.7C683.8 69.3 694.2 78.2 696.7 91.0C697.5 95.4 697.4 152.9 696.6 153.3C695.2 153.8 668.7 153.6 668.3 153.0C668.1 152.7 668.0 142.9 668.0 128.2C668.0 101.8 668.0 101.5 666.0 97.5C661.2 87.6 648.7 86.1 640.4 94.4C635.2 99.6 635.3 99.4 635.3 128.7C635.3 147.7 635.2 153.0 634.8 153.3C634.3 153.6 608.3 153.8 607.1 153.4ZM465.4 130.5C481.7 127.1 485.6 103.2 471.3 94.2C466.5 91.1 459.1 90.2 453.7 92.1C435.8 98.3 436.4 125.4 454.5 130.1C457.5 130.9 462.6 131.1 465.4 130.5ZM50.7 110.8C42.7 107.9 37.4 102.3 35.5 94.5C33.9 88.0 33.9 72.5 35.5 62.7C36.4 57.2 36.2 57.2 41.2 62.1C45.8 66.6 50.4 69.9 56.6 73.4C71.5 81.6 75.7 89.9 70.4 101.0C67.0 108.3 57.2 113.1 50.7 110.8ZM113.8 111.3C107.7 109.8 101.7 104.3 99.7 98.3C96.4 88.5 101.0 80.9 114.3 73.5C121.0 69.8 125.5 66.6 129.8 62.2C132.1 59.9 134.1 58.0 134.3 58.0C137.0 58.0 137.8 88.4 135.2 96.4C132.3 105.6 121.5 113.1 113.8 111.3ZM57.3 101.3C60.5 99.8 62.6 96.8 62.9 93.0C63.3 87.9 61.6 85.9 53.6 82.0C50.7 80.6 47.2 78.7 45.9 77.9C41.8 75.4 41.7 75.6 42.0 82.8C42.5 96.8 49.3 104.9 57.3 101.3ZM121.8 100.8C126.7 98.0 129.4 90.7 129.4 80.7C129.5 75.6 129.3 75.5 125.4 77.9C124.1 78.7 120.5 80.7 117.4 82.1C109.7 85.9 108.3 87.4 108.3 92.0C108.3 99.6 115.7 104.4 121.8 100.8ZM345.9 98.4C347.2 97.4 344.8 91.9 341.9 89.2C334.6 82.4 319.9 84.5 316.0 92.9C314.6 95.9 314.3 97.8 315.0 98.4C315.9 99.1 345.0 99.1 345.9 98.4ZM574.0 98.5C575.3 97.7 573.4 92.6 570.9 89.9C563.9 82.3 548.4 84.1 544.2 93.0C542.8 96.0 542.5 97.8 543.3 98.4C544.1 98.9 573.2 99.1 574.0 98.5Z"/></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 214.5"><title>Helgen</title><ellipse cx="100" cy="107.25" rx="96" ry="103" fill="#2C3C5B"/><path fill="#51F3DA" fill-rule="evenodd" d="M96.2 213.2C32.3 210.2 -12.0 145.2 4.7 79.0C15.9 34.9 53.0 3.1 95.7 1.2C161.5 -1.7 211.4 63.4 196.0 132.3C185.1 181.0 142.7 215.4 96.2 213.2ZM104.0 199.9C160.7 196.6 198.8 136.5 181.2 78.1C169.4 39.2 134.8 12.9 97.1 14.5C40.8 16.7 1.8 75.9 18.2 134.3C29.5 174.7 65.4 202.1 104.0 199.9ZM62.5 132.8C60.4 132.5 57.0 131.1 53.2 129.0C44.3 124.0 40.5 116.9 39.4 103.5C39.0 98.4 39.4 83.9 40.1 78.2C40.2 76.8 41.7 67.7 41.7 67.5C41.9 67.2 42.3 67.6 46.7 71.9C52.2 77.4 53.3 78.4 57.7 81.4C60.9 83.6 63.3 85.0 67.7 87.3C82.4 95.3 87.9 104.4 84.6 115.9C82.6 122.8 79.1 126.8 72.0 130.3C67.7 132.4 65.1 133.1 62.5 132.8ZM137.4 132.8C133.3 132.3 126.2 128.4 122.9 125.0C117.9 119.7 115.7 110.4 118.0 103.9C119.8 98.6 123.9 94.1 130.7 89.7C132.1 88.8 133.6 88.0 136.9 86.2C141.2 83.9 145.9 80.8 149.6 77.9C150.6 77.1 152.3 75.5 155.7 72.1C160.4 67.3 160.7 67.1 160.8 67.7C161.0 68.2 162.3 76.5 162.4 77.7C163.1 83.0 163.5 99.2 163.1 104.2C161.9 117.6 157.9 124.4 148.5 129.4C143.2 132.2 140.2 133.1 137.4 132.8ZM65.6 120.9C70.8 119.4 73.8 115.0 73.8 109.1C73.8 103.2 72.1 101.3 62.4 97.0C57.7 94.8 52.6 91.9 49.6 89.7C49.0 89.2 48.9 89.3 48.7 90.3C47.4 95.7 49.0 107.8 51.6 113.2C53.7 117.4 57.0 120.2 60.8 121.1C61.9 121.3 64.5 121.2 65.6 120.9ZM142.2 121.0C146.5 119.8 150.2 115.9 151.9 110.9C153.9 104.7 154.9 95.2 154.0 90.6C153.7 89.3 153.6 89.2 152.9 89.8C149.3 92.3 145.2 94.6 139.0 97.5C130.9 101.2 129.0 103.1 128.7 107.9C128.3 114.7 131.8 119.8 137.6 121.1C138.6 121.3 141.3 121.2 142.2 121.0Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -21,13 +21,13 @@ La lupa busca por unidad (H00xx), serie (HBOX5-E07-A010-R0006), conector (P15, J
| **Diagnosticar** una unidad que falla en la máquina | [Diagnóstico en campo](diagnostico/procedimiento-diagnostico-campo.md) | [Diagnóstico en campo](formularios/diagnostico.html) |
| **Diagnosticar** una unidad en la mesa | [Diagnóstico en banco](procedimientos/diagnostico-banco.md) | [Banco](formularios/banco.html) |
| **Instalar en sitio** (ICAVE, LZC, EIT, TIMSA) | [Checklist de puesta en marcha](procedimientos/v5-checklist-puesta-en-marcha.md) | [Instalación en campo](formularios/instalacion.html) |
| **Ver los tableros** (kanban, ingeniería, producción, actas) | [Tableros](tableros/index.md) | — |
| **Inventario** (existencias, entradas y salidas) | [Inventario](inventario/inventario.html) | — |
| **Ver los tableros** (pendientes, ingeniería, producción, actas) | [Tableros](tableros/index.md) | — |
| **Inventario** (existencias, entradas y salidas) | [Inventario](tableros/inventario.md) | — |
| **Pedir una tarjeta o saber qué versión va** | [Tarjetas y hardware](hardware/index.md) | — |
| **Descargar firmware** (por tarjeta, con SHA-256) | [Descargas](descargas/index.md) | [Reflasheo](formularios/reflasheo.html) |
| Reflashear una unidad en taller | [Reflasheo de unidades](procedimientos/reflasheo-unidades.md) | [Reflasheo](formularios/reflasheo.html) |
| Enviar, recibir o reportar un daño | [Ciclo de vida](procedimientos/ciclo-de-vida.md) | [Envío](formularios/envio.html) · [Recepción](formularios/recepcion.html) · [Daño](formularios/dano.html) |
| Ver todo lo que le pasó a una unidad | [Expediente de una unidad](unidad.html) | — |
| Ver todo lo que le pasó a una unidad | [Expediente de una unidad](tableros/unidad.md) | — |
| Saber qué tarjeta tengo en la mano | [Manuales por tarjeta](manuales/index.md) | — |
| Entender cómo llegamos aquí | [Historia del proyecto](historia/index.md) | — |
@ -43,7 +43,7 @@ La lupa busca por unidad (H00xx), serie (HBOX5-E07-A010-R0006), conector (P15, J
| <span class="badge-v3">V3</span> | 3 unidades en campo (RTK-003/006/014). Registro congelado. | — | 4.0.0 | [Manual V3](manuales/manual-tarjeta-V3.md) |
| <span class="badge-fw">firmware</span> | El binario se elige **por tarjeta**, nunca por número de versión. 0.6.3 en banco desde el 1-oct, no para campo. | | | [Descargas](descargas/index.md#firmware-del-hbox-que-binario-va-en-que-tarjeta) · [notas de versión](descargas/notas-de-version.md) |
Pedido total: **570 HBOX** (113 V5.6 + 327 V5.7 + 130 v6). Avance real contra el plan: [Tablero de producción](tableros/produccion.html).
Pedido total: **570 HBOX** (113 V5.6 + 327 V5.7 + 130 v6). Avance real contra el plan: [Tablero de producción](tableros/produccion.md).
## Hoy — 19 de octubre de 2026
@ -56,6 +56,6 @@ Pedido total: **570 HBOX** (113 V5.6 + 327 V5.7 + 130 v6). Avance real contra el
- **Decisiones de Leo (D-19-1…4):** puertos de V5.6/V5.7 (si van, entran al piloto), Dbk2, auto-sync y clase HV24: [§7](hardware/re-auditoria-19oct.md#7-decisiones-para-leo).
- **V5.7 sin Mean Well acepta 9–32 V** (máquinas de 12 y 24 V). Recomendado: TVS bidireccional para que la polaridad invertida no abra el fusible.
- **Jesús:** medir en una V5.5 el límite del cargador con TP1 ([§4.5 de la auditoría del 16-oct](hardware/auditoria-produccion-16oct.md#45-prueba-definitiva-con-tp1-clprog-cualquier-tarjeta-cualquier-corriente)): (TP1 ÷ 32) ÷ voltaje en RS1 ≈ 3.1 confirma el defecto.
- **Plan de 570 unidades** (113 / 327 / 130). Pendiente: la caja plástica del HBOX, donde el cable Ethernet no cabe. Tareas y responsables en el [Kanban](tableros/kanban.html).
- **Plan de 570 unidades** (113 / 327 / 130). Pendiente: la caja plástica del HBOX, donde el cable Ethernet no cabe. Tareas y responsables en [Pendientes](tableros/pendientes.md).
Fuente: repositorio Drogheda (`hbox-docs` se publica desde él). Contacto: leo@helgen.tech

View File

@ -8,15 +8,15 @@ tags: [project-drogheda, hbox, inventario, produccion, compras]
Qué hay en el taller para producir e instalar HBOX: tarjetas, módulos, gabinetes, cajas, fuentes, packs, antenas, cables y los componentes de entrega larga. No es una lista de BOM: aquí solo entra lo que se cuenta a mano.
**[Abrir el inventario →](inventario.html)**
**[Abrir el inventario →](../tableros/inventario.md)**
## Qué hay aquí
| Página | Para qué |
|---|---|
| [Inventario — existencias y movimientos](inventario.html) | Tabla de existencias por ítem con mínimo y alerta; alta de ítem; movimiento de **entrada** (compra, devolución) o **salida** (ensamble, envío, baja) con motivo y liga a acta/unidad; exportar CSV |
| [Tablero de producción](../tableros/produccion.html) | Lo que el inventario tiene que cubrir: 113 V5.6 · 327 V5.7 · 130 v6 |
| [Kanban — pista Compras](../tableros/kanban.html) | Pedidos en curso (SD-15B-05 ×113, paquetes LFP, gabinetes, cajas HBOX, antenas) |
| [Inventario — existencias y movimientos](../tableros/inventario.md) | Tabla de existencias por ítem con mínimo y alerta; alta de ítem; movimiento de **entrada** (compra, devolución) o **salida** (ensamble, envío, baja) con motivo y liga a acta/unidad; exportar CSV |
| [Tablero de producción](../tableros/produccion.md) | Lo que el inventario tiene que cubrir: 113 V5.6 · 327 V5.7 · 130 v6 |
| [Pendientes, pista Compras](../tableros/pendientes.md?pista=compras) | Pedidos en curso (SD-15B-05 ×113, paquetes LFP, gabinetes, cajas HBOX, antenas) |
## Qué se registra y qué no
@ -24,7 +24,7 @@ Qué hay en el taller para producir e instalar HBOX: tarjetas, módulos, gabinet
|---|---|
| Tarjetas EinarBoard por versión (V5.5, V5.6, V5.7, v6) | Resistencias, capacitores y componentes de la BOM normal |
| Módulos: ArduSimple simpleRTK2B Pro, 4G NTRIP Master | Herramienta y consumibles del banco |
| Gabinetes IP67, cajas plásticas HBOX, fuentes SD-15B, packs LFP | Unidades ya ensambladas (esas viven en el [tablero](../tablero.html) por su acta) |
| Gabinetes IP67, cajas plásticas HBOX, fuentes SD-15B, packs LFP | Unidades ya ensambladas (esas viven en el [tablero](../tableros/actas.md) por su acta) |
| Antenas GNSS y LTE, cable Ethernet, conectores GX16, fusibles DIN | — |
| Componentes críticos de entrega larga: LTC4040, TPS54360B, SRP1265A | — |
@ -44,7 +44,7 @@ El stock es la **suma de los movimientos**; nadie lo escribe a mano. Cada movimi
- El **mínimo** lo fija quien compra (Leo), pensando en el tiempo de entrega: chips y antenas RTK tardan ~4 semanas; LTC4040 y la fuente a bordo son de entrega larga.
- Para el pedido de 570, la fuente Mean Well SD-15B-05 tiene mínimo **113** (solo la usa V5.6).
- Por debajo del mínimo el ítem se marca en rojo aquí y en el [tablero de producción](../tableros/produccion.html). La alerta no manda correos: hay que mirar el tablero.
- Por debajo del mínimo el ítem se marca en rojo aquí y en el [tablero de producción](../tableros/produccion.md). La alerta no manda correos: hay que mirar el tablero.
- Mínimo 0 = sin alerta (consumibles que no vale la pena vigilar).
## Conteo inicial (pendiente del 13-oct)

View File

@ -1,241 +1,18 @@
<!doctype html>
<!-- Desde el 20-oct-2026 este tablero vive dentro del sitio: docs/tableros/inventario.md → /tableros/inventario/
Este archivo solo redirige (conserva ?serie=, ?id=, ?pista=… y #…) para que las ligas viejas
guardadas en pendientes y actas sigan sirviendo. No se edita: los cambios van en tableros/inventario.md. -->
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inventario de materiales — HBOX</title>
<link rel="stylesheet" href="../tableros/tablero.css">
<style>
table.t td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
table.t th.num{text-align:right}
table.t tr.alerta td.num.stock{color:var(--falla);font-weight:700}
table.t td .cod{font-weight:700;cursor:pointer;color:var(--naranja)}
table.t td .cat{font-size:11px;color:var(--tinta2);display:block}
table.t td button{padding:5px 9px;font-size:12.5px;margin:1px 2px 1px 0}
.acciones{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 12px}
.acciones .cnt{color:var(--tinta2);font-size:13px;margin-left:auto}
.mov-fila{display:grid;grid-template-columns:120px 70px 70px 1fr;gap:6px 10px;padding:6px 0;border-bottom:1px dashed var(--linea);font-size:13px;align-items:baseline}
.mov-fila .d{font-variant-numeric:tabular-nums}
.mov-fila .pos{color:var(--ok);font-weight:700} .mov-fila .neg{color:var(--falla);font-weight:700}
@media(max-width:560px){.mov-fila{grid-template-columns:1fr 1fr}}
.ayuda{font-size:12.5px;color:var(--tinta2);margin-top:3px}
.mini{font-size:12.5px;color:var(--tinta2)}
@media(max-width:760px){#cont table.t{min-width:780px}} /* en teléfono: desliza de lado, no aplastes columnas */
</style>
<meta name="robots" content="noindex">
<title>Inventario - Drogheda</title>
<script>location.replace('/tableros/inventario/' + location.search + location.hash);</script>
<meta http-equiv="refresh" content="0; url=/tableros/inventario/">
<style>body{margin:0;padding:24px;background:#0E1524;color:#E7ECF5;font:16px/1.5 system-ui,sans-serif}a{color:#7FE9D8}</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Inventario de materiales</h1><div class="meta" id="meta">Cargando…</div></div>
<nav>
<a href="../">← Documentación</a>
<a href="../tableros/produccion.html">Producción</a>
<a href="../tableros/kanban.html">Pendientes</a>
<a href="./">Política de inventario</a>
</nav>
</header>
<details class="como">
<summary>Cómo se usa</summary>
<ol>
<li>El <b>stock no se escribe a mano</b>: es la suma de los movimientos. Llegó material → <b>Entrada</b>. Se usó en un ensamble o se envió → <b>Salida</b> con la unidad o el acta como referencia.</li>
<li>Contaste y no cuadra → <b>Ajuste</b> con el conteo físico; el sistema calcula la diferencia y la deja registrada con tu nombre.</li>
<li>Toca el <b>código</b> para ver los movimientos de ese ítem. Cada uno dice quién, cuándo, cuánto y por qué.</li>
<li><b>Alerta</b> (rojo) = stock por debajo del mínimo. El mínimo lo pone quien compra; el tablero de producción lo repite.</li>
<li><b>Nuevo ítem</b> solo para cosas que se cuentan: tarjetas, módulos, gabinetes, cajas, fuentes, packs, antenas, cables y los componentes de entrega larga. Nada de resistencias.</li>
<li><b>Exportar CSV</b> baja la tabla para Excel. Nada se borra: si un ítem ya no aplica, ponle «(obsoleto)» en la descripción.</li>
</ol>
</details>
<div class="acciones">
<select id="fCat"><option value="">Todas las categorías</option></select>
<select id="fVer"><option value="">Aplica a: todas</option></select>
<label style="margin:0;display:flex;gap:6px;align-items:center;font-weight:500"><input type="checkbox" id="fAlerta" style="width:auto"> solo alertas</label>
<input type="search" id="fBuscar" placeholder="Buscar código o descripción…" style="flex:1;min-width:150px">
<button id="bMov">+ Movimiento</button>
<button id="bNuevo" class="sec">+ Nuevo ítem</button>
<a href="/api/inventario/export.csv" class="sec" style="padding:8px 12px;border:1px solid var(--linea);border-radius:9px;text-decoration:none;font-size:14px;font-weight:600;background:var(--tarjeta);color:var(--tinta2)">⬇ Exportar CSV</a>
<span class="cnt" id="cnt"></span>
</div>
<div class="msg" id="aviso"></div>
<div id="cont" class="tblwrap"></div>
</div>
<dialog id="dlg"></dialog>
<script>
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 $ = id => document.getElementById(id);
const CAT = { tarjeta: 'Tarjeta', modulo: 'Módulo', gabinete: 'Gabinete', caja: 'Caja', fuente: 'Fuente', bateria: 'Batería', antena: 'Antena', cable: 'Cable', conector: 'Conector', 'componente-critico': 'Componente crítico', otro: 'Otro' };
const VERS = ['V5.5', 'V5.6', 'V5.7', 'v6'];
const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' };
let ITEMS = [], ADMIN = false, USER = '';
const dlg = $('dlg'), aviso = $('aviso');
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 4000 : 9000); }
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; }
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(CAT).forEach(([k, v]) => $('fCat').append(el('option', { value: k, text: v })));
VERS.forEach(v => $('fVer').append(el('option', { value: v, text: 'Aplica a: ' + v })));
function visibles() {
const cat = $('fCat').value, ver = $('fVer').value, solo = $('fAlerta').checked, q = $('fBuscar').value.trim().toLowerCase();
return ITEMS.filter(i => (!cat || i.categoria === cat) && (!ver || (i.aplica_a || []).includes(ver)) && (!solo || i.alerta)
&& (!q || (i.codigo + ' ' + i.descripcion + ' ' + (i.proveedor || '') + ' ' + (i.ubicacion || '')).toLowerCase().includes(q)));
}
function pintar() {
const cont = $('cont'); cont.innerHTML = '';
const vis = visibles();
$('cnt').textContent = vis.length + ' ítem(s)' + (vis.length !== ITEMS.length ? ' de ' + ITEMS.length : '') + ' · ' + vis.filter(i => i.alerta).length + ' en alerta';
if (!vis.length) { cont.append(el('div', { class: 'vacio', text: ITEMS.length ? 'Nada coincide con el filtro.' : 'Todavía no hay ítems. Crea el primero con «Nuevo ítem» o corre la siembra.' })); return; }
const t = el('table', { class: 't' });
t.innerHTML = '<tr><th>Código</th><th>Descripción</th><th>Aplica a</th><th class="num">Stock</th><th class="num">Mínimo</th><th>Ubicación</th><th>Proveedor</th><th></th></tr>';
vis.forEach(i => {
const tr = el('tr', { class: i.alerta ? 'alerta' : '' });
const cod = el('span', { class: 'cod', text: i.codigo, title: 'Ver movimientos' }); cod.addEventListener('click', () => verMovimientos(i));
tr.append(el('td', null, cod, el('span', { class: 'cat', text: CAT[i.categoria] || i.categoria })));
tr.append(el('td', null, document.createTextNode(i.descripcion), i.notas ? el('div', { class: 'mini', text: i.notas.length > 90 ? i.notas.slice(0, 89) + '…' : i.notas }) : null));
const ap = el('td'); (i.aplica_a || []).forEach(v => ap.append(el('span', { class: BADGE[v] || 'pill pend', text: v, style: 'margin-right:3px' }))); tr.append(ap);
tr.append(el('td', { class: 'num stock', html: esc(i.stock) + ' <span class="mini">' + esc(i.unidad) + '</span>' + (i.alerta ? ' <span class="pill falla">alerta</span>' : '') }));
tr.append(el('td', { class: 'num', text: i.minimo || '—' }));
tr.append(el('td', { text: i.ubicacion || '—' }));
tr.append(el('td', { html: esc(i.proveedor || '—') + (i.codigo_proveedor ? '<div class="mini">' + esc(i.codigo_proveedor) + '</div>' : '') }));
const acc = el('td', { style: 'white-space:nowrap' });
const bm = el('button', { text: 'Movimiento' }); bm.addEventListener('click', () => movimiento(i));
const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(i));
acc.append(bm, be); tr.append(acc);
t.append(tr);
});
cont.append(t);
}
async function cargar() {
$('meta').textContent = 'Actualizando…';
try {
const j = await api('/api/inventario');
ITEMS = j.items || []; ADMIN = !!j.admin; USER = j.user || '';
pintar();
$('meta').textContent = ITEMS.length + ' ítems · ' + (j.alertas || 0) + ' en alerta · actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }) + (USER ? ' · ' + USER : '');
} catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo cargar el inventario: ' + e.message })); $('meta').textContent = 'Error'; }
}
['fCat', 'fVer', 'fAlerta'].forEach(id => $(id).addEventListener('change', pintar));
$('fBuscar').addEventListener('input', pintar);
// ---------- diálogos ----------
function abrirDialogo(titulo, cuerpoFn, botones) {
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo }));
const x = el('button', { type: 'button', text: '✕' }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
const m = el('div', { class: 'msg' });
cuerpoFn(cuerpo, m); cuerpo.append(m);
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
const cancelar = el('button', { type: 'button', class: 'sec', text: botones ? 'Cancelar' : 'Cerrar' }); cancelar.addEventListener('click', () => dlg.close()); pie.append(cancelar);
(botones || []).forEach(([txt, fn]) => { const b = el('button', { type: 'button', text: txt }); b.addEventListener('click', async () => { b.disabled = true; try { await fn(m); } catch (e) { m.className = 'msg err'; m.textContent = e.message; } b.disabled = false; }); pie.append(b); });
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
}
function selItems(sel) {
const s = el('select'); s.append(el('option', { value: '', text: '— elegir ítem —' }));
ITEMS.forEach(i => s.append(el('option', { value: i.id, text: i.codigo + ' — ' + i.descripcion + ' (' + i.stock + ' ' + i.unidad + ')' })));
if (sel) s.value = sel.id; return s;
}
function movimiento(item) {
abrirDialogo('Registrar movimiento', (c) => {
const fItem = selItems(item);
const fTipo = el('select'); [['entrada', 'Entrada — llegó material (+)'], ['salida', 'Salida — se usó o se envió (−)'], ['ajuste', 'Ajuste — conteo físico (fija el stock)']].forEach(([v, t]) => fTipo.append(el('option', { value: v, text: t })));
const fCant = el('input', { type: 'number', min: '0', step: '1', inputmode: 'numeric', placeholder: '0' });
const fMot = el('input', { type: 'text', maxlength: '200', placeholder: 'llegó pedido Mouser · ensamble · envío a ICAVE · conteo físico' });
const fRef = el('input', { type: 'text', maxlength: '120', placeholder: 'HBOX5-E07-A010-R0006 · OC-0042 · acta de envío' });
const ayudaCant = el('div', { class: 'ayuda', text: 'Cantidad que entra.' });
fTipo.addEventListener('change', () => { ayudaCant.textContent = fTipo.value === 'entrada' ? 'Cantidad que entra.' : fTipo.value === 'salida' ? 'Cantidad que sale. No puede dejar el stock en negativo.' : 'Lo que contaste físicamente: el stock quedará exactamente en ese número.'; });
c.append(campo('Ítem', fItem), campo('Tipo', fTipo));
const g = el('div', { class: 'dos-col' }); const cc = campo('Cantidad', fCant); cc.append(ayudaCant); g.append(cc, campo('Referencia (unidad, acta u orden)', fRef)); c.append(g);
c.append(campo('Motivo (obligatorio)', fMot));
c._leer = () => ({ item_id: fItem.value, tipo: fTipo.value, cantidad: fCant.value === '' ? null : +fCant.value, motivo: fMot.value.trim(), referencia: fRef.value.trim() });
setTimeout(() => (item ? fCant : fItem).focus(), 50);
}, [['Registrar', async (m) => {
const d = dlg.querySelector('.dcuerpo')._leer();
if (!d.item_id) throw new Error('Elige el ítem.');
if (d.cantidad == null || isNaN(d.cantidad)) throw new Error('Escribe la cantidad.');
if (!d.motivo) throw new Error('Escribe el motivo.');
const j = await api('/api/inventario/movimiento', json('POST', d));
dlg.close(); mensaje('Movimiento registrado: ' + j.item.codigo + ' ahora tiene ' + j.item.stock + ' ' + j.item.unidad + '.', true); cargar();
}]]);
}
function formItem(c, i) {
i = i || {};
const f = {};
f.codigo = el('input', { type: 'text', maxlength: '40', value: i.codigo || '', placeholder: 'EB-V56 · SD-15B-05', style: 'text-transform:uppercase' });
f.descripcion = el('input', { type: 'text', maxlength: '200', value: i.descripcion || '' });
f.categoria = el('select'); Object.entries(CAT).forEach(([k, v]) => f.categoria.append(el('option', { value: k, text: v }))); f.categoria.value = i.categoria || 'otro';
f.unidad = el('select'); [['pza', 'pza (piezas)'], ['m', 'm (metros)']].forEach(([v, t]) => f.unidad.append(el('option', { value: v, text: t }))); f.unidad.value = i.unidad || 'pza';
f.aplica = el('div', { class: 'lista-chk', style: 'max-height:none;display:flex;gap:14px;flex-wrap:wrap' });
VERS.forEach(v => { const l = el('label', { style: 'margin:0' }); const ck = el('input', { type: 'checkbox', value: v }); ck.checked = (i.aplica_a || []).includes(v); l.append(ck, document.createTextNode(v)); f.aplica.append(l); });
f.minimo = el('input', { type: 'number', min: '0', step: '1', value: i.minimo || 0 });
f.ubicacion = el('input', { type: 'text', maxlength: '120', value: i.ubicacion || '', placeholder: 'Anaquel A2 · taller Veracruz' });
f.proveedor = el('input', { type: 'text', maxlength: '120', value: i.proveedor || '' });
f.codigo_proveedor = el('input', { type: 'text', maxlength: '60', value: i.codigo_proveedor || '', placeholder: 'LCSC C2057152 · Mouser 709-SD15B05' });
f.notas = el('textarea', { rows: '2' }); f.notas.value = i.notas || '';
const g = el('div', { class: 'dos-col' });
g.append(campo('Código (único)', f.codigo), campo('Categoría', f.categoria));
c.append(g, campo('Descripción', f.descripcion), campo('Aplica a (versión de tarjeta)', f.aplica));
const g2 = el('div', { class: 'dos-col' }); g2.append(campo('Unidad', f.unidad), campo('Mínimo (0 = sin alerta)', f.minimo), campo('Ubicación', f.ubicacion), campo('Proveedor', f.proveedor), campo('Código del proveedor', f.codigo_proveedor)); c.append(g2);
if (!i.id) { f.stock_inicial = el('input', { type: 'number', min: '0', step: '1', value: '0' }); c.append(campo('Stock inicial (queda registrado como entrada)', f.stock_inicial)); }
c.append(campo('Notas', f.notas));
return () => {
const d = { codigo: f.codigo.value.trim().toUpperCase(), descripcion: f.descripcion.value.trim(), categoria: f.categoria.value, unidad: f.unidad.value,
aplica_a: [...f.aplica.querySelectorAll('input:checked')].map(x => x.value), minimo: +f.minimo.value || 0, ubicacion: f.ubicacion.value.trim(),
proveedor: f.proveedor.value.trim(), codigo_proveedor: f.codigo_proveedor.value.trim(), notas: f.notas.value.trim() };
if (f.stock_inicial) d.stock_inicial = +f.stock_inicial.value || 0;
return d;
};
}
function nuevo() {
let leer;
abrirDialogo('Nuevo ítem', c => { leer = formItem(c); }, [['Crear ítem', async () => {
const d = leer(); if (!d.codigo) throw new Error('Escribe el código.'); if (!d.descripcion) throw new Error('Escribe la descripción.');
await api('/api/inventario', json('POST', d)); dlg.close(); mensaje('Ítem ' + d.codigo + ' creado.', true); cargar();
}]]);
}
function editar(i) {
let leer;
abrirDialogo('Editar ' + i.codigo, c => {
leer = formItem(c, i);
const h = el('div', { class: 'hist' });
h.append(el('div', { html: '<b>Creado</b> por ' + esc(i.creado_por) + ' el ' + esc(fmt(i.creado_en)) + ' · stock actual <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b> (se cambia con movimientos, no aquí)' }));
(i.historial || []).slice().reverse().slice(0, 12).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + esc(x.cambio) })));
c.append(h);
}, [['Guardar', async () => {
const d = leer(); const cambios = {};
Object.keys(d).forEach(k => { if (JSON.stringify(d[k]) !== JSON.stringify(i[k] == null ? (Array.isArray(d[k]) ? [] : (typeof d[k] === 'number' ? 0 : '')) : i[k])) cambios[k] = d[k]; });
if (Object.keys(cambios).length) await api('/api/inventario/item?id=' + encodeURIComponent(i.id), json('PATCH', cambios));
dlg.close(); mensaje('Ítem guardado.', true); cargar();
}]]);
}
async function verMovimientos(i) {
abrirDialogo('Movimientos de ' + i.codigo, async c => {
c.append(el('p', { class: 'sub', html: esc(i.descripcion) + ' · stock <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b>' + (i.minimo ? ' · mínimo ' + esc(i.minimo) : '') }));
const lista = el('div', { text: 'Cargando…' }); c.append(lista);
const b = el('button', { text: '+ Movimiento', style: 'margin-top:10px' }); b.addEventListener('click', () => movimiento(i)); c.append(b);
try {
const j = await api('/api/inventario/movimientos?item_id=' + encodeURIComponent(i.id));
lista.innerHTML = '';
if (!j.total) { lista.append(el('div', { class: 'vacio', text: 'Sin movimientos. El stock es 0 hasta que registres una entrada o un ajuste.' })); return; }
lista.append(el('div', { class: 'mov-fila', style: 'font-weight:700;color:var(--tinta2);font-size:11.5px;text-transform:uppercase' }, el('span', { text: 'Cuándo · quién' }), el('span', { text: 'Tipo' }), el('span', { text: 'Δ · stock' }), el('span', { text: 'Motivo · referencia' })));
j.movimientos.forEach(m => {
lista.append(el('div', { class: 'mov-fila' },
el('span', { html: esc(fmt(m.cuando)) + '<br><b>' + esc(m.quien) + '</b>' }),
el('span', { text: m.tipo + (m.tipo === 'ajuste' ? ' → ' + m.cantidad : '') }),
el('span', { class: 'd', html: '<span class="' + (m.delta >= 0 ? 'pos' : 'neg') + '">' + (m.delta >= 0 ? '+' : '') + esc(m.delta) + '</span> · ' + esc(m.stock_despues) }),
el('span', { html: esc(m.motivo) + (m.referencia ? '<br><span class="mini">ref. ' + esc(m.referencia) + '</span>' : '') })));
});
} catch (e) { lista.textContent = 'No se pudieron leer los movimientos: ' + e.message; }
});
}
$('bMov').addEventListener('click', () => movimiento(null));
$('bNuevo').addEventListener('click', nuevo);
cargar();
</script>
<p>Este tablero ahora está dentro del sitio: <a href="/tableros/inventario/">Inventario</a>.</p>
</body>
</html>

33
docs/js/sitio.js Normal file
View File

@ -0,0 +1,33 @@
/* Sitio Drogheda: detalles que necesitan datos del servidor en todas las páginas.
- Junto a «Pendientes» en el menú lateral, cuántos hay abiertos (de /api/tareas/resumen).
Si el servidor no responde, el menú queda igual. */
(function () {
function cuentaPendientes() {
var ligas = [].slice.call(document.querySelectorAll('.md-nav--primary a.md-nav__link')).filter(function (a) {
try { return /\/tableros\/pendientes\/?$/.test(new URL(a.href, location.href).pathname); } catch (e) { return false; }
});
if (!ligas.length) return;
fetch('/api/tareas/resumen', { cache: 'no-store', credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
if (!j || !j.ok || !j.por_pista) return;
var abiertos = 0;
Object.keys(j.por_pista).forEach(function (p) {
var e = j.por_pista[p] || {};
abiertos += (e['por-hacer'] || 0) + (e['en-curso'] || 0) + (e['bloqueado'] || 0);
});
var vencidos = (j.vencidas || []).length;
ligas.forEach(function (a) {
if (a.querySelector('.hg-cuenta')) return;
var s = document.createElement('span');
s.className = 'hg-cuenta' + (vencidos ? ' hg-cuenta--vencidos' : '');
s.textContent = abiertos;
s.title = abiertos + ' abiertos' + (vencidos ? ', ' + vencidos + ' vencido' + (vencidos > 1 ? 's' : '') : '');
a.appendChild(s);
});
})
.catch(function () {});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', cuentaPendientes);
else cuentaPendientes();
})();

43
docs/js/tema.js Normal file
View File

@ -0,0 +1,43 @@
/* Modo claro u oscuro en las páginas sueltas (actas, cuenta, administración).
Lee la misma preferencia que el sitio (Material la guarda en localStorage con la
clave "<scope>.__palette"; scope = extra.scope de mkdocs.yml = "/drogheda/").
Oscuro por omisión. Va en el <head> de cada página, antes de su hoja de estilo,
para que no parpadee. Agrega un botón de modo al encabezado de la página. */
(function () {
var CLAVE = '/drogheda/.__palette';
var html = document.documentElement;
function leer() {
try { var p = JSON.parse(localStorage.getItem(CLAVE) || 'null'); return p && p.color && p.color.scheme === 'default' ? 'claro' : 'oscuro'; }
catch (e) { return 'oscuro'; }
}
function aplicar(modo) { if (modo === 'claro') html.setAttribute('data-tema', 'claro'); else html.removeAttribute('data-tema'); }
function guardar(modo) {
// mismo formato que Material: índice 0 = oscuro (slate), 1 = claro (default)
var p = { index: modo === 'claro' ? 1 : 0, color: { media: '', scheme: modo === 'claro' ? 'default' : 'slate', primary: 'custom', accent: 'custom' } };
try { localStorage.setItem(CLAVE, JSON.stringify(p)); } catch (e) {}
}
aplicar(leer());
var SOL = '<path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM12 2.8v2.2M12 19v2.2M4.9 4.9l1.6 1.6M17.5 17.5l1.6 1.6M2.8 12H5M19 12h2.2M4.9 19.1l1.6-1.6M17.5 6.5l1.6-1.6"/>';
var LUNA = '<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/>';
function boton() {
var cab = document.querySelector('header.top');
if (!cab || cab.querySelector('.hg-modo')) return;
var b = document.createElement('button');
b.type = 'button'; b.className = 'hg-modo';
function pintar() {
var claro = html.getAttribute('data-tema') === 'claro';
b.setAttribute('aria-label', claro ? 'Cambiar a modo oscuro' : 'Cambiar a modo claro');
b.title = b.getAttribute('aria-label');
b.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (claro ? LUNA : SOL) + '</svg>';
}
b.addEventListener('click', function () {
var modo = html.getAttribute('data-tema') === 'claro' ? 'oscuro' : 'claro';
aplicar(modo); guardar(modo); pintar();
});
pintar();
cab.appendChild(b);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boton); else boton();
// si la persona cambia el modo en otra pestaña del sitio, esta página lo sigue
window.addEventListener('storage', function (e) { if (e.key === CLAVE) aplicar(leer()); });
})();

View File

@ -15,7 +15,7 @@ version: 1.2
- Tiempo: acta de envío o recepción 15–30 min por unidad.
- Necesitas: los [formularios](../actas/index.md); para recepción, fuente de 24 V (≥ 1 A; límite en 1 A si lo tiene), multímetro, cable USB con datos y laptop.
Todas las actas cuelgan del número de serie; el [expediente](../unidad.html) de cualquier tarjeta, módulo o unidad cuenta la historia completa, daños incluidos.
Todas las actas cuelgan del número de serie; el [expediente](../tableros/unidad.md) de cualquier tarjeta, módulo o unidad cuenta la historia completa, daños incluidos.
## Mapa

View File

@ -11,7 +11,7 @@ version: 1.6
!!! abstract "Para quién · Cuándo · Tiempo · Necesitas"
- Para quién: técnico de banco.
- Cuándo: una vez por tarjeta V4 (EinarBoard 2.0, Blackpill en zócalo), antes de darla por buena con el firmware de campo. Sin serie: etiquétala V4-NN con el siguiente libre del [tablero](../tablero.html).
- Cuándo: una vez por tarjeta V4 (EinarBoard 2.0, Blackpill en zócalo), antes de darla por buena con el firmware de campo. Sin serie: etiquétala V4-NN con el siguiente libre del [tablero](../tableros/actas.md).
- Tiempo: ~1 h por tarjeta, más ~20 min en batería.
- Necesitas: tarjeta V4 completa (Blackpill, simpleRTK2B, módem con SIM, antena GNSS con cielo, antena LTE, batería); 24 V al GX16 que puedas desconectar; diodo Schottky BAT54 (como D5), BAT85, BAT43 o 1N5711, nunca 1N5819; alambre wire-wrap, cautín fino, flux, Kapton, termofil, lupa; multímetro con MIN/MAX; laptop con terminal serie y `dfu-util` o STM32CubeProgrammer; dos cables USB-A a USB-C (uno normal y uno sin 5 V, Preparación 2); cargador Li-ion de 1 celda o fuente de banco; monitor y [acta de prueba V4](../formularios/v4.html) abierta desde el inicio (se guarda sola en el navegador).
@ -113,7 +113,7 @@ Pasa si en los dos casos el módem registra solo en < 2 min y el monitor vuelve
## Registro
Todo va en el [acta de prueba V4](../formularios/v4.html), con el log completo de consola. Cada etapa termina con una pregunta de paso; si la tarjeta no pasa, marca "me detengo", llena el cierre y envía con resultado FALLA. Queda en el [expediente](../unidad.html) y en el [tablero](../tablero.html).
Todo va en el [acta de prueba V4](../formularios/v4.html), con el log completo de consola. Cada etapa termina con una pregunta de paso; si la tarjeta no pasa, marca "me detengo", llena el cierre y envía con resultado FALLA. Queda en el [expediente](../tableros/unidad.md) y en el [tablero](../tableros/actas.md).
!!! danger "Detente y avisa a ingeniería"
- P2 en 0 V con 24 V puestos.

View File

@ -4,7 +4,7 @@ title: "Producción"
# Producción
De la tarjeta desnuda a la unidad lista para embarcar. Una unidad a la vez; cada paso deja su acta y la unidad no avanza si falla un paso. Plan y avance real: [Tablero de producción](../tableros/produccion.html). Existencias: [Inventario](../inventario/inventario.html).
De la tarjeta desnuda a la unidad lista para embarcar. Una unidad a la vez; cada paso deja su acta y la unidad no avanza si falla un paso. Plan y avance real: [Tablero de producción](../tableros/produccion.md). Existencias: [Inventario](../tableros/inventario.md).
## Plan de 570 unidades (13-oct-2026)
@ -36,10 +36,10 @@ La V4 no entra al pipeline de unidades V5; se prepara y prueba en [Taller](../ta
## Dónde ver el avance
- [Tablero de producción](../tableros/produccion.html): plan 113 / 327 / 130 contra las unidades reales por etapa; bloqueos.
- [Estado de actas](../tablero.html): qué tarjeta, módulo y unidad pasó cada paso (series V5-NN, ARD-NNN, RTK-NNNN).
- [Expediente de una unidad](../unidad.html): la historia completa de una serie.
- [Tablero de producción](../tableros/produccion.md): plan 113 / 327 / 130 contra las unidades reales por etapa; bloqueos.
- [Estado de actas](../tableros/actas.md): qué tarjeta, módulo y unidad pasó cada paso (series V5-NN, ARD-NNN, RTK-NNNN).
- [Expediente de una unidad](../tableros/unidad.md): la historia completa de una serie.
- [Inventario](../inventario/index.md): tarjetas, módulos, gabinetes, cajas, paquetes, fuentes; mínimos y alertas.
- [Kanban](../tableros/kanban.html): tareas de la pista Producción y Compras.
- [Pendientes](../tableros/pendientes.md): tareas de la pista Producción y Compras.
- [Descargas](../descargas/index.md): firmware vigente por tarjeta y SHA-256. Hoy: 0.6.0 CAMPO; 0.6.3 solo banco.
- [Índice de actas](../actas/index.md): cómo se llenan y qué registra cada una.

View File

@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cerrar sesión — HBOX</title>
<script src="js/tema.js"></script>
<link rel="stylesheet" href="formularios/acta.css">
</head>
<body>
@ -12,7 +13,7 @@
<section class="paso">
<p id="estado">Cerrando sesión…</p>
<div id="listo" style="display:none">
<p><b>✓ Sesión cerrada.</b></p>
<p><b>Sesión cerrada.</b></p>
<p>Para entrar con otro usuario, abre de nuevo el sitio y aparecerá la ventana de
inicio de sesión.</p>
<p style="color:var(--tinta2);font-size:14px">Si por seguridad del navegador la ventana

View File

@ -27,10 +27,10 @@ Un sitio es un puerto o terminal donde hay unidades HBOX instaladas (hoy: ICAVE,
| Sitio | Puerto | Operador | Página |
|---|---|---|---|
| **ICAVE** | Veracruz | Hutchison Ports | [Abrir](sitio.html?id=icave) |
| **LZC** | Lázaro Cárdenas | Hutchison Ports | [Abrir](sitio.html?id=lzc) |
| **EIT** | Ensenada | Hutchison Ports | [Abrir](sitio.html?id=eit) |
| **TIMSA** | Manzanillo | Hutchison Ports | [Abrir](sitio.html?id=timsa) |
| **ICAVE** | Veracruz | Hutchison Ports | [Abrir](../tableros/sitio.md?id=icave) |
| **LZC** | Lázaro Cárdenas | Hutchison Ports | [Abrir](../tableros/sitio.md?id=lzc) |
| **EIT** | Ensenada | Hutchison Ports | [Abrir](../tableros/sitio.md?id=eit) |
| **TIMSA** | Manzanillo | Hutchison Ports | [Abrir](../tableros/sitio.md?id=timsa) |
En notas antiguas Lázaro Cárdenas aparece como «LCT»; el nombre oficial en el sitio es **LZC**.
@ -49,7 +49,7 @@ En notas antiguas Lázaro Cárdenas aparece como «LCT»; el nombre oficial en e
## Dar de alta un sitio (administrador)
1. En la página de cualquier sitio, elige **+ Nuevo sitio…** en el selector, o entra a [`sitio.html?nuevo=1`](sitio.html?nuevo=1).
1. En la página de cualquier sitio, elige **+ Nuevo sitio…** en el selector, o entra a [`sitio.html?nuevo=1`](../tableros/sitio.md?nuevo=1).
2. Id corto en minúsculas (`icave`, `lzc`), nombre como lo dice el cliente, cliente, ubicación.
3. **Alias**: los nombres con los que ese sitio aparece en actas viejas (por ejemplo `LCT` para LZC), para que la búsqueda por texto las encuentre.
4. Contactos: nombre, rol, teléfono, correo. Guarda. El sitio aparece de inmediato en los formularios.
@ -59,6 +59,6 @@ Un sitio no se borra: se marca **inactivo** y deja de salir en los formularios,
## Relacionados
- [Acta de instalación en campo](../formularios/instalacion.html) — una por unidad y vehículo.
- [Tablero de campo](../diagnostico/tablero-campo.html) — diagnósticos por unidad.
- [Expediente de una unidad](../unidad.html) — producción → envío → instalación → campo.
- [Pendientes de campo](../tableros/kanban.html?pista=campo)
- [Tablero de campo](../tableros/campo.md) — diagnósticos por unidad.
- [Expediente de una unidad](../tableros/unidad.md) — producción → envío → instalación → campo.
- [Pendientes de campo](../tableros/pendientes.md?pista=campo)

View File

@ -1,204 +1,18 @@
<!doctype html>
<!-- Desde el 20-oct-2026 este tablero vive dentro del sitio: docs/tableros/sitio.md → /tableros/sitio/
Este archivo solo redirige (conserva ?serie=, ?id=, ?pista=… y #…) para que las ligas viejas
guardadas en pendientes y actas sigan sirviendo. No se edita: los cambios van en tableros/sitio.md. -->
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sitio — HBOX</title>
<link rel="stylesheet" href="../tableros/tablero.css">
<style>
.ficha{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:14px}
.ficha .c{background:var(--tarjeta);border:1px solid var(--linea);border-radius:10px;padding:10px 12px}
.ficha .l{font-size:11px;color:var(--tinta2);text-transform:uppercase;letter-spacing:.05em}
.ficha .v{font-weight:600;font-size:15px}
.contacto{padding:6px 0;border-top:1px dashed var(--linea);font-size:14px}
.contacto:first-of-type{border-top:0}
.contacto .r{color:var(--tinta2);font-size:12.5px}
.linea{position:relative;padding-left:18px;border-left:2px solid var(--linea);margin-left:6px}
.ev{position:relative;padding:9px 0 9px 4px;border-bottom:1px solid var(--linea);font-size:14px}
.ev:last-child{border-bottom:0}
.ev::before{content:"";position:absolute;left:-25px;top:15px;width:11px;height:11px;border-radius:50%;background:var(--pend);border:2px solid var(--papel)}
.ev.instalacion-campo::before{background:var(--ok)} .ev.diagnostico-campo::before{background:var(--naranja)}
.ev.dano::before{background:var(--falla)} .ev.envio::before,.ev.recepcion::before{background:var(--info)}
.ev .f{color:var(--tinta2);font-size:12.5px}
.sitio-sel{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
.contactos-ed .fila{display:grid;grid-template-columns:1.2fr 1fr 1fr 1.2fr auto;gap:6px;margin-bottom:6px}
@media(max-width:700px){.contactos-ed .fila{grid-template-columns:1fr 1fr}}
.contactos-ed .fila button{padding:6px 9px;font-size:13px}
.por-texto{font-size:11px;color:var(--tinta2);border:1px dashed var(--linea);border-radius:99px;padding:0 7px}
</style>
<meta name="robots" content="noindex">
<title>Sitios - Drogheda</title>
<script>location.replace('/tableros/sitio/' + location.search + location.hash);</script>
<meta http-equiv="refresh" content="0; url=/tableros/sitio/">
<style>body{margin:0;padding:24px;background:#0E1524;color:#E7ECF5;font:16px/1.5 system-ui,sans-serif}a{color:#7FE9D8}</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1 id="titulo">Sitio</h1><div class="meta" id="meta">Cargando…</div></div>
<nav>
<a href="../">← Documentación</a>
<a href="./">Sitios</a>
<a href="../formularios/instalacion.html">Nueva instalación</a>
<a href="../formularios/diagnostico.html">Diagnóstico</a>
<a href="../tableros/kanban.html?pista=campo">Pendientes de campo</a>
</nav>
</header>
<details class="como">
<summary>Cómo se usa</summary>
<ol>
<li>Esta página junta todo lo que pasó en un sitio: unidades instaladas, envíos, recepciones, diagnósticos y daños. Sale de las actas; aquí no se captura nada de campo.</li>
<li>Para que una unidad aparezca, el acta debe traer el <b>sitio</b> elegido en el formulario (instalación, envío, recepción, diagnóstico, daño). Las actas viejas sin sitio se buscan por el nombre del puerto y se marcan «por texto».</li>
<li>Toca una serie para abrir su expediente completo. Rojo = unidad con marca de daño o baja.</li>
<li>Contactos y ubicación los edita solo el administrador (botón «Editar sitio»). Si falta un dato, díselo a Leo.</li>
<li>Para dar de alta otro sitio: <a href="./">lista de sitios</a> → «Nuevo sitio» (administrador).</li>
</ol>
</details>
<div class="sitio-sel"><label style="margin:0">Sitio</label><select id="sel"></select><button id="bEditar" class="sec" style="display:none">Editar sitio</button></div>
<div class="msg" id="aviso"></div>
<div id="cont"></div>
</div>
<dialog id="dlg"></dialog>
<script>
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 $ = id => document.getElementById(id);
const NOMBRE = { 'instalacion-campo': 'Instalación', 'diagnostico-campo': 'Diagnóstico en campo', 'envio': '📦 Envío', 'recepcion': '📦 Recepción', 'dano': '⚡ Daño o incidente' };
let SITIOS = [], ADMIN = false, ACTUAL = null, DATOS = null;
const dlg = $('dlg'), aviso = $('aviso');
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 4000 : 9000); }
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; }
const json = (m, b) => ({ method: m, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) });
function campo(l, i) { const d = el('div'); d.append(el('label', { text: l }), i); return d; }
function render(j) {
const s = j.sitio, cont = $('cont'); cont.innerHTML = '';
$('titulo').textContent = s.nombre + (s.cliente ? ' — ' + s.cliente : '');
$('meta').textContent = (s.ubicacion || 'ubicación por confirmar') + ' · ' + j.total_unidades + ' unidad(es) · ' + j.total_actas + ' acta(s)' + (s.activo === false ? ' · INACTIVO' : '');
document.title = s.nombre + ' — sitio HBOX';
const ficha = el('div', { class: 'ficha' });
const c = (l, v) => ficha.append(el('div', { class: 'c' }, el('div', { class: 'l', text: l }), el('div', { class: 'v', html: v })));
c('Cliente', esc(s.cliente || '—')); c('Ubicación', esc(s.ubicacion || 'por confirmar'));
c('Unidades instaladas', String(j.unidades.filter(u => u.instalada).length) + ' <span style="font-weight:400;color:var(--tinta2);font-size:12.5px">con acta de instalación OK</span>');
c('Con daño o baja', '<span style="color:' + (j.danos.length ? 'var(--falla)' : 'inherit') + '">' + j.danos.length + '</span>');
cont.append(ficha);
if (s.notas) cont.append(el('p', { class: 'sub', style: 'margin:-6px 0 12px', text: s.notas }));
// contactos
const b0 = el('section', { class: 'bloque' });
b0.append(el('h2', { text: 'Contactos' }));
if (!(s.contactos || []).length) b0.append(el('div', { class: 'vacio', text: 'Sin contactos registrados. Pide a Leo que los agregue.' }));
(s.contactos || []).forEach(k => b0.append(el('div', { class: 'contacto', html: '<b>' + esc(k.nombre) + '</b>' + (k.rol ? ' <span class="r">· ' + esc(k.rol) + '</span>' : '') + (k.telefono ? ' · <a href="tel:' + esc(k.telefono) + '">' + esc(k.telefono) + '</a>' : '') + (k.correo ? ' · <a href="mailto:' + esc(k.correo) + '">' + esc(k.correo) + '</a>' : '') })));
cont.append(b0);
// unidades
const b1 = el('section', { class: 'bloque' });
b1.append(el('h2', { text: 'Unidades en este sitio' }), el('p', { class: 'sub', text: 'Toda serie con alguna acta que apunte a este sitio. «Instalada» = fecha de la última acta de instalación aprobada.' }));
if (!j.unidades.length) b1.append(el('div', { class: 'vacio', text: 'Todavía no hay unidades con actas en este sitio. Llena un acta de instalación o de envío con este sitio.' }));
else {
const t = el('table', { class: 't' }); t.innerHTML = '<tr><th>Unidad</th><th>Máquina</th><th>Instalada</th><th>Última acta</th><th>Actas</th><th>Estado</th></tr>';
j.unidades.forEach(u => {
const tr = el('tr');
tr.innerHTML = '<td><a href="../unidad.html?serie=' + encodeURIComponent(u.serie) + '"><b>' + esc(u.serie) + '</b></a>' + (u.coincidencia === 'texto' ? ' <span class="por-texto" title="el acta no trae el sitio; coincide por el nombre del puerto">por texto</span>' : '') + '</td>'
+ '<td>' + esc(u.maquina || '—') + '</td><td>' + (u.instalada ? '<span class="pill ok">' + esc(u.instalada) + '</span>' : '<span class="pill pend">sin acta</span>') + '</td>'
+ '<td>' + esc(NOMBRE[(u.ultima || {}).tipo] || (u.ultima || {}).tipo || '—') + (u.ultima && u.ultima.fecha ? ' · ' + esc(u.ultima.fecha) : '') + (u.ultima && u.ultima.accion ? '<div style="font-size:12px;color:var(--tinta2)">' + esc(u.ultima.accion) + '</div>' : '') + '</td>'
+ '<td>' + u.actas + '</td><td>' + (u.marca ? '<span class="pill ' + (u.marca === 'BAJA' ? 'falla' : 'aviso') + '">' + (u.marca === 'BAJA' ? 'BAJA' : 'DAÑADA') + '</span>' : '<span class="pill ok">sin marca</span>') + '</td>';
t.append(tr);
});
b1.append(el('div', { class: 'tblwrap' }, t));
}
cont.append(b1);
// daños abiertos
if (j.danos.length) {
const b2 = el('section', { class: 'bloque' });
b2.append(el('h2', { text: 'Daños abiertos' }), el('p', { class: 'sub', text: 'Unidades de este sitio con marca vigente. Se levanta con un acta de daño «Reparada».' }));
j.danos.forEach(u => { const a = u.marca_acta || {}; b2.append(el('div', { class: 'ev dano', style: 'padding-left:0', html: '<b><a href="../unidad.html?serie=' + encodeURIComponent(u.serie) + '">' + esc(u.serie) + '</a></b> · ' + esc(a.clasificacion || '') + (a.resumen ? ' — ' + esc(a.resumen) : '') + '<div class="f">' + esc(a.fecha || fmt(a.recibido)) + ' · ' + esc(a.tecnico || '') + (a.accion ? ' · ' + esc(a.accion) : '') + '</div>' })); });
cont.append(b2);
}
// actas
const b3 = el('section', { class: 'bloque' });
b3.append(el('h2', { text: 'Actas de instalación y campo' }), el('p', { class: 'sub', text: 'De la más reciente a la más vieja: instalaciones, diagnósticos, envíos, recepciones y daños.' }));
if (!j.actas.length) b3.append(el('div', { class: 'vacio', text: 'Sin actas.' }));
else {
const lt = el('div', { class: 'linea' });
j.actas.forEach(a => {
const r = String(a.resultado || '');
lt.append(el('div', { class: 'ev ' + a.tipo, html: '<b>' + esc(NOMBRE[a.tipo] || a.tipo) + '</b> — <a href="../unidad.html?serie=' + encodeURIComponent(a.serie) + '">' + esc(a.serie) + '</a>'
+ (a.maquina ? ' · ' + esc(a.maquina) : '') + ' <span class="pill ' + (r === 'FALLA' ? 'falla' : r ? 'ok' : 'pend') + '">' + esc(r || '—') + '</span>'
+ (a.coincidencia === 'texto' ? ' <span class="por-texto">por texto</span>' : '')
+ '<div class="f">' + esc(a.fecha || '') + ' · recibida ' + esc(fmt(a.recibido)) + ' · ' + esc(a.tecnico || '—') + (a.fotos ? ' · ' + a.fotos + ' foto(s)' : '') + '</div>'
+ (a.accion ? '<div>→ ' + esc(a.accion) + '</div>' : '') + (a.motivo ? '<div>Motivo: ' + esc(a.motivo) + '</div>' : '') + (a.resumen ? '<div>' + esc(a.resumen) + '</div>' : '') }));
});
b3.append(lt);
}
cont.append(b3);
}
async function cargar(id) {
$('cont').innerHTML = '<p class="vacio">Cargando…</p>';
try { DATOS = await api('/api/sitio/unidades?id=' + encodeURIComponent(id)); ACTUAL = DATOS.sitio; render(DATOS); $('bEditar').style.display = ADMIN ? '' : 'none'; }
catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo leer el sitio: ' + e.message })); $('meta').textContent = 'Error'; }
}
// ---------- alta / edición (admin) ----------
function formSitio(c, s) {
s = s || {}; const f = {};
f.id = el('input', { type: 'text', maxlength: '30', value: s.id || '', placeholder: 'icave (minúsculas, sin espacios)' }); if (s.id) f.id.disabled = true;
f.nombre = el('input', { type: 'text', maxlength: '80', value: s.nombre || '', placeholder: 'ICAVE' });
f.cliente = el('input', { type: 'text', maxlength: '120', value: s.cliente || '', placeholder: 'Hutchison Ports' });
f.ubicacion = el('input', { type: 'text', maxlength: '200', value: s.ubicacion || '', placeholder: 'Veracruz, Ver.' });
f.alias = el('input', { type: 'text', value: (s.alias || []).join(', '), placeholder: 'LCT, Lázaro Cárdenas (nombres con los que aparece en actas viejas)' });
f.notas = el('textarea', { rows: '2' }); f.notas.value = s.notas || '';
f.activo = el('select'); [['true', 'Activo'], ['false', 'Inactivo (ya no se muestra en los formularios)']].forEach(([v, t]) => f.activo.append(el('option', { value: v, text: t }))); f.activo.value = s.activo === false ? 'false' : 'true';
const g = el('div', { class: 'dos-col' }); g.append(campo('Id (slug)', f.id), campo('Nombre', f.nombre), campo('Cliente', f.cliente), campo('Ubicación', f.ubicacion)); c.append(g);
c.append(campo('Alias (separados por coma)', f.alias));
const ced = el('div', { class: 'contactos-ed' });
function fila(k) { const r = el('div', { class: 'fila' }); r.append(el('input', { type: 'text', value: k.nombre || '', placeholder: 'Nombre' }), el('input', { type: 'text', value: k.rol || '', placeholder: 'Rol' }), el('input', { type: 'text', value: k.telefono || '', placeholder: 'Teléfono' }), el('input', { type: 'text', value: k.correo || '', placeholder: 'Correo' })); const q = el('button', { type: 'button', class: 'sec', text: '✕' }); q.addEventListener('click', () => r.remove()); r.append(q); ced.append(r); }
(s.contactos || []).forEach(fila);
const add = el('button', { type: 'button', class: 'sec', text: '+ contacto', style: 'padding:6px 10px;font-size:13px' }); add.addEventListener('click', () => fila({}));
c.append(campo('Contactos', ced), add, campo('Notas', f.notas), campo('Estado', f.activo));
return () => ({ id: f.id.value.trim().toLowerCase(), nombre: f.nombre.value.trim(), cliente: f.cliente.value.trim(), ubicacion: f.ubicacion.value.trim(),
alias: f.alias.value.split(',').map(x => x.trim()).filter(Boolean), notas: f.notas.value.trim(), activo: f.activo.value === 'true',
contactos: [...ced.querySelectorAll('.fila')].map(r => ({ nombre: r.children[0].value.trim(), rol: r.children[1].value.trim(), telefono: r.children[2].value.trim(), correo: r.children[3].value.trim() })).filter(k => k.nombre) });
}
function dialogo(titulo, s) {
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo })); const x = el('button', { type: 'button', text: '✕' }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
const leer = formSitio(cuerpo, s);
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: s ? 'Guardar' : 'Crear sitio' });
guardar.addEventListener('click', async () => {
const d = leer();
if (!d.nombre) { m.className = 'msg err'; m.textContent = 'Escribe el nombre.'; return; }
guardar.disabled = true;
try {
if (s) { delete d.id; await api('/api/sitio?id=' + encodeURIComponent(s.id), json('PATCH', d)); dlg.close(); mensaje('Sitio guardado.', true); await cargarLista(); cargar(s.id); }
else { const j = await api('/api/sitios', json('POST', d)); dlg.close(); mensaje('Sitio ' + j.sitio.nombre + ' creado.', true); await cargarLista(); history.replaceState(null, '', '?id=' + encodeURIComponent(j.sitio.id)); $('sel').value = j.sitio.id; cargar(j.sitio.id); }
} 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', '');
}
$('bEditar').addEventListener('click', () => ACTUAL && dialogo('Editar sitio ' + ACTUAL.nombre, ACTUAL));
async function cargarLista() {
const j = await api('/api/sitios?todos=1'); SITIOS = j.sitios || []; ADMIN = !!j.admin;
const sel = $('sel'); sel.innerHTML = ''; sel.append(el('option', { value: '', text: '— elegir sitio —' }));
SITIOS.forEach(s => sel.append(el('option', { value: s.id, text: s.nombre + (s.activo === false ? ' (inactivo)' : '') })));
if (ADMIN) sel.append(el('option', { value: '__nuevo__', text: '+ Nuevo sitio…' }));
}
$('sel').addEventListener('change', () => { const v = $('sel').value; if (v === '__nuevo__') { $('sel').value = ACTUAL ? ACTUAL.id : ''; dialogo('Nuevo sitio', null); } else if (v) { history.replaceState(null, '', '?id=' + encodeURIComponent(v)); cargar(v); } });
(async () => {
try { await cargarLista(); } catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo leer la lista de sitios: ' + e.message })); return; }
const q = new URLSearchParams(location.search);
if (q.get('nuevo') === '1') { if (ADMIN) dialogo('Nuevo sitio', null); else mensaje('Solo el administrador da de alta sitios. Pide a Leo que lo cree.', false); }
const id = q.get('id') || (SITIOS[0] || {}).id;
if (id) { $('sel').value = id; cargar(id); } else { $('meta').textContent = 'No hay sitios dados de alta.'; $('cont').innerHTML = '<p class="vacio">Pide a Leo que cree el primer sitio.</p>'; }
})();
</script>
<p>Este tablero ahora está dentro del sitio: <a href="/tableros/sitio/">Sitios</a>.</p>
</body>
</html>

View File

@ -1,165 +1,443 @@
/* ==========================================================================
HBOX — Documentación técnica · hoja de estilo Helgen
Colores tomados de _brand/estilo-helgen.tex (\definecolor):
HelgenIndigo #2C3C5B · HelgenCyan #51F3DA · HelgenTeal #17A08C
HelgenMist #EFF1F5 · HelgenLightCyan #E7FCF8 · HelgenLightIndigo #EDEFF4
Drogheda · piel Helgen para Material for MkDocs
Los colores y las letras vienen de helgen.css (tokens --hg-*). Aquí solo se
conectan con las variables de Material y se ajusta el diseño:
barra lateral fija a la izquierda (como un proyecto de Notion), columna de
lectura centrada y tableros a todo lo ancho (plantilla tablero.html).
========================================================================== */
:root {
--helgen-indigo: #2C3C5B;
--helgen-indigo-dark: #1F2A44;
--helgen-indigo-light: #3D5078;
--helgen-cyan: #51F3DA;
--helgen-teal: #17A08C;
--helgen-mist: #EFF1F5;
--helgen-light-cyan: #E7FCF8;
--helgen-light-indigo: #EDEFF4;
}
/* ---- Paleta "custom" de Material ---------------------------------------- */
[data-md-color-primary="custom"] {
--md-primary-fg-color: var(--helgen-indigo);
--md-primary-fg-color--light: var(--helgen-indigo-light);
--md-primary-fg-color--dark: var(--helgen-indigo-dark);
--md-primary-bg-color: #ffffff;
--md-primary-bg-color--light: rgba(255, 255, 255, 0.7);
}
[data-md-color-accent="custom"] {
--md-accent-fg-color: var(--helgen-teal);
--md-accent-fg-color--transparent: rgba(23, 160, 140, 0.1);
--md-accent-bg-color: #ffffff;
--md-accent-bg-color--light: rgba(255, 255, 255, 0.7);
}
/* ---- Variables de Material a partir de los tokens ------------------------ */
[data-md-color-scheme="slate"],
[data-md-color-scheme="default"] {
--md-typeset-a-color: #14685c;
}
[data-md-color-scheme="slate"][data-md-color-accent="custom"] {
--md-accent-fg-color: var(--helgen-cyan);
--md-accent-fg-color--transparent: rgba(81, 243, 218, 0.12);
--md-accent-bg-color: var(--helgen-indigo-dark);
}
[data-md-color-scheme="slate"] {
--md-typeset-a-color: var(--helgen-cyan);
--md-default-bg-color: #161d2b;
--md-code-bg-color: #1c2536;
--md-default-bg-color: var(--hg-fondo);
--md-default-bg-color--light: color-mix(in srgb, var(--hg-fondo) 54%, transparent);
--md-default-bg-color--lighter: color-mix(in srgb, var(--hg-fondo) 26%, transparent);
--md-default-bg-color--lightest: color-mix(in srgb, var(--hg-fondo) 7%, transparent);
--md-default-fg-color: var(--hg-texto);
--md-default-fg-color--light: var(--hg-texto-2);
--md-default-fg-color--lighter: var(--hg-texto-3);
--md-default-fg-color--lightest: var(--hg-linea);
--md-primary-fg-color: var(--hg-cabecera);
--md-primary-fg-color--light: var(--hg-indigo);
--md-primary-fg-color--dark: var(--hg-barra);
--md-primary-bg-color: var(--hg-sobre-cabecera);
--md-primary-bg-color--light: color-mix(in srgb, var(--hg-sobre-cabecera) 70%, transparent);
--md-accent-fg-color: var(--hg-liga-hover);
--md-accent-fg-color--transparent: var(--hg-acento-suave);
--md-accent-bg-color: var(--hg-plano);
--md-accent-bg-color--light: var(--hg-plano-2);
--md-typeset-color: var(--hg-texto);
--md-typeset-a-color: var(--hg-liga);
--md-typeset-mark-color: color-mix(in srgb, var(--hg-aviso) 30%, transparent);
--md-typeset-table-color: var(--hg-linea);
--md-typeset-table-color--light: color-mix(in srgb, var(--hg-elevado) 45%, transparent);
--md-typeset-kbd-color: var(--hg-plano);
--md-typeset-kbd-accent-color: var(--hg-linea-2);
--md-typeset-kbd-border-color: var(--hg-linea);
--md-code-fg-color: var(--hg-texto);
--md-code-bg-color: var(--hg-plano);
--md-code-bg-color--light: var(--hg-plano-2);
--md-code-bg-color--lighter: var(--hg-plano-2);
--md-admonition-fg-color: var(--hg-texto);
--md-admonition-bg-color: var(--hg-plano);
--md-footer-fg-color: var(--hg-texto);
--md-footer-fg-color--light: var(--hg-texto-2);
--md-footer-fg-color--lighter: var(--hg-texto-3);
--md-footer-bg-color: var(--hg-barra);
--md-footer-bg-color--dark: var(--hg-barra);
--md-shadow-z1: none;
--md-shadow-z2: var(--hg-sombra-suave);
--md-shadow-z3: var(--hg-sombra);
}
/* Fuentes del sistema (sitio utilizable sin Internet) */
:root {
--md-text-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--md-code-font: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--hg-ancho-barra: 14rem;
--md-text-font: "Atkinson Hyperlegible Next";
--md-code-font: "Atkinson Hyperlegible Mono";
}
/* ---- Tamaño: pensado para tableta en el taller -------------------------- */
.md-grid { max-width: 1400px; }
.md-typeset { font-size: 0.85rem; line-height: 1.6; }
body {
background-color: var(--hg-fondo);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:focus-visible { outline: 2px solid var(--hg-foco); outline-offset: 2px; }
/* Íconos de línea: Material pinta los SVG con relleno; los nuestros son trazo. */
svg.hg-i { fill: none !important; stroke: currentColor; }
/* ---- Encabezado ---------------------------------------------------------- */
.md-header {
background-color: var(--hg-cabecera);
color: var(--hg-sobre-cabecera);
border-bottom: 1px solid var(--hg-linea-suave);
box-shadow: none;
}
[data-md-color-scheme="default"] .md-header { border-bottom-color: transparent; }
.md-header--shadow { box-shadow: none; }
.md-header__button.md-logo { margin: 0.2rem 0.1rem 0.2rem 0.35rem; padding: 0.2rem; }
.md-header__button.md-logo svg,
.md-nav__title .md-nav__button.md-logo svg { width: auto; height: 1.6rem; }
.md-header__title { margin-left: 0.3rem; }
.md-header__topic:first-child {
font-family: var(--hg-fuente-titulo);
font-weight: 600;
font-size: 0.95rem;
letter-spacing: 0;
}
.md-header__topic + .md-header__topic { font-weight: 600; font-size: 0.8rem; }
/* búsqueda */
@media screen and (min-width: 60em) {
.md-search__form {
height: 1.75rem;
border-radius: 8px;
background-color: var(--hg-plano);
border: 1px solid var(--hg-linea);
}
.md-search__form:hover { background-color: var(--hg-elevado); }
[data-md-color-scheme="default"] .md-search__form {
background-color: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.18);
}
[data-md-color-scheme="default"] .md-search__form:hover { background-color: rgba(255, 255, 255, 0.18); }
[data-md-color-scheme="default"] .md-search__input,
[data-md-color-scheme="default"] .md-search__input::placeholder,
[data-md-color-scheme="default"] .md-search__input ~ .md-search__icon { color: rgba(255, 255, 255, 0.82); }
[data-md-toggle="search"]:checked ~ .md-header .md-search__form {
background-color: var(--hg-plano);
border-color: var(--hg-linea-2);
border-radius: 8px 8px 0 0;
}
[data-md-color-scheme="default"] [data-md-toggle="search"]:checked ~ .md-header .md-search__input { color: var(--hg-texto); }
.md-search__output { border-radius: 0 0 10px 10px; }
}
.md-search__input { font-size: 0.75rem; }
.md-search__input::placeholder { color: var(--hg-texto-3); }
.md-search__output { background-color: var(--hg-plano); }
.md-search-result__meta { background-color: var(--hg-plano-2); color: var(--hg-texto-2); }
.md-search-result__item { border-top: 1px solid var(--hg-linea-suave); }
.md-search-result__link:focus,
.md-search-result__link:hover,
.md-search-result__more > summary:hover { background-color: var(--hg-elevado); }
.md-search-result mark { color: var(--hg-liga); background: none; }
/* ---- Esqueleto: barra lateral a la izquierda, columna de lectura --------- */
@media screen and (min-width: 76.25em) {
.md-typeset { font-size: 0.9rem; }
.md-header__inner.md-grid,
.md-main__inner.md-grid { max-width: none; margin-left: 0; margin-right: 0; }
.md-main__inner { margin-top: 0; }
/* la barra lateral se pinta en el fondo de .md-main: Material le da altura 0 a .md-sidebar */
.md-main {
background: linear-gradient(to right,
var(--hg-barra) 0, var(--hg-barra) var(--hg-ancho-barra),
var(--hg-linea-suave) var(--hg-ancho-barra), var(--hg-linea-suave) calc(var(--hg-ancho-barra) + 1px),
transparent calc(var(--hg-ancho-barra) + 1px));
}
.md-sidebar--primary {
padding: 0.7rem 0 1.2rem;
width: var(--hg-ancho-barra);
}
.md-sidebar--primary .md-sidebar__scrollwrap { margin: 0 0.25rem; }
[dir=ltr] .md-sidebar--primary .md-sidebar__inner { padding-right: 0.15rem; }
/* el nombre del sitio ya está en el encabezado */
.md-nav--primary > .md-nav__title { display: none; }
.md-sidebar--secondary { padding-top: 1.6rem; }
/* la columna de lectura se centra en el espacio libre */
[dir=ltr] .md-sidebar--primary:not([hidden]) ~ .md-content > .md-content__inner,
[dir=ltr] .md-sidebar--secondary:not([hidden]) ~ .md-content > .md-content__inner,
.md-content > .md-content__inner {
margin-left: auto;
margin-right: auto;
}
}
.md-typeset h1 { color: var(--helgen-indigo); font-weight: 700; }
[data-md-color-scheme="slate"] .md-typeset h1 { color: var(--helgen-cyan); }
.md-typeset h2 { font-weight: 600; border-bottom: 2px solid var(--helgen-light-indigo); padding-bottom: 0.2em; }
[data-md-color-scheme="slate"] .md-typeset h2 { border-bottom-color: var(--helgen-indigo-light); }
.md-content__inner {
max-width: var(--hg-ancho-contenido, 46rem);
padding: 1.4rem 1.2rem 0;
box-sizing: border-box;
}
@media screen and (max-width: 76.234375em) {
.md-content__inner { padding: 1rem 0.2rem 0; }
}
.md-content__inner::before { display: none; }
/* Cabecera y pestañas */
.md-header__button.md-logo img { height: 1.6rem; width: auto; }
.md-tabs__link { font-size: 0.75rem; font-weight: 600; }
.md-tabs__link--active { border-bottom: 3px solid var(--helgen-cyan); }
/* ---- Menú lateral -------------------------------------------------------- */
.md-nav { font-size: 0.7rem; line-height: 1.35; }
.md-nav--primary .md-nav__link {
margin-top: 1px;
padding: 0.32rem 0.5rem;
border-radius: var(--hg-radio-chico);
color: var(--hg-texto-suave);
align-items: center;
gap: 0.5rem;
}
.md-nav--primary .md-nav__link svg.hg-i { width: 18px; height: 18px; margin: 0; color: var(--hg-texto-3); }
.md-nav--primary .md-nav__link[href]:hover,
.md-nav--primary .md-nav__link[for]:hover { color: var(--hg-texto-fuerte); background-color: color-mix(in srgb, var(--hg-elevado) 55%, transparent); }
.md-nav--primary .md-nav__item .md-nav__link--active,
.md-nav--primary .md-nav__item .md-nav__link--active code {
color: var(--hg-texto-fuerte);
background-color: var(--hg-elevado);
font-weight: 600;
}
.md-nav--primary .md-nav__link--active svg.hg-i { color: var(--hg-acento); }
.md-nav--primary .md-nav__container > .md-nav__link { padding: 0; margin: 0; background: none; }
.md-nav--primary .md-nav__container { padding: 0; }
.md-nav--primary .md-nav__container > .md-nav__link:first-child { padding: 0.32rem 0.5rem; border-radius: var(--hg-radio-chico); }
.md-nav--primary .md-nav__container > .md-nav__link[for] { padding: 0.32rem 0.35rem; border-radius: var(--hg-radio-chico); }
.md-nav__icon::after { background-color: var(--hg-texto-3); }
/* lo que no tiene ícono deja el hueco del ícono, para que los nombres queden alineados */
.md-nav--primary .md-nav__link:not(.md-nav__container):not(:has(> svg))::before,
.md-nav--primary .md-nav__container > .md-nav__link:first-child:not(:has(> svg))::before {
content: "";
flex: none;
width: 18px;
}
.md-nav--primary .md-nav__container > .md-nav__link[for]::before { display: none; }
@media screen and (min-width: 76.25em) {
.md-nav--primary .md-nav__item--section > .md-nav__link::before,
.md-nav--primary .md-nav__item--section > .md-nav__container > .md-nav__link:first-child::before { display: none; }
}
/* ---- Tarjetas (grid cards) ---------------------------------------------- */
/* secciones de primer nivel (Tableros, Documentos, Cuenta): rótulo discreto, sin mayúsculas */
@media screen and (min-width: 76.25em) {
.md-nav--primary > .md-nav__list > .md-nav__item--section { margin: 1.1rem 0 0; }
.md-nav--primary > .md-nav__list > .md-nav__item--section > .md-nav__link,
.md-nav--primary > .md-nav__list > .md-nav__item--section > .md-nav__container > .md-nav__link:first-child {
font-size: 0.62rem;
font-weight: 600;
color: var(--hg-texto-3);
padding: 0 0.5rem 0.3rem;
background: none;
}
.md-nav--primary > .md-nav__list > .md-nav__item--section > .md-nav__container > .md-nav__link:first-child svg.hg-i { display: none; }
}
.md-nav--primary .md-nav__item--nested:not(.md-nav__item--section) > .md-nav { padding-left: 0.55rem; }
/* el campo «status:» de los documentos es texto para personas, no un ícono en el menú */
.md-nav .md-status { display: none; }
/* conteo junto a «Pendientes» (js/sitio.js) */
.hg-cuenta {
margin-left: auto;
font-size: 0.62rem;
font-weight: 500;
color: var(--hg-texto-3);
font-variant-numeric: tabular-nums;
}
.hg-cuenta--vencidos::before {
content: "";
display: inline-block;
width: 6px; height: 6px;
margin-right: 5px;
border-radius: 50%;
background: var(--hg-bloqueado);
vertical-align: 0.1em;
}
/* en teléfono y tableta el menú es un cajón */
@media screen and (max-width: 76.234375em) {
.md-nav--primary,
.md-nav--primary .md-nav { background-color: var(--hg-barra); }
.md-nav--primary .md-nav__title { background-color: var(--hg-cabecera); color: var(--hg-sobre-cabecera); }
.md-nav--primary .md-nav__title .md-logo svg { height: 2rem; }
.md-nav--primary .md-nav__item { font-size: 0.75rem; }
.md-nav--primary .md-nav__link { padding: 0.45rem 0.6rem; margin: 1px 0.4rem; }
.md-nav--primary .md-nav__link.md-nav__container { padding: 0; }
.md-nav--primary .md-nav__container > .md-nav__link:first-child { padding: 0.45rem 0.6rem; }
}
/* índice de la página (derecha) */
.md-nav--secondary .md-nav__title { color: var(--hg-texto-3); font-weight: 600; font-size: 0.62rem; background: none; box-shadow: none; }
.md-nav--secondary .md-nav__link { color: var(--hg-texto-2); margin-top: 0.4em; }
.md-nav--secondary .md-nav__link--active { color: var(--hg-texto-fuerte); }
.md-nav--secondary .md-nav__link:hover { color: var(--hg-liga-hover); }
/* ---- Texto --------------------------------------------------------------- */
.md-typeset { font-size: 0.8rem; line-height: 1.62; color: var(--hg-texto); }
.md-typeset h1,
.md-typeset h2 {
font-family: var(--hg-fuente-titulo);
color: var(--hg-texto-fuerte);
}
.md-typeset h1 {
font-weight: 700;
font-size: 2.15em;
line-height: 1.08;
letter-spacing: -0.012em;
margin: 0 0 0.8em;
}
.md-typeset h2 {
font-weight: 600;
font-size: 1.5em;
line-height: 1.18;
letter-spacing: -0.004em;
margin: 1.9em 0 0.6em;
}
.md-typeset h3 { font-weight: 700; font-size: 1.12em; line-height: 1.35; letter-spacing: 0; margin: 1.6em 0 0.5em; color: var(--hg-texto-fuerte); }
.md-typeset h4 { font-weight: 700; letter-spacing: 0; }
.md-typeset h5, .md-typeset h6 { text-transform: none; color: var(--hg-texto-2); }
.md-typeset .headerlink { color: var(--hg-texto-3); }
.md-typeset a { color: var(--hg-liga); text-underline-offset: 0.18em; }
.md-typeset a:hover, .md-typeset a:focus { color: var(--hg-liga-hover); text-decoration: underline; }
.md-typeset strong { font-weight: 700; color: var(--hg-texto-fuerte); }
.md-typeset hr { border-bottom: 1px solid var(--hg-linea); }
.md-typeset blockquote { border-left: 2px solid var(--hg-linea-2); color: var(--hg-texto-2); }
.md-typeset mark { color: inherit; }
/* código: la mono solo para lo que se teclea o se compara */
.md-typeset code { border-radius: 4px; font-size: 0.86em; background-color: var(--hg-elevado); }
.md-typeset pre > code { background-color: var(--hg-plano); border: 1px solid var(--hg-linea); border-radius: var(--hg-radio); }
.md-typeset kbd { font-family: var(--hg-fuente-mono); }
/* tablas */
.md-typeset table:not([class]) {
font-size: 0.7rem;
border: 1px solid var(--hg-linea);
border-radius: var(--hg-radio);
background-color: var(--hg-plano-2);
}
.md-typeset table:not([class]) th {
background-color: var(--hg-plano);
color: var(--hg-texto-2);
font-weight: 600;
min-width: 4rem;
padding: 0.7em 1em;
}
.md-typeset table:not([class]) td { padding: 0.75em 1em; border-top: 1px solid var(--hg-linea); }
.md-typeset table:not([class]) tbody tr:hover { background-color: color-mix(in srgb, var(--hg-elevado) 35%, transparent); box-shadow: none; }
/* listas de pasos numeradas (índices de sección) */
.md-typeset ol.pasos > li { margin-bottom: 0.6em; }
.md-typeset ol.pasos > li::marker { font-weight: 700; color: var(--hg-texto-2); }
/* avisos y bloques plegables: borde completo y tenue, el color solo en el ícono */
.md-typeset .admonition,
.md-typeset details {
--hg-av: var(--hg-texto-2);
font-size: 0.74rem;
border-radius: var(--hg-radio);
box-shadow: none;
}
.md-typeset :is(.admonition, details)[class] { border: 1px solid color-mix(in srgb, var(--hg-av) 30%, var(--hg-linea)); background-color: var(--hg-plano); }
.md-typeset :is(.admonition, details)[class]:focus-within { box-shadow: 0 0 0 3px var(--hg-acento-suave); }
.md-typeset :is(.admonition, details)[class] > :is(.admonition-title, summary) {
background-color: transparent;
border: 0;
font-weight: 700;
color: var(--hg-texto-fuerte);
}
.md-typeset :is(.admonition, details)[class] > :is(.admonition-title, summary)::before { background-color: var(--hg-av); }
.md-typeset :is(.admonition, details)[class] > summary::after { background-color: var(--hg-texto-3); }
.md-typeset :is(.admonition, details):is(.info, .todo, .abstract, .summary, .tldr, .example) { --hg-av: var(--hg-info); }
.md-typeset :is(.admonition, details):is(.tip, .hint, .important, .success, .check, .done) { --hg-av: var(--hg-hecho); }
.md-typeset :is(.admonition, details):is(.warning, .caution, .attention, .question, .help, .faq) { --hg-av: var(--hg-aviso); }
.md-typeset :is(.admonition, details):is(.danger, .error, .failure, .fail, .missing, .bug) { --hg-av: var(--hg-bloqueado); }
/* tarjetas en rejilla (índices de sección) */
.md-typeset .grid.cards > ul > li,
.md-typeset .grid > .card {
border-radius: 0.5rem;
border: 1px solid var(--helgen-light-indigo);
transition: box-shadow 0.15s ease, transform 0.15s ease, border-color 0.15s ease;
border: 1px solid var(--hg-linea);
border-radius: var(--hg-radio);
background-color: var(--hg-plano);
transition: border-color 0.15s ease;
}
[data-md-color-scheme="slate"] .md-typeset .grid.cards > ul > li { border-color: #2a3650; }
.md-typeset .grid.cards > ul > li:hover,
.md-typeset .grid > .card:hover {
border-color: var(--helgen-teal);
box-shadow: 0 6px 20px rgba(44, 60, 91, 0.18);
transform: translateY(-2px);
}
.md-typeset .grid > .card:hover { border-color: var(--hg-linea-2); box-shadow: none; transform: none; }
.md-typeset .grid.cards > ul > li > hr { margin: 0.6em 0; }
.md-typeset .grid.cards > ul > li h3 { margin-top: 0; }
.md-typeset .grid.cards > ul > li .md-button { margin: 0.2em 0.4em 0.2em 0; font-size: 0.78em; padding: 0.45em 1em; }
/* Botones */
.md-typeset .md-button--primary {
background-color: var(--helgen-indigo);
border-color: var(--helgen-indigo);
color: #fff;
}
.md-typeset .md-button--primary:hover {
background-color: var(--helgen-teal);
border-color: var(--helgen-teal);
/* botones: uno principal por pantalla */
.md-typeset .md-button {
border: 1px solid var(--hg-linea-2);
border-radius: var(--hg-radio-campo);
color: var(--hg-texto);
font-weight: 600;
padding: 0.5em 1.1em;
}
.md-typeset .md-button:hover,
.md-typeset .md-button:focus { background-color: var(--hg-elevado); border-color: var(--hg-linea-2); color: var(--hg-texto-fuerte); }
.md-typeset .md-button--primary { background-color: var(--hg-acento); border-color: var(--hg-acento); color: var(--hg-sobre-acento); }
.md-typeset .md-button--primary:hover,
.md-typeset .md-button--primary:focus { background-color: var(--hg-acento); border-color: var(--hg-acento); color: var(--hg-sobre-acento); filter: brightness(1.08); }
/* ---- Pastillas de estado de tarjeta ------------------------------------- */
.badge-v3, .badge-v4, .badge-v55, .badge-v56, .badge-v57, .badge-v6, .badge-fw {
display: inline-block;
padding: 0.1em 0.65em;
border-radius: 999px;
font-size: 0.72em;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1.6;
vertical-align: middle;
white-space: nowrap;
}
.badge-v56 { background: var(--helgen-indigo); color: #fff; }
.badge-v55 { background: var(--helgen-teal); color: #fff; }
.badge-v57 { background: #e3f5e8; color: #1c5c2e; border: 1px solid #8fd1a3; }
.badge-v6 { background: #fff3cd; color: #7a5200; border: 1px solid #f0c36d; }
.badge-v4 { background: var(--helgen-light-indigo); color: var(--helgen-indigo); border: 1px solid #c9d0e0; }
.badge-v3 { background: #e9e9e9; color: #555; border: 1px solid #ccc; }
.badge-fw { background: var(--helgen-light-cyan); color: #0f5f52; border: 1px solid #9fe6d8; }
[data-md-color-scheme="slate"] .badge-v4 { background: #2a3650; color: #dfe6f5; border-color: #3d5078; }
[data-md-color-scheme="slate"] .badge-v3 { background: #333; color: #ccc; border-color: #555; }
[data-md-color-scheme="slate"] .badge-v57 { background: #143d22; color: #9fe6b4; border-color: #1c5c2e; }
[data-md-color-scheme="slate"] .badge-v6 { background: #4a3a10; color: #ffd97a; border-color: #7a5200; }
[data-md-color-scheme="slate"] .badge-fw { background: #123a34; color: var(--helgen-cyan); border-color: #17A08C; }
/* Aviso de búsqueda en la portada */
/* aviso de búsqueda en la portada */
.buscar-aviso {
background: var(--helgen-light-cyan);
border-left: 4px solid var(--helgen-teal);
padding: 0.6em 1em;
border-radius: 0.3rem;
display: flex;
gap: 0.6rem;
align-items: flex-start;
background: var(--hg-plano);
border: 1px solid var(--hg-linea);
border-radius: var(--hg-radio);
color: var(--hg-texto-2);
padding: 0.7em 1em;
margin: 1em 0 1.5em;
}
[data-md-color-scheme="slate"] .buscar-aviso { background: #123a34; }
/* Tablas: más compactas y con cabecera Helgen */
.md-typeset table:not([class]) th {
background: var(--helgen-light-indigo);
color: var(--helgen-indigo);
.buscar-aviso::before {
content: "";
flex: none;
width: 18px; height: 18px;
margin-top: 0.15em;
background-color: var(--hg-texto-3);
-webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.8" stroke-linecap="round"><path d="M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zM20 20l-4.4-4.4"/></svg>') center / contain no-repeat;
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.8" stroke-linecap="round"><path d="M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zM20 20l-4.4-4.4"/></svg>') center / contain no-repeat;
}
[data-md-color-scheme="slate"] .md-typeset table:not([class]) th {
background: #2a3650;
color: #fff;
}
.md-typeset table:not([class]) td, .md-typeset table:not([class]) th { padding: 0.5em 0.8em; }
.buscar-aviso > :first-child { margin-top: 0; }
.buscar-aviso > :last-child { margin-bottom: 0; }
/* Listas de pasos numeradas (índices de sección) */
.md-typeset ol.pasos > li { margin-bottom: 0.6em; }
.md-typeset ol.pasos > li::marker { font-weight: 700; color: var(--helgen-teal); }
/* casillas de las listas de verificación */
.md-typeset .task-list-indicator::before { background-color: var(--hg-linea-2); }
.md-typeset [type="checkbox"]:checked + .task-list-indicator::before { background-color: var(--hg-hecho); }
/* ---- Impresión (checklists y procedimientos) ---------------------------- */
/* ---- Pie de página ------------------------------------------------------- */
.md-footer { border-top: 1px solid var(--hg-linea-suave); }
.md-footer__link { color: var(--hg-texto-2); }
.md-footer__link:hover { color: var(--hg-texto-fuerte); opacity: 1; }
.md-footer__title { font-size: 0.75rem; }
.md-footer__direction { color: var(--hg-texto-3); }
.md-footer-meta { background-color: var(--hg-barra); }
.md-footer-meta__inner { color: var(--hg-texto-3); }
.md-copyright { color: var(--hg-texto-3); }
.md-copyright__highlight { color: var(--hg-texto-2); }
.md-top { background-color: var(--hg-plano); color: var(--hg-texto-2); border: 1px solid var(--hg-linea); box-shadow: none; }
.md-top:hover { background-color: var(--hg-elevado); color: var(--hg-texto-fuerte); }
/* ---- Impresión (checklists y procedimientos) ----------------------------- */
@media print {
.md-header, .md-tabs, .md-sidebar, .md-footer, .md-top, .md-search,
.md-header, .md-sidebar, .md-footer, .md-top, .md-search,
.md-content__button, .md-source-file, .md-typeset .headerlink { display: none !important; }
body, .md-typeset { background: #fff !important; color: #000 !important; }
.md-main__inner { margin: 0; }
.md-content { max-width: 100%; margin: 0; }
.md-typeset { font-size: 10.5pt; line-height: 1.35; color: #000; }
.md-typeset h1 { font-size: 16pt; color: #000; page-break-after: avoid; }
.md-content__inner { max-width: none; padding: 0; }
.md-typeset { font-size: 10.5pt; line-height: 1.35; }
.md-typeset h1, .md-typeset h2, .md-typeset h3, .md-typeset strong { color: #000 !important; }
.md-typeset h1 { font-size: 16pt; page-break-after: avoid; }
.md-typeset h2 { font-size: 13pt; page-break-after: avoid; border-bottom: 1px solid #000; }
.md-typeset h3 { font-size: 11.5pt; page-break-after: avoid; }
.md-typeset table:not([class]) { background: #fff; border-color: #000; }
.md-typeset table:not([class]) th { background: #fff; color: #000; }
.md-typeset table { page-break-inside: avoid; font-size: 9.5pt; }
.md-typeset .admonition, .md-typeset details { page-break-inside: avoid; border: 1px solid #000; box-shadow: none; }
.md-typeset .admonition, .md-typeset details { page-break-inside: avoid; border: 1px solid #000 !important; background: #fff !important; box-shadow: none; }
.md-typeset .task-list-control .task-list-indicator { border: 1px solid #000; background: #fff; }
.md-typeset a { color: #000; text-decoration: none; }
.md-typeset a[href^="http"]::after { content: none; }
.badge-v3, .badge-v4, .badge-v55, .badge-v56, .badge-v57, .badge-v6, .badge-fw { border: 1px solid #000; background: none; color: #000; }
.md-typeset code { background: #fff; border: 1px solid #999; color: #000; }
.badge-v3, .badge-v4, .badge-v55, .badge-v56, .badge-v57, .badge-v6, .badge-fw { border: 1px solid #000; color: #000; }
@page { margin: 12mm 14mm; }
}

202
docs/stylesheets/helgen.css Normal file
View File

@ -0,0 +1,202 @@
/* ==========================================================================
Helgen · tokens del sitio drogheda.helgen.online
Un solo archivo de colores y letras para el sitio (Material), los tableros y
las actas. Oscuro primero; el modo claro usa los mismos nombres.
- Dentro del sitio (Material) el modo lo decide <body data-md-color-scheme>:
"slate" = oscuro (por omisión), "default" = claro.
- Las páginas sueltas (actas, cuenta) usan <html data-tema="claro"> cuando la
persona eligió claro en el sitio (lo pone js/tema.js).
Reglas de la casa: sin emoji, sin MAYÚSCULAS de adorno, sin tarjetas con
borde de color a la izquierda. El cian solo marca lo que está vivo
(activo, en curso, foco). Íconos de línea de 1.8 px con puntas redondas.
========================================================================== */
/* ---- Letras (latin, ≈ 76 KB en total; licencia OFL en docs/fonts/) ------ */
@font-face {
font-family: "League Spartan";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("../fonts/league-spartan-latin.woff2") format("woff2");
}
@font-face {
font-family: "Atkinson Hyperlegible Next";
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url("../fonts/atkinson-hyperlegible-next-latin.woff2") format("woff2");
}
@font-face {
font-family: "Atkinson Hyperlegible Mono";
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url("../fonts/atkinson-hyperlegible-mono-latin.woff2") format("woff2");
}
/* ---- Oscuro (por omisión) ------------------------------------------------ */
:root,
[data-md-color-scheme="slate"] {
--hg-fuente-titulo: "League Spartan", "Futura", "Avenir Next", system-ui, sans-serif;
--hg-fuente: "Atkinson Hyperlegible Next", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--hg-fuente-mono: "Atkinson Hyperlegible Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
/* superficies */
--hg-fondo: #0E1524; /* Noche: fondo de página */
--hg-barra: #0B111D; /* Barra: barra lateral y encabezado */
--hg-plano: #141D30; /* Plano: tarjetas y campos */
--hg-plano-2: #111A2C; /* paneles y celdas */
--hg-elevado: #1A2540; /* selección y menú activo */
--hg-linea: #233049; /* bordes */
--hg-linea-2: #2E3D5C; /* bordes de controles */
--hg-linea-suave: #1A2438; /* divisiones discretas */
--hg-indigo: #2C3C5B; /* índigo Helgen */
--hg-cabecera: #0B111D; /* encabezado del sitio */
--hg-sobre-cabecera: #E7ECF5;
/* texto */
--hg-texto: #E7ECF5; /* Niebla */
--hg-texto-fuerte: #F2F5FA;
--hg-texto-suave: #C3CCDD;
--hg-texto-2: #A9B4CB; /* Secundario */
--hg-texto-3: #8C99B5; /* Terciario: etiquetas y conteos */
/* acento: solo lo vivo */
--hg-acento: #51F3DA; /* Cian */
--hg-sobre-acento: #0B111D;
--hg-acento-suave: rgba(81, 243, 218, 0.14);
--hg-foco: #51F3DA;
--hg-liga: #7FE9D8;
--hg-liga-hover: #51F3DA;
--hg-teal: #17A08C;
/* estados */
--hg-curso: #51F3DA; --hg-curso-bg: #133A40;
--hg-hecho: #4FD8BE; --hg-hecho-bg: #123A36; --hg-hecho-icono: #2FBF9F;
--hg-bloqueado: #FF9494; --hg-bloqueado-bg: #3A1F27;
--hg-aviso: #F2C46B; --hg-aviso-bg: #3A3020;
--hg-info: #9DB8FF; --hg-info-bg: #1B2847;
--hg-pend: #8C99B5; --hg-pend-bg: #1F2A42;
/* pistas del tablero */
--hg-p-ingenieria: #8FB0FF;
--hg-p-produccion: #63D6A8;
--hg-p-mecanica: #E9B865;
--hg-p-compras: #C6A8FF;
--hg-p-campo: #FF9E70;
/* versiones de tarjeta: etiqueta de serigrafía (texto y borde) */
--hg-v55: #4FD8BE; --hg-v55-borde: rgba(79, 216, 190, 0.42);
--hg-v56: #A9C1FF; --hg-v56-borde: rgba(169, 193, 255, 0.42);
--hg-v57: #8BE3A7; --hg-v57-borde: rgba(139, 227, 167, 0.42);
--hg-v6: #F2C46B; --hg-v6-borde: rgba(242, 196, 107, 0.42);
--hg-v4: #B9C3D8; --hg-v4-borde: rgba(185, 195, 216, 0.38);
--hg-v3: #9AA3B5; --hg-v3-borde: rgba(154, 163, 181, 0.36);
--hg-fw: #7FE9D8; --hg-fw-borde: rgba(127, 233, 216, 0.38);
/* forma */
--hg-radio: 10px;
--hg-radio-campo: 9px;
--hg-radio-chico: 7px;
--hg-sombra: 0 18px 48px rgba(3, 7, 15, 0.55);
--hg-sombra-suave: 0 1px 2px rgba(3, 7, 15, 0.35);
}
/* ---- Claro: mismos nombres ----------------------------------------------- */
[data-md-color-scheme="default"],
html[data-tema="claro"],
:where(html:has(> body[data-md-color-scheme="default"])) {
--hg-fondo: #EFF1F5; /* Mist */
--hg-barra: #F7F8FB;
--hg-plano: #FFFFFF;
--hg-plano-2: #F6F7FA;
--hg-elevado: #E2E8F3;
--hg-linea: #D5DAE5;
--hg-linea-2: #C2CADA;
--hg-linea-suave: #E1E5EE;
--hg-indigo: #2C3C5B;
--hg-cabecera: #2C3C5B; /* el encabezado conserva el índigo de la marca */
--hg-sobre-cabecera: #FFFFFF;
--hg-texto: #1F2A40;
--hg-texto-fuerte: #141C2E;
--hg-texto-suave: #34415C;
--hg-texto-2: #4A5875;
--hg-texto-3: #65728D;
--hg-acento: #14685C; /* sobre claro, el cian no se lee: teal oscuro */
--hg-sobre-acento: #FFFFFF;
--hg-acento-suave: rgba(20, 104, 92, 0.10);
--hg-foco: #17A08C;
--hg-liga: #14685C;
--hg-liga-hover: #0E4F46;
--hg-teal: #17A08C;
--hg-curso: #0E6E62; --hg-curso-bg: #DDF5F1;
--hg-hecho: #13806F; --hg-hecho-bg: #E2F5F1; --hg-hecho-icono: #17A08C;
--hg-bloqueado: #B42318; --hg-bloqueado-bg: #FDECEC;
--hg-aviso: #8A5A00; --hg-aviso-bg: #FFF3D6;
--hg-info: #2F55B5; --hg-info-bg: #E6EDFC;
--hg-pend: #65728D; --hg-pend-bg: #E8ECF3;
--hg-p-ingenieria: #3D63C9;
--hg-p-produccion: #1E8A5E;
--hg-p-mecanica: #A26B0A;
--hg-p-compras: #7A4FD0;
--hg-p-campo: #C2531F;
--hg-v55: #13806F; --hg-v55-borde: rgba(19, 128, 111, 0.45);
--hg-v56: #3552A8; --hg-v56-borde: rgba(53, 82, 168, 0.45);
--hg-v57: #2E7D46; --hg-v57-borde: rgba(46, 125, 70, 0.45);
--hg-v6: #8F6208; --hg-v6-borde: rgba(143, 98, 8, 0.45);
--hg-v4: #4F5B75; --hg-v4-borde: rgba(79, 91, 117, 0.40);
--hg-v3: #646B7A; --hg-v3-borde: rgba(100, 107, 122, 0.38);
--hg-fw: #14685C; --hg-fw-borde: rgba(20, 104, 92, 0.40);
--hg-sombra: 0 18px 48px rgba(31, 42, 64, 0.22);
--hg-sombra-suave: 0 1px 2px rgba(31, 42, 64, 0.08);
}
/* Barras de desplazamiento y controles nativos del mismo modo */
html { color-scheme: dark; }
html[data-tema="claro"],
:where(html:has(> body[data-md-color-scheme="default"])) { color-scheme: light; }
[data-md-color-scheme="slate"] { color-scheme: dark; }
[data-md-color-scheme="default"] { color-scheme: light; }
/* ---- Etiquetas de versión de tarjeta (sirven en todo el sitio) ----------- */
.badge-v3, .badge-v4, .badge-v55, .badge-v56, .badge-v57, .badge-v6, .badge-fw {
display: inline-block;
font-family: var(--hg-fuente-mono);
font-size: 0.78em;
font-weight: 500;
line-height: 1;
padding: 0.28em 0.5em;
border-radius: 4px;
border: 1px solid currentColor;
background: transparent;
white-space: nowrap;
vertical-align: 0.08em;
letter-spacing: 0;
}
.badge-v55 { color: var(--hg-v55); border-color: var(--hg-v55-borde); }
.badge-v56 { color: var(--hg-v56); border-color: var(--hg-v56-borde); }
.badge-v57 { color: var(--hg-v57); border-color: var(--hg-v57-borde); }
.badge-v6 { color: var(--hg-v6); border-color: var(--hg-v6-borde); }
.badge-v4 { color: var(--hg-v4); border-color: var(--hg-v4-borde); }
.badge-v3 { color: var(--hg-v3); border-color: var(--hg-v3-borde); }
.badge-fw { color: var(--hg-fw); border-color: var(--hg-fw-borde); font-family: var(--hg-fuente); font-weight: 600; }
/* Íconos de línea (los SVG del sitio llevan esta clase) */
.hg-i {
width: 18px;
height: 18px;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}

View File

@ -1,224 +1,18 @@
<!doctype html>
<!-- Desde el 20-oct-2026 este tablero vive dentro del sitio: docs/tableros/actas.md → /tableros/actas/
Este archivo solo redirige (conserva ?serie=, ?id=, ?pista=… y #…) para que las ligas viejas
guardadas en pendientes y actas sigan sirviendo. No se edita: los cambios van en tableros/actas.md. -->
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tablero de estado — HBOX</title>
<style>
:root{
--naranja:#e8590c; --tinta:#1d1c19; --tinta2:#5a564d; --linea:#e6e1d6;
--papel:#faf8f4; --tarjeta:#fff; --superf:#fcfcfb;
--apro:#2f9e44; --apro-bg:#ebfbee; --falla:#e03131; --falla-bg:#fff5f5;
--pend:#adb5bd; --pend-bg:#f4f2ee; --ens:#1971c2; --ens-bg:#e7f1fb;
}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){
--papel:#191b1e;--tinta:#e6e1d6;--tinta2:#a09a8d;--linea:#33363b;--tarjeta:#1f2226;--superf:#1b1d20;
--apro:#69db7c;--apro-bg:#1b2a1f;--falla:#ff8787;--falla-bg:#2c1d1d;--pend:#5c636a;--pend-bg:#26282c;--ens:#74c0fc;--ens-bg:#182734;}}
*{box-sizing:border-box}
body{margin:0;background:var(--papel);color:var(--tinta);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.wrap{max-width:1000px;margin:0 auto;padding:18px 16px 70px}
header.top{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;
background:#2C3C5B;color:#fff;padding:16px;border-radius:12px;margin-bottom:16px}
header.top h1{margin:0;font-size:20px}
header.top .meta{color:#d7d2c8;font-size:13px}
button{background:var(--naranja);color:#fff;border:0;border-radius:9px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer}
a.volver{color:#fff;font-size:13px;text-decoration:none;opacity:.85}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:18px}
.kpi{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:14px 16px}
.kpi .n{font-size:30px;font-weight:700;line-height:1}
.kpi .l{color:var(--tinta2);font-size:13px;margin-top:4px}
section.bloque{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:16px;margin:14px 0}
section.bloque h2{margin:0 0 2px;font-size:17px}
.resumen{color:var(--tinta2);font-size:13.5px;margin:0 0 10px}
.barra{display:flex;height:14px;border-radius:7px;overflow:hidden;background:var(--pend-bg);margin:6px 0 14px;gap:2px}
.barra span{display:block;height:100%}
.barra .a{background:var(--apro)} .barra .f{background:var(--falla)} .barra .e{background:var(--ens)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}
.cel{display:flex;flex-direction:column;gap:2px;text-decoration:none;color:inherit;
border:1px solid var(--linea);border-radius:10px;padding:9px 10px;background:var(--superf)}
.cel .s{font-weight:700;font-size:14px}
.cel .st{font-size:11.5px;font-weight:600;display:flex;align-items:center;gap:4px}
.cel .who{font-size:11px;color:var(--tinta2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cel.apro{background:var(--apro-bg);border-color:var(--apro)} .cel.apro .st{color:var(--apro)}
.cel.falla{background:var(--falla-bg);border-color:var(--falla)} .cel.falla .st{color:var(--falla)}
.cel.ens{background:var(--ens-bg);border-color:var(--ens)} .cel.ens .st{color:var(--ens)}
.cel.pend .st{color:var(--tinta2)}
.leyenda{display:flex;flex-wrap:wrap;gap:14px;font-size:12.5px;color:var(--tinta2);margin-top:10px}
.leyenda b{font-weight:600}
table{border-collapse:collapse;width:100%;font-size:13.5px;margin-top:6px}
th{text-align:left;color:var(--tinta2);font-size:11.5px;text-transform:uppercase;letter-spacing:.05em;padding:6px 8px 6px 0;border-bottom:1px solid var(--tinta2)}
td{padding:7px 8px 7px 0;border-bottom:1px solid var(--linea)}
.pill{font-size:11.5px;font-weight:700;padding:2px 8px;border-radius:99px}
.pill.apro{background:var(--apro-bg);color:var(--apro)} .pill.falla{background:var(--falla-bg);color:var(--falla)}
.dos{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:680px){.dos{grid-template-columns:1fr}}
.err{background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla);padding:10px 14px;border-radius:10px}
</style>
<meta name="robots" content="noindex">
<title>Estado de actas - Drogheda</title>
<script>location.replace('/tableros/actas/' + location.search + location.hash);</script>
<meta http-equiv="refresh" content="0; url=/tableros/actas/">
<style>body{margin:0;padding:24px;background:#0E1524;color:#E7ECF5;font:16px/1.5 system-ui,sans-serif}a{color:#7FE9D8}</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Tablero de estado — HBOX</h1><div class="meta" id="meta">Cargando…</div></div>
<div style="display:flex;gap:12px;align-items:center">
<a class="volver" href="./">← Documentación</a>
<button id="refrescar">Actualizar</button>
</div>
</header>
<div id="cont"></div>
</div>
<script>
const STREAMS = [
{ tipo:'prueba-v5', prefijo:'V5', desde:1, n:30, pad:2, titulo:'Tarjetas EinarBoard — V5', form:'formularios/v5.html' },
{ tipo:'modulo-ard', prefijo:'ARD', desde:1, n:30, pad:3, titulo:'Módulos simpleRTK2B Pro — ARD', form:'formularios/ardusimple.html', usoClave:'ard' },
{ tipo:'modulo-4g', prefijo:'RTK', desde:1, n:30, pad:4, titulo:'Módems 4G NTRIP Master — RTK', form:'formularios/modem4g.html', usoClave:'modem' },
{ tipo:'prueba-v4', prefijo:'V4', desde:1, n:15, pad:2, titulo:'Tarjetas EinarBoard — V4 (fw 0.6.x)', form:'formularios/v4.html' },
];
const TABLAS = [
{ tipo:'integracion', titulo:'Integración ArduSimple + 4G (parejas)', form:'formularios/integracion.html', cols:[['ard','ArduSimple'],['modem','4G (RTK-)']] },
{ tipo:'ensamble', titulo:'Unidades ensambladas — HBOX v5', form:'formularios/ensamble.html', cols:[['einar','EinarBoard'],['ard','ArduSimple'],['modem','4G (RTK-)']], unidad:true },
];
const ICON = { apro:'✓', falla:'✕', pend:'·', ens:'▣' };
const cont = document.getElementById('cont');
const meta = document.getElementById('meta');
function fmt(iso){ return iso ? iso.replace('T',' ').slice(0,16) : ''; }
function celda(st, sc){
const cls = st.estado;
const a = document.createElement('a');
a.className = 'cel ' + cls;
a.href = (cls==='pend') ? sc.form : ('formularios/ver.html?tipo='+encodeURIComponent(sc.tipo)+'&serie='+encodeURIComponent(st.serie));
a.title = st.serie + (st.tecnico? ' — '+st.tecnico : '');
const label = cls==='apro' ? 'Aprobada' : cls==='falla' ? 'Falla' : cls==='ens' ? 'Ensamblada' : 'Pendiente';
a.innerHTML = '<span class="s">'+st.serie+'</span>'
+ '<span class="st">'+ (ICON[cls]||'·') +' '+label+'</span>'
+ '<span class="who">'+ (st.tecnico? st.tecnico : '—') +'</span>';
return a;
}
function bloque(sc, actas){
const map = {};
actas.filter(a=>a.tipo===sc.tipo).forEach(a=>{ map[a.serie]=a; });
let apro=0, falla=0, pend=0, ens=0;
const cells=[];
const desde = sc.desde||1, pad = sc.pad||2;
for(let i=desde;i<desde+sc.n;i++){
const serie = sc.prefijo+'-'+String(i).padStart(pad,'0');
const a = map[serie];
let estado='pend', tecnico='';
if(a){ tecnico=a.tecnico||'';
estado = a.resultado==='FALLA' ? 'falla' : 'apro';
// módulo aprobado y ya montado en una unidad → 'ens' (ensamblado)
if(estado==='apro' && sc.usoClave && enUnidad[sc.usoClave] && enUnidad[sc.usoClave][serie]){ estado='ens'; tecnico='en '+enUnidad[sc.usoClave][serie]; }
}
if(estado==='apro')apro++; else if(estado==='falla')falla++; else if(estado==='ens')ens++; else pend++;
cells.push(celda({serie,estado,tecnico,recibido:a?a.recibido:''}, sc));
}
const hechas = sc.n - pend;
const sec = document.createElement('section'); sec.className='bloque';
sec.innerHTML = '<h2>'+sc.titulo+'</h2>'
+ '<p class="resumen">'+hechas+' de '+sc.n+' registradas · '+apro+' aprobadas'
+ (ens ? ' · '+ens+' ya en unidad' : '') + ' · '+falla+' con falla · '+pend+' pendientes</p>';
const barra = document.createElement('div'); barra.className='barra';
const seg=(c,n)=>{ if(!n)return; const s=document.createElement('span'); s.className=c; s.style.flex=n; barra.append(s); };
seg('a',apro); seg('e',ens); seg('f',falla);
const rest=document.createElement('span'); rest.style.flex=pend; barra.append(rest);
sec.append(barra);
const grid=document.createElement('div'); grid.className='grid'; cells.forEach(c=>grid.append(c));
sec.append(grid);
return sec;
}
let enUnidad = {}; // clave -> { serieModulo: serieUnidad }
function tabla(tc, actas){
const rows = actas.filter(a=>a.tipo===tc.tipo);
const sec=document.createElement('section'); sec.className='bloque';
const ok = rows.filter(a=>a.resultado!=='FALLA').length, bad = rows.length-ok;
sec.innerHTML='<h2>'+tc.titulo+'</h2><p class="resumen">'+rows.length+' registradas · '+ok+' aprobadas · '+bad+' con falla · <a href="'+tc.form+'">+ nueva acta</a></p>';
const t=document.createElement('table');
t.innerHTML='<tr><th>'+(tc.unidad?'Unidad':'Pareja')+'</th>'+tc.cols.map(c=>'<th>'+c[1]+'</th>').join('')+'<th>Técnico</th><th>Resultado</th><th>Fecha</th></tr>';
rows.forEach(a=>{
const tr=document.createElement('tr');
const p = a.resultado==='FALLA'?'<span class="pill falla">FALLA</span>':'<span class="pill apro">OK</span>';
const link='formularios/ver.html?tipo='+encodeURIComponent(tc.tipo)+'&serie='+encodeURIComponent(a.serie||'');
tr.innerHTML='<td><a href="'+link+'"><b>'+(a.serie||'')+'</b></a></td>'
+ tc.cols.map(c=>'<td>'+((a.componentes||{})[c[0]]||'—')+'</td>').join('')
+ '<td>'+(a.tecnico||'')+'</td><td>'+p+'</td><td>'+fmt(a.recibido)+'</td>';
t.append(tr);
});
sec.append(t); if(!rows.length) sec.innerHTML+='<p class="resumen">Aún no hay registros.</p>';
return sec;
}
function render(actas){
cont.innerHTML='';
// módulos ya montados en unidades (actas de ensamble reales)
enUnidad = {};
actas.filter(a=>a.tipo==='ensamble' && a.componentes && a.resultado!=='FALLA').forEach(a=>{
for(const k in a.componentes){ enUnidad[k]=enUnidad[k]||{}; enUnidad[k][a.componentes[k]]=a.serie; }
});
// KPIs
const v5 = actas.filter(a=>a.tipo==='prueba-v5');
const rtk = actas.filter(a=>a.tipo==='modulo-ard'), g4 = actas.filter(a=>a.tipo==='modulo-4g');
const integ = actas.filter(a=>a.tipo==='integracion');
const ens = actas.filter(a=>a.tipo==='ensamble');
const okN = l=>l.filter(a=>a.resultado!=='FALLA').length;
const kpis=document.createElement('div'); kpis.className='kpis';
const kpi=(n,l)=>{ const d=document.createElement('div'); d.className='kpi'; d.innerHTML='<div class="n">'+n+'</div><div class="l">'+l+'</div>'; return d; };
kpis.append(kpi(okN(v5),'EinarBoard V5 aprobadas'));
kpis.append(kpi(okN(rtk),'ArduSimple aprobados'));
kpis.append(kpi(okN(g4),'Módems 4G aprobados'));
kpis.append(kpi(okN(integ),'Parejas integradas'));
kpis.append(kpi(okN(ens),'Unidades HBOX listas'));
cont.append(kpis);
// unidades e integración primero, luego inventario de módulos
TABLAS.slice().reverse().forEach(tc=>cont.append(tabla(tc,actas)));
STREAMS.forEach(sc=>cont.append(bloque(sc,actas)));
// leyenda
const ley=document.createElement('div'); ley.className='leyenda';
ley.innerHTML='<span><b style="color:var(--apro)">✓</b> Aprobada</span>'
+'<span><b style="color:var(--falla)">✕</b> Falla</span>'
+'<span><b style="color:var(--ens)">▣</b> Ya montado en una unidad</span>'
+'<span><b>·</b> Pendiente</span>'
+'<span>· Toca una tarjeta para ver o llenar su acta.</span>';
cont.append(ley);
// actividad reciente + por técnico
const dos=document.createElement('div'); dos.className='dos';
const rec=document.createElement('section'); rec.className='bloque';
rec.innerHTML='<h2>Actividad reciente</h2>';
const tbl=document.createElement('table');
tbl.innerHTML='<tr><th>Serie</th><th>Técnico</th><th>Resultado</th><th>Fecha</th></tr>';
actas.slice(0,12).forEach(a=>{
const tr=document.createElement('tr');
const p = a.resultado==='FALLA'?'<span class="pill falla">FALLA</span>':'<span class="pill apro">OK</span>';
tr.innerHTML='<td><b>'+(a.serie||'')+'</b></td><td>'+(a.tecnico||'')+'</td><td>'+p+'</td><td>'+fmt(a.recibido)+'</td>';
tbl.append(tr);
});
rec.append(tbl); if(!actas.length) rec.innerHTML+='<p class="resumen">Aún no hay actas registradas.</p>';
const pt=document.createElement('section'); pt.className='bloque';
pt.innerHTML='<h2>Por técnico</h2>';
const cnt={}; actas.forEach(a=>{ const t=(a.tecnico||'—').trim()||'—'; cnt[t]=(cnt[t]||0)+1; });
const t2=document.createElement('table'); t2.innerHTML='<tr><th>Técnico</th><th>Actas</th></tr>';
Object.entries(cnt).sort((x,y)=>y[1]-x[1]).forEach(([t,c])=>{ const tr=document.createElement('tr'); tr.innerHTML='<td>'+t+'</td><td>'+c+'</td>'; t2.append(tr); });
pt.append(t2); if(!actas.length) pt.innerHTML+='<p class="resumen">—</p>';
dos.append(rec,pt); cont.append(dos);
}
async function cargar(){
meta.textContent='Actualizando…';
try{
const r=await fetch('/api/actas',{cache:'no-store'});
const j=await r.json();
if(!j.ok) throw new Error(j.error||'error');
render(j.actas||[]);
meta.textContent='Actualizado '+new Date().toLocaleTimeString('es-MX',{hour:'2-digit',minute:'2-digit'})+' · '+(j.total||0)+' actas';
}catch(e){
cont.innerHTML='<div class="err">No se pudo cargar el tablero: '+e.message+'</div>';
meta.textContent='Error';
}
}
document.getElementById('refrescar').addEventListener('click',cargar);
cargar();
setInterval(cargar, 30000);
</script>
<p>Este tablero ahora está dentro del sitio: <a href="/tableros/actas/">Estado de actas</a>.</p>
</body>
</html>

173
docs/tableros/actas.md Normal file
View File

@ -0,0 +1,173 @@
---
title: "Actas"
description: "Qué tarjeta, módulo o unidad pasó cada acta: series V5, ARD, RTK y V4, parejas y unidades."
template: tablero.html
hide:
- toc
---
<div class="tb tb-actas">
<header class="tb-cab">
<div class="tb-cab__t"><h1>Estado de actas</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>
<div id="cont"></div>
</div>
<script>
(function () {
const STREAMS = [
{ tipo:'prueba-v5', prefijo:'V5', desde:1, n:30, pad:2, titulo:'Tarjetas EinarBoard — V5', form:'/formularios/v5.html' },
{ tipo:'modulo-ard', prefijo:'ARD', desde:1, n:30, pad:3, titulo:'Módulos simpleRTK2B Pro — ARD', form:'/formularios/ardusimple.html', usoClave:'ard' },
{ tipo:'modulo-4g', prefijo:'RTK', desde:1, n:30, pad:4, titulo:'Módems 4G NTRIP Master — RTK', form:'/formularios/modem4g.html', usoClave:'modem' },
{ tipo:'prueba-v4', prefijo:'V4', desde:1, n:15, pad:2, titulo:'Tarjetas EinarBoard — V4 (fw 0.6.x)', form:'/formularios/v4.html' },
];
const TABLAS = [
{ tipo:'integracion', titulo:'Integración ArduSimple + 4G (parejas)', form:'/formularios/integracion.html', cols:[['ard','ArduSimple'],['modem','4G (RTK-)']] },
{ tipo:'ensamble', titulo:'Unidades ensambladas — HBOX v5', form:'/formularios/ensamble.html', cols:[['einar','EinarBoard'],['ard','ArduSimple'],['modem','4G (RTK-)']], unidad:true },
];
const ICON = { apro: Kanban.icono('ok'), falla: Kanban.icono('cerrar'), pend: '·', ens: Kanban.icono('unidad') };
const cont = document.getElementById('cont');
const meta = document.getElementById('meta');
function fmt(iso){ return iso ? iso.replace('T',' ').slice(0,16) : ''; }
function celda(st, sc){
const cls = st.estado;
const a = document.createElement('a');
a.className = 'cel ' + cls;
a.href = (cls==='pend') ? sc.form : ('/formularios/ver.html?tipo='+encodeURIComponent(sc.tipo)+'&serie='+encodeURIComponent(st.serie));
a.title = st.serie + (st.tecnico? ' — '+st.tecnico : '');
const label = cls==='apro' ? 'Aprobada' : cls==='falla' ? 'Falla' : cls==='ens' ? 'Ensamblada' : 'Pendiente';
a.innerHTML = '<span class="s">'+st.serie+'</span>'
+ '<span class="st">'+ (ICON[cls]||'·') +' '+label+'</span>'
+ '<span class="who">'+ (st.tecnico? st.tecnico : '—') +'</span>';
return a;
}
function bloque(sc, actas){
const map = {};
actas.filter(a=>a.tipo===sc.tipo).forEach(a=>{ map[a.serie]=a; });
let apro=0, falla=0, pend=0, ens=0;
const cells=[];
const desde = sc.desde||1, pad = sc.pad||2;
for(let i=desde;i<desde+sc.n;i++){
const serie = sc.prefijo+'-'+String(i).padStart(pad,'0');
const a = map[serie];
let estado='pend', tecnico='';
if(a){ tecnico=a.tecnico||'';
estado = a.resultado==='FALLA' ? 'falla' : 'apro';
// módulo aprobado y ya montado en una unidad → 'ens' (ensamblado)
if(estado==='apro' && sc.usoClave && enUnidad[sc.usoClave] && enUnidad[sc.usoClave][serie]){ estado='ens'; tecnico='en '+enUnidad[sc.usoClave][serie]; }
}
if(estado==='apro')apro++; else if(estado==='falla')falla++; else if(estado==='ens')ens++; else pend++;
cells.push(celda({serie,estado,tecnico,recibido:a?a.recibido:''}, sc));
}
const hechas = sc.n - pend;
const sec = document.createElement('section'); sec.className='bloque';
sec.innerHTML = '<h2>'+sc.titulo+'</h2>'
+ '<p class="resumen">'+hechas+' de '+sc.n+' registradas · '+apro+' aprobadas'
+ (ens ? ' · '+ens+' ya en unidad' : '') + ' · '+falla+' con falla · '+pend+' pendientes</p>';
const barra = document.createElement('div'); barra.className='barra';
const seg=(c,n)=>{ if(!n)return; const s=document.createElement('span'); s.className=c; s.style.flex=n; barra.append(s); };
seg('a',apro); seg('e',ens); seg('f',falla);
const rest=document.createElement('span'); rest.style.flex=pend; barra.append(rest);
sec.append(barra);
const grid=document.createElement('div'); grid.className='grid'; cells.forEach(c=>grid.append(c));
sec.append(grid);
return sec;
}
let enUnidad = {}; // clave -> { serieModulo: serieUnidad }
function tabla(tc, actas){
const rows = actas.filter(a=>a.tipo===tc.tipo);
const sec=document.createElement('section'); sec.className='bloque';
const ok = rows.filter(a=>a.resultado!=='FALLA').length, bad = rows.length-ok;
sec.innerHTML='<h2>'+tc.titulo+'</h2><p class="resumen">'+rows.length+' registradas · '+ok+' aprobadas · '+bad+' con falla · <a href="'+tc.form+'">Nueva acta</a></p>';
const t=document.createElement('table');
t.innerHTML='<tr><th>'+(tc.unidad?'Unidad':'Pareja')+'</th>'+tc.cols.map(c=>'<th>'+c[1]+'</th>').join('')+'<th>Técnico</th><th>Resultado</th><th>Fecha</th></tr>';
rows.forEach(a=>{
const tr=document.createElement('tr');
const p = a.resultado==='FALLA'?'<span class="pill falla">FALLA</span>':'<span class="pill apro">OK</span>';
const link='/formularios/ver.html?tipo='+encodeURIComponent(tc.tipo)+'&serie='+encodeURIComponent(a.serie||'');
tr.innerHTML='<td><a href="'+link+'"><b>'+(a.serie||'')+'</b></a></td>'
+ tc.cols.map(c=>'<td>'+((a.componentes||{})[c[0]]||'—')+'</td>').join('')
+ '<td>'+(a.tecnico||'')+'</td><td>'+p+'</td><td>'+fmt(a.recibido)+'</td>';
t.append(tr);
});
sec.append(t); if(!rows.length) sec.innerHTML+='<p class="resumen">Aún no hay registros.</p>';
return sec;
}
function render(actas){
cont.innerHTML='';
// módulos ya montados en unidades (actas de ensamble reales)
enUnidad = {};
actas.filter(a=>a.tipo==='ensamble' && a.componentes && a.resultado!=='FALLA').forEach(a=>{
for(const k in a.componentes){ enUnidad[k]=enUnidad[k]||{}; enUnidad[k][a.componentes[k]]=a.serie; }
});
// KPIs
const v5 = actas.filter(a=>a.tipo==='prueba-v5');
const rtk = actas.filter(a=>a.tipo==='modulo-ard'), g4 = actas.filter(a=>a.tipo==='modulo-4g');
const integ = actas.filter(a=>a.tipo==='integracion');
const ens = actas.filter(a=>a.tipo==='ensamble');
const okN = l=>l.filter(a=>a.resultado!=='FALLA').length;
const kpis=document.createElement('div'); kpis.className='kpis';
const kpi=(n,l)=>{ const d=document.createElement('div'); d.className='kpi'; d.innerHTML='<div class="n">'+n+'</div><div class="l">'+l+'</div>'; return d; };
kpis.append(kpi(okN(v5),'EinarBoard V5 aprobadas'));
kpis.append(kpi(okN(rtk),'ArduSimple aprobados'));
kpis.append(kpi(okN(g4),'Módems 4G aprobados'));
kpis.append(kpi(okN(integ),'Parejas integradas'));
kpis.append(kpi(okN(ens),'Unidades HBOX listas'));
cont.append(kpis);
// unidades e integración primero, luego inventario de módulos
TABLAS.slice().reverse().forEach(tc=>cont.append(tabla(tc,actas)));
STREAMS.forEach(sc=>cont.append(bloque(sc,actas)));
// leyenda
const ley=document.createElement('div'); ley.className='leyenda';
ley.innerHTML='<span class="st" style="color:var(--apro)">'+ICON.apro+'<span style="color:var(--tinta2)">Aprobada</span></span>'
+'<span class="st" style="color:var(--falla)">'+ICON.falla+'<span style="color:var(--tinta2)">Falla</span></span>'
+'<span class="st" style="color:var(--ens)">'+ICON.ens+'<span style="color:var(--tinta2)">Ya montado en una unidad</span></span>'
+'<span><b>·</b> Pendiente</span>'
+'<span>· Toca una tarjeta para ver o llenar su acta.</span>';
cont.append(ley);
// actividad reciente + por técnico
const dos=document.createElement('div'); dos.className='dos';
const rec=document.createElement('section'); rec.className='bloque';
rec.innerHTML='<h2>Actividad reciente</h2>';
const tbl=document.createElement('table');
tbl.innerHTML='<tr><th>Serie</th><th>Técnico</th><th>Resultado</th><th>Fecha</th></tr>';
actas.slice(0,12).forEach(a=>{
const tr=document.createElement('tr');
const p = a.resultado==='FALLA'?'<span class="pill falla">FALLA</span>':'<span class="pill apro">OK</span>';
tr.innerHTML='<td><b>'+(a.serie||'')+'</b></td><td>'+(a.tecnico||'')+'</td><td>'+p+'</td><td>'+fmt(a.recibido)+'</td>';
tbl.append(tr);
});
rec.append(tbl); if(!actas.length) rec.innerHTML+='<p class="resumen">Aún no hay actas registradas.</p>';
const pt=document.createElement('section'); pt.className='bloque';
pt.innerHTML='<h2>Por técnico</h2>';
const cnt={}; actas.forEach(a=>{ const t=(a.tecnico||'—').trim()||'—'; cnt[t]=(cnt[t]||0)+1; });
const t2=document.createElement('table'); t2.innerHTML='<tr><th>Técnico</th><th>Actas</th></tr>';
Object.entries(cnt).sort((x,y)=>y[1]-x[1]).forEach(([t,c])=>{ const tr=document.createElement('tr'); tr.innerHTML='<td>'+t+'</td><td>'+c+'</td>'; t2.append(tr); });
pt.append(t2); if(!actas.length) pt.innerHTML+='<p class="resumen">—</p>';
dos.append(rec,pt); cont.append(dos);
}
async function cargar(){
meta.textContent='Actualizando…';
try{
const r=await fetch('/api/actas',{cache:'no-store'});
const j=await r.json();
if(!j.ok) throw new Error(j.error||'error');
render(j.actas||[]);
meta.textContent='Actualizado '+new Date().toLocaleTimeString('es-MX',{hour:'2-digit',minute:'2-digit'})+' · '+(j.total||0)+' actas';
}catch(e){
cont.innerHTML='<div class="err">No se pudo cargar el tablero: '+e.message+'</div>';
meta.textContent='Error';
}
}
document.getElementById('refrescar').addEventListener('click',cargar);
cargar();
setInterval(cargar, 30000);
})();
</script>

146
docs/tableros/campo.md Normal file
View File

@ -0,0 +1,146 @@
---
title: "Visitas de campo"
description: "Los diagnósticos en campo y en banco, por terminal y por unidad, con fotos y logs."
template: tablero.html
hide:
- toc
---
<div class="tb tb-campo">
<header class="tb-cab">
<div class="tb-cab__t"><h1>Visitas de campo</h1><span class="meta" id="meta">Cargando…</span></div>
<div class="tb-cab__acc"><a class="boton sec" href="/formularios/diagnostico.html"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="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"/></svg>Nueva acta de diagnóstico</a><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>
<div id="cont"></div>
</div>
<script src="/formularios/flota.js"></script>
<script>
(function () {
const TIPOS = ['diagnostico-campo','diagnostico-banco'];
const TIPO = 'diagnostico-campo';
const cont = document.getElementById('cont');
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=>e.append(c)); return e;};
const fecha = s => (s||'').replace('T',' ').slice(0,16);
function pill(v){
const t = String(v.accion||'');
if (/^Instalada y reportando/.test(t)) return ['ok','Operando'];
if (/^Instalada/.test(t)) return ['falla','Instalada sin resolver'];
if (/Querétaro/.test(t)) return ['info','En camino a QRO'];
if (/taller/.test(t)) return ['info','En taller'];
if (/Reemplazada/.test(t)) return ['ok','Reemplazada'];
return ['pend', v.resultado || '—'];
}
async function detalle(tipo, serie, archivo, caja){
caja.textContent = 'Cargando…';
try{
const r = await fetch(`/api/acta?tipo=${encodeURIComponent(tipo)}&serie=${encodeURIComponent(serie)}&archivo=${encodeURIComponent(archivo)}`);
const j = await r.json();
if(!j.exists) throw new Error('no encontrada');
const t = el('table',{class:'det'});
const res = j.acta.respuestas || {};
Object.keys(res).forEach(k=>{
const v = res[k]; if(v.val===''||v.val===false||v.val==null) return;
t.append(el('tr',null, el('td',{class:'k',text:(v.lbl||k).replace(/<[^>]+>/g,'')}),
el('td',{text: v.val===true ? 'sí' : String(v.val)})));
});
caja.innerHTML=''; caja.append(t);
}catch(e){ caja.textContent = 'No se pudo cargar el detalle.'; }
}
function render(actas){
cont.innerHTML='';
const porUnidad = {};
actas.filter(a=>TIPOS.includes(a.tipo)).forEach(a=>{ (porUnidad[a.serie]=porUnidad[a.serie]||[]).push(a); });
const flota = (window.FLOTA||[]).slice();
Object.keys(porUnidad).forEach(s=>{ if(!flota.some(u=>u.serie===s)) flota.push({serie:s, etiqueta:s, maquina:'(fuera del registro de flota)', terminal:'Sin clasificar'}); });
const visitas = actas.filter(a=>TIPOS.includes(a.tipo));
const conVisita = Object.keys(porUnidad).length;
const operando = flota.filter(u=>{const v=(porUnidad[u.serie]||[])[0]; return v && pill(v)[0]==='ok';}).length;
const kp = el('div',{class:'kpis'});
[[flota.length,'Unidades en el registro'],[visitas.length,'Diagnósticos recibidos'],
[conVisita,'Unidades ya diagnosticadas'],[operando,'Reportadas como operando']]
.forEach(([n,l])=>kp.append(el('div',{class:'kpi'}, el('div',{class:'n',text:String(n)}), el('div',{class:'l',text:l}))));
cont.append(kp);
const grupos = {};
flota.forEach(u=>{ (grupos[u.terminal||'Sin clasificar']=grupos[u.terminal||'Sin clasificar']||[]).push(u); });
Object.keys(grupos).sort().forEach(g=>{
const b = el('section',{class:'bloque'});
b.append(el('h2',{text:g}));
b.append(el('p',{class:'resumen',text:grupos[g].length+' unidad(es) registradas en esta terminal.'}));
grupos[g].forEach(u=>{
const vs = (porUnidad[u.serie]||[]);
const box = el('div',{class:'unidad'});
const cab = el('div',{class:'cab'});
cab.append(el('div',null,
el('div',{class:'s'}, el('a',{href:'/tableros/unidad/?serie='+encodeURIComponent(u.serie),
style:'color:inherit;text-decoration:none;border-bottom:1px dotted currentColor',
title:'Ver el expediente completo',text:u.serie})),
el('div',{class:'m',text:(u.maquina||'')+(u.nota?' · '+u.nota:'')})));
const [cls,txt] = vs.length ? pill(vs[0]) : ['pend','Sin diagnóstico'];
if(DANOS[u.serie]) cab.append(el('span',{class:'pill falla',text:DANOS[u.serie].marca==='BAJA'?'Baja':'Dañada'}));
cab.append(el('span',{class:'pill '+cls,text:txt}));
box.append(cab);
if(!vs.length) box.append(el('div',{class:'visita vacio',text:'Todavía no hay ningún diagnóstico registrado para esta unidad.'}));
vs.forEach(v=>{
const d = el('div',{class:'visita'});
d.append(el('div',{class:'f',text:(v.tipo==='diagnostico-banco'?'Banco · ':'Campo · ')+fecha(v.recibido)+' · '+(v.tecnico||'—')+(v.motivo?' · '+v.motivo:'')}));
if(v.clasificacion) d.append(el('div',{class:'cl',text:v.clasificacion}));
if(v.accion) d.append(el('div',{text:'→ '+v.accion}));
if(v.fotos && v.fotos.length){
const f = el('div',{class:'fotos'});
v.fotos.forEach(r=>{
const a = el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(r),target:'_blank'});
a.append(el('img',{src:'/api/adjunto?ruta='+encodeURIComponent(r),loading:'lazy'}));
f.append(a);
});
d.append(f);
}
if(v.archivos && v.archivos.length){
const g = el('div',{class:'logs'});
v.archivos.forEach(a=>{
const x = el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(a.ruta),target:'_blank',
text:(a.nombre||'captura')});
g.append(x);
});
d.append(g);
}
const det = el('details');
det.append(el('summary',{text:'Ver todas las respuestas'}));
const caja = el('div'); det.append(caja);
det.addEventListener('toggle',()=>{ if(det.open && !caja.dataset.listo){ caja.dataset.listo='1'; detalle(v.tipo, v.serie, v.archivo_n, caja); } });
d.append(det);
box.append(d);
});
b.append(box);
});
cont.append(b);
});
}
let DANOS = {};
async function cargar(){
document.getElementById('meta').textContent = 'Cargando…';
try{
try{ const d=await (await fetch('/api/danos',{cache:'no-store'})).json(); DANOS={}; (d.series||[]).forEach(x=>DANOS[x.serie]=x); }catch(e){}
const r = await fetch('/api/actas',{cache:'no-store'});
const j = await r.json();
render(j.actas||[]);
document.getElementById('meta').textContent = 'Actualizado ' + new Date().toLocaleString('es-MX',{dateStyle:'short',timeStyle:'short'});
}catch(e){
cont.innerHTML=''; cont.append(el('div',{class:'err',text:'No se pudo leer el servidor de actas: '+e.message}));
document.getElementById('meta').textContent = 'Error';
}
}
document.getElementById('refrescar').addEventListener('click',cargar);
cargar();
})();
</script>

View File

@ -1,226 +1,18 @@
<!doctype html>
<!-- Desde el 20-oct-2026 este tablero vive dentro del sitio: docs/tableros/equipo.md → /tableros/equipo/
Este archivo solo redirige (conserva ?serie=, ?id=, ?pista=… y #…) para que las ligas viejas
guardadas en pendientes y actas sigan sirviendo. No se edita: los cambios van en tableros/equipo.md. -->
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Equipo y accesos — HBOX</title>
<link rel="stylesheet" href="tablero.css">
<style>
table.t td.num{text-align:right;font-variant-numeric:tabular-nums}
table.t th.num{text-align:right}
table.t tr.inactivo td{opacity:.55}
table.t td .nom{font-weight:700;cursor:pointer;color:var(--naranja)}
table.t td .nom:hover{text-decoration:underline}
table.t td .id{font-size:11.5px;color:var(--tinta2);display:block}
table.t td button{padding:5px 9px;font-size:12.5px;margin:1px 2px 1px 0}
.area{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:99px;background:var(--pend-bg);color:var(--tinta2)}
.pass-una-vez{background:var(--aviso-bg);border:1px solid var(--aviso);border-radius:10px;padding:12px 14px;margin-top:10px;font-size:14.5px;color:#7a4a00}
.pass-una-vez code{font-size:17px;font-weight:700;letter-spacing:.04em;user-select:all}
.mis-pend .card{cursor:default}
@media(max-width:760px){#cont table.t{min-width:760px}}
</style>
<meta name="robots" content="noindex">
<title>Equipo - Drogheda</title>
<script>location.replace('/tableros/equipo/' + location.search + location.hash);</script>
<meta http-equiv="refresh" content="0; url=/tableros/equipo/">
<style>body{margin:0;padding:24px;background:#0E1524;color:#E7ECF5;font:16px/1.5 system-ui,sans-serif}a{color:#7FE9D8}</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Equipo y accesos</h1><div class="meta" id="meta">Cargando…</div></div>
<nav>
<a href="../">← Documentación</a>
<a href="kanban.html">Pendientes</a>
<a href="ingenieria.html">Ingeniería</a>
<a href="produccion.html">Producción</a>
<a class="activo" href="equipo.html">Equipo</a>
<a href="../cuenta.html">Mi cuenta</a>
</nav>
</header>
<details class="como">
<summary>Cómo se usa</summary>
<ol>
<li>Aquí está <b>quién es quién</b>: nombre, rol, área, si tiene acceso al sitio y cuántos pendientes abiertos y vencidos trae cada quien.</li>
<li><b>Todo pendiente tiene responsable.</b> Al crearlo en el tablero se elige de esta lista; puede haber más de uno.</li>
<li>Un miembro <b>sin acceso al sitio</b> también puede ser responsable (Adriana hoy): el pendiente se le asigna igual y alguien con acceso lo mueve por ella.</li>
<li>Toca un nombre para ver sus pendientes abiertos y abrir el tablero filtrado.</li>
<li>Los <b>accesos los crea Leo aquí</b> (botón «Nuevo usuario / miembro»), ya no con <code>htpasswd</code> en el servidor. La contraseña inicial se muestra <b>una sola vez</b>: pásala en persona o por mensaje directo; la persona la cambia en <a href="../cuenta.html">Mi cuenta</a>.</li>
<li>«Desactivar» oculta al miembro de las listas sin borrar nada; «Quitar acceso» le cierra la entrada al sitio y lo desactiva. Nadie se borra: el historial se queda.</li>
<li>Si no tienes acceso o no apareces, pídeselo a Leo.</li>
</ol>
</details>
<div class="msg" id="aviso"></div>
<div id="acciones" style="display:none;margin:0 0 12px"><button id="bNuevo">+ Nuevo usuario / miembro</button></div>
<div id="cont" class="tblwrap"></div>
<section class="bloque mis-pend" id="detalle" style="display:none"></section>
</div>
<dialog id="dlg"></dialog>
<script src="kanban.js"></script>
<script>
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 $ = id => document.getElementById(id);
const AREA = { direccion: 'Dirección', operaciones: 'Operaciones', ingenieria: 'Ingeniería', layout: 'Layout', produccion: 'Producción', pruebas: 'Pruebas', fabricacion: 'Fabricación', software: 'Software', campo: 'Campo', asistente: 'Asistente' };
const PISTAS = Kanban.PISTAS, ESTADOS = Object.fromEntries(Kanban.ESTADOS);
let MIEMBROS = [], ADMIN = false, USER = '', PASS_MIN = 8, AREAS = Object.keys(AREA), HTPASSWD_OK = true;
const dlg = $('dlg'), aviso = $('aviso');
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.innerHTML = 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) {} 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', { style: 'font-size:12.5px;color:var(--tinta2);margin-top:3px', text: ayuda })); return d; }
function pintar() {
const cont = $('cont'); cont.innerHTML = '';
if (!MIEMBROS.length) { cont.append(el('div', { class: 'vacio', text: 'Todavía no hay miembros. Corre la siembra o crea el primero.' })); return; }
const t = el('table', { class: 't' });
t.innerHTML = '<tr><th>Nombre</th><th>Rol</th><th>Área</th><th>Acceso</th><th>Admin</th><th class="num">Abiertos</th><th class="num">Vencidos</th><th>Activo</th>' + (ADMIN ? '<th></th>' : '') + '</tr>';
MIEMBROS.forEach(m => {
const tr = el('tr', { class: m.activo === false ? 'inactivo' : '' });
const nom = el('span', { class: 'nom', text: m.nombre }); nom.addEventListener('click', () => detalle(m));
tr.append(el('td', null, nom, el('span', { class: 'id', text: m.id + (m.correo ? ' · ' + m.correo : '') + (m.telefono ? ' · ' + m.telefono : '') })));
tr.append(el('td', { text: m.rol || '—' }));
tr.append(el('td', null, el('span', { class: 'area', text: AREA[m.area] || m.area })));
tr.append(el('td', { html: m.tiene_login ? '<span class="pill ok">sí</span>' : '<span class="pill pend">no</span>' }));
tr.append(el('td', { html: m.es_admin ? '<span class="pill info">admin</span>' : '' }));
const p = m.pendientes || {};
tr.append(el('td', { class: 'num', html: p.abiertos ? '<a href="kanban.html?responsable=' + encodeURIComponent(m.id) + '"><b>' + p.abiertos + '</b></a>' : '0' }));
tr.append(el('td', { class: 'num', html: p.vencidas ? '<span style="color:var(--falla);font-weight:700">' + p.vencidas + '</span>' : '0' }));
tr.append(el('td', { html: m.activo === false ? '<span class="pill falla">inactivo</span>' : '<span class="pill ok">activo</span>' }));
if (ADMIN) {
const acc = el('td', { style: 'white-space:nowrap' });
const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(m)); acc.append(be);
if (m.activo !== false) { const bd = el('button', { class: 'sec', text: 'Desactivar' }); bd.addEventListener('click', () => desactivar(m)); acc.append(bd); }
else { const ba = el('button', { class: 'sec', text: 'Reactivar' }); ba.addEventListener('click', () => reactivar(m)); acc.append(ba); }
if (m.tiene_login && m.id.toLowerCase() !== USER.toLowerCase()) { const bq = el('button', { class: 'peligro', text: 'Quitar acceso' }); bq.addEventListener('click', () => quitarAcceso(m)); acc.append(bq); }
if (!m.tiene_login && m.activo !== false) { const bc = el('button', { text: 'Crear acceso' }); bc.addEventListener('click', () => nuevo(m)); acc.append(bc); }
tr.append(acc);
}
t.append(tr);
});
cont.append(t);
}
async function cargar() {
$('meta').textContent = 'Actualizando…';
try {
const j = await api('/api/equipo?todos=1');
MIEMBROS = j.miembros || []; ADMIN = !!j.admin; USER = j.user || ''; PASS_MIN = j.pass_min || 8; AREAS = j.areas || AREAS; HTPASSWD_OK = j.htpasswd_ok !== false;
Kanban.usarEquipo(Object.fromEntries(MIEMBROS.map(m => [m.id, { nombre: m.nombre, activo: m.activo !== false, area: m.area }])));
pintar();
$('acciones').style.display = ADMIN ? '' : 'none';
const act = MIEMBROS.filter(m => m.activo !== false), con = act.filter(m => m.tiene_login).length;
$('meta').textContent = act.length + ' miembros activos · ' + con + ' con acceso al sitio · ' + (USER ? USER + (ADMIN ? ' (admin)' : '') : '');
if (ADMIN && !HTPASSWD_OK) mensaje('El servicio no encuentra el archivo de usuarios (HTPASSWD_FILE): se pueden editar miembros, pero no crear ni quitar accesos. Avisa a Leo.', false);
if (ADMIN && (j.logins_sin_miembro || []).length) mensaje('Cuentas con acceso que no están en el equipo: <b>' + j.logins_sin_miembro.map(esc).join(', ') + '</b>. Dales de alta como miembro (Nuevo usuario / miembro, sin marcar «crear acceso») para que puedan ser responsables.', false);
const q = new URLSearchParams(location.search).get('id'); if (q) { const m = MIEMBROS.find(x => x.id === q); if (m) detalle(m); }
} catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo cargar el equipo: ' + e.message })); $('meta').textContent = 'Error'; }
}
// ---------- pendientes de un miembro ----------
async function detalle(m) {
const sec = $('detalle'); sec.style.display = ''; sec.innerHTML = '';
sec.append(el('h2', { text: 'Pendientes de ' + m.nombre }), el('p', { class: 'sub', html: esc(m.rol || '') + ' · ' + esc(AREA[m.area] || m.area) + ' · <a href="kanban.html?responsable=' + encodeURIComponent(m.id) + '">Abrir en el tablero →</a>' }));
const lista = el('div', { text: 'Cargando…' }); sec.append(lista);
sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const j = await api('/api/tareas?responsable=' + encodeURIComponent(m.id));
const abiertas = (j.tareas || []).filter(t => t.estado !== 'hecho');
lista.innerHTML = '';
if (!abiertas.length) { lista.append(el('div', { class: 'vacio', text: 'Sin pendientes abiertos.' })); return; }
abiertas.forEach(t => {
const c = el('div', { class: 'card p-' + t.pista + (t.vencida ? ' vencida' : '') });
c.append(el('div', { class: 'tit', html: '<a href="kanban.html?id=' + encodeURIComponent(t.id) + '" style="color:inherit;text-decoration:none">' + esc(t.titulo) + '</a>' }));
c.append(el('div', { class: 'meta', html: '<span class="chip p-' + t.pista + '">' + esc(PISTAS[t.pista] || t.pista) + '</span><span class="chip">' + esc(ESTADOS[t.estado] || t.estado) + '</span>'
+ (t.fecha_limite ? '<span class="fecha' + (t.vencida ? ' mal' : '') + '">' + (t.vencida ? '⚠ venció ' : '📅 ') + esc(t.fecha_limite) + '</span>' : '')
+ ((t.responsable_ids || []).length > 1 ? '<span>👥 ' + esc(Kanban.nombresResp(t).join(', ')) + '</span>' : '') }));
lista.append(c);
});
} catch (e) { lista.textContent = 'No se pudieron leer los pendientes: ' + e.message; }
}
// ---------- diálogos (admin) ----------
function abrirDialogo(titulo, cuerpoFn, botones) {
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo }));
const x = el('button', { type: 'button', text: '✕' }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
const m = el('div', { class: 'msg' });
cuerpoFn(cuerpo, m); cuerpo.append(m);
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
const cancelar = el('button', { type: 'button', class: 'sec', text: botones.length ? 'Cancelar' : 'Cerrar' }); cancelar.addEventListener('click', () => dlg.close()); pie.append(cancelar);
botones.forEach(([txt, fn]) => { const b = el('button', { type: 'button', text: txt }); b.addEventListener('click', async () => { b.disabled = true; try { await fn(m, b, pie); } catch (e) { m.className = 'msg err'; m.textContent = e.message; } b.disabled = false; }); pie.append(b); });
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
}
function formMiembro(c, m, conAcceso) {
m = m || {}; const f = {};
f.id = el('input', { type: 'text', maxlength: '30', value: m.id || '', placeholder: 'adriana (minúsculas, sin acentos; será el usuario del sitio)', autocapitalize: 'none' }); if (m.id) f.id.disabled = true;
f.nombre = el('input', { type: 'text', maxlength: '80', value: m.nombre || '', placeholder: 'Nombre y apellido' });
f.rol = el('input', { type: 'text', maxlength: '120', value: m.rol || '', placeholder: 'Técnico de pruebas · Layout de PCB · Operaciones' });
f.area = el('select'); AREAS.forEach(a => f.area.append(el('option', { value: a, text: AREA[a] || a }))); f.area.value = m.area || 'produccion';
f.correo = el('input', { type: 'email', maxlength: '120', value: m.correo || '' });
f.telefono = el('input', { type: 'tel', maxlength: '40', value: m.telefono || '' });
const g = el('div', { class: 'dos-col' }); g.append(campo('Usuario (id)', f.id), campo('Nombre', f.nombre), campo('Rol', f.rol), campo('Área', f.area), campo('Correo', f.correo), campo('Teléfono', f.telefono)); c.append(g);
if (conAcceso) {
f.acceso = el('input', { type: 'checkbox', style: 'width:auto' }); f.acceso.checked = !m.tiene_login && HTPASSWD_OK; f.acceso.disabled = !HTPASSWD_OK;
const l = el('label', { class: 'chip-sel', style: 'margin-top:12px' }); l.append(f.acceso, document.createTextNode(' Crear acceso al sitio (usuario y contraseña)')); c.append(l);
f.pass = el('input', { type: 'text', autocomplete: 'off', placeholder: 'mínimo ' + PASS_MIN + ' caracteres; se muestra una sola vez' });
const cp = campo('Contraseña inicial', f.pass, 'La persona la cambia en Mi cuenta la primera vez que entre.'); c.append(cp);
const gen = el('button', { type: 'button', class: 'sec', text: 'Generar una', style: 'padding:6px 10px;font-size:13px' });
gen.addEventListener('click', () => { const A = 'abcdefghjkmnpqrstuvwxyz23456789'; let p = ''; const r = new Uint32Array(12); crypto.getRandomValues(r); r.forEach(v => p += A[v % A.length]); f.pass.value = p.slice(0, 4) + '-' + p.slice(4, 8) + '-' + p.slice(8, 12); });
cp.append(gen);
const sync = () => { cp.style.display = f.acceso.checked ? '' : 'none'; }; f.acceso.addEventListener('change', sync); sync();
if (!HTPASSWD_OK) c.append(el('div', { class: 'err', text: 'No se pueden crear accesos: el servicio no encuentra el archivo de usuarios.' }));
}
return f;
}
const leerMiembro = f => ({ nombre: f.nombre.value.trim(), rol: f.rol.value.trim(), area: f.area.value, correo: f.correo.value.trim(), telefono: f.telefono.value.trim() });
function nuevo(base) {
let f;
abrirDialogo(base ? 'Crear acceso para ' + base.nombre : 'Nuevo usuario / miembro', c => { f = formMiembro(c, base, true); if (base) { f.acceso.checked = true; f.acceso.dispatchEvent(new Event('change')); } },
[['Guardar', async (m, b, pie) => {
const id = Kanban.norm(f.id.value); const d = leerMiembro(f);
if (!id) throw new Error('Escribe el usuario (id).');
if (!d.nombre) throw new Error('Escribe el nombre.');
if (f.acceso.checked) {
if (f.pass.value.length < PASS_MIN) throw new Error('La contraseña inicial debe tener al menos ' + PASS_MIN + ' caracteres.');
const j = await api('/api/usuarios', json('POST', Object.assign({ usuario: id, password_inicial: f.pass.value }, d)));
const pw = f.pass.value;
dlg.querySelector('.dcuerpo').innerHTML = '';
dlg.querySelector('.dcuerpo').append(el('div', { class: 'pass-una-vez', html: '✓ Acceso creado para <b>' + esc(j.usuario) + '</b>.<br>Contraseña inicial (no se vuelve a mostrar): <code>' + esc(pw) + '</code><br><small>Pásasela en persona o por mensaje directo y dile que la cambie en Mi cuenta.</small>' }));
pie.innerHTML = ''; const ok = el('button', { type: 'button', text: 'Listo' }); ok.addEventListener('click', () => dlg.close()); pie.append(ok);
cargar(); return;
}
if (base) throw new Error('Marca «Crear acceso al sitio» para darle acceso.');
await api('/api/equipo', json('POST', Object.assign({ id }, d)));
dlg.close(); mensaje('Miembro ' + esc(d.nombre) + ' creado (sin acceso al sitio).', true); cargar();
}]]);
}
function editar(m) {
let f;
abrirDialogo('Editar ' + m.nombre, c => {
f = formMiembro(c, m, false);
const h = el('div', { class: 'hist' });
h.append(el('div', { html: '<b>Creado</b> por ' + esc(m.creado_por) + ' el ' + esc(fmt(m.creado_en)) + (m.tiene_login ? ' · tiene acceso al sitio' : ' · sin acceso al sitio') }));
(m.historial || []).slice().reverse().slice(0, 10).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + esc(x.cambio) })));
c.append(h);
}, [['Guardar', async () => {
const d = leerMiembro(f); const cambios = {};
Object.keys(d).forEach(k => { if (d[k] !== (m[k] || '')) cambios[k] = d[k]; });
if (Object.keys(cambios).length) await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', cambios));
dlg.close(); mensaje('Guardado.', true); cargar();
}]]);
}
async function desactivar(m) {
if (!confirm('¿Desactivar a ' + m.nombre + '? Deja de aparecer en las listas de responsables; sus pendientes y su historial se quedan.' + (m.tiene_login ? ' Su acceso al sitio NO se quita (usa «Quitar acceso» para eso).' : ''))) return;
try { await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', { activo: false })); mensaje(m.nombre + ' desactivado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
}
async function reactivar(m) {
try { await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', { activo: true })); mensaje(m.nombre + ' reactivado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
}
async function quitarAcceso(m) {
if (!confirm('¿Quitar el acceso al sitio a ' + m.nombre + ' (' + m.id + ')? Ya no podrá entrar y quedará como inactivo. Sus actas y pendientes se conservan.')) return;
try { await api('/api/usuario?u=' + encodeURIComponent(m.id), { method: 'DELETE' }); mensaje('Acceso de ' + m.id + ' retirado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
}
$('bNuevo').addEventListener('click', () => nuevo(null));
cargar();
</script>
<p>Este tablero ahora está dentro del sitio: <a href="/tableros/equipo/">Equipo</a>.</p>
</body>
</html>

205
docs/tableros/equipo.md Normal file
View File

@ -0,0 +1,205 @@
---
title: "Equipo"
description: "Quién es quién: miembros, área, acceso al sitio y pendientes de cada quien."
template: tablero.html
hide:
- toc
---
<div class="tb tb-equipo">
<header class="tb-cab">
<div class="tb-cab__t"><h1>Equipo</h1><span class="meta" id="meta">Cargando…</span></div>
<div class="tb-cab__acc"><a class="boton sec" href="/cuenta.html"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zM5 20c.8-3.8 3.6-6 7-6s6.2 2.2 7 6"/></svg>Mi cuenta</a></div>
</header>
<details class="info tb-como">
<summary>Cómo se usa</summary>
<ol>
<li>Aquí está <b>quién es quién</b>: nombre, rol, área, si tiene acceso al sitio y cuántos pendientes abiertos y vencidos trae cada quien.</li>
<li><b>Todo pendiente tiene responsable.</b> Al crearlo en el tablero se elige de esta lista; puede haber más de uno.</li>
<li>Un miembro <b>sin acceso al sitio</b> también puede ser responsable (Adriana hoy): el pendiente se le asigna igual y alguien con acceso lo mueve por ella.</li>
<li>Toca un nombre para ver sus pendientes abiertos y abrir el tablero filtrado.</li>
<li>Los <b>accesos los crea Leo aquí</b> (botón «Nuevo usuario / miembro»), ya no con <code>htpasswd</code> en el servidor. La contraseña inicial se muestra <b>una sola vez</b>: pásala en persona o por mensaje directo; la persona la cambia en <a href="/cuenta.html">Mi cuenta</a>.</li>
<li>«Desactivar» oculta al miembro de las listas sin borrar nada; «Quitar acceso» le cierra la entrada al sitio y lo desactiva. Nadie se borra: el historial se queda.</li>
<li>Si no tienes acceso o no apareces, pídeselo a Leo.</li>
</ol>
</details>
<div class="msg" id="aviso"></div>
<div id="acciones" style="display:none;margin:0 0 12px"><button id="bNuevo"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Nuevo usuario o miembro</button></div>
<div id="cont" class="tblwrap"></div>
<section class="bloque mis-pend" id="detalle" style="display:none"></section>
<dialog id="dlg"></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 $ = id => document.getElementById(id);
const AREA = { direccion: 'Dirección', operaciones: 'Operaciones', ingenieria: 'Ingeniería', layout: 'Layout', produccion: 'Producción', pruebas: 'Pruebas', fabricacion: 'Fabricación', software: 'Software', campo: 'Campo', asistente: 'Asistente' };
const PISTAS = Kanban.PISTAS, ESTADOS = Object.fromEntries(Kanban.ESTADOS);
let MIEMBROS = [], ADMIN = false, USER = '', PASS_MIN = 8, AREAS = Object.keys(AREA), HTPASSWD_OK = true;
const dlg = $('dlg'), aviso = $('aviso');
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.innerHTML = 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) {} 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', { style: 'font-size:12.5px;color:var(--tinta2);margin-top:3px', text: ayuda })); return d; }
function pintar() {
const cont = $('cont'); cont.innerHTML = '';
if (!MIEMBROS.length) { cont.append(el('div', { class: 'vacio', text: 'Todavía no hay miembros. Corre la siembra o crea el primero.' })); return; }
const t = el('table', { class: 't' });
t.innerHTML = '<tr><th>Nombre</th><th>Rol</th><th>Área</th><th>Acceso</th><th>Admin</th><th class="num">Abiertos</th><th class="num">Vencidos</th><th>Activo</th>' + (ADMIN ? '<th></th>' : '') + '</tr>';
MIEMBROS.forEach(m => {
const tr = el('tr', { class: m.activo === false ? 'inactivo' : '' });
const nom = el('span', { class: 'nom' }, Kanban.avatar(m.id), document.createTextNode(m.nombre)); nom.addEventListener('click', () => detalle(m));
tr.append(el('td', null, nom, el('span', { class: 'id', text: m.id + (m.correo ? ' · ' + m.correo : '') + (m.telefono ? ' · ' + m.telefono : '') })));
tr.append(el('td', { text: m.rol || '—' }));
tr.append(el('td', null, el('span', { class: 'area', text: AREA[m.area] || m.area })));
tr.append(el('td', { html: m.tiene_login ? '<span class="pill ok">sí</span>' : '<span class="pill pend">no</span>' }));
tr.append(el('td', { html: m.es_admin ? '<span class="pill info">admin</span>' : '' }));
const p = m.pendientes || {};
tr.append(el('td', { class: 'num', html: p.abiertos ? '<a href="/tableros/pendientes/?responsable=' + encodeURIComponent(m.id) + '"><b>' + p.abiertos + '</b></a>' : '0' }));
tr.append(el('td', { class: 'num', html: p.vencidas ? '<span style="color:var(--falla);font-weight:700">' + p.vencidas + '</span>' : '0' }));
tr.append(el('td', { html: m.activo === false ? '<span class="pill falla">inactivo</span>' : '<span class="pill ok">activo</span>' }));
if (ADMIN) {
const acc = el('td', { style: 'white-space:nowrap' });
const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(m)); acc.append(be);
if (m.activo !== false) { const bd = el('button', { class: 'sec', text: 'Desactivar' }); bd.addEventListener('click', () => desactivar(m)); acc.append(bd); }
else { const ba = el('button', { class: 'sec', text: 'Reactivar' }); ba.addEventListener('click', () => reactivar(m)); acc.append(ba); }
if (m.tiene_login && m.id.toLowerCase() !== USER.toLowerCase()) { const bq = el('button', { class: 'peligro', text: 'Quitar acceso' }); bq.addEventListener('click', () => quitarAcceso(m)); acc.append(bq); }
if (!m.tiene_login && m.activo !== false) { const bc = el('button', { class: 'sec', text: 'Crear acceso' }); bc.addEventListener('click', () => nuevo(m)); acc.append(bc); }
tr.append(acc);
}
t.append(tr);
});
cont.append(t);
}
async function cargar() {
$('meta').textContent = 'Actualizando…';
try {
const j = await api('/api/equipo?todos=1');
MIEMBROS = j.miembros || []; ADMIN = !!j.admin; USER = j.user || ''; PASS_MIN = j.pass_min || 8; AREAS = j.areas || AREAS; HTPASSWD_OK = j.htpasswd_ok !== false;
Kanban.usarEquipo(Object.fromEntries(MIEMBROS.map(m => [m.id, { nombre: m.nombre, activo: m.activo !== false, area: m.area }])));
pintar();
$('acciones').style.display = ADMIN ? '' : 'none';
const act = MIEMBROS.filter(m => m.activo !== false), con = act.filter(m => m.tiene_login).length;
$('meta').textContent = act.length + ' miembros activos · ' + con + ' con acceso al sitio · ' + (USER ? USER + (ADMIN ? ' (admin)' : '') : '');
if (ADMIN && !HTPASSWD_OK) mensaje('El servicio no encuentra el archivo de usuarios (HTPASSWD_FILE): se pueden editar miembros, pero no crear ni quitar accesos. Avisa a Leo.', false);
if (ADMIN && (j.logins_sin_miembro || []).length) mensaje('Cuentas con acceso que no están en el equipo: <b>' + j.logins_sin_miembro.map(esc).join(', ') + '</b>. Dales de alta como miembro (Nuevo usuario / miembro, sin marcar «crear acceso») para que puedan ser responsables.', false);
const q = new URLSearchParams(location.search).get('id'); if (q) { const m = MIEMBROS.find(x => x.id === q); if (m) detalle(m); }
} catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo cargar el equipo: ' + e.message })); $('meta').textContent = 'Error'; }
}
// ---------- pendientes de un miembro ----------
async function detalle(m) {
const sec = $('detalle'); sec.style.display = ''; sec.innerHTML = '';
sec.append(el('h2', { text: 'Pendientes de ' + m.nombre }), el('p', { class: 'sub', html: esc(m.rol || '') + ' · ' + esc(AREA[m.area] || m.area) + ' · <a href="/tableros/pendientes/?responsable=' + encodeURIComponent(m.id) + '">Abrir en el tablero →</a>' }));
const lista = el('div', { text: 'Cargando…' }); sec.append(lista);
sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const j = await api('/api/tareas?responsable=' + encodeURIComponent(m.id));
const abiertas = (j.tareas || []).filter(t => t.estado !== 'hecho');
lista.innerHTML = '';
if (!abiertas.length) { lista.append(el('div', { class: 'vacio', text: 'Sin pendientes abiertos.' })); return; }
abiertas.forEach(t => {
const c = el('div', { class: 'card p-' + t.pista + (t.vencida ? ' vencida' : '') });
c.append(el('div', { class: 'tit', html: '<a href="/tableros/pendientes/?id=' + encodeURIComponent(t.id) + '" style="color:inherit;text-decoration:none">' + esc(t.titulo) + '</a>' }));
c.append(el('div', { class: 'meta', html: '<span class="chip p-' + t.pista + '">' + esc(PISTAS[t.pista] || t.pista) + '</span><span class="chip">' + esc(ESTADOS[t.estado] || t.estado) + '</span>'
+ (t.fecha_limite ? '<span class="fecha' + (t.vencida ? ' mal' : '') + '">' + (t.vencida ? 'Venció ' : '') + esc(t.fecha_limite) + '</span>' : '')
+ ((t.responsable_ids || []).length > 1 ? '<span>Con ' + esc(Kanban.nombresResp(t).join(', ')) + '</span>' : '') }));
lista.append(c);
});
} catch (e) { lista.textContent = 'No se pudieron leer los pendientes: ' + e.message; }
}
// ---------- diálogos (admin) ----------
function abrirDialogo(titulo, cuerpoFn, botones) {
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo }));
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 m = el('div', { class: 'msg' });
cuerpoFn(cuerpo, m); cuerpo.append(m);
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
const cancelar = el('button', { type: 'button', class: 'sec', text: botones.length ? 'Cancelar' : 'Cerrar' }); cancelar.addEventListener('click', () => dlg.close()); pie.append(cancelar);
botones.forEach(([txt, fn]) => { const b = el('button', { type: 'button', text: txt }); b.addEventListener('click', async () => { b.disabled = true; try { await fn(m, b, pie); } catch (e) { m.className = 'msg err'; m.textContent = e.message; } b.disabled = false; }); pie.append(b); });
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
}
function formMiembro(c, m, conAcceso) {
m = m || {}; const f = {};
f.id = el('input', { type: 'text', maxlength: '30', value: m.id || '', placeholder: 'adriana (minúsculas, sin acentos; será el usuario del sitio)', autocapitalize: 'none' }); if (m.id) f.id.disabled = true;
f.nombre = el('input', { type: 'text', maxlength: '80', value: m.nombre || '', placeholder: 'Nombre y apellido' });
f.rol = el('input', { type: 'text', maxlength: '120', value: m.rol || '', placeholder: 'Técnico de pruebas · Layout de PCB · Operaciones' });
f.area = el('select'); AREAS.forEach(a => f.area.append(el('option', { value: a, text: AREA[a] || a }))); f.area.value = m.area || 'produccion';
f.correo = el('input', { type: 'email', maxlength: '120', value: m.correo || '' });
f.telefono = el('input', { type: 'tel', maxlength: '40', value: m.telefono || '' });
const g = el('div', { class: 'dos-col' }); g.append(campo('Usuario (id)', f.id), campo('Nombre', f.nombre), campo('Rol', f.rol), campo('Área', f.area), campo('Correo', f.correo), campo('Teléfono', f.telefono)); c.append(g);
if (conAcceso) {
f.acceso = el('input', { type: 'checkbox', style: 'width:auto' }); f.acceso.checked = !m.tiene_login && HTPASSWD_OK; f.acceso.disabled = !HTPASSWD_OK;
const l = el('label', { class: 'chip-sel', style: 'margin-top:12px' }); l.append(f.acceso, document.createTextNode(' Crear acceso al sitio (usuario y contraseña)')); c.append(l);
f.pass = el('input', { type: 'text', autocomplete: 'off', placeholder: 'mínimo ' + PASS_MIN + ' caracteres; se muestra una sola vez' });
const cp = campo('Contraseña inicial', f.pass, 'La persona la cambia en Mi cuenta la primera vez que entre.'); c.append(cp);
const gen = el('button', { type: 'button', class: 'sec', text: 'Generar una', style: 'padding:6px 10px;font-size:13px' });
gen.addEventListener('click', () => { const A = 'abcdefghjkmnpqrstuvwxyz23456789'; let p = ''; const r = new Uint32Array(12); crypto.getRandomValues(r); r.forEach(v => p += A[v % A.length]); f.pass.value = p.slice(0, 4) + '-' + p.slice(4, 8) + '-' + p.slice(8, 12); });
cp.append(gen);
const sync = () => { cp.style.display = f.acceso.checked ? '' : 'none'; }; f.acceso.addEventListener('change', sync); sync();
if (!HTPASSWD_OK) c.append(el('div', { class: 'err', text: 'No se pueden crear accesos: el servicio no encuentra el archivo de usuarios.' }));
}
return f;
}
const leerMiembro = f => ({ nombre: f.nombre.value.trim(), rol: f.rol.value.trim(), area: f.area.value, correo: f.correo.value.trim(), telefono: f.telefono.value.trim() });
function nuevo(base) {
let f;
abrirDialogo(base ? 'Crear acceso para ' + base.nombre : 'Nuevo usuario / miembro', c => { f = formMiembro(c, base, true); if (base) { f.acceso.checked = true; f.acceso.dispatchEvent(new Event('change')); } },
[['Guardar', async (m, b, pie) => {
const id = Kanban.norm(f.id.value); const d = leerMiembro(f);
if (!id) throw new Error('Escribe el usuario (id).');
if (!d.nombre) throw new Error('Escribe el nombre.');
if (f.acceso.checked) {
if (f.pass.value.length < PASS_MIN) throw new Error('La contraseña inicial debe tener al menos ' + PASS_MIN + ' caracteres.');
const j = await api('/api/usuarios', json('POST', Object.assign({ usuario: id, password_inicial: f.pass.value }, d)));
const pw = f.pass.value;
dlg.querySelector('.dcuerpo').innerHTML = '';
dlg.querySelector('.dcuerpo').append(el('div', { class: 'pass-una-vez', html: 'Acceso creado para <b>' + esc(j.usuario) + '</b>.<br>Contraseña inicial (no se vuelve a mostrar): <code>' + esc(pw) + '</code><br><small>Pásasela en persona o por mensaje directo y dile que la cambie en Mi cuenta.</small>' }));
pie.innerHTML = ''; const ok = el('button', { type: 'button', text: 'Listo' }); ok.addEventListener('click', () => dlg.close()); pie.append(ok);
cargar(); return;
}
if (base) throw new Error('Marca «Crear acceso al sitio» para darle acceso.');
await api('/api/equipo', json('POST', Object.assign({ id }, d)));
dlg.close(); mensaje('Miembro ' + esc(d.nombre) + ' creado (sin acceso al sitio).', true); cargar();
}]]);
}
function editar(m) {
let f;
abrirDialogo('Editar ' + m.nombre, c => {
f = formMiembro(c, m, false);
const h = el('div', { class: 'hist' });
h.append(el('div', { html: '<b>Creado</b> por ' + esc(m.creado_por) + ' el ' + esc(fmt(m.creado_en)) + (m.tiene_login ? ' · tiene acceso al sitio' : ' · sin acceso al sitio') }));
(m.historial || []).slice().reverse().slice(0, 10).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + esc(x.cambio) })));
c.append(h);
}, [['Guardar', async () => {
const d = leerMiembro(f); const cambios = {};
Object.keys(d).forEach(k => { if (d[k] !== (m[k] || '')) cambios[k] = d[k]; });
if (Object.keys(cambios).length) await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', cambios));
dlg.close(); mensaje('Guardado.', true); cargar();
}]]);
}
async function desactivar(m) {
if (!confirm('¿Desactivar a ' + m.nombre + '? Deja de aparecer en las listas de responsables; sus pendientes y su historial se quedan.' + (m.tiene_login ? ' Su acceso al sitio NO se quita (usa «Quitar acceso» para eso).' : ''))) return;
try { await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', { activo: false })); mensaje(m.nombre + ' desactivado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
}
async function reactivar(m) {
try { await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', { activo: true })); mensaje(m.nombre + ' reactivado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
}
async function quitarAcceso(m) {
if (!confirm('¿Quitar el acceso al sitio a ' + m.nombre + ' (' + m.id + ')? Ya no podrá entrar y quedará como inactivo. Sus actas y pendientes se conservan.')) return;
try { await api('/api/usuario?u=' + encodeURIComponent(m.id), { method: 'DELETE' }); mensaje('Acceso de ' + m.id + ' retirado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
}
$('bNuevo').addEventListener('click', () => nuevo(null));
cargar();
})();
</script>

View File

@ -4,36 +4,35 @@ title: "Tableros"
# Tableros
Las páginas que abres para saber **dónde está el proyecto hoy**: qué tareas hay y de quién son, en qué estado va cada versión de tarjeta, cuántas unidades llevamos contra el plan de 570 y qué pasó con cada serie. Se alimentan solas de las actas y de los registros del servidor (`/api/…`); no hay nada que copiar a mano.
Las páginas que abres para saber **dónde está el proyecto hoy**: qué pendientes hay y de quién son, en qué estado va cada versión de tarjeta, cuántas unidades llevamos contra el plan de 570 y qué pasó con cada serie. Se alimentan solas de las actas y de los registros del servidor (`/api/…`); no hay nada que copiar a mano. Todos viven en el menú de la izquierda, bajo **Tableros**.
## ¿Qué quiero ver?
| Quiero… | Tablero | Qué muestra |
|---|---|---|
| Las **tareas** del equipo y quién las tiene | [Kanban](kanban.html) | Columnas *Por hacer · En curso · Bloqueado · Hecho*; pistas Ingeniería / Producción / Mecánica / Compras / Campo; responsable, fecha límite, ligas a documentos y actas; historial de movimientos |
| **Dónde está el hardware** (V5.5 → V5.6 → V5.7 → v6) | [Ingeniería](ingenieria.html) | Una tarjeta por versión con su estado, pendientes con responsable y fecha, últimas decisiones y filosofía de diseño; las tareas de la pista Ingeniería |
| **Cuántas unidades** llevamos y cuántas faltan | [Producción](produccion.html) | Plan 113 V5.6 · 327 V5.7 · 130 v6 contra las unidades reales por etapa (tarjeta → módulos → ensamble → envío → instalación); embudo por paso; bloqueos (caja plástica / cable Ethernet) |
| Qué **tarjeta, módulo o unidad** pasó cada acta | [Estado de actas](../tablero.html) | Series V5-NN, ARD-NNN, RTK-NNNN, V4-NN; parejas integradas; unidades ensambladas. Pendiente = liga al acta |
| Las **visitas de diagnóstico** en los puertos | [Tablero de campo](../diagnostico/tablero-campo.html) | Todas las actas de diagnóstico en campo, con fotos |
| **Todo sobre una serie** | [Expediente de una unidad](../unidad.html) | Cada acta, foto y log de una unidad en una línea de tiempo, de la tarjeta al sitio |
| Los **pendientes** del equipo y quién los tiene | [Pendientes](pendientes.md) | Columnas *Por hacer · En curso · Bloqueado · Hecho*; pistas Ingeniería, Producción, Mecánica, Compras y Campo; responsable, fecha límite, ligas a documentos y actas; historial de cada cambio. «Solo los míos» deja solo los tuyos |
| **Dónde está el hardware** (V5.5 → V5.6 → V5.7 → v6) | [Ingeniería](ingenieria.md) | Una tarjeta por versión con su estado, siguiente paso, responsables y documentos; los pendientes de la pista Ingeniería; últimas decisiones y filosofía de diseño |
| **Cuántas unidades** llevamos y cuántas faltan | [Producción](produccion.md) | Plan 113 V5.6 · 327 V5.7 · 130 v6 contra las unidades reales por etapa; embudo por paso; bloqueos; alertas de inventario |
| Qué **tarjeta, módulo o unidad** pasó cada acta | [Actas](actas.md) | Series V5-NN, ARD-NNN, RTK-NNNN y V4-NN; parejas integradas; unidades ensambladas. Pendiente = liga al acta |
| Qué **material** hay y qué falta | [Inventario](inventario.md) | Existencias, mínimos y cada movimiento con quién y por qué |
| Qué hay **en cada puerto** | [Sitios](sitio.md) | Unidades instaladas, envíos, diagnósticos, daños y contactos de ICAVE, LZC, EIT y TIMSA |
| **Quién es quién** | [Equipo](equipo.md) | Miembros, área, acceso al sitio y pendientes abiertos y vencidos de cada quien. El administrador crea aquí los accesos |
| **Todo sobre una serie** | [Expediente de unidad](unidad.md) | Cada acta, foto y log de una unidad en una línea de tiempo, de la tarjeta al sitio |
| Las **visitas de diagnóstico** | [Visitas de campo](campo.md) | Todas las actas de diagnóstico en campo y en banco, con fotos |
## Cómo se usan
1. Las tareas se mueven arrastrando entre columnas. Cualquier usuario autenticado crea y mueve; **borrar y reabrir** es de administradores (`ACTAS_ADMINS`).
2. Cada tarjeta del Kanban lleva **pista**, **responsable** (leo, michelle, aldair, jesús, alan, daniel, claude), **fecha límite** y ligas. Si una tarea depende de otra, escríbelo en las notas y déjala en *Bloqueado*.
3. Los tableros de Ingeniería y Producción son **vistas** del mismo Kanban filtradas por pista, más sus bloques fijos (versiones, cantidades).
1. Los pendientes se mueven arrastrando entre columnas (en el teléfono, con «Mover a…»). Cualquier usuario autenticado crea y mueve; **borrar y reabrir** es de administradores (`ACTAS_ADMINS`).
2. Cada pendiente lleva **pista**, **responsable** (uno o varios del [Equipo](equipo.md)), **fecha límite** y ligas. Si depende de otro, márcalo en «Espera a que terminen» y, si no puedes avanzar, déjalo en *Bloqueado*.
3. Los tableros de Ingeniería y Producción son **vistas** de los mismos pendientes filtrados por pista, más sus bloques fijos (versiones, cantidades).
4. Las cantidades del plan de producción (versión → objetivo → fecha) las edita un administrador en el propio tablero; los conteos reales salen de las actas.
5. El Kanban sustituye al *Task Tracker* de Notion, que queda como histórico (sus tareas se importaron una vez).
5. Los pendientes sustituyen al *Task Tracker* de Notion, que queda como histórico (sus tareas se importaron una vez).
## [Equipo](equipo.html)
Quién es quién y qué tiene cada uno: miembros, área, si tienen acceso al sitio y sus pendientes abiertos y vencidos. El administrador crea aquí los accesos (ya no por `htpasswd` en el servidor).
Las direcciones viejas (`tableros/kanban.html`, `unidad.html?serie=…`, `sitios/sitio.html?id=…` y las demás) siguen sirviendo: llevan solas a la página nueva.
## Relacionados
- [Detalle por tarjeta y módulo](../tablero.html) — qué serie pasó cada acta del lote actual.
- [Inventario](../inventario/index.md) — stock, mínimos y movimientos.
- [Sitios](../sitios/index.md) — unidades instaladas por puerto.
- [Expediente de una unidad](../unidad.html) — todo lo registrado para una serie.
- [Historia del proyecto](../historia/index.md) — jun → oct 2026 con ligas.
- [Política de inventario](../inventario/index.md): qué se cuenta, mínimos y cómo se registra.
- [Sitios y puertos](../sitios/index.md): la lista de sitios y cómo se da de alta uno.
- [Bitácora](../bitacora.md): los documentos por fecha.
- [Historia del proyecto](../historia/index.md): jun → oct 2026 con ligas.

View File

@ -1,125 +1,18 @@
<!doctype html>
<!-- Desde el 20-oct-2026 este tablero vive dentro del sitio: docs/tableros/ingenieria.md → /tableros/ingenieria/
Este archivo solo redirige (conserva ?serie=, ?id=, ?pista=… y #…) para que las ligas viejas
guardadas en pendientes y actas sigan sirviendo. No se edita: los cambios van en tableros/ingenieria.md. -->
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tablero de ingeniería — EinarBoard</title>
<link rel="stylesheet" href="tablero.css">
<meta name="robots" content="noindex">
<title>Ingeniería - Drogheda</title>
<script>location.replace('/tableros/ingenieria/' + location.search + location.hash);</script>
<meta http-equiv="refresh" content="0; url=/tableros/ingenieria/">
<style>body{margin:0;padding:24px;background:#0E1524;color:#E7ECF5;font:16px/1.5 system-ui,sans-serif}a{color:#7FE9D8}</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Tablero de ingeniería — ¿dónde está el hardware?</h1><div class="meta" id="meta">V5.5 · V5.6 · V5.7 · v6 — estado al 19-oct-2026</div></div>
<nav>
<a href="../">← Documentación</a>
<a href="kanban.html">Pendientes</a>
<a class="activo" href="ingenieria.html">Ingeniería</a>
<a href="produccion.html">Producción</a>
<a href="../hardware/">Tarjetas y hardware</a>
<a href="equipo.html">👥 Equipo</a>
</nav>
</header>
<details class="como">
<summary>Cómo se usa</summary>
<ol>
<li>Arriba, una tarjeta por versión de EinarBoard: estado, siguiente paso, responsable y sus documentos. Es texto fijo: cuando cambie una decisión, pide a Leo o a Claude que lo actualice aquí.</li>
<li>En medio, los <b>pendientes de la pista Ingeniería</b>: son los mismos del <a href="kanban.html">tablero de pendientes</a>, filtrados. Puedes moverlos desde aquí.</li>
<li>Abajo, las últimas decisiones con fecha y la filosofía de diseño. Antes de proponer un cambio, revisa que no contradiga una decisión ya tomada.</li>
<li>Para una tarjeta nueva o un cambio de pedido, primero el pendiente, luego el trabajo: así queda el rastro de quién y cuándo.</li>
</ol>
</details>
<div class="versiones" id="versiones"></div>
<section class="bloque">
<h2>Pendientes de ingeniería</h2>
<p class="sub">Pista Ingeniería del tablero. Arrastra o usa «Mover a…»; el historial anota quién lo hizo. <a href="kanban.html">Ver todas las pistas →</a></p>
<div id="kb"></div>
</section>
<section class="bloque">
<h2>Últimas decisiones</h2>
<p class="sub">Lo decidido manda hasta que otra decisión con fecha posterior lo cambie. Fuente: notas del proyecto; detalle en la sección de hardware.</p>
<ul class="decisiones">
<li><b>19-oct (19:30)</b> — <b>V5.6: qué es crítico y qué se puede quedar</b> (archivo de trabajo de las 18:59, sobre T30). <b>Un solo crítico, nuevo:</b> U6 (LTC4040) no tiene capacitor en sus pines VSYS; el más cercano, C34, está a 11.5 mm (viene desde la V5.5). La hoja de datos lo pone por encima de todo en el layout. Se agrega <b>C129</b> (10 µF 0603, C19702) junto a los pines 1/24, sin mover piezas. Regresar 2 vías de <code>/MAIN5V</code> y 2 de <code>/3V3_MODEM</code> que se perdieron al guardar. L7 lejos de U7 y lo heredado de la V5.5 (L6, L8, Dbk1) se quedan para el piloto, y se miden en el banco. <a href="../hardware/v56-clasificacion-19oct/">Clasificación</a></li>
<li><b>19-oct (17:10)</b> — <b>Re-auditoría V5.6 / V5.7 / v6</b> sobre la pasada 3 de Michelle (HBox_PCB <code>4f4681f</code>). <b>V5.6 casi lista</b>: <code>/BAT_PLUS</code> fuera de capas internas, <code>/3V3_MODEM</code> a 1.0 mm, 4 vías en el EP de U7, sin cobre suelto; faltan T30 + ≈ 1 h (L7/Dbk2, salida VSYS de U6, 3 vías, exclusiones del DRC). <b>V5.7 NO LISTA</b>: ya replica la V5.6, pero <code>/SYS5V</code> va 10.9 mm por In2 a 0.5 mm bajo K1 (bloqueante) y el lazo de entrada de U25 mide 52 mm². <b>v6 sin avance</b> en sus 8 bloqueantes (solo 15 vías GND): plan recorrido una semana, piloto de 20 el lun 2-nov; pre-órdenes de JLC ya. <b>Script T30</b> probado en copias en la Mac (DRC sin infracciones nuevas, 0 sin conectar). Merge del lunes (<code>40decf8</code>) arreglado por Leo (<code>240e2c3</code> + <code>merge=binary</code>) y verificado objeto por objeto. Decisiones para Leo: D-19-1 puertos (si van, entran al piloto), D-19-2 Dbk2, D-19-3 sacar KiCad del auto-sync de las 18:00, D-19-4 clase HV24. <a href="../hardware/re-auditoria-19oct/">Re-auditoría</a> · <a href="../hardware/v56-v57-layout-pasada-4/">Pasada 4</a> · <a href="../hardware/v6-plan-fines-de-semana/">Plan v6 rev. 1.1</a></li>
<li><b>16-oct (17:30)</b> — <b>Auditoría de producción de la v6: NO LISTA.</b> Ruteo completo (<code>2612891</code>, DRC 0 errores), pero: U25 con el código del AP64350, sin barrenos, 5 V por In1/In2 a 0.5 mm, cargador con pistas de 0.5 mm y 1 vía en el EP, RF del módem por In1, 81 vías de tierra, stackup genérico. Scripts listos: T25 <code>--set v6</code> y <b>T28</b> (probados con DRC). <b>Plan de fines de semana</b>: layout el 17–18 y el 24–25 oct, pedido del piloto de 20 el 26-oct, firmware y banco hasta ≈ 15-nov. P7/P8 se quitan (decisión del 9-oct). <a href="../hardware/v6-auditoria-produccion-16oct/">Auditoría v6</a> · <a href="../hardware/v6-plan-fines-de-semana/">Plan</a></li>
<li><b>16-oct (16:00)</b> — <b>T27 aplicado</b> en V5.6 y V5.7 (<code>f65c830</code>). Figuras de los pasos 3, 4 y 5 en la pasada 3; corregido el dato de A4 (son dos vías de <code>/MAIN5V</code>, no una). <b>Tablero de pendientes al día</b>: pasada 2 y fuente de la V5.7 en Hecho; 9 pendientes nuevos (pasada 3 parte A, medición con TP1, decisiones de Leo, firmware 0.7.0, piloto y banco V5.6, prototipos V5.7, barrenos de la v6). <a href="../tableros/kanban.html">Kanban</a></li>
<li><b>16-oct (tarde)</b> — Decisión de Leo: <b>MH11 se quita</b> de V5.6 y V5.7. Pasos 1 y 2 de la pasada 3 por script (T27): MH11 fuera, <code>/CHRG_STAT</code> lejos de MH10 (V5.6) y <b>sensado Kelvin de RS1</b> (RS1 girado 180°; el LTC4040 deja de ver ≈ 27 mΩ y vuelve a limitar en 2.5 A). Probado en copias con DRC: 0 infracciones nuevas, 0 sin conectar. Prueba de banco nueva con TP1 (CLPROG). <a href="../hardware/v56-v57-layout-pasada-3/">Pasada 3</a> · <a href="../hardware/auditoria-produccion-16oct/#44-la-correccion-rs1-girado-180-y-sensado-kelvin-script-t27">Qué mide RS1</a></li>
<li><b>16-oct</b> — Auditoría de producción V5.6 / V5.7 / v6: <b>ninguna lista para pedir</b>. Corte de 10 s en batería: V5.6/V5.7 lo permiten en hardware, falta el firmware 0.7.0 y el banco. V5.7 sin SD-15B: entrada <b>9–32 V</b> (recomendado D10 → SMBJ33CA). v6: PCB sin actualizar y sin el firmware NTRIP que sustituye al 4G NTRIP Master. Puertos de V5.6/V5.7 con defectos ya resueltos en el esquemático v6 (portarlos: decide Leo). Acelerómetro en las tres. Barrenos de la V5.5 a V5.6/V5.7 con el script T25 (<b>MH4 en 185.2, no 193.2</b>). Hallazgo: límite de entrada del LTC4040 ≈ 1 A en V5.5/V5.6/V5.7 por el sensado de RS1. Recomendado: piloto de 5 V5.6 antes de las 113. <a href="../hardware/auditoria-produccion-16oct/">Auditoría</a> · <a href="../hardware/v56-v57-layout-pasada-3/">Pasada 3</a></li>
<li><b>13-oct</b> — Pedido de <b>570 HBOX</b>: 113 × V5.6 (con SD-15B), 327 × V5.7, 130 × v6; todos los reemplazos futuros en v6. Paquetes de fabricación V5.6 / V5.7 / v6 a <b>Aldair</b> (fabricación/ensamble, rol por confirmar). Caja plástica HBOX: el cable Ethernet no cabe — cambio por definir (pista Mecánica).</li>
<li><b>13-oct</b> — Auditoría del layout V5.6 de Michelle (d71098d): <b>NO LISTA</b> — /BAT_PLUS por In1 (bloqueante), MAIN5V/SYS5V/SW3 por capas internas; las 18 piezas movidas se quedan (opción A). Esquemático V5.7 aplicado con ERC 0 (F2 SMD 3 A, Q10/D10 reusados, fuera U2 y P2, JP1 puenteado). Reparto: <b>Leo</b> coloca la fuente en V5.7; <b>Michelle</b> corrige V5.6 y después toma V5.7. <a href="../hardware/v56-auditoria-layout-13oct/">Auditoría</a> · <a href="../hardware/v56-layout-pasada-2/">Pasada 2</a></li>
<li><b>12-oct</b> — <b>V5.7 = V5.6 + fuente conmutada 24 V → 5 V a bordo</b> (U25 TPS54360B, bloque de la v6), sin SD-15B externa. Proyecto KiCad separado <code>hbox-watchdog-v57</code>; V5.6 y V5.7 conviven. <a href="../hardware/v57-eco-fuente/">ECO V5.7</a></li>
<li><b>09-oct</b> — <b>P12 (sonda NTC) eliminada</b> de V5.6 con R42/C24: redundante y se necesita el espacio. Sin lectura de temperatura de celda en batería (aceptado). Aplica a P7/P8 de la v6.</li>
<li><b>08-oct</b> — Acelerómetro <b>U26 SC7A20TR</b> (I²C 0x18, INT1 → PB9) añadido a V5.6. <b>Regla DFM LTC4040 (U6)</b>: pasta del EP ≤ 50 %, vías tapadas (epoxy filled &amp; capped) o fuera de las ventanas de pasta, X-ray/AOI en U6, lupa en cada tarjeta antes de energizar. Vale para toda revisión, incluida la v6.</li>
<li><b>05-oct</b> — Revisión del bus I²C V5.6 / v6 (direcciones, pull-ups, TMP102 U18 en 0x49).</li>
<li><b>30-sep → 1-oct</b> — V5.6: revisión de modo batería y layout (U4 junto a L6, DzG1 corregido); estado hacia producción.</li>
<li><b>28-sep</b> — v6 auditada con las lecciones de V5.5 y plan de corrección. <b>«No habrá v6.1»</b>: lo que falte se corrige antes de pedir. <a href="../hardware/v6-plan-correccion/">Plan de corrección</a> · <a href="../hardware/v6-proximos-pasos/">Próximos pasos</a></li>
<li><b>27-sep</b> — Requisito V5.6: ciclo de 10 min en batería y mejor manejo térmico. ECO V5.6: LFP de alta temperatura, JP2 2-3, R55 5.6 k → flotación 3.50 V, RS1 10 mΩ, TMP102 U18 0x49. Regla de campo V5.5/V4 con 0.6.0: respaldo continuo en batería, nunca ciclos de etapa en batería. <a href="../hardware/v56-eco/">ECO V5.6</a></li>
<li><b>26-sep</b> — Decisión V5.6: los 500 ArduSimple ya pedidos se quedan (arquitectura con zócalos); nada de rework en tarjetas existentes; buck del módem AP64350 desde SYS5V, MODEM_EN/RST/LED al MCU, lista EMC. Hallazgo: el módem no arrancaba en batería porque el MP1584 (4.5 V mín.) caía en UVLO sobre el riel de 4.6–4.79 V del boost.</li>
<li><b>19/21-sep</b> — Un solo firmware por tarjeta; la versión del firmware no indica la tarjeta: V3 → <code>blackpillrtkcode_v4</code>, V4 → <code>hbox-tarjetaV4_x.y.z.bin</code>, V5 → <code>hbox-v5-bringup_x.y.z.bin</code>. V5.6 necesita 0.7.0 (<code>board_v56.h</code>).</li>
<li><b>22-ago</b> — V5.5 ordenada, 15 pzas (JLC). Auditoría EMC: /SW_BAT 0.3 mm y /BAT_PLUS 0.3–0.5 mm = críticos para la siguiente corrida (origen de la regla «nada de potencia por capas internas»).</li>
</ul>
</section>
<section class="bloque">
<h2>Filosofía de diseño</h2>
<p class="sub">Cinco reglas que explican casi todas las decisiones anteriores. Detalle y documentos en <a href="../hardware/">Tarjetas y hardware</a>.</p>
<ul class="filosofia">
<li><b>Primero la seguridad en campo.</b> La unidad vive en un puerto a más de 45 °C y en batería: en V5.5/V4, respaldo continuo, nunca ciclos de etapa en batería; desde V5.6 el corte de 10 s en batería es requisito, y se prueba en banco antes de usarlo en campo; se mide antes de energizar; una tarjeta con marca de daño no entra a un ensamble.</li>
<li><b>Una etapa probada vale más que una nueva.</b> Los zócalos para ArduSimple y módem se quedan (500 ya pedidos); nada de rework en tarjetas existentes; la fuente a bordo de V5.7 reutiliza el bloque ya auditado de la v6.</li>
<li><b>Redundancia solo cuando un modo de falla la justifica.</b> Por eso salió P12 (NTC redundante, estorbaba) y entró el acelerómetro U26 (detecta golpes y movimiento, que sí nos han fallado).</li>
<li><b>Las reglas de fabricación son órdenes permanentes.</b> LTC4040: pasta ≤ 50 %, vías tapadas, X-ray; ninguna red de potencia por capas internas (lección de V5.5); lupa en U6 antes de energizar. Se aplican a toda revisión, sin excepciones por prisa.</li>
<li><b>Cambios con prueba y rastro.</b> Esquemático por etiquetas y ediciones con script verificadas contra la netlist (ERC 0, paridad 100 %); git en dos pasos (submódulo KiCad y luego Drogheda); una persona por PCB a la vez y scripts solo con KiCad cerrado (git no mezcla archivos de KiCad: <code>merge=binary</code>); una tarjeta por proyecto KiCad (V5.7 separado); «no habrá v6.1»: se corrige antes de pedir, no después.</li>
</ul>
</section>
</div>
<script src="kanban.js"></script>
<script>
const VERSIONES = [
{ v: 'V5.5', cls: 'v55', badge: 'badge-v55', estado: 'En campo — 15 pzas (lote 22-ago), fw 0.6.x',
siguiente: 'Mantener la flota en 0.6.x con respaldo continuo en batería. Sin rework: lo aprendido va a V5.6. Inspeccionar U6 con lupa en cada tarjeta. Medir en una unidad el límite de entrada del cargador (≈ 1 A, auditoría 16-oct §4.3).',
quien: 'Jesús (pruebas) · Alan y Daniel (campo)',
docs: [['Manual V5', '../manuales/manual-tarjeta-V5/'], ['Auditoría 16-oct §4', '../hardware/auditoria-produccion-16oct/'], ['Lecciones V5.5 → v6', '../hardware/v6-revision-lecciones-v55/'], ['Acta de tarjeta V5', '../formularios/v5.html']] },
{ v: 'V5.6', cls: 'v56', badge: 'badge-v56', estado: 'Casi lista: T30 aplicado (7650b96); falta la pasada 4, parte A, con un crítico nuevo: C129 en VSYS de U6 (19-oct, tarde)',
siguiente: 'Pasada 4, parte A: primero C129 junto a VSYS de U6 (crítico); regresar 4 vías perdidas; quitar la rama de Dbk2; exclusiones del DRC → Claude re-audita y genera Gerber/BOM/CPL → pedido del piloto de 5 (≈ 21–22-oct, con los 5 de V5.7) → banco con firmware 0.7.0 (incluye medir VSYS, el riel del módem y el GNSS) → las 108 restantes (con SD-15B). Antes del pedido: decisión D-19-1 (puertos).',
quien: 'Michelle (layout) · Claude (auditoría) · Leo (T30, pedido, firmware) · Jesús (banco)',
docs: [['▶ Qué es crítico (19-oct, tarde)', '../hardware/v56-clasificacion-19oct/'], ['▶ Pasada 4 — V5.6 y V5.7', '../hardware/v56-v57-layout-pasada-4/'], ['Re-auditoría 19-oct', '../hardware/re-auditoria-19oct/'], ['Auditoría 16-oct (requisitos)', '../hardware/auditoria-produccion-16oct/'], ['ECO V5.6', '../hardware/v56-eco/'], ['Hoja de datos', '../hardware/v56-datasheet/'], ['Pinout', '../hardware/v56-pinout/']] },
{ v: 'V5.7', cls: 'v57', badge: 'badge-v57', estado: 'Re-auditada 19-oct — NO LISTA: pasada 3 hecha (f3f35f4), ya replica la V5.6; bloqueante /SYS5V por In2 bajo K1; lazo de entrada de U25 de 52 mm²',
siguiente: 'Lun 19: T30 (Leo). Mar 20 – mié 21: Michelle pasada 4, parte B (≈ medio día: /SYS5V fuera de In2, C149/C150 y L22 junto a U25, lo mismo que V5.6) → re-auditoría → 5 prototipos con el piloto de V5.6 (ECO §6: 9/12/24/32 V, polaridad, temperatura) → pedido 327. Entrada 9–32 V.',
quien: 'Michelle (layout) · Claude (auditoría) · Leo (T30, pedido)',
docs: [['▶ Pasada 4 — parte B', '../hardware/v56-v57-layout-pasada-4/#parte-b-v57-hbox-watchdog-v57-medio-dia'], ['Re-auditoría 19-oct §4', '../hardware/re-auditoria-19oct/#4-v57-punto-por-punto'], ['ECO V5.7 — fuente a bordo', '../hardware/v57-eco-fuente/']] },
{ v: 'v6', cls: 'v6', badge: 'badge-v6', estado: 'Re-auditada 19-oct: NO LISTA, sin cambio — solo 15 vías de tierra nuevas (00a9af4); siguen los 8 bloqueantes del 16-oct',
siguiente: 'Lun 19 – mar 20: pre-órdenes JLC (THVD1410DR, TPS7A2033PDBVR, J14). Sáb 24 8:00: Leo corre T25 --set v6 + T28. Sáb 24: Michelle bloques A y B (potencia) + MH4/MH6. Dom 25: bloques C, D y E (RF del módem, U20, GNSS, U12). Sáb 31 – dom 1-nov: pares, puertos, In1, T29 (vías GND), serigrafía, DRC final. Lun 2-nov: piloto de 20. Firmware v6 los fines de semana 7–8 y 14–15 nov; banco 21–22 nov (G2). No habrá v6.1.',
quien: 'Michelle (layout) · Leo · Claude · Adriana (pre-órdenes)',
docs: [['▶ Plan de fines de semana (rev. 1.1)', '../hardware/v6-plan-fines-de-semana/'], ['Auditoría v6 (16-oct)', '../hardware/v6-auditoria-produccion-16oct/'], ['Re-auditoría 19-oct §8', '../hardware/re-auditoria-19oct/#8-v6'], ['Plan de corrección (28-sep)', '../hardware/v6-plan-correccion/'], ['Manual v6', '../manuales/manual-tarjeta-v6/'], ['Plan de cobre', '../hardware/EinarBoard-v6_Plan-de-Cobre_ES.html']] },
];
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 cont = document.getElementById('versiones');
const contadores = {};
VERSIONES.forEach(x => {
const c = el('div', { class: 'ver ' + x.cls });
c.append(el('h3', null, el('span', { text: 'EinarBoard ' + x.v }), el('span', { class: x.badge, text: x.v })));
c.append(el('div', { class: 'est', text: x.estado }));
c.append(el('div', { class: 'sig', html: '<b>Siguiente:</b> ' + x.siguiente }));
c.append(el('div', { class: 'quien', text: '👤 ' + x.quien }));
const d = el('div', { class: 'docs' }); x.docs.forEach(([t, h]) => d.append(el('a', { href: h, text: t }))); c.append(d);
const cnt = el('div', { class: 'cnt', text: '…' }); contadores[x.v] = cnt; c.append(cnt);
cont.append(c);
});
Kanban.montar(document.getElementById('kb'), {
pista: 'ingenieria', filtros: true,
onCargado: tareas => {
VERSIONES.forEach(x => {
const mios = tareas.filter(t => t.version_tarjeta === x.v && t.estado !== 'hecho');
const bloq = mios.filter(t => t.estado === 'bloqueado').length, venc = mios.filter(t => t.vencida).length;
const a = el('a', { href: 'kanban.html?pista=ingenieria', text: mios.length + ' pendiente(s) abierto(s)' + (bloq ? ' · ' + bloq + ' bloqueado(s)' : '') + (venc ? ' · ' + venc + ' vencido(s)' : '') });
contadores[x.v].innerHTML = ''; contadores[x.v].append(a);
});
}
});
</script>
<p>Este tablero ahora está dentro del sitio: <a href="/tableros/ingenieria/">Ingeniería</a>.</p>
</body>
</html>

115
docs/tableros/ingenieria.md Normal file
View File

@ -0,0 +1,115 @@
---
title: "Ingeniería"
description: "Dónde está el hardware: una tarjeta por versión de EinarBoard, sus pendientes y las decisiones."
template: tablero.html
hide:
- toc
---
<div class="tb tb-ingenieria">
<header class="tb-cab">
<div class="tb-cab__t"><h1>Ingeniería</h1><span class="meta" id="meta">V5.5 · V5.6 · V5.7 · v6 — estado al 19-oct-2026</span></div>
</header>
<details class="info tb-como">
<summary>Cómo se usa</summary>
<ol>
<li>Arriba, una tarjeta por versión de EinarBoard: estado, siguiente paso, responsable y sus documentos. Es texto fijo: cuando cambie una decisión, pide a Leo o a Claude que lo actualice aquí.</li>
<li>En medio, los <b>pendientes de la pista Ingeniería</b>: son los mismos del <a href="/tableros/pendientes/">tablero de pendientes</a>, filtrados. Puedes moverlos desde aquí.</li>
<li>Abajo, las últimas decisiones con fecha y la filosofía de diseño. Antes de proponer un cambio, revisa que no contradiga una decisión ya tomada.</li>
<li>Para una tarjeta nueva o un cambio de pedido, primero el pendiente, luego el trabajo: así queda el rastro de quién y cuándo.</li>
</ol>
</details>
<div class="versiones" id="versiones"></div>
<section class="bloque">
<h2>Pendientes de ingeniería</h2>
<p class="sub">Pista Ingeniería del tablero. Arrastra o usa «Mover a…»; el historial anota quién lo hizo. <a href="/tableros/pendientes/">Ver todas las pistas →</a></p>
<div id="kb"></div>
</section>
<section class="bloque">
<h2>Últimas decisiones</h2>
<p class="sub">Lo decidido manda hasta que otra decisión con fecha posterior lo cambie. Fuente: notas del proyecto; detalle en la sección de hardware.</p>
<ul class="decisiones">
<li><b>19-oct (19:30)</b> — <b>V5.6: qué es crítico y qué se puede quedar</b> (archivo de trabajo de las 18:59, sobre T30). <b>Un solo crítico, nuevo:</b> U6 (LTC4040) no tiene capacitor en sus pines VSYS; el más cercano, C34, está a 11.5 mm (viene desde la V5.5). La hoja de datos lo pone por encima de todo en el layout. Se agrega <b>C129</b> (10 µF 0603, C19702) junto a los pines 1/24, sin mover piezas. Regresar 2 vías de <code>/MAIN5V</code> y 2 de <code>/3V3_MODEM</code> que se perdieron al guardar. L7 lejos de U7 y lo heredado de la V5.5 (L6, L8, Dbk1) se quedan para el piloto, y se miden en el banco. <a href="/hardware/v56-clasificacion-19oct/">Clasificación</a></li>
<li><b>19-oct (17:10)</b> — <b>Re-auditoría V5.6 / V5.7 / v6</b> sobre la pasada 3 de Michelle (HBox_PCB <code>4f4681f</code>). <b>V5.6 casi lista</b>: <code>/BAT_PLUS</code> fuera de capas internas, <code>/3V3_MODEM</code> a 1.0 mm, 4 vías en el EP de U7, sin cobre suelto; faltan T30 + ≈ 1 h (L7/Dbk2, salida VSYS de U6, 3 vías, exclusiones del DRC). <b>V5.7 NO LISTA</b>: ya replica la V5.6, pero <code>/SYS5V</code> va 10.9 mm por In2 a 0.5 mm bajo K1 (bloqueante) y el lazo de entrada de U25 mide 52 mm². <b>v6 sin avance</b> en sus 8 bloqueantes (solo 15 vías GND): plan recorrido una semana, piloto de 20 el lun 2-nov; pre-órdenes de JLC ya. <b>Script T30</b> probado en copias en la Mac (DRC sin infracciones nuevas, 0 sin conectar). Merge del lunes (<code>40decf8</code>) arreglado por Leo (<code>240e2c3</code> + <code>merge=binary</code>) y verificado objeto por objeto. Decisiones para Leo: D-19-1 puertos (si van, entran al piloto), D-19-2 Dbk2, D-19-3 sacar KiCad del auto-sync de las 18:00, D-19-4 clase HV24. <a href="/hardware/re-auditoria-19oct/">Re-auditoría</a> · <a href="/hardware/v56-v57-layout-pasada-4/">Pasada 4</a> · <a href="/hardware/v6-plan-fines-de-semana/">Plan v6 rev. 1.1</a></li>
<li><b>16-oct (17:30)</b> — <b>Auditoría de producción de la v6: NO LISTA.</b> Ruteo completo (<code>2612891</code>, DRC 0 errores), pero: U25 con el código del AP64350, sin barrenos, 5 V por In1/In2 a 0.5 mm, cargador con pistas de 0.5 mm y 1 vía en el EP, RF del módem por In1, 81 vías de tierra, stackup genérico. Scripts listos: T25 <code>--set v6</code> y <b>T28</b> (probados con DRC). <b>Plan de fines de semana</b>: layout el 17–18 y el 24–25 oct, pedido del piloto de 20 el 26-oct, firmware y banco hasta ≈ 15-nov. P7/P8 se quitan (decisión del 9-oct). <a href="/hardware/v6-auditoria-produccion-16oct/">Auditoría v6</a> · <a href="/hardware/v6-plan-fines-de-semana/">Plan</a></li>
<li><b>16-oct (16:00)</b> — <b>T27 aplicado</b> en V5.6 y V5.7 (<code>f65c830</code>). Figuras de los pasos 3, 4 y 5 en la pasada 3; corregido el dato de A4 (son dos vías de <code>/MAIN5V</code>, no una). <b>Tablero de pendientes al día</b>: pasada 2 y fuente de la V5.7 en Hecho; 9 pendientes nuevos (pasada 3 parte A, medición con TP1, decisiones de Leo, firmware 0.7.0, piloto y banco V5.6, prototipos V5.7, barrenos de la v6). <a href="/tableros/pendientes/">Kanban</a></li>
<li><b>16-oct (tarde)</b> — Decisión de Leo: <b>MH11 se quita</b> de V5.6 y V5.7. Pasos 1 y 2 de la pasada 3 por script (T27): MH11 fuera, <code>/CHRG_STAT</code> lejos de MH10 (V5.6) y <b>sensado Kelvin de RS1</b> (RS1 girado 180°; el LTC4040 deja de ver ≈ 27 mΩ y vuelve a limitar en 2.5 A). Probado en copias con DRC: 0 infracciones nuevas, 0 sin conectar. Prueba de banco nueva con TP1 (CLPROG). <a href="/hardware/v56-v57-layout-pasada-3/">Pasada 3</a> · <a href="/hardware/auditoria-produccion-16oct/#44-la-correccion-rs1-girado-180-y-sensado-kelvin-script-t27">Qué mide RS1</a></li>
<li><b>16-oct</b> — Auditoría de producción V5.6 / V5.7 / v6: <b>ninguna lista para pedir</b>. Corte de 10 s en batería: V5.6/V5.7 lo permiten en hardware, falta el firmware 0.7.0 y el banco. V5.7 sin SD-15B: entrada <b>9–32 V</b> (recomendado D10 → SMBJ33CA). v6: PCB sin actualizar y sin el firmware NTRIP que sustituye al 4G NTRIP Master. Puertos de V5.6/V5.7 con defectos ya resueltos en el esquemático v6 (portarlos: decide Leo). Acelerómetro en las tres. Barrenos de la V5.5 a V5.6/V5.7 con el script T25 (<b>MH4 en 185.2, no 193.2</b>). Hallazgo: límite de entrada del LTC4040 ≈ 1 A en V5.5/V5.6/V5.7 por el sensado de RS1. Recomendado: piloto de 5 V5.6 antes de las 113. <a href="/hardware/auditoria-produccion-16oct/">Auditoría</a> · <a href="/hardware/v56-v57-layout-pasada-3/">Pasada 3</a></li>
<li><b>13-oct</b> — Pedido de <b>570 HBOX</b>: 113 × V5.6 (con SD-15B), 327 × V5.7, 130 × v6; todos los reemplazos futuros en v6. Paquetes de fabricación V5.6 / V5.7 / v6 a <b>Aldair</b> (fabricación/ensamble, rol por confirmar). Caja plástica HBOX: el cable Ethernet no cabe — cambio por definir (pista Mecánica).</li>
<li><b>13-oct</b> — Auditoría del layout V5.6 de Michelle (d71098d): <b>NO LISTA</b> — /BAT_PLUS por In1 (bloqueante), MAIN5V/SYS5V/SW3 por capas internas; las 18 piezas movidas se quedan (opción A). Esquemático V5.7 aplicado con ERC 0 (F2 SMD 3 A, Q10/D10 reusados, fuera U2 y P2, JP1 puenteado). Reparto: <b>Leo</b> coloca la fuente en V5.7; <b>Michelle</b> corrige V5.6 y después toma V5.7. <a href="/hardware/v56-auditoria-layout-13oct/">Auditoría</a> · <a href="/hardware/v56-layout-pasada-2/">Pasada 2</a></li>
<li><b>12-oct</b> — <b>V5.7 = V5.6 + fuente conmutada 24 V → 5 V a bordo</b> (U25 TPS54360B, bloque de la v6), sin SD-15B externa. Proyecto KiCad separado <code>hbox-watchdog-v57</code>; V5.6 y V5.7 conviven. <a href="/hardware/v57-eco-fuente/">ECO V5.7</a></li>
<li><b>09-oct</b> — <b>P12 (sonda NTC) eliminada</b> de V5.6 con R42/C24: redundante y se necesita el espacio. Sin lectura de temperatura de celda en batería (aceptado). Aplica a P7/P8 de la v6.</li>
<li><b>08-oct</b> — Acelerómetro <b>U26 SC7A20TR</b> (I²C 0x18, INT1 → PB9) añadido a V5.6. <b>Regla DFM LTC4040 (U6)</b>: pasta del EP ≤ 50 %, vías tapadas (epoxy filled &amp; capped) o fuera de las ventanas de pasta, X-ray/AOI en U6, lupa en cada tarjeta antes de energizar. Vale para toda revisión, incluida la v6.</li>
<li><b>05-oct</b> — Revisión del bus I²C V5.6 / v6 (direcciones, pull-ups, TMP102 U18 en 0x49).</li>
<li><b>30-sep → 1-oct</b> — V5.6: revisión de modo batería y layout (U4 junto a L6, DzG1 corregido); estado hacia producción.</li>
<li><b>28-sep</b> — v6 auditada con las lecciones de V5.5 y plan de corrección. <b>«No habrá v6.1»</b>: lo que falte se corrige antes de pedir. <a href="/hardware/v6-plan-correccion/">Plan de corrección</a> · <a href="/hardware/v6-proximos-pasos/">Próximos pasos</a></li>
<li><b>27-sep</b> — Requisito V5.6: ciclo de 10 min en batería y mejor manejo térmico. ECO V5.6: LFP de alta temperatura, JP2 2-3, R55 5.6 k → flotación 3.50 V, RS1 10 mΩ, TMP102 U18 0x49. Regla de campo V5.5/V4 con 0.6.0: respaldo continuo en batería, nunca ciclos de etapa en batería. <a href="/hardware/v56-eco/">ECO V5.6</a></li>
<li><b>26-sep</b> — Decisión V5.6: los 500 ArduSimple ya pedidos se quedan (arquitectura con zócalos); nada de rework en tarjetas existentes; buck del módem AP64350 desde SYS5V, MODEM_EN/RST/LED al MCU, lista EMC. Hallazgo: el módem no arrancaba en batería porque el MP1584 (4.5 V mín.) caía en UVLO sobre el riel de 4.6–4.79 V del boost.</li>
<li><b>19/21-sep</b> — Un solo firmware por tarjeta; la versión del firmware no indica la tarjeta: V3 → <code>blackpillrtkcode_v4</code>, V4 → <code>hbox-tarjetaV4_x.y.z.bin</code>, V5 → <code>hbox-v5-bringup_x.y.z.bin</code>. V5.6 necesita 0.7.0 (<code>board_v56.h</code>).</li>
<li><b>22-ago</b> — V5.5 ordenada, 15 pzas (JLC). Auditoría EMC: /SW_BAT 0.3 mm y /BAT_PLUS 0.3–0.5 mm = críticos para la siguiente corrida (origen de la regla «nada de potencia por capas internas»).</li>
</ul>
</section>
<section class="bloque">
<h2>Filosofía de diseño</h2>
<p class="sub">Cinco reglas que explican casi todas las decisiones anteriores. Detalle y documentos en <a href="/hardware/">Tarjetas y hardware</a>.</p>
<ul class="filosofia">
<li><b>Primero la seguridad en campo.</b> La unidad vive en un puerto a más de 45 °C y en batería: en V5.5/V4, respaldo continuo, nunca ciclos de etapa en batería; desde V5.6 el corte de 10 s en batería es requisito, y se prueba en banco antes de usarlo en campo; se mide antes de energizar; una tarjeta con marca de daño no entra a un ensamble.</li>
<li><b>Una etapa probada vale más que una nueva.</b> Los zócalos para ArduSimple y módem se quedan (500 ya pedidos); nada de rework en tarjetas existentes; la fuente a bordo de V5.7 reutiliza el bloque ya auditado de la v6.</li>
<li><b>Redundancia solo cuando un modo de falla la justifica.</b> Por eso salió P12 (NTC redundante, estorbaba) y entró el acelerómetro U26 (detecta golpes y movimiento, que sí nos han fallado).</li>
<li><b>Las reglas de fabricación son órdenes permanentes.</b> LTC4040: pasta ≤ 50 %, vías tapadas, X-ray; ninguna red de potencia por capas internas (lección de V5.5); lupa en U6 antes de energizar. Se aplican a toda revisión, sin excepciones por prisa.</li>
<li><b>Cambios con prueba y rastro.</b> Esquemático por etiquetas y ediciones con script verificadas contra la netlist (ERC 0, paridad 100 %); git en dos pasos (submódulo KiCad y luego Drogheda); una persona por PCB a la vez y scripts solo con KiCad cerrado (git no mezcla archivos de KiCad: <code>merge=binary</code>); una tarjeta por proyecto KiCad (V5.7 separado); «no habrá v6.1»: se corrige antes de pedir, no después.</li>
</ul>
</section>
</div>
<script>
(function () {
const VERSIONES = [
{ v: 'V5.5', cls: 'v55', badge: 'badge-v55', estado: 'En campo — 15 pzas (lote 22-ago), fw 0.6.x',
siguiente: 'Mantener la flota en 0.6.x con respaldo continuo en batería. Sin rework: lo aprendido va a V5.6. Inspeccionar U6 con lupa en cada tarjeta. Medir en una unidad el límite de entrada del cargador (≈ 1 A, auditoría 16-oct §4.3).',
quien: 'Jesús (pruebas) · Alan y Daniel (campo)',
docs: [['Manual V5', '/manuales/manual-tarjeta-V5/'], ['Auditoría 16-oct §4', '/hardware/auditoria-produccion-16oct/'], ['Lecciones V5.5 → v6', '/hardware/v6-revision-lecciones-v55/'], ['Acta de tarjeta V5', '/formularios/v5.html']] },
{ v: 'V5.6', cls: 'v56', badge: 'badge-v56', estado: 'Casi lista: T30 aplicado (7650b96); falta la pasada 4, parte A, con un crítico nuevo: C129 en VSYS de U6 (19-oct, tarde)',
siguiente: 'Pasada 4, parte A: primero C129 junto a VSYS de U6 (crítico); regresar 4 vías perdidas; quitar la rama de Dbk2; exclusiones del DRC → Claude re-audita y genera Gerber/BOM/CPL → pedido del piloto de 5 (≈ 21–22-oct, con los 5 de V5.7) → banco con firmware 0.7.0 (incluye medir VSYS, el riel del módem y el GNSS) → las 108 restantes (con SD-15B). Antes del pedido: decisión D-19-1 (puertos).',
quien: 'Michelle (layout) · Claude (auditoría) · Leo (T30, pedido, firmware) · Jesús (banco)',
docs: [['Qué es crítico (19-oct, tarde)', '/hardware/v56-clasificacion-19oct/'], ['Pasada 4 — V5.6 y V5.7', '/hardware/v56-v57-layout-pasada-4/'], ['Re-auditoría 19-oct', '/hardware/re-auditoria-19oct/'], ['Auditoría 16-oct (requisitos)', '/hardware/auditoria-produccion-16oct/'], ['ECO V5.6', '/hardware/v56-eco/'], ['Hoja de datos', '/hardware/v56-datasheet/'], ['Pinout', '/hardware/v56-pinout/']] },
{ v: 'V5.7', cls: 'v57', badge: 'badge-v57', estado: 'Re-auditada 19-oct — NO LISTA: pasada 3 hecha (f3f35f4), ya replica la V5.6; bloqueante /SYS5V por In2 bajo K1; lazo de entrada de U25 de 52 mm²',
siguiente: 'Lun 19: T30 (Leo). Mar 20 – mié 21: Michelle pasada 4, parte B (≈ medio día: /SYS5V fuera de In2, C149/C150 y L22 junto a U25, lo mismo que V5.6) → re-auditoría → 5 prototipos con el piloto de V5.6 (ECO §6: 9/12/24/32 V, polaridad, temperatura) → pedido 327. Entrada 9–32 V.',
quien: 'Michelle (layout) · Claude (auditoría) · Leo (T30, pedido)',
docs: [['Pasada 4 — parte B', '/hardware/v56-v57-layout-pasada-4/#parte-b-v57-hbox-watchdog-v57-medio-dia'], ['Re-auditoría 19-oct §4', '/hardware/re-auditoria-19oct/#4-v57-punto-por-punto'], ['ECO V5.7 — fuente a bordo', '/hardware/v57-eco-fuente/']] },
{ v: 'v6', cls: 'v6', badge: 'badge-v6', estado: 'Re-auditada 19-oct: NO LISTA, sin cambio — solo 15 vías de tierra nuevas (00a9af4); siguen los 8 bloqueantes del 16-oct',
siguiente: 'Lun 19 – mar 20: pre-órdenes JLC (THVD1410DR, TPS7A2033PDBVR, J14). Sáb 24 8:00: Leo corre T25 --set v6 + T28. Sáb 24: Michelle bloques A y B (potencia) + MH4/MH6. Dom 25: bloques C, D y E (RF del módem, U20, GNSS, U12). Sáb 31 – dom 1-nov: pares, puertos, In1, T29 (vías GND), serigrafía, DRC final. Lun 2-nov: piloto de 20. Firmware v6 los fines de semana 7–8 y 14–15 nov; banco 21–22 nov (G2). No habrá v6.1.',
quien: 'Michelle (layout) · Leo · Claude · Adriana (pre-órdenes)',
docs: [['Plan de fines de semana (rev. 1.1)', '/hardware/v6-plan-fines-de-semana/'], ['Auditoría v6 (16-oct)', '/hardware/v6-auditoria-produccion-16oct/'], ['Re-auditoría 19-oct §8', '/hardware/re-auditoria-19oct/#8-v6'], ['Plan de corrección (28-sep)', '/hardware/v6-plan-correccion/'], ['Manual v6', '/manuales/manual-tarjeta-v6/'], ['Plan de cobre', '/hardware/EinarBoard-v6_Plan-de-Cobre_ES.html']] },
];
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 cont = document.getElementById('versiones');
const contadores = {};
VERSIONES.forEach(x => {
const c = el('div', { class: 'ver ' + x.cls });
c.append(el('h3', null, el('span', { text: 'EinarBoard ' + x.v }), el('span', { class: x.badge, text: x.v })));
c.append(el('div', { class: 'est', text: x.estado }));
c.append(el('div', { class: 'sig', html: '<b>Siguiente:</b> ' + x.siguiente }));
const qd = el('div', { class: 'quien', html: Kanban.icono('equipo') }); qd.append(el('span', { text: x.quien })); c.append(qd);
const d = el('div', { class: 'docs' }); x.docs.forEach(([t, h]) => d.append(el('a', { href: h, text: t }))); c.append(d);
const cnt = el('div', { class: 'cnt', text: '…' }); contadores[x.v] = cnt; c.append(cnt);
cont.append(c);
});
Kanban.montar(document.getElementById('kb'), {
pista: 'ingenieria', filtros: true,
onCargado: tareas => {
VERSIONES.forEach(x => {
const mios = tareas.filter(t => t.version_tarjeta === x.v && t.estado !== 'hecho');
const bloq = mios.filter(t => t.estado === 'bloqueado').length, venc = mios.filter(t => t.vencida).length;
const a = el('a', { href: '/tableros/pendientes/?pista=ingenieria', text: mios.length + ' pendiente(s) abierto(s)' + (bloq ? ' · ' + bloq + ' bloqueado(s)' : '') + (venc ? ' · ' + venc + ' vencido(s)' : '') });
contadores[x.v].innerHTML = ''; contadores[x.v].append(a);
});
}
});
})();
</script>

219
docs/tableros/inventario.md Normal file
View File

@ -0,0 +1,219 @@
---
title: "Inventario"
description: "Existencias y movimientos de materiales: entradas, salidas, ajustes y alertas de mínimo."
template: tablero.html
hide:
- toc
---
<div class="tb tb-inventario">
<header class="tb-cab">
<div class="tb-cab__t"><h1>Inventario</h1><span class="meta" id="meta">Cargando…</span></div>
<div class="tb-cab__acc"><a class="boton sec" href="/inventario/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4.5h11a3 3 0 0 1 3 3v12.5H8a3 3 0 0 1-3-3zM5 17a3 3 0 0 1 3-3h11"/></svg>Política de inventario</a></div>
</header>
<details class="info tb-como">
<summary>Cómo se usa</summary>
<ol>
<li>El <b>stock no se escribe a mano</b>: es la suma de los movimientos. Llegó material → <b>Entrada</b>. Se usó en un ensamble o se envió → <b>Salida</b> con la unidad o el acta como referencia.</li>
<li>Contaste y no cuadra → <b>Ajuste</b> con el conteo físico; el sistema calcula la diferencia y la deja registrada con tu nombre.</li>
<li>Toca el <b>código</b> para ver los movimientos de ese ítem. Cada uno dice quién, cuándo, cuánto y por qué.</li>
<li><b>Alerta</b> (rojo) = stock por debajo del mínimo. El mínimo lo pone quien compra; el tablero de producción lo repite.</li>
<li><b>Nuevo ítem</b> solo para cosas que se cuentan: tarjetas, módulos, gabinetes, cajas, fuentes, packs, antenas, cables y los componentes de entrega larga. Nada de resistencias.</li>
<li><b>Exportar CSV</b> baja la tabla para Excel. Nada se borra: si un ítem ya no aplica, ponle «(obsoleto)» en la descripción.</li>
</ol>
</details>
<div class="acciones">
<select id="fCat"><option value="">Todas las categorías</option></select>
<select id="fVer"><option value="">Aplica a: todas</option></select>
<label><input type="checkbox" id="fAlerta" style="width:auto"> solo alertas</label>
<input type="search" id="fBuscar" placeholder="Buscar código o descripción…" style="flex:1;min-width:150px">
<button id="bMov"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Movimiento</button>
<button id="bNuevo" class="sec"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Nuevo ítem</button>
<a href="/api/inventario/export.csv" class="boton sec"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 20h14"/></svg>Exportar CSV</a>
<span class="cnt" id="cnt"></span>
</div>
<div class="msg" id="aviso"></div>
<div id="cont" class="tblwrap"></div>
<dialog id="dlg"></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 $ = id => document.getElementById(id);
const CAT = { tarjeta: 'Tarjeta', modulo: 'Módulo', gabinete: 'Gabinete', caja: 'Caja', fuente: 'Fuente', bateria: 'Batería', antena: 'Antena', cable: 'Cable', conector: 'Conector', 'componente-critico': 'Componente crítico', otro: 'Otro' };
const VERS = ['V5.5', 'V5.6', 'V5.7', 'v6'];
const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' };
let ITEMS = [], ADMIN = false, USER = '';
const dlg = $('dlg'), aviso = $('aviso');
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 4000 : 9000); }
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; }
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(CAT).forEach(([k, v]) => $('fCat').append(el('option', { value: k, text: v })));
VERS.forEach(v => $('fVer').append(el('option', { value: v, text: 'Aplica a: ' + v })));
function visibles() {
const cat = $('fCat').value, ver = $('fVer').value, solo = $('fAlerta').checked, q = $('fBuscar').value.trim().toLowerCase();
return ITEMS.filter(i => (!cat || i.categoria === cat) && (!ver || (i.aplica_a || []).includes(ver)) && (!solo || i.alerta)
&& (!q || (i.codigo + ' ' + i.descripcion + ' ' + (i.proveedor || '') + ' ' + (i.ubicacion || '')).toLowerCase().includes(q)));
}
function pintar() {
const cont = $('cont'); cont.innerHTML = '';
const vis = visibles();
$('cnt').textContent = vis.length + ' ítem(s)' + (vis.length !== ITEMS.length ? ' de ' + ITEMS.length : '') + ' · ' + vis.filter(i => i.alerta).length + ' en alerta';
if (!vis.length) { cont.append(el('div', { class: 'vacio', text: ITEMS.length ? 'Nada coincide con el filtro.' : 'Todavía no hay ítems. Crea el primero con «Nuevo ítem» o corre la siembra.' })); return; }
const t = el('table', { class: 't' });
t.innerHTML = '<tr><th>Código</th><th>Descripción</th><th>Aplica a</th><th class="num">Stock</th><th class="num">Mínimo</th><th>Ubicación</th><th>Proveedor</th><th></th></tr>';
vis.forEach(i => {
const tr = el('tr', { class: i.alerta ? 'alerta' : '' });
const cod = el('span', { class: 'cod', text: i.codigo, title: 'Ver movimientos' }); cod.addEventListener('click', () => verMovimientos(i));
tr.append(el('td', null, cod, el('span', { class: 'cat', text: CAT[i.categoria] || i.categoria })));
tr.append(el('td', null, document.createTextNode(i.descripcion), i.notas ? el('div', { class: 'mini', text: i.notas.length > 90 ? i.notas.slice(0, 89) + '…' : i.notas }) : null));
const ap = el('td'); (i.aplica_a || []).forEach(v => ap.append(el('span', { class: BADGE[v] || 'pill pend', text: v, style: 'margin-right:3px' }))); tr.append(ap);
tr.append(el('td', { class: 'num stock', html: esc(i.stock) + ' <span class="mini">' + esc(i.unidad) + '</span>' + (i.alerta ? ' <span class="pill falla">alerta</span>' : '') }));
tr.append(el('td', { class: 'num', text: i.minimo || '—' }));
tr.append(el('td', { text: i.ubicacion || '—' }));
tr.append(el('td', { html: esc(i.proveedor || '—') + (i.codigo_proveedor ? '<div class="mini">' + esc(i.codigo_proveedor) + '</div>' : '') }));
const acc = el('td', { style: 'white-space:nowrap' });
const bm = el('button', { text: 'Movimiento' }); bm.addEventListener('click', () => movimiento(i));
const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(i));
acc.append(bm, be); tr.append(acc);
t.append(tr);
});
cont.append(t);
}
async function cargar() {
$('meta').textContent = 'Actualizando…';
try {
const j = await api('/api/inventario');
ITEMS = j.items || []; ADMIN = !!j.admin; USER = j.user || '';
pintar();
$('meta').textContent = ITEMS.length + ' ítems · ' + (j.alertas || 0) + ' en alerta · actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }) + (USER ? ' · ' + USER : '');
} catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo cargar el inventario: ' + e.message })); $('meta').textContent = 'Error'; }
}
['fCat', 'fVer', 'fAlerta'].forEach(id => $(id).addEventListener('change', pintar));
$('fBuscar').addEventListener('input', pintar);
// ---------- diálogos ----------
function abrirDialogo(titulo, cuerpoFn, botones) {
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo }));
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 m = el('div', { class: 'msg' });
cuerpoFn(cuerpo, m); cuerpo.append(m);
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
const cancelar = el('button', { type: 'button', class: 'sec', text: botones ? 'Cancelar' : 'Cerrar' }); cancelar.addEventListener('click', () => dlg.close()); pie.append(cancelar);
(botones || []).forEach(([txt, fn]) => { const b = el('button', { type: 'button', text: txt }); b.addEventListener('click', async () => { b.disabled = true; try { await fn(m); } catch (e) { m.className = 'msg err'; m.textContent = e.message; } b.disabled = false; }); pie.append(b); });
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
}
function selItems(sel) {
const s = el('select'); s.append(el('option', { value: '', text: '— elegir ítem —' }));
ITEMS.forEach(i => s.append(el('option', { value: i.id, text: i.codigo + ' — ' + i.descripcion + ' (' + i.stock + ' ' + i.unidad + ')' })));
if (sel) s.value = sel.id; return s;
}
function movimiento(item) {
abrirDialogo('Registrar movimiento', (c) => {
const fItem = selItems(item);
const fTipo = el('select'); [['entrada', 'Entrada — llegó material (+)'], ['salida', 'Salida — se usó o se envió (−)'], ['ajuste', 'Ajuste — conteo físico (fija el stock)']].forEach(([v, t]) => fTipo.append(el('option', { value: v, text: t })));
const fCant = el('input', { type: 'number', min: '0', step: '1', inputmode: 'numeric', placeholder: '0' });
const fMot = el('input', { type: 'text', maxlength: '200', placeholder: 'llegó pedido Mouser · ensamble · envío a ICAVE · conteo físico' });
const fRef = el('input', { type: 'text', maxlength: '120', placeholder: 'HBOX5-E07-A010-R0006 · OC-0042 · acta de envío' });
const ayudaCant = el('div', { class: 'ayuda', text: 'Cantidad que entra.' });
fTipo.addEventListener('change', () => { ayudaCant.textContent = fTipo.value === 'entrada' ? 'Cantidad que entra.' : fTipo.value === 'salida' ? 'Cantidad que sale. No puede dejar el stock en negativo.' : 'Lo que contaste físicamente: el stock quedará exactamente en ese número.'; });
c.append(campo('Ítem', fItem), campo('Tipo', fTipo));
const g = el('div', { class: 'dos-col' }); const cc = campo('Cantidad', fCant); cc.append(ayudaCant); g.append(cc, campo('Referencia (unidad, acta u orden)', fRef)); c.append(g);
c.append(campo('Motivo (obligatorio)', fMot));
c._leer = () => ({ item_id: fItem.value, tipo: fTipo.value, cantidad: fCant.value === '' ? null : +fCant.value, motivo: fMot.value.trim(), referencia: fRef.value.trim() });
setTimeout(() => (item ? fCant : fItem).focus(), 50);
}, [['Registrar', async (m) => {
const d = dlg.querySelector('.dcuerpo')._leer();
if (!d.item_id) throw new Error('Elige el ítem.');
if (d.cantidad == null || isNaN(d.cantidad)) throw new Error('Escribe la cantidad.');
if (!d.motivo) throw new Error('Escribe el motivo.');
const j = await api('/api/inventario/movimiento', json('POST', d));
dlg.close(); mensaje('Movimiento registrado: ' + j.item.codigo + ' ahora tiene ' + j.item.stock + ' ' + j.item.unidad + '.', true); cargar();
}]]);
}
function formItem(c, i) {
i = i || {};
const f = {};
f.codigo = el('input', { type: 'text', maxlength: '40', value: i.codigo || '', placeholder: 'EB-V56 · SD-15B-05', style: 'text-transform:uppercase' });
f.descripcion = el('input', { type: 'text', maxlength: '200', value: i.descripcion || '' });
f.categoria = el('select'); Object.entries(CAT).forEach(([k, v]) => f.categoria.append(el('option', { value: k, text: v }))); f.categoria.value = i.categoria || 'otro';
f.unidad = el('select'); [['pza', 'pza (piezas)'], ['m', 'm (metros)']].forEach(([v, t]) => f.unidad.append(el('option', { value: v, text: t }))); f.unidad.value = i.unidad || 'pza';
f.aplica = el('div', { class: 'lista-chk', style: 'max-height:none;display:flex;gap:14px;flex-wrap:wrap' });
VERS.forEach(v => { const l = el('label', { style: 'margin:0' }); const ck = el('input', { type: 'checkbox', value: v }); ck.checked = (i.aplica_a || []).includes(v); l.append(ck, document.createTextNode(v)); f.aplica.append(l); });
f.minimo = el('input', { type: 'number', min: '0', step: '1', value: i.minimo || 0 });
f.ubicacion = el('input', { type: 'text', maxlength: '120', value: i.ubicacion || '', placeholder: 'Anaquel A2 · taller Veracruz' });
f.proveedor = el('input', { type: 'text', maxlength: '120', value: i.proveedor || '' });
f.codigo_proveedor = el('input', { type: 'text', maxlength: '60', value: i.codigo_proveedor || '', placeholder: 'LCSC C2057152 · Mouser 709-SD15B05' });
f.notas = el('textarea', { rows: '2' }); f.notas.value = i.notas || '';
const g = el('div', { class: 'dos-col' });
g.append(campo('Código (único)', f.codigo), campo('Categoría', f.categoria));
c.append(g, campo('Descripción', f.descripcion), campo('Aplica a (versión de tarjeta)', f.aplica));
const g2 = el('div', { class: 'dos-col' }); g2.append(campo('Unidad', f.unidad), campo('Mínimo (0 = sin alerta)', f.minimo), campo('Ubicación', f.ubicacion), campo('Proveedor', f.proveedor), campo('Código del proveedor', f.codigo_proveedor)); c.append(g2);
if (!i.id) { f.stock_inicial = el('input', { type: 'number', min: '0', step: '1', value: '0' }); c.append(campo('Stock inicial (queda registrado como entrada)', f.stock_inicial)); }
c.append(campo('Notas', f.notas));
return () => {
const d = { codigo: f.codigo.value.trim().toUpperCase(), descripcion: f.descripcion.value.trim(), categoria: f.categoria.value, unidad: f.unidad.value,
aplica_a: [...f.aplica.querySelectorAll('input:checked')].map(x => x.value), minimo: +f.minimo.value || 0, ubicacion: f.ubicacion.value.trim(),
proveedor: f.proveedor.value.trim(), codigo_proveedor: f.codigo_proveedor.value.trim(), notas: f.notas.value.trim() };
if (f.stock_inicial) d.stock_inicial = +f.stock_inicial.value || 0;
return d;
};
}
function nuevo() {
let leer;
abrirDialogo('Nuevo ítem', c => { leer = formItem(c); }, [['Crear ítem', async () => {
const d = leer(); if (!d.codigo) throw new Error('Escribe el código.'); if (!d.descripcion) throw new Error('Escribe la descripción.');
await api('/api/inventario', json('POST', d)); dlg.close(); mensaje('Ítem ' + d.codigo + ' creado.', true); cargar();
}]]);
}
function editar(i) {
let leer;
abrirDialogo('Editar ' + i.codigo, c => {
leer = formItem(c, i);
const h = el('div', { class: 'hist' });
h.append(el('div', { html: '<b>Creado</b> por ' + esc(i.creado_por) + ' el ' + esc(fmt(i.creado_en)) + ' · stock actual <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b> (se cambia con movimientos, no aquí)' }));
(i.historial || []).slice().reverse().slice(0, 12).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + esc(x.cambio) })));
c.append(h);
}, [['Guardar', async () => {
const d = leer(); const cambios = {};
Object.keys(d).forEach(k => { if (JSON.stringify(d[k]) !== JSON.stringify(i[k] == null ? (Array.isArray(d[k]) ? [] : (typeof d[k] === 'number' ? 0 : '')) : i[k])) cambios[k] = d[k]; });
if (Object.keys(cambios).length) await api('/api/inventario/item?id=' + encodeURIComponent(i.id), json('PATCH', cambios));
dlg.close(); mensaje('Ítem guardado.', true); cargar();
}]]);
}
async function verMovimientos(i) {
abrirDialogo('Movimientos de ' + i.codigo, async c => {
c.append(el('p', { class: 'sub', html: esc(i.descripcion) + ' · stock <b>' + esc(i.stock) + ' ' + esc(i.unidad) + '</b>' + (i.minimo ? ' · mínimo ' + esc(i.minimo) : '') }));
const lista = el('div', { text: 'Cargando…' }); c.append(lista);
const b = el('button', { html: Kanban.icono('mas') + 'Movimiento', style: 'margin-top:10px' }); b.addEventListener('click', () => movimiento(i)); c.append(b);
try {
const j = await api('/api/inventario/movimientos?item_id=' + encodeURIComponent(i.id));
lista.innerHTML = '';
if (!j.total) { lista.append(el('div', { class: 'vacio', text: 'Sin movimientos. El stock es 0 hasta que registres una entrada o un ajuste.' })); return; }
lista.append(el('div', { class: 'mov-fila', style: 'font-weight:700;color:var(--tinta2);font-size:11.5px;text-transform:uppercase' }, el('span', { text: 'Cuándo · quién' }), el('span', { text: 'Tipo' }), el('span', { text: 'Δ · stock' }), el('span', { text: 'Motivo · referencia' })));
j.movimientos.forEach(m => {
lista.append(el('div', { class: 'mov-fila' },
el('span', { html: esc(fmt(m.cuando)) + '<br><b>' + esc(m.quien) + '</b>' }),
el('span', { text: m.tipo + (m.tipo === 'ajuste' ? ' → ' + m.cantidad : '') }),
el('span', { class: 'd', html: '<span class="' + (m.delta >= 0 ? 'pos' : 'neg') + '">' + (m.delta >= 0 ? '+' : '') + esc(m.delta) + '</span> · ' + esc(m.stock_despues) }),
el('span', { html: esc(m.motivo) + (m.referencia ? '<br><span class="mini">ref. ' + esc(m.referencia) + '</span>' : '') })));
});
} catch (e) { lista.textContent = 'No se pudieron leer los movimientos: ' + e.message; }
});
}
$('bMov').addEventListener('click', () => movimiento(null));
$('bNuevo').addEventListener('click', nuevo);
cargar();
})();
</script>

View File

@ -1,54 +1,18 @@
<!doctype html>
<!-- Desde el 20-oct-2026 este tablero vive dentro del sitio: docs/tableros/pendientes.md → /tableros/pendientes/
Este archivo solo redirige (conserva ?serie=, ?id=, ?pista=… y #…) para que las ligas viejas
guardadas en pendientes y actas sigan sirviendo. No se edita: los cambios van en tableros/pendientes.md. -->
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tablero de pendientes (kanban) — HBOX</title>
<link rel="stylesheet" href="tablero.css">
<meta name="robots" content="noindex">
<title>Pendientes - Drogheda</title>
<script>location.replace('/tableros/pendientes/' + location.search + location.hash);</script>
<meta http-equiv="refresh" content="0; url=/tableros/pendientes/">
<style>body{margin:0;padding:24px;background:#0E1524;color:#E7ECF5;font:16px/1.5 system-ui,sans-serif}a{color:#7FE9D8}</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Tablero de pendientes</h1><div class="meta" id="meta">Ingeniería · Producción · Mecánica · Compras · Campo</div></div>
<nav>
<a href="../">← Documentación</a>
<a class="activo" href="kanban.html">Pendientes</a>
<a href="ingenieria.html">Ingeniería</a>
<a href="produccion.html">Producción</a>
<a href="../inventario/inventario.html">Inventario</a>
<a href="../sitios/">Sitios</a>
<a href="equipo.html">👥 Equipo</a>
</nav>
</header>
<details class="como">
<summary>Cómo se usa</summary>
<ol>
<li>Cada tarjeta del tablero es un <b>pendiente</b> (no confundir con la tarjeta EinarBoard). Léelo antes de empezar el trabajo.</li>
<li>Arrastra el pendiente a la columna que corresponda; en el teléfono usa <b>Mover a…</b>. El tablero anota quién lo movió y cuándo.</li>
<li>Toca el título para ver el detalle: descripción, enlaces, de qué depende y su historial. Ahí mismo edita y guarda.</li>
<li><b>Nuevo pendiente</b>: título corto, pista, responsable y fecha límite. Si depende de otro, márcalo en «Bloqueado por».</li>
<li>Columna <b>Bloqueado</b>: solo si no puedes avanzar. Escribe en la descripción qué falta y quién lo destraba.</li>
<li>Al terminar, muévelo a <b>Hecho</b>. No lo borres: solo el administrador borra, y el historial sirve de bitácora.</li>
<li>Rojo = vencido. Filtra por pista, responsable o versión de tarjeta; los filtros no cambian nada en el servidor.</li>
</ol>
</details>
<div id="kb"></div>
</div>
<script src="kanban.js"></script>
<script>
Kanban.montar(document.getElementById('kb'), {
filtros: true,
onCargado: (tareas, meta) => {
const abiertas = tareas.filter(t => t.estado !== 'hecho').length;
const venc = tareas.filter(t => t.vencida).length;
const bloq = tareas.filter(t => t.estado === 'bloqueado').length;
document.getElementById('meta').textContent = abiertas + ' abiertos · ' + bloq + ' bloqueados · ' + venc + ' vencidos'
+ ' · actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })
+ (meta.user ? ' · ' + meta.user + (meta.admin ? ' (admin)' : '') : '');
}
});
</script>
<p>Este tablero ahora está dentro del sitio: <a href="/tableros/pendientes/">Pendientes</a>.</p>
</body>
</html>

View File

@ -1,11 +1,12 @@
/* Tablero kanban HBOX — módulo compartido (kanban.html, ingenieria.html, produccion.html).
/* Tablero de pendientes HBOX — módulo compartido (tableros/pendientes, ingenieria, produccion, equipo).
Vocabulario: la tarjeta del tablero se llama "pendiente" en pantalla para no confundirla con la
tarjeta EinarBoard. Columnas fijas: Por hacer · En curso · Bloqueado · Hecho.
Uso: const kb = Kanban.montar(document.getElementById('kb'), {
pista: 'ingenieria', // opcional: fija la pista (oculta su filtro y la precarga en "Nuevo")
filtros: true, // barra de filtros (responsable / versión / buscar / pista)
filtros: true, // barra de filtros (responsable / versión / buscar / pista / solo los míos)
compacto: false, // tarjetas más pequeñas (tableros que solo muestran un extracto)
botonNuevoEn: elemento, // opcional: dónde va el botón «Nuevo pendiente» (p. ej. el encabezado)
onCargado: (tareas, meta) => {} // callback después de cada carga
});
kb.recargar();
@ -14,7 +15,9 @@
DELETE /api/tarea?id= (solo admin). Cada cambio guarda quién y cuándo en el historial.
Equipo (14-oct): los responsables son miembros de /api/equipo (GET /api/tareas ya trae el mapa
id → nombre). El pendiente exige responsable; se puede escribir texto libre si la persona aún no
está dada de alta (el servidor avisa). Kanban.nombresResp(t, equipo) sirve a otras páginas. */
está dada de alta (el servidor avisa). Kanban.nombresResp(t, equipo) sirve a otras páginas.
Estilos: tableros/tablero.css (todo bajo .tb). Sin emoji: íconos de línea (Kanban.icono) y
avatares con iniciales (Kanban.avatar), con el mismo color por persona en todo el sitio. */
(function () {
const PISTAS = { ingenieria: 'Ingeniería', produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', campo: 'Campo' };
const ESTADOS = [['por-hacer', 'Por hacer'], ['en-curso', 'En curso'], ['bloqueado', 'Bloqueado'], ['hecho', 'Hecho']];
@ -22,9 +25,45 @@
// Lista de respaldo si /api/equipo no responde; lo normal es que el equipo venga del servidor.
const RESPONSABLES = ['leo', 'adriana', 'michelle', 'jesus', 'alan', 'daniel', 'aldair', 'gibran', 'claude'];
const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' };
const MESES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'];
// Un color por persona, el mismo en todo el sitio (sistema visual del 20-oct).
const AVATAR = {
leo: ['LR', '#3A5A9A'], adriana: ['AD', '#8A4F7D'], michelle: ['MM', '#2E7D74'], jesus: ['JS', '#9A6A2E'],
aldair: ['AL', '#5B5FA8'], alan: ['AB', '#57682B'], daniel: ['DM', '#A0503F'], gibran: ['GB', '#36708E'],
produccion: ['PR', '#5A6680'], claude: ['CL', '#4A5468']
};
const TONOS = ['#3A5A9A', '#8A4F7D', '#2E7D74', '#9A6A2E', '#5B5FA8', '#57682B', '#A0503F', '#36708E', '#5A6680', '#6B4F8A'];
// Íconos de línea (24×24, trazo redondo).
const ICONOS = {
liga: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1',
aviso: 'M12 3.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17zM12 8v5M12 16.2v.3',
mas: 'M12 5v14M5 12h14',
cerrar: 'M6 6l12 12M18 6 6 18',
persona: 'M12 5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zM5 20c.8-3.8 3.6-6 7-6s6.2 2.2 7 6',
fecha: 'M6 5h12a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM8 3v4M16 3v4M4 10h16',
espera: 'M12 3.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17zM12 7.5V12l3 2',
ok: 'M5 12.5l4.5 4.5L19 7.5',
equipo: 'M9 5.3a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4zM3.5 19.5c.6-3.2 2.8-5 5.5-5s4.9 1.8 5.5 5M16.8 7a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM15.8 14.6c2.3 0 4.2 1.6 4.7 4.4',
descargar: 'M12 4v11M7.5 10.5 12 15l4.5-4.5M5 20h14',
documento: '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',
flecha: 'M5 12h14M13 6l6 6-6 6',
unidad: 'M5 7.5 12 4l7 3.5v9L12 20l-7-3.5zM5 7.5l7 3.5 7-3.5M12 11v9',
actualizar: 'M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4',
imprimir: 'M7 8.5V4h10v4.5M7 17H5a1.5 1.5 0 0 1-1.5-1.5v-5A1.5 1.5 0 0 1 5 9h14a1.5 1.5 0 0 1 1.5 1.5v5A1.5 1.5 0 0 1 19 17h-2M7 14h10v6H7z'
};
const icono = (n, cls) => '<svg class="i' + (cls ? ' ' + cls : '') + '" viewBox="0 0 24 24" aria-hidden="true"><path d="' + (ICONOS[n] || '') + '"/></svg>';
// Íconos de estado de las columnas (con relleno; colores de los tokens).
const ESTADO_SVG = {
'por-hacer': '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z" style="fill:none;stroke:var(--hg-texto-3);stroke-width:2;stroke-dasharray:2.6 2.6"/></svg>',
'en-curso': '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z" style="fill:none;stroke:var(--hg-curso);stroke-width:2"/><path d="M12 4a8 8 0 0 1 0 16z" style="fill:var(--hg-curso)"/></svg>',
'bloqueado': '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z" style="fill:none;stroke:var(--hg-bloqueado);stroke-width:2"/><path d="M6.8 17.2 17.2 6.8" style="fill:none;stroke:var(--hg-bloqueado);stroke-width:2.2;stroke-linecap:round"/></svg>',
'hecho': '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z" style="fill:var(--hg-hecho-icono);stroke:var(--hg-hecho-icono);stroke-width:2"/><path d="M8.3 12.3l2.5 2.5 4.9-5.1" style="fill:none;stroke:var(--hg-fondo);stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round"/></svg>'
};
// ---- equipo: id → {nombre, activo, area}. 'Jesús' ≡ 'jesus' (sin acentos, minúsculas).
const norm = s => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim().replace(/[^a-z0-9_\-.]/g, '');
const norm = s => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim().replace(/[^a-z0-9_\-.]/g, '');
let EQUIPO = {};
let equipoProm = null;
function cargarEquipo() {
@ -48,11 +87,45 @@
}
const libresResp = (t, eq) => { eq = eq || EQUIPO; return partesResp(t).filter(p => !eq[norm(p.replace(/\s+/g, ''))] && !eq[norm(p)]); };
// ---- avatares: iniciales sobre el color de la persona
function iniciales(nombre) {
const w = String(nombre || '?').trim().split(/\s+/).filter(Boolean);
if (w.length >= 2) return (w[0][0] + w[1][0]).toUpperCase();
return (w[0] || '?').slice(0, 2).toUpperCase();
}
function tono(id) { let h = 0; for (const c of String(id)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return TONOS[h % TONOS.length]; }
// avatar(id) para un miembro; avatar(null, 'texto libre') para alguien que no está en Equipo
function avatar(id, libre) {
const s = document.createElement('span');
if (id) {
const m = EQUIPO[id] || {};
const a = AVATAR[id] || [iniciales(m.nombre || id), tono(id)];
s.className = 'av'; s.textContent = a[0]; s.style.background = a[1]; s.title = m.nombre || id;
} else {
s.className = 'av libre'; s.textContent = iniciales(libre); s.title = (libre || '') + ' (sin registrar en Equipo)';
}
return s;
}
function avatares(t) {
const g = document.createElement('span'); g.className = 'avs';
idsResp(t).forEach(id => g.append(avatar(id)));
libresResp(t).forEach(x => g.append(avatar(null, x)));
g.title = nombresResp(t).join(', ');
return g;
}
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 hoyISO = () => { const d = new Date(); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 10); };
const fmt = iso => (iso || '').replace('T', ' ').slice(0, 16);
const fechaCorta = f => { if (!f) return ''; const [y, m, d] = f.split('-'); return d + '-' + ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'][+m - 1] + (y !== hoyISO().slice(0, 4) ? '-' + y.slice(2) : ''); };
const fechaCorta = f => { if (!f) return ''; const [y, m, d] = f.split('-'); return (+d) + ' ' + MESES[+m - 1] + (y !== hoyISO().slice(0, 4) ? ' ' + y : ''); };
// texto y clase de la fecha límite de un pendiente
function fechaDe(t) {
if (!t.fecha_limite) return null;
if (t.vencida) return { texto: 'Venció ' + fechaCorta(t.fecha_limite), cls: 'mal' };
if (t.estado !== 'hecho' && t.fecha_limite === hoyISO()) return { texto: 'Hoy', cls: 'hoy' };
return { texto: fechaCorta(t.fecha_limite), cls: '' };
}
async function api(url, opts) {
const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {}));
@ -65,27 +138,31 @@
function montar(root, opts) {
opts = opts || {};
let tareas = [], admin = false, user = '', porId = {};
const filtro = { pista: opts.pista || '', responsable: '', version: '', buscar: '', hechas: 'recientes' };
const q0 = new URLSearchParams(location.search);
const filtro = { pista: opts.pista || '', responsable: '', version: '', buscar: '', hechas: 'recientes', mios: q0.get('mios') === '1' };
root.classList.add('kanban-root');
const contenedor = root.closest('.tb') || root;
// ---------- barra de filtros ----------
const barra = el('div', { class: 'kb-barra' });
const selPista = el('select', { title: 'Pista' }); selPista.append(el('option', { value: '', text: 'Todas las pistas' }));
const selPista = el('select', { title: 'Pista', 'aria-label': 'Pista' }); selPista.append(el('option', { value: '', text: 'Pista: todas' }));
Object.entries(PISTAS).forEach(([k, v]) => selPista.append(el('option', { value: k, text: v })));
const selResp = el('select', { title: 'Responsable' }); selResp.append(el('option', { value: '', text: 'Todos los responsables' }));
const selVer = el('select', { title: 'Versión de tarjeta' }); selVer.append(el('option', { value: '', text: 'Todas las versiones' }));
const selResp = el('select', { title: 'Responsable', 'aria-label': 'Responsable' }); selResp.append(el('option', { value: '', text: 'Responsable: todos' }));
const selVer = el('select', { title: 'Versión de tarjeta', 'aria-label': 'Versión de tarjeta' }); selVer.append(el('option', { value: '', text: 'Tarjeta: todas' }));
VERSIONES.filter(v => v !== '—').forEach(v => selVer.append(el('option', { value: v, text: v })));
const selHechas = el('select', { title: 'Hechas' });
[['recientes', 'Hechas: últimos 14 días'], ['todas', 'Hechas: todas'], ['ninguna', 'Hechas: ocultar']].forEach(([v, t]) => selHechas.append(el('option', { value: v, text: t })));
const buscar = el('input', { type: 'search', placeholder: 'Buscar…', style: 'min-width:140px;flex:1' });
const btnNuevo = el('button', { text: '+ Nuevo pendiente' });
const cnt = el('span', { class: 'cnt' });
const selHechas = el('select', { title: 'Hechos', 'aria-label': 'Hechos' });
[['recientes', 'Hechos: últimos 14 días'], ['todas', 'Hechos: todos'], ['ninguna', 'Hechos: ocultar']].forEach(([v, t]) => selHechas.append(el('option', { value: v, text: t })));
const buscar = el('input', { type: 'search', placeholder: 'Buscar en pendientes', 'aria-label': 'Buscar en pendientes' });
const btnMios = el('button', { type: 'button', class: 'kb-solo', 'aria-pressed': String(filtro.mios), html: icono('persona') + 'Solo los míos' });
const btnNuevo = el('button', { type: 'button', class: 'kb-nuevo', html: icono('mas') + 'Nuevo pendiente' });
if (opts.filtros !== false) {
barra.append(btnMios);
if (!opts.pista) barra.append(selPista);
barra.append(selResp, selVer, selHechas, buscar, btnNuevo, cnt);
barra.append(selResp, selVer, selHechas, buscar);
if (opts.botonNuevoEn) opts.botonNuevoEn.append(btnNuevo); else barra.append(btnNuevo);
root.append(barra);
}
const aviso = el('div', { class: 'msg' }); root.append(aviso);
const aviso = el('div', { class: 'msg', role: 'status' }); root.append(aviso);
function mensaje(txt, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = txt; clearTimeout(aviso._t); aviso._t = setTimeout(() => { aviso.className = 'msg'; }, bien ? 3500 : 8000); }
// ---------- columnas ----------
@ -94,7 +171,12 @@
const cols = {};
ESTADOS.forEach(([k, nombre]) => {
const c = el('div', { class: 'col e-' + k, 'data-estado': k });
c.append(el('h3', null, el('span', { text: nombre }), el('span', { class: 'n', text: '0' })));
c.append(el('h3', { html: ESTADO_SVG[k] + '<span>' + nombre + '</span><span class="n">0</span>' }));
if (k !== 'hecho') {
const mas = el('button', { type: 'button', class: 'kb-mas', html: icono('mas') + 'Nuevo' });
mas.addEventListener('click', () => abrir(null, k));
c.append(mas);
}
c.addEventListener('dragover', e => { e.preventDefault(); c.classList.add('sobre'); });
c.addEventListener('dragleave', () => c.classList.remove('sobre'));
c.addEventListener('drop', e => {
@ -114,8 +196,10 @@
function visibles() {
const q = filtro.buscar.trim().toLowerCase();
const corte = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10);
const yo = norm(user);
return tareas.filter(t => {
if (filtro.pista && t.pista !== filtro.pista) return false;
if (filtro.mios && yo && !idsResp(t).includes(yo) && !String(t.responsable || '').toLowerCase().includes(yo)) return false;
if (filtro.responsable) {
const f = filtro.responsable;
if (EQUIPO[f]) { if (!idsResp(t).includes(f)) return false; }
@ -126,34 +210,47 @@
if (filtro.hechas === 'ninguna') return false;
if (filtro.hechas === 'recientes' && (t.modificado_en || '').slice(0, 10) < corte) return false;
}
if (q) { const txt = [t.titulo, t.descripcion, t.responsable, (t.etiquetas || []).join(' '), t.id].join(' ').toLowerCase(); if (!txt.includes(q)) return false; }
if (q) { const txt = [t.titulo, t.descripcion, t.responsable, nombresResp(t).join(' '), (t.etiquetas || []).join(' '), t.id].join(' ').toLowerCase(); if (!txt.includes(q)) return false; }
return true;
});
}
function bloqueosAbiertos(t) { return (t.bloqueada_por || []).map(id => porId[id]).filter(b => b && b.estado !== 'hecho'); }
const corto = (s, n) => s.length > n ? s.slice(0, n - 1) + '…' : s;
function tarjeta(t) {
const c = el('div', { class: 'card p-' + t.pista + (t.vencida ? ' vencida' : ''), draggable: 'true', 'data-id': t.id });
const c = el('div', { class: 'card p-' + t.pista + ' e-' + t.estado + (t.vencida ? ' vencida' : ''), draggable: 'true', 'data-id': t.id });
c.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', t.id); e.dataTransfer.effectAllowed = 'move'; c.classList.add('arrastrando'); });
c.addEventListener('dragend', () => c.classList.remove('arrastrando'));
const tit = el('div', { class: 'tit', text: t.titulo }); tit.addEventListener('click', () => abrir(t)); c.append(tit);
const meta = el('div', { class: 'meta' });
if (!opts.pista) meta.append(el('span', { class: 'chip p-' + t.pista, text: PISTAS[t.pista] || t.pista }));
if (t.version_tarjeta && t.version_tarjeta !== '—') meta.append(el('span', { class: BADGE[t.version_tarjeta] || 'chip', text: t.version_tarjeta }));
if (t.responsable) meta.append(el('span', { text: '👤 ' + nombresResp(t).join(', '), title: t.responsable }));
if (t.fecha_limite) meta.append(el('span', { class: 'fecha' + (t.vencida ? ' mal' : ''), text: (t.vencida ? '⚠ venció ' : '📅 ') + fechaCorta(t.fecha_limite) }));
(t.etiquetas || []).slice(0, 3).forEach(x => meta.append(el('span', { class: 'chip', text: x })));
c.append(meta);
// pista y versión de tarjeta
const sup = el('div', { class: 'sup' });
if (!opts.pista) sup.append(el('span', { class: 'pista-p' }), el('span', { text: PISTAS[t.pista] || t.pista }));
if (t.version_tarjeta && t.version_tarjeta !== '—') sup.append(el('span', { class: (BADGE[t.version_tarjeta] || 'chip') + ' ver-t', text: t.version_tarjeta }));
if (sup.childNodes.length) c.append(sup);
// título (abre el detalle)
const tit = el('div', { class: 'tit', text: t.titulo, role: 'button', tabindex: '0' });
tit.addEventListener('click', () => abrir(t));
tit.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); abrir(t); } });
c.append(tit);
// de qué depende
const bl = bloqueosAbiertos(t);
if (bl.length) c.append(el('div', { class: 'bloq', text: '⛓ espera: ' + bl.map(b => b.titulo.length > 38 ? b.titulo.slice(0, 37) + '…' : b.titulo).join(' · ') }));
if (bl.length) c.append(el('div', { class: 'nota', html: icono('aviso') + '<span>Espera: ' + esc(bl.map(b => corto(b.titulo.split(':')[0], 42)).join(' · ')) + '</span>' }));
// ligas
if ((t.enlaces || []).length) {
const e = el('div', { class: 'enl' });
t.enlaces.slice(0, 3).forEach(x => e.append(el('a', { href: x.url, text: '🔗 ' + (x.titulo || x.url) })));
t.enlaces.slice(0, 2).forEach(x => { const a = el('a', { href: x.url, html: icono('liga') + '<span>' + esc(x.titulo || x.url) + '</span>' }); a.addEventListener('click', ev => ev.stopPropagation()); e.append(a); });
if (t.enlaces.length > 2) e.append(el('span', { class: 'vacio', text: '+' + (t.enlaces.length - 2) + ' más' }));
c.append(e);
}
if ((t.etiquetas || []).length) { const g = el('div', { class: 'etq' }); t.etiquetas.slice(0, 3).forEach(x => g.append(el('span', { class: 'chip', text: x }))); c.append(g); }
// responsables y fecha
const pie = el('div', { class: 'pie' });
pie.append(t.responsable ? avatares(t) : el('span', { class: 'vacio', text: 'Sin responsable' }));
const f = fechaDe(t);
if (f) pie.append(el('span', { class: 'fecha ' + f.cls, text: f.texto, title: 'Fecha límite ' + t.fecha_limite }));
c.append(pie);
// "Mover a…": teléfonos y quien no arrastra
const mv = el('div', { class: 'mover' });
const sel = el('select');
const sel = el('select', { 'aria-label': 'Mover a otra columna' });
sel.append(el('option', { value: '', text: 'Mover a…' }));
ESTADOS.filter(([k]) => k !== t.estado).forEach(([k, n]) => sel.append(el('option', { value: k, text: n })));
sel.addEventListener('change', () => { if (sel.value) mover(t.id, sel.value, 0); });
@ -168,7 +265,8 @@
const c = cols[k]; c.querySelectorAll('.card').forEach(x => x.remove());
const lst = vis.filter(t => t.estado === k).sort((a, b) => (a.orden - b.orden) || (a.creado_en < b.creado_en ? -1 : 1));
c.querySelector('.n').textContent = lst.length;
lst.forEach(t => c.append(tarjeta(t)));
const mas = c.querySelector('.kb-mas');
lst.forEach(t => mas ? c.insertBefore(tarjeta(t), mas) : c.append(tarjeta(t)));
});
// responsables presentes en los datos (más los conocidos)
selResp.querySelectorAll('option:not(:first-child)').forEach(o => o.remove());
@ -177,8 +275,9 @@
const libres = new Set(); tareas.forEach(t => libresResp(t).forEach(x => libres.add(x.toLowerCase())));
[...libres].sort().forEach(x => selResp.append(el('option', { value: x, text: x + ' (sin registrar)' })));
selResp.value = filtro.responsable;
const venc = vis.filter(t => t.vencida).length;
cnt.textContent = vis.length + ' pendiente(s)' + (venc ? ' · ' + venc + ' vencido(s)' : '') + (vis.length !== tareas.length ? ' · de ' + tareas.length : '');
btnMios.setAttribute('aria-pressed', String(filtro.mios));
btnMios.disabled = !user;
btnMios.title = user ? 'Solo los pendientes de ' + ((EQUIPO[norm(user)] || {}).nombre || user) : 'No se sabe quién eres (falta X-Remote-User)';
}
async function recargar() {
@ -204,22 +303,23 @@
try {
if (!mismaCol) await api('/api/tarea?id=' + encodeURIComponent(id), json('PATCH', { estado, orden: ids.indexOf(id) }));
await api('/api/tareas/orden', json('POST', { estado, ids }));
if (!mismaCol) mensaje('«' + t.titulo.slice(0, 40) + (t.titulo.length > 40 ? '…' : '') + '» → ' + ESTADOS.find(e => e[0] === estado)[1], true);
if (!mismaCol) mensaje('«' + corto(t.titulo, 41) + '» pasó a ' + ESTADOS.find(e => e[0] === estado)[1] + '.', true);
await recargar();
} catch (e) { t.estado = prev.estado; t.orden = prev.orden; pintar(); mensaje('No se pudo mover: ' + e.message, false); }
}
// ---------- diálogo: alta y detalle ----------
const dlg = el('dialog');
document.body.append(dlg);
const dlg = el('dialog', { class: 'kb-dlg', 'aria-label': 'Pendiente' });
contenedor.append(dlg);
function campo(lbl, input) { const d = el('div'); d.append(el('label', { text: lbl }), input); return d; }
function abrir(t) {
function abrir(t, estadoInicial) {
const nuevo = !t;
t = t || { titulo: '', descripcion: '', pista: opts.pista || 'produccion', estado: 'por-hacer', responsable: '', fecha_limite: null, enlaces: [], bloqueada_por: [], etiquetas: [], version_tarjeta: '—' };
t = t || { titulo: '', descripcion: '', pista: opts.pista || 'produccion', estado: estadoInicial || 'por-hacer', responsable: '', fecha_limite: null, enlaces: [], bloqueada_por: [], etiquetas: [], version_tarjeta: '—' };
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' });
cab.append(el('h2', { text: nuevo ? 'Nuevo pendiente' : 'Pendiente ' + t.id }));
const cerrar = el('button', { type: 'button', text: '✕', title: 'Cerrar' }); cerrar.addEventListener('click', () => dlg.close()); cab.append(cerrar);
cab.append(el('h2', { text: nuevo ? 'Nuevo pendiente' : 'Pendiente' }));
if (!nuevo) cab.append(el('span', { class: 'dcab-id', text: t.id }));
const cerrar = el('button', { type: 'button', title: 'Cerrar', 'aria-label': 'Cerrar', html: icono('cerrar') }); cerrar.addEventListener('click', () => dlg.close()); cab.append(cerrar);
dlg.append(cab);
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
@ -234,12 +334,12 @@
const l = el('label', { class: 'chip-sel' + (yaIds.includes(id) ? ' on' : '') });
const ck = el('input', { type: 'checkbox', value: id }); ck.checked = yaIds.includes(id);
ck.addEventListener('change', () => l.classList.toggle('on', ck.checked));
l.append(ck, document.createTextNode(m.nombre + (m.activo === false ? ' (inactivo)' : ''))); fResp.append(l);
l.append(ck, avatar(id), document.createTextNode(m.nombre + (m.activo === false ? ' (inactivo)' : ''))); fResp.append(l);
});
const fLibre = el('input', { type: 'text', value: libresResp(t).join(', '), placeholder: 'Otro (texto libre, solo si la persona aún no está en Equipo)', style: 'margin-top:6px' });
const fLibre = el('input', { type: 'text', value: libresResp(t).join(', '), placeholder: 'Otro (texto libre, solo si la persona aún no está en Equipo)', style: 'margin-top:8px' });
const leerResp = () => [...fResp.querySelectorAll('input:checked')].map(c => c.value).concat(fLibre.value.split(',').map(x => x.trim()).filter(Boolean)).join(', ');
const fFecha = el('input', { type: 'date', value: t.fecha_limite || '' });
const fVer = el('select'); VERSIONES.forEach(v => fVer.append(el('option', { value: v, text: v === '—' ? '— (no aplica)' : v }))); fVer.value = t.version_tarjeta || '—';
const fVer = el('select'); VERSIONES.forEach(v => fVer.append(el('option', { value: v, text: v === '—' ? 'No aplica' : v }))); fVer.value = t.version_tarjeta || '—';
const fEtq = el('input', { type: 'text', value: (t.etiquetas || []).join(', '), placeholder: 'layout, pedido 570, bloqueante' });
cuerpo.append(campo('Título', fTit), campo('Descripción', fDesc));
@ -252,19 +352,19 @@
const f = el('div', { class: 'fila' });
const a = el('input', { type: 'text', value: e.titulo || '', placeholder: 'Título' });
const b = el('input', { type: 'text', value: e.url || '', placeholder: '/hardware/… o https://…' });
const q = el('button', { type: 'button', class: 'sec', text: '✕' }); q.addEventListener('click', () => f.remove());
const q = el('button', { type: 'button', class: 'sec', title: 'Quitar la liga', 'aria-label': 'Quitar la liga', html: icono('cerrar') }); q.addEventListener('click', () => f.remove());
f.append(a, b, q); enlBox.append(f);
}
(t.enlaces || []).forEach(filaEnlace);
const addEnl = el('button', { type: 'button', class: 'sec', text: '+ enlace', style: 'padding:6px 10px;font-size:13px' }); addEnl.addEventListener('click', () => filaEnlace({}));
cuerpo.append(campo('Enlaces (documentos, actas, expediente de unidad)', enlBox), addEnl);
const addEnl = el('button', { type: 'button', class: 'sec', html: icono('mas') + 'Liga', style: 'padding:6px 10px;font-size:13px' }); addEnl.addEventListener('click', () => filaEnlace({}));
cuerpo.append(campo('Documentos, actas o expediente de unidad', enlBox), addEnl);
// bloqueada por (otros pendientes abiertos)
const lst = el('div', { class: 'lista-chk' });
const otros = tareas.filter(x => x.id !== t.id && (x.estado !== 'hecho' || (t.bloqueada_por || []).includes(x.id)));
if (!otros.length) lst.append(el('span', { class: 'vacio', text: 'No hay otros pendientes.' }));
otros.forEach(x => { const l = el('label'); const ck = el('input', { type: 'checkbox', value: x.id }); ck.checked = (t.bloqueada_por || []).includes(x.id); l.append(ck, document.createTextNode(x.titulo + ' (' + (PISTAS[x.pista] || x.pista) + ')')); lst.append(l); });
cuerpo.append(campo('Bloqueado por (espera a que terminen)', lst));
cuerpo.append(campo('Espera a que terminen', lst));
if (!nuevo) {
const h = el('div', { class: 'hist' });
@ -301,7 +401,7 @@
const cambios = {}; Object.keys(d).forEach(k => { if (JSON.stringify(d[k]) !== JSON.stringify(t[k] == null ? (Array.isArray(d[k]) ? [] : (k === 'fecha_limite' ? null : '')) : t[k])) cambios[k] = d[k]; });
if (Object.keys(cambios).length) r = await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', cambios));
}
dlg.close(); mensaje((nuevo ? 'Pendiente creado.' : 'Cambios guardados.') + (r && r.aviso ? ' ⚠ ' + r.aviso : ''), !(r && r.aviso)); recargar();
dlg.close(); mensaje((nuevo ? 'Pendiente creado.' : 'Cambios guardados.') + (r && r.aviso ? ' Aviso: ' + r.aviso : ''), !(r && r.aviso)); recargar();
} catch (e) { m.className = 'msg err'; m.textContent = e.message; }
guardar.disabled = false;
});
@ -316,15 +416,19 @@
selVer.addEventListener('change', () => { filtro.version = selVer.value; pintar(); });
selHechas.addEventListener('change', () => { filtro.hechas = selHechas.value; pintar(); });
buscar.addEventListener('input', () => { filtro.buscar = buscar.value; pintar(); });
btnMios.addEventListener('click', () => {
filtro.mios = !filtro.mios; pintar();
const u = new URL(location.href); if (filtro.mios) u.searchParams.set('mios', '1'); else u.searchParams.delete('mios');
history.replaceState(null, '', u.pathname + u.search + u.hash);
});
btnNuevo.addEventListener('click', () => abrir(null));
const q = new URLSearchParams(location.search);
if (q.get('pista') && !opts.pista) { selPista.value = q.get('pista'); filtro.pista = q.get('pista'); }
if (q.get('responsable')) { filtro.responsable = q.get('responsable').toLowerCase(); }
if (q.get('id')) setTimeout(() => { const t = porId[q.get('id')]; if (t) abrir(t); }, 600);
if (q0.get('pista') && !opts.pista) { selPista.value = q0.get('pista'); filtro.pista = q0.get('pista'); }
if (q0.get('responsable')) { filtro.responsable = q0.get('responsable').toLowerCase(); }
if (q0.get('id')) setTimeout(() => { const t = porId[q0.get('id')]; if (t) abrir(t); }, 600);
recargar();
return { recargar, abrir, get tareas() { return tareas; }, get admin() { return admin; } };
return { recargar, abrir, get tareas() { return tareas; }, get admin() { return admin; }, get user() { return user; } };
}
window.Kanban = { montar, PISTAS, ESTADOS, VERSIONES, RESPONSABLES, cargarEquipo, usarEquipo, nombresResp, idsResp, norm, get equipo() { return EQUIPO; } };
window.Kanban = { montar, PISTAS, ESTADOS, VERSIONES, RESPONSABLES, cargarEquipo, usarEquipo, nombresResp, idsResp, norm, avatar, avatares, icono, fechaDe, get equipo() { return EQUIPO; } };
})();

View File

@ -0,0 +1,47 @@
---
title: "Pendientes"
description: "El tablero de pendientes del equipo: por hacer, en curso, bloqueado y hecho."
template: tablero.html
hide:
- toc
---
<div class="tb tb-pendientes">
<header class="tb-cab">
<div class="tb-cab__t"><h1>Pendientes</h1><span class="meta" id="meta">Ingeniería, producción, mecánica, compras y campo</span></div>
<div class="tb-cab__acc"><span id="acciones-kb"></span></div>
</header>
<details class="info tb-como">
<summary>Cómo se usa</summary>
<ol>
<li>Cada tarjeta del tablero es un <b>pendiente</b> (no confundir con la tarjeta EinarBoard). Léelo antes de empezar el trabajo.</li>
<li>Arrastra el pendiente a la columna que corresponda; en el teléfono usa <b>Mover a…</b>. El tablero anota quién lo movió y cuándo.</li>
<li>Toca el título para ver el detalle: descripción, enlaces, de qué depende y su historial. Ahí mismo edita y guarda.</li>
<li><b>Nuevo pendiente</b>: título corto, pista, responsable y fecha límite. Si depende de otro, márcalo en «Bloqueado por».</li>
<li>Columna <b>Bloqueado</b>: solo si no puedes avanzar. Escribe en la descripción qué falta y quién lo destraba.</li>
<li>Al terminar, muévelo a <b>Hecho</b>. No lo borres: solo el administrador borra, y el historial sirve de bitácora.</li>
<li>Rojo = vencido. Filtra por pista, responsable o versión de tarjeta; los filtros no cambian nada en el servidor.</li>
</ol>
</details>
<div id="kb"></div>
</div>
<script>
(function () {
Kanban.montar(document.getElementById('kb'), {
filtros: true,
botonNuevoEn: document.getElementById('acciones-kb'),
onCargado: (tareas, meta) => {
const abiertas = tareas.filter(t => t.estado !== 'hecho').length;
const venc = tareas.filter(t => t.vencida).length;
const bloq = tareas.filter(t => t.estado === 'bloqueado').length;
const n = (k, uno, varios) => k + ' ' + (k === 1 ? uno : varios);
document.getElementById('meta').textContent = [n(abiertas, 'abierto', 'abiertos'), n(bloq, 'bloqueado', 'bloqueados'), n(venc, 'vencido', 'vencidos')].join(' · ')
+ ' · al día a las ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit', hour12: false })
+ (meta.user ? ' · ' + meta.user + (meta.admin ? ' (admin)' : '') : '');
}
});
})();
</script>

View File

@ -1,236 +1,18 @@
<!doctype html>
<!-- Desde el 20-oct-2026 este tablero vive dentro del sitio: docs/tableros/produccion.md → /tableros/produccion/
Este archivo solo redirige (conserva ?serie=, ?id=, ?pista=… y #…) para que las ligas viejas
guardadas en pendientes y actas sigan sirviendo. No se edita: los cambios van en tableros/produccion.md. -->
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tablero de producción — pedido 570</title>
<link rel="stylesheet" href="tablero.css">
<meta name="robots" content="noindex">
<title>Producción - Drogheda</title>
<script>location.replace('/tableros/produccion/' + location.search + location.hash);</script>
<meta http-equiv="refresh" content="0; url=/tableros/produccion/">
<style>body{margin:0;padding:24px;background:#0E1524;color:#E7ECF5;font:16px/1.5 system-ui,sans-serif}a{color:#7FE9D8}</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Tablero de producción — pedido de 570 HBOX</h1><div class="meta" id="meta">Cargando…</div></div>
<nav>
<a href="../">← Documentación</a>
<a href="kanban.html">Pendientes</a>
<a href="ingenieria.html">Ingeniería</a>
<a class="activo" href="produccion.html">Producción</a>
<a href="../tablero.html">Detalle por tarjeta</a>
<a href="../inventario/inventario.html">Inventario</a>
<a href="equipo.html">👥 Equipo</a>
<button id="refrescar" style="padding:6px 12px">Actualizar</button>
</nav>
</header>
<details class="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="kanban.html">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>
</div>
<dialog id="dlgPlan"></dialog>
<script src="kanban.js"></script>
<script>
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: '⚠ 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 });
c.append(el('div', { class: 'tit', html: '<a href="kanban.html?id=' + encodeURIComponent(t.id) + '" style="color:inherit;text-decoration:none">' + esc(t.titulo) + '</a>' }));
c.append(el('div', { class: 'meta', html: '<span class="chip p-' + t.pista + '">' + esc(PISTAS[t.pista] || t.pista) + '</span>' + (t.responsable ? '<span title="' + esc(t.responsable) + '">👤 ' + esc(Kanban.nombresResp(t, EQUIPO).join(', ')) + '</span>' : '<span>sin responsable</span>') + (t.fecha_limite ? '<span class="fecha' + (t.vencida ? ' mal' : '') + '">📅 ' + esc(t.fecha_limite) + '</span>' : '') }));
if (t.descripcion) c.append(el('div', { style: 'font-size:13px;color:var(--tinta2);margin-top:4px', text: t.descripcion.length > 160 ? t.descripcion.slice(0, 159) + '…' : t.descripcion }));
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="../inventario/inventario.html">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="../unidad.html?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', text: '✕' }); 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', text: '✕', 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', text: '+ 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>
<p>Este tablero ahora está dentro del sitio: <a href="/tableros/produccion/">Producción</a>.</p>
</body>
</html>

232
docs/tableros/produccion.md Normal file
View File

@ -0,0 +1,232 @@
---
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>

178
docs/tableros/sitio.md Normal file
View File

@ -0,0 +1,178 @@
---
title: "Sitios"
description: "Todo lo que pasó en un sitio: unidades instaladas, envíos, diagnósticos, daños y contactos."
template: tablero.html
hide:
- toc
---
<div class="tb tb-sitio">
<header class="tb-cab">
<div class="tb-cab__t"><h1 id="titulo">Sitio</h1><span class="meta" id="meta">Cargando…</span></div>
<div class="tb-cab__acc"><a class="boton sec" href="/formularios/instalacion.html"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="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"/></svg>Nueva instalación</a><a class="boton sec" href="/formularios/diagnostico.html"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="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"/></svg>Diagnóstico</a><a class="boton sec" href="/tableros/pendientes/?pista=campo"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4.5h14a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 18V6A1.5 1.5 0 0 1 5 4.5zM9.3 4.5v15M14.7 4.5v15"/></svg>Pendientes de campo</a></div>
</header>
<details class="info tb-como">
<summary>Cómo se usa</summary>
<ol>
<li>Esta página junta todo lo que pasó en un sitio: unidades instaladas, envíos, recepciones, diagnósticos y daños. Sale de las actas; aquí no se captura nada de campo.</li>
<li>Para que una unidad aparezca, el acta debe traer el <b>sitio</b> elegido en el formulario (instalación, envío, recepción, diagnóstico, daño). Las actas viejas sin sitio se buscan por el nombre del puerto y se marcan «por texto».</li>
<li>Toca una serie para abrir su expediente completo. Rojo = unidad con marca de daño o baja.</li>
<li>Contactos y ubicación los edita solo el administrador (botón «Editar sitio»). Si falta un dato, díselo a Leo.</li>
<li>Para dar de alta otro sitio: <a href="/sitios/">lista de sitios</a> → «Nuevo sitio» (administrador).</li>
</ol>
</details>
<div class="sitio-sel"><label for="sel">Sitio</label><select id="sel"></select><button id="bEditar" class="sec" style="display:none">Editar sitio</button></div>
<div class="msg" id="aviso"></div>
<div id="cont"></div>
<dialog id="dlg"></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 $ = id => document.getElementById(id);
const NOMBRE = { 'instalacion-campo': 'Instalación', 'diagnostico-campo': 'Diagnóstico en campo', 'envio': 'Envío', 'recepcion': 'Recepción', 'dano': 'Daño o incidente' };
let SITIOS = [], ADMIN = false, ACTUAL = null, DATOS = null;
const dlg = $('dlg'), aviso = $('aviso');
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.textContent = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 4000 : 9000); }
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; }
const json = (m, b) => ({ method: m, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) });
function campo(l, i) { const d = el('div'); d.append(el('label', { text: l }), i); return d; }
function render(j) {
const s = j.sitio, cont = $('cont'); cont.innerHTML = '';
$('titulo').textContent = s.nombre + (s.cliente ? ' — ' + s.cliente : '');
$('meta').textContent = (s.ubicacion || 'ubicación por confirmar') + ' · ' + j.total_unidades + ' unidad(es) · ' + j.total_actas + ' acta(s)' + (s.activo === false ? ' · INACTIVO' : '');
document.title = s.nombre + ' - Drogheda';
const ficha = el('div', { class: 'ficha' });
const c = (l, v) => ficha.append(el('div', { class: 'c' }, el('div', { class: 'l', text: l }), el('div', { class: 'v', html: v })));
c('Cliente', esc(s.cliente || '—')); c('Ubicación', esc(s.ubicacion || 'por confirmar'));
c('Unidades instaladas', String(j.unidades.filter(u => u.instalada).length) + ' <span style="font-weight:400;color:var(--tinta2);font-size:12.5px">con acta de instalación OK</span>');
c('Con daño o baja', '<span style="color:' + (j.danos.length ? 'var(--falla)' : 'inherit') + '">' + j.danos.length + '</span>');
cont.append(ficha);
if (s.notas) cont.append(el('p', { class: 'sub', style: 'margin:-6px 0 12px', text: s.notas }));
// contactos
const b0 = el('section', { class: 'bloque' });
b0.append(el('h2', { text: 'Contactos' }));
if (!(s.contactos || []).length) b0.append(el('div', { class: 'vacio', text: 'Sin contactos registrados. Pide a Leo que los agregue.' }));
(s.contactos || []).forEach(k => b0.append(el('div', { class: 'contacto', html: '<b>' + esc(k.nombre) + '</b>' + (k.rol ? ' <span class="r">· ' + esc(k.rol) + '</span>' : '') + (k.telefono ? ' · <a href="tel:' + esc(k.telefono) + '">' + esc(k.telefono) + '</a>' : '') + (k.correo ? ' · <a href="mailto:' + esc(k.correo) + '">' + esc(k.correo) + '</a>' : '') })));
cont.append(b0);
// unidades
const b1 = el('section', { class: 'bloque' });
b1.append(el('h2', { text: 'Unidades en este sitio' }), el('p', { class: 'sub', text: 'Toda serie con alguna acta que apunte a este sitio. «Instalada» = fecha de la última acta de instalación aprobada.' }));
if (!j.unidades.length) b1.append(el('div', { class: 'vacio', text: 'Todavía no hay unidades con actas en este sitio. Llena un acta de instalación o de envío con este sitio.' }));
else {
const t = el('table', { class: 't' }); t.innerHTML = '<tr><th>Unidad</th><th>Máquina</th><th>Instalada</th><th>Última acta</th><th>Actas</th><th>Estado</th></tr>';
j.unidades.forEach(u => {
const tr = el('tr');
tr.innerHTML = '<td><a href="/tableros/unidad/?serie=' + encodeURIComponent(u.serie) + '"><b>' + esc(u.serie) + '</b></a>' + (u.coincidencia === 'texto' ? ' <span class="por-texto" title="el acta no trae el sitio; coincide por el nombre del puerto">por texto</span>' : '') + '</td>'
+ '<td>' + esc(u.maquina || '—') + '</td><td>' + (u.instalada ? '<span class="pill ok">' + esc(u.instalada) + '</span>' : '<span class="pill pend">sin acta</span>') + '</td>'
+ '<td>' + esc(NOMBRE[(u.ultima || {}).tipo] || (u.ultima || {}).tipo || '—') + (u.ultima && u.ultima.fecha ? ' · ' + esc(u.ultima.fecha) : '') + (u.ultima && u.ultima.accion ? '<div style="font-size:12px;color:var(--tinta2)">' + esc(u.ultima.accion) + '</div>' : '') + '</td>'
+ '<td>' + u.actas + '</td><td>' + (u.marca ? '<span class="pill ' + (u.marca === 'BAJA' ? 'falla' : 'aviso') + '">' + (u.marca === 'BAJA' ? 'BAJA' : 'DAÑADA') + '</span>' : '<span class="pill ok">sin marca</span>') + '</td>';
t.append(tr);
});
b1.append(el('div', { class: 'tblwrap' }, t));
}
cont.append(b1);
// daños abiertos
if (j.danos.length) {
const b2 = el('section', { class: 'bloque' });
b2.append(el('h2', { text: 'Daños abiertos' }), el('p', { class: 'sub', text: 'Unidades de este sitio con marca vigente. Se levanta con un acta de daño «Reparada».' }));
j.danos.forEach(u => { const a = u.marca_acta || {}; b2.append(el('div', { class: 'ev dano', style: 'padding-left:0', html: '<b><a href="/tableros/unidad/?serie=' + encodeURIComponent(u.serie) + '">' + esc(u.serie) + '</a></b> · ' + esc(a.clasificacion || '') + (a.resumen ? ' — ' + esc(a.resumen) : '') + '<div class="f">' + esc(a.fecha || fmt(a.recibido)) + ' · ' + esc(a.tecnico || '') + (a.accion ? ' · ' + esc(a.accion) : '') + '</div>' })); });
cont.append(b2);
}
// actas
const b3 = el('section', { class: 'bloque' });
b3.append(el('h2', { text: 'Actas de instalación y campo' }), el('p', { class: 'sub', text: 'De la más reciente a la más vieja: instalaciones, diagnósticos, envíos, recepciones y daños.' }));
if (!j.actas.length) b3.append(el('div', { class: 'vacio', text: 'Sin actas.' }));
else {
const lt = el('div', { class: 'linea' });
j.actas.forEach(a => {
const r = String(a.resultado || '');
lt.append(el('div', { class: 'ev ' + a.tipo, html: '<b>' + esc(NOMBRE[a.tipo] || a.tipo) + '</b> — <a href="/tableros/unidad/?serie=' + encodeURIComponent(a.serie) + '">' + esc(a.serie) + '</a>'
+ (a.maquina ? ' · ' + esc(a.maquina) : '') + ' <span class="pill ' + (r === 'FALLA' ? 'falla' : r ? 'ok' : 'pend') + '">' + esc(r || '—') + '</span>'
+ (a.coincidencia === 'texto' ? ' <span class="por-texto">por texto</span>' : '')
+ '<div class="f">' + esc(a.fecha || '') + ' · recibida ' + esc(fmt(a.recibido)) + ' · ' + esc(a.tecnico || '—') + (a.fotos ? ' · ' + a.fotos + ' foto(s)' : '') + '</div>'
+ (a.accion ? '<div>→ ' + esc(a.accion) + '</div>' : '') + (a.motivo ? '<div>Motivo: ' + esc(a.motivo) + '</div>' : '') + (a.resumen ? '<div>' + esc(a.resumen) + '</div>' : '') }));
});
b3.append(lt);
}
cont.append(b3);
}
async function cargar(id) {
$('cont').innerHTML = '<p class="vacio">Cargando…</p>';
try { DATOS = await api('/api/sitio/unidades?id=' + encodeURIComponent(id)); ACTUAL = DATOS.sitio; render(DATOS); $('bEditar').style.display = ADMIN ? '' : 'none'; }
catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo leer el sitio: ' + e.message })); $('meta').textContent = 'Error'; }
}
// ---------- alta / edición (admin) ----------
function formSitio(c, s) {
s = s || {}; const f = {};
f.id = el('input', { type: 'text', maxlength: '30', value: s.id || '', placeholder: 'icave (minúsculas, sin espacios)' }); if (s.id) f.id.disabled = true;
f.nombre = el('input', { type: 'text', maxlength: '80', value: s.nombre || '', placeholder: 'ICAVE' });
f.cliente = el('input', { type: 'text', maxlength: '120', value: s.cliente || '', placeholder: 'Hutchison Ports' });
f.ubicacion = el('input', { type: 'text', maxlength: '200', value: s.ubicacion || '', placeholder: 'Veracruz, Ver.' });
f.alias = el('input', { type: 'text', value: (s.alias || []).join(', '), placeholder: 'LCT, Lázaro Cárdenas (nombres con los que aparece en actas viejas)' });
f.notas = el('textarea', { rows: '2' }); f.notas.value = s.notas || '';
f.activo = el('select'); [['true', 'Activo'], ['false', 'Inactivo (ya no se muestra en los formularios)']].forEach(([v, t]) => f.activo.append(el('option', { value: v, text: t }))); f.activo.value = s.activo === false ? 'false' : 'true';
const g = el('div', { class: 'dos-col' }); g.append(campo('Id (slug)', f.id), campo('Nombre', f.nombre), campo('Cliente', f.cliente), campo('Ubicación', f.ubicacion)); c.append(g);
c.append(campo('Alias (separados por coma)', f.alias));
const ced = el('div', { class: 'contactos-ed' });
function fila(k) { const r = el('div', { class: 'fila' }); r.append(el('input', { type: 'text', value: k.nombre || '', placeholder: 'Nombre' }), el('input', { type: 'text', value: k.rol || '', placeholder: 'Rol' }), el('input', { type: 'text', value: k.telefono || '', placeholder: 'Teléfono' }), el('input', { type: 'text', value: k.correo || '', placeholder: 'Correo' })); const q = el('button', { type: 'button', class: 'sec', 'aria-label': 'Quitar el contacto', html: Kanban.icono('cerrar') }); q.addEventListener('click', () => r.remove()); r.append(q); ced.append(r); }
(s.contactos || []).forEach(fila);
const add = el('button', { type: 'button', class: 'sec', html: Kanban.icono('mas') + 'Contacto', style: 'padding:6px 10px;font-size:13px' }); add.addEventListener('click', () => fila({}));
c.append(campo('Contactos', ced), add, campo('Notas', f.notas), campo('Estado', f.activo));
return () => ({ id: f.id.value.trim().toLowerCase(), nombre: f.nombre.value.trim(), cliente: f.cliente.value.trim(), ubicacion: f.ubicacion.value.trim(),
alias: f.alias.value.split(',').map(x => x.trim()).filter(Boolean), notas: f.notas.value.trim(), activo: f.activo.value === 'true',
contactos: [...ced.querySelectorAll('.fila')].map(r => ({ nombre: r.children[0].value.trim(), rol: r.children[1].value.trim(), telefono: r.children[2].value.trim(), correo: r.children[3].value.trim() })).filter(k => k.nombre) });
}
function dialogo(titulo, s) {
dlg.innerHTML = '';
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo })); 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 leer = formSitio(cuerpo, s);
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: s ? 'Guardar' : 'Crear sitio' });
guardar.addEventListener('click', async () => {
const d = leer();
if (!d.nombre) { m.className = 'msg err'; m.textContent = 'Escribe el nombre.'; return; }
guardar.disabled = true;
try {
if (s) { delete d.id; await api('/api/sitio?id=' + encodeURIComponent(s.id), json('PATCH', d)); dlg.close(); mensaje('Sitio guardado.', true); await cargarLista(); cargar(s.id); }
else { const j = await api('/api/sitios', json('POST', d)); dlg.close(); mensaje('Sitio ' + j.sitio.nombre + ' creado.', true); await cargarLista(); history.replaceState(null, '', '?id=' + encodeURIComponent(j.sitio.id)); $('sel').value = j.sitio.id; cargar(j.sitio.id); }
} 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', '');
}
$('bEditar').addEventListener('click', () => ACTUAL && dialogo('Editar sitio ' + ACTUAL.nombre, ACTUAL));
async function cargarLista() {
const j = await api('/api/sitios?todos=1'); SITIOS = j.sitios || []; ADMIN = !!j.admin;
const sel = $('sel'); sel.innerHTML = ''; sel.append(el('option', { value: '', text: '— elegir sitio —' }));
SITIOS.forEach(s => sel.append(el('option', { value: s.id, text: s.nombre + (s.activo === false ? ' (inactivo)' : '') })));
if (ADMIN) sel.append(el('option', { value: '__nuevo__', text: '+ Nuevo sitio…' }));
}
$('sel').addEventListener('change', () => { const v = $('sel').value; if (v === '__nuevo__') { $('sel').value = ACTUAL ? ACTUAL.id : ''; dialogo('Nuevo sitio', null); } else if (v) { history.replaceState(null, '', '?id=' + encodeURIComponent(v)); cargar(v); } });
(async () => {
try { await cargarLista(); } catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo leer la lista de sitios: ' + e.message })); return; }
const q = new URLSearchParams(location.search);
if (q.get('nuevo') === '1') { if (ADMIN) dialogo('Nuevo sitio', null); else mensaje('Solo el administrador da de alta sitios. Pide a Leo que lo cree.', false); }
const id = q.get('id') || (SITIOS[0] || {}).id;
if (id) { $('sel').value = id; cargar(id); } else { $('meta').textContent = 'No hay sitios dados de alta.'; $('cont').innerHTML = '<p class="vacio">Pide a Leo que cree el primer sitio.</p>'; }
})();
})();
</script>

View File

@ -1,148 +1,530 @@
/* Tableros HBOX (kanban, ingeniería, producción) — misma paleta que tablero.html y acta.css */
:root{
--naranja:#e8590c; --naranja-suave:#fff1e8; --tinta:#1d1c19; --tinta2:#5a564d; --linea:#e6e1d6;
--papel:#faf8f4; --tarjeta:#fff; --superf:#fcfcfb;
--ok:#2f9e44; --ok-bg:#ebfbee; --falla:#e03131; --falla-bg:#fff5f5;
--pend:#adb5bd; --pend-bg:#f4f2ee; --info:#1971c2; --info-bg:#e7f1fb;
--aviso:#f08c00; --aviso-bg:#fff9db; --morado:#7048e8; --morado-bg:#f3f0ff;
--cabecera:#2C3C5B; --cabecera-texto:#fff; --cabecera-meta:#c9d0e0; /* índigo Helgen, igual que la barra del sitio; fijo en claro y oscuro */
/* ==========================================================================
Tableros dentro del sitio (plantilla overrides/tablero.html)
Pendientes · Ingeniería · Producción · Actas · Inventario · Sitios · Equipo ·
Expediente de unidad · Visitas de campo.
Todo vive bajo .tb (el contenedor de cada página) para no chocar con los
estilos de Material. Colores: tokens --hg-* de stylesheets/helgen.css.
========================================================================== */
.tb {
/* nombres viejos que todavía usan los scripts de las páginas en estilos en línea */
--tinta: var(--hg-texto);
--tinta2: var(--hg-texto-2);
--linea: var(--hg-linea);
--tarjeta: var(--hg-plano);
--superf: var(--hg-plano-2);
--papel: var(--hg-fondo);
--naranja: var(--hg-liga);
--ok: var(--hg-hecho); --ok-bg: var(--hg-hecho-bg);
--apro: var(--hg-hecho); --apro-bg: var(--hg-hecho-bg);
--falla: var(--hg-bloqueado); --falla-bg: var(--hg-bloqueado-bg);
--info: var(--hg-info); --info-bg: var(--hg-info-bg);
--ens: var(--hg-info); --ens-bg: var(--hg-info-bg);
--pend: var(--hg-pend); --pend-bg: var(--hg-pend-bg);
--aviso: var(--hg-aviso); --aviso-bg: var(--hg-aviso-bg);
font-family: var(--hg-fuente);
font-size: 15px;
line-height: 1.45;
color: var(--hg-texto);
}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){
--papel:#191b1e;--tinta:#e6e1d6;--tinta2:#a09a8d;--linea:#33363b;--tarjeta:#1f2226;--superf:#1b1d20;
--naranja-suave:#2e2218;--ok:#69db7c;--ok-bg:#1b2a1f;--falla:#ff8787;--falla-bg:#2c1d1d;--pend:#5c636a;--pend-bg:#26282c;
--info:#74c0fc;--info-bg:#182734;--aviso:#ffc078;--aviso-bg:#2e2712;--morado:#b197fc;--morado-bg:#241d33;
--cabecera:#1F2A44;--cabecera-texto:#fff;--cabecera-meta:#b8c0d4;}}
*{box-sizing:border-box}
body{margin:0;background:var(--papel);color:var(--tinta);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%}
.wrap{max-width:1240px;margin:0 auto;padding:16px 16px 70px}
header.top{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;
background:var(--cabecera);color:var(--cabecera-texto);padding:14px 16px;border-radius:12px;margin-bottom:14px}
header.top h1{margin:0;font-size:20px}
header.top .meta{color:var(--cabecera-meta);font-size:13px}
header.top nav{display:flex;flex-wrap:wrap;gap:4px 14px;align-items:center}
header.top nav a{color:var(--cabecera-texto);font-size:13px;text-decoration:none;opacity:.85}
header.top nav a.activo{opacity:1;font-weight:700;border-bottom:2px solid var(--naranja)}
button{background:var(--naranja);color:#fff;border:0;border-radius:9px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer}
button:disabled{opacity:.5;cursor:not-allowed}
button.sec{background:var(--tarjeta);color:var(--tinta2);border:1px solid var(--linea)}
button.sec:hover{border-color:var(--naranja);color:var(--naranja)}
button.peligro{background:var(--tarjeta);color:var(--falla);border:1px solid var(--falla)}
select,input,textarea{padding:9px 11px;border:1px solid var(--linea);border-radius:9px;font-size:15px;background:var(--tarjeta);color:var(--tinta);max-width:100%}
input:focus,select:focus,textarea:focus{outline:2px solid var(--naranja);border-color:var(--naranja)}
label{display:block;font-weight:600;font-size:13px;margin:10px 0 4px;color:var(--tinta2)}
section.bloque{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:16px;margin:14px 0}
section.bloque h2{margin:0 0 4px;font-size:17px}
section.bloque .sub{color:var(--tinta2);font-size:13.5px;margin:0 0 10px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:14px}
.kpi{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:12px 14px}
.kpi .n{font-size:28px;font-weight:700;line-height:1}
.kpi .l{color:var(--tinta2);font-size:12.5px;margin-top:4px}
.kpi.mal .n{color:var(--falla)} .kpi.bien .n{color:var(--ok)}
.err{background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla);padding:10px 14px;border-radius:10px;margin:10px 0}
.vacio{color:var(--tinta2);font-style:italic;font-size:14px}
.pill{display:inline-block;font-size:11.5px;font-weight:700;padding:2px 9px;border-radius:99px;white-space:nowrap;vertical-align:middle}
.pill.ok{background:var(--ok-bg);color:var(--ok)} .pill.falla{background:var(--falla-bg);color:var(--falla)}
.pill.info{background:var(--info-bg);color:var(--info)} .pill.pend{background:var(--pend-bg);color:var(--tinta2)}
.pill.aviso{background:var(--aviso-bg);color:#7a4a00}
table.t{border-collapse:collapse;width:100%;font-size:13.5px}
table.t th{text-align:left;color:var(--tinta2);font-size:11.5px;text-transform:uppercase;letter-spacing:.05em;padding:6px 8px 6px 0;border-bottom:1px solid var(--tinta2)}
table.t td{padding:7px 8px 7px 0;border-bottom:1px solid var(--linea);vertical-align:top}
.tblwrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
a{color:var(--naranja)}
.md-typeset .tb { font-size: 15px; line-height: 1.45; }
.tb *, .tb *::before, .tb *::after { box-sizing: border-box; }
/* ---- "Cómo se usa": bloque plegable al inicio de cada página ---- */
details.como{background:var(--info-bg);border:1px solid #c9dcec;border-radius:10px;padding:8px 14px;margin:0 0 14px;font-size:14px}
details.como summary{cursor:pointer;font-weight:700;color:var(--info)}
details.como ol{margin:8px 0 4px;padding-left:20px} details.como li{margin:3px 0}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]) details.como{border-color:#2d4760}}
/* ---- Encabezado de la página -------------------------------------------- */
.tb-cab {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 10px 16px;
margin: 2px 0 16px;
}
.tb-cab__t { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; min-width: 0; }
.md-typeset .tb .tb-cab h1 {
margin: 0;
font-family: var(--hg-fuente-titulo);
font-weight: 700;
font-size: 34px;
line-height: 1.05;
letter-spacing: -0.012em;
color: var(--hg-texto-fuerte);
}
.tb .tb-cab .meta, .tb-cab__meta { font-size: 14px; color: var(--hg-texto-2); }
.tb-cab__acc { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
/* ---- Kanban ---- */
.kb-barra{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 12px}
.kb-barra select,.kb-barra input{padding:8px 10px;font-size:14px}
.kb-barra .cnt{color:var(--tinta2);font-size:13px;margin-left:auto}
.kb{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;align-items:start}
@media(max-width:980px){.kb{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:560px){.kb{grid-template-columns:1fr}}
.col{background:var(--pend-bg);border:1px solid var(--linea);border-radius:12px;padding:8px;min-height:120px}
.col.sobre{outline:2px dashed var(--naranja);outline-offset:-4px;background:var(--naranja-suave)}
.col h3{margin:2px 4px 8px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--tinta2);display:flex;justify-content:space-between}
.col h3 .n{background:var(--tarjeta);border:1px solid var(--linea);border-radius:99px;padding:0 8px;font-size:12px}
.col.e-bloqueado h3{color:var(--falla)} .col.e-hecho h3{color:var(--ok)} .col.e-en-curso h3{color:var(--info)}
.card{background:var(--tarjeta);border:1px solid var(--linea);border-left:4px solid var(--pend);border-radius:10px;padding:9px 10px;margin:0 0 8px;cursor:grab;font-size:14px;position:relative}
.card:active{cursor:grabbing}
.card.arrastrando{opacity:.45}
.card.p-ingenieria{border-left-color:var(--morado)} .card.p-produccion{border-left-color:var(--ok)}
.card.p-mecanica{border-left-color:var(--aviso)} .card.p-compras{border-left-color:var(--info)} .card.p-campo{border-left-color:var(--naranja)}
.card.vencida{box-shadow:inset 0 0 0 1px var(--falla)}
.card .tit{font-weight:700;line-height:1.3;margin-bottom:5px;cursor:pointer}
.card .tit:hover{color:var(--naranja)}
.card .meta{display:flex;flex-wrap:wrap;gap:4px 8px;font-size:12px;color:var(--tinta2);align-items:center}
.card .chip{display:inline-block;font-size:11px;font-weight:700;padding:1px 7px;border-radius:99px;background:var(--pend-bg);color:var(--tinta2)}
.card .chip.p-ingenieria{background:var(--morado-bg);color:var(--morado)} .card .chip.p-produccion{background:var(--ok-bg);color:var(--ok)}
.card .chip.p-mecanica{background:var(--aviso-bg);color:#7a4a00} .card .chip.p-compras{background:var(--info-bg);color:var(--info)}
.card .chip.p-campo{background:var(--naranja-suave);color:var(--naranja)}
.card .fecha.mal{color:var(--falla);font-weight:700}
.card .bloq{margin-top:5px;font-size:12px;color:var(--falla)}
.card .enl{margin-top:5px;display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px}
.card .mover{margin-top:7px;display:flex;gap:6px;align-items:center}
.card .mover select{padding:5px 7px;font-size:12.5px;flex:1}
@media(min-width:981px){.card .mover{display:none} .kb.movil .card .mover{display:flex}}
.badge-v3,.badge-v4,.badge-v55,.badge-v56,.badge-v57,.badge-v6{display:inline-block;padding:0 7px;border-radius:999px;font-size:11px;font-weight:700;line-height:1.7;white-space:nowrap}
.badge-v56{background:#2C3C5B;color:#fff} .badge-v55{background:#17A08C;color:#fff}
.badge-v57{background:#e3f5e8;color:#1c5c2e;border:1px solid #8fd1a3} .badge-v6{background:#fff3cd;color:#7a5200;border:1px solid #f0c36d}
.badge-v4{background:#EDEFF4;color:#2C3C5B;border:1px solid #c9d0e0} .badge-v3{background:#e9e9e9;color:#555;border:1px solid #ccc}
/* «Cómo se usa»: el bloque plegable de Material, más compacto */
.md-typeset .tb details.tb-como { margin: 0 0 16px; font-size: 14px; }
.md-typeset .tb details.tb-como ol { margin: 6px 0 10px 1.2em; }
.md-typeset .tb details.tb-como li { margin: 4px 0; }
/* ---- diálogo de detalle / alta ---- */
dialog{border:1px solid var(--linea);border-radius:14px;padding:0;max-width:720px;width:calc(100vw - 24px);background:var(--tarjeta);color:var(--tinta)}
dialog::backdrop{background:rgba(0,0,0,.45)}
dialog .dcab{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--linea);background:var(--cabecera);color:var(--cabecera-texto);border-radius:14px 14px 0 0}
dialog .dcab h2{margin:0;font-size:17px}
dialog .dcab button{background:transparent;color:#fff;font-size:20px;padding:2px 8px}
dialog .dcuerpo{padding:6px 18px 14px;max-height:calc(100vh - 160px);overflow:auto}
dialog .dpie{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end;padding:12px 18px;border-top:1px solid var(--linea)}
dialog .dpie .izq{margin-right:auto}
.dos-col{display:grid;grid-template-columns:1fr 1fr;gap:0 14px}
@media(max-width:560px){.dos-col{grid-template-columns:1fr}}
dialog input,dialog select,dialog textarea{width:100%}
.enlaces-ed .fila{display:grid;grid-template-columns:1fr 1.4fr auto;gap:6px;margin-bottom:6px}
.enlaces-ed .fila button{padding:6px 9px;font-size:13px}
.lista-chk{max-height:140px;overflow:auto;border:1px solid var(--linea);border-radius:9px;padding:6px 10px;font-size:13.5px}
.lista-chk label{display:flex;gap:8px;align-items:flex-start;font-weight:500;margin:4px 0;color:var(--tinta)}
.lista-chk input{width:auto;margin-top:3px}
/* responsables: chips seleccionables (miembros del equipo) */
.chips{display:flex;flex-wrap:wrap;gap:6px}
.chip-sel{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--linea);border-radius:99px;padding:5px 11px;font-size:13.5px;font-weight:500;cursor:pointer;background:var(--superf);margin:0;color:var(--tinta)}
.chip-sel input{width:auto;margin:0;accent-color:var(--naranja)}
.chip-sel.on{border-color:var(--naranja);background:var(--naranja-suave);color:var(--naranja);font-weight:700}
.hist{font-size:13px;color:var(--tinta2);max-height:180px;overflow:auto;border-top:1px solid var(--linea);margin-top:12px;padding-top:8px}
.hist div{padding:3px 0;border-bottom:1px dashed var(--linea)}
.hist b{color:var(--tinta)}
.msg{display:none;padding:10px 12px;border-radius:9px;margin-top:10px;font-weight:600;font-size:14px}
.msg.ok{display:block;background:var(--ok-bg);color:var(--ok);border:1px solid var(--ok)}
.msg.err{display:block;background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla)}
/* los <details> que arman los scripts (detalle de un acta) van sin caja */
.md-typeset .tb details:not(.tb-como) {
margin: 8px 0 0;
padding: 0;
border: 0;
background: none;
box-shadow: none;
font-size: inherit;
overflow: visible;
}
.md-typeset .tb details:not(.tb-como) > summary {
padding: 0;
margin: 0;
min-height: 0;
background: none;
border: 0;
font-size: 13px;
font-weight: 600;
color: var(--hg-liga);
cursor: pointer;
}
.md-typeset .tb details:not(.tb-como) > summary::before,
.md-typeset .tb details:not(.tb-como) > summary::after { display: none; }
.md-typeset .tb details:not(.tb-como) > summary:hover { color: var(--hg-liga-hover); }
/* ---- tablero de ingeniería: tira de versiones ---- */
.versiones{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px;margin-bottom:14px}
.ver{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:14px 16px;border-top:4px solid var(--pend)}
.ver.v55{border-top-color:#17A08C} .ver.v56{border-top-color:#2C3C5B} .ver.v57{border-top-color:#1c5c2e} .ver.v6{border-top-color:#c99a1a}
.ver h3{margin:0 0 4px;font-size:17px;display:flex;justify-content:space-between;align-items:center;gap:8px}
.ver .est{font-weight:700;font-size:13.5px}
.ver .sig{font-size:13.5px;margin:6px 0} .ver .sig b{color:var(--naranja)}
.ver .quien{font-size:12.5px;color:var(--tinta2)}
.ver .docs{margin-top:8px;display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12.5px}
.ver .cnt{font-size:12.5px;color:var(--tinta2);margin-top:6px}
.decisiones li{margin:5px 0;font-size:14px} .decisiones b{color:var(--tinta)}
.filosofia li{margin:6px 0;font-size:14px}
/* ---- Controles ----------------------------------------------------------- */
.tb button,
.tb .boton {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--hg-acento);
background: var(--hg-acento);
color: var(--hg-sobre-acento);
font: inherit;
font-size: 14px;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.tb button:hover:not(:disabled) { filter: brightness(1.07); }
.tb button:disabled { opacity: 0.5; cursor: not-allowed; }
.tb button.sec,
.tb .boton.sec {
background: transparent;
border-color: var(--hg-linea-2);
color: var(--hg-texto);
font-weight: 600;
}
.tb button.sec:hover:not(:disabled),
.tb .boton.sec:hover { background: var(--hg-elevado); filter: none; color: var(--hg-texto-fuerte); text-decoration: none; }
.tb button.peligro {
background: transparent;
border-color: color-mix(in srgb, var(--hg-bloqueado) 55%, transparent);
color: var(--hg-bloqueado);
font-weight: 600;
}
.tb button.peligro:hover:not(:disabled) { background: var(--hg-bloqueado-bg); filter: none; }
.tb button.fantasma { background: transparent; border-color: transparent; color: var(--hg-texto-2); font-weight: 500; }
.tb button.fantasma:hover:not(:disabled) { color: var(--hg-texto-fuerte); background: var(--hg-elevado); filter: none; }
.tb button.icono { padding: 6px; width: 32px; height: 32px; justify-content: center; }
.tb button svg, .tb .boton svg { width: 15px; height: 15px; flex: none; }
/* ---- tablero de producción ---- */
.prog{height:14px;border-radius:7px;background:var(--pend-bg);overflow:hidden;margin:6px 0 4px;display:flex}
.prog span{display:block;height:100%;background:var(--ok)}
.prog span.pl{background:var(--info)}
.obj{display:flex;justify-content:space-between;font-size:13px;color:var(--tinta2)}
.embudo{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px}
.etapa{background:var(--superf);border:1px solid var(--linea);border-radius:10px;padding:10px 10px;text-align:center}
.etapa .n{font-size:26px;font-weight:700;line-height:1}
.etapa .l{font-size:12px;color:var(--tinta2);margin-top:3px;line-height:1.25}
.etapa .f{font-size:11px;color:var(--tinta2);margin-top:3px;opacity:.8}
.etapa.sin .n{color:var(--pend)}
.bloqueos .card{cursor:default}
.tb select,
.tb input:not([type="checkbox"]):not([type="radio"]),
.tb textarea {
padding: 8px 11px;
border: 1px solid var(--hg-linea-2);
border-radius: var(--hg-radio-campo);
background: var(--hg-plano);
color: var(--hg-texto);
font: inherit;
font-size: 14px;
max-width: 100%;
}
.tb select { padding-right: 28px; }
.tb input::placeholder, .tb textarea::placeholder { color: var(--hg-texto-3); }
.tb input:focus, .tb select:focus, .tb textarea:focus { outline: 2px solid var(--hg-foco); outline-offset: 1px; border-color: transparent; }
.tb input[type="checkbox"], .tb input[type="radio"] { accent-color: var(--hg-teal); }
.tb label { display: block; font-weight: 600; font-size: 13px; margin: 12px 0 5px; color: var(--hg-texto-2); }
/* ---- Bloques, cifras, avisos -------------------------------------------- */
.tb section.bloque {
background: var(--hg-plano-2);
border: 1px solid var(--hg-linea);
border-radius: 12px;
padding: 16px 18px;
margin: 14px 0;
}
.md-typeset .tb section.bloque h2,
.md-typeset .tb h2 {
margin: 0 0 4px;
font-family: var(--hg-fuente-titulo);
font-weight: 600;
font-size: 21px;
line-height: 1.2;
letter-spacing: 0;
color: var(--hg-texto-fuerte);
}
.md-typeset .tb h3 { margin: 0 0 6px; font-size: 16px; line-height: 1.3; color: var(--hg-texto-fuerte); }
.md-typeset .tb p { margin: 0 0 10px; }
.tb .sub, .tb .resumen { color: var(--hg-texto-2); font-size: 14px; margin: 0 0 12px; }
.md-typeset .tb ul, .md-typeset .tb ol { margin: 6px 0 0 1.25em; }
.md-typeset .tb li { margin: 0 0 6px; }
.tb .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 16px; }
.tb .kpi { background: var(--hg-plano); border: 1px solid var(--hg-linea); border-radius: 12px; padding: 13px 15px; }
.tb .kpi .n { font-family: var(--hg-fuente-titulo); font-size: 30px; font-weight: 600; line-height: 1; color: var(--hg-texto-fuerte); font-variant-numeric: tabular-nums; }
.tb .kpi .l { color: var(--hg-texto-2); font-size: 13px; margin-top: 6px; line-height: 1.3; }
.tb .kpi.mal .n { color: var(--hg-bloqueado); }
.tb .kpi.bien .n { color: var(--hg-hecho); }
.tb .err { background: var(--hg-bloqueado-bg); color: var(--hg-bloqueado); border: 1px solid color-mix(in srgb, var(--hg-bloqueado) 45%, transparent); padding: 10px 14px; border-radius: 10px; margin: 10px 0; }
.tb .vacio { color: var(--hg-texto-3); font-size: 14px; }
.tb .msg { display: none; padding: 10px 12px; border-radius: 9px; margin: 10px 0; font-weight: 600; font-size: 14px; }
.tb .msg.ok { display: block; background: var(--hg-hecho-bg); color: var(--hg-hecho); border: 1px solid color-mix(in srgb, var(--hg-hecho) 40%, transparent); }
.tb .msg.err { display: block; background: var(--hg-bloqueado-bg); color: var(--hg-bloqueado); border: 1px solid color-mix(in srgb, var(--hg-bloqueado) 45%, transparent); }
.tb .pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 2px 9px; border-radius: 6px; white-space: nowrap; vertical-align: middle; background: var(--hg-pend-bg); color: var(--hg-texto-2); }
.tb .pill.ok, .tb .pill.apro { background: var(--hg-hecho-bg); color: var(--hg-hecho); }
.tb .pill.falla { background: var(--hg-bloqueado-bg); color: var(--hg-bloqueado); }
.tb .pill.info { background: var(--hg-info-bg); color: var(--hg-info); }
.tb .pill.aviso { background: var(--hg-aviso-bg); color: var(--hg-aviso); }
.tb .pill.pend { background: var(--hg-pend-bg); color: var(--hg-texto-2); }
/* tablas */
.tb .tblwrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.md-typeset .tb table.t,
.md-typeset .tb table:not([class]) {
display: table;
border: 0;
border-collapse: collapse;
border-radius: 0;
background: none;
width: 100%;
font-size: 14px;
}
.md-typeset .tb table.t th,
.md-typeset .tb table:not([class]) th {
text-align: left;
color: var(--hg-texto-3);
font-size: 12.5px;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
padding: 6px 10px 7px 0;
min-width: 0;
background: none;
border-bottom: 1px solid var(--hg-linea-2);
}
.md-typeset .tb table.t td,
.md-typeset .tb table:not([class]) td { padding: 8px 10px 8px 0; border-top: 0; border-bottom: 1px solid var(--hg-linea); vertical-align: top; }
.md-typeset .tb table.t tr:hover,
.md-typeset .tb table:not([class]) tbody tr:hover { background: none; box-shadow: none; }
/* versión de tarjeta: etiqueta de serigrafía (helgen.css) a tamaño de tablero */
.tb .badge-v3, .tb .badge-v4, .tb .badge-v55, .tb .badge-v56, .tb .badge-v57, .tb .badge-v6 { font-size: 11.5px; padding: 3px 6px; vertical-align: middle; }
/* avatares del equipo */
.tb .avs { display: inline-flex; align-items: center; }
.tb .av {
width: 22px; height: 22px;
border-radius: 50%;
display: inline-grid;
place-items: center;
flex: none;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.02em;
color: #FFFFFF;
background: #4A5468;
box-shadow: 0 0 0 2px var(--hg-plano);
}
.tb .avs .av + .av { margin-left: -6px; }
.tb .av.libre { background: transparent; color: var(--hg-texto-2); box-shadow: inset 0 0 0 1px var(--hg-linea-2); }
.tb .persona { display: inline-flex; align-items: center; gap: 7px; }
/* íconos de línea en el tablero */
.tb .i { width: 14px; height: 14px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* ---- Tablero de pendientes ---------------------------------------------- */
.tb .kb-barra { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 14px; }
.tb .kb-barra select, .tb .kb-barra input { padding: 7px 10px; font-size: 13.5px; }
.tb .kb-barra input[type="search"] { flex: 0 1 210px; min-width: 140px; }
.tb .kb-barra .cnt { color: var(--hg-texto-3); font-size: 13px; }
.tb .kb-barra .kb-nuevo { margin-left: auto; }
.tb .kb-solo {
display: inline-flex; align-items: center; gap: 7px;
padding: 7px 12px; border-radius: 99px;
border: 1px solid var(--hg-linea-2); background: transparent; color: var(--hg-texto-suave);
font-size: 13.5px; font-weight: 500;
}
.tb .kb-solo[aria-pressed="true"] { background: var(--hg-curso-bg); border-color: color-mix(in srgb, var(--hg-curso) 45%, transparent); color: var(--hg-curso); }
.tb .kb-solo:hover:not(:disabled) { filter: none; color: var(--hg-texto-fuerte); }
.tb .kb { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; align-items: start; }
@media (max-width: 1100px) { .tb .kb { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .tb .kb { grid-template-columns: 1fr; } }
.tb .kb.compacto { gap: 12px; }
.tb .col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 90px; border-radius: 10px; }
.tb .col.sobre { outline: 2px dashed color-mix(in srgb, var(--hg-acento) 60%, transparent); outline-offset: 4px; }
.md-typeset .tb .col h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 0 2px 8px;
border-bottom: 1px solid var(--hg-linea);
font-family: var(--hg-fuente);
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
text-transform: none;
color: var(--hg-texto);
}
.tb .col h3 .n { font-weight: 400; font-size: 13px; color: var(--hg-texto-3); font-variant-numeric: tabular-nums; }
.tb .col h3 svg { width: 16px; height: 16px; flex: none; }
.tb .col .kb-mas {
align-self: flex-start;
background: transparent; border: 0; color: var(--hg-texto-3);
font-size: 13.5px; font-weight: 500; padding: 6px 4px;
}
.tb .col .kb-mas:hover:not(:disabled) { color: var(--hg-texto-fuerte); filter: none; }
.tb .card {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 12px 11px;
background: var(--hg-plano);
border: 1px solid var(--hg-linea);
border-radius: 10px;
font-size: 14px;
cursor: grab;
}
.tb .card:hover { border-color: var(--hg-linea-2); }
.tb .card:active { cursor: grabbing; }
.tb .card.arrastrando { opacity: 0.45; }
.tb .card.vencida { border-color: color-mix(in srgb, var(--hg-bloqueado) 45%, var(--hg-linea)); }
.tb .card.e-hecho .tit { color: var(--hg-texto-2); }
.tb .card .sup { display: flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--hg-texto-2); min-width: 0; }
.tb .card .sup .ver-t { margin-left: auto; }
.tb .pista-p { width: 8px; height: 8px; border-radius: 2px; flex: none; background: var(--hg-texto-3); display: inline-block; }
.tb .p-ingenieria .pista-p, .tb .pista-p.p-ingenieria { background: var(--hg-p-ingenieria); }
.tb .p-produccion .pista-p, .tb .pista-p.p-produccion { background: var(--hg-p-produccion); }
.tb .p-mecanica .pista-p, .tb .pista-p.p-mecanica { background: var(--hg-p-mecanica); }
.tb .p-compras .pista-p, .tb .pista-p.p-compras { background: var(--hg-p-compras); }
.tb .p-campo .pista-p, .tb .pista-p.p-campo { background: var(--hg-p-campo); }
.tb .card .tit { font-weight: 600; font-size: 14.5px; line-height: 1.36; color: var(--hg-texto); cursor: pointer; overflow-wrap: anywhere; }
.tb .card .tit:hover { color: var(--hg-liga-hover); }
.tb .card .tit a { color: inherit; text-decoration: none; }
.tb .card .nota { display: flex; gap: 6px; align-items: flex-start; font-size: 12.5px; line-height: 1.35; color: var(--hg-aviso); }
.tb .card .nota.mal { color: var(--hg-bloqueado); }
.tb .card .nota .i { margin-top: 1px; }
.tb .card .enl { display: flex; flex-direction: column; gap: 3px; font-size: 12.5px; }
.tb .card .enl a { display: inline-flex; align-items: center; gap: 6px; color: var(--hg-liga); min-width: 0; }
.tb .card .enl a span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tb .card .etq { display: flex; flex-wrap: wrap; gap: 4px; }
.tb .chip { display: inline-block; font-size: 11.5px; font-weight: 500; padding: 1px 7px; border-radius: 5px; background: var(--hg-pend-bg); color: var(--hg-texto-2); white-space: nowrap; }
.tb .chip.p-ingenieria, .tb .chip.p-produccion, .tb .chip.p-mecanica, .tb .chip.p-compras, .tb .chip.p-campo { background: none; padding: 0; color: var(--hg-texto-2); display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; }
.tb .chip.p-ingenieria::before, .tb .chip.p-produccion::before, .tb .chip.p-mecanica::before, .tb .chip.p-compras::before, .tb .chip.p-campo::before { content: ""; width: 8px; height: 8px; border-radius: 2px; background: var(--hg-texto-3); }
.tb .chip.p-ingenieria::before { background: var(--hg-p-ingenieria); }
.tb .chip.p-produccion::before { background: var(--hg-p-produccion); }
.tb .chip.p-mecanica::before { background: var(--hg-p-mecanica); }
.tb .chip.p-compras::before { background: var(--hg-p-compras); }
.tb .chip.p-campo::before { background: var(--hg-p-campo); }
.tb .card .pie { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.tb .card .fecha { font-size: 12.5px; color: var(--hg-texto-2); white-space: nowrap; }
.tb .card .fecha.hoy { color: var(--hg-acento); font-weight: 600; }
.tb .card .fecha.mal { color: var(--hg-bloqueado); font-weight: 600; }
.tb .card .meta { display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 12.5px; color: var(--hg-texto-2); align-items: center; }
.tb .card .mover { display: flex; gap: 6px; align-items: center; }
.tb .card .mover select { padding: 5px 8px; font-size: 13px; flex: 1; }
@media (min-width: 981px) { .tb .card .mover { display: none; } .tb .kb.movil .card .mover { display: flex; } }
/* ---- Diálogo (detalle y alta) -------------------------------------------- */
.tb dialog {
border: 1px solid var(--hg-linea-2);
border-radius: 14px;
padding: 0;
max-width: 760px;
width: calc(100vw - 24px);
background: var(--hg-plano-2);
color: var(--hg-texto);
box-shadow: var(--hg-sombra);
font-size: 15px;
}
.tb dialog::backdrop { background: rgba(5, 9, 18, 0.62); backdrop-filter: blur(1.5px); }
.tb dialog .dcab { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 16px 20px 10px; }
.md-typeset .tb dialog .dcab h2 { margin: 0; font-family: var(--hg-fuente-titulo); font-size: 22px; font-weight: 600; line-height: 1.15; }
.tb dialog .dcab .dcab-id { font-family: var(--hg-fuente-mono); font-size: 12.5px; color: var(--hg-texto-3); margin-right: auto; padding-left: 4px; }
.tb dialog .dcab button { background: transparent; border-color: transparent; color: var(--hg-texto-2); padding: 6px; width: 32px; height: 32px; justify-content: center; }
.tb dialog .dcab button:hover { background: var(--hg-elevado); color: var(--hg-texto-fuerte); filter: none; }
.tb dialog .dcuerpo { padding: 2px 20px 16px; max-height: calc(100vh - 180px); overflow: auto; }
.tb dialog .dpie { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; align-items: center; padding: 12px 20px; border-top: 1px solid var(--hg-linea); }
.tb dialog .dpie .izq { margin-right: auto; }
.tb .dos-col { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
@media (max-width: 560px) { .tb .dos-col { grid-template-columns: 1fr; } }
.tb dialog input:not([type="checkbox"]):not([type="radio"]), .tb dialog select, .tb dialog textarea { width: 100%; }
.tb .enlaces-ed .fila { display: grid; grid-template-columns: 1fr 1.4fr auto; gap: 6px; margin-bottom: 6px; }
.tb .enlaces-ed .fila button { padding: 6px 9px; }
.tb .lista-chk { max-height: 150px; overflow: auto; border: 1px solid var(--hg-linea); border-radius: 9px; padding: 6px 10px; font-size: 13.5px; background: var(--hg-plano); }
.tb .lista-chk label { display: flex; gap: 8px; align-items: flex-start; font-weight: 500; margin: 4px 0; color: var(--hg-texto); font-size: 13.5px; }
.tb .lista-chk input { width: auto; margin-top: 3px; }
.tb .chips { display: flex; flex-wrap: wrap; gap: 6px; }
.tb .chip-sel {
display: inline-flex; align-items: center; gap: 7px;
border: 1px solid var(--hg-linea-2); border-radius: 99px;
padding: 4px 11px 4px 5px; margin: 0;
font-size: 13.5px; font-weight: 500; cursor: pointer;
background: var(--hg-plano); color: var(--hg-texto);
}
.tb .chip-sel input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.tb .chip-sel.on { border-color: color-mix(in srgb, var(--hg-curso) 50%, transparent); background: var(--hg-curso-bg); color: var(--hg-texto-fuerte); font-weight: 600; }
.tb .chip-sel:focus-within { outline: 2px solid var(--hg-foco); outline-offset: 1px; }
.tb .hist { font-size: 13px; color: var(--hg-texto-2); max-height: 200px; overflow: auto; border-top: 1px solid var(--hg-linea); margin-top: 14px; padding-top: 8px; }
.tb .hist div { padding: 4px 0; border-bottom: 1px dashed var(--hg-linea); }
.tb .hist b { color: var(--hg-texto); }
/* ---- Ingeniería: una tarjeta por versión --------------------------------- */
.tb .versiones { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 12px; margin-bottom: 14px; }
.tb .versiones .ver { background: var(--hg-plano); border: 1px solid var(--hg-linea); border-radius: 12px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; }
.md-typeset .tb .versiones .ver h3 { margin: 0; font-family: var(--hg-fuente-titulo); font-size: 19px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.tb .versiones .ver .est { font-weight: 600; font-size: 13.5px; color: var(--hg-texto); }
.tb .versiones .ver .sig { font-size: 13.5px; color: var(--hg-texto-suave); }
.tb .versiones .ver .sig b { color: var(--hg-texto-fuerte); }
.tb .versiones .ver .quien { font-size: 12.5px; color: var(--hg-texto-2); display: flex; gap: 6px; align-items: flex-start; }
.tb .versiones .ver .docs { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; }
.tb .versiones .ver .cnt { font-size: 13px; color: var(--hg-texto-3); margin-top: auto; padding-top: 6px; border-top: 1px solid var(--hg-linea); }
.md-typeset .tb .decisiones li { margin: 6px 0; font-size: 14px; color: var(--hg-texto-suave); }
.md-typeset .tb .decisiones b { color: var(--hg-texto-fuerte); }
.md-typeset .tb .filosofia li { margin: 7px 0; font-size: 14px; color: var(--hg-texto-suave); }
/* ---- Producción ---------------------------------------------------------- */
.tb .prog { height: 10px; border-radius: 5px; background: var(--hg-pend-bg); overflow: hidden; margin: 8px 0 4px; display: flex; }
.tb .prog span { display: block; height: 100%; background: var(--hg-hecho); }
.tb .prog span.pl { background: var(--hg-info); }
.tb .obj { display: flex; justify-content: space-between; font-size: 13px; color: var(--hg-texto-2); }
.tb .embudo { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 8px; }
.tb .etapa { background: var(--hg-plano); border: 1px solid var(--hg-linea); border-radius: 10px; padding: 11px 11px; text-align: center; color: inherit; text-decoration: none; }
.tb a.etapa:hover { border-color: var(--hg-linea-2); text-decoration: none; }
.tb .etapa .n { font-family: var(--hg-fuente-titulo); font-size: 26px; font-weight: 600; line-height: 1; color: var(--hg-texto-fuerte); }
.tb .etapa .l { font-size: 12.5px; color: var(--hg-texto-2); margin-top: 4px; line-height: 1.25; }
.tb .etapa .f { font-size: 11.5px; color: var(--hg-texto-3); margin-top: 3px; }
.tb .etapa.sin .n { color: var(--hg-texto-3); }
.tb .bloqueos .card { cursor: default; }
/* ---- Equipo ---------------------------------------------------------------- */
.tb table.t td.num, .tb table.t th.num { text-align: right; font-variant-numeric: tabular-nums; }
.tb table.t tr.inactivo td { opacity: 0.55; }
.tb table.t td .nom { font-weight: 700; cursor: pointer; color: var(--hg-liga); display: inline-flex; align-items: center; gap: 8px; }
.tb table.t td .nom:hover { color: var(--hg-liga-hover); text-decoration: underline; }
.tb table.t td .id { font-size: 12px; color: var(--hg-texto-3); display: block; margin-top: 2px; }
.tb table.t td button { padding: 5px 9px; font-size: 12.5px; margin: 1px 2px 1px 0; }
.tb .area { display: inline-block; font-size: 12px; font-weight: 500; padding: 1px 8px; border-radius: 5px; background: var(--hg-pend-bg); color: var(--hg-texto-2); }
.tb .pass-una-vez { background: var(--hg-aviso-bg); border: 1px solid color-mix(in srgb, var(--hg-aviso) 45%, transparent); border-radius: 10px; padding: 12px 14px; margin-top: 10px; font-size: 14.5px; color: var(--hg-texto); }
.tb .pass-una-vez code { font-family: var(--hg-fuente-mono); font-size: 17px; font-weight: 600; letter-spacing: 0.04em; user-select: all; }
.tb .mis-pend .card { cursor: default; }
/* ---- Inventario ------------------------------------------------------------ */
.tb table.t tr.alerta td.num.stock { color: var(--hg-bloqueado); font-weight: 700; }
.tb table.t td .cod { font-family: var(--hg-fuente-mono); font-weight: 600; cursor: pointer; color: var(--hg-liga); }
.tb table.t td .cod:hover { text-decoration: underline; }
.tb table.t td .cat { font-size: 12px; color: var(--hg-texto-3); display: block; }
.tb .acciones { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 12px; }
.tb .acciones .cnt { color: var(--hg-texto-3); font-size: 13px; margin-left: auto; }
.tb .acciones label { margin: 0; display: inline-flex; gap: 6px; align-items: center; font-weight: 500; color: var(--hg-texto-2); }
.tb .mov-fila { display: grid; grid-template-columns: 120px 80px 90px 1fr; gap: 6px 10px; padding: 7px 0; border-bottom: 1px dashed var(--hg-linea); font-size: 13px; align-items: baseline; }
.tb .mov-fila .d { font-variant-numeric: tabular-nums; }
.tb .mov-fila .pos { color: var(--hg-hecho); font-weight: 700; }
.tb .mov-fila .neg { color: var(--hg-bloqueado); font-weight: 700; }
@media (max-width: 560px) { .tb .mov-fila { grid-template-columns: 1fr 1fr; } }
.tb .ayuda { font-size: 12.5px; color: var(--hg-texto-3); margin-top: 4px; }
.tb .mini { font-size: 12.5px; color: var(--hg-texto-3); }
@media (max-width: 760px) { .tb #cont table.t { min-width: 780px; } }
/* ---- Sitios ------------------------------------------------------------------ */
.tb .ficha { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin-bottom: 14px; }
.tb .ficha .c { background: var(--hg-plano); border: 1px solid var(--hg-linea); border-radius: 10px; padding: 10px 13px; }
.tb .ficha .l { font-size: 12.5px; color: var(--hg-texto-3); }
.tb .ficha .v { font-weight: 600; font-size: 15px; }
.tb .contacto { padding: 7px 0; border-top: 1px dashed var(--hg-linea); font-size: 14px; }
.tb .contacto:first-of-type { border-top: 0; }
.tb .contacto .r { color: var(--hg-texto-2); font-size: 12.5px; }
.tb .sitio-sel { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
.tb .sitio-sel label { margin: 0; }
.tb .contactos-ed .fila { display: grid; grid-template-columns: 1.2fr 1fr 1fr 1.2fr auto; gap: 6px; margin-bottom: 6px; }
@media (max-width: 700px) { .tb .contactos-ed .fila { grid-template-columns: 1fr 1fr; } }
.tb .contactos-ed .fila button { padding: 6px 9px; }
.tb .por-texto { font-size: 11.5px; color: var(--hg-texto-3); border: 1px dashed var(--hg-linea-2); border-radius: 5px; padding: 0 6px; }
/* línea de tiempo (sitio y expediente) */
.tb .linea { position: relative; padding-left: 20px; border-left: 2px solid var(--hg-linea); margin-left: 6px; }
.tb .ev { position: relative; padding: 11px 0 11px 4px; border-bottom: 1px solid var(--hg-linea); font-size: 14px; }
.tb .ev:last-child { border-bottom: 0; }
.tb .linea .ev::before { content: ""; position: absolute; left: -27px; top: 17px; width: 10px; height: 10px; border-radius: 50%; background: var(--hg-texto-3); box-shadow: 0 0 0 3px var(--hg-plano-2); }
.tb .ev.instalacion-campo::before, .tb .ev.prod::before { background: var(--hg-hecho-icono); }
.tb .ev.diagnostico-campo::before, .tb .ev.campo::before { background: var(--hg-p-campo); }
.tb .ev.dano::before, .tb .ev.falla::before { background: var(--hg-bloqueado); }
.tb .ev.envio::before, .tb .ev.recepcion::before, .tb .ev.banco::before { background: var(--hg-info); }
.tb .ev.flash::before { background: var(--hg-p-compras); }
.tb .ev .f { color: var(--hg-texto-3); font-size: 12.5px; }
.tb .ev .cab { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; justify-content: space-between; }
.tb .ev .t { font-weight: 700; font-size: 15px; }
.tb .ev .cl { margin-top: 3px; }
/* ---- Estado de actas ---------------------------------------------------- */
.tb .barra { display: flex; height: 10px; border-radius: 5px; overflow: hidden; background: var(--hg-pend-bg); margin: 6px 0 14px; gap: 2px; }
.tb .barra span { display: block; height: 100%; }
.tb .barra .a { background: var(--hg-hecho-icono); }
.tb .barra .f { background: var(--hg-bloqueado); }
.tb .barra .e { background: var(--hg-info); }
.tb .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; }
.tb .cel { display: flex; flex-direction: column; gap: 2px; text-decoration: none; color: inherit; border: 1px solid var(--hg-linea); border-radius: 9px; padding: 8px 10px; background: var(--hg-plano); }
.tb a.cel:hover { border-color: var(--hg-linea-2); text-decoration: none; }
.tb .cel .s { font-family: var(--hg-fuente-mono); font-weight: 600; font-size: 13.5px; color: var(--hg-texto-fuerte); }
.tb .cel .st { font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 5px; }
.tb .cel .who { font-size: 11.5px; color: var(--hg-texto-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tb .cel.apro { border-color: color-mix(in srgb, var(--hg-hecho) 40%, var(--hg-linea)); } .tb .cel.apro .st { color: var(--hg-hecho); }
.tb .cel.falla { border-color: color-mix(in srgb, var(--hg-bloqueado) 45%, var(--hg-linea)); background: var(--hg-bloqueado-bg); } .tb .cel.falla .st { color: var(--hg-bloqueado); }
.tb .cel.ens { border-color: color-mix(in srgb, var(--hg-info) 40%, var(--hg-linea)); } .tb .cel.ens .st { color: var(--hg-info); }
.tb .cel.pend .st { color: var(--hg-texto-3); }
.tb .leyenda { display: flex; flex-wrap: wrap; gap: 14px; font-size: 13px; color: var(--hg-texto-2); margin-top: 10px; }
.tb .leyenda b { font-weight: 600; }
.tb .dos { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 680px) { .tb .dos { grid-template-columns: 1fr; } }
.tb .st .i, .tb .leyenda .i { width: 13px; height: 13px; }
/* ---- Expediente de unidad y visitas de campo ----------------------------- */
.tb .buscar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 16px; }
.tb .buscar input { flex: 1 1 260px; }
.tb .fotos { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 9px; }
.tb .fotos a { display: block; width: 78px; height: 78px; border-radius: 8px; overflow: hidden; border: 1px solid var(--hg-linea); }
.tb .fotos img { width: 100%; height: 100%; object-fit: cover; display: block; background: var(--hg-pend-bg); }
.tb .logs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 9px; }
.tb .logs a { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--hg-liga); text-decoration: none; border: 1px solid var(--hg-linea); border-radius: 7px; padding: 5px 10px; background: var(--hg-plano); }
.tb .logs a:hover { border-color: var(--hg-linea-2); }
.md-typeset .tb table.det { display: table; border: 0; border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 8px; background: none; }
.md-typeset .tb table.det td { padding: 5px 8px 5px 0; border: 0; border-bottom: 1px solid var(--hg-linea); vertical-align: top; }
.md-typeset .tb table.det td.k { color: var(--hg-texto-2); width: 46%; }
.tb .cadena { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 4px; }
.tb .cadena .paso { flex: 1 1 118px; min-width: 118px; border: 1px solid var(--hg-linea); border-radius: 9px; padding: 7px 9px; background: var(--hg-plano); font-size: 12.5px; position: relative; }
.tb .cadena .paso .n { font-weight: 700; font-size: 13px; }
.tb .cadena .paso .e { margin-top: 2px; font-weight: 600; }
.tb .cadena .paso .d { color: var(--hg-texto-3); font-size: 11.5px; margin-top: 2px; line-height: 1.3; }
.tb .cadena .paso.ok { border-color: color-mix(in srgb, var(--hg-hecho) 40%, var(--hg-linea)); } .tb .cadena .paso.ok .e { color: var(--hg-hecho); }
.tb .cadena .paso.falla { border-color: color-mix(in srgb, var(--hg-bloqueado) 45%, var(--hg-linea)); background: var(--hg-bloqueado-bg); } .tb .cadena .paso.falla .e { color: var(--hg-bloqueado); }
.tb .cadena .paso.info { border-color: color-mix(in srgb, var(--hg-info) 40%, var(--hg-linea)); } .tb .cadena .paso.info .e { color: var(--hg-info); }
.tb .cadena .paso.pend .e { color: var(--hg-texto-3); }
.tb .cadena .paso a { color: inherit; }
.tb .cad-t { font-size: 13px; font-weight: 600; color: var(--hg-texto-3); margin: 10px 0 0; }
.tb .unidad { border: 1px solid var(--hg-linea); border-radius: 11px; padding: 12px 14px; margin: 10px 0; background: var(--hg-plano); }
.tb .unidad .cab { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; justify-content: space-between; }
.tb .unidad .s { font-family: var(--hg-fuente-mono); font-weight: 600; font-size: 15px; }
.tb .unidad .m { color: var(--hg-texto-2); font-size: 13px; }
.tb .visita { border-top: 1px solid var(--hg-linea); margin-top: 10px; padding-top: 10px; font-size: 14px; }
.tb .visita .f { color: var(--hg-texto-3); font-size: 12.5px; }
.tb .visita .cl { font-weight: 700; }
.tb .marca-unidad { border-radius: 10px; padding: 10px 14px; margin: 0 0 14px; font-size: 14.5px; }
@media print {
.tb .tb-cab__acc, .tb .buscar, .tb button, .tb .kb-barra, .tb details.tb-como { display: none !important; }
.tb section.bloque, .tb .kpi, .tb .card, .tb .cel, .tb .cadena .paso { background: #fff !important; border-color: #999 !important; color: #000 !important; }
}

268
docs/tableros/unidad.md Normal file
View File

@ -0,0 +1,268 @@
---
title: "Expediente de unidad"
description: "Todo lo registrado para una serie: actas, fotos, logs y trazabilidad de la tarjeta al puerto."
template: tablero.html
hide:
- toc
---
<div class="tb tb-unidad">
<header class="tb-cab">
<div class="tb-cab__t"><h1 id="titulo">Expediente de unidad</h1><span class="meta" id="meta">Elige una unidad para ver todo su historial.</span></div>
<div class="tb-cab__acc"><a class="boton sec" href="/tableros/campo/"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17zM12 7v5l3.2 2"/></svg>Visitas de campo</a><button type="button" class="sec" onclick="window.print()"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 8.5V4h10v4.5M7 17H5a1.5 1.5 0 0 1-1.5-1.5v-5A1.5 1.5 0 0 1 5 9h14a1.5 1.5 0 0 1 1.5 1.5v5A1.5 1.5 0 0 1 19 17h-2M7 14h10v6H7z"/></svg>Imprimir</button></div>
</header>
<div class="buscar">
<select id="sel"><option value="">— elegir unidad —</option></select>
<input id="libre" placeholder="…o escribe una serie: RTK-014, ARD-007, V5-03">
<button id="ir">Ver expediente</button>
</div>
<div id="cont"></div>
</div>
<script src="/formularios/flota.js"></script>
<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=>e.append(c)); return e;};
const $=i=>document.getElementById(i);
const cont=$('cont');
const fecha=s=>(s||'').replace('T',' ').slice(0,16);
const kb=n=>n>1048576?(n/1048576).toFixed(1)+' MB':Math.max(1,Math.round(n/1024))+' kB';
const NOMBRE={'diagnostico-campo':['Diagnóstico en campo','campo'],'diagnostico-banco':['Diagnóstico en banco','banco'],
'reflasheo':['Reflasheo','flash'],'instalacion-campo':['Instalación en sitio','campo'],
'dano':['Daño o incidente','falla'],'envio':['Envío','prod'],'recepcion':['Recepción','prod'],
'prueba-v5':['Prueba de tarjeta V5','prod'],'prueba-v4':['Prueba de tarjeta V4','prod'],
'modulo-ard':['Módulo ArduSimple','prod'],'modulo-4g':['Módem 4G','prod'],
'integracion':['Integración RTK + 4G','prod'],'ensamble':['Ensamble de unidad','prod']};
// lista de unidades: la flota + lo que el servidor ya tenga guardado
(async () => {
const vistas = new Set();
(window.FLOTA||[]).forEach(u => {
vistas.add(u.serie);
$('sel').append(el('option',{value:u.serie,text:u.serie+' — '+(u.maquina||'')}));
});
try{
const j = await (await fetch('/api/series')).json();
(j.series||[]).forEach(s => { if(!vistas.has(s.serie))
$('sel').append(el('option',{value:s.serie,text:s.serie+' ('+s.tipos.join(', ')+')'})); });
}catch(e){}
const q = new URLSearchParams(location.search).get('serie');
if(q){ $('sel').value=q; $('libre').value=q; cargar(q); }
})();
$('ir').addEventListener('click',()=>{ const s=($('libre').value||$('sel').value||'').trim().toUpperCase(); if(s) irA(s); });
$('sel').addEventListener('change',()=>{ if($('sel').value){ $('libre').value=''; irA($('sel').value); } });
$('libre').addEventListener('keydown',e=>{ if(e.key==='Enter') $('ir').click(); });
function irA(s){ history.replaceState(null,'','?serie='+encodeURIComponent(s)); cargar(s); }
async function detalle(tipo, serie, archivo, caja){
caja.textContent='Cargando…';
try{
const j = await (await fetch(`/api/acta?tipo=${encodeURIComponent(tipo)}&serie=${encodeURIComponent(serie)}&archivo=${encodeURIComponent(archivo)}`)).json();
if(!j.exists) throw new Error('no encontrada');
const t=el('table',{class:'det'}); const res=j.acta.respuestas||{};
Object.keys(res).forEach(k=>{
const v=res[k]; if(v.val===''||v.val===false||v.val==null) return;
t.append(el('tr',null, el('td',{class:'k',text:(v.lbl||k).replace(/<[^>]+>/g,'')}),
el('td',{text: v.val===true?'sí':String(v.val)})));
});
caja.innerHTML=''; caja.append(t.rows.length?t:el('div',{class:'vacio',text:'El acta no tiene respuestas guardadas.'}));
}catch(e){ caja.textContent='No se pudo cargar el detalle.'; }
}
function galeria(rutas){
const g=el('div',{class:'fotos'});
rutas.forEach(r=>{
const a=el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(r),target:'_blank'});
a.append(el('img',{src:'/api/adjunto?ruta='+encodeURIComponent(r),loading:'lazy'}));
g.append(a);
});
return g;
}
function render(j){
cont.innerHTML='';
const u=(window.FLOTA||[]).find(x=>x.serie===j.serie);
$('titulo').textContent='Expediente · '+j.serie;
$('meta').innerHTML = u
? '<b>'+(u.maquina||'')+'</b> · '+(u.terminal||'')+(u.nota?'<br>'+u.nota:'')
: 'Esta serie no está en el registro de flota (flota.js).';
if(j.marca){
const m=j.marca_acta||{};
cont.append(el('div',{class:'err',style:j.marca==='BAJA'?'':'background:var(--aviso-bg);color:var(--aviso);border-color:var(--aviso)',
html:(j.marca==='BAJA'?'<b>Serie dada de BAJA</b>':'<b>Serie marcada como DAÑADA</b>')
+(m.fecha?' desde el '+m.fecha:'')+(m.clasificacion?' — '+m.clasificacion:'')+(m.resumen?'<br>'+m.resumen:'')
+(m.accion?'<br>Disposición: '+m.accion:'')}));
}
const kp=el('div',{class:'kpis'});
[[j.total,'Actas registradas'],[j.total_fotos,'Fotos'],[j.total_logs,'Archivos de log'],
[j.relacionadas.length,'Actas que la mencionan']]
.forEach(([n,l])=>kp.append(el('div',{class:'kpi'},el('div',{class:'n',text:String(n)}),el('div',{class:'l',text:l}))));
cont.append(kp);
// ---- trazabilidad: cadena de producción y cadena de campo ----
cont.append(trazabilidad(j));
// ---- línea de tiempo ----
const b=el('section',{class:'bloque'});
b.append(el('h2',{text:'Historial'}), el('p',{class:'sub',text:'Todo lo registrado para esta serie, de lo más reciente a lo más viejo.'}));
if(!j.actas.length) b.append(el('div',{class:'vacio',text:'Todavía no hay ninguna acta para esta unidad.'}));
else{
const lt=el('div',{class:'linea'});
j.actas.forEach(a=>{
const [nom,cls]=NOMBRE[a.tipo]||[a.tipo,'pend'];
const ev=el('div',{class:'ev '+cls});
const cab=el('div',{class:'cab'});
cab.append(el('div',null, el('div',{class:'t',text:nom}),
el('div',{class:'f',text:(a.fecha?a.fecha+' · ':'')+fecha(a.recibido)+' · '+(a.tecnico||'—')+(a.maquina?' · '+a.maquina:'')})));
const r=String(a.resultado||'');
cab.append(el('span',{class:'pill '+(r==='FALLA'?'falla':r?'ok':'pend'),text:r||'—'}));
ev.append(cab);
if(a.motivo) ev.append(el('div',{class:'cl',text:'Motivo: '+a.motivo}));
if(a.clasificacion) ev.append(el('div',{class:'cl',html:'<b>'+a.clasificacion+'</b>'}));
if(a.accion) ev.append(el('div',{class:'cl',text:'→ '+a.accion}));
if(a.fotos.length) ev.append(galeria(a.fotos));
if(a.archivos.length){
const g=el('div',{class:'logs'});
a.archivos.forEach(x=>g.append(el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(x.ruta),
target:'_blank',text:(x.nombre||'captura')})));
ev.append(g);
}
const det=el('details'); det.append(el('summary',{text:'Ver todas las respuestas'}));
const caja=el('div'); det.append(caja);
det.addEventListener('toggle',()=>{ if(det.open&&!caja.dataset.l){ caja.dataset.l='1'; detalle(a.tipo,j.serie,a.archivo,caja);} });
ev.append(det);
lt.append(ev);
});
b.append(lt);
}
cont.append(b);
// ---- todos los archivos ----
const f=el('section',{class:'bloque'});
f.append(el('h2',{text:'Archivos de log'}),
el('p',{class:'sub',text:'Todas las capturas subidas para esta serie, vengan del acta que vengan.'}));
if(!j.logs.length) f.append(el('div',{class:'vacio',text:'Sin archivos de log.'}));
else{
const g=el('div',{class:'logs'});
j.logs.forEach(l=>g.append(el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(l.ruta),target:'_blank',
text:l.nombre+' ('+kb(l.bytes)+')'})));
f.append(g);
}
cont.append(f);
const ph=el('section',{class:'bloque'});
ph.append(el('h2',{text:'Todas las fotos'}), el('p',{class:'sub',text:j.total_fotos+' foto(s) acumuladas.'}));
if(!j.fotos.length) ph.append(el('div',{class:'vacio',text:'Sin fotos.'}));
else ph.append(galeria(j.fotos.map(x=>x.ruta)));
cont.append(ph);
if(j.relacionadas.length){
const r=el('section',{class:'bloque'});
r.append(el('h2',{text:'Aparece como componente en'}),
el('p',{class:'sub',text:'Actas de otras unidades que incluyen esta serie entre sus módulos.'}));
j.relacionadas.forEach(a=>{
const [nom]=NOMBRE[a.tipo]||[a.tipo];
r.append(el('div',{class:'ev'},
el('div',{class:'t',html:nom+' — <a href="?serie='+encodeURIComponent(a.serie)+'">'+a.serie+'</a>'}),
el('div',{class:'f',text:fecha(a.recibido)+' · '+(a.tecnico||'—')+' · '+(a.resultado||'')})));
});
cont.append(r);
}
}
// ---------- trazabilidad ----------
// Cadena de producción (tarjeta → módulos → integración → ensamble → configuración) y cadena de
// campo (envío → recepción → instalación → diagnósticos → daños), con fecha y sitio. Para una unidad
// HBOX, las actas de sus módulos viven en OTRAS series (componentes del ensamble): se consultan aparte.
let SITIOS = null;
async function nombreSitio(id){
if(!id) return '';
if(!SITIOS){ try{ SITIOS = ((await (await fetch('/api/sitios?todos=1')).json()).sitios)||[]; }catch(e){ SITIOS=[]; } }
const s = SITIOS.find(x=>x.id===id); return s ? s.nombre : id;
}
function paso(nombre, cls, estado, detalle){
const p=el('div',{class:'paso '+cls}); p.append(el('div',{class:'n',text:nombre}), el('div',{class:'e',text:estado}));
if(detalle) p.append(el('div',{class:'d',html:detalle}));
return p;
}
function ultima(actas, tipos){ return actas.filter(a=>tipos.includes(a.tipo))[0]||null; } // ya vienen de reciente a vieja
function pasoActa(nombre, a, textoOK, textoPend){
if(!a) return paso(nombre,'pend',textoPend||'sin acta');
const falla = a.resultado==='FALLA';
return paso(nombre, falla?'falla':'ok', falla?'Falla':(textoOK||(a.resultado||'OK')), (a.fecha||fecha(a.recibido).slice(0,10))+(a.tecnico?' · '+a.tecnico:''));
}
function trazabilidad(j){
const sec=el('section',{class:'bloque'});
sec.append(el('h2',{text:'Trazabilidad'}), el('p',{class:'sub',text:'De la tarjeta al puerto: cada paso con su acta, fecha y sitio. Gris = todavía no hay acta.'}));
const A=j.actas, esHbox=/^HBOX/i.test(j.serie);
// --- producción
sec.append(el('div',{class:'cad-t',text:'Producción'}));
const prod=el('div',{class:'cadena'}); sec.append(prod);
const ens=ultima(A,['ensamble']);
let comp=(ens&&ens.componentes)||{};
// sin acta de ensamble, los módulos se deducen del número de unidad: HBOX5-E02-A002-R0002 → V5-02 · ARD-002 · RTK-0002
const mSer=!comp.einar && j.serie.match(/^HBOX5-E(\d+)-A(\d+)-R(\d+)$/i);
if(mSer) comp={einar:'V5-'+mSer[1].padStart(2,'0'), ard:'ARD-'+mSer[2].padStart(3,'0'), modem:'RTK-'+mSer[3].padStart(4,'0')};
if(esHbox){
// módulos: se consultan por su serie
const m=[['Tarjeta EinarBoard','prueba-v5',comp.einar],['ArduSimple','modulo-ard',comp.ard],['Módem 4G','modulo-4g',comp.modem]];
const ph={}; m.forEach(([n,,s])=>{ const p=paso(n,'pend',s?'consultando…':'sin ensamble', s?'<a href="?serie='+encodeURIComponent(s)+'">'+s+'</a>':''); ph[n]=p; prod.append(p); });
const integ=paso('Integración','pend',comp.ard&&comp.modem?'consultando…':'—'); prod.append(integ);
(async()=>{
for(const [n,tipo,s] of m){
if(!s) continue;
let txt='sin acta', cls='pend';
try{ const r=await (await fetch(`/api/acta?tipo=${tipo}&serie=${encodeURIComponent(s)}`)).json();
if(r.exists){ cls=r.acta.resultado==='FALLA'?'falla':'ok'; txt=cls==='ok'?'Aprobada':'Falla'; ph[n].querySelector('.d').innerHTML+=' · '+(r.acta.fecha||fecha(r.acta._recibido).slice(0,10)); } }catch(e){}
ph[n].className='paso '+cls; ph[n].querySelector('.e').textContent=txt;
}
if(comp.ard&&comp.modem){
const si='INT-A'+(String(comp.ard).match(/(\d+)\s*$/)||[,''])[1]+'-R'+(String(comp.modem).match(/(\d+)\s*$/)||[,''])[1];
let txt='sin acta', cls='pend';
try{ const r=await (await fetch(`/api/acta?tipo=integracion&serie=${encodeURIComponent(si)}`)).json();
if(r.exists){ cls=r.acta.resultado==='FALLA'?'falla':'ok'; txt=cls==='ok'?'Aprobada':'Falla'; integ.append(el('div',{class:'d',html:'<a href="?serie='+encodeURIComponent(si)+'">'+si+'</a> · '+(r.acta.fecha||fecha(r.acta._recibido).slice(0,10))})); } }catch(e){}
integ.className='paso '+cls; integ.querySelector('.e').textContent=txt;
}
})();
prod.append(pasoActa('Ensamble',ens,'Unidad lista'));
prod.append(pasoActa('Configuración / pruebas',ultima(A,['reflasheo','prueba-ethernet-serie','prueba-bateria-modem']),null,'sin acta'));
} else {
// pieza suelta: su propia acta de prueba, y en qué unidad quedó
prod.append(pasoActa('Acta de la pieza',ultima(A,['prueba-v5','prueba-v4','modulo-ard','modulo-4g'])));
const integ=j.relacionadas.find(r=>r.tipo==='integracion'), ensR=j.relacionadas.find(r=>r.tipo==='ensamble');
prod.append(integ?paso('Integración',integ.resultado==='FALLA'?'falla':'ok',integ.resultado==='FALLA'?'Falla':'En pareja','<a href="?serie='+encodeURIComponent(integ.serie)+'">'+integ.serie+'</a>'):paso('Integración','pend','sin acta'));
prod.append(ensR?paso('Ensamble',ensR.resultado==='FALLA'?'falla':'info',ensR.resultado==='FALLA'?'Falla':'Montada en unidad','<a href="?serie='+encodeURIComponent(ensR.serie)+'">'+ensR.serie+'</a>'):paso('Ensamble','pend','libre (no está en una unidad)'));
prod.append(pasoActa('Banco / reflasheo',ultima(A,['diagnostico-banco','reflasheo']),null,'—'));
}
// --- campo
sec.append(el('div',{class:'cad-t',text:'Campo'}));
const campo=el('div',{class:'cadena'}); sec.append(campo);
const env=ultima(A,['envio']), rec=ultima(A,['recepcion']), ins=A.filter(a=>a.tipo==='instalacion-campo'&&a.resultado!=='FALLA')[0]||ultima(A,['instalacion-campo']);
const diags=A.filter(a=>a.tipo==='diagnostico-campo'), danos=A.filter(a=>a.tipo==='dano');
const conSitio=async(p,a)=>{ if(a&&a.sitio){ const n=await nombreSitio(a.sitio); let d=p.querySelector('.d'); if(!d){ d=el('div',{class:'d'}); p.append(d); } d.innerHTML+=' · <a href="/tableros/sitio/?id='+encodeURIComponent(a.sitio)+'">'+n+'</a>'; } };
const pEnv=env?paso('Envío',env.accion&&/detenida/i.test(env.accion)?'info':'ok',env.accion||'Enviada',(env.fecha||'')+(env.clasificacion?' · '+env.clasificacion:'')):paso('Envío','pend','sin acta'); campo.append(pEnv); conSitio(pEnv,env);
const pRec=rec?paso('Recepción',rec.resultado==='FALLA'?'falla':'ok',rec.accion||rec.resultado||'Recibida',(rec.fecha||'')+(rec.clasificacion?' · '+rec.clasificacion:'')):paso('Recepción','pend','sin acta'); campo.append(pRec); conSitio(pRec,rec);
const pIns=ins?paso('Instalación',ins.resultado==='FALLA'?'falla':'ok',ins.accion||(ins.resultado==='FALLA'?'Falla':'Instalada'),(ins.fecha||'')+(ins.maquina?' · '+ins.maquina:'')):paso('Instalación','pend','sin acta','<a href="/formularios/instalacion.html">llenar acta</a>'); campo.append(pIns); conSitio(pIns,ins);
const pDg=diags.length?paso('Diagnósticos',diags[0].resultado==='FALLA'?'falla':'info',diags.length+' visita(s)',(diags[0].fecha||'')+(diags[0].accion?' · '+diags[0].accion:'')):paso('Diagnósticos','pend','ninguno'); campo.append(pDg); conSitio(pDg,diags[0]);
const pDn=j.marca?paso('Daños',j.marca==='BAJA'?'falla':'falla',j.marca==='BAJA'?'Baja':'Dañada',(j.marca_acta&&j.marca_acta.fecha)||''):(danos.length?paso('Daños','ok','reparada / sin marca',danos.length+' acta(s)'):paso('Daños','ok','sin daños')); campo.append(pDn); conSitio(pDn,danos[0]);
return sec;
}
async function cargar(serie){
cont.innerHTML='<p class="vacio">Cargando…</p>';
try{
const j=await (await fetch('/api/unidad?serie='+encodeURIComponent(serie),{cache:'no-store'})).json();
if(!j.ok) throw new Error(j.error||'error');
render(j);
}catch(e){
cont.innerHTML='';
cont.append(el('div',{class:'err',text:'No se pudo leer el expediente: '+e.message}));
}
}
})();
</script>

View File

@ -4,7 +4,7 @@ title: "Taller"
# Taller
Unidad o tarjeta **en la mesa**: llegó del campo, falló en producción o hay que validar un firmware. Antes de tocarla, abre su [expediente](../unidad.html) y lee las actas anteriores. Reglas de banco (límite de corriente, 5 V ≥ 5.2 V o paquete conectado, baterías hinchadas): [diagnóstico en banco](../procedimientos/diagnostico-banco.md#reglas-de-banco).
Unidad o tarjeta **en la mesa**: llegó del campo, falló en producción o hay que validar un firmware. Antes de tocarla, abre su [expediente](../tableros/unidad.md) y lee las actas anteriores. Reglas de banco (límite de corriente, 5 V ≥ 5.2 V o paquete conectado, baterías hinchadas): [diagnóstico en banco](../procedimientos/diagnostico-banco.md#reglas-de-banco).
## Qué trabajo, qué procedimiento, qué acta
@ -30,5 +30,5 @@ Por tarjeta, nunca por número de versión. **0.6.0** es el de campo para V4 y V
## Referencias de la mesa
- [Manual V5](../manuales/manual-tarjeta-V5.md) · [Manual V4](../manuales/manual-tarjeta-V4.md) · [Manual V3](../manuales/manual-tarjeta-V3.md) · [Pinout V5.6](../hardware/v56-pinout.md) · [Hoja de datos V5.6](../hardware/v56-datasheet.md)
- [Estado de actas](../tablero.html) · [Expediente de una unidad](../unidad.html) · [Kanban](../tableros/kanban.html) (pista Producción)
- [Estado de actas](../tableros/actas.md) · [Expediente de una unidad](../tableros/unidad.md) · [Pendientes](../tableros/pendientes.md) (pista Producción)
- Procedimientos anteriores (V3, protoboard, fan v3.0.3): [Referencia e histórico](../historia/index.md)

View File

@ -1,341 +1,18 @@
<!doctype html>
<!-- Desde el 20-oct-2026 este tablero vive dentro del sitio: docs/tableros/unidad.md → /tableros/unidad/
Este archivo solo redirige (conserva ?serie=, ?id=, ?pista=… y #…) para que las ligas viejas
guardadas en pendientes y actas sigan sirviendo. No se edita: los cambios van en tableros/unidad.md. -->
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Expediente de la unidad — HBOX</title>
<style>
:root{
--naranja:#e8590c; --tinta:#1d1c19; --tinta2:#5a564d; --linea:#e6e1d6;
--papel:#faf8f4; --tarjeta:#fff; --superf:#fcfcfb;
--ok:#2f9e44; --ok-bg:#ebfbee; --falla:#e03131; --falla-bg:#fff5f5;
--pend:#adb5bd; --pend-bg:#f4f2ee; --info:#1971c2; --info-bg:#e7f1fb;
}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){
--papel:#191b1e;--tinta:#e6e1d6;--tinta2:#a09a8d;--linea:#33363b;--tarjeta:#1f2226;--superf:#1b1d20;
--ok:#69db7c;--ok-bg:#1b2a1f;--falla:#ff8787;--falla-bg:#2c1d1d;--pend:#5c636a;--pend-bg:#26282c;--info:#74c0fc;--info-bg:#182734;}}
*{box-sizing:border-box}
body{margin:0;background:var(--papel);color:var(--tinta);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.wrap{max-width:1020px;margin:0 auto;padding:18px 16px 70px}
header.top{background:#2C3C5B;color:#fff;padding:16px;border-radius:12px;margin-bottom:16px}
header.top h1{margin:0;font-size:21px}
header.top .meta{color:#d7d2c8;font-size:13.5px;margin-top:4px}
header.top a{color:#fff;font-size:13px;text-decoration:none;opacity:.85;margin-right:14px}
.buscar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:16px}
select,input{padding:10px 12px;border:1px solid var(--linea);border-radius:9px;font-size:15px;
background:var(--tarjeta);color:var(--tinta)}
button{background:var(--naranja);color:#fff;border:0;border-radius:9px;padding:10px 18px;font-size:14px;font-weight:600;cursor:pointer}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-bottom:18px}
.kpi{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:14px 16px}
.kpi .n{font-size:28px;font-weight:700;line-height:1}
.kpi .l{color:var(--tinta2);font-size:12.5px;margin-top:4px}
section.bloque{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:16px;margin:14px 0}
section.bloque h2{margin:0 0 4px;font-size:17px}
section.bloque .sub{color:var(--tinta2);font-size:13.5px;margin:0 0 12px}
.linea{position:relative;padding-left:20px;border-left:2px solid var(--linea);margin-left:6px}
.ev{position:relative;padding:12px 0 12px 4px;border-bottom:1px solid var(--linea)}
.ev:last-child{border-bottom:0}
.ev::before{content:"";position:absolute;left:-27px;top:18px;width:11px;height:11px;border-radius:50%;
background:var(--pend);border:2px solid var(--papel)}
.ev.campo::before{background:var(--naranja)} .ev.banco::before{background:var(--info)}
.ev.flash::before{background:#7048e8} .ev.prod::before{background:var(--ok)} .ev.falla::before{background:var(--falla)}
.ev .cab{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;justify-content:space-between}
.ev .t{font-weight:700;font-size:15px}
.ev .f{color:var(--tinta2);font-size:12.5px}
.ev .cl{margin-top:3px}
.pill{font-size:11px;font-weight:700;padding:2px 9px;border-radius:99px;white-space:nowrap}
.pill.ok{background:var(--ok-bg);color:var(--ok)}
.pill.falla{background:var(--falla-bg);color:var(--falla)}
.pill.info{background:var(--info-bg);color:var(--info)}
.pill.pend{background:var(--pend-bg);color:var(--tinta2)}
.fotos{display:flex;flex-wrap:wrap;gap:8px;margin-top:9px}
.fotos a{display:block;width:78px;height:78px;border-radius:8px;overflow:hidden;border:1px solid var(--linea)}
.fotos img{width:100%;height:100%;object-fit:cover;display:block;background:var(--pend-bg)}
.logs{display:flex;flex-wrap:wrap;gap:8px;margin-top:9px}
.logs a{font-size:12.5px;font-weight:600;color:var(--info);text-decoration:none;
border:1px solid var(--linea);border-radius:7px;padding:5px 10px;background:var(--superf)}
details{margin-top:8px}
summary{cursor:pointer;color:var(--naranja);font-size:13px;font-weight:600}
table.det{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px}
table.det td{padding:5px 8px 5px 0;border-bottom:1px solid var(--linea);vertical-align:top}
table.det td.k{color:var(--tinta2);width:46%}
.vacio{color:var(--tinta2);font-style:italic;font-size:14px}
.err{background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla);padding:10px 14px;border-radius:10px}
/* cadenas de trazabilidad: producción → campo */
.cadena{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 4px}
.cadena .paso{flex:1 1 118px;min-width:118px;border:1px solid var(--linea);border-radius:9px;padding:7px 9px;background:var(--superf);font-size:12.5px;position:relative}
.cadena .paso .n{font-weight:700;font-size:13px}
.cadena .paso .e{margin-top:2px}
.cadena .paso .d{color:var(--tinta2);font-size:11.5px;margin-top:2px;line-height:1.3}
.cadena .paso.ok{border-color:var(--ok);background:var(--ok-bg)} .cadena .paso.ok .e{color:var(--ok)}
.cadena .paso.falla{border-color:var(--falla);background:var(--falla-bg)} .cadena .paso.falla .e{color:var(--falla)}
.cadena .paso.info{border-color:var(--info);background:var(--info-bg)} .cadena .paso.info .e{color:var(--info)}
.cadena .paso.pend .e{color:var(--tinta2)}
.cadena .paso a{color:inherit}
.cad-t{font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--tinta2);margin:8px 0 0}
@media print{header.top{background:#fff;color:#000} .buscar,button{display:none}}
</style>
<meta name="robots" content="noindex">
<title>Expediente de unidad - Drogheda</title>
<script>location.replace('/tableros/unidad/' + location.search + location.hash);</script>
<meta http-equiv="refresh" content="0; url=/tableros/unidad/">
<style>body{margin:0;padding:24px;background:#0E1524;color:#E7ECF5;font:16px/1.5 system-ui,sans-serif}a{color:#7FE9D8}</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><a href="./">← Documentación</a><a href="diagnostico/tablero-campo.html">Tablero de campo</a>
<a href="#" onclick="window.print();return false">Imprimir</a></div>
<h1 id="titulo">Expediente de la unidad</h1>
<div class="meta" id="meta">Elige una unidad para ver todo su historial.</div>
</header>
<div class="buscar">
<select id="sel"><option value="">— elegir unidad —</option></select>
<input id="libre" placeholder="…o escribe una serie: RTK-014, ARD-007, V5-03">
<button id="ir">Ver expediente</button>
</div>
<div id="cont"></div>
</div>
<script src="formularios/flota.js"></script>
<script>
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=>e.append(c)); return e;};
const $=i=>document.getElementById(i);
const cont=$('cont');
const fecha=s=>(s||'').replace('T',' ').slice(0,16);
const kb=n=>n>1048576?(n/1048576).toFixed(1)+' MB':Math.max(1,Math.round(n/1024))+' kB';
const NOMBRE={'diagnostico-campo':['Diagnóstico en campo','campo'],'diagnostico-banco':['Diagnóstico en banco','banco'],
'reflasheo':['Reflasheo','flash'],'instalacion-campo':['Instalación en sitio','campo'],
'dano':['⚡ Daño o incidente','falla'],'envio':['📦 Envío','prod'],'recepcion':['📦 Recepción','prod'],
'prueba-v5':['Prueba de tarjeta V5','prod'],'prueba-v4':['Prueba de tarjeta V4','prod'],
'modulo-ard':['Módulo ArduSimple','prod'],'modulo-4g':['Módem 4G','prod'],
'integracion':['Integración RTK + 4G','prod'],'ensamble':['Ensamble de unidad','prod']};
// lista de unidades: la flota + lo que el servidor ya tenga guardado
(async () => {
const vistas = new Set();
(window.FLOTA||[]).forEach(u => {
vistas.add(u.serie);
$('sel').append(el('option',{value:u.serie,text:u.serie+' — '+(u.maquina||'')}));
});
try{
const j = await (await fetch('/api/series')).json();
(j.series||[]).forEach(s => { if(!vistas.has(s.serie))
$('sel').append(el('option',{value:s.serie,text:s.serie+' ('+s.tipos.join(', ')+')'})); });
}catch(e){}
const q = new URLSearchParams(location.search).get('serie');
if(q){ $('sel').value=q; $('libre').value=q; cargar(q); }
})();
$('ir').addEventListener('click',()=>{ const s=($('libre').value||$('sel').value||'').trim().toUpperCase(); if(s) irA(s); });
$('sel').addEventListener('change',()=>{ if($('sel').value){ $('libre').value=''; irA($('sel').value); } });
$('libre').addEventListener('keydown',e=>{ if(e.key==='Enter') $('ir').click(); });
function irA(s){ history.replaceState(null,'','?serie='+encodeURIComponent(s)); cargar(s); }
async function detalle(tipo, serie, archivo, caja){
caja.textContent='Cargando…';
try{
const j = await (await fetch(`/api/acta?tipo=${encodeURIComponent(tipo)}&serie=${encodeURIComponent(serie)}&archivo=${encodeURIComponent(archivo)}`)).json();
if(!j.exists) throw new Error('no encontrada');
const t=el('table',{class:'det'}); const res=j.acta.respuestas||{};
Object.keys(res).forEach(k=>{
const v=res[k]; if(v.val===''||v.val===false||v.val==null) return;
t.append(el('tr',null, el('td',{class:'k',text:(v.lbl||k).replace(/<[^>]+>/g,'')}),
el('td',{text: v.val===true?'✓':String(v.val)})));
});
caja.innerHTML=''; caja.append(t.rows.length?t:el('div',{class:'vacio',text:'El acta no tiene respuestas guardadas.'}));
}catch(e){ caja.textContent='No se pudo cargar el detalle.'; }
}
function galeria(rutas){
const g=el('div',{class:'fotos'});
rutas.forEach(r=>{
const a=el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(r),target:'_blank'});
a.append(el('img',{src:'/api/adjunto?ruta='+encodeURIComponent(r),loading:'lazy'}));
g.append(a);
});
return g;
}
function render(j){
cont.innerHTML='';
const u=(window.FLOTA||[]).find(x=>x.serie===j.serie);
$('titulo').textContent='Expediente · '+j.serie;
$('meta').innerHTML = u
? '<b>'+(u.maquina||'')+'</b> · '+(u.terminal||'')+(u.nota?'<br>'+u.nota:'')
: 'Esta serie no está en el registro de flota (flota.js).';
if(j.marca){
const m=j.marca_acta||{};
cont.append(el('div',{class:'err',style:j.marca==='BAJA'?'':'background:#fff9db;color:#7a4a00;border-color:#f08c00',
html:(j.marca==='BAJA'?'⛔ <b>Serie dada de BAJA</b>':'⚠ <b>Serie marcada como DAÑADA</b>')
+(m.fecha?' desde el '+m.fecha:'')+(m.clasificacion?' — '+m.clasificacion:'')+(m.resumen?'<br>'+m.resumen:'')
+(m.accion?'<br>Disposición: '+m.accion:'')}));
}
const kp=el('div',{class:'kpis'});
[[j.total,'Actas registradas'],[j.total_fotos,'Fotos'],[j.total_logs,'Archivos de log'],
[j.relacionadas.length,'Actas que la mencionan']]
.forEach(([n,l])=>kp.append(el('div',{class:'kpi'},el('div',{class:'n',text:String(n)}),el('div',{class:'l',text:l}))));
cont.append(kp);
// ---- trazabilidad: cadena de producción y cadena de campo ----
cont.append(trazabilidad(j));
// ---- línea de tiempo ----
const b=el('section',{class:'bloque'});
b.append(el('h2',{text:'Historial'}), el('p',{class:'sub',text:'Todo lo registrado para esta serie, de lo más reciente a lo más viejo.'}));
if(!j.actas.length) b.append(el('div',{class:'vacio',text:'Todavía no hay ninguna acta para esta unidad.'}));
else{
const lt=el('div',{class:'linea'});
j.actas.forEach(a=>{
const [nom,cls]=NOMBRE[a.tipo]||[a.tipo,'pend'];
const ev=el('div',{class:'ev '+cls});
const cab=el('div',{class:'cab'});
cab.append(el('div',null, el('div',{class:'t',text:nom}),
el('div',{class:'f',text:(a.fecha?a.fecha+' · ':'')+fecha(a.recibido)+' · '+(a.tecnico||'—')+(a.maquina?' · '+a.maquina:'')})));
const r=String(a.resultado||'');
cab.append(el('span',{class:'pill '+(r==='FALLA'?'falla':r?'ok':'pend'),text:r||'—'}));
ev.append(cab);
if(a.motivo) ev.append(el('div',{class:'cl',text:'Motivo: '+a.motivo}));
if(a.clasificacion) ev.append(el('div',{class:'cl',html:'<b>'+a.clasificacion+'</b>'}));
if(a.accion) ev.append(el('div',{class:'cl',text:'→ '+a.accion}));
if(a.fotos.length) ev.append(galeria(a.fotos));
if(a.archivos.length){
const g=el('div',{class:'logs'});
a.archivos.forEach(x=>g.append(el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(x.ruta),
target:'_blank',text:'📄 '+(x.nombre||'captura')})));
ev.append(g);
}
const det=el('details'); det.append(el('summary',{text:'Ver todas las respuestas'}));
const caja=el('div'); det.append(caja);
det.addEventListener('toggle',()=>{ if(det.open&&!caja.dataset.l){ caja.dataset.l='1'; detalle(a.tipo,j.serie,a.archivo,caja);} });
ev.append(det);
lt.append(ev);
});
b.append(lt);
}
cont.append(b);
// ---- todos los archivos ----
const f=el('section',{class:'bloque'});
f.append(el('h2',{text:'Archivos de log'}),
el('p',{class:'sub',text:'Todas las capturas subidas para esta serie, vengan del acta que vengan.'}));
if(!j.logs.length) f.append(el('div',{class:'vacio',text:'Sin archivos de log.'}));
else{
const g=el('div',{class:'logs'});
j.logs.forEach(l=>g.append(el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(l.ruta),target:'_blank',
text:'📄 '+l.nombre+' ('+kb(l.bytes)+')'})));
f.append(g);
}
cont.append(f);
const ph=el('section',{class:'bloque'});
ph.append(el('h2',{text:'Todas las fotos'}), el('p',{class:'sub',text:j.total_fotos+' foto(s) acumuladas.'}));
if(!j.fotos.length) ph.append(el('div',{class:'vacio',text:'Sin fotos.'}));
else ph.append(galeria(j.fotos.map(x=>x.ruta)));
cont.append(ph);
if(j.relacionadas.length){
const r=el('section',{class:'bloque'});
r.append(el('h2',{text:'Aparece como componente en'}),
el('p',{class:'sub',text:'Actas de otras unidades que incluyen esta serie entre sus módulos.'}));
j.relacionadas.forEach(a=>{
const [nom]=NOMBRE[a.tipo]||[a.tipo];
r.append(el('div',{class:'ev'},
el('div',{class:'t',html:nom+' — <a href="?serie='+encodeURIComponent(a.serie)+'">'+a.serie+'</a>'}),
el('div',{class:'f',text:fecha(a.recibido)+' · '+(a.tecnico||'—')+' · '+(a.resultado||'')})));
});
cont.append(r);
}
}
// ---------- trazabilidad ----------
// Cadena de producción (tarjeta → módulos → integración → ensamble → configuración) y cadena de
// campo (envío → recepción → instalación → diagnósticos → daños), con fecha y sitio. Para una unidad
// HBOX, las actas de sus módulos viven en OTRAS series (componentes del ensamble): se consultan aparte.
let SITIOS = null;
async function nombreSitio(id){
if(!id) return '';
if(!SITIOS){ try{ SITIOS = ((await (await fetch('/api/sitios?todos=1')).json()).sitios)||[]; }catch(e){ SITIOS=[]; } }
const s = SITIOS.find(x=>x.id===id); return s ? s.nombre : id;
}
function paso(nombre, cls, estado, detalle){
const p=el('div',{class:'paso '+cls}); p.append(el('div',{class:'n',text:nombre}), el('div',{class:'e',text:estado}));
if(detalle) p.append(el('div',{class:'d',html:detalle}));
return p;
}
function ultima(actas, tipos){ return actas.filter(a=>tipos.includes(a.tipo))[0]||null; } // ya vienen de reciente a vieja
function pasoActa(nombre, a, textoOK, textoPend){
if(!a) return paso(nombre,'pend',textoPend||'sin acta');
const falla = a.resultado==='FALLA';
return paso(nombre, falla?'falla':'ok', falla?'✕ FALLA':(textoOK||'✓ '+(a.resultado||'OK')), (a.fecha||fecha(a.recibido).slice(0,10))+(a.tecnico?' · '+a.tecnico:''));
}
function trazabilidad(j){
const sec=el('section',{class:'bloque'});
sec.append(el('h2',{text:'Trazabilidad'}), el('p',{class:'sub',text:'De la tarjeta al puerto: cada paso con su acta, fecha y sitio. Gris = todavía no hay acta.'}));
const A=j.actas, esHbox=/^HBOX/i.test(j.serie);
// --- producción
sec.append(el('div',{class:'cad-t',text:'Producción'}));
const prod=el('div',{class:'cadena'}); sec.append(prod);
const ens=ultima(A,['ensamble']);
let comp=(ens&&ens.componentes)||{};
// sin acta de ensamble, los módulos se deducen del número de unidad: HBOX5-E02-A002-R0002 → V5-02 · ARD-002 · RTK-0002
const mSer=!comp.einar && j.serie.match(/^HBOX5-E(\d+)-A(\d+)-R(\d+)$/i);
if(mSer) comp={einar:'V5-'+mSer[1].padStart(2,'0'), ard:'ARD-'+mSer[2].padStart(3,'0'), modem:'RTK-'+mSer[3].padStart(4,'0')};
if(esHbox){
// módulos: se consultan por su serie
const m=[['Tarjeta EinarBoard','prueba-v5',comp.einar],['ArduSimple','modulo-ard',comp.ard],['Módem 4G','modulo-4g',comp.modem]];
const ph={}; m.forEach(([n,,s])=>{ const p=paso(n,'pend',s?'consultando…':'sin ensamble', s?'<a href="?serie='+encodeURIComponent(s)+'">'+s+'</a>':''); ph[n]=p; prod.append(p); });
const integ=paso('Integración','pend',comp.ard&&comp.modem?'consultando…':'—'); prod.append(integ);
(async()=>{
for(const [n,tipo,s] of m){
if(!s) continue;
let txt='sin acta', cls='pend';
try{ const r=await (await fetch(`/api/acta?tipo=${tipo}&serie=${encodeURIComponent(s)}`)).json();
if(r.exists){ cls=r.acta.resultado==='FALLA'?'falla':'ok'; txt=cls==='ok'?'✓ aprobada':'✕ FALLA'; ph[n].querySelector('.d').innerHTML+=' · '+(r.acta.fecha||fecha(r.acta._recibido).slice(0,10)); } }catch(e){}
ph[n].className='paso '+cls; ph[n].querySelector('.e').textContent=txt;
}
if(comp.ard&&comp.modem){
const si='INT-A'+(String(comp.ard).match(/(\d+)\s*$/)||[,''])[1]+'-R'+(String(comp.modem).match(/(\d+)\s*$/)||[,''])[1];
let txt='sin acta', cls='pend';
try{ const r=await (await fetch(`/api/acta?tipo=integracion&serie=${encodeURIComponent(si)}`)).json();
if(r.exists){ cls=r.acta.resultado==='FALLA'?'falla':'ok'; txt=cls==='ok'?'✓ aprobada':'✕ FALLA'; integ.append(el('div',{class:'d',html:'<a href="?serie='+encodeURIComponent(si)+'">'+si+'</a> · '+(r.acta.fecha||fecha(r.acta._recibido).slice(0,10))})); } }catch(e){}
integ.className='paso '+cls; integ.querySelector('.e').textContent=txt;
}
})();
prod.append(pasoActa('Ensamble',ens,'✓ unidad lista'));
prod.append(pasoActa('Configuración / pruebas',ultima(A,['reflasheo','prueba-ethernet-serie','prueba-bateria-modem']),null,'sin acta'));
} else {
// pieza suelta: su propia acta de prueba, y en qué unidad quedó
prod.append(pasoActa('Acta de la pieza',ultima(A,['prueba-v5','prueba-v4','modulo-ard','modulo-4g'])));
const integ=j.relacionadas.find(r=>r.tipo==='integracion'), ensR=j.relacionadas.find(r=>r.tipo==='ensamble');
prod.append(integ?paso('Integración',integ.resultado==='FALLA'?'falla':'ok',integ.resultado==='FALLA'?'✕ FALLA':'✓ en pareja','<a href="?serie='+encodeURIComponent(integ.serie)+'">'+integ.serie+'</a>'):paso('Integración','pend','sin acta'));
prod.append(ensR?paso('Ensamble',ensR.resultado==='FALLA'?'falla':'info',ensR.resultado==='FALLA'?'✕ FALLA':'▣ montada en unidad','<a href="?serie='+encodeURIComponent(ensR.serie)+'">'+ensR.serie+'</a>'):paso('Ensamble','pend','libre (no está en una unidad)'));
prod.append(pasoActa('Banco / reflasheo',ultima(A,['diagnostico-banco','reflasheo']),null,'—'));
}
// --- campo
sec.append(el('div',{class:'cad-t',text:'Campo'}));
const campo=el('div',{class:'cadena'}); sec.append(campo);
const env=ultima(A,['envio']), rec=ultima(A,['recepcion']), ins=A.filter(a=>a.tipo==='instalacion-campo'&&a.resultado!=='FALLA')[0]||ultima(A,['instalacion-campo']);
const diags=A.filter(a=>a.tipo==='diagnostico-campo'), danos=A.filter(a=>a.tipo==='dano');
const conSitio=async(p,a)=>{ if(a&&a.sitio){ const n=await nombreSitio(a.sitio); p.querySelector('.d').innerHTML+=' · <a href="sitios/sitio.html?id='+encodeURIComponent(a.sitio)+'">'+n+'</a>'; } };
const pEnv=env?paso('Envío',env.accion&&/detenida/i.test(env.accion)?'info':'ok',env.accion||'✓ enviada',(env.fecha||'')+(env.clasificacion?' · '+env.clasificacion:'')):paso('Envío','pend','sin acta'); campo.append(pEnv); conSitio(pEnv,env);
const pRec=rec?paso('Recepción',rec.resultado==='FALLA'?'falla':'ok',rec.accion||rec.resultado||'✓',(rec.fecha||'')+(rec.clasificacion?' · '+rec.clasificacion:'')):paso('Recepción','pend','sin acta'); campo.append(pRec); conSitio(pRec,rec);
const pIns=ins?paso('Instalación',ins.resultado==='FALLA'?'falla':'ok',ins.accion||(ins.resultado==='FALLA'?'✕ FALLA':'✓ instalada'),(ins.fecha||'')+(ins.maquina?' · '+ins.maquina:'')):paso('Instalación','pend','sin acta','<a href="formularios/instalacion.html">llenar acta</a>'); campo.append(pIns); conSitio(pIns,ins);
const pDg=diags.length?paso('Diagnósticos',diags[0].resultado==='FALLA'?'falla':'info',diags.length+' visita(s)',(diags[0].fecha||'')+(diags[0].accion?' · '+diags[0].accion:'')):paso('Diagnósticos','pend','ninguno'); campo.append(pDg); conSitio(pDg,diags[0]);
const pDn=j.marca?paso('Daños',j.marca==='BAJA'?'falla':'falla',j.marca==='BAJA'?'⛔ BAJA':'⚠ DAÑADA',(j.marca_acta&&j.marca_acta.fecha)||''):(danos.length?paso('Daños','ok','reparada / sin marca',danos.length+' acta(s)'):paso('Daños','ok','sin daños')); campo.append(pDn); conSitio(pDn,danos[0]);
return sec;
}
async function cargar(serie){
cont.innerHTML='<p class="vacio">Cargando…</p>';
try{
const j=await (await fetch('/api/unidad?serie='+encodeURIComponent(serie),{cache:'no-store'})).json();
if(!j.ok) throw new Error(j.error||'error');
render(j);
}catch(e){
cont.innerHTML='';
cont.append(el('div',{class:'err',text:'No se pudo leer el expediente: '+e.message}));
}
}
</script>
<p>Este tablero ahora está dentro del sitio: <a href="/tableros/unidad/">Expediente de unidad</a>.</p>
</body>
</html>

47
hooks/frontmatter.py Normal file
View File

@ -0,0 +1,47 @@
"""Repara el encabezado YAML (front matter) de las páginas que llegan con dos puntos sin comillas.
Ejemplo real (19-oct): `author: Helgen Technologies (auditoría: Claude; revisión: Leo)`.
YAML no acepta ese valor, MkDocs ignora todo el encabezado y lo pinta como texto al principio
de la página. Aquí, solo si el encabezado no se puede leer, se ponen entre comillas los valores
que tienen «: » y se vuelve a intentar. El archivo en disco no se toca (muchas de estas páginas
llegan por scripts/sync-docs.sh); lo correcto es arreglar el original en el repo Drogheda.
"""
import logging
import re
import yaml
log = logging.getLogger("mkdocs.hooks.frontmatter")
_FM = re.compile(r"\A(---[ \t]*\n)(.*?\n)(---[ \t]*\n)", re.S)
_LINEA = re.compile(r"^([A-Za-z0-9_\-]+):[ \t]+(.+?)[ \t]*$")
def _lee(texto):
try:
datos = yaml.safe_load(texto)
return isinstance(datos, dict)
except yaml.YAMLError:
return False
def on_page_read_source(page, config, **kwargs):
try:
with open(page.file.abs_src_path, encoding="utf-8-sig") as f:
fuente = f.read()
except OSError:
return None
m = _FM.match(fuente)
if not m or _lee(m.group(2)):
return None
lineas = []
for linea in m.group(2).split("\n"):
r = _LINEA.match(linea)
if r and ": " in r.group(2) and r.group(2)[0] not in "\"'[{|>":
valor = r.group(2).replace("\\", "\\\\").replace('"', '\\"')
linea = f'{r.group(1)}: "{valor}"'
lineas.append(linea)
nuevo = "\n".join(lineas)
if not _lee(nuevo):
return None
log.info("Encabezado YAML reparado (dos puntos sin comillas): %s", page.file.src_uri)
return m.group(1) + nuevo + m.group(3) + fuente[m.end():]

38
hooks/iconos.py Normal file
View File

@ -0,0 +1,38 @@
"""Íconos de línea del menú lateral (overrides/.icons/helgen/*.svg).
Material pone el ícono que trae cada página en su front matter (`icon:`). Varias
páginas llegan por scripts/sync-docs.sh desde el repo Drogheda y se sobrescriben,
así que el ícono se asigna aquí, por ruta, sin tocar esas páginas.
Si una página ya trae `icon:` en su front matter, ese manda.
"""
ICONOS = {
"index.md": "helgen/inicio",
"tableros/pendientes.md": "helgen/pendientes",
"bitacora.md": "helgen/bitacora",
# Tableros
"tableros/index.md": "helgen/pendientes",
"tableros/ingenieria.md": "helgen/ingenieria",
"tableros/produccion.md": "helgen/barras",
"tableros/actas.md": "helgen/actas",
"tableros/inventario.md": "helgen/inventario",
"tableros/sitio.md": "helgen/sitios",
"tableros/equipo.md": "helgen/equipo",
"tableros/unidad.md": "helgen/unidad",
"tableros/campo.md": "helgen/visitas",
# Documentos
"guia-ingeniero.md": "helgen/manuales",
"produccion/index.md": "helgen/fabrica",
"taller/index.md": "helgen/taller",
"campo/index.md": "helgen/campo",
"hardware/index.md": "helgen/tarjetas",
"historia/index.md": "helgen/historia",
"descargas/index.md": "helgen/descargas",
}
def on_page_markdown(markdown, page, config, files, **kwargs):
icono = ICONOS.get(page.file.src_uri)
if icono and not page.meta.get("icon"):
page.meta["icon"] = icono
return markdown

View File

@ -1,31 +1,37 @@
site_name: HBOX — Documentación técnica (Helgen)
site_description: Procedimientos de producción, taller, campo y hardware del HBOX (EinarBoard) — Helgen Technologies
site_name: Drogheda
site_description: HBOX RTK (EinarBoard) — producción, taller, campo, tableros y hardware. Sitio interno de Helgen Technologies.
site_url: https://drogheda.helgen.online/
copyright: "© Helgen Technologies — uso interno"
theme:
name: material
custom_dir: overrides # logo, plantilla de tableros e íconos de línea (overrides/.icons/helgen)
language: es
logo: img/helgen-logo-white.png
favicon: img/favicon.png
font: false
font: false # letras propias en docs/fonts (sitio utilizable sin Internet)
icon:
search: helgen/buscar
menu: helgen/menu
close: helgen/cerrar
previous: helgen/izquierda
next: helgen/derecha
top: helgen/arriba
# Oscuro por omisión: el primero de la lista manda. El botón del encabezado cambia a claro.
palette:
- scheme: default
primary: custom
accent: custom
toggle:
icon: material/weather-night
name: Modo oscuro
- scheme: slate
primary: custom
accent: custom
toggle:
icon: material/weather-sunny
name: Modo claro
icon: helgen/sol
name: Cambiar a modo claro
- scheme: default
primary: custom
accent: custom
toggle:
icon: helgen/luna
name: Cambiar a modo oscuro
features:
- navigation.tabs
- navigation.tabs.sticky
- navigation.sections
- navigation.sections # barra lateral con secciones, sin pestañas arriba
- navigation.indexes
- navigation.top
- navigation.footer
@ -35,9 +41,22 @@ theme:
- content.code.copy
- toc.follow
extra:
# Clave con que el navegador guarda las preferencias (modo claro/oscuro).
# Se cambió el 20-oct-2026 para que todos arranquen en oscuro; js/tema.js lee la misma clave.
scope: /drogheda/
extra_css:
- stylesheets/helgen.css
- stylesheets/extra.css
extra_javascript:
- js/sitio.js
hooks:
- hooks/iconos.py # íconos del menú lateral (también para páginas que llegan por sync-docs.sh)
- hooks/frontmatter.py # encabezados YAML con «: » sin comillas: se leen igual (no toca los archivos)
plugins:
- search:
lang: [es, en]
@ -46,6 +65,7 @@ plugins:
markdown_extensions:
- admonition
- tables
- def_list
- attr_list
- md_in_html
- pymdownx.details
@ -55,149 +75,148 @@ markdown_extensions:
- toc:
permalink: true
# Menú lateral. Reglas: sin emoji, títulos de 4 palabras o menos.
# Documento nuevo con fecha (auditoría, pasada, plan): agréguelo arriba en
# Tarjetas y hardware → Auditorías y pasadas («19 oct · Tema») y en bitacora.md.
# Cuando lo supere otro, muévalo a Historia → Tarjetas superadas.
nav:
- "🏠 Inicio":
- index.md
- "Guía del ingeniero": guia-ingeniero.md
- "⚙ Cuenta":
- "Mi cuenta (cambiar contraseña)": cuenta.html
- "Administración (Leo)": admin.html
- "Salir": salir.html
- Inicio: index.md
- Pendientes: tableros/pendientes.md
- Bitácora: bitacora.md
- "📋 Tableros":
- Tableros:
- tableros/index.md
- "Kanban — tareas del equipo": tableros/kanban.html
- "Equipo — miembros, responsables y accesos": tableros/equipo.html
- "Ingeniería — estado por versión de tarjeta": tableros/ingenieria.html
- "Producción — plan 570 vs. avance real": tableros/produccion.html
- "Estado de actas (tarjetas, módulos, unidades)": tablero.html
- "Tablero de campo (visitas de diagnóstico)": diagnostico/tablero-campo.html
- "Expediente de una unidad": unidad.html
- Ingeniería: tableros/ingenieria.md
- Producción: tableros/produccion.md
- Actas: tableros/actas.md
- Inventario: tableros/inventario.md
- Sitios: tableros/sitio.md
- Equipo: tableros/equipo.md
- Expediente de unidad: tableros/unidad.md
- Visitas de campo: tableros/campo.md
- "🏭 Producción":
- Documentos:
- Guía del ingeniero: guia-ingeniero.md
- Producción:
- produccion/index.md
- "1 · Prueba y flasheo de tarjetas":
- procedimientos/v5-prueba-y-flasheo.md
- "Acta de tarjeta V5": formularios/v5.html
- "2 · Módulos RTK y 4G":
- procedimientos/v5-modulos-integracion.md
- "Acta ArduSimple": formularios/ardusimple.html
- "Acta módem 4G": formularios/modem4g.html
- "Acta de integración": formularios/integracion.html
- "3 · Ensamble de la unidad":
- procedimientos/v5-ensamble.md
- "Acta de ensamble": formularios/ensamble.html
- "4 · Prueba final antes de embarcar": procedimientos/v5-prueba-final-unidad.md
- "5 · Configuración de la unidad": procedimientos/v5-configuracion-unidad.md
- "📦 Inventario":
- inventario/index.md
- "Inventario — existencias y movimientos": inventario/inventario.html
- "🔬 Taller":
- Prueba de tarjetas:
- Procedimiento: procedimientos/v5-prueba-y-flasheo.md
- Acta de tarjeta V5: formularios/v5.html
- Módulos RTK y 4G:
- Procedimiento: procedimientos/v5-modulos-integracion.md
- Acta ArduSimple: formularios/ardusimple.html
- Acta módem 4G: formularios/modem4g.html
- Acta de integración: formularios/integracion.html
- Ensamble:
- Procedimiento: procedimientos/v5-ensamble.md
- Acta de ensamble: formularios/ensamble.html
- Prueba final: procedimientos/v5-prueba-final-unidad.md
- Configuración: procedimientos/v5-configuracion-unidad.md
- Política de inventario: inventario/index.md
- Taller:
- taller/index.md
- "Diagnóstico en banco":
- procedimientos/diagnostico-banco.md
- "Acta de diagnóstico en banco": formularios/banco.html
- "Reflasheo de unidades":
- procedimientos/reflasheo-unidades.md
- "Acta de reflasheo y configuración": formularios/reflasheo.html
- "Prueba: módem 4G en batería":
- procedimientos/prueba-bateria-modem.md
- "Acta: módem en batería": formularios/prueba-bateria.html
- "Prueba: Ethernet y puertos serie":
- procedimientos/v5-prueba-ethernet-serie.md
- "Acta: Ethernet y puertos serie": formularios/ethernet-serie.html
- "V4 — preparación y prueba con 0.6.0":
- procedimientos/v4-prueba-fw-0.5.6.md
- "Acta de prueba V4": formularios/v4.html
- "V4 — riel SW_5V y arranque del F9P (sin NMEA)": procedimientos/v4-prueba-riel-sw5v-f9p.md
- "🚜 Campo":
- Diagnóstico en banco:
- Procedimiento: procedimientos/diagnostico-banco.md
- Acta de banco: formularios/banco.html
- Reflasheo:
- Procedimiento: procedimientos/reflasheo-unidades.md
- Acta de reflasheo: formularios/reflasheo.html
- Módem en batería:
- Procedimiento: procedimientos/prueba-bateria-modem.md
- Acta de la prueba: formularios/prueba-bateria.html
- Ethernet y serie:
- Procedimiento: procedimientos/v5-prueba-ethernet-serie.md
- Acta de la prueba: formularios/ethernet-serie.html
- V4 con 0.6.0:
- Procedimiento: procedimientos/v4-prueba-fw-0.5.6.md
- Acta de prueba V4: formularios/v4.html
- V4 sin NMEA: procedimientos/v4-prueba-riel-sw5v-f9p.md
- Campo:
- campo/index.md
- "Diagnóstico en campo":
- diagnostico/procedimiento-diagnostico-campo.md
- "Acta de diagnóstico en campo": formularios/diagnostico.html
- "Instalación en el puerto":
- "Checklist de puesta en marcha (imprimir)": procedimientos/v5-checklist-puesta-en-marcha.md
- "Acta de instalación en campo": formularios/instalacion.html
- "Envío, recepción y daños":
- procedimientos/ciclo-de-vida.md
- "Acta de envío": formularios/envio.html
- "Acta de recepción e inspección": formularios/recepcion.html
- "Acta de daño o incidente": formularios/dano.html
- "📍 Sitios":
- sitios/index.md
- "Sitio — unidades instaladas y actas": sitios/sitio.html
- "Actas — índice y cómo se usan": actas/index.md
- "🔩 Tarjetas y hardware":
- Diagnóstico en campo:
- Procedimiento: diagnostico/procedimiento-diagnostico-campo.md
- Acta de diagnóstico: formularios/diagnostico.html
- Instalación:
- Checklist para imprimir: procedimientos/v5-checklist-puesta-en-marcha.md
- Acta de instalación: formularios/instalacion.html
- Envío y recepción:
- Ciclo de vida: procedimientos/ciclo-de-vida.md
- Acta de envío: formularios/envio.html
- Acta de recepción: formularios/recepcion.html
- Acta de daño: formularios/dano.html
- Sitios y puertos: sitios/index.md
- Cómo funcionan las actas: actas/index.md
- Tarjetas y hardware:
- hardware/index.md
- "▶ V5.6 — qué es crítico y qué se puede quedar (19-oct, tarde)": hardware/v56-clasificacion-19oct.md
- "▶ Re-auditoría V5.6 · V5.7 · v6 (19-oct)": hardware/re-auditoria-19oct.md
- "▶ Layout pasada 4 — cierre V5.6 y V5.7 (Michelle, 19-oct)": hardware/v56-v57-layout-pasada-4.md
- "▶ v6 lista para producción — plan de fines de semana (rev. 1.1)": hardware/v6-plan-fines-de-semana.md
- "Auditoría de producción v6 (16-oct)": hardware/v6-auditoria-produccion-16oct.md
- "Auditoría de producción V5.6 · V5.7 · v6 (16-oct)": hardware/auditoria-produccion-16oct.md
- "Manuales por tarjeta": manuales/index.md
- "V5.5 en campo · V5.6 en layout → pedido 113":
- "Manual de la tarjeta V5 (V5.5 y V5.6)": manuales/manual-tarjeta-V5.md
- "Hoja de datos V5.6 (1-oct)": hardware/v56-datasheet.md
- "Pinout de conectores V5.6 (29-sep)": hardware/v56-pinout.md
- "ECO V5.6 — requisitos R1–R5 (27-sep)": hardware/v56-eco.md
- "Cambios al esquemático V5.5 → V5.6 (28-sep)": hardware/v56-cambios-esquematico.md
- "Reglas de layout: esquemático → PCB (29-sep)": hardware/v56-layout-instrucciones.md
- "V5.7 fuente a bordo (9–32 V) → pedido 327 (en layout)":
- "ECO V5.7 — fuente 24 V → 5 V a bordo (v0.2, 13-oct)": hardware/v57-eco-fuente.md
- "v6 GNSS + 4G a bordo → pedido 130 (en diseño)":
- "Manual de la tarjeta v6 (marcador)": manuales/manual-tarjeta-v6.md
- "Próximos pasos para ordenar (29-sep, superado el 16-oct)": hardware/v6-proximos-pasos.md
- "Plan de corrección y lista de liberación (28-sep)": hardware/v6-plan-correccion.md
- "Esquemático final — resumen de las 3 pasadas (28-sep)": hardware/v6-cambios-resumen.md
- "Mapa de pines MCU para board_v6.h (28-sep)": hardware/v6-pinmap.md
- "Plan de cobre y colocación EMC (5-sep)": hardware/EinarBoard-v6_Plan-de-Cobre_ES.html
- "Plan de cobre y colocación EMC (inglés)": hardware/EinarBoard-v6_Copper-Plan_EN.html
- "v6 — auditoría del 28-sep y sus pasadas (detalle)":
- "Auditoría completa v6 (28-sep)": hardware/v6-auditoria-completa.md
- "Auditoría completa v6 (inglés, original)": hardware/v6-full-audit.md
- "Pasada 1 — cambios al esquemático (28-sep)": hardware/v6-cambios-esquematico.md
- "Pasada 2 — puertos y sin nueva versión (inglés)": hardware/v6-puertos-sin-nueva-version.md
- "Pasada 3 — revisión final independiente (inglés)": hardware/v6-revision-final.md
- "Revisión v6 contra lecciones V5.5 (28-sep, superada)": hardware/v6-revision-lecciones-v55.md
- "V4 y V3 — tarjetas anteriores aún en campo":
- "Manual de la tarjeta V4": manuales/manual-tarjeta-V4.md
- "Manual de la tarjeta V3": manuales/manual-tarjeta-V3.md
- "Pinout V3 (arneses y conectores, jul-2026)": montaje/pinout-arneses.md
- "📕 Manuales y descargas":
- "Manual del cliente (instalación y operación)": manuales/manual-cliente.md
- "Manual de instalación y operación v2.0 (PDF)": manuales/Drogheda-Manual-Instalacion-Operacion_v2.0.pdf
- "Ficha técnica HBOX v2.0 (PDF)": manuales/Drogheda-HBOX-Datasheet_v2.0.pdf
- "Descargas (firmware por tarjeta)":
- Manuales por tarjeta: manuales/index.md
- Auditorías y pasadas:
- 19 oct · Críticos V5.6: hardware/v56-clasificacion-19oct.md
- 19 oct · Re-auditoría: hardware/re-auditoria-19oct.md
- 19 oct · Pasada 4: hardware/v56-v57-layout-pasada-4.md
- 16 oct · Plan v6: hardware/v6-plan-fines-de-semana.md
- 16 oct · Auditoría v6: hardware/v6-auditoria-produccion-16oct.md
- 16 oct · Auditoría de producción: hardware/auditoria-produccion-16oct.md
- V5.5 y V5.6:
- Manual V5: manuales/manual-tarjeta-V5.md
- Hoja de datos V5.6: hardware/v56-datasheet.md
- Pinout V5.6: hardware/v56-pinout.md
- ECO V5.6: hardware/v56-eco.md
- Cambios al esquemático: hardware/v56-cambios-esquematico.md
- Reglas de layout: hardware/v56-layout-instrucciones.md
- V5.7:
- ECO V5.7: hardware/v57-eco-fuente.md
- v6:
- Manual v6: manuales/manual-tarjeta-v6.md
- Plan de corrección: hardware/v6-plan-correccion.md
- Esquemático final: hardware/v6-cambios-resumen.md
- Mapa de pines: hardware/v6-pinmap.md
- Plan de cobre: hardware/EinarBoard-v6_Plan-de-Cobre_ES.html
- Plan de cobre (inglés): hardware/EinarBoard-v6_Copper-Plan_EN.html
- Auditoría del 28 sep: hardware/v6-auditoria-completa.md
- Auditoría 28 sep (inglés): hardware/v6-full-audit.md
- Pasada 1 (28 sep): hardware/v6-cambios-esquematico.md
- Pasada 2 (inglés): hardware/v6-puertos-sin-nueva-version.md
- Pasada 3 (inglés): hardware/v6-revision-final.md
- Próximos pasos (superado): hardware/v6-proximos-pasos.md
- V4 y V3:
- Manual V4: manuales/manual-tarjeta-V4.md
- Manual V3: manuales/manual-tarjeta-V3.md
- Pinout V3: montaje/pinout-arneses.md
- Manuales y descargas:
- Manual del cliente: manuales/manual-cliente.md
- Manual de instalación (PDF): manuales/Drogheda-Manual-Instalacion-Operacion_v2.0.pdf
- Ficha técnica (PDF): manuales/Drogheda-HBOX-Datasheet_v2.0.pdf
- Descargas:
- descargas/index.md
- "Notas de versión del firmware V4 / V5": descargas/notas-de-version.md
- "README firmware hbox_v5_bringup (inglés)": descargas/hbox-v5-bringup-README.md
- "Git y archivos de diseño (KiCad)":
- "Guía Git para colaboradores": git/guia-git-es.md
- "Guía Git (inglés)": git/git-guide-en.md
- "📚 Referencia e histórico":
- Notas de versión: descargas/notas-de-version.md
- README del firmware: descargas/hbox-v5-bringup-README.md
- Git y KiCad:
- Guía Git: git/guia-git-es.md
- Guía Git (inglés): git/git-guide-en.md
- Historia:
- historia/index.md
- "Procedimientos anteriores (superados)":
- "Protocolo de diagnóstico remoto — unidades v1 (jun-2026)": diagnostico/protocolo-diagnostico-remoto.md
- "EinarBoard 1.0 — puesta en marcha (jul-2026)": procedimientos/einarboard-1.0-bringup.md
- "FW v3.0.3 — prueba de banco del ventilador (ago-2026)": procedimientos/fan-policy-bench-test.md
- "Prototipo v3 en protoboard — bring-up (jun-2026)": procedimientos/historico-proto-bench-v3.md
- "Prototipo v3 en protoboard — lista de compras (jun-2026)": procedimientos/historico-protoboard.md
- "V3 — producción de jul-2026 (superado)":
- "BOM V3 dividido para ensamble (jul-2026)": montaje/bom-split.md
- "Guía de ensamble y QC — borrador V3 (sep-2026)": montaje/guia-ensamble-qc.md
- "Guía de ensamble del gabinete 0007-1000 (PDF)": montaje/assembly-guide.md
- "Tarjetas — documentos superados":
- "V5.6/V5.7 layout pasada 3 (16-oct) — cerrada; lo pendiente pasó a la pasada 4": hardware/v56-v57-layout-pasada-3.md
- "V5.6 layout pasada 2 (Michelle, 13-oct) — superada por la pasada 3": hardware/v56-layout-pasada-2.md
- "V5.6 auditoría del layout 13-oct (inglés) — superada por la del 16-oct": hardware/v56-auditoria-layout-13oct.md
- "V5.7 guía de layout de la fuente (Leo, 13-oct) — superada: la fuente ya está colocada": hardware/v57-layout-guia.md
- "V5.7 colocación y ruteo (14-oct) — superada por la pasada 3": hardware/v57-colocacion-ruteo.md
- "V5.6 layout pasada 1 (Michelle, 12-oct) — superada por la pasada 2": hardware/v56-layout-pasada-1.md
- "v6 auditoría pre-fabricación (25-sep) — superada por la del 28-sep": hardware/v6-auditoria-prefab.md
- "v6 auditoría pre-fabricación (25-sep, inglés)": hardware/v6-prefab-audit.md
- Procedimientos superados:
- Diagnóstico remoto v1: diagnostico/protocolo-diagnostico-remoto.md
- EinarBoard 1.0: procedimientos/einarboard-1.0-bringup.md
- Ventilador FW 3.0.3: procedimientos/fan-policy-bench-test.md
- Protoboard v3: procedimientos/historico-proto-bench-v3.md
- Compras protoboard v3: procedimientos/historico-protoboard.md
- Producción V3:
- BOM V3: montaje/bom-split.md
- Ensamble y QC V3: montaje/guia-ensamble-qc.md
- Gabinete 0007-1000: montaje/assembly-guide.md
- Tarjetas superadas:
- 16 oct · Pasada 3: hardware/v56-v57-layout-pasada-3.md
- 14 oct · Ruteo V5.7: hardware/v57-colocacion-ruteo.md
- 13 oct · Pasada 2: hardware/v56-layout-pasada-2.md
- 13 oct · Auditoría de layout: hardware/v56-auditoria-layout-13oct.md
- 13 oct · Guía fuente V5.7: hardware/v57-layout-guia.md
- 12 oct · Pasada 1: hardware/v56-layout-pasada-1.md
- 28 sep · Lecciones V5.5: hardware/v6-revision-lecciones-v55.md
- 25 sep · Pre-fabricación v6: hardware/v6-auditoria-prefab.md
- 25 sep · Pre-fab (inglés): hardware/v6-prefab-audit.md
- Cuenta:
- Mi cuenta: cuenta.html
- Administración: admin.html
- Salir: salir.html

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 4.5h10a2 2 0 0 1 2 2V19a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6.5a2 2 0 0 1 2-2zM9 3.5h6v2.5H9zM8.5 11h7M8.5 15h5"/></svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3.5 19 6v5.5c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6zM9 12l2.2 2.2L15.5 10"/></svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M6 11l6-6 6 6"/></svg>

After

Width:  |  Height:  |  Size: 232 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17zM12 8v5M12 16.2v.3"/></svg>

After

Width:  |  Height:  |  Size: 275 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 20V11M10.5 20V5M16 20v-6M3 20h18"/></svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12h4l2.5-6 5 12L17 12h4"/></svg>

After

Width:  |  Height:  |  Size: 237 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zM20 20l-4.4-4.4"/></svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.5l6-2.5 6 2.5 6-2.5v13.5l-6 2.5-6-2.5-6 2.5zM9 4v13.5M15 6.5V20"/></svg>

After

Width:  |  Height:  |  Size: 279 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h5l3 3h8M4 17h7l3-3h6M7 7v10"/></svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zM5 20c.8-3.8 3.6-6 7-6s6.2 2.2 7 6"/></svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 5.5l6.5 6.5L9 18.5"/></svg>

After

Width:  |  Height:  |  Size: 232 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 20h14"/></svg>

After

Width:  |  Height:  |  Size: 250 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="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"/></svg>

After

Width:  |  Height:  |  Size: 300 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 5.3a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4zM3.5 19.5c.6-3.2 2.8-5 5.5-5s4.9 1.8 5.5 5M16.8 7a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM15.8 14.6c2.3 0 4.2 1.6 4.7 4.4"/></svg>

After

Width:  |  Height:  |  Size: 376 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 20.5h19M4 20.5V11l5 3v-3l5 3V7.5h4.5L20 20.5"/></svg>

After

Width:  |  Height:  |  Size: 260 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 5h12a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM8 3v4M16 3v4M4 10h16"/></svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4.5h16v4H4zM5.5 8.5V19a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V8.5M10 12.5h4"/></svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 18a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM7 16h4l3-8h3"/></svg>

After

Width:  |  Height:  |  Size: 293 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 10.5 12 3l9 7.5M5 9.5V20h14V9.5M10 20v-6h4v6"/></svg>

After

Width:  |  Height:  |  Size: 258 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9"/></svg>

After

Width:  |  Height:  |  Size: 267 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 5.5 8.5 12l6.5 6.5"/></svg>

After

Width:  |  Height:  |  Size: 233 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>

After

Width:  |  Height:  |  Size: 309 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="hg-i" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></svg>

After

Width:  |  Height:  |  Size: 260 B

Some files were not shown because too many files have changed in this diff Show More