/* * 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 lives only in App\Services\CapacityCalculator. The client * does a plain ressourcen sum for the typing-responsive feel; Reserves / * Available come from the PATCH response (`per_worker`), which is the * single source of truth. * * 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 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'); } } // --------------------------------------------------------------------- // Number snapping / formatting helpers // --------------------------------------------------------------------- const snap05 = (x) => Math.round(x * 2) / 2; 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); } // --------------------------------------------------------------------- // 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); } // --------------------------------------------------------------------- // Worker row Σ-days + capacity Ressourcen — plain DOM sum. // // After-reserves / Available are NOT computed here; the server PATCH // response (`per_worker` envelope) is authoritative — see // `applyServerCapacity` below. That keeps the capacity formula in // CapacityCalculator.php as the single source of truth. // --------------------------------------------------------------------- function recomputeRow(swId) { 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 sumEl = qs('[data-sum-days]', row); if (sumEl) { sumEl.textContent = fmtDays(sum); } qsa('[data-cap-ressourcen][data-sw-id="' + swId + '"]').forEach(function (r) { r.textContent = fmtDays(sum); }); } 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