nodebb-plugin-equipment-calendar 1.0.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -27,12 +27,12 @@ const DEFAULT_SETTINGS = {
27
27
  creatorGroups: 'registered-users',
28
28
  approverGroup: 'administrators',
29
29
  notifyGroup: 'administrators',
30
- // JSON array of items: [{ "id": "cam1", "name": "Caméra A", "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,
@@ -583,9 +583,8 @@ plugin.init = async function (params) {
583
583
  router.post('/admin/plugins/equipment-calendar/reservations/:rid/delete', middleware.applyCSRF, handleAdminDelete);
584
584
  }
585
585
 
586
- // Public route
587
- router.get('/calendar', middleware.buildHeader, renderCalendarPage);
588
- router.get('/api/calendar', middleware.buildHeader, renderCalendarPage);
586
+ // Convenience alias (optional): /calendar -> /equipment/calendar
587
+ router.get('/calendar', (req, res) => res.redirect('/equipment/calendar'));
589
588
 
590
589
 
591
590
  // To verify webhook signature we need raw body; add a rawBody collector for this route only
@@ -674,7 +673,6 @@ async function renderAdminReservationsPage(req, res) {
674
673
  if (!(await ensureIsAdmin(req, res))) return;
675
674
 
676
675
  const settings = await getSettings();
677
- const saved = false;
678
676
  const items = await getActiveItems(settings);
679
677
  const itemById = {};
680
678
  items.forEach(it => { itemById[it.id] = it; });
@@ -741,7 +739,6 @@ async function renderAdminReservationsPage(req, res) {
741
739
  res.render('admin/plugins/equipment-calendar-reservations', {
742
740
  title: 'Equipment Calendar - Réservations',
743
741
  settings,
744
- saved,
745
742
  rows: pageRows,
746
743
  hasRows: pageRows.length > 0,
747
744
  itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
@@ -813,8 +810,7 @@ async function handleHelloAssoTest(req, res) {
813
810
  const clear = String(req.query.clear || '') === '1';
814
811
  // force=1 skips in-memory cache and refresh_token; clear=1 wipes stored refresh token
815
812
  haTokenCache = null;
816
- await getHelloAssoAccessToken(settings,
817
- { force, clearStored: clear });
813
+ await getHelloAssoAccessToken(settings, { force, clearStored: clear });
818
814
  const items = await fetchHelloAssoItems(settings);
819
815
  const list = Array.isArray(items) ? items : (Array.isArray(items.data) ? items.data : []);
820
816
  count = list.length;
@@ -837,7 +833,7 @@ async function handleHelloAssoTest(req, res) {
837
833
  message,
838
834
  count,
839
835
  settings,
840
- sampleItems,
836
+ sampleItems,
841
837
  hasSampleItems,
842
838
  hasSampleItems: sampleItems && sampleItems.length > 0,
843
839
  });
@@ -845,11 +841,9 @@ sampleItems,
845
841
 
846
842
  async function renderAdminPage(req, res) {
847
843
  const settings = await getSettings();
848
- const saved = String(req.query.saved || '') === '1';
849
844
  res.render('admin/plugins/equipment-calendar', {
850
845
  title: 'Equipment Calendar',
851
846
  settings,
852
- saved,
853
847
  saved: req.query && String(req.query.saved || '') === '1',
854
848
  purged: req.query && parseInt(req.query.purged, 10) || 0,
855
849
  view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
@@ -874,8 +868,7 @@ async function handleHelloAssoCallback(req, res) {
874
868
 
875
869
  try {
876
870
  if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
877
- const checkout = await fetchHelloAssoCheckoutIntent(settings,
878
- saved, checkoutIntentId);
871
+ const checkout = await fetchHelloAssoCheckoutIntent(settings, checkoutIntentId);
879
872
 
880
873
  const paid = isCheckoutPaid(checkout);
881
874
  if (paid) {
@@ -1125,8 +1118,7 @@ async function renderApprovalsPage(req, res) {
1125
1118
 
1126
1119
  // --- Actions ---
1127
1120
 
1128
- async function createReservationForItem(req, res, settings,
1129
- saved, itemId, startMs, endMs, notesUser, bookingId) {
1121
+ async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId) {
1130
1122
  const items = await getActiveItems(settings);
1131
1123
  const item = items.find(i => i.id === itemId);
1132
1124
  if (!item) {
@@ -1170,8 +1162,7 @@ async function handleCreateReservation(req, res) {
1170
1162
  const itemIdsRaw = String(req.body.itemIds || req.body.itemId || '').trim();
1171
1163
  const itemIds = itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
1172
1164
  if (!itemIds.length) {
1173
- req.flash('error', 'Veuillez sélectionner au moins un matériel.');
1174
- return res.redirect('/equipment/calendar');
1165
+ return res.status(400).send('itemId required');
1175
1166
  }
1176
1167
  const item = items.find(i => i.id === itemId);
1177
1168
  if (!item) return res.status(400).send('Invalid item');
@@ -1251,8 +1242,7 @@ async function handleApproveReservation(req, res) {
1251
1242
  return res.status(400).send('HelloAsso not configured');
1252
1243
  }
1253
1244
  const token = await helloAssoGetAccessToken(settings);
1254
- const checkout = await helloAssoCreateCheckout(settings,
1255
- saved, token, reservation, item);
1245
+ const checkout = await helloAssoCreateCheckout(settings, token, reservation, item);
1256
1246
  reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
1257
1247
  reservation.ha_paymentUrl = checkout.paymentUrl;
1258
1248
  }
@@ -1403,7 +1393,7 @@ async function handleAdminSave(req, res) {
1403
1393
  itemsJson: '[]',
1404
1394
  ha_itemsFormType: String(req.body.ha_itemsFormType || DEFAULT_SETTINGS.ha_itemsFormType),
1405
1395
  ha_itemsFormSlug: String(req.body.ha_itemsFormSlug || DEFAULT_SETTINGS.ha_itemsFormSlug),
1406
-
1396
+ ha_locationMapJson: String(req.body.ha_locationMapJson || DEFAULT_SETTINGS.ha_locationMapJson),
1407
1397
  ha_clientId: String(req.body.ha_clientId || ''),
1408
1398
  ha_clientSecret: String(req.body.ha_clientSecret || ''),
1409
1399
  ha_organizationSlug: String(req.body.ha_organizationSlug || ''),
@@ -1413,8 +1403,7 @@ async function handleAdminSave(req, res) {
1413
1403
  timezone: String(req.body.timezone || DEFAULT_SETTINGS.timezone),
1414
1404
  paymentTimeoutMinutes: String(req.body.paymentTimeoutMinutes || DEFAULT_SETTINGS.paymentTimeoutMinutes),
1415
1405
  showRequesterToAll: (req.body.showRequesterToAll === '1' || req.body.showRequesterToAll === 'on') ? '1' : '0',
1416
-
1417
- ha_apiBaseUrl: String(req.body.ha_apiBaseUrl || DEFAULT_SETTINGS.ha_apiBaseUrl),};
1406
+ };
1418
1407
 
1419
1408
  await meta.settings.set(SETTINGS_KEY, values);
1420
1409
  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.0.7",
3
+ "version": "2.0.0",
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.7",
28
+ "version": "0.7.0",
29
29
  "minver": "4.7.1"
30
30
  }
@@ -6,10 +6,10 @@ define('admin/plugins/equipment-calendar', ['jquery', 'settings', 'alerts'], fun
6
6
  const Admin = {};
7
7
 
8
8
  Admin.init = function () {
9
- const $container = /*$removed*/('.equipment-calendar-settings');
9
+ const $container = $('.equipment-calendar-settings');
10
10
  Settings.load('equipmentCalendar', $container);
11
11
 
12
- /*$removed*/('#save').on('click', function () {
12
+ $('#save').on('click', function () {
13
13
  Settings.save('equipmentCalendar', $container, function () {
14
14
  alerts.success('Sauvegardé');
15
15
  });
@@ -1,145 +1,179 @@
1
+ require(['jquery'], function ($) {
1
2
  'use strict';
3
+ /* global window, document, FullCalendar, bootbox */
2
4
 
3
5
  (function () {
4
- // Expose as globals for templates that might call them
5
- window.updateTotalPrice = window.updateTotalPrice || function () {};
6
- window.getReservationDays = window.getReservationDays || function () { return 1; };
7
-
8
- function toUtcMidnightMs(dateObj) {
9
- return Date.UTC(dateObj.getUTCFullYear(), dateObj.getUTCMonth(), dateObj.getUTCDate());
6
+ function overlaps(aStartMs, aEndMs, bStartMs, bEndMs) {
7
+ return aStartMs < bEndMs && aEndMs > bStartMs;
10
8
  }
11
9
 
12
- function formatDateInputValue(dateObj) {
13
- const y = dateObj.getUTCFullYear();
14
- const m = String(dateObj.getUTCMonth() + 1).padStart(2, '0');
15
- const d = String(dateObj.getUTCDate()).padStart(2, '0');
16
- return `${y}-${m}-${d}`;
10
+ function fmt(dt) {
11
+ try {
12
+ return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'full', timeStyle: 'short' }).format(dt);
13
+ } catch (e) {
14
+ return dt.toISOString();
15
+ }
17
16
  }
18
17
 
19
- function getReservationDays() {
20
- const startMs = parseInt(document.getElementById('ec-start-ms')?.value || '0', 10);
21
- const endMs = parseInt(document.getElementById('ec-end-ms')?.value || '0', 10);
22
- if (!startMs || !endMs || endMs <= startMs) return 1;
23
- const days = Math.round((endMs - startMs) / (24 * 60 * 60 * 1000));
24
- return days > 0 ? days : 1;
25
- }
26
- window.getReservationDays = getReservationDays;
18
+ function buildAvailability(startMs, endMs) {
19
+ const blocks = Array.isArray(window.EC_BLOCKS) ? window.EC_BLOCKS : [];
20
+ const items = Array.isArray(window.EC_ITEMS) ? window.EC_ITEMS : [];
27
21
 
28
- function updateTotalPrice() {
29
- try {
30
- const sel = document.getElementById('ec-item-ids');
31
- const out = document.getElementById('ec-total-price');
32
- const daysEl = document.getElementById('ec-total-days');
33
- if (!sel || !out) return;
34
-
35
- let unitTotal = 0;
36
- Array.from(sel.selectedOptions || []).forEach((opt) => {
37
- const p = parseFloat(opt.getAttribute('data-price') || '0');
38
- if (!Number.isNaN(p)) unitTotal += p;
39
- });
40
-
41
- const days = getReservationDays();
42
- const finalTotal = unitTotal * days;
43
-
44
- if (daysEl) {
45
- daysEl.textContent = days + (days > 1 ? ' jours' : ' jour');
46
- }
22
+ const blockedByItem = {};
23
+ for (const b of blocks) {
24
+ if (!blockedByItem[b.itemId]) blockedByItem[b.itemId] = [];
25
+ blockedByItem[b.itemId].push(b);
26
+ }
47
27
 
48
- const txt = Number.isInteger(finalTotal) ? String(finalTotal) : finalTotal.toFixed(2);
49
- out.textContent = txt + ' €';
50
- } catch (e) {}
28
+ const available = [];
29
+ for (const it of items) {
30
+ const bks = blockedByItem[it.id] || [];
31
+ const hasOverlap = bks.some(b => overlaps(startMs, endMs, Number(b.startMs), Number(b.endMs)));
32
+ if (!hasOverlap) available.push(it);
33
+ }
34
+ return available;
51
35
  }
52
- window.updateTotalPrice = updateTotalPrice;
53
-
54
- function openCreateModal(startMs, endMs) {
55
- const startDateEl = document.getElementById('ec-start-date');
56
- const endDateEl = document.getElementById('ec-end-date');
57
- const startMsEl = document.getElementById('ec-start-ms');
58
- const endMsEl = document.getElementById('ec-end-ms');
59
36
 
60
- if (startMsEl) startMsEl.value = String(startMs);
61
- if (endMsEl) endMsEl.value = String(endMs);
37
+ function submitReservation(startISO, endISO, itemId, notes) {
38
+ const form = document.getElementById('ec-create-form');
39
+ if (!form) return;
62
40
 
63
- if (startDateEl) startDateEl.value = formatDateInputValue(new Date(startMs));
64
- if (endDateEl) endDateEl.value = formatDateInputValue(new Date(endMs - 24 * 60 * 60 * 1000));
41
+ const startInput = form.querySelector('input[name="start"]');
42
+ const endInput = form.querySelector('input[name="end"]');
43
+ const itemInput = form.querySelector('input[name="itemIds"]');
44
+ const notesInput = form.querySelector('input[name="notesUser"]');
65
45
 
66
- updateTotalPrice();
46
+ if (startInput) startInput.value = startISO;
47
+ if (endInput) endInput.value = endISO;
48
+ if (itemInput) itemInput.value = itemId;
49
+ if (notesInput) notesInput.value = notes || '';
67
50
 
68
- const modalEl = document.getElementById('ec-create-modal');
69
- if (modalEl && window.bootstrap && window.bootstrap.Modal) {
70
- const modal = window.bootstrap.Modal.getOrCreateInstance(modalEl);
71
- modal.show();
72
- }
51
+ form.submit();
73
52
  }
74
53
 
75
- function syncDatesToMs() {
76
- try {
77
- const s = document.getElementById('ec-start-date')?.value;
78
- const e = document.getElementById('ec-end-date')?.value;
79
- if (!s || !e) return;
54
+ function openNodeBBModal(startISO, endISO) {
55
+ const startDate = new Date(startISO);
56
+ const endDate = new Date(endISO);
57
+ const startMs = startDate.getTime();
58
+ const endMs = endDate.getTime();
80
59
 
81
- const sd = new Date(s + 'T00:00:00Z');
82
- const ed = new Date(e + 'T00:00:00Z');
60
+ const available = buildAvailability(startMs, endMs);
61
+
62
+ if (!available.length) {
63
+ if (typeof bootbox !== 'undefined') {
64
+ bootbox.alert('Aucun matériel n’est disponible sur cette plage.');
65
+ } else {
66
+ alert('Aucun matériel n’est disponible sur cette plage.');
67
+ }
68
+ return;
69
+ }
83
70
 
84
- const startMs = toUtcMidnightMs(sd);
85
- let endMs = toUtcMidnightMs(ed) + 24 * 60 * 60 * 1000;
86
- if (endMs <= startMs) endMs = startMs + 24 * 60 * 60 * 1000;
71
+ // Build modal HTML
72
+ let optionsHtml = '';
73
+ for (const it of available) {
74
+ const label = it.location ? `${it.name} — ${it.location}` : it.name;
75
+ optionsHtml += `<option value="${it.id}">${label}</option>`;
76
+ }
87
77
 
88
- const startMsEl = document.getElementById('ec-start-ms');
89
- const endMsEl = document.getElementById('ec-end-ms');
90
- if (startMsEl) startMsEl.value = String(startMs);
91
- if (endMsEl) endMsEl.value = String(endMs);
78
+ const body = `
79
+ <div class="mb-3">
80
+ <label class="form-label">Début</label>
81
+ <input class="form-control" type="text" value="${fmt(startDate)}" readonly>
82
+ </div>
83
+ <div class="mb-3">
84
+ <label class="form-label">Fin</label>
85
+ <input class="form-control" type="text" value="${fmt(endDate)}" readonly>
86
+ </div>
87
+ <div class="mb-3">
88
+ <label class="form-label">Matériel (Ctrl/Cmd pour multi-sélection)</label>
89
+ <select class="form-select" id="ec-modal-item" multiple size="6">
90
+ ${optionsHtml}
91
+ </select>
92
+ </div>
93
+ <div class="mb-3">
94
+ <label class="form-label">Note (optionnel)</label>
95
+ <input class="form-control" type="text" id="ec-modal-notes" maxlength="2000" placeholder="Ex: besoin de trépied, etc.">
96
+ </div>
97
+ `;
98
+
99
+ if (typeof bootbox === 'undefined') {
100
+ // Fallback without bootbox (should be rare on NodeBB pages)
101
+ const itemId = available[0].id;
102
+ submitReservation(startDate.toISOString(), endDate.toISOString(), itemId, '');
103
+ return;
104
+ }
92
105
 
93
- updateTotalPrice();
94
- } catch (e) {}
106
+ bootbox.dialog({
107
+ title: 'Demande de réservation',
108
+ message: body,
109
+ buttons: {
110
+ cancel: {
111
+ label: 'Annuler',
112
+ className: 'btn-outline-secondary',
113
+ },
114
+ ok: {
115
+ label: 'Envoyer la demande',
116
+ className: 'btn-primary',
117
+ callback: function () {
118
+ const itemEl = document.getElementById('ec-modal-item');
119
+ const notesEl = document.getElementById('ec-modal-notes');
120
+ let ids = [];
121
+ if (itemEl && itemEl.options) {
122
+ for (const opt of itemEl.options) { if (opt.selected) ids.push(opt.value); }
123
+ }
124
+ if (!ids.length) ids = [available[0].id];
125
+ const notes = notesEl ? notesEl.value : '';
126
+ submitReservation(startDate.toISOString(), endDate.toISOString(), ids.join(','), notes);
127
+ }
128
+ }
129
+ }
130
+ });
95
131
  }
96
132
 
97
- function initEquipmentCalendar() {
98
- const calendarEl = document.getElementById('ec-calendar');
99
- if (!calendarEl || !window.FullCalendar) return;
133
+ function initCalendar() {
134
+ const el = document.getElementById('equipment-calendar');
135
+ if (!el || typeof FullCalendar === 'undefined') return;
100
136
 
101
- const eventsJsonEl = document.getElementById('ec-events-json');
102
- let events = [];
103
- try {
104
- events = eventsJsonEl ? JSON.parse(eventsJsonEl.textContent || '[]') : [];
105
- } catch (e) {
106
- events = [];
107
- }
137
+ const events = window.EC_EVENTS || [];
138
+ const initialDate = window.EC_INITIAL_DATE;
139
+ const initialView = window.EC_INITIAL_VIEW || 'dayGridMonth';
108
140
 
109
- const calendar = new window.FullCalendar.Calendar(calendarEl, {
110
- initialView: 'dayGridMonth',
111
- selectable: true,
141
+ const calendar = new FullCalendar.Calendar(el, {
142
+ initialView: initialView,
143
+ initialDate: initialDate,
144
+ timeZone: window.EC_TZ || 'local',
145
+ selectable: window.EC_CAN_CREATE === true,
112
146
  selectMirror: true,
113
- events,
147
+ events: events,
148
+
149
+ // Range selection (drag)
114
150
  select: function (info) {
115
- const startMs = toUtcMidnightMs(info.start);
116
- const endMs = toUtcMidnightMs(info.end);
117
- openCreateModal(startMs, endMs);
151
+ openNodeBBModal(info.startStr, info.endStr);
118
152
  },
153
+
154
+ // Single date click
119
155
  dateClick: function (info) {
120
- const startMs = toUtcMidnightMs(info.date);
121
- const endMs = startMs + 24 * 60 * 60 * 1000;
122
- openCreateModal(startMs, endMs);
156
+ // Default: 1h slot
157
+ const start = info.date;
158
+ const end = new Date(start.getTime() + 60 * 60 * 1000);
159
+ openNodeBBModal(start.toISOString(), end.toISOString());
160
+ },
161
+
162
+ headerToolbar: {
163
+ left: 'prev,next today',
164
+ center: 'title',
165
+ right: 'dayGridMonth,timeGridWeek,timeGridDay'
123
166
  },
124
167
  });
125
168
 
126
169
  calendar.render();
127
-
128
- const sel = document.getElementById('ec-item-ids');
129
- if (sel) {
130
- sel.addEventListener('change', updateTotalPrice);
131
- }
132
- const s = document.getElementById('ec-start-date');
133
- const e = document.getElementById('ec-end-date');
134
- if (s) s.addEventListener('change', syncDatesToMs);
135
- if (e) e.addEventListener('change', syncDatesToMs);
136
-
137
- updateTotalPrice();
138
170
  }
139
171
 
140
- // Init on normal page load
141
- document.addEventListener('DOMContentLoaded', initEquipmentCalendar);
172
+ if (document.readyState === 'loading') {
173
+ document.addEventListener('DOMContentLoaded', initCalendar);
174
+ } else {
175
+ initCalendar();
176
+ }
177
+ }());
142
178
 
143
- // Init after ajaxify navigation (NodeBB emits this on window)
144
- window.addEventListener('action:ajaxify.end', initEquipmentCalendar);
145
- })();
179
+ });
@@ -13,72 +13,57 @@
13
13
 
14
14
  <div class="card card-body mb-3">
15
15
  <h5>Permissions</h5>
16
-
17
16
  <div class="mb-3">
18
17
  <label class="form-label">Groupes autorisés à créer une demande (creatorGroups)</label>
19
18
  <input class="form-control" type="text" name="creatorGroups" value="{settings.creatorGroups}">
20
- <div class="form-text">Nom(s) de groupes NodeBB. Pour plusieurs groupes, sépare par des virgules. Ex: <code>registered-users, members</code></div>
21
19
  </div>
22
-
23
20
  <div class="mb-3">
24
21
  <label class="form-label">Groupe valideur (approverGroup)</label>
25
22
  <input class="form-control" type="text" name="approverGroup" value="{settings.approverGroup}">
26
- <div class="form-text">Les membres de ce groupe voient /equipment/approvals et peuvent valider/refuser.</div>
27
23
  </div>
28
-
29
24
  <div class="mb-0">
30
25
  <label class="form-label">Groupe notifié (notifyGroup)</label>
31
26
  <input class="form-control" type="text" name="notifyGroup" value="{settings.notifyGroup}">
32
- <div class="form-text">Envoi d’un email/notification au groupe lors d’une nouvelle demande.</div>
33
27
  </div>
34
28
  </div>
35
29
 
36
-
37
30
  <div class="card card-body mb-3">
38
31
  <h5>Paiement</h5>
39
32
  <div class="mb-0">
40
33
  <label class="form-label">Timeout paiement (minutes)</label>
41
34
  <input class="form-control" type="number" min="1" name="paymentTimeoutMinutes" value="{settings.paymentTimeoutMinutes}">
42
- <div class="form-text">Après validation, si le paiement n’est pas confirmé dans ce délai, la réservation est annulée et le matériel est débloqué.</div>
43
35
  </div>
44
36
  </div>
45
37
 
46
- <div class="card card-body mb-3">
38
+ <div class="card card-body mb-3">
47
39
  <h5>HelloAsso</h5>
48
-
49
40
  <div class="mb-3">
50
41
  <label class="form-label">API Base URL (prod/sandbox)</label>
51
42
  <input class="form-control" type="text" name="ha_apiBaseUrl" value="{settings.ha_apiBaseUrl}">
52
- <div class="form-text">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}">
@@ -13,60 +13,14 @@
13
13
  <div id="equipment-calendar"></div>
14
14
  </div>
15
15
 
16
- <!-- Modal création -->
17
- <div class="modal fade" id="ec-create-modal" tabindex="-1" aria-hidden="true">
18
- <div class="modal-dialog modal-dialog-centered">
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
- <form id="ec-create-form" method="post" action="/equipment/reservations/create">
26
- <div class="modal-body">
27
- <input type="hidden" name="_csrf" value="{config.csrf_token}">
28
- <input type="hidden" id="ec-start-ms" name="startMs" value="0">
29
- <input type="hidden" id="ec-end-ms" name="endMs" value="0">
30
-
31
- <div class="row g-3 mb-3">
32
- <div class="col-6">
33
- <label class="form-label">Début</label>
34
- <input class="form-control" type="date" id="ec-start-date" required>
35
- </div>
36
- <div class="col-6">
37
- <label class="form-label">Fin</label>
38
- <input class="form-control" type="date" id="ec-end-date" required>
39
- </div>
40
- </div>
41
-
42
- <div class="mb-3">
43
- <label class="form-label">Matériel</label>
44
- <select class="form-select" id="ec-item-ids" name="itemIds" multiple required></select>
45
- <div class="form-text">Tu peux sélectionner plusieurs matériels.</div>
46
- </div>
47
-
48
- <div class="mb-3">
49
- <div class="fw-semibold">Durée</div>
50
- <div id="ec-total-days">1 jour</div>
51
- <hr class="my-2">
52
- <div class="fw-semibold">Total estimé</div>
53
- <div id="ec-total-price" class="fs-5">0 €</div>
54
- </div>
55
-
56
- <div class="mb-0">
57
- <label class="form-label">Note</label>
58
- <textarea class="form-control" name="notesUser" rows="3" placeholder="Optionnel"></textarea>
59
- </div>
60
- </div>
61
-
62
- <div class="modal-footer">
63
- <button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuler</button>
64
- <button type="submit" class="btn btn-primary">Envoyer la demande</button>
65
- </div>
66
- </form>
67
- </div>
68
- </div>
69
- </div>
16
+ <!-- Formulaire de soumission (utilisé par la modale JS) -->
17
+ <form id="ec-create-form" method="post" action="/equipment/reservations/create" class="d-none">
18
+ <input type="hidden" name="_csrf" value="{config.csrf_token}">
19
+ <input type="hidden" name="start" value="">
20
+ <input type="hidden" name="end" value="">
21
+ <input type="hidden" name="itemIds" value="">
22
+ <input type="hidden" name="notesUser" value="">
23
+ </form>
70
24
  </div>
71
25
 
72
26
  <script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js"></script>
@@ -77,7 +31,12 @@
77
31
  window.EC_BLOCKS = JSON.parse(atob('{blocksB64}'));
78
32
  window.EC_ITEMS = JSON.parse(atob('{itemsB64}'));
79
33
  window.EC_INITIAL_DATE = "{initialDateISO}";
34
+ window.EC_INITIAL_VIEW = "{view}";
80
35
  window.EC_TZ = "{tz}";
81
36
  window.EC_CAN_CREATE = {canCreateJs};
82
37
  </script>
83
- <script type="application/json" id="ec-events-json">{eventsJson}</script>
38
+
39
+ <style>
40
+ .ec-status-pending .fc-event-title { font-weight: 600; }
41
+ .ec-status-valid .fc-event-title { font-weight: 700; }
42
+ </style>