From bce8f58be3ebd5a42b31ee0d3eadf5a84b2bd02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Sun, 13 Sep 2026 18:08:39 +0200 Subject: [PATCH] feat: replace the navigation with a dashboard and per-area boards --- assets/controllers/mobilenav_controller.js | 22 --- assets/icons/lucide/armchair.svg | 1 + assets/icons/lucide/car.svg | 1 + src/Booking/BoardPresenter.php | 30 +-- src/Booking/DashboardPresenter.php | 71 +++++++ src/Command/SeedCommand.php | 2 +- src/Controller/BoardController.php | 94 ++++++++++ src/Controller/BookingController.php | 20 +- src/Controller/DashboardController.php | 62 ++----- src/Entity/ResourceType.php | 56 ++++++ src/Repository/BookingRepository.php | 37 +++- templates/_partials/_nav.html.twig | 39 ---- .../{dashboard => board}/_calendar.html.twig | 6 +- templates/board/_floorplan.html.twig | 32 ++++ .../{dashboard => board}/_timerange.html.twig | 6 +- templates/board/index.html.twig | 34 ++++ templates/board/plan/_desk.html.twig | 28 +++ templates/board/plan/_parking.html.twig | 25 +++ templates/board/plan/_spot.html.twig | 62 +++++++ templates/booking/index.html.twig | 8 +- templates/dashboard/_floorplan.html.twig | 119 ------------ templates/dashboard/index.html.twig | 89 +++++++-- templates/layout.html.twig | 50 ++--- tests/Controller/BookingControllerTest.php | 14 +- tests/Controller/DashboardControllerTest.php | 174 ++++++++++++++++++ tests/Entity/ResourceTypeTest.php | 56 ++++++ 26 files changed, 810 insertions(+), 328 deletions(-) delete mode 100644 assets/controllers/mobilenav_controller.js create mode 100644 assets/icons/lucide/armchair.svg create mode 100644 assets/icons/lucide/car.svg create mode 100644 src/Booking/DashboardPresenter.php create mode 100644 src/Controller/BoardController.php delete mode 100644 templates/_partials/_nav.html.twig rename templates/{dashboard => board}/_calendar.html.twig (89%) create mode 100644 templates/board/_floorplan.html.twig rename templates/{dashboard => board}/_timerange.html.twig (88%) create mode 100644 templates/board/index.html.twig create mode 100644 templates/board/plan/_desk.html.twig create mode 100644 templates/board/plan/_parking.html.twig create mode 100644 templates/board/plan/_spot.html.twig delete mode 100644 templates/dashboard/_floorplan.html.twig create mode 100644 tests/Controller/DashboardControllerTest.php create mode 100644 tests/Entity/ResourceTypeTest.php diff --git a/assets/controllers/mobilenav_controller.js b/assets/controllers/mobilenav_controller.js deleted file mode 100644 index 6e64254..0000000 --- a/assets/controllers/mobilenav_controller.js +++ /dev/null @@ -1,22 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; - -/* - * Off-canvas mobile navigation drawer. Ported from myep. - * - * Two instances cooperate through a Stimulus outlet: the opener button in the header and the - * drawer itself. Purely client-side -- htmx is not involved. - */ -export default class extends Controller { - static outlets = ['mobilenav']; - static classes = ['closed']; - - trigger({ params: { mode } }) { - this.mobilenavOutlet.toggle(mode); - } - - toggle(mode) { - // Added only now, so the drawer does not animate into place on first paint. - this.element.classList.add('transition-transform', 'duration-300'); - this.element.classList.toggle(this.closedClass, 'close' === mode); - } -} diff --git a/assets/icons/lucide/armchair.svg b/assets/icons/lucide/armchair.svg new file mode 100644 index 0000000..f406fc0 --- /dev/null +++ b/assets/icons/lucide/armchair.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/lucide/car.svg b/assets/icons/lucide/car.svg new file mode 100644 index 0000000..537f4d5 --- /dev/null +++ b/assets/icons/lucide/car.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Booking/BoardPresenter.php b/src/Booking/BoardPresenter.php index 626b044..d83142e 100644 --- a/src/Booking/BoardPresenter.php +++ b/src/Booking/BoardPresenter.php @@ -10,16 +10,16 @@ use App\Repository\BookingRepository; use App\Repository\ResourceRepository; /** - * Builds the context behind the two fragments that make up the booking board: the month - * calendar and the floor plan. + * Builds the context behind the two fragments that make up a booking board: the month calendar + * and the floor plan. * * Both are always rendered from the same context so that one htmx round trip can swap both -- * picking a day (or a time span) updates the grid and the plan in a single out-of-band response. * - * Everything here is relative to a TimeRange. With part-day bookings "is this desk free?" has no - * answer on its own, only "is it free between 09:00 and 13:00?", so the span the user picked is - * carried through the occupancy counts and the plan colours alike -- the number in a calendar - * cell always means the same thing as the colours on the plan. + * Everything here is relative to a ResourceType **and** a TimeRange. One board shows one kind of + * resource, and with part-day bookings "is this free?" has no answer without a span -- so both + * are carried through the occupancy counts and the plan colours alike, and the number in a + * calendar cell always means the same thing as the colours on the plan beside it. */ final readonly class BoardPresenter { @@ -34,7 +34,7 @@ final readonly class BoardPresenter /** * @return array */ - public function context(\DateTimeImmutable $selected, TimeRange $time, User $user): array + public function context(ResourceType $type, \DateTimeImmutable $selected, TimeRange $time, User $user): array { $selected = $selected->setTime(0, 0); $today = new \DateTimeImmutable('today'); @@ -43,10 +43,10 @@ final readonly class BoardPresenter $gridStart = $monthStart->modify('monday this week'); $gridEnd = $gridStart->modify(\sprintf('+%d days', self::WEEKS * 7 - 1)); - $counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd, $time); + $counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd, $time, $type); $mine = []; - foreach ($this->bookings->findForUserInRange($user, $gridStart, $gridEnd) as $booking) { + foreach ($this->bookings->findForUserInRange($user, $gridStart, $gridEnd, $type) as $booking) { $mine[$booking->getDate()->format('Y-m-d')][] = $booking; } @@ -72,33 +72,33 @@ final readonly class BoardPresenter } $byResource = []; - foreach ($this->bookings->findForDate($selected, $time) as $booking) { + foreach ($this->bookings->findForDate($selected, $time, $type) as $booking) { // Several bookings can overlap the span on one resource once times are in play; the // first is enough to colour the shape, and the tooltip lists them all. $byResource[$booking->getResource()->getId()][] = $booking; } return [ + 'type' => $type, 'selected_date' => $selected, 'selected_time' => $time, 'month_start' => $monthStart, 'weeks' => $weeks, 'is_past' => $selected < $today, - 'desks' => $this->resources->findActiveByType(ResourceType::DESK), - 'parking' => $this->resources->findActiveByType(ResourceType::PARKING), + 'resources' => $this->resources->findActiveByType($type), 'bookings_by_resource' => $byResource, 'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [], ]; } /** - * The query parameters that reproduce a board view, so links and HX-Push-Url agree. + * The route parameters that reproduce a board view, so links and HX-Push-Url agree. * * @return array */ - public static function urlParameters(\DateTimeImmutable $date, TimeRange $time): array + public static function urlParameters(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): array { - $parameters = ['tag' => $date->format('Y-m-d')]; + $parameters = ['type' => $type->slug(), 'tag' => $date->format('Y-m-d')]; if (!$time->isAllDay()) { $parameters['von'] = $time->start(); diff --git a/src/Booking/DashboardPresenter.php b/src/Booking/DashboardPresenter.php new file mode 100644 index 0000000..29efe5d --- /dev/null +++ b/src/Booking/DashboardPresenter.php @@ -0,0 +1,71 @@ +> + */ + public function tiles(User $user, \DateTimeImmutable $on): array + { + $on = $on->setTime(0, 0); + $tiles = []; + + foreach (ResourceType::cases() as $type) { + $resources = $this->resources->findActiveByType($type); + + // Availability is judged over the whole day: a resource booked for any part of it is + // not "free today" without qualification, and the dashboard is not the place to ask + // the user for a time span before it can say anything. + $taken = []; + $mine = null; + foreach ($this->bookings->findForDate($on, null, $type) as $booking) { + $taken[$booking->getResource()->getId()] = true; + + if ($booking->belongsTo($user)) { + $mine = $booking; + } + } + + $tiles[] = [ + 'type' => $type, + 'total' => \count($resources), + 'free' => \count($resources) - \count($taken), + 'mine' => $mine, + ]; + } + + return $tiles; + } + + /** + * @return list<\App\Entity\Booking> + */ + public function upcoming(User $user, \DateTimeImmutable $from, int $limit = 5): array + { + $bookings = $this->bookings->findForUserInRange($user, $from, $from->modify('+1 year')); + + return \array_slice($bookings, 0, $limit); + } +} diff --git a/src/Command/SeedCommand.php b/src/Command/SeedCommand.php index ac3be66..f354981 100644 --- a/src/Command/SeedCommand.php +++ b/src/Command/SeedCommand.php @@ -23,7 +23,7 @@ use Symfony\Component\DependencyInjection\Attribute\When; * * Dev only, and idempotent: it matches on the natural key (resource label, user email) so it can * be re-run after a migration without duplicating anything. The resource svgElementId values are - * the contract with templates/dashboard/_floorplan.html.twig -- change one and the other stops + * the contract with templates/board/plan/_.html.twig -- change one and the other stops * drawing that resource. */ #[When('dev')] diff --git a/src/Controller/BoardController.php b/src/Controller/BoardController.php new file mode 100644 index 0000000..fd50efc --- /dev/null +++ b/src/Controller/BoardController.php @@ -0,0 +1,94 @@ + '[a-z]+'], methods: ['GET'])] + public function index(Request $request, string $type): Response + { + $resourceType = $this->resourceType($type); + $date = DateParam::parseOrToday($request->query->getString('tag')); + + return $this->render('board/index.html.twig', $this->board->context( + $resourceType, + $date, + self::timeOf($request), + $this->currentUser(), + )); + } + + /** + * Picking a day -- or narrowing the span -- swaps the calendar and the floor plan in one + * response. + * + * This is the whole reason htmxOobResponse exists: two regions, one round trip, no + * client-side event choreography. Requests without the htmx header get a plain redirect + * rather than a bare pair of fragments, so the URL stays shareable. + */ + #[Route('/buchen/{type}/tag/{date}', name: 'app_board_day', requirements: ['type' => '[a-z]+', 'date' => DateParam::PATTERN], methods: ['GET'])] + public function day(Request $request, string $type, string $date): Response + { + $resourceType = $this->resourceType($type); + $selected = DateParam::parseOrToday($date); + $time = self::timeOf($request); + + if (!$request->headers->has('HX-Request')) { + return $this->redirectToRoute('app_board', BoardPresenter::urlParameters($resourceType, $selected, $time)); + } + + return $this->htmxOobResponse( + 'board/index.html.twig', + ['calendar', 'floorplan'], + $this->board->context($resourceType, $selected, $time, $this->currentUser()), + $this->generateUrl('app_board', BoardPresenter::urlParameters($resourceType, $selected, $time)), + ); + } + + /** + * A malformed or missing span falls back to the whole day rather than erroring: these are + * hand-editable query parameters, not route requirements. + */ + public static function timeOf(Request $request): TimeRange + { + return TimeRange::parse($request->query->getString('von'), $request->query->getString('bis')) + ?? TimeRange::wholeDay(); + } + + /** + * Route requirements cannot call a method, so the slug is matched loosely and resolved here. + */ + private function resourceType(string $slug): ResourceType + { + try { + return ResourceType::fromSlug($slug); + } catch (\ValueError $e) { + throw $this->createNotFoundException($e->getMessage(), $e); + } + } +} diff --git a/src/Controller/BookingController.php b/src/Controller/BookingController.php index a7c7180..a80224a 100644 --- a/src/Controller/BookingController.php +++ b/src/Controller/BookingController.php @@ -14,6 +14,7 @@ use App\Booking\TimeRange; use App\Entity\Booking; use App\Entity\BookingSeries; use App\Entity\Resource; +use App\Entity\ResourceType; use App\Form\BookingRequest; use App\Form\BookingType; use App\Htmx\HxTrait; @@ -67,7 +68,7 @@ final class BookingController extends AbstractController } $selected = DateParam::parseOrToday($date); - $boardTime = DashboardController::timeOf($request); + $boardTime = BoardController::timeOf($request); // Prefilled from whatever span the board is showing, so narrowing the board and then // clicking a desk books exactly what was on screen. @@ -96,7 +97,7 @@ final class BookingController extends AbstractController return $this->invalidModal($form, $resource, $e->getMessage()); } - return $this->boardResponse($data->date, $boardTime); + return $this->boardResponse($resource->getType(), $data->date, $boardTime); } return $this->render('booking/_modal_create.html.twig', [ @@ -114,7 +115,7 @@ final class BookingController extends AbstractController { $this->denyUnlessOwn($booking); - $boardTime = DashboardController::timeOf($request); + $boardTime = BoardController::timeOf($request); $data = new BookingRequest($booking->getDate(), $booking->getTimeRange()); $form = $this->createForm(BookingType::class, $data, [ @@ -134,7 +135,7 @@ final class BookingController extends AbstractController $this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange())); - return $this->boardResponse($booking->getDate(), $boardTime); + return $this->boardResponse($booking->getResource()->getType(), $booking->getDate(), $boardTime); } return $this->render('booking/_modal_edit.html.twig', [ @@ -151,6 +152,7 @@ final class BookingController extends AbstractController $this->denyUnlessCsrfValid($request, 'release-'.$booking->getId()); $date = $booking->getDate(); + $type = $booking->getResource()->getType(); $label = $booking->getResource()->getLabel(); $this->bookings->release($booking); @@ -161,7 +163,7 @@ final class BookingController extends AbstractController return $this->redirectToRoute('app_booking'); } - return $this->boardResponse($date, DashboardController::timeOf($request)); + return $this->boardResponse($type, $date, BoardController::timeOf($request)); } /** @@ -235,13 +237,13 @@ final class BookingController extends AbstractController * The whole response is out-of-band board blocks. Callers pair it with hx-swap="none" * (or an outerHTML target that should vanish, as the modal does). */ - private function boardResponse(\DateTimeImmutable $date, TimeRange $time): Response + private function boardResponse(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): Response { return $this->htmxOobResponse( - 'dashboard/index.html.twig', + 'board/index.html.twig', ['calendar', 'floorplan', 'flashes'], - $this->board->context($date, $time, $this->currentUser()), - $this->generateUrl('app_dashboard', BoardPresenter::urlParameters($date, $time)), + $this->board->context($type, $date, $time, $this->currentUser()), + $this->generateUrl('app_board', BoardPresenter::urlParameters($type, $date, $time)), ); } diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index 249d47c..966889a 100644 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -4,68 +4,36 @@ declare(strict_types=1); namespace App\Controller; -use App\Booking\BoardPresenter; -use App\Booking\DateParam; -use App\Booking\TimeRange; -use App\Htmx\HxTrait; +use App\Booking\DashboardPresenter; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; /** - * The booking board: a month calendar next to the floor plan for the selected day and span. + * The landing screen: one tile per bookable kind, plus what this person has coming up. + * + * It replaces the sidebar navigation, so it is also the only route to the booking boards and the + * bookings list -- which is why the tiles are generated from ResourceType rather than written + * out: a kind with no tile would be unreachable. */ final class DashboardController extends AbstractController { use CurrentUserTrait; - use HxTrait; - public function __construct(private readonly BoardPresenter $board) + public function __construct(private readonly DashboardPresenter $dashboard) { } #[Route('/', name: 'app_dashboard', methods: ['GET'])] - public function index(Request $request): Response + public function index(): Response { - $date = DateParam::parseOrToday($request->query->getString('tag')); + $today = new \DateTimeImmutable('today'); + $user = $this->currentUser(); - return $this->render('dashboard/index.html.twig', $this->board->context($date, self::timeOf($request), $this->currentUser())); - } - - /** - * Picking a day -- or narrowing the span -- swaps the calendar and the floor plan in one - * response. - * - * This is the whole reason htmxOobResponse exists: two regions, one round trip, no - * client-side event choreography. Requests without the htmx header get a plain redirect - * rather than a bare pair of fragments, so the URL stays shareable. - */ - #[Route('/tag/{date}', name: 'app_dashboard_day', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])] - public function day(Request $request, string $date): Response - { - $selected = DateParam::parseOrToday($date); - $time = self::timeOf($request); - - if (!$request->headers->has('HX-Request')) { - return $this->redirectToRoute('app_dashboard', BoardPresenter::urlParameters($selected, $time)); - } - - return $this->htmxOobResponse( - 'dashboard/index.html.twig', - ['calendar', 'floorplan'], - $this->board->context($selected, $time, $this->currentUser()), - $this->generateUrl('app_dashboard', BoardPresenter::urlParameters($selected, $time)), - ); - } - - /** - * A malformed or missing span falls back to the whole day rather than erroring: these are - * hand-editable query parameters, not route requirements. - */ - public static function timeOf(Request $request): TimeRange - { - return TimeRange::parse($request->query->getString('von'), $request->query->getString('bis')) - ?? TimeRange::wholeDay(); + return $this->render('dashboard/index.html.twig', [ + 'tiles' => $this->dashboard->tiles($user, $today), + 'upcoming' => $this->dashboard->upcoming($user, $today), + 'today' => $today, + ]); } } diff --git a/src/Entity/ResourceType.php b/src/Entity/ResourceType.php index 310856e..b95747e 100644 --- a/src/Entity/ResourceType.php +++ b/src/Entity/ResourceType.php @@ -4,6 +4,18 @@ declare(strict_types=1); namespace App\Entity; +/** + * The kinds of thing that can be booked. + * + * **This enum is the extension point.** The dashboard builds one tile per case, the board route + * accepts one slug per case, and the floor plan is looked up by convention from the case's + * value. Adding a third bookable kind is therefore: a case here, a + * `templates/board/plan/_.html.twig` with its geometry, and rows in `app:seed`. Nothing + * else needs editing — in particular no template lists the kinds by hand. + * + * The backing values are persisted in `resource.type`, which is why they stay English while the + * URLs do not: `slug()` is what appears in a path, so the routes read German like the rest. + */ enum ResourceType: string { case DESK = 'desk'; @@ -16,4 +28,48 @@ enum ResourceType: string self::PARKING => 'Parkplatz', }; } + + public function plural(): string + { + return match ($this) { + self::DESK => 'Schreibtische', + self::PARKING => 'Parkplätze', + }; + } + + public function description(): string + { + return match ($this) { + self::DESK => 'Einen Platz im Büro reservieren.', + self::PARKING => 'Einen Stellplatz reservieren.', + }; + } + + public function icon(): string + { + return match ($this) { + self::DESK => 'lucide:armchair', + self::PARKING => 'lucide:car', + }; + } + + /** What appears in a URL. */ + public function slug(): string + { + return match ($this) { + self::DESK => 'schreibtische', + self::PARKING => 'parkplaetze', + }; + } + + public static function fromSlug(string $slug): self + { + foreach (self::cases() as $case) { + if ($case->slug() === $slug) { + return $case; + } + } + + throw new \ValueError(\sprintf('"%s" ist kein bekannter Buchungsbereich.', $slug)); + } } diff --git a/src/Repository/BookingRepository.php b/src/Repository/BookingRepository.php index 19066a8..d5adf0f 100644 --- a/src/Repository/BookingRepository.php +++ b/src/Repository/BookingRepository.php @@ -32,7 +32,7 @@ class BookingRepository extends ServiceEntityRepository * * @return list */ - public function findForDate(\DateTimeImmutable $date, ?TimeRange $within = null): array + public function findForDate(\DateTimeImmutable $date, ?TimeRange $within = null, ?ResourceType $type = null): array { $qb = $this->createQueryBuilder('b') ->addSelect('u', 'r') @@ -47,6 +47,10 @@ class BookingRepository extends ServiceEntityRepository self::restrictToOverlap($qb, $within); } + if (null !== $type) { + $qb->andWhere('r.type = :type')->setParameter('type', $type); + } + return $qb->getQuery()->getResult(); } @@ -106,9 +110,9 @@ class BookingRepository extends ServiceEntityRepository * * @return list */ - public function findForUserInRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): array + public function findForUserInRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to, ?ResourceType $type = null): array { - return $this->createQueryBuilder('b') + $qb = $this->createQueryBuilder('b') ->addSelect('r', 's') ->join('b.resource', 'r') ->leftJoin('b.series', 's') @@ -119,20 +123,29 @@ class BookingRepository extends ServiceEntityRepository ->setParameter('to', $to->setTime(0, 0)) ->orderBy('b.date', 'ASC') ->addOrderBy('b.startMinute', 'ASC') - ->addOrderBy('r.label', 'ASC') - ->getQuery() - ->getResult(); + ->addOrderBy('r.label', 'ASC'); + + if (null !== $type) { + $qb->andWhere('r.type = :type')->setParameter('type', $type); + } + + return $qb->getQuery()->getResult(); } /** * Bookings per day across a window, keyed by Y-m-d -- what the calendar grid renders as - * occupancy. Counts only what overlaps the span the board is currently showing, so the - * number in a day cell always means the same thing as the colours on the plan. + * occupancy. Counts only what overlaps the span the board is currently showing, and only the + * kind of resource that board is about, so the number in a day cell always means the same + * thing as the colours on the plan next to it. * * @return array */ - public function countPerDateInRange(\DateTimeImmutable $from, \DateTimeImmutable $to, ?TimeRange $within = null): array - { + public function countPerDateInRange( + \DateTimeImmutable $from, + \DateTimeImmutable $to, + ?TimeRange $within = null, + ?ResourceType $type = null, + ): array { $qb = $this->createQueryBuilder('b') ->select('b.date AS date', 'COUNT(b.id) AS total') ->andWhere('b.date BETWEEN :from AND :to') @@ -144,6 +157,10 @@ class BookingRepository extends ServiceEntityRepository self::restrictToOverlap($qb, $within); } + if (null !== $type) { + $qb->join('b.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type); + } + $counts = []; foreach ($qb->getQuery()->getResult() as $row) { $counts[$row['date']->format('Y-m-d')] = (int) $row['total']; diff --git a/templates/_partials/_nav.html.twig b/templates/_partials/_nav.html.twig deleted file mode 100644 index f47c192..0000000 --- a/templates/_partials/_nav.html.twig +++ /dev/null @@ -1,39 +0,0 @@ -{# Plain nav instead of myep's knp-menu builder: a handful of links does not justify the - indirection. Active state is a prefix match on the current route name, which mirrors what - myep's menu matcher does with its 'routes' extras. #} -{% set current_route = app.request.attributes.get('_route') %} -{% set items = [ - { route: 'app_dashboard', label: 'Übersicht', icon: 'lucide:layout-grid' }, - { route: 'app_booking', label: 'Meine Buchungen', icon: 'lucide:calendar-days' }, -] %} - -
- -
diff --git a/templates/dashboard/_calendar.html.twig b/templates/board/_calendar.html.twig similarity index 89% rename from templates/dashboard/_calendar.html.twig rename to templates/board/_calendar.html.twig index 09067ce..ae5776b 100644 --- a/templates/dashboard/_calendar.html.twig +++ b/templates/board/_calendar.html.twig @@ -9,7 +9,7 @@ @@ -19,7 +19,7 @@ @@ -49,7 +49,7 @@ diff --git a/templates/board/index.html.twig b/templates/board/index.html.twig new file mode 100644 index 0000000..9551e2e --- /dev/null +++ b/templates/board/index.html.twig @@ -0,0 +1,34 @@ +{% extends 'layout.html.twig' %} + +{% block title %}{{ type.plural }} buchen · ephub{% endblock %} + +{% block content %} + {# The dashboard is the only navigation there is, so every board says how to get back. #} + ← Übersicht + +

{{ type.plural }} buchen

+

Tag und Zeitraum wählen, dann einen freien Platz anklicken.

+ +
+
+ {# + The two blocks below are what BoardController::day and BookingController swap out + of band. Each one owns a stable id and emits hx-swap-oob only when rendered + through htmxOobResponse -- rendered normally they are plain markup. + #} + {% block calendar %} +
+ {% include 'board/_calendar.html.twig' %} +
+ {% endblock %} +
+ +
+ {% block floorplan %} +
+ {% include 'board/_floorplan.html.twig' %} +
+ {% endblock %} +
+
+{% endblock %} diff --git a/templates/board/plan/_desk.html.twig b/templates/board/plan/_desk.html.twig new file mode 100644 index 0000000..72e4cf0 --- /dev/null +++ b/templates/board/plan/_desk.html.twig @@ -0,0 +1,28 @@ +{# + The office. Hand-authored geometry: `svgElementId` on each Resource is the key that ties a + database row to a shape here, so a resource whose id is missing from the map is simply not + drawn (and vice versa) -- app:seed and this map have to agree. + + Found by convention from ResourceType::DESK->value, see board/_floorplan.html.twig. +#} +{% import 'board/plan/_spot.html.twig' as plan %} + +{% set geometry = { + 'desk-01': { x: 40, y: 60 }, 'desk-02': { x: 200, y: 60 }, 'desk-03': { x: 360, y: 60 }, + 'desk-04': { x: 40, y: 160 }, 'desk-05': { x: 200, y: 160 }, 'desk-06': { x: 360, y: 160 }, + 'desk-07': { x: 40, y: 260 }, 'desk-08': { x: 200, y: 260 }, 'desk-09': { x: 360, y: 260 }, + 'desk-10': { x: 40, y: 360 }, 'desk-11': { x: 200, y: 360 }, 'desk-12': { x: 360, y: 360 } +} %} +{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %} + + + + Büro + + {% for resource in resources %} + {% if resource.svgElementId and geometry[resource.svgElementId] is defined %} + {{ plan.spot(resource, geometry[resource.svgElementId], 140, 70, + bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }} + {% endif %} + {% endfor %} + diff --git a/templates/board/plan/_parking.html.twig b/templates/board/plan/_parking.html.twig new file mode 100644 index 0000000..3f89eb5 --- /dev/null +++ b/templates/board/plan/_parking.html.twig @@ -0,0 +1,25 @@ +{# + The car park. See board/plan/_desk.html.twig for how geometry and Resource.svgElementId + relate; found by convention from ResourceType::PARKING->value. +#} +{% import 'board/plan/_spot.html.twig' as plan %} + +{% set geometry = { + 'parking-01': { x: 40, y: 60 }, 'parking-02': { x: 40, y: 130 }, 'parking-03': { x: 40, y: 200 }, + 'parking-04': { x: 280, y: 60 }, 'parking-05': { x: 280, y: 130 }, 'parking-06': { x: 280, y: 200 } +} %} +{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %} + + + + Parkplatz + {# The lane between the two rows. #} + + + {% for resource in resources %} + {% if resource.svgElementId and geometry[resource.svgElementId] is defined %} + {{ plan.spot(resource, geometry[resource.svgElementId], 200, 55, + bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }} + {% endif %} + {% endfor %} + diff --git a/templates/board/plan/_spot.html.twig b/templates/board/plan/_spot.html.twig new file mode 100644 index 0000000..1a7abcb --- /dev/null +++ b/templates/board/plan/_spot.html.twig @@ -0,0 +1,62 @@ +{# + The shared shape a plan is drawn from. Imported by each board/plan/_.html.twig, so the + per-type partials carry geometry only and free/belegt/deine looks identical everywhere. + + Clicking always opens a modal: the create modal when free, the edit modal when it is yours + (which is where changing the times and releasing live). Nothing else is clickable, so the SVG + carries no confirm dialogs or inline CSRF tokens. +#} +{% macro spot(resource, geometry, width, height, bookings, selected_date, is_past, time_params) %} + {% set own = bookings|filter(b => b.user.id == app.user.id)|first|default(null) %} + {% set mine = own is not null %} + + {% if mine %} + {% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %} + {% elseif bookings is not empty %} + {% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %} + {% else %} + {% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %} + {% endif %} + + {% set interactive = not is_past and (mine or bookings is empty) %} + + {# Several bookings can overlap the board's span once part-day bookings exist, so the shape + counts them and the tooltip spells them out. #} + {% if mine %} + {% set caption = own.timeRange.allDay ? 'du' : 'du ' ~ own.timeRange %} + {% elseif bookings|length > 1 %} + {% set caption = bookings|length ~ ' Buchungen' %} + {% elseif bookings is not empty %} + {% set caption = bookings|first.user.name %} + {% else %} + {% set caption = null %} + {% endif %} + + ; closing it is Stimulus, not htmx. #} + hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}" + hx-target="body" + hx-swap="beforeend" + {%- endif %}> + + {{- resource.label }} + {%- if bookings is empty %} — frei + {%- else %} + {%- for booking in bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %} + {%- endif -%} + + + {{ resource.label }} + {% if caption %} + {{ caption }} + {% endif %} + +{% endmacro %} diff --git a/templates/booking/index.html.twig b/templates/booking/index.html.twig index 39cca2e..2b142f5 100644 --- a/templates/booking/index.html.twig +++ b/templates/booking/index.html.twig @@ -3,7 +3,9 @@ {% block title %}Meine Buchungen · ephub{% endblock %} {% block content %} -

Meine Buchungen

+ ← Übersicht + +

Meine Buchungen

Alles, was ab heute für dich reserviert ist.

{% if series is not empty %} @@ -32,7 +34,7 @@ {% if bookings is empty %}

- Noch nichts gebucht. Zum Raumplan. + Noch nichts gebucht. Zur Übersicht.

{% else %}
    @@ -55,7 +57,7 @@
    Im Plan + href="{{ path('app_board', { type: booking.resource.type.slug, tag: booking.date|date('Y-m-d') }) }}">Im Plan {# Ordinary form post: this page has no board fragments to swap, so it reloads afterwards rather than pretending to be htmx. #}
    diff --git a/templates/dashboard/_floorplan.html.twig b/templates/dashboard/_floorplan.html.twig deleted file mode 100644 index 1eb6ad5..0000000 --- a/templates/dashboard/_floorplan.html.twig +++ /dev/null @@ -1,119 +0,0 @@ -{# - Hand-authored floor plan. The geometry below is the drawing; `svgElementId` on each Resource - is the key that ties a database row to a shape here. A resource whose id is missing from the - map is simply not drawn (and vice versa), which is why app:seed and this map have to agree. - - Colouring needs no JavaScript at all: the SVG is re-rendered server side for the selected day - and span, so free/belegt/deine is decided in Twig. Note "free" is always relative to the - span in the header -- a desk booked 09:00-13:00 is free for an afternoon board and belegt for - an all-day one. - - Clicking a shape always opens a modal: the create modal when it is free, the edit modal when - it is yours (which is where changing the times and releasing it live). Nothing else is - clickable, so the SVG carries no confirm dialogs or inline tokens. -#} -{% set desk_plan = { - 'desk-01': { x: 50, y: 70 }, 'desk-02': { x: 190, y: 70 }, 'desk-03': { x: 330, y: 70 }, - 'desk-04': { x: 50, y: 170 }, 'desk-05': { x: 190, y: 170 }, 'desk-06': { x: 330, y: 170 }, - 'desk-07': { x: 50, y: 270 }, 'desk-08': { x: 190, y: 270 }, 'desk-09': { x: 330, y: 270 }, - 'desk-10': { x: 50, y: 370 }, 'desk-11': { x: 190, y: 370 }, 'desk-12': { x: 330, y: 370 } -} %} -{% set parking_plan = { - 'parking-01': { x: 540, y: 60 }, 'parking-02': { x: 540, y: 125 }, 'parking-03': { x: 540, y: 190 }, - 'parking-04': { x: 540, y: 255 }, 'parking-05': { x: 540, y: 320 }, 'parking-06': { x: 540, y: 385 } -} %} -{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %} - -{% macro spot(resource, geometry, width, height, bookings, selected_date, is_past, time_params) %} - {% set own = bookings|filter(b => b.user.id == app.user.id)|first|default(null) %} - {% set mine = own is not null %} - - {% if mine %} - {% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %} - {% elseif bookings is not empty %} - {% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %} - {% else %} - {% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %} - {% endif %} - - {% set interactive = not is_past and (mine or bookings is empty) %} - - {# What the second line of the shape says. Several bookings can overlap the board's span once - part-day bookings exist, so the shape counts them and the tooltip spells them out. #} - {% if mine %} - {% set caption = own.timeRange.allDay ? 'du' : 'du ' ~ own.timeRange %} - {% elseif bookings|length > 1 %} - {% set caption = bookings|length ~ ' Buchungen' %} - {% elseif bookings is not empty %} - {% set caption = bookings|first.user.name %} - {% else %} - {% set caption = null %} - {% endif %} - - ; closing it is Stimulus, not htmx. #} - hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}" - hx-target="body" - hx-swap="beforeend" - {%- endif %}> - - {{- resource.label }} - {%- if bookings is empty %} — frei - {%- else %} - {%- for booking in bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %} - {%- endif -%} - - - {{ resource.label }} - {% if caption %} - {{ caption }} - {% endif %} - -{% endmacro %} - -
    -
    -

    {{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}

    -
    - frei - belegt - deine -
    -
    - - {% include 'dashboard/_timerange.html.twig' %} - - {% if is_past %} -

    Vergangener Tag — hier lässt sich nichts mehr buchen.

    - {% endif %} - - - - Büro - - - Parkplatz - - {% for resource in desks %} - {% if resource.svgElementId and desk_plan[resource.svgElementId] is defined %} - {{ _self.spot(resource, desk_plan[resource.svgElementId], 120, 70, - bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }} - {% endif %} - {% endfor %} - - {% for resource in parking %} - {% if resource.svgElementId and parking_plan[resource.svgElementId] is defined %} - {{ _self.spot(resource, parking_plan[resource.svgElementId], 230, 50, - bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }} - {% endif %} - {% endfor %} - -
    diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index e636ab6..ad2b995 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -3,29 +3,78 @@ {% block title %}Übersicht · ephub{% endblock %} {% block content %} -

    Buchen

    -

    Tag und Zeitraum wählen, dann einen freien Platz im Raumplan anklicken.

    +

    Übersicht

    +

    {{ today|format_datetime(pattern: 'EEEE, d. MMMM y') }}

    -
    -
    - {# - The two blocks below are what DashboardController::day and BookingController - swap out of band. Each one owns a stable id and emits hx-swap-oob only when - rendered through htmxOobResponse -- rendered normally they are plain markup. - #} - {% block calendar %} -
    - {% include 'dashboard/_calendar.html.twig' %} + {# + One tile per ResourceType, generated rather than written out. A new bookable kind appears + here -- with a working link and a correct count -- by adding a case to the enum. + #} + - + +

    Als Nächstes

    + + {% if upcoming is empty %} +

    Nichts gebucht.

    + {% else %} +
      + {% for booking in upcoming %} +
    • +
      +
      + {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM') }} + · {{ booking.timeRange }} +
      +
      + {{ booking.resource.type.label }} · {{ booking.resource.label }} + {% if booking.partOfSeries %} + Serie + {% endif %} +
      +
      + + Im Plan + +
    • + {% endfor %} +
    + {% endif %} {% endblock %} diff --git a/templates/layout.html.twig b/templates/layout.html.twig index 631414f..b5e3bbc 100644 --- a/templates/layout.html.twig +++ b/templates/layout.html.twig @@ -1,42 +1,32 @@ {% extends 'base.html.twig' %} +{# + The shell. Deliberately has no navigation: the dashboard at / is how you get anywhere, so the + only things up here are the way back to it and the way out. A booking board reached from a + tile links back with its own breadcrumb. +#} {% block body %} -
    -
    - +
    +
    + ephub - -
    - - -
    - {% block content %}{% endblock %} + {% if app.user %} + + {% endif %}
    -
    -
    -
    - - -
    -
    - {% include '_partials/_nav.html.twig' %} -
    -
    +
    + {% block content %}{% endblock %}
    {# Swapped out of band after a booking, hence the stable id and the conditional attribute. #} diff --git a/tests/Controller/BookingControllerTest.php b/tests/Controller/BookingControllerTest.php index 4317cba..69ae732 100644 --- a/tests/Controller/BookingControllerTest.php +++ b/tests/Controller/BookingControllerTest.php @@ -44,11 +44,11 @@ class BookingControllerTest extends WebTestCase { $this->client->loginUser($this->user('anna@ep-reisen.de')); - $this->client->request('GET', '/tag/2026-10-01', server: ['HTTP_HX-Request' => 'true']); + $this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01', server: ['HTTP_HX-Request' => 'true']); $response = $this->client->getResponse(); self::assertResponseIsSuccessful(); - self::assertSame('/?tag=2026-10-01', $response->headers->get('HX-Push-Url')); + self::assertSame('/buchen/schreibtische?tag=2026-10-01', $response->headers->get('HX-Push-Url')); self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"')); self::assertStringContainsString('id="board-calendar"', (string) $response->getContent()); self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent()); @@ -58,9 +58,9 @@ class BookingControllerTest extends WebTestCase { $this->client->loginUser($this->user('anna@ep-reisen.de')); - $this->client->request('GET', '/tag/2026-10-01'); + $this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01'); - self::assertResponseRedirects('/?tag=2026-10-01'); + self::assertResponseRedirects('/buchen/schreibtische?tag=2026-10-01'); } /** @@ -205,7 +205,7 @@ class BookingControllerTest extends WebTestCase $this->em->flush(); $this->client->loginUser($anna); - $crawler = $this->client->request('GET', '/?tag=2026-10-01'); + $crawler = $this->client->request('GET', '/buchen/schreibtische?tag=2026-10-01'); self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/buchungen/%d/bearbeiten"]', $booking->getId()))); } @@ -351,10 +351,10 @@ class BookingControllerTest extends WebTestCase { $this->client->loginUser($this->user('anna@ep-reisen.de')); - $this->client->request('GET', '/tag/2026-10-01?von=09:00&bis=13:00', server: ['HTTP_HX-Request' => 'true']); + $this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01?von=09:00&bis=13:00', server: ['HTTP_HX-Request' => 'true']); self::assertResponseIsSuccessful(); - self::assertSame('/?tag=2026-10-01&von=09:00&bis=13:00', $this->client->getResponse()->headers->get('HX-Push-Url')); + self::assertSame('/buchen/schreibtische?tag=2026-10-01&von=09:00&bis=13:00', $this->client->getResponse()->headers->get('HX-Push-Url')); } public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void diff --git a/tests/Controller/DashboardControllerTest.php b/tests/Controller/DashboardControllerTest.php new file mode 100644 index 0000000..4443814 --- /dev/null +++ b/tests/Controller/DashboardControllerTest.php @@ -0,0 +1,174 @@ +client = self::createClient(); + $this->client->disableReboot(); + $this->em = self::getContainer()->get(EntityManagerInterface::class); + $this->em->getConnection()->beginTransaction(); + } + + protected function tearDown(): void + { + $this->em->getConnection()->rollBack(); + parent::tearDown(); + } + + /** + * The extensibility guarantee, asserted against the enum rather than against a fixed list: + * a new ResourceType case must produce a working tile without anyone editing a template. + */ + public function testThereIsATileForEveryBookableKind(): void + { + $this->client->loginUser($this->user()); + $crawler = $this->client->request('GET', '/'); + + self::assertResponseIsSuccessful(); + + foreach (ResourceType::cases() as $type) { + self::assertCount( + 1, + $crawler->filter(\sprintf('a[href="/buchen/%s"]', $type->slug())), + \sprintf('no dashboard tile links to the %s board', $type->slug()), + ); + self::assertStringContainsString($type->plural(), $crawler->html()); + } + } + + /** + * The bookings list is only reachable from here now that the sidebar is gone. + */ + public function testTheDashboardLinksToTheBookingsList(): void + { + $this->client->loginUser($this->user()); + $crawler = $this->client->request('GET', '/'); + + self::assertCount(1, $crawler->filter('a[href="/buchungen"]')); + } + + public function testTheNavigationIsGone(): void + { + $this->client->loginUser($this->user()); + $crawler = $this->client->request('GET', '/'); + + self::assertCount(0, $crawler->filter('#mobilenav')); + self::assertCount(1, $crawler->filter('a[href="/logout"]')); + } + + public function testATileCountsWhatIsStillFreeToday(): void + { + $user = $this->user(); + $today = new \DateTimeImmutable('today'); + + $this->resource('D1'); + $taken = $this->resource('D2'); + $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $taken, $today)); + $this->em->flush(); + + $this->client->loginUser($user); + $crawler = $this->client->request('GET', '/'); + + $tile = $crawler->filter('a[href="/buchen/schreibtische"]')->text(); + self::assertStringContainsString('von 2 heute frei', $tile); + self::assertStringContainsString('1', $tile); + } + + /** + * A part-day booking still makes the resource "not free today" -- the dashboard has no span + * to qualify the answer with, so it takes the whole day. + */ + public function testAPartDayBookingCountsAsTakenForTheDay(): void + { + $desk = $this->resource('D1'); + $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('today'), TimeRange::fromMinutes(540, 780))); + $this->em->flush(); + + $this->client->loginUser($this->user()); + $crawler = $this->client->request('GET', '/'); + + self::assertStringContainsString('heute alles belegt', $crawler->filter('a[href="/buchen/schreibtische"]')->text()); + } + + public function testYourOwnBookingIsCalledOutOnTheTile(): void + { + $user = $this->user(); + $this->em->persist(new Booking($user, $this->resource('D1'), new \DateTimeImmutable('today'))); + $this->em->flush(); + + $this->client->loginUser($user); + $crawler = $this->client->request('GET', '/'); + + self::assertStringContainsString('D1', $crawler->filter('a[href="/buchen/schreibtische"]')->text()); + } + + public function testABoardOnlyShowsItsOwnKindOfResource(): void + { + $this->resource('D1', svgElementId: 'desk-01'); + $this->resource('P1', ResourceType::PARKING, 'parking-01'); + + $this->client->loginUser($this->user()); + + $desks = $this->client->request('GET', '/buchen/schreibtische')->html(); + self::assertStringContainsString('D1', $desks); + self::assertStringNotContainsString('P1', $desks); + + $parking = $this->client->request('GET', '/buchen/parkplaetze')->html(); + self::assertStringContainsString('P1', $parking); + self::assertStringNotContainsString('D1', $parking); + } + + public function testAnUnknownBookingAreaIsNotFound(): void + { + $this->client->loginUser($this->user()); + $this->client->request('GET', '/buchen/fahrraeder'); + + self::assertResponseStatusCodeSame(404); + } + + public function testABoardWithNothingSetUpSaysSo(): void + { + $this->client->loginUser($this->user()); + + self::assertStringContainsString( + 'noch nichts eingerichtet', + $this->client->request('GET', '/buchen/parkplaetze')->html(), + ); + } + + private function user(string $email = 'anna@ep-reisen.de'): User + { + $user = new User($email, 'Test'); + $user->setRoles(['ROLE_EMPLOYEE']); + $this->em->persist($user); + $this->em->flush(); + + return $user; + } + + private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource + { + $resource = new Resource($type, $label, $svgElementId); + $this->em->persist($resource); + $this->em->flush(); + + return $resource; + } +} diff --git a/tests/Entity/ResourceTypeTest.php b/tests/Entity/ResourceTypeTest.php new file mode 100644 index 0000000..53cd521 --- /dev/null +++ b/tests/Entity/ResourceTypeTest.php @@ -0,0 +1,56 @@ +slug())); + } + } + + public function testSlugsAreUnique(): void + { + $slugs = array_map(static fn (ResourceType $type): string => $type->slug(), ResourceType::cases()); + + self::assertSame($slugs, array_unique($slugs)); + } + + public function testAnUnknownSlugIsRejected(): void + { + $this->expectException(\ValueError::class); + + ResourceType::fromSlug('fahrraeder'); + } + + /** + * The URL slugs are German while the backing values stay English, because the values are + * persisted in resource.type and renaming them would need a migration. + */ + public function testTheSlugIsNotTheStoredValue(): void + { + self::assertSame('desk', ResourceType::DESK->value); + self::assertSame('schreibtische', ResourceType::DESK->slug()); + } + + /** + * Every case must carry the presentation a dashboard tile needs, or adding one would produce + * a broken tile rather than a working one. + */ + public function testEveryCaseIsFullyDescribed(): void + { + foreach (ResourceType::cases() as $type) { + self::assertNotSame('', $type->label()); + self::assertNotSame('', $type->plural()); + self::assertNotSame('', $type->description()); + self::assertStringStartsWith('lucide:', $type->icon()); + } + } +}