|
|
@@ -0,0 +1,286 @@
|
|
|
+/* 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) {
|
|
|
+ const afterReserves = roundHalf(ressourcen * (1 - reserveFraction));
|
|
|
+ const available = afterReserves; // committed prio-1 lands in Phase 6
|
|
|
+ return { ressourcen, afterReserves, available };
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------------
|
|
|
+ // 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) {
|
|
|
+ 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 cap = capacity(sum);
|
|
|
+
|
|
|
+ $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');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 $tbody = $root.find('[data-tbody]');
|
|
|
+ if ($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) { flash(data.moved ? 'Order saved' : 'No changes'); })
|
|
|
+ .catch(function (e) { flash(e.message, true); });
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Recompute once at boot in case the server-rendered sums drift from the
|
|
|
+ // JS formula (e.g. after a stale reload).
|
|
|
+ $root.find('[data-sw-row]').each(function () {
|
|
|
+ recomputeRow(parseInt($(this).data('sw-id'), 10));
|
|
|
+ });
|
|
|
+ recomputeSumMax();
|
|
|
+
|
|
|
+})(jQuery);
|