PageSourceSearch

https://redactprompt.com/demo/js/reports.js?v=32

js redactprompt.com collected 2026-10-02 00:55:28 UTC 29,079 bytes, 620 lines download raw bytes

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.