nodebb-plugin-equipment-calendar 0.4.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/library.js +691 -5
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/js/client.js +9 -5
- package/public/templates/admin/plugins/equipment-calendar-reservations.tpl +104 -0
- package/public/templates/admin/plugins/equipment-calendar.tpl +79 -2
- package/public/templates/equipment-calendar/calendar.tpl +1 -1
- package/public/templates/equipment-calendar/payment-return.tpl +11 -5
package/library.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// Node 18+ has global fetch; fallback to undici if needed
|
|
4
|
+
let fetchFn = global.fetch;
|
|
5
|
+
try { if (!fetchFn) { fetchFn = require('undici').fetch; } } catch (e) {}
|
|
6
|
+
|
|
3
7
|
const db = require.main.require('./src/database');
|
|
4
8
|
const meta = require.main.require('./src/meta');
|
|
5
9
|
const groups = require.main.require('./src/groups');
|
|
@@ -25,6 +29,12 @@ const DEFAULT_SETTINGS = {
|
|
|
25
29
|
notifyGroup: 'administrators',
|
|
26
30
|
// JSON array of items: [{ "id": "cam1", "name": "Caméra A", "priceCents": 5000, "location": "Stock A", "active": true }]
|
|
27
31
|
itemsJson: '[]',
|
|
32
|
+
itemsSource: 'manual',
|
|
33
|
+
ha_itemsFormType: '',
|
|
34
|
+
ha_itemsFormSlug: '',
|
|
35
|
+
ha_locationMapJson: '{}',
|
|
36
|
+
ha_calendarItemNamePrefix: 'Location matériel',
|
|
37
|
+
paymentTimeoutMinutes: 10,
|
|
28
38
|
// HelloAsso
|
|
29
39
|
ha_clientId: '',
|
|
30
40
|
ha_clientSecret: '',
|
|
@@ -38,6 +48,196 @@ const DEFAULT_SETTINGS = {
|
|
|
38
48
|
showRequesterToAll: '0', // 0/1
|
|
39
49
|
};
|
|
40
50
|
|
|
51
|
+
|
|
52
|
+
function parseLocationMap(locationMapJson) {
|
|
53
|
+
try {
|
|
54
|
+
const obj = JSON.parse(locationMapJson || '{}');
|
|
55
|
+
return (obj && typeof obj === 'object') ? obj : {};
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let haTokenCache = null; // { accessToken, refreshToken, expMs }
|
|
62
|
+
|
|
63
|
+
async function getHelloAssoAccessToken(settings) {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
if (haTokenCache && haTokenCache.accessToken && haTokenCache.expMs && now < haTokenCache.expMs - 30_000) {
|
|
66
|
+
return haTokenCache.accessToken;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const tokenKey = 'equipmentCalendar:ha:token';
|
|
70
|
+
let stored = null;
|
|
71
|
+
try {
|
|
72
|
+
stored = await db.getObject(tokenKey);
|
|
73
|
+
} catch (e) {}
|
|
74
|
+
|
|
75
|
+
// If refresh token exists and not expired locally, try refresh flow first
|
|
76
|
+
const canRefresh = stored && stored.refresh_token;
|
|
77
|
+
const useRefresh = canRefresh && stored.refresh_expires_at && now < parseInt(stored.refresh_expires_at, 10);
|
|
78
|
+
|
|
79
|
+
const formBody = new URLSearchParams();
|
|
80
|
+
if (useRefresh) {
|
|
81
|
+
formBody.set('grant_type', 'refresh_token');
|
|
82
|
+
formBody.set('refresh_token', stored.refresh_token);
|
|
83
|
+
} else {
|
|
84
|
+
formBody.set('grant_type', 'client_credentials');
|
|
85
|
+
formBody.set('client_id', String(settings.ha_clientId || ''));
|
|
86
|
+
formBody.set('client_secret', String(settings.ha_clientSecret || ''));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const resp = await fetchFn('https://api.helloasso.com/oauth2/token', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
92
|
+
body: formBody.toString(),
|
|
93
|
+
});
|
|
94
|
+
if (!resp.ok) {
|
|
95
|
+
const t = await resp.text();
|
|
96
|
+
throw new Error(`HelloAsso token error: ${resp.status} ${t}`);
|
|
97
|
+
}
|
|
98
|
+
const json = await resp.json();
|
|
99
|
+
const accessToken = json.access_token;
|
|
100
|
+
const refreshToken = json.refresh_token;
|
|
101
|
+
|
|
102
|
+
const expiresIn = parseInt(json.expires_in, 10) || 0;
|
|
103
|
+
const expMs = now + (expiresIn * 1000);
|
|
104
|
+
|
|
105
|
+
// Per docs, refresh token is valid ~30 days and rotates; keep a conservative expiry (29 days)
|
|
106
|
+
const refreshExpMs = now + (29 * 24 * 60 * 60 * 1000);
|
|
107
|
+
|
|
108
|
+
haTokenCache = { accessToken, refreshToken, expMs };
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await db.setObject(tokenKey, {
|
|
112
|
+
refresh_token: refreshToken || stored && stored.refresh_token || '',
|
|
113
|
+
refresh_expires_at: String(refreshExpMs),
|
|
114
|
+
});
|
|
115
|
+
} catch (e) {}
|
|
116
|
+
|
|
117
|
+
return accessToken;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async function createHelloAssoCheckoutIntent(settings, bookingId, reservations) {
|
|
122
|
+
const org = String(settings.ha_organizationSlug || '').trim();
|
|
123
|
+
if (!org) throw new Error('HelloAsso organization slug missing');
|
|
124
|
+
|
|
125
|
+
const baseUrl = (meta && meta.config && meta.config.url) ? meta.config.url.replace(/\/$/, '') : '';
|
|
126
|
+
const backUrl = baseUrl + '/equipment/calendar';
|
|
127
|
+
const returnUrl = baseUrl + `/equipment/helloasso/callback?type=return&bookingId=${encodeURIComponent(bookingId)}`;
|
|
128
|
+
const errorUrl = baseUrl + `/equipment/helloasso/callback?type=error&bookingId=${encodeURIComponent(bookingId)}`;
|
|
129
|
+
|
|
130
|
+
const items = await getActiveItems(settings);
|
|
131
|
+
const byId = {};
|
|
132
|
+
items.forEach(i => { byId[i.id] = i; });
|
|
133
|
+
|
|
134
|
+
const names = reservations.map(r => (byId[r.itemId] && byId[r.itemId].name) || r.itemId);
|
|
135
|
+
const totalAmount = reservations.reduce((sum, r) => {
|
|
136
|
+
const price = (byId[r.itemId] && parseInt(byId[r.itemId].priceCents, 10)) || 0;
|
|
137
|
+
return sum + (Number.isFinite(price) ? price : 0);
|
|
138
|
+
}, 0);
|
|
139
|
+
|
|
140
|
+
if (!totalAmount || totalAmount <= 0) {
|
|
141
|
+
throw new Error('Montant total invalide (prix manquant sur les items).');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const body = {
|
|
145
|
+
totalAmount,
|
|
146
|
+
initialAmount: totalAmount,
|
|
147
|
+
itemName: `${String(settings.ha_calendarItemNamePrefix || 'Location matériel')}: ${names.join(', ')}`.slice(0, 250),
|
|
148
|
+
backUrl,
|
|
149
|
+
errorUrl,
|
|
150
|
+
returnUrl,
|
|
151
|
+
containsDonation: false,
|
|
152
|
+
metadata: {
|
|
153
|
+
bookingId,
|
|
154
|
+
rids: reservations.map(r => r.rid || r.id),
|
|
155
|
+
uid: reservations[0] && reservations[0].uid,
|
|
156
|
+
startMs: reservations[0] && reservations[0].startMs,
|
|
157
|
+
endMs: reservations[0] && reservations[0].endMs,
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const token = await getHelloAssoAccessToken(settings);
|
|
162
|
+
const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
|
|
163
|
+
const resp = await fetchFn(url, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: {
|
|
166
|
+
accept: 'application/json',
|
|
167
|
+
'content-type': 'application/json',
|
|
168
|
+
authorization: `Bearer ${token}`,
|
|
169
|
+
},
|
|
170
|
+
body: JSON.stringify(body),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!resp.ok) {
|
|
174
|
+
const t = await resp.text();
|
|
175
|
+
throw new Error(`HelloAsso checkout-intents error: ${resp.status} ${t}`);
|
|
176
|
+
}
|
|
177
|
+
return await resp.json();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function fetchHelloAssoCheckoutIntent(settings, checkoutIntentId) {
|
|
181
|
+
const org = String(settings.ha_organizationSlug || '').trim();
|
|
182
|
+
const token = await getHelloAssoAccessToken(settings);
|
|
183
|
+
const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents/${encodeURIComponent(checkoutIntentId)}`;
|
|
184
|
+
const resp = await fetchFn(url, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' } });
|
|
185
|
+
if (!resp.ok) {
|
|
186
|
+
const t = await resp.text();
|
|
187
|
+
throw new Error(`HelloAsso checkout-intents GET error: ${resp.status} ${t}`);
|
|
188
|
+
}
|
|
189
|
+
return await resp.json();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isCheckoutPaid(checkout) {
|
|
193
|
+
const payments = (checkout && (checkout.payments || (checkout.order && checkout.order.payments))) || [];
|
|
194
|
+
const list = Array.isArray(payments) ? payments : [];
|
|
195
|
+
const states = list.map(p => String(p.state || p.status || p.paymentState || '').toLowerCase());
|
|
196
|
+
if (states.some(s => ['authorized', 'paid', 'succeeded', 'success'].includes(s))) return true;
|
|
197
|
+
|
|
198
|
+
const op = String(checkout && (checkout.state || checkout.operationState || '')).toLowerCase();
|
|
199
|
+
if (['authorized', 'paid', 'succeeded'].includes(op)) return true;
|
|
200
|
+
|
|
201
|
+
if (checkout && (checkout.order || checkout.orderId)) {
|
|
202
|
+
if (list.length) return true;
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function fetchHelloAssoItems(settings) {
|
|
208
|
+
const org = String(settings.ha_organizationSlug || '').trim();
|
|
209
|
+
const formType = String(settings.ha_itemsFormType || '').trim();
|
|
210
|
+
const formSlug = String(settings.ha_itemsFormSlug || '').trim();
|
|
211
|
+
if (!org || !formType || !formSlug) return [];
|
|
212
|
+
|
|
213
|
+
const cacheKey = `equipmentCalendar:ha:items:${org}:${formType}:${formSlug}`;
|
|
214
|
+
const cache = await db.getObject(cacheKey);
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
if (cache && cache.payload && cache.expiresAt && now < parseInt(cache.expiresAt, 10)) {
|
|
217
|
+
try {
|
|
218
|
+
return JSON.parse(cache.payload);
|
|
219
|
+
} catch (e) {}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const token = await getHelloAssoAccessToken(settings);
|
|
223
|
+
const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/forms/${encodeURIComponent(formType)}/${encodeURIComponent(formSlug)}/items`;
|
|
224
|
+
const resp = await fetchFn(url, { headers: { authorization: `Bearer ${token}` } });
|
|
225
|
+
if (!resp.ok) {
|
|
226
|
+
const t = await resp.text();
|
|
227
|
+
throw new Error(`HelloAsso items error: ${resp.status} ${t}`);
|
|
228
|
+
}
|
|
229
|
+
const json = await resp.json();
|
|
230
|
+
// API responses are usually { data: [...] } but keep it flexible
|
|
231
|
+
const list = Array.isArray(json) ? json : (Array.isArray(json.data) ? json.data : []);
|
|
232
|
+
|
|
233
|
+
// cache 15 minutes
|
|
234
|
+
try {
|
|
235
|
+
await db.setObject(cacheKey, { payload: JSON.stringify(list), expiresAt: String(now + 15 * 60 * 1000) });
|
|
236
|
+
} catch (e) {}
|
|
237
|
+
|
|
238
|
+
return list;
|
|
239
|
+
}
|
|
240
|
+
|
|
41
241
|
function parseItems(itemsJson) {
|
|
42
242
|
try {
|
|
43
243
|
const arr = JSON.parse(itemsJson || '[]');
|
|
@@ -54,6 +254,36 @@ function parseItems(itemsJson) {
|
|
|
54
254
|
}
|
|
55
255
|
}
|
|
56
256
|
|
|
257
|
+
async function getActiveItems(settings) {
|
|
258
|
+
const source = String(settings.itemsSource || 'manual');
|
|
259
|
+
if (source === 'helloasso') {
|
|
260
|
+
const rawItems = await fetchHelloAssoItems(settings);
|
|
261
|
+
const locMap = parseLocationMap(settings.ha_locationMapJson);
|
|
262
|
+
return (rawItems || []).map((it) => {
|
|
263
|
+
const id = String(it.id || it.itemId || it.reference || it.slug || it.name || '').trim();
|
|
264
|
+
const name = String(it.name || it.label || it.title || id).trim();
|
|
265
|
+
// Price handling is not displayed in the public calendar, keep a field if you want later
|
|
266
|
+
const price =
|
|
267
|
+
(it.price && (it.price.value || it.price.amount)) ||
|
|
268
|
+
(it.amount && (it.amount.value || it.amount)) ||
|
|
269
|
+
it.price;
|
|
270
|
+
const priceCents = typeof price === 'number' ? price : 0;
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
id: id || name,
|
|
274
|
+
name,
|
|
275
|
+
location: String(locMap[id] || ''),
|
|
276
|
+
priceCents,
|
|
277
|
+
active: true,
|
|
278
|
+
source: 'helloasso',
|
|
279
|
+
};
|
|
280
|
+
}).filter(i => i.id);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// manual
|
|
284
|
+
return parseItems(settings.itemsJson).filter(i => i.active);
|
|
285
|
+
}
|
|
286
|
+
|
|
57
287
|
async function getSettings() {
|
|
58
288
|
const settings = await meta.settings.get(SETTINGS_KEY);
|
|
59
289
|
return { ...DEFAULT_SETTINGS, ...(settings || {}) };
|
|
@@ -70,8 +300,29 @@ function resKey(id) { return `equipmentCalendar:res:${id}`; }
|
|
|
70
300
|
function itemIndexKey(itemId) { return `equipmentCalendar:item:${itemId}:res`; }
|
|
71
301
|
|
|
72
302
|
function statusBlocksItem(status) {
|
|
73
|
-
|
|
74
|
-
|
|
303
|
+
const s = String(status || '');
|
|
304
|
+
if (s === 'rejected' || s === 'cancelled') return false;
|
|
305
|
+
return (s === 'pending' || s === 'approved' || s === 'payment_pending' || s === 'paid');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async function saveBooking(booking) {
|
|
312
|
+
const bid = booking.bookingId;
|
|
313
|
+
await db.setObject(`equipmentCalendar:booking:${bid}`, booking);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function getBooking(bookingId) {
|
|
317
|
+
return await db.getObject(`equipmentCalendar:booking:${bookingId}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function addReservationToBooking(bookingId, rid) {
|
|
321
|
+
await db.setAdd(`equipmentCalendar:booking:${bookingId}:rids`, rid);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function getBookingRids(bookingId) {
|
|
325
|
+
return await db.getSetMembers(`equipmentCalendar:booking:${bookingId}:rids`) || [];
|
|
75
326
|
}
|
|
76
327
|
|
|
77
328
|
async function saveReservation(res) {
|
|
@@ -312,8 +563,14 @@ plugin.init = async function (params) {
|
|
|
312
563
|
// Admin (ACP) routes
|
|
313
564
|
if (mid && mid.admin) {
|
|
314
565
|
router.get('/admin/plugins/equipment-calendar', middleware.applyCSRF, mid.admin.buildHeader, renderAdminPage);
|
|
566
|
+
router.get('/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, mid.admin.buildHeader, renderAdminReservationsPage);
|
|
567
|
+
router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
|
|
315
568
|
router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
|
|
316
569
|
router.post('/admin/plugins/equipment-calendar/save', middleware.applyCSRF, handleAdminSave);
|
|
570
|
+
router.post('/admin/plugins/equipment-calendar/purge', middleware.applyCSRF, handleAdminPurge);
|
|
571
|
+
router.post('/admin/plugins/equipment-calendar/reservations/:rid/approve', middleware.applyCSRF, handleAdminApprove);
|
|
572
|
+
router.post('/admin/plugins/equipment-calendar/reservations/:rid/reject', middleware.applyCSRF, handleAdminReject);
|
|
573
|
+
router.post('/admin/plugins/equipment-calendar/reservations/:rid/delete', middleware.applyCSRF, handleAdminDelete);
|
|
317
574
|
}
|
|
318
575
|
|
|
319
576
|
// Convenience alias (optional): /calendar -> /equipment/calendar
|
|
@@ -394,15 +651,143 @@ plugin.addAdminNavigation = async function (header) {
|
|
|
394
651
|
plugin.addAdminRoutes = async function (params) {
|
|
395
652
|
const { router, middleware: mid } = params;
|
|
396
653
|
router.get('/admin/plugins/equipment-calendar', middleware.applyCSRF, mid.admin.buildHeader, renderAdminPage);
|
|
654
|
+
router.get('/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, mid.admin.buildHeader, renderAdminReservationsPage);
|
|
655
|
+
router.get('/api/admin/plugins/equipment-calendar/reservations', middleware.applyCSRF, renderAdminReservationsPage);
|
|
397
656
|
router.get('/api/admin/plugins/equipment-calendar', middleware.applyCSRF, renderAdminPage);
|
|
398
657
|
};
|
|
399
658
|
|
|
659
|
+
|
|
660
|
+
async function renderAdminReservationsPage(req, res) {
|
|
661
|
+
if (!(await ensureIsAdmin(req, res))) return;
|
|
662
|
+
|
|
663
|
+
const settings = await getSettings();
|
|
664
|
+
const items = await getActiveItems(settings);
|
|
665
|
+
const itemById = {};
|
|
666
|
+
items.forEach(it => { itemById[it.id] = it; });
|
|
667
|
+
|
|
668
|
+
const status = String(req.query.status || ''); // optional filter
|
|
669
|
+
const itemId = String(req.query.itemId || ''); // optional filter
|
|
670
|
+
const q = String(req.query.q || '').trim(); // search rid/user/notes
|
|
671
|
+
|
|
672
|
+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
|
673
|
+
const perPage = Math.min(100, Math.max(10, parseInt(req.query.perPage, 10) || 50));
|
|
674
|
+
|
|
675
|
+
const allRids = await db.getSortedSetRevRange('equipmentCalendar:reservations', 0, -1);
|
|
676
|
+
const totalAll = allRids.length;
|
|
677
|
+
|
|
678
|
+
const rows = [];
|
|
679
|
+
for (const rid of allRids) {
|
|
680
|
+
// eslint-disable-next-line no-await-in-loop
|
|
681
|
+
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
682
|
+
if (!r || !r.rid) continue;
|
|
683
|
+
|
|
684
|
+
if (status && String(r.status) !== status) continue;
|
|
685
|
+
if (itemId && String(r.itemId) !== itemId) continue;
|
|
686
|
+
|
|
687
|
+
const notes = String(r.notesUser || '');
|
|
688
|
+
const ridStr = String(r.rid || rid);
|
|
689
|
+
if (q) {
|
|
690
|
+
const hay = (ridStr + ' ' + String(r.uid || '') + ' ' + notes).toLowerCase();
|
|
691
|
+
if (!hay.includes(q.toLowerCase())) continue;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const startMs = parseInt(r.startMs, 10) || 0;
|
|
695
|
+
const endMs = parseInt(r.endMs, 10) || 0;
|
|
696
|
+
const createdAt = parseInt(r.createdAt, 10) || 0;
|
|
697
|
+
|
|
698
|
+
rows.push({
|
|
699
|
+
rid: ridStr,
|
|
700
|
+
itemId: String(r.itemId || ''),
|
|
701
|
+
itemName: (itemById[String(r.itemId || '')] && itemById[String(r.itemId || '')].name) || String(r.itemId || ''),
|
|
702
|
+
uid: String(r.uid || ''),
|
|
703
|
+
status: String(r.status || ''),
|
|
704
|
+
start: startMs ? new Date(startMs).toISOString() : '',
|
|
705
|
+
end: endMs ? new Date(endMs).toISOString() : '',
|
|
706
|
+
createdAt: createdAt ? new Date(createdAt).toISOString() : '',
|
|
707
|
+
notesUser: notes,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const total = rows.length;
|
|
712
|
+
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
|
713
|
+
const safePage = Math.min(page, totalPages);
|
|
714
|
+
const startIndex = (safePage - 1) * perPage;
|
|
715
|
+
const pageRows = rows.slice(startIndex, startIndex + perPage);
|
|
716
|
+
|
|
717
|
+
const itemOptions = [{ id: '', name: 'Tous' }].concat(items.map(i => ({ id: i.id, name: i.name })));
|
|
718
|
+
const statusOptions = [
|
|
719
|
+
{ id: '', name: 'Tous' },
|
|
720
|
+
{ id: 'pending', name: 'pending' },
|
|
721
|
+
{ id: 'approved', name: 'approved' },
|
|
722
|
+
{ id: 'paid', name: 'paid' },
|
|
723
|
+
{ id: 'rejected', name: 'rejected' },
|
|
724
|
+
{ id: 'cancelled', name: 'cancelled' },
|
|
725
|
+
];
|
|
726
|
+
|
|
727
|
+
res.render('admin/plugins/equipment-calendar-reservations', {
|
|
728
|
+
title: 'Equipment Calendar - Réservations',
|
|
729
|
+
settings,
|
|
730
|
+
rows: pageRows,
|
|
731
|
+
hasRows: pageRows.length > 0,
|
|
732
|
+
itemOptions: itemOptions.map(o => ({ ...o, selected: o.id === itemId })),
|
|
733
|
+
statusOptions: statusOptions.map(o => ({ ...o, selected: o.id === status })),
|
|
734
|
+
q,
|
|
735
|
+
page: safePage,
|
|
736
|
+
perPage,
|
|
737
|
+
total,
|
|
738
|
+
totalAll,
|
|
739
|
+
totalPages,
|
|
740
|
+
prevPage: safePage > 1 ? safePage - 1 : 0,
|
|
741
|
+
nextPage: safePage < totalPages ? safePage + 1 : 0,
|
|
742
|
+
actionBase: '/admin/plugins/equipment-calendar/reservations',
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function handleAdminApprove(req, res) {
|
|
747
|
+
if (!(await ensureIsAdmin(req, res))) return;
|
|
748
|
+
const rid = String(req.params.rid || '').trim();
|
|
749
|
+
if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
750
|
+
|
|
751
|
+
const key = `equipmentCalendar:reservation:${rid}`;
|
|
752
|
+
const r = await db.getObject(key);
|
|
753
|
+
if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
754
|
+
|
|
755
|
+
r.status = 'approved';
|
|
756
|
+
await db.setObject(key, r);
|
|
757
|
+
|
|
758
|
+
return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function handleAdminReject(req, res) {
|
|
762
|
+
if (!(await ensureIsAdmin(req, res))) return;
|
|
763
|
+
const rid = String(req.params.rid || '').trim();
|
|
764
|
+
if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
765
|
+
|
|
766
|
+
const key = `equipmentCalendar:reservation:${rid}`;
|
|
767
|
+
const r = await db.getObject(key);
|
|
768
|
+
if (!r || !r.rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
769
|
+
|
|
770
|
+
r.status = 'rejected';
|
|
771
|
+
await db.setObject(key, r);
|
|
772
|
+
|
|
773
|
+
return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function handleAdminDelete(req, res) {
|
|
777
|
+
if (!(await ensureIsAdmin(req, res))) return;
|
|
778
|
+
const rid = String(req.params.rid || '').trim();
|
|
779
|
+
if (!rid) return res.redirect('/admin/plugins/equipment-calendar/reservations');
|
|
780
|
+
await deleteReservation(rid);
|
|
781
|
+
return res.redirect('/admin/plugins/equipment-calendar/reservations?updated=1');
|
|
782
|
+
}
|
|
783
|
+
|
|
400
784
|
async function renderAdminPage(req, res) {
|
|
401
785
|
const settings = await getSettings();
|
|
402
786
|
res.render('admin/plugins/equipment-calendar', {
|
|
403
787
|
title: 'Equipment Calendar',
|
|
404
788
|
settings,
|
|
405
789
|
saved: req.query && String(req.query.saved || '') === '1',
|
|
790
|
+
purged: req.query && parseInt(req.query.purged, 10) || 0,
|
|
406
791
|
view_dayGridMonth: (settings.defaultView || 'dayGridMonth') === 'dayGridMonth',
|
|
407
792
|
view_timeGridWeek: (settings.defaultView || '') === 'timeGridWeek',
|
|
408
793
|
view_timeGridDay: (settings.defaultView || '') === 'timeGridDay',
|
|
@@ -411,9 +796,68 @@ async function renderAdminPage(req, res) {
|
|
|
411
796
|
}
|
|
412
797
|
|
|
413
798
|
// --- Calendar page ---
|
|
799
|
+
|
|
800
|
+
async function handleHelloAssoCallback(req, res) {
|
|
801
|
+
const settings = await getSettings();
|
|
802
|
+
|
|
803
|
+
const bookingId = String(req.query.bookingId || '').trim();
|
|
804
|
+
const checkoutIntentId = String(req.query.checkoutIntentId || '').trim();
|
|
805
|
+
const code = String(req.query.code || '').trim();
|
|
806
|
+
const type = String(req.query.type || '').trim();
|
|
807
|
+
|
|
808
|
+
let status = 'pending';
|
|
809
|
+
let message = 'Retour paiement reçu. Vérification en cours…';
|
|
810
|
+
|
|
811
|
+
try {
|
|
812
|
+
if (!checkoutIntentId) throw new Error('checkoutIntentId manquant');
|
|
813
|
+
const checkout = await fetchHelloAssoCheckoutIntent(settings, checkoutIntentId);
|
|
814
|
+
|
|
815
|
+
const paid = isCheckoutPaid(checkout);
|
|
816
|
+
if (paid) {
|
|
817
|
+
status = 'paid';
|
|
818
|
+
message = 'Paiement confirmé. Merci !';
|
|
819
|
+
|
|
820
|
+
if (bookingId) {
|
|
821
|
+
const rids = await getBookingRids(bookingId);
|
|
822
|
+
for (const rid of rids) {
|
|
823
|
+
// eslint-disable-next-line no-await-in-loop
|
|
824
|
+
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
825
|
+
if (!r || !r.rid) continue;
|
|
826
|
+
r.status = 'paid';
|
|
827
|
+
r.paidAt = Date.now();
|
|
828
|
+
r.checkoutIntentId = checkoutIntentId;
|
|
829
|
+
// eslint-disable-next-line no-await-in-loop
|
|
830
|
+
await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
const b = (await getBooking(bookingId)) || { bookingId };
|
|
834
|
+
b.status = 'paid';
|
|
835
|
+
b.checkoutIntentId = checkoutIntentId;
|
|
836
|
+
b.paidAt = Date.now();
|
|
837
|
+
await saveBooking(b);
|
|
838
|
+
} catch (e) {}
|
|
839
|
+
}
|
|
840
|
+
} else {
|
|
841
|
+
status = (type === 'error' || code) ? 'error' : 'pending';
|
|
842
|
+
message = 'Paiement non confirmé. Si vous venez de payer, réessayez dans quelques instants.';
|
|
843
|
+
}
|
|
844
|
+
} catch (e) {
|
|
845
|
+
status = 'error';
|
|
846
|
+
message = e.message || 'Erreur de vérification paiement';
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
res.render('equipment-calendar/payment-return', {
|
|
850
|
+
title: 'Retour paiement',
|
|
851
|
+
status,
|
|
852
|
+
statusError: status === 'error',
|
|
853
|
+
statusPaid: status === 'paid',
|
|
854
|
+
message,
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
414
858
|
async function renderCalendarPage(req, res) {
|
|
415
859
|
const settings = await getSettings();
|
|
416
|
-
const items =
|
|
860
|
+
const items = await getActiveItems(settings);
|
|
417
861
|
|
|
418
862
|
const tz = settings.timezone || 'Europe/Paris';
|
|
419
863
|
|
|
@@ -494,6 +938,79 @@ async function renderCalendarPage(req, res) {
|
|
|
494
938
|
|
|
495
939
|
|
|
496
940
|
// --- Approvals page ---
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
async function notifyApprovers(reservations, settings) {
|
|
945
|
+
const groupName = (settings.notifyGroup || settings.approverGroup || 'administrators').trim();
|
|
946
|
+
if (!groupName) return;
|
|
947
|
+
|
|
948
|
+
const rid = reservations[0] && (reservations[0].rid || reservations[0].id) || '';
|
|
949
|
+
const path = '/equipment/approvals';
|
|
950
|
+
|
|
951
|
+
// In-app notification (NodeBB notifications)
|
|
952
|
+
try {
|
|
953
|
+
const Notifications = require.main.require('./src/notifications');
|
|
954
|
+
const notif = await Notifications.create({
|
|
955
|
+
bodyShort: 'Nouvelle demande de réservation',
|
|
956
|
+
bodyLong: 'Une nouvelle demande de réservation est en attente de validation.',
|
|
957
|
+
nid: 'equipment-calendar:' + rid,
|
|
958
|
+
path,
|
|
959
|
+
});
|
|
960
|
+
if (Notifications.pushGroup) {
|
|
961
|
+
await Notifications.pushGroup(notif, groupName);
|
|
962
|
+
}
|
|
963
|
+
} catch (e) {
|
|
964
|
+
// ignore
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Direct email to members of notifyGroup (uses NodeBB Emailer + your SMTP/emailer plugin)
|
|
968
|
+
try {
|
|
969
|
+
const Emailer = require.main.require('./src/emailer');
|
|
970
|
+
const uids = await groups.getMembers(groupName, 0, -1);
|
|
971
|
+
if (!uids || !uids.length) return;
|
|
972
|
+
|
|
973
|
+
// Build a short summary
|
|
974
|
+
const items = await getActiveItems(await getSettings());
|
|
975
|
+
const nameById = {};
|
|
976
|
+
items.forEach(i => { nameById[i.id] = i.name; });
|
|
977
|
+
|
|
978
|
+
const start = new Date(reservations[0].startMs).toISOString();
|
|
979
|
+
const end = new Date(reservations[0].endMs).toISOString();
|
|
980
|
+
const itemList = reservations.map(r => nameById[r.itemId] || r.itemId).join(', ');
|
|
981
|
+
|
|
982
|
+
// Use a core template ('notification') that exists in NodeBB installs
|
|
983
|
+
// Params vary a bit by version; we provide common fields.
|
|
984
|
+
const params = {
|
|
985
|
+
subject: '[Réservation] Nouvelle demande en attente',
|
|
986
|
+
intro: 'Une nouvelle demande de réservation a été créée.',
|
|
987
|
+
body: `Matériel: ${itemList}\nDébut: ${start}\nFin: ${end}\nVoir: ${path}`,
|
|
988
|
+
notification_url: path,
|
|
989
|
+
url: path,
|
|
990
|
+
// Some templates use "site_title" etc but NodeBB will inject globals
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
for (const uid of uids) {
|
|
994
|
+
// eslint-disable-next-line no-await-in-loop
|
|
995
|
+
const email = await user.getUserField(uid, 'email');
|
|
996
|
+
if (!email) continue;
|
|
997
|
+
|
|
998
|
+
// eslint-disable-next-line no-await-in-loop
|
|
999
|
+
const lang = (await user.getUserField(uid, 'language')) || 'fr';
|
|
1000
|
+
if (Emailer.sendToEmail) {
|
|
1001
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1002
|
+
await Emailer.sendToEmail('notification', email, lang, params);
|
|
1003
|
+
} else if (Emailer.send) {
|
|
1004
|
+
// Some NodeBB versions use Emailer.send(template, uid/email, params)
|
|
1005
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1006
|
+
await Emailer.send('notification', uid, params);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
// ignore email errors to not block reservation flow
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
497
1014
|
async function renderApprovalsPage(req, res) {
|
|
498
1015
|
const settings = await getSettings();
|
|
499
1016
|
const ok = req.uid ? await canApprove(req.uid, settings) : false;
|
|
@@ -542,6 +1059,40 @@ async function renderApprovalsPage(req, res) {
|
|
|
542
1059
|
}
|
|
543
1060
|
|
|
544
1061
|
// --- Actions ---
|
|
1062
|
+
|
|
1063
|
+
async function createReservationForItem(req, res, settings, itemId, startMs, endMs, notesUser, bookingId) {
|
|
1064
|
+
const items = await getActiveItems(settings);
|
|
1065
|
+
const item = items.find(i => i.id === itemId);
|
|
1066
|
+
if (!item) {
|
|
1067
|
+
throw new Error('Unknown item: ' + itemId);
|
|
1068
|
+
}
|
|
1069
|
+
// Reject if overlaps any blocking reservation
|
|
1070
|
+
const overlapsBlocking = await hasOverlap(itemId, startMs, endMs);
|
|
1071
|
+
if (overlapsBlocking) {
|
|
1072
|
+
const err = new Error('Item not available: ' + itemId);
|
|
1073
|
+
err.code = 'ITEM_UNAVAILABLE';
|
|
1074
|
+
throw err;
|
|
1075
|
+
}
|
|
1076
|
+
const rid = generateId();
|
|
1077
|
+
const data = {
|
|
1078
|
+
id: rid,
|
|
1079
|
+
rid,
|
|
1080
|
+
itemId,
|
|
1081
|
+
uid: req.uid,
|
|
1082
|
+
startMs,
|
|
1083
|
+
endMs,
|
|
1084
|
+
status: 'pending',
|
|
1085
|
+
notesUser: notesUser || '',
|
|
1086
|
+
bookingId: bookingId || '',
|
|
1087
|
+
createdAt: Date.now(),
|
|
1088
|
+
};
|
|
1089
|
+
await saveReservation(data);
|
|
1090
|
+
if (bookingId) {
|
|
1091
|
+
await addReservationToBooking(bookingId, rid);
|
|
1092
|
+
}
|
|
1093
|
+
return data;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
545
1096
|
async function handleCreateReservation(req, res) {
|
|
546
1097
|
try {
|
|
547
1098
|
const settings = await getSettings();
|
|
@@ -549,8 +1100,12 @@ async function handleCreateReservation(req, res) {
|
|
|
549
1100
|
return helpers.notAllowed(req, res);
|
|
550
1101
|
}
|
|
551
1102
|
|
|
552
|
-
const items =
|
|
553
|
-
const
|
|
1103
|
+
const items = await getActiveItems(settings);
|
|
1104
|
+
const itemIdsRaw = String(req.body.itemIds || req.body.itemId || '').trim();
|
|
1105
|
+
const itemIds = itemIdsRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
1106
|
+
if (!itemIds.length) {
|
|
1107
|
+
return res.status(400).send('itemId required');
|
|
1108
|
+
}
|
|
554
1109
|
const item = items.find(i => i.id === itemId);
|
|
555
1110
|
if (!item) return res.status(400).send('Invalid item');
|
|
556
1111
|
|
|
@@ -685,6 +1240,86 @@ async function handleRejectReservation(req, res) {
|
|
|
685
1240
|
}
|
|
686
1241
|
|
|
687
1242
|
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
async function ensureIsAdmin(req, res) {
|
|
1246
|
+
const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
|
|
1247
|
+
if (!isAdmin) {
|
|
1248
|
+
helpers.notAllowed(req, res);
|
|
1249
|
+
return false;
|
|
1250
|
+
}
|
|
1251
|
+
return true;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function deleteReservation(rid) {
|
|
1255
|
+
const key = `equipmentCalendar:reservation:${rid}`;
|
|
1256
|
+
const data = await db.getObject(key);
|
|
1257
|
+
if (data && data.itemId) {
|
|
1258
|
+
await db.sortedSetRemove(`equipmentCalendar:item:${data.itemId}:reservations`, rid);
|
|
1259
|
+
}
|
|
1260
|
+
await db.delete(key);
|
|
1261
|
+
await db.sortedSetRemove('equipmentCalendar:reservations', rid);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
async function handleAdminPurge(req, res) {
|
|
1265
|
+
try {
|
|
1266
|
+
const isAdmin = req.uid ? await groups.isMember(req.uid, 'administrators') : false;
|
|
1267
|
+
if (!isAdmin) {
|
|
1268
|
+
return helpers.notAllowed(req, res);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const mode = String(req.body.mode || 'nonblocking'); // nonblocking|olderThan|all
|
|
1272
|
+
const olderThanDays = parseInt(req.body.olderThanDays, 10);
|
|
1273
|
+
|
|
1274
|
+
// Load all reservation ids
|
|
1275
|
+
const rids = await db.getSortedSetRange('equipmentCalendar:reservations', 0, -1);
|
|
1276
|
+
let deleted = 0;
|
|
1277
|
+
|
|
1278
|
+
const now = Date.now();
|
|
1279
|
+
for (const rid of rids) {
|
|
1280
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1281
|
+
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
1282
|
+
if (!r || !r.rid) {
|
|
1283
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1284
|
+
await db.sortedSetRemove('equipmentCalendar:reservations', rid);
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const status = String(r.status || '');
|
|
1289
|
+
const createdAt = parseInt(r.createdAt, 10) || 0;
|
|
1290
|
+
const startMs = parseInt(r.startMs, 10) || 0;
|
|
1291
|
+
const endMs = parseInt(r.endMs, 10) || 0;
|
|
1292
|
+
|
|
1293
|
+
let shouldDelete = false;
|
|
1294
|
+
|
|
1295
|
+
if (mode === 'all') {
|
|
1296
|
+
shouldDelete = true;
|
|
1297
|
+
} else if (mode === 'olderThan') {
|
|
1298
|
+
const days = Number.isFinite(olderThanDays) ? olderThanDays : 0;
|
|
1299
|
+
if (days > 0) {
|
|
1300
|
+
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
|
1301
|
+
// use createdAt if present, otherwise startMs
|
|
1302
|
+
const t = createdAt || startMs || endMs;
|
|
1303
|
+
shouldDelete = t > 0 && t < cutoff;
|
|
1304
|
+
}
|
|
1305
|
+
} else {
|
|
1306
|
+
// nonblocking cleanup: delete rejected/cancelled only
|
|
1307
|
+
shouldDelete = (status === 'rejected' || status === 'cancelled');
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (shouldDelete) {
|
|
1311
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1312
|
+
await deleteReservation(rid);
|
|
1313
|
+
deleted++;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
return res.redirect(`/admin/plugins/equipment-calendar?saved=1&purged=${deleted}`);
|
|
1318
|
+
} catch (e) {
|
|
1319
|
+
return res.status(500).send(e.message || 'error');
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
688
1323
|
async function handleAdminSave(req, res) {
|
|
689
1324
|
try {
|
|
690
1325
|
// Simple admin check (avoid relying on client-side require/settings)
|
|
@@ -705,6 +1340,7 @@ async function handleAdminSave(req, res) {
|
|
|
705
1340
|
ha_webhookSecret: String(req.body.ha_webhookSecret || ''),
|
|
706
1341
|
defaultView: String(req.body.defaultView || DEFAULT_SETTINGS.defaultView),
|
|
707
1342
|
timezone: String(req.body.timezone || DEFAULT_SETTINGS.timezone),
|
|
1343
|
+
paymentTimeoutMinutes: String(req.body.paymentTimeoutMinutes || DEFAULT_SETTINGS.paymentTimeoutMinutes),
|
|
708
1344
|
showRequesterToAll: (req.body.showRequesterToAll === '1' || req.body.showRequesterToAll === 'on') ? '1' : '0',
|
|
709
1345
|
};
|
|
710
1346
|
|
|
@@ -715,4 +1351,54 @@ async function handleAdminSave(req, res) {
|
|
|
715
1351
|
}
|
|
716
1352
|
}
|
|
717
1353
|
|
|
1354
|
+
|
|
1355
|
+
let paymentTimeoutInterval = null;
|
|
1356
|
+
|
|
1357
|
+
function startPaymentTimeoutScheduler() {
|
|
1358
|
+
if (paymentTimeoutInterval) return;
|
|
1359
|
+
// Run every minute
|
|
1360
|
+
paymentTimeoutInterval = setInterval(async () => {
|
|
1361
|
+
try {
|
|
1362
|
+
const settings = await getSettings();
|
|
1363
|
+
const timeoutMin = Math.max(1, parseInt(settings.paymentTimeoutMinutes, 10) || 10);
|
|
1364
|
+
const cutoff = Date.now() - timeoutMin * 60 * 1000;
|
|
1365
|
+
|
|
1366
|
+
// Scan bookings keys (we keep an index set)
|
|
1367
|
+
const bookingIds = await db.getSetMembers('equipmentCalendar:bookings') || [];
|
|
1368
|
+
for (const bid of bookingIds) {
|
|
1369
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1370
|
+
const b = await getBooking(bid);
|
|
1371
|
+
if (!b || !b.bookingId) continue;
|
|
1372
|
+
const status = String(b.status || '');
|
|
1373
|
+
if (status !== 'payment_pending') continue;
|
|
1374
|
+
|
|
1375
|
+
const pendingAt = parseInt(b.paymentPendingAt, 10) || parseInt(b.createdAt, 10) || 0;
|
|
1376
|
+
if (!pendingAt || pendingAt > cutoff) continue;
|
|
1377
|
+
|
|
1378
|
+
// Timeout reached -> cancel booking + all reservations
|
|
1379
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1380
|
+
const rids = await getBookingRids(bid);
|
|
1381
|
+
for (const rid of rids) {
|
|
1382
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1383
|
+
const r = await db.getObject(`equipmentCalendar:reservation:${rid}`);
|
|
1384
|
+
if (!r || !r.rid) continue;
|
|
1385
|
+
if (String(r.status) === 'paid') continue; // safety
|
|
1386
|
+
r.status = 'cancelled';
|
|
1387
|
+
r.cancelledAt = Date.now();
|
|
1388
|
+
r.paymentUrl = '';
|
|
1389
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1390
|
+
await db.setObject(`equipmentCalendar:reservation:${rid}`, r);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
b.status = 'cancelled';
|
|
1394
|
+
b.cancelledAt = Date.now();
|
|
1395
|
+
b.paymentUrl = '';
|
|
1396
|
+
await saveBooking(b);
|
|
1397
|
+
}
|
|
1398
|
+
} catch (e) {
|
|
1399
|
+
// ignore to keep scheduler alive
|
|
1400
|
+
}
|
|
1401
|
+
}, 60 * 1000);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
718
1404
|
module.exports = plugin;
|
package/package.json
CHANGED
package/plugin.json
CHANGED
package/public/js/client.js
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
const startInput = form.querySelector('input[name="start"]');
|
|
41
41
|
const endInput = form.querySelector('input[name="end"]');
|
|
42
|
-
const itemInput = form.querySelector('input[name="
|
|
42
|
+
const itemInput = form.querySelector('input[name="itemIds"]');
|
|
43
43
|
const notesInput = form.querySelector('input[name="notesUser"]');
|
|
44
44
|
|
|
45
45
|
if (startInput) startInput.value = startISO;
|
|
@@ -84,8 +84,8 @@
|
|
|
84
84
|
<input class="form-control" type="text" value="${fmt(endDate)}" readonly>
|
|
85
85
|
</div>
|
|
86
86
|
<div class="mb-3">
|
|
87
|
-
<label class="form-label">Matériel</label>
|
|
88
|
-
<select class="form-select" id="ec-modal-item">
|
|
87
|
+
<label class="form-label">Matériel (Ctrl/Cmd pour multi-sélection)</label>
|
|
88
|
+
<select class="form-select" id="ec-modal-item" multiple size="6">
|
|
89
89
|
${optionsHtml}
|
|
90
90
|
</select>
|
|
91
91
|
</div>
|
|
@@ -116,9 +116,13 @@
|
|
|
116
116
|
callback: function () {
|
|
117
117
|
const itemEl = document.getElementById('ec-modal-item');
|
|
118
118
|
const notesEl = document.getElementById('ec-modal-notes');
|
|
119
|
-
|
|
119
|
+
let ids = [];
|
|
120
|
+
if (itemEl && itemEl.options) {
|
|
121
|
+
for (const opt of itemEl.options) { if (opt.selected) ids.push(opt.value); }
|
|
122
|
+
}
|
|
123
|
+
if (!ids.length) ids = [available[0].id];
|
|
120
124
|
const notes = notesEl ? notesEl.value : '';
|
|
121
|
-
submitReservation(startDate.toISOString(), endDate.toISOString(),
|
|
125
|
+
submitReservation(startDate.toISOString(), endDate.toISOString(), ids.join(','), notes);
|
|
122
126
|
}
|
|
123
127
|
}
|
|
124
128
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<div class="acp-page-container">
|
|
2
|
+
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
|
3
|
+
<h1 class="mb-0">Equipment Calendar</h1>
|
|
4
|
+
<div class="btn-group">
|
|
5
|
+
<a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar">Paramètres</a>
|
|
6
|
+
<a class="btn btn-secondary active" href="/admin/plugins/equipment-calendar/reservations">Réservations</a>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
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>
|
|
41
|
+
|
|
42
|
+
{{{ if hasRows }}}
|
|
43
|
+
<div class="table-responsive">
|
|
44
|
+
<table class="table table-striped align-middle">
|
|
45
|
+
<thead>
|
|
46
|
+
<tr>
|
|
47
|
+
<th>RID</th>
|
|
48
|
+
<th>Matériel</th>
|
|
49
|
+
<th>UID</th>
|
|
50
|
+
<th>Début</th>
|
|
51
|
+
<th>Fin</th>
|
|
52
|
+
<th>Statut</th>
|
|
53
|
+
<th>Créé</th>
|
|
54
|
+
<th>Note</th>
|
|
55
|
+
<th>Actions</th>
|
|
56
|
+
</tr>
|
|
57
|
+
</thead>
|
|
58
|
+
<tbody>
|
|
59
|
+
{{{ each rows }}}
|
|
60
|
+
<tr>
|
|
61
|
+
<td><code>{rows.rid}</code></td>
|
|
62
|
+
<td>{rows.itemName}</td>
|
|
63
|
+
<td>{rows.uid}</td>
|
|
64
|
+
<td><small>{rows.start}</small></td>
|
|
65
|
+
<td><small>{rows.end}</small></td>
|
|
66
|
+
<td><code>{rows.status}</code></td>
|
|
67
|
+
<td><small>{rows.createdAt}</small></td>
|
|
68
|
+
<td><small>{rows.notesUser}</small></td>
|
|
69
|
+
<td class="text-nowrap">
|
|
70
|
+
<form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/approve" class="d-inline">
|
|
71
|
+
<input type="hidden" name="_csrf" value="{config.csrf_token}">
|
|
72
|
+
<button class="btn btn-sm btn-success" type="submit">Approve</button>
|
|
73
|
+
</form>
|
|
74
|
+
<form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/reject" class="d-inline ms-1">
|
|
75
|
+
<input type="hidden" name="_csrf" value="{config.csrf_token}">
|
|
76
|
+
<button class="btn btn-sm btn-warning" type="submit">Reject</button>
|
|
77
|
+
</form>
|
|
78
|
+
<form method="post" action="/admin/plugins/equipment-calendar/reservations/{rows.rid}/delete" class="d-inline ms-1" onsubmit="return confirm('Supprimer définitivement ?');">
|
|
79
|
+
<input type="hidden" name="_csrf" value="{config.csrf_token}">
|
|
80
|
+
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
|
|
81
|
+
</form>
|
|
82
|
+
</td>
|
|
83
|
+
</tr>
|
|
84
|
+
{{{ end }}}
|
|
85
|
+
</tbody>
|
|
86
|
+
</table>
|
|
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
|
+
|
|
101
|
+
{{{ else }}}
|
|
102
|
+
<div class="alert alert-info">Aucune réservation trouvée.</div>
|
|
103
|
+
{{{ end }}}
|
|
104
|
+
</div>
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
<div class="acp-page-container">
|
|
2
|
-
<
|
|
2
|
+
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
|
3
|
+
<h1 class="mb-0">Equipment Calendar</h1>
|
|
4
|
+
<div class="btn-group">
|
|
5
|
+
<a class="btn btn-secondary active" href="/admin/plugins/equipment-calendar">Paramètres</a>
|
|
6
|
+
<a class="btn btn-outline-secondary" href="/admin/plugins/equipment-calendar/reservations">Réservations</a>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
3
9
|
|
|
4
10
|
{{{ if saved }}}
|
|
5
11
|
<div class="alert alert-success">Paramètres enregistrés.</div>
|
|
@@ -49,7 +55,33 @@
|
|
|
49
55
|
|
|
50
56
|
<div class="card card-body mb-3">
|
|
51
57
|
<h5>Matériel</h5>
|
|
52
|
-
<
|
|
58
|
+
<div class="mb-3">
|
|
59
|
+
<label class="form-label">Source</label>
|
|
60
|
+
<select class="form-select" name="itemsSource">
|
|
61
|
+
<option value="manual">Manuel (JSON)</option>
|
|
62
|
+
<option value="helloasso">HelloAsso (articles d’un formulaire)</option>
|
|
63
|
+
</select>
|
|
64
|
+
<div class="form-text">Si HelloAsso est choisi, la liste du matériel est récupérée via l’API HelloAsso (items d’un formulaire).</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="mb-3">
|
|
68
|
+
<label class="form-label">HelloAsso formType (ex: shop, event, membership, donation)</label>
|
|
69
|
+
<input class="form-control" name="ha_itemsFormType" value="{settings.ha_itemsFormType}">
|
|
70
|
+
</div>
|
|
71
|
+
<div class="mb-3">
|
|
72
|
+
<label class="form-label">HelloAsso formSlug</label>
|
|
73
|
+
<input class="form-control" name="ha_itemsFormSlug" value="{settings.ha_itemsFormSlug}">
|
|
74
|
+
</div>
|
|
75
|
+
<div class="mb-3">
|
|
76
|
+
<label class="form-label">Mapping lieux (JSON) par id d’article HelloAsso</label>
|
|
77
|
+
<textarea class="form-control" rows="4" name="ha_locationMapJson">{settings.ha_locationMapJson}</textarea>
|
|
78
|
+
<div class="form-text">Ex: { "12345": "Local A", "67890": "Local B" }</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="mb-3">
|
|
82
|
+
<label class="form-label">Matériel manuel (JSON) — utilisé uniquement si Source=Manuel</label>
|
|
83
|
+
<textarea name="itemsJson" class="form-control" rows="10">{settings.itemsJson}</textarea>
|
|
84
|
+
</div>
|
|
53
85
|
</div>
|
|
54
86
|
|
|
55
87
|
<div class="card card-body mb-3">
|
|
@@ -63,6 +95,11 @@
|
|
|
63
95
|
|
|
64
96
|
<div class="card card-body mb-3">
|
|
65
97
|
<h5>Calendrier</h5>
|
|
98
|
+
<div class="mb-3">
|
|
99
|
+
<label class="form-label">Timeout paiement (minutes)</label>
|
|
100
|
+
<input class="form-control" type="number" min="1" name="paymentTimeoutMinutes" value="{settings.paymentTimeoutMinutes}">
|
|
101
|
+
<div class="form-text">Après validation, si le paiement n’est pas confirmé dans ce délai, la réservation est annulée et le matériel est débloqué.</div>
|
|
102
|
+
</div>
|
|
66
103
|
<div class="mb-3">
|
|
67
104
|
<label class="form-label">Vue par défaut</label>
|
|
68
105
|
<select name="defaultView" class="form-select">
|
|
@@ -74,7 +111,47 @@
|
|
|
74
111
|
<div class="mb-3"><label class="form-label">Timezone</label><input name="timezone" class="form-control" value="{settings.timezone}"></div>
|
|
75
112
|
</div>
|
|
76
113
|
|
|
114
|
+
|
|
115
|
+
<div class="card card-body mb-3">
|
|
116
|
+
<h5>Purge</h5>
|
|
117
|
+
<p class="text-muted mb-2">Supprime des réservations de la base (action irréversible).</p>
|
|
118
|
+
|
|
119
|
+
<div class="d-flex flex-wrap gap-2">
|
|
120
|
+
<button type="submit" class="btn btn-outline-danger" formaction="/admin/plugins/equipment-calendar/purge" formmethod="post" name="mode" value="nonblocking"
|
|
121
|
+
onclick="return confirm('Supprimer toutes les réservations refusées/annulées ?');">
|
|
122
|
+
Purger refusées/annulées
|
|
123
|
+
</button>
|
|
124
|
+
|
|
125
|
+
<div class="d-flex align-items-center gap-2">
|
|
126
|
+
<input class="form-control" style="max-width: 140px" type="number" min="1" name="olderThanDays" placeholder="Jours">
|
|
127
|
+
<button type="submit" class="btn btn-outline-danger" formaction="/admin/plugins/equipment-calendar/purge" formmethod="post" name="mode" value="olderThan"
|
|
128
|
+
onclick="return confirm('Supprimer les réservations plus anciennes que N jours ?');">
|
|
129
|
+
Purger plus anciennes que…
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<button type="submit" class="btn btn-danger" formaction="/admin/plugins/equipment-calendar/purge" formmethod="post" name="mode" value="all"
|
|
134
|
+
onclick="return confirm('⚠️ Supprimer TOUTES les réservations ?');">
|
|
135
|
+
Tout purger
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{{{ if purged }}}
|
|
140
|
+
<div class="alert alert-success mt-3">Purge effectuée : {purged} réservation(s) supprimée(s).</div>
|
|
141
|
+
<script>
|
|
142
|
+
(function () {
|
|
143
|
+
try {
|
|
144
|
+
if (window.app && window.app.alertSuccess) {
|
|
145
|
+
window.app.alertSuccess('Purge effectuée : {purged} réservation(s) supprimée(s).');
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {}
|
|
148
|
+
}());
|
|
149
|
+
</script>
|
|
150
|
+
{{{ end }}}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
77
153
|
<button id="ec-save" class="btn btn-primary" type="button">Sauvegarder</button>
|
|
154
|
+
|
|
78
155
|
</form>
|
|
79
156
|
</div>
|
|
80
157
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
<input type="hidden" name="_csrf" value="{config.csrf_token}">
|
|
19
19
|
<input type="hidden" name="start" value="">
|
|
20
20
|
<input type="hidden" name="end" value="">
|
|
21
|
-
<input type="hidden" name="
|
|
21
|
+
<input type="hidden" name="itemIds" value="">
|
|
22
22
|
<input type="hidden" name="notesUser" value="">
|
|
23
23
|
</form>
|
|
24
24
|
</div>
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
<div class="
|
|
2
|
-
<
|
|
1
|
+
<div class="card card-body">
|
|
2
|
+
<h4>Retour paiement</h4>
|
|
3
|
+
|
|
4
|
+
{{{ if statusPaid }}}
|
|
5
|
+
<div class="alert alert-success">✅ Paiement confirmé.</div>
|
|
6
|
+
{{{ end }}}
|
|
7
|
+
|
|
3
8
|
{{{ if statusError }}}
|
|
4
|
-
<div class="alert alert-danger"
|
|
5
|
-
{{{ else }}}
|
|
6
|
-
<div class="alert alert-info">Merci. Si le paiement est confirmé, la réservation passera en "validée". Référence: <code>{rid}</code></div>
|
|
9
|
+
<div class="alert alert-danger">❌ Paiement non confirmé.</div>
|
|
7
10
|
{{{ end }}}
|
|
11
|
+
|
|
12
|
+
<p>{message}</p>
|
|
13
|
+
|
|
8
14
|
<a class="btn btn-primary" href="/equipment/calendar">Retour au calendrier</a>
|
|
9
15
|
</div>
|