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

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +66 -31
index.html CHANGED
@@ -27,7 +27,7 @@
27
  .import-file-label{padding:10px 14px;border:1px solid #3666a4;border-radius:9px;background:#162c49;color:#dcf1ff;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap}
28
  .import-file-label span{color:#53d9bf;font-size:16px;margin-right:7px}
29
  #scanFile{display:none}.site-label{font-size:11px;color:#a6bad8}#siteSelect{background:#132947;border:1px solid #325172;border-radius:8px;color:#eaf2ff;max-width:185px;padding:9px;font:inherit;font-size:12px}
30
- #siteSelect:disabled{opacity:.55}#periodSelect{background:#132947;border:1px solid #325172;border-radius:8px;color:#eaf2ff;padding:9px;font:inherit;font-size:12px;max-width:138px}#periodSelect:disabled{opacity:.55}.import-state{color:#78e0c3;font-size:11px;max-width:210px;line-height:1.4}
31
  @media(max-width:760px){.import-panel{align-items:start}.import-intro{flex-basis:100%}.import-state{flex-basis:100%;max-width:none}}
32
 
33
  /* v0.5: agent-workflow-inzicht, zonder de goedgekeurde basisvormgeving te wijzigen */
@@ -166,13 +166,13 @@
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> 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>
177
  </section>
178
  <section class="orchestra panel">
@@ -246,7 +246,7 @@ function clock(){ $('clock').textContent=new Intl.DateTimeFormat('nl-NL',{hour:'
246
  clock();setInterval(clock,60000);
247
  function renderKPIs(data){$('kpis').innerHTML=data.kpis.map(k=>`<div class="kpi"><small>${escapeHTML(k.label)}</small><strong>${escapeHTML(k.value)}</strong><span>${escapeHTML(k.change)}</span></div>`).join('');}
248
  function renderChecks(data){$('checks').innerHTML=data.checks.map(c=>{const delta=c.ours-c.shadow;return `<div class="check-row"><span>${escapeHTML(c.label)}</span><span>${c.ours.toLocaleString('nl-NL')} / ${c.shadow.toLocaleString('nl-NL')} &nbsp; <strong class="${delta?'bad':''}">${delta?'⚠ '+(delta>0?'+':'')+delta:'✓ 0'}</strong></span></div>`}).join('');}
249
- function drawChart(data){const canvas=$('chart'),ctx=canvas.getContext('2d'),w=640,h=250;ctx.clearRect(0,0,w,h);ctx.font='13px Segoe UI';ctx.textAlign='left';const max=500;for(let i=0;i<=4;i++){let y=25+i*45;ctx.strokeStyle='#29405a';ctx.beginPath();ctx.moveTo(44,y);ctx.lineTo(w-15,y);ctx.stroke();ctx.fillStyle='#8298b4';ctx.fillText(String(Math.round(max*(4-i)/4)),5,y+4);}const cols=['#3ee1b6','#ffc66a','#5b9cff','#ad88ff'];data.locations.forEach((loc,index)=>{ctx.strokeStyle=cols[index];ctx.lineWidth=3;ctx.beginPath();loc.visits.forEach((v,j)=>{const x=90+j*235,y=205-(v/max)*180;if(j===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)});ctx.stroke();loc.visits.forEach((v,j)=>{const x=90+j*235,y=205-(v/max)*180;ctx.fillStyle=cols[index];ctx.beginPath();ctx.arc(x,y,4,0,Math.PI*2);ctx.fill()})});ctx.textAlign='center';ctx.fillStyle='#a6b9d0';['JUL','AUG','SEP'].forEach((m,i)=>ctx.fillText(m,90+i*235,239));}
250
  function log(text){const now=new Date().toLocaleTimeString('nl-NL',{hour:'2-digit',minute:'2-digit',second:'2-digit'});if($('logs').querySelector('.muted'))$('logs').innerHTML='';$('logs').insertAdjacentHTML('afterbegin',`<div class="logitem"><time>${now}</time>${escapeHTML(text)}</div>`)}
251
  // De lichtpunten volgen de vijf bestaande SVG-verbindingen. Ze lopen alleen tijdens een actieve processtap.
252
  const signalPaths={data:'M500 0 V30 H100 V90',analysis:'M500 0 V30 H300 V90',insight:'M500 0 V90',verify:'M500 0 V30 H700 V90',report:'M500 0 V30 H900 V90'};
@@ -447,7 +447,7 @@ async function readExcelLocal(file,onProgress=()=>{}){
447
  passConflicts:passConflicts.size,identityConflicts:identityConflicts.size,
448
  unmatchedPasses:unmatchedPasses.size,linkedByPass,rowsWithPass};
449
  }
450
- // v1.1 — rapportagekwartaal; voor historische definitie tellen alleen scans 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){
453
  const m=String(key).match(/^(\d{4})-Q([1-4])$/);if(!m)throw Error('Kies een geldig rapportagekwartaal');
@@ -472,12 +472,46 @@ function firstScansBySite(all){
472
  for(const r of all){const key=JSON.stringify([r.site,r.id]);if(!first.has(key)||r.date<first.get(key))first.set(key,r.date);}
473
  return first;
474
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  function periodContext(key){
476
- const p=quarterDates(key),lastSeen=importedData.all.reduce((max,r)=>r.date>max?r.date:max,'');
477
- const mostRecent=quarterKey(lastSeen)===key;
478
- const latestInQuarter=importedData.all.filter(r=>quarterKey(r.date)===key).reduce((m,r)=>r.date>m?r.date:m,'');
479
- // Nog lopend kwartaal krijgt de laatste geregistreerde dag als peildatum.
480
- const peildatum=mostRecent?latestInQuarter:p.end;
 
481
  return {...p,peildatum,activeFrom:precedingThreeMonths(peildatum)};
482
  }
483
  function calculateImported(site='*'){
@@ -555,9 +589,9 @@ function independentlyVerify(result){
555
  {label:'Nieuwe instroom · eerste scan',ours:result.newIntake,shadow:shadow.newIntake},
556
  {label:'Actieve cliënten · laatste 3 maanden',ours:result.active,shadow:shadow.active},
557
  {label:'Unieke bezoekdagen · cliënt/locatie/dag',ours:result.visits,shadow:shadow.visits},
558
- {label:'Bezoekers rapportagekwartaal',ours:result.quarterClients,shadow:shadow.quarterClients},
559
- {label:'Herhaalde scans in kwartaal',ours:result.duplicates,shadow:shadow.duplicates},
560
- {label:'Maandinstroom versus kwartaalinstroom',ours:result.newIntake,shadow:shadow.monthNew}
561
  ];
562
  const mismatches=checks.filter(c=>c.ours!==c.shadow).length;
563
  const sourceIssues=[
@@ -578,10 +612,10 @@ function drawImportedChart(result){
578
  const colors=['#3ee1b6','#ffc66a','#5b9cff','#ad88ff'];
579
  series.forEach((loc,li)=>{
580
  ctx.strokeStyle=colors[li];ctx.lineWidth=3;ctx.beginPath();
581
- loc.visits.forEach((v,j)=>{const x=90+j*235,y=205-(v/max)*180;j===0?ctx.moveTo(x,y):ctx.lineTo(x,y)});ctx.stroke();
582
- loc.visits.forEach((v,j)=>{const x=90+j*235,y=205-(v/max)*180;ctx.fillStyle=colors[li];ctx.beginPath();ctx.arc(x,y,4,0,Math.PI*2);ctx.fill()});
583
  });ctx.fillStyle='#a6b9d0';ctx.textAlign='center';
584
- result.months.forEach((m,i)=>ctx.fillText(m,90+i*235,239));
585
  const legend=document.querySelector('.legend');
586
  legend.innerHTML=series.map((loc,i)=>`<span><i style="background:${colors[i]}"></i> ${escapeHTML(loc.name)}</span>`).join('');
587
  document.querySelector('.chart-panel .subtle').textContent=result.months.join(' · ')+(result.locations.length>4?' · 4 van '+result.locations.length+' locaties':'');
@@ -600,9 +634,9 @@ function renderImported(result){
600
  ['Cliënten ooit (per locatie)',num(result.unique)],
601
  ['Nieuwe instroom (per locatie)',num(result.newIntake)],
602
  ['Actieve cliënten (per locatie)',num(result.active)],
603
- ['Bezoekers binnen kwartaal',num(result.quarterClients)],
604
- ['Unieke bezoekdagen kwartaal',num(result.visits)],
605
- ['Herhaalde scans kwartaal',num(result.duplicates)],
606
  ['Scanpassen: apart werkblad',result.passSheetFound?num(result.passRows)+' registraties':'NIET AANWEZIG'],
607
  ['Cliënten met meerdere passen',result.passChecked?num(result.multiPassClients):'NIET TE CONTROLEREN'],
608
  ['Scanregels via pas gekoppeld (hele bestand)',num(result.linkedByPass)],
@@ -618,7 +652,7 @@ function renderImported(result){
618
  document.querySelector('.verification .warning').textContent=quality.mismatches?`⚠ ${quality.mismatches} interne telverschillen gevonden. Cijfers nader onderzoeken. NIET GEVALIDEERD.`:
619
  quality.sourceIssues.length?`⚠ Tweede telling sluit aan, maar ${quality.sourceIssues.length} soorten bronproblemen gevonden. NIET GEVALIDEERD.`:
620
  '✓ 7 interne telcontroles sluiten aan. Let op: volledigheid van historische scans onbekend; externe businesscontrollercontrole ontbreekt. NIET EXTERN GEVALIDEERD.';
621
- document.querySelector('.paper-watermark').textContent='FICTIEVE TESTGEGEVENS · NIET GEVALIDEERD';
622
  document.querySelector('.paper p').textContent=result.period.label+' · lokale Excel-proef · niet voor publicatie';
623
  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});
624
  const pl=document.querySelectorAll('.paper-kpis small');if(pl[2])pl[2].textContent='Eerste scan in '+result.period.label;
@@ -657,18 +691,19 @@ $('scanFile').addEventListener('change',async e=>{
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;
660
- const periods=[...new Set(importedData.all.map(r=>quarterKey(r.date)))].sort().reverse();
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';}});
671
- $('periodSelect').addEventListener('change',()=>{if(importedData){importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Kwartaal gewijzigd · analyse opnieuw starten';}});
 
672
  // Bestaande chat blijft demo-gericht. Voor echte Excel-proef antwoorden we uitsluitend met controleerbare berekende cijfers.
673
  const originalReply=reply;
674
  reply=function(q){
@@ -676,7 +711,7 @@ reply=function(q){
676
  const r=importedData.last;
677
  if(!r)return 'Klik op Start demo-analyse om de lokaal ingelezen testgegevens te berekenen.';
678
  if(/valid|controle|wouter|business|betrouw|verschil|akkoord/.test(q.toLowerCase()))return 'Deze Excel-proef is NIET extern gevalideerd. De schaduwberekening van de businesscontroller ontbreekt en de rekenregels moeten nog worden afgestemd.';
679
- if(/bezoek|uniek|scan|groei|locatie|samenvat|rapport|analyse|kwartaal|cijfer|instroom|actief/.test(q.toLowerCase()))return `Fictieve proef (${r.site==='*'?'som locaties':r.site}, ${r.period.label}, t/m ${readableDate(r.peildatum)}): ${r.unique} unieke cliënten in de beschikbare historie, ${r.newIntake} nieuwe instromers, ${r.active} actieve cliënten in de afgelopen drie maanden en ${r.visits} unieke bezoekdagen in het kwartaal. ${r.duplicates} herhaalde scans zijn uitgesloten. Deze cijfers zijn nog niet extern gevalideerd en hangen af van een volledige beschikbare scanhistorie.`;
680
  return 'De AI-API is nog niet gekoppeld. Ik kan voorlopig alleen de gecontroleerde basisgetallen van het fictieve Excel-testbestand toelichten.';
681
  };
682
 
@@ -768,10 +803,10 @@ function buildWhiteReport(){
768
  <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>
769
  <section class="report-panel"><h2>Managementanalyse</h2><div class="report-analysis">${summary.map(t=>`<p>${t}</p>`).join('')}</div></section>
770
  </div>
771
- <div class="report-foot"><span>Bron: Fictieve scanregistratie · Unieke cliënten uit historie t/m peildatum; bezoeken en instroom uit rapportagekwartaal · Historie beperkt tot bestand</span><span class="report-provisional">FICTIEVE TESTGEGEVENS · NIET GEVALIDEERD · NIET PUBLICEREN</span></div>`;
772
  }
773
  function openWhiteReport(){
774
- if(!importedData?.last){alert('Upload eerst het fictieve Excel-testbestand en rond de analyse af. Deze witte rapportage gebruikt geen verzonnen demo-KPI’s.');return;}
775
  if(importedData.last.site==='*'){alert('Kies eerst één locatie en voer daarna de analyse opnieuw uit. De witte monitor is in v0.8 een rapport per locatie.');return;}
776
  buildWhiteReport();$('reportOverlay').classList.add('open');$('reportOverlay').setAttribute('aria-hidden','false');document.body.classList.add('report-open');$('reportOverlay').scrollTop=0;
777
  }
@@ -831,7 +866,7 @@ function resetControllerComparison(clearInputs=false){
831
  }
832
  function updateControllerContext(){
833
  const r=importedData?.last;
834
- $('controllerContext').textContent=r?`ORCHESTRA: ${r.site==='*'?'Alle locaties (som van locaties)':r.site} · ${r.period.label} · peildatum ${readableDate(r.period.peildatum)}. Gebruik precies deze selectie voor de controle.`:'Analyseer eerst het fictieve Excel-bestand en kies een locatie en kwartaal.';
835
  }
836
  function compareController(){
837
  const r=importedData?.last;
 
27
  .import-file-label{padding:10px 14px;border:1px solid #3666a4;border-radius:9px;background:#162c49;color:#dcf1ff;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap}
28
  .import-file-label span{color:#53d9bf;font-size:16px;margin-right:7px}
29
  #scanFile{display:none}.site-label{font-size:11px;color:#a6bad8}#siteSelect{background:#132947;border:1px solid #325172;border-radius:8px;color:#eaf2ff;max-width:185px;padding:9px;font:inherit;font-size:12px}
30
+ #siteSelect:disabled{opacity:.55}#periodType,#periodSelect{background:#132947;border:1px solid #325172;border-radius:8px;color:#eaf2ff;padding:9px;font:inherit;font-size:12px;max-width:138px}#periodType:disabled,#periodSelect:disabled{opacity:.55}.import-state{color:#78e0c3;font-size:11px;max-width:210px;line-height:1.4}
31
  @media(max-width:760px){.import-panel{align-items:start}.import-intro{flex-basis:100%}.import-state{flex-basis:100%;max-width:none}}
32
 
33
  /* v0.5: agent-workflow-inzicht, zonder de goedgekeurde basisvormgeving te wijzigen */
 
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">
 
246
  clock();setInterval(clock,60000);
247
  function renderKPIs(data){$('kpis').innerHTML=data.kpis.map(k=>`<div class="kpi"><small>${escapeHTML(k.label)}</small><strong>${escapeHTML(k.value)}</strong><span>${escapeHTML(k.change)}</span></div>`).join('');}
248
  function renderChecks(data){$('checks').innerHTML=data.checks.map(c=>{const delta=c.ours-c.shadow;return `<div class="check-row"><span>${escapeHTML(c.label)}</span><span>${c.ours.toLocaleString('nl-NL')} / ${c.shadow.toLocaleString('nl-NL')} &nbsp; <strong class="${delta?'bad':''}">${delta?'⚠ '+(delta>0?'+':'')+delta:'✓ 0'}</strong></span></div>`}).join('');}
249
+ function drawChart(data){const canvas=$('chart'),ctx=canvas.getContext('2d'),w=640,h=250;ctx.clearRect(0,0,w,h);ctx.font='13px Segoe UI';ctx.textAlign='left';const max=500;for(let i=0;i<=4;i++){let y=25+i*45;ctx.strokeStyle='#29405a';ctx.beginPath();ctx.moveTo(44,y);ctx.lineTo(w-15,y);ctx.stroke();ctx.fillStyle='#8298b4';ctx.fillText(String(Math.round(max*(4-i)/4)),5,y+4);}const cols=['#3ee1b6','#ffc66a','#5b9cff','#ad88ff'];data.locations.forEach((loc,index)=>{ctx.strokeStyle=cols[index];ctx.lineWidth=3;ctx.beginPath();loc.visits.forEach((v,j)=>{const x=72+(result.months.length===1?0:j*(w-105)/(result.months.length-1)),y=205-(v/max)*180;if(j===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)});ctx.stroke();loc.visits.forEach((v,j)=>{const x=72+(result.months.length===1?0:j*(w-105)/(result.months.length-1)),y=205-(v/max)*180;ctx.fillStyle=cols[index];ctx.beginPath();ctx.arc(x,y,4,0,Math.PI*2);ctx.fill()})});ctx.textAlign='center';ctx.fillStyle='#a6b9d0';['JUL','AUG','SEP'].forEach((m,i)=>ctx.fillText(m,72+(result.months.length===1?0:i*(w-105)/(result.months.length-1)),239));}
250
  function log(text){const now=new Date().toLocaleTimeString('nl-NL',{hour:'2-digit',minute:'2-digit',second:'2-digit'});if($('logs').querySelector('.muted'))$('logs').innerHTML='';$('logs').insertAdjacentHTML('afterbegin',`<div class="logitem"><time>${now}</time>${escapeHTML(text)}</div>`)}
251
  // De lichtpunten volgen de vijf bestaande SVG-verbindingen. Ze lopen alleen tijdens een actieve processtap.
252
  const signalPaths={data:'M500 0 V30 H100 V90',analysis:'M500 0 V30 H300 V90',insight:'M500 0 V90',verify:'M500 0 V30 H700 V90',report:'M500 0 V30 H900 V90'};
 
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){
453
  const m=String(key).match(/^(\d{4})-Q([1-4])$/);if(!m)throw Error('Kies een geldig rapportagekwartaal');
 
472
  for(const r of all){const key=JSON.stringify([r.site,r.id]);if(!first.has(key)||r.date<first.get(key))first.set(key,r.date);}
473
  return first;
474
  }
475
+ function reportPeriodDates(key){
476
+ const match=String(key).match(/^(\d{4})-(Q[1-4]|H[12]|M9)$/);
477
+ if(!match)throw Error('Ongeldige rapportageperiode');
478
+ const year=Number(match[1]),part=match[2];
479
+ const first=part.startsWith('Q')?(Number(part[1])-1)*3+1:part==='H2'?7:1;
480
+ const count=part.startsWith('Q')?3:part.startsWith('H')?6:9;
481
+ const begin=`${year}-${String(first).padStart(2,'0')}-01`;
482
+ const end=new Date(Date.UTC(year,first+count-1,0)).toISOString().slice(0,10);
483
+ const months=Array.from({length:count},(_,i)=>`${year}-${String(first+i).padStart(2,'0')}`);
484
+ const label=part.startsWith('Q')?`Q${part[1]} ${year}`:part.startsWith('H')?`H${part[1]} ${year}`:`Januari t/m september ${year}`;
485
+ return {key,begin,end,months,label,type:part.startsWith('Q')?'quarter':part.startsWith('H')?'half':'nine'};
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'];
493
+ for(const suffix of suffixes){
494
+ const key=year+'-'+suffix,p=reportPeriodDates(key);
495
+ if(dataMonths.some(month=>month>=p.months[0]&&month<=p.months.at(-1)))keys.push(key);
496
+ }
497
+ }
498
+ return keys;
499
+ }
500
+ function refreshPeriodChoices(prefer){
501
+ const select=$('periodSelect'),keys=availablePeriods($('periodType').value);
502
+ select.replaceChildren();
503
+ keys.forEach(key=>{const opt=document.createElement('option');opt.value=key;opt.textContent=reportPeriodDates(key).label;select.appendChild(opt)});
504
+ if(prefer&&keys.includes(prefer))select.value=prefer;
505
+ select.disabled=!keys.length;
506
+ if(!keys.length){const opt=document.createElement('option');opt.textContent='Geen perioden beschikbaar';select.appendChild(opt)}
507
+ }
508
  function periodContext(key){
509
+ const p=reportPeriodDates(key);
510
+ const seen=importedData.all.reduce((max,r)=>r.date>max?r.date:max,'');
511
+ // Onvolledig nieuwste tijdvak: laatste beschikbare scandag is de peildatum.
512
+ const latestInPeriod=importedData.all.reduce((max,r)=>r.date>=p.begin&&r.date<=p.end&&r.date>max?r.date:max,'');
513
+ const peildatum=seen<p.end?latestInPeriod:p.end;
514
+ if(!peildatum)throw Error('Geen scans in de geselecteerde rapportageperiode');
515
  return {...p,peildatum,activeFrom:precedingThreeMonths(peildatum)};
516
  }
517
  function calculateImported(site='*'){
 
589
  {label:'Nieuwe instroom · eerste scan',ours:result.newIntake,shadow:shadow.newIntake},
590
  {label:'Actieve cliënten · laatste 3 maanden',ours:result.active,shadow:shadow.active},
591
  {label:'Unieke bezoekdagen · cliënt/locatie/dag',ours:result.visits,shadow:shadow.visits},
592
+ {label:'Bezoekers rapportageperiode',ours:result.quarterClients,shadow:shadow.quarterClients},
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=[
 
612
  const colors=['#3ee1b6','#ffc66a','#5b9cff','#ad88ff'];
613
  series.forEach((loc,li)=>{
614
  ctx.strokeStyle=colors[li];ctx.lineWidth=3;ctx.beginPath();
615
+ loc.visits.forEach((v,j)=>{const x=72+(result.months.length===1?0:j*(w-105)/(result.months.length-1)),y=205-(v/max)*180;j===0?ctx.moveTo(x,y):ctx.lineTo(x,y)});ctx.stroke();
616
+ loc.visits.forEach((v,j)=>{const x=72+(result.months.length===1?0:j*(w-105)/(result.months.length-1)),y=205-(v/max)*180;ctx.fillStyle=colors[li];ctx.beginPath();ctx.arc(x,y,4,0,Math.PI*2);ctx.fill()});
617
  });ctx.fillStyle='#a6b9d0';ctx.textAlign='center';
618
+ result.months.forEach((m,i)=>ctx.fillText(m,72+(result.months.length===1?0:i*(w-105)/(result.months.length-1)),239));
619
  const legend=document.querySelector('.legend');
620
  legend.innerHTML=series.map((loc,i)=>`<span><i style="background:${colors[i]}"></i> ${escapeHTML(loc.name)}</span>`).join('');
621
  document.querySelector('.chart-panel .subtle').textContent=result.months.join(' · ')+(result.locations.length>4?' · 4 van '+result.locations.length+' locaties':'');
 
634
  ['Cliënten ooit (per locatie)',num(result.unique)],
635
  ['Nieuwe instroom (per locatie)',num(result.newIntake)],
636
  ['Actieve cliënten (per locatie)',num(result.active)],
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)],
 
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});
658
  const pl=document.querySelectorAll('.paper-kpis small');if(pl[2])pl[2].textContent='Eerste scan in '+result.period.label;
 
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';}});
707
  // Bestaande chat blijft demo-gericht. Voor echte Excel-proef antwoorden we uitsluitend met controleerbare berekende cijfers.
708
  const originalReply=reply;
709
  reply=function(q){
 
711
  const r=importedData.last;
712
  if(!r)return 'Klik op Start demo-analyse om de lokaal ingelezen testgegevens te berekenen.';
713
  if(/valid|controle|wouter|business|betrouw|verschil|akkoord/.test(q.toLowerCase()))return 'Deze Excel-proef is NIET extern gevalideerd. De schaduwberekening van de businesscontroller ontbreekt en de rekenregels moeten nog worden afgestemd.';
714
+ if(/bezoek|uniek|scan|groei|locatie|samenvat|rapport|analyse|kwartaal|cijfer|instroom|actief/.test(q.toLowerCase()))return `Lokale Excel-analyse (${r.site==='*'?'som locaties':r.site}, ${r.period.label}, t/m ${readableDate(r.peildatum)}): ${r.unique} unieke cliënten in de beschikbare historie, ${r.newIntake} nieuwe instromers, ${r.active} actieve cliënten in de afgelopen drie maanden en ${r.visits} unieke bezoekdagen in de geselecteerde periode. ${r.duplicates} herhaalde scans zijn uitgesloten. Deze cijfers zijn nog niet extern gevalideerd en hangen af van een volledige beschikbare scanhistorie.`;
715
  return 'De AI-API is nog niet gekoppeld. Ik kan voorlopig alleen de gecontroleerde basisgetallen van het fictieve Excel-testbestand toelichten.';
716
  };
717
 
 
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;}
810
  if(importedData.last.site==='*'){alert('Kies eerst één locatie en voer daarna de analyse opnieuw uit. De witte monitor is in v0.8 een rapport per locatie.');return;}
811
  buildWhiteReport();$('reportOverlay').classList.add('open');$('reportOverlay').setAttribute('aria-hidden','false');document.body.classList.add('report-open');$('reportOverlay').scrollTop=0;
812
  }
 
866
  }
867
  function updateControllerContext(){
868
  const r=importedData?.last;
869
+ $('controllerContext').textContent=r?`ORCHESTRA: ${r.site==='*'?'Alle locaties (som van locaties)':r.site} · ${r.period.label} · peildatum ${readableDate(r.period.peildatum)}. Gebruik precies deze selectie voor de controle.`:'Analyseer eerst het Excelbestand en kies een locatie en rapportageperiode.';
870
  }
871
  function compareController(){
872
  const r=importedData?.last;