sprint-settings.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. /* global jQuery */
  2. /**
  3. * Sprint settings page: JSON mutation plumbing + jQuery UI sortable wiring.
  4. *
  5. * The settings page mounts a single root element with `data-sprint-id` and
  6. * `data-csrf`. Everything below scopes itself to that root.
  7. */
  8. (function ($) {
  9. 'use strict';
  10. const $root = $('[data-sprint-root]');
  11. if ($root.length === 0) {
  12. return;
  13. }
  14. const sprintId = parseInt($root.data('sprint-id'), 10);
  15. const csrf = String($root.data('csrf') || '');
  16. // ---------------------------------------------------------------------
  17. // AJAX plumbing
  18. // ---------------------------------------------------------------------
  19. function request(method, url, body) {
  20. const opts = {
  21. method,
  22. headers: {
  23. 'Accept': 'application/json',
  24. 'X-CSRF-Token': csrf,
  25. },
  26. credentials: 'same-origin',
  27. };
  28. if (body !== undefined) {
  29. opts.headers['Content-Type'] = 'application/json';
  30. opts.body = JSON.stringify(body);
  31. }
  32. return fetch(url, opts).then(async function (res) {
  33. let payload = null;
  34. try { payload = await res.json(); } catch (_) { /* ignore */ }
  35. if (!res.ok || !payload || payload.ok !== true) {
  36. const msg = (payload && payload.error && payload.error.message)
  37. ? payload.error.message
  38. : res.statusText || 'Request failed';
  39. const err = new Error(msg);
  40. err.status = res.status;
  41. err.payload = payload;
  42. throw err;
  43. }
  44. return payload.data;
  45. });
  46. }
  47. // ---------------------------------------------------------------------
  48. // Toast / status line
  49. // ---------------------------------------------------------------------
  50. const $status = $root.find('[data-status]');
  51. let statusTimer = null;
  52. function flash(text, isError) {
  53. $status
  54. .text(text)
  55. .removeClass('text-green-700 text-red-700 bg-green-50 bg-red-50 border-green-200 border-red-200')
  56. .addClass(isError ? 'text-red-700 bg-red-50 border-red-200' : 'text-green-700 bg-green-50 border-green-200')
  57. .removeClass('opacity-0')
  58. .addClass('opacity-100');
  59. clearTimeout(statusTimer);
  60. statusTimer = setTimeout(function () {
  61. $status.removeClass('opacity-100').addClass('opacity-0');
  62. }, 2500);
  63. }
  64. // ---------------------------------------------------------------------
  65. // Sprint meta — save on change / blur
  66. // ---------------------------------------------------------------------
  67. function patchMeta(payload) {
  68. return request('PATCH', '/sprints/' + sprintId, payload)
  69. .then(function () { flash('Saved'); })
  70. .catch(function (e) { flash(e.message, true); });
  71. }
  72. const metaDebounce = {};
  73. function debouncedMeta(field, value, ms) {
  74. clearTimeout(metaDebounce[field]);
  75. metaDebounce[field] = setTimeout(function () {
  76. const payload = {};
  77. payload[field] = value;
  78. patchMeta(payload);
  79. }, ms || 400);
  80. }
  81. $root.find('[data-meta]').on('change', function () {
  82. const $el = $(this);
  83. const field = $el.attr('name');
  84. let v = $el.val();
  85. if (field === 'reserve_fraction') {
  86. v = Number(v) / 100; // form shows percent
  87. }
  88. debouncedMeta(field, v, 0);
  89. });
  90. // ---------------------------------------------------------------------
  91. // Weeks count
  92. // ---------------------------------------------------------------------
  93. $root.find('[data-weeks-form]').on('submit', function (ev) {
  94. ev.preventDefault();
  95. const n = parseInt($(this).find('input[name="n_weeks"]').val(), 10);
  96. if (!Number.isInteger(n) || n < 1) {
  97. flash('Weeks must be a positive integer', true);
  98. return;
  99. }
  100. request('POST', '/sprints/' + sprintId + '/weeks', { n_weeks: n })
  101. .then(function () { window.location.reload(); })
  102. .catch(function (e) { flash(e.message, true); });
  103. });
  104. // ---------------------------------------------------------------------
  105. // Per-week weekday checkboxes (Phase 12)
  106. //
  107. // Each row carries five [data-day-toggle] boxes. On any change we rebuild
  108. // the row's mask from all five and send it in one PATCH — no debounce on
  109. // per-checkbox granularity (each click is one state change), but we do
  110. // delay per-row in case the user ticks several in quick succession.
  111. // ---------------------------------------------------------------------
  112. const weekDebounce = {};
  113. function maskFromRow($row) {
  114. let mask = 0;
  115. $row.find('[data-day-toggle]').each(function () {
  116. if ($(this).is(':checked')) {
  117. const bit = parseInt($(this).data('bit'), 10);
  118. if (Number.isInteger(bit)) { mask |= (1 << bit); }
  119. }
  120. });
  121. return mask;
  122. }
  123. function popcount5(mask) {
  124. let n = 0;
  125. for (let i = 0; i < 5; i++) { if ((mask >> i) & 1) { n++; } }
  126. return n;
  127. }
  128. $root.on('change', '[data-day-toggle]', function () {
  129. const $row = $(this).closest('[data-week-row]');
  130. const weekId = parseInt($row.data('week-id'), 10);
  131. const mask = maskFromRow($row);
  132. // Optimistic local update: derived count flips immediately.
  133. $row.find('[data-week-count]').text(String(popcount5(mask)));
  134. clearTimeout(weekDebounce[weekId]);
  135. weekDebounce[weekId] = setTimeout(function () {
  136. request('PATCH', '/sprints/' + sprintId + '/week/' + weekId, { active_days_mask: mask })
  137. .then(function (data) {
  138. if (data && data.sprint_week) {
  139. $row.find('[data-week-count]')
  140. .text(String(data.sprint_week.max_working_days));
  141. }
  142. flash('Saved');
  143. })
  144. .catch(function (e) { flash(e.message, true); });
  145. }, 250);
  146. });
  147. // ---------------------------------------------------------------------
  148. // Worker picker
  149. // ---------------------------------------------------------------------
  150. function workerRowTemplate(sw) {
  151. const nameSafe = $('<div>').text(sw.worker_name).html();
  152. return $(
  153. '<li class="flex items-center gap-2 px-3 py-2 border-b bg-white last:border-b-0"' +
  154. ' data-sw-id="' + sw.id + '"' +
  155. ' data-worker-id="' + sw.worker_id + '">' +
  156. '<span class="handle cursor-grab text-slate-400">&#8801;</span>' +
  157. '<span class="flex-1">' + nameSafe + '</span>' +
  158. '<input type="number" step="0.05" min="0" max="1" value="' + Number(sw.rtb).toFixed(2) + '"' +
  159. ' data-rtb class="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">' +
  160. '<button type="button" data-remove class="text-sm text-red-600 hover:underline">Remove</button>' +
  161. '</li>'
  162. );
  163. }
  164. function availableRowTemplate(worker) {
  165. const nameSafe = $('<div>').text(worker.name).html();
  166. return $(
  167. '<li class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0"' +
  168. ' data-worker-id="' + worker.id + '">' +
  169. '<span class="flex-1">' + nameSafe + '</span>' +
  170. '<button type="button" data-add class="text-sm text-blue-700 hover:underline">Add →</button>' +
  171. '</li>'
  172. );
  173. }
  174. const $available = $root.find('[data-available]');
  175. const $inSprint = $root.find('[data-in-sprint]');
  176. // Add a worker
  177. $available.on('click', '[data-add]', function () {
  178. const $li = $(this).closest('li');
  179. const workerId = parseInt($li.data('worker-id'), 10);
  180. const name = $li.find('span.flex-1').text();
  181. request('POST', '/sprints/' + sprintId + '/workers', { worker_id: workerId })
  182. .then(function (data) {
  183. const sw = data.sprint_worker;
  184. sw.worker_name = sw.worker_name || name;
  185. $inSprint.append(workerRowTemplate(sw));
  186. $li.remove();
  187. flash('Worker added');
  188. refreshEmptyStates();
  189. })
  190. .catch(function (e) { flash(e.message, true); });
  191. });
  192. // Remove a worker
  193. $inSprint.on('click', '[data-remove]', function () {
  194. const $li = $(this).closest('li');
  195. const swId = parseInt($li.data('sw-id'), 10);
  196. const workerId = parseInt($li.data('worker-id'), 10);
  197. const name = $li.find('span.flex-1').text();
  198. request('DELETE', '/sprints/' + sprintId + '/workers/' + swId)
  199. .then(function () {
  200. $li.remove();
  201. $available.append(availableRowTemplate({ id: workerId, name: name }));
  202. flash('Worker removed');
  203. refreshEmptyStates();
  204. })
  205. .catch(function (e) { flash(e.message, true); });
  206. });
  207. // RTB edit on blur / change
  208. $inSprint.on('change', '[data-rtb]', function () {
  209. const $input = $(this);
  210. const swId = parseInt($input.closest('li').data('sw-id'), 10);
  211. let v = Number($input.val());
  212. if (Number.isNaN(v) || v < 0 || v > 1) {
  213. flash('RTB must be 0–1', true);
  214. return;
  215. }
  216. // Snap to 0.05 step
  217. v = Math.round(v * 20) / 20;
  218. $input.val(v.toFixed(2));
  219. request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v })
  220. .then(function () { flash('Saved'); })
  221. .catch(function (e) { flash(e.message, true); });
  222. });
  223. // Drag reorder (requires jQuery UI)
  224. if (typeof $.fn.sortable === 'function') {
  225. $inSprint.sortable({
  226. handle: '.handle',
  227. axis: 'y',
  228. placeholder: 'bg-slate-100 h-10',
  229. update: function () {
  230. const ordering = $inSprint.children('li').map(function (i, el) {
  231. return {
  232. sprint_worker_id: parseInt($(el).data('sw-id'), 10),
  233. sort_order: i + 1,
  234. };
  235. }).get();
  236. request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering)
  237. .then(function (data) {
  238. flash(data.moved ? 'Order saved' : 'No changes');
  239. })
  240. .catch(function (e) { flash(e.message, true); });
  241. },
  242. });
  243. } else {
  244. // eslint-disable-next-line no-console
  245. console.warn('[sprint-settings] jQuery UI not loaded — drag reorder disabled.');
  246. }
  247. function refreshEmptyStates() {
  248. $root.find('[data-empty-available]').toggle($available.children('li').length === 0);
  249. $root.find('[data-empty-sprint]').toggle($inSprint.children('li').length === 0);
  250. }
  251. refreshEmptyStates();
  252. })(jQuery);