nodebb-plugin-equipment-calendar 5.0.1 → 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
@@ -7,23 +7,6 @@ try { if (!fetchFn) { fetchFn = require('undici').fetch; } } catch (e) {}
7
7
  const db = require.main.require('./src/database');
8
8
  const meta = require.main.require('./src/meta');
9
9
  const groups = require.main.require('./src/groups');
10
-
11
- async function isUserInAnyGroups(uid, groupsCsv) {
12
- if (!uid) return false;
13
- const str = Array.isArray(groupsCsv) ? groupsCsv.join(',') : String(groupsCsv || '');
14
- const names = str.split(',').map(s => s.trim()).filter(Boolean);
15
- if (!names.length) return false;
16
- for (const name of names) {
17
- try {
18
- // groups.isMember expects (uid, groupName)
19
- const ok = await groups.isMember(uid, name);
20
- if (ok) return true;
21
- } catch (e) {
22
- // ignore
23
- }
24
- }
25
- return false;
26
- }
27
10
  const user = require.main.require('./src/user');
28
11
  const notifications = require.main.require('./src/notifications');
29
12
  const Emailer = require.main.require('./src/emailer');
@@ -52,12 +35,6 @@ const crypto = require('crypto');
52
35
  const plugin = {};
53
36
  const SETTINGS_KEY = 'equipmentCalendar';
54
37
 
55
- const EC_KEYS = {
56
- reservationsZset: 'ec:reservations',
57
- reservationKey: (id) => `ec:reservation:${id}`,
58
- itemZset: (itemId) => `ec:item:${itemId}`,
59
- };
60
-
61
38
  const DEFAULT_SETTINGS = {
62
39
  creatorGroups: 'registered-users',
63
40
  approverGroup: 'administrators',
@@ -279,12 +256,21 @@ async function fetchHelloAssoItems(settings) {
279
256
 
280
257
  function pushItem(it, tierName) {
281
258
  if (!it) return;
282
- const id = String(it.id || it.itemId || it.reference || it.slug || it.code || it.name || '').trim();
283
- const name = String(it.name || it.label || it.title || '').trim() || (tierName ? String(tierName) : id);
284
- if (!id || !name) return;
285
- const amount = it.amount || it.price || it.unitPrice || it.totalAmount || it.initialAmount;
286
- const priceCents = (typeof amount === 'number' ? amount : parseInt(amount, 10)) || 0;
287
- 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: '' });
288
274
  }
289
275
 
290
276
  // Try a few known layouts
@@ -321,7 +307,7 @@ async function getActiveItems() {
321
307
  const items = (Array.isArray(rawItems) ? rawItems : []).map((it) => ({
322
308
  id: String(it.id || '').trim(),
323
309
  name: String(it.name || '').trim(),
324
- price: Number(it.price || 0) || 0,
310
+ price: Number((it.price !== undefined ? it.price : it.priceCents) || 0) || 0,
325
311
  active: true,
326
312
  })).filter(it => it.id && it.name);
327
313
  return items;
@@ -371,6 +357,8 @@ async function getBookingRids(bookingId) {
371
357
  async function saveReservation(res) {
372
358
  await db.setObject(resKey(res.id), res);
373
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);
374
362
  }
375
363
 
376
364
  async function getReservation(id) {
@@ -563,28 +551,22 @@ function verifyWebhook(req, secret) {
563
551
 
564
552
  // --- Rendering helpers ---
565
553
  function toEvent(res, item, requesterName, canSeeRequester) {
566
- const start = DateTime.fromMillis(res.startMs).toISO();
567
- const end = DateTime.fromMillis(res.endMs).toISO();
568
-
569
- let icon = '⏳';
554
+ const start = DateTime.fromMillis(res.startMs).toISODate();
555
+ const end = DateTime.fromMillis(res.endMs).toISODate();
570
556
  let className = 'ec-status-pending';
571
- if (res.status === 'approved_waiting_payment') { icon = '💳'; className = 'ec-status-awaitpay'; }
572
- if (res.status === 'paid_validated') { icon = ''; className = 'ec-status-valid'; }
573
- if (res.status === 'rejected' || res.status === 'cancelled') { icon = '❌'; className = 'ec-status-cancel'; }
574
-
575
- 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];
576
561
  if (canSeeRequester && requesterName) titleParts.push(`- ${requesterName}`);
577
562
  return {
578
563
  id: res.id,
579
564
  title: titleParts.join(' '),
580
565
  start,
581
566
  end,
582
- allDay: false,
583
- className,
584
- extendedProps: {
585
- status: res.status,
586
- itemId: res.itemId,
587
- },
567
+ allDay: true,
568
+ classNames: [className],
569
+ extendedProps: { status: res.status, itemId: res.itemId },
588
570
  };
589
571
  }
590
572
 
@@ -670,9 +652,6 @@ plugin.init = async function (params) {
670
652
 
671
653
  // Page routes are attached via filter:router.page as well, but we also add directly to be safe:
672
654
  router.get('/equipment/calendar', middleware.buildHeader, renderCalendarPage);
673
- router.get('/calendar', middleware.buildHeader, renderCalendarPage);
674
- router.get('/api/calendar', middleware.buildHeader, renderCalendarPage);
675
-
676
655
  router.get('/equipment/approvals', middleware.buildHeader, renderApprovalsPage);
677
656
 
678
657
  router.post('/equipment/reservations/create', middleware.applyCSRF, handleCreateReservation);
@@ -707,6 +686,91 @@ plugin.addAdminRoutes = async function (params) {
707
686
  };
708
687
 
709
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
+ }
710
774
 
711
775
  async function handleAdminApprove(req, res) {
712
776
  if (!(await ensureIsAdmin(req, res))) return;
@@ -866,6 +930,86 @@ async function handleHelloAssoCallback(req, res) {
866
930
  });
867
931
  }
868
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
+ }
869
1013
 
