nodebb-plugin-onekite-calendar 2.0.42 → 2.0.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/admin.js +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 +296 -252
- 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
|
@@ -110,6 +110,11 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
110
110
|
// Current FullCalendar instance (for refresh after actions)
|
|
111
111
|
let currentCalendar = null;
|
|
112
112
|
|
|
113
|
+
// The creation chooser handler is created inside init() (it needs the
|
|
114
|
+
// calendar instance and capability flags). The mobile FAB is mounted outside
|
|
115
|
+
// init(), so we store the handler here.
|
|
116
|
+
let createFromSelectionHandler = null;
|
|
117
|
+
|
|
113
118
|
// Mobile FAB (mounted only on the calendar page)
|
|
114
119
|
let fabEl = null;
|
|
115
120
|
let fabHandler = null;
|
|
@@ -208,7 +213,8 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
208
213
|
return opts.join('');
|
|
209
214
|
}
|
|
210
215
|
|
|
211
|
-
async function openSpecialEventDialog(selectionInfo) {
|
|
216
|
+
async function openSpecialEventDialog(selectionInfo, opts) {
|
|
217
|
+
const kind = (opts && opts.kind) ? String(opts.kind) : 'special';
|
|
212
218
|
const start = selectionInfo.start;
|
|
213
219
|
// FullCalendar can omit `end` for certain interactions. Also, for all-day
|
|
214
220
|
// selections, `end` is exclusive (next day at 00:00). We normalise below
|
|
@@ -259,10 +265,13 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
259
265
|
|
|
260
266
|
const seStartTime = timeString(seStart);
|
|
261
267
|
const seEndTime = timeString(seEnd);
|
|
268
|
+
const defaultTitlePlaceholder = kind === 'outing' ? 'Prévision de sortie' : 'Ex: ...';
|
|
269
|
+
const defaultTitleValue = kind === 'outing' ? 'Prévision de sortie' : '';
|
|
270
|
+
|
|
262
271
|
const html = `
|
|
263
272
|
<div class="mb-3">
|
|
264
273
|
<label class="form-label">Titre</label>
|
|
265
|
-
<input type="text" class="form-control" id="onekite-se-title"
|
|
274
|
+
<input type="text" class="form-control" id="onekite-se-title" value="${escapeHtml(defaultTitleValue)}" placeholder="${escapeHtml(defaultTitlePlaceholder)}" />
|
|
266
275
|
</div>
|
|
267
276
|
<div class="row g-2">
|
|
268
277
|
<div class="col-12 col-md-6">
|
|
@@ -307,7 +316,7 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
307
316
|
return await new Promise((resolve) => {
|
|
308
317
|
let resolved = false;
|
|
309
318
|
const dialog = bootbox.dialog({
|
|
310
|
-
title: 'Créer un évènement',
|
|
319
|
+
title: kind === 'outing' ? 'Créer une prévision de sortie' : 'Créer un évènement',
|
|
311
320
|
message: html,
|
|
312
321
|
buttons: {
|
|
313
322
|
cancel: {
|
|
@@ -427,6 +436,18 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
|
|
|
427
436
|
});
|
|
428
437
|
}
|
|
429
438
|
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
async function openOutingDialog(selectionInfo) {
|
|
442
|
+
// Same UI as special events, but saved as "Sorties" (prévisions) and with
|
|
443
|
+
// a different title.
|
|
444
|
+
const payload = await openSpecialEventDialog(selectionInfo, { kind: 'outing' });
|
|
445
|
+
if (!payload) return null;
|
|
446
|
+
// Default title if empty
|
|
447
|
+
if (!payload.title) payload.title = 'Prévision de sortie';
|
|
448
|
+
return payload;
|
|
449
|
+
}
|
|
450
|
+
|
|
430
451
|
async function openMapViewer(title, address, lat, lon) {
|
|
431
452
|
const mapId = `onekite-map-view-${Date.now()}-${Math.floor(Math.random()*10000)}`;
|
|
432
453
|
const safeAddr = (address || '').trim();
|
|
@@ -1158,97 +1179,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1158
1179
|
const canCreateSpecial = !!caps.canCreateSpecial;
|
|
1159
1180
|
const canDeleteSpecial = !!caps.canDeleteSpecial;
|
|
1160
1181
|
|
|
1161
|
-
//
|
|
1162
|
-
// Persist the user's last choice so actions (create/approve/refuse/refetch) don't reset it.
|
|
1163
|
-
const modeStorageKey = (() => {
|
|
1164
|
-
try {
|
|
1165
|
-
const uid = (typeof app !== 'undefined' && app && app.user && (app.user.uid || app.user.uid === 0)) ? String(app.user.uid)
|
|
1166
|
-
: (window.ajaxify && window.ajaxify.data && (window.ajaxify.data.uid || window.ajaxify.data.uid === 0)) ? String(window.ajaxify.data.uid)
|
|
1167
|
-
: '0';
|
|
1168
|
-
return `onekiteCalendarMode:${uid}`;
|
|
1169
|
-
} catch (e) {
|
|
1170
|
-
return 'onekiteCalendarMode:0';
|
|
1171
|
-
}
|
|
1172
|
-
})();
|
|
1173
|
-
|
|
1174
|
-
function loadSavedMode() {
|
|
1175
|
-
try {
|
|
1176
|
-
const v = (window.localStorage && window.localStorage.getItem(modeStorageKey)) || '';
|
|
1177
|
-
return (v === 'special' || v === 'reservation') ? v : 'reservation';
|
|
1178
|
-
} catch (e) {
|
|
1179
|
-
return 'reservation';
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
function saveMode(v) {
|
|
1184
|
-
try {
|
|
1185
|
-
if (!window.localStorage) return;
|
|
1186
|
-
window.localStorage.setItem(modeStorageKey, v);
|
|
1187
|
-
} catch (e) {
|
|
1188
|
-
// ignore
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
let mode = loadSavedMode();
|
|
1193
|
-
// Avoid showing the "mode évènement" hint multiple times (desktop + mobile handlers)
|
|
1194
|
-
let lastModeHintAt = 0;
|
|
1195
|
-
|
|
1196
|
-
function refreshDesktopModeButton() {
|
|
1197
|
-
try {
|
|
1198
|
-
const btn = document.querySelector('#onekite-calendar .fc-newSpecial-button');
|
|
1199
|
-
if (!btn) return;
|
|
1200
|
-
|
|
1201
|
-
const isSpecial = mode === 'special';
|
|
1202
|
-
const label = isSpecial ? 'Évènement ✓' : 'Évènement';
|
|
1203
|
-
|
|
1204
|
-
// Ensure a single canonical .fc-button-text (prevents "ÉvènementÉvènement" after rerenders)
|
|
1205
|
-
let span = btn.querySelector('.fc-button-text');
|
|
1206
|
-
if (!span) {
|
|
1207
|
-
span = document.createElement('span');
|
|
1208
|
-
span.className = 'fc-button-text';
|
|
1209
|
-
// Remove stray text nodes before inserting span
|
|
1210
|
-
[...btn.childNodes].forEach((n) => {
|
|
1211
|
-
if (n && n.nodeType === Node.TEXT_NODE) n.remove();
|
|
1212
|
-
});
|
|
1213
|
-
btn.appendChild(span);
|
|
1214
|
-
} else {
|
|
1215
|
-
// Remove any stray text nodes beside the span
|
|
1216
|
-
[...btn.childNodes].forEach((n) => {
|
|
1217
|
-
if (n && n.nodeType === Node.TEXT_NODE && n.textContent.trim()) n.remove();
|
|
1218
|
-
});
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
span.textContent = label;
|
|
1222
|
-
btn.classList.toggle('onekite-active', isSpecial);
|
|
1223
|
-
} catch (e) {}
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
function setMode(next, opts) {
|
|
1227
|
-
if (next !== 'reservation' && next !== 'special') return;
|
|
1228
|
-
mode = next;
|
|
1229
|
-
saveMode(mode);
|
|
1230
|
-
|
|
1231
|
-
// Reset any pending selection/dialog state when switching modes
|
|
1232
|
-
try { if (mode === 'reservation') { calendar.unselect(); isDialogOpen = false; } } catch (e) {}
|
|
1233
|
-
|
|
1234
|
-
const silent = !!(opts && opts.silent);
|
|
1235
|
-
if (!silent && mode === 'special') {
|
|
1236
|
-
const now = Date.now();
|
|
1237
|
-
if (now - lastModeHintAt > 1200) {
|
|
1238
|
-
lastModeHintAt = now;
|
|
1239
|
-
showAlert('success', 'Mode évènement : sélectionnez une date ou une plage');
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
refreshDesktopModeButton();
|
|
1243
|
-
try {
|
|
1244
|
-
const mb = document.querySelector('#onekite-mobile-controls .onekite-mode-btn');
|
|
1245
|
-
if (mb) {
|
|
1246
|
-
const isSpecial = mode === 'special';
|
|
1247
|
-
mb.textContent = isSpecial ? 'Mode évènement ✓' : 'Mode évènement';
|
|
1248
|
-
mb.classList.toggle('onekite-active', isSpecial);
|
|
1249
|
-
}
|
|
1250
|
-
} catch (e) {}
|
|
1251
|
-
}
|
|
1182
|
+
// Creation chooser: Location / Prévision de sortie / Évènement (si autorisé).
|
|
1252
1183
|
|
|
1253
1184
|
// Inject lightweight responsive CSS once.
|
|
1254
1185
|
try {
|
|
@@ -1315,100 +1246,29 @@ function toDatetimeLocalValue(date) {
|
|
|
1315
1246
|
left: 'prev,next today',
|
|
1316
1247
|
center: 'title',
|
|
1317
1248
|
// Only month + week (no day view)
|
|
1318
|
-
right:
|
|
1249
|
+
right: 'dayGridMonth,timeGridWeek',
|
|
1319
1250
|
};
|
|
1320
1251
|
|
|
1321
1252
|
let calendar;
|
|
1322
1253
|
|
|
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`).
|
|
1254
|
+
// Unified handler for creation actions via a chooser modal.
|
|
1327
1255
|
async function handleCreateFromSelection(info) {
|
|
1328
|
-
if (isDialogOpen)
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
//
|
|
1332
|
-
if (!lockAction('create', 900)) {
|
|
1333
|
-
return;
|
|
1334
|
-
}
|
|
1335
|
-
isDialogOpen = true;
|
|
1256
|
+
if (isDialogOpen) return;
|
|
1257
|
+
if (!lockAction('create', 900)) return;
|
|
1258
|
+
|
|
1259
|
+
// Business rule: nothing can be created in the past.
|
|
1336
1260
|
try {
|
|
1337
|
-
|
|
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;
|
|
1261
|
+
const startDateCheck = toLocalYmd(info.start);
|
|
1262
|
+
const todayCheck = toLocalYmd(new Date());
|
|
1263
|
+
if (startDateCheck < todayCheck) {
|
|
1264
|
+
lastDateRuleToastAt = Date.now();
|
|
1265
|
+
showAlert('error', "Impossible de créer pour une date passée.");
|
|
1266
|
+
try { calendar.unselect(); } catch (e) {}
|
|
1358
1267
|
return;
|
|
1359
1268
|
}
|
|
1269
|
+
} catch (e) {}
|
|
1360
1270
|
|
|
1361
|
-
|
|
1362
|
-
// (We validate again on the server, but this gives immediate feedback.)
|
|
1363
|
-
try {
|
|
1364
|
-
const startDateCheck = toLocalYmd(info.start);
|
|
1365
|
-
const todayCheck = toLocalYmd(new Date());
|
|
1366
|
-
if (startDateCheck < todayCheck) {
|
|
1367
|
-
lastDateRuleToastAt = Date.now();
|
|
1368
|
-
showAlert('error', "Impossible de réserver pour une date passée.");
|
|
1369
|
-
calendar.unselect();
|
|
1370
|
-
isDialogOpen = false;
|
|
1371
|
-
return;
|
|
1372
|
-
}
|
|
1373
|
-
} catch (e) {
|
|
1374
|
-
// ignore
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
if (!items || !items.length) {
|
|
1378
|
-
showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
|
|
1379
|
-
calendar.unselect();
|
|
1380
|
-
isDialogOpen = false;
|
|
1381
|
-
return;
|
|
1382
|
-
}
|
|
1383
|
-
const chosen = await openReservationDialog(info, items);
|
|
1384
|
-
if (!chosen || !chosen.itemIds || !chosen.itemIds.length) {
|
|
1385
|
-
calendar.unselect();
|
|
1386
|
-
isDialogOpen = false;
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
// Send date strings (no hours) so reservations are day-based.
|
|
1390
|
-
const startDate = toLocalYmd(info.start);
|
|
1391
|
-
// NOTE: FullCalendar's `info.end` reflects the original selection.
|
|
1392
|
-
// If the user used "Durée rapide", the effective end date is held
|
|
1393
|
-
// inside the dialog (returned as `chosen.endDate`).
|
|
1394
|
-
const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
|
|
1395
|
-
const resp = await requestReservation({
|
|
1396
|
-
start: startDate,
|
|
1397
|
-
end: endDate,
|
|
1398
|
-
itemIds: chosen.itemIds,
|
|
1399
|
-
itemNames: chosen.itemNames,
|
|
1400
|
-
total: chosen.total,
|
|
1401
|
-
});
|
|
1402
|
-
if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
|
|
1403
|
-
showAlert('success', 'Réservation confirmée.');
|
|
1404
|
-
} else {
|
|
1405
|
-
showAlert('success', 'Demande envoyée (en attente de validation).');
|
|
1406
|
-
}
|
|
1407
|
-
invalidateEventsCache();
|
|
1408
|
-
scheduleRefetch(calendar);
|
|
1409
|
-
calendar.unselect();
|
|
1410
|
-
isDialogOpen = false;
|
|
1411
|
-
} catch (e) {
|
|
1271
|
+
function handleCreateError(e) {
|
|
1412
1272
|
const code = String((e && (e.status || e.message)) || '');
|
|
1413
1273
|
const payload = e && e.payload ? e.payload : null;
|
|
1414
1274
|
|
|
@@ -1416,35 +1276,134 @@ function toDatetimeLocalValue(date) {
|
|
|
1416
1276
|
const msg = payload && (payload.message || payload.error || payload.msg) ? String(payload.message || payload.error || payload.msg) : '';
|
|
1417
1277
|
const c = payload && payload.code ? String(payload.code) : '';
|
|
1418
1278
|
if (c === 'NOT_MEMBER' || /adh(é|e)rent/i.test(msg) || /membership/i.test(msg)) {
|
|
1419
|
-
showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer
|
|
1279
|
+
showAlert('error', msg || 'Vous devez être adhérent pour pouvoir effectuer cette action.');
|
|
1420
1280
|
} else {
|
|
1421
|
-
showAlert('error', msg || '
|
|
1281
|
+
showAlert('error', msg || 'Action impossible : droits insuffisants (groupe).');
|
|
1422
1282
|
}
|
|
1423
1283
|
} else if (code === '409') {
|
|
1424
|
-
showAlert('error', 'Impossible :
|
|
1284
|
+
showAlert('error', 'Impossible : conflit de réservation sur cette période.');
|
|
1425
1285
|
} else if (code === '400' && payload && (payload.error === 'date-too-soon' || payload.code === 'date-too-soon')) {
|
|
1426
|
-
// If we already showed the client-side toast a moment ago, avoid a duplicate.
|
|
1427
1286
|
if (!lastDateRuleToastAt || (Date.now() - lastDateRuleToastAt) > 1500) {
|
|
1428
|
-
showAlert('error', String(payload.message || "Impossible de
|
|
1287
|
+
showAlert('error', String(payload.message || "Impossible de créer pour une date passée."));
|
|
1429
1288
|
}
|
|
1430
1289
|
} else {
|
|
1431
1290
|
const msgRaw = payload && (payload.message || payload.error || payload.msg)
|
|
1432
1291
|
? String(payload.message || payload.error || payload.msg)
|
|
1433
1292
|
: '';
|
|
1434
|
-
|
|
1435
|
-
// NodeBB can return a plain "not-logged-in" string when the user is not authenticated.
|
|
1436
|
-
// We want a user-friendly message consistent with the membership requirement.
|
|
1437
1293
|
const msg = (/\bnot-logged-in\b/i.test(msgRaw) || /\[\[error:not-logged-in\]\]/i.test(msgRaw))
|
|
1438
1294
|
? 'Vous devez être adhérent Onekite'
|
|
1439
1295
|
: msgRaw;
|
|
1440
|
-
|
|
1441
|
-
showAlert('error', msg || ((e && (e.status === 401 || e.status === 403)) ? 'Vous devez être adhérent Onekite' : 'Erreur lors de la création de la demande.'));
|
|
1296
|
+
showAlert('error', msg || 'Erreur lors de la création.');
|
|
1442
1297
|
}
|
|
1443
|
-
calendar.unselect();
|
|
1444
|
-
isDialogOpen = false;
|
|
1445
1298
|
}
|
|
1299
|
+
|
|
1300
|
+
const buttons = {
|
|
1301
|
+
close: { label: 'Annuler', className: 'btn-secondary' },
|
|
1302
|
+
location: {
|
|
1303
|
+
label: 'Location',
|
|
1304
|
+
className: 'btn-primary',
|
|
1305
|
+
callback: async () => {
|
|
1306
|
+
try {
|
|
1307
|
+
isDialogOpen = true;
|
|
1308
|
+
if (!items || !items.length) {
|
|
1309
|
+
showAlert('error', 'Aucun matériel disponible (items HelloAsso non chargés).');
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
const chosen = await openReservationDialog(info, items);
|
|
1313
|
+
if (!chosen || !chosen.itemIds || !chosen.itemIds.length) return;
|
|
1314
|
+
const startDate = toLocalYmd(info.start);
|
|
1315
|
+
const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
|
|
1316
|
+
const resp = await requestReservation({
|
|
1317
|
+
start: startDate,
|
|
1318
|
+
end: endDate,
|
|
1319
|
+
itemIds: chosen.itemIds,
|
|
1320
|
+
itemNames: chosen.itemNames,
|
|
1321
|
+
total: chosen.total,
|
|
1322
|
+
});
|
|
1323
|
+
if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
|
|
1324
|
+
showAlert('success', 'Réservation confirmée.');
|
|
1325
|
+
} else {
|
|
1326
|
+
showAlert('success', 'Demande envoyée (en attente de validation).');
|
|
1327
|
+
}
|
|
1328
|
+
invalidateEventsCache();
|
|
1329
|
+
scheduleRefetch(calendar);
|
|
1330
|
+
} catch (e) {
|
|
1331
|
+
handleCreateError(e);
|
|
1332
|
+
} finally {
|
|
1333
|
+
try { calendar.unselect(); } catch (e) {}
|
|
1334
|
+
isDialogOpen = false;
|
|
1335
|
+
}
|
|
1336
|
+
return true;
|
|
1337
|
+
},
|
|
1338
|
+
},
|
|
1339
|
+
outing: {
|
|
1340
|
+
label: 'Prévision de sortie',
|
|
1341
|
+
className: 'btn-outline-primary',
|
|
1342
|
+
callback: async () => {
|
|
1343
|
+
try {
|
|
1344
|
+
isDialogOpen = true;
|
|
1345
|
+
const payload = await openOutingDialog(info);
|
|
1346
|
+
if (!payload) return;
|
|
1347
|
+
await fetchJson('/api/v3/plugins/calendar-onekite/outings', {
|
|
1348
|
+
method: 'POST',
|
|
1349
|
+
body: JSON.stringify(payload),
|
|
1350
|
+
});
|
|
1351
|
+
showAlert('success', 'Prévision de sortie créée.');
|
|
1352
|
+
invalidateEventsCache();
|
|
1353
|
+
scheduleRefetch(calendar);
|
|
1354
|
+
} catch (e) {
|
|
1355
|
+
handleCreateError(e);
|
|
1356
|
+
} finally {
|
|
1357
|
+
try { calendar.unselect(); } catch (e) {}
|
|
1358
|
+
isDialogOpen = false;
|
|
1359
|
+
}
|
|
1360
|
+
return true;
|
|
1361
|
+
},
|
|
1362
|
+
},
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
if (canCreateSpecial) {
|
|
1366
|
+
buttons.special = {
|
|
1367
|
+
label: 'Évènement',
|
|
1368
|
+
className: 'btn-outline-secondary',
|
|
1369
|
+
callback: async () => {
|
|
1370
|
+
try {
|
|
1371
|
+
isDialogOpen = true;
|
|
1372
|
+
const payload = await openSpecialEventDialog(info);
|
|
1373
|
+
if (!payload) return;
|
|
1374
|
+
await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
|
|
1375
|
+
method: 'POST',
|
|
1376
|
+
body: JSON.stringify(payload),
|
|
1377
|
+
}).catch(async () => {
|
|
1378
|
+
return await fetchJson('/api/v3/plugins/calendar-onekite/special-events', {
|
|
1379
|
+
method: 'POST',
|
|
1380
|
+
body: JSON.stringify(payload),
|
|
1381
|
+
});
|
|
1382
|
+
});
|
|
1383
|
+
showAlert('success', 'Évènement créé.');
|
|
1384
|
+
invalidateEventsCache();
|
|
1385
|
+
scheduleRefetch(calendar);
|
|
1386
|
+
} catch (e) {
|
|
1387
|
+
handleCreateError(e);
|
|
1388
|
+
} finally {
|
|
1389
|
+
try { calendar.unselect(); } catch (e) {}
|
|
1390
|
+
isDialogOpen = false;
|
|
1391
|
+
}
|
|
1392
|
+
return true;
|
|
1393
|
+
},
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
bootbox.dialog({
|
|
1398
|
+
title: 'Créer',
|
|
1399
|
+
message: '<div class="text-muted">Que veux-tu créer sur ces dates ?</div>',
|
|
1400
|
+
buttons,
|
|
1401
|
+
});
|
|
1446
1402
|
}
|
|
1447
1403
|
|
|
1404
|
+
// Expose to the mobile FAB (mounted outside init).
|
|
1405
|
+
createFromSelectionHandler = handleCreateFromSelection;
|
|
1406
|
+
|
|
1448
1407
|
calendar = new FullCalendar.Calendar(el, {
|
|
1449
1408
|
initialView: 'dayGridMonth',
|
|
1450
1409
|
height: 'auto',
|
|
@@ -1455,14 +1414,7 @@ function toDatetimeLocalValue(date) {
|
|
|
1455
1414
|
headerToolbar: headerToolbar,
|
|
1456
1415
|
// Keep titles short on mobile to avoid horizontal overflow
|
|
1457
1416
|
titleFormat: isMobileNow() ? { year: 'numeric', month: 'short' } : undefined,
|
|
1458
|
-
customButtons:
|
|
1459
|
-
newSpecial: {
|
|
1460
|
-
text: 'Évènement',
|
|
1461
|
-
click: () => {
|
|
1462
|
-
setMode((mode === 'special') ? 'reservation' : 'special');
|
|
1463
|
-
},
|
|
1464
|
-
},
|
|
1465
|
-
} : {},
|
|
1417
|
+
customButtons: {},
|
|
1466
1418
|
// We display the time ourselves inside the title for "special" events,
|
|
1467
1419
|
// to match reservation icons and avoid FullCalendar's fixed-width time column.
|
|
1468
1420
|
displayEventTime: false,
|
|
@@ -1585,7 +1537,13 @@ function toDatetimeLocalValue(date) {
|
|
|
1585
1537
|
} else if (p0.type === 'special' && p0.eid) {
|
|
1586
1538
|
const details = await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(String(p0.eid))}`);
|
|
1587
1539
|
p = Object.assign({}, p0, details, {
|
|
1588
|
-
|
|
1540
|
+
pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
|
|
1541
|
+
pickupLat: details.lat || details.pickupLat || p0.pickupLat,
|
|
1542
|
+
pickupLon: details.lon || details.pickupLon || p0.pickupLon,
|
|
1543
|
+
});
|
|
1544
|
+
} else if (p0.type === 'outing' && p0.oid) {
|
|
1545
|
+
const details = await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(String(p0.oid))}`);
|
|
1546
|
+
p = Object.assign({}, p0, details, {
|
|
1589
1547
|
pickupAddress: details.address || details.pickupAddress || p0.pickupAddress,
|
|
1590
1548
|
pickupLat: details.lat || details.pickupLat || p0.pickupLat,
|
|
1591
1549
|
pickupLon: details.lon || details.pickupLon || p0.pickupLon,
|
|
@@ -1598,53 +1556,106 @@ function toDatetimeLocalValue(date) {
|
|
|
1598
1556
|
|
|
1599
1557
|
try {
|
|
1600
1558
|
if (p.type === 'special') {
|
|
1601
|
-
|
|
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
|
-
|
|
1559
|
+
const username = String(p.username || '').trim();
|
|
1560
|
+
const userLine = username
|
|
1561
|
+
? `<div class="mb-2"><strong>Créé par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
|
|
1562
|
+
: '';
|
|
1563
|
+
const addr = String(p.pickupAddress || p.address || '').trim();
|
|
1564
|
+
const lat = Number(p.pickupLat || p.lat);
|
|
1565
|
+
const lon = Number(p.pickupLon || p.lon);
|
|
1566
|
+
const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
|
|
1567
|
+
const notes = String(p.notes || '').trim();
|
|
1568
|
+
const addrHtml = addr
|
|
1569
|
+
? (hasCoords
|
|
1570
|
+
? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
|
|
1571
|
+
: `${escapeHtml(addr)}`)
|
|
1572
|
+
: '';
|
|
1573
|
+
const html = `
|
|
1574
|
+
<div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
|
|
1575
|
+
${userLine}
|
|
1576
|
+
<div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
|
|
1577
|
+
${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
|
|
1578
|
+
${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
|
|
1579
|
+
`;
|
|
1580
|
+
const canDel = !!(p.canDeleteSpecial || canDeleteSpecial);
|
|
1581
|
+
bootbox.dialog({
|
|
1582
|
+
title: 'Évènement',
|
|
1583
|
+
message: html,
|
|
1584
|
+
buttons: {
|
|
1585
|
+
close: { label: 'Fermer', className: 'btn-secondary' },
|
|
1586
|
+
...(canDel ? {
|
|
1587
|
+
del: {
|
|
1588
|
+
label: 'Supprimer',
|
|
1589
|
+
className: 'btn-danger',
|
|
1590
|
+
callback: async () => {
|
|
1591
|
+
try {
|
|
1592
|
+
const eid = String(p.eid || ev.id).replace(/^special:/, '');
|
|
1593
|
+
await fetchJson(`/api/v3/plugins/calendar-onekite/special-events/${encodeURIComponent(eid)}`, { method: 'DELETE' });
|
|
1594
|
+
showAlert('success', 'Évènement supprimé.');
|
|
1595
|
+
calendar.refetchEvents();
|
|
1596
|
+
} catch (e) {
|
|
1597
|
+
showAlert('error', 'Suppression impossible.');
|
|
1598
|
+
}
|
|
1599
|
+
},
|
|
1641
1600
|
},
|
|
1642
|
-
},
|
|
1643
|
-
}
|
|
1644
|
-
}
|
|
1645
|
-
|
|
1646
|
-
|
|
1601
|
+
} : {}),
|
|
1602
|
+
},
|
|
1603
|
+
});
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (p.type === 'outing') {
|
|
1608
|
+
const username = String(p.username || '').trim();
|
|
1609
|
+
const userLine = username
|
|
1610
|
+
? `<div class="mb-2"><strong>Créé par</strong><br><a class="onekite-user-link" href="${window.location.origin}/user/${encodeURIComponent(username)}">${escapeHtml(username)}</a></div>`
|
|
1611
|
+
: '';
|
|
1612
|
+
const addr = String(p.address || p.pickupAddress || '').trim();
|
|
1613
|
+
const lat = Number(p.lat || p.pickupLat);
|
|
1614
|
+
const lon = Number(p.lon || p.pickupLon);
|
|
1615
|
+
const hasCoords = Number.isFinite(lat) && Number.isFinite(lon);
|
|
1616
|
+
const notes = String(p.notes || '').trim();
|
|
1617
|
+
const addrHtml = addr
|
|
1618
|
+
? (hasCoords
|
|
1619
|
+
? `<a href="#" class="onekite-map-link" data-address="${escapeHtml(addr)}" data-lat="${escapeHtml(String(lat))}" data-lon="${escapeHtml(String(lon))}">${escapeHtml(addr)}</a>`
|
|
1620
|
+
: `${escapeHtml(addr)}`)
|
|
1621
|
+
: '';
|
|
1622
|
+
const html = `
|
|
1623
|
+
<div class="mb-2"><strong>Titre</strong><br>${escapeHtml(p.title || ev.title || '')}</div>
|
|
1624
|
+
${userLine}
|
|
1625
|
+
<div class="mb-2"><strong>Période</strong><br>${escapeHtml(formatDtWithTime(ev.start))} → ${escapeHtml(formatDtWithTime(ev.end))}</div>
|
|
1626
|
+
${addr ? `<div class="mb-2"><strong>Adresse</strong><br>${addrHtml}</div>` : ''}
|
|
1627
|
+
${notes ? `<div class="mb-2"><strong>Notes</strong><br>${escapeHtml(notes).replace(/\n/g,'<br>')}</div>` : ''}
|
|
1628
|
+
`;
|
|
1629
|
+
const canDel = !!(p.canDeleteOuting);
|
|
1630
|
+
bootbox.dialog({
|
|
1631
|
+
title: 'Prévision de sortie',
|
|
1632
|
+
message: html,
|
|
1633
|
+
buttons: {
|
|
1634
|
+
close: { label: 'Fermer', className: 'btn-secondary' },
|
|
1635
|
+
...(canDel ? {
|
|
1636
|
+
del: {
|
|
1637
|
+
label: 'Annuler',
|
|
1638
|
+
className: 'btn-danger',
|
|
1639
|
+
callback: async () => {
|
|
1640
|
+
try {
|
|
1641
|
+
const oid = String(p.oid || ev.id).replace(/^outing:/, '');
|
|
1642
|
+
await fetchJson(`/api/v3/plugins/calendar-onekite/outings/${encodeURIComponent(oid)}`, { method: 'DELETE' });
|
|
1643
|
+
showAlert('success', 'Prévision annulée.');
|
|
1644
|
+
calendar.refetchEvents();
|
|
1645
|
+
} catch (e) {
|
|
1646
|
+
showAlert('error', 'Annulation impossible.');
|
|
1647
|
+
}
|
|
1648
|
+
},
|
|
1649
|
+
},
|
|
1650
|
+
} : {}),
|
|
1651
|
+
},
|
|
1652
|
+
});
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
} catch (e) {
|
|
1656
|
+
// ignore
|
|
1647
1657
|
}
|
|
1658
|
+
|
|
1648
1659
|
const rid = p.rid || ev.id;
|
|
1649
1660
|
const status = p.status || '';
|
|
1650
1661
|
|
|
@@ -1956,9 +1967,6 @@ function toDatetimeLocalValue(date) {
|
|
|
1956
1967
|
message: baseHtml,
|
|
1957
1968
|
buttons,
|
|
1958
1969
|
});
|
|
1959
|
-
} finally {
|
|
1960
|
-
isDialogOpen = false;
|
|
1961
|
-
}
|
|
1962
1970
|
},
|
|
1963
1971
|
});
|
|
1964
1972
|
|
|
@@ -2090,7 +2098,7 @@ function toDatetimeLocalValue(date) {
|
|
|
2090
2098
|
}
|
|
2091
2099
|
|
|
2092
2100
|
|
|
2093
|
-
async function openFabDatePicker() {
|
|
2101
|
+
async function openFabDatePicker(onSelection) {
|
|
2094
2102
|
if (!lockAction('fab-date-picker', 700)) return;
|
|
2095
2103
|
|
|
2096
2104
|
// Cannot book past dates (mobile FAB). Same-day booking is allowed.
|
|
@@ -2148,6 +2156,12 @@ async function openFabDatePicker() {
|
|
|
2148
2156
|
const endExcl = new Date(e);
|
|
2149
2157
|
endExcl.setDate(endExcl.getDate() + 1);
|
|
2150
2158
|
|
|
2159
|
+
// If a callback is provided, delegate the next step (chooser).
|
|
2160
|
+
if (typeof onSelection === 'function') {
|
|
2161
|
+
try { onSelection({ start: s, end: endExcl, allDay: true }); } catch (e) {}
|
|
2162
|
+
return true;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2151
2165
|
(async () => {
|
|
2152
2166
|
try {
|
|
2153
2167
|
if (isDialogOpen) return;
|
|
@@ -2174,8 +2188,6 @@ async function openFabDatePicker() {
|
|
|
2174
2188
|
}
|
|
2175
2189
|
} catch (err) {
|
|
2176
2190
|
// ignore
|
|
2177
|
-
} finally {
|
|
2178
|
-
isDialogOpen = false;
|
|
2179
2191
|
}
|
|
2180
2192
|
})();
|
|
2181
2193
|
|
|
@@ -2311,6 +2323,33 @@ async function openFabDatePicker() {
|
|
|
2311
2323
|
render();
|
|
2312
2324
|
});
|
|
2313
2325
|
}
|
|
2326
|
+
|
|
2327
|
+
// Swipe left/right to change month
|
|
2328
|
+
try {
|
|
2329
|
+
const root = document.getElementById('onekite-range-picker');
|
|
2330
|
+
if (root) {
|
|
2331
|
+
let x0 = null;
|
|
2332
|
+
root.addEventListener('touchstart', (ev) => {
|
|
2333
|
+
try { x0 = ev.touches && ev.touches[0] ? ev.touches[0].clientX : null; } catch (e) { x0 = null; }
|
|
2334
|
+
}, { passive: true });
|
|
2335
|
+
root.addEventListener('touchend', (ev) => {
|
|
2336
|
+
try {
|
|
2337
|
+
if (x0 === null) return;
|
|
2338
|
+
const x1 = ev.changedTouches && ev.changedTouches[0] ? ev.changedTouches[0].clientX : null;
|
|
2339
|
+
if (x1 === null) return;
|
|
2340
|
+
const dx = x1 - x0;
|
|
2341
|
+
if (Math.abs(dx) < 40) return;
|
|
2342
|
+
if (dx < 0) {
|
|
2343
|
+
state.cursor.setMonth(state.cursor.getMonth() + 1);
|
|
2344
|
+
} else {
|
|
2345
|
+
state.cursor.setMonth(state.cursor.getMonth() - 1);
|
|
2346
|
+
}
|
|
2347
|
+
render();
|
|
2348
|
+
} catch (e) {}
|
|
2349
|
+
finally { x0 = null; }
|
|
2350
|
+
}, { passive: true });
|
|
2351
|
+
}
|
|
2352
|
+
} catch (e) {}
|
|
2314
2353
|
render();
|
|
2315
2354
|
} catch (e) {}
|
|
2316
2355
|
});
|
|
@@ -2342,7 +2381,12 @@ function parseYmdDate(ymdStr) {
|
|
|
2342
2381
|
fabEl.setAttribute('aria-label', 'Nouvelle réservation');
|
|
2343
2382
|
fabEl.innerHTML = '<i class="fa fa-plus"></i>';
|
|
2344
2383
|
|
|
2345
|
-
fabHandler = () =>
|
|
2384
|
+
fabHandler = () => {
|
|
2385
|
+
// init() sets createFromSelectionHandler. If the calendar has not
|
|
2386
|
+
// finished initialising, do nothing.
|
|
2387
|
+
if (typeof createFromSelectionHandler !== 'function') return;
|
|
2388
|
+
openFabDatePicker(createFromSelectionHandler);
|
|
2389
|
+
};
|
|
2346
2390
|
fabEl.addEventListener('click', fabHandler);
|
|
2347
2391
|
|
|
2348
2392
|
document.body.appendChild(fabEl);
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
<li class="nav-item" role="presentation">
|
|
12
12
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#onekite-tab-events" type="button" role="tab">Évènements</button>
|
|
13
13
|
</li>
|
|
14
|
+
<li class="nav-item" role="presentation">
|
|
15
|
+
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#onekite-tab-outings" type="button" role="tab">Sorties</button>
|
|
16
|
+
</li>
|
|
14
17
|
<li class="nav-item" role="presentation">
|
|
15
18
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#onekite-tab-pending" type="button" role="tab">Demandes en attente</button>
|
|
16
19
|
</li>
|
|
@@ -80,6 +83,18 @@
|
|
|
80
83
|
<div class="form-text">Si vide, aucune notification Discord n'est envoyée.</div>
|
|
81
84
|
</div>
|
|
82
85
|
|
|
86
|
+
<div class="mb-3">
|
|
87
|
+
<label class="form-label">Webhook URL — Évènements</label>
|
|
88
|
+
<input class="form-control" name="discordWebhookUrlEvents" placeholder="https://discord.com/api/webhooks/...">
|
|
89
|
+
<div class="form-text">Canal Discord dédié aux notifications d'évènements (création / annulation).</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div class="mb-3">
|
|
93
|
+
<label class="form-label">Webhook URL — Sorties</label>
|
|
94
|
+
<input class="form-control" name="discordWebhookUrlOutings" placeholder="https://discord.com/api/webhooks/...">
|
|
95
|
+
<div class="form-text">Canal Discord dédié aux notifications de sorties (création / annulation).</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
83
98
|
<div class="mb-3">
|
|
84
99
|
<label class="form-label">Envoyer une notification à la demande</label>
|
|
85
100
|
<select class="form-select" name="discordNotifyOnRequest">
|
|
@@ -177,6 +192,20 @@
|
|
|
177
192
|
<div class="form-text mt-2">Supprime définitivement tous les évènements dont la date de début est dans l'année sélectionnée.</div>
|
|
178
193
|
</div>
|
|
179
194
|
|
|
195
|
+
<div class="tab-pane fade" id="onekite-tab-outings" role="tabpanel">
|
|
196
|
+
<h4>Sorties (prévisions)</h4>
|
|
197
|
+
<div class="form-text mb-3">Prévisions de sortie (autre couleur) créées par les utilisateurs autorisés à faire une demande de location. Les utilisateurs peuvent annuler leurs propres prévisions. Les droits sont ceux des locations.</div>
|
|
198
|
+
|
|
199
|
+
<hr class="my-4" />
|
|
200
|
+
<h4>Purge des sorties</h4>
|
|
201
|
+
<div class="d-flex gap-2 align-items-center">
|
|
202
|
+
<input class="form-control" style="max-width: 160px;" id="onekite-out-purge-year" placeholder="YYYY">
|
|
203
|
+
<button type="button" class="btn btn-outline-danger" id="onekite-out-purge">Purger</button>
|
|
204
|
+
</div>
|
|
205
|
+
<div class="form-text mt-2">Supprime définitivement toutes les sorties dont la date de début est dans l'année sélectionnée.</div>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
|
|
180
209
|
<div class="tab-pane fade" id="onekite-tab-pending" role="tabpanel">
|
|
181
210
|
<h4>Demandes en attente</h4>
|
|
182
211
|
<div id="onekite-pending" class="list-group"></div>
|