feat: list bookings inside the floor plan shapes

This commit is contained in:
fromme committed 2026-09-13 19:16:56 +02:00
1 parent 78839838f0
commit a17c61400a
4 files changed
+246 -41

No files matched your search

+11 -7
View File
@@ -3,25 +3,29 @@
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.
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.
Found by convention from ResourceType::DESK->value, see board/_floorplan.html.twig.
#}
{% import 'board/plan/_spot.html.twig' as plan %}
{% set geometry = {
'desk-01': { x: 40, y: 60 }, 'desk-02': { x: 200, y: 60 }, 'desk-03': { x: 360, y: 60 },
'desk-04': { x: 40, y: 160 }, 'desk-05': { x: 200, y: 160 }, 'desk-06': { x: 360, y: 160 },
'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: 360 }, 'desk-11': { x: 200, y: 360 }, 'desk-12': { x: 360, y: 360 }
'desk-10': { x: 40, y: 365 }, 'desk-11': { x: 200, y: 365 }, 'desk-12': { x: 360, y: 365 }
} %}
{% set time_params = selected_time.allDay ? {} : { from: selected_time.start, to: selected_time.end } %}
<svg viewBox="0 0 520 460" class="w-full h-auto" role="img" aria-label="Raumplan Büro">
<rect x="10" y="20" width="500" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="20" y="14" class="fill-gray-400 text-xs font-semibold">Büro</text>
<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, 70,
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 140, 86,
selected_date, is_past, time_params) }}
{% endif %}
{% endfor %}
+9 -9
View File
@@ -1,24 +1,24 @@
{#
The car park. See board/plan/_desk.html.twig for how geometry and Resource.svgElementId
relate; found by convention from ResourceType::PARKING->value.
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: 60 }, 'parking-02': { x: 40, y: 130 }, 'parking-03': { x: 40, y: 200 },
'parking-04': { x: 280, y: 60 }, 'parking-05': { x: 280, y: 130 }, 'parking-06': { x: 280, y: 200 }
'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 300" class="w-full h-auto" role="img" aria-label="Parkplatz">
<rect x="10" y="20" width="500" height="260" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
<text x="20" y="14" class="fill-gray-400 text-xs font-semibold">Parkplatz</text>
<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="40" x2="265" y2="260" class="stroke-gray-200" stroke-width="2" stroke-dasharray="6 6"></line>
<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, 55,
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 200, 78,
selected_date, is_past, time_params) }}
{% endif %}
{% endfor %}
+38 -25
View File
@@ -7,11 +7,18 @@
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.
#}
{% macro spot(spot, geometry, width, height, selected_date, is_past, time_params) %}
{% set MAX_LINES = 3 %}
{% set state = spot.state.name %}
{% if state == 'MINE' %}
@@ -26,21 +33,32 @@
{% set interactive = not is_past and spot.actionable %}
{# The second line: what you would be told at a glance. For a partly-free spot that is the
window still available, because that is the reason to click it. #}
{% if state == 'MINE' %}
{% set caption = spot.own.timeRange.allDay ? 'du' : 'du ' ~ spot.own.timeRange %}
{% elseif state == 'PARTLY' %}
{% set caption = 'frei ' ~ spot.suggested %}
{% elseif spot.bookings|length > 1 %}
{% set caption = spot.bookings|length ~ ' Buchungen' %}
{% elseif spot.bookings is not empty %}
{% set caption = spot.bookings|first.user.name %}
{% else %}
{% set caption = null %}
{# 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 ? 'cursor-pointer' : 'cursor-default' }}"
{%- if interactive %} 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"
@@ -52,21 +70,16 @@
hx-target="body"
hx-swap="beforeend"
{%- endif %}>
<title>
{{- spot.resource.label }}
{%- if spot.bookings is empty %} — frei
{%- else %}
{%- for booking in spot.bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %}
{%- for window in spot.free %} — frei {{ window }}{% endfor %}
{%- endif -%}
</title>
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (caption ? height / 2 - 4 : height / 2 + 4) }}"
{# 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>
{% if caption %}
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</text>
{% endif %}
{% 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 %}