{# The shared shape a plan is drawn from. Imported by each board/plan/_.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 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 %}