nodebb-plugin-calendar-onekite 11.2.13 → 11.2.15

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "11.2.13",
3
+ "version": "11.2.15",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/admin.js CHANGED
@@ -2,6 +2,10 @@
2
2
  define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts, bootbox) {
3
3
  'use strict';
4
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
+
5
9
  function showAlert(type, msg) {
6
10
  try {
7
11
  if (alerts && typeof alerts[type] === 'function') {
@@ -187,6 +191,8 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
187
191
  if (!wrap) return;
188
192
  wrap.innerHTML = '';
189
193
 
194
+ pendingCache.clear();
195
+
190
196
  if (!list || !list.length) {
191
197
  wrap.innerHTML = '<div class="text-muted">Aucune demande.</div>';
192
198
  return;
@@ -203,11 +209,12 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
203
209
  };
204
210
 
205
211
  for (const r of list) {
212
+ if (r && r.rid) pendingCache.set(String(r.rid), r);
206
213
  const created = r.createdAt ? fmtFR(r.createdAt) : '';
207
214
  const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
208
215
  const itemsHtml = `<ul style="margin: 0 0 10px 18px;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
209
216
  const div = document.createElement('div');
210
- div.className = 'list-group-item';
217
+ div.className = 'list-group-item onekite-pending-row';
211
218
  div.innerHTML = `
212
219
  <div class="d-flex justify-content-between align-items-start gap-2">
213
220
  <div style="min-width: 0;">
@@ -216,149 +223,11 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
216
223
  <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>
217
224
  </div>
218
225
  <div class="d-flex gap-2">
219
- <button class="btn btn-outline-danger btn-sm">Rejeter</button>
220
- <button class="btn btn-success btn-sm">Valider</button>
226
+ <button class="btn btn-outline-danger btn-sm" data-action="refuse" data-rid="${escapeHtml(String(r.rid || ''))}">Refuser</button>
227
+ <button class="btn btn-success btn-sm" data-action="approve" data-rid="${escapeHtml(String(r.rid || ''))}">Valider</button>
221
228
  </div>
222
229
  </div>
223
230
  `;
224
- const [refuseBtn, approveBtn] = div.querySelectorAll('button');
225
- refuseBtn.addEventListener('click', async () => {
226
- if (!confirm('Rejeter cette demande ?')) return;
227
- try {
228
- // Server route: PUT /api/v3/admin/plugins/calendar-onekite/reservations/:rid/refuse
229
- await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${encodeURIComponent(r.rid)}/refuse`, { method: 'PUT' });
230
- showAlert('success', 'Demande rejetée.');
231
- await loadPending();
232
- } catch (e) {
233
- showAlert('error', 'Rejet impossible.');
234
- }
235
- });
236
-
237
- approveBtn.addEventListener('click', async () => {
238
- const itemNamesModal = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
239
- const itemsHtmlModal = `<ul style="margin: 0 0 10px 18px;">${itemNamesModal.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
240
-
241
- const opts = (() => {
242
- const out = [];
243
- for (let h = 7; h < 24; h++) {
244
- for (let m = 0; m < 60; m += 5) {
245
- out.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
246
- }
247
- }
248
- return out;
249
- })().map(t => `<option value="${t}">${t}</option>`).join('');
250
-
251
- const html = `
252
- <div class="mb-2"><strong>Matériel</strong>${itemsHtmlModal}</div>
253
- <div class="mb-3">
254
- <label class="form-label">Adresse de récupération</label>
255
- <div class="input-group">
256
- <input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
257
- <button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
258
- </div>
259
- <div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
260
- <div class="form-text">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
261
- <input type="hidden" id="onekite-pickup-lat" />
262
- <input type="hidden" id="onekite-pickup-lon" />
263
- </div>
264
- <div class="mb-3">
265
- <label class="form-label">Notes (facultatif)</label>
266
- <textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
267
- </div>
268
- <div class="mb-2">
269
- <label class="form-label">Heure de récupération</label>
270
- <select class="form-select" id="onekite-pickup-time">${opts}</select>
271
- </div>
272
- `;
273
-
274
- const dlg = bootbox.dialog({
275
- title: 'Valider la demande',
276
- message: html,
277
- buttons: {
278
- cancel: { label: 'Annuler', className: 'btn-secondary' },
279
- ok: {
280
- label: 'Valider',
281
- className: 'btn-success',
282
- callback: async () => {
283
- try {
284
- const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
285
- const notes = (document.getElementById('onekite-notes')?.value || '').trim();
286
- const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
287
- const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
288
- const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
289
- // Server route: PUT /api/v3/admin/plugins/calendar-onekite/reservations/:rid/approve
290
- await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${encodeURIComponent(r.rid)}/approve`, { method: 'PUT', body: JSON.stringify({ pickupAddress, notes, pickupTime, pickupLat, pickupLon }) });
291
- showAlert('success', 'Demande validée.');
292
- await loadPending();
293
- } catch (e) {
294
- showAlert('error', 'Validation impossible.');
295
- }
296
- },
297
- },
298
- },
299
- });
300
-
301
- dlg.on('shown.bs.modal', async () => {
302
- try {
303
- const L = await loadLeaflet();
304
- const mapEl = document.getElementById('onekite-map');
305
- if (!mapEl) return;
306
- const map = L.map(mapEl, { scrollWheelZoom: false });
307
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
308
- maxZoom: 19,
309
- attribution: '&copy; OpenStreetMap',
310
- }).addTo(map);
311
- map.setView([46.7, 2.5], 5);
312
- let marker = null;
313
- function setMarker(lat, lon, zoom) {
314
- const ll = [lat, lon];
315
- if (!marker) {
316
- marker = L.marker(ll, { draggable: true }).addTo(map);
317
- marker.on('dragend', () => {
318
- const p2 = marker.getLatLng();
319
- document.getElementById('onekite-pickup-lat').value = String(p2.lat);
320
- document.getElementById('onekite-pickup-lon').value = String(p2.lng);
321
- });
322
- } else {
323
- marker.setLatLng(ll);
324
- }
325
- document.getElementById('onekite-pickup-lat').value = String(lat);
326
- document.getElementById('onekite-pickup-lon').value = String(lon);
327
- if (zoom) map.setView(ll, zoom); else map.panTo(ll);
328
- }
329
- map.on('click', (e) => {
330
- if (e && e.latlng) setMarker(e.latlng.lat, e.latlng.lng, map.getZoom());
331
- });
332
- const geocodeBtn = document.getElementById('onekite-geocode');
333
- const addrInput = document.getElementById('onekite-pickup-address');
334
- async function runGeocode() {
335
- try {
336
- const addr = (addrInput?.value || '').trim();
337
- if (!addr) return;
338
- const hit = await geocodeAddress(addr);
339
- if (!hit) {
340
- showAlert('error', 'Adresse introuvable.');
341
- return;
342
- }
343
- setMarker(hit.lat, hit.lon, 16);
344
- } catch (e) {
345
- showAlert('error', 'Recherche adresse impossible.');
346
- }
347
- }
348
- if (geocodeBtn) geocodeBtn.addEventListener('click', runGeocode);
349
- if (addrInput) {
350
- addrInput.addEventListener('keydown', (e) => {
351
- if (e.key === 'Enter') {
352
- e.preventDefault();
353
- runGeocode();
354
- }
355
- });
356
- }
357
- } catch (e) {}
358
- });
359
- return false;
360
- });
361
-
362
231
  wrap.appendChild(div);
