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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user