Explorar el Código

Phase 4: sprint settings — meta, weeks, workers, reorder, RTB

Domain:
- Domain\SprintWeek and Domain\SprintWorker value objects with
  toAuditSnapshot(). SprintWorker denormalises the worker name for
  display but excludes it from the audit snapshot.

Repositories:
- SprintRepository gains update(id, changes) for the whitelisted
  fields {name, start_date, end_date, reserve_fraction, is_archived}.
  Returns before/after.
- SprintWeekRepository: allForSprint, find, syncCount(sprintId,
  sprintStart, targetCount) — resizes the week set, returning
  {added, removed} so the caller can audit each CREATE/DELETE. Added
  rows default to max_working_days=5; removed rows cascade-delete
  any sprint_worker_days via FK.
- SprintWorkerRepository: allForSprint (joined with worker name),
  find, add (appends at MAX(sort_order)+1), remove, reorder
  (two-phase with negated staging so future unique constraints on
  sort_order won't bite; returns per-row before/after, skipping
  unchanged rows), setRtb.
- WorkerRepository gains activeNotInSprint() — used to populate the
  "available workers" pane on the settings page.

HTTP:
- Response::ok(data) and Response::err(code, message, status, details)
  build the {ok: true, data} / {ok: false, error:{…}} envelopes from
  spec §7.

Controller:
- SprintController picks up SprintWeek/SprintWorker/Worker repos and
  gains: settings (GET /sprints/{id}/settings page),
  updateMeta (PATCH /sprints/{id}), replaceWeeks
  (POST /sprints/{id}/weeks), addWorker (POST /sprints/{id}/workers),
  removeWorker (DELETE /sprints/{id}/workers/{sw_id}),
  reorderWorkers (POST /sprints/{id}/workers/reorder),
  updateWorker (PATCH /sprints/{id}/workers/{sw_id}).
  Shared gateJsonAdmin() helper enforces auth + admin + CSRF and
  returns JSON error envelopes. Every mutation records per-row
  audit entries inside the same tx; no-op updates self-cancel via
  AuditLogger.

Routing:
- Asset dir moved from /assets to /public/assets so Apache can serve
  it directly (FallbackResource only kicks in for non-files).
- /public/assets/js/sprint-settings.js mounts on
  [data-sprint-root], handles AJAX with X-CSRF-Token, wires up
  meta-save-on-change, weeks-apply, add/remove/rtb-edit, and
  jQuery UI sortable for drag-reorder. Status line fades in/out.

View:
- views/sprints/settings.php — three sections (sprint meta form,
  weeks table + resize form, two-column worker picker).

Verified:
- php -l on all changed/new files.
- End-to-end: meta update (with no-op detection), weeks grow 4→6
  then shrink 6→3 with matching CREATE/DELETE audit, worker
  add/remove/reorder/setRtb, UNIQUE-on-(sprint_id,worker_id)
  enforced, activeNotInSprint correctly hides already-in-sprint
  workers, no-op reorder writes zero audit rows, 13 audit rows total
  covering every mutation.
- Settings view renders all data-attributes, controls and JS include.
- Response envelope shapes match spec §7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa hace 2 semanas
padre
commit
38ba1511de

+ 0 - 0
assets/css/.gitkeep → public/assets/css/.gitkeep


+ 0 - 0
assets/js/.gitkeep → public/assets/js/.gitkeep


+ 230 - 0
public/assets/js/sprint-settings.js

