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", "price": 5000, "location": "Stock A", "active": true }]
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 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);
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 price = (typeof amount === 'number' ? amount : parseInt(amount, 10)) || 0;
249
- out.push({ id, name, price: String(price), location: '' });
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.price || 0));
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 items = await getActiveItems(settings);
1169
- const itemIdsRaw = String(req.body.itemIds || req.body.itemId || '').trim();
1170
- const itemIds = itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
1171
- if (!itemIds.length) {
1172
- req.flash('error', 'Veuillez sélectionner au moins un matériel.');
1173
- return res.redirect('/equipment/calendar');
1174
- }
1175
- const item = items.find(i => i.id === itemId);
1176
- if (!item) return res.status(400).send('Invalid item');
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
- // Overlap
1190
- if (await hasOverlap(itemId, startMs, endMs)) {
1191
- return res.status(409).send('Overlap');
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 reservation = {
1195
- id: uuidv4(),
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
- await saveReservation(reservation);
1186
+ const notesUser = String(req.body.notesUser || '').trim();
1213
1187
 
1214
- // Notify group
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
- return res.redirect(`/equipment/calendar?itemId=${encodeURIComponent(itemId)}`);
1224
- } catch (e) {
1225
- return res.status(500).send(e.message || 'error');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "1.1.0",
3
+ "version": "2.0.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.6.8",
28
+ "version": "0.7.1",
29
29
  "minver": "4.7.1"
30
30
  }
@@ -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
- // NodeBB client environment: use requirejs to fetch jQuery safely
7
- function init() {
8
- require(['jquery'], function ($) {
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');
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', init);
138
- $(init);
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">Production: <code>https://api.helloasso.com</code> — Sandbox: <code>https://api.helloasso-sandbox.com</code></div>
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="equipment-calendar"></div>
12
+ <div class="card card-body mb-3">
13
+ <div id="ec-calendar"></div>
14
14
  </div>
15
15
 
16
- <!-- Modal création -->
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-ms" name="startMs" value="0">
29
- <input type="hidden" id="ec-end-ms" name="endMs" value="0">
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></select>
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-light" data-bs-dismiss="modal">Annuler</button>
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>