feat: groups price calculator admin crud, booking/offer flow and api

This commit is contained in:
Björn Fromme
2026-08-03 15:25:10 +02:00
parent eabed8295a
commit 9d2aa11fdb
258 changed files with 16231 additions and 582 deletions
@@ -0,0 +1,53 @@
{% extends 'layout_admin.html.twig' %}
{% block content %}
<twig:page:heading>Neue Buchung</twig:page:heading>
{% form_theme form 'forms_admin.html.twig' %}
{{ form_start(form) }}
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
<div class="lg:col-span-2">
{{ form_row(form.accommodation) }}
</div>
<h2 class="lg:col-span-2 text-base font-bold pt-2">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) }}
<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) }}
<h2 class="lg:col-span-2 text-base font-bold pt-2">Status & Rabatt</h2>
<div class="lg:col-span-2">
{{ form_row(form.isInquiry) }}
</div>
{{ form_row(form.accommodationDiscount) }}
{{ form_row(form.boardServiceDiscount) }}
{{ form_row(form.additionalServicesDiscount) }}
<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') }}" class="button button--secondary button--small">
zurück
</a>
<button type="submit" class="button button--primary button--small">
speichern
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -0,0 +1,67 @@
{% extends 'layout_admin.html.twig' %}
{% block content %}
<twig:page:heading>Buchung {{ booking.groupName }}</twig:page:heading>
<p class="text-sm text-gray-500 mb-6">{{ booking.accommodation.name }}</p>
{% 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) }}
<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) }}
{% 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) }}
{% endif %}
{% if form.selectedAdditionalServices is defined %}
<div class="lg:col-span-2">
{{ form_row(form.selectedAdditionalServices) }}
</div>
{% endif %}
{% endif %}
<h2 class="lg:col-span-2 text-base font-bold pt-2">Status & Rabatt</h2>
<div class="lg:col-span-2">
{{ form_row(form.isInquiry) }}
</div>
{% 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 %}
{{ form_row(form.accommodationDiscount) }}
{{ form_row(form.boardServiceDiscount) }}
{{ form_row(form.additionalServicesDiscount) }}
<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', { 'id': booking.id }) }}" class="button button--secondary button--small">
zurück
</a>
<button type="submit" class="button button--primary button--small">
speichern
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -0,0 +1,100 @@
{% extends 'layout_admin.html.twig' %}
{% block content %}
<twig:page:heading>Buchungen & Anfragen</twig:page:heading>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner text-sm">
<table class="data-table">
<thead>
<tr>
<th>
{{ knp_pagination_sortable(pagination, 'Eingang', 'booking.createdAt') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Gruppenhaus', 'accommodation.name') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Gruppe', 'booking.groupName') }}
</th>
<th>
{{ knp_pagination_sortable(pagination, 'Anreise', 'booking.dateFrom') }}
</th>
<th>
Personen
</th>
<th>
Status
</th>
<th>
Rabatt
</th>
<th></th>
</tr>
</thead>
<tbody>
{% for booking in pagination %}
<tr>
<td>
{{ booking.createdAt | date('d.m.Y, H:i') }}
</td>
<td>
{{ booking.accommodation.name }}
</td>
<td>
{{ booking.groupName }}
</td>
<td>
{{ booking.dateFrom | date('d.m.Y') }} {{ booking.dateTo | date('d.m.Y') }}
</td>
<td>
{{ booking.paxCount }}
</td>
<td>
{% if booking.isInquiry %}
Anfrage
{% else %}
Buchung
{% endif %}
</td>
<td>
{% set discounts = [] %}
{% if booking.accommodationDiscount is not null %}
{% set discounts = discounts | merge(['Unterkunft ' ~ booking.accommodationDiscount ~ '%']) %}
{% endif %}
{% if booking.boardServiceDiscount is not null %}
{% set discounts = discounts | merge(['Verpflegung ' ~ booking.boardServiceDiscount ~ '%']) %}
{% endif %}
{% if booking.additionalServicesDiscount is not null %}
{% set discounts = discounts | merge(['Zusatzleistungen ' ~ booking.additionalServicesDiscount ~ '%']) %}
{% endif %}
{{ discounts | length > 0 ? (discounts | join(', ')) : '' }}
</td>
<td class="text-right">
<twig:dropdown>
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_show', { 'id': booking.id, 'r': return_url() }) }}">
Details
</twig:dropdown:link>
<twig:dropdown:link url="{{ path('app_admin_accommodationbooking_edit', { 'id': booking.id }) }}">
bearbeiten
</twig:dropdown:link>
</twig:dropdown>
</td>
</tr>
{% else %}
<tr>
<td colspan="8">
Keine Daten...
</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ knp_pagination_render(pagination) }}
</div>
</div>
<div class="flex justify-end">
<a href="{{ path('app_admin_accommodationbooking_create') }}" class="button button--primary button--small">
neu
</a>
</div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Möchtest du für <em>{{ booking.groupName }}</em> einen neuen Zugangslink generieren?
Ein zuvor generierter Link wird dadurch ungültig.
</div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Möchtest du den aktuellen Zugangslink für <em>{{ booking.groupName }}</em> an
<em>{{ booking.email }}</em> senden?
</div>
{% endblock %}
@@ -0,0 +1,237 @@
{% extends 'layout_admin.html.twig' %}
{% block content %}
<twig:page:heading>
{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}
{{ booking.groupName }}
</twig:page:heading>
<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>
<dt class="font-medium">Anzahl Personen</dt>
<dd>{{ booking.paxCount }}</dd>
{% if booking.minorsCount > 0 %}
<dt class="font-medium">davon Kinder (03 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">Status</dt>
<dd>{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}</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 %}
</dl>
</div>
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
<div>
<h2 class="text-lg font-bold mb-4">Gebuchte Leistungen</h2>
<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>
</div>
{% endif %}
{% if priceBreakdown is not null %}
<div>
<h2 class="text-lg font-bold mb-4">Preise</h2>
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<table class="w-full text-sm">
{% if priceBreakdown.basePrice > 0 %}
<tr>
<td class="py-1">
Basispreis
<span class="text-xs text-gray-500">für {{ priceBreakdown.includedPax }} Pers.</span>
</td>
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.basePrice / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.additionalPersonsPrice > 0 %}
<tr>
<td class="py-1">
Aufpreis Personenzahl
<span class="text-xs text-gray-500">für {{ priceBreakdown.effectivePax - priceBreakdown.includedPax }} Pers.</span>
</td>
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.additionalPersonsPrice / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.shortTermSurcharge > 0 %}
<tr>
<td class="py-1">Aufpreis Kurzzeit</td>
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.shortTermSurcharge / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.boardPrice > 0 %}
<tr>
<td class="py-1">Verpflegung</td>
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.boardPrice / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.undersubscriptionSurcharge > 0 %}
<tr>
<td class="py-1">Verpflegungs-Aufschlag für Gruppen unter {{ priceBreakdown.undersubscriptionThreshold }} Personen</td>
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.undersubscriptionSurcharge / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% for service in priceBreakdown.serviceDetails %}
{% if service.price > 0 %}
<tr>
<td class="py-1">{{ service.label }}</td>
<td class="py-1 text-right whitespace-nowrap">{{ (service.price / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% endfor %}
{% if priceBreakdown.runningCosts > 0 %}
<tr>
<td class="py-1">Strom- und Abfallgebühren</td>
<td class="py-1 text-right whitespace-nowrap">{{ (priceBreakdown.runningCosts / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
<tr class="border-t border-gray-200 font-semibold">
<td class="pt-2">
Gesamtpreis
<span class="block text-xs font-normal text-gray-500">zzgl. ortsabhängiger Gebühren</span>
</td>
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.total / 100) | format_currency(currency) }}</td>
</tr>
{% if booking.accommodationDiscount is not null or booking.boardServiceDiscount is not null or booking.additionalServicesDiscount is not null %}
{% if booking.accommodationDiscount is not null %}
{% set accommodationDiscountAmount = ((priceBreakdown.basePrice + priceBreakdown.additionalPersonsPrice) * booking.accommodationDiscount / 100) | round %}
<tr class="text-green-700">
<td class="pt-1">Rabatt Unterkunft {{ booking.accommodationDiscount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap"> {{ (accommodationDiscountAmount / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% if booking.boardServiceDiscount is not null %}
{% set boardDiscountAmount = (priceBreakdown.boardPrice * booking.boardServiceDiscount / 100) | round %}
<tr class="text-green-700">
<td class="pt-1">Rabatt Verpflegung {{ booking.boardServiceDiscount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap"> {{ (boardDiscountAmount / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% if booking.additionalServicesDiscount is not null %}
{% set servicesDiscountAmount = (priceBreakdown.servicesPrice * booking.additionalServicesDiscount / 100) | round %}
<tr class="text-green-700">
<td class="pt-1">Rabatt Zusatzleistungen {{ booking.additionalServicesDiscount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap"> {{ (servicesDiscountAmount / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
<tr class="border-t border-gray-200 font-bold text-green-700">
<td class="pt-2">Gesamtpreis nach Rabatt</td>
<td class="pt-2 text-right whitespace-nowrap">{{ ((booking.totalPrice ?? priceBreakdown.total) / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
</table>
</div>
{% 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>
<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>
<div class="mt-6 border-t border-gray-200 pt-6">
<h2 class="text-lg font-bold mb-2">Zugangslink</h2>
{% if accessLink %}
<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>
<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>
{% endif %}
</p>
{% else %}
<p class="text-sm text-gray-500">Es wurde noch kein Zugangslink generiert.</p>
{% endif %}
<div class="mt-2 flex items-center gap-2">
<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">
{{ accessLink ? 'Link erneut generieren' : 'Link generieren' }}
</button>
{% if accessLink %}
<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>
{% endif %}
</div>
</div>
<div class="flex justify-between pt-8">
<a href="{{ returnUrl }}" class="button button--secondary button--small">
zurück
</a>
<a href="{{ path('app_admin_accommodationbooking_edit', { 'id': booking.id }) }}" class="button button--primary button--small">
bearbeiten
</a>
</div>
{% endblock %}