225 lines
13 KiB
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>
|