363
232
  }
364
233
  }
@@ -552,53 +421,14 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
552
421
  const rowEl = btn.closest('tr') || btn.closest('.onekite-pending-row');
553
422
 
554
423
  try {
555
- if (action === 'approve') {
556
- const opts = timeOptions(5).map(t => `<option value="${t}">${t}</option>`).join('');
424
+ if (action === 'refuse') {
557
425
  const html = `
558
426
  <div class="mb-3">
559
- <label class="form-label">Note (incluse dans l'email)</label>
560
- <textarea class="form-control" id="onekite-admin-note" rows="3" placeholder="Matériel à récupérer à l'adresse : ..."></textarea>
561
- </div>
562
- <div class="mb-2">
563
- <label class="form-label">Heure de récupération</label>
564
- <select class="form-select" id="onekite-pickup-time">${opts}</select>
565
- </div>
566
- `;
567
- await new Promise((resolve) => {
568
- bootbox.dialog({
569
- title: 'Valider la réservation',
570
- message: html,
571
- buttons: {
572
- cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(false) },
573
- ok: {
574
- label: 'Valider',
575
- className: 'btn-success',
576
- callback: async () => {
577
- try {
578
- const adminNote = (document.getElementById('onekite-admin-note')?.value || '').trim();
579
- const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
580
- await approve(rid, { adminNote, pickupTime });
581
- if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
582
- bootbox.alert('Réservation validée.');
583
- resolve(true);
584
- } catch (e) {
585
- showAlert('error', 'Validation impossible.');
586
- resolve(false);
587
- }
588
- return false;
589
- },
590
- },
591
- },
592
- });
593
- });
594
- } else if (action === 'refuse') {
595
- const html = `
596
- <div class="mb-3">
597
- <label class="form-label">Raison du refus (incluse dans l'email)</label>
427
+ <label class="form-label">Raison du refus</label>
598
428
  <textarea class="form-control" id="onekite-refuse-reason" rows="3" placeholder="Ex: matériel indisponible, dates impossibles, dossier incomplet..."></textarea>
599
429
  </div>
600
430
  `;
601
- await new Promise((resolve) => {
431
+ const ok = await new Promise((resolve) => {
602
432
  bootbox.dialog({
603
433
  title: 'Refuser la réservation',
604
434
  message: html,
@@ -612,7 +442,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
612
442
  const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
613
443
  await refuse(rid, { reason });
614
444
  if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
615
- bootbox.alert('Réservation refusée.');
445
+ showAlert('success', 'Demande refusée.');
616
446
  resolve(true);
617
447
  } catch (e) {
618
448
  showAlert('error', 'Refus impossible.');
@@ -624,8 +454,149 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
624
454
  },
625
455
  });
626
456
  });
457
+ if (ok) {
458
+ await refreshPending();
459
+ }
460
+ return;
461
+ }
462
+
463
+ if (action === 'approve') {
464
+ const r = pendingCache.get(String(rid)) || {};
465
+ const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
466
+ ? r.itemNames
467
+ : (typeof r.itemNames === 'string' && r.itemNames.trim()
468
+ ? r.itemNames.split(',').map(s => s.trim()).filter(Boolean)
469
+ : ([r.itemName || r.itemId].filter(Boolean)));
470
+ const itemsListHtml = itemNames.length
471
+ ? `<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>`
472
+ : '';
473
+ const opts = timeOptions(5).map(t => `<option value="${t}" ${t === '07:00' ? 'selected' : ''}>${t}</option>`).join('');
474
+
475
+ const html = `
476
+ ${itemsListHtml}
477
+ <div class="mb-3">
478
+ <label class="form-label">Adresse de récupération</label>
479
+ <div class="input-group">
480
+ <input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
481
+ <button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
482
+ </div>
483
+ <div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
484
+ <div class="form-text" id="onekite-map-help">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
485
+ <input type="hidden" id="onekite-pickup-lat" />
486
+ <input type="hidden" id="onekite-pickup-lon" />
487
+ </div>
488
+ <div class="mb-3">
489
+ <label class="form-label">Notes (facultatif)</label>
490
+ <textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
491
+ </div>
492
+ <div class="mb-2">
493
+ <label class="form-label">Heure de récupération</label>
494
+ <select class="form-select" id="onekite-pickup-time">${opts}</select>
495
+ </div>
496
+ `;
497
+
498
+ const dlg = bootbox.dialog({
499
+ title: 'Valider la demande',
500
+ message: html,
501
+ buttons: {
502
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
503
+ ok: {
504
+ label: 'Valider',
505
+ className: 'btn-success',
506
+ callback: async () => {
507
+ try {
508
+ const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
509
+ const notes = (document.getElementById('onekite-notes')?.value || '').trim();
510
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
511
+ const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
512
+ const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
513
+ await approve(rid, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
514
+ if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
515
+ showAlert('success', 'Demande validée.');
516
+ await refreshPending();
517
+ } catch (e) {
518
+ showAlert('error', 'Validation impossible.');
519
+ }
520
+ return false;
521
+ },
522
+ },
523
+ },
524
+ });
525
+
526
+ // Init Leaflet map once the modal is visible.
527
+ dlg.on('shown.bs.modal', async () => {
528
+ try {
529
+ const L = await loadLeaflet();
530
+ const mapEl = document.getElementById('onekite-map');
531
+ if (!mapEl) return;
532
+
533
+ const map = L.map(mapEl, { scrollWheelZoom: false });
534
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
535
+ maxZoom: 19,
536
+ attribution: '&copy; OpenStreetMap',
537
+ }).addTo(map);
538
+
539
+ // Default view (France-ish)
540
+ map.setView([46.7, 2.5], 5);
541
+
542
+ let marker = null;
543
+ function setMarker(lat, lon, zoom) {
544
+ const ll = [lat, lon];
545
+ if (!marker) {
546
+ marker = L.marker(ll, { draggable: true }).addTo(map);
547
+ marker.on('dragend', () => {
548
+ const p2 = marker.getLatLng();
549
+ document.getElementById('onekite-pickup-lat').value = String(p2.lat);
550
+ document.getElementById('onekite-pickup-lon').value = String(p2.lng);
551
+ });
552
+ } else {
553
+ marker.setLatLng(ll);
554
+ }
555
+ document.getElementById('onekite-pickup-lat').value = String(lat);
556
+ document.getElementById('onekite-pickup-lon').value = String(lon);
557
+ if (zoom) {
558
+ map.setView(ll, zoom);
559
+ } else {
560
+ map.panTo(ll);
561
+ }
562
+ }
563
+
564
+ map.on('click', (e) => {
565
+ if (e && e.latlng) {
566
+ setMarker(e.latlng.lat, e.latlng.lng, map.getZoom());
567
+ }
568
+ });
569
+
570
+ const geocodeBtn = document.getElementById('onekite-geocode');
571
+ const addrInput = document.getElementById('onekite-pickup-address');
572
+ async function runGeocode() {
573
+ try {
574
+ const addr = (addrInput?.value || '').trim();
575
+ if (!addr) return;
576
+ const hit = await geocodeAddress(addr);
577
+ if (!hit) {
578
+ showAlert('error', 'Adresse introuvable.');
579
+ return;
580
+ }
581
+ setMarker(hit.lat, hit.lon, 16);
582
+ } catch (e) {
583
+ showAlert('error', 'Recherche adresse impossible.');
584
+ }
585
+ }
586
+ if (geocodeBtn) geocodeBtn.addEventListener('click', runGeocode);
587
+ if (addrInput) {
588
+ addrInput.addEventListener('keydown', (e) => {
589
+ if (e.key === 'Enter') {
590
+ e.preventDefault();
591
+ runGeocode();
592
+ }
593
+ });
594
+ }
595
+ } catch (e) {
596
+ // ignore
597
+ }
598
+ });
627
599
  }
628
- await refreshPending();
629
600
  } catch (e) {
630
601
  showAlert('error', 'Action impossible.');
631
602
  }
