|
@@ -11,10 +11,12 @@ use App\Http\Response;
|
|
|
use App\Http\View;
|
|
use App\Http\View;
|
|
|
use App\Repositories\SprintRepository;
|
|
use App\Repositories\SprintRepository;
|
|
|
use App\Repositories\SprintWeekRepository;
|
|
use App\Repositories\SprintWeekRepository;
|
|
|
|
|
+use App\Repositories\SprintWorkerDayRepository;
|
|
|
use App\Repositories\SprintWorkerRepository;
|
|
use App\Repositories\SprintWorkerRepository;
|
|
|
use App\Repositories\UserRepository;
|
|
use App\Repositories\UserRepository;
|
|
|
use App\Repositories\WorkerRepository;
|
|
use App\Repositories\WorkerRepository;
|
|
|
use App\Services\AuditLogger;
|
|
use App\Services\AuditLogger;
|
|
|
|
|
+use App\Services\CapacityCalculator;
|
|
|
use DateTimeImmutable;
|
|
use DateTimeImmutable;
|
|
|
use PDO;
|
|
use PDO;
|
|
|
use PDOException;
|
|
use PDOException;
|
|
@@ -23,14 +25,15 @@ use Throwable;
|
|
|
final class SprintController
|
|
final class SprintController
|
|
|
{
|
|
{
|
|
|
public function __construct(
|
|
public function __construct(
|
|
|
- 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,
|
|
|
|
|
|
|
+ private readonly PDO $pdo,
|
|
|
|
|
+ private readonly UserRepository $users,
|
|
|
|
|
+ private readonly SprintRepository $sprints,
|
|
|
|
|
+ private readonly SprintWeekRepository $weeks,
|
|
|
|
|
+ private readonly SprintWorkerRepository $sprintWorkers,
|
|
|
|
|
+ private readonly SprintWorkerDayRepository $days,
|
|
|
|
|
+ private readonly WorkerRepository $workers,
|
|
|
|
|
+ private readonly AuditLogger $audit,
|
|
|
|
|
+ private readonly View $view,
|
|
|
) {
|
|
) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -146,7 +149,7 @@ final class SprintController
|
|
|
return Response::redirect('/sprints/' . $sprint->id);
|
|
return Response::redirect('/sprints/' . $sprint->id);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** GET /sprints/{id} — minimal detail (settings/tasks follow in later phases). */
|
|
|
|
|
|
|
+ /** GET /sprints/{id} — main planning view (Section A Arbeitstage; tasks land in Phase 6). */
|
|
|
public function show(Request $req, array $params): Response
|
|
public function show(Request $req, array $params): Response
|
|
|
{
|
|
{
|
|
|
$actor = SessionGuard::requireAuth($this->users);
|
|
$actor = SessionGuard::requireAuth($this->users);
|
|
@@ -160,11 +163,32 @@ final class SprintController
|
|
|
return Response::text('Not Found', 404);
|
|
return Response::text('Not Found', 404);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ $weeks = $this->weeks->allForSprint($id);
|
|
|
|
|
+ $sprintWorkers = $this->sprintWorkers->allForSprint($id);
|
|
|
|
|
+ $grid = $this->days->grid($id);
|
|
|
|
|
+
|
|
|
|
|
+ // Seed initial capacity server-side so the page is meaningful without JS
|
|
|
|
|
+ // and the JS has the same numbers to compare against.
|
|
|
|
|
+ $capacity = [];
|
|
|
|
|
+ foreach ($sprintWorkers as $sw) {
|
|
|
|
|
+ $wkDays = $grid[$sw->id] ?? [];
|
|
|
|
|
+ $ressourcen = array_sum($wkDays);
|
|
|
|
|
+ $capacity[$sw->id] = CapacityCalculator::forWorker(
|
|
|
|
|
+ $ressourcen,
|
|
|
|
|
+ $sprint->reserveFraction,
|
|
|
|
|
+ 0.0, // prio-1 commitments come with Phase 6
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return Response::html($this->view->render('sprints/show', [
|
|
return Response::html($this->view->render('sprints/show', [
|
|
|
- 'title' => $sprint->name,
|
|
|
|
|
- 'currentUser' => $actor,
|
|
|
|
|
- 'csrfToken' => SessionGuard::csrfToken(),
|
|
|
|
|
- 'sprint' => $sprint,
|
|
|
|
|
|
|
+ 'title' => $sprint->name,
|
|
|
|
|
+ 'currentUser' => $actor,
|
|
|
|
|
+ 'csrfToken' => SessionGuard::csrfToken(),
|
|
|
|
|
+ 'sprint' => $sprint,
|
|
|
|
|
+ 'weeks' => $weeks,
|
|
|
|
|
+ 'sprintWorkers' => $sprintWorkers,
|
|
|
|
|
+ 'grid' => $grid,
|
|
|
|
|
+ 'capacity' => $capacity,
|
|
|
]));
|
|
]));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -528,6 +552,164 @@ final class SprintController
|
|
|
return Response::ok(['sprint_worker' => $result['after']->toAuditSnapshot()]);
|
|
return Response::ok(['sprint_worker' => $result['after']->toAuditSnapshot()]);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** PATCH /sprints/{id}/week-cells — JSON — batch upsert of sprint_worker_days. */
|
|
|
|
|
+ public function updateWeekCells(Request $req, array $params): Response
|
|
|
|
|
+ {
|
|
|
|
|
+ $gate = $this->gateJsonAdmin($req);
|
|
|
|
|
+ if ($gate instanceof Response) {
|
|
|
|
|
+ return $gate;
|
|
|
|
|
+ }
|
|
|
|
|
+ $actor = $gate;
|
|
|
|
|
+
|
|
|
|
|
+ $sprintId = (int) $params['id'];
|
|
|
|
|
+ $sprint = $this->sprints->find($sprintId);
|
|
|
|
|
+ if ($sprint === 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, sprint_week_id, days}', 422);
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($body === []) {
|
|
|
|
|
+ return Response::ok(['applied' => 0, 'noop' => 0, 'per_worker' => new \stdClass()]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Cross-check every cell belongs to this sprint.
|
|
|
|
|
+ $validSw = array_column(
|
|
|
|
|
+ array_map(fn($sw) => ['id' => $sw->id], $this->sprintWorkers->allForSprint($sprintId)),
|
|
|
|
|
+ 'id',
|
|
|
|
|
+ );
|
|
|
|
|
+ $validSw = array_flip($validSw);
|
|
|
|
|
+
|
|
|
|
|
+ $validWk = array_column(
|
|
|
|
|
+ array_map(fn($w) => ['id' => $w->id], $this->weeks->allForSprint($sprintId)),
|
|
|
|
|
+ 'id',
|
|
|
|
|
+ );
|
|
|
|
|
+ $validWk = array_flip($validWk);
|
|
|
|
|
+
|
|
|
|
|
+ $cells = [];
|
|
|
|
|
+ foreach ($body as $i => $row) {
|
|
|
|
|
+ if (!is_array($row) || !isset($row['sprint_worker_id'], $row['sprint_week_id'], $row['days'])) {
|
|
|
|
|
+ return Response::err('validation', "cell[{$i}] needs sprint_worker_id, sprint_week_id, days", 422);
|
|
|
|
|
+ }
|
|
|
|
|
+ $swId = (int) $row['sprint_worker_id'];
|
|
|
|
|
+ $wkId = (int) $row['sprint_week_id'];
|
|
|
|
|
+ $daysN = $row['days'];
|
|
|
|
|
+ if (!is_numeric($daysN)) {
|
|
|
|
|
+ return Response::err('validation', "cell[{$i}] days must be numeric", 422);
|
|
|
|
|
+ }
|
|
|
|
|
+ $days = (float) $daysN;
|
|
|
|
|
+ if (!isset($validSw[$swId])) {
|
|
|
|
|
+ return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!isset($validWk[$wkId])) {
|
|
|
|
|
+ return Response::err('validation', "cell[{$i}] sprint_week {$wkId} not in sprint", 422);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!CapacityCalculator::isHalfStep($days, 0.0, 5.0)) {
|
|
|
|
|
+ return Response::err('validation', "cell[{$i}] days must be 0..5 in 0.5 steps", 422);
|
|
|
|
|
+ }
|
|
|
|
|
+ $cells[] = ['sw_id' => $swId, 'week_id' => $wkId, 'days' => $days];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $applied = 0;
|
|
|
|
|
+ $noop = 0;
|
|
|
|
|
+ $touchedWorkers = [];
|
|
|
|
|
+
|
|
|
|
|
+ $this->pdo->beginTransaction();
|
|
|
|
|
+ try {
|
|
|
|
|
+ foreach ($cells as $c) {
|
|
|
|
|
+ $result = $this->days->upsert($c['sw_id'], $c['week_id'], $c['days']);
|
|
|
|
|
+ if ($result['action'] === 'NOOP') {
|
|
|
|
|
+ $noop++;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $applied++;
|
|
|
|
|
+ $touchedWorkers[$c['sw_id']] = true;
|
|
|
|
|
+
|
|
|
|
|
+ $this->audit->recordForRequest(
|
|
|
|
|
+ action: $result['action'],
|
|
|
|
|
+ entityType: 'sprint_worker_days',
|
|
|
|
|
+ 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 cells', 500);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Recompute capacity for every worker whose row changed.
|
|
|
|
|
+ $grid = $this->days->grid($sprintId);
|
|
|
|
|
+ $perWorker = [];
|
|
|
|
|
+ foreach (array_keys($touchedWorkers) as $swId) {
|
|
|
|
|
+ $ressourcen = array_sum($grid[$swId] ?? []);
|
|
|
|
|
+ $perWorker[(string) $swId] = CapacityCalculator::forWorker(
|
|
|
|
|
+ $ressourcen,
|
|
|
|
|
+ $sprint->reserveFraction,
|
|
|
|
|
+ 0.0,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Response::ok([
|
|
|
|
|
+ 'applied' => $applied,
|
|
|
|
|
+ 'noop' => $noop,
|
|
|
|
|
+ 'per_worker' => $perWorker === [] ? new \stdClass() : $perWorker,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** PATCH /sprints/{id}/week/{week_id} — JSON — edit max_working_days for one week. */
|
|
|
|
|
+ public function updateWeekMax(Request $req, array $params): Response
|
|
|
|
|
+ {
|
|
|
|
|
+ $gate = $this->gateJsonAdmin($req);
|
|
|
|
|
+ if ($gate instanceof Response) {
|
|
|
|
|
+ return $gate;
|
|
|
|
|
+ }
|
|
|
|
|
+ $actor = $gate;
|
|
|
|
|
+
|
|
|
|
|
+ $sprintId = (int) $params['id'];
|
|
|
|
|
+ $weekId = (int) $params['week_id'];
|
|
|
|
|
+
|
|
|
|
|
+ $week = $this->weeks->find($weekId);
|
|
|
|
|
+ if ($week === null || $week->sprintId !== $sprintId) {
|
|
|
|
|
+ return Response::err('not_found', 'sprint_week not found in this sprint', 404);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $body = $req->json() ?? [];
|
|
|
|
|
+ if (!isset($body['max_working_days']) || !is_numeric($body['max_working_days'])) {
|
|
|
|
|
+ return Response::err('validation', 'max_working_days required', 422);
|
|
|
|
|
+ }
|
|
|
|
|
+ $maxDays = (float) $body['max_working_days'];
|
|
|
|
|
+ if (!CapacityCalculator::isHalfStep($maxDays, 0.0, 5.0)) {
|
|
|
|
|
+ return Response::err('validation', 'max_working_days must be 0..5 in 0.5 steps', 422);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (abs($week->maxWorkingDays - $maxDays) < 1e-9) {
|
|
|
|
|
+ return Response::ok(['sprint_week' => $week->toAuditSnapshot()]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->pdo->beginTransaction();
|
|
|
|
|
+ try {
|
|
|
|
|
+ $result = $this->weeks->setMaxWorkingDays($weekId, $maxDays);
|
|
|
|
|
+ $this->audit->recordForRequest(
|
|
|
|
|
+ 'UPDATE', 'sprint_week', $weekId,
|
|
|
|
|
+ $result['before']->toAuditSnapshot(),
|
|
|
|
|
+ $result['after']->toAuditSnapshot(),
|
|
|
|
|
+ $req, $actor,
|
|
|
|
|
+ );
|
|
|
|
|
+ $this->pdo->commit();
|
|
|
|
|
+ } catch (Throwable) {
|
|
|
|
|
+ $this->pdo->rollBack();
|
|
|
|
|
+ return Response::err('db_error', 'Could not update week', 500);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Response::ok(['sprint_week' => $result['after']->toAuditSnapshot()]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// ------------------------------------------------------------------
|
|
// ------------------------------------------------------------------
|
|
|
// Shared helpers
|
|
// Shared helpers
|
|
|
// ------------------------------------------------------------------
|
|
// ------------------------------------------------------------------
|