/* * Copyright 2026 Alessandro Chiapparini * SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ /** * Sprint planning view — /sprints/{id} and /sprints/{id}/present. * * Vanilla JS. Drops jQuery / jQuery UI in favour of: * - native DOM APIs + delegated event listeners * - SortableJS (vendored at public/assets/js/vendor/sortable.min.js) for * drag-reorder of worker rows and task rows * - the existing JSON-envelope PATCH/POST endpoints for live edits * * Capacity math MUST stay in lock-step with App\Services\CapacityCalculator. * Behaviours (all preserved verbatim from the previous jQuery version): * - Day cells (per worker, per week) snap to 0.5 on blur, batch-saved via * PATCH /sprints/{id}/week-cells with 400 ms debounce. * - The Arbeitstage row is read-only (Phase 12); weekday selection lives * on /sprints/{id}/settings. * - RTB inputs snap to 0.05, save via PATCH /sprints/{id}/workers/{sw_id}. * - Worker rows + task rows are sortable via SortableJS handles. * - Task list: create / edit / delete + per-cell assignment days + * per-cell status (Phase 18) with their own debounced pipelines. * - Filters: search, prio, multi-select owner, single focus worker, * multi-select status (Phase 18), columns. Reset clears the lot. * - Sort: client-side, three states per column. * - Beamer flag: namespaces localStorage keys, seeds hidden columns, * flips on vertical headers when the table overflows. */ (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') || ''); const reserveFraction = Number(root.getAttribute('data-reserve-fraction') || 0); const isBeamer = Number(root.getAttribute('data-beamer')) === 1; const keySuffix = isBeamer ? ':beamer' : ''; const taskSection = root.querySelector('[data-task-section]'); const taskStatusEnabled = taskSection !== null && taskSection.getAttribute('data-task-status-enabled') === '1'; const STATUSES = ['zugewiesen', 'gestartet', 'abgeschlossen', 'abgebrochen']; // --------------------------------------------------------------------- // Tiny DOM helpers // --------------------------------------------------------------------- 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 show(el, visible) { if (!el) { return; } el.style.display = visible ? '' : 'none'; } function setHidden(el, hidden) { if (!el) { return; } if (hidden) { el.classList.add('hidden'); } else { el.classList.remove('hidden'); } } // --------------------------------------------------------------------- // Capacity math — must match App\Services\CapacityCalculator // --------------------------------------------------------------------- function roundHalf(x) { return Math.round(x * 2) / 2; } const snap05 = roundHalf; const snap005 = (x) => Math.round(x * 20) / 20; function fmtDays(x) { const n = Number(x); if (Math.abs(n - Math.round(n)) < 1e-9) { return String(Math.round(n)); } return n.toFixed(1); } function fmtRtb(x) { return Number(x).toFixed(2); } function capacity(ressourcen, committedPrio1) { committedPrio1 = committedPrio1 || 0; const afterReserves = roundHalf(ressourcen * (1 - reserveFraction)); return { ressourcen, afterReserves, committedPrio1, available: afterReserves - committedPrio1, }; } // Sum of prio-1 task assignment cells per sprint worker, read from DOM. function committedPrio1FromDom() { const per = {}; qsa('tr[data-task-row]').forEach(function (row) { if (parseInt(row.getAttribute('data-prio'), 10) !== 1) { return; } qsa('[data-assign]', row).forEach(function (inp) { const key = String(inp.getAttribute('data-sw-id')); const v = Number(inp.value); if (!Number.isNaN(v) && v > 0) { per[key] = (per[key] || 0) + v; } }); }); return per; } // --------------------------------------------------------------------- // HTTP helper — spec §7 envelopes // --------------------------------------------------------------------- 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; }); } // --------------------------------------------------------------------- // Status flash // --------------------------------------------------------------------- const statusEl = qs('[data-status]'); let statusTimer = null; const successCls = ['text-green-700', 'bg-green-50', 'border-green-200']; const errorCls = ['text-red-700', 'bg-red-50', 'border-red-200']; 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); } // --------------------------------------------------------------------- // Recompute worker row sum + capacity summary locally // --------------------------------------------------------------------- function recomputeRow(swId, commitMap) { const row = qs('[data-sw-row][data-sw-id="' + swId + '"]'); if (!row) { return; } let sum = 0; qsa('[data-day]', row).forEach(function (inp) { const v = Number(inp.value); if (!Number.isNaN(v)) { sum += v; } }); const committed = (commitMap || committedPrio1FromDom())[String(swId)] || 0; const cap = capacity(sum, committed); const sumEl = qs('[data-sum-days]', row); if (sumEl) { sumEl.textContent = fmtDays(cap.ressourcen); } qsa('[data-cap-ressourcen][data-sw-id="' + swId + '"]').forEach(function (r) { r.textContent = fmtDays(cap.ressourcen); }); qsa('[data-cap-after-reserves][data-sw-id="' + swId + '"]').forEach(function (a) { a.textContent = fmtDays(cap.afterReserves); }); qsa('[data-cap-available][data-sw-id="' + swId + '"]').forEach(function (av) { av.textContent = fmtDays(cap.available); av.classList.toggle('text-red-700', cap.available < 0); av.classList.toggle('text-slate-900', cap.available >= 0); }); } function recomputeAllCapacity() { const commit = committedPrio1FromDom(); qsa('[data-sw-row]').forEach(function (row) { recomputeRow(parseInt(row.getAttribute('data-sw-id'), 10), commit); }); } function applyServerCapacity(perWorker) { if (!perWorker || typeof perWorker !== 'object') { return; } Object.keys(perWorker).forEach(function (swIdStr) { const c = perWorker[swIdStr]; qsa('[data-cap-ressourcen][data-sw-id="' + swIdStr + '"]').forEach(function (r) { r.textContent = fmtDays(c.ressourcen); }); qsa('[data-cap-after-reserves][data-sw-id="' + swIdStr + '"]').forEach(function (a) { a.textContent = fmtDays(c.after_reserves); }); qsa('[data-cap-available][data-sw-id="' + swIdStr + '"]').forEach(function (av) { av.textContent = fmtDays(c.available); av.classList.toggle('text-red-700', c.available < 0); av.classList.toggle('text-slate-900', c.available >= 0); }); }); } // --------------------------------------------------------------------- // Pending-cell queue, debounced batch save (Arbeitstage day cells) // --------------------------------------------------------------------- const pendingCells = new Map(); let cellDebounce = null; function queueCell(swId, weekId, days) { pendingCells.set(swId + ':' + weekId, { sprint_worker_id: swId, sprint_week_id: weekId, days, }); clearTimeout(cellDebounce); cellDebounce = setTimeout(flushCells, 400); } function flushCells() { if (pendingCells.size === 0) { return; } const cells = Array.from(pendingCells.values()); pendingCells.clear(); request('PATCH', '/sprints/' + sprintId + '/week-cells', 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.per_worker); if (data.per_worker) { Object.keys(data.per_worker).forEach(function (swIdStr) { const sumEl = qs('[data-sw-row][data-sw-id="' + swIdStr + '"] [data-sum-days]'); if (sumEl) { sumEl.textContent = fmtDays(data.per_worker[swIdStr].ressourcen); } }); } }) .catch((e) => flash(e.message, true)); } // Day cell edit — clamp / snap / queue on(root, 'change', '[data-day]', function () { let v = Number(this.value); if (Number.isNaN(v)) { v = 0; } if (v < 0) { v = 0; } if (v > 5) { v = 5; } v = snap05(v); this.value = fmtDays(v); const swId = parseInt(this.getAttribute('data-sw-id'), 10); const weekId = parseInt(this.getAttribute('data-week-id'), 10); queueCell(swId, weekId, v); recomputeRow(swId); }); on(root, 'blur', '[data-day]', function () { this.dispatchEvent(new Event('change', { bubbles: true })); }); // RTB cell edit on(root, 'change', '[data-rtb]', function () { let v = Number(this.value); if (Number.isNaN(v)) { v = 0; } if (v < 0) { v = 0; } if (v > 1) { v = 1; } v = snap005(v); this.value = fmtRtb(v); const swId = parseInt(this.getAttribute('data-sw-id'), 10); request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v }) .then(() => flash('Saved')) .catch((e) => flash(e.message, true)); }); on(root, 'blur', '[data-rtb]', function () { this.dispatchEvent(new Event('change', { bubbles: true })); }); // --------------------------------------------------------------------- // SortableJS — worker rows // --------------------------------------------------------------------- const tbody = qs('[data-tbody]'); const sortableAvailable = typeof window.Sortable === 'function'; if (!sortableAvailable) { // eslint-disable-next-line no-console console.warn('[sprint-planner] SortableJS not loaded — drag reorder disabled.'); } if (sortableAvailable && tbody && qs('.handle', tbody)) { window.Sortable.create(tbody, { handle: '.handle', draggable: 'tr[data-sw-row]', animation: 150, onEnd: function () { const ordering = qsa('tr[data-sw-row]', tbody).map(function (el, i) { return { sprint_worker_id: parseInt(el.getAttribute('data-sw-id'), 10), sort_order: i + 1, }; }); request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering) .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((e) => flash(e.message, true)); }, }); } // ===================================================================== // Task list // ===================================================================== const taskTbody = qs('[data-task-tbody]'); const hasTaskUi = !!taskTbody; // Clone the server-rendered