nodebb-plugin-onekite-calendar 1.0.36 → 2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog – calendar-onekite
2
2
 
3
+ ## 1.2.6
4
+ - ACP : anti double action (verrou UI + boutons désactivés/spinner) sur valider/refuser (unitaire + batch)
5
+
6
+ ## 1.2.5
7
+ - Mobile : bouton flottant « + Réserver »
8
+ - Création : anti double-tap/click (verrou actions) + invalidation cache events + refetch léger
9
+ - Calendrier : jours passés / aujourd’hui affichés comme non-sélectionnables (règle visuelle)
10
+ - Popup réservation : raccourcis de durée (1j/2j/3j/7j) + recalcul total + mise à jour des matériels bloqués
11
+ - ACP : actions en batch (valider/refuser une sélection) + compteur de sélection
12
+ - API : idempotence sur valider/refuser + audit enrichi (refusedBy, cancelledByUsername)
13
+
3
14
  ## 1.0.3
4
15
  - Suppression du texte d’archivage dans le toast de purge (plus de « 0 archivés »)
5
16
  - Renommage du plugin : nodebb-plugin-onekite-calendar
package/lib/api.js CHANGED
@@ -553,6 +553,11 @@ api.getReservationDetails = async function (req, res) {
553
553
  const r = await dbLayer.getReservation(rid);
554
554
  if (!r) return res.status(404).json({ error: 'not-found' });
555
555
 
556
+ // Idempotence: if already refused, return ok.
557
+ if (String(r.status) === 'refused') {
558
+ return res.json({ ok: true, status: 'refused' });
559
+ }
560
+
556
561
  const isOwner = String(r.uid) === String(uid);
557
562
  if (!isOwner && !canMod) return res.status(403).json({ error: 'not-allowed' });
558
563
 
@@ -881,7 +886,13 @@ api.approveReservation = async function (req, res) {
881
886
  const rid = req.params.rid;
882
887
  const r = await dbLayer.getReservation(rid);
883
888
  if (!r) return res.status(404).json({ error: 'not-found' });
884
- if (r.status !== 'pending') return res.status(400).json({ error: 'bad-status' });
889
+ // Idempotence: if already approved (awaiting payment or paid), return ok.
890
+ if (r.status !== 'pending') {
891
+ if (['awaiting_payment', 'approved', 'paid'].includes(String(r.status))) {
892
+ return res.json({ ok: true, status: r.status });
893
+ }
894
+ return res.status(400).json({ error: 'bad-status' });
895
+ }
885
896
 
886
897
  r.status = 'awaiting_payment';
887
898
  // Backwards compatible: old clients sent `adminNote` to describe the pickup place.
@@ -994,6 +1005,14 @@ api.refuseReservation = async function (req, res) {
994
1005
  r.status = 'refused';
995
1006
  r.refusedAt = Date.now();
996
1007
  r.refusedReason = String((req.body && (req.body.reason || req.body.refusedReason || req.body.refuseReason)) || '').trim();
1008
+ try {
1009
+ const refuser = await user.getUserFields(uid, ['username']);
1010
+ r.refusedBy = uid;
1011
+ r.refusedByUsername = refuser && refuser.username ? refuser.username : '';
1012
+ } catch (e) {
1013
+ r.refusedBy = uid;
1014
+ r.refusedByUsername = '';
1015
+ }
997
1016
  await dbLayer.saveReservation(r);
998
1017
 
999
1018
  const requesterUid2 = parseInt(r.uid, 10);
@@ -1043,6 +1062,13 @@ api.cancelReservation = async function (req, res) {
1043
1062
  r.cancelledAt = Date.now();
1044
1063
  r.cancelledBy = uid;
1045
1064
 
1065
+ try {
1066
+ const canceller = await user.getUserFields(uid, ['username']);
1067
+ r.cancelledByUsername = canceller && canceller.username ? canceller.username : '';
1068
+ } catch (e) {
1069
+ r.cancelledByUsername = '';
1070
+ }
1071
+
1046
1072
  await dbLayer.saveReservation(r);
1047
1073
 
1048
1074
  // Discord webhook (optional)
@@ -1058,6 +1084,7 @@ api.cancelReservation = async function (req, res) {
1058
1084
  status: r.status,
1059
1085
  cancelledAt: r.cancelledAt,
1060
1086
  cancelledBy: r.cancelledBy,
1087
+ cancelledByUsername: r.cancelledByUsername || '',
1061
1088
  });
1062
1089
  } catch (e) {}
1063
1090
 
@@ -1074,8 +1101,8 @@ api.cancelReservation = async function (req, res) {
1074
1101
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
1075
1102
  itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
1076
1103
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
1077
- cancelledBy: canceller && canceller.username ? canceller.username : '',
1078
- cancelledByUrl: (canceller && canceller.username) ? `${normalizeBaseUrl(meta)}/user/${encodeURIComponent(String(canceller.username))}` : '',
1104
+ cancelledBy: (r.cancelledByUsername || (canceller && canceller.username) || ''),
1105
+ cancelledByUrl: ((r.cancelledByUsername || (canceller && canceller.username)) ? `${normalizeBaseUrl(meta)}/user/${encodeURIComponent(String(r.cancelledByUsername || canceller.username))}` : ''),
1079
1106
  });
1080
1107
  }
