Upload index.html
Browse files- 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="
|
| 172 |
-
<div class="import-intro"><span class="section-kicker">V1.
|
| 173 |
-
<label class="import-file-label" for="scanFile"><span>▤</span>
|
|
|
|
| 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">
|
|
|
|
| 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 (
|
| 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 (
|
| 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 ·
|
| 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.
|
| 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
|
| 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; //
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
| 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(
|
| 436 |
-
if(!
|
|
|
|
|
|
|
| 437 |
all.push({date,site,id,pass});
|
| 438 |
}
|
| 439 |
-
if(!all.length)throw Error('Geen bruikbare
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=[
|
| 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=
|
|
|
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
| 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 |
-
|
| 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.
|
| 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 |
-
|
|
|
|
| 695 |
type.disabled=false;
|
| 696 |
type.value=availablePeriods('nine').length?'nine':'quarter';
|
| 697 |
refreshPeriodChoices();
|
| 698 |
-
state.textContent=`${importedData.
|
|
|
|
| 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
|
| 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
|
| 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.
|
| 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:
|
| 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> — 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;}
|