nodebb-plugin-equipment-calendar 2.0.0 → 2.0.3

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
@@ -1158,63 +1158,47 @@ async function handleCreateReservation(req, res) {
1158
1158
  return helpers.notAllowed(req, res);
1159
1159
  }
1160
1160
 
1161
- const items = await getActiveItems(settings);
1162
- const itemIdsRaw = String(req.body.itemIds || req.body.itemId || '').trim();
1163
- const itemIds = itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
1164
- if (!itemIds.length) {
1165
- return res.status(400).send('itemId required');
1166
- }
1167
- const item = items.find(i => i.id === itemId);
1168
- 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
+ }
1169
1170
 
1170
1171
  const tz = settings.timezone || 'Europe/Paris';
1171
1172
  const start = DateTime.fromISO(String(req.body.start || ''), { zone: tz });
1172
1173
  const end = DateTime.fromISO(String(req.body.end || ''), { zone: tz });
1173
-
1174
1174
  if (!start.isValid || !end.isValid) return res.status(400).send('Invalid dates');
1175
- const startMs = start.toMillis();
1176
- const endMs = end.toMillis();
1177
-
1178
- if (endMs <= startMs) return res.status(400).send('End must be after start');
1179
- if (end.diff(start, 'days').days > 31) return res.status(400).send('Range too large');
1180
1175
 
1181
- // Overlap
1182
- if (await hasOverlap(itemId, startMs, endMs)) {
1183
- 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 });
1184
1181
  }
1185
1182
 
1186
- const reservation = {
1187
- id: uuidv4(),
1188
- itemId,
1189
- uid: req.uid,
1190
- startMs,
1191
- endMs,
1192
- status: 'pending',
1193
- createdAtMs: Date.now(),
1194
- updatedAtMs: Date.now(),
1195
- validatorUid: 0,
1196
- notesUser: String(req.body.notesUser || '').slice(0, 2000),
1197
- notesAdmin: '',
1198
- ha_checkoutIntentId: '',
1199
- ha_paymentUrl: '',
1200
- ha_paymentStatus: '',
1201
- ha_paidAtMs: 0,
1202
- };
1183
+ const startMs = startDay.toMillis();
1184
+ const endMs = endDay.toMillis();
1203
1185
 
1204
- await saveReservation(reservation);
1186
+ const notesUser = String(req.body.notesUser || '').trim();
1205
1187
 
1206
- // Notify group
1207
- const link = `/equipment/approvals`;
1208
- await sendGroupNotificationAndEmail(
1209
- settings.notifyGroup || settings.approverGroup,
1210
- 'Nouvelle demande de réservation',
1211
- `Une demande de réservation est en attente (matériel: ${item.name}).`,
1212
- link
1213
- );
1188
+ const bookingId = `${req.uid}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
1214
1189
 
1215
- return res.redirect(`/equipment/calendar?itemId=${encodeURIComponent(itemId)}`);
1216
- } catch (e) {
1217
- 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');
1218
1202
  }
1219
1203
  }
1220
1204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "2.0.0",
3
+ "version": "2.0.3",
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.7.0",
28
+ "version": "0.7.1",
29
29
  "minver": "4.7.1"
30
30
  }
@@ -1,179 +1,134 @@
1
- require(['jquery'], function ($) {
1
+ require(['jquery', 'bootstrap'], function ($, bootstrap) {
2
2
  'use strict';
3
- /* global window, document, FullCalendar, bootbox */
3
+ /* global window, document, FullCalendar */
4
4
 
5
5
  (function () {
6
- function overlaps(aStartMs, aEndMs, bStartMs, bEndMs) {
7
- return aStartMs < bEndMs && aEndMs > bStartMs;
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}`;
8
12
  }
9
13
 
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
- }
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());
16
19
  }
17
20
 
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;
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;
35
29
  }
36
30
 
37
- function submitReservation(startISO, endISO, itemId, notes) {
38
- const form = document.getElementById('ec-create-form');
39
- if (!form) return;
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;
40
36
 
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"]');
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
+ });
45
42
 
46
- if (startInput) startInput.value = startISO;
47
- if (endInput) endInput.value = endISO;
48
- if (itemInput) itemInput.value = itemId;
49
- if (notesInput) notesInput.value = notes || '';
43
+ const days = getReservationDays();
44
+ if (daysEl) daysEl.textContent = days + (days > 1 ? ' jours' : ' jour');
50
45
 
51
- form.submit();
46
+ const finalTotal = unitTotal * days;
47
+ const txt = Number.isInteger(finalTotal) ? String(finalTotal) : finalTotal.toFixed(2);
48
+ out.textContent = txt + ' €';
52
49
  }
53
50
 
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();
59
-
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
- }
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
- }
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
+ }
77
64
 
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;
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
+ const BS = (bootstrap && bootstrap.Modal) ? bootstrap : (window.bootstrap && window.bootstrap.Modal ? window.bootstrap : null);
80
+ if (modalEl && BS && BS.Modal) {
81
+ BS.Modal.getOrCreateInstance(modalEl).show();
82
+ } else {
83
+ // Fallback: if bootstrap modal isn't available, scroll to form area
84
+ modalEl?.scrollIntoView({ behavior: 'smooth', block: 'center' });
104
85
  }
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
- }
129
- }
130
- });
131
86
  }