1081
1108
  } catch (e) {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "1.0.36",
3
+ "version": "2.0.0",
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/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "1.0.36"
42
+ "version": "2.0.0"
43
43
  }
package/public/admin.js CHANGED
@@ -6,6 +6,58 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
6
6
  // can open rich modals without embedding large JSON blobs into the DOM.
7
7
  const pendingCache = new Map();
8
8
 
9
+ // Prevent double actions (double taps/clicks) in ACP.
10
+ // Keyed by action (eg. approve:RID, refuse:RID, batch:approve).
11
+ const actionLocks = new Set();
12
+
13
+ function setButtonsDisabled(btns, disabled) {
14
+ (btns || []).filter(Boolean).forEach((b) => {
15
+ try {
16
+ b.disabled = !!disabled;
17
+ b.classList.toggle('disabled', !!disabled);
18
+ b.setAttribute('aria-disabled', disabled ? 'true' : 'false');
19
+ } catch (e) {}
20
+ });
21
+ }
22
+
23
+ function setBtnBusy(btn, busy) {
24
+ if (!btn) return;
25
+ try {
26
+ if (busy) {
27
+ if (!btn.dataset.okcOrigHtml) btn.dataset.okcOrigHtml = btn.innerHTML;
28
+ btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right:6px;"></span>' + btn.dataset.okcOrigHtml;
29
+ } else if (btn.dataset.okcOrigHtml) {
30
+ btn.innerHTML = btn.dataset.okcOrigHtml;
31
+ }
32
+ } catch (e) {}
33
+ }
34
+
35
+ async function withLock(lockKey, btns, fn) {
36
+ if (!lockKey) return await fn();
37
+ if (actionLocks.has(lockKey)) return;
38
+ actionLocks.add(lockKey);
39
+ setButtonsDisabled(btns, true);
40
+ // Only show a spinner on the primary button (first in list)
41
+ setBtnBusy(btns && btns[0], true);
42
+ try {
43
+ return await fn();
44
+ } finally {
45
+ setBtnBusy(btns && btns[0], false);
46
+ setButtonsDisabled(btns, false);
47
+ actionLocks.delete(lockKey);
48
+ }
49
+ }
50
+
51
+ function getActiveBootboxFooterButtons() {
52
+ try {
53
+ const modal = document.querySelector('.bootbox.modal.show');
54
+ if (!modal) return [];
55
+ return Array.from(modal.querySelectorAll('.modal-footer button'));
56
+ } catch (e) {
57
+ return [];
58
+ }
59
+ }
60
+
9
61
  function showAlert(type, msg) {
10
62
  // Deduplicate identical alerts that can be triggered multiple times
11
63
  // by NodeBB ACP save buttons/hooks across ajaxify navigations.
@@ -208,6 +260,17 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
208
260
  return;
209
261
  }
210
262
 
263
+ // Batch actions (low volume but handy on mobile / multiple requests)
264
+ const batchBar = document.createElement('div');
265
+ batchBar.className = 'd-flex flex-wrap gap-2 align-items-center mb-2';
266
+ batchBar.innerHTML = `
267
+ <button type="button" class="btn btn-outline-secondary btn-sm" data-action="toggle-all">Tout sélectionner</button>
268
+ <button type="button" class="btn btn-success btn-sm" data-action="approve-selected">Valider sélection</button>
269
+ <button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse-selected">Refuser sélection</button>
270
+ <span class="text-muted" style="font-size:12px;">(<span id="onekite-selected-count">0</span> sélectionnée)</span>
271
+ `;
272
+ wrap.appendChild(batchBar);
273
+
211
274
  const fmtFR = (ts) => {
212
275
  const d = new Date(parseInt(ts, 10));
213
276
  const dd = String(d.getDate()).padStart(2, '0');
@@ -227,10 +290,15 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
227
290
  div.className = 'list-group-item onekite-pending-row';
228
291
  div.innerHTML = `
229
292
  <div class="d-flex justify-content-between align-items-start gap-2">
230
- <div style="min-width: 0;">
293
+ <div style="min-width: 0;" class="d-flex gap-2">
294
+ <div style="padding-top: 2px;">
295
+ <input type="checkbox" class="form-check-input onekite-pending-select" data-rid="${escapeHtml(String(r.rid || ''))}" />
296
+ </div>
297
+ <div style="min-width:0;">
231
298
  <div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong></div>
232
299
  <div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
233
300
  <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>
301
+ </div>
234
302
  </div>
235
303
  <div class="d-flex gap-2">
236
304
  <!-- IMPORTANT: type="button" to avoid submitting the settings form and resetting ACP tabs -->
@@ -241,6 +309,17 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
241
309
  `;
242
310
  wrap.appendChild(div);
243
311
  }
