nodebb-plugin-equipment-calendar 5.0.0 → 6.0.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/library.js CHANGED
@@ -35,12 +35,6 @@ const crypto = require('crypto');
35
35
  const plugin = {};
36
36
  const SETTINGS_KEY = 'equipmentCalendar';
37
37
 
38
- const EC_KEYS = {
39
- reservationsZset: 'ec:reservations',
40
- reservationKey: (id) => `ec:reservation:${id}`,
41
- itemZset: (itemId) => `ec:item:${itemId}`,
42
- };
43
-
44
38
  const DEFAULT_SETTINGS = {
45
39
  creatorGroups: 'registered-users',
46
40
  approverGroup: 'administrators',
@@ -262,12 +256,21 @@ async function fetchHelloAssoItems(settings) {
262
256
 
263
257
  function pushItem(it, tierName) {
264
258
  if (!it) return;
265
- const id = String(it.id || it.itemId || it.reference || it.slug || it.code || it.name || '').trim();
266
- const name = String(it.name || it.label || it.title || '').trim() || (tierName ? String(tierName) : id);
267
- if (!id || !name) return;
268
- const amount = it.amount || it.price || it.unitPrice || it.totalAmount || it.initialAmount;
269
- const priceCents = (typeof amount === 'number' ? amount : parseInt(amount, 10)) || 0;
270
- out.push({ id, name, priceCents: String(priceCents), location: '' });
259
+ const id = String(it.id || it.itemId || it.productId || it.code || it.slug || '').trim();
260
+ const baseName = String(it.name || it.label || it.title || '').trim();
261
+ const name = (tierName && baseName) ? `${tierName} — ${baseName}` : (baseName || tierName || id);
262
+ let amount = it.amount ?? it.price ?? it.unitPrice ?? it.totalAmount ?? it.initialAmount ?? it.minimumAmount;
263
+ if (amount && typeof amount === 'object') {
264
+ amount = amount.amount ?? amount.value ?? amount.total ?? amount.price ?? amount.cents ?? amount.centAmount ?? 0;
265
+ }
266
+ let price = 0;
267
+ if (typeof amount === 'number') price = amount;
268
+ else if (typeof amount === 'string') {
269
+ const s = amount.replace(',', '.').replace(/[^0-9.\-]/g, '');
270
+ price = parseFloat(s) || 0;
271
+ }
272
+ if (!id) return;
273
+ out.push({ id, name, price, location: '' });
271
274
  }
272
275
 
273
276
  // Try a few known layouts
@@ -304,7 +307,7 @@ async function getActiveItems() {
304
307
  const items = (Array.isArray(rawItems) ? rawItems : []).map((it) => ({
305
308
  id: String(it.id || '').trim(),
306
309
  name: String(it.name || '').trim(),
307
- price: Number(it.price || 0) || 0,
310
+ price: Number((it.price !== undefined ? it.price : it.priceCents) || 0) || 0,
308
311
  active: true,
309
312
  })).filter(it => it.id && it.name);
310
313
  return items;
@@ -354,6 +357,8 @@ async function getBookingRids(bookingId) {
354
357
  async function saveReservation(res) {
355
358
  await db.setObject(resKey(res.id), res);
356
359
  await db.sortedSetAdd(itemIndexKey(res.itemId), res.startMs, res.id);
360
+ // Global index for ACP listing
361
+ await db.sortedSetAdd('equipmentCalendar:reservations', res.startMs, res.id);
357
362
  }
358
363
 
359
364
  async function getReservation(id) {
@@ -546,28 +551,22 @@ function verifyWebhook(req, secret) {
546
551
 
547
552
  // --- Rendering helpers ---
548
553
  function toEvent(res, item, requesterName, canSeeRequester) {
549
- const start = DateTime.fromMillis(res.startMs).toISO();
550
- const end = DateTime.fromMillis(res.endMs).toISO();
551
-
552
- let icon = '⏳';
554
+ const start = DateTime.fromMillis(res.startMs).toISODate();
555
+ const end = DateTime.fromMillis(res.endMs).toISODate();
553
556
  let className = 'ec-status-pending';
554
- if (res.status === 'approved_waiting_payment') { icon = '💳'; className = 'ec-status-awaitpay'; }
555
- if (res.status === 'paid_validated') { icon = ''; className = 'ec-status-valid'; }
556
- if (res.status === 'rejected' || res.status === 'cancelled') { icon = '❌'; className = 'ec-status-cancel'; }
557
-
558
- const titleParts = [icon, item ? item.name : res.itemId];
557
+ if (res.status === 'approved_waiting_payment' || res.status === 'approved') { className = 'ec-status-approved'; }
558
+ if (res.status === 'paid_validated' || res.status === 'paid') { className = 'ec-status-paid'; }
559
+ if (res.status === 'rejected' || res.status === 'cancelled') { className = 'ec-status-cancelled'; }
560
+ const titleParts = [item ? item.name : res.itemId];
559
561
  if (canSeeRequester && requesterName) titleParts.push(`- ${requesterName}`);
560
562
  return {
561
563
  id: res.id,
562
564
  title: titleParts.join(' '),
563
565
  start,
564
566
  end,
565
- allDay: false,
566
- className,
567
- extendedProps: {
568
- status: res.status,
569
- itemId: res.itemId,
570
- },
567
+ allDay: true,
568
+ classNames: [className],
569
+ extendedProps: { status: res.status, itemId: res.itemId },
571
570
  };
572
571
  }
573
572
 
@@ -653,9 +652,6 @@ plugin.init = async function (params) {
653
652
 
654
653
  // Page routes are attached via filter:router.page as well, but we also add directly to be safe:
655
654
  router.get('/equipment/calendar', middleware.buildHeader, renderCalendarPage);
656
- router.get('/calendar', middleware.buildHeader, renderCalendarPage);
657
- router.get('/api/calendar', middleware.buildHeader, renderCalendarPage);
658
-
659
655
  router.get('/equipment/approvals', middleware.buildHeader, renderApprovalsPage);
660
656
 
661
657
  router.post('/equipment/reservations/create', middleware.applyCSRF, handleCreateReservation);
@@ -690,6 +686,91 @@ plugin.addAdminRoutes = async function (params) {
690
686
  };
691
687
 
692
688
 
689
+ async function renderAdminReservationsPage(req, res) {
690
+ if (!(await ensureIsAdmin(req, res))) return;
691
+
692
+ const settings = await getSettings();
693
+ const items = await getActiveItems(settings);
694
+ const itemById = {};
695
+ items.forEach(it => { itemById[it.id] = it; });
696
+
697
+ const status = String(req.query.status || ''); // optional filter
698
+ const itemId = String(req.query.itemId || ''); // optional filter
699
+ const q = String(req.query.q || '').trim(); // search rid/user/notes
700
+
701
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
702
+ const perPage = Math.min(100, Math.max(10, parseInt(req.query.perPage, 10) || 50));
703
+
704
+ const allRids = await db.getSortedSetRevRange('equipmentCalendar:reservations', 0, -1);
705
+ const totalAll = allRids.length;
706
+
707
+ const rows = [];
708
+ for (const rid of allRids) {
709
+ // eslint-disable-next-line no-await-in-loop
710
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
711
+ if (!r || !r.rid) continue;
712
+
713
+ if (status && String(r.status) !== status) continue;
714
+ if (itemId && String(r.itemId) !== itemId) continue;
715
+
716
+ const notes = String(r.notesUser || '');
717
+ const ridStr = String(r.rid || rid);
718
+ if (q) {
719
+ const hay = (ridStr + ' ' + String(r.uid || '') + ' ' + notes).toLowerCase();
720
+ if (!hay.includes(q.toLowerCase())) continue;
721
+ }
722
+
723
+ const startMs = parseInt(r.startMs, 10) || 0;
724
+ const endMs = parseInt(r.endMs, 10) || 0;
725
+ const createdAt = parseInt(r.createdAt, 10) || 0;
726
+
727
+ rows.push({
728
+ rid: ridStr,
729
+ itemId: String(r.itemId || ''),
730
+ itemName: (itemById[String(r.itemId || '')] && itemById[String(r.itemId || '')].name) || String(r.itemId || ''),
731
+ uid: String(r.uid || ''),
732
+ status: String(r.status || ''),
733
+ start: startMs ? new Date(startMs).toISOString() : '',
734
+ end: endMs ? new Date(endMs).toISOString() : '',
735
+ createdAt: createdAt ? new Date(createdAt).toISOString() : '',
736
+ notesUser: notes,
737
+ });
738
+ }
739
+
740
+ const total = rows.length;
741
+ const totalPages = Math.max(1, Math.ceil(total / perPage));
742
+ const safePage = Math.min(page, totalPages);
743
+ const startIndex = (safePage - 1) * perPage;
744
+ const pageRows = rows.slice(startIndex, startIndex + perPage);
745
+
746
+ const itemOptions = [{ id: '', name: 'Tous' }].concat(items.map(i => ({ id: i.id, name: i.name })));
747
+ const statusOptions = [
748
+ { id: '', name: 'Tous' },
749
+ { id: 'pending', name: 'pending' },
750
+ { id: 'approved', name: 'approved' },
751
+ { id: 'paid', name: 'paid' },
752
+ { id: 'rejected', name: 'rejected' },
753
+ { id: 'cancelled', name: 'cancelled' },
754
+ ];
755
+
756
+ res.render('admin/plugins/equipment-calendar-reservations', {
757
+ title: 'Equipment Calendar - Réservations',
758
+ settings,
759
+ rows: pageRows,
760
+ hasRows: pageRows.length > 0,
761
+ itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
762
+ statusOptions: statusOptions.map(o => ({ ...o, selected: o.id === status })),
763
+ q,
764
+ page: safePage,
765
+ perPage,
766
+ total,
767
+ totalAll,
768
+ totalPages,
769
+ prevPage: safePage > 1 ? safePage - 1 : 0,
770
+ nextPage: safePage < totalPages ? safePage + 1 : 0,
771
+ actionBase: '/admin/plugins/equipment-calendar/reservations',
772
+ });
773
+ }
693
774
 
694
775
  async function handleAdminApprove(req, res) {
695
776
  if (!(await ensureIsAdmin(req, res))) return;
@@ -849,6 +930,86 @@ async function handleHelloAssoCallback(req, res) {
849
930
  });
850
931
  }
851
932
 
933
+ async function renderCalendarPage(req, res) {
934
+ const settings = await getSettings();
935
+ const items = await getActiveItems(settings);
936
+
937
+ const tz = settings.timezone || 'Europe/Paris';
938
+
939
+ // Determine range to render
940
+ const now = DateTime.now().setZone(tz);
941
+ const view = String(req.query.view || settings.defaultView || 'dayGridMonth');
942
+ const startQ = req.query.start;
943
+ const endQ = req.query.end;
944
+
945
+ let start, end;
946
+ if (startQ && endQ) {
947
+ const r = clampRange(String(startQ), String(endQ), tz);
948
+ start = r.start;
949
+ end = r.end;
950
+ } else {
951
+ // Default to current month range
952
+ start = now.startOf('month');
953
+ end = now.endOf('month').plus({ days: 1 });
954
+ }
955
+
956
+ // Load reservations for ALL items within range (so we can build availability client-side without extra requests)
957
+ const allReservations = [];
958
+ for (const it of items) {
959
+ // eslint-disable-next-line no-await-in-loop
960
+ const resForItem = await listReservationsForRange(it.id, start.toMillis(), end.toMillis());
961
+ for (const r of resForItem) allReservations.push(r);
962
+ }
963
+
964
+ const showRequesterToAll = String(settings.showRequesterToAll) === '1';
965
+ const isApprover = req.uid ? await canApprove(req.uid, settings) : false;
966
+
967
+ const requesterUids = Array.from(new Set(allReservations.map(r => r.uid))).filter(Boolean);
968
+ const users = requesterUids.length ? await user.getUsersData(requesterUids) : [];
969
+ const nameByUid = {};
970
+ (users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
971
+
972
+ const itemById = {};
973
+ items.forEach(it => { itemById[it.id] = it; });
974
+
975
+ const events = allReservations
976
+ .filter(r => r.status !== 'rejected' && r.status !== 'cancelled')
977
+ .map(r => {
978
+ const item = itemById[r.itemId];
979
+ // Include item name in title so "all items" view is readable
980
+ const requesterName = nameByUid[r.uid] || '';
981
+ return toEvent(r, item, requesterName, isApprover || showRequesterToAll);
982
+ });
983
+
984
+ const canUserCreate = req.uid ? await canCreate(req.uid, settings) : false;
985
+
986
+ // We expose minimal reservation data for availability checks in the modal
987
+ const blocks = allReservations
988
+ .filter(r => statusBlocksItem(r.status))
989
+ .map(r => ({
990
+ itemId: r.itemId,
991
+ startMs: r.startMs,
992
+ endMs: r.endMs,
993
+ status: r.status,
994
+ }));
995
+
996
+ res.render('equipment-calendar/calendar', {
997
+ title: 'Réservation de matériel',
998
+ items,
999
+ view,
1000
+ tz,
1001
+ startISO: start.toISO(),
1002
+ endISO: end.toISO(),
1003
+ initialDateISO: start.toISODate(),
1004
+ canCreate: canUserCreate,
1005
+ canCreateJs: canUserCreate ? 'true' : 'false',
1006
+ isApprover,
1007
+ // events are base64 encoded to avoid template escaping issues
1008
+ eventsB64: Buffer.from(JSON.stringify(events), 'utf8').toString('base64'),
1009
+ blocksB64: Buffer.from(JSON.stringify(blocks), 'utf8').toString('base64'),
1010
+ itemsB64: Buffer.from(JSON.stringify(items.map(i => ({ id: i.id, name: i.name, location: i.location }))), 'utf8').toString('base64'),
1011
+ });
1012
+ }
852
1013
 
853
1014
 
854
1015
  // --- Approvals page ---
@@ -1009,70 +1170,52 @@ async function createReservationForItem(req, res, settings, itemId, startMs, end
1009
1170
 
1010
1171
  async function handleCreateReservation(req, res) {
1011
1172
  try {
1012
- if (!req.uid) return res.status(403).json({ error: 'not-logged-in' });
1013
-
1014
1173
  const settings = await getSettings();
1015
- const canCreate = await isUserInAnyGroups(req.uid, settings.creatorGroups);
1016
- if (!canCreate) return res.status(403).json({ error: 'forbidden' });
1174
+ if (!req.uid || !(await canCreate(req.uid, settings))) {
1175
+ return helpers.notAllowed(req, res);
1176
+ }
1017
1177
 
1018
- const notesUser = String(req.body.notesUser || '').trim();
1019
- const itemIdsRaw = req.body.itemIds || req.body.itemId || [];
1020
- const itemIds = Array.isArray(itemIdsRaw) ? itemIdsRaw : String(itemIdsRaw || '').split(',').filter(Boolean);
1021
-
1022
- const startMsRaw = parseInt(req.body.startMs, 10) || 0;
1023
- const endMsRaw = parseInt(req.body.endMs, 10) || 0;
1024
-
1025
- const startDate = startMsRaw ? new Date(startMsRaw) : null;
1026
- const endDate = endMsRaw ? new Date(endMsRaw) : null;
1027
- const startMs = startDate ? Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate()) : 0;
1028
- let endMs = 0;
1029
- if (endDate) {
1030
- endMs = Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate());
1031
- if (endMs <= startMs) endMs = startMs + 24 * 60 * 60 * 1000;
1178
+ const itemIdsRaw = req.body.itemIds || req.body.itemId || '';
1179
+ const itemIds = (Array.isArray(itemIdsRaw) ? itemIdsRaw : String(itemIdsRaw))
1180
+ .split(',')
1181
+ .map(s => String(s).trim())
1182
+ .filter(Boolean);
1183
+
1184
+ if (!itemIds.length) {
1185
+ return res.status(400).send('itemIds required');
1186
+ }
1187
+
1188
+ const tz = settings.timezone || 'Europe/Paris';
1189
+ const start = DateTime.fromISO(String(req.body.start || ''), { zone: tz });
1190
+ const end = DateTime.fromISO(String(req.body.end || ''), { zone: tz });
1191
+ if (!start.isValid || !end.isValid) return res.status(400).send('Invalid dates');
1192
+
1193
+ // Normalize to whole days (no hours). End is exclusive.
1194
+ const startDay = start.startOf('day');
1195
+ let endDay = end.startOf('day');
1196
+ if (endDay <= startDay) {
1197
+ endDay = startDay.plus({ days: 1 });
1032
1198
  }
1033
- if (!startMs || !endMs) return res.status(400).json({ error: 'invalid-dates' });
1034
-
1035
- const days = Math.max(1, Math.round((endMs - startMs) / (24 * 60 * 60 * 1000)));
1036
-
1037
- const items = await getActiveItems();
1038
- const byId = {};
1039
- items.forEach((it) => { byId[String(it.id)] = it; });
1040
-
1041
- const valid = itemIds.map(String).filter((id) => byId[id]);
1042
- if (!valid.length) return res.status(400).json({ error: 'unknown-item' });
1043
-
1044
- const bookingId = generateId();
1045
- for (const itemId of valid) {
1046
- const it = byId[itemId];
1047
- const id = generateId();
1048
- const unitPrice = Number(it.price || 0) || 0;
1049
- const total = unitPrice * days;
1050
-
1051
- const reservation = {
1052
- id,
1053
- bookingId,
1054
- uid: req.uid,
1055
- itemId,
1056
- itemName: it.name,
1057
- unitPrice,
1058
- days,
1059
- total,
1060
- notesUser,
1061
- startMs,
1062
- endMs,
1063
- status: 'pending',
1064
- createdAt: Date.now(),
1065
- };
1066
-
1067
- await db.setObject(EC_KEYS.reservationKey(id), reservation);
1068
- await db.sortedSetAdd(EC_KEYS.reservationsZset, startMs, id);
1069
- await db.sortedSetAdd(EC_KEYS.itemZset(itemId), startMs, id);
1199
+
1200
+ const startMs = startDay.toMillis();
1201
+ const endMs = endDay.toMillis();
1202
+
1203
+ const notesUser = String(req.body.notesUser || '').trim();
1204
+
1205
+ const bookingId = `${req.uid}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
1206
+
1207
+ // Create one reservation per item, linked by bookingId
1208
+ const created = [];
1209
+ for (const itemId of itemIds) {
1210
+ const rid = await createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId);
1211
+ created.push(rid);
1070
1212
  }
1071
1213
 
1072
- res.redirect(nconf.get('relative_path') + '/calendar?created=1&bookingId=' + bookingId);
1214
+ // Redirect to calendar with a success flag
1215
+ return res.redirect('/equipment/calendar?requested=1');
1073
1216
  } catch (err) {
1074
- winston.error('[equipment-calendar] create error', err);
1075
- res.status(500).json({ error: 'internal-error' });
1217
+ winston.error(err);
1218
+ return res.status(500).send(err.message || 'Error');
1076
1219
  }
1077
1220
  }
1078
1221
 
@@ -1321,83 +1464,3 @@ function startPaymentTimeoutScheduler() {
1321
1464
  }
1322
1465
 
1323
1466
  module.exports = plugin;
1324
-
1325
- async function handleAdminReservationAction(req, res, action) {
1326
- const isAdmin = await user.isAdminOrGlobalMod(req.uid);
1327
- if (!isAdmin) return res.status(403).json({ error: 'forbidden' });
1328
-
1329
- const id = String(req.params.id || '');
1330
- const key = EC_KEYS.reservationKey(id);
1331
- const r = await db.getObject(key);
1332
- if (!r || !r.id) return res.status(404).json({ error: 'not-found' });
1333
-
1334
- if (action === 'delete') {
1335
- await db.delete(key);
1336
- await db.sortedSetRemove(EC_KEYS.reservationsZset, id);
1337
- if (r.itemId) await db.sortedSetRemove(EC_KEYS.itemZset(r.itemId), id);
1338
- return res.redirect(nconf.get('relative_path') + '/admin/plugins/equipment-calendar/reservations?done=1');
1339
- }
1340
-
1341
- const status = action === 'approve' ? 'approved' : 'cancelled';
1342
- await db.setObjectField(key, 'status', status);
1343
- return res.redirect(nconf.get('relative_path') + '/admin/plugins/equipment-calendar/reservations?done=1');
1344
- }
1345
-
1346
- async function renderAdminReservationsPage(req, res) {
1347
- const isAdmin = await user.isAdminOrGlobalMod(req.uid);
1348
- if (!isAdmin) return res.status(403).render('403', {});
1349
- const ids = await db.getSortedSetRevRange(EC_KEYS.reservationsZset, 0, 199);
1350
- const rowsRaw = await db.getObjects(ids.map(id => EC_KEYS.reservationKey(id)));
1351
- const rows = (rowsRaw || []).filter(Boolean).map((r) => {
1352
- const startMs = parseInt(r.startMs, 10) || 0;
1353
- const endMs = parseInt(r.endMs, 10) || 0;
1354
- return {
1355
- id: r.id,
1356
- bookingId: r.bookingId || '',
1357
- itemName: r.itemName || r.itemId,
1358
- startIso: startMs ? new Date(startMs).toISOString().slice(0,10) : '',
1359
- endIso: endMs ? new Date(endMs - 24*60*60*1000).toISOString().slice(0,10) : '',
1360
- days: parseInt(r.days, 10) || 1,
1361
- status: r.status || 'pending',
1362
- total: (Number(r.total || 0) || 0),
1363
- };
1364
- });
1365
- res.render('admin/plugins/equipment-calendar-reservations', { title: 'Réservations', rows, hasRows: rows.length>0, csrf: req.csrfToken(), done: String(req.query.done||'')==='1' });
1366
- }
1367
-
1368
- async function getCalendarEvents() {
1369
- const ids = await db.getSortedSetRange(EC_KEYS.reservationsZset, 0, -1);
1370
- const rows = await db.getObjects(ids.map(id => EC_KEYS.reservationKey(id)));
1371
- return (rows || []).filter(Boolean).map((r) => {
1372
- const startMs = parseInt(r.startMs, 10) || 0;
1373
- const endMs = parseInt(r.endMs, 10) || 0;
1374
- const status = r.status || 'pending';
1375
- return {
1376
- id: r.id,
1377
- title: (r.itemName || 'Réservation'),
1378
- start: startMs ? new Date(startMs).toISOString().slice(0,10) : null,
1379
- end: endMs ? new Date(endMs).toISOString().slice(0,10) : null,
1380
- allDay: true,
1381
- classNames: ['ec-status-' + status],
1382
- extendedProps: { status, bookingId: r.bookingId || '' },
1383
- };
1384
- });
1385
- }
1386
-
1387
- async function renderCalendarPage(req, res) {
1388
- const settings = await getSettings();
1389
- const canCreate = req.uid ? await isUserInAnyGroups(req.uid, settings.creatorGroups) : false;
1390
- const isApprover = req.uid ? await isUserInAnyGroups(req.uid, settings.approverGroup) : false;
1391
- const items = await getActiveItems();
1392
- const events = await getCalendarEvents();
1393
-
1394
- res.render('equipment-calendar/calendar', {
1395
- title: 'Calendrier',
1396
- canCreate,
1397
- isApprover,
1398
- items,
1399
- eventsJson: JSON.stringify(events),
1400
- defaultView: settings.defaultView || 'dayGridMonth',
1401
- created: String(req.query.created || '') === '1',
1402
- });
1403
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "5.0.0",
3
+ "version": "6.0.0",
4
4
  "description": "Equipment reservation calendar for NodeBB (FullCalendar, approvals, HelloAsso payments)",
5
5
  "main": "library.js",
6
6
  "scripts": {
package/plugin.json CHANGED
@@ -25,6 +25,6 @@
25
25
  "scripts": [
26
26
  "public/js/client.js"
27
27
  ],
28
- "version": "0.8.0",
28
+ "version": "3.0.0-stable1",
29
29
  "minver": "4.7.1"
30
30
  }
@@ -93,8 +93,9 @@ require(['jquery', 'bootstrap'], function ($, bootstrap) {
93
93
  const events = Array.isArray(window.EC_EVENTS) ? window.EC_EVENTS : [];
94
94
 
95
95
  const calendar = new window.FullCalendar.Calendar(calendarEl, {
96
- locale: 'fr',
97
- headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,dayGridWeek,listMonth,listWeek' },
96
+ initialView: 'dayGridMonth',
97
+ locale: 'fr',
98
+ headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,dayGridWeek,listMonth,listWeek' },
98
99
  selectable: canCreate,
99
100
  selectMirror: true,
100
101
  events,
@@ -1,49 +1,104 @@
1
-
2
1
  <div class="acp-page-container">
3
2
  <div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
4
- <h1 class="mb-0">Réservations</h1>
5
- <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar">Paramètres</a>
3
+ <h1 class="mb-0">Equipment Calendar</h1>
4
+ <div class="btn-group">
5
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar">Paramètres</a>
6
+ <a class="btn btn-secondary active" href="/admin/plugins/equipment-calendar/reservations">Réservations</a>
7
+ </div>
6
8
  </div>
7
9
 
8
- {{{ if done }}}
9
- <script>
10
- if (window.app && window.app.alertSuccess) window.app.alertSuccess('Action effectuée');
11
- </script>
12
- {{{ end }}}
10
+ <form method="get" action="/admin/plugins/equipment-calendar/reservations" class="card card-body mt-3 mb-3">
11
+ <div class="row g-2 align-items-end">
12
+ <div class="col-md-3">
13
+ <label class="form-label">Statut</label>
14
+ <select class="form-select" name="status">
15
+ {{{ each statusOptions }}}
16
+ <option value="{statusOptions.id}" {{{ if statusOptions.selected }}}selected{{{ end }}}>{statusOptions.name}</option>
17
+ {{{ end }}}
18
+ </select>
19
+ </div>
20
+ <div class="col-md-4">
21
+ <label class="form-label">Matériel</label>
22
+ <select class="form-select" name="itemId">
23
+ {{{ each itemOptions }}}
24
+ <option value="{itemOptions.id}" {{{ if itemOptions.selected }}}selected{{{ end }}}>{itemOptions.name}</option>
25
+ {{{ end }}}
26
+ </select>
27
+ </div>
28
+ <div class="col-md-3">
29
+ <label class="form-label">Recherche</label>
30
+ <input class="form-control" name="q" value="{q}" placeholder="rid, uid, note">
31
+ </div>
32
+ <div class="col-md-2">
33
+ <button class="btn btn-primary w-100" type="submit">Filtrer</button>
34
+ </div>
35
+ </div>
36
+ </form>
37
+
38
+ <div class="text-muted small mb-2">
39
+ Total (filtré) : <strong>{total}</strong> — Total (global) : <strong>{totalAll}</strong>
40
+ </div>
13
41
 
14
42
  {{{ if hasRows }}}
15
- <div class="table-responsive mt-3">
43
+ <div class="table-responsive">
16
44
  <table class="table table-striped align-middle">
17
- <thead><tr><th>ID</th><th>Matériel</th><th>Période</th><th>Jours</th><th>Statut</th><th>Total</th><th class="text-end">Actions</th></tr></thead>
45
+ <thead>
46
+ <tr>
47
+ <th>RID</th>
48
+ <th>Matériel</th>
49
+ <th>UID</th>
50
+ <th>Début</th>
51
+ <th>Fin</th>
52
+ <th>Statut</th>
53
+ <th>Créé</th>
54
+ <th>Note</th>
55
+ <th>Actions</th>
56
+ </tr>
57
+ </thead>
18
58
  <tbody>
19
- <!-- BEGIN rows -->
59
+ {{{ each rows }}}
20
60
  <tr>
21
- <td><code>{rows.id}</code><br><small class="text-muted">{rows.bookingId}</small></td>
61
+ <td><code>{rows.rid}</code></td>
22
62
  <td>{rows.itemName}</td>
23
- <td>{rows.startIso} → {rows.endIso}</td>
24
- <td>{rows.days}</td>
25
- <td><span class="badge text-bg-secondary">{rows.status}</span></td>
26
- <td>{rows.total} €</td>
27
- <td class="text-end">
28
- <form class="d-inline" method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.id}/approve">
29
- <input type="hidden" name="_csrf" value="{csrf}">
30
- <button class="btn btn-sm btn-primary" type="submit">Approve</button>
63
+ <td>{rows.uid}</td>
64
+ <td><small>{rows.start}</small></td>
65
+ <td><small>{rows.end}</small></td>
66
+ <td><code>{rows.status}</code></td>
67
+ <td><small>{rows.createdAt}</small></td>
68
+ <td><small>{rows.notesUser}</small></td>
69
+ <td class="text-nowrap">
70
+ <form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/approve" class="d-inline">
71
+ <input type="hidden" name="_csrf" value="{config.csrf_token}">
72
+ <button class="btn btn-sm btn-success" type="submit">Approve</button>
31
73
  </form>
32
- <form class="d-inline" method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.id}/reject">
33
- <input type="hidden" name="_csrf" value="{csrf}">
74
+ <form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/reject" class="d-inline ms-1">
75
+ <input type="hidden" name="_csrf" value="{config.csrf_token}">
34
76
  <button class="btn btn-sm btn-warning" type="submit">Reject</button>
35
77
  </form>
36
- <form class="d-inline" method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.id}/delete" onsubmit="return confirm('Supprimer ?');">
37
- <input type="hidden" name="_csrf" value="{csrf}">
78
+ <form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/delete" class="d-inline ms-1" onsubmit="return confirm('Supprimer définitivement ?');">
79
+ <input type="hidden" name="_csrf" value="{config.csrf_token}">
38
80
  <button class="btn btn-sm btn-danger" type="submit">Delete</button>
39
81
  </form>
40
82
  </td>
41
83
  </tr>
42
- <!-- END rows -->
84
+ {{{ end }}}
43
85
  </tbody>
44
86
  </table>
45
87
  </div>
88
+
89
+ <div class="d-flex justify-content-between align-items-center mt-3">
90
+ <div>Page {page} / {totalPages}</div>
91
+ <div class="btn-group">
92
+ {{{ if prevPage }}}
93
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar/reservations?page={prevPage}&perPage={perPage}">Précédent</a>
94
+ {{{ end }}}
95
+ {{{ if nextPage }}}
96
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar/reservations?page={nextPage}&perPage={perPage}">Suivant</a>
97
+ {{{ end }}}
98
+ </div>
99
+ </div>
100
+
46
101
  {{{ else }}}
47
- <div class="alert alert-info mt-3">Aucune réservation.</div>
102
+ <div class="alert alert-info">Aucune réservation trouvée.</div>
48
103
  {{{ end }}}
49
104
  </div>
@@ -36,19 +36,6 @@
36
36
  </div>
37
37
 
38
38
  <div class="card card-body mb-3">
39
- <h5>Affichage calendrier</h5>
40
- <div class="mb-0">
41
- <label class="form-label">Vue par défaut</label>
42
- <select class="form-select" name="defaultView">
43
- <option value="dayGridMonth">Mois</option>
44
- <option value="dayGridWeek">Semaine</option>
45
- <option value="listMonth">Liste (mois)</option>
46
- <option value="listWeek">Liste (semaine)</option>
47
- </select>
48
- </div>
49
- </div>
50
-
51
- <div class="card card-body mb-3">
52
39
  <h5>HelloAsso</h5>
53
40
  <div class="mb-3">
54
41
  <label class="form-label">API Base URL (prod/sandbox)</label>
@@ -43,8 +43,8 @@
43
43
  <label class="form-label">Matériel</label>
44
44
  <select class="form-select" id="ec-item-ids" name="itemIds" multiple required>
45
45
  <!-- BEGIN items -->
46
- <option value="{items.id}" data-price="{items.price}">{items.name} — {items.price} €</option>
47
- <!-- END items -->
46
+ <option value="{items.id}" data-price="{items.price}">{items.name} — {items.price} €</option>
47
+ <!-- END items -->
48
48
  </select>
49
49
  <div class="form-text">Tu peux sélectionner plusieurs matériels.</div>
50
50
  </div>
@@ -84,9 +84,10 @@
84
84
  </script>
85
85
 
86
86
  <style>
87
+ /* Event styling */
88
+ .fc .fc-event-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
87
89
  .fc-event.ec-status-pending { background: rgba(108,117,125,.18); border-color: rgba(108,117,125,.45); }
88
90
  .fc-event.ec-status-approved { background: rgba(13,110,253,.18); border-color: rgba(13,110,253,.45); }
89
91
  .fc-event.ec-status-paid { background: rgba(25,135,84,.18); border-color: rgba(25,135,84,.45); }
90
92
  .fc-event.ec-status-cancelled { background: rgba(220,53,69,.10); border-color: rgba(220,53,69,.35); opacity:.65; text-decoration: line-through; }
91
- .fc .fc-event-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
92
93
  </style>