ソースを参照

Phase 18: per-cell task-status colours + filter + global toggle

Each task-assignment cell on /sprints/{id} and /sprints/{id}/present now
carries a workflow status — zugewiesen (transparent, default), gestartet
(yellow), abgeschlossen (green), abgebrochen (red) — picked from a small
chevron-only <select> next to the day input. The cell wrapper takes the
matching .assign-status-* class, so the colour shows under the day field
and the status selector together.

A new "Status" multi-select filter sits between Owners and Focus in the
task-list toolbar. A row passes when at least one of its cells is in the
picked set; the default 'zugewiesen' state matches only when days > 0
so picking it doesn't include every task. State persists per sprint (and
:beamer-namespaced for present view) in localStorage; the Reset button
clears it alongside the existing filters.

The whole feature is gated by a new app_settings.task_status_enabled
flag, off by default. A new admin-only /settings page (linked from the
hamburger menu) exposes it. PATCH /tasks/{id}/assignments/status — the
first non-admin write surface — accepts status updates from any
signed-in user (CSRF + per-cell audit unchanged), refuses with 403 when
the flag is off; the existing PATCH /tasks/{id}/assignments stays
admin-only and days-only.

Schema: migration 003 adds task_assignments.status TEXT NOT NULL DEFAULT
'zugewiesen' with CHECK constraint, plus an app_settings(key,value,
updated_at) KV table seeded with the toggle off. The status column
preserves through every existing days-only path (TaskAssignment::upsert)
and is independently writable through TaskAssignment::upsertStatus,
which inserts a days=0 row when none exists so a state can be tracked
even before any work is assigned.

Tailwind: the four .assign-status-* class names are interpolated server-
side ("assign-status-<?= $st ?>"), so they go through tailwind.config.js
safelist; the build was silently dropping them otherwise. Strict CSP
unchanged — no inline handlers, no new external hosts.

Tests: 105 / 265 (was 88 / 208). New TaskAssignmentTest pins the status
enum + audit-snapshot shape; AppSettingsRepositoryTest covers the seeded
flag, get/set roundtrip, and the no-op equality rule;
TaskAssignmentRepositoryTest covers upsertStatus's four cases (NOOP on
default for empty, CREATE with days=0 on explicit for empty, UPDATE
preserving days on existing, days writes preserving status), the
InvalidArgumentException guard, and statusGridForSprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 3 日 前
コミット
9cb7669e6b

+ 61 - 0
assets/css/input.css

@@ -55,4 +55,65 @@
         transform: rotate(180deg);
         padding: 0.5rem 0.25rem;
     }
+
+    /* Phase 18: per-cell task-assignment status. Each <td> holding a
+       day input also wraps it in a .assign-cell with an
+       .assign-status-* color class. The class is on the wrapper so
+       both the day input/span and the chevron-only <select> share
+       the tint. The selector itself shows only its native arrow —
+       no text, no width — so it sits as a tiny chevron next to the
+       day field. */
+    .assign-cell {
+        display: inline-flex;
+        align-items: center;
+        gap: 1px;
+        padding: 1px;
+        border-radius: 0.25rem;
+        line-height: 1;
+    }
+    .assign-status-zugewiesen { background-color: transparent; }
+    .assign-status-gestartet     { background-color: theme('colors.yellow.200'); }
+    .assign-status-abgeschlossen { background-color: theme('colors.green.200'); }
+    .assign-status-abgebrochen   { background-color: theme('colors.red.200'); }
+    .dark .assign-status-gestartet     { background-color: theme('colors.yellow.700'); }
+    .dark .assign-status-abgeschlossen { background-color: theme('colors.green.700'); }
+    .dark .assign-status-abgebrochen   { background-color: theme('colors.red.700'); }
+
+    /* Inputs/spans inside a coloured cell get a transparent background
+       so the wrapper tint shows through; the day input keeps its own
+       focus ring. */
+    .assign-cell:not(.assign-status-zugewiesen) input[data-assign],
+    .assign-cell:not(.assign-status-zugewiesen) > .font-mono {
+        background-color: transparent;
+    }
+
+    .assign-status-select {
+        /* Strip the native <select> body — keep only the dropdown
+           chevron. We hide the text by sizing the box to zero width
+           plus padding for the arrow, and rely on each browser's
+           default arrow rendering. */
+        width: 1.1rem;
+        min-width: 0;
+        padding: 0;
+        margin: 0;
+        border: 0;
+        background-color: transparent;
+        font-size: 0;          /* hide selected option text */
+        line-height: 1;
+        color: transparent;    /* fallback if font-size:0 fails */
+        cursor: pointer;
+    }
+    .assign-status-select:focus {
+        outline: 2px solid theme('colors.slate.400');
+        outline-offset: 1px;
+        border-radius: 2px;
+    }
+    .assign-status-select option {
+        font-size: 0.875rem;
+        color: theme('colors.slate.900');
+    }
+    .dark .assign-status-select option {
+        color: theme('colors.slate.100');
+        background-color: theme('colors.slate.800');
+    }
 }

+ 24 - 0
migrations/003_task_status_and_app_settings.sql

@@ -0,0 +1,24 @@
+-- Phase 18: per-cell task-assignment status + global feature toggle.
+--
+-- task_assignments.status names where this cell sits in the workflow:
+--   zugewiesen    — assigned (default; transparent in UI)
+--   gestartet     — in progress (yellow)
+--   abgeschlossen — done (green)
+--   abgebrochen   — cancelled (red)
+--
+-- The whole feature is gated by app_settings.task_status_enabled. Default is
+-- '0' (disabled) so the UI is unchanged on first deploy until an admin flips
+-- it on under /settings.
+
+ALTER TABLE task_assignments
+    ADD COLUMN status TEXT NOT NULL DEFAULT 'zugewiesen'
+    CHECK (status IN ('zugewiesen','gestartet','abgeschlossen','abgebrochen'));
+
+CREATE TABLE app_settings (
+    key        TEXT PRIMARY KEY,
+    value      TEXT NOT NULL,
+    updated_at TEXT NOT NULL
+);
+
+INSERT INTO app_settings (key, value, updated_at)
+VALUES ('task_status_enabled', '0', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'));

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

@@ -31,6 +31,14 @@
     const isBeamer   = Number($root.data('beamer')) === 1;
     const keySuffix  = isBeamer ? ':beamer' : '';
 
+    // Phase 18: per-cell task-assignment status. The flag is rendered onto
+    // the [data-task-section] element so both this view and present.php
+    // light up identically. When false, the per-cell selectors and the
+    // toolbar Status filter are simply absent from the DOM.
+    const taskStatusEnabled =
+        $root.find('[data-task-section]').attr('data-task-status-enabled') === '1';
+    const STATUSES = ['zugewiesen', 'gestartet', 'abgeschlossen', 'abgebrochen'];
+
     // ---------------------------------------------------------------------
     // Capacity math — MUST match App\Services\CapacityCalculator
     // ---------------------------------------------------------------------
@@ -386,11 +394,31 @@
             const $td = $('<td class="px-1 py-1 text-center"></td>')
                 .attr('data-col', 'sw-' + sw.id)
                 .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))
+
+            const $input = $('<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);
+
+            if (taskStatusEnabled) {
+                // New tasks always start with the default status.
+                const $cell = $('<span class="assign-cell assign-status-zugewiesen"></span>')
+                    .attr('data-assign-cell', '')
                     .attr('data-sw-id', sw.id)
-            );
+                    .attr('data-status', 'zugewiesen');
+                $cell.append($input);
+
+                const $status = $('<select data-assign-status aria-label="Status" class="assign-status-select"></select>')
+                    .attr('data-sw-id', sw.id);
+                STATUSES.forEach(function (s) {
+                    $('<option>').val(s).text(s).appendTo($status);
+                });
+                $status.val('zugewiesen');
+                $cell.append($status);
+
+                $td.append($cell);
+            } else {
+                $td.append($input);
+            }
             $tr.append($td);
         });
 