312
+
313
+ // Selected counter
314
+ try {
315
+ const countEl = document.getElementById('onekite-selected-count');
316
+ const refreshCount = () => {
317
+ const n = wrap.querySelectorAll('.onekite-pending-select:checked').length;
318
+ if (countEl) countEl.textContent = String(n);
319
+ };
320
+ wrap.querySelectorAll('.onekite-pending-select').forEach((cb) => cb.addEventListener('change', refreshCount));
321
+ refreshCount();
322
+ } catch (e) {}
244
323
  }
245
324
 
246
325
 
@@ -430,6 +509,24 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
430
509
  const pendingWrap = document.getElementById('onekite-pending');
431
510
  if (pendingWrap && !pendingWrap.dataset.okcBound) {
432
511
  pendingWrap.dataset.okcBound = '1';
512
+
513
+ function selectedRids() {
514
+ return Array.from(pendingWrap.querySelectorAll('input.onekite-pending-select:checked'))
515
+ .map(cb => cb.getAttribute('data-rid'))
516
+ .filter(Boolean);
517
+ }
518
+
519
+ function refreshSelectedCount() {
520
+ const el = document.getElementById('onekite-selected-count');
521
+ if (el) el.textContent = String(selectedRids().length);
522
+ }
523
+
524
+ pendingWrap.addEventListener('change', (ev) => {
525
+ const cb = ev.target && ev.target.closest ? ev.target.closest('input.onekite-pending-select') : null;
526
+ if (!cb) return;
527
+ refreshSelectedCount();
528
+ });
529
+
433
530
  pendingWrap.addEventListener('click', async (ev) => {
434
531
  const btn = ev.target && ev.target.closest('button[data-action]');
435
532
  if (!btn) return;
@@ -441,10 +538,178 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
441
538
  } catch (e) {}
442
539
  const action = btn.getAttribute('data-action');
443
540
  const rid = btn.getAttribute('data-rid');
541
+
542
+ // Prevent accidental double-open of modals on mobile/trackpad double taps
543
+ if (rid && (action === 'approve' || action === 'refuse')) {
544
+ const openKey = `open:${action}:${rid}`;
545
+ if (actionLocks.has(openKey)) return;
546
+ actionLocks.add(openKey);
547
+ setTimeout(() => actionLocks.delete(openKey), 800);
548
+ }
549
+
550
+ // Batch actions (no rid)
551
+ if ((action === 'toggle-all' || action === 'approve-selected' || action === 'refuse-selected') && !rid) {
552
+ const batchBtns = Array.from(pendingWrap.querySelectorAll('button[data-action="toggle-all"], button[data-action="approve-selected"], button[data-action="refuse-selected"]'));
553
+ const allCbs = Array.from(pendingWrap.querySelectorAll('input.onekite-pending-select'));
554
+ if (action === 'toggle-all') {
555
+ const want = allCbs.some(cb => !cb.checked);
556
+ allCbs.forEach(cb => { cb.checked = want; });
557
+ refreshSelectedCount();
558
+ btn.textContent = want ? 'Tout désélectionner' : 'Tout sélectionner';
559
+ return;
560
+ }
561
+
562
+ const rids = selectedRids();
563
+ if (!rids.length) {
564
+ showAlert('error', 'Aucune demande sélectionnée.');
565
+ return;
566
+ }
567
+
568
+ if (action === 'refuse-selected') {
569
+ const html = `
570
+ <div class="mb-3">
571
+ <label class="form-label">Raison du refus (appliquée à toutes les demandes sélectionnées)</label>
572
+ <textarea class="form-control" id="onekite-refuse-reason" rows="3" placeholder="Ex: matériel indisponible, dates impossibles, dossier incomplet..."></textarea>
573
+ </div>
574
+ `;
575
+ await new Promise((resolve) => {
576
+ bootbox.dialog({
577
+ title: `Refuser ${rids.length} demande(s)`,
578
+ message: html,
579
+ buttons: {
580
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(false) },
581
+ ok: {
582
+ label: 'Refuser',
583
+ className: 'btn-danger',
584
+ callback: async () => {
585
+ await withLock(`batch:refuse`, batchBtns.concat(getActiveBootboxFooterButtons()), async () => {
586
+ try {
587
+ const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
588
+ for (const rr of rids) {
589
+ await refuse(rr, { reason });
590
+ }
591
+ showAlert('success', `${rids.length} demande(s) refusée(s).`);
592
+ resolve(true);
593
+ } catch (e) {
594
+ showAlert('error', 'Refus impossible.');
595
+ resolve(false);
596
+ }
597
+ });
598
+ return false;
599
+ },
600
+ },
601
+ },
602
+ });
603
+ });
604
+ await refreshPending();
605
+ return;
606
+ }
607
+
608
+ if (action === 'approve-selected') {
609
+ const opts = timeOptions(5).map(t => `<option value="${t}" ${t === '07:00' ? 'selected' : ''}>${t}</option>`).join('');
610
+ const html = `
611
+ <div class="mb-3">
612
+ <label class="form-label">Adresse de récupération</label>
613
+ <div class="input-group">
614
+ <input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
615
+ <button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
616
+ </div>
617
+ <div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
618
+ <div class="form-text" id="onekite-map-help">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
619
+ <input type="hidden" id="onekite-pickup-lat" />
620
+ <input type="hidden" id="onekite-pickup-lon" />
621
+ </div>
622
+ <div class="mb-3">
623
+ <label class="form-label">Notes (facultatif)</label>
624
+ <textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
625
+ </div>
626
+ <div class="mb-2">
627
+ <label class="form-label">Heure de récupération</label>
628
+ <select class="form-select" id="onekite-pickup-time">${opts}</select>
629
+ </div>
630
+ <div class="text-muted" style="font-size:12px;">Ces infos seront appliquées aux ${rids.length} demandes sélectionnées.</div>
631
+ `;
632
+ const dlg = bootbox.dialog({
633
+ title: `Valider ${rids.length} demande(s)` ,
634
+ message: html,
635
+ buttons: {
636
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
637
+ ok: {
638
+ label: 'Valider',
639
+ className: 'btn-success',
640
+ callback: async () => {
641
+ await withLock(`batch:approve`, batchBtns.concat(getActiveBootboxFooterButtons()), async () => {
642
+ try {
643
+ const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
644
+ const notes = (document.getElementById('onekite-notes')?.value || '').trim();
645
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
646
+ const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
647
+ const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
648
+ for (const rr of rids) {
649
+ await approve(rr, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
650
+ }
651
+ showAlert('success', `${rids.length} demande(s) validée(s).`);
652
+ await refreshPending();
653
+ } catch (e) {
654
+ showAlert('error', 'Validation impossible.');
655
+ }
656
+ });
657
+ },
658
+ },
659
+ },
660
+ });
661
+
662
+ // Init Leaflet map once the modal is visible.
663
+ dlg.on('shown.bs.modal', async () => {
664
+ try {
665
+ const L = await loadLeaflet();
666
+ const mapEl = document.getElementById('onekite-map');
667
+ if (!mapEl) return;
668
+ const map = L.map(mapEl).setView([45.7640, 4.8357], 12);
669
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
670
+ maxZoom: 19,
671
+ attribution: '&copy; OpenStreetMap contributors',
672
+ }).addTo(map);
673
+ const marker = L.marker([45.7640, 4.8357], { draggable: true }).addTo(map);
674
+
675
+ function setLatLon(lat, lon) {
676
+ const latEl = document.getElementById('onekite-pickup-lat');
677
+ const lonEl = document.getElementById('onekite-pickup-lon');
678
+ if (latEl) latEl.value = String(lat);
679
+ if (lonEl) lonEl.value = String(lon);
680
+ }
681
+ setLatLon(45.7640, 4.8357);
682
+
683
+ marker.on('dragend', () => {
684
+ const p = marker.getLatLng();
685
+ setLatLon(p.lat, p.lng);
686
+ });
687
+
688
+ const btnGeocode = document.getElementById('onekite-geocode');
689
+ if (btnGeocode) {
690
+ btnGeocode.addEventListener('click', async () => {
691
+ try {
692
+ const addr = (document.getElementById('onekite-pickup-address')?.value || '').trim();
693
+ const hit = await geocodeAddress(addr);
694
+ if (!hit) return;
695
+ marker.setLatLng([hit.lat, hit.lon]);
696
+ map.setView([hit.lat, hit.lon], 16);
697
+ setLatLon(hit.lat, hit.lon);
698
+ } catch (e) {}
699
+ });
700
+ }
701
+ setTimeout(() => { try { map.invalidateSize(); } catch (e) {} }, 250);
702
+ } catch (e) {}
703
+ });
704
+ return;
705
+ }
706
+ }
707
+
444
708
  if (!rid) return;
