feat: replace the navigation with a dashboard and per-area boards

This commit is contained in:
fromme committed 2026-09-13 18:08:39 +02:00
1 parent d63df2cc1b
commit bce8f58be3
26 files changed
+810 -328

No files matched your search

@@ -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);
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3"/><path d="M3 16a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-5a2 2 0 0 0-4 0v1.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V11a2 2 0 0 0-4 0zm2 2v2m14-2v2"/></g></svg>

After

Width:  |  Height:  |  Size: 358 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/></g></svg>

After

Width:  |  Height:  |  Size: 433 B

+15 -15
View File
@@ -10,16 +10,16 @@ use App\Repository\BookingRepository;
use App\Repository\ResourceRepository; use App\Repository\ResourceRepository;
/** /**
* Builds the context behind the two fragments that make up the booking board: the month * Builds the context behind the two fragments that make up a booking board: the month calendar
* calendar and the floor plan. * and the floor plan.
* *
* Both are always rendered from the same context so that one htmx round trip can swap both -- * 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. * 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 * Everything here is relative to a ResourceType **and** a TimeRange. One board shows one kind of
* answer on its own, only "is it free between 09:00 and 13:00?", so the span the user picked is * resource, and with part-day bookings "is this free?" has no answer without a span -- so both
* carried through the occupancy counts and the plan colours alike -- the number in a calendar * are carried through the occupancy counts and the plan colours alike, and the number in a
* cell always means the same thing as the colours on the plan. * calendar cell always means the same thing as the colours on the plan beside it.
*/ */
final readonly class BoardPresenter final readonly class BoardPresenter
{ {
@@ -34,7 +34,7 @@ final readonly class BoardPresenter
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
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); $selected = $selected->setTime(0, 0);
$today = new \DateTimeImmutable('today'); $today = new \DateTimeImmutable('today');
@@ -43,10 +43,10 @@ final readonly class BoardPresenter
$gridStart = $monthStart->modify('monday this week'); $gridStart = $monthStart->modify('monday this week');
$gridEnd = $gridStart->modify(\sprintf('+%d days', self::WEEKS * 7 - 1)); $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 = []; $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; $mine[$booking->getDate()->format('Y-m-d')][] = $booking;
} }
@@ -72,33 +72,33 @@ final readonly class BoardPresenter
} }
$byResource = []; $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 // 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. // first is enough to colour the shape, and the tooltip lists them all.
$byResource[$booking->getResource()->getId()][] = $booking; $byResource[$booking->getResource()->getId()][] = $booking;
} }
return [ return [
'type' => $type,
'selected_date' => $selected, 'selected_date' => $selected,
'selected_time' => $time, 'selected_time' => $time,
'month_start' => $monthStart, 'month_start' => $monthStart,
'weeks' => $weeks, 'weeks' => $weeks,
'is_past' => $selected < $today, 'is_past' => $selected < $today,
'desks' => $this->resources->findActiveByType(ResourceType::DESK), 'resources' => $this->resources->findActiveByType($type),
'parking' => $this->resources->findActiveByType(ResourceType::PARKING),
'bookings_by_resource' => $byResource, 'bookings_by_resource' => $byResource,
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [], '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<string, string> * @return array<string, string>
*/ */
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()) { if (!$time->isAllDay()) {
$parameters['von'] = $time->start(); $parameters['von'] = $time->start();
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Booking;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Repository\BookingRepository;
use App\Repository\ResourceRepository;
/**
* The dashboard's tiles: one per bookable kind, built by walking ResourceType.
*
* Nothing here names a kind. Adding a case to the enum adds a tile with a working link and a
* correct count, which is what "extensible" has to mean if it is to survive contact with the
* next requirement.
*/
final readonly class DashboardPresenter
{
public function __construct(
private BookingRepository $bookings,
private ResourceRepository $resources,
) {
}
/**
* @return list<array<string, mixed>>
*/
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);
}
}
+1 -1
View File
@@ -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 * 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 * 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/_<type>.html.twig -- change one and the other stops
* drawing that resource. * drawing that resource.
*/ */
#[When('dev')] #[When('dev')]
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Booking\BoardPresenter;
use App\Booking\DateParam;
use App\Booking\TimeRange;
use App\Entity\ResourceType;
use App\Htmx\HxTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* One booking board per resource kind: a month calendar next to that kind's floor plan.
*
* The kind is a route parameter rather than a separate controller per kind, so a new
* ResourceType case gets a working board without touching this file.
*/
final class BoardController extends AbstractController
{
use CurrentUserTrait;
use HxTrait;
public function __construct(private readonly BoardPresenter $board)
{
}
#[Route('/buchen/{type}', name: 'app_board', requirements: ['type' => '[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);
}
}
}
+11 -9
View File
@@ -14,6 +14,7 @@ use App\Booking\TimeRange;
use App\Entity\Booking; use App\Entity\Booking;
use App\Entity\BookingSeries; use App\Entity\BookingSeries;
use App\Entity\Resource; use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Form\BookingRequest; use App\Form\BookingRequest;
use App\Form\BookingType; use App\Form\BookingType;
use App\Htmx\HxTrait; use App\Htmx\HxTrait;
@@ -67,7 +68,7 @@ final class BookingController extends AbstractController
} }
$selected = DateParam::parseOrToday($date); $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 // Prefilled from whatever span the board is showing, so narrowing the board and then
// clicking a desk books exactly what was on screen. // 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->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', [ return $this->render('booking/_modal_create.html.twig', [
@@ -114,7 +115,7 @@ final class BookingController extends AbstractController
{ {
$this->denyUnlessOwn($booking); $this->denyUnlessOwn($booking);
$boardTime = DashboardController::timeOf($request); $boardTime = BoardController::timeOf($request);
$data = new BookingRequest($booking->getDate(), $booking->getTimeRange()); $data = new BookingRequest($booking->getDate(), $booking->getTimeRange());
$form = $this->createForm(BookingType::class, $data, [ $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())); $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', [ return $this->render('booking/_modal_edit.html.twig', [
@@ -151,6 +152,7 @@ final class BookingController extends AbstractController
$this->denyUnlessCsrfValid($request, 'release-'.$booking->getId()); $this->denyUnlessCsrfValid($request, 'release-'.$booking->getId());
$date = $booking->getDate(); $date = $booking->getDate();
$type = $booking->getResource()->getType();
$label = $booking->getResource()->getLabel(); $label = $booking->getResource()->getLabel();
$this->bookings->release($booking); $this->bookings->release($booking);
@@ -161,7 +163,7 @@ final class BookingController extends AbstractController
return $this->redirectToRoute('app_booking'); 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" * 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). * (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( return $this->htmxOobResponse(
'dashboard/index.html.twig', 'board/index.html.twig',
['calendar', 'floorplan', 'flashes'], ['calendar', 'floorplan', 'flashes'],
$this->board->context($date, $time, $this->currentUser()), $this->board->context($type, $date, $time, $this->currentUser()),
$this->generateUrl('app_dashboard', BoardPresenter::urlParameters($date, $time)), $this->generateUrl('app_board', BoardPresenter::urlParameters($type, $date, $time)),
); );
} }
+15 -47
View File
@@ -4,68 +4,36 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Booking\BoardPresenter; use App\Booking\DashboardPresenter;
use App\Booking\DateParam;
use App\Booking\TimeRange;
use App\Htmx\HxTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; 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 final class DashboardController extends AbstractController
{ {
use CurrentUserTrait; use CurrentUserTrait;
use HxTrait;
public function __construct(private readonly BoardPresenter $board) public function __construct(private readonly DashboardPresenter $dashboard)
{ {
} }
#[Route('/', name: 'app_dashboard', methods: ['GET'])] #[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())); return $this->render('dashboard/index.html.twig', [
} 'tiles' => $this->dashboard->tiles($user, $today),
'upcoming' => $this->dashboard->upcoming($user, $today),
/** 'today' => $today,
* 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();
} }
} }
+56
View File
@@ -4,6 +4,18 @@ declare(strict_types=1);
namespace App\Entity; 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/_<value>.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 enum ResourceType: string
{ {
case DESK = 'desk'; case DESK = 'desk';
@@ -16,4 +28,48 @@ enum ResourceType: string
self::PARKING => 'Parkplatz', 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));
}
} }
+27 -10
View File
@@ -32,7 +32,7 @@ class BookingRepository extends ServiceEntityRepository
* *
* @return list<Booking> * @return list<Booking>
*/ */
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') $qb = $this->createQueryBuilder('b')
->addSelect('u', 'r') ->addSelect('u', 'r')
@@ -47,6 +47,10 @@ class BookingRepository extends ServiceEntityRepository
self::restrictToOverlap($qb, $within); self::restrictToOverlap($qb, $within);
} }
if (null !== $type) {
$qb->andWhere('r.type = :type')->setParameter('type', $type);
}
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
@@ -106,9 +110,9 @@ class BookingRepository extends ServiceEntityRepository
* *
* @return list<Booking> * @return list<Booking>
*/ */
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') ->addSelect('r', 's')
->join('b.resource', 'r') ->join('b.resource', 'r')
->leftJoin('b.series', 's') ->leftJoin('b.series', 's')
@@ -119,20 +123,29 @@ class BookingRepository extends ServiceEntityRepository
->setParameter('to', $to->setTime(0, 0)) ->setParameter('to', $to->setTime(0, 0))
->orderBy('b.date', 'ASC') ->orderBy('b.date', 'ASC')
->addOrderBy('b.startMinute', 'ASC') ->addOrderBy('b.startMinute', 'ASC')
->addOrderBy('r.label', 'ASC') ->addOrderBy('r.label', 'ASC');
->getQuery()
->getResult(); 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 * 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 * occupancy. Counts only what overlaps the span the board is currently showing, and only the
* number in a day cell always means the same thing as the colours on the plan. * 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<string, int> * @return array<string, int>
*/ */
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') $qb = $this->createQueryBuilder('b')
->select('b.date AS date', 'COUNT(b.id) AS total') ->select('b.date AS date', 'COUNT(b.id) AS total')
->andWhere('b.date BETWEEN :from AND :to') ->andWhere('b.date BETWEEN :from AND :to')
@@ -144,6 +157,10 @@ class BookingRepository extends ServiceEntityRepository
self::restrictToOverlap($qb, $within); self::restrictToOverlap($qb, $within);
} }
if (null !== $type) {
$qb->join('b.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type);
}
$counts = []; $counts = [];
foreach ($qb->getQuery()->getResult() as $row) { foreach ($qb->getQuery()->getResult() as $row) {
$counts[$row['date']->format('Y-m-d')] = (int) $row['total']; $counts[$row['date']->format('Y-m-d')] = (int) $row['total'];
-39
View File
@@ -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' },
] %}
<div class="min-h-full flex grow flex-col gap-y-5 lg:border-r border-gray-200 bg-white md:pr-6">
<nav class="flex flex-1 flex-col">
<ul role="list" class="space-y-1">
{% for item in items %}
{% set is_active = current_route starts with item.route %}
<li>
<a href="{{ path(item.route) }}"
class="group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold {{ is_active ? 'bg-gray-100 text-gray-900' : 'text-gray-700 hover:bg-gray-50' }}">
{{ ux_icon(item.icon, { class: 'h-5 w-5 shrink-0 ' ~ (is_active ? 'text-gray-600' : 'text-gray-400') }) }}
<span class="flex-1">{{ item.label }}</span>
</a>
</li>
{% endfor %}
</ul>
{% if app.user %}
<div class="mt-auto pt-6 border-t border-gray-200">
<div class="px-2 pb-2 text-sm">
<div class="font-semibold">{{ app.user.name }}</div>
<div class="text-gray-500 truncate">{{ app.user.email }}</div>
</div>
<a href="{{ path('app_logout') }}"
class="group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold text-gray-700 hover:bg-gray-50">
{{ ux_icon('lucide:log-out', { class: 'h-5 w-5 shrink-0 text-gray-400' }) }}
<span class="flex-1">Abmelden</span>
</a>
</div>
{% endif %}
</nav>
</div>
@@ -9,7 +9,7 @@
<button type="button" <button type="button"
class="p-1 rounded-md hover:bg-gray-100" class="p-1 rounded-md hover:bg-gray-100"
aria-label="Voriger Monat" aria-label="Voriger Monat"
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('-1 month')|date('Y-m-d') }|merge(time_params)) }}" hx-get="{{ path('app_board_day', { type: type.slug, date: month_start|date_modify('-1 month')|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none"> hx-swap="none">
{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }} {{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}
</button> </button>
@@ -19,7 +19,7 @@
<button type="button" <button type="button"
class="p-1 rounded-md hover:bg-gray-100" class="p-1 rounded-md hover:bg-gray-100"
aria-label="Nächster Monat" aria-label="Nächster Monat"
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('+1 month')|date('Y-m-d') }|merge(time_params)) }}" hx-get="{{ path('app_board_day', { type: type.slug, date: month_start|date_modify('+1 month')|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none"> hx-swap="none">
{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }} {{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}
</button> </button>
@@ -49,7 +49,7 @@
<button type="button" <button type="button"
class="{{ classes }}" class="{{ classes }}"
aria-pressed="{{ day.is_selected ? 'true' : 'false' }}" aria-pressed="{{ day.is_selected ? 'true' : 'false' }}"
hx-get="{{ path('app_dashboard_day', { date: day.date|date('Y-m-d') }|merge(time_params)) }}" hx-get="{{ path('app_board_day', { type: type.slug, date: day.date|date('Y-m-d') }|merge(time_params)) }}"
hx-swap="none"> hx-swap="none">
<span>{{ day.date|date('j') }}</span> <span>{{ day.date|date('j') }}</span>
{# Team view: the occupancy count is the whole point of showing the month. #} {# Team view: the occupancy count is the whole point of showing the month. #}
+32
View File
@@ -0,0 +1,32 @@
{#
The frame around a plan: heading, legend, span picker. The drawing itself lives in
board/plan/_<type>.html.twig and is included by convention, so a new ResourceType needs a new
partial there and nothing else.
Colouring needs no JavaScript at all: the plan 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.
#}
<div class="rounded-md border border-gray-200 p-4">
<div class="flex flex-wrap items-baseline justify-between gap-2 pb-2">
<h2 class="!pb-0">{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}</h2>
<div class="flex items-center gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-emerald-50 border-2 border-emerald-500"></span> frei</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-gray-200 border-2 border-gray-400"></span> belegt</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-primary border-2 border-primary-dark"></span> deine</span>
</div>
</div>
{% include 'board/_timerange.html.twig' %}
{% if is_past %}
<p class="pb-4 text-sm text-amber-700">Vergangener Tag — hier lässt sich nichts mehr buchen.</p>
{% endif %}
{% if resources is empty %}
<p class="py-8 text-center text-sm text-gray-500">Für diesen Bereich ist noch nichts eingerichtet.</p>
{% else %}
{% include 'board/plan/_' ~ type.value ~ '.html.twig' %}
{% endif %}
</div>
@@ -15,7 +15,7 @@
<label class="block text-xs text-gray-500 pb-1" for="board-von">Von</label> <label class="block text-xs text-gray-500 pb-1" for="board-von">Von</label>
<input type="time" id="board-von" name="von" value="{{ selected_time.allDay ? '' : selected_time.start }}" step="900" <input type="time" id="board-von" name="von" value="{{ selected_time.allDay ? '' : selected_time.start }}" step="900"
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary" class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
hx-get="{{ path('app_dashboard_day', { date: day }) }}" hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
hx-include="#board-bis" hx-include="#board-bis"
hx-trigger="change" hx-trigger="change"
hx-swap="none"> hx-swap="none">
@@ -24,7 +24,7 @@
<label class="block text-xs text-gray-500 pb-1" for="board-bis">Bis</label> <label class="block text-xs text-gray-500 pb-1" for="board-bis">Bis</label>
<input type="time" id="board-bis" name="bis" value="{{ selected_time.allDay ? '' : selected_time.end }}" step="900" <input type="time" id="board-bis" name="bis" value="{{ selected_time.allDay ? '' : selected_time.end }}" step="900"
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary" class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
hx-get="{{ path('app_dashboard_day', { date: day }) }}" hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
hx-include="#board-von" hx-include="#board-von"
hx-trigger="change" hx-trigger="change"
hx-swap="none"> hx-swap="none">
@@ -32,7 +32,7 @@
{% if not selected_time.allDay %} {% if not selected_time.allDay %}
<button type="button" class="button button--small button--neutral" <button type="button" class="button button--small button--neutral"
hx-get="{{ path('app_dashboard_day', { date: day }) }}" hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
hx-swap="none"> hx-swap="none">
Ganzer Tag Ganzer Tag
</button> </button>
+34
View File
@@ -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. #}
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
<h1 class="pt-2">{{ type.plural }} buchen</h1>
<p class="subline">Tag und Zeitraum wählen, dann einen freien Platz anklicken.</p>
<div class="mt-8 grid gap-8 xl:grid-cols-3">
<div class="xl:col-span-1">
{#
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 %}
<div id="board-calendar"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include 'board/_calendar.html.twig' %}
</div>
{% endblock %}
</div>
<div class="xl:col-span-2">
{% block floorplan %}
<div id="board-floorplan"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include 'board/_floorplan.html.twig' %}
</div>
{% endblock %}
</div>
</div>
{% endblock %}
+28
View File
@@ -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 } %}
<svg viewBox="0 0 520 460" class="w-full h-auto" role="img" aria-label="Raumplan Büro">
<rect x="10" y="20" width="500" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="20" y="14" class="fill-gray-400 text-xs font-semibold">Büro</text>
{% 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 %}
</svg>
+25
View File
@@ -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 } %}
<svg viewBox="0 0 520 300" class="w-full h-auto" role="img" aria-label="Parkplatz">
<rect x="10" y="20" width="500" height="260" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="20" y="14" class="fill-gray-400 text-xs font-semibold">Parkplatz</text>
{# The lane between the two rows. #}
<line x1="265" y1="40" x2="265" y2="260" class="stroke-gray-200" stroke-width="2" stroke-dasharray="6 6"></line>
{% 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 %}
</svg>
+62
View File
@@ -0,0 +1,62 @@
{#
The shared shape a plan is drawn from. Imported by each board/plan/_<type>.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 %}
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
{%- if interactive and mine %}
hx-get="{{ path('app_booking_edit', { id: own.id }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- elseif interactive %}
{# The modal is appended to the end of <body>; 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 %}>
<title>
{{- 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 -%}
</title>
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (caption ? height / 2 - 4 : height / 2 + 4) }}"
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ resource.label }}</text>
{% if caption %}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</text>
{% endif %}
</g>
{% endmacro %}
+5 -3
View File
@@ -3,7 +3,9 @@
{% block title %}Meine Buchungen · ephub{% endblock %} {% block title %}Meine Buchungen · ephub{% endblock %}
{% block content %} {% block content %}
<h1>Meine Buchungen</h1> <a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
<h1 class="pt-2">Meine Buchungen</h1>
<p class="subline">Alles, was ab heute für dich reserviert ist.</p> <p class="subline">Alles, was ab heute für dich reserviert ist.</p>
{% if series is not empty %} {% if series is not empty %}
@@ -32,7 +34,7 @@
{% if bookings is empty %} {% if bookings is empty %}
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
Noch nichts gebucht. <a class="underline" href="{{ path('app_dashboard') }}">Zum Raumplan</a>. Noch nichts gebucht. <a class="underline" href="{{ path('app_dashboard') }}">Zur Übersicht</a>.
</p> </p>
{% else %} {% else %}
<ul class="divide-y divide-gray-200 border-y border-gray-200"> <ul class="divide-y divide-gray-200 border-y border-gray-200">
@@ -55,7 +57,7 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a class="text-sm underline" <a class="text-sm underline"
href="{{ path('app_dashboard', { tag: booking.date|date('Y-m-d') }) }}">Im Plan</a> href="{{ path('app_board', { type: booking.resource.type.slug, tag: booking.date|date('Y-m-d') }) }}">Im Plan</a>
{# Ordinary form post: this page has no board fragments to swap, so it {# Ordinary form post: this page has no board fragments to swap, so it
reloads afterwards rather than pretending to be htmx. #} reloads afterwards rather than pretending to be htmx. #}
<form method="post" action="{{ path('app_booking_release', { id: booking.id }) }}"> <form method="post" action="{{ path('app_booking_release', { id: booking.id }) }}">
-119
View File
@@ -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 %}
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
{%- if interactive and mine %}
hx-get="{{ path('app_booking_edit', { id: own.id }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- elseif interactive %}
{# The modal is appended to the end of <body>; 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 %}>
<title>
{{- 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 -%}
</title>
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (caption ? height / 2 - 4 : height / 2 + 4) }}"
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ resource.label }}</text>
{% if caption %}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</text>
{% endif %}
</g>
{% endmacro %}
<div class="rounded-md border border-gray-200 p-4">
<div class="flex flex-wrap items-baseline justify-between gap-2 pb-2">
<h2 class="!pb-0">{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}</h2>
<div class="flex items-center gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-emerald-50 border-2 border-emerald-500"></span> frei</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-gray-200 border-2 border-gray-400"></span> belegt</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-primary border-2 border-primary-dark"></span> deine</span>
</div>
</div>
{% include 'dashboard/_timerange.html.twig' %}
{% if is_past %}
<p class="pb-4 text-sm text-amber-700">Vergangener Tag — hier lässt sich nichts mehr buchen.</p>
{% endif %}
<svg viewBox="0 0 810 480" class="w-full h-auto" role="img" aria-label="Raumplan">
<rect x="20" y="30" width="470" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="30" y="22" class="fill-gray-400 text-xs font-semibold">Büro</text>
<rect x="520" y="30" width="270" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="530" y="22" class="fill-gray-400 text-xs font-semibold">Parkplatz</text>
{% 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 %}
</svg>
</div>
+69 -20
View File
@@ -3,29 +3,78 @@
{% block title %}Übersicht · ephub{% endblock %} {% block title %}Übersicht · ephub{% endblock %}
{% block content %} {% block content %}
<h1>Buchen</h1> <h1>Übersicht</h1>
<p class="subline">Tag und Zeitraum wählen, dann einen freien Platz im Raumplan anklicken.</p> <p class="subline">{{ today|format_datetime(pattern: 'EEEE, d. MMMM y') }}</p>
<div class="mt-8 grid gap-8 xl:grid-cols-3"> {#
<div class="xl:col-span-1"> 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.
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 <div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
rendered through htmxOobResponse -- rendered normally they are plain markup. {% for tile in tiles %}
#} <a href="{{ path('app_board', { type: tile.type.slug }) }}"
{% block calendar %} class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div id="board-calendar"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}> <div class="flex items-center gap-3 pb-2">
{% include 'dashboard/_calendar.html.twig' %} {{ ux_icon(tile.type.icon, { class: 'h-6 w-6 shrink-0 text-primary' }) }}
<span class="text-lg font-bold">{{ tile.type.plural }}</span>
</div> </div>
{% endblock %}
</div>
<div class="xl:col-span-2"> <p class="text-sm text-gray-500 pb-4">{{ tile.type.description }}</p>
{% block floorplan %}
<div id="board-floorplan"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}> <div class="mt-auto flex items-baseline justify-between">
{% include 'dashboard/_floorplan.html.twig' %} <span class="text-sm">
{% if tile.total == 0 %}
<span class="text-gray-400">nichts eingerichtet</span>
{% elseif tile.free == 0 %}
<span class="font-semibold text-red-600">heute alles belegt</span>
{% else %}
<span class="font-semibold text-emerald-700">{{ tile.free }}</span>
<span class="text-gray-500">von {{ tile.total }} heute frei</span>
{% endif %}
</span>
{% if tile.mine %}
<span class="badge bg-primary text-white">{{ tile.mine.resource.label }}</span>
{% endif %}
</div> </div>
{% endblock %} </a>
</div> {% endfor %}
<a href="{{ path('app_booking') }}"
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
<div class="flex items-center gap-3 pb-2">
{{ ux_icon('lucide:calendar-days', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
<span class="text-lg font-bold">Meine Buchungen</span>
</div>
<p class="text-sm text-gray-500 pb-4">Alle Termine und Serien verwalten.</p>
</a>
</div> </div>
<h2 class="pt-10">Als Nächstes</h2>
{% if upcoming is empty %}
<p class="text-sm text-gray-500">Nichts gebucht.</p>
{% else %}
<ul class="divide-y divide-gray-200 border-y border-gray-200">
{% for booking in upcoming %}
<li class="flex items-center justify-between gap-4 py-3">
<div>
<div class="font-semibold">
{{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM') }}
<span class="font-normal text-gray-500">· {{ booking.timeRange }}</span>
</div>
<div class="text-sm text-gray-500">
{{ booking.resource.type.label }} · {{ booking.resource.label }}
{% if booking.partOfSeries %}
<span class="badge bg-gray-100 text-gray-600 ml-1">Serie</span>
{% endif %}
</div>
</div>
<a class="text-sm underline shrink-0"
href="{{ path('app_board', { type: booking.resource.type.slug, tag: booking.date|date('Y-m-d') }) }}">
Im Plan
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %} {% endblock %}
+20 -30
View File
@@ -1,42 +1,32 @@
{% extends 'base.html.twig' %} {% 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 %} {% block body %}
<div class="max-w-screen-2xl mx-auto px-4 lg:px-8 lg:grid lg:grid-cols-4 gap-8 py-8"> <div class="border-b border-gray-200">
<div class="lg:col-span-4 flex items-center justify-between pb-4 lg:pb-0"> <div class="max-w-screen-2xl mx-auto px-4 lg:px-8 h-16 flex items-center justify-between gap-4">
<a href="{{ path('app_dashboard') }}"> <a href="{{ path('app_dashboard') }}" class="shrink-0">
<img class="h-8 w-auto" src="{{ asset('images/logo_ep.svg') }}" alt="ephub"> <img class="h-8 w-auto" src="{{ asset('images/logo_ep.svg') }}" alt="ephub">
</a> </a>
<button type="button"
class="lg:hidden"
{{ stimulus_controller('mobilenav', [], [], { 'mobilenav': '#mobilenav' }) }}
{{ stimulus_action('mobilenav', 'trigger', null, { 'mode': 'open' }) }}>
{{ ux_icon('lucide:menu', { class: 'w-8 h-8 text-gray-700' }) }}
</button>
</div>
<div class="hidden lg:block sticky top-0 h-screen"> {% if app.user %}
{% include '_partials/_nav.html.twig' %} <div class="flex items-center gap-4">
</div> <span class="hidden sm:block text-sm text-gray-500">{{ app.user.name }}</span>
<a href="{{ path('app_logout') }}"
<div class="lg:col-span-3"> class="flex items-center gap-2 text-sm font-semibold text-gray-700 hover:text-gray-900">
{% block content %}{% endblock %} {{ ux_icon('lucide:log-out', { class: 'h-5 w-5 shrink-0 text-gray-400' }) }}
<span class="hidden sm:inline">Abmelden</span>
</a>
</div>
{% endif %}
</div> </div>
</div> </div>
<div id="mobilenav" <div class="max-w-screen-2xl mx-auto px-4 lg:px-8 py-8">
class="fixed z-50 top-0 left-0 inset-0 pr-16 bg-black/70 -translate-x-full" {% block content %}{% endblock %}
{{ stimulus_controller('mobilenav', [], { 'closed': '-translate-x-full' }, { 'mobilenav': '#mobilenav' }) }}>
<div class="h-screen w-full bg-white">
<div class="flex justify-between p-8">
<img class="h-8 w-auto" src="{{ asset('images/logo_ep.svg') }}" alt="">
<button type="button" {{ stimulus_action('mobilenav', 'trigger', null, { 'mode': 'close' }) }}>
{{ ux_icon('lucide:x', { class: 'w-8 h-8' }) }}
</button>
</div>
<div class="w-full h-full p-8 pt-0 overflow-y-scroll">
{% include '_partials/_nav.html.twig' %}
</div>
</div>
</div> </div>
{# Swapped out of band after a booking, hence the stable id and the conditional attribute. #} {# Swapped out of band after a booking, hence the stable id and the conditional attribute. #}
+7 -7
View File
@@ -44,11 +44,11 @@ class BookingControllerTest extends WebTestCase
{ {
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$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(); $response = $this->client->getResponse();
self::assertResponseIsSuccessful(); 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::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"'));
self::assertStringContainsString('id="board-calendar"', (string) $response->getContent()); self::assertStringContainsString('id="board-calendar"', (string) $response->getContent());
self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent()); self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent());
@@ -58,9 +58,9 @@ class BookingControllerTest extends WebTestCase
{ {
$this->client->loginUser($this->user('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$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->em->flush();
$this->client->loginUser($anna); $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()))); 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('[email protected]')); $this->client->loginUser($this->user('[email protected]'));
$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::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 public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class DashboardControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $em;
protected function setUp(): void
{
$this->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('[email protected]'), $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('[email protected]'), $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 = '[email protected]'): 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;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\ResourceType;
use PHPUnit\Framework\TestCase;
class ResourceTypeTest extends TestCase
{
public function testEverySlugResolvesBackToItsCase(): void
{
foreach (ResourceType::cases() as $type) {
self::assertSame($type, ResourceType::fromSlug($type->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());
}
}
}