Quellcode durchsuchen

Phase 6: task list, assignments, client-side sort/filter/search

Domain:
- Domain\Task (id, sprint_id, title, owner_worker_id, priority, sort_order, ts)
- Domain\TaskAssignment (id, task_id, sprint_worker_id, days)
Both with toAuditSnapshot().

Repositories:
- TaskRepository: allForSprint, find, create (sort_order=MAX+1), update
  (whitelist: title, owner_worker_id, priority — returns before/after),
  delete (returns the pre-delete row), reorder (two-phase negate-then-
  apply, diff-only output).
- TaskAssignmentRepository: allForTask, find, gridForSprint (task_id->
  sw_id->days), upsert (same four-case semantics as sprint_worker_days:
  NOOP / CREATE / UPDATE — empty→0 stays NOOP, row kept at 0 when
  zeroed out), committedPrio1BySprint (Σ assignment days where
  task.priority=1 grouped by sw_id).

SessionGuard:
- requireAdminJson(req, users) consolidates the auth+admin+CSRF JSON
  gate so every JSON controller can reuse a single line. SprintController
  still has its private helper but both go through the same check.

Controllers:
- TaskController with 5 JSON endpoints:
    POST   /sprints/{id}/tasks           create
    PATCH  /tasks/{id}                   edit title/owner/priority
    DELETE /tasks/{id}                   snapshots + audits each assignment
                                         BEFORE the FK cascade, then deletes
                                         the task
    POST   /sprints/{id}/tasks/reorder   [{task_id, sort_order}, …]
    PATCH  /tasks/{id}/assignments       batch upsert; task's sprint-wide
                                         capacity returned when applicable
  Every non-NOOP mutation writes per-row audit inside the same tx. When
  a priority change or a prio-1 task gets deleted/modified, the response
  includes per_worker capacity so the client can paint Available without
  re-fetching.

- SprintController::show now pulls tasks + task grid + committed prio-1,
  plugs committed_prio1 into CapacityCalculator so the server-rendered
  capacity strip already reflects task commitments.

View (views/sprints/show.php):
- New Section B (Tasks) below the capacity summary, with a toolbar
  (search, prio filter, owner filter incl. "No owner", "+ Add task"),
  a responsive horizontally-scrolling table:
    handle · Title · Owner · Prio · Tot · <one col per sprint worker> · ×
  Clickable sort headers with ↕/↑/↓ indicators. Non-admins see a
  read-only variant (no inputs, no handles, no add/delete buttons).

JS (public/assets/js/sprint-planner.js):
- capacity() now takes committedPrio1, committed is computed from DOM
  by committedPrio1FromDom().
- recomputeRow accepts an optional commit map to avoid recomputing it
  once per row; recomputeAllCapacity scans prio-1 assignments once and
  updates every worker row.
- Task behaviours: click-to-add (optimistic row insert + focus title),
  debounced title edits, owner/prio change with PATCH + filter re-apply
  + per-worker capacity refresh, per-cell assignment queue with 400 ms
  batch flush to /tasks/{id}/assignments, delete with confirm.
- jQuery UI sortable on the tasks tbody; drag drops clear any active
  sort first, then POST /tasks/reorder.
- Client-side sort cycles asc→desc→cleared per header click; sort keys
  cover title/owner/prio/tot and each sw-<id> column. Clearing restores
  data-sort-order. Drag-reorder is disabled whenever a sort is active.
- Filters (search, prio, owner) apply together; empty-filter banner
  shows when no rows match.
- Worker row drag-reorder on the Arbeitstage now reloads the page so
  task column order stays in sync.

Routing:
- TaskController wired up for all five routes alongside the Phase 5
  JSON endpoints.

Verified:
- php -l on every changed/new file.
- End-to-end flow: three tasks created, priority promoted 2→1,
  four assignments upserted (Alice 3+4=7, Bob 2+1.5=3.5 prio-1
  committed), capacity math matches spec (20 res, 20% reserve →
  afterRes=16, Alice avail=9, Bob avail=12.5), three-way reorder
  produces the right three diffs, delete cascades with matching
  task_assignment DELETE audit rows before the task row itself.
  14 audit rows total — every non-NOOP mutation accounted for.
- Render tests for admin (all inputs/controls), non-admin (read-only),
  and empty-tasks state all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa vor 2 Wochen
Ursprung
Commit
ad78283125

+ 443 - 6
public/assets/js/sprint-planner.js

@@ -41,10 +41,28 @@
 
     function fmtRtb(x) { return Number(x).toFixed(2); }
 
-    function capacity(ressourcen) {
+    function capacity(ressourcen, committedPrio1) {
+        committedPrio1 = committedPrio1 || 0;
         const afterReserves = roundHalf(ressourcen * (1 - reserveFraction));
-        const available     = afterReserves; // committed prio-1 lands in Phase 6
-        return { ressourcen, afterReserves, available };
+        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;
     }
 
     // ---------------------------------------------------------------------
@@ -103,14 +121,16 @@
     // Recompute worker row sum + capacity summary locally
     // ---------------------------------------------------------------------
 
-    function recomputeRow(swId) {
+    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 cap = capacity(sum);
+
+        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));
@@ -125,6 +145,15 @@
         }
     }
 
+    // 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 () {
@@ -270,17 +299,425 @@
                 }).get();
 
                 request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering)