445
709
 
446
710
  // Remove the row immediately on success for a snappier UX
447
711
  const rowEl = btn.closest('tr') || btn.closest('.onekite-pending-row');
712
+ const rowBtns = rowEl ? Array.from(rowEl.querySelectorAll('button[data-action="approve"],button[data-action="refuse"]')) : [btn];
448
713
 
449
714
  try {
450
715
  if (action === 'refuse') {
@@ -464,16 +729,18 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
464
729
  label: 'Refuser',
465
730
  className: 'btn-danger',
466
731
  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
- }
732
+ await withLock(`refuse:${rid}`, rowBtns.concat(getActiveBootboxFooterButtons()), async () => {
733
+ try {
734
+ const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
735
+ await refuse(rid, { reason });
736
+ if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
737
+ showAlert('success', 'Demande refusée.');
738
+ resolve(true);
739
+ } catch (e) {
740
+ showAlert('error', 'Refus impossible.');
741
+ resolve(false);
742
+ }
743
+ });
477
744
  return false;
478
745
  },
479
746
  },
@@ -530,19 +797,21 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
530
797
  label: 'Valider',
531
798
  className: 'btn-success',
532
799
  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
- }
800
+ await withLock(`approve:${rid}`, rowBtns.concat(getActiveBootboxFooterButtons()), async () => {
801
+ try {
802
+ const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
803
+ const notes = (document.getElementById('onekite-notes')?.value || '').trim();
804
+ const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
805
+ const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
806
+ const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
807
+ await approve(rid, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
808
+ if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
809
+ showAlert('success', 'Demande validée.');
810
+ await refreshPending();
811
+ } catch (e) {
812
+ showAlert('error', 'Validation impossible.');
813
+ }
814
+ });
546
815
  return false;
547
816
  },
