nodebb-plugin-onekite-calendar 2.0.11 → 2.0.13
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/CHANGELOG.md +29 -0
- package/lib/admin.js +21 -9
- package/lib/api.js +235 -4
- package/lib/db.js +114 -0
- package/lib/helloassoWebhook.js +28 -0
- package/library.js +7 -0
- package/package.json +1 -1
- package/pkg/package/CHANGELOG.md +106 -0
- package/pkg/package/lib/admin.js +554 -0
- package/pkg/package/lib/api.js +1458 -0
- package/pkg/package/lib/controllers.js +11 -0
- package/pkg/package/lib/db.js +224 -0
- package/pkg/package/lib/discord.js +190 -0
- package/pkg/package/lib/helloasso.js +352 -0
- package/pkg/package/lib/helloassoWebhook.js +389 -0
- package/pkg/package/lib/scheduler.js +201 -0
- package/pkg/package/lib/widgets.js +460 -0
- package/pkg/package/library.js +164 -0
- package/pkg/package/package.json +14 -0
- package/pkg/package/plugin.json +43 -0
- package/pkg/package/public/admin.js +1477 -0
- package/pkg/package/public/client.js +2228 -0
- package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -0
- package/pkg/package/templates/calendar-onekite.tpl +51 -0
- package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
- package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
- package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
- package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
- package/plugin.json +1 -1
- package/public/admin.js +205 -4
- package/public/client.js +238 -7
- package/templates/admin/plugins/calendar-onekite.tpl +74 -0
|
@@ -0,0 +1,1477 @@
|
|
|
1
|
+
|
|
2
|
+
define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts, bootbox) {
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
// ACP dark mode compatibility: style the address autocomplete dropdown using
|
|
6
|
+
// Bootstrap CSS variables (so it remains readable in dark themes).
|
|
7
|
+
(function ensureOnekiteAdminStyles() {
|
|
8
|
+
try {
|
|
9
|
+
if (document.getElementById('onekite-acp-inline-styles')) return;
|
|
10
|
+
const style = document.createElement('style');
|
|
11
|
+
style.id = 'onekite-acp-inline-styles';
|
|
12
|
+
style.textContent = `
|
|
13
|
+
.onekite-autocomplete-menu {
|
|
14
|
+
background: var(--bs-body-bg, #fff);
|
|
15
|
+
color: var(--bs-body-color, #212529);
|
|
16
|
+
border: 1px solid var(--bs-border-color, rgba(0,0,0,.15));
|
|
17
|
+
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15);
|
|
18
|
+
}
|
|
19
|
+
.onekite-autocomplete-item {
|
|
20
|
+
color: inherit;
|
|
21
|
+
background: transparent;
|
|
22
|
+
}
|
|
23
|
+
.onekite-autocomplete-item:hover,
|
|
24
|
+
.onekite-autocomplete-item:focus {
|
|
25
|
+
background: var(--bs-tertiary-bg, rgba(0,0,0,.05));
|
|
26
|
+
outline: none;
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
29
|
+
document.head.appendChild(style);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
// ignore
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
// Cache of pending reservations keyed by rid so delegated click handlers
|
|
36
|
+
// can open rich modals without embedding large JSON blobs into the DOM.
|
|
37
|
+
const pendingCache = new Map();
|
|
38
|
+
|
|
39
|
+
// Prevent double actions (double taps/clicks) in ACP.
|
|
40
|
+
// Keyed by action (eg. approve:RID, refuse:RID, batch:approve).
|
|
41
|
+
const actionLocks = new Set();
|
|
42
|
+
|
|
43
|
+
function setButtonsDisabled(btns, disabled) {
|
|
44
|
+
(btns || []).filter(Boolean).forEach((b) => {
|
|
45
|
+
try {
|
|
46
|
+
b.disabled = !!disabled;
|
|
47
|
+
b.classList.toggle('disabled', !!disabled);
|
|
48
|
+
b.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
|
49
|
+
} catch (e) {}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function setBtnBusy(btn, busy) {
|
|
54
|
+
if (!btn) return;
|
|
55
|
+
try {
|
|
56
|
+
if (busy) {
|
|
57
|
+
if (!btn.dataset.okcOrigHtml) btn.dataset.okcOrigHtml = btn.innerHTML;
|
|
58
|
+
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right:6px;"></span>' + btn.dataset.okcOrigHtml;
|
|
59
|
+
} else if (btn.dataset.okcOrigHtml) {
|
|
60
|
+
btn.innerHTML = btn.dataset.okcOrigHtml;
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function withLock(lockKey, btns, fn) {
|
|
66
|
+
if (!lockKey) return await fn();
|
|
67
|
+
if (actionLocks.has(lockKey)) return;
|
|
68
|
+
actionLocks.add(lockKey);
|
|
69
|
+
setButtonsDisabled(btns, true);
|
|
70
|
+
// Only show a spinner on the primary button (first in list)
|
|
71
|
+
setBtnBusy(btns && btns[0], true);
|
|
72
|
+
try {
|
|
73
|
+
return await fn();
|
|
74
|
+
} finally {
|
|
75
|
+
setBtnBusy(btns && btns[0], false);
|
|
76
|
+
setButtonsDisabled(btns, false);
|
|
77
|
+
actionLocks.delete(lockKey);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getActiveBootboxFooterButtons() {
|
|
82
|
+
try {
|
|
83
|
+
const modal = document.querySelector('.bootbox.modal.show');
|
|
84
|
+
if (!modal) return [];
|
|
85
|
+
return Array.from(modal.querySelectorAll('.modal-footer button'));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function showAlert(type, msg) {
|
|
92
|
+
// Deduplicate identical alerts that can be triggered multiple times
|
|
93
|
+
// by NodeBB ACP save buttons/hooks across ajaxify navigations.
|
|
94
|
+
try {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
const last = window.oneKiteCalendarLastAlert;
|
|
97
|
+
if (last && last.type === type && last.msg === msg && (now - last.ts) < 1200) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
window.oneKiteCalendarLastAlert = { type, msg, ts: now };
|
|
101
|
+
} catch (e) {}
|
|
102
|
+
try {
|
|
103
|
+
if (alerts && typeof alerts[type] === 'function') {
|
|
104
|
+
alerts[type](msg);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {}
|
|
108
|
+
alert(msg);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function fetchJson(url, opts) {
|
|
112
|
+
const res = await fetch(url, {
|
|
113
|
+
credentials: 'same-origin',
|
|
114
|
+
headers: (() => {
|
|
115
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
116
|
+
const token =
|
|
117
|
+
(window.config && (window.config.csrf_token || window.config.csrfToken)) ||
|
|
118
|
+
(window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
|
|
119
|
+
(document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
|
|
120
|
+
(document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
|
|
121
|
+
(typeof app !== 'undefined' && app && app.csrfToken) ||
|
|
122
|
+
null;
|
|
123
|
+
if (token) headers['x-csrf-token'] = token;
|
|
124
|
+
return headers;
|
|
125
|
+
})(),
|
|
126
|
+
...opts,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
// NodeBB versions differ: some expose admin APIs under /api/admin instead of /api/v3/admin
|
|
132
|
+
if (res.status === 404 && typeof url === 'string' && url.includes('/api/v3/admin/')) {
|
|
133
|
+
const altUrl = url.replace('/api/v3/admin/', '/api/admin/');
|
|
134
|
+
const res2 = await fetch(altUrl, {
|
|
135
|
+
credentials: 'same-origin',
|
|
136
|
+
headers: (() => {
|
|
137
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
138
|
+
const token =
|
|
139
|
+
(window.config && (window.config.csrf_token || window.config.csrfToken)) ||
|
|
140
|
+
(window.ajaxify && window.ajaxify.data && window.ajaxify.data.csrf_token) ||
|
|
141
|
+
(document.querySelector('meta[name="csrf-token"]') && document.querySelector('meta[name="csrf-token"]').getAttribute('content')) ||
|
|
142
|
+
(document.querySelector('meta[name="csrf_token"]') && document.querySelector('meta[name="csrf_token"]').getAttribute('content')) ||
|
|
143
|
+
(typeof app !== 'undefined' && app && app.csrfToken) ||
|
|
144
|
+
null;
|
|
145
|
+
if (token) headers['x-csrf-token'] = token;
|
|
146
|
+
return headers;
|
|
147
|
+
})(),
|
|
148
|
+
...opts,
|
|
149
|
+
});
|
|
150
|
+
if (res2.ok) {
|
|
151
|
+
return await res2.json();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const text = await res.text().catch(() => '');
|
|
155
|
+
throw new Error(`${res.status} ${text}`);
|
|
156
|
+
}
|
|
157
|
+
return await res.json();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Leaflet (OpenStreetMap) helpers - loaded lazily only when needed.
|
|
161
|
+
let leafletPromise = null;
|
|
162
|
+
function loadLeaflet() {
|
|
163
|
+
if (leafletPromise) return leafletPromise;
|
|
164
|
+
leafletPromise = new Promise((resolve, reject) => {
|
|
165
|
+
try {
|
|
166
|
+
if (window.L && window.L.map) {
|
|
167
|
+
resolve(window.L);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const cssId = 'onekite-leaflet-css';
|
|
171
|
+
const jsId = 'onekite-leaflet-js';
|
|
172
|
+
if (!document.getElementById(cssId)) {
|
|
173
|
+
const link = document.createElement('link');
|
|
174
|
+
link.id = cssId;
|
|
175
|
+
link.rel = 'stylesheet';
|
|
176
|
+
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
|
177
|
+
document.head.appendChild(link);
|
|
178
|
+
}
|
|
179
|
+
const existing = document.getElementById(jsId);
|
|
180
|
+
if (existing) {
|
|
181
|
+
existing.addEventListener('load', () => resolve(window.L));
|
|
182
|
+
existing.addEventListener('error', () => reject(new Error('leaflet-load-failed')));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const script = document.createElement('script');
|
|
186
|
+
script.id = jsId;
|
|
187
|
+
script.async = true;
|
|
188
|
+
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
|
189
|
+
script.onload = () => resolve(window.L);
|
|
190
|
+
script.onerror = () => reject(new Error('leaflet-load-failed'));
|
|
191
|
+
document.head.appendChild(script);
|
|
192
|
+
} catch (e) {
|
|
193
|
+
reject(e);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
return leafletPromise;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function geocodeAddress(query) {
|
|
200
|
+
const q = String(query || '').trim();
|
|
201
|
+
if (!q) return null;
|
|
202
|
+
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`;
|
|
203
|
+
const res = await fetch(url, {
|
|
204
|
+
method: 'GET',
|
|
205
|
+
headers: {
|
|
206
|
+
'Accept': 'application/json',
|
|
207
|
+
'Accept-Language': 'fr',
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
if (!res.ok) return null;
|
|
211
|
+
const arr = await res.json();
|
|
212
|
+
if (!Array.isArray(arr) || !arr.length) return null;
|
|
213
|
+
const hit = arr[0];
|
|
214
|
+
const lat = Number(hit.lat);
|
|
215
|
+
const lon = Number(hit.lon);
|
|
216
|
+
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
|
217
|
+
return { lat, lon, displayName: hit.display_name || q };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Lightweight address autocomplete (OpenStreetMap Nominatim)
|
|
221
|
+
// Designed to work inside Bootstrap "input-group" without moving DOM nodes.
|
|
222
|
+
async function searchAddresses(query, limit) {
|
|
223
|
+
const q = String(query || '').trim();
|
|
224
|
+
const lim = Math.min(10, Math.max(1, Number(limit) || 6));
|
|
225
|
+
if (q.length < 3) return [];
|
|
226
|
+
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=${lim}&q=${encodeURIComponent(q)}`;
|
|
227
|
+
const res = await fetch(url, {
|
|
228
|
+
method: 'GET',
|
|
229
|
+
headers: {
|
|
230
|
+
'Accept': 'application/json',
|
|
231
|
+
'Accept-Language': 'fr',
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
if (!res.ok) return [];
|
|
235
|
+
const arr = await res.json();
|
|
236
|
+
if (!Array.isArray(arr)) return [];
|
|
237
|
+
return arr.map((hit) => {
|
|
238
|
+
const lat = Number(hit.lat);
|
|
239
|
+
const lon = Number(hit.lon);
|
|
240
|
+
return {
|
|
241
|
+
displayName: hit.display_name || q,
|
|
242
|
+
lat: Number.isFinite(lat) ? lat : null,
|
|
243
|
+
lon: Number.isFinite(lon) ? lon : null,
|
|
244
|
+
};
|
|
245
|
+
}).filter(h => h && h.displayName);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function attachAddressAutocomplete(inputEl, onPick) {
|
|
249
|
+
if (!inputEl) return;
|
|
250
|
+
if (inputEl.getAttribute('data-onekite-autocomplete') === '1') return;
|
|
251
|
+
inputEl.setAttribute('data-onekite-autocomplete', '1');
|
|
252
|
+
|
|
253
|
+
// In Bootstrap input-groups (especially in ACP), wrapping the input breaks layout.
|
|
254
|
+
// So we anchor the menu to the closest input-group (or parent) without moving the input.
|
|
255
|
+
const anchor = inputEl.closest && inputEl.closest('.input-group')
|
|
256
|
+
? inputEl.closest('.input-group')
|
|
257
|
+
: (inputEl.parentNode || document.body);
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const cs = window.getComputedStyle(anchor);
|
|
261
|
+
if (!cs || cs.position === 'static') {
|
|
262
|
+
anchor.style.position = 'relative';
|
|
263
|
+
}
|
|
264
|
+
} catch (e) {
|
|
265
|
+
anchor.style.position = 'relative';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const menu = document.createElement('div');
|
|
269
|
+
menu.className = 'onekite-autocomplete-menu';
|
|
270
|
+
menu.style.position = 'absolute';
|
|
271
|
+
menu.style.left = '0';
|
|
272
|
+
menu.style.right = '0';
|
|
273
|
+
menu.style.top = '100%';
|
|
274
|
+
menu.style.zIndex = '2000';
|
|
275
|
+
// Colors are handled via CSS variables (supports ACP dark mode).
|
|
276
|
+
menu.style.borderTop = '0';
|
|
277
|
+
menu.style.maxHeight = '220px';
|
|
278
|
+
menu.style.overflowY = 'auto';
|
|
279
|
+
menu.style.display = 'none';
|
|
280
|
+
menu.style.borderRadius = '0 0 .375rem .375rem';
|
|
281
|
+
anchor.appendChild(menu);
|
|
282
|
+
|
|
283
|
+
let timer = null;
|
|
284
|
+
let lastQuery = '';
|
|
285
|
+
let busy = false;
|
|
286
|
+
|
|
287
|
+
function hide() {
|
|
288
|
+
menu.style.display = 'none';
|
|
289
|
+
menu.innerHTML = '';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function show(hits) {
|
|
293
|
+
if (!hits || !hits.length) {
|
|
294
|
+
hide();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
menu.innerHTML = '';
|
|
298
|
+
hits.forEach((h) => {
|
|
299
|
+
const btn = document.createElement('button');
|
|
300
|
+
btn.type = 'button';
|
|
301
|
+
btn.className = 'onekite-autocomplete-item';
|
|
302
|
+
btn.textContent = h.displayName;
|
|
303
|
+
btn.style.display = 'block';
|
|
304
|
+
btn.style.width = '100%';
|
|
305
|
+
btn.style.textAlign = 'left';
|
|
306
|
+
btn.style.padding = '.35rem .5rem';
|
|
307
|
+
btn.style.border = '0';
|
|
308
|
+
btn.style.cursor = 'pointer';
|
|
309
|
+
btn.addEventListener('click', () => {
|
|
310
|
+
inputEl.value = h.displayName;
|
|
311
|
+
hide();
|
|
312
|
+
try { onPick && onPick(h); } catch (e) {}
|
|
313
|
+
});
|
|
314
|
+
menu.appendChild(btn);
|
|
315
|
+
});
|
|
316
|
+
menu.style.display = 'block';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function run(q) {
|
|
320
|
+
if (busy) return;
|
|
321
|
+
busy = true;
|
|
322
|
+
try {
|
|
323
|
+
const hits = await searchAddresses(q, 6);
|
|
324
|
+
if (String(inputEl.value || '').trim() !== q) return; // ignore stale
|
|
325
|
+
show(hits);
|
|
326
|
+
} catch (e) {
|
|
327
|
+
hide();
|
|
328
|
+
} finally {
|
|
329
|
+
busy = false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
inputEl.addEventListener('input', () => {
|
|
334
|
+
const q = String(inputEl.value || '').trim();
|
|
335
|
+
lastQuery = q;
|
|
336
|
+
if (timer) clearTimeout(timer);
|
|
337
|
+
if (q.length < 3) {
|
|
338
|
+
hide();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
timer = setTimeout(() => run(lastQuery), 250);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
inputEl.addEventListener('focus', () => {
|
|
345
|
+
const q = String(inputEl.value || '').trim();
|
|
346
|
+
if (q.length >= 3) {
|
|
347
|
+
if (timer) clearTimeout(timer);
|
|
348
|
+
timer = setTimeout(() => run(q), 150);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
353
|
+
if (e.key === 'Escape') hide();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Close when clicking outside (once per menu)
|
|
357
|
+
document.addEventListener('click', (e) => {
|
|
358
|
+
try {
|
|
359
|
+
if (!anchor.contains(e.target)) hide();
|
|
360
|
+
} catch (err) {}
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function formToObject(form) {
|
|
365
|
+
const out = {};
|
|
366
|
+
new FormData(form).forEach((v, k) => {
|
|
367
|
+
out[k] = String(v);
|
|
368
|
+
});
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function fillForm(form, data) {
|
|
373
|
+
[...form.elements].forEach((el) => {
|
|
374
|
+
if (!el.name) return;
|
|
375
|
+
if (Object.prototype.hasOwnProperty.call(data, el.name)) {
|
|
376
|
+
el.value = data[el.name];
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function normalizeCsvGroupsWithDefault(csv, defaultGroup) {
|
|
382
|
+
const extras = String(csv || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
383
|
+
const set = new Set();
|
|
384
|
+
const out = [];
|
|
385
|
+
if (defaultGroup) {
|
|
386
|
+
const dg = String(defaultGroup).trim();
|
|
387
|
+
if (dg) {
|
|
388
|
+
set.add(dg);
|
|
389
|
+
out.push(dg);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
for (const g of extras) {
|
|
393
|
+
if (!set.has(g)) {
|
|
394
|
+
set.add(g);
|
|
395
|
+
out.push(g);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return out.join(', ');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function ensureSpecialFieldsExist(form) {
|
|
402
|
+
// If the ACP template didn't include these fields (older installs), inject them.
|
|
403
|
+
if (!form) return;
|
|
404
|
+
const hasCreator = form.querySelector('[name="specialCreatorGroups"]');
|
|
405
|
+
const hasDeleter = form.querySelector('[name="specialDeleterGroups"]');
|
|
406
|
+
if (hasCreator && hasDeleter) return;
|
|
407
|
+
const wrap = document.createElement('div');
|
|
408
|
+
wrap.innerHTML = `
|
|
409
|
+
<hr />
|
|
410
|
+
<h4>Évènements (autre couleur)</h4>
|
|
411
|
+
<p class="text-muted" style="max-width: 900px;">Permet de créer des évènements non liés aux locations (autre couleur), avec date/heure, adresse (OpenStreetMap) et notes.</p>
|
|
412
|
+
<div class="mb-3">
|
|
413
|
+
<label class="form-label">Groupes autorisés à créer ces évènements (CSV)</label>
|
|
414
|
+
<input type="text" class="form-control" name="specialCreatorGroups" placeholder="ex: staff, instructors" />
|
|
415
|
+
</div>
|
|
416
|
+
<div class="mb-3">
|
|
417
|
+
<label class="form-label">Groupes autorisés à supprimer ces évènements (CSV)</label>
|
|
418
|
+
<input type="text" class="form-control" name="specialDeleterGroups" placeholder="ex: administrators" />
|
|
419
|
+
</div>
|
|
420
|
+
`;
|
|
421
|
+
form.appendChild(wrap);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
function renderPending(list) {
|
|
426
|
+
const wrap = document.getElementById('onekite-pending');
|
|
427
|
+
if (!wrap) return;
|
|
428
|
+
wrap.innerHTML = '';
|
|
429
|
+
|
|
430
|
+
pendingCache.clear();
|
|
431
|
+
|
|
432
|
+
if (!list || !list.length) {
|
|
433
|
+
wrap.innerHTML = '<div class="text-muted">Aucune demande.</div>';
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Batch actions (low volume but handy on mobile / multiple requests)
|
|
438
|
+
const batchBar = document.createElement('div');
|
|
439
|
+
batchBar.className = 'd-flex flex-wrap gap-2 align-items-center mb-2';
|
|
440
|
+
batchBar.innerHTML = `
|
|
441
|
+
<button type="button" class="btn btn-outline-secondary btn-sm" data-action="toggle-all">Tout sélectionner</button>
|
|
442
|
+
<button type="button" class="btn btn-success btn-sm" data-action="approve-selected">Valider sélection</button>
|
|
443
|
+
<button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse-selected">Refuser sélection</button>
|
|
444
|
+
<span class="text-muted" style="font-size:12px;">(<span id="onekite-selected-count">0</span> sélectionnée)</span>
|
|
445
|
+
`;
|
|
446
|
+
wrap.appendChild(batchBar);
|
|
447
|
+
|
|
448
|
+
const fmtFR = (ts) => {
|
|
449
|
+
const d = new Date(parseInt(ts, 10));
|
|
450
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
451
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
452
|
+
const yyyy = d.getFullYear();
|
|
453
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
454
|
+
const mi = String(d.getMinutes()).padStart(2, '0');
|
|
455
|
+
return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
for (const r of list) {
|
|
459
|
+
if (r && r.rid) pendingCache.set(String(r.rid), r);
|
|
460
|
+
const created = r.createdAt ? fmtFR(r.createdAt) : '';
|
|
461
|
+
const itemNames = Array.isArray(r.itemNames) && r.itemNames.length ? r.itemNames : [r.itemName || r.itemId].filter(Boolean);
|
|
462
|
+
const itemsHtml = `<ul style="margin: 0 0 10px 18px;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul>`;
|
|
463
|
+
const div = document.createElement('div');
|
|
464
|
+
div.className = 'list-group-item onekite-pending-row';
|
|
465
|
+
div.innerHTML = `
|
|
466
|
+
<div class="d-flex justify-content-between align-items-start gap-2">
|
|
467
|
+
<div style="min-width: 0;" class="d-flex gap-2">
|
|
468
|
+
<div style="padding-top: 2px;">
|
|
469
|
+
<input type="checkbox" class="form-check-input onekite-pending-select" data-rid="${escapeHtml(String(r.rid || ''))}" />
|
|
470
|
+
</div>
|
|
471
|
+
<div style="min-width:0;">
|
|
472
|
+
<div><strong>${itemsHtml || escapeHtml(r.itemName || '')}</strong></div>
|
|
473
|
+
<div class="text-muted" style="font-size: 12px;">Créée: ${escapeHtml(created)}</div>
|
|
474
|
+
<div class="text-muted" style="font-size: 12px;">Période: ${escapeHtml(new Date(parseInt(r.start, 10)).toLocaleDateString('fr-FR'))} → ${escapeHtml(new Date(parseInt(r.end, 10)).toLocaleDateString('fr-FR'))}</div>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
<div class="d-flex gap-2">
|
|
478
|
+
<!-- IMPORTANT: type="button" to avoid submitting the settings form and resetting ACP tabs -->
|
|
479
|
+
<button type="button" class="btn btn-outline-danger btn-sm" data-action="refuse" data-rid="${escapeHtml(String(r.rid || ''))}">Refuser</button>
|
|
480
|
+
<button type="button" class="btn btn-success btn-sm" data-action="approve" data-rid="${escapeHtml(String(r.rid || ''))}">Valider</button>
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
`;
|
|
484
|
+
wrap.appendChild(div);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Selected counter
|
|
488
|
+
try {
|
|
489
|
+
const countEl = document.getElementById('onekite-selected-count');
|
|
490
|
+
const refreshCount = () => {
|
|
491
|
+
const n = wrap.querySelectorAll('.onekite-pending-select:checked').length;
|
|
492
|
+
if (countEl) countEl.textContent = String(n);
|
|
493
|
+
};
|
|
494
|
+
wrap.querySelectorAll('.onekite-pending-select').forEach((cb) => cb.addEventListener('change', refreshCount));
|
|
495
|
+
refreshCount();
|
|
496
|
+
} catch (e) {}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
function timeOptions(stepMinutes) {
|
|
501
|
+
const step = stepMinutes || 5;
|
|
502
|
+
const out = [];
|
|
503
|
+
for (let h = 7; h < 24; h++) {
|
|
504
|
+
for (let m = 0; m < 60; m += step) {
|
|
505
|
+
const hh = String(h).padStart(2, '0');
|
|
506
|
+
const mm = String(m).padStart(2, '0');
|
|
507
|
+
out.push(`${hh}:${mm}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function escapeHtml(s) {
|
|
514
|
+
return String(s || '')
|
|
515
|
+
.replace(/&/g, '&')
|
|
516
|
+
.replace(/</g, '<')
|
|
517
|
+
.replace(/>/g, '>')
|
|
518
|
+
.replace(/"/g, '"')
|
|
519
|
+
.replace(/'/g, ''');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function loadSettings() {
|
|
523
|
+
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/settings');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function saveSettings(payload) {
|
|
527
|
+
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/settings', {
|
|
528
|
+
method: 'PUT',
|
|
529
|
+
body: JSON.stringify(payload),
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function loadPending() {
|
|
534
|
+
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/pending');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function approve(rid, payload) {
|
|
538
|
+
return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${rid}/approve`, {
|
|
539
|
+
method: 'PUT',
|
|
540
|
+
body: JSON.stringify(payload || {}),
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function refuse(rid, payload) {
|
|
545
|
+
return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/reservations/${rid}/refuse`, {
|
|
546
|
+
method: 'PUT',
|
|
547
|
+
body: JSON.stringify(payload || {}),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function purge(year) {
|
|
552
|
+
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/purge', {
|
|
553
|
+
method: 'POST',
|
|
554
|
+
body: JSON.stringify({ year }),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function purgeSpecialEvents(year) {
|
|
559
|
+
try {
|
|
560
|
+
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/special-events/purge', {
|
|
561
|
+
method: 'POST',
|
|
562
|
+
body: JSON.stringify({ year }),
|
|
563
|
+
});
|
|
564
|
+
} catch (e) {
|
|
565
|
+
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/special-events/purge', {
|
|
566
|
+
method: 'POST',
|
|
567
|
+
body: JSON.stringify({ year }),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function debugHelloAsso() {
|
|
573
|
+
try {
|
|
574
|
+
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
|
|
575
|
+
} catch (e) {
|
|
576
|
+
return await fetchJson('/api/v3/admin/plugins/calendar-onekite/debug');
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function loadAccounting(from, to) {
|
|
581
|
+
const params = new URLSearchParams();
|
|
582
|
+
if (from) params.set('from', from);
|
|
583
|
+
if (to) params.set('to', to);
|
|
584
|
+
const qs = params.toString();
|
|
585
|
+
return await fetchJson(`/api/v3/admin/plugins/calendar-onekite/accounting${qs ? `?${qs}` : ''}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function init() {
|
|
589
|
+
const form = document.getElementById('onekite-settings-form');
|
|
590
|
+
if (!form) return;
|
|
591
|
+
|
|
592
|
+
// Make the HelloAsso debug output readable in both light and dark ACP themes.
|
|
593
|
+
// NodeBB 4.x uses Bootstrap variables, so we can rely on CSS variables here.
|
|
594
|
+
(function injectAdminCss() {
|
|
595
|
+
const id = 'onekite-admin-css';
|
|
596
|
+
if (document.getElementById(id)) return;
|
|
597
|
+
const style = document.createElement('style');
|
|
598
|
+
style.id = id;
|
|
599
|
+
style.textContent = `
|
|
600
|
+
#onekite-debug-output.onekite-debug-output {
|
|
601
|
+
background: var(--bs-body-bg) !important;
|
|
602
|
+
color: var(--bs-body-color) !important;
|
|
603
|
+
border: 1px solid var(--bs-border-color) !important;
|
|
604
|
+
}
|
|
605
|
+
`;
|
|
606
|
+
document.head.appendChild(style);
|
|
607
|
+
})();
|
|
608
|
+
|
|
609
|
+
// Load settings
|
|
610
|
+
try {
|
|
611
|
+
const s = await loadSettings();
|
|
612
|
+
fillForm(form, s || {});
|
|
613
|
+
|
|
614
|
+
// Ensure default creator group prefix appears in the ACP field
|
|
615
|
+
const y = new Date().getFullYear();
|
|
616
|
+
const defaultGroup = `onekite-ffvl-${y}`;
|
|
617
|
+
const cgEl = form.querySelector('[name="creatorGroups"]');
|
|
618
|
+
if (cgEl) {
|
|
619
|
+
cgEl.value = normalizeCsvGroupsWithDefault(cgEl.value, defaultGroup);
|
|
620
|
+
}
|
|
621
|
+
} catch (e) {
|
|
622
|
+
showAlert('error', 'Impossible de charger les paramètres.');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Load pending
|
|
626
|
+
async function refreshPending() {
|
|
627
|
+
try {
|
|
628
|
+
const p = await loadPending();
|
|
629
|
+
renderPending(p);
|
|
630
|
+
} catch (e) {
|
|
631
|
+
showAlert('error', 'Impossible de charger les demandes en attente.');
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
await refreshPending();
|
|
636
|
+
|
|
637
|
+
async function doSave(ev) {
|
|
638
|
+
// Guard against duplicate handlers (some themes bind multiple save buttons)
|
|
639
|
+
// and against rapid double-clicks.
|
|
640
|
+
if (doSave._inFlight) {
|
|
641
|
+
if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
doSave._inFlight = true;
|
|
645
|
+
try {
|
|
646
|
+
if (ev && typeof ev.preventDefault === 'function') ev.preventDefault();
|
|
647
|
+
const payload = formToObject(form);
|
|
648
|
+
// Always prefix with default yearly group
|
|
649
|
+
const y = new Date().getFullYear();
|
|
650
|
+
const defaultGroup = `onekite-ffvl-${y}`;
|
|
651
|
+
if (Object.prototype.hasOwnProperty.call(payload, 'creatorGroups')) {
|
|
652
|
+
payload.creatorGroups = normalizeCsvGroupsWithDefault(payload.creatorGroups, defaultGroup);
|
|
653
|
+
}
|
|
654
|
+
await saveSettings(payload);
|
|
655
|
+
showAlert('success', 'Paramètres enregistrés.');
|
|
656
|
+
} catch (e) {
|
|
657
|
+
showAlert('error', 'Échec de l\'enregistrement.');
|
|
658
|
+
} finally {
|
|
659
|
+
doSave._inFlight = false;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Expose the latest save handler so the global delegated listener (bound once)
|
|
664
|
+
// can always call the current instance tied to the current form.
|
|
665
|
+
window.oneKiteCalendarAdminDoSave = doSave;
|
|
666
|
+
|
|
667
|
+
// Save buttons (NodeBB header/footer "Enregistrer" + floppy icon)
|
|
668
|
+
// Bind a SINGLE delegated listener for the entire admin session.
|
|
669
|
+
const SAVE_SELECTOR = '#save, .save, [data-action="save"], .settings-save, .floating-save, .btn[data-action="save"]';
|
|
670
|
+
if (!window.oneKiteCalendarAdminBound) {
|
|
671
|
+
window.oneKiteCalendarAdminBound = true;
|
|
672
|
+
document.addEventListener('click', (ev) => {
|
|
673
|
+
const btn = ev.target && ev.target.closest && ev.target.closest(SAVE_SELECTOR);
|
|
674
|
+
if (!btn) return;
|
|
675
|
+
// Only handle clicks while we're on this plugin page
|
|
676
|
+
if (!document.getElementById('onekite-settings-form')) return;
|
|
677
|
+
const fn = window.oneKiteCalendarAdminDoSave;
|
|
678
|
+
if (typeof fn === 'function') fn(ev);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Approve/refuse buttons
|
|
683
|
+
const pendingWrap = document.getElementById('onekite-pending');
|
|
684
|
+
if (pendingWrap && !pendingWrap.dataset.okcBound) {
|
|
685
|
+
pendingWrap.dataset.okcBound = '1';
|
|
686
|
+
|
|
687
|
+
function selectedRids() {
|
|
688
|
+
return Array.from(pendingWrap.querySelectorAll('input.onekite-pending-select:checked'))
|
|
689
|
+
.map(cb => cb.getAttribute('data-rid'))
|
|
690
|
+
.filter(Boolean);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function refreshSelectedCount() {
|
|
694
|
+
const el = document.getElementById('onekite-selected-count');
|
|
695
|
+
if (el) el.textContent = String(selectedRids().length);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
pendingWrap.addEventListener('change', (ev) => {
|
|
699
|
+
const cb = ev.target && ev.target.closest ? ev.target.closest('input.onekite-pending-select') : null;
|
|
700
|
+
if (!cb) return;
|
|
701
|
+
refreshSelectedCount();
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
pendingWrap.addEventListener('click', async (ev) => {
|
|
705
|
+
const btn = ev.target && ev.target.closest('button[data-action]');
|
|
706
|
+
if (!btn) return;
|
|
707
|
+
// Prevent the settings form from submitting (default <button> behavior)
|
|
708
|
+
// and avoid triggering NodeBB ACP tab navigation side-effects.
|
|
709
|
+
try {
|
|
710
|
+
ev.preventDefault();
|
|
711
|
+
ev.stopPropagation();
|
|
712
|
+
} catch (e) {}
|
|
713
|
+
const action = btn.getAttribute('data-action');
|
|
714
|
+
const rid = btn.getAttribute('data-rid');
|
|
715
|
+
|
|
716
|
+
// Prevent accidental double-open of modals on mobile/trackpad double taps
|
|
717
|
+
if (rid && (action === 'approve' || action === 'refuse')) {
|
|
718
|
+
const openKey = `open:${action}:${rid}`;
|
|
719
|
+
if (actionLocks.has(openKey)) return;
|
|
720
|
+
actionLocks.add(openKey);
|
|
721
|
+
setTimeout(() => actionLocks.delete(openKey), 800);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Batch actions (no rid)
|
|
725
|
+
if ((action === 'toggle-all' || action === 'approve-selected' || action === 'refuse-selected') && !rid) {
|
|
726
|
+
const batchBtns = Array.from(pendingWrap.querySelectorAll('button[data-action="toggle-all"], button[data-action="approve-selected"], button[data-action="refuse-selected"]'));
|
|
727
|
+
const allCbs = Array.from(pendingWrap.querySelectorAll('input.onekite-pending-select'));
|
|
728
|
+
if (action === 'toggle-all') {
|
|
729
|
+
const want = allCbs.some(cb => !cb.checked);
|
|
730
|
+
allCbs.forEach(cb => { cb.checked = want; });
|
|
731
|
+
refreshSelectedCount();
|
|
732
|
+
btn.textContent = want ? 'Tout désélectionner' : 'Tout sélectionner';
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const rids = selectedRids();
|
|
737
|
+
if (!rids.length) {
|
|
738
|
+
showAlert('error', 'Aucune demande sélectionnée.');
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (action === 'refuse-selected') {
|
|
743
|
+
const html = `
|
|
744
|
+
<div class="mb-3">
|
|
745
|
+
<label class="form-label">Raison du refus (appliquée à toutes les demandes sélectionnées)</label>
|
|
746
|
+
<textarea class="form-control" id="onekite-refuse-reason" rows="3" placeholder="Ex: matériel indisponible, dates impossibles, dossier incomplet..."></textarea>
|
|
747
|
+
</div>
|
|
748
|
+
`;
|
|
749
|
+
await new Promise((resolve) => {
|
|
750
|
+
bootbox.dialog({
|
|
751
|
+
title: `Refuser ${rids.length} demande(s)`,
|
|
752
|
+
message: html,
|
|
753
|
+
buttons: {
|
|
754
|
+
cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(false) },
|
|
755
|
+
ok: {
|
|
756
|
+
label: 'Refuser',
|
|
757
|
+
className: 'btn-danger',
|
|
758
|
+
callback: async () => {
|
|
759
|
+
await withLock(`batch:refuse`, batchBtns.concat(getActiveBootboxFooterButtons()), async () => {
|
|
760
|
+
try {
|
|
761
|
+
const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
|
|
762
|
+
for (const rr of rids) {
|
|
763
|
+
await refuse(rr, { reason });
|
|
764
|
+
}
|
|
765
|
+
showAlert('success', `${rids.length} demande(s) refusée(s).`);
|
|
766
|
+
resolve(true);
|
|
767
|
+
} catch (e) {
|
|
768
|
+
showAlert('error', 'Refus impossible.');
|
|
769
|
+
resolve(false);
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
return false;
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
await refreshPending();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (action === 'approve-selected') {
|
|
783
|
+
const opts = timeOptions(5).map(t => `<option value="${t}" ${t === '07:00' ? 'selected' : ''}>${t}</option>`).join('');
|
|
784
|
+
const html = `
|
|
785
|
+
<div class="mb-3">
|
|
786
|
+
<label class="form-label">Adresse de récupération</label>
|
|
787
|
+
<div class="input-group">
|
|
788
|
+
<input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
|
|
789
|
+
<button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
|
|
790
|
+
</div>
|
|
791
|
+
<div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
|
|
792
|
+
<div class="form-text" id="onekite-map-help">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
|
|
793
|
+
<input type="hidden" id="onekite-pickup-lat" />
|
|
794
|
+
<input type="hidden" id="onekite-pickup-lon" />
|
|
795
|
+
</div>
|
|
796
|
+
<div class="mb-3">
|
|
797
|
+
<label class="form-label">Notes (facultatif)</label>
|
|
798
|
+
<textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
|
|
799
|
+
</div>
|
|
800
|
+
<div class="mb-2">
|
|
801
|
+
<label class="form-label">Heure de récupération</label>
|
|
802
|
+
<select class="form-select" id="onekite-pickup-time">${opts}</select>
|
|
803
|
+
</div>
|
|
804
|
+
<div class="text-muted" style="font-size:12px;">Ces infos seront appliquées aux ${rids.length} demandes sélectionnées.</div>
|
|
805
|
+
`;
|
|
806
|
+
const dlg = bootbox.dialog({
|
|
807
|
+
title: `Valider ${rids.length} demande(s)` ,
|
|
808
|
+
message: html,
|
|
809
|
+
buttons: {
|
|
810
|
+
cancel: { label: 'Annuler', className: 'btn-secondary' },
|
|
811
|
+
ok: {
|
|
812
|
+
label: 'Valider',
|
|
813
|
+
className: 'btn-success',
|
|
814
|
+
callback: async () => {
|
|
815
|
+
await withLock(`batch:approve`, batchBtns.concat(getActiveBootboxFooterButtons()), async () => {
|
|
816
|
+
try {
|
|
817
|
+
const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
|
|
818
|
+
const notes = (document.getElementById('onekite-notes')?.value || '').trim();
|
|
819
|
+
const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
|
|
820
|
+
const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
|
|
821
|
+
const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
|
|
822
|
+
for (const rr of rids) {
|
|
823
|
+
await approve(rr, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
|
|
824
|
+
}
|
|
825
|
+
showAlert('success', `${rids.length} demande(s) validée(s).`);
|
|
826
|
+
await refreshPending();
|
|
827
|
+
} catch (e) {
|
|
828
|
+
showAlert('error', 'Validation impossible.');
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
},
|
|
832
|
+
},
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// Init Leaflet map once the modal is visible.
|
|
837
|
+
dlg.on('shown.bs.modal', async () => {
|
|
838
|
+
try {
|
|
839
|
+
const L = await loadLeaflet();
|
|
840
|
+
const mapEl = document.getElementById('onekite-map');
|
|
841
|
+
if (!mapEl) return;
|
|
842
|
+
const map = L.map(mapEl).setView([45.7640, 4.8357], 12);
|
|
843
|
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
844
|
+
maxZoom: 19,
|
|
845
|
+
attribution: '© OpenStreetMap contributors',
|
|
846
|
+
}).addTo(map);
|
|
847
|
+
const marker = L.marker([45.7640, 4.8357], { draggable: true }).addTo(map);
|
|
848
|
+
|
|
849
|
+
function setLatLon(lat, lon) {
|
|
850
|
+
const latEl = document.getElementById('onekite-pickup-lat');
|
|
851
|
+
const lonEl = document.getElementById('onekite-pickup-lon');
|
|
852
|
+
if (latEl) latEl.value = String(lat);
|
|
853
|
+
if (lonEl) lonEl.value = String(lon);
|
|
854
|
+
}
|
|
855
|
+
setLatLon(45.7640, 4.8357);
|
|
856
|
+
|
|
857
|
+
marker.on('dragend', () => {
|
|
858
|
+
const p = marker.getLatLng();
|
|
859
|
+
setLatLon(p.lat, p.lng);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
const btnGeocode = document.getElementById('onekite-geocode');
|
|
863
|
+
if (btnGeocode) {
|
|
864
|
+
btnGeocode.addEventListener('click', async () => {
|
|
865
|
+
try {
|
|
866
|
+
const addr = (document.getElementById('onekite-pickup-address')?.value || '').trim();
|
|
867
|
+
const hit = await geocodeAddress(addr);
|
|
868
|
+
if (!hit) return;
|
|
869
|
+
marker.setLatLng([hit.lat, hit.lon]);
|
|
870
|
+
map.setView([hit.lat, hit.lon], 16);
|
|
871
|
+
setLatLon(hit.lat, hit.lon);
|
|
872
|
+
} catch (e) {}
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Autocomplete like on the calendar validation modal
|
|
877
|
+
const addrInput = document.getElementById('onekite-pickup-address');
|
|
878
|
+
attachAddressAutocomplete(addrInput, (h) => {
|
|
879
|
+
try {
|
|
880
|
+
if (!h || !Number.isFinite(Number(h.lat)) || !Number.isFinite(Number(h.lon))) return;
|
|
881
|
+
const lat = Number(h.lat);
|
|
882
|
+
const lon = Number(h.lon);
|
|
883
|
+
marker.setLatLng([lat, lon]);
|
|
884
|
+
map.setView([lat, lon], 16);
|
|
885
|
+
setLatLon(lat, lon);
|
|
886
|
+
} catch (e) {}
|
|
887
|
+
});
|
|
888
|
+
setTimeout(() => { try { map.invalidateSize(); } catch (e) {} }, 250);
|
|
889
|
+
} catch (e) {}
|
|
890
|
+
});
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (!rid) return;
|
|
896
|
+
|
|
897
|
+
// Remove the row immediately on success for a snappier UX
|
|
898
|
+
const rowEl = btn.closest('tr') || btn.closest('.onekite-pending-row');
|
|
899
|
+
const rowBtns = rowEl ? Array.from(rowEl.querySelectorAll('button[data-action="approve"],button[data-action="refuse"]')) : [btn];
|
|
900
|
+
|
|
901
|
+
try {
|
|
902
|
+
if (action === 'refuse') {
|
|
903
|
+
const html = `
|
|
904
|
+
<div class="mb-3">
|
|
905
|
+
<label class="form-label">Raison du refus</label>
|
|
906
|
+
<textarea class="form-control" id="onekite-refuse-reason" rows="3" placeholder="Ex: matériel indisponible, dates impossibles, dossier incomplet..."></textarea>
|
|
907
|
+
</div>
|
|
908
|
+
`;
|
|
909
|
+
const ok = await new Promise((resolve) => {
|
|
910
|
+
bootbox.dialog({
|
|
911
|
+
title: 'Refuser la réservation',
|
|
912
|
+
message: html,
|
|
913
|
+
buttons: {
|
|
914
|
+
cancel: { label: 'Annuler', className: 'btn-secondary', callback: () => resolve(false) },
|
|
915
|
+
ok: {
|
|
916
|
+
label: 'Refuser',
|
|
917
|
+
className: 'btn-danger',
|
|
918
|
+
callback: async () => {
|
|
919
|
+
await withLock(`refuse:${rid}`, rowBtns.concat(getActiveBootboxFooterButtons()), async () => {
|
|
920
|
+
try {
|
|
921
|
+
const reason = (document.getElementById('onekite-refuse-reason')?.value || '').trim();
|
|
922
|
+
await refuse(rid, { reason });
|
|
923
|
+
if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
|
|
924
|
+
showAlert('success', 'Demande refusée.');
|
|
925
|
+
resolve(true);
|
|
926
|
+
} catch (e) {
|
|
927
|
+
showAlert('error', 'Refus impossible.');
|
|
928
|
+
resolve(false);
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
return false;
|
|
932
|
+
},
|
|
933
|
+
},
|
|
934
|
+
},
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
if (ok) {
|
|
938
|
+
await refreshPending();
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (action === 'approve') {
|
|
944
|
+
const r = pendingCache.get(String(rid)) || {};
|
|
945
|
+
const itemNames = Array.isArray(r.itemNames) && r.itemNames.length
|
|
946
|
+
? r.itemNames
|
|
947
|
+
: (typeof r.itemNames === 'string' && r.itemNames.trim()
|
|
948
|
+
? r.itemNames.split(',').map(s => s.trim()).filter(Boolean)
|
|
949
|
+
: ([r.itemName || r.itemId].filter(Boolean)));
|
|
950
|
+
const itemsListHtml = itemNames.length
|
|
951
|
+
? `<div class="mb-2"><strong>Matériel</strong><ul style="margin:0.25rem 0 0 1.1rem; padding:0;">${itemNames.map(n => `<li>${escapeHtml(String(n))}</li>`).join('')}</ul></div>`
|
|
952
|
+
: '';
|
|
953
|
+
const opts = timeOptions(5).map(t => `<option value="${t}" ${t === '07:00' ? 'selected' : ''}>${t}</option>`).join('');
|
|
954
|
+
|
|
955
|
+
const html = `
|
|
956
|
+
${itemsListHtml}
|
|
957
|
+
<div class="mb-3">
|
|
958
|
+
<label class="form-label">Adresse de récupération</label>
|
|
959
|
+
<div class="input-group">
|
|
960
|
+
<input type="text" class="form-control" id="onekite-pickup-address" placeholder="Adresse complète" />
|
|
961
|
+
<button class="btn btn-outline-secondary" type="button" id="onekite-geocode">Rechercher</button>
|
|
962
|
+
</div>
|
|
963
|
+
<div id="onekite-map" style="height:220px; border:1px solid #ddd; border-radius:6px; margin-top:0.5rem;"></div>
|
|
964
|
+
<div class="form-text" id="onekite-map-help">Vous pouvez déplacer le marqueur pour ajuster la position.</div>
|
|
965
|
+
<input type="hidden" id="onekite-pickup-lat" />
|
|
966
|
+
<input type="hidden" id="onekite-pickup-lon" />
|
|
967
|
+
</div>
|
|
968
|
+
<div class="mb-3">
|
|
969
|
+
<label class="form-label">Notes (facultatif)</label>
|
|
970
|
+
<textarea class="form-control" id="onekite-notes" rows="3" placeholder="Ex: code portail, personne à contacter, horaires..."></textarea>
|
|
971
|
+
</div>
|
|
972
|
+
<div class="mb-2">
|
|
973
|
+
<label class="form-label">Heure de récupération</label>
|
|
974
|
+
<select class="form-select" id="onekite-pickup-time">${opts}</select>
|
|
975
|
+
</div>
|
|
976
|
+
`;
|
|
977
|
+
|
|
978
|
+
const dlg = bootbox.dialog({
|
|
979
|
+
title: 'Valider la demande',
|
|
980
|
+
message: html,
|
|
981
|
+
buttons: {
|
|
982
|
+
cancel: { label: 'Annuler', className: 'btn-secondary' },
|
|
983
|
+
ok: {
|
|
984
|
+
label: 'Valider',
|
|
985
|
+
className: 'btn-success',
|
|
986
|
+
callback: async () => {
|
|
987
|
+
await withLock(`approve:${rid}`, rowBtns.concat(getActiveBootboxFooterButtons()), async () => {
|
|
988
|
+
try {
|
|
989
|
+
const pickupAddress = (document.getElementById('onekite-pickup-address')?.value || '').trim();
|
|
990
|
+
const notes = (document.getElementById('onekite-notes')?.value || '').trim();
|
|
991
|
+
const pickupTime = (document.getElementById('onekite-pickup-time')?.value || '').trim();
|
|
992
|
+
const pickupLat = (document.getElementById('onekite-pickup-lat')?.value || '').trim();
|
|
993
|
+
const pickupLon = (document.getElementById('onekite-pickup-lon')?.value || '').trim();
|
|
994
|
+
await approve(rid, { pickupAddress, notes, pickupTime, pickupLat, pickupLon });
|
|
995
|
+
if (rowEl && rowEl.parentNode) rowEl.parentNode.removeChild(rowEl);
|
|
996
|
+
showAlert('success', 'Demande validée.');
|
|
997
|
+
await refreshPending();
|
|
998
|
+
} catch (e) {
|
|
999
|
+
showAlert('error', 'Validation impossible.');
|
|
1000
|
+
}
|
|
1001
|
+
});
|
|
1002
|
+
return false;
|
|
1003
|
+
},
|
|
1004
|
+
},
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
// Init Leaflet map once the modal is visible.
|
|
1009
|
+
dlg.on('shown.bs.modal', async () => {
|
|
1010
|
+
try {
|
|
1011
|
+
const L = await loadLeaflet();
|
|
1012
|
+
const mapEl = document.getElementById('onekite-map');
|
|
1013
|
+
if (!mapEl) return;
|
|
1014
|
+
|
|
1015
|
+
const map = L.map(mapEl, { scrollWheelZoom: false });
|
|
1016
|
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
1017
|
+
maxZoom: 19,
|
|
1018
|
+
attribution: '© OpenStreetMap',
|
|
1019
|
+
}).addTo(map);
|
|
1020
|
+
|
|
1021
|
+
// Default view (France-ish)
|
|
1022
|
+
map.setView([46.7, 2.5], 5);
|
|
1023
|
+
|
|
1024
|
+
let marker = null;
|
|
1025
|
+
function setMarker(lat, lon, zoom) {
|
|
1026
|
+
const ll = [lat, lon];
|
|
1027
|
+
if (!marker) {
|
|
1028
|
+
marker = L.marker(ll, { draggable: true }).addTo(map);
|
|
1029
|
+
marker.on('dragend', () => {
|
|
1030
|
+
const p2 = marker.getLatLng();
|
|
1031
|
+
document.getElementById('onekite-pickup-lat').value = String(p2.lat);
|
|
1032
|
+
document.getElementById('onekite-pickup-lon').value = String(p2.lng);
|
|
1033
|
+
});
|
|
1034
|
+
} else {
|
|
1035
|
+
marker.setLatLng(ll);
|
|
1036
|
+
}
|
|
1037
|
+
document.getElementById('onekite-pickup-lat').value = String(lat);
|
|
1038
|
+
document.getElementById('onekite-pickup-lon').value = String(lon);
|
|
1039
|
+
if (zoom) {
|
|
1040
|
+
map.setView(ll, zoom);
|
|
1041
|
+
} else {
|
|
1042
|
+
map.panTo(ll);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
map.on('click', (e) => {
|
|
1047
|
+
if (e && e.latlng) {
|
|
1048
|
+
setMarker(e.latlng.lat, e.latlng.lng, map.getZoom());
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
const geocodeBtn = document.getElementById('onekite-geocode');
|
|
1053
|
+
const addrInput = document.getElementById('onekite-pickup-address');
|
|
1054
|
+
async function runGeocode() {
|
|
1055
|
+
try {
|
|
1056
|
+
const addr = (addrInput?.value || '').trim();
|
|
1057
|
+
if (!addr) return;
|
|
1058
|
+
const hit = await geocodeAddress(addr);
|
|
1059
|
+
if (!hit) {
|
|
1060
|
+
showAlert('error', 'Adresse introuvable.');
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
setMarker(hit.lat, hit.lon, 16);
|
|
1064
|
+
} catch (e) {
|
|
1065
|
+
showAlert('error', 'Recherche adresse impossible.');
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (geocodeBtn) geocodeBtn.addEventListener('click', runGeocode);
|
|
1069
|
+
if (addrInput) {
|
|
1070
|
+
addrInput.addEventListener('keydown', (e) => {
|
|
1071
|
+
if (e.key === 'Enter') {
|
|
1072
|
+
e.preventDefault();
|
|
1073
|
+
runGeocode();
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Autocomplete like on the calendar validation modal
|
|
1079
|
+
attachAddressAutocomplete(addrInput, (h) => {
|
|
1080
|
+
try {
|
|
1081
|
+
if (!h || !Number.isFinite(Number(h.lat)) || !Number.isFinite(Number(h.lon))) return;
|
|
1082
|
+
setMarker(Number(h.lat), Number(h.lon), 16);
|
|
1083
|
+
} catch (e) {}
|
|
1084
|
+
});
|
|
1085
|
+
} catch (e) {
|
|
1086
|
+
// ignore
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
} catch (e) {
|
|
1091
|
+
showAlert('error', 'Action impossible.');
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Purge
|
|
1097
|
+
const purgeBtn = document.getElementById('onekite-purge');
|
|
1098
|
+
if (purgeBtn) {
|
|
1099
|
+
purgeBtn.addEventListener('click', async () => {
|
|
1100
|
+
const yearInput = document.getElementById('onekite-purge-year');
|
|
1101
|
+
const year = (yearInput ? yearInput.value : '').trim();
|
|
1102
|
+
if (!/^\d{4}$/.test(year)) {
|
|
1103
|
+
showAlert('error', 'Année invalide (YYYY)');
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
bootbox.confirm(`Purger toutes les réservations de ${year} ?`, async (ok) => {
|
|
1107
|
+
if (!ok) return;
|
|
1108
|
+
try {
|
|
1109
|
+
const r = await purge(year);
|
|
1110
|
+
showAlert('success', `Purge OK (${r.removed || 0} supprimées).`);
|
|
1111
|
+
await refreshPending();
|
|
1112
|
+
} catch (e) {
|
|
1113
|
+
showAlert('error', 'Purge impossible.');
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Purge special events by year
|
|
1120
|
+
const sePurgeBtn = document.getElementById('onekite-se-purge');
|
|
1121
|
+
if (sePurgeBtn) {
|
|
1122
|
+
sePurgeBtn.addEventListener('click', async () => {
|
|
1123
|
+
const yearInput = document.getElementById('onekite-se-purge-year');
|
|
1124
|
+
const year = (yearInput ? yearInput.value : '').trim();
|
|
1125
|
+
if (!/^\d{4}$/.test(year)) {
|
|
1126
|
+
showAlert('error', 'Année invalide (YYYY)');
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
bootbox.confirm(`Purger tous les évènements de ${year} ?`, async (ok) => {
|
|
1130
|
+
if (!ok) return;
|
|
1131
|
+
try {
|
|
1132
|
+
const r = await purgeSpecialEvents(year);
|
|
1133
|
+
showAlert('success', `Purge OK (${r.removed || 0} supprimé(s)).`);
|
|
1134
|
+
} catch (e) {
|
|
1135
|
+
showAlert('error', 'Purge impossible.');
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Debug
|
|
1142
|
+
const debugBtn = document.getElementById('onekite-debug-run');
|
|
1143
|
+
if (debugBtn) {
|
|
1144
|
+
debugBtn.addEventListener('click', async () => {
|
|
1145
|
+
const out = document.getElementById('onekite-debug-output');
|
|
1146
|
+
if (out) out.textContent = 'Chargement...';
|
|
1147
|
+
try {
|
|
1148
|
+
const result = await debugHelloAsso();
|
|
1149
|
+
if (out) out.textContent = JSON.stringify(result, null, 2);
|
|
1150
|
+
const catalogCount = result && result.catalog ? parseInt(result.catalog.count, 10) || 0 : 0;
|
|
1151
|
+
const catalogOk = !!(result && result.catalog && result.catalog.ok);
|
|
1152
|
+
// Accept "count > 0" even if ok flag is false (some proxies can strip fields, etc.)
|
|
1153
|
+
if (catalogOk || catalogCount > 0) {
|
|
1154
|
+
showAlert('success', `Catalogue HelloAsso: ${catalogCount} item(s)`);
|
|
1155
|
+
} else {
|
|
1156
|
+
showAlert('error', 'HelloAsso: impossible de récupérer le catalogue.');
|
|
1157
|
+
}
|
|
1158
|
+
} catch (e) {
|
|
1159
|
+
if (out) out.textContent = String(e && e.message ? e.message : e);
|
|
1160
|
+
showAlert('error', 'Debug impossible.');
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Accounting (paid reservations)
|
|
1166
|
+
const accFrom = document.getElementById('onekite-acc-from');
|
|
1167
|
+
const accTo = document.getElementById('onekite-acc-to');
|
|
1168
|
+
const accRefresh = document.getElementById('onekite-acc-refresh');
|
|
1169
|
+
const accExport = document.getElementById('onekite-acc-export');
|
|
1170
|
+
const accPurge = document.getElementById('onekite-acc-purge');
|
|
1171
|
+
const accSummary = document.querySelector('#onekite-acc-summary tbody');
|
|
1172
|
+
const accRows = document.querySelector('#onekite-acc-rows tbody');
|
|
1173
|
+
const accFreeRows = document.querySelector('#onekite-acc-free-rows tbody');
|
|
1174
|
+
|
|
1175
|
+
function ymd(d) {
|
|
1176
|
+
const yyyy = d.getUTCFullYear();
|
|
1177
|
+
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
1178
|
+
const dd = String(d.getUTCDate()).padStart(2, '0');
|
|
1179
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
1180
|
+
}
|
|
1181
|
+
if (accFrom && accTo) {
|
|
1182
|
+
const now = new Date();
|
|
1183
|
+
const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
|
|
1184
|
+
const from = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1));
|
|
1185
|
+
if (!accFrom.value) accFrom.value = ymd(from);
|
|
1186
|
+
if (!accTo.value) accTo.value = ymd(to);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function renderAccounting(payload) {
|
|
1190
|
+
if (accSummary) accSummary.innerHTML = '';
|
|
1191
|
+
if (accRows) accRows.innerHTML = '';
|
|
1192
|
+
if (accFreeRows) accFreeRows.innerHTML = '';
|
|
1193
|
+
if (!payload || !payload.ok) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
(payload.summary || []).forEach((s) => {
|
|
1198
|
+
const tr = document.createElement('tr');
|
|
1199
|
+
if (s.isFree) {
|
|
1200
|
+
tr.innerHTML = `<td><em>${escapeHtml(s.item)}</em></td><td>${escapeHtml(String(s.count || 0))}</td><td>-</td>`;
|
|
1201
|
+
} else {
|
|
1202
|
+
tr.innerHTML = `<td>${escapeHtml(s.item)}</td><td>${escapeHtml(String(s.count || 0))}</td><td>${escapeHtml((Number(s.total) || 0).toFixed(2))}</td>`;
|
|
1203
|
+
}
|
|
1204
|
+
accSummary && accSummary.appendChild(tr);
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
(payload.rows || []).forEach((r) => {
|
|
1208
|
+
const tr = document.createElement('tr');
|
|
1209
|
+
const user = r.username ? `<a href="/user/${encodeURIComponent(r.username)}" target="_blank">${escapeHtml(r.username)}</a>${r.isFree ? ' <em>(gratuit)</em>' : ''}` : (r.isFree ? '<em>(gratuit)</em>' : '');
|
|
1210
|
+
const items = Array.isArray(r.items) ? r.items.map((x) => escapeHtml(x)).join('<br>') : '';
|
|
1211
|
+
const totalCell = r.isFree ? '-' : escapeHtml((Number(r.total) || 0).toFixed(2));
|
|
1212
|
+
if (r.isFree) {
|
|
1213
|
+
tr.innerHTML = `<td>${escapeHtml(r.startDate)} → ${escapeHtml(r.endDate)}</td><td>${user}</td><td>${items}</td><td><code>${escapeHtml(r.rid)}</code></td>`;
|
|
1214
|
+
accFreeRows && accFreeRows.appendChild(tr);
|
|
1215
|
+
} else {
|
|
1216
|
+
tr.innerHTML = `<td>${escapeHtml(r.startDate)} → ${escapeHtml(r.endDate)}</td><td>${user}</td><td>${items}</td><td>${totalCell}</td><td><code>${escapeHtml(r.rid)}</code></td>`;
|
|
1217
|
+
accRows && accRows.appendChild(tr);
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
async function refreshAccounting() {
|
|
1223
|
+
if (!accRefresh) return;
|
|
1224
|
+
try {
|
|
1225
|
+
const from = accFrom ? accFrom.value : '';
|
|
1226
|
+
const to = accTo ? accTo.value : '';
|
|
1227
|
+
accRefresh.disabled = true;
|
|
1228
|
+
const payload = await loadAccounting(from, to);
|
|
1229
|
+
renderAccounting(payload);
|
|
1230
|
+
} catch (e) {
|
|
1231
|
+
showAlert('error', 'Impossible de charger la comptabilisation.');
|
|
1232
|
+
} finally {
|
|
1233
|
+
accRefresh.disabled = false;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (accRefresh) {
|
|
1238
|
+
accRefresh.addEventListener('click', refreshAccounting);
|
|
1239
|
+
// Load once on init
|
|
1240
|
+
refreshAccounting();
|
|
1241
|
+
}
|
|
1242
|
+
if (accExport) {
|
|
1243
|
+
accExport.addEventListener('click', () => {
|
|
1244
|
+
const params = new URLSearchParams();
|
|
1245
|
+
if (accFrom && accFrom.value) params.set('from', accFrom.value);
|
|
1246
|
+
if (accTo && accTo.value) params.set('to', accTo.value);
|
|
1247
|
+
const qs = params.toString();
|
|
1248
|
+
const url = `/api/v3/admin/plugins/calendar-onekite/accounting.csv${qs ? `?${qs}` : ''}`;
|
|
1249
|
+
window.open(url, '_blank');
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
if (accPurge) {
|
|
1253
|
+
accPurge.addEventListener('click', async () => {
|
|
1254
|
+
const ok = window.confirm('Purger la comptabilité pour la période sélectionnée ?\nCela masquera ces réservations dans l\'onglet Comptabilisation (sans modifier leur statut payé).');
|
|
1255
|
+
if (!ok) return;
|
|
1256
|
+
try {
|
|
1257
|
+
const params = new URLSearchParams();
|
|
1258
|
+
if (accFrom && accFrom.value) params.set('from', accFrom.value);
|
|
1259
|
+
if (accTo && accTo.value) params.set('to', accTo.value);
|
|
1260
|
+
const qs = params.toString();
|
|
1261
|
+
const url = `/api/v3/admin/plugins/calendar-onekite/accounting/purge${qs ? `?${qs}` : ''}`;
|
|
1262
|
+
const res = await fetchJson(url, { method: 'POST' });
|
|
1263
|
+
if (res && res.ok) {
|
|
1264
|
+
showAlert('success', `Compta purgée : ${res.purged || 0} réservation(s).`);
|
|
1265
|
+
// Refresh accounting tables after purge
|
|
1266
|
+
try {
|
|
1267
|
+
const from = accFrom ? accFrom.value : '';
|
|
1268
|
+
const to = accTo ? accTo.value : '';
|
|
1269
|
+
const data = await loadAccounting(from, to);
|
|
1270
|
+
renderAccounting(data);
|
|
1271
|
+
} catch (e) {
|
|
1272
|
+
// If refresh fails, keep the success message and show a soft warning
|
|
1273
|
+
showAlert('error', 'Compta purgée, mais rafraîchissement impossible.');
|
|
1274
|
+
}
|
|
1275
|
+
} else {
|
|
1276
|
+
showAlert('error', 'Purge impossible.');
|
|
1277
|
+
}
|
|
1278
|
+
} catch (e) {
|
|
1279
|
+
showAlert('error', 'Purge impossible.');
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// --------------------
|
|
1287
|
+
// Maintenance (simple ON/OFF)
|
|
1288
|
+
// --------------------
|
|
1289
|
+
const maintSearch = document.getElementById('onekite-maint-search');
|
|
1290
|
+
const maintRefresh = document.getElementById('onekite-maint-refresh');
|
|
1291
|
+
const maintAllOn = document.getElementById('onekite-maint-all-on');
|
|
1292
|
+
const maintAllOff = document.getElementById('onekite-maint-all-off');
|
|
1293
|
+
const maintTableBody = document.querySelector('#onekite-maint-table tbody');
|
|
1294
|
+
|
|
1295
|
+
let maintItemsCache = [];
|
|
1296
|
+
async function loadItemsWithMaintenance() {
|
|
1297
|
+
// Public items endpoint returns catalog + maintenance flag
|
|
1298
|
+
return await fetchJson('/api/v3/plugins/calendar-onekite/items');
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function renderMaintenanceTable(items) {
|
|
1302
|
+
if (!maintTableBody) return;
|
|
1303
|
+
const q = maintSearch ? String(maintSearch.value || '').trim().toLowerCase() : '';
|
|
1304
|
+
const filtered = (items || []).filter((it) => {
|
|
1305
|
+
if (!it) return false;
|
|
1306
|
+
if (!q) return true;
|
|
1307
|
+
return String(it.name || '').toLowerCase().includes(q);
|
|
1308
|
+
});
|
|
1309
|
+
maintTableBody.innerHTML = filtered.map((it) => {
|
|
1310
|
+
const id = String(it.id);
|
|
1311
|
+
const name = String(it.name || '').replace(/</g, '<').replace(/>/g, '>');
|
|
1312
|
+
const checked = it.maintenance ? 'checked' : '';
|
|
1313
|
+
return `<tr data-itemid="${id}">
|
|
1314
|
+
<td>${name}</td>
|
|
1315
|
+
<td>
|
|
1316
|
+
<div class="form-check form-switch">
|
|
1317
|
+
<input class="form-check-input onekite-maint-toggle" type="checkbox" ${checked} />
|
|
1318
|
+
</div>
|
|
1319
|
+
</td>
|
|
1320
|
+
</tr>`;
|
|
1321
|
+
}).join('') || '<tr><td colspan="2" class="text-muted">Aucun matériel.</td></tr>';
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
async function refreshMaintenance() {
|
|
1325
|
+
try {
|
|
1326
|
+
const items = await loadItemsWithMaintenance();
|
|
1327
|
+
maintItemsCache = Array.isArray(items) ? items : [];
|
|
1328
|
+
renderMaintenanceTable(maintItemsCache);
|
|
1329
|
+
} catch (e) {
|
|
1330
|
+
showAlert('error', 'Impossible de charger le matériel (maintenance).');
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (maintRefresh) maintRefresh.addEventListener('click', refreshMaintenance);
|
|
1335
|
+
|
|
1336
|
+
async function setAllMaintenance(enabled) {
|
|
1337
|
+
const label = enabled ? 'mettre TOUS les matériels en maintenance' : 'enlever la maintenance sur TOUS les matériels';
|
|
1338
|
+
const ok = window.confirm(`Confirmer : ${label} ?`);
|
|
1339
|
+
if (!ok) return;
|
|
1340
|
+
try {
|
|
1341
|
+
await fetchJson('/api/v3/plugins/calendar-onekite/maintenance', {
|
|
1342
|
+
method: 'PUT',
|
|
1343
|
+
body: JSON.stringify({ enabled: !!enabled, all: true }),
|
|
1344
|
+
});
|
|
1345
|
+
// Update cache locally without refetching if possible
|
|
1346
|
+
maintItemsCache = maintItemsCache.map((it) => Object.assign({}, it, { maintenance: !!enabled }));
|
|
1347
|
+
renderMaintenanceTable(maintItemsCache);
|
|
1348
|
+
showAlert('success', enabled ? 'Tous les matériels sont en maintenance.' : 'Maintenance supprimée pour tous les matériels.');
|
|
1349
|
+
} catch (e) {
|
|
1350
|
+
showAlert('error', 'Impossible de modifier la maintenance pour tous les matériels.');
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
if (maintAllOn) maintAllOn.addEventListener('click', () => setAllMaintenance(true));
|
|
1355
|
+
if (maintAllOff) maintAllOff.addEventListener('click', () => setAllMaintenance(false));
|
|
1356
|
+
if (maintSearch) maintSearch.addEventListener('input', () => renderMaintenanceTable(maintItemsCache));
|
|
1357
|
+
if (maintTableBody) {
|
|
1358
|
+
maintTableBody.addEventListener('change', async (ev) => {
|
|
1359
|
+
const el = ev && ev.target;
|
|
1360
|
+
if (!el || !el.classList || !el.classList.contains('onekite-maint-toggle')) return;
|
|
1361
|
+
const tr = el.closest('tr');
|
|
1362
|
+
const itemId = tr ? tr.getAttribute('data-itemid') : '';
|
|
1363
|
+
const enabled = !!el.checked;
|
|
1364
|
+
if (!itemId) return;
|
|
1365
|
+
// Optimistic UI
|
|
1366
|
+
try {
|
|
1367
|
+
await fetchJson(`/api/v3/plugins/calendar-onekite/maintenance/${encodeURIComponent(String(itemId))}`, {
|
|
1368
|
+
method: 'PUT',
|
|
1369
|
+
body: JSON.stringify({ enabled }),
|
|
1370
|
+
});
|
|
1371
|
+
// Update cache
|
|
1372
|
+
maintItemsCache = maintItemsCache.map((it) => (String(it.id) === String(itemId) ? Object.assign({}, it, { maintenance: enabled }) : it));
|
|
1373
|
+
showAlert('success', enabled ? 'Maintenance activée.' : 'Maintenance désactivée.');
|
|
1374
|
+
} catch (e) {
|
|
1375
|
+
// Revert
|
|
1376
|
+
el.checked = !enabled;
|
|
1377
|
+
showAlert('error', 'Impossible de modifier la maintenance.');
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Load once
|
|
1383
|
+
if (maintTableBody) {
|
|
1384
|
+
refreshMaintenance();
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// --------------------
|
|
1388
|
+
// Audit (purge par année)
|
|
1389
|
+
// --------------------
|
|
1390
|
+
const auditYearEl = document.getElementById('onekite-audit-year');
|
|
1391
|
+
const auditSearchEl = document.getElementById('onekite-audit-search');
|
|
1392
|
+
const auditRefreshBtn = document.getElementById('onekite-audit-refresh');
|
|
1393
|
+
const auditPurgeBtn = document.getElementById('onekite-audit-purge');
|
|
1394
|
+
const auditTbody = document.querySelector('#onekite-audit-table tbody');
|
|
1395
|
+
|
|
1396
|
+
let auditCache = [];
|
|
1397
|
+
function fmtDateTime(ts) {
|
|
1398
|
+
try {
|
|
1399
|
+
return new Date(Number(ts) || 0).toLocaleString('fr-FR');
|
|
1400
|
+
} catch (e) {
|
|
1401
|
+
return String(ts || '');
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
function renderAudit(entries) {
|
|
1405
|
+
if (!auditTbody) return;
|
|
1406
|
+
const q = auditSearchEl ? String(auditSearchEl.value || '').trim().toLowerCase() : '';
|
|
1407
|
+
const rows = (entries || []).filter((e) => {
|
|
1408
|
+
if (!e) return false;
|
|
1409
|
+
if (!q) return true;
|
|
1410
|
+
const hay = [e.action, e.actorUsername, e.targetType, e.targetId, JSON.stringify(e)].join(' ').toLowerCase();
|
|
1411
|
+
return hay.includes(q);
|
|
1412
|
+
}).map((e) => {
|
|
1413
|
+
const actor = e.actorUsername ? `${e.actorUsername} (#${e.actorUid || 0})` : `#${e.actorUid || 0}`;
|
|
1414
|
+
const target = `${e.targetType || ''} ${e.targetId || ''}`.trim();
|
|
1415
|
+
const details = (() => {
|
|
1416
|
+
const parts = [];
|
|
1417
|
+
if (e.itemNames && Array.isArray(e.itemNames) && e.itemNames.length) parts.push(e.itemNames.join(', '));
|
|
1418
|
+
if (e.startDate && e.endDate) parts.push(`${e.startDate} → ${e.endDate}`);
|
|
1419
|
+
if (e.reason) parts.push(`Raison: ${e.reason}`);
|
|
1420
|
+
if (e.removed) parts.push(`Supprimés: ${e.removed}`);
|
|
1421
|
+
return parts.join(' — ');
|
|
1422
|
+
})();
|
|
1423
|
+
return `<tr>
|
|
1424
|
+
<td>${fmtDateTime(e.ts)}</td>
|
|
1425
|
+
<td>${String(actor).replace(/</g,'<').replace(/>/g,'>')}</td>
|
|
1426
|
+
<td>${String(e.action || '').replace(/</g,'<').replace(/>/g,'>')}</td>
|
|
1427
|
+
<td>${String(target).replace(/</g,'<').replace(/>/g,'>')}</td>
|
|
1428
|
+
<td class="text-muted">${String(details || '').replace(/</g,'<').replace(/>/g,'>')}</td>
|
|
1429
|
+
</tr>`;
|
|
1430
|
+
}).join('');
|
|
1431
|
+
auditTbody.innerHTML = rows || '<tr><td colspan="5" class="text-muted">Aucune entrée.</td></tr>';
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
async function refreshAudit() {
|
|
1435
|
+
if (!auditTbody) return;
|
|
1436
|
+
const y = Number((auditYearEl && auditYearEl.value) || new Date().getFullYear());
|
|
1437
|
+
try {
|
|
1438
|
+
const data = await fetchJson(`/api/v3/plugins/calendar-onekite/audit?year=${encodeURIComponent(String(y))}&limit=300`);
|
|
1439
|
+
auditCache = (data && data.entries) ? data.entries : [];
|
|
1440
|
+
renderAudit(auditCache);
|
|
1441
|
+
} catch (e) {
|
|
1442
|
+
showAlert('error', 'Impossible de charger l\'audit.');
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if (auditYearEl && !auditYearEl.value) {
|
|
1447
|
+
auditYearEl.value = String(new Date().getFullYear());
|
|
1448
|
+
}
|
|
1449
|
+
if (auditRefreshBtn) auditRefreshBtn.addEventListener('click', refreshAudit);
|
|
1450
|
+
if (auditSearchEl) auditSearchEl.addEventListener('input', () => renderAudit(auditCache));
|
|
1451
|
+
if (auditPurgeBtn) {
|
|
1452
|
+
auditPurgeBtn.addEventListener('click', async () => {
|
|
1453
|
+
const y = Number((auditYearEl && auditYearEl.value) || 0);
|
|
1454
|
+
if (!y) return;
|
|
1455
|
+
const ok = window.confirm(`Purger définitivement l’audit de l’année ${y} ?`);
|
|
1456
|
+
if (!ok) return;
|
|
1457
|
+
try {
|
|
1458
|
+
const res = await fetchJson('/api/v3/plugins/calendar-onekite/audit/purge', {
|
|
1459
|
+
method: 'POST',
|
|
1460
|
+
body: JSON.stringify({ year: y }),
|
|
1461
|
+
});
|
|
1462
|
+
showAlert('success', `Audit purgé : ${res && res.removed ? res.removed : 0} entrée(s).`);
|
|
1463
|
+
await refreshAudit();
|
|
1464
|
+
} catch (e) {
|
|
1465
|
+
showAlert('error', 'Purge audit impossible.');
|
|
1466
|
+
}
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Load once
|
|
1471
|
+
if (auditTbody) {
|
|
1472
|
+
refreshAudit();
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
return { init };
|
|
1477
|
+
});
|