132
87
 
133
88
  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,
89
+ const calendarEl = document.getElementById('ec-calendar');
90
+ if (!calendarEl || !window.FullCalendar) return;
148
91
 
149
- // Range selection (drag)
150
- select: function (info) {
151
- openNodeBBModal(info.startStr, info.endStr);
152
- },
92
+ const canCreate = !!window.EC_CAN_CREATE;
93
+ const events = Array.isArray(window.EC_EVENTS) ? window.EC_EVENTS : [];
153
94
 
154
- // Single date click
95
+ const calendar = new window.FullCalendar.Calendar(calendarEl, {
96
+ initialView: 'dayGridMonth',
97
+ selectable: canCreate,
98
+ selectMirror: true,
99
+ events,
155
100
  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());
101
+ if (!canCreate) return;
102
+ const startMs = Date.UTC(info.date.getUTCFullYear(), info.date.getUTCMonth(), info.date.getUTCDate());
103
+ const endMs = startMs + 24 * 60 * 60 * 1000;
104
+ console.debug('[equipment-calendar] open modal', { startMs, endMs });
105
+ openModalWithRange(startMs, endMs);
160
106
  },
161
-
162
- headerToolbar: {
163
- left: 'prev,next today',
164
- center: 'title',
165
- right: 'dayGridMonth,timeGridWeek,timeGridDay'
107
+ select: function (info) {
108
+ if (!canCreate) return;
109
+ const startMs = Date.UTC(info.start.getUTCFullYear(), info.start.getUTCMonth(), info.start.getUTCDate());
110
+ const endMs = Date.UTC(info.end.getUTCFullYear(), info.end.getUTCMonth(), info.end.getUTCDate());
111
+ console.debug('[equipment-calendar] open modal', { startMs, endMs });
112
+ openModalWithRange(startMs, endMs);
166
113
  },
167
114
  });
168
115
 
169
116
  calendar.render();
170
- }
171
117
 
172
- if (document.readyState === 'loading') {
173
- document.addEventListener('DOMContentLoaded', initCalendar);
174
- } else {
175
- initCalendar();
118
+ document.getElementById('ec-item-ids')?.addEventListener('change', updateTotalPrice);
119
+ document.getElementById('ec-start-date')?.addEventListener('change', function () {
120
+ syncHiddenIsoFields();
121
+ updateTotalPrice();
122
+ });
123
+ document.getElementById('ec-end-date')?.addEventListener('change', function () {
124
+ syncHiddenIsoFields();
125
+ updateTotalPrice();
126
+ });
127
+
128
+ updateTotalPrice();
176
129
  }
177
- }());
178
130
 
131
+ $(window).off('action:ajaxify.end.ec').on('action:ajaxify.end.ec', initCalendar);
132
+ $(initCalendar);
133
+ })();
179
134
  });
@@ -9,34 +9,76 @@
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
- <!-- 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>
16
+ <!-- Modal de demande -->
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
+ <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
+ <div class="modal-body">
27
+ <input type="hidden" name="_csrf" value="{config.csrf_token}">
28
+ <input type="hidden" id="ec-start-iso" name="start" value="">
29
+ <input type="hidden" id="ec-end-iso" name="end" value="">
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>
45
+ {{{ each items }}}
46
+ <option value="{@value.id}" data-price="{@value.price}">{@value.name} — {@value.price} €</option>
47
+ {{{ end }}}
48
+ </select>
49
+ <div class="form-text">Tu peux sélectionner plusieurs matériels.</div>
50
+ </div>
51
+
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">
58
+ <div class="fw-semibold">Durée</div>
59
+ <div id="ec-total-days">1 jour</div>
60
+ <hr class="my-2">
61
+ <div class="fw-semibold">Total estimé</div>
62
+ <div id="ec-total-price" class="fs-5">0 €</div>
63
+ </div>
64
+ </div>
65
+
66
+ <div class="modal-footer">
67
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
68
+ <button type="submit" class="btn btn-primary">Envoyer la demande</button>
69
+ </div>
70
+ </form>
71
+ </div>
72
+ </div>
73
+ </div>
24
74
  </div>
25
75
 
26
76
  <script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js"></script>
27
- <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>
28
78
 
29
79
  <script>
30
80
  window.EC_EVENTS = JSON.parse(atob('{eventsB64}'));
31
81
  window.EC_BLOCKS = JSON.parse(atob('{blocksB64}'));
32
- window.EC_ITEMS = JSON.parse(atob('{itemsB64}'));
33
- window.EC_INITIAL_DATE = "{initialDateISO}";
34
- window.EC_INITIAL_VIEW = "{view}";
35
- window.EC_TZ = "{tz}";
36
82
  window.EC_CAN_CREATE = {canCreateJs};
83
+ window.EC_TZ = '{tz}';
37
84
  </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>