-                    .then(function (data) { flash(data.moved ? 'Order saved' : 'No changes'); })
+                    .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 <tr> from an object ----------------------------
+
+    function buildTaskRow(task, assignments) {
+        assignments = assignments || {};
+        const $tr = $('<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($('<td class="px-2 py-1"></td>').append(
+            $('<span class="handle cursor-grab text-slate-400 select-none">').html('&#8801;')
+        ));
+
+        // title
+        $tr.append(
+            $('<td class="px-2 py-1 min-w-[14rem]"></td>').append(
+                $('<input type="text" data-title class="w-full rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400">')
+                    .val(task.title)
+            )
+        );
+
+        // owner
+        const $sel = $('<select data-owner-select class="w-full rounded border border-slate-200 px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">');
+        $sel.append('<option value="">—</option>');
+        ownerChoices().forEach(function (o) {
+            const $opt = $('<option>').val(o.id).text(o.name);
+            if (Number(o.id) === Number(task.owner_worker_id)) { $opt.attr('selected', 'selected'); }
+            $sel.append($opt);
+        });
+        $tr.append($('<td class="px-2 py-1"></td>').append($sel));
+
+        // priority
+        const $prio = $('<select data-prio-select class="rounded border border-slate-200 px-2 py-1 bg-white font-mono focus:outline-none focus:ring-2 focus:ring-slate-400"><option value="1">1</option><option value="2">2</option></select>');
+        $prio.val(String(task.priority));
+        $tr.append($('<td class="px-2 py-1 text-center"></td>').append($prio));
+
+        // tot
+        let tot = 0;
+        Object.keys(assignments).forEach(function (k) { tot += Number(assignments[k]) || 0; });
+        $tr.append($('<td class="px-2 py-1 text-center font-mono font-semibold" data-task-tot>').text(fmtDays(tot)));
+
+        // per-worker assignment cells
+        sprintWorkerHeaders().forEach(function (sw) {
+            const v = Number(assignments[sw.id] || 0);
+            const $td = $('<td class="px-1 py-1 text-center"></td>')
+                .attr('data-sort-value-sw-' + sw.id, v.toFixed(2));
+            $td.append(
+                $('<input type="number" min="0" step="0.5" data-assign class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">')
+                    .val(fmtDays(v))
+                    .attr('data-sw-id', sw.id)
+            );
+            $tr.append($td);
+        });
+
+        // delete
+        $tr.append(
+            $('<td class="px-1 py-1 text-right"></td>').append(
+                $('<button type="button" data-delete-task class="text-sm text-red-600 hover:underline">').text('×')
+            )
+        );
+
+        return $tr;
+    }
+
+    // --- Add task ---------------------------------------------------------
+
+    $root.on('click', '[data-add-task]', function () {
+        request('POST', '/sprints/' + sprintId + '/tasks', { title: '', priority: 1 })
+            .then(function (data) {
+                $root.find('[data-empty-tasks]').remove();
+                const $row = buildTaskRow(data.task, data.assignments || {});
+                $taskTbody.append($row);
+                // Clear any active sort so the new row is actually visible at the end.
+                clearSort();
+                applyFilters();
+                $row.find('[data-title]').trigger('focus').trigger('select');
+                flash('Task added');
+            })
+            .catch(function (e) { flash(e.message, true); });
+    });
+
+    // --- Edit task fields (title / owner / prio) --------------------------
+
+    const titleDebounce = {};
+    $root.on('input', '[data-title]', function () {
+        const $inp = $(this);
+        const taskId = parseInt($inp.closest('tr').data('task-id'), 10);
+        clearTimeout(titleDebounce[taskId]);
+        titleDebounce[taskId] = setTimeout(function () {
+            const title = String($inp.val()).trim();
+            if (title === '') {
+                flash('Title cannot be empty', true);
+                return;
+            }
+            request('PATCH', '/tasks/' + taskId, { title: title })
+                .then(function () { flash('Saved'); })
+                .catch(function (e) { flash(e.message, true); });
+        }, 400);
+    });
+
+    $root.on('change', '[data-owner-select]', function () {
+        const $sel = $(this);
+        const $row = $sel.closest('tr');
+        const taskId = parseInt($row.data('task-id'), 10);
+        const v = $sel.val();
+        const owner = v === '' ? null : parseInt(String(v), 10);
+        $row.attr('data-owner', owner === null ? '' : owner);
+
+        request('PATCH', '/tasks/' + taskId, { owner_worker_id: owner })
+            .then(function () { flash('Saved'); applyFilters(); })
+            .catch(function (e) { flash(e.message, true); });
+    });
+
+    $root.on('change', '[data-prio-select]', function () {
+        const $sel = $(this);
+        const $row = $sel.closest('tr');
+        const taskId = parseInt($row.data('task-id'), 10);
+        const prio = parseInt(String($sel.val()), 10);
+        $row.attr('data-prio', prio);
+
+        request('PATCH', '/tasks/' + taskId, { priority: prio })
+            .then(function (data) {
+                flash('Saved');
+                applyFilters();
+                applyServerCapacity(data && data.per_worker);
+                recomputeAllCapacity();
+            })
+            .catch(function (e) { flash(e.message, true); });
+    });
+
+    // --- Delete task ------------------------------------------------------
+
+    $root.on('click', '[data-delete-task]', function () {
+        const $row = $(this).closest('tr');
+        const taskId = parseInt($row.data('task-id'), 10);
+        const title  = $row.find('[data-title]').val() || '(untitled)';
+        if (!window.confirm('Delete task "' + title + '"?')) { return; }
+
+        request('DELETE', '/tasks/' + taskId)
+            .then(function (data) {
+                $row.remove();
+                applyServerCapacity(data && data.per_worker);
+                recomputeAllCapacity();
+                flash('Task deleted');
+                if ($taskTbody.children('tr[data-task-row]').length === 0) {
+                    // Re-show the empty-state row (simpler than templating).
+                    window.location.reload();
+                }
+            })
+            .catch(function (e) { flash(e.message, true); });
+    });
+
+    // --- Assignment cells (per task, per sprint worker) -------------------
+    // Same shape as the Arbeitstage cell queue, but against /tasks/{id}/assignments.
+
+    const pendingAssign = new Map(); // taskId -> Map<swId, days>
+    const assignTimers = {};
+
+    function queueAssign(taskId, swId, days) {
+        if (!pendingAssign.has(taskId)) { pendingAssign.set(taskId, new Map()); }
+        pendingAssign.get(taskId).set(swId, days);
+        clearTimeout(assignTimers[taskId]);
+        assignTimers[taskId] = setTimeout(function () { flushAssign(taskId); }, 400);
+    }
+
+    function flushAssign(taskId) {
+        const m = pendingAssign.get(taskId);
+        if (!m || m.size === 0) { return; }
+        const cells = [];
+        m.forEach(function (days, swId) {
+            cells.push({ sprint_worker_id: swId, days: days });
+        });
+        pendingAssign.delete(taskId);
+
+        request('PATCH', '/tasks/' + taskId + '/assignments', 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 && data.per_worker);
+            })
+            .catch(function (e) { flash(e.message, true); });
+    }
+
+    $root.on('blur change', '[data-assign]', function () {
+        const $el = $(this);
+        let v = Number($el.val());
+        if (Number.isNaN(v) || v < 0) { v = 0; }
+        v = snap05(v);
+        $el.val(fmtDays(v));
+        $el.closest('td').attr('data-sort-value-sw-' + $el.data('sw-id'), v.toFixed(2));
+
+        const taskId = parseInt($el.closest('tr').data('task-id'), 10);
+        const swId   = parseInt($el.data('sw-id'), 10);
+        queueAssign(taskId, swId, v);
+
+        // Row total
+        let tot = 0;
+        $el.closest('tr').find('[data-assign]').each(function () {
+            const n = Number($(this).val());
+            if (!Number.isNaN(n)) { tot += n; }
+        });
+        $el.closest('tr').find('[data-task-tot]').text(fmtDays(tot));
+
+        // Available recompute (in case this is a prio-1 task)
+        recomputeAllCapacity();
+    });
+
+    function applyServerCapacity(perWorker) {
+        if (!perWorker || typeof perWorker !== 'object') { return; }
+        Object.keys(perWorker).forEach(function (swIdStr) {
+            const c = perWorker[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');
+            }
+        });
+    }
+
+    // --- Task reorder (drag) ----------------------------------------------
+
+    if (hasTaskUi && $taskTbody.find('.handle').length > 0) {
+        $taskTbody.sortable({
+            handle: '.handle',
+            items:  'tr[data-task-row]',
+            axis:   'y',
+            helper: function (e, tr) {
+                const $cells = tr.children();
+                const $clone = tr.clone();
+                $clone.children().each(function (i) { $(this).width($cells.eq(i).width()); });
+                return $clone;
+            },
+            start: function () {
+                // Drag only makes sense when no sort is active.
+                if (currentSort.col !== null) { clearSort(); }
+            },
+            update: function () {
+                const ordering = $taskTbody.find('tr[data-task-row]').map(function (i, el) {
+                    return { task_id: parseInt($(el).data('task-id'), 10), sort_order: i + 1 };
+                }).get();
+
+                request('POST', '/sprints/' + sprintId + '/tasks/reorder', ordering)
+                    .then(function (data) {
+                        ordering.forEach(function (o) {
+                            $taskTbody.find('tr[data-task-id="' + o.task_id + '"]').attr('data-sort-order', o.sort_order);
+                        });
+                        flash(data.moved ? 'Order saved' : 'No changes');
+                    })
+                    .catch(function (e) { flash(e.message, true); });
+            },
+        });
+    }
+
+    // --- Filters (search / prio / owner) ----------------------------------
+
+    function applyFilters() {
+        const q         = String($root.find('[data-task-search]').val() || '').trim().toLowerCase();
+        const prio      = String($root.find('[data-prio-filter]').val() || '');
+        const owner     = String($root.find('[data-owner-filter]').val() || '');
+        let visibleCount = 0;
+
+        $taskTbody.children('tr[data-task-row]').each(function () {
+            const $row      = $(this);
+            const title     = String($row.find('[data-title]').val() || $row.find('[data-title]').text() || '').toLowerCase();
+            const rowPrio   = String($row.attr('data-prio'));
+            const rowOwner  = String($row.attr('data-owner') || '');
+
+            let ok = true;
+            if (q !== '' && !title.includes(q)) { ok = false; }
+            if (prio !== '' && rowPrio !== prio) { ok = false; }
+            if (owner === '__none__') {
+                if (rowOwner !== '') { ok = false; }
+            } else if (owner !== '' && rowOwner !== owner) {
+                ok = false;
+            }
+
+            $row.toggle(ok);
+            if (ok) { visibleCount++; }
+        });
+
+        const totalRows = $taskTbody.children('tr[data-task-row]').length;
+        $root.find('[data-task-empty-filter]').toggle(totalRows > 0 && visibleCount === 0);
+    }
+
+    let searchDebounce = null;
+    $root.on('input', '[data-task-search]', function () {
+        clearTimeout(searchDebounce);
+        searchDebounce = setTimeout(applyFilters, 120);
+    });
+    $root.on('change', '[data-prio-filter], [data-owner-filter]', applyFilters);
+
+    // --- Column sort (client-side) ----------------------------------------
+
+    const currentSort = { col: null, dir: null }; // dir: 'asc' | 'desc'
+
+    function clearSort() {
+        currentSort.col = null; currentSort.dir = null;
+        $root.find('[data-sort-col]').each(function () {
+            $(this).find('.sort-ind').text('↕').addClass('opacity-30').removeClass('opacity-100');
+        });
+        // Restore original order by data-sort-order
+        const rows = $taskTbody.children('tr[data-task-row]').get();
+        rows.sort(function (a, b) {
+            return Number($(a).attr('data-sort-order')) - Number($(b).attr('data-sort-order'));
+        });
+        rows.forEach(function (el) { $taskTbody.append(el); });
+    }
+
+    function rowValueFor(col, $row) {
+        if (col === 'title') {
+            return String($row.find('[data-title]').val() || $row.find('[data-title]').text() || '').toLowerCase();
+        }
+        if (col === 'owner') {
+            const id = String($row.attr('data-owner') || '');
+            if (id === '') { return '\uFFFF'; } // sort empty last
+            const opt = $row.find('[data-owner-select] option:selected');
+            return String(opt.text() || '').toLowerCase();
+        }
+        if (col === 'prio') { return Number($row.attr('data-prio')); }
+        if (col === 'tot')  { return Number($row.find('[data-task-tot]').text()) || 0; }
+        if (col.indexOf('sw-') === 0) {
+            const swId = col.slice(3);
+            return Number($row.find('[data-assign][data-sw-id="' + swId + '"]').val()) || 0;
+        }
+        return 0;
+    }
+
+    function applySort(col) {
+        let dir;
+        if (currentSort.col !== col) { dir = 'asc'; }
+        else if (currentSort.dir === 'asc') { dir = 'desc'; }
+        else { clearSort(); return; } // third click clears
+
+        currentSort.col = col;
+        currentSort.dir = dir;
+
+        $root.find('[data-sort-col] .sort-ind').text('↕').addClass('opacity-30').removeClass('opacity-100');
+        $root.find('[data-sort-col="' + col + '"] .sort-ind')
+            .text(dir === 'asc' ? '↑' : '↓')
+            .removeClass('opacity-30').addClass('opacity-100');
+
+        const rows = $taskTbody.children('tr[data-task-row]').get();
+        rows.sort(function (a, b) {
+            const va = rowValueFor(col, $(a));
+            const vb = rowValueFor(col, $(b));
+            if (va < vb) { return dir === 'asc' ? -1 : 1; }
+            if (va > vb) { return dir === 'asc' ? 1 : -1; }
+            // Stable-ish tiebreak: fall back to data-sort-order
+            return Number($(a).attr('data-sort-order')) - Number($(b).attr('data-sort-order'));
+        });
+        rows.forEach(function (el) { $taskTbody.append(el); });
+    }
+
+    $root.on('click', '[data-sort-col]', function () {
+        applySort(String($(this).attr('data-sort-col')));
+    });
+
+    // =====================================================================
+    // Boot
+    // =====================================================================
+
     // 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();
