|
@@ -41,10 +41,28 @@
|
|
|
|
|
|
|
|
function fmtRtb(x) { return Number(x).toFixed(2); }
|
|
function fmtRtb(x) { return Number(x).toFixed(2); }
|
|
|
|
|
|
|
|
- function capacity(ressourcen) {
|
|
|
|
|
|
|
+ function capacity(ressourcen, committedPrio1) {
|
|
|
|
|
+ committedPrio1 = committedPrio1 || 0;
|
|
|
const afterReserves = roundHalf(ressourcen * (1 - reserveFraction));
|
|
const afterReserves = roundHalf(ressourcen * (1 - reserveFraction));
|
|
|
- const available = afterReserves; // committed prio-1 lands in Phase 6
|
|
|
|
|
- return { ressourcen, afterReserves, available };
|
|
|
|
|
|
|
+ const available = afterReserves - committedPrio1;
|
|
|
|
|
+ return { ressourcen, afterReserves, committedPrio1, available };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Sum of prio-1 task assignment cells per sprint worker, read from DOM.
|
|
|
|
|
+ function committedPrio1FromDom() {
|
|
|
|
|
+ const per = {};
|
|
|
|
|
+ $root.find('tr[data-task-row]').each(function () {
|
|
|
|
|
+ const $row = $(this);
|
|
|
|
|
+ if (parseInt($row.attr('data-prio'), 10) !== 1) { return; }
|
|
|
|
|
+ $row.find('[data-assign]').each(function () {
|
|
|
|
|
+ const key = String($(this).data('sw-id'));
|
|
|
|
|
+ const v = Number($(this).val());
|
|
|
|
|
+ if (!Number.isNaN(v) && v > 0) {
|
|
|
|
|
+ per[key] = (per[key] || 0) + v;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ return per;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------
|
|
@@ -103,14 +121,16 @@
|
|
|
// Recompute worker row sum + capacity summary locally
|
|
// Recompute worker row sum + capacity summary locally
|
|
|
// ---------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------
|
|
|
|
|
|
|
|
- function recomputeRow(swId) {
|
|
|
|
|
|
|
+ function recomputeRow(swId, commitMap) {
|
|
|
const $row = $root.find('[data-sw-row][data-sw-id="' + swId + '"]');
|
|
const $row = $root.find('[data-sw-row][data-sw-id="' + swId + '"]');
|
|
|
let sum = 0;
|
|
let sum = 0;
|
|
|
$row.find('[data-day]').each(function () {
|
|
$row.find('[data-day]').each(function () {
|
|
|
const v = Number($(this).val());
|
|
const v = Number($(this).val());
|
|
|
if (!Number.isNaN(v)) { sum += v; }
|
|
if (!Number.isNaN(v)) { sum += v; }
|
|
|
});
|
|
});
|
|
|
- const cap = capacity(sum);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ const committed = (commitMap || committedPrio1FromDom())[String(swId)] || 0;
|
|
|
|
|
+ const cap = capacity(sum, committed);
|
|
|
|
|
|
|
|
$row.find('[data-sum-days]').text(fmtDays(cap.ressourcen));
|
|
$row.find('[data-sum-days]').text(fmtDays(cap.ressourcen));
|
|
|
$root.find('[data-cap-ressourcen][data-sw-id="' + swId + '"]').text(fmtDays(cap.ressourcen));
|
|
$root.find('[data-cap-ressourcen][data-sw-id="' + swId + '"]').text(fmtDays(cap.ressourcen));
|
|
@@ -125,6 +145,15 @@
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Recompute every worker row (capacity summary) — called when a task-side
|
|
|
|
|
+ // change might affect committed prio-1 values.
|
|
|
|
|
+ function recomputeAllCapacity() {
|
|
|
|
|
+ const commit = committedPrio1FromDom();
|
|
|
|
|
+ $root.find('[data-sw-row]').each(function () {
|
|
|
|
|
+ recomputeRow(parseInt($(this).data('sw-id'), 10), commit);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
function recomputeSumMax() {
|
|
function recomputeSumMax() {
|
|
|
let sum = 0;
|
|
let sum = 0;
|
|
|
$root.find('[data-week-max]').each(function () {
|
|
$root.find('[data-week-max]').each(function () {
|
|
@@ -270,17 +299,425 @@
|
|
|
}).get();
|
|
}).get();
|
|
|
|
|
|
|
|
request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering)
|
|
request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering)
|
|
|
- .then(function (data) { flash(data.moved ? 'Order saved' : 'No changes'); })
|
|
|
|
|
|
|
+ .then(function (data) {
|
|
|
|
|
+ if (data.moved) {
|
|
|
|
|
+ // Column order in the task list below depends on
|
|
|
|
|
+ // this ordering — simplest to re-render.
|
|
|
|
|
+ window.location.reload();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ flash('No changes');
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
.catch(function (e) { flash(e.message, true); });
|
|
.catch(function (e) { flash(e.message, true); });
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // =====================================================================
|
|
|
|
|
+ // Task list — create/edit/delete/reorder + assignments + filter/sort
|
|
|
|
|
+ // =====================================================================
|
|
|
|
|
+
|
|
|
|
|
+ const $taskTbody = $root.find('[data-task-tbody]');
|
|
|
|
|
+ const hasTaskUi = $taskTbody.length > 0;
|
|
|
|
|
+
|
|
|
|
|
+ // --- Worker/owner helpers read from the DOM once ----------------------
|
|
|
|
|
+
|
|
|
|
|
+ function sprintWorkerHeaders() {
|
|
|
|
|
+ const out = [];
|
|
|
|
|
+ $root.find('[data-task-table] thead th[data-sort-col^="sw-"]').each(function () {
|
|
|
|
|
+ const col = String($(this).attr('data-sort-col'));
|
|
|
|
|
+ out.push({
|
|
|
|
|
+ id: parseInt(col.slice(3), 10),
|
|
|
|
|
+ name: $(this).clone().children().remove().end().text().trim(),
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function ownerChoices() {
|
|
|
|
|
+ const out = [];
|
|
|
|
|
+ $root.find('[data-owner-filter] option').each(function () {
|
|
|
|
|
+ const v = $(this).val();
|
|
|
|
|
+ if (v === '' || v === '__none__') { return; }
|
|
|
|
|
+ out.push({ id: parseInt(String(v), 10), name: String($(this).text()).trim() });
|
|
|
|
|
+ });
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // --- Build a task row <tr> from an object ----------------------------
|
|
|
|
|
+
|
|
|
|
|
+ function buildTaskRow(task, assignments) {
|
|
|
|
|
+ assignments = assignments || {};
|
|
|
|
|
+ const $tr = $('<tr>')
|
|
|
|
|
+ .attr('data-task-row', '')
|
|
|
|
|
+ .attr('data-task-id', task.id)
|
|
|
|
|
+ .attr('data-prio', task.priority)
|
|
|
|
|
+ .attr('data-owner', task.owner_worker_id || '')
|
|
|
|
|
+ .attr('data-sort-order', task.sort_order);
|
|
|
|
|
+
|
|
|
|
|
+ // handle
|
|
|
|
|
+ $tr.append($('<td class="px-2 py-1"></td>').append(
|
|
|
|
|
+ $('<span class="handle cursor-grab text-slate-400 select-none">').html('≡')
|
|
|
|
|
+ ));
|
|
|
|
|
+
|
|
|
|
|
+ // title
|
|
|
|
|
+ $tr.append(
|
|
|
|
|
+ $('<td class="px-2 py-1 min-w-[14rem]"></td>').append(
|
|
|
|
|
+ $('<input type="text" data-title class="w-full rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400">')
|
|
|
|
|
+ .val(task.title)
|
|
|
|
|
+ )
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // owner
|
|
|
|
|
+ const $sel = $('<select data-owner-select class="w-full rounded border border-slate-200 px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">');
|
|
|
|
|
+ $sel.append('<option value="">—</option>');
|
|
|
|
|
+ ownerChoices().forEach(function (o) {
|
|
|
|
|
+ const $opt = $('<option>').val(o.id).text(o.name);
|
|
|
|
|
+ if (Number(o.id) === Number(task.owner_worker_id)) { $opt.attr('selected', 'selected'); }
|
|
|
|
|
+ $sel.append($opt);
|
|
|
|
|
+ });
|
|
|
|
|
+ $tr.append($('<td class="px-2 py-1"></td>').append($sel));
|
|
|
|
|
+
|
|
|
|
|
+ // priority
|
|
|
|
|
+ const $prio = $('<select data-prio-select class="rounded border border-slate-200 px-2 py-1 bg-white font-mono focus:outline-none focus:ring-2 focus:ring-slate-400"><option value="1">1</option><option value="2">2</option></select>');
|
|
|
|
|
+ $prio.val(String(task.priority));
|
|
|
|
|
+ $tr.append($('<td class="px-2 py-1 text-center"></td>').append($prio));
|
|
|
|
|
+
|
|
|
|
|
+ // tot
|
|
|
|
|
+ let tot = 0;
|
|
|
|
|
+ Object.keys(assignments).forEach(function (k) { tot += Number(assignments[k]) || 0; });
|
|
|
|
|
+ $tr.append($('<td class="px-2 py-1 text-center font-mono font-semibold" data-task-tot>').text(fmtDays(tot)));
|
|
|
|
|
+
|
|
|
|
|
+ // per-worker assignment cells
|
|
|
|
|
+ sprintWorkerHeaders().forEach(function (sw) {
|
|
|
|
|
+ const v = Number(assignments[sw.id] || 0);
|
|
|
|
|
+ const $td = $('<td class="px-1 py-1 text-center"></td>')
|
|
|
|
|
+ .attr('data-sort-value-sw-' + sw.id, v.toFixed(2));
|
|
|
|
|
+ $td.append(
|
|
|
|
|
+ $('<input type="number" min="0" step="0.5" data-assign class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">')
|
|
|
|
|
+ .val(fmtDays(v))
|
|
|
|
|
+ .attr('data-sw-id', sw.id)
|
|
|
|
|
+ );
|
|
|
|
|
+ $tr.append($td);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // delete
|
|
|
|
|
+ $tr.append(
|
|
|
|
|
+ $('<td class="px-1 py-1 text-right"></td>').append(
|
|
|
|
|
+ $('<button type="button" data-delete-task class="text-sm text-red-600 hover:underline">').text('×')
|
|
|
|
|
+ )
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ return $tr;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // --- Add task ---------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ $root.on('click', '[data-add-task]', function () {
|
|
|
|
|
+ request('POST', '/sprints/' + sprintId + '/tasks', { title: '', priority: 1 })
|
|
|
|
|
+ .then(function (data) {
|
|
|
|
|
+ $root.find('[data-empty-tasks]').remove();
|
|
|
|
|
+ const $row = buildTaskRow(data.task, data.assignments || {});
|
|
|
|
|
+ $taskTbody.append($row);
|
|
|
|
|
+ // Clear any active sort so the new row is actually visible at the end.
|
|
|
|
|
+ clearSort();
|
|
|
|
|
+ applyFilters();
|
|
|
|
|
+ $row.find('[data-title]').trigger('focus').trigger('select');
|
|
|
|
|
+ flash('Task added');
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(function (e) { flash(e.message, true); });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // --- Edit task fields (title / owner / prio) --------------------------
|
|
|
|
|
+
|
|
|
|
|
+ const titleDebounce = {};
|
|
|
|
|
+ $root.on('input', '[data-title]', function () {
|
|
|
|
|
+ const $inp = $(this);
|
|
|
|
|
+ const taskId = parseInt($inp.closest('tr').data('task-id'), 10);
|
|
|
|
|
+ clearTimeout(titleDebounce[taskId]);
|
|
|
|
|
+ titleDebounce[taskId] = setTimeout(function () {
|
|
|
|
|
+ const title = String($inp.val()).trim();
|
|
|
|
|
+ if (title === '') {
|
|
|
|
|
+ flash('Title cannot be empty', true);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ request('PATCH', '/tasks/' + taskId, { title: title })
|
|
|
|
|
+ .then(function () { flash('Saved'); })
|
|
|
|
|
+ .catch(function (e) { flash(e.message, true); });
|
|
|
|
|
+ }, 400);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $root.on('change', '[data-owner-select]', function () {
|
|
|
|
|
+ const $sel = $(this);
|
|
|
|
|
+ const $row = $sel.closest('tr');
|
|
|
|
|
+ const taskId = parseInt($row.data('task-id'), 10);
|
|
|
|
|
+ const v = $sel.val();
|
|
|
|
|
+ const owner = v === '' ? null : parseInt(String(v), 10);
|
|
|
|
|
+ $row.attr('data-owner', owner === null ? '' : owner);
|
|
|
|
|
+
|
|
|
|
|
+ request('PATCH', '/tasks/' + taskId, { owner_worker_id: owner })
|
|
|
|
|
+ .then(function () { flash('Saved'); applyFilters(); })
|
|
|
|
|
+ .catch(function (e) { flash(e.message, true); });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $root.on('change', '[data-prio-select]', function () {
|
|
|
|
|
+ const $sel = $(this);
|
|
|
|
|
+ const $row = $sel.closest('tr');
|
|
|
|
|
+ const taskId = parseInt($row.data('task-id'), 10);
|
|
|
|
|
+ const prio = parseInt(String($sel.val()), 10);
|
|
|
|
|
+ $row.attr('data-prio', prio);
|
|
|
|
|
+
|
|
|
|
|
+ request('PATCH', '/tasks/' + taskId, { priority: prio })
|
|
|
|
|
+ .then(function (data) {
|
|
|
|
|
+ flash('Saved');
|
|
|
|
|
+ applyFilters();
|
|
|
|
|
+ applyServerCapacity(data && data.per_worker);
|
|
|
|
|
+ recomputeAllCapacity();
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(function (e) { flash(e.message, true); });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // --- Delete task ------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ $root.on('click', '[data-delete-task]', function () {
|
|
|
|
|
+ const $row = $(this).closest('tr');
|
|
|
|
|
+ const taskId = parseInt($row.data('task-id'), 10);
|
|
|
|
|
+ const title = $row.find('[data-title]').val() || '(untitled)';
|
|
|
|
|
+ if (!window.confirm('Delete task "' + title + '"?')) { return; }
|
|
|
|
|
+
|
|
|
|
|
+ request('DELETE', '/tasks/' + taskId)
|
|
|
|
|
+ .then(function (data) {
|
|
|
|
|
+ $row.remove();
|
|
|
|
|
+ applyServerCapacity(data && data.per_worker);
|
|
|
|
|
+ recomputeAllCapacity();
|
|
|
|
|
+ flash('Task deleted');
|
|
|
|
|
+ if ($taskTbody.children('tr[data-task-row]').length === 0) {
|
|
|
|
|
+ // Re-show the empty-state row (simpler than templating).
|
|
|
|
|
+ window.location.reload();
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(function (e) { flash(e.message, true); });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // --- Assignment cells (per task, per sprint worker) -------------------
|
|
|
|
|
+ // Same shape as the Arbeitstage cell queue, but against /tasks/{id}/assignments.
|
|
|
|
|
+
|
|
|
|
|
+ const pendingAssign = new Map(); // taskId -> Map<swId, days>
|
|
|
|
|
+ const assignTimers = {};
|
|
|
|
|
+
|
|
|
|
|
+ function queueAssign(taskId, swId, days) {
|
|
|
|
|
+ if (!pendingAssign.has(taskId)) { pendingAssign.set(taskId, new Map()); }
|
|
|
|
|
+ pendingAssign.get(taskId).set(swId, days);
|
|
|
|
|
+ clearTimeout(assignTimers[taskId]);
|
|
|
|
|
+ assignTimers[taskId] = setTimeout(function () { flushAssign(taskId); }, 400);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function flushAssign(taskId) {
|
|
|
|
|
+ const m = pendingAssign.get(taskId);
|
|
|
|
|
+ if (!m || m.size === 0) { return; }
|
|
|
|
|
+ const cells = [];
|
|
|
|
|
+ m.forEach(function (days, swId) {
|
|
|
|
|
+ cells.push({ sprint_worker_id: swId, days: days });
|
|
|
|
|
+ });
|
|
|
|
|
+ pendingAssign.delete(taskId);
|
|
|
|
|
+
|
|
|
|
|
+ request('PATCH', '/tasks/' + taskId + '/assignments', cells)
|
|
|
|
|
+ .then(function (data) {
|
|
|
|
|
+ if (data.applied === 0 && data.noop > 0) { flash('No changes'); }
|
|
|
|
|
+ else { flash('Saved ' + data.applied + (data.applied === 1 ? ' cell' : ' cells')); }
|
|
|
|
|
+ applyServerCapacity(data && data.per_worker);
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(function (e) { flash(e.message, true); });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $root.on('blur change', '[data-assign]', function () {
|
|
|
|
|
+ const $el = $(this);
|
|
|
|
|
+ let v = Number($el.val());
|
|
|
|
|
+ if (Number.isNaN(v) || v < 0) { v = 0; }
|
|
|
|
|
+ v = snap05(v);
|
|
|
|
|
+ $el.val(fmtDays(v));
|
|
|
|
|
+ $el.closest('td').attr('data-sort-value-sw-' + $el.data('sw-id'), v.toFixed(2));
|
|
|
|
|
+
|
|
|
|
|
+ const taskId = parseInt($el.closest('tr').data('task-id'), 10);
|
|
|
|
|
+ const swId = parseInt($el.data('sw-id'), 10);
|
|
|
|
|
+ queueAssign(taskId, swId, v);
|
|
|
|
|
+
|
|
|
|
|
+ // Row total
|
|
|
|
|
+ let tot = 0;
|
|
|
|
|
+ $el.closest('tr').find('[data-assign]').each(function () {
|
|
|
|
|
+ const n = Number($(this).val());
|
|
|
|
|
+ if (!Number.isNaN(n)) { tot += n; }
|
|
|
|
|
+ });
|
|
|
|
|
+ $el.closest('tr').find('[data-task-tot]').text(fmtDays(tot));
|
|
|
|
|
+
|
|
|
|
|
+ // Available recompute (in case this is a prio-1 task)
|
|
|
|
|
+ recomputeAllCapacity();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ function applyServerCapacity(perWorker) {
|
|
|
|
|
+ if (!perWorker || typeof perWorker !== 'object') { return; }
|
|
|
|
|
+ Object.keys(perWorker).forEach(function (swIdStr) {
|
|
|
|
|
+ const c = perWorker[swIdStr];
|
|
|
|
|
+ $root.find('[data-cap-ressourcen][data-sw-id="' + swIdStr + '"]').text(fmtDays(c.ressourcen));
|
|
|
|
|
+ $root.find('[data-cap-after-reserves][data-sw-id="' + swIdStr + '"]').text(fmtDays(c.after_reserves));
|
|
|
|
|
+ const $av = $root.find('[data-cap-available][data-sw-id="' + swIdStr + '"]');
|
|
|
|
|
+ $av.text(fmtDays(c.available));
|
|
|
|
|
+ if (c.available < 0) {
|
|
|
|
|
+ $av.removeClass('text-slate-900').addClass('text-red-700');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $av.removeClass('text-red-700').addClass('text-slate-900');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // --- Task reorder (drag) ----------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ if (hasTaskUi && $taskTbody.find('.handle').length > 0) {
|
|
|
|
|
+ $taskTbody.sortable({
|
|
|
|
|
+ handle: '.handle',
|
|
|
|
|
+ items: 'tr[data-task-row]',
|
|
|
|
|
+ axis: 'y',
|
|
|
|
|
+ helper: function (e, tr) {
|
|
|
|
|
+ const $cells = tr.children();
|
|
|
|
|
+ const $clone = tr.clone();
|
|
|
|
|
+ $clone.children().each(function (i) { $(this).width($cells.eq(i).width()); });
|
|
|
|
|
+ return $clone;
|
|
|
|
|
+ },
|
|
|
|
|
+ start: function () {
|
|
|
|
|
+ // Drag only makes sense when no sort is active.
|
|
|
|
|
+ if (currentSort.col !== null) { clearSort(); }
|
|
|
|
|
+ },
|
|
|
|
|
+ update: function () {
|
|
|
|
|
+ const ordering = $taskTbody.find('tr[data-task-row]').map(function (i, el) {
|
|
|
|
|
+ return { task_id: parseInt($(el).data('task-id'), 10), sort_order: i + 1 };
|
|
|
|
|
+ }).get();
|
|
|
|
|
+
|
|
|
|
|
+ request('POST', '/sprints/' + sprintId + '/tasks/reorder', ordering)
|
|
|
|
|
+ .then(function (data) {
|
|
|
|
|
+ ordering.forEach(function (o) {
|
|
|
|
|
+ $taskTbody.find('tr[data-task-id="' + o.task_id + '"]').attr('data-sort-order', o.sort_order);
|
|
|
|
|
+ });
|
|
|
|
|
+ flash(data.moved ? 'Order saved' : 'No changes');
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(function (e) { flash(e.message, true); });
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // --- Filters (search / prio / owner) ----------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ function applyFilters() {
|
|
|
|
|
+ const q = String($root.find('[data-task-search]').val() || '').trim().toLowerCase();
|
|
|
|
|
+ const prio = String($root.find('[data-prio-filter]').val() || '');
|
|
|
|
|
+ const owner = String($root.find('[data-owner-filter]').val() || '');
|
|
|
|
|
+ let visibleCount = 0;
|
|
|
|
|
+
|
|
|
|
|
+ $taskTbody.children('tr[data-task-row]').each(function () {
|
|
|
|
|
+ const $row = $(this);
|
|
|
|
|
+ const title = String($row.find('[data-title]').val() || $row.find('[data-title]').text() || '').toLowerCase();
|
|
|
|
|
+ const rowPrio = String($row.attr('data-prio'));
|
|
|
|
|
+ const rowOwner = String($row.attr('data-owner') || '');
|
|
|
|
|
+
|
|
|
|
|
+ let ok = true;
|
|
|
|
|
+ if (q !== '' && !title.includes(q)) { ok = false; }
|
|
|
|
|
+ if (prio !== '' && rowPrio !== prio) { ok = false; }
|
|
|
|
|
+ if (owner === '__none__') {
|
|
|
|
|
+ if (rowOwner !== '') { ok = false; }
|
|
|
|
|
+ } else if (owner !== '' && rowOwner !== owner) {
|
|
|
|
|
+ ok = false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $row.toggle(ok);
|
|
|
|
|
+ if (ok) { visibleCount++; }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const totalRows = $taskTbody.children('tr[data-task-row]').length;
|
|
|
|
|
+ $root.find('[data-task-empty-filter]').toggle(totalRows > 0 && visibleCount === 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let searchDebounce = null;
|
|
|
|
|
+ $root.on('input', '[data-task-search]', function () {
|
|
|
|
|
+ clearTimeout(searchDebounce);
|
|
|
|
|
+ searchDebounce = setTimeout(applyFilters, 120);
|
|
|
|
|
+ });
|
|
|
|
|
+ $root.on('change', '[data-prio-filter], [data-owner-filter]', applyFilters);
|
|
|
|
|
+
|
|
|
|
|
+ // --- Column sort (client-side) ----------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ const currentSort = { col: null, dir: null }; // dir: 'asc' | 'desc'
|
|
|
|
|
+
|
|
|
|
|
+ function clearSort() {
|
|
|
|
|
+ currentSort.col = null; currentSort.dir = null;
|
|
|
|
|
+ $root.find('[data-sort-col]').each(function () {
|
|
|
|
|
+ $(this).find('.sort-ind').text('↕').addClass('opacity-30').removeClass('opacity-100');
|
|
|
|
|
+ });
|
|
|
|
|
+ // Restore original order by data-sort-order
|
|
|
|
|
+ const rows = $taskTbody.children('tr[data-task-row]').get();
|
|
|
|
|
+ rows.sort(function (a, b) {
|
|
|
|
|
+ return Number($(a).attr('data-sort-order')) - Number($(b).attr('data-sort-order'));
|
|
|
|
|
+ });
|
|
|
|
|
+ rows.forEach(function (el) { $taskTbody.append(el); });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function rowValueFor(col, $row) {
|
|
|
|
|
+ if (col === 'title') {
|
|
|
|
|
+ return String($row.find('[data-title]').val() || $row.find('[data-title]').text() || '').toLowerCase();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (col === 'owner') {
|
|
|
|
|
+ const id = String($row.attr('data-owner') || '');
|
|
|
|
|
+ if (id === '') { return '\uFFFF'; } // sort empty last
|
|
|
|
|
+ const opt = $row.find('[data-owner-select] option:selected');
|
|
|
|
|
+ return String(opt.text() || '').toLowerCase();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (col === 'prio') { return Number($row.attr('data-prio')); }
|
|
|
|
|
+ if (col === 'tot') { return Number($row.find('[data-task-tot]').text()) || 0; }
|
|
|
|
|
+ if (col.indexOf('sw-') === 0) {
|
|
|
|
|
+ const swId = col.slice(3);
|
|
|
|
|
+ return Number($row.find('[data-assign][data-sw-id="' + swId + '"]').val()) || 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function applySort(col) {
|
|
|
|
|
+ let dir;
|
|
|
|
|
+ if (currentSort.col !== col) { dir = 'asc'; }
|
|
|
|
|
+ else if (currentSort.dir === 'asc') { dir = 'desc'; }
|
|
|
|
|
+ else { clearSort(); return; } // third click clears
|
|
|
|
|
+
|
|
|
|
|
+ currentSort.col = col;
|
|
|
|
|
+ currentSort.dir = dir;
|
|
|
|
|
+
|
|
|
|
|
+ $root.find('[data-sort-col] .sort-ind').text('↕').addClass('opacity-30').removeClass('opacity-100');
|
|
|
|
|
+ $root.find('[data-sort-col="' + col + '"] .sort-ind')
|
|
|
|
|
+ .text(dir === 'asc' ? '↑' : '↓')
|
|
|
|
|
+ .removeClass('opacity-30').addClass('opacity-100');
|
|
|
|
|
+
|
|
|
|
|
+ const rows = $taskTbody.children('tr[data-task-row]').get();
|
|
|
|
|
+ rows.sort(function (a, b) {
|
|
|
|
|
+ const va = rowValueFor(col, $(a));
|
|
|
|
|
+ const vb = rowValueFor(col, $(b));
|
|
|
|
|
+ if (va < vb) { return dir === 'asc' ? -1 : 1; }
|
|
|
|
|
+ if (va > vb) { return dir === 'asc' ? 1 : -1; }
|
|
|
|
|
+ // Stable-ish tiebreak: fall back to data-sort-order
|
|
|
|
|
+ return Number($(a).attr('data-sort-order')) - Number($(b).attr('data-sort-order'));
|
|
|
|
|
+ });
|
|
|
|
|
+ rows.forEach(function (el) { $taskTbody.append(el); });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $root.on('click', '[data-sort-col]', function () {
|
|
|
|
|
+ applySort(String($(this).attr('data-sort-col')));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // =====================================================================
|
|
|
|
|
+ // Boot
|
|
|
|
|
+ // =====================================================================
|
|
|
|
|
+
|
|
|
// Recompute once at boot in case the server-rendered sums drift from the
|
|
// Recompute once at boot in case the server-rendered sums drift from the
|
|
|
// JS formula (e.g. after a stale reload).
|
|
// JS formula (e.g. after a stale reload).
|
|
|
$root.find('[data-sw-row]').each(function () {
|
|
$root.find('[data-sw-row]').each(function () {
|
|
|
recomputeRow(parseInt($(this).data('sw-id'), 10));
|
|
recomputeRow(parseInt($(this).data('sw-id'), 10));
|
|
|
});
|
|
});
|
|
|
recomputeSumMax();
|
|
recomputeSumMax();
|
|
|
|
|
+ applyFilters();
|
|
|
|
|
|
|
|
})(jQuery);
|
|
})(jQuery);
|