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
-3
@@ -68,8 +68,12 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford
|
|||||||
Defaults 0.5/0.3/0.2, `ausschluss`-IDs für „Weitere 5", Sortierung
|
Defaults 0.5/0.3/0.2, `ausschluss`-IDs für „Weitere 5", Sortierung
|
||||||
absteigend nach Score, bei Gleichstand alphabetisch), `MahlzeitAdoptUseCase`
|
absteigend nach Score, bei Gleichstand alphabetisch), `MahlzeitAdoptUseCase`
|
||||||
(Quelle → neue `geplant`-Kopie mit `gegessenAm = null`, `MahlzeitAdoptedEvent`).
|
(Quelle → neue `geplant`-Kopie mit `gegessenAm = null`, `MahlzeitAdoptedEvent`).
|
||||||
Schritt 2 (UI): Planungsliste mit SortableJS + `POST /mahlzeiten/reorder`,
|
Schritt 2 (UI): `GET /mahlzeiten` = flache, per SortableJS sortierbare
|
||||||
Inspiration-View `GET /inspiration` + `POST /mahlzeiten/{id}/adopt`.
|
Planungsliste (nur `geplant`, Drag-Handle, `POST /mahlzeiten/reorder`
|
||||||
|
mit JSON/Form `ids` → 204); Tagesansicht ausgelagert nach
|
||||||
|
`GET /mahlzeiten/tage`; Inspiration unter `GET /inspiration` (5 Vorschläge,
|
||||||
|
„Weitere 5" via `?ausschluss=…`, 1-Klick-`POST /mahlzeiten/{id}/adopt`
|
||||||
|
mit Kontext-Erhaltung).
|
||||||
|
|
||||||
### M4 – Härtung & API
|
### M4 – Härtung & API
|
||||||
- `X-Api-Token`-Guard (optional, `.env`) für `GET /api/today` (HA-ready)
|
- `X-Api-Token`-Guard (optional, `.env`) für `GET /api/today` (HA-ready)
|
||||||
@@ -97,6 +101,6 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford
|
|||||||
| M0 | 2026-09-13 | abgenommen |
|
| M0 | 2026-09-13 | abgenommen |
|
||||||
| M1 | 2026-09-19 | abgenommen |
|
| M1 | 2026-09-19 | abgenommen |
|
||||||
| M2 | 2026-09-19 | abgenommen |
|
| M2 | 2026-09-19 | abgenommen |
|
||||||
| M3 | | offen |
|
| M3 | 2026-09-19 | abgenommen |
|
||||||
| M4 | | offen |
|
| M4 | | offen |
|
||||||
| M5 | | offen |
|
| M5 | | offen |
|
||||||
|
|||||||
@@ -63,6 +63,30 @@ mahlzeiten_loeschen:
|
|||||||
requirements:
|
requirements:
|
||||||
id: \d+
|
id: \d+
|
||||||
|
|
||||||
|
# M3: Tagesansicht (alle Mahlzeiten nach Wochentag) + Reorder (Drag & Drop)
|
||||||
|
mahlzeiten_tage:
|
||||||
|
path: /mahlzeiten/tage
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::tage
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
mahlzeiten_reorder:
|
||||||
|
path: /mahlzeiten/reorder
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::reorder
|
||||||
|
methods: [POST]
|
||||||
|
|
||||||
|
# M3: Inspiration (ADR-006): Vorschläge aus dem Archiv + 1-Klick-Adopt
|
||||||
|
mahlzeiten_inspiration:
|
||||||
|
path: /inspiration
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::inspiration
|
||||||
|
methods: [GET]
|
||||||
|
|
||||||
|
mahlzeiten_adopt:
|
||||||
|
path: /mahlzeiten/{id}/adopt
|
||||||
|
controller: App\UI\Http\Essen\MahlzeitController::adopt
|
||||||
|
methods: [POST]
|
||||||
|
requirements:
|
||||||
|
id: \d+
|
||||||
|
|
||||||
# M2: Status-Wechsel als htmx-Partial-Endpunkte (Fragment-Antwort)
|
# M2: Status-Wechsel als htmx-Partial-Endpunkte (Fragment-Antwort)
|
||||||
mahlzeiten_checkoff:
|
mahlzeiten_checkoff:
|
||||||
path: /mahlzeiten/{id}/checkoff
|
path: /mahlzeiten/{id}/checkoff
|
||||||
|
|||||||
@@ -7,15 +7,20 @@ namespace App\UI\Http\Essen;
|
|||||||
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
|
use App\Logic\Essen\Dto\MahlzeitCreateRequest;
|
||||||
use App\Logic\Essen\Dto\MahlzeitListRequest;
|
use App\Logic\Essen\Dto\MahlzeitListRequest;
|
||||||
use App\Logic\Essen\Dto\MahlzeitResponse;
|
use App\Logic\Essen\Dto\MahlzeitResponse;
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitReorderRequest;
|
||||||
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
|
use App\Logic\Essen\Dto\MahlzeitUpdateRequest;
|
||||||
|
use App\Logic\Essen\Dto\MahlzeitVorschlagResponse;
|
||||||
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
use App\Logic\Essen\Manager\MahlzeitManagerInterface;
|
||||||
use App\Logic\Essen\Model\Mahlzeit;
|
use App\Logic\Essen\Model\Mahlzeit;
|
||||||
use App\Logic\Essen\Model\MahlzeitStatus;
|
use App\Logic\Essen\Model\MahlzeitStatus;
|
||||||
use App\Logic\Essen\Model\Wochentag;
|
use App\Logic\Essen\Model\Wochentag;
|
||||||
|
use App\Logic\Essen\Query\MahlzeitInspirationQuery;
|
||||||
use App\Logic\Essen\Query\MahlzeitListQuery;
|
use App\Logic\Essen\Query\MahlzeitListQuery;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitAdoptUseCase;
|
||||||
use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase;
|
use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase;
|
||||||
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
|
use App\Logic\Essen\UseCase\MahlzeitCreateUseCase;
|
||||||
use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase;
|
use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase;
|
||||||
|
use App\Logic\Essen\UseCase\MahlzeitReorderUseCase;
|
||||||
use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase;
|
use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase;
|
||||||
use App\Logic\Essen\UseCase\MahlzeitUpdateUseCase;
|
use App\Logic\Essen\UseCase\MahlzeitUpdateUseCase;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -23,13 +28,18 @@ use Symfony\Component\HttpFoundation\Request;
|
|||||||
use Symfony\Component\HttpFoundation\Response;
|
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`.
|
* - Reads über `MahlzeitListQuery` / `MahlzeitInspirationQuery`
|
||||||
* - Writes über die UseCases (Create/Update/Delete/CheckOff/Uncheck).
|
* / `MahlzeitManagerInterface::find`.
|
||||||
|
* - Writes über die UseCases (Create/Update/Delete/CheckOff/Uncheck/Reorder/Adopt).
|
||||||
* - M1: klassisches POST/Redirect/Flash (Anlegen/Bearbeiten/Löschen).
|
* - M1: klassisches POST/Redirect/Flash (Anlegen/Bearbeiten/Löschen).
|
||||||
* - M2: Check-Off als htmx-Toggle – die Endpunkte liefern nur das geänderte
|
* - M2: Check-Off als htmx-Toggle – die Endpunkte liefern nur das geänderte
|
||||||
* Mahlzeiten-Partial zurück (Fragment, kein Voll-HTML).
|
* 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
|
* - Syntaktische Pflichtfeld-Prüfung (Name) passiert hier im UI-Layer
|
||||||
* (rein PHP, s. architektur.md §1.13); fachliche Invarianten bleiben
|
* (rein PHP, s. architektur.md §1.13); fachliche Invarianten bleiben
|
||||||
* im UseCase/Model und laufen zentral über den ExceptionSubscriber.
|
* im UseCase/Model und laufen zentral über den ExceptionSubscriber.
|
||||||
@@ -42,21 +52,89 @@ final class MahlzeitController extends AbstractController
|
|||||||
private readonly MahlzeitDeleteUseCase $delete,
|
private readonly MahlzeitDeleteUseCase $delete,
|
||||||
private readonly MahlzeitCheckOffUseCase $checkOff,
|
private readonly MahlzeitCheckOffUseCase $checkOff,
|
||||||
private readonly MahlzeitUncheckUseCase $uncheck,
|
private readonly MahlzeitUncheckUseCase $uncheck,
|
||||||
|
private readonly MahlzeitReorderUseCase $reorder,
|
||||||
|
private readonly MahlzeitAdoptUseCase $adopt,
|
||||||
private readonly MahlzeitListQuery $list,
|
private readonly MahlzeitListQuery $list,
|
||||||
|
private readonly MahlzeitInspirationQuery $inspiration,
|
||||||
private readonly MahlzeitManagerInterface $mahlzeiten,
|
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
|
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;
|
$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),
|
'tage' => $this->gruppierenNachTag($mahlzeiten),
|
||||||
'total' => \count($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. */
|
/** GET /mahlzeiten/archiv — Archiv: gegessene Mahlzeiten. */
|
||||||
public function archiv(): Response
|
public function archiv(): Response
|
||||||
{
|
{
|
||||||
@@ -177,6 +255,67 @@ final class MahlzeitController extends AbstractController
|
|||||||
return [$name, $notiz, $wochentage];
|
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
|
* @param array{name: string, notiz: string, wochentage: list<Wochentag>} $werte
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
|
|||||||
@@ -64,6 +64,8 @@
|
|||||||
.actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
.actions { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||||
.list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
|
.list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
.item { display: flex; justify-content: space-between; gap: 1rem; padding: 0.75rem; border: 1px solid #eee; border-radius: 0.4rem; align-items: center; flex-wrap: wrap; }
|
.item { display: flex; justify-content: space-between; gap: 1rem; padding: 0.75rem; border: 1px solid #eee; border-radius: 0.4rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
.drag-handle { cursor: grab; color: var(--muted); font-weight: 600; margin-right: 0.55rem; user-select: none; }
|
||||||
|
.item-drag { opacity: 0.4; }
|
||||||
.tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.4rem; }
|
.tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.4rem; }
|
||||||
.tag { background: #eef4ff; color: var(--accent); padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.8rem; }
|
.tag { background: #eef4ff; color: var(--accent); padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.8rem; }
|
||||||
.status { font-size: 0.8rem; color: var(--muted); }
|
.status { font-size: 0.8rem; color: var(--muted); }
|
||||||
@@ -91,6 +93,7 @@
|
|||||||
<a href="{{ path('home') }}" style="color: inherit; font-weight: 600;">essen.ljh31.de</a>
|
<a href="{{ path('home') }}" style="color: inherit; font-weight: 600;">essen.ljh31.de</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a>
|
<a href="{{ path('mahlzeiten_index') }}">Mahlzeiten</a>
|
||||||
|
<a href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
|
||||||
<a href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
|
<a href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
|
||||||
<a href="{{ path('logout') }}">Abmelden</a>
|
<a href="{{ path('logout') }}">Abmelden</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
{#
|
{#
|
||||||
Mahlzeiten-Partial (eine Zeile). Wird sowohl in der Tagesansicht/Archiv
|
Mahlzeiten-Partial (eine Zeile). Wird in Planungsliste/Tagesansicht/Archiv
|
||||||
eingebunden als auch als htmx-Fragment von den CheckOff/Uncheck-Endpunkten
|
eingebunden und als htmx-Fragment von den CheckOff/Uncheck-Endpunkten
|
||||||
zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese <li>).
|
zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese <li>).
|
||||||
|
|
||||||
@param MahlzeitResponse m
|
@param MahlzeitResponse m
|
||||||
|
@param bool sortierbar optional: zeigt Drag-Handle + data-id (Planungsliste)
|
||||||
#}
|
#}
|
||||||
<li class="item" id="mahlzeit-{{ m.id }}">
|
<li class="item" id="mahlzeit-{{ m.id }}"
|
||||||
|
{% if sortierbar is defined and sortierbar %}data-id="{{ m.id }}"{% endif %}>
|
||||||
<div>
|
<div>
|
||||||
|
{% if sortierbar is defined and sortierbar %}
|
||||||
|
<span class="drag-handle" title="Ziehen zum Sortieren">⋮</span>
|
||||||
|
{% endif %}
|
||||||
<strong>{{ m.name }}</strong>
|
<strong>{{ m.name }}</strong>
|
||||||
{% if m.notiz != '' %}
|
{% if m.notiz != '' %}
|
||||||
<div class="muted">{{ m.notiz }}</div>
|
<div class="muted">{{ m.notiz }}</div>
|
||||||
|
|||||||
@@ -7,25 +7,53 @@
|
|||||||
<div class="row-between">
|
<div class="row-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="margin: 0;">Mahlzeiten</h1>
|
<h1 style="margin: 0;">Mahlzeiten</h1>
|
||||||
<div class="muted" style="margin-top: 0.2rem;">Tagesansicht nach Wochentag</div>
|
<div class="muted" style="margin-top: 0.2rem;">Planungsliste – per Drag & Drop sortieren</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
|
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
|
||||||
|
<a class="btn btn-link" href="{{ path('mahlzeiten_inspiration') }}">Inspiration</a>
|
||||||
|
<a class="btn btn-link" href="{{ path('mahlzeiten_tage') }}">Tagesansicht</a>
|
||||||
<a class="btn btn-link" href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
|
<a class="btn btn-link" href="{{ path('mahlzeiten_archiv') }}">Archiv</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if total == 0 %}
|
{% if total == 0 %}
|
||||||
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten angelegt.</p>
|
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten in der Planung.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for tag, mahlzeiten in tage %}
|
<ul class="list sortierbar">
|
||||||
<h2 style="margin: 1.25rem 0 0;">{{ tag }}</h2>
|
{% for m in mahlzeiten %}
|
||||||
<ul class="list">
|
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m, sortierbar: true }) }}
|
||||||
{% for m in mahlzeiten %}
|
{% endfor %}
|
||||||
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
|
</ul>
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
<script src="{{ asset('js/sortable.min.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const liste = document.querySelector('ul.sortierbar');
|
||||||
|
if (!liste || typeof Sortable === 'undefined') { return; }
|
||||||
|
const url = '{{ path("mahlzeiten_reorder") }}';
|
||||||
|
Sortable.create(liste, {
|
||||||
|
handle: '.drag-handle',
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'item-drag',
|
||||||
|
onEnd: function () {
|
||||||
|
const ids = Array.prototype.map.call(
|
||||||
|
liste.querySelectorAll('li[data-id]'),
|
||||||
|
function (li) { return Number(li.getAttribute('data-id')); }
|
||||||
|
);
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids: ids }),
|
||||||
|
}).then(function (antwort) {
|
||||||
|
if (!antwort.ok) { window.location.reload(); }
|
||||||
|
}).catch(function () { window.location.reload(); });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends 'layout/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Inspiration · svc-essensplanung{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="row-between">
|
||||||
|
<div>
|
||||||
|
<h1 style="margin: 0;">Inspiration</h1>
|
||||||
|
<div class="muted" style="margin-top: 0.2rem;">Gerichte aus dem Archiv – was kochst du mal wieder?</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Für "Weitere 5" / Adopt-Kontext: bisheriger Ausschluss + die IDs dieser Seite #}
|
||||||
|
{% set alleAusschluss = ausschluss + gezeigte %}
|
||||||
|
|
||||||
|
{% if total == 0 %}
|
||||||
|
<p class="muted" style="margin-top: 1rem;">
|
||||||
|
Noch keine Inspiration: Hake Mahlzeiten ab – dann erscheinen sie hier als Vorschlag.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="list">
|
||||||
|
{% for v in vorschlaege %}
|
||||||
|
<li class="item" id="vorschlag-{{ v.id }}">
|
||||||
|
<div>
|
||||||
|
<strong>{{ v.name }}</strong>
|
||||||
|
{% if v.notiz != '' %}
|
||||||
|
<div class="muted">{{ v.notiz }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<span class="status">
|
||||||
|
{% if v.gegessenAm %}
|
||||||
|
zuletzt gegessen · {{ v.gegessenAm|date('d.m.Y') }}
|
||||||
|
{% else %}
|
||||||
|
aus dem Archiv
|
||||||
|
{% endif %}
|
||||||
|
· {{ v.auftraete }}× in der Küche
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" action="{{ path('mahlzeiten_adopt', { id: v.id }) }}">
|
||||||
|
{% for id in alleAusschluss %}
|
||||||
|
<input type="hidden" name="ausschluss[]" value="{{ id }}">
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn">Übernehmen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if mehrVerfuegbar %}
|
||||||
|
<p style="margin: 1rem 0 0;">
|
||||||
|
<a class="btn btn-link"
|
||||||
|
href="{{ path('mahlzeiten_inspiration', { ausschluss: alleAusschluss|join(',') }) }}">Weitere 5 Vorschläge</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends 'layout/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Tagesansicht · svc-essensplanung{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="row-between">
|
||||||
|
<div>
|
||||||
|
<h1 style="margin: 0;">Tagesansicht</h1>
|
||||||
|
<div class="muted" style="margin-top: 0.2rem;">Alle Mahlzeiten nach Wochentag</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-link" href="{{ path('mahlzeiten_index') }}">← Mahlzeiten</a>
|
||||||
|
<a class="btn" href="{{ path('mahlzeiten_neu') }}">+ Neue Mahlzeit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if total == 0 %}
|
||||||
|
<p class="muted" style="margin-top: 1rem;">Noch keine Mahlzeiten angelegt.</p>
|
||||||
|
{% else %}
|
||||||
|
{% for tag, mahlzeiten in tage %}
|
||||||
|
<h2 style="margin: 1.25rem 0 0;">{{ tag }}</h2>
|
||||||
|
<ul class="list">
|
||||||
|
{% for m in mahlzeiten %}
|
||||||
|
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -149,14 +149,14 @@ final class MahlzeitControllerTest extends WebTestCase
|
|||||||
|
|
||||||
// ---- M2 Schritt 2: Status (Check-Off/Toggle), Tagesansicht, Archiv ----
|
// ---- M2 Schritt 2: Status (Check-Off/Toggle), Tagesansicht, Archiv ----
|
||||||
|
|
||||||
public function testIndexGroupsMealsByWeekday(): void
|
public function testTageViewGroupsMealsByWeekday(): void
|
||||||
{
|
{
|
||||||
$client = $this->vorbereiten();
|
$client = $this->vorbereiten();
|
||||||
$this->anlegen($client, 'Curry', '', ['di']);
|
$this->anlegen($client, 'Curry', '', ['di']);
|
||||||
$this->anlegen($client, 'Pizza', '', ['sa']);
|
$this->anlegen($client, 'Pizza', '', ['sa']);
|
||||||
$this->anlegen($client, 'Bolognese', '', ['mo', 'mi']);
|
$this->anlegen($client, 'Bolognese', '', ['mo', 'mi']);
|
||||||
|
|
||||||
$client->request('GET', '/mahlzeiten');
|
$client->request('GET', '/mahlzeiten/tage');
|
||||||
$crawler = $client->getCrawler();
|
$crawler = $client->getCrawler();
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
@@ -176,12 +176,12 @@ final class MahlzeitControllerTest extends WebTestCase
|
|||||||
self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mi')->html());
|
self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mi')->html());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIndexGroupsMealWithoutWeekdayUnderOhneTag(): void
|
public function testTageViewGroupsMealWithoutWeekdayUnderOhneTag(): void
|
||||||
{
|
{
|
||||||
$client = $this->vorbereiten();
|
$client = $this->vorbereiten();
|
||||||
$this->anlegen($client, 'Reis', '', []);
|
$this->anlegen($client, 'Reis', '', []);
|
||||||
|
|
||||||
$client->request('GET', '/mahlzeiten');
|
$client->request('GET', '/mahlzeiten/tage');
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
self::assertStringContainsString('Reis', $this->listeNachHeading($client->getCrawler(), 'Ohne Tag')->html());
|
self::assertStringContainsString('Reis', $this->listeNachHeading($client->getCrawler(), 'Ohne Tag')->html());
|
||||||
@@ -265,6 +265,174 @@ final class MahlzeitControllerTest extends WebTestCase
|
|||||||
self::assertResponseStatusCodeSame(404);
|
self::assertResponseStatusCodeSame(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- M3: Planungsliste (flach/sortierbar), Reorder, Inspiration, Adopt ----
|
||||||
|
|
||||||
|
public function testIndexRendersFlatPlannedListOnly(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$geplantId = $this->anlegen($client, 'GeplanteMahlzeit', '', []);
|
||||||
|
$gegessenId = $this->anlegen($client, 'GegesseneMahlzeit', '', []);
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $gegessenId));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$client->request('GET', '/mahlzeiten');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
// Sortierbare Planungsliste mit Drag-Handle + data-id; gegessene Mahlzeit fehlt.
|
||||||
|
self::assertSelectorExists('ul.sortierbar');
|
||||||
|
self::assertSelectorExists('li[data-id="' . $geplantId . '"]');
|
||||||
|
self::assertSelectorExists('li[data-id="' . $geplantId . '"] .drag-handle');
|
||||||
|
self::assertSelectorNotExists('li[data-id="' . $gegessenId . '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexKeepsPersistedSortOrder(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$a = $this->anlegen($client, 'Erste', '', []);
|
||||||
|
$b = $this->anlegen($client, 'Zweite', '', []);
|
||||||
|
$c = $this->anlegen($client, 'Dritte', '', []);
|
||||||
|
|
||||||
|
// Anordnung ändern: C, A, B (Formularfeld `ids[]`).
|
||||||
|
$client->request('POST', '/mahlzeiten/reorder', ['ids' => [$c, $a, $b]]);
|
||||||
|
self::assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
// Persistenz: sortOrder neu nummeriert.
|
||||||
|
$this->em()->clear();
|
||||||
|
$repo = $this->em()->getRepository(MahlzeitEntity::class);
|
||||||
|
self::assertSame(1, $repo->find($c)->getSortOrder());
|
||||||
|
self::assertSame(2, $repo->find($a)->getSortOrder());
|
||||||
|
self::assertSame(3, $repo->find($b)->getSortOrder());
|
||||||
|
|
||||||
|
// Index rendert die neue Reihenfolge.
|
||||||
|
$client->request('GET', '/mahlzeiten');
|
||||||
|
$reihenfolge = $client->getCrawler()
|
||||||
|
->filter('li[data-id]')
|
||||||
|
->each(static fn (Crawler $node): int => (int) $node->attr('data-id'));
|
||||||
|
self::assertSame([$c, $a, $b], $reihenfolge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReorderUnbekannteIdLiefert404(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$client->request('POST', '/mahlzeiten/reorder', ['ids' => [99999]]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReorderAkzeptiertJsonIds(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$a = $this->anlegen($client, 'Erste', '', []);
|
||||||
|
$b = $this->anlegen($client, 'Zweite', '', []);
|
||||||
|
|
||||||
|
// SortableJS schickt JSON: {"ids": [...]} – hier als roher Body.
|
||||||
|
$client->request('POST', '/mahlzeiten/reorder', [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
'HTTP_ACCEPT' => 'application/json',
|
||||||
|
], json_encode(['ids' => [$b, $a]]));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
$this->em()->clear();
|
||||||
|
$repo = $this->em()->getRepository(MahlzeitEntity::class);
|
||||||
|
self::assertSame(1, $repo->find($b)->getSortOrder());
|
||||||
|
self::assertSame(2, $repo->find($a)->getSortOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInspirationListetArchivGerichteMitAdopt(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$id = $this->anlegen($client, 'Bolognese', '', ['mo']);
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$client->request('GET', '/inspiration');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('h1', 'Inspiration');
|
||||||
|
self::assertSelectorTextContains('strong', 'Bolognese');
|
||||||
|
self::assertSelectorExists('form[action$="/adopt"] button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInspirationLeerOhneArchivEintraege(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$this->anlegen($client, 'NurGeplant', '', []);
|
||||||
|
|
||||||
|
$client->request('GET', '/inspiration');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('p', 'Noch keine Inspiration');
|
||||||
|
self::assertSelectorNotExists('li[id^="vorschlag-"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInspirationWeitere5ExkludiertBereitsGezeigte(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
foreach (['A', 'B', 'C', 'D', 'E', 'F'] as $buchstabe) {
|
||||||
|
$id = $this->anlegen($client, sprintf('Gericht%s', $buchstabe), '', []);
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
$client->request('GET', '/inspiration');
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$ersteSeite = $client->getCrawler()->filter('li[id^="vorschlag-"]');
|
||||||
|
self::assertCount(5, $ersteSeite);
|
||||||
|
$ersteSeiteNamen = $ersteSeite->each(static fn (Crawler $n): string => $n->filter('strong')->text());
|
||||||
|
|
||||||
|
// "Weitere 5" führt mit Ausschluss-Liste zurück zur Inspiration.
|
||||||
|
$link = $client->getCrawler()->filter('a:contains("Weitere 5 Vorschläge")');
|
||||||
|
self::assertCount(1, $link);
|
||||||
|
self::assertStringContainsString('ausschluss=', (string) $link->link()->getUri());
|
||||||
|
|
||||||
|
$client->request('GET', (string) $link->link()->getUri());
|
||||||
|
$zweiteSeite = $client->getCrawler()->filter('li[id^="vorschlag-"]');
|
||||||
|
self::assertCount(1, $zweiteSeite);
|
||||||
|
self::assertNotContains($zweiteSeite->filter('strong')->text(), $ersteSeiteNamen);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdoptErstelltNeuenGeplantEintrag(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$quellId = $this->anlegen($client, 'Pasta', 'mit Knoblauch', ['di']);
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $quellId));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$client->request('POST', sprintf('/mahlzeiten/%d/adopt', $quellId));
|
||||||
|
self::assertResponseRedirects('/inspiration');
|
||||||
|
|
||||||
|
$this->em()->clear();
|
||||||
|
$repo = $this->em()->getRepository(MahlzeitEntity::class);
|
||||||
|
|
||||||
|
// Quelle bleibt unverändert im Archiv.
|
||||||
|
self::assertSame('gegessen', $repo->find($quellId)->getStatus());
|
||||||
|
|
||||||
|
// Neue Kopie: gleicher Name/Notiz/Wochentag, aber geplant + ohne gegessenAm.
|
||||||
|
$kopien = $repo->findBy(['name' => 'Pasta']);
|
||||||
|
self::assertCount(2, $kopien);
|
||||||
|
$neue = null;
|
||||||
|
foreach ($kopien as $kandidat) {
|
||||||
|
if ((int) $kandidat->getId() !== $quellId) {
|
||||||
|
$neue = $kandidat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertNotNull($neue);
|
||||||
|
self::assertSame('geplant', $neue->getStatus());
|
||||||
|
self::assertNull($neue->getGegessenAm());
|
||||||
|
self::assertSame('mit Knoblauch', $neue->getNotiz());
|
||||||
|
self::assertSame('di', $neue->getWochentage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdoptUnbekannteIdLiefert404(): void
|
||||||
|
{
|
||||||
|
$client = $this->vorbereiten();
|
||||||
|
$client->request('POST', '/mahlzeiten/99999/adopt');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert die <ul>-Liste, die direkt unter der angegebenen <h2>-Überschrift
|
* Liefert die <ul>-Liste, die direkt unter der angegebenen <h2>-Überschrift
|
||||||
* (z. B. "Di", "Ohne Tag") in der Tagesansicht liegt.
|
* (z. B. "Di", "Ohne Tag") in der Tagesansicht liegt.
|
||||||
|
|||||||
Reference in New Issue
Block a user