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 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", "priceCents": 5000, "location": "Stock A", "active": true }]
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
- ha_locationMapJson: '{}',
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, opts = {}) {
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, bookingId, reservations) {
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 price = (byId[r.itemId] && parseInt(byId[r.itemId].priceCents, 10)) || 0;
140
- return sum + (Number.isFinite(price) ? price : 0);
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, checkoutIntentId) {
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 priceCents = (typeof amount === 'number' ? amount : parseInt(amount, 10)) || 0;
252
- out.push({ id, name, priceCents: String(priceCents), location: '' });
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
- priceCents: Number(it.priceCents || 0),
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 price =
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 priceCents = typeof price === 'number' ? price : 0;
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
- location: String(locMap[id] || ''),
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, token, reservation, item) {
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.priceCents || 0));
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, { force, clearStored: clear });
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, checkoutIntentId);
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, itemId, startMs, endMs, notesUser, bookingId) {
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
- return res.status(400).send('itemId required');
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, token, reservation, item);
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
- ha_locationMapJson: String(req.body.ha_locationMapJson || DEFAULT_SETTINGS.ha_locationMapJson),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "0.8.6",
3
+ "version": "0.9.1",
4
4
  "description": "Equipment reservation calendar for NodeBB (FullCalendar, approvals, HelloAsso payments)",
5
5
  "main": "library.js",
6
6
  "scripts": {
package/plugin.json CHANGED
@@ -25,6 +25,6 @@
25
25
  "scripts": [
26
26
  "public/js/client.js"
27
27
  ],
28
- "version": "0.5.0",
28
+ "version": "0.5.6",
29
29
  "minver": "4.7.1"
30
30
  }
@@ -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", "priceCents": 50, "location": "Stock A", "active": true },
32
- { "id": "light1", "name": "Projecteur", "priceCents": 20, "location": "Stock B", "active": true }
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>priceCents est en euros</strong> dans ce plugin (ex: 50 = 50€).</div>
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">