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;
/**
* 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<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);
$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<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()) {
$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
* 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.
*/
#[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\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)),
);
}
+15 -47
View File
@@ -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,
]);
}
}
+56
View File
@@ -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/_<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
{
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));
}
}
+27 -10
View File
@@ -32,7 +32,7 @@ class BookingRepository extends ServiceEntityRepository
*
* @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')
->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<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')
->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<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')
->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'];
-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"
class="p-1 rounded-md hover:bg-gray-100"
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">
{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}
</button>
@@ -19,7 +19,7 @@
<button type="button"
class="p-1 rounded-md hover:bg-gray-100"
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">
{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}
</button>
@@ -49,7 +49,7 @@
<button type="button"
class="{{ classes }}"
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">
<span>{{ day.date|date('j') }}</span>
{# 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>
<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"
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
hx-include="#board-bis"
hx-trigger="change"
hx-swap="none">
@@ -24,7 +24,7 @@
<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"
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-trigger="change"
hx-swap="none">
@@ -32,7 +32,7 @@
{% if not selected_time.allDay %}
<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">
Ganzer Tag
</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 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>
{% if series is not empty %}
@@ -32,7 +34,7 @@
{% if bookings is empty %}
<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>
{% else %}
<ul class="divide-y divide-gray-200 border-y border-gray-200">
@@ -55,7 +57,7 @@
</div>
<div class="flex items-center gap-3">
<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
reloads afterwards rather than pretending to be htmx. #}
<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 content %}
<h1>Buchen</h1>
<p class="subline">Tag und Zeitraum wählen, dann einen freien Platz im Raumplan anklicken.</p>
<h1>Übersicht</h1>
<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">
{#
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 %}
<div id="board-calendar"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% 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.
#}
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{% for tile in tiles %}
<a href="{{ path('app_board', { type: tile.type.slug }) }}"
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(tile.type.icon, { class: 'h-6 w-6 shrink-0 text-primary' }) }}
<span class="text-lg font-bold">{{ tile.type.plural }}</span>
</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 'dashboard/_floorplan.html.twig' %}
<p class="text-sm text-gray-500 pb-4">{{ tile.type.description }}</p>
<div class="mt-auto flex items-baseline justify-between">
<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>
{% endblock %}
</div>
</a>
{% 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>
<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 %}
+20 -30
View File
@@ -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 %}
<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="lg:col-span-4 flex items-center justify-between pb-4 lg:pb-0">
<a href="{{ path('app_dashboard') }}">
<div class="border-b border-gray-200">
<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') }}" class="shrink-0">
<img class="h-8 w-auto" src="{{ asset('images/logo_ep.svg') }}" alt="ephub">
</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">
{% include '_partials/_nav.html.twig' %}
</div>
<div class="lg:col-span-3">
{% block content %}{% endblock %}
{% if app.user %}
<div class="flex items-center gap-4">
<span class="hidden sm:block text-sm text-gray-500">{{ app.user.name }}</span>
<a href="{{ path('app_logout') }}"
class="flex items-center gap-2 text-sm font-semibold text-gray-700 hover:text-gray-900">
{{ 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 id="mobilenav"
class="fixed z-50 top-0 left-0 inset-0 pr-16 bg-black/70 -translate-x-full"
{{ 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 class="max-w-screen-2xl mx-auto px-4 lg:px-8 py-8">
{% block content %}{% endblock %}
</div>
{# 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->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('[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->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('[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::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
@@ -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());
}
}
}