nodebb-plugin-onekite-calendar 2.0.16 → 2.0.18

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog – calendar-onekite
2
2
 
3
+ ## 1.3.12
4
+ - Expiration automatique : envoi d’un email au demandeur avec la raison :
5
+ - « Demande non prise en charge dans le temps imparti » (demande en attente)
6
+ - « Paiement non reçu dans le temps imparti » (paiement en attente)
7
+ - ACP : ajout de 2 paramètres pour envoyer un rappel email aux validateurs sur les demandes en attente / paiement en attente.
8
+ - Emails validateurs : nouveaux templates (rappel + expiration) avec lien vers l’ACP.
9
+
10
+ ## 1.3.11
11
+ - ACP (Demandes en attente) : affichage du pseudo du demandeur avec lien vers sa fiche utilisateur.
12
+
13
+ ## 1.3.10
14
+ - Mobile FAB : surlignage de la sélection de dates rendu lisible en light mode (contraste renforcé, start/end en primary).
15
+
3
16
  ## 1.3.9
4
17
  - Mobile FAB : correction de la sélection de plage (tous les jours sélectionnés sont désormais bien inclus dans la surbrillance).
5
18
  - Mobile FAB : amélioration du rendu en mode sombre (bordures/fonds adaptés, contraste renforcé).
package/lib/admin.js CHANGED
@@ -101,6 +101,30 @@ admin.listPending = async function (req, res) {
101
101
  const rows = await dbLayer.getReservations(ids);
102
102
  const pending = (rows || []).filter(r => r && r.status === 'pending');
103
103
  pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
104
+
105
+ // Enrich with user info for ACP display (username + userslug)
106
+ try {
107
+ const uids = Array.from(new Set(pending
108
+ .map(r => r && r.uid)
109
+ .map(v => (v ? parseInt(v, 10) : NaN))
110
+ .filter(v => Number.isInteger(v) && v > 0)));
111
+
112
+ if (uids.length) {
113
+ // user.getUsersFields exists in NodeBB 4.x
114
+ const users = await user.getUsersFields(uids, ['uid', 'username', 'userslug']);
115
+ const byUid = new Map((users || []).filter(Boolean).map(u => [String(u.uid), u]));
116
+ for (const r of pending) {
117
+ const u = byUid.get(String(r.uid));
118
+ if (u) {
119
+ r.username = u.username || r.username || '';
120
+ r.userslug = u.userslug || r.userslug || '';
121
+ }
122
+ }
123
+ }
124
+ } catch (e) {
125
+ // Best-effort only; keep API working even if user lookups fail
126
+ }
127
+
104
128
  res.json(pending);
105
129
  };
106
130
 
package/lib/scheduler.js CHANGED
@@ -4,6 +4,8 @@ const meta = require.main.require('./src/meta');
4
4
  const db = require.main.require('./src/database');
5
5
  const dbLayer = require('./db');
6
6
  const discord = require('./discord');
7
+ const nconf = require.main.require('nconf');
8
+ const groups = require.main.require('./src/groups');
7
9
 
8
10
  let timer = null;
9
11
 
@@ -14,12 +16,136 @@ function getSetting(settings, key, fallback) {
14
16
  return v;
15
17
  }
16
18
 
