nodebb-plugin-equipment-calendar 1.1.0 → 2.0.1
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) {
|
|
@@ -1165,64 +1158,47 @@ async function handleCreateReservation(req, res) {
|
|
|
1165
1158
|
return helpers.notAllowed(req, res);
|
|
1166
1159
|
}
|
|
1167
1160
|
|
|
1168
|
-
const
|
|
1169
|
-
const
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1161
|
+
const itemIdsRaw = req.body.itemIds || req.body.itemId || '';
|
|
1162
|
+
const itemIds = (Array.isArray(itemIdsRaw) ? itemIdsRaw : String(itemIdsRaw))
|
|
1163
|
+
.split(',')
|
|
1164
|
+
.map(s => String(s).trim())
|
|
1165
|
+
.filter(Boolean);
|
|
1166
|
+
|
|
1167
|
+
if (!itemIds.length) {
|
|
1168
|
+
return res.status(400).send('itemIds required');
|
|
1169
|
+
}
|
|
1177
1170
|
|
|
1178
1171
|
const tz = settings.timezone || 'Europe/Paris';
|
|
1179
1172
|
const start = DateTime.fromISO(String(req.body.start || ''), { zone: tz });
|
|
1180
1173
|
const end = DateTime.fromISO(String(req.body.end || ''), { zone: tz });
|
|
1181
|
-
|
|
1182
1174
|
if (!start.isValid || !end.isValid) return res.status(400).send('Invalid dates');
|
|
1183
|
-
const startMs = start.toMillis();
|
|
1184
|
-
const endMs = end.toMillis();
|
|
1185
|
-
|
|
1186
|
-
if (endMs <= startMs) return res.status(400).send('End must be after start');
|
|
1187
|
-
if (end.diff(start, 'days').days > 31) return res.status(400).send('Range too large');
|
|
1188
1175
|
|
|
1189
|
-
//
|
|
1190
|
-
|
|
1191
|
-
|
|
1176
|
+
// Normalize to whole days (no hours). End is exclusive.
|
|
1177
|
+
const startDay = start.startOf('day');
|
|
1178
|
+
let endDay = end.startOf('day');
|
|
1179
|
+
if (endDay <= startDay) {
|
|
1180
|
+
endDay = startDay.plus({ days: 1 });
|
|
1192
1181
|
}
|
|
1193
1182
|
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
itemId,
|
|
1197
|
-
uid: req.uid,
|
|
1198
|
-
startMs,
|
|
1199
|
-
endMs,
|
|
1200
|
-
status: 'pending',
|
|
1201
|
-
createdAtMs: Date.now(),
|
|
1202
|
-
updatedAtMs: Date.now(),
|
|
1203
|
-
validatorUid: 0,
|
|
1204
|
-
notesUser: String(req.body.notesUser || '').slice(0, 2000),
|
|
1205
|
-
notesAdmin: '',
|
|
1206
|
-
ha_checkoutIntentId: '',
|
|
1207
|
-
ha_paymentUrl: '',
|
|
1208
|
-
ha_paymentStatus: '',
|
|
1209
|
-
ha_paidAtMs: 0,
|
|
1210
|
-
};
|
|
1183
|
+
const startMs = startDay.toMillis();
|
|
1184
|
+
const endMs = endDay.toMillis();
|
|
1211
1185
|
|
|
1212
|
-
|
|
1186
|
+
const notesUser = String(req.body.notesUser || '').trim();
|
|
1213
1187
|
|
|
1214
|
-
|
|
1215
|
-
const link = `/equipment/approvals`;
|
|
1216
|
-
await sendGroupNotificationAndEmail(
|
|
1217
|
-
settings.notifyGroup || settings.approverGroup,
|
|
1218
|
-
'Nouvelle demande de réservation',
|
|
1219
|
-
`Une demande de réservation est en attente (matériel: ${item.name}).`,
|
|
1220
|
-
link
|
|
1221
|
-
);
|
|
1188
|
+
const bookingId = `${req.uid}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
|
1222
1189
|
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1190
|
+
// Create one reservation per item, linked by bookingId
|
|
1191
|
+
const created = [];
|
|
1192
|
+
for (const itemId of itemIds) {
|
|
1193
|
+
const rid = await createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId);
|
|
1194
|
+
created.push(rid);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Redirect to calendar with a success flag
|
|
1198
|
+
return res.redirect('/equipment/calendar?requested=1');
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
winston.error(err);
|
|
1201
|
+
return res.status(500).send(err.message || 'Error');
|
|
1226
1202
|
}
|
|
1227
1203
|
}
|
|
1228
1204
|
|
|
@@ -1250,8 +1226,7 @@ async function handleApproveReservation(req, res) {
|
|
|
1250
1226
|
return res.status(400).send('HelloAsso not configured');
|
|
1251
1227
|
}
|
|
1252
1228
|
const token = await helloAssoGetAccessToken(settings);
|
|
1253
|
-
const checkout = await helloAssoCreateCheckout(settings,
|
|
1254
|
-
saved, token, reservation, item);
|
|
1229
|
+
const checkout = await helloAssoCreateCheckout(settings, token, reservation, item);
|
|
1255
1230
|
reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
|
|
1256
1231
|
reservation.ha_paymentUrl = checkout.paymentUrl;
|
|
1257
1232
|
}
|
|
@@ -1402,7 +1377,7 @@ async function handleAdminSave(req, res) {
|
|
|
1402
1377
|
itemsJson: '[]',
|
|
1403
1378
|
ha_itemsFormType: String(req.body.ha_itemsFormType || DEFAULT_SETTINGS.ha_itemsFormType),
|
|
1404
1379
|
ha_itemsFormSlug: String(req.body.ha_itemsFormSlug || DEFAULT_SETTINGS.ha_itemsFormSlug),
|
|
1405
|
-
|
|
1380
|
+
ha_locationMapJson: String(req.body.ha_locationMapJson || DEFAULT_SETTINGS.ha_locationMapJson),
|
|
1406
1381
|
ha_clientId: String(req.body.ha_clientId || ''),
|
|
1407
1382
|
ha_clientSecret: String(req.body.ha_clientSecret || ''),
|
|
1408
1383
|
ha_organizationSlug: String(req.body.ha_organizationSlug || ''),
|
|
@@ -1412,8 +1387,7 @@ async function handleAdminSave(req, res) {
|
|
|
1412
1387
|
timezone: String(req.body.timezone || DEFAULT_SETTINGS.timezone),
|
|
1413
1388
|
paymentTimeoutMinutes: String(req.body.paymentTimeoutMinutes || DEFAULT_SETTINGS.paymentTimeoutMinutes),
|
|
1414
1389
|
showRequesterToAll: (req.body.showRequesterToAll === '1' || req.body.showRequesterToAll === 'on') ? '1' : '0',
|
|
1415
|
-
|
|
1416
|
-
ha_apiBaseUrl: String(req.body.ha_apiBaseUrl || DEFAULT_SETTINGS.ha_apiBaseUrl),};
|
|
1390
|
+
};
|
|
1417
1391
|
|
|
1418
1392
|
await meta.settings.set(SETTINGS_KEY, values);
|
|
1419
1393
|
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,128 @@
|
|
|
1
|
+
require(['jquery'], function ($) {
|
|
1
2
|
'use strict';
|
|
2
|
-
|
|
3
|
-
/* global define, require */
|
|
3
|
+
/* global window, document, FullCalendar */
|
|
4
4
|
|
|
5
5
|
(function () {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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');
|
|
63
|
-
|
|
64
|
-
if (startMsEl) startMsEl.value = String(startMs);
|
|
65
|
-
if (endMsEl) endMsEl.value = String(endMs);
|
|
66
|
-
|
|
67
|
-
if (startDateEl) startDateEl.value = formatDateInputValue(new Date(startMs));
|
|
68
|
-
if (endDateEl) endDateEl.value = formatDateInputValue(new Date(endMs - 24 * 60 * 60 * 1000));
|
|
69
|
-
|
|
70
|
-
updateTotalPrice();
|
|
71
|
-
|
|
72
|
-
const modalEl = document.getElementById('ec-create-modal');
|
|
73
|
-
if (modalEl && window.bootstrap && window.bootstrap.Modal) {
|
|
74
|
-
const modal = window.bootstrap.Modal.getOrCreateInstance(modalEl);
|
|
75
|
-
modal.show();
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function syncDatesToMs() {
|
|
80
|
-
try {
|
|
81
|
-
const s = document.getElementById('ec-start-date')?.value;
|
|
82
|
-
const e = document.getElementById('ec-end-date')?.value;
|
|
83
|
-
if (!s || !e) return;
|
|
84
|
-
|
|
85
|
-
const sd = new Date(s + 'T00:00:00Z');
|
|
86
|
-
const ed = new Date(e + 'T00:00:00Z');
|
|
87
|
-
|
|
88
|
-
const startMs = toUtcMidnightMs(sd);
|
|
89
|
-
let endMs = toUtcMidnightMs(ed) + 24 * 60 * 60 * 1000;
|
|
90
|
-
if (endMs <= startMs) endMs = startMs + 24 * 60 * 60 * 1000;
|
|
91
|
-
|
|
92
|
-
const startMsEl = document.getElementById('ec-start-ms');
|
|
93
|
-
const endMsEl = document.getElementById('ec-end-ms');
|
|
94
|
-
if (startMsEl) startMsEl.value = String(startMs);
|
|
95
|
-
if (endMsEl) endMsEl.value = String(endMs);
|
|
96
|
-
|
|
97
|
-
updateTotalPrice();
|
|
98
|
-
} catch (e) {}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Bind change events (no jQuery needed, but we already have it)
|
|
102
|
-
$('#ec-item-ids').off('change.ec').on('change.ec', updateTotalPrice);
|
|
103
|
-
$('#ec-start-date').off('change.ec').on('change.ec', syncDatesToMs);
|
|
104
|
-
$('#ec-end-date').off('change.ec').on('change.ec', syncDatesToMs);
|
|
105
|
-
|
|
106
|
-
// FullCalendar init
|
|
107
|
-
if (window.FullCalendar) {
|
|
108
|
-
const eventsJsonEl = document.getElementById('ec-events-json');
|
|
109
|
-
let events = [];
|
|
110
|
-
try {
|
|
111
|
-
events = eventsJsonEl ? JSON.parse(eventsJsonEl.textContent || '[]') : [];
|
|
112
|
-
} catch (e) { events = []; }
|
|
113
|
-
|
|
114
|
-
const calendar = new window.FullCalendar.Calendar(calendarEl, {
|
|
115
|
-
initialView: 'dayGridMonth',
|
|
116
|
-
selectable: true,
|
|
117
|
-
selectMirror: true,
|
|
118
|
-
events,
|
|
119
|
-
select: function (info) {
|
|
120
|
-
const startMs = toUtcMidnightMs(info.start);
|
|
121
|
-
const endMs = toUtcMidnightMs(info.end);
|
|
122
|
-
openCreateModal(startMs, endMs);
|
|
123
|
-
},
|
|
124
|
-
dateClick: function (info) {
|
|
125
|
-
const startMs = toUtcMidnightMs(info.date);
|
|
126
|
-
const endMs = startMs + 24 * 60 * 60 * 1000;
|
|
127
|
-
openCreateModal(startMs, endMs);
|
|
128
|
-
},
|
|
129
|
-
});
|
|
130
|
-
calendar.render();
|
|
131
|
-
}
|
|
6
|
+
function toIsoDateUTC(ms) {
|
|
7
|
+
const d = new Date(ms);
|
|
8
|
+
const y = d.getUTCFullYear();
|
|
9
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
10
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
11
|
+
return `${y}-${m}-${day}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseDateInputToMs(value) {
|
|
15
|
+
// value is YYYY-MM-DD
|
|
16
|
+
if (!value) return 0;
|
|
17
|
+
const d = new Date(value + 'T00:00:00Z');
|
|
18
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getReservationDays() {
|
|
22
|
+
const startVal = document.getElementById('ec-start-date')?.value;
|
|
23
|
+
const endVal = document.getElementById('ec-end-date')?.value;
|
|
24
|
+
const startMs = parseDateInputToMs(startVal);
|
|
25
|
+
const endMs = parseDateInputToMs(endVal);
|
|
26
|
+
if (!startMs || !endMs) return 1;
|
|
27
|
+
const days = Math.round((endMs - startMs) / (24 * 60 * 60 * 1000)) + 1;
|
|
28
|
+
return days > 0 ? days : 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function updateTotalPrice() {
|
|
32
|
+
const sel = document.getElementById('ec-item-ids');
|
|
33
|
+
const out = document.getElementById('ec-total-price');
|
|
34
|
+
const daysEl = document.getElementById('ec-total-days');
|
|
35
|
+
if (!sel || !out) return;
|
|
132
36
|
|
|
37
|
+
let unitTotal = 0;
|
|
38
|
+
Array.from(sel.selectedOptions || []).forEach((opt) => {
|
|
39
|
+
const p = parseFloat(opt.getAttribute('data-price') || '0');
|
|
40
|
+
if (!Number.isNaN(p)) unitTotal += p;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const days = getReservationDays();
|
|
44
|
+
if (daysEl) daysEl.textContent = days + (days > 1 ? ' jours' : ' jour');
|
|
45
|
+
|
|
46
|
+
const finalTotal = unitTotal * days;
|
|
47
|
+
const txt = Number.isInteger(finalTotal) ? String(finalTotal) : finalTotal.toFixed(2);
|
|
48
|
+
out.textContent = txt + ' €';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function syncHiddenIsoFields() {
|
|
52
|
+
const s = document.getElementById('ec-start-date')?.value;
|
|
53
|
+
const e = document.getElementById('ec-end-date')?.value;
|
|
54
|
+
const startIsoEl = document.getElementById('ec-start-iso');
|
|
55
|
+
const endIsoEl = document.getElementById('ec-end-iso');
|
|
56
|
+
if (!s || !e || !startIsoEl || !endIsoEl) return;
|
|
57
|
+
|
|
58
|
+
// Start inclusive at 00:00Z; End exclusive = day after end date at 00:00Z
|
|
59
|
+
const startMs = parseDateInputToMs(s);
|
|
60
|
+
const endMsExclusive = parseDateInputToMs(e) + 24 * 60 * 60 * 1000;
|
|
61
|
+
startIsoEl.value = new Date(startMs).toISOString();
|
|
62
|
+
endIsoEl.value = new Date(endMsExclusive).toISOString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function openModalWithRange(startMs, endMsExclusive) {
|
|
66
|
+
// Convert to date inputs: end date = endExclusive -1 day
|
|
67
|
+
const startDate = toIsoDateUTC(startMs);
|
|
68
|
+
const endDate = toIsoDateUTC(endMsExclusive - 24 * 60 * 60 * 1000);
|
|
69
|
+
|
|
70
|
+
const startEl = document.getElementById('ec-start-date');
|
|
71
|
+
const endEl = document.getElementById('ec-end-date');
|
|
72
|
+
if (startEl) startEl.value = startDate;
|
|
73
|
+
if (endEl) endEl.value = endDate;
|
|
74
|
+
|
|
75
|
+
syncHiddenIsoFields();
|
|
76
|
+
updateTotalPrice();
|
|
77
|
+
|
|
78
|
+
const modalEl = document.getElementById('ec-create-modal');
|
|
79
|
+
if (modalEl && window.bootstrap && window.bootstrap.Modal) {
|
|
80
|
+
window.bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function initCalendar() {
|
|
85
|
+
const calendarEl = document.getElementById('ec-calendar');
|
|
86
|
+
if (!calendarEl || !window.FullCalendar) return;
|
|
87
|
+
|
|
88
|
+
const canCreate = !!window.EC_CAN_CREATE;
|
|
89
|
+
const events = Array.isArray(window.EC_EVENTS) ? window.EC_EVENTS : [];
|
|
90
|
+
|
|
91
|
+
const calendar = new window.FullCalendar.Calendar(calendarEl, {
|
|
92
|
+
initialView: 'dayGridMonth',
|
|
93
|
+
selectable: canCreate,
|
|
94
|
+
selectMirror: true,
|
|
95
|
+
events,
|
|
96
|
+
dateClick: function (info) {
|
|
97
|
+
if (!canCreate) return;
|
|
98
|
+
const startMs = Date.UTC(info.date.getUTCFullYear(), info.date.getUTCMonth(), info.date.getUTCDate());
|
|
99
|
+
const endMs = startMs + 24 * 60 * 60 * 1000;
|
|
100
|
+
openModalWithRange(startMs, endMs);
|
|
101
|
+
},
|
|
102
|
+
select: function (info) {
|
|
103
|
+
if (!canCreate) return;
|
|
104
|
+
const startMs = Date.UTC(info.start.getUTCFullYear(), info.start.getUTCMonth(), info.start.getUTCDate());
|
|
105
|
+
const endMs = Date.UTC(info.end.getUTCFullYear(), info.end.getUTCMonth(), info.end.getUTCDate());
|
|
106
|
+
openModalWithRange(startMs, endMs);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
calendar.render();
|
|
111
|
+
|
|
112
|
+
document.getElementById('ec-item-ids')?.addEventListener('change', updateTotalPrice);
|
|
113
|
+
document.getElementById('ec-start-date')?.addEventListener('change', function () {
|
|
114
|
+
syncHiddenIsoFields();
|
|
133
115
|
updateTotalPrice();
|
|
134
116
|
});
|
|
117
|
+
document.getElementById('ec-end-date')?.addEventListener('change', function () {
|
|
118
|
+
syncHiddenIsoFields();
|
|
119
|
+
updateTotalPrice();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
updateTotalPrice();
|
|
135
123
|
}
|
|
136
124
|
|
|
137
|
-
$(window).off('action:ajaxify.end.ec').on('action:ajaxify.end.ec',
|
|
138
|
-
$(
|
|
125
|
+
$(window).off('action:ajaxify.end.ec').on('action:ajaxify.end.ec', initCalendar);
|
|
126
|
+
$(initCalendar);
|
|
139
127
|
})();
|
|
128
|
+
});
|
|
@@ -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}">
|
|
@@ -9,24 +9,24 @@
|
|
|
9
9
|
<div class="alert alert-info">Tu peux consulter le calendrier, mais tu n’as pas les droits pour créer une demande.</div>
|
|
10
10
|
{{{ end }}}
|
|
11
11
|
|
|
12
|
-
<div class="card card-body">
|
|
13
|
-
<div id="
|
|
12
|
+
<div class="card card-body mb-3">
|
|
13
|
+
<div id="ec-calendar"></div>
|
|
14
14
|
</div>
|
|
15
15
|
|
|
16
|
-
<!-- Modal
|
|
16
|
+
<!-- Modal de demande -->
|
|
17
17
|
<div class="modal fade" id="ec-create-modal" tabindex="-1" aria-hidden="true">
|
|
18
18
|
<div class="modal-dialog modal-dialog-centered">
|
|
19
19
|
<div class="modal-content">
|
|
20
|
-
<div class="modal-header">
|
|
21
|
-
<h5 class="modal-title">Nouvelle demande</h5>
|
|
22
|
-
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
23
|
-
</div>
|
|
24
|
-
|
|
25
20
|
<form id="ec-create-form" method="post" action="{relative_path}/equipment/reservations/create">
|
|
21
|
+
<div class="modal-header">
|
|
22
|
+
<h5 class="modal-title">Demande de réservation</h5>
|
|
23
|
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
26
|
<div class="modal-body">
|
|
27
27
|
<input type="hidden" name="_csrf" value="{config.csrf_token}">
|
|
28
|
-
<input type="hidden" id="ec-start-
|
|
29
|
-
<input type="hidden" id="ec-end-
|
|
28
|
+
<input type="hidden" id="ec-start-iso" name="start" value="">
|
|
29
|
+
<input type="hidden" id="ec-end-iso" name="end" value="">
|
|
30
30
|
|
|
31
31
|
<div class="row g-3 mb-3">
|
|
32
32
|
<div class="col-6">
|
|
@@ -41,26 +41,30 @@
|
|
|
41
41
|
|
|
42
42
|
<div class="mb-3">
|
|
43
43
|
<label class="form-label">Matériel</label>
|
|
44
|
-
<select class="form-select" id="ec-item-ids" name="itemIds" multiple required
|
|
44
|
+
<select class="form-select" id="ec-item-ids" name="itemIds" multiple required>
|
|
45
|
+
{{{ each items }}}
|
|
46
|
+
<option value="{@value.id}" data-price="{@value.price}">{@value.name} — {@value.price} €</option>
|
|
47
|
+
{{{ end }}}
|
|
48
|
+
</select>
|
|
45
49
|
<div class="form-text">Tu peux sélectionner plusieurs matériels.</div>
|
|
46
50
|
</div>
|
|
47
51
|
|
|
48
52
|
<div class="mb-3">
|
|
53
|
+
<label class="form-label">Notes</label>
|
|
54
|
+
<textarea class="form-control" name="notesUser" rows="3" placeholder="Infos utiles..."></textarea>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="mb-0">
|
|
49
58
|
<div class="fw-semibold">Durée</div>
|
|
50
59
|
<div id="ec-total-days">1 jour</div>
|
|
51
60
|
<hr class="my-2">
|
|
52
61
|
<div class="fw-semibold">Total estimé</div>
|
|
53
62
|
<div id="ec-total-price" class="fs-5">0 €</div>
|
|
54
63
|
</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
64
|
</div>
|
|
61
65
|
|
|
62
66
|
<div class="modal-footer">
|
|
63
|
-
<button type="button" class="btn btn-
|
|
67
|
+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
|
|
64
68
|
<button type="submit" class="btn btn-primary">Envoyer la demande</button>
|
|
65
69
|
</div>
|
|
66
70
|
</form>
|
|
@@ -70,13 +74,11 @@
|
|
|
70
74
|
</div>
|
|
71
75
|
|
|
72
76
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js"></script>
|
|
73
|
-
<script src="/plugins/nodebb-plugin-equipment-calendar/js/client.js"></script>
|
|
77
|
+
<script src="{relative_path}/plugins/nodebb-plugin-equipment-calendar/js/client.js"></script>
|
|
74
78
|
|
|
75
79
|
<script>
|
|
76
80
|
window.EC_EVENTS = JSON.parse(atob('{eventsB64}'));
|
|
77
81
|
window.EC_BLOCKS = JSON.parse(atob('{blocksB64}'));
|
|
78
|
-
window.EC_ITEMS = JSON.parse(atob('{itemsB64}'));
|
|
79
|
-
window.EC_INITIAL_DATE = "{initialDateISO}";
|
|
80
|
-
window.EC_TZ = "{tz}";
|
|
81
82
|
window.EC_CAN_CREATE = {canCreateJs};
|
|
83
|
+
window.EC_TZ = '{tz}';
|
|
82
84
|
</script>
|