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

362 lines
21 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:#f8fafc; --surface:#ffffff; --border:#e2e8f0; --border-strong:#cbd5e1;
--ink:#0f172a; --ink-2:#475569; --ink-3:#94a3b8; --accent:#1e40af; --accent-soft:#eff6ff;
--pass-bg:#dcfce7; --pass-ink:#166534; --fail-bg:#fee2e2; --fail-ink:#991b1b;
--hover:#f1f5f9; --sel:#e0e7ff; --mono:ui-monospace,"SFMono-Regular",Consolas,"Liberation Mono",monospace;
--sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{font-family:var(--sans);font-size:14px;color:var(--ink);background:var(--bg);
display:grid;grid-template-rows:auto 1fr;overflow:hidden}
/* ---------- header ---------- */
header{display:flex;align-items:center;gap:16px;padding:0 16px;height:52px;
background:var(--surface);border-bottom:1px solid var(--border)}
.brand{font-weight:700;letter-spacing:-.01em;white-space:nowrap}
.brand span{color:var(--accent)}
.omni{flex:1;position:relative}
.omni input{width:100%;height:36px;padding:0 12px 0 32px;font-size:14px;font-family:var(--sans);
border:1px solid var(--border-strong);border-radius:6px;background:var(--bg);color:var(--ink)}
.omni input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft);background:#fff}
.omni .ic{position:absolute;left:10px;top:9px;color:var(--ink-3);font-size:15px}
.omni .route{position:absolute;right:10px;top:9px;font-size:11px;color:var(--ink-3);font-family:var(--mono)}
.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:inherit;font-size:12px;color:var(--ink-2);background:none;border:1px solid var(--border);
border-radius:6px;height:30px;padding:0 10px;cursor:pointer}
.hbtn:hover{background:var(--hover)}
/* ---------- layout ---------- */
main{display:grid;grid-template-columns:232px 1fr var(--insp,460px);min-height:0;height:100%}
.pane{min-height:0;overflow:auto}
/* ---------- presets ---------- */
.presets{display:flex;flex-direction:column;gap:5px}
.preset{display:flex;align-items:center;gap:7px;width:100%;text-align:left;font:inherit;font-size:12.5px;
height:30px;padding:0 9px;border:1px solid var(--border);border-radius:6px;background:#fff;color:var(--ink);cursor:pointer}
.preset:hover{background:var(--accent-soft);border-color:#bfdbfe}
.preset .pi{width:15px;text-align:center;color:var(--ink-3)}
.preset.fam{display:inline-flex;width:auto;height:26px;font-family:var(--mono);font-size:11.5px;padding:0 8px}
.preset.fam:disabled,.preset:disabled{opacity:.5;cursor:not-allowed;background:#fff}
.famrow{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
/* ---------- filter rail ---------- */
.rail{border-right:1px solid var(--border);background:var(--surface);padding:14px}
.rail h3{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3);margin:18px 0 8px;font-weight:600}
.rail h3:first-child{margin-top:0}
.seg{display:flex;border:1px solid var(--border-strong);border-radius:6px;overflow:hidden}
.seg button{flex:1;font:inherit;font-size:12px;height:30px;border:0;background:#fff;color:var(--ink-2);cursor:pointer}
.seg button+button{border-left:1px solid var(--border)}
.seg button.on{background:var(--accent);color:#fff}
.rail label{display:block;font-size:11px;color:var(--ink-3);margin:8px 0 3px}
.rail input,.rail select{width:100%;height:30px;font:inherit;font-size:13px;padding:0 8px;
border:1px solid var(--border-strong);border-radius:6px;background:#fff;color:var(--ink)}
.rail input:focus,.rail select:focus{outline:none;border-color:var(--accent)}
.reset{margin-top:18px;width:100%;height:30px;font:inherit;font-size:12px;background:none;
border:1px solid var(--border);border-radius:6px;color:var(--ink-2);cursor:pointer}
.reset:hover{background:var(--hover)}
/* ---------- results ---------- */
.results{display:grid;grid-template-rows: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:600;color:var(--ink)}
.rtoolbar .sp{flex:1}
.rtoolbar a,.rtoolbar select{font:inherit;font-size:12px;color:var(--accent);text-decoration:none}
.rtoolbar select{color:var(--ink-2);border:1px solid var(--border);border-radius:5px;height:26px}
.twrap{overflow:auto;min-height:0}
table{width:100%;border-collapse:collapse;font-size:13px}
thead th{position:sticky;top:0;background:#f8fafc;border-bottom:1px solid var(--border-strong);
text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);
font-weight:600;padding:7px 10px;white-space:nowrap;z-index:1}
tbody td{padding:5px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
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}
.pill{display:inline-block;font-size:11px;font-weight:600;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)}
.pager{display:flex;align-items:center;gap:10px;padding:8px 12px;border-top:1px solid var(--border);font-size:12px;color:var(--ink-2)}
.pager button{font:inherit;font-size:12px;height:28px;padding:0 10px;border:1px solid var(--border-strong);
border-radius:6px;background:#fff;cursor:pointer;color:var(--ink)}
.pager button:disabled{opacity:.4;cursor:default}
/* ---------- 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}
.insp .meta{padding:12px 14px;border-bottom:1px solid var(--border)}
.insp .meta .sn{font-family:var(--mono);font-size:16px;font-weight:700}
.insp .meta dl{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;margin:10px 0 0;font-size:12.5px}
.insp .meta dt{color:var(--ink-3)}
.insp .meta dd{margin:0;font-family:var(--mono)}
.insp .acts{display:flex;gap:6px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--border)}
.insp .acts a,.insp .acts button{font:inherit;font-size:12px;height:28px;padding:0 10px;border:1px solid var(--border-strong);
border-radius:6px;background:#fff;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;cursor:pointer}
.insp .acts .pri{background:var(--accent);border-color:var(--accent);color:#fff}
.insp iframe{width:100%;height:100%;border:0;background:#fff}
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--ink-3);gap:8px;font-size:13px;text-align:center;padding:20px}
.skel{height:24px;margin:6px 10px;border-radius:4px;background:linear-gradient(90deg,#f1f5f9,#e8edf3,#f1f5f9);background-size:200% 100%;animation:sh 1.1s infinite}
@keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
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}
</style>
</head>
<body>
<header>
<div class="brand">Dataforth <span>·</span> TestDataDB</div>
<div class="omni">
<span class="ic">🔍</span>
<input id="omni" autocomplete="off" spellcheck="false"
placeholder="Search serial, model, or text… (press / to focus)">
<span class="route" id="route"></span>
</div>
<div class="badge"><span class="dot"></span><span id="ingest">loading…</span></div>
<button class="hbtn" id="statsBtn" title="Database statistics">Stats</button>
</header>
<main>
<!-- filter rail -->
<aside class="rail pane">
<h3>Quick searches</h3>
<div class="presets" id="presets"></div>
<div class="famrow" id="families"></div>
<h3>Test date</h3>
<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 filters</button>
</aside>
<!-- results -->
<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="#">Export CSV ↓</a>
</div>
<div class="twrap" id="twrap">
<table>
<thead><tr>
<th>Serial</th><th>Model</th><th>Test Date</th><th>Stn</th><th>Log</th><th>Result</th><th>Web</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
</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 style="color:var(--ink-3)"><kbd>/</kbd> search · <kbd></kbd><kbd></kbd> rows · <kbd></kbd> open · <kbd>Esc</kbd> clear</span>
</div>
</section>
<!-- inspector -->
<aside class="insp">
<div class="resizer" id="resizer" title="Drag to resize"></div>
<div class="meta" id="meta"><div class="empty" style="height:auto;padding:8px 0">No record selected</div></div>
<div class="acts" id="acts" style="display:none"></div>
<div id="viewer"><div class="empty">Search a serial number, then select a row.<br>The calibration certificate renders here.</div></div>
</aside>
</main>
<script>
const API = ''; // same-origin; relative /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:[] };
let timer=null;
// ---- omni-search routing: serial-ish -> serial, model-ish -> model, else full-text ----
function routeOmni(v){
v=v.trim(); state.q=state.serial=state.model='';
if(!v){ $('route').textContent=''; return; }
if(/\s/.test(v)){ state.q=v; $('route').textContent='text'; }
else if(/^(scm|dsc|[5780]b|pwr|vas)/i.test(v)){ state.model=v; $('route').textContent='model'; }
else { state.serial=v; $('route').textContent='serial'; }
}
function params(extra){
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(extra!=='export'){ 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 fmtDate(d){ return d? String(d).slice(0,10) : ''; }
async function search(){
$('rows').innerHTML = Array.from({length:8}).map(()=>'<tr><td colspan="7"><div class="skel"></div></td></tr>').join('');
try{
const r = await fetch(API+'/api/search?'+params().toString());
const data = await r.json();
state.rows = data.records||[]; state.total = data.total||0;
renderRows();
}catch(e){ $('rows').innerHTML='<tr><td colspan="7" style="color:var(--fail-ink);padding:14px">Search failed: '+e.message+'</td></tr>'; }
$('exportCsv').href = API+'/api/export?'+params('export').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){ $('rows').innerHTML='<tr><td colspan="7" style="color:var(--ink-3);padding:18px">No records match. Try clearing a filter.</td></tr>'; return; }
$('rows').innerHTML = state.rows.map(r=>{
const web = r.api_uploaded_at ? '●' : '○';
return `<tr data-id="${r.id}">
<td class="mono" style="font-weight:600">${r.serial_number||''}</td>
<td class="mono">${r.model_number||''}</td>
<td class="mono">${fmtDate(r.test_date)}</td>
<td class="mono">${(r.test_station||'').replace(/^TS-/,'')}</td>
<td class="mono" style="color:var(--ink-3)">${(r.log_type||'').replace(/LOG$/,'')}</td>
<td><span class="pill ${r.overall_result}">${r.overall_result||''}</span></td>
<td style="color:${r.api_uploaded_at?'var(--pass-ink)':'var(--ink-3)'};text-align:center" title="${r.api_uploaded_at?'published to website':'not published'}">${web}</td>
</tr>`;}).join('');
[...$('rows').children].forEach(tr=>tr.onclick=()=>select(tr.dataset.id));
// auto-select first (the serial fast-path) if a serial query
if(state.serial && state.rows[0]) select(state.rows[0].id);
else if(state.selected){ const tr=[...$('rows').children].find(t=>t.dataset.id==state.selected); if(tr) tr.classList.add('sel'); }
}
function select(id){
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">${r.serial_number||''}</div>
<dl><dt>Model</dt><dd>${r.model_number||''}</dd>
<dt>Date</dt><dd>${fmtDate(r.test_date)}</dd>
<dt>Station</dt><dd>${r.test_station||''}</dd>
<dt>Result</dt><dd><span class="pill ${r.overall_result}">${r.overall_result||''}</span></dd>
<dt>Log</dt><dd>${r.log_type||''}</dd>
${r.work_order?`<dt>WO</dt><dd>${r.work_order}</dd>`:''}
<dt>Web</dt><dd>${r.api_uploaded_at?'published '+fmtDate(r.api_uploaded_at):'not published'}</dd></dl>`;
$('acts').style.display='flex';
const ds = API+'/api/datasheet/'+id;
$('acts').innerHTML = `<a class="pri" href="${ds}?format=html" target="_blank">Open ↗</a>
<button onclick="document.getElementById('viewer').querySelector('iframe').contentWindow.print()">Print</button>
<a href="${ds}?format=txt" download="${r.serial_number}.txt">TXT</a>
<a href="${ds}?format=html" download="${r.serial_number}.html">HTML</a>`;
$('viewer').innerHTML = '<iframe id="dsframe" title="datasheet"></iframe>';
const _f=document.getElementById('dsframe'); _f.onload=fitCert; _f.src=ds+'?format=html';
syncUrl();
}
// ---- filters wiring ----
function debouncedSearch(){ clearTimeout(timer); timer=setTimeout(()=>{state.page=0;search();},280); }
$('omni').addEventListener('input', e=>{ routeOmni(e.target.value); debouncedSearch(); });
$('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]='');
$('omni').value='';$('route').textContent='';$('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value='';
state.page=0; search(); };
// ---- cert fit-to-width (same-origin: scale the rendered cert so it never side-scrolls) ----
function fitCert(){
const f=document.getElementById('dsframe'); if(!f) return;
try{ const doc=f.contentDocument; if(!doc) return;
const root=doc.documentElement; root.style.zoom='';
const natural=Math.max(doc.body?doc.body.scrollWidth:0, root.scrollWidth);
const avail=f.clientWidth-12;
root.style.zoom = (natural>avail) ? Math.max(0.45, avail/natural) : 1;
}catch(e){}
}
window.addEventListener('resize', fitCert);
// ---- resizable inspector ----
(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(340, Math.min(window.innerWidth-560, 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(); } });
})();
// ---- quick-search presets ----
function clearAll(){ ['serial','model','q','result','station','logtype','from','to'].forEach(k=>state[k]='');
$('omni').value='';$('route').textContent='';$('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value=''; }
function applyPreset(fn){ clearAll(); fn(); state.page=0; search(); }
const _iso=d=>d.toISOString().slice(0,10);
const PRESETS=[
{ic:'◷',label:'Recent',fn:()=>{}},
{ic:'✕',label:'Failures',fn:()=>{state.result='FAIL';}},
{ic:'•',label:'Today',fn:()=>{const t=_iso(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=_iso(d);}},
{ic:'∷',label:'This year',fn:()=>{state.from=new Date().getFullYear()+'-01-01';}},
];
const SOON=[
{label:'Latest upload batch',why:'needs an upload-time sort param 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(){
const host=$('presets'); host.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); host.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; host.appendChild(b); });
const fam=$('families'); fam.innerHTML='';
['DSCA','8B','5B','7B','SCM5B'].forEach(m=>{ const b=document.createElement('button'); b.className='preset fam';
b.textContent=m; b.onclick=()=>applyPreset(()=>{state.model=m;$('fModel').value=m;}); fam.appendChild(b); });
}
// ---- keyboard ----
document.addEventListener('keydown', e=>{
if(e.key==='/' && document.activeElement!==$('omni')){ e.preventDefault(); $('omni').focus(); $('omni').select(); return; }
if(e.key==='Escape'){ 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);
select(state.rows[i].id); const tr=[...$('rows').children][i]; if(tr) tr.scrollIntoView({block:'nearest'}); }
if(e.key==='Enter' && state.selected){ window.open(API+'/api/datasheet/'+state.selected+'?format=html','_blank'); }
});
// ---- bootstrap ----
async function boot(){
// restore from URL
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);
state.selected=u.get('selected');
// filters + stats
try{ const f=await (await fetch(API+'/api/filters')).json();
$('modelList').innerHTML=(f.models||[]).map(m=>`<option value="${m.model_number}">${m.model_number} (${m.count})</option>`).join('');
$('fStation').innerHTML='<option value="">any station</option>'+(f.stations||[]).map(s=>`<option>${s}</option>`).join('');
$('fLog').innerHTML='<option value="">any log</option>'+(f.log_types||[]).map(l=>`<option>${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'; }
$('statsBtn').onclick=async()=>{ const s=await (await fetch(API+'/api/stats')).json();
alert('Total: '+s.total_records.toLocaleString()+'\nResult: '+(s.by_result||[]).map(r=>r.overall_result+' '+r.count.toLocaleString()).join(' · ')
+'\nDates: '+fmtDate(s.date_range.oldest)+' → '+fmtDate(s.date_range.newest)
+'\nLog types: '+(s.by_log_type||[]).map(r=>r.log_type+' '+r.count.toLocaleString()).join(' · ')); };
renderPresets();
$('omni').focus();
search();
}
boot();
</script>
</body>
</html>