feat: replace the navigation with a dashboard and per-area boards
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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')]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
@@ -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. #}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 }) }}">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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. #}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user