/** * /sprints/{id}/settings — vanilla JS + SortableJS. * - debounced PATCH /sprints/{id} for sprint meta on change; when the * response signals weeks were resynced, reload to pick up the new rows * - PATCH /sprints/{id}/week/{week_id} for per-week weekday mask * - POST /sprints/{id}/workers for adding a worker, DELETE for removing * - PATCH /sprints/{id}/workers/{sw_id} for RTB and reorder * - SortableJS replaces jQuery UI sortable on the in-sprint worker list */ (function () { 'use strict'; const root = document.querySelector('[data-sprint-root]'); if (!root) { return; } const sprintId = parseInt(root.getAttribute('data-sprint-id'), 10); const csrf = String(root.getAttribute('data-csrf') || ''); function qs(sel, ctx) { return (ctx || root).querySelector(sel); } function qsa(sel, ctx) { return Array.from((ctx || root).querySelectorAll(sel)); } function on(ctx, ev, sel, fn) { ctx.addEventListener(ev, function (e) { const t = e.target.closest(sel); if (t && ctx.contains(t)) { fn.call(t, e, t); } }); } 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; }); } const statusEl = qs('[data-status]'); const successCls = ['text-green-700', 'bg-green-50', 'border-green-200']; const errorCls = ['text-red-700', 'bg-red-50', 'border-red-200']; let statusTimer = null; function flash(text, isError) { if (!statusEl) { return; } statusEl.textContent = text; successCls.concat(errorCls).forEach((c) => statusEl.classList.remove(c)); (isError ? errorCls : successCls).forEach((c) => statusEl.classList.add(c)); statusEl.classList.remove('opacity-0'); statusEl.classList.add('opacity-100'); clearTimeout(statusTimer); statusTimer = setTimeout(function () { statusEl.classList.remove('opacity-100'); statusEl.classList.add('opacity-0'); }, 2500); } // ---- Sprint meta ---------------------------------------------------- function patchMeta(payload) { return request('PATCH', '/sprints/' + sprintId, payload) .then(function (data) { flash('Saved'); // Server resyncs the week set when start_date/end_date change. // Reload so the table reflects added/removed rows. if (data && data.weeks_synced) { window.location.reload(); } }) .catch((e) => flash(e.message, true)); } const metaDebounce = {}; on(root, 'change', '[data-meta]', function () { const field = this.getAttribute('name'); let v = this.value; if (field === 'reserve_fraction') { v = Number(v) / 100; } clearTimeout(metaDebounce[field]); metaDebounce[field] = setTimeout(function () { const payload = {}; payload[field] = v; patchMeta(payload); }, 0); }); // ---- Per-week weekday checkboxes (Phase 12) ------------------------ function maskFromRow(row) { let mask = 0; qsa('[data-day-toggle]', row).forEach(function (cb) { if (cb.checked) { const bit = parseInt(cb.getAttribute('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; } const weekDebounce = {}; on(root, 'change', '[data-day-toggle]', function () { const row = this.closest('[data-week-row]'); const weekId = parseInt(row.getAttribute('data-week-id'), 10); const mask = maskFromRow(row); const cnt = qs('[data-week-count]', row); if (cnt) { cnt.textContent = 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 && cnt) { cnt.textContent = String(data.sprint_week.max_working_days); } flash('Saved'); }) .catch((e) => flash(e.message, true)); }, 250); }); // ---- Worker picker -------------------------------------------------- const available = qs('[data-available]'); const inSprint = qs('[data-in-sprint]'); function workerRowTemplate(sw) { const li = document.createElement('li'); 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'; li.setAttribute('data-sw-id', String(sw.id)); li.setAttribute('data-worker-id', String(sw.worker_id)); const handle = document.createElement('span'); handle.className = 'handle cursor-grab text-slate-400 select-none dark:text-slate-500'; handle.innerHTML = '≡'; const name = document.createElement('span'); name.className = 'flex-1'; name.textContent = sw.worker_name || ''; const rtb = document.createElement('input'); rtb.type = 'number'; rtb.step = '0.05'; rtb.min = '0'; rtb.max = '1'; rtb.value = Number(sw.rtb).toFixed(2); rtb.setAttribute('data-rtb', ''); 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'; const remove = document.createElement('button'); remove.type = 'button'; remove.setAttribute('data-remove', ''); remove.className = 'text-sm text-red-600 hover:underline dark:text-red-400'; remove.textContent = 'Remove'; li.appendChild(handle); li.appendChild(name); li.appendChild(rtb); li.appendChild(remove); return li; } function availableRowTemplate(worker) { const li = document.createElement('li'); li.className = 'flex items-center gap-2 px-3 py-2 border-b last:border-b-0 dark:border-slate-700'; li.setAttribute('data-worker-id', String(worker.id)); const name = document.createElement('span'); name.className = 'flex-1'; name.textContent = worker.name || ''; const add = document.createElement('button'); add.type = 'button'; add.setAttribute('data-add', ''); add.className = 'text-sm text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300'; add.textContent = 'Add →'; li.appendChild(name); li.appendChild(add); return li; } if (available) { on(available, 'click', '[data-add]', function () { const li = this.closest('li'); const workerId = parseInt(li.getAttribute('data-worker-id'), 10); const span = li.querySelector('span.flex-1'); const name = span ? span.textContent : ''; request('POST', '/sprints/' + sprintId + '/workers', { worker_id: workerId }) .then(function (data) { const sw = data.sprint_worker; sw.worker_name = sw.worker_name || name; inSprint.appendChild(workerRowTemplate(sw)); li.remove(); flash('Worker added'); refreshEmptyStates(); }) .catch((e) => flash(e.message, true)); }); } if (inSprint) { on(inSprint, 'click', '[data-remove]', function () { const li = this.closest('li'); const swId = parseInt(li.getAttribute('data-sw-id'), 10); const workerId = parseInt(li.getAttribute('data-worker-id'), 10); const span = li.querySelector('span.flex-1'); const name = span ? span.textContent : ''; request('DELETE', '/sprints/' + sprintId + '/workers/' + swId) .then(function () { li.remove(); available.appendChild(availableRowTemplate({ id: workerId, name })); flash('Worker removed'); refreshEmptyStates(); }) .catch((e) => flash(e.message, true)); }); on(inSprint, 'change', '[data-rtb]', function () { const li = this.closest('li'); const swId = parseInt(li.getAttribute('data-sw-id'), 10); let v = Number(this.value); if (Number.isNaN(v) || v < 0 || v > 1) { flash('RTB must be 0–1', true); return; } v = Math.round(v * 20) / 20; this.value = v.toFixed(2); request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v }) .then(() => flash('Saved')) .catch((e) => flash(e.message, true)); }); if (typeof window.Sortable === 'function') { window.Sortable.create(inSprint, { handle: '.handle', animation: 150, onEnd: function () { const ordering = qsa('li', inSprint).map(function (li, i) { return { sprint_worker_id: parseInt(li.getAttribute('data-sw-id'), 10), sort_order: i + 1, }; }); request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering) .then((data) => flash(data.moved ? 'Order saved' : 'No changes')) .catch((e) => flash(e.message, true)); }, }); } else { // eslint-disable-next-line no-console console.warn('[sprint-settings] SortableJS not loaded — drag reorder disabled.'); } } function refreshEmptyStates() { const ea = qs('[data-empty-available]'); const es = qs('[data-empty-sprint]'); if (ea && available) { ea.style.display = available.querySelectorAll('li').length === 0 ? '' : 'none'; } if (es && inSprint) { es.style.display = inSprint.querySelectorAll('li').length === 0 ? '' : 'none'; } } refreshEmptyStates(); })();