@@ -0,0 +1,230 @@
+/* global jQuery */
+/**
+ * Sprint settings page: JSON mutation plumbing + jQuery UI sortable wiring.
+ *
+ * The settings page mounts a single root element with `data-sprint-id` and
+ * `data-csrf`. Everything below scopes itself to that root.
+ */
+(function ($) {
+    'use strict';
+
+    const $root = $('[data-sprint-root]');
+    if ($root.length === 0) {
+        return;
+    }
+
+    const sprintId = parseInt($root.data('sprint-id'), 10);
+    const csrf     = String($root.data('csrf') || '');
+
+    // ---------------------------------------------------------------------
+    // AJAX plumbing
+    // ---------------------------------------------------------------------
+
+    function request(method, url, body) {
+        const opts = {
+            method,
+            headers: {
+                'Accept':        'application/json',
+                'X-CSRF-Token':  csrf,
+            },
+            credentials: 'same-origin',
+        };
+        if (body !== undefined) {
+            opts.headers['Content-Type'] = 'application/json';
+            opts.body = JSON.stringify(body);
+        }
+        return fetch(url, opts).then(async function (res) {
+            let payload = null;
+            try { payload = await res.json(); } catch (_) { /* ignore */ }
+            if (!res.ok || !payload || payload.ok !== true) {
+                const msg = (payload && payload.error && payload.error.message)
+                    ? payload.error.message
+                    : res.statusText || 'Request failed';
+                const err = new Error(msg);
+                err.status = res.status;
+                err.payload = payload;
+                throw err;
+            }
+            return payload.data;
+        });
+    }
+
+    // ---------------------------------------------------------------------
+    // Toast / status line
+    // ---------------------------------------------------------------------
+
+    const $status = $root.find('[data-status]');
+    let statusTimer = null;
+    function flash(text, isError) {
+        $status
+            .text(text)
+            .removeClass('text-green-700 text-red-700 bg-green-50 bg-red-50 border-green-200 border-red-200')
+            .addClass(isError ? 'text-red-700 bg-red-50 border-red-200' : 'text-green-700 bg-green-50 border-green-200')
+            .removeClass('opacity-0')
+            .addClass('opacity-100');
+        clearTimeout(statusTimer);
+        statusTimer = setTimeout(function () {
+            $status.removeClass('opacity-100').addClass('opacity-0');
+        }, 2500);
+    }
+
+    // ---------------------------------------------------------------------
+    // Sprint meta — save on change / blur
+    // ---------------------------------------------------------------------
+
+    function patchMeta(payload) {
+        return request('PATCH', '/sprints/' + sprintId, payload)
+            .then(function () { flash('Saved'); })
+            .catch(function (e) { flash(e.message, true); });
+    }
+
+    const metaDebounce = {};
+    function debouncedMeta(field, value, ms) {
+        clearTimeout(metaDebounce[field]);
+        metaDebounce[field] = setTimeout(function () {
+            const payload = {};
+            payload[field] = value;
+            patchMeta(payload);
+        }, ms || 400);
+    }
+
+    $root.find('[data-meta]').on('change', function () {
+        const $el = $(this);
+        const field = $el.attr('name');
+        let v = $el.val();
+        if (field === 'reserve_fraction') {
+            v = Number(v) / 100; // form shows percent
+        }
+        debouncedMeta(field, v, 0);
+    });
+
+    // ---------------------------------------------------------------------
+    // Weeks count
+    // ---------------------------------------------------------------------
+
+    $root.find('[data-weeks-form]').on('submit', function (ev) {
+        ev.preventDefault();
+        const n = parseInt($(this).find('input[name="n_weeks"]').val(), 10);
+        if (!Number.isInteger(n) || n < 1) {
+            flash('Weeks must be a positive integer', true);
+            return;
+        }
+        request('POST', '/sprints/' + sprintId + '/weeks', { n_weeks: n })
+            .then(function () { window.location.reload(); })
+            .catch(function (e) { flash(e.message, true); });
+    });
+
+    // ---------------------------------------------------------------------
+    // Worker picker
+    // ---------------------------------------------------------------------
+
+    function workerRowTemplate(sw) {
+        const nameSafe = $('<div>').text(sw.worker_name).html();
+        return $(
+            '<li class="flex items-center gap-2 px-3 py-2 border-b bg-white last:border-b-0"' +
+               ' data-sw-id="' + sw.id + '"' +
+               ' data-worker-id="' + sw.worker_id + '">' +
+                '<span class="handle cursor-grab text-slate-400">&#8801;</span>' +
+                '<span class="flex-1">' + nameSafe + '</span>' +
+                '<input type="number" step="0.05" min="0" max="1" value="' + Number(sw.rtb).toFixed(2) + '"' +
+                    ' data-rtb class="w-20 rounded border border-slate-300 px-2 py-1 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">' +
+                '<button type="button" data-remove class="text-sm text-red-600 hover:underline">Remove</button>' +
+            '</li>'
+        );
+    }
+
+    function availableRowTemplate(worker) {
+        const nameSafe = $('<div>').text(worker.name).html();
+        return $(
+            '<li class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0"' +
+               ' data-worker-id="' + worker.id + '">' +
+                '<span class="flex-1">' + nameSafe + '</span>' +
+                '<button type="button" data-add class="text-sm text-blue-700 hover:underline">Add →</button>' +
+            '</li>'
+        );
+    }
+
+    const $available = $root.find('[data-available]');
+    const $inSprint  = $root.find('[data-in-sprint]');
+
+    // Add a worker
+    $available.on('click', '[data-add]', function () {
+        const $li = $(this).closest('li');
+        const workerId = parseInt($li.data('worker-id'), 10);
+        const name = $li.find('span.flex-1').text();
+
+        request('POST', '/sprints/' + sprintId + '/workers', { worker_id: workerId })
+            .then(function (data) {
+                const sw = data.sprint_worker;
+                sw.worker_name = sw.worker_name || name;
+                $inSprint.append(workerRowTemplate(sw));
+                $li.remove();
+                flash('Worker added');
+                refreshEmptyStates();
+            })
+            .catch(function (e) { flash(e.message, true); });
+    });
+
+    // Remove a worker
+    $inSprint.on('click', '[data-remove]', function () {
+        const $li = $(this).closest('li');
+        const swId = parseInt($li.data('sw-id'), 10);
+        const workerId = parseInt($li.data('worker-id'), 10);
+        const name = $li.find('span.flex-1').text();
+
+        request('DELETE', '/sprints/' + sprintId + '/workers/' + swId)
+            .then(function () {
+                $li.remove();
+                $available.append(availableRowTemplate({ id: workerId, name: name }));
+                flash('Worker removed');
+                refreshEmptyStates();
+            })
+            .catch(function (e) { flash(e.message, true); });
+    });
+
+    // RTB edit on blur / change
+    $inSprint.on('change', '[data-rtb]', function () {
+        const $input = $(this);
+        const swId = parseInt($input.closest('li').data('sw-id'), 10);
+        let v = Number($input.val());
+        if (Number.isNaN(v) || v < 0 || v > 1) {
+            flash('RTB must be 0–1', true);
+            return;
+        }
+        // Snap to 0.05 step
+        v = Math.round(v * 20) / 20;
+        $input.val(v.toFixed(2));
+
+        request('PATCH', '/sprints/' + sprintId + '/workers/' + swId, { rtb: v })
+            .then(function () { flash('Saved'); })
+            .catch(function (e) { flash(e.message, true); });
+    });
+
+    // Drag reorder
+    $inSprint.sortable({
+        handle: '.handle',
+        axis: 'y',
+        placeholder: 'bg-slate-100 h-10',
+        update: function () {
+            const ordering = $inSprint.children('li').map(function (i, el) {
+                return {
+                    sprint_worker_id: parseInt($(el).data('sw-id'), 10),
+                    sort_order: i + 1,
+                };
+            }).get();
+
+            request('POST', '/sprints/' + sprintId + '/workers/reorder', ordering)
+                .then(function (data) {
+                    flash(data.moved ? 'Order saved' : 'No changes');
+                })
+                .catch(function (e) { flash(e.message, true); });
+        },
+    });
+
+    function refreshEmptyStates() {
+        $root.find('[data-empty-available]').toggle($available.children('li').length === 0);
+        $root.find('[data-empty-sprint]').toggle($inSprint.children('li').length === 0);
+    }
+    refreshEmptyStates();
+
+})(jQuery);

+ 19 - 4
public/index.php

@@ -15,6 +15,8 @@ use App\Http\Response;
 use App\Http\Router;
 use App\Http\View;
 use App\Repositories\SprintRepository;
+use App\Repositories\SprintWeekRepository;
+use App\Repositories\SprintWorkerRepository;
 use App\Repositories\UserRepository;
 use App\Repositories\WorkerRepository;
 use App\Services\AuditLogger;
@@ -77,10 +79,14 @@ $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);
 $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, $audit, $view);
+$sprintCtrl    = new SprintController(
+    $pdo, $users, $sprints, $sprintWeeks, $sprintWorkers, $workers, $audit, $view,
+);
 
 // ---------------------------------------------------------------------------
 // Routing
@@ -121,9 +127,18 @@ $router->get('/workers',         $workerCtrl->index(...));
 $router->post('/workers',        $workerCtrl->create(...));
 $router->post('/workers/{id}',   $workerCtrl->update(...));
 
