Marcel0123 commited on
Commit
a319f82
·
verified ·
1 Parent(s): cd7a39d

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +65 -50
index.html CHANGED
@@ -169,8 +169,8 @@
169
  <header><div><div class="eyebrow">REAKT / AI ANALYTICS PLATFORM</div><h1>Inloop <span>Intelligence</span> <em>ORCHESTRA</em></h1><p>Een intelligente blik op ontwikkeling, kwaliteit en herstelondersteunende inloop</p></div><div class="header-actions"><span class="pill"><i></i> DEMO MODE</span><span class="clock" id="clock"></span></div></header>
170
  <div class="topbar"><div><span class="section-kicker">CONTROL CENTER</span><h2>Multi-Agent Orchestration <span class="live-dot"></span></h2></div><div class="actions"><label class="pace-label" for="paceSelect">Weergave <select id="paceSelect" aria-label="Animatiesnelheid"><option value="presentation">Presentatie ± 30 sec</option><option value="fast">Snel ± 6 sec</option></select></label><button id="resetBtn" class="ghost">↺ Reset</button><button id="startBtn" class="primary">▶ Start demo-analyse</button></div></div>
171
  <section class="import-panel panel" aria-label="Excel-proef met fictieve gegevens">
172
- <div class="import-intro"><span class="section-kicker">V1.0 · EXCEL-PROEF</span><strong>Lokale scandata-test</strong><small>Alleen fictieve of volledig geanonimiseerde testbestanden · geen upload naar een server</small></div>
173
- <label class="import-file-label" for="scanFile"><span>▤</span> Kies testbestand (.xlsx)</label><input type="file" id="scanFile" accept=".xlsx" />
174
  <label class="site-label" for="siteSelect">Locatie</label><select id="siteSelect" disabled><option value="*">Alle locaties</option></select>
175
  <label class="site-label" for="periodSelect">Kwartaal</label><select id="periodSelect" disabled aria-label="Rapportagekwartaal"><option>—</option></select>
176
  <span id="importState" class="import-state">Demo staat klaar</span>
