hbox-docs/docs/tablero.html

225 lines
13 KiB
HTML

<!doctype html>
<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:var(--tinta);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>
</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>
</body>
</html>