nodebb-plugin-onekite-calendar 2.0.40 → 2.0.41

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/lib/api.js CHANGED
@@ -359,30 +359,17 @@ async function auditLog(action, actorUid, payload) {
359
359
  }
360
360
  }
361
361
 
362
- async function canCreateSpecial(uid, settings) {
363
- if (!uid) return false;
364
- try {
365
- const isAdmin = await groups.isMember(uid, 'administrators');
366
- if (isAdmin) return true;
367
- } catch (e) {}
368
- const allowed = normalizeAllowedGroups(settings.specialCreatorGroups || '');
369
- if (!allowed.length) return false;
370
- if (await userInAnyGroup(uid, allowed)) return true;
371
-
372
- return false;
362
+ async function canCreateSpecial(uid, settings, startTs) {
363
+ // Special events ("Évènements" / "Sorties") use the same rights as creating a reservation.
364
+ // startTs is used to resolve the auto yearly group (onekite-ffvl-YYYY).
365
+ const ts = Number(startTs) || Date.now();
366
+ return await canRequest(uid, settings, ts);
373
367
  }
374
368
 
375
- async function canDeleteSpecial(uid, settings) {
376
- if (!uid) return false;
377
- try {
378
- const isAdmin = await groups.isMember(uid, 'administrators');
379
- if (isAdmin) return true;
380
- } catch (e) {}
381
- const allowed = normalizeAllowedGroups(settings.specialDeleterGroups || settings.specialCreatorGroups || '');
382
- if (!allowed.length) return false;
383
- if (await userInAnyGroup(uid, allowed)) return true;
384
-
385
- return false;
369
+ async function canDeleteSpecial(uid, settings, startTs) {
370
+ // Deletion rights are the same as creation rights (creatorGroups).
371
+ const ts = Number(startTs) || Date.now();
372
+ return await canRequest(uid, settings, ts);
386
373
  }
387
374
 
388
375
  function eventsFor(resv) {
@@ -442,23 +429,36 @@ function eventsFor(resv) {
442
429
  }
443
430
 
444
431
  function eventsForSpecial(ev) {
432
+ const kind = String(ev.kind || 'event');
445
433
  const start = new Date(parseInt(ev.start, 10));
446
434
  const end = new Date(parseInt(ev.end, 10));
447
435
  const startIso = start.toISOString();
448
436
  const endIso = end.toISOString();
437
+
438
+ // Color coding:
439
+ // - event: purple
440
+ // - outing: blue
441
+ const palette = (kind === 'outing')
442
+ ? { bg: '#0d6efd', border: '#0d6efd' }
443
+ : { bg: '#8e44ad', border: '#8e44ad' };
444
+
445
+ const defaultTitle = (kind === 'outing') ? 'Sortie' : 'Évènement';
446
+
449
447
  return {
450
448
  id: `special:${ev.eid}`,
451
- title: `${ev.title || 'Évènement'}`.trim(),
449
+ title: `${ev.title || defaultTitle}`.trim(),
452
450
  allDay: false,
453
451
  start: startIso,
454
452
  end: endIso,
455
- backgroundColor: '#8e44ad',
456
- borderColor: '#8e44ad',
453
+ backgroundColor: palette.bg,
454
+ borderColor: palette.border,
457
455
  textColor: '#ffffff',
458
456
  extendedProps: {
459
457
  type: 'special',
458
+ kind,
460
459
  eid: ev.eid,
461
- title: ev.title || '',
460
+ kind: String(ev.kind || 'event'),
461
+ title: ev.title || '',
462
462
  notes: ev.notes || '',
463
463
  pickupAddress: ev.address || '',
464
464
  pickupLat: ev.lat || '',
@@ -493,8 +493,8 @@ api.getEvents = async function (req, res) {
493
493
  const settings = await meta.settings.get('calendar-onekite');
494
494
  const canMod = req.uid ? await canValidate(req.uid, settings) : false;
495
495
  const widgetMode = String((req.query && req.query.widget) || '') === '1';
496
- const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
497
- const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
496
+ const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings, Date.now()) : false;
497
+ const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings, Date.now()) : false;
498
498
 
499
499
  // Fetch a wider window because an event can start before the query range
500
500
  // and still overlap.
@@ -611,7 +611,7 @@ api.getEvents = async function (req, res) {
611
611
  type: 'special',
612
612
  eid: sev.eid,
613
613
  canCreateSpecial: canSpecialCreate,
614
- canDeleteSpecial: canSpecialDelete,
614
+ canDeleteSpecial: canSpecialDeleteResolved,
615
615
  },
616
616
  };
617
617
  if (sev.username && (canMod || canSpecialDelete || (req.uid && String(req.uid) === String(sev.uid)))) {
@@ -696,17 +696,19 @@ api.getSpecialEventDetails = async function (req, res) {
696
696
 
697
697
  const settings = await meta.settings.get('calendar-onekite');
698
698
  const canMod = await canValidate(uid, settings);
699
- const canSpecialDelete = await canDeleteSpecial(uid, settings);
700
699
 
701
700
  const eid = String(req.params.eid || '').trim();
702
701
  if (!eid) return res.status(400).json({ error: 'missing-eid' });
703
702
  const ev = await dbLayer.getSpecialEvent(eid);
704
703
  if (!ev) return res.status(404).json({ error: 'not-found' });
705
704
 
705
+ const canSpecialDeleteResolved = await canDeleteSpecial(uid, settings, ev && ev.start ? Number(ev.start) : Date.now());
706
+
706
707
  // Anyone who can see the calendar can view special events, but creator username
707
708
  // is only visible to moderators/allowed users or the creator.
708
709
  const out = {
709
710
  eid: ev.eid,
711
+ kind: String(ev.kind || 'event'),
710
712
  title: ev.title || '',
711
713
  start: ev.start,
712
714
  end: ev.end,
@@ -714,9 +716,9 @@ api.getSpecialEventDetails = async function (req, res) {
714
716
  lat: ev.lat || '',
715
717
  lon: ev.lon || '',
716
718
  notes: ev.notes || '',
717
- canDeleteSpecial: canSpecialDelete,
719
+ canDeleteSpecial: canSpecialDeleteResolved,
718
720
  };
719
- if (ev.username && (canMod || canSpecialDelete || (uid && String(uid) === String(ev.uid)))) {
721
+ if (ev.username && (canMod || canSpecialDeleteResolved || (uid && String(uid) === String(ev.uid)))) {
720
722
  out.username = String(ev.username);
721
723
  }
722
724
  return res.json(out);
@@ -728,23 +730,28 @@ api.getCapabilities = async function (req, res) {
728
730
  const canMod = uid ? await canValidate(uid, settings) : false;
729
731
  res.json({
730
732
  canModerate: canMod,
731
- canCreateSpecial: uid ? await canCreateSpecial(uid, settings) : false,
732
- canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings) : false,
733
+ canCreateSpecial: uid ? await canCreateSpecial(uid, settings, Date.now()) : false,
734
+ canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings, Date.now()) : false,
733
735
  });
734
736
  };
735
737
 
736
738
  api.createSpecialEvent = async function (req, res) {
737
739
  const settings = await meta.settings.get('calendar-onekite');
738
740
  if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
739
- const ok = await canCreateSpecial(req.uid, settings);
740
- if (!ok) return res.status(403).json({ error: 'not-allowed' });
741
741
 
742
- const title = String((req.body && req.body.title) || '').trim() || 'Évènement';
742
+ const kind = String((req.body && req.body.kind) || 'event').trim().toLowerCase();
743
+ const safeKind = (kind === 'outing') ? 'outing' : 'event';
744
+
743
745
  const startTs = toTs(req.body && req.body.start);
744
746
  const endTs = toTs(req.body && req.body.end);
745
747
  if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || !(startTs < endTs)) {
746
748
  return res.status(400).json({ error: 'bad-dates' });
747
749
  }
750
+
751
+ const ok = await canCreateSpecial(req.uid, settings, startTs);
752
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
753
+
754
+ const title = String((req.body && req.body.title) || '').trim() || (safeKind === 'outing' ? 'Sortie' : 'Évènement');
748
755
  const address = String((req.body && req.body.address) || '').trim();
749
756
  const notes = String((req.body && req.body.notes) || '').trim();
750
757
  const lat = String((req.body && req.body.lat) || '').trim();
@@ -754,6 +761,7 @@ api.createSpecialEvent = async function (req, res) {
754
761
  const eid = crypto.randomUUID();
755
762
  const ev = {
756
763
  eid,
764
+ kind: safeKind,
757
765
  title,
758
766
  start: String(startTs),
759
767
  end: String(endTs),
@@ -765,21 +773,41 @@ api.createSpecialEvent = async function (req, res) {
765
773
  username: u && u.username ? String(u.username) : '',
766
774
  createdAt: String(Date.now()),
767
775
  };
776
+
768
777
  await dbLayer.saveSpecialEvent(ev);
778
+
779
+ // Discord notifications (separate webhooks per kind)
780
+ try {
781
+ await discord.notifySpecialEvent(settings, 'created', ev);
782
+ } catch (e) {}
783
+
769
784
  // Real-time refresh for all viewers
770
- realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'created', eid: ev.eid });
785
+ realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'created', eid: ev.eid, specialKind: ev.kind });
771
786
  res.json({ ok: true, eid });
772
787
  };
773
788
 
774
789
  api.deleteSpecialEvent = async function (req, res) {
775
790
  const settings = await meta.settings.get('calendar-onekite');
776
791
  if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
777
- const ok = await canDeleteSpecial(req.uid, settings);
778
- if (!ok) return res.status(403).json({ error: 'not-allowed' });
792
+
779
793
  const eid = String(req.params.eid || '').replace(/^special:/, '').trim();
780
794
  if (!eid) return res.status(400).json({ error: 'bad-id' });
795
+
796
+ const ev = await dbLayer.getSpecialEvent(eid);
797
+ if (!ev) return res.status(404).json({ error: 'not-found' });
798
+
799
+ const canSpecialDeleteResolved = await canDeleteSpecial(uid, settings, ev && ev.start ? Number(ev.start) : Date.now());
800
+
801
+ const ok = await canDeleteSpecial(req.uid, settings, ev && ev.start ? Number(ev.start) : Date.now());
802
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
803
+
781
804
  await dbLayer.removeSpecialEvent(eid);
782
- realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'deleted', eid });
805
+
806
+ try {
807
+ await discord.notifySpecialEvent(settings, 'deleted', Object.assign({ eid }, ev || {}));
808
+ } catch (e) {}
809
+
810
+ realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'deleted', eid, specialKind: ev && ev.kind ? String(ev.kind) : 'event' });
783
811
  res.json({ ok: true });
