pivotgrid-js 0.1.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.
@@ -0,0 +1,687 @@
1
+ /**
2
+ * config-editor.js
3
+ */
4
+
5
+ // ── i18n ──────────────────────────────────────────────────────────────────────
6
+
7
+ const _lang = document.body.dataset.lang || 'ru';
8
+ const t = (key, vars) => {
9
+ let str = I18N[_lang]?.[key] ?? I18N.ru[key] ?? key;
10
+ if (vars) Object.entries(vars).forEach(([k, v]) => { str = str.replace(`{${k}}`, v); });
11
+ return str;
12
+ };
13
+
14
+ // Apply translations to static HTML
15
+ document.querySelectorAll('[data-i18n]').forEach(el => {
16
+ el.textContent = t(el.dataset.i18n);
17
+ });
18
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
19
+ el.placeholder = t(el.dataset.i18nPlaceholder);
20
+ });
21
+
22
+ // ── State ─────────────────────────────────────────────────────────────────
23
+
24
+ let _columns = []; // { name, title, type, sortKey, checked }
25
+ const _zones = { free: [], rows: [], columns: [], cache: [] };
26
+
27
+ // ── DOM refs ──────────────────────────────────────────────────────────────────
28
+
29
+ const mainQueryEl = document.getElementById('main-query');
30
+ const colsQueryEl = document.getElementById('cols-query');
31
+
32
+ // ── Sync cols-query with main-query ────────────────────────────────────────────
33
+
34
+ mainQueryEl.addEventListener('input', () => {
35
+ const q = mainQueryEl.value.trim();
36
+ colsQueryEl.value = q ? `SELECT * FROM (\n ${q}\n) _t LIMIT 1` : '';
37
+ });
38
+
39
+ // ── Fetch columns ──────────────────────────────────────────────────────────────
40
+
41
+ document.getElementById('btn-fetch-cols').addEventListener('click', async () => {
42
+ const url = document.getElementById('server-url').value.trim();
43
+ const query = colsQueryEl.value.trim();
44
+ const status = document.getElementById('fetch-status');
45
+ const btn = document.getElementById('btn-fetch-cols');
46
+
47
+ if (!url || !query) { showStatus(status, 'error', t('ce_fillUrl')); return; }
48
+
49
+ btn.disabled = true;
50
+ btn.innerHTML = '<span class="spinner"></span>' + t('loading');
51
+ status.className = 'status';
52
+
53
+ try {
54
+ const res = await fetch(url + '/query', {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify({ query }),
58
+ });
59
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
60
+ const rows = await res.json();
61
+ if (!rows.length) throw new Error(t('ce_zeroRows'));
62
+
63
+ // Preserve existing column settings
64
+ _columns = Object.keys(rows[0]).map(name => {
65
+ const existing = _columns.find(c => c.name === name);
66
+ return existing || { name, title: '', type: guessType(name), sortKey: '', checked: false };
67
+ });
68
+
69
+ renderColsList();
70
+ renderZones();
71
+ updateMeasureSelect();
72
+ showStatus(status, 'ok', t('ce_colsLoaded', { n: _columns.length }));
73
+ generateConfig();
74
+
75
+ } catch (err) {
76
+ showStatus(status, 'error', t('ce_loadError') + err.message);
77
+ } finally {
78
+ btn.disabled = false;
79
+ btn.textContent = t('ce_fetchBtn');
80
+ }
81
+ });
82
+
83
+ // ── Guess column type ───────────────────────────────────────────────────────────
84
+
85
+ function guessType(name) {
86
+ return /revenue|amount|sales|units|qty|quantity|price|cost|profit|sum|total/i.test(name)
87
+ ? 'measure' : 'dimension';
88
+ }
89
+
90
+ // ── Column list ────────────────────────────────────────────────────────────────
91
+
92
+ function renderColsList() {
93
+ const wrap = document.getElementById('cols-wrap');
94
+ if (!_columns.length) {
95
+ wrap.innerHTML = `<div class="fields-empty">${t('ce_emptyFields')}</div>`;
96
+ return;
97
+ }
98
+
99
+ wrap.innerHTML = `
100
+ <div class="cols-list">
101
+ <div class="cols-list-header">
102
+ <span></span>
103
+ <span>${t('ce_colDb')}</span>
104
+ <span>${t('ce_colTitle')}</span>
105
+ <span>${t('type')}</span>
106
+ <span>sortKey</span>
107
+ </div>
108
+ ${_columns.map((col, i) => `
109
+ <div class="col-row ${col.checked ? 'is-checked' : ''}" data-i="${i}">
110
+ <input type="checkbox" data-i="${i}" ${col.checked ? 'checked' : ''}>
111
+ <span class="col-db-name" title="${col.name}">${col.name}</span>
112
+ <input type="text" data-i="${i}" data-f="title"
113
+ value="${escAttr(col.title)}" placeholder="${escAttr(col.name)}">
114
+ <select data-i="${i}" data-f="type">
115
+ <option value="dimension" ${col.type === 'dimension' ? 'selected' : ''}>${t('ce_dimension')}</option>
116
+ <option value="measure" ${col.type === 'measure' ? 'selected' : ''}>${t('ce_measure_type')}</option>
117
+ </select>
118
+ <select data-i="${i}" data-f="sortKey" ${col.type === 'measure' ? 'disabled' : ''}>
119
+ <option value="">—</option>
120
+ ${_columns.filter((_, j) => j !== i)
121
+ .map(c => `<option value="${c.name}" ${col.sortKey === c.name ? 'selected' : ''}>${c.name}</option>`)
122
+ .join('')}
123
+ </select>
124
+ </div>
125
+ `).join('')}
126
+ </div>
127
+ `;
128
+
129
+ // Checkboxes
130
+ wrap.querySelectorAll('input[type="checkbox"]').forEach(cb => {
131
+ cb.addEventListener('change', () => {
132
+ const col = _columns[+cb.dataset.i];
133
+ col.checked = cb.checked;
134
+ cb.closest('.col-row').classList.toggle('is-checked', cb.checked);
135
+ if (cb.checked) {
136
+ if (!inAnyZone(col.name) && col.type !== 'measure') _zones.free.push(col.name);
137
+ } else {
138
+ removeFromAllZones(col.name);
139
+ }
140
+ renderZones();
141
+ updateMeasureSelect();
142
+ generateConfig();
143
+ });
144
+ });
145
+
146
+ // Title / type / sortKey
147
+ wrap.querySelectorAll('input[data-f], select[data-f]').forEach(el => {
148
+ const update = () => {
149
+ _columns[+el.dataset.i][el.dataset.f] = el.value;
150
+
151
+ if (el.dataset.f === 'type') {
152
+ if (el.value === 'measure') {
153
+ _columns[+el.dataset.i].sortKey = '';
154
+ removeFromAllZones(_columns[+el.dataset.i].name);
155
+ } else {
156
+ const col = _columns[+el.dataset.i];
157
+ if (col.checked && !inAnyZone(col.name)) _zones.free.push(col.name);
158
+ }
159
+ updateMeasureSelect();
160
+ const scrollTop = document.querySelector('.cols-list')?.scrollTop || 0;
161
+ renderColsList();
162
+ document.querySelector('.cols-list').scrollTop = scrollTop;
163
+ renderZones();
164
+ }
165
+
166
+ if (el.dataset.f === 'title') renderZones();
167
+
168
+ generateConfig();
169
+ };
170
+ el.addEventListener('change', update);
171
+ if (el.tagName === 'INPUT') el.addEventListener('input', update);
172
+ });
173
+ }
174
+
175
+ function inAnyZone(name) {
176
+ return ['free', 'rows', 'columns', 'cache'].some(z => _zones[z].includes(name));
177
+ }
178
+
179
+ function removeFromAllZones(name) {
180
+ for (const z of ['free', 'rows', 'columns', 'cache']) {
181
+ _zones[z] = _zones[z].filter(n => n !== name);
182
+ }
183
+ }
184
+
185
+ // ── Drag zones ─────────────────────────────────────────────────────────────────
186
+
187
+ function renderZones() {
188
+ for (const zone of ['free', 'rows', 'columns', 'cache']) {
189
+ const el = document.getElementById('dz-' + zone);
190
+ el.innerHTML = '';
191
+ for (const name of _zones[zone]) el.appendChild(makeChip(name, zone));
192
+ }
193
+ }
194
+
195
+ function makeChip(name, zone) {
196
+ const col = _columns.find(c => c.name === name);
197
+ const chip = document.createElement('div');
198
+ chip.className = 'dz-chip';
199
+ chip.dataset.name = name;
200
+ chip.dataset.zone = zone;
201
+
202
+ const lbl = document.createElement('span');
203
+ lbl.textContent = col?.title || name;
204
+ chip.appendChild(lbl);
205
+
206
+ if (zone !== 'free') {
207
+ const rm = document.createElement('span');
208
+ rm.className = 'dz-chip-remove';
209
+ rm.textContent = '×';
210
+ rm.addEventListener('mousedown', e => e.stopPropagation());
211
+ rm.addEventListener('click', () => {
212
+ _zones[zone] = _zones[zone].filter(n => n !== name);
213
+ if (zone !== 'cache' && !_zones.rows.includes(name) &&
214
+ !_zones.columns.includes(name) && !_zones.free.includes(name)) {
215
+ _zones.free.push(name);
216
+ }
217
+ renderZones();
218
+ generateConfig();
219
+ });
220
+ chip.appendChild(rm);
221
+ }
222
+
223
+ initChipDrag(chip);
224
+ return chip;
225
+ }
226
+
227
+ // ── Drag & Drop ───────────────────────────────────────────────────────────────
228
+
229
+ let _placeholder = null;
230
+
231
+ function initChipDrag(chip) {
232
+ chip.addEventListener('mousedown', e => {
233
+ if (e.target.classList.contains('dz-chip-remove')) return;
234
+ e.preventDefault();
235
+
236
+ const name = chip.dataset.name;
237
+ const fromZone = chip.dataset.zone;
238
+ const startX = e.clientX;
239
+ const startY = e.clientY;
240
+ let dragging = false;
241
+ let ghost = null;
242
+
243
+ const onMove = mv => {
244
+ if (!dragging && Math.hypot(mv.clientX - startX, mv.clientY - startY) > 5) {
245
+ dragging = true;
246
+ chip.classList.add('dz-chip--dragging');
247
+ ghost = chip.cloneNode(true);
248
+ ghost.className = 'dz-chip dz-chip--ghost';
249
+ Object.assign(ghost.style, {
250
+ position: 'fixed', pointerEvents: 'none', zIndex: '9999',
251
+ left: mv.clientX + 12 + 'px', top: mv.clientY - 12 + 'px',
252
+ });
253
+ document.body.appendChild(ghost);
254
+ }
255
+ if (!dragging) return;
256
+ ghost.style.left = mv.clientX + 12 + 'px';
257
+ ghost.style.top = mv.clientY - 12 + 'px';
258
+ ghost.style.visibility = chip.style.visibility = 'hidden';
259
+ updatePlaceholder(mv);
260
+ ghost.style.visibility = chip.style.visibility = '';
261
+ };
262
+
263
+ const onUp = () => {
264
+ document.removeEventListener('mousemove', onMove);
265
+ document.removeEventListener('mouseup', onUp);
266
+ ghost?.remove();
267
+ chip.classList.remove('dz-chip--dragging');
268
+ chip.style.visibility = '';
269
+
270
+ if (!dragging) { clearHighlight(); return; }
271
+
272
+ const ph = _placeholder;
273
+ if (!ph?.parentNode) { clearHighlight(); return; }
274
+
275
+ const zoneEl = ph.parentNode.closest('[data-dz-zone]') || ph.parentNode;
276
+ const toZone = zoneEl.dataset.dzZone;
277
+ const siblings = [...ph.parentNode.children];
278
+ const afterChips = siblings.slice(siblings.indexOf(ph) + 1)
279
+ .filter(el => el.classList.contains('dz-chip'));
280
+ const beforeName = afterChips[0]?.dataset.name || null;
281
+
282
+ clearHighlight();
283
+ if (toZone) applyDrop(name, fromZone, toZone, beforeName);
284
+ };
285
+
286
+ document.addEventListener('mousemove', onMove);
287
+ document.addEventListener('mouseup', onUp);
288
+ });
289
+ }
290
+
291
+ function applyDrop(name, fromZone, toZone, beforeName) {
292
+ if (toZone === 'cache') {
293
+ if (!_zones.cache.includes(name)) _zones.cache.push(name);
294
+ renderZones(); generateConfig(); return;
295
+ }
296
+ if (fromZone === 'cache') {
297
+ _zones.cache = _zones.cache.filter(n => n !== name);
298
+ renderZones(); generateConfig(); return;
299
+ }
300
+
301
+ _zones[fromZone] = _zones[fromZone].filter(n => n !== name);
302
+ const arr = _zones[toZone];
303
+ if (beforeName) {
304
+ const idx = arr.indexOf(beforeName);
305
+ arr.splice(idx !== -1 ? idx : arr.length, 0, name);
306
+ } else {
307
+ arr.push(name);
308
+ }
309
+ renderZones();
310
+ generateConfig();
311
+ }
312
+
313
+ function updatePlaceholder(e) {
314
+ clearHighlight();
315
+ const zoneEl = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-dz-zone]');
316
+ if (zoneEl) zoneEl.classList.add('dz-over');
317
+
318
+ const ph = document.createElement('div');
319
+ ph.className = 'dz-placeholder';
320
+ _placeholder = ph;
321
+
322
+ const target = document.elementFromPoint(e.clientX, e.clientY)?.closest('.dz-chip');
323
+ if (target && !target.classList.contains('dz-placeholder')) {
324
+ const rect = target.getBoundingClientRect();
325
+ target.parentNode.insertBefore(ph, e.clientX < rect.left + rect.width / 2 ? target : target.nextSibling);
326
+ } else if (zoneEl) {
327
+ (zoneEl.querySelector('[id^="dz-"]') || zoneEl).appendChild(ph);
328
+ }
329
+ }
330
+
331
+ function clearHighlight() {
332
+ document.querySelectorAll('[data-dz-zone]').forEach(z => z.classList.remove('dz-over'));
333
+ _placeholder?.remove();
334
+ _placeholder = null;
335
+ }
336
+
337
+ // ── Selects ─────────────────────────────────────────────────────────────────────
338
+
339
+ function updateMeasureSelect() {
340
+ const sel = document.getElementById('init-measure');
341
+ const current = sel.value;
342
+ const measures = _columns.filter(c => c.checked && c.type === 'measure');
343
+ sel.innerHTML = measures.map(c =>
344
+ `<option value="${c.name}" ${c.name === current ? 'selected' : ''}>${c.title || c.name}</option>`
345
+ ).join('');
346
+ }
347
+
348
+ function updateFuncSelect() {
349
+ const sel = document.getElementById('init-func');
350
+ const current = sel.value;
351
+ const funcs = [...document.querySelectorAll('#funcs-wrap input:checked')].map(cb => cb.value);
352
+ sel.innerHTML = funcs.map(f =>
353
+ `<option value="${f}" ${f === current ? 'selected' : ''}>${f}</option>`
354
+ ).join('');
355
+ }
356
+
357
+ // ── Drillthrough toggle ────────────────────────────────────────────────────────
358
+
359
+ document.querySelectorAll('input[name="dt-type"]').forEach(r => {
360
+ r.addEventListener('change', () => {
361
+ document.getElementById('dt-sql-wrap').style.display = r.value === 'sql' ? '' : 'none';
362
+ document.getElementById('dt-url-wrap').style.display = r.value === 'url' ? '' : 'none';
363
+ generateConfig();
364
+ });
365
+ });
366
+
367
+ document.getElementById('dt-query').addEventListener('input', generateConfig);
368
+ document.getElementById('dt-url').addEventListener('input', generateConfig);
369
+
370
+ // ── Config generation ──────────────────────────────────────────────────────────
371
+
372
+ ['init-measure', 'init-func', 'init-max-rows', 'init-filter-limit'].forEach(id => {
373
+ document.getElementById(id).addEventListener('change', generateConfig);
374
+ document.getElementById(id).addEventListener('input', generateConfig);
375
+ });
376
+
377
+ document.querySelectorAll('#funcs-wrap input').forEach(cb => {
378
+ cb.addEventListener('change', () => { updateFuncSelect(); generateConfig(); });
379
+ });
380
+
381
+ function generateConfig() {
382
+ const active = _columns.filter(c => c.checked);
383
+ const dims = active.filter(c => c.type === 'dimension');
384
+ const measures = active.filter(c => c.type === 'measure');
385
+ const funcs = [...document.querySelectorAll('#funcs-wrap input:checked')].map(cb => cb.value);
386
+
387
+ const mainQuery = mainQueryEl.value.trim();
388
+ const maxRows = parseInt(document.getElementById('init-max-rows').value) || 500_000;
389
+ const measure = document.getElementById('init-measure').value || (measures[0]?.name ?? '');
390
+ const func = document.getElementById('init-func').value;
391
+ const fltLimit = parseInt(document.getElementById('init-filter-limit').value) || 30;
392
+ const dtType = document.querySelector('input[name="dt-type"]:checked').value;
393
+ const dtQuery = document.getElementById('dt-query').value.trim();
394
+ const dtUrl = document.getElementById('dt-url').value.trim();
395
+
396
+ const fieldsLines = active.map(c => {
397
+ const parts = [`label: '${escStr(c.name)}'`];
398
+ if (c.title) parts.push(`title: '${escStr(c.title)}'`);
399
+ if (c.sortKey) parts.push(`sortKey: '${escStr(c.sortKey)}'`);
400
+ return ` ${c.name}: { ${parts.join(', ')} },`;
401
+ }).join('\n');
402
+
403
+ const list = arr => arr.map(n => `'${n}'`).join(', ');
404
+ const cacheFiltered = _zones.cache.filter(n => dims.find(d => d.name === n));
405
+ const maxRowsStr = maxRows.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '_');
406
+ const drillthroughStr = dtType === 'sql'
407
+ ? `drillthroughQuery: \`\n ${dtQuery}\n \`,`
408
+ : `drillthroughUrl: '${dtUrl}',`;
409
+
410
+ const config = `const CONFIG = {
411
+ query: \`
412
+ ${mainQuery}
413
+ \`,
414
+
415
+ dimensions: [${list(dims.map(c => c.name))}],
416
+ measures: [${list(measures.map(c => c.name))}],
417
+ funcs: [${funcs.map(f => `'${f}'`).join(', ')}],
418
+
419
+ fields: {
420
+ ${fieldsLines}
421
+ },
422
+
423
+ cachedDimensions: [${list(cacheFiltered)}],
424
+
425
+ rows: [${list(_zones.rows)}],
426
+ columns: [${list(_zones.columns)}],
427
+ measure: '${measure}',
428
+ func: '${func}',
429
+
430
+ maxCachedRows: ${maxRowsStr},
431
+ filterCheckboxLimit: ${fltLimit},
432
+ ${drillthroughStr}
433
+ };`;
434
+
435
+ document.getElementById('config-preview').value = config;
436
+ localStorage.setItem('pivot_config_preview', config);
437
+ }
438
+
439
+ // ── Preview ────────────────────────────────────────────────────────────────────
440
+
441
+ document.getElementById('btn-preview').addEventListener('click', () => {
442
+ const text = document.getElementById('config-preview').value;
443
+ if (!text.trim()) return;
444
+ localStorage.setItem('pivot_config_preview', text);
445
+ window.open('../demo/index.html?preview=1', '_blank');
446
+ });
447
+
448
+ // ── Configs on server ──────────────────────────────────────────────────────────
449
+
450
+ const serverUrl = () => document.getElementById('server-url').value.trim();
451
+
452
+ async function loadConfigList() {
453
+ try {
454
+ const res = await fetch(serverUrl() + '/configs');
455
+ const names = await res.json();
456
+ const sel = document.getElementById('sel-config-name');
457
+ const current = sel.value;
458
+ sel.innerHTML = `<option value="">${t('ce_selectConfig')}</option>` +
459
+ names.map(n => `<option value="${n}" ${n === current ? 'selected' : ''}>${n}</option>`).join('');
460
+ } catch (e) {
461
+ console.warn('Не удалось загрузить список конфигов:', e.message);
462
+ }
463
+ }
464
+
465
+ // Load config from server
466
+ document.getElementById('sel-config-name').addEventListener('change', async (e) => {
467
+ const name = e.target.value;
468
+ if (!name) return;
469
+ document.getElementById('inp-config-name').value = name;
470
+ document.getElementById('btn-delete-config').disabled = !name;
471
+ try {
472
+ const res = await fetch(serverUrl() + '/configs/' + name);
473
+ const cfg = await res.json();
474
+ _columns = [];
475
+ applyConfig(cfg);
476
+ document.getElementById('btn-fetch-cols').click();
477
+ } catch (err) {
478
+ alert(t('ce_loadFailed') + err.message);
479
+ }
480
+ });
481
+
482
+ // Save config to server
483
+ document.getElementById('btn-save-config').addEventListener('click', async () => {
484
+ const name = document.getElementById('inp-config-name').value.trim();
485
+ if (!name) { alert(t('ce_enterName')); return; }
486
+
487
+ const text = document.getElementById('config-preview').value;
488
+ if (!text.trim()) { alert(t('ce_emptyConfig')); return; }
489
+
490
+ try {
491
+ const fn = new Function(text + '\nreturn CONFIG;');
492
+ const cfg = fn();
493
+ const res = await fetch(serverUrl() + '/configs/' + name, {
494
+ method: 'POST',
495
+ headers: { 'Content-Type': 'application/json' },
496
+ body: JSON.stringify(cfg),
497
+ });
498
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
499
+ await loadConfigList();
500
+ document.getElementById('sel-config-name').value = name;
501
+ alert(t('ce_configSaved', { name }));
502
+ } catch (err) {
503
+ alert(t('ce_saveFailed') + err.message);
504
+ }
505
+ });
506
+
507
+ // ── Apply config ───────────────────────────────────────────────────────────────
508
+
509
+ function applyConfig(cfg) {
510
+ mainQueryEl.value = (cfg.query || '').trim();
511
+ mainQueryEl.dispatchEvent(new Event('input'));
512
+
513
+ const funcsSet = new Set(cfg.funcs || []);
514
+ document.querySelectorAll('#funcs-wrap input').forEach(cb => {
515
+ cb.checked = funcsSet.has(cb.value);
516
+ });
517
+ updateFuncSelect();
518
+
519
+ document.getElementById('init-max-rows').value = cfg.maxCachedRows || 500000;
520
+ document.getElementById('init-filter-limit').value = cfg.filterCheckboxLimit || 30;
521
+ document.getElementById('init-func').value = cfg.func || 'sum';
522
+
523
+ if (cfg.drillthroughUrl) {
524
+ document.querySelector('input[name="dt-type"][value="url"]').checked = true;
525
+ document.getElementById('dt-sql-wrap').style.display = 'none';
526
+ document.getElementById('dt-url-wrap').style.display = '';
527
+ document.getElementById('dt-url').value = cfg.drillthroughUrl;
528
+ } else {
529
+ document.querySelector('input[name="dt-type"][value="sql"]').checked = true;
530
+ document.getElementById('dt-sql-wrap').style.display = '';
531
+ document.getElementById('dt-url-wrap').style.display = 'none';
532
+ document.getElementById('dt-query').value = (cfg.drillthroughQuery || '').trim();
533
+ }
534
+
535
+ if (cfg.fields) {
536
+ _columns = Object.entries(cfg.fields).map(([name, def]) => ({
537
+ name,
538
+ title: def.title || '',
539
+ type: (cfg.measures || []).includes(name) ? 'measure' : 'dimension',
540
+ sortKey: def.sortKey || '',
541
+ checked: true,
542
+ }));
543
+
544
+ _zones.rows = [...(cfg.rows || [])];
545
+ _zones.columns = [...(cfg.columns || [])];
546
+ _zones.cache = [...(cfg.cachedDimensions || [])];
547
+ _zones.free = _columns
548
+ .filter(c => c.checked && c.type === 'dimension')
549
+ .map(c => c.name)
550
+ .filter(n => !_zones.rows.includes(n) && !_zones.columns.includes(n));
551
+
552
+ renderColsList();
553
+ renderZones();
554
+ updateMeasureSelect();
555
+ document.getElementById('init-measure').value = cfg.measure || '';
556
+ }
557
+
558
+ generateConfig();
559
+ }
560
+
561
+ // ── Utilities ───────────────────────────────────────────────────────────────────
562
+
563
+ function escAttr(str) { return (str || '').replace(/"/g, '&quot;'); }
564
+ function escStr(str) { return (str || '').replace(/'/g, "\\'"); }
565
+
566
+ function showStatus(el, type, msg) {
567
+ el.className = 'status ' + type;
568
+ el.textContent = msg;
569
+ if (type === 'ok') setTimeout(() => { el.className = 'status'; }, 3000);
570
+ }
571
+
572
+ // ── DB settings ────────────────────────────────────────────────────────────────
573
+
574
+ document.getElementById('btn-load-db').addEventListener('click', async () => {
575
+ const status = document.getElementById('db-status');
576
+ try {
577
+ const res = await fetch(serverUrl() + '/server-config');
578
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
579
+ const data = await res.json();
580
+
581
+ const sel = document.getElementById('db-connector');
582
+ sel.innerHTML = Object.entries(data.connectors || {})
583
+ .map(([k, name]) => `<option value="${k}" ${k === data.connector ? 'selected' : ''}>${name}</option>`)
584
+ .join('');
585
+
586
+ document.getElementById('db-host').value = data.host || '';
587
+ document.getElementById('db-port').value = data.port || '';
588
+ document.getElementById('db-name').value = data.dbname || '';
589
+ document.getElementById('db-user').value = data.user || '';
590
+
591
+ showStatus(status, 'ok', t('ce_dbLoaded'));
592
+ } catch (err) {
593
+ showStatus(status, 'error', t('ce_loadError') + err.message);
594
+ }
595
+ });
596
+
597
+ document.getElementById('btn-save-db').addEventListener('click', async () => {
598
+ const status = document.getElementById('db-status');
599
+ const data = {
600
+ connector: document.getElementById('db-connector').value,
601
+ host: document.getElementById('db-host').value.trim(),
602
+ port: document.getElementById('db-port').value.trim(),
603
+ dbname: document.getElementById('db-name').value.trim(),
604
+ user: document.getElementById('db-user').value.trim(),
605
+ password: document.getElementById('db-password').value,
606
+ };
607
+
608
+ if (!data.host || !data.dbname || !data.user) {
609
+ showStatus(status, 'error', t('ce_fillDbFields'));
610
+ return;
611
+ }
612
+
613
+ try {
614
+ const res = await fetch(serverUrl() + '/server-config', {
615
+ method: 'POST',
616
+ headers: { 'Content-Type': 'application/json' },
617
+ body: JSON.stringify(data),
618
+ });
619
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
620
+ document.getElementById('db-password').value = '';
621
+ showStatus(status, 'ok', t('ce_dbSaved'));
622
+ } catch (err) {
623
+ showStatus(status, 'error', t('ce_loadError') + err.message);
624
+ }
625
+ });
626
+
627
+ document.getElementById('btn-test-db').addEventListener('click', async () => {
628
+ const status = document.getElementById('db-status');
629
+ const data = {
630
+ host: document.getElementById('db-host').value.trim(),
631
+ port: document.getElementById('db-port').value.trim(),
632
+ dbname: document.getElementById('db-name').value.trim(),
633
+ user: document.getElementById('db-user').value.trim(),
634
+ password: document.getElementById('db-password').value,
635
+ };
636
+ try {
637
+ const res = await fetch(serverUrl() + '/test-connection', {
638
+ method: 'POST',
639
+ headers: { 'Content-Type': 'application/json' },
640
+ body: JSON.stringify(data),
641
+ });
642
+ const json = await res.json();
643
+ if (!res.ok) throw new Error(json.error);
644
+ showStatus(status, 'ok', t('ce_testOk'));
645
+ } catch (err) {
646
+ showStatus(status, 'error', t('ce_testError') + err.message);
647
+ }
648
+ });
649
+
650
+ document.getElementById('btn-new-config').addEventListener('click', () => {
651
+ document.getElementById('sel-config-name').value = '';
652
+ document.getElementById('inp-config-name').value = '';
653
+ _columns = [];
654
+ _zones.free = [];
655
+ _zones.rows = [];
656
+ _zones.columns = [];
657
+ _zones.cache = [];
658
+ mainQueryEl.value = '';
659
+ colsQueryEl.value = '';
660
+ renderColsList();
661
+ renderZones();
662
+ updateMeasureSelect();
663
+ generateConfig();
664
+ });
665
+
666
+ document.getElementById('btn-delete-config').addEventListener('click', async () => {
667
+ const name = document.getElementById('inp-config-name').value.trim();
668
+ if (!name) return;
669
+ if (!confirm(t('ce_confirmDelete', { name }))) return;
670
+ try {
671
+ const res = await fetch(serverUrl() + '/configs/' + name, { method: 'DELETE' });
672
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
673
+ document.getElementById('inp-config-name').value = '';
674
+ await loadConfigList();
675
+ document.getElementById('btn-new-config').click();
676
+ showStatus(document.getElementById('db-status'), 'ok', t('ce_deleteOk', { name }));
677
+ } catch (err) {
678
+ showStatus(document.getElementById('db-status'), 'error', t('ce_deleteFailed') + err.message);
679
+ }
680
+ });
681
+
682
+ // ── Initialization ─────────────────────────────────────────────────────────────
683
+
684
+ updateFuncSelect();
685
+ generateConfig();
686
+ loadConfigList();
687
+ document.getElementById('btn-load-db').click();