nodebb-plugin-onekite-calendar 2.0.42 → 2.0.44

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/admin.js CHANGED
@@ -305,6 +305,24 @@ admin.purgeSpecialEventsByYear = async function (req, res) {
305
305
  return res.json({ ok: true, removed: count });
306
306
  };
307
307
 
308
+ admin.purgeOutingsByYear = async function (req, res) {
309
+ const year = (req.body && req.body.year ? String(req.body.year) : '').trim();
310
+ if (!/^\d{4}$/.test(year)) {
311
+ return res.status(400).json({ error: 'invalid-year' });
312
+ }
313
+ const y = parseInt(year, 10);
314
+ const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
315
+ const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
316
+
317
+ const ids = await dbLayer.listOutingIdsByStartRange(startTs, endTs, 200000);
318
+ let count = 0;
319
+ for (const oid of ids) {
320
+ await dbLayer.removeOuting(oid);
321
+ count++;
322
+ }
323
+ return res.json({ ok: true, removed: count });
324
+ };
325
+
308
326
  // Debug endpoint to validate HelloAsso connectivity and item loading
309
327
 
310
328
 
package/lib/api.js CHANGED
@@ -469,6 +469,35 @@ function eventsForSpecial(ev) {
469
469
  };
470
470
  }
471
471
 
472
+
473
+ function eventsForOuting(o) {
474
+ const start = new Date(parseInt(o.start, 10));
475
+ const end = new Date(parseInt(o.end, 10));
476
+ const startIso = start.toISOString();
477
+ const endIso = end.toISOString();
478
+ return {
479
+ id: `outing:${o.oid}`,
480
+ title: `${o.title || 'Prévision de sortie'}`.trim(),
481
+ allDay: false,
482
+ start: startIso,
483
+ end: endIso,
484
+ backgroundColor: '#2980b9',
485
+ borderColor: '#2980b9',
486
+ textColor: '#ffffff',
487
+ extendedProps: {
488
+ type: 'outing',
489
+ oid: o.oid,
490
+ title: o.title || '',
491
+ notes: o.notes || '',
492
+ pickupAddress: o.address || '',
493
+ pickupLat: o.lat || '',
494
+ pickupLon: o.lon || '',
495
+ createdBy: o.uid || 0,
496
+ username: o.username || '',
497
+ },
498
+ };
499
+ }
500
+
472
501
  const api = {};
473
502
 
474
503
  function computeEtag(payload) {
@@ -623,6 +652,41 @@ api.getEvents = async function (req, res) {
623
652
  // ignore
624
653
  }
625
654
 
655
+
656
+ // Outings (prévisions de sortie)
657
+ try {
658
+ const outingIds = await dbLayer.listOutingIdsByStartRange(wideStart, endTs, 8000);
659
+ const outings = await dbLayer.getOutings(outingIds);
660
+ for (const o of (outings || [])) {
661
+ if (!o) continue;
662
+ const oStart = parseInt(o.start, 10);
663
+ const oEnd = parseInt(o.end, 10);
664
+ if (!(oStart < endTs && startTs < oEnd)) continue;
665
+ const full = eventsForOuting(o);
666
+ const minimal = {
667
+ id: full.id,
668
+ title: full.title,
669
+ allDay: full.allDay,
670
+ start: full.start,
671
+ end: full.end,
672
+ backgroundColor: full.backgroundColor,
673
+ borderColor: full.borderColor,
674
+ textColor: full.textColor,
675
+ extendedProps: {
676
+ type: 'outing',
677
+ oid: o.oid,
678
+ canDeleteOuting: canMod || (req.uid && String(req.uid) === String(o.uid)),
679
+ },
680
+ };
681
+ if (o.username && (canMod || (req.uid && String(req.uid) === String(o.uid)))) {
682
+ minimal.extendedProps.username = String(o.username);
683
+ }
684
+ out.push(minimal);
685
+ }
686
+ } catch (e) {
687
+ // ignore
688
+ }
689
+
626
690
  // Stable ordering -> stable ETag
627
691
  out.sort((a, b) => {
628
692
  const as = String(a.start || '');
@@ -730,6 +794,7 @@ api.getCapabilities = async function (req, res) {
730
794
  canModerate: canMod,
731
795
  canCreateSpecial: uid ? await canCreateSpecial(uid, settings) : false,
732
796
  canDeleteSpecial: uid ? await canDeleteSpecial(uid, settings) : false,
797
+ canCreateOuting: uid ? await canRequest(uid, settings, Date.now()) : false,
733
798
  });
734
799
  };
735
800
 
@@ -745,6 +810,19 @@ api.createSpecialEvent = async function (req, res) {
745
810
  if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || !(startTs < endTs)) {
746
811
  return res.status(400).json({ error: 'bad-dates' });
747
812
  }
813
+
814
+ // Business rule: nothing can be created in the past.
815
+ try {
816
+ const today0 = new Date();
817
+ today0.setHours(0, 0, 0, 0);
818
+ const today0ts = today0.getTime();
819
+ if (startTs < today0ts) {
820
+ return res.status(400).json({
821
+ error: 'date-too-soon',
822
+ message: "Impossible de créer pour une date passée.",
823
+ });
824
+ }
825
+ } catch (e) {}
748
826
  const address = String((req.body && req.body.address) || '').trim();
749
827
  const notes = String((req.body && req.body.notes) || '').trim();
750
828
  const lat = String((req.body && req.body.lat) || '').trim();
@@ -766,6 +844,11 @@ api.createSpecialEvent = async function (req, res) {
766
844
  createdAt: String(Date.now()),
767
845
  };
768
846
  await dbLayer.saveSpecialEvent(ev);
847
+
848
+ try {
849
+ await discord.notifySpecialEventCreated(settings, ev);
850
+ } catch (e) {}
851
+
769
852
  // Real-time refresh for all viewers
770
853
  realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'created', eid: ev.eid });
771
854
  res.json({ ok: true, eid });
@@ -779,10 +862,125 @@ api.deleteSpecialEvent = async function (req, res) {
779
862
  const eid = String(req.params.eid || '').replace(/^special:/, '').trim();
780
863
  if (!eid) return res.status(400).json({ error: 'bad-id' });
781
864
  await dbLayer.removeSpecialEvent(eid);
865
+
866
+ try {
867
+ await discord.notifySpecialEventDeleted(settings, Object.assign({}, { eid }, { deletedBy: String(req.uid || '') }));
868
+ } catch (e) {}
869
+
782
870
  realtime.emitCalendarUpdated({ kind: 'specialEvent', action: 'deleted', eid });
783
871
  res.json({ ok: true });
784
872
  };
785
873
 
