/* global jQuery */ /** * Sprint settings page: JSON mutation plumbing + jQuery UI sortable wiring. * * The settings page mounts a single root element with `data-sprint-id` and * `data-csrf`. Everything below scopes itself to that root. */ (function ($) { 'use strict'; const $root = $('[data-sprint-root]'); if ($root.length === 0) { return; } const sprintId = parseInt($root.data('sprint-id'), 10); const csrf = String($root.data('csrf') || ''); // --------------------------------------------------------------------- // AJAX plumbing // --------------------------------------------------------------------- function request(method, url, body) { const opts = { method, headers: { 'Accept': 'application/json', 'X-CSRF-Token': csrf, }, credentials: 'same-origin', }; if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } return fetch(url, opts).then(async function (res) { let payload = null; try { payload = await res.json(); } catch (_) { /* ignore */ } if (!res.ok || !payload || payload.ok !== true) { const msg = (payload && payload.error && payload.error.message) ? payload.error.message : res.statusText || 'Request failed'; const err = new Error(msg); err.status = res.status; err.payload = payload; throw err; } return payload.data; }); } // --------------------------------------------------------------------- // Toast / status line // --------------------------------------------------------------------- const $status = $root.find('[data-status]'); let statusTimer = null; function flash(text, isError) { $status .text(text) .removeClass('text-green-700 text-red-700 bg-green-50 bg-red-50 border-green-200 border-red-200') .addClass(isError ? 'text-red-700 bg-red-50 border-red-200' : 'text-green-700 bg-green-50 border-green-200') .removeClass('opacity-0') .addClass('opacity-100'); clearTimeout(statusTimer); statusTimer = setTimeout(function () { $status.removeClass('opacity-100').addClass('opacity-0'); }, 2500); } // --------------------------------------------------------------------- // Sprint meta — save on change / blur // --------------------------------------------------------------------- function patchMeta(payload) { return request('PATCH', '/sprints/' + sprintId, payload) .then(function () { flash('Saved'); }) .catch(function (e) { flash(e.message, true); }); } const metaDebounce = {}; function debouncedMeta(field, value, ms) { clearTimeout(metaDebounce[field]); metaDebounce[field] = setTimeout(function () { const payload = {}; payload[field] = value; patchMeta(payload); }, ms || 400); } $root.find('[data-meta]').on('change', function () { const $el = $(this); const field = $el.attr('name'); let v = $el.val(); if (field === 'reserve_fraction') { v = Number(v) / 100; // form shows percent } debouncedMeta(field, v, 0); }); // --------------------------------------------------------------------- // Weeks count // --------------------------------------------------------------------- $root.find('[data-weeks-form]').on('submit', function (ev) { ev.preventDefault(); const n = parseInt($(this).find('input[name="n_weeks"]').val(), 10); if (!Number.isInteger(n) || n < 1) { flash('Weeks must be a positive integer', true); return; } request('POST', '/sprints/' + sprintId + '/weeks', { n_weeks: n }) .then(function () { window.location.reload(); }) .catch(function (e) { flash(e.message, true); }); }); // --------------------------------------------------------------------- // Worker picker // --------------------------------------------------------------------- function workerRowTemplate(sw) { const nameSafe = $('
').text(sw.worker_name).html(); return $( '
  • ' + '' + '' + nameSafe + '' + '' + '' + '
  • ' ); } function availableRowTemplate(worker) { const nameSafe = $('
    ').text(worker.name).html(); return $( '
  • ' + '' + nameSafe + '' + '' + '
  • ' ); } const $available = $root.find('[data-available]'); const $inSprint = $root.find('[data-in-sprint]'); // Add a worker $available.on('click', '[data-add]', function () { const $li = $(this).closest('li'); const workerId = parseInt($li.data('worker-id'), 10); const name = $li.find('span.flex-1').text(); request('POST', '/sprints/' + sprintId + '/workers', { worker_id: workerId }) .then(function (data) { const sw = data.sprint_worker; sw.worker_name = sw.worker_name || name; $inSprint.append(workerRowTemplate(sw)); $li.remove(); flash('Worker added'); refreshEmptyStates(); }) .catch(function (e) { flash(e.message, true); }); }); // Remove a worker $inSprint.on('click', '[data-remove]', function () { const $li = $(this).closest('li'); const swId = parseInt($li.data('sw-id'), 10); const workerId = parseInt($li.data('worker-id'), 10); const name = $li.find('span.flex-1').text(); request('DELETE', '/sprints/' + sprintId + '/workers/' + swId) .then(function () { $li.remove(); $available.append(availableRowTemplate({ id: workerId, name: name })); flash('Worker removed'); refreshEmptyStates(); }) .catch(function (e) { flash(e.message, true); }); }); // RTB edit on blur / change $inSprint.on('change', '[data-rtb]', function () { const $input = $(this); const swId = parseInt($input.closest('li').data('sw-id'), 10); let v = Number($input.val()); if (Number.isNaN(v) || v < 0 || v > 1) { flash('RTB must be 0–1', true); return; } // Snap to 0.05 step v = Math.round(v * 20) / 20; $input.val(v.toFixed(2)); request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v }) .then(function () { flash('Saved'); }) .catch(function (e) { flash(e.message, true); }); }); // Drag reorder $inSprint.sortable({ handle: '.handle', axis: 'y', placeholder: 'bg-slate-100 h-10', update: function () { const ordering = $inSprint.children('li').map(function (i, el) { return { sprint_worker_id: parseInt($(el).data('sw-id'), 10), sort_order: i + 1, }; }).get(); request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering) .then(function (data) { flash(data.moved ? 'Order saved' : 'No changes'); }) .catch(function (e) { flash(e.message, true); }); }, }); function refreshEmptyStates() { $root.find('[data-empty-available]').toggle($available.children('li').length === 0); $root.find('[data-empty-sprint]').toggle($inSprint.children('li').length === 0); } refreshEmptyStates(); })(jQuery);