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.
Files changed (35) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/admin.js +21 -9
  3. package/lib/api.js +235 -4
  4. package/lib/db.js +114 -0
  5. package/lib/helloassoWebhook.js +28 -0
  6. package/library.js +7 -0
  7. package/package.json +1 -1
  8. package/pkg/package/CHANGELOG.md +106 -0
  9. package/pkg/package/lib/admin.js +554 -0
  10. package/pkg/package/lib/api.js +1458 -0
  11. package/pkg/package/lib/controllers.js +11 -0
  12. package/pkg/package/lib/db.js +224 -0
  13. package/pkg/package/lib/discord.js +190 -0
  14. package/pkg/package/lib/helloasso.js +352 -0
  15. package/pkg/package/lib/helloassoWebhook.js +389 -0
  16. package/pkg/package/lib/scheduler.js +201 -0
  17. package/pkg/package/lib/widgets.js +460 -0
  18. package/pkg/package/library.js +164 -0
  19. package/pkg/package/package.json +14 -0
  20. package/pkg/package/plugin.json +43 -0
  21. package/pkg/package/public/admin.js +1477 -0
  22. package/pkg/package/public/client.js +2228 -0
  23. package/pkg/package/templates/admin/plugins/calendar-onekite.tpl +298 -0
  24. package/pkg/package/templates/calendar-onekite.tpl +51 -0
  25. package/pkg/package/templates/emails/calendar-onekite_approved.tpl +40 -0
  26. package/pkg/package/templates/emails/calendar-onekite_cancelled.tpl +15 -0
  27. package/pkg/package/templates/emails/calendar-onekite_expired.tpl +11 -0
  28. package/pkg/package/templates/emails/calendar-onekite_paid.tpl +15 -0
  29. package/pkg/package/templates/emails/calendar-onekite_pending.tpl +15 -0
  30. package/pkg/package/templates/emails/calendar-onekite_refused.tpl +15 -0
  31. package/pkg/package/templates/emails/calendar-onekite_reminder.tpl +20 -0
  32. package/plugin.json +1 -1
  33. package/public/admin.js +205 -4
  34. package/public/client.js +238 -7
  35. package/templates/admin/plugins/calendar-onekite.tpl +74 -0
package/public/admin.js CHANGED
@@ -1170,6 +1170,7 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
1170
1170
  const accPurge = document.getElementById('onekite-acc-purge');
1171
1171
  const accSummary = document.querySelector('#onekite-acc-summary tbody');
1172
1172
  const accRows = document.querySelector('#onekite-acc-rows tbody');
1173
+ const accFreeRows = document.querySelector('#onekite-acc-free-rows tbody');
1173
1174
 
1174
1175
  function ymd(d) {
1175
1176
  const yyyy = d.getUTCFullYear();
@@ -1188,22 +1189,33 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
1188
1189
  function renderAccounting(payload) {
1189
1190
  if (accSummary) accSummary.innerHTML = '';
1190
1191
  if (accRows) accRows.innerHTML = '';
1192
+ if (accFreeRows) accFreeRows.innerHTML = '';
1191
1193
  if (!payload || !payload.ok) {
1192
1194
  return;
1193
1195
  }
1194
1196
 
1195
1197
  (payload.summary || []).forEach((s) => {
1196
1198
  const tr = document.createElement('tr');
1197
- tr.innerHTML = `<td>${escapeHtml(s.item)}</td><td>${escapeHtml(String(s.count || 0))}</td><td>${escapeHtml((Number(s.total) || 0).toFixed(2))}</td>`;
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
+ }
1198
1204
  accSummary && accSummary.appendChild(tr);
1199
1205
  });
1200
1206
 
1201
1207
  (payload.rows || []).forEach((r) => {
1202
1208
  const tr = document.createElement('tr');
1203
- const user = r.username ? `<a href="/user/${encodeURIComponent(r.username)}" target="_blank">${escapeHtml(r.username)}</a>` : '';
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>' : '');
1204
1210
  const items = Array.isArray(r.items) ? r.items.map((x) => escapeHtml(x)).join('<br>') : '';
1205
- tr.innerHTML = `<td>${escapeHtml(r.startDate)} ${escapeHtml(r.endDate)}</td><td>${user}</td><td>${items}</td><td>${escapeHtml((Number(r.total) || 0).toFixed(2))}</td><td><code>${escapeHtml(r.rid)}</code></td>`;
1206
- accRows && accRows.appendChild(tr);
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
+ }
1207
1219
  });