@@ -307,7 +307,7 @@ function ask(q){if(!q.trim())return;addMessage('user',q);addMessage('assistant',
307
  // v0.4: veilige browser-lokale Excel-proef, uitsluitend met fictieve / geanonimiseerde voorbeelden.
308
  // Er vindt GEEN netwerkverzoek, bestandsoverdracht of externe AI-aanroep plaats.
309
  let importedData=null;
310
- const excelLimit=2*1024*1024; // om echte grote (en mogelijk cliëntgevoelige) dumps nog niet te verwerken
311
  const excelNS='http://schemas.openxmlformats.org/spreadsheetml/2006/main';
312
  const decoder=new TextDecoder('utf-8');
313
  function bytesText(a){return decoder.decode(a)}
@@ -321,15 +321,16 @@ function zipDirectory(buffer){
321
  const entries=new Map();let pos=offset;
322
  for(let i=0;i<count;i++){
323
  if(view.getUint32(pos,true)!==0x02014b50)throw Error('ZIP-structuur ongeldig');
324
- const method=view.getUint16(pos+10,true), size=view.getUint32(pos+20,true),len=view.getUint16(pos+28,true),extra=view.getUint16(pos+30,true),comment=view.getUint16(pos+32,true),local=view.getUint32(pos+42,true);
325
  const name=bytesText(bytes.subarray(pos+46,pos+46+len));
326
- if(name.startsWith('xl/'))entries.set(name,{method,size,local});
327
  pos+=46+len+extra+comment;
328
  }
329
  return {view,bytes,entries};
330
  }
331
  async function xmlEntry(zip,name){
332
  const entry=zip.entries.get(name);if(!entry)return '';
 
333
  const {view,bytes}=zip, off=entry.local;
334
  if(view.getUint32(off,true)!==0x04034b50)throw Error('ZIP-bestandskop ongeldig');
335
  const start=off+30+view.getUint16(off+26,true)+view.getUint16(off+28,true);
@@ -343,15 +344,26 @@ function xmlDoc(str){
343
  const doc=new DOMParser().parseFromString(str,'application/xml');
344
  if(doc.getElementsByTagName('parsererror').length)throw Error('Excel-XML niet leesbaar');return doc;
345
  }
346
- function cellsToDict(row,shared){
347
- const values={};
348
- for(const c of row.getElementsByTagNameNS(excelNS,'c')){
349
- const key=(c.getAttribute('r')||'').match(/^[A-Z]+/);if(!key)continue;
350
- const v=c.getElementsByTagNameNS(excelNS,'v')[0],is=c.getElementsByTagNameNS(excelNS,'is')[0];let value=v?.textContent??'';
351
- if(c.getAttribute('t')==='s')value=shared[Number(value)]??'';
352
- else if(c.getAttribute('t')==='inlineStr')value=is?.textContent??'';
353
- values[key[0]]=value;
354
- }return values;
 
 
 
 
 
 
 
 
 
 
 
355
  }
356
  function scanDate(v){
357
  if(v===''||v===undefined||v===null)return '';
@@ -366,43 +378,34 @@ function scanDate(v){
366
  if(nl)return `${nl[3]}-${nl[2].padStart(2,'0')}-${nl[1].padStart(2,'0')}`;
367
  return '';
368
  }
369
- async function readExcelLocal(file){
370
- if(!file.name.toLowerCase().endsWith('.xlsx'))throw Error('Kies een XLSX-testbestand');
371
- if(file.size>excelLimit)throw Error('Gebruik voorlopig uitsluitend het kleine fictieve testbestand (maximaal 2 MB).');
 
372
  const zip=zipDirectory(await file.arrayBuffer()),shared=[];
 
 
 
373
  const sharedXML=await xmlEntry(zip,'xl/sharedStrings.xml');
374
- if(sharedXML){for(const si of xmlDoc(sharedXML).getElementsByTagNameNS(excelNS,'si'))shared.push(si.textContent??'')}
375
- // Deze eerste proef verwacht het klassieke Reakt-testformat met Data dump op sheet1 en Scanpassen op sheet2.
376
- const sheet=await xmlEntry(zip,'xl/worksheets/sheet1.xml');
377
- if(!sheet)throw Error('Werkblad Data dump (sheet1) ontbreekt');
378
- const rows=xmlDoc(sheet).getElementsByTagNameNS(excelNS,'row');
379
- if(rows.length<2)throw Error('Geen scanregels gevonden');
380
- if(rows.length>18000)throw Error('Voor deze eerste proef maximaal 18.000 scanregels');
381
- const header=cellsToDict(rows[0],shared),lookup={};
382
- for(const [col,name] of Object.entries(header))lookup[String(name).toLowerCase().trim()]=col;
383
  const pick=(l,...names)=>names.map(n=>l[n]).find(Boolean)||null;
384
- const dateCol=pick(lookup,'scantijd','scandatum'),siteCol=pick(lookup,'locatie'),idCol=pick(lookup,'clientnummer','cliëntnummer'),passColumn=pick(lookup,'pasnummer','scanpasnummer','pas nummer','pasnr');
385
- if(!dateCol||!siteCol||(!idCol&&!passColumn))throw Error('Kolommen scantijd, locatie en cliëntnummer of pasnummer ontbreken');
386
- // Werkblad 2 bevat een onafhankelijke pas-cliëntregistratie. Nooit identificeren op naam.
387
- const passXML=await xmlEntry(zip,'xl/worksheets/sheet2.xml');
388
  const passToClient=new Map(),clientToPasses=new Map(),passConflicts=new Set();let passRows=0,passMissing=0;
389
  const pasDates={startPresent:0,endPresent:0,migration:0,invalidDates:0,notAvailable:false,activeAtPeildatum:0};
390
  const passDateRecords=[];
 
 
391
  if(passXML){
392
- const pr=xmlDoc(passXML).getElementsByTagNameNS(excelNS,'row');
393
- if(pr.length>20000)throw Error('Voor deze proef maximaal 20.000 scanpassen');
394
- if(pr.length){
395
- const ph=cellsToDict(pr[0],shared),pl={};for(const [col,v] of Object.entries(ph))pl[String(v).toLowerCase().trim()]=col;
396
  const pc=pick(pl,'pasnummer','scanpasnummer','pas nummer','pasnr'),ic=pick(pl,'clientnummer','cliëntnummer'),startCol=pick(pl,'startdatum'),endCol=pick(pl,'einddatum');
397
  pasDates.notAvailable=!(startCol&&endCol);
398
  if(!pc||!ic)throw Error('Scanpassen mist kolommen Pasnummer en Clientnummer');
399
- for(let i=1;i<pr.length;i++){
400
- const r=cellsToDict(pr[i],shared),p=String(r[pc]??'').trim(),c=String(r[ic]??'').trim();passRows++;
401
- const start=startCol?scanDate(r[startCol]):'',end=endCol?scanDate(r[endCol]):'';
402
- if(start)pasDates.startPresent++;if(end)pasDates.endPresent++;
403
- if(start==='2022-10-28')pasDates.migration++;
404
- if(start&&end&&end<start)pasDates.invalidDates++;
405
- if(c&&p)passDateRecords.push({client:c,start,end});
406
  if(!p||!c){passMissing++;continue}
407
  if(passToClient.has(p)&&passToClient.get(p)!==c)passConflicts.add(p);
408
  else passToClient.set(p,c);
@@ -410,9 +413,18 @@ async function readExcelLocal(file){
410
  }
411
  }
412
  }
413
- const all=[],invalid={datum:0,locatie:0,client:0},unmatchedPasses=new Set(),identityConflicts=new Set();let linkedByPass=0,rowsWithPass=0;
414
- for(let i=1;i<rows.length;i++){
415
- const r=cellsToDict(rows[i],shared),site=String(r[siteCol]??'').trim(),date=scanDate(r[dateCol]);
 
 
 
 
 
 
 
 
 
416
  const pass=passColumn?String(r[passColumn]??'').trim():'',directId=idCol?String(r[idCol]??'').trim():'';
417
  if(pass)rowsWithPass++;
418
  const resolved=pass&&!passConflicts.has(pass)?passToClient.get(pass):null;
@@ -429,7 +441,8 @@ async function readExcelLocal(file){
429
  const peildatum=all.reduce((m,r)=>r.date>m?r.date:m,'');
430
  const activeIds=new Set(passDateRecords.filter(r=>r.start&&r.end&&r.start<=peildatum&&r.end>=peildatum).map(r=>r.client));
431
  pasDates.activeAtPeildatum=activeIds.size;
432
- return {all,sites,peildatum,pasDates,rawCount:rows.length-1,invalid,hasPassColumn:!!passColumn,
 
433
  passSheetFound:!!passXML,passRows,passMissing,passToClient,clientToPasses,
434
  passConflicts:passConflicts.size,identityConflicts:identityConflicts.size,
435
  unmatchedPasses:unmatchedPasses.size,linkedByPass,rowsWithPass};
@@ -638,7 +651,9 @@ $('scanFile').addEventListener('change',async e=>{
638
  importedData=null;resetControllerComparison(true);updateControllerContext();const file=e.target.files?.[0];if(!file)return;
639
  const state=$('importState');state.textContent='Bestand wordt lokaal gelezen...';$('startBtn').disabled=true;
640
  try{
641
- importedData=await readExcelLocal(file);
 
 
642
  const sel=$('siteSelect');sel.innerHTML='<option value="*">Alle locaties</option>';
643
  importedData.sites.forEach(loc=>{const opt=document.createElement('option');opt.value=loc;opt.textContent=loc;sel.appendChild(opt)});
644
  sel.disabled=false;
@@ -646,10 +661,10 @@ $('scanFile').addEventListener('change',async e=>{
646
  const quarter=$('periodSelect');quarter.replaceChildren();
647
  periods.forEach(key=>{const opt=document.createElement('option');opt.value=key;opt.textContent=quarterDates(key).label;quarter.appendChild(opt)});
648
  quarter.disabled=false;
649
- state.textContent=`${importedData.rawCount} testregels · ${importedData.sites.length} locaties · Scanpassen ${importedData.passSheetFound?importedData.passRows+' regels':'ontbreekt'}`;
650
- $('startBtn').textContent='▶ Analyseer testbestand';
651
- $('flowState').textContent='Testbestand lokaal ingelezen · start de analyse';
652
- log('Fictief Excel-testbestand is lokaal ingelezen; nog geen berekeningen gepubliceerd');
653
  }catch(err){$('startBtn').textContent='▶ Start demo-analyse';state.textContent='⚠ '+err.message;$('siteSelect').disabled=true;$('periodSelect').disabled=true;log('Excel-proef: '+err.message)}finally{$('startBtn').disabled=false}
654
  });
655
  $('siteSelect').addEventListener('change',()=>{if(importedData){importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Locatie gewijzigd · analyse opnieuw starten';}});
 
169
  <header><div><div class="eyebrow">REAKT / AI ANALYTICS PLATFORM</div><h1>Inloop <span>Intelligence</span> <em>ORCHESTRA</em></h1><p>Een intelligente blik op ontwikkeling, kwaliteit en herstelondersteunende inloop</p></div><div class="header-actions"><span class="pill"><i></i> DEMO MODE</span><span class="clock" id="clock"></span></div></header>
170
  <div class="topbar"><div><span class="section-kicker">CONTROL CENTER</span><h2>Multi-Agent Orchestration <span class="live-dot"></span></h2></div><div class="actions"><label class="pace-label" for="paceSelect">Weergave <select id="paceSelect" aria-label="Animatiesnelheid"><option value="presentation">Presentatie ± 30 sec</option><option value="fast">Snel ± 6 sec</option></select></label><button id="resetBtn" class="ghost">↺ Reset</button><button id="startBtn" class="primary">▶ Start demo-analyse</button></div></div>
171
  <section class="import-panel panel" aria-label="Excel-proef met fictieve gegevens">
172
+ <div class="import-intro"><span class="section-kicker">V1.3 · GROTE-BESTANDENPROEF</span><strong>Lokale scandata-test</strong><small>Test lokaal in browser · eerst fictieve gegevens · 25 MB testlimiet</small></div>
173
+ <label class="import-file-label" for="scanFile"><span>▤</span> Kies Excelbestand (.xlsx)</label><input type="file" id="scanFile" accept=".xlsx" />
174
  <label class="site-label" for="siteSelect">Locatie</label><select id="siteSelect" disabled><option value="*">Alle locaties</option></select>
175
  <label class="site-label" for="periodSelect">Kwartaal</label><select id="periodSelect" disabled aria-label="Rapportagekwartaal"><option>—</option></select>
176
  <span id="importState" class="import-state">Demo staat klaar</span>
 
307
  // v0.4: veilige browser-lokale Excel-proef, uitsluitend met fictieve / geanonimiseerde voorbeelden.
308
  // Er vindt GEEN netwerkverzoek, bestandsoverdracht of externe AI-aanroep plaats.
309
  let importedData=null;
310
+ const excelLimit=25*1024*1024; // v1.3: technische grens, geen privacytoestemming
311
  const excelNS='http://schemas.openxmlformats.org/spreadsheetml/2006/main';
312
  const decoder=new TextDecoder('utf-8');
313
  function bytesText(a){return decoder.decode(a)}
 
321
  const entries=new Map();let pos=offset;
322
  for(let i=0;i<count;i++){
323
  if(view.getUint32(pos,true)!==0x02014b50)throw Error('ZIP-structuur ongeldig');
324
+ const method=view.getUint16(pos+10,true), size=view.getUint32(pos+20,true),inflated=view.getUint32(pos+24,true),len=view.getUint16(pos+28,true),extra=view.getUint16(pos+30,true),comment=view.getUint16(pos+32,true),local=view.getUint32(pos+42,true);
325
  const name=bytesText(bytes.subarray(pos+46,pos+46+len));
326
+ if(name.startsWith('xl/'))entries.set(name,{method,size,inflated,local});
327
  pos+=46+len+extra+comment;
328
  }
329
  return {view,bytes,entries};
330
  }
331
  async function xmlEntry(zip,name){
332
  const entry=zip.entries.get(name);if(!entry)return '';
333
+ if(entry.inflated>180*1024*1024)throw Error('Een Excel-onderdeel is uitgepakt groter dan 180 MB; bestand eerst opsplitsen');
334
  const {view,bytes}=zip, off=entry.local;
335
  if(view.getUint32(off,true)!==0x04034b50)throw Error('ZIP-bestandskop ongeldig');
336
  const start=off+30+view.getUint16(off+26,true)+view.getUint16(off+28,true);
 
344
  const doc=new DOMParser().parseFromString(str,'application/xml');
345
  if(doc.getElementsByTagName('parsererror').length)throw Error('Excel-XML niet leesbaar');return doc;
346
  }
347
+ // v1.3: rijgewijs XML uitlezen; geen DOM-boom van 100+ MB grote werkbladen.
348
+ function decodeXML(v){return String(v).replace(/&(?:amp|lt|gt|quot|apos|#(?:x[0-9a-fA-F]+|\d+));/g,m=>{const k=m.slice(1,-1);if(k[0]==='#'){const n=k[1]?.toLowerCase()==='x'?parseInt(k.slice(2),16):Number(k.slice(1));return Number.isInteger(n)&&n>=0&&n<=0x10ffff?String.fromCodePoint(n):m}return {amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}[k]||m})}
349
+ function parseSharedLite(xml){
350
+ const arr=[];const si=/<(?:[\w.-]+:)?si\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?si>/g;let m;
351
+ while((m=si.exec(xml))!==null){let t='',p;const re=/<(?:[\w.-]+:)?t(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w.-]+:)?t>/g;while((p=re.exec(m[1]))!==null)t+=decodeXML(p[1]);arr.push(t)}
352
+ return arr;
353
+ }
354
+ function *sheetRows(xml,shared){
355
+ const rowRe=/<(?:[\w.-]+:)?row\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?row>/g, cellRe=/<(?:[\w.-]+:)?c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/(?:[\w.-]+:)?c>)/g;let row;
356
+ while((row=rowRe.exec(xml))!==null){const values={};let c;cellRe.lastIndex=0;
357
+ while((c=cellRe.exec(row[1]))!==null){const col=/\br="([A-Z]+)\d+"/.exec(c[1]);if(!col)continue;
358
+ const type=/\bt="([^"]+)"/.exec(c[1])?.[1]||'';const payload=c[2]||'';
359
+ let v=/<(?:[\w.-]+:)?v(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w.-]+:)?v>/.exec(payload)?.[1]??'';
360
+ if(type==='s')v=shared[Number(v)]??'';
361
+ else if(type==='inlineStr'){v='';let t;const tr=/<(?:[\w.-]+:)?t(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w.-]+:)?t>/g;while((t=tr.exec(payload))!==null)v+=decodeXML(t[1]);}
362
+ else v=decodeXML(v);
363
+ values[col[1]]=v;
364
+ }
365
+ yield values;
366
+ }
367
  }
368
  function scanDate(v){
369
  if(v===''||v===undefined||v===null)return '';
 
378
  if(nl)return `${nl[3]}-${nl[2].padStart(2,'0')}-${nl[1].padStart(2,'0')}`;
379
  return '';
380
  }
381
+ async function readExcelLocal(file,onProgress=()=>{}){
382
+ if(!file.name.toLowerCase().endsWith('.xlsx'))throw Error('Kies een XLSX-bestand');
383
+ if(file.size>excelLimit)throw Error('Dit bestand is groter dan de v1.3-testgrens van 25 MB');
384
+ onProgress('Excel wordt geopend ('+(file.size/1024/1024).toFixed(1)+' MB)…');
385
  const zip=zipDirectory(await file.arrayBuffer()),shared=[];
386
+ const scanMeta=zip.entries.get('xl/worksheets/sheet1.xml');
387
+ if(!scanMeta)throw Error('Werkblad Data dump (sheet1) ontbreekt');
388
+ onProgress('Excel-structuur gecontroleerd · werkbladen worden gelezen…');
389
  const sharedXML=await xmlEntry(zip,'xl/sharedStrings.xml');
390
+ if(sharedXML){for(const item of parseSharedLite(sharedXML))shared.push(item);}
 
 
 
 
 
 
 
 
391
  const pick=(l,...names)=>names.map(n=>l[n]).find(Boolean)||null;
 
 
 
 
392
  const passToClient=new Map(),clientToPasses=new Map(),passConflicts=new Set();let passRows=0,passMissing=0;
393
  const pasDates={startPresent:0,endPresent:0,migration:0,invalidDates:0,notAvailable:false,activeAtPeildatum:0};
394
  const passDateRecords=[];
395
+ // Eerst de pasregistratie, zodat scans zonder direct cliëntnummer gekoppeld kunnen worden.
396
+ const passXML=await xmlEntry(zip,'xl/worksheets/sheet2.xml');
397
  if(passXML){
398
+ const it=sheetRows(passXML,shared),first=it.next();
399
+ if(!first.done){const pl={};for(const [col,v] of Object.entries(first.value))pl[String(v).toLowerCase().trim()]=col;
 
 
400
  const pc=pick(pl,'pasnummer','scanpasnummer','pas nummer','pasnr'),ic=pick(pl,'clientnummer','cliëntnummer'),startCol=pick(pl,'startdatum'),endCol=pick(pl,'einddatum');
401
  pasDates.notAvailable=!(startCol&&endCol);
402
  if(!pc||!ic)throw Error('Scanpassen mist kolommen Pasnummer en Clientnummer');
403
+ for(const r of it){const p=String(r[pc]??'').trim(),c=String(r[ic]??'').trim();passRows++;
404
+ if(passRows>350000)throw Error('Meer dan 350.000 pasregistraties; contacteer ontwikkelaar voor grotere import');
405
+ const st=startCol?scanDate(r[startCol]):'',en=endCol?scanDate(r[endCol]):'';
406
+ if(st)pasDates.startPresent++;if(en)pasDates.endPresent++;
407
+ if(st==='2022-10-28')pasDates.migration++;if(st&&en&&en<st)pasDates.invalidDates++;
408
+ if(c&&p)passDateRecords.push({client:c,start:st,end:en});
 
409
  if(!p||!c){passMissing++;continue}
410
  if(passToClient.has(p)&&passToClient.get(p)!==c)passConflicts.add(p);
411
  else passToClient.set(p,c);
 
413
  }
414
  }
415
  }
416
+ onProgress('Scanpassen verwerkt ('+passRows.toLocaleString('nl-NL')+') · scanhistorie wordt gelezen…');
417
+ await new Promise(resolve=>setTimeout(resolve,0));
418
+ const sheet=await xmlEntry(zip,'xl/worksheets/sheet1.xml');
419
+ if(!sheet)throw Error('Werkblad Data dump ontbreekt');
420
+ const it=sheetRows(sheet,shared),first=it.next();if(first.done)throw Error('Werkblad Data dump is leeg');
421
+ const lookup={};for(const [col,name] of Object.entries(first.value))lookup[String(name).toLowerCase().trim()]=col;
422
+ const dateCol=pick(lookup,'scantijd','scandatum'),siteCol=pick(lookup,'locatie'),idCol=pick(lookup,'clientnummer','cliëntnummer'),passColumn=pick(lookup,'pasnummer','scanpasnummer','pas nummer','pasnr');
423
+ if(!dateCol||!siteCol||(!idCol&&!passColumn))throw Error('Kolommen scantijd, locatie en cliëntnummer of pasnummer ontbreken');
424
+ const all=[],invalid={datum:0,locatie:0,client:0},unmatchedPasses=new Set(),identityConflicts=new Set();let linkedByPass=0,rowsWithPass=0,rawCount=0;
425
+ for(const r of it){rawCount++;
426
+ if(rawCount>500000)throw Error('Meer dan 500.000 scans: importlimiet v1.3 bereikt; bestand opsplitsen');
427
+ const site=String(r[siteCol]??'').trim(),date=scanDate(r[dateCol]);
428
  const pass=passColumn?String(r[passColumn]??'').trim():'',directId=idCol?String(r[idCol]??'').trim():'';
429
  if(pass)rowsWithPass++;
430
  const resolved=pass&&!passConflicts.has(pass)?passToClient.get(pass):null;
 
441
  const peildatum=all.reduce((m,r)=>r.date>m?r.date:m,'');
442
  const activeIds=new Set(passDateRecords.filter(r=>r.start&&r.end&&r.start<=peildatum&&r.end>=peildatum).map(r=>r.client));
443
  pasDates.activeAtPeildatum=activeIds.size;
444
+ onProgress('Ingelezen: '+rawCount.toLocaleString('nl-NL')+' scanregels · '+sites.length+' locaties');
445
+ return {all,sites,peildatum,pasDates,rawCount,invalid,hasPassColumn:!!passColumn,
446
  passSheetFound:!!passXML,passRows,passMissing,passToClient,clientToPasses,
447
  passConflicts:passConflicts.size,identityConflicts:identityConflicts.size,
448
  unmatchedPasses:unmatchedPasses.size,linkedByPass,rowsWithPass};
 
651
  importedData=null;resetControllerComparison(true);updateControllerContext();const file=e.target.files?.[0];if(!file)return;
652
  const state=$('importState');state.textContent='Bestand wordt lokaal gelezen...';$('startBtn').disabled=true;
653
  try{
654
+ const importStart=performance.now();
655
+ importedData=await readExcelLocal(file,msg=>{state.textContent=msg});
656
+ const importSeconds=((performance.now()-importStart)/1000).toFixed(1);
657
  const sel=$('siteSelect');sel.innerHTML='<option value="*">Alle locaties</option>';
658
  importedData.sites.forEach(loc=>{const opt=document.createElement('option');opt.value=loc;opt.textContent=loc;sel.appendChild(opt)});
659
  sel.disabled=false;
 
661
  const quarter=$('periodSelect');quarter.replaceChildren();
662
  periods.forEach(key=>{const opt=document.createElement('option');opt.value=key;opt.textContent=quarterDates(key).label;quarter.appendChild(opt)});
663
  quarter.disabled=false;
664
+ state.textContent=`${importedData.rawCount.toLocaleString('nl-NL')} scanregels · ${importedData.sites.length} locaties · ${importSeconds} sec inleestijd · Scanpassen ${importedData.passSheetFound?importedData.passRows+' regels':'ontbreekt'}`;
665
+ $('startBtn').textContent='▶ Analyseer Excelbestand';
666
+ $('flowState').textContent='Excelbestand lokaal ingelezen · start de analyse';
667
+ log('Excelbestand lokaal verwerkt; analyse moet nog starten. Voor echte data eerst interne toestemming controleren.');
668
  }catch(err){$('startBtn').textContent='▶ Start demo-analyse';state.textContent='⚠ '+err.message;$('siteSelect').disabled=true;$('periodSelect').disabled=true;log('Excel-proef: '+err.message)}finally{$('startBtn').disabled=false}
669
  });
670
  $('siteSelect').addEventListener('change',()=>{if(importedData){importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Locatie gewijzigd · analyse opnieuw starten';}});