+    applyFilters();
 
 })(jQuery);

+ 25 - 8
public/index.php

@@ -7,6 +7,7 @@ use App\Auth\OidcClient;
 use App\Auth\SessionGuard;
 use App\Controllers\AuthController;
 use App\Controllers\SprintController;
+use App\Controllers\TaskController;
 use App\Controllers\WorkerController;
 use App\Db\Connection;
 use App\Db\Migrator;
@@ -18,6 +19,8 @@ use App\Repositories\SprintRepository;
 use App\Repositories\SprintWeekRepository;
 use App\Repositories\SprintWorkerDayRepository;
 use App\Repositories\SprintWorkerRepository;
+use App\Repositories\TaskAssignmentRepository;
+use App\Repositories\TaskRepository;
 use App\Repositories\UserRepository;
 use App\Repositories\WorkerRepository;
 use App\Services\AuditLogger;
@@ -80,14 +83,21 @@ $view          = new View(APP_ROOT . '/views');
 $users         = new UserRepository($pdo);
 $workers       = new WorkerRepository($pdo);
 $sprints       = new SprintRepository($pdo);
-$sprintWeeks   = new SprintWeekRepository($pdo);
-$sprintWorkers = new SprintWorkerRepository($pdo);
-$swDays        = new SprintWorkerDayRepository($pdo);
-$audit         = new AuditLogger($pdo);
-$auth          = new AuthController($pdo, $users, $audit, $view);
-$workerCtrl    = new WorkerController($pdo, $users, $workers, $audit, $view);
-$sprintCtrl    = new SprintController(
-    $pdo, $users, $sprints, $sprintWeeks, $sprintWorkers, $swDays, $workers, $audit, $view,
+$sprintWeeks    = new SprintWeekRepository($pdo);
+$sprintWorkers  = new SprintWorkerRepository($pdo);
+$swDays         = new SprintWorkerDayRepository($pdo);
+$tasks          = new TaskRepository($pdo);
+$taskAssign     = new TaskAssignmentRepository($pdo);
+$audit          = new AuditLogger($pdo);
+$auth           = new AuthController($pdo, $users, $audit, $view);
+$workerCtrl     = new WorkerController($pdo, $users, $workers, $audit, $view);
+$sprintCtrl     = new SprintController(
+    $pdo, $users, $sprints, $sprintWeeks, $sprintWorkers, $swDays,
+    $tasks, $taskAssign, $workers, $audit, $view,
+);
+$taskCtrl       = new TaskController(
+    $pdo, $users, $sprints, $sprintWorkers, $swDays,
+    $tasks, $taskAssign, $workers, $audit,
 );
 
 // ---------------------------------------------------------------------------
@@ -146,6 +156,13 @@ $router->patch('/sprints/{id}/workers/{sw_id}',       $sprintCtrl->updateWorker(
 $router->patch('/sprints/{id}/week-cells',            $sprintCtrl->updateWeekCells(...));
 $router->patch('/sprints/{id}/week/{week_id}',        $sprintCtrl->updateWeekMax(...));
 
+// Phase 6 — Task list:
+$router->post('/sprints/{id}/tasks',                  $taskCtrl->create(...));
+$router->post('/sprints/{id}/tasks/reorder',          $taskCtrl->reorder(...));
+$router->patch('/tasks/{id}',                         $taskCtrl->update(...));
+$router->delete('/tasks/{id}',                        $taskCtrl->delete(...));
+$router->patch('/tasks/{id}/assignments',             $taskCtrl->updateAssignments(...));
+
 // ---------------------------------------------------------------------------
 // Dispatch
 // ---------------------------------------------------------------------------

+ 19 - 0
src/Auth/SessionGuard.php

@@ -159,4 +159,23 @@ final class SessionGuard
         }
         return $user;
     }
+
+    /**
+     * JSON-flavoured admin gate: auth + admin + CSRF. Returns the signed-in
+     * User on success, or a JSON error envelope response per spec §7.
+     */
+    public static function requireAdminJson(Request $req, UserRepository $users): User|Response
+    {
+        $user = self::currentUser($users);
+        if ($user === null) {
+            return Response::err('unauthenticated', 'Sign in required', 401);
+        }
+        if (!$user->isAdmin) {
+            return Response::err('forbidden', 'Admin access required', 403);
+        }
+        if (!self::verifyCsrf($req)) {
+            return Response::err('csrf', 'CSRF token invalid', 403);
+        }
+        return $user;
+    }
 }

