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 @@