1
0

sprint-settings.js 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  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. // Worker picker
  106. // ---------------------------------------------------------------------
  107. function workerRowTemplate(sw) {
  108. const nameSafe = $('<div>').text(sw.worker_name).html();
  109. return $(
  110. '<li class="flex items-center gap-2 px-3 py-2 border-b bg-white last:border-b-0"' +
  111. ' data-sw-id="' + sw.id + '"' +
  112. ' data-worker-id="' + sw.worker_id + '">' +
  113. '<span class="handle cursor-grab text-slate-400">&#8801;</span>' +
  114. '<span class="flex-1">' + nameSafe + '</span>' +
  115. '<input type="number" step="0.05" min="0" max="1" value="' + Number(sw.rtb).toFixed(2) + '"' +
  116. ' 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">' +
  117. '<button type="button" data-remove class="text-sm text-red-600 hover:underline">Remove</button>' +
  118. '</li>'
  119. );
  120. }
  121. function availableRowTemplate(worker) {
  122. const nameSafe = $('<div>').text(worker.name).html();
  123. return $(
  124. '<li class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0"' +
  125. ' data-worker-id="' + worker.id + '">' +
  126. '<span class="flex-1">' + nameSafe + '</span>' +
  127. '<button type="button" data-add class="text-sm text-blue-700 hover:underline">Add →</button>' +
  128. '</li>'
  129. );
  130. }
  131. const $available = $root.find('[data-available]');
  132. const $inSprint = $root.find('[data-in-sprint]');
  133. // Add a worker
  134. $available.on('click', '[data-add]', function () {
  135. const $li = $(this).closest('li');
  136. const workerId = parseInt($li.data('worker-id'), 10);
  137. const name = $li.find('span.flex-1').text();
  138. request('POST', '/sprints/' + sprintId + '/workers', { worker_id: workerId })
  139. .then(function (data) {
  140. const sw = data.sprint_worker;
  141. sw.worker_name = sw.worker_name || name;
  142. $inSprint.append(workerRowTemplate(sw));
  143. $li.remove();
  144. flash('Worker added');
  145. refreshEmptyStates();
  146. })
  147. .catch(function (e) { flash(e.message, true); });
  148. });
  149. // Remove a worker
  150. $inSprint.on('click', '[data-remove]', function () {
  151. const $li = $(this).closest('li');
  152. const swId = parseInt($li.data('sw-id'), 10);
  153. const workerId = parseInt($li.data('worker-id'), 10);
  154. const name = $li.find('span.flex-1').text();
  155. request('DELETE', '/sprints/' + sprintId + '/workers/' + swId)
  156. .then(function () {
  157. $li.remove();
  158. $available.append(availableRowTemplate({ id: workerId, name: name }));
  159. flash('Worker removed');
  160. refreshEmptyStates();
  161. })
  162. .catch(function (e) { flash(e.message, true); });
  163. });
  164. // RTB edit on blur / change
  165. $inSprint.on('change', '[data-rtb]', function () {
  166. const $input = $(this);
  167. const swId = parseInt($input.closest('li').data('sw-id'), 10);
  168. let v = Number($input.val());
  169. if (Number.isNaN(v) || v < 0 || v > 1) {
  170. flash('RTB must be 0–1', true);
  171. return;
  172. }
  173. // Snap to 0.05 step
  174. v = Math.round(v * 20) / 20;
  175. $input.val(v.toFixed(2));
  176. request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v })
  177. .then(function () { flash('Saved'); })
  178. .catch(function (e) { flash(e.message, true); });
  179. });
  180. // Drag reorder (requires jQuery UI)
  181. if (typeof $.fn.sortable === 'function') {
  182. $inSprint.sortable({
  183. handle: '.handle',
  184. axis: 'y',
  185. placeholder: 'bg-slate-100 h-10',
  186. update: function () {
  187. const ordering = $inSprint.children('li').map(function (i, el) {
  188. return {
  189. sprint_worker_id: parseInt($(el).data('sw-id'), 10),
  190. sort_order: i + 1,
  191. };
  192. }).get();
  193. request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering)
  194. .then(function (data) {
  195. flash(data.moved ? 'Order saved' : 'No changes');
  196. })
  197. .catch(function (e) { flash(e.message, true); });
  198. },
  199. });
  200. } else {
  201. // eslint-disable-next-line no-console
  202. console.warn('[sprint-settings] jQuery UI not loaded — drag reorder disabled.');
  203. }
  204. function refreshEmptyStates() {
  205. $root.find('[data-empty-available]').toggle($available.children('li').length === 0);
  206. $root.find('[data-empty-sprint]').toggle($inSprint.children('li').length === 0);
  207. }
  208. refreshEmptyStates();
  209. })(jQuery);