nodebb-plugin-onekite-calendar 2.0.11 → 2.0.13

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 (35) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/admin.js +21 -9
  3. package/lib/api.js +235 -4
  4. package/lib/db.js +114 -0
  5. package/lib/helloassoWebhook.js +28 -0
  6. package/library.js +7 -0
  7. package/package.json +1 -1
  8. package/pkg/package/CHANGELOG.md +106 -0
  9. package/pkg/package/lib/admin.js +554 -0
  10. package/pkg/package/lib/api.js +1458 -0
  11. package/pkg/package/lib/controllers.js +11 -0
  12. package/pkg/package/lib/db.js +224 -0
  13. package/pkg/package/lib/discord.js +190 -0
  14. package/pkg/package/lib/helloasso.js +352 -0
  15. package/pkg/package/lib/helloassoWebhook.js +389 -0
  16. package/pkg/package/lib/scheduler.js +201 -0
  17. package/pkg/package/lib/widgets.js +460 -0
  18. package/pkg/package/library.js +164 -0
  19. package/pkg/package/package.json +14 -0
  20. package/pkg/package/plugin.json +43 -0
  21. package/pkg/package/public/admin.js +1477 -0
  22. package/pkg/package/public/client.js +2228 -0
  23. package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -0
  24. package/pkg/package/templates/calendar-onekite.tpl +51 -0
  25. package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
  26. package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
  27. package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
  28. package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
  29. package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
  30. package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
  31. package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
  32. package/plugin.json +1 -1
  33. package/public/admin.js +205 -4
  34. package/public/client.js +238 -7
  35. package/templates/admin/plugins/calendar-onekite.tpl +74 -0
