nodebb-plugin-onekite-calendar 2.0.42 → 2.0.43

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.43",
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.43"
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
@@ -427,6 +427,17 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
427
427
  });
428
428
  }
429
429
 
430
+
431
+
432
+ async function openOutingDialog(selectionInfo) {
433
+ // Same UI as special events, but saved as "Sorties" (prévisions).
434
+ const payload = await openSpecialEventDialog(selectionInfo);
435
+ if (!payload) return null;
436
+ // Default title if empty
437
+ if (!payload.title) payload.title = 'Prévision de sortie';
438
+ return payload;
439
+ }
440
+
430
441
  async function openMapViewer(title, address, lat, lon) {
431
442
  const mapId = `onekite-map-view-${Date.now()}-${Math.floor(Math.random()*10000)}`;
432
443
  const safeAddr = (address || '').trim();
@@ -1158,97 +1169,7 @@ function toDatetimeLocalValue(date) {
1158
1169
  const canCreateSpecial = !!caps.canCreateSpecial;
1159
1170
  const canDeleteSpecial = !!caps.canDeleteSpecial;
1160
1171
 
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
- }
1172
+ // Creation chooser: Location / Prévision de sortie / Évènement (si autorisé).
1252
1173
 
1253
1174
  // Inject lightweight responsive CSS once.
1254
1175
  try {
@@ -1315,100 +1236,29 @@ function toDatetimeLocalValue(date) {
1315
1236
  left: 'prev,next today',
1316
1237
  center: 'title',
1317
1238
  // Only month + week (no day view)
1318
- right: (canCreateSpecial ? 'newSpecial ' : '') + 'dayGridMonth,timeGridWeek',
1239
+ right: 'dayGridMonth,timeGridWeek',
1319
1240
  };
1320
1241
 
1321
1242
  let calendar;
1322
1243
 
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`).
1244
+ // Unified handler for creation actions via a chooser modal.
1327
1245
  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;
1246
+ if (isDialogOpen) return;
1247
+ if (!lockAction('create', 900)) return;
1248
+
1249
+ // Business rule: nothing can be created in the past.
1336
1250
  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;
1251
+ const startDateCheck = toLocalYmd(info.start);
1252
+ const todayCheck = toLocalYmd(new Date());
1253
+ if (startDateCheck < todayCheck) {
1254
+ lastDateRuleToastAt = Date.now();
1255
+ showAlert('error', "Impossible de créer pour une date passée.");
1256
+ try { calendar.unselect(); } catch (e) {}
1358
1257
  return;
1359
1258
  }
1259
+ } catch (e) {}
1360
1260
 
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) {
1261
+ function handleCreateError(e) {
1412
1262
  const code = String((e && (e.status || e.message)) || '');
1413
1263
  const payload = e && e.payload ? e.payload : null;
1414
1264
 
@@ -1416,33 +1266,129 @@ function toDatetimeLocalValue(date) {
1416
1266
  const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
1417
1267
  const c = payload && payload.code ? String(payload.code) : '';
1418
1268
  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.');
1269
+ showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer cette action.');
1420
1270
  } else {
1421
- showAlert('error', msg || 'Impossible de créer la demande : droits insuffisants (groupe).');
1271
+ showAlert('error', msg || 'Action impossible : droits insuffisants (groupe).');
1422
1272
  }
1423
1273
  } 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.');
1274
+ showAlert('error', 'Impossible : conflit de réservation sur cette période.');
1425
1275
  } 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
1276
  if (!lastDateRuleToastAt || (Date.now() - lastDateRuleToastAt) > 1500) {
1428
- showAlert('error', String(payload.message || "Impossible de réserver pour une date passée."));
1277
+ showAlert('error', String(payload.message || "Impossible de créer pour une date passée."));
1429
1278
  }
1430
1279
  } else {
1431
1280
  const msgRaw = payload && (payload.message || payload.error || payload.msg)
1432
1281
  ? String(payload.message || payload.error || payload.msg)
1433
1282
  : '';
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
1283
  const msg = (/\bnot-logged-in\b/i.test(msgRaw) || /\[\[error:not-logged-in\]\]/i.test(msgRaw))
1438
1284
  ? 'Vous devez être adhérent Onekite'
1439
1285
  : 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.'));
1286
+ showAlert('error', msg || 'Erreur lors de la création.');
1442
1287
  }
1443
- calendar.unselect();
1444
- isDialogOpen = false;
1445
1288
  }
1289
+
1290
+ const buttons = {
1291
+ close: { label: 'Annuler', className: 'btn-secondary' },
1292
+ location: {
1293
+ label: 'Location',
1294
+ className: 'btn-primary',
1295
+ callback: async () => {
1296
+ try {
1297
+ isDialogOpen = true;
1298
+ if (!items || !items.length) {
1299
+ showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
1300
+ return;
1301
+ }
1302
+ const chosen = await openReservationDialog(info, items);
1303
+ if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
1304
+ const startDate = toLocalYmd(info.start);
1305
+ const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
1306
+ const resp = await requestReservation({
1307
+ start: startDate,
1308
+ end: endDate,
1309
+ itemIds: chosen.itemIds,
1310
+ itemNames: chosen.itemNames,
1311
+ total: chosen.total,
1312
+ });
1313
+ if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
1314
+ showAlert('success', 'Réservation confirmée.');
1315
+ } else {
1316
+ showAlert('success', 'Demande envoyée (en attente de validation).');
1317
+ }
1318
+ invalidateEventsCache();
1319
+ scheduleRefetch(calendar);
1320
+ } catch (e) {
1321
+ handleCreateError(e);
1322
+ } finally {
1323
+ try { calendar.unselect(); } catch (e) {}
1324
+ isDialogOpen = false;
1325
+ }
1326
+ return true;
1327
+ },
1328
+ },
1329
+ outing: {
1330
+ label: 'Prévision de sortie',
1331
+ className: 'btn-outline-primary',
1332
+ callback: async () => {
1333
+ try {
1334
+ isDialogOpen = true;
1335
+ const payload = await openOutingDialog(info);
1336
+ if (!payload) return;
1337
+ await fetchJson('/api/v3/plugins/calendar-onekite/outings', {
1338
+ method: 'POST',
1339
+ body: JSON.stringify(payload),
1340
+ });
1341
+ showAlert('success', 'Prévision de sortie créée.');
1342
+ invalidateEventsCache();
1343
+ scheduleRefetch(calendar);
1344
+ } catch (e) {
1345
+ handleCreateError(e);
1346
+ } finally {
1347
+ try { calendar.unselect(); } catch (e) {}
1348
+ isDialogOpen = false;
1349
+ }
1350
+ return true;
1351
+ },
1352
+ },
1353
+ };
1354
+
1355
+ if (canCreateSpecial) {
1356
+ buttons.special = {
1357
+ label: 'Évènement',
1358
+ className: 'btn-outline-secondary',
1359
+ callback: async () => {
1360
+ try {
1361
+ isDialogOpen = true;
1362
+ const payload = await openSpecialEventDialog(info);
1363
+ if (!payload) return;
1364
+ await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1365
+ method: 'POST',
1366
+ body: JSON.stringify(payload),
1367
+ }).catch(async () => {
1368
+ return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
1369
+ method: 'POST',
1370
+ body: JSON.stringify(payload),
1371
+ });
1372
+ });
1373
+ showAlert('success', 'Évènement créé.');
1374
+ invalidateEventsCache();
1375
+ scheduleRefetch(calendar);
1376
+ } catch (e) {
1377
+ handleCreateError(e);
1378
+ } finally {
1379
+ try { calendar.unselect(); } catch (e) {}
1380
+ isDialogOpen = false;
1381
+ }
1382
+ return true;
1383
+ },
1384
+ };
1385
+ }
1386
+
1387
+ bootbox.dialog({
1388
+ title: 'Créer',
1389
+ message: '<div class="text-muted">Que veux-tu créer sur ces dates ?</div>',
1390
+ buttons,
1391
+ });
1446
1392
  }
1447
1393
 
1448
1394
  calendar = new FullCalendar.Calendar(el, {
@@ -1455,14 +1401,7 @@ function toDatetimeLocalValue(date) {
1455
1401
  headerToolbar: headerToolbar,
1456
1402
  // Keep titles short on mobile to avoid horizontal overflow
1457
1403
  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
- } : {},
1404
+ customButtons: {},
1466
1405
  // We display the time ourselves inside the title for "special" events,
1467
1406
  // to match reservation icons and avoid FullCalendar's fixed-width time column.
1468
1407
  displayEventTime: false,
@@ -1585,7 +1524,13 @@ function toDatetimeLocalValue(date) {
1585
1524
  } else if (p0.type === 'special' && p0.eid) {
1586
1525
  const details = await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(String(p0.eid))}`);
1587
1526
  p = Object.assign({}, p0, details, {
1588
- // keep backward compat with older field names used by templates below
1527
+ pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1528
+ pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1529
+ pickupLon: details.lon || details.pickupLon || p0.pickupLon,
1530
+ });
1531
+ } else if (p0.type === 'outing' && p0.oid) {
1532
+ const details = await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(String(p0.oid))}`);
1533
+ p = Object.assign({}, p0, details, {
1589
1534
  pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
1590
1535
  pickupLat: details.lat || details.pickupLat || p0.pickupLat,
1591
1536
  pickupLon: details.lon || details.pickupLon || p0.pickupLon,
@@ -1598,53 +1543,106 @@ function toDatetimeLocalValue(date) {
1598
1543
 
1599
1544
  try {
1600
1545
  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
- }
1546
+ const username = String(p.username || '').trim();
1547
+ const userLine = username
1548
+ ? `<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>`
1549
+ : '';
1550
+ const addr = String(p.pickupAddress || p.address || '').trim();
1551
+ const lat = Number(p.pickupLat || p.lat);
1552
+ const lon = Number(p.pickupLon || p.lon);
1553
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1554
+ const notes = String(p.notes || '').trim();
1555
+ const addrHtml = addr
1556
+ ? (hasCoords
1557
+ ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
1558
+ : `${escapeHtml(addr)}`)
1559
+ : '';
1560
+ const html = `
1561
+ <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1562
+ ${userLine}
1563
+ <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
1564
+ ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1565
+ ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1566
+ `;
1567
+ const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
1568
+ bootbox.dialog({
1569
+ title: 'Évènement',
1570
+ message: html,
1571
+ buttons: {
1572
+ close: { label: 'Fermer', className: 'btn-secondary' },
1573
+ ...(canDel ? {
1574
+ del: {
1575
+ label: 'Supprimer',
1576
+ className: 'btn-danger',
1577
+ callback: async () => {
1578
+ try {
1579
+ const eid = String(p.eid || ev.id).replace(/^special:/, '');
1580
+ await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' });
1581
+ showAlert('success', 'Évènement supprimé.');
1582
+ calendar.refetchEvents();
1583
+ } catch (e) {
1584
+ showAlert('error', 'Suppression impossible.');
1585
+ }
1586
+ },
1641
1587
  },
1642
- },
1643
- } : {}),
1644
- },
1645
- });
1646
- return;
1588
+ } : {}),
1589
+ },
1590
+ });
1591
+ return;
1592
+ }
1593
+
1594
+ if (p.type === 'outing') {
1595
+ const username = String(p.username || '').trim();
1596
+ const userLine = username
1597
+ ? `<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>`
1598
+ : '';
1599
+ const addr = String(p.address || p.pickupAddress || '').trim();
1600
+ const lat = Number(p.lat || p.pickupLat);
1601
+ const lon = Number(p.lon || p.pickupLon);
1602
+ const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
1603
+ const notes = String(p.notes || '').trim();
1604
+ const addrHtml = addr
1605
+ ? (hasCoords
1606
+ ? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
1607
+ : `${escapeHtml(addr)}`)
1608
+ : '';
1609
+ const html = `
1610
+ <div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
1611
+ ${userLine}
1612
+ <div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
1613
+ ${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
1614
+ ${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
1615
+ `;
1616
+ const canDel = !!(p.canDeleteOuting);
1617
+ bootbox.dialog({
1618
+ title: 'Prévision de sortie',
1619
+ message: html,
1620
+ buttons: {
1621
+ close: { label: 'Fermer', className: 'btn-secondary' },
1622
+ ...(canDel ? {
1623
+ del: {
1624
+ label: 'Annuler',
1625
+ className: 'btn-danger',
1626
+ callback: async () => {
1627
+ try {
1628
+ const oid = String(p.oid || ev.id).replace(/^outing:/, '');
1629
+ await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(oid)}`, { method: 'DELETE' });
1630
+ showAlert('success', 'Prévision annulée.');
1631
+ calendar.refetchEvents();
1632
+ } catch (e) {
1633
+ showAlert('error', 'Annulation impossible.');
1634
+ }
1635
+ },
1636
+ },
1637
+ } : {}),
1638
+ },
1639
+ });
1640
+ return;
1641
+ }
1642
+ } catch (e) {
1643
+ // ignore
1647
1644
  }
