M3: Reorder + Inspiration – Schritt 2 (UI): Planungsliste mit SortableJS, Tagesansicht /mahlzeiten/tage, Inspiration /inspiration mit 'Weitere 5' + 1-Klick-Adopt, Feature-Tests

This commit is contained in:
2026-09-19 17:09:15 +02:00
parent 52a34225fa
commit fa5894426d
9 changed files with 488 additions and 25 deletions
+144 -5
View File
@@ -7,15 +7,20 @@ namespace App\UI\Http\Essen;
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
use App\Logic\Essen\Dto\MahlzeitListRequest;
use App\Logic\Essen\Dto\MahlzeitResponse;
use App\Logic\Essen\Dto\MahlzeitReorderRequest;
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
use App\Logic\Essen\Dto\MahlzeitVorschlagResponse;
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
use App\Logic\Essen\Model\Mahlzeit;
use App\Logic\Essen\Model\MahlzeitStatus;
use App\Logic\Essen\Model\Wochentag;
use App\Logic\Essen\Query\MahlzeitInspirationQuery;
use App\Logic\Essen\Query\MahlzeitListQuery;
use App\Logic\Essen\UseCase\MahlzeitAdoptUseCase;
use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase;
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase;
use App\Logic\Essen\UseCase\MahlzeitReorderUseCase;
use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase;
use App\Logic\Essen\UseCase\MahlzeitUpdateUseCase;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -23,13 +28,18 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* CRUD-Slice "Mahlzeit" (M1) + Status & Tagesansicht (M2) – Web-UI.
* CRUD-Slice "Mahlzeit" (M1) + Status & Tagesansicht (M2)
* + Reorder & Inspiration (M3) – Web-UI.
*
* - Reads über `MahlzeitListQuery` / `MahlzeitManagerInterface::find`.
* - Writes über die UseCases (Create/Update/Delete/CheckOff/Uncheck).
* - Reads über `MahlzeitListQuery` / `MahlzeitInspirationQuery`
* / `MahlzeitManagerInterface::find`.
* - Writes über die UseCases (Create/Update/Delete/CheckOff/Uncheck/Reorder/Adopt).
* - M1: klassisches POST/Redirect/Flash (Anlegen/Bearbeiten/Löschen).
* - M2: Check-Off als htmx-Toggle – die Endpunkte liefern nur das geänderte
* Mahlzeiten-Partial zurück (Fragment, kein Voll-HTML).
* - M3: Index = flache, per SortableJS sortierbare Planungsliste
* (`POST /mahlzeiten/reorder` → 204); Tagesansicht unter `/mahlzeiten/tage`;
* Inspiration unter `/inspiration` mit 1-Klick-Adopt.
* - Syntaktische Pflichtfeld-Prüfung (Name) passiert hier im UI-Layer
* (rein PHP, s. architektur.md §1.13); fachliche Invarianten bleiben
* im UseCase/Model und laufen zentral über den ExceptionSubscriber.
@@ -42,21 +52,89 @@ final class MahlzeitController extends AbstractController
private readonly MahlzeitDeleteUseCase $delete,
private readonly MahlzeitCheckOffUseCase $checkOff,
private readonly MahlzeitUncheckUseCase $uncheck,
private readonly MahlzeitReorderUseCase $reorder,
private readonly MahlzeitAdoptUseCase $adopt,
private readonly MahlzeitListQuery $list,
private readonly MahlzeitInspirationQuery $inspiration,
private readonly MahlzeitManagerInterface $mahlzeiten,
) {}
/** GET /mahlzeiten — Tagesansicht: alle Mahlzeiten nach Wochentag gruppiert. */
/** GET /mahlzeiten — Planungsliste: geplante Mahlzeiten in sortOrder-Reihenfolge (sortable). */
public function index(): Response
{
$mahlzeiten = $this->list->execute(new MahlzeitListRequest(status: MahlzeitStatus::Geplant))->items;
return $this->render('mahlzeiten/index.html.twig', [
'mahlzeiten' => $mahlzeiten,
'total' => \count($mahlzeiten),
]);
}
/** GET /mahlzeiten/tage — Tagesansicht: alle Mahlzeiten nach Wochentag gruppiert. */
public function tage(): Response
{
$mahlzeiten = $this->list->execute(new MahlzeitListRequest())->items;
return $this->render('mahlzeiten/index.html.twig', [
return $this->render('mahlzeiten/tage.html.twig', [
'tage' => $this->gruppierenNachTag($mahlzeiten),
'total' => \count($mahlzeiten),
]);
}
/**
* POST /mahlzeiten/reorder — neue Reihenfolge der Planungsliste (Drag & Drop)
* persistieren. Erwartet JSON `{"ids": [3, 1, 2]}` oder das Formularfeld
* `ids` (Array oder kommagetrennte Liste). Antwort: 204 No Content.
* Wirft 404 bei unbekannten IDs.
*/
public function reorder(Request $request): Response
{
$this->reorder->execute(new MahlzeitReorderRequest($this->bindeReihenfolge($request)));
return new Response(null, Response::HTTP_NO_CONTENT);
}
/**
* GET /inspiration — Vorschläge aus dem Archiv (ADR-006).
* `?ausschluss=1,2,3` (kommagetrennte Repräsentanten-IDs) liefert "Weitere 5".
*/
public function inspiration(Request $request): Response
{
$ausschluss = $this->bindeAusschluss($request->query->all()['ausschluss'] ?? null);
// Proben mit 6: so wissen wir, ob hinter den 5 gezeigten noch mehr liegen.
$vorschlaege = $this->inspiration->execute(6, $ausschluss);
$mehrVerfuegbar = \count($vorschlaege) > 5;
$vorschlaege = array_slice($vorschlaege, 0, 5);
return $this->render('mahlzeiten/inspiration.html.twig', [
'vorschlaege' => $vorschlaege,
'ausschluss' => $ausschluss,
'gezeigte' => array_map(static fn (MahlzeitVorschlagResponse $v): int => $v->id, $vorschlaege),
'mehrVerfuegbar' => $mehrVerfuegbar,
'total' => \count($vorschlaege),
]);
}
/**
* POST /mahlzeiten/{id}/adopt — Gericht als NEUE geplante Mahlzeit übernehmen
* (Kopie, s. `MahlzeitAdoptUseCase`). Redirect zurück zur Inspiration,
* dabei die bisherige Ausschluss-Liste (für "Weitere 5") mitnehmen.
*/
public function adopt(Request $request, int $id): Response
{
$ergebnis = $this->adopt->execute($id);
$this->addFlash('notice', sprintf('Gericht „%s“ in die Planungsliste übernommen.', $ergebnis->name));
$params = [];
$ausschluss = $this->bindeAusschluss($request->request->all()['ausschluss'] ?? null);
if ($ausschluss !== []) {
$params['ausschluss'] = implode(',', $ausschluss);
}
return $this->redirectToRoute('mahlzeiten_inspiration', $params);
}
/** GET /mahlzeiten/archiv — Archiv: gegessene Mahlzeiten. */
public function archiv(): Response
{
@@ -177,6 +255,67 @@ final class MahlzeitController extends AbstractController
return [$name, $notiz, $wochentage];
}
/**
* Bindet die neue ID-Reihenfolge (Drag & Drop) aus dem Request.
* Unterstützt JSON `{"ids": [...]}`, das Formularfeld `ids[]`
* und kommagetrennte `ids=1,2,3`. Ungültige Werte werden verworfen.
*
* @return list<int>
*/
private function bindeReihenfolge(Request $request): array
{
$roh = null;
$json = json_decode((string) $request->getContent(), true);
if (\is_array($json) && \is_array($json['ids'] ?? null)) {
$roh = $json['ids'];
} else {
// Raw-Bag-Zugriff: `get()`/`all(key)` werfen je auf Array/Skalar.
$roh = $request->request->all()['ids'] ?? null;
if (\is_string($roh)) {
$roh = array_filter(explode(',', $roh), static fn (string $teil): bool => trim($teil) !== '');
}
}
if (!\is_array($roh)) {
return [];
}
$ids = [];
foreach ($roh as $wert) {
if ((\is_int($wert) || \is_string($wert)) && preg_match('/^\d+$/', trim((string) $wert)) === 1) {
$ids[] = (int) $wert;
}
}
return $ids;
}
/**
* Bindet die Ausschluss-Liste bereits gezeigter Vorschläge ("Weitere 5").
* Akzeptiert ein String-Feld (`?ausschluss=1,2,3`) oder ein Array-Feld
* (`ausschluss[]`); rawer Bag-Zugriff, da `ParameterBag::get()`/`all(key)`
* je auf Skalar/Array werfen.
*
* @return list<int>
*/
private function bindeAusschluss(array|string|null $werte): array
{
if ($werte === null || $werte === '') {
return [];
}
if (\is_string($werte)) {
$werte = array_filter(explode(',', $werte), static fn (string $teil): bool => trim($teil) !== '');
}
$ids = [];
foreach ($werte as $wert) {
if (\is_scalar($wert) && preg_match('/^\d+$/', trim((string) $wert)) === 1) {
$ids[] = (int) trim((string) $wert);
}
}
return $ids;
}
/**
* @param array{name: string, notiz: string, wochentage: list<Wochentag>} $werte
* @return array<string, mixed>