package/public/client.js CHANGED
@@ -25,7 +25,18 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
25
25
  text-overflow: clip;
26
26
  white-space: nowrap;
27
27
  }
28
- `;
28
+
29
+
30
+ /* FullCalendar: keep the clock emoji aligned like other inline icons */
31
+ .fc .fc-event-time .okc-clock {
32
+ line-height: 1;
33
+ display: inline-flex;
34
+ align-items: center;
35
+ }
36
+ .fc .fc-event-time .okc-time-dash {
37
+ opacity: 0.8;
38
+ }
39
+ `;
29
40
  document.head.appendChild(style);
30
41
  } catch (e) {
31
42
  // ignore
@@ -947,17 +958,30 @@ function toDatetimeLocalValue(date) {
947
958
  el2.style.color = '#ffffff';
948
959
  }
949
960
 
950
- // Improve readability: show a clock icon before the time and a dash after it.
951
- // Example: "🕒 07:00 - Titre"
952
- try {
953
- const timeEl = arg.el && arg.el.querySelector && arg.el.querySelector('.fc-event-time');
954
- if (timeEl && typeof timeEl.textContent === 'string') {
955
- const t = timeEl.textContent.trim();
956
- if (t && !t.startsWith('🕒')) {
957
- timeEl.textContent = `🕒 ${t} -`;
958
- }
959
- }
960
- } catch (e2) {}
961
+
962
+ // Improve readability: show a clock icon before the time and a dash after it.
963
+ // We use a tiny inline wrapper to keep the clock aligned with other inline icons (like locations).
964
+ try {
965
+ const timeEl = arg.el && arg.el.querySelector && arg.el.querySelector('.fc-event-time');
966
+
967
+ if (timeEl && typeof timeEl.textContent === 'string') {
968
+ const t = timeEl.textContent.trim();
969
+ if (t) {
970
+ // Inline-flex alignment fixes baseline issues caused by emoji glyphs.
971
+ timeEl.style.display = 'inline-flex';
972
+ timeEl.style.alignItems = 'center';
973
+ timeEl.style.gap = '4px';
974
+ timeEl.style.whiteSpace = 'nowrap';
975
+
976
+ // Avoid duplicating the marker if rerendered.
977
+ if (!timeEl.dataset.okcClock) {
978
+ timeEl.dataset.okcClock = '1';
979
+ const safeText = (t || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
980
+ timeEl.innerHTML = `<span class="okc-clock" aria-hidden="true">🕒</span><span class="okc-time-text">${safeText}</span><span class="okc-time-dash" aria-hidden="true">-</span>`;
981
+ }
982
+ }
983
+ }
984
+ } catch (e2) {}
961
985
  }
962
986
  } catch (e) {}