548
817
  },
package/public/client.js CHANGED
@@ -30,6 +30,17 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
30
30
  .fc {
31
31
  touch-action: manipulation;
32
32
  }
33
+
34
+ /* Show days that cannot be selected for new reservations (today & past) */
35
+ .fc .onekite-day-disabled {
36
+ background: rgba(0, 0, 0, 0.03);
37
+ }
38
+ .fc .onekite-day-disabled .fc-daygrid-day-number {
39
+ opacity: 0.5;
40
+ }
41
+ .fc .onekite-day-disabled:hover {
42
+ cursor: not-allowed;
43
+ }
33
44
  `;
34
45
  document.head.appendChild(style);
35
46
  } catch (e) {
@@ -41,6 +52,17 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
41
52
  // interactions or quick re-renders.
42
53
  let isDialogOpen = false;
43
54
 
55
+ // Prevent double taps/clicks on actions that trigger network calls (mobile especially).
56
+ const actionLocks = new Map(); // key -> ts
57
+ function lockAction(key, ms) {
58
+ const ttl = Math.max(0, parseInt(ms, 10) || 900);
59
+ const now = Date.now();
60
+ const last = actionLocks.get(key) || 0;
61
+ if (now - last < ttl) return false;
62
+ actionLocks.set(key, now);
63
+ return true;
64
+ }
65
+
44
66
  // Avoid duplicate toasts when the same validation fails both client-side and
45
67
  // server-side (e.g. "today or past" date rule).
46
68
  let lastDateRuleToastAt = 0;
@@ -446,6 +468,16 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
446
468
  // Simple in-memory cache for JSON endpoints (used for events prefetch + ETag).
447
469
  const jsonCache = new Map(); // url -> { etag, data, ts }
448
470
 
471
+ function invalidateEventsCache() {
472
+ try { jsonCache.clear(); } catch (e) {}
473
+ try {
474
+ if (window.__onekiteEventsAbort) {
475
+ window.__onekiteEventsAbort.abort();
476
+ window.__onekiteEventsAbort = null;
477
+ }
478
+ } catch (e) {}
479
+ }
480
+
449
481
  function scheduleRefetch(cal) {
450
482
  try {
451
483
  if (!cal || typeof cal.refetchEvents !== 'function') return;
@@ -782,16 +814,19 @@ function toDatetimeLocalValue(date) {
782
814
 
783
815
  async function openReservationDialog(selectionInfo, items) {
784
816
  const start = selectionInfo.start;
785
- const end = selectionInfo.end;
817
+ let end = selectionInfo.end;
786
818
 
787
819
  // days (end is exclusive in FullCalendar)
788
820
  const msPerDay = 24 * 60 * 60 * 1000;
789
- const days = Math.max(1, Math.round((end.getTime() - start.getTime()) / msPerDay));
821
+ let days = Math.max(1, Math.round((end.getTime() - start.getTime()) / msPerDay));
790
822
 
791
823
  // Fetch existing events overlapping the selection to disable already reserved items.
792
824
  let blocked = new Set();
793
825
  try {
794
- const qs = new URLSearchParams({ start: selectionInfo.startStr, end: selectionInfo.endStr });
826
+ const qs = new URLSearchParams({
827
+ start: (selectionInfo.startStr || (start instanceof Date ? start.toISOString() : String(start))),
828
+ end: (selectionInfo.endStr || (end instanceof Date ? end.toISOString() : String(end))),
829
+ });
795
830
  const evs = await fetchJson(`/api/v3/plugins/calendar-onekite/events?${qs.toString()}`);
796
831
  (evs || []).forEach((ev) => {
797
832
  const st = (ev.extendedProps && ev.extendedProps.status) || '';
@@ -815,9 +850,9 @@ function toDatetimeLocalValue(date) {
815
850
  const priceTxt = fmtPrice(it.price || 0);
816
851
  const safeName = String(it.name).replace(/</g, '&lt;').replace(/>/g, '&gt;');
817
852
  return `