@@ -548,6 +576,62 @@
         recomputeAllCapacity();
     });
 
+    // --- Phase 18: per-cell status save pipeline -------------------------
+    // Independent of the days pipeline: hits /tasks/{id}/assignments/status
+    // (signed-in route, gated by app_settings.task_status_enabled). Same
+    // debounce semantics + same audit weight (one row per changed cell).
+
+    const pendingStatus = new Map(); // taskId -> Map<swId, status>
+    const statusTimers  = {};
+
+    function queueStatus(taskId, swId, status) {
+        if (!pendingStatus.has(taskId)) { pendingStatus.set(taskId, new Map()); }
+        pendingStatus.get(taskId).set(swId, status);
+        clearTimeout(statusTimers[taskId]);
+        statusTimers[taskId] = setTimeout(function () { flushStatus(taskId); }, 400);
+    }
+
+    function flushStatus(taskId) {
+        const m = pendingStatus.get(taskId);
+        if (!m || m.size === 0) { return; }
+        const cells = [];
+        m.forEach(function (status, swId) {
+            cells.push({ sprint_worker_id: swId, status: status });
+        });
+        pendingStatus.delete(taskId);
+
+        request('PATCH', '/tasks/' + taskId + '/assignments/status', cells)
+            .then(function (data) {
+                if (data.applied === 0 && data.noop > 0) { flash('No changes'); }
+                else { flash('Saved ' + data.applied + (data.applied === 1 ? ' status' : ' statuses')); }
+            })
+            .catch(function (e) { flash(e.message, true); });
+    }
+
+    function applyStatusClass($cell, next) {
+        // Replace any existing assign-status-* with the new one. Keep the
+        // class set deterministic (any unknown classes get scrubbed).
+        STATUSES.forEach(function (s) { $cell.removeClass('assign-status-' + s); });
+        $cell.addClass('assign-status-' + next);
+        $cell.attr('data-status', next);
+    }
+
+    $root.on('change', '[data-assign-status]', function () {
+        const $sel  = $(this);
+        const next  = String($sel.val() || '');
+        if (STATUSES.indexOf(next) === -1) { return; }
+        const $cell = $sel.closest('[data-assign-cell]');
+        applyStatusClass($cell, next);
+
+        const taskId = parseInt($sel.closest('tr').data('task-id'), 10);
+        const swId   = parseInt($sel.data('sw-id'), 10);
+        queueStatus(taskId, swId, next);
+
+        // Re-evaluate the status filter immediately so the row hides /
+        // shows without waiting for the server round-trip.
+        applyFilters();
+    });
+
     function applyServerCapacity(perWorker) {
         if (!perWorker || typeof perWorker !== 'object') { return; }
         Object.keys(perWorker).forEach(function (swIdStr) {
@@ -677,7 +761,86 @@
         $sel.val(focusWorker);
     }
 
-    // --- Filters (search / prio / owner / focus) --------------------------
+    // --- Phase 18: status filter (multi-select, persisted) ----------------
+
+    const statusFilterKey = 'sp:' + sprintId + ':statusFilter' + keySuffix;
+    /** @type {Set<string>} */
+    const statusFilterSet = (function () {
+        if (!taskStatusEnabled) { return new Set(); }
+        try {
+            const raw = window.localStorage.getItem(statusFilterKey);
+            if (raw) {
+                const arr = JSON.parse(raw);
+                if (Array.isArray(arr)) { return new Set(arr.map(String)); }
+            }
+        } catch (_) { /* ignore */ }
+        return new Set();
+    })();
+
+    function persistStatusFilter() {
+        try {
+            window.localStorage.setItem(statusFilterKey, JSON.stringify(Array.from(statusFilterSet)));
+        } catch (_) { /* ignore quota / private mode */ }
+    }
+
+    function updateStatusFilterUi() {
+        $root.find('[data-status-filter-opt]').each(function () {
+            $(this).prop('checked', statusFilterSet.has(String($(this).val())));
+        });
+        const n = statusFilterSet.size;
+        $root.find('[data-status-filter-count]').text(n === 0 ? '' : '(' + n + ')');
+    }
+
+    $root.on('change', '[data-status-filter-opt]', function () {
+        const v = String($(this).val());
+        if (STATUSES.indexOf(v) === -1) { return; }
+        if ($(this).is(':checked')) { statusFilterSet.add(v); } else { statusFilterSet.delete(v); }
+        persistStatusFilter();
+        updateStatusFilterUi();
+        applyFilters();
+    });
+
+    $root.on('click', '[data-status-filter-clear]', function () {
+        statusFilterSet.clear();
+        persistStatusFilter();
+        updateStatusFilterUi();
+        applyFilters();
+    });
+
+    $root.on('click', '[data-status-filter-trigger]', function (ev) {
+        ev.stopPropagation();
+        $root.find('[data-owner-filter-dropdown]').addClass('hidden');
+        $root.find('[data-columns-dropdown]').addClass('hidden');
+        $root.find('[data-status-filter-dropdown]').toggleClass('hidden');
+    });
+
+    // Predicate: row passes if at least one of its cells is in the picked
+    // status set. The default 'zugewiesen' state matches only when there's
+    // actual work assigned (days > 0) so picking it doesn't match every
+    // task; the explicit states (gestartet/abgeschlossen/abgebrochen) match
+    // regardless of days because a user only sets them deliberately.
+    function rowMatchesStatusFilter($row) {
+        if (statusFilterSet.size === 0) { return true; }
+        let matched = false;
+        $row.find('[data-assign-cell]').each(function () {
+            const $cell  = $(this);
+            const status = String($cell.attr('data-status') || 'zugewiesen');
+            if (!statusFilterSet.has(status)) { return; }
+            if (status === 'zugewiesen') {
+                const $inp = $cell.find('[data-assign]');
+                const days = $inp.length
+                    ? (Number($inp.val()) || 0)
+                    : (Number($cell.find('.font-mono').text()) || 0);
+                if (days > 0) { matched = true; return false; }
+                return;
+            }
+            matched = true;
+            return false;
+        });
+        return matched;
+    }
+
+    // --- Filters (search / prio / owner / focus / status) ----------------
 
     function applyFilters() {
         const q    = String($root.find('[data-task-search]').val() || '').trim().toLowerCase();
@@ -703,6 +866,7 @@
                 const v = Number($row.find('[data-assign][data-sw-id="' + focus + '"]').val());
                 if (!(v > 0)) { ok = false; }
             }
+            if (ok && taskStatusEnabled && !rowMatchesStatusFilter($row)) { ok = false; }
 
             $row.toggle(ok);
             if (ok) { visibleCount++; }
@@ -814,7 +978,8 @@
             || prio !== ''
             || ownerFilterSet.size > 0
             || String(focusWorker || '') !== ''
-            || hiddenCols.size > 0;
+            || hiddenCols.size > 0
+            || (taskStatusEnabled && statusFilterSet.size > 0);
     }
 
     function updateResetVisibility() {
@@ -830,9 +995,14 @@
         persistFocus();
         hiddenCols.clear();
         persistHiddenCols();
+        if (taskStatusEnabled) {
+            statusFilterSet.clear();
+            persistStatusFilter();
+        }
 
         updateOwnerFilterUi();
         updateFocusUi();
+        if (taskStatusEnabled) { updateStatusFilterUi(); }
         applyColumnVisibility();
         applyFilters();
     });
@@ -851,6 +1021,9 @@
         if ($(ev.target).closest('[data-columns-root]').length === 0) {
             $root.find('[data-columns-dropdown]').addClass('hidden');
         }
+        if ($(ev.target).closest('[data-status-filter-root]').length === 0) {
+            $root.find('[data-status-filter-dropdown]').addClass('hidden');
+        }
     });
 
     // --- Column sort (client-side) ----------------------------------------