874
+ api.getOutingDetails = async function (req, res) {
875
+ const uid = req.uid;
876
+ if (!uid) return res.status(401).json({ error: 'not-logged-in' });
877
+
878
+ const settings = await meta.settings.get('calendar-onekite');
879
+ const canMod = await canValidate(uid, settings);
880
+
881
+ const oid = String(req.params.oid || '').trim();
882
+ if (!oid) return res.status(400).json({ error: 'missing-oid' });
883
+ const o = await dbLayer.getOuting(oid);
884
+ if (!o) return res.status(404).json({ error: 'not-found' });
885
+
886
+ const out = {
887
+ oid: o.oid,
888
+ title: o.title || '',
889
+ start: o.start,
890
+ end: o.end,
891
+ address: o.address || '',
892
+ lat: o.lat || '',
893
+ lon: o.lon || '',
894
+ notes: o.notes || '',
895
+ canDeleteOuting: canMod || (uid && String(uid) === String(o.uid)),
896
+ };
897
+ if (o.username && (canMod || (uid && String(uid) === String(o.uid)))) {
898
+ out.username = String(o.username);
899
+ }
900
+ return res.json(out);
901
+ };
902
+
903
+ api.createOuting = async function (req, res) {
904
+ const settings = await meta.settings.get('calendar-onekite');
905
+ if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
906
+
907
+ const startTs = toTs(req.body && req.body.start);
908
+ const ok = await canRequest(req.uid, settings, startTs);
909
+ if (!ok) return res.status(403).json({ error: 'not-allowed' });
910
+
911
+ const title = String((req.body && req.body.title) || '').trim() || 'Prévision de sortie';
912
+ const endTs = toTs(req.body && req.body.end);
913
+ if (!Number.isFinite(startTs) || !Number.isFinite(endTs) || !(startTs < endTs)) {
914
+ return res.status(400).json({ error: 'bad-dates' });
915
+ }
916
+
917
+ // Business rule: nothing can be created in the past.
918
+ try {
919
+ const today0 = new Date();
920
+ today0.setHours(0, 0, 0, 0);
921
+ const today0ts = today0.getTime();
922
+ if (startTs < today0ts) {
923
+ return res.status(400).json({
924
+ error: 'date-too-soon',
925
+ message: "Impossible de créer pour une date passée.",
926
+ });
927
+ }
928
+ } catch (e) {}
929
+
930
+ const address = String((req.body && req.body.address) || '').trim();
931
+ const notes = String((req.body && req.body.notes) || '').trim();
932
+ const lat = String((req.body && req.body.lat) || '').trim();
933
+ const lon = String((req.body && req.body.lon) || '').trim();
934
+
935
+ const u = await user.getUserFields(req.uid, ['username']);
936
+ const oid = crypto.randomUUID();
937
+ const o = {
938
+ oid,
939
+ title,
940
+ start: String(startTs),
941
+ end: String(endTs),
942
+ address,
943
+ notes,
944
+ lat,
945
+ lon,
946
+ uid: String(req.uid),
947
+ username: u && u.username ? String(u.username) : '',
948
+ createdAt: String(Date.now()),
949
+ };
950
+ await dbLayer.saveOuting(o);
951
+
952
+ try {
953
+ await discord.notifyOutingCreated(settings, o);
954
+ } catch (e) {}
955
+
956
+ realtime.emitCalendarUpdated({ kind: 'outing', action: 'created', oid: o.oid });
957
+ return res.json({ ok: true, oid });
958
+ };
959
+
960
+ api.deleteOuting = async function (req, res) {
961
+ const settings = await meta.settings.get('calendar-onekite');
962
+ if (!req.uid) return res.status(401).json({ error: 'not-logged-in' });
963
+
964
+ const oid = String(req.params.oid || '').replace(/^outing:/, '').trim();
965
+ if (!oid) return res.status(400).json({ error: 'bad-id' });
966
+
967
+ const o = await dbLayer.getOuting(oid);
968
+ if (!o) return res.status(404).json({ error: 'not-found' });
969
+
970
+ const canMod = await canValidate(req.uid, settings);
971
+ const isOwner = String(o.uid) === String(req.uid);
972
+ if (!canMod && !isOwner) return res.status(403).json({ error: 'not-allowed' });
973
+
974
+ await dbLayer.removeOuting(oid);
975
+
976
+ try {
977
+ await discord.notifyOutingDeleted(settings, Object.assign({}, o, { deletedBy: String(req.uid || '') }));
978
+ } catch (e) {}
979
+
980
+ realtime.emitCalendarUpdated({ kind: 'outing', action: 'deleted', oid });
981
+ return res.json({ ok: true });
982
+ };
983
+
786
984
  api.getItems = async function (req, res) {
787
985
  const settings = await meta.settings.get('calendar-onekite');
788
986
 
package/lib/db.js CHANGED
@@ -10,6 +10,10 @@ const KEY_CHECKOUT_INTENT_TO_RID = 'calendar-onekite:helloasso:checkoutIntentToR
10
10
  const KEY_SPECIAL_ZSET = 'calendar-onekite:special';
11
11
  const KEY_SPECIAL_OBJ = (eid) => `calendar-onekite:special:${eid}`;
12
12
 
13
+ // Outings (previsions de sortie)
14
+ const KEY_OUTING_ZSET = 'calendar-onekite:outings';
15
+ const KEY_OUTING_OBJ = (oid) => `calendar-onekite:outings:${oid}`;
16
+
13
17
  // Maintenance (simple ON/OFF per item, no dates)
14
18
  const KEY_MAINTENANCE_ZSET = 'calendar-onekite:maintenance:itemIds';
15
19
 
@@ -26,6 +30,10 @@ function specialKey(eid) {
26
30
  return KEY_SPECIAL_OBJ(eid);
27
31
  }
28
32
 
33
+ function outingKey(oid) {
34
+ return KEY_OUTING_OBJ(oid);
35
+ }
36
+
29
37
  async function getReservation(rid) {
30
38
  return await db.getObject(KEY_OBJ(rid));
31
39
  }
@@ -208,6 +216,34 @@ module.exports = {
208
216
  const stop = Math.max(0, (parseInt(limit, 10) || 2000) - 1);
209
217
  return await db.getSortedSetRangeByScore(KEY_SPECIAL_ZSET, start, stop, startTs, endTs);
210
218
  },
219
+
220
+ // Outings
221
+ getOuting: async (oid) => await db.getObject(KEY_OUTING_OBJ(oid)),
222
+ getOutings: async (oids) => {
223
+ const ids = Array.isArray(oids) ? oids.filter(Boolean) : [];
224
+ if (!ids.length) return [];
225
+ const keys = ids.map(outingKey);
226
+ const rows = await db.getObjects(keys);
227
+ return (rows || []).map((row, idx) => {
228
+ if (!row) return null;
229
+ if (!row.oid) row.oid = String(ids[idx]);
230
+ return row;
231
+ });
232
+ },
233
+ saveOuting: async (o) => {
234
+ await db.setObject(KEY_OUTING_OBJ(o.oid), o);
235
+ await db.sortedSetAdd(KEY_OUTING_ZSET, o.start, o.oid);
236
+ },
237
+ removeOuting: async (oid) => {
238
+ await db.sortedSetRemove(KEY_OUTING_ZSET, oid);
239
+ await db.delete(KEY_OUTING_OBJ(oid));
240
+ },
241
+ listOutingIdsByStartRange: async (startTs, endTs, limit = 5000) => {
242
+ const start = 0;
243
+ const stop = Math.max(0, (parseInt(limit, 10) || 5000) - 1);
244
+ return await db.getSortedSetRangeByScore(KEY_OUTING_ZSET, start, stop, startTs, endTs);
245
+ },
246
+
211
247
  listReservationIdsByStartRange,
212
248
  listAllReservationIds,
213
249
 
package/lib/discord.js CHANGED
@@ -183,8 +183,93 @@ async function notifyReservationCancelled(settings, reservation) {
183
183
  }
184
184
  }
