Files
boc/qx-audit.html
T

390 lines
30 KiB
HTML
Raw Normal View History

<!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>