@@ -933,6 +1106,7 @@
     // columns don't briefly flash in before being toggled off.
     updateOwnerFilterUi();
     updateFocusUi();
+    if (taskStatusEnabled) { updateStatusFilterUi(); }
     applyColumnVisibility();
     applyFilters();
     // Reset button visibility is a function of every filter; applyFilters

+ 12 - 2
public/index.php

@@ -7,6 +7,7 @@ use App\Auth\OidcClient;
 use App\Auth\SessionGuard;
 use App\Controllers\AuditController;
 use App\Controllers\AuthController;
+use App\Controllers\SettingsController;
 use App\Controllers\SprintController;
 use App\Controllers\TaskController;
 use App\Controllers\UserController;
@@ -17,6 +18,7 @@ use App\Http\Request;
 use App\Http\Response;
 use App\Http\Router;
 use App\Http\View;
+use App\Repositories\AppSettingsRepository;
 use App\Repositories\AuditRepository;
 use App\Repositories\SprintRepository;
 use App\Repositories\SprintWeekRepository;
@@ -92,19 +94,21 @@ $swDays         = new SprintWorkerDayRepository($pdo);
 $tasks          = new TaskRepository($pdo);
 $taskAssign     = new TaskAssignmentRepository($pdo);
 $auditRepo      = new AuditRepository($pdo);
+$appSettings    = new AppSettingsRepository($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,
+    $tasks, $taskAssign, $workers, $audit, $view, $appSettings,
 );
 $taskCtrl       = new TaskController(
     $pdo, $users, $sprints, $sprintWorkers, $swDays,
-    $tasks, $taskAssign, $workers, $audit,
+    $tasks, $taskAssign, $workers, $audit, $appSettings,
 );
 $auditCtrl      = new AuditController($users, $auditRepo, $view);
 $userCtrl       = new UserController($pdo, $users, $audit, $view);
+$settingsCtrl   = new SettingsController($pdo, $users, $appSettings, $audit, $view);
 
 // ---------------------------------------------------------------------------
 // Routing
@@ -173,6 +177,12 @@ $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(...));
+// Phase 18 — task-cell status (any signed-in user, gated by global flag):
+$router->patch('/tasks/{id}/assignments/status',      $taskCtrl->updateAssignmentsStatus(...));
+
+// Phase 18 — global app settings (admin):
+$router->get('/settings',                             $settingsCtrl->show(...));
+$router->post('/settings',                            $settingsCtrl->update(...));
 
 // ---------------------------------------------------------------------------
 // Dispatch

+ 17 - 0
src/Auth/SessionGuard.php

@@ -178,4 +178,21 @@ final class SessionGuard
         }
         return $user;
     }
+
+    /**
+     * JSON gate for endpoints any signed-in user may hit (Phase 18:
+     * task-status writes are the first such surface). Auth + CSRF, no admin
+     * requirement.
+     */
+    public static function requireAuthJson(Request $req, UserRepository $users): User|Response
+    {
+        $user = self::currentUser($users);
+        if ($user === null) {
+            return Response::err('unauthenticated', 'Sign in required', 401);
+        }
+        if (!self::verifyCsrf($req)) {
+            return Response::err('csrf', 'CSRF token invalid', 403);
+        }
+        return $user;
+    }
 }

+ 108 - 0
src/Controllers/SettingsController.php

@@ -0,0 +1,108 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\Auth\SessionGuard;
+use App\Http\Request;
+use App\Http\Response;
+use App\Http\View;
+use App\Repositories\AppSettingsRepository;
+use App\Repositories\UserRepository;
+use App\Services\AuditLogger;
+use PDO;
+use Throwable;
+
+/**
+ * /settings — admin-only global configuration.
+ *
+ * Holds keys with app-wide effect. Currently:
+ *   task_status_enabled — toggles Phase 18 per-cell status selectors +
+ *                         filter on /sprints/{id} and /sprints/{id}/present.
+ *
+ * Updates POST a form (CSRF via _csrf input, like /auth/logout) and audit
+ * with entity_type='app_setting', entity_id=null. Pure admin surface; no
+ * per-sprint scoping.
+ */
+final class SettingsController
+{
+    /** Whitelisted keys + their human descriptions. */
+    public const KEYS = [
+        'task_status_enabled' => 'Task status colors',
+    ];
+
+    public function __construct(
+        private readonly PDO                   $pdo,
+        private readonly UserRepository        $users,
+        private readonly AppSettingsRepository $settings,
+        private readonly AuditLogger           $audit,
+        private readonly View                  $view,
+    ) {
+    }
+
+    /** GET /settings — admin only. */
+    public function show(Request $req): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $values = [];
+        foreach (array_keys(self::KEYS) as $k) {
+            $values[$k] = $this->settings->getBool($k, false);
+        }
+
+        return Response::html($this->view->render('settings/index', [
+            'title'       => 'Settings',
+            'currentUser' => $actor,
+            'csrfToken'   => SessionGuard::csrfToken(),
+            'values'      => $values,
+            'keyLabels'   => self::KEYS,
+            'flash'       => $req->queryString('updated') !== '' ? 'Saved' : null,
+        ]));
+    }
+
+    /** POST /settings — admin only, CSRF via _csrf form field. */
+    public function update(Request $req): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+        if (!SessionGuard::verifyCsrf($req)) {
+            return Response::text('CSRF token invalid', 403);
+        }
+
+        $changed = [];
+
+        $this->pdo->beginTransaction();
+        try {
+            foreach (array_keys(self::KEYS) as $key) {
+                // Checkboxes that are unchecked don't post a value at all,
+                // so absence == off.
+                $raw = isset($req->post[$key]) ? (string) $req->post[$key] : '';
+                $next = in_array($raw, ['1', 'on', 'true'], true) ? '1' : '0';
+
+                $result = $this->settings->set($key, $next);
+                if ($result['before'] === $result['after']) {
+                    continue;
+                }
+                $this->audit->recordForRequest(
+                    'UPDATE', 'app_setting', null,
+                    ['key' => $key, 'value' => $result['before']],
+                    ['key' => $key, 'value' => $result['after']],
+                    $req, $actor,
+                );
+                $changed[] = $key;
+            }
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::redirect('/settings?error=db');
+        }
+
+        return Response::redirect('/settings?updated=1');
+    }
+}

+ 15 - 8
src/Controllers/SprintController.php

@@ -11,6 +11,7 @@ use App\Http\Request;
 use App\Http\Response;
 use App\Http\View;
 use InvalidArgumentException;
+use App\Repositories\AppSettingsRepository;
 use App\Repositories\SprintRepository;
 use App\Repositories\SprintWeekRepository;
 use App\Repositories\SprintWorkerDayRepository;
@@ -40,6 +41,7 @@ final class SprintController
         private readonly WorkerRepository          $workers,
         private readonly AuditLogger               $audit,
         private readonly View                      $view,
+        private readonly AppSettingsRepository     $appSettings,
     ) {
     }
 
@@ -215,7 +217,9 @@ final class SprintController
      *   capacity: array<int, array{ressourcen:float, after_reserves:float, committed_prio1:float, available:float}>,
      *   tasks: list<\App\Domain\Task>,
      *   taskGrid: array<int, array<int, float>>,
+     *   statusGrid: array<int, array<int, string>>,
      *   ownerChoices: list<\App\Domain\Worker>,
+     *   taskStatusEnabled: bool,
      * }|null
      */
     private function loadSprintPage(int $id): ?array
@@ -230,6 +234,7 @@ final class SprintController
         $grid          = $this->days->grid($id);
         $tasks         = $this->tasks->allForSprint($id);
         $taskGrid      = $this->assignments->gridForSprint($id);
+        $statusGrid    = $this->assignments->statusGridForSprint($id);
         $committedP1   = $this->assignments->committedPrio1BySprint($id);
 
         // Seed initial capacity server-side so the page is meaningful without JS
