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
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260914091500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds resource groups (rooms of the office, areas of the car park)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE resource_group (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(255) NOT NULL, label VARCHAR(50) NOT NULL, position INT NOT NULL, svg_element_id VARCHAR(50) DEFAULT NULL, UNIQUE INDEX uniq_resource_group_label (type, label), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE resource ADD group_id INT DEFAULT NULL');
|
||||
// SET NULL rather than RESTRICT: a group is configuration describing the building, and
|
||||
// removing a room must leave its desks bookable rather than blocking the delete. The
|
||||
// plan falls back to drawing them without a frame.
|
||||
$this->addSql('ALTER TABLE resource ADD CONSTRAINT FK_BC91F416FE54D947 FOREIGN KEY (group_id) REFERENCES resource_group (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX IDX_BC91F416FE54D947 ON resource (group_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE resource DROP FOREIGN KEY FK_BC91F416FE54D947');
|
||||
$this->addSql('DROP INDEX IDX_BC91F416FE54D947 ON resource');
|
||||
$this->addSql('ALTER TABLE resource DROP group_id');
|
||||
$this->addSql('DROP TABLE resource_group');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,32 +1,47 @@
|
||||
{#
|
||||
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.
|
||||
The office. Hand-authored geometry in two maps: `rooms` is keyed by ResourceGroup.svgElementId
|
||||
and `geometry` by Resource.svgElementId, so a row whose id is missing from its map is simply
|
||||
not drawn (and vice versa) -- app:seed and these maps have to agree.
|
||||
|
||||
Box height is set by what goes inside it, not by taste: _spot.html.twig puts the label at
|
||||
+18 and up to three lines at +34, +47 and +60, so 86 leaves a comfortable margin below the
|
||||
last one. Changing MAX_LINES there means changing the height here.
|
||||
|
||||
A room band is 118 high: 16 of margin, an 86-high desk, 16 again. The 30 between two bands is
|
||||
what the next room's caption sits in, since room() draws it above the rect. Moving a desk
|
||||
between rooms therefore means moving its y here as well as its group in the database -- the
|
||||
drawing is artwork, and no amount of indirection changes that.
|
||||
|
||||
Found by convention from ResourceType::DESK->value, see board/_floorplan.html.twig.
|
||||
#}
|
||||
{% import 'board/plan/_spot.html.twig' as plan %}
|
||||
|
||||
{% set rooms = {
|
||||
'room-01': { x: 10, y: 22, width: 500, height: 118 },
|
||||
'room-02': { x: 10, y: 170, width: 500, height: 118 },
|
||||
'room-03': { x: 10, y: 318, width: 500, height: 118 },
|
||||
'room-04': { x: 10, y: 466, width: 500, height: 118 }
|
||||
} %}
|
||||
|
||||
{% set geometry = {
|
||||
'desk-01': { x: 40, y: 50 }, 'desk-02': { x: 200, y: 50 }, 'desk-03': { x: 360, y: 50 },
|
||||
'desk-04': { x: 40, y: 155 }, 'desk-05': { x: 200, y: 155 }, 'desk-06': { x: 360, y: 155 },
|
||||
'desk-07': { x: 40, y: 260 }, 'desk-08': { x: 200, y: 260 }, 'desk-09': { x: 360, y: 260 },
|
||||
'desk-10': { x: 40, y: 365 }, 'desk-11': { x: 200, y: 365 }, 'desk-12': { x: 360, y: 365 }
|
||||
'desk-01': { x: 40, y: 38 }, 'desk-02': { x: 200, y: 38 }, 'desk-03': { x: 360, y: 38 },
|
||||
'desk-04': { x: 40, y: 186 }, 'desk-05': { x: 200, y: 186 }, 'desk-06': { x: 360, y: 186 },
|
||||
'desk-07': { x: 40, y: 334 }, 'desk-08': { x: 200, y: 334 }, 'desk-09': { x: 360, y: 334 },
|
||||
'desk-10': { x: 40, y: 482 }, 'desk-11': { x: 200, y: 482 }, 'desk-12': { x: 360, y: 482 }
|
||||
} %}
|
||||
{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
|
||||
|
||||
<svg viewBox="0 0 520 486" class="w-full h-auto" role="group" aria-label="Raumplan Büro">
|
||||
<rect x="10" y="18" width="500" height="448" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
|
||||
<text x="20" y="12" 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(spots[resource.id], geometry[resource.svgElementId], 140, 86,
|
||||
selected_date, is_past, time_params) }}
|
||||
<svg viewBox="0 0 520 594" class="w-full h-auto" role="group" aria-label="Raumplan Büro">
|
||||
{% for spot_group in spot_groups %}
|
||||
{% if spot_group.group and rooms[spot_group.group.svgElementId] is defined %}
|
||||
{{ plan.room(spot_group.group.label, rooms[spot_group.group.svgElementId], spot_group.freeCount) }}
|
||||
{% endif %}
|
||||
|
||||
{% for spot in spot_group.spots %}
|
||||
{% if spot.resource.svgElementId and geometry[spot.resource.svgElementId] is defined %}
|
||||
{{ plan.spot(spot, geometry[spot.resource.svgElementId], 140, 86,
|
||||
selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
@@ -16,10 +16,17 @@
|
||||
{# The lane between the two rows. #}
|
||||
<line x1="265" y1="36" x2="265" y2="310" 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(spots[resource.id], geometry[resource.svgElementId], 200, 78,
|
||||
selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{#
|
||||
The car park is one area, so nothing here is grouped and every spot arrives in the single
|
||||
null-group entry -- which is why this plan keeps drawing its own frame above instead of
|
||||
letting room() do it. See board/plan/_desk.html.twig for the grouped case.
|
||||
#}
|
||||
{% for spot_group in spot_groups %}
|
||||
{% for spot in spot_group.spots %}
|
||||
{% if spot.resource.svgElementId and geometry[spot.resource.svgElementId] is defined %}
|
||||
{{ plan.spot(spot, geometry[spot.resource.svgElementId], 200, 78,
|
||||
selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
@@ -15,8 +15,29 @@
|
||||
Clicking always opens a modal: the create modal when anything is still 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.
|
||||
|
||||
room() lives here for the same reason spot() does: a plan that frames its shapes into rooms
|
||||
and one that does not must otherwise draw their borders in two places and drift apart.
|
||||
#}
|
||||
|
||||
{#
|
||||
The frame around one room, captioned with its name and how many of its spots are still free
|
||||
for the whole of the board's span.
|
||||
|
||||
The caption sits above the rect rather than inside it, so it never collides with a spot box,
|
||||
and the geometry only has to say where the room is.
|
||||
#}
|
||||
{% macro room(label, geometry, free) %}
|
||||
<rect x="{{ geometry.x }}" y="{{ geometry.y }}"
|
||||
width="{{ geometry.width }}" height="{{ geometry.height }}" rx="8"
|
||||
class="fill-white stroke-gray-300" stroke-width="2"></rect>
|
||||
<text x="{{ geometry.x + 10 }}" y="{{ geometry.y - 6 }}" class="fill-gray-400 text-xs font-semibold">
|
||||
{{- label -}}
|
||||
{%- if free > 0 %} <tspan class="fill-emerald-600">· {{ free }} frei</tspan>
|
||||
{%- else %} <tspan class="fill-gray-400">· belegt</tspan>{% endif -%}
|
||||
</text>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro spot(spot, geometry, width, height, selected_date, is_past, time_params) %}
|
||||
{% set MAX_LINES = 3 %}
|
||||
{% set state = spot.state.name %}
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
{% for entry in series %}
|
||||
<li class="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<div class="font-semibold">{{ entry.resource.label }}</div>
|
||||
<div class="font-semibold">
|
||||
{{ entry.resource.label }}
|
||||
{%- if entry.resource.group %} <span class="font-normal text-gray-500">· {{ entry.resource.group.label }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}
|
||||
</div>
|
||||
@@ -47,6 +50,7 @@
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ booking.resource.type.label }} · {{ booking.resource.label }}
|
||||
{%- if booking.resource.group %} · {{ booking.resource.group.label }}{% endif %}
|
||||
{% if booking.partOfSeries %}
|
||||
<span class="badge bg-gray-100 text-gray-600 ml-1">Serie</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Booking;
|
||||
|
||||
use App\Booking\Spot;
|
||||
use App\Booking\SpotGroup;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SpotGroupTest extends TestCase
|
||||
{
|
||||
public function testSpotsArePartitionedByTheirRoom(): void
|
||||
{
|
||||
$north = $this->room('Raum Nord');
|
||||
$south = $this->room('Raum Süd');
|
||||
|
||||
$groups = SpotGroup::over([
|
||||
$this->spot('D1', $north),
|
||||
$this->spot('D2', $north),
|
||||
$this->spot('D3', $south),
|
||||
]);
|
||||
|
||||
self::assertCount(2, $groups);
|
||||
self::assertSame($north, $groups[0]->group);
|
||||
self::assertCount(2, $groups[0]->spots);
|
||||
self::assertSame($south, $groups[1]->group);
|
||||
self::assertCount(1, $groups[1]->spots);
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository orders by group position, so the order spots arrive in is already the order
|
||||
* they should be drawn in -- grouping must not reshuffle it.
|
||||
*/
|
||||
public function testTheOrderSpotsArriveInIsKept(): void
|
||||
{
|
||||
$first = $this->room('Erster Raum');
|
||||
$second = $this->room('Zweiter Raum');
|
||||
|
||||
$groups = SpotGroup::over([$this->spot('D1', $second), $this->spot('D2', $first)]);
|
||||
|
||||
self::assertSame(['Zweiter Raum', 'Erster Raum'], array_map(
|
||||
static fn (SpotGroup $group): string => $group->group?->getLabel() ?? '',
|
||||
$groups,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Two unpersisted groups both have a null id, so grouping cannot key on it.
|
||||
*/
|
||||
public function testTwoUnsavedRoomsDoNotCollapseIntoOne(): void
|
||||
{
|
||||
$groups = SpotGroup::over([$this->spot('D1', $this->room('A')), $this->spot('D2', $this->room('B'))]);
|
||||
|
||||
self::assertCount(2, $groups);
|
||||
}
|
||||
|
||||
public function testUngroupedSpotsCollectIntoOneTrailingGroup(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord');
|
||||
|
||||
$groups = SpotGroup::over([$this->spot('D1', null), $this->spot('D2', $room), $this->spot('D3', null)]);
|
||||
|
||||
self::assertCount(2, $groups);
|
||||
self::assertSame($room, $groups[0]->group);
|
||||
self::assertNull($groups[1]->group);
|
||||
self::assertCount(2, $groups[1]->spots);
|
||||
}
|
||||
|
||||
public function testAPlanWithNoGroupsAtAllIsOneNullGroup(): void
|
||||
{
|
||||
$groups = SpotGroup::over([$this->spot('P1', null), $this->spot('P2', null)]);
|
||||
|
||||
self::assertCount(1, $groups);
|
||||
self::assertNull($groups[0]->group);
|
||||
}
|
||||
|
||||
public function testNoSpotsMeansNoGroups(): void
|
||||
{
|
||||
self::assertSame([], SpotGroup::over([]));
|
||||
}
|
||||
|
||||
/**
|
||||
* FREE only, deliberately: a partly booked desk is already amber on the plan, and counting
|
||||
* it here would make the badge disagree with the colours beside it.
|
||||
*/
|
||||
public function testOnlyFullyFreeSpotsAreCounted(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord');
|
||||
|
||||
$group = SpotGroup::over([
|
||||
$this->spot('D1', $room),
|
||||
$this->spot('D2', $room, [$this->booking(540, 780)]), // PARTLY
|
||||
$this->spot('D3', $room, [$this->booking(0, 1440)]), // TAKEN
|
||||
])[0];
|
||||
|
||||
self::assertCount(3, $group->spots);
|
||||
self::assertSame(1, $group->freeCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Booking> $bookings
|
||||
*/
|
||||
private function spot(string $label, ?ResourceGroup $group, array $bookings = []): Spot
|
||||
{
|
||||
$resource = new Resource(ResourceType::DESK, $label, strtolower($label));
|
||||
$resource->setGroup($group);
|
||||
|
||||
return Spot::for($resource, $bookings, TimeRange::wholeDay(), $this->viewer());
|
||||
}
|
||||
|
||||
private function booking(int $start, int $end): Booking
|
||||
{
|
||||
return new Booking(
|
||||
new UserWithId('[email protected]', 'Bea', 2),
|
||||
new Resource(ResourceType::DESK, 'X', 'x'),
|
||||
new \DateTimeImmutable('2026-10-01'),
|
||||
TimeRange::fromMinutes($start, $end),
|
||||
);
|
||||
}
|
||||
|
||||
private function room(string $label): ResourceGroup
|
||||
{
|
||||
return new ResourceGroup(ResourceType::DESK, $label);
|
||||
}
|
||||
|
||||
private function viewer(): User
|
||||
{
|
||||
return new UserWithId('[email protected]', 'Anna', 1);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Tests\Controller;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -139,6 +140,107 @@ class FloorPlanTest extends WebTestCase
|
||||
self::assertSame('Schreibtisch 01 — Bea K. 09:00–13:00, frei 13:00–24:00', $label);
|
||||
}
|
||||
|
||||
public function testARoomIsDrawnWithItsNameAndHowManyAreFree(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord', 'room-01');
|
||||
$this->deskIn($room, 'desk-01', 'Schreibtisch 01');
|
||||
$this->deskIn($room, 'desk-02', 'Schreibtisch 02');
|
||||
$this->deskIn($room, 'desk-03', 'Schreibtisch 03');
|
||||
|
||||
self::assertSame(['Raum Nord · 3 frei'], $this->roomsOf($this->board()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The badge counts what can still be had for the whole span, so a desk that is free only in
|
||||
* the afternoon does not count -- it is already amber on the plan, and counting it would
|
||||
* make the number disagree with the colour beside it.
|
||||
*/
|
||||
public function testAPartlyBookedDeskIsNotCountedAsFree(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord', 'room-01');
|
||||
$partly = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
|
||||
$this->deskIn($room, 'desk-02', 'Schreibtisch 02');
|
||||
$this->book($partly, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
|
||||
|
||||
self::assertSame(['Raum Nord · 1 frei'], $this->roomsOf($this->board()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The count means the same thing as the colours beside it, so it is relative to the board's
|
||||
* span: the desk that is unavailable for the whole day is free for an afternoon board.
|
||||
*/
|
||||
public function testTheFreeCountFollowsTheSelectedSpan(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord', 'room-01');
|
||||
$desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
|
||||
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 540, 780);
|
||||
|
||||
self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board()));
|
||||
self::assertSame(['Raum Nord · 1 frei'], $this->roomsOf($this->board(query: '&from=14:00&to=18:00')));
|
||||
}
|
||||
|
||||
public function testARoomWithNothingLeftSaysSoInsteadOfShowingAZero(): void
|
||||
{
|
||||
$room = $this->room('Raum Nord', 'room-01');
|
||||
$desk = $this->deskIn($room, 'desk-01', 'Schreibtisch 01');
|
||||
$this->book($desk, $this->user('[email protected]', 'Bea Kranz'), 0, 1440);
|
||||
|
||||
self::assertSame(['Raum Nord · belegt'], $this->roomsOf($this->board()));
|
||||
}
|
||||
|
||||
public function testRoomsAreDrawnInTheirConfiguredOrder(): void
|
||||
{
|
||||
$this->deskIn($this->room('Zweiter Raum', 'room-02', 1), 'desk-04', 'Schreibtisch 04');
|
||||
$this->deskIn($this->room('Erster Raum', 'room-01', 0), 'desk-01', 'Schreibtisch 01');
|
||||
|
||||
self::assertSame(['Erster Raum · 1 frei', 'Zweiter Raum · 1 frei'], $this->roomsOf($this->board()));
|
||||
}
|
||||
|
||||
/**
|
||||
* A resource with no group is still bookable and still drawn -- the plan simply does not
|
||||
* frame it. That is the path the car park runs on.
|
||||
*/
|
||||
public function testAnUngroupedDeskIsDrawnWithoutARoom(): void
|
||||
{
|
||||
$this->desk('desk-01');
|
||||
|
||||
$crawler = $this->board();
|
||||
|
||||
self::assertSame([], $this->roomsOf($crawler));
|
||||
self::assertSame(['Schreibtisch 01'], $this->linesOf($crawler));
|
||||
}
|
||||
|
||||
/**
|
||||
* The room captions, which sit directly under the <svg> rather than inside a shape's <g>.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function roomsOf(\Symfony\Component\DomCrawler\Crawler $crawler): array
|
||||
{
|
||||
return $crawler->filter('svg > text')->each(
|
||||
static fn (\Symfony\Component\DomCrawler\Crawler $node): string => trim(preg_replace('/\s+/', ' ', $node->text()) ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
private function room(string $label, string $svgElementId, int $position = 0): ResourceGroup
|
||||
{
|
||||
$group = new ResourceGroup(ResourceType::DESK, $label, $position, $svgElementId);
|
||||
$this->em->persist($group);
|
||||
$this->em->flush();
|
||||
|
||||
return $group;
|
||||
}
|
||||
|
||||
private function deskIn(ResourceGroup $group, string $svgElementId, string $label): Resource
|
||||
{
|
||||
$resource = new Resource(ResourceType::DESK, $label, $svgElementId);
|
||||
$resource->setGroup($group);
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -149,11 +251,11 @@ class FloorPlanTest extends WebTestCase
|
||||
);
|
||||
}
|
||||
|
||||
private function board(?User $as = null): \Symfony\Component\DomCrawler\Crawler
|
||||
private function board(?User $as = null, string $query = ''): \Symfony\Component\DomCrawler\Crawler
|
||||
{
|
||||
$this->client->loginUser($as ?? $this->user('[email protected]', 'Anna Berger'));
|
||||
|
||||
return $this->client->request('GET', '/book/desk?date=2026-10-01');
|
||||
return $this->client->request('GET', '/book/desk?date=2026-10-01'.$query);
|
||||
}
|
||||
|
||||
private function desk(string $svgElementId): Resource
|
||||
|
||||
Reference in new issue
Block a user