feat: group resources into rooms on the floor plan

This commit is contained in:
fromme committed 2026-09-14 08:25:05 +02:00
1 parent faef4d70a8
commit 8caf618950
14 files changed
+653 -34

No files matched your search

+2
View File
@@ -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')] ?? [],
];
}
+85
View File
@@ -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
View File
@@ -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
*/
+18
View File
@@ -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;
+96
View File
@@ -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();
}
}
+10 -1
View File
@@ -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();
}