870
1014
 
871
1015
  // --- Approvals page ---
@@ -1026,70 +1170,52 @@ async function createReservationForItem(req, res, settings, itemId, startMs, end
1026
1170
 
1027
1171
  async function handleCreateReservation(req, res) {
1028
1172
  try {
1029
- if (!req.uid) return res.status(403).json({ error: 'not-logged-in' });
1030
-
1031
1173
  const settings = await getSettings();
1032
- const canCreate = await isUserInAnyGroups(req.uid, settings.creatorGroups);
1033
- 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
+ }
1034
1177
 
1035
- const notesUser = String(req.body.notesUser || '').trim();
1036
- const itemIdsRaw = req.body.itemIds || req.body.itemId || [];
1037
- const itemIds = Array.isArray(itemIdsRaw) ? itemIdsRaw : String(itemIdsRaw || '').split(',').filter(Boolean);
1038
-
1039
- const startMsRaw = parseInt(req.body.startMs, 10) || 0;
1040
- const endMsRaw = parseInt(req.body.endMs, 10) || 0;
1041
-
1042
- const startDate = startMsRaw ? new Date(startMsRaw) : null;
1043
- const endDate = endMsRaw ? new Date(endMsRaw) : null;
1044
- const startMs = startDate ? Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate()) : 0;
1045
- let endMs = 0;
1046
- if (endDate) {
1047
- endMs = Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate());
1048
- 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');
1049
1186
  }
1050
- if (!startMs || !endMs) return res.status(400).json({ error: 'invalid-dates' });
1051
-
1052
- const days = Math.max(1, Math.round((endMs - startMs) / (24 * 60 * 60 * 1000)));
1053
-
1054
- const items = await getActiveItems();
1055
- const byId = {};
1056
- items.forEach((it) => { byId[String(it.id)] = it; });
1057
-
1058
- const valid = itemIds.map(String).filter((id) => byId[id]);
1059
- if (!valid.length) return res.status(400).json({ error: 'unknown-item' });
1060
-
1061
- const bookingId = generateId();
1062
- for (const itemId of valid) {
1063
- const it = byId[itemId];
1064
- const id = generateId();
1065
- const unitPrice = Number(it.price || 0) || 0;
1066
- const total = unitPrice * days;
1067
-
1068
- const reservation = {
1069
- id,
1070
- bookingId,
1071
- uid: req.uid,
1072
- itemId,
1073
- itemName: it.name,
1074
- unitPrice,
1075
- days,
1076
- total,
1077
- notesUser,
1078
- startMs,
1079
- endMs,
1080
- status: 'pending',
1081
- createdAt: Date.now(),
1082
- };
1083
-
1084
- await db.setObject(EC_KEYS.reservationKey(id), reservation);
1085
- await db.sortedSetAdd(EC_KEYS.reservationsZset, startMs, id);
1086
- await db.sortedSetAdd(EC_KEYS.itemZset(itemId), startMs, id);
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 });
1198
+ }
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);
1087
1212
  }
1088
1213
 
1089
- 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');
1090
1216
  } catch (err) {
1091
- winston.error('[equipment-calendar] create error', err);
1092
- res.status(500).json({ error: 'internal-error' });
1217
+ winston.error(err);
1218
+ return res.status(500).send(err.message || 'Error');
1093
1219
  }
1094
1220
  }
1095
1221
 
