nodebb-plugin-equipment-calendar 1.1.0 → 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
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,
|
|
@@ -673,7 +673,6 @@ async function renderAdminReservationsPage(req, res) {
|
|
|
673
673
|
if (!(await ensureIsAdmin(req, res))) return;
|
|
674
674
|
|
|
675
675
|
const settings = await getSettings();
|
|
676
|
-
const saved = false;
|
|
677
676
|
const items = await getActiveItems(settings);
|
|
678
677
|
const itemById = {};
|
|
679
678
|
items.forEach(it => { itemById[it.id] = it; });
|
|
@@ -740,7 +739,6 @@ async function renderAdminReservationsPage(req, res) {
|
|
|
740
739
|
res.render('admin/plugins/equipment-calendar-reservations', {
|
|
741
740
|
title: 'Equipment Calendar - Réservations',
|
|
742
741
|
settings,
|
|
743
|
-
saved,
|
|
744
742
|
rows: pageRows,
|
|
745
743
|
hasRows: pageRows.length > 0,
|
|
746
744
|
itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
|
|
@@ -812,8 +810,7 @@ async function handleHelloAssoTest(req, res) {
|
|
|
812
810
|
const clear = String(req.query.clear || '') === '1';
|
|
813
811
|
// force=1 skips in-memory cache and refresh_token; clear=1 wipes stored refresh token
|
|
814
812
|
haTokenCache = null;
|
|
815
|
-
await getHelloAssoAccessToken(settings,
|
|
816
|
-
{ force, clearStored: clear });
|
|
813
|
+
await getHelloAssoAccessToken(settings, { force, clearStored: clear });
|
|
817
814
|
const items = await fetchHelloAssoItems(settings);
|
|
818
815
|
const list = Array.isArray(items) ? items : (Array.isArray(items.data) ? items.data : []);
|
|
819
816
|
count = list.length;
|
|
@@ -836,7 +833,7 @@ async function handleHelloAssoTest(req, res) {
|
|
|
836
833
|
message,
|
|
837
834
|
count,
|
|
838
835
|
settings,
|
|
839
|
-
sampleItems,
|
|
836
|
+
sampleItems,
|
|
840
837
|
hasSampleItems,
|
|
841
838
|
hasSampleItems: sampleItems && sampleItems.length > 0,
|
|
842
839
|
});
|
|
@@ -844,11 +841,9 @@ sampleItems,
|
|
|
844
841
|
|
|
845
842
|
async function renderAdminPage(req, res) {
|
|
846
843
|
const settings = await getSettings();
|
|
847
|
-
const saved = String(req.query.saved || '') === '1';
|
|
848
844
|
res.render('admin/plugins/equipment-calendar', {
|
|
849
845
|
title: 'Equipment Calendar',
|
|
850
846
|
settings,
|
|
851
|
-
saved,
|
|
852
847
|
saved: req.query && String(req.query.saved || '') === '1',
|
|
853
848
|
purged: req.query && parseInt(req.query.purged, 10) || 0,
|
|
854
849
|
view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
|
|
@@ -873,8 +868,7 @@ async function handleHelloAssoCallback(req, res) {
|
|
|
873
868
|
|
|
874
869
|
try {
|
|
875
870
|
if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
|
|
876
|
-
const checkout = await fetchHelloAssoCheckoutIntent(settings,
|
|
877
|
-
saved, checkoutIntentId);
|
|
871
|
+
const checkout = await fetchHelloAssoCheckoutIntent(settings, checkoutIntentId);
|
|
878
872
|
|
|
879
873
|
const paid = isCheckoutPaid(checkout);
|
|
880
874
|
if (paid) {
|
|
@@ -1124,8 +1118,7 @@ async function renderApprovalsPage(req, res) {
|
|
|
1124
1118
|
|
|
1125
1119
|
// --- Actions ---
|
|
1126
1120
|
|
|
1127
|
-
async function createReservationForItem(req, res, settings,
|
|
1128
|
-
saved, itemId, startMs, endMs, notesUser, bookingId) {
|
|
1121
|
+
async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId) {
|
|
1129
1122
|
const items = await getActiveItems(settings);
|
|
1130
1123
|
const item = items.find(i => i.id === itemId);
|
|
1131
1124
|
if (!item) {
|
|
@@ -1169,8 +1162,7 @@ async function handleCreateReservation(req, res) {
|
|
|
1169
1162
|
const itemIdsRaw = String(req.body.itemIds || req.body.itemId || '').trim();
|
|
1170
1163
|
const itemIds = itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
1171
1164
|
if (!itemIds.length) {
|
|
1172
|
-
|
|
1173
|
-
return res.redirect('/equipment/calendar');
|
|
1165
|
+
return res.status(400).send('itemId required');
|
|
1174
1166
|
}
|
|
1175
1167
|
const item = items.find(i => i.id === itemId);
|
|
1176
1168
|
if (!item) return res.status(400).send('Invalid item');
|
|
@@ -1250,8 +1242,7 @@ async function handleApproveReservation(req, res) {
|
|
|
1250
1242
|
return res.status(400).send('HelloAsso not configured');
|
|
1251
1243
|
}
|
|
1252
1244
|
const token = await helloAssoGetAccessToken(settings);
|
|
1253
|
-
const checkout = await helloAssoCreateCheckout(settings,
|
|
1254
|
-
saved, token, reservation, item);
|
|
1245
|
+
const checkout = await helloAssoCreateCheckout(settings, token, reservation, item);
|
|
1255
1246
|
reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
|
|
1256
1247
|
reservation.ha_paymentUrl = checkout.paymentUrl;
|
|
1257
1248
|
}
|
|
@@ -1402,7 +1393,7 @@ async function handleAdminSave(req, res) {
|
|
|
1402
1393
|
itemsJson: '[]',
|
|
1403
1394
|
ha_itemsFormType: String(req.body.ha_itemsFormType || DEFAULT_SETTINGS.ha_itemsFormType),
|
|
1404
1395
|
ha_itemsFormSlug: String(req.body.ha_itemsFormSlug || DEFAULT_SETTINGS.ha_itemsFormSlug),
|
|
1405
|
-
|
|
1396
|
+
ha_locationMapJson: String(req.body.ha_locationMapJson || DEFAULT_SETTINGS.ha_locationMapJson),
|
|
1406
1397
|
ha_clientId: String(req.body.ha_clientId || ''),
|
|
1407
1398
|
ha_clientSecret: String(req.body.ha_clientSecret || ''),
|
|
1408
1399
|
ha_organizationSlug: String(req.body.ha_organizationSlug || ''),
|
|
@@ -1412,8 +1403,7 @@ async function handleAdminSave(req, res) {
|
|
|
1412
1403
|
timezone: String(req.body.timezone || DEFAULT_SETTINGS.timezone),
|
|
1413
1404
|
paymentTimeoutMinutes: String(req.body.paymentTimeoutMinutes || DEFAULT_SETTINGS.paymentTimeoutMinutes),
|
|
1414
1405
|
showRequesterToAll: (req.body.showRequesterToAll === '1' || req.body.showRequesterToAll === 'on') ? '1' : '0',
|
|
1415
|
-
|
|
1416
|
-
ha_apiBaseUrl: String(req.body.ha_apiBaseUrl || DEFAULT_SETTINGS.ha_apiBaseUrl),};
|
|
1406
|
+
};
|
|
1417
1407
|
|
|
1418
1408
|
await meta.settings.set(SETTINGS_KEY, values);
|
|
1419
1409
|
return res.redirect('/admin/plugins/equipment-calendar?saved=1');
|
package/package.json
CHANGED
package/plugin.json
CHANGED
package/public/js/client.js
CHANGED
|
@@ -1,139 +1,179 @@
|
|
|
1
|
+
require(['jquery'], function ($) {
|
|
1
2
|
'use strict';
|
|
2
|
-
|
|
3
|
-
/* global define, require */
|
|
3
|
+
/* global window, document, FullCalendar, bootbox */
|
|
4
4
|
|
|
5
5
|
(function () {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// If this page isn't the equipment calendar page, do nothing
|
|
10
|
-
const calendarEl = document.getElementById('ec-calendar');
|
|
11
|
-
if (!calendarEl) return;
|
|
12
|
-
|
|
13
|
-
// Expose helpers globally (some inline scripts might call them)
|
|
14
|
-
window.getReservationDays = window.getReservationDays || function () { return 1; };
|
|
15
|
-
window.updateTotalPrice = window.updateTotalPrice || function () {};
|
|
16
|
-
|
|
17
|
-
function toUtcMidnightMs(dateObj) {
|
|
18
|
-
return Date.UTC(dateObj.getUTCFullYear(), dateObj.getUTCMonth(), dateObj.getUTCDate());
|
|
19
|
-
}
|
|
20
|
-
function formatDateInputValue(dateObj) {
|
|
21
|
-
const y = dateObj.getUTCFullYear();
|
|
22
|
-
const m = String(dateObj.getUTCMonth() + 1).padStart(2, '0');
|
|
23
|
-
const d = String(dateObj.getUTCDate()).padStart(2, '0');
|
|
24
|
-
return `${y}-${m}-${d}`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function getReservationDays() {
|
|
28
|
-
const startMs = parseInt(document.getElementById('ec-start-ms')?.value || '0', 10);
|
|
29
|
-
const endMs = parseInt(document.getElementById('ec-end-ms')?.value || '0', 10);
|
|
30
|
-
if (!startMs || !endMs || endMs <= startMs) return 1;
|
|
31
|
-
const days = Math.round((endMs - startMs) / (24 * 60 * 60 * 1000));
|
|
32
|
-
return days > 0 ? days : 1;
|
|
33
|
-
}
|
|
34
|
-
window.getReservationDays = getReservationDays;
|
|
35
|
-
|
|
36
|
-
function updateTotalPrice() {
|
|
37
|
-
try {
|
|
38
|
-
const sel = document.getElementById('ec-item-ids');
|
|
39
|
-
const out = document.getElementById('ec-total-price');
|
|
40
|
-
const daysEl = document.getElementById('ec-total-days');
|
|
41
|
-
if (!sel || !out) return;
|
|
42
|
-
let unitTotal = 0;
|
|
43
|
-
Array.from(sel.selectedOptions || []).forEach((opt) => {
|
|
44
|
-
const p = parseFloat(opt.getAttribute('data-price') || '0');
|
|
45
|
-
if (!Number.isNaN(p)) unitTotal += p;
|
|
46
|
-
});
|
|
47
|
-
const days = getReservationDays();
|
|
48
|
-
const finalTotal = unitTotal * days;
|
|
49
|
-
if (daysEl) {
|
|
50
|
-
daysEl.textContent = days + (days > 1 ? ' jours' : ' jour');
|
|
51
|
-
}
|
|
52
|
-
const txt = Number.isInteger(finalTotal) ? String(finalTotal) : finalTotal.toFixed(2);
|
|
53
|
-
out.textContent = txt + ' €';
|
|
54
|
-
} catch (e) {}
|
|
55
|
-
}
|
|
56
|
-
window.updateTotalPrice = updateTotalPrice;
|
|
57
|
-
|
|
58
|
-
function openCreateModal(startMs, endMs) {
|
|
59
|
-
const startDateEl = document.getElementById('ec-start-date');
|
|
60
|
-
const endDateEl = document.getElementById('ec-end-date');
|
|
61
|
-
const startMsEl = document.getElementById('ec-start-ms');
|
|
62
|
-
const endMsEl = document.getElementById('ec-end-ms');
|
|
6
|
+
function overlaps(aStartMs, aEndMs, bStartMs, bEndMs) {
|
|
7
|
+
return aStartMs < bEndMs && aEndMs > bStartMs;
|
|
8
|
+
}
|
|
63
9
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|
|
16
|
+
}
|
|
66
17
|
|
|
67
|
-
|
|
68
|
-
|
|
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 : [];
|
|
21
|
+
|
|
22
|
+
const blockedByItem = {};
|
|
23
|
+
for (const b of blocks) {
|
|
24
|
+
if (!blockedByItem[b.itemId]) blockedByItem[b.itemId] = [];
|
|
25
|
+
blockedByItem[b.itemId].push(b);
|
|
26
|
+
}
|
|
27
|
+
|
|
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;
|
|
35
|
+
}
|
|
69
36
|
|
|
70
|
-
|
|
37
|
+
function submitReservation(startISO, endISO, itemId, notes) {
|
|
38
|
+
const form = document.getElementById('ec-create-form');
|
|
39
|
+
if (!form) return;
|
|
71
40
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
}
|
|
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"]');
|
|
78
45
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (!s || !e) return;
|
|
46
|
+
if (startInput) startInput.value = startISO;
|
|
47
|
+
if (endInput) endInput.value = endISO;
|
|
48
|
+
if (itemInput) itemInput.value = itemId;
|
|
49
|
+
if (notesInput) notesInput.value = notes || '';
|
|
84
50
|
|
|
85
|
-
|
|
86
|
-
|
|
51
|
+
form.submit();
|
|
52
|
+
}
|
|
87
53
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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();
|
|
91
59
|
|
|
92
|
-
|
|
93
|
-
const endMsEl = document.getElementById('ec-end-ms');
|
|
94
|
-
if (startMsEl) startMsEl.value = String(startMs);
|
|
95
|
-
if (endMsEl) endMsEl.value = String(endMs);
|
|
60
|
+
const available = buildAvailability(startMs, endMs);
|
|
96
61
|
|
|
97
|
-
|
|
98
|
-
|
|
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.');
|
|
99
67
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
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
|
+
}
|
|
77
|
+
|
|
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
|
+
}
|
|
105
|
+
|
|
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
|
+
}
|
|
131
129
|
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
function initCalendar() {
|
|
134
|
+
const el = document.getElementById('equipment-calendar');
|
|
135
|
+
if (!el || typeof FullCalendar === 'undefined') return;
|
|
136
|
+
|
|
137
|
+
const events = window.EC_EVENTS || [];
|
|
138
|
+
const initialDate = window.EC_INITIAL_DATE;
|
|
139
|
+
const initialView = window.EC_INITIAL_VIEW || 'dayGridMonth';
|
|
140
|
+
|
|
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,
|
|
146
|
+
selectMirror: true,
|
|
147
|
+
events: events,
|
|
148
|
+
|
|
149
|
+
// Range selection (drag)
|
|
150
|
+
select: function (info) {
|
|
151
|
+
openNodeBBModal(info.startStr, info.endStr);
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
// Single date click
|
|
155
|
+
dateClick: function (info) {
|
|
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'
|
|
166
|
+
},
|
|
134
167
|
});
|
|
168
|
+
|
|
169
|
+
calendar.render();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (document.readyState === 'loading') {
|
|
173
|
+
document.addEventListener('DOMContentLoaded', initCalendar);
|
|
174
|
+
} else {
|
|
175
|
+
initCalendar();
|
|
135
176
|
}
|
|
177
|
+
}());
|
|
136
178
|
|
|
137
|
-
|
|
138
|
-
$(init);
|
|
139
|
-
})();
|
|
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="{relative_path}/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,6 +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>
|
|
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>
|