fix: allow booking the free part of a partly booked day

This commit is contained in:
fromme committed 2026-09-13 19:11:51 +02:00
1 parent a88ff7ff24
commit 78839838f0
15 files changed
+541 -60

No files matched your search

+1
View File
@@ -13,6 +13,7 @@
<h2 class="!pb-0">{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}</h2>
<div class="flex items-center gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-emerald-50 border-2 border-emerald-500"></span> frei</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-amber-50 border-2 border-amber-500"></span> teilweise frei</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-gray-200 border-2 border-gray-400"></span> belegt</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-primary border-2 border-primary-dark"></span> deine</span>
</div>
+2 -2
View File
@@ -21,8 +21,8 @@
{% for resource in resources %}
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
{{ plan.spot(resource, geometry[resource.svgElementId], 140, 70,
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 140, 70,
selected_date, is_past, time_params) }}
{% endif %}
{% endfor %}
</svg>
+2 -2
View File
@@ -18,8 +18,8 @@
{% for resource in resources %}
{% if resource.svgElementId and geometry[resource.svgElementId] is defined %}
{{ plan.spot(resource, geometry[resource.svgElementId], 200, 55,
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
{{ plan.spot(spots[resource.id], geometry[resource.svgElementId], 200, 55,
selected_date, is_past, time_params) }}
{% endif %}
{% endfor %}
</svg>
+36 -26
View File
@@ -1,59 +1,69 @@
{#
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 free/taken/yours looks identical everywhere.
per-type partials carry geometry only and the states look identical everywhere.
Clicking always opens a modal: the create modal when 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.
The state comes from App\Booking\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.
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(resource, geometry, width, height, bookings, selected_date, is_past, time_params) %}
{% set own = bookings|filter(b => b.user.id == app.user.id)|first|default(null) %}
{% set mine = own is not null %}
{% macro spot(spot, geometry, width, height, selected_date, is_past, time_params) %}
{% set state = spot.state.name %}
{% if mine %}
{% if state == 'MINE' %}
{% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %}
{% elseif bookings is not empty %}
{% 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 (mine or bookings is empty) %}
{% set interactive = not is_past and spot.actionable %}
{# Several bookings can overlap the board's span once part-day bookings exist, so the shape
counts them and the tooltip spells them out. #}
{% if mine %}
{% set caption = own.timeRange.allDay ? 'du' : 'du ' ~ own.timeRange %}
{% elseif bookings|length > 1 %}
{% set caption = bookings|length ~ ' Buchungen' %}
{% elseif bookings is not empty %}
{% set caption = bookings|first.user.name %}
{# 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 %}
{% endif %}
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
{%- if interactive and mine %}
hx-get="{{ path('app_booking_edit', { id: own.id }|merge(time_params)) }}"
{%- 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. #}
hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
{# The modal is appended to the end of <body>; closing it is Stimulus, not htmx.
It prefills the free window itself -- see BookingController::create. #}
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"
{%- endif %}>
<title>
{{- resource.label }}
{%- if bookings is empty %} — frei
{{- spot.resource.label }}
{%- if spot.bookings is empty %} — frei
{%- else %}
{%- for booking in bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %}
{%- 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) }}"
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ resource.label }}</text>
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>