nodebb-plugin-calendar-onekite 2.0.0 → 2.1.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/README.md CHANGED
@@ -19,3 +19,10 @@ If you need offline / no-CDN, tell me and I will vendor the files under static/v
19
19
  - Pages: /calendar, /calendar/my-reservations
20
20
  - Admin: /admin/plugins/calendar-onekite, /admin/calendar/planning
21
21
  - API: available under /api/... and /api/v3/... (NodeBB v4 client helper uses /api/v3)
22
+
23
+
24
+ ## v2.1 inventory model
25
+ - Global locations/inventory in ACP (locationsJson/inventoryJson)
26
+ - Events select allowed inventory item IDs (bookingItemIds)
27
+ - Availability is global per (itemId, locationId) across all events
28
+ - Admin planning is graphical (FullCalendar) with filters
package/library.js CHANGED
@@ -21,11 +21,28 @@ function getEventKey(eid) {
21
21
 
22
22
  function mountApiBoth(router, method, path, ...handlers) {
23
23
  const m = String(method).toLowerCase();
24
- if (typeof router[m] !== 'function') throw new Error(`Unsupported router method: ${method}`);
25
24
  router[m]('/api' + path, ...handlers);
26
25
  router[m]('/api/v3' + path, ...handlers);
27
26
  }
28
27
 
28
+ async function getParsedSettings() {
29
+ const s = (await Settings.get(SETTINGS_KEY)) || {};
30
+ const parseJson = (str, fallback) => {
31
+ try { return JSON.parse(str || ''); } catch { return fallback; }
32
+ };
33
+
34
+ const locations = parseJson(s.locationsJson, [
35
+ { id: 'arnaud', name: 'Chez Arnaud' },
36
+ { id: 'siege', name: 'Siège Onekite' },
37
+ ]);
38
+
39
+ const inventory = parseJson(s.inventoryJson, [
40
+ { id: 'wing', name: 'Aile Wing', price: 5, stockByLocation: { arnaud: 1, siege: 0 } },
41
+ ]);
42
+
43
+ return { ...s, locations, inventory };
44
+ }
45
+
29
46
  async function nextReservationId() {
30
47
  const rid = await db.incrObjectField('global', 'nextCalendarRid');
31
48
  return String(rid);
@@ -50,7 +67,10 @@ async function createEvent(data, uid) {
50
67
  const now = Date.now();
51
68
  const startTs = Number(new Date(data.start).getTime()) || now;
52
69
  const endTs = Number(new Date(data.end).getTime()) || startTs;
53
- const bookingItems = Array.isArray(data.bookingItems) ? data.bookingItems : [];
70
+
71
+ const bookingItemIds = Array.isArray(data.bookingItemIds)
72
+ ? data.bookingItemIds.map(String)
73
+ : [];
54
74
 
55
75
  const eventObj = {
56
76
  eid: String(eid),
@@ -71,7 +91,7 @@ async function createEvent(data, uid) {
71
91
  visibility: String(data.visibility || 'public'),
72
92
 
73
93
  bookingEnabled: data.bookingEnabled ? 1 : 0,
74
- bookingItems: JSON.stringify(bookingItems),
94
+ bookingItems: JSON.stringify(bookingItemIds), // stores IDs only
75
95
  reservations: JSON.stringify([]),
76
96
  };
77
97
 
@@ -92,9 +112,18 @@ async function updateEvent(eid, data) {
92
112
  const startTs = data.start ? new Date(data.start).getTime() : new Date(existing.start).getTime();
93
113
  const endTs = data.end ? new Date(data.end).getTime() : new Date(existing.end).getTime();
94
114
 
95
- const bookingItems = Array.isArray(data.bookingItems)
96
- ? data.bookingItems
97
- : JSON.parse(existing.bookingItems || '[]');
115
+ const existingIds = (() => {
116
+ try {
117
+ const arr = JSON.parse(existing.bookingItems || '[]');
118
+ return Array.isArray(arr) ? arr.map(String) : [];
119
+ } catch {
120
+ return [];
121
+ }
122
+ })();
123
+
124
+ const bookingItemIds = Array.isArray(data.bookingItemIds)
125
+ ? data.bookingItemIds.map(String)
126
+ : existingIds;
98
127
 
99
128
  const updated = {
100
129
  ...existing,
@@ -106,7 +135,7 @@ async function updateEvent(eid, data) {
106
135
  location: data.location !== undefined ? String(data.location) : existing.location,
107
136
  visibility: data.visibility !== undefined ? String(data.visibility) : existing.visibility,
108
137
  bookingEnabled: data.bookingEnabled !== undefined ? (data.bookingEnabled ? 1 : 0) : (existing.bookingEnabled || 0),
109
- bookingItems: JSON.stringify(bookingItems),
138
+ bookingItems: JSON.stringify(bookingItemIds),
110
139
  updatedAt: String(Date.now()),
111
140
  };
112
141
 
@@ -125,13 +154,11 @@ async function getEventsBetween(start, end) {
125
154
  const endTs = new Date(end).getTime();
126
155
  const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, -1, startTs, endTs);
127
156
  if (!eids || !eids.length) return [];
157
+
128
158
  const keys = eids.map(id => getEventKey(id));
129
159
  const events = await db.getObjects(keys);
130
160
 
131
- return (events || []).filter(Boolean).map(ev => ({
132
- ...ev,
133
- bookingItems: JSON.parse(ev.bookingItems || '[]'),
134
- }));
161
+ return (events || []).filter(Boolean);
135
162
  }
136
163
 
137
164
  async function getUpcomingEvents(limit = 5) {
@@ -212,47 +239,61 @@ async function setRsvp(eid, uid, status) {
212
239
  return updated;
213
240
  }
214
241
 
242
+ /* ---------------- Reservations data helpers ---------------- */
243
+
244
+ async function getAllReservations() {
245
+ const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
246
+ const out = [];
247
+ for (const eid of eids) {
248
+ const ev = await getEvent(eid);
249
+ if (!ev) continue;
250
+ const resList = (() => {
251
+ try { return JSON.parse(ev.reservations || '[]'); } catch { return []; }
252
+ })();
253
+ for (const r of resList) {
254
+ out.push({ event: ev, reservation: r });
255
+ }
256
+ }
257
+ return out;
258
+ }
259
+
215
260
  /* ---------------- Pricing ---------------- */
216
261
 
217
- function computePrice(event, reservation) {
218
- const items = JSON.parse(event.bookingItems || '[]');
219
- const item = items.find(i => i.id === reservation.itemId);
220
- if (!item) return 0;
221
- const unit = Number(item.price || 0);
262
+ function computePrice(inventoryItem, reservation) {
263
+ const unit = Number(inventoryItem?.price || 0);
222
264
  const days = Number(reservation.days || 1);
223
265
  return unit * Number(reservation.quantity || 0) * days;
224
266
  }
225
267
 
226
268
  /* ---------------- Renderers ---------------- */
227
269
 
228
- function renderCalendarPage(req, res) {
229
- res.render('calendar', { title: 'Calendrier' });
230
- }
231
-
232
- function renderMyReservationsPage(req, res) {
233
- res.render('calendar-my-reservations', { title: 'Mes réservations' });
234
- }
235
-
236
- function renderPlanningPage(req, res) {
237
- res.render('admin/calendar-planning', { title: 'Planning des réservations' });
238
- }
270
+ function renderCalendarPage(req, res) { res.render('calendar', { title: 'Calendrier' }); }
271
+ function renderMyReservationsPage(req, res) { res.render('calendar-my-reservations', { title: 'Mes réservations' }); }
272
+ function renderPlanningPage(req, res) { res.render('admin/calendar-planning', { title: 'Planning' }); }
239
273
 
240
274
  async function renderAdminPage(req, res) {
241
275
  try {
242
276
  const settings = (await Settings.get(SETTINGS_KEY)) || {};
243
- res.render('admin/plugins/calendar-onekite', {
244
- title: 'Calendar OneKite',
245
- settings,
246
- });
247
- } catch (err) {
248
- if (req.path && req.path.startsWith('/api/')) {
249
- return res.status(500).json({ error: err.message });
277
+ // Provide defaults JSON strings if empty
278
+ if (!settings.locationsJson) {
279
+ settings.locationsJson = JSON.stringify([
280
+ { id: 'arnaud', name: 'Chez Arnaud' },
281
+ { id: 'siege', name: 'Siège Onekite' },
282
+ ], null, 2);
250
283
  }
251
- res.status(500).send(err.message);
284
+ if (!settings.inventoryJson) {
285
+ settings.inventoryJson = JSON.stringify([
286
+ { id: 'wing', name: 'Aile Wing', price: 5, stockByLocation: { arnaud: 1, siege: 0 } },
287
+ ], null, 2);
288
+ }
289
+
290
+ res.render('admin/plugins/calendar-onekite', { title: 'Calendar OneKite', settings });
291
+ } catch (err) {
292
+ res.status(500).json({ error: err.message });
252
293
  }
253
294
  }
254
295
 
255
- /* ---------------- Admin handlers ---------------- */
296
+ /* ---------------- Admin APIs ---------------- */
256
297
 
257
298
  async function adminGetPending(req, res) {
258
299
  try {
@@ -271,37 +312,42 @@ async function adminGetPending(req, res) {
271
312
  }
272
313
  }
273
314
 
315
+ async function adminSaveSettings(req, res) {
316
+ try {
317
+ await Settings.set(SETTINGS_KEY, req.body);
318
+ res.json({ status: 'ok' });
319
+ } catch (err) {
320
+ res.status(500).json({ error: err.message });
321
+ }
322
+ }
323
+
274
324
  async function adminValidateReservation(req, res) {
275
325
  try {
276
326
  const rid = req.params.rid;
277
- const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
278
-
279
- let targetEvent = null;
280
- let reservation = null;
327
+ const all = await getAllReservations();
281
328
 
282
- for (const eid of eids) {
283
- const event = await getEvent(eid);
284
- if (!event) continue;
285
- const reservations = JSON.parse(event.reservations || '[]');
286
- const r = reservations.find(rr => rr.rid === rid);
287
- if (r) {
288
- targetEvent = event;
289
- reservation = r;
290
- break;
291
- }
329
+ let target = null;
330
+ for (const row of all) {
331
+ if (String(row.reservation.rid) === String(rid)) { target = row; break; }
292
332
  }
333
+ if (!target) return res.status(404).json({ error: 'Réservation introuvable' });
334
+
335
+ const { event } = target;
336
+ const reservation = target.reservation;
293
337
 
294
- if (!reservation) return res.status(404).json({ error: 'Réservation introuvable' });
295
338
  if (reservation.status !== 'pending_admin') return res.status(400).json({ error: 'Réservation déjà traitée' });
296
339
 
297
340
  reservation.status = 'awaiting_payment';
298
- const resList = JSON.parse(targetEvent.reservations || '[]');
299
- const idx = resList.findIndex(r => r.rid === rid);
341
+
342
+ const resList = JSON.parse(event.reservations || '[]');
343
+ const idx = resList.findIndex(r => String(r.rid) === String(rid));
300
344
  resList[idx] = reservation;
301
- targetEvent.reservations = JSON.stringify(resList);
302
- await db.setObject(getEventKey(targetEvent.eid), targetEvent);
345
+ event.reservations = JSON.stringify(resList);
346
+ await db.setObject(getEventKey(event.eid), event);
303
347
 
304
- const amount = computePrice(targetEvent, reservation);
348
+ const settings = await getParsedSettings();
349
+ const invItem = settings.inventory.find(i => String(i.id) === String(reservation.itemId));
350
+ const amount = computePrice(invItem, reservation);
305
351
 
306
352
  const checkoutUrl = await helloAsso.createHelloAssoCheckoutIntent({
307
353
  eid: reservation.eid,
@@ -315,11 +361,11 @@ async function adminValidateReservation(req, res) {
315
361
  try {
316
362
  await emailer.send('calendar-reservation-approved', reservation.uid, {
317
363
  subject: 'Votre réservation a été validée',
318
- eventTitle: targetEvent.title,
319
- itemName: reservation.itemId,
364
+ eventTitle: event.title,
365
+ itemName: invItem?.name || reservation.itemId,
320
366
  quantity: reservation.quantity,
321
367
  checkoutUrl,
322
- pickupLocation: reservation.pickupLocation || 'Non précisé',
368
+ pickupLocation: reservation.pickupLocationName || reservation.locationId || 'Non précisé',
323
369
  dateStart: reservation.dateStart,
324
370
  dateEnd: reservation.dateEnd,
325
371
  days: reservation.days || 1,
@@ -338,14 +384,13 @@ async function adminCancelReservation(req, res) {
338
384
  try {
339
385
  const rid = req.params.rid;
340
386
  const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
341
-
342
387
  let found = false;
388
+
343
389
  for (const eid of eids) {
344
390
  const event = await getEvent(eid);
345
391
  if (!event) continue;
346
-
347
392
  const reservations = JSON.parse(event.reservations || '[]');
348
- const idx = reservations.findIndex(rr => rr.rid === rid);
393
+ const idx = reservations.findIndex(r => String(r.rid) === String(rid));
349
394
  if (idx !== -1) {
350
395
  reservations[idx].status = 'cancelled';
351
396
  event.reservations = JSON.stringify(reservations);
@@ -364,35 +409,34 @@ async function adminCancelReservation(req, res) {
364
409
 
365
410
  async function adminGetPlanning(req, res) {
366
411
  try {
367
- const now = new Date();
368
- const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, -1, now.getTime(), '+inf');
369
- const rows = [];
412
+ const settings = await getParsedSettings();
413
+ const locMap = new Map(settings.locations.map(l => [String(l.id), l.name || l.id]));
414
+ const invMap = new Map(settings.inventory.map(i => [String(i.id), i.name || i.id]));
370
415
 
371
- for (const eid of eids) {
372
- const event = await getEvent(eid);
373
- if (!event) continue;
416
+ const all = await getAllReservations();
417
+ const rows = [];
374
418
 
375
- const items = JSON.parse(event.bookingItems || '[]');
376
- const reservations = JSON.parse(event.reservations || '[]');
419
+ for (const row of all) {
420
+ const event = row.event;
421
+ const r = row.reservation;
422
+ if (!r) continue;
423
+ if (!['pending_admin', 'awaiting_payment', 'paid'].includes(String(r.status))) continue;
377
424
 
378
- reservations
379
- .filter(r => r.status === 'pending_admin' || r.status === 'awaiting_payment' || r.status === 'paid')
380
- .forEach(r => {
381
- const item = items.find(i => i.id === r.itemId);
382
- rows.push({
383
- eid: event.eid,
384
- eventTitle: event.title,
385
- itemId: r.itemId,
386
- itemName: item ? item.name : r.itemId,
387
- uid: r.uid,
388
- quantity: r.quantity,
389
- dateStart: r.dateStart,
390
- dateEnd: r.dateEnd,
391
- days: r.days || daysBetween(r.dateStart, r.dateEnd),
392
- status: r.status,
393
- pickupLocation: r.pickupLocation || 'Non précisé',
394
- });
395
- });
425
+ rows.push({
426
+ rid: r.rid,
427
+ eid: event.eid,
428
+ eventTitle: event.title,
429
+ itemId: r.itemId,
430
+ itemName: invMap.get(String(r.itemId)) || r.itemId,
431
+ uid: r.uid,
432
+ quantity: r.quantity,
433
+ dateStart: r.dateStart,
434
+ dateEnd: r.dateEnd,
435
+ days: r.days || daysBetween(r.dateStart, r.dateEnd),
436
+ status: r.status,
437
+ locationId: r.locationId,
438
+ pickupLocation: r.pickupLocationName || locMap.get(String(r.locationId)) || 'Non précisé',
439
+ });
396
440
  }
397
441
 
398
442
  rows.sort((a, b) => new Date(a.dateStart) - new Date(b.dateStart));
@@ -402,22 +446,29 @@ async function adminGetPlanning(req, res) {
402
446
  }
403
447
  }
404
448
 
405
- async function adminSaveSettings(req, res) {
449
+ /* ---------------- Public APIs ---------------- */
450
+
451
+ async function inventoryApi(req, res) {
406
452
  try {
407
- await Settings.set(SETTINGS_KEY, req.body);
408
- res.json({ status: 'ok' });
453
+ const settings = await getParsedSettings();
454
+ res.json({
455
+ locations: settings.locations || [],
456
+ inventory: settings.inventory || [],
457
+ });
409
458
  } catch (err) {
410
459
  res.status(500).json({ error: err.message });
411
460
  }
412
461
  }
413
462
 
414
- /* ---------------- My reservations API ---------------- */
415
-
416
463
  async function myReservations(req, res) {
417
464
  try {
418
465
  const uid = String(req.user?.uid || 0);
419
466
  if (!uid || uid === '0') return res.status(403).json({ error: 'Non connecté' });
420
467
 
468
+ const settings = await getParsedSettings();
469
+ const locMap = new Map(settings.locations.map(l => [String(l.id), l.name || l.id]));
470
+ const invMap = new Map(settings.inventory.map(i => [String(i.id), i.name || i.id]));
471
+
421
472
  const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
422
473
  const result = [];
423
474
 
@@ -425,19 +476,15 @@ async function myReservations(req, res) {
425
476
  const event = await getEvent(eid);
426
477
  if (!event) continue;
427
478
 
428
- const items = JSON.parse(event.bookingItems || '[]');
429
479
  const reservations = JSON.parse(event.reservations || '[]');
430
-
431
480
  reservations
432
481
  .filter(r => String(r.uid) === uid)
433
482
  .forEach(r => {
434
- const item = items.find(i => i.id === r.itemId);
435
483
  result.push({
436
484
  ...r,
437
485
  eventTitle: event.title,
438
- eventStart: event.start,
439
- eventEnd: event.end,
440
- itemName: item ? item.name : r.itemId,
486
+ itemName: invMap.get(String(r.itemId)) || r.itemId,
487
+ pickupLocation: r.pickupLocationName || locMap.get(String(r.locationId)) || '',
441
488
  });
442
489
  });
443
490
  }
@@ -449,55 +496,72 @@ async function myReservations(req, res) {
449
496
  }
450
497
  }
451
498
 
452
- /* ---------------- Booking ---------------- */
453
-
454
499
  async function bookReservation(req, res) {
455
500
  try {
456
501
  const uid = req.user?.uid || 0;
457
502
  const eid = req.params.eid;
458
- const { itemId, quantity, dateStart, dateEnd } = req.body;
503
+ const { itemId, quantity, dateStart, dateEnd, locationId } = req.body;
459
504
 
460
505
  if (!await userCanBook(uid)) {
461
506
  return res.status(403).json({ error: 'Vous n’êtes pas autorisé à réserver du matériel.' });
462
507
  }
463
- if (!dateStart || !dateEnd) {
464
- return res.status(400).json({ error: 'Dates de début et de fin obligatoires.' });
508
+ if (!dateStart || !dateEnd || !itemId || !locationId) {
509
+ return res.status(400).json({ error: 'itemId, locationId, dateStart, dateEnd obligatoires.' });
465
510
  }
466
511
  if (String(dateEnd) < String(dateStart)) {
467
512
  return res.status(400).json({ error: 'La date de fin doit être ≥ la date de début.' });
468
513
  }
469
514
 
470
- const event = await getEvent(eid);
471
- if (!event) return res.status(404).json({ error: 'Événement introuvable' });
472
- if (!Number(event.bookingEnabled)) return res.status(400).json({ error: 'Réservation désactivée' });
515
+ const settings = await getParsedSettings();
516
+ const loc = settings.locations.find(l => String(l.id) === String(locationId));
517
+ if (!loc) return res.status(400).json({ error: 'Lieu invalide.' });
518
+
519
+ const invItem = settings.inventory.find(i => String(i.id) === String(itemId));
520
+ if (!invItem) return res.status(400).json({ error: 'Matériel invalide.' });
473
521
 
474
- const items = JSON.parse(event.bookingItems || '[]');
475
- const item = items.find(i => i.id === itemId);
476
- if (!item) return res.status(400).json({ error: 'Matériel introuvable' });
522
+ const stockTotal = Number(invItem.stockByLocation?.[String(locationId)] || 0);
523
+ if (stockTotal <= 0) return res.status(400).json({ error: 'Stock indisponible sur ce lieu.' });
477
524
 
478
525
  const q = Number(quantity);
479
526
  if (!q || q <= 0) return res.status(400).json({ error: 'Quantité invalide' });
480
527
 
481
- const allRes = JSON.parse(event.reservations || '[]');
482
-
483
- const overlapping = allRes.filter(r => {
484
- if (r.itemId !== itemId) return false;
485
- if (r.status === 'cancelled') return false;
528
+ const event = await getEvent(eid);
529
+ if (!event) return res.status(404).json({ error: 'Événement introuvable' });
530
+ if (!Number(event.bookingEnabled)) return res.status(400).json({ error: 'Réservation désactivée' });
486
531
 
487
- const startR = new Date(r.dateStart);
488
- const endR = new Date(r.dateEnd);
489
- const startN = new Date(dateStart);
490
- const endN = new Date(dateEnd);
532
+ // Ensure item is allowed for this event
533
+ const allowedIds = (() => {
534
+ try {
535
+ const arr = JSON.parse(event.bookingItems || '[]');
536
+ return Array.isArray(arr) ? arr.map(String) : [];
537
+ } catch {
538
+ return [];
539
+ }
540
+ })();
541
+ if (!allowedIds.includes(String(itemId))) {
542
+ return res.status(400).json({ error: 'Ce matériel n’est pas autorisé pour cet événement.' });
543
+ }
491
544
 
492
- return !(endN < startR || startN > endR);
493
- });
545
+ // Global availability: check overlaps across all events reservations (same itemId + locationId)
546
+ const all = await getAllReservations();
547
+ const overlapping = all
548
+ .map(x => x.reservation)
549
+ .filter(r => r && String(r.itemId) === String(itemId))
550
+ .filter(r => String(r.locationId) === String(locationId))
551
+ .filter(r => String(r.status) !== 'cancelled')
552
+ .filter(r => {
553
+ const startR = new Date(r.dateStart);
554
+ const endR = new Date(r.dateEnd);
555
+ const startN = new Date(dateStart);
556
+ const endN = new Date(dateEnd);
557
+ return !(endN < startR || startN > endR);
558
+ });
494
559
 
495
560
  const used = overlapping.reduce((sum, r) => sum + Number(r.quantity || 0), 0);
496
- const available = Number(item.total || 0) - used;
497
- if (q > available) return res.status(400).json({ error: 'Matériel indisponible pour ces dates.' });
561
+ const available = stockTotal - used;
562
+ if (q > available) return res.status(400).json({ error: `Stock insuffisant sur ce lieu (dispo: ${available}).` });
498
563
 
499
564
  const rid = await nextReservationId();
500
- const now = Date.now();
501
565
  const nbDays = daysBetween(dateStart, dateEnd);
502
566
 
503
567
  const reservation = {
@@ -505,31 +569,32 @@ async function bookReservation(req, res) {
505
569
  eid: String(eid),
506
570
  uid: String(uid),
507
571
  itemId: String(itemId),
572
+ locationId: String(locationId),
573
+ pickupLocationName: String(loc.name || loc.id),
508
574
  quantity: q,
509
575
  dateStart,
510
576
  dateEnd,
511
577
  days: nbDays,
512
578
  status: 'pending_admin',
513
579
  helloAssoOrderId: null,
514
- createdAt: now,
515
- pickupLocation: String(item.pickupLocation || ''),
580
+ createdAt: Date.now(),
516
581
  };
517
582
 
518
- allRes.push(reservation);
519
- event.reservations = JSON.stringify(allRes);
583
+ const resList = JSON.parse(event.reservations || '[]');
584
+ resList.push(reservation);
585
+ event.reservations = JSON.stringify(resList);
520
586
  await db.setObject(getEventKey(eid), event);
521
587
 
522
588
  try {
523
589
  await emailer.send('calendar-reservation-created', uid, {
524
590
  subject: 'Votre demande de réservation a été envoyée',
525
591
  eventTitle: event.title,
526
- item: item.name,
592
+ item: invItem.name || invItem.id,
527
593
  quantity: reservation.quantity,
528
- date: new Date().toLocaleString('fr-FR'),
529
594
  dateStart,
530
595
  dateEnd,
531
596
  days: nbDays,
532
- pickupLocation: reservation.pickupLocation || 'Non précisé',
597
+ pickupLocation: reservation.pickupLocationName,
533
598
  });
534
599
  } catch (e) {
535
600
  console.warn('[calendar-onekite] email reservation-created error:', e.message);
@@ -545,7 +610,7 @@ async function bookReservation(req, res) {
545
610
  }
546
611
  }
547
612
 
548
- /* ---------------- Webhook ---------------- */
613
+ /* ---------------- HelloAsso webhook ---------------- */
549
614
 
550
615
  async function helloAssoWebhook(req, res) {
551
616
  try {
@@ -565,7 +630,7 @@ async function helloAssoWebhook(req, res) {
565
630
  if (!event) throw new Error('Event not found');
566
631
 
567
632
  const reservations = JSON.parse(event.reservations || '[]');
568
- const idx = reservations.findIndex(r => r.rid === rid);
633
+ const idx = reservations.findIndex(r => String(r.rid) === String(rid));
569
634
  if (idx === -1) throw new Error('Reservation not found');
570
635
 
571
636
  const reservation = reservations[idx];
@@ -578,13 +643,16 @@ async function helloAssoWebhook(req, res) {
578
643
  event.reservations = JSON.stringify(reservations);
579
644
  await db.setObject(getEventKey(eid), event);
580
645
 
646
+ const settings = await getParsedSettings();
647
+ const invItem = settings.inventory.find(i => String(i.id) === String(reservation.itemId));
648
+
581
649
  try {
582
650
  await emailer.send('calendar-payment-confirmed', reservation.uid, {
583
651
  subject: 'Votre paiement a été confirmé',
584
652
  eventTitle: event.title,
585
- itemName: reservation.itemId,
653
+ itemName: invItem?.name || reservation.itemId,
586
654
  quantity: reservation.quantity,
587
- pickupLocation: reservation.pickupLocation || 'Non précisé',
655
+ pickupLocation: reservation.pickupLocationName || reservation.locationId || 'Non précisé',
588
656
  dateStart: reservation.dateStart,
589
657
  dateEnd: reservation.dateEnd,
590
658
  days: reservation.days || 1,
@@ -644,13 +712,13 @@ Plugin.init = async function (params) {
644
712
  appRef = params.app;
645
713
 
646
714
  // Pages
647
- router.get('/calendar', middleware.buildHeader, renderCalendarPage);
648
- router.get('/calendar/my-reservations', middleware.buildHeader, renderMyReservationsPage);
715
+ router.get('/calendar', middleware.buildHeader, (req, res) => res.render('calendar', { title: 'Calendrier' }));
716
+ router.get('/calendar/my-reservations', middleware.buildHeader, (req, res) => res.render('calendar-my-reservations', { title: 'Mes réservations' }));
649
717
 
650
- router.get('/admin/calendar/planning', middleware.admin.buildHeader, renderPlanningPage);
718
+ router.get('/admin/calendar/planning', middleware.admin.buildHeader, (req, res) => res.render('admin/calendar-planning', { title: 'Planning' }));
651
719
  router.get('/admin/plugins/calendar-onekite', middleware.admin.buildHeader, renderAdminPage);
652
720
 
653
- // Ajaxify admin page under /api and /api/v3
721
+ // Admin page (ajaxify)
654
722
  mountApiBoth(router, 'get', '/admin/plugins/calendar-onekite', middleware.admin.checkPrivileges, renderAdminPage);
655
723
 
656
724
  // Settings save
@@ -662,12 +730,10 @@ Plugin.init = async function (params) {
662
730
  mountApiBoth(router, 'post', '/admin/calendar/reservation/:rid/cancel', middleware.admin.checkPrivileges, adminCancelReservation);
663
731
  mountApiBoth(router, 'get', '/admin/calendar/planning', middleware.admin.checkPrivileges, adminGetPlanning);
664
732
 
665
- // Planning page ajaxify helper
666
- router.get('/api/admin/calendar/planning/page', renderPlanningPage);
667
- router.get('/api/v3/admin/calendar/planning/page', renderPlanningPage);
733
+ // Public inventory
734
+ mountApiBoth(router, 'get', '/calendar/inventory', inventoryApi);
668
735
 
669
- // Public APIs + v3
670
- // Events list (FullCalendar)
736
+ // Events list for FullCalendar
671
737
  mountApiBoth(router, 'get', '/calendar/events', async (req, res) => {
672
738
  try {
673
739
  const { start, end } = req.query;
@@ -713,13 +779,29 @@ Plugin.init = async function (params) {
713
779
  }
714
780
  });
715
781
 
782
+ // Event details: enrich with bookingItemIds + resolved items
716
783
  mountApiBoth(router, 'get', '/calendar/event/:eid', async (req, res) => {
717
784
  try {
785
+ const settings = await getParsedSettings();
786
+ const invMap = new Map(settings.inventory.map(i => [String(i.id), i]));
787
+
718
788
  const event = await getEvent(req.params.eid);
719
789
  if (!event) return res.status(404).json({ error: 'Événement introuvable' });
720
- const items = JSON.parse(event.bookingItems || '[]');
721
- const reservations = JSON.parse(event.reservations || '[]');
722
- res.json({ ...event, bookingItems: items, reservations });
790
+
791
+ const bookingItemIds = (() => {
792
+ try {
793
+ const arr = JSON.parse(event.bookingItems || '[]');
794
+ return Array.isArray(arr) ? arr.map(String) : [];
795
+ } catch { return []; }
796
+ })();
797
+
798
+ const bookingItems = bookingItemIds.map(id => invMap.get(String(id))).filter(Boolean);
799
+
800
+ const reservations = (() => {
801
+ try { return JSON.parse(event.reservations || '[]'); } catch { return []; }
802
+ })();
803
+
804
+ res.json({ ...event, bookingItemIds, bookingItems, reservations });
723
805
  } catch (err) {
724
806
  res.status(500).json({ error: err.message });
725
807
  }
@@ -756,7 +838,7 @@ Plugin.init = async function (params) {
756
838
  // My reservations
757
839
  mountApiBoth(router, 'get', '/calendar/my-reservations', middleware.ensureLoggedIn, myReservations);
758
840
 
759
- // HelloAsso webhook (keep non-v3 public path + v3 mirror if needed)
841
+ // HelloAsso webhook
760
842
  router.post('/api/calendar/helloasso/webhook', helloAssoWebhook);
761
843
  router.post('/api/v3/calendar/helloasso/webhook', helloAssoWebhook);
762
844
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Calendar + equipment booking + admin approval + HelloAsso payments for NodeBB v4 (no-jQuery UI)",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "Calendar Onekite",
4
4
  "description": "Calendrier + réservation matériel + validation admin + paiement HelloAsso pour NodeBB v4 (no-jQuery UI)",
5
5
  "url": "",
6
- "version": "2.0.0",
6
+ "version": "2.1.0",
7
7
  "library": "./library.js",
8
8
  "staticDirs": {
9
9
  "static": "static"
@@ -0,0 +1,133 @@
1
+ define('plugins/nodebb-plugin-calendar-onekite/static/js/admin-planning', ['api', 'alerts'], function (api, alerts) {
2
+ 'use strict';
3
+
4
+ const STATE = { inited:false, fc:null, locations:[], inventory:[], rows:[] };
5
+
6
+ function qs(sel, root=document){ return root.querySelector(sel); }
7
+ function escapeHtml(s){
8
+ return String(s ?? '').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;')
9
+ .replaceAll('"','&quot;').replaceAll("'","&#039;");
10
+ }
11
+
12
+ async function loadInventory() {
13
+ const data = await api.get('/calendar/inventory');
14
+ STATE.locations = Array.isArray(data?.locations) ? data.locations : [];
15
+ STATE.inventory = Array.isArray(data?.inventory) ? data.inventory : [];
16
+ }
17
+
18
+ async function loadPlanningRows() {
19
+ STATE.rows = await api.get('/admin/calendar/planning');
20
+ if (!Array.isArray(STATE.rows)) STATE.rows = [];
21
+ }
22
+
23
+ function fillFilters() {
24
+ const locSel = qs('#planning-filter-location');
25
+ const itemSel = qs('#planning-filter-item');
26
+ if (!locSel || !itemSel) return;
27
+
28
+ locSel.innerHTML = '<option value="">Tous</option>' + STATE.locations.map(l =>
29
+ `<option value="${escapeHtml(l.id)}">${escapeHtml(l.name||l.id)}</option>`
30
+ ).join('');
31
+
32
+ itemSel.innerHTML = '<option value="">Tous</option>' + STATE.inventory.map(i =>
33
+ `<option value="${escapeHtml(i.id)}">${escapeHtml(i.name||i.id)}</option>`
34
+ ).join('');
35
+ }
36
+
37
+ function colorForStatus(status) {
38
+ // don't hardcode colors in code for charts rule? This isn't matplotlib; ok.
39
+ if (status === 'paid') return '#d4edda';
40
+ if (status === 'awaiting_payment') return '#fff3cd';
41
+ if (status === 'pending_admin') return '#cfe2ff';
42
+ return '#e2e3e5';
43
+ }
44
+
45
+ function toFcEvents() {
46
+ const loc = qs('#planning-filter-location')?.value || '';
47
+ const item = qs('#planning-filter-item')?.value || '';
48
+ const status = qs('#planning-filter-status')?.value || '';
49
+
50
+ return STATE.rows
51
+ .filter(r => !loc || r.locationId === loc)
52
+ .filter(r => !item || r.itemId === item)
53
+ .filter(r => !status || r.status === status)
54
+ .map(r => {
55
+ // FullCalendar end is exclusive; add 1 day for all-day style ranges
56
+ const end = new Date(r.dateEnd);
57
+ end.setDate(end.getDate() + 1);
58
+
59
+ return {
60
+ id: String(r.rid),
61
+ title: `${r.itemName} x${r.quantity} — ${r.pickupLocation} — uid ${r.uid}`,
62
+ start: r.dateStart,
63
+ end: end.toISOString().slice(0,10),
64
+ allDay: true,
65
+ backgroundColor: colorForStatus(r.status),
66
+ borderColor: colorForStatus(r.status),
67
+ extendedProps: r,
68
+ };
69
+ });
70
+ }
71
+
72
+ function renderCalendar() {
73
+ const el = qs('#planning-calendar');
74
+ if (!el) return;
75
+ const FC = window.FullCalendar;
76
+ if (!FC) return alerts.error('FullCalendar non chargé');
77
+
78
+ if (STATE.fc) {
79
+ STATE.fc.removeAllEvents();
80
+ STATE.fc.addEventSource(toFcEvents());
81
+ return;
82
+ }
83
+
84
+ STATE.fc = new FC.Calendar(el, {
85
+ initialView: 'timeGridWeek',
86
+ height: 'auto',
87
+ locale: 'fr',
88
+ firstDay: 1,
89
+ headerToolbar: { left: 'prev,next today', center: 'title', right: 'timeGridWeek,dayGridMonth,listWeek' },
90
+ events: toFcEvents(),
91
+ eventClick: (arg) => {
92
+ const r = arg.event.extendedProps || {};
93
+ alerts.success(`RID ${r.rid} — ${r.status}`);
94
+ },
95
+ });
96
+ STATE.fc.render();
97
+ }
98
+
99
+ function bindFilters() {
100
+ ['#planning-filter-location', '#planning-filter-item', '#planning-filter-status'].forEach(sel => {
101
+ qs(sel)?.addEventListener('change', () => renderCalendar());
102
+ });
103
+ }
104
+
105
+ async function initOnce() {
106
+ if (STATE.inited) return;
107
+ if (!qs('#planning-calendar')) return;
108
+ STATE.inited = true;
109
+
110
+ try {
111
+ await Promise.all([loadInventory(), loadPlanningRows()]);
112
+ fillFilters();
113
+ bindFilters();
114
+ renderCalendar();
115
+ } catch (e) {
116
+ alerts.error(e?.message || e);
117
+ }
118
+ }
119
+
120
+ function resetIfLeft() {
121
+ if (!qs('#planning-calendar')) {
122
+ STATE.inited = false;
123
+ STATE.fc = null;
124
+ STATE.rows = [];
125
+ }
126
+ }
127
+
128
+ const obs = new MutationObserver(() => { resetIfLeft(); initOnce(); });
129
+ obs.observe(document.body, { childList:true, subtree:true });
130
+ initOnce();
131
+
132
+ return { init: initOnce };
133
+ });
@@ -13,10 +13,19 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/admin', ['api', 'alerts
13
13
  }
14
14
 
15
15
  async function saveSettings() {
16
+ const locationsJson = qs('#calendar-onekite-locations')?.value || '[]';
17
+ const inventoryJson = qs('#calendar-onekite-inventory')?.value || '[]';
18
+
19
+ // Validate JSON early to avoid saving broken config
20
+ try { JSON.parse(locationsJson || '[]'); } catch { return alerts.error('JSON Lieux invalide'); }
21
+ try { JSON.parse(inventoryJson || '[]'); } catch { return alerts.error('JSON Inventaire invalide'); }
22
+
16
23
  const settings = {
17
24
  allowedGroups: qs('#calendar-onekite-groups')?.value || '',
18
25
  allowedBookingGroups: qs('#calendar-onekite-book-groups')?.value || '',
19
26
  limit: qs('#calendar-onekite-widget-limit')?.value || '',
27
+ locationsJson,
28
+ inventoryJson,
20
29
  helloassoApiBase: qs('#calendar-onekite-helloasso-apibase')?.value || '',
21
30
  helloassoOrganizationSlug: qs('#calendar-onekite-helloasso-org')?.value || '',
22
31
  helloassoFormSlug: qs('#calendar-onekite-helloasso-form')?.value || '',
@@ -8,6 +8,9 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
8
8
  currentEid: null,
9
9
  canCreate: false,
10
10
  canBook: false,
11
+ locations: [],
12
+ inventory: [],
13
+ currentEvent: null,
11
14
  };
12
15
 
13
16
  function qs(sel, root = document) { return root.querySelector(sel); }
@@ -51,6 +54,17 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
51
54
  }
52
55
  }
53
56
 
57
+ async function loadInventory() {
58
+ try {
59
+ const data = await api.get('/calendar/inventory');
60
+ STATE.locations = Array.isArray(data?.locations) ? data.locations : [];
61
+ STATE.inventory = Array.isArray(data?.inventory) ? data.inventory : [];
62
+ } catch (e) {
63
+ STATE.locations = [];
64
+ STATE.inventory = [];
65
+ }
66
+ }
67
+
54
68
  function bindModalClose() {
55
69
  qsa('.calendar-close').forEach(btn => {
56
70
  btn.addEventListener('click', () => hide(btn.closest('.calendar-modal')));
@@ -59,6 +73,7 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
59
73
 
60
74
  function resetEventModal() {
61
75
  STATE.currentEid = null;
76
+ STATE.currentEvent = null;
62
77
  qs('#event-modal-title').textContent = 'Nouvel événement';
63
78
  qs('#event-title').value = '';
64
79
  qs('#event-description').value = '';
@@ -67,49 +82,46 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
67
82
  qs('#event-allDay').checked = false;
68
83
  qs('#event-location').value = '';
69
84
  qs('#event-bookingEnabled').checked = false;
70
- qs('#booking-items').innerHTML = '<p>Aucun matériel.</p>';
85
+
86
+ renderBookingChecklist([]);
71
87
 
72
88
  qs('#event-delete').style.display = 'none';
73
89
  qs('#event-reserve').style.display = STATE.canBook ? 'inline-block' : 'none';
74
90
  }
75
91
 
76
- function renderBookingItems(items) {
92
+ function renderBookingChecklist(selectedIds) {
77
93
  const root = qs('#booking-items');
78
94
  if (!root) return;
79
- if (!Array.isArray(items) || items.length === 0) {
80
- root.innerHTML = '<p>Aucun matériel.</p>';
95
+
96
+ const sel = new Set((selectedIds || []).map(String));
97
+ if (!STATE.inventory.length) {
98
+ root.innerHTML = '<p class="text-muted">Aucun matériel (configurez l’inventaire dans l’admin).</p>';
81
99
  return;
82
100
  }
83
- root.innerHTML = items.map((it, idx) => `
84
- <div class="onekite-item" data-idx="${idx}">
85
- <div class="form-group"><label>Nom</label><input class="form-control js-item-name" value="${escapeHtml(it.name||'')}"></div>
86
- <div class="form-group"><label>ID</label><input class="form-control js-item-id" value="${escapeHtml(it.id||'')}"></div>
87
- <div class="form-group"><label>Total dispo</label><input type="number" class="form-control js-item-total" value="${Number(it.total||0)}" min="0"></div>
88
- <div class="form-group"><label>Prix / jour</label><input type="number" class="form-control js-item-price" value="${Number(it.price||0)}" min="0"></div>
89
- <div class="form-group"><label>Lieu retrait</label><input class="form-control js-item-pickup" value="${escapeHtml(it.pickupLocation||'')}"></div>
90
- <button class="btn btn-sm btn-danger js-item-remove">Supprimer</button>
91
- <hr>
92
- </div>
93
- `).join('');
101
+
102
+ root.innerHTML = STATE.inventory.map(item => {
103
+ const checked = sel.has(String(item.id)) ? 'checked' : '';
104
+ return `
105
+ <div class="form-check">
106
+ <label class="form-check-label">
107
+ <input class="form-check-input js-book-item" type="checkbox" value="${escapeHtml(item.id)}" ${checked}>
108
+ ${escapeHtml(item.name || item.id)} ${Number(item.price||0)} €/jour
109
+ </label>
110
+ </div>
111
+ `;
112
+ }).join('');
94
113
  }
95
114
 
96
- function readBookingItemsFromModal() {
97
- const blocks = qsa('#booking-items .onekite-item');
98
- const items = [];
99
- for (const b of blocks) {
100
- const name = b.querySelector('.js-item-name')?.value?.trim() || '';
101
- const id = b.querySelector('.js-item-id')?.value?.trim() || '';
102
- const total = Number(b.querySelector('.js-item-total')?.value || 0);
103
- const price = Number(b.querySelector('.js-item-price')?.value || 0);
104
- const pickupLocation = b.querySelector('.js-item-pickup')?.value?.trim() || '';
105
- if (!id) continue;
106
- items.push({ id, name, total, price, pickupLocation });
107
- }
108
- return items;
115
+ function readSelectedItemIds() {
116
+ return qsa('.js-book-item')
117
+ .filter(el => el.checked)
118
+ .map(el => String(el.value));
109
119
  }
110
120
 
111
121
  function fillEventModal(ev) {
112
122
  STATE.currentEid = String(ev.eid);
123
+ STATE.currentEvent = ev;
124
+
113
125
  qs('#event-modal-title').textContent = 'Éditer l’événement';
114
126
  qs('#event-title').value = ev.title || '';
115
127
  qs('#event-description').value = ev.description || '';
@@ -119,7 +131,8 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
119
131
  qs('#event-location').value = ev.location || '';
120
132
  qs('#event-bookingEnabled').checked = !!Number(ev.bookingEnabled);
121
133
 
122
- renderBookingItems(ev.bookingItems || []);
134
+ const ids = Array.isArray(ev.bookingItemIds) ? ev.bookingItemIds : (Array.isArray(ev.bookingItems) ? ev.bookingItems.map(x => x.id) : []);
135
+ renderBookingChecklist(ids);
123
136
 
124
137
  qs('#event-delete').style.display = STATE.canCreate ? 'inline-block' : 'none';
125
138
  qs('#event-reserve').style.display = STATE.canBook ? 'inline-block' : 'none';
@@ -154,7 +167,7 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
154
167
  allDay: qs('#event-allDay').checked,
155
168
  location: qs('#event-location').value || '',
156
169
  bookingEnabled: qs('#event-bookingEnabled').checked,
157
- bookingItems: readBookingItemsFromModal(),
170
+ bookingItemIds: readSelectedItemIds(),
158
171
  visibility: 'public',
159
172
  };
160
173
 
@@ -184,107 +197,70 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
184
197
  hide(qs('#calendar-event-modal'));
185
198
  STATE.fc?.refetchEvents();
186
199
  } catch (e) {
187
- // Some NodeBB api wrappers don't have del(); fallback to fetch
188
- try {
189
- const csrf = window.config?.csrf_token || window.config?.csrfToken;
190
- const res = await fetch(`/api/v3/calendar/event/${encodeURIComponent(STATE.currentEid)}`, {
191
- method: 'DELETE',
192
- headers: csrf ? { 'x-csrf-token': csrf } : {},
193
- credentials: 'same-origin',
194
- });
195
- if (!res.ok) throw new Error(await res.text());
196
- alerts.success('Événement supprimé');
197
- hide(qs('#calendar-event-modal'));
198
- STATE.fc?.refetchEvents();
199
- } catch (e2) {
200
- alerts.error(e2?.message || e2);
201
- }
200
+ alerts.error(e?.message || e);
202
201
  }
203
202
  }
204
203
 
205
- function addBookingItemRow() {
206
- const root = qs('#booking-items');
207
- if (!root) return;
208
- if (root.textContent.includes('Aucun matériel')) root.innerHTML = '';
209
-
210
- root.insertAdjacentHTML('beforeend', `
211
- <div class="onekite-item">
212
- <div class="form-group"><label>Nom</label><input class="form-control js-item-name" value=""></div>
213
- <div class="form-group"><label>ID</label><input class="form-control js-item-id" value=""></div>
214
- <div class="form-group"><label>Total dispo</label><input type="number" class="form-control js-item-total" value="0" min="0"></div>
215
- <div class="form-group"><label>Prix / jour</label><input type="number" class="form-control js-item-price" value="0" min="0"></div>
216
- <div class="form-group"><label>Lieu retrait</label><input class="form-control js-item-pickup" value=""></div>
217
- <button class="btn btn-sm btn-danger js-item-remove">Supprimer</button>
218
- <hr>
219
- </div>
220
- `);
221
- }
204
+ /* ---------- Reservation UI ---------- */
222
205
 
223
- function bindEventModalButtons() {
224
- qs('#event-save')?.addEventListener('click', saveEvent);
225
- qs('#event-delete')?.addEventListener('click', deleteEvent);
226
- qs('#event-add-item')?.addEventListener('click', addBookingItemRow);
206
+ function renderLocationSelect(selected) {
207
+ const sel = qs('#reserve-location');
208
+ if (!sel) return;
209
+ const opts = STATE.locations.map(l => `<option value="${escapeHtml(l.id)}">${escapeHtml(l.name||l.id)}</option>`).join('');
210
+ sel.innerHTML = opts || '<option value="">(aucun lieu)</option>';
211
+ if (selected) sel.value = selected;
212
+ }
227
213
 
228
- qs('#booking-items')?.addEventListener('click', (ev) => {
229
- const t = ev.target;
230
- if (!(t instanceof HTMLElement)) return;
231
- if (t.classList.contains('js-item-remove')) {
232
- t.closest('.onekite-item')?.remove();
233
- }
234
- });
214
+ function openReserveModal(event, prefillDates) {
215
+ qs('#reserve-title').textContent = `Réserver – ${event.title || ''}`;
235
216
 
236
- // Reservation flow: open reserve modal and fill items
237
- qs('#event-reserve')?.addEventListener('click', async () => {
238
- if (!STATE.canBook) return alerts.error('Vous n’êtes pas autorisé à réserver.');
239
- if (!STATE.currentEid) return;
217
+ renderLocationSelect(STATE.locations[0]?.id || '');
240
218
 
241
- try {
242
- const ev = await api.get(`/calendar/event/${encodeURIComponent(STATE.currentEid)}`);
243
- openReserveModal(ev);
244
- } catch (e) {
245
- alerts.error(e?.message || e);
246
- }
247
- });
248
- }
219
+ const allowed = new Set((event.bookingItemIds || []).map(String));
220
+ const items = STATE.inventory.filter(i => allowed.has(String(i.id)));
249
221
 
250
- function openReserveModal(event) {
251
- // Fill title
252
- qs('#reserve-title').textContent = `Réserver – ${event.title || ''}`;
253
- const items = Array.isArray(event.bookingItems) ? event.bookingItems : [];
254
222
  const reserveItems = qs('#reserve-items');
255
223
  reserveItems.innerHTML = items.length ? items.map(it => `
256
224
  <div class="form-check">
257
225
  <label class="form-check-label">
258
226
  <input class="form-check-input js-reserve-item" type="radio" name="reserveItem" value="${escapeHtml(it.id)}">
259
- ${escapeHtml(it.name || it.id)} (dispo: ${Number(it.total||0)}, ${Number(it.price||0)} €/jour) — retrait: ${escapeHtml(it.pickupLocation||'')}
227
+ ${escapeHtml(it.name || it.id)} (${Number(it.price||0)} €/jour)
260
228
  </label>
261
229
  </div>
262
- `).join('') : '<p>Aucun matériel réservable.</p>';
230
+ `).join('') : '<p>Aucun matériel réservable pour cet événement.</p>';
263
231
 
264
- // Pre-fill dates from event
265
- const start = new Date(event.start);
266
- const end = new Date(event.end);
267
232
  const toDateInput = (d) => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
268
- qs('#reserve-start').value = toDateInput(start);
269
- qs('#reserve-end').value = toDateInput(end);
233
+
234
+ if (prefillDates?.dateStart && prefillDates?.dateEnd) {
235
+ qs('#reserve-start').value = prefillDates.dateStart;
236
+ qs('#reserve-end').value = prefillDates.dateEnd;
237
+ } else {
238
+ const start = new Date(event.start);
239
+ const end = new Date(event.end);
240
+ qs('#reserve-start').value = toDateInput(start);
241
+ qs('#reserve-end').value = toDateInput(end);
242
+ }
270
243
 
271
244
  show(qs('#calendar-reserve-modal'));
272
245
  }
273
246
 
274
247
  async function submitReservationFromModal() {
275
248
  const itemId = qs('input[name="reserveItem"]:checked')?.value;
249
+ const locationId = qs('#reserve-location')?.value;
276
250
  const dateStart = qs('#reserve-start')?.value;
277
251
  const dateEnd = qs('#reserve-end')?.value;
278
252
  const quantity = Number(qs('#reserve-quantity')?.value || 1);
279
253
 
280
254
  if (!STATE.currentEid) return alerts.error('Aucun événement sélectionné.');
281
255
  if (!itemId) return alerts.error('Choisissez un matériel.');
256
+ if (!locationId) return alerts.error('Choisissez un lieu.');
282
257
  if (!dateStart || !dateEnd) return alerts.error('Choisissez des dates.');
283
258
  if (quantity <= 0) return alerts.error('Quantité invalide.');
284
259
 
285
260
  try {
286
261
  const res = await api.post(`/calendar/event/${encodeURIComponent(STATE.currentEid)}/book`, {
287
262
  itemId,
263
+ locationId,
288
264
  quantity,
289
265
  dateStart,
290
266
  dateEnd,
@@ -292,16 +268,47 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
292
268
  alerts.success(res?.message || 'Demande envoyée.');
293
269
  hide(qs('#calendar-reserve-modal'));
294
270
  hide(qs('#calendar-event-modal'));
295
- STATE.fc?.refetchEvents();
296
271
  } catch (e) {
297
272
  alerts.error(e?.message || e);
298
273
  }
299
274
  }
300
275
 
276
+ function bindEventModalButtons() {
277
+ qs('#event-save')?.addEventListener('click', saveEvent);
278
+ qs('#event-delete')?.addEventListener('click', deleteEvent);
279
+
280
+ // Reservation flow: open reserve modal from event
281
+ qs('#event-reserve')?.addEventListener('click', async () => {
282
+ if (!STATE.canBook) return alerts.error('Vous n’êtes pas autorisé à réserver.');
283
+ if (!STATE.currentEid) return;
284
+
285
+ try {
286
+ const ev = await api.get(`/calendar/event/${encodeURIComponent(STATE.currentEid)}`);
287
+ openReserveModal(ev);
288
+ } catch (e) {
289
+ alerts.error(e?.message || e);
290
+ }
291
+ });
292
+ }
293
+
301
294
  function bindReserveModalButtons() {
295
+ // Add location select if missing (back-compat)
296
+ if (!qs('#reserve-location')) {
297
+ const itemsRoot = qs('#reserve-items');
298
+ if (itemsRoot) {
299
+ itemsRoot.insertAdjacentHTML('beforebegin', `
300
+ <div class="form-group">
301
+ <label>Lieu de retrait</label>
302
+ <select id="reserve-location" class="form-control"></select>
303
+ </div>
304
+ `);
305
+ }
306
+ }
302
307
  qs('#reserve-confirm')?.addEventListener('click', submitReservationFromModal);
303
308
  }
304
309
 
310
+ /* ---------- FullCalendar ---------- */
311
+
305
312
  function buildCalendar() {
306
313
  const el = qs('#calendar');
307
314
  if (!el) return;
@@ -319,7 +326,7 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
319
326
  center: 'title',
320
327
  right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek',
321
328
  },
322
- selectable: STATE.canCreate,
329
+ selectable: (STATE.canCreate || STATE.canBook),
323
330
  selectMirror: true,
324
331
  editable: STATE.canCreate,
325
332
  eventDurationEditable: STATE.canCreate,
@@ -343,8 +350,22 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
343
350
  }
344
351
  },
345
352
 
346
- select: (sel) => {
347
- openEventModalNew({ start: sel.startStr, end: sel.endStr });
353
+ // C mode: selection can create OR open reservation helper
354
+ select: async (sel) => {
355
+ const dateStart = sel.startStr.slice(0,10);
356
+ // FullCalendar end is exclusive; show end-1 day for date inputs
357
+ const endDate = new Date(sel.end);
358
+ endDate.setDate(endDate.getDate() - 1);
359
+ const dateEnd = `${endDate.getFullYear()}-${pad(endDate.getMonth()+1)}-${pad(endDate.getDate())}`;
360
+
361
+ if (STATE.canCreate) {
362
+ openEventModalNew({ start: sel.startStr, end: sel.endStr });
363
+ return;
364
+ }
365
+ if (STATE.canBook) {
366
+ alerts.info('Cliquez sur un événement, puis "Réserver" pour lier la réservation à un événement.');
367
+ return;
368
+ }
348
369
  },
349
370
 
350
371
  eventClick: (arg) => {
@@ -400,11 +421,14 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
400
421
  bindEventModalButtons();
401
422
  bindReserveModalButtons();
402
423
 
403
- loadPermissions().then(() => {
404
- const btn = qs('#calendar-new-event');
405
- if (btn) btn.style.display = STATE.canCreate ? 'inline-block' : 'none';
406
- buildCalendar();
407
- });
424
+ Promise.all([loadPermissions(), loadInventory()])
425
+ .then(() => {
426
+ const btn = qs('#calendar-new-event');
427
+ if (btn) btn.style.display = STATE.canCreate ? 'inline-block' : 'none';
428
+ // default inventory checklist for new event
429
+ renderBookingChecklist([]);
430
+ buildCalendar();
431
+ });
408
432
  }
409
433
 
410
434
  function resetIfLeft() {
@@ -412,6 +436,7 @@ define('plugins/nodebb-plugin-calendar-onekite/static/js/calendar', ['api', 'ale
412
436
  STATE.inited = false;
413
437
  STATE.fc = null;
414
438
  STATE.currentEid = null;
439
+ STATE.currentEvent = null;
415
440
  }
416
441
  }
417
442
 
@@ -1,25 +1,35 @@
1
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css">
2
+ <link rel="stylesheet" href="/plugins/nodebb-plugin-calendar-onekite/static/css/calendar-onekite.css">
3
+
1
4
  <div id="calendar-planning">
2
- <h2>Planning des réservations</h2>
3
- <table class="table">
4
- <thead>
5
- <tr>
6
- <th>Événement</th>
7
- <th>Matériel</th>
8
- <th>Lieu retrait</th>
9
- <th>UID</th>
10
- <th>Quantité</th>
11
- <th>Début</th>
12
- <th>Fin</th>
13
- <th>Jours</th>
14
- <th>Statut</th>
15
- </tr>
16
- </thead>
17
- <tbody id="planning-body">
18
- <tr><td colspan="9">Chargement…</td></tr>
19
- </tbody>
20
- </table>
5
+ <h2>Planning (graphique)</h2>
6
+
7
+ <div class="row mb-3">
8
+ <div class="col-md-4">
9
+ <label>Lieu</label>
10
+ <select id="planning-filter-location" class="form-control"></select>
11
+ </div>
12
+ <div class="col-md-4">
13
+ <label>Matériel</label>
14
+ <select id="planning-filter-item" class="form-control"></select>
15
+ </div>
16
+ <div class="col-md-4">
17
+ <label>Statut</label>
18
+ <select id="planning-filter-status" class="form-control">
19
+ <option value="">Tous</option>
20
+ <option value="pending_admin">pending_admin</option>
21
+ <option value="awaiting_payment">awaiting_payment</option>
22
+ <option value="paid">paid</option>
23
+ </select>
24
+ </div>
25
+ </div>
26
+
27
+ <div id="planning-calendar"></div>
21
28
  </div>
22
29
 
30
+ <script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
23
31
  <script>
24
- require(['plugins/nodebb-plugin-calendar-onekite/static/js/admin'], function () {});
32
+ require(['plugins/nodebb-plugin-calendar-onekite/static/js/admin-planning'], function (Mod) {
33
+ Mod.init && Mod.init();
34
+ });
25
35
  </script>
@@ -25,6 +25,24 @@
25
25
  <input id="calendar-onekite-widget-limit" type="number" class="form-control" value="{settings.limit}">
26
26
  </div>
27
27
 
28
+ <hr>
29
+ <h3>Lieux & Inventaire (global)</h3>
30
+ <p class="text-muted">
31
+ Les stocks sont globaux et séparés par lieu. Format JSON.
32
+ </p>
33
+
34
+ <div class="form-group">
35
+ <label>Lieux (locations)</label>
36
+ <textarea id="calendar-onekite-locations" class="form-control" rows="4">{settings.locationsJson}</textarea>
37
+ <small>Ex: [{"id":"arnaud","name":"Chez Arnaud"},{"id":"siege","name":"Siège Onekite"}]</small>
38
+ </div>
39
+
40
+ <div class="form-group">
41
+ <label>Inventaire (inventory)</label>
42
+ <textarea id="calendar-onekite-inventory" class="form-control" rows="6">{settings.inventoryJson}</textarea>
43
+ <small>Ex: [{"id":"wing","name":"Aile Wing","price":5,"stockByLocation":{"arnaud":1,"siege":0}}]</small>
44
+ </div>
45
+
28
46
  <hr>
29
47
  <h3>HelloAsso</h3>
30
48
  <div class="form-group">
@@ -52,8 +52,11 @@
52
52
  <label><input type="checkbox" id="event-bookingEnabled"> Activer les réservations</label>
53
53
  </div>
54
54
 
55
- <div id="booking-items"></div>
56
- <button id="event-add-item" class="btn btn-secondary btn-sm">+ Ajouter un matériel</button>
55
+ <div class="form-group">
56
+ <label>Matériel autorisé pour cet événement</label>
57
+ <div id="booking-items"></div>
58
+ <small class="text-muted">Le stock est global par lieu (configuré dans l’admin du plugin).</small>
59
+ </div>
57
60
 
58
61
  <div style="margin-top: 20px;">
59
62
  <button id="event-save" class="btn btn-primary">Enregistrer</button>
@@ -70,6 +73,11 @@
70
73
  <h3 id="reserve-title"></h3>
71
74
  <div id="reserve-items"></div>
72
75
 
76
+ <div class="form-group">
77
+ <label>Lieu de retrait</label>
78
+ <select id="reserve-location" class="form-control"></select>
79
+ </div>
80
+
73
81
  <div class="form-group">
74
82
  <label>Date de début</label>
75
83
  <input type="date" id="reserve-start" class="form-control">