Marcel0123 commited on
Commit
3f08a04
·
verified ·
1 Parent(s): 63ee63d

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +133 -33
index.html CHANGED
@@ -162,18 +162,26 @@
162
  /* v1.2: los, compact controlepaneel; bestaande ORCHESTRA en rapport-opmaak ongewijzigd */
163
  .controller-panel{padding:17px}.controller-note,.controller-footer{font-size:11px;line-height:1.55;color:#a8bad5;margin:6px 0 12px}.controller-context{border:1px solid #345075;background:#102642;border-radius:7px;padding:9px 12px;color:#b5d5f5;font-size:11px;margin-bottom:12px}
164
  .controller-entry{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.controller-entry label{display:flex;flex-direction:column;gap:7px;color:#b5c6dd;font-size:10px}.controller-entry input{width:100%;min-width:0;background:#0a1528;border:1px solid #365579;border-radius:7px;padding:10px;color:#f2f7ff;font-size:14px}.controller-entry input:focus{outline:1px solid #8e8bff}.controller-actions{display:flex;gap:9px;margin:12px 0}.controller-result{font-size:11px;color:#a9bfda}.controller-table{width:100%;border-collapse:collapse;margin-top:8px;font-size:11px}.controller-table th,.controller-table td{text-align:right;padding:9px 7px;border-bottom:1px solid #28415e}.controller-table th:first-child,.controller-table td:first-child{text-align:left}.controller-table th{color:#94b4dc;font-weight:600}.controller-ok{color:#57edb7;font-weight:700}.controller-bad{color:#ffb37b;font-weight:700}.controller-summary{padding:10px 12px;border-radius:7px;border:1px solid #5a5b79;background:#23243d;margin:10px 0}.controller-footer{margin-bottom:0}.controller-note strong{color:#eaf4ff}@media(max-width:800px){.controller-entry{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:460px){.controller-entry{grid-template-columns:1fr}}
 
 
 
 
 
 
165
  </style></head>
166
  <body>
167
  <div class="app">
168
  <main class="main">
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> LOKALE ANALYSE</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.4 · FLEXIBELE RAPPORTAGES</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="periodType">Rapportageperiode</label><select id="periodType" disabled aria-label="Soort rapportage"><option value="quarter">Kwartaalrapportage</option><option value="half">Halfjaarrapportage</option><option value="nine">Negenmaandsrapportage</option></select><select id="periodSelect" disabled aria-label="Geselecteerde rapportageperiode"><option>—</option></select>
176
- <span id="importState" class="import-state">Demo staat klaar</span>
 
177
  </section>
178
  <section class="orchestra panel">
179
  <div class="mesh"></div><div class="conductor-wrap"><div class="conductor" id="conductor"><div class="conductor-glow"></div><div class="conductor-icon" aria-hidden="true"><svg class="brain-symbol" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5c-2.7-3.2-7.4-.9-7.1 2.4C2 9 2 13.6 5 15.2c-.7 4.4 4.9 6.8 7 2.6M12 5c2.7-3.2 7.4-.9 7.1 2.4 2.9 1.6 2.9 6.2-.1 7.8.7 4.4-4.9 6.8-7 2.6M12 5v13"/><path d="M6.6 10.1 9 12v3M17.4 10.1 15 12v3"/><circle cx="9" cy="15.5" r=".9" fill="currentColor" stroke="none"/><circle cx="15" cy="15.5" r=".9" fill="currentColor" stroke="none"/></svg></div><div><small>MASTER ORCHESTRATOR</small><strong>THE CONDUCTOR</strong><span id="conductorStatus">Gereed voor analyse</span></div><div class="conductor-indicator"></div></div></div>
@@ -208,9 +216,9 @@
208
  <div class="controller-context" id="controllerContext">Analyseer eerst het fictieve Excel-bestand en kies een locatie en kwartaal.</div>
209
  <div class="controller-entry" id="controllerFields">
210
  <label>Unieke cliënten (historie)<input id="ctrlUnique" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 18"></label>
211
- <label>Nieuwe instroom (kwartaal)<input id="ctrlIntake" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 14"></label>
212
  <label>Actieve cliënten (3 maanden)<input id="ctrlActive" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 17"></label>
213
- <label>Unieke bezoekdagen (kwartaal)<input id="ctrlVisits" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 49"></label>
214
  </div>
215
  <div class="controller-actions"><button id="controllerCompare" class="primary" type="button">↔ Vergelijk cijfers</button><button id="controllerClear" class="ghost" type="button">Wissen</button></div>
216
  <div id="controllerResult" class="controller-result" aria-live="polite">Nog geen onafhankelijke controlecijfers vergeleken.</div>
@@ -221,11 +229,11 @@
221
  <aside class="right-content"><section class="panel chatpanel"><div class="panel-heading"><div><span class="section-kicker">MANAGEMENT ASSISTENT</span><h3><span class="chat-icon">✧</span> Vraag het aan Inloop AI</h3></div><span class="demo-badge">DEMO</span></div><div class="chat-messages" id="messages"><div class="message assistant"><small>INLOOP AI · DEMONSTRATIE</small><p>Welkom! Start de demo-analyse en stel daarna een vraag over de fictieve locatiecijfers.</p></div></div><div class="quick-questions"><button data-q="Welke locatie groeit het sterkst?">Grootste groei?</button><button data-q="Zijn de cijfers gevalideerd?">Validatiestatus?</button><button data-q="Maak een managementsamenvatting">Samenvatting?</button></div><form id="chatForm" class="chat-form"><input id="chatInput" autocomplete="off" placeholder="Stel een vraag over de demo..." aria-label="Vraag aan Inloop AI"><button aria-label="Verstuur vraag">➤</button></form><div class="chat-disclaimer">Vooraf ingestelde demo-antwoorden · geen echte AI</div></section>
222
  <section class="panel preview"><div class="panel-heading"><div><span class="section-kicker">OUTPUT</span><h3>Infographic preview</h3></div><span class="warn-label">CONCEPT</span></div><div class="paper"><div class="paper-top"><strong>REAKT<span>.</span></strong><small>INLOOPMONITOR / Q3 2026</small></div><h4>Inloop in beeld</h4><p>Illustratieve managementrapportage</p><div class="paper-kpis"><div><strong>12.480</strong><small>Bezoeken</small></div><div><strong>1.286</strong><small>Uniek</small></div><div><strong>164</strong><small>Instroom</small></div></div><div class="paper-bars"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div><div class="paper-watermark">FICTIEVE DATA · NIET GEVALIDEERD</div></div><button class="export" id="exportBtn">▣ Open witte Reakt-monitor</button></section></aside>
223
  </div>
224
- <footer>REAKT INLOOP INTELLIGENCE <span>ORCHESTRA PROTOTYPE · ALLEEN FICTIEVE TESTGEGEVENS · GEEN PRODUCTIEOMGEVING</span></footer>
225
  </main>
226
  </div>
227
  <div id="reportOverlay" role="dialog" aria-modal="true" aria-label="Reakt Monitor rapport" aria-hidden="true">
228
- <div class="report-actions"><span><strong>REAKT · Rapportvoorvertoning v1.2</strong> &nbsp; — uitsluitend fictieve testgegevens</span><div><button id="reportClose" type="button">✕ Sluiten</button> <button id="reportPng" class="report-png" type="button">⬇ Download PNG</button> <button id="reportJpg" class="report-jpg" type="button">⬇ Download JPG</button> <button id="reportPrint" class="report-print" type="button">↓ Opslaan als PDF / Afdrukken</button></div></div>
229
  <section id="reportSheet" aria-label="Witte Inloop Monitor"></section>
230
  </div>
231
  <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
@@ -274,7 +282,7 @@ function stepDescriptions(agent, result, fake){
274
  if(agent==='data')return fake?['Demobron geladen','4 voorbeeldlocaties','Fictieve scanregels','Democontrole afgerond','Overdracht aan analyse']:[
275
  'Excel-bestand ingelezen',`${sites} locaties herkend`,`${n(rows)} scanregels bekeken`,`${n(repeat)} herhaalde scans gevonden`,result?.passChecked?`${n(result.multiPassClients)} cliënten met meerdere passen`:'Pascontrole niet beschikbaar'];
276
  if(agent==='analysis')return fake?['Voorbeeld-KPI’s geladen','Locaties vergeleken','Trends weergegeven','Groei samengevat','Overdracht aan inzicht']:[
277
- 'Dubbele scans uitgesloten',`${n(unique)} unieke cliënten historisch`,`${n(result.newIntake)} instromers in kwartaal`,`${n(result.active)} actieve cliënten`, 'Overdracht aan Insight Agent'];
278
  if(agent==='insight')return fake?['Voorbeeldtrend gelezen','Locatieverschillen bekeken','Fictieve inzichten verzameld','Conceptduiding opgesteld','Overdracht aan verificatie']:[
279
  'Maandgegevens beschikbaar','Locaties in grafiek gezet','Signalen zonder causaliteit','AI-duiding niet aangesloten','Overdracht aan controle'];
280
  if(agent==='verify')return fake?['Demovergelijking geopend','5 indicatoren bekeken','1 fictieve afwijking','Rapport niet gevalideerd','Overdracht met waarschuwing']:[
@@ -307,7 +315,13 @@ 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=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)}
@@ -421,10 +435,12 @@ async function readExcelLocal(file,onProgress=()=>{}){
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;
@@ -432,11 +448,13 @@ async function readExcelLocal(file,onProgress=()=>{}){
432
  if(directId&&resolved&&directId!==resolved)identityConflicts.add(pass);
433
  if(!directId&&resolved)linkedByPass++;
434
  const id=directId||resolved||'';
435
- if(!date)invalid.datum++;if(!site)invalid.locatie++;if(!id)invalid.client++;
436
- if(!site||!id||!date)continue;
 
 
437
  all.push({date,site,id,pass});
438
  }
439
- if(!all.length)throw Error('Geen bruikbare scanregels');
440
  const sites=[...new Set(all.map(r=>r.site))].sort((a,b)=>a.localeCompare(b,'nl'));
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));
@@ -445,8 +463,40 @@ async function readExcelLocal(file,onProgress=()=>{}){
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};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  }
 
450
  // v1.4 — kwartaal-, halfjaar- en negenmaandsrapportage; historie t/m peildatum.
451
  function quarterKey(date){return date.slice(0,4)+'-Q'+(Math.floor((Number(date.slice(5,7))-1)/3)+1);}
452
  function quarterDates(key){
@@ -486,7 +536,7 @@ function reportPeriodDates(key){
486
  }
487
  function availablePeriods(type){
488
  const dataMonths=[...new Set(importedData.all.map(r=>r.date.slice(0,7)))].sort();
489
- const years=[...new Set(dataMonths.map(x=>x.slice(0,4)))].sort().reverse();
490
  const keys=[];
491
  for(const year of years){
492
  const suffixes=type==='quarter'?['Q4','Q3','Q2','Q1']:type==='half'?['H2','H1']:['M9'];
@@ -523,9 +573,15 @@ function calculateImported(site='*'){
523
  const quarterRows=historicRows.filter(r=>r.date>=period.begin&&r.date<=period.peildatum);
524
  const visitKeys=new Set(),clientKeys=new Set(),quarterClientKeys=new Set(),activeKeys=new Set(),monthSite=new Map();
525
  const instroomByMonth=new Map(period.months.map(m=>[m,0]));
526
- const firstByClient=new Map();
 
 
 
 
 
527
  for(const r of historicRows){
528
- const key=JSON.stringify([r.site,r.id]);clientKeys.add(key);
 
529
  if(!firstByClient.has(key)||r.date<firstByClient.get(key))firstByClient.set(key,r.date);
530
  if(r.date>=period.activeFrom)activeKeys.add(key);
531
  }
@@ -541,7 +597,11 @@ function calculateImported(site='*'){
541
  visitKeys.add(key);
542
  const mk=r.site+'|'+r.date.slice(0,7);monthSite.set(mk,(monthSite.get(mk)||0)+1);
543
  }
544
- const active=activeKeys.size,unique=clientKeys.size;
 
 
 
 
545
  const passtiedIds=new Set(historicRows.map(r=>r.id));
546
  const passByClient=new Map();let missingPass=0;
547
  for(const r of historicRows){if(r.pass){if(!passByClient.has(r.id))passByClient.set(r.id,new Set());passByClient.get(r.id).add(r.pass)}else missingPass++;}
@@ -551,6 +611,8 @@ function calculateImported(site='*'){
551
  const locations=(site==='*'?importedData.sites:[site]).map(name=>({name,visits:period.months.map(m=>monthSite.get(name+'|'+m)||0)}));
552
  return {site,period,rows:quarterRows.length,historyRows:historicRows.length,visits:visitKeys.size,unique,
553
  quarterClients:quarterClientKeys.size,newIntake:instroom,active,firstByClient,instroomByMonth,
 
 
554
  duplicates:quarterRows.length-visitKeys.size,months:period.months,locations,
555
  invalid:importedData.invalid,passChecked:importedData.hasPassColumn||importedData.passSheetFound,
556
  passSheetFound:importedData.passSheetFound,pasDates:importedData.pasDates,peildatum:period.peildatum,multiPassClients,
@@ -562,9 +624,13 @@ function calculateImported(site='*'){
562
  function independentlyVerify(result){
563
  const {begin,peildatum,activeFrom,months}=result.period;
564
  const rows=importedData.all.filter(r=>(result.site==='*'||r.site===result.site)&&r.date<=peildatum);
565
- const people=new Map(),visits=new Map(),monthFirst=new Map(months.map(m=>[m,0]));
 
 
 
566
  for(const r of rows){
567
- const key=JSON.stringify([r.site,r.id]);
 
568
  let p=people.get(key);
569
  if(!p){p={first:r.date,recent:false,inQuarter:false};people.set(key,p);}
570
  if(r.date<p.first)p.first=r.date;
@@ -584,6 +650,9 @@ function independentlyVerify(result){
584
  const inQuarterRows=[...visits.values()].reduce((s,n)=>s+n,0);
585
  const shadow={unique:people.size,newIntake:intake,active,visits:visits.size,quarterClients,
586
  duplicates:inQuarterRows-visits.size,monthNew:[...monthFirst.values()].reduce((a,b)=>a+b,0)};
 
 
 
587
  const checks=[
588
  {label:'Unieke cliënten · tweede telling',ours:result.unique,shadow:shadow.unique},
589
  {label:'Nieuwe instroom · eerste scan',ours:result.newIntake,shadow:shadow.newIntake},
@@ -593,6 +662,10 @@ function independentlyVerify(result){
593
  {label:'Herhaalde scans in periode',ours:result.duplicates,shadow:shadow.duplicates},
594
  {label:'Maandinstroom versus periode-instroom',ours:result.newIntake,shadow:shadow.monthNew}
595
  ];
 
 
 
 
596
  const mismatches=checks.filter(c=>c.ours!==c.shadow).length;
597
  const sourceIssues=[
598
  ['Tegenstrijdige pasregistraties',result.passConflicts],
@@ -624,7 +697,7 @@ function renderImported(result){
624
  const num=n=>n.toLocaleString('nl-NL');
625
  const kpis=[{label:'Unieke bezoekdagen',value:num(result.visits),change:result.period.label+' · cliënt / dag / locatie'},
626
  {label:'Unieke cliënten',value:num(result.unique),change:'Ooit t/m peildatum · per locatie'},
627
- {label:'Nieuwe instroom',value:num(result.newIntake),change:'Eerste scan in '+result.period.label},
628
  {label:'Actieve cliënten',value:num(result.active),change:'Laatste 3 maanden t/m peildatum'}];
629
  renderKPIs({kpis});drawImportedChart(result);
630
  const quality=result.verification||independentlyVerify(result);
@@ -637,6 +710,13 @@ function renderImported(result){
637
  ['Bezoekers binnen periode',num(result.quarterClients)],
638
  ['Unieke bezoekdagen periode',num(result.visits)],
639
  ['Herhaalde scans periode',num(result.duplicates)],
 
 
 
 
 
 
 
640
  ['Scanpassen: apart werkblad',result.passSheetFound?num(result.passRows)+' registraties':'NIET AANWEZIG'],
641
  ['Cliënten met meerdere passen',result.passChecked?num(result.multiPassClients):'NIET TE CONTROLEREN'],
642
  ['Scanregels via pas gekoppeld (hele bestand)',num(result.linkedByPass)],
@@ -651,7 +731,7 @@ function renderImported(result){
651
  const issue=quality.mismatches||quality.sourceIssues.length;
652
  document.querySelector('.verification .warning').textContent=quality.mismatches?`⚠ ${quality.mismatches} interne telverschillen gevonden. Cijfers nader onderzoeken. NIET GEVALIDEERD.`:
653
  quality.sourceIssues.length?`⚠ Tweede telling sluit aan, maar ${quality.sourceIssues.length} soorten bronproblemen gevonden. NIET GEVALIDEERD.`:
654
- '✓ 7 interne telcontroles sluiten aan. Let op: volledigheid van historische scans onbekend; externe businesscontrollercontrole ontbreekt. NIET EXTERN GEVALIDEERD.';
655
  document.querySelector('.paper-watermark').textContent='LOKALE EXCEL-ANALYSE · NIET EXTERN GEVALIDEERD';
656
  document.querySelector('.paper p').textContent=result.period.label+' · lokale Excel-proef · niet voor publicatie';
657
  const pk=document.querySelectorAll('.paper-kpis strong');[num(result.visits),num(result.unique),num(result.newIntake)].forEach((x,i)=>{if(pk[i])pk[i].textContent=x});
@@ -669,7 +749,7 @@ async function startImported(){
669
  {agent:'data',text:`${result.rows.toLocaleString('nl-NL')} scanregels gecontroleerd; ${result.duplicates} herhaalde scans; Scanpassen: ${result.passSheetFound?result.passRows+' registraties':'geen apart werkblad'}`},
670
  {agent:'analysis',text:`${result.unique} unieke cliënten ooit; ${result.newIntake} nieuwe instromers in ${result.period.label}; ${result.active} actieve cliënten`},
671
  {agent:'insight',text:'Maandtrends berekend; AI-duiding nog niet verbonden'},
672
- {agent:'verify',text:`${result.verification.checks.length} onafhankelijke interne telcontroles; ${result.verification.mismatches} afwijkingen; ${result.verification.sourceIssues.length} soorten bronproblemen. Extern niet gevalideerd`},
673
  {agent:'report',text:'Conceptpreview bijgewerkt · NIET GEVALIDEERD'}];
674
  try{
675
  resetControllerComparison(true);updateControllerContext();
@@ -682,7 +762,7 @@ async function startImported(){
682
  }catch(err){log('FOUT: '+err.message);$('flowState').textContent='Proefberekening mislukt';}finally{clearSignal();running=false;$('startBtn').disabled=false;$('resetBtn').disabled=false;$('conductor').classList.remove('running');document.querySelector('.orchestra').classList.remove('running')}
683
  }
684
  $('scanFile').addEventListener('change',async e=>{
685
- importedData=null;resetControllerComparison(true);updateControllerContext();const file=e.target.files?.[0];if(!file)return;
686
  const state=$('importState');state.textContent='Bestand wordt lokaal gelezen...';$('startBtn').disabled=true;
687
  try{
688
  const importStart=performance.now();
@@ -691,16 +771,35 @@ $('scanFile').addEventListener('change',async e=>{
691
  const sel=$('siteSelect');sel.innerHTML='<option value="*">Alle locaties</option>';
692
  importedData.sites.forEach(loc=>{const opt=document.createElement('option');opt.value=loc;opt.textContent=loc;sel.appendChild(opt)});
693
  sel.disabled=false;
694
- const type=$('periodType');
 
695
  type.disabled=false;
696
  type.value=availablePeriods('nine').length?'nine':'quarter';
697
  refreshPeriodChoices();
698
- state.textContent=`${importedData.rawCount.toLocaleString('nl-NL')} scanregels · ${importedData.sites.length} locaties · ${importSeconds} sec inleestijd · Scanpassen ${importedData.passSheetFound?importedData.passRows+' regels':'ontbreekt'}`;
 
699
  $('startBtn').textContent='▶ Analyseer Excelbestand';
700
  $('flowState').textContent='Excelbestand lokaal ingelezen · start de analyse';
701
  log('Excelbestand lokaal verwerkt; analyse moet nog starten. Voor echte data eerst interne toestemming controleren.');
702
  }catch(err){$('startBtn').textContent='▶ Start demo-analyse';state.textContent='⚠ '+err.message;$('siteSelect').disabled=true;$('periodSelect').disabled=true;$('periodType').disabled=true;log('Excel-proef: '+err.message)}finally{$('startBtn').disabled=false}
703
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  $('siteSelect').addEventListener('change',()=>{if(importedData){importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Locatie gewijzigd · analyse opnieuw starten';}});
705
  $('periodSelect').addEventListener('change',()=>{if(importedData){importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Rapportageperiode gewijzigd · analyse opnieuw starten';}});
706
  $('periodType').addEventListener('change',()=>{if(importedData){refreshPeriodChoices();importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Rapportagesoort gewijzigd · analyse opnieuw starten';}});
@@ -759,7 +858,8 @@ function getReportData(){
759
  return {site:site==='*'?'Alle locaties':site,period,months,byWeek,intensities,
760
  visits:uniqueDates.size,unique:result.unique,quarterClients:result.quarterClients,newIntake:result.newIntake,active:result.active,
761
  average:counts.size?uniqueDates.size/counts.size:0,median,maxDays:Math.max(0,...daysPerClient),
762
- earliest:period.begin,latest:period.peildatum,issues:result.passConflicts+result.identityConflicts+result.unmatchedPasses,hasShadow:false};
 
763
  }
764
  function buildWhiteReport(){
765
  const d=getReportData();if(!d)return;
@@ -771,7 +871,7 @@ function buildWhiteReport(){
771
  const summary=[`Tot en met de peildatum staan ${reportNL(d.unique)} unieke cliënten geregistreerd voor ${escapeHTML(d.site)} (binnen de beschikbare historie). In ${d.period.label} zijn ${reportNL(d.visits)} unieke bezoekdagen geregistreerd door ${reportNL(d.quarterClients)} cliënten.`,
772
  `De instroom in ${d.period.label} bedraagt ${reportNL(d.newIntake)} cliënten. Er zijn ${reportNL(d.active)} actieve cliënten met minimaal één scan van ${activeWindow}.`,
773
  `De bezoekintensiteit bedraagt gemiddeld ${avg} unieke dagen per bezoeker in de rapportageperiode (mediaan ${String(d.median).replace('.',',')}). Meeste bezoekdagen: ${busiest} (${reportNL(weekdayMax)}).`,
774
- `Instroom is gebaseerd op de eerste scan in de aangeleverde historie, niet op pasuitgifte. Eerdere bezoeken buiten het bestand kunnen ontbreken. Businesscontrollerverificatie ontbreekt.`];
775
  const kpis=[{klass:'purple',icon:'♟',label:'Unieke cliënten',value:reportNL(d.unique),sub:'Minimaal één scan ooit op deze locatie'},
776
  {klass:'green',icon:'+',label:'Nieuwe instroom',value:reportNL(d.newIntake),sub:'Eerste scan op locatie in '+d.period.label},
777
  {klass:'purple',icon:'♙',label:'Actieve cliënten',value:reportNL(d.active),sub:'Min. 1 scan in 3 maanden t/m peildatum'},
@@ -788,10 +888,10 @@ function buildWhiteReport(){
788
  <div class="report-definitions-title">Zo leest u de cliëntcijfers</div>
789
  <div class="report-definitions-grid">
790
  <div><b>Unieke cliënten</b>Iedere cliënt die op deze locatie ooit minimaal één keer heeft gescand (binnen de beschikbare historie).</div>
791
- <div><b>Nieuwe instroom</b>De eerste scan ooit van een cliënt op deze locatie valt in de rapportageperiode.</div>
792
  <div><b>Actieve cliënten</b>Minimaal één scan op deze locatie tussen ${escapeHTML(activeWindow)}.</div>
793
  </div>
794
- <div class="report-definitions-note">Een vervangende scanpas telt niet als nieuwe cliënt. Alle passen van één cliënt worden gekoppeld.</div>
795
  </section>
796
  <div class="report-chart-grid">
797
  <section class="report-panel"><h2>Instroom per maand <small>(nieuwe deelnemers)</small></h2><div class="chart-box">${reportChartBars(mon,newPerMonth,'#288e28')}</div></section>
@@ -803,7 +903,7 @@ function buildWhiteReport(){
803
  <section class="report-panel"><h2>Bezoekintensiteit <small>(unieke bezoekdagen per cliëntnummer)</small></h2>${itHTML}<div class="report-intensity-summary"><span>Gemiddeld<br><strong>${avg}</strong></span><span>Mediaan<br><strong>${String(d.median).replace('.',',')}</strong></span><span>Hoogste aantal dagen<br><strong>${d.maxDays}</strong></span></div></section>
804
  <section class="report-panel"><h2>Managementanalyse</h2><div class="report-analysis">${summary.map(t=>`<p>${t}</p>`).join('')}</div></section>
805
  </div>
806
- <div class="report-foot"><span>Bron: Lokaal ingelezen scanregistratie · Unieke cliënten uit historie t/m peildatum; bezoeken en instroom uit geselecteerde periode · Historie beperkt tot bestand</span><span class="report-provisional">NIET EXTERN GEVALIDEERD · CONTROLEER VÓÓR PUBLICATIE</span></div>`;
807
  }
808
  function openWhiteReport(){
809
  if(!importedData?.last){alert('Upload eerst een Excelbestand en rond de analyse af. Deze witte rapportage gebruikt geen verzonnen demo-KPI’s.');return;}
 
162
  /* v1.2: los, compact controlepaneel; bestaande ORCHESTRA en rapport-opmaak ongewijzigd */
163
  .controller-panel{padding:17px}.controller-note,.controller-footer{font-size:11px;line-height:1.55;color:#a8bad5;margin:6px 0 12px}.controller-context{border:1px solid #345075;background:#102642;border-radius:7px;padding:9px 12px;color:#b5d5f5;font-size:11px;margin-bottom:12px}
164
  .controller-entry{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.controller-entry label{display:flex;flex-direction:column;gap:7px;color:#b5c6dd;font-size:10px}.controller-entry input{width:100%;min-width:0;background:#0a1528;border:1px solid #365579;border-radius:7px;padding:10px;color:#f2f7ff;font-size:14px}.controller-entry input:focus{outline:1px solid #8e8bff}.controller-actions{display:flex;gap:9px;margin:12px 0}.controller-result{font-size:11px;color:#a9bfda}.controller-table{width:100%;border-collapse:collapse;margin-top:8px;font-size:11px}.controller-table th,.controller-table td{text-align:right;padding:9px 7px;border-bottom:1px solid #28415e}.controller-table th:first-child,.controller-table td:first-child{text-align:left}.controller-table th{color:#94b4dc;font-weight:600}.controller-ok{color:#57edb7;font-weight:700}.controller-bad{color:#ffb37b;font-weight:700}.controller-summary{padding:10px 12px;border-radius:7px;border:1px solid #5a5b79;background:#23243d;margin:10px 0}.controller-footer{margin-bottom:0}.controller-note strong{color:#eaf4ff}@media(max-width:800px){.controller-entry{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:460px){.controller-entry{grid-template-columns:1fr}}
165
+ /* v1.5: uitsluitend compacte toevoegingen voor historische invoer */
166
+ .history-status{grid-column:1/-1;color:#94b7d7;font-size:10px;line-height:1.4;border-top:1px solid #26415e;padding-top:9px}
167
+ .history-status.ready{color:#5ce5b4}
168
+ .history-status.warning{color:#ffc27f}
169
+ .history-label{border-color:#4c5581!important}
170
+ #historyFile{display:none}
171
  </style></head>
172
  <body>
173
  <div class="app">
174
  <main class="main">
175
  <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> LOKALE ANALYSE</span><span class="clock" id="clock"></span></div></header>
176
  <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>
177
+ <section class="import-panel panel" aria-label="Lokale Excelanalyse met 2026 en historische referentie 2025">
178
+ <div class="import-intro"><span class="section-kicker">V1.5 · HISTORISCHE INSTROOMCONTROLE</span><strong>Scanregistratie 2026 + referentie 2025</strong><small>Browser-lokale analyse · tien locaties · geen bestanden in Space-repository</small></div>
179
+ <label class="import-file-label" for="scanFile"><span>▤</span> Rapportage 2026 (.xlsx)</label><input type="file" id="scanFile" accept=".xlsx" aria-label="Rapportagebestand januari tot en met september 2026" />
180
+ <label class="import-file-label history-label" for="historyFile"><span>◴</span> Historie 2025 (.xlsx)</label><input type="file" id="historyFile" accept=".xlsx" aria-label="Historisch referentiebestand 2025" disabled/>
181
  <label class="site-label" for="siteSelect">Locatie</label><select id="siteSelect" disabled><option value="*">Alle locaties</option></select>
182
  <label class="site-label" for="periodType">Rapportageperiode</label><select id="periodType" disabled aria-label="Soort rapportage"><option value="quarter">Kwartaalrapportage</option><option value="half">Halfjaarrapportage</option><option value="nine">Negenmaandsrapportage</option></select><select id="periodSelect" disabled aria-label="Geselecteerde rapportageperiode"><option>—</option></select>
183
+ <span id="importState" class="import-state">Kies eerst het rapportagebestand 2026</span>
184
+ <div id="historyStatus" class="history-status">◴ Historie 2025 nog niet geladen. Zonder deze referentie zijn instroomcijfers voorlopig.</div>
185
  </section>
186
  <section class="orchestra panel">
187
  <div class="mesh"></div><div class="conductor-wrap"><div class="conductor" id="conductor"><div class="conductor-glow"></div><div class="conductor-icon" aria-hidden="true"><svg class="brain-symbol" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5c-2.7-3.2-7.4-.9-7.1 2.4C2 9 2 13.6 5 15.2c-.7 4.4 4.9 6.8 7 2.6M12 5c2.7-3.2 7.4-.9 7.1 2.4 2.9 1.6 2.9 6.2-.1 7.8.7 4.4-4.9 6.8-7 2.6M12 5v13"/><path d="M6.6 10.1 9 12v3M17.4 10.1 15 12v3"/><circle cx="9" cy="15.5" r=".9" fill="currentColor" stroke="none"/><circle cx="15" cy="15.5" r=".9" fill="currentColor" stroke="none"/></svg></div><div><small>MASTER ORCHESTRATOR</small><strong>THE CONDUCTOR</strong><span id="conductorStatus">Gereed voor analyse</span></div><div class="conductor-indicator"></div></div></div>
 
216
  <div class="controller-context" id="controllerContext">Analyseer eerst het fictieve Excel-bestand en kies een locatie en kwartaal.</div>
217
  <div class="controller-entry" id="controllerFields">
218
  <label>Unieke cliënten (historie)<input id="ctrlUnique" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 18"></label>
219
+ <label>Nieuwe instroom (periode)<input id="ctrlIntake" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 14"></label>
220
  <label>Actieve cliënten (3 maanden)<input id="ctrlActive" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 17"></label>
221
+ <label>Unieke bezoekdagen (periode)<input id="ctrlVisits" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 49"></label>
222
  </div>
223
  <div class="controller-actions"><button id="controllerCompare" class="primary" type="button">↔ Vergelijk cijfers</button><button id="controllerClear" class="ghost" type="button">Wissen</button></div>
224
  <div id="controllerResult" class="controller-result" aria-live="polite">Nog geen onafhankelijke controlecijfers vergeleken.</div>
 
229
  <aside class="right-content"><section class="panel chatpanel"><div class="panel-heading"><div><span class="section-kicker">MANAGEMENT ASSISTENT</span><h3><span class="chat-icon">✧</span> Vraag het aan Inloop AI</h3></div><span class="demo-badge">DEMO</span></div><div class="chat-messages" id="messages"><div class="message assistant"><small>INLOOP AI · DEMONSTRATIE</small><p>Welkom! Start de demo-analyse en stel daarna een vraag over de fictieve locatiecijfers.</p></div></div><div class="quick-questions"><button data-q="Welke locatie groeit het sterkst?">Grootste groei?</button><button data-q="Zijn de cijfers gevalideerd?">Validatiestatus?</button><button data-q="Maak een managementsamenvatting">Samenvatting?</button></div><form id="chatForm" class="chat-form"><input id="chatInput" autocomplete="off" placeholder="Stel een vraag over de demo..." aria-label="Vraag aan Inloop AI"><button aria-label="Verstuur vraag">➤</button></form><div class="chat-disclaimer">Vooraf ingestelde demo-antwoorden · geen echte AI</div></section>
230
  <section class="panel preview"><div class="panel-heading"><div><span class="section-kicker">OUTPUT</span><h3>Infographic preview</h3></div><span class="warn-label">CONCEPT</span></div><div class="paper"><div class="paper-top"><strong>REAKT<span>.</span></strong><small>INLOOPMONITOR / Q3 2026</small></div><h4>Inloop in beeld</h4><p>Illustratieve managementrapportage</p><div class="paper-kpis"><div><strong>12.480</strong><small>Bezoeken</small></div><div><strong>1.286</strong><small>Uniek</small></div><div><strong>164</strong><small>Instroom</small></div></div><div class="paper-bars"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div><div class="paper-watermark">FICTIEVE DATA · NIET GEVALIDEERD</div></div><button class="export" id="exportBtn">▣ Open witte Reakt-monitor</button></section></aside>
231
  </div>
232
+ <footer>REAKT INLOOP INTELLIGENCE <span>ORCHESTRA PROTOTYPE · LOKALE VERWERKING · CONTROLEER CIJFERS VÓÓR PUBLICATIE</span></footer>
233
  </main>
234
  </div>
235
  <div id="reportOverlay" role="dialog" aria-modal="true" aria-label="Reakt Monitor rapport" aria-hidden="true">
236
+ <div class="report-actions"><span><strong>REAKT · Rapportvoorvertoning v1.5</strong> &nbsp; — voorlopige managementrapportage, controle vereist</span><div><button id="reportClose" type="button">✕ Sluiten</button> <button id="reportPng" class="report-png" type="button">⬇ Download PNG</button> <button id="reportJpg" class="report-jpg" type="button">⬇ Download JPG</button> <button id="reportPrint" class="report-print" type="button">↓ Opslaan als PDF / Afdrukken</button></div></div>
237
  <section id="reportSheet" aria-label="Witte Inloop Monitor"></section>
238
  </div>
239
  <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
 
282
  if(agent==='data')return fake?['Demobron geladen','4 voorbeeldlocaties','Fictieve scanregels','Democontrole afgerond','Overdracht aan analyse']:[
283
  'Excel-bestand ingelezen',`${sites} locaties herkend`,`${n(rows)} scanregels bekeken`,`${n(repeat)} herhaalde scans gevonden`,result?.passChecked?`${n(result.multiPassClients)} cliënten met meerdere passen`:'Pascontrole niet beschikbaar'];
284
  if(agent==='analysis')return fake?['Voorbeeld-KPI’s geladen','Locaties vergeleken','Trends weergegeven','Groei samengevat','Overdracht aan inzicht']:[
285
+ 'Dubbele scans uitgesloten',`${n(unique)} unieke cliënten historisch`,`${n(result.newIntake)} instromers in periode`,`${n(result.active)} actieve cliënten`, 'Overdracht aan Insight Agent'];
286
  if(agent==='insight')return fake?['Voorbeeldtrend gelezen','Locatieverschillen bekeken','Fictieve inzichten verzameld','Conceptduiding opgesteld','Overdracht aan verificatie']:[
287
  'Maandgegevens beschikbaar','Locaties in grafiek gezet','Signalen zonder causaliteit','AI-duiding niet aangesloten','Overdracht aan controle'];
288
  if(agent==='verify')return fake?['Demovergelijking geopend','5 indicatoren bekeken','1 fictieve afwijking','Rapport niet gevalideerd','Overdracht met waarschuwing']:[
 
315
  // v0.4: veilige browser-lokale Excel-proef, uitsluitend met fictieve / geanonimiseerde voorbeelden.
316
  // Er vindt GEEN netwerkverzoek, bestandsoverdracht of externe AI-aanroep plaats.
317
  let importedData=null;
318
+ const excelLimit=25*1024*1024; // limiet per afzonderlijk XLSX-bestand
319
+ const REPORT_YEAR='2026';
320
+ const REPORT_SITES=['IJmond','Platinaweg','Purmerend','Rietdijk','Rozebottel','Soeda','Sterhuis','Voorburg','Vredehof','Zinkwerf'];
321
+ const siteCanonical=new Map(REPORT_SITES.map(s=>[s.toLocaleLowerCase('nl-NL'),s]));
322
+ function allowedSite(label){return siteCanonical.get(String(label||'').trim().toLocaleLowerCase('nl-NL'))||'';}
323
+ function historyKey(site,id){return JSON.stringify([site,id]);}
324
+ function setHistoryStatus(message,cls=''){const el=$('historyStatus');el.textContent=message;el.className='history-status '+cls;}
325
  const excelNS='http://schemas.openxmlformats.org/spreadsheetml/2006/main';
326
  const decoder=new TextDecoder('utf-8');
327
  function bytesText(a){return decoder.decode(a)}
 
435
  const lookup={};for(const [col,name] of Object.entries(first.value))lookup[String(name).toLowerCase().trim()]=col;
436
  const dateCol=pick(lookup,'scantijd','scandatum'),siteCol=pick(lookup,'locatie'),idCol=pick(lookup,'clientnummer','cliëntnummer'),passColumn=pick(lookup,'pasnummer','scanpasnummer','pas nummer','pasnr');
437
  if(!dateCol||!siteCol||(!idCol&&!passColumn))throw Error('Kolommen scantijd, locatie en cliëntnummer of pasnummer ontbreken');
438
+ const all=[],invalid={datum:0,locatie:0,client:0},unmatchedPasses=new Set(),identityConflicts=new Set();let linkedByPass=0,rowsWithPass=0,rawCount=0,ignoredSites=0,resultZero=0,resultZeroNoIdentity=0;
439
+ const outcomeColumn=pick(lookup,'resultaat');
440
  for(const r of it){rawCount++;
441
  if(rawCount>500000)throw Error('Meer dan 500.000 scans: importlimiet v1.3 bereikt; bestand opsplitsen');
442
+ const site=allowedSite(r[siteCol]),date=scanDate(r[dateCol]);
443
+ if(!site){ignoredSites++;continue;}
444
  const pass=passColumn?String(r[passColumn]??'').trim():'',directId=idCol?String(r[idCol]??'').trim():'';
445
  if(pass)rowsWithPass++;
446
  const resolved=pass&&!passConflicts.has(pass)?passToClient.get(pass):null;
 
448
  if(directId&&resolved&&directId!==resolved)identityConflicts.add(pass);
449
  if(!directId&&resolved)linkedByPass++;
450
  const id=directId||resolved||'';
451
+ if(outcomeColumn&&String(r[outcomeColumn]??'').trim()==='0'){resultZero++;if(!id)resultZeroNoIdentity++;}
452
+ if(!date)invalid.datum++;if(!id)invalid.client++;
453
+ if(!id||!date||date.slice(0,4)!==REPORT_YEAR)continue;
454
+ // Resultaat=0 is geen uitsluitcriterium. Onkoppelbare registraties worden niet als nieuwe cliënten verzonnen.
455
  all.push({date,site,id,pass});
456
  }
457
+ if(!all.length)throw Error('Geen bruikbare scans over 2026 op de tien afgesproken locaties; controleer het bestand en het jaartal');
458
  const sites=[...new Set(all.map(r=>r.site))].sort((a,b)=>a.localeCompare(b,'nl'));
459
  const peildatum=all.reduce((m,r)=>r.date>m?r.date:m,'');
460
  const activeIds=new Set(passDateRecords.filter(r=>r.start&&r.end&&r.start<=peildatum&&r.end>=peildatum).map(r=>r.client));
 
463
  return {all,sites,peildatum,pasDates,rawCount,invalid,hasPassColumn:!!passColumn,
464
  passSheetFound:!!passXML,passRows,passMissing,passToClient,clientToPasses,
465
  passConflicts:passConflicts.size,identityConflicts:identityConflicts.size,
466
+ unmatchedPasses:unmatchedPasses.size,linkedByPass,rowsWithPass,ignoredSites,resultZero,resultZeroNoIdentity,
467
+ historyFirst:new Map(),historyLoaded:false,historyInfo:null};
468
+ }
469
+ // v1.5: historische index met uitsluitend de eerste scan per cliënt/locatie.
470
+ // Lees GEEN inhoud van het pasregister van 2025 en bewaar geen individuele scanregels uit 2025.
471
+ async function readHistoricFirsts(file,onProgress=()=>{}){
472
+ if(!file.name.toLowerCase().endsWith('.xlsx'))throw Error('Kies een XLSX-bestand voor 2025');
473
+ if(file.size>excelLimit)throw Error('2025-bestand overschrijdt de limiet van 25 MB');
474
+ onProgress('Referentie 2025 openen ('+(file.size/1024/1024).toFixed(1)+' MB)…');
475
+ const zip=zipDirectory(await file.arrayBuffer());
476
+ const sharedXML=await xmlEntry(zip,'xl/sharedStrings.xml');
477
+ const shared=sharedXML?parseSharedLite(sharedXML):[];
478
+ onProgress('2025: scanregels controleren en eerste bezoekdatums verzamelen…');
479
+ await new Promise(resolve=>setTimeout(resolve,0));
480
+ const sheet=await xmlEntry(zip,'xl/worksheets/sheet1.xml');
481
+ if(!sheet)throw Error('Scanwerkblad 2025 ontbreekt');
482
+ const it=sheetRows(sheet,shared),first=it.next();
483
+ if(first.done)throw Error('Het scanwerkblad 2025 is leeg');
484
+ const cols={};for(const [col,v] of Object.entries(first.value))cols[String(v).toLowerCase().trim()]=col;
485
+ const dateCol=cols.scantijd||cols.scandatum,siteCol=cols.locatie,idCol=cols.clientnummer||cols['cliëntnummer'];
486
+ if(!dateCol||!siteCol||!idCol)throw Error('2025 mist Scantijd, Locatie of Clientnummer');
487
+ const firsts=new Map();let raw=0,valid=0,otherSites=0,missingIds=0,otherYears=0;
488
+ for(const r of it){raw++;if(raw>500000)throw Error('2025 bevat meer dan 500.000 scanregels');
489
+ const site=allowedSite(r[siteCol]);if(!site){otherSites++;continue;}
490
+ const date=scanDate(r[dateCol]);if(!date||date.slice(0,4)!=='2025'){otherYears++;continue;}
491
+ const id=String(r[idCol]??'').trim();if(!id){missingIds++;continue;}
492
+ valid++;const key=historyKey(site,id);
493
+ if(!firsts.has(key)||date<firsts.get(key))firsts.set(key,date);
494
+ }
495
+ if(!valid)throw Error('Geen bruikbare historische scans uit 2025 voor de tien locaties');
496
+ onProgress('Referentie 2025 gereed: '+valid.toLocaleString('nl-NL')+' bruikbare scanregels');
497
+ return {firsts,info:{raw,valid,otherSites,missingIds,otherYears,people:firsts.size}};
498
  }
499
+
500
  // v1.4 — kwartaal-, halfjaar- en negenmaandsrapportage; historie t/m peildatum.
501
  function quarterKey(date){return date.slice(0,4)+'-Q'+(Math.floor((Number(date.slice(5,7))-1)/3)+1);}
502
  function quarterDates(key){
 
536
  }
537
  function availablePeriods(type){
538
  const dataMonths=[...new Set(importedData.all.map(r=>r.date.slice(0,7)))].sort();
539
+ const years=[REPORT_YEAR];
540
  const keys=[];
541
  for(const year of years){
542
  const suffixes=type==='quarter'?['Q4','Q3','Q2','Q1']:type==='half'?['H2','H1']:['M9'];
 
573
  const quarterRows=historicRows.filter(r=>r.date>=period.begin&&r.date<=period.peildatum);
574
  const visitKeys=new Set(),clientKeys=new Set(),quarterClientKeys=new Set(),activeKeys=new Set(),monthSite=new Map();
575
  const instroomByMonth=new Map(period.months.map(m=>[m,0]));
576
+ const firstByClient=new Map(),firstWithoutReference=new Map();
577
+ // Historische bezoeken uit 2025 bepalen de vroegste geregistreerde scan,
578
+ // maar dragen NOOIT bij aan bezoeken, actieve cliënten of maandgrafieken over 2026.
579
+ if(importedData.historyLoaded){for(const [key,date] of importedData.historyFirst){
580
+ if(site==='*'||JSON.parse(key)[0]===site)firstByClient.set(key,date);
581
+ }}
582
  for(const r of historicRows){
583
+ const key=historyKey(r.site,r.id);clientKeys.add(key);
584
+ if(!firstWithoutReference.has(key)||r.date<firstWithoutReference.get(key))firstWithoutReference.set(key,r.date);
585
  if(!firstByClient.has(key)||r.date<firstByClient.get(key))firstByClient.set(key,r.date);
586
  if(r.date>=period.activeFrom)activeKeys.add(key);
587
  }
 
597
  visitKeys.add(key);
598
  const mk=r.site+'|'+r.date.slice(0,7);monthSite.set(mk,(monthSite.get(mk)||0)+1);
599
  }
600
+ const active=activeKeys.size,unique=firstByClient.size;
601
+ const priorCandidates=[...firstWithoutReference].filter(([key,date])=>date>=period.begin&&date<=period.peildatum);
602
+ const preHistoryIntake=priorCandidates.length;
603
+ const correctedExisting=priorCandidates.filter(([key])=>importedData.historyLoaded&&importedData.historyFirst.has(key)).length;
604
+ const correctedJanuary=priorCandidates.filter(([key,date])=>date.slice(0,7)==='2026-01'&&importedData.historyLoaded&&importedData.historyFirst.has(key)).length;
605
  const passtiedIds=new Set(historicRows.map(r=>r.id));
606
  const passByClient=new Map();let missingPass=0;
607
  for(const r of historicRows){if(r.pass){if(!passByClient.has(r.id))passByClient.set(r.id,new Set());passByClient.get(r.id).add(r.pass)}else missingPass++;}
 
611
  const locations=(site==='*'?importedData.sites:[site]).map(name=>({name,visits:period.months.map(m=>monthSite.get(name+'|'+m)||0)}));
612
  return {site,period,rows:quarterRows.length,historyRows:historicRows.length,visits:visitKeys.size,unique,
613
  quarterClients:quarterClientKeys.size,newIntake:instroom,active,firstByClient,instroomByMonth,
614
+ preHistoryIntake,correctedExisting,correctedJanuary,historyLoaded:importedData.historyLoaded,
615
+ historyPeople:importedData.historyLoaded?importedData.historyInfo.people:0,
616
  duplicates:quarterRows.length-visitKeys.size,months:period.months,locations,
617
  invalid:importedData.invalid,passChecked:importedData.hasPassColumn||importedData.passSheetFound,
618
  passSheetFound:importedData.passSheetFound,pasDates:importedData.pasDates,peildatum:period.peildatum,multiPassClients,
 
624
  function independentlyVerify(result){
625
  const {begin,peildatum,activeFrom,months}=result.period;
626
  const rows=importedData.all.filter(r=>(result.site==='*'||r.site===result.site)&&r.date<=peildatum);
627
+ const people=new Map(),visits=new Map(),monthFirst=new Map(months.map(m=>[m,0])),seen2026=new Map();
628
+ if(importedData.historyLoaded){for(const [key,date] of importedData.historyFirst){
629
+ if(result.site==='*'||JSON.parse(key)[0]===result.site)people.set(key,{first:date,recent:false,inQuarter:false});
630
+ }}
631
  for(const r of rows){
632
+ const key=historyKey(r.site,r.id);
633
+ if(!seen2026.has(key)||r.date<seen2026.get(key))seen2026.set(key,r.date);
634
  let p=people.get(key);
635
  if(!p){p={first:r.date,recent:false,inQuarter:false};people.set(key,p);}
636
  if(r.date<p.first)p.first=r.date;
 
650
  const inQuarterRows=[...visits.values()].reduce((s,n)=>s+n,0);
651
  const shadow={unique:people.size,newIntake:intake,active,visits:visits.size,quarterClients,
652
  duplicates:inQuarterRows-visits.size,monthNew:[...monthFirst.values()].reduce((a,b)=>a+b,0)};
653
+ const uncorrected=[...seen2026].filter(([key,date])=>date>=begin&&date<=peildatum);
654
+ const independentlyRejected=uncorrected.filter(([key])=>importedData.historyLoaded&&importedData.historyFirst.has(key)).length;
655
+ const janRejected=uncorrected.filter(([key,date])=>date.slice(0,7)==='2026-01'&&importedData.historyLoaded&&importedData.historyFirst.has(key)).length;
656
  const checks=[
657
  {label:'Unieke cliënten · tweede telling',ours:result.unique,shadow:shadow.unique},
658
  {label:'Nieuwe instroom · eerste scan',ours:result.newIntake,shadow:shadow.newIntake},
 
662
  {label:'Herhaalde scans in periode',ours:result.duplicates,shadow:shadow.duplicates},
663
  {label:'Maandinstroom versus periode-instroom',ours:result.newIntake,shadow:shadow.monthNew}
664
  ];
665
+ if(importedData.historyLoaded){
666
+ checks.push({label:'Correctie instroom door historie 2025',ours:result.correctedExisting,shadow:independentlyRejected});
667
+ checks.push({label:'Correctie januari door historie 2025',ours:result.correctedJanuary,shadow:janRejected});
668
+ }
669
  const mismatches=checks.filter(c=>c.ours!==c.shadow).length;
670
  const sourceIssues=[
671
  ['Tegenstrijdige pasregistraties',result.passConflicts],
 
697
  const num=n=>n.toLocaleString('nl-NL');
698
  const kpis=[{label:'Unieke bezoekdagen',value:num(result.visits),change:result.period.label+' · cliënt / dag / locatie'},
699
  {label:'Unieke cliënten',value:num(result.unique),change:'Ooit t/m peildatum · per locatie'},
700
+ {label:'Nieuwe instroom',value:num(result.newIntake),change:'Eerste scan in '+result.period.label+(result.historyLoaded?' · met historie 2025':' · voorlopig zonder 2025')},
701
  {label:'Actieve cliënten',value:num(result.active),change:'Laatste 3 maanden t/m peildatum'}];
702
  renderKPIs({kpis});drawImportedChart(result);
703
  const quality=result.verification||independentlyVerify(result);
 
710
  ['Bezoekers binnen periode',num(result.quarterClients)],
711
  ['Unieke bezoekdagen periode',num(result.visits)],
712
  ['Herhaalde scans periode',num(result.duplicates)],
713
+ ['Historisch referentiebestand 2025',result.historyLoaded?'INGELEZEN · '+num(result.historyPeople)+' cliënt/locatie-combinaties':'NIET AANGELEVERD · instroom voorlopig'],
714
+ ['Nieuwe instroom zonder 2025',num(result.preHistoryIntake)],
715
+ ['Herkenbaar als bestaande cliënt in 2025',result.historyLoaded?num(result.correctedExisting):'NIET TE BEPALEN'],
716
+ ['Waarvan januari 2026',result.historyLoaded?num(result.correctedJanuary):'NIET TE BEPALEN'],
717
+ ['Resultaat=0 (2026, telt mee bij geldige cliënt)',num(importedData.resultZero)],
718
+ ['Resultaat=0 zonder koppelbaar cliëntnummer',num(importedData.resultZeroNoIdentity)],
719
+ ['Andere locaties buiten afbakening',num(importedData.ignoredSites)],
720
  ['Scanpassen: apart werkblad',result.passSheetFound?num(result.passRows)+' registraties':'NIET AANWEZIG'],
721
  ['Cliënten met meerdere passen',result.passChecked?num(result.multiPassClients):'NIET TE CONTROLEREN'],
722
  ['Scanregels via pas gekoppeld (hele bestand)',num(result.linkedByPass)],
 
731
  const issue=quality.mismatches||quality.sourceIssues.length;
732
  document.querySelector('.verification .warning').textContent=quality.mismatches?`⚠ ${quality.mismatches} interne telverschillen gevonden. Cijfers nader onderzoeken. NIET GEVALIDEERD.`:
733
  quality.sourceIssues.length?`⚠ Tweede telling sluit aan, maar ${quality.sourceIssues.length} soorten bronproblemen gevonden. NIET GEVALIDEERD.`:
734
+ `✓ ${quality.checks.length} interne telcontroles sluiten aan. ${result.historyLoaded?'Historie 2025 meegenomen.':'2025 ontbreekt: instroom voorlopig.'} Businesscontrollercontrole optioneel; niet extern gevalideerd.`;
735
  document.querySelector('.paper-watermark').textContent='LOKALE EXCEL-ANALYSE · NIET EXTERN GEVALIDEERD';
736
  document.querySelector('.paper p').textContent=result.period.label+' · lokale Excel-proef · niet voor publicatie';
737
  const pk=document.querySelectorAll('.paper-kpis strong');[num(result.visits),num(result.unique),num(result.newIntake)].forEach((x,i)=>{if(pk[i])pk[i].textContent=x});
 
749
  {agent:'data',text:`${result.rows.toLocaleString('nl-NL')} scanregels gecontroleerd; ${result.duplicates} herhaalde scans; Scanpassen: ${result.passSheetFound?result.passRows+' registraties':'geen apart werkblad'}`},
750
  {agent:'analysis',text:`${result.unique} unieke cliënten ooit; ${result.newIntake} nieuwe instromers in ${result.period.label}; ${result.active} actieve cliënten`},
751
  {agent:'insight',text:'Maandtrends berekend; AI-duiding nog niet verbonden'},
752
+ {agent:'verify',text:`${result.verification.checks.length} onafhankelijke interne telcontroles; ${result.verification.mismatches} afwijkingen; ${result.correctedExisting} instromers herkend uit 2025. Extern niet gevalideerd`},
753
  {agent:'report',text:'Conceptpreview bijgewerkt · NIET GEVALIDEERD'}];
754
  try{
755
  resetControllerComparison(true);updateControllerContext();
 
762
  }catch(err){log('FOUT: '+err.message);$('flowState').textContent='Proefberekening mislukt';}finally{clearSignal();running=false;$('startBtn').disabled=false;$('resetBtn').disabled=false;$('conductor').classList.remove('running');document.querySelector('.orchestra').classList.remove('running')}
763
  }
764
  $('scanFile').addEventListener('change',async e=>{
765
+ importedData=null;$('historyFile').disabled=true;$('historyFile').value='';setHistoryStatus('◴ 2025 nog niet geladen. Instroom is voorlopig.');resetControllerComparison(true);updateControllerContext();const file=e.target.files?.[0];if(!file)return;
766
  const state=$('importState');state.textContent='Bestand wordt lokaal gelezen...';$('startBtn').disabled=true;
767
  try{
768
  const importStart=performance.now();
 
771
  const sel=$('siteSelect');sel.innerHTML='<option value="*">Alle locaties</option>';
772
  importedData.sites.forEach(loc=>{const opt=document.createElement('option');opt.value=loc;opt.textContent=loc;sel.appendChild(opt)});
773
  sel.disabled=false;
774
+ $('historyFile').disabled=false;
775
+ const type=$('periodType');
776
  type.disabled=false;
777
  type.value=availablePeriods('nine').length?'nine':'quarter';
778
  refreshPeriodChoices();
779
+ state.textContent=`2026: ${importedData.all.length.toLocaleString('nl-NL')} bruikbare scans · ${importedData.sites.length} van 10 locaties · ${importSeconds} sec · historie 2025 optioneel`;
780
+ setHistoryStatus('◴ Historie 2025 ontbreekt nog: upload het aparte referentiebestand om bestaande cliënten uit 2025 te herkennen.','warning');
781
  $('startBtn').textContent='▶ Analyseer Excelbestand';
782
  $('flowState').textContent='Excelbestand lokaal ingelezen · start de analyse';
783
  log('Excelbestand lokaal verwerkt; analyse moet nog starten. Voor echte data eerst interne toestemming controleren.');
784
  }catch(err){$('startBtn').textContent='▶ Start demo-analyse';state.textContent='⚠ '+err.message;$('siteSelect').disabled=true;$('periodSelect').disabled=true;$('periodType').disabled=true;log('Excel-proef: '+err.message)}finally{$('startBtn').disabled=false}
785
  });
786
+ $('historyFile').addEventListener('change',async e=>{
787
+ const file=e.target.files?.[0];if(!file||!importedData)return;
788
+ const state=$('importState');$('startBtn').disabled=true;
789
+ importedData.historyLoaded=false;importedData.historyFirst=new Map();importedData.historyInfo=null;importedData.last=null;
790
+ resetControllerComparison(true);updateControllerContext();
791
+ try{
792
+ const begun=performance.now();
793
+ const index=await readHistoricFirsts(file,msg=>setHistoryStatus(msg));
794
+ importedData.historyFirst=index.firsts;importedData.historyInfo=index.info;importedData.historyLoaded=true;
795
+ const duration=((performance.now()-begun)/1000).toFixed(1);
796
+ setHistoryStatus(`✓ Historie 2025 ingelezen: ${index.info.valid.toLocaleString('nl-NL')} scans · ${index.info.people.toLocaleString('nl-NL')} cliënt/locatie-combinaties · ${duration} sec. Start de analyse opnieuw.`,'ready');
797
+ state.textContent='2026 + historische referentie 2025 gereed · kies locatie/periode en start analyse';
798
+ $('flowState').textContent='2025 gekoppeld · analyse opnieuw starten';
799
+ log('Referentie 2025 gekoppeld; uitsluitend tien locaties; eerdere bezoeken kunnen instroom corrigeren.');
800
+ }catch(err){setHistoryStatus('⚠ Historie 2025 niet gekoppeld: '+err.message,'warning');$('historyFile').value='';log('Historische import: '+err.message)}
801
+ finally{$('startBtn').disabled=false;}
802
+ });
803
  $('siteSelect').addEventListener('change',()=>{if(importedData){importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Locatie gewijzigd · analyse opnieuw starten';}});
804
  $('periodSelect').addEventListener('change',()=>{if(importedData){importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Rapportageperiode gewijzigd · analyse opnieuw starten';}});
805
  $('periodType').addEventListener('change',()=>{if(importedData){refreshPeriodChoices();importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Rapportagesoort gewijzigd · analyse opnieuw starten';}});
 
858
  return {site:site==='*'?'Alle locaties':site,period,months,byWeek,intensities,
859
  visits:uniqueDates.size,unique:result.unique,quarterClients:result.quarterClients,newIntake:result.newIntake,active:result.active,
860
  average:counts.size?uniqueDates.size/counts.size:0,median,maxDays:Math.max(0,...daysPerClient),
861
+ earliest:period.begin,latest:period.peildatum,issues:result.passConflicts+result.identityConflicts+result.unmatchedPasses,hasShadow:false,
862
+ historyLoaded:result.historyLoaded,correctedExisting:result.correctedExisting,correctedJanuary:result.correctedJanuary};
863
  }
864
  function buildWhiteReport(){
865
  const d=getReportData();if(!d)return;
 
871
  const summary=[`Tot en met de peildatum staan ${reportNL(d.unique)} unieke cliënten geregistreerd voor ${escapeHTML(d.site)} (binnen de beschikbare historie). In ${d.period.label} zijn ${reportNL(d.visits)} unieke bezoekdagen geregistreerd door ${reportNL(d.quarterClients)} cliënten.`,
872
  `De instroom in ${d.period.label} bedraagt ${reportNL(d.newIntake)} cliënten. Er zijn ${reportNL(d.active)} actieve cliënten met minimaal één scan van ${activeWindow}.`,
873
  `De bezoekintensiteit bedraagt gemiddeld ${avg} unieke dagen per bezoeker in de rapportageperiode (mediaan ${String(d.median).replace('.',',')}). Meeste bezoekdagen: ${busiest} (${reportNL(weekdayMax)}).`,
874
+ `Instroom is gebaseerd op de eerste scan in ${d.historyLoaded?'2025 en 2026':'alleen 2026'}, niet op pasuitgifte. ${d.historyLoaded?reportNL(d.correctedExisting)+' aanvankelijke instromers bleken al in 2025 bezoeker.':'Historie 2025 ontbreekt; instroom voorlopig.'} Eerdere bezoeken vóór 2025 kunnen ontbreken. Businesscontrollerverificatie is optioneel.`];
875
  const kpis=[{klass:'purple',icon:'♟',label:'Unieke cliënten',value:reportNL(d.unique),sub:'Minimaal één scan ooit op deze locatie'},
876
  {klass:'green',icon:'+',label:'Nieuwe instroom',value:reportNL(d.newIntake),sub:'Eerste scan op locatie in '+d.period.label},
877
  {klass:'purple',icon:'♙',label:'Actieve cliënten',value:reportNL(d.active),sub:'Min. 1 scan in 3 maanden t/m peildatum'},
 
888
  <div class="report-definitions-title">Zo leest u de cliëntcijfers</div>
889
  <div class="report-definitions-grid">
890
  <div><b>Unieke cliënten</b>Iedere cliënt die op deze locatie ooit minimaal één keer heeft gescand (binnen de beschikbare historie).</div>
891
+ <div><b>Nieuwe instroom</b>De eerste geregistreerde scan van een cliënt op deze locatie (binnen de beschikbare historie) valt in de rapportageperiode.</div>
892
  <div><b>Actieve cliënten</b>Minimaal één scan op deze locatie tussen ${escapeHTML(activeWindow)}.</div>
893
  </div>
894
+ <div class="report-definitions-note">Een vervangende scanpas telt niet als nieuwe cliënt. Historie: ${d.historyLoaded?'2025 + 2026 gekoppeld; '+reportNL(d.correctedExisting)+' eerdere bezoekers niet opnieuw als instroom gerekend.':'alleen 2026 — instroom voorlopig.'}</div>
895
  </section>
896
  <div class="report-chart-grid">
897
  <section class="report-panel"><h2>Instroom per maand <small>(nieuwe deelnemers)</small></h2><div class="chart-box">${reportChartBars(mon,newPerMonth,'#288e28')}</div></section>
 
903
  <section class="report-panel"><h2>Bezoekintensiteit <small>(unieke bezoekdagen per cliëntnummer)</small></h2>${itHTML}<div class="report-intensity-summary"><span>Gemiddeld<br><strong>${avg}</strong></span><span>Mediaan<br><strong>${String(d.median).replace('.',',')}</strong></span><span>Hoogste aantal dagen<br><strong>${d.maxDays}</strong></span></div></section>
904
  <section class="report-panel"><h2>Managementanalyse</h2><div class="report-analysis">${summary.map(t=>`<p>${t}</p>`).join('')}</div></section>
905
  </div>
906
+ <div class="report-foot"><span>Bron: lokaal ingelezen scans 2026${d.historyLoaded?' + historisch referentiebestand 2025':''} · Instroom = eerste scan sinds ${d.historyLoaded?'2025':'2026'} · Resultaat=0 niet uitgesloten · Historie kan vóór 2025 onvolledig zijn</span><span class="report-provisional">NIET EXTERN GEVALIDEERD · CONTROLEER VÓÓR PUBLICATIE</span></div>`;
907
  }
908
  function openWhiteReport(){
909
  if(!importedData?.last){alert('Upload eerst een Excelbestand en rond de analyse af. Deze witte rapportage gebruikt geen verzonnen demo-KPI’s.');return;}