19
+ function formatFR(ts) {
20
+ const d = new Date(ts);
21
+ const dd = String(d.getDate()).padStart(2, '0');
22
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
23
+ const yyyy = d.getFullYear();
24
+ return `${dd}/${mm}/${yyyy}`;
25
+ }
26
+
27
+ function forumBaseUrl() {
28
+ const base = String(nconf.get('url') || '').trim().replace(/\/$/, '');
29
+ return base;
30
+ }
31
+
32
+ // Resolve group identifiers from ACP (name or slug) and return UIDs.
33
+ async function getMembersByGroupIdentifier(groupIdentifier) {
34
+ const id = String(groupIdentifier || '').trim();
35
+ if (!id) return [];
36
+
37
+ let members = [];
38
+ try {
39
+ members = await groups.getMembers(id, 0, -1);
40
+ } catch (e) {
41
+ members = [];
42
+ }
43
+ if (Array.isArray(members) && members.length) return members;
44
+
45
+ if (typeof groups.getGroupNameByGroupSlug === 'function') {
46
+ let groupName = null;
47
+ try {
48
+ if (groups.getGroupNameByGroupSlug.length >= 2) {
49
+ groupName = await new Promise((resolve) => {
50
+ groups.getGroupNameByGroupSlug(id, (err, name) => resolve(err ? null : name));
51
+ });
52
+ } else {
53
+ groupName = await groups.getGroupNameByGroupSlug(id);
54
+ }
55
+ } catch (e) {
56
+ groupName = null;
57
+ }
58
+
59
+ if (groupName && String(groupName).trim() && String(groupName).trim() !== id) {
60
+ try {
61
+ members = await groups.getMembers(String(groupName).trim(), 0, -1);
62
+ } catch (e) {
63
+ members = [];
64
+ }
65
+ if (Array.isArray(members) && members.length) return members;
66
+ }
67
+ }
68
+
69
+ return Array.isArray(members) ? members : [];
70
+ }
71
+
72
+ function normalizeUids(members) {
73
+ if (!Array.isArray(members)) return [];
74
+ const out = [];
75
+ for (const m of members) {
76
+ if (Number.isInteger(m)) { out.push(m); continue; }
77
+ if (typeof m === 'string' && m.trim() && !Number.isNaN(parseInt(m, 10))) { out.push(parseInt(m, 10)); continue; }
78
+ if (m && typeof m === 'object' && (Number.isInteger(m.uid) || (typeof m.uid === 'string' && m.uid.trim()))) {
79
+ const u = Number.isInteger(m.uid) ? m.uid : parseInt(m.uid, 10);
80
+ if (!Number.isNaN(u)) out.push(u);
81
+ }
82
+ }
83
+ return Array.from(new Set(out)).filter((u) => Number.isInteger(u) && u > 0);
84
+ }
85
+
86
+ function normalizeAllowedGroups(raw) {
87
+ if (!raw) return [];
88
+ if (Array.isArray(raw)) {
89
+ return raw.map((s) => String(s || '').trim()).filter(Boolean);
90
+ }
91
+ return String(raw)
92
+ .split(',')
93
+ .map((s) => String(s || '').trim())
94
+ .filter(Boolean);
95
+ }
96
+
97
+ async function getValidatorUids(settings) {
98
+ const out = new Set();
99
+ // Always include administrators
100
+ try {
101
+ const admins = await getMembersByGroupIdentifier('administrators');
102
+ normalizeUids(admins).forEach((u) => out.add(u));
103
+ } catch (e) {}
104
+
105
+ const groupsCsv = normalizeAllowedGroups(settings && settings.validatorGroups);
106
+ for (const g of groupsCsv) {
107
+ try {
108
+ const members = await getMembersByGroupIdentifier(g);
109
+ normalizeUids(members).forEach((u) => out.add(u));
110
+ } catch (e) {}
111
+ }
112
+ return Array.from(out);
113
+ }
114
+
17
115
  // Pending holds: short lock after a user creates a request (defaults to 5 minutes)