+ 16 - 1
src/Controllers/SprintController.php

@@ -13,6 +13,8 @@ use App\Repositories\SprintRepository;
 use App\Repositories\SprintWeekRepository;
 use App\Repositories\SprintWorkerDayRepository;
 use App\Repositories\SprintWorkerRepository;
+use App\Repositories\TaskAssignmentRepository;
+use App\Repositories\TaskRepository;
 use App\Repositories\UserRepository;
 use App\Repositories\WorkerRepository;
 use App\Services\AuditLogger;
@@ -31,6 +33,8 @@ final class SprintController
         private readonly SprintWeekRepository      $weeks,
         private readonly SprintWorkerRepository    $sprintWorkers,
         private readonly SprintWorkerDayRepository $days,
+        private readonly TaskRepository            $tasks,
+        private readonly TaskAssignmentRepository  $assignments,
         private readonly WorkerRepository          $workers,
         private readonly AuditLogger               $audit,
         private readonly View                      $view,
@@ -166,6 +170,9 @@ final class SprintController
         $weeks         = $this->weeks->allForSprint($id);
         $sprintWorkers = $this->sprintWorkers->allForSprint($id);
         $grid          = $this->days->grid($id);
+        $tasks         = $this->tasks->allForSprint($id);
+        $taskGrid      = $this->assignments->gridForSprint($id);
+        $committedP1   = $this->assignments->committedPrio1BySprint($id);
 
         // Seed initial capacity server-side so the page is meaningful without JS
         // and the JS has the same numbers to compare against.
@@ -176,10 +183,15 @@ final class SprintController
             $capacity[$sw->id] = CapacityCalculator::forWorker(
                 $ressourcen,
                 $sprint->reserveFraction,
-                0.0, // prio-1 commitments come with Phase 6
+                $committedP1[$sw->id] ?? 0.0,
             );
         }
 
+        // Owner dropdown source: all active workers (not just sprint members,
+        // since the Excel allows the owner to be any worker — but typically
+        // they are one of the sprint workers. Keep it restrictive for the UI).
+        $ownerChoices = $this->workers->all();
+
         return Response::html($this->view->render('sprints/show', [
             'title'         => $sprint->name,
             'currentUser'   => $actor,
@@ -189,6 +201,9 @@ final class SprintController
             'sprintWorkers' => $sprintWorkers,
             'grid'          => $grid,
             'capacity'      => $capacity,
+            'tasks'         => $tasks,
+            'taskGrid'      => $taskGrid,
+            'ownerChoices'  => $ownerChoices,
         ]));
     }
 

+ 404 - 0
src/Controllers/TaskController.php

