nodebb-plugin-equipment-calendar 0.8.6 → 0.9.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 +37 -33
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/js/client.js +37 -0
- package/public/templates/admin/plugins/equipment-calendar.tpl +3 -44
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", "price": 5000, "location": "Stock A", "active": true }]
|
|
31
31
|
itemsJson: '[]',
|
|
32
32
|
itemsSource: 'manual',
|
|
33
33
|
ha_itemsFormType: '',
|
|
34
34
|
ha_itemsFormSlug: '',
|
|
35
|
-
|
|
35
|
+
|
|
36
36
|
ha_calendarItemNamePrefix: 'Location matériel',
|
|
37
37
|
paymentTimeoutMinutes: 10,
|
|
38
38
|
// HelloAsso
|
|
@@ -49,18 +49,11 @@ 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
|
-
}
|
|
60
52
|
|
|
61
53
|
let haTokenCache = null; // { accessToken, refreshToken, expMs }
|
|
62
54
|
|
|
63
|
-
async function getHelloAssoAccessToken(settings,
|
|
55
|
+
async function getHelloAssoAccessToken(settings,
|
|
56
|
+
saved, opts = {}) {
|
|
64
57
|
const now = Date.now();
|
|
65
58
|
if (!opts.force && haTokenCache && haTokenCache.accessToken && haTokenCache.expMs && now < haTokenCache.expMs - 30_000) {
|
|
66
59
|
return haTokenCache.accessToken;
|
|
@@ -121,7 +114,8 @@ async function getHelloAssoAccessToken(settings, opts = {}) {
|
|
|
121
114
|
}
|
|
122
115
|
|
|
123
116
|
|
|
124
|
-
async function createHelloAssoCheckoutIntent(settings,
|
|
117
|
+
async function createHelloAssoCheckoutIntent(settings,
|
|
118
|
+
saved, bookingId, reservations) {
|
|
125
119
|
const org = String(settings.ha_organizationSlug || '').trim();
|
|
126
120
|
if (!org) throw new Error('HelloAsso organization slug missing');
|
|
127
121
|
|
|
@@ -135,9 +129,11 @@ async function createHelloAssoCheckoutIntent(settings, bookingId, reservations)
|
|
|
135
129
|
items.forEach(i => { byId[i.id] = i; });
|
|
136
130
|
|
|
137
131
|
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;
|
|
138
133
|
const totalAmount = reservations.reduce((sum, r) => {
|
|
139
|
-
const
|
|
140
|
-
|
|
134
|
+
const priceEuro = (byId[r.itemId] && parseFloat(byId[r.itemId].price)) || 0;
|
|
135
|
+
const priceCents = Math.round((Number.isFinite(priceEuro) ? priceEuro : 0) * 100);
|
|
136
|
+
return sum + (priceCents * days);
|
|
141
137
|
}, 0);
|
|
142
138
|
|
|
143
139
|
if (!totalAmount || totalAmount <= 0) {
|
|
@@ -180,7 +176,8 @@ async function createHelloAssoCheckoutIntent(settings, bookingId, reservations)
|
|
|
180
176
|
return await resp.json();
|
|
181
177
|
}
|
|
182
178
|
|
|
183
|
-
async function fetchHelloAssoCheckoutIntent(settings,
|
|
179
|
+
async function fetchHelloAssoCheckoutIntent(settings,
|
|
180
|
+
saved, checkoutIntentId) {
|
|
184
181
|
const org = String(settings.ha_organizationSlug || '').trim();
|
|
185
182
|
const token = await getHelloAssoAccessToken(settings);
|
|
186
183
|
const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents/${encodeURIComponent(checkoutIntentId)}`;
|
|
@@ -248,8 +245,8 @@ async function fetchHelloAssoItems(settings) {
|
|
|
248
245
|
const name = String(it.name || it.label || it.title || '').trim() || (tierName ? String(tierName) : id);
|
|
249
246
|
if (!id || !name) return;
|
|
250
247
|
const amount = it.amount || it.price || it.unitPrice || it.totalAmount || it.initialAmount;
|
|
251
|
-
const
|
|
252
|
-
out.push({ id, name,
|
|
248
|
+
const price = (typeof amount === 'number' ? amount : parseInt(amount, 10)) || 0;
|
|
249
|
+
out.push({ id, name, price: String(price), location: '' });
|
|
253
250
|
}
|
|
254
251
|
|
|
255
252
|
// Try a few known layouts
|
|
@@ -285,7 +282,7 @@ function parseItems(itemsJson) {
|
|
|
285
282
|
return arr.map(it => ({
|
|
286
283
|
id: String(it.id || '').trim(),
|
|
287
284
|
name: String(it.name || '').trim(),
|
|
288
|
-
|
|
285
|
+
price: Number(it.price || 0),
|
|
289
286
|
location: String(it.location || '').trim(),
|
|
290
287
|
active: it.active !== false,
|
|
291
288
|
})).filter(it => it.id && it.name);
|
|
@@ -297,23 +294,20 @@ function parseItems(itemsJson) {
|
|
|
297
294
|
async function getActiveItems(settings) {
|
|
298
295
|
const source = String(settings.itemsSource || 'manual');
|
|
299
296
|
if (source === 'helloasso') {
|
|
300
|
-
const rawItems = await fetchHelloAssoItems(settings);
|
|
301
|
-
const locMap = parseLocationMap(settings.ha_locationMapJson);
|
|
302
|
-
return (rawItems || []).map((it) => {
|
|
297
|
+
const rawItems = await fetchHelloAssoItems(settings); return (rawItems || []).map((it) => {
|
|
303
298
|
const id = String(it.id || it.itemId || it.reference || it.slug || it.name || '').trim();
|
|
304
299
|
const name = String(it.name || it.label || it.title || id).trim();
|
|
305
300
|
// Price handling is not displayed in the public calendar, keep a field if you want later
|
|
306
|
-
const
|
|
301
|
+
const priceRaw =
|
|
307
302
|
(it.price && (it.price.value || it.price.amount)) ||
|
|
308
303
|
(it.amount && (it.amount.value || it.amount)) ||
|
|
309
304
|
it.price;
|
|
310
|
-
const
|
|
305
|
+
const price = (typeof priceRaw === 'number' ? priceRaw : parseInt(priceRaw, 10)) || 0;
|
|
311
306
|
|
|
312
307
|
return {
|
|
313
308
|
id: id || name,
|
|
314
309
|
name,
|
|
315
|
-
|
|
316
|
-
priceCents,
|
|
310
|
+
price,
|
|
317
311
|
active: true,
|
|
318
312
|
source: 'helloasso',
|
|
319
313
|
};
|
|
@@ -513,14 +507,15 @@ async function helloAssoGetAccessToken(settings) {
|
|
|
513
507
|
return resp.data.access_token;
|
|
514
508
|
}
|
|
515
509
|
|
|
516
|
-
async function helloAssoCreateCheckout(settings,
|
|
510
|
+
async function helloAssoCreateCheckout(settings,
|
|
511
|
+
saved, token, reservation, item) {
|
|
517
512
|
// Minimal: create a checkout intent and return redirectUrl
|
|
518
513
|
// This endpoint/payload may need adaptation depending on your HelloAsso setup.
|
|
519
514
|
const org = settings.ha_organizationSlug;
|
|
520
515
|
if (!org) throw new Error('HelloAsso organizationSlug missing');
|
|
521
516
|
|
|
522
517
|
const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
|
|
523
|
-
const amountCents = Math.max(0, Number(item.
|
|
518
|
+
const amountCents = Math.max(0, Number(item.price || 0));
|
|
524
519
|
|
|
525
520
|
const payload = {
|
|
526
521
|
totalAmount: amountCents,
|
|
@@ -771,6 +766,7 @@ async function renderAdminReservationsPage(req, res) {
|
|
|
771
766
|
res.render('admin/plugins/equipment-calendar-reservations', {
|
|
772
767
|
title: 'Equipment Calendar - Réservations',
|
|
773
768
|
settings,
|
|
769
|
+
saved,
|
|
774
770
|
rows: pageRows,
|
|
775
771
|
hasRows: pageRows.length > 0,
|
|
776
772
|
itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
|
|
@@ -842,7 +838,8 @@ async function handleHelloAssoTest(req, res) {
|
|
|
842
838
|
const clear = String(req.query.clear || '') === '1';
|
|
843
839
|
// force=1 skips in-memory cache and refresh_token; clear=1 wipes stored refresh token
|
|
844
840
|
haTokenCache = null;
|
|
845
|
-
await getHelloAssoAccessToken(settings,
|
|
841
|
+
await getHelloAssoAccessToken(settings,
|
|
842
|
+
saved, { force, clearStored: clear });
|
|
846
843
|
const items = await fetchHelloAssoItems(settings);
|
|
847
844
|
const list = Array.isArray(items) ? items : (Array.isArray(items.data) ? items.data : []);
|
|
848
845
|
count = list.length;
|
|
@@ -865,6 +862,7 @@ async function handleHelloAssoTest(req, res) {
|
|
|
865
862
|
message,
|
|
866
863
|
count,
|
|
867
864
|
settings,
|
|
865
|
+
saved,
|
|
868
866
|
sampleItems,
|
|
869
867
|
hasSampleItems,
|
|
870
868
|
hasSampleItems: sampleItems && sampleItems.length > 0,
|
|
@@ -873,9 +871,11 @@ async function handleHelloAssoTest(req, res) {
|
|
|
873
871
|
|
|
874
872
|
async function renderAdminPage(req, res) {
|
|
875
873
|
const settings = await getSettings();
|
|
874
|
+
const saved = String(req.query.saved || '') === '1';
|
|
876
875
|
res.render('admin/plugins/equipment-calendar', {
|
|
877
876
|
title: 'Equipment Calendar',
|
|
878
877
|
settings,
|
|
878
|
+
saved,
|
|
879
879
|
saved: req.query && String(req.query.saved || '') === '1',
|
|
880
880
|
purged: req.query && parseInt(req.query.purged, 10) || 0,
|
|
881
881
|
view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
|
|
@@ -900,7 +900,8 @@ async function handleHelloAssoCallback(req, res) {
|
|
|
900
900
|
|
|
901
901
|
try {
|
|
902
902
|
if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
|
|
903
|
-
const checkout = await fetchHelloAssoCheckoutIntent(settings,
|
|
903
|
+
const checkout = await fetchHelloAssoCheckoutIntent(settings,
|
|
904
|
+
saved, checkoutIntentId);
|
|
904
905
|
|
|
905
906
|
const paid = isCheckoutPaid(checkout);
|
|
906
907
|
if (paid) {
|
|
@@ -1150,7 +1151,8 @@ async function renderApprovalsPage(req, res) {
|
|
|
1150
1151
|
|
|
1151
1152
|
// --- Actions ---
|
|
1152
1153
|
|
|
1153
|
-
async function createReservationForItem(req, res, settings,
|
|
1154
|
+
async function createReservationForItem(req, res, settings,
|
|
1155
|
+
saved, itemId, startMs, endMs, notesUser, bookingId) {
|
|
1154
1156
|
const items = await getActiveItems(settings);
|
|
1155
1157
|
const item = items.find(i => i.id === itemId);
|
|
1156
1158
|
if (!item) {
|
|
@@ -1194,7 +1196,8 @@ async function handleCreateReservation(req, res) {
|
|
|
1194
1196
|
const itemIdsRaw = String(req.body.itemIds || req.body.itemId || '').trim();
|
|
1195
1197
|
const itemIds = itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
1196
1198
|
if (!itemIds.length) {
|
|
1197
|
-
|
|
1199
|
+
req.flash('error', 'Veuillez sélectionner au moins un matériel.');
|
|
1200
|
+
return res.redirect('/equipment/calendar');
|
|
1198
1201
|
}
|
|
1199
1202
|
const item = items.find(i => i.id === itemId);
|
|
1200
1203
|
if (!item) return res.status(400).send('Invalid item');
|
|
@@ -1274,7 +1277,8 @@ async function handleApproveReservation(req, res) {
|
|
|
1274
1277
|
return res.status(400).send('HelloAsso not configured');
|
|
1275
1278
|
}
|
|
1276
1279
|
const token = await helloAssoGetAccessToken(settings);
|
|
1277
|
-
const checkout = await helloAssoCreateCheckout(settings,
|
|
1280
|
+
const checkout = await helloAssoCreateCheckout(settings,
|
|
1281
|
+
saved, token, reservation, item);
|
|
1278
1282
|
reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
|
|
1279
1283
|
reservation.ha_paymentUrl = checkout.paymentUrl;
|
|
1280
1284
|
}
|
|
@@ -1426,7 +1430,7 @@ async function handleAdminSave(req, res) {
|
|
|
1426
1430
|
itemsSource: String(req.body.itemsSource || DEFAULT_SETTINGS.itemsSource),
|
|
1427
1431
|
ha_itemsFormType: String(req.body.ha_itemsFormType || DEFAULT_SETTINGS.ha_itemsFormType),
|
|
1428
1432
|
ha_itemsFormSlug: String(req.body.ha_itemsFormSlug || DEFAULT_SETTINGS.ha_itemsFormSlug),
|
|
1429
|
-
|
|
1433
|
+
|
|
1430
1434
|
ha_clientId: String(req.body.ha_clientId || ''),
|
|
1431
1435
|
ha_clientSecret: String(req.body.ha_clientSecret || ''),
|
|
1432
1436
|
ha_organizationSlug: String(req.body.ha_organizationSlug || ''),
|
package/package.json
CHANGED
package/plugin.json
CHANGED
package/public/js/client.js
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
|
|
2
|
+
function updateTotalPrice() {
|
|
3
|
+
try {
|
|
4
|
+
const sel = document.getElementById('ec-item-ids');
|
|
5
|
+
const out = document.getElementById('ec-total-price');
|
|
6
|
+
const daysEl = document.getElementById('ec-total-days');
|
|
7
|
+
if (!sel || !out) return;
|
|
8
|
+
let total = 0;
|
|
9
|
+
Array.from(sel.selectedOptions || []).forEach(opt => {
|
|
10
|
+
const p = parseFloat(opt.getAttribute('data-price') || '0');
|
|
11
|
+
if (!Number.isNaN(p)) total += p;
|
|
12
|
+
});
|
|
13
|
+
const days = getReservationDays();
|
|
14
|
+
const finalTotal = total * days;
|
|
15
|
+
if (daysEl) {
|
|
16
|
+
daysEl.textContent = days + (days > 1 ? ' jours' : ' jour');
|
|
17
|
+
}
|
|
18
|
+
const txt = Number.isInteger(finalTotal) ? String(finalTotal) : finalTotal.toFixed(2);
|
|
19
|
+
out.textContent = txt + ' €';
|
|
20
|
+
} catch (e) {}
|
|
21
|
+
});
|
|
22
|
+
const txt = Number.isInteger(total) ? String(total) : total.toFixed(2);
|
|
23
|
+
out.textContent = txt + ' €';
|
|
24
|
+
} catch (e) {}
|
|
25
|
+
}
|
|
26
|
+
|
|
1
27
|
'use strict';
|
|
2
28
|
/* global window, document, FullCalendar, bootbox */
|
|
3
29
|
|
|
@@ -174,3 +200,14 @@
|
|
|
174
200
|
initCalendar();
|
|
175
201
|
}
|
|
176
202
|
}());
|
|
203
|
+
|
|
204
|
+
const ecItemSel = document.getElementById('ec-item-ids');
|
|
205
|
+
if (ecItemSel) { ecItemSel.addEventListener('change', updateTotalPrice); updateTotalPrice(); }
|
|
206
|
+
|
|
207
|
+
function getReservationDays() {
|
|
208
|
+
const startMs = parseInt(document.getElementById('ec-start-ms')?.value || '0', 10);
|
|
209
|
+
const endMs = parseInt(document.getElementById('ec-end-ms')?.value || '0', 10);
|
|
210
|
+
if (!startMs || !endMs || endMs <= startMs) return 1;
|
|
211
|
+
const days = Math.round((endMs - startMs) / (24*60*60*1000));
|
|
212
|
+
return days > 0 ? days : 1;
|
|
213
|
+
}
|
|
@@ -28,55 +28,14 @@
|
|
|
28
28
|
<div class="alert alert-warning">
|
|
29
29
|
Le champ "Matériel" doit être un JSON valide (array). Exemple :
|
|
30
30
|
<pre class="mb-0">[
|
|
31
|
-
{ "id": "cam1", "name": "Caméra A", "
|
|
32
|
-
{ "id": "light1", "name": "Projecteur", "
|
|
31
|
+
{ "id": "cam1", "name": "Caméra A", "price": 50, "": "Stock A", "active": true },
|
|
32
|
+
{ "id": "light1", "name": "Projecteur", "price": 20, "": "Stock B", "active": true }
|
|
33
33
|
]</pre>
|
|
34
|
-
<div class="mt-2">Note : <strong>
|
|
34
|
+
<div class="mt-2">Note : <strong>price est en euros</strong> dans ce plugin (ex: 50 = 50€).</div>
|
|
35
35
|
</div>
|
|
36
36
|
|
|
37
37
|
<div class="card card-body mb-3">
|
|
38
38
|
<h5>Permissions</h5>
|
|
39
|
-
<div class="mb-3">
|
|
40
|
-
<label class="form-label">Groupes autorisés à créer (CSV)</label>
|
|
41
|
-
<input name="creatorGroups" class="form-control" value="{settings.creatorGroups}">
|
|
42
|
-
</div>
|
|
43
|
-
<div class="mb-3">
|
|
44
|
-
<label class="form-label">Groupe validateur</label>
|
|
45
|
-
<input name="approverGroup" class="form-control" value="{settings.approverGroup}">
|
|
46
|
-
</div>
|
|
47
|
-
<div class="mb-3">
|
|
48
|
-
<label class="form-label">Groupe notifié (emails/notifs)</label>
|
|
49
|
-
<input name="notifyGroup" class="form-control" value="{settings.notifyGroup}">
|
|
50
|
-
</div>
|
|
51
|
-
<div class="form-check">
|
|
52
|
-
<input class="form-check-input" type="checkbox" name="showRequesterToAll" id="showRequesterToAll" {{{ if view_showRequesterToAll }}}checked{{{ end }}}>
|
|
53
|
-
<label class="form-check-label" for="showRequesterToAll">Afficher le demandeur à tout le monde</label>
|
|
54
|
-
</div>
|
|
55
|
-
</div>
|
|
56
|
-
|
|
57
|
-
<div class="card card-body mb-3">
|
|
58
|
-
<h5>Matériel</h5>
|
|
59
|
-
<div class="mb-3">
|
|
60
|
-
<label class="form-label">Source</label>
|
|
61
|
-
<select class="form-select" name="itemsSource">
|
|
62
|
-
<option value="manual">Manuel (JSON)</option>
|
|
63
|
-
<option value="helloasso">HelloAsso (articles d’un formulaire)</option>
|
|
64
|
-
</select>
|
|
65
|
-
<div class="form-text">Si HelloAsso est choisi, la liste du matériel est récupérée via l’API HelloAsso (items d’un formulaire).</div>
|
|
66
|
-
</div>
|
|
67
|
-
|
|
68
|
-
<div class="mb-3">
|
|
69
|
-
<label class="form-label">HelloAsso formType (ex: shop, event, membership, donation)</label>
|
|
70
|
-
<input class="form-control" name="ha_itemsFormType" value="{settings.ha_itemsFormType}">
|
|
71
|
-
</div>
|
|
72
|
-
<div class="mb-3">
|
|
73
|
-
<label class="form-label">HelloAsso formSlug</label>
|
|
74
|
-
<input class="form-control" name="ha_itemsFormSlug" value="{settings.ha_itemsFormSlug}">
|
|
75
|
-
</div>
|
|
76
|
-
<div class="mb-3">
|
|
77
|
-
<label class="form-label">Mapping lieux (JSON) par id d’article HelloAsso</label>
|
|
78
|
-
<textarea class="form-control" rows="4" name="ha_locationMapJson">{settings.ha_locationMapJson}</textarea>
|
|
79
|
-
<div class="form-text">Ex: { "12345": "Local A", "67890": "Local B" }</div>
|
|
80
39
|
</div>
|
|
81
40
|
|
|
82
41
|
<div class="mb-3">
|