1645
+
1648
1646
  const rid = p.rid || ev.id;
1649
1647
  const status = p.status || '';
1650
1648
 
@@ -1956,9 +1954,6 @@ function toDatetimeLocalValue(date) {
1956
1954
  message: baseHtml,
1957
1955
  buttons,
1958
1956
  });
1959
- } finally {
1960
- isDialogOpen = false;
1961
- }
1962
1957
  },
1963
1958
  });
1964
1959
 
@@ -2090,7 +2085,7 @@ function toDatetimeLocalValue(date) {
2090
2085
  }
2091
2086
 
2092
2087
 
2093
- async function openFabDatePicker() {
2088
+ async function openFabDatePicker(onSelection) {
2094
2089
  if (!lockAction('fab-date-picker', 700)) return;
2095
2090
 
2096
2091
  // Cannot book past dates (mobile FAB). Same-day booking is allowed.
@@ -2148,6 +2143,12 @@ async function openFabDatePicker() {
2148
2143
  const endExcl = new Date(e);
2149
2144
  endExcl.setDate(endExcl.getDate() + 1);
2150
2145
 
2146
+ // If a callback is provided, delegate the next step (chooser).
2147
+ if (typeof onSelection === 'function') {
2148
+ try { onSelection({ start: s, end: endExcl, allDay: true }); } catch (e) {}
2149
+ return true;
2150
+ }
2151
+
2151
2152
  (async () => {
2152
2153
  try {
2153
2154
  if (isDialogOpen) return;
@@ -2174,8 +2175,6 @@ async function openFabDatePicker() {
2174
2175
  }
2175
2176
  } catch (err) {
2176
2177
  // ignore
2177
- } finally {
2178
- isDialogOpen = false;
2179
2178
  }
2180
2179
  })();
2181
2180
 
@@ -2311,6 +2310,33 @@ async function openFabDatePicker() {
2311
2310
  render();
2312
2311
  });
2313
2312
  }
2313
+
2314
+ // Swipe left/right to change month
2315
+ try {
2316
+ const root = document.getElementById('onekite-range-picker');
2317
+ if (root) {
2318
+ let x0 = null;
2319
+ root.addEventListener('touchstart', (ev) => {
2320
+ try { x0 = ev.touches && ev.touches[0] ? ev.touches[0].clientX : null; } catch (e) { x0 = null; }
2321
+ }, { passive: true });
2322
+ root.addEventListener('touchend', (ev) => {
2323
+ try {
2324
+ if (x0 === null) return;
2325
+ const x1 = ev.changedTouches && ev.changedTouches[0] ? ev.changedTouches[0].clientX : null;
2326
+ if (x1 === null) return;
2327
+ const dx = x1 - x0;
2328
+ if (Math.abs(dx) < 40) return;
2329
+ if (dx < 0) {
2330
+ state.cursor.setMonth(state.cursor.getMonth() + 1);
2331
+ } else {
2332
+ state.cursor.setMonth(state.cursor.getMonth() - 1);
2333
+ }
2334
+ render();
2335
+ } catch (e) {}
2336
+ finally { x0 = null; }
2337
+ }, { passive: true });
2338
+ }
2339
+ } catch (e) {}
2314
2340
  render();
2315
2341
  } catch (e) {}
2316
2342
  });
@@ -2342,7 +2368,7 @@ function parseYmdDate(ymdStr) {
2342
2368
  fabEl.setAttribute('aria-label', 'Nouvelle réservation');
2343
2369
  fabEl.innerHTML = '<i class="fa fa-plus"></i>';
2344
2370
 
2345
- fabHandler = () => openFabDatePicker();
2371
+ fabHandler = () => openFabDatePicker(handleCreateFromSelection);
2346
2372
  fabEl.addEventListener('click', fabHandler);
2347
2373
 
2348
2374
  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>