818
- <div class="d-flex align-items-center py-1 ${disabled ? 'opacity-50' : ''}" style="border-bottom: 1px solid var(--bs-border-color, #ddd);">
853
+ <div class="d-flex align-items-center py-1 ${disabled ? 'opacity-50' : ''}" data-itemid="${id}" style="border-bottom: 1px solid var(--bs-border-color, #ddd);">
819
854
  <div style="width: 26px;">
820
- ${disabled ? '' : `<input type="checkbox" class="form-check-input onekite-item-cb" data-id="${id}" data-name="${safeName}" data-price="${String(it.price || 0)}">`}
855
+ <input type="checkbox" class="form-check-input onekite-item-cb" data-id="${id}" data-name="${safeName}" data-price="${String(it.price || 0)}" ${disabled ? 'disabled' : ''}>
821
856
  </div>
822
857
  <div class="flex-grow-1">
823
858
  <div><strong>${safeName}</strong></div>
@@ -827,8 +862,25 @@ function toDatetimeLocalValue(date) {
827
862
  `;
828
863
  }).join('');
829
864
 
865
+ const shortcutsHtml = (() => {
866
+ // Only show shortcuts when selection is a single day (common mobile flow)
867
+ if (days !== 1) return '';
868
+ return `
869
+ <div class="mb-2" id="onekite-duration-shortcuts">
870
+ <div class="text-muted" style="font-size:12px; margin-bottom:6px;">Durée rapide</div>
871
+ <div class="btn-group btn-group-sm" role="group">
872
+ <button type="button" class="btn btn-outline-secondary" data-days="1">1j</button>
873
+ <button type="button" class="btn btn-outline-secondary" data-days="2">2j</button>
874
+ <button type="button" class="btn btn-outline-secondary" data-days="3">3j</button>
875
+ <button type="button" class="btn btn-outline-secondary" data-days="7">7j</button>
876
+ </div>
877
+ </div>
878
+ `;
879
+ })();
880
+
830
881
  const messageHtml = `
831
- <div class="mb-2"><strong>Période</strong><br>${formatDt(start)} → ${formatDt(end)} <span class="text-muted">(${days} jour${days > 1 ? 's' : ''})</span></div>
882
+ <div class="mb-2" id="onekite-period"><strong>Période</strong><br>${formatDt(start)} → ${formatDt(end)} <span class="text-muted" id="onekite-days">(${days} jour${days > 1 ? 's' : ''})</span></div>
883
+ ${shortcutsHtml}
832
884
  <div class="mb-2"><strong>Matériel</strong></div>
833
885
  <div id="onekite-items" class="mb-2" style="max-height: 320px; overflow: auto; border: 1px solid var(--bs-border-color, #ddd); border-radius: 6px; padding: 6px;">
834
886
  ${rows}
@@ -880,6 +932,47 @@ function toDatetimeLocalValue(date) {
880
932
  // live total update
881
933
  setTimeout(() => {
882
934
  const totalEl = document.getElementById('onekite-total');
935
+ const periodEl = document.getElementById('onekite-period');
936
+ const daysEl = document.getElementById('onekite-days');
937
+
938
+ function rangeQuery(s, e) {
939
+ try {
940
+ const sIso = (s instanceof Date) ? s.toISOString() : new Date(s).toISOString();
941
+ const eIso = (e instanceof Date) ? e.toISOString() : new Date(e).toISOString();
942
+ return new URLSearchParams({ start: sIso, end: eIso }).toString();
943
+ } catch (err) {
944
+ return '';
945
+ }
946
+ }
947
+
948
+ async function refreshBlocked() {
949
+ try {
950
+ const qs = rangeQuery(start, end);
951
+ if (!qs) return;
952
+ const evs = await fetchJson(`/api/v3/plugins/calendar-onekite/events?${qs}`);
953
+ blocked = new Set();
954
+ (evs || []).forEach((ev) => {
955
+ const st = (ev.extendedProps && ev.extendedProps.status) || '';
956
+ if (!['pending', 'awaiting_payment', 'approved', 'paid'].includes(st)) return;
957
+ const ids = (ev.extendedProps && ev.extendedProps.itemIds) || (ev.extendedProps && ev.extendedProps.itemId ? [ev.extendedProps.itemId] : []);
958
+ ids.forEach((id) => blocked.add(String(id)));
959
+ });
960
+
961
+ document.querySelectorAll('#onekite-items [data-itemid]').forEach((row) => {
962
+ const iid = row.getAttribute('data-itemid');
963
+ const dis = blocked.has(String(iid));
964
+ row.classList.toggle('opacity-50', dis);
965
+ const cb = row.querySelector('input.onekite-item-cb');
966
+ if (cb) {
967
+ cb.disabled = dis;
968
+ if (dis) cb.checked = false;
969
+ }
970
+ });
971
+ } catch (e) {
972
+ // ignore
973
+ }
974
+ }
975
+
883
976
  function refreshTotal() {
884
977
  const cbs = Array.from(document.querySelectorAll('.onekite-item-cb')).filter(cb => cb.checked);
885
978
  const sum = cbs.reduce((acc, cb) => acc + (parseFloat(cb.getAttribute('data-price') || '0') || 0), 0);
@@ -890,6 +983,31 @@ function toDatetimeLocalValue(date) {
890
983
  }
891
984
  document.querySelectorAll('.onekite-item-cb').forEach(cb => cb.addEventListener('change', refreshTotal));
892
985
  refreshTotal();
986
+
987
+ // Duration shortcuts (single-day selections): update end/days + refresh blocked items
988
+ const shortcutsWrap = document.getElementById('onekite-duration-shortcuts');
989
+ if (shortcutsWrap) {
990
+ shortcutsWrap.addEventListener('click', async (ev) => {
991
+ const btn = ev.target && ev.target.closest ? ev.target.closest('button[data-days]') : null;
992
+ if (!btn) return;
993
+ const nd = parseInt(btn.getAttribute('data-days'), 10);
994
+ if (!Number.isFinite(nd) || nd < 1) return;
995
+ // end is exclusive -> add nd days to start
996
+ end = new Date(start);
997
+ end.setDate(end.getDate() + nd);
998
+ days = nd;
999
+ if (daysEl) {
1000
+ daysEl.textContent = `(${days} jour${days > 1 ? 's' : ''})`;
1001
+ }
1002
+ if (periodEl) {
1003
+ periodEl.innerHTML = `<strong>Période</strong><br>${formatDt(start)} → ${formatDt(end)} <span class="text-muted" id="onekite-days">(${days} jour${days > 1 ? 's' : ''})</span>`;
1004
+ }
1005
+ // Toggle active button
1006
+ shortcutsWrap.querySelectorAll('button[data-days]').forEach((b) => b.classList.toggle('active', b === btn));
1007
+ await refreshBlocked();
1008
+ refreshTotal();
1009
+ });
1010
+ }
893
1011
  }, 0);
894
1012
  });
895
1013
  }