@@ -0,0 +1,404 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\Auth\SessionGuard;
+use App\Http\Request;
+use App\Http\Response;
+use App\Repositories\SprintRepository;
+use App\Repositories\SprintWorkerDayRepository;
+use App\Repositories\SprintWorkerRepository;
+use App\Repositories\TaskAssignmentRepository;
+use App\Repositories\TaskRepository;
+use App\Repositories\UserRepository;
+use App\Repositories\WorkerRepository;
+use App\Services\AuditLogger;
+use App\Services\CapacityCalculator;
+use PDO;
+use Throwable;
+
+/**
+ * Task CRUD + assignments.
+ *
+ * Tasks live per-sprint; assignments are per (task, sprint_worker) cell.
+ * All endpoints are admin-only JSON. Every mutation writes per-row audit
+ * entries inside the same transaction as the DB change.
+ */
+final class TaskController
+{
+    public function __construct(
+        private readonly PDO                       $pdo,
+        private readonly UserRepository            $users,
+        private readonly SprintRepository          $sprints,
+        private readonly SprintWorkerRepository    $sprintWorkers,
+        private readonly SprintWorkerDayRepository $days,
+        private readonly TaskRepository            $tasks,
+        private readonly TaskAssignmentRepository  $assignments,
+        private readonly WorkerRepository          $workers,
+        private readonly AuditLogger               $audit,
+    ) {
+    }
+
+    /** POST /sprints/{id}/tasks — create a task (rows append at sort_order MAX+1). */
+    public function create(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $sprintId = (int) $params['id'];
+        $sprint   = $this->sprints->find($sprintId);
+        if ($sprint === null) {
+            return Response::err('not_found', 'Sprint not found', 404);
+        }
+
+        $body  = $req->json() ?? [];
+        $title = isset($body['title']) && is_string($body['title']) ? trim($body['title']) : '';
+        if ($title === '') {
+            $title = '(Untitled task)';
+        }
+
+        $priority = isset($body['priority']) ? (int) $body['priority'] : 1;
+        if ($priority !== 1 && $priority !== 2) {
+            return Response::err('validation', 'priority must be 1 or 2', 422);
+        }
+
+        $ownerWorkerId = null;
+        if (isset($body['owner_worker_id']) && $body['owner_worker_id'] !== null && $body['owner_worker_id'] !== '') {
+            $ownerWorkerId = (int) $body['owner_worker_id'];
+            if ($this->workers->find($ownerWorkerId) === null) {
+                return Response::err('validation', 'Unknown owner_worker_id', 422);
+            }
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $task = $this->tasks->create($sprintId, $title, $ownerWorkerId, $priority);
+            $this->audit->recordForRequest(
+                'CREATE', 'task', $task->id,
+                null, $task->toAuditSnapshot(),
+                $req, $actor,
+            );
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not create task', 500);
+        }
+
+        return Response::ok([
+            'task'        => $task->toAuditSnapshot(),
+            'assignments' => (object) [],  // new task, no assignments yet
+        ]);
+    }
+
+    /** PATCH /tasks/{id} — edit title / owner / priority. */
+    public function update(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $id = (int) $params['id'];
+        $task = $this->tasks->find($id);
+        if ($task === null) {
+            return Response::err('not_found', 'Task not found', 404);
+        }
+
+        $body    = $req->json() ?? [];
+        $changes = [];
+
+        if (array_key_exists('title', $body)) {
+            $title = is_string($body['title']) ? trim($body['title']) : '';
+            if ($title === '') {
+                return Response::err('validation', 'title cannot be empty', 422);
+            }
+            $changes['title'] = $title;
+        }
+
+        if (array_key_exists('priority', $body)) {
+            $p = (int) $body['priority'];
+            if ($p !== 1 && $p !== 2) {
+                return Response::err('validation', 'priority must be 1 or 2', 422);
+            }
+            $changes['priority'] = $p;
+        }
+
+        if (array_key_exists('owner_worker_id', $body)) {
+            $v = $body['owner_worker_id'];
+            if ($v === null || $v === '') {
+                $changes['owner_worker_id'] = null;
+            } else {
+                $ow = (int) $v;
+                if ($this->workers->find($ow) === null) {
+                    return Response::err('validation', 'Unknown owner_worker_id', 422);
+                }
+                $changes['owner_worker_id'] = $ow;
+            }
+        }
+
+        if ($changes === []) {
+            return Response::ok(['task' => $task->toAuditSnapshot()]);
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $result = $this->tasks->update($id, $changes);
+            $this->audit->recordForRequest(
+                'UPDATE', 'task', $id,
+                $result['before']->toAuditSnapshot(),
+                $result['after']->toAuditSnapshot(),
+                $req, $actor,
+            );
+
+            $responsePayload = [
+                'task' => $result['after']->toAuditSnapshot(),
+            ];
+
+            // If priority changed, touched workers' Available depends on prio-1
+            // commitments. Recompute capacity for every sprint worker so the
+            // client can paint the updated summary in one go.
+            if (array_key_exists('priority', $changes) && $changes['priority'] !== $result['before']->priority) {
+                $responsePayload['per_worker'] = $this->computeCapacity($result['after']->sprintId);
+            }
+
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not update task', 500);
+        }
+
+        return Response::ok($responsePayload);
+    }
+
+    /** DELETE /tasks/{id} — delete a task; audits each assignment before cascade. */
+    public function delete(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $id = (int) $params['id'];
+        $task = $this->tasks->find($id);
+        if ($task === null) {
+            return Response::err('not_found', 'Task not found', 404);
+        }
+
+        // Snapshot every assignment before the FK cascade wipes them.
+        $assignments = $this->assignments->allForTask($id);
+
+        $this->pdo->beginTransaction();
+        try {
+            foreach ($assignments as $a) {
+                $this->audit->recordForRequest(
+                    'DELETE', 'task_assignment', $a->id,
+                    $a->toAuditSnapshot(), null,
+                    $req, $actor,
+                );
+            }
+
+            $this->tasks->delete($id);
+            $this->audit->recordForRequest(
+                'DELETE', 'task', $id,
+                $task->toAuditSnapshot(), null,
+                $req, $actor,
+            );
+
+            $responsePayload = ['removed_id' => $id];
+
+            // If it was a prio-1 task, available changes on every worker that
+            // had an assignment.
+            if ($task->priority === 1 && $assignments !== []) {
+                $responsePayload['per_worker'] = $this->computeCapacity($task->sprintId);
+            }
+
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not delete task', 500);
+        }
+
+        return Response::ok($responsePayload);
+    }
+
+    /** POST /sprints/{id}/tasks/reorder — apply an ordering. */
+    public function reorder(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $sprintId = (int) $params['id'];
+        if ($this->sprints->find($sprintId) === null) {
+            return Response::err('not_found', 'Sprint not found', 404);
+        }
+
+        $body = $req->json();
+        if (!is_array($body) || !array_is_list($body)) {
+            return Response::err('validation', 'body must be a list of {task_id, sort_order}', 422);
+        }
+
+        $ordering  = [];
+        $seenOrder = [];
+        foreach ($body as $row) {
+            if (!is_array($row) || !isset($row['task_id'], $row['sort_order'])) {
+                return Response::err('validation', 'each entry needs task_id and sort_order', 422);
+            }
+            $tid   = (int) $row['task_id'];
+            $order = (int) $row['sort_order'];
+            if ($tid <= 0 || $order < 1) {
+                return Response::err('validation', 'ids/orders must be positive', 422);
+            }
+            if (isset($seenOrder[$order])) {
+                return Response::err('validation', 'duplicate sort_order', 422);
+            }
+            $seenOrder[$order] = true;
+            $ordering[] = ['task_id' => $tid, 'sort_order' => $order];
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $diffs = $this->tasks->reorder($sprintId, $ordering);
+            foreach ($diffs as $d) {
+                $this->audit->recordForRequest(
+                    'UPDATE', 'task', $d['after']->id,
+                    $d['before']->toAuditSnapshot(),
+                    $d['after']->toAuditSnapshot(),
+                    $req, $actor,
+                );
+            }
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not reorder', 500);
+        }
+
+        return Response::ok(['moved' => count($diffs)]);
+    }
+
+    /** PATCH /tasks/{id}/assignments — batch upsert of task_assignments. */
+    public function updateAssignments(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdminJson($req, $this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $taskId = (int) $params['id'];
+        $task = $this->tasks->find($taskId);
+        if ($task === null) {
+            return Response::err('not_found', 'Task not found', 404);
+        }
+
+        $body = $req->json();
+        if (!is_array($body) || !array_is_list($body)) {
+            return Response::err('validation', 'body must be a list of {sprint_worker_id, days}', 422);
+        }
+        if ($body === []) {
+            return Response::ok(['applied' => 0, 'noop' => 0]);
+        }
+
+        // Cross-check sprint worker IDs belong to the task's sprint.
+        $validSw = [];
+        foreach ($this->sprintWorkers->allForSprint($task->sprintId) as $sw) {
+            $validSw[$sw->id] = true;
+        }
+
+        $cells = [];
+        foreach ($body as $i => $row) {
+            if (!is_array($row) || !isset($row['sprint_worker_id'], $row['days'])) {
+                return Response::err('validation', "cell[{$i}] needs sprint_worker_id, days", 422);
+            }
+            $swId  = (int) $row['sprint_worker_id'];
+            $daysN = $row['days'];
+            if (!is_numeric($daysN)) {
+                return Response::err('validation', "cell[{$i}] days must be numeric", 422);
+            }
+            $days = (float) $daysN;
+            if (!isset($validSw[$swId])) {
+                return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422);
+            }
+            if ($days < 0) {
+                return Response::err('validation', "cell[{$i}] days cannot be negative", 422);
+            }
+            // Assignments step by 0.5 but have no hard upper bound per spec §3.
+            $doubled = $days * 2;
+            if (abs($doubled - round($doubled)) > 1e-9) {
+                return Response::err('validation', "cell[{$i}] days must step by 0.5", 422);
+            }
+            $cells[] = ['sw_id' => $swId, 'days' => $days];
+        }
+
+        $applied = 0;
+        $noop    = 0;
+
+        $this->pdo->beginTransaction();
+        try {
+            foreach ($cells as $c) {
+                $result = $this->assignments->upsert($taskId, $c['sw_id'], $c['days']);
+                if ($result['action'] === 'NOOP') {
+                    $noop++;
+                    continue;
+                }
+                $applied++;
+                $this->audit->recordForRequest(
+                    action:     $result['action'],
+                    entityType: 'task_assignment',
+                    entityId:   $result['after']?->id ?? $result['before']?->id,
+                    before:     $result['before']?->toAuditSnapshot(),
+                    after:      $result['after']?->toAuditSnapshot(),
+                    req:        $req,
+                    actor:      $actor,
+                );
+            }
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not save assignments', 500);
+        }
+
+        $data = [
+            'applied' => $applied,
+            'noop'    => $noop,
+            'task_id' => $taskId,
+        ];
+
+        if ($applied > 0 && $task->priority === 1) {
+            $data['per_worker'] = $this->computeCapacity($task->sprintId);
+        }
+
+        return Response::ok($data);
+    }
+
+    /**
+     * Full per-worker capacity recompute for a sprint. Used to keep the
+     * client-side capacity strip in sync when changes cascade across rows.
+     *
+     * @return array<string, array{ressourcen:float, after_reserves:float, committed_prio1:float, available:float}>
+     */
+    private function computeCapacity(int $sprintId): array
+    {
+        $sprint = $this->sprints->find($sprintId);
+        if ($sprint === null) {
+            return [];
+        }
+        $dayGrid   = $this->days->grid($sprintId);
+        $committed = $this->assignments->committedPrio1BySprint($sprintId);
+
+        $out = [];
+        foreach ($this->sprintWorkers->allForSprint($sprintId) as $sw) {
+            $ressourcen = array_sum($dayGrid[$sw->id] ?? []);
+            $out[(string) $sw->id] = CapacityCalculator::forWorker(
+                $ressourcen,
+                $sprint->reserveFraction,
+                $committed[$sw->id] ?? 0.0,
+            );
+        }
+        return $out;
+    }
+}

