Files
claudetools/projects/dataforth-dos/testdatadb-fix/public/index.redesign.html

489 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dataforth · TestDataDB</title>
<style>
:root{
--bg:#f6f8fa; --surface:#ffffff; --border:#e3e8ee; --border-strong:#cbd5e1;
--ink:#0f172a; --ink-2:#475569; --ink-3:#94a3b8; --accent:#1e40af; --accent-soft:#eef4ff;
--pass-bg:#dcfce7; --pass-ink:#15803d; --fail-bg:#fee2e2; --fail-ink:#b91c1c;
--hover:#f1f5f9; --sel:#e0e7ff; --desk:#e7ecf1;
--mono:ui-monospace,"SFMono-Regular",Consolas,"Liberation Mono",monospace;
--sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--r:6px; --insp:480px;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{font-family:var(--sans);font-size:13.5px;color:var(--ink);background:var(--bg);
display:grid;grid-template-rows:auto 1fr;overflow:hidden;-webkit-font-smoothing:antialiased}
button,input,select{font-family:inherit}
:focus-visible{outline:none;box-shadow:0 0 0 2px var(--surface),0 0 0 4px var(--accent)}
/* ---------- header ---------- */
header{display:flex;align-items:center;gap:14px;padding:0 14px;height:50px;
background:var(--surface);border-bottom:1px solid var(--border);position:relative;z-index:30}
.brand{font-weight:700;letter-spacing:-.01em;white-space:nowrap;font-size:14.5px}
.brand b{color:var(--accent);font-weight:700}
.menubtn{display:none;border:1px solid var(--border);background:none;border-radius:var(--r);height:32px;width:34px;cursor:pointer;color:var(--ink-2)}
.omni{flex:1;position:relative;max-width:780px}
.omni .field{display:flex;align-items:center;height:38px;border:1px solid var(--border-strong);border-radius:8px;background:var(--bg);padding:0 8px 0 30px;position:relative}
.omni .field:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft);background:#fff}
.omni .ic{position:absolute;left:9px;color:var(--ink-3);font-size:15px;line-height:1}
.omni input{flex:1;height:100%;border:0;background:none;font-size:14px;color:var(--ink);outline:none}
.mode{font-size:10.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--accent);
background:var(--accent-soft);border:1px solid #dbeafe;border-radius:5px;padding:2px 7px;cursor:pointer;white-space:nowrap}
.mode:hover{background:#dbeafe}
.history{position:absolute;top:44px;left:0;right:0;background:#fff;border:1px solid var(--border);border-radius:8px;
box-shadow:0 8px 24px rgba(15,23,42,.12);padding:5px;display:none;z-index:40}
.history.show{display:block}
.history .hl{font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--ink-3);padding:5px 8px}
.history button{display:flex;align-items:center;gap:8px;width:100%;text-align:left;border:0;background:none;
border-radius:5px;padding:6px 8px;font-size:13px;cursor:pointer;color:var(--ink);font-family:var(--mono)}
.history button:hover{background:var(--hover)}
.badge{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-2);white-space:nowrap}
.dot{width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 0 3px #22c55e22}
.hbtn{font-size:12.5px;color:var(--ink-2);background:none;border:1px solid var(--border);border-radius:var(--r);height:32px;padding:0 11px;cursor:pointer}
.hbtn:hover{background:var(--hover)}
/* ---------- stats popover ---------- */
.pop{position:absolute;top:46px;right:12px;width:340px;background:#fff;border:1px solid var(--border);
border-radius:10px;box-shadow:0 12px 34px rgba(15,23,42,.16);padding:16px;display:none;z-index:50}
.pop.show{display:block}
.pop h4{margin:0 0 2px;font-size:13px}
.pop .big{font-size:26px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
.pop .sub{font-size:11.5px;color:var(--ink-3)}
.pop .bar{display:flex;height:10px;border-radius:5px;overflow:hidden;margin:12px 0 6px;background:#eef2f6}
.pop .bar i{display:block}
.pop .legend{display:flex;gap:14px;font-size:11.5px;color:var(--ink-2)}
.pop .legend span::before{content:"";display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px;vertical-align:middle}
.pop .lt{display:flex;justify-content:space-between;font-size:12px;padding:3px 0;border-top:1px solid var(--border);font-family:var(--mono)}
.pop .lt:first-of-type{border-top:0;margin-top:8px}
/* ---------- layout ---------- */
main{display:grid;grid-template-columns:236px 1fr var(--insp);min-height:0;height:100%}
.pane{min-height:0;overflow:auto}
/* ---------- filter rail ---------- */
.rail{border-right:1px solid var(--border);background:var(--surface);padding:14px;overflow-y:auto}
.rail h3{font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3);margin:18px 0 8px;font-weight:600}
.rail h3:first-child{margin-top:0}
.presets{display:flex;flex-direction:column;gap:5px}
.preset{display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12.5px;height:31px;padding:0 9px;
border:1px solid var(--border);border-radius:var(--r);background:#fff;color:var(--ink);cursor:pointer;transition:background .08s}
.preset:hover:not(:disabled){background:var(--accent-soft);border-color:#bfdbfe}
.preset .pi{width:14px;text-align:center;color:var(--accent);font-size:12px}
.preset:disabled{opacity:.45;cursor:not-allowed}
.famrow{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
.fam{font-family:var(--mono);font-size:11.5px;height:26px;padding:0 9px;border:1px solid var(--border);border-radius:5px;background:#fff;color:var(--ink-2);cursor:pointer}
.fam:hover{background:var(--accent-soft);border-color:#bfdbfe;color:var(--accent)}
.rail label{display:block;font-size:11px;color:var(--ink-3);margin:8px 0 3px}
.rail input,.rail select{width:100%;height:31px;font-size:13px;padding:0 8px;border:1px solid var(--border-strong);border-radius:var(--r);background:#fff;color:var(--ink)}
.rail input:focus,.rail select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.chips{display:flex;gap:5px;margin-top:7px}
.chip{font-size:11px;height:24px;padding:0 8px;border:1px solid var(--border);border-radius:20px;background:#fff;color:var(--ink-2);cursor:pointer}
.chip:hover{background:var(--hover)}
.reset{margin-top:18px;width:100%;height:31px;font-size:12px;background:none;border:1px solid var(--border);border-radius:var(--r);color:var(--ink-2);cursor:pointer}
.reset:hover{background:var(--hover)}
/* ---------- results ---------- */
.results{display:grid;grid-template-rows:auto auto 1fr auto;min-height:0;background:var(--surface)}
.rtoolbar{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid var(--border);font-size:12px;color:var(--ink-2)}
.rtoolbar .count{font-weight:700;color:var(--ink);font-variant-numeric:tabular-nums}
.rtoolbar .sp{flex:1}
.rtoolbar a{font-size:12px;color:var(--accent);text-decoration:none}
.rtoolbar a:hover{text-decoration:underline}
.rtoolbar select{font-size:12px;color:var(--ink-2);border:1px solid var(--border);border-radius:5px;height:27px;background:#fff}
.selbar{display:none;align-items:center;gap:10px;padding:6px 12px;background:var(--accent-soft);border-bottom:1px solid #dbeafe;font-size:12px;color:var(--accent)}
.selbar.show{display:flex}
.selbar button{font-size:12px;height:26px;padding:0 9px;border:1px solid #bfdbfe;border-radius:var(--r);background:#fff;color:var(--accent);cursor:pointer}
.selbar button:disabled{opacity:.5;cursor:not-allowed}
.twrap{overflow:auto;min-height:0}
table{width:100%;border-collapse:collapse;font-size:13px}
thead th{position:sticky;top:0;background:#f7f9fb;border-bottom:1px solid var(--border-strong);text-align:left;
font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);font-weight:600;padding:7px 10px;white-space:nowrap;z-index:1;user-select:none}
thead th.s{cursor:pointer}
thead th.s:hover{color:var(--ink-2)}
thead th .arr{color:var(--accent);font-size:9px;margin-left:3px}
thead th.ck{width:30px;padding-left:12px}
tbody td{padding:5px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
tbody td.ck{padding-left:12px}
tbody tr{cursor:pointer}
tbody tr:hover{background:var(--hover)}
tbody tr.sel{background:var(--sel)}
tbody tr.sel td:first-child{box-shadow:inset 3px 0 0 var(--accent)}
.mono{font-family:var(--mono);font-size:12.5px;font-variant-numeric:tabular-nums}
.pill{display:inline-block;font-size:10.5px;font-weight:700;padding:1px 8px;border-radius:4px;font-family:var(--mono)}
.pill.PASS{background:var(--pass-bg);color:var(--pass-ink)}
.pill.FAIL{background:var(--fail-bg);color:var(--fail-ink)}
.web{font-size:13px}
.pager{display:flex;align-items:center;gap:10px;padding:7px 12px;border-top:1px solid var(--border);font-size:12px;color:var(--ink-2)}
.pager button{font-size:12px;height:28px;padding:0 11px;border:1px solid var(--border-strong);border-radius:var(--r);background:#fff;cursor:pointer;color:var(--ink)}
.pager button:disabled{opacity:.4;cursor:default}
.pager .hint{color:var(--ink-3)}
.state{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:40px 20px;color:var(--ink-3);text-align:center}
.state .em{font-size:26px;opacity:.6}
.state.err{color:var(--fail-ink)}
.state button{margin-top:4px;font-size:12px;height:28px;padding:0 12px;border:1px solid var(--border-strong);border-radius:var(--r);background:#fff;cursor:pointer;color:var(--ink)}
.skel{height:13px;margin:8px 10px;border-radius:4px;background:linear-gradient(90deg,#eef2f6,#e2e8f0,#eef2f6);background-size:200% 100%;animation:sh 1.1s infinite}
@keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
/* ---------- inspector ---------- */
.insp{position:relative;border-left:1px solid var(--border);background:var(--surface);display:grid;grid-template-rows:auto auto 1fr;min-height:0}
.resizer{position:absolute;left:-3px;top:0;bottom:0;width:7px;cursor:col-resize;z-index:5}
.resizer:hover,.resizer.drag{background:linear-gradient(90deg,transparent,var(--accent) 45%,var(--accent) 55%,transparent)}
body.resizing{cursor:col-resize;user-select:none}
.meta{padding:12px 14px;border-bottom:1px solid var(--border)}
.meta .sn{font-family:var(--mono);font-size:16px;font-weight:700;letter-spacing:-.01em}
.meta dl{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;margin:10px 0 0;font-size:12.5px}
.meta dt{color:var(--ink-3)}
.meta dd{margin:0;font-family:var(--mono)}
.acts{display:flex;gap:6px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--border)}
.acts a,.acts button{font-size:12px;height:29px;padding:0 11px;border:1px solid var(--border-strong);border-radius:var(--r);
background:#fff;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer}
.acts a:hover,.acts button:hover:not(:disabled){background:var(--hover)}
.acts .pri{background:var(--accent);border-color:var(--accent);color:#fff}
.acts .pri:hover{background:#1c3aa9}
.acts button:disabled{opacity:.5;cursor:not-allowed}
.viewer{background:var(--desk);overflow:auto;padding:14px;position:relative}
.viewer iframe{display:block;width:100%;min-height:100%;border:1px solid var(--border);border-radius:5px;background:#fff;
box-shadow:0 1px 5px rgba(15,23,42,.10)}
kbd{font-family:var(--mono);font-size:11px;background:#f1f5f9;border:1px solid var(--border-strong);border-bottom-width:2px;border-radius:4px;padding:0 5px;color:var(--ink-2)}
/* ---------- responsive ---------- */
@media (max-width:1180px){
main{grid-template-columns:1fr var(--insp)}
.rail{position:absolute;top:50px;bottom:0;left:0;width:250px;z-index:20;box-shadow:6px 0 20px rgba(15,23,42,.12);transform:translateX(-104%);transition:transform .18s}
body.rail-open .rail{transform:none}
.menubtn{display:inline-flex;align-items:center;justify-content:center}
}
@media (max-width:820px){ main{grid-template-columns:1fr} .insp{position:absolute;top:50px;bottom:0;right:0;width:min(94vw,520px);z-index:18;box-shadow:-8px 0 24px rgba(15,23,42,.14)} .insp:not(.open){display:none} .resizer{display:none} }
</style>
</head>
<body>
<header>
<button class="menubtn" id="menu" title="Filters"></button>
<div class="brand">Dataforth <b>·</b> TestDataDB</div>
<div class="omni">
<div class="field">
<span class="ic">🔍</span>
<input id="omni" autocomplete="off" spellcheck="false" placeholder="Search serial, model, or text… ( / to focus, s: m: t: to force )">
<button class="mode" id="mode" title="Click to force the search mode">auto</button>
</div>
<div class="history" id="history"></div>
</div>
<div class="badge"><span class="dot"></span><span id="ingest">loading…</span></div>
<button class="hbtn" id="statsBtn">Stats ▾</button>
<div class="pop" id="statsPop"></div>
</header>
<main>
<aside class="rail pane" id="rail">
<h3>Quick searches</h3>
<div class="presets" id="presets"></div>
<div class="famrow" id="families"></div>
<h3>Test date</h3>
<div class="chips" id="dateChips"></div>
<label>From</label><input type="date" id="fFrom">
<label>To</label><input type="date" id="fTo">
<h3>Model</h3>
<input id="fModel" list="modelList" placeholder="any model" autocomplete="off">
<datalist id="modelList"></datalist>
<h3>Station</h3>
<select id="fStation"><option value="">any station</option></select>
<h3>Log type</h3>
<select id="fLog"><option value="">any log</option></select>
<button class="reset" id="reset">Reset all</button>
</aside>
<section class="results">
<div class="rtoolbar">
<span class="count" id="count"></span><span>results</span>
<span class="sp"></span>
<label>rows <select id="pageSize"><option>25</option><option selected>50</option><option>100</option></select></label>
<a id="exportCsv" href="#" title="Export current filtered results">Export CSV ↓</a>
</div>
<div class="selbar" id="selbar">
<span id="selcount">0 selected</span>
<button id="copySel">Copy serials</button>
<button id="repushSel" disabled title="Re-publish to the website — needs an API endpoint">Re-push ▴</button>
<span class="sp" style="flex:1"></span>
<button id="selclear" style="border:0;background:none">clear</button>
</div>
<div class="twrap" id="twrap">
<table>
<thead><tr>
<th class="ck"><input type="checkbox" id="ckAll" title="select page"></th>
<th class="s" data-s="serial_number">Serial</th>
<th class="s" data-s="model_number">Model</th>
<th class="s" data-s="test_date">Test Date</th>
<th>Stn</th><th>Log</th>
<th class="s" data-s="overall_result">Result</th>
<th title="Published to public website">Web</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div id="state"></div>
</div>
<div class="pager">
<button id="prev"> Prev</button>
<span id="pginfo">Page 1</span>
<button id="next">Next </button>
<span class="sp" style="flex:1"></span>
<span class="hint"><kbd>/</kbd> search · <kbd></kbd><kbd></kbd> rows · <kbd></kbd> open · <kbd>Esc</kbd> back</span>
</div>
</section>
<aside class="insp" id="insp">
<div class="resizer" id="resizer" title="Drag to resize"></div>
<div class="meta" id="meta"></div>
<div class="acts" id="acts" style="display:none"></div>
<div class="viewer" id="viewer"></div>
</aside>
</main>
<script>
const API='';
const $=id=>document.getElementById(id);
const state={q:'',serial:'',model:'',result:'',station:'',logtype:'',from:'',to:'',size:50,page:0,total:0,
selected:null,rows:[],sort:'',dir:'desc',checks:new Set(),force:''};
let timer=null, certTimer=null;
const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const fmtDate=d=>d?String(d).slice(0,10):'';
const isoD=d=>d.toISOString().slice(0,10);
/* ---------- omni routing (with s:/m:/t: overrides + encoded serials) ---------- */
function routeOmni(raw){
state.q=state.serial=state.model=''; let v=(raw||'').trim(), mode=state.force;
const pm=v.match(/^([smt]):\s*(.*)$/i); if(pm){ mode={s:'serial',m:'model',t:'text'}[pm[1].toLowerCase()]; v=pm[2]; }
if(!v){ $('mode').textContent=state.force||'auto'; return; }
if(!mode){
if(/\s/.test(v)) mode='text';
else if(/^(scm|dsc|[57]b|8b|pwr|vas)/i.test(v)) mode='model';
else mode='serial'; // numeric AND encoded (A243-1) serials land here
}
if(mode==='model') state.model=v; else if(mode==='text') state.q=v; else state.serial=v;
$('mode').textContent=mode;
}
$('mode').onclick=()=>{ const seq=['auto','serial','model','text']; state.force=seq[(seq.indexOf(state.force||'auto')+1)%4]; if(state.force==='auto')state.force=''; routeOmni($('omni').value); state.page=0; search(); };
/* ---------- query / search ---------- */
function params(forExport){
const p=new URLSearchParams();
for(const k of ['q','serial','model','result','station','logtype','from','to']) if(state[k]) p.set(k,state[k]);
if(state.sort){ p.set('sort',state.sort); p.set('dir',state.dir); } // needs API; harmless if ignored
if(!forExport){ p.set('limit',state.size); p.set('offset',state.page*state.size); }
return p;
}
function syncUrl(){ const p=params(); if(state.selected)p.set('selected',state.selected); history.replaceState(null,'','?'+p.toString()); }
function showState(html,cls){ $('state').className='state '+(cls||''); $('state').innerHTML=html; $('rows').innerHTML=''; }
async function search(){
$('state').innerHTML=''; $('rows').innerHTML=Array.from({length:9}).map(()=>'<tr><td colspan="8"><div class="skel"></div></td></tr>').join('');
try{
const r=await fetch(API+'/api/search?'+params().toString());
if(!r.ok) throw new Error('HTTP '+r.status);
const d=await r.json(); state.rows=d.records||[]; state.total=d.total||0; renderRows();
}catch(e){ showState('<div class="em">⚠</div>Search failed — '+esc(e.message)+'<button onclick="search()">Retry</button>','err'); $('count').textContent='—'; }
$('exportCsv').href=API+'/api/export?'+params(true).toString(); syncUrl();
}
function renderRows(){
$('count').textContent=state.total.toLocaleString();
const pages=Math.max(1,Math.ceil(state.total/state.size));
$('pginfo').textContent='Page '+(state.page+1)+' of '+pages.toLocaleString();
$('prev').disabled=state.page<=0; $('next').disabled=state.page>=pages-1;
if(!state.rows.length){ showState('<div class="em">∅</div>No records match.'+(activeFilters()?'<button onclick="document.getElementById(\'reset\').click()">Clear filters</button>':'')); return; }
$('state').innerHTML='';
$('rows').innerHTML=state.rows.map(r=>{
const ck=state.checks.has(String(r.id))?'checked':'';
return `<tr data-id="${r.id}">
<td class="ck"><input type="checkbox" data-ck="${r.id}" ${ck}></td>
<td class="mono" style="font-weight:600">${esc(r.serial_number)}</td>
<td class="mono">${esc(r.model_number)}</td>
<td class="mono">${fmtDate(r.test_date)}</td>
<td class="mono">${esc((r.test_station||'').replace(/^TS-/,''))}</td>
<td class="mono" style="color:var(--ink-3)">${esc((r.log_type||'').replace(/LOG$/,''))}</td>
<td><span class="pill ${r.overall_result}">${esc(r.overall_result)}</span></td>
<td class="web" style="text-align:center;color:${r.api_uploaded_at?'var(--pass-ink)':'var(--ink-3)'}" title="${r.api_uploaded_at?'published':'not published'}">${r.api_uploaded_at?'●':'○'}</td>
</tr>`;}).join('');
[...$('rows').children].forEach(tr=>{
tr.onclick=e=>{ if(e.target.dataset.ck!==undefined)return; select(tr.dataset.id); };
const cb=tr.querySelector('[data-ck]'); cb.onclick=e=>{e.stopPropagation(); const id=cb.dataset.ck; cb.checked?state.checks.add(id):state.checks.delete(id); updateSel();};
});
updateSortHeads();
if(state.serial&&state.rows[0]) select(state.rows[0].id,true);
else if(state.selected){ const tr=[...$('rows').children].find(t=>t.dataset.id==state.selected); if(tr)tr.classList.add('sel'); }
}
function activeFilters(){ return ['serial','model','q','result','station','logtype','from','to'].some(k=>state[k]); }
/* ---------- selection (instant) + cert (lazy) ---------- */
function select(id,auto){
state.selected=id;
[...$('rows').children].forEach(t=>t.classList.toggle('sel',t.dataset.id==id));
const r=state.rows.find(x=>x.id==id); if(!r) return;
$('meta').innerHTML=`<div class="sn">${esc(r.serial_number)}</div>
<dl><dt>Model</dt><dd>${esc(r.model_number)}</dd>
<dt>Date</dt><dd>${fmtDate(r.test_date)}</dd>
<dt>Station</dt><dd>${esc(r.test_station)}</dd>
<dt>Result</dt><dd><span class="pill ${r.overall_result}">${esc(r.overall_result)}</span></dd>
<dt>Log</dt><dd>${esc(r.log_type)}</dd>
${r.work_order?`<dt>WO</dt><dd>${esc(r.work_order)}</dd>`:''}
<dt>Web</dt><dd>${r.api_uploaded_at?'published '+fmtDate(r.api_uploaded_at):'not published'}</dd></dl>`;
const ds=API+'/api/datasheet/'+id;
$('acts').style.display='flex';
$('acts').innerHTML=`<a class="pri" href="${ds}?format=html" target="_blank">Open ↗</a>
<button onclick="printCert()">Print</button>
<a href="${ds}?format=txt" download="${esc(r.serial_number)}.txt">TXT</a>
<a href="${ds}?format=html" download="${esc(r.serial_number)}.html">HTML</a>
<button disabled title="Re-publish this cert — needs a POST /api/publish endpoint">Push to Web ▴</button>`;
$('insp').classList.add('open');
// lazy-load the certificate so fast arrow/typing stays snappy
$('viewer').innerHTML='<div class="state"><div class="skel" style="width:60%"></div></div>';
clearTimeout(certTimer);
certTimer=setTimeout(()=>loadCert(ds),auto?240:60);
syncUrl();
}
function loadCert(ds){
$('viewer').innerHTML='<iframe id="dsframe" title="datasheet"></iframe>';
const f=$('dsframe'); f.onload=()=>{styleCert();fitCert();}; f.src=ds+'?format=html';
}
function styleCert(){ const f=$('dsframe'); try{ const doc=f.contentDocument; if(!doc)return;
if(!doc.getElementById('_inj')){ const s=doc.createElement('style'); s.id='_inj';
s.textContent='html,body{background:#fff!important;margin:0!important}body{padding:22px 26px!important;color:#0f172a}pre{margin:0;font-family:'+getComputedStyle(document.body).getPropertyValue('--mono')+';font-size:12.5px;line-height:1.32}';
(doc.head||doc.documentElement).appendChild(s); }
}catch(e){} }
function fitCert(){ const f=$('dsframe'); if(!f)return; try{ const doc=f.contentDocument; if(!doc)return;
const root=doc.documentElement; root.style.zoom='';
const nat=Math.max(doc.body?doc.body.scrollWidth:0,root.scrollWidth), av=f.clientWidth-2;
root.style.zoom=(nat>av)?Math.max(.45,av/nat):1;
f.style.height=Math.ceil((doc.body?doc.body.scrollHeight:600)*(nat>av?av/nat:1)+4)+'px';
}catch(e){} }
function printCert(){ const f=$('dsframe'); if(f&&f.contentWindow){f.contentWindow.focus();f.contentWindow.print();} }
window.addEventListener('resize',()=>{fitCert();});
/* ---------- multi-select ---------- */
function updateSel(){ const n=state.checks.size; $('selbar').classList.toggle('show',n>0); $('selcount').textContent=n+' selected';
$('ckAll').checked=n>0&&state.rows.every(r=>state.checks.has(String(r.id))); }
$('ckAll').onclick=e=>{ state.rows.forEach(r=>{ e.target.checked?state.checks.add(String(r.id)):state.checks.delete(String(r.id)); });
[...$('rows').querySelectorAll('[data-ck]')].forEach(c=>c.checked=e.target.checked); updateSel(); };
$('copySel').onclick=()=>{ const sns=state.rows.filter(r=>state.checks.has(String(r.id))).map(r=>r.serial_number).join('\n');
navigator.clipboard&&navigator.clipboard.writeText(sns); $('copySel').textContent='Copied ✓'; setTimeout(()=>$('copySel').textContent='Copy serials',1200); };
$('selclear').onclick=()=>{ state.checks.clear(); [...$('rows').querySelectorAll('[data-ck]')].forEach(c=>c.checked=false); $('ckAll').checked=false; updateSel(); };
/* ---------- sort ---------- */
function updateSortHeads(){ [...document.querySelectorAll('thead th.s')].forEach(th=>{
const on=th.dataset.s===state.sort; th.querySelector('.arr')?.remove();
if(on){ const a=document.createElement('span'); a.className='arr'; a.textContent=state.dir==='asc'?'▲':'▼'; th.appendChild(a);} }); }
document.querySelectorAll('thead th.s').forEach(th=>th.onclick=()=>{
const f=th.dataset.s; if(state.sort===f) state.dir=state.dir==='asc'?'desc':'asc'; else {state.sort=f;state.dir=f==='test_date'?'desc':'asc';}
state.page=0; search(); });
/* ---------- filters / paging ---------- */
function debouncedSearch(){ clearTimeout(timer); timer=setTimeout(()=>{state.page=0;state.checks.clear();updateSel();search();},280); }
$('omni').addEventListener('input',e=>{routeOmni(e.target.value);debouncedSearch();});
$('omni').addEventListener('focus',()=>{ if(!$('omni').value) showHistory(true); });
$('omni').addEventListener('blur',()=>setTimeout(()=>showHistory(false),150));
$('fFrom').onchange=e=>{state.from=e.target.value;state.page=0;search();};
$('fTo').onchange=e=>{state.to=e.target.value;state.page=0;search();};
$('fModel').onchange=e=>{state.model=e.target.value;state.page=0;search();};
$('fStation').onchange=e=>{state.station=e.target.value;state.page=0;search();};
$('fLog').onchange=e=>{state.logtype=e.target.value;state.page=0;search();};
$('pageSize').onchange=e=>{state.size=+e.target.value;state.page=0;search();};
$('prev').onclick=()=>{if(state.page>0){state.page--;search();$('twrap').scrollTop=0;}};
$('next').onclick=()=>{state.page++;search();$('twrap').scrollTop=0;};
$('reset').onclick=()=>{ ['serial','model','q','result','station','logtype','from','to'].forEach(k=>state[k]='');
state.sort='';state.checks.clear();updateSel(); $('omni').value='';$('mode').textContent=state.force||'auto';
$('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value=''; state.page=0; search(); };
$('menu').onclick=()=>document.body.classList.toggle('rail-open');
/* ---------- presets ---------- */
function clearAll(){ ['serial','model','q','result','station','logtype','from','to'].forEach(k=>state[k]='');
$('omni').value='';$('mode').textContent=state.force||'auto';$('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value=''; }
function applyPreset(fn){ clearAll(); fn(); state.page=0; state.checks.clear(); updateSel(); document.body.classList.remove('rail-open'); search(); }
const PRESETS=[
{ic:'◷',label:'Recent',fn:()=>{}},
{ic:'✕',label:'Failures',fn:()=>{state.result='FAIL';}},
{ic:'•',label:'Today',fn:()=>{const t=isoD(new Date());state.from=t;state.to=t;}},
{ic:'7',label:'Last 7 days',fn:()=>{const d=new Date();d.setDate(d.getDate()-7);state.from=isoD(d);}},
];
const SOON=[
{label:'Latest upload batch',why:'needs sort=uploaded in /api/search'},
{label:'Retested units',why:'needs a retest flag in the pipeline'},
{label:'Not yet published',why:'needs a published filter in /api/search'},
];
function renderPresets(){
$('presets').innerHTML='';
PRESETS.forEach(p=>{const b=document.createElement('button');b.className='preset';b.innerHTML='<span class="pi">'+p.ic+'</span>'+p.label;b.onclick=()=>applyPreset(p.fn);$('presets').appendChild(b);});
SOON.forEach(s=>{const b=document.createElement('button');b.className='preset';b.disabled=true;b.title=s.why;b.innerHTML='<span class="pi">…</span>'+s.label;$('presets').appendChild(b);});
$('families').innerHTML='';
['DSCA','8B','5B','7B','SCM5B'].forEach(m=>{const b=document.createElement('button');b.className='fam';b.textContent=m;b.onclick=()=>applyPreset(()=>{state.model=m;$('fModel').value=m;});$('families').appendChild(b);});
$('dateChips').innerHTML='';
[['Today',0],['7d',7],['30d',30],['Year',-1]].forEach(([lbl,n])=>{const b=document.createElement('button');b.className='chip';b.textContent=lbl;
b.onclick=()=>{const d=new Date(); if(n===-1)state.from=d.getFullYear()+'-01-01'; else if(n===0){state.from=state.to=isoD(d);} else {d.setDate(d.getDate()-n);state.from=isoD(d);state.to='';}
$('fFrom').value=state.from;$('fTo').value=state.to||''; state.page=0;search();};
$('dateChips').appendChild(b);});
}
/* ---------- recent searches ---------- */
function recents(){ try{return JSON.parse(localStorage.getItem('tdb_recent')||'[]')}catch(e){return[]} }
function pushRecent(q){ if(!q)return; let r=recents().filter(x=>x!==q); r.unshift(q); r=r.slice(0,8); localStorage.setItem('tdb_recent',JSON.stringify(r)); }
function showHistory(on){ const r=recents(); if(!on||!r.length){$('history').classList.remove('show');return;}
$('history').innerHTML='<div class="hl">Recent</div>'+r.map(q=>`<button data-q="${esc(q)}">↩ ${esc(q)}</button>`).join('');
[...$('history').querySelectorAll('button')].forEach(b=>b.onmousedown=()=>{$('omni').value=b.dataset.q;routeOmni(b.dataset.q);state.page=0;search();showHistory(false);});
$('history').classList.add('show'); }
/* ---------- resizer ---------- */
(function(){const rz=$('resizer');let on=false;
rz.addEventListener('mousedown',e=>{on=true;rz.classList.add('drag');document.body.classList.add('resizing');e.preventDefault();});
window.addEventListener('mousemove',e=>{if(!on)return;let w=Math.max(360,Math.min(window.innerWidth-540,window.innerWidth-e.clientX));document.documentElement.style.setProperty('--insp',w+'px');});
window.addEventListener('mouseup',()=>{if(on){on=false;rz.classList.remove('drag');document.body.classList.remove('resizing');fitCert();}});
})();
/* ---------- stats popover ---------- */
let statsLoaded=false;
$('statsBtn').onclick=async()=>{ const p=$('statsPop'); if(p.classList.contains('show')){p.classList.remove('show');return;}
p.classList.add('show'); if(statsLoaded)return;
try{ const s=await (await fetch(API+'/api/stats')).json(); statsLoaded=true;
const res=s.by_result||[]; const pass=(res.find(r=>r.overall_result==='PASS')||{}).count||0; const fail=(res.find(r=>r.overall_result==='FAIL')||{}).count||0; const tot=pass+fail||1;
p.innerHTML=`<h4>Database</h4><div class="big">${(s.total_records||0).toLocaleString()}</div><div class="sub">records · ${fmtDate(s.date_range&&s.date_range.oldest)}${fmtDate(s.date_range&&s.date_range.newest)}</div>
<div class="bar"><i style="width:${pass/tot*100}%;background:var(--pass-ink)"></i><i style="width:${fail/tot*100}%;background:var(--fail-ink)"></i></div>
<div class="legend"><span style="--c:var(--pass-ink)">PASS ${pass.toLocaleString()}</span><span>FAIL ${fail.toLocaleString()}</span></div>
<div style="margin-top:6px">${(s.by_log_type||[]).slice(0,7).map(l=>`<div class="lt"><span>${esc(l.log_type)}</span><span>${l.count.toLocaleString()}</span></div>`).join('')}</div>`;
p.querySelectorAll('.legend span')[0].style.cssText='--c:var(--pass-ink)';
}catch(e){ p.innerHTML='<div class="sub">stats unavailable</div>'; } };
document.addEventListener('click',e=>{ if(!$('statsPop').contains(e.target)&&e.target!==$('statsBtn')) $('statsPop').classList.remove('show'); });
/* ---------- keyboard ---------- */
document.addEventListener('keydown',e=>{
if(e.key==='/'&&document.activeElement!==$('omni')){e.preventDefault();$('omni').focus();$('omni').select();return;}
if(e.key==='Escape'){ if($('statsPop').classList.contains('show')){$('statsPop').classList.remove('show');return;}
if($('insp').classList.contains('open')&&window.innerWidth<=820){$('insp').classList.remove('open');return;}
if(document.activeElement===$('omni')&&$('omni').value){$('omni').value='';routeOmni('');debouncedSearch();}else $('omni').focus(); return;}
if((e.key==='ArrowDown'||e.key==='ArrowUp')&&state.rows.length){
e.preventDefault(); let i=state.rows.findIndex(r=>r.id==state.selected);
i=e.key==='ArrowDown'?Math.min(state.rows.length-1,i+1):Math.max(0,i-1);
if(state.rows[i]){select(state.rows[i].id);const tr=[...$('rows').children][i];if(tr)tr.scrollIntoView({block:'nearest'});}}
if(e.key==='Enter'){ if(document.activeElement===$('omni')) pushRecent($('omni').value.trim());
if(state.selected) window.open(API+'/api/datasheet/'+state.selected+'?format=html','_blank'); }
});
/* ---------- bootstrap ---------- */
async function boot(){
const u=new URLSearchParams(location.search);
if(u.get('serial')){state.serial=u.get('serial');$('omni').value=state.serial;routeOmni(state.serial);}
else if(u.get('model')){state.model=u.get('model');$('omni').value=state.model;routeOmni(state.model);}
else if(u.get('q')){state.q=u.get('q');$('omni').value=state.q;routeOmni(state.q);}
for(const k of ['result','station','logtype','from','to']) if(u.get(k))state[k]=u.get(k);
if(u.get('sort')){state.sort=u.get('sort');state.dir=u.get('dir')||'desc';}
state.selected=u.get('selected');
renderPresets();
try{ const f=await (await fetch(API+'/api/filters')).json();
$('modelList').innerHTML=(f.models||[]).map(m=>`<option value="${esc(m.model_number)}">${esc(m.model_number)} (${m.count})</option>`).join('');
$('fStation').innerHTML='<option value="">any station</option>'+(f.stations||[]).map(s=>`<option>${esc(s)}</option>`).join('');
$('fLog').innerHTML='<option value="">any log</option>'+(f.log_types||[]).map(l=>`<option>${esc(l)}</option>`).join('');
}catch(e){}
try{ const s=await (await fetch(API+'/api/stats')).json();
$('ingest').textContent=(s.total_records||0).toLocaleString()+' records · newest '+fmtDate(s.date_range&&s.date_range.newest);
}catch(e){ $('ingest').textContent='stats unavailable'; }
if(state.from)$('fFrom').value=state.from; if(state.to)$('fTo').value=state.to;
if(state.station)$('fStation').value=state.station; if(state.logtype)$('fLog').value=state.logtype; if(state.model)$('fModel').value=state.model;
$('omni').focus(); search();
}
boot();
</script>
</body>
</html>