feat: implement screendesign

This commit is contained in:
Björn Fromme
2025-12-06 15:02:33 +01:00
parent b206c3990b
commit bfb1a04ea0
84 changed files with 2807 additions and 2603 deletions
-5
View File
@@ -1,5 +0,0 @@
<div class="mt-8 pt-4 border-t border-gray-200 text-center">
<a href="{{ path('app_booking_cancel') }}" class="text-sm text-gray-600 hover:text-gray-900 hover:underline">
Buchung abbrechen und zur Startseite zurückkehren
</a>
</div>
+75
View File
@@ -0,0 +1,75 @@
{%- block form_row -%}
{%- set row_attr = row_attr|merge({ 'class': (row_attr.class|default('') ~ ' mb-4')|trim }) -%}
{%- set widget_attr = {} -%}
{%- if help is not empty -%}
{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
{%- endif -%}
{%- if form.vars.expanded is defined and form.vars.expanded -%}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="{{ html_classes('w-full bg-primary-bg p-2 font-semibold uppercase', { 'text-red-500': form.vars.errors | length > 0 }) }}">
{{ label }}
{%- if required -%}<span>*</span>{%- endif -%}
</legend>
{{- form_widget(form, widget_attr) -}}
</fieldset>
{{- form_errors(form) -}}
{{- form_help(form) -}}
</div>
{%- else -%}
<div{% with {attr: row_attr} %}{{ block('attributes') }}{% endwith %}>
{{- form_label(form) -}}
{{- form_widget(form, widget_attr) -}}
{{- form_errors(form) -}}
{{- form_help(form) -}}
</div>
{%- endif -%}
{%- endblock form_row -%}
{%- block choice_widget_expanded -%}
<table class="w-full table-fixed border-collapse">
{%- for child in form %}
{% set choiceData = form.vars.choices[loop.index0].data %}
{% set description = child.vars.attr['data-description']|default(null) %}
{% set isReadonly = child.vars.attr.readonly is defined %}
{%- set child_attr = {} -%}
{%- if attr['hx-trigger'] is defined -%}
{%- set child_attr = {
'hx-trigger': attr['hx-trigger'],
'hx-post': attr['hx-post'],
'hx-swap': attr['hx-swap']
} -%}
{%- endif -%}
<tr>
<td class="border border-primary-bg p-2 align-top">
<div>{{- child.vars.label -}}</div>
{%- if description is not null -%}
<div {{ stimulus_controller('tooltip') }}>
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
Details
</button>
<template data-tooltip-target="template">
<div class="text-sm">{{ description|raw }}</div>
</template>
</div>
{%- endif -%}
</td>
{% if choiceData %}
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if choiceData.price %}
{{ choiceData.price | format_currency('EUR') }}
{% endif %}
</td>
{% endif %}
<td class="border border-primary-bg p-2 align-top w-12 text-center">
{% set childTooltip = child.vars.attr['data-tooltip']|default(null) %}
<div class="{{ html_classes({ 'cursor-not-allowed': isReadonly }) }}"{% if childTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': childTooltip }) }}{% endif %}>
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}">
{{- form_widget(child, { 'attr': child_attr }) -}}
</div>
</div>
</td>
</tr>
{% endfor -%}
</table>
{%- endblock choice_widget_expanded -%}
+30
View File
@@ -0,0 +1,30 @@
{% set current_step = current_step ?? 1 %}
{% set step_routes = {
1: 'app_booking_create_step_1',
2: 'app_booking_create_step_2',
3: 'app_booking_create_step_3',
4: 'app_booking_create_step_4'
} %}
<nav class="pagination">
{% for step in 1..4 %}
<div class="pagination-item">
{% if step < current_step %}
<a href="{{ path(step_routes[step]) }}"
class="pagination-item__border" {{ stimulus_action('loading', 'toggle') }}>
<span class="pagination-item__inner bg-primary-bg text-gray-800">
{{ step }}
</span>
</a>
{% else %}
<span class="pagination-item__border">
<span class="{{ html_classes('pagination-item__inner', {
'bg-primary-light text-white': step == current_step,
'bg-primary-bg text-gray-800': step != current_step
}) }}">
{{ step }}
</span>
</span>
{% endif %}
</div>
{% endfor %}
</nav>
+32 -54
View File
@@ -4,66 +4,44 @@
{% set errorMessages = cardData.errorMessages|default([]) %}
{% set mode = mode|default('create') %}
<div id="participant-card-{{ index }}"
class="{{ html_classes('border rounded p-4', { 'border-gray-400 bg-gray-50': isCanceled, 'border-red-700': not isValid }) }}">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-start gap-3">
<div class="flex-shrink-0"
{{ stimulus_controller('gravatar', {
url: gravatar_url(cardData.email),
alt: cardData.name
}, {
image: 'w-12 h-12 rounded-full'
}) }}
{{ qa_attribute('participant-avatar', index) }}>
{{ icon('user', 'w-12 h-12 text-gray-400') }}
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-semibold {{ isCanceled ? 'text-gray-600' : '' }}" {{ qa_attribute('participant-name', index) }}>
{{ cardData.name }}
</h3>
{% if isCanceled %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-700 text-white" {{ qa_attribute('participant-canceled', index) }}>
storniert
</span>
{% endif %}
{% if not isValid %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-700 text-white" {{ qa_attribute('participant-invalid', index) }}>
unvollständige oder fehlerhafte Daten
</span>
{% endif %}
</div>
<p class="text-sm text-gray-600" {{ qa_attribute('participant-room-name', index) }}>{{ cardData.roomName }}</p>
</div>
</div>
<div class="py-4" id="participant-card-{{ index }}">
<div class="flex items-start space-x-4">
<div class="{{ html_classes('w-12 h-12 inline-flex flex-shrink-0 items-center justify-center rounded-full text-white text-2xl font-bold', { 'bg-primary-dark': isValid, 'bg-red-500': not isValid }) }}">
{{ participantNumber }}
</div>
<div class="flex items-center gap-4">
<span class="font-medium {{ isCanceled ? 'text-gray-500' : '' }}" {{ qa_attribute('participant-price', index) }}>{{ cardData.price }}</span>
<div class="flex-1 leading-5">
<div class="font-semibold mb-1">
{{ cardData.name }}
</div>
{% if isCanceled %}
<button type="button"
class="button bg-button bg-button--secondary opacity-50 cursor-not-allowed"
disabled
title="Stornierte Teilnehmer können nicht bearbeitet werden">
Bearbeiten
</button>
<div class="text-xs font-medium text-gray-700" {{ qa_attribute('participant-canceled', index) }}>
storniert
</div>
{% elseif not isValid %}
<div class="text-xs font-medium text-red-500" {{ qa_attribute('participant-invalid', index) }}>
unvollständige/fehlerhafte Daten
</div>
{% else %}
<div class="text-gray-600" {{ qa_attribute('participant-room-name', index) }}>
{{ cardData.roomName }}
<br>
{{ cardData.price }}
</div>
{% endif %}
</div>
<div class="flex flex-col justify-center">
{% if not isCanceled %}
{% if mode == 'edit' %}
<a href="{{ path('app_booking_edit_participant', {id: bookingId, index: index}) }}"
class="button bg-button bg-button--secondary"
data-action="click->loading#show"
{{ qa_attribute('btn-edit-participant', index) }}>
Bearbeiten
</a>
{% set url = path('app_booking_edit_participant', {id: bookingId, index: index}) %}
{% else %}
<a href="{{ path('app_booking_create_step_2_participant', {index: index}) }}"
class="button bg-button bg-button--secondary"
data-action="click->loading#show"
{{ qa_attribute('btn-edit-participant', index) }}>
Bearbeiten
</a>
{% set url = path('app_booking_create_step_2_participant', {index: index}) %}
{% endif %}
<a href="{{ url }}"
class="button button--small button--primary"
data-action="click->loading#show"
{{ qa_attribute('btn-edit-participant', index) }}>
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M92.69,216H48a8,8,0,0,1-8-8V163.31a8,8,0,0,1,2.34-5.65L165.66,34.34a8,8,0,0,1,11.31,0L221.66,79a8,8,0,0,1,0,11.31L98.34,213.66A8,8,0,0,1,92.69,216Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="136" y1="64" x2="192" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="164" y1="92" x2="68" y2="188" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="95.49" y1="215.49" x2="40.51" y2="160.51" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
{% endif %}
</div>
</div>
+491 -358
View File
@@ -1,5 +1,3 @@
{% import _self as macros %}
{# Macro to render a form field or static value based on field state #}
{% macro field_or_static(form, fieldName, label, staticValue, bookingDto, participantIndex, options = {}) %}
{% if form[fieldName] is defined %}
@@ -27,384 +25,519 @@
{% endif %}
{% endmacro %}
{# Specialized macro for checkbox fields (like rental insurance) that need manual fieldset wrapping #}
{% macro checkbox_field(form, fieldName, label, options = {}, undefinedLabel = 'Nicht wählbar') %}
{% if form[fieldName] is defined %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">{{ label }}</legend>
{{ form_row(form[fieldName], options) }}
</fieldset>
{% else %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">{{ label }}</legend>
{% if undefinedLabel %}
<div class="text-sm text-gray-500">{{ undefinedLabel }}</div>
{# Macro to render a service choice table row with optional price and tooltip #}
{% macro service_choice_row(child, price, tooltipText, tooltipLabel, widgetAttr = {}) %}
{% set isReadonly = child.vars.attr.readonly is defined %}
<tr>
<td class="border border-primary-bg p-2 align-top">
<div>{{ child.vars.label }}</div>
{%- if tooltipText is not null -%}
<div {{ stimulus_controller('tooltip') }}>
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
{{ tooltipLabel|default('Details') }}
</button>
<template data-tooltip-target="template">
<div class="text-sm">{{ tooltipText|raw }}</div>
</template>
</div>
{%- endif -%}
</td>
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if price %}
{{ price | format_currency('EUR') }}
{% endif %}
</fieldset>
{% endif %}
</td>
<td class="border border-primary-bg p-2 align-top w-12 text-center">
{% set childTooltip = child.vars.attr['data-tooltip']|default(null) %}
<div class="{{ html_classes({ 'cursor-not-allowed': isReadonly }) }}"{% if childTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': childTooltip }) }}{% endif %}>
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}">
{{ form_widget(child, { 'attr': widgetAttr }) }}
</div>
</div>
</td>
</tr>
{% endmacro %}
{# Macro to render a checkbox table row (for trailing checkboxes like rental insurance, parking, bulk insurance) #}
{% macro checkbox_row(checkboxField, label, price, tooltipText, tooltipLabel, widgetAttr = {}) %}
{% set isReadonly = checkboxField.vars.attr.readonly is defined %}
<tr>
<td class="border border-primary-bg p-2 align-top">
<div>{{ label }}</div>
{%- if tooltipText is not null -%}
<div {{ stimulus_controller('tooltip') }}>
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
{{ tooltipLabel|default('Info') }}
</button>
<template data-tooltip-target="template">
<div class="text-sm">{{ tooltipText|raw }}</div>
</template>
</div>
{%- endif -%}
</td>
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if price %}
{{ price | format_currency('EUR') }}
{% endif %}
</td>
<td class="border border-primary-bg p-2 align-top w-12 text-center">
{% set fieldTooltip = checkboxField.vars.attr['data-tooltip']|default(null) %}
<div class="{{ html_classes({ 'cursor-not-allowed': isReadonly }) }}"{% if fieldTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': fieldTooltip }) }}{% endif %}>
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}">
{{ form_widget(checkboxField, { 'attr': widgetAttr }) }}
</div>
</div>
</td>
</tr>
{% endmacro %}
{# Macro to render insurance choice table row with product info links #}
{% macro insurance_choice_row(child, price, urlsProductInfo, widgetAttr = {}) %}
{% set isReadonly = child.vars.attr.readonly is defined %}
<tr>
<td class="border border-primary-bg p-2 align-top">
<div>{{ child.vars.label }}</div>
{%- if urlsProductInfo is not empty -%}
<div {{ stimulus_controller('tooltip') }}>
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
Details
</button>
<template data-tooltip-target="template">
<div class="text-sm">
{%- for url in urlsProductInfo -%}
<a href="{{ url }}" target="_blank" rel="noopener noreferrer" class="block">
Produktinformation {{ urlsProductInfo|length > 1 ? loop.index : '' }}
</a>
{%- endfor -%}
</div>
</template>
</div>
{%- endif -%}
</td>
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if price %}
{{ price | format_currency('EUR') }}
{% endif %}
</td>
<td class="border border-primary-bg p-2 align-top w-12 text-center">
{% set childTooltip = child.vars.attr['data-tooltip']|default(null) %}
<div class="{{ html_classes({ 'cursor-not-allowed': isReadonly }) }}"{% if childTooltip is not null %} {{ stimulus_controller('tooltip', { 'content': childTooltip }) }}{% endif %}>
<div class="{{ isReadonly ? 'pointer-events-none' : '' }}">
{{ form_widget(child, { 'attr': widgetAttr }) }}
</div>
</div>
</td>
</tr>
{% endmacro %}
{# Standalone participant form view (replaces main content area) #}
{% block participant_form %}
{% import _self as macros %}
<div id="participant-form-view">
<div class="flex items-center gap-4 mb-6">
<div class="flex-shrink-0"
{{ stimulus_controller('gravatar', {
url: gravatar_url(form.vars.data.participant.email ?? ''),
alt: participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1)
}, {
image: 'w-16 h-16 rounded-full'
}) }}>
{{ icon('user', 'w-16 h-16 text-gray-400') }}
{% form_theme form 'booking/_form_theme.html.twig' %}
{% import _self as macros %}
<div class="flex items-center gap-4 pb-4 mb-4 border-b border-primary-bg">
<div class="w-12 h-12 inline-flex flex-shrink-0 items-center justify-center rounded-full bg-primary-dark text-white text-2xl font-bold">
{{ participantIndex + 1 }}
</div>
<div class="text-2xl">
{{ participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}
</div>
<h2 class="text-2xl">{{ participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}</h2>
</div>
{{ form_start(form, {
'attr': {
'novalidate': 'novalidate',
'hx-post': path(submitRouteName, submitRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
<div id="participant-form" class="space-y-4">
{# Personal data section #}
<div class="grid grid-cols-2 gap-4">
{{ macros.field_or_static(form, 'firstName', 'Vorname', form.vars.data.participant.firstName, bookingDto, participantIndex) }}
{{ macros.field_or_static(form, 'lastName', 'Nachname', form.vars.data.participant.lastName, bookingDto, participantIndex) }}
<div id="participant-form" class="space-y-4">
{{ macros.field_or_static(
form,
'dateOfBirth',
'Geburtsdatum',
form.vars.data.participant.dateOfBirth ? form.vars.data.participant.dateOfBirth|date('d.m.Y') : null,
bookingDto,
participantIndex,
{
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}
) }}
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
{{ validation_alert(form) }}
{% set genderLabel = form.vars.data.participant.gender == 'M' ? 'männlich' : (form.vars.data.participant.gender == 'W' ? 'weiblich' : (form.vars.data.participant.gender == 'D' ? 'divers' : null)) %}
{{ macros.field_or_static(form, 'gender', 'Geschlecht', genderLabel, bookingDto, participantIndex) }}
{# Eligibility checks #}
{% set participantData = form.vars.data.participant %}
{% set hasDateOfBirth = participantData and participantData.dateOfBirth %}
{% set isEligible = hasDateOfBirth and is_participant_eligible(bookingDto, participantIndex) %}
{{ macros.field_or_static(form, 'nationality', 'Nationalität', form.vars.data.participant.nationality|map_nationality, bookingDto, participantIndex) }}
</div>
{% if not hasDateOfBirth %}
{% include '_partials/_alert.html.twig' with {
level: 'info',
messages: ['Leistungen sind erst nach Angabe des Geburtsdatums buchbar']
} %}
{% elseif not isEligible %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
messages: ['Buchung wegen des Alters von Teilnehmer:in ' ~ (participantIndex + 1) ~ ' nicht möglich']
} %}
{% endif %}
{# Personal data section #}
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ macros.field_or_static(form, 'firstName', 'Vorname', form.vars.data.participant.firstName, bookingDto, participantIndex) }}
{{ macros.field_or_static(form, 'lastName', 'Nachname', form.vars.data.participant.lastName, bookingDto, participantIndex) }}
{{ macros.field_or_static(
form,
'dateOfBirth',
'Geburtsdatum',
form.vars.data.participant.dateOfBirth ? form.vars.data.participant.dateOfBirth|date('d.m.Y') : null,
bookingDto,
participantIndex,
{
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}
) }}
{% set genderLabel = form.vars.data.participant.gender == 'M' ? 'männlich' : (form.vars.data.participant.gender == 'W' ? 'weiblich' : (form.vars.data.participant.gender == 'D' ? 'divers' : null)) %}
{{ macros.field_or_static(form, 'gender', 'Geschlecht', genderLabel, bookingDto, participantIndex) }}
{{ macros.field_or_static(form, 'nationality', 'Nationalität', form.vars.data.participant.nationality|map_nationality, bookingDto, participantIndex) }}
{# Contact information #}
<div class="grid grid-cols-2 gap-4">
{{ macros.field_or_static(form, 'email', 'E-Mail', form.vars.data.participant.email, bookingDto, participantIndex) }}
{{ macros.field_or_static(form, 'mobile', 'Telefon (mobil)', form.vars.data.participant.mobile, bookingDto, participantIndex) }}
</div>
{# Address #}
{% if form.address is defined %}
{# Address field is in form - render as editable form fields #}
<div class="grid grid-cols-2 gap-4">
{{ form_row(form.address.street) }}
{{ form_row(form.address.postCode) }}
{{ form_row(form.address.city) }}
{{ form_row(form.address.country) }}
</div>
{% elseif is_static_text('address', bookingDto, participantIndex) %}
{# Address excluded from form but should render as static text #}
{# For applicant (index 0), use booking.applicant.address which has full data from BPN #}
{# For other participants, use participants[index].address (may only have country in BPN response) #}
{% set addressSource = (0 == participantIndex and bookingDto.booking) ? bookingDto.booking.applicant : bookingDto.participants[participantIndex] %}
<div>
<label class="font-semibold mb-1 block">Adresse</label>
{% if addressSource.address %}
<div class="text-sm text-gray-600">
{% if addressSource.address.street %}{{ addressSource.address.street }}<br>{% endif %}
{% if addressSource.address.postCode or addressSource.address.city %}
{{ addressSource.address.postCode }} {{ addressSource.address.city }}<br>
{% endif %}
{% if addressSource.address.country %}{{ addressSource.address.country|map_country }}{% endif %}
</div>
{% else %}
<div class="text-sm text-gray-600">-</div>
{% endif %}
</div>
{% endif %}
{# Room assignment - hidden until date of birth is provided, or shown as static text in edit mode #}
{% if form.assignedRoomId is defined or is_static_text('assignedRoomId', bookingDto, participantIndex) %}
<div class="grid grid-cols-2 gap-4">
{% set assignedRoom = bookingDto.travel.getRoomById(form.vars.data.participant.assignedRoomId) %}
{% set roomLabel = assignedRoom ? assignedRoom.label : 'Kein Zimmer zugewiesen' %}
{{ macros.field_or_static(form, 'assignedRoomId', 'Zimmer', roomLabel, bookingDto, participantIndex, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
{% if form.remarksRoom is defined %}
{{ form_row(form.remarksRoom) }}
{% endif %}
</div>
{% endif %}
{# Eligibility checks #}
{% set participantData = form.vars.data.participant %}
{% set hasDateOfBirth = participantData and participantData.dateOfBirth %}
{% set isEligible = hasDateOfBirth and is_participant_eligible(bookingDto, participantIndex) %}
{% if not hasDateOfBirth %}
<div class="my-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<p class="text-sm text-blue-800">
Leistungen sind erst nach Angabe des Geburtsdatums buchbar
</p>
</div>
</div>
{% elseif not isEligible %}
<div class="my-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-center">
<svg class="w-5 h-5 text-red-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
<p class="text-sm text-red-800">
Buchung wegen des Alters von Teilnehmer:in {{ participantIndex + 1 }} nicht möglich
</p>
</div>
</div>
{% endif %}
{# Service selection #}
{% if isEligible %}
<div class="grid grid-cols-2 gap-4">
{{ macros.service_field(form, 'skiPass', 'Skipass', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'courses', 'Kurse', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'additionalServices', 'Zusatzleistungen', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'board', 'Verpflegung', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
<div>
{{ macros.service_field(form, 'rentals', 'Leihmaterial', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}, 'Bitte zuerst den Skipass auswählen') }}
{{ macros.checkbox_field(form, 'rentalInsurance', null, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}, null) }}
</div>
{% if form.bodyDimensions is defined %}
<div class="grid gap-y-4">
{{ form_row(form.bodyDimensions.height) }}
{{ form_row(form.bodyDimensions.shoeSize) }}
{{ form_row(form.bodyDimensions.weight) }}
</div>
{% else %}
<div>{# empty slot to maintain grid layout #}</div>
{% endif %}
<div class="col-span-2">
{# Insurance display for edit mode (read-only) #}
{% if bookingDto.mode == constant('App\\Form\\Model\\BookingDto::MODE_EDIT') and form.vars.data.participant.insurance %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">Reiseversicherung</legend>
<div class="text-sm text-gray-600">
{{ form.vars.data.participant.insurance.label }}
{% if form.vars.data.participant.insurance.price and form.vars.data.participant.insurance.price > 0 %}
<span class="text-gray-500">(€{{ form.vars.data.participant.insurance.price|number_format(2, ',', '.') }})</span>
{% endif %}
</div>
</fieldset>
{# Insurance field OR assigned insurance display for dependent participants (create mode) #}
{% else %}
{% set showBulkInsurance = participantIndex > 0 and bookingDto.participants[0].bulkInsuranceBooking %}
{% if showBulkInsurance %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">Reiseversicherung</legend>
<div class="text-sm text-gray-600">
{% set applicantInsurance = bookingDto.participants[0].insurance %}
{% if applicantInsurance %}
{{ applicantInsurance.label }}
{% if applicantInsurance.price and applicantInsurance.price > 0 %}
<span class="text-gray-500">(€{{ applicantInsurance.price|number_format(2, ',', '.') }})</span>
{% endif %}
<span class="italic text-gray-500 ml-2"> wie Anmelder</span>
{% else %}
<span class="italic">wie Anmelder</span>
{% endif %}
</div>
</fieldset>
{% else %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">Reiseversicherung</legend>
{# Bulk insurance booking checkbox (applicant only) #}
{% if form.bulkInsuranceBooking is defined %}
{{ form_row(form.bulkInsuranceBooking) }}
{% endif %}
{% if form.insurance is defined %}
{{ form_row(form.insurance, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
},
'label': false
}) }}
{% else %}
<div class="text-sm text-gray-500">Nicht wählbar</div>
{% endif %}
</fieldset>
{% endif %}
{% endif %}
</div>
</div>
{# Transportation Services Section #}
<div class="mt-6 border-t pt-4">
<h3 class="font-semibold text-lg mb-4">Anreise</h3>
<div class="grid grid-cols-2 gap-4">
{% if form.transportationOutbound is defined %}
{{ form_row(form.transportationOutbound, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{% endif %}
{% if form.transportationInbound is defined %}
{{ form_row(form.transportationInbound, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{% endif %}
{% if form.pickup is defined %}
<div class="col-span-2 mt-4">
{{ form_row(form.pickup, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
</div>
{% endif %}
{% if form.parking is defined %}
<div class="mt-4">
{{ form_row(form.parking, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
</div>
{% endif %}
{% if form.licensePlate is defined %}
<div class="mt-4">
{{ form_row(form.licensePlate) }}
</div>
{% endif %}
</div>
</div>
{% endif %}
{# Voucher fields - available for all participants regardless of eligibility #}
{% if form.purchaseVoucherCode is defined or form.promoVoucherCode is defined %}
<div class="mt-6 border-t pt-4">
<h3 class="font-semibold text-lg mb-4">Gutscheine</h3>
<div class="grid grid-cols-2 gap-4">
{% if form.purchaseVoucherCode is defined %}
{{ form_row(form.purchaseVoucherCode) }}
{% endif %}
{% if form.promoVoucherCode is defined %}
{{ form_row(form.promoVoucherCode) }}
{% endif %}
</div>
</div>
{% endif %}
{{ macros.field_or_static(form, 'email', 'E-Mail', form.vars.data.participant.email, bookingDto, participantIndex) }}
{{ macros.field_or_static(form, 'mobile', 'Telefon (mobil)', form.vars.data.participant.mobile, bookingDto, participantIndex) }}
</div>
<div class="flex justify-between mt-8">
{% if cancelRouteName is defined %}
<a href="{{ path(cancelRouteName, cancelRouteParams|default({})) }}"
class="button bg-button bg-button--secondary"
data-action="click->loading#show"
{{ qa_attribute('btn-cancel') }}>
Abbrechen
</a>
{% else %}
<a href="{{ path('app_booking_create_step_2') }}"
class="button bg-button bg-button--secondary"
data-action="click->loading#show"
{{ qa_attribute('btn-cancel') }}>
Abbrechen
</a>
{# Address #}
{% if form.address is defined %}
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{# Address field is in form - render as editable form fields #}
{{ form_row(form.address.street) }}
{{ form_row(form.address.postCode) }}
{{ form_row(form.address.city) }}
{{ form_row(form.address.country) }}
</div>
{% elseif is_static_text('address', bookingDto, participantIndex) %}
{# Address excluded from form but should render as static text #}
{# For applicant (index 0), use booking.applicant.address which has full data from BPN #}
{# For other participants, use participants[index].address (may only have country in BPN response) #}
{% set addressSource = (0 == participantIndex and bookingDto.booking) ? bookingDto.booking.applicant : bookingDto.participants[participantIndex] %}
<div>
<label class="font-semibold mb-1 block">Adresse</label>
{% if addressSource.address %}
<div class="text-sm text-gray-600">
{% if addressSource.address.street %}{{ addressSource.address.street }}<br>{% endif %}
{% if addressSource.address.postCode or addressSource.address.city %}
{{ addressSource.address.postCode }} {{ addressSource.address.city }}<br>
{% endif %}
{% if addressSource.address.country %}{{ addressSource.address.country|map_country }}{% endif %}
</div>
{% else %}
<div class="text-sm text-gray-600">-</div>
{% endif %}
</div>
{% endif %}
<button type="submit" class="button bg-button bg-button--secondary" {{ qa_attribute('btn-submit') }}>
Speichern
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
</div>
{# Room assignment - hidden until date of birth is provided, or shown as static text in edit mode #}
{% if form.assignedRoomId is defined or is_static_text('assignedRoomId', bookingDto, participantIndex) %}
{% set assignedRoom = bookingDto.travel.getRoomById(form.vars.data.participant.assignedRoomId) %}
{% set roomLabel = assignedRoom ? assignedRoom.label : 'Kein Zimmer zugewiesen' %}
<div class="grid lg:grid-cols-2 lg:gap-x-8">
{{ macros.field_or_static(form, 'assignedRoomId', 'Zimmer', roomLabel, bookingDto, participantIndex, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
{% if form.remarksRoom is defined %}
{{ form_row(form.remarksRoom) }}
{% endif %}
</div>
{% endif %}
{# Service selection #}
{% if isEligible %}
{{ macros.service_field(form, 'skiPass', 'Skipass', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'courses', 'Kurse', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'additionalServices', 'Zusatzleistungen', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{{ macros.service_field(form, 'board', 'Verpflegung', {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{% set htmxAttr = {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
} %}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="{{ html_classes('w-full bg-primary-bg p-2 font-semibold uppercase', { 'text-red-500': form.rentals is defined and not form.rentals.vars.valid }) }}">
Leihmaterial
{% if form.rentals is defined and form.rentals.vars.required %}<span>*</span>{% endif %}
</legend>
{% if form.rentals is defined %}
<table class="w-full table-fixed border-collapse">
{% for child in form.rentals %}
{% set choiceData = form.rentals.vars.choices[loop.index0].data %}
{{ macros.service_choice_row(child, choiceData.price|default(null), child.vars.attr['data-description']|default(null), 'Details', htmxAttr) }}
{% endfor %}
{% if form.rentalInsurance is defined %}
{% set rentalInsuranceService = bookingDto.travel.getAdditionalServicesBySubTypes(constant('App\\BusProNet\\Constants::TOKEN_RENTAL_INSURANCE'), true, true)|first %}
{{ macros.checkbox_row(
form.rentalInsurance,
constant('App\\BusProNet\\Constants::SERVICE_LABELS')[constant('App\\BusProNet\\Constants::TOKEN_RENTAL_INSURANCE')],
rentalInsuranceService.price|default(null),
form.rentalInsurance.vars.help|default(null),
'Info',
htmxAttr
) }}
{% endif %}
</table>
{% else %}
<div class="p-2 text-sm text-gray-500">Bitte zuerst den Skipass auswählen</div>
{% endif %}
</fieldset>
{% if form.rentals is defined %}
{{ form_errors(form.rentals) }}
{{ form_help(form.rentals) }}
{% endif %}
</div>
{% if form.bodyDimensions is defined %}
<div class="grid lg:grid-cols-3 lg:gap-x-4">
{{ form_row(form.bodyDimensions.height) }}
{{ form_row(form.bodyDimensions.shoeSize) }}
{{ form_row(form.bodyDimensions.weight) }}
</div>
{% endif %}
{# Insurance display for edit mode (read-only) - only show if insurance is selected #}
{% if bookingDto.mode == constant('App\\Form\\Model\\BookingDto::MODE_EDIT') %}
{% if form.vars.data.participant.insurance %}
{% set editInsurance = form.vars.data.participant.insurance %}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">
Reiseversicherung
</legend>
<table class="w-full table-fixed border-collapse">
<tr>
<td class="border border-primary-bg p-2 align-top">
<div>{{ editInsurance.label }}</div>
{%- if editInsurance.getAllUrlsProductInfo() is not empty -%}
<div {{ stimulus_controller('tooltip') }}>
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
Details
</button>
<template data-tooltip-target="template">
<div class="text-sm">
{%- for url in editInsurance.getAllUrlsProductInfo() -%}
<a href="{{ url }}" target="_blank" rel="noopener noreferrer" class="block">
Produktinformation {{ editInsurance.getAllUrlsProductInfo()|length > 1 ? loop.index : '' }}
</a>
{%- endfor -%}
</div>
</template>
</div>
{%- endif -%}
</td>
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if editInsurance.price and editInsurance.price > 0 %}
{{ editInsurance.price | format_currency('EUR') }}
{% endif %}
</td>
<td class="border border-primary-bg p-2 align-top w-12"></td>
</tr>
</table>
</fieldset>
</div>
{% endif %}
{# In edit mode without insurance: don't show the section at all #}
{# Insurance field OR assigned insurance display for dependent participants (create mode) #}
{% else %}
{% set showBulkInsurance = participantIndex > 0 and bookingDto.participants[0].bulkInsuranceBooking %}
{% if showBulkInsurance %}
{% set applicantInsurance = bookingDto.participants[0].insurance %}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="w-full bg-primary-bg p-2 font-semibold uppercase">
Reiseversicherung* <span class="font-normal normal-case text-sm text-gray-600 italic"> wie Anmelder</span>
</legend>
{% if applicantInsurance %}
<table class="w-full table-fixed border-collapse">
<tr>
<td class="border border-primary-bg p-2 align-top">
<div>{{ applicantInsurance.label }}</div>
{%- if applicantInsurance.getAllUrlsProductInfo() is not empty -%}
<div {{ stimulus_controller('tooltip') }}>
<button type="button" data-tooltip-target="trigger" class="text-sm text-primary-light">
Details
</button>
<template data-tooltip-target="template">
<div class="text-sm">
{%- for url in applicantInsurance.getAllUrlsProductInfo() -%}
<a href="{{ url }}" target="_blank" rel="noopener noreferrer" class="block">
Produktinformation {{ applicantInsurance.getAllUrlsProductInfo()|length > 1 ? loop.index : '' }}
</a>
{%- endfor -%}
</div>
</template>
</div>
{%- endif -%}
</td>
<td class="border border-primary-bg p-2 align-top w-24 text-right whitespace-nowrap">
{% if applicantInsurance.price and applicantInsurance.price > 0 %}
{{ applicantInsurance.price | format_currency('EUR') }}
{% endif %}
</td>
<td class="border border-primary-bg p-2 align-top w-12"></td>
</tr>
</table>
{% else %}
<table class="w-full table-fixed border-collapse">
<tr>
<td class="border border-primary-bg p-2 align-top text-sm text-gray-500 italic">wie Anmelder</td>
<td class="border border-primary-bg p-2 align-top w-24"></td>
<td class="border border-primary-bg p-2 align-top w-12"></td>
</tr>
</table>
{% endif %}
</fieldset>
</div>
{% else %}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="{{ html_classes('w-full bg-primary-bg p-2 font-semibold uppercase', { 'text-red-500': form.insurance is defined and not form.insurance.vars.valid }) }}">
Reiseversicherung*
</legend>
{% if form.insurance is defined %}
<table class="w-full table-fixed border-collapse">
{% if form.bulkInsuranceBooking is defined %}
{{ macros.checkbox_row(
form.bulkInsuranceBooking,
form.bulkInsuranceBooking.vars.label,
null,
form.bulkInsuranceBooking.vars.attr['data-description']|default(null),
'Info',
htmxAttr
) }}
{% endif %}
{% for child in form.insurance %}
{% set choiceData = form.insurance.vars.choices[loop.index0].data %}
{{ macros.insurance_choice_row(child, choiceData.price|default(null), choiceData ? choiceData.getAllUrlsProductInfo() : [], htmxAttr) }}
{% endfor %}
</table>
{% else %}
<div class="p-2 text-sm text-gray-500">Nicht wählbar</div>
{% endif %}
</fieldset>
{% if form.insurance is defined %}
{{ form_errors(form.insurance) }}
{{ form_help(form.insurance) }}
{% endif %}
</div>
{% endif %}
{% endif %}
{# Transportation Services Section #}
<h3 class="font-semibold text-lg mb-4">Anreise</h3>
{% if form.transportationOutbound is defined %}
<div class="pb-4">
<fieldset class="border border-primary-bg">
<legend class="{{ html_classes('w-full bg-primary-bg p-2 font-semibold uppercase', { 'text-red-500': not form.transportationOutbound.vars.valid }) }}">
{{ form.transportationOutbound.vars.label }}
{% if form.transportationOutbound.vars.required %}<span>*</span>{% endif %}
</legend>
<table class="w-full table-fixed border-collapse">
{% for child in form.transportationOutbound %}
{% set choiceData = form.transportationOutbound.vars.choices[loop.index0].data %}
{{ macros.service_choice_row(child, choiceData.price|default(null), child.vars.attr['data-description']|default(null), 'Details', htmxAttr) }}
{% endfor %}
{% if form.parking is defined %}
{% set parkingService = bookingDto.travel.getAdditionalServicesBySubTypes(constant('App\\BusProNet\\Constants::TOKEN_PARKING'), true)|first %}
{{ macros.checkbox_row(
form.parking,
'Parkplatz',
parkingService.price|default(null),
null,
null,
htmxAttr
) }}
{% endif %}
</table>
</fieldset>
{{ form_errors(form.transportationOutbound) }}
{{ form_help(form.transportationOutbound) }}
</div>
{% endif %}
{% if form.transportationInbound is defined %}
{{ form_row(form.transportationInbound, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{% endif %}
{% if form.pickup is defined %}
{{ form_row(form.pickup, {
'attr': {
'hx-trigger': 'change',
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content', 'hx-swap': 'innerHTML'
}
}) }}
{% endif %}
{% if form.licensePlate is defined %}
{{ form_row(form.licensePlate) }}
{% endif %}
{% endif %}
{# Voucher fields - available for all participants regardless of eligibility #}
{% if form.purchaseVoucherCode is defined or form.promoVoucherCode is defined %}
<h3 class="font-semibold text-lg mb-4">
Gutscheine
</h3>
<div class="grid lg:grid-cols-2 lg:gap-x-4">
{% if form.purchaseVoucherCode is defined %}
{{ form_row(form.purchaseVoucherCode) }}
{% endif %}
{% if form.promoVoucherCode is defined %}
{{ form_row(form.promoVoucherCode) }}
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
{# Sidebar summary with conditional OOB swap - only render for HTMX requests #}
{% if htmx_oob_swap|default(false) %}
{% block booking_summary %}
<div id="booking-summary" hx-swap-oob="true">
<div id="booking-summary" hx-swap-oob="innerHTML">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData,
+218 -171
View File
@@ -1,193 +1,240 @@
<div class="booking-summary p-8 bg-gray-50 rounded-lg border sticky top-4">
<h3 class="font-bold text-xl mb-6 text-gray-800">Buchungsübersicht</h3>
{# Inquiry booking notification #}
{% if bookingCreateDto is defined and bookingCreateDto.isInquiryBooking() %}
<div class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800">
<span class="font-semibold">Anfragebuchung:</span> Diese Reise ist ausgebucht. Ihre Buchung wird als Anfrage verarbeitet.
<div class="bg-white h-full flex flex-col lg:border-l lg:border-primary-bg">
{# Header - always visible #}
<div class="shrink-0 px-4 lg:px-8 py-4 lg:flex lg:flex-col lg:justify-center shadow-md relative z-10">
<div class="flex items-center justify-between pb-2">
<span class="block uppercase font-semibold">Gesamtpreis</span>
<span class="font-semibold">{{ summaryData.payableAmount|format_currency('EUR') }}</span>
</div>
{% endif %}
{# Mutability information (edit mode only) #}
{% if bookingCreateDto.mode == constant('App\\Form\\Model\\BookingDto::MODE_EDIT') and mutableData %}
<div class="mb-6 pb-4 border-b border-gray-200">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Aktualisierung möglich bis</h4>
<ul class="space-y-1 text-sm text-gray-600">
<li class="flex items-start">
<span class="mr-2">•</span>
<span>
<span class="font-medium">Zusatzleistungen:</span>
{% if mutableData.items['additional_services'].mutable %}
{{ mutableData.items['additional_services'].mutableBefore|date('d.m.Y') }}
{% else %}
<span class="text-red-600">nicht mehr möglich</span>
{% endif %}
</span>
</li>
<li class="flex items-start">
<span class="mr-2">•</span>
<span>
<span class="font-medium">Beförderung:</span>
{% if mutableData.items['transportation'].mutable %}
{{ mutableData.items['transportation'].mutableBefore|date('d.m.Y') }}
{% else %}
<span class="text-red-600">nicht mehr möglich</span>
{% endif %}
</span>
</li>
<li class="flex items-start">
<span class="mr-2">•</span>
<span>
<span class="font-medium">Zustiege:</span>
{% if mutableData.items['pickup'].mutable %}
{{ mutableData.items['pickup'].mutableBefore|date('d.m.Y') }}
{% else %}
<span class="text-red-600">nicht mehr möglich</span>
{% endif %}
</span>
</li>
</ul>
</div>
{% endif %}
{# Travel Information #}
<div class="mb-6 pb-4 border-b border-gray-200">
{% if summaryData.cmsData.hotel.images is defined %}
<img src="{{ summaryData.cmsData.hotel.images.resized.l[0].url }}" alt="{{ summaryData.cmsData.hotel.images.resized.s[0].alt }}" class="w-full rounded mb-4">
{% endif %}
<div class="space-y-2 text-sm text-gray-600">
<div><span class="font-medium">Reise:</span> {{ bookingCreateDto.travel.label }}</div>
{% if summaryData.cmsData.region is defined %}
<div><span class="font-medium">Gebiet:</span> {{ summaryData.cmsData.region.name }}</div>
{% endif %}
{% if summaryData.cmsData.country is defined %}
<div><span class="font-medium">Land:</span> {{ summaryData.cmsData.country.name }}</div>
{% endif %}
<div><span class="font-medium">Zeitraum:</span> {{ bookingCreateDto.travel.dateFrom|date('d.m.Y') }} - {{ bookingCreateDto.travel.dateTo|date('d.m.Y') }}</div>
<div><span class="font-medium">Unterkunft:</span> {{ bookingCreateDto.travel.hotel.name }}{% if summaryData.cmsData.hotel.address is defined %}, {{ summaryData.cmsData.hotel.address | replace({"\n": ', '}) }}{% endif %}</div>
<div><span class="font-medium">Anzahl Teilnehmer:</span> {{ summaryData.participantCount }}</div>
<div class="flex items-center justify-between">
<span class="block uppercase font-semibold">Buchungsübersicht</span>
<button type="button"
class="lg:hidden button button--small bg-primary-light"
data-action="toggle#toggle">
<svg class="w-4 h-4 text-white transition-transform"
data-toggle-target="icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="208 96 128 176 48 96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
</div>
</div>
{# Rooms Section #}
{% if summaryData.pricingData.rooms is not empty %}
<div class="mb-6 pb-4 border-b border-gray-200">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Unterkunft</h4>
<div class="space-y-2">
{% for roomPricing in summaryData.pricingData.rooms %}
<div class="flex justify-between items-start">
<div class="text-sm text-gray-600">
<span class="font-medium">{{ roomPricing.quantity }}x {{ roomPricing.label }}</span>
{% if summaryData.assignmentCounts[roomPricing.roomId] is defined %}
<span class="block text-xs text-gray-500">{{ summaryData.assignmentCounts[roomPricing.roomId] }} belegt</span>
{% endif %}
</div>
<div class="text-right">
<div class="text-gray-900">
{{ roomPricing.totalPrice|number_format(2, ',', '.') }}
</div>
<div class="text-xs text-gray-500">
{{ roomPricing.unitPrice|number_format(2, ',', '.') }} pro Person
</div>
</div>
</div>
{% endfor %}
</div>
{# Content - toggleable on mobile, always visible on desktop #}
<div class="hidden lg:block flex-1 min-h-0 bg-white shadow-lg lg:shadow-none overflow-y-auto px-4 lg:px-8 py-8"
data-toggle-target="content">
{# Travel Information #}
<div class="pb-4">
<table>
<tr>
<th class="text-left align-top border-r-2 border-gray-300 pr-2">
Reise
</th>
<td class="pl-2">
{{ bookingCreateDto.travel.label }}
</td>
</tr>
<tr>
<th class="text-left align-top border-r-2 border-gray-300 pr-2">
Zeitraum
</th>
<td class="pl-2">
{{ bookingCreateDto.travel.dateFrom|date('d.m.Y') }} - {{ bookingCreateDto.travel.dateTo|date('d.m.Y') }}
</td>
</tr>
<tr>
<th class="text-left align-top border-r-2 border-gray-300 pr-2">
Unterkunft
</th>
<td class="pl-2">
{{ bookingCreateDto.travel.hotel.name }}
</td>
</tr>
<tr>
<th class="text-left align-top border-r-2 border-gray-300 pr-2">
Teilnehmer
</th>
<td class="pl-2">
{{ summaryData.participantCount }}
</td>
</tr>
</table>
</div>
{% endif %}
{# Services Section #}
{% if summaryData.pricingData.services is not empty %}
<div class="mb-6">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Leistungen</h4>
{% for serviceGroup in summaryData.pricingData.services %}
<div class="mb-4 last:mb-0">
<h5 class="font-medium text-sm text-gray-700 mb-2 uppercase tracking-wide">{{ serviceGroup.groupName }}</h5>
<div class="space-y-1 ml-4">
{% for servicePricing in serviceGroup.services %}
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600">
{{ servicePricing.participantCount }}x {{ servicePricing.label }}
</span>
<span class="font-medium text-gray-900">
{{ servicePricing.totalPrice|number_format(2, ',', '.') }}
</span>
</div>
{% endfor %}
</div>
{% include 'booking/_summary_hotel.html.twig' %}
{# Inquiry booking notification #}
{% if bookingCreateDto is defined and bookingCreateDto.isInquiryBooking() %}
<div>
Deine Buchung wird als Anfrage verarbeitet.
</div>
{% endif %}
{# Mutability information (edit mode only) #}
{% if bookingCreateDto.mode == constant('App\\Form\\Model\\BookingDto::MODE_EDIT') and mutableData %}
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">
Aktualisierung möglich bis
</div>
{% endfor %}
</div>
{% endif %}
<table class="w-full table-fixed">
<tr>
<td class="p-2 align-top">
Zusatzleistungen
</td>
<td class="p-2 align-top w-24 text-right whitespace-nowrap">
{% if mutableData.items['additional_services'].mutable %}
{{ mutableData.items['additional_services'].mutableBefore|date('d.m.Y') }}
{% else %}
nicht mehr möglich
{% endif %}
</td>
</tr>
<tr>
<td class="p-2 align-top">
Beförderung
</td>
<td class="p-2 align-top w-24 text-right whitespace-nowrap">
{% if mutableData.items['transportation'].mutable %}
{{ mutableData.items['transportation'].mutableBefore|date('d.m.Y') }}
{% else %}
nicht mehr möglich
{% endif %}
</td>
</tr>
<tr>
<td class="p-2 align-top">
Zustiege
</td>
<td class="p-2 align-top w-24 text-right whitespace-nowrap">
{% if mutableData.items['pickup'].mutable %}
{{ mutableData.items['pickup'].mutableBefore|date('d.m.Y') }}
{% else %}
nicht mehr möglich
{% endif %}
</td>
</tr>
</table>
</div>
{% endif %}
{# Surcharges Section (Edit mode only) #}
{% if summaryData.pricingData.surcharges is defined and summaryData.pricingData.surcharges is not empty %}
<div class="mb-6 pb-4 border-b border-gray-200">
<h4 class="font-semibold text-lg mb-3 text-gray-800">Zuschläge</h4>
<div class="space-y-1">
{% for surchargePricing in summaryData.pricingData.surcharges %}
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600">
{{ surchargePricing.participantCount }}x {{ surchargePricing.label }}
</span>
<span class="font-medium text-gray-900">
{{ surchargePricing.totalPrice|number_format(2, ',', '.') }}
</span>
{# Rooms Section #}
{% if summaryData.pricingData.rooms is not empty %}
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">
Unterkunft
</div>
<table class="w-full table-fixed">
{% for roomPricing in summaryData.pricingData.rooms %}
<tr>
<td class="p-2 align-top">
<div>{{ roomPricing.quantity }}x {{ roomPricing.label }}</div>
{% if summaryData.assignmentCounts[roomPricing.roomId] is defined %}
<div class="text-sm text-gray-600">{{ summaryData.assignmentCounts[roomPricing.roomId] }}x Erwachsener</div>
{% endif %}
</td>
<td class="p-2 align-top w-24 text-right whitespace-nowrap">
{{ roomPricing.totalPrice | format_currency('EUR') }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Services Section #}
{% if summaryData.pricingData.services is not empty %}
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">
Leistungen
</div>
{% for serviceGroup in summaryData.pricingData.services %}
<div class="p-2 text-primary-dark/70 uppercase font-semibold">
{{ serviceGroup.groupName }}
</div>
<table class="w-full table-fixed">
{% for servicePricing in serviceGroup.services %}
<tr>
<td class="px-2 pb-2 align-top">
{{ servicePricing.participantCount }}x {{ servicePricing.label }}
</td>
<td class="px-2 pb-2 align-top w-24 text-right whitespace-nowrap">
{{ servicePricing.totalPrice | format_currency('EUR') }}
</td>
</tr>
{% endfor %}
</table>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
{# Total Section #}
{% if summaryData.pricingData.grandTotal is defined and summaryData.pricingData.grandTotal > 0 %}
<div class="pt-4 border-t-2 border-gray-300">
{# Surcharges Section (Edit mode only) #}
{% if summaryData.pricingData.surcharges is defined and summaryData.pricingData.surcharges is not empty %}
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">
Zu-/Abschläge
</div>
<table class="w-full table-fixed">
{% for surchargePricing in summaryData.pricingData.surcharges %}
<tr>
<td class="px-2 pb-2 align-top">
{{ surchargePricing.participantCount }}x {{ surchargePricing.label }}
</td>
<td class="px-2 pb-2 align-top w-24 text-right whitespace-nowrap">
{{ surchargePricing.totalPrice|format_currency('EUR') }}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{# Total Section #}
{% if summaryData.totalPrice > 0 %}
{# Show subtotal and voucher discounts when vouchers are applied #}
{% set acceptedVouchers = bookingCreateDto.getAcceptedVouchers() %}
{% if acceptedVouchers and acceptedVouchers.hasVouchers() %}
{# Subtotal before vouchers #}
<div class="flex justify-between items-center mb-2">
<span class="text-gray-600">Gesamtpreis:</span>
<span class="text-gray-900">
{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }}
</span>
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">
Kostenübersicht
</div>
<table class="w-full table-fixed">
{# Subtotal before vouchers #}
<tr>
<td class="px-2 pb-2 align-top">
Gesamtpreis
</td>
<td class="px-2 pb-2 align-top w-24 text-right whitespace-nowrap">
{{ summaryData.totalPrice|format_currency('EUR') }}
</td>
</tr>
{# Voucher discounts breakdown #}
{% for voucher in acceptedVouchers.vouchers %}
<tr>
<td class="px-2 pb-2 align-top">
{% if voucher.promotional %}
Aktionsgutschein ({{ voucher.code }})
{% elseif voucher.goodwill %}
Kulanzgutschein ({{ voucher.code }})
{% elseif voucher.purchase %}
Kaufgutschein ({{ voucher.code }})
{% endif %}
</td>
<td class="px-2 pb-2 align-top w-24 text-right whitespace-nowrap">
- {{ voucher.amount|format_currency('EUR') }}
</td>
</tr>
{% endfor %}
</table>
</div>
{# Voucher discounts breakdown #}
<div class="mb-3 space-y-1">
{% for voucher in acceptedVouchers.vouchers %}
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600">
{% if voucher.promotional %}
Aktionsgutschein ({{ voucher.code }})
{% elseif voucher.goodwill %}
Kulanzgutschein ({{ voucher.code }})
{% elseif voucher.purchase %}
Kaufgutschein ({{ voucher.code }})
{% endif %}
</span>
<span class="font-medium text-green-600">
-€{{ voucher.amount|number_format(2, ',', '.') }}
</span>
</div>
{% endfor %}
</div>
{# Amount to pay after vouchers #}
<div class="flex justify-between items-center pt-2 border-t border-gray-200">
<span class="font-bold text-lg text-gray-800">Zu zahlen:</span>
<span class="font-bold text-xl text-gray-900" {{ qa_attribute('summary-total') }}>
{{ (summaryData.pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }}
</span>
<div class="flex items-center justify-between px-2 pb-2">
<span class="block uppercase font-semibold">Zu zahlen</span>
<span class="font-semibold">{{ summaryData.payableAmount|format_currency('EUR') }}</span>
</div>
{% else %}
<div class="flex justify-between items-center">
<span class="font-bold text-lg text-gray-800">Gesamtpreis:</span>
<span class="font-bold text-xl text-gray-900" {{ qa_attribute('summary-total') }}>
{{ summaryData.pricingData.grandTotal|number_format(2, ',', '.') }}
</span>
<div class="flex items-center justify-between pb-2">
<span class="block uppercase font-semibold">Gesamtpreis</span>
<span class="font-semibold">{{ summaryData.payableAmount|format_currency('EUR') }}</span>
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
</div>
@@ -0,0 +1,13 @@
{% if summaryData.cmsData.hotel.images is defined %}
<div class="grid grid-cols-3 gap-x-4 py-4 border-t border-primary-bg">
<img src="{{ summaryData.cmsData.hotel.images.resized.l[0].url }}"
alt="{{ summaryData.cmsData.hotel.images.resized.l[0].alt }}"
class="block w-full h-auto">
<div class="col-span-2">
<span class="block font-semibold uppercase">{{ summaryData.cmsData.hotel.name }}</span>
{% if summaryData.cmsData.hotel.address is defined %}
{{ summaryData.cmsData.hotel.address | nl2br }}
{% endif %}
</div>
</div>
{% endif %}
+80 -82
View File
@@ -1,98 +1,96 @@
{% extends 'layout.html.twig' %}
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<div class="px-4 lg:px-8 py-8 lg:py-16">
<h1 class="mb-4">
Neue Buchung
</h1>
{% include '_partials/_flashes.html.twig' %}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h2 class="text-blue-900 font-semibold text-lg mb-3">
Optional: Anmelden für schnelleres Buchen
</h2>
<p class="text-blue-800 mb-2">
Melde dich an, um deine persönlichen Daten automatisch in die Buchung zu übernehmen.
Dies ist <strong>vollständig optional</strong> du kannst auch ohne Anmeldung als Gast fortfahren.
</p>
</div>
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
Neue<br>Buchung
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{# Login Form #}
<div>
<h3 class="mb-4 font-semibold text-lg">
Mit bestehendem Account anmelden
</h3>
{% if error %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
{% endif %}
<form action="{{ path('app_login') }}" method="post" {{ stimulus_action('loading', 'toggle') }}>
<div class="mb-4">
<label for="username" class="mb-1 font-semibold">
E-Mail:
</label>
<input type="email"
id="username"
name="_username"
value="{{ last_username }}"
class="form-field"
required
autocomplete="username">
</div>
<div class="mb-4">
<label for="password" class="mb-1 font-semibold">
Passwort:
</label>
<input type="password"
id="password"
name="_password"
class="form-field"
required
autocomplete="current-password">
</div>
<div class="flex flex-col space-y-2">
<button type="submit" class="button bg-button">
Anmelden und fortfahren
</button>
<a href="{{ path('app_reset_password') }}"
class="text-sm text-center text-blue-600 hover:underline">
Passwort vergessen?
</a>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form>
<div class="text-white uppercase pb-4 ld:pb-8">
{{ travel_title }}
<br>
{{ travel_date_from | date('d.m.Y') }} - {{ travel_date_to | date('d.m.Y') }}
</div>
{# Continue as Guest #}
<div>
<h3 class="mb-4 font-semibold text-lg">
Als Gast fortfahren
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<a href="{{ path('app_booking_create_step_1') }}" class="button button--secondary mb-4">
Als Gast fortfahren
</a>
<p class="mb-4 text-gray-700">
Du kannst auch ohne Anmeldung buchen. Deine persönlichen Daten gibst du dann im nächsten Schritt ein.
</p>
<a href="{{ path('app_booking_create_step_1') }}" class="button bg-button bg-button--secondary w-full block text-center">
Als Gast fortfahren
</a>
<div class="mt-6 p-4 bg-gray-50 border border-gray-200 rounded">
<h4 class="font-semibold mb-2 text-sm">
<h2 class="text-base text-white">
Noch kein Account?
</h4>
<p class="text-sm text-gray-700">
</h2>
<p class="text-white">
Nach Abschluss deiner Buchung wird automatisch ein Account für dich erstellt.
Du erhältst dann Zugangsdaten per E-Mail und kannst deine Buchungen jederzeit verwalten.
</p>
</div>
</div>
</div>
<div>
<h2 class="text-base text-white">
Mit bestehendem Account anmelden
</h2>
{% include 'booking/_cancel_link.html.twig' %}
{% if error %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
{% endif %}
<div class="pb-4">
<form action="{{ path('app_login') }}" method="post" {{ stimulus_action('loading', 'toggle') }}>
<div class="mb-4">
<label for="username" class="mb-1 font-semibold text-white">
E-Mail:
</label>
<input type="email"
id="username"
name="_username"
value="{{ last_username }}"
class="form-field"
required
autocomplete="username">
</div>
<div class="mb-4">
<label for="password" class="mb-1 font-semibold text-white">
Passwort:
</label>
<input type="password"
id="password"
name="_password"
class="form-field"
required
autocomplete="current-password">
</div>
<button type="submit" class="button button--primary">
Anmelden und fortfahren
</button>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
</form>
</div>
<a href="{{ path('app_reset_password') }}"
class="text-sm underline text-white">
Passwort vergessen?
</a>
</div>
</div>
</div>
{% endblock %}
{% block footer %}
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4">
<button type="button"
hx-get="{{ path('app_booking_cancel') }}"
hx-target="body"
hx-swap="beforeend"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10">
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
</div>
{% endblock %}
+10 -28
View File
@@ -5,34 +5,16 @@
{% block body %}
<div class="container mx-auto px-4 py-8">
<div class="max-w-lg mx-auto">
<div class="bg-red-50 border border-red-200 rounded-lg p-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
Booking Error
</h3>
<div class="mt-2 text-sm text-red-700">
{% for flash_message in app.flashes('error') %}
<p>{{ flash_message }}</p>
{% else %}
<p>Es ist ein Fehler beim Starten des Buchungsvorgangs aufgetreten.</p>
{% endfor %}
</div>
<div class="mt-4">
<div class="flex space-x-2">
<a href="https://www.ep-reisen.de" class="button bg-button bg-button--secondary">
Zur Startseite
</a>
</div>
</div>
</div>
</div>
</div>
{% set errorMessages = app.flashes('error')|default(['Es ist ein Fehler beim Starten des Buchungsvorgangs aufgetreten.']) %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
title: 'Booking Error',
messages: errorMessages
} %}
<a href="https://www.ep-reisen.de" class="button button--secondary">
Zur Startseite
</a>
</div>
</div>
{% endblock %}
+98 -57
View File
@@ -1,64 +1,105 @@
{% extends 'layout.html.twig' %}
{% extends 'layout_booking.html.twig' %}
{% macro stepFormField(form) %}
<div class="grid grid-cols-3 pb-2">
<div class="col-span-2 pr-2">
<div class="font-semibold">
{{ form.vars.label_room }}
</div>
<div class="text-gray-500">
{{ form.vars.label_price }}
</div>
</div>
<div>
{{ form_row(form, {
'attr': {
'hx-post': path('app_booking_create_step_1_refresh'),
'hx-swap': 'none',
'hx-trigger': 'change delay:300ms',
}
}) }}
</div>
</div>
{% endmacro %}
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<h1>Neue Buchung</h1>
<div class="grid grid-cols-3 gap-8">
<div id="form-wrapper" class="col-span-2">
<h2 class="pb-4">
Unterkunft
</h2>
{{ form_start(form) }}
{% do form.roomSelections.setRendered %}
{# This block contains the room selection form fields #}
{% block room_selection_form %}
<div id="room-selection-form"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{{ form_errors(form) }}
{% if groupedRooms.by_room is not empty %}
<h3>
Zimmer
</h3>
{% for roomId, room in groupedRooms.by_room %}
{{ form_row(form.roomSelections[roomId], {
'attr': {
'hx-post': path('app_booking_create_step_1_refresh'),
'hx-swap': 'none',
'hx-trigger': 'change delay:300ms',
}
}) }}
{% endfor %}
{% endif %}
{% if groupedRooms.by_pax is not empty %}
<h3>
Betten
</h3>
{% for roomId, room in groupedRooms.by_pax %}
{{ form_row(form.roomSelections[roomId], {
'attr': {
'hx-post': path('app_booking_create_step_1_refresh'),
'hx-swap': 'none',
'hx-trigger': 'change delay:300ms'
}
}) }}
{% endfor %}
{% endif %}
{{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'hx-post': path('app_booking_create_step_1'),
'hx-target': '#form-wrapper',
'hx-swap': 'innerHTML'
}
}) }}
{# Pagination - mobile only (above summary) #}
<div class="lg:hidden">
{% include 'booking/_pagination.html.twig' with { 'current_step': 1 } %}
</div>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{% block booking_summary %}
<div id="booking-summary"
class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}
{% if htmx_oob_swap is defined and htmx_oob_swap %}hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingCreateDto,
'summaryData': summaryData
} %}
</div>
{% endblock %}
{% block form %}
<div id="form-wrapper"
class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-primary-bg flex flex-col min-h-0">
{# Flash messages - must be inside form-wrapper for HTMX swap to display them #}
{% include '_partials/_flashes.html.twig' %}
{# Pagination - desktop only (fixed above scrollable content) #}
<div class="hidden lg:block shrink-0">
{% include 'booking/_pagination.html.twig' with { 'current_step': 1 } %}
</div>
<div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
<h2 class="pb-4">
Unterkunft
</h2>
{% do form.roomSelections.setRendered %}
{% block room_selection_form %}
<div id="room-selection-form"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{{ form_errors(form) }}
{% if groupedRooms.by_room is not empty %}
<h3>
Zimmer
</h3>
{% for roomId, room in groupedRooms.by_room %}
{{ _self.stepFormField(form.roomSelections[roomId]) }}
{% endfor %}
{% endif %}
{% if groupedRooms.by_pax is not empty %}
<h3>
Betten
</h3>
{% for roomId, room in groupedRooms.by_pax %}
{{ _self.stepFormField(form.roomSelections[roomId]) }}
{% endfor %}
{% endif %}
</div>
{% endblock %}
</div>
</div>
{% endblock %}
<div class="flex justify-end pt-4">
<button type="submit" class="button bg-button bg-button--secondary" {{ qa_attribute('btn-submit')}} {{ stimulus_action('loading', 'toggle') }}>Weiter</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
</div>
{% block booking_summary %}
<div id="booking-summary"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingCreateDto,
'summaryData': summaryData
} %}
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex justify-between" hx-disinherit="*">
<button type="button"
hx-get="{{ path('app_booking_cancel') }}"
hx-target="body"
hx-swap="beforeend"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10">
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit')}} {{ stimulus_action('loading', 'toggle') }}>
Weiter zu Schritt 2
</button>
</div>
{% endblock %}
</div>
{% include 'booking/_cancel_link.html.twig' %}
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+68 -62
View File
@@ -1,74 +1,80 @@
{% extends 'layout.html.twig' %}
{% extends 'layout_booking.html.twig' %}
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<h1>Neue Buchung</h1>
{{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'hx-post': path('app_booking_create_step_2'),
'hx-target': '#main-content',
'hx-swap': 'innerHTML scroll:top'
}
}) }}
{# Pagination - mobile only (above summary) #}
<div class="lg:hidden">
{% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
</div>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{% block booking_summary %}
<div id="booking-summary"
class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}
{% if htmx_oob_swap|default(false) %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData
} %}
</div>
{% endblock %}
{% block participant_cards %}
<div id="main-content"
class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-primary-bg flex flex-col min-h-0">
{# Pagination - desktop only (fixed above scrollable content) #}
<div class="hidden lg:block shrink-0">
{% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
</div>
<div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
<h2 class="pb-4">
Teilnehmer
</h2>
<div class="grid grid-cols-3 gap-8">
{# Main content area - cards grid #}
{% block participant_cards %}
<div id="main-content" class="col-span-2">
{{ form_start(form, {
'method': 'POST',
'action': path('app_booking_create_step_2'),
'attr': {
'hx-post': path('app_booking_create_step_2'),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
}
}) }}
{# Display form-level validation errors #}
{% if form.vars.submitted and not form.vars.valid %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
title: 'Bitte überprüfe die Teilnehmerdaten',
messages: ['Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.']
} %}
{% endif %}
<h2>Teilnehmer</h2>
{# Display form-level validation errors #}
{% if form.vars.submitted and not form.vars.valid %}
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg" {{ qa_attribute('alert-form-errors') }}>
<div class="flex items-start">
<svg class="w-5 h-5 text-red-600 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
<div>
<p class="font-semibold text-red-800 mb-1">Bitte überprüfe die Teilnehmerdaten</p>
<p class="text-sm text-red-700">Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.</p>
<div class="divide-y divide-gray-200">
{% for cardData in cardsData %}
{% include 'booking/_participant_card.html.twig' with {
'cardData': cardData,
'index': loop.index0,
'participantNumber': loop.index,
'mode': 'create'
} %}
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div id="participant-cards-grid" class="space-y-4">
{% for cardData in cardsData %}
{% include 'booking/_participant_card.html.twig' with {
'cardData': cardData,
'index': loop.index0,
'mode': 'create'
} %}
{% endfor %}
</div>
<div class="flex justify-between mt-8">
<a href="{{ path('app_booking_create_step_1') }}" class="button bg-button bg-button--secondary" {{ qa_attribute('btn-back') }} {{ stimulus_action('loading', 'toggle') }}>
Zurück
</a>
<button type="submit" class="button bg-button bg-button--secondary" {{ qa_attribute('btn-submit') }} {{ stimulus_action('loading', 'toggle') }}>
Weiter
{% endblock %}
</div>
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex justify-between" hx-disinherit="*">
<button type="button"
hx-get="{{ path('app_booking_cancel') }}"
hx-target="body"
hx-swap="beforeend"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10">
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }} {{ stimulus_action('loading', 'toggle') }}>
Weiter zu Schritt 3
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
</div>
{% endblock %}
{# Sidebar summary #}
{% block booking_summary %}
<div id="booking-summary"{% if htmx_oob_swap|default(false) %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData
} %}
</div>
{% endblock %}
</div>
{% include 'booking/_cancel_link.html.twig' %}
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
@@ -1,23 +1,54 @@
{% extends 'layout.html.twig' %}
{% extends 'layout_booking.html.twig' %}
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<h1>Neue Buchung</h1>
<div class="grid grid-cols-3 gap-8">
{# Main content area - participant form #}
<div id="main-content" class="col-span-2">
{% include 'booking/_participant_form.html.twig' %}
{{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'novalidate': 'novalidate',
'hx-post': path(submitRouteName, submitRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML scroll:top'
}
}) }}
{# Pagination - mobile only (above form) #}
<div class="lg:hidden">
{% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
</div>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Summary - hidden on mobile, visible on desktop #}
<div id="booking-summary"
class="hidden lg:block lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData
} %}
</div>
{# Sidebar summary #}
<div id="booking-summary">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData
} %}
{# Form area - full width mobile, 3 cols desktop #}
<div class="flex-1 order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
{# Pagination - desktop only (fixed above scrollable content) #}
<div class="hidden lg:block shrink-0">
{% include 'booking/_pagination.html.twig' with { 'current_step': 2 } %}
</div>
<div id="main-content" class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
{# Flash messages - must be inside main-content for HTMX swap to display them #}
{% include '_partials/_flashes.html.twig' %}
{% include 'booking/_participant_form.html.twig' %}
</div>
</div>
</div>
</div>
{% include 'booking/_cancel_link.html.twig' %}
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex justify-between">
<a href="{{ path('app_booking_create_step_2') }}"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10"
{{ stimulus_action('loading', 'toggle') }}>
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }} {{ stimulus_action('loading', 'toggle') }}>
Speichern
</button>
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+81 -57
View File
@@ -1,68 +1,92 @@
{% extends 'layout.html.twig' %}
{% extends 'layout_booking.html.twig' %}
{% block content %}
<h1>Neue Buchung</h1>
{{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'novalidate': 'novalidate',
'hx-post': path('app_booking_create_step_3'),
'hx-target': '#form-wrapper',
'hx-select': '#form-wrapper',
'hx-swap': 'outerHTML'
}
}) }}
{# Pagination - mobile only (above summary) #}
<div class="lg:hidden">
{% include 'booking/_pagination.html.twig' with { 'current_step': 3 } %}
</div>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-3 min-h-0 relative">
{% block booking_summary %}
<div id="booking-summary"
class="order-1 lg:order-2 lg:flex-1 lg:min-h-0"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}
{% if htmx_oob_swap is defined and htmx_oob_swap %}hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingCreateDto,
'summaryData': summaryData
} %}
</div>
{% endblock %}
{% block form %}
<div id="form-wrapper"
class="flex-1 order-2 lg:order-1 lg:col-span-2 bg-white flex flex-col min-h-0">
{# Flash messages - must be inside form-wrapper for HTMX swap to display them #}
{% include '_partials/_flashes.html.twig' %}
{# Pagination - desktop only (fixed above scrollable content) #}
<div class="hidden lg:block shrink-0">
{% include 'booking/_pagination.html.twig' with { 'current_step': 3 } %}
</div>
<div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
<h2 class="pb-4">
Zahlungsart
</h2>
<div class="grid grid-cols-3 gap-8">
<div id="form-wrapper" class="col-span-2">
{% include '_partials/_flashes.html.twig' %}
{# Payment method selection with HTMX #}
<div id="form-payment"
hx-post="{{ path('app_booking_create_step_3_refresh') }}"
hx-trigger="change"
hx-target="#form-payment"
hx-select="#form-payment"
hx-swap="outerHTML">
{{ form_row(form.paymentMethod) }}
<h2 class="mb-6">Zahlungsart</h2>
{# Bank account section - conditionally rendered #}
{% if form.bankAccount is defined %}
<h3 class="font-semibold text-lg mb-4">
Bankverbindung
</h3>
{{ form_start(form, {
'attr': {
'novalidate': 'novalidate',
'hx-post': path('app_booking_create_step_3'),
'hx-target': '#form-wrapper',
'hx-select': '#form-wrapper',
'hx-swap': 'outerHTML'
}
}) }}
<div class="space-y-4">
{{ form_row(form.bankAccount.iban) }}
{{ form_row(form.bankAccount.accountHolder) }}
{{ form_row(form.bankAccount.bankName) }}
{# Payment method selection with HTMX #}
<div id="form-payment"
hx-post="{{ path('app_booking_create_step_3_refresh') }}"
hx-trigger="change"
hx-target="#form-payment"
hx-select="#form-payment"
hx-swap="outerHTML">
{{ form_row(form.paymentMethod) }}
{# Bank account section - conditionally rendered #}
{% if form.bankAccount is defined %}
<div class="border border-gray-300 rounded-lg p-4 bg-gray-50 mt-4">
<h3 class="font-semibold text-lg mb-4">Bankverbindung</h3>
<div class="space-y-4">
{{ form_row(form.bankAccount.iban) }}
{{ form_row(form.bankAccount.accountHolder) }}
{{ form_row(form.bankAccount.bankName) }}
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded">
{{ form_row(form.bankAccount.sepaMandateAccepted, {
'label_attr': {'class': 'text-sm'}
}) }}
</div>
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded">
{{ form_row(form.bankAccount.sepaMandateAccepted, {
'label_attr': {'class': 'text-sm'}
}) }}
</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="flex justify-between pt-6">
<a href="{{ path('app_booking_create_step_2') }}" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Zurück</a>
<button type="submit" class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle') }}>Weiter</button>
</div>
{{ form_end(form) }}
</div>
{% endblock %}
</div>
<div id="booking-summary">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingCreateDto,
'summaryData': summaryData
} %}
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex justify-between" hx-disinherit="*">
<button type="button"
hx-get="{{ path('app_booking_cancel') }}"
hx-target="body"
hx-swap="beforeend"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10">
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }} {{ stimulus_action('loading', 'toggle') }}>
Weiter zu Schritt 4
</button>
</div>
</div>
</div>
{% include 'booking/_cancel_link.html.twig' %}
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
File diff suppressed because it is too large Load Diff
+49 -30
View File
@@ -3,37 +3,56 @@
{% block title %}Buchung erfolgreich{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto text-center py-12">
<h1 class="text-3xl font-bold mb-4">Buchung erfolgreich abgeschlossen</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
<div>
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
Vielen Dank
<br>
für deine Buchung
</h1>
<p class="text-xl mb-8">
Deine Buchungsnummer lautet <strong class="font-mono">{{ bookingNumber }}</strong>
</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<p class="text-gray-700">
Du erhältst in Kürze eine Bestätigungs-E-Mail mit allen Details zu Deiner Buchung.
</p>
</div>
{% if app.user %}
{# Authenticated user - show account-related actions #}
<div class="space-y-4">
<a href="{{ path('app_personal_data') }}" class="button bg-button bg-button--primary inline-block">
Zu meinen persönlichen Daten
</a>
<a href="{{ path('app_bookings') }}" class="button bg-button bg-button--secondary inline-block ml-2">
Meine Buchungen anzeigen
</a>
<a href="{{ path('app_logout') }}" class="button bg-button bg-button--outline inline-block ml-2">
Abmelden
</a>
<div class="text-xl text-white mb-8">
Deine Buchungsnummer lautet
<br>
<strong class="font-mono">{{ bookingNumber }}</strong>
</div>
{% else %}
{# Guest user - show simple homepage link #}
<a href="https://www.ep-reisen.de" class="button bg-button bg-button--primary">
Zurück zur Startseite
</a>
{% endif %}
<div class="pb-8">
{% include '_partials/_alert.html.twig' with {
level: 'info',
messages: ['Du erhältst in Kürze eine Bestätigungs-E-Mail mit allen Details.']
} %}
</div>
{% if app.user %}
{# Authenticated user - show account-related actions #}
<ul class="divide-y divide-primary-bg/40">
<li class="py-4">
<a href="{{ path('app_bookings') }}" class="flex items-center space-x-2 group" {{ stimulus_action('loading', 'toggle') }}>
<svg class="w-8 h-8 text-white group-hover:text-primary-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="128" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="128" y1="64" x2="216" y2="64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="128" y1="192" x2="216" y2="192" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="40 64 56 80 88 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="40 128 56 144 88 112" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="40 192 56 208 88 176" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
<span class="text-xl uppercase text-white group-hover:text-primary-light">Meine Buchungen</span>
</a>
</li>
<li class="py-4">
<a href="{{ path('app_personal_data') }}" class="flex items-center space-x-2 group" {{ stimulus_action('loading', 'toggle') }}>
<svg class="w-8 h-8 text-white group-hover:text-primary-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M160,224c3.67-13.8,16.6-24,32-24s28.33,10.2,32,24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="192" cy="176" r="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M120,208H40a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H93.33a8,8,0,0,1,4.8,1.6l27.74,20.8a8,8,0,0,0,4.8,1.6H216a8,8,0,0,1,8,8v32" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
<span class="text-xl uppercase text-white group-hover:text-primary-light">Meine Daten</span>
</a>
</li>
<li class="py-4">
<a href="{{ path('app_logout') }}" class="flex items-center space-x-2 group">
<svg class="w-8 h-8 text-white group-hover:text-primary-light" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="112 40 48 40 48 216 112 216" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="112" y1="128" x2="224" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="184 88 224 128 184 168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
<span class="text-xl uppercase text-white group-hover:text-primary-light">abmelden</span>
</a>
</li>
</ul>
{% else %}
{# Guest user - show simple homepage link #}
<a href="https://www.ep-reisen.de" class="button button--primary">
Zurück zur Startseite
</a>
{% endif %}
</div>
</div>
{% endblock %}
+93 -94
View File
@@ -1,109 +1,108 @@
{% extends 'layout.html.twig' %}
{% extends 'layout_booking.html.twig' %}
{% block content %}
{% include '_partials/_flashes.html.twig' %}
{# Header with reload button #}
<div class="flex justify-between items-center pb-8">
<h1 class="text-3xl font-semibold">Buchung bearbeiten</h1>
<button type="button"
hx-post="{{ path('app_booking_edit_reload', {id: bookingData.id}) }}"
hx-confirm="Alle nicht gespeicherten Änderungen gehen verloren. Fortfahren?"
{{ stimulus_action('loading', 'toggle') }}
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded transition-colors">
Änderungen verwerfen
</button>
</div>
{# Grid layout with 2/3 cards + 1/3 summary #}
<div class="grid grid-cols-3 gap-8">
{# Main content area - cards grid #}
<div id="main-content" class="col-span-2">
{% block participant_cards %}
{{ form_start(form) }}
<div>
<h2>Teilnehmer</h2>
{% if isDirty %}
<div class="mb-6 p-4 bg-yellow-50 border border-yellow-400 rounded-lg">
<div class="flex items-start">
<svg class="w-6 h-6 text-yellow-600 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div>
<h3 class="font-semibold text-yellow-800">Ungespeicherte Änderungen</h3>
<p class="text-yellow-700 text-sm mt-1">
Du hast Änderungen an der Buchung vorgenommen.
Bitte denke daran, abschließend den 'Buchung aktualisieren' Button zu klicken.
</p>
</div>
</div>
</div>
{% endif %}
{# Display validation errors #}
{% if hasValidationErrors|default(false) %}
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-red-600 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
<div>
<p class="font-semibold text-red-800 mb-1">Bitte überprüfe die Teilnehmerdaten</p>
<p class="text-sm text-red-700">Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.</p>
</div>
</div>
</div>
{% endif %}
<div id="participant-cards-grid" class="space-y-4">
{% for participant in bookingDto.participants %}
{% set isCanceled = (bookingData.participantsStatus[loop.index0] ?? null) == 'S' %}
{% include 'booking/_participant_card.html.twig' with {
'cardData': cardsData[loop.index0],
'index': loop.index0,
'mode': 'edit',
'isCanceled': isCanceled,
'bookingId': bookingData.id
} %}
{% endfor %}
{{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0'
}
}) }}
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Sidebar summary #}
{% block booking_summary %}
<div id="booking-summary"
class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}
{% if htmx_oob_swap is defined and htmx_oob_swap %}hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData,
'mutableData': mutableData|default(null)
} %}
</div>
{% endblock %}
<div class="flex justify-between mt-8">
<button type="button"
hx-post="{{ path('app_booking_edit_cancel', {id: bookingData.id}) }}"
{{ stimulus_action('loading', 'toggle') }}
class="button bg-button bg-button--secondary">
Zurück
</button>
{# Main content area #}
<div id="main-content"
class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-primary-bg flex flex-col min-h-0">
{# Header - desktop only #}
<div class="hidden lg:flex shrink-0 px-4 lg:px-8 h-16 items-center justify-between shadow-md relative z-10 bg-white">
<h1 class="text-xl font-semibold uppercase">
Buchung bearbeiten
</h1>
{% if isDirty %}
<button type="submit"
<button type="button"
hx-post="{{ path('app_booking_edit_reload', {id: bookingData.id}) }}"
hx-confirm="Alle nicht gespeicherten Änderungen gehen verloren. Fortfahren?"
{{ stimulus_action('loading', 'toggle') }}
{% if hasValidationErrors|default(false) %}
disabled
title="Bitte behebe zuerst alle Validierungsfehler"
{% endif %}
class="button bg-button bg-button--secondary {{ hasValidationErrors|default(false) ? 'opacity-50 cursor-not-allowed' : '' }}">
Buchung aktualisieren
class="button button--small button--secondary">
Änderungen verwerfen
</button>
{% endif %}
</div>
{# Scrollable content #}
<div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
{% block participant_cards %}
<h2 class="pb-4">Teilnehmer</h2>
{% if isDirty %}
{% include '_partials/_alert.html.twig' with {
level: 'warning',
title: 'Ungespeicherte Änderungen',
messages: ['Du hast Änderungen an der Buchung vorgenommen. Bitte denke daran, abschließend den \'Buchung aktualisieren\' Button zu klicken.']
} %}
{% endif %}
{# Display validation errors #}
{% if hasValidationErrors|default(false) %}
{% include '_partials/_alert.html.twig' with {
level: 'error',
title: 'Bitte überprüfe die Teilnehmerdaten',
messages: ['Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.']
} %}
{% endif %}
<div id="participant-cards-grid" class="space-y-4">
{% for participant in bookingDto.participants %}
{% set isCanceled = (bookingData.participantsStatus[loop.index0] ?? null) == 'S' %}
{% include 'booking/_participant_card.html.twig' with {
'cardData': cardsData[loop.index0],
'index': loop.index0,
'participantNumber': loop.index,
'mode': 'edit',
'isCanceled': isCanceled,
'bookingId': bookingData.id
} %}
{% endfor %}
</div>
{% endblock %}
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
</div>
{# Sidebar summary #}
{% block booking_summary %}
<div id="booking-summary"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData,
'mutableData': mutableData|default(null)
} %}
{# Fixed footer #}
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex justify-between">
<button type="button"
hx-post="{{ path('app_booking_edit_cancel', {id: bookingData.id}) }}"
{{ stimulus_action('loading', 'toggle') }}
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10">
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
{% if isDirty %}
<button type="submit"
{{ stimulus_action('loading', 'toggle') }}
{% if hasValidationErrors|default(false) %}
disabled
title="Bitte behebe zuerst alle Validierungsfehler"
{% endif %}
class="button button--primary {{ hasValidationErrors|default(false) ? 'opacity-50 cursor-not-allowed' : '' }}">
Buchung aktualisieren
</button>
{% endif %}
</div>
{% endblock %}
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+46 -18
View File
@@ -1,24 +1,52 @@
{% extends 'layout.html.twig' %}
{% extends 'layout_booking.html.twig' %}
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<h1>Buchung bearbeiten</h1>
{{ form_start(form, {
'attr': {
'class': 'flex-1 flex flex-col min-h-0',
'novalidate': 'novalidate',
'hx-post': path(submitRouteName, submitRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML scroll:top'
}
}) }}
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Summary - hidden on mobile, visible on desktop #}
<div id="booking-summary"
class="hidden lg:block lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData,
'mutableData': mutableData|default(null)
} %}
</div>
<div class="grid grid-cols-3 gap-8">
{# Main content area - participant form #}
<div id="main-content" class="col-span-2">
{% include 'booking/_participant_form.html.twig' %}
{# Form area - full width mobile, 3 cols desktop #}
<div class="flex-1 order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
{# Header - desktop only #}
<div class="hidden lg:flex shrink-0 px-4 lg:px-8 h-16 items-center shadow-md relative z-10 bg-white">
<h1 class="text-xl font-semibold uppercase">Buchung bearbeiten</h1>
</div>
<div id="main-content" class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
{# Flash messages - must be inside main-content for HTMX swap to display them #}
{% include '_partials/_flashes.html.twig' %}
{% include 'booking/_participant_form.html.twig' %}
</div>
</div>
</div>
{# Sidebar summary #}
<div id="booking-summary">
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingDto,
'summaryData': summaryData,
'mutableData': mutableData|default(null)
} %}
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex justify-between">
<a href="{{ path('app_booking_edit', {id: bookingDto.booking.id}) }}"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10"
{{ stimulus_action('loading', 'toggle') }}
{{ qa_attribute('btn-cancel') }}>
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }} {{ stimulus_action('loading', 'toggle') }}>
Speichern
</button>
</div>
</div>
</div>
{% include 'booking/_cancel_link.html.twig' %}
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+100 -87
View File
@@ -1,92 +1,105 @@
{% extends 'layout.html.twig' %}
{% block content %}
<h1 class="text-2xl font-semibold pb-4">
Meine Buchungen
</h1>
{% include '_partials/_flashes.html.twig' %}
<table class="w-full mb-4 responsive">
<thead>
<tr class="bg-zinc-200">
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-left text-sm md:text-base" scope="col">
Reise
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-sm md:text-base" scope="col">
Reisedatum
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-sm md:text-base" scope="col">
Buchungsnummer
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-sm md:text-base" scope="col">
Reisepreis
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-sm md:text-base" scope="col">
offen
</th>
<th class="border-r border-white/50 px-2 py-1 md:p-2 text-sm md:text-base" scope="col">
Status
</th>
<th scope="col">
<span class="sr-only">Aktionen</span>
</th>
</tr>
</thead>
<tbody>
{% for booking in bookings %}
<tr class="odd:bg-zinc-50 hover:bg-zinc-100 odd:hover:bg-zinc-100">
<td class="border-r border-zinc-200 px-2 py-1 md:p-2"
data-label="Reise">
{{ booking.travelName }}
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2"
data-label="Buchungsdatum">
{% if booking.travelData %}
{{ booking.travelData.dateFrom|date('d.m.Y') }} - {{ booking.travelData.dateTo|date('d.m.Y') }}
{% else %}
{{ booking.travelDate|date('d.m.Y') }}
{% endif %}
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2"
data-label="Buchungsnummer">
{{ booking.bookingNumber }}
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2"
data-label="Reisepreis">
{{ booking.price|format_currency('EUR') }}
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2"
data-label="offen">
{{ booking.balance ? booking.balance|format_currency('EUR') : '-' }}
</td>
<td class="border-r border-zinc-200 px-2 py-1 md:p-2"
data-label="Status">
{{ booking.status|map_status }}
</td>
<td class="px-2 py-1 md:p-2">
<div class="flex md:flex-col space-x-2 md:space-x-0 md:space-y-1">
{% if booking.editable %}
<a href="{{ path('app_booking_edit', { 'id': booking.id }) }}"
{{ stimulus_action('loading', 'toggle') }}
class="button bg-button bg-button--secondary button--small">
bearbeiten
</a>
{% endif %}
<a href="{{ path('app_booking_invoice', { 'id': booking.id }) }}"
class="button bg-button button--small"
target="_blank">
Rechnung
</a>
{% if booking.travelInfoUrl is not null %}
<a href="{{ booking.travelInfoUrl }}"
class="button bg-button bg-pink button--small"
target="_blank">
Reiseinfos
</a>
{% endif %}
<div class="flex-1 flex flex-col min-h-0">
{# Header #}
<div class="shrink-0 px-4 lg:px-8 py-8 lg:py-16">
<h1 class="text-white uppercase pb-4">
Meine<br>Buchungen
</h1>
</div>
{# Scrollable content area #}
<div class="flex-1 overflow-y-auto p-4 lg:p-8 bg-primary-bg">
{% include '_partials/_flashes.html.twig' %}
<div class="space-y-8">
{% for booking in bookings %}
<div class="grid md:grid-cols-2 gap-4 p-2 bg-white rounded-md">
<div class="flex items-start space-x-4">
<div class="w-12 h-12 inline-flex flex-shrink-0 items-center justify-center rounded-full bg-primary-dark text-white text-2xl font-bold">
{{ loop.index }}
</div>
<div class="text-xl font-semibold">
{{ booking.travelName }}
</div>
</div>
<div>
<table class="mb-4">
<tr>
<th class="text-left border-r-2 border-gray-300 pr-2">
Reisedatum
</th>
<td class="pl-2">
{% if booking.travelData %}
{{ booking.travelData.dateFrom|date('d.m.Y') }} - {{ booking.travelData.dateTo|date('d.m.Y') }}
{% else %}
{{ booking.travelDate|date('d.m.Y') }}
{% endif %}
</td>
</tr>
<tr>
<th class="text-left border-r-2 border-gray-300 pr-2">
Buchungsnr.
</th>
<td class="pl-2">
{{ booking.bookingNumber }}
</td>
</tr>
<tr>
<th class="text-left border-r-2 border-gray-300">
Reisepreis
</th>
<td class="pl-2">
{{ booking.price|format_currency('EUR') }}
</td>
</tr>
<tr>
<th class="text-left border-r-2 border-gray-300">
offen
</th>
<td class="pl-2">
{{ booking.balance ? booking.balance|format_currency('EUR') : '-' }}
</td>
</tr>
<tr>
<th class="text-left border-r-2 border-gray-300">
Status
</th>
<td class="pl-2">
{{ booking.status|map_status }}
</td>
</tr>
</table>
<div class="flex space-x-2">
{% if booking.editable %}
<a href="{{ path('app_booking_edit', { 'id': booking.id }) }}"
{{ stimulus_action('loading', 'toggle') }}
class="button button--primary button--small"
title="Buchung bearbeiten">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M92.69,216H48a8,8,0,0,1-8-8V163.31a8,8,0,0,1,2.34-5.65L165.66,34.34a8,8,0,0,1,11.31,0L221.66,79a8,8,0,0,1,0,11.31L98.34,213.66A8,8,0,0,1,92.69,216Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="136" y1="64" x2="192" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="164" y1="92" x2="68" y2="188" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="95.49" y1="215.49" x2="40.51" y2="160.51" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
{% endif %}
<a href="{{ path('app_booking_invoice', { 'id': booking.id }) }}"
class="button button--secondary button--small"
title="Rechnung herunterladen"
target="_blank">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M64,56H40A16,16,0,0,0,24,72h0A16,16,0,0,0,40,88H56a16,16,0,0,1,16,16h0a16,16,0,0,1-16,16H28" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="48" y1="48" x2="48" y2="56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="48" y1="120" x2="48" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M96,56H224V192a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V152" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="104" y1="104" x2="224" y2="104" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="80" y1="152" x2="224" y2="152" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="168" y1="104" x2="168" y2="200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
{% if booking.travelInfoUrl is not null %}
<a href="{{ booking.travelInfoUrl }}"
class="button button--secondary button--small"
title="zu den Reiseinfos"
target="_blank">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M120,120a8,8,0,0,1,8,8v40a8,8,0,0,0,8,8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="124" cy="84" r="12"/></svg>
</a>
{% endif %}
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Sicher?{% endblock %}
{% block content %}
<div class="pb-8">
Willst du den Buchungsvorgang wirklich abbrechen?
</div>
<div class="flex justify-between">
<button type="button"
hx-post="{{ path('app_booking_cancel') }}"
hx-target="body"
class="button button--small button--primary">
Ja
</button>
<button type="button"
{{ stimulus_action('modal', 'close') }}
class="button button--small button--secondary">
Nein
</button>
</div>
{% endblock %}