@@ -251,14 +256,16 @@ final class SprintController
         $ownerChoices = $this->workers->all();
 
         return [
-            'sprint'        => $sprint,
-            'weeks'         => $weeks,
-            'sprintWorkers' => $sprintWorkers,
-            'grid'          => $grid,
-            'capacity'      => $capacity,
-            'tasks'         => $tasks,
-            'taskGrid'      => $taskGrid,
-            'ownerChoices'  => $ownerChoices,
+            'sprint'            => $sprint,
+            'weeks'             => $weeks,
+            'sprintWorkers'     => $sprintWorkers,
+            'grid'              => $grid,
+            'capacity'          => $capacity,
+            'tasks'             => $tasks,
+            'taskGrid'          => $taskGrid,
+            'statusGrid'        => $statusGrid,
+            'ownerChoices'      => $ownerChoices,
+            'taskStatusEnabled' => $this->appSettings->getBool('task_status_enabled', false),
         ];
     }
 

+ 99 - 0
src/Controllers/TaskController.php

@@ -5,8 +5,10 @@ declare(strict_types=1);
 namespace App\Controllers;
 
 use App\Auth\SessionGuard;
+use App\Domain\TaskAssignment;
 use App\Http\Request;
 use App\Http\Response;
+use App\Repositories\AppSettingsRepository;
 use App\Repositories\SprintRepository;
 use App\Repositories\SprintWorkerDayRepository;
 use App\Repositories\SprintWorkerRepository;
@@ -16,6 +18,7 @@ use App\Repositories\UserRepository;
 use App\Repositories\WorkerRepository;
 use App\Services\AuditLogger;
 use App\Services\CapacityCalculator;
+use InvalidArgumentException;
 use PDO;
 use Throwable;
 
@@ -38,6 +41,7 @@ final class TaskController
         private readonly TaskAssignmentRepository  $assignments,
         private readonly WorkerRepository          $workers,
         private readonly AuditLogger               $audit,
+        private readonly AppSettingsRepository     $appSettings,
     ) {
     }
 
@@ -375,6 +379,101 @@ final class TaskController
         return Response::ok($data);
     }
 
+    /**
+     * PATCH /tasks/{id}/assignments/status — set per-cell workflow status.
+     *
+     * Open to any signed-in user (Phase 18 — first non-admin write surface),
+     * gated by the global app_settings.task_status_enabled flag. CSRF still
+     * required. Days are NOT modified by this endpoint.
+     */
+    public function updateAssignmentsStatus(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAuthJson($req, $this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+        if (!$this->appSettings->getBool('task_status_enabled', false)) {
+            return Response::err('feature_disabled', 'Task status colors are disabled', 403);
+        }
+
+        $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, status}', 422);
+        }
+        if ($body === []) {
+            return Response::ok(['applied' => 0, 'noop' => 0]);
+        }
+
+        $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['status'])) {
+                return Response::err('validation', "cell[{$i}] needs sprint_worker_id, status", 422);
+            }
+            $swId   = (int) $row['sprint_worker_id'];
+            $status = is_string($row['status']) ? $row['status'] : '';
+            if (!isset($validSw[$swId])) {
+                return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422);
+            }
+            if (!TaskAssignment::isValidStatus($status)) {
+                return Response::err('validation', "cell[{$i}] invalid status", 422);
+            }
+            $cells[] = ['sw_id' => $swId, 'status' => $status];
+        }
+
+        $applied = 0;
+        $noop    = 0;
+
+        $this->pdo->beginTransaction();
+        try {
+            foreach ($cells as $c) {
+                try {
+                    $result = $this->assignments->upsertStatus(
+                        $taskId, $c['sw_id'], $c['status'],
+                    );
+                } catch (InvalidArgumentException) {
+                    // Already validated above, but guard the repo invariant.
+                    $this->pdo->rollBack();
+                    return Response::err('validation', 'invalid status', 422);
+                }
+                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 statuses', 500);
+        }
+
+        return Response::ok([
+            'applied' => $applied,
+            'noop'    => $noop,
+            'task_id' => $taskId,
+        ]);
+    }
+
     /**
      * Full per-worker capacity recompute for a sprint. Used to keep the
      * client-side capacity strip in sync when changes cascade across rows.

+ 23 - 4
src/Domain/TaskAssignment.php

@@ -6,14 +6,32 @@ namespace App\Domain;
 
 final class TaskAssignment
 {
+    public const STATUS_ZUGEWIESEN    = 'zugewiesen';
+    public const STATUS_GESTARTET     = 'gestartet';
+    public const STATUS_ABGESCHLOSSEN = 'abgeschlossen';
+    public const STATUS_ABGEBROCHEN   = 'abgebrochen';
+
+    public const STATUSES = [
+        self::STATUS_ZUGEWIESEN,
+        self::STATUS_GESTARTET,
+        self::STATUS_ABGESCHLOSSEN,
+        self::STATUS_ABGEBROCHEN,
+    ];
+
     public function __construct(
-        public readonly int   $id,
-        public readonly int   $taskId,
-        public readonly int   $sprintWorkerId,
-        public readonly float $days,
+        public readonly int    $id,
+        public readonly int    $taskId,
+        public readonly int    $sprintWorkerId,
+        public readonly float  $days,
+        public readonly string $status = self::STATUS_ZUGEWIESEN,
     ) {
     }
 
+    public static function isValidStatus(string $status): bool
+    {
+        return in_array($status, self::STATUSES, true);
+    }
+
     public function toAuditSnapshot(): array
     {
         return [
@@ -21,6 +39,7 @@ final class TaskAssignment
             'task_id'          => $this->taskId,
             'sprint_worker_id' => $this->sprintWorkerId,
             'days'             => $this->days,
+            'status'           => $this->status,
         ];
     }
 }

+ 60 - 0
src/Repositories/AppSettingsRepository.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repositories;
+
+use PDO;
+
+/**
+ * Tiny key/value store for app-wide flags.
+ *
+ * Currently holds:
+ *   task_status_enabled  '0' | '1'  — Phase 18 task-cell colour states.
+ *
+ * Values are stored as TEXT; callers cast as needed. Keep this thin — config
+ * with structured shape belongs in dedicated tables.
+ */
+final class AppSettingsRepository
+{
+    public function __construct(private readonly PDO $pdo)
+    {
+    }
+
+    public function get(string $key, ?string $default = null): ?string
+    {
+        $stmt = $this->pdo->prepare('SELECT value FROM app_settings WHERE key = ?');
+        $stmt->execute([$key]);
+        $row = $stmt->fetch();
+        if (!is_array($row)) {
+            return $default;
+        }
+        return (string) $row['value'];
+    }
+
+    public function getBool(string $key, bool $default = false): bool
+    {
+        $v = $this->get($key);
+        if ($v === null) {
+            return $default;
+        }
+        return $v === '1' || strtolower($v) === 'true';
+    }
+
+    /** Returns ['before' => ?string, 'after' => string]. */
+    public function set(string $key, string $value): array
+    {
+        $before = $this->get($key);
+
+        if ($before === $value) {
+            return ['before' => $before, 'after' => $value];
+        }
+
+        $stmt = $this->pdo->prepare(
+            'INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
+             ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at'
+        );
+        $stmt->execute([$key, $value, gmdate('Y-m-d\TH:i:s\Z')]);
+        return ['before' => $before, 'after' => $value];
+    }
+}

+ 79 - 8
src/Repositories/TaskAssignmentRepository.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\Repositories;
 
 use App\Domain\TaskAssignment;
+use InvalidArgumentException;
 use PDO;
 
 final class TaskAssignmentRepository
@@ -81,6 +82,31 @@ final class TaskAssignmentRepository
         return $out;
     }
 
