Files
boc/qx-audit.html
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

390 lines
30 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="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>quiXzoom · Claim Audit</title>
<style>
:root{
--paper:#EBEEE8;--card:#F6F7F3;--ink:#181E22;--graphite:#6A7278;
--signal:#234FBF;--pass:#1B7A4A;--fail:#B22F25;--review:#A8761A;
--rule:#D3D7CD;--rule-soft:#E1E4DC;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
*{box-sizing:border-box}html,body{margin:0}
body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-size:15px;line-height:1.45;-webkit-font-smoothing:antialiased}
.wrap{max-width:1080px;margin:0 auto;padding:0 20px 80px}
.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--graphite)}
.num{font-family:var(--mono);font-variant-numeric:tabular-nums}
header{padding:34px 0 22px;border-bottom:1px solid var(--rule)}
header .mark{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
header h1{font-size:23px;font-weight:680;letter-spacing:-.01em;margin:0}
header h1 b{color:var(--signal);font-weight:680}
header p{margin:10px 0 0;color:var(--graphite);max-width:62ch;font-size:14px}
nav.tabs{display:flex;gap:2px;margin:26px 0 0;border-bottom:1px solid var(--rule)}
nav.tabs button{appearance:none;background:none;border:none;cursor:pointer;font-family:var(--mono);font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--graphite);padding:11px 16px;border-bottom:2px solid transparent;margin-bottom:-1px}
nav.tabs button:hover{color:var(--ink)}
nav.tabs button[aria-selected="true"]{color:var(--ink);border-bottom-color:var(--signal)}
.view{display:none;padding-top:26px}.view.active{display:block}
.panel{background:var(--card);border:1px solid var(--rule-soft);border-radius:10px;padding:20px}
.panel+.panel{margin-top:18px}
.panel h2{font-size:15px;margin:0 0 4px;font-weight:640}
.panel .sub{color:var(--graphite);font-size:13px;margin:0 0 16px}
label{display:block;font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--graphite);margin:0 0 5px}
input[type=text],input[type=number],textarea,select{width:100%;font-family:var(--sans);font-size:14px;color:var(--ink);background:#fff;border:1px solid var(--rule);border-radius:7px;padding:9px 11px}
input:focus,textarea:focus,select:focus{outline:2px solid var(--signal);outline-offset:0;border-color:var(--signal)}
textarea{resize:vertical;min-height:54px}
.row{display:flex;gap:12px;flex-wrap:wrap}.row>div{flex:1;min-width:120px}
button.act{appearance:none;cursor:pointer;font-family:var(--sans);font-size:14px;font-weight:560;background:var(--signal);color:#fff;border:none;border-radius:7px;padding:10px 18px}
button.act:hover{filter:brightness(1.07)}
button.ghost{background:none;color:var(--graphite);border:1px solid var(--rule);font-weight:500;cursor:pointer;font-family:var(--sans);font-size:14px;border-radius:7px;padding:10px 18px}
button.ghost:hover{color:var(--ink);border-color:var(--graphite)}
button.tiny{font-size:11px;padding:5px 10px;border-radius:6px}
.scoregrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px}
.scoregrid .crit small{display:block;text-transform:none;letter-spacing:0;font-family:var(--sans);color:var(--graphite);font-size:11px;margin-top:2px}
.seg{display:flex;border:1px solid var(--rule);border-radius:7px;overflow:hidden;margin-top:6px}
.seg button{flex:1;appearance:none;background:#fff;border:none;border-right:1px solid var(--rule);cursor:pointer;font-family:var(--mono);font-size:13px;padding:7px 0;color:var(--graphite)}
.seg button:last-child{border-right:none}
.seg button[aria-pressed="true"]{background:var(--signal);color:#fff}
.sorttoggle{display:flex;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap}
.sorttoggle .seg{margin:0;width:auto}
.sorttoggle .seg button{padding:7px 14px;font-family:var(--sans);font-size:13px}
.claimcard{border:1px solid var(--rule-soft);border-radius:9px;padding:15px 16px;margin-bottom:12px;background:#fff}
.claimcard .top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
.claimcard h3{margin:0;font-size:16px;font-weight:620}
.claimcard .dps{margin:7px 0 0;color:var(--graphite);font-size:13px}
.claimcard .dps span{font-family:var(--mono);font-size:12px;background:var(--paper);border:1px solid var(--rule-soft);border-radius:5px;padding:2px 7px;margin:0 5px 5px 0;display:inline-block}
.badge{font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;padding:4px 8px;border-radius:5px;white-space:nowrap}
.badge.first{background:var(--signal);color:#fff}
.badge.month{background:none;color:var(--signal);border:1px solid var(--signal)}
.metrics{display:flex;gap:22px;flex-wrap:wrap;margin-top:13px;padding-top:13px;border-top:1px dashed var(--rule)}
.metric .v{font-family:var(--mono);font-size:19px;font-variant-numeric:tabular-nums;line-height:1}
.metric .k{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--graphite);margin-top:4px}
.metric .v.pass{color:var(--pass)}.metric .v.fail{color:var(--fail)}.metric .v.review{color:var(--review)}
.cardactions{display:flex;gap:8px;margin-top:13px}
.empty{color:var(--graphite);font-size:14px;text-align:center;padding:30px 10px;border:1px dashed var(--rule);border-radius:9px}
.quad-wrap{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start}
.quad-wrap svg{flex:1;min-width:300px;max-width:560px}
.quad-legend{flex:1;min-width:200px;font-size:13px;color:var(--graphite)}
.quad-legend b{color:var(--ink)}
.qlabel{font-family:var(--mono);font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;fill:var(--graphite)}
.qdot{fill:var(--signal)}
.qname{font-family:var(--mono);font-size:10.5px;fill:var(--ink)}
.dprow{display:flex;align-items:center;gap:12px;padding:9px 0;border-bottom:1px solid var(--rule-soft)}
.dprow .lbl{flex:1;font-size:14px}
.tri{display:flex;border:1px solid var(--rule);border-radius:7px;overflow:hidden}
.tri button{appearance:none;background:#fff;border:none;border-right:1px solid var(--rule);cursor:pointer;font-family:var(--mono);font-size:11px;letter-spacing:.04em;padding:6px 11px;color:var(--graphite)}
.tri button:last-child{border-right:none}
.tri button[aria-pressed="true"][data-v="pass"]{background:var(--pass);color:#fff}
.tri button[aria-pressed="true"][data-v="cap"]{background:var(--review);color:#fff}
.tri button[aria-pressed="true"][data-v="con"]{background:var(--fail);color:#fff}
.sessrow{display:flex;gap:14px;align-items:center;padding:8px 0;border-bottom:1px solid var(--rule-soft);font-size:13px}
.sessrow .verdict{font-family:var(--mono);font-size:11px;padding:2px 7px;border-radius:5px}
.sessrow .verdict.v-ok{background:rgba(27,122,74,.13);color:var(--pass)}
.sessrow .verdict.v-cap{background:rgba(168,118,26,.15);color:var(--review)}
.sessrow .verdict.v-con{background:rgba(178,47,37,.13);color:var(--fail)}
.sessrow .meta{margin-left:auto;font-family:var(--mono);font-size:12px;color:var(--graphite)}
.dpbreak{margin-top:14px}
.bar{height:7px;border-radius:4px;background:var(--rule-soft);overflow:hidden;margin-top:4px}
.bar i{display:block;height:100%;background:var(--fail)}
.dpline{margin-bottom:11px}
.dpline .t{display:flex;justify-content:space-between;font-size:13px}
.dpline .t .num{color:var(--graphite)}
.hint{font-size:12.5px;color:var(--graphite);font-family:var(--mono);margin-top:14px;padding-top:12px;border-top:1px solid var(--rule-soft);line-height:1.6}
.cpvc-banner{background:#fff;border:1px solid var(--rule-soft);border-radius:8px;padding:14px 16px;margin-top:16px;display:flex;gap:24px;flex-wrap:wrap}
.cpvc-banner .metric .v{font-size:24px}
@media(max-width:560px){header h1{font-size:20px}.metrics{gap:16px}}
</style>
</head>
<body><div class="wrap">
<header>
<div class="mark"><span class="eyebrow">quiXzoom · internt · v0</span>
<h1>Claim&nbsp;<b>Audit</b></h1></div>
<p>Bevisa eller motbevisa. Enda frågan: kan claimet verifieras billigare än kunden kan göra själv? Primärt KPI: <b>CPVC</b> — Cost Per Verified Claim.</p>
</header>
<nav class="tabs" role="tablist">
<button role="tab" aria-selected="true" data-tab="claims">Claims &amp; ranking</button>
<button role="tab" aria-selected="false" data-tab="audit">Logga video</button>
<button role="tab" aria-selected="false" data-tab="cpvc">Ekonomi / CPVC</button>
</nav>
<!-- CLAIMS -->
<section class="view active" id="view-claims">
<div class="panel">
<h2>Beslutskvadrant</h2>
<p class="sub">Verifieringsgrad mot tid. Uppe till vänster = testa först.</p>
<div id="quad"></div>
</div>
<div class="panel">
<h2>Rangordning</h2>
<p class="sub">Lärande-först: välj en claim att testa denna vecka. Kommersiellt: vilka tre är värda att driva.</p>
<div class="sorttoggle">
<span class="eyebrow">Sortera</span>
<div class="seg" id="sortseg">
<button data-sort="learn" aria-pressed="true">Lärande-först</button>
<button data-sort="commercial" aria-pressed="false">Kommersiellt</button>
</div>
</div>
<div id="claimlist"></div>
</div>
<div class="panel">
<h2 id="formtitle">Ny claim</h2>
<p class="sub">Skriv datakrav — inte bildkrav. "Serienummer" inte "foto av etikett".</p>
<div style="margin-bottom:14px">
<label for="cname">Claim</label>
<input type="text" id="cname" placeholder="t.ex. Elmätare verifierad">
</div>
<div style="margin-bottom:14px">
<label>Datakrav (alla krävs för godkänt)</label>
<div id="dplist"></div>
<button class="ghost tiny" id="adddp" style="margin-top:8px">+ Datapunkt</button>
</div>
<div class="scoregrid" id="scoregrid"></div>
<div style="display:flex;gap:10px;margin-top:18px">
<button class="act" id="saveclaim">Spara claim</button>
<button class="ghost" id="canceledit" style="display:none">Avbryt</button>
</div>
<p class="hint">Kill-kriterier: verifiering &lt;80% · snitt-tid &gt;120s · CPVC över målpris · kritiska datapunkter &lt;95% tillförlitlighet → pausa projektet.</p>
</div>
</section>
<!-- AUDIT -->
<section class="view" id="view-audit">
<div class="panel">
<h2>Logga en video</h2>
<p class="sub">Titta på sekvensen. För varje datapunkt: ✓ synlig · ✗ bild (capture-fel, fixbart med guidning) · ✗ verklighet (content-fel, claimen håller inte för det objektet).</p>
<div style="margin-bottom:16px;max-width:340px">
<label for="auditclaim">Claim</label>
<select id="auditclaim"></select>
</div>
<div id="auditform"></div>
</div>
<div class="panel" id="rollup"></div>
</section>
<!-- CPVC -->
<section class="view" id="view-cpvc">
<div class="panel">
<h2>Ekonomi — Cost Per Verified Claim</h2>
<p class="sub">CPVC = total kostnad / godkända claims. Det är det enda talet som avgör om quiXzoom har lönsam enhetsekonomi.</p>
<div class="row" style="margin-bottom:16px">
<div><label for="cpvc-wage">Zoomer-ersättning (kr/timme)</label><input type="number" id="cpvc-wage" value="120" min="0"></div>
<div><label for="cpvc-overhead">Operativ overhead (kr/claim)</label><input type="number" id="cpvc-overhead" value="5" min="0"></div>
<div><label for="cpvc-target">Målpris per claim (kr)</label><input type="number" id="cpvc-target" value="50" min="0"></div>
</div>
<div id="cpvc-table"></div>
</div>
</section>
</div>
<script>
const SK="qx_audit_v0";
const CRITS=[
{k:"value",label:"Kundvärde",hint:"Hur mycket vill kunden veta detta?"},
{k:"ease",label:"Enkelhet",hint:"Lätt att verifiera i fält?"},
{k:"fraud",label:"Bedrägeririsk",hint:"Lätt att fuska? (högt=sämre)"},
{k:"quality",label:"Datakvalitet",hint:"Blir signalen ren?"},
{k:"margin",label:"Marginal",hint:"Lönsamhet per claim?"},
];
let state={claims:[],sessions:[]};
let sortMode="learn",editingId=null,formDraft=null,auditDraft=null;
function load(){try{const r=localStorage.getItem(SK);if(r)state=JSON.parse(r);}catch(e){}
if(!state.claims)state.claims=[];if(!state.sessions)state.sessions=[];}
function save(){try{localStorage.setItem(SK,JSON.stringify(state));}catch(e){}}
function weekScore(s){return 0.40*s.ease+0.40*s.quality+0.20*(6-s.fraud);}
function monthScore(s){return 0.35*s.value+0.30*s.margin+0.15*s.ease+0.10*s.quality+0.10*(6-s.fraud);}
function uid(){return Math.random().toString(36).slice(2,9);}
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function sessionVerdict(sess,claim){
const dps=claim.datapoints.map(d=>sess.datapoints[d.id]);
if(dps.some(v=>v==="con"))return"con";
if(dps.some(v=>v==="cap"))return"cap";
return"ok";
}
function claimStats(claim){
const ss=state.sessions.filter(s=>s.claimId===claim.id);
if(!ss.length)return null;
let ok=0,ceiling=0,confSum=0,secSum=0,insSum=0;
ss.forEach(s=>{const v=sessionVerdict(s,claim);if(v==="ok")ok++;if(v!=="con")ceiling++;confSum+=Number(s.confidence)||0;secSum+=Number(s.seconds)||0;insSum+=Number(s.instructions)||0;});
return{n:ss.length,verify:ok/ss.length,ceiling:ceiling/ss.length,conf:confSum/ss.length,sec:secSum/ss.length,ins:insSum/ss.length};
}
function dpContentFail(claim){
const ss=state.sessions.filter(s=>s.claimId===claim.id);
return claim.datapoints.map(d=>{let con=0,cap=0;ss.forEach(s=>{if(s.datapoints[d.id]==="con")con++;else if(s.datapoints[d.id]==="cap")cap++;});return{label:d.label,con,cap,n:ss.length};});
}
/* claims view */
function renderClaims(){
const list=document.getElementById("claimlist");
if(!state.claims.length){list.innerHTML='<div class="empty">Inga claims. Definiera 35 nedan — börja med elmätare, fordon och skylt.</div>';renderQuad();return;}
const ranked=[...state.claims].sort((a,b)=>sortMode==="learn"?weekScore(b.scores)-weekScore(a.scores):monthScore(b.scores)-monthScore(a.scores));
const monthTop=[...state.claims].sort((a,b)=>monthScore(b.scores)-monthScore(a.scores)).slice(0,3).map(c=>c.id);
list.innerHTML=ranked.map((c,i)=>{
const st=claimStats(c);
const score=sortMode==="learn"?weekScore(c.scores):monthScore(c.scores);
const fb=sortMode==="learn"&&i===0?'<span class="badge first">Testa först</span>':'';
const mb=sortMode==="commercial"&&monthTop.includes(c.id)?'<span class="badge month">Månadens 3</span>':'';
let metrics='';
if(st){const vc=st.verify>=.85?'pass':st.verify>=.6?'review':'fail';
metrics=`<div class="metrics"><div class="metric"><div class="v ${vc}">${Math.round(st.verify*100)}%</div><div class="k">verifierat</div></div><div class="metric"><div class="v">${Math.round(st.ceiling*100)}%</div><div class="k">tak (guidning)</div></div><div class="metric"><div class="v">${st.sec.toFixed(0)}s</div><div class="k">snitt-tid</div></div><div class="metric"><div class="v">${st.ins.toFixed(1)}</div><div class="k">instruktioner</div></div><div class="metric"><div class="v">${st.n}</div><div class="k">videor</div></div></div>`;
}else{metrics='<div class="metrics"><div class="metric"><div class="v" style="color:var(--graphite)">—</div><div class="k">inga videor</div></div></div>';}
return`<div class="claimcard"><div class="top"><div><h3>${esc(c.name)}</h3><div class="dps">${c.datapoints.map(d=>`<span>${esc(d.label)}</span>`).join('')}</div></div><div style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">${fb}${mb}<span class="num" style="font-size:13px;color:var(--graphite)">poäng ${score.toFixed(2)}</span></div></div>${metrics}<div class="cardactions"><button class="ghost tiny" data-edit="${c.id}">Redigera</button><button class="ghost tiny" data-del="${c.id}">Ta bort</button></div></div>`;
}).join('');
list.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>startEdit(b.dataset.edit));
list.querySelectorAll('[data-del]').forEach(b=>b.onclick=()=>delClaim(b.dataset.del));
renderQuad();
}
function renderQuad(){
const host=document.getElementById("quad");
const pts=state.claims.map(c=>({c,st:claimStats(c)})).filter(x=>x.st);
if(!pts.length){host.innerHTML='<div class="empty">Kvadranten fylls efter första loggade videon.</div>';return;}
const W=560,H=340,m={l:54,r:18,t:18,b:46};
const maxSec=Math.max(120,...pts.map(p=>p.st.sec));
const x=s=>m.l+(s/maxSec)*(W-m.l-m.r);
const y=v=>m.t+(1-v)*(H-m.t-m.b);
const midX=x(maxSec/2),midY=y(0.5);
const dots=pts.map(p=>{const cx=x(p.st.sec),cy=y(p.st.verify);const nm=p.c.name.length>16?p.c.name.slice(0,15)+'…':p.c.name;return`<circle class="qdot" cx="${cx}" cy="${cy}" r="6"/><text class="qname" x="${cx+10}" y="${cy+4}">${esc(nm)}</text>`;}).join('');
host.innerHTML=`<div class="quad-wrap"><svg viewBox="0 0 ${W} ${H}"><line x1="${m.l}" y1="${m.t}" x2="${m.l}" y2="${H-m.b}" stroke="var(--rule)"/><line x1="${m.l}" y1="${H-m.b}" x2="${W-m.r}" y2="${H-m.b}" stroke="var(--rule)"/><line x1="${midX}" y1="${m.t}" x2="${midX}" y2="${H-m.b}" stroke="var(--rule-soft)" stroke-dasharray="3 4"/><line x1="${m.l}" y1="${midY}" x2="${W-m.r}" y2="${midY}" stroke="var(--rule-soft)" stroke-dasharray="3 4"/><text class="qlabel" x="${m.l+6}" y="${m.t+14}">Testa först ↑</text><text class="qlabel" x="${W-m.r-90}" y="${H-m.b-8}">Dyrt · osäkert</text><text class="qlabel" x="${m.l}" y="${H-10}">Kostnad (sek/claim) →</text>${dots}</svg><div class="quad-legend"><p><b>Uppe till vänster = testa först.</b> Hög verifieringsgrad, låg tid.</p><p>Glapp mellan <b>verifierat</b> och <b>tak</b> = problem med guidning, inte verkligheten.</p><p>Lågt tak = content-fel. Claimen håller inte. Släpp den.</p></div></div>`;
}
function blankDraft(){return{name:"",datapoints:[{id:uid(),label:""}],scores:{value:3,ease:3,fraud:3,quality:3,margin:3}};}
function renderForm(){
if(!formDraft)formDraft=blankDraft();
document.getElementById("cname").value=formDraft.name;
document.getElementById("formtitle").textContent=editingId?"Redigera claim":"Ny claim";
document.getElementById("canceledit").style.display=editingId?"inline-block":"none";
const dl=document.getElementById("dplist");
dl.innerHTML=formDraft.datapoints.map(d=>`<div style="display:flex;gap:8px;margin-bottom:7px"><input type="text" data-dp="${d.id}" value="${esc(d.label)}" placeholder="t.ex. Serienummer"><button class="ghost tiny" data-rmdp="${d.id}" ${formDraft.datapoints.length<=1?'disabled':''}>✕</button></div>`).join('');
dl.querySelectorAll('[data-dp]').forEach(inp=>inp.oninput=()=>{const d=formDraft.datapoints.find(x=>x.id===inp.dataset.dp);d.label=inp.value;});
dl.querySelectorAll('[data-rmdp]').forEach(b=>b.onclick=()=>{formDraft.datapoints=formDraft.datapoints.filter(x=>x.id!==b.dataset.rmdp);renderForm();});
const sg=document.getElementById("scoregrid");
sg.innerHTML=CRITS.map(c=>`<div class="crit"><label>${c.label}<small>${c.hint}</small></label><div class="seg" data-crit="${c.k}">${[1,2,3,4,5].map(n=>`<button data-n="${n}" aria-pressed="${formDraft.scores[c.k]===n}">${n}</button>`).join('')}</div></div>`).join('');
sg.querySelectorAll('.seg').forEach(seg=>seg.querySelectorAll('button').forEach(btn=>btn.onclick=()=>{formDraft.scores[seg.dataset.crit]=Number(btn.dataset.n);seg.querySelectorAll('button').forEach(x=>x.setAttribute('aria-pressed',x===btn));}));
}
function startEdit(id){const c=state.claims.find(x=>x.id===id);if(!c)return;editingId=id;formDraft={name:c.name,datapoints:c.datapoints.map(d=>({...d})),scores:{...c.scores}};renderForm();document.getElementById("formtitle").scrollIntoView({behavior:"smooth",block:"center"});}
function delClaim(id){if(!confirm("Ta bort claim och dess videor?"))return;state.claims=state.claims.filter(c=>c.id!==id);state.sessions=state.sessions.filter(s=>s.claimId!==id);save();renderClaims();renderAuditPicker();renderAuditForm();}
function saveClaim(){
formDraft.name=document.getElementById("cname").value.trim();
formDraft.datapoints=formDraft.datapoints.filter(d=>d.label.trim()).map(d=>({id:d.id,label:d.label.trim()}));
if(!formDraft.name){alert("Ge claimen ett namn.");return;}
if(!formDraft.datapoints.length){alert("Lägg till minst ett datakrav.");return;}
if(editingId){Object.assign(state.claims.find(x=>x.id===editingId),formDraft);}
else{state.claims.push({id:uid(),...formDraft});}
editingId=null;formDraft=blankDraft();save();renderForm();renderClaims();renderAuditPicker();renderAuditForm();
}
/* audit view */
function renderAuditPicker(){
const sel=document.getElementById("auditclaim");
if(!state.claims.length){sel.innerHTML='<option>— skapa en claim först —</option>';return;}
const cur=sel.value;
sel.innerHTML=state.claims.map(c=>`<option value="${c.id}">${esc(c.name)}</option>`).join('');
if(cur&&state.claims.find(c=>c.id===cur))sel.value=cur;
}
function newAuditDraft(claim){const dps={};claim.datapoints.forEach(d=>dps[d.id]=null);return{datapoints:dps,confidence:"",seconds:"",instructions:"",tagger:"",note:"",zoomer_cost:""};}
function renderAuditForm(){
const host=document.getElementById("auditform");
const id=document.getElementById("auditclaim").value;
const claim=state.claims.find(c=>c.id===id);
if(!claim){host.innerHTML='<div class="empty">Välj en claim.</div>';renderRollup(null);return;}
if(!auditDraft||auditDraft._claim!==id){auditDraft=newAuditDraft(claim);auditDraft._claim=id;}
host.innerHTML=`${claim.datapoints.map(d=>`<div class="dprow"><span class="lbl">${esc(d.label)}</span><div class="tri" data-dp="${d.id}"><button data-v="pass" aria-pressed="${auditDraft.datapoints[d.id]==='pass'}">✓ synlig</button><button data-v="cap" aria-pressed="${auditDraft.datapoints[d.id]==='cap'}">✗ bild</button><button data-v="con" aria-pressed="${auditDraft.datapoints[d.id]==='con'}">✗ verklighet</button></div></div>`).join('')}
<div class="row" style="margin-top:16px">
<div><label for="aconf">Confidence (0100)</label><input type="number" id="aconf" min="0" max="100" value="${esc(auditDraft.confidence)}"></div>
<div><label for="asec">Tid till capture (sek)</label><input type="number" id="asec" min="0" value="${esc(auditDraft.seconds)}"></div>
<div><label for="ains">Antal instruktioner</label><input type="number" id="ains" min="0" value="${esc(auditDraft.instructions)}"></div>
<div><label for="atag">Taggare (initialer)</label><input type="text" id="atag" maxlength="4" value="${esc(auditDraft.tagger)}"></div>
</div>
<div class="row" style="margin-top:12px">
<div><label for="acost">Zoomer-kostnad för sessionen (kr)</label><input type="number" id="acost" min="0" step="0.1" value="${esc(auditDraft.zoomer_cost)}" placeholder="tid × tariff"></div>
<div><label for="anote">Notering</label><textarea id="anote" placeholder="Vad gjorde det svårt?">${esc(auditDraft.note)}</textarea></div>
</div>
<div style="margin-top:16px"><button class="act" id="logsess">Logga video</button></div>
<p class="hint">✗ bild = fixbart med bättre guidning. ✗ verklighet = content-fel, claimen håller inte för det objektet. Dubbeltagga stickprov med olika initialer.</p>`;
host.querySelectorAll('.tri').forEach(tri=>tri.querySelectorAll('button').forEach(btn=>btn.onclick=()=>{auditDraft.datapoints[tri.dataset.dp]=btn.dataset.v;tri.querySelectorAll('button').forEach(x=>x.setAttribute('aria-pressed',x===btn));}));
const b=(el,key)=>{const n=document.getElementById(el);if(n)n.oninput=()=>auditDraft[key]=n.value;};
b("aconf","confidence");b("asec","seconds");b("ains","instructions");b("atag","tagger");b("anote","note");b("acost","zoomer_cost");
document.getElementById("logsess").onclick=()=>logSession(claim);
renderRollup(claim);
}
function logSession(claim){
const unset=claim.datapoints.filter(d=>!auditDraft.datapoints[d.id]);
if(unset.length){alert("Bedöm varje datapunkt.");return;}
state.sessions.push({id:uid(),claimId:claim.id,ts:Date.now(),...auditDraft});
auditDraft=newAuditDraft(claim);auditDraft._claim=claim.id;
save();renderAuditForm();renderClaims();renderCPVC();
}
function renderRollup(claim){
const host=document.getElementById("rollup");
if(!claim){host.innerHTML='';return;}
const st=claimStats(claim);
const ss=state.sessions.filter(s=>s.claimId===claim.id).sort((a,b)=>b.ts-a.ts);
if(!st){host.innerHTML=`<h2>${esc(claim.name)}</h2><p class="sub">Inga videor loggade.</p>`;return;}
const breaks=dpContentFail(claim);
host.innerHTML=`<h2>${esc(claim.name)} · resultat</h2>
<div class="metrics" style="border-top:none;padding-top:4px"><div class="metric"><div class="v ${st.verify>=.85?'pass':st.verify>=.6?'review':'fail'}">${Math.round(st.verify*100)}%</div><div class="k">verifierat</div></div><div class="metric"><div class="v">${Math.round(st.ceiling*100)}%</div><div class="k">tak (guidning)</div></div><div class="metric"><div class="v">${st.sec.toFixed(0)}s</div><div class="k">snitt-tid</div></div><div class="metric"><div class="v">${st.ins.toFixed(1)}</div><div class="k">instruktioner</div></div><div class="metric"><div class="v">${st.conf.toFixed(0)}</div><div class="k">confidence</div></div><div class="metric"><div class="v">${st.n}</div><div class="k">videor</div></div></div>
<div class="dpbreak"><p class="eyebrow" style="margin:18px 0 10px">Content-fel per datapunkt</p>${breaks.map(b=>`<div class="dpline"><div class="t"><span>${esc(b.label)}</span><span class="num">${b.con} content · ${b.cap} bild · av ${b.n}</span></div><div class="bar"><i style="width:${b.n?Math.round(b.con/b.n*100):0}%"></i></div></div>`).join('')}</div>
<p class="eyebrow" style="margin:20px 0 8px">Senaste videor</p>
${ss.slice(0,12).map(s=>{const v=sessionVerdict(s,claim);const vcls=v==="ok"?"v-ok":v==="cap"?"v-cap":"v-con";const vlbl=v==="ok"?"verifierad":v==="cap"?"capture-fel":"content-fel";return`<div class="sessrow"><span class="verdict ${vcls}">${vlbl}</span><span>${s.tagger?esc(s.tagger)+' · ':''}conf ${esc(String(s.confidence||''))}</span><span class="meta">${esc(String(s.seconds||''))}s · ${esc(String(s.instructions||''))} instr <button class="ghost tiny" data-rms="${s.id}" style="margin-left:8px">✕</button></span></div>`;}).join('')}`;
host.querySelectorAll('[data-rms]').forEach(b=>b.onclick=()=>{state.sessions=state.sessions.filter(s=>s.id!==b.dataset.rms);save();renderAuditForm();renderClaims();});
}
/* cpvc view */
function renderCPVC(){
const host=document.getElementById("cpvc-table");
if(!state.claims.length){host.innerHTML='<div class="empty">Inga claims.</div>';return;}
const wage=Number(document.getElementById("cpvc-wage").value)||120;
const overhead=Number(document.getElementById("cpvc-overhead").value)||5;
const target=Number(document.getElementById("cpvc-target").value)||50;
let rows='';
state.claims.forEach(claim=>{
const ss=state.sessions.filter(s=>s.claimId===claim.id);
if(!ss.length)return;
const ok=ss.filter(s=>sessionVerdict(s,claim)==="ok");
const totalCost=ss.reduce((a,s)=>{
const explicit=Number(s.zoomer_cost)||0;
const derived=explicit||((Number(s.seconds)||60)/3600*wage);
return a+derived+overhead;
},0);
const cpvc=ok.length?totalCost/ok.length:null;
const kill=cpvc&&cpvc>target;
const verPct=Math.round(ok.length/ss.length*100);
rows+=`<tr style="border-bottom:1px solid var(--rule-soft)">
<td style="padding:10px 8px;font-size:14px">${esc(claim.name)}</td>
<td class="num" style="padding:10px 8px;text-align:right">${ss.length}</td>
<td class="num" style="padding:10px 8px;text-align:right;color:${verPct>=80?'var(--pass)':verPct>=60?'var(--review)':'var(--fail)'}">${verPct}%</td>
<td class="num" style="padding:10px 8px;text-align:right">${totalCost.toFixed(0)} kr</td>
<td class="num" style="padding:10px 8px;text-align:right;font-weight:700;color:${cpvc?(kill?'var(--fail)':'var(--pass)'):'var(--graphite)'}">${cpvc?cpvc.toFixed(1)+' kr':'—'}</td>
<td style="padding:10px 8px;text-align:center">${kill?'<span style="color:var(--fail);font-family:var(--mono);font-size:11px">KILL</span>':'<span style="color:var(--pass);font-family:var(--mono);font-size:11px">OK</span>'}</td>
</tr>`;
});
if(!rows){host.innerHTML='<div class="empty">Logga videor för att se CPVC.</div>';return;}
host.innerHTML=`<table style="width:100%;border-collapse:collapse"><thead><tr style="border-bottom:1.5px solid var(--rule)"><th style="text-align:left;padding:8px;font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--graphite)">Claim</th><th style="text-align:right;padding:8px;font-family:var(--mono);font-size:11px;color:var(--graphite)">Videor</th><th style="text-align:right;padding:8px;font-family:var(--mono);font-size:11px;color:var(--graphite)">Verifierat</th><th style="text-align:right;padding:8px;font-family:var(--mono);font-size:11px;color:var(--graphite)">Total kostnad</th><th style="text-align:right;padding:8px;font-family:var(--mono);font-size:11px;color:var(--graphite)">CPVC</th><th style="text-align:center;padding:8px;font-family:var(--mono);font-size:11px;color:var(--graphite)">Kill?</th></tr></thead><tbody>${rows}</tbody></table>
<p class="hint">Kill-kriterier: CPVC > ${target} kr · verifierat &lt;80% · datapunkt &lt;95% → pausa och omarbeta hypotesen. Zoomer-kostnad kan loggas explicit per session eller beräknas från tid × ${wage} kr/h.</p>`;
}
/* wiring */
document.querySelectorAll('nav.tabs button').forEach(b=>b.onclick=()=>{
document.querySelectorAll('nav.tabs button').forEach(x=>x.setAttribute('aria-selected',x===b));
['claims','audit','cpvc'].forEach(id=>document.getElementById('view-'+id).classList.toggle('active',b.dataset.tab===id));
if(b.dataset.tab==='audit'){renderAuditPicker();renderAuditForm();}
if(b.dataset.tab==='cpvc'){renderCPVC();}
});
document.getElementById('sortseg').querySelectorAll('button').forEach(b=>b.onclick=()=>{sortMode=b.dataset.sort;document.getElementById('sortseg').querySelectorAll('button').forEach(x=>x.setAttribute('aria-pressed',x===b));renderClaims();});
document.getElementById('adddp').onclick=()=>{formDraft.datapoints.push({id:uid(),label:""});renderForm();};
document.getElementById('saveclaim').onclick=saveClaim;
document.getElementById('canceledit').onclick=()=>{editingId=null;formDraft=blankDraft();renderForm();};
document.getElementById('auditclaim').onchange=renderAuditForm;
['cpvc-wage','cpvc-overhead','cpvc-target'].forEach(id=>{const el=document.getElementById(id);if(el)el.oninput=renderCPVC;});
load();formDraft=blankDraft();renderForm();renderClaims();renderAuditPicker();
</script>
</body>
</html>