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;
|
use App\Repository\ResourceRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the context behind the two fragments that make up the booking board: the month
|
* Builds the context behind the two fragments that make up a booking board: the month calendar
|
||||||
* calendar and the floor plan.
|
* and the floor plan.
|
||||||
*
|
*
|
||||||
* Both are always rendered from the same context so that one htmx round trip can swap both --
|
* Both are always rendered from the same context so that one htmx round trip can swap both --
|
||||||
* picking a day (or a time span) updates the grid and the plan in a single out-of-band response.
|
* picking a day (or a time span) updates the grid and the plan in a single out-of-band response.
|
||||||
*
|
*
|
||||||
* Everything here is relative to a TimeRange. With part-day bookings "is this desk free?" has no
|
* Everything here is relative to a ResourceType **and** a TimeRange. One board shows one kind of
|
||||||
* answer on its own, only "is it free between 09:00 and 13:00?", so the span the user picked is
|
* resource, and with part-day bookings "is this free?" has no answer without a span -- so both
|
||||||
* carried through the occupancy counts and the plan colours alike -- the number in a calendar
|
* are carried through the occupancy counts and the plan colours alike, and the number in a
|
||||||
* cell always means the same thing as the colours on the plan.
|
* calendar cell always means the same thing as the colours on the plan beside it.
|
||||||
*/
|
*/
|
||||||
final readonly class BoardPresenter
|
final readonly class BoardPresenter
|
||||||
{
|
{
|
||||||
@@ -34,7 +34,7 @@ final readonly class BoardPresenter
|
|||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function context(\DateTimeImmutable $selected, TimeRange $time, User $user): array
|
public function context(ResourceType $type, \DateTimeImmutable $selected, TimeRange $time, User $user): array
|
||||||
{
|
{
|
||||||
$selected = $selected->setTime(0, 0);
|
$selected = $selected->setTime(0, 0);
|
||||||
$today = new \DateTimeImmutable('today');
|
$today = new \DateTimeImmutable('today');
|
||||||
@@ -43,10 +43,10 @@ final readonly class BoardPresenter
|
|||||||
$gridStart = $monthStart->modify('monday this week');
|
$gridStart = $monthStart->modify('monday this week');
|
||||||
$gridEnd = $gridStart->modify(\sprintf('+%d days', self::WEEKS * 7 - 1));
|
$gridEnd = $gridStart->modify(\sprintf('+%d days', self::WEEKS * 7 - 1));
|
||||||
|
|
||||||
$counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd, $time);
|
$counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd, $time, $type);
|
||||||
|
|
||||||
$mine = [];
|
$mine = [];
|
||||||
foreach ($this->bookings->findForUserInRange($user, $gridStart, $gridEnd) as $booking) {
|
foreach ($this->bookings->findForUserInRange($user, $gridStart, $gridEnd, $type) as $booking) {
|
||||||
$mine[$booking->getDate()->format('Y-m-d')][] = $booking;
|
$mine[$booking->getDate()->format('Y-m-d')][] = $booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,33 +72,33 @@ final readonly class BoardPresenter
|
|||||||
}
|
}
|
||||||
|
|
||||||
$byResource = [];
|
$byResource = [];
|
||||||
foreach ($this->bookings->findForDate($selected, $time) as $booking) {
|
foreach ($this->bookings->findForDate($selected, $time, $type) as $booking) {
|
||||||
// Several bookings can overlap the span on one resource once times are in play; the
|
// Several bookings can overlap the span on one resource once times are in play; the
|
||||||
// first is enough to colour the shape, and the tooltip lists them all.
|
// first is enough to colour the shape, and the tooltip lists them all.
|
||||||
$byResource[$booking->getResource()->getId()][] = $booking;
|
$byResource[$booking->getResource()->getId()][] = $booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'type' => $type,
|
||||||
'selected_date' => $selected,
|
'selected_date' => $selected,
|
||||||
'selected_time' => $time,
|
'selected_time' => $time,
|
||||||
'month_start' => $monthStart,
|
'month_start' => $monthStart,
|
||||||
'weeks' => $weeks,
|
'weeks' => $weeks,
|
||||||
'is_past' => $selected < $today,
|
'is_past' => $selected < $today,
|
||||||
'desks' => $this->resources->findActiveByType(ResourceType::DESK),
|
'resources' => $this->resources->findActiveByType($type),
|
||||||
'parking' => $this->resources->findActiveByType(ResourceType::PARKING),
|
|
||||||
'bookings_by_resource' => $byResource,
|
'bookings_by_resource' => $byResource,
|
||||||
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
|
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The query parameters that reproduce a board view, so links and HX-Push-Url agree.
|
* The route parameters that reproduce a board view, so links and HX-Push-Url agree.
|
||||||
*
|
*
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
public static function urlParameters(\DateTimeImmutable $date, TimeRange $time): array
|
public static function urlParameters(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): array
|
||||||
{
|
{
|
||||||
$parameters = ['tag' => $date->format('Y-m-d')];
|
$parameters = ['type' => $type->slug(), 'tag' => $date->format('Y-m-d')];
|
||||||
|
|
||||||
if (!$time->isAllDay()) {
|
if (!$time->isAllDay()) {
|
||||||
$parameters['von'] = $time->start();
|
$parameters['von'] = $time->start();
|
||||||
|
|||||||
@@ -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
|
* Dev only, and idempotent: it matches on the natural key (resource label, user email) so it can
|
||||||
* be re-run after a migration without duplicating anything. The resource svgElementId values are
|
* be re-run after a migration without duplicating anything. The resource svgElementId values are
|
||||||
* the contract with templates/dashboard/_floorplan.html.twig -- change one and the other stops
|
* the contract with templates/board/plan/_<type>.html.twig -- change one and the other stops
|
||||||
* drawing that resource.
|
* drawing that resource.
|
||||||
*/
|
*/
|
||||||
#[When('dev')]
|
#[When('dev')]
|
||||||
|
|||||||
@@ -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\Booking;
|
||||||
use App\Entity\BookingSeries;
|
use App\Entity\BookingSeries;
|
||||||
use App\Entity\Resource;
|
use App\Entity\Resource;
|
||||||
|
use App\Entity\ResourceType;
|
||||||
use App\Form\BookingRequest;
|
use App\Form\BookingRequest;
|
||||||
use App\Form\BookingType;
|
use App\Form\BookingType;
|
||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
@@ -67,7 +68,7 @@ final class BookingController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$selected = DateParam::parseOrToday($date);
|
$selected = DateParam::parseOrToday($date);
|
||||||
$boardTime = DashboardController::timeOf($request);
|
$boardTime = BoardController::timeOf($request);
|
||||||
|
|
||||||
// Prefilled from whatever span the board is showing, so narrowing the board and then
|
// Prefilled from whatever span the board is showing, so narrowing the board and then
|
||||||
// clicking a desk books exactly what was on screen.
|
// clicking a desk books exactly what was on screen.
|
||||||
@@ -96,7 +97,7 @@ final class BookingController extends AbstractController
|
|||||||
return $this->invalidModal($form, $resource, $e->getMessage());
|
return $this->invalidModal($form, $resource, $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->boardResponse($data->date, $boardTime);
|
return $this->boardResponse($resource->getType(), $data->date, $boardTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('booking/_modal_create.html.twig', [
|
return $this->render('booking/_modal_create.html.twig', [
|
||||||
@@ -114,7 +115,7 @@ final class BookingController extends AbstractController
|
|||||||
{
|
{
|
||||||
$this->denyUnlessOwn($booking);
|
$this->denyUnlessOwn($booking);
|
||||||
|
|
||||||
$boardTime = DashboardController::timeOf($request);
|
$boardTime = BoardController::timeOf($request);
|
||||||
$data = new BookingRequest($booking->getDate(), $booking->getTimeRange());
|
$data = new BookingRequest($booking->getDate(), $booking->getTimeRange());
|
||||||
|
|
||||||
$form = $this->createForm(BookingType::class, $data, [
|
$form = $this->createForm(BookingType::class, $data, [
|
||||||
@@ -134,7 +135,7 @@ final class BookingController extends AbstractController
|
|||||||
|
|
||||||
$this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange()));
|
$this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange()));
|
||||||
|
|
||||||
return $this->boardResponse($booking->getDate(), $boardTime);
|
return $this->boardResponse($booking->getResource()->getType(), $booking->getDate(), $boardTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('booking/_modal_edit.html.twig', [
|
return $this->render('booking/_modal_edit.html.twig', [
|
||||||
@@ -151,6 +152,7 @@ final class BookingController extends AbstractController
|
|||||||
$this->denyUnlessCsrfValid($request, 'release-'.$booking->getId());
|
$this->denyUnlessCsrfValid($request, 'release-'.$booking->getId());
|
||||||
|
|
||||||
$date = $booking->getDate();
|
$date = $booking->getDate();
|
||||||
|
$type = $booking->getResource()->getType();
|
||||||
$label = $booking->getResource()->getLabel();
|
$label = $booking->getResource()->getLabel();
|
||||||
$this->bookings->release($booking);
|
$this->bookings->release($booking);
|
||||||
|
|
||||||
@@ -161,7 +163,7 @@ final class BookingController extends AbstractController
|
|||||||
return $this->redirectToRoute('app_booking');
|
return $this->redirectToRoute('app_booking');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->boardResponse($date, DashboardController::timeOf($request));
|
return $this->boardResponse($type, $date, BoardController::timeOf($request));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,13 +237,13 @@ final class BookingController extends AbstractController
|
|||||||
* The whole response is out-of-band board blocks. Callers pair it with hx-swap="none"
|
* The whole response is out-of-band board blocks. Callers pair it with hx-swap="none"
|
||||||
* (or an outerHTML target that should vanish, as the modal does).
|
* (or an outerHTML target that should vanish, as the modal does).
|
||||||
*/
|
*/
|
||||||
private function boardResponse(\DateTimeImmutable $date, TimeRange $time): Response
|
private function boardResponse(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): Response
|
||||||
{
|
{
|
||||||
return $this->htmxOobResponse(
|
return $this->htmxOobResponse(
|
||||||
'dashboard/index.html.twig',
|
'board/index.html.twig',
|
||||||
['calendar', 'floorplan', 'flashes'],
|
['calendar', 'floorplan', 'flashes'],
|
||||||
$this->board->context($date, $time, $this->currentUser()),
|
$this->board->context($type, $date, $time, $this->currentUser()),
|
||||||
$this->generateUrl('app_dashboard', BoardPresenter::urlParameters($date, $time)),
|
$this->generateUrl('app_board', BoardPresenter::urlParameters($type, $date, $time)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,68 +4,36 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Booking\BoardPresenter;
|
use App\Booking\DashboardPresenter;
|
||||||
use App\Booking\DateParam;
|
|
||||||
use App\Booking\TimeRange;
|
|
||||||
use App\Htmx\HxTrait;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The booking board: a month calendar next to the floor plan for the selected day and span.
|
* The landing screen: one tile per bookable kind, plus what this person has coming up.
|
||||||
|
*
|
||||||
|
* It replaces the sidebar navigation, so it is also the only route to the booking boards and the
|
||||||
|
* bookings list -- which is why the tiles are generated from ResourceType rather than written
|
||||||
|
* out: a kind with no tile would be unreachable.
|
||||||
*/
|
*/
|
||||||
final class DashboardController extends AbstractController
|
final class DashboardController extends AbstractController
|
||||||
{
|
{
|
||||||
use CurrentUserTrait;
|
use CurrentUserTrait;
|
||||||
use HxTrait;
|
|
||||||
|
|
||||||
public function __construct(private readonly BoardPresenter $board)
|
public function __construct(private readonly DashboardPresenter $dashboard)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/', name: 'app_dashboard', methods: ['GET'])]
|
#[Route('/', name: 'app_dashboard', methods: ['GET'])]
|
||||||
public function index(Request $request): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
$date = DateParam::parseOrToday($request->query->getString('tag'));
|
$today = new \DateTimeImmutable('today');
|
||||||
|
$user = $this->currentUser();
|
||||||
|
|
||||||
return $this->render('dashboard/index.html.twig', $this->board->context($date, self::timeOf($request), $this->currentUser()));
|
return $this->render('dashboard/index.html.twig', [
|
||||||
}
|
'tiles' => $this->dashboard->tiles($user, $today),
|
||||||
|
'upcoming' => $this->dashboard->upcoming($user, $today),
|
||||||
/**
|
'today' => $today,
|
||||||
* Picking a day -- or narrowing the span -- swaps the calendar and the floor plan in one
|
]);
|
||||||
* response.
|
|
||||||
*
|
|
||||||
* This is the whole reason htmxOobResponse exists: two regions, one round trip, no
|
|
||||||
* client-side event choreography. Requests without the htmx header get a plain redirect
|
|
||||||
* rather than a bare pair of fragments, so the URL stays shareable.
|
|
||||||
*/
|
|
||||||
#[Route('/tag/{date}', name: 'app_dashboard_day', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
|
|
||||||
public function day(Request $request, string $date): Response
|
|
||||||
{
|
|
||||||
$selected = DateParam::parseOrToday($date);
|
|
||||||
$time = self::timeOf($request);
|
|
||||||
|
|
||||||
if (!$request->headers->has('HX-Request')) {
|
|
||||||
return $this->redirectToRoute('app_dashboard', BoardPresenter::urlParameters($selected, $time));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->htmxOobResponse(
|
|
||||||
'dashboard/index.html.twig',
|
|
||||||
['calendar', 'floorplan'],
|
|
||||||
$this->board->context($selected, $time, $this->currentUser()),
|
|
||||||
$this->generateUrl('app_dashboard', BoardPresenter::urlParameters($selected, $time)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A malformed or missing span falls back to the whole day rather than erroring: these are
|
|
||||||
* hand-editable query parameters, not route requirements.
|
|
||||||
*/
|
|
||||||
public static function timeOf(Request $request): TimeRange
|
|
||||||
{
|
|
||||||
return TimeRange::parse($request->query->getString('von'), $request->query->getString('bis'))
|
|
||||||
?? TimeRange::wholeDay();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kinds of thing that can be booked.
|
||||||
|
*
|
||||||
|
* **This enum is the extension point.** The dashboard builds one tile per case, the board route
|
||||||
|
* accepts one slug per case, and the floor plan is looked up by convention from the case's
|
||||||
|
* value. Adding a third bookable kind is therefore: a case here, a
|
||||||
|
* `templates/board/plan/_<value>.html.twig` with its geometry, and rows in `app:seed`. Nothing
|
||||||
|
* else needs editing — in particular no template lists the kinds by hand.
|
||||||
|
*
|
||||||
|
* The backing values are persisted in `resource.type`, which is why they stay English while the
|
||||||
|
* URLs do not: `slug()` is what appears in a path, so the routes read German like the rest.
|
||||||
|
*/
|
||||||
enum ResourceType: string
|
enum ResourceType: string
|
||||||
{
|
{
|
||||||
case DESK = 'desk';
|
case DESK = 'desk';
|
||||||
@@ -16,4 +28,48 @@ enum ResourceType: string
|
|||||||
self::PARKING => 'Parkplatz',
|
self::PARKING => 'Parkplatz',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function plural(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DESK => 'Schreibtische',
|
||||||
|
self::PARKING => 'Parkplätze',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DESK => 'Einen Platz im Büro reservieren.',
|
||||||
|
self::PARKING => 'Einen Stellplatz reservieren.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function icon(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DESK => 'lucide:armchair',
|
||||||
|
self::PARKING => 'lucide:car',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What appears in a URL. */
|
||||||
|
public function slug(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::DESK => 'schreibtische',
|
||||||
|
self::PARKING => 'parkplaetze',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromSlug(string $slug): self
|
||||||
|
{
|
||||||
|
foreach (self::cases() as $case) {
|
||||||
|
if ($case->slug() === $slug) {
|
||||||
|
return $case;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \ValueError(\sprintf('"%s" ist kein bekannter Buchungsbereich.', $slug));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ class BookingRepository extends ServiceEntityRepository
|
|||||||
*
|
*
|
||||||
* @return list<Booking>
|
* @return list<Booking>
|
||||||
*/
|
*/
|
||||||
public function findForDate(\DateTimeImmutable $date, ?TimeRange $within = null): array
|
public function findForDate(\DateTimeImmutable $date, ?TimeRange $within = null, ?ResourceType $type = null): array
|
||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('b')
|
$qb = $this->createQueryBuilder('b')
|
||||||
->addSelect('u', 'r')
|
->addSelect('u', 'r')
|
||||||
@@ -47,6 +47,10 @@ class BookingRepository extends ServiceEntityRepository
|
|||||||
self::restrictToOverlap($qb, $within);
|
self::restrictToOverlap($qb, $within);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (null !== $type) {
|
||||||
|
$qb->andWhere('r.type = :type')->setParameter('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,9 +110,9 @@ class BookingRepository extends ServiceEntityRepository
|
|||||||
*
|
*
|
||||||
* @return list<Booking>
|
* @return list<Booking>
|
||||||
*/
|
*/
|
||||||
public function findForUserInRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): array
|
public function findForUserInRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to, ?ResourceType $type = null): array
|
||||||
{
|
{
|
||||||
return $this->createQueryBuilder('b')
|
$qb = $this->createQueryBuilder('b')
|
||||||
->addSelect('r', 's')
|
->addSelect('r', 's')
|
||||||
->join('b.resource', 'r')
|
->join('b.resource', 'r')
|
||||||
->leftJoin('b.series', 's')
|
->leftJoin('b.series', 's')
|
||||||
@@ -119,20 +123,29 @@ class BookingRepository extends ServiceEntityRepository
|
|||||||
->setParameter('to', $to->setTime(0, 0))
|
->setParameter('to', $to->setTime(0, 0))
|
||||||
->orderBy('b.date', 'ASC')
|
->orderBy('b.date', 'ASC')
|
||||||
->addOrderBy('b.startMinute', 'ASC')
|
->addOrderBy('b.startMinute', 'ASC')
|
||||||
->addOrderBy('r.label', 'ASC')
|
->addOrderBy('r.label', 'ASC');
|
||||||
->getQuery()
|
|
||||||
->getResult();
|
if (null !== $type) {
|
||||||
|
$qb->andWhere('r.type = :type')->setParameter('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bookings per day across a window, keyed by Y-m-d -- what the calendar grid renders as
|
* Bookings per day across a window, keyed by Y-m-d -- what the calendar grid renders as
|
||||||
* occupancy. Counts only what overlaps the span the board is currently showing, so the
|
* occupancy. Counts only what overlaps the span the board is currently showing, and only the
|
||||||
* number in a day cell always means the same thing as the colours on the plan.
|
* kind of resource that board is about, so the number in a day cell always means the same
|
||||||
|
* thing as the colours on the plan next to it.
|
||||||
*
|
*
|
||||||
* @return array<string, int>
|
* @return array<string, int>
|
||||||
*/
|
*/
|
||||||
public function countPerDateInRange(\DateTimeImmutable $from, \DateTimeImmutable $to, ?TimeRange $within = null): array
|
public function countPerDateInRange(
|
||||||
{
|
\DateTimeImmutable $from,
|
||||||
|
\DateTimeImmutable $to,
|
||||||
|
?TimeRange $within = null,
|
||||||
|
?ResourceType $type = null,
|
||||||
|
): array {
|
||||||
$qb = $this->createQueryBuilder('b')
|
$qb = $this->createQueryBuilder('b')
|
||||||
->select('b.date AS date', 'COUNT(b.id) AS total')
|
->select('b.date AS date', 'COUNT(b.id) AS total')
|
||||||
->andWhere('b.date BETWEEN :from AND :to')
|
->andWhere('b.date BETWEEN :from AND :to')
|
||||||
@@ -144,6 +157,10 @@ class BookingRepository extends ServiceEntityRepository
|
|||||||
self::restrictToOverlap($qb, $within);
|
self::restrictToOverlap($qb, $within);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (null !== $type) {
|
||||||
|
$qb->join('b.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
$counts = [];
|
$counts = [];
|
||||||
foreach ($qb->getQuery()->getResult() as $row) {
|
foreach ($qb->getQuery()->getResult() as $row) {
|
||||||
$counts[$row['date']->format('Y-m-d')] = (int) $row['total'];
|
$counts[$row['date']->format('Y-m-d')] = (int) $row['total'];
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
{# Plain nav instead of myep's knp-menu builder: a handful of links does not justify the
|
|
||||||
indirection. Active state is a prefix match on the current route name, which mirrors what
|
|
||||||
myep's menu matcher does with its 'routes' extras. #}
|
|
||||||
{% set current_route = app.request.attributes.get('_route') %}
|
|
||||||
{% set items = [
|
|
||||||
{ route: 'app_dashboard', label: 'Übersicht', icon: 'lucide:layout-grid' },
|
|
||||||
{ route: 'app_booking', label: 'Meine Buchungen', icon: 'lucide:calendar-days' },
|
|
||||||
] %}
|
|
||||||
|
|
||||||
<div class="min-h-full flex grow flex-col gap-y-5 lg:border-r border-gray-200 bg-white md:pr-6">
|
|
||||||
<nav class="flex flex-1 flex-col">
|
|
||||||
<ul role="list" class="space-y-1">
|
|
||||||
{% for item in items %}
|
|
||||||
{% set is_active = current_route starts with item.route %}
|
|
||||||
<li>
|
|
||||||
<a href="{{ path(item.route) }}"
|
|
||||||
class="group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold {{ is_active ? 'bg-gray-100 text-gray-900' : 'text-gray-700 hover:bg-gray-50' }}">
|
|
||||||
{{ ux_icon(item.icon, { class: 'h-5 w-5 shrink-0 ' ~ (is_active ? 'text-gray-600' : 'text-gray-400') }) }}
|
|
||||||
<span class="flex-1">{{ item.label }}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{% if app.user %}
|
|
||||||
<div class="mt-auto pt-6 border-t border-gray-200">
|
|
||||||
<div class="px-2 pb-2 text-sm">
|
|
||||||
<div class="font-semibold">{{ app.user.name }}</div>
|
|
||||||
<div class="text-gray-500 truncate">{{ app.user.email }}</div>
|
|
||||||
</div>
|
|
||||||
<a href="{{ path('app_logout') }}"
|
|
||||||
class="group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold text-gray-700 hover:bg-gray-50">
|
|
||||||
{{ ux_icon('lucide:log-out', { class: 'h-5 w-5 shrink-0 text-gray-400' }) }}
|
|
||||||
<span class="flex-1">Abmelden</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
class="p-1 rounded-md hover:bg-gray-100"
|
class="p-1 rounded-md hover:bg-gray-100"
|
||||||
aria-label="Voriger Monat"
|
aria-label="Voriger Monat"
|
||||||
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('-1 month')|date('Y-m-d') }|merge(time_params)) }}"
|
hx-get="{{ path('app_board_day', { type: type.slug, date: month_start|date_modify('-1 month')|date('Y-m-d') }|merge(time_params)) }}"
|
||||||
hx-swap="none">
|
hx-swap="none">
|
||||||
{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}
|
{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}
|
||||||
</button>
|
</button>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
class="p-1 rounded-md hover:bg-gray-100"
|
class="p-1 rounded-md hover:bg-gray-100"
|
||||||
aria-label="Nächster Monat"
|
aria-label="Nächster Monat"
|
||||||
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('+1 month')|date('Y-m-d') }|merge(time_params)) }}"
|
hx-get="{{ path('app_board_day', { type: type.slug, date: month_start|date_modify('+1 month')|date('Y-m-d') }|merge(time_params)) }}"
|
||||||
hx-swap="none">
|
hx-swap="none">
|
||||||
{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}
|
{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}
|
||||||
</button>
|
</button>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
class="{{ classes }}"
|
class="{{ classes }}"
|
||||||
aria-pressed="{{ day.is_selected ? 'true' : 'false' }}"
|
aria-pressed="{{ day.is_selected ? 'true' : 'false' }}"
|
||||||
hx-get="{{ path('app_dashboard_day', { date: day.date|date('Y-m-d') }|merge(time_params)) }}"
|
hx-get="{{ path('app_board_day', { type: type.slug, date: day.date|date('Y-m-d') }|merge(time_params)) }}"
|
||||||
hx-swap="none">
|
hx-swap="none">
|
||||||
<span>{{ day.date|date('j') }}</span>
|
<span>{{ day.date|date('j') }}</span>
|
||||||
{# Team view: the occupancy count is the whole point of showing the month. #}
|
{# Team view: the occupancy count is the whole point of showing the month. #}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{#
|
||||||
|
The frame around a plan: heading, legend, span picker. The drawing itself lives in
|
||||||
|
board/plan/_<type>.html.twig and is included by convention, so a new ResourceType needs a new
|
||||||
|
partial there and nothing else.
|
||||||
|
|
||||||
|
Colouring needs no JavaScript at all: the plan is re-rendered server side for the selected day
|
||||||
|
and span, so free/belegt/deine is decided in Twig. Note "free" is always relative to the span
|
||||||
|
in the header -- a desk booked 09:00-13:00 is free for an afternoon board and belegt for an
|
||||||
|
all-day one.
|
||||||
|
#}
|
||||||
|
<div class="rounded-md border border-gray-200 p-4">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-2 pb-2">
|
||||||
|
<h2 class="!pb-0">{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}</h2>
|
||||||
|
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||||
|
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-emerald-50 border-2 border-emerald-500"></span> frei</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-gray-200 border-2 border-gray-400"></span> belegt</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-primary border-2 border-primary-dark"></span> deine</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include 'board/_timerange.html.twig' %}
|
||||||
|
|
||||||
|
{% if is_past %}
|
||||||
|
<p class="pb-4 text-sm text-amber-700">Vergangener Tag — hier lässt sich nichts mehr buchen.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if resources is empty %}
|
||||||
|
<p class="py-8 text-center text-sm text-gray-500">Für diesen Bereich ist noch nichts eingerichtet.</p>
|
||||||
|
{% else %}
|
||||||
|
{% include 'board/plan/_' ~ type.value ~ '.html.twig' %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<label class="block text-xs text-gray-500 pb-1" for="board-von">Von</label>
|
<label class="block text-xs text-gray-500 pb-1" for="board-von">Von</label>
|
||||||
<input type="time" id="board-von" name="von" value="{{ selected_time.allDay ? '' : selected_time.start }}" step="900"
|
<input type="time" id="board-von" name="von" value="{{ selected_time.allDay ? '' : selected_time.start }}" step="900"
|
||||||
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
|
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
|
||||||
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
|
hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
|
||||||
hx-include="#board-bis"
|
hx-include="#board-bis"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-swap="none">
|
hx-swap="none">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<label class="block text-xs text-gray-500 pb-1" for="board-bis">Bis</label>
|
<label class="block text-xs text-gray-500 pb-1" for="board-bis">Bis</label>
|
||||||
<input type="time" id="board-bis" name="bis" value="{{ selected_time.allDay ? '' : selected_time.end }}" step="900"
|
<input type="time" id="board-bis" name="bis" value="{{ selected_time.allDay ? '' : selected_time.end }}" step="900"
|
||||||
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
|
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
|
||||||
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
|
hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
|
||||||
hx-include="#board-von"
|
hx-include="#board-von"
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-swap="none">
|
hx-swap="none">
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
{% if not selected_time.allDay %}
|
{% if not selected_time.allDay %}
|
||||||
<button type="button" class="button button--small button--neutral"
|
<button type="button" class="button button--small button--neutral"
|
||||||
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
|
hx-get="{{ path('app_board_day', { type: type.slug, date: day }) }}"
|
||||||
hx-swap="none">
|
hx-swap="none">
|
||||||
Ganzer Tag
|
Ganzer Tag
|
||||||
</button>
|
</button>
|
||||||
@@ -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 title %}Meine Buchungen · ephub{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Meine Buchungen</h1>
|
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
|
||||||
|
|
||||||
|
<h1 class="pt-2">Meine Buchungen</h1>
|
||||||
<p class="subline">Alles, was ab heute für dich reserviert ist.</p>
|
<p class="subline">Alles, was ab heute für dich reserviert ist.</p>
|
||||||
|
|
||||||
{% if series is not empty %}
|
{% if series is not empty %}
|
||||||
@@ -32,7 +34,7 @@
|
|||||||
|
|
||||||
{% if bookings is empty %}
|
{% if bookings is empty %}
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
Noch nichts gebucht. <a class="underline" href="{{ path('app_dashboard') }}">Zum Raumplan</a>.
|
Noch nichts gebucht. <a class="underline" href="{{ path('app_dashboard') }}">Zur Übersicht</a>.
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<ul class="divide-y divide-gray-200 border-y border-gray-200">
|
<ul class="divide-y divide-gray-200 border-y border-gray-200">
|
||||||
@@ -55,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a class="text-sm underline"
|
<a class="text-sm underline"
|
||||||
href="{{ path('app_dashboard', { tag: booking.date|date('Y-m-d') }) }}">Im Plan</a>
|
href="{{ path('app_board', { type: booking.resource.type.slug, tag: booking.date|date('Y-m-d') }) }}">Im Plan</a>
|
||||||
{# Ordinary form post: this page has no board fragments to swap, so it
|
{# Ordinary form post: this page has no board fragments to swap, so it
|
||||||
reloads afterwards rather than pretending to be htmx. #}
|
reloads afterwards rather than pretending to be htmx. #}
|
||||||
<form method="post" action="{{ path('app_booking_release', { id: booking.id }) }}">
|
<form method="post" action="{{ path('app_booking_release', { id: booking.id }) }}">
|
||||||
|
|||||||
@@ -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 title %}Übersicht · ephub{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Buchen</h1>
|
<h1>Übersicht</h1>
|
||||||
<p class="subline">Tag und Zeitraum wählen, dann einen freien Platz im Raumplan anklicken.</p>
|
<p class="subline">{{ today|format_datetime(pattern: 'EEEE, d. MMMM y') }}</p>
|
||||||
|
|
||||||
<div class="mt-8 grid gap-8 xl:grid-cols-3">
|
{#
|
||||||
<div class="xl:col-span-1">
|
One tile per ResourceType, generated rather than written out. A new bookable kind appears
|
||||||
{#
|
here -- with a working link and a correct count -- by adding a case to the enum.
|
||||||
The two blocks below are what DashboardController::day and BookingController
|
#}
|
||||||
swap out of band. Each one owns a stable id and emits hx-swap-oob only when
|
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
rendered through htmxOobResponse -- rendered normally they are plain markup.
|
{% for tile in tiles %}
|
||||||
#}
|
<a href="{{ path('app_board', { type: tile.type.slug }) }}"
|
||||||
{% block calendar %}
|
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
|
||||||
<div id="board-calendar"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
|
<div class="flex items-center gap-3 pb-2">
|
||||||
{% include 'dashboard/_calendar.html.twig' %}
|
{{ ux_icon(tile.type.icon, { class: 'h-6 w-6 shrink-0 text-primary' }) }}
|
||||||
|
<span class="text-lg font-bold">{{ tile.type.plural }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
<p class="text-sm text-gray-500 pb-4">{{ tile.type.description }}</p>
|
||||||
{% block floorplan %}
|
|
||||||
<div id="board-floorplan"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
|
<div class="mt-auto flex items-baseline justify-between">
|
||||||
{% include 'dashboard/_floorplan.html.twig' %}
|
<span class="text-sm">
|
||||||
|
{% if tile.total == 0 %}
|
||||||
|
<span class="text-gray-400">nichts eingerichtet</span>
|
||||||
|
{% elseif tile.free == 0 %}
|
||||||
|
<span class="font-semibold text-red-600">heute alles belegt</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="font-semibold text-emerald-700">{{ tile.free }}</span>
|
||||||
|
<span class="text-gray-500">von {{ tile.total }} heute frei</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% if tile.mine %}
|
||||||
|
<span class="badge bg-primary text-white">{{ tile.mine.resource.label }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
</a>
|
||||||
</div>
|
{% endfor %}
|
||||||
|
|
||||||
|
<a href="{{ path('app_booking') }}"
|
||||||
|
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
|
||||||
|
<div class="flex items-center gap-3 pb-2">
|
||||||
|
{{ ux_icon('lucide:calendar-days', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
|
||||||
|
<span class="text-lg font-bold">Meine Buchungen</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 pb-4">Alle Termine und Serien verwalten.</p>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="pt-10">Als Nächstes</h2>
|
||||||
|
|
||||||
|
{% if upcoming is empty %}
|
||||||
|
<p class="text-sm text-gray-500">Nichts gebucht.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="divide-y divide-gray-200 border-y border-gray-200">
|
||||||
|
{% for booking in upcoming %}
|
||||||
|
<li class="flex items-center justify-between gap-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM') }}
|
||||||
|
<span class="font-normal text-gray-500">· {{ booking.timeRange }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{{ booking.resource.type.label }} · {{ booking.resource.label }}
|
||||||
|
{% if booking.partOfSeries %}
|
||||||
|
<span class="badge bg-gray-100 text-gray-600 ml-1">Serie</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="text-sm underline shrink-0"
|
||||||
|
href="{{ path('app_board', { type: booking.resource.type.slug, tag: booking.date|date('Y-m-d') }) }}">
|
||||||
|
Im Plan
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+20
-30
@@ -1,42 +1,32 @@
|
|||||||
{% extends 'base.html.twig' %}
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
The shell. Deliberately has no navigation: the dashboard at / is how you get anywhere, so the
|
||||||
|
only things up here are the way back to it and the way out. A booking board reached from a
|
||||||
|
tile links back with its own breadcrumb.
|
||||||
|
#}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="max-w-screen-2xl mx-auto px-4 lg:px-8 lg:grid lg:grid-cols-4 gap-8 py-8">
|
<div class="border-b border-gray-200">
|
||||||
<div class="lg:col-span-4 flex items-center justify-between pb-4 lg:pb-0">
|
<div class="max-w-screen-2xl mx-auto px-4 lg:px-8 h-16 flex items-center justify-between gap-4">
|
||||||
<a href="{{ path('app_dashboard') }}">
|
<a href="{{ path('app_dashboard') }}" class="shrink-0">
|
||||||
<img class="h-8 w-auto" src="{{ asset('images/logo_ep.svg') }}" alt="ephub">
|
<img class="h-8 w-auto" src="{{ asset('images/logo_ep.svg') }}" alt="ephub">
|
||||||
</a>
|
</a>
|
||||||
<button type="button"
|
|
||||||
class="lg:hidden"
|
|
||||||
{{ stimulus_controller('mobilenav', [], [], { 'mobilenav': '#mobilenav' }) }}
|
|
||||||
{{ stimulus_action('mobilenav', 'trigger', null, { 'mode': 'open' }) }}>
|
|
||||||
{{ ux_icon('lucide:menu', { class: 'w-8 h-8 text-gray-700' }) }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hidden lg:block sticky top-0 h-screen">
|
{% if app.user %}
|
||||||
{% include '_partials/_nav.html.twig' %}
|
<div class="flex items-center gap-4">
|
||||||
</div>
|
<span class="hidden sm:block text-sm text-gray-500">{{ app.user.name }}</span>
|
||||||
|
<a href="{{ path('app_logout') }}"
|
||||||
<div class="lg:col-span-3">
|
class="flex items-center gap-2 text-sm font-semibold text-gray-700 hover:text-gray-900">
|
||||||
{% block content %}{% endblock %}
|
{{ ux_icon('lucide:log-out', { class: 'h-5 w-5 shrink-0 text-gray-400' }) }}
|
||||||
|
<span class="hidden sm:inline">Abmelden</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="mobilenav"
|
<div class="max-w-screen-2xl mx-auto px-4 lg:px-8 py-8">
|
||||||
class="fixed z-50 top-0 left-0 inset-0 pr-16 bg-black/70 -translate-x-full"
|
{% block content %}{% endblock %}
|
||||||
{{ stimulus_controller('mobilenav', [], { 'closed': '-translate-x-full' }, { 'mobilenav': '#mobilenav' }) }}>
|
|
||||||
<div class="h-screen w-full bg-white">
|
|
||||||
<div class="flex justify-between p-8">
|
|
||||||
<img class="h-8 w-auto" src="{{ asset('images/logo_ep.svg') }}" alt="">
|
|
||||||
<button type="button" {{ stimulus_action('mobilenav', 'trigger', null, { 'mode': 'close' }) }}>
|
|
||||||
{{ ux_icon('lucide:x', { class: 'w-8 h-8' }) }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="w-full h-full p-8 pt-0 overflow-y-scroll">
|
|
||||||
{% include '_partials/_nav.html.twig' %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Swapped out of band after a booking, hence the stable id and the conditional attribute. #}
|
{# Swapped out of band after a booking, hence the stable id and the conditional attribute. #}
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ class BookingControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$this->client->request('GET', '/tag/2026-10-01', server: ['HTTP_HX-Request' => 'true']);
|
$this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01', server: ['HTTP_HX-Request' => 'true']);
|
||||||
$response = $this->client->getResponse();
|
$response = $this->client->getResponse();
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
self::assertSame('/?tag=2026-10-01', $response->headers->get('HX-Push-Url'));
|
self::assertSame('/buchen/schreibtische?tag=2026-10-01', $response->headers->get('HX-Push-Url'));
|
||||||
self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"'));
|
self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"'));
|
||||||
self::assertStringContainsString('id="board-calendar"', (string) $response->getContent());
|
self::assertStringContainsString('id="board-calendar"', (string) $response->getContent());
|
||||||
self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent());
|
self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent());
|
||||||
@@ -58,9 +58,9 @@ class BookingControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$this->client->request('GET', '/tag/2026-10-01');
|
$this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01');
|
||||||
|
|
||||||
self::assertResponseRedirects('/?tag=2026-10-01');
|
self::assertResponseRedirects('/buchen/schreibtische?tag=2026-10-01');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,7 +205,7 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->client->loginUser($anna);
|
$this->client->loginUser($anna);
|
||||||
$crawler = $this->client->request('GET', '/?tag=2026-10-01');
|
$crawler = $this->client->request('GET', '/buchen/schreibtische?tag=2026-10-01');
|
||||||
|
|
||||||
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/buchungen/%d/bearbeiten"]', $booking->getId())));
|
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/buchungen/%d/bearbeiten"]', $booking->getId())));
|
||||||
}
|
}
|
||||||
@@ -351,10 +351,10 @@ class BookingControllerTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
|
|
||||||
$this->client->request('GET', '/tag/2026-10-01?von=09:00&bis=13:00', server: ['HTTP_HX-Request' => 'true']);
|
$this->client->request('GET', '/buchen/schreibtische/tag/2026-10-01?von=09:00&bis=13:00', server: ['HTTP_HX-Request' => 'true']);
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
self::assertSame('/?tag=2026-10-01&von=09:00&bis=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
|
self::assertSame('/buchen/schreibtische?tag=2026-10-01&von=09:00&bis=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void
|
public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Booking\TimeRange;
|
||||||
|
use App\Entity\Booking;
|
||||||
|
use App\Entity\Resource;
|
||||||
|
use App\Entity\ResourceType;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
class DashboardControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private KernelBrowser $client;
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->client = self::createClient();
|
||||||
|
$this->client->disableReboot();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$this->em->getConnection()->beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->em->getConnection()->rollBack();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The extensibility guarantee, asserted against the enum rather than against a fixed list:
|
||||||
|
* a new ResourceType case must produce a working tile without anyone editing a template.
|
||||||
|
*/
|
||||||
|
public function testThereIsATileForEveryBookableKind(): void
|
||||||
|
{
|
||||||
|
$this->client->loginUser($this->user());
|
||||||
|
$crawler = $this->client->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
foreach (ResourceType::cases() as $type) {
|
||||||
|
self::assertCount(
|
||||||
|
1,
|
||||||
|
$crawler->filter(\sprintf('a[href="/buchen/%s"]', $type->slug())),
|
||||||
|
\sprintf('no dashboard tile links to the %s board', $type->slug()),
|
||||||
|
);
|
||||||
|
self::assertStringContainsString($type->plural(), $crawler->html());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bookings list is only reachable from here now that the sidebar is gone.
|
||||||
|
*/
|
||||||
|
public function testTheDashboardLinksToTheBookingsList(): void
|
||||||
|
{
|
||||||
|
$this->client->loginUser($this->user());
|
||||||
|
$crawler = $this->client->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertCount(1, $crawler->filter('a[href="/buchungen"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheNavigationIsGone(): void
|
||||||
|
{
|
||||||
|
$this->client->loginUser($this->user());
|
||||||
|
$crawler = $this->client->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertCount(0, $crawler->filter('#mobilenav'));
|
||||||
|
self::assertCount(1, $crawler->filter('a[href="/logout"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testATileCountsWhatIsStillFreeToday(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$today = new \DateTimeImmutable('today');
|
||||||
|
|
||||||
|
$this->resource('D1');
|
||||||
|
$taken = $this->resource('D2');
|
||||||
|
$this->em->persist(new Booking($this->user('[email protected]'), $taken, $today));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->client->loginUser($user);
|
||||||
|
$crawler = $this->client->request('GET', '/');
|
||||||
|
|
||||||
|
$tile = $crawler->filter('a[href="/buchen/schreibtische"]')->text();
|
||||||
|
self::assertStringContainsString('von 2 heute frei', $tile);
|
||||||
|
self::assertStringContainsString('1', $tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A part-day booking still makes the resource "not free today" -- the dashboard has no span
|
||||||
|
* to qualify the answer with, so it takes the whole day.
|
||||||
|
*/
|
||||||
|
public function testAPartDayBookingCountsAsTakenForTheDay(): void
|
||||||
|
{
|
||||||
|
$desk = $this->resource('D1');
|
||||||
|
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('today'), TimeRange::fromMinutes(540, 780)));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->client->loginUser($this->user());
|
||||||
|
$crawler = $this->client->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertStringContainsString('heute alles belegt', $crawler->filter('a[href="/buchen/schreibtische"]')->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testYourOwnBookingIsCalledOutOnTheTile(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$this->em->persist(new Booking($user, $this->resource('D1'), new \DateTimeImmutable('today')));
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->client->loginUser($user);
|
||||||
|
$crawler = $this->client->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertStringContainsString('D1', $crawler->filter('a[href="/buchen/schreibtische"]')->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testABoardOnlyShowsItsOwnKindOfResource(): void
|
||||||
|
{
|
||||||
|
$this->resource('D1', svgElementId: 'desk-01');
|
||||||
|
$this->resource('P1', ResourceType::PARKING, 'parking-01');
|
||||||
|
|
||||||
|
$this->client->loginUser($this->user());
|
||||||
|
|
||||||
|
$desks = $this->client->request('GET', '/buchen/schreibtische')->html();
|
||||||
|
self::assertStringContainsString('D1', $desks);
|
||||||
|
self::assertStringNotContainsString('P1', $desks);
|
||||||
|
|
||||||
|
$parking = $this->client->request('GET', '/buchen/parkplaetze')->html();
|
||||||
|
self::assertStringContainsString('P1', $parking);
|
||||||
|
self::assertStringNotContainsString('D1', $parking);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnUnknownBookingAreaIsNotFound(): void
|
||||||
|
{
|
||||||
|
$this->client->loginUser($this->user());
|
||||||
|
$this->client->request('GET', '/buchen/fahrraeder');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testABoardWithNothingSetUpSaysSo(): void
|
||||||
|
{
|
||||||
|
$this->client->loginUser($this->user());
|
||||||
|
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'noch nichts eingerichtet',
|
||||||
|
$this->client->request('GET', '/buchen/parkplaetze')->html(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function user(string $email = '[email protected]'): User
|
||||||
|
{
|
||||||
|
$user = new User($email, 'Test');
|
||||||
|
$user->setRoles(['ROLE_EMPLOYEE']);
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource
|
||||||
|
{
|
||||||
|
$resource = new Resource($type, $label, $svgElementId);
|
||||||
|
$this->em->persist($resource);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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