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 +11 -0
- package/lib/api.js +30 -3
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/admin.js +293 -24
- package/public/client.js +187 -11
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 (
|
|
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:
|
|
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
package/plugin.json
CHANGED
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: '© 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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
817
|
+
let end = selectionInfo.end;
|
|
786
818
|
|
|
787
819
|
// days (end is exclusive in FullCalendar)
|
|
788
820
|
const msPerDay = 24 * 60 * 60 * 1000;
|
|
789
|
-
|
|
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({
|
|
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, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|