1/* ============================================ 2 Reports Page â Connected to API 3 Echte event-data, geen demo-fallback: secties 4 blijven leeg / chart wordt overgeslagen zonder data 5 ============================================ */ 6 7const ReportsPage = { 8 initialized: false, 9 currentDays: 30, 10 11 // Chatbotgebruik per medewerker (spec 2026-08-06). Vaste volgorde: prompts 12 // aflopend. Sorteren was een handeling zonder antwoord; de vraag is wie 13 // welke chatbot gebruikt en hoeveel daarvan PII bevatte, en die staat nu 14 // gewoon in de rij. 15 usageRows: [], 16 // Volgnummer van de laatst gestarte gebruiksblok-lading. Het blok laadt bij 17 // elk bezoek opnieuw, dus twee ladingen kunnen overlappen als iemand snel 18 // heen en weer navigeert; alleen de laatste mag renderen. 19 usageLoadSeq: 0, 20 21 escapeHtml(str) { 22 const d = document.createElement('div'); 23 d.textContent = str; 24 return d.innerHTML; 25 }, 26 27 async init() { 28 const eersteKeer = !this.initialized; 29 if (eersteKeer) { 30 this.initialized = true; 31 this.bindActions(); 32 } 33 34 // Het gebruiksblok wordt bij ELK bezoek aan de pagina opnieuw opgehaald, 35 // ook als de rest van de pagina al geïnitialiseerd is. Reden: dit blok 36 // hangt aan een schakelaar die de klant zelf omzet op Detectiebeleid 37 // ("Gebruik per medewerker bijhouden"). Zonder herladen blijft een lijst 38 // met namen en percentages de rest van de sessie staan nadat de klant 39 // hem net heeft uitgezet (review-fix 2). De backend is de enige poort, 40 // dus opnieuw ophalen is ook de enige betrouwbare manier om te weten of 41 // hij nog aan staat; een lokale spiegel van de schakelaar zou een tweede 42 // waarheid worden. Tot het antwoord binnen is staat het blok verborgen, 43 // precies zoals bij een eerste pagina-lading, zodat er geen verouderde 44 // namen in beeld blijven tijdens de call. 45 this._hideUsageBlock(); 46 const jobs = [this.loadUsageBlock()]; 47 // loadData() staat los van het gebruiksblok: die volgt het datumbereik 48 // bovenaan de pagina en herlaadt via de change-handler, dus alleen bij de 49 // eerste initialisatie. 50 if (eersteKeer) jobs.push(this.loadData()); 51 await Promise.all(jobs); 52 }, 53 54 /** Leest het geselecteerde datumbereik (#reports-date-range) als aantal dagen. */ 55 _selectedDays() { 56 const sel = document.getElementById('reports-date-range'); 57 const text = sel ? (sel.value || (sel.options[sel.selectedIndex] || {}).text || '') : ''; 58 const m = String(text).match(/\d+/); 59 return m ? parseInt(m[0], 10) : 30; 60 }, 61 62 async loadData() { 63 this.currentDays = this._selectedDays(); 64 const rangeEl = document.getElementById('reports-chart-range'); 65 if (rangeEl) rangeEl.textContent = RPT('reports.lastDays', 'Laatste') + ' ' + this.currentDays + ' ' + RPT('reports.days', 'dagen'); 66 const q = '?days=' + this.currentDays; 67 try { 68 const [stats, categories, chatbotStats, daily] = await Promise.all([ 69 Api.get('/events/stats' + q).catch(() => null), 70 Api.get('/events/stats/categories' + q).catch(() => null), 71 Api.get('/events/stats/chatbots' + q).catch(() => null), 72 Api.get('/events/stats/daily' + q).catch(() => null), 73 ]); 74 75 this.renderKPIs(stats); 76 this.renderActionBreakdown(stats); 77 this.renderChart(daily); 78 this.renderCategories(categories); 79 this.renderChatbotUsage(chatbotStats); 80 } catch (err) { 81 console.error('Reports load error:', err); 82 } 83 }, 84 85 // Geaggregeerd gebruiksvenster + /users voor de merge. Vaste periode 86 // (maanden), volgt het datumbereik bovenaan de pagina niet, dus geen `q` 87 // hier. Beide subcalls zijn zelf al faalveilig; een mislukte /users-call 88 // levert users: null (i.p.v. []) zodat renderUsage het verschil kent tussen 89 // "geen leden" en "onbekend hoeveel leden". 90 async loadUsage() { 91 const [usage, users] = await Promise.all([ 92 Api.get('/enrollment/usage?months=3').catch(() => null), 93 Api.get('/users').catch(() => null), 94 ]); 95 return { usage, users }; 96 }, 97 98 // Verbergt het gebruiksblok en wist wat er gerenderd stond. Wordt bij elk 99 // bezoek aan de pagina aangeroepen vóór de nieuwe call, zodat er nooit een 100 // lijst van een vorige lading (of een vorige organisatie) in beeld staat 101 // terwijl we nog niet weten of het blok nog getoond mag worden. 102 _hideUsageBlock() { 103 this.usageRows = []; 104 const blok = document.getElementById('usage-blok'); 105 if (blok) blok.style.display = 'none'; 106 ['usage-samenvatting', 'usage-lijst', 'usage-voet'].forEach(id => { 107 const el = document.getElementById(id); 108 if (el) el.innerHTML = ''; 109 }); 110 const vensterEl = document.getElementById('usage-venster'); 111 if (vensterEl) vensterEl.textContent = ''; 112 }, 113
114 // Haalt het gebruiksblok op (vanuit init(), niet loadData()) en rendert het. 115 // Losstaand van het days-datumbereik: dat bereik heeft geen invloed op deze 116 // vaste-maanden-periode, dus een wissel erin hoeft loadUsage() niet opnieuw 117 // te triggeren. 118 async loadUsageBlock() { 119 const seq = ++this.usageLoadSeq; 120 const usageBundle = await this.loadUsage().catch(() => null); 121 // Een tragere, oudere lading mag nooit over een nieuwere heen renderen: 122 // dat zou juist het geval terugbrengen dat we hier bestrijden, een lijst 123 // die niet bij de huidige stand hoort. 124 if (seq !== this.usageLoadSeq) return; 125 this.renderUsage(usageBundle ? usageBundle.usage : null, usageBundle ? usageBundle.users : null); 126 }, 127 128 renderKPIs(stats) { 129 const container = document.getElementById('reports-kpi-grid'); 130 if (!container) return; 131 132 const s = stats || { total_events: 0, total_prompts: 0, total_clean: 0 }; 133 const hasData = s.total_prompts > 0; 134 const pct = hasData ? (s.total_events / s.total_prompts) * 100 : 0; 135 const pctFmt = new Intl.NumberFormat({ en: 'en-GB', de: 'de-DE' }[window.RP_LANG] || 'nl-NL', { maximumFractionDigits: 1 }).format(pct); 136 137 // Report-only krijgt geen eigen KPI-kaart meer; het leeft in de actie-breakdown eronder. 138 const kpis = [ 139 { label: RPT('kpi.promptsScanned', 'Totaal prompts gescand'), value: hasData ? formatNumber(s.total_prompts) : '-', sub: hasData ? (RPT('reports.lastDaysLower', 'laatste') + ' ' + this.currentDays + ' ' + RPT('reports.days', 'dagen')) : RPT('kpi.noData', 'nog geen data') }, 140 { label: RPT('kpi.sensitiveDetected', 'Gevoelige data gedetecteerd'), value: hasData ? formatNumber(s.total_events) : '-', sub: hasData ? RPT('kpi.sensitiveSub', 'prompts met PII') : RPT('kpi.noData', 'nog geen data') }, 141 { label: RPT('kpi.detectionRate', 'Detectiepercentage'), value: hasData ? pctFmt + '%' : '-', sub: hasData ? RPT('kpi.ofAllPrompts', 'van alle prompts') : RPT('kpi.noData', 'nog geen data') }, 142 { label: RPT('kpi.promptsClean', 'Prompts zonder detecties'), value: hasData ? formatNumber(s.total_clean) : '-', sub: hasData ? RPT('kpi.safePrompts', 'veilige prompts') : RPT('kpi.noData', 'nog geen data') }, 143 ]; 144 145 let html = ''; 146 kpis.forEach(kpi => { 147 html += '<div class="kpi-card">' 148 + '<div class="kpi-header"><span class="kpi-label">' + kpi.label + '</span></div>' 149 + '<div class="kpi-value">' + kpi.value + '</div>' 150 + '<div class="kpi-sub">' + kpi.sub + '</div>' 151 + '</div>'; 152 }); 153 container.innerHTML = html; 154 }, 155 156 renderActionBreakdown(stats) { 157 const container = document.getElementById('reports-action-bars'); 158 if (!container) return; 159 const s = stats || {}; 160 const rows = [ 161 { label: RPT('action.protected', 'Beschermd (redact/pseudo)'), value: (s.total_redacted || 0) + (s.total_pseudo || 0), color: '#2C5EEA' }, 162 { label: RPT('action.sentOriginal', 'Bewust origineel verstuurd'), value: s.total_original || 0, color: '#6E93F2' }, 163 { label: RPT('action.reportOnly', 'Doorgelaten (report-only)'), value: s.total_reported || 0, color: '#3B6EF5' }, 164 { label: RPT('action.clean', 'Schoon (geen PII)'), value: s.total_clean || 0, color: '#A8C0F8' }, 165 ]; 166 const maxVal = Math.max(...rows.map(r => r.value), 1); 167 container.innerHTML = rows.map(r => { 168 const pct = Math.round((r.value / maxVal) * 100); 169 // Bij een lege balk ook de padding weghalen, anders toont de padding + kleur een sliver. 170 const pad = pct === 0 ? '; padding:0' : ''; 171 return '<div class="cat-bar-row">' 172 + '<span class="cat-bar-label">' + r.label + '</span>' 173 + '<div class="cat-bar-track"><div class="cat-bar-fill" data-style="width:' + pct + '%; background:' + r.color + pad + '">' 174 + (pct >= 30 ? '<span>' + formatNumber(r.value) + '</span>' : '') 175 + '</div></div>' 176 + '<span class="cat-bar-value">' + formatNumber(r.value) + '</span>' 177 + '</div>'; 178 }).join(''); 179 }, 180 181 renderChart(daily) { 182 setTimeout(() => {
183 try { 184 const canvasId = 'chart-reports-prompts'; 185 Charts.destroy(canvasId); 186 const ctx = document.getElementById(canvasId); 187 if (!ctx) return; 188 // Lege-staat resetten (kan er nog staan van een vorige lege range) 189 const wrap = ctx.closest('.chart-container'); 190 wrap?.querySelector('.chart-empty-note')?.remove(); 191 ctx.style.display = ''; 192 193 let labels, totals; 194 // Elke dag met â¥1 event is genoeg om te renderen; de gap-filler hieronder 195 // vult stille dagen met 0 (de API geeft alleen dagen mét events terug). 196 const hasReal = daily && daily.length > 0; 197 198 if (hasReal) { 199 const dayMap = {}; 200 daily.forEach(d => { dayMap[d.date] = d.total; }); 201 const allLabels = []; 202 const allTotals = []; 203 // Anker de x-as op het gekozen venster (nu â N-1 ⦠nu), niet op de 204 // eerste/laatste event-datum â anders krimpt de as bij stille randen. 205 const days = this.currentDays || 30; 206 const end = new Date(new Date().toISOString().slice(0, 10)); // vandaag (UTC-datum, zoals de API groepeert) 207 const start = new Date(end); 208 start.setDate(start.getDate() - (days - 1)); 209 for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { 210 const key = d.toISOString().slice(0, 10); 211 allLabels.push(d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short' })); 212 allTotals.push(dayMap[key] || 0); 213 } 214 labels = allLabels; 215 totals = allTotals; 216 } else { 217 // Geen data in dit bereik: toon een nette lege-staat i.p.v. een leeg canvas. 218 if (wrap) { 219 ctx.style.display = 'none'; 220 const note = document.createElement('div'); 221 note.className = 'chart-empty-note text-muted'; 222 note.style.cssText = 'display:flex;align-items:center;justify-content:center;height:100%;font-size:0.85rem'; 223 note.textContent = 'Geen detecties in dit datumbereik.'; 224 wrap.appendChild(note); 225 } 226 return; 227 } 228 229 Charts.instances[canvasId] = new Chart(ctx, { 230 type: 'line', 231 data: { 232 labels: labels, 233 datasets: [{ 234 label: RPT('chart.detectionsPerDay', 'Detecties per dag'), 235 data: totals, 236 borderColor: '#2C5EEA', 237 backgroundColor: (context) => { 238 const g = context.chart.ctx.createLinearGradient(0, 0, 0, context.chart.height); 239 g.addColorStop(0, 'rgba(44, 94, 234, 0.12)'); 240 g.addColorStop(1, 'rgba(44, 94, 234, 0.01)'); 241 return g; 242 }, 243 fill: true, tension: 0.4, borderWidth: 2, 244 pointRadius: 0, pointHoverRadius: 4, 245 pointHoverBackgroundColor: '#2C5EEA', 246 pointHoverBorderColor: '#fff', pointHoverBorderWidth: 2 247 }] 248 }, 249 options: { 250 responsive: true, 251 maintainAspectRatio: false, 252 interaction: { intersect: false, mode: 'index' }, 253 plugins: { 254 legend: { display: false } 255 }, 256 scales: { 257 x: { ticks: { maxTicksLimit: 8, font: { size: 12 }, color: '#64748B', padding: 4 }, grid: { display: false }, border: { display: false } }, 258 y: { 259 beginAtZero: true, 260 grace: '10%', 261 afterFit(axis) { axis.width = Math.max(axis.width, 50); }, 262 ticks: { maxTicksLimit: 5, padding: 8, font: { family: "var(--font-mono)", size: 13 }, color: 'transparent' }, 263 grid: { color: '#E2E8F0', lineWidth: 1 }, 264 border: { display: false } 265 } 266 } 267 } 268 }); 269 } catch (e) { 270 console.warn('Chart.js not available:', e.message); 271 } 272 }, 100); 273 }, 274 275 renderCategories(data) { 276 const container = document.getElementById('reports-category-bars'); 277 if (!container) return; 278 279 if (!data || !data.labels || data.labels.length === 0) { 280 // Oude balken wissen â anders blijven bars van het vorige bereik staan. 281 container.innerHTML = '<div class="text-muted" data-style="font-size:0.82rem;padding:8px 0">Geen detecties in dit datumbereik.</div>'; 282 return; 283 } 284 const catData = data; 285 286 const maxVal = Math.max(...catData.values); 287 const barColors = ['#2C5EEA', '#3B6EF5', '#6E93F2', '#A8C0F8', '#C9D8FC', '#DCE6FC', '#E8EEFF', '#F1F5F9']; 288 289 let html = '';
290 catData.labels.forEach((label, i) => { 291 const pct = maxVal > 0 ? Math.round((catData.values[i] / maxVal) * 100) : 0; 292 const color = barColors[i] || barColors[barColors.length - 1]; 293 const showInnerLabel = pct >= 30; 294 const pad = pct === 0 ? '; padding:0' : ''; 295 html += '<div class="cat-bar-row">' 296 + '<span class="cat-bar-label">' + this.escapeHtml(RPLabel.cat(label)) + '</span>' 297 + '<div class="cat-bar-track">' 298 + '<div class="cat-bar-fill" data-style="width:' + pct + '%; background:' + color + pad + '">' 299 + (showInnerLabel ? '<span>' + formatNumber(catData.values[i]) + '</span>' : '') 300 + '</div></div>' 301 + '<span class="cat-bar-value">' + formatNumber(catData.values[i]) + '</span>' 302 + '</div>'; 303 }); 304 container.innerHTML = html; 305 }, 306 307 renderChatbotUsage(data) { 308 const container = document.getElementById('reports-chatbot-list'); 309 const totalEl = document.getElementById('reports-chatbot-total'); 310 if (!container) return; 311 312 if (!data || !data.labels || data.labels.length === 0) { 313 container.innerHTML = '<div class="text-muted" data-style="font-size:0.82rem;padding:8px 0">Geen chatbot-activiteit in dit datumbereik.</div>'; 314 if (totalEl) totalEl.textContent = ''; 315 return; 316 } 317 const cbData = data; 318 319 const total = cbData.values.reduce((s, v) => s + v, 0); 320 const maxVal = Math.max(...cbData.values); 321 const barColors = ['#2C5EEA', '#3B6EF5', '#6E93F2', '#A8C0F8', '#C9D8FC', '#DCE6FC', '#E8EEFF']; 322 323 if (totalEl) totalEl.textContent = formatNumber(total) + ' ' + RPT('ui.total', 'totaal'); 324 325 let html = ''; 326 cbData.labels.forEach((label, i) => { 327 const pct = total > 0 ? Math.round((cbData.values[i] / total) * 100) : 0; 328 const barPct = maxVal > 0 ? Math.round((cbData.values[i] / maxVal) * 100) : 0; 329 const color = barColors[i] || barColors[barColors.length - 1]; 330 html += '<div class="chatbot-row">' 331 + '<div class="chatbot-rank">' + (i + 1) + '</div>' 332 + '<span class="chatbot-name">' + this.escapeHtml(RPLabel.bot(label)) + '</span>' 333 + '<div class="chatbot-bar-wrap"><div class="chatbot-bar-track">' 334 + '<div class="chatbot-bar-fill" data-style="width:' + barPct + '%; background:' + color + '"></div>' 335 + '</div></div>' 336 + '<span class="chatbot-pct">' + pct + '%</span>' 337 + '<span class="chatbot-count">' + formatNumber(cbData.values[i]) + '</span>' 338 + '</div>'; 339 }); 340 container.innerHTML = html; 341 }, 342 343 // --- Chatbotgebruik per medewerker ----------------------------------- 344 // (spec 2026-08-06). Eén blok per medewerker: naam met totalen, een 345 // volumebalk (het donkere segment is het deel met PII), en de gebruikte 346 // chatbots eronder. Geen strook, geen sorteerknoppen, geen meetdrempel. 347 348 _capBrowser(s) { 349 const m = { chrome: 'Chrome', edge: 'Edge', brave: 'Brave', other: 'Browser' }; 350 s = (s || '').toLowerCase(); 351 return m[s] || (s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Browser'); 352 }, 353 _capOs(s) { 354 const m = { macos: 'macOS', windows: 'Windows', linux: 'Linux' }; 355 s = (s || '').toLowerCase(); 356 return m[s] || (s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Onbekend'); 357 }, 358 359 // "2026-06" .. "2026-08" -> "juni t/m augustus 2026" (of met twee 360 // jaartallen als het venster een jaargrens kruist). 361 _formatVenster(w) { 362 if (!w || !w.from || !w.to) return ''; 363 const MAANDEN = ['januari', 'februari', 'maart', 'april', 'mei', 'juni', 364 'juli', 'augustus', 'september', 'oktober', 'november', 'december']; 365 const parse = s => { const [y, m] = String(s).split('-').map(Number); return { y, m }; }; 366 const f = parse(w.from), t = parse(w.to); 367 if (!f.m || !t.m) return ''; 368 const van = MAANDEN[f.m - 1] + (f.y === t.y ? '' : ' ' + f.y); 369 const tot = MAANDEN[t.m - 1] + ' ' + t.y; 370 return van + ' t/m ' + tot; 371 }, 372 373 // Bouwt één rij per medewerker/apparaat-groep uit devices[] + items[] 374 // (usage), samengevoegd tegen users via RP_MERGE.key(), dezelfde 375 // koppelregel als op Leden (users.js) en server-side merge_key(). Devices 376 // zonder user_upn worden nooit gegroepeerd (elk zo'n apparaat is zijn eigen 377 // rij), net als op Leden.
378 _buildUsageRows(usage, users) { 379 const itemsByDevice = {}; 380 (usage.items || []).forEach(it => { 381 (itemsByDevice[it.device_uid] = itemsByDevice[it.device_uid] || []).push(it); 382 }); 383 384 const usersByKey = {}; 385 (users || []).forEach(u => { 386 const k = RP_MERGE.key(u.email); 387 if (k) usersByKey[k] = u; 388 }); 389 390 const groups = new Map(); // groupKey -> { key, devices: [] } 391 (usage.devices || []).forEach(d => { 392 const k = RP_MERGE.key(d.user_upn); 393 const groupKey = k || ('solo:' + d.device_uid); 394 if (!groups.has(groupKey)) groups.set(groupKey, { key: k, devices: [] }); 395 groups.get(groupKey).devices.push(d); 396 }); 397 398 const matchedKeys = new Set(); 399 const rows = []; 400 groups.forEach(group => { 401 const chatbotTotals = {}; 402 let prompts = 0, detections = 0; 403 group.devices.forEach(d => { 404 (itemsByDevice[d.device_uid] || []).forEach(it => { 405 const ct = chatbotTotals[it.chatbot_name] || { prompts: 0, detections: 0 }; 406 ct.prompts += it.prompts; 407 ct.detections += it.detections; 408 chatbotTotals[it.chatbot_name] = ct; 409 prompts += it.prompts; 410 detections += it.detections; 411 }); 412 }); 413 const chatbots = Object.keys(chatbotTotals) 414 .map(naam => ({ name: naam, prompts: chatbotTotals[naam].prompts, detections: chatbotTotals[naam].detections })) 415 .sort((a, b) => b.prompts - a.prompts || a.name.localeCompare(b.name, 'nl')); 416 417 // isMember: deze rij hangt aan een echt lid (UPN matcht een User). 418 // "Alleen apparaat" en "Geen gebruikersnaam" zijn wel gemeten en staan 419 // wel in de lijst, maar zijn geen lid. De samenvattingszin TELT daarom 420 // alleen de member-rijen, anders klopt de optelsom tegen Leden niet 421 // (review-fix). Het gemiddelde loopt wel over alles, zie hieronder. 422 let name, secondary, isMember = false; 423 if (!group.key) { 424 const d = group.devices[0]; 425 name = `${this._capBrowser(d.browser)} · ${this._capOs(d.os)}`; 426 secondary = 'Geen gebruikersnaam'; 427 } else if (usersByKey[group.key]) { 428 const u = usersByKey[group.key]; 429 matchedKeys.add(group.key); 430 name = u.name || u.email; 431 secondary = u.email; 432 isMember = true; 433 } else { 434 name = group.devices[0].user_upn; 435 secondary = 'Alleen apparaat'; 436 } 437 438 rows.push({ name, secondary, prompts, detections, chatbots, isMember }); 439 }); 440 441 // De telling in de samenvattingszin ("N medewerkers met een aangemeld 442 // apparaat") gaat over leden, niet over apparaten: memberCount + 443 // unattached moet optellen tot het ledenaantal op Leden. 444 const usersAvailable = !!users; 445 const memberRows = rows.filter(r => r.isMember); 446 447 // Leden zonder aangemeld apparaat: alleen te tellen als /users is gelukt. 448 let unattached = null; 449 if (users) { 450 unattached = users.filter(u => { 451 const k = RP_MERGE.key(u.email); 452 return !k || !matchedKeys.has(k); 453 }).length; 454 } 455 456 // Gepoold, niet ongewogen: de rijen tonen aantallen, dus het gemiddelde 457 // moet dezelfde optelsom volgen (spec 2026-08-06 §6). Loopt over álle 458 // gemeten rijen, ook over "Alleen apparaat" en "Geen gebruikersnaam"; 459 // het is een organisatiecijfer, geen ledentelling. 460 const totaalPrompts = rows.reduce((s, r) => s + r.prompts, 0); 461 const totaalDetecties = rows.reduce((s, r) => s + r.detections, 0); 462 const pooledPct = totaalPrompts ? Math.round((totaalDetecties / totaalPrompts) * 100) : null; 463 464 // Vaste volgorde: druk gebruik bovenaan, apparaten zonder gemeten gebruik 465 // vanzelf onderaan. Gelijke aantallen op naam, zodat de lijst tussen twee 466 // bezoeken niet van volgorde wisselt. 467 rows.sort((a, b) => b.prompts - a.prompts || a.name.localeCompare(b.name, 'nl')); 468 469 return { rows, rowCount: rows.length, memberCount: memberRows.length, pooledPct, unattached, usersAvailable }; 470 }, 471 472 renderUsageLijst() { 473 const el = document.getElementById('usage-lijst'); 474 if (!el) return; 475 // Balklengte is relatief aan de drukste rij, niet aan een vaste as: de 476 // vraag "wie gebruikt dit het meest" leest af aan de lengte. 477 const max = this.usageRows.reduce((m, r) => Math.max(m, r.prompts), 0); 478 el.innerHTML = this.usageRows.map(r =>
478 this._usageRowHtml(r, max)).join(''); 479 }, 480 481 // "96 (14 met PII) · 46 (4)" wordt onleesbaar als elke chatbot het woord 482 // herhaalt; alleen de eerste draagt het label. 483 _usageBotsText(chatbots) { 484 return chatbots.map((c, i) => { 485 const det = i === 0 ? `${formatNumber(c.detections)} met PII` : formatNumber(c.detections); 486 return `${this.escapeHtml(c.name)} ${formatNumber(c.prompts)} (${det})`; 487 }).join(' · '); 488 }, 489 490 _usageRowHtml(r, max) { 491 const kop = '<div class="vd-top">' 492 + '<div class="vd-name-cell">' 493 + `<span>${this.escapeHtml(r.name)}</span>` 494 + `<span class="text-muted vd-name-secondary">${this.escapeHtml(r.secondary)}</span>` 495 + '</div>'; 496 497 // Een aangemeld apparaat dat nog niets gemeten heeft krijgt geen balk: 498 // een balk van nul zegt niets, de zin wel. 499 if (!r.prompts) { 500 return '<div class="chatbot-row vd-row">' 501 + kop + '<span class="vd-totaal text-muted">nog geen gebruik gemeten</span></div>' 502 + '</div>'; 503 } 504 505 const promptWoord = r.prompts === 1 ? 'prompt' : 'prompts'; 506 // Minimaal 2%: één prompt naast een drukke collega moet zic
506htbaar blijven. 507 const barPct = max ? Math.max(2, Math.round((r.prompts / max) * 100)) : 0; 508 // Zelfde soort vloer als hierboven: bij een lage maar niet-nul PII-ratio 509 // (bv. 2 op 500 -> 0%) rondt Math.round het segment weg tot een balk 510 // zonder donker stukje, terwijl de tekst ernaast wel "met PII" zegt. Een 511 // vloer van 1% houdt het segment zichtbaar zodra er echt iets gevonden is. 512 const piiPct = r.detections > 0 ? Math.max(1, Math.round((r.detections / r.prompts) * 100)) : 0; 513 514 return '<div class="chatbot-row vd-row">' 515 + kop 516 + `<span class="vd-totaal">${formatNumber(r.prompts)} ${promptWoord} · ${formatNumber(r.detections)} met PII</span>` 517 + '</div>' 518 + '<div class="vd-bar-track">' 519 + `<div class="vd-bar-fill" data-style="width:${barPct}%"><div class="vd-bar-pii" data-style="width:${piiPct}%"></div></div>` 520 + '</div>' 521 + `<div class="vd-bots text-muted">${this._usageBotsText(r.chatbots)}</div>` 522 + '</div>'; 523 }, 524 525 _usageSamenvattingHtml({ memberCount, rowCount, pooledPct, unattached }) { 526 const n = memberCount; 527 const parts = []; 528 // "0 medewerkers met een aangemeld apparaat" boven een gevulde lijst is 529 // een zin die zichzelf tegenspreekt, en het is de normale vorm van een 530 // Intune-only uitrol: apparaten melden zich met een UPN, maar er zijn 531 // geen User-rijen om ze aan te koppelen (of het domein wijkt af). Dan 532 // geen telling, maar de uitleg waarom er geen namen in de lijst staan 533 // (review-fix 3). 534 const geenKoppeling = n === 0 && rowCount > 0; 535 if (geenKoppeling) { 536 parts.push('Geen van de aangemelde apparaten hoort bij een lid, dus de lijst toont apparaten in plaats van medewerkers.'); 537 } else { 538 parts.push(`${n} ${n === 1 ? 'medewerker' : 'medewerkers'} met een aangemeld apparaat.`); 539 }
540 if (pooledPct !== null) { 541 // Gepoold over alle gemeten prompts, dus dezelfde optelsom als de 542 // balken eronder. Nooit "hun prompts": het gemiddelde loopt ook over 543 // apparaten die bij geen enkel lid horen. 544 parts.push(`Van de gemeten prompts bevatte ${pooledPct}% PII.`); 545 } 546 if (unattached !== null && unattached > 0) { 547 const ledenWoord = unattached === 1 ? 'lid heeft' : 'leden hebben'; 548 parts.push(`${unattached} ${ledenWoord} geen aangemeld apparaat; van hen meten we niets.`); 549 } 550 return `<p data-style="font-size:0.88rem;margin:0">${parts.join(' ')}</p>`; 551 }, 552 553 // Twee regels, geen drie. De herkomstregel over het niet-gecontroleerde 554 // e-mailadres staat sinds spec 2026-08-06 alleen nog bij de schakelaar op 555 // Detectiebeleid, waar de keuze gemaakt wordt. 556 _usageVoetHtml() { 557 return '<p class="text-muted" data-style="font-size:0.78rem;margin:0 0 4px">Deze cijfers gaan over de periode in de kop en alleen over aangemelde apparaten. Ze kunnen daarom afwijken van de cijfers bovenaan deze pagina.</p>' 558 + '<p class="text-muted" data-style="font-size:0.78rem;margin:0">Apparaten en de koppeling aan een lid beheer je bij <a href="#" data-action="App.navigateTo" data-arg="leden" data-style="color:var(--brand-primary)">Leden</a>.</p>'; 559 }, 560 561 // Twee leegstaten (spec 2026-08-06 §1): blok verborgen (module niet 562 // beschikbaar, attributie uit, of een mislukte call, alle drie hetzelfde 563 // resultaat: geen kop, geen melding, geen verwijzing), en wel aan maar nog 564 // geen gebruik. 565 renderUsage(usage, users) { 566 const blok = document.getElementById('usage-blok'); 567 if (!blok) return; 568 569 // Eén gate voor alle drie de uitgezette standen: call mislukt, module niet 570 // beschikbaar, of de klant heeft de schakelaar zelf uit staan. In alle drie 571 // hoort er op Rapportages niets over gebruik per medewerker te staan, ook 572 // geen melding met een verwijzing (spec 2026-08-06 §1). De uitleg over wat 573 // de schakelaar doet staat bij de schakelaar, op Detectiebeleid. 574 if (!usage || !usage.usage_attribution_available || !usage.usage_attribution) { 575 this._hideUsageBlock(); 576 return; 577 } 578 blok.style.display = ''; 579 580 const vensterEl = document.getElementById('usage-venster'); 581 if (vensterEl) { 582 // Een leeg of misvormd venster levert '' op (_formatVenster hierboven). 583 // Dan hoort er niets te staan, geen kale "Periode: " zonder waarde erachter. 584 const vensterTekst = this._formatVenster(usage.window); 585 vensterEl.textContent = vensterTekst ? RPT('reports.period', 'Periode') + ': ' + vensterTekst : ''; 586 } 587 588 const samenvattingEl = document.getElementById('usage-samenvatting'); 589 const lijstEl = document.getElementById('usage-lijst'); 590 const voetEl = document.getElementById('usage-voet'); 591 592 if (!usage.items || usage.items.length === 0) { 593 if (samenvattingEl) samenvattingEl.innerHTML = '<p class="text-muted" data-style="font-size:0.88rem">Nog geen chatbotgebruik in deze periode.</p>'; 594 if (lijstEl) lijstEl.innerHTML = ''; 595 if (voetEl) voetEl.innerHTML = ''; 596 this.usageRows = []; 597 return; 598 } 599 600 const built = this._buildUsageRows(usage, users); 601 this.usageRows = built.rows; 602 603 if (samenvattingEl) samenvattingEl.innerHTML = built.usersAvailable ? this._usageSamenvattingHtml(built) : ''; 604 this.renderUsageLijst(); 605 if (voetEl) voetEl.innerHTML = this._usageVoetHtml(); 606 }, 607 608 bindActions() { 609 const exportBtn = document.getElementById('btn-export-report'); 610 if (exportBtn) { 611 exportBtn.addEventListener('click', () => Toast.show('Export als CSV gestart...', 'info')); 612 } 613 const dateRange = document.getElementById('reports-date-range'); 614 if (dateRange) { 615 dateRange.addEventListener('change', () => this.loadData()); 616 } 617 } 618}; 619 620App.registerPage('rapportages', ReportsPage);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.