121 lines
6.8 KiB
Twig
121 lines
6.8 KiB
Twig
{#
|
|
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 %}
|