nodebb-plugin-equipment-calendar 0.1.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/README.md +37 -0
- package/library.js +649 -0
- package/package.json +23 -0
- package/plugin.json +25 -0
- package/public/lib/admin.js +32 -0
- package/public/lib/client.js +50 -0
- package/public/styles.css +1 -0
- package/public/templates/admin/plugins/equipment-calendar.tpl +67 -0
- package/public/templates/equipment-calendar/approvals.tpl +51 -0
- package/public/templates/equipment-calendar/calendar.tpl +68 -0
- package/public/templates/equipment-calendar/payment-return.tpl +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# NodeBB Equipment Calendar (v0.1.0)
|
|
2
|
+
|
|
3
|
+
Plugin NodeBB (testé pour NodeBB v4.7.x) pour gérer des réservations de matériel via un calendrier (FullCalendar),
|
|
4
|
+
avec workflow : demande -> validation par un groupe -> lien de paiement HelloAsso -> statut payé/validé.
|
|
5
|
+
|
|
6
|
+
## Fonctionnement (sans "AJAX applicatif")
|
|
7
|
+
- La page calendrier est rendue côté serveur avec les évènements de la période demandée.
|
|
8
|
+
- Les actions (création, validation, refus) sont des POST classiques, suivis d'une redirection.
|
|
9
|
+
- FullCalendar est utilisé en mode "events inline" (pas de feed JSON automatique).
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
```bash
|
|
13
|
+
cd /path/to/nodebb
|
|
14
|
+
npm install /path/to/nodebb-plugin-equipment-calendar
|
|
15
|
+
./nodebb build
|
|
16
|
+
./nodebb restart
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
Dans l'ACP : Plugins -> Equipment Calendar
|
|
21
|
+
|
|
22
|
+
- Groupes autorisés à créer une demande
|
|
23
|
+
- Groupe validateur
|
|
24
|
+
- Groupe notifié
|
|
25
|
+
- Matériel (JSON)
|
|
26
|
+
- Paramètres HelloAsso (clientId, clientSecret, organizationSlug, returnUrl, webhookSecret, etc.)
|
|
27
|
+
|
|
28
|
+
## Webhook HelloAsso
|
|
29
|
+
Déclare l'URL :
|
|
30
|
+
`https://<ton-forum>/equipment/webhook/helloasso`
|
|
31
|
+
|
|
32
|
+
Le plugin vérifie la signature si `webhookSecret` est renseigné (exemple basique).
|
|
33
|
+
|
|
34
|
+
## Remarques
|
|
35
|
+
- Ce plugin est un squelette complet mais générique : adapte la logique de paiement HelloAsso selon ton besoin exact
|
|
36
|
+
(type de checkout, itemization, montant, etc.).
|
|
37
|
+
- Pour un contrôle d'overlap strict : le plugin empêche les réservations qui chevauchent (même item) pour les statuts bloquants.
|
package/library.js
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const db = require.main.require('./src/database');
|
|
4
|
+
const meta = require.main.require('./src/meta');
|
|
5
|
+
const groups = require.main.require('./src/groups');
|
|
6
|
+
const user = require.main.require('./src/user');
|
|
7
|
+
const notifications = require.main.require('./src/notifications');
|
|
8
|
+
const Emailer = require.main.require('./src/emailer');
|
|
9
|
+
const routeHelpers = require.main.require('./src/routes/helpers');
|
|
10
|
+
const middleware = require.main.require('./src/middleware');
|
|
11
|
+
const helpers = require.main.require('./src/controllers/helpers');
|
|
12
|
+
const nconf = require.main.require('nconf');
|
|
13
|
+
|
|
14
|
+
const axios = require('axios');
|
|
15
|
+
const { DateTime } = require('luxon');
|
|
16
|
+
const { v4: uuidv4 } = require('uuid');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
|
|
19
|
+
const plugin = {};
|
|
20
|
+
const SETTINGS_KEY = 'equipmentCalendar';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_SETTINGS = {
|
|
23
|
+
creatorGroups: 'registered-users',
|
|
24
|
+
approverGroup: 'administrators',
|
|
25
|
+
notifyGroup: 'administrators',
|
|
26
|
+
// JSON array of items: [{ "id": "cam1", "name": "Caméra A", "priceCents": 5000, "location": "Stock A", "active": true }]
|
|
27
|
+
itemsJson: '[]',
|
|
28
|
+
// HelloAsso
|
|
29
|
+
ha_clientId: '',
|
|
30
|
+
ha_clientSecret: '',
|
|
31
|
+
ha_organizationSlug: '',
|
|
32
|
+
ha_returnUrl: '',
|
|
33
|
+
ha_webhookSecret: '',
|
|
34
|
+
// calendar
|
|
35
|
+
defaultView: 'dayGridMonth',
|
|
36
|
+
timezone: 'Europe/Paris',
|
|
37
|
+
// privacy
|
|
38
|
+
showRequesterToAll: '0', // 0/1
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function parseItems(itemsJson) {
|
|
42
|
+
try {
|
|
43
|
+
const arr = JSON.parse(itemsJson || '[]');
|
|
44
|
+
if (!Array.isArray(arr)) return [];
|
|
45
|
+
return arr.map(it => ({
|
|
46
|
+
id: String(it.id || '').trim(),
|
|
47
|
+
name: String(it.name || '').trim(),
|
|
48
|
+
priceCents: Number(it.priceCents || 0),
|
|
49
|
+
location: String(it.location || '').trim(),
|
|
50
|
+
active: it.active !== false,
|
|
51
|
+
})).filter(it => it.id && it.name);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function getSettings() {
|
|
58
|
+
const settings = await meta.settings.get(SETTINGS_KEY);
|
|
59
|
+
return { ...DEFAULT_SETTINGS, ...(settings || {}) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Data layer ---
|
|
63
|
+
// Keys:
|
|
64
|
+
// item hash: equipmentCalendar:items (stored in settings as JSON)
|
|
65
|
+
// reservations stored as objects in db, indexed by id, and by itemId
|
|
66
|
+
// reservation object key: equipmentCalendar:res:<id>
|
|
67
|
+
// index by item: equipmentCalendar:item:<itemId>:res (sorted set score=startMillis, value=resId)
|
|
68
|
+
|
|
69
|
+
function resKey(id) { return `equipmentCalendar:res:${id}`; }
|
|
70
|
+
function itemIndexKey(itemId) { return `equipmentCalendar:item:${itemId}:res`; }
|
|
71
|
+
|
|
72
|
+
function statusBlocksItem(status) {
|
|
73
|
+
// statuses that block availability
|
|
74
|
+
return ['pending', 'approved_waiting_payment', 'paid_validated'].includes(status);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function saveReservation(res) {
|
|
78
|
+
await db.setObject(resKey(res.id), res);
|
|
79
|
+
await db.sortedSetAdd(itemIndexKey(res.itemId), res.startMs, res.id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function getReservation(id) {
|
|
83
|
+
const obj = await db.getObject(resKey(id));
|
|
84
|
+
if (!obj || !obj.id) return null;
|
|
85
|
+
// db returns strings, normalize
|
|
86
|
+
return normalizeReservation(obj);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeReservation(obj) {
|
|
90
|
+
const startMs = Number(obj.startMs);
|
|
91
|
+
const endMs = Number(obj.endMs);
|
|
92
|
+
return {
|
|
93
|
+
id: obj.id,
|
|
94
|
+
itemId: obj.itemId,
|
|
95
|
+
uid: Number(obj.uid),
|
|
96
|
+
startMs,
|
|
97
|
+
endMs,
|
|
98
|
+
status: obj.status,
|
|
99
|
+
createdAtMs: Number(obj.createdAtMs || 0),
|
|
100
|
+
updatedAtMs: Number(obj.updatedAtMs || 0),
|
|
101
|
+
validatorUid: obj.validatorUid ? Number(obj.validatorUid) : 0,
|
|
102
|
+
notesUser: obj.notesUser || '',
|
|
103
|
+
notesAdmin: obj.notesAdmin || '',
|
|
104
|
+
ha_checkoutIntentId: obj.ha_checkoutIntentId || '',
|
|
105
|
+
ha_paymentUrl: obj.ha_paymentUrl || '',
|
|
106
|
+
ha_paymentStatus: obj.ha_paymentStatus || '',
|
|
107
|
+
ha_paidAtMs: Number(obj.ha_paidAtMs || 0),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function listReservationsForRange(itemId, startMs, endMs) {
|
|
112
|
+
// fetch candidates by score range with some padding
|
|
113
|
+
const ids = await db.getSortedSetRangeByScore(itemIndexKey(itemId), 0, -1, startMs - 86400000, endMs + 86400000);
|
|
114
|
+
if (!ids || !ids.length) return [];
|
|
115
|
+
const objs = await db.getObjects(ids.map(resKey));
|
|
116
|
+
return (objs || []).filter(Boolean).map(normalizeReservation).filter(r => {
|
|
117
|
+
// overlap check
|
|
118
|
+
return r.startMs < endMs && r.endMs > startMs;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function hasOverlap(itemId, startMs, endMs) {
|
|
123
|
+
const existing = await listReservationsForRange(itemId, startMs, endMs);
|
|
124
|
+
return existing.some(r => statusBlocksItem(r.status) && r.startMs < endMs && r.endMs > startMs);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- Permissions helpers ---
|
|
128
|
+
async function isInAnyGroup(uid, groupNamesCsv) {
|
|
129
|
+
if (!uid) return false;
|
|
130
|
+
const groupNames = String(groupNamesCsv || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
131
|
+
if (!groupNames.length) return false;
|
|
132
|
+
for (const g of groupNames) {
|
|
133
|
+
const isMember = await groups.isMember(uid, g);
|
|
134
|
+
if (isMember) return true;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function canCreate(uid, settings) {
|
|
140
|
+
return isInAnyGroup(uid, settings.creatorGroups);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function canApprove(uid, settings) {
|
|
144
|
+
return groups.isMember(uid, settings.approverGroup);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function listGroupUids(groupName) {
|
|
148
|
+
try {
|
|
149
|
+
const members = await groups.getMembers(groupName, 0, 9999);
|
|
150
|
+
return (members && members.users) ? members.users.map(u => u.uid) : [];
|
|
151
|
+
} catch (e) {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function sendGroupNotificationAndEmail(groupName, notifTitle, notifBody, link) {
|
|
157
|
+
const uids = await listGroupUids(groupName);
|
|
158
|
+
if (!uids.length) return;
|
|
159
|
+
|
|
160
|
+
// NodeBB notifications
|
|
161
|
+
try {
|
|
162
|
+
await notifications.create({
|
|
163
|
+
bodyShort: notifTitle,
|
|
164
|
+
bodyLong: notifBody,
|
|
165
|
+
nid: `equipmentCalendar:${uuidv4()}`,
|
|
166
|
+
from: 0,
|
|
167
|
+
path: link,
|
|
168
|
+
});
|
|
169
|
+
} catch (e) { /* ignore */ }
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
// push notification to each user
|
|
173
|
+
for (const uid of uids) {
|
|
174
|
+
try {
|
|
175
|
+
await notifications.push({
|
|
176
|
+
uid,
|
|
177
|
+
bodyShort: notifTitle,
|
|
178
|
+
bodyLong: notifBody,
|
|
179
|
+
nid: `equipmentCalendar:${uuidv4()}`,
|
|
180
|
+
from: 0,
|
|
181
|
+
path: link,
|
|
182
|
+
});
|
|
183
|
+
} catch (e) { /* ignore per user */ }
|
|
184
|
+
}
|
|
185
|
+
} catch (e) { /* ignore */ }
|
|
186
|
+
|
|
187
|
+
// Emails (best-effort)
|
|
188
|
+
try {
|
|
189
|
+
const users = await user.getUsersData(uids);
|
|
190
|
+
const emails = (users || []).map(u => u.email).filter(Boolean);
|
|
191
|
+
if (emails.length) {
|
|
192
|
+
await Emailer.send('equipmentCalendar', {
|
|
193
|
+
// Emailer templates: we provide a minimal HTML via meta.template? NodeBB typically uses email templates
|
|
194
|
+
// Here we rely on Emailer plugin config; this is best-effort and may vary per installation.
|
|
195
|
+
// Fallback: send each email individually with raw subject/text if supported.
|
|
196
|
+
subject: notifTitle,
|
|
197
|
+
body: `${notifBody}\n\n${link}`,
|
|
198
|
+
to: emails.join(','),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
} catch (e) {
|
|
202
|
+
// Many NodeBB installs do not support ad-hoc Emailer.send signatures; ignore gracefully.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- HelloAsso helpers ---
|
|
207
|
+
// This is a minimal integration skeleton.
|
|
208
|
+
// See HelloAsso API v5 docs for exact endpoints and payloads.
|
|
209
|
+
async function helloAssoGetAccessToken(settings) {
|
|
210
|
+
// HelloAsso uses OAuth2 client credentials.
|
|
211
|
+
// Endpoint: https://api.helloasso.com/oauth2/token
|
|
212
|
+
const url = 'https://api.helloasso.com/oauth2/token';
|
|
213
|
+
const params = new URLSearchParams();
|
|
214
|
+
params.append('grant_type', 'client_credentials');
|
|
215
|
+
params.append('client_id', settings.ha_clientId);
|
|
216
|
+
params.append('client_secret', settings.ha_clientSecret);
|
|
217
|
+
|
|
218
|
+
const resp = await axios.post(url, params.toString(), {
|
|
219
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
220
|
+
timeout: 15000,
|
|
221
|
+
});
|
|
222
|
+
return resp.data.access_token;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function helloAssoCreateCheckout(settings, token, reservation, item) {
|
|
226
|
+
// Minimal: create a checkout intent and return redirectUrl
|
|
227
|
+
// This endpoint/payload may need adaptation depending on your HelloAsso setup.
|
|
228
|
+
const org = settings.ha_organizationSlug;
|
|
229
|
+
if (!org) throw new Error('HelloAsso organizationSlug missing');
|
|
230
|
+
|
|
231
|
+
const url = `https://api.helloasso.com/v5/organizations/${encodeURIComponent(org)}/checkout-intents`;
|
|
232
|
+
const amountCents = Math.max(0, Number(item.priceCents || 0));
|
|
233
|
+
|
|
234
|
+
const payload = {
|
|
235
|
+
totalAmount: amountCents,
|
|
236
|
+
initialAmount: amountCents,
|
|
237
|
+
itemName: item.name,
|
|
238
|
+
backUrl: settings.ha_returnUrl || `${nconf.get('url')}/equipment/payment/return?rid=${encodeURIComponent(reservation.id)}`,
|
|
239
|
+
errorUrl: settings.ha_returnUrl || `${nconf.get('url')}/equipment/payment/return?rid=${encodeURIComponent(reservation.id)}&status=error`,
|
|
240
|
+
metadata: {
|
|
241
|
+
reservationId: reservation.id,
|
|
242
|
+
itemId: reservation.itemId,
|
|
243
|
+
uid: String(reservation.uid),
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const resp = await axios.post(url, payload, {
|
|
248
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
249
|
+
timeout: 15000,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Typical response fields (may vary): id, redirectUrl
|
|
253
|
+
return {
|
|
254
|
+
checkoutIntentId: resp.data.id || resp.data.checkoutIntentId || '',
|
|
255
|
+
paymentUrl: resp.data.redirectUrl || resp.data.paymentUrl || '',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Webhook signature check (simple HMAC SHA256 over raw body)
|
|
260
|
+
function verifyWebhook(req, secret) {
|
|
261
|
+
if (!secret) return true;
|
|
262
|
+
const sig = req.headers['x-helloasso-signature'] || req.headers['x-helloasso-signature-hmac-sha256'];
|
|
263
|
+
if (!sig) return false;
|
|
264
|
+
const raw = req.rawBody || '';
|
|
265
|
+
const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex');
|
|
266
|
+
// Some providers prefix, some base64; accept hex only in this skeleton
|
|
267
|
+
return String(sig).toLowerCase() === expected.toLowerCase();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- Rendering helpers ---
|
|
271
|
+
function toEvent(res, item, requesterName, canSeeRequester) {
|
|
272
|
+
const start = DateTime.fromMillis(res.startMs).toISO();
|
|
273
|
+
const end = DateTime.fromMillis(res.endMs).toISO();
|
|
274
|
+
|
|
275
|
+
let icon = '⏳';
|
|
276
|
+
let className = 'ec-status-pending';
|
|
277
|
+
if (res.status === 'approved_waiting_payment') { icon = '💳'; className = 'ec-status-awaitpay'; }
|
|
278
|
+
if (res.status === 'paid_validated') { icon = '✅'; className = 'ec-status-valid'; }
|
|
279
|
+
if (res.status === 'rejected' || res.status === 'cancelled') { icon = '❌'; className = 'ec-status-cancel'; }
|
|
280
|
+
|
|
281
|
+
const titleParts = [icon, item ? item.name : res.itemId];
|
|
282
|
+
if (canSeeRequester && requesterName) titleParts.push(`- ${requesterName}`);
|
|
283
|
+
return {
|
|
284
|
+
id: res.id,
|
|
285
|
+
title: titleParts.join(' '),
|
|
286
|
+
start,
|
|
287
|
+
end,
|
|
288
|
+
allDay: false,
|
|
289
|
+
className,
|
|
290
|
+
extendedProps: {
|
|
291
|
+
status: res.status,
|
|
292
|
+
itemId: res.itemId,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function clampRange(startStr, endStr, tz) {
|
|
298
|
+
const start = DateTime.fromISO(startStr, { zone: tz }).isValid ? DateTime.fromISO(startStr, { zone: tz }) : DateTime.now().setZone(tz).startOf('month');
|
|
299
|
+
const end = DateTime.fromISO(endStr, { zone: tz }).isValid ? DateTime.fromISO(endStr, { zone: tz }) : start.plus({ months: 1 });
|
|
300
|
+
// prevent crazy ranges
|
|
301
|
+
const maxDays = 62;
|
|
302
|
+
const diffDays = Math.abs(end.diff(start, 'days').days);
|
|
303
|
+
const safeEnd = diffDays > maxDays ? start.plus({ days: maxDays }) : end;
|
|
304
|
+
return { start, end: safeEnd };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- Routes ---
|
|
308
|
+
plugin.init = async function (params) {
|
|
309
|
+
const { router } = params;
|
|
310
|
+
|
|
311
|
+
// To verify webhook signature we need raw body; add a rawBody collector for this route only
|
|
312
|
+
router.post('/equipment/webhook/helloasso',
|
|
313
|
+
require.main.require('body-parser').text({ type: '*/*' }),
|
|
314
|
+
async (req, res) => {
|
|
315
|
+
req.rawBody = req.body || '';
|
|
316
|
+
let json;
|
|
317
|
+
try { json = JSON.parse(req.rawBody || '{}'); } catch (e) { json = {}; }
|
|
318
|
+
|
|
319
|
+
const settings = await getSettings();
|
|
320
|
+
if (!verifyWebhook(req, settings.ha_webhookSecret)) {
|
|
321
|
+
return res.status(401).json({ ok: false, error: 'invalid signature' });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Minimal mapping: expect metadata.reservationId and event indicating payment succeeded.
|
|
325
|
+
const reservationId = json?.metadata?.reservationId || json?.data?.metadata?.reservationId || '';
|
|
326
|
+
const paymentStatus = json?.eventType || json?.type || json?.status || 'unknown';
|
|
327
|
+
|
|
328
|
+
if (reservationId) {
|
|
329
|
+
const reservation = await getReservation(reservationId);
|
|
330
|
+
if (reservation) {
|
|
331
|
+
reservation.ha_paymentStatus = String(paymentStatus);
|
|
332
|
+
// Heuristic: if payload includes "OrderPaid" or "Payment" success
|
|
333
|
+
const isPaid = /paid|success|payment/i.test(String(paymentStatus)) || json?.data?.state === 'Paid';
|
|
334
|
+
if (isPaid) {
|
|
335
|
+
reservation.status = 'paid_validated';
|
|
336
|
+
reservation.ha_paidAtMs = Date.now();
|
|
337
|
+
}
|
|
338
|
+
reservation.updatedAtMs = Date.now();
|
|
339
|
+
await saveReservation(reservation);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return res.json({ ok: true });
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// Return endpoint (optional)
|
|
348
|
+
router.get('/equipment/payment/return', middleware.buildHeader, async (req, res) => {
|
|
349
|
+
res.render('equipment-calendar/payment-return', {
|
|
350
|
+
title: 'Paiement',
|
|
351
|
+
rid: req.query.rid || '',
|
|
352
|
+
status: req.query.status || 'ok',
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Page routes are attached via filter:router.page as well, but we also add directly to be safe:
|
|
357
|
+
router.get('/equipment/calendar', middleware.buildHeader, renderCalendarPage);
|
|
358
|
+
router.get('/equipment/approvals', middleware.buildHeader, renderApprovalsPage);
|
|
359
|
+
|
|
360
|
+
router.post('/equipment/reservations/create', middleware.applyCSRF, handleCreateReservation);
|
|
361
|
+
router.post('/equipment/reservations/:id/approve', middleware.applyCSRF, handleApproveReservation);
|
|
362
|
+
router.post('/equipment/reservations/:id/reject', middleware.applyCSRF, handleRejectReservation);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
plugin.addPageRoutes = async function (data) {
|
|
366
|
+
// Ensure routes are treated as "page" routes by NodeBB router filter
|
|
367
|
+
// Not strictly necessary when using router.get with buildHeader, but kept for compatibility.
|
|
368
|
+
return data;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
plugin.addAdminNavigation = async function (header) {
|
|
372
|
+
header.plugins.push({
|
|
373
|
+
route: '/plugins/equipment-calendar',
|
|
374
|
+
icon: 'fa-calendar',
|
|
375
|
+
name: 'Equipment Calendar',
|
|
376
|
+
});
|
|
377
|
+
return header;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// --- Admin page routes (ACP) ---
|
|
381
|
+
plugin.addAdminRoutes = async function (params) {
|
|
382
|
+
const { router, middleware: mid } = params;
|
|
383
|
+
router.get('/admin/plugins/equipment-calendar', mid.admin.buildHeader, renderAdminPage);
|
|
384
|
+
router.get('/api/admin/plugins/equipment-calendar', renderAdminPage);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
async function renderAdminPage(req, res) {
|
|
388
|
+
const settings = await getSettings();
|
|
389
|
+
res.render('admin/plugins/equipment-calendar', {
|
|
390
|
+
title: 'Equipment Calendar',
|
|
391
|
+
settings,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// --- Calendar page ---
|
|
396
|
+
async function renderCalendarPage(req, res) {
|
|
397
|
+
const settings = await getSettings();
|
|
398
|
+
const items = parseItems(settings.itemsJson).filter(i => i.active);
|
|
399
|
+
|
|
400
|
+
const tz = settings.timezone || 'Europe/Paris';
|
|
401
|
+
|
|
402
|
+
const itemId = String(req.query.itemId || (items[0]?.id || '')).trim();
|
|
403
|
+
const chosenItem = items.find(i => i.id === itemId) || items[0] || null;
|
|
404
|
+
|
|
405
|
+
// Determine range to render
|
|
406
|
+
const now = DateTime.now().setZone(tz);
|
|
407
|
+
const view = String(req.query.view || settings.defaultView || 'dayGridMonth');
|
|
408
|
+
const startQ = req.query.start;
|
|
409
|
+
const endQ = req.query.end;
|
|
410
|
+
|
|
411
|
+
let start, end;
|
|
412
|
+
if (startQ && endQ) {
|
|
413
|
+
const r = clampRange(String(startQ), String(endQ), tz);
|
|
414
|
+
start = r.start;
|
|
415
|
+
end = r.end;
|
|
416
|
+
} else {
|
|
417
|
+
// Default to current month range
|
|
418
|
+
start = now.startOf('month');
|
|
419
|
+
end = now.endOf('month').plus({ days: 1 });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Load reservations for chosen item within range
|
|
423
|
+
const reservations = chosenItem ? await listReservationsForRange(chosenItem.id, start.toMillis(), end.toMillis()) : [];
|
|
424
|
+
const showRequesterToAll = String(settings.showRequesterToAll) === '1';
|
|
425
|
+
const isApprover = req.uid ? await canApprove(req.uid, settings) : false;
|
|
426
|
+
|
|
427
|
+
const requesterUids = Array.from(new Set(reservations.map(r => r.uid))).filter(Boolean);
|
|
428
|
+
const users = requesterUids.length ? await user.getUsersData(requesterUids) : [];
|
|
429
|
+
const nameByUid = {};
|
|
430
|
+
(users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
|
|
431
|
+
|
|
432
|
+
const events = reservations
|
|
433
|
+
.filter(r => r.status !== 'rejected' && r.status !== 'cancelled')
|
|
434
|
+
.map(r => toEvent(r, chosenItem, nameByUid[r.uid], isApprover || showRequesterToAll));
|
|
435
|
+
|
|
436
|
+
const canUserCreate = req.uid ? await canCreate(req.uid, settings) : false;
|
|
437
|
+
|
|
438
|
+
res.render('equipment-calendar/calendar', {
|
|
439
|
+
title: 'Réservation de matériel',
|
|
440
|
+
items,
|
|
441
|
+
chosenItemId: chosenItem ? chosenItem.id : '',
|
|
442
|
+
chosenItemName: chosenItem ? chosenItem.name : '',
|
|
443
|
+
chosenItemPriceCents: chosenItem ? chosenItem.priceCents : 0,
|
|
444
|
+
chosenItemLocation: chosenItem ? chosenItem.location : '',
|
|
445
|
+
view,
|
|
446
|
+
tz,
|
|
447
|
+
startISO: start.toISO(),
|
|
448
|
+
endISO: end.toISO(),
|
|
449
|
+
initialDateISO: start.toISODate(),
|
|
450
|
+
eventsJson: JSON.stringify(events),
|
|
451
|
+
canCreate: canUserCreate,
|
|
452
|
+
isApprover,
|
|
453
|
+
csrf: req.csrfToken,
|
|
454
|
+
forumUrl: nconf.get('url'),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// --- Approvals page ---
|
|
459
|
+
async function renderApprovalsPage(req, res) {
|
|
460
|
+
const settings = await getSettings();
|
|
461
|
+
const ok = req.uid ? await canApprove(req.uid, settings) : false;
|
|
462
|
+
if (!ok) {
|
|
463
|
+
return helpers.notAllowed(req, res);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const items = parseItems(settings.itemsJson);
|
|
467
|
+
// naive scan: for a real install, store a global sorted set too; for now list per item and merge
|
|
468
|
+
// We'll pull recent 200 per item and then filter
|
|
469
|
+
const pending = [];
|
|
470
|
+
for (const it of items) {
|
|
471
|
+
const ids = await db.getSortedSetRevRange(itemIndexKey(it.id), 0, 199);
|
|
472
|
+
if (!ids || !ids.length) continue;
|
|
473
|
+
const objs = await db.getObjects(ids.map(resKey));
|
|
474
|
+
const resArr = (objs || []).filter(Boolean).map(normalizeReservation).filter(r => r.status === 'pending' || r.status === 'approved_waiting_payment');
|
|
475
|
+
for (const r of resArr) pending.push(r);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Sort by createdAt desc
|
|
479
|
+
pending.sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
|
|
480
|
+
|
|
481
|
+
const uids = Array.from(new Set(pending.map(r => r.uid))).filter(Boolean);
|
|
482
|
+
const users = uids.length ? await user.getUsersData(uids) : [];
|
|
483
|
+
const nameByUid = {};
|
|
484
|
+
(users || []).forEach(u => { nameByUid[u.uid] = u.username || ''; });
|
|
485
|
+
|
|
486
|
+
const rows = pending.map(r => {
|
|
487
|
+
const item = items.find(i => i.id === r.itemId);
|
|
488
|
+
return {
|
|
489
|
+
id: r.id,
|
|
490
|
+
itemName: item ? item.name : r.itemId,
|
|
491
|
+
requester: nameByUid[r.uid] || `uid:${r.uid}`,
|
|
492
|
+
start: DateTime.fromMillis(r.startMs).toFormat('dd/LL/yyyy HH:mm'),
|
|
493
|
+
end: DateTime.fromMillis(r.endMs).toFormat('dd/LL/yyyy HH:mm'),
|
|
494
|
+
status: r.status,
|
|
495
|
+
paymentUrl: r.ha_paymentUrl || '',
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
res.render('equipment-calendar/approvals', {
|
|
500
|
+
title: 'Validation des réservations',
|
|
501
|
+
rows,
|
|
502
|
+
csrf: req.csrfToken,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// --- Actions ---
|
|
507
|
+
async function handleCreateReservation(req, res) {
|
|
508
|
+
try {
|
|
509
|
+
const settings = await getSettings();
|
|
510
|
+
if (!req.uid || !(await canCreate(req.uid, settings))) {
|
|
511
|
+
return helpers.notAllowed(req, res);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const items = parseItems(settings.itemsJson).filter(i => i.active);
|
|
515
|
+
const itemId = String(req.body.itemId || '').trim();
|
|
516
|
+
const item = items.find(i => i.id === itemId);
|
|
517
|
+
if (!item) return res.status(400).send('Invalid item');
|
|
518
|
+
|
|
519
|
+
const tz = settings.timezone || 'Europe/Paris';
|
|
520
|
+
const start = DateTime.fromISO(String(req.body.start || ''), { zone: tz });
|
|
521
|
+
const end = DateTime.fromISO(String(req.body.end || ''), { zone: tz });
|
|
522
|
+
|
|
523
|
+
if (!start.isValid || !end.isValid) return res.status(400).send('Invalid dates');
|
|
524
|
+
const startMs = start.toMillis();
|
|
525
|
+
const endMs = end.toMillis();
|
|
526
|
+
|
|
527
|
+
if (endMs <= startMs) return res.status(400).send('End must be after start');
|
|
528
|
+
if (end.diff(start, 'days').days > 31) return res.status(400).send('Range too large');
|
|
529
|
+
|
|
530
|
+
// Overlap
|
|
531
|
+
if (await hasOverlap(itemId, startMs, endMs)) {
|
|
532
|
+
return res.status(409).send('Overlap');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const reservation = {
|
|
536
|
+
id: uuidv4(),
|
|
537
|
+
itemId,
|
|
538
|
+
uid: req.uid,
|
|
539
|
+
startMs,
|
|
540
|
+
endMs,
|
|
541
|
+
status: 'pending',
|
|
542
|
+
createdAtMs: Date.now(),
|
|
543
|
+
updatedAtMs: Date.now(),
|
|
544
|
+
validatorUid: 0,
|
|
545
|
+
notesUser: String(req.body.notesUser || '').slice(0, 2000),
|
|
546
|
+
notesAdmin: '',
|
|
547
|
+
ha_checkoutIntentId: '',
|
|
548
|
+
ha_paymentUrl: '',
|
|
549
|
+
ha_paymentStatus: '',
|
|
550
|
+
ha_paidAtMs: 0,
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
await saveReservation(reservation);
|
|
554
|
+
|
|
555
|
+
// Notify group
|
|
556
|
+
const link = `/equipment/approvals`;
|
|
557
|
+
await sendGroupNotificationAndEmail(
|
|
558
|
+
settings.notifyGroup || settings.approverGroup,
|
|
559
|
+
'Nouvelle demande de réservation',
|
|
560
|
+
`Une demande de réservation est en attente (matériel: ${item.name}).`,
|
|
561
|
+
link
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
return res.redirect(`/equipment/calendar?itemId=${encodeURIComponent(itemId)}`);
|
|
565
|
+
} catch (e) {
|
|
566
|
+
return res.status(500).send(e.message || 'error');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function handleApproveReservation(req, res) {
|
|
571
|
+
try {
|
|
572
|
+
const settings = await getSettings();
|
|
573
|
+
if (!req.uid || !(await canApprove(req.uid, settings))) {
|
|
574
|
+
return helpers.notAllowed(req, res);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const id = String(req.params.id || '').trim();
|
|
578
|
+
const reservation = await getReservation(id);
|
|
579
|
+
if (!reservation) return res.status(404).send('Not found');
|
|
580
|
+
if (reservation.status !== 'pending' && reservation.status !== 'approved_waiting_payment') {
|
|
581
|
+
return res.status(409).send('Invalid status');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const items = parseItems(settings.itemsJson);
|
|
585
|
+
const item = items.find(i => i.id === reservation.itemId);
|
|
586
|
+
if (!item) return res.status(400).send('Invalid item');
|
|
587
|
+
|
|
588
|
+
// Create HelloAsso checkout
|
|
589
|
+
if (!reservation.ha_paymentUrl) {
|
|
590
|
+
if (!settings.ha_clientId || !settings.ha_clientSecret) {
|
|
591
|
+
return res.status(400).send('HelloAsso not configured');
|
|
592
|
+
}
|
|
593
|
+
const token = await helloAssoGetAccessToken(settings);
|
|
594
|
+
const checkout = await helloAssoCreateCheckout(settings, token, reservation, item);
|
|
595
|
+
reservation.ha_checkoutIntentId = checkout.checkoutIntentId;
|
|
596
|
+
reservation.ha_paymentUrl = checkout.paymentUrl;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
reservation.status = 'approved_waiting_payment';
|
|
600
|
+
reservation.validatorUid = req.uid;
|
|
601
|
+
reservation.updatedAtMs = Date.now();
|
|
602
|
+
await saveReservation(reservation);
|
|
603
|
+
|
|
604
|
+
// Notify requester with payment link (best-effort)
|
|
605
|
+
try {
|
|
606
|
+
const u = await user.getUserData(reservation.uid);
|
|
607
|
+
const subject = 'Réservation approuvée - Paiement requis';
|
|
608
|
+
const body = `Ta réservation de "${item.name}" a été approuvée.\n\nLien de paiement:\n${reservation.ha_paymentUrl}\n`;
|
|
609
|
+
// NodeBB Emailer signature may vary; this is best-effort
|
|
610
|
+
await Emailer.send('equipmentCalendar-payment', {
|
|
611
|
+
subject,
|
|
612
|
+
body,
|
|
613
|
+
to: u.email,
|
|
614
|
+
});
|
|
615
|
+
} catch (e) { /* ignore */ }
|
|
616
|
+
|
|
617
|
+
return res.redirect('/equipment/approvals');
|
|
618
|
+
} catch (e) {
|
|
619
|
+
return res.status(500).send(e.message || 'error');
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function handleRejectReservation(req, res) {
|
|
624
|
+
try {
|
|
625
|
+
const settings = await getSettings();
|
|
626
|
+
if (!req.uid || !(await canApprove(req.uid, settings))) {
|
|
627
|
+
return helpers.notAllowed(req, res);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const id = String(req.params.id || '').trim();
|
|
631
|
+
const reservation = await getReservation(id);
|
|
632
|
+
if (!reservation) return res.status(404).send('Not found');
|
|
633
|
+
if (reservation.status !== 'pending' && reservation.status !== 'approved_waiting_payment') {
|
|
634
|
+
return res.status(409).send('Invalid status');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
reservation.status = 'rejected';
|
|
638
|
+
reservation.validatorUid = req.uid;
|
|
639
|
+
reservation.notesAdmin = String(req.body.notesAdmin || '').slice(0, 2000);
|
|
640
|
+
reservation.updatedAtMs = Date.now();
|
|
641
|
+
await saveReservation(reservation);
|
|
642
|
+
|
|
643
|
+
return res.redirect('/equipment/approvals');
|
|
644
|
+
} catch (e) {
|
|
645
|
+
return res.status(500).send(e.message || 'error');
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
module.exports = plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nodebb-plugin-equipment-calendar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Equipment reservation calendar for NodeBB (FullCalendar, approvals, HelloAsso payments)",
|
|
5
|
+
"main": "library.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"lint": "node -v"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"nodebb",
|
|
11
|
+
"plugin",
|
|
12
|
+
"calendar",
|
|
13
|
+
"fullcalendar",
|
|
14
|
+
"helloasso"
|
|
15
|
+
],
|
|
16
|
+
"author": "Generated by ChatGPT",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"axios": "^1.7.9",
|
|
20
|
+
"luxon": "^3.5.0",
|
|
21
|
+
"uuid": "^9.0.1"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/plugin.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "nodebb-plugin-equipment-calendar",
|
|
3
|
+
"name": "Equipment Calendar",
|
|
4
|
+
"description": "Calendar-based equipment reservations with group approvals and HelloAsso payments.",
|
|
5
|
+
"url": "https://example.invalid",
|
|
6
|
+
"library": "./library.js",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"hook": "static:app.load",
|
|
10
|
+
"method": "init"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"hook": "filter:admin.header.build",
|
|
14
|
+
"method": "addAdminNavigation"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"hook": "filter:router.page",
|
|
18
|
+
"method": "addPageRoutes"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"staticDirs": {
|
|
22
|
+
"public": "./public"
|
|
23
|
+
},
|
|
24
|
+
"templates": "./public/templates"
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* global $, app */
|
|
3
|
+
|
|
4
|
+
define('admin/plugins/equipment-calendar', function () {
|
|
5
|
+
const EquipmentCalendar = {};
|
|
6
|
+
|
|
7
|
+
EquipmentCalendar.init = function () {
|
|
8
|
+
$('#save').on('click', function () {
|
|
9
|
+
const payload = {
|
|
10
|
+
creatorGroups: $('#creatorGroups').val(),
|
|
11
|
+
approverGroup: $('#approverGroup').val(),
|
|
12
|
+
notifyGroup: $('#notifyGroup').val(),
|
|
13
|
+
itemsJson: $('#itemsJson').val(),
|
|
14
|
+
ha_clientId: $('#ha_clientId').val(),
|
|
15
|
+
ha_clientSecret: $('#ha_clientSecret').val(),
|
|
16
|
+
ha_organizationSlug: $('#ha_organizationSlug').val(),
|
|
17
|
+
ha_returnUrl: $('#ha_returnUrl').val(),
|
|
18
|
+
ha_webhookSecret: $('#ha_webhookSecret').val(),
|
|
19
|
+
defaultView: $('#defaultView').val(),
|
|
20
|
+
timezone: $('#timezone').val(),
|
|
21
|
+
showRequesterToAll: $('#showRequesterToAll').is(':checked') ? '1' : '0',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
socket.emit('admin.settings.save', { hash: 'equipmentCalendar', values: payload }, function (err) {
|
|
25
|
+
if (err) return app.alertError(err.message || err);
|
|
26
|
+
app.alertSuccess('Sauvegardé');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return EquipmentCalendar;
|
|
32
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* global $, window, document, FullCalendar */
|
|
3
|
+
|
|
4
|
+
(function () {
|
|
5
|
+
function submitCreate(startISO, endISO) {
|
|
6
|
+
const form = document.getElementById('ec-create-form');
|
|
7
|
+
if (!form) return;
|
|
8
|
+
form.querySelector('input[name="start"]').value = startISO;
|
|
9
|
+
form.querySelector('input[name="end"]').value = endISO;
|
|
10
|
+
form.submit();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
$(document).ready(function () {
|
|
14
|
+
const el = document.getElementById('equipment-calendar');
|
|
15
|
+
if (!el) return;
|
|
16
|
+
|
|
17
|
+
const events = window.EC_EVENTS || [];
|
|
18
|
+
const initialDate = window.EC_INITIAL_DATE;
|
|
19
|
+
const initialView = window.EC_INITIAL_VIEW || 'dayGridMonth';
|
|
20
|
+
|
|
21
|
+
const calendar = new FullCalendar.Calendar(el, {
|
|
22
|
+
initialView: initialView,
|
|
23
|
+
initialDate: initialDate,
|
|
24
|
+
timeZone: window.EC_TZ || 'local',
|
|
25
|
+
selectable: window.EC_CAN_CREATE === true,
|
|
26
|
+
selectMirror: true,
|
|
27
|
+
events: events,
|
|
28
|
+
select: function (info) {
|
|
29
|
+
// FullCalendar provides end exclusive for all-day; for timed selections it's fine.
|
|
30
|
+
submitCreate(info.startStr, info.endStr);
|
|
31
|
+
},
|
|
32
|
+
dateClick: function (info) {
|
|
33
|
+
// Create 1-hour slot by default
|
|
34
|
+
const start = info.date;
|
|
35
|
+
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
|
36
|
+
submitCreate(start.toISOString(), end.toISOString());
|
|
37
|
+
},
|
|
38
|
+
eventClick: function (info) {
|
|
39
|
+
// optionally show details
|
|
40
|
+
},
|
|
41
|
+
headerToolbar: {
|
|
42
|
+
left: 'prev,next today',
|
|
43
|
+
center: 'title',
|
|
44
|
+
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
calendar.render();
|
|
49
|
+
});
|
|
50
|
+
}());
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.equipment-calendar-page { }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<div class="acp-page-container">
|
|
2
|
+
<h1>Equipment Calendar</h1>
|
|
3
|
+
|
|
4
|
+
<div class="alert alert-warning">
|
|
5
|
+
Les champs "Matériel" doivent être un JSON valide (array). Exemple :
|
|
6
|
+
<pre class="mb-0">[
|
|
7
|
+
{ "id": "cam1", "name": "Caméra A", "priceCents": 5000, "location": "Stock A", "active": true },
|
|
8
|
+
{ "id": "light1", "name": "Projecteur", "priceCents": 2000, "location": "Stock B", "active": true }
|
|
9
|
+
]</pre>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="row">
|
|
13
|
+
<div class="col-lg-8">
|
|
14
|
+
<div class="card card-body mb-3">
|
|
15
|
+
<h5>Permissions</h5>
|
|
16
|
+
<div class="mb-3">
|
|
17
|
+
<label class="form-label">Groupes autorisés à créer (CSV)</label>
|
|
18
|
+
<input id="creatorGroups" class="form-control" value="{{settings.creatorGroups}}">
|
|
19
|
+
</div>
|
|
20
|
+
<div class="mb-3">
|
|
21
|
+
<label class="form-label">Groupe validateur</label>
|
|
22
|
+
<input id="approverGroup" class="form-control" value="{{settings.approverGroup}}">
|
|
23
|
+
</div>
|
|
24
|
+
<div class="mb-3">
|
|
25
|
+
<label class="form-label">Groupe notifié (emails/notifs)</label>
|
|
26
|
+
<input id="notifyGroup" class="form-control" value="{{settings.notifyGroup}}">
|
|
27
|
+
</div>
|
|
28
|
+
<div class="form-check">
|
|
29
|
+
<input class="form-check-input" type="checkbox" id="showRequesterToAll" {{#if (eq settings.showRequesterToAll "1")}}checked{{/if}}>
|
|
30
|
+
<label class="form-check-label" for="showRequesterToAll">Afficher le demandeur à tout le monde</label>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="card card-body mb-3">
|
|
35
|
+
<h5>Matériel</h5>
|
|
36
|
+
<textarea id="itemsJson" class="form-control" rows="10">{{settings.itemsJson}}</textarea>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div class="card card-body mb-3">
|
|
40
|
+
<h5>HelloAsso</h5>
|
|
41
|
+
<div class="mb-3"><label class="form-label">Client ID</label><input id="ha_clientId" class="form-control" value="{{settings.ha_clientId}}"></div>
|
|
42
|
+
<div class="mb-3"><label class="form-label">Client Secret</label><input id="ha_clientSecret" class="form-control" value="{{settings.ha_clientSecret}}"></div>
|
|
43
|
+
<div class="mb-3"><label class="form-label">Organization Slug</label><input id="ha_organizationSlug" class="form-control" value="{{settings.ha_organizationSlug}}"></div>
|
|
44
|
+
<div class="mb-3"><label class="form-label">Return URL</label><input id="ha_returnUrl" class="form-control" value="{{settings.ha_returnUrl}}"></div>
|
|
45
|
+
<div class="mb-3"><label class="form-label">Webhook Secret (HMAC SHA256)</label><input id="ha_webhookSecret" class="form-control" value="{{settings.ha_webhookSecret}}"></div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="card card-body mb-3">
|
|
49
|
+
<h5>Calendrier</h5>
|
|
50
|
+
<div class="mb-3"><label class="form-label">Vue par défaut</label>
|
|
51
|
+
<select id="defaultView" class="form-select">
|
|
52
|
+
<option value="dayGridMonth" {{#if (eq settings.defaultView "dayGridMonth")}}selected{{/if}}>Mois</option>
|
|
53
|
+
<option value="timeGridWeek" {{#if (eq settings.defaultView "timeGridWeek")}}selected{{/if}}>Semaine</option>
|
|
54
|
+
<option value="timeGridDay" {{#if (eq settings.defaultView "timeGridDay")}}selected{{/if}}>Jour</option>
|
|
55
|
+
</select>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="mb-3"><label class="form-label">Timezone</label><input id="timezone" class="form-control" value="{{settings.timezone}}"></div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<button id="save" class="btn btn-primary">Sauvegarder</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<script>
|
|
66
|
+
require(['admin/plugins/equipment-calendar'], function (m) { m.init(); });
|
|
67
|
+
</script>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<div class="equipment-approvals-page">
|
|
2
|
+
<h1>Validation des réservations</h1>
|
|
3
|
+
|
|
4
|
+
{{#if rows.length}}
|
|
5
|
+
<div class="table-responsive">
|
|
6
|
+
<table class="table table-striped align-middle">
|
|
7
|
+
<thead>
|
|
8
|
+
<tr>
|
|
9
|
+
<th>Matériel</th>
|
|
10
|
+
<th>Demandeur</th>
|
|
11
|
+
<th>Début</th>
|
|
12
|
+
<th>Fin</th>
|
|
13
|
+
<th>Statut</th>
|
|
14
|
+
<th>Paiement</th>
|
|
15
|
+
<th>Actions</th>
|
|
16
|
+
</tr>
|
|
17
|
+
</thead>
|
|
18
|
+
<tbody>
|
|
19
|
+
{{#rows}}
|
|
20
|
+
<tr>
|
|
21
|
+
<td>{{itemName}}</td>
|
|
22
|
+
<td>{{requester}}</td>
|
|
23
|
+
<td>{{start}}</td>
|
|
24
|
+
<td>{{end}}</td>
|
|
25
|
+
<td><code>{{status}}</code></td>
|
|
26
|
+
<td>
|
|
27
|
+
{{#if paymentUrl}}
|
|
28
|
+
<a href="{{paymentUrl}}" target="_blank" rel="noreferrer">Lien</a>
|
|
29
|
+
{{else}}
|
|
30
|
+
-
|
|
31
|
+
{{/if}}
|
|
32
|
+
</td>
|
|
33
|
+
<td>
|
|
34
|
+
<form method="post" action="/equipment/reservations/{{id}}/approve" class="d-inline">
|
|
35
|
+
<input type="hidden" name="_csrf" value="{{../csrf}}">
|
|
36
|
+
<button class="btn btn-sm btn-success" type="submit">Approuver</button>
|
|
37
|
+
</form>
|
|
38
|
+
<form method="post" action="/equipment/reservations/{{id}}/reject" class="d-inline ms-1">
|
|
39
|
+
<input type="hidden" name="_csrf" value="{{../csrf}}">
|
|
40
|
+
<button class="btn btn-sm btn-danger" type="submit">Refuser</button>
|
|
41
|
+
</form>
|
|
42
|
+
</td>
|
|
43
|
+
</tr>
|
|
44
|
+
{{/rows}}
|
|
45
|
+
</tbody>
|
|
46
|
+
</table>
|
|
47
|
+
</div>
|
|
48
|
+
{{else}}
|
|
49
|
+
<div class="alert alert-success">Aucune demande en attente 🎉</div>
|
|
50
|
+
{{/if}}
|
|
51
|
+
</div>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<div class="equipment-calendar-page">
|
|
2
|
+
<h1>Réservation de matériel</h1>
|
|
3
|
+
|
|
4
|
+
<div class="mb-3">
|
|
5
|
+
<form method="get" action="/equipment/calendar" class="d-flex gap-2 align-items-end">
|
|
6
|
+
<div>
|
|
7
|
+
<label class="form-label">Matériel</label>
|
|
8
|
+
<select name="itemId" class="form-select" onchange="this.form.submit()">
|
|
9
|
+
{{#items}}
|
|
10
|
+
<option value="{{id}}" {{#if (eq ../chosenItemId id)}}selected{{/if}}>{{name}} — {{location}}</option>
|
|
11
|
+
{{/items}}
|
|
12
|
+
</select>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="text-muted small">
|
|
15
|
+
<div><strong>Lieu:</strong> {{chosenItemLocation}}</div>
|
|
16
|
+
<div><strong>Prix:</strong> {{chosenItemPriceCents}} cts</div>
|
|
17
|
+
</div>
|
|
18
|
+
</form>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
{{#if canCreate}}
|
|
22
|
+
<form id="ec-create-form" method="post" action="/equipment/reservations/create" class="card card-body mb-3">
|
|
23
|
+
<input type="hidden" name="_csrf" value="{{csrf}}">
|
|
24
|
+
<input type="hidden" name="itemId" value="{{chosenItemId}}">
|
|
25
|
+
<input type="hidden" name="start" value="">
|
|
26
|
+
<input type="hidden" name="end" value="">
|
|
27
|
+
<div class="row g-2 align-items-end">
|
|
28
|
+
<div class="col-md-8">
|
|
29
|
+
<label class="form-label">Note (optionnel)</label>
|
|
30
|
+
<input class="form-control" type="text" name="notesUser" maxlength="2000" placeholder="Ex: besoin de trépied, etc.">
|
|
31
|
+
</div>
|
|
32
|
+
<div class="col-md-4">
|
|
33
|
+
<button class="btn btn-primary w-100" type="submit" onclick="return false;">Sélectionne une date sur le calendrier</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="form-text">Clique sur une date ou sélectionne une plage sur le calendrier pour soumettre une demande.</div>
|
|
37
|
+
</form>
|
|
38
|
+
{{else}}
|
|
39
|
+
<div class="alert alert-info">Tu peux consulter le calendrier, mais tu n’as pas les droits pour créer une demande.</div>
|
|
40
|
+
{{/if}}
|
|
41
|
+
|
|
42
|
+
<div class="card card-body">
|
|
43
|
+
<div id="equipment-calendar"></div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
{{#if isApprover}}
|
|
47
|
+
<div class="mt-3">
|
|
48
|
+
<a class="btn btn-outline-secondary" href="/equipment/approvals">Aller à la validation</a>
|
|
49
|
+
</div>
|
|
50
|
+
{{/if}}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.css">
|
|
54
|
+
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js"></script>
|
|
55
|
+
<script src="/plugins/nodebb-plugin-equipment-calendar/lib/client.js"></script>
|
|
56
|
+
|
|
57
|
+
<script>
|
|
58
|
+
window.EC_EVENTS = {{{eventsJson}}};
|
|
59
|
+
window.EC_INITIAL_DATE = "{{initialDateISO}}";
|
|
60
|
+
window.EC_INITIAL_VIEW = "{{view}}";
|
|
61
|
+
window.EC_TZ = "{{tz}}";
|
|
62
|
+
window.EC_CAN_CREATE = {{#if canCreate}}true{{else}}false{{/if}};
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<style>
|
|
66
|
+
.ec-status-pending .fc-event-title { font-weight: 600; }
|
|
67
|
+
.ec-status-valid .fc-event-title { font-weight: 700; }
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<div class="equipment-payment-return">
|
|
2
|
+
<h1>Paiement</h1>
|
|
3
|
+
{{#if (eq status "error")}}
|
|
4
|
+
<div class="alert alert-danger">Le paiement semble avoir échoué. Référence réservation: <code>{{rid}}</code></div>
|
|
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>
|
|
7
|
+
{{/if}}
|
|
8
|
+
<a class="btn btn-primary" href="/equipment/calendar">Retour au calendrier</a>
|
|
9
|
+
</div>
|