sprint-settings.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. /*
  2. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  3. * SPDX-License-Identifier: Apache-2.0
  4. *
  5. * Licensed under the Apache License, Version 2.0 (the "License");
  6. * you may not use this file except in compliance with the License.
  7. * See the LICENSE file in the project root for the full license text.
  8. */
  9. /**
  10. * /sprints/{id}/settings — vanilla JS + SortableJS.
  11. * - debounced PATCH /sprints/{id} for sprint meta on change; when the
  12. * response signals weeks were resynced, reload to pick up the new rows
  13. * - PATCH /sprints/{id}/week/{week_id} for per-week weekday mask
  14. * - POST /sprints/{id}/workers for adding a worker, DELETE for removing
  15. * - PATCH /sprints/{id}/workers/{sw_id} for RTB and reorder
  16. * - SortableJS replaces jQuery UI sortable on the in-sprint worker list
  17. */
  18. (function () {
  19. 'use strict';
  20. const root = document.querySelector('[data-sprint-root]');
  21. if (!root) { return; }
  22. const sprintId = parseInt(root.getAttribute('data-sprint-id'), 10);
  23. const csrf = String(root.getAttribute('data-csrf') || '');
  24. function qs(sel, ctx) { return (ctx || root).querySelector(sel); }
  25. function qsa(sel, ctx) { return Array.from((ctx || root).querySelectorAll(sel)); }
  26. function on(ctx, ev, sel, fn) {
  27. ctx.addEventListener(ev, function (e) {
  28. const t = e.target.closest(sel);
  29. if (t && ctx.contains(t)) { fn.call(t, e, t); }
  30. });
  31. }
  32. function request(method, url, body) {
  33. const opts = {
  34. method,
  35. headers: { Accept: 'application/json', 'X-CSRF-Token': csrf },
  36. credentials: 'same-origin',
  37. };
  38. if (body !== undefined) {
  39. opts.headers['Content-Type'] = 'application/json';
  40. opts.body = JSON.stringify(body);
  41. }
  42. return fetch(url, opts).then(async function (res) {
  43. let payload = null;
  44. try { payload = await res.json(); } catch (_) { /* ignore */ }
  45. if (!res.ok || !payload || payload.ok !== true) {
  46. const msg = (payload && payload.error && payload.error.message)
  47. ? payload.error.message
  48. : (res.statusText || 'Request failed');
  49. const err = new Error(msg);
  50. err.status = res.status;
  51. err.payload = payload;
  52. throw err;
  53. }
  54. return payload.data;
  55. });
  56. }
  57. const statusEl = qs('[data-status]');
  58. const successCls = ['text-green-700', 'bg-green-50', 'border-green-200'];
  59. const errorCls = ['text-red-700', 'bg-red-50', 'border-red-200'];
  60. let statusTimer = null;
  61. function flash(text, isError) {
  62. if (!statusEl) { return; }
  63. statusEl.textContent = text;
  64. successCls.concat(errorCls).forEach((c) => statusEl.classList.remove(c));
  65. (isError ? errorCls : successCls).forEach((c) => statusEl.classList.add(c));
  66. statusEl.classList.remove('opacity-0');
  67. statusEl.classList.add('opacity-100');
  68. clearTimeout(statusTimer);
  69. statusTimer = setTimeout(function () {
  70. statusEl.classList.remove('opacity-100');
  71. statusEl.classList.add('opacity-0');
  72. }, 2500);
  73. }
  74. // ---- Sprint meta ----------------------------------------------------
  75. function patchMeta(payload) {
  76. return request('PATCH', '/sprints/' + sprintId, payload)
  77. .then(function (data) {
  78. flash('Saved');
  79. // Server resyncs the week set when start_date/end_date change.
  80. // Reload so the table reflects added/removed rows.
  81. if (data && data.weeks_synced) {
  82. window.location.reload();
  83. }
  84. })
  85. .catch((e) => flash(e.message, true));
  86. }
  87. const metaDebounce = {};
  88. on(root, 'change', '[data-meta]', function () {
  89. const field = this.getAttribute('name');
  90. let v = this.value;
  91. if (field === 'reserve_fraction') { v = Number(v) / 100; }
  92. clearTimeout(metaDebounce[field]);
  93. metaDebounce[field] = setTimeout(function () {
  94. const payload = {}; payload[field] = v;
  95. patchMeta(payload);
  96. }, 0);
  97. });
  98. // ---- Per-week weekday checkboxes (Phase 12) ------------------------
  99. function maskFromRow(row) {
  100. let mask = 0;
  101. qsa('[data-day-toggle]', row).forEach(function (cb) {
  102. if (cb.checked) {
  103. const bit = parseInt(cb.getAttribute('data-bit'), 10);
  104. if (Number.isInteger(bit)) { mask |= (1 << bit); }
  105. }
  106. });
  107. return mask;
  108. }
  109. function popcount5(mask) {
  110. let n = 0;
  111. for (let i = 0; i < 5; i++) { if ((mask >> i) & 1) { n++; } }
  112. return n;
  113. }
  114. const weekDebounce = {};
  115. on(root, 'change', '[data-day-toggle]', function () {
  116. const row = this.closest('[data-week-row]');
  117. const weekId = parseInt(row.getAttribute('data-week-id'), 10);
  118. const mask = maskFromRow(row);
  119. const cnt = qs('[data-week-count]', row);
  120. if (cnt) { cnt.textContent = String(popcount5(mask)); }
  121. clearTimeout(weekDebounce[weekId]);
  122. weekDebounce[weekId] = setTimeout(function () {
  123. request('PATCH', '/sprints/' + sprintId + '/week/' + weekId, { active_days_mask: mask })
  124. .then(function (data) {
  125. if (data && data.sprint_week && cnt) {
  126. cnt.textContent = String(data.sprint_week.max_working_days);
  127. }
  128. flash('Saved');
  129. })
  130. .catch((e) => flash(e.message, true));
  131. }, 250);
  132. });
  133. // ---- Worker picker --------------------------------------------------
  134. const available = qs('[data-available]');
  135. const inSprint = qs('[data-in-sprint]');
  136. function workerRowTemplate(sw) {
  137. const li = document.createElement('li');
  138. li.className = 'flex items-center gap-2 px-3 py-2 border-b bg-white last:border-b-0 dark:bg-slate-800 dark:border-slate-700';
  139. li.setAttribute('data-sw-id', String(sw.id));
  140. li.setAttribute('data-worker-id', String(sw.worker_id));
  141. const handle = document.createElement('span');
  142. handle.className = 'handle cursor-grab text-slate-400 select-none dark:text-slate-500';
  143. handle.innerHTML = '&#8801;';
  144. const name = document.createElement('span');
  145. name.className = 'flex-1';
  146. name.textContent = sw.worker_name || '';
  147. const rtb = document.createElement('input');
  148. rtb.type = 'number'; rtb.step = '0.05'; rtb.min = '0'; rtb.max = '1';
  149. rtb.value = Number(sw.rtb).toFixed(2);
  150. rtb.setAttribute('data-rtb', '');
  151. rtb.className = 'w-20 rounded border border-slate-300 px-2 py-1 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
  152. const remove = document.createElement('button');
  153. remove.type = 'button';
  154. remove.setAttribute('data-remove', '');
  155. remove.className = 'text-sm text-red-600 hover:underline dark:text-red-400';
  156. remove.textContent = 'Remove';
  157. li.appendChild(handle);
  158. li.appendChild(name);
  159. li.appendChild(rtb);
  160. li.appendChild(remove);
  161. return li;
  162. }
  163. function availableRowTemplate(worker) {
  164. const li = document.createElement('li');
  165. li.className = 'flex items-center gap-2 px-3 py-2 border-b last:border-b-0 dark:border-slate-700';
  166. li.setAttribute('data-worker-id', String(worker.id));
  167. const name = document.createElement('span');
  168. name.className = 'flex-1';
  169. name.textContent = worker.name || '';
  170. const add = document.createElement('button');
  171. add.type = 'button';
  172. add.setAttribute('data-add', '');
  173. add.className = 'text-sm text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300';
  174. add.textContent = 'Add →';
  175. li.appendChild(name);
  176. li.appendChild(add);
  177. return li;
  178. }
  179. if (available) {
  180. on(available, 'click', '[data-add]', function () {
  181. const li = this.closest('li');
  182. const workerId = parseInt(li.getAttribute('data-worker-id'), 10);
  183. const span = li.querySelector('span.flex-1');
  184. const name = span ? span.textContent : '';
  185. request('POST', '/sprints/' + sprintId + '/workers', { worker_id: workerId })
  186. .then(function (data) {
  187. const sw = data.sprint_worker;
  188. sw.worker_name = sw.worker_name || name;
  189. inSprint.appendChild(workerRowTemplate(sw));
  190. li.remove();
  191. flash('Worker added');
  192. refreshEmptyStates();
  193. })
  194. .catch((e) => flash(e.message, true));
  195. });
  196. }
  197. if (inSprint) {
  198. on(inSprint, 'click', '[data-remove]', function () {
  199. const li = this.closest('li');
  200. const swId = parseInt(li.getAttribute('data-sw-id'), 10);
  201. const workerId = parseInt(li.getAttribute('data-worker-id'), 10);
  202. const span = li.querySelector('span.flex-1');
  203. const name = span ? span.textContent : '';
  204. request('DELETE', '/sprints/' + sprintId + '/workers/' + swId)
  205. .then(function () {
  206. li.remove();
  207. available.appendChild(availableRowTemplate({ id: workerId, name }));
  208. flash('Worker removed');
  209. refreshEmptyStates();
  210. })
  211. .catch((e) => flash(e.message, true));
  212. });
  213. on(inSprint, 'change', '[data-rtb]', function () {
  214. const li = this.closest('li');
  215. const swId = parseInt(li.getAttribute('data-sw-id'), 10);
  216. let v = Number(this.value);
  217. if (Number.isNaN(v) || v < 0 || v > 1) { flash('RTB must be 0–1', true); return; }
  218. v = Math.round(v * 20) / 20;
  219. this.value = v.toFixed(2);
  220. request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v })
  221. .then(() => flash('Saved'))
  222. .catch((e) => flash(e.message, true));
  223. });
  224. if (typeof window.Sortable === 'function') {
  225. window.Sortable.create(inSprint, {
  226. handle: '.handle',
  227. animation: 150,
  228. onEnd: function () {
  229. const ordering = qsa('li', inSprint).map(function (li, i) {
  230. return {
  231. sprint_worker_id: parseInt(li.getAttribute('data-sw-id'), 10),
  232. sort_order: i + 1,
  233. };
  234. });
  235. request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering)
  236. .then((data) => flash(data.moved ? 'Order saved' : 'No changes'))
  237. .catch((e) => flash(e.message, true));
  238. },
  239. });
  240. } else {
  241. // eslint-disable-next-line no-console
  242. console.warn('[sprint-settings] SortableJS not loaded — drag reorder disabled.');
  243. }
  244. }
  245. function refreshEmptyStates() {
  246. const ea = qs('[data-empty-available]');
  247. const es = qs('[data-empty-sprint]');
  248. if (ea && available) { ea.style.display = available.querySelectorAll('li').length === 0 ? '' : 'none'; }
  249. if (es && inSprint) { es.style.display = inSprint.querySelectorAll('li').length === 0 ? '' : 'none'; }
  250. }
  251. refreshEmptyStates();
  252. // ----- Danger zone: type-the-name confirmation gate -----------------
  253. //
  254. // The submit button stays disabled until the input matches the sprint
  255. // name verbatim. SprintController::delete repeats the check server-side
  256. // — this is just a UX guard, not the authoritative one.
  257. (function () {
  258. const form = document.querySelector('[data-delete-sprint-form]');
  259. if (!form) { return; }
  260. const expected = String(form.getAttribute('data-confirm-name') || '');
  261. const input = form.querySelector('[data-delete-confirm-input]');
  262. const btn = form.querySelector('[data-delete-confirm-btn]');
  263. if (!input || !btn) { return; }
  264. function refresh() {
  265. btn.disabled = String(input.value) !== expected;
  266. }
  267. input.addEventListener('input', refresh);
  268. refresh();
  269. form.addEventListener('submit', function (ev) {
  270. if (String(input.value) !== expected) {
  271. ev.preventDefault();
  272. return;
  273. }
  274. // Final native confirm so a misclick on the now-enabled button
  275. // doesn't fire the destructive POST.
  276. if (!window.confirm('Delete sprint "' + expected + '" and all its data? This cannot be undone.')) {
  277. ev.preventDefault();
  278. }
  279. });
  280. })();
  281. })();