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 +18 -0
- package/lib/api.js +198 -0
- package/lib/db.js +36 -0
- package/lib/discord.js +85 -0
- package/library.js +7 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/public/admin.js +36 -0
- package/public/client.js +275 -249
- package/templates/admin/plugins/calendar-onekite.tpl +29 -0
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
package/plugin.json
CHANGED
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
|
-
//
|
|
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:
|
|
1239
|
+
right: 'dayGridMonth,timeGridWeek',
|
|
1319
1240
|
};
|
|
1320
1241
|
|
|
1321
1242
|
let calendar;
|
|
1322
1243
|
|
|
1323
|
-
// Unified handler for creation actions
|
|
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
|
-
|
|
1330
|
-
|
|
1331
|
-
//
|
|
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
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
-
|
|
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
|
|
1269
|
+
showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer cette action.');
|
|
1420
1270
|
} else {
|
|
1421
|
-
showAlert('error', msg || '
|
|
1271
|
+
showAlert('error', msg || 'Action impossible : droits insuffisants (groupe).');
|
|
1422
1272
|
}
|
|
1423
1273
|
} else if (code === '409') {
|
|
1424
|
-
showAlert('error', 'Impossible :
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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
|
-
|
|
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>
|