@@ -1080,6 +1198,10 @@ function toDatetimeLocalValue(date) {
1080
1198
  if (isDialogOpen) {
1081
1199
  return;
1082
1200
  }
1201
+ // Avoid double-taps creating two dialogs / two requests.
1202
+ if (!lockAction('create', 900)) {
1203
+ return;
1204
+ }
1083
1205
  isDialogOpen = true;
1084
1206
  try {
1085
1207
  if (mode === 'special' && canCreateSpecial) {
@@ -1099,7 +1221,8 @@ function toDatetimeLocalValue(date) {
1099
1221
  });
1100
1222
  });
1101
1223
  showAlert('success', 'Évènement créé.');
1102
- calendar.refetchEvents();
1224
+ invalidateEventsCache();
1225
+ scheduleRefetch(calendar);
1103
1226
  calendar.unselect();
1104
1227
  isDialogOpen = false;
1105
1228
  return;
@@ -1144,7 +1267,8 @@ function toDatetimeLocalValue(date) {
1144
1267
  total: chosen.total,
1145
1268
  });
1146
1269
  showAlert('success', 'Demande envoyée (en attente de validation).');
1147
- calendar.refetchEvents();
1270
+ invalidateEventsCache();
1271
+ scheduleRefetch(calendar);
1148
1272
  calendar.unselect();
1149
1273
  isDialogOpen = false;