784
812
  };
785
813
 
package/lib/discord.js CHANGED
@@ -183,8 +183,65 @@ async function notifyReservationCancelled(settings, reservation) {
183
183
  }
184
184
  }
185
185
 
186
+
187
+ function buildSpecialWebhookPayload(action, ev) {
188
+ const kind = String((ev && ev.kind) || 'event');
189
+ const webhookUsername = kind === 'outing'
190
+ ? (action === 'deleted' ? 'Onekite • Sortie • Annulation' : 'Onekite • Sortie')
191
+ : (action === 'deleted' ? 'Onekite • Évènement • Annulation' : 'Onekite • Évènement');
192
+
193
+ const calUrl = 'https://www.onekite.com/calendar';
194
+ const title = kind === 'outing'
195
+ ? (action === 'deleted' ? '❌ Sortie annulée' : '🗓️ Nouvelle sortie')
196
+ : (action === 'deleted' ? '❌ Évènement annulé' : '📣 Nouvel évènement');
197
+
198
+ const username = ev && ev.username ? String(ev.username) : '';
199
+ const start = ev && ev.start ? Number(ev.start) : NaN;
200
+ const end = ev && ev.end ? Number(ev.end) : NaN;
201
+ const address = ev && ev.address ? String(ev.address) : '';
202
+ const notes = ev && ev.notes ? String(ev.notes) : '';
203
+
204
+ const fields = [];
205
+ if (username) fields.push({ name: 'Créé par', value: username, inline: true });
206
+ if (Number.isFinite(start) && Number.isFinite(end)) {
207
+ fields.push({ name: 'Période', value: `Du ${formatFRShort(start)} au ${formatFRShort(end)}`, inline: false });
208
+ }
209
+ if (address) fields.push({ name: 'Adresse', value: address, inline: false });
210
+ if (notes) fields.push({ name: 'Notes', value: notes.length > 900 ? (notes.slice(0, 897) + '...') : notes, inline: false });
211
+
212
+ return {
213
+ username: webhookUsername,
214
+ content: '',
215
+ embeds: [
216
+ {
217
+ title,
218
+ url: calUrl,
219
+ description: (ev && ev.title) ? String(ev.title) : '',
220
+ fields,
221
+ footer: { text: 'Onekite • Calendrier' },
222
+ timestamp: new Date().toISOString(),
223
+ },
224
+ ],
225
+ };
226
+ }
227
+
228
+ async function notifySpecialEvent(settings, action, ev) {
229
+ const kind = String((ev && ev.kind) || 'event');
230
+ const url = kind === 'outing'
231
+ ? String((settings && settings.discordWebhookUrlOutings) || '').trim()
232
+ : String((settings && settings.discordWebhookUrlEvents) || '').trim();
233
+ if (!url) return;
234
+
235
+ try {
236
+ await postWebhook(url, buildSpecialWebhookPayload(action, ev));
237
+ } catch (e) {
238
+ console.warn('[calendar-onekite] Discord webhook failed (special)', e && e.message ? e.message : String(e));
239
+ }
240
+ }
241
+
186
242
  module.exports = {
187
243
  notifyReservationRequested,
188
244
  notifyPaymentReceived,
189
245
  notifyReservationCancelled,
246
+ notifySpecialEvent,
190
247
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.40",
3
+ "version": "2.0.41",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "2.0.40"
42
+ "version": "2.0.41"
43
43
  }
