nodebb-plugin-equipment-calendar 0.4.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/library.js CHANGED
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ // Node 18+ has global fetch; fallback to undici if needed
4
+ let fetchFn = global.fetch;
5
+ try { if (!fetchFn) { fetchFn = require('undici').fetch; } } catch (e) {}
6
+
3
7
  const db = require.main.require('./src/database');
4
8
  const meta = require.main.require('./src/meta');
5
9
  const groups = require.main.require('./src/groups');
@@ -25,6 +29,12 @@ const DEFAULT_SETTINGS = {
25
29
  notifyGroup: 'administrators',
26
30
  // JSON array of items: [{ "id": "cam1", "name": "Caméra A", "priceCents": 5000, "location": "Stock A", "active": true }]
27
31
  itemsJson: '[]',
32
+ itemsSource: 'manual',
33
+ ha_itemsFormType: '',
34
+ ha_itemsFormSlug: '',
35
+ ha_locationMapJson: '{}',
36
+ ha_calendarItemNamePrefix: 'Location matériel',
37
+ paymentTimeoutMinutes: 10,
28
38
  // HelloAsso
29
39
  ha_clientId: '',
30
40
  ha_clientSecret: '',
@@ -38,6 +48,196 @@ const DEFAULT_SETTINGS = {
38
48
  showRequesterToAll: '0', // 0/1
39
49
  };
40
50
 
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
+ }
60
+
61
+ let haTokenCache = null; // { accessToken, refreshToken, expMs }
62
+
63
+ async function getHelloAssoAccessToken(settings) {
64
+ const now = Date.now();
65
+ if (haTokenCache && haTokenCache.accessToken && haTokenCache.expMs && now < haTokenCache.expMs - 30_000) {
66
+ return haTokenCache.accessToken;
67
+ }
68
+
69
+ const tokenKey = 'equipmentCalendar:ha:token';
70
+ let stored = null;
71
+ try {
72
+ stored = await db.getObject(tokenKey);
73
+ } catch (e) {}
74
+
75
+ // If refresh token exists and not expired locally, try refresh flow first
76
+ const canRefresh = stored && stored.refresh_token;
77
+ const useRefresh = canRefresh && stored.refresh_expires_at && now < parseInt(stored.refresh_expires_at, 10);
78
+
79
+ const formBody = new URLSearchParams();
80
+ if (useRefresh) {
81
+ formBody.set('grant_type', 'refresh_token');
82
+ formBody.set('refresh_token', stored.refresh_token);
83
+ } else {
84
+ formBody.set('grant_type', 'client_credentials');
85
+ formBody.set('client_id', String(settings.ha_clientId || ''));
86
+ formBody.set('client_secret', String(settings.ha_clientSecret || ''));
87
+ }
88
+
89
+ const resp = await fetchFn('https://api.helloasso.com/oauth2/token', {
90
+ method: 'POST',
91
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
92
+ body: formBody.toString(),
93
+ });
94
+ if (!resp.ok) {
95
+ const t = await resp.text();
96
+ throw new Error(`HelloAsso token error: ${resp.status} ${t}`);
97
+ }
98
+ const json = await resp.json();
99
+ const accessToken = json.access_token;
100
+ const refreshToken = json.refresh_token;
101
+
102
+ const expiresIn = parseInt(json.expires_in, 10) || 0;
103
+ const expMs = now + (expiresIn * 1000);
104
+
105
+ // Per docs, refresh token is valid ~30 days and rotates; keep a conservative expiry (29 days)
106
+ const refreshExpMs = now + (29 * 24 * 60 * 60 * 1000);
107
+
108
+ haTokenCache = { accessToken, refreshToken, expMs };
109
+
110
+ try {
111
+ await db.setObject(tokenKey, {
112
+ refresh_token: refreshToken || stored && stored.refresh_token || '',
113
+ refresh_expires_at: String(refreshExpMs),
114
+ });
115
+ } catch (e) {}
116
+
117
+ return accessToken;
118
+ }
119
+
120
+
121
+ async function createHelloAssoCheckoutIntent(settings, bookingId, reservations) {
122
+ const org = String(settings.ha_organizationSlug || '').trim();
123
+ if (!org) throw new Error('HelloAsso organization slug missing');
124
+
125
+ const baseUrl = (meta && meta.config && meta.config.url) ? meta.config.url.replace(/\/$/, '') : '';
126
+ const backUrl = baseUrl + '/equipment/calendar';
127
+ const returnUrl = baseUrl + `/equipment/helloasso/callback?type=return&bookingId=${encodeURIComponent(bookingId)}`;
128
+ const errorUrl = baseUrl + `/equipment/helloasso/callback?type=error&bookingId=${encodeURIComponent(bookingId)}`;
129
+
130
+ const items = await getActiveItems(settings);
131
+ const byId = {};
132
+ items.forEach(i => { byId[i.id] = i; });
133
+
134
+ const names = reservations.map(r => (byId[r.itemId] && byId[r.itemId].name) || r.itemId);
135
+ const totalAmount = reservations.reduce((sum, r) => {
136
+ const price = (byId[r.itemId] && parseInt(byId[r.itemId].priceCents, 10)) || 0;
137
+ return sum + (Number.isFinite(price) ? price : 0);
138
+ }, 0);
139
+
140
+ if (!totalAmount || totalAmount <= 0) {
141
+ throw new Error('Montant total invalide (prix manquant sur les items).');
142
+ }
143
+
144
+ const body = {
145
+ totalAmount,
146
+ initialAmount: totalAmount,
147
+ itemName: `${String(settings.ha_calendarItemNamePrefix || 'Location matériel')}: ${names.join(', ')}`.slice(0, 250),
148
+ backUrl,
149
+ errorUrl,
150
+ returnUrl,
151
+ containsDonation: false,
152
+ metadata: {
153
+ bookingId,
154
+ rids: reservations.map(r => r.rid || r.id),
155
+ uid: reservations[0] && reservations[0].uid,
156
+ startMs: reservations[0] && reservations[0].startMs,
157
+ endMs: reservations[0] && reservations[0].endMs,
158
+ },
159
+ };
160
+
161
+ const token = await getHelloAssoAccessToken(settings);
162
+ const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
163
+ const resp = await fetchFn(url, {
164
+ method: 'POST',
165
+ headers: {
166
+ accept: 'application/json',
167
+ 'content-type': 'application/json',
168
+ authorization: `Bearer ${token}`,
169
+ },
170
+ body: JSON.stringify(body),
171
+ });
172
+
173
+ if (!resp.ok) {
174
+ const t = await resp.text();
175
+ throw new Error(`HelloAsso checkout-intents error: ${resp.status} ${t}`);
176
+ }
177
+ return await resp.json();
178
+ }
179
+
180
+ async function fetchHelloAssoCheckoutIntent(settings, checkoutIntentId) {
181
+ const org = String(settings.ha_organizationSlug || '').trim();
182
+ const token = await getHelloAssoAccessToken(settings);
183
+ const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents/${encodeURIComponent(checkoutIntentId)}`;
184
+ const resp = await fetchFn(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' } });
185
+ if (!resp.ok) {
186
+ const t = await resp.text();
187
+ throw new Error(`HelloAsso checkout-intents GET error: ${resp.status} ${t}`);
188
+ }
189
+ return await resp.json();
190
+ }
191
+
192
+ function isCheckoutPaid(checkout) {
193
+ const payments = (checkout && (checkout.payments || (checkout.order && checkout.order.payments))) || [];
194
+ const list = Array.isArray(payments) ? payments : [];
195
+ const states = list.map(p => String(p.state || p.status || p.paymentState || '').toLowerCase());
196
+ if (states.some(s => ['authorized', 'paid', 'succeeded', 'success'].includes(s))) return true;
197
+
198
+ const op = String(checkout && (checkout.state || checkout.operationState || '')).toLowerCase();
199
+ if (['authorized', 'paid', 'succeeded'].includes(op)) return true;
200
+
201
+ if (checkout && (checkout.order || checkout.orderId)) {
202
+ if (list.length) return true;
203
+ }
204
+ return false;
205
+ }
206
+
207
+ async function fetchHelloAssoItems(settings) {
208
+ const org = String(settings.ha_organizationSlug || '').trim();
209
+ const formType = String(settings.ha_itemsFormType || '').trim();
210
+ const formSlug = String(settings.ha_itemsFormSlug || '').trim();
211
+ if (!org || !formType || !formSlug) return [];
212
+
213
+ const cacheKey = `equipmentCalendar:ha:items:${org}:${formType}:${formSlug}`;
214
+ const cache = await db.getObject(cacheKey);
215
+ const now = Date.now();
216
+ if (cache && cache.payload && cache.expiresAt && now < parseInt(cache.expiresAt, 10)) {
217
+ try {
218
+ return JSON.parse(cache.payload);
219
+ } catch (e) {}
220
+ }
221
+
222
+ const token = await getHelloAssoAccessToken(settings);
223
+ const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/items`;
224
+ const resp = await fetchFn(url, { headers: { authorization: `Bearer ${token}` } });
225
+ if (!resp.ok) {
226
+ const t = await resp.text();
227
+ throw new Error(`HelloAsso items error: ${resp.status} ${t}`);
228
+ }
229
+ const json = await resp.json();
230
+ // API responses are usually { data: [...] } but keep it flexible
231
+ const list = Array.isArray(json) ? json : (Array.isArray(json.data) ? json.data : []);
232
+
233
+ // cache 15 minutes
234
+ try {
235
+ await db.setObject(cacheKey, { payload: JSON.stringify(list), expiresAt: String(now + 15 * 60 * 1000) });
236
+ } catch (e) {}
237
+
238
+ return list;
239
+ }
240
+
41
241
  function parseItems(itemsJson) {
42
242
  try {
43
243
  const arr = JSON.parse(itemsJson || '[]');
@@ -54,6 +254,36 @@ function parseItems(itemsJson) {
54
254
  }
55
255
  }
56
256
 
257
+ async function getActiveItems(settings) {
258
+ const source = String(settings.itemsSource || 'manual');
259
+ if (source === 'helloasso') {
260
+ const rawItems = await fetchHelloAssoItems(settings);
261
+ const locMap = parseLocationMap(settings.ha_locationMapJson);
262
+ return (rawItems || []).map((it) => {
263
+ const id = String(it.id || it.itemId || it.reference || it.slug || it.name || '').trim();
264
+ const name = String(it.name || it.label || it.title || id).trim();
265
+ // Price handling is not displayed in the public calendar, keep a field if you want later
266
+ const price =
267
+ (it.price && (it.price.value || it.price.amount)) ||
268
+ (it.amount && (it.amount.value || it.amount)) ||
269
+ it.price;
270
+ const priceCents = typeof price === 'number' ? price : 0;
271
+
272
+ return {
273
+ id: id || name,
274
+ name,
275
+ location: String(locMap[id] || ''),
276
+ priceCents,
277
+ active: true,
278
+ source: 'helloasso',
279
+ };
280
+ }).filter(i => i.id);
281
+ }
282
+
283
+ // manual
284
+ return parseItems(settings.itemsJson).filter(i => i.active);
285
+ }
286
+
57
287
  async function getSettings() {
58
288
  const settings = await meta.settings.get(SETTINGS_KEY);
59
289
  return { ...DEFAULT_SETTINGS, ...(settings || {}) };
@@ -70,8 +300,29 @@ function resKey(id) { return `equipmentCalendar:res:${id}`; }
70
300
  function itemIndexKey(itemId) { return `equipmentCalendar:item:${itemId}:res`; }
71
301
 
72
302
  function statusBlocksItem(status) {
73
- // statuses that block availability
74
- return ['pending', 'approved_waiting_payment', 'paid_validated'].includes(status);
303
+ const s = String(status || '');
304
+ if (s === 'rejected' || s === 'cancelled') return false;
305
+ return (s === 'pending' || s === 'approved' || s === 'payment_pending' || s === 'paid');
306
+ }
307
+
308
+
309
+
310
+
311
+ async function saveBooking(booking) {
312
+ const bid = booking.bookingId;
313
+ await db.setObject(`equipmentCalendar:booking:${bid}`, booking);
314
+ }
315
+
316
+ async function getBooking(bookingId) {
317
+ return await db.getObject(`equipmentCalendar:booking:${bookingId}`);
318
+ }
319
+
320
+ async function addReservationToBooking(bookingId, rid) {
321
+ await db.setAdd(`equipmentCalendar:booking:${bookingId}:rids`, rid);
322
+ }
323
+
324
+ async function getBookingRids(bookingId) {
325
+ return await db.getSetMembers(`equipmentCalendar:booking:${bookingId}:rids`) || [];
75
326
  }
76
327
 
77
328
  async function saveReservation(res) {
@@ -312,8 +563,14 @@ plugin.init = async function (params) {
312
563
  // Admin (ACP) routes
313
564
  if (mid && mid.admin) {
314
565
  router.get('/admin/plugins/equipment-calendar', middleware.applyCSRF, mid.admin.buildHeader, renderAdminPage);
566
+ router.get('/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, mid.admin.buildHeader, renderAdminReservationsPage);
567
+ router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
315
568
  router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
316
569
  router.post('/admin/plugins/equipment-calendar/save', middleware.applyCSRF, handleAdminSave);
570
+ router.post('/admin/plugins/equipment-calendar/purge', middleware.applyCSRF, handleAdminPurge);
571
+ router.post('/admin/plugins/equipment-calendar/reservations/:rid/approve', middleware.applyCSRF, handleAdminApprove);
572
+ router.post('/admin/plugins/equipment-calendar/reservations/:rid/reject', middleware.applyCSRF, handleAdminReject);
573
+ router.post('/admin/plugins/equipment-calendar/reservations/:rid/delete', middleware.applyCSRF, handleAdminDelete);
317
574
  }
318
575
 
319
576
  // Convenience alias (optional): /calendar -> /equipment/calendar
@@ -394,15 +651,143 @@ plugin.addAdminNavigation = async function (header) {
394
651
  plugin.addAdminRoutes = async function (params) {
395
652
  const { router, middleware: mid } = params;
396
653
  router.get('/admin/plugins/equipment-calendar', middleware.applyCSRF, mid.admin.buildHeader, renderAdminPage);
654
+ router.get('/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, mid.admin.buildHeader, renderAdminReservationsPage);
655
+ router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
397
656
  router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
398
657
  };
399
658
 
659
+
660
+ async function renderAdminReservationsPage(req, res) {
661
+ if (!(await ensureIsAdmin(req, res))) return;
662
+
663
+ const settings = await getSettings();
664
+ const items = await getActiveItems(settings);
665
+ const itemById = {};
666
+ items.forEach(it => { itemById[it.id] = it; });
667
+
668
+ const status = String(req.query.status || ''); // optional filter
669
+ const itemId = String(req.query.itemId || ''); // optional filter
670
+ const q = String(req.query.q || '').trim(); // search rid/user/notes
671
+
672
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
673
+ const perPage = Math.min(100, Math.max(10, parseInt(req.query.perPage, 10) || 50));
674
+
675
+ const allRids = await db.getSortedSetRevRange('equipmentCalendar:reservations', 0, -1);
676
+ const totalAll = allRids.length;
677
+
678
+ const rows = [];
679
+ for (const rid of allRids) {
680
+ // eslint-disable-next-line no-await-in-loop
681
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
682
+ if (!r || !r.rid) continue;
683
+
684
+ if (status && String(r.status) !== status) continue;
685
+ if (itemId && String(r.itemId) !== itemId) continue;
686
+
687
+ const notes = String(r.notesUser || '');
688
+ const ridStr = String(r.rid || rid);
689
+ if (q) {
690
+ const hay = (ridStr + ' ' + String(r.uid || '') + ' ' + notes).toLowerCase();
691
+ if (!hay.includes(q.toLowerCase())) continue;
692
+ }
693
+
694
+ const startMs = parseInt(r.startMs, 10) || 0;
695
+ const endMs = parseInt(r.endMs, 10) || 0;
696
+ const createdAt = parseInt(r.createdAt, 10) || 0;
697
+
698
+ rows.push({
699
+ rid: ridStr,
700
+ itemId: String(r.itemId || ''),
701
+ itemName: (itemById[String(r.itemId || '')] && itemById[String(r.itemId || '')].name) || String(r.itemId || ''),
702
+ uid: String(r.uid || ''),
703
+ status: String(r.status || ''),
704
+ start: startMs ? new Date(startMs).toISOString() : '',
705
+ end: endMs ? new Date(endMs).toISOString() : '',
706
+ createdAt: createdAt ? new Date(createdAt).toISOString() : '',
707
+ notesUser: notes,
708
+ });
709
+ }
710
+
711
+ const total = rows.length;
712
+ const totalPages = Math.max(1, Math.ceil(total / perPage));
713
+ const safePage = Math.min(page, totalPages);
714
+ const startIndex = (safePage - 1) * perPage;
715
+ const pageRows = rows.slice(startIndex, startIndex + perPage);
716
+
717
+ const itemOptions = [{ id: '', name: 'Tous' }].concat(items.map(i => ({ id: i.id, name: i.name })));
718
+ const statusOptions = [
719
+ { id: '', name: 'Tous' },
720
+ { id: 'pending', name: 'pending' },
721
+ { id: 'approved', name: 'approved' },
722
+ { id: 'paid', name: 'paid' },
723
+ { id: 'rejected', name: 'rejected' },
724
+ { id: 'cancelled', name: 'cancelled' },
725
+ ];
726
+
727
+ res.render('admin/plugins/equipment-calendar-reservations', {
728
+ title: 'Equipment Calendar - Réservations',
729
+ settings,
730
+ rows: pageRows,
731
+ hasRows: pageRows.length > 0,
732
+ itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
733
+ statusOptions: statusOptions.map(o => ({ ...o, selected: o.id === status })),
734
+ q,
735
+ page: safePage,
736
+ perPage,
737
+ total,
738
+ totalAll,
739
+ totalPages,
740
+ prevPage: safePage > 1 ? safePage - 1 : 0,
741
+ nextPage: safePage < totalPages ? safePage + 1 : 0,
742
+ actionBase: '/admin/plugins/equipment-calendar/reservations',
743
+ });
744
+ }
745
+
746
+ async function handleAdminApprove(req, res) {
747
+ if (!(await ensureIsAdmin(req, res))) return;
748
+ const rid = String(req.params.rid || '').trim();
749
+ if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
750
+
751
+ const key = `equipmentCalendar:reservation:${rid}`;
752
+ const r = await db.getObject(key);
753
+ if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
754
+
755
+ r.status = 'approved';
756
+ await db.setObject(key, r);
757
+
758
+ return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
759
+ }
760
+
761
+ async function handleAdminReject(req, res) {
762
+ if (!(await ensureIsAdmin(req, res))) return;
763
+ const rid = String(req.params.rid || '').trim();
764
+ if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
765
+
766
+ const key = `equipmentCalendar:reservation:${rid}`;
767
+ const r = await db.getObject(key);
768
+ if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
769
+
770
+ r.status = 'rejected';
771
+ await db.setObject(key, r);
772
+
773
+ return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
774
+ }
775
+
776
+ async function handleAdminDelete(req, res) {
777
+ if (!(await ensureIsAdmin(req, res))) return;
778
+ const rid = String(req.params.rid || '').trim();
779
+ if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
780
+ await deleteReservation(rid);
781
+ return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
782
+ }
783
+
400
784
  async function renderAdminPage(req, res) {
401
785
  const settings = await getSettings();
402
786
  res.render('admin/plugins/equipment-calendar', {
403
787
  title: 'Equipment Calendar',
404
788
  settings,
405
789
  saved: req.query && String(req.query.saved || '') === '1',
790
+ purged: req.query && parseInt(req.query.purged, 10) || 0,
406
791
  view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
407
792
  view_timeGridWeek: (settings.defaultView || '') === 'timeGridWeek',
408
793
  view_timeGridDay: (settings.defaultView || '') === 'timeGridDay',
@@ -411,9 +796,68 @@ async function renderAdminPage(req, res) {
411
796
  }
412
797
 
413
798
  // --- Calendar page ---
799
+
800
+ async function handleHelloAssoCallback(req, res) {
801
+ const settings = await getSettings();
802
+
803
+ const bookingId = String(req.query.bookingId || '').trim();
804
+ const checkoutIntentId = String(req.query.checkoutIntentId || '').trim();
805
+ const code = String(req.query.code || '').trim();
806
+ const type = String(req.query.type || '').trim();
807
+
808
+ let status = 'pending';
809
+ let message = 'Retour paiement reçu. Vérification en cours…';
810
+
811
+ try {
812
+ if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
813
+ const checkout = await fetchHelloAssoCheckoutIntent(settings, checkoutIntentId);
814
+
815
+ const paid = isCheckoutPaid(checkout);
816
+ if (paid) {
817
+ status = 'paid';
818
+ message = 'Paiement confirmé. Merci !';
819
+
820
+ if (bookingId) {
821
+ const rids = await getBookingRids(bookingId);
822
+ for (const rid of rids) {
823
+ // eslint-disable-next-line no-await-in-loop
824
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
825
+ if (!r || !r.rid) continue;
826
+ r.status = 'paid';
827
+ r.paidAt = Date.now();
828
+ r.checkoutIntentId = checkoutIntentId;
829
+ // eslint-disable-next-line no-await-in-loop
830
+ await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
831
+ }
832
+ try {
833
+ const b = (await getBooking(bookingId)) || { bookingId };
834
+ b.status = 'paid';
835
+ b.checkoutIntentId = checkoutIntentId;
836
+ b.paidAt = Date.now();
837
+ await saveBooking(b);
838
+ } catch (e) {}
839
+ }
840
+ } else {
841
+ status = (type === 'error' || code) ? 'error' : 'pending';
842
+ message = 'Paiement non confirmé. Si vous venez de payer, réessayez dans quelques instants.';
843
+ }
844
+ } catch (e) {
845
+ status = 'error';
846
+ message = e.message || 'Erreur de vérification paiement';
847
+ }
848
+
849
+ res.render('equipment-calendar/payment-return', {
850
+ title: 'Retour paiement',
851
+ status,
852
+ statusError: status === 'error',
853
+ statusPaid: status === 'paid',
854
+ message,
855
+ });
856
+ }
857
+
414
858
  async function renderCalendarPage(req, res) {
415
859
  const settings = await getSettings();
416
- const items = parseItems(settings.itemsJson).filter(i => i.active);
860
+ const items = await getActiveItems(settings);
417
861
 
418
862
  const tz = settings.timezone || 'Europe/Paris';
419
863
 
@@ -494,6 +938,79 @@ async function renderCalendarPage(req, res) {
494
938
 
495
939
 
496
940
  // --- Approvals page ---
941
+
942
+
943
+
944
+ async function notifyApprovers(reservations, settings) {
945
+ const groupName = (settings.notifyGroup || settings.approverGroup || 'administrators').trim();
946
+ if (!groupName) return;
947
+
948
+ const rid = reservations[0] && (reservations[0].rid || reservations[0].id) || '';
949
+ const path = '/equipment/approvals';
950
+
951
+ // In-app notification (NodeBB notifications)
952
+ try {
953
+ const Notifications = require.main.require('./src/notifications');
954
+ const notif = await Notifications.create({
955
+ bodyShort: 'Nouvelle demande de réservation',
956
+ bodyLong: 'Une nouvelle demande de réservation est en attente de validation.',
957
+ nid: 'equipment-calendar:' + rid,
958
+ path,
959
+ });
960
+ if (Notifications.pushGroup) {
961
+ await Notifications.pushGroup(notif, groupName);
962
+ }
963
+ } catch (e) {
964
+ // ignore
965
+ }
966
+
967
+ // Direct email to members of notifyGroup (uses NodeBB Emailer + your SMTP/emailer plugin)
968
+ try {
969
+ const Emailer = require.main.require('./src/emailer');
970
+ const uids = await groups.getMembers(groupName, 0, -1);
971
+ if (!uids || !uids.length) return;
972
+
973
+ // Build a short summary
974
+ const items = await getActiveItems(await getSettings());
975
+ const nameById = {};
976
+ items.forEach(i => { nameById[i.id] = i.name; });
977
+
978
+ const start = new Date(reservations[0].startMs).toISOString();
979
+ const end = new Date(reservations[0].endMs).toISOString();
980
+ const itemList = reservations.map(r => nameById[r.itemId] || r.itemId).join(', ');
981
+
982
+ // Use a core template ('notification') that exists in NodeBB installs
983
+ // Params vary a bit by version; we provide common fields.
984
+ const params = {
985
+ subject: '[Réservation] Nouvelle demande en attente',
986
+ intro: 'Une nouvelle demande de réservation a été créée.',
987
+ body: `Matériel: ${itemList}\nDébut: ${start}\nFin: ${end}\nVoir: ${path}`,
988
+ notification_url: path,
989
+ url: path,
990
+ // Some templates use "site_title" etc but NodeBB will inject globals
991
+ };
992
+
993
+ for (const uid of uids) {
994
+ // eslint-disable-next-line no-await-in-loop
995
+ const email = await user.getUserField(uid, 'email');
996
+ if (!email) continue;
997
+
998
+ // eslint-disable-next-line no-await-in-loop
999
+ const lang = (await user.getUserField(uid, 'language')) || 'fr';
1000
+ if (Emailer.sendToEmail) {
1001
+ // eslint-disable-next-line no-await-in-loop
1002
+ await Emailer.sendToEmail('notification', email, lang, params);
1003
+ } else if (Emailer.send) {
1004
+ // Some NodeBB versions use Emailer.send(template, uid/email, params)
1005
+ // eslint-disable-next-line no-await-in-loop
1006
+ await Emailer.send('notification', uid, params);
1007
+ }
1008
+ }
1009
+ } catch (e) {
1010
+ // ignore email errors to not block reservation flow
1011
+ }
1012
+ }
1013
+
497
1014
  async function renderApprovalsPage(req, res) {
498
1015
  const settings = await getSettings();
499
1016
  const ok = req.uid ? await canApprove(req.uid, settings) : false;
@@ -542,6 +1059,40 @@ async function renderApprovalsPage(req, res) {
542
1059
  }
543
1060
 
544
1061
  // --- Actions ---
1062
+
1063
+ async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId) {
1064
+ const items = await getActiveItems(settings);
1065
+ const item = items.find(i => i.id === itemId);
1066
+ if (!item) {
1067
+ throw new Error('Unknown item: ' + itemId);
1068
+ }
1069
+ // Reject if overlaps any blocking reservation
1070
+ const overlapsBlocking = await hasOverlap(itemId, startMs, endMs);
1071
+ if (overlapsBlocking) {
1072
+ const err = new Error('Item not available: ' + itemId);
1073
+ err.code = 'ITEM_UNAVAILABLE';
1074
+ throw err;
1075
+ }
1076
+ const rid = generateId();
1077
+ const data = {
1078
+ id: rid,
1079
+ rid,
1080
+ itemId,
1081
+ uid: req.uid,
1082
+ startMs,
1083
+ endMs,
1084
+ status: 'pending',
1085
+ notesUser: notesUser || '',
1086
+ bookingId: bookingId || '',
1087
+ createdAt: Date.now(),
1088
+ };
1089
+ await saveReservation(data);
1090
+ if (bookingId) {
1091
+ await addReservationToBooking(bookingId, rid);
1092
+ }
1093
+ return data;
1094
+ }
1095
+
545
1096
  async function handleCreateReservation(req, res) {
546
1097
  try {
547
1098
  const settings = await getSettings();
@@ -549,8 +1100,12 @@ async function handleCreateReservation(req, res) {
549
1100
  return helpers.notAllowed(req, res);
550
1101
  }
551
1102
 
552
- const items = parseItems(settings.itemsJson).filter(i => i.active);
553
- const itemId = String(req.body.itemId || '').trim();
1103
+ const items = await getActiveItems(settings);
1104
+ const itemIdsRaw = String(req.body.itemIds || req.body.itemId || '').trim();
1105
+ const itemIds = itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
1106
+ if (!itemIds.length) {
1107
+ return res.status(400).send('itemId required');
1108
+ }
554
1109
  const item = items.find(i => i.id === itemId);
555
1110
  if (!item) return res.status(400).send('Invalid item');
556
1111
 
@@ -685,6 +1240,86 @@ async function handleRejectReservation(req, res) {
685
1240
  }
686
1241
 
687
1242
 
1243
+
1244
+
1245
+ async function ensureIsAdmin(req, res) {
1246
+ const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
1247
+ if (!isAdmin) {
1248
+ helpers.notAllowed(req, res);
1249
+ return false;
1250
+ }
1251
+ return true;
1252
+ }
1253
+
1254
+ async function deleteReservation(rid) {
1255
+ const key = `equipmentCalendar:reservation:${rid}`;
1256
+ const data = await db.getObject(key);
1257
+ if (data && data.itemId) {
1258
+ await db.sortedSetRemove(`equipmentCalendar:item:${data.itemId}:reservations`, rid);
1259
+ }
1260
+ await db.delete(key);
1261
+ await db.sortedSetRemove('equipmentCalendar:reservations', rid);
1262
+ }
1263
+
1264
+ async function handleAdminPurge(req, res) {
1265
+ try {
1266
+ const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
1267
+ if (!isAdmin) {
1268
+ return helpers.notAllowed(req, res);
1269
+ }
1270
+
1271
+ const mode = String(req.body.mode || 'nonblocking'); // nonblocking|olderThan|all
1272
+ const olderThanDays = parseInt(req.body.olderThanDays, 10);
1273
+
1274
+ // Load all reservation ids
1275
+ const rids = await db.getSortedSetRange('equipmentCalendar:reservations', 0, -1);
1276
+ let deleted = 0;
1277
+
1278
+ const now = Date.now();
1279
+ for (const rid of rids) {
1280
+ // eslint-disable-next-line no-await-in-loop
1281
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
1282
+ if (!r || !r.rid) {
1283
+ // eslint-disable-next-line no-await-in-loop
1284
+ await db.sortedSetRemove('equipmentCalendar:reservations', rid);
1285
+ continue;
1286
+ }
1287
+
1288
+ const status = String(r.status || '');
1289
+ const createdAt = parseInt(r.createdAt, 10) || 0;
1290
+ const startMs = parseInt(r.startMs, 10) || 0;
1291
+ const endMs = parseInt(r.endMs, 10) || 0;
1292
+
1293
+ let shouldDelete = false;
1294
+
1295
+ if (mode === 'all') {
1296
+ shouldDelete = true;
1297
+ } else if (mode === 'olderThan') {
1298
+ const days = Number.isFinite(olderThanDays) ? olderThanDays : 0;
1299
+ if (days > 0) {
1300
+ const cutoff = now - days * 24 * 60 * 60 * 1000;
1301
+ // use createdAt if present, otherwise startMs
1302
+ const t = createdAt || startMs || endMs;
1303
+ shouldDelete = t > 0 && t < cutoff;
1304
+ }
1305
+ } else {
1306
+ // nonblocking cleanup: delete rejected/cancelled only
1307
+ shouldDelete = (status === 'rejected' || status === 'cancelled');
1308
+ }
1309
+
1310
+ if (shouldDelete) {
1311
+ // eslint-disable-next-line no-await-in-loop
1312
+ await deleteReservation(rid);
1313
+ deleted++;
1314
+ }
1315
+ }
1316
+
1317
+ return res.redirect(`/admin/plugins/equipment-calendar?saved=1&purged=${deleted}`);
1318
+ } catch (e) {
1319
+ return res.status(500).send(e.message || 'error');
1320
+ }
1321
+ }
1322
+
688
1323
  async function handleAdminSave(req, res) {
689
1324
  try {
690
1325
  // Simple admin check (avoid relying on client-side require/settings)
@@ -705,6 +1340,7 @@ async function handleAdminSave(req, res) {
705
1340
  ha_webhookSecret: String(req.body.ha_webhookSecret || ''),
706
1341
  defaultView: String(req.body.defaultView || DEFAULT_SETTINGS.defaultView),
707
1342
  timezone: String(req.body.timezone || DEFAULT_SETTINGS.timezone),
1343
+ paymentTimeoutMinutes: String(req.body.paymentTimeoutMinutes || DEFAULT_SETTINGS.paymentTimeoutMinutes),
708
1344
  showRequesterToAll: (req.body.showRequesterToAll === '1' || req.body.showRequesterToAll === 'on') ? '1' : '0',
709
1345
  };
710
1346
 
@@ -715,4 +1351,54 @@ async function handleAdminSave(req, res) {
715
1351
  }
716
1352
  }
717
1353
 
1354
+
1355
+ let paymentTimeoutInterval = null;
1356
+
1357
+ function startPaymentTimeoutScheduler() {
1358
+ if (paymentTimeoutInterval) return;
1359
+ // Run every minute
1360
+ paymentTimeoutInterval = setInterval(async () => {
1361
+ try {
1362
+ const settings = await getSettings();
1363
+ const timeoutMin = Math.max(1, parseInt(settings.paymentTimeoutMinutes, 10) || 10);
1364
+ const cutoff = Date.now() - timeoutMin * 60 * 1000;
1365
+
1366
+ // Scan bookings keys (we keep an index set)
1367
+ const bookingIds = await db.getSetMembers('equipmentCalendar:bookings') || [];
1368
+ for (const bid of bookingIds) {
1369
+ // eslint-disable-next-line no-await-in-loop
1370
+ const b = await getBooking(bid);
1371
+ if (!b || !b.bookingId) continue;
1372
+ const status = String(b.status || '');
1373
+ if (status !== 'payment_pending') continue;
1374
+
1375
+ const pendingAt = parseInt(b.paymentPendingAt, 10) || parseInt(b.createdAt, 10) || 0;
1376
+ if (!pendingAt || pendingAt > cutoff) continue;
1377
+
1378
+ // Timeout reached -> cancel booking + all reservations
1379
+ // eslint-disable-next-line no-await-in-loop
1380
+ const rids = await getBookingRids(bid);
1381
+ for (const rid of rids) {
1382
+ // eslint-disable-next-line no-await-in-loop
1383
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
1384
+ if (!r || !r.rid) continue;
1385
+ if (String(r.status) === 'paid') continue; // safety
1386
+ r.status = 'cancelled';
1387
+ r.cancelledAt = Date.now();
1388
+ r.paymentUrl = '';
1389
+ // eslint-disable-next-line no-await-in-loop
1390
+ await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
1391
+ }
1392
+
1393
+ b.status = 'cancelled';
1394
+ b.cancelledAt = Date.now();
1395
+ b.paymentUrl = '';
1396
+ await saveBooking(b);
1397
+ }
1398
+ } catch (e) {
1399
+ // ignore to keep scheduler alive
1400
+ }
1401
+ }, 60 * 1000);
1402
+ }
1403
+
718
1404
  module.exports = plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "0.4.1",
3
+ "version": "0.6.1",
4
4
  "description": "Equipment reservation calendar for NodeBB (FullCalendar, approvals, HelloAsso payments)",
5
5
  "main": "library.js",
6
6
  "scripts": {
package/plugin.json CHANGED
@@ -26,6 +26,6 @@
26
26
  "scripts": [
27
27
  "public/js/client.js"
28
28
  ],
29
- "version": "0.2.5",
29
+ "version": "0.4.1",
30
30
  "minver": "4.7.1"
31
31
  }
@@ -39,7 +39,7 @@
39
39
 
40
40
  const startInput = form.querySelector('input[name="start"]');
41
41
  const endInput = form.querySelector('input[name="end"]');
42
- const itemInput = form.querySelector('input[name="itemId"]');
42
+ const itemInput = form.querySelector('input[name="itemIds"]');
43
43
  const notesInput = form.querySelector('input[name="notesUser"]');
44
44
 
45
45
  if (startInput) startInput.value = startISO;
@@ -84,8 +84,8 @@
84
84
  <input class="form-control" type="text" value="${fmt(endDate)}" readonly>
85
85
  </div>
86
86
  <div class="mb-3">
87
- <label class="form-label">Matériel</label>
88
- <select class="form-select" id="ec-modal-item">
87
+ <label class="form-label">Matériel (Ctrl/Cmd pour multi-sélection)</label>
88
+ <select class="form-select" id="ec-modal-item" multiple size="6">
89
89
  ${optionsHtml}
90
90
  </select>
91
91
  </div>
@@ -116,9 +116,13 @@
116
116
  callback: function () {
117
117
  const itemEl = document.getElementById('ec-modal-item');
118
118
  const notesEl = document.getElementById('ec-modal-notes');
119
- const itemId = itemEl ? itemEl.value : available[0].id;
119
+ let ids = [];
120
+ if (itemEl && itemEl.options) {
121
+ for (const opt of itemEl.options) { if (opt.selected) ids.push(opt.value); }
122
+ }
123
+ if (!ids.length) ids = [available[0].id];
120
124
  const notes = notesEl ? notesEl.value : '';
121
- submitReservation(startDate.toISOString(), endDate.toISOString(), itemId, notes);
125
+ submitReservation(startDate.toISOString(), endDate.toISOString(), ids.join(','), notes);
122
126
  }
123
127
  }
124
128
  }
@@ -0,0 +1,104 @@
1
+ <div class="acp-page-container">
2
+ <div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
3
+ <h1 class="mb-0">Equipment Calendar</h1>
4
+ <div class="btn-group">
5
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar">Paramètres</a>
6
+ <a class="btn btn-secondary active" href="/admin/plugins/equipment-calendar/reservations">Réservations</a>
7
+ </div>
8
+ </div>
9
+
10
+ <form method="get" action="/admin/plugins/equipment-calendar/reservations" class="card card-body mt-3 mb-3">
11
+ <div class="row g-2 align-items-end">
12
+ <div class="col-md-3">
13
+ <label class="form-label">Statut</label>
14
+ <select class="form-select" name="status">
15
+ {{{ each statusOptions }}}
16
+ <option value="{statusOptions.id}" {{{ if statusOptions.selected }}}selected{{{ end }}}>{statusOptions.name}</option>
17
+ {{{ end }}}
18
+ </select>
19
+ </div>
20
+ <div class="col-md-4">
21
+ <label class="form-label">Matériel</label>
22
+ <select class="form-select" name="itemId">
23
+ {{{ each itemOptions }}}
24
+ <option value="{itemOptions.id}" {{{ if itemOptions.selected }}}selected{{{ end }}}>{itemOptions.name}</option>
25
+ {{{ end }}}
26
+ </select>
27
+ </div>
28
+ <div class="col-md-3">
29
+ <label class="form-label">Recherche</label>
30
+ <input class="form-control" name="q" value="{q}" placeholder="rid, uid, note">
31
+ </div>
32
+ <div class="col-md-2">
33
+ <button class="btn btn-primary w-100" type="submit">Filtrer</button>
34
+ </div>
35
+ </div>
36
+ </form>
37
+
38
+ <div class="text-muted small mb-2">
39
+ Total (filtré) : <strong>{total}</strong> — Total (global) : <strong>{totalAll}</strong>
40
+ </div>
41
+
42
+ {{{ if hasRows }}}
43
+ <div class="table-responsive">
44
+ <table class="table table-striped align-middle">
45
+ <thead>
46
+ <tr>
47
+ <th>RID</th>
48
+ <th>Matériel</th>
49
+ <th>UID</th>
50
+ <th>Début</th>
51
+ <th>Fin</th>
52
+ <th>Statut</th>
53
+ <th>Créé</th>
54
+ <th>Note</th>
55
+ <th>Actions</th>
56
+ </tr>
57
+ </thead>
58
+ <tbody>
59
+ {{{ each rows }}}
60
+ <tr>
61
+ <td><code>{rows.rid}</code></td>
62
+ <td>{rows.itemName}</td>
63
+ <td>{rows.uid}</td>
64
+ <td><small>{rows.start}</small></td>
65
+ <td><small>{rows.end}</small></td>
66
+ <td><code>{rows.status}</code></td>
67
+ <td><small>{rows.createdAt}</small></td>
68
+ <td><small>{rows.notesUser}</small></td>
69
+ <td class="text-nowrap">
70
+ <form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/approve" class="d-inline">
71
+ <input type="hidden" name="_csrf" value="{config.csrf_token}">
72
+ <button class="btn btn-sm btn-success" type="submit">Approve</button>
73
+ </form>
74
+ <form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/reject" class="d-inline ms-1">
75
+ <input type="hidden" name="_csrf" value="{config.csrf_token}">
76
+ <button class="btn btn-sm btn-warning" type="submit">Reject</button>
77
+ </form>
78
+ <form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/delete" class="d-inline ms-1" onsubmit="return confirm('Supprimer définitivement ?');">
79
+ <input type="hidden" name="_csrf" value="{config.csrf_token}">
80
+ <button class="btn btn-sm btn-danger" type="submit">Delete</button>
81
+ </form>
82
+ </td>
83
+ </tr>
84
+ {{{ end }}}
85
+ </tbody>
86
+ </table>
87
+ </div>
88
+
89
+ <div class="d-flex justify-content-between align-items-center mt-3">
90
+ <div>Page {page} / {totalPages}</div>
91
+ <div class="btn-group">
92
+ {{{ if prevPage }}}
93
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar/reservations?page={prevPage}&perPage={perPage}">Précédent</a>
94
+ {{{ end }}}
95
+ {{{ if nextPage }}}
96
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar/reservations?page={nextPage}&perPage={perPage}">Suivant</a>
97
+ {{{ end }}}
98
+ </div>
99
+ </div>
100
+
101
+ {{{ else }}}
102
+ <div class="alert alert-info">Aucune réservation trouvée.</div>
103
+ {{{ end }}}
104
+ </div>
@@ -1,5 +1,11 @@
1
1
  <div class="acp-page-container">
2
- <h1>Equipment Calendar</h1>
2
+ <div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
3
+ <h1 class="mb-0">Equipment Calendar</h1>
4
+ <div class="btn-group">
5
+ <a class="btn btn-secondary active" href="/admin/plugins/equipment-calendar">Paramètres</a>
6
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar/reservations">Réservations</a>
7
+ </div>
8
+ </div>
3
9
 
4
10
  {{{ if saved }}}
5
11
  <div class="alert alert-success">Paramètres enregistrés.</div>
@@ -49,7 +55,33 @@
49
55
 
50
56
  <div class="card card-body mb-3">
51
57
  <h5>Matériel</h5>
52
- <textarea name="itemsJson" class="form-control" rows="10">{settings.itemsJson}</textarea>
58
+ <div class="mb-3">
59
+ <label class="form-label">Source</label>
60
+ <select class="form-select" name="itemsSource">
61
+ <option value="manual">Manuel (JSON)</option>
62
+ <option value="helloasso">HelloAsso (articles d’un formulaire)</option>
63
+ </select>
64
+ <div class="form-text">Si HelloAsso est choisi, la liste du matériel est récupérée via l’API HelloAsso (items d’un formulaire).</div>
65
+ </div>
66
+
67
+ <div class="mb-3">
68
+ <label class="form-label">HelloAsso formType (ex: shop, event, membership, donation)</label>
69
+ <input class="form-control" name="ha_itemsFormType" value="{settings.ha_itemsFormType}">
70
+ </div>
71
+ <div class="mb-3">
72
+ <label class="form-label">HelloAsso formSlug</label>
73
+ <input class="form-control" name="ha_itemsFormSlug" value="{settings.ha_itemsFormSlug}">
74
+ </div>
75
+ <div class="mb-3">
76
+ <label class="form-label">Mapping lieux (JSON) par id d’article HelloAsso</label>
77
+ <textarea class="form-control" rows="4" name="ha_locationMapJson">{settings.ha_locationMapJson}</textarea>
78
+ <div class="form-text">Ex: { "12345": "Local A", "67890": "Local B" }</div>
79
+ </div>
80
+
81
+ <div class="mb-3">
82
+ <label class="form-label">Matériel manuel (JSON) — utilisé uniquement si Source=Manuel</label>
83
+ <textarea name="itemsJson" class="form-control" rows="10">{settings.itemsJson}</textarea>
84
+ </div>
53
85
  </div>
54
86
 
55
87
  <div class="card card-body mb-3">
@@ -63,6 +95,11 @@
63
95
 
64
96
  <div class="card card-body mb-3">
65
97
  <h5>Calendrier</h5>
98
+ <div class="mb-3">
99
+ <label class="form-label">Timeout paiement (minutes)</label>
100
+ <input class="form-control" type="number" min="1" name="paymentTimeoutMinutes" value="{settings.paymentTimeoutMinutes}">
101
+ <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>
102
+ </div>
66
103
  <div class="mb-3">
67
104
  <label class="form-label">Vue par défaut</label>
68
105
  <select name="defaultView" class="form-select">
@@ -74,7 +111,47 @@
74
111
  <div class="mb-3"><label class="form-label">Timezone</label><input name="timezone" class="form-control" value="{settings.timezone}"></div>
75
112
  </div>
76
113
 
114
+
115
+ <div class="card card-body mb-3">
116
+ <h5>Purge</h5>
117
+ <p class="text-muted mb-2">Supprime des réservations de la base (action irréversible).</p>
118
+
119
+ <div class="d-flex flex-wrap gap-2">
120
+ <button type="submit" class="btn btn-outline-danger" formaction="/admin/plugins/equipment-calendar/purge" formmethod="post" name="mode" value="nonblocking"
121
+ onclick="return confirm('Supprimer toutes les réservations refusées/annulées ?');">
122
+ Purger refusées/annulées
123
+ </button>
124
+
125
+ <div class="d-flex align-items-center gap-2">
126
+ <input class="form-control" style="max-width: 140px" type="number" min="1" name="olderThanDays" placeholder="Jours">
127
+ <button type="submit" class="btn btn-outline-danger" formaction="/admin/plugins/equipment-calendar/purge" formmethod="post" name="mode" value="olderThan"
128
+ onclick="return confirm('Supprimer les réservations plus anciennes que N jours ?');">
129
+ Purger plus anciennes que…
130
+ </button>
131
+ </div>
132
+
133
+ <button type="submit" class="btn btn-danger" formaction="/admin/plugins/equipment-calendar/purge" formmethod="post" name="mode" value="all"
134
+ onclick="return confirm('⚠️ Supprimer TOUTES les réservations ?');">
135
+ Tout purger
136
+ </button>
137
+ </div>
138
+
139
+ {{{ if purged }}}
140
+ <div class="alert alert-success mt-3">Purge effectuée : {purged} réservation(s) supprimée(s).</div>
141
+ <script>
142
+ (function () {
143
+ try {
144
+ if (window.app && window.app.alertSuccess) {
145
+ window.app.alertSuccess('Purge effectuée : {purged} réservation(s) supprimée(s).');
146
+ }
147
+ } catch (e) {}
148
+ }());
149
+ </script>
150
+ {{{ end }}}
151
+ </div>
152
+
77
153
  <button id="ec-save" class="btn btn-primary" type="button">Sauvegarder</button>
154
+
78
155
  </form>
79
156
  </div>
80
157
 
@@ -18,7 +18,7 @@
18
18
  <input type="hidden" name="_csrf" value="{config.csrf_token}">
19
19
  <input type="hidden" name="start" value="">
20
20
  <input type="hidden" name="end" value="">
21
- <input type="hidden" name="itemId" value="">
21
+ <input type="hidden" name="itemIds" value="">
22
22
  <input type="hidden" name="notesUser" value="">
23
23
  </form>
24
24
  </div>
@@ -1,9 +1,15 @@
1
- <div class="equipment-payment-return">
2
- <h1>Paiement</h1>
1
+ <div class="card card-body">
2
+ <h4>Retour paiement</h4>
3
+
4
+ {{{ if statusPaid }}}
5
+ <div class="alert alert-success">✅ Paiement confirmé.</div>
6
+ {{{ end }}}
7
+
3
8
  {{{ if statusError }}}
4
- <div class="alert alert-danger">Le paiement semble avoir échoué. Référence réservation: <code>{rid}</code></div>
5
- {{{ else }}}
6
- <div class="alert alert-info">Merci. Si le paiement est confirmé, la réservation passera en "validée". Référence: <code>{rid}</code></div>
9
+ <div class="alert alert-danger">❌ Paiement non confirmé.</div>
7
10
  {{{ end }}}
11
+
12
+ <p>{message}</p>
13
+
8
14
  <a class="btn btn-primary" href="/equipment/calendar">Retour au calendrier</a>
9
15
  </div>