nodebb-plugin-equipment-calendar 1.0.7 → 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/library.js +30 -41
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/js/admin.js +2 -2
- package/public/js/client.js +144 -110
- package/public/templates/admin/plugins/equipment-calendar.tpl +2 -17
- package/public/templates/equipment-calendar/calendar.tpl +14 -55
package/library.js
CHANGED
|
@@ -27,12 +27,12 @@ const DEFAULT_SETTINGS = {
|
|
|
27
27
|
creatorGroups: 'registered-users',
|
|
28
28
|
approverGroup: 'administrators',
|
|
29
29
|
notifyGroup: 'administrators',
|
|
30
|
-
// JSON array of items: [{ "id": "cam1", "name": "Caméra A", "
|
|
30
|
+
// JSON array of items: [{ "id": "cam1", "name": "Caméra A", "priceCents": 5000, "location": "Stock A", "active": true }]
|
|
31
31
|
itemsJson: '[]',
|
|
32
|
-
|
|
32
|
+
itemsSource: 'manual',
|
|
33
33
|
ha_itemsFormType: '',
|
|
34
34
|
ha_itemsFormSlug: '',
|
|
35
|
-
|
|
35
|
+
ha_locationMapJson: '{}',
|
|
36
36
|
ha_calendarItemNamePrefix: 'Location matériel',
|
|
37
37
|
paymentTimeoutMinutes: 10,
|
|
38
38
|
// HelloAsso
|
|
@@ -49,11 +49,18 @@ const DEFAULT_SETTINGS = {
|
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
function parseLocationMap(locationMapJson) {
|
|
53
|
+
try {
|
|
54
|
+
const obj = JSON.parse(locationMapJson || '{}');
|
|
55
|
+
return (obj && typeof obj === 'object') ? obj : {};
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
52
60
|
|
|
53
61
|
let haTokenCache = null; // { accessToken, refreshToken, expMs }
|
|
54
62
|
|
|
55
|
-
async function getHelloAssoAccessToken(settings,
|
|
56
|
-
saved, opts = {}) {
|
|
63
|
+
async function getHelloAssoAccessToken(settings, opts = {}) {
|
|
57
64
|
const now = Date.now();
|
|
58
65
|
if (!opts.force && haTokenCache && haTokenCache.accessToken && haTokenCache.expMs && now < haTokenCache.expMs - 30_000) {
|
|
59
66
|
return haTokenCache.accessToken;
|
|
@@ -114,8 +121,7 @@ async function getHelloAssoAccessToken(settings,
|
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
|
|
117
|
-
async function createHelloAssoCheckoutIntent(settings,
|
|
118
|
-
saved, bookingId, reservations) {
|
|
124
|
+
async function createHelloAssoCheckoutIntent(settings, bookingId, reservations) {
|
|
119
125
|
const org = String(settings.ha_organizationSlug || '').trim();
|
|
120
126
|
if (!org) throw new Error('HelloAsso organization slug missing');
|
|
121
127
|
|
|
@@ -129,11 +135,9 @@ async function createHelloAssoCheckoutIntent(settings,
|
|
|
129
135
|
items.forEach(i => { byId[i.id] = i; });
|
|
130
136
|
|
|
131
137
|
const names = reservations.map(r => (byId[r.itemId] && byId[r.itemId].name) || r.itemId);
|
|
132
|
-
const days = reservations.length ? Math.max(1, Math.round((reservations[0].endMs - reservations[0].startMs) / (24*60*60*1000))) : 1;
|
|
133
138
|
const totalAmount = reservations.reduce((sum, r) => {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
return sum + (priceCents * days);
|
|
139
|
+
const price = (byId[r.itemId] && parseInt(byId[r.itemId].priceCents, 10)) || 0;
|
|
140
|
+
return sum + (Number.isFinite(price) ? price : 0);
|
|
137
141
|
}, 0);
|
|
138
142
|
|
|
139
143
|
if (!totalAmount || totalAmount <= 0) {
|
|
@@ -176,8 +180,7 @@ async function createHelloAssoCheckoutIntent(settings,
|
|
|
176
180
|
return await resp.json();
|
|
177
181
|
}
|
|
178
182
|
|
|
179
|
-
async function fetchHelloAssoCheckoutIntent(settings,
|
|
180
|
-
saved, checkoutIntentId) {
|
|
183
|
+
async function fetchHelloAssoCheckoutIntent(settings, checkoutIntentId) {
|
|
181
184
|
const org = String(settings.ha_organizationSlug || '').trim();
|
|
182
185
|
const token = await getHelloAssoAccessToken(settings);
|
|
183
186
|
const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents/${encodeURIComponent(checkoutIntentId)}`;
|
|
@@ -245,8 +248,8 @@ async function fetchHelloAssoItems(settings) {
|
|
|
245
248
|
const name = String(it.name || it.label || it.title || '').trim() || (tierName ? String(tierName) : id);
|
|
246
249
|
if (!id || !name) return;
|
|
247
250
|
const amount = it.amount || it.price || it.unitPrice || it.totalAmount || it.initialAmount;
|
|
248
|
-
const
|
|
249
|
-
out.push({ id, name,
|
|
251
|
+
const priceCents = (typeof amount === 'number' ? amount : parseInt(amount, 10)) || 0;
|
|
252
|
+
out.push({ id, name, priceCents: String(priceCents), location: '' });
|
|
250
253
|
}
|
|
251
254
|
|
|
252
255
|
// Try a few known layouts
|
|
@@ -280,14 +283,12 @@ async function fetchHelloAssoItems(settings) {
|
|
|
280
283
|
async function getActiveItems() {
|
|
281
284
|
const settings = await getSettings();
|
|
282
285
|
const rawItems = await fetchHelloAssoItems(settings);
|
|
283
|
-
// Normalize
|
|
284
286
|
const items = (Array.isArray(rawItems) ? rawItems : []).map((it) => ({
|
|
285
287
|
id: String(it.id || '').trim(),
|
|
286
288
|
name: String(it.name || '').trim(),
|
|
287
289
|
price: String(it.price || '0').trim(),
|
|
288
290
|
active: true,
|
|
289
291
|
})).filter(it => it.id && it.name);
|
|
290
|
-
|
|
291
292
|
return items;
|
|
292
293
|
}
|
|
293
294
|
|
|
@@ -480,15 +481,14 @@ async function helloAssoGetAccessToken(settings) {
|
|
|
480
481
|
return resp.data.access_token;
|
|
481
482
|
}
|
|
482
483
|
|
|
483
|
-
async function helloAssoCreateCheckout(settings,
|
|
484
|
-
saved, token, reservation, item) {
|
|
484
|
+
async function helloAssoCreateCheckout(settings, token, reservation, item) {
|
|
485
485
|
// Minimal: create a checkout intent and return redirectUrl
|
|
486
486
|
// This endpoint/payload may need adaptation depending on your HelloAsso setup.
|
|
487
487
|
const org = settings.ha_organizationSlug;
|
|
488
488
|
if (!org) throw new Error('HelloAsso organizationSlug missing');
|
|
489
489
|
|
|
490
490
|
const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
|
|
491
|
-
const amountCents = Math.max(0, Number(item.
|
|
491
|
+
const amountCents = Math.max(0, Number(item.priceCents || 0));
|
|
492
492
|
|
|
493
493
|
const payload = {
|
|
494
494
|
totalAmount: amountCents,
|
|
@@ -583,9 +583,8 @@ plugin.init = async function (params) {
|
|
|
583
583
|
router.post('/admin/plugins/equipment-calendar/reservations/:rid/delete', middleware.applyCSRF, handleAdminDelete);
|
|
584
584
|
}
|
|
585
585
|
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
router.get('/api/calendar', middleware.buildHeader, renderCalendarPage);
|
|
586
|
+
// Convenience alias (optional): /calendar -> /equipment/calendar
|
|
587
|
+
router.get('/calendar', (req, res) => res.redirect('/equipment/calendar'));
|
|
589
588
|
|
|
590
589
|
|
|
591
590
|
// To verify webhook signature we need raw body; add a rawBody collector for this route only
|
|
@@ -674,7 +673,6 @@ async function renderAdminReservationsPage(req, res) {
|
|
|
674
673
|
if (!(await ensureIsAdmin(req, res))) return;
|
|
675
674
|
|
|
676
675
|
const settings = await getSettings();
|
|
677
|
-
const saved = false;
|
|
678
676
|
const items = await getActiveItems(settings);
|
|
679
677
|
const itemById = {};
|
|
680
678
|
items.forEach(it => { itemById[it.id] = it; });
|
|
@@ -741,7 +739,6 @@ async function renderAdminReservationsPage(req, res) {
|
|
|
741
739
|
res.render('admin/plugins/equipment-calendar-reservations', {
|
|
742
740
|
title: 'Equipment Calendar - Réservations',
|
|
743
741
|
settings,
|
|
744
|
-
saved,
|
|
745
742
|
rows: pageRows,
|
|
746
743
|
hasRows: pageRows.length > 0,
|
|
747
744
|
itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
|
|
@@ -813,8 +810,7 @@ async function handleHelloAssoTest(req, res) {
|
|
|
813
810
|
const clear = String(req.query.clear || '') === '1';
|
|
814
811
|
// force=1 skips in-memory cache and refresh_token; clear=1 wipes stored refresh token
|
|
815
812
|
haTokenCache = null;
|
|
816
|
-
await getHelloAssoAccessToken(settings,
|
|
817
|
-
{ force, clearStored: clear });
|
|
813
|
+
await getHelloAssoAccessToken(settings, { force, clearStored: clear });
|
|
818
814
|
const items = await fetchHelloAssoItems(settings);
|
|
819
815
|
const list = Array.isArray(items) ? items : (Array.isArray(items.data) ? items.data : []);
|
|
820
816
|
count = list.length;
|
|
@@ -837,7 +833,7 @@ async function handleHelloAssoTest(req, res) {
|
|
|
837
833
|
message,
|
|
838
834
|
count,
|
|
839
835
|
settings,
|
|
840
|
-
sampleItems,
|
|
836
|
+
sampleItems,
|
|
841
837
|
hasSampleItems,
|
|
842
838
|
hasSampleItems: sampleItems && sampleItems.length > 0,
|
|
843
839
|
});
|
|
@@ -845,11 +841,9 @@ sampleItems,
|
|
|
845
841
|
|
|
846
842
|
async function renderAdminPage(req, res) {
|
|
847
843
|
const settings = await getSettings();
|
|
848
|
-
const saved = String(req.query.saved || '') === '1';
|
|
849
844
|
res.render('admin/plugins/equipment-calendar', {
|
|
850
845
|
title: 'Equipment Calendar',
|
|
851
846
|
settings,
|
|
852
|
-
saved,
|
|
853
847
|
saved: req.query && String(req.query.saved || '') === '1',
|
|
854
848
|
purged: req.query && parseInt(req.query.purged, 10) || 0,
|
|
855
849
|
view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
|
|
@@ -874,8 +868,7 @@ async function handleHelloAssoCallback(req, res) {
|
|
|
874
868
|
|
|
875
869
|
try {
|
|
876
870
|
if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
|
|
877
|
-
const checkout = await fetchHelloAssoCheckoutIntent(settings,
|
|
878
|
-
saved, checkoutIntentId);
|
|
871
|
+
const checkout = await fetchHelloAssoCheckoutIntent(settings, checkoutIntentId);
|
|
879
872
|
|
|
880
873
|
const paid = isCheckoutPaid(checkout);
|
|
881
874
|
if (paid) {
|
|
@@ -1125,8 +1118,7 @@ async function renderApprovalsPage(req, res) {
|
|
|
1125
1118
|
|
|
1126
1119
|
// --- Actions ---
|
|
1127
1120
|
|
|
1128
|
-
async function createReservationForItem(req, res, settings,
|
|
1129
|
-
saved, itemId, startMs, endMs, notesUser, bookingId) {
|
|
1121
|
+
async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId) {
|
|
1130
1122
|
const items = await getActiveItems(settings);
|
|
1131
1123
|
const item = items.find(i => i.id === itemId);
|
|
1132
1124
|
if (!item) {
|
|
@@ -1170,8 +1162,7 @@ async function handleCreateReservation(req, res) {
|
|
|
1170
1162
|
const itemIdsRaw = String(req.body.itemIds || req.body.itemId || '').trim();
|
|
1171
1163
|
const itemIds = itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
1172
1164
|
if (!itemIds.length) {
|
|
1173
|
-
|
|
1174
|
-
return res.redirect('/equipment/calendar');
|
|
1165
|
+
return res.status(400).send('itemId required');
|
|
1175
1166
|
}
|
|
1176
1167
|
const item = items.find(i => i.id === itemId);
|
|
1177
1168
|
if (!item) return res.status(400).send('Invalid item');
|
|
@@ -1251,8 +1242,7 @@ async function handleApproveReservation(req, res) {
|
|
|
1251
1242
|
return res.status(400).send('HelloAsso not configured');
|
|
1252
1243
|
}
|
|
1253
1244
|
const token = await helloAssoGetAccessToken(settings);
|
|
1254
|
-
const checkout = await helloAssoCreateCheckout(settings,
|
|
1255
|
-
saved, token, reservation, item);
|
|
1245
|
+
const checkout = await helloAssoCreateCheckout(settings, token, reservation, item);
|
|
1256
1246
|
reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
|
|
1257
1247
|
reservation.ha_paymentUrl = checkout.paymentUrl;
|
|
1258
1248
|
}
|
|
@@ -1403,7 +1393,7 @@ async function handleAdminSave(req, res) {
|
|
|
1403
1393
|
itemsJson: '[]',
|
|
1404
1394
|
ha_itemsFormType: String(req.body.ha_itemsFormType || DEFAULT_SETTINGS.ha_itemsFormType),
|
|
1405
1395
|
ha_itemsFormSlug: String(req.body.ha_itemsFormSlug || DEFAULT_SETTINGS.ha_itemsFormSlug),
|
|
1406
|
-
|
|
1396
|
+
ha_locationMapJson: String(req.body.ha_locationMapJson || DEFAULT_SETTINGS.ha_locationMapJson),
|
|
1407
1397
|
ha_clientId: String(req.body.ha_clientId || ''),
|
|
1408
1398
|
ha_clientSecret: String(req.body.ha_clientSecret || ''),
|
|
1409
1399
|
ha_organizationSlug: String(req.body.ha_organizationSlug || ''),
|
|
@@ -1413,8 +1403,7 @@ async function handleAdminSave(req, res) {
|
|
|
1413
1403
|
timezone: String(req.body.timezone || DEFAULT_SETTINGS.timezone),
|
|
1414
1404
|
paymentTimeoutMinutes: String(req.body.paymentTimeoutMinutes || DEFAULT_SETTINGS.paymentTimeoutMinutes),
|
|
1415
1405
|
showRequesterToAll: (req.body.showRequesterToAll === '1' || req.body.showRequesterToAll === 'on') ? '1' : '0',
|
|
1416
|
-
|
|
1417
|
-
ha_apiBaseUrl: String(req.body.ha_apiBaseUrl || DEFAULT_SETTINGS.ha_apiBaseUrl),};
|
|
1406
|
+
};
|
|
1418
1407
|
|
|
1419
1408
|
await meta.settings.set(SETTINGS_KEY, values);
|
|
1420
1409
|
return res.redirect('/admin/plugins/equipment-calendar?saved=1');
|
package/package.json
CHANGED
package/plugin.json
CHANGED
package/public/js/admin.js
CHANGED
|
@@ -6,10 +6,10 @@ define('admin/plugins/equipment-calendar', ['jquery', 'settings', 'alerts'], fun
|
|
|
6
6
|
const Admin = {};
|
|
7
7
|
|
|
8
8
|
Admin.init = function () {
|
|
9
|
-
const $container =
|
|
9
|
+
const $container = $('.equipment-calendar-settings');
|
|
10
10
|
Settings.load('equipmentCalendar', $container);
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
$('#save').on('click', function () {
|
|
13
13
|
Settings.save('equipmentCalendar', $container, function () {
|
|
14
14
|
alerts.success('Sauvegardé');
|
|
15
15
|
});
|
package/public/js/client.js
CHANGED
|
@@ -1,145 +1,179 @@
|
|
|
1
|
+
require(['jquery'], function ($) {
|
|
1
2
|
'use strict';
|
|
3
|
+
/* global window, document, FullCalendar, bootbox */
|
|
2
4
|
|
|
3
5
|
(function () {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
window.getReservationDays = window.getReservationDays || function () { return 1; };
|
|
7
|
-
|
|
8
|
-
function toUtcMidnightMs(dateObj) {
|
|
9
|
-
return Date.UTC(dateObj.getUTCFullYear(), dateObj.getUTCMonth(), dateObj.getUTCDate());
|
|
6
|
+
function overlaps(aStartMs, aEndMs, bStartMs, bEndMs) {
|
|
7
|
+
return aStartMs < bEndMs && aEndMs > bStartMs;
|
|
10
8
|
}
|
|
11
9
|
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
function fmt(dt) {
|
|
11
|
+
try {
|
|
12
|
+
return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'full', timeStyle: 'short' }).format(dt);
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return dt.toISOString();
|
|
15
|
+
}
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
function
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
if (!startMs || !endMs || endMs <= startMs) return 1;
|
|
23
|
-
const days = Math.round((endMs - startMs) / (24 * 60 * 60 * 1000));
|
|
24
|
-
return days > 0 ? days : 1;
|
|
25
|
-
}
|
|
26
|
-
window.getReservationDays = getReservationDays;
|
|
18
|
+
function buildAvailability(startMs, endMs) {
|
|
19
|
+
const blocks = Array.isArray(window.EC_BLOCKS) ? window.EC_BLOCKS : [];
|
|
20
|
+
const items = Array.isArray(window.EC_ITEMS) ? window.EC_ITEMS : [];
|
|
27
21
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (!sel || !out) return;
|
|
34
|
-
|
|
35
|
-
let unitTotal = 0;
|
|
36
|
-
Array.from(sel.selectedOptions || []).forEach((opt) => {
|
|
37
|
-
const p = parseFloat(opt.getAttribute('data-price') || '0');
|
|
38
|
-
if (!Number.isNaN(p)) unitTotal += p;
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const days = getReservationDays();
|
|
42
|
-
const finalTotal = unitTotal * days;
|
|
43
|
-
|
|
44
|
-
if (daysEl) {
|
|
45
|
-
daysEl.textContent = days + (days > 1 ? ' jours' : ' jour');
|
|
46
|
-
}
|
|
22
|
+
const blockedByItem = {};
|
|
23
|
+
for (const b of blocks) {
|
|
24
|
+
if (!blockedByItem[b.itemId]) blockedByItem[b.itemId] = [];
|
|
25
|
+
blockedByItem[b.itemId].push(b);
|
|
26
|
+
}
|
|
47
27
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
28
|
+
const available = [];
|
|
29
|
+
for (const it of items) {
|
|
30
|
+
const bks = blockedByItem[it.id] || [];
|
|
31
|
+
const hasOverlap = bks.some(b => overlaps(startMs, endMs, Number(b.startMs), Number(b.endMs)));
|
|
32
|
+
if (!hasOverlap) available.push(it);
|
|
33
|
+
}
|
|
34
|
+
return available;
|
|
51
35
|
}
|
|
52
|
-
window.updateTotalPrice = updateTotalPrice;
|
|
53
|
-
|
|
54
|
-
function openCreateModal(startMs, endMs) {
|
|
55
|
-
const startDateEl = document.getElementById('ec-start-date');
|
|
56
|
-
const endDateEl = document.getElementById('ec-end-date');
|
|
57
|
-
const startMsEl = document.getElementById('ec-start-ms');
|
|
58
|
-
const endMsEl = document.getElementById('ec-end-ms');
|
|
59
36
|
|
|
60
|
-
|
|
61
|
-
|
|
37
|
+
function submitReservation(startISO, endISO, itemId, notes) {
|
|
38
|
+
const form = document.getElementById('ec-create-form');
|
|
39
|
+
if (!form) return;
|
|
62
40
|
|
|
63
|
-
|
|
64
|
-
|
|
41
|
+
const startInput = form.querySelector('input[name="start"]');
|
|
42
|
+
const endInput = form.querySelector('input[name="end"]');
|
|
43
|
+
const itemInput = form.querySelector('input[name="itemIds"]');
|
|
44
|
+
const notesInput = form.querySelector('input[name="notesUser"]');
|
|
65
45
|
|
|
66
|
-
|
|
46
|
+
if (startInput) startInput.value = startISO;
|
|
47
|
+
if (endInput) endInput.value = endISO;
|
|
48
|
+
if (itemInput) itemInput.value = itemId;
|
|
49
|
+
if (notesInput) notesInput.value = notes || '';
|
|
67
50
|
|
|
68
|
-
|
|
69
|
-
if (modalEl && window.bootstrap && window.bootstrap.Modal) {
|
|
70
|
-
const modal = window.bootstrap.Modal.getOrCreateInstance(modalEl);
|
|
71
|
-
modal.show();
|
|
72
|
-
}
|
|
51
|
+
form.submit();
|
|
73
52
|
}
|
|
74
53
|
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
54
|
+
function openNodeBBModal(startISO, endISO) {
|
|
55
|
+
const startDate = new Date(startISO);
|
|
56
|
+
const endDate = new Date(endISO);
|
|
57
|
+
const startMs = startDate.getTime();
|
|
58
|
+
const endMs = endDate.getTime();
|
|
80
59
|
|
|
81
|
-
|
|
82
|
-
|
|
60
|
+
const available = buildAvailability(startMs, endMs);
|
|
61
|
+
|
|
62
|
+
if (!available.length) {
|
|
63
|
+
if (typeof bootbox !== 'undefined') {
|
|
64
|
+
bootbox.alert('Aucun matériel n’est disponible sur cette plage.');
|
|
65
|
+
} else {
|
|
66
|
+
alert('Aucun matériel n’est disponible sur cette plage.');
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
83
70
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
71
|
+
// Build modal HTML
|
|
72
|
+
let optionsHtml = '';
|
|
73
|
+
for (const it of available) {
|
|
74
|
+
const label = it.location ? `${it.name} — ${it.location}` : it.name;
|
|
75
|
+
optionsHtml += `<option value="${it.id}">${label}</option>`;
|
|
76
|
+
}
|
|
87
77
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
78
|
+
const body = `
|
|
79
|
+
<div class="mb-3">
|
|
80
|
+
<label class="form-label">Début</label>
|
|
81
|
+
<input class="form-control" type="text" value="${fmt(startDate)}" readonly>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="mb-3">
|
|
84
|
+
<label class="form-label">Fin</label>
|
|
85
|
+
<input class="form-control" type="text" value="${fmt(endDate)}" readonly>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="mb-3">
|
|
88
|
+
<label class="form-label">Matériel (Ctrl/Cmd pour multi-sélection)</label>
|
|
89
|
+
<select class="form-select" id="ec-modal-item" multiple size="6">
|
|
90
|
+
${optionsHtml}
|
|
91
|
+
</select>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="mb-3">
|
|
94
|
+
<label class="form-label">Note (optionnel)</label>
|
|
95
|
+
<input class="form-control" type="text" id="ec-modal-notes" maxlength="2000" placeholder="Ex: besoin de trépied, etc.">
|
|
96
|
+
</div>
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
if (typeof bootbox === 'undefined') {
|
|
100
|
+
// Fallback without bootbox (should be rare on NodeBB pages)
|
|
101
|
+
const itemId = available[0].id;
|
|
102
|
+
submitReservation(startDate.toISOString(), endDate.toISOString(), itemId, '');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
92
105
|
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
bootbox.dialog({
|
|
107
|
+
title: 'Demande de réservation',
|
|
108
|
+
message: body,
|
|
109
|
+
buttons: {
|
|
110
|
+
cancel: {
|
|
111
|
+
label: 'Annuler',
|
|
112
|
+
className: 'btn-outline-secondary',
|
|
113
|
+
},
|
|
114
|
+
ok: {
|
|
115
|
+
label: 'Envoyer la demande',
|
|
116
|
+
className: 'btn-primary',
|
|
117
|
+
callback: function () {
|
|
118
|
+
const itemEl = document.getElementById('ec-modal-item');
|
|
119
|
+
const notesEl = document.getElementById('ec-modal-notes');
|
|
120
|
+
let ids = [];
|
|
121
|
+
if (itemEl && itemEl.options) {
|
|
122
|
+
for (const opt of itemEl.options) { if (opt.selected) ids.push(opt.value); }
|
|
123
|
+
}
|
|
124
|
+
if (!ids.length) ids = [available[0].id];
|
|
125
|
+
const notes = notesEl ? notesEl.value : '';
|
|
126
|
+
submitReservation(startDate.toISOString(), endDate.toISOString(), ids.join(','), notes);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
95
131
|
}
|
|
96
132
|
|
|
97
|
-
function
|
|
98
|
-
const
|
|
99
|
-
if (!
|
|
133
|
+
function initCalendar() {
|
|
134
|
+
const el = document.getElementById('equipment-calendar');
|
|
135
|
+
if (!el || typeof FullCalendar === 'undefined') return;
|
|
100
136
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
events = eventsJsonEl ? JSON.parse(eventsJsonEl.textContent || '[]') : [];
|
|
105
|
-
} catch (e) {
|
|
106
|
-
events = [];
|
|
107
|
-
}
|
|
137
|
+
const events = window.EC_EVENTS || [];
|
|
138
|
+
const initialDate = window.EC_INITIAL_DATE;
|
|
139
|
+
const initialView = window.EC_INITIAL_VIEW || 'dayGridMonth';
|
|
108
140
|
|
|
109
|
-
const calendar = new
|
|
110
|
-
initialView:
|
|
111
|
-
|
|
141
|
+
const calendar = new FullCalendar.Calendar(el, {
|
|
142
|
+
initialView: initialView,
|
|
143
|
+
initialDate: initialDate,
|
|
144
|
+
timeZone: window.EC_TZ || 'local',
|
|
145
|
+
selectable: window.EC_CAN_CREATE === true,
|
|
112
146
|
selectMirror: true,
|
|
113
|
-
events,
|
|
147
|
+
events: events,
|
|
148
|
+
|
|
149
|
+
// Range selection (drag)
|
|
114
150
|
select: function (info) {
|
|
115
|
-
|
|
116
|
-
const endMs = toUtcMidnightMs(info.end);
|
|
117
|
-
openCreateModal(startMs, endMs);
|
|
151
|
+
openNodeBBModal(info.startStr, info.endStr);
|
|
118
152
|
},
|
|
153
|
+
|
|
154
|
+
// Single date click
|
|
119
155
|
dateClick: function (info) {
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
156
|
+
// Default: 1h slot
|
|
157
|
+
const start = info.date;
|
|
158
|
+
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
|
159
|
+
openNodeBBModal(start.toISOString(), end.toISOString());
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
headerToolbar: {
|
|
163
|
+
left: 'prev,next today',
|
|
164
|
+
center: 'title',
|
|
165
|
+
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
|
123
166
|
},
|
|
124
167
|
});
|
|
125
168
|
|
|
126
169
|
calendar.render();
|
|
127
|
-
|
|
128
|
-
const sel = document.getElementById('ec-item-ids');
|
|
129
|
-
if (sel) {
|
|
130
|
-
sel.addEventListener('change', updateTotalPrice);
|
|
131
|
-
}
|
|
132
|
-
const s = document.getElementById('ec-start-date');
|
|
133
|
-
const e = document.getElementById('ec-end-date');
|
|
134
|
-
if (s) s.addEventListener('change', syncDatesToMs);
|
|
135
|
-
if (e) e.addEventListener('change', syncDatesToMs);
|
|
136
|
-
|
|
137
|
-
updateTotalPrice();
|
|
138
170
|
}
|
|
139
171
|
|
|
140
|
-
|
|
141
|
-
|
|
172
|
+
if (document.readyState === 'loading') {
|
|
173
|
+
document.addEventListener('DOMContentLoaded', initCalendar);
|
|
174
|
+
} else {
|
|
175
|
+
initCalendar();
|
|
176
|
+
}
|
|
177
|
+
}());
|
|
142
178
|
|
|
143
|
-
|
|
144
|
-
window.addEventListener('action:ajaxify.end', initEquipmentCalendar);
|
|
145
|
-
})();
|
|
179
|
+
});
|
|
@@ -13,72 +13,57 @@
|
|
|
13
13
|
|
|
14
14
|
<div class="card card-body mb-3">
|
|
15
15
|
<h5>Permissions</h5>
|
|
16
|
-
|
|
17
16
|
<div class="mb-3">
|
|
18
17
|
<label class="form-label">Groupes autorisés à créer une demande (creatorGroups)</label>
|
|
19
18
|
<input class="form-control" type="text" name="creatorGroups" value="{settings.creatorGroups}">
|
|
20
|
-
<div class="form-text">Nom(s) de groupes NodeBB. Pour plusieurs groupes, sépare par des virgules. Ex: <code>registered-users, members</code></div>
|
|
21
19
|
</div>
|
|
22
|
-
|
|
23
20
|
<div class="mb-3">
|
|
24
21
|
<label class="form-label">Groupe valideur (approverGroup)</label>
|
|
25
22
|
<input class="form-control" type="text" name="approverGroup" value="{settings.approverGroup}">
|
|
26
|
-
<div class="form-text">Les membres de ce groupe voient /equipment/approvals et peuvent valider/refuser.</div>
|
|
27
23
|
</div>
|
|
28
|
-
|
|
29
24
|
<div class="mb-0">
|
|
30
25
|
<label class="form-label">Groupe notifié (notifyGroup)</label>
|
|
31
26
|
<input class="form-control" type="text" name="notifyGroup" value="{settings.notifyGroup}">
|
|
32
|
-
<div class="form-text">Envoi d’un email/notification au groupe lors d’une nouvelle demande.</div>
|
|
33
27
|
</div>
|
|
34
28
|
</div>
|
|
35
29
|
|
|
36
|
-
|
|
37
30
|
<div class="card card-body mb-3">
|
|
38
31
|
<h5>Paiement</h5>
|
|
39
32
|
<div class="mb-0">
|
|
40
33
|
<label class="form-label">Timeout paiement (minutes)</label>
|
|
41
34
|
<input class="form-control" type="number" min="1" name="paymentTimeoutMinutes" value="{settings.paymentTimeoutMinutes}">
|
|
42
|
-
<div class="form-text">Après validation, si le paiement n’est pas confirmé dans ce délai, la réservation est annulée et le matériel est débloqué.</div>
|
|
43
35
|
</div>
|
|
44
36
|
</div>
|
|
45
37
|
|
|
46
|
-
<div class="card card-body mb-3">
|
|
38
|
+
<div class="card card-body mb-3">
|
|
47
39
|
<h5>HelloAsso</h5>
|
|
48
|
-
|
|
49
40
|
<div class="mb-3">
|
|
50
41
|
<label class="form-label">API Base URL (prod/sandbox)</label>
|
|
51
42
|
<input class="form-control" type="text" name="ha_apiBaseUrl" value="{settings.ha_apiBaseUrl}">
|
|
52
|
-
<div class="form-text">
|
|
43
|
+
<div class="form-text">Prod: <code>https://api.helloasso.com</code> — Sandbox: <code>https://api.helloasso-sandbox.com</code></div>
|
|
53
44
|
</div>
|
|
54
|
-
|
|
55
45
|
<div class="mb-3">
|
|
56
46
|
<label class="form-label">Organization slug</label>
|
|
57
47
|
<input class="form-control" type="text" name="ha_organizationSlug" value="{settings.ha_organizationSlug}">
|
|
58
48
|
</div>
|
|
59
|
-
|
|
60
49
|
<div class="mb-3">
|
|
61
50
|
<label class="form-label">Client ID</label>
|
|
62
51
|
<input class="form-control" type="text" name="ha_clientId" value="{settings.ha_clientId}">
|
|
63
52
|
</div>
|
|
64
|
-
|
|
65
53
|
<div class="mb-3">
|
|
66
54
|
<label class="form-label">Client Secret</label>
|
|
67
55
|
<input class="form-control" type="password" name="ha_clientSecret" value="{settings.ha_clientSecret}">
|
|
68
56
|
</div>
|
|
69
|
-
|
|
70
57
|
<div class="row g-3">
|
|
71
58
|
<div class="col-md-6">
|
|
72
59
|
<label class="form-label">Form Type</label>
|
|
73
60
|
<input class="form-control" type="text" name="ha_itemsFormType" value="{settings.ha_itemsFormType}" placeholder="shop">
|
|
74
|
-
<div class="form-text">Ex: <code>shop</code></div>
|
|
75
61
|
</div>
|
|
76
62
|
<div class="col-md-6">
|
|
77
63
|
<label class="form-label">Form Slug</label>
|
|
78
64
|
<input class="form-control" type="text" name="ha_itemsFormSlug" value="{settings.ha_itemsFormSlug}" placeholder="locations-materiel-2026">
|
|
79
65
|
</div>
|
|
80
66
|
</div>
|
|
81
|
-
|
|
82
67
|
<div class="mb-0 mt-3">
|
|
83
68
|
<label class="form-label">Préfixe itemName (checkout)</label>
|
|
84
69
|
<input class="form-control" type="text" name="ha_calendarItemNamePrefix" value="{settings.ha_calendarItemNamePrefix}">
|
|
@@ -13,60 +13,14 @@
|
|
|
13
13
|
<div id="equipment-calendar"></div>
|
|
14
14
|
</div>
|
|
15
15
|
|
|
16
|
-
<!--
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
<form id="ec-create-form" method="post" action="/equipment/reservations/create">
|
|
26
|
-
<div class="modal-body">
|
|
27
|
-
<input type="hidden" name="_csrf" value="{config.csrf_token}">
|
|
28
|
-
<input type="hidden" id="ec-start-ms" name="startMs" value="0">
|
|
29
|
-
<input type="hidden" id="ec-end-ms" name="endMs" value="0">
|
|
30
|
-
|
|
31
|
-
<div class="row g-3 mb-3">
|
|
32
|
-
<div class="col-6">
|
|
33
|
-
<label class="form-label">Début</label>
|
|
34
|
-
<input class="form-control" type="date" id="ec-start-date" required>
|
|
35
|
-
</div>
|
|
36
|
-
<div class="col-6">
|
|
37
|
-
<label class="form-label">Fin</label>
|
|
38
|
-
<input class="form-control" type="date" id="ec-end-date" required>
|
|
39
|
-
</div>
|
|
40
|
-
</div>
|
|
41
|
-
|
|
42
|
-
<div class="mb-3">
|
|
43
|
-
<label class="form-label">Matériel</label>
|
|
44
|
-
<select class="form-select" id="ec-item-ids" name="itemIds" multiple required></select>
|
|
45
|
-
<div class="form-text">Tu peux sélectionner plusieurs matériels.</div>
|
|
46
|
-
</div>
|
|
47
|
-
|
|
48
|
-
<div class="mb-3">
|
|
49
|
-
<div class="fw-semibold">Durée</div>
|
|
50
|
-
<div id="ec-total-days">1 jour</div>
|
|
51
|
-
<hr class="my-2">
|
|
52
|
-
<div class="fw-semibold">Total estimé</div>
|
|
53
|
-
<div id="ec-total-price" class="fs-5">0 €</div>
|
|
54
|
-
</div>
|
|
55
|
-
|
|
56
|
-
<div class="mb-0">
|
|
57
|
-
<label class="form-label">Note</label>
|
|
58
|
-
<textarea class="form-control" name="notesUser" rows="3" placeholder="Optionnel"></textarea>
|
|
59
|
-
</div>
|
|
60
|
-
</div>
|
|
61
|
-
|
|
62
|
-
<div class="modal-footer">
|
|
63
|
-
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuler</button>
|
|
64
|
-
<button type="submit" class="btn btn-primary">Envoyer la demande</button>
|
|
65
|
-
</div>
|
|
66
|
-
</form>
|
|
67
|
-
</div>
|
|
68
|
-
</div>
|
|
69
|
-
</div>
|
|
16
|
+
<!-- Formulaire de soumission (utilisé par la modale JS) -->
|
|
17
|
+
<form id="ec-create-form" method="post" action="/equipment/reservations/create" class="d-none">
|
|
18
|
+
<input type="hidden" name="_csrf" value="{config.csrf_token}">
|
|
19
|
+
<input type="hidden" name="start" value="">
|
|
20
|
+
<input type="hidden" name="end" value="">
|
|
21
|
+
<input type="hidden" name="itemIds" value="">
|
|
22
|
+
<input type="hidden" name="notesUser" value="">
|
|
23
|
+
</form>
|
|
70
24
|
</div>
|
|
71
25
|
|
|
72
26
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js"></script>
|
|
@@ -77,7 +31,12 @@
|
|
|
77
31
|
window.EC_BLOCKS = JSON.parse(atob('{blocksB64}'));
|
|
78
32
|
window.EC_ITEMS = JSON.parse(atob('{itemsB64}'));
|
|
79
33
|
window.EC_INITIAL_DATE = "{initialDateISO}";
|
|
34
|
+
window.EC_INITIAL_VIEW = "{view}";
|
|
80
35
|
window.EC_TZ = "{tz}";
|
|
81
36
|
window.EC_CAN_CREATE = {canCreateJs};
|
|
82
37
|
</script>
|
|
83
|
-
|
|
38
|
+
|
|
39
|
+
<style>
|
|
40
|
+
.ec-status-pending .fc-event-title { font-weight: 600; }
|
|
41
|
+
.ec-status-valid .fc-event-title { font-weight: 700; }
|
|
42
|
+
</style>
|