feat: add booking time ranges and repeating series

This commit is contained in:
fromme committed 2026-09-13 17:34:28 +02:00
1 parent 59feca2f8e
commit d433d271d0
29 files changed
+2135 -180

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
{#
The shared body of both booking modals. Which fields exist is decided by the controller
(the edit modal removes date and the recurrence fields), so everything here is guarded with
`is defined` rather than duplicated into two templates.
Show/hide is Stimulus, not htmx: toggling a fieldset needs no server round trip, and the
house rule keeps purely client-side behaviour out of htmx.
#}
<div {{ stimulus_controller('bookingform') }}>
{% if form.date is defined %}
{{ form_row(form.date) }}
{% endif %}
<div class="pt-4">
{{ form_row(form.allDay, { attr: { 'data-action': 'bookingform#toggleTimes' } }) }}
<div class="grid grid-cols-2 gap-4 pt-2 {{ form.allDay.vars.data ? 'hidden' : '' }}"
{{ stimulus_target('bookingform', 'times') }}>
{{ form_row(form.startTime) }}
{{ form_row(form.endTime) }}
</div>
</div>
{% if form.repeat is defined %}
<div class="pt-4 border-t border-gray-200 mt-4">
{{ form_row(form.repeat, { attr: { 'data-action': 'bookingform#toggleRepeat' } }) }}
<div class="pt-2 {{ form.repeat.vars.data is null ? 'hidden' : '' }}"
{{ stimulus_target('bookingform', 'repeat') }}>
<div class="{{ form.repeat.vars.data is null or form.repeat.vars.data.value != 'weekly' ? 'hidden' : '' }}"
{{ stimulus_target('bookingform', 'weekdays') }}>
{{ form_row(form.weekdays, { row_attr: { class: 'pb-2' } }) }}
</div>
{{ form_row(form.until) }}
<p class="pt-1 text-xs text-gray-500">
Belegte Tage werden übersprungen und danach aufgelistet.
</p>
</div>
</div>
{% endif %}
</div>
+1 -1
View File
@@ -26,7 +26,7 @@
{{ resource.type.label }} · {{ resource.label }}
</p>
{{ form_row(form.date) }}
{% include 'booking/_fields.html.twig' %}
<div class="flex justify-end gap-2 pt-6">
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
+56
View File
@@ -0,0 +1,56 @@
{#
Editing one occurrence. For a booking that belongs to a series this is the "override": only
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
or inline CSRF tokens -- clicking your own desk always just opens this.
#}
{% extends 'htmx_modal.html.twig' %}
{% block title %}{{ resource.label }} ändern{% endblock %}
{% block content %}
{{ form_start(form, { attr: {
'hx-post': form.vars.action,
'hx-target': '#htmx-modal',
'hx-swap': 'outerHTML',
} }) }}
{{ form_errors(form) }}
<p class="pb-4 text-sm text-gray-500">
{{ resource.type.label }} · {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }}
</p>
{% if booking.partOfSeries %}
<div class="mb-4 rounded-md bg-gray-50 px-4 py-3 text-sm">
Teil einer Serie: {{ booking.series.describe }}.
<span class="text-gray-500">Eine Änderung hier gilt nur für diesen Tag.</span>
</div>
{% endif %}
{% include 'booking/_fields.html.twig' %}
<div class="flex flex-wrap justify-between gap-2 pt-6">
{# type="button" with its own hx-post: htmx issues the release request directly, so
this never submits the surrounding edit form and no nested <form> is needed. #}
<button type="button" class="button button--small button--warning"
hx-post="{{ path('app_booking_release', { id: booking.id }) }}"
hx-vals='{{ { _token: csrf_token('release-' ~ booking.id) }|json_encode }}'
hx-confirm="{{ resource.label }} am {{ booking.date|date('d.m.Y') }} freigeben?"
hx-target="#htmx-modal"
hx-swap="outerHTML">
Freigeben
</button>
<div class="flex gap-2">
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
Abbrechen
</button>
<button type="submit" class="button button--primary">
Speichern
</button>
</div>
</div>
{{ form_end(form) }}
{% endblock %}
+33 -2
View File
@@ -6,20 +6,51 @@
<h1>Meine Buchungen</h1>
<p class="subline">Alles, was ab heute für dich reserviert ist.</p>
{% if series is not empty %}
<h2 class="pt-8">Serien</h2>
<ul class="divide-y divide-gray-200 border-y border-gray-200">
{% for entry in series %}
<li class="flex items-center justify-between gap-4 py-3">
<div>
<div class="font-semibold">{{ entry.resource.label }}</div>
<div class="text-sm text-gray-500">
{{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}
</div>
</div>
{# Ends the series from today on; past occurrences are history and stay put. #}
<form method="post" action="{{ path('app_series_end', { id: entry.id }) }}"
onsubmit="return confirm('Serie ab heute beenden? Alle künftigen Termine werden entfernt.')">
<input type="hidden" name="_token" value="{{ csrf_token('end-series-' ~ entry.id) }}">
<button type="submit" class="button button--small button--neutral">Serie beenden</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<h2 class="pt-8">Termine</h2>
{% if bookings is empty %}
<p class="mt-8 text-sm text-gray-500">
<p class="text-sm text-gray-500">
Noch nichts gebucht. <a class="underline" href="{{ path('app_dashboard') }}">Zum Raumplan</a>.
</p>
{% else %}
<ul class="mt-8 divide-y divide-gray-200 border-y border-gray-200">
<ul class="divide-y divide-gray-200 border-y border-gray-200">
{% for booking in bookings %}
<li class="flex items-center justify-between gap-4 py-3">
<div>
<div class="font-semibold">
{{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }}
<span class="font-normal text-gray-500">· {{ booking.timeRange }}</span>
</div>
<div class="text-sm text-gray-500">
{{ booking.resource.type.label }} · {{ booking.resource.label }}
{% if booking.partOfSeries %}
<span class="badge bg-gray-100 text-gray-600 ml-1">Serie</span>
{% endif %}
{% if booking.overridden %}
<span class="badge bg-amber-100 text-amber-800 ml-1">abweichend</span>
{% endif %}
</div>
</div>
<div class="flex items-center gap-3">