nodebb-plugin-equipment-calendar 5.0.1 → 8.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',
@@ -284,7 +261,10 @@ async function fetchHelloAssoItems(settings) {
284
261
  if (!id || !name) return;
285
262
  const amount = it.amount || it.price || it.unitPrice || it.totalAmount || it.initialAmount;
286
263
  const priceCents = (typeof amount === 'number' ? amount : parseInt(amount, 10)) || 0;
287
- out.push({ id, name, priceCents: String(priceCents), location: '' });
264
+ const raw = (typeof priceCents === 'number' ? priceCents : parseInt(priceCents, 10)) || 0;
265
+ // Heuristic: HelloAsso amounts are often in cents; convert when it looks like cents.
266
+ const price = (raw >= 1000 && raw % 100 === 0) ? (raw / 100) : raw;
267
+ out.push({ id, name, price, priceRaw: raw });
288
268
  }
289
269
 
290
270
  // Try a few known layouts
@@ -321,7 +301,7 @@ async function getActiveItems() {
321
301
  const items = (Array.isArray(rawItems) ? rawItems : []).map((it) => ({
322
302
  id: String(it.id || '').trim(),
323
303
  name: String(it.name || '').trim(),
324
- price: Number(it.price || 0) || 0,
304
+ price: (Number(it.price || 0) || ((Number(it.priceCents || 0) || 0) >= 1000 && (Number(it.priceCents||0)%100===0) ? (Number(it.priceCents||0)/100) : (Number(it.priceCents||0)||0)) || 0),
325
305
  active: true,
326
306
  })).filter(it => it.id && it.name);
327
307
  return items;
@@ -370,7 +350,10 @@ async function getBookingRids(bookingId) {
370
350
 
371
351
  async function saveReservation(res) {
372
352
  await db.setObject(resKey(res.id), res);
353
+ // per-item index (calendar fetch by item)
373
354
  await db.sortedSetAdd(itemIndexKey(res.itemId), res.startMs, res.id);
355
+ // global index (ACP list)
356
+ await db.sortedSetAdd('equipmentCalendar:reservations', res.startMs, res.id);
374
357
  }
375
358
 
376
359
  async function getReservation(id) {
@@ -670,9 +653,6 @@ plugin.init = async function (params) {
670
653
 
671
654
  // Page routes are attached via filter:router.page as well, but we also add directly to be safe:
672
655
  router.get('/equipment/calendar', middleware.buildHeader, renderCalendarPage);
673
- router.get('/calendar', middleware.buildHeader, renderCalendarPage);
674
- router.get('/api/calendar', middleware.buildHeader, renderCalendarPage);
675
-
676
656
  router.get('/equipment/approvals', middleware.buildHeader, renderApprovalsPage);
677
657
 
678
658
  router.post('/equipment/reservations/create', middleware.applyCSRF, handleCreateReservation);
@@ -707,6 +687,91 @@ plugin.addAdminRoutes = async function (params) {
707
687
  };
708
688
 
709
689
 
690
+ async function renderAdminReservationsPage(req, res) {
691
+ if (!(await ensureIsAdmin(req, res))) return;
692
+
693
+ const settings = await getSettings();
694
+ const items = await getActiveItems(settings);
695
+ const itemById = {};
696
+ items.forEach(it => { itemById[it.id] = it; });
697
+
698
+ const status = String(req.query.status || ''); // optional filter
699
+ const itemId = String(req.query.itemId || ''); // optional filter
700
+ const q = String(req.query.q || '').trim(); // search rid/user/notes
701
+
702
+ const page = Math.max(1, parseInt(req.query.page, 10) || 1);
703
+ const perPage = Math.min(100, Math.max(10, parseInt(req.query.perPage, 10) || 50));
704
+
705
+ const allRids = await db.getSortedSetRevRange('equipmentCalendar:reservations', 0, -1);
706
+ const totalAll = allRids.length;
707
+
708
+ const rows = [];
709
+ for (const rid of allRids) {
710
+ // eslint-disable-next-line no-await-in-loop
711
+ const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
712
+ if (!r || !r.rid) continue;
713
+
714
+ if (status && String(r.status) !== status) continue;
715
+ if (itemId && String(r.itemId) !== itemId) continue;
716
+
717
+ const notes = String(r.notesUser || '');
718
+ const ridStr = String(r.rid || rid);
719
+ if (q) {
720
+ const hay = (ridStr + ' ' + String(r.uid || '') + ' ' + notes).toLowerCase();
721
+ if (!hay.includes(q.toLowerCase())) continue;
722
+ }
723
+
724
+ const startMs = parseInt(r.startMs, 10) || 0;
725
+ const endMs = parseInt(r.endMs, 10) || 0;
726
+ const createdAt = parseInt(r.createdAt, 10) || 0;
727
+
728
+ rows.push({
729
+ rid: ridStr,
730
+ itemId: String(r.itemId || ''),
731
+ itemName: (itemById[String(r.itemId || '')] && itemById[String(r.itemId || '')].name) || String(r.itemId || ''),
732
+ uid: String(r.uid || ''),
733
+ status: String(r.status || ''),
734
+ start: startMs ? new Date(startMs).toISOString() : '',
735
+ end: endMs ? new Date(endMs).toISOString() : '',
736
+ createdAt: createdAt ? new Date(createdAt).toISOString() : '',
737
+ notesUser: notes,
738
+ });
739
+ }
740
+
741
+ const total = rows.length;
742
+ const totalPages = Math.max(1, Math.ceil(total / perPage));
743
+ const safePage = Math.min(page, totalPages);
744
+ const startIndex = (safePage - 1) * perPage;
745
+ const pageRows = rows.slice(startIndex, startIndex + perPage);
746
+
747
+ const itemOptions = [{ id: '', name: 'Tous' }].concat(items.map(i => ({ id: i.id, name: i.name })));
748
+ const statusOptions = [
749
+ { id: '', name: 'Tous' },
750
+ { id: 'pending', name: 'pending' },
751
+ { id: 'approved', name: 'approved' },
752
+ { id: 'paid', name: 'paid' },
753
+ { id: 'rejected', name: 'rejected' },
754
+ { id: 'cancelled', name: 'cancelled' },
755
+ ];
756
+
757
+ res.render('admin/plugins/equipment-calendar-reservations', {
758
+ title: 'Equipment Calendar - Réservations',
759
+ settings,
760
+ rows: pageRows,
761
+ hasRows: pageRows.length > 0,
762
+ itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
763
+ statusOptions: statusOptions.map(o => ({ ...o, selected: o.id === status })),
764
+ q,
765
+ page: safePage,
766
+ perPage,
767
+ total,
768
+ totalAll,
769
+ totalPages,
770
+ prevPage: safePage > 1 ? safePage - 1 : 0,
771
+ nextPage: safePage < totalPages ? safePage + 1 : 0,
772
+ actionBase: '/admin/plugins/equipment-calendar/reservations',
773
+ });
774
+ }
710
775
 
711
776
  async function handleAdminApprove(req, res) {
712
777
  if (!(await ensureIsAdmin(req, res))) return;
@@ -866,6 +931,86 @@ async function handleHelloAssoCallback(req, res) {
866
931
  });
867
932
  }
868
933
 
934
+ async function renderCalendarPage(req, res) {
935
+ const settings = await getSettings();
936
+ const items = await getActiveItems(settings);
937
+
938
+ const tz = settings.timezone || 'Europe/Paris';
939
+
940
+ // Determine range to render
941
+ const now = DateTime.now().setZone(tz);
942
+ const view = String(req.query.view || settings.defaultView || 'dayGridMonth');
943
+ const startQ = req.query.start;
944
+ const endQ = req.query.end;
945
+
946
+ let start, end;
947
+ if (startQ && endQ) {
948
+ const r = clampRange(String(startQ), String(endQ), tz);
949
+ start = r.start;
950
+ end = r.end;
951
+ } else {
952
+ // Default to current month range
953
+ start = now.startOf('month');
954
+ end = now.endOf('month').plus({ days: 1 });
955
+ }
956
+
957
+ // Load reservations for ALL items within range (so we can build availability client-side without extra requests)
958
+ const allReservations = [];
959
+ for (const it of items) {
960
+ // eslint-disable-next-line no-await-in-loop
961
+ const resForItem = await listReservationsForRange(it.id, start.toMillis(), end.toMillis());
962
+ for (const r of resForItem) allReservations.push(r);
963
+ }
964
+
965
+ const showRequesterToAll = String(settings.showRequesterToAll) === '1';
966
+ const isApprover = req.uid ? await canApprove(req.uid, settings) : false;
967
+
968
+ const requesterUids = Array.from(new Set(allReservations.map(r => r.uid))).filter(Boolean);
969
+ const users = requesterUids.length ? await user.getUsersData(requesterUids) : [];
970
+ const nameByUid = {};
971
+ (users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
972
+
973
+ const itemById = {};
974
+ items.forEach(it => { itemById[it.id] = it; });
975
+
976
+ const events = allReservations
977
+ .filter(r => r.status !== 'rejected' && r.status !== 'cancelled')
978
+ .map(r => {
979
+ const item = itemById[r.itemId];
980
+ // Include item name in title so "all items" view is readable
981
+ const requesterName = nameByUid[r.uid] || '';
982
+ return toEvent(r, item, requesterName, isApprover || showRequesterToAll);
983
+ });
984
+
985
+ const canUserCreate = req.uid ? await canCreate(req.uid, settings) : false;
986
+
987
+ // We expose minimal reservation data for availability checks in the modal
988
+ const blocks = allReservations
989
+ .filter(r => statusBlocksItem(r.status))
990
+ .map(r => ({
991
+ itemId: r.itemId,
992
+ startMs: r.startMs,
993
+ endMs: r.endMs,
994
+ status: r.status,
995
+ }));
996
+
997
+ res.render('equipment-calendar/calendar', {
998
+ title: 'Réservation de matériel',
999
+ items,
1000
+ view,
1001
+ tz,
1002
+ startISO: start.toISO(),
1003
+ endISO: end.toISO(),
1004
+ initialDateISO: start.toISODate(),
1005
+ canCreate: canUserCreate,
1006
+ canCreateJs: canUserCreate ? 'true' : 'false',
1007
+ isApprover,
1008
+ // events are base64 encoded to avoid template escaping issues
1009
+ eventsB64: Buffer.from(JSON.stringify(events), 'utf8').toString('base64'),
1010
+ blocksB64: Buffer.from(JSON.stringify(blocks), 'utf8').toString('base64'),
1011
+ itemsB64: Buffer.from(JSON.stringify(items.map(i => ({ id: i.id, name: i.name, location: i.location }))), 'utf8').toString('base64'),
1012
+ });
1013
+ }
869
1014
 
870
1015
 
871
1016
  // --- Approvals page ---
@@ -1026,70 +1171,52 @@ async function createReservationForItem(req, res, settings, itemId, startMs, end
1026
1171
 
1027
1172
  async function handleCreateReservation(req, res) {
1028
1173
  try {
1029
- if (!req.uid) return res.status(403).json({ error: 'not-logged-in' });
1030
-
1031
1174
  const settings = await getSettings();
1032
- const canCreate = await isUserInAnyGroups(req.uid, settings.creatorGroups);
1033
- if (!canCreate) return res.status(403).json({ error: 'forbidden' });
1175
+ if (!req.uid || !(await canCreate(req.uid, settings))) {
1176
+ return helpers.notAllowed(req, res);
1177
+ }
1034
1178
 
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;
1179
+ const itemIdsRaw = req.body.itemIds || req.body.itemId || '';
1180
+ const itemIds = (Array.isArray(itemIdsRaw) ? itemIdsRaw : String(itemIdsRaw))
1181
+ .split(',')
1182
+ .map(s => String(s).trim())
1183
+ .filter(Boolean);
1184
+
1185
+ if (!itemIds.length) {
1186
+ return res.status(400).send('itemIds required');
1049
1187
  }
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);
1188
+
1189
+ const tz = settings.timezone || 'Europe/Paris';
1190
+ const start = DateTime.fromISO(String(req.body.start || ''), { zone: tz });
1191
+ const end = DateTime.fromISO(String(req.body.end || ''), { zone: tz });
1192
+ if (!start.isValid || !end.isValid) return res.status(400).send('Invalid dates');
1193
+
1194
+ // Normalize to whole days (no hours). End is exclusive.
1195
+ const startDay = start.startOf('day');
1196
+ let endDay = end.startOf('day');
1197
+ if (endDay <= startDay) {
1198
+ endDay = startDay.plus({ days: 1 });
1087
1199
  }
1088
1200
 
1089
- res.redirect(nconf.get('relative_path') + '/calendar?created=1&bookingId=' + bookingId);
1201
+ const startMs = startDay.toMillis();
1202
+ const endMs = endDay.toMillis();
1203
+
1204
+ const notesUser = String(req.body.notesUser || '').trim();
1205
+
1206
+ const bookingId = `${req.uid}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
1207
+
1208
+ // Create one reservation per item, linked by bookingId
1209
+ const created = [];
1210
+ for (const itemId of itemIds) {
1211
+ const rid = await createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId);
1212
+ created.push(rid);
1213
+ }
1214
+
1215
+ // Redirect to calendar with a success flag
1216
+ return res.redirect('/equipment/calendar?requested=1');
1090
1217
  } catch (err) {
1091
- winston.error('[equipment-calendar] create error', err);
1092
- res.status(500).json({ error: 'internal-error' });
1218
+ winston.error(err);
1219
+ return res.status(500).send(err.message || 'Error');
1093
1220
  }
1094
1221
  }
1095
1222
 
@@ -1339,82 +1466,31 @@ function startPaymentTimeoutScheduler() {
1339
1466
 
1340
1467
  module.exports = plugin;
1341
1468
 
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
1469
 
1346
- const id = String(req.params.id || '');
1347
- const key = EC_KEYS.reservationKey(id);
1348
- const r = await db.getObject(key);
1470
+ async function handleGetReservation(req, res) {
1471
+ if (!req.uid) return res.status(403).json({ error: 'not-logged-in' });
1472
+ const rid = String(req.params.id || '');
1473
+ const r = await db.getObject(resKey(rid));
1349
1474
  if (!r || !r.id) return res.status(404).json({ error: 'not-found' });
1350
1475
 
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
1476
  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',
1477
+ const isAdmin = await user.isAdminOrGlobalMod(req.uid);
1478
+ const isOwner = String(r.uid) === String(req.uid);
1479
+ const canSee = isAdmin || isOwner || String(settings.showRequesterToAll || '0') === '1';
1480
+ if (!canSee) return res.status(403).json({ error: 'forbidden' });
1481
+
1482
+ res.json({
1483
+ id: r.id,
1484
+ bookingId: r.bookingId || '',
1485
+ itemId: r.itemId,
1486
+ itemName: r.itemName || '',
1487
+ startMs: parseInt(r.startMs, 10) || 0,
1488
+ endMs: parseInt(r.endMs, 10) || 0,
1489
+ days: parseInt(r.days, 10) || 1,
1490
+ status: r.status || 'pending',
1491
+ total: Number(r.total || 0) || 0,
1492
+ unitPrice: Number(r.unitPrice || 0) || 0,
1493
+ notesUser: r.notesUser || '',
1494
+ uid: r.uid,
1419
1495
  });
1420
1496
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-equipment-calendar",
3
- "version": "5.0.1",
3
+ "version": "8.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": "0.7.5",
29
29
  "minver": "4.7.1"
30
30
  }
@@ -29,107 +29,34 @@ require(['jquery', 'bootstrap'], function ($, bootstrap) {
29
29
  }
30
30
 
31
31
  function updateTotalPrice() {
32
+ try {
32
33
  const sel = document.getElementById('ec-item-ids');
34
+ if (sel) sel.addEventListener('change', updateTotalPrice);
33
35
  const out = document.getElementById('ec-total-price');
34
- const daysEl = document.getElementById('ec-total-days');
35
- if (!sel || !out) return;
36
-
37
- let unitTotal = 0;
38
- Array.from(sel.selectedOptions || []).forEach((opt) => {
39
- const p = parseFloat(opt.getAttribute('data-price') || '0');
40
- if (!Number.isNaN(p)) unitTotal += p;
41
- });
42
-
43
- const days = getReservationDays();
44
- if (daysEl) daysEl.textContent = days + (days > 1 ? ' jours' : ' jour');
45
-
46
- const finalTotal = unitTotal * days;
47
- const txt = Number.isInteger(finalTotal) ? String(finalTotal) : finalTotal.toFixed(2);
48
- out.textContent = txt + ' €';
49
- }
50
-
51
- function syncHiddenIsoFields() {
52
- const s = document.getElementById('ec-start-date')?.value;
53
- const e = document.getElementById('ec-end-date')?.value;
54
- const startIsoEl = document.getElementById('ec-start-iso');
55
- const endIsoEl = document.getElementById('ec-end-iso');
56
- if (!s || !e || !startIsoEl || !endIsoEl) return;
57
-
58
- // Start inclusive at 00:00Z; End exclusive = day after end date at 00:00Z
59
- const startMs = parseDateInputToMs(s);
60
- const endMsExclusive = parseDateInputToMs(e) + 24 * 60 * 60 * 1000;
61
- startIsoEl.value = new Date(startMs).toISOString();
62
- endIsoEl.value = new Date(endMsExclusive).toISOString();
63
- }
64
-
65
- function openModalWithRange(startMs, endMsExclusive) {
66
- // Convert to date inputs: end date = endExclusive -1 day
67
- const startDate = toIsoDateUTC(startMs);
68
- const endDate = toIsoDateUTC(endMsExclusive - 24 * 60 * 60 * 1000);
69
-
70
- const startEl = document.getElementById('ec-start-date');
71
- const endEl = document.getElementById('ec-end-date');
72
- if (startEl) startEl.value = startDate;
73
- if (endEl) endEl.value = endDate;
74
-
75
- syncHiddenIsoFields();
76
- updateTotalPrice();
77
-
78
- const modalEl = document.getElementById('ec-create-modal');
79
- const BS = (bootstrap && bootstrap.Modal) ? bootstrap : (window.bootstrap && window.bootstrap.Modal ? window.bootstrap : null);
80
- if (modalEl && BS && BS.Modal) {
81
- BS.Modal.getOrCreateInstance(modalEl).show();
82
- } else {
83
- // Fallback: if bootstrap modal isn't available, scroll to form area
84
- modalEl?.scrollIntoView({ behavior: 'smooth', block: 'center' });
36
+ const startEl = document.getElementById('ec-start');
37
+ const endEl = document.getElementById('ec-end');
38
+ if (startEl) startEl.addEventListener('change', updateTotalPrice);
39
+ if (endEl) endEl.addEventListener('change', updateTotalPrice);
40
+ if (!sel || !out || !startEl || !endEl) return;
41
+
42
+ const start = startEl.value ? new Date(startEl.value + 'T00:00:00Z') : null;
43
+ const end = endEl.value ? new Date(endEl.value + 'T00:00:00Z') : null;
44
+ let days = 1;
45
+ if (start && end) {
46
+ const diff = (end.getTime() - start.getTime());
47
+ days = Math.max(1, Math.round(diff / (24*60*60*1000)));
85
48
  }
86
- }
87
-
88
- function initCalendar() {
89
- const calendarEl = document.getElementById('ec-calendar');
90
- if (!calendarEl || !window.FullCalendar) return;
91
-
92
- const canCreate = !!window.EC_CAN_CREATE;
93
- const events = Array.isArray(window.EC_EVENTS) ? window.EC_EVENTS : [];
94
-
95
- const calendar = new window.FullCalendar.Calendar(calendarEl, {
96
- locale: 'fr',
97
- headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,dayGridWeek,listMonth,listWeek' },
98
- selectable: canCreate,
99
- selectMirror: true,
100
- events,
101
- dateClick: function (info) {
102
- if (!canCreate) return;
103
- const startMs = Date.UTC(info.date.getUTCFullYear(), info.date.getUTCMonth(), info.date.getUTCDate());
104
- const endMs = startMs + 24 * 60 * 60 * 1000;
105
- console.debug('[equipment-calendar] open modal', { startMs, endMs });
106
- openModalWithRange(startMs, endMs);
107
- },
108
- select: function (info) {
109
- if (!canCreate) return;
110
- const startMs = Date.UTC(info.start.getUTCFullYear(), info.start.getUTCMonth(), info.start.getUTCDate());
111
- const endMs = Date.UTC(info.end.getUTCFullYear(), info.end.getUTCMonth(), info.end.getUTCDate());
112
- console.debug('[equipment-calendar] open modal', { startMs, endMs });
113
- openModalWithRange(startMs, endMs);
114
- },
115
- });
116
49
 
117
- calendar.render();
118
-
119
- document.getElementById('ec-item-ids')?.addEventListener('change', updateTotalPrice);
120
- document.getElementById('ec-start-date')?.addEventListener('change', function () {
121
- syncHiddenIsoFields();
122
- updateTotalPrice();
123
- });
124
- document.getElementById('ec-end-date')?.addEventListener('change', function () {
125
- syncHiddenIsoFields();
126
- updateTotalPrice();
127
- });
128
-
129
- updateTotalPrice();
130
- }
50
+ let sum = 0;
51
+ const opts = Array.from(sel.selectedOptions || []);
52
+ for (const opt of opts) {
53
+ const raw = (opt.getAttribute('data-price') || '').trim().replace(',', '.');
54
+ const price = parseFloat(raw);
55
+ if (!Number.isNaN(price)) sum += price;
56
+ }
131
57
 
132
- $(window).off('action:ajaxify.end.ec').on('action:ajaxify.end.ec', initCalendar);
133
- $(initCalendar);
58
+ const total = sum * days;
59
+ out.textContent = total.toFixed(2).replace('.00','') + ' €';
60
+ } catch (e) rememberError(e);
134
61
  })();
135
62
  });
@@ -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}</option>
47
+ <!-- END items -->
48
48
  </select>
49
49
  <div class="form-text">Tu peux sélectionner plusieurs matériels.</div>
50
50
  </div>
@@ -82,11 +82,3 @@
82
82
  window.EC_CAN_CREATE = {canCreateJs};
83
83
  window.EC_TZ = '{tz}';
84
84
  </script>
85
-
86
- <style>
87
- .fc-event.ec-status-pending { background: rgba(108,117,125,.18); border-color: rgba(108,117,125,.45); }
88
- .fc-event.ec-status-approved { background: rgba(13,110,253,.18); border-color: rgba(13,110,253,.45); }
89
- .fc-event.ec-status-paid { background: rgba(25,135,84,.18); border-color: rgba(25,135,84,.45); }
90
- .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
- </style>