+    /**
+     * Status grid for a sprint: [task_id][sw_id] => status. Mirrors
+     * gridForSprint(); cells with no row are absent (default to
+     * STATUS_ZUGEWIESEN at the call site).
+     *
+     * @return array<int, array<int, string>>
+     */
+    public function statusGridForSprint(int $sprintId): array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT ta.task_id, ta.sprint_worker_id, ta.status
+             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'];
+            $out[$tid][$sw] = (string) $row['status'];
+        }
+        return $out;
+    }
+
     /**
      * Σ task_assignments.days where task.priority = 1, grouped by sprint_worker_id.
      * Used by CapacityCalculator via SprintController::show.
@@ -108,9 +134,9 @@ final class TaskAssignmentRepository
      * 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
+     *   - empty cell, days>0   -> CREATE (status seeded with default)
      *   - existing, unchanged  -> NOOP
-     *   - existing, changed    -> UPDATE (row kept when zeroed)
+     *   - existing, changed    -> UPDATE (row kept when zeroed; status preserved)
      *
      * @return array{action:string, before: ?TaskAssignment, after: ?TaskAssignment}
      */
@@ -131,13 +157,57 @@ final class TaskAssignmentRepository
             );
             $stmt->execute([$taskId, $swId, $days]);
             $id    = (int) $this->pdo->lastInsertId();
-            $after = new TaskAssignment($id, $taskId, $swId, $days);
+            $after = new TaskAssignment($id, $taskId, $swId, $days, TaskAssignment::STATUS_ZUGEWIESEN);
             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);
+        $after = new TaskAssignment($existing->id, $taskId, $swId, $days, $existing->status);
+        return ['action' => 'UPDATE', 'before' => $existing, 'after' => $after];
+    }
+
+    /**
+     * Set status for one (task, sprint_worker) cell. Creates a row with
+     * days=0 when none exists so a state can be tracked even before any
+     * days are assigned.
+     *
+     * @return array{action:string, before: ?TaskAssignment, after: ?TaskAssignment}
+     */
+    public function upsertStatus(int $taskId, int $swId, string $status): array
+    {
+        if (!TaskAssignment::isValidStatus($status)) {
+            throw new InvalidArgumentException("invalid status: {$status}");
+        }
+
+        $existing = $this->find($taskId, $swId);
+
+        if ($existing !== null && $existing->status === $status) {
+            return ['action' => 'NOOP', 'before' => $existing, 'after' => $existing];
+        }
+
+        if ($existing === null) {
+            // Default status on a fresh row would be 'zugewiesen', so writing
+            // that is a no-op — saves an audit row for cells that never had
+            // a non-default state.
+            if ($status === TaskAssignment::STATUS_ZUGEWIESEN) {
+                return ['action' => 'NOOP', 'before' => null, 'after' => null];
+            }
+            $stmt = $this->pdo->prepare(
+                'INSERT INTO task_assignments (task_id, sprint_worker_id, days, status)
+                 VALUES (?, ?, 0, ?)'
+            );
+            $stmt->execute([$taskId, $swId, $status]);
+            $id    = (int) $this->pdo->lastInsertId();
+            $after = new TaskAssignment($id, $taskId, $swId, 0.0, $status);
+            return ['action' => 'CREATE', 'before' => null, 'after' => $after];
+        }
+
+        $stmt = $this->pdo->prepare('UPDATE task_assignments SET status = ? WHERE id = ?');
+        $stmt->execute([$status, $existing->id]);
+        $after = new TaskAssignment(
+            $existing->id, $taskId, $swId, $existing->days, $status,
+        );
         return ['action' => 'UPDATE', 'before' => $existing, 'after' => $after];
     }
 
@@ -147,10 +217,11 @@ final class TaskAssignmentRepository
     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'],
+            id:              (int)    $row['id'],
+            taskId:          (int)    $row['task_id'],
+            sprintWorkerId:  (int)    $row['sprint_worker_id'],
+            days:            (float)  $row['days'],
+            status:          (string) ($row['status'] ?? TaskAssignment::STATUS_ZUGEWIESEN),
         );
     }
 }

+ 11 - 0
tailwind.config.js

@@ -6,6 +6,17 @@ module.exports = {
         './src/**/*.php',
         './public/assets/js/**/*.js',
     ],
+    // Phase 18 status classes are interpolated server-side
+    // (`assign-status-<?= $st ?>`), so Tailwind's content scanner can't see
+    // the literal class names and would otherwise prune the rules from
+    // assets/css/input.css's @layer components block. The safelist keeps
+    // them in the build.
+    safelist: [
+        'assign-status-zugewiesen',
+        'assign-status-gestartet',
+        'assign-status-abgeschlossen',
+        'assign-status-abgebrochen',
+    ],
     theme: {
         extend: {},
     },

+ 59 - 0
tests/Domain/TaskAssignmentTest.php

@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Domain;
+
+use App\Domain\TaskAssignment;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Phase 18: status enum guard. The repo + controller both lean on this for
+ * validation, so it gets its own pin.
+ */
+final class TaskAssignmentTest extends TestCase
+{
+    public function testKnownStatusesAreValid(): void
+    {
+        $this->assertTrue(TaskAssignment::isValidStatus('zugewiesen'));
+        $this->assertTrue(TaskAssignment::isValidStatus('gestartet'));
+        $this->assertTrue(TaskAssignment::isValidStatus('abgeschlossen'));
+        $this->assertTrue(TaskAssignment::isValidStatus('abgebrochen'));
+    }
+
+    public function testUnknownStatusesAreRejected(): void
+    {
+        $this->assertFalse(TaskAssignment::isValidStatus(''));
+        $this->assertFalse(TaskAssignment::isValidStatus('done'));
+        $this->assertFalse(TaskAssignment::isValidStatus('in-progress'));
+        $this->assertFalse(TaskAssignment::isValidStatus('ZUGEWIESEN'));
+    }
+
+    public function testAuditSnapshotIncludesStatus(): void
+    {
+        $a = new TaskAssignment(1, 2, 3, 1.5, TaskAssignment::STATUS_GESTARTET);
+        $snapshot = $a->toAuditSnapshot();
+
+        $this->assertSame(1,                                $snapshot['id']);
+        $this->assertSame(2,                                $snapshot['task_id']);
+        $this->assertSame(3,                                $snapshot['sprint_worker_id']);
+        $this->assertSame(1.5,                              $snapshot['days']);
+        $this->assertSame(TaskAssignment::STATUS_GESTARTET, $snapshot['status']);
+    }
+
+    public function testStatusesListIsCanonicalOrder(): void
+    {
+        // Workflow order: assigned → started → done | cancelled. Tests both
+        // the constant order and the count, so adding a new status doesn't
+        // silently slot in the wrong spot.
+        $this->assertSame(
+            [
+                TaskAssignment::STATUS_ZUGEWIESEN,
+                TaskAssignment::STATUS_GESTARTET,
+                TaskAssignment::STATUS_ABGESCHLOSSEN,
+                TaskAssignment::STATUS_ABGEBROCHEN,
+            ],
+            TaskAssignment::STATUSES,
+        );
+    }
+}

+ 67 - 0
tests/Repositories/AppSettingsRepositoryTest.php