185
185
 
186
+
187
+
188
+ // --------------------------
189
+ // Special events & outings
190
+ // --------------------------
191
+
192
+ function buildSimpleCalendarPayload(kind, label, entity, opts) {
193
+ const options = opts || {};
194
+ const calUrl = options.calUrl || 'https://www.onekite.com/calendar';
195
+ const webhookUsername = options.webhookUsername || `Onekite • ${label}`;
196
+
197
+ const title = kind === 'deleted' ? '❌ ' + label + ' annulé(e)' : label + ' créé(e)';
198
+ const showIcon = kind === 'deleted';
199
+ const embedTitle = showIcon ? title : title.replace(/^❌\s*/, '');
200
+
201
+ const start = entity && entity.start ? Number(entity.start) : NaN;
202
+ const end = entity && entity.end ? Number(entity.end) : NaN;
203
+ const fields = [];
204
+ const name = entity && entity.title ? String(entity.title) : '';
205
+ if (name) fields.push({ name: 'Titre', value: name, inline: false });
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
+ const username = entity && entity.username ? String(entity.username) : '';
210
+ if (username) fields.push({ name: 'Membre', value: username, inline: true });
211
+
212
+ return {
213
+ username: webhookUsername,
214
+ content: '',
215
+ embeds: [
216
+ {
217
+ title: embedTitle,
218
+ url: calUrl,
219
+ description: kind === 'deleted' ? 'Une entrée a été annulée.' : 'Une nouvelle entrée a été créée.',
220
+ fields,
221
+ footer: { text: 'Onekite • Calendrier' },
222
+ timestamp: new Date().toISOString(),
223
+ },
224
+ ],
225
+ };
226
+ }
227
+
228
+ async function notifySpecialEventCreated(settings, ev) {
229
+ const url = settings && settings.discordWebhookUrlEvents ? String(settings.discordWebhookUrlEvents).trim() : '';
230
+ if (!url) return;
231
+ try {
232
+ await postWebhook(url, buildSimpleCalendarPayload('created', 'Évènement', ev, { webhookUsername: 'Onekite • Évènement' }));
233
+ } catch (e) {
234
+ console.warn('[calendar-onekite] Discord webhook failed (event created)', e && e.message ? e.message : String(e));
235
+ }
236
+ }
237
+
238
+ async function notifySpecialEventDeleted(settings, ev) {
239
+ const url = settings && settings.discordWebhookUrlEvents ? String(settings.discordWebhookUrlEvents).trim() : '';
240
+ if (!url) return;
241
+ try {
242
+ await postWebhook(url, buildSimpleCalendarPayload('deleted', 'Évènement', ev, { webhookUsername: 'Onekite • Annulation' }));
243
+ } catch (e) {
244
+ console.warn('[calendar-onekite] Discord webhook failed (event deleted)', e && e.message ? e.message : String(e));
245
+ }
246
+ }
247
+
248
+ async function notifyOutingCreated(settings, o) {
249
+ const url = settings && settings.discordWebhookUrlOutings ? String(settings.discordWebhookUrlOutings).trim() : '';
250
+ if (!url) return;
251
+ try {
252
+ await postWebhook(url, buildSimpleCalendarPayload('created', 'Sortie', o, { webhookUsername: 'Onekite • Sortie' }));
253
+ } catch (e) {
254
+ console.warn('[calendar-onekite] Discord webhook failed (outing created)', e && e.message ? e.message : String(e));
255
+ }
256
+ }
257
+
258
+ async function notifyOutingDeleted(settings, o) {
259
+ const url = settings && settings.discordWebhookUrlOutings ? String(settings.discordWebhookUrlOutings).trim() : '';
260
+ if (!url) return;
261
+ try {
262
+ await postWebhook(url, buildSimpleCalendarPayload('deleted', 'Sortie', o, { webhookUsername: 'Onekite • Annulation' }));
263
+ } catch (e) {
264
+ console.warn('[calendar-onekite] Discord webhook failed (outing deleted)', e && e.message ? e.message : String(e));
265
+ }
266
+ }
186
267
  module.exports = {
187
268
  notifyReservationRequested,
188
269
  notifyPaymentReceived,
189
270
  notifyReservationCancelled,
271
+ notifySpecialEventCreated,
272
+ notifySpecialEventDeleted,
273
+ notifyOutingCreated,
274
+ notifyOutingDeleted,
190
275
  };
package/library.js CHANGED
@@ -83,6 +83,11 @@ Plugin.init = async function (params) {
83
83
  router.get('/api/v3/plugins/calendar-onekite/special-events/:eid', ...publicExpose, api.getSpecialEventDetails);
84
84
  router.delete('/api/v3/plugins/calendar-onekite/special-events/:eid', ...publicExpose, api.deleteSpecialEvent);
85
85
 
86
+ // Outings (prévisions de sortie)
87
+ router.post('/api/v3/plugins/calendar-onekite/outings', ...publicExpose, api.createOuting);
88
+ router.get('/api/v3/plugins/calendar-onekite/outings/:oid', ...publicExpose, api.getOutingDetails);
89
+ router.delete('/api/v3/plugins/calendar-onekite/outings/:oid', ...publicExpose, api.deleteOuting);
90
+
86
91
  // Admin API (JSON)
87
92
  const adminBases = ['/api/v3/admin/plugins/calendar-onekite'];
88
93
 
@@ -103,6 +108,8 @@ Plugin.init = async function (params) {
103
108
 
104
109
  // Purge special events by year
105
110
  router.post(`${base}/special-events/purge`, ...adminMws, admin.purgeSpecialEventsByYear);
111
+ // Purge outings by year
112
+ router.post(`${base}/outings/purge`, ...adminMws, admin.purgeOutingsByYear);
106
113
  });
107
114
 
108
115
  // HelloAsso callback endpoint (hardened)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.42",
3
+ "version": "2.0.44",
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.42"
42
+ "version": "2.0.44"
43
43
  }
package/public/admin.js CHANGED
@@ -559,6 +559,20 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
559
559
  }