18
116
  async function expirePending() {
19
117
  const settings = await meta.settings.get('calendar-onekite');
20
118
  const holdMins = parseInt(getSetting(settings, 'pendingHoldMinutes', '5'), 10) || 5;
119
+ const validatorReminderMins = parseInt(getSetting(settings, 'validatorReminderMinutesPending', '0'), 10) || 0;
21
120
  const now = Date.now();
22
121
 
122
+ const emailer = require.main.require('./src/emailer');
123
+ const user = require.main.require('./src/user');
124
+
125
+ async function sendEmail(template, uid, subject, data) {
126
+ const toUid = Number.isInteger(uid) ? uid : (uid ? parseInt(uid, 10) : NaN);
127
+ if (!Number.isInteger(toUid) || toUid <= 0) return;
128
+ const params = Object.assign({}, data || {}, subject ? { subject } : {});
129
+ try {
130
+ if (typeof emailer.send !== 'function') return;
131
+ await emailer.send(template, toUid, params);
132
+ } catch (err) {
133
+ // eslint-disable-next-line no-console
134
+ console.warn('[calendar-onekite] Failed to send email (scheduler)', {
135
+ template,
136
+ uid: toUid,
137
+ err: String((err && err.message) || err),
138
+ });
139
+ }
140
+ }
141
+
142
+ const adminUrl = (() => {
143
+ const base = forumBaseUrl();
144
+ return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
145
+ })();
146
+
147
+ const validatorUids = validatorReminderMins > 0 ? await getValidatorUids(settings) : [];
148
+
23
149
  const ids = await dbLayer.listAllReservationIds(5000);
24
150
  if (!ids || !ids.length) {
25
151
  return;
@@ -32,8 +158,66 @@ async function expirePending() {
32
158
  }
33
159
  const createdAt = parseInt(resv.createdAt, 10) || 0;
34
160
  const expiresAt = createdAt + holdMins * 60 * 1000;
161
+
162
+ // Reminder to validators while still pending
163
+ if (validatorReminderMins > 0 && createdAt && now >= (createdAt + validatorReminderMins * 60 * 1000) && now < expiresAt) {
164
+ const reminderKey = 'calendar-onekite:email:validatorReminder:pending';
165
+ const first = await db.setAdd(reminderKey, rid);
166
+ if (first && validatorUids.length) {
167
+ const requesterUid = parseInt(resv.uid, 10) || 0;
168
+ const requester = requesterUid ? await user.getUserFields(requesterUid, ['username']) : null;
169
+ const requesterUsername = requester && requester.username ? requester.username : (resv.username || '');
170
+ for (const vuid of validatorUids) {
171
+ await sendEmail('calendar-onekite_validator_reminder', vuid, 'Location matériel - Demande en attente', {
172
+ rid,
173
+ requesterUsername,
174
+ itemNames: (Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : [])).filter(Boolean),
175
+ dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
176
+ adminUrl,
177
+ kind: 'pending',
178
+ delayMinutes: validatorReminderMins,
179
+ });
180
+ }
181
+ }
182
+ }
183
+
35
184
  if (now > expiresAt) {
36
- // Expire (remove from calendar)
185
+ // Expire (remove from calendar) + notify requester + validators
186
+ const expiredKey = 'calendar-onekite:email:expiredSent:pending';
187
+ const firstExpired = await db.setAdd(expiredKey, rid);
188
+
189
+ const requesterUid = parseInt(resv.uid, 10) || 0;
190
+ const requester = requesterUid ? await user.getUserFields(requesterUid, ['username']) : null;
191
+ const requesterUsername = requester && requester.username ? requester.username : (resv.username || '');
192
+
193
+ const reason = 'Demande non prise en charge dans le temps imparti.';
194
+
195
+ if (firstExpired && requesterUid) {
196
+ await sendEmail('calendar-onekite_expired', requesterUid, 'Location matériel - Demande expirée', {
197
+ uid: requesterUid,
198
+ username: requesterUsername,
199
+ itemNames: (Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : [])).filter(Boolean),
200
+ dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
201
+ delayMinutes: holdMins,
202
+ reason,
203
+ });
204
+ }
205
+
206
+ // Validators info email (best-effort)
207
+ if (firstExpired) {
208
+ const validators = await getValidatorUids(settings);
209
+ for (const vuid of validators) {
210
+ await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
211
+ rid,
212
+ requesterUsername,
213
+ itemNames: (Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : [])).filter(Boolean),
214
+ dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
215
+ adminUrl,
216
+ reason,
217
+ });
218
+ }
219
+ }
220
+
37
221
  await dbLayer.removeReservation(rid);
