/* global jQuery */ /** * Main planning view (/sprints/{id}) — Section A: Arbeitstage grid. * * Behaviours: * - 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 header row is derived from the weekday selection in * Sprint Settings — it's read-only here (Phase 12). * - RTB inputs snap to 0.05 on blur, saved via PATCH /sprints/{id}/workers/{sw_id}. * - Worker rows are sortable (jQuery UI). Drop posts to * POST /sprints/{id}/workers/reorder. * * All capacity values are recomputed client-side with the same formula as * `App\Services\CapacityCalculator` so the UI stays in sync without waiting * for the server response. */ (function ($) { 'use strict'; const $root = $('[data-sprint-root]'); if ($root.length === 0) { return; } const sprintId = parseInt($root.data('sprint-id'), 10); const csrf = String($root.data('csrf') || ''); const reserveFraction = Number($root.data('reserve-fraction') || 0); // Phase 15: the presentation view stamps data-beamer="1" on the root so // we can namespace its localStorage keys (don't clobber the user's // workflow on /sprints/{id}) and flip on vertical-header rotation if // the task table overflows after the first filter pass. const isBeamer = Number($root.data('beamer')) === 1; const keySuffix = isBeamer ? ':beamer' : ''; // --------------------------------------------------------------------- // Capacity math — MUST match App\Services\CapacityCalculator // --------------------------------------------------------------------- function roundHalf(x) { return Math.round(x * 2) / 2; } function snap05(x) { return roundHalf(x); } function snap005(x) { return 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)); 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; } // --------------------------------------------------------------------- // 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 line (shared with settings page styling) // --------------------------------------------------------------------- const $status = $root.find('[data-status]'); let statusTimer = null; 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'); clearTimeout(statusTimer); statusTimer = setTimeout(function () { $status.removeClass('opacity-100').addClass('opacity-0'); }, 2500); } // --------------------------------------------------------------------- // Recompute worker row sum + capacity summary locally // --------------------------------------------------------------------- function recomputeRow(swId, commitMap) { const $row = $root.find('[data-sw-row][data-sw-id="' + swId + '"]'); let sum = 0; $row.find('[data-day]').each(function () { const v = Number($(this).val()); if (!Number.isNaN(v)) { sum += v; } }); const committed = (commitMap || committedPrio1FromDom())[String(swId)] || 0; const cap = capacity(sum, committed); $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-after-reserves][data-sw-id="' + swId + '"]').text(fmtDays(cap.afterReserves)); const $avail = $root.find('[data-cap-available][data-sw-id="' + swId + '"]'); $avail.text(fmtDays(cap.available)); if (cap.available < 0) { $avail.removeClass('text-slate-900').addClass('text-red-700'); } else { $avail.removeClass('text-red-700').addClass('text-slate-900'); } } // 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); }); } // --------------------------------------------------------------------- // Pending-cell queue, debounced batch save // --------------------------------------------------------------------- // key = "swId:weekId" → { sw_id, week_id, days } 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: 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')); } // Trust the server's capacity numbers — same formula, but a // safety net if an input was tampered with. if (data.per_worker && typeof data.per_worker === 'object') { Object.keys(data.per_worker).forEach(function (swIdStr) { const c = data.per_worker[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'); } $root.find('[data-sw-row][data-sw-id="' + swIdStr + '"] [data-sum-days]').text(fmtDays(c.ressourcen)); }); } }) .catch(function (e) { flash(e.message, true); }); } // --------------------------------------------------------------------- // Day cells // --------------------------------------------------------------------- $root.on('blur change', '[data-day]', function () { const $el = $(this); let v = Number($el.val()); if (Number.isNaN(v)) { v = 0; } if (v < 0) { v = 0; } if (v > 5) { v = 5; } v = snap05(v); $el.val(fmtDays(v)); const swId = parseInt($el.data('sw-id'), 10); const weekId = parseInt($el.data('week-id'), 10); queueCell(swId, weekId, v); recomputeRow(swId); }); // --------------------------------------------------------------------- // Per-row RTB edit // --------------------------------------------------------------------- $root.on('blur change', '[data-rtb]', function () { const $el = $(this); let v = Number($el.val()); if (Number.isNaN(v)) { v = 0; } if (v < 0) { v = 0; } if (v > 1) { v = 1; } v = snap005(v); $el.val(fmtRtb(v)); const swId = parseInt($el.data('sw-id'), 10); request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v }) .then(function () { flash('Saved'); }) .catch(function (e) { flash(e.message, true); }); }); // --------------------------------------------------------------------- // Worker row drag-reorder (admin only — tbody only exists with handles) // --------------------------------------------------------------------- const sortableAvailable = typeof $.fn.sortable === 'function'; if (!sortableAvailable) { // jQuery UI didn't load (SRI mismatch, offline CDN, ad blocker). // Drag-reorder is unavailable but the rest of the page still works. // eslint-disable-next-line no-console console.warn('[sprint-planner] jQuery UI not loaded — drag reorder disabled.'); } const $tbody = $root.find('[data-tbody]'); if (sortableAvailable && $tbody.find('.handle').length > 0) { $tbody.sortable({ handle: '.handle', items: 'tr[data-sw-row]', axis: 'y', helper: function (e, tr) { // Preserve the td widths so the row doesn't collapse while dragging. const $cells = tr.children(); const $clone = tr.clone(); $clone.children().each(function (i) { $(this).width($cells.eq(i).width()); }); return $clone; }, update: function () { const ordering = $tbody.find('tr[data-sw-row]').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) { 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); }); }, }); } // ===================================================================== // 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; } // The owner dropdown used to be a plain ') .val(task.title) ) ); // owner const $sel = $(''); $prio.val(String(task.priority)); $tr.append($('').append($prio)); // tot let tot = 0; Object.keys(assignments).forEach(function (k) { tot += Number(assignments[k]) || 0; }); $tr.append($('').text(fmtDays(tot))); // per-worker assignment cells sprintWorkerHeaders().forEach(function (sw) { const v = Number(assignments[sw.id] || 0); const $td = $('') .attr('data-col', 'sw-' + sw.id) .attr('data-sort-value-sw-' + sw.id, v.toFixed(2)); $td.append( $('') .val(fmtDays(v)) .attr('data-sw-id', sw.id) ); $tr.append($td); }); // delete $tr.append( $('').append( $('