-$router->get('/sprints/new',     $sprintCtrl->newForm(...));
-$router->post('/sprints',        $sprintCtrl->create(...));
-$router->get('/sprints/{id}',    $sprintCtrl->show(...));
+$router->get('/sprints/new',              $sprintCtrl->newForm(...));
+$router->post('/sprints',                 $sprintCtrl->create(...));
+$router->get('/sprints/{id}',             $sprintCtrl->show(...));
+$router->get('/sprints/{id}/settings',    $sprintCtrl->settings(...));
+
+// JSON mutation endpoints (admin, CSRF via X-CSRF-Token header):
+$router->patch('/sprints/{id}',                       $sprintCtrl->updateMeta(...));
+$router->post('/sprints/{id}/weeks',                  $sprintCtrl->replaceWeeks(...));
+$router->post('/sprints/{id}/workers',                $sprintCtrl->addWorker(...));
+$router->delete('/sprints/{id}/workers/{sw_id}',      $sprintCtrl->removeWorker(...));
+$router->post('/sprints/{id}/workers/reorder',        $sprintCtrl->reorderWorkers(...));
+$router->patch('/sprints/{id}/workers/{sw_id}',       $sprintCtrl->updateWorker(...));
 
 // ---------------------------------------------------------------------------
 // Dispatch

+ 402 - 5
src/Controllers/SprintController.php

@@ -5,24 +5,32 @@ declare(strict_types=1);
 namespace App\Controllers;
 
 use App\Auth\SessionGuard;
+use App\Domain\User;
 use App\Http\Request;
 use App\Http\Response;
 use App\Http\View;
 use App\Repositories\SprintRepository;
+use App\Repositories\SprintWeekRepository;
+use App\Repositories\SprintWorkerRepository;
 use App\Repositories\UserRepository;
+use App\Repositories\WorkerRepository;
 use App\Services\AuditLogger;
 use DateTimeImmutable;
 use PDO;
+use PDOException;
 use Throwable;
 
 final class SprintController
 {
     public function __construct(
-        private readonly PDO              $pdo,
-        private readonly UserRepository   $users,
-        private readonly SprintRepository $sprints,
-        private readonly AuditLogger      $audit,
-        private readonly View             $view,
+        private readonly PDO                    $pdo,
+        private readonly UserRepository         $users,
+        private readonly SprintRepository       $sprints,
+        private readonly SprintWeekRepository   $weeks,
+        private readonly SprintWorkerRepository $sprintWorkers,
+        private readonly WorkerRepository       $workers,
+        private readonly AuditLogger            $audit,
+        private readonly View                   $view,
     ) {
     }
 
@@ -159,4 +167,393 @@ final class SprintController
             'sprint'      => $sprint,
         ]));
     }