1208
1220
  }
1209
1221
 
@@ -1270,6 +1282,195 @@ define('admin/plugins/calendar-onekite', ['alerts', 'bootbox'], function (alerts
1270
1282
  }
1271
1283
 
1272
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, '&lt;').replace(/>/g, '&gt;');
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,'&lt;').replace(/>/g,'&gt;')}</td>
1426
+ <td>${String(e.action || '').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</td>
1427
+ <td>${String(target).replace(/</g,'&lt;').replace(/>/g,'&gt;')}</td>
1428
+ <td class="text-muted">${String(details || '').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</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
+ }
1273
1474
  }
1274
1475
 
1275
1476
  return { init };
package/public/client.js CHANGED
@@ -41,7 +41,27 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
41
41
  .fc .onekite-day-disabled:hover {
42
42
  cursor: not-allowed;
43
43
  }
44
- `;
44
+
45
+ /* Mobile floating action button (FAB) */
46
+ @media (max-width: 768px) {
47
+ .onekite-fab {
48
+ position: fixed;
49
+ right: 16px;
50
+ bottom: 16px;
51
+ width: 56px;
52
+ height: 56px;
53
+ border-radius: 999px;
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ z-index: 1055;
58
+ box-shadow: 0 10px 22px rgba(0,0,0,.25);
59
+ }
60
+ .onekite-fab .fa, .onekite-fab .fas, .onekite-fab .fa-solid {
61
+ font-size: 20px;
62
+ }
63
+ }
64
+ `;
45
65
  document.head.appendChild(style);
