nodebb-plugin-equipment-calendar 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/library.js +232 -169
- 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
|
@@ -35,12 +35,6 @@ const crypto = require('crypto');
|
|
|
35
35
|
const plugin = {};
|
|
36
36
|
const SETTINGS_KEY = 'equipmentCalendar';
|
|
37
37
|
|
|
38
|
-
const EC_KEYS = {
|
|
39
|
-
reservationsZset: 'ec:reservations',
|
|
40
|
-
reservationKey: (id) => `ec:reservation:${id}`,
|
|
41
|
-
itemZset: (itemId) => `ec:item:${itemId}`,
|
|
42
|
-
};
|
|
43
|
-
|
|
44
38
|
const DEFAULT_SETTINGS = {
|
|
45
39
|
creatorGroups: 'registered-users',
|
|
46
40
|
approverGroup: 'administrators',
|
|
@@ -262,12 +256,21 @@ async function fetchHelloAssoItems(settings) {
|
|
|
262
256
|
|
|
263
257
|
function pushItem(it, tierName) {
|
|
264
258
|
if (!it) return;
|
|
265
|
-
const id = String(it.id || it.itemId || it.
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
259
|
+
const id = String(it.id || it.itemId || it.productId || it.code || it.slug || '').trim();
|
|
260
|
+
const baseName = String(it.name || it.label || it.title || '').trim();
|
|
261
|
+
const name = (tierName && baseName) ? `${tierName} — ${baseName}` : (baseName || tierName || id);
|
|
262
|
+
let amount = it.amount ?? it.price ?? it.unitPrice ?? it.totalAmount ?? it.initialAmount ?? it.minimumAmount;
|
|
263
|
+
if (amount && typeof amount === 'object') {
|
|
264
|
+
amount = amount.amount ?? amount.value ?? amount.total ?? amount.price ?? amount.cents ?? amount.centAmount ?? 0;
|
|
265
|
+
}
|
|
266
|
+
let price = 0;
|
|
267
|
+
if (typeof amount === 'number') price = amount;
|
|
268
|
+
else if (typeof amount === 'string') {
|
|
269
|
+
const s = amount.replace(',', '.').replace(/[^0-9.\-]/g, '');
|
|
270
|
+
price = parseFloat(s) || 0;
|
|
271
|
+
}
|
|
272
|
+
if (!id) return;
|
|
273
|
+
out.push({ id, name, price, location: '' });
|
|
271
274
|
}
|
|
272
275
|
|
|
273
276
|
// Try a few known layouts
|
|
@@ -304,7 +307,7 @@ async function getActiveItems() {
|
|
|
304
307
|
const items = (Array.isArray(rawItems) ? rawItems : []).map((it) => ({
|
|
305
308
|
id: String(it.id || '').trim(),
|
|
306
309
|
name: String(it.name || '').trim(),
|
|
307
|
-
price: Number(it.price || 0) || 0,
|
|
310
|
+
price: Number((it.price !== undefined ? it.price : it.priceCents) || 0) || 0,
|
|
308
311
|
active: true,
|
|
309
312
|
})).filter(it => it.id && it.name);
|
|
310
313
|
return items;
|
|
@@ -354,6 +357,8 @@ async function getBookingRids(bookingId) {
|
|
|
354
357
|
async function saveReservation(res) {
|
|
355
358
|
await db.setObject(resKey(res.id), res);
|
|
356
359
|
await db.sortedSetAdd(itemIndexKey(res.itemId), res.startMs, res.id);
|
|
360
|
+
// Global index for ACP listing
|
|
361
|
+
await db.sortedSetAdd('equipmentCalendar:reservations', res.startMs, res.id);
|
|
357
362
|
}
|
|
358
363
|
|
|
359
364
|
async function getReservation(id) {
|
|
@@ -546,28 +551,22 @@ function verifyWebhook(req, secret) {
|
|
|
546
551
|
|
|
547
552
|
// --- Rendering helpers ---
|
|
548
553
|
function toEvent(res, item, requesterName, canSeeRequester) {
|
|
549
|
-
const start = DateTime.fromMillis(res.startMs).
|
|
550
|
-
const end = DateTime.fromMillis(res.endMs).
|
|
551
|
-
|
|
552
|
-
let icon = '⏳';
|
|
554
|
+
const start = DateTime.fromMillis(res.startMs).toISODate();
|
|
555
|
+
const end = DateTime.fromMillis(res.endMs).toISODate();
|
|
553
556
|
let className = 'ec-status-pending';
|
|
554
|
-
if (res.status === 'approved_waiting_payment'
|
|
555
|
-
if (res.status === 'paid_validated'
|
|
556
|
-
if (res.status === 'rejected' || res.status === 'cancelled') {
|
|
557
|
-
|
|
558
|
-
const titleParts = [icon, item ? item.name : res.itemId];
|
|
557
|
+
if (res.status === 'approved_waiting_payment' || res.status === 'approved') { className = 'ec-status-approved'; }
|
|
558
|
+
if (res.status === 'paid_validated' || res.status === 'paid') { className = 'ec-status-paid'; }
|
|
559
|
+
if (res.status === 'rejected' || res.status === 'cancelled') { className = 'ec-status-cancelled'; }
|
|
560
|
+
const titleParts = [item ? item.name : res.itemId];
|
|
559
561
|
if (canSeeRequester && requesterName) titleParts.push(`- ${requesterName}`);
|
|
560
562
|
return {
|
|
561
563
|
id: res.id,
|
|
562
564
|
title: titleParts.join(' '),
|
|
563
565
|
start,
|
|
564
566
|
end,
|
|
565
|
-
allDay:
|
|
566
|
-
className,
|
|
567
|
-
extendedProps: {
|
|
568
|
-
status: res.status,
|
|
569
|
-
itemId: res.itemId,
|
|
570
|
-
},
|
|
567
|
+
allDay: true,
|
|
568
|
+
classNames: [className],
|
|
569
|
+
extendedProps: { status: res.status, itemId: res.itemId },
|
|
571
570
|
};
|
|
572
571
|
}
|
|
573
572
|
|
|
@@ -653,9 +652,6 @@ plugin.init = async function (params) {
|
|
|
653
652
|
|
|
654
653
|
// Page routes are attached via filter:router.page as well, but we also add directly to be safe:
|
|
655
654
|
router.get('/equipment/calendar', middleware.buildHeader, renderCalendarPage);
|
|
656
|
-
router.get('/calendar', middleware.buildHeader, renderCalendarPage);
|
|
657
|
-
router.get('/api/calendar', middleware.buildHeader, renderCalendarPage);
|
|
658
|
-
|
|
659
655
|
router.get('/equipment/approvals', middleware.buildHeader, renderApprovalsPage);
|
|
660
656
|
|
|
661
657
|
router.post('/equipment/reservations/create', middleware.applyCSRF, handleCreateReservation);
|
|
@@ -690,6 +686,91 @@ plugin.addAdminRoutes = async function (params) {
|
|
|
690
686
|
};
|
|
691
687
|
|
|
692
688
|
|
|
689
|
+
async function renderAdminReservationsPage(req, res) {
|
|
690
|
+
if (!(await ensureIsAdmin(req, res))) return;
|
|
691
|
+
|
|
692
|
+
const settings = await getSettings();
|
|
693
|
+
const items = await getActiveItems(settings);
|
|
694
|
+
const itemById = {};
|
|
695
|
+
items.forEach(it => { itemById[it.id] = it; });
|
|
696
|
+
|
|
697
|
+
const status = String(req.query.status || ''); // optional filter
|
|
698
|
+
const itemId = String(req.query.itemId || ''); // optional filter
|
|
699
|
+
const q = String(req.query.q || '').trim(); // search rid/user/notes
|
|
700
|
+
|
|
701
|
+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
|
702
|
+
const perPage = Math.min(100, Math.max(10, parseInt(req.query.perPage, 10) || 50));
|
|
703
|
+
|
|
704
|
+
const allRids = await db.getSortedSetRevRange('equipmentCalendar:reservations', 0, -1);
|
|
705
|
+
const totalAll = allRids.length;
|
|
706
|
+
|
|
707
|
+
const rows = [];
|
|
708
|
+
for (const rid of allRids) {
|
|
709
|
+
// eslint-disable-next-line no-await-in-loop
|
|
710
|
+
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
711
|
+
if (!r || !r.rid) continue;
|
|
712
|
+
|
|
713
|
+
if (status && String(r.status) !== status) continue;
|
|
714
|
+
if (itemId && String(r.itemId) !== itemId) continue;
|
|
715
|
+
|
|
716
|
+
const notes = String(r.notesUser || '');
|
|
717
|
+
const ridStr = String(r.rid || rid);
|
|
718
|
+
if (q) {
|
|
719
|
+
const hay = (ridStr + ' ' + String(r.uid || '') + ' ' + notes).toLowerCase();
|
|
720
|
+
if (!hay.includes(q.toLowerCase())) continue;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const startMs = parseInt(r.startMs, 10) || 0;
|
|
724
|
+
const endMs = parseInt(r.endMs, 10) || 0;
|
|
725
|
+
const createdAt = parseInt(r.createdAt, 10) || 0;
|
|
726
|
+
|
|
727
|
+
rows.push({
|
|
728
|
+
rid: ridStr,
|
|
729
|
+
itemId: String(r.itemId || ''),
|
|
730
|
+
itemName: (itemById[String(r.itemId || '')] && itemById[String(r.itemId || '')].name) || String(r.itemId || ''),
|
|
731
|
+
uid: String(r.uid || ''),
|
|
732
|
+
status: String(r.status || ''),
|
|
733
|
+
start: startMs ? new Date(startMs).toISOString() : '',
|
|
734
|
+
end: endMs ? new Date(endMs).toISOString() : '',
|
|
735
|
+
createdAt: createdAt ? new Date(createdAt).toISOString() : '',
|
|
736
|
+
notesUser: notes,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const total = rows.length;
|
|
741
|
+
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
|
742
|
+
const safePage = Math.min(page, totalPages);
|
|
743
|
+
const startIndex = (safePage - 1) * perPage;
|
|
744
|
+
const pageRows = rows.slice(startIndex, startIndex + perPage);
|
|
745
|
+
|
|
746
|
+
const itemOptions = [{ id: '', name: 'Tous' }].concat(items.map(i => ({ id: i.id, name: i.name })));
|
|
747
|
+
const statusOptions = [
|
|
748
|
+
{ id: '', name: 'Tous' },
|
|
749
|
+
{ id: 'pending', name: 'pending' },
|
|
750
|
+
{ id: 'approved', name: 'approved' },
|
|
751
|
+
{ id: 'paid', name: 'paid' },
|
|
752
|
+
{ id: 'rejected', name: 'rejected' },
|
|
753
|
+
{ id: 'cancelled', name: 'cancelled' },
|
|
754
|
+
];
|
|
755
|
+
|
|
756
|
+
res.render('admin/plugins/equipment-calendar-reservations', {
|
|
757
|
+
title: 'Equipment Calendar - Réservations',
|
|
758
|
+
settings,
|
|
759
|
+
rows: pageRows,
|
|
760
|
+
hasRows: pageRows.length > 0,
|
|
761
|
+
itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
|
|
762
|
+
statusOptions: statusOptions.map(o => ({ ...o, selected: o.id === status })),
|
|
763
|
+
q,
|
|
764
|
+
page: safePage,
|
|
765
|
+
perPage,
|
|
766
|
+
total,
|
|
767
|
+
totalAll,
|
|
768
|
+
totalPages,
|
|
769
|
+
prevPage: safePage > 1 ? safePage - 1 : 0,
|
|
770
|
+
nextPage: safePage < totalPages ? safePage + 1 : 0,
|
|
771
|
+
actionBase: '/admin/plugins/equipment-calendar/reservations',
|
|
772
|
+
});
|
|
773
|
+
}
|
|
693
774
|
|
|
694
775
|
async function handleAdminApprove(req, res) {
|
|
695
776
|
if (!(await ensureIsAdmin(req, res))) return;
|
|
@@ -849,6 +930,86 @@ async function handleHelloAssoCallback(req, res) {
|
|
|
849
930
|
});
|
|
850
931
|
}
|
|
851
932
|
|
|
933
|
+
async function renderCalendarPage(req, res) {
|
|
934
|
+
const settings = await getSettings();
|
|
935
|
+
const items = await getActiveItems(settings);
|
|
936
|
+
|
|
937
|
+
const tz = settings.timezone || 'Europe/Paris';
|
|
938
|
+
|
|
939
|
+
// Determine range to render
|
|
940
|
+
const now = DateTime.now().setZone(tz);
|
|
941
|
+
const view = String(req.query.view || settings.defaultView || 'dayGridMonth');
|
|
942
|
+
const startQ = req.query.start;
|
|
943
|
+
const endQ = req.query.end;
|
|
944
|
+
|
|
945
|
+
let start, end;
|
|
946
|
+
if (startQ && endQ) {
|
|
947
|
+
const r = clampRange(String(startQ), String(endQ), tz);
|
|
948
|
+
start = r.start;
|
|
949
|
+
end = r.end;
|
|
950
|
+
} else {
|
|
951
|
+
// Default to current month range
|
|
952
|
+
start = now.startOf('month');
|
|
953
|
+
end = now.endOf('month').plus({ days: 1 });
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Load reservations for ALL items within range (so we can build availability client-side without extra requests)
|
|
957
|
+
const allReservations = [];
|
|
958
|
+
for (const it of items) {
|
|
959
|
+
// eslint-disable-next-line no-await-in-loop
|
|
960
|
+
const resForItem = await listReservationsForRange(it.id, start.toMillis(), end.toMillis());
|
|
961
|
+
for (const r of resForItem) allReservations.push(r);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const showRequesterToAll = String(settings.showRequesterToAll) === '1';
|
|
965
|
+
const isApprover = req.uid ? await canApprove(req.uid, settings) : false;
|
|
966
|
+
|
|
967
|
+
const requesterUids = Array.from(new Set(allReservations.map(r => r.uid))).filter(Boolean);
|
|
968
|
+
const users = requesterUids.length ? await user.getUsersData(requesterUids) : [];
|
|
969
|
+
const nameByUid = {};
|
|
970
|
+
(users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
|
|
971
|
+
|
|
972
|
+
const itemById = {};
|
|
973
|
+
items.forEach(it => { itemById[it.id] = it; });
|
|
974
|
+
|
|
975
|
+
const events = allReservations
|
|
976
|
+
.filter(r => r.status !== 'rejected' && r.status !== 'cancelled')
|
|
977
|
+
.map(r => {
|
|
978
|
+
const item = itemById[r.itemId];
|
|
979
|
+
// Include item name in title so "all items" view is readable
|
|
980
|
+
const requesterName = nameByUid[r.uid] || '';
|
|
981
|
+
return toEvent(r, item, requesterName, isApprover || showRequesterToAll);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
const canUserCreate = req.uid ? await canCreate(req.uid, settings) : false;
|
|
985
|
+
|
|
986
|
+
// We expose minimal reservation data for availability checks in the modal
|
|
987
|
+
const blocks = allReservations
|
|
988
|
+
.filter(r => statusBlocksItem(r.status))
|
|
989
|
+
.map(r => ({
|
|
990
|
+
itemId: r.itemId,
|
|
991
|
+
startMs: r.startMs,
|
|
992
|
+
endMs: r.endMs,
|
|
993
|
+
status: r.status,
|
|
994
|
+
}));
|
|
995
|
+
|
|
996
|
+
res.render('equipment-calendar/calendar', {
|
|
997
|
+
title: 'Réservation de matériel',
|
|
998
|
+
items,
|
|
999
|
+
view,
|
|
1000
|
+
tz,
|
|
1001
|
+
startISO: start.toISO(),
|
|
1002
|
+
endISO: end.toISO(),
|
|
1003
|
+
initialDateISO: start.toISODate(),
|
|
1004
|
+
canCreate: canUserCreate,
|
|
1005
|
+
canCreateJs: canUserCreate ? 'true' : 'false',
|
|
1006
|
+
isApprover,
|
|
1007
|
+
// events are base64 encoded to avoid template escaping issues
|
|
1008
|
+
eventsB64: Buffer.from(JSON.stringify(events), 'utf8').toString('base64'),
|
|
1009
|
+
blocksB64: Buffer.from(JSON.stringify(blocks), 'utf8').toString('base64'),
|
|
1010
|
+
itemsB64: Buffer.from(JSON.stringify(items.map(i => ({ id: i.id, name: i.name, location: i.location }))), 'utf8').toString('base64'),
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
852
1013
|
|
|
853
1014
|
|
|
854
1015
|
// --- Approvals page ---
|
|
@@ -1009,70 +1170,52 @@ async function createReservationForItem(req, res, settings, itemId, startMs, end
|
|
|
1009
1170
|
|
|
1010
1171
|
async function handleCreateReservation(req, res) {
|
|
1011
1172
|
try {
|
|
1012
|
-
if (!req.uid) return res.status(403).json({ error: 'not-logged-in' });
|
|
1013
|
-
|
|
1014
1173
|
const settings = await getSettings();
|
|
1015
|
-
|
|
1016
|
-
|
|
1174
|
+
if (!req.uid || !(await canCreate(req.uid, settings))) {
|
|
1175
|
+
return helpers.notAllowed(req, res);
|
|
1176
|
+
}
|
|
1017
1177
|
|
|
1018
|
-
const
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1178
|
+
const itemIdsRaw = req.body.itemIds || req.body.itemId || '';
|
|
1179
|
+
const itemIds = (Array.isArray(itemIdsRaw) ? itemIdsRaw : String(itemIdsRaw))
|
|
1180
|
+
.split(',')
|
|
1181
|
+
.map(s => String(s).trim())
|
|
1182
|
+
.filter(Boolean);
|
|
1183
|
+
|
|
1184
|
+
if (!itemIds.length) {
|
|
1185
|
+
return res.status(400).send('itemIds required');
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const tz = settings.timezone || 'Europe/Paris';
|
|
1189
|
+
const start = DateTime.fromISO(String(req.body.start || ''), { zone: tz });
|
|
1190
|
+
const end = DateTime.fromISO(String(req.body.end || ''), { zone: tz });
|
|
1191
|
+
if (!start.isValid || !end.isValid) return res.status(400).send('Invalid dates');
|
|
1192
|
+
|
|
1193
|
+
// Normalize to whole days (no hours). End is exclusive.
|
|
1194
|
+
const startDay = start.startOf('day');
|
|
1195
|
+
let endDay = end.startOf('day');
|
|
1196
|
+
if (endDay <= startDay) {
|
|
1197
|
+
endDay = startDay.plus({ days: 1 });
|
|
1032
1198
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
const it = byId[itemId];
|
|
1047
|
-
const id = generateId();
|
|
1048
|
-
const unitPrice = Number(it.price || 0) || 0;
|
|
1049
|
-
const total = unitPrice * days;
|
|
1050
|
-
|
|
1051
|
-
const reservation = {
|
|
1052
|
-
id,
|
|
1053
|
-
bookingId,
|
|
1054
|
-
uid: req.uid,
|
|
1055
|
-
itemId,
|
|
1056
|
-
itemName: it.name,
|
|
1057
|
-
unitPrice,
|
|
1058
|
-
days,
|
|
1059
|
-
total,
|
|
1060
|
-
notesUser,
|
|
1061
|
-
startMs,
|
|
1062
|
-
endMs,
|
|
1063
|
-
status: 'pending',
|
|
1064
|
-
createdAt: Date.now(),
|
|
1065
|
-
};
|
|
1066
|
-
|
|
1067
|
-
await db.setObject(EC_KEYS.reservationKey(id), reservation);
|
|
1068
|
-
await db.sortedSetAdd(EC_KEYS.reservationsZset, startMs, id);
|
|
1069
|
-
await db.sortedSetAdd(EC_KEYS.itemZset(itemId), startMs, id);
|
|
1199
|
+
|
|
1200
|
+
const startMs = startDay.toMillis();
|
|
1201
|
+
const endMs = endDay.toMillis();
|
|
1202
|
+
|
|
1203
|
+
const notesUser = String(req.body.notesUser || '').trim();
|
|
1204
|
+
|
|
1205
|
+
const bookingId = `${req.uid}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
|
1206
|
+
|
|
1207
|
+
// Create one reservation per item, linked by bookingId
|
|
1208
|
+
const created = [];
|
|
1209
|
+
for (const itemId of itemIds) {
|
|
1210
|
+
const rid = await createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId);
|
|
1211
|
+
created.push(rid);
|
|
1070
1212
|
}
|
|
1071
1213
|
|
|
1072
|
-
|
|
1214
|
+
// Redirect to calendar with a success flag
|
|
1215
|
+
return res.redirect('/equipment/calendar?requested=1');
|
|
1073
1216
|
} catch (err) {
|
|
1074
|
-
winston.error(
|
|
1075
|
-
res.status(500).
|
|
1217
|
+
winston.error(err);
|
|
1218
|
+
return res.status(500).send(err.message || 'Error');
|
|
1076
1219
|
}
|
|
1077
1220
|
}
|
|
1078
1221
|
|
|
@@ -1321,83 +1464,3 @@ function startPaymentTimeoutScheduler() {
|
|
|
1321
1464
|
}
|
|
1322
1465
|
|
|
1323
1466
|
module.exports = plugin;
|
|
1324
|
-
|
|
1325
|
-
async function handleAdminReservationAction(req, res, action) {
|
|
1326
|
-
const isAdmin = await user.isAdminOrGlobalMod(req.uid);
|
|
1327
|
-
if (!isAdmin) return res.status(403).json({ error: 'forbidden' });
|
|
1328
|
-
|
|
1329
|
-
const id = String(req.params.id || '');
|
|
1330
|
-
const key = EC_KEYS.reservationKey(id);
|
|
1331
|
-
const r = await db.getObject(key);
|
|
1332
|
-
if (!r || !r.id) return res.status(404).json({ error: 'not-found' });
|
|
1333
|
-
|
|
1334
|
-
if (action === 'delete') {
|
|
1335
|
-
await db.delete(key);
|
|
1336
|
-
await db.sortedSetRemove(EC_KEYS.reservationsZset, id);
|
|
1337
|
-
if (r.itemId) await db.sortedSetRemove(EC_KEYS.itemZset(r.itemId), id);
|
|
1338
|
-
return res.redirect(nconf.get('relative_path') + '/admin/plugins/equipment-calendar/reservations?done=1');
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
const status = action === 'approve' ? 'approved' : 'cancelled';
|
|
1342
|
-
await db.setObjectField(key, 'status', status);
|
|
1343
|
-
return res.redirect(nconf.get('relative_path') + '/admin/plugins/equipment-calendar/reservations?done=1');
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
async function renderAdminReservationsPage(req, res) {
|
|
1347
|
-
const isAdmin = await user.isAdminOrGlobalMod(req.uid);
|
|
1348
|
-
if (!isAdmin) return res.status(403).render('403', {});
|
|
1349
|
-
const ids = await db.getSortedSetRevRange(EC_KEYS.reservationsZset, 0, 199);
|
|
1350
|
-
const rowsRaw = await db.getObjects(ids.map(id => EC_KEYS.reservationKey(id)));
|
|
1351
|
-
const rows = (rowsRaw || []).filter(Boolean).map((r) => {
|
|
1352
|
-
const startMs = parseInt(r.startMs, 10) || 0;
|
|
1353
|
-
const endMs = parseInt(r.endMs, 10) || 0;
|
|
1354
|
-
return {
|
|
1355
|
-
id: r.id,
|
|
1356
|
-
bookingId: r.bookingId || '',
|
|
1357
|
-
itemName: r.itemName || r.itemId,
|
|
1358
|
-
startIso: startMs ? new Date(startMs).toISOString().slice(0,10) : '',
|
|
1359
|
-
endIso: endMs ? new Date(endMs - 24*60*60*1000).toISOString().slice(0,10) : '',
|
|
1360
|
-
days: parseInt(r.days, 10) || 1,
|
|
1361
|
-
status: r.status || 'pending',
|
|
1362
|
-
total: (Number(r.total || 0) || 0),
|
|
1363
|
-
};
|
|
1364
|
-
});
|
|
1365
|
-
res.render('admin/plugins/equipment-calendar-reservations', { title: 'Réservations', rows, hasRows: rows.length>0, csrf: req.csrfToken(), done: String(req.query.done||'')==='1' });
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
async function getCalendarEvents() {
|
|
1369
|
-
const ids = await db.getSortedSetRange(EC_KEYS.reservationsZset, 0, -1);
|
|
1370
|
-
const rows = await db.getObjects(ids.map(id => EC_KEYS.reservationKey(id)));
|
|
1371
|
-
return (rows || []).filter(Boolean).map((r) => {
|
|
1372
|
-
const startMs = parseInt(r.startMs, 10) || 0;
|
|
1373
|
-
const endMs = parseInt(r.endMs, 10) || 0;
|
|
1374
|
-
const status = r.status || 'pending';
|
|
1375
|
-
return {
|
|
1376
|
-
id: r.id,
|
|
1377
|
-
title: (r.itemName || 'Réservation'),
|
|
1378
|
-
start: startMs ? new Date(startMs).toISOString().slice(0,10) : null,
|
|
1379
|
-
end: endMs ? new Date(endMs).toISOString().slice(0,10) : null,
|
|
1380
|
-
allDay: true,
|
|
1381
|
-
classNames: ['ec-status-' + status],
|
|
1382
|
-
extendedProps: { status, bookingId: r.bookingId || '' },
|
|
1383
|
-
};
|
|
1384
|
-
});
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
async function renderCalendarPage(req, res) {
|
|
1388
|
-
const settings = await getSettings();
|
|
1389
|
-
const canCreate = req.uid ? await isUserInAnyGroups(req.uid, settings.creatorGroups) : false;
|
|
1390
|
-
const isApprover = req.uid ? await isUserInAnyGroups(req.uid, settings.approverGroup) : false;
|
|
1391
|
-
const items = await getActiveItems();
|
|
1392
|
-
const events = await getCalendarEvents();
|
|
1393
|
-
|
|
1394
|
-
res.render('equipment-calendar/calendar', {
|
|
1395
|
-
title: 'Calendrier',
|
|
1396
|
-
canCreate,
|
|
1397
|
-
isApprover,
|
|
1398
|
-
items,
|
|
1399
|
-
eventsJson: JSON.stringify(events),
|
|
1400
|
-
defaultView: settings.defaultView || 'dayGridMonth',
|
|
1401
|
-
created: String(req.query.created || '') === '1',
|
|
1402
|
-
});
|
|
1403
|
-
}
|
package/package.json
CHANGED
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>
|