nodebb-plugin-onekite-calendar 1.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.
@@ -0,0 +1,812 @@
1
+
2
+ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts, bootbox) {
3
+ 'use strict';
4
+
5
+ // Cache of pending reservations keyed by rid so delegated click handlers
6
+ // can open rich modals without embedding large JSON blobs into the DOM.
7
+ const pendingCache = new Map();
8
+
9
+ function showAlert(type, msg) {
10
+ // Deduplicate identical alerts that can be triggered multiple times
11
+ // by NodeBB ACP save buttons/hooks across ajaxify navigations.
12
+ try {
13
+ const now = Date.now();
14
+ const last = window.oneKiteCalendarLastAlert;
15
+ if (last && last.type === type && last.msg === msg && (now - last.ts) < 1200) {
16
+ return;
17
+ }
18
+ window.oneKiteCalendarLastAlert = { type, msg, ts: now };
19
+ } catch (e) {}
20
+ try {
21
+ if (alerts && typeof alerts[type] === 'function') {
22
+ alerts[type](msg);
23
+ return;
24
+ }
25
+ } catch (e) {}
26
+ alert(msg);
27
+ }
28
+
29
+ async function fetchJson(url, opts) {
30
+ const res = await fetch(url, {
31
+ credentials: 'same-origin',
32
+ headers: (() => {
33
+ const headers = { 'Content-Type': 'application/json' };
34
+ const token =
35
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
36
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
37
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
38
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
39
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
40
+ null;
41
+ if (token) headers['x-csrf-token'] = token;
42
+ return headers;
43
+ })(),
44
+ ...opts,
45
+ });
46
+
47
+
48
+ if (!res.ok) {
49
+ // NodeBB versions differ: some expose admin APIs under /api/admin instead of /api/v3/admin
50
+ if (res.status === 404 && typeof url === 'string' && url.includes('/api/v3/admin/')) {
51
+ const altUrl = url.replace('/api/v3/admin/', '/api/admin/');
52
+ const res2 = await fetch(altUrl, {
53
+ credentials: 'same-origin',
54
+ headers: (() => {
55
+ const headers = { 'Content-Type': 'application/json' };
56
+ const token =
57
+ (window.config && (window.config.csrf_token || window.config.csrfToken)) ||
58
+ (window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
59
+ (document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
60
+ (document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
61
+ (typeof app !== 'undefined' && app && app.csrfToken) ||
62
+ null;
63
+ if (token) headers['x-csrf-token'] = token;
64
+ return headers;
65
+ })(),
66
+ ...opts,
67
+ });
68
+ if (res2.ok) {
69
+ return await res2.json();
70
+ }
71
+ }
72
+ const text = await res.text().catch(() => '');
73
+ throw new Error(`${res.status} ${text}`);
74
+ }
75
+ return await res.json();
76
+ }
77
+
78
+ // Leaflet (OpenStreetMap) helpers - loaded lazily only when needed.
79
+ let leafletPromise = null;
80
+ function loadLeaflet() {
81
+ if (leafletPromise) return leafletPromise;
82
+ leafletPromise = new Promise((resolve, reject) => {
83
+ try {
84
+ if (window.L && window.L.map) {
85
+ resolve(window.L);
86
+ return;
87
+ }
88
+ const cssId = 'onekite-leaflet-css';
89
+ const jsId = 'onekite-leaflet-js';
90
+ if (!document.getElementById(cssId)) {
91
+ const link = document.createElement('link');
92
+ link.id = cssId;
93
+ link.rel = 'stylesheet';
94
+ link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
95
+ document.head.appendChild(link);
96
+ }
97
+ const existing = document.getElementById(jsId);
98
+ if (existing) {
99
+ existing.addEventListener('load', () => resolve(window.L));
100
+ existing.addEventListener('error', () => reject(new Error('leaflet-load-failed')));
101
+ return;
102
+ }
103
+ const script = document.createElement('script');
104
+ script.id = jsId;
105
+ script.async = true;
106
+ script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
107
+ script.onload = () => resolve(window.L);
108
+ script.onerror = () => reject(new Error('leaflet-load-failed'));
109
+ document.head.appendChild(script);
110
+ } catch (e) {
111
+ reject(e);
112
+ }
113
+ });
114
+ return leafletPromise;
115
+ }
116
+
117
+ async function geocodeAddress(query) {
118
+ const q = String(query || '').trim();
119
+ if (!q) return null;
120
+ const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`;
121
+ const res = await fetch(url, {
122
+ method: 'GET',
123
+ headers: {
124
+ 'Accept': 'application/json',
125
+ 'Accept-Language': 'fr',
126
+ },
127
+ });
128
+ if (!res.ok) return null;
129
+ const arr = await res.json();
130
+ if (!Array.isArray(arr) || !arr.length) return null;
131
+ const hit = arr[0];
132
+ const lat = Number(hit.lat);
133
+ const lon = Number(hit.lon);
134
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
135
+ return { lat, lon, displayName: hit.display_name || q };
136
+ }
137
+
138
+ function formToObject(form) {
139
+ const out = {};
140
+ new FormData(form).forEach((v, k) => {
141
+ out[k] = String(v);
142
+ });
143
+ return out;
144
+ }
145
+
146
+ function fillForm(form, data) {
147
+ [...form.elements].forEach((el) => {
148
+ if (!el.name) return;
149
+ if (Object.prototype.hasOwnProperty.call(data, el.name)) {
150
+ el.value = data[el.name];
151
+ }
152
+ });
153
+ }
154
+
155
+ function normalizeCsvGroupsWithDefault(csv, defaultGroup) {
156
+ const extras = String(csv || '').split(',').map(s => s.trim()).filter(Boolean);
157
+ const set = new Set();
158
+ const out = [];
159
+ if (defaultGroup) {
160
+ const dg = String(defaultGroup).trim();
161
+ if (dg) {
162
+ set.add(dg);
163
+ out.push(dg);
164
+ }
165
+ }
166
+ for (const g of extras) {
167
+ if (!set.has(g)) {
168
+ set.add(g);
169
+ out.push(g);
170
+ }
171
+ }
172
+ return out.join(', ');
173
+ }
174
+
175
+ function ensureSpecialFieldsExist(form) {
176
+ // If the ACP template didn't include these fields (older installs), inject them.
177
+ if (!form) return;
178
+ const hasCreator = form.querySelector('[name="specialCreatorGroups"]');
179
+ const hasDeleter = form.querySelector('[name="specialDeleterGroups"]');
180
+ if (hasCreator && hasDeleter) return;
181
+ const wrap = document.createElement('div');
182
+ wrap.innerHTML = `
183
+ <hr />
184
+ <h4>Évènements (autre couleur)</h4>
185
+ <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>
186
+ <div class="mb-3">
187
+ <label class="form-label">Groupes autorisés à créer ces évènements (CSV)</label>
188
+ <input type="text" class="form-control" name="specialCreatorGroups" placeholder="ex: staff, instructors" />
189
+ </div>
190
+ <div class="mb-3">
191
+ <label class="form-label">Groupes autorisés à supprimer ces évènements (CSV)</label>
192
+ <input type="text" class="form-control" name="specialDeleterGroups" placeholder="ex: administrators" />
193
+ </div>
194
+ `;
195
+ form.appendChild(wrap);
196
+ }
197
+
198
+
199
+ function renderPending(list) {
200
+ const wrap = document.getElementById('onekite-pending');
201
+ if (!wrap) return;
202
+ wrap.innerHTML = '';
203
+
204
+ pendingCache.clear();
205
+
206
+ if (!list || !list.length) {
207
+ wrap.innerHTML = '<div class="text-muted">Aucune demande.</div>';
208
+ return;
209
+ }
210
+
211
+ const fmtFR = (ts) => {
212
+ const d = new Date(parseInt(ts, 10));
213
+ const dd = String(d.getDate()).padStart(2, '0');
214
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
215
+ const yyyy = d.getFullYear();
216
+ const hh = String(d.getHours()).padStart(2, '0');
217
+ const mi = String(d.getMinutes()).padStart(2, '0');
218
+ return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
219
+ };
220
+
221
+ for (const r of list) {
222
+ if (r && r.rid) pendingCache.set(String(r.rid), r);
223
+ const created = r.createdAt ? fmtFR(r.createdAt) : '';
224
+ const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
225
+ const itemsHtml = `<ul style="margin: 0 0 10px 18px;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
226
+ const div = document.createElement('div');
227
+ div.className = 'list-group-item onekite-pending-row';
228
+ div.innerHTML = `
229
+ <div class="d-flex justify-content-between align-items-start gap-2">
230
+ <div style="min-width: 0;">
231
+ <div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong></div>
232
+ <div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
233
+ <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>
234
+ </div>
235
+ <div class="d-flex gap-2">
236
+ <!-- IMPORTANT: type="button" to avoid submitting the settings form and resetting ACP tabs -->
237
+ <button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse" data-rid="${escapeHtml(String(r.rid || ''))}">Refuser</button>
238
+ <button type="button" class="btn btn-success btn-sm" data-action="approve" data-rid="${escapeHtml(String(r.rid || ''))}">Valider</button>
239
+ </div>
240
+ </div>
241
+ `;
242
+ wrap.appendChild(div);
243
+ }
244
+ }
245
+
246
+
247
+ function timeOptions(stepMinutes) {
248
+ const step = stepMinutes || 5;
249
+ const out = [];
250
+ for (let h = 7; h < 24; h++) {
251
+ for (let m = 0; m < 60; m += step) {
252
+ const hh = String(h).padStart(2, '0');
253
+ const mm = String(m).padStart(2, '0');
254
+ out.push(`${hh}:${mm}`);
255
+ }
256
+ }
257
+ return out;
258
+ }
259
+
260
+ function escapeHtml(s) {
261
+ return String(s || '')
262
+ .replace(/&/g, '&amp;')
263
+ .replace(/</g, '&lt;')
264
+ .replace(/>/g, '&gt;')
265
+ .replace(/"/g, '&quot;')
266
+ .replace(/'/g, '&#39;');
267
+ }
268
+
269
+ async function loadSettings() {
270
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/settings');
271
+ }
272
+
273
+ async function saveSettings(payload) {
274
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/settings', {
275
+ method: 'PUT',
276
+ body: JSON.stringify(payload),
277
+ });
278
+ }
279
+
280
+ async function loadPending() {
281
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/pending');
282
+ }
283
+
284
+ async function approve(rid, payload) {
285
+ return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${rid}/approve`, {
286
+ method: 'PUT',
287
+ body: JSON.stringify(payload || {}),
288
+ });
289
+ }
290
+
291
+ async function refuse(rid, payload) {
292
+ return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${rid}/refuse`, {
293
+ method: 'PUT',
294
+ body: JSON.stringify(payload || {}),
295
+ });
296
+ }
297
+
298
+ async function purge(year) {
299
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/purge', {
300
+ method: 'POST',
301
+ body: JSON.stringify({ year }),
302
+ });
303
+ }
304
+
305
+ async function purgeSpecialEvents(year) {
306
+ try {
307
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/special-events/purge', {
308
+ method: 'POST',
309
+ body: JSON.stringify({ year }),
310
+ });
311
+ } catch (e) {
312
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/special-events/purge', {
313
+ method: 'POST',
314
+ body: JSON.stringify({ year }),
315
+ });
316
+ }
317
+ }
318
+
319
+ async function debugHelloAsso() {
320
+ try {
321
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
322
+ } catch (e) {
323
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
324
+ }
325
+ }
326
+
327
+ async function loadAccounting(from, to) {
328
+ const params = new URLSearchParams();
329
+ if (from) params.set('from', from);
330
+ if (to) params.set('to', to);
331
+ const qs = params.toString();
332
+ return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/accounting${qs ? `?${qs}` : ''}`);
333
+ }
334
+
335
+ async function init() {
336
+ const form = document.getElementById('onekite-settings-form');
337
+ if (!form) return;
338
+
339
+ // Make the HelloAsso debug output readable in both light and dark ACP themes.
340
+ // NodeBB 4.x uses Bootstrap variables, so we can rely on CSS variables here.
341
+ (function injectAdminCss() {
342
+ const id = 'onekite-admin-css';
343
+ if (document.getElementById(id)) return;
344
+ const style = document.createElement('style');
345
+ style.id = id;
346
+ style.textContent = `
347
+ #onekite-debug-output.onekite-debug-output {
348
+ background: var(--bs-body-bg) !important;
349
+ color: var(--bs-body-color) !important;
350
+ border: 1px solid var(--bs-border-color) !important;
351
+ }
352
+ `;
353
+ document.head.appendChild(style);
354
+ })();
355
+
356
+ // Load settings
357
+ try {
358
+ const s = await loadSettings();
359
+ fillForm(form, s || {});
360
+
361
+ // Ensure default creator group prefix appears in the ACP field
362
+ const y = new Date().getFullYear();
363
+ const defaultGroup = `onekite-ffvl-${y}`;
364
+ const cgEl = form.querySelector('[name="creatorGroups"]');
365
+ if (cgEl) {
366
+ cgEl.value = normalizeCsvGroupsWithDefault(cgEl.value, defaultGroup);
367
+ }
368
+ } catch (e) {
369
+ showAlert('error', 'Impossible de charger les paramètres.');
370
+ }
371
+
372
+ // Load pending
373
+ async function refreshPending() {
374
+ try {
375
+ const p = await loadPending();
376
+ renderPending(p);
377
+ } catch (e) {
378
+ showAlert('error', 'Impossible de charger les demandes en attente.');
379
+ }
380
+ }
381
+
382
+ await refreshPending();
383
+
384
+ async function doSave(ev) {
385
+ // Guard against duplicate handlers (some themes bind multiple save buttons)
386
+ // and against rapid double-clicks.
387
+ if (doSave._inFlight) {
388
+ if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
389
+ return;
390
+ }
391
+ doSave._inFlight = true;
392
+ try {
393
+ if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
394
+ const payload = formToObject(form);
395
+ // Always prefix with default yearly group
396
+ const y = new Date().getFullYear();
397
+ const defaultGroup = `onekite-ffvl-${y}`;
398
+ if (Object.prototype.hasOwnProperty.call(payload, 'creatorGroups')) {
399
+ payload.creatorGroups = normalizeCsvGroupsWithDefault(payload.creatorGroups, defaultGroup);
400
+ }
401
+ await saveSettings(payload);
402
+ showAlert('success', 'Paramètres enregistrés.');
403
+ } catch (e) {
404
+ showAlert('error', 'Échec de l\'enregistrement.');
405
+ } finally {
406
+ doSave._inFlight = false;
407
+ }
408
+ }
409
+
410
+ // Expose the latest save handler so the global delegated listener (bound once)
411
+ // can always call the current instance tied to the current form.
412
+ window.oneKiteCalendarAdminDoSave = doSave;
413
+
414
+ // Save buttons (NodeBB header/footer "Enregistrer" + floppy icon)
415
+ // Bind a SINGLE delegated listener for the entire admin session.
416
+ const SAVE_SELECTOR = '#save, .save, [data-action="save"], .settings-save, .floating-save, .btn[data-action="save"]';
417
+ if (!window.oneKiteCalendarAdminBound) {
418
+ window.oneKiteCalendarAdminBound = true;
419
+ document.addEventListener('click', (ev) => {
420
+ const btn = ev.target && ev.target.closest && ev.target.closest(SAVE_SELECTOR);
421
+ if (!btn) return;
422
+ // Only handle clicks while we're on this plugin page
423
+ if (!document.getElementById('onekite-settings-form')) return;
424
+ const fn = window.oneKiteCalendarAdminDoSave;
425
+ if (typeof fn === 'function') fn(ev);
426
+ });
427
+ }
428
+
429
+ // Approve/refuse buttons
430
+ const pendingWrap = document.getElementById('onekite-pending');
431
+ if (pendingWrap && !pendingWrap.dataset.okcBound) {
432
+ pendingWrap.dataset.okcBound = '1';
433
+ pendingWrap.addEventListener('click', async (ev) => {
434
+ const btn = ev.target && ev.target.closest('button[data-action]');
435
+ if (!btn) return;
436
+ // Prevent the settings form from submitting (default <button> behavior)
437
+ // and avoid triggering NodeBB ACP tab navigation side-effects.
438
+ try {
439
+ ev.preventDefault();
440
+ ev.stopPropagation();
441
+ } catch (e) {}
442
+ const action = btn.getAttribute('data-action');
443
+ const rid = btn.getAttribute('data-rid');
444
+ if (!rid) return;
445
+
446
+ // Remove the row immediately on success for a snappier UX
447
+ const rowEl = btn.closest('tr') || btn.closest('.onekite-pending-row');
448
+
449
+ try {
450
+ if (action === 'refuse') {
451
+ const html = `
452
+ <div class="mb-3">
453
+ <label class="form-label">Raison du refus</label>
454
+ <textarea class="form-control" id="onekite-refuse-reason" rows="3" placeholder="Ex: matériel indisponible, dates impossibles, dossier incomplet..."></textarea>
455
+ </div>
456
+ `;
457
+ const ok = await new Promise((resolve) => {
458
+ bootbox.dialog({
459
+ title: 'Refuser la réservation',
460
+ message: html,
461
+ buttons: {
462
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(false) },
463
+ ok: {
464
+ label: 'Refuser',
465
+ className: 'btn-danger',
466
+ callback: async () => {
467
+ try {
468
+ const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
469
+ await refuse(rid, { reason });
470
+ if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
471
+ showAlert('success', 'Demande refusée.');
472
+ resolve(true);
473
+ } catch (e) {
474
+ showAlert('error', 'Refus impossible.');
475
+ resolve(false);
476
+ }
477
+ return false;
478
+ },
479
+ },
480
+ },
481
+ });
482
+ });
483
+ if (ok) {
484
+ await refreshPending();
485
+ }
486
+ return;
487
+ }
488
+
489
+ if (action === 'approve') {
490
+ const r = pendingCache.get(String(rid)) || {};
491
+ const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
492
+ ? r.itemNames
493
+ : (typeof r.itemNames === 'string' && r.itemNames.trim()
494
+ ? r.itemNames.split(',').map(s => s.trim()).filter(Boolean)
495
+ : ([r.itemName || r.itemId].filter(Boolean)));
496
+ const itemsListHtml = itemNames.length
497
+ ? `<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>`
498
+ : '';
499
+ const opts = timeOptions(5).map(t => `<option value="${t}" ${t === '07:00' ? 'selected' : ''}>${t}</option>`).join('');
500
+
501
+ const html = `
502
+ ${itemsListHtml}
503
+ <div class="mb-3">
504
+ <label class="form-label">Adresse de récupération</label>
505
+ <div class="input-group">
506
+ <input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
507
+ <button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
508
+ </div>
509
+ <div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
510
+ <div class="form-text" id="onekite-map-help">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
511
+ <input type="hidden" id="onekite-pickup-lat" />
512
+ <input type="hidden" id="onekite-pickup-lon" />
513
+ </div>
514
+ <div class="mb-3">
515
+ <label class="form-label">Notes (facultatif)</label>
516
+ <textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
517
+ </div>
518
+ <div class="mb-2">
519
+ <label class="form-label">Heure de récupération</label>
520
+ <select class="form-select" id="onekite-pickup-time">${opts}</select>
521
+ </div>
522
+ `;
523
+
524
+ const dlg = bootbox.dialog({
525
+ title: 'Valider la demande',
526
+ message: html,
527
+ buttons: {
528
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
529
+ ok: {
530
+ label: 'Valider',
531
+ className: 'btn-success',
532
+ callback: async () => {
533
+ try {
534
+ const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
535
+ const notes = (document.getElementById('onekite-notes')?.value || '').trim();
536
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
537
+ const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
538
+ const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
539
+ await approve(rid, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
540
+ if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
541
+ showAlert('success', 'Demande validée.');
542
+ await refreshPending();
543
+ } catch (e) {
544
+ showAlert('error', 'Validation impossible.');
545
+ }
546
+ return false;
547
+ },
548
+ },
549
+ },
550
+ });
551
+
552
+ // Init Leaflet map once the modal is visible.
553
+ dlg.on('shown.bs.modal', async () => {
554
+ try {
555
+ const L = await loadLeaflet();
556
+ const mapEl = document.getElementById('onekite-map');
557
+ if (!mapEl) return;
558
+
559
+ const map = L.map(mapEl, { scrollWheelZoom: false });
560
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
561
+ maxZoom: 19,
562
+ attribution: '&copy; OpenStreetMap',
563
+ }).addTo(map);
564
+
565
+ // Default view (France-ish)
566
+ map.setView([46.7, 2.5], 5);
567
+
568
+ let marker = null;
569
+ function setMarker(lat, lon, zoom) {
570
+ const ll = [lat, lon];
571
+ if (!marker) {
572
+ marker = L.marker(ll, { draggable: true }).addTo(map);
573
+ marker.on('dragend', () => {
574
+ const p2 = marker.getLatLng();
575
+ document.getElementById('onekite-pickup-lat').value = String(p2.lat);
576
+ document.getElementById('onekite-pickup-lon').value = String(p2.lng);
577
+ });
578
+ } else {
579
+ marker.setLatLng(ll);
580
+ }
581
+ document.getElementById('onekite-pickup-lat').value = String(lat);
582
+ document.getElementById('onekite-pickup-lon').value = String(lon);
583
+ if (zoom) {
584
+ map.setView(ll, zoom);
585
+ } else {
586
+ map.panTo(ll);
587
+ }
588
+ }
589
+
590
+ map.on('click', (e) => {
591
+ if (e && e.latlng) {
592
+ setMarker(e.latlng.lat, e.latlng.lng, map.getZoom());
593
+ }
594
+ });
595
+
596
+ const geocodeBtn = document.getElementById('onekite-geocode');
597
+ const addrInput = document.getElementById('onekite-pickup-address');
598
+ async function runGeocode() {
599
+ try {
600
+ const addr = (addrInput?.value || '').trim();
601
+ if (!addr) return;
602
+ const hit = await geocodeAddress(addr);
603
+ if (!hit) {
604
+ showAlert('error', 'Adresse introuvable.');
605
+ return;
606
+ }
607
+ setMarker(hit.lat, hit.lon, 16);
608
+ } catch (e) {
609
+ showAlert('error', 'Recherche adresse impossible.');
610
+ }
611
+ }
612
+ if (geocodeBtn) geocodeBtn.addEventListener('click', runGeocode);
613
+ if (addrInput) {
614
+ addrInput.addEventListener('keydown', (e) => {
615
+ if (e.key === 'Enter') {
616
+ e.preventDefault();
617
+ runGeocode();
618
+ }
619
+ });
620
+ }
621
+ } catch (e) {
622
+ // ignore
623
+ }
624
+ });
625
+ }
626
+ } catch (e) {
627
+ showAlert('error', 'Action impossible.');
628
+ }
629
+ });
630
+ }
631
+
632
+ // Purge
633
+ const purgeBtn = document.getElementById('onekite-purge');
634
+ if (purgeBtn) {
635
+ purgeBtn.addEventListener('click', async () => {
636
+ const yearInput = document.getElementById('onekite-purge-year');
637
+ const year = (yearInput ? yearInput.value : '').trim();
638
+ if (!/^\d{4}$/.test(year)) {
639
+ showAlert('error', 'Année invalide (YYYY)');
640
+ return;
641
+ }
642
+ bootbox.confirm(`Purger toutes les réservations de ${year} ?`, async (ok) => {
643
+ if (!ok) return;
644
+ try {
645
+ const r = await purge(year);
646
+ showAlert('success', `Purge OK (${r.removed || 0} supprimées, ${r.archivedForAccounting || 0} archivées — compta conservée).`);
647
+ await refreshPending();
648
+ } catch (e) {
649
+ showAlert('error', 'Purge impossible.');
650
+ }
651
+ });
652
+ });
653
+ }
654
+
655
+ // Purge special events by year
656
+ const sePurgeBtn = document.getElementById('onekite-se-purge');
657
+ if (sePurgeBtn) {
658
+ sePurgeBtn.addEventListener('click', async () => {
659
+ const yearInput = document.getElementById('onekite-se-purge-year');
660
+ const year = (yearInput ? yearInput.value : '').trim();
661
+ if (!/^\d{4}$/.test(year)) {
662
+ showAlert('error', 'Année invalide (YYYY)');
663
+ return;
664
+ }
665
+ bootbox.confirm(`Purger tous les évènements de ${year} ?`, async (ok) => {
666
+ if (!ok) return;
667
+ try {
668
+ const r = await purgeSpecialEvents(year);
669
+ showAlert('success', `Purge OK (${r.removed || 0} supprimé(s)).`);
670
+ } catch (e) {
671
+ showAlert('error', 'Purge impossible.');
672
+ }
673
+ });
674
+ });
675
+ }
676
+
677
+ // Debug
678
+ const debugBtn = document.getElementById('onekite-debug-run');
679
+ if (debugBtn) {
680
+ debugBtn.addEventListener('click', async () => {
681
+ const out = document.getElementById('onekite-debug-output');
682
+ if (out) out.textContent = 'Chargement...';
683
+ try {
684
+ const result = await debugHelloAsso();
685
+ if (out) out.textContent = JSON.stringify(result, null, 2);
686
+ const catalogCount = result && result.catalog ? parseInt(result.catalog.count, 10) || 0 : 0;
687
+ const catalogOk = !!(result && result.catalog && result.catalog.ok);
688
+ // Accept "count > 0" even if ok flag is false (some proxies can strip fields, etc.)
689
+ if (catalogOk || catalogCount > 0) {
690
+ showAlert('success', `Catalogue HelloAsso: ${catalogCount} item(s)`);
691
+ } else {
692
+ showAlert('error', 'HelloAsso: impossible de récupérer le catalogue.');
693
+ }
694
+ } catch (e) {
695
+ if (out) out.textContent = String(e && e.message ? e.message : e);
696
+ showAlert('error', 'Debug impossible.');
697
+ }
698
+ });
699
+ }
700
+
701
+ // Accounting (paid reservations)
702
+ const accFrom = document.getElementById('onekite-acc-from');
703
+ const accTo = document.getElementById('onekite-acc-to');
704
+ const accRefresh = document.getElementById('onekite-acc-refresh');
705
+ const accExport = document.getElementById('onekite-acc-export');
706
+ const accPurge = document.getElementById('onekite-acc-purge');
707
+ const accSummary = document.querySelector('#onekite-acc-summary tbody');
708
+ const accRows = document.querySelector('#onekite-acc-rows tbody');
709
+
710
+ function ymd(d) {
711
+ const yyyy = d.getUTCFullYear();
712
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
713
+ const dd = String(d.getUTCDate()).padStart(2, '0');
714
+ return `${yyyy}-${mm}-${dd}`;
715
+ }
716
+ if (accFrom && accTo) {
717
+ const now = new Date();
718
+ const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
719
+ const from = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1));
720
+ if (!accFrom.value) accFrom.value = ymd(from);
721
+ if (!accTo.value) accTo.value = ymd(to);
722
+ }
723
+
724
+ function renderAccounting(payload) {
725
+ if (accSummary) accSummary.innerHTML = '';
726
+ if (accRows) accRows.innerHTML = '';
727
+ if (!payload || !payload.ok) {
728
+ return;
729
+ }
730
+
731
+ (payload.summary || []).forEach((s) => {
732
+ const tr = document.createElement('tr');
733
+ tr.innerHTML = `<td>${escapeHtml(s.item)}</td><td>${escapeHtml(String(s.count || 0))}</td><td>${escapeHtml((Number(s.total) || 0).toFixed(2))}</td>`;
734
+ accSummary && accSummary.appendChild(tr);
735
+ });
736
+
737
+ (payload.rows || []).forEach((r) => {
738
+ const tr = document.createElement('tr');
739
+ const user = r.username ? `<a href="/user/${encodeURIComponent(r.username)}" target="_blank">${escapeHtml(r.username)}</a>` : '';
740
+ const items = Array.isArray(r.items) ? r.items.map((x) => escapeHtml(x)).join('<br>') : '';
741
+ tr.innerHTML = `<td>${escapeHtml(r.startDate)} → ${escapeHtml(r.endDate)}</td><td>${user}</td><td>${items}</td><td>${escapeHtml((Number(r.total) || 0).toFixed(2))}</td><td><code>${escapeHtml(r.rid)}</code></td>`;
742
+ accRows && accRows.appendChild(tr);
743
+ });
744
+ }
745
+
746
+ async function refreshAccounting() {
747
+ if (!accRefresh) return;
748
+ try {
749
+ const from = accFrom ? accFrom.value : '';
750
+ const to = accTo ? accTo.value : '';
751
+ accRefresh.disabled = true;
752
+ const payload = await loadAccounting(from, to);
753
+ renderAccounting(payload);
754
+ } catch (e) {
755
+ showAlert('error', 'Impossible de charger la comptabilisation.');
756
+ } finally {
757
+ accRefresh.disabled = false;
758
+ }
759
+ }
760
+
761
+ if (accRefresh) {
762
+ accRefresh.addEventListener('click', refreshAccounting);
763
+ // Load once on init
764
+ refreshAccounting();
765
+ }
766
+ if (accExport) {
767
+ accExport.addEventListener('click', () => {
768
+ const params = new URLSearchParams();
769
+ if (accFrom && accFrom.value) params.set('from', accFrom.value);
770
+ if (accTo && accTo.value) params.set('to', accTo.value);
771
+ const qs = params.toString();
772
+ const url = `/api/v3/admin/plugins/calendar-onekite/accounting.csv${qs ? `?${qs}` : ''}`;
773
+ window.open(url, '_blank');
774
+ });
775
+
776
+ if (accPurge) {
777
+ accPurge.addEventListener('click', async () => {
778
+ 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é).');
779
+ if (!ok) return;
780
+ try {
781
+ const params = new URLSearchParams();
782
+ if (accFrom && accFrom.value) params.set('from', accFrom.value);
783
+ if (accTo && accTo.value) params.set('to', accTo.value);
784
+ const qs = params.toString();
785
+ const url = `/api/v3/admin/plugins/calendar-onekite/accounting/purge${qs ? `?${qs}` : ''}`;
786
+ const res = await fetchJson(url, { method: 'POST' });
787
+ if (res && res.ok) {
788
+ showAlert('success', `Compta purgée : ${res.purged || 0} réservation(s).`);
789
+ // Refresh accounting tables after purge
790
+ try {
791
+ const from = accFrom ? accFrom.value : '';
792
+ const to = accTo ? accTo.value : '';
793
+ const data = await loadAccounting(from, to);
794
+ renderAccounting(data);
795
+ } catch (e) {
796
+ // If refresh fails, keep the success message and show a soft warning
797
+ showAlert('error', 'Compta purgée, mais rafraîchissement impossible.');
798
+ }
799
+ } else {
800
+ showAlert('error', 'Purge impossible.');
801
+ }
802
+ } catch (e) {
803
+ showAlert('error', 'Purge impossible.');
804
+ }
805
+ });
806
+ }
807
+
808
+ }
809
+ }
810
+
811
+ return { init };
812
+ });