+ 34 - 0
src/Domain/Task.php

@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain;
+
+final class Task
+{
+    public function __construct(
+        public readonly int    $id,
+        public readonly int    $sprintId,
+        public readonly string $title,
+        public readonly ?int   $ownerWorkerId,
+        public readonly int    $priority,         // 1 or 2
+        public readonly int    $sortOrder,
+        public readonly string $createdAt,
+        public readonly string $updatedAt,
+    ) {
+    }
+
+    public function toAuditSnapshot(): array
+    {
+        return [
+            'id'              => $this->id,
+            'sprint_id'       => $this->sprintId,
+            'title'           => $this->title,
+            'owner_worker_id' => $this->ownerWorkerId,
+            'priority'        => $this->priority,
+            'sort_order'      => $this->sortOrder,
+            'created_at'      => $this->createdAt,
+            'updated_at'      => $this->updatedAt,
+        ];
+    }
+}

+ 26 - 0
src/Domain/TaskAssignment.php

@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain;
+
+final class TaskAssignment
+{
+    public function __construct(
+        public readonly int   $id,
+        public readonly int   $taskId,
+        public readonly int   $sprintWorkerId,
+        public readonly float $days,
+    ) {
+    }
+
+    public function toAuditSnapshot(): array
+    {
+        return [
+            'id'               => $this->id,
+            'task_id'          => $this->taskId,
+            'sprint_worker_id' => $this->sprintWorkerId,
+            'days'             => $this->days,
+        ];
+    }
+}

+ 137 - 0
src/Repositories/TaskAssignmentRepository.php

@@ -0,0 +1,137 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repositories;
+
+use App\Domain\TaskAssignment;
+use PDO;
+
+final class TaskAssignmentRepository
+{
+    public function __construct(private readonly PDO $pdo)
+    {
+    }
+
+    /** @return list<TaskAssignment> rows for a single task */
+    public function allForTask(int $taskId): array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT * FROM task_assignments WHERE task_id = ?'
+        );
+        $stmt->execute([$taskId]);
+        $out = [];
+        foreach ($stmt as $row) {
+            $out[] = self::hydrate($row);
+        }
+        return $out;
+    }
+
+    public function find(int $taskId, int $swId): ?TaskAssignment
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT * FROM task_assignments WHERE task_id = ? AND sprint_worker_id = ?'
+        );
+        $stmt->execute([$taskId, $swId]);
+        $row = $stmt->fetch();
+        return is_array($row) ? self::hydrate($row) : null;
+    }
+
+    /**
+     * Full grid for a sprint: [task_id][sw_id] => days. Tasks with no rows
+     * are simply absent from the outer map.
+     *
+     * @return array<int, array<int, float>>
+     */
+    public function gridForSprint(int $sprintId): array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT ta.task_id, ta.sprint_worker_id, ta.days
+             FROM task_assignments ta
+             JOIN tasks t ON t.id = ta.task_id
+             WHERE t.sprint_id = ?'
+        );
+        $stmt->execute([$sprintId]);
+        $out = [];
+        foreach ($stmt as $row) {
+            $tid = (int)   $row['task_id'];
+            $sw  = (int)   $row['sprint_worker_id'];
+            $d   = (float) $row['days'];
+            $out[$tid][$sw] = $d;
+        }
+        return $out;
+    }
+
+    /**
+     * Σ task_assignments.days where task.priority = 1, grouped by sprint_worker_id.
+     * Used by CapacityCalculator via SprintController::show.
+     *
+     * @return array<int, float>
+     */
+    public function committedPrio1BySprint(int $sprintId): array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT ta.sprint_worker_id, SUM(ta.days) AS committed
+             FROM task_assignments ta
+             JOIN tasks t ON t.id = ta.task_id
+             WHERE t.sprint_id = ? AND t.priority = 1
+             GROUP BY ta.sprint_worker_id'
+        );
+        $stmt->execute([$sprintId]);
+        $out = [];
+        foreach ($stmt as $row) {
+            $out[(int) $row['sprint_worker_id']] = (float) $row['committed'];
+        }
+        return $out;
+    }
+
+    /**
+     * Set days for one (task, sprint_worker) cell with the same four-case
+     * semantics as SprintWorkerDayRepository::upsert:
+     *   - empty cell, days=0   -> NOOP
+     *   - empty cell, days>0   -> CREATE
+     *   - existing, unchanged  -> NOOP
+     *   - existing, changed    -> UPDATE (row kept when zeroed)
+     *
+     * @return array{action:string, before: ?TaskAssignment, after: ?TaskAssignment}
+     */
+    public function upsert(int $taskId, int $swId, float $days): array
+    {
+        $existing = $this->find($taskId, $swId);
+
+        if ($existing !== null && abs($existing->days - $days) < 1e-9) {
+            return ['action' => 'NOOP', 'before' => $existing, 'after' => $existing];
+        }
+
+        if ($existing === null) {
+            if (abs($days) < 1e-9) {
+                return ['action' => 'NOOP', 'before' => null, 'after' => null];
+            }
+            $stmt = $this->pdo->prepare(
+                'INSERT INTO task_assignments (task_id, sprint_worker_id, days) VALUES (?, ?, ?)'
+            );
+            $stmt->execute([$taskId, $swId, $days]);
+            $id    = (int) $this->pdo->lastInsertId();
+            $after = new TaskAssignment($id, $taskId, $swId, $days);
+            return ['action' => 'CREATE', 'before' => null, 'after' => $after];
+        }
+
+        $stmt = $this->pdo->prepare('UPDATE task_assignments SET days = ? WHERE id = ?');
+        $stmt->execute([$days, $existing->id]);
+        $after = new TaskAssignment($existing->id, $taskId, $swId, $days);
+        return ['action' => 'UPDATE', 'before' => $existing, 'after' => $after];
+    }
+
+    /**
+     * @param array<string,mixed> $row
+     */
+    private static function hydrate(array $row): TaskAssignment
+    {
+        return new TaskAssignment(
+            id:              (int)   $row['id'],
+            taskId:          (int)   $row['task_id'],
+            sprintWorkerId:  (int)   $row['sprint_worker_id'],
+            days:            (float) $row['days'],
+        );
+    }
+}