963
987
  },
@@ -95,6 +95,14 @@
95
95
  <input class="form-control" name="helloassoFormSlug">
96
96
  </div>
97
97
  </form>
98
+
99
+ <hr class="my-4" />
100
+ <h4>Purge des locations</h4>
101
+ <div class="d-flex gap-2 align-items-center">
102
+ <input class="form-control" style="max-width: 160px;" id="onekite-purge-year" placeholder="YYYY">
103
+ <button type="button" class="btn btn-outline-danger" id="onekite-purge">Purger</button>
104
+ </div>
105
+ <div class="form-text mt-2">Supprime définitivement toutes les locations (réservations) dont la date de début est dans l'année sélectionnée.</div>
98
106
  </div>
99
107
 
100
108
  <div class="tab-pane fade" id="onekite-tab-events" role="tabpanel">
@@ -125,14 +133,6 @@
125
133
  <div class="tab-pane fade" id="onekite-tab-pending" role="tabpanel">
126
134
  <h4>Demandes en attente</h4>
127
135
  <div id="onekite-pending" class="list-group"></div>
128
-
129
- <hr class="my-4" />
130
-
131
- <h4>Purge</h4>
132
- <div class="d-flex gap-2 align-items-center">
133
- <input class="form-control" style="max-width: 160px;" id="onekite-purge-year" placeholder="YYYY">
134
- <button type="button" class="btn btn-outline-danger" id="onekite-purge">Purger</button>
135
- </div>
136
136
  </div>
137
137
 
138
138
  <div class="tab-pane fade" id="onekite-tab-debug" role="tabpanel">