sprint-settings.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. /**
  2. * /sprints/{id}/settings — vanilla JS + SortableJS.
  3. * Mirrors the previous jQuery/jQuery UI implementation feature-for-feature:
  4. * - debounced PATCH /sprints/{id} for sprint meta on change
  5. * - POST /sprints/{id}/weeks for week count resize (full reload on success)
  6. * - PATCH /sprints/{id}/week/{week_id} for per-week weekday mask
  7. * - POST /sprints/{id}/workers for adding a worker, DELETE for removing
  8. * - PATCH /sprints/{id}/workers/{sw_id} for RTB and reorder
  9. * - SortableJS replaces jQuery UI sortable on the in-sprint worker list
  10. */
  11. (function () {
  12. 'use strict';
  13. const root = document.querySelector('[data-sprint-root]');
  14. if (!root) { return; }
  15. const sprintId = parseInt(root.getAttribute('data-sprint-id'), 10);
  16. const csrf = String(root.getAttribute('data-csrf') || '');
  17. function qs(sel, ctx) { return (ctx || root).querySelector(sel); }
  18. function qsa(sel, ctx) { return Array.from((ctx || root).querySelectorAll(sel)); }
  19. function on(ctx, ev, sel, fn) {
  20. ctx.addEventListener(ev, function (e) {
  21. const t = e.target.closest(sel);
  22. if (t && ctx.contains(t)) { fn.call(t, e, t); }
  23. });
  24. }
  25. function request(method, url, body) {
  26. const opts = {
  27. method,
  28. headers: { Accept: 'application/json', 'X-CSRF-Token': csrf },
  29. credentials: 'same-origin',
  30. };
  31. if (body !== undefined) {
  32. opts.headers['Content-Type'] = 'application/json';
  33. opts.body = JSON.stringify(body);
  34. }
  35. return fetch(url, opts).then(async function (res) {
  36. let payload = null;
  37. try { payload = await res.json(); } catch (_) { /* ignore */ }
  38. if (!res.ok || !payload || payload.ok !== true) {
  39. const msg = (payload && payload.error && payload.error.message)
  40. ? payload.error.message
  41. : (res.statusText || 'Request failed');
  42. const err = new Error(msg);
  43. err.status = res.status;
  44. err.payload = payload;
  45. throw err;
  46. }
  47. return payload.data;
  48. });
  49. }
  50. const statusEl = qs('[data-status]');
  51. const successCls = ['text-green-700', 'bg-green-50', 'border-green-200'];
  52. const errorCls = ['text-red-700', 'bg-red-50', 'border-red-200'];
  53. let statusTimer = null;
  54. function flash(text, isError) {
  55. if (!statusEl) { return; }
  56. statusEl.textContent = text;
  57. successCls.concat(errorCls).forEach((c) => statusEl.classList.remove(c));
  58. (isError ? errorCls : successCls).forEach((c) => statusEl.classList.add(c));
  59. statusEl.classList.remove('opacity-0');
  60. statusEl.classList.add('opacity-100');
  61. clearTimeout(statusTimer);
  62. statusTimer = setTimeout(function () {
  63. statusEl.classList.remove('opacity-100');
  64. statusEl.classList.add('opacity-0');
  65. }, 2500);
  66. }
  67. // ---- Sprint meta ----------------------------------------------------
  68. function patchMeta(payload) {
  69. return request('PATCH', '/sprints/' + sprintId, payload)
  70. .then(() => flash('Saved'))
  71. .catch((e) => flash(e.message, true));
  72. }
  73. const metaDebounce = {};
  74. on(root, 'change', '[data-meta]', function () {
  75. const field = this.getAttribute('name');
  76. let v = this.value;
  77. if (field === 'reserve_fraction') { v = Number(v) / 100; }
  78. clearTimeout(metaDebounce[field]);
  79. metaDebounce[field] = setTimeout(function () {
  80. const payload = {}; payload[field] = v;
  81. patchMeta(payload);
  82. }, 0);
  83. });
  84. // ---- Week count resize ---------------------------------------------
  85. on(root, 'submit', '[data-weeks-form]', function (ev) {
  86. ev.preventDefault();
  87. const inp = this.querySelector('input[name="n_weeks"]');
  88. const n = parseInt(inp ? inp.value : '', 10);
  89. if (!Number.isInteger(n) || n < 1) {
  90. flash('Weeks must be a positive integer', true);
  91. return;
  92. }
  93. request('POST', '/sprints/' + sprintId + '/weeks', { n_weeks: n })
  94. .then(() => window.location.reload())
  95. .catch((e) => flash(e.message, true));
  96. });
  97. // ---- Per-week weekday checkboxes (Phase 12) ------------------------
  98. function maskFromRow(row) {
  99. let mask = 0;
  100. qsa('[data-day-toggle]', row).forEach(function (cb) {
  101. if (cb.checked) {
  102. const bit = parseInt(cb.getAttribute('data-bit'), 10);
  103. if (Number.isInteger(bit)) { mask |= (1 << bit); }
  104. }
  105. });
  106. return mask;
  107. }
  108. function popcount5(mask) {
  109. let n = 0;
  110. for (let i = 0; i < 5; i++) { if ((mask >> i) & 1) { n++; } }
  111. return n;
  112. }
  113. const weekDebounce = {};
  114. on(root, 'change', '[data-day-toggle]', function () {
  115. const row = this.closest('[data-week-row]');
  116. const weekId = parseInt(row.getAttribute('data-week-id'), 10);
  117. const mask = maskFromRow(row);
  118. const cnt = qs('[data-week-count]', row);
  119. if (cnt) { cnt.textContent = String(popcount5(mask)); }
  120. clearTimeout(weekDebounce[weekId]);
  121. weekDebounce[weekId] = setTimeout(function () {
  122. request('PATCH', '/sprints/' + sprintId + '/week/' + weekId, { active_days_mask: mask })
  123. .then(function (data) {
  124. if (data && data.sprint_week && cnt) {
  125. cnt.textContent = String(data.sprint_week.max_working_days);
  126. }
  127. flash('Saved');
  128. })
  129. .catch((e) => flash(e.message, true));
  130. }, 250);
  131. });
  132. // ---- Worker picker --------------------------------------------------
  133. const available = qs('[data-available]');
  134. const inSprint = qs('[data-in-sprint]');
  135. function workerRowTemplate(sw) {
  136. const li = document.createElement('li');
  137. 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';
  138. li.setAttribute('data-sw-id', String(sw.id));
  139. li.setAttribute('data-worker-id', String(sw.worker_id));
  140. const handle = document.createElement('span');
  141. handle.className = 'handle cursor-grab text-slate-400 select-none dark:text-slate-500';
  142. handle.innerHTML = '&#8801;';
  143. const name = document.createElement('span');
  144. name.className = 'flex-1';
  145. name.textContent = sw.worker_name || '';
  146. const rtb = document.createElement('input');
  147. rtb.type = 'number'; rtb.step = '0.05'; rtb.min = '0'; rtb.max = '1';
  148. rtb.value = Number(sw.rtb).toFixed(2);
  149. rtb.setAttribute('data-rtb', '');
  150. 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';
  151. const remove = document.createElement('button');
  152. remove.type = 'button';
  153. remove.setAttribute('data-remove', '');
  154. remove.className = 'text-sm text-red-600 hover:underline dark:text-red-400';
  155. remove.textContent = 'Remove';
  156. li.appendChild(handle);
  157. li.appendChild(name);
  158. li.appendChild(rtb);
  159. li.appendChild(remove);
  160. return li;
  161. }
  162. function availableRowTemplate(worker) {
  163. const li = document.createElement('li');
  164. li.className = 'flex items-center gap-2 px-3 py-2 border-b last:border-b-0 dark:border-slate-700';
  165. li.setAttribute('data-worker-id', String(worker.id));
  166. const name = document.createElement('span');
  167. name.className = 'flex-1';
  168. name.textContent = worker.name || '';
  169. const add = document.createElement('button');
  170. add.type = 'button';
  171. add.setAttribute('data-add', '');
  172. add.className = 'text-sm text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300';
  173. add.textContent = 'Add →';
  174. li.appendChild(name);
  175. li.appendChild(add);
  176. return li;
  177. }
  178. if (available) {
  179. on(available, 'click', '[data-add]', function () {
  180. const li = this.closest('li');
  181. const workerId = parseInt(li.getAttribute('data-worker-id'), 10);
  182. const span = li.querySelector('span.flex-1');
  183. const name = span ? span.textContent : '';
  184. request('POST', '/sprints/' + sprintId + '/workers', { worker_id: workerId })
  185. .then(function (data) {
  186. const sw = data.sprint_worker;
  187. sw.worker_name = sw.worker_name || name;
  188. inSprint.appendChild(workerRowTemplate(sw));
  189. li.remove();
  190. flash('Worker added');
  191. refreshEmptyStates();
  192. })
  193. .catch((e) => flash(e.message, true));
  194. });
  195. }
  196. if (inSprint) {
  197. on(inSprint, 'click', '[data-remove]', function () {
  198. const li = this.closest('li');
  199. const swId = parseInt(li.getAttribute('data-sw-id'), 10);
  200. const workerId = parseInt(li.getAttribute('data-worker-id'), 10);
  201. const span = li.querySelector('span.flex-1');
  202. const name = span ? span.textContent : '';
  203. request('DELETE', '/sprints/' + sprintId + '/workers/' + swId)
  204. .then(function () {
  205. li.remove();
  206. available.appendChild(availableRowTemplate({ id: workerId, name }));
  207. flash('Worker removed');
  208. refreshEmptyStates();
  209. })
  210. .catch((e) => flash(e.message, true));
  211. });
  212. on(inSprint, 'change', '[data-rtb]', function () {
  213. const li = this.closest('li');
  214. const swId = parseInt(li.getAttribute('data-sw-id'), 10);
  215. let v = Number(this.value);
  216. if (Number.isNaN(v) || v < 0 || v > 1) { flash('RTB must be 0–1', true); return; }
  217. v = Math.round(v * 20) / 20;
  218. this.value = v.toFixed(2);
  219. request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v })
  220. .then(() => flash('Saved'))
  221. .catch((e) => flash(e.message, true));
  222. });
  223. if (typeof window.Sortable === 'function') {
  224. window.Sortable.create(inSprint, {
  225. handle: '.handle',
  226. animation: 150,
  227. onEnd: function () {
  228. const ordering = qsa('li', inSprint).map(function (li, i) {
  229. return {
  230. sprint_worker_id: parseInt(li.getAttribute('data-sw-id'), 10),
  231. sort_order: i + 1,
  232. };
  233. });
  234. request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering)
  235. .then((data) => flash(data.moved ? 'Order saved' : 'No changes'))
  236. .catch((e) => flash(e.message, true));
  237. },
  238. });
  239. } else {
  240. // eslint-disable-next-line no-console
  241. console.warn('[sprint-settings] SortableJS not loaded — drag reorder disabled.');
  242. }
  243. }
  244. function refreshEmptyStates() {
  245. const ea = qs('[data-empty-available]');
  246. const es = qs('[data-empty-sprint]');
  247. if (ea && available) { ea.style.display = available.querySelectorAll('li').length === 0 ? '' : 'none'; }
  248. if (es && inSprint) { es.style.display = inSprint.querySelectorAll('li').length === 0 ? '' : 'none'; }
  249. }
  250. refreshEmptyStates();
  251. })();