@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Repositories;
+
+use App\Repositories\AppSettingsRepository;
+use App\Tests\TestCase;
+
+/**
+ * Phase 18: app_settings KV store. The migration seeds
+ * task_status_enabled='0', so the boolean reader has a known starting point.
+ */
+final class AppSettingsRepositoryTest extends TestCase
+{
+    public function testSeededFlagDefaultsToOff(): void
+    {
+        $pdo  = $this->makeDb();
+        $repo = new AppSettingsRepository($pdo);
+
+        $this->assertSame('0', $repo->get('task_status_enabled'));
+        $this->assertFalse($repo->getBool('task_status_enabled', false));
+    }
+
+    public function testSetReturnsBeforeAndAfter(): void
+    {
+        $pdo  = $this->makeDb();
+        $repo = new AppSettingsRepository($pdo);
+
+        $r = $repo->set('task_status_enabled', '1');
+        $this->assertSame('0', $r['before']);
+        $this->assertSame('1', $r['after']);
+        $this->assertTrue($repo->getBool('task_status_enabled'));
+    }
+
+    public function testSetWithSameValueIsRecognisedAsNoop(): void
+    {
+        $pdo  = $this->makeDb();
+        $repo = new AppSettingsRepository($pdo);
+
+        $r = $repo->set('task_status_enabled', '0');
+        $this->assertSame('0', $r['before']);
+        $this->assertSame('0', $r['after']);
+    }
+
+    public function testGetReturnsDefaultForUnknownKey(): void
+    {
+        $pdo  = $this->makeDb();
+        $repo = new AppSettingsRepository($pdo);
+
+        $this->assertNull($repo->get('does_not_exist'));
+        $this->assertSame('fallback', $repo->get('does_not_exist', 'fallback'));
+        $this->assertFalse($repo->getBool('does_not_exist'));
+        $this->assertTrue($repo->getBool('does_not_exist', true));
+    }
+
+    public function testInsertNewKeyOnFirstWrite(): void
+    {
+        $pdo  = $this->makeDb();
+        $repo = new AppSettingsRepository($pdo);
+
+        $r = $repo->set('new_flag', '1');
+        $this->assertNull($r['before']);
+        $this->assertSame('1', $r['after']);
+        $this->assertSame('1', $repo->get('new_flag'));
+    }
+}

+ 145 - 0
tests/Repositories/TaskAssignmentRepositoryTest.php

@@ -0,0 +1,145 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Repositories;
+
+use App\Domain\TaskAssignment;
+use App\Repositories\SprintRepository;
+use App\Repositories\SprintWorkerRepository;
+use App\Repositories\TaskAssignmentRepository;
+use App\Repositories\TaskRepository;
+use App\Repositories\WorkerRepository;
+use App\Tests\TestCase;
+use InvalidArgumentException;
+use PDO;
+
+/**
+ * Phase 18: per-cell status on task_assignments. Verifies upsertStatus
+ * semantics, the no-op rule, the "row created with days=0 when status is
+ * set on an empty cell" path, and the new statusGridForSprint reader.
+ */
+final class TaskAssignmentRepositoryTest extends TestCase
+{
+    /** @return array{PDO,TaskAssignmentRepository,int,int,int,int} pdo, repo, sprintId, swId, taskId, workerId */
+    private function seed(): array
+    {
+        $pdo     = $this->makeDb();
+        $sprints = new SprintRepository($pdo);
+        $workers = new WorkerRepository($pdo);
+        $sw      = new SprintWorkerRepository($pdo);
+        $tasks   = new TaskRepository($pdo);
+        $repo    = new TaskAssignmentRepository($pdo);
+
+        $sprint = $sprints->create('S', '2026-01-05', '2026-01-30', 0.2);
+        $sprints->materializeWeeks($sprint->id, '2026-01-05', 4);
+        $worker = $workers->create('Alice', true, 0.0);
+        $sworker = $sw->add($sprint->id, $worker->id, 0.0);
+        $task    = $tasks->create($sprint->id, 'Build thing', null, 1);
+
+        return [$pdo, $repo, $sprint->id, $sworker->id, $task->id, $worker->id];
+    }
+
+    public function testUpsertStatusOnEmptyCellWithDefaultIsNoop(): void
+    {
+        [, $repo, , $swId, $taskId] = $this->seed();
+
+        $r = $repo->upsertStatus($taskId, $swId, TaskAssignment::STATUS_ZUGEWIESEN);
+
+        $this->assertSame('NOOP', $r['action']);
+        $this->assertNull($r['before']);
+        $this->assertNull($r['after']);
+        $this->assertNull($repo->find($taskId, $swId));
+    }
+
+    public function testUpsertStatusOnEmptyCellWithExplicitCreatesZeroDayRow(): void
+    {
+        [, $repo, , $swId, $taskId] = $this->seed();
+
+        $r = $repo->upsertStatus($taskId, $swId, TaskAssignment::STATUS_GESTARTET);
+
+        $this->assertSame('CREATE', $r['action']);
+        $this->assertNull($r['before']);
+        $this->assertNotNull($r['after']);
+        $this->assertSame(0.0, $r['after']->days);
+        $this->assertSame(TaskAssignment::STATUS_GESTARTET, $r['after']->status);
+
+        // Reload to confirm persistence.
+        $loaded = $repo->find($taskId, $swId);
+        $this->assertNotNull($loaded);
+        $this->assertSame(TaskAssignment::STATUS_GESTARTET, $loaded->status);
+    }
+
+    public function testUpsertStatusUnchangedIsNoop(): void
+    {
+        [, $repo, , $swId, $taskId] = $this->seed();
+
+        $repo->upsert($taskId, $swId, 1.5);
+        $first = $repo->upsertStatus($taskId, $swId, TaskAssignment::STATUS_GESTARTET);
+        $this->assertSame('UPDATE', $first['action']);
+
+        $again = $repo->upsertStatus($taskId, $swId, TaskAssignment::STATUS_GESTARTET);
+        $this->assertSame('NOOP', $again['action']);
+        $this->assertSame(TaskAssignment::STATUS_GESTARTET, $again['after']->status);
+    }
+
+    public function testUpsertStatusUpdatesExistingRowAndKeepsDays(): void
+    {
+        [, $repo, , $swId, $taskId] = $this->seed();
+
+        $repo->upsert($taskId, $swId, 2.5);
+        $r = $repo->upsertStatus($taskId, $swId, TaskAssignment::STATUS_ABGESCHLOSSEN);
+
+        $this->assertSame('UPDATE', $r['action']);
+        $this->assertSame(2.5,                                $r['before']->days);
+        $this->assertSame(TaskAssignment::STATUS_ZUGEWIESEN,  $r['before']->status);
+        $this->assertSame(2.5,                                $r['after']->days);
+        $this->assertSame(TaskAssignment::STATUS_ABGESCHLOSSEN, $r['after']->status);
+    }
+
+    public function testUpsertDaysOnExistingPreservesStatus(): void
+    {
+        [, $repo, , $swId, $taskId] = $this->seed();
+
+        $repo->upsert($taskId, $swId, 1.0);
+        $repo->upsertStatus($taskId, $swId, TaskAssignment::STATUS_GESTARTET);
+        $r = $repo->upsert($taskId, $swId, 3.0);
+
+        $this->assertSame('UPDATE', $r['action']);
+        $this->assertSame(TaskAssignment::STATUS_GESTARTET, $r['after']->status);
+    }
+
+    public function testUpsertStatusRejectsUnknownValue(): void
+    {
+        [, $repo, , $swId, $taskId] = $this->seed();
+
+        $this->expectException(InvalidArgumentException::class);
+        $repo->upsertStatus($taskId, $swId, 'in-flight');
+    }
+
+    public function testStatusGridForSprintGroupsByTaskAndWorker(): void
+    {
+        [, $repo, $sprintId, $swId, $taskId] = $this->seed();
+
+        $repo->upsert($taskId, $swId, 1.0);
+        $repo->upsertStatus($taskId, $swId, TaskAssignment::STATUS_ABGEBROCHEN);
+
+        $grid = $repo->statusGridForSprint($sprintId);
+        $this->assertArrayHasKey($taskId, $grid);
+        $this->assertArrayHasKey($swId, $grid[$taskId]);
+        $this->assertSame(TaskAssignment::STATUS_ABGEBROCHEN, $grid[$taskId][$swId]);
+    }
+
+    public function testHydrateOnFreshUpsertCarriesDefaultStatus(): void
+    {
+        [, $repo, , $swId, $taskId] = $this->seed();
+
+        $r = $repo->upsert($taskId, $swId, 1.0);
+        $this->assertSame('CREATE', $r['action']);
+        $this->assertSame(TaskAssignment::STATUS_ZUGEWIESEN, $r['after']->status);
+
+        $loaded = $repo->find($taskId, $swId);
+        $this->assertNotNull($loaded);
+        $this->assertSame(TaskAssignment::STATUS_ZUGEWIESEN, $loaded->status);
+    }
+}