+ 191 - 0
src/Repositories/TaskRepository.php

@@ -0,0 +1,191 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repositories;
+
+use App\Domain\Task;
+use PDO;
+use RuntimeException;
+
+final class TaskRepository
+{
+    /** Whitelisted updatable columns. */
+    private const UPDATABLE = ['title', 'owner_worker_id', 'priority'];
+
+    public function __construct(private readonly PDO $pdo)
+    {
+    }
+
+    /** @return list<Task> ordered by sort_order ASC */
+    public function allForSprint(int $sprintId): array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT * FROM tasks WHERE sprint_id = ? ORDER BY sort_order ASC'
+        );
+        $stmt->execute([$sprintId]);
+        $out = [];
+        foreach ($stmt as $row) {
+            $out[] = self::hydrate($row);
+        }
+        return $out;
+    }
+
+    public function find(int $id): ?Task
+    {
+        $stmt = $this->pdo->prepare('SELECT * FROM tasks WHERE id = ?');
+        $stmt->execute([$id]);
+        $row = $stmt->fetch();
+        return is_array($row) ? self::hydrate($row) : null;
+    }
+
+    public function create(
+        int $sprintId,
+        string $title,
+        ?int $ownerWorkerId,
+        int $priority,
+    ): Task {
+        $now = gmdate('Y-m-d\TH:i:s\Z');
+        $max = (int) $this->pdo
+            ->query('SELECT COALESCE(MAX(sort_order), 0) FROM tasks WHERE sprint_id = ' . $sprintId)
+            ->fetchColumn();
+        $stmt = $this->pdo->prepare(
+            'INSERT INTO tasks (sprint_id, title, owner_worker_id, priority, sort_order, created_at, updated_at)
+             VALUES (?, ?, ?, ?, ?, ?, ?)'
+        );
+        $stmt->execute([$sprintId, $title, $ownerWorkerId, $priority, $max + 1, $now, $now]);
+        $id = (int) $this->pdo->lastInsertId();
+        $task = $this->find($id);
+        if ($task === null) {
+            throw new RuntimeException('Inserted task not found');
+        }
+        return $task;
+    }
+
+    /**
+     * @param array<string,mixed> $changes
+     * @return array{before: Task, after: Task}
+     */
+    public function update(int $id, array $changes): array
+    {
+        $before = $this->find($id);
+        if ($before === null) {
+            throw new RuntimeException("Task {$id} not found");
+        }
+
+        $changes = array_intersect_key($changes, array_flip(self::UPDATABLE));
+        if ($changes === []) {
+            return ['before' => $before, 'after' => $before];
+        }
+
+        $sets = [];
+        $vals = [];
+        foreach ($changes as $col => $v) {
+            $sets[] = "{$col} = ?";
+            $vals[] = match ($col) {
+                'title'           => (string) $v,
+                'owner_worker_id' => $v === null ? null : (int) $v,
+                'priority'        => (int) $v,
+                default           => $v,
+            };
+        }
+        $sets[] = 'updated_at = ?';
+        $vals[] = gmdate('Y-m-d\TH:i:s\Z');
+        $vals[] = $id;
+
+        $stmt = $this->pdo->prepare(
+            'UPDATE tasks SET ' . implode(', ', $sets) . ' WHERE id = ?'
+        );
+        $stmt->execute($vals);
+
+        $after = $this->find($id) ?? $before;
+        return ['before' => $before, 'after' => $after];
+    }
+
+    /**
+     * Delete a task. Does NOT read cascaded assignment rows; the controller
+     * is responsible for auditing those BEFORE calling this method.
+     * Returns the pre-deletion row for auditing.
+     */
+    public function delete(int $id): ?Task
+    {
+        $before = $this->find($id);
+        if ($before === null) {
+            return null;
+        }
+        $this->pdo
+            ->prepare('DELETE FROM tasks WHERE id = ?')
+            ->execute([$id]);
+        return $before;
+    }
+
+    /**
+     * Apply an ordering of tasks within a sprint. Same two-phase negate-then-
+     * apply pattern as SprintWorkerRepository::reorder.
+     *
+     * @param list<array{task_id:int, sort_order:int}> $ordering
+     * @return list<array{before: Task, after: Task}>
+     */
+    public function reorder(int $sprintId, array $ordering): array
+    {
+        if ($ordering === []) {
+            return [];
+        }
+
+        $current = [];
+        foreach ($this->allForSprint($sprintId) as $t) {
+            $current[$t->id] = $t;
+        }
+
+        $stage = $this->pdo->prepare(
+            'UPDATE tasks SET sort_order = -? WHERE id = ? AND sprint_id = ?'
+        );
+        foreach ($ordering as $row) {
+            $stage->execute([$row['sort_order'], $row['task_id'], $sprintId]);
+        }
+
+        $apply = $this->pdo->prepare(
+            'UPDATE tasks SET sort_order = ?, updated_at = ? WHERE id = ? AND sprint_id = ?'
+        );
+        $now = gmdate('Y-m-d\TH:i:s\Z');
+        foreach ($ordering as $row) {
+            $apply->execute([$row['sort_order'], $now, $row['task_id'], $sprintId]);
+        }
+
+        $out = [];
+        foreach ($ordering as $row) {
+            $tid = (int) $row['task_id'];
+            $before = $current[$tid] ?? null;
+            if ($before === null) {
+                continue;
+            }
+            if ($before->sortOrder === (int) $row['sort_order']) {
+                continue;
+            }
+            $after = $this->find($tid);
+            if ($after !== null) {
+                $out[] = ['before' => $before, 'after' => $after];
+            }
+        }
+        return $out;
+    }
+
+    /**
+     * @param array<string,mixed> $row
+     */
+    private static function hydrate(array $row): Task
+    {
+        return new Task(
+            id:            (int)    $row['id'],
+            sprintId:      (int)    $row['sprint_id'],
+            title:         (string) $row['title'],
+            ownerWorkerId: isset($row['owner_worker_id']) && $row['owner_worker_id'] !== null
+                ? (int) $row['owner_worker_id']
+                : null,
+            priority:      (int)    $row['priority'],
+            sortOrder:     (int)    $row['sort_order'],
+            createdAt:     (string) $row['created_at'],
+            updatedAt:     (string) $row['updated_at'],
+        );
+    }
+}

+ 163 - 4
views/sprints/show.php

@@ -6,7 +6,13 @@
 /** @var list<\App\Domain\SprintWorker> $sprintWorkers */
 /** @var array<int, array<int, float>> $grid        sw_id => week_id => days */
 /** @var array<int, array{ressourcen:float, after_reserves:float, committed_prio1:float, available:float}> $capacity */
+/** @var list<\App\Domain\Task>        $tasks */
+/** @var array<int, array<int, float>> $taskGrid    task_id => sw_id => days */
+/** @var list<\App\Domain\Worker>      $ownerChoices */
 use function App\Http\e;