@@ -1338,83 +1464,3 @@ function startPaymentTimeoutScheduler() {
1338
1464
  }
1339
1465
 
1340
1466
  module.exports = plugin;
1341
-
1342
- async function handleAdminReservationAction(req, res, action) {
1343
- const isAdmin = await user.isAdminOrGlobalMod(req.uid);
1344
- if (!isAdmin) return res.status(403).json({ error: 'forbidden' });
1345
-
1346
- const id = String(req.params.id || '');
1347
- const key = EC_KEYS.reservationKey(id);
1348
- const r = await db.getObject(key);
1349
- if (!r || !r.id) return res.status(404).json({ error: 'not-found' });
1350
-
1351
- if (action === 'delete') {
1352
- await db.delete(key);
1353
- await db.sortedSetRemove(EC_KEYS.reservationsZset, id);
1354
- if (r.itemId) await db.sortedSetRemove(EC_KEYS.itemZset(r.itemId), id);
1355
- return res.redirect(nconf.get('relative_path') + '/admin/plugins/equipment-calendar/reservations?done=1');
1356
- }
1357
-
1358
- const status = action === 'approve' ? 'approved' : 'cancelled';
1359
- await db.setObjectField(key, 'status', status);
1360
- return res.redirect(nconf.get('relative_path') + '/admin/plugins/equipment-calendar/reservations?done=1');
1361
- }
1362
-
1363
- async function renderAdminReservationsPage(req, res) {
1364
- const isAdmin = await user.isAdminOrGlobalMod(req.uid);
1365
- if (!isAdmin) return res.status(403).render('403', {});
1366
- const ids = await db.getSortedSetRevRange(EC_KEYS.reservationsZset, 0, 199);
1367
- const rowsRaw = await db.getObjects(ids.map(id => EC_KEYS.reservationKey(id)));
1368
- const rows = (rowsRaw || []).filter(Boolean).map((r) => {
1369
- const startMs = parseInt(r.startMs, 10) || 0;
1370
- const endMs = parseInt(r.endMs, 10) || 0;
1371
- return {
1372
- id: r.id,
1373
- bookingId: r.bookingId || '',
1374
- itemName: r.itemName || r.itemId,
1375
- startIso: startMs ? new Date(startMs).toISOString().slice(0,10) : '',
1376
- endIso: endMs ? new Date(endMs - 24*60*60*1000).toISOString().slice(0,10) : '',
1377
- days: parseInt(r.days, 10) || 1,
1378
- status: r.status || 'pending',
1379
- total: (Number(r.total || 0) || 0),
1380
- };
1381
- });
1382
- res.render('admin/plugins/equipment-calendar-reservations', { title: 'Réservations', rows, hasRows: rows.length>0, csrf: req.csrfToken(), done: String(req.query.done||'')==='1' });
1383
- }
1384
-
1385
- async function getCalendarEvents() {
1386
- const ids = await db.getSortedSetRange(EC_KEYS.reservationsZset, 0, -1);
1387
- const rows = await db.getObjects(ids.map(id => EC_KEYS.reservationKey(id)));
1388
- return (rows || []).filter(Boolean).map((r) => {
1389
- const startMs = parseInt(r.startMs, 10) || 0;
1390
- const endMs = parseInt(r.endMs, 10) || 0;
1391
- const status = r.status || 'pending';
1392
- return {
1393
- id: r.id,
1394
- title: (r.itemName || 'Réservation'),
1395
- start: startMs ? new Date(startMs).toISOString().slice(0,10) : null,
1396
- end: endMs ? new Date(endMs).toISOString().slice(0,10) : null,
1397
- allDay: true,
1398
- classNames: ['ec-status-' + status],
1399
- extendedProps: { status, bookingId: r.bookingId || '' },
1400
- };
1401
- });
1402
- }
1403
-
1404
- async function renderCalendarPage(req, res) {
1405
- const settings = await getSettings();
1406
- const canCreate = req.uid ? await isUserInAnyGroups(req.uid, settings.creatorGroups) : false;
1407
- const isApprover = req.uid ? await isUserInAnyGroups(req.uid, settings.approverGroup) : false;
1408
- const items = await getActiveItems();
1409
- const events = await getCalendarEvents();
1410
-
1411
- res.render('equipment-calendar/calendar', {
1412
- title: 'Calendrier',
1413
- canCreate,
1414
- isApprover,
1415
- items,
1416
- eventsJson: JSON.stringify(events),
1417
- defaultView: settings.defaultView || 'dayGridMonth',
1418
- created: String(req.query.created || '') === '1',
1419
- });
1420
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "5.0.1",
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.1",
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>