nodebb-plugin-equipment-calendar 1.1.0 → 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,
@@ -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) {
@@ -1169,8 +1162,7 @@ async function handleCreateReservation(req, res) {
1169
1162
  const itemIdsRaw = String(req.body.itemIds || req.body.itemId || '').trim();
1170
1163
  const itemIds = itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
1171
1164
  if (!itemIds.length) {
1172
- req.flash('error', 'Veuillez sélectionner au moins un matériel.');
1173
- return res.redirect('/equipment/calendar');
1165
+ return res.status(400).send('itemId required');
1174
1166
  }
1175
1167
  const item = items.find(i => i.id === itemId);
1176
1168
  if (!item) return res.status(400).send('Invalid item');
@@ -1250,8 +1242,7 @@ async function handleApproveReservation(req, res) {
1250
1242
  return res.status(400).send('HelloAsso not configured');
1251
1243
  }
1252
1244
  const token = await helloAssoGetAccessToken(settings);
1253
- const checkout = await helloAssoCreateCheckout(settings,
1254
- saved, token, reservation, item);
1245
+ const checkout = await helloAssoCreateCheckout(settings, token, reservation, item);
1255
1246
  reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
1256
1247
  reservation.ha_paymentUrl = checkout.paymentUrl;
1257
1248
  }
@@ -1402,7 +1393,7 @@ async function handleAdminSave(req, res) {
1402
1393
  itemsJson: '[]',
1403
1394
  ha_itemsFormType: String(req.body.ha_itemsFormType || DEFAULT_SETTINGS.ha_itemsFormType),
1404
1395
  ha_itemsFormSlug: String(req.body.ha_itemsFormSlug || DEFAULT_SETTINGS.ha_itemsFormSlug),
1405
-
1396
+ ha_locationMapJson: String(req.body.ha_locationMapJson || DEFAULT_SETTINGS.ha_locationMapJson),
1406
1397
  ha_clientId: String(req.body.ha_clientId || ''),
1407
1398
  ha_clientSecret: String(req.body.ha_clientSecret || ''),
1408
1399
  ha_organizationSlug: String(req.body.ha_organizationSlug || ''),
@@ -1412,8 +1403,7 @@ async function handleAdminSave(req, res) {
1412
1403
  timezone: String(req.body.timezone || DEFAULT_SETTINGS.timezone),
1413
1404
  paymentTimeoutMinutes: String(req.body.paymentTimeoutMinutes || DEFAULT_SETTINGS.paymentTimeoutMinutes),
1414
1405
  showRequesterToAll: (req.body.showRequesterToAll === '1' || req.body.showRequesterToAll === 'on') ? '1' : '0',
1415
-
1416
- ha_apiBaseUrl: String(req.body.ha_apiBaseUrl || DEFAULT_SETTINGS.ha_apiBaseUrl),};
1406
+ };
1417
1407
 
1418
1408
  await meta.settings.set(SETTINGS_KEY, values);
1419
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.1.0",
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.8",
28
+ "version": "0.7.0",
29
29
  "minver": "4.7.1"
30
30
  }
@@ -1,139 +1,179 @@
1
+ require(['jquery'], function ($) {
1
2
  'use strict';
2
-
3
- /* global define, require */
3
+ /* global window, document, FullCalendar, bootbox */
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');
6
+ function overlaps(aStartMs, aEndMs, bStartMs, bEndMs) {
7
+ return aStartMs < bEndMs && aEndMs > bStartMs;
8
+ }
63
9
 
64
- if (startMsEl) startMsEl.value = String(startMs);
65
- if (endMsEl) endMsEl.value = String(endMs);
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
+ }
16
+ }
66
17
 
67
- if (startDateEl) startDateEl.value = formatDateInputValue(new Date(startMs));
68
- if (endDateEl) endDateEl.value = formatDateInputValue(new Date(endMs - 24 * 60 * 60 * 1000));
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 : [];
21
+
22
+ const blockedByItem = {};
23
+ for (const b of blocks) {
24
+ if (!blockedByItem[b.itemId]) blockedByItem[b.itemId] = [];
25
+ blockedByItem[b.itemId].push(b);
26
+ }
27
+
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;
35
+ }
69
36
 
70
- updateTotalPrice();
37
+ function submitReservation(startISO, endISO, itemId, notes) {
38
+ const form = document.getElementById('ec-create-form');
39
+ if (!form) return;
71
40
 
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
- }
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"]');
78
45
 
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;
46
+ if (startInput) startInput.value = startISO;
47
+ if (endInput) endInput.value = endISO;
48
+ if (itemInput) itemInput.value = itemId;
49
+ if (notesInput) notesInput.value = notes || '';
84
50
 
85
- const sd = new Date(s + 'T00:00:00Z');
86
- const ed = new Date(e + 'T00:00:00Z');
51
+ form.submit();
52
+ }
87
53
 
88
- const startMs = toUtcMidnightMs(sd);
89
- let endMs = toUtcMidnightMs(ed) + 24 * 60 * 60 * 1000;
90
- if (endMs <= startMs) endMs = startMs + 24 * 60 * 60 * 1000;
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();
91
59
 
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);
60
+ const available = buildAvailability(startMs, endMs);
96
61
 
97
- updateTotalPrice();
98
- } catch (e) {}
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.');
99
67
  }
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();
68
+ return;
69
+ }
70
+
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
+ }
77
+
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
+ }
105
+
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
+ }
131
129
  }
130
+ });
131
+ }
132
132
 
133
- updateTotalPrice();
133
+ function initCalendar() {
134
+ const el = document.getElementById('equipment-calendar');
135
+ if (!el || typeof FullCalendar === 'undefined') return;
136
+
137
+ const events = window.EC_EVENTS || [];
138
+ const initialDate = window.EC_INITIAL_DATE;
139
+ const initialView = window.EC_INITIAL_VIEW || 'dayGridMonth';
140
+
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,
146
+ selectMirror: true,
147
+ events: events,
148
+
149
+ // Range selection (drag)
150
+ select: function (info) {
151
+ openNodeBBModal(info.startStr, info.endStr);
152
+ },
153
+
154
+ // Single date click
155
+ dateClick: function (info) {
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'
166
+ },
134
167
  });
168
+
169
+ calendar.render();
170
+ }
171
+
172
+ if (document.readyState === 'loading') {
173
+ document.addEventListener('DOMContentLoaded', initCalendar);
174
+ } else {
175
+ initCalendar();
135
176
  }
177
+ }());
136
178
 
137
- $(window).off('action:ajaxify.end.ec').on('action:ajaxify.end.ec', init);
138
- $(init);
139
- })();
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="{relative_path}/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,6 +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>
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>