1150
1274
  } catch (e) {
@@ -1210,6 +1334,19 @@ function toDatetimeLocalValue(date) {
1210
1334
  // Mobile: make selection responsive on touch
1211
1335
  longPressDelay: 300,
1212
1336
  selectLongPressDelay: 300,
1337
+ dayCellDidMount: function (arg) {
1338
+ // Visually disable today and past days for reservation creation rules,
1339
+ // without breaking event clicks.
1340
+ try {
1341
+ const cellDate = arg && arg.date ? new Date(arg.date) : null;
1342
+ if (!cellDate) return;
1343
+ const ymd = toLocalYmd(cellDate);
1344
+ const today = toLocalYmd(new Date());
1345
+ if (ymd <= today) {
1346
+ arg.el.classList.add('onekite-day-disabled');
1347
+ }
1348
+ } catch (e) {}
1349
+ },
1213
1350
  events: async function (info, successCallback, failureCallback) {
1214
1351
  try {
1215
1352
  // Abort previous in-flight events fetch to avoid "double refresh" effects.
@@ -1461,9 +1598,11 @@ function toDatetimeLocalValue(date) {
1461
1598
  className: 'btn-outline-warning',
1462
1599
  callback: async () => {
1463
1600
  try {
1601
+ if (!lockAction(`cancel:${rid}`, 1200)) return false;
1464
1602
  await cancelReservation(rid);
1465
1603
  showAlert('success', 'Réservation annulée.');
1466
- calendar.refetchEvents();
1604
+ invalidateEventsCache();
1605
+ scheduleRefetch(calendar);
1467
1606
  } catch (e) {
1468
1607
  showAlert('error', 'Annulation impossible.');
1469
1608
  }
@@ -1493,9 +1632,11 @@ function toDatetimeLocalValue(date) {
1493
1632
  callback: async () => {
1494
1633
  try {
1495
1634
  const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
1635
+ if (!lockAction(`refuse:${rid}`, 1200)) return false;
1496
1636
  await refuseReservation(rid, { reason });
1497
1637
  showAlert('success', 'Demande refusée.');
1498
- calendar.refetchEvents();
1638
+ invalidateEventsCache();
1639
+ scheduleRefetch(calendar);
1499
1640
  resolve(true);
1500
1641
  } catch (e) {
1501
1642
  showAlert('error', 'Refus impossible.');
@@ -1565,9 +1706,11 @@ function toDatetimeLocalValue(date) {
1565
1706
  const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
1566
1707
  const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
1567
1708
  const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
1709
+ if (!lockAction(`approve:${rid}`, 1200)) return false;
1568
1710
  await approveReservation(rid, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
1569
1711
  showAlert('success', 'Demande validée.');
1570
- calendar.refetchEvents();
1712
+ invalidateEventsCache();
1713
+ scheduleRefetch(calendar);
1571
1714
  } catch (e) {
1572
1715
  showAlert('error', 'Validation impossible.');
1573
1716
  }
@@ -1683,6 +1826,39 @@ function toDatetimeLocalValue(date) {
1683
1826
 
1684
1827
  refreshDesktopModeButton();
1685
1828
 
1829
+ // Mobile: floating action button to create a reservation quickly
1830
+ try {
1831
+ const fabId = 'onekite-fab-create';
1832
+ const existing = document.getElementById(fabId);
1833
+ if (existing) existing.remove();
1834
+ if (isMobileNow()) {
1835
+ const fab = document.createElement('button');
1836
+ fab.id = fabId;
1837
+ fab.type = 'button';
1838
+ fab.className = 'btn btn-primary shadow';
1839
+ fab.textContent = '+ Réserver';
1840
+ fab.style.position = 'fixed';
1841
+ fab.style.right = '14px';
1842
+ fab.style.bottom = '14px';
1843
+ fab.style.zIndex = '1050';
1844
+ fab.style.borderRadius = '999px';
1845
+ fab.style.padding = '12px 16px';
1846
+ fab.addEventListener('click', async () => {
1847
+ try {
1848
+ if (isDialogOpen) return;
1849
+ if (!lockAction('fab', 900)) return;
1850
+ const start = new Date();
1851
+ start.setHours(0, 0, 0, 0);
1852
+ start.setDate(start.getDate() + 1);
1853
+ const end = new Date(start);
1854
+ end.setDate(end.getDate() + 1);
1855
+ await handleCreateFromSelection({ start, end, allDay: true });
1856
+ } catch (e) {}
1857
+ });
1858
+ document.body.appendChild(fab);
1859
+ }
1860
+ } catch (e) {}
1861
+
1686
1862
 
1687
1863
  // Mobile controls: view (month/week) + mode (reservation/event) without bloating the header.
1688
1864
  try {