nodebb-plugin-equipment-calendar 0.2.6 → 0.2.8

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
@@ -413,14 +413,8 @@ async function renderCalendarPage(req, res) {
413
413
  const settings = await getSettings();
414
414
  const items = parseItems(settings.itemsJson).filter(i => i.active);
415
415
 
416
- const rawItems = items;
417
-
418
416
  const tz = settings.timezone || 'Europe/Paris';
419
417
 
420
- const itemId = String(req.query.itemId || (rawItems[0]?.id || '')).trim();
421
- const chosenItem = rawItems.find(i => i.id === itemId) || rawItems[0] || null;
422
- const itemsView = rawItems.map(it => ({ ...it, selected: chosenItem ? it.id === chosenItem.id : false }));
423
-
424
418
  // Determine range to render
425
419
  const now = DateTime.now().setZone(tz);
426
420
  const view = String(req.query.view || settings.defaultView || 'dayGridMonth');
@@ -438,43 +432,66 @@ async function renderCalendarPage(req, res) {
438
432
  end = now.endOf('month').plus({ days: 1 });
439
433
  }
440
434
 
441
- // Load reservations for chosen item within range
442
- const reservations = chosenItem ? await listReservationsForRange(chosenItem.id, start.toMillis(), end.toMillis()) : [];
435
+ // Load reservations for ALL items within range (so we can build availability client-side without extra requests)
436
+ const allReservations = [];
437
+ for (const it of items) {
438
+ // eslint-disable-next-line no-await-in-loop
439
+ const resForItem = await listReservationsForRange(it.id, start.toMillis(), end.toMillis());
440
+ for (const r of resForItem) allReservations.push(r);
441
+ }
442
+
443
443
  const showRequesterToAll = String(settings.showRequesterToAll) === '1';
444
444
  const isApprover = req.uid ? await canApprove(req.uid, settings) : false;
445
445
 
446
- const requesterUids = Array.from(new Set(reservations.map(r => r.uid))).filter(Boolean);
446
+ const requesterUids = Array.from(new Set(allReservations.map(r => r.uid))).filter(Boolean);
447
447
  const users = requesterUids.length ? await user.getUsersData(requesterUids) : [];
448
448
  const nameByUid = {};
449
449
  (users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
450
450
 
451
- const events = reservations
451
+ const itemById = {};
452
+ items.forEach(it => { itemById[it.id] = it; });
453
+
454
+ const events = allReservations
452
455
  .filter(r => r.status !== 'rejected' && r.status !== 'cancelled')
453
- .map(r => toEvent(r, chosenItem, nameByUid[r.uid], isApprover || showRequesterToAll));
456
+ .map(r => {
457
+ const item = itemById[r.itemId];
458
+ // Include item name in title so "all items" view is readable
459
+ const requesterName = nameByUid[r.uid] || '';
460
+ return toEvent(r, item, requesterName, isApprover || showRequesterToAll);
461
+ });
454
462
 
455
463
  const canUserCreate = req.uid ? await canCreate(req.uid, settings) : false;
456
464
 
465
+ // We expose minimal reservation data for availability checks in the modal
466
+ const blocks = allReservations
467
+ .filter(r => statusBlocksItem(r.status))
468
+ .map(r => ({
469
+ itemId: r.itemId,
470
+ startMs: r.startMs,
471
+ endMs: r.endMs,
472
+ status: r.status,
473
+ }));
474
+
457
475
  res.render('equipment-calendar/calendar', {
458
476
  title: 'Réservation de matériel',
459
- items: itemsView,
460
- chosenItemId: chosenItem ? chosenItem.id : '',
461
- chosenItemName: chosenItem ? chosenItem.name : '',
462
- chosenItemPriceCents: chosenItem ? chosenItem.priceCents : 0,
463
- chosenItemLocation: chosenItem ? chosenItem.location : '',
477
+ items,
464
478
  view,
465
479
  tz,
466
480
  startISO: start.toISO(),
467
481
  endISO: end.toISO(),
468
482
  initialDateISO: start.toISODate(),
469
- eventsB64: Buffer.from(JSON.stringify(events), 'utf8').toString('base64'),
470
483
  canCreate: canUserCreate,
471
484
  canCreateJs: canUserCreate ? 'true' : 'false',
472
485
  isApprover,
486
+ // events are base64 encoded to avoid template escaping issues
487
+ eventsB64: Buffer.from(JSON.stringify(events), 'utf8').toString('base64'),
488
+ blocksB64: Buffer.from(JSON.stringify(blocks), 'utf8').toString('base64'),
489
+ itemsB64: Buffer.from(JSON.stringify(items.map(i => ({ id: i.id, name: i.name, location: i.location }))), 'utf8').toString('base64'),
473
490
  csrf: req.csrfToken,
474
- forumUrl: nconf.get('url'),
475
491
  });
476
492
  }
477
493
 
494
+
478
495
  // --- Approvals page ---
479
496
  async function renderApprovalsPage(req, res) {
480
497
  const settings = await getSettings();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Equipment reservation calendar for NodeBB (FullCalendar, approvals, HelloAsso payments)",
5
5
  "main": "library.js",
6
6
  "scripts": {
@@ -18,7 +18,6 @@
18
18
  "dependencies": {
19
19
  "axios": "^1.7.9",
20
20
  "luxon": "^3.5.0",
21
- "uuid": "^9.0.1",
22
- "fullcalendar": "^6.1.19"
21
+ "uuid": "^9.0.1"
23
22
  }
24
23
  }
package/plugin.json CHANGED
@@ -21,8 +21,7 @@
21
21
  "templates": "./public/templates",
22
22
  "staticDirs": {
23
23
  "js": "public/js",
24
- "css": "public/css",
25
- "vendor": "node_modules/fullcalendar/dist"
24
+ "css": "public/css"
26
25
  },
27
26
  "scripts": [
28
27
  "public/js/client.js"
@@ -1,40 +1,20 @@
1
1
  'use strict';
2
- /* global socket */
3
2
 
4
- define('admin/plugins/equipment-calendar', ['jquery'], function ($) {
5
- const EquipmentCalendar = {};
3
+ /* global define */
6
4
 
7
- EquipmentCalendar.init = function () {
8
- $('#save').on('click', function () {
9
- const payload = {
10
- creatorGroups: $('#creatorGroups').val(),
11
- approverGroup: $('#approverGroup').val(),
12
- notifyGroup: $('#notifyGroup').val(),
13
- itemsJson: $('#itemsJson').val(),
14
- ha_clientId: $('#ha_clientId').val(),
15
- ha_clientSecret: $('#ha_clientSecret').val(),
16
- ha_organizationSlug: $('#ha_organizationSlug').val(),
17
- ha_returnUrl: $('#ha_returnUrl').val(),
18
- ha_webhookSecret: $('#ha_webhookSecret').val(),
19
- defaultView: $('#defaultView').val(),
20
- timezone: $('#timezone').val(),
21
- showRequesterToAll: $('#showRequesterToAll').is(':checked') ? '1' : '0',
22
- };
5
+ define('admin/plugins/equipment-calendar', ['jquery', 'settings', 'alerts'], function ($, Settings, alerts) {
6
+ const Admin = {};
7
+
8
+ Admin.init = function () {
9
+ const $container = $('.equipment-calendar-settings');
10
+ Settings.load('equipmentCalendar', $container);
23
11
 
24
- socket.emit('admin.settings.save', { hash: 'equipmentCalendar', values: payload }, function (err) {
25
- if (err) {
26
- if (window.app && window.app.alertError) {
27
- return window.app.alertError(err.message || err);
28
- }
29
- return alert(err.message || err);
30
- }
31
- if (window.app && window.app.alertSuccess) {
32
- return window.app.alertSuccess('Sauvegardé');
33
- }
34
- alert('Sauvegardé');
12
+ $('#save').on('click', function () {
13
+ Settings.save('equipmentCalendar', $container, function () {
14
+ alerts.success('Sauvegardé');
35
15
  });
36
16
  });
37
17
  };
38
18
 
39
- return EquipmentCalendar;
19
+ return Admin;
40
20
  });
@@ -1,18 +1,85 @@
1
1
  'use strict';
2
- /* global window, document, FullCalendar */
2
+ /* global window, document, FullCalendar, bootstrap */
3
3
 
4
4
  (function () {
5
- function submitCreate(startISO, endISO) {
5
+ function overlaps(aStartMs, aEndMs, bStartMs, bEndMs) {
6
+ return aStartMs < bEndMs && aEndMs > bStartMs;
7
+ }
8
+
9
+ function fmt(dt) {
10
+ try {
11
+ return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'full', timeStyle: 'short' }).format(dt);
12
+ } catch (e) {
13
+ return dt.toISOString();
14
+ }
15
+ }
16
+
17
+ function openModal(startISO, endISO) {
6
18
  const form = document.getElementById('ec-create-form');
7
19
  if (!form) return;
20
+
21
+ const startDate = new Date(startISO);
22
+ const endDate = new Date(endISO);
23
+ const startMs = startDate.getTime();
24
+ const endMs = endDate.getTime();
25
+
26
+ // write hidden ISO values
8
27
  const startInput = form.querySelector('input[name="start"]');
9
28
  const endInput = form.querySelector('input[name="end"]');
10
- if (startInput) startInput.value = startISO;
11
- if (endInput) endInput.value = endISO;
12
- form.submit();
29
+ if (startInput) startInput.value = startDate.toISOString();
30
+ if (endInput) endInput.value = endDate.toISOString();
31
+
32
+ // displays
33
+ const sDisp = document.getElementById('ecStartDisplay');
34
+ const eDisp = document.getElementById('ecEndDisplay');
35
+ if (sDisp) sDisp.value = fmt(startDate);
36
+ if (eDisp) eDisp.value = fmt(endDate);
37
+
38
+ // compute availability
39
+ const blocks = Array.isArray(window.EC_BLOCKS) ? window.EC_BLOCKS : [];
40
+ const items = Array.isArray(window.EC_ITEMS) ? window.EC_ITEMS : [];
41
+
42
+ const blockedByItem = {};
43
+ for (const b of blocks) {
44
+ if (!blockedByItem[b.itemId]) blockedByItem[b.itemId] = [];
45
+ blockedByItem[b.itemId].push(b);
46
+ }
47
+
48
+ const available = [];
49
+ for (const it of items) {
50
+ const bks = blockedByItem[it.id] || [];
51
+ const hasOverlap = bks.some(b => overlaps(startMs, endMs, Number(b.startMs), Number(b.endMs)));
52
+ if (!hasOverlap) available.push(it);
53
+ }
54
+
55
+ const select = document.getElementById('ecItemSelect');
56
+ const hint = document.getElementById('ecItemHint');
57
+ const noAvail = document.getElementById('ecNoAvailability');
58
+ const submitBtn = document.getElementById('ecSubmitBtn');
59
+
60
+ if (select) {
61
+ select.innerHTML = '';
62
+ for (const it of available) {
63
+ const opt = document.createElement('option');
64
+ opt.value = it.id;
65
+ opt.textContent = it.location ? `${it.name} — ${it.location}` : it.name;
66
+ select.appendChild(opt);
67
+ }
68
+ }
69
+
70
+ const hasAny = available.length > 0;
71
+ if (hint) hint.textContent = hasAny ? '' : '';
72
+ if (noAvail) noAvail.classList.toggle('d-none', hasAny);
73
+ if (submitBtn) submitBtn.disabled = !hasAny;
74
+
75
+ // show modal
76
+ const modalEl = document.getElementById('ecReserveModal');
77
+ if (!modalEl) return;
78
+ const modal = bootstrap ? bootstrap.Modal.getOrCreateInstance(modalEl) : null;
79
+ if (modal) modal.show();
13
80
  }
14
81
 
15
- function init() {
82
+ function initCalendar() {
16
83
  const el = document.getElementById('equipment-calendar');
17
84
  if (!el || typeof FullCalendar === 'undefined') return;
18
85
 
@@ -28,12 +95,13 @@
28
95
  selectMirror: true,
29
96
  events: events,
30
97
  select: function (info) {
31
- submitCreate(info.startStr, info.endStr);
98
+ openModal(info.startStr, info.endStr);
32
99
  },
33
100
  dateClick: function (info) {
101
+ // default 1 hour
34
102
  const start = info.date;
35
103
  const end = new Date(start.getTime() + 60 * 60 * 1000);
36
- submitCreate(start.toISOString(), end.toISOString());
104
+ openModal(start.toISOString(), end.toISOString());
37
105
  },
38
106
  headerToolbar: {
39
107
  left: 'prev,next today',
@@ -46,8 +114,8 @@
46
114
  }
47
115
 
48
116
  if (document.readyState === 'loading') {
49
- document.addEventListener('DOMContentLoaded', init);
117
+ document.addEventListener('DOMContentLoaded', initCalendar);
50
118
  } else {
51
- init();
119
+ initCalendar();
52
120
  }
53
121
  }());
@@ -1,8 +1,8 @@
1
- <div class="acp-page-container">
1
+ <div class="acp-page-container equipment-calendar-settings">
2
2
  <h1>Equipment Calendar</h1>
3
3
 
4
4
  <div class="alert alert-warning">
5
- Les champs "Matériel" doivent être un JSON valide (array). Exemple :
5
+ Le champ "Matériel" doit être un JSON valide (array). Exemple :
6
6
  <pre class="mb-0">[
7
7
  { "id": "cam1", "name": "Caméra A", "priceCents": 5000, "location": "Stock A", "active": true },
8
8
  { "id": "light1", "name": "Projecteur", "priceCents": 2000, "location": "Stock B", "active": true }
@@ -13,48 +13,53 @@
13
13
  <div class="col-lg-8">
14
14
  <div class="card card-body mb-3">
15
15
  <h5>Permissions</h5>
16
+
16
17
  <div class="mb-3">
17
18
  <label class="form-label">Groupes autorisés à créer (CSV)</label>
18
- <input id="creatorGroups" class="form-control" value="{settings.creatorGroups}">
19
+ <input class="form-control" data-key="creatorGroups" placeholder="registered-users,staff">
19
20
  </div>
21
+
20
22
  <div class="mb-3">
21
23
  <label class="form-label">Groupe validateur</label>
22
- <input id="approverGroup" class="form-control" value="{settings.approverGroup}">
24
+ <input class="form-control" data-key="approverGroup" placeholder="administrators">
23
25
  </div>
26
+
24
27
  <div class="mb-3">
25
28
  <label class="form-label">Groupe notifié (emails/notifs)</label>
26
- <input id="notifyGroup" class="form-control" value="{settings.notifyGroup}">
29
+ <input class="form-control" data-key="notifyGroup" placeholder="administrators">
27
30
  </div>
31
+
28
32
  <div class="form-check">
29
- <input class="form-check-input" type="checkbox" id="showRequesterToAll" {{{ if view_showRequesterToAll }}}checked{{{ end }}}>
33
+ <input class="form-check-input" type="checkbox" data-key="showRequesterToAll" id="showRequesterToAll">
30
34
  <label class="form-check-label" for="showRequesterToAll">Afficher le demandeur à tout le monde</label>
31
35
  </div>
32
36
  </div>
33
37
 
34
38
  <div class="card card-body mb-3">
35
39
  <h5>Matériel</h5>
36
- <textarea id="itemsJson" class="form-control" rows="10">{settings.itemsJson}</textarea>
40
+ <textarea class="form-control" rows="10" data-key="itemsJson"></textarea>
37
41
  </div>
38
42
 
39
43
  <div class="card card-body mb-3">
40
44
  <h5>HelloAsso</h5>
41
- <div class="mb-3"><label class="form-label">Client ID</label><input id="ha_clientId" class="form-control" value="{settings.ha_clientId}"></div>
42
- <div class="mb-3"><label class="form-label">Client Secret</label><input id="ha_clientSecret" class="form-control" value="{settings.ha_clientSecret}"></div>
43
- <div class="mb-3"><label class="form-label">Organization Slug</label><input id="ha_organizationSlug" class="form-control" value="{settings.ha_organizationSlug}"></div>
44
- <div class="mb-3"><label class="form-label">Return URL</label><input id="ha_returnUrl" class="form-control" value="{settings.ha_returnUrl}"></div>
45
- <div class="mb-3"><label class="form-label">Webhook Secret (HMAC SHA256)</label><input id="ha_webhookSecret" class="form-control" value="{settings.ha_webhookSecret}"></div>
45
+ <div class="mb-3"><label class="form-label">Client ID</label><input class="form-control" data-key="ha_clientId"></div>
46
+ <div class="mb-3"><label class="form-label">Client Secret</label><input class="form-control" data-key="ha_clientSecret"></div>
47
+ <div class="mb-3"><label class="form-label">Organization Slug</label><input class="form-control" data-key="ha_organizationSlug"></div>
48
+ <div class="mb-3"><label class="form-label">Return URL</label><input class="form-control" data-key="ha_returnUrl"></div>
49
+ <div class="mb-3"><label class="form-label">Webhook Secret (HMAC SHA256)</label><input class="form-control" data-key="ha_webhookSecret"></div>
46
50
  </div>
47
51
 
48
52
  <div class="card card-body mb-3">
49
53
  <h5>Calendrier</h5>
50
- <div class="mb-3"><label class="form-label">Vue par défaut</label>
51
- <select id="defaultView" class="form-select">
52
- <option value="dayGridMonth" {{{ if view_dayGridMonth }}}selected{{{ end }}}>Mois</option>
53
- <option value="timeGridWeek" {{{ if view_timeGridWeek }}}selected{{{ end }}}>Semaine</option>
54
- <option value="timeGridDay" {{{ if view_timeGridDay }}}selected{{{ end }}}>Jour</option>
54
+ <div class="mb-3">
55
+ <label class="form-label">Vue par défaut</label>
56
+ <select class="form-select" data-key="defaultView">
57
+ <option value="dayGridMonth">Mois</option>
58
+ <option value="timeGridWeek">Semaine</option>
59
+ <option value="timeGridDay">Jour</option>
55
60
  </select>
56
61
  </div>
57
- <div class="mb-3"><label class="form-label">Timezone</label><input id="timezone" class="form-control" value="{settings.timezone}"></div>
62
+ <div class="mb-3"><label class="form-label">Timezone</label><input class="form-control" data-key="timezone"></div>
58
63
  </div>
59
64
 
60
65
  <button id="save" class="btn btn-primary">Sauvegarder</button>
@@ -63,5 +68,5 @@
63
68
  </div>
64
69
 
65
70
  <script>
66
- require(['admin/plugins/equipment-calendar'], function (m) { m.init(); });
71
+ require(['admin/plugins/equipment-calendar'], function (m) { m.init(); });
67
72
  </script>
@@ -1,40 +1,10 @@
1
1
  <div class="equipment-calendar-page">
2
2
  <h1>Réservation de matériel</h1>
3
3
 
4
- <div class="mb-3">
5
- <form method="get" action="/equipment/calendar" class="d-flex gap-2 align-items-end">
6
- <div>
7
- <label class="form-label">Matériel</label>
8
- <select name="itemId" class="form-select" onchange="this.form.submit()">
9
- {{{ each items }}}
10
- <option value="{items.id}" {{{ if items.selected }}}selected{{{ end }}}>{items.name} — {items.location}</option>
11
- {{{ end }}}
12
- </select>
13
- </div>
14
- <div class="text-muted small">
15
- <div><strong>Lieu:</strong> {chosenItemLocation}</div>
16
- <div><strong>Prix:</strong> {chosenItemPriceCents} cts</div>
17
- </div>
18
- </form>
19
- </div>
20
-
21
4
  {{{ if canCreate }}}
22
- <form id="ec-create-form" method="post" action="/equipment/reservations/create" class="card card-body mb-3">
23
- <input type="hidden" name="_csrf" value="{csrf}">
24
- <input type="hidden" name="itemId" value="{chosenItemId}">
25
- <input type="hidden" name="start" value="">
26
- <input type="hidden" name="end" value="">
27
- <div class="row g-2 align-items-end">
28
- <div class="col-md-8">
29
- <label class="form-label">Note (optionnel)</label>
30
- <input class="form-control" type="text" name="notesUser" maxlength="2000" placeholder="Ex: besoin de trépied, etc.">
31
- </div>
32
- <div class="col-md-4">
33
- <button class="btn btn-primary w-100" type="submit" onclick="return false;">Sélectionne une date sur le calendrier</button>
34
- </div>
35
- </div>
36
- <div class="form-text">Clique sur une date ou sélectionne une plage sur le calendrier pour soumettre une demande.</div>
37
- </form>
5
+ <div class="alert alert-info">
6
+ Clique sur une date ou sélectionne une plage sur le calendrier pour faire une demande.
7
+ </div>
38
8
  {{{ else }}}
39
9
  <div class="alert alert-info">Tu peux consulter le calendrier, mais tu n’as pas les droits pour créer une demande.</div>
40
10
  {{{ end }}}
@@ -42,20 +12,62 @@
42
12
  <div class="card card-body">
43
13
  <div id="equipment-calendar"></div>
44
14
  </div>
15
+ </div>
16
+
17
+ <!-- Modal de réservation -->
18
+ <div class="modal fade" id="ecReserveModal" tabindex="-1" aria-hidden="true">
19
+ <div class="modal-dialog">
20
+ <div class="modal-content">
21
+ <form id="ec-create-form" method="post" action="/equipment/reservations/create">
22
+ <div class="modal-header">
23
+ <h5 class="modal-title">Demande de réservation</h5>
24
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
25
+ </div>
26
+ <div class="modal-body">
27
+ <input type="hidden" name="_csrf" value="{csrf}">
28
+ <input type="hidden" name="start" value="">
29
+ <input type="hidden" name="end" value="">
45
30
 
46
- {{{ if isApprover }}}
47
- <div class="mt-3">
48
- <a class="btn btn-outline-secondary" href="/equipment/approvals">Aller à la validation</a>
31
+ <div class="mb-3">
32
+ <label class="form-label">Début</label>
33
+ <input type="text" class="form-control" id="ecStartDisplay" readonly>
34
+ </div>
35
+ <div class="mb-3">
36
+ <label class="form-label">Fin</label>
37
+ <input type="text" class="form-control" id="ecEndDisplay" readonly>
38
+ </div>
39
+
40
+ <div class="mb-3">
41
+ <label class="form-label">Matériel disponible</label>
42
+ <select class="form-select" name="itemId" id="ecItemSelect" required></select>
43
+ <div class="form-text" id="ecItemHint"></div>
44
+ </div>
45
+
46
+ <div class="mb-3">
47
+ <label class="form-label">Note (optionnel)</label>
48
+ <input class="form-control" type="text" name="notesUser" maxlength="2000" placeholder="Ex: besoin de trépied, etc.">
49
+ </div>
50
+
51
+ <div class="alert alert-warning d-none" id="ecNoAvailability">
52
+ Aucun matériel n’est disponible sur cette plage.
53
+ </div>
54
+ </div>
55
+ <div class="modal-footer">
56
+ <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
57
+ <button type="submit" class="btn btn-primary" id="ecSubmitBtn">Envoyer la demande</button>
58
+ </div>
59
+ </form>
60
+ </div>
49
61
  </div>
50
- {{{ end }}}
51
62
  </div>
52
63
 
53
- <link rel="stylesheet" href="/plugins/nodebb-plugin-equipment-calendar/vendor/index.global.min.css">
54
- <script src="/plugins/nodebb-plugin-equipment-calendar/vendor/index.global.min.js"></script>
64
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/fullcalendar/6.1.19/index.global.min.js"></script>
55
65
  <script src="/plugins/nodebb-plugin-equipment-calendar/js/client.js"></script>
56
66
 
57
67
  <script>
58
68
  window.EC_EVENTS = JSON.parse(atob('{eventsB64}'));
69
+ window.EC_BLOCKS = JSON.parse(atob('{blocksB64}'));
70
+ window.EC_ITEMS = JSON.parse(atob('{itemsB64}'));
59
71
  window.EC_INITIAL_DATE = "{initialDateISO}";
60
72
  window.EC_INITIAL_VIEW = "{view}";
61
73
  window.EC_TZ = "{tz}";