560
560
  }
561
561
 
562
+ async function purgeOutings(year) {
563
+ try {
564
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/outings/purge', {
565
+ method: 'POST',
566
+ body: JSON.stringify({ year }),
567
+ });
568
+ } catch (e) {
569
+ return await fetchJson('/api/v3/admin/plugins/calendar-onekite/outings/purge', {
570
+ method: 'POST',
571
+ body: JSON.stringify({ year }),
572
+ });
573
+ }
574
+ }
575
+
562
576
  async function debugHelloAsso() {
563
577
  try {
564
578
  return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
@@ -1128,6 +1142,28 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
1128
1142
  });
1129
1143
  }
1130
1144
 
1145
+
1146
+ // Purge outings by year
1147
+ const outPurgeBtn = document.getElementById('onekite-out-purge');
1148
+ if (outPurgeBtn) {
1149
+ outPurgeBtn.addEventListener('click', async () => {
1150
+ const yearInput = document.getElementById('onekite-out-purge-year');
1151
+ const year = (yearInput ? yearInput.value : '').trim();
1152
+ if (!/^\d{4}$/.test(year)) {
1153
+ showAlert('error', 'Année invalide (YYYY)');
1154
+ return;
1155
+ }
1156
+ bootbox.confirm(`Purger toutes les sorties de ${year} ?`, async (ok) => {
1157
+ if (!ok) return;
1158
+ try {
1159
+ const r = await purgeOutings(year);
1160
+ showAlert('success', `Purge OK (${r.removed || 0} supprimé(s)).`);
1161
+ } catch (e) {
1162
+ showAlert('error', 'Purge impossible.');
1163
+ }
1164
+ });
1165
+ });
1166
+ }
1131
1167
  // Debug
1132
1168
  const debugBtn = document.getElementById('onekite-debug-run');
1133
1169
  if (debugBtn) {
package/public/client.js CHANGED
@@ -110,6 +110,11 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
110
110
  // Current FullCalendar instance (for refresh after actions)
111
111
  let currentCalendar = null;
112
112
 
113
+ // The creation chooser handler is created inside init() (it needs the
114
+ // calendar instance and capability flags). The mobile FAB is mounted outside
115
+ // init(), so we store the handler here.
116
+ let createFromSelectionHandler = null;
117
+
113
118
  // Mobile FAB (mounted only on the calendar page)
114
119
  let fabEl = null;
115
120
  let fabHandler = null;
@@ -208,7 +213,8 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
208
213
  return opts.join('');
209
214
  }
210
215
 