38
222
  }
39
223
  }
@@ -87,6 +271,11 @@ async function processAwaitingPayment() {
87
271
  return `${dd}/${mm}/${yyyy}`;
88
272
  }
89
273
 
274
+ const adminUrl = (() => {
275
+ const base = forumBaseUrl();
276
+ return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
277
+ })();
278
+
90
279
  for (const rid of ids) {
91
280
  const r = await dbLayer.getReservation(rid);
92
281
  if (!r || r.status !== 'awaiting_payment') continue;
@@ -146,16 +335,33 @@ async function processAwaitingPayment() {
146
335
  const u = await user.getUserFields(toUid, ['username']);
147
336
 
148
337
  if (shouldEmail && toUid) {
149
- await sendEmail('calendar-onekite_expired', toUid, 'Location matériel - Rappel', {
338
+ await sendEmail('calendar-onekite_expired', toUid, 'Location matériel - Demande expirée', {
150
339
  uid: toUid,
151
340
  username: (u && u.username) ? u.username : '',
152
341
  itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
153
342
  itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
154
343
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
155
344
  delayMinutes: holdMins,
345
+ reason: 'Paiement non reçu dans le temps imparti.',
156
346
  });
157
347
  }
158
348
 
349
+ // Validators info email (best-effort)
350
+ if (shouldEmail) {
351
+ const validators = await getValidatorUids(settings);
352
+ for (const vuid of validators) {
353
+ await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
354
+ rid: r.rid || rid,
355
+ requesterUsername: (u && u.username) ? u.username : (r.username || ''),
356
+ itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
357
+ dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
358
+ adminUrl,
359
+ reason: 'Paiement non reçu dans le temps imparti.',
360
+ paymentUrl: r.paymentUrl || '',
361
+ });
362
+ }
363
+ }
364
+
159
365
  if (shouldDiscord) {
160
366
  try {
161
367
  await discord.notifyReservationCancelled(settings, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -101,6 +101,29 @@ admin.listPending = async function (req, res) {
101
101
  const rows = await dbLayer.getReservations(ids);
102
102
  const pending = (rows || []).filter(r => r && r.status === 'pending');
103
103
  pending.sort((a, b) => parseInt(a.start, 10) - parseInt(b.start, 10));
104
+
105
+ // Enrich with user info for ACP display (username + userslug)
106
+ try {
107
+ const uids = Array.from(new Set(pending
108
+ .map(r => r && r.uid)
109
+ .map(v => (v ? parseInt(v, 10) : NaN))
110
+ .filter(v => Number.isInteger(v) && v > 0)));
111
+
112
+ if (uids.length) {
113
+ const users = await user.getUsersFields(uids, ['uid', 'username', 'userslug']);
114
+ const byUid = new Map((users || []).filter(Boolean).map(u => [String(u.uid), u]));
115
+ for (const r of pending) {
116
+ const u = byUid.get(String(r.uid));
117
+ if (u) {
118
+ r.username = u.username || r.username || '';
119
+ r.userslug = u.userslug || r.userslug || '';
120
+ }
121
+ }
122
+ }
123
+ } catch (e) {
124
+ // Best-effort only
125
+ }
126
+
104
127
  res.json(pending);
105
128
  };
106
129
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "1.3.10",
3
+ "version": "1.3.12",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "1.3.9"
42
+ "version": "1.3.11"
43
43
  }
@@ -458,6 +458,11 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
458
458
  for (const r of list) {
459
459
  if (r && r.rid) pendingCache.set(String(r.rid), r);
460
460
  const created = r.createdAt ? fmtFR(r.createdAt) : '';
461
+ const userSlug = (r && (r.userslug || r.username)) ? String(r.userslug || r.username) : '';
462
+ const userName = (r && r.username) ? String(r.username) : '';
463
+ const userLink = (userSlug && userName)
464
+ ? `<a href="${escapeHtml('/user/' + userSlug)}" target="_blank" rel="noopener">${escapeHtml(userName)}</a>`
465
+ : '';
461
466
  const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
462
467
  const itemsHtml = `<ul style="margin: 0 0 10px 18px;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
463
468
  const div = document.createElement('div');
@@ -471,6 +476,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
471
476
  <div style="min-width:0;">
472
477
  <div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong></div>
473
478
  <div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
479
+ ${userLink ? `<div class="text-muted" style="font-size: 12px;">Réservée par: ${userLink}</div>` : ''}
474
480
  <div class="text-muted" style="font-size: 12px;">Période: ${escapeHtml(new Date(parseInt(r.start, 10)).toLocaleDateString('fr-FR'))} → ${escapeHtml(new Date(parseInt(r.end, 10)).toLocaleDateString('fr-FR'))}</div>
475
481
  </div>
476
482
  </div>
package/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "2.0.16"
42
+ "version": "2.0.18"
43
43
  }
package/public/admin.js CHANGED
@@ -458,6 +458,11 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
458
458
  for (const r of list) {
459
459
  if (r && r.rid) pendingCache.set(String(r.rid), r);
460
460
  const created = r.createdAt ? fmtFR(r.createdAt) : '';
461
+ const userSlug = (r && (r.userslug || r.username)) ? String(r.userslug || r.username) : '';
462
+ const userName = (r && r.username) ? String(r.username) : '';
463
+ const userLink = (userSlug && userName)
464
+ ? `<a href="${escapeHtml('/user/' + userSlug)}" target="_blank" rel="noopener">${escapeHtml(userName)}</a>`
465
+ : '';
461
466
  const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
462
467
  const itemsHtml = `<ul style="margin: 0 0 10px 18px;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
463
468
  const div = document.createElement('div');
@@ -471,6 +476,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
471
476
  <div style="min-width:0;">
472
477
  <div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong></div>
473
478
  <div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
479
+ ${userLink ? `<div class="text-muted" style="font-size: 12px;">Réservée par: ${userLink}</div>` : ''}
474
480
  <div class="text-muted" style="font-size: 12px;">Période: ${escapeHtml(new Date(parseInt(r.start, 10)).toLocaleDateString('fr-FR'))} → ${escapeHtml(new Date(parseInt(r.end, 10)).toLocaleDateString('fr-FR'))}</div>
475
481
  </div>
476
482
  </div>
package/public/client.js CHANGED
@@ -72,14 +72,24 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
72
72
  .onekite-range-day { border:1px solid var(--bs-border-color, rgba(0,0,0,.18)); border-radius:10px; padding:8px 0; text-align:center; cursor:pointer; background:var(--bs-body-bg,#fff); color:var(--bs-body-color,#212529); }
73
73
  .onekite-range-day.is-empty { border-color:transparent; background:transparent; cursor:default; }
74
74
  .onekite-range-day.is-disabled { opacity:.35; cursor:not-allowed; }
75
- .onekite-range-day.is-start, .onekite-range-day.is-end { border-color:var(--bs-border-color, rgba(0,0,0,.28)); font-weight:700; background:var(--bs-primary-bg-subtle, var(--bs-secondary-bg, var(--bs-tertiary-bg, rgba(0,0,0,.06)))); }
76
- .onekite-range-day.is-inrange { background:var(--bs-secondary-bg, var(--bs-tertiary-bg, rgba(0,0,0,.04))); }
75
+ .onekite-range-day.is-start, .onekite-range-day.is-end {
76
+ /* Use strong, theme-aware contrast so the selection is always visible in light mode */
77
+ border-color: var(--bs-primary, var(--bs-link-color, #0d6efd));
78
+ font-weight: 700;
79
+ background: var(--bs-primary, #0d6efd);
80
+ color: var(--bs-white, #fff);
81
+ }
82
+ .onekite-range-day.is-inrange {
83
+ /* Some NodeBB themes set secondary-bg ~= body-bg, making the range invisible. */
84
+ background: var(--bs-primary-bg-subtle, rgba(13,110,253,.12));
85
+ box-shadow: inset 0 0 0 1px var(--bs-primary-border-subtle, rgba(13,110,253,.25));
86
+ }
77
87
  .onekite-range-day:focus { outline: none; }
78
88
 
79
89
  @media (prefers-color-scheme: dark) {
80
90
  .onekite-range-day { border-color: var(--bs-border-color, rgba(255,255,255,.18)); }
81
91
  .onekite-range-day.is-inrange { background: rgba(255,255,255,.08); }
82
- .onekite-range-day.is-start, .onekite-range-day.is-end { background: rgba(255,255,255,.14); }
92
+ .onekite-range-day.is-start, .onekite-range-day.is-end { background: rgba(255,255,255,.14); color: var(--bs-body-color,#f8f9fa); border-color: var(--bs-border-color, rgba(255,255,255,.22)); }
83
93
  }
84
94
  .onekite-range-summary { margin-top:10px; font-size:.9rem; }
85
95
 
@@ -53,6 +53,12 @@
53
53
  <input class="form-control" name="pendingHoldMinutes" placeholder="5">
54
54
  </div>
55
55
 
56
+ <div class="mb-3">
57
+ <label class="form-label">Rappel validateurs (demande en attente) après (minutes)</label>
58
+ <input class="form-control" name="validatorReminderMinutesPending" placeholder="0">
59
+ <div class="form-text">Si &gt; 0, un email de rappel est envoyé aux validateurs après ce délai tant que la demande est toujours en attente.</div>
60
+ </div>
61
+
56
62
  <div class="mb-3">
57
63
  <label class="form-label">Délai rappel paiement (minutes)</label>
58
64
  <input class="form-control" name="paymentHoldMinutes" placeholder="60">
@@ -1,5 +1,6 @@
1
1
  <p>Bonjour {username},</p>
2
- <p>Votre demande de location de matériel a expiré (paiement non reçu à temps).</p>
2
+ <p>Votre demande de location de matériel a expiré.</p>
3
+ <p><strong>Raison :</strong> {reason}</p>
3
4
 
4
5
  <p><strong>Matériel</strong></p>
5
6
  <ul>
@@ -0,0 +1,17 @@
1
+ <p>Bonjour,</p>
2
+
3
+ <p>Une demande de location de matériel a expiré automatiquement.</p>
4
+
5
+ <p><strong>Demandeur :</strong> {requesterUsername}</p>
6
+ <p><strong>Raison :</strong> {reason}</p>
7
+
8
+ <p><strong>Matériel</strong></p>
9
+ <ul>
10
+ <!-- BEGIN itemNames -->
11
+ <li>{itemNames}</li>
12
+ <!-- END itemNames -->
13
+ </ul>
14
+
15
+ <p>{dateRange}</p>
16
+
17
+ <p><a href="{adminUrl}">Ouvrir l'ACP</a></p>
@@ -0,0 +1,16 @@
1
+ <p>Bonjour,</p>
2
+
3
+ <p>Une demande de location de matériel nécessite une action.</p>
4
+
5
+ <p><strong>Demandeur :</strong> {requesterUsername}</p>
6
+
7
+ <p><strong>Matériel</strong></p>
8
+ <ul>
9
+ <!-- BEGIN itemNames -->
10
+ <li>{itemNames}</li>
11
+ <!-- END itemNames -->
12
+ </ul>
13
+
14
+ <p>{dateRange}</p>
15
+
16
+ <p><a href="{adminUrl}">Ouvrir l'ACP (Demandes en attente)</a></p>