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 +7 -0
- package/library.js +230 -148
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/static/js/admin-planning.js +133 -0
- package/static/js/admin.js +9 -0
- package/static/js/calendar.js +130 -105
- package/templates/admin/calendar-planning.tpl +30 -20
- package/templates/admin/plugins/calendar-onekite.tpl +18 -0
- package/templates/calendar.tpl +10 -2
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
|
-
|
|
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(
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
|
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)
|
|
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(
|
|
218
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
settings
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
278
|
-
|
|
279
|
-
let targetEvent = null;
|
|
280
|
-
let reservation = null;
|
|
327
|
+
const all = await getAllReservations();
|
|
281
328
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
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
|
-
|
|
299
|
-
const
|
|
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
|
-
|
|
302
|
-
await db.setObject(getEventKey(
|
|
345
|
+
event.reservations = JSON.stringify(resList);
|
|
346
|
+
await db.setObject(getEventKey(event.eid), event);
|
|
303
347
|
|
|
304
|
-
const
|
|
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:
|
|
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.
|
|
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(
|
|
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
|
|
368
|
-
const
|
|
369
|
-
const
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
if (!event) continue;
|
|
416
|
+
const all = await getAllReservations();
|
|
417
|
+
const rows = [];
|
|
374
418
|
|
|
375
|
-
|
|
376
|
-
const
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
449
|
+
/* ---------------- Public APIs ---------------- */
|
|
450
|
+
|
|
451
|
+
async function inventoryApi(req, res) {
|
|
406
452
|
try {
|
|
407
|
-
await
|
|
408
|
-
res.json({
|
|
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
|
-
|
|
439
|
-
|
|
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: '
|
|
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
|
|
471
|
-
|
|
472
|
-
if (!
|
|
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
|
|
475
|
-
|
|
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
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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 =
|
|
497
|
-
if (q > available) return res.status(400).json({ error:
|
|
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
|
-
|
|
519
|
-
|
|
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:
|
|
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.
|
|
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
|
-
/* ----------------
|
|
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.
|
|
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,
|
|
648
|
-
router.get('/calendar/my-reservations', middleware.buildHeader,
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
//
|
|
666
|
-
router
|
|
667
|
-
router.get('/api/v3/admin/calendar/planning/page', renderPlanningPage);
|
|
733
|
+
// Public inventory
|
|
734
|
+
mountApiBoth(router, 'get', '/calendar/inventory', inventoryApi);
|
|
668
735
|
|
|
669
|
-
//
|
|
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
|
-
|
|
721
|
-
const
|
|
722
|
-
|
|
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
|
|
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
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.
|
|
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('&','&').replaceAll('<','<').replaceAll('>','>')
|
|
9
|
+
.replaceAll('"','"').replaceAll("'","'");
|
|
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
|
+
});
|
package/static/js/admin.js
CHANGED
|
@@ -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 || '',
|
package/static/js/calendar.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
92
|
+
function renderBookingChecklist(selectedIds) {
|
|
77
93
|
const root = qs('#booking-items');
|
|
78
94
|
if (!root) return;
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<div class="form-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
224
|
-
qs('#
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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)} (
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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()
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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">
|
package/templates/calendar.tpl
CHANGED
|
@@ -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
|
|
56
|
-
|
|
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">
|