nodebb-plugin-equipment-calendar 4.9.0 → 5.0.1

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,6 +7,23 @@ 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
+ }
10
27
  const user = require.main.require('./src/user');
11
28
  const notifications = require.main.require('./src/notifications');
12
29
  const Emailer = require.main.require('./src/emailer');
@@ -17,16 +34,6 @@ const nconf = require.main.require('nconf');
17
34
 
18
35
  const winston = require.main.require('winston');
19
36
 
20
- function decorateCalendarEvents(events) {
21
- return (events || []).map((ev) => {
22
- const status = ev.status || (ev.extendedProps && ev.extendedProps.status) || 'pending';
23
- return Object.assign({}, ev, {
24
- classNames: (ev.classNames || []).concat(['ec-status-' + status]),
25
- extendedProps: Object.assign({}, ev.extendedProps || {}, { status }),
26
- });
27
- });
28
- }
29
-
30
37
  function generateId() {
31
38
  try {
32
39
  // Node 14+ / modern: crypto.randomUUID
@@ -48,6 +55,7 @@ const SETTINGS_KEY = 'equipmentCalendar';
48
55
  const EC_KEYS = {
49
56
  reservationsZset: 'ec:reservations',
50
57
  reservationKey: (id) => `ec:reservation:${id}`,
58
+ itemZset: (itemId) => `ec:item:${itemId}`,
51
59
  };
52
60
 
53
61
  const DEFAULT_SETTINGS = {
@@ -360,13 +368,9 @@ async function getBookingRids(bookingId) {
360
368
  return await db.getSetMembers(`equipmentCalendar:booking:${bookingId}:rids`) || [];
361
369
  }
362
370
 
363
- const GLOBAL_INDEX_KEY = 'ec:reservations';
364
-
365
371
  async function saveReservation(res) {
366
372
  await db.setObject(resKey(res.id), res);
367
373
  await db.sortedSetAdd(itemIndexKey(res.itemId), res.startMs, res.id);
368
- // Global index for ACP listing
369
- await db.sortedSetAdd(GLOBAL_INDEX_KEY, res.startMs, res.id);
370
374
  }
371
375
 
372
376
  async function getReservation(id) {
@@ -666,6 +670,9 @@ plugin.init = async function (params) {
666
670
 
667
671
  // Page routes are attached via filter:router.page as well, but we also add directly to be safe:
668
672
  router.get('/equipment/calendar', middleware.buildHeader, renderCalendarPage);
673
+ router.get('/calendar', middleware.buildHeader, renderCalendarPage);
674
+ router.get('/api/calendar', middleware.buildHeader, renderCalendarPage);
675
+
669
676
  router.get('/equipment/approvals', middleware.buildHeader, renderApprovalsPage);
670
677
 
671
678
  router.post('/equipment/reservations/create', middleware.applyCSRF, handleCreateReservation);
@@ -701,30 +708,6 @@ plugin.addAdminRoutes = async function (params) {
701
708
 
702
709
 
703
710
 
704
- async function renderAdminReservationsPage(req, res) {
705
- const isAdmin = await user.isAdminOrGlobalMod(req.uid);
706
- if (!isAdmin) return res.status(403).render('403', {});
707
- const ids = await db.getSortedSetRevRange(GLOBAL_INDEX_KEY, 0, 199);
708
- const rowsRaw = await db.getObjects(ids.map(id => resKey(id)));
709
- const rows = (rowsRaw || []).filter(Boolean).map((r) => {
710
- const startMs = parseInt(r.startMs, 10) || 0;
711
- const endMs = parseInt(r.endMs, 10) || 0;
712
- return {
713
- id: r.id,
714
- bookingId: r.bookingId || '',
715
- itemName: r.itemName || r.itemId,
716
- startIso: startMs ? new Date(startMs).toISOString().slice(0,10) : '',
717
- endIso: endMs ? new Date(endMs - 24*60*60*1000).toISOString().slice(0,10) : '',
718
- days: Math.max(1, Math.round((endMs - startMs) / (24*60*60*1000))),
719
- status: r.status || 'pending',
720
- total: r.total || '0',
721
- uid: r.uid,
722
- createdAt: r.createdAt || 0,
723
- };
724
- });
725
- res.render('admin/plugins/equipment-calendar-reservations', { title: 'Réservations', rows, hasRows: rows.length>0 });
726
- }
727
-
728
711
  async function handleAdminApprove(req, res) {
729
712
  if (!(await ensureIsAdmin(req, res))) return;
730
713
  const rid = String(req.params.rid || '').trim();
@@ -883,88 +866,6 @@ async function handleHelloAssoCallback(req, res) {
883
866
  });
884
867
  }
885
868
 
886
- async function renderCalendarPage(req, res) {
887
- const settings = await getSettings();
888
- const items = await getActiveItems(settings);
889
-
890
- const tz = settings.timezone || 'Europe/Paris';
891
-
892
- // Determine range to render
893
- const now = DateTime.now().setZone(tz);
894
- const view = String(req.query.view || settings.defaultView || 'dayGridMonth');
895
- const startQ = req.query.start;
896
- const endQ = req.query.end;
897
-
898
- let start, end;
899
- if (startQ && endQ) {
900
- const r = clampRange(String(startQ), String(endQ), tz);
901
- start = r.start;
902
- end = r.end;
903
- } else {
904
- // Default to current month range
905
- start = now.startOf('month');
906
- end = now.endOf('month').plus({ days: 1 });
907
- }
908
-
909
- // Load reservations for ALL items within range (so we can build availability client-side without extra requests)
910
- const allReservations = [];
911
- for (const it of items) {
912
- // eslint-disable-next-line no-await-in-loop
913
- const resForItem = await listReservationsForRange(it.id, start.toMillis(), end.toMillis());
914
- for (const r of resForItem) allReservations.push(r);
915
- }
916
-
917
- const showRequesterToAll = String(settings.showRequesterToAll) === '1';
918
- const isApprover = req.uid ? await canApprove(req.uid, settings) : false;
919
-
920
- const requesterUids = Array.from(new Set(allReservations.map(r => r.uid))).filter(Boolean);
921
- const users = requesterUids.length ? await user.getUsersData(requesterUids) : [];
922
- const nameByUid = {};
923
- (users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
924
-
925
- const itemById = {};
926
- items.forEach(it => { itemById[it.id] = it; });
927
-
928
- const events = allReservations
929
- .filter(r => r.status !== 'rejected' && r.status !== 'cancelled')
930
- .map(r => {
931
- const item = itemById[r.itemId];
932
- // Include item name in title so "all items" view is readable
933
- const requesterName = nameByUid[r.uid] || '';
934
- return toEvent(r, item, requesterName, isApprover || showRequesterToAll);
935
- });
936
-
937
- const canUserCreate = req.uid ? await canCreate(req.uid, settings) : false;
938
-
939
- // We expose minimal reservation data for availability checks in the modal
940
- const blocks = allReservations
941
- .filter(r => statusBlocksItem(r.status))
942
- .map(r => ({
943
- itemId: r.itemId,
944
- startMs: r.startMs,
945
- endMs: r.endMs,
946
- status: r.status,
947
- }));
948
-
949
- res.render('equipment-calendar/calendar', {
950
- defaultView: settings.defaultView,
951
-
952
- title: 'Réservation de matériel',
953
- items,
954
- view,
955
- tz,
956
- startISO: start.toISO(),
957
- endISO: end.toISO(),
958
- initialDateISO: start.toISODate(),
959
- canCreate: canUserCreate,
960
- canCreateJs: canUserCreate ? 'true' : 'false',
961
- isApprover,
962
- // events are base64 encoded to avoid template escaping issues
963
- eventsB64: Buffer.from(JSON.stringify(events), 'utf8').toString('base64'),
964
- blocksB64: Buffer.from(JSON.stringify(blocks), 'utf8').toString('base64'),
965
- itemsB64: Buffer.from(JSON.stringify(items.map(i => ({ id: i.id, name: i.name, location: i.location }))), 'utf8').toString('base64'),
966
- });
967
- }
968
869
 
969
870
 
970
871
  // --- Approvals page ---
@@ -1125,52 +1026,70 @@ async function createReservationForItem(req, res, settings, itemId, startMs, end
1125
1026
 
1126
1027
  async function handleCreateReservation(req, res) {
1127
1028
  try {
1128
- const settings = await getSettings();
1129
- if (!req.uid || !(await canCreate(req.uid, settings))) {
1130
- return helpers.notAllowed(req, res);
1131
- }
1132
-
1133
- const itemIdsRaw = req.body.itemIds || req.body.itemId || '';
1134
- const itemIds = (Array.isArray(itemIdsRaw) ? itemIdsRaw : String(itemIdsRaw))
1135
- .split(',')
1136
- .map(s => String(s).trim())
1137
- .filter(Boolean);
1138
-
1139
- if (!itemIds.length) {
1140
- return res.status(400).send('itemIds required');
1141
- }
1029
+ if (!req.uid) return res.status(403).json({ error: 'not-logged-in' });
1142
1030
 
1143
- const tz = settings.timezone || 'Europe/Paris';
1144
- const start = DateTime.fromISO(String(req.body.start || ''), { zone: tz });
1145
- const end = DateTime.fromISO(String(req.body.end || ''), { zone: tz });
1146
- if (!start.isValid || !end.isValid) return res.status(400).send('Invalid dates');
1147
-
1148
- // Normalize to whole days (no hours). End is exclusive.
1149
- const startDay = start.startOf('day');
1150
- let endDay = end.startOf('day');
1151
- if (endDay <= startDay) {
1152
- endDay = startDay.plus({ days: 1 });
1153
- }
1154
-
1155
- const startMs = startDay.toMillis();
1156
- const endMs = endDay.toMillis();
1031
+ const settings = await getSettings();
1032
+ const canCreate = await isUserInAnyGroups(req.uid, settings.creatorGroups);
1033
+ if (!canCreate) return res.status(403).json({ error: 'forbidden' });
1157
1034
 
1158
1035
  const notesUser = String(req.body.notesUser || '').trim();
1159
-
1160
- const bookingId = `${req.uid}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
1161
-
1162
- // Create one reservation per item, linked by bookingId
1163
- const created = [];
1164
- for (const itemId of itemIds) {
1165
- const rid = await createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId);
1166
- created.push(rid.id || rid);
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;
1049
+ }
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);
1167
1087
  }
1168
1088
 
1169
- // Redirect to calendar with a success flag
1170
- return res.redirect('/equipment/calendar?requested=1');
1089
+ res.redirect(nconf.get('relative_path') + '/calendar?created=1&bookingId=' + bookingId);
1171
1090
  } catch (err) {
1172
- winston.error(err);
1173
- return res.status(500).send(err.message || 'Error');
1091
+ winston.error('[equipment-calendar] create error', err);
1092
+ res.status(500).json({ error: 'internal-error' });
1174
1093
  }
1175
1094
  }
1176
1095
 
@@ -1419,3 +1338,83 @@ function startPaymentTimeoutScheduler() {
1419
1338
  }
1420
1339
 
1421
1340
  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": "4.9.0",
3
+ "version": "5.0.1",
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.7.7",
28
+ "version": "0.8.1",
29
29
  "minver": "4.7.1"
30
30
  }
@@ -93,26 +93,11 @@ 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
- headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,dayGridWeek,timeGridWeek,listMonth,listWeek' },
97
- initialView: (window.EC_DEFAULT_VIEW || 'dayGridMonth'),
96
+ locale: 'fr',
97
+ headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,dayGridWeek,listMonth,listWeek' },
98
98
  selectable: canCreate,
99
99
  selectMirror: true,
100
100
  events,
101
- eventContent: function(arg) {
102
- const status = (arg.event.extendedProps && arg.event.extendedProps.status) || 'pending';
103
- const wrap = document.createElement('div');
104
- wrap.className = 'ec-event';
105
- const dot = document.createElement('span');
106
- dot.className = 'ec-dot';
107
- const title = document.createElement('span');
108
- title.className = 'ec-title';
109
- title.textContent = arg.event.title || '';
110
- wrap.appendChild(dot);
111
- wrap.appendChild(title);
112
- return { domNodes: [wrap] };
113
- },
114
- eventClassNames:function(arg){return (arg.event.classNames||[]);},
115
- eventDidMount:function(info){try{const s=info.event.extendedProps&&info.event.extendedProps.status;info.el.title=(s?('Statut: '+s+' — '):'')+info.event.title;}catch(e){}},
116
101
  dateClick: function (info) {
117
102
  if (!canCreate) return;
118
103
  const startMs = Date.UTC(info.date.getUTCFullYear(), info.date.getUTCMonth(), info.date.getUTCDate());
@@ -1,104 +1,49 @@
1
+
1
2
  <div class="acp-page-container">
2
3
  <div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
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>
4
+ <h1 class="mb-0">Réservations</h1>
5
+ <a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar">Paramètres</a>
8
6
  </div>
9
7
 
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>
8
+ {{{ if done }}}
9
+ <script>
10
+ if (window.app && window.app.alertSuccess) window.app.alertSuccess('Action effectuée');
11
+ </script>
12
+ {{{ end }}}
41
13
 
42
14
  {{{ if hasRows }}}
43
- <div class="table-responsive">
15
+ <div class="table-responsive mt-3">
44
16
  <table class="table table-striped align-middle">
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>
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>
58
18
  <tbody>
59
- {{{ each rows }}}
19
+ <!-- BEGIN rows -->
60
20
  <tr>
61
- <td><code>{rows.rid}</code></td>
21
+ <td><code>{rows.id}</code><br><small class="text-muted">{rows.bookingId}</small></td>
62
22
  <td>{rows.itemName}</td>
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>
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>
73
31
  </form>
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}">
32
+ <form class="d-inline" method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.id}/reject">
33
+ <input type="hidden" name="_csrf" value="{csrf}">
76
34
  <button class="btn btn-sm btn-warning" type="submit">Reject</button>
77
35
  </form>
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}">
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}">
80
38
  <button class="btn btn-sm btn-danger" type="submit">Delete</button>
81
39
  </form>
82
40
  </td>
83
41
  </tr>
84
- {{{ end }}}
42
+ <!-- END rows -->
85
43
  </tbody>
86
44
  </table>
87
45
  </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
-
101
46
  {{{ else }}}
102
- <div class="alert alert-info">Aucune réservation trouvée.</div>
47
+ <div class="alert alert-info mt-3">Aucune réservation.</div>
103
48
  {{{ end }}}
104
49
  </div>
@@ -35,17 +35,15 @@
35
35
  </div>
36
36
  </div>
37
37
 
38
-
39
38
  <div class="card card-body mb-3">
40
39
  <h5>Affichage calendrier</h5>
41
40
  <div class="mb-0">
42
41
  <label class="form-label">Vue par défaut</label>
43
42
  <select class="form-select" name="defaultView">
44
- <option value="dayGridMonth" {{{ if view_dayGridMonth }}}selected{{{ end }}}>Mois</option>
45
- <option value="dayGridWeek" {{{ if view_dayGridWeek }}}selected{{{ end }}}>Semaine</option>
46
- <option value="timeGridWeek" {{{ if view_timeGridWeek }}}selected{{{ end }}}>Semaine (grille)</option>
47
- <option value="listMonth" {{{ if view_listMonth }}}selected{{{ end }}}>Liste (mois)</option>
48
- <option value="listWeek" {{{ if view_listWeek }}}selected{{{ end }}}>Liste (semaine)</option>
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>
49
47
  </select>
50
48
  </div>
51
49
  </div>
@@ -1,24 +1,3 @@
1
-
2
- <style>
3
- /* Professional status styling */
4
- .fc .ec-event { display:flex; align-items:center; gap:.4rem; padding:.05rem .35rem; border-radius:.5rem; }
5
- .fc .ec-dot { width:.55rem; height:.55rem; border-radius:999px; display:inline-block; }
6
- .fc .ec-title { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
7
-
8
- .fc-event.ec-status-pending { border-color: rgba(108,117,125,.35); }
9
- .fc-event.ec-status-approved { border-color: rgba(13,110,253,.35); }
10
- .fc-event.ec-status-paid { border-color: rgba(25,135,84,.35); }
11
- .fc-event.ec-status-cancelled { opacity:.55; text-decoration: line-through; }
12
-
13
- .fc-event.ec-status-pending .ec-dot { background: #6c757d; }
14
- .fc-event.ec-status-approved .ec-dot { background: #0d6efd; }
15
- .fc-event.ec-status-paid .ec-dot { background: #198754; }
16
- .fc-event.ec-status-cancelled .ec-dot { background: #dc3545; }
17
-
18
- /* list view rows */
19
- .fc .fc-list-event.ec-status-cancelled { opacity:.6; text-decoration: line-through; }
20
- </style>
21
-
22
1
  <div class="equipment-calendar-page">
23
2
  <h1>Réservation de matériel</h1>
24
3
 
@@ -31,15 +10,7 @@
31
10
  {{{ end }}}
32
11
 
33
12
  <div class="card card-body mb-3">
34
-
35
- <div class="d-flex flex-wrap gap-2 align-items-center mb-2" id="ec-legend">
36
- <span class="badge text-bg-secondary"><i class="fa fa-hourglass-half me-1"></i>En attente</span>
37
- <span class="badge text-bg-primary"><i class="fa fa-check me-1"></i>Validée</span>
38
- <span class="badge text-bg-success"><i class="fa fa-check-double me-1"></i>Payée</span>
39
- <span class="badge text-bg-danger"><i class="fa fa-times me-1"></i>Annulée</span>
40
- </div>
41
-
42
- <div id="ec-calendar"></div>
13
+ <div id="ec-calendar"></div>
43
14
  </div>
44
15
 
45
16
  <!-- Modal de demande -->
@@ -72,8 +43,8 @@
72
43
  <label class="form-label">Matériel</label>
73
44
  <select class="form-select" id="ec-item-ids" name="itemIds" multiple required>
74
45
  <!-- BEGIN items -->
75
- <option value="{items.id}" data-price="{items.price}">{items.name} — {items.price} €</option>
76
- <!-- END items -->
46
+ <option value="{items.id}" data-price="{items.price}">{items.name} — {items.price} €</option>
47
+ <!-- END items -->
77
48
  </select>
78
49
  <div class="form-text">Tu peux sélectionner plusieurs matériels.</div>
79
50
  </div>
@@ -103,7 +74,6 @@
103
74
  </div>
104
75
 
105
76
  <script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js"></script>
106
- <script>window.EC_DEFAULT_VIEW = '{defaultView}';</script>
107
77
  <script src="{relative_path}/plugins/nodebb-plugin-equipment-calendar/js/client.js"></script>
108
78
 
109
79
  <script>
@@ -113,4 +83,10 @@
113
83
  window.EC_TZ = '{tz}';
114
84
  </script>
115
85
 
116
- <style>.ec-status-pending{opacity:.85}.ec-status-paid{font-weight:600}.ec-status-cancelled,.ec-status-expired{opacity:.6;text-decoration:line-through}</style>
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>