| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- /* 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); });
- });
- // ---------------------------------------------------------------------
- // Per-week weekday checkboxes (Phase 12)
- //
- // Each row carries five [data-day-toggle] boxes. On any change we rebuild
- // the row's mask from all five and send it in one PATCH — no debounce on
- // per-checkbox granularity (each click is one state change), but we do
- // delay per-row in case the user ticks several in quick succession.
- // ---------------------------------------------------------------------
- const weekDebounce = {};
- function maskFromRow($row) {
- let mask = 0;
- $row.find('[data-day-toggle]').each(function () {
- if ($(this).is(':checked')) {
- const bit = parseInt($(this).data('bit'), 10);
- if (Number.isInteger(bit)) { mask |= (1 << bit); }
- }
- });
- return mask;
- }
- function popcount5(mask) {
- let n = 0;
- for (let i = 0; i < 5; i++) { if ((mask >> i) & 1) { n++; } }
- return n;
- }
- $root.on('change', '[data-day-toggle]', function () {
- const $row = $(this).closest('[data-week-row]');
- const weekId = parseInt($row.data('week-id'), 10);
- const mask = maskFromRow($row);
- // Optimistic local update: derived count flips immediately.
- $row.find('[data-week-count]').text(String(popcount5(mask)));
- clearTimeout(weekDebounce[weekId]);
- weekDebounce[weekId] = setTimeout(function () {
- request('PATCH', '/sprints/' + sprintId + '/week/' + weekId, { active_days_mask: mask })
- .then(function (data) {
- if (data && data.sprint_week) {
- $row.find('[data-week-count]')
- .text(String(data.sprint_week.max_working_days));
- }
- flash('Saved');
- })
- .catch(function (e) { flash(e.message, true); });
- }, 250);
- });
- // ---------------------------------------------------------------------
- // Worker picker
- // ---------------------------------------------------------------------
- function workerRowTemplate(sw) {
- const nameSafe = $('<div>').text(sw.worker_name).html();
- return $(
- '<li class="flex items-center gap-2 px-3 py-2 border-b bg-white last:border-b-0"' +
- ' data-sw-id="' + sw.id + '"' +
- ' data-worker-id="' + sw.worker_id + '">' +
- '<span class="handle cursor-grab text-slate-400">≡</span>' +
- '<span class="flex-1">' + nameSafe + '</span>' +
- '<input type="number" step="0.05" min="0" max="1" value="' + Number(sw.rtb).toFixed(2) + '"' +
- ' 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">' +
- '<button type="button" data-remove class="text-sm text-red-600 hover:underline">Remove</button>' +
- '</li>'
- );
- }
- function availableRowTemplate(worker) {
- const nameSafe = $('<div>').text(worker.name).html();
- return $(
- '<li class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0"' +
- ' data-worker-id="' + worker.id + '">' +
- '<span class="flex-1">' + nameSafe + '</span>' +
- '<button type="button" data-add class="text-sm text-blue-700 hover:underline">Add →</button>' +
- '</li>'
- );
- }
- 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 (requires jQuery UI)
- if (typeof $.fn.sortable === 'function') {
- $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); });
- },
- });
- } else {
- // eslint-disable-next-line no-console
- console.warn('[sprint-settings] jQuery UI not loaded — drag reorder disabled.');
- }
- 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);
|