/* 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.
* - Max-working-days cells (the "Arbeitstage" row) snap to 0.5 on blur,
* saved via PATCH /sprints/{id}/week/{week_id}.
* - 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);
// ---------------------------------------------------------------------
// 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);
});
}
function recomputeSumMax() {
let sum = 0;
$root.find('[data-week-max]').each(function () {
const v = Number($(this).val());
if (!Number.isNaN(v)) { sum += v; }
});
$root.find('[data-sum-max]').text(fmtDays(sum));
}
// ---------------------------------------------------------------------
// 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);
});
// ---------------------------------------------------------------------
// Max working days (Arbeitstage row)
// ---------------------------------------------------------------------
$root.on('blur change', '[data-week-max]', 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 weekId = parseInt($el.data('week-id'), 10);
recomputeSumMax();
request('PATCH', '/sprints/' + sprintId + '/week/' + weekId, { max_working_days: v })
.then(function () { flash('Saved'); })
.catch(function (e) { flash(e.message, true); });
});
// ---------------------------------------------------------------------
// 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;
}
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
from an object ----------------------------
function buildTaskRow(task, assignments) {
assignments = assignments || {};
const $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($(' | ').append(
$('').html('≡')
));
// title
$tr.append(
$(' | ').append(
$('')
.val(task.title)
)
);
// owner
const $sel = $('