+
+    // -----------------------------------------------------------------------
+    // Phase 4 — settings page + JSON mutation endpoints.
+    // -----------------------------------------------------------------------
+
+    /** GET /sprints/{id}/settings — admin-only. */
+    public function settings(Request $req, array $params): Response
+    {
+        $actor = SessionGuard::requireAdmin($this->users);
+        if ($actor instanceof Response) {
+            return $actor;
+        }
+
+        $id = (int) $params['id'];
+        $sprint = $this->sprints->find($id);
+        if ($sprint === null) {
+            return Response::text('Not Found', 404);
+        }
+
+        return Response::html($this->view->render('sprints/settings', [
+            'title'            => "Settings — {$sprint->name}",
+            'currentUser'      => $actor,
+            'csrfToken'        => SessionGuard::csrfToken(),
+            'sprint'           => $sprint,
+            'weeks'            => $this->weeks->allForSprint($id),
+            'sprintWorkers'    => $this->sprintWorkers->allForSprint($id),
+            'availableWorkers' => $this->workers->activeNotInSprint($id),
+        ]));
+    }
+
+    /** PATCH /sprints/{id} — JSON — update name / dates / reserve_fraction. */
+    public function updateMeta(Request $req, array $params): Response
+    {
+        $gate = $this->gateJsonAdmin($req);
+        if ($gate instanceof Response) {
+            return $gate;
+        }
+        $actor = $gate;
+
+        $id = (int) $params['id'];
+        $sprint = $this->sprints->find($id);
+        if ($sprint === null) {
+            return Response::err('not_found', 'Sprint not found', 404);
+        }
+
+        $body = $req->json() ?? [];
+        $changes = [];
+
+        if (array_key_exists('name', $body)) {
+            $name = is_string($body['name']) ? trim($body['name']) : '';
+            if ($name === '') {
+                return Response::err('validation', 'Name cannot be empty', 422, ['field' => 'name']);
+            }
+            $changes['name'] = $name;
+        }
+
+        if (array_key_exists('start_date', $body)) {
+            if (!is_string($body['start_date']) || !self::isIsoDate($body['start_date'])) {
+                return Response::err('validation', 'Invalid start_date', 422, ['field' => 'start_date']);
+            }
+            $changes['start_date'] = $body['start_date'];
+        }
+
+        if (array_key_exists('end_date', $body)) {
+            if (!is_string($body['end_date']) || !self::isIsoDate($body['end_date'])) {
+                return Response::err('validation', 'Invalid end_date', 422, ['field' => 'end_date']);
+            }
+            $changes['end_date'] = $body['end_date'];
+        }
+
+        if (array_key_exists('reserve_fraction', $body)) {
+            if (!is_numeric($body['reserve_fraction'])) {
+                return Response::err('validation', 'reserve_fraction must be numeric', 422);
+            }
+            $rf = (float) $body['reserve_fraction'];
+            if ($rf < 0.0 || $rf > 1.0) {
+                return Response::err('validation', 'reserve_fraction must be 0..1', 422);
+            }
+            $changes['reserve_fraction'] = $rf;
+        }
+
+        $effectiveStart = $changes['start_date'] ?? $sprint->startDate;
+        $effectiveEnd   = $changes['end_date']   ?? $sprint->endDate;
+        if ($effectiveEnd < $effectiveStart) {
+            return Response::err('validation', 'end_date must be on or after start_date', 422);
+        }
+
+        if ($changes === []) {
+            return Response::ok(['sprint' => $sprint->toAuditSnapshot()]);
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $result = $this->sprints->update($id, $changes);
+            $this->audit->recordForRequest(
+                'UPDATE', 'sprint', $id,
+                $result['before']->toAuditSnapshot(),
+                $result['after']->toAuditSnapshot(),
+                $req, $actor,
+            );
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not save sprint', 500);
+        }
+
+        return Response::ok(['sprint' => $result['after']->toAuditSnapshot()]);
+    }
+
+    /** POST /sprints/{id}/weeks — JSON — resize the week set. */
+    public function replaceWeeks(Request $req, array $params): Response
+    {
+        $gate = $this->gateJsonAdmin($req);
+        if ($gate instanceof Response) {
+            return $gate;
+        }
+        $actor = $gate;
+
+        $id = (int) $params['id'];
+        $sprint = $this->sprints->find($id);
+        if ($sprint === null) {
+            return Response::err('not_found', 'Sprint not found', 404);
+        }
+
+        $body = $req->json() ?? [];
+        if (!isset($body['n_weeks']) || !is_int($body['n_weeks']) || $body['n_weeks'] < 1 || $body['n_weeks'] > 26) {
+            return Response::err('validation', 'n_weeks must be an integer in 1..26', 422);
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $diff = $this->weeks->syncCount($id, $sprint->startDate, (int) $body['n_weeks']);
+
+            foreach ($diff['added'] as $w) {
+                $this->audit->recordForRequest(
+                    'CREATE', 'sprint_week', $w->id,
+                    null, $w->toAuditSnapshot(),
+                    $req, $actor,
+                );
+            }
+            foreach ($diff['removed'] as $w) {
+                $this->audit->recordForRequest(
+                    'DELETE', 'sprint_week', $w->id,
+                    $w->toAuditSnapshot(), null,
+                    $req, $actor,
+                );
+            }
+
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not update weeks', 500);
+        }
+
+        return Response::ok([
+            'weeks' => array_map(
+                fn($w) => $w->toAuditSnapshot(),
+                $this->weeks->allForSprint($id)
+            ),
+            'added'   => count($diff['added']),
+            'removed' => count($diff['removed']),
+        ]);
+    }
+
+    /** POST /sprints/{id}/workers — JSON — add a worker to the sprint. */
+    public function addWorker(Request $req, array $params): Response
+    {
+        $gate = $this->gateJsonAdmin($req);
+        if ($gate instanceof Response) {
+            return $gate;
+        }
+        $actor = $gate;
+
+        $sprintId = (int) $params['id'];
+        if ($this->sprints->find($sprintId) === null) {
+            return Response::err('not_found', 'Sprint not found', 404);
+        }
+
+        $body = $req->json() ?? [];
+        if (!isset($body['worker_id']) || !is_int($body['worker_id'])) {
+            return Response::err('validation', 'worker_id required', 422);
+        }
+        $workerId = (int) $body['worker_id'];
+        $worker = $this->workers->find($workerId);
+        if ($worker === null) {
+            return Response::err('validation', 'Unknown worker', 422, ['field' => 'worker_id']);
+        }
+        if (!$worker->isActive) {
+            return Response::err('validation', 'Worker is inactive', 422, ['field' => 'worker_id']);
+        }
+
+        $rtb = $worker->defaultRtb;
+        if (isset($body['rtb'])) {
+            if (!is_numeric($body['rtb']) || (float) $body['rtb'] < 0.0 || (float) $body['rtb'] > 1.0) {
+                return Response::err('validation', 'rtb must be 0..1', 422);
+            }
+            $rtb = (float) $body['rtb'];
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $sw = $this->sprintWorkers->add($sprintId, $workerId, $rtb);
+            $this->audit->recordForRequest(
+                'CREATE', 'sprint_worker', $sw->id,
+                null, $sw->toAuditSnapshot(),
+                $req, $actor,
+            );
+            $this->pdo->commit();
+        } catch (PDOException $e) {
+            $this->pdo->rollBack();
+            if (str_contains(strtolower($e->getMessage()), 'unique')) {
+                return Response::err('conflict', 'Worker already in sprint', 409);
+            }
+            return Response::err('db_error', 'Could not add worker', 500);
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not add worker', 500);
+        }
+
+        return Response::ok([
+            'sprint_worker' => $sw->toAuditSnapshot() + ['worker_name' => $sw->workerName],
+        ]);
+    }
+
+    /** DELETE /sprints/{id}/workers/{sw_id} — JSON — remove a worker from the sprint. */
+    public function removeWorker(Request $req, array $params): Response
+    {
+        $gate = $this->gateJsonAdmin($req);
+        if ($gate instanceof Response) {
+            return $gate;
+        }
+        $actor = $gate;
+
+        $sprintId = (int) $params['id'];
+        $swId     = (int) $params['sw_id'];
+
+        $existing = $this->sprintWorkers->find($swId);
+        if ($existing === null || $existing->sprintId !== $sprintId) {
+            return Response::err('not_found', 'sprint_worker not found in this sprint', 404);
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $removed = $this->sprintWorkers->remove($swId);
+            if ($removed !== null) {
+                $this->audit->recordForRequest(
+                    'DELETE', 'sprint_worker', $removed->id,
+                    $removed->toAuditSnapshot(), null,
+                    $req, $actor,
+                );
+            }
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not remove worker', 500);
+        }
+
+        return Response::ok(['removed_id' => $swId]);
+    }
+
+    /** POST /sprints/{id}/workers/reorder — JSON — apply an ordering. */
+    public function reorderWorkers(Request $req, array $params): Response
+    {
+        $gate = $this->gateJsonAdmin($req);
+        if ($gate instanceof Response) {
+            return $gate;
+        }
+        $actor = $gate;
+
+        $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 {sprint_worker_id, sort_order}', 422);
+        }
+
+        $ordering = [];
+        $seenOrder = [];
+        foreach ($body as $row) {
+            if (!is_array($row) || !isset($row['sprint_worker_id'], $row['sort_order'])) {
+                return Response::err('validation', 'each entry needs sprint_worker_id and sort_order', 422);
+            }
+            $sw    = (int) $row['sprint_worker_id'];
+            $order = (int) $row['sort_order'];
+            if ($sw <= 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[] = ['sprint_worker_id' => $sw, 'sort_order' => $order];
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $diffs = $this->sprintWorkers->reorder($sprintId, $ordering);
+            foreach ($diffs as $d) {
+                $this->audit->recordForRequest(
+                    'UPDATE', 'sprint_worker', $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 /sprints/{id}/workers/{sw_id} — JSON — edit RTB. */
+    public function updateWorker(Request $req, array $params): Response
+    {
+        $gate = $this->gateJsonAdmin($req);
+        if ($gate instanceof Response) {
+            return $gate;
+        }
+        $actor = $gate;
+
+        $sprintId = (int) $params['id'];
+        $swId     = (int) $params['sw_id'];
+
+        $existing = $this->sprintWorkers->find($swId);
+        if ($existing === null || $existing->sprintId !== $sprintId) {
+            return Response::err('not_found', 'sprint_worker not found in this sprint', 404);
+        }
+
+        $body = $req->json() ?? [];
+        if (!isset($body['rtb']) || !is_numeric($body['rtb'])) {
+            return Response::err('validation', 'rtb required', 422);
+        }
+        $rtb = (float) $body['rtb'];
+        if ($rtb < 0.0 || $rtb > 1.0) {
+            return Response::err('validation', 'rtb must be 0..1', 422);
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $result = $this->sprintWorkers->setRtb($swId, $rtb);
+            $this->audit->recordForRequest(
+                'UPDATE', 'sprint_worker', $swId,
+                $result['before']->toAuditSnapshot(),
+                $result['after']->toAuditSnapshot(),
+                $req, $actor,
+            );
+            $this->pdo->commit();
+        } catch (Throwable) {
+            $this->pdo->rollBack();
+            return Response::err('db_error', 'Could not update worker', 500);
+        }
+
+        return Response::ok(['sprint_worker' => $result['after']->toAuditSnapshot()]);
+    }
+
+    // ------------------------------------------------------------------
+    // Shared helpers
+    // ------------------------------------------------------------------
+
+    /**
+     * Admin gate for JSON endpoints. Returns the signed-in User on success,
+     * or an `Response::err(...)` JSON envelope on failure. Also enforces CSRF.
+     */
+    private function gateJsonAdmin(Request $req): User|Response
+    {
+        $user = SessionGuard::currentUser($this->users);
+        if ($user === null) {
+            return Response::err('unauthenticated', 'Sign in required', 401);
+        }
+        if (!$user->isAdmin) {
+            return Response::err('forbidden', 'Admin access required', 403);
+        }
+        if (!SessionGuard::verifyCsrf($req)) {
+            return Response::err('csrf', 'CSRF token invalid', 403);
+        }
+        return $user;
+    }
+
+    private static function isIsoDate(string $s): bool
+    {
+        $d = DateTimeImmutable::createFromFormat('Y-m-d', $s);
+        return $d !== false && $d->format('Y-m-d') === $s;
+    }
 }

+ 30 - 0
src/Domain/SprintWeek.php

@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain;
+
+final class SprintWeek
+{
+    public function __construct(
+        public readonly int    $id,
+        public readonly int    $sprintId,
+        public readonly int    $sortOrder,
+        public readonly int    $isoWeek,
+        public readonly string $startDate,
+        public readonly float  $maxWorkingDays,
+    ) {
+    }
+
+    public function toAuditSnapshot(): array
+    {
+        return [
+            'id'               => $this->id,
+            'sprint_id'        => $this->sprintId,
+            'sort_order'       => $this->sortOrder,
+            'iso_week'         => $this->isoWeek,
+            'start_date'       => $this->startDate,
+            'max_working_days' => $this->maxWorkingDays,
+        ];
+    }
+}

+ 33 - 0
src/Domain/SprintWorker.php

@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain;
+
+final class SprintWorker
+{
+    public function __construct(
+        public readonly int    $id,
+        public readonly int    $sprintId,
+        public readonly int    $workerId,
+        public readonly string $workerName,
+        public readonly float  $rtb,
+        public readonly int    $sortOrder,
+    ) {
+    }
+
+    /**
+     * Snapshot of the sprint_workers row as stored in the DB (excludes the
+     * denormalised worker name which belongs to the workers table).
+     */
+    public function toAuditSnapshot(): array
+    {
+        return [
+            'id'         => $this->id,
+            'sprint_id'  => $this->sprintId,
+            'worker_id'  => $this->workerId,
+            'rtb'        => $this->rtb,
+            'sort_order' => $this->sortOrder,
+        ];
+    }
+}

+ 23 - 0
src/Http/Response.php

@@ -28,6 +28,29 @@ final class Response
         return new self($status, $body, ['Content-Type' => 'application/json; charset=utf-8']);
     }
 
+    /** JSON success envelope per spec §7: { ok: true, data: … } */
+    public static function ok(mixed $data, int $status = 200): self
+    {
+        return self::json(['ok' => true, 'data' => $data], $status);
+    }
+
+    /**
+     * JSON error envelope per spec §7: { ok: false, error: { code, message, details? } }.
+     * Validation errors should pass $status = 422.
+     */
+    public static function err(
+        string $code,
+        string $message,
+        int $status = 400,
+        ?array $details = null,
+    ): self {
+        $error = ['code' => $code, 'message' => $message];
+        if ($details !== null) {
+            $error['details'] = $details;
+        }
+        return self::json(['ok' => false, 'error' => $error], $status);
+    }
+
     public static function redirect(string $location, int $status = 302): self
     {
         return new self($status, '', ['Location' => $location]);

+ 44 - 0
src/Repositories/SprintRepository.php

@@ -73,6 +73,50 @@ final class SprintRepository
         return $sprint;
     }
 
+    /** Whitelisted updatable columns on `sprints` for admin edits. */
+    private const UPDATABLE = ['name', 'start_date', 'end_date', 'reserve_fraction', 'is_archived'];
+
+    /**
+     * Apply the given field changes. Returns before/after for auditing.
+     *
+     * @param array<string,mixed> $changes
+     * @return array{before: Sprint, after: Sprint}
+     */
+    public function update(int $id, array $changes): array
+    {
+        $before = $this->find($id);
+        if ($before === null) {
+            throw new RuntimeException("Sprint {$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) {
+                'is_archived'       => ((bool) $v) ? 1 : 0,
+                'reserve_fraction'  => (float) $v,
+                default             => (string) $v,
+            };
+        }
+        $sets[] = 'updated_at = ?';
+        $vals[] = gmdate('Y-m-d\TH:i:s\Z');
+        $vals[] = $id;
+
+        $stmt = $this->pdo->prepare(
+            'UPDATE sprints SET ' . implode(', ', $sets) . ' WHERE id = ?'
+        );
+        $stmt->execute($vals);
+
+        $after = $this->find($id) ?? $before;
+        return ['before' => $before, 'after' => $after];
+    }
+
     /**
      * Materialise N week rows for a sprint with sensible defaults.
      *

+ 121 - 0
src/Repositories/SprintWeekRepository.php

@@ -0,0 +1,121 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repositories;
+
+use App\Domain\SprintWeek;
+use DateTimeImmutable;
+use PDO;
+use RuntimeException;
+
+final class SprintWeekRepository
+{
+    public function __construct(private readonly PDO $pdo)
+    {
+    }
+
+    /** @return list<SprintWeek> ordered by sort_order ASC */
+    public function allForSprint(int $sprintId): array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT * FROM sprint_weeks 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): ?SprintWeek
+    {
+        $stmt = $this->pdo->prepare('SELECT * FROM sprint_weeks WHERE id = ?');
+        $stmt->execute([$id]);
+        $row = $stmt->fetch();
+        return is_array($row) ? self::hydrate($row) : null;
+    }
+
+    /**
+     * Resize the week set of a sprint to $targetCount weeks.
+     *
+     * - Added rows get max_working_days=5 and dates offset +7 days per week
+     *   from the sprint start.
+     * - Removed rows are the trailing ones; any sprint_worker_days attached
+     *   to them cascade-delete via the FK.
+     *
+     * Returns the before/after diff for auditing.
+     *
+     * @return array{added: list<SprintWeek>, removed: list<SprintWeek>}
+     */
+    public function syncCount(
+        int $sprintId,
+        string $sprintStartDate,
+        int $targetCount,
+    ): array {
+        if ($targetCount < 1) {
+            throw new RuntimeException("targetCount must be >= 1, got {$targetCount}");
+        }
+
+        $existing = $this->allForSprint($sprintId);
+        $currentCount = count($existing);
+
+        if ($targetCount === $currentCount) {
+            return ['added' => [], 'removed' => []];
+        }
+
+        if ($targetCount < $currentCount) {
+            // Drop the trailing rows by sort_order.
+            $toRemove = array_slice($existing, $targetCount);
+            $ids = array_map(fn(SprintWeek $w) => $w->id, $toRemove);
+            $placeholders = implode(',', array_fill(0, count($ids), '?'));
+            $this->pdo
+                ->prepare("DELETE FROM sprint_weeks WHERE id IN ({$placeholders})")
+                ->execute($ids);
+            return ['added' => [], 'removed' => $toRemove];
+        }
+
+        // Append rows.
+        $d0 = DateTimeImmutable::createFromFormat('Y-m-d', $sprintStartDate);
+        if ($d0 === false) {
+            throw new RuntimeException("Invalid sprintStartDate: {$sprintStartDate}");
+        }
+
+        $insert = $this->pdo->prepare(
+            'INSERT INTO sprint_weeks (sprint_id, sort_order, iso_week, start_date, max_working_days)
+             VALUES (?, ?, ?, ?, ?)'
+        );
+        $added = [];
+        for ($i = $currentCount + 1; $i <= $targetCount; $i++) {
+            $weekStart = $d0->modify('+' . ($i - 1) . ' weeks');
+            $iso       = (int) $weekStart->format('W');
+            $ymd       = $weekStart->format('Y-m-d');
+            $insert->execute([$sprintId, $i, $iso, $ymd, 5.0]);
+            $added[] = new SprintWeek(
+                id:              (int) $this->pdo->lastInsertId(),
+                sprintId:        $sprintId,
+                sortOrder:       $i,
+                isoWeek:         $iso,
+                startDate:       $ymd,
+                maxWorkingDays:  5.0,
+            );
+        }
+        return ['added' => $added, 'removed' => []];
+    }
+
+    /**
+     * @param array<string,mixed> $row
+     */
+    private static function hydrate(array $row): SprintWeek
+    {
+        return new SprintWeek(
+            id:             (int)    $row['id'],
+            sprintId:       (int)    $row['sprint_id'],
+            sortOrder:      (int)    $row['sort_order'],
+            isoWeek:        (int)    $row['iso_week'],
+            startDate:      (string) $row['start_date'],
+            maxWorkingDays: (float)  $row['max_working_days'],
+        );
+    }
+}

