@datalabz_/dashboard-kit 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/README.md +156 -0
  2. package/package.json +15 -0
  3. package/v4.css +694 -0
  4. package/v4.js +1093 -0
  5. package/wrapper.css +326 -0
  6. package/wrapper.js +310 -0
package/v4.js ADDED
@@ -0,0 +1,1093 @@
1
+ /* DashboardKit v4.js */
2
+ (function (global) {
3
+ 'use strict';
4
+
5
+ // Default gateway. The parent may name its own via `endpoint` in
6
+ // CUSTOM_DASHBOARDS_PARAMS (each BackOffice has its own); omitted → this one.
7
+ const DEFAULT_ENDPOINT = "https://aidqgateway.draftplaza.com/aidq";
8
+ // Retry is off: 1 = a single attempt. Set to 2 to re-enable one retry for
9
+ // whatever _isRetryable allows (no response, 5xx, 200 without rows).
10
+ const MAX_ATTEMPTS = 1;
11
+
12
+ // ── State ────────────────────────────────────────────────────────────────────
13
+ let _authToken = '';
14
+ let _endpoint = DEFAULT_ENDPOINT;
15
+ let _dateFromIso = '';
16
+ let _dateToIso = '';
17
+ let _timezone = '';
18
+ let _hierarchyId = null;
19
+ let _hasBooted = false;
20
+ let _activeTabId = '';
21
+ let _sections = []; // { id, label, load, loaded, errors }
22
+ let _title = '';
23
+ let _gitVer = '5f9741d'; // replaced at deploy: sed -i "s/5f9741d/$(git rev-parse --short HEAD)/g" v4.js
24
+ let _calls = []; // System Info call log
25
+ let _sectionErrors = {}; // sectionId → error count
26
+ let _generation = 0;
27
+ let _ttEl = null;
28
+
29
+ // ── Inject CSS ───────────────────────────────────────────────────────────────
30
+ // v4.css is expected to be loaded via <link> alongside this script.
31
+ // No runtime injection — keeps CSP simple and avoids FOUC.
32
+
33
+ // ── Helpers ──────────────────────────────────────────────────────────────────
34
+ function esc(s) {
35
+ return String(s == null ? '' : s)
36
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
37
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
38
+ }
39
+
40
+ function fmtAmount(v) {
41
+ const n = parseFloat(v);
42
+ if (isNaN(n)) return '—';
43
+ return n.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,');
44
+ }
45
+
46
+ function fmtInt(v) {
47
+ if (v == null) return '—';
48
+ return String(v);
49
+ }
50
+
51
+ function _fmtDate(iso, tz, withTime) {
52
+ if (!iso) return '—';
53
+ try {
54
+ const p = {};
55
+ new Intl.DateTimeFormat('en-CA', {
56
+ timeZone: tz || _timezone || 'UTC',
57
+ day: '2-digit', month: '2-digit', year: 'numeric',
58
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
59
+ }).formatToParts(new Date(iso)).forEach(function (x) { p[x.type] = x.value; });
60
+ const date = p.day + '.' + p.month + '.' + p.year;
61
+ return withTime ? date + ' ' + p.hour + ':' + p.minute + ':' + p.second : date;
62
+ } catch { return withTime ? iso : String(iso).slice(0, 10); }
63
+ }
64
+
65
+ const CALENDAR_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
66
+
67
+ // DD.MM.YYYY for a bare YYYY-MM-DD that names a real day; null otherwise, so anything else
68
+ // (2026-13-01, 2026-02-30) takes the existing path and formats exactly as it did before.
69
+ function _calendarDay(iso) {
70
+ const m = typeof iso === 'string' ? CALENDAR_DATE.exec(iso) : null;
71
+ if (!m) return null;
72
+ const t = new Date(Date.UTC(+m[1], m[2] - 1, +m[3]));
73
+ return t.getUTCMonth() === m[2] - 1 && t.getUTCDate() === +m[3] ? m[3] + '.' + m[2] + '.' + m[1] : null;
74
+ }
75
+
76
+ // A calendar date (a Postgres `date`, e.g. a birth date) names a day, not a moment:
77
+ // show that day, whatever the timezone. Instants still format in the report's timezone.
78
+ function fmtDate(iso, tz) {
79
+ return _calendarDay(iso) || _fmtDate(iso, tz, false);
80
+ }
81
+
82
+ // A calendar date has no time of day, so it is shown as the date alone; 00:00:00 would invent one.
83
+ function fmtDatetime(iso, tz) {
84
+ return _calendarDay(iso) || _fmtDate(iso, tz, true);
85
+ }
86
+
87
+ function formatCurrency(amount, currency) {
88
+ const n = fmtAmount(amount);
89
+ return currency ? `${currency} ${n}` : n;
90
+ }
91
+
92
+ function formatPercent(v, decimals) {
93
+ const n = parseFloat(v);
94
+ if (isNaN(n)) return '—';
95
+ return n.toFixed(decimals ?? 2) + '%';
96
+ }
97
+
98
+ function colorClass(v) {
99
+ const n = Number(v);
100
+ return n > 0 ? 'pos' : n < 0 ? 'neg' : '';
101
+ }
102
+
103
+ // ── Tooltip ──────────────────────────────────────────────────────────────────
104
+ function _initTooltip() {
105
+ if (_ttEl) return;
106
+ _ttEl = document.createElement('div');
107
+ _ttEl.id = 'dk-tt';
108
+ document.body.appendChild(_ttEl);
109
+ let _cur = null;
110
+ document.addEventListener('mouseover', function (e) {
111
+ const tip = e.target.closest('.dk-tip');
112
+ if (tip === _cur) return;
113
+ _cur = tip;
114
+ if (!tip) { _ttEl.style.opacity = '0'; return; }
115
+ const text = tip.getAttribute('data-tooltip');
116
+ if (!text) return;
117
+ _ttEl.textContent = text;
118
+ _ttEl.style.display = 'block';
119
+ _ttEl.style.opacity = '0';
120
+ requestAnimationFrame(function () {
121
+ const r = tip.getBoundingClientRect();
122
+ const tw = _ttEl.offsetWidth, th = _ttEl.offsetHeight;
123
+ _ttEl.style.left = Math.max(4, Math.min(r.left + r.width / 2 - tw / 2, window.innerWidth - tw - 4)) + 'px';
124
+ _ttEl.style.top = (r.top - th - 6) + 'px';
125
+ _ttEl.style.opacity = '1';
126
+ });
127
+ });
128
+ document.addEventListener('mouseout', function (e) {
129
+ if (_cur && !_cur.contains(e.relatedTarget)) {
130
+ _cur = null;
131
+ _ttEl.style.opacity = '0';
132
+ }
133
+ });
134
+ }
135
+
136
+ // ── Status pill ───────────────────────────────────────────────────────────────
137
+ function _setPill(state, label) {
138
+ const p = document.getElementById('dk-pill');
139
+ if (!p) return;
140
+ p.className = 'dk-pill ' + state;
141
+ p.textContent = label;
142
+ }
143
+
144
+ // ── postMessage ───────────────────────────────────────────────────────────────
145
+ function _isGatewayUrl(v) {
146
+ if (typeof v !== 'string' || !v) return false;
147
+ try {
148
+ const u = new URL(v);
149
+ return u.protocol === 'https:' ||
150
+ (u.protocol === 'http:' && (u.hostname === 'localhost' || u.hostname === '127.0.0.1'));
151
+ } catch { return false; }
152
+ }
153
+
154
+ function _onMessage(e) {
155
+ if (!e.data || typeof e.data !== 'object') return;
156
+ if (e.data.type === 'RK_SNAPSHOT') { _snapshot(); return; }
157
+ if (e.data.type === 'CHAT_MODE') { document.documentElement.dataset.chatMode = 'true'; return; }
158
+ if (e.data.type !== 'CUSTOM_DASHBOARDS_PARAMS') return;
159
+
160
+ if (typeof e.data.token === 'string') _authToken = e.data.token;
161
+
162
+ // The token rides on every call to the endpoint, so only the parent may set
163
+ // it, and only to an https URL (http for localhost dev). Anything else is
164
+ // ignored and the current endpoint stays.
165
+ if (e.data.endpoint !== undefined) {
166
+ if (e.source === window.parent && _isGatewayUrl(e.data.endpoint)) _endpoint = e.data.endpoint;
167
+ else console.warn('[DashboardKit] endpoint ignored:', e.data.endpoint);
168
+ }
169
+
170
+ if (e.data.theme === 'light' || e.data.theme === 'dark')
171
+ document.documentElement.dataset.theme = e.data.theme;
172
+
173
+ const f = e.data.filters;
174
+ if (f && typeof f === 'object') {
175
+ if (typeof f.dateFrom === 'string') _dateFromIso = f.dateFrom;
176
+ if (typeof f.dateTo === 'string') _dateToIso = f.dateTo;
177
+ if (typeof f.timezone === 'string') _timezone = f.timezone;
178
+ if ('hierarchyId' in f)
179
+ _hierarchyId = f.hierarchyId == null ? null : Number(f.hierarchyId);
180
+ }
181
+
182
+ if (!_hasBooted && _authToken && _dateFromIso && _dateToIso && _timezone) {
183
+ _hasBooted = true;
184
+ const wait = document.getElementById('dk-wait');
185
+ if (wait) wait.hidden = true;
186
+ _buildTabNav();
187
+ _activateTab(_sections[0].id);
188
+ } else if (_hasBooted) {
189
+ _invalidateAll();
190
+ _loadSection(_activeTabId);
191
+ }
192
+ }
193
+
194
+ // ── Tab state machine ─────────────────────────────────────────────────────────
195
+ function _buildTabNav() {
196
+ const nav = document.getElementById('dk-tabs');
197
+ if (!nav) return;
198
+ nav.innerHTML = '';
199
+ _sections.forEach(function (s) {
200
+ const btn = document.createElement('button');
201
+ btn.id = 'dk-tab-' + s.id;
202
+ btn.textContent = s.label;
203
+ if (s.tooltip) {
204
+ const icon = document.createElement('span');
205
+ icon.className = 'dk-tip';
206
+ icon.setAttribute('data-tooltip', s.tooltip);
207
+ icon.textContent = 'i';
208
+ btn.appendChild(icon);
209
+ }
210
+ btn.addEventListener('click', function () { _activateTab(s.id); });
211
+ nav.appendChild(btn);
212
+ });
213
+ _renderTabBtns();
214
+ }
215
+
216
+ function _renderTabBtns() {
217
+ _sections.forEach(function (s) {
218
+ const btn = document.getElementById('dk-tab-' + s.id);
219
+ if (!btn) return;
220
+ btn.className = s.id === _activeTabId ? 'active' : '';
221
+ const old = btn.querySelector('.dk-badge');
222
+ if (old) old.remove();
223
+ if (s.errors > 0) {
224
+ const b = document.createElement('span');
225
+ b.className = 'dk-badge';
226
+ b.textContent = s.errors;
227
+ btn.appendChild(b);
228
+ }
229
+ });
230
+ }
231
+
232
+ function _activateTab(id) {
233
+ _activeTabId = id;
234
+ document.querySelectorAll('.dk-panel').forEach(function (p) {
235
+ p.classList.remove('active');
236
+ });
237
+ const panel = document.getElementById('dk-panel-' + id);
238
+ if (panel) panel.classList.add('active');
239
+ _renderTabBtns();
240
+ const sec = _sections.find(function (s) { return s.id === id; });
241
+ if (sec && !sec.loaded) _loadSection(id);
242
+ }
243
+
244
+ function _loadSection(id) {
245
+ const sec = _sections.find(function (s) { return s.id === id; });
246
+ const panel = document.getElementById('dk-panel-' + id);
247
+ if (!sec || !panel) return;
248
+ sec.loaded = false;
249
+ _runSectionLoad(sec, panel);
250
+ }
251
+
252
+ function _invalidateAll() {
253
+ _generation++;
254
+ _sections.forEach(function (s) { s.loaded = false; s.errors = 0; });
255
+ _sectionErrors = {};
256
+ _calls = [];
257
+ _setPill('', 'idle');
258
+ _renderSystemInfo();
259
+ }
260
+
261
+ async function _runSectionLoad(sec, panel) {
262
+ if (sec.id === 'dk-sysinfo') { _renderSystemInfo(); sec.loaded = true; return; }
263
+ const gen = _generation;
264
+ // _sectionErrors is cumulative for the whole session, only ever cleared by
265
+ // _invalidateAll() — a full-dashboard Refresh. Retry reloads one section on
266
+ // its own, so without this reset a section that fails once, then succeeds
267
+ // on retry, would keep showing its old error badge forever.
268
+ _sectionErrors[sec.id] = 0;
269
+ setSectionState(panel, 'loading');
270
+ _setPill('loading', 'loading…');
271
+ try {
272
+ await sec.load({
273
+ runQuery,
274
+ formatCurrency,
275
+ formatPercent,
276
+ formatInteger: fmtInt,
277
+ formatDate: fmtDate,
278
+ formatDatetime: fmtDatetime,
279
+ renderTable,
280
+ renderKpiCard,
281
+ renderKpiStrip,
282
+ renderInfoGrid,
283
+ renderTotalsStrip,
284
+ escHtml: esc,
285
+ colorClass,
286
+ filters: DashboardKit.filters,
287
+ });
288
+ if (gen !== _generation) return;
289
+ sec.loaded = true;
290
+ sec.errors = _sectionErrors[sec.id] || 0;
291
+ } catch (e) {
292
+ // The real exception, stack and dkCall included — the panel and the report only get a phrase.
293
+ console.error('[DashboardKit] section "' + sec.id + '" failed:', e);
294
+ console.log('[DashboardKit] section "' + sec.id + '" failed:', e);
295
+ if (gen !== _generation) return;
296
+ setSectionState(panel, 'error', e.message || String(e), e && e.dkCall, e && e.stack);
297
+ sec.errors = _sectionErrors[sec.id] || 0;
298
+ sec.loaded = true;
299
+ }
300
+ _renderTabBtns();
301
+ _refreshPill();
302
+ _renderSystemInfo();
303
+ }
304
+
305
+ function _refreshPill() {
306
+ const anyErr = Object.keys(_sectionErrors).some(function (k) { return _sectionErrors[k] > 0; });
307
+ _setPill(anyErr ? 'error' : 'ok', anyErr ? 'error' : 'ok');
308
+ }
309
+
310
+ // ── Failure kind ─────────────────────────────────────────────────────────
311
+ // One question: did a response arrive? 'transport' = no (network/CORS/DNS),
312
+ // 'gateway' = yes, whatever the status or body. Who is to blame is read from
313
+ // the status and the gateway's own fields by the host/server, not here.
314
+ // Builds the exception a failed call throws, diagnosis attached as err.dkCall —
315
+ // with parallel runQuery calls, searching _calls afterwards is ambiguous.
316
+ function _failure(entry, kind) {
317
+ const err = new Error(entry.error);
318
+ const r = entry.response || {};
319
+ const gw = kind === 'gateway';
320
+ // '' is refused by the server; null is "not present".
321
+ const s = function (v) { return typeof v === 'string' && v.length ? v : null; };
322
+ let detail = gw
323
+ ? s(r.error || r.message || (typeof r._raw === 'string' ? r._raw.slice(0, 200) : ''))
324
+ : s(entry.browserMessage);
325
+ // pg_position has no wire field of its own — the one permitted append.
326
+ if (detail && gw && r.pg_position != null) detail += ' [pg_position ' + r.pg_position + ']';
327
+ err.dkCall = {
328
+ kind,
329
+ label: entry.label, sql: entry.sql, args: entry.args,
330
+ durationMs: entry.durationMs, clientMs: entry.clientMs,
331
+ status: entry.status,
332
+ error: entry.error,
333
+ detail: detail,
334
+ errorCode: gw ? s(r.error_code) : null,
335
+ pgDetail: gw ? s(r.pg_detail) : null,
336
+ pgHint: gw ? s(r.pg_hint) : null,
337
+ trackId: gw ? s(r.track_id) : null,
338
+ };
339
+ return err;
340
+ }
341
+
342
+ // By status, not kind: no response and 5xx may clear on their own; a 200
343
+ // without { rows } is a proxy/captive portal; 4xx (408 included) is deterministic.
344
+ function _isRetryable(entry) {
345
+ if (entry.status === null) return true;
346
+ if (entry.status >= 500) return true;
347
+ if (entry.status === 200) return true;
348
+ return false;
349
+ }
350
+
351
+ // Randomized so every dashboard open against a recovering service doesn't
352
+ // retry in the same instant and hammer it right as it's coming back —
353
+ // the gateway team's own warning: retrying a struggling backend in lockstep
354
+ // makes things worse, not better.
355
+ function _retryDelay() {
356
+ return new Promise(function (resolve) { setTimeout(resolve, 800 + Math.random() * 700); });
357
+ }
358
+
359
+ // ── runQuery ──────────────────────────────────────────────────────────────────
360
+ // One retry, only where _isRetryable says the same request may succeed.
361
+ async function runQuery(sectionId, callLabel, sql, args, params) {
362
+ const t0 = performance.now();
363
+ const entry = {
364
+ index: _calls.length + 1,
365
+ sectionId, label: callLabel, sql,
366
+ args: args || [],
367
+ requestHeaders: { 'Content-Type': 'application/json', Authorization: 'Bearer ***' },
368
+ status: null, clientMs: null, durationMs: null, serverMs: null,
369
+ bytes: null, rowCount: null, response: null,
370
+ error: null, state: 'running',
371
+ ts: new Date().toISOString(),
372
+ };
373
+ _calls.push(entry);
374
+
375
+ const body = { sql };
376
+ if (args && args.length) body.args = args;
377
+ if (params && params.hierarchy_id != null) body.hierarchy_id = params.hierarchy_id;
378
+ else if (_hierarchyId != null) body.hierarchy_id = _hierarchyId;
379
+
380
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
381
+ // Cleared each attempt so a stale status/text from a prior try can't
382
+ // classify or describe this one.
383
+ entry.status = null;
384
+ entry.error = null;
385
+ entry.browserMessage = null;
386
+ const ta = performance.now(); // this attempt only — clientMs includes the retry pause
387
+ try {
388
+ const res = await fetch(_endpoint, {
389
+ method: 'POST',
390
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + _authToken },
391
+ body: JSON.stringify(body),
392
+ });
393
+ entry.status = res.status;
394
+ const text = await res.text();
395
+ entry.bytes = text.length;
396
+ let data;
397
+ try { data = JSON.parse(text); } catch { data = { _raw: text }; }
398
+ entry.response = data;
399
+ entry.clientMs = Math.round(performance.now() - t0);
400
+ entry.durationMs = Math.round(performance.now() - ta);
401
+
402
+ // A 200 whose body isn't { rows: [...] } (proxy HTML, truncated JSON) is
403
+ // a gateway failure too, not an empty result.
404
+ let failed = false;
405
+ if (!res.ok) {
406
+ entry.error = 'HTTP ' + res.status + ': ' + (data && (data.error || data.message) || text);
407
+ failed = true;
408
+ } else if (!Array.isArray(data.rows)) {
409
+ entry.error = 'HTTP 200 but the body is not { rows: [...] }' +
410
+ (data._raw !== undefined ? ' — not JSON: ' + text.slice(0, 200) : '');
411
+ failed = true;
412
+ }
413
+ if (failed) {
414
+ if (attempt < MAX_ATTEMPTS && _isRetryable(entry)) {
415
+ entry.retried = true;
416
+ await _retryDelay();
417
+ continue;
418
+ }
419
+ entry.state = 'error';
420
+ _sectionErrors[sectionId] = (_sectionErrors[sectionId] || 0) + 1;
421
+ throw _failure(entry, 'gateway');
422
+ }
423
+ entry.serverMs = data.duration_ms ?? null;
424
+ entry.rowCount = data.rows.length;
425
+ entry.state = 'ok';
426
+ return data.rows;
427
+ } catch (err) {
428
+ // The failed-response throw lands here too — already built.
429
+ if (err && err.dkCall) throw err;
430
+ entry.clientMs = Math.round(performance.now() - t0);
431
+ entry.durationMs = Math.round(performance.now() - ta);
432
+ if (entry.status === null) {
433
+ // The browser's own message goes to the wire untouched; the suffix is
434
+ // for System Info only.
435
+ entry.browserMessage = err.message;
436
+ entry.error = err.message + ' [no HTTP response — network/CORS error]';
437
+ } else if (!entry.error) {
438
+ entry.error = err.message;
439
+ }
440
+ if (attempt < MAX_ATTEMPTS && _isRetryable(entry)) {
441
+ entry.retried = true;
442
+ await _retryDelay();
443
+ continue;
444
+ }
445
+ entry.state = 'error';
446
+ _sectionErrors[sectionId] = (_sectionErrors[sectionId] || 0) + 1;
447
+ throw _failure(entry, entry.status === null ? 'transport' : 'gateway');
448
+ }
449
+ }
450
+ }
451
+
452
+ // ── Container resolution ──────────────────────────────────────────────────────
453
+ // Shared by setSectionState and every render primitive: accepts a CSS selector
454
+ // string or a DOM element, throws (fail loud) instead of silently rendering
455
+ // nothing when it resolves to nothing. Called from inside a section's load(),
456
+ // the throw propagates through the kit's own try/catch into the section's
457
+ // error state — same path a failed runQuery already uses.
458
+ function resolveContainer(fnName, arg) {
459
+ const el = typeof arg === 'string' ? document.querySelector(arg) : arg;
460
+ if (!el) {
461
+ const shown = typeof arg === 'string' ? `"${arg}"` : String(arg);
462
+ throw new Error(`${fnName}: container ${shown} matched no element`);
463
+ }
464
+ return el;
465
+ }
466
+
467
+ // ── Header filter values ─────────────────────────────────────────────────────
468
+ // Generic — reads whatever dashboard-specific inputs/selects the author put in
469
+ // <header data-dk-header>, keyed by element id. Dashboards define their own
470
+ // filters beyond date/timezone/hierarchy, so the kit can't hardcode field names.
471
+ function _headerFilterValues() {
472
+ const header = document.querySelector('[data-dk-header]');
473
+ const out = {};
474
+ if (!header) return out;
475
+ header.querySelectorAll('input, select').forEach(function (el) {
476
+ if (el.id) out[el.id] = el.value;
477
+ });
478
+ return out;
479
+ }
480
+
481
+ // ── Error report ── the turn:dashboard-error wire shape, nothing else. The host
482
+ // adds chatId/dashboardFile and strips what the kit appends after it.
483
+ function _wireReport(sectionId, sectionLabel, failedCall, msg, stack) {
484
+ if (!failedCall) {
485
+ return {
486
+ kind: 'script', phase: 'section',
487
+ sectionId: sectionId, sectionLabel: sectionLabel,
488
+ error: { message: msg || '', stack: stack || null },
489
+ };
490
+ }
491
+ const w = {
492
+ kind: failedCall.kind, // 'transport' | 'gateway'
493
+ sectionId: sectionId, sectionLabel: sectionLabel,
494
+ callLabel: failedCall.label,
495
+ sql: failedCall.sql, args: failedCall.args || [],
496
+ durationMs: failedCall.durationMs,
497
+ detail: failedCall.detail,
498
+ };
499
+ if (failedCall.kind === 'gateway') {
500
+ w.status = failedCall.status;
501
+ w.errorCode = failedCall.errorCode;
502
+ w.pgDetail = failedCall.pgDetail;
503
+ w.pgHint = failedCall.pgHint;
504
+ w.trackId = failedCall.trackId;
505
+ }
506
+ return w;
507
+ }
508
+
509
+ // ── Section state helpers ─────────────────────────────────────────────────────
510
+ function setSectionState(container, state, msg, failedCall, stack) {
511
+ container = resolveContainer('setSectionState', container);
512
+ if (state === 'loading') {
513
+ container.innerHTML =
514
+ '<div class="dk-loading"><span class="dk-spinner"></span>Loading…</div>';
515
+ } else if (state === 'error') {
516
+ // A caller can be the section panel itself or, from a dashboard's own
517
+ // load(), an arbitrary sub-container inside it — either way the nearest
518
+ // .dk-panel ancestor is the section that actually failed. Falling back to
519
+ // _activeTabId only covers the (shouldn't-happen) case of an element
520
+ // outside any panel.
521
+ const ownPanel = container.closest ? container.closest('.dk-panel') : null;
522
+ const sectionId = ownPanel ? ownPanel.id.replace('dk-panel-', '') : _activeTabId;
523
+ const sec = _sections.find(function (s) { return s.id === sectionId; });
524
+ // failedCall is err.dkCall from runQuery; without one the rejection didn't
525
+ // come from a gateway call at all — that's kind 'script'. The operator gets
526
+ // a fixed phrase; the raw text goes to the parent via "Ask AI to fix" and to
527
+ // System Info. No panel Retry: the header's Refresh covers it.
528
+ container.innerHTML =
529
+ '<div class="dk-error"><span>This view couldn\'t load — try Refresh.</span>' +
530
+ '<button class="dk-error-report-btn" type="button">Ask AI to fix</button></div>';
531
+
532
+ const reportBtn = container.querySelector('.dk-error-report-btn');
533
+ if (reportBtn) {
534
+ reportBtn.addEventListener('click', function () {
535
+ const dashboardSections = _sections.filter(function (s) { return s.id !== 'dk-sysinfo'; });
536
+ const failedSections = dashboardSections.filter(function (s) { return (s.errors || 0) > 0; }).length;
537
+ window.parent.postMessage(Object.assign(
538
+ { type: 'DASHBOARD_ERROR' },
539
+ _wireReport(sectionId, sec ? sec.label : '', failedCall, msg, stack),
540
+ {
541
+ // Host-only, stripped before the socket.
542
+ title: _title,
543
+ message: msg || '',
544
+ sectionErrorCount: sec ? sec.errors : 0,
545
+ totalSections: dashboardSections.length,
546
+ failedSections: failedSections,
547
+ filters: Object.assign(
548
+ {
549
+ dateFromIso: _dateFromIso,
550
+ dateToIso: _dateToIso,
551
+ timezone: _timezone,
552
+ hierarchyId: _hierarchyId,
553
+ },
554
+ _headerFilterValues(),
555
+ ),
556
+ call: failedCall,
557
+ },
558
+ ), '*');
559
+ reportBtn.disabled = true;
560
+ reportBtn.textContent = 'Sent ✓';
561
+ setTimeout(function () {
562
+ reportBtn.disabled = false;
563
+ reportBtn.textContent = 'Ask AI to fix';
564
+ }, 1500);
565
+ });
566
+ }
567
+ } else if (state === 'empty') {
568
+ container.innerHTML =
569
+ '<div class="dk-empty">No data for the selected period.</div>';
570
+ }
571
+ }
572
+
573
+ // ── renderTable ───────────────────────────────────────────────────────────────
574
+ // columns: [{ key, label, format?, sortable?, tooltip? }]
575
+ // format receives (value, row) — read row.<key> for per-row currency, etc.
576
+ // totalsRow: [label|{key,agg}] matching columns length — optional
577
+ // tableTitle: table heading; also the CSV filename — omit to hide title + CSV button
578
+ function renderTable(containerOrSelector, rows, schema) {
579
+ const container = resolveContainer('renderTable', containerOrSelector);
580
+
581
+ const { columns = [], totalsRow, tableTitle } = schema || {};
582
+
583
+ if (!rows || !rows.length) {
584
+ container.innerHTML = '<div class="dk-empty">No data for the selected period.</div>';
585
+ return;
586
+ }
587
+
588
+ let sortKey = null, sortDir = 'asc';
589
+
590
+ function buildBodyRows(data) {
591
+ return data.map(function (row) {
592
+ return '<tr>' + columns.map(function (col) {
593
+ const raw = row[col.key];
594
+ const displayed = col.format ? col.format(raw, row) : (raw == null ? '—' : raw);
595
+ return '<td>' + esc(displayed) + '</td>';
596
+ }).join('') + '</tr>';
597
+ }).join('');
598
+ }
599
+
600
+ function sortedRows() {
601
+ if (!sortKey) return rows;
602
+ return rows.slice().sort(function (a, b) {
603
+ let av = a[sortKey], bv = b[sortKey];
604
+ if (av != null && bv != null && !isNaN(+av) && !isNaN(+bv)) { av = +av; bv = +bv; }
605
+ if (av == null && bv == null) return 0;
606
+ if (av == null) return 1;
607
+ if (bv == null) return -1;
608
+ const cmp = av < bv ? -1 : av > bv ? 1 : 0;
609
+ return sortDir === 'asc' ? cmp : -cmp;
610
+ });
611
+ }
612
+
613
+ // Totals row
614
+ let totalsHtml = '';
615
+ if (totalsRow) {
616
+ totalsHtml = '<tr class="dk-totals-row">';
617
+ totalsRow.forEach(function (cell, i) {
618
+ const col = columns[i] || {};
619
+ if (typeof cell === 'string' || cell == null) {
620
+ totalsHtml += '<td>' + esc(cell ?? '—') + '</td>';
621
+ } else if (cell && cell.agg === 'sum') {
622
+ const sum = rows.reduce(function (a, r) { return a + (Number(r[cell.key]) || 0); }, 0);
623
+ const fmt = col.format || function (v) { return v; };
624
+ // Totals have no single row; pass an empty row so a per-row-currency
625
+ // formatter degrades gracefully instead of throwing. Don't sum a
626
+ // mixed-currency column (see the all-currencies note in the docs).
627
+ let out;
628
+ try { out = fmt(sum, {}); } catch { out = '—'; }
629
+ totalsHtml += '<td>' + esc(out) + '</td>';
630
+ } else {
631
+ totalsHtml += '<td>—</td>';
632
+ }
633
+ });
634
+ totalsHtml += '</tr>';
635
+ }
636
+
637
+ const tblId = 'dk-tbl-' + Math.random().toString(36).slice(2, 7);
638
+ let html = '<div class="dk-table-wrap">';
639
+ html += '<div class="dk-toolbar">';
640
+ if (tableTitle) html += '<span class="dk-table-title">' + esc(tableTitle) + '</span>';
641
+ html += '<button class="dk-csv-btn" data-dk-csv="' + tblId + '">CSV ↓</button>';
642
+ html += '</div>';
643
+ html += '<table class="dk-table" id="' + tblId + '"><thead><tr>';
644
+ columns.forEach(function (col) {
645
+ const sortAttr = col.sortable === true ? ' data-dk-sort-key="' + esc(col.key) + '"' : '';
646
+ const tipHtml = col.tooltip ? '<span class="dk-tip" data-tooltip="' + esc(col.tooltip) + '">i</span>' : '';
647
+ html += '<th' + sortAttr + '><div class="dk-th-inner"><span>' + esc(col.label) + '</span>' + tipHtml + '</div></th>';
648
+ });
649
+ html += '</tr></thead><tbody>';
650
+ if (totalsHtml) html += totalsHtml;
651
+ html += buildBodyRows(rows);
652
+ html += '</tbody></table></div>';
653
+
654
+ container.innerHTML = html;
655
+
656
+ // Sort handler
657
+ const tblEl = container.querySelector('#' + tblId);
658
+ tblEl.querySelector('thead').addEventListener('click', function (e) {
659
+ const th = e.target.closest('th[data-dk-sort-key]');
660
+ if (!th) return;
661
+ const key = th.dataset.dkSortKey;
662
+ sortDir = key === sortKey && sortDir === 'asc' ? 'desc' : 'asc';
663
+ sortKey = key;
664
+ tblEl.querySelector('tbody').innerHTML = (totalsHtml || '') + buildBodyRows(sortedRows());
665
+ tblEl.querySelectorAll('thead th[data-dk-sort-key]').forEach(function (h) {
666
+ h.classList.remove('dk-sort-asc', 'dk-sort-desc');
667
+ if (h.dataset.dkSortKey === sortKey) h.classList.add('dk-sort-' + sortDir);
668
+ });
669
+ });
670
+
671
+ // Wire CSV button
672
+ const csvBtn = container.querySelector('[data-dk-csv]');
673
+ if (csvBtn) {
674
+ csvBtn.addEventListener('click', function () {
675
+ const tbl = document.getElementById(csvBtn.dataset.dkCsv);
676
+ if (!tbl) return;
677
+ const name = [_title, tableTitle].filter(Boolean).concat(fmtDatetime(new Date().toISOString()).replace(/:/g, '-')).join(' - ') + '.csv';
678
+ _downloadCsv(tbl, name);
679
+ });
680
+ }
681
+ }
682
+
683
+ // ── renderKpiCard ─────────────────────────────────────────────────────────────
684
+ // item: { label, value, colorClass?, tooltip? }
685
+ function _kpiCardHtml(item) {
686
+ const cls = 'dk-kpi-value' + (item.colorClass ? ' ' + item.colorClass : '');
687
+ const tipIcon = item.tooltip ? '<span class="dk-tip" data-tooltip="' + esc(item.tooltip) + '">i</span>' : '';
688
+ return '<div class="dk-kpi">' +
689
+ tipIcon +
690
+ '<div class="dk-kpi-label">' + esc(item.label) + '</div>' +
691
+ '<div class="' + cls + '">' + esc(item.value) + '</div>' +
692
+ '</div>';
693
+ }
694
+
695
+ function renderKpiCard(containerOrSelector, item) {
696
+ const container = resolveContainer('renderKpiCard', containerOrSelector);
697
+ container.innerHTML = _kpiCardHtml(item);
698
+ }
699
+
700
+ // ── renderKpiStrip ────────────────────────────────────────────────────────────
701
+ // items: [{ label, value, colorClass? }]
702
+ // options: { stripTitle? }
703
+ function renderKpiStrip(containerOrSelector, items, options) {
704
+ const container = resolveContainer('renderKpiStrip', containerOrSelector);
705
+ const { stripTitle } = options || {};
706
+ let html = '';
707
+ if (stripTitle) html += '<div class="dk-toolbar"><span class="dk-table-title">' + esc(stripTitle) + '</span></div>';
708
+ html += '<div class="dk-kpi-strip">' +
709
+ (items || []).map(_kpiCardHtml).join('') +
710
+ '</div>';
711
+ container.innerHTML = html;
712
+ }
713
+
714
+ // ── renderInfoGrid ────────────────────────────────────────────────────────────
715
+ // items: [[label, value, colorClass?], ...] — renders a responsive key/value grid
716
+ // options: { gridTitle? }
717
+ function renderInfoGrid(containerOrSelector, items, options) {
718
+ const container = resolveContainer('renderInfoGrid', containerOrSelector);
719
+ const { gridTitle } = options || {};
720
+ let html = '';
721
+ if (gridTitle) html += '<div class="dk-toolbar"><span class="dk-table-title">' + esc(gridTitle) + '</span></div>';
722
+ html += '<div class="dk-info-grid">' +
723
+ (items || []).map(function (item) {
724
+ const cls = 'dk-val' + (item[2] ? ' ' + item[2] : '');
725
+ return '<div class="dk-info-row">' +
726
+ '<span class="dk-lbl">' + esc(item[0]) + '</span>' +
727
+ '<span class="' + cls + '">' + esc(item[1] != null ? item[1] : '—') + '</span>' +
728
+ '</div>';
729
+ }).join('') +
730
+ '</div>';
731
+ container.innerHTML = html;
732
+ }
733
+
734
+ // ── renderTotalsStrip ─────────────────────────────────────────────────────────
735
+ // items: [[label, value, colorClass?], ...] — renders a horizontal totals bar
736
+ // options: { stripTitle? }
737
+ function renderTotalsStrip(containerOrSelector, items, options) {
738
+ const container = resolveContainer('renderTotalsStrip', containerOrSelector);
739
+ const { stripTitle } = options || {};
740
+ let html = '';
741
+ if (stripTitle) html += '<div class="dk-toolbar"><span class="dk-table-title">' + esc(stripTitle) + '</span></div>';
742
+ html += '<div class="dk-totals-strip">' +
743
+ (items || []).map(function (item) {
744
+ const cls = 'dk-field-value' + (item[2] ? ' ' + item[2] : '');
745
+ return '<div class="dk-field">' +
746
+ '<span class="dk-field-label">' + esc(item[0]) + '</span>' +
747
+ '<span class="' + cls + '">' + esc(item[1] != null ? item[1] : '—') + '</span>' +
748
+ '</div>';
749
+ }).join('') +
750
+ '</div>';
751
+ container.innerHTML = html;
752
+ }
753
+
754
+ // ── CSV download ──────────────────────────────────────────────────────────────
755
+ function _downloadCsv(tableEl, filename) {
756
+ const rows = [];
757
+ tableEl.querySelectorAll('tr').forEach(function (tr) {
758
+ const cells = Array.from(tr.querySelectorAll('th, td')).map(function (td) {
759
+ const v = td.dataset.csvValue !== undefined ? td.dataset.csvValue : td.textContent.trim();
760
+ const safe = /^[=+\-@]/.test(String(v)) && !/^-\d/.test(String(v)) ? "'" + String(v) : String(v);
761
+ return '"' + safe.replace(/"/g, '""') + '"';
762
+ });
763
+ if (cells.length) rows.push(cells.join(','));
764
+ });
765
+ const blob = new Blob(['' + rows.join('\r\n')], { type: 'text/csv;charset=utf-8' });
766
+ const a = document.createElement('a');
767
+ a.href = URL.createObjectURL(blob);
768
+ a.download = filename;
769
+ a.click();
770
+ URL.revokeObjectURL(a.href);
771
+ }
772
+
773
+ // ── Snapshot ──────────────────────────────────────────────────────────────────
774
+ function _snapshot() {
775
+ // Freeze current filter values into attributes so cloneNode captures them.
776
+ document.querySelectorAll('[data-dk-header] input, [data-dk-header-right] input').forEach(function (el) {
777
+ el.setAttribute('value', el.value);
778
+ });
779
+ document.querySelectorAll('[data-dk-header] select, [data-dk-header-right] select').forEach(function (el) {
780
+ el.querySelectorAll('option').forEach(function (opt) {
781
+ opt.selected = opt.value === el.value;
782
+ });
783
+ });
784
+
785
+ const clone = document.documentElement.cloneNode(true);
786
+
787
+ // Strip auth token from System Info pre elements
788
+ if (_authToken) {
789
+ clone.querySelectorAll('pre').forEach(function (pre) {
790
+ if (pre.textContent.includes(_authToken))
791
+ pre.textContent = pre.textContent.replaceAll(_authToken, '***');
792
+ });
793
+ }
794
+
795
+ // Remove action buttons that make no sense in a static snapshot
796
+ ['#dk-btn-refresh', '#dk-btn-snapshot'].forEach(function (sel) {
797
+ const el = clone.querySelector(sel);
798
+ if (el) el.remove();
799
+ });
800
+
801
+ // Disable all inputs; keep tab buttons enabled
802
+ clone.querySelectorAll('input, select, button').forEach(function (el) {
803
+ el.setAttribute('disabled', '');
804
+ });
805
+
806
+ // Remove live scripts
807
+ clone.querySelectorAll('script').forEach(function (s) { s.remove(); });
808
+
809
+ // Re-attach tab-click handlers only
810
+ const tabScript = document.createElement('script');
811
+ tabScript.textContent = '(function(){' +
812
+ 'document.querySelectorAll("nav.dk-tabs button").forEach(function(btn){' +
813
+ 'btn.removeAttribute("disabled");' +
814
+ 'btn.addEventListener("click",function(){' +
815
+ 'var id=btn.id.replace("dk-tab-","");' +
816
+ 'document.querySelectorAll(".dk-panel").forEach(function(p){p.classList.remove("active");});' +
817
+ 'var panel=document.getElementById("dk-panel-"+id);' +
818
+ 'if(panel)panel.classList.add("active");' +
819
+ 'document.querySelectorAll("nav.dk-tabs button").forEach(function(b){b.classList.remove("active");});' +
820
+ 'btn.classList.add("active");' +
821
+ '});' +
822
+ '});' +
823
+ '})();';
824
+ clone.querySelector('body').appendChild(tabScript);
825
+
826
+ const dateSlug = (_dateFromIso || '').slice(0, 10);
827
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
828
+ const filename = _title + '-snapshot-' + dateSlug + '-' + ts + '.html';
829
+
830
+ const blob = new Blob(['<!doctype html>\n' + clone.outerHTML], { type: 'text/html' });
831
+ const a = document.createElement('a');
832
+ a.href = URL.createObjectURL(blob);
833
+ a.download = filename;
834
+ a.click();
835
+ URL.revokeObjectURL(a.href);
836
+ }
837
+
838
+ // ── System Info ───────────────────────────────────────────────────────────────
839
+ function _renderSystemInfo() {
840
+ const container = document.getElementById('dk-panel-dk-sysinfo');
841
+ if (!container) return;
842
+ const calls = _calls;
843
+ const okN = calls.filter(function (c) { return c.state === 'ok'; }).length;
844
+ const errN = calls.filter(function (c) { return c.state === 'error'; }).length;
845
+ const runN = calls.filter(function (c) { return c.state === 'running'; }).length;
846
+ const totMs = calls.reduce(function (a, c) { return a + (c.clientMs || 0); }, 0);
847
+ const totB = calls.reduce(function (a, c) { return a + (c.bytes || 0); }, 0);
848
+ const hasCors = calls.some(function (c) { return c.state === 'error' && c.status === null; });
849
+
850
+ const callsHtml = calls.length === 0
851
+ ? '<p style="color:var(--muted);font-size:12px">No calls yet.</p>'
852
+ : calls.map(function (c) {
853
+ const statusLabel = c.status === null
854
+ ? '<span class="dk-s-net">NET</span>'
855
+ : c.state === 'ok'
856
+ ? '<span class="dk-s-ok">' + c.status + '</span>'
857
+ : '<span class="dk-s-err">' + c.status + '</span>';
858
+ return '<details class="dk-call"><summary>' +
859
+ '<span style="color:var(--muted);font-size:11px">#' + c.index + '</span>' +
860
+ statusLabel +
861
+ '<span style="flex:1">' + esc(c.label) + '</span>' +
862
+ '<span style="color:var(--muted);font-size:11px">' + (c.clientMs != null ? c.clientMs + 'ms' : '—') + '</span>' +
863
+ (c.serverMs != null ? '<span style="color:var(--muted);font-size:11px">srv:' + c.serverMs + 'ms</span>' : '') +
864
+ (c.rowCount != null ? '<span style="color:var(--muted);font-size:11px">' + c.rowCount + ' rows</span>' : '') +
865
+ '<span style="color:var(--muted);font-size:10px">' + c.ts.slice(11, 19) + '</span>' +
866
+ '</summary><div class="dk-call-body">' +
867
+ (c.error ? '<div class="dk-s-err" style="margin-bottom:6px">' + esc(c.error) + '</div>' : '') +
868
+ '<p style="font-size:11px;color:var(--muted);margin:0 0 4px">Data Request:</p>' +
869
+ '<pre>' + esc(c.sql) + '</pre>' +
870
+ (c.args && c.args.length ? '<p style="font-size:11px;color:var(--muted);margin:8px 0 4px">Args:</p><pre>' + esc(JSON.stringify(c.args, null, 2)) + '</pre>' : '') +
871
+ '<div class="dk-resp-head">' +
872
+ '<span>Response:</span><button type="button" class="dk-resp-toggle" aria-label="Toggle response">−</button>' +
873
+ '</div>' +
874
+ '<pre class="dk-resp-body">' + esc(JSON.stringify(c.response, null, 2)) + '</pre>' +
875
+ '</div></details>';
876
+ }).join('');
877
+
878
+ container.innerHTML =
879
+ '<div class="dk-sysinfo">' +
880
+ (hasCors ? '<div class="dk-cors-hint">⚠ One or more calls received no HTTP response — likely a CORS or network error. Open DevTools → Network to inspect.</div>' : '') +
881
+ '<div><h3>Context</h3><table class="dk-sys-table">' +
882
+ '<tr><td>Version</td><td>' + esc(_gitVer) + '</td></tr>' +
883
+ '<tr><td>Dashboard title</td><td>' + esc(_title) + '</td></tr>' +
884
+ '<tr><td>Hierarchy ID</td><td>' + (_hierarchyId != null ? _hierarchyId : '—') + '</td></tr>' +
885
+ '<tr><td>Timezone</td><td>' + esc(_timezone || '—') + '</td></tr>' +
886
+ '<tr><td>Day window (UTC)</td><td>' + esc(_dateFromIso) + ' → ' + esc(_dateToIso) + '</td></tr>' +
887
+ '</table></div>' +
888
+ '<div><h3>Totals</h3><table class="dk-sys-table">' +
889
+ '<tr><td>Calls</td><td>' + calls.length + ' (' + okN + ' ok · ' + errN + ' error' + (runN ? ' · ' + runN + ' running' : '') + ')</td></tr>' +
890
+ '<tr><td>Client duration</td><td>' + totMs + ' ms</td></tr>' +
891
+ '<tr><td>Bytes received</td><td>' + fmtInt(totB) + '</td></tr>' +
892
+ '</table></div>' +
893
+ '<div><h3>Calls <button id="dk-sysinfo-export" style="margin-left:8px;background:transparent;color:var(--brand);border:1px solid var(--brand);border-radius:3px;padding:0 8px;height:22px;font-size:11px;cursor:pointer">Export JSON ↓</button></h3>' +
894
+ callsHtml + '</div>' +
895
+ '</div>';
896
+
897
+ container.querySelectorAll('.dk-resp-toggle').forEach(function (btn) {
898
+ btn.addEventListener('click', function () {
899
+ const pre = btn.parentElement.nextElementSibling;
900
+ pre.hidden = !pre.hidden;
901
+ btn.textContent = pre.hidden ? '+' : '−';
902
+ });
903
+ });
904
+
905
+ const expBtn = document.getElementById('dk-sysinfo-export');
906
+ if (expBtn) {
907
+ expBtn.addEventListener('click', function () {
908
+ const payload = {
909
+ title: _title,
910
+ hierarchyId: _hierarchyId, timezone: _timezone,
911
+ dateFromIso: _dateFromIso, dateToIso: _dateToIso,
912
+ calls: _calls.map(function (c) {
913
+ return Object.assign({}, c, { requestHeaders: { 'Content-Type': 'application/json', Authorization: 'Bearer ***' } });
914
+ }),
915
+ };
916
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
917
+ const a = document.createElement('a');
918
+ a.href = URL.createObjectURL(blob);
919
+ a.download = 'system-info-' + (_dateFromIso || '').slice(0, 10) + '-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
920
+ a.click();
921
+ URL.revokeObjectURL(a.href);
922
+ });
923
+ }
924
+ }
925
+
926
+ // ── Header wiring ─────────────────────────────────────────────────────────────
927
+ function _buildHeader(title) {
928
+ const header = document.querySelector('[data-dk-header]');
929
+ if (!header) return;
930
+
931
+ // Collect dashboard-provided content before clearing
932
+ const dashboardNodes = Array.from(header.childNodes);
933
+
934
+ const titleEl = document.createElement('span');
935
+ titleEl.className = 'dk-title';
936
+ titleEl.textContent = title;
937
+
938
+ const spacer = document.createElement('div');
939
+ spacer.className = 'dk-spacer';
940
+
941
+ const btnRefresh = document.createElement('button');
942
+ btnRefresh.id = 'dk-btn-refresh';
943
+ btnRefresh.textContent = 'Refresh';
944
+ btnRefresh.disabled = true;
945
+ btnRefresh.addEventListener('click', function () {
946
+ _invalidateAll();
947
+ _loadSection(_activeTabId);
948
+ });
949
+
950
+ const pill = document.createElement('div');
951
+ pill.id = 'dk-pill';
952
+ pill.className = 'dk-pill';
953
+ pill.textContent = 'idle';
954
+
955
+ // Rebuild: title | spacer | dashboard filters | Refresh | Snapshot | pill
956
+ header.innerHTML = '';
957
+ header.appendChild(titleEl);
958
+ header.appendChild(spacer);
959
+ dashboardNodes.forEach(function (n) { header.appendChild(n); });
960
+ header.appendChild(btnRefresh);
961
+ header.appendChild(pill);
962
+ }
963
+
964
+ // ── DashboardKit.init ────────────────────────────────────────────────────────────
965
+ // A throw here leaves an empty document with no "Ask AI to fix" to click, so the
966
+ // report is posted immediately. No sectionId: the server refuses init with one.
967
+ function init(opts) {
968
+ try {
969
+ _init(opts);
970
+ } catch (e) {
971
+ console.error('[DashboardKit] init failed:', e);
972
+ console.log('[DashboardKit] init failed:', e);
973
+ window.parent.postMessage({
974
+ type: 'DASHBOARD_ERROR',
975
+ kind: 'script', phase: 'init',
976
+ error: { message: e.message || String(e), stack: e.stack || null },
977
+ title: (opts && opts.title) || '',
978
+ }, '*');
979
+ throw e;
980
+ }
981
+ }
982
+
983
+ function _init(opts) {
984
+ _title = opts.title || '';
985
+ if (opts.gitVersion) _gitVer = opts.gitVersion;
986
+ _initTooltip();
987
+
988
+ // Build internal section list, append System Info last
989
+ _sections = (opts.sections || []).map(function (s) {
990
+ return { id: s.id, label: s.label, tooltip: s.tooltip, load: s.load, loaded: false, errors: 0 };
991
+ });
992
+ _sections.push({ id: 'dk-sysinfo', label: 'System Info', load: null, loaded: false, errors: 0 });
993
+
994
+ // Build header controls
995
+ _buildHeader(_title);
996
+
997
+ // Build nav + panels for each section
998
+ const nav = document.querySelector('[data-dk-tabs]') || _createNav();
999
+ nav.id = 'dk-tabs';
1000
+ nav.classList.add('dk-tabs');
1001
+ const content = document.querySelector('.dk-content') || _createContent();
1002
+
1003
+ _sections.forEach(function (s) {
1004
+ if (!document.getElementById('dk-panel-' + s.id)) {
1005
+ const panel = document.createElement('section');
1006
+ panel.id = 'dk-panel-' + s.id;
1007
+ panel.className = 'dk-panel';
1008
+ content.appendChild(panel);
1009
+ }
1010
+ });
1011
+
1012
+ // Wait message
1013
+ const wait = document.createElement('div');
1014
+ wait.id = 'dk-wait';
1015
+ wait.className = 'dk-wait';
1016
+ wait.textContent = 'Waiting for dashboard parameters…';
1017
+ content.insertBefore(wait, content.firstChild);
1018
+
1019
+ // Enable refresh after first boot
1020
+ const origOnMessage = _onMessage;
1021
+ window.addEventListener('message', function (e) {
1022
+ origOnMessage(e);
1023
+ if (_hasBooted) {
1024
+ const btn = document.getElementById('dk-btn-refresh');
1025
+ if (btn) btn.disabled = false;
1026
+ }
1027
+ });
1028
+ }
1029
+
1030
+ function _createNav() {
1031
+ const nav = document.createElement('nav');
1032
+ nav.id = 'dk-tabs';
1033
+ nav.className = 'dk-tabs';
1034
+ document.body.appendChild(nav);
1035
+ return nav;
1036
+ }
1037
+
1038
+ function _createContent() {
1039
+ const div = document.createElement('div');
1040
+ div.className = 'dk-content';
1041
+ document.body.appendChild(div);
1042
+ return div;
1043
+ }
1044
+
1045
+ // ── Public API ────────────────────────────────────────────────────────────────
1046
+ const DashboardKit = {
1047
+ init,
1048
+ get filters() {
1049
+ return {
1050
+ dateFromIso: _dateFromIso,
1051
+ dateToIso: _dateToIso,
1052
+ timezone: _timezone,
1053
+ hierarchyId: _hierarchyId,
1054
+ };
1055
+ },
1056
+ // Helpers are also passed into each load() function — that is the canonical
1057
+ // way to use them. They are mirrored here so DashboardKit.<helper>(…) works too.
1058
+ runQuery,
1059
+ setSectionState,
1060
+ renderTable,
1061
+ renderKpiCard,
1062
+ renderKpiStrip,
1063
+ renderInfoGrid,
1064
+ renderTotalsStrip,
1065
+ formatCurrency,
1066
+ formatInteger: fmtInt,
1067
+ formatPercent,
1068
+ formatDate: fmtDate,
1069
+ formatDatetime: fmtDatetime,
1070
+ colorClass,
1071
+ escHtml: esc,
1072
+ };
1073
+
1074
+ // Expose primitives directly (called inside load functions via destructuring).
1075
+ // Deprecated: prefer the load() arguments or DashboardKit.<helper>; these bare
1076
+ // globals remain for backwards compatibility and may be removed later.
1077
+ global.DashboardKit = DashboardKit;
1078
+ global.runQuery = runQuery;
1079
+ global.setSectionState = setSectionState;
1080
+ global.renderTable = renderTable;
1081
+ global.renderKpiCard = renderKpiCard;
1082
+ global.renderKpiStrip = renderKpiStrip;
1083
+ global.renderInfoGrid = renderInfoGrid;
1084
+ global.renderTotalsStrip = renderTotalsStrip;
1085
+ global.formatCurrency = formatCurrency;
1086
+ global.formatInteger = fmtInt;
1087
+ global.formatPercent = formatPercent;
1088
+ global.formatDate = fmtDate;
1089
+ global.formatDatetime = fmtDatetime;
1090
+ global.colorClass = colorClass;
1091
+ global.escHtml = esc;
1092
+
1093
+ }(window));