@@ -0,0 +1,1477 @@
1
+
2
+ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts, bootbox) {
3
+ 'use strict';
4
+
5
+ // ACP dark mode compatibility: style the address autocomplete dropdown using
6
+ // Bootstrap CSS variables (so it remains readable in dark themes).
7
+ (function ensureOnekiteAdminStyles() {
8
+ try {
9
+ if (document.getElementById('onekite-acp-inline-styles')) return;
10
+ const style = document.createElement('style');
11
+ style.id = 'onekite-acp-inline-styles';
12
+ style.textContent = `
13
+ .onekite-autocomplete-menu {
14
+ background: var(--bs-body-bg, #fff);
15
+ color: var(--bs-body-color, #212529);
16
+ border: 1px solid var(--bs-border-color, rgba(0,0,0,.15));
17
+ box-shadow: 0 .5rem 1rem rgba(0,0,0,.15);
18
+ }
19
+ .onekite-autocomplete-item {
20
+ color: inherit;
21
+ background: transparent;
22
+ }
23
+ .onekite-autocomplete-item:hover,
24
+ .onekite-autocomplete-item:focus {
25
+ background: var(--bs-tertiary-bg, rgba(0,0,0,.05));
26
+ outline: none;
27
+ }
28
+ `;
29
+ document.head.appendChild(style);
30
+ } catch (e) {
31
+ // ignore
32
+ }
33
+ })();
34
+
35
+ // Cache of pending reservations keyed by rid so delegated click handlers
36
+ // can open rich modals without embedding large JSON blobs into the DOM.
37
+ const pendingCache = new Map();
38
+
39
+ // Prevent double actions (double taps/clicks) in ACP.
40
+ // Keyed by action (eg. approve:RID, refuse:RID, batch:approve).
41
+ const actionLocks = new Set();
42
+
43
+ function setButtonsDisabled(btns, disabled) {
44
+ (btns || []).filter(Boolean).forEach((b) => {
45
+ try {
46
+ b.disabled = !!disabled;
47
+ b.classList.toggle('disabled', !!disabled);
48
+ b.setAttribute('aria-disabled', disabled ? 'true' : 'false');
49
+ } catch (e) {}
50
+ });
51
+ }
52
+
53
+ function setBtnBusy(btn, busy) {
54
+ if (!btn) return;
55
+ try {
56
+ if (busy) {
57
+ if (!btn.dataset.okcOrigHtml) btn.dataset.okcOrigHtml = btn.innerHTML;
58
+ btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right:6px;"></span>' + btn.dataset.okcOrigHtml;
59
+ } else if (btn.dataset.okcOrigHtml) {
60
+ btn.innerHTML = btn.dataset.okcOrigHtml;
61
+ }
62
+ } catch (e) {}
63
+ }
64
+
65
+ async function withLock(lockKey, btns, fn) {
66
+ if (!lockKey) return await fn();
67
+ if (actionLocks.has(lockKey)) return;
68
+ actionLocks.add(lockKey);
69
+ setButtonsDisabled(btns, true);
70
+ // Only show a spinner on the primary button (first in list)
71
+ setBtnBusy(btns && btns[0], true);
72
+ try {
73
+ return await fn();
74
+ } finally {
75
+ setBtnBusy(btns && btns[0], false);
76
+ setButtonsDisabled(btns, false);
77
+ actionLocks.delete(lockKey);
78
+ }
79
+ }
80
+
81
+ function getActiveBootboxFooterButtons() {
82
+ try {
83
+ const modal = document.querySelector('.bootbox.modal.show');
84
+ if (!modal) return [];
85
+ return Array.from(modal.querySelectorAll('.modal-footer button'));
86
+ } catch (e) {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ function showAlert(type, msg) {
92
+ // Deduplicate identical alerts that can be triggered multiple times
93
+ // by NodeBB ACP save buttons/hooks across ajaxify navigations.
94
+ try {
95
+ const now = Date.now();
96
+ const last = window.oneKiteCalendarLastAlert;
97
+ if (last && last.type === type && last.msg === msg && (now - last.ts) < 1200) {
98
+ return;
99
+ }
100
+ window.oneKiteCalendarLastAlert = { type, msg, ts: now };
101
+ } catch (e) {}
102
+ try {
103
+ if (alerts && typeof alerts[type] === 'function') {
104
+ alerts[type](msg);
105
+ return;
106
+ }
107
+ } catch (e) {}
108
+ alert(msg);
109
+ }
110
+
111
+ async function fetchJson(url, opts) {
112
+ const res = await fetch(url, {
113
+ credentials: 'same-origin',
114
+ headers: (() => {
115
+ const headers = { 'Content-Type': 'application/json' };
116
+ const token =
117
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
118
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
119
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
120
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
121
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
122
+ null;
123
+ if (token) headers['x-csrf-token'] = token;
124
+ return headers;
125
+ })(),
126
+ ...opts,
127
+ });
128
+
129
+
130
+ if (!res.ok) {
131
+ // NodeBB versions differ: some expose admin APIs under /api/admin instead of /api/v3/admin
132
+ if (res.status === 404 && typeof url === 'string' && url.includes('/api/v3/admin/')) {
133
+ const altUrl = url.replace('/api/v3/admin/', '/api/admin/');
134
+ const res2 = await fetch(altUrl, {
135
+ credentials: 'same-origin',
136
+ headers: (() => {
137
+ const headers = { 'Content-Type': 'application/json' };
138
+ const token =
139
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
140
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
141
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
142
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
143
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
144
+ null;
145
+ if (token) headers['x-csrf-token'] = token;
146
+ return headers;
147
+ })(),
148
+ ...opts,
149
+ });
150
+ if (res2.ok) {
151
+ return await res2.json();
152
+ }
153
+ }
154
+ const text = await res.text().catch(() => '');
155
+ throw new Error(`${res.status} ${text}`);
156
+ }
157
+ return await res.json();
158
+ }
159
+
160
+ // Leaflet (OpenStreetMap) helpers - loaded lazily only when needed.
161
+ let leafletPromise = null;
162
+ function loadLeaflet() {
163
+ if (leafletPromise) return leafletPromise;
164
+ leafletPromise = new Promise((resolve, reject) => {
165
+ try {
166
+ if (window.L && window.L.map) {
167
+ resolve(window.L);
168
+ return;
169
+ }
170
+ const cssId = 'onekite-leaflet-css';
171
+ const jsId = 'onekite-leaflet-js';
172
+ if (!document.getElementById(cssId)) {
173
+ const link = document.createElement('link');
174
+ link.id = cssId;
175
+ link.rel = 'stylesheet';
176
+ link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
177
+ document.head.appendChild(link);
178
+ }
179
+ const existing = document.getElementById(jsId);
180
+ if (existing) {
181
+ existing.addEventListener('load', () => resolve(window.L));
182
+ existing.addEventListener('error', () => reject(new Error('leaflet-load-failed')));
183
+ return;
184
+ }
185
+ const script = document.createElement('script');
186
+ script.id = jsId;
187
+ script.async = true;
188
+ script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
189
+ script.onload = () => resolve(window.L);
190
+ script.onerror = () => reject(new Error('leaflet-load-failed'));
191
+ document.head.appendChild(script);
192
+ } catch (e) {
193
+ reject(e);
194
+ }
195
+ });
196
+ return leafletPromise;
197
+ }
198
+
199
+ async function geocodeAddress(query) {
200
+ const q = String(query || '').trim();
201
+ if (!q) return null;
202
+ const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`;
203
+ const res = await fetch(url, {
204
+ method: 'GET',
205
+ headers: {
206
+ 'Accept': 'application/json',
207
+ 'Accept-Language': 'fr',
208
+ },
209
+ });
210
+ if (!res.ok) return null;
211
+ const arr = await res.json();
212
+ if (!Array.isArray(arr) || !arr.length) return null;
213
+ const hit = arr[0];
214
+ const lat = Number(hit.lat);
215
+ const lon = Number(hit.lon);
216
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
217
+ return { lat, lon, displayName: hit.display_name || q };
218
+ }
219
+
220
+ // Lightweight address autocomplete (OpenStreetMap Nominatim)
221
+ // Designed to work inside Bootstrap "input-group" without moving DOM nodes.
222
+ async function searchAddresses(query, limit) {
223
+ const q = String(query || '').trim();
224
+ const lim = Math.min(10, Math.max(1, Number(limit) || 6));
225
+ if (q.length < 3) return [];
226
+ const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=${lim}&q=${encodeURIComponent(q)}`;
227
+ const res = await fetch(url, {
228
+ method: 'GET',
229
+ headers: {
230
+ 'Accept': 'application/json',
231
+ 'Accept-Language': 'fr',
232
+ },
233
+ });
234
+ if (!res.ok) return [];
235
+ const arr = await res.json();
236
+ if (!Array.isArray(arr)) return [];
237
+ return arr.map((hit) => {
238
+ const lat = Number(hit.lat);
239
+ const lon = Number(hit.lon);
240
+ return {
241
+ displayName: hit.display_name || q,
242
+ lat: Number.isFinite(lat) ? lat : null,
243
+ lon: Number.isFinite(lon) ? lon : null,
244
+ };
245
+ }).filter(h => h && h.displayName);
246
+ }
247
+
248
+ function attachAddressAutocomplete(inputEl, onPick) {
249
+ if (!inputEl) return;
250
+ if (inputEl.getAttribute('data-onekite-autocomplete') === '1') return;
251
+ inputEl.setAttribute('data-onekite-autocomplete', '1');
252
+
253
+ // In Bootstrap input-groups (especially in ACP), wrapping the input breaks layout.
254
+ // So we anchor the menu to the closest input-group (or parent) without moving the input.
255
+ const anchor = inputEl.closest && inputEl.closest('.input-group')
256
+ ? inputEl.closest('.input-group')
257
+ : (inputEl.parentNode || document.body);
258
+
259
+ try {
260
+ const cs = window.getComputedStyle(anchor);
261
+ if (!cs || cs.position === 'static') {
262
+ anchor.style.position = 'relative';
263
+ }
264
+ } catch (e) {
265
+ anchor.style.position = 'relative';
266
+ }
267
+
268
+ const menu = document.createElement('div');
269
+ menu.className = 'onekite-autocomplete-menu';
270
+ menu.style.position = 'absolute';
271
+ menu.style.left = '0';
272
+ menu.style.right = '0';
273
+ menu.style.top = '100%';
274
+ menu.style.zIndex = '2000';
275
+ // Colors are handled via CSS variables (supports ACP dark mode).
276
+ menu.style.borderTop = '0';
277
+ menu.style.maxHeight = '220px';
278
+ menu.style.overflowY = 'auto';
279
+ menu.style.display = 'none';
280
+ menu.style.borderRadius = '0 0 .375rem .375rem';
281
+ anchor.appendChild(menu);
282
+
283
+ let timer = null;
284
+ let lastQuery = '';
285
+ let busy = false;
286
+
287
+ function hide() {
288
+ menu.style.display = 'none';
289
+ menu.innerHTML = '';
290
+ }
291
+
292
+ function show(hits) {
293
+ if (!hits || !hits.length) {
294
+ hide();
295
+ return;
296
+ }
297
+ menu.innerHTML = '';
298
+ hits.forEach((h) => {
299
+ const btn = document.createElement('button');
300
+ btn.type = 'button';
301
+ btn.className = 'onekite-autocomplete-item';
302
+ btn.textContent = h.displayName;
303
+ btn.style.display = 'block';
304
+ btn.style.width = '100%';
305
+ btn.style.textAlign = 'left';
306
+ btn.style.padding = '.35rem .5rem';
307
+ btn.style.border = '0';
308
+ btn.style.cursor = 'pointer';
309
+ btn.addEventListener('click', () => {
310
+ inputEl.value = h.displayName;
311
+ hide();
312
+ try { onPick && onPick(h); } catch (e) {}
313
+ });
314
+ menu.appendChild(btn);
315
+ });
316
+ menu.style.display = 'block';
317
+ }
318
+
319
+ async function run(q) {
320
+ if (busy) return;
321
+ busy = true;
322
+ try {
323
+ const hits = await searchAddresses(q, 6);
324
+ if (String(inputEl.value || '').trim() !== q) return; // ignore stale
325
+ show(hits);
326
+ } catch (e) {
327
+ hide();
328
+ } finally {
329
+ busy = false;
330
+ }
331
+ }
332
+
333
+ inputEl.addEventListener('input', () => {
334
+ const q = String(inputEl.value || '').trim();
335
+ lastQuery = q;
336
+ if (timer) clearTimeout(timer);
337
+ if (q.length < 3) {
338
+ hide();
339
+ return;
340
+ }
341
+ timer = setTimeout(() => run(lastQuery), 250);
342
+ });
343
+
344
+ inputEl.addEventListener('focus', () => {
345
+ const q = String(inputEl.value || '').trim();
346
+ if (q.length >= 3) {
347
+ if (timer) clearTimeout(timer);
348
+ timer = setTimeout(() => run(q), 150);
349
+ }
350
+ });
351
+
352
+ inputEl.addEventListener('keydown', (e) => {
353
+ if (e.key === 'Escape') hide();
354
+ });
355
+
356
+ // Close when clicking outside (once per menu)
357
+ document.addEventListener('click', (e) => {
358
+ try {
359
+ if (!anchor.contains(e.target)) hide();
360
+ } catch (err) {}
361
+ });
362
+ }
363
+
364
+ function formToObject(form) {
365
+ const out = {};
366
+ new FormData(form).forEach((v, k) => {
367
+ out[k] = String(v);
368
+ });
369
+ return out;
370
+ }
371
+
372
+ function fillForm(form, data) {
373
+ [...form.elements].forEach((el) => {
374
+ if (!el.name) return;
375
+ if (Object.prototype.hasOwnProperty.call(data, el.name)) {
376
+ el.value = data[el.name];
377
+ }
378
+ });
379
+ }
380
+
381
+ function normalizeCsvGroupsWithDefault(csv, defaultGroup) {
382
+ const extras = String(csv || '').split(',').map(s => s.trim()).filter(Boolean);
383
+ const set = new Set();
384
+ const out = [];
385
+ if (defaultGroup) {
386
+ const dg = String(defaultGroup).trim();
387
+ if (dg) {
388
+ set.add(dg);
389
+ out.push(dg);
390
+ }
391
+ }
392
+ for (const g of extras) {
393
+ if (!set.has(g)) {
394
+ set.add(g);
395
+ out.push(g);
396
+ }
397
+ }
398
+ return out.join(', ');
399
+ }
400
+
401
+ function ensureSpecialFieldsExist(form) {
402
+ // If the ACP template didn't include these fields (older installs), inject them.
403
+ if (!form) return;
404
+ const hasCreator = form.querySelector('[name="specialCreatorGroups"]');
405
+ const hasDeleter = form.querySelector('[name="specialDeleterGroups"]');
406
+ if (hasCreator && hasDeleter) return;
407
+ const wrap = document.createElement('div');
408
+ wrap.innerHTML = `
409
+ <hr />
410
+ <h4>Évènements (autre couleur)</h4>
411
+ <p class="text-muted" style="max-width: 900px;">Permet de créer des évènements non liés aux locations (autre couleur), avec date/heure, adresse (OpenStreetMap) et notes.</p>
412
+ <div class="mb-3">
413
+ <label class="form-label">Groupes autorisés à créer ces évènements (CSV)</label>
414
+ <input type="text" class="form-control" name="specialCreatorGroups" placeholder="ex: staff, instructors" />
415
+ </div>
416
+ <div class="mb-3">
417
+ <label class="form-label">Groupes autorisés à supprimer ces évènements (CSV)</label>
418
+ <input type="text" class="form-control" name="specialDeleterGroups" placeholder="ex: administrators" />
419
+ </div>
420
+ `;
421
+ form.appendChild(wrap);
422
+ }
423
+
424
+
425
+ function renderPending(list) {
426
+ const wrap = document.getElementById('onekite-pending');
427
+ if (!wrap) return;
428
+ wrap.innerHTML = '';
429
+
430
+ pendingCache.clear();
431
+
432
+ if (!list || !list.length) {
433
+ wrap.innerHTML = '<div class="text-muted">Aucune demande.</div>';
434
+ return;
435
+ }
436
+
437
+ // Batch actions (low volume but handy on mobile / multiple requests)
438
+ const batchBar = document.createElement('div');
439
+ batchBar.className = 'd-flex flex-wrap gap-2 align-items-center mb-2';
440
+ batchBar.innerHTML = `
441
+ <button type="button" class="btn btn-outline-secondary btn-sm" data-action="toggle-all">Tout sélectionner</button>
442
+ <button type="button" class="btn btn-success btn-sm" data-action="approve-selected">Valider sélection</button>
443
+ <button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse-selected">Refuser sélection</button>
444
+ <span class="text-muted" style="font-size:12px;">(<span id="onekite-selected-count">0</span> sélectionnée)</span>
445
+ `;
446
+ wrap.appendChild(batchBar);
447
+
448
+ const fmtFR = (ts) => {
449
+ const d = new Date(parseInt(ts, 10));
450
+ const dd = String(d.getDate()).padStart(2, '0');
451
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
452
+ const yyyy = d.getFullYear();
453
+ const hh = String(d.getHours()).padStart(2, '0');
454
+ const mi = String(d.getMinutes()).padStart(2, '0');
455
+ return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
456
+ };
457
+
458
+ for (const r of list) {
459
+ if (r && r.rid) pendingCache.set(String(r.rid), r);
460
+ const created = r.createdAt ? fmtFR(r.createdAt) : '';
461
+ const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
462
+ const itemsHtml = `<ul style="margin: 0 0 10px 18px;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
463
+ const div = document.createElement('div');
464
+ div.className = 'list-group-item onekite-pending-row';
465
+ div.innerHTML = `
466
+ <div class="d-flex justify-content-between align-items-start gap-2">
467
+ <div style="min-width: 0;" class="d-flex gap-2">
468
+ <div style="padding-top: 2px;">
469
+ <input type="checkbox" class="form-check-input onekite-pending-select" data-rid="${escapeHtml(String(r.rid || ''))}" />
470
+ </div>
471
+ <div style="min-width:0;">
472
+ <div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong></div>
473
+ <div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
474
+ <div class="text-muted" style="font-size: 12px;">Période: ${escapeHtml(new Date(parseInt(r.start, 10)).toLocaleDateString('fr-FR'))} → ${escapeHtml(new Date(parseInt(r.end, 10)).toLocaleDateString('fr-FR'))}</div>
475
+ </div>
476
+ </div>
477
+ <div class="d-flex gap-2">
478
+ <!-- IMPORTANT: type="button" to avoid submitting the settings form and resetting ACP tabs -->
479
+ <button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse" data-rid="${escapeHtml(String(r.rid || ''))}">Refuser</button>
480
+ <button type="button" class="btn btn-success btn-sm" data-action="approve" data-rid="${escapeHtml(String(r.rid || ''))}">Valider</button>
481
+ </div>
482
+ </div>
483
+ `;
484
+ wrap.appendChild(div);
485
+ }
486
+
487
+ // Selected counter
488
+ try {
489
+ const countEl = document.getElementById('onekite-selected-count');
490
+ const refreshCount = () => {
491
+ const n = wrap.querySelectorAll('.onekite-pending-select:checked').length;
492
+ if (countEl) countEl.textContent = String(n);
493
+ };
494
+ wrap.querySelectorAll('.onekite-pending-select').forEach((cb) => cb.addEventListener('change', refreshCount));
495
+ refreshCount();
496
+ } catch (e) {}
497
+ }
498
+
499
+
500
+ function timeOptions(stepMinutes) {
501
+ const step = stepMinutes || 5;
502
+ const out = [];
503
+ for (let h = 7; h < 24; h++) {
504
+ for (let m = 0; m < 60; m += step) {
505
+ const hh = String(h).padStart(2, '0');
506
+ const mm = String(m).padStart(2, '0');
507
+ out.push(`${hh}:${mm}`);
508
+ }
509
+ }
510
+ return out;
511
+ }
512
+
513
+ function escapeHtml(s) {
514
+ return String(s || '')
515
+ .replace(/&/g, '&amp;')
516
+ .replace(/</g, '&lt;')
517
+ .replace(/>/g, '&gt;')
518
+ .replace(/"/g, '&quot;')
519
+ .replace(/'/g, '&#39;');
520
+ }
521
+
522
+ async function loadSettings() {
523
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/settings');
524
+ }
525
+
526
+ async function saveSettings(payload) {
527
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/settings', {
528
+ method: 'PUT',
529
+ body: JSON.stringify(payload),
530
+ });
531
+ }
532
+
533
+ async function loadPending() {
534
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/pending');
535
+ }
536
+
537
+ async function approve(rid, payload) {
538
+ return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${rid}/approve`, {
539
+ method: 'PUT',
540
+ body: JSON.stringify(payload || {}),
541
+ });
542
+ }
543
+
544
+ async function refuse(rid, payload) {
545
+ return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${rid}/refuse`, {
546
+ method: 'PUT',
547
+ body: JSON.stringify(payload || {}),
548
+ });
549
+ }
550
+
551
+ async function purge(year) {
552
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/purge', {
553
+ method: 'POST',
554
+ body: JSON.stringify({ year }),
555
+ });
556
+ }
557
+
558
+ async function purgeSpecialEvents(year) {
559
+ try {
560
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/special-events/purge', {
561
+ method: 'POST',
562
+ body: JSON.stringify({ year }),
563
+ });
564
+ } catch (e) {
565
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/special-events/purge', {
566
+ method: 'POST',
567
+ body: JSON.stringify({ year }),
568
+ });
569
+ }
570
+ }
571
+
572
+ async function debugHelloAsso() {
573
+ try {
574
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
575
+ } catch (e) {
576
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
577
+ }
578
+ }
579
+
580
+ async function loadAccounting(from, to) {
581
+ const params = new URLSearchParams();
582
+ if (from) params.set('from', from);
583
+ if (to) params.set('to', to);
584
+ const qs = params.toString();
585
+ return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/accounting${qs ? `?${qs}` : ''}`);
586
+ }
587
+
588
+ async function init() {
589
+ const form = document.getElementById('onekite-settings-form');
590
+ if (!form) return;
591
+
592
+ // Make the HelloAsso debug output readable in both light and dark ACP themes.
593
+ // NodeBB 4.x uses Bootstrap variables, so we can rely on CSS variables here.
594
+ (function injectAdminCss() {
595
+ const id = 'onekite-admin-css';
596
+ if (document.getElementById(id)) return;
597
+ const style = document.createElement('style');
598
+ style.id = id;
599
+ style.textContent = `
600
+ #onekite-debug-output.onekite-debug-output {
601
+ background: var(--bs-body-bg) !important;
602
+ color: var(--bs-body-color) !important;
603
+ border: 1px solid var(--bs-border-color) !important;
604
+ }
605
+ `;
606
+ document.head.appendChild(style);
607
+ })();
608
+
609
+ // Load settings
610
+ try {
611
+ const s = await loadSettings();
612
+ fillForm(form, s || {});
613
+
614
+ // Ensure default creator group prefix appears in the ACP field
615
+ const y = new Date().getFullYear();
616
+ const defaultGroup = `onekite-ffvl-${y}`;
617
+ const cgEl = form.querySelector('[name="creatorGroups"]');
618
+ if (cgEl) {
619
+ cgEl.value = normalizeCsvGroupsWithDefault(cgEl.value, defaultGroup);
620
+ }
621
+ } catch (e) {
622
+ showAlert('error', 'Impossible de charger les paramètres.');
623
+ }
624
+
625
+ // Load pending
626
+ async function refreshPending() {
627
+ try {
628
+ const p = await loadPending();
629
+ renderPending(p);
630
+ } catch (e) {
631
+ showAlert('error', 'Impossible de charger les demandes en attente.');
632
+ }
633
+ }
634
+
635
+ await refreshPending();
636
+
637
+ async function doSave(ev) {
638
+ // Guard against duplicate handlers (some themes bind multiple save buttons)
639
+ // and against rapid double-clicks.
640
+ if (doSave._inFlight) {
641
+ if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
642
+ return;
643
+ }
644
+ doSave._inFlight = true;
645
+ try {
646
+ if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
647
+ const payload = formToObject(form);
648
+ // Always prefix with default yearly group
649
+ const y = new Date().getFullYear();
650
+ const defaultGroup = `onekite-ffvl-${y}`;
651
+ if (Object.prototype.hasOwnProperty.call(payload, 'creatorGroups')) {
652
+ payload.creatorGroups = normalizeCsvGroupsWithDefault(payload.creatorGroups, defaultGroup);
653
+ }
654
+ await saveSettings(payload);
655
+ showAlert('success', 'Paramètres enregistrés.');
656
+ } catch (e) {
657
+ showAlert('error', 'Échec de l\'enregistrement.');
658
+ } finally {
659
+ doSave._inFlight = false;
660
+ }
661
+ }
662
+
663
+ // Expose the latest save handler so the global delegated listener (bound once)
664
+ // can always call the current instance tied to the current form.
665
+ window.oneKiteCalendarAdminDoSave = doSave;
666
+
667
+ // Save buttons (NodeBB header/footer "Enregistrer" + floppy icon)
668
+ // Bind a SINGLE delegated listener for the entire admin session.
669
+ const SAVE_SELECTOR = '#save, .save, [data-action="save"], .settings-save, .floating-save, .btn[data-action="save"]';
670
+ if (!window.oneKiteCalendarAdminBound) {
671
+ window.oneKiteCalendarAdminBound = true;
672
+ document.addEventListener('click', (ev) => {
673
+ const btn = ev.target && ev.target.closest && ev.target.closest(SAVE_SELECTOR);
674
+ if (!btn) return;
675
+ // Only handle clicks while we're on this plugin page
676
+ if (!document.getElementById('onekite-settings-form')) return;
677
+ const fn = window.oneKiteCalendarAdminDoSave;
678
+ if (typeof fn === 'function') fn(ev);
679
+ });
680
+ }
681
+
682
+ // Approve/refuse buttons
683
+ const pendingWrap = document.getElementById('onekite-pending');
684
+ if (pendingWrap && !pendingWrap.dataset.okcBound) {
685
+ pendingWrap.dataset.okcBound = '1';
686
+
687
+ function selectedRids() {
688
+ return Array.from(pendingWrap.querySelectorAll('input.onekite-pending-select:checked'))
689
+ .map(cb => cb.getAttribute('data-rid'))
690
+ .filter(Boolean);
691
+ }
692
+
693
+ function refreshSelectedCount() {
694
+ const el = document.getElementById('onekite-selected-count');
695
+ if (el) el.textContent = String(selectedRids().length);
696
+ }
697
+
698
+ pendingWrap.addEventListener('change', (ev) => {
699
+ const cb = ev.target && ev.target.closest ? ev.target.closest('input.onekite-pending-select') : null;
700
+ if (!cb) return;
701
+ refreshSelectedCount();
702
+ });
703
+
704
+ pendingWrap.addEventListener('click', async (ev) => {
705
+ const btn = ev.target && ev.target.closest('button[data-action]');
706
+ if (!btn) return;
707
+ // Prevent the settings form from submitting (default <button> behavior)
708
+ // and avoid triggering NodeBB ACP tab navigation side-effects.
709
+ try {
710
+ ev.preventDefault();
711
+ ev.stopPropagation();
712
+ } catch (e) {}
713
+ const action = btn.getAttribute('data-action');
714
+ const rid = btn.getAttribute('data-rid');
715
+
716
+ // Prevent accidental double-open of modals on mobile/trackpad double taps
717
+ if (rid && (action === 'approve' || action === 'refuse')) {
718
+ const openKey = `open:${action}:${rid}`;
719
+ if (actionLocks.has(openKey)) return;
720
+ actionLocks.add(openKey);
721
+ setTimeout(() => actionLocks.delete(openKey), 800);
722
+ }
723
+
724
+ // Batch actions (no rid)
725
+ if ((action === 'toggle-all' || action === 'approve-selected' || action === 'refuse-selected') && !rid) {
726
+ const batchBtns = Array.from(pendingWrap.querySelectorAll('button[data-action="toggle-all"], button[data-action="approve-selected"], button[data-action="refuse-selected"]'));
727
+ const allCbs = Array.from(pendingWrap.querySelectorAll('input.onekite-pending-select'));
728
+ if (action === 'toggle-all') {
729
+ const want = allCbs.some(cb => !cb.checked);
730
+ allCbs.forEach(cb => { cb.checked = want; });
731
+ refreshSelectedCount();
732
+ btn.textContent = want ? 'Tout désélectionner' : 'Tout sélectionner';
733
+ return;
734
+ }
735
+
736
+ const rids = selectedRids();
737
+ if (!rids.length) {
738
+ showAlert('error', 'Aucune demande sélectionnée.');
739
+ return;
740
+ }
741
+
742
+ if (action === 'refuse-selected') {
743
+ const html = `
744
+ <div class="mb-3">
745
+ <label class="form-label">Raison du refus (appliquée à toutes les demandes sélectionnées)</label>
746
+ <textarea class="form-control" id="onekite-refuse-reason" rows="3" placeholder="Ex: matériel indisponible, dates impossibles, dossier incomplet..."></textarea>
747
+ </div>
748
+ `;
749
+ await new Promise((resolve) => {
750
+ bootbox.dialog({
751
+ title: `Refuser ${rids.length} demande(s)`,
752
+ message: html,
753
+ buttons: {
754
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(false) },
755
+ ok: {
756
+ label: 'Refuser',
757
+ className: 'btn-danger',
758
+ callback: async () => {
759
+ await withLock(`batch:refuse`, batchBtns.concat(getActiveBootboxFooterButtons()), async () => {
760
+ try {
761
+ const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
762
+ for (const rr of rids) {
763
+ await refuse(rr, { reason });
764
+ }
765
+ showAlert('success', `${rids.length} demande(s) refusée(s).`);
766
+ resolve(true);
767
+ } catch (e) {
768
+ showAlert('error', 'Refus impossible.');
769
+ resolve(false);
770
+ }
771
+ });
772
+ return false;
773
+ },
774
+ },
775
+ },
776
+ });
777
+ });
778
+ await refreshPending();
779
+ return;
780
+ }
781
+
782
+ if (action === 'approve-selected') {
783
+ const opts = timeOptions(5).map(t => `<option value="${t}" ${t === '07:00' ? 'selected' : ''}>${t}</option>`).join('');
784
+ const html = `
785
+ <div class="mb-3">
786
+ <label class="form-label">Adresse de récupération</label>
787
+ <div class="input-group">
788
+ <input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
789
+ <button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
790
+ </div>
791
+ <div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
792
+ <div class="form-text" id="onekite-map-help">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
793
+ <input type="hidden" id="onekite-pickup-lat" />
794
+ <input type="hidden" id="onekite-pickup-lon" />
795
+ </div>
796
+ <div class="mb-3">
797
+ <label class="form-label">Notes (facultatif)</label>
798
+ <textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
799
+ </div>
800
+ <div class="mb-2">
801
+ <label class="form-label">Heure de récupération</label>
802
+ <select class="form-select" id="onekite-pickup-time">${opts}</select>
803
+ </div>
804
+ <div class="text-muted" style="font-size:12px;">Ces infos seront appliquées aux ${rids.length} demandes sélectionnées.</div>
805
+ `;
806
+ const dlg = bootbox.dialog({
807
+ title: `Valider ${rids.length} demande(s)` ,
808
+ message: html,
809
+ buttons: {
810
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
811
+ ok: {
812
+ label: 'Valider',
813
+ className: 'btn-success',
814
+ callback: async () => {
815
+ await withLock(`batch:approve`, batchBtns.concat(getActiveBootboxFooterButtons()), async () => {
816
+ try {
817
+ const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
818
+ const notes = (document.getElementById('onekite-notes')?.value || '').trim();
819
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
820
+ const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
821
+ const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
822
+ for (const rr of rids) {
823
+ await approve(rr, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
824
+ }
825
+ showAlert('success', `${rids.length} demande(s) validée(s).`);
826
+ await refreshPending();
827
+ } catch (e) {
828
+ showAlert('error', 'Validation impossible.');
829
+ }
830
+ });
831
+ },
832
+ },
833
+ },
834
+ });
835
+
836
+ // Init Leaflet map once the modal is visible.
837
+ dlg.on('shown.bs.modal', async () => {
838
+ try {
839
+ const L = await loadLeaflet();
840
+ const mapEl = document.getElementById('onekite-map');
841
+ if (!mapEl) return;
842
+ const map = L.map(mapEl).setView([45.7640, 4.8357], 12);
843
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
844
+ maxZoom: 19,
845
+ attribution: '&copy; OpenStreetMap contributors',
846
+ }).addTo(map);
847
+ const marker = L.marker([45.7640, 4.8357], { draggable: true }).addTo(map);
848
+
849
+ function setLatLon(lat, lon) {
850
+ const latEl = document.getElementById('onekite-pickup-lat');
851
+ const lonEl = document.getElementById('onekite-pickup-lon');
852
+ if (latEl) latEl.value = String(lat);
853
+ if (lonEl) lonEl.value = String(lon);
854
+ }
855
+ setLatLon(45.7640, 4.8357);
856
+
857
+ marker.on('dragend', () => {
858
+ const p = marker.getLatLng();
859
+ setLatLon(p.lat, p.lng);
860
+ });
861
+
862
+ const btnGeocode = document.getElementById('onekite-geocode');
863
+ if (btnGeocode) {
864
+ btnGeocode.addEventListener('click', async () => {
865
+ try {
866
+ const addr = (document.getElementById('onekite-pickup-address')?.value || '').trim();
867
+ const hit = await geocodeAddress(addr);
868
+ if (!hit) return;
869
+ marker.setLatLng([hit.lat, hit.lon]);
870
+ map.setView([hit.lat, hit.lon], 16);
871
+ setLatLon(hit.lat, hit.lon);
872
+ } catch (e) {}
873
+ });
874
+ }
875
+
876
+ // Autocomplete like on the calendar validation modal
877
+ const addrInput = document.getElementById('onekite-pickup-address');
878
+ attachAddressAutocomplete(addrInput, (h) => {
879
+ try {
880
+ if (!h || !Number.isFinite(Number(h.lat)) || !Number.isFinite(Number(h.lon))) return;
881
+ const lat = Number(h.lat);
882
+ const lon = Number(h.lon);
883
+ marker.setLatLng([lat, lon]);
884
+ map.setView([lat, lon], 16);
885
+ setLatLon(lat, lon);
886
+ } catch (e) {}
887
+ });
888
+ setTimeout(() => { try { map.invalidateSize(); } catch (e) {} }, 250);
889
+ } catch (e) {}
890
+ });
891
+ return;
892
+ }
893
+ }
894
+
895
+ if (!rid) return;
896
+
897
+ // Remove the row immediately on success for a snappier UX
898
+ const rowEl = btn.closest('tr') || btn.closest('.onekite-pending-row');
899
+ const rowBtns = rowEl ? Array.from(rowEl.querySelectorAll('button[data-action="approve"],button[data-action="refuse"]')) : [btn];
900
+
901
+ try {
902
+ if (action === 'refuse') {
903
+ const html = `
904
+ <div class="mb-3">
905
+ <label class="form-label">Raison du refus</label>
906
+ <textarea class="form-control" id="onekite-refuse-reason" rows="3" placeholder="Ex: matériel indisponible, dates impossibles, dossier incomplet..."></textarea>
907
+ </div>
908
+ `;
909
+ const ok = await new Promise((resolve) => {
910
+ bootbox.dialog({
911
+ title: 'Refuser la réservation',
912
+ message: html,
913
+ buttons: {
914
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(false) },
915
+ ok: {
916
+ label: 'Refuser',
917
+ className: 'btn-danger',
918
+ callback: async () => {
919
+ await withLock(`refuse:${rid}`, rowBtns.concat(getActiveBootboxFooterButtons()), async () => {
920
+ try {
921
+ const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
922
+ await refuse(rid, { reason });
923
+ if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
924
+ showAlert('success', 'Demande refusée.');
925
+ resolve(true);
926
+ } catch (e) {
927
+ showAlert('error', 'Refus impossible.');
928
+ resolve(false);
929
+ }
930
+ });
931
+ return false;
932
+ },
933
+ },
934
+ },
935
+ });
936
+ });
937
+ if (ok) {
938
+ await refreshPending();
939
+ }
940
+ return;
941
+ }
942
+
943
+ if (action === 'approve') {
944
+ const r = pendingCache.get(String(rid)) || {};
945
+ const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
946
+ ? r.itemNames
947
+ : (typeof r.itemNames === 'string' && r.itemNames.trim()
948
+ ? r.itemNames.split(',').map(s => s.trim()).filter(Boolean)
949
+ : ([r.itemName || r.itemId].filter(Boolean)));
950
+ const itemsListHtml = itemNames.length
951
+ ? `<div class="mb-2"><strong>Matériel</strong><ul style="margin:0.25rem 0 0 1.1rem; padding:0;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul></div>`
952
+ : '';
953
+ const opts = timeOptions(5).map(t => `<option value="${t}" ${t === '07:00' ? 'selected' : ''}>${t}</option>`).join('');
954
+
955
+ const html = `
956
+ ${itemsListHtml}
957
+ <div class="mb-3">
958
+ <label class="form-label">Adresse de récupération</label>
959
+ <div class="input-group">
960
+ <input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
961
+ <button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
962
+ </div>
963
+ <div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
964
+ <div class="form-text" id="onekite-map-help">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
965
+ <input type="hidden" id="onekite-pickup-lat" />
966
+ <input type="hidden" id="onekite-pickup-lon" />
967
+ </div>
968
+ <div class="mb-3">
969
+ <label class="form-label">Notes (facultatif)</label>
970
+ <textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
971
+ </div>
972
+ <div class="mb-2">
973
+ <label class="form-label">Heure de récupération</label>
974
+ <select class="form-select" id="onekite-pickup-time">${opts}</select>
975
+ </div>
976
+ `;
977
+
978
+ const dlg = bootbox.dialog({
979
+ title: 'Valider la demande',
980
+ message: html,
981
+ buttons: {
982
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
983
+ ok: {
984
+ label: 'Valider',
985
+ className: 'btn-success',
986
+ callback: async () => {
987
+ await withLock(`approve:${rid}`, rowBtns.concat(getActiveBootboxFooterButtons()), async () => {
988
+ try {
989
+ const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
990
+ const notes = (document.getElementById('onekite-notes')?.value || '').trim();
991
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
992
+ const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
993
+ const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
994
+ await approve(rid, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
995
+ if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
996
+ showAlert('success', 'Demande validée.');
997
+ await refreshPending();
998
+ } catch (e) {
999
+ showAlert('error', 'Validation impossible.');
1000
+ }
1001
+ });
1002
+ return false;
1003
+ },
1004
+ },
1005
+ },
1006
+ });
1007
+
1008
+ // Init Leaflet map once the modal is visible.
1009
+ dlg.on('shown.bs.modal', async () => {
1010
+ try {
1011
+ const L = await loadLeaflet();
1012
+ const mapEl = document.getElementById('onekite-map');
1013
+ if (!mapEl) return;
1014
+
1015
+ const map = L.map(mapEl, { scrollWheelZoom: false });
1016
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
1017
+ maxZoom: 19,
1018
+ attribution: '&copy; OpenStreetMap',
1019
+ }).addTo(map);
1020
+
1021
+ // Default view (France-ish)
1022
+ map.setView([46.7, 2.5], 5);
1023
+
1024
+ let marker = null;
1025
+ function setMarker(lat, lon, zoom) {
1026
+ const ll = [lat, lon];
1027
+ if (!marker) {
1028
+ marker = L.marker(ll, { draggable: true }).addTo(map);
1029
+ marker.on('dragend', () => {
1030
+ const p2 = marker.getLatLng();
1031
+ document.getElementById('onekite-pickup-lat').value = String(p2.lat);
1032
+ document.getElementById('onekite-pickup-lon').value = String(p2.lng);
1033
+ });
1034
+ } else {
1035
+ marker.setLatLng(ll);
1036
+ }
1037
+ document.getElementById('onekite-pickup-lat').value = String(lat);
1038
+ document.getElementById('onekite-pickup-lon').value = String(lon);
1039
+ if (zoom) {
1040
+ map.setView(ll, zoom);
1041
+ } else {
1042
+ map.panTo(ll);
1043
+ }
1044
+ }
1045
+
1046
+ map.on('click', (e) => {
1047
+ if (e && e.latlng) {
1048
+ setMarker(e.latlng.lat, e.latlng.lng, map.getZoom());
1049
+ }
1050
+ });
1051
+
1052
+ const geocodeBtn = document.getElementById('onekite-geocode');
1053
+ const addrInput = document.getElementById('onekite-pickup-address');
1054
+ async function runGeocode() {
1055
+ try {
1056
+ const addr = (addrInput?.value || '').trim();
1057
+ if (!addr) return;
1058
+ const hit = await geocodeAddress(addr);
1059
+ if (!hit) {
1060
+ showAlert('error', 'Adresse introuvable.');
1061
+ return;
1062
+ }
1063
+ setMarker(hit.lat, hit.lon, 16);
1064
+ } catch (e) {
1065
+ showAlert('error', 'Recherche adresse impossible.');
1066
+ }
1067
+ }
1068
+ if (geocodeBtn) geocodeBtn.addEventListener('click', runGeocode);
1069
+ if (addrInput) {
1070
+ addrInput.addEventListener('keydown', (e) => {
1071
+ if (e.key === 'Enter') {
1072
+ e.preventDefault();
1073
+ runGeocode();
1074
+ }
1075
+ });
1076
+ }
1077
+
1078
+ // Autocomplete like on the calendar validation modal
1079
+ attachAddressAutocomplete(addrInput, (h) => {
1080
+ try {
1081
+ if (!h || !Number.isFinite(Number(h.lat)) || !Number.isFinite(Number(h.lon))) return;
1082
+ setMarker(Number(h.lat), Number(h.lon), 16);
1083
+ } catch (e) {}
1084
+ });
1085
+ } catch (e) {
1086
+ // ignore
1087
+ }
1088
+ });
1089
+ }
1090
+ } catch (e) {
1091
+ showAlert('error', 'Action impossible.');
1092
+ }
1093
+ });
1094
+ }
1095
+
1096
+ // Purge
1097
+ const purgeBtn = document.getElementById('onekite-purge');
1098
+ if (purgeBtn) {
1099
+ purgeBtn.addEventListener('click', async () => {
1100
+ const yearInput = document.getElementById('onekite-purge-year');
1101
+ const year = (yearInput ? yearInput.value : '').trim();
1102
+ if (!/^\d{4}$/.test(year)) {
1103
+ showAlert('error', 'Année invalide (YYYY)');
1104
+ return;
1105
+ }
1106
+ bootbox.confirm(`Purger toutes les réservations de ${year} ?`, async (ok) => {
1107
+ if (!ok) return;
1108
+ try {
1109
+ const r = await purge(year);
1110
+ showAlert('success', `Purge OK (${r.removed || 0} supprimées).`);
1111
+ await refreshPending();
1112
+ } catch (e) {
1113
+ showAlert('error', 'Purge impossible.');
1114
+ }
1115
+ });
1116
+ });
1117
+ }
1118
+
1119
+ // Purge special events by year
1120
+ const sePurgeBtn = document.getElementById('onekite-se-purge');
1121
+ if (sePurgeBtn) {
1122
+ sePurgeBtn.addEventListener('click', async () => {
1123
+ const yearInput = document.getElementById('onekite-se-purge-year');
1124
+ const year = (yearInput ? yearInput.value : '').trim();
1125
+ if (!/^\d{4}$/.test(year)) {
1126
+ showAlert('error', 'Année invalide (YYYY)');
1127
+ return;
1128
+ }
1129
+ bootbox.confirm(`Purger tous les évènements de ${year} ?`, async (ok) => {
1130
+ if (!ok) return;
1131
+ try {
1132
+ const r = await purgeSpecialEvents(year);
1133
+ showAlert('success', `Purge OK (${r.removed || 0} supprimé(s)).`);
1134
+ } catch (e) {
1135
+ showAlert('error', 'Purge impossible.');
1136
+ }
1137
+ });
1138
+ });
1139
+ }
1140
+
1141
+ // Debug
1142
+ const debugBtn = document.getElementById('onekite-debug-run');
1143
+ if (debugBtn) {
1144
+ debugBtn.addEventListener('click', async () => {
1145
+ const out = document.getElementById('onekite-debug-output');
1146
+ if (out) out.textContent = 'Chargement...';
1147
+ try {
1148
+ const result = await debugHelloAsso();
1149
+ if (out) out.textContent = JSON.stringify(result, null, 2);
1150
+ const catalogCount = result && result.catalog ? parseInt(result.catalog.count, 10) || 0 : 0;
1151
+ const catalogOk = !!(result && result.catalog && result.catalog.ok);
1152
+ // Accept "count > 0" even if ok flag is false (some proxies can strip fields, etc.)
1153
+ if (catalogOk || catalogCount > 0) {
1154
+ showAlert('success', `Catalogue HelloAsso: ${catalogCount} item(s)`);
1155
+ } else {
1156
+ showAlert('error', 'HelloAsso: impossible de récupérer le catalogue.');
1157
+ }
1158
+ } catch (e) {
1159
+ if (out) out.textContent = String(e && e.message ? e.message : e);
1160
+ showAlert('error', 'Debug impossible.');
1161
+ }
1162
+ });
1163
+ }
1164
+
1165
+ // Accounting (paid reservations)
1166
+ const accFrom = document.getElementById('onekite-acc-from');
1167
+ const accTo = document.getElementById('onekite-acc-to');
1168
+ const accRefresh = document.getElementById('onekite-acc-refresh');
1169
+ const accExport = document.getElementById('onekite-acc-export');
1170
+ const accPurge = document.getElementById('onekite-acc-purge');
1171
+ const accSummary = document.querySelector('#onekite-acc-summary tbody');
1172
+ const accRows = document.querySelector('#onekite-acc-rows tbody');
1173
+ const accFreeRows = document.querySelector('#onekite-acc-free-rows tbody');
1174
+
1175
+ function ymd(d) {
1176
+ const yyyy = d.getUTCFullYear();
1177
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
1178
+ const dd = String(d.getUTCDate()).padStart(2, '0');
1179
+ return `${yyyy}-${mm}-${dd}`;
1180
+ }
1181
+ if (accFrom && accTo) {
1182
+ const now = new Date();
1183
+ const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
1184
+ const from = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1));
1185
+ if (!accFrom.value) accFrom.value = ymd(from);
1186
+ if (!accTo.value) accTo.value = ymd(to);
1187
+ }
1188
+
1189
+ function renderAccounting(payload) {
1190
+ if (accSummary) accSummary.innerHTML = '';
1191
+ if (accRows) accRows.innerHTML = '';
1192
+ if (accFreeRows) accFreeRows.innerHTML = '';
1193
+ if (!payload || !payload.ok) {
1194
+ return;
1195
+ }
1196
+
1197
+ (payload.summary || []).forEach((s) => {
1198
+ const tr = document.createElement('tr');
1199
+ if (s.isFree) {
1200
+ tr.innerHTML = `<td><em>${escapeHtml(s.item)}</em></td><td>${escapeHtml(String(s.count || 0))}</td><td>-</td>`;
1201
+ } else {
1202
+ tr.innerHTML = `<td>${escapeHtml(s.item)}</td><td>${escapeHtml(String(s.count || 0))}</td><td>${escapeHtml((Number(s.total) || 0).toFixed(2))}</td>`;
1203
+ }
1204
+ accSummary && accSummary.appendChild(tr);
1205
+ });
1206
+
1207
+ (payload.rows || []).forEach((r) => {
1208
+ const tr = document.createElement('tr');
1209
+ const user = r.username ? `<a href="/user/${encodeURIComponent(r.username)}" target="_blank">${escapeHtml(r.username)}</a>${r.isFree ? ' <em>(gratuit)</em>' : ''}` : (r.isFree ? '<em>(gratuit)</em>' : '');
1210
+ const items = Array.isArray(r.items) ? r.items.map((x) => escapeHtml(x)).join('<br>') : '';
1211
+ const totalCell = r.isFree ? '-' : escapeHtml((Number(r.total) || 0).toFixed(2));
1212
+ if (r.isFree) {
1213
+ tr.innerHTML = `<td>${escapeHtml(r.startDate)} → ${escapeHtml(r.endDate)}</td><td>${user}</td><td>${items}</td><td><code>${escapeHtml(r.rid)}</code></td>`;
1214
+ accFreeRows && accFreeRows.appendChild(tr);
1215
+ } else {
1216
+ tr.innerHTML = `<td>${escapeHtml(r.startDate)} → ${escapeHtml(r.endDate)}</td><td>${user}</td><td>${items}</td><td>${totalCell}</td><td><code>${escapeHtml(r.rid)}</code></td>`;
1217
+ accRows && accRows.appendChild(tr);
1218
+ }
1219
+ });
1220
+ }
1221
+
1222
+ async function refreshAccounting() {
1223
+ if (!accRefresh) return;
1224
+ try {
1225
+ const from = accFrom ? accFrom.value : '';
1226
+ const to = accTo ? accTo.value : '';
1227
+ accRefresh.disabled = true;
1228
+ const payload = await loadAccounting(from, to);
1229
+ renderAccounting(payload);
1230
+ } catch (e) {
1231
+ showAlert('error', 'Impossible de charger la comptabilisation.');
1232
+ } finally {
1233
+ accRefresh.disabled = false;
1234
+ }
1235
+ }
1236
+
1237
+ if (accRefresh) {
1238
+ accRefresh.addEventListener('click', refreshAccounting);
1239
+ // Load once on init
1240
+ refreshAccounting();
1241
+ }
1242
+ if (accExport) {
1243
+ accExport.addEventListener('click', () => {
1244
+ const params = new URLSearchParams();
1245
+ if (accFrom && accFrom.value) params.set('from', accFrom.value);
1246
+ if (accTo && accTo.value) params.set('to', accTo.value);
1247
+ const qs = params.toString();
1248
+ const url = `/api/v3/admin/plugins/calendar-onekite/accounting.csv${qs ? `?${qs}` : ''}`;
1249
+ window.open(url, '_blank');
1250
+ });
1251
+
1252
+ if (accPurge) {
1253
+ accPurge.addEventListener('click', async () => {
1254
+ const ok = window.confirm('Purger la comptabilité pour la période sélectionnée ?\nCela masquera ces réservations dans l\'onglet Comptabilisation (sans modifier leur statut payé).');
1255
+ if (!ok) return;
1256
+ try {
1257
+ const params = new URLSearchParams();
1258
+ if (accFrom && accFrom.value) params.set('from', accFrom.value);
1259
+ if (accTo && accTo.value) params.set('to', accTo.value);
1260
+ const qs = params.toString();
1261
+ const url = `/api/v3/admin/plugins/calendar-onekite/accounting/purge${qs ? `?${qs}` : ''}`;
1262
+ const res = await fetchJson(url, { method: 'POST' });
1263
+ if (res && res.ok) {
1264
+ showAlert('success', `Compta purgée : ${res.purged || 0} réservation(s).`);
1265
+ // Refresh accounting tables after purge
1266
+ try {
1267
+ const from = accFrom ? accFrom.value : '';
1268
+ const to = accTo ? accTo.value : '';
1269
+ const data = await loadAccounting(from, to);
1270
+ renderAccounting(data);
1271
+ } catch (e) {
1272
+ // If refresh fails, keep the success message and show a soft warning
1273
+ showAlert('error', 'Compta purgée, mais rafraîchissement impossible.');
1274
+ }
1275
+ } else {
1276
+ showAlert('error', 'Purge impossible.');
1277
+ }
1278
+ } catch (e) {
1279
+ showAlert('error', 'Purge impossible.');
1280
+ }
1281
+ });
1282
+ }
1283
+
1284
+ }
1285
+
1286
+ // --------------------
1287
+ // Maintenance (simple ON/OFF)
1288
+ // --------------------
1289
+ const maintSearch = document.getElementById('onekite-maint-search');
1290
+ const maintRefresh = document.getElementById('onekite-maint-refresh');
1291
+ const maintAllOn = document.getElementById('onekite-maint-all-on');
1292
+ const maintAllOff = document.getElementById('onekite-maint-all-off');
1293
+ const maintTableBody = document.querySelector('#onekite-maint-table tbody');
1294
+
1295
+ let maintItemsCache = [];
1296
+ async function loadItemsWithMaintenance() {
1297
+ // Public items endpoint returns catalog + maintenance flag
1298
+ return await fetchJson('/api/v3/plugins/calendar-onekite/items');
1299
+ }
1300
+
1301
+ function renderMaintenanceTable(items) {
1302
+ if (!maintTableBody) return;
1303
+ const q = maintSearch ? String(maintSearch.value || '').trim().toLowerCase() : '';
1304
+ const filtered = (items || []).filter((it) => {
1305
+ if (!it) return false;
1306
+ if (!q) return true;
1307
+ return String(it.name || '').toLowerCase().includes(q);
1308
+ });
1309
+ maintTableBody.innerHTML = filtered.map((it) => {
1310
+ const id = String(it.id);
1311
+ const name = String(it.name || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1312
+ const checked = it.maintenance ? 'checked' : '';
1313
+ return `<tr data-itemid="${id}">
1314
+ <td>${name}</td>
1315
+ <td>
1316
+ <div class="form-check form-switch">
1317
+ <input class="form-check-input onekite-maint-toggle" type="checkbox" ${checked} />
1318
+ </div>
1319
+ </td>
1320
+ </tr>`;
1321
+ }).join('') || '<tr><td colspan="2" class="text-muted">Aucun matériel.</td></tr>';
1322
+ }
1323
+
1324
+ async function refreshMaintenance() {
1325
+ try {
1326
+ const items = await loadItemsWithMaintenance();
1327
+ maintItemsCache = Array.isArray(items) ? items : [];
1328
+ renderMaintenanceTable(maintItemsCache);
1329
+ } catch (e) {
1330
+ showAlert('error', 'Impossible de charger le matériel (maintenance).');
1331
+ }
1332
+ }
1333
+
1334
+ if (maintRefresh) maintRefresh.addEventListener('click', refreshMaintenance);
1335
+
1336
+ async function setAllMaintenance(enabled) {
1337
+ const label = enabled ? 'mettre TOUS les matériels en maintenance' : 'enlever la maintenance sur TOUS les matériels';
1338
+ const ok = window.confirm(`Confirmer : ${label} ?`);
1339
+ if (!ok) return;
1340
+ try {
1341
+ await fetchJson('/api/v3/plugins/calendar-onekite/maintenance', {
1342
+ method: 'PUT',
1343
+ body: JSON.stringify({ enabled: !!enabled, all: true }),
1344
+ });
1345
+ // Update cache locally without refetching if possible
1346
+ maintItemsCache = maintItemsCache.map((it) => Object.assign({}, it, { maintenance: !!enabled }));
1347
+ renderMaintenanceTable(maintItemsCache);
1348
+ showAlert('success', enabled ? 'Tous les matériels sont en maintenance.' : 'Maintenance supprimée pour tous les matériels.');
1349
+ } catch (e) {
1350
+ showAlert('error', 'Impossible de modifier la maintenance pour tous les matériels.');
1351
+ }
1352
+ }
1353
+
1354
+ if (maintAllOn) maintAllOn.addEventListener('click', () => setAllMaintenance(true));
1355
+ if (maintAllOff) maintAllOff.addEventListener('click', () => setAllMaintenance(false));
1356
+ if (maintSearch) maintSearch.addEventListener('input', () => renderMaintenanceTable(maintItemsCache));
1357
+ if (maintTableBody) {
1358
+ maintTableBody.addEventListener('change', async (ev) => {
1359
+ const el = ev && ev.target;
1360
+ if (!el || !el.classList || !el.classList.contains('onekite-maint-toggle')) return;
1361
+ const tr = el.closest('tr');
1362
+ const itemId = tr ? tr.getAttribute('data-itemid') : '';
1363
+ const enabled = !!el.checked;
1364
+ if (!itemId) return;
1365
+ // Optimistic UI
1366
+ try {
1367
+ await fetchJson(`/api/v3/plugins/calendar-onekite/maintenance/${encodeURIComponent(String(itemId))}`, {
1368
+ method: 'PUT',
1369
+ body: JSON.stringify({ enabled }),
1370
+ });
1371
+ // Update cache
1372
+ maintItemsCache = maintItemsCache.map((it) => (String(it.id) === String(itemId) ? Object.assign({}, it, { maintenance: enabled }) : it));
1373
+ showAlert('success', enabled ? 'Maintenance activée.' : 'Maintenance désactivée.');
1374
+ } catch (e) {
1375
+ // Revert
1376
+ el.checked = !enabled;
1377
+ showAlert('error', 'Impossible de modifier la maintenance.');
1378
+ }
1379
+ });
1380
+ }
1381
+
1382
+ // Load once
1383
+ if (maintTableBody) {
1384
+ refreshMaintenance();
1385
+ }
1386
+
1387
+ // --------------------
1388
+ // Audit (purge par année)
1389
+ // --------------------
1390
+ const auditYearEl = document.getElementById('onekite-audit-year');
1391
+ const auditSearchEl = document.getElementById('onekite-audit-search');
1392
+ const auditRefreshBtn = document.getElementById('onekite-audit-refresh');
1393
+ const auditPurgeBtn = document.getElementById('onekite-audit-purge');
1394
+ const auditTbody = document.querySelector('#onekite-audit-table tbody');
1395
+
1396
+ let auditCache = [];
1397
+ function fmtDateTime(ts) {
1398
+ try {
1399
+ return new Date(Number(ts) || 0).toLocaleString('fr-FR');
1400
+ } catch (e) {
1401
+ return String(ts || '');
1402
+ }
1403
+ }
1404
+ function renderAudit(entries) {
1405
+ if (!auditTbody) return;
1406
+ const q = auditSearchEl ? String(auditSearchEl.value || '').trim().toLowerCase() : '';
1407
+ const rows = (entries || []).filter((e) => {
1408
+ if (!e) return false;
1409
+ if (!q) return true;
1410
+ const hay = [e.action, e.actorUsername, e.targetType, e.targetId, JSON.stringify(e)].join(' ').toLowerCase();
1411
+ return hay.includes(q);
1412
+ }).map((e) => {
1413
+ const actor = e.actorUsername ? `${e.actorUsername} (#${e.actorUid || 0})` : `#${e.actorUid || 0}`;
1414
+ const target = `${e.targetType || ''} ${e.targetId || ''}`.trim();
1415
+ const details = (() => {
1416
+ const parts = [];
1417
+ if (e.itemNames && Array.isArray(e.itemNames) && e.itemNames.length) parts.push(e.itemNames.join(', '));
1418
+ if (e.startDate && e.endDate) parts.push(`${e.startDate} → ${e.endDate}`);
1419
+ if (e.reason) parts.push(`Raison: ${e.reason}`);
1420
+ if (e.removed) parts.push(`Supprimés: ${e.removed}`);
1421
+ return parts.join(' — ');
1422
+ })();
1423
+ return `<tr>
1424
+ <td>${fmtDateTime(e.ts)}</td>
1425
+ <td>${String(actor).replace(/</g,'&lt;').replace(/>/g,'&gt;')}</td>
1426
+ <td>${String(e.action || '').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</td>
1427
+ <td>${String(target).replace(/</g,'&lt;').replace(/>/g,'&gt;')}</td>
1428
+ <td class="text-muted">${String(details || '').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</td>
1429
+ </tr>`;
1430
+ }).join('');
1431
+ auditTbody.innerHTML = rows || '<tr><td colspan="5" class="text-muted">Aucune entrée.</td></tr>';
1432
+ }
1433
+
1434
+ async function refreshAudit() {
1435
+ if (!auditTbody) return;
1436
+ const y = Number((auditYearEl && auditYearEl.value) || new Date().getFullYear());
1437
+ try {
1438
+ const data = await fetchJson(`/api/v3/plugins/calendar-onekite/audit?year=${encodeURIComponent(String(y))}&limit=300`);
1439
+ auditCache = (data && data.entries) ? data.entries : [];
1440
+ renderAudit(auditCache);
1441
+ } catch (e) {
1442
+ showAlert('error', 'Impossible de charger l\'audit.');
1443
+ }
1444
+ }
1445
+
1446
+ if (auditYearEl && !auditYearEl.value) {
1447
+ auditYearEl.value = String(new Date().getFullYear());
1448
+ }
1449
+ if (auditRefreshBtn) auditRefreshBtn.addEventListener('click', refreshAudit);
1450
+ if (auditSearchEl) auditSearchEl.addEventListener('input', () => renderAudit(auditCache));
1451
+ if (auditPurgeBtn) {
1452
+ auditPurgeBtn.addEventListener('click', async () => {
1453
+ const y = Number((auditYearEl && auditYearEl.value) || 0);
1454
+ if (!y) return;
1455
+ const ok = window.confirm(`Purger définitivement l’audit de l’année ${y} ?`);
1456
+ if (!ok) return;
1457
+ try {
1458
+ const res = await fetchJson('/api/v3/plugins/calendar-onekite/audit/purge', {
1459
+ method: 'POST',
1460
+ body: JSON.stringify({ year: y }),
1461
+ });
1462
+ showAlert('success', `Audit purgé : ${res && res.removed ? res.removed : 0} entrée(s).`);
1463
+ await refreshAudit();
1464
+ } catch (e) {
1465
+ showAlert('error', 'Purge audit impossible.');
1466
+ }
1467
+ });
1468
+ }
1469
+
1470
+ // Load once
1471
+ if (auditTbody) {
1472
+ refreshAudit();
1473
+ }
1474
+ }
1475
+
1476
+ return { init };
1477
+ });