|
@@ -1,32 +1,35 @@
|
|
|
-/* 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.
|
|
|
|
|
|
|
+ * /sprints/{id}/settings — vanilla JS + SortableJS.
|
|
|
|
|
+ * Mirrors the previous jQuery/jQuery UI implementation feature-for-feature:
|
|
|
|
|
+ * - debounced PATCH /sprints/{id} for sprint meta on change
|
|
|
|
|
+ * - POST /sprints/{id}/weeks for week count resize (full reload on success)
|
|
|
|
|
+ * - 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 ($) {
|
|
|
|
|
|
|
+(function () {
|
|
|
'use strict';
|
|
'use strict';
|
|
|
|
|
|
|
|
- const $root = $('[data-sprint-root]');
|
|
|
|
|
- if ($root.length === 0) {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const root = document.querySelector('[data-sprint-root]');
|
|
|
|
|
+ if (!root) { return; }
|
|
|
|
|
|
|
|
- const sprintId = parseInt($root.data('sprint-id'), 10);
|
|
|
|
|
- const csrf = String($root.data('csrf') || '');
|
|
|
|
|
|
|
+ const sprintId = parseInt(root.getAttribute('data-sprint-id'), 10);
|
|
|
|
|
+ const csrf = String(root.getAttribute('data-csrf') || '');
|
|
|
|
|
|
|
|
- // ---------------------------------------------------------------------
|
|
|
|
|
- // AJAX plumbing
|
|
|
|
|
- // ---------------------------------------------------------------------
|
|
|
|
|
|
|
+ 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) {
|
|
function request(method, url, body) {
|
|
|
const opts = {
|
|
const opts = {
|
|
|
method,
|
|
method,
|
|
|
- headers: {
|
|
|
|
|
- 'Accept': 'application/json',
|
|
|
|
|
- 'X-CSRF-Token': csrf,
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ headers: { Accept: 'application/json', 'X-CSRF-Token': csrf },
|
|
|
credentials: 'same-origin',
|
|
credentials: 'same-origin',
|
|
|
};
|
|
};
|
|
|
if (body !== undefined) {
|
|
if (body !== undefined) {
|
|
@@ -39,9 +42,9 @@
|
|
|
if (!res.ok || !payload || payload.ok !== true) {
|
|
if (!res.ok || !payload || payload.ok !== true) {
|
|
|
const msg = (payload && payload.error && payload.error.message)
|
|
const msg = (payload && payload.error && payload.error.message)
|
|
|
? payload.error.message
|
|
? payload.error.message
|
|
|
- : res.statusText || 'Request failed';
|
|
|
|
|
|
|
+ : (res.statusText || 'Request failed');
|
|
|
const err = new Error(msg);
|
|
const err = new Error(msg);
|
|
|
- err.status = res.status;
|
|
|
|
|
|
|
+ err.status = res.status;
|
|
|
err.payload = payload;
|
|
err.payload = payload;
|
|
|
throw err;
|
|
throw err;
|
|
|
}
|
|
}
|
|
@@ -49,237 +52,221 @@
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ---------------------------------------------------------------------
|
|
|
|
|
- // Toast / status line
|
|
|
|
|
- // ---------------------------------------------------------------------
|
|
|
|
|
-
|
|
|
|
|
- const $status = $root.find('[data-status]');
|
|
|
|
|
|
|
+ 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;
|
|
let statusTimer = null;
|
|
|
function flash(text, isError) {
|
|
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');
|
|
|
|
|
|
|
+ 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);
|
|
clearTimeout(statusTimer);
|
|
|
statusTimer = setTimeout(function () {
|
|
statusTimer = setTimeout(function () {
|
|
|
- $status.removeClass('opacity-100').addClass('opacity-0');
|
|
|
|
|
|
|
+ statusEl.classList.remove('opacity-100');
|
|
|
|
|
+ statusEl.classList.add('opacity-0');
|
|
|
}, 2500);
|
|
}, 2500);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ---------------------------------------------------------------------
|
|
|
|
|
- // Sprint meta — save on change / blur
|
|
|
|
|
- // ---------------------------------------------------------------------
|
|
|
|
|
|
|
+ // ---- Sprint meta ----------------------------------------------------
|
|
|
|
|
|
|
|
function patchMeta(payload) {
|
|
function patchMeta(payload) {
|
|
|
return request('PATCH', '/sprints/' + sprintId, payload)
|
|
return request('PATCH', '/sprints/' + sprintId, payload)
|
|
|
- .then(function () { flash('Saved'); })
|
|
|
|
|
- .catch(function (e) { flash(e.message, true); });
|
|
|
|
|
|
|
+ .then(() => flash('Saved'))
|
|
|
|
|
+ .catch((e) => flash(e.message, true));
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
const metaDebounce = {};
|
|
const metaDebounce = {};
|
|
|
- function debouncedMeta(field, value, ms) {
|
|
|
|
|
|
|
+ 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]);
|
|
clearTimeout(metaDebounce[field]);
|
|
|
metaDebounce[field] = setTimeout(function () {
|
|
metaDebounce[field] = setTimeout(function () {
|
|
|
- const payload = {};
|
|
|
|
|
- payload[field] = value;
|
|
|
|
|
|
|
+ const payload = {}; payload[field] = v;
|
|
|
patchMeta(payload);
|
|
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);
|
|
|
|
|
|
|
+ }, 0);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // ---------------------------------------------------------------------
|
|
|
|
|
- // Weeks count
|
|
|
|
|
- // ---------------------------------------------------------------------
|
|
|
|
|
|
|
+ // ---- Week count resize ---------------------------------------------
|
|
|
|
|
|
|
|
- $root.find('[data-weeks-form]').on('submit', function (ev) {
|
|
|
|
|
|
|
+ on(root, 'submit', '[data-weeks-form]', function (ev) {
|
|
|
ev.preventDefault();
|
|
ev.preventDefault();
|
|
|
- const n = parseInt($(this).find('input[name="n_weeks"]').val(), 10);
|
|
|
|
|
|
|
+ const inp = this.querySelector('input[name="n_weeks"]');
|
|
|
|
|
+ const n = parseInt(inp ? inp.value : '', 10);
|
|
|
if (!Number.isInteger(n) || n < 1) {
|
|
if (!Number.isInteger(n) || n < 1) {
|
|
|
flash('Weeks must be a positive integer', true);
|
|
flash('Weeks must be a positive integer', true);
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
request('POST', '/sprints/' + sprintId + '/weeks', { n_weeks: n })
|
|
request('POST', '/sprints/' + sprintId + '/weeks', { n_weeks: n })
|
|
|
- .then(function () { window.location.reload(); })
|
|
|
|
|
- .catch(function (e) { flash(e.message, true); });
|
|
|
|
|
|
|
+ .then(() => window.location.reload())
|
|
|
|
|
+ .catch((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.
|
|
|
|
|
- // ---------------------------------------------------------------------
|
|
|
|
|
|
|
+ // ---- Per-week weekday checkboxes (Phase 12) ------------------------
|
|
|
|
|
|
|
|
- const weekDebounce = {};
|
|
|
|
|
-
|
|
|
|
|
- function maskFromRow($row) {
|
|
|
|
|
|
|
+ function maskFromRow(row) {
|
|
|
let mask = 0;
|
|
let mask = 0;
|
|
|
- $row.find('[data-day-toggle]').each(function () {
|
|
|
|
|
- if ($(this).is(':checked')) {
|
|
|
|
|
- const bit = parseInt($(this).data('bit'), 10);
|
|
|
|
|
|
|
+ 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); }
|
|
if (Number.isInteger(bit)) { mask |= (1 << bit); }
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
return mask;
|
|
return mask;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
function popcount5(mask) {
|
|
function popcount5(mask) {
|
|
|
let n = 0;
|
|
let n = 0;
|
|
|
for (let i = 0; i < 5; i++) { if ((mask >> i) & 1) { n++; } }
|
|
for (let i = 0; i < 5; i++) { if ((mask >> i) & 1) { n++; } }
|
|
|
return 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)));
|
|
|
|
|
|
|
+ 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]);
|
|
clearTimeout(weekDebounce[weekId]);
|
|
|
weekDebounce[weekId] = setTimeout(function () {
|
|
weekDebounce[weekId] = setTimeout(function () {
|
|
|
request('PATCH', '/sprints/' + sprintId + '/week/' + weekId, { active_days_mask: mask })
|
|
request('PATCH', '/sprints/' + sprintId + '/week/' + weekId, { active_days_mask: mask })
|
|
|
.then(function (data) {
|
|
.then(function (data) {
|
|
|
- if (data && data.sprint_week) {
|
|
|
|
|
- $row.find('[data-week-count]')
|
|
|
|
|
- .text(String(data.sprint_week.max_working_days));
|
|
|
|
|
|
|
+ if (data && data.sprint_week && cnt) {
|
|
|
|
|
+ cnt.textContent = String(data.sprint_week.max_working_days);
|
|
|
}
|
|
}
|
|
|
flash('Saved');
|
|
flash('Saved');
|
|
|
})
|
|
})
|
|
|
- .catch(function (e) { flash(e.message, true); });
|
|
|
|
|
|
|
+ .catch((e) => flash(e.message, true));
|
|
|
}, 250);
|
|
}, 250);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // ---------------------------------------------------------------------
|
|
|
|
|
- // Worker picker
|
|
|
|
|
- // ---------------------------------------------------------------------
|
|
|
|
|
|
|
+ // ---- Worker picker --------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ const available = qs('[data-available]');
|
|
|
|
|
+ const inSprint = qs('[data-in-sprint]');
|
|
|
|
|
|
|
|
function workerRowTemplate(sw) {
|
|
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>'
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ 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) {
|
|
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 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;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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); });
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ 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));
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // 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();
|
|
|
|
|
|
|
+ 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));
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- 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); });
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ 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));
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- // 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;
|
|
|
|
|
|
|
+ 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.');
|
|
|
}
|
|
}
|
|
|
- // 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() {
|
|
function refreshEmptyStates() {
|
|
|
- $root.find('[data-empty-available]').toggle($available.children('li').length === 0);
|
|
|
|
|
- $root.find('[data-empty-sprint]').toggle($inSprint.children('li').length === 0);
|
|
|
|
|
|
|
+ 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();
|
|
refreshEmptyStates();
|
|
|
-
|
|
|
|
|
-})(jQuery);
|
|
|
|
|
|
|
+})();
|