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 +232 -186
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/js/client.js +3 -2
- package/public/templates/admin/plugins/equipment-calendar-reservations.tpl +81 -26
- package/public/templates/admin/plugins/equipment-calendar.tpl +0 -13
- package/public/templates/equipment-calendar/calendar.tpl +4 -3
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.
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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).
|
|
567
|
-
const end = DateTime.fromMillis(res.endMs).
|
|
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'
|
|
572
|
-
if (res.status === 'paid_validated'
|
|
573
|
-
if (res.status === 'rejected' || res.status === 'cancelled') {
|
|
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:
|
|
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
|
-
|
|
1033
|
-
|
|
1174
|
+
if (!req.uid || !(await canCreate(req.uid, settings))) {
|
|
1175
|
+
return helpers.notAllowed(req, res);
|
|
1176
|
+
}
|
|
1034
1177
|
|
|
1035
|
-
const
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
if (
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1214
|
+
// Redirect to calendar with a success flag
|
|
1215
|
+
return res.redirect('/equipment/calendar?requested=1');
|
|
1090
1216
|
} catch (err) {
|
|
1091
|
-
winston.error(
|
|
1092
|
-
res.status(500).
|
|
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
package/plugin.json
CHANGED
package/public/js/client.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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">
|
|
5
|
-
<
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
43
|
+
<div class="table-responsive">
|
|
16
44
|
<table class="table table-striped align-middle">
|
|
17
|
-
<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
|
-
|
|
59
|
+
{{{ each rows }}}
|
|
20
60
|
<tr>
|
|
21
|
-
<td><code>{rows.
|
|
61
|
+
<td><code>{rows.rid}</code></td>
|
|
22
62
|
<td>{rows.itemName}</td>
|
|
23
|
-
<td>{rows.
|
|
24
|
-
<td>{rows.
|
|
25
|
-
<td><
|
|
26
|
-
<td>{rows.
|
|
27
|
-
<td
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
33
|
-
<input type="hidden" name="_csrf" value="{
|
|
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
|
|
37
|
-
<input type="hidden" name="_csrf" value="{
|
|
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
|
-
|
|
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
|
-
|
|
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>
|