+$tasks        = $tasks        ?? [];
+$taskGrid     = $taskGrid     ?? [];
+$ownerChoices = $ownerChoices ?? [];
 
 if (!function_exists('fmt_days')) {
     function fmt_days(float $x): string
@@ -200,11 +206,164 @@ if (!function_exists('fmt_days')) {
             with a 400&nbsp;ms debounce; Available turns red if a worker is overcommitted.
         </p>
 
-    <?php endif; ?>
+    <!-- Section B: Task list -->
+    <section class="rounded-lg border bg-white overflow-hidden"
+             data-task-section>
+        <div class="px-4 py-3 border-b bg-slate-50 flex flex-wrap items-center gap-2">
+            <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Tasks</h2>
+
+            <!-- Toolbar -->
+            <div class="ml-auto flex flex-wrap items-center gap-2">
+                <input type="search" data-task-search placeholder="Search…"
+                       class="rounded border border-slate-300 px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
+
+                <select data-prio-filter
+                        class="rounded border border-slate-300 px-2 py-1 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
+                    <option value="">All prios</option>
+                    <option value="1">Prio 1 only</option>
+                    <option value="2">Prio 2 only</option>
+                </select>
+
+                <select data-owner-filter
+                        class="rounded border border-slate-300 px-2 py-1 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
+                    <option value="">All owners</option>
+                    <option value="__none__">No owner</option>
+                    <?php foreach ($ownerChoices as $ow): ?>
+                        <option value="<?= (int) $ow->id ?>"><?= e($ow->name) ?></option>
+                    <?php endforeach; ?>
+                </select>
+
+                <?php if ($currentUser->isAdmin): ?>
+                    <button type="button" data-add-task
+                            class="rounded bg-slate-900 text-white px-3 py-1 text-sm font-medium hover:bg-slate-800">
+                        + Add task
+                    </button>
+                <?php endif; ?>
+            </div>
+        </div>
 
-    <div class="rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
-        Task list lands in Phase 6.
-    </div>
+        <div class="overflow-x-auto">
+            <table class="min-w-full text-sm" data-task-table>
+                <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
+                    <tr>
+                        <th class="w-6 px-2 py-2"></th>
+                        <th class="text-left px-2 py-2 font-semibold cursor-pointer select-none"
+                            data-sort-col="title">Task <span class="sort-ind opacity-30">↕</span></th>
+                        <th class="text-left px-2 py-2 font-semibold cursor-pointer select-none"
+                            data-sort-col="owner">Owner <span class="sort-ind opacity-30">↕</span></th>
+                        <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none"
+                            data-sort-col="prio">Prio <span class="sort-ind opacity-30">↕</span></th>
+                        <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none"
+                            data-sort-col="tot">Tot <span class="sort-ind opacity-30">↕</span></th>
+                        <?php foreach ($sprintWorkers as $sw): ?>
+                            <th class="text-center px-2 py-2 font-semibold cursor-pointer select-none whitespace-nowrap"
+                                data-sort-col="sw-<?= (int) $sw->id ?>">
+                                <?= e($sw->workerName) ?>
+                                <span class="sort-ind opacity-30">↕</span>
+                            </th>
+                        <?php endforeach; ?>
+                        <th class="w-8 px-2 py-2"></th>
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100" data-task-tbody>
+                    <?php if ($tasks === []): ?>
+                        <tr data-empty-tasks>
+                            <td colspan="<?= 6 + count($sprintWorkers) ?>" class="px-3 py-8 text-center text-slate-500 text-sm">
+                                No tasks yet.
+                                <?php if ($currentUser->isAdmin): ?>
+                                    Click <b>+ Add task</b> to start.
+                                <?php endif; ?>
+                            </td>
+                        </tr>
+                    <?php else: ?>
+                        <?php foreach ($tasks as $t): ?>
+                            <?php $assign = $taskGrid[$t->id] ?? []; $tot = array_sum($assign); ?>
+                            <tr data-task-row
+                                data-task-id="<?= (int) $t->id ?>"
+                                data-prio="<?= (int) $t->priority ?>"
+                                data-owner="<?= $t->ownerWorkerId !== null ? (int) $t->ownerWorkerId : '' ?>"
+                                data-sort-order="<?= (int) $t->sortOrder ?>">
+                                <td class="px-2 py-1">
+                                    <?php if ($currentUser->isAdmin): ?>
+                                        <span class="handle cursor-grab text-slate-400 select-none">&#8801;</span>
+                                    <?php endif; ?>
+                                </td>
+                                <td class="px-2 py-1 min-w-[14rem]">
+                                    <?php if ($currentUser->isAdmin): ?>
+                                        <input type="text" data-title
+                                               value="<?= e($t->title) ?>"
+                                               class="w-full rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                    <?php else: ?>
+                                        <span><?= e($t->title) ?></span>
+                                    <?php endif; ?>
+                                </td>
+                                <td class="px-2 py-1">
+                                    <?php if ($currentUser->isAdmin): ?>
+                                        <select data-owner-select
+                                                class="w-full rounded border border-slate-200 px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                            <option value="">—</option>
+                                            <?php foreach ($ownerChoices as $ow): ?>
+                                                <option value="<?= (int) $ow->id ?>" <?= $t->ownerWorkerId === $ow->id ? 'selected' : '' ?>>
+                                                    <?= e($ow->name) ?>
+                                                </option>
+                                            <?php endforeach; ?>
+                                        </select>
+                                    <?php else: ?>
+                                        <?php
+                                        $ownerName = '—';
+                                        foreach ($ownerChoices as $ow) {
+                                            if ($ow->id === $t->ownerWorkerId) { $ownerName = $ow->name; break; }
+                                        }
+                                        echo e($ownerName);
+                                        ?>
+                                    <?php endif; ?>
+                                </td>
+                                <td class="px-2 py-1 text-center">
+                                    <?php if ($currentUser->isAdmin): ?>
+                                        <select data-prio-select
+                                                class="rounded border border-slate-200 px-2 py-1 bg-white font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                            <option value="1" <?= $t->priority === 1 ? 'selected' : '' ?>>1</option>
+                                            <option value="2" <?= $t->priority === 2 ? 'selected' : '' ?>>2</option>
+                                        </select>
+                                    <?php else: ?>
+                                        <span class="font-mono"><?= (int) $t->priority ?></span>
+                                    <?php endif; ?>
+                                </td>
+                                <td class="px-2 py-1 text-center font-mono font-semibold"
+                                    data-task-tot>
+                                    <?= e(fmt_days($tot)) ?>
+                                </td>
+                                <?php foreach ($sprintWorkers as $sw): $d = (float) ($assign[$sw->id] ?? 0.0); ?>
+                                    <td class="px-1 py-1 text-center"
+                                        data-sort-value-sw-<?= (int) $sw->id ?>="<?= e(number_format($d, 2, '.', '')) ?>">
+                                        <?php if ($currentUser->isAdmin): ?>
+                                            <input type="number" min="0" step="0.5"
+                                                   value="<?= e(fmt_days($d)) ?>"
+                                                   data-assign
+                                                   data-sw-id="<?= (int) $sw->id ?>"
+                                                   class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                        <?php else: ?>
+                                            <span class="font-mono"><?= e(fmt_days($d)) ?></span>
+                                        <?php endif; ?>
+                                    </td>
+                                <?php endforeach; ?>
+                                <td class="px-1 py-1 text-right">
+                                    <?php if ($currentUser->isAdmin): ?>
+                                        <button type="button" data-delete-task
+                                                class="text-sm text-red-600 hover:underline">×</button>
+                                    <?php endif; ?>
+                                </td>
+                            </tr>
+                        <?php endforeach; ?>
+                    <?php endif; ?>
+                </tbody>
+            </table>
+        </div>
+        <div data-task-empty-filter class="hidden p-4 text-center text-slate-500 text-sm">
+            No tasks match the current filters.
+        </div>
+    </section>
+    <?php endif; ?>
 </section>
 
 <script src="/assets/js/sprint-planner.js" defer></script>