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 +235 -159
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/js/client.js +24 -97
- 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 +2 -10
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
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1175
|
+
if (!req.uid || !(await canCreate(req.uid, settings))) {
|
|
1176
|
+
return helpers.notAllowed(req, res);
|
|
1177
|
+
}
|
|
1034
1178
|
|
|
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;
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
if (
|
|
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
|
-
|
|
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(
|
|
1092
|
-
res.status(500).
|
|
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
|
-
|
|
1347
|
-
|
|
1348
|
-
const
|
|
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
|
|
1407
|
-
const
|
|
1408
|
-
const
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
res.
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
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
package/plugin.json
CHANGED
package/public/js/client.js
CHANGED
|
@@ -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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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">
|
|
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}
|
|
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>
|