+ 181 - 0
src/Repositories/SprintWorkerRepository.php

@@ -0,0 +1,181 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repositories;
+
+use App\Domain\SprintWorker;
+use PDO;
+use RuntimeException;
+
+final class SprintWorkerRepository
+{
+    public function __construct(private readonly PDO $pdo)
+    {
+    }
+
+    /** @return list<SprintWorker> ordered by sort_order ASC, with worker name joined. */
+    public function allForSprint(int $sprintId): array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT sw.*, w.name AS worker_name
+             FROM sprint_workers sw
+             JOIN workers w ON w.id = sw.worker_id
+             WHERE sw.sprint_id = ?
+             ORDER BY sw.sort_order ASC'
+        );
+        $stmt->execute([$sprintId]);
+        $out = [];
+        foreach ($stmt as $row) {
+            $out[] = self::hydrate($row);
+        }
+        return $out;
+    }
+
+    public function find(int $id): ?SprintWorker
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT sw.*, w.name AS worker_name
+             FROM sprint_workers sw
+             JOIN workers w ON w.id = sw.worker_id
+             WHERE sw.id = ?'
+        );
+        $stmt->execute([$id]);
+        $row = $stmt->fetch();
+        return is_array($row) ? self::hydrate($row) : null;
+    }
+
+    /**
+     * Add a worker to a sprint at the end of the order. Returns the created
+     * SprintWorker. UNIQUE(sprint_id, worker_id) enforces that a worker
+     * can't be added twice; PDOException propagates.
+     */
+    public function add(int $sprintId, int $workerId, float $rtb): SprintWorker
+    {
+        $maxOrder = (int) $this->pdo
+            ->query('SELECT COALESCE(MAX(sort_order), 0) FROM sprint_workers WHERE sprint_id = ' . $sprintId)
+            ->fetchColumn();
+        $newOrder = $maxOrder + 1;
+
+        $stmt = $this->pdo->prepare(
+            'INSERT INTO sprint_workers (sprint_id, worker_id, rtb, sort_order) VALUES (?, ?, ?, ?)'
+        );
+        $stmt->execute([$sprintId, $workerId, $rtb, $newOrder]);
+
+        $id = (int) $this->pdo->lastInsertId();
+        $sw = $this->find($id);
+        if ($sw === null) {
+            throw new RuntimeException('Inserted sprint_worker not found');
+        }
+        return $sw;
+    }
+
+    /**
+     * Remove a sprint_worker row. Returns the removed row (before) for
+     * auditing, or null if it didn't exist.
+     */
+    public function remove(int $id): ?SprintWorker
+    {
+        $before = $this->find($id);
+        if ($before === null) {
+            return null;
+        }
+        $this->pdo
+            ->prepare('DELETE FROM sprint_workers WHERE id = ?')
+            ->execute([$id]);
+        return $before;
+    }
+
+    /**
+     * Apply an ordering of sprint_workers within a single sprint. The
+     * $ordering payload is a list of {sprint_worker_id, sort_order} pairs,
+     * assumed to be self-consistent (no duplicate orders, all IDs belong to
+     * the same sprint).
+     *
+     * Returns per-row before/after for auditing. Unchanged rows are omitted
+     * (the audit logger's no-op rule would drop them anyway, but this also
+     * avoids unnecessary UPDATE statements).
+     *
+     * @param list<array{sprint_worker_id:int, sort_order:int}> $ordering
+     * @return list<array{before: SprintWorker, after: SprintWorker}>
+     */
+    public function reorder(int $sprintId, array $ordering): array
+    {
+        if ($ordering === []) {
+            return [];
+        }
+
+        // Pre-fetch current state for the sprint so we can diff before/after.
+        $current = [];
+        foreach ($this->allForSprint($sprintId) as $sw) {
+            $current[$sw->id] = $sw;
+        }
+
+        // Stage new sort orders into negative space first so the updates don't
+        // violate any hypothetical unique constraint (the schema doesn't have
+        // one on sort_order today, but this keeps us future-proof).
+        $stage = $this->pdo->prepare(
+            'UPDATE sprint_workers SET sort_order = -? WHERE id = ? AND sprint_id = ?'
+        );
+        foreach ($ordering as $row) {
+            $stage->execute([$row['sort_order'], $row['sprint_worker_id'], $sprintId]);
+        }
+
+        $apply = $this->pdo->prepare(
+            'UPDATE sprint_workers SET sort_order = ? WHERE id = ? AND sprint_id = ?'
+        );
+        foreach ($ordering as $row) {
+            $apply->execute([$row['sort_order'], $row['sprint_worker_id'], $sprintId]);
+        }
+
+        $out = [];
+        foreach ($ordering as $row) {
+            $swId = (int) $row['sprint_worker_id'];
+            $before = $current[$swId] ?? null;
+            if ($before === null) {
+                continue;
+            }
+            if ($before->sortOrder === (int) $row['sort_order']) {
+                continue;
+            }
+            $after = $this->find($swId);
+            if ($after !== null) {
+                $out[] = ['before' => $before, 'after' => $after];
+            }
+        }
+        return $out;
+    }
+
+    /**
+     * Edit the RTB for a single sprint_worker. Returns before/after.
+     *
+     * @return array{before: SprintWorker, after: SprintWorker}
+     */
+    public function setRtb(int $id, float $rtb): array
+    {
+        $before = $this->find($id);
+        if ($before === null) {
+            throw new RuntimeException("sprint_worker {$id} not found");
+        }
+        $this->pdo
+            ->prepare('UPDATE sprint_workers SET rtb = ? WHERE id = ?')
+            ->execute([$rtb, $id]);
+        $after = $this->find($id) ?? $before;
+        return ['before' => $before, 'after' => $after];
+    }
+
+    /**
+     * @param array<string,mixed> $row
+     */
+    private static function hydrate(array $row): SprintWorker
+    {
+        return new SprintWorker(
+            id:         (int)    $row['id'],
+            sprintId:   (int)    $row['sprint_id'],
+            workerId:   (int)    $row['worker_id'],
+            workerName: (string) $row['worker_name'],
+            rtb:        (float)  $row['rtb'],
+            sortOrder:  (int)    $row['sort_order'],
+        );
+    }
+}

