Download index.html from Marcel0123/Reakt-Inloop-Intelligence-II: direct link, hf CLI and curl.
- Browser
- Download file 201 kB
-
https://huggingface.co/spaces/Marcel0123/Reakt-Inloop-Intelligence-II/resolve/main/index.html
- Command line
-
hf download hf://spaces/Marcel0123/Reakt-Inloop-Intelligence-II/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Marcel0123/Reakt-Inloop-Intelligence-II/resolve/main/index.html
201 kB
| <html lang="nl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Reakt | ORCHESTRA</title><style> | |
| :root{font-family:Inter,'Segoe UI',Arial,sans-serif;color:#eaf2ff;background:#060d1d;font-synthesis:none}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 50% -15%,#162d58 0%,#081224 43%,#060b18 100%);min-height:100vh}button,input{font:inherit}button{cursor:pointer}button:disabled{opacity:.5;cursor:wait}.app{display:flex;min-height:100vh}.sidebar{width:225px;flex:none;border-right:1px solid #223351;background:#091326;padding:25px 16px;position:sticky;top:0;height:100vh}.brand{display:flex;align-items:center;gap:12px;margin-bottom:55px}.brand-icon{font-size:34px;color:#54d9ff;text-shadow:0 0 20px #38baff}.brand strong{font-size:24px;letter-spacing:1.5px}.brand-dot,.paper-top span{color:#58d8f5}.brand small{display:block;font-size:9px;letter-spacing:1.8px;color:#7791b5;margin-top:3px}.nav-label{font-size:10px;letter-spacing:1.5px;color:#607a9e;margin:0 8px 15px}.nav-label.second{margin-top:42px}.nav{display:flex;gap:13px;align-items:center;padding:13px 12px;color:#8fa6c7;font-size:13px;border-radius:9px;margin-bottom:5px}.nav.active{color:#c5ecff;background:linear-gradient(90deg,#153d68,#102039);border:1px solid #24517c;box-shadow:inset 3px 0 #4fc9ff}.side-note{border:1px solid #244568;border-radius:10px;background:#0d2137;padding:15px;color:#c5e2f7;font-size:12px;line-height:2}.side-note small{color:#839dbb}.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#8194ad;margin-right:7px}.dot.green{background:#32e3aa;box-shadow:0 0 10px #32e3aa}.sidebar-bottom{position:absolute;bottom:26px;font-size:12px;color:#8ba3c2;line-height:1.9}.sidebar-bottom span{color:#40cbfb}.sidebar-bottom small{font-size:10px}.main{min-width:0;flex:1;max-width:1900px;padding:24px 30px;margin:auto}header{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:18px;align-items:center;padding-bottom:23px;border-bottom:1px solid #1c3451}.header-copy{min-width:0}.header-center-logo{display:flex;justify-content:center;align-items:center;padding:0 8px}.header-center-logo img{display:block;height:74px;width:auto;filter:drop-shadow(0 6px 16px rgba(0,0,0,.18))}.eyebrow,.section-kicker{font-size:10px;letter-spacing:2px;color:#56b8e9;font-weight:700}h1{font-size:29px;letter-spacing:-.7px;margin:8px 0 5px;font-weight:800}h1 span{color:#75b8ff}h1 em{font-size:10px;font-style:normal;border:1px solid #674cb5;border-radius:5px;color:#bda7ff;padding:5px 8px;letter-spacing:1px;vertical-align:middle;margin-left:8px}header p{margin:0;color:#8299b8;font-size:12px}.header-actions{display:flex;align-items:center;gap:20px;justify-self:end}.pill{border:1px solid #23866c;color:#7aefd0;background:#0b2e2c;border-radius:30px;padding:9px 14px;font-size:10px;font-weight:700;letter-spacing:1px}.pill i{display:inline-block;width:6px;height:6px;background:#54f6bc;border-radius:50%;margin-right:7px}.clock{color:#879dbc;font-size:12px}.topbar{display:flex;justify-content:space-between;align-items:end;margin:23px 0 13px}.topbar h2{font-size:20px;margin:6px 0 0}.live-dot{display:inline-block;background:#34dca7;width:7px;height:7px;border-radius:50%;vertical-align:middle;box-shadow:0 0 13px #24e6a7}.actions{display:flex;gap:10px}button{border:0;border-radius:8px;padding:11px 15px;color:#e9f4ff;background:#172c48;font-size:12px;font-weight:700}.ghost{border:1px solid #2c4568}.primary{background:linear-gradient(110deg,#1369d9,#6b47d4);box-shadow:0 5px 25px #235be355}.panel{border:1px solid #223a5a;background:linear-gradient(150deg,#101e35ee,#0b1629ee);border-radius:13px;box-shadow:0 10px 35px #0003}.orchestra{position:relative;overflow:hidden;padding:18px 20px 0;isolation:isolate}.mesh{position:absolute;inset:0;z-index:-1;background-image:linear-gradient(#28507914 1px,transparent 1px),linear-gradient(90deg,#28507914 1px,transparent 1px);background-size:24px 24px;mask-image:linear-gradient(to bottom,transparent,#000 20%,#000 80%,transparent)}.conductor-wrap{display:flex;justify-content:center}.conductor{width:285px;display:flex;align-items:center;gap:14px;padding:13px 17px;border:1px solid #8054df;border-radius:13px;background:linear-gradient(110deg,#2d1e57,#161d43);box-shadow:0 0 26px #864de94a, inset 0 0 20px #a063ff17;position:relative}.conductor-icon{width:46px;height:46px;display:grid;place-items:center;border-radius:50%;border:1px solid #9a73ef;color:#ddc5ff;font-size:29px;text-shadow:0 0 15px #b79bff;box-shadow:0 0 20px #a77aff44}.conductor small{display:block;color:#ae9bd7;font-size:9px;letter-spacing:1.5px}.conductor strong{display:block;font-size:17px;margin:4px 0;font-weight:750}.conductor span{font-size:12px;color:#cbbce9}.conductor-indicator{position:absolute;right:14px;top:14px;width:7px;height:7px;border-radius:50%;background:#8e7bad}.conductor.running{animation:conductorPulse 1.5s infinite}.conductor.running .conductor-indicator{background:#c5a2ff;box-shadow:0 0 12px #d1a9ff}.connections{display:block;width:100%;height:90px;overflow:visible}.connections path{fill:none;stroke:#38527a;stroke-width:1.5;vector-effect:non-scaling-stroke}.connections .flowline{stroke:#a77bff;stroke-width:2;stroke-dasharray:7 20;opacity:.2}.orchestra.running .flowline{opacity:1;animation:dash 1.2s linear infinite;filter:drop-shadow(0 0 5px #b38cff)}.agents{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;position:relative}.agent{min-width:0;border:1px solid color-mix(in srgb,var(--accent) 55%,#1d3150);background:linear-gradient(150deg,#142841,#0a182c);border-radius:12px;padding:13px 12px 11px;box-shadow:inset 0 0 25px color-mix(in srgb,var(--accent) 6%,transparent);transition:box-shadow .3s,transform .3s,border-color .3s}.agent.active{transform:translateY(-5px);border-color:var(--accent);box-shadow:0 0 24px color-mix(in srgb,var(--accent) 43%,transparent),inset 0 0 22px color-mix(in srgb,var(--accent) 12%,transparent)}.agent.done{border-color:var(--accent)}.agent-top{display:flex;justify-content:space-between;gap:4px;align-items:center;color:var(--accent);font-size:9px;letter-spacing:1px}.agent .status{font-size:8px;color:#8499b6;letter-spacing:0}.agent.active .status{color:var(--accent)}.agent-symbol{font-size:27px;color:var(--accent);margin-top:13px;text-shadow:0 0 17px var(--accent)}.agent h3{font-size:17px;line-height:1.22;font-weight:750;letter-spacing:.1px;margin:9px 0 5px}.agent p{font-size:13px;line-height:1.35;color:#c2d2e8;margin:0;font-weight:500}.mini-visual{height:42px;margin:13px 0;color:var(--accent);font-size:24px;display:flex;align-items:center;justify-content:center}.mini-visual.bars{align-items:end;gap:6px;padding-bottom:6px}.bars b{background:var(--accent);opacity:.8;width:9px;border-radius:2px 2px 0 0;height:45%}.bars b:nth-child(2){height:80%}.bars b:nth-child(3){height:55%}.bars b:nth-child(4){height:95%}.bars b:nth-child(5){height:65%}.bars b:nth-child(6){height:75%}.bars b:nth-child(7){height:45%}.spark{font-size:27px;letter-spacing:-2px}.rings{font-size:22px;gap:9px}.rings span{font-size:29px}.checkicons{font-size:19px}.reportlines{font-size:13px;line-height:1.1;letter-spacing:3px}.agent-bottom{display:flex;align-items:center;gap:7px}.track{height:5px;flex:1;background:#26344e;border-radius:20px;overflow:hidden}.track i{display:block;height:100%;width:0;background:var(--accent);box-shadow:0 0 9px var(--accent);transition:width .13s linear}.percent{font-size:9px;color:#a3b3cc;width:25px;text-align:right}.orchestra-footer{display:flex;justify-content:space-between;gap:10px;border-top:1px solid #203550;margin-top:20px;padding:13px 0;color:#849bb8;font-size:10px}.content-grid{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(295px,.75fr);gap:14px;margin-top:15px}.left-content,.right-content{display:flex;flex-direction:column;gap:14px;min-width:0}.kpi-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px}.kpi{padding:15px;border:1px solid #2b4160;background:linear-gradient(135deg,#11223b,#0c192d);border-radius:10px}.kpi small{display:block;font-size:10px;color:#829bbb}.kpi strong{display:block;font-size:24px;margin:8px 0 3px}.kpi span{color:#55dcb5;font-size:10px}.kpi:last-child span{color:#ffba6b}.charts-grid{display:grid;grid-template-columns:1.15fr 1fr;gap:14px}.chart-panel,.verification,.logpanel,.chatpanel,.preview{padding:17px}.panel-heading{display:flex;align-items:start;justify-content:space-between;gap:10px;margin-bottom:12px}.panel-heading h3{font-size:14px;margin:5px 0}.subtle{font-size:10px;color:#8aa2c0}.chart-panel canvas{display:block;width:100%;height:165px}.legend{display:flex;gap:10px;flex-wrap:wrap;color:#9caec7;font-size:9px;margin-top:10px}.legend i{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:3px}.checks{display:flex;flex-direction:column;gap:9px}.check-row{display:flex;justify-content:space-between;font-size:10px;color:#aebed3;border-bottom:1px solid #223650;padding-bottom:7px;gap:5px}.check-row strong{color:#61e4b5}.check-row strong.bad{color:#ffb56b}.warning{background:#5b3d1e55;border:1px solid #9c6b37;border-radius:7px;padding:9px;margin-top:12px;color:#ffc48b;font-size:10px}.warn-label{font-size:9px;color:#ffb971;border:1px solid #7b5834;padding:5px 8px;border-radius:5px}.logpanel{min-height:145px}.logs{max-height:150px;overflow:auto;font-size:11px;color:#abc0d9}.logitem{padding:6px 0;border-bottom:1px solid #1d314b}.logitem time{color:#6985a7;margin-right:10px}.logitem.muted{color:#7287a4}.chatpanel{display:flex;flex-direction:column;min-height:425px}.chat-icon{color:#b2a0ff}.demo-badge{font-size:9px;border:1px solid #4a6384;color:#9eb5d3;padding:5px 7px;border-radius:6px}.chat-messages{flex:1;overflow:auto;max-height:275px;min-height:140px;display:flex;flex-direction:column;gap:12px}.message{padding:12px;border-radius:10px;font-size:11px;line-height:1.55}.message.assistant{background:#142c49;border:1px solid #264c74;align-self:flex-start}.message.user{background:#25385c;border:1px solid #3e5681;align-self:flex-end}.message small{color:#6ed0f2;font-size:9px;letter-spacing:1px}.message p{margin:6px 0 0}.quick-questions{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0}.quick-questions button{font-size:9px;padding:7px;background:#152944;border:1px solid #315075}.chat-form{display:flex;gap:7px}.chat-form input{min-width:0;flex:1;background:#0b1b30;border:1px solid #2c4b6e;border-radius:8px;padding:12px;color:white;font-size:11px}.chat-form button{background:#275cba;font-size:16px;padding:7px 13px}.chat-disclaimer{font-size:9px;color:#7e91ac;margin-top:9px}.paper{background:#f3f7fd;color:#163354;border-radius:5px;padding:16px;min-height:225px;position:relative;overflow:hidden}.paper-top{display:flex;justify-content:space-between;align-items:center;border-bottom:2px solid #0e618c;padding-bottom:7px}.paper-top strong{font-size:14px}.paper-top small{font-size:7px;color:#5b7490}.paper h4{font-size:18px;margin:14px 0 2px}.paper p{font-size:9px;color:#67809c;margin:0}.paper-kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;margin:13px 0}.paper-kpis div{background:#e0ebf5;border-radius:4px;padding:7px}.paper-kpis strong{font-size:14px;display:block}.paper-kpis small{font-size:8px;color:#52728c}.paper-bars{height:43px;display:flex;align-items:end;gap:5px}.paper-bars i{flex:1;height:45%;background:#338bb9}.paper-bars i:nth-child(3n){height:80%;background:#f2aa65}.paper-bars i:nth-child(4n){height:65%}.paper-bars i:nth-child(5n){height:95%;background:#42b9a2}.paper-watermark{position:absolute;bottom:0;left:0;right:0;background:#a13c3c;color:white;text-align:center;padding:4px;font-size:9px;letter-spacing:1px}.export{width:100%;margin-top:10px;border:1px solid #345579;background:#183354}.export:disabled{opacity:.6}footer{display:flex;justify-content:space-between;gap:10px;padding:22px 0;color:#6e88a9;font-size:9px;letter-spacing:1px}@keyframes dash{to{stroke-dashoffset:-54}}@keyframes conductorPulse{50%{box-shadow:0 0 42px #9a6bffbb,inset 0 0 28px #a063ff33}}@media(max-width:1250px){.sidebar{width:185px}.main{padding:18px}.agents{gap:7px}.agent{padding:10px 8px}.agent h3{font-size:14px}.agent p{font-size:12px}.charts-grid{grid-template-columns:1fr}}@media(max-width:900px){.sidebar{display:none}.content-grid{grid-template-columns:1fr}.right-content{display:grid;grid-template-columns:1fr 1fr}.agents{grid-template-columns:repeat(5,minmax(0,1fr))}}@media(max-width:680px){.main{padding:12px}header{align-items:start}h1{font-size:21px}.header-actions .clock{display:none}.topbar{align-items:start;gap:10px}.topbar h2{font-size:16px}.actions{flex-direction:column}.agents{grid-template-columns:repeat(2,minmax(0,1fr))}.connections{display:none}.orchestra{padding:12px}.agent:last-child{grid-column:span 2}.kpi-grid{grid-template-columns:repeat(2,1fr)}.right-content{display:flex}.orchestra-footer{flex-wrap:wrap}.clock{display:none}footer span{display:none}}@media print{.sidebar,header,.topbar,.orchestra,.chatpanel,.logpanel,.chart-panel,.verification,.kpi-grid,footer,.export{display:none!important}.content-grid,.right-content{display:block}.preview{border:0;background:white}.paper{max-width:750px;min-height:800px;margin:auto}.paper-watermark{font-size:15px}.paper h4{font-size:40px}} | |
| /* ORCHESTRA v0.3 — alleen de dirigent: subtiele activiteit en gestuurde lichtsignalen */ | |
| .conductor-icon {position:relative;isolation:isolate;flex:none;} | |
| .conductor-icon::before,.conductor-icon::after {content:"";position:absolute;border-radius:50%;pointer-events:none;} | |
| .conductor-icon::before {inset:-7px;border:2px solid transparent;border-top-color:#cfb0ff;border-right-color:#9367ff;filter:drop-shadow(0 0 5px #ab74ff);animation:orbitConductor 10s linear infinite;opacity:.9;} | |
| .conductor-icon::after {inset:-3px;border:1px dashed #9470db;animation:orbitConductor 18s linear infinite reverse;opacity:.38;} | |
| .conductor-icon .brain-symbol {width:30px;height:30px;display:block;position:relative;z-index:1;filter:drop-shadow(0 0 5px #ad82ff);animation:brainBreath 3.8s ease-in-out infinite;transform-origin:center;} | |
| .conductor.running .conductor-icon::before {animation-duration:2.1s;border-top-color:#e6d3ff;border-right-color:#be98ff;filter:drop-shadow(0 0 9px #c99aff);} | |
| .conductor.running .conductor-icon::after {animation-duration:5s;opacity:.75;} | |
| .conductor.running .brain-symbol {animation-duration:1.7s;} | |
| .conductor.attention .conductor-icon::before {border-top-color:#ffbc6d;border-right-color:#f59c4b;filter:drop-shadow(0 0 6px #ffb167);} | |
| .conductor.attention .brain-symbol {color:#ffd3a0;filter:drop-shadow(0 0 7px #ffb167);} | |
| .connections .signal-layer .signal-particle {stroke:none;filter:drop-shadow(0 0 4px #c69aff);} | |
| .connections .signal-layer .signal-halo {stroke:none;opacity:.24;filter:drop-shadow(0 0 7px #b78cff);} | |
| @keyframes orbitConductor {to{transform:rotate(360deg)}} | |
| @keyframes brainBreath {0%,100% {opacity:.78;transform:scale(.94)}50% {opacity:1;transform:scale(1.08)}} | |
| @media (prefers-reduced-motion:reduce) { .conductor-icon::before,.conductor-icon::after,.conductor-icon .brain-symbol {animation:none!important} .connections .signal-layer {display:none} } | |
| /* v0.7 – lokale Excel-proef met Scanpassen; bestaande dashboardstijl bewust ongewijzigd */ | |
| .import-panel{display:flex;align-items:center;gap:13px;flex-wrap:wrap;padding:12px 17px;margin-bottom:13px;border-color:#305278;background:linear-gradient(135deg,#10263c,#0d192f)} | |
| .import-intro{display:flex;flex-direction:column;gap:4px;flex:1;min-width:250px}.import-intro strong{font-size:14px}.import-intro small{font-size:10px;color:#9bb0cd} | |
| .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} | |
| .import-file-label span{color:#53d9bf;font-size:16px;margin-right:7px} | |
| #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} | |
| #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} | |
| @media(max-width:760px){.import-panel{align-items:start}.import-intro{flex-basis:100%}.import-state{flex-basis:100%;max-width:none}} | |
| /* v0.5: agent-workflow-inzicht, zonder de goedgekeurde basisvormgeving te wijzigen */ | |
| .pace-label{display:flex;gap:7px;align-items:center;color:#a3b8d5;font-size:11px} | |
| #paceSelect{max-width:175px;color:#e2f2ff;background:#102641;border:1px solid #36577d;border-radius:7px;padding:8px;font-size:11px} | |
| .agent-steps{display:none;margin:11px 0 10px;min-height:116px;flex-direction:column;gap:5px} | |
| .agent.has-steps .agent-steps{display:flex} | |
| .agent.has-steps .mini-visual{display:none} | |
| .agent-step{display:flex;gap:6px;align-items:flex-start;font-size:10px;line-height:1.35;color:#7288a7;min-height:17px} | |
| .agent-step .step-mark{flex:none;display:inline-flex;align-items:center;justify-content:center;width:13px;height:13px;border:1px solid #526782;border-radius:50%;font-size:9px;color:#718cac;margin-top:1px} | |
| .agent-step.complete{color:#d6efe8}.agent-step.complete .step-mark{border-color:#34e6b3;background:#0c624b;color:#fff} | |
| .agent-step.current{color:#f1f7ff;font-weight:650}.agent-step.current .step-mark{border-color:var(--accent);color:var(--accent);box-shadow:0 0 7px var(--accent);animation:stepBlink 1s ease-in-out infinite} | |
| .agent-step.skipped{color:#b6a9c9}.agent-step.skipped .step-mark{border-color:#b18aff;color:#b18aff} | |
| @keyframes stepBlink{50%{opacity:.45}} | |
| @media(max-width:1250px){.agent-step{font-size:9px}.pace-label{font-size:10px}} | |
| @media(max-width:680px){.pace-label{flex-direction:column;align-items:start}.agent-steps{min-height:110px}} | |
| @media(prefers-reduced-motion:reduce){.agent-step.current .step-mark{animation:none}} | |
| /* V0.8: Witte vaste rapportlay-out, gescheiden van de ORCHESTRA-werkruimte. */ | |
| #reportOverlay{position:fixed;inset:0;background:#050b19eF;z-index:5000;overflow:auto;padding:22px;display:none} | |
| #reportOverlay.open{display:block}body.report-open{overflow:hidden} | |
| .report-actions{max-width:1510px;margin:0 auto 14px;display:flex;justify-content:space-between;gap:12px;align-items:center;color:#eaf2ff;font-size:14px} | |
| .report-actions button{background:#1e497f;border:1px solid #4c7bad;padding:11px 18px}.report-actions .report-print{background:#146bd8}.report-actions .report-png{background:#17664f;border-color:#39967a}.report-actions .report-jpg{background:#7a4f12;border-color:#b07d34} | |
| #reportSheet{width:min(100%,1510px);margin:auto;background:white;color:#0a164a;font-family:'Segoe UI',Arial,sans-serif;padding:25px 28px 17px;box-shadow:0 22px 65px #0009;line-height:1.24} | |
| #reportSheet *{box-sizing:border-box}#reportSheet .report-brand{margin-bottom:11px}#reportSheet .report-logo{display:block;height:82px;width:auto;object-fit:contain} | |
| .report-heading{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:14px}.report-heading h1{font-size:32px;margin:0;color:#0b164d;letter-spacing:-1.1px}.report-heading p{font-size:16px;margin:3px 0 0;color:#29335f} | |
| .report-meta{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;min-width:48%}.report-meta>div{border:1px solid #dce2ef;border-radius:7px;padding:10px 13px;display:flex;align-items:center;gap:11px;min-width:0}.report-meta .meta-icon{font-size:23px;color:#20116c}.report-meta strong{font-size:11px;display:block}.report-meta span{font-size:12px} | |
| .report-kpi-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:12px 0}.report-kpi{border:1px solid #e0e5ef;border-radius:8px;min-height:136px;padding:13px 12px;display:grid;grid-template-columns:54px 1fr;column-gap:12px;position:relative;overflow:hidden}.report-kpi .k-icon{width:52px;height:52px;border-radius:50%;display:grid;place-items:center;color:white;font-size:26px;font-weight:700}.report-kpi .k-label{font-size:14px;font-weight:750;margin-top:1px}.report-kpi .k-num{font-size:32px;font-weight:850;margin:7px 0 2px;color:#0b164d;white-space:nowrap}.report-kpi .k-sub{font-size:11px;color:#333f65;line-height:1.33}.report-kpi.purple .k-icon{background:linear-gradient(140deg,#863eec,#4c24bc)}.report-kpi.green .k-icon{background:linear-gradient(140deg,#50c52d,#168c24)}.report-kpi.orange .k-icon{background:linear-gradient(140deg,#fe8c20,#ff4d00)}.report-kpi.purple .k-label{color:#5b20d6}.report-kpi.green .k-label{color:#219824}.report-kpi.orange .k-label{color:#f45c0b}.report-kpi .k-underline{position:absolute;bottom:0;left:0;right:0;height:5px;background:linear-gradient(90deg,transparent,#ded6ff,transparent)} | |
| .report-chart-grid{display:grid;grid-template-columns:1fr 1fr 1.13fr;gap:10px}.report-panel{border:1px solid #e1e5ef;border-radius:8px;padding:12px 14px;min-width:0;background:white}.report-panel h2{font-size:15px;margin:0 0 9px;color:#11176b}.report-panel h2 small{font-weight:400;font-size:11px}.report-panel .chart-box{height:172px;display:flex;align-items:stretch;min-width:0} | |
| .report-chart-grid .report-panel{min-height:224px}.report-bottom-grid{display:grid;grid-template-columns:1fr 1.1fr 1.08fr;gap:10px;margin-top:10px}.report-bottom-grid .report-panel{min-height:216px} | |
| .report-mini-table{width:100%;border-collapse:collapse;font-size:11px;text-align:center}.report-mini-table th{background:#f6f7fd;padding:10px 4px;color:#10166b;border:1px solid #e7eafa;font-weight:700}.report-mini-table td{padding:11px 3px;border-bottom:1px solid #f0f1f7}.report-mini-table .total td{font-weight:750;background:#f5f2ff;color:#5c20da}.report-mini-table td:first-child{text-align:left} | |
| .report-intensity-row{display:flex;align-items:center;gap:9px;font-size:11px;margin:9px 0}.report-intensity-row .int-label{width:99px;flex:none}.report-intensity-row .int-bar-track{flex:1;height:19px;background:#f7f4ff}.report-intensity-row .int-bar{height:100%;background:linear-gradient(90deg,#7037c7,#a46ce6)}.report-intensity-row strong{width:25px}.report-intensity-summary{margin-top:9px;padding:8px;background:#f8f9fe;font-size:12px;color:#0a165a;display:flex;gap:14px;justify-content:space-between}.report-intensity-summary strong{font-size:17px} | |
| .report-analysis{font-size:12px;line-height:1.45;color:#263254}.report-analysis p{margin:0 0 9px}.report-foot{margin-top:12px;font-size:10px;color:#53638e;display:flex;justify-content:space-between;gap:12px;border-top:1px solid #e6eafa;padding-top:9px}.report-provisional{color:#8f3c12;font-weight:700}.report-no-data{display:flex;align-items:center;justify-content:center;text-align:center;color:#697497;font-size:12px;border:1px dashed #e4e7f2;width:100%;height:100%;padding:15px} | |
| .report-preview-trigger{width:100%;margin-top:9px;border:1px solid #536de4;background:linear-gradient(110deg,#203f8c,#663dc2);padding:12px 8px;color:white} | |
| @media(max-width:1100px){.report-heading{display:block}.report-meta{margin-top:12px}.report-chart-grid,.report-bottom-grid{grid-template-columns:1fr}.report-kpi-grid{grid-template-columns:repeat(2,1fr)}} | |
| @media print{ | |
| @page{size:A3 landscape;margin:6mm} | |
| html,body{background:white!important;margin:0!important;padding:0!important;height:auto!important;overflow:visible!important} | |
| body>.app{display:none!important}#reportOverlay{display:block!important;position:static!important;background:white!important;padding:0!important;margin:0!important;overflow:visible!important} | |
| .report-actions{display:none!important}#reportSheet{box-shadow:none!important;width:100%!important;max-width:none!important;margin:0!important;padding:0!important;zoom:.94;print-color-adjust:exact;-webkit-print-color-adjust:exact} | |
| .report-panel,.report-kpi{break-inside:avoid}.report-chart-grid,.report-bottom-grid{break-inside:avoid} | |
| } | |
| /* v0.8.1 — PDF-afdruk: A3 liggend, vaste dashboardkolommen ongeacht printviewport */ | |
| @media print { | |
| @page { size: 420mm 297mm; margin: 7mm; } | |
| html,body { width:100%!important; margin:0!important; padding:0!important; overflow:visible!important; } | |
| #reportOverlay { display:block!important; position:static!important; width:100%!important; padding:0!important; margin:0!important; overflow:visible!important; } | |
| #reportSheet { | |
| display:block!important; box-sizing:border-box!important; | |
| width:100%!important; max-width:none!important; margin:0!important; | |
| padding:4mm 5mm!important; zoom:1!important; | |
| break-inside:avoid!important; page-break-inside:avoid!important; | |
| } | |
| #reportSheet .report-heading { display:flex!important; align-items:center!important; } | |
| #reportSheet .report-meta { display:grid!important; grid-template-columns:repeat(3,minmax(0,1fr))!important; margin-top:0!important; } | |
| #reportSheet .report-kpi-grid { display:grid!important; grid-template-columns:repeat(4,minmax(0,1fr))!important; } | |
| #reportSheet .report-chart-grid { display:grid!important; grid-template-columns:1fr 1fr 1.13fr!important; } | |
| #reportSheet .report-bottom-grid { display:grid!important; grid-template-columns:1fr 1.1fr 1.08fr!important; } | |
| #reportSheet .report-chart-grid .report-panel { min-height:190px!important; } | |
| #reportSheet .report-bottom-grid .report-panel { min-height:196px!important; } | |
| #reportSheet .report-panel .chart-box { height:145px!important; } | |
| #reportSheet .report-kpi { min-height:116px!important; } | |
| #reportSheet .report-kpi .k-num { font-size:28px!important; } | |
| #reportSheet .report-intensity-row { margin:6px 0!important; } | |
| #reportSheet .report-analysis { font-size:11px!important; } | |
| #reportSheet .report-foot { margin-top:7px!important; } | |
| #reportSheet .report-panel, #reportSheet .report-kpi { break-inside:avoid!important; } | |
| } | |
| /* v0.8.2 — A4 liggend: vaste drie kolommen en alle onderdelen op één afdrukvel. */ | |
| @media print { | |
| @page { size: A4 landscape; margin: 4mm; } | |
| html,body { width:100%!important; margin:0!important; padding:0!important; } | |
| #reportOverlay { width:100%!important; margin:0!important; padding:0!important; } | |
| #reportSheet { width:100%!important; max-width:none!important; padding:2mm 3mm!important; zoom:1!important; } | |
| #reportSheet .report-brand { font-size:24px!important; margin-bottom:6px!important; } | |
| #reportSheet .report-brand sup { font-size:8px!important; } | |
| #reportSheet .report-heading { display:flex!important; gap:9px!important; margin-bottom:8px!important; } | |
| #reportSheet .report-heading h1 { font-size:22px!important; letter-spacing:-.4px!important; } | |
| #reportSheet .report-heading p { font-size:11px!important; } | |
| #reportSheet .report-meta { min-width:49%!important; gap:4px!important; } | |
| #reportSheet .report-meta>div { gap:5px!important; padding:5px 6px!important; } | |
| #reportSheet .report-meta .meta-icon { font-size:17px!important; } | |
| #reportSheet .report-meta strong { font-size:9px!important; } | |
| #reportSheet .report-meta span { font-size:9px!important; overflow-wrap:anywhere; } | |
| #reportSheet .report-kpi-grid { gap:6px!important; margin:8px 0!important; } | |
| #reportSheet .report-kpi { min-height:96px!important; padding:9px 8px!important; grid-template-columns:38px minmax(0,1fr)!important; column-gap:8px!important; } | |
| #reportSheet .report-kpi .k-icon { width:36px!important; height:36px!important; font-size:19px!important; } | |
| #reportSheet .report-kpi .k-label { font-size:11px!important; } | |
| #reportSheet .report-kpi .k-num { font-size:24px!important; margin:3px 0 1px!important; } | |
| #reportSheet .report-kpi .k-sub { font-size:9px!important; line-height:1.2!important; } | |
| #reportSheet .report-chart-grid { grid-template-columns:1fr 1fr 1.13fr!important; gap:6px!important; } | |
| #reportSheet .report-bottom-grid { grid-template-columns:1fr 1.1fr 1.08fr!important; gap:6px!important; margin-top:6px!important; } | |
| #reportSheet .report-panel { padding:7px 8px!important; border-radius:6px!important; } | |
| #reportSheet .report-panel h2 { font-size:11px!important; margin:0 0 5px!important; } | |
| #reportSheet .report-panel h2 small { font-size:8px!important; } | |
| #reportSheet .report-chart-grid .report-panel { min-height:160px!important; } | |
| #reportSheet .report-bottom-grid .report-panel { min-height:167px!important; } | |
| #reportSheet .report-panel .chart-box { height:137px!important; } | |
| #reportSheet .report-mini-table { font-size:8px!important; } | |
| #reportSheet .report-mini-table th { padding:6px 2px!important; } | |
| #reportSheet .report-mini-table td { padding:7px 2px!important; } | |
| #reportSheet .report-intensity-row { margin:5px 0!important; gap:5px!important; font-size:9px!important; } | |
| #reportSheet .report-intensity-row .int-label { width:80px!important; } | |
| #reportSheet .report-intensity-row .int-bar-track { height:13px!important; } | |
| #reportSheet .report-intensity-summary { margin-top:5px!important; padding:5px!important; font-size:9px!important; } | |
| #reportSheet .report-intensity-summary strong { font-size:12px!important; } | |
| #reportSheet .report-analysis { font-size:9px!important; line-height:1.3!important; } | |
| #reportSheet .report-analysis p { margin-bottom:5px!important; } | |
| #reportSheet .report-foot { margin-top:6px!important; padding-top:5px!important; font-size:8px!important; } | |
| } | |
| /* v0.9.1: transparante cliëntdefinities zonder aanpassing van de ORCHESTRA-huisstijl. */ | |
| .definition-guide{border:1px solid #2c4971;border-radius:11px;background:linear-gradient(110deg,#11243b,#111a33);padding:12px 16px;margin:11px 0 16px;color:#cadcf0} | |
| .definition-guide-heading{display:flex;align-items:center;gap:10px;margin-bottom:9px;font-size:12px} | |
| .definition-guide-heading strong{color:#f2f8ff}.definition-guide-heading>span:last-child{font-size:10px;color:#93abc9;margin-left:auto} | |
| .definition-guide-icon{color:#57dcbc;font-size:17px} | |
| .definition-guide-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px} | |
| .definition-guide-grid>div{border-left:2px solid #3d7091;padding:1px 0 1px 10px} | |
| .definition-guide-grid b{font-size:11px;color:#e3f3ff}.definition-guide-grid p{font-size:10px;line-height:1.42;margin:4px 0 0;color:#adbed6} | |
| .definition-guide-note{font-size:10px;color:#77dbb7;margin-top:10px;line-height:1.35} | |
| @media(max-width:760px){.definition-guide-grid{grid-template-columns:1fr}.definition-guide-heading{flex-wrap:wrap}.definition-guide-heading>span:last-child{margin-left:0}} | |
| .report-definitions{border:1px solid #e2e6f1;border-radius:7px;background:#f8faff;padding:8px 12px;margin:-3px 0 10px} | |
| .report-definitions-title{font-weight:800;color:#181c65;font-size:11px;margin-bottom:5px} | |
| .report-definitions-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px} | |
| .report-definitions-grid>div{border-left:3px solid #7850b9;padding:2px 0 2px 8px;color:#324062;font-size:10px;line-height:1.32} | |
| .report-definitions-grid b{display:block;font-size:10px;color:#161c64;margin-bottom:2px} | |
| .report-definitions-note{font-size:9px;color:#52618d;margin-top:5px} | |
| @media(max-width:1000px){.report-definitions-grid{grid-template-columns:1fr}} | |
| @media print{#reportSheet .report-definitions-grid{grid-template-columns:repeat(3,minmax(0,1fr))!important}#reportSheet .report-definitions{padding:4px 7px!important;margin:1px 0 6px!important}#reportSheet .report-definitions-grid>div{font-size:7px!important;line-height:1.2!important}#reportSheet .report-definitions-grid b,#reportSheet .report-definitions-title{font-size:8px!important}#reportSheet .report-definitions-note{font-size:7px!important}} | |
| /* v1.2: los, compact controlepaneel; bestaande ORCHESTRA en rapport-opmaak ongewijzigd */ | |
| .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} | |
| .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}} | |
| /* v1.5: uitsluitend compacte toevoegingen voor historische invoer */ | |
| .history-status{grid-column:1/-1;color:#94b7d7;font-size:10px;line-height:1.4;border-top:1px solid #26415e;padding-top:9px} | |
| .history-status.ready{color:#5ce5b4} | |
| .history-status.warning{color:#ffc27f} | |
| .history-label{border-color:#4c5581!important} | |
| #historyFile{display:none} | |
| /* v1.6: Bronafwijkingen met aantallen en voorzichtige KPI-impact, uitsluitend in verificatiepaneel */ | |
| .source-audit{margin-top:13px;padding:12px;border:1px solid #344d70;background:#0c1b31;border-radius:9px} | |
| .source-audit h4{margin:0 0 6px;font-size:11px;color:#c5d9f7;letter-spacing:.25px} | |
| .source-audit .source-intro{font-size:10px;line-height:1.5;color:#9fb4d2;margin:0 0 9px} | |
| .source-issue{border-top:1px solid #243d5b;padding:10px 0 8px} | |
| .source-issue-top{display:flex;align-items:center;justify-content:space-between;gap:9px} | |
| .source-issue-name{font-size:11px;color:#e8f0ff;font-weight:700} | |
| .source-issue-count{font-size:10px;color:#d3dff1;white-space:nowrap} | |
| .source-issue-impact{font-size:9px;font-weight:700;letter-spacing:.2px;margin-top:5px} | |
| .source-issue-impact.none{color:#5fe3b9}.source-issue-impact.possible{color:#ffc17e}.source-issue-impact.unknown{color:#c4c5fa} | |
| .source-issue-detail{font-size:10px;color:#adbfdb;line-height:1.5;margin:4px 0 0} | |
| .source-audit-ok{font-size:10px;color:#71e8be;line-height:1.5} | |
| @media(max-width:1080px){header{grid-template-columns:1fr;justify-items:start}.header-center-logo{justify-content:flex-start;padding:4px 0 0}.header-center-logo img{height:60px}.header-actions{justify-self:start}} | |
| /* v1.6.3: alleen de witte managementrapportage, bestaande dashboard-huisstijl onveranderd */ | |
| #reportSheet .report-average-note{margin-top:8px;font-size:9px;color:#68739a;line-height:1.25} | |
| #reportSheet .report-client-month-chart{height:157px;max-width:100%;min-width:0} | |
| #reportSheet .report-client-month-chart svg{display:block;max-width:100%;height:100%} | |
| @media print{#reportSheet .report-average-note{font-size:8px!important;margin-top:5px!important}#reportSheet .report-client-month-chart{height:133px!important}} | |
| /* v1.6.4 — leesbare managementrij, uitsluitend witte Inloop Monitor */ | |
| @media(min-width:1101px){ | |
| #reportSheet .report-bottom-grid{grid-template-columns:.98fr 1.30fr 1fr;} | |
| } | |
| #reportSheet .report-bottom-grid .report-panel{min-height:232px} | |
| #reportSheet .report-client-month-chart{height:196px;} | |
| #reportSheet .report-average-highlight{display:flex;gap:9px;align-items:center;margin-top:14px;padding:12px 14px;background:#f6f3ff;border:1px solid #e5dafa;border-radius:7px;} | |
| #reportSheet .report-average-highlight .highlight-num{font-size:27px;font-weight:850;color:#5b26c3;line-height:1;white-space:nowrap;} | |
| #reportSheet .report-average-highlight .highlight-label{font-size:12px;font-weight:700;color:#27325b;line-height:1.3;} | |
| #reportSheet .report-analysis{font-size:12px;line-height:1.52;} | |
| #reportSheet .report-analysis p{margin:0 0 11px;} | |
| @media print{ | |
| #reportSheet .report-bottom-grid{grid-template-columns:.98fr 1.30fr 1fr!important;} | |
| #reportSheet .report-bottom-grid .report-panel{min-height:183px!important;} | |
| #reportSheet .report-client-month-chart{height:153px!important;} | |
| #reportSheet .report-average-highlight{margin-top:5px!important;padding:6px!important;gap:5px!important;} | |
| #reportSheet .report-average-highlight .highlight-num{font-size:16px!important;} | |
| #reportSheet .report-average-highlight .highlight-label{font-size:8px!important;} | |
| #reportSheet .report-analysis{font-size:9px!important;line-height:1.38!important;} | |
| #reportSheet .report-analysis p{margin:0 0 7px!important;} | |
| } | |
| /* v1.6.5b: alleen een extra, exact gecentreerde ORCHESTRA-titel in de bestaande header. */ | |
| .main header{position:relative} | |
| .orchestra-hero-title{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;text-align:center;white-space:nowrap;pointer-events:none;z-index:1} | |
| .orchestra-hero-word{display:block;font-family:Inter,'Segoe UI',Arial,sans-serif;font-size:clamp(37px,3.7vw,62px);line-height:1.02;font-weight:850;letter-spacing:.3px;background:linear-gradient(100deg,#91efff 0%,#49c0fd 49%,#468dff 100%);background-clip:text;-webkit-background-clip:text;color:transparent;-webkit-text-fill-color:transparent} | |
| .orchestra-hero-credit{display:block;font-size:12px;letter-spacing:4px;font-weight:500;color:#60c9f8;line-height:1.4} | |
| /* Op smallere schermen eigen gecentreerde rij, zodat de bestaande headerdelen nooit overlappen. */ | |
| @media(max-width:1380px){.orchestra-hero-title{position:static;transform:none;grid-column:1/-1;grid-row:2;justify-self:center;margin:14px 0 2px}.orchestra-hero-word{font-size:clamp(35px,5vw,54px)}} | |
| @media(max-width:1080px){.orchestra-hero-title{grid-column:1;grid-row:auto;justify-self:center;margin:12px 0 4px}} | |
| @media(max-width:680px){.orchestra-hero-word{font-size:clamp(31px,8vw,44px)}.orchestra-hero-credit{font-size:10px;letter-spacing:3px}} | |
| /* v1.6.5c: uitsluitend de oranje Reakt-woordmark in de donkere bovenbalk. */ | |
| .header-center-logo .reakt-wordmark{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;width:max-content;background:transparent;color:#ff5a0a;line-height:1;white-space:nowrap;} | |
| .header-center-logo .reakt-name{display:block;font-family:Arial,'Segoe UI',sans-serif;font-weight:800;font-size:clamp(40px,3vw,56px);letter-spacing:-2.6px;line-height:.99;color:#ff5a0a;} | |
| .header-center-logo .reakt-tagline{display:block;font-family:Arial,'Segoe UI',sans-serif;font-weight:500;font-size:clamp(12px,.9vw,15px);line-height:1.16;letter-spacing:-.2px;color:#ff5a0a;margin-top:4px;} | |
| @media(max-width:1080px){.header-center-logo .reakt-name{font-size:44px}.header-center-logo .reakt-tagline{font-size:12px}} | |
| /* ORCHESTRA NEXT: dynamischer command center met vloeiende netwerkanimaties */ | |
| .orchestra{padding:28px 28px 0;background:radial-gradient(ellipse at 50% 10%,#20376a 0%,#0b1730 58%,#08111f 100%);overflow:hidden} | |
| .orchestra::before,.orchestra::after{content:'';position:absolute;pointer-events:none;z-index:0;border-radius:50%;filter:blur(54px);opacity:.2} | |
| .orchestra::before{width:320px;height:320px;left:-70px;top:90px;background:#35c9ff} | |
| .orchestra::after{width:360px;height:360px;right:-95px;top:170px;background:#8549ff} | |
| .orchestra .conductor-wrap{position:relative;z-index:3;margin-bottom:34px} | |
| .orchestra .conductor{width:362px;padding:20px 22px;border-color:#c194ff;box-shadow:0 0 45px #8c54df6f,inset 0 0 22px #9a69ff24;transition:transform .35s ease,box-shadow .35s ease,border-color .35s ease;overflow:hidden} | |
| .orchestra .conductor::before{content:'';position:absolute;inset:-45% auto -45% -60%;width:52%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.18),transparent);transform:rotate(18deg) translateX(-160%);opacity:0} | |
| .orchestra .conductor::after{content:'';position:absolute;inset:auto;left:50%;top:50%;width:155px;height:155px;background:radial-gradient(circle,rgba(188,150,255,.16),transparent 68%);transform:translate(-50%,-50%);pointer-events:none;opacity:.65} | |
| .orchestra .conductor strong{font-size:19px} | |
| .orchestra .conductor.running{animation:conductorFloat 4.8s ease-in-out infinite} | |
| .orchestra .conductor.transfering{transform:translateY(-4px) scale(1.02);border-color:#efd7ff;box-shadow:0 0 60px #b779ff88,inset 0 0 28px #d0a7ff30} | |
| .orchestra .conductor.transfering::before{opacity:1;animation:conductorSweep 1s ease-out} | |
| .orchestra .conductor.transfering .conductor-icon{box-shadow:0 0 28px #d0a7ff99,0 0 54px #7de1ff33} | |
| .orchestra .conductor.transfering .brain-symbol{animation-duration:.9s} | |
| .orchestra .conductor.attention{box-shadow:0 0 46px #ffae5990,inset 0 0 26px #ffbf6a24} | |
| .orchestra .conductor.attention::after{background:radial-gradient(circle,rgba(255,189,106,.18),transparent 68%)} | |
| .orchestra .agents{display:grid;grid-template-columns:repeat(3,minmax(300px,1fr));gap:34px 58px;max-width:1490px;margin:0 auto;z-index:2;position:relative;padding:20px 18px 28px} | |
| .orchestra .agent{min-height:178px;padding:16px 18px;position:relative;z-index:2;overflow:hidden;transition:transform .42s ease,box-shadow .42s ease,border-color .42s ease;box-shadow:0 0 16px color-mix(in srgb,var(--accent) 10%,transparent),inset 0 0 25px color-mix(in srgb,var(--accent) 6%,transparent)} | |
| .orchestra .agent::before{content:'';position:absolute;inset:auto -10% -48% auto;width:110px;height:110px;background:radial-gradient(circle,color-mix(in srgb,var(--accent) 34%,transparent),transparent 72%);opacity:.58;filter:blur(8px);pointer-events:none} | |
| .orchestra .agent::after{content:'';position:absolute;inset:-30% auto -30% -55%;width:42%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.12),transparent);transform:rotate(12deg) translateX(-210%);opacity:0;pointer-events:none} | |
| .orchestra .agent:hover{transform:translateY(-2px)} | |
| .orchestra .agent.active{transform:translateY(-8px) scale(1.012);box-shadow:0 0 34px color-mix(in srgb,var(--accent) 34%,transparent),inset 0 0 24px color-mix(in srgb,var(--accent) 13%,transparent)} | |
| .orchestra .agent.active::after{opacity:1;animation:agentSweep 1.35s linear infinite} | |
| .orchestra .agent.done .status{color:#62e5bd} | |
| .orchestra .agent h3{font-size:17px;margin:4px 0} | |
| .orchestra .agent .agent-symbol{font-size:23px;margin-top:7px;animation:iconFloat 5.5s ease-in-out infinite} | |
| .orchestra .agent .mini-visual{margin:5px 0;height:27px;font-size:17px;opacity:.95} | |
| .orchestra .agent .agent-steps{margin:5px 0;min-height:82px} | |
| .orchestra .agent .agent-step{font-size:10px} | |
| .orchestra .agent p{font-size:12px} | |
| .orchestra .agent-bottom .track{height:6px;background:#223650} | |
| .orchestra .agent-bottom .track i{transition:width .35s ease} | |
| #agent-data{order:1}#agent-identity{order:2}#agent-history{order:3}#agent-analysis{order:4}#agent-insight{order:5}#agent-verify{order:6}#agent-privacy{order:7}#agent-report{order:8}#agent-quality{order:9} | |
| #agent-data .bars b{animation:dataBar 1.75s ease-in-out infinite} | |
| #agent-data .bars b:nth-child(2){animation-delay:.18s}#agent-data .bars b:nth-child(3){animation-delay:.36s}#agent-data .bars b:nth-child(4){animation-delay:.54s}#agent-data .bars b:nth-child(5){animation-delay:.72s}#agent-data .bars b:nth-child(6){animation-delay:.9s}#agent-data .bars b:nth-child(7){animation-delay:1.08s} | |
| #agent-analysis .spark{animation:analysisSpark 2.4s ease-in-out infinite;letter-spacing:1px} | |
| #agent-insight .rings{animation:ringsDrift 7.5s ease-in-out infinite} | |
| #agent-insight .rings span{display:inline-block;animation:insightPulse 2.1s ease-in-out infinite} | |
| #agent-verify .checkicons{animation:verifyPulse 2.35s ease-in-out infinite} | |
| #agent-report .reportlines{animation:reportScan 2.6s ease-in-out infinite} | |
| #agent-identity .mini-visual{animation:identityLink 2.8s ease-in-out infinite;letter-spacing:1px} | |
| #agent-history .mini-visual{animation:historyLoop 3.7s ease-in-out infinite} | |
| #agent-privacy .mini-visual{animation:privacyShield 2.9s ease-in-out infinite} | |
| #agent-quality .mini-visual{animation:qualitySpark 2.4s ease-in-out infinite} | |
| #agent-data.active .bars b,#agent-analysis.active .spark,#agent-insight.active .rings,#agent-insight.active .rings span,#agent-verify.active .checkicons,#agent-report.active .reportlines,#agent-identity.active .mini-visual,#agent-history.active .mini-visual,#agent-privacy.active .mini-visual,#agent-quality.active .mini-visual{animation-duration:.95s} | |
| .network-links{position:absolute;inset:0;z-index:1;pointer-events:none;overflow:hidden} | |
| #networkSvg{width:100%;height:100%;overflow:visible} | |
| #networkEdges path{fill:none;stroke:#3e6888;stroke-width:1.55;opacity:.48;vector-effect:non-scaling-stroke;animation:routeGlow 5.5s ease-in-out infinite} | |
| #networkEdges path.transfer{stroke:#86ecff;stroke-width:3.1;stroke-dasharray:8 12;opacity:1;animation:nextStream .72s linear infinite,routeTransferGlow 1.15s ease-in-out infinite;filter:drop-shadow(0 0 7px #75dafa)} | |
| #networkEdges path.route-done{stroke:#4cc69f;opacity:.68;filter:drop-shadow(0 0 5px rgba(76,198,159,.35))} | |
| #networkSignals circle{filter:drop-shadow(0 0 8px #8df3ff)} | |
| @keyframes nextStream{to{stroke-dashoffset:-40}} | |
| @keyframes routeGlow{50%{opacity:.72}} | |
| @keyframes routeTransferGlow{50%{filter:drop-shadow(0 0 12px #9ef4ff)}} | |
| @keyframes conductorFloat{50%{transform:translateY(-4px)}} | |
| @keyframes conductorSweep{0%{transform:rotate(18deg) translateX(-160%)}100%{transform:rotate(18deg) translateX(360%)}} | |
| @keyframes agentSweep{0%{transform:rotate(12deg) translateX(-220%)}100%{transform:rotate(12deg) translateX(360%)}} | |
| @keyframes iconFloat{50%{transform:translateY(-3px)}} | |
| @keyframes dataBar{0%,100%{transform:scaleY(.72);opacity:.7}50%{transform:scaleY(1.18);opacity:1}} | |
| @keyframes analysisSpark{0%,100%{transform:translateX(0);opacity:.78}50%{transform:translateX(4px);opacity:1}} | |
| @keyframes ringsDrift{50%{transform:translateY(-2px) rotate(4deg)}} | |
| @keyframes insightPulse{50%{transform:scale(1.16);text-shadow:0 0 10px currentColor}} | |
| @keyframes verifyPulse{0%,100%{opacity:.75}50%{opacity:1;text-shadow:0 0 14px currentColor}} | |
| @keyframes reportScan{0%,100%{transform:translateY(0);opacity:.82}50%{transform:translateY(-2px);letter-spacing:4px;opacity:1}} | |
| @keyframes identityLink{50%{letter-spacing:3px;transform:translateX(2px)}} | |
| @keyframes historyLoop{50%{transform:translateY(-2px) scale(1.03);opacity:1}} | |
| @keyframes privacyShield{50%{transform:scale(1.08);text-shadow:0 0 11px currentColor}} | |
| @keyframes qualitySpark{50%{transform:translateY(-2px) scale(1.08);text-shadow:0 0 10px currentColor}} | |
| .network-legend{display:flex;gap:20px;flex-wrap:wrap;justify-content:center;font-size:11px;color:#99b3d3;padding:2px 10px 14px;position:relative;z-index:2} | |
| .network-legend strong{color:#d5eaff} | |
| .next-narrative{text-align:center;color:#d9ebff;font-size:13px;line-height:1.55;margin:4px auto 15px;max-width:880px;text-shadow:0 0 12px rgba(98,167,255,.12)} | |
| .next-mode{font-size:11px;color:#70dcc3;border:1px solid #255e5c;padding:6px 11px;border-radius:30px;white-space:nowrap} | |
| @media(max-width:1080px){.orchestra .agents{max-width:1200px;gap:22px 24px;grid-template-columns:repeat(3,minmax(0,1fr))}.orchestra .agent{padding:13px 12px;min-height:170px}.orchestra .agent h3{font-size:15px}} | |
| @media(max-width:700px){.orchestra .agents{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.orchestra .agent{min-height:154px}.orchestra .conductor{max-width:100%}.network-links{display:none}.network-legend{justify-content:flex-start}} | |
| @media(prefers-reduced-motion:reduce){#networkEdges path,#networkEdges path.transfer,.orchestra .conductor,.orchestra .agent .agent-symbol,.orchestra .agent .mini-visual,#agent-data .bars b,#agent-analysis .spark,#agent-insight .rings,#agent-insight .rings span,#agent-verify .checkicons,#agent-report .reportlines,#agent-identity .mini-visual,#agent-history .mini-visual,#agent-privacy .mini-visual,#agent-quality .mini-visual{animation:none!important}.orchestra .agent::after,.orchestra .conductor::before{display:none}} | |
| /* ORCHESTRA NEXT v4 — zichtbare lichtstromen, gebaseerd op de SVG-benadering van de originele Space. | |
| Alleen interface: geen wijziging aan de Excel-import, berekeningen of rapportagefuncties. */ | |
| .orchestra{padding:26px 27px 0;isolation:isolate} | |
| .orchestra .conductor-wrap{margin-bottom:68px;z-index:4} | |
| .orchestra .conductor{overflow:visible;isolation:isolate;min-width:330px;max-width:100%;position:relative;animation:conductorBreathe 4s ease-in-out infinite} | |
| .orchestra .conductor::after{content:"";position:absolute;inset:-8px;border:1px solid rgba(180,128,255,.43);border-radius:19px;box-shadow:0 0 18px rgba(168,112,255,.22);pointer-events:none;z-index:-1;animation:conductorRing 3.2s ease-in-out infinite} | |
| .orchestra .conductor::before{content:"";position:absolute;inset:-17px;border:1px dashed rgba(154,125,255,.24);border-radius:26px;pointer-events:none;opacity:.45;z-index:-1;animation:conductorRing 5.3s ease-in-out infinite reverse} | |
| .orchestra .conductor .conductor-icon{overflow:visible;box-shadow:0 0 27px rgba(193,151,255,.6);position:relative} | |
| .orchestra .conductor .conductor-icon::before{inset:-8px;border-width:2.5px;opacity:1} | |
| .orchestra .conductor .conductor-icon::after{inset:-16px;border-color:#ae86fc;opacity:.7} | |
| .orchestra .conductor .brain-symbol{animation:brainBreath 2.8s ease-in-out infinite} | |
| .orchestra .conductor.running{animation:conductorBreathe 2.5s ease-in-out infinite,conductorPulse 1.3s ease-in-out infinite} | |
| .orchestra .conductor.transfering{border-color:#eee0ff;box-shadow:0 0 48px rgba(180,112,255,.85),0 0 86px rgba(91,204,255,.29),inset 0 0 35px rgba(151,107,255,.34);animation:conductorDispatch .85s ease-out 1,conductorPulse 1.5s infinite} | |
| .orchestra .conductor.transfering .brain-symbol{filter:drop-shadow(0 0 10px #e5bcff);animation:brainBreath .9s ease-in-out infinite} | |
| .orchestra .conductor.transfering .conductor-icon::before{animation-duration:.95s;border-top-color:#fff;border-right-color:#80e9ff} | |
| .orchestra .agents{max-width:none;grid-template-columns:repeat(3,minmax(0,1fr));gap:88px clamp(65px,6vw,110px);padding:15px 13px 28px;margin:0 auto} | |
| .orchestra .agent{min-height:228px;padding:18px 18px 15px;overflow:hidden;transition:transform .28s ease,box-shadow .3s ease,border-color .25s ease} | |
| .orchestra .agent h3{font-size:18px;margin:7px 0 6px} | |
| .orchestra .agent .agent-symbol{margin-top:8px} | |
| .orchestra .agent .agent-steps{display:flex!important;flex-direction:column;gap:6px;min-height:110px;margin:12px 0 0;justify-content:flex-start} | |
| .orchestra .agent .mini-visual{display:none!important} | |
| /* v6: de oorspronkelijke voortgangsbalk terug onder de zichtbare werkstappen */ | |
| .orchestra .agent .agent-bottom{display:flex!important;align-items:center;gap:10px;margin-top:10px;padding-top:9px;border-top:1px solid rgba(135,166,199,.16);position:relative;z-index:3} | |
| .orchestra .agent .agent-bottom .track{display:block;flex:1;min-width:0;height:7px;background:#1c304c;border:1px solid rgba(124,150,190,.16);border-radius:20px;overflow:hidden} | |
| .orchestra .agent .agent-bottom .track i{display:block;width:0%;height:100%;background:var(--accent);border-radius:20px;box-shadow:0 0 10px var(--accent);transition:none} | |
| .orchestra .agent .agent-bottom .percent{display:inline-block;flex:none;width:39px;text-align:right;color:#c8d9f2;font-size:11px;font-variant-numeric:tabular-nums;font-weight:750} | |
| .orchestra .agent.done .agent-bottom .track i{box-shadow:0 0 12px var(--accent)} | |
| @media(max-width:700px){.orchestra .agent .agent-bottom{margin-top:8px;gap:6px}.orchestra .agent .agent-bottom .percent{font-size:10px;width:33px}} | |
| .orchestra .agent .agent-step{font-size:11px;line-height:1.4;min-height:19px;color:#9ab2d1;display:flex;gap:7px;animation:lineAppear .27s ease both} | |
| .orchestra .agent .agent-step .step-mark{width:16px;height:16px;font-size:10px;flex:none;margin-top:1px} | |
| .orchestra .agent .agent-step.current{color:#f4faff} | |
| .orchestra .agent .agent-step.complete{color:#c5efe1} | |
| .orchestra .agent .agent-step.skipped{color:#ffd39b} | |
| .orchestra .agent.active{transform:translateY(-5px);border-color:var(--accent);box-shadow:0 0 31px color-mix(in srgb,var(--accent) 40%,transparent),inset 0 0 24px color-mix(in srgb,var(--accent) 13%,transparent)} | |
| .orchestra .agent.active::after{animation:agentSweep 1.15s ease-in-out infinite;opacity:1} | |
| .orchestra .agent.completed-pulse{animation:agentComplete .55s ease-out 1} | |
| .orchestra .agent .agent-symbol{animation:iconFloat 3.4s ease-in-out infinite} | |
| .network-links{position:absolute;inset:0;z-index:2;overflow:hidden;pointer-events:none} | |
| #networkSvg{display:block;width:100%;height:100%;overflow:hidden} | |
| #networkEdges .network-base{fill:none;stroke:#486f94;stroke-width:2;opacity:.7;vector-effect:non-scaling-stroke;animation:none} | |
| #networkEdges .network-complete{fill:none;stroke:#50cead;stroke-width:2.1;opacity:.47;vector-effect:non-scaling-stroke;animation:none} | |
| #networkEdges .network-active{fill:none;stroke:#83e9ff;stroke-width:3.4;stroke-dasharray:9 14;opacity:1;vector-effect:non-scaling-stroke;animation:networkChase .72s linear infinite;filter:drop-shadow(0 0 6px #5addff)} | |
| #networkEdges .network-active-glow{fill:none;stroke:#80dfff;stroke-width:10;opacity:.21;vector-effect:non-scaling-stroke;filter:blur(3px)} | |
| #networkSignals .network-orb{fill:#e5ffff;stroke:#70eaff;stroke-width:1.5;filter:drop-shadow(0 0 9px #6deeff)} | |
| #networkSignals .network-orb-halo{fill:#6feaff;opacity:.27;filter:blur(4px)} | |
| .network-legend{margin-top:7px} | |
| @keyframes networkChase{to{stroke-dashoffset:-46}} | |
| @keyframes conductorBreathe{50%{filter:brightness(1.16)}} | |
| @keyframes conductorRing{50%{box-shadow:0 0 27px rgba(189,147,255,.56);opacity:.96;transform:scale(1.022)}} | |
| @keyframes conductorDispatch{0%,100%{transform:scale(1)}45%{transform:scale(1.045) translateY(-3px)}} | |
| @keyframes agentComplete{0%{filter:brightness(1)}45%{filter:brightness(1.6)}100%{filter:brightness(1)}} | |
| @keyframes lineAppear{from{opacity:.3;transform:translateY(3px)}to{opacity:1;transform:translateY(0)}} | |
| @media(max-width:1120px){.orchestra .agents{gap:65px 34px}.orchestra .agent{min-height:235px;padding:12px}.orchestra .agent h3{font-size:15px}} | |
| @media(max-width:760px){.orchestra .agents{grid-template-columns:repeat(2,minmax(0,1fr));gap:38px 15px}.orchestra .conductor-wrap{margin-bottom:38px}.orchestra .agent{min-height:248px}.orchestra .agent .agent-step{font-size:10px}.network-links{display:none}} | |
| @media(max-width:470px){.orchestra .agents{grid-template-columns:1fr}.orchestra .agent{min-height:185px}.orchestra .conductor{width:100%;min-width:0}} | |
| /* Neutraliseer oudere pseudo-elementafmetingen; anders verschijnt links een vreemd schuin vlak. */ | |
| .orchestra .conductor::before{inset:-17px!important;left:-17px!important;right:-17px!important;top:-17px!important;bottom:-17px!important;width:auto!important;height:auto!important;background:none!important;transform:none!important;filter:none!important} | |
| .orchestra .conductor::after{inset:-8px!important;left:-8px!important;right:-8px!important;top:-8px!important;bottom:-8px!important;width:auto!important;height:auto!important;background:none!important;transform:none!important;filter:none!important} | |
| @media(prefers-reduced-motion:reduce){.orchestra .conductor,.orchestra .conductor::before,.orchestra .conductor::after,.orchestra .conductor.transfering,.orchestra .agent .agent-symbol,.orchestra .agent .agent-step,.orchestra .agent.active::after,#networkEdges .network-active,.orchestra .agent.completed-pulse{animation:none!important}#networkSignals{display:none}} | |
| /* ORCHESTRA NEXT v5 - echte doorlopende looplichten naar voorbeeld van de originele Space. | |
| Animatie van het SVG stroke-dashoffset werkt op ALLE routepaden, niet alleen de actieve kaart. | |
| Berekeningen, controller-verificatie, Excel-lezer en rapportage blijven ongewijzigd. */ | |
| #networkEdges path.network-active{ | |
| fill:none; | |
| stroke:#69e5ff; | |
| stroke-width:2.85; | |
| stroke-linecap:round; | |
| stroke-dasharray:9 18; | |
| opacity:.33; | |
| vector-effect:non-scaling-stroke; | |
| animation:none; | |
| filter:none; | |
| } | |
| .orchestra.running #networkEdges path.network-active{ | |
| stroke:#77e8ff; | |
| stroke-width:3.1; | |
| opacity:.73; | |
| animation:orchestraV5Chase 1.25s linear infinite; | |
| filter:none; | |
| } | |
| .orchestra.running #networkEdges path.network-current{ | |
| stroke:#d4ffff; | |
| stroke-width:4.1; | |
| stroke-dasharray:10 17; | |
| opacity:1; | |
| animation:orchestraV5Chase 1s linear infinite; | |
| filter:drop-shadow(0 0 7px #62dfff) drop-shadow(0 0 11px rgba(98,223,255,.55)); | |
| } | |
| @keyframes orchestraV5Chase{ | |
| from{stroke-dashoffset:0} | |
| to{stroke-dashoffset:-54} | |
| } | |
| /* De oude Space animeerde zijn flowline tijdens Start ook als Windows 'minder beweging' | |
| aan stond. Voor het door de gebruiker expliciet gestarte proces behouden we dat gedrag; | |
| overige overgangen en decoratieve bewegingen respecteren de systeeminstelling. */ | |
| @media (prefers-reduced-motion:reduce){ | |
| .orchestra.running #networkEdges path.network-active{ | |
| animation:orchestraV5Chase 1.25s linear infinite !important; | |
| } | |
| .orchestra.running #networkEdges path.network-current{ | |
| animation:orchestraV5Chase 1s linear infinite !important; | |
| } | |
| } | |
| /* ORCHESTRA NEXT v7 — direct dashboard en toekomstige AI Insight Studio */ | |
| .dashboard-top-button{background:linear-gradient(110deg,#16725f,#1585a7);border:1px solid #39a5a5;box-shadow:0 5px 19px rgba(27,184,171,.18);white-space:nowrap} | |
| .dashboard-top-button:disabled{opacity:.45;filter:grayscale(.3);box-shadow:none;cursor:not-allowed} | |
| .insight-studio{padding:19px 18px 18px;position:relative;overflow:hidden;border-color:#365477;background:radial-gradient(circle at 10% 0%,rgba(28,111,160,.26),transparent 46%),linear-gradient(145deg,#112440,#0a172b)} | |
| .insight-studio::after{content:'';position:absolute;right:-65px;top:-75px;width:180px;height:180px;border:1px solid #4ebfdd20;border-radius:50%;box-shadow:0 0 0 22px #4ebfdd0a,0 0 0 46px #4ebfdd08;pointer-events:none} | |
| .insight-heading{display:flex;gap:13px;align-items:center;margin-bottom:14px;position:relative;z-index:1} | |
| .insight-symbol{width:47px;height:47px;display:grid;place-items:center;flex:none;font-size:27px;color:#b3f1ff;border:1px solid #4a96cf;border-radius:12px;background:#152d50;box-shadow:0 0 22px #3bc9e533} | |
| .insight-heading h3{font-size:19px;margin:4px 0;color:#ecf9ff;letter-spacing:.3px} | |
| .insight-heading p{font-size:11px;line-height:1.45;color:#a6bfdd;margin:0} | |
| .insight-state{font-size:10px;color:#9fc7df;background:#122d40;border:1px solid #296178;border-radius:8px;padding:9px 11px;line-height:1.5} | |
| .insight-state-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#7ad8f0;margin-right:6px;box-shadow:0 0 8px #65d9f6} | |
| .insight-intro{font-size:12px;color:#d6eaff;margin:18px 0 11px;font-weight:600} | |
| .insight-questions{display:flex;flex-direction:column;gap:9px} | |
| .insight-question{display:flex;gap:11px;align-items:start;border:1px solid #2c4767;background:linear-gradient(110deg,#152944,#10223b);border-radius:10px;padding:12px 11px;transition:border-color .2s,transform .2s} | |
| .insight-question:hover{border-color:var(--insight);transform:translateX(2px)} | |
| .insight-q-icon{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:none;background:color-mix(in srgb,var(--insight) 12%,#12213a);color:var(--insight);font-size:19px;border:1px solid color-mix(in srgb,var(--insight) 40%,#243953)} | |
| .insight-question strong{display:block;color:#eef6ff;font-size:12px;margin:1px 0 4px} | |
| .insight-question p{font-size:11px;line-height:1.48;color:#b4cce6;margin:0} | |
| .insight-note{font-size:10px;line-height:1.55;color:#a0b6d4;margin-top:15px;border-top:1px solid #2c4462;padding-top:12px} | |
| @media(max-width:900px){.topbar .actions{flex-wrap:wrap;justify-content:flex-end}} | |
| /* v8 — hergebruik van bestaande Reakt-monitor grafieken in de linkerkolom. */ | |
| .inline-report-charts{display:grid;grid-template-columns:minmax(0,1fr);gap:15px;margin-top:22px;padding-top:18px;border-top:1px solid #2a4667} | |
| .inline-chart-card{border:1px solid #355374;border-radius:10px;background:linear-gradient(145deg,#132944,#102036);padding:12px;min-width:0} | |
| .inline-chart-card h4{font-size:12px;line-height:1.35;color:#e7f4ff;margin:0 0 10px;font-weight:750} | |
| .inline-chart-card h4 small{font-size:10px;font-weight:400;color:#a8bcd3} | |
| .inline-chart-svg{height:188px;background:linear-gradient(180deg,#0f1f35,#0b1728);border:1px solid #294766;border-radius:7px;padding:7px;overflow:hidden;box-shadow:inset 0 0 0 1px rgba(120,171,220,.06)} | |
| .inline-chart-svg svg{display:block;width:100%;height:100%} | |
| .inline-chart-empty{border:1px dashed #385473;border-radius:9px;color:#9fb8d4;font-size:11px;line-height:1.5;padding:15px} | |
| .inline-chart-foot{color:#8eaaca;font-size:10px;line-height:1.4;margin-top:10px} | |
| /* v9 · Region Intelligence — nieuwe laag; de originele locatieberekeningen blijven intact. */ | |
| .topbar .actions{align-items:center;flex-wrap:wrap;justify-content:flex-end} | |
| .region-top-label{display:flex;align-items:center;gap:8px;color:#9ec8e6;font-size:10px;font-weight:800;letter-spacing:1.2px} | |
| #regionSelect{max-width:160px;min-width:132px;background:#102944;border:1px solid #44779a;border-radius:7px;padding:9px 7px;color:#e5f5ff;font:inherit;font-size:12px;letter-spacing:0;cursor:pointer} | |
| .region-dashboard-button{background:linear-gradient(110deg,#1c7d7b,#2863b2);border:1px solid #48a3aa;color:#f5fcff;white-space:nowrap;box-shadow:0 4px 18px #1b7c8c2b} | |
| .region-dashboard-button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none} | |
| #reportSheet.region-sheet{--purple:#6027cb;--navy:#0b164d} | |
| .region-sheet .region-wordmark{display:flex;align-items:baseline;gap:18px;margin:0 0 9px} | |
| .region-sheet .region-reakt{color:#fb5c13;font-size:47px;letter-spacing:-2.2px;font-weight:850;line-height:1} | |
| .region-sheet .region-wordmark>small{color:#f47639;font-size:14px} | |
| .region-sheet .region-title-tag{display:inline-block;padding:5px 10px;background:#f3edff;border-radius:6px;color:#5f2bbf;font-size:11px;font-weight:800;letter-spacing:.7px} | |
| .region-sheet .region-heading-title{font-size:32px;font-weight:850;color:#111957;letter-spacing:-.7px;margin:8px 0 2px} | |
| .region-sheet .region-heading-sub{margin:0 0 10px;font-size:12px;color:#465778} | |
| .region-sheet .region-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:12px} | |
| .region-sheet .region-meta>span{background:#f7f9ff;border:1px solid #dee6f5;border-radius:6px;color:#334365;padding:7px 11px;font-size:11px} | |
| .region-sheet .region-cover-warning{padding:10px 12px;border:1px solid #f4c88d;background:#fff7eb;color:#814b11;border-radius:7px;margin:9px 0;font-size:11px;line-height:1.5} | |
| .region-sheet .region-rule{padding:9px 12px;border:1px solid #dcd5f2;background:#f8f6ff;color:#242d5c;border-radius:7px;font-size:11px;line-height:1.45;margin:9px 0} | |
| .region-sheet .region-rule strong{color:#5822c7} | |
| .region-sheet .region-panel-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-top:10px} | |
| .region-sheet .region-panel-grid .report-panel{min-height:215px} | |
| .region-sheet .region-panel-grid .chart-box{height:168px} | |
| .region-sheet .region-detail-grid{display:grid;grid-template-columns:1.08fr 1.45fr;gap:10px;margin-top:10px;align-items:stretch} | |
| .region-sheet .region-detail-grid .report-panel{min-width:0} | |
| .region-sheet .region-comparison-svg{width:100%;height:200px;max-height:230px} | |
| .region-sheet .region-comparison-svg svg{display:block;width:100%;height:100%} | |
| .region-sheet .region-table{width:100%;border-collapse:collapse;font-size:10px;color:#202954;font-variant-numeric:tabular-nums} | |
| .region-sheet .region-table th{text-align:right;border-bottom:2px solid #ebe6f5;background:#f8f7ff;padding:8px 4px;color:#342a80;font-weight:800} | |
| .region-sheet .region-table th:first-child,.region-sheet .region-table td:first-child{text-align:left} | |
| .region-sheet .region-table td{text-align:right;padding:8px 4px;border-bottom:1px solid #eaecf3} | |
| .region-sheet .region-table tr:last-child td{background:#f0edfa;border-top:2px solid #dcd1f3;font-weight:800;color:#4d258f} | |
| .region-sheet .region-manager-note{display:grid;grid-template-columns:1fr 1fr;gap:11px;margin-top:10px} | |
| .region-sheet .region-manager-note>div{padding:11px 13px;background:#f7f9fd;border:1px solid #e0e6f1;border-radius:7px;color:#324062;font-size:11px;line-height:1.55} | |
| .region-sheet .region-manager-note strong{display:block;margin-bottom:5px;color:#131c68} | |
| .region-sheet .region-average-number{font-size:29px;line-height:1.3;font-weight:850;color:#5822c7;margin:3px 0 7px;font-variant-numeric:tabular-nums} | |
| .region-sheet .region-average-number span{font-size:12px;font-weight:650;color:#465579} | |
| .region-sheet .region-small{color:#5f6a8a;font-size:10px;line-height:1.5} | |
| @media(max-width:1160px){.region-sheet .region-panel-grid{grid-template-columns:1fr 1fr}.region-sheet .region-detail-grid{grid-template-columns:1fr}.region-sheet .region-comparison-svg{height:220px}} | |
| @media(max-width:720px){.region-sheet .region-panel-grid,.region-sheet .region-manager-note{grid-template-columns:1fr}.region-sheet .region-wordmark{display:block}.region-sheet .region-heading-title{font-size:24px}} | |
| @media print{ | |
| #reportSheet.region-sheet{padding:2mm 3mm!important} | |
| .region-sheet .region-reakt{font-size:31px!important} | |
| .region-sheet .region-wordmark{margin-bottom:4px!important} | |
| .region-sheet .region-heading-title{font-size:23px!important;margin:3px 0!important} | |
| .region-sheet .region-heading-sub{font-size:9px!important;margin:2px 0 5px!important} | |
| .region-sheet .region-meta{margin-bottom:5px!important;gap:4px!important} | |
| .region-sheet .region-meta>span{padding:4px 7px!important;font-size:8px!important} | |
| .region-sheet .region-rule,.region-sheet .region-cover-warning{font-size:8px!important;padding:5px 7px!important;margin:4px 0!important} | |
| .region-sheet .region-panel-grid{grid-template-columns:1fr 1fr 1fr!important;gap:6px!important;margin-top:6px!important} | |
| .region-sheet .region-panel-grid .report-panel{min-height:137px!important} | |
| .region-sheet .region-panel-grid .chart-box{height:109px!important} | |
| .region-sheet .region-detail-grid{grid-template-columns:1.08fr 1.45fr!important;gap:6px!important;margin-top:6px!important} | |
| .region-sheet .region-comparison-svg{height:153px!important} | |
| .region-sheet .region-table{font-size:7px!important} | |
| .region-sheet .region-table th,.region-sheet .region-table td{padding:4px 2px!important} | |
| .region-sheet .region-manager-note{grid-template-columns:1fr 1fr!important;gap:6px!important;margin-top:6px!important} | |
| .region-sheet .region-manager-note>div{padding:6px!important;font-size:8px!important} | |
| .region-sheet .region-small{font-size:7px!important} | |
| } | |
| /* v9: Regionale PDF op één pagina, zonder losse bronvoet op vervolgpagina. */ | |
| @media print { | |
| #reportSheet.region-sheet { break-inside:avoid!important; page-break-inside:avoid!important; } | |
| .region-sheet .region-manager-note { break-inside:avoid!important; page-break-inside:avoid!important; } | |
| } | |
| </style></head> | |
| <body> | |
| <div class="app"> | |
| <main class="main"> | |
| <header><div class="header-copy"><div class="eyebrow">REAKT / AI ANALYTICS PLATFORM</div><h1>Inloop <span>Intelligence</span> <em>ORCHESTRA</em></h1><p>Maak complexe informatie begrijpelijk en toegankelijk voor iedereen.</p></div><div class="orchestra-hero-title" aria-label="ORCHESTRA by Marcel Ooms"><span class="orchestra-hero-word">ORCHESTRA</span><span class="orchestra-hero-credit">by Marcel Ooms</span></div><div class="header-center-logo"><div class="reakt-wordmark" role="img" aria-label="Reakt — Werken aan je toekomst"><strong class="reakt-name">Reakt</strong><span class="reakt-tagline">Werken aan je toekomst</span></div></div><div class="header-actions"><span class="pill"><i></i> LOKALE ANALYSE</span><span class="clock" id="clock"></span></div></header> | |
| <div class="topbar"><div><span class="section-kicker">CONTROL CENTER</span><h2>Agent Command Center <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 voorbeeldanalyse</button><button id="exportBtn" class="dashboard-top-button" disabled title="Analyseer eerst één locatie">▣ Open dashboard</button><label class="region-top-label" for="regionSelect">REGIO <select id="regionSelect" aria-label="Kies een regio"><option value="rijnmond">Rijnmond</option><option value="midden-holland">Midden-Holland</option><option value="haaglanden">Haaglanden</option><option value="noord-holland">Noord-Holland</option></select></label><button id="regionReportBtn" class="region-dashboard-button" type="button" disabled title="Laad eerst een Excelbestand met gegevens uit deze regio">▣ Open regiodashboard</button></div></div> | |
| <section class="import-panel panel" aria-label="Lokale Excelanalyse met 2026 en historische referentie 2025"> | |
| <div class="import-intro"><span class="section-kicker">ORCHESTRA NEXT · BROWSER-LOKAAL</span><strong>Scanregistratie 2026 + referentie 2025</strong><small>Browser-lokale analyse · alle bronlocaties · geen bestanden in Space-repository</small></div> | |
| <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" /> | |
| <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/> | |
| <label class="site-label" for="siteSelect">Locatie</label><select id="siteSelect" disabled><option value="*">Alle locaties</option></select> | |
| <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> | |
| <span id="importState" class="import-state">Kies eerst het rapportagebestand 2026</span> | |
| <div id="historyStatus" class="history-status">◴ Historie 2025 nog niet geladen. Zonder deze referentie zijn instroomcijfers voorlopig.</div> | |
| </section> | |
| <section class="orchestra panel"> | |
| <div class="mesh"></div><p class="next-narrative">Van ruwe scangegevens naar helder inzicht · volg taakverdeling, gegevensoverdracht en controles onder de motorkap.</p><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> | |
| <div class="network-links" aria-hidden="true"><svg id="networkSvg" preserveAspectRatio="none"><g id="networkEdges"></g><g id="networkSignals"></g></svg></div> | |
| <div class="agents"> | |
| <article class="agent" id="agent-data" style="--accent:#34e6b3"><div class="agent-top"><span>01 / DATA</span><span class="status">STANDBY</span></div><div class="agent-symbol">▤</div><h3>Data Agent</h3><p>Structuur & kwaliteit</p><div class="agent-steps" aria-live="polite" aria-label="Werkstappen"></div><div class="mini-visual bars"><b></b><b></b><b></b><b></b><b></b><b></b><b></b></div><div class="agent-bottom"><div class="track"><i></i></div><span class="percent">0%</span></div></article> | |
| <article class="agent" id="agent-analysis" style="--accent:#ffcb62"><div class="agent-top"><span>04 / ANALYSE</span><span class="status">STANDBY</span></div><div class="agent-symbol">⌁</div><h3>Analysis Agent</h3><p>Trends & vergelijkingen</p><div class="agent-steps" aria-live="polite" aria-label="Werkstappen"></div><div class="mini-visual spark">▁ ▃ ▂ ▅ ▄ ▆ ▇</div><div class="agent-bottom"><div class="track"><i></i></div><span class="percent">0%</span></div></article> | |
| <article class="agent" id="agent-insight" style="--accent:#51b9ff"><div class="agent-top"><span>06 / TREND</span><span class="status">STANDBY</span></div><div class="agent-symbol">✦</div><h3>Trend Agent</h3><p>Maandpatronen & duiding</p><div class="agent-steps" aria-live="polite" aria-label="Werkstappen"></div><div class="mini-visual rings">◎ <span>◉</span> ◎</div><div class="agent-bottom"><div class="track"><i></i></div><span class="percent">0%</span></div></article> | |
| <article class="agent" id="agent-verify" style="--accent:#b18aff"><div class="agent-top"><span>07 / CONTROLE</span><span class="status">STANDBY</span></div><div class="agent-symbol">⬡</div><h3>Verification Agent</h3><p>Schaduwberekeningen</p><div class="agent-steps" aria-live="polite" aria-label="Werkstappen"></div><div class="mini-visual checkicons">✓ ✓ ! ✓</div><div class="agent-bottom"><div class="track"><i></i></div><span class="percent">0%</span></div></article> | |
| <article class="agent" id="agent-report" style="--accent:#ff9a65"><div class="agent-top"><span>09 / RAPPORT</span><span class="status">STANDBY</span></div><div class="agent-symbol">▧</div><h3>Report Agent</h3><p>Infographic & export</p><div class="agent-steps" aria-live="polite" aria-label="Werkstappen"></div><div class="mini-visual reportlines">▰▰▰▰▰<br>▰▰▰▰<br>▰▰▰</div><div class="agent-bottom"><div class="track"><i></i></div><span class="percent">0%</span></div></article> | |
| <article class="agent" id="agent-identity" style="--accent:#53d5ec"><div class="agent-top"><span>02 / IDENTITEIT</span><span class="status">STANDBY</span></div><div class="agent-symbol">⛓</div><h3>Identity Agent</h3><p>Pas- en cliëntkoppelingen</p><div class="agent-steps" aria-live="polite"></div><div class="mini-visual">◎ ↔ ◎</div><div class="agent-bottom"><div class="track"><i></i></div><span class="percent">0%</span></div></article> | |
| <article class="agent" id="agent-history" style="--accent:#60bafa"><div class="agent-top"><span>03 / HISTORIE</span><span class="status">STANDBY</span></div><div class="agent-symbol">◷</div><h3>History Agent</h3><p>Referentie 2025</p><div class="agent-steps" aria-live="polite"></div><div class="mini-visual">2025 ↔ 2026</div><div class="agent-bottom"><div class="track"><i></i></div><span class="percent">0%</span></div></article> | |
| <article class="agent" id="agent-privacy" style="--accent:#f28cbd"><div class="agent-top"><span>08 / PRIVACY</span><span class="status">STANDBY</span></div><div class="agent-symbol">◇</div><h3>Privacy Agent</h3><p>Lokale verwerkingsroute</p><div class="agent-steps" aria-live="polite"></div><div class="mini-visual">◈ ⛨ ◈</div><div class="agent-bottom"><div class="track"><i></i></div><span class="percent">0%</span></div></article> | |
| <article class="agent" id="agent-quality" style="--accent:#ea88e9"><div class="agent-top"><span>10 / KWALITEIT</span><span class="status">STANDBY</span></div><div class="agent-symbol">✧</div><h3>Quality Agent</h3><p>Rapportagestatus</p><div class="agent-steps" aria-live="polite"></div><div class="mini-visual">✓ / !</div><div class="agent-bottom"><div class="track"><i></i></div><span class="percent">0%</span></div></article> | |
| </div> | |
| <div class="orchestra-footer"><span><i class="dot green"></i> Systeem gereed</span><span id="flowState">Wacht op startcommando</span><span>◌ <span id="completed">0</span> / 9 agenten voltooid</span></div> | |
| </section> | |
| <div class="content-grid"> | |
| <div class="left-content"> | |
| <div class="kpi-grid" id="kpis"></div> | |
| <section class="definition-guide" aria-label="Betekenis van de cliëntcijfers"> | |
| <div class="definition-guide-heading"><span class="definition-guide-icon">ⓘ</span><strong>Wat betekenen deze cliëntcijfers?</strong><span>Rekenregels per inlooplocatie</span></div> | |
| <div class="definition-guide-grid"> | |
| <div><b>Unieke cliënten</b><p>Iedere cliënt die op deze locatie binnen de gekozen rapportageperiode minimaal één geldige scan heeft.</p></div> | |
| <div><b>Nieuwe instroom</b><p>Cliënten met hun allereerste geldige scan op deze locatie in de gekozen rapportageperiode.</p></div> | |
| <div><b>Actieve cliënten</b><p>Cliënten die in de afgelopen drie maanden t/m de peildatum minimaal één keer op deze locatie hebben gescand.</p></div> | |
| </div> | |
| <div class="definition-guide-note">Een verloren of vervangen scanpas telt niet als een nieuwe cliënt: meerdere passen van dezelfde cliënt worden samengevoegd.</div> | |
| </section> | |
| <div class="charts-grid"><section class="panel chart-panel"><div class="panel-heading"><div><span class="section-kicker">TRENDANALYSE</span><h3>Bezoeken per locatie</h3></div><span class="subtle">Jul – Sep 2026</span></div><canvas id="chart" width="640" height="250" aria-label="Grafiek fictieve bezoeken per locatie"></canvas><div class="legend"><span><i style="background:#3ee1b6"></i> Soeda</span><span><i style="background:#ffc66a"></i> Rietdijk</span><span><i style="background:#5b9cff"></i> Vredehof</span><span><i style="background:#ad88ff"></i> Gouda</span></div><div id="inlineReportCharts" class="inline-report-charts" aria-live="polite"><div class="inline-chart-empty">Na de Excelanalyse verschijnen hier drie bestaande grafieken uit de witte Reakt-monitor.</div></div></section> | |
| <section class="panel verification"><div class="panel-heading"><div><span class="section-kicker">KWALITEITSBORGING</span><h3>Verificatie Businesscontroller</h3></div><span class="warn-label">DEMO</span></div><div id="checks" class="checks"></div><div class="warning">⚠ 1 fictieve afwijking. Rapport niet gevalideerd.</div></section></div> | |
| <section class="panel controller-panel" id="controllerPanel" aria-label="Vergelijking met businesscontroller"> | |
| <div class="panel-heading"><div><span class="section-kicker">EXTERNE SCHADUWCONTROLE · FICTIEVE TEST</span><h3>Vergelijk met businesscontroller</h3></div><span class="warn-label" id="controllerBadge">NIET AANGELEVERD</span></div> | |
| <p class="controller-note">Vul uitsluitend <strong>fictieve, geaggregeerde</strong> controlecijfers in voor exact dezelfde locatie, periode en definities. Geen cliëntgegevens. Invoer blijft in deze browserpagina en wordt niet opgeslagen.</p> | |
| <div class="controller-context" id="controllerContext">Analyseer eerst het fictieve Excel-bestand en kies een locatie en kwartaal.</div> | |
| <div class="controller-entry" id="controllerFields"> | |
| <label>Unieke cliënten (periode)<input id="ctrlUnique" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 18"></label> | |
| <label>Nieuwe instroom (periode)<input id="ctrlIntake" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 14"></label> | |
| <label>Actieve cliënten (3 maanden)<input id="ctrlActive" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 17"></label> | |
| <label>Unieke bezoekdagen (periode)<input id="ctrlVisits" type="number" min="0" step="1" inputmode="numeric" placeholder="Bijv. 49"></label> | |
| </div> | |
| <div class="controller-actions"><button id="controllerCompare" class="primary" type="button">↔ Vergelijk cijfers</button><button id="controllerClear" class="ghost" type="button">Wissen</button></div> | |
| <div id="controllerResult" class="controller-result" aria-live="polite">Nog geen onafhankelijke controlecijfers vergeleken.</div> | |
| <div class="controller-footer">Interne controles en vergelijking met ingevoerde waarden zijn afzonderlijk. Een overeenkomst geldt niet als formele validatie of vrijgave.</div> | |
| </section> | |
| <section class="panel logpanel"><div class="panel-heading"><div><span class="section-kicker">REAL-TIME PROCESS</span><h3>Agent activity log</h3></div><span class="subtle" id="logStatus">Stand-by</span></div><div class="logs" id="logs"><div class="logitem muted">◌ Klik op ‘Start demo-analyse’ om de workflow te bekijken.</div></div></section> | |
| </div> | |
| <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> | |
| <section class="panel insight-studio" aria-label="AI Insight Studio, toekomstige functionaliteit"> | |
| <div class="insight-heading"><div class="insight-symbol">✧</div><div><span class="section-kicker">TOEKOMST · ORCHESTRA NEXT</span><h3>AI INSIGHT STUDIO</h3><p>Van cijfers naar nieuwe vragen en beter onderbouwde inzichten.</p></div></div> | |
| <div class="insight-state"><span class="insight-state-dot"></span> Concept voor toekomstige uitbreiding · momenteel geen AI-API aangesloten</div> | |
| <div class="insight-intro">Welke vragen zou je straks aan ORCHESTRA kunnen stellen?</div> | |
| <div class="insight-questions"> | |
| <div class="insight-question" style="--insight:#59e0c1"><span class="insight-q-icon">↗</span><div><strong>Ontwikkelingen</strong><p>Welke locaties laten de grootste veranderingen in bezoekersaantallen zien?</p></div></div> | |
| <div class="insight-question" style="--insight:#5fcafa"><span class="insight-q-icon">◎</span><div><strong>Nieuwe instroom</strong><p>Hoe ontwikkelt de instroom zich per kwartaal ten opzichte van eerdere perioden?</p></div></div> | |
| <div class="insight-question" style="--insight:#bb91ff"><span class="insight-q-icon">◈</span><div><strong>Opvallende patronen</strong><p>Welke veranderingen in bezoekfrequentie en actieve cliënten verdienen nader onderzoek?</p></div></div> | |
| <div class="insight-question" style="--insight:#ffbd77"><span class="insight-q-icon">⬡</span><div><strong>Datakwaliteit</strong><p>Welke bronafwijkingen kunnen KPI's beïnvloeden en wat blijft onzeker?</p></div></div> | |
| <div class="insight-question" style="--insight:#f090c6"><span class="insight-q-icon">▤</span><div><strong>Managementsamenvatting</strong><p>Welke ontwikkelingen, aandachtspunten en beperkingen kunnen we feitelijk rapporteren?</p></div></div> | |
| </div> | |
| <div class="insight-note">♢ Inspirerende voorbeeldvragen, geen actieve AI-analyse. Een toekomstige API-koppeling vraagt vooraf een privacy- en informatiebeveiligingsbeoordeling. Geen cliëntgegevens naar externe AI-diensten.</div> | |
| </section></aside> | |
| </div> | |
| <footer>REAKT INLOOP INTELLIGENCE <span>ORCHESTRA PROTOTYPE · LOKALE VERWERKING · CONTROLEER CIJFERS VÓÓR PUBLICATIE</span></footer> | |
| </main> | |
| </div> | |
| <div id="reportOverlay" role="dialog" aria-modal="true" aria-label="Reakt Monitor rapport" aria-hidden="true"> | |
| <div class="report-actions"><span><strong>REAKT · Rapportvoorvertoning v1.6.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> | |
| <section id="reportSheet" aria-label="Witte Inloop Monitor"></section> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script> | |
| <script type="application/json" id="orchestra-demo-data">{"mode": "DEMO", "period": "Q3 2026 · fictief", "kpis": [{"label": "Bezoeken", "value": "12.480", "change": "+8,4%"}, {"label": "Unieke bezoekers", "value": "1.286", "change": "+3,2%"}, {"label": "Nieuwe instroom", "value": "164", "change": "+5,1%"}, {"label": "Verificatie", "value": "4 / 5", "change": "1 afwijking"}], "locations": [{"name": "Soeda", "visits": [350, 395, 428], "unique": 121}, {"name": "Rietdijk", "visits": [285, 305, 334], "unique": 108}, {"name": "Vredehof", "visits": [245, 268, 294], "unique": 93}, {"name": "Gouda", "visits": [320, 343, 365], "unique": 115}], "checks": [{"label": "Bezoeken", "ours": 2845, "shadow": 2845}, {"label": "Unieke bezoekers", "ours": 386, "shadow": 381}, {"label": "Nieuwe instroom", "ours": 42, "shadow": 42}, {"label": "Bezoeken vorig jaar", "ours": 2610, "shadow": 2610}, {"label": "Actieve locaties", "ours": 4, "shadow": 4}], "steps": [{"agent": "data", "text": "Demo-gegevens ingelezen en structuur gecontroleerd"}, {"agent": "analysis", "text": "Maandtrends en locatievergelijkingen berekend"}, {"agent": "insight", "text": "Opvallende ontwikkelingen samengevat (demo)"}, {"agent": "verify", "text": "Schaduwberekeningen vergeleken: 1 afwijking"}, {"agent": "report", "text": "Conceptinfographic voorbereid · NIET GEVALIDEERD"}]}</script> | |
| <script> | |
| window.addEventListener('error', function(event){ | |
| var status=document.getElementById('flowState'); | |
| if(status) status.textContent='Scriptfout – meld dit bij de ontwikkelaar'; | |
| console.error('ORCHESTRA scriptfout:', event.message); | |
| }); | |
| ; | |
| let demoData=null, running=false, completed=0; | |
| const $=id=>document.getElementById(id); | |
| const ids=['data','identity','history','analysis','insight','verify','privacy','report','quality']; | |
| const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)); | |
| const escapeHTML=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| function clock(){ $('clock').textContent=new Intl.DateTimeFormat('nl-NL',{hour:'2-digit',minute:'2-digit',day:'numeric',month:'short'}).format(new Date()); } | |
| clock();setInterval(clock,60000); | |
| 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('');} | |
| 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')} <strong class="${delta?'bad':''}">${delta?'⚠ '+(delta>0?'+':'')+delta:'✓ 0'}</strong></span></div>`}).join('');} | |
| 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+(loc.visits.length===1?0:j*(w-105)/(loc.visits.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+(loc.visits.length===1?0:j*(w-105)/(loc.visits.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+(3===1?0:i*(w-105)/2),239));} | |
| 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>`)} | |
| // De lichtpunten volgen de vijf bestaande SVG-verbindingen. Ze lopen alleen tijdens een actieve processtap. | |
| 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'}; | |
| const signalColors={data:'#34e6b3',analysis:'#ffcb62',insight:'#51b9ff',verify:'#b18aff',report:'#ff9a65'}; | |
| function clearSignal(){const layer=document.querySelector('.signal-layer');if(layer)layer.replaceChildren();} | |
| function signalFor(agent){ | |
| clearSignal(); | |
| const layer=document.querySelector('.signal-layer'); | |
| if(!layer||!signalPaths[agent]||window.matchMedia('(prefers-reduced-motion: reduce)').matches)return; | |
| const ns='http://www.w3.org/2000/svg'; | |
| for(const [radius,cls,delay] of [[7,'signal-halo','0s'],[3.2,'signal-particle','0s']]){ | |
| const circle=document.createElementNS(ns,'circle');circle.setAttribute('r',radius);circle.setAttribute('class',cls);circle.setAttribute('fill',signalColors[agent]); | |
| const motion=document.createElementNS(ns,'animateMotion');motion.setAttribute('path',signalPaths[agent]);motion.setAttribute('dur','1.35s');motion.setAttribute('repeatCount','indefinite');motion.setAttribute('begin',delay); | |
| circle.appendChild(motion);layer.appendChild(circle); | |
| } | |
| } | |
| function reset(){if(running)return;clearStages();clearSignal();$('conductor').classList.remove('attention');completed=0;$('completed').textContent='0';$('conductorStatus').textContent='Gereed voor analyse';$('flowState').textContent='Wacht op startcommando';$('logStatus').textContent='Stand-by';$('logs').innerHTML='<div class="logitem muted">◌ Klik op ‘Start demo-analyse’ om de workflow te bekijken.</div>';$('orchestra-root');document.querySelector('.orchestra').classList.remove('running');$('conductor').classList.remove('running');ids.forEach(id=>{let e=$('agent-'+id);e.classList.remove('active','done');e.querySelector('.status').textContent='STANDBY';e.querySelector('.track i').style.width='0%';e.querySelector('.percent').textContent='0%'});} | |
| // v0.5: voortgang is een presentatielaag, geen indicatie van de rekentijd. | |
| function paceDelay(){return $('paceSelect').value==='fast'?170:1050;} | |
| function stepDescriptions(agent, result, fake){ | |
| const n=v=>Number(v).toLocaleString('nl-NL'); | |
| const sites=result?.sites??0, rows=result?.rows??0, repeat=result?.duplicates??0; | |
| const provisional=result?.visits??0, unique=result?.unique??0; | |
| if(agent==='data')return fake?['Demobron geladen','4 voorbeeldlocaties','Fictieve scanregels','Democontrole afgerond','Overdracht aan analyse']:[ | |
| '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']; | |
| if(agent==='analysis')return fake?['Voorbeeld-KPI’s geladen','Locaties vergeleken','Trends weergegeven','Groei samengevat','Overdracht aan inzicht']:[ | |
| 'Dubbele scans uitgesloten',`${n(unique)} unieke cliënten in periode`,`${n(result.newIntake)} instromers in periode`,`${n(result.active)} actieve cliënten`, 'Overdracht aan Insight Agent']; | |
| if(agent==='insight')return fake?['Voorbeeldtrend gelezen','Locatieverschillen bekeken','Fictieve inzichten verzameld','Conceptduiding opgesteld','Overdracht aan verificatie']:[ | |
| 'Maandgegevens beschikbaar','Locaties in grafiek gezet','Signalen zonder causaliteit','AI-duiding niet aangesloten','Overdracht aan controle']; | |
| if(agent==='verify')return fake?['Demovergelijking geopend','5 indicatoren bekeken','1 fictieve afwijking','Rapport niet gevalideerd','Overdracht met waarschuwing']:[ | |
| 'Tweede telroute vanaf bronregels',`${result?.verification?.checks.length??0} onafhankelijke vergelijkingen`,`${result?.verification?.mismatches??0} interne telverschillen`,`${result?.verification?.sourceIssues.length??0} soorten bronproblemen`,'Extern niet gevalideerd']; | |
| return fake?['Concept gestart','Voorbeeldcijfers verwerkt','Illustratie bijgewerkt','Niet gevalideerd gemarkeerd','Concept gereed']:[ | |
| 'Concept gestart','KPI-preview bijgewerkt','Instroom op eerste scan berekend','Niet gevalideerd gemarkeerd','Concept gereed']; | |
| } | |
| function showStages(agent,steps){ | |
| const box=$('agent-'+agent);box.classList.add('has-steps');const wrap=box.querySelector('.agent-steps');wrap.replaceChildren(); | |
| steps.forEach((str,i)=>{const d=document.createElement('div');d.className='agent-step';d.innerHTML='<span class="step-mark">○</span><span class="step-label"></span>';d.querySelector('.step-label').textContent=str;wrap.appendChild(d)}); | |
| } | |
| async function animateStages(agent,steps){ | |
| const el=$('agent-'+agent),items=[...el.querySelectorAll('.agent-step')]; | |
| const delay=paceDelay(); | |
| for(let i=0;i<items.length;i++){ | |
| items[i].classList.add('current');items[i].querySelector('.step-mark').textContent='◌'; | |
| $('flowState').textContent=steps[i]; | |
| await sleep(delay); | |
| items[i].classList.remove('current');items[i].classList.add('complete');items[i].querySelector('.step-mark').textContent='✓'; | |
| el.querySelector('.track i').style.width=Math.round((i+1)*100/items.length)+'%'; | |
| el.querySelector('.percent').textContent=Math.round((i+1)*100/items.length)+'%'; | |
| } | |
| } | |
| function clearStages(){ids.forEach(id=>{const el=$('agent-'+id);el.classList.remove('has-steps');el.querySelector('.agent-steps').replaceChildren()})} | |
| async function start(){if(importedData)return startImported();if(running)return;clearStages();clearSignal();$('conductor').classList.remove('attention');running=true;$('startBtn').disabled=true;$('resetBtn').disabled=true;completed=0;ids.forEach(id=>{const e=$('agent-'+id);e.classList.remove('done','active');e.querySelector('.status').textContent='STANDBY';e.querySelector('.track i').style.width='0%';e.querySelector('.percent').textContent='0%'});$('logs').innerHTML='';$('completed').textContent='0';$('conductor').classList.add('running');document.querySelector('.orchestra').classList.add('running');$('conductorStatus').textContent='Workflow wordt aangestuurd';$('logStatus').textContent='Actief';log('Dirigent toont de gesimuleerde workflow stap voor stap');try{const response=await Promise.resolve({ok:true,json:async()=>JSON.parse(document.getElementById('orchestra-demo-data').textContent)});if(!response.ok)throw Error('API reageert niet');demoData=await response.json();renderKPIs(demoData);renderChecks(demoData);drawChart(demoData);for(let i=0;i<demoData.steps.length;i++){const step=demoData.steps[i];const e=$('agent-'+step.agent);signalFor(step.agent);e.classList.add('active');e.querySelector('.status').textContent='ACTIEF';$('flowState').textContent=step.text;log('Start: '+e.querySelector('h3').textContent);const stageLabels=stepDescriptions(step.agent,null,true);showStages(step.agent,stageLabels);await animateStages(step.agent,stageLabels);clearSignal();e.classList.remove('active');e.classList.add('done');if(step.agent==='verify' && demoData.checks.some(c=>c.ours!==c.shadow))$('conductor').classList.add('attention');e.querySelector('.status').textContent=step.agent==='verify'?'AFWIJKING':'GEREED';completed++;$('completed').textContent=String(completed);log(step.text);await sleep(280)}$('conductorStatus').textContent='Analyse voltooid · controle vereist';$('flowState').textContent='Concept gereed · NIET GEVALIDEERD';$('logStatus').textContent='Afgerond';log('Eindstatus: concept beschikbaar, niet vrijgegeven');}catch(e){log('FOUT: '+e.message);$('flowState').textContent='Verwerking mislukt';$('conductorStatus').textContent='Fout gedetecteerd';}finally{clearSignal();running=false;$('startBtn').disabled=false;$('resetBtn').disabled=false;$('conductor').classList.remove('running');document.querySelector('.orchestra').classList.remove('running')}} | |
| function reply(question){const q=question.toLowerCase();if(!completed)return 'Start eerst de demo-analyse. Daarna kan ik vragen beantwoorden over de fictieve cijfers.';if(/valid|wouter|controle|betrouw|verschil|akkoord/.test(q))return 'In deze fictieve demo wijken de unieke bezoekers af: monitor 386 tegenover schaduwberekening 381, een verschil van 5. De oorzaak is niet vastgesteld. Daarom blijft de infographic NIET GEVALIDEERD.';if(/groei|stijg|locatie|soeda|rietdijk|vredehof|gouda/.test(q))return 'In de fictieve reeks juli–september stijgt Soeda van 350 naar 428 bezoeken (+22,3%). Rietdijk stijgt van 285 naar 334 (+17,2%), Vredehof van 245 naar 294 (+20,0%) en Gouda van 320 naar 365 (+14,1%). Soeda heeft de grootste relatieve groei.';if(/samenvat|rapport|infographic|management/.test(q))return 'Conceptmanagementsamenvatting (fictief): alle vier getoonde locaties laten in juli–september groei in bezoeken zien. Er is een afwijking van 5 unieke bezoekers bij de schaduwcontrole. Publicatie is niet toegestaan totdat dit verschil is onderzocht en beoordeeld.';if(/bezoek|uniek|instroom|kwartaal|cijfer/.test(q))return 'De fictieve KPI-kaarten tonen 12.480 bezoeken, 1.286 unieke bezoekers en 164 nieuwe instromers. Deze totaalcijfers zijn illustratief en vormen geen optelsom van de vier voorbeeldlocaties in de grafiek.';return 'Dit is een demo-chat zonder AI-API. Ik kan alleen vooraf gedefinieerde vragen over de fictieve locatiegroei, KPI’s, verificatie en managementsamenvatting beantwoorden.'} | |
| function addMessage(role,text){const e=document.createElement('div');e.className='message '+role;if(role==='assistant'){const s=document.createElement('small');s.textContent='INLOOP AI · DEMONSTRATIE';e.appendChild(s)}const p=document.createElement('p');p.textContent=text;e.appendChild(p);$('messages').appendChild(e);$('messages').scrollTop=$('messages').scrollHeight;} | |
| function ask(q){if(!q.trim())return;addMessage('user',q);addMessage('assistant',reply(q));} | |
| // v0.4: veilige browser-lokale Excel-proef, uitsluitend met fictieve / geanonimiseerde voorbeelden. | |
| // Er vindt GEEN netwerkverzoek, bestandsoverdracht of externe AI-aanroep plaats. | |
| let importedData=null; | |
| const excelLimit=25*1024*1024; // limiet per afzonderlijk XLSX-bestand | |
| const REPORT_YEAR='2026'; | |
| const REPORT_SITES=['De Brink','De Kerk Den Haag','Gouda','Haagse Zaak','Hoorn','IJmond','Platinaweg','Purmerend','Rietdijk','Rozebottel','Soeda','Sterhuis','Voorburg','Vredehof','Zinkwerf']; | |
| const siteCanonical=new Map(REPORT_SITES.map(s=>[s.toLocaleLowerCase('nl-NL'),s])); | |
| function allowedSite(label){const raw=String(label??'').trim();if(!raw||raw.length>80)return '';return siteCanonical.get(raw.toLocaleLowerCase('nl-NL'))||raw;} | |
| function historyKey(site,id){return JSON.stringify([site,id]);} | |
| function validClientId(value){ | |
| const id=String(value??'').trim(); | |
| if(!id||/^0+(?:[.,]0+)?$/.test(id)||/^(?:#.*|n\/a|nan|none|null|undefined|false|onwaar|geen)$/i.test(id))return ''; | |
| return id; | |
| } | |
| function setHistoryStatus(message,cls=''){const el=$('historyStatus');el.textContent=message;el.className='history-status '+cls;} | |
| const excelNS='http://schemas.openxmlformats.org/spreadsheetml/2006/main'; | |
| const decoder=new TextDecoder('utf-8'); | |
| function bytesText(a){return decoder.decode(a)} | |
| function zipDirectory(buffer){ | |
| const bytes=new Uint8Array(buffer), view=new DataView(buffer); | |
| let eocd=-1; | |
| for(let i=bytes.length-22;i>=Math.max(0,bytes.length-65557);i--){if(view.getUint32(i,true)===0x06054b50){eocd=i;break}} | |
| if(eocd<0)throw Error('Geen geldig XLSX-/ZIP-bestand'); | |
| const count=view.getUint16(eocd+10,true), offset=view.getUint32(eocd+16,true); | |
| if(count>1000)throw Error('Dit testbestand heeft te veel onderdelen'); | |
| const entries=new Map();let pos=offset; | |
| for(let i=0;i<count;i++){ | |
| if(view.getUint32(pos,true)!==0x02014b50)throw Error('ZIP-structuur ongeldig'); | |
| const method=view.getUint16(pos+10,true), size=view.getUint32(pos+20,true),inflated=view.getUint32(pos+24,true),len=view.getUint16(pos+28,true),extra=view.getUint16(pos+30,true),comment=view.getUint16(pos+32,true),local=view.getUint32(pos+42,true); | |
| const name=bytesText(bytes.subarray(pos+46,pos+46+len)); | |
| if(name.startsWith('xl/'))entries.set(name,{method,size,inflated,local}); | |
| pos+=46+len+extra+comment; | |
| } | |
| return {view,bytes,entries}; | |
| } | |
| async function xmlEntry(zip,name){ | |
| const entry=zip.entries.get(name);if(!entry)return ''; | |
| if(entry.inflated>180*1024*1024)throw Error('Een Excel-onderdeel is uitgepakt groter dan 180 MB; bestand eerst opsplitsen'); | |
| const {view,bytes}=zip, off=entry.local; | |
| if(view.getUint32(off,true)!==0x04034b50)throw Error('ZIP-bestandskop ongeldig'); | |
| const start=off+30+view.getUint16(off+26,true)+view.getUint16(off+28,true); | |
| const compressed=bytes.slice(start,start+entry.size); | |
| if(entry.method===0)return bytesText(compressed); | |
| if(entry.method!==8)throw Error('Niet-ondersteunde Excel-compressie'); | |
| if(typeof DecompressionStream!=='function')throw Error('Deze browser ondersteunt lokale Excel-decompressie niet'); | |
| return await new Response(new Blob([compressed]).stream().pipeThrough(new DecompressionStream('deflate-raw'))).text(); | |
| } | |
| function xmlDoc(str){ | |
| const doc=new DOMParser().parseFromString(str,'application/xml'); | |
| if(doc.getElementsByTagName('parsererror').length)throw Error('Excel-XML niet leesbaar');return doc; | |
| } | |
| // v1.3: rijgewijs XML uitlezen; geen DOM-boom van 100+ MB grote werkbladen. | |
| function decodeXML(v){return String(v).replace(/&(?:amp|lt|gt|quot|apos|#(?:x[0-9a-fA-F]+|\d+));/g,m=>{const k=m.slice(1,-1);if(k[0]==='#'){const n=k[1]?.toLowerCase()==='x'?parseInt(k.slice(2),16):Number(k.slice(1));return Number.isInteger(n)&&n>=0&&n<=0x10ffff?String.fromCodePoint(n):m}return {amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}[k]||m})} | |
| function parseSharedLite(xml){ | |
| const arr=[];const si=/<(?:[\w.-]+:)?si\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?si>/g;let m; | |
| while((m=si.exec(xml))!==null){let t='',p;const re=/<(?:[\w.-]+:)?t(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w.-]+:)?t>/g;while((p=re.exec(m[1]))!==null)t+=decodeXML(p[1]);arr.push(t)} | |
| return arr; | |
| } | |
| function *sheetRows(xml,shared){ | |
| const rowRe=/<(?:[\w.-]+:)?row\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?row>/g, cellRe=/<(?:[\w.-]+:)?c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/(?:[\w.-]+:)?c>)/g;let row; | |
| while((row=rowRe.exec(xml))!==null){const values={};let c;cellRe.lastIndex=0; | |
| while((c=cellRe.exec(row[1]))!==null){const col=/\br="([A-Z]+)\d+"/.exec(c[1]);if(!col)continue; | |
| const type=/\bt="([^"]+)"/.exec(c[1])?.[1]||'';const payload=c[2]||''; | |
| let v=/<(?:[\w.-]+:)?v(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w.-]+:)?v>/.exec(payload)?.[1]??''; | |
| if(type==='s')v=shared[Number(v)]??''; | |
| else if(type==='inlineStr'){v='';let t;const tr=/<(?:[\w.-]+:)?t(?:\s[^>]*)?>([\s\S]*?)<\/(?:[\w.-]+:)?t>/g;while((t=tr.exec(payload))!==null)v+=decodeXML(t[1]);} | |
| else v=decodeXML(v); | |
| values[col[1]]=v; | |
| } | |
| yield values; | |
| } | |
| } | |
| function scanDate(v){ | |
| if(v===''||v===undefined||v===null)return ''; | |
| const numeric=Number(v); | |
| if(v!==''&&Number.isFinite(numeric)&&numeric>30000&&numeric<80000){ | |
| const d=new Date(Date.UTC(1899,11,30)+Math.floor(numeric)*86400000); | |
| return d.toISOString().slice(0,10); | |
| } | |
| const str=String(v).trim(),iso=str.match(/^(\d{4})-(\d{2})-(\d{2})/); | |
| if(iso)return `${iso[1]}-${iso[2]}-${iso[3]}`; | |
| const nl=str.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})/); | |
| if(nl)return `${nl[3]}-${nl[2].padStart(2,'0')}-${nl[1].padStart(2,'0')}`; | |
| return ''; | |
| } | |
| async function readExcelLocal(file,onProgress=()=>{}){ | |
| if(!file.name.toLowerCase().endsWith('.xlsx'))throw Error('Kies een XLSX-bestand'); | |
| if(file.size>excelLimit)throw Error('Dit bestand is groter dan de v1.3-testgrens van 25 MB'); | |
| onProgress('Excel wordt geopend ('+(file.size/1024/1024).toFixed(1)+' MB)…'); | |
| const zip=zipDirectory(await file.arrayBuffer()),shared=[]; | |
| const scanMeta=zip.entries.get('xl/worksheets/sheet1.xml'); | |
| if(!scanMeta)throw Error('Werkblad Data dump (sheet1) ontbreekt'); | |
| onProgress('Excel-structuur gecontroleerd · werkbladen worden gelezen…'); | |
| const sharedXML=await xmlEntry(zip,'xl/sharedStrings.xml'); | |
| if(sharedXML){for(const item of parseSharedLite(sharedXML))shared.push(item);} | |
| const pick=(l,...names)=>names.map(n=>l[n]).find(Boolean)||null; | |
| const passToClient=new Map(),clientToPasses=new Map(),passConflicts=new Set();let passRows=0,passMissing=0; | |
| const pasDates={startPresent:0,endPresent:0,migration:0,invalidDates:0,notAvailable:false,activeAtPeildatum:0}; | |
| const passDateRecords=[]; | |
| const invalidDatePasses=new Set(); | |
| // Eerst de pasregistratie, zodat scans zonder direct cliëntnummer gekoppeld kunnen worden. | |
| const passXML=await xmlEntry(zip,'xl/worksheets/sheet2.xml'); | |
| if(passXML){ | |
| const it=sheetRows(passXML,shared),first=it.next(); | |
| if(!first.done){const pl={};for(const [col,v] of Object.entries(first.value))pl[String(v).toLowerCase().trim()]=col; | |
| const pc=pick(pl,'pasnummer','scanpasnummer','pas nummer','pasnr'),ic=pick(pl,'clientnummer','cliëntnummer'),startCol=pick(pl,'startdatum'),endCol=pick(pl,'einddatum'); | |
| pasDates.notAvailable=!(startCol&&endCol); | |
| if(!pc||!ic)throw Error('Scanpassen mist kolommen Pasnummer en Clientnummer'); | |
| for(const r of it){const p=String(r[pc]??'').trim(),c=validClientId(r[ic]);passRows++; | |
| if(passRows>350000)throw Error('Meer dan 350.000 pasregistraties; contacteer ontwikkelaar voor grotere import'); | |
| const st=startCol?scanDate(r[startCol]):'',en=endCol?scanDate(r[endCol]):''; | |
| if(st)pasDates.startPresent++;if(en)pasDates.endPresent++; | |
| if(st==='2022-10-28')pasDates.migration++;if(st&&en&&en<st){pasDates.invalidDates++;if(p)invalidDatePasses.add(p);} | |
| if(c&&p)passDateRecords.push({client:c,start:st,end:en}); | |
| if(!p||!c){passMissing++;continue} | |
| if(passToClient.has(p)&&passToClient.get(p)!==c)passConflicts.add(p); | |
| else passToClient.set(p,c); | |
| if(!clientToPasses.has(c))clientToPasses.set(c,new Set());clientToPasses.get(c).add(p); | |
| } | |
| } | |
| } | |
| onProgress('Scanpassen verwerkt ('+passRows.toLocaleString('nl-NL')+') · scanhistorie wordt gelezen…'); | |
| await new Promise(resolve=>setTimeout(resolve,0)); | |
| const sheet=await xmlEntry(zip,'xl/worksheets/sheet1.xml'); | |
| if(!sheet)throw Error('Werkblad Data dump ontbreekt'); | |
| const it=sheetRows(sheet,shared),first=it.next();if(first.done)throw Error('Werkblad Data dump is leeg'); | |
| const lookup={};for(const [col,name] of Object.entries(first.value))lookup[String(name).toLowerCase().trim()]=col; | |
| const dateCol=pick(lookup,'scantijd','scandatum'),siteCol=pick(lookup,'locatie'),idCol=pick(lookup,'clientnummer','cliëntnummer'),passColumn=pick(lookup,'pasnummer','scanpasnummer','pas nummer','pasnr'); | |
| if(!dateCol||!siteCol||(!idCol&&!passColumn))throw Error('Kolommen scantijd, locatie en cliëntnummer of pasnummer ontbreken'); | |
| 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; | |
| const qualityRows=[],undatedBySite=new Map(),excludedFalseRows=[]; // Alleen minimale foutsignalen; geen identifiers. | |
| const outcomeColumn=pick(lookup,'resultaat'); | |
| for(const r of it){rawCount++; | |
| if(rawCount>500000)throw Error('Meer dan 500.000 scans: importlimiet v1.3 bereikt; bestand opsplitsen'); | |
| const site=allowedSite(r[siteCol]),date=scanDate(r[dateCol]); | |
| if(!site){ignoredSites++;continue;} | |
| const pass=passColumn?String(r[passColumn]??'').trim():'',directId=idCol?validClientId(r[idCol]):''; | |
| const resolved=pass&&!passConflicts.has(pass)?passToClient.get(pass):null; | |
| const id=directId||resolved||''; | |
| // Afgekeurde/onware scans (Resultaat=0) tellen niet mee in 2026 en ook niet in de historische referentie. | |
| if(outcomeColumn&&['0','false','onwaar'].includes(String(r[outcomeColumn]??'').trim().toLowerCase())){ | |
| resultZero++;if(!id)resultZeroNoIdentity++;excludedFalseRows.push({site,date});continue; | |
| } | |
| if(pass)rowsWithPass++; | |
| if(pass&&passXML&&!resolved)unmatchedPasses.add(pass); | |
| if(directId&&resolved&&directId!==resolved)identityConflicts.add(pass); | |
| if(!directId&&resolved)linkedByPass++; | |
| if(!date){invalid.datum++;undatedBySite.set(site,(undatedBySite.get(site)||0)+1);}if(!id)invalid.client++; | |
| const badPassConflict=!!pass&&passConflicts.has(pass); | |
| const badClientMatch=!!directId&&!!resolved&&directId!==resolved; | |
| const badUnknownPass=!!pass&&!!passXML&&!resolved; | |
| const badPassDate=!!pass&&invalidDatePasses.has(pass); | |
| if(date&&date.slice(0,4)===REPORT_YEAR&&(badPassConflict||badClientMatch||badUnknownPass||badPassDate||!id)){ | |
| qualityRows.push({site,date,hasIdentity:!!id,hasDirectId:!!directId, | |
| passConflict:badPassConflict,clientMismatch:badClientMatch,unknownPass:badUnknownPass,badPassDate}); | |
| } | |
| if(!id||!date||date.slice(0,4)!==REPORT_YEAR)continue; | |
| // Alleen ware, aan een cliënt gekoppelde scans tellen; vervangende passen blijven op cliëntnummer samengevoegd. | |
| all.push({date,site,id,pass}); | |
| } | |
| if(!all.length)throw Error('Geen geldige (Resultaat=1) scanregels over 2026; controleer bestand, locatienamen en status'); | |
| const sites=[...new Set(all.map(r=>r.site))].sort((a,b)=>a.localeCompare(b,'nl')); | |
| const peildatum=all.reduce((m,r)=>r.date>m?r.date:m,''); | |
| const activeIds=new Set(passDateRecords.filter(r=>r.start&&r.end&&r.start<=peildatum&&r.end>=peildatum).map(r=>r.client)); | |
| pasDates.activeAtPeildatum=activeIds.size; | |
| onProgress('Ingelezen: '+rawCount.toLocaleString('nl-NL')+' scanregels · '+sites.length+' locaties'); | |
| return {all,sites,peildatum,pasDates,rawCount,invalid,hasPassColumn:!!passColumn, | |
| passSheetFound:!!passXML,passRows,passMissing,passToClient,clientToPasses, | |
| passConflicts:passConflicts.size,identityConflicts:identityConflicts.size, | |
| unmatchedPasses:unmatchedPasses.size,linkedByPass,rowsWithPass,ignoredSites,resultZero,resultZeroNoIdentity,excludedFalseRows, | |
| qualityRows,undatedBySite, | |
| historyFirst:new Map(),historyLoaded:false,historyInfo:null}; | |
| } | |
| // v1.5: historische index met uitsluitend de eerste scan per cliënt/locatie. | |
| // Lees GEEN inhoud van het pasregister van 2025 en bewaar geen individuele scanregels uit 2025. | |
| async function readHistoricFirsts(file,onProgress=()=>{}){ | |
| if(!file.name.toLowerCase().endsWith('.xlsx'))throw Error('Kies een XLSX-bestand voor 2025'); | |
| if(file.size>excelLimit)throw Error('2025-bestand overschrijdt de limiet van 25 MB'); | |
| onProgress('Referentie 2025 openen ('+(file.size/1024/1024).toFixed(1)+' MB)…'); | |
| const zip=zipDirectory(await file.arrayBuffer()); | |
| const sharedXML=await xmlEntry(zip,'xl/sharedStrings.xml'); | |
| const shared=sharedXML?parseSharedLite(sharedXML):[]; | |
| onProgress('2025: scanregels controleren en eerste bezoekdatums verzamelen…'); | |
| await new Promise(resolve=>setTimeout(resolve,0)); | |
| const sheet=await xmlEntry(zip,'xl/worksheets/sheet1.xml'); | |
| if(!sheet)throw Error('Scanwerkblad 2025 ontbreekt'); | |
| const it=sheetRows(sheet,shared),first=it.next(); | |
| if(first.done)throw Error('Het scanwerkblad 2025 is leeg'); | |
| const cols={};for(const [col,v] of Object.entries(first.value))cols[String(v).toLowerCase().trim()]=col; | |
| const dateCol=cols.scantijd||cols.scandatum,siteCol=cols.locatie,idCol=cols.clientnummer||cols['cliëntnummer']; | |
| if(!dateCol||!siteCol||!idCol)throw Error('2025 mist Scantijd, Locatie of Clientnummer'); | |
| const firsts=new Map();let raw=0,valid=0,otherSites=0,missingIds=0,otherYears=0,falseScans=0; | |
| const resultCol=cols.resultaat; | |
| for(const r of it){raw++;if(raw>500000)throw Error('2025 bevat meer dan 500.000 scanregels'); | |
| // Tel alle onware scans, ook de uitzonderlijke regels zonder locatienaam; gebruik geen van deze voor historie. | |
| if(resultCol&&['0','false','onwaar'].includes(String(r[resultCol]??'').trim().toLowerCase())){falseScans++;continue;} | |
| const site=allowedSite(r[siteCol]);if(!site){otherSites++;continue;} | |
| const date=scanDate(r[dateCol]);if(!date||date.slice(0,4)!=='2025'){otherYears++;continue;} | |
| const id=validClientId(r[idCol]);if(!id){missingIds++;continue;} | |
| valid++;const key=historyKey(site,id); | |
| if(!firsts.has(key)||date<firsts.get(key))firsts.set(key,date); | |
| } | |
| if(!valid)throw Error('Geen geldige historische scans uit 2025 in de beschikbare locaties'); | |
| onProgress('Referentie 2025 gereed: '+valid.toLocaleString('nl-NL')+' bruikbare scanregels'); | |
| return {firsts,info:{raw,valid,otherSites,missingIds,otherYears,falseScans,people:firsts.size}}; | |
| } | |
| // v1.6.2 — unieke cliënten binnen periode; historische referentie alleen voor nieuwe instroom. | |
| function quarterKey(date){return date.slice(0,4)+'-Q'+(Math.floor((Number(date.slice(5,7))-1)/3)+1);} | |
| function quarterDates(key){ | |
| const m=String(key).match(/^(\d{4})-Q([1-4])$/);if(!m)throw Error('Kies een geldig rapportagekwartaal'); | |
| const year=Number(m[1]),q=Number(m[2]),month=(q-1)*3+1; | |
| const begin=`${year}-${String(month).padStart(2,'0')}-01`; | |
| const end=new Date(Date.UTC(year,month+2,0)).toISOString().slice(0,10); | |
| return {begin,end,key,label:`Q${q} ${year}`,months:Array.from({length:3},(_,i)=>`${year}-${String(month+i).padStart(2,'0')}`)}; | |
| } | |
| function precedingThreeMonths(peildatum){ | |
| // Rolling interval: (peildatum - 3 kalendermaanden, peildatum], dag-afgekapt. | |
| // Bijvoorbeeld 30 september -> 1 juli; 25 september -> 26 juni. | |
| const [year,month,day]=peildatum.split('-').map(Number); | |
| const base=new Date(Date.UTC(year,month-1-3,1)); | |
| const lastDay=new Date(Date.UTC(base.getUTCFullYear(),base.getUTCMonth()+1,0)).getUTCDate(); | |
| const earlier=new Date(Date.UTC(base.getUTCFullYear(),base.getUTCMonth(),Math.min(day,lastDay))); | |
| earlier.setUTCDate(earlier.getUTCDate()+1); | |
| return earlier.toISOString().slice(0,10); | |
| } | |
| function readableDate(date){if(!date)return '—';const [y,m,d]=date.split('-');return `${d}-${m}-${y}`;} | |
| function firstScansBySite(all){ | |
| const first=new Map(); | |
| 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);} | |
| return first; | |
| } | |
| function reportPeriodDates(key){ | |
| const match=String(key).match(/^(\d{4})-(Q[1-4]|H[12]|M9)$/); | |
| if(!match)throw Error('Ongeldige rapportageperiode'); | |
| const year=Number(match[1]),part=match[2]; | |
| const first=part.startsWith('Q')?(Number(part[1])-1)*3+1:part==='H2'?7:1; | |
| const count=part.startsWith('Q')?3:part.startsWith('H')?6:9; | |
| const begin=`${year}-${String(first).padStart(2,'0')}-01`; | |
| const end=new Date(Date.UTC(year,first+count-1,0)).toISOString().slice(0,10); | |
| const months=Array.from({length:count},(_,i)=>`${year}-${String(first+i).padStart(2,'0')}`); | |
| const label=part==='Q1'?`Januari t/m maart ${year}`:part==='H1'?`Januari t/m juni ${year}`:part==='M9'?`Januari t/m september ${year}`:part.startsWith('Q')?`Q${part[1]} ${year}`:`H${part[1]} ${year}`; | |
| return {key,begin,end,months,label,type:part.startsWith('Q')?'quarter':part.startsWith('H')?'half':'nine'}; | |
| } | |
| function availablePeriods(type){ | |
| const dataMonths=[...new Set(importedData.all.map(r=>r.date.slice(0,7)))].sort(); | |
| const years=[REPORT_YEAR]; | |
| const keys=[]; | |
| for(const year of years){ | |
| const suffixes=type==='quarter'?['Q1']:type==='half'?['H1']:['M9']; | |
| for(const suffix of suffixes){ | |
| const key=year+'-'+suffix,p=reportPeriodDates(key); | |
| if(dataMonths.some(month=>month>=p.months[0]&&month<=p.months.at(-1)))keys.push(key); | |
| } | |
| } | |
| return keys; | |
| } | |
| function refreshPeriodChoices(prefer){ | |
| const select=$('periodSelect'),keys=availablePeriods($('periodType').value); | |
| select.replaceChildren(); | |
| keys.forEach(key=>{const opt=document.createElement('option');opt.value=key;opt.textContent=reportPeriodDates(key).label;select.appendChild(opt)}); | |
| if(prefer&&keys.includes(prefer))select.value=prefer; | |
| select.disabled=!keys.length; | |
| if(!keys.length){const opt=document.createElement('option');opt.textContent='Geen perioden beschikbaar';select.appendChild(opt)} | |
| } | |
| function periodContext(key){ | |
| const p=reportPeriodDates(key); | |
| const seen=importedData.all.reduce((max,r)=>r.date>max?r.date:max,''); | |
| // Onvolledig nieuwste tijdvak: laatste beschikbare scandag is de peildatum. | |
| const latestInPeriod=importedData.all.reduce((max,r)=>r.date>=p.begin&&r.date<=p.end&&r.date>max?r.date:max,''); | |
| const peildatum=seen<p.end?latestInPeriod:p.end; | |
| if(!peildatum)throw Error('Geen scans in de geselecteerde rapportageperiode'); | |
| return {...p,peildatum,activeFrom:precedingThreeMonths(peildatum)}; | |
| } | |
| function calculateImported(site='*'){ | |
| if(!importedData)throw Error('Laad eerst fictieve gegevens'); | |
| const chosenPeriod=$('periodSelect').value; | |
| const period=periodContext(chosenPeriod); | |
| const locationRows=importedData.all.filter(r=>site==='*'||r.site===site); | |
| const historicRows=locationRows.filter(r=>r.date<=period.peildatum); | |
| const quarterRows=historicRows.filter(r=>r.date>=period.begin&&r.date<=period.peildatum); | |
| const visitKeys=new Set(),clientKeys=new Set(),quarterClientKeys=new Set(),activeKeys=new Set(),monthSite=new Map(); | |
| const instroomByMonth=new Map(period.months.map(m=>[m,0])); | |
| const firstByClient=new Map(),firstWithoutReference=new Map(); | |
| // Historische bezoeken uit 2025 bepalen de vroegste geregistreerde scan, | |
| // maar dragen NOOIT bij aan bezoeken, actieve cliënten of maandgrafieken over 2026. | |
| if(importedData.historyLoaded){for(const [key,date] of importedData.historyFirst){ | |
| if(site==='*'||JSON.parse(key)[0]===site)firstByClient.set(key,date); | |
| }} | |
| for(const r of historicRows){ | |
| const key=historyKey(r.site,r.id);clientKeys.add(key); | |
| if(!firstWithoutReference.has(key)||r.date<firstWithoutReference.get(key))firstWithoutReference.set(key,r.date); | |
| if(!firstByClient.has(key)||r.date<firstByClient.get(key))firstByClient.set(key,r.date); | |
| if(r.date>=period.activeFrom)activeKeys.add(key); | |
| } | |
| let instroom=0; | |
| for(const [key,date] of firstByClient){ | |
| if(date>=period.begin&&date<=period.peildatum){instroom++; | |
| const month=date.slice(0,7);instroomByMonth.set(month,(instroomByMonth.get(month)||0)+1); | |
| } | |
| } | |
| for(const r of quarterRows){ | |
| const ck=JSON.stringify([r.site,r.id]);quarterClientKeys.add(ck); | |
| const key=JSON.stringify([r.site,r.date,r.id]);if(visitKeys.has(key))continue; | |
| visitKeys.add(key); | |
| const mk=r.site+'|'+r.date.slice(0,7);monthSite.set(mk,(monthSite.get(mk)||0)+1); | |
| } | |
| const active=activeKeys.size,unique=quarterClientKeys.size; // Wouter: unieke cliënten = minstens één geldige scan in geselecteerde periode | |
| const priorCandidates=[...firstWithoutReference].filter(([key,date])=>date>=period.begin&&date<=period.peildatum); | |
| const preHistoryIntake=priorCandidates.length; | |
| const correctedExisting=priorCandidates.filter(([key])=>importedData.historyLoaded&&importedData.historyFirst.has(key)).length; | |
| const correctedJanuary=priorCandidates.filter(([key,date])=>date.slice(0,7)==='2026-01'&&importedData.historyLoaded&&importedData.historyFirst.has(key)).length; | |
| const passtiedIds=new Set(historicRows.map(r=>r.id)); | |
| const passByClient=new Map();let missingPass=0; | |
| 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++;} | |
| let multiPassClients=0; | |
| if(importedData.passSheetFound){for(const [client,passes] of importedData.clientToPasses)if(passtiedIds.has(client)&&passes.size>1)multiPassClients++;} | |
| else multiPassClients=[...passByClient.values()].filter(p=>p.size>1).length; | |
| const locations=(site==='*'?importedData.sites:[site]).map(name=>({name,visits:period.months.map(m=>monthSite.get(name+'|'+m)||0)})); | |
| const excludedFalse=importedData.excludedFalseRows.filter(r=>(site==='*'||r.site===site)&&r.date>=period.begin&&r.date<=period.peildatum).length; | |
| return {site,period,rows:quarterRows.length,historyRows:historicRows.length,visits:visitKeys.size,unique, | |
| quarterClients:quarterClientKeys.size,newIntake:instroom,active,firstByClient,instroomByMonth, | |
| preHistoryIntake,correctedExisting,correctedJanuary,excludedFalse,historyLoaded:importedData.historyLoaded, | |
| historyPeople:importedData.historyLoaded?importedData.historyInfo.people:0, | |
| duplicates:quarterRows.length-visitKeys.size,months:period.months,locations, | |
| invalid:importedData.invalid,passChecked:importedData.hasPassColumn||importedData.passSheetFound, | |
| passSheetFound:importedData.passSheetFound,pasDates:importedData.pasDates,peildatum:period.peildatum,multiPassClients, | |
| passConflicts:importedData.passConflicts,identityConflicts:importedData.identityConflicts, | |
| unmatchedPasses:importedData.unmatchedPasses,linkedByPass:importedData.linkedByPass,missingPass,passRows:importedData.passRows}; | |
| } | |
| // v1.6: Verifieer bronafwijkingen apart. De conclusie "geen direct effect" geldt alleen | |
| // als het betreffende veld niet in de KPI-rekenregels wordt gebruikt of iedere | |
| // betroffen scan een rechtstreeks cliëntnummer heeft. Mogelijke effecten blijven waarschuwingen. | |
| function assessSourceIssues(result){ | |
| const source=importedData,site=result.site,{begin,peildatum}=result.period; | |
| const selected=source.qualityRows.filter(r=>(site==='*'||r.site===site)&&r.date>=begin&&r.date<=peildatum); | |
| const count=f=>selected.filter(f).length; | |
| const undated=site==='*'?[...source.undatedBySite.values()].reduce((a,b)=>a+b,0):(source.undatedBySite.get(site)||0); | |
| const issues=[]; | |
| const add=(label,total,impact,detail)=>{if(total>0)issues.push({label,total,impact,detail});}; | |
| const badDateScan=count(r=>r.badPassDate); | |
| add('Pasregistraties met onlogische start-/einddatum',result.pasDates?.invalidDates||0,'none', | |
| `${badDateScan.toLocaleString('nl-NL')} scanregel${badDateScan===1?'':'s'} in deze rapportage ${badDateScan===1?'gebruikt':'gebruiken'} een van deze passen. Pasdatums tellen niet mee bij bezoekdagen, instroom of actieve cliënten; daarom geen direct rekenkundig effect. Controleer de bronregistratie wel.`); | |
| const conflictRows=count(r=>r.passConflict),conflictExcluded=count(r=>r.passConflict&&!r.hasIdentity); | |
| add('Pasnummer gekoppeld aan verschillende cliënten',result.passConflicts||0,conflictExcluded>0?'possible':'unknown', | |
| `${conflictRows.toLocaleString('nl-NL')} betreffende scanregel${conflictRows===1?'':'s'} in deze periode, waarvan ${conflictExcluded.toLocaleString('nl-NL')} zonder koppelbaar cliëntnummer uitgesloten. Bij overige regels geldt het cliëntnummer uit de scan; de identiteit moet worden nagekeken.`); | |
| const mismatchRows=count(r=>r.clientMismatch); | |
| add('Cliëntnummer scan wijkt af van pasregistratie',result.identityConflicts||0,'possible', | |
| `${mismatchRows.toLocaleString('nl-NL')} betreffende scanregel${mismatchRows===1?'':'s'} in deze periode. Het rechtstreeks geregistreerde cliëntnummer wordt gebruikt. Een onjuiste koppeling kan cliënt- en instroomaantallen beïnvloeden; niet automatisch te kwantificeren.`); | |
| const unknownRows=count(r=>r.unknownPass),unknownExcluded=count(r=>r.unknownPass&&!r.hasIdentity); | |
| add('Niet-herkende of niet eenduidige scanpassen',result.unmatchedPasses||0,unknownExcluded>0?'possible':'none', | |
| `${unknownRows.toLocaleString('nl-NL')} betrokken scanregel${unknownRows===1?'':'s'} in deze periode; ${unknownExcluded.toLocaleString('nl-NL')} ${unknownExcluded===1?'is':'zijn'} zonder cliëntnummer uitgesloten. ${unknownExcluded>0?'Daardoor kunnen de KPI’s te laag zijn.':'Alle betrokken scanregels hebben een rechtstreeks cliëntnummer en tellen gewoon mee.'}`); | |
| const excludedNoId=count(r=>!r.hasIdentity); | |
| add('Scans zonder koppelbaar cliëntnummer',excludedNoId,'possible', | |
| `${excludedNoId.toLocaleString('nl-NL')} regel${excludedNoId===1?'':'s'} in deze locatie/periode ${excludedNoId===1?'is':'zijn'} niet meegenomen. Zonder identiteit kan ORCHESTRA geen betrouwbare unieke cliënt of bezoekdag vaststellen; mogelijke onderschatting.`); | |
| add('Scans zonder bruikbare scandatum',undated,'unknown', | |
| `Deze regels zijn uitgesloten. Hun periode is niet vast te stellen; het getal betreft de gekozen locatie over het hele 2026-bestand, niet uitsluitend deze rapportageperiode. Mogelijke onderschatting.`); | |
| return issues; | |
| } | |
| // v1.1: onafhankelijke controletelling. Geen hergebruik van de KPI-sets uit calculateImported. | |
| function independentlyVerify(result){ | |
| const {begin,peildatum,activeFrom,months}=result.period; | |
| const rows=importedData.all.filter(r=>(result.site==='*'||r.site===result.site)&&r.date<=peildatum); | |
| const people=new Map(),visits=new Map(),monthFirst=new Map(months.map(m=>[m,0])),seen2026=new Map(); | |
| if(importedData.historyLoaded){for(const [key,date] of importedData.historyFirst){ | |
| if(result.site==='*'||JSON.parse(key)[0]===result.site)people.set(key,{first:date,recent:false,inQuarter:false}); | |
| }} | |
| for(const r of rows){ | |
| const key=historyKey(r.site,r.id); | |
| if(!seen2026.has(key)||r.date<seen2026.get(key))seen2026.set(key,r.date); | |
| let p=people.get(key); | |
| if(!p){p={first:r.date,recent:false,inQuarter:false};people.set(key,p);} | |
| if(r.date<p.first)p.first=r.date; | |
| if(r.date>=activeFrom&&r.date<=peildatum)p.recent=true; | |
| if(r.date>=begin&&r.date<=peildatum){ | |
| p.inQuarter=true; | |
| const vkey=JSON.stringify([r.site,r.id,r.date]); | |
| visits.set(vkey,(visits.get(vkey)||0)+1); | |
| } | |
| } | |
| let intake=0,active=0,quarterClients=0; | |
| for(const p of people.values()){ | |
| if(p.recent)active++; | |
| if(p.inQuarter)quarterClients++; | |
| if(p.first>=begin&&p.first<=peildatum){intake++;const m=p.first.slice(0,7);monthFirst.set(m,monthFirst.get(m)+1);} | |
| } | |
| const inQuarterRows=[...visits.values()].reduce((s,n)=>s+n,0); | |
| const shadow={unique:quarterClients,newIntake:intake,active,visits:visits.size,quarterClients, | |
| duplicates:inQuarterRows-visits.size,monthNew:[...monthFirst.values()].reduce((a,b)=>a+b,0)}; | |
| const uncorrected=[...seen2026].filter(([key,date])=>date>=begin&&date<=peildatum); | |
| const independentlyRejected=uncorrected.filter(([key])=>importedData.historyLoaded&&importedData.historyFirst.has(key)).length; | |
| const janRejected=uncorrected.filter(([key,date])=>date.slice(0,7)==='2026-01'&&importedData.historyLoaded&&importedData.historyFirst.has(key)).length; | |
| const checks=[ | |
| {label:'Unieke cliënten in periode · tweede telling',ours:result.unique,shadow:shadow.unique}, | |
| {label:'Nieuwe instroom · eerste scan',ours:result.newIntake,shadow:shadow.newIntake}, | |
| {label:'Actieve cliënten · laatste 3 maanden',ours:result.active,shadow:shadow.active}, | |
| {label:'Unieke bezoekdagen · cliënt/locatie/dag',ours:result.visits,shadow:shadow.visits}, | |
| {label:'Bezoekers rapportageperiode',ours:result.quarterClients,shadow:shadow.quarterClients}, | |
| {label:'Herhaalde scans in periode',ours:result.duplicates,shadow:shadow.duplicates}, | |
| {label:'Maandinstroom versus periode-instroom',ours:result.newIntake,shadow:shadow.monthNew} | |
| ]; | |
| if(importedData.historyLoaded){ | |
| checks.push({label:'Correctie instroom door historie 2025',ours:result.correctedExisting,shadow:independentlyRejected}); | |
| checks.push({label:'Correctie januari door historie 2025',ours:result.correctedJanuary,shadow:janRejected}); | |
| } | |
| const mismatches=checks.filter(c=>c.ours!==c.shadow).length; | |
| const sourceIssues=assessSourceIssues(result); | |
| return {checks,mismatches,sourceIssues,passed:mismatches===0&&sourceIssues.length===0}; | |
| } | |
| function drawImportedChart(result){ | |
| const cvs=$('chart'),ctx=cvs.getContext('2d'),w=cvs.width,h=cvs.height; | |
| ctx.clearRect(0,0,w,h);ctx.font='13px Segoe UI'; | |
| const series=result.locations.slice(0,4),max=Math.max(10,...series.flatMap(x=>x.visits))*1.15; | |
| ctx.strokeStyle='#29405a';ctx.fillStyle='#8aa4c3';ctx.textAlign='left'; | |
| for(let i=0;i<=4;i++){const y=25+i*45;ctx.beginPath();ctx.moveTo(44,y);ctx.lineTo(w-14,y);ctx.stroke();ctx.fillText(String(Math.round(max*(4-i)/4)),7,y+4)} | |
| const colors=['#3ee1b6','#ffc66a','#5b9cff','#ad88ff']; | |
| series.forEach((loc,li)=>{ | |
| ctx.strokeStyle=colors[li];ctx.lineWidth=3;ctx.beginPath(); | |
| loc.visits.forEach((v,j)=>{const x=72+(loc.visits.length===1?0:j*(w-105)/(loc.visits.length-1)),y=205-(v/max)*180;j===0?ctx.moveTo(x,y):ctx.lineTo(x,y)});ctx.stroke(); | |
| loc.visits.forEach((v,j)=>{const x=72+(loc.visits.length===1?0:j*(w-105)/(loc.visits.length-1)),y=205-(v/max)*180;ctx.fillStyle=colors[li];ctx.beginPath();ctx.arc(x,y,4,0,Math.PI*2);ctx.fill()}); | |
| });ctx.fillStyle='#a6b9d0';ctx.textAlign='center'; | |
| result.months.forEach((m,i)=>ctx.fillText(m,72+(result.months.length===1?0:i*(w-105)/(result.months.length-1)),239)); | |
| const legend=document.querySelector('.legend'); | |
| legend.innerHTML=series.map((loc,i)=>`<span><i style="background:${colors[i]}"></i> ${escapeHTML(loc.name)}</span>`).join(''); | |
| document.querySelector('.chart-panel .subtle').textContent=result.months.join(' · ')+(result.locations.length>4?' · 4 van '+result.locations.length+' locaties':''); | |
| } | |
| function renderImported(result){ | |
| const num=n=>n.toLocaleString('nl-NL'); | |
| const kpis=[{label:'Unieke bezoekdagen',value:num(result.visits),change:result.period.label+' · cliënt / dag / locatie'}, | |
| {label:'Unieke cliënten',value:num(result.unique),change:'Min. 1 geldige scan in gekozen periode'}, | |
| {label:'Nieuwe instroom',value:num(result.newIntake),change:'Eerste geldige scan in '+result.period.label+(result.historyLoaded?' · met historie 2025':' · voorlopig zonder 2025')}, | |
| {label:'Actieve cliënten',value:num(result.active),change:'Laatste 3 maanden t/m peildatum'}]; | |
| renderKPIs({kpis});drawImportedChart(result); | |
| const quality=result.verification||independentlyVerify(result); | |
| const lines=[ | |
| ['Geselecteerde periode',result.period.label+' ('+readableDate(result.period.begin)+' t/m '+readableDate(result.period.peildatum)+')'], | |
| ['Actieve cliënten: meetperiode',readableDate(result.period.activeFrom)+' t/m '+readableDate(result.peildatum)], | |
| ['Unieke cliënten in gekozen periode',num(result.unique)], | |
| ['Nieuwe instroom (per locatie)',num(result.newIntake)], | |
| ['Actieve cliënten (per locatie)',num(result.active)], | |
| ['Cliënten periode · extra controletelling',num(result.quarterClients)], | |
| ['Unieke bezoekdagen periode',num(result.visits)], | |
| ['Herhaalde scans periode',num(result.duplicates)], | |
| ['Historisch referentiebestand 2025',result.historyLoaded?'INGELEZEN · '+num(result.historyPeople)+' cliënt/locatie-combinaties':'NIET AANGELEVERD · instroom voorlopig'], | |
| ['Resultaat=0 uitgesloten uit historie 2025',result.historyLoaded?num(importedData.historyInfo.falseScans):'NIET AANGELEVERD'], | |
| ['Nieuwe instroom zonder 2025',num(result.preHistoryIntake)], | |
| ['Herkenbaar als bestaande cliënt in 2025',result.historyLoaded?num(result.correctedExisting):'NIET TE BEPALEN'], | |
| ['Waarvan januari 2026',result.historyLoaded?num(result.correctedJanuary):'NIET TE BEPALEN'], | |
| ['Resultaat=0 uitgesloten (2026 totaal)',num(importedData.resultZero)], | |
| ['Resultaat=0 uitgesloten (gekozen locatie/periode)',num(result.excludedFalse)], | |
| ['Lege/ongeldige locatienamen uitgesloten',num(importedData.ignoredSites)], | |
| ['Scanpassen: apart werkblad',result.passSheetFound?num(result.passRows)+' registraties':'NIET AANWEZIG'], | |
| ['Cliënten met meerdere passen',result.passChecked?num(result.multiPassClients):'NIET TE CONTROLEREN'], | |
| ['Scanregels via pas gekoppeld (hele bestand)',num(result.linkedByPass)], | |
| ['Migratiedatum 28-10-2022 (passen)',num(result.pasDates?.migration??0)], | |
| ['Ongeldige pasdatums',num(result.pasDates?.invalidDates??0)], | |
| ['Niet-herkende passen',num(result.unmatchedPasses)], | |
| ['Tegenstrijdige pasregistraties',num(result.passConflicts)], | |
| ['Verschil scan/registratie',num(result.identityConflicts)], | |
| ['Businesscontroller','NIET AANGELEVERD']]; | |
| const verifyLines=quality.checks.map(c=>'<div class="check-row"><span>'+escapeHTML(c.label)+'</span><span>'+num(c.ours)+' / '+num(c.shadow)+' <strong class="'+(c.ours===c.shadow?'':'bad')+'">'+(c.ours===c.shadow?'✓ gelijk':'⚠ verschil')+'</strong></span></div>').join(''); | |
| $('checks').innerHTML='<div style="font-size:10px;color:#a8c9ee;margin:5px 0 11px">TWEEDE CONTROLETELLING · DASHBOARD / CONTROLE</div>'+verifyLines+'<div style="font-size:10px;color:#a8c9ee;margin:12px 0 9px">BRON- EN PERIODECONTROLES</div>'+lines.map(([name,val])=>'<div class="check-row"><span>'+escapeHTML(name)+'</span><span>'+escapeHTML(val)+'</span></div>').join(''); | |
| const impactLabel={none:'GEEN DIRECT REKENKUNDIG EFFECT',possible:'MOGELIJKE INVLOED OP KPI’S',unknown:'IMPACT NIET VAST TE STELLEN'}; | |
| const sourceAudit=quality.sourceIssues.length | |
| ?'<div class="source-audit"><h4>BRONAFWIJKINGEN · AANTAL & KPI-IMPACT</h4><p class="source-intro">Aantallen komen uit het bronbestand; de toelichting over scanregels betreft de gekozen locatie en periode, tenzij anders vermeld. Eén scanregel kan onder meerdere afwijkingen vallen.</p>'+ | |
| quality.sourceIssues.map(i=>'<div class="source-issue"><div class="source-issue-top"><span class="source-issue-name">'+escapeHTML(i.label)+'</span><span class="source-issue-count">'+num(i.total)+' registratie'+(i.total===1?'':'s')+'</span></div><div class="source-issue-impact '+i.impact+'">'+impactLabel[i.impact]+'</div><p class="source-issue-detail">'+escapeHTML(i.detail)+'</p></div>').join('')+'</div>' | |
| :'<div class="source-audit"><h4>BRONAFWIJKINGEN · AANTAL & KPI-IMPACT</h4><p class="source-audit-ok">Geen bronafwijkingen gesignaleerd door de beschikbare controles.</p></div>'; | |
| $('checks').insertAdjacentHTML('beforeend',sourceAudit); | |
| const issue=quality.mismatches||quality.sourceIssues.length; | |
| document.querySelector('.verification .warning').textContent=quality.mismatches?`⚠ ${quality.mismatches} interne telverschillen gevonden. Cijfers nader onderzoeken. NIET GEVALIDEERD.`: | |
| quality.sourceIssues.length?`⚠ Tweede telling sluit aan; ${quality.sourceIssues.length} soorten bronafwijkingen beoordeeld. Zie aantallen en KPI-impact hierboven. NIET EXTERN GEVALIDEERD.`: | |
| `✓ ${quality.checks.length} interne telcontroles sluiten aan. ${result.historyLoaded?'Historie 2025 meegenomen.':'2025 ontbreekt: instroom voorlopig.'} Businesscontrollercontrole optioneel; niet extern gevalideerd.`; | |
| renderInlineReportCharts(); | |
| } | |
| async function startImported(){ | |
| if(running)return; | |
| running=true;$('startBtn').disabled=true;$('resetBtn').disabled=true;completed=0;clearSignal(); | |
| $('logs').innerHTML='';$('completed').textContent='0';$('conductor').classList.remove('attention'); | |
| $('conductor').classList.add('running');document.querySelector('.orchestra').classList.add('running'); | |
| $('conductorStatus').textContent='Excel-berekeningen worden gepresenteerd';$('logStatus').textContent='Actief'; | |
| for(const id of ids){let el=$('agent-'+id);el.classList.remove('active','done');el.querySelector('.status').textContent='STANDBY';el.querySelector('.track i').style.width='0%';el.querySelector('.percent').textContent='0%'} | |
| const result=calculateImported($('siteSelect').value);result.verification=independentlyVerify(result);importedData.last=result; | |
| const steps=[ | |
| {agent:'data',text:`${result.rows.toLocaleString('nl-NL')} scanregels gecontroleerd; ${result.duplicates} herhaalde scans; Scanpassen: ${result.passSheetFound?result.passRows+' registraties':'geen apart werkblad'}`}, | |
| {agent:'analysis',text:`${result.unique} unieke cliënten in de periode; ${result.newIntake} nieuwe instromers in ${result.period.label}; ${result.active} actieve cliënten`}, | |
| {agent:'insight',text:'Maandtrends berekend; AI-duiding nog niet verbonden'}, | |
| {agent:'verify',text:`${result.verification.checks.length} onafhankelijke interne telcontroles; ${result.verification.mismatches} afwijkingen; ${result.correctedExisting} instromers herkend uit 2025. Extern niet gevalideerd`}, | |
| {agent:'report',text:'Conceptpreview bijgewerkt · NIET GEVALIDEERD'}]; | |
| try{ | |
| resetControllerComparison(true);updateControllerContext(); | |
| renderImported(result); | |
| for(const step of steps){const el=$('agent-'+step.agent);signalFor(step.agent);el.classList.add('active');el.querySelector('.status').textContent='ACTIEF';$('flowState').textContent=step.text;log('Start: '+el.querySelector('h3').textContent); | |
| const stageLabels=stepDescriptions(step.agent,{...result,sites:importedData.sites.length},false);showStages(step.agent,stageLabels);await animateStages(step.agent,stageLabels); | |
| clearSignal();el.classList.remove('active');el.classList.add('done');el.querySelector('.status').textContent=step.agent==='verify'?(result.verification.passed?'INTERN OK':'LET OP'):'GEREED';completed++;$('completed').textContent=String(completed);log(step.text);await sleep(200) | |
| } | |
| $('conductor').classList.add('attention');$('flowState').textContent='Concept · NIET GEVALIDEERD';$('conductorStatus').textContent='Proefberekening voltooid';$('logStatus').textContent='Afgerond'; | |
| }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')} | |
| } | |
| $('scanFile').addEventListener('change',async e=>{ | |
| 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; | |
| const state=$('importState');state.textContent='Bestand wordt lokaal gelezen...';$('startBtn').disabled=true; | |
| try{ | |
| const importStart=performance.now(); | |
| importedData=await readExcelLocal(file,msg=>{state.textContent=msg}); | |
| const importSeconds=((performance.now()-importStart)/1000).toFixed(1); | |
| const sel=$('siteSelect');sel.innerHTML='<option value="*">Alle locaties</option>'; | |
| importedData.sites.forEach(loc=>{const opt=document.createElement('option');opt.value=loc;opt.textContent=loc;sel.appendChild(opt)}); | |
| sel.disabled=false; | |
| $('historyFile').disabled=false; | |
| const type=$('periodType'); | |
| type.disabled=false; | |
| type.value=availablePeriods('nine').length?'nine':'quarter'; | |
| refreshPeriodChoices(); | |
| state.textContent=`2026: ${importedData.all.length.toLocaleString('nl-NL')} bruikbare scans · ${importedData.sites.length} locaties · ${importSeconds} sec · historie 2025 optioneel`; | |
| setHistoryStatus('◴ Historie 2025 ontbreekt nog: upload het aparte referentiebestand om bestaande cliënten uit 2025 te herkennen.','warning'); | |
| $('startBtn').textContent='▶ Analyseer Excelbestand'; | |
| $('flowState').textContent='Excelbestand lokaal ingelezen · start de analyse'; | |
| log('Excelbestand lokaal verwerkt; analyse moet nog starten. Voor echte data eerst interne toestemming controleren.'); | |
| }catch(err){$('startBtn').textContent='▶ Start voorbeeldanalyse';state.textContent='⚠ '+err.message;$('siteSelect').disabled=true;$('periodSelect').disabled=true;$('periodType').disabled=true;log('Excel-proef: '+err.message)}finally{$('startBtn').disabled=false} | |
| }); | |
| $('historyFile').addEventListener('change',async e=>{ | |
| const file=e.target.files?.[0];if(!file||!importedData)return; | |
| const state=$('importState');$('startBtn').disabled=true; | |
| importedData.historyLoaded=false;importedData.historyFirst=new Map();importedData.historyInfo=null;importedData.last=null; | |
| resetControllerComparison(true);updateControllerContext(); | |
| try{ | |
| const begun=performance.now(); | |
| const index=await readHistoricFirsts(file,msg=>setHistoryStatus(msg)); | |
| importedData.historyFirst=index.firsts;importedData.historyInfo=index.info;importedData.historyLoaded=true; | |
| const duration=((performance.now()-begun)/1000).toFixed(1); | |
| 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'); | |
| state.textContent='2026 + historische referentie 2025 gereed · kies locatie/periode en start analyse'; | |
| $('flowState').textContent='2025 gekoppeld · analyse opnieuw starten'; | |
| log('Referentie 2025 gekoppeld; alle locaties uit het rapportagebestand; eerdere bezoeken kunnen instroom corrigeren.'); | |
| }catch(err){setHistoryStatus('⚠ Historie 2025 niet gekoppeld: '+err.message,'warning');$('historyFile').value='';log('Historische import: '+err.message)} | |
| finally{$('startBtn').disabled=false;} | |
| }); | |
| $('siteSelect').addEventListener('change',()=>{if(importedData){importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Locatie gewijzigd · analyse opnieuw starten';}}); | |
| $('periodSelect').addEventListener('change',()=>{if(importedData){importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Rapportageperiode gewijzigd · analyse opnieuw starten';}}); | |
| $('periodType').addEventListener('change',()=>{if(importedData){refreshPeriodChoices();importedData.last=null;resetControllerComparison(true);updateControllerContext();$('flowState').textContent='Rapportagesoort gewijzigd · analyse opnieuw starten';}}); | |
| // Bestaande chat blijft demo-gericht. Voor echte Excel-proef antwoorden we uitsluitend met controleerbare berekende cijfers. | |
| const originalReply=reply; | |
| reply=function(q){ | |
| if(!importedData)return originalReply(q); | |
| const r=importedData.last; | |
| if(!r)return 'Klik op Start demo-analyse om de lokaal ingelezen testgegevens te berekenen.'; | |
| 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.'; | |
| 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 geselecteerde periode, ${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.`; | |
| return 'De AI-API is nog niet gekoppeld. Ik kan voorlopig alleen de gecontroleerde basisgetallen van het fictieve Excel-testbestand toelichten.'; | |
| }; | |
| // v0.8 — rapport wordt opgebouwd uit lokaal berekende, geaggregeerde testgegevens. | |
| // Er is geen LLM, er wordt niet geüpload en ontbrekende velden worden NIET ingevuld. | |
| const reportNL=n=>Number(n).toLocaleString('nl-NL'); | |
| function reportChartBars(names,values,color='#7935cf',asWeek=false,dark=false){ | |
| const W=440,H=195,left=38,right=12,top=17,bottom=31,max=Math.max(1,...values); | |
| const gridColor=dark?'#2d4b67':'#e6e8f2'; | |
| const valueColor=dark?'#dff3ff':'#18116b'; | |
| const labelColor=dark?'#a8c7e5':'#1f2a56'; | |
| const bgRect=dark?`<rect x="0" y="0" width="${W}" height="${H}" rx="8" fill="#0c182a"/>`:''; | |
| let out=`<svg viewBox="0 0 ${W} ${H}" width="100%" height="100%" role="img" aria-label="Staafgrafiek">${bgRect}`; | |
| for(let i=0;i<=4;i++){let y=top+(H-top-bottom)*i/4;out+=`<line x1="${left}" y1="${y}" x2="${W-right}" y2="${y}" stroke="${gridColor}" stroke-width="1"/>`} | |
| const band=(W-left-right)/Math.max(1,names.length),bar=Math.min(38,band*.7); | |
| values.forEach((v,i)=>{const bh=(H-top-bottom-10)*v/max,x=left+band*i+(band-bar)/2,y=H-bottom-bh; | |
| out+=`<rect x="${x}" y="${y}" width="${bar}" height="${Math.max(bh,0)}" fill="${color}" rx="1.5"/><text x="${x+bar/2}" y="${Math.max(10,y-5)}" text-anchor="middle" fill="${valueColor}" font-size="${asWeek?10:12}" font-weight="700">${v}</text><text x="${x+bar/2}" y="${H-11}" text-anchor="middle" fill="${labelColor}" font-size="${asWeek?8:11}">${escapeHTML(names[i])}</text>`}); | |
| return out+'</svg>'; | |
| } | |
| function reportChartClientMonth(names,values,dark=false){ | |
| // Alleen presentatie: dezelfde berekende maandwaarden; iedere cliënt eenmaal per maand. | |
| const W=480,H=220,left=24,right=9,top=19,bottom=34; | |
| const max=Math.max(1,...values),span=H-top-bottom; | |
| const gridColor=dark?'#2d4b67':'#e8e6f2'; | |
| const valueColor=dark?'#eee6ff':'#2e196e'; | |
| const labelColor=dark?'#c3b4ff':'#22285e'; | |
| const bgRect=dark?`<rect x="0" y="0" width="${W}" height="${H}" rx="8" fill="#0c182a"/>`:''; | |
| const band=(W-left-right)/Math.max(1,names.length),bar=Math.min(46,band*.82); | |
| let out=`<svg viewBox="0 0 ${W} ${H}" width="100%" height="100%" role="img" aria-label="Aantal unieke cliënten per maand">${bgRect}`; | |
| for(let i=0;i<=4;i++){ | |
| const y=top+span*i/4; | |
| out+=`<line x1="${left}" y1="${y}" x2="${W-right}" y2="${y}" stroke="${gridColor}" stroke-width="1"/>`; | |
| } | |
| values.forEach((v,i)=>{ | |
| const height=(span-15)*v/max; | |
| const x=left+band*i+(band-bar)/2,y=H-bottom-height; | |
| out+=`<rect x="${x}" y="${y}" width="${bar}" height="${Math.max(0,height)}" fill="#763fc7" rx="1.8"/>`; | |
| out+=`<text x="${x+bar/2}" y="${Math.max(13,y-6)}" text-anchor="middle" fill="${valueColor}" font-size="14" font-weight="800">${v}</text>`; | |
| out+=`<text x="${x+bar/2}" y="${H-11}" text-anchor="middle" fill="${labelColor}" font-size="12" font-weight="600">${escapeHTML(names[i])}</text>`; | |
| }); | |
| return out+'</svg>'; | |
| } | |
| function reportChartLine(names,values){ | |
| if(!values.length)return '<div class="report-no-data">Geen gegevens beschikbaar</div>'; | |
| const W=440,H=195,L=40,T=24,B=34,R=15,max=Math.max(1,...values)*1.16,span=W-L-R; | |
| let out=`<svg viewBox="0 0 ${W} ${H}" width="100%" height="100%" role="img" aria-label="Lijngrafiek">`; | |
| for(let i=0;i<=4;i++){const y=T+(H-T-B)*i/4;out+=`<line x1="${L}" y1="${y}" x2="${W-R}" y2="${y}" stroke="#e5e8f2"/><text x="7" y="${y+4}" font-size="10" fill="#3e4771">${Math.round(max*(4-i)/4)}</text>`} | |
| const dots=values.map((v,i)=>[L+(values.length===1?span/2:span*i/(values.length-1)),H-B-v/max*(H-T-B-7)]); | |
| out+=`<polyline fill="none" stroke="#6630dc" stroke-width="3" points="${dots.map(p=>p.join(',')).join(' ')}"/>`; | |
| dots.forEach(([x,y],i)=>{out+=`<circle cx="${x}" cy="${y}" r="4" fill="#6630dc"/><text x="${x}" y="${Math.max(11,y-10)}" font-size="11" font-weight="700" text-anchor="middle" fill="#5a22cb">${values[i]}</text><text x="${x}" y="${H-9}" text-anchor="middle" font-size="10" fill="#1d255e">${escapeHTML(names[i])}</text>`}); | |
| return out+'</svg>'; | |
| } | |
| // Hergebruik exact de grafieken en de brongegevens van de witte Reakt-monitor. | |
| // Geen aparte KPI-rekenregels, geen AI-API en geen verzonnen grafiekwaarden. | |
| function renderInlineReportCharts(){ | |
| const host=$('inlineReportCharts');if(!host)return; | |
| const data=getReportData(); | |
| if(!data){host.innerHTML='<div class="inline-chart-empty">Analyseer eerst een Excelbestand om de bestaande rapportagegrafieken te zien.</div>';return;} | |
| const labels=data.months.map(m=>m.label); | |
| const specs=[ | |
| ['Nieuwe instroom per maand','Eerste geregistreerde scan op locatie',reportChartBars(labels,data.months.map(m=>m.newIntake),'#63d219',false,true)], | |
| ['Bezoekdagen per weekdag','Unieke cliënt-/locatie-/bezoekdagen',reportChartBars(['Ma','Di','Wo','Do','Vr','Za','Zo'],data.byWeek,'#ff7a10',true,true)], | |
| ['Aantal cliënten per maand','Iedere cliënt maximaal één keer per maand',reportChartClientMonth(labels,data.months.map(m=>m.clients),true)] | |
| ]; | |
| host.innerHTML=specs.map(([title,description,svg])=>`<section class="inline-chart-card"><h4>${title} <small>· ${description}</small></h4><div class="inline-chart-svg">${svg}</div></section>`).join('')+ | |
| '<div class="inline-chart-foot">Dezelfde gegevens en grafieken als in de witte Reakt-monitor. Alleen lokale Excelanalyse · niet extern gevalideerd.</div>'; | |
| } | |
| function invalidateInlineReportCharts(){const host=$('inlineReportCharts');if(host)host.innerHTML='<div class="inline-chart-empty">Selectie gewijzigd: start de analyse voor bijgewerkte grafieken.</div>';} | |
| function getReportData(){ | |
| if(!importedData?.last) return null; | |
| const result=importedData.last,site=result.site,period=result.period; | |
| const history=importedData.all.filter(r=>(site==='*'||r.site===site)&&r.date<=period.peildatum); | |
| const quarter=history.filter(r=>r.date>=period.begin); | |
| const counts=new Map(),uniqueDates=new Set(),byMonth=new Map(period.months.map(m=>[m,0])),clientsByMonth=new Map(period.months.map(m=>[m,new Set()])),daysWithScans=new Set(),byWeek=[0,0,0,0,0,0,0]; | |
| for(const r of quarter){ | |
| const key=JSON.stringify([r.site,r.id,r.date]);if(uniqueDates.has(key))continue; | |
| uniqueDates.add(key); | |
| const mk=r.date.slice(0,7);byMonth.set(mk,(byMonth.get(mk)||0)+1); | |
| const wk=new Date(r.date+'T12:00:00Z').getUTCDay();byWeek[(wk+6)%7]++; | |
| const ck=JSON.stringify([r.site,r.id]);counts.set(ck,(counts.get(ck)||0)+1); | |
| clientsByMonth.get(mk)?.add(ck); | |
| daysWithScans.add(r.date); | |
| } | |
| const intensities=[0,0,0,0,0],daysPerClient=[...counts.values()]; | |
| daysPerClient.forEach(c=>{intensities[c<=5?0:c<=20?1:c<=50?2:c<=100?3:4]++}); | |
| const sorted=daysPerClient.sort((a,b)=>a-b),median=sorted.length?(sorted.length%2?sorted[(sorted.length-1)/2]:(sorted[sorted.length/2-1]+sorted[sorted.length/2])/2):0; | |
| const monthLabels=['jan','feb','mrt','apr','mei','jun','jul','aug','sep','okt','nov','dec']; | |
| const months=period.months.map(key=>({key,label:monthLabels[Number(key.slice(5))-1],visits:byMonth.get(key),clients:clientsByMonth.get(key)?.size||0,newIntake:result.instroomByMonth.get(key)||0})); | |
| return {site:site==='*'?'Alle locaties':site,period,months,byWeek,intensities, | |
| visits:uniqueDates.size,unique:result.unique,quarterClients:result.quarterClients,newIntake:result.newIntake,active:result.active, | |
| average:counts.size?uniqueDates.size/counts.size:0,averageClientsPerDay:daysWithScans.size?uniqueDates.size/daysWithScans.size:0,daysWithScans:daysWithScans.size,median,maxDays:Math.max(0,...daysPerClient), | |
| earliest:period.begin,latest:period.peildatum,issues:result.passConflicts+result.identityConflicts+result.unmatchedPasses,hasShadow:false, | |
| historyLoaded:result.historyLoaded,correctedExisting:result.correctedExisting,correctedJanuary:result.correctedJanuary}; | |
| } | |
| function buildWhiteReport(){ | |
| const d=getReportData();if(!d)return; | |
| const range=d.earliest===d.latest?d.earliest:`${d.earliest} t/m ${d.latest}`; | |
| const avgDay=d.averageClientsPerDay.toLocaleString('nl-NL',{minimumFractionDigits:1,maximumFractionDigits:1}); | |
| const activeWindow=readableDate(d.period.activeFrom)+' t/m '+readableDate(d.period.peildatum); | |
| const mon=d.months.map(m=>m.label),values=d.months.map(m=>m.visits),clientsPerMonth=d.months.map(m=>m.clients),newPerMonth=d.months.map(m=>m.newIntake),week=['Ma','Di','Wo','Do','Vr','Za','Zo']; | |
| const weekdayMax=Math.max(...d.byWeek),weekdayMin=Math.min(...d.byWeek),busiest=week[d.byWeek.indexOf(weekdayMax)],quietest=week[d.byWeek.indexOf(weekdayMin)]; | |
| const summary=[ | |
| `In ${d.period.label} registreerde ${escapeHTML(d.site)} ${reportNL(d.unique)} unieke cliënten en ${reportNL(d.visits)} bezoekdagen.`, | |
| `Op ${reportNL(d.daysWithScans)} dagen met geregistreerde scans kwamen gemiddeld ${avgDay} cliënten per dag. De meeste bezoekdagen vielen op ${busiest} (${reportNL(weekdayMax)}).`, | |
| `Nieuwe instroom: ${reportNL(d.newIntake)} cliënten. Actieve cliënten in de afgelopen drie maanden: ${reportNL(d.active)}.${d.historyLoaded?' Na vergelijking met 2025 bleken '+reportNL(d.correctedExisting)+' aanvankelijk nieuwe cliënten al eerder geregistreerd.':' Historie over 2025 ontbreekt; de instroom is voorlopig.'}` | |
| ]; | |
| const kpis=[{klass:'purple',icon:'♟',label:'Unieke cliënten',value:reportNL(d.unique),sub:''}, | |
| {klass:'green',icon:'+',label:'Nieuwe instroom',value:reportNL(d.newIntake),sub:'Eerste geldige scan op locatie in '+d.period.label}, | |
| {klass:'purple',icon:'♙',label:'Actieve cliënten',value:reportNL(d.active),sub:'Minimaal 1 scan in de afgelopen drie maanden.'}, | |
| {klass:'orange',icon:'▦',label:'Bezoekdagen',value:reportNL(d.visits),sub:'Eén cliënt per locatie per kalenderdag'}]; | |
| const kpiHTML=kpis.map(k=>`<div class="report-kpi ${k.klass}"><div class="k-icon">${k.icon}</div><div><div class="k-label">${k.label}</div><div class="k-num">${k.value}</div><div class="k-sub">${k.sub}</div></div><div class="k-underline"></div></div>`).join(''); | |
| $('reportSheet').innerHTML=` | |
| <div class="report-brand"><img class="report-logo" src="data:image/png;base64,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" alt="Reakt PG logo"></div> | |
| <div class="report-heading"><div><h1>Reakt Inloop Monitor ${escapeHTML(d.site)}</h1><p>Inzicht in bereik, betrokkenheid en bezoekpatronen</p></div> | |
| <div class="report-meta"><div><b class="meta-icon">▦</b><span><strong>Periode</strong>${escapeHTML(range)}</span></div><div><b class="meta-icon">⌖</b><span><strong>Locatie</strong>${escapeHTML(d.site)}</span></div><div><b class="meta-icon">↻</b><span><strong>Peildatum</strong>${escapeHTML(d.latest)}</span></div></div></div> | |
| <div class="report-kpi-grid">${kpiHTML}</div> | |
| <section class="report-definitions" aria-label="Definities cliëntindicatoren"> | |
| <div class="report-definitions-title">Zo leest u de cliëntcijfers</div> | |
| <div class="report-definitions-grid"> | |
| <div><b>Unieke cliënten</b>Iedere cliënt met minimaal één geldige scan op deze locatie binnen de gekozen rapportageperiode.</div> | |
| <div><b>Nieuwe instroom</b>De eerste geldige scan van een cliënt op deze locatie (binnen de beschikbare historie) valt in de rapportageperiode.</div> | |
| <div><b>Actieve cliënten</b>Minimaal één scan op deze locatie tussen ${escapeHTML(activeWindow)}.</div> | |
| </div> | |
| <div class="report-definitions-note">Een vervangende scanpas telt niet als nieuwe cliënt.</div> | |
| </section> | |
| <div class="report-chart-grid"> | |
| <section class="report-panel"><h2>Instroom per maand <small>(nieuwe cliënten)</small></h2><div class="chart-box">${reportChartBars(mon,newPerMonth,'#288e28')}</div></section> | |
| <section class="report-panel"><h2>Bezoekdagen per maand</h2><div class="chart-box">${reportChartLine(mon,values)}</div></section> | |
| <section class="report-panel"><h2>Bezoekdagen per weekdag</h2><div class="chart-box">${reportChartBars(week,d.byWeek,'#ff6410',true)}</div></section> | |
| </div> | |
| <div class="report-bottom-grid"> | |
| <section class="report-panel"><h2>Kerncijfers locatie</h2><table class="report-mini-table"><thead><tr><th>Locatie</th><th>Unieke cliënten</th><th>Bezoekdagen</th><th>Gem. cliënten per dag*</th></tr></thead><tbody><tr><td>⌖ ${escapeHTML(d.site)}</td><td>${reportNL(d.unique)}</td><td>${reportNL(d.visits)}</td><td>${avgDay}</td></tr><tr class="total"><td>Totaal / gemiddelde</td><td>${reportNL(d.unique)}</td><td>${reportNL(d.visits)}</td><td>${avgDay}</td></tr></tbody></table><div class="report-average-highlight"><span class="highlight-num">${avgDay}</span><span class="highlight-label">Gemiddeld aantal cliënten per dag</span></div><div class="report-average-note">* Berekend over ${reportNL(d.daysWithScans)} dagen met geregistreerde scans.</div></section> | |
| <section class="report-panel"><h2>Aantal cliënten per maand <small>(iedere cliënt maximaal één keer per maand)</small></h2><div class="chart-box report-client-month-chart">${reportChartClientMonth(mon,clientsPerMonth)}</div></section> | |
| <section class="report-panel"><h2>Managementanalyse</h2><div class="report-analysis">${summary.map(t=>`<p>${t}</p>`).join('')}</div></section> | |
| </div> | |
| <div class="report-foot"><span>Bron: scanregistratie 2026${d.historyLoaded?' en historisch referentiebestand 2025':''}. Rapportageperiode en peildatum staan bovenaan vermeld.</span></div>`; | |
| } | |
| function openWhiteReport(){ | |
| if(!importedData?.last){alert('Upload eerst een Excelbestand en rond de analyse af. Deze witte rapportage gebruikt geen verzonnen demo-KPI’s.');return;} | |
| 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;} | |
| buildWhiteReport();$('reportOverlay').classList.add('open');$('reportOverlay').setAttribute('aria-hidden','false');document.body.classList.add('report-open');$('reportOverlay').scrollTop=0; | |
| } | |
| function closeWhiteReport(){$('reportOverlay').classList.remove('open');$('reportOverlay').setAttribute('aria-hidden','true');document.body.classList.remove('report-open')} | |
| async function downloadReportImage(type='png'){ | |
| const overlay=$('reportOverlay'); | |
| if(!overlay.classList.contains('open')) openWhiteReport(); | |
| const target=$('reportSheet'); | |
| const pngBtn=$('reportPng'), jpgBtn=$('reportJpg'); | |
| const activeBtn=type==='png'?pngBtn:jpgBtn; | |
| if(!window.html2canvas){ | |
| alert('De afbeeldingsexport kon niet starten. Vernieuw de pagina en probeer opnieuw.'); | |
| return; | |
| } | |
| const prev=activeBtn.textContent; | |
| try{ | |
| activeBtn.disabled=true; activeBtn.textContent='Bezig met exporteren...'; | |
| await new Promise(r=>setTimeout(r,80)); | |
| const canvas=await html2canvas(target,{backgroundColor:'#ffffff',scale:2.5,useCORS:true,logging:false}); | |
| const mime=type==='jpg'?'image/jpeg':'image/png'; | |
| const quality=type==='jpg'?0.94:1; | |
| const dataUrl=canvas.toDataURL(mime,quality); | |
| const link=document.createElement('a'); | |
| const site=(importedData?.last?.site||'monitor').toLowerCase().replace(/[^a-z0-9]+/gi,'_'); | |
| link.href=dataUrl; | |
| link.download=`reakt_inloop_monitor_${site}.${type==='jpg'?'jpg':'png'}`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| link.remove(); | |
| }catch(err){ | |
| console.error(err); | |
| alert('Het downloaden van de afbeelding is niet gelukt. Probeer het opnieuw.'); | |
| }finally{ | |
| activeBtn.disabled=false; activeBtn.textContent=prev; | |
| } | |
| } | |
| $('reportClose').addEventListener('click',closeWhiteReport); | |
| $('reportPrint').addEventListener('click',()=>window.print()); | |
| $('reportPng').addEventListener('click',()=>downloadReportImage('png')); | |
| $('reportJpg').addEventListener('click',()=>downloadReportImage('jpg')); | |
| document.addEventListener('keydown',e=>{if(e.key==='Escape'&&$('reportOverlay').classList.contains('open'))closeWhiteReport()}); | |
| // Dashboard uitsluitend na een afgeronde berekening voor één geselecteerde locatie. | |
| function syncDashboardButton(){const b=$('exportBtn');if(b)b.disabled=running||!importedData?.last||importedData.last.site==='*';} | |
| ['siteSelect','periodSelect','periodType','scanFile','historyFile'].forEach(id=>$(id)?.addEventListener('change',()=>{$('exportBtn').disabled=true;})); | |
| $('startBtn').addEventListener('click',start);$('resetBtn').addEventListener('click',reset);$('chatForm').addEventListener('submit',e=>{e.preventDefault();const q=$('chatInput').value;$('chatInput').value='';ask(q)});document.querySelectorAll('[data-q]').forEach(b=>b.addEventListener('click',()=>ask(b.dataset.q)));$('exportBtn').addEventListener('click',openWhiteReport); | |
| Promise.resolve({ok:true,json:async()=>JSON.parse(document.getElementById('orchestra-demo-data').textContent)}).then(r=>r.json()).then(d=>{demoData=d;renderKPIs(d);renderChecks(d);drawChart(d)}).catch(()=>log('Demo-gegevens konden niet worden geladen')); | |
| // v1.2 — expliciete, handmatig ingevoerde onafhankelijke fictieve aggregaten. | |
| // Resultaten worden NIET opgeslagen; andere locatie/kwartaal/bestand maakt vergelijking ongeldig. | |
| const controllerDefs=[['unique','ctrlUnique','Unieke cliënten (periode)'],['newIntake','ctrlIntake','Nieuwe instroom'],['active','ctrlActive','Actieve cliënten'],['visits','ctrlVisits','Unieke bezoekdagen']]; | |
| let controllerComparison=null; | |
| function resetControllerComparison(clearInputs=false){ | |
| controllerComparison=null; | |
| $('controllerBadge').textContent='NIET AANGELEVERD'; | |
| $('controllerResult').textContent='Nog geen onafhankelijke controlecijfers vergeleken.'; | |
| if(clearInputs)controllerDefs.forEach(([,id])=>{$(id).value=''}); | |
| } | |
| function updateControllerContext(){ | |
| const r=importedData?.last; | |
| $('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.'; | |
| } | |
| function compareController(){ | |
| const r=importedData?.last; | |
| if(!r){$('controllerResult').textContent='⚠ Analyseer eerst een fictief Excel-testbestand.';return} | |
| const values={}; | |
| for(const [key,id] of controllerDefs){const el=$(id),v=el.value.trim();if(!/^\d+$/.test(v)||!Number.isSafeInteger(Number(v))){$('controllerResult').textContent='⚠ Vul alle vier de controlewaarden in als geldige, niet-negatieve gehele getallen.';el.focus();return}values[key]=Number(v)} | |
| const rows=controllerDefs.map(([key,,label])=>({label,ours:r[key],controller:values[key],delta:r[key]-values[key]})); | |
| const count=rows.filter(x=>x.delta!==0).length; | |
| controllerComparison={site:r.site,period:r.period.label,rows,count}; | |
| const fmt=x=>Number(x).toLocaleString('nl-NL'); | |
| const rendered=rows.map(x=>`<tr><td>${escapeHTML(x.label)}</td><td>${fmt(x.ours)}</td><td>${fmt(x.controller)}</td><td class="${x.delta?'controller-bad':'controller-ok'}">${x.delta?(x.delta>0?'+':'')+fmt(x.delta):'✓ 0'}</td></tr>`).join(''); | |
| $('controllerResult').innerHTML=`<div class="controller-summary">${count?`⚠ ${count} van 4 indicatoren wijken af. Onderzoek de oorzaak voordat de cijfers worden gebruikt.`:'✓ Alle 4 ingevoerde controlegetallen komen overeen. Dit is nog geen formele validatie.'}</div><table class="controller-table"><thead><tr><th>Indicator</th><th>ORCHESTRA</th><th>Controle</th><th>Verschil</th></tr></thead><tbody>${rendered}</tbody></table>`; | |
| $('controllerBadge').textContent=count?`${count} VERSCHIL(LEN)`:'4 / 4 GELIJK'; | |
| log('Fictieve businesscontrollervergelijking: '+count+' verschil(len) op 4 indicatoren; geen formele vrijgave'); | |
| } | |
| $('controllerCompare').addEventListener('click',compareController); | |
| $('controllerClear').addEventListener('click',()=>resetControllerComparison(true)); | |
| controllerDefs.forEach(([,id])=>$(id).addEventListener('input',()=>{if(controllerComparison)resetControllerComparison(false)})); | |
| // ORCHESTRA NEXT v4. De concrete rekenfuncties hierboven blijven ongewijzigd. | |
| // De netwerkpaden worden aan de kaartRANDEN gekoppeld, niet achter het midden van de kaart. | |
| // Bewegende lichtpunten gebruiken requestAnimationFrame/getPointAtLength: zichtbaar in moderne browsers, | |
| // zonder afhankelijkheid van SVG animateMotion. De wachttijd is presentatie; echte rekentijd meten we apart. | |
| const nextRoutes=[ | |
| ['conductor','identity'],['data','identity'],['identity','history'], | |
| ['data','analysis'],['identity','insight'],['history','verify'], | |
| ['analysis','insight'],['insight','verify'],['analysis','privacy'], | |
| ['insight','report'],['verify','quality'],['privacy','report'],['report','quality'] | |
| ]; | |
| const nextIds=['data','identity','history','analysis','insight','verify','privacy','report','quality']; | |
| const nextRouteMap=new Map(); | |
| let nextActiveKeys=[],nextFinishedKeys=[],nextAnimationFrame=0,nextAnimationStart=0,nextSignalCircles=[],nextIsRunning=false,nextTransferTimeout=0; | |
| const nextMotionDisabled=()=>window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| const nextAgentElement=id=>id==='conductor'?$('conductor'):$('agent-'+id); | |
| const nextRouteKey=(a,b)=>a+'>'+b; | |
| const nextWait=ms=>new Promise(resolve=>setTimeout(resolve,ms)); | |
| const nextShowCount=()=>{$('completed').textContent=String(nextIds.filter(id=>$('agent-'+id).classList.contains('done')).length)}; | |
| function nextPathFor(a,b,frame){ | |
| const acx=(a.left+a.right)/2,acy=(a.top+a.bottom)/2,bcx=(b.left+b.right)/2,bcy=(b.top+b.bottom)/2; | |
| const sameRow=Math.abs(acy-bcy)<Math.min(a.height,b.height)*.58; | |
| let x1,y1,x2,y2; | |
| if(sameRow){const towardsRight=bcx>=acx;x1=towardsRight?a.right:a.left;y1=acy;x2=towardsRight?b.left:b.right;y2=bcy; | |
| const bend=Math.min(20,Math.abs(x2-x1)*.15);return `M ${x1-frame.left} ${y1-frame.top} C ${x1-frame.left+(towardsRight?bend:-bend)} ${y1-frame.top-10},${x2-frame.left-(towardsRight?bend:-bend)} ${y2-frame.top+10},${x2-frame.left} ${y2-frame.top}`;} | |
| const downward=bcy>=acy; | |
| x1=acx;y1=downward?a.bottom:a.top;x2=bcx;y2=downward?b.top:b.bottom; | |
| const mid=(y1+y2)/2; | |
| return `M ${x1-frame.left} ${y1-frame.top} C ${x1-frame.left} ${mid-frame.top},${x2-frame.left} ${mid-frame.top},${x2-frame.left} ${y2-frame.top}`; | |
| } | |
| function nextMakeSvg(tag,attrs={},className=''){ | |
| const n=document.createElementNS('http://www.w3.org/2000/svg',tag); | |
| Object.entries(attrs).forEach(([k,v])=>n.setAttribute(k,String(v))); | |
| if(className)n.setAttribute('class',className); | |
| return n; | |
| } | |
| function nextBuildGraph(){ | |
| const root=document.querySelector('.orchestra'),svg=$('networkSvg'),edge=$('networkEdges'); | |
| if(!root||!svg||!edge)return; | |
| const frame=root.getBoundingClientRect();if(!frame.width||!frame.height)return; | |
| svg.setAttribute('viewBox',`0 0 ${frame.width} ${frame.height}`); | |
| edge.replaceChildren();nextRouteMap.clear(); | |
| for(const [from,to] of nextRoutes){ | |
| const a=nextAgentElement(from)?.getBoundingClientRect(),b=nextAgentElement(to)?.getBoundingClientRect(); | |
| if(!a||!b)continue; | |
| const d=nextPathFor(a,b,frame),key=nextRouteKey(from,to); | |
| const base=nextMakeSvg('path',{d},'network-base');base.dataset.route=key;edge.appendChild(base); | |
| const glow=nextMakeSvg('path',{d},'network-active-glow');glow.dataset.route=key;edge.appendChild(glow); | |
| const flow=nextMakeSvg('path',{d},'network-active');flow.dataset.route=key;edge.appendChild(flow); | |
| nextRouteMap.set(key,{base,glow,flow,length:0}); | |
| } | |
| nextDrawRouteStates(); | |
| } | |
| function nextDrawRouteStates(){ | |
| for(const [key,r] of nextRouteMap){ | |
| const active=nextActiveKeys.includes(key),done=nextFinishedKeys.includes(key); | |
| r.base.setAttribute('class',done?'network-complete':'network-base'); | |
| // Naar voorbeeld van de oorspronkelijke ORCHESTRA: een looplicht over ELKE verbinding. | |
| // Tijdens de analyse lichten actieve overdrachten extra sterk op. | |
| // Geen afhankelijkheid van de kleine bolletjes: de lijn zélf beweegt zichtbaar. | |
| r.glow.style.display=active?'':'none'; | |
| r.flow.style.display=''; | |
| r.flow.setAttribute('class',active?'network-active network-current':'network-active network-ambient'); | |
| } | |
| nextBuildOrbs(); | |
| } | |
| function nextBuildOrbs(){ | |
| const layer=$('networkSignals');if(!layer)return; | |
| if(nextAnimationFrame)cancelAnimationFrame(nextAnimationFrame); | |
| nextAnimationFrame=0;nextSignalCircles=[];layer.replaceChildren(); | |
| if(!nextActiveKeys.length||nextMotionDisabled()||!document.querySelector('.orchestra.running'))return; | |
| for(const key of nextActiveKeys){ | |
| const r=nextRouteMap.get(key);if(!r)continue; | |
| try{r.length=r.flow.getTotalLength()}catch(e){continue} | |
| if(!r.length)continue; | |
| for(let j=0;j<2;j++){ | |
| const halo=nextMakeSvg('circle',{r:13,cx:0,cy:0},'network-orb-halo'); | |
| const dot=nextMakeSvg('circle',{r:4.3,cx:0,cy:0},'network-orb'); | |
| layer.append(halo,dot);nextSignalCircles.push({r,halo,dot,offset:j*.52}); | |
| } | |
| } | |
| nextAnimationStart=performance.now(); | |
| if(nextSignalCircles.length)nextAnimationFrame=requestAnimationFrame(nextTickOrbs); | |
| } | |
| function nextTickOrbs(now){ | |
| const ms=$('paceSelect').value==='fast'?560:1450; | |
| for(const o of nextSignalCircles){ | |
| const progress=((now-nextAnimationStart)/ms+o.offset)%1; | |
| const p=o.r.flow.getPointAtLength(o.r.length*progress); | |
| o.halo.setAttribute('cx',p.x);o.halo.setAttribute('cy',p.y); | |
| o.dot.setAttribute('cx',p.x);o.dot.setAttribute('cy',p.y); | |
| } | |
| nextAnimationFrame=requestAnimationFrame(nextTickOrbs); | |
| } | |
| function nextConductorSignal(){ | |
| const c=$('conductor');if(!c)return; | |
| clearTimeout(nextTransferTimeout);c.classList.remove('transfering'); | |
| // Herstart de CSS-puls betrouwbaar bij elke OVERDRACHT, ook bij opeenvolgende overdrachten. | |
| void c.offsetWidth;c.classList.add('transfering'); | |
| nextTransferTimeout=setTimeout(()=>c.classList.remove('transfering'),$('paceSelect').value==='fast'?500:1000); | |
| } | |
| function nextRoutesActive(pairs=[]){ | |
| nextActiveKeys=pairs.map(([a,b])=>nextRouteKey(a,b)).filter(k=>nextRouteMap.has(k)); | |
| nextDrawRouteStates(); | |
| if(nextActiveKeys.length)nextConductorSignal(); | |
| } | |
| function nextLines(id,lines,doneIndex=-1,warning=false){ | |
| const card=$('agent-'+id),wrap=card.querySelector('.agent-steps');card.classList.add('has-steps');wrap.replaceChildren(); | |
| lines.forEach((line,i)=>{ | |
| const item=document.createElement('div');item.className='agent-step '+(i<=doneIndex?(warning?'skipped':'complete'):i===doneIndex+1?'current':''); | |
| const mark=document.createElement('span');mark.className='step-mark';mark.textContent=i<=doneIndex?(warning?'!':'✓'):i===doneIndex+1?'◌':'·'; | |
| const txt=document.createElement('span');txt.textContent=line; | |
| item.append(mark,txt);wrap.appendChild(item); | |
| }); | |
| } | |
| function nextSetStatus(id,value){ | |
| const card=$('agent-'+id);card.classList.toggle('active',value==='active');card.classList.toggle('done',value==='done'||value==='warning'); | |
| card.querySelector('.status').textContent={active:'ACTIEF',done:'GEREED',warning:'LET OP',skipped:'NIET BESCHIKBAAR',idle:'STANDBY'}[value]||'STANDBY'; | |
| } | |
| // v6: visuele voortgang, synchroon met de gekozen procesPRESENTATIE (niet met CPU-tijd). | |
| let nextProgressFrame=0; | |
| function nextSetProgress(id,value){ | |
| const card=$('agent-'+id),fill=card?.querySelector('.agent-bottom .track i'),label=card?.querySelector('.agent-bottom .percent'); | |
| if(!fill||!label)return; | |
| const pct=Math.max(0,Math.min(100,Math.round(value))); | |
| fill.style.width=pct+'%';label.textContent=pct+'%'; | |
| } | |
| function nextStopProgress(){if(nextProgressFrame)cancelAnimationFrame(nextProgressFrame);nextProgressFrame=0;} | |
| function nextAnimateProgress(id,duration){ | |
| nextStopProgress();nextSetProgress(id,0); | |
| const started=performance.now(); | |
| function tick(now){ | |
| const fraction=Math.min(1,Math.max(0,(now-started)/duration)); | |
| nextSetProgress(id,fraction*100); | |
| if(fraction<1)nextProgressFrame=requestAnimationFrame(tick); | |
| else nextProgressFrame=0; | |
| } | |
| nextProgressFrame=requestAnimationFrame(tick); | |
| } | |
| function nextInitCards(){ | |
| nextStopProgress(); | |
| for(const id of nextIds){ | |
| const card=$('agent-'+id);card.classList.remove('has-steps','active','done','completed-pulse'); | |
| nextSetStatus(id,'idle');nextSetProgress(id,0);card.querySelector('.agent-bottom').title='Voortgang van de procesweergave; geen meting van de werkelijke rekentijd.'; | |
| nextLines(id,['Wacht op lokale analyse','Werkstappen verschijnen hier','Overdracht wordt zichtbaar'], -2); | |
| } | |
| nextShowCount(); | |
| } | |
| async function nextPhase(id,lines,routes=[],warning=false){ | |
| nextSetStatus(id,'active');nextLines(id,lines,-1,false); | |
| nextRoutesActive(routes); | |
| $('conductorStatus').textContent='Overdracht · '+$('agent-'+id).querySelector('h3').textContent; | |
| $('flowState').textContent=lines[0]; | |
| log('Procesweergave: '+$('agent-'+id).querySelector('h3').textContent); | |
| const phaseDuration=$('paceSelect').value==='fast'?650:3300; | |
| const durationStep=phaseDuration/lines.length; | |
| nextAnimateProgress(id,phaseDuration); | |
| for(let i=0;i<lines.length;i++){ | |
| await nextWait(durationStep); | |
| nextLines(id,lines,i,false); | |
| $('flowState').textContent=lines[i]; | |
| } | |
| nextStopProgress();nextSetProgress(id,100); | |
| for(const key of nextActiveKeys)if(!nextFinishedKeys.includes(key))nextFinishedKeys.push(key); | |
| nextSetStatus(id,warning?'warning':'done');nextLines(id,lines,lines.length-1,warning); | |
| const card=$('agent-'+id);card.classList.remove('completed-pulse');void card.offsetWidth;card.classList.add('completed-pulse'); | |
| log((warning?'Aandacht: ':'Afgerond: ')+id+' · '+lines.at(-1)); | |
| nextShowCount(); | |
| } | |
| function nextClearNetwork(reset=false){ | |
| nextStopProgress(); | |
| nextActiveKeys=[];if(reset)nextFinishedKeys=[]; | |
| clearTimeout(nextTransferTimeout);$('conductor').classList.remove('transfering'); | |
| if(nextAnimationFrame)cancelAnimationFrame(nextAnimationFrame);nextAnimationFrame=0; | |
| nextSignalCircles=[];$('networkSignals')?.replaceChildren();nextDrawRouteStates(); | |
| } | |
| function nextResetUI(){nextClearNetwork(true);nextInitCards();requestAnimationFrame(nextBuildGraph)} | |
| const nextOriginalReset=reset; | |
| reset=function(){nextOriginalReset();nextResetUI()}; | |
| $('resetBtn').removeEventListener('click',nextOriginalReset); | |
| $('resetBtn').addEventListener('click',reset); | |
| const nextOriginalStart=start; | |
| async function nextStartImported(){ | |
| if(running||!importedData)return; | |
| running=true;nextIsRunning=true;$('startBtn').disabled=true;$('resetBtn').disabled=true; | |
| const conductor=$('conductor');conductor.classList.remove('attention');conductor.classList.add('running'); | |
| document.querySelector('.orchestra').classList.add('running'); | |
| $('logs').replaceChildren();$('logStatus').textContent='Weergave actief'; | |
| nextFinishedKeys=[];nextInitCards();nextBuildGraph(); | |
| const timestamp=performance.now(),fmt=n=>Number(n||0).toLocaleString('nl-NL');let cpu=0; | |
| try{ | |
| await nextPhase('data',[ | |
| 'Bestand 2026 eerder lokaal ingelezen', | |
| fmt(importedData.rawCount)+' bronregels beoordeeld bij het inlezen', | |
| importedData.sites.length+' inlooplocaties herkend' | |
| ],[['conductor','identity'],['data','identity']]); | |
| const t1=performance.now(); | |
| const result=calculateImported($('siteSelect').value); | |
| cpu+=performance.now()-t1; | |
| await nextPhase('identity',[ | |
| 'Pas- en cliëntkoppelingen toegepast', | |
| fmt(result.multiPassClients)+' cliënten met meerdere passen gesignaleerd', | |
| 'Herhaalde scans apart geteld: '+fmt(result.duplicates) | |
| ],[['data','identity'],['identity','history']]); | |
| await nextPhase('history',result.historyLoaded?[ | |
| 'Historische referentie 2025 gebruikt', | |
| fmt(result.correctedExisting)+' cliënten uit 2025 herkend', | |
| 'Eerste registraties over jaren vergeleken' | |
| ]:[ | |
| 'Geen referentiebestand 2025 geladen', | |
| 'Eerdere bezoeken zijn niet volledig te toetsen', | |
| 'Nieuwe instroom blijft voorlopig' | |
| ],[['identity','history'],['history','verify']],!result.historyLoaded); | |
| await nextPhase('analysis',[ | |
| fmt(result.rows)+' scans in de geselecteerde periode', | |
| fmt(result.visits)+' unieke bezoekdagen vastgesteld', | |
| fmt(result.active)+' actieve cliënten berekend' | |
| ],[['data','analysis'],['identity','insight']]); | |
| await nextPhase('insight',[ | |
| result.months.length+' maandperioden verwerkt', | |
| 'Getelde trends beschikbaar voor de grafiek', | |
| 'Geen AI-duiding: alleen feitelijke patronen' | |
| ],[['analysis','insight'],['insight','verify']]); | |
| const v=performance.now();result.verification=independentlyVerify(result);importedData.last=result;cpu+=performance.now()-v; | |
| await nextPhase('verify',[ | |
| result.verification.checks.length+' interne telvergelijkingen uitgevoerd', | |
| result.verification.mismatches+' interne telverschillen gevonden', | |
| result.verification.sourceIssues.length+' typen bronafwijkingen gerapporteerd' | |
| ],[['history','verify'],['insight','verify'],['verify','quality']],result.verification.mismatches>0); | |
| await nextPhase('privacy',[ | |
| 'Excelgegevens lokaal in de browser verwerkt', | |
| 'Geen AI-API voor deze agentweergave', | |
| 'Geen zelfstandige AVG-certificering uitgevoerd' | |
| ],[['analysis','privacy']]); | |
| resetControllerComparison(true);updateControllerContext(); | |
| const t3=performance.now();renderImported(result);cpu+=performance.now()-t3; | |
| await nextPhase('report',[ | |
| 'Dashboard met berekende KPI’s bijgewerkt', | |
| 'Witte managementrapportage beschikbaar', | |
| 'Concept: niet extern gevalideerd' | |
| ],[['insight','report'],['privacy','report'],['report','quality']]); | |
| const concern=result.verification.mismatches>0||!result.historyLoaded; | |
| await nextPhase('quality',[ | |
| 'Interne rekenvergelijking: '+(result.verification.mismatches?'afwijking':'geen telverschil'), | |
| 'Historie 2025: '+(result.historyLoaded?'meegenomen':'niet aangeleverd'), | |
| 'Geen formele vrijgave door businesscontroller' | |
| ],[['verify','quality'],['report','quality']],concern); | |
| conductor.classList.add('attention');$('conductorStatus').textContent='Verwerkingsweergave afgerond'; | |
| $('flowState').textContent='Concept beschikbaar · NIET EXTERN GEVALIDEERD';$('logStatus').textContent='Afgerond'; | |
| log(`Echte berekening, interne verificatie en weergave: ${(cpu/1000).toFixed(2)} sec. Geanimeerde procesweergave: ${((performance.now()-timestamp)/1000).toFixed(1)} sec (exclusief Excel-invoer).`); | |
| }catch(err){log('FOUT: '+err.message);$('flowState').textContent='Berekening of weergave mislukt';conductor.classList.add('attention');$('conductorStatus').textContent='Fout gedetecteerd';} | |
| finally{nextClearNetwork(false);running=false;nextIsRunning=false;$('startBtn').disabled=false;$('resetBtn').disabled=false;conductor.classList.remove('running');document.querySelector('.orchestra').classList.remove('running');syncDashboardButton();} | |
| } | |
| start=async function(){ | |
| if(running)return; | |
| if(importedData)return nextStartImported(); | |
| nextClearNetwork(true); | |
| for(const id of ['identity','history','privacy','quality']){nextSetStatus(id,'skipped');nextLines(id,['Geen echte Excelbron in demonstratie','Controle niet uitgevoerd'],1,true);} | |
| return nextOriginalStart(); | |
| }; | |
| $('startBtn').removeEventListener('click',nextOriginalStart); | |
| // Dashboard uitsluitend na een afgeronde berekening voor één geselecteerde locatie. | |
| function syncDashboardButton(){const b=$('exportBtn');if(b)b.disabled=running||!importedData?.last||importedData.last.site==='*';} | |
| ['siteSelect','periodSelect','periodType','scanFile','historyFile'].forEach(id=>$(id)?.addEventListener('change',()=>{$('exportBtn').disabled=true;})); | |
| $('startBtn').addEventListener('click',start); | |
| window.addEventListener('resize',()=>requestAnimationFrame(nextBuildGraph)); | |
| if(typeof ResizeObserver!=='undefined')new ResizeObserver(()=>requestAnimationFrame(nextBuildGraph)).observe(document.querySelector('.orchestra')); | |
| // Bij veranderde bronbestanden of selectie blijven oude grafieken niet ten onrechte zichtbaar. | |
| ['scanFile','historyFile','siteSelect','periodSelect','periodType'].forEach(id=>$(id)?.addEventListener('change',invalidateInlineReportCharts)); | |
| nextInitCards();requestAnimationFrame(nextBuildGraph); | |
| // v9 — Region Intelligence: uitsluitend nieuwe berekeningen op de ingelezen, lokaal | |
| // beschikbare scanregels. calculateImported, getReportData en buildWhiteReport blijven ongewijzigd. | |
| const REGION_DEFS={ | |
| 'rijnmond':{name:'Rijnmond',sites:['Soeda','Vredehof','Rietdijk']}, | |
| 'midden-holland':{name:'Midden-Holland',sites:['Gouda','Voorburg']}, | |
| 'haaglanden':{name:'Haaglanden',sites:['De Kerk Den Haag','Platinaweg','Rozebottel','Sterhuis','Haagse Zaak','Zinkwerf']}, | |
| 'noord-holland':{name:'Noord-Holland',sites:['Purmerend','IJmond']} | |
| }; | |
| // Haagse Zaak is de bronbenaming uit ORCHESTRA; 'Haagsche zaak' van de opgave | |
| // is een naamvariant, geen extra locatie. Verder geen bronlabels herschrijven. | |
| const REGION_ALIAS=new Map([['haagsche zaak','Haagse Zaak']]); | |
| function regionCanonical(label){const s=String(label??'').trim().toLocaleLowerCase('nl-NL');return REGION_ALIAS.get(s)||REPORT_SITES.find(x=>x.toLocaleLowerCase('nl-NL')===s)||String(label??'').trim();} | |
| function regionAvailable(code){const def=REGION_DEFS[code];if(!def)return {found:[],missing:[]}; | |
| const present=new Set(importedData?.sites?.map(regionCanonical)||[]); | |
| return {found:def.sites.filter(s=>present.has(s)),missing:def.sites.filter(s=>!present.has(s))}; | |
| } | |
| function syncRegionButton(){const b=$('regionReportBtn');if(!b)return; | |
| const key=$('regionSelect').value,available=regionAvailable(key),ready=!!importedData&&!running&&!$('periodSelect').disabled&&!!$('periodSelect').value&&available.found.length>0; | |
| b.disabled=!ready; | |
| b.title=!importedData?'Laad eerst het Excelbestand 2026':!available.found.length?'Voor deze regio bevat het bestand geen bruikbare scanregels':'Open wit Reakt-regiodashboard · '+REGION_DEFS[key].name; | |
| } | |
| function regionReportData(code){ | |
| if(!importedData||!REGION_DEFS[code])throw Error('Laad eerst het rapportagebestand en selecteer een regio'); | |
| const def=REGION_DEFS[code],period=periodContext($('periodSelect').value),available=regionAvailable(code); | |
| if(!available.found.length)throw Error('Deze regio heeft geen locaties met geldige scanregels in het ingelezen bestand'); | |
| const members=new Set(def.sites),rows=importedData.all.filter(r=>members.has(regionCanonical(r.site))&&r.date<=period.peildatum); | |
| const inPeriod=rows.filter(r=>r.date>=period.begin); | |
| // Per locatie wordt een bezoekdag gedefinieerd als locatie + cliënt + kalenderdag, | |
| // identiek aan calculateImported/getReportData van de locatierapportage. | |
| const seenVisits=new Set(),byMonth=new Map(period.months.map(x=>[x,0])),byWeek=[0,0,0,0,0,0,0],monthClientSets=new Map(period.months.map(x=>[x,new Set()])); | |
| const regionClients=new Set(),activeClients=new Set(),visitedDays=new Set(),daysByClient=new Map(),uniqueClientsByDay=new Map(); | |
| const perSite=new Map(def.sites.map(s=>[s,{name:s,visits:0,clientIds:new Set(),activeIds:new Set(),firstByClient:new Map(),intake:0}])); | |
| const firstInRegion=new Map(); | |
| function recordFirst(map,id,date){if(!map.has(id)||date<map.get(id))map.set(id,date);} | |
| // Historische 2025-data uitsluitend voor de eerste scan, niet voor bezoeken of actief. | |
| if(importedData.historyLoaded){for(const [key,date] of importedData.historyFirst){ | |
| let ident;try{ident=JSON.parse(key)}catch(_){continue} | |
| if(!Array.isArray(ident)||ident.length!==2)continue; | |
| const canonical=regionCanonical(ident[0]);if(!members.has(canonical))continue; | |
| const id=String(ident[1]);recordFirst(firstInRegion,id,date);recordFirst(perSite.get(canonical).firstByClient,id,date); | |
| }} | |
| for(const r of rows){ | |
| const canonical=regionCanonical(r.site),site=perSite.get(canonical);if(!site)continue; | |
| recordFirst(firstInRegion,r.id,r.date);recordFirst(site.firstByClient,r.id,r.date); | |
| if(r.date>=period.activeFrom){activeClients.add(r.id);site.activeIds.add(r.id);} | |
| } | |
| for(const r of inPeriod){ | |
| const site=perSite.get(regionCanonical(r.site));if(!site)continue; | |
| regionClients.add(r.id);site.clientIds.add(r.id); | |
| if(!uniqueClientsByDay.has(r.date))uniqueClientsByDay.set(r.date,new Set()); | |
| uniqueClientsByDay.get(r.date).add(r.id); | |
| const key=JSON.stringify([r.site,r.id,r.date]);if(seenVisits.has(key))continue; | |
| seenVisits.add(key);site.visits++; | |
| const mk=r.date.slice(0,7);byMonth.set(mk,(byMonth.get(mk)||0)+1); | |
| monthClientSets.get(mk)?.add(r.id);visitedDays.add(r.date); | |
| daysByClient.set(r.id,(daysByClient.get(r.id)||0)+1); | |
| const dow=(new Date(r.date+'T12:00:00Z').getUTCDay()+6)%7;byWeek[dow]++; | |
| } | |
| const intakeByMonth=new Map(period.months.map(m=>[m,0]));let newIntake=0; | |
| for(const [,date] of firstInRegion){if(date>=period.begin&&date<=period.peildatum){newIntake++;intakeByMonth.set(date.slice(0,7),(intakeByMonth.get(date.slice(0,7))||0)+1);}} | |
| for(const site of perSite.values())for(const date of site.firstByClient.values())if(date>=period.begin&&date<=period.peildatum)site.intake++; | |
| const fmtMonth=['jan','feb','mrt','apr','mei','jun','jul','aug','sep','okt','nov','dec']; | |
| const months=period.months.map(m=>({key:m,label:fmtMonth[Number(m.slice(5))-1],visits:byMonth.get(m),clients:monthClientSets.get(m).size,newIntake:intakeByMonth.get(m)})); | |
| const locations=def.sites.map(s=>{const p=perSite.get(s);return {name:s,visits:p.visits,unique:p.clientIds.size,newIntake:p.intake,active:p.activeIds.size,present:available.found.includes(s)}}); | |
| const visits=seenVisits.size,locationVisits=locations.reduce((s,l)=>s+l.visits,0),sumMonths=months.reduce((s,m)=>s+m.visits,0),sumDays=byWeek.reduce((s,x)=>s+x,0); | |
| const checks={visitsSame:visits===locationVisits&&visits===sumMonths&&visits===sumDays, | |
| uniqueBound:regionClients.size<=locations.reduce((s,l)=>s+l.unique,0), | |
| intakeBound:newIntake<=locations.reduce((s,l)=>s+l.newIntake,0), | |
| activeBound:activeClients.size<=locations.reduce((s,l)=>s+l.active,0)}; | |
| if(Object.values(checks).some(x=>!x))throw Error('Regionale controletelling komt niet overeen. Rapport niet gegenereerd.'); | |
| return {code,region:def.name,definedSites:def.sites,period,available,locations,months,byWeek, | |
| unique:regionClients.size,active:activeClients.size,newIntake,visits,daysWithScans:visitedDays.size, | |
| averagePerDay:visitedDays.size?visits/visitedDays.size:0, | |
| averageClientsPerDay:uniqueClientsByDay.size?[...uniqueClientsByDay.values()].reduce((total,ids)=>total+ids.size,0)/uniqueClientsByDay.size:0, | |
| overlappingClients:locations.reduce((s,l)=>s+l.unique,0)-regionClients.size, | |
| historyLoaded:importedData.historyLoaded,checks, | |
| sourceYear:REPORT_YEAR}; | |
| } | |
| function regionCompareChart(items){ | |
| const W=690,step=31,H=35+items.length*step,axis=200,right=62,max=Math.max(1,...items.map(x=>x.visits)); | |
| let svg=`<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Bezoekdagen per locatie">`; | |
| const colors=['#632bcd','#46a82b','#ff741d','#8352d8','#268fa6','#e26972']; | |
| items.forEach((p,i)=>{const y=12+i*step,bar=Math.max(0,(W-axis-right-12)*p.visits/max); | |
| svg+=`<text x="${axis-8}" y="${y+15}" text-anchor="end" fill="#29345e" font-size="14" font-weight="650">${escapeHTML(p.name)}</text>`+ | |
| `<rect x="${axis}" y="${y+1}" width="${W-axis-right-12}" height="20" rx="4" fill="#f1eff9"/>`+ | |
| `<rect x="${axis}" y="${y+1}" width="${bar}" height="20" rx="4" fill="${colors[i%colors.length]}"/>`+ | |
| `<text x="${W-6}" y="${y+16}" text-anchor="end" fill="#241761" font-size="14" font-weight="800">${reportNL(p.visits)}</text>`; | |
| }); | |
| return svg+'</svg>'; | |
| } | |
| function buildRegionReport(d){ | |
| const nf=n=>reportNL(n),q=d.period,m=d.months,mon=m.map(x=>x.label),cols=['purple','green','purple','orange']; | |
| const kpis=[ | |
| ['Unieke cliënten',d.unique,'Regionaal ontdubbeld op cliëntnummer'], | |
| ['Nieuwe instroom',d.newIntake,'Eerste geregistreerde scan in deze regio'], | |
| ['Actieve cliënten',d.active,'Minimaal één scan binnen de regionale 3-maandsperiode'], | |
| ['Bezoekdagen',d.visits,'Som van unieke cliënt-/locatie-/kalenderdagen'] | |
| ]; | |
| const cards=kpis.map(([label,noteValue,detail],i)=>`<div class="report-kpi ${cols[i]}"><div class="k-icon">${['♟','+','♙','▦'][i]}</div><div><div class="k-label">${label}</div><div class="k-num">${nf(noteValue)}</div><div class="k-sub">${escapeHTML(detail)}</div></div><div class="k-underline"></div></div>`).join(''); | |
| const tableRows=d.locations.map(x=>`<tr><td>${escapeHTML(x.name)}${x.present?'':' *'}</td><td>${nf(x.unique)}</td><td>${nf(x.newIntake)}</td><td>${nf(x.active)}</td><td>${nf(x.visits)}</td></tr>`).join(''); | |
| const avail=d.available; | |
| $('reportSheet').classList.add('region-sheet'); | |
| $('reportSheet').innerHTML=` | |
| <div class="region-wordmark"><span class="region-reakt">Reakt</span><small>Werken aan je toekomst</small></div> | |
| <div class="region-title-tag">ORCHESTRA NEXT · REGION INTELLIGENCE</div> | |
| <div class="region-heading-title">Regiomonitor ${escapeHTML(d.region)}</div> | |
| <p class="region-heading-sub">Inloopvoorzieningen · vergelijking van locaties</p> | |
| <div class="region-meta"><span><b>Rapportage:</b> ${escapeHTML(q.label)}</span><span><b>Peildatum:</b> ${readableDate(q.peildatum)}</span><span><b>Locaties:</b> ${avail.found.length} van ${d.definedSites.length} beschikbaar</span><span><b>Status:</b> Actief</span></div> | |
| ${avail.missing.length?`<div class="region-cover-warning"><b>Onvolledige regioregistratie.</b> Geen bruikbare scans aangetroffen voor: ${escapeHTML(avail.missing.join(', '))}. Deze locaties staan in de vergelijking als ‘*’; hun nulwaarden betekenen niet dat er geen bezoekers waren. De regiocijfers zijn uitsluitend gebaseerd op locaties die in het ingelezen bestand voorkomen.</div>`:''} | |
| ${!d.historyLoaded?'<div class="region-cover-warning"><b>Historie 2025 ontbreekt.</b> Nieuwe instroom is voorlopig: eerder geregistreerde bezoekers kunnen ten onrechte als nieuw meetellen.</div>':''} | |
| <div class="report-kpi-grid">${cards}</div> | |
| <div class="region-rule"><strong>Regioberekening:</strong> cliënten worden op regioniveau één keer geteld, ook als ze meerdere locaties bezoeken. Bezoekdagen zijn de optelsom van de afzonderlijke unieke bezoekdagen per locatie.</div> | |
| <div class="region-panel-grid"> | |
| <section class="report-panel"><h2>Bezoekdagen per maand</h2><div class="chart-box">${reportChartLine(mon,m.map(x=>x.visits))}</div></section> | |
| <section class="report-panel"><h2>Nieuwe instroom per maand</h2><div class="chart-box">${reportChartBars(mon,m.map(x=>x.newIntake),'#288e28')}</div></section> | |
| <section class="report-panel"><h2>Bezoekdagen per weekdag</h2><div class="chart-box">${reportChartBars(['Ma','Di','Wo','Do','Vr','Za','Zo'],d.byWeek,'#ff6410',true)}</div></section> | |
| </div> | |
| <div class="region-detail-grid"> | |
| <section class="report-panel"><h2>Bezoekdagen per locatie</h2><div class="region-comparison-svg">${regionCompareChart(d.locations)}</div></section> | |
| <section class="report-panel"><h2>Locatievergelijking <small>· regiosom niet van toepassing op unieke cliënten</small></h2><table class="region-table"><thead><tr><th>Locatie</th><th>Uniek</th><th>Instroom</th><th>Actief</th><th>Bezoekdagen</th></tr></thead><tbody>${tableRows}<tr><td><b>Regio · uniek berekend</b></td><td>${nf(d.unique)}</td><td>${nf(d.newIntake)}</td><td>${nf(d.active)}</td><td>${nf(d.visits)}</td></tr></tbody></table></section> | |
| </div> | |
| <div class="region-manager-note"> | |
| <div><strong>Managementinzicht</strong>In ${escapeHTML(q.label)} registreerden de beschikbare locaties in ${escapeHTML(d.region)} gezamenlijk ${nf(d.visits)} bezoekdagen bij ${nf(d.unique)} verschillende cliënten. Gemiddeld ${d.averagePerDay.toLocaleString('nl-NL',{minimumFractionDigits:1,maximumFractionDigits:1})} bezoekdagen per kalenderdag met registratie (${nf(d.daysWithScans)} dagen). ${nf(d.newIntake)} cliënten waren nieuw in de regio${d.historyLoaded?' op basis van 2025 en 2026':' binnen de beschikbare historie van 2026'}.</div> | |
| <div class="region-daily-average"><strong>Gemiddeld aantal cliënten per dag</strong><div class="region-average-number">${d.averageClientsPerDay.toLocaleString('nl-NL',{minimumFractionDigits:1,maximumFractionDigits:1})} <span>cliënten per dag</span></div><div>Iedere cliënt telt per kalenderdag maximaal één keer mee binnen de hele regio, ook bij bezoek aan meerdere locaties. Berekend over ${nf(d.daysWithScans)} dagen met geregistreerde bezoeken in de gekozen rapportageperiode.</div></div> | |
| </div> | |
| `; | |
| } | |
| function openRegionReport(){ | |
| try{ | |
| const d=regionReportData($('regionSelect').value); | |
| buildRegionReport(d); | |
| $('reportOverlay').classList.add('open');$('reportOverlay').setAttribute('aria-hidden','false');document.body.classList.add('report-open');$('reportOverlay').scrollTop=0; | |
| }catch(err){alert('Regiodashboard niet beschikbaar: '+err.message);} | |
| } | |
| // De bestaande functie voor het locatiedashboard blijft onveranderd. | |
| // Verwijder enkel de region-specifieke stylesheetklasse voordat de locatieversie opent. | |
| const originalOpenWhiteReport=openWhiteReport; | |
| openWhiteReport=function(){ | |
| $('reportSheet').classList.remove('region-sheet'); | |
| originalOpenWhiteReport(); | |
| }; | |
| // De originele event-listener op de locatiedashboardknop was al gebonden: | |
| // vervang die zodat de region-specifieke klasse ook na een regio-optie wordt verwijderd. | |
| $('exportBtn').removeEventListener('click',originalOpenWhiteReport); | |
| $('exportBtn').addEventListener('click',openWhiteReport); | |
| $('regionReportBtn').addEventListener('click',openRegionReport); | |
| $('regionSelect').addEventListener('change',syncRegionButton); | |
| ['scanFile','historyFile','periodType','periodSelect','siteSelect'].forEach(id=>$(id)?.addEventListener('change',()=>{syncRegionButton();})); | |
| // 2026 XLSX wordt asynchroon ingelezen: de statusupdate schakelt de regioknop pas | |
| // in nadat de import succesvol is voltooid. | |
| new MutationObserver(syncRegionButton).observe($('importState'),{subtree:true,childList:true,characterData:true}); | |
| syncRegionButton(); | |
| </script></body></html> | |