diff --git a/Doku/plan.md b/Doku/plan.md index d0d12e8..8443c25 100644 --- a/Doku/plan.md +++ b/Doku/plan.md @@ -48,6 +48,12 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford - `CheckOffDish`/`UncheckDish` (Status `aktiv`/`gecheckt`), `ArchiveDish` - Wochentag-Tags (Mo–So) setzen/ändern, Liste nach Tag gruppier-/filterbar - **DoD:** Check-Off via htmx-Toggle, Tagesansicht, Archiv-Tab +- *Konkrete Umsetzung (M1-Konvention, Domäne DE):* `MahlzeitCheckOffUseCase` + (geplant→gegessen) / `MahlzeitUncheckUseCase` (gegessen→geplant), beide + idempotent, `MahlzeitStatusChangedEvent`. „Archiv“ = Read-View auf + `status = gegessen` (kein eigener Status/Use-Case). Check-Off als htmx-Partial + (`POST /mahlzeiten/{id}/checkoff|uncheck`), Tagesansicht gruppiert nach + Wochentag (Mo…So, „Ohne Tag“), Archiv-Tab `GET /mahlzeiten/archiv`. ### M3 – Reorder + Inspiration - `ReorderDishes` (SortableJS-`change` → Position-Ints, persistiert) @@ -76,11 +82,11 @@ Status: **final** (2026-09-13, abgestimmt) · Basis: [`anforderungen.md`](anford ## Abnahme-Historie -| MS | Datum | Status | -|----|-------|--------| -| M0 | | offen | -| M1 | | offen | -| M2 | | offen | -| M3 | | offen | -| M4 | | offen | -| M5 | | offen | +| MS | Datum | Status | +|----|----------|------------| +| M0 | 2026-09-13 | abgenommen | +| M1 | 2026-09-19 | abgenommen | +| M2 | 2026-09-19 | abgenommen | +| M3 | | offen | +| M4 | | offen | +| M5 | | offen | diff --git a/config/routes.yaml b/config/routes.yaml index b275cc8..91304df 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -26,6 +26,12 @@ mahlzeiten_index: controller: App\UI\Http\Essen\MahlzeitController::index methods: [GET] +# M2: Archiv (gegessene Mahlzeiten) +mahlzeiten_archiv: + path: /mahlzeiten/archiv + controller: App\UI\Http\Essen\MahlzeitController::archiv + methods: [GET] + mahlzeiten_neu: path: /mahlzeiten/neu controller: App\UI\Http\Essen\MahlzeitController::neu @@ -56,3 +62,18 @@ mahlzeiten_loeschen: methods: [POST] requirements: id: \d+ + +# M2: Status-Wechsel als htmx-Partial-Endpunkte (Fragment-Antwort) +mahlzeiten_checkoff: + path: /mahlzeiten/{id}/checkoff + controller: App\UI\Http\Essen\MahlzeitController::checkoff + methods: [POST] + requirements: + id: \d+ + +mahlzeiten_uncheck: + path: /mahlzeiten/{id}/uncheck + controller: App\UI\Http\Essen\MahlzeitController::uncheck + methods: [POST] + requirements: + id: \d+ diff --git a/src/UI/Http/Essen/MahlzeitController.php b/src/UI/Http/Essen/MahlzeitController.php index 9c49b7b..2b9fe9f 100644 --- a/src/UI/Http/Essen/MahlzeitController.php +++ b/src/UI/Http/Essen/MahlzeitController.php @@ -6,25 +6,30 @@ 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\MahlzeitUpdateRequest; 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\MahlzeitListQuery; +use App\Logic\Essen\UseCase\MahlzeitCheckOffUseCase; use App\Logic\Essen\UseCase\MahlzeitCreateUseCase; use App\Logic\Essen\UseCase\MahlzeitDeleteUseCase; +use App\Logic\Essen\UseCase\MahlzeitUncheckUseCase; use App\Logic\Essen\UseCase\MahlzeitUpdateUseCase; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; /** - * CRUD-Slice "Mahlzeit" (M1) – Web-UI. - * - * Stil: klassisches POST/Redirect/Flash (htmx-Toggles kommen in M2). + * CRUD-Slice "Mahlzeit" (M1) + Status & Tagesansicht (M2) – Web-UI. * * - Reads über `MahlzeitListQuery` / `MahlzeitManagerInterface::find`. - * - Writes über die UseCases (Create/Update/Delete). + * - Writes über die UseCases (Create/Update/Delete/CheckOff/Uncheck). + * - 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). * - 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. @@ -35,15 +40,30 @@ final class MahlzeitController extends AbstractController private readonly MahlzeitCreateUseCase $create, private readonly MahlzeitUpdateUseCase $update, private readonly MahlzeitDeleteUseCase $delete, + private readonly MahlzeitCheckOffUseCase $checkOff, + private readonly MahlzeitUncheckUseCase $uncheck, private readonly MahlzeitListQuery $list, private readonly MahlzeitManagerInterface $mahlzeiten, ) {} - /** GET /mahlzeiten — Planungsliste. */ + /** GET /mahlzeiten — Tagesansicht: alle Mahlzeiten nach Wochentag gruppiert. */ public function index(): Response { + $mahlzeiten = $this->list->execute(new MahlzeitListRequest())->items; + return $this->render('mahlzeiten/index.html.twig', [ - 'mahlzeiten' => $this->list->execute(new MahlzeitListRequest())->items, + 'tage' => $this->gruppierenNachTag($mahlzeiten), + 'total' => \count($mahlzeiten), + ]); + } + + /** GET /mahlzeiten/archiv — Archiv: gegessene Mahlzeiten. */ + public function archiv(): Response + { + $mahlzeiten = $this->list->execute(new MahlzeitListRequest(status: MahlzeitStatus::Gegessen))->items; + + return $this->render('mahlzeiten/archiv.html.twig', [ + 'mahlzeiten' => $mahlzeiten, ]); } @@ -118,6 +138,31 @@ final class MahlzeitController extends AbstractController return $this->redirectToRoute('mahlzeiten_index'); } + /** + * POST /mahlzeiten/{id}/checkoff — htmx-Partial: abhaken (geplant → gegessen). + * + * Liefert nur das aktualisierte Mahlzeiten-Partial zurück (Fragment), das + * htmx an der Stelle des Klicks austauscht. Wirft 404 bei unbekannter ID. + */ + public function checkoff(Request $request, int $id): Response + { + $mahlzeit = $this->checkOff->execute($id); + + return $this->render('mahlzeiten/_mahlzeit.html.twig', ['m' => $mahlzeit]); + } + + /** + * POST /mahlzeiten/{id}/uncheck — htmx-Partial: aufheben (gegessen → geplant). + * + * Liefert nur das aktualisierte Mahlzeiten-Partial zurück (Fragment). + */ + public function uncheck(Request $request, int $id): Response + { + $mahlzeit = $this->uncheck->execute($id); + + return $this->render('mahlzeiten/_mahlzeit.html.twig', ['m' => $mahlzeit]); + } + /** * Bindet die Formularfelder (Name/Notiz/Wochentage) aus dem Request. * @@ -145,4 +190,35 @@ final class MahlzeitController extends AbstractController 'wochentage_choices' => Wochentag::alle(), ]; } + + /** + * Gruppiert Mahlzeiten nach ihren Wochentagen (Mo…So). + * + * Eine Mahlzeit mit mehreren Wochentagen erscheint unter jedem dieser Tage. + * Mahlzeiten ohne Wochentag landen in der Gruppe "Ohne Tag". Leere Gruppen + * werden herausgefiltert; die Reihenfolge folgt Mo…So, danach "Ohne Tag". + * + * @param list $mahlzeiten + * @return array> + */ + private function gruppierenNachTag(array $mahlzeiten): array + { + $tage = []; + foreach (Wochentag::alle() as $tag) { + $tage[$tag->name] = []; + } + $tage['Ohne Tag'] = []; + + foreach ($mahlzeiten as $m) { + if ($m->wochentage === []) { + $tage['Ohne Tag'][] = $m; + continue; + } + foreach ($m->wochentage as $tag) { + $tage[$tag->name][] = $m; + } + } + + return \array_filter($tage, static fn (array $liste) => $liste !== []); + } } diff --git a/templates/layout/base.html.twig b/templates/layout/base.html.twig index fba7bfb..7077b81 100644 --- a/templates/layout/base.html.twig +++ b/templates/layout/base.html.twig @@ -91,6 +91,7 @@ essen.ljh31.de diff --git a/templates/mahlzeiten/_mahlzeit.html.twig b/templates/mahlzeiten/_mahlzeit.html.twig new file mode 100644 index 0000000..89256ab --- /dev/null +++ b/templates/mahlzeiten/_mahlzeit.html.twig @@ -0,0 +1,46 @@ +{# + Mahlzeiten-Partial (eine Zeile). Wird sowohl in der Tagesansicht/Archiv + eingebunden als auch als htmx-Fragment von den CheckOff/Uncheck-Endpunkten + zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese
  • ). + + @param MahlzeitResponse m +#} +
  • +
    + {{ m.name }} + {% if m.notiz != '' %} +
    {{ m.notiz }}
    + {% endif %} + {% if m.wochentage|length > 0 %} +
    + {% for tag in m.wochentage %} + {{ tag.name }} + {% endfor %} +
    + {% endif %} + + {{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }} + {% if m.status.value == 'gegessen' and m.gegessenAm %} + · {{ m.gegessenAm|date('d.m.Y H:i') }} + {% endif %} + +
    +
    + {% if m.status.value == 'geplant' %} + + {% else %} + + {% endif %} + Bearbeiten +
    + +
    +
    +
  • diff --git a/templates/mahlzeiten/archiv.html.twig b/templates/mahlzeiten/archiv.html.twig new file mode 100644 index 0000000..f650cce --- /dev/null +++ b/templates/mahlzeiten/archiv.html.twig @@ -0,0 +1,27 @@ +{% extends 'layout/base.html.twig' %} + +{% block title %}Archiv · svc-essensplanung{% endblock %} + +{% block body %} +
    +
    +

    Archiv

    + +
    + +

    Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.

    + + {% if mahlzeiten|length == 0 %} +

    Noch nichts gegessen – das Archiv ist leer.

    + {% else %} +
      + {% for m in mahlzeiten %} + {{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }} + {% endfor %} +
    + {% endif %} +
    +{% endblock %} diff --git a/templates/mahlzeiten/index.html.twig b/templates/mahlzeiten/index.html.twig index 25e4abe..c24a854 100644 --- a/templates/mahlzeiten/index.html.twig +++ b/templates/mahlzeiten/index.html.twig @@ -5,42 +5,27 @@ {% block body %}
    -

    Mahlzeiten

    - + Neue Mahlzeit +
    +

    Mahlzeiten

    +
    Tagesansicht nach Wochentag
    +
    +
    - {% if mahlzeiten|length == 0 %} + {% if total == 0 %}

    Noch keine Mahlzeiten angelegt.

    {% else %} -
      - {% for m in mahlzeiten %} -
    • -
      - {{ m.name }} - {% if m.notiz != '' %} -
      {{ m.notiz }}
      - {% endif %} - {% if m.wochentage|length > 0 %} -
      - {% for tag in m.wochentage %} - {{ tag.name }} - {% endfor %} -
      - {% endif %} - - {{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }} - -
      -
      - Bearbeiten -
      - -
      -
      -
    • - {% endfor %} -
    + {% for tag, mahlzeiten in tage %} +

    {{ tag }}

    +
      + {% for m in mahlzeiten %} + {{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }} + {% endfor %} +
    + {% endfor %} {% endif %}
    {% endblock %} diff --git a/tests/Functional/UI/Essen/MahlzeitControllerTest.php b/tests/Functional/UI/Essen/MahlzeitControllerTest.php index cd5a3d1..fe9bb4e 100644 --- a/tests/Functional/UI/Essen/MahlzeitControllerTest.php +++ b/tests/Functional/UI/Essen/MahlzeitControllerTest.php @@ -10,6 +10,7 @@ use Doctrine\ORM\Tools\SchemaTool; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\BrowserKit\Cookie; +use Symfony\Component\DomCrawler\Crawler; /** * M1 Schritt 2: CRUD-Slice "Mahlzeit" über die Web-UI (eingeloggte Session). @@ -146,6 +147,137 @@ final class MahlzeitControllerTest extends WebTestCase self::assertSelectorExists('form[action$="/loeschen"]'); } + // ---- M2 Schritt 2: Status (Check-Off/Toggle), Tagesansicht, Archiv ---- + + public function testIndexGroupsMealsByWeekday(): void + { + $client = $this->vorbereiten(); + $this->anlegen($client, 'Curry', '', ['di']); + $this->anlegen($client, 'Pizza', '', ['sa']); + $this->anlegen($client, 'Bolognese', '', ['mo', 'mi']); + + $client->request('GET', '/mahlzeiten'); + $crawler = $client->getCrawler(); + + self::assertResponseIsSuccessful(); + + // Curry exakt unter "Di", nicht unter "Sa". + $diUl = $this->listeNachHeading($crawler, 'Di'); + self::assertStringContainsString('Curry', $diUl->html()); + self::assertStringNotContainsString('Pizza', $diUl->html()); + + // Pizza exakt unter "Sa". + $saUl = $this->listeNachHeading($crawler, 'Sa'); + self::assertStringContainsString('Pizza', $saUl->html()); + self::assertStringNotContainsString('Curry', $saUl->html()); + + // Bolognese hat zwei Wochentage → erscheint unter "Mo" UND "Mi". + self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mo')->html()); + self::assertStringContainsString('Bolognese', $this->listeNachHeading($crawler, 'Mi')->html()); + } + + public function testIndexGroupsMealWithoutWeekdayUnderOhneTag(): void + { + $client = $this->vorbereiten(); + $this->anlegen($client, 'Reis', '', []); + + $client->request('GET', '/mahlzeiten'); + + self::assertResponseIsSuccessful(); + self::assertStringContainsString('Reis', $this->listeNachHeading($client->getCrawler(), 'Ohne Tag')->html()); + } + + public function testCheckOffReturnsGegessenPartial(): void + { + $client = $this->vorbereiten(); + $id = $this->anlegen($client, 'Pasta', '', ['mo']); + + $client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id)); + + self::assertResponseIsSuccessful(); + // htmx-Fragment: genau das Mahlzeiten-Partial, keine komplette Seite. + self::assertSelectorExists('li#mahlzeit-' . $id); + self::assertSelectorTextContains('span.status', 'gegessen'); + $content = (string) $client->getResponse()->getContent(); + self::assertStringNotContainsString('em()->clear(); + $entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id); + self::assertSame('gegessen', $entity->getStatus()); + self::assertNotNull($entity->getGegessenAm()); + } + + public function testUncheckRestoresGeplantStatus(): void + { + $client = $this->vorbereiten(); + $id = $this->anlegen($client, 'Suppe', '', []); + $client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $id)); + self::assertResponseIsSuccessful(); + + $client->request('POST', sprintf('/mahlzeiten/%d/uncheck', $id)); + + self::assertResponseIsSuccessful(); + self::assertSelectorExists('li#mahlzeit-' . $id); + self::assertSelectorTextContains('span.status', 'geplant'); + + // Persistenz: Status zurück auf "geplant" + gegessenAm zurückgesetzt. + $this->em()->clear(); + $entity = $this->em()->getRepository(MahlzeitEntity::class)->find($id); + self::assertSame('geplant', $entity->getStatus()); + self::assertNull($entity->getGegessenAm()); + } + + public function testArchivShowsOnlyGegessenMeals(): void + { + $client = $this->vorbereiten(); + $this->anlegen($client, 'GeplanteMahlzeit', '', ['mo']); + $gegessenId = $this->anlegen($client, 'GegesseneMahlzeit', '', ['di']); + + $client->request('POST', sprintf('/mahlzeiten/%d/checkoff', $gegessenId)); + self::assertResponseIsSuccessful(); + + $client->request('GET', '/mahlzeiten/archiv'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('h1', 'Archiv'); + + // Nur die Ergebnisliste prüfen (Flash-Meldungen der Anleg-Requests ausblenden). + $liste = $client->getCrawler()->filter('ul')->html(); + self::assertStringContainsString('GegesseneMahlzeit', $liste); + self::assertStringNotContainsString('GeplanteMahlzeit', $liste); + } + + public function testCheckOffUnknownIdReturns404(): void + { + $client = $this->vorbereiten(); + $client->request('POST', '/mahlzeiten/99999/checkoff'); + + self::assertResponseStatusCodeSame(404); + } + + public function testUncheckUnknownIdReturns404(): void + { + $client = $this->vorbereiten(); + $client->request('POST', '/mahlzeiten/99999/uncheck'); + + self::assertResponseStatusCodeSame(404); + } + + /** + * Liefert die
      -Liste, die direkt unter der angegebenen

      -Überschrift + * (z. B. "Di", "Ohne Tag") in der Tagesansicht liegt. + */ + private function listeNachHeading(Crawler $crawler, string $heading): Crawler + { + $h2 = $crawler->filterXPath(sprintf('//h2[normalize-space()="%s"]', $heading)); + self::assertCount(1, $h2, sprintf('Genau ein

      "%s" erwartet.', $heading)); + + // Das
        ist der erste nächste Geschwister-Knoten der Überschrift. + return $h2->nextAll()->first(); + } + /** * Client + sauberes Schema (+ optional eingeloggte Session) bereitstellen. */