+ 2 - 0
views/layout.php

@@ -65,6 +65,8 @@ $csrfToken   = $csrfToken   ?? '';
                                    class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:text-slate-200 dark:hover:bg-slate-700">Users</a>
                                 <a href="/audit" role="menuitem"
                                    class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:text-slate-200 dark:hover:bg-slate-700">Audit log</a>
+                                <a href="/settings" role="menuitem"
+                                   class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:text-slate-200 dark:hover:bg-slate-700">Settings</a>
                             <?php endif; ?>
                             <button type="button" role="menuitem" data-theme-toggle
                                     class="w-full text-left px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 flex items-center justify-between focus:outline-none focus:ring-2 focus:ring-slate-400 dark:text-slate-200 dark:hover:bg-slate-700">

+ 61 - 0
views/settings/index.php

@@ -0,0 +1,61 @@
+<?php
+/**
+ * /settings — admin-only global flags.
+ *
+ * @var \App\Domain\User $currentUser
+ * @var string           $csrfToken
+ * @var array<string,bool>   $values     key => current bool
+ * @var array<string,string> $keyLabels  key => human label
+ * @var ?string          $flash
+ */
+use function App\Http\e;
+?>
+<section class="space-y-6">
+    <header class="flex items-end justify-between gap-4">
+        <div>
+            <nav class="text-xs text-slate-500 dark:text-slate-400">
+                <a href="/" class="hover:underline">Sprints</a> /
+            </nav>
+            <h1 class="text-2xl font-semibold tracking-tight">Settings</h1>
+            <p class="text-slate-600 mt-1 text-sm dark:text-slate-400">
+                Global flags. Changes take effect on every sprint immediately.
+            </p>
+        </div>
+    </header>
+
+    <?php if ($flash !== null): ?>
+        <div class="rounded-md border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-800 dark:bg-green-900 dark:border-green-800 dark:text-green-200">
+            <?= e($flash) ?>
+        </div>
+    <?php endif; ?>
+
+    <form method="post" action="/settings" class="rounded-lg border bg-white p-5 space-y-4 dark:bg-slate-800 dark:border-slate-700">
+        <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+
+        <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Features</h2>
+
+        <label class="flex items-start gap-3">
+            <input type="checkbox" name="task_status_enabled" value="1"
+                   <?= !empty($values['task_status_enabled']) ? 'checked' : '' ?>
+                   class="mt-1 rounded border-slate-300 focus:ring-slate-400 dark:border-slate-600 dark:focus:ring-slate-500">
+            <span>
+                <span class="font-medium"><?= e($keyLabels['task_status_enabled'] ?? 'Task status colors') ?></span>
+                <span class="block text-xs text-slate-500 mt-0.5 dark:text-slate-400">
+                    Show a status selector next to each task-cell day input on every
+                    sprint plan. States: <em>zugewiesen</em> (transparent),
+                    <em>gestartet</em> (yellow), <em>abgeschlossen</em> (green),
+                    <em>abgebrochen</em> (red). Adds a Status filter to the task list.
+                    Any signed-in user can change a cell's status; days remain
+                    admin-only.
+                </span>
+            </span>
+        </label>
+
+        <div>
+            <button type="submit"
+                    class="rounded-md bg-slate-900 text-white px-3 py-2 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">
+                Save
+            </button>
+        </div>
+    </form>
+</section>

+ 65 - 6
views/sprints/present.php

@@ -18,13 +18,18 @@
  * @var list<\App\Domain\SprintWorker> $sprintWorkers
  * @var list<\App\Domain\Task>         $tasks
  * @var array<int, array<int, float>>  $taskGrid    task_id => sw_id => days
+ * @var array<int, array<int, string>> $statusGrid  task_id => sw_id => status
  * @var list<\App\Domain\Worker>       $ownerChoices
+ * @var bool                           $taskStatusEnabled
  */
+use App\Domain\TaskAssignment;
 use function App\Http\e;