211
- async function openSpecialEventDialog(selectionInfo) {
216
+ async function openSpecialEventDialog(selectionInfo, opts) {
217
+ const kind = (opts && opts.kind) ? String(opts.kind) : 'special';
212
218
  const start = selectionInfo.start;
213
219
  // FullCalendar can omit `end` for certain interactions. Also, for all-day
214
220
  // selections, `end` is exclusive (next day at 00:00). We normalise below
@@ -259,10 +265,13 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
259
265
 
260
266
  const seStartTime = timeString(seStart);
261
267
  const seEndTime = timeString(seEnd);
268
+ const defaultTitlePlaceholder = kind === 'outing' ? 'Prévision de sortie' : 'Ex: ...';
269
+ const defaultTitleValue = kind === 'outing' ? 'Prévision de sortie' : '';
270
+
262
271
  const html = `
263
272
  <div class="mb-3">
264
273
  <label class="form-label">Titre</label>
265
- <input type="text" class="form-control" id="onekite-se-title" placeholder="Ex: ..." />
274
+ <input type="text" class="form-control" id="onekite-se-title" value="${escapeHtml(defaultTitleValue)}" placeholder="${escapeHtml(defaultTitlePlaceholder)}" />
266
275
  </div>
267
276
  <div class="row g-2">
268
277
  <div class="col-12 col-md-6">
@@ -307,7 +316,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
307
316
  return await new Promise((resolve) => {
308
317
  let resolved = false;
309
318
  const dialog = bootbox.dialog({
310
- title: 'Créer un évènement',
319
+ title: kind === 'outing' ? 'Créer une prévision de sortie' : 'Créer un évènement',
311
320
  message: html,
312
321
  buttons: {
313
322
  cancel: {
@@ -427,6 +436,18 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
427
436
  });
428
437
  }
429
438
 
439
+
440
+
441
+ async function openOutingDialog(selectionInfo) {
442
+ // Same UI as special events, but saved as "Sorties" (prévisions) and with
443
+ // a different title.
444
+ const payload = await openSpecialEventDialog(selectionInfo, { kind: 'outing' });
445
+ if (!payload) return null;
446
+ // Default title if empty
447
+ if (!payload.title) payload.title = 'Prévision de sortie';
448
+ return payload;
449
+ }
450
+
430
451
  async function openMapViewer(title, address, lat, lon) {
431
452
  const mapId = `onekite-map-view-${Date.now()}-${Math.floor(Math.random()*10000)}`;
432
453
  const safeAddr = (address || '').trim();
@@ -1158,97 +1179,7 @@ function toDatetimeLocalValue(date) {
1158
1179
  const canCreateSpecial = !!caps.canCreateSpecial;
1159
1180
  const canDeleteSpecial = !!caps.canDeleteSpecial;
1160
1181
 
1161
- // Current creation mode: reservation (location) or special (event).
1162
- // Persist the user's last choice so actions (create/approve/refuse/refetch) don't reset it.
1163
- const modeStorageKey = (() => {
1164
- try {
1165
- const uid = (typeof app !== 'undefined' && app && app.user && (app.user.uid || app.user.uid === 0)) ? String(app.user.uid)
1166
- : (window.ajaxify && window.ajaxify.data && (window.ajaxify.data.uid || window.ajaxify.data.uid === 0)) ? String(window.ajaxify.data.uid)
1167
- : '0';
1168
- return `onekiteCalendarMode:${uid}`;
1169
- } catch (e) {
1170
- return 'onekiteCalendarMode:0';
1171
- }
1172
- })();
1173
-
1174
- function loadSavedMode() {
1175
- try {
1176
- const v = (window.localStorage && window.localStorage.getItem(modeStorageKey)) || '';
1177
- return (v === 'special' || v === 'reservation') ? v : 'reservation';
1178
- } catch (e) {
1179
- return 'reservation';
1180
- }
1181
- }
1182
-
1183
- function saveMode(v) {
1184
- try {
1185
- if (!window.localStorage) return;
1186
- window.localStorage.setItem(modeStorageKey, v);
1187
- } catch (e) {
1188
- // ignore
1189
- }
1190
- }
1191
-
1192
- let mode = loadSavedMode();
1193
- // Avoid showing the "mode évènement" hint multiple times (desktop + mobile handlers)
1194
- let lastModeHintAt = 0;
1195
-
1196
- function refreshDesktopModeButton() {
1197
- try {
1198
- const btn = document.querySelector('#onekite-calendar .fc-newSpecial-button');
1199
- if (!btn) return;
1200
-
1201
- const isSpecial = mode === 'special';
1202
- const label = isSpecial ? 'Évènement ✓' : 'Évènement';
1203
-
1204
- // Ensure a single canonical .fc-button-text (prevents "ÉvènementÉvènement" after rerenders)
1205
- let span = btn.querySelector('.fc-button-text');
1206
- if (!span) {
1207
- span = document.createElement('span');
1208
- span.className = 'fc-button-text';
1209
- // Remove stray text nodes before inserting span
1210
- [...btn.childNodes].forEach((n) => {
1211
- if (n && n.nodeType === Node.TEXT_NODE) n.remove();
1212
- });
1213
- btn.appendChild(span);
1214
- } else {
1215
- // Remove any stray text nodes beside the span
1216
- [...btn.childNodes].forEach((n) => {
1217
- if (n && n.nodeType === Node.TEXT_NODE && n.textContent.trim()) n.remove();
1218
- });
1219
- }
1220
-
1221
- span.textContent = label;
1222
- btn.classList.toggle('onekite-active', isSpecial);
1223
- } catch (e) {}
1224
- }
1225
-
1226
- function setMode(next, opts) {
1227
- if (next !== 'reservation' && next !== 'special') return;
1228
- mode = next;
1229
- saveMode(mode);
1230
-
1231
- // Reset any pending selection/dialog state when switching modes
1232
- try { if (mode === 'reservation') { calendar.unselect(); isDialogOpen = false; } } catch (e) {}
1233
-
1234
- const silent = !!(opts && opts.silent);
1235
- if (!silent && mode === 'special') {
1236
- const now = Date.now();
1237
- if (now - lastModeHintAt > 1200) {
1238
- lastModeHintAt = now;
1239
- showAlert('success', 'Mode évènement : sélectionnez une date ou une plage');
1240
- }
1241
- }
1242
- refreshDesktopModeButton();
1243
- try {
1244
- const mb = document.querySelector('#onekite-mobile-controls .onekite-mode-btn');
1245
- if (mb) {
1246
- const isSpecial = mode === 'special';
1247
- mb.textContent = isSpecial ? 'Mode évènement ✓' : 'Mode évènement';
1248
- mb.classList.toggle('onekite-active', isSpecial);
1249
- }
1250
- } catch (e) {}
1251
- }
1182
+ // Creation chooser: Location / Prévision de sortie / Évènement (si autorisé).
1252
1183
 
1253
1184
  // Inject lightweight responsive CSS once.
1254
1185
  try {
@@ -1315,100 +1246,29 @@ function toDatetimeLocalValue(date) {
1315
1246
  left: 'prev,next today',
1316
1247
  center: 'title',
1317
1248
  // Only month + week (no day view)
1318
- right: (canCreateSpecial ? 'newSpecial ' : '') + 'dayGridMonth,timeGridWeek',
1249
+ right: 'dayGridMonth,timeGridWeek',
1319
1250
  };
1320
1251
 
1321
1252
  let calendar;
1322
1253
 
1323
- // Unified handler for creation actions (reservations vs special events).
1324
- // On mobile, FullCalendar may emit `dateClick` but not `select` for a simple tap.
1325
- // We therefore support both without calling `calendar.select()` (which could
1326
- // double-trigger `select`).
1254
+ // Unified handler for creation actions via a chooser modal.
1327
1255
  async function handleCreateFromSelection(info) {
1328
- if (isDialogOpen) {
1329
- return;
1330
- }
1331
- // Avoid double-taps creating two dialogs / two requests.
1332
- if (!lockAction('create', 900)) {
1333
- return;
1334
- }
1335
- isDialogOpen = true;
1256
+ if (isDialogOpen) return;
1257
+ if (!lockAction('create', 900)) return;
1258
+
1259
+ // Business rule: nothing can be created in the past.
1336
1260
  try {
1337
- if (mode === 'special' && canCreateSpecial) {
1338
- const payload = await openSpecialEventDialog(info);
1339
- if (!payload) {
1340
- calendar.unselect();
1341
- isDialogOpen = false;
1342
- return;
1343
- }
1344
- await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1345
- method: 'POST',
1346
- body: JSON.stringify(payload),
1347
- }).catch(async () => {
1348
- return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1349
- method: 'POST',
1350
- body: JSON.stringify(payload),
1351
- });
1352
- });
1353
- showAlert('success', 'Évènement créé.');
1354
- invalidateEventsCache();
1355
- scheduleRefetch(calendar);
1356
- calendar.unselect();
1357
- isDialogOpen = false;
1261
+ const startDateCheck = toLocalYmd(info.start);
1262
+ const todayCheck = toLocalYmd(new Date());
1263
+ if (startDateCheck < todayCheck) {
1264
+ lastDateRuleToastAt = Date.now();
1265
+ showAlert('error', "Impossible de créer pour une date passée.");
1266
+ try { calendar.unselect(); } catch (e) {}
1358
1267
  return;
1359
1268
  }
1269
+ } catch (e) {}
1360
1270
 
1361
- // Business rule: reservations cannot start in the past.
1362
- // (We validate again on the server, but this gives immediate feedback.)
1363
- try {
1364
- const startDateCheck = toLocalYmd(info.start);
1365
- const todayCheck = toLocalYmd(new Date());
1366
- if (startDateCheck < todayCheck) {
1367
- lastDateRuleToastAt = Date.now();
1368
- showAlert('error', "Impossible de réserver pour une date passée.");
1369
- calendar.unselect();
1370
- isDialogOpen = false;
1371
- return;
1372
- }
1373
- } catch (e) {
1374
- // ignore
1375
- }
1376
-
1377
- if (!items || !items.length) {
1378
- showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1379
- calendar.unselect();
1380
- isDialogOpen = false;
1381
- return;
1382
- }
1383
- const chosen = await openReservationDialog(info, items);
1384
- if (!chosen || !chosen.itemIds || !chosen.itemIds.length) {
1385
- calendar.unselect();
1386
- isDialogOpen = false;
1387
- return;
1388
- }
1389
- // Send date strings (no hours) so reservations are day-based.
1390
- const startDate = toLocalYmd(info.start);
1391
- // NOTE: FullCalendar's `info.end` reflects the original selection.
1392
- // If the user used "Durée rapide", the effective end date is held
1393
- // inside the dialog (returned as `chosen.endDate`).
1394
- const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
1395
- const resp = await requestReservation({
1396
- start: startDate,
1397
- end: endDate,
1398
- itemIds: chosen.itemIds,
1399
- itemNames: chosen.itemNames,
1400
- total: chosen.total,
1401
- });
1402
- if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
1403
- showAlert('success', 'Réservation confirmée.');
1404
- } else {
1405
- showAlert('success', 'Demande envoyée (en attente de validation).');
1406
- }
1407
- invalidateEventsCache();
1408
- scheduleRefetch(calendar);
1409
- calendar.unselect();
1410
- isDialogOpen = false;
1411
- } catch (e) {
1271
+ function handleCreateError(e) {
1412
1272
  const code = String((e && (e.status || e.message)) || '');
1413
1273
  const payload = e && e.payload ? e.payload : null;
1414
1274
 
@@ -1416,35 +1276,134 @@ function toDatetimeLocalValue(date) {
1416
1276
  const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
1417
1277
  const c = payload && payload.code ? String(payload.code) : '';
1418
1278
  if (c === 'NOT_MEMBER' || /adh(é|e)rent/i.test(msg) || /membership/i.test(msg)) {
1419
- showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer une réservation.');
1279
+ showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer cette action.');
1420
1280
  } else {
1421
- showAlert('error', msg || 'Impossible de créer la demande : droits insuffisants (groupe).');
1281
+ showAlert('error', msg || 'Action impossible : droits insuffisants (groupe).');
1422
1282
  }
1423
1283
  } else if (code === '409') {
1424
- showAlert('error', 'Impossible : au moins un matériel est déjà réservé ou en attente sur cette période.');
1284
+ showAlert('error', 'Impossible : conflit de réservation sur cette période.');
1425
1285
  } else if (code === '400' && payload && (payload.error === 'date-too-soon' || payload.code === 'date-too-soon')) {
1426
- // If we already showed the client-side toast a moment ago, avoid a duplicate.
1427
1286
  if (!lastDateRuleToastAt || (Date.now() - lastDateRuleToastAt) > 1500) {
1428
- showAlert('error', String(payload.message || "Impossible de réserver pour une date passée."));
1287
+ showAlert('error', String(payload.message || "Impossible de créer pour une date passée."));
1429
1288
  }
1430
1289
  } else {
1431
1290
  const msgRaw = payload && (payload.message || payload.error || payload.msg)
1432
1291
  ? String(payload.message || payload.error || payload.msg)
1433
1292
  : '';
1434
-
1435
- // NodeBB can return a plain "not-logged-in" string when the user is not authenticated.
1436
- // We want a user-friendly message consistent with the membership requirement.
1437
1293
  const msg = (/\bnot-logged-in\b/i.test(msgRaw) || /\[\[error:not-logged-in\]\]/i.test(msgRaw))
1438
1294
  ? 'Vous devez être adhérent Onekite'
1439
1295
  : msgRaw;
1440
-
1441
- showAlert('error', msg || ((e && (e.status === 401 || e.status === 403)) ? 'Vous devez être adhérent Onekite' : 'Erreur lors de la création de la demande.'));
1296
+ showAlert('error', msg || 'Erreur lors de la création.');
1442
1297
  }
1443
- calendar.unselect();
1444
- isDialogOpen = false;
1445
1298
  }
1299
+
1300
+ const buttons = {
1301
+ close: { label: 'Annuler', className: 'btn-secondary' },
1302
+ location: {
1303
+ label: 'Location',
1304
+ className: 'btn-primary',
1305
+ callback: async () => {
1306
+ try {
1307
+ isDialogOpen = true;
1308
+ if (!items || !items.length) {
1309
+ showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1310
+ return;
1311
+ }
1312
+ const chosen = await openReservationDialog(info, items);
1313
+ if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1314
+ const startDate = toLocalYmd(info.start);
1315
+ const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
1316
+ const resp = await requestReservation({
1317
+ start: startDate,
1318
+ end: endDate,
1319
+ itemIds: chosen.itemIds,
1320
+ itemNames: chosen.itemNames,
1321
+ total: chosen.total,
1322
+ });
1323
+ if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
1324
+ showAlert('success', 'Réservation confirmée.');
1325
+ } else {
1326
+ showAlert('success', 'Demande envoyée (en attente de validation).');
1327
+ }
1328
+ invalidateEventsCache();
1329
+ scheduleRefetch(calendar);
1330
+ } catch (e) {
1331
+ handleCreateError(e);
1332
+ } finally {
1333
+ try { calendar.unselect(); } catch (e) {}
1334
+ isDialogOpen = false;
1335
+ }
1336
+ return true;
1337
+ },
1338
+ },
1339
+ outing: {
1340
+ label: 'Prévision de sortie',
1341
+ className: 'btn-outline-primary',
1342
+ callback: async () => {
1343
+ try {
1344
+ isDialogOpen = true;
1345
+ const payload = await openOutingDialog(info);
1346
+ if (!payload) return;
1347
+ await fetchJson('/api/v3/plugins/calendar-onekite/outings', {
1348
+ method: 'POST',
1349
+ body: JSON.stringify(payload),
1350
+ });
1351
+ showAlert('success', 'Prévision de sortie créée.');
1352
+ invalidateEventsCache();
1353
+ scheduleRefetch(calendar);
1354
+ } catch (e) {
1355
+ handleCreateError(e);
1356
+ } finally {
1357
+ try { calendar.unselect(); } catch (e) {}
1358
+ isDialogOpen = false;
1359
+ }
1360
+ return true;
1361
+ },
1362
+ },
1363
+ };
1364
+
1365
+ if (canCreateSpecial) {
1366
+ buttons.special = {
1367
+ label: 'Évènement',
1368
+ className: 'btn-outline-secondary',
1369
+ callback: async () => {
1370
+ try {
1371
+ isDialogOpen = true;
1372
+ const payload = await openSpecialEventDialog(info);
1373
+ if (!payload) return;
1374
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1375
+ method: 'POST',
1376
+ body: JSON.stringify(payload),
1377
+ }).catch(async () => {
1378
+ return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1379
+ method: 'POST',
1380
+ body: JSON.stringify(payload),
1381
+ });
1382
+ });
1383
+ showAlert('success', 'Évènement créé.');
1384
+ invalidateEventsCache();
1385
+ scheduleRefetch(calendar);
1386
+ } catch (e) {
1387
+ handleCreateError(e);
1388
+ } finally {
1389
+ try { calendar.unselect(); } catch (e) {}
1390
+ isDialogOpen = false;
1391
+ }
1392
+ return true;
1393
+ },
1394
+ };
1395
+ }
1396
+
1397
+ bootbox.dialog({
1398
+ title: 'Créer',
1399
+ message: '<div class="text-muted">Que veux-tu créer sur ces dates ?</div>',
1400
+ buttons,
1401
+ });
1446
1402
  }
1447
1403
 
1404
+ // Expose to the mobile FAB (mounted outside init).
1405
+ createFromSelectionHandler = handleCreateFromSelection;
1406
+
1448
1407
  calendar = new FullCalendar.Calendar(el, {
1449
1408
  initialView: 'dayGridMonth',
1450
1409
  height: 'auto',
@@ -1455,14 +1414,7 @@ function toDatetimeLocalValue(date) {
1455
1414
  headerToolbar: headerToolbar,
1456
1415
  // Keep titles short on mobile to avoid horizontal overflow
1457
1416
  titleFormat: isMobileNow() ? { year: 'numeric', month: 'short' } : undefined,
1458
- customButtons: canCreateSpecial ? {
1459
- newSpecial: {
1460
- text: 'Évènement',
1461
- click: () => {
1462
- setMode((mode === 'special') ? 'reservation' : 'special');
1463
- },
1464
- },
1465
- } : {},
1417
+ customButtons: {},
1466
1418
  // We display the time ourselves inside the title for "special" events,
1467
1419
  // to match reservation icons and avoid FullCalendar's fixed-width time column.
1468
1420
  displayEventTime: false,
@@ -1585,7 +1537,13 @@ function toDatetimeLocalValue(date) {
1585
1537
  } else if (p0.type === 'special' && p0.eid) {
1586
1538
  const details = await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(String(p0.eid))}`);
1587
1539
  p = Object.assign({}, p0, details, {
1588
- // keep backward compat with older field names used by templates below
1540
+ pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1541
+ pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1542
+ pickupLon: details.lon || details.pickupLon || p0.pickupLon,
1543
+ });
1544
+ } else if (p0.type === 'outing' && p0.oid) {
1545
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(String(p0.oid))}`);
1546
+ p = Object.assign({}, p0, details, {
1589
1547
  pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1590
1548
  pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1591
1549
  pickupLon: details.lon || details.pickupLon || p0.pickupLon,
@@ -1598,53 +1556,106 @@ function toDatetimeLocalValue(date) {
1598
1556
 
1599
1557
  try {
1600
1558
  if (p.type === 'special') {
1601
- const username = String(p.username || '').trim();
1602
- const userLine = username
1603
- ? `<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>`
1604
- : '';
1605
- const addr = String(p.pickupAddress || '').trim();
1606
- const lat = Number(p.pickupLat);
1607
- const lon = Number(p.pickupLon);
1608
- const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1609
- const notes = String(p.notes || '').trim();
1610
- const addrHtml = addr
1611
- ? (hasCoords
1612
- ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
1613
- : `${escapeHtml(addr)}`)
1614
- : '';
1615
- const html = `
1616
- <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1617
- ${userLine}
1618
- <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
1619
- ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1620
- ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1621
- `;
1622
- const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
1623
- bootbox.dialog({
1624
- title: 'Évènement',
1625
- message: html,
1626
- buttons: {
1627
- close: { label: 'Fermer', className: 'btn-secondary' },
1628
- ...(canDel ? {
1629
- del: {
1630
- label: 'Supprimer',
1631
- className: 'btn-danger',
1632
- callback: async () => {
1633
- try {
1634
- const eid = String(p.eid || ev.id).replace(/^special:/, '');
1635
- await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' });
1636
- showAlert('success', 'Évènement supprimé.');
1637
- calendar.refetchEvents();
1638
- } catch (e) {
1639
- showAlert('error', 'Suppression impossible.');
1640
- }
1559
+ const username = String(p.username || '').trim();
1560
+ const userLine = username
1561
+ ? `<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>`
1562
+ : '';
1563
+ const addr = String(p.pickupAddress || p.address || '').trim();
1564
+ const lat = Number(p.pickupLat || p.lat);
1565
+ const lon = Number(p.pickupLon || p.lon);
1566
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1567
+ const notes = String(p.notes || '').trim();
1568
+ const addrHtml = addr
1569
+ ? (hasCoords
1570
+ ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
1571
+ : `${escapeHtml(addr)}`)
1572
+ : '';
1573
+ const html = `
1574
+ <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1575
+ ${userLine}
1576
+ <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
1577
+ ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1578
+ ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1579
+ `;
1580
+ const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
1581
+ bootbox.dialog({
1582
+ title: 'Évènement',
1583
+ message: html,
1584
+ buttons: {
1585
+ close: { label: 'Fermer', className: 'btn-secondary' },
1586
+ ...(canDel ? {
1587
+ del: {
1588
+ label: 'Supprimer',
1589
+ className: 'btn-danger',
1590
+ callback: async () => {
1591
+ try {
1592
+ const eid = String(p.eid || ev.id).replace(/^special:/, '');
1593
+ await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' });
1594
+ showAlert('success', 'Évènement supprimé.');
1595
+ calendar.refetchEvents();
1596
+ } catch (e) {
1597
+ showAlert('error', 'Suppression impossible.');
1598
+ }
1599
+ },
1641
1600
  },
1642
- },
1643
- } : {}),
1644
- },
1645
- });
1646
- return;
1601
+ } : {}),
1602
+ },
1603
+ });
1604
+ return;
1605
+ }
1606
+
1607
+ if (p.type === 'outing') {
1608
+ const username = String(p.username || '').trim();
1609
+ const userLine = username
1610
+ ? `<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>`
1611
+ : '';
1612
+ const addr = String(p.address || p.pickupAddress || '').trim();
1613
+ const lat = Number(p.lat || p.pickupLat);
1614
+ const lon = Number(p.lon || p.pickupLon);
1615
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1616
+ const notes = String(p.notes || '').trim();
1617
+ const addrHtml = addr
1618
+ ? (hasCoords
1619
+ ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
1620
+ : `${escapeHtml(addr)}`)
1621
+ : '';
1622
+ const html = `
1623
+ <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1624
+ ${userLine}
1625
+ <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
1626
+ ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1627
+ ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1628
+ `;
1629
+ const canDel = !!(p.canDeleteOuting);
1630
+ bootbox.dialog({
1631
+ title: 'Prévision de sortie',
1632
+ message: html,
1633
+ buttons: {
1634
+ close: { label: 'Fermer', className: 'btn-secondary' },
1635
+ ...(canDel ? {
1636
+ del: {
1637
+ label: 'Annuler',
1638
+ className: 'btn-danger',
1639
+ callback: async () => {
1640
+ try {
1641
+ const oid = String(p.oid || ev.id).replace(/^outing:/, '');
1642
+ await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(oid)}`, { method: 'DELETE' });
1643
+ showAlert('success', 'Prévision annulée.');
1644
+ calendar.refetchEvents();
1645
+ } catch (e) {
1646
+ showAlert('error', 'Annulation impossible.');
1647
+ }
1648
+ },
1649
+ },
1650
+ } : {}),
1651
+ },
1652
+ });
1653
+ return;
1654
+ }
1655
+ } catch (e) {
1656
+ // ignore
1647
1657
  }
