feat: panel layout for the accommodation booking edit and detail pages

This commit is contained in:
fromme committed 2026-09-30 18:06:45 +02:00
1 parent 5fdee68ccd
commit eb45693257
9 files changed
+531 -398

No files matched your search

@@ -12,6 +12,7 @@ use App\Form\Admin\Groups\AccommodationBookingType;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use App\Repository\UserRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -33,6 +34,7 @@ class EditController extends AbstractController
private readonly AdditionalServiceRepository $additionalServiceRepo,
private readonly UserRepository $userRepo,
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
) {
}
@@ -128,10 +130,7 @@ class EditController extends AbstractController
// The submitted changes stay on the entity but are never flushed, so the form
// comes back filled in with the error on it. Logging is safe here: the database
// handler writes through DBAL, not the EntityManager.
return $this->render('admin/accommodation_booking/edit.html.twig', [
'booking' => $booking,
'form' => $form,
]);
return $this->renderForm($booking, $form);
}
// Editing never moves the booking on and never mails the customer: every status
@@ -160,9 +159,22 @@ class EditController extends AbstractController
return $this->redirectToRoute('app_admin_accommodationbooking_show', $parameters);
}
return $this->renderForm($booking, $form);
}
/**
* The sidebar shows the stored price, not one computed from the submitted values: after a
* refused save the entity carries the unsaved changes, and the price there is labelled as the
* state of the last save.
*
* @param FormInterface<AccommodationBooking> $form
*/
private function renderForm(AccommodationBooking $booking, FormInterface $form): Response
{
return $this->render('admin/accommodation_booking/edit.html.twig', [
'booking' => $booking,
'form' => $form,
'priceBreakdown' => $this->breakdownCalculator->compute($booking),
]);
}
@@ -18,10 +18,11 @@ class AccommodationBookingPeriodType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// "Überschrift" rather than "Bezeichnung", which the lines below use for their own field.
->add('label', TextType::class, [
'label' => 'Bezeichnung',
'label' => 'Überschrift (optional)',
'required' => false,
'help' => 'Überschrift in der Preisaufstellung, z. B. „Vorausteam“. Ohne Bezeichnung steht dort nur der Zeitraum.',
'attr' => ['placeholder' => 'z. B. Vorausteam'],
])
->add('nights', IntegerType::class, [
'label' => 'Nächte',
@@ -0,0 +1,32 @@
{#- One split-off period on the edit page, with its actions. Context: booking, period. -#}
{% set period_params = { id: period.id } %}
{% if app.request.query.get('r') %}
{% set period_params = period_params | merge({ r: app.request.query.get('r') }) %}
{% endif %}
<tr class="border-t border-gray-200 align-top">
<td class="py-1 whitespace-nowrap">{{ period.dateFrom | date('d.m.Y') }} – {{ period.dateTo | date('d.m.Y') }}</td>
<td class="py-1 whitespace-nowrap">{{ period.nights }} {{ period.nights != 1 ? 'Nächte' : 'Nacht' }}</td>
<td class="py-1">
{% if period.label %}
<div class="font-medium">{{ period.label }}</div>
{% endif %}
{% for line in period.lines %}
<div>{{ line.label }}</div>
{% else %}
<div class="text-gray-500">keine Positionen</div>
{% endfor %}
</td>
<td class="py-1 text-right whitespace-nowrap">
{{ (period.total / 100) | format_currency(booking.pricingCurrency ?? booking.accommodation.currency) }}
</td>
<td class="py-1 text-right">
<twig:dropdown>
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_edit', period_params) }}">
bearbeiten
</twig:dropdown:hxbutton>
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_remove', period_params) }}" warning>
entfernen
</twig:dropdown:hxbutton>
</twig:dropdown>
</td>
</tr>
@@ -0,0 +1,14 @@
{#- One split-off period of the price table on the detail page. Context: period, currency. -#}
<tr class="border-t border-gray-200 font-medium">
<td class="pt-2 pb-1" colspan="2">
{% if period.label ?? null %}{{ period.label }} · {% endif %}
{{ period.dateFrom | date('d.m.Y') }} – {{ period.dateTo | date('d.m.Y') }},
{{ period.nights }} {{ period.nights != 1 ? 'Nächte' : 'Nacht' }}
</td>
</tr>
{% for line in period.lines %}
<tr>
<td class="py-1">{{ line.label }}</td>
<td class="py-1 text-right whitespace-nowrap">{{ (line.amount / 100) | format_currency(currency) }}</td>
</tr>
{% endfor %}
@@ -22,155 +22,200 @@
{% form_theme form 'forms_admin.html.twig' %}
{{ form_start(form) }}
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
<h2 class="lg:col-span-2 text-base font-bold">Kontaktdaten</h2>
{{ form_row(form.groupName) }}
{{ form_row(form.salutation) }}
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
{{ form_row(form.email) }}
{{ form_row(form.phone) }}
{{ form_row(form.street) }}
{{ form_row(form.zip) }}
{{ form_row(form.city) }}
{{ form_errors(form) }}
{# Ordered by what gets negotiated: the stay first, the contact data last. The sidebar keeps
status, price and the save button in view on wide screens. #}
<div class="xl:grid xl:grid-cols-3 xl:gap-6 xl:items-start space-y-6 xl:space-y-0">
<div class="xl:col-span-2 space-y-6">
<twig:form:section title="Aufenthalt">
<div class="grid sm:grid-cols-2 gap-4">
{{ form_row(form.dateFrom) }}
{{ form_row(form.dateTo) }}
</div>
<div class="grid sm:grid-cols-3 gap-4 pt-4">
{{ form_row(form.paxCount) }}
{{ form_row(form.minorsCount) }}
{{ form_row(form.childrenCount) }}
</div>
<h2 class="lg:col-span-2 text-base font-bold pt-2">Reisedaten</h2>
{{ form_row(form.dateFrom) }}
{{ form_row(form.dateTo) }}
{{ form_row(form.paxCount) }}
{{ form_row(form.minorsCount) }}
{{ form_row(form.childrenCount) }}
{# Saved by their own modals, which reprice the booking on the spot — see SplitPeriodController. #}
<div class="lg:col-span-2">
<h3 class="font-bold pb-1">Zeiträume</h3>
<p class="text-xs text-gray-500 pb-2">
Anreise und Abreise umfassen den ganzen Aufenthalt. Abgeteilte Zeiträume, etwa für ein
Vorausteam, werden mit eigenen Positionen berechnet; Personen und Leistungen oben gelten
nur für den Zeitraum der Gruppe.
</p>
<table class="w-full text-sm">
{% if booking.leadingPeriod %}
{% set p = booking.leadingPeriod %}
{{ block('period_row') }}
{% endif %}
<tr class="border-t border-gray-200">
<td class="py-1 whitespace-nowrap">{{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }}</td>
<td class="py-1 whitespace-nowrap">{{ booking.mainNights }} {{ booking.mainNights != 1 ? 'Nächte' : 'Nacht' }}</td>
<td class="py-1" colspan="2">Gruppe</td>
<td class="py-1 text-right">
{# One period per side, so only the free sides are offered. #}
{% if booking.leadingPeriod is null or booking.trailingPeriod is null %}
<twig:dropdown>
{% if booking.leadingPeriod is null %}
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_split', back_params | merge({ position: 'leading' })) }}">
am Anfang abteilen
</twig:dropdown:hxbutton>
{% endif %}
{% if booking.trailingPeriod is null %}
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_split', back_params | merge({ position: 'trailing' })) }}">
am Ende abteilen
</twig:dropdown:hxbutton>
{% endif %}
</twig:dropdown>
{# Saved by their own modals, which reprice the booking on the spot — see SplitPeriodController. #}
<div class="mt-6 border-t border-gray-200 pt-4">
<div class="font-bold pb-1">Zeiträume</div>
<p class="text-xs text-gray-500 pb-2">
Anreise und Abreise umfassen den ganzen Aufenthalt. Abgeteilte Zeiträume, etwa für ein
Vorausteam, werden mit eigenen Positionen berechnet; Personen und Leistungen gelten
nur für den Zeitraum der Gruppe.
</p>
<table class="w-full text-sm">
{% if booking.leadingPeriod %}
{% include 'admin/accommodation_booking/_period_row.html.twig' with { period: booking.leadingPeriod } %}
{% endif %}
</td>
</tr>
{% if booking.trailingPeriod %}
{% set p = booking.trailingPeriod %}
{{ block('period_row') }}
<tr class="border-t border-gray-200">
<td class="py-1 whitespace-nowrap">{{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }}</td>
<td class="py-1 whitespace-nowrap">{{ booking.mainNights }} {{ booking.mainNights != 1 ? 'Nächte' : 'Nacht' }}</td>
<td class="py-1" colspan="2">Gruppe</td>
<td class="py-1 text-right">
{# One period per side, so only the free sides are offered. #}
{% if booking.leadingPeriod is null or booking.trailingPeriod is null %}
<twig:dropdown>
{% if booking.leadingPeriod is null %}
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_split', back_params | merge({ position: 'leading' })) }}">
am Anfang abteilen
</twig:dropdown:hxbutton>
{% endif %}
{% if booking.trailingPeriod is null %}
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_split', back_params | merge({ position: 'trailing' })) }}">
am Ende abteilen
</twig:dropdown:hxbutton>
{% endif %}
</twig:dropdown>
{% endif %}
</td>
</tr>
{% if booking.trailingPeriod %}
{% include 'admin/accommodation_booking/_period_row.html.twig' with { period: booking.trailingPeriod } %}
{% endif %}
</table>
</div>
</twig:form:section>
{% if form.boardService is defined or form.selectedAdditionalServices is defined %}
<twig:form:section title="Leistungen">
<div class="space-y-4">
{% if form.boardService is defined %}
{{ form_row(form.boardService) }}
{% endif %}
{% if form.selectedAdditionalServices is defined %}
<div>
{{ form_label(form.selectedAdditionalServices) }}
{# Rows written out here rather than through the theme's choice_widget_expanded,
whose first:pt-0 would shift only the first cell of the two columns. #}
<div class="grid sm:grid-cols-2 gap-x-8">
{% for child in form.selectedAdditionalServices %}
<div class="relative flex items-start py-1">
<div class="min-w-0 flex-1 text-sm leading-4">
<label for="{{ child.vars.id }}" class="select-none font-medium text-gray-900">
{{ child.vars.label }}
</label>
</div>
<div class="ml-3 flex h-4 items-center">
{{ form_widget(child) }}
</div>
</div>
{% endfor %}
</div>
{{ form_errors(form.selectedAdditionalServices) }}
</div>
{% endif %}
</div>
</twig:form:section>
{% endif %}
<twig:form:section title="Rabatte">
<div class="grid sm:grid-cols-3 gap-4">
{{ form_row(form.accommodationDiscount) }}
{{ form_row(form.boardServiceDiscount) }}
{{ form_row(form.additionalServicesDiscount) }}
</div>
<div class="grid sm:grid-cols-3 gap-4 pt-4">
{{ form_row(form.totalDiscountLabel, { row_attr: { class: 'sm:col-span-2' } }) }}
{{ form_row(form.totalDiscountAmount) }}
</div>
</twig:form:section>
<twig:form:section title="Kontakt">
<div class="grid sm:grid-cols-2 gap-4">
{{ form_row(form.groupName, { row_attr: { class: 'sm:col-span-2' } }) }}
{{ form_row(form.salutation) }}
<div class="hidden sm:block"></div>
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
{{ form_row(form.email) }}
{{ form_row(form.phone) }}
{{ form_row(form.street, { row_attr: { class: 'sm:col-span-2' } }) }}
{{ form_row(form.zip) }}
{{ form_row(form.city) }}
</div>
</twig:form:section>
<twig:form:section title="Bemerkungen">
{{ form_widget(form.remarks) }}
{{ form_errors(form.remarks) }}
</twig:form:section>
</div>
<aside class="space-y-6 xl:sticky xl:top-8">
<twig:form:section title="Status">
{# Read-only: the status only ever moves through a named action on the detail page,
so that it cannot be set without the email and timestamp that belong to it. #}
<dl class="grid grid-cols-2 gap-y-1 text-sm">
<dt class="text-gray-500">Status</dt>
<dd>{% include 'admin/accommodation_booking/_status_label.html.twig' %}</dd>
<dt class="text-gray-500">Herkunft</dt>
<dd>{{ booking.origin.label }}</dd>
{% if booking.acceptedAt is not null %}
<dt class="text-gray-500">Angenommen</dt>
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
{% endif %}
{% if booking.confirmedAt is not null %}
<dt class="text-gray-500">Bestätigt</dt>
<dd>{{ booking.confirmedAt | date('d.m.Y, H:i') }}</dd>
{% endif %}
</dl>
{% if form.managedBy is defined %}
<div class="pt-4">
{{ form_row(form.managedBy) }}
</div>
{% endif %}
</table>
</div>
</twig:form:section>
{% if form.boardService is defined or form.selectedAdditionalServices is defined %}
<h2 class="lg:col-span-2 text-base font-bold pt-2">Leistungen</h2>
{% if form.boardService is defined %}
{{ form_row(form.boardService) }}
{% if priceBreakdown is not null %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<twig:form:section title="Preis">
<table class="w-full text-sm">
{% if priceBreakdown.periods is not empty %}
<tr>
<td class="py-1">Gruppe</td>
<td class="py-1 text-right whitespace-nowrap">{{ ((priceBreakdown.total - priceBreakdown.periodsTotal) / 100) | format_currency(currency) }}</td>
</tr>
<tr>
<td class="py-1">Zeiträume</td>
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.periodsTotal / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.discountedTotal != priceBreakdown.total %}
{% if priceBreakdown.periods is empty %}
<tr>
<td class="py-1">Preis</td>
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.total / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
<tr class="text-green-700">
<td class="py-1">Rabatte</td>
<td class="py-1 text-right whitespace-nowrap">– {{ ((priceBreakdown.total - priceBreakdown.discountedTotal) / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
<tr class="border-t border-gray-200 font-bold">
<td class="pt-2">Gesamtpreis</td>
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.discountedTotal / 100) | format_currency(currency) }}</td>
</tr>
</table>
<p class="text-xs text-gray-500 pt-2">
Stand der letzten Speicherung. Änderungen oben werden erst beim Speichern neu berechnet.
</p>
</twig:form:section>
{% endif %}
{% if form.selectedAdditionalServices is defined %}
{{ form_row(form.selectedAdditionalServices) }}
{% endif %}
{% endif %}
<h2 class="lg:col-span-2 text-base font-bold pt-2">Status & Rabatt</h2>
{# Read-only: the status only ever moves through a named action on the detail page,
so that it cannot be set without the email and timestamp that belong to it. #}
<p class="lg:col-span-2 text-sm">
<span class="font-medium">Status:</span>
{% include 'admin/accommodation_booking/_status_label.html.twig' %}
<span class="text-gray-500">· Herkunft: {{ booking.origin.label }}</span>
</p>
{% if booking.acceptedAt is not null %}
<p class="lg:col-span-2 text-xs text-gray-500">
Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }}
</p>
{% endif %}
{% if booking.confirmedAt is not null %}
<p class="lg:col-span-2 text-xs text-gray-500">
Bestätigt am {{ booking.confirmedAt | date('d.m.Y, H:i') }}
</p>
{% endif %}
{% if form.managedBy is defined %}
<div class="lg:col-span-2">
{{ form_row(form.managedBy) }}
<div class="flex flex-col gap-3">
<button type="submit" class="button button--primary button--small w-full">
speichern
</button>
<a href="{{ path('app_admin_accommodationbooking_show', back_params) }}" class="text-sm text-gray-500 hover:text-gray-900 hover:underline">
← zurück ohne speichern
</a>
</div>
{% endif %}
<div class="lg:col-span-2 grid lg:grid-cols-3 gap-y-4 lg:gap-x-8">
{{ form_row(form.accommodationDiscount) }}
{{ form_row(form.boardServiceDiscount) }}
{{ form_row(form.additionalServicesDiscount) }}
</div>
<div class="lg:col-span-2 grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
{{ form_row(form.totalDiscountLabel) }}
{{ form_row(form.totalDiscountAmount) }}
</div>
<div class="lg:col-span-2">
{{ form_row(form.remarks) }}
</div>
</div>
<div class="flex justify-between pt-8">
<a href="{{ path('app_admin_accommodationbooking_show', back_params) }}" class="button button--secondary button--small">
zurück
</a>
<button type="submit" class="button button--primary button--small">
speichern
</button>
</aside>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
{% block period_row %}
{% set period_params = { id: p.id } %}
{% if app.request.query.get('r') %}
{% set period_params = period_params | merge({ r: app.request.query.get('r') }) %}
{% endif %}
<tr class="border-t border-gray-200 align-top">
<td class="py-1 whitespace-nowrap">{{ p.dateFrom | date('d.m.Y') }} – {{ p.dateTo | date('d.m.Y') }}</td>
<td class="py-1 whitespace-nowrap">{{ p.nights }} {{ p.nights != 1 ? 'Nächte' : 'Nacht' }}</td>
<td class="py-1">
{% if p.label %}
<div class="font-medium">{{ p.label }}</div>
{% endif %}
{% for line in p.lines %}
<div>{{ line.label }}</div>
{% else %}
<div class="text-gray-500">keine Positionen</div>
{% endfor %}
</td>
<td class="py-1 text-right whitespace-nowrap">
{{ (p.total / 100) | format_currency(booking.pricingCurrency ?? booking.accommodation.currency) }}
</td>
<td class="py-1 text-right">
<twig:dropdown>
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_edit', period_params) }}">
bearbeiten
</twig:dropdown:hxbutton>
<twig:dropdown:hxbutton url="{{ path('app_admin_accommodationbooking_period_remove', period_params) }}" warning>
entfernen
</twig:dropdown:hxbutton>
</twig:dropdown>
</td>
</tr>
{% endblock %}
@@ -1,12 +1,21 @@
{% extends 'htmx_modal_admin.html.twig' %}
{% form_theme form 'forms_admin.html.twig' %}
{# One line as a row of bare widgets; the column headings above name them once for all rows.
Plain flex rather than an arbitrary grid template: the modal is loaded into a page whose
stylesheet may predate any class used only here. #}
{% macro line_row(line) %}
<div class="flex items-start gap-2" data-form-collection-target="field">
<div class="flex-1">{{ form_row(line.label) }}</div>
<div class="w-40">{{ form_row(line.amount) }}</div>
<div class="flex gap-2 items-start" data-form-collection-target="field">
<div class="flex-1 min-w-0">
{{ form_widget(line.label, { attr: { placeholder: 'z. B. Übernachtung' } }) }}
{{ form_errors(line.label) }}
</div>
<div class="w-32 shrink-0">
{{ form_widget(line.amount) }}
{{ form_errors(line.amount) }}
</div>
<button type="button"
class="mt-9 text-gray-500 hover:text-red-500"
class="shrink-0 mt-2 text-gray-400 hover:text-red-500"
data-action="form-collection#removeItem"
title="Position entfernen">
{{ icon('close', 'w-5 h-5') }}
@@ -16,38 +25,45 @@
{% block content %}
{% import _self as period %}
<div class="text-xl font-bold pb-4">
<div class="text-xl font-bold pb-1">
{{ title }}
</div>
<p class="text-sm text-gray-500 pb-4">
<p class="text-sm text-gray-500 pb-6">
Die Nächte werden vom Zeitraum der Gruppe abgezogen, der neu berechnet wird. Die Positionen
kommen zum Gesamtpreis hinzu und fallen nur unter den Rabatt auf den Gesamtpreis.
</p>
{{ form_start(form) }}
{{ form_errors(form) }}
<div class="pb-4">
{{ form_row(form.label) }}
</div>
<div class="w-40 pb-4">
{{ form_row(form.nights) }}
<div class="flex gap-4 items-start">
{{ form_row(form.label, { row_attr: { class: 'flex-1 min-w-0' } }) }}
{{ form_row(form.nights, { row_attr: { class: 'w-32 shrink-0' } }) }}
</div>
{% set prototype %}{{ period.line_row(form.lines.vars.prototype) }}{% endset %}
<div {{ stimulus_controller('form-collection', { prototype: prototype|json_encode }) }}>
<div class="font-bold pb-2">Positionen</div>
{{ form_errors(form.lines) }}
<div class="space-y-2" data-form-collection-target="fields">
{% for line in form.lines %}
{{ period.line_row(line) }}
{% endfor %}
<div class="mt-6 border border-gray-200 rounded-md" {{ stimulus_controller('form-collection', { prototype: prototype|json_encode }) }}>
<div class="flex gap-2 px-4 py-2 bg-gray-100 border-b border-gray-200 rounded-t-md text-sm font-bold">
<div class="flex-1">Position</div>
{# w-5 matches the remove button's icon, so the heading sits over the amounts. #}
<div class="w-32 shrink-0">Betrag</div>
<div class="w-5 shrink-0"></div>
</div>
<div class="p-4">
{{ form_errors(form.lines) }}
<div class="space-y-2" data-form-collection-target="fields">
{% for line in form.lines %}
{{ period.line_row(line) }}
{% endfor %}
</div>
<button type="button"
class="mt-3 text-sm font-medium text-primary hover:underline"
data-action="form-collection#addItem">
+ Position hinzufügen
</button>
</div>
<button type="button"
class="button button--secondary button--small mt-2"
data-action="form-collection#addItem">
Position hinzufügen
</button>
</div>
{% do form.lines.setRendered %}
<div class="flex justify-end pt-8">
<div class="flex justify-end pt-6">
<button type="submit" class="button button--primary button--small">
speichern
</button>
@@ -1,103 +1,72 @@
{% extends 'layout_admin.html.twig' %}
{% block content %}
{% set edit_params = { 'id': booking.id } %}
{% if app.request.query.get('r') %}
{% set edit_params = edit_params | merge({ 'r': app.request.query.get('r') }) %}
{% endif %}
<twig:page:heading>
{{ booking.recordLabel }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %}
{{ booking.groupName }}
</twig:page:heading>
<p class="text-sm text-gray-500 mb-6">{{ booking.accommodation.name }}</p>
<div class="grid lg:grid-cols-2 gap-8">
<div>
<h2 class="text-lg font-bold mb-4">Kontakt</h2>
<dl class="grid grid-cols-2 gap-y-2 text-sm">
<dt class="font-medium">Gruppenname</dt>
<dd>{{ booking.groupName }}</dd>
<dt class="font-medium">Anrede</dt>
<dd>{{ booking.salutation | default('–') }}</dd>
<dt class="font-medium">Name</dt>
<dd>{{ booking.firstName }} {{ booking.lastName }}</dd>
<dt class="font-medium">E-Mail</dt>
<dd>{{ booking.email }}</dd>
<dt class="font-medium">Telefon</dt>
<dd>{{ booking.phone | default('–') }}</dd>
<dt class="font-medium">Adresse</dt>
<dd>
{% if booking.street %}
{{ booking.street }}<br>
{{ booking.zip }} {{ booking.city }}
{% else %}
–
{% endif %}
</dd>
</dl>
</div>
<div>
<h2 class="text-lg font-bold mb-4">Reisedaten</h2>
<dl class="grid grid-cols-2 gap-y-2 text-sm">
<dt class="font-medium">Gruppenhaus</dt>
<dd>{{ booking.accommodation.name }}</dd>
<dt class="font-medium">Anreise</dt>
<dd>{{ booking.dateFrom | date('d.m.Y') }}</dd>
<dt class="font-medium">Abreise</dt>
<dd>{{ booking.dateTo | date('d.m.Y') }}</dd>
<dt class="font-medium">Nächte</dt>
<dd>{{ booking.nights }}</dd>
{% if booking.periods is not empty %}
<dt class="font-medium">Zeitraum Gruppe</dt>
<dd>
{{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }},
{{ booking.mainNights }} {{ booking.mainNights != 1 ? 'Nächte' : 'Nacht' }}
</dd>
{% endif %}
<dt class="font-medium">Anzahl Personen</dt>
<dd>{{ booking.paxCount }}</dd>
{% if booking.minorsCount > 0 %}
<dt class="font-medium">davon Kinder (0–3 Jahre)</dt>
<dd>{{ booking.minorsCount }}</dd>
{% endif %}
{% if booking.childrenCount > 0 %}
<dt class="font-medium">davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} Jahre)</dt>
<dd>{{ booking.childrenCount }}</dd>
{% endif %}
<dt class="font-medium">Herkunft</dt>
<dd>{{ booking.origin.label }}</dd>
<dt class="font-medium">Status</dt>
<dd>{% include 'admin/accommodation_booking/_status_label.html.twig' %}</dd>
{% if booking.acceptedAt is not null %}
<dt class="font-medium">Angenommen am</dt>
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
{% endif %}
{% if booking.confirmedAt is not null %}
<dt class="font-medium">Bestätigt am</dt>
<dd>{{ booking.confirmedAt | date('d.m.Y, H:i') }}</dd>
{% endif %}
</dl>
</div>
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
<div>
<h2 class="text-lg font-bold mb-4">Gebuchte Leistungen</h2>
{# Same arrangement as the edit form: what was booked in the main column, status, the next
step and the actions in a sidebar that stays in view on wide screens. #}
<div class="xl:grid xl:grid-cols-3 xl:gap-6 xl:items-start space-y-6 xl:space-y-0">
<div class="xl:col-span-2 space-y-6">
<twig:form:section title="Aufenthalt">
<dl class="grid grid-cols-2 gap-y-2 text-sm">
{% if booking.boardServiceLabel %}
<dt class="font-medium">Verpflegung</dt>
<dd>{{ booking.boardServiceLabel }}</dd>
<dt class="font-medium">Gruppenhaus</dt>
<dd>{{ booking.accommodation.name }}</dd>
<dt class="font-medium">Anreise</dt>
<dd>{{ booking.dateFrom | date('d.m.Y') }}</dd>
<dt class="font-medium">Abreise</dt>
<dd>{{ booking.dateTo | date('d.m.Y') }}</dd>
<dt class="font-medium">Nächte</dt>
<dd>{{ booking.nights }}</dd>
{% if booking.periods is not empty %}
<dt class="font-medium">Zeitraum Gruppe</dt>
<dd>
{{ booking.mainDateFrom | date('d.m.Y') }} – {{ booking.mainDateTo | date('d.m.Y') }},
{{ booking.mainNights }} {{ booking.mainNights != 1 ? 'Nächte' : 'Nacht' }}
</dd>
{% endif %}
<dt class="font-medium">Anzahl Personen</dt>
<dd>{{ booking.paxCount }}</dd>
{% if booking.minorsCount > 0 %}
<dt class="font-medium">davon Kinder (0–3 Jahre)</dt>
<dd>{{ booking.minorsCount }}</dd>
{% endif %}
{% if booking.childrenCount > 0 %}
<dt class="font-medium">davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} Jahre)</dt>
<dd>{{ booking.childrenCount }}</dd>
{% endif %}
{% for service in booking.additionalServices %}
<dt class="font-medium">{{ loop.first ? 'Zusatzleistungen' : '' }}</dt>
<dd>{{ service.label }}</dd>
{% endfor %}
</dl>
</div>
{% endif %}
</twig:form:section>
{% if priceBreakdown is not null %}
<div>
<h2 class="text-lg font-bold mb-4">Preise</h2>
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
<twig:form:section title="Leistungen">
<dl class="grid grid-cols-2 gap-y-2 text-sm">
{% if booking.boardServiceLabel %}
<dt class="font-medium">Verpflegung</dt>
<dd>{{ booking.boardServiceLabel }}</dd>
{% endif %}
{% for service in booking.additionalServices %}
<dt class="font-medium">{{ loop.first ? 'Zusatzleistungen' : '' }}</dt>
<dd>{{ service.label }}</dd>
{% endfor %}
</dl>
</twig:form:section>
{% endif %}
{% if priceBreakdown is not null %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<twig:form:section title="Preise">
<table class="w-full text-sm">
{% for period in priceBreakdown.periods | filter(p => p.position == 'leading') %}
{{ block('period_rows') }}
{% include 'admin/accommodation_booking/_price_period.html.twig' %}
{% endfor %}
{% if priceBreakdown.periods is not empty %}
<tr class="border-t border-gray-200 font-medium">
@@ -158,7 +127,7 @@
</tr>
{% endif %}
{% for period in priceBreakdown.periods | filter(p => p.position == 'trailing') %}
{{ block('period_rows') }}
{% include 'admin/accommodation_booking/_price_period.html.twig' %}
{% endfor %}
<tr class="border-t border-gray-200 font-semibold">
<td class="pt-2">
@@ -192,153 +161,181 @@
</tr>
{% endif %}
</table>
</div>
{% endif %}
</twig:form:section>
{% endif %}
{% if booking.remarks %}
<div class="lg:col-span-2">
<h2 class="text-lg font-bold mb-4">Bemerkungen</h2>
<p class="text-sm">{{ booking.remarks | nl2br }}</p>
</div>
{% endif %}
</div>
<twig:form:section title="Kontakt">
<dl class="grid grid-cols-2 gap-y-2 text-sm">
<dt class="font-medium">Gruppenname</dt>
<dd>{{ booking.groupName }}</dd>
<dt class="font-medium">Anrede</dt>
<dd>{{ booking.salutation | default('–') }}</dd>
<dt class="font-medium">Name</dt>
<dd>{{ booking.firstName }} {{ booking.lastName }}</dd>
<dt class="font-medium">E-Mail</dt>
<dd>{{ booking.email }}</dd>
<dt class="font-medium">Telefon</dt>
<dd>{{ booking.phone | default('–') }}</dd>
<dt class="font-medium">Adresse</dt>
<dd>
{% if booking.street %}
{{ booking.street }}<br>
{{ booking.zip }} {{ booking.city }}
{% else %}
–
{% endif %}
</dd>
</dl>
</twig:form:section>
<div class="text-xs text-gray-400 mt-6">
Eingegangen: {{ booking.createdAt | date('d.m.Y, H:i') }}
{% if booking.updatedAt is not null and booking.updatedAt != booking.createdAt %}
· Aktualisiert: {{ booking.updatedAt | date('d.m.Y, H:i') }}
{% endif %}
</div>
{% if booking.draft or booking.requested %}
<div class="mt-6 border-t border-gray-200 pt-6">
<h2 class="text-lg font-bold mb-2">Angebot</h2>
<p class="text-sm text-gray-500 mb-2">
Das Angebot wurde dem Kunden noch nicht zugestellt. Beim Versand wird der Zugangslink
erzeugt und die Buchung wartet anschließend auf die Annahme durch den Kunden.
</p>
<div class="flex items-center gap-2">
<button type="button"
class="button button--primary button--small"
hx-get="{{ path('app_admin_accommodationbooking_send_offer', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Angebot per E-Mail senden
</button>
<button type="button"
class="button button--secondary button--small"
hx-get="{{ path('app_admin_accommodationbooking_generate_access_link', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Link erzeugen
</button>
</div>
{% if booking.remarks %}
<twig:form:section title="Bemerkungen">
<p class="text-sm">{{ booking.remarks | nl2br }}</p>
</twig:form:section>
{% endif %}
</div>
{% endif %}
{% if booking.received %}
<div class="mt-6 border-t border-gray-200 pt-6">
<h2 class="text-lg font-bold mb-2">Bestätigung</h2>
<p class="text-sm text-gray-500 mb-2">
Die Buchung des Kunden ist eingegangen, aber es wurde noch keine Bestätigung verschickt.
</p>
<button type="button"
class="button button--primary button--small"
hx-get="{{ path('app_admin_accommodationbooking_confirm', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Buchung per E-Mail bestätigen
</button>
</div>
{% endif %}
<aside class="space-y-6 xl:sticky xl:top-8">
<twig:form:section title="Status">
<dl class="grid grid-cols-2 gap-y-1 text-sm">
<dt class="text-gray-500">Status</dt>
<dd>{% include 'admin/accommodation_booking/_status_label.html.twig' %}</dd>
<dt class="text-gray-500">Herkunft</dt>
<dd>{{ booking.origin.label }}</dd>
{% if booking.managedBy is not null %}
<dt class="text-gray-500">Bearbeiter:in</dt>
<dd>{{ booking.managedBy.displayName }}</dd>
{% endif %}
{% if booking.acceptedAt is not null %}
<dt class="text-gray-500">Angenommen</dt>
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
{% endif %}
{% if booking.confirmedAt is not null %}
<dt class="text-gray-500">Bestätigt</dt>
<dd>{{ booking.confirmedAt | date('d.m.Y, H:i') }}</dd>
{% endif %}
<dt class="text-gray-500">Eingegangen</dt>
<dd>{{ booking.createdAt | date('d.m.Y, H:i') }}</dd>
{% if booking.updatedAt is not null and booking.updatedAt != booking.createdAt %}
<dt class="text-gray-500">Aktualisiert</dt>
<dd>{{ booking.updatedAt | date('d.m.Y, H:i') }}</dd>
{% endif %}
</dl>
</twig:form:section>
<div class="mt-6 border-t border-gray-200 pt-6">
<h2 class="text-lg font-bold mb-2">Zugangslink</h2>
{% if not booking.customerAccessible %}
<p class="text-sm text-gray-500">
Ein Zugangslink wird beim Versand des Angebots erzeugt.
</p>
{% elseif accessLink %}
<div class="flex items-center gap-2">
<button type="button"
class="button button--secondary button--small"
data-controller="clipboard"
data-clipboard-content-value="{{ accessLink }}"
data-clipboard-label-value="Kopiert!"
data-action="clipboard#copy">
{{ icon('copy', 'w-4 h-4') }} Link kopieren
</button>
<button type="button"
class="button button--secondary button--small"
hx-get="{{ path('app_admin_accommodationbooking_send_access_link', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Link senden
</button>
</div>
<p class="text-xs text-gray-500 mt-1">
Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }}
{% if accessLinkExpiresAt < date() %}
<span class="text-red-600 font-medium">(abgelaufen)</span>
{# One pink button per page, for the next step; everything else is grey, and the one
destructive action is set apart below the others. #}
{% set has_next_step = booking.draft or booking.requested or booking.received %}
{% if booking.draft or booking.requested %}
<twig:form:section title="Nächster Schritt: Angebot">
<p class="text-sm text-gray-500 mb-3">
Das Angebot wurde dem Kunden noch nicht zugestellt. Beim Versand wird der Zugangslink
erzeugt und die Buchung wartet anschließend auf die Annahme durch den Kunden.
</p>
<div class="flex flex-col gap-2">
<button type="button"
class="button button--primary button--small w-full"
hx-get="{{ path('app_admin_accommodationbooking_send_offer', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Angebot per E-Mail senden
</button>
<button type="button"
class="button button--neutral button--small w-full"
hx-get="{{ path('app_admin_accommodationbooking_generate_access_link', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
nur Link erzeugen
</button>
</div>
</twig:form:section>
{% elseif booking.received %}
<twig:form:section title="Nächster Schritt: Bestätigung">
<p class="text-sm text-gray-500 mb-3">
Die Buchung des Kunden ist eingegangen, aber es wurde noch keine Bestätigung verschickt.
</p>
<button type="button"
class="button button--primary button--small w-full"
hx-get="{{ path('app_admin_accommodationbooking_confirm', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Buchung per E-Mail bestätigen
</button>
</twig:form:section>
{% endif %}
<twig:form:section title="Zugangslink">
{% if not booking.customerAccessible %}
<p class="text-sm text-gray-500">
Ein Zugangslink wird beim Versand des Angebots erzeugt.
</p>
{% elseif accessLink %}
<div class="grid grid-cols-2 gap-2">
<button type="button"
class="button button--neutral button--small sm:text-xs w-full"
data-controller="clipboard"
data-clipboard-content-value="{{ accessLink }}"
data-clipboard-label-value="Kopiert!"
data-action="clipboard#copy">
{{ icon('copy', 'w-4 h-4') }} Link kopieren
</button>
<button type="button"
class="button button--neutral button--small sm:text-xs w-full"
hx-get="{{ path('app_admin_accommodationbooking_send_access_link', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Link senden
</button>
</div>
<p class="text-xs text-gray-500 mt-2">
Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }}
{% if accessLinkExpiresAt < date() %}
<span class="text-red-600 font-medium">(abgelaufen)</span>
{% endif %}
</p>
{% else %}
<p class="text-sm text-gray-500">
Der Zugangslink wird beim Versand des Angebots erzeugt.
</p>
{% endif %}
</p>
{% else %}
<p class="text-sm text-gray-500">
Der Zugangslink wird beim Versand des Angebots erzeugt.
</p>
{% endif %}
</div>
</twig:form:section>
<div class="flex justify-between pt-8">
<a href="{{ returnUrl }}" class="button button--secondary button--small">
zurück
</a>
<div class="flex items-center gap-2">
{% if not booking.confirmed and not booking.discarded %}
<button type="button"
class="button button--warning button--small"
hx-get="{{ path('app_admin_accommodationbooking_discard', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Absagen
</button>
{% endif %}
{% if booking.confirmed %}
<a href="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}" class="button button--secondary button--small">
PDF herunterladen
</a>
{% endif %}
{% set edit_params = { 'id': booking.id } %}
{% if app.request.query.get('r') %}
{% set edit_params = edit_params | merge({ 'r': app.request.query.get('r') }) %}
{% endif %}
<button type="button"
class="button button--secondary button--small"
hx-get="{{ path('app_admin_accommodationbooking_duplicate', edit_params) }}"
hx-target="body"
hx-swap="beforeend">
duplizieren
</button>
<a href="{{ path('app_admin_accommodationbooking_edit', edit_params) }}" class="button button--primary button--small">
bearbeiten
<twig:form:section title="Aktionen">
<div class="flex flex-col gap-2">
<a href="{{ path('app_admin_accommodationbooking_edit', edit_params) }}"
class="button {{ has_next_step ? 'button--neutral' : 'button--primary' }} button--small w-full">
bearbeiten
</a>
<button type="button"
class="button button--neutral button--small w-full"
hx-get="{{ path('app_admin_accommodationbooking_duplicate', edit_params) }}"
hx-target="body"
hx-swap="beforeend">
duplizieren
</button>
{% if booking.confirmed %}
<a href="{{ path('app_admin_accommodationbooking_pdf', { 'id': booking.id }) }}"
class="button button--neutral button--small w-full">
PDF herunterladen
</a>
{% endif %}
</div>
{% if not booking.confirmed and not booking.discarded %}
<div class="mt-4 pt-3 border-t border-gray-200 text-center">
<button type="button"
class="text-sm font-medium text-red-600 hover:underline"
hx-get="{{ path('app_admin_accommodationbooking_discard', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
{{ booking.recordLabel }} absagen
</button>
</div>
{% endif %}
</twig:form:section>
<a href="{{ returnUrl }}" class="inline-block text-sm text-gray-500 hover:text-gray-900 hover:underline">
← zurück
</a>
</div>
</aside>
</div>
{% endblock %}
{# One split-off period of the breakdown: its range as a heading, then its lines. #}
{% block period_rows %}
<tr class="border-t border-gray-200 font-medium">
<td class="pt-2 pb-1" colspan="2">
{% if period.label ?? null %}{{ period.label }} · {% endif %}
{{ period.dateFrom | date('d.m.Y') }} – {{ period.dateTo | date('d.m.Y') }},
{{ period.nights }} {{ period.nights != 1 ? 'Nächte' : 'Nacht' }}
</td>
</tr>
{% for line in period.lines %}
<tr>
<td class="py-1">{{ line.label }}</td>
<td class="py-1 text-right whitespace-nowrap">{{ (line.amount / 100) | format_currency(currency) }}</td>
</tr>
{% endfor %}
{% endblock %}
@@ -0,0 +1,11 @@
{#- A titled panel grouping the fields of a long admin form. No overflow-hidden: the dots
dropdowns inside must be able to open past its edge. -#}
<div {{ attributes.defaults({class: 'bg-white border border-gray-200 rounded-md'}).without('title') }}>
<div class="flex items-center justify-between gap-4 px-4 py-2 bg-gray-100 border-b border-gray-200 rounded-t-md font-bold">
{{ title }}
{% block actions %}{% endblock %}
</div>
<div class="p-4">
{% block content %}{% endblock %}
</div>
</div>
@@ -12,6 +12,7 @@ use App\Exception\ImplausibleBookingPriceException;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use App\Repository\UserRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -72,6 +73,7 @@ class EditControllerTest extends TestCase
$this->createStub(AdditionalServiceRepository::class),
$this->createStub(UserRepository::class),
$bookingService,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$form,
);
@@ -108,6 +110,7 @@ class EditControllerTest extends TestCase
$this->createStub(AdditionalServiceRepository::class),
$this->createStub(UserRepository::class),
$bookingService,
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$form,
);
@@ -171,6 +174,7 @@ class EditControllerTest extends TestCase
$this->createStub(AdditionalServiceRepository::class),
$userRepo,
$this->createStub(AccommodationBookingService::class),
$this->createStub(AccommodationBookingBreakdownCalculator::class),
$form,
$granted,
);
@@ -189,10 +193,11 @@ final class TestableEditController extends EditController
AdditionalServiceRepository $additionalServiceRepo,
UserRepository $userRepo,
AccommodationBookingService $bookingService,
AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly FormInterface $form,
private readonly bool $granted = false,
) {
parent::__construct($entityManager, $logger, $boardServiceRepo, $additionalServiceRepo, $userRepo, $bookingService);
parent::__construct($entityManager, $logger, $boardServiceRepo, $additionalServiceRepo, $userRepo, $bookingService, $breakdownCalculator);
}
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface