feat: render the floor plan in html instead of svg

This commit is contained in:
fromme committed 2026-09-17 16:10:50 +02:00
1 parent 5a01054b0e
commit ad5d5acb00
20 files changed
+256 -356

No files matched your search

+30 -4
View File
@@ -1,7 +1,6 @@
{#
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.
The plan: heading, legend, span picker, then one section per room with a card per spot.
Every ResourceType renders the same way, so a new one needs no template of its own.
Colouring needs no JavaScript at all: the plan is re-rendered server side for the selected day
and span, so free/taken/yours is decided in Twig. Note "free" is always relative to the span
@@ -28,6 +27,33 @@
{% 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' %}
{% import 'board/_spot.html.twig' as plan %}
{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
<div class="space-y-6">
{#
Rooms in their configured order, then anything without one. Ungrouped spots get no
heading: a kind of resource that is not split across rooms (the car park) has
nothing to say there that the page heading has not already said.
#}
{% for spot_group in spot_groups %}
<section>
{% if spot_group.group %}
{% set free = spot_group.freeCount %}
<h3 class="!mb-0 pb-2 text-sm font-semibold text-gray-500 md:text-sm">
{{- spot_group.group.label -}}
{%- if free > 0 %} <span class="text-emerald-600">· {{ free }} frei</span>
{%- else %} <span>· belegt</span>{% endif -%}
</h3>
{% endif %}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{% for spot in spot_group.spots %}
{{ plan.spot(spot, selected_date, is_past, time_params) }}
{% endfor %}
</div>
</section>
{% endfor %}
</div>
{% endif %}
</div>
+99
View File
@@ -0,0 +1,99 @@
{#
One bookable thing on the plan: a card coloured by its state, with who holds it listed inside.
The state comes from App\Model\Spot, not from counting bookings here. That distinction is
the fix for a real bug: a desk booked 09:00-13:00 used to render exactly like one booked all
day on an all-day board, and carried no hx-get either -- so a second person could not book the
afternoon at all unless they happened to narrow the span by hand first.
Who holds what is listed INSIDE the card rather than in a title tooltip: a tooltip only
appears on hover, so it is useless on touch and invisible when scanning the plan, which is the
one thing a floor plan is for.
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 a card carries no confirm dialogs or inline CSRF tokens.
#}
{% macro spot(spot, selected_date, is_past, time_params) %}
{% set MAX_LINES = 3 %}
{% set state = spot.state.name %}
{% if state == 'MINE' %}
{% set colours = 'bg-primary border-primary-dark text-white' %}
{% elseif state == 'TAKEN' %}
{% set colours = 'bg-gray-200 border-gray-400 text-gray-600' %}
{% elseif state == 'PARTLY' %}
{% set colours = 'bg-amber-50 border-amber-500 text-amber-900' %}
{% else %}
{% set colours = 'bg-emerald-50 border-emerald-500 text-emerald-900' %}
{% endif %}
{% set interactive = not is_past and spot.actionable %}
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot
should go back into the pool. Colours are untouched: MINE still means mine, and only
clickability widens. Past days stay inert for admins too; there is no past-write path.
Only a card that is not already actionable: a partly booked one still opens the create
modal, because taking the free window is the more useful act there and an admin can reach
that booking from /admin/bookings anyway. #}
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %}
{# One line per booking, then the window still free if there is one -- that last line is the
reason a partly booked card is worth clicking, so it earns its place. #}
{% set lines = [] %}
{% for booking in spot.bookings %}
{% if booking.user.id == app.user.id %}
{% set who = 'du' %}
{% else %}
{# "Anna Berger" -> "Anna B." : keeps a card on one line per booking at phone width. #}
{% set parts = booking.user.name|split(' ')|filter(part => part is not empty) %}
{% set who = parts|length > 1 ? parts|first ~ ' ' ~ parts|last|slice(0, 1) ~ '.' : booking.user.name %}
{% endif %}
{% set lines = lines|merge([who ~ (booking.timeRange.allDay ? '' : ' ' ~ booking.timeRange)]) %}
{% endfor %}
{% if state == 'PARTLY' %}
{% set lines = lines|merge(['frei ' ~ spot.suggested]) %}
{% endif %}
{# Summarise rather than let one busy desk stretch its whole row. Two lines plus a counter
beats three lines and a silent omission. #}
{% if lines|length > MAX_LINES %}
{% set lines = lines|slice(0, MAX_LINES - 1)|merge(['+' ~ (lines|length - MAX_LINES + 1) ~ ' weitere']) %}
{% endif %}
{% set tag = interactive or manageable ? 'button' : 'div' %}
<{{ tag }} data-spot
{%- if tag == 'button' %} type="button"{% endif %}
class="flex min-h-[5.5rem] w-full flex-col items-center rounded-md border-2 px-2 py-2 text-center {{ colours }}
{{- tag == 'button' ? ' cursor-pointer hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2' : ' cursor-default' }}"
{%- if interactive and state == 'MINE' %}
hx-get="{{ path('app_booking_edit', { id: spot.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.
It prefills the free window itself -- see Booking\CreateController. #}
hx-get="{{ path('app_booking_new', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- elseif manageable %}
{# Not app_booking_edit: a card can hold several bookings on one day, and an admin
releases rather than reschedules. The modal lists them and frees one. #}
hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- endif %}>
<span class="text-xs font-semibold{{ lines is empty ? ' my-auto' : '' }}">{{ spot.resource.label }}</span>
{% if lines is empty %}
{# The colour already says "free" to anyone who can see it. #}
<span class="sr-only">frei</span>
{% else %}
<ul class="pt-1 text-[11px] leading-4">
{% for line in lines %}
<li>{{ line }}</li>
{% endfor %}
</ul>
{% endif %}
</{{ tag }}>
{% endmacro %}
-47
View File
@@ -1,47 +0,0 @@
{#
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: 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 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>
-32
View File
@@ -1,32 +0,0 @@
{#
The car park. See board/plan/_desk.html.twig for how geometry, Resource.svgElementId and the
box height relate; found by convention from ResourceType::PARKING->value.
#}
{% import 'board/plan/_spot.html.twig' as plan %}
{% set geometry = {
'parking-01': { x: 40, y: 50 }, 'parking-02': { x: 40, y: 140 }, 'parking-03': { x: 40, y: 230 },
'parking-04': { x: 280, y: 50 }, 'parking-05': { x: 280, y: 140 }, 'parking-06': { x: 280, y: 230 }
} %}
{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
<svg viewBox="0 0 520 346" class="w-full h-auto" role="group" aria-label="Parkplatz">
<rect x="10" y="18" width="500" height="308" 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">Parkplatz</text>
{# 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>
{#
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>
-120
View File
@@ -1,120 +0,0 @@
{#
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 the states look identical everywhere.
The state comes from App\Model\Spot, not from counting bookings here. That distinction is
the fix for a real bug: a desk booked 09:00-13:00 used to render exactly like one booked all
day on an all-day board, and carried no hx-get either -- so a second person could not book the
afternoon at all unless they happened to narrow the span by hand first.
Who holds what is listed INSIDE the shape rather than in an SVG <title> flyout: a tooltip only
appears on hover, so it is useless on touch and invisible when scanning the plan, which is the
one thing a floor plan is for. The box therefore has a fixed layout -- label on top, one line
per booking below -- and the per-type partials size their boxes to fit MAX_LINES of them.
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 %}
{% if state == 'MINE' %}
{% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %}
{% elseif state == 'TAKEN' %}
{% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %}
{% elseif state == 'PARTLY' %}
{% set fill = 'fill-amber-50' %}{% set stroke = 'stroke-amber-500' %}{% set text = 'fill-amber-900' %}
{% else %}
{% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %}
{% endif %}
{% set interactive = not is_past and spot.actionable %}
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot
should go back into the pool. Colours are untouched: MINE still means mine, and only
clickability widens. Past days stay inert for admins too; there is no past-write path.
Only a shape that is not already actionable: a partly booked one still opens the create
modal, because taking the free window is the more useful act there and an admin can reach
that booking from /admin/bookings anyway. #}
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %}
{# One line per booking, then the window still free if there is one -- that last line is the
reason a partly booked shape is worth clicking, so it earns its place. #}
{% set lines = [] %}
{% for booking in spot.bookings %}
{% if booking.user.id == app.user.id %}
{% set who = 'du' %}
{% else %}
{# "Anna Berger" -> "Anna B." : full names do not fit a 140px box at this size. #}
{% set parts = booking.user.name|split(' ')|filter(part => part is not empty) %}
{% set who = parts|length > 1 ? parts|first ~ ' ' ~ parts|last|slice(0, 1) ~ '.' : booking.user.name %}
{% endif %}
{% set lines = lines|merge([who ~ (booking.timeRange.allDay ? '' : ' ' ~ booking.timeRange)]) %}
{% endfor %}
{% if state == 'PARTLY' %}
{% set lines = lines|merge(['frei ' ~ spot.suggested]) %}
{% endif %}
{# Overflow rather than spill out of the box. Two lines plus a counter beats three lines and
a silent omission. #}
{% if lines|length > MAX_LINES %}
{% set lines = lines|slice(0, MAX_LINES - 1)|merge(['+' ~ (lines|length - MAX_LINES + 1) ~ ' weitere']) %}
{% endif %}
<g class="{{ interactive or manageable ? 'cursor-pointer' : 'cursor-default' }}"
{%- if interactive or manageable %} role="button"{% endif %}
aria-label="{{ spot.resource.label }}{% if lines is empty %} — frei{% else %} — {{ lines|join(', ') }}{% endif %}"
{%- if interactive and state == 'MINE' %}
hx-get="{{ path('app_booking_edit', { id: spot.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.
It prefills the free window itself -- see Booking\CreateController. #}
hx-get="{{ path('app_booking_new', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- elseif manageable %}
{# Not app_booking_edit: a shape can hold several bookings on one day, and an admin
releases rather than reschedules. The modal lists them and frees one. #}
hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
hx-target="body"
hx-swap="beforeend"
{%- endif %}>
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
{# Nothing to list: the colour already says "free", so the label sits centred as before. #}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (lines is empty ? height / 2 + 4 : 18) }}"
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ spot.resource.label }}</text>
{% for line in lines %}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + 34 + loop.index0 * 13 }}"
text-anchor="middle" class="{{ text }} text-[10px]">{{ line }}</text>
{% endfor %}
</g>
{% endmacro %}
+1 -1
View File
@@ -3,7 +3,7 @@
this day moves, the rest of the series is untouched and the occurrence is flagged as
differing from it.
Releasing lives here too rather than on the floor plan, so the SVG needs no confirm dialogs
Releasing lives here too rather than on the floor plan, so a card needs no confirm dialogs
or inline CSRF tokens -- clicking your own desk always just opens this. The release button
opens the shared confirmation modal over this one.
#}