feat: group resources into rooms on the floor plan
This commit is contained in:
1 parent
faef4d70a8
commit
8caf618950
14 files changed
+653
-34
No files matched your search
@@ -94,6 +94,8 @@ final readonly class BoardPresenter
|
||||
'is_past' => $selected < $today,
|
||||
'resources' => $resources,
|
||||
'spots' => $spots,
|
||||
// The same spots again, partitioned into the rooms the plan frames them with.
|
||||
'spot_groups' => SpotGroup::over(array_values($spots)),
|
||||
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
|
||||
use App\Entity\ResourceGroup;
|
||||
|
||||
/**
|
||||
* The spots of one room, in draw order.
|
||||
*
|
||||
* The floor plan needs the resources of a room together so it can frame them, and it needs the
|
||||
* room's own label and geometry key alongside them -- which is all this is. The grouping is done
|
||||
* once in the presenter rather than in Twig, because deciding what goes where is not a
|
||||
* presentation concern and a template cannot express it without a nested loop over every spot
|
||||
* per room.
|
||||
*/
|
||||
final readonly class SpotGroup
|
||||
{
|
||||
/**
|
||||
* @param list<Spot> $spots
|
||||
*/
|
||||
private function __construct(
|
||||
public ?ResourceGroup $group,
|
||||
public array $spots,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions spots by their resource's group, preserving the order they arrive in.
|
||||
*
|
||||
* Ungrouped spots collect into a single trailing entry whose group is null. That entry is
|
||||
* not an error case: a kind of resource that is not split across rooms has no groups at all,
|
||||
* and its plan draws its own frame instead.
|
||||
*
|
||||
* @param list<Spot> $spots
|
||||
*
|
||||
* @return list<self>
|
||||
*/
|
||||
public static function over(array $spots): array
|
||||
{
|
||||
$grouped = [];
|
||||
$ungrouped = [];
|
||||
|
||||
foreach ($spots as $spot) {
|
||||
$group = $spot->resource->getGroup();
|
||||
|
||||
if (null === $group) {
|
||||
$ungrouped[] = $spot;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keyed by object rather than by id: Doctrine's identity map already guarantees one
|
||||
// instance per row, so this groups exactly as an id would -- and it also works for a
|
||||
// group that has not been persisted yet, where every id is still null and every
|
||||
// group would otherwise collapse into one. The repository orders by group, so
|
||||
// insertion order is already draw order.
|
||||
$grouped[spl_object_id($group)][] = $spot;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($grouped as $spotsOfGroup) {
|
||||
$result[] = new self($spotsOfGroup[0]->resource->getGroup(), $spotsOfGroup);
|
||||
}
|
||||
|
||||
if ([] !== $ungrouped) {
|
||||
$result[] = new self(null, $ungrouped);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of these can still be booked for the whole of the board's span.
|
||||
*
|
||||
* Deliberately FREE only, not isBookable(): the badge reads "3 frei", and a partly booked
|
||||
* desk is already amber on the plan. Counting those too would make the number disagree with
|
||||
* the colours next to it.
|
||||
*/
|
||||
public function freeCount(): int
|
||||
{
|
||||
return \count(array_filter($this->spots, static fn (Spot $spot): bool => SpotState::FREE === $spot->state));
|
||||
}
|
||||
}
|
||||
+63
-10
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Repository\ResourceGroupRepository;
|
||||
use App\Repository\ResourceRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
@@ -19,12 +21,15 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\When;
|
||||
|
||||
/**
|
||||
* Seeds the desks, parking spots and a handful of employees.
|
||||
* Seeds the office rooms, the desks, the parking spots and a handful of employees.
|
||||
*
|
||||
* 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/board/plan/_<type>.html.twig -- change one and the other stops
|
||||
* drawing that resource.
|
||||
* Dev only, and idempotent: it matches on the natural key (group and resource label, user email)
|
||||
* so it can be re-run after a migration without duplicating anything. The svgElementId values --
|
||||
* on both resources and groups -- are the contract with
|
||||
* templates/board/plan/_<type>.html.twig: change one and the other stops drawing it.
|
||||
*
|
||||
* The room names and their desk membership are placeholders standing in for the real office
|
||||
* layout, and they only make sense together with the geometry in _desk.html.twig.
|
||||
*/
|
||||
#[When('dev')]
|
||||
#[AsCommand(name: 'app:seed', description: 'Seeds desks, parking spots and demo employees (dev only)')]
|
||||
@@ -33,6 +38,7 @@ final class SeedCommand extends Command
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ResourceRepository $resources,
|
||||
private readonly ResourceGroupRepository $groups,
|
||||
private readonly UserRepository $users,
|
||||
) {
|
||||
parent::__construct();
|
||||
@@ -44,10 +50,30 @@ final class SeedCommand extends Command
|
||||
|
||||
$created = 0;
|
||||
|
||||
for ($i = 1; $i <= 12; ++$i) {
|
||||
$created += $this->ensureResource(ResourceType::DESK, \sprintf('Schreibtisch %02d', $i), \sprintf('desk-%02d', $i));
|
||||
// Four rooms of three desks, in the order they are drawn down the plan.
|
||||
$rooms = [];
|
||||
foreach ([
|
||||
['Raum Nord', 'room-01'],
|
||||
['Raum Ost', 'room-02'],
|
||||
['Raum Süd', 'room-03'],
|
||||
['Besprechungsraum', 'room-04'],
|
||||
] as $position => [$label, $svgElementId]) {
|
||||
[$room, $isNew] = $this->ensureGroup(ResourceType::DESK, $label, $position, $svgElementId);
|
||||
$rooms[] = $room;
|
||||
$created += $isNew;
|
||||
}
|
||||
|
||||
for ($i = 1; $i <= 12; ++$i) {
|
||||
$created += $this->ensureResource(
|
||||
ResourceType::DESK,
|
||||
\sprintf('Schreibtisch %02d', $i),
|
||||
\sprintf('desk-%02d', $i),
|
||||
$rooms[intdiv($i - 1, 3)],
|
||||
);
|
||||
}
|
||||
|
||||
// Parking is deliberately left ungrouped: the car park is one area, and it is what keeps
|
||||
// the "a plan without groups draws its own frame" path exercised by real data.
|
||||
for ($i = 1; $i <= 6; ++$i) {
|
||||
$created += $this->ensureResource(ResourceType::PARKING, \sprintf('Parkplatz %02d', $i), \sprintf('parking-%02d', $i));
|
||||
}
|
||||
@@ -71,17 +97,44 @@ final class SeedCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function ensureResource(ResourceType $type, string $label, string $svgElementId): int
|
||||
private function ensureResource(ResourceType $type, string $label, string $svgElementId, ?ResourceGroup $group = null): int
|
||||
{
|
||||
if (null !== $this->resources->findOneBy(['type' => $type, 'label' => $label])) {
|
||||
$resource = $this->resources->findOneBy(['type' => $type, 'label' => $label]);
|
||||
|
||||
if (null !== $resource) {
|
||||
// Backfill only. A resource seeded before groups existed has no room and would never
|
||||
// be drawn inside one, but a group somebody moved it into by hand is left alone.
|
||||
if (null !== $group && null === $resource->getGroup()) {
|
||||
$resource->setGroup($group);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->em->persist(new Resource($type, $label, $svgElementId));
|
||||
$resource = new Resource($type, $label, $svgElementId);
|
||||
$resource->setGroup($group);
|
||||
$this->em->persist($resource);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ResourceGroup, int} the group, and 1 if it was created
|
||||
*/
|
||||
private function ensureGroup(ResourceType $type, string $label, int $position, string $svgElementId): array
|
||||
{
|
||||
$group = $this->groups->findOneBy(['type' => $type, 'label' => $label]);
|
||||
|
||||
if (null !== $group) {
|
||||
return [$group, 0];
|
||||
}
|
||||
|
||||
$group = new ResourceGroup($type, $label, $position, $svgElementId);
|
||||
$this->em->persist($group);
|
||||
|
||||
return [$group, 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
|
||||
@@ -34,6 +34,14 @@ class Resource
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $svgElementId = null;
|
||||
|
||||
/**
|
||||
* Where it physically is. Nullable because a group is optional: a kind of resource that is
|
||||
* not split across rooms simply has none, and its plan draws its own single frame.
|
||||
*/
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ResourceGroup $group = null;
|
||||
|
||||
/**
|
||||
* Retired resources stay in the table so historical bookings keep resolving; they are just
|
||||
* not offered any more.
|
||||
@@ -78,6 +86,16 @@ class Resource
|
||||
$this->svgElementId = $svgElementId;
|
||||
}
|
||||
|
||||
public function getGroup(): ?ResourceGroup
|
||||
{
|
||||
return $this->group;
|
||||
}
|
||||
|
||||
public function setGroup(?ResourceGroup $group): void
|
||||
{
|
||||
$this->group = $group;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->active;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ResourceGroupRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Where a bookable thing physically is: a room of the office, an area of the car park.
|
||||
*
|
||||
* A group belongs to exactly one ResourceType, because "Raum Nord" means nothing for parking.
|
||||
* It is a description of the building, so it is configuration seeded alongside the resources
|
||||
* rather than something a user creates.
|
||||
*
|
||||
* Note there is deliberately no method named `group`, `label` or `position` other than the plain
|
||||
* accessors: Twig resolves `x.label` to a method of that literal name before trying getLabel(),
|
||||
* and static or not it would win. See handover.md.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ResourceGroupRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_resource_group_label', columns: ['type', 'label'])]
|
||||
class ResourceGroup
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(enumType: ResourceType::class)]
|
||||
private ResourceType $type;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Assert\NotBlank]
|
||||
private string $label;
|
||||
|
||||
/** Draw and list order within the type. */
|
||||
#[ORM\Column]
|
||||
private int $position = 0;
|
||||
|
||||
/**
|
||||
* Id of the matching frame in the hand-authored floor plan SVG -- the same role svgElementId
|
||||
* plays on Resource. Null for a group that is not drawn.
|
||||
*/
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $svgElementId = null;
|
||||
|
||||
public function __construct(ResourceType $type, string $label, int $position = 0, ?string $svgElementId = null)
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->label = $label;
|
||||
$this->position = $position;
|
||||
$this->svgElementId = $svgElementId;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getType(): ResourceType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): void
|
||||
{
|
||||
$this->label = $label;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): void
|
||||
{
|
||||
$this->position = $position;
|
||||
}
|
||||
|
||||
public function getSvgElementId(): ?string
|
||||
{
|
||||
return $this->svgElementId;
|
||||
}
|
||||
|
||||
public function setSvgElementId(?string $svgElementId): void
|
||||
{
|
||||
$this->svgElementId = $svgElementId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Entity\ResourceType;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ResourceGroup>
|
||||
*/
|
||||
class ResourceGroupRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ResourceGroup::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ResourceGroup>
|
||||
*/
|
||||
public function findByType(ResourceType $type): array
|
||||
{
|
||||
return $this->createQueryBuilder('g')
|
||||
->andWhere('g.type = :type')
|
||||
->setParameter('type', $type)
|
||||
->orderBy('g.position', 'ASC')
|
||||
->addOrderBy('g.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,20 @@ class ResourceRepository extends ServiceEntityRepository
|
||||
*/
|
||||
public function findActiveByType(ResourceType $type): array
|
||||
{
|
||||
// The group is selected, not just joined: the floor plan reads resource.group.label once
|
||||
// per shape, so a lazy association would be one query per desk -- the same N+1 that
|
||||
// findForDate() eager-loads user and resource to avoid.
|
||||
return $this->createQueryBuilder('r')
|
||||
->leftJoin('r.group', 'g')
|
||||
->addSelect('g')
|
||||
->andWhere('r.active = true')
|
||||
->andWhere('r.type = :type')
|
||||
->setParameter('type', $type)
|
||||
->orderBy('r.label', 'ASC')
|
||||
// Ungrouped resources sort last: MariaDB orders NULL first ascending, and a stray
|
||||
// resource with no room should not open the plan.
|
||||
->orderBy('CASE WHEN g.id IS NULL THEN 1 ELSE 0 END', 'ASC')
|
||||
->addOrderBy('g.position', 'ASC')
|
||||
->addOrderBy('r.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user