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
@@ -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'];
|
||||
|
||||
Reference in new issue
Block a user