package/public/client.js CHANGED
@@ -208,7 +208,12 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
208
208
  return opts.join('');
209
209
  }
210
210
 
211
- async function openSpecialEventDialog(selectionInfo) {
211
+ async function openSpecialEventDialog(selectionInfo, opts) {
212
+ const options = opts || {};
213
+ const kind = (String(options.kind || 'event').trim().toLowerCase() === 'outing') ? 'outing' : 'event';
214
+ const dialogTitle = String(options.dialogTitle || (kind === 'outing' ? 'Créer une prévision de sortie' : 'Créer un évènement'));
215
+ const defaultTitle = String(options.defaultTitle || (kind === 'outing' ? 'Sortie' : 'Évènement'));
216
+
212
217
  const start = selectionInfo.start;
213
218
  // FullCalendar can omit `end` for certain interactions. Also, for all-day
214
219
  // selections, `end` is exclusive (next day at 00:00). We normalise below
@@ -262,7 +267,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
262
267
  const html = `
263
268
  <div class="mb-3">
264
269
  <label class="form-label">Titre</label>
265
- <input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." />
270
+ <input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." value="${escapeHtml(defaultTitle)}" />
266
271
  </div>
267
272
  <div class="row g-2">
268
273
  <div class="col-12 col-md-6">
@@ -307,7 +312,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
307
312
  return await new Promise((resolve) => {
308
313
  let resolved = false;
309
314
  const dialog = bootbox.dialog({
310
- title: 'Créer un évènement',
315
+ title: dialogTitle,
311
316
  message: html,
312
317
  buttons: {
313
318
  cancel: {
@@ -356,7 +361,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
356
361
  const lat = (document.getElementById('onekite-se-lat')?.value || '').trim();
357
362
  const lon = (document.getElementById('onekite-se-lon')?.value || '').trim();
358
363
  resolved = true;
359
- resolve({ title, start: startVal, end: endVal, address, notes, lat, lon });
364
+ resolve({ kind, title, start: startVal, end: endVal, address, notes, lat, lon });
360
365
  return true;
361
366
  },
362
367
  },
@@ -1358,6 +1363,55 @@ function toDatetimeLocalValue(date) {
1358
1363
  return;
1359
1364
  }
1360
1365
 
1366
+
1367
+
1368
+ // On a day click / selection in reservation mode, propose the user to create either
1369
+ // a Location (reservation) or a "Prévision de sortie" (special event).
1370
+ try {
1371
+ const choice = await new Promise((resolve) => {
1372
+ bootbox.dialog({
1373
+ title: 'Créer',
1374
+ message: '<div>Que veux-tu créer ?</div>',
1375
+ buttons: {
1376
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(null) },
1377
+ outing: { label: 'Prévision de sortie', className: 'btn-info', callback: () => resolve('outing') },
1378
+ reservation: { label: 'Location', className: 'btn-primary', callback: () => resolve('reservation') },
1379
+ },
1380
+ });
1381
+ });
1382
+
1383
+ if (!choice) {
1384
+ calendar.unselect();
1385
+ isDialogOpen = false;
1386
+ return;
1387
+ }
1388
+
1389
+ if (choice === 'outing') {
1390
+ const payload = await openSpecialEventDialog(info, { kind: 'outing', dialogTitle: 'Créer une prévision de sortie', defaultTitle: 'Sortie' });
1391
+ if (!payload) {
1392
+ calendar.unselect();
1393
+ isDialogOpen = false;
1394
+ return;
1395
+ }
1396
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1397
+ method: 'POST',
1398
+ body: JSON.stringify(payload),
1399
+ }).catch(async () => {
1400
+ return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1401
+ method: 'POST',
1402
+ body: JSON.stringify(payload),
1403
+ });
1404
+ });
1405
+ showAlert('success', 'Sortie créée.');
1406
+ invalidateEventsCache();
1407
+ scheduleRefetch(calendar);
1408
+ calendar.unselect();
1409
+ isDialogOpen = false;
1410
+ return;
1411
+ }
1412
+ } catch (e) {
1413
+ // ignore
1414
+ }
1361
1415
  // Business rule: reservations cannot start in the past.
1362
1416
  // (We validate again on the server, but this gives immediate feedback.)
1363
1417
  try {
@@ -1544,15 +1598,19 @@ function toDatetimeLocalValue(date) {
1544
1598
  }
1545
1599
  },
1546
1600
  eventDidMount: function (arg) {
1547
- // Keep special event colors consistent.
1601
+ // Keep special event colors consistent (kind-specific).
1548
1602
  try {
1549
1603
  const ev = arg && arg.event;
1550
1604
  if (!ev) return;
1551
1605
  if (ev.extendedProps && ev.extendedProps.type === 'special') {
1606
+ const kind = String((ev.extendedProps.kind || 'event'));
1607
+ const palette = (kind === 'outing')
1608
+ ? { bg: '#0d6efd', border: '#0d6efd' }
1609
+ : { bg: '#8e44ad', border: '#8e44ad' };
1552
1610
  const el2 = arg.el;
1553
1611
  if (el2 && el2.style) {
1554
- el2.style.backgroundColor = '#8e44ad';
1555
- el2.style.borderColor = '#8e44ad';
1612
+ el2.style.backgroundColor = palette.bg;
1613
+ el2.style.borderColor = palette.border;
1556
1614
  el2.style.color = '#ffffff';
1557
1615
  }
1558
1616
  }
@@ -1598,6 +1656,8 @@ function toDatetimeLocalValue(date) {
1598
1656
 
1599
1657
  try {
1600
1658
  if (p.type === 'special') {
1659
+ const specialKind = String((p.kind || p0.kind || 'event'));
1660
+ const specialLabel = (specialKind === 'outing') ? 'Sortie' : 'Évènement';
1601
1661
  const username = String(p.username || '').trim();
1602
1662
  const userLine = username
1603
1663
  ? `<div class="mb-2"><strong>Créé par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
@@ -1621,7 +1681,7 @@ function toDatetimeLocalValue(date) {
1621
1681
  `;
1622
1682
  const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
1623
1683
  bootbox.dialog({
1624
- title: 'Évènement',
1684
+ title: specialLabel,
1625
1685
  message: html,
1626
1686
  buttons: {
1627
1687
  close: { label: 'Fermer', className: 'btn-secondary' },
@@ -1633,7 +1693,7 @@ function toDatetimeLocalValue(date) {
1633
1693
  try {
1634
1694
  const eid = String(p.eid || ev.id).replace(/^special:/, '');
1635
1695
  await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' });
1636
- showAlert('success', 'Évènement supprimé.');
1696
+ showAlert('success', `${specialLabel} supprimé.`);
1637
1697
  calendar.refetchEvents();
1638
1698
  } catch (e) {
1639
1699
  showAlert('error', 'Suppression impossible.');
@@ -2152,6 +2212,40 @@ async function openFabDatePicker() {
2152
2212
  try {
2153
2213
  if (isDialogOpen) return;
2154
2214
  isDialogOpen = true;
2215
+
2216
+ // Let the user pick between a Location or an Outing forecast.
2217
+ const choice = await new Promise((resolve) => {
2218
+ bootbox.dialog({
2219
+ title: 'Créer',
2220
+ message: '<div>Que veux-tu créer ?</div>',
2221
+ buttons: {
2222
+ cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(null) },
2223
+ outing: { label: 'Prévision de sortie', className: 'btn-info', callback: () => resolve('outing') },
2224
+ reservation: { label: 'Location', className: 'btn-primary', callback: () => resolve('reservation') },
2225
+ },
2226
+ });
2227
+ });
2228
+
2229
+ if (!choice) return;
2230
+
2231
+ if (choice === 'outing') {
2232
+ const payload = await openSpecialEventDialog({ start: s, end: endExcl, allDay: true }, { kind: 'outing', dialogTitle: 'Créer une prévision de sortie', defaultTitle: 'Sortie' });
2233
+ if (!payload) return;
2234
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
2235
+ method: 'POST',
2236
+ body: JSON.stringify(payload),
2237
+ }).catch(async () => {
2238
+ return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
2239
+ method: 'POST',
2240
+ body: JSON.stringify(payload),
2241
+ });
2242
+ });
2243
+ showAlert('success', 'Sortie créée.');
2244
+ invalidateEventsCache();
2245
+ if (currentCalendar) scheduleRefetch(currentCalendar);
2246
+ return;
2247
+ }
2248
+
2155
2249
  const items = cachedItems || (await loadItems());
2156
2250
  const chosen = await openReservationDialog({ start: s, end: endExcl }, items);
2157
2251
  if (chosen && chosen.itemIds && chosen.itemIds.length) {
@@ -80,6 +80,19 @@
80
80
  <div class="form-text">Si vide, aucune notification Discord n'est envoyée.</div>
81
81
  </div>
82
82
 
83
+ <div class="mb-3">
84
+ <label class="form-label">Webhook URL (Évènements)</label>
85
+ <input class="form-control" name="discordWebhookUrlEvents" placeholder="https://discord.com/api/webhooks/...">
86
+ <div class="form-text">Notifications Discord pour les <strong>évènements</strong> (création & annulation). Si vide, aucune notification n'est envoyée.</div>
87
+ </div>
88
+
89
+ <div class="mb-3">
90
+ <label class="form-label">Webhook URL (Sorties)</label>
91
+ <input class="form-control" name="discordWebhookUrlOutings" placeholder="https://discord.com/api/webhooks/...">
92
+ <div class="form-text">Notifications Discord pour les <strong>sorties</strong> (création & annulation). Si vide, aucune notification n'est envoyée.</div>
93
+ </div>
94
+
95
+
83
96
  <div class="mb-3">
84
97
  <label class="form-label">Envoyer une notification à la demande</label>
85
98
  <select class="form-select" name="discordNotifyOnRequest">
@@ -157,17 +170,6 @@
157
170
  <div class="tab-pane fade" id="onekite-tab-events" role="tabpanel">
158
171
  <h4>Évènements (autre couleur)</h4>
159
172
  <div class="form-text mb-3">Permet de créer des évènements horaires (début/fin) avec adresse (Leaflet) et notes.</div>
160
-
161
- <div class="mb-3">
162
- <label class="form-label">Groupes autorisés à créer des évènements (csv)</label>
163
- <input class="form-control" name="specialCreatorGroups" placeholder="ex: staff,instructors">
164
- </div>
165
-
166
- <div class="mb-3">
167
- <label class="form-label">Groupes autorisés à supprimer des évènements (csv)</label>
168
- <input class="form-control" name="specialDeleterGroups" placeholder="Si vide: même liste que la création">
169
- </div>
170
-
171
173
  <hr class="my-4" />
172
174
  <h4>Purge des évènements</h4>
173
175
  <div class="d-flex gap-2 align-items-center">