1658
+
1648
1659
  const rid = p.rid || ev.id;
1649
1660
  const status = p.status || '';
1650
1661
 
@@ -1956,9 +1967,6 @@ function toDatetimeLocalValue(date) {
1956
1967
  message: baseHtml,
1957
1968
  buttons,
1958
1969
  });
1959
- } finally {
1960
- isDialogOpen = false;
1961
- }
1962
1970
  },
1963
1971
  });
1964
1972
 
@@ -2090,7 +2098,7 @@ function toDatetimeLocalValue(date) {
2090
2098
  }
2091
2099
 
2092
2100
 
2093
- async function openFabDatePicker() {
2101
+ async function openFabDatePicker(onSelection) {
2094
2102
  if (!lockAction('fab-date-picker', 700)) return;
2095
2103
 
2096
2104
  // Cannot book past dates (mobile FAB). Same-day booking is allowed.
@@ -2148,6 +2156,12 @@ async function openFabDatePicker() {
2148
2156
  const endExcl = new Date(e);
2149
2157
  endExcl.setDate(endExcl.getDate() + 1);
2150
2158
 
2159
+ // If a callback is provided, delegate the next step (chooser).
2160
+ if (typeof onSelection === 'function') {
2161
+ try { onSelection({ start: s, end: endExcl, allDay: true }); } catch (e) {}
2162
+ return true;
2163
+ }
2164
+
2151
2165
  (async () => {
2152
2166
  try {
2153
2167
  if (isDialogOpen) return;
@@ -2174,8 +2188,6 @@ async function openFabDatePicker() {
2174
2188
  }
2175
2189
  } catch (err) {
2176
2190
  // ignore
2177
- } finally {
2178
- isDialogOpen = false;
2179
2191
  }
2180
2192
  })();
2181
2193
 
@@ -2311,6 +2323,33 @@ async function openFabDatePicker() {
2311
2323
  render();
2312
2324
  });
2313
2325
  }
2326
+
2327
+ // Swipe left/right to change month
2328
+ try {
2329
+ const root = document.getElementById('onekite-range-picker');
2330
+ if (root) {
2331
+ let x0 = null;
2332
+ root.addEventListener('touchstart', (ev) => {
2333
+ try { x0 = ev.touches && ev.touches[0] ? ev.touches[0].clientX : null; } catch (e) { x0 = null; }
2334
+ }, { passive: true });
2335
+ root.addEventListener('touchend', (ev) => {
2336
+ try {
2337
+ if (x0 === null) return;
2338
+ const x1 = ev.changedTouches && ev.changedTouches[0] ? ev.changedTouches[0].clientX : null;
2339
+ if (x1 === null) return;
2340
+ const dx = x1 - x0;
2341
+ if (Math.abs(dx) < 40) return;
2342
+ if (dx < 0) {
2343
+ state.cursor.setMonth(state.cursor.getMonth() + 1);
2344
+ } else {
2345
+ state.cursor.setMonth(state.cursor.getMonth() - 1);
2346
+ }
2347
+ render();
2348
+ } catch (e) {}
2349
+ finally { x0 = null; }
2350
+ }, { passive: true });
2351
+ }
2352
+ } catch (e) {}
2314
2353
  render();
2315
2354
  } catch (e) {}