46
66
  } catch (e) {
47
67
  // ignore
@@ -52,6 +72,16 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
52
72
  // interactions or quick re-renders.
53
73
  let isDialogOpen = false;
54
74
 
75
+ // Cached list of reservable items (loaded once per page init)
76
+ let cachedItems = null;
77
+
78
+ // Current FullCalendar instance (for refresh after actions)
79
+ let currentCalendar = null;
80
+
81
+ // Mobile FAB (mounted only on the calendar page)
82
+ let fabEl = null;
83
+ let fabHandler = null;
84
+
55
85
  // Prevent double taps/clicks on actions that trigger network calls (mobile especially).
56
86
  const actionLocks = new Map(); // key -> ts
57
87
  function lockAction(key, ms) {
@@ -848,6 +878,9 @@ function toDatetimeLocalValue(date) {
848
878
  // (no dependency on hours, timezone or DST).
849
879
  let days = calendarDaysExclusive(start, end);
850
880
 
881
+ // Maintenance is a simple ON/OFF flag per item (no dates).
882
+ const maintenance = new Set((items || []).filter(it => it && it.maintenance).map(it => String(it.id)));
883
+
851
884
  // Fetch existing events overlapping the selection to disable already reserved items.
852
885
  let blocked = new Set();
853
886
  try {
@@ -878,16 +911,18 @@ function toDatetimeLocalValue(date) {
878
911
 
879
912
  const rows = items.map((it, idx) => {
880
913
  const id = String(it.id);
881
- const disabled = blocked.has(id);
914
+ const isMaint = maintenance.has(id);
915
+ const disabled = blocked.has(id) || isMaint;
882
916
  const priceTxt = fmtPrice(it.price || 0);
883
917
  const safeName = String(it.name).replace(/</g, '&lt;').replace(/>/g, '&gt;');
918
+ const labelName = isMaint ? `🔧 ${safeName} (en maintenance)` : safeName;
884
919
  return `
885
920
  <div class="d-flex align-items-center py-1 ${disabled ? 'opacity-50' : ''}" data-itemid="${id}" style="border-bottom: 1px solid var(--bs-border-color, #ddd);">
886
921
  <div style="width: 26px;">
887
922
  <input type="checkbox" class="form-check-input onekite-item-cb" data-id="${id}" data-name="${safeName}" data-price="${String(it.price || 0)}" ${disabled ? 'disabled' : ''}>
888
923
  </div>
889
924
  <div class="flex-grow-1">
890
- <div><strong>${safeName}</strong></div>
925
+ <div><strong>${labelName}</strong></div>
891
926
  <div class="text-muted" style="font-size: 12px;">${priceTxt} € / jour</div>
892
927
  </div>
893
928
  </div>
@@ -1001,7 +1036,7 @@ function toDatetimeLocalValue(date) {
1001
1036
 
1002
1037
  document.querySelectorAll('#onekite-items [data-itemid]').forEach((row) => {
1003
1038
  const iid = row.getAttribute('data-itemid');
1004
- const dis = blocked.has(String(iid));
1039
+ const dis = blocked.has(String(iid)) || maintenance.has(String(iid));
1005
1040
  row.classList.toggle('opacity-50', dis);
1006
1041
  const cb = row.querySelector('input.onekite-item-cb');
1007
1042
  if (cb) {
@@ -1065,6 +1100,7 @@ function toDatetimeLocalValue(date) {
1065
1100
  }
1066
1101
 
1067
1102
  const items = await loadItems();
1103
+ cachedItems = items;
1068
1104
  const caps = await loadCapabilities().catch(() => ({}));
1069
1105
  const canCreateSpecial = !!caps.canCreateSpecial;
1070
1106
  const canDeleteSpecial = !!caps.canDeleteSpecial;
@@ -1303,14 +1339,18 @@ function toDatetimeLocalValue(date) {
1303
1339
  // If the user used "Durée rapide", the effective end date is held
1304
1340
  // inside the dialog (returned as `chosen.endDate`).
1305
1341
  const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
1306
- await requestReservation({
1342
+ const resp = await requestReservation({
1307
1343
  start: startDate,
1308
1344
  end: endDate,
1309
1345
  itemIds: chosen.itemIds,
1310
1346
  itemNames: chosen.itemNames,
1311
1347
  total: chosen.total,
1312
1348
  });
1313
- showAlert('success', 'Demande envoyée (en attente de validation).');
1349
+ if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
1350
+ showAlert('success', 'Réservation confirmée.');
1351
+ } else {
1352
+ showAlert('success', 'Demande envoyée (en attente de validation).');
1353
+ }
1314
1354
  invalidateEventsCache();
1315
1355
  scheduleRefetch(calendar);
1316
1356
  calendar.unselect();
@@ -1865,6 +1905,7 @@ function toDatetimeLocalValue(date) {
1865
1905
 
1866
1906
  // Expose for live updates
1867
1907
  try { window.oneKiteCalendar = calendar; } catch (e) {}
1908
+ currentCalendar = calendar;
1868
1909
 
1869
1910
  calendar.render();
1870
1911
 
@@ -1959,11 +2000,201 @@ function toDatetimeLocalValue(date) {
1959
2000
  }
1960
2001
 
1961
2002
  // Auto-init on /calendar when ajaxify finishes rendering.
1962
- function autoInit(data) {
2003
+
2004
+ function unmountMobileFab() {
2005
+ try {
2006
+ if (fabEl) {
2007
+ if (fabHandler) fabEl.removeEventListener('click', fabHandler);
2008
+ fabEl.remove();
2009
+ }
2010
+ } catch (e) {}
2011
+ fabEl = null;
2012
+ fabHandler = null;
2013
+ }
2014
+
2015
+ function formatDdMmYyyy(d) {
2016
+ const pad = (n) => String(n).padStart(2, '0');
2017
+ return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
2018
+ }
2019
+
2020
+ function parseDdMmYyyy(s) {
2021
+ const m = String(s || '').trim().match(/^([0-3]\d)\/([0-1]\d)\/(\d{4})$/);
2022
+ if (!m) return null;
2023
+ const day = parseInt(m[1], 10);
2024
+ const month = parseInt(m[2], 10);
2025
+ const year = parseInt(m[3], 10);
2026
+ if (month < 1 || month > 12) return null;
2027
+ const d = new Date(year, month - 1, day, 0, 0, 0, 0);
2028
+ // Validate (Date will overflow on invalid day)
2029
+ if (d.getFullYear() !== year || (d.getMonth() + 1) !== month || d.getDate() !== day) return null;
2030
+ return d;
2031
+ }
2032
+
2033
+ async function openFabDatePicker() {
2034
+ if (!lockAction('fab-date-picker', 700)) return;
2035
+
2036
+ // Defaults: tomorrow -> tomorrow (cannot book today or past)
2037
+ const today = new Date();
2038
+ today.setHours(0, 0, 0, 0);
2039
+ const minStart = new Date(today);
2040
+ minStart.setDate(minStart.getDate() + 1);
2041
+
2042
+ const html = `
2043
+ <div class="onekite-fab-dates">
2044
+ <div class="mb-2">
2045
+ <label class="form-label">Date de début</label>
2046
+ <input class="form-control" type="date" id="onekite-fab-start" autocomplete="off" />
2047
+ </div>
2048
+ <div class="mb-2">
2049
+ <label class="form-label">Date de fin (incluse)</label>
2050
+ <input class="form-control" type="date" id="onekite-fab-end" autocomplete="off" />
2051
+ <div class="form-text">Sélectionne une période (la date de fin est incluse).</div>
2052
+ </div>
2053
+ </div>
2054
+ `;
2055
+
2056
+ const dlg = bootbox.dialog({
2057
+ title: 'Choisir des dates',
2058
+ message: html,
2059
+ buttons: {
2060
+ cancel: { label: 'Annuler', className: 'btn-secondary' },
2061
+ ok: {
2062
+ label: 'Continuer',
2063
+ className: 'btn-primary',
2064
+ callback: function () {
2065
+ const sStr = String(document.getElementById('onekite-fab-start').value || '');
2066
+ const eStr = String(document.getElementById('onekite-fab-end').value || '');
2067
+ const s = parseYmdDate(sStr);
2068
+ const e = parseYmdDate(eStr);
2069
+ if (!s || !e) {
2070
+ alerts.error('Dates invalides.');
2071
+ return false;
2072
+ }
2073
+
2074
+ // Cannot book today or past
2075
+ if (s < minStart || e < minStart) {
2076
+ alerts.error('Impossible de réserver pour le jour même ou dans le passé.');
2077
+ return false;
2078
+ }
2079
+ if (e < s) {
2080
+ alerts.error('La date de fin doit être après la date de début.');
2081
+ return false;
2082
+ }
2083
+
2084
+ // Convert end inclusive -> end exclusive (FullCalendar rule)
2085
+ const endExcl = new Date(e);
2086
+ endExcl.setDate(endExcl.getDate() + 1);
2087
+
2088
+ // Open the standard reservation dialog
2089
+ (async () => {
2090
+ try {
2091
+ if (isDialogOpen) return;
2092
+ isDialogOpen = true;
2093
+ const items = cachedItems || (await loadItems());
2094
+ const chosen = await openReservationDialog({ start: s, end: endExcl }, items);
2095
+ if (chosen && chosen.itemIds && chosen.itemIds.length) {
2096
+ const startDate = toLocalYmd(s);
2097
+ const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(endExcl);
2098
+ const resp = await requestReservation({
2099
+ start: startDate,
2100
+ end: endDate,
2101
+ itemIds: chosen.itemIds,
2102
+ itemNames: chosen.itemNames,
2103
+ total: chosen.total,
2104
+ });
2105
+ if (resp && (resp.autoPaid || String(resp.status) === 'paid')) {
2106
+ showAlert('success', 'Réservation confirmée.');
2107
+ } else {
2108
+ showAlert('success', 'Demande envoyée (en attente de validation).');
2109
+ }
2110
+ invalidateEventsCache();
2111
+ if (currentCalendar) scheduleRefetch(currentCalendar);
2112
+ }
2113
+ } catch (err) {
2114
+ // ignore
2115
+ } finally {
2116
+ isDialogOpen = false;
2117
+ }
2118
+ })();
2119
+
2120
+ // close picker modal
2121
+ return true;
2122
+ },
2123
+ },
2124
+ },
2125
+ });
2126
+
2127
+ dlg.on('shown.bs.modal', () => {
2128
+ try {
2129
+ const startEl = document.getElementById('onekite-fab-start');
2130
+ const endEl = document.getElementById('onekite-fab-end');
2131
+ const minStr = toLocalYmd(minStart);
2132
+ if (startEl) {
2133
+ startEl.min = minStr;
2134
+ startEl.value = minStr;
2135
+ }
2136
+ if (endEl) {
2137
+ endEl.min = minStr;
2138
+ endEl.value = minStr;
2139
+ }
2140
+ if (startEl && endEl) {
2141
+ startEl.addEventListener('change', () => {
2142
+ try {
2143
+ const d = parseYmdDate(String(startEl.value || ''));
2144
+ if (!d) return;
2145
+ const minEnd = new Date(d);
2146
+ const minEndStr = toLocalYmd(minEnd);
2147
+ endEl.min = minEndStr;
2148
+ if (String(endEl.value || '') < minEndStr) endEl.value = minEndStr;
2149
+ } catch (e) {}
2150
+ });
2151
+ }
2152
+ if (startEl) startEl.focus();
2153
+ } catch (e) {}
2154
+ });
2155
+ }
2156
+
2157
+ function parseYmdDate(ymdStr) {
2158
+ // Expect YYYY-MM-DD (from <input type="date">)
2159
+ if (!ymdStr || typeof ymdStr !== 'string') return null;
2160
+ const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(ymdStr);
2161
+ if (!m) return null;
2162
+ const y = parseInt(m[1], 10);
2163
+ const mo = parseInt(m[2], 10);
2164
+ const d = parseInt(m[3], 10);
2165
+ if (!y || !mo || !d) return null;
2166
+ const dt = new Date(y, mo - 1, d);
2167
+ dt.setHours(0, 0, 0, 0);
2168
+ return dt;
2169
+ }
2170
+
2171
+ function mountMobileFab() {
2172
+ try {
2173
+ unmountMobileFab();
2174
+ // Only on mobile screens
2175
+ if (!window.matchMedia || !window.matchMedia('(max-width: 768px)').matches) return;
2176
+
2177
+ fabEl = document.createElement('button');
2178
+ fabEl.type = 'button';
2179
+ fabEl.className = 'btn btn-primary onekite-fab';
2180
+ fabEl.setAttribute('aria-label', 'Nouvelle réservation');
2181
+ fabEl.innerHTML = '<i class="fa fa-plus"></i>';
2182
+
2183
+ fabHandler = () => openFabDatePicker();
2184
+ fabEl.addEventListener('click', fabHandler);
2185
+
2186
+ document.body.appendChild(fabEl);
2187
+ } catch (e) {}
2188
+ }
2189
+
2190
+ function autoInit(data) {
1963
2191
  try {
1964
2192
  const tpl = data && data.template ? data.template.name : (ajaxify && ajaxify.data && ajaxify.data.template ? ajaxify.data.template.name : '');
1965
2193
  if (tpl === 'calendar-onekite') {
1966
2194
  init('#onekite-calendar');
2195
+ mountMobileFab();
2196
+ } else {
2197
+ unmountMobileFab();
1967
2198
  }
1968
2199
  } catch (e) {}
1969
2200
  }
@@ -20,6 +20,12 @@
20
20
  <li class="nav-item" role="presentation">
21
21
  <button class="nav-link" data-bs-toggle="tab" data-bs-target="#onekite-tab-accounting" type="button" role="tab">Comptabilisation</button>
22
22
  </li>
23
+ <li class="nav-item" role="presentation">
24
+ <button class="nav-link" data-bs-toggle="tab" data-bs-target="#onekite-tab-maintenance" type="button" role="tab">Maintenance</button>
25
+ </li>
26
+ <li class="nav-item" role="presentation">
27
+ <button class="nav-link" data-bs-toggle="tab" data-bs-target="#onekite-tab-audit" type="button" role="tab">Audit</button>
28
+ </li>
23
29
  </ul>
24
30
 
25
31
  <form id="onekite-settings-form">
@@ -53,6 +59,12 @@
53
59
  <div class="form-text">Après validation (statut <code>paiement en attente</code>), un rappel est envoyé après ce délai. La réservation est ensuite expirée après <strong>2×</strong> ce délai.</div>
54
60
  </div>
55
61
 
62
+ <div class="mb-3">
63
+ <label class="form-label">Location longue durée (jours) pour validateurs</label>
64
+ <input class="form-control" name="validatorFreeMaxDays" placeholder="0">
65
+ <div class="form-text">Les validateurs ne paient pas leurs propres locations <strong>jusqu’à</strong> ce nombre de jours (inclus). Au-delà, le workflow normal (demande → validation → paiement) s’applique. Mets <code>0</code> pour toujours gratuit.</div>
66
+ </div>
67
+
56
68
  <hr class="my-4" />
57
69
  <h4>Discord</h4>
58
70
 
@@ -207,6 +219,68 @@
207
219
  <tbody></tbody>
208
220
  </table>
209
221
  </div>
222
+
223
+ <h5 class="mt-4">Détails des sorties gratuites</h5>
224
+ <div class="form-text mb-2">Réservations gratuites (validateurs) : comptées séparément (hors chiffre d’affaires).</div>
225
+ <div class="table-responsive">
226
+ <table class="table table-sm" id="onekite-acc-free-rows">
227
+ <thead>
228
+ <tr><th>Date</th><th>Utilisateur</th><th>Matériel</th><th>RID</th></tr>
229
+ </thead>
230
+ <tbody></tbody>
231
+ </table>
232
+ </div>
233
+ </div>
234
+
235
+ <div class="tab-pane fade" id="onekite-tab-maintenance" role="tabpanel">
236
+ <h4>Maintenance (blocage manuel)</h4>
237
+ <div class="form-text mb-3">Active la maintenance sur un matériel pour le rendre indisponible (sans notion de dates). Les utilisateurs verront <strong>🔧 (en maintenance)</strong> dans la modale de réservation.</div>
238
+
239
+ <div class="d-flex flex-wrap gap-2 align-items-center mb-2">
240
+ <input class="form-control" id="onekite-maint-search" placeholder="Rechercher un matériel..." style="max-width: 420px;">
241
+ <button type="button" class="btn btn-outline-secondary" id="onekite-maint-refresh">Rafraîchir</button>
242
+ <div class="vr d-none d-md-block"></div>
243
+ <button type="button" class="btn btn-outline-warning" id="onekite-maint-all-on" title="Active la maintenance sur tous les matériels">Tout mettre en maintenance</button>
244
+ <button type="button" class="btn btn-outline-success" id="onekite-maint-all-off" title="Désactive la maintenance sur tous les matériels">Tout enlever de maintenance</button>
245
+ </div>
246
+
247
+ <div class="table-responsive">
248
+ <table class="table table-sm" id="onekite-maint-table">
249
+ <thead>
250
+ <tr><th>Matériel</th><th style="width: 140px;">Maintenance</th></tr>
251
+ </thead>
252
+ <tbody></tbody>
253
+ </table>
254
+ </div>
255
+ </div>
256
+
257
+ <div class="tab-pane fade" id="onekite-tab-audit" role="tabpanel">
258
+ <h4>Journal d’audit</h4>
259
+ <div class="form-text mb-3">Historique des actions (demandes, validations, refus, annulations, maintenance). Purge possible par année.</div>
260
+
261
+ <div class="d-flex flex-wrap gap-2 align-items-end mb-3">
262
+ <div>
263
+ <label class="form-label">Année</label>
264
+ <input class="form-control" id="onekite-audit-year" placeholder="YYYY" style="max-width: 160px;">
265
+ </div>
266
+ <div>
267
+ <label class="form-label">Recherche</label>
268
+ <input class="form-control" id="onekite-audit-search" placeholder="pseudo, matériel, action..." style="max-width: 360px;">
269
+ </div>
270
+ <div>
271
+ <button type="button" class="btn btn-primary" id="onekite-audit-refresh">Rafraîchir</button>
272
+ <button type="button" class="btn btn-outline-danger" id="onekite-audit-purge">Purger l’année</button>
273
+ </div>
274
+ </div>
275
+
276
+ <div class="table-responsive">
277
+ <table class="table table-sm" id="onekite-audit-table">
278
+ <thead>
279
+ <tr><th>Date</th><th>Acteur</th><th>Action</th><th>Cible</th><th>Détails</th></tr>
280
+ </thead>
281
+ <tbody></tbody>
282
+ </table>
283
+ </div>
210
284
  </div>
211
285
  </div>
212
286
  </form>