+ 24 - 0
src/Repositories/WorkerRepository.php

@@ -46,6 +46,30 @@ final class WorkerRepository
         return is_array($row) ? self::hydrate($row) : null;
     }
 
+    /**
+     * Active workers that are NOT yet members of the given sprint. Used by
+     * the sprint-settings page to populate the "available" list.
+     *
+     * @return list<Worker>
+     */
+    public function activeNotInSprint(int $sprintId): array
+    {
+        $stmt = $this->pdo->prepare(
+            'SELECT w.* FROM workers w
+             WHERE w.is_active = 1
+               AND w.id NOT IN (
+                   SELECT sw.worker_id FROM sprint_workers sw WHERE sw.sprint_id = ?
+               )
+             ORDER BY LOWER(w.name) ASC'
+        );
+        $stmt->execute([$sprintId]);
+        $out = [];
+        foreach ($stmt as $row) {
+            $out[] = self::hydrate($row);
+        }
+        return $out;
+    }
+
     /**
      * Insert a new worker. Throws if the unique name constraint is violated.
      */

+ 162 - 0
views/sprints/settings.php

@@ -0,0 +1,162 @@
+<?php
+/** @var \App\Domain\Sprint $sprint */
+/** @var \App\Domain\User $currentUser */
+/** @var string $csrfToken */
+/** @var list<\App\Domain\SprintWeek>   $weeks */
+/** @var list<\App\Domain\SprintWorker> $sprintWorkers */
+/** @var list<\App\Domain\Worker>       $availableWorkers */
+use function App\Http\e;
+?>
+<section class="space-y-6"
+         data-sprint-root
+         data-sprint-id="<?= (int) $sprint->id ?>"
+         data-csrf="<?= e($csrfToken) ?>">
+
+    <header class="flex items-end justify-between gap-4">
+        <div>
+            <nav class="text-xs text-slate-500">
+                <a href="/" class="hover:underline">Sprints</a> /
+                <a href="/sprints/<?= (int) $sprint->id ?>" class="hover:underline"><?= e($sprint->name) ?></a> /
+            </nav>
+            <h1 class="text-2xl font-semibold tracking-tight">Settings</h1>
+        </div>
+        <div data-status
+             class="text-sm border rounded px-3 py-1 opacity-0 transition-opacity duration-200 border-slate-200 bg-slate-50 text-slate-700">
+        </div>
+    </header>
+
+    <!-- Sprint meta -->
+    <section class="rounded-lg border bg-white p-5 space-y-4">
+        <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Sprint</h2>
+        <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
+            <label class="md:col-span-2 block">
+                <span class="text-sm text-slate-700">Name</span>
+                <input data-meta name="name" type="text" value="<?= e($sprint->name) ?>"
+                       class="mt-1 block w-full rounded-md border border-slate-300 shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400">
+            </label>
+            <label class="block">
+                <span class="text-sm text-slate-700">Start date</span>
+                <input data-meta name="start_date" type="date" value="<?= e($sprint->startDate) ?>"
+                       class="mt-1 block w-full rounded-md border border-slate-300 shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400">
+            </label>
+            <label class="block">
+                <span class="text-sm text-slate-700">End date</span>
+                <input data-meta name="end_date" type="date" value="<?= e($sprint->endDate) ?>"
+                       class="mt-1 block w-full rounded-md border border-slate-300 shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-400">
+            </label>
+            <label class="block">
+                <span class="text-sm text-slate-700">Reserve (%)</span>
+                <input data-meta name="reserve_fraction" type="number" min="0" max="100" step="1"
+                       value="<?= e(number_format($sprint->reserveFraction * 100, 0)) ?>"
+                       class="mt-1 block w-full rounded-md border border-slate-300 shadow-sm px-3 py-2 font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">
+            </label>
+        </div>
+        <p class="text-xs text-slate-500">Changes save automatically.</p>
+    </section>
+
+    <!-- Weeks -->
+    <section class="rounded-lg border bg-white p-5 space-y-4">
+        <div class="flex items-end justify-between gap-4">
+            <div>
+                <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Weeks</h2>
+                <p class="text-xs text-slate-500 mt-1">
+                    Current: <?= count($weeks) ?> week<?= count($weeks) === 1 ? '' : 's' ?>.
+                    Per-week max-working-days is edited on the sprint page (Phase 5).
+                </p>
+            </div>
+            <form data-weeks-form class="flex items-end gap-2">
+                <label class="block">
+                    <span class="text-xs text-slate-600">Set to</span>
+                    <input name="n_weeks" type="number" min="1" max="26" step="1"
+                           value="<?= count($weeks) ?>"
+                           class="mt-1 w-24 rounded-md border border-slate-300 shadow-sm px-3 py-2 font-mono focus:outline-none focus:ring-2 focus:ring-slate-400">
+                </label>
+                <button type="submit"
+                        class="rounded-md bg-slate-900 text-white px-3 py-2 text-sm font-medium hover:bg-slate-800">
+                    Apply
+                </button>
+            </form>
+        </div>
+        <div class="overflow-hidden rounded border border-slate-200">
+            <table class="min-w-full text-sm">
+                <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
+                    <tr>
+                        <th class="text-left px-3 py-2 font-semibold">#</th>
+                        <th class="text-left px-3 py-2 font-semibold">KW</th>
+                        <th class="text-left px-3 py-2 font-semibold">Start</th>
+                        <th class="text-right px-3 py-2 font-semibold">Max days</th>
+                    </tr>
+                </thead>
+                <tbody class="divide-y divide-slate-100">
+                    <?php foreach ($weeks as $w): ?>
+                        <tr>
+                            <td class="px-3 py-2 font-mono"><?= (int) $w->sortOrder ?></td>
+                            <td class="px-3 py-2 font-mono">KW<?= (int) $w->isoWeek ?></td>
+                            <td class="px-3 py-2 font-mono"><?= e($w->startDate) ?></td>
+                            <td class="px-3 py-2 font-mono text-right"><?= e(number_format($w->maxWorkingDays, 1)) ?></td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        </div>
+        <p class="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
+            Reducing the week count deletes trailing weeks and any data attached to them.
+        </p>
+    </section>
+
+    <!-- Worker picker -->
+    <section class="rounded-lg border bg-white p-5 space-y-4">
+        <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Workers</h2>
+        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+            <div>
+                <h3 class="text-xs font-semibold text-slate-500 uppercase mb-2">Available</h3>
+                <div class="rounded border border-slate-200">
+                    <ul data-available class="divide-y divide-slate-100">
+                        <?php foreach ($availableWorkers as $w): ?>
+                            <li class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0"
+                                data-worker-id="<?= (int) $w->id ?>">
+                                <span class="flex-1"><?= e($w->name) ?></span>
+                                <button type="button" data-add class="text-sm text-blue-700 hover:underline">Add →</button>
+                            </li>
+                        <?php endforeach; ?>
+                    </ul>
+                    <div data-empty-available
+                         class="p-3 text-center text-xs text-slate-500"
+                         <?= $availableWorkers === [] ? '' : 'style="display:none"' ?>>
+                        No other active workers.
+                    </div>
+                </div>
+            </div>
+            <div>
+                <h3 class="text-xs font-semibold text-slate-500 uppercase mb-2">In sprint (drag to reorder)</h3>
+                <div class="rounded border border-slate-200">
+                    <ul data-in-sprint class="divide-y divide-slate-100">
+                        <?php foreach ($sprintWorkers as $sw): ?>
+                            <li class="flex items-center gap-2 px-3 py-2 border-b bg-white last:border-b-0"
+                                data-sw-id="<?= (int) $sw->id ?>"
+                                data-worker-id="<?= (int) $sw->workerId ?>">
+                                <span class="handle cursor-grab text-slate-400 select-none">&#8801;</span>
+                                <span class="flex-1"><?= e($sw->workerName) ?></span>
+                                <input type="number" step="0.05" min="0" max="1"
+                                       value="<?= e(number_format($sw->rtb, 2, '.', '')) ?>"
+                                       data-rtb
+                                       class="w-20 rounded border border-slate-300 px-2 py-1 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
+                                <button type="button" data-remove class="text-sm text-red-600 hover:underline">Remove</button>
+                            </li>
+                        <?php endforeach; ?>
+                    </ul>
+                    <div data-empty-sprint
+                         class="p-3 text-center text-xs text-slate-500"
+                         <?= $sprintWorkers === [] ? '' : 'style="display:none"' ?>>
+                        No workers assigned yet.
+                    </div>
+                </div>
+            </div>
+        </div>
+        <p class="text-xs text-slate-500">
+            RTB (Run-the-Business) is informational and does not reduce computed capacity.
+        </p>
+    </section>
+</section>
+
+<script src="/assets/js/sprint-settings.js" defer></script>