2316
2355
  });
@@ -2342,7 +2381,12 @@ function parseYmdDate(ymdStr) {
2342
2381
  fabEl.setAttribute('aria-label', 'Nouvelle réservation');
2343
2382
  fabEl.innerHTML = '<i class="fa fa-plus"></i>';
2344
2383
 
2345
- fabHandler = () => openFabDatePicker();
2384
+ fabHandler = () => {
2385
+ // init() sets createFromSelectionHandler. If the calendar has not
2386
+ // finished initialising, do nothing.
2387
+ if (typeof createFromSelectionHandler !== 'function') return;
2388
+ openFabDatePicker(createFromSelectionHandler);
2389
+ };
2346
2390
  fabEl.addEventListener('click', fabHandler);
2347
2391
 
2348
2392
  document.body.appendChild(fabEl);
@@ -11,6 +11,9 @@
11
11
  <li class="nav-item" role="presentation">
12
12
  <button class="nav-link" data-bs-toggle="tab" data-bs-target="#onekite-tab-events" type="button" role="tab">Évènements</button>
13
13
  </li>
14
+ <li class="nav-item" role="presentation">
15
+ <button class="nav-link" data-bs-toggle="tab" data-bs-target="#onekite-tab-outings" type="button" role="tab">Sorties</button>
16
+ </li>
14
17
  <li class="nav-item" role="presentation">
15
18
  <button class="nav-link" data-bs-toggle="tab" data-bs-target="#onekite-tab-pending" type="button" role="tab">Demandes en attente</button>
16
19
  </li>
@@ -80,6 +83,18 @@
80
83
  <div class="form-text">Si vide, aucune notification Discord n'est envoyée.</div>
81
84
  </div>
82
85
 
86
+ <div class="mb-3">
87
+ <label class="form-label">Webhook URL — Évènements</label>
88
+ <input class="form-control" name="discordWebhookUrlEvents" placeholder="https://discord.com/api/webhooks/...">
89
+ <div class="form-text">Canal Discord dédié aux notifications d'évènements (création / annulation).</div>
90
+ </div>
91
+
92
+ <div class="mb-3">
93
+ <label class="form-label">Webhook URL — Sorties</label>
94
+ <input class="form-control" name="discordWebhookUrlOutings" placeholder="https://discord.com/api/webhooks/...">
95
+ <div class="form-text">Canal Discord dédié aux notifications de sorties (création / annulation).</div>
96
+ </div>
97
+
83
98
  <div class="mb-3">
84
99
  <label class="form-label">Envoyer une notification à la demande</label>
85
100
  <select class="form-select" name="discordNotifyOnRequest">
@@ -177,6 +192,20 @@
177
192
  <div class="form-text mt-2">Supprime définitivement tous les évènements dont la date de début est dans l'année sélectionnée.</div>
178
193
  </div>
179
194
 
195
+ <div class="tab-pane fade" id="onekite-tab-outings" role="tabpanel">
196
+ <h4>Sorties (prévisions)</h4>
197
+ <div class="form-text mb-3">Prévisions de sortie (autre couleur) créées par les utilisateurs autorisés à faire une demande de location. Les utilisateurs peuvent annuler leurs propres prévisions. Les droits sont ceux des locations.</div>
198
+
199
+ <hr class="my-4" />
200
+ <h4>Purge des sorties</h4>
201
+ <div class="d-flex gap-2 align-items-center">
202
+ <input class="form-control" style="max-width: 160px;" id="onekite-out-purge-year" placeholder="YYYY">
203
+ <button type="button" class="btn btn-outline-danger" id="onekite-out-purge">Purger</button>
204
+ </div>
205
+ <div class="form-text mt-2">Supprime définitivement toutes les sorties dont la date de début est dans l'année sélectionnée.</div>
206
+ </div>
207
+
208
+
180
209
  <div class="tab-pane fade" id="onekite-tab-pending" role="tabpanel">
181
210
  <h4>Demandes en attente</h4>
182
211
  <div id="onekite-pending" class="list-group"></div>