|
|
@@ -31,6 +31,14 @@
|
|
|
const isBeamer = Number($root.data('beamer')) === 1;
|
|
|
const keySuffix = isBeamer ? ':beamer' : '';
|
|
|
|
|
|
+ // Phase 18: per-cell task-assignment status. The flag is rendered onto
|
|
|
+ // the [data-task-section] element so both this view and present.php
|
|
|
+ // light up identically. When false, the per-cell selectors and the
|
|
|
+ // toolbar Status filter are simply absent from the DOM.
|
|
|
+ const taskStatusEnabled =
|
|
|
+ $root.find('[data-task-section]').attr('data-task-status-enabled') === '1';
|
|
|
+ const STATUSES = ['zugewiesen', 'gestartet', 'abgeschlossen', 'abgebrochen'];
|
|
|
+
|
|
|
// ---------------------------------------------------------------------
|
|
|
// Capacity math — MUST match App\Services\CapacityCalculator
|
|
|
// ---------------------------------------------------------------------
|
|
|
@@ -386,11 +394,31 @@
|
|
|
const $td = $('<td class="px-1 py-1 text-center"></td>')
|
|
|
.attr('data-col', 'sw-' + sw.id)
|
|
|
.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))
|
|
|
+
|
|
|
+ const $input = $('<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);
|
|
|
+
|
|
|
+ if (taskStatusEnabled) {
|
|
|
+ // New tasks always start with the default status.
|
|
|
+ const $cell = $('<span class="assign-cell assign-status-zugewiesen"></span>')
|
|
|
+ .attr('data-assign-cell', '')
|
|
|
.attr('data-sw-id', sw.id)
|
|
|
- );
|
|
|
+ .attr('data-status', 'zugewiesen');
|
|
|
+ $cell.append($input);
|
|
|
+
|
|
|
+ const $status = $('<select data-assign-status aria-label="Status" class="assign-status-select"></select>')
|
|
|
+ .attr('data-sw-id', sw.id);
|
|
|
+ STATUSES.forEach(function (s) {
|
|
|
+ $('<option>').val(s).text(s).appendTo($status);
|
|
|
+ });
|
|
|
+ $status.val('zugewiesen');
|
|
|
+ $cell.append($status);
|
|
|
+
|
|
|
+ $td.append($cell);
|
|
|
+ } else {
|
|
|
+ $td.append($input);
|
|
|
+ }
|
|
|
$tr.append($td);
|
|
|
});
|
|
|
|
|
|
@@ -548,6 +576,62 @@
|
|
|
recomputeAllCapacity();
|
|
|
});
|
|
|
|
|
|
+ // --- Phase 18: per-cell status save pipeline -------------------------
|
|
|
+ // Independent of the days pipeline: hits /tasks/{id}/assignments/status
|
|
|
+ // (signed-in route, gated by app_settings.task_status_enabled). Same
|
|
|
+ // debounce semantics + same audit weight (one row per changed cell).
|
|
|
+
|
|
|
+ const pendingStatus = new Map(); // taskId -> Map<swId, status>
|
|
|
+ const statusTimers = {};
|
|
|
+
|
|
|
+ function queueStatus(taskId, swId, status) {
|
|
|
+ if (!pendingStatus.has(taskId)) { pendingStatus.set(taskId, new Map()); }
|
|
|
+ pendingStatus.get(taskId).set(swId, status);
|
|
|
+ clearTimeout(statusTimers[taskId]);
|
|
|
+ statusTimers[taskId] = setTimeout(function () { flushStatus(taskId); }, 400);
|
|
|
+ }
|
|
|
+
|
|
|
+ function flushStatus(taskId) {
|
|
|
+ const m = pendingStatus.get(taskId);
|
|
|
+ if (!m || m.size === 0) { return; }
|
|
|
+ const cells = [];
|
|
|
+ m.forEach(function (status, swId) {
|
|
|
+ cells.push({ sprint_worker_id: swId, status: status });
|
|
|
+ });
|
|
|
+ pendingStatus.delete(taskId);
|
|
|
+
|
|
|
+ request('PATCH', '/tasks/' + taskId + '/assignments/status', cells)
|
|
|
+ .then(function (data) {
|
|
|
+ if (data.applied === 0 && data.noop > 0) { flash('No changes'); }
|
|
|
+ else { flash('Saved ' + data.applied + (data.applied === 1 ? ' status' : ' statuses')); }
|
|
|
+ })
|
|
|
+ .catch(function (e) { flash(e.message, true); });
|
|
|
+ }
|
|
|
+
|
|
|
+ function applyStatusClass($cell, next) {
|
|
|
+ // Replace any existing assign-status-* with the new one. Keep the
|
|
|
+ // class set deterministic (any unknown classes get scrubbed).
|
|
|
+ STATUSES.forEach(function (s) { $cell.removeClass('assign-status-' + s); });
|
|
|
+ $cell.addClass('assign-status-' + next);
|
|
|
+ $cell.attr('data-status', next);
|
|
|
+ }
|
|
|
+
|
|
|
+ $root.on('change', '[data-assign-status]', function () {
|
|
|
+ const $sel = $(this);
|
|
|
+ const next = String($sel.val() || '');
|
|
|
+ if (STATUSES.indexOf(next) === -1) { return; }
|
|
|
+ const $cell = $sel.closest('[data-assign-cell]');
|
|
|
+ applyStatusClass($cell, next);
|
|
|
+
|
|
|
+ const taskId = parseInt($sel.closest('tr').data('task-id'), 10);
|
|
|
+ const swId = parseInt($sel.data('sw-id'), 10);
|
|
|
+ queueStatus(taskId, swId, next);
|
|
|
+
|
|
|
+ // Re-evaluate the status filter immediately so the row hides /
|
|
|
+ // shows without waiting for the server round-trip.
|
|
|
+ applyFilters();
|
|
|
+ });
|
|
|
+
|
|
|
function applyServerCapacity(perWorker) {
|
|
|
if (!perWorker || typeof perWorker !== 'object') { return; }
|
|
|
Object.keys(perWorker).forEach(function (swIdStr) {
|
|
|
@@ -677,7 +761,86 @@
|
|
|
$sel.val(focusWorker);
|
|
|
}
|
|
|
|
|
|
- // --- Filters (search / prio / owner / focus) --------------------------
|
|
|
+ // --- Phase 18: status filter (multi-select, persisted) ----------------
|
|
|
+
|
|
|
+ const statusFilterKey = 'sp:' + sprintId + ':statusFilter' + keySuffix;
|
|
|
+ /** @type {Set<string>} */
|
|
|
+ const statusFilterSet = (function () {
|
|
|
+ if (!taskStatusEnabled) { return new Set(); }
|
|
|
+ try {
|
|
|
+ const raw = window.localStorage.getItem(statusFilterKey);
|
|
|
+ if (raw) {
|
|
|
+ const arr = JSON.parse(raw);
|
|
|
+ if (Array.isArray(arr)) { return new Set(arr.map(String)); }
|
|
|
+ }
|
|
|
+ } catch (_) { /* ignore */ }
|
|
|
+ return new Set();
|
|
|
+ })();
|
|
|
+
|
|
|
+ function persistStatusFilter() {
|
|
|
+ try {
|
|
|
+ window.localStorage.setItem(statusFilterKey, JSON.stringify(Array.from(statusFilterSet)));
|
|
|
+ } catch (_) { /* ignore quota / private mode */ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateStatusFilterUi() {
|
|
|
+ $root.find('[data-status-filter-opt]').each(function () {
|
|
|
+ $(this).prop('checked', statusFilterSet.has(String($(this).val())));
|
|
|
+ });
|
|
|
+ const n = statusFilterSet.size;
|
|
|
+ $root.find('[data-status-filter-count]').text(n === 0 ? '' : '(' + n + ')');
|
|
|
+ }
|
|
|
+
|
|
|
+ $root.on('change', '[data-status-filter-opt]', function () {
|
|
|
+ const v = String($(this).val());
|
|
|
+ if (STATUSES.indexOf(v) === -1) { return; }
|
|
|
+ if ($(this).is(':checked')) { statusFilterSet.add(v); } else { statusFilterSet.delete(v); }
|
|
|
+ persistStatusFilter();
|
|
|
+ updateStatusFilterUi();
|
|
|
+ applyFilters();
|
|
|
+ });
|
|
|
+
|
|
|
+ $root.on('click', '[data-status-filter-clear]', function () {
|
|
|
+ statusFilterSet.clear();
|
|
|
+ persistStatusFilter();
|
|
|
+ updateStatusFilterUi();
|
|
|
+ applyFilters();
|
|
|
+ });
|
|
|
+
|
|
|
+ $root.on('click', '[data-status-filter-trigger]', function (ev) {
|
|
|
+ ev.stopPropagation();
|
|
|
+ $root.find('[data-owner-filter-dropdown]').addClass('hidden');
|
|
|
+ $root.find('[data-columns-dropdown]').addClass('hidden');
|
|
|
+ $root.find('[data-status-filter-dropdown]').toggleClass('hidden');
|
|
|
+ });
|
|
|
+
|
|
|
+ // Predicate: row passes if at least one of its cells is in the picked
|
|
|
+ // status set. The default 'zugewiesen' state matches only when there's
|
|
|
+ // actual work assigned (days > 0) so picking it doesn't match every
|
|
|
+ // task; the explicit states (gestartet/abgeschlossen/abgebrochen) match
|
|
|
+ // regardless of days because a user only sets them deliberately.
|
|
|
+ function rowMatchesStatusFilter($row) {
|
|
|
+ if (statusFilterSet.size === 0) { return true; }
|
|
|
+ let matched = false;
|
|
|
+ $row.find('[data-assign-cell]').each(function () {
|
|
|
+ const $cell = $(this);
|
|
|
+ const status = String($cell.attr('data-status') || 'zugewiesen');
|
|
|
+ if (!statusFilterSet.has(status)) { return; }
|
|
|
+ if (status === 'zugewiesen') {
|
|
|
+ const $inp = $cell.find('[data-assign]');
|
|
|
+ const days = $inp.length
|
|
|
+ ? (Number($inp.val()) || 0)
|
|
|
+ : (Number($cell.find('.font-mono').text()) || 0);
|
|
|
+ if (days > 0) { matched = true; return false; }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ matched = true;
|
|
|
+ return false;
|
|
|
+ });
|
|
|
+ return matched;
|
|
|
+ }
|
|
|
+
|
|
|
+ // --- Filters (search / prio / owner / focus / status) ----------------
|
|
|
|
|
|
function applyFilters() {
|
|
|
const q = String($root.find('[data-task-search]').val() || '').trim().toLowerCase();
|
|
|
@@ -703,6 +866,7 @@
|
|
|
const v = Number($row.find('[data-assign][data-sw-id="' + focus + '"]').val());
|
|
|
if (!(v > 0)) { ok = false; }
|
|
|
}
|
|
|
+ if (ok && taskStatusEnabled && !rowMatchesStatusFilter($row)) { ok = false; }
|
|
|
|
|
|
$row.toggle(ok);
|
|
|
if (ok) { visibleCount++; }
|
|
|
@@ -814,7 +978,8 @@
|
|
|
|| prio !== ''
|
|
|
|| ownerFilterSet.size > 0
|
|
|
|| String(focusWorker || '') !== ''
|
|
|
- || hiddenCols.size > 0;
|
|
|
+ || hiddenCols.size > 0
|
|
|
+ || (taskStatusEnabled && statusFilterSet.size > 0);
|
|
|
}
|
|
|
|
|
|
function updateResetVisibility() {
|
|
|
@@ -830,9 +995,14 @@
|
|
|
persistFocus();
|
|
|
hiddenCols.clear();
|
|
|
persistHiddenCols();
|
|
|
+ if (taskStatusEnabled) {
|
|
|
+ statusFilterSet.clear();
|
|
|
+ persistStatusFilter();
|
|
|
+ }
|
|
|
|
|
|
updateOwnerFilterUi();
|
|
|
updateFocusUi();
|
|
|
+ if (taskStatusEnabled) { updateStatusFilterUi(); }
|
|
|
applyColumnVisibility();
|
|
|
applyFilters();
|
|
|
});
|
|
|
@@ -851,6 +1021,9 @@
|
|
|
if ($(ev.target).closest('[data-columns-root]').length === 0) {
|
|
|
$root.find('[data-columns-dropdown]').addClass('hidden');
|
|
|
}
|
|
|
+ if ($(ev.target).closest('[data-status-filter-root]').length === 0) {
|
|
|
+ $root.find('[data-status-filter-dropdown]').addClass('hidden');
|
|
|
+ }
|
|
|
});
|
|
|
|
|
|
// --- Column sort (client-side) ----------------------------------------
|
|
|
@@ -933,6 +1106,7 @@
|
|
|
// columns don't briefly flash in before being toggled off.
|
|
|
updateOwnerFilterUi();
|
|
|
updateFocusUi();
|
|
|
+ if (taskStatusEnabled) { updateStatusFilterUi(); }
|
|
|
applyColumnVisibility();
|
|
|
applyFilters();
|
|
|
// Reset button visibility is a function of every filter; applyFilters
|