-$tasks        = $tasks        ?? [];
-$taskGrid     = $taskGrid     ?? [];
-$ownerChoices = $ownerChoices ?? [];
-$sprintWorkers = $sprintWorkers ?? [];
+$tasks             = $tasks             ?? [];
+$taskGrid          = $taskGrid          ?? [];
+$statusGrid        = $statusGrid        ?? [];
+$ownerChoices      = $ownerChoices      ?? [];
+$sprintWorkers     = $sprintWorkers     ?? [];
+$taskStatusEnabled = $taskStatusEnabled ?? false;
 
 if (!function_exists('fmt_days')) {
     function fmt_days(float $x): string
@@ -93,7 +98,8 @@ if (!function_exists('fmt_days')) {
          [data-task-section]. sprint-planner.js wires everything from the
          data-* attributes, so this is essentially a copy of that block. -->
     <section class="rounded-lg border bg-white overflow-hidden m-2 dark:bg-slate-800 dark:border-slate-700"
-             data-task-section>
+             data-task-section
+             data-task-status-enabled="<?= $taskStatusEnabled ? '1' : '0' ?>">
         <div class="px-4 py-3 border-b bg-slate-50 flex flex-wrap items-center gap-2 dark:bg-slate-700 dark:border-slate-700">
             <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Tasks</h2>
 
@@ -145,6 +151,39 @@ if (!function_exists('fmt_days')) {
                     </div>
                 </div>
 
+                <?php if ($taskStatusEnabled): ?>
+                <!-- Status filter (Phase 18) -->
+                <div class="relative" data-status-filter-root>
+                    <button type="button" data-status-filter-trigger
+                            class="rounded border border-slate-300 px-2 py-1 text-sm bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                        Status <span data-status-filter-count class="text-slate-500 dark:text-slate-400"></span>
+                    </button>
+                    <div data-status-filter-dropdown
+                         class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg dark:bg-slate-800 dark:border-slate-700">
+                        <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between dark:text-slate-400">
+                            <span>Status</span>
+                            <button type="button" data-status-filter-clear
+                                    class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">Clear</button>
+                        </div>
+                        <div class="max-h-64 overflow-y-auto">
+                            <?php foreach ([
+                                'zugewiesen'    => ['Zugewiesen',    'border border-slate-300 dark:border-slate-600'],
+                                'gestartet'     => ['Gestartet',     'bg-yellow-300 dark:bg-yellow-500'],
+                                'abgeschlossen' => ['Abgeschlossen', 'bg-green-300 dark:bg-green-500'],
+                                'abgebrochen'   => ['Abgebrochen',   'bg-red-300 dark:bg-red-500'],
+                            ] as $key => $meta): ?>
+                                <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
+                                    <input type="checkbox" data-status-filter-opt value="<?= e($key) ?>"
+                                           class="rounded border-slate-300 dark:border-slate-600">
+                                    <span class="inline-block h-3 w-3 rounded <?= e($meta[1]) ?>"></span>
+                                    <span><?= e($meta[0]) ?></span>
+                                </label>
+                            <?php endforeach; ?>
+                        </div>
+                    </div>
+                </div>
+                <?php endif; ?>
+
                 <!-- Focus filter -->
                 <div class="flex items-center gap-1" data-focus-filter-root>
                     <label for="data-focus-select" class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">Focus</label>
@@ -289,10 +328,19 @@ if (!function_exists('fmt_days')) {
                                     data-col="tot" data-task-tot>
                                     <?= e(fmt_days($tot)) ?>
                                 </td>
-                                <?php foreach ($sprintWorkers as $sw): $d = (float) ($assign[$sw->id] ?? 0.0); ?>
+                                <?php foreach ($sprintWorkers as $sw):
+                                    $d  = (float) ($assign[$sw->id] ?? 0.0);
+                                    $st = (string) ($statusGrid[$t->id][$sw->id] ?? TaskAssignment::STATUS_ZUGEWIESEN);
+                                ?>
                                     <td class="px-1 py-1 text-center"
                                         data-col="sw-<?= (int) $sw->id ?>"
                                         data-sort-value-sw-<?= (int) $sw->id ?>="<?= e(number_format($d, 2, '.', '')) ?>">
+                                        <?php if ($taskStatusEnabled): ?>
+                                            <span class="assign-cell assign-status-<?= e($st) ?>"
+                                                  data-assign-cell
+                                                  data-sw-id="<?= (int) $sw->id ?>"
+                                                  data-status="<?= e($st) ?>">
+                                        <?php endif; ?>
                                         <?php if ($currentUser->isAdmin): ?>
                                             <input type="number" min="0" step="0.5"
                                                    value="<?= e(fmt_days($d)) ?>"
@@ -302,6 +350,17 @@ if (!function_exists('fmt_days')) {
                                         <?php else: ?>
                                             <span class="font-mono"><?= e(fmt_days($d)) ?></span>
                                         <?php endif; ?>
+                                        <?php if ($taskStatusEnabled): ?>
+                                            <select data-assign-status
+                                                    data-sw-id="<?= (int) $sw->id ?>"
+                                                    aria-label="Status"
+                                                    class="assign-status-select">
+                                                <?php foreach (TaskAssignment::STATUSES as $opt): ?>
+                                                    <option value="<?= e($opt) ?>" <?= $opt === $st ? 'selected' : '' ?>><?= e($opt) ?></option>
+                                                <?php endforeach; ?>
+                                            </select>
+                                            </span>
+                                        <?php endif; ?>
                                     </td>
                                 <?php endforeach; ?>
                                 <td class="px-1 py-1 text-right">

+ 67 - 5
views/sprints/show.php

@@ -8,12 +8,17 @@
 /** @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 array<int, array<int, string>> $statusGrid task_id => sw_id => status */
 /** @var list<\App\Domain\Worker>      $ownerChoices */
+/** @var bool $taskStatusEnabled */
 use App\Domain\SprintWeek;
+use App\Domain\TaskAssignment;
 use function App\Http\e;
-$tasks        = $tasks        ?? [];
-$taskGrid     = $taskGrid     ?? [];
-$ownerChoices = $ownerChoices ?? [];
+$tasks             = $tasks             ?? [];
+$taskGrid          = $taskGrid          ?? [];
+$statusGrid        = $statusGrid        ?? [];
+$ownerChoices      = $ownerChoices      ?? [];
+$taskStatusEnabled = $taskStatusEnabled ?? false;
 
 if (!function_exists('fmt_days')) {
     function fmt_days(float $x): string
@@ -219,7 +224,8 @@ if (!function_exists('fmt_days')) {
 
     <!-- Section B: Task list -->
     <section class="rounded-lg border bg-white overflow-hidden dark:bg-slate-800 dark:border-slate-700"
-             data-task-section>
+             data-task-section
+             data-task-status-enabled="<?= $taskStatusEnabled ? '1' : '0' ?>">
         <div class="px-4 py-3 border-b bg-slate-50 flex flex-wrap items-center gap-2 dark:bg-slate-700 dark:border-slate-700">
             <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Tasks</h2>
 
@@ -271,6 +277,42 @@ if (!function_exists('fmt_days')) {
                     </div>
                 </div>
 
+                <?php if ($taskStatusEnabled): ?>
+                <!-- Status filter (Phase 18) — multi-select; hides tasks
+                     whose cells are not in any of the picked states. The
+                     'zugewiesen' (default) variant only matches cells with
+                     days > 0 so the legend doubles as a sanity check. -->
+                <div class="relative" data-status-filter-root>
+                    <button type="button" data-status-filter-trigger
+                            class="rounded border border-slate-300 px-2 py-1 text-sm bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700">
+                        Status <span data-status-filter-count class="text-slate-500 dark:text-slate-400"></span>
+                    </button>
+                    <div data-status-filter-dropdown
+                         class="hidden absolute right-0 z-20 mt-1 w-56 rounded-md border border-slate-200 bg-white shadow-lg dark:bg-slate-800 dark:border-slate-700">
+                        <div class="px-3 py-2 text-xs text-slate-500 flex items-center justify-between dark:text-slate-400">
+                            <span>Status</span>
+                            <button type="button" data-status-filter-clear
+                                    class="text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300">Clear</button>
+                        </div>
+                        <div class="max-h-64 overflow-y-auto">
+                            <?php foreach ([
+                                'zugewiesen'    => ['Zugewiesen',    'border border-slate-300 dark:border-slate-600'],
+                                'gestartet'     => ['Gestartet',     'bg-yellow-300 dark:bg-yellow-500'],
+                                'abgeschlossen' => ['Abgeschlossen', 'bg-green-300 dark:bg-green-500'],
+                                'abgebrochen'   => ['Abgebrochen',   'bg-red-300 dark:bg-red-500'],
+                            ] as $key => $meta): ?>
+                                <label class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
+                                    <input type="checkbox" data-status-filter-opt value="<?= e($key) ?>"
+                                           class="rounded border-slate-300 dark:border-slate-600">
+                                    <span class="inline-block h-3 w-3 rounded <?= e($meta[1]) ?>"></span>
+                                    <span><?= e($meta[0]) ?></span>
+                                </label>
+                            <?php endforeach; ?>
+                        </div>
+                    </div>
+                </div>
+                <?php endif; ?>
+
                 <!-- Focus filter — one sprint worker; hides rows where their
                      assignment is 0 and collapses worker columns that are
                      all-zero for the remaining rows. -->
@@ -417,10 +459,19 @@ if (!function_exists('fmt_days')) {
                                     data-col="tot" data-task-tot>
                                     <?= e(fmt_days($tot)) ?>
                                 </td>
-                                <?php foreach ($sprintWorkers as $sw): $d = (float) ($assign[$sw->id] ?? 0.0); ?>
+                                <?php foreach ($sprintWorkers as $sw):
+                                    $d  = (float) ($assign[$sw->id] ?? 0.0);
+                                    $st = (string) ($statusGrid[$t->id][$sw->id] ?? TaskAssignment::STATUS_ZUGEWIESEN);
+                                ?>
                                     <td class="px-1 py-1 text-center"
                                         data-col="sw-<?= (int) $sw->id ?>"
                                         data-sort-value-sw-<?= (int) $sw->id ?>="<?= e(number_format($d, 2, '.', '')) ?>">
+                                        <?php if ($taskStatusEnabled): ?>
+                                            <span class="assign-cell assign-status-<?= e($st) ?>"
+                                                  data-assign-cell
+                                                  data-sw-id="<?= (int) $sw->id ?>"
+                                                  data-status="<?= e($st) ?>">
+                                        <?php endif; ?>
                                         <?php if ($currentUser->isAdmin): ?>
                                             <input type="number" min="0" step="0.5"
                                                    value="<?= e(fmt_days($d)) ?>"
@@ -430,6 +481,17 @@ if (!function_exists('fmt_days')) {
                                         <?php else: ?>
                                             <span class="font-mono"><?= e(fmt_days($d)) ?></span>
                                         <?php endif; ?>
+                                        <?php if ($taskStatusEnabled): ?>
+                                            <select data-assign-status
+                                                    data-sw-id="<?= (int) $sw->id ?>"
+                                                    aria-label="Status"
+                                                    class="assign-status-select">
+                                                <?php foreach (TaskAssignment::STATUSES as $opt): ?>
+                                                    <option value="<?= e($opt) ?>" <?= $opt === $st ? 'selected' : '' ?>><?= e($opt) ?></option>
+                                                <?php endforeach; ?>
+                                            </select>
+                                            </span>
+                                        <?php endif; ?>
                                     </td>
                                 <?php endforeach; ?>
                                 <td class="px-1 py-1 text-right">