diff --git a/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php new file mode 100644 index 0000000..8e7696d --- /dev/null +++ b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsCommand.php @@ -0,0 +1,111 @@ +request->event->participation_options = $this->normalize($this->request->questions); + $this->request->event->save(); + + $response->success = true; + return $response; + } + + /** + * Normalisiert die rohe Fragen-Definition: trimmt Labels, generiert stabile Keys/Values und verwirft + * unvollständige Fragen (kein Label oder keine gültige Option). Keys/Values sind innerhalb ihres + * Geltungsbereichs eindeutig, damit Antworten robust referenzieren können. + * + * @param array> $questions + * @return array}> + */ + private function normalize(array $questions): array + { + $normalized = []; + $usedKeys = []; + + foreach ($questions as $question) { + $label = trim((string)($question['label'] ?? '')); + if ($label === '') { + continue; + } + + $options = $this->normalizeOptions($question['options'] ?? []); + if ($options === []) { + continue; + } + + $key = $this->uniqueSlug((string)($question['key'] ?? ''), $label, $usedKeys); + $usedKeys[] = $key; + + $normalized[] = [ + 'key' => $key, + 'label' => $label, + 'options' => $options, + ]; + } + + return $normalized; + } + + /** + * @param array> $options + * @return array + */ + private function normalizeOptions(mixed $options): array + { + if (!is_array($options)) { + return []; + } + + $normalized = []; + $usedValues = []; + + foreach ($options as $option) { + $label = trim((string)($option['label'] ?? '')); + if ($label === '') { + continue; + } + + $value = $this->uniqueSlug((string)($option['value'] ?? ''), $label, $usedValues); + $usedValues[] = $value; + + $normalized[] = ['value' => $value, 'label' => $label]; + } + + return $normalized; + } + + /** + * Liefert einen eindeutigen Slug: bevorzugt den bestehenden Wert (Bestandsschutz für schon gespeicherte + * Fragen/Antworten), sonst aus dem Label abgeleitet; Kollisionen werden durchnummeriert. + * + * @param array $used + */ + private function uniqueSlug(string $existing, string $label, array $used): string + { + $base = $existing !== '' ? $existing : Str::slug($label); + if ($base === '') { + $base = 'option'; + } + + $candidate = $base; + $suffix = 2; + while (in_array($candidate, $used, true)) { + $candidate = $base . '-' . $suffix; + $suffix++; + } + + return $candidate; + } +} diff --git a/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsRequest.php b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsRequest.php new file mode 100644 index 0000000..ad89323 --- /dev/null +++ b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsRequest.php @@ -0,0 +1,19 @@ +> Rohe Fragen-Definition aus dem Admin-Panel. */ + public array $questions; + + public function __construct(Event $event, array $questions) + { + $this->event = $event; + $this->questions = $questions; + } +} diff --git a/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsResponse.php b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsResponse.php new file mode 100644 index 0000000..603018c --- /dev/null +++ b/app/Domains/Event/Actions/SetParticipationOptions/SetParticipationOptionsResponse.php @@ -0,0 +1,15 @@ +success = false; + $this->message = null; + } +} diff --git a/app/Domains/Event/Actions/SignUp/SignUpCommand.php b/app/Domains/Event/Actions/SignUp/SignUpCommand.php index f4ad093..518fbad 100644 --- a/app/Domains/Event/Actions/SignUp/SignUpCommand.php +++ b/app/Domains/Event/Actions/SignUp/SignUpCommand.php @@ -27,6 +27,14 @@ class SignUpCommand { return $response; } + // Teilnahmeoptionen (event-spezifische Pflicht-Auswahl) gegen die Event-Definition absichern. + $participationOptionRows = $this->buildParticipationOptionRows(); + if ($participationOptionRows === null) { + $response->success = false; + $response->message = 'Bitte alle Auswahlfelder ausfüllen.'; + return $response; + } + $eatingHabit = match ($this->request->eating_habit) { 'vegan' => EatingHabit::EATING_HABIT_VEGAN, 'vegetarian' => EatingHabit::EATING_HABIT_VEGETARIAN, @@ -80,8 +88,68 @@ class SignUpCommand { ] ); + $this->persistParticipationOptions($response->participant, $participationOptionRows); + $response->success = true; return $response; } + /** + * Validiert die Antworten gegen die am Event definierten Teilnahmeoptionen und erzeugt die zu speichernden + * Zeilen mit Label-Snapshots. Gibt null zurück, wenn eine Pflicht-Frage unbeantwortet oder eine Antwort + * ungültig ist (Absicherung gegen manipulierte Payloads). + * + * @return array|null + */ + private function buildParticipationOptionRows(): ?array + { + $rows = []; + + foreach ($this->request->event->participation_options ?? [] as $question) { + $key = $question['key'] ?? null; + if ($key === null) { + continue; + } + + $value = $this->request->participationOptions[$key] ?? null; + if ($value === null || $value === '') { + return null; + } + + $option = null; + foreach ($question['options'] ?? [] as $candidate) { + if (($candidate['value'] ?? null) === $value) { + $option = $candidate; + break; + } + } + + if ($option === null) { + return null; + } + + $rows[] = [ + 'question_key' => $key, + 'question_label' => $question['label'] ?? $key, + 'value' => $value, + 'value_label' => $option['label'] ?? $value, + ]; + } + + return $rows; + } + + /** + * @param array $rows + */ + private function persistParticipationOptions(\App\Models\EventParticipant $participant, array $rows): void + { + foreach ($rows as $row) { + $participant->selectedOptions()->create(array_merge($row, [ + 'tenant' => $this->request->event->tenant, + 'event_id' => $this->request->event->id, + ])); + } + } + } diff --git a/app/Domains/Event/Actions/SignUp/SignUpRequest.php b/app/Domains/Event/Actions/SignUp/SignUpRequest.php index c64979b..de08ed7 100644 --- a/app/Domains/Event/Actions/SignUp/SignUpRequest.php +++ b/app/Domains/Event/Actions/SignUp/SignUpRequest.php @@ -46,6 +46,8 @@ class SignUpRequest { public Amount $amount, public ?string $paymentMethod, public array $paymentOptions = [], + /** Antworten auf die Teilnahmeoptionen: [ question_key => value ]. */ + public array $participationOptions = [], ) { } } diff --git a/app/Domains/Event/Controllers/DetailsController.php b/app/Domains/Event/Controllers/DetailsController.php index b64c281..2bb71d2 100644 --- a/app/Domains/Event/Controllers/DetailsController.php +++ b/app/Domains/Event/Controllers/DetailsController.php @@ -4,6 +4,8 @@ namespace App\Domains\Event\Controllers; use App\Domains\Event\Actions\SetParticipationFees\SetParticipationFeesCommand; use App\Domains\Event\Actions\SetParticipationFees\SetParticipationFeesRequest; +use App\Domains\Event\Actions\SetParticipationOptions\SetParticipationOptionsCommand; +use App\Domains\Event\Actions\SetParticipationOptions\SetParticipationOptionsRequest; use App\Domains\Event\Actions\SetPaymentMethods\SetPaymentMethodsCommand; use App\Domains\Event\Actions\SetPaymentMethods\SetPaymentMethodsRequest; use App\Domains\Event\Actions\UpdateEvent\UpdateEventCommand; @@ -142,6 +144,17 @@ class DetailsController extends CommonController { return response()->json(['status' => $response->success ? 'success' : 'error']); } + public function updateParticipationOptions(int $eventId, Request $request): JsonResponse + { + $event = $this->events->getById($eventId); + + $setRequest = new SetParticipationOptionsRequest($event, (array)$request->input('questions', [])); + $setCommand = new SetParticipationOptionsCommand($setRequest); + $response = $setCommand->execute(); + + return response()->json(['status' => $response->success ? 'success' : 'error', 'message' => $response->message]); + } + public function downloadPdfList(string $eventId, string $listType, Request $request): Response { $event = $this->events->getByIdentifier($eventId); diff --git a/app/Domains/Event/Controllers/SignupController.php b/app/Domains/Event/Controllers/SignupController.php index 643d0d6..ad776a1 100644 --- a/app/Domains/Event/Controllers/SignupController.php +++ b/app/Domains/Event/Controllers/SignupController.php @@ -142,6 +142,7 @@ class SignupController extends CommonController { $amount, $registrationData['paymentMethod'] ?? null, (array)($registrationData['paymentOptions'] ?? []), + (array)($registrationData['participationOptions'] ?? []), ); $signupCommand = new SignUpCommand($signupRequest); diff --git a/app/Domains/Event/Routes/api.php b/app/Domains/Event/Routes/api.php index 34cb9b6..760e935 100644 --- a/app/Domains/Event/Routes/api.php +++ b/app/Domains/Event/Routes/api.php @@ -46,6 +46,7 @@ Route::prefix('api/v1') Route::post('/participation-fees', [DetailsController::class, 'updateParticipationFees']); Route::post('/common-settings', [DetailsController::class, 'updateCommonSettings']); Route::post('/payment-methods', [DetailsController::class, 'updatePaymentMethods']); + Route::post('/participation-options', [DetailsController::class, 'updateParticipationOptions']); }); diff --git a/app/Domains/Event/Views/Partials/Overview.vue b/app/Domains/Event/Views/Partials/Overview.vue index 920e544..26607b7 100644 --- a/app/Domains/Event/Views/Partials/Overview.vue +++ b/app/Domains/Event/Views/Partials/Overview.vue @@ -1,15 +1,16 @@ + + + + diff --git a/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue b/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue index 3d5e9ab..70258ed 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue @@ -6,6 +6,7 @@ import StepPersonalData from './steps/StepPersonalData.vue' import StepRegistrationMode from './steps/StepRegistrationMode.vue' import StepArrival from './steps/StepArrival.vue' import StepAddons from './steps/StepAddons.vue' +import StepParticipationOptions from './steps/StepParticipationOptions.vue' import StepPhotoPermissions from './steps/StepPhotoPermissions.vue' import StepAllergies from './steps/StepAllergies.vue' import StepPaymentMethod from './steps/StepPaymentMethod.vue' @@ -33,10 +34,11 @@ const steps = [ { step: 4, label: 'An-/Abreise' }, { step: 5, label: 'Teilnahmegruppe' }, { step: 6, label: 'Zusatzoptionen' }, - { step: 7, label: 'Fotoerlaubnis' }, - { step: 8, label: 'Allergien' }, - {step: 9, label: 'Zahlungsart'}, - {step: 10, label: 'Zusammenfassung'}, + {step: 7, label: 'Teilnahmeoptionen'}, + {step: 8, label: 'Fotoerlaubnis'}, + {step: 9, label: 'Allergien'}, + {step: 10, label: 'Zahlungsart'}, + {step: 11, label: 'Zusammenfassung'}, ] @@ -93,12 +95,16 @@ const steps = [ - - - + + + 0 + } + + if (step === STEP_PARTICIPATION_OPTIONS) { + return (event.participationOptions?.length ?? 0) > 0 + } + + return true +} + +// Nächster sichtbarer Schritt nach `step` (überspringt ausgeblendete optionale Schritte). +export function nextVisibleFrom(event, step) { + let candidate = step + 1 + while (candidate < STEP_SUMMARY && !stepVisible(event, candidate)) { + candidate++ + } + return candidate +} + +// Vorheriger sichtbarer Schritt vor `step`. +export function prevVisibleFrom(event, step) { + let candidate = step - 1 + while (candidate > STEP_AGE && !stepVisible(event, candidate)) { + candidate-- + } + return candidate +} diff --git a/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js b/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js index 5485f66..7a0a709 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js +++ b/app/Domains/Event/Views/Partials/SignUpForm/composables/useSignupForm.js @@ -1,5 +1,6 @@ import {reactive, ref} from 'vue' import axios from 'axios' +import {STEP_PAYMENT, STEP_SUMMARY} from './stepFlow.js' export function useSignupForm(event, participantData) { const currentStep = ref(1) @@ -16,6 +17,8 @@ export function useSignupForm(event, participantData) { eatingHabit: 'EATING_HABIT_VEGAN', paymentMethod: activeMethods.length === 1 ? activeMethods[0].slug : null, paymentOptions: {}, + // Antworten auf die event-spezifischen Teilnahmeoptionen: { [question.key]: option.value } + participationOptions: {}, userId: participantData.id, eventId: event.id, vorname: participantData.firstname ?? '', @@ -79,10 +82,6 @@ export function useSignupForm(event, participantData) { } } - // Steps: ... 8 Allergien, 9 Zahlungsart, 10 Zusammenfassung. - const STEP_PAYMENT = 9 - const STEP_SUMMARY = 10 - const goToStep = async (step) => { if (step === STEP_PAYMENT) { // Betrag ermitteln: Bei 0 € wird die Zahlungsart-Auswahl übersprungen. diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue index ad1b60c..aab4f94 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAddons.vue @@ -1,6 +1,10 @@ diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAllergies.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAllergies.vue index a9be072..fa53956 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAllergies.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepAllergies.vue @@ -43,8 +43,8 @@ const emit = defineEmits(['next', 'back']) - - + + diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue new file mode 100644 index 0000000..b4d7499 --- /dev/null +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepParticipationOptions.vue @@ -0,0 +1,48 @@ + + + diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPaymentMethod.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPaymentMethod.vue index f850594..acc8442 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPaymentMethod.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPaymentMethod.vue @@ -78,8 +78,8 @@ const canProceed = computed(() => {
- - +
diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPhotoPermissions.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPhotoPermissions.vue index 43e77e6..db42926 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPhotoPermissions.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepPhotoPermissions.vue @@ -1,16 +1,15 @@ diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepRegistrationMode.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepRegistrationMode.vue index ca46946..d611ec8 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepRegistrationMode.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepRegistrationMode.vue @@ -1,5 +1,6 @@ diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue index 89eeb5f..9173a87 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepSummary.vue @@ -32,8 +32,17 @@ const paymentConfirmationText = computed(() => { return template.replaceAll('{amount}', props.summaryAmount ?? '') }) -// Zurück führt zur Zahlungsart (9), sofern dieser Schritt gezeigt wurde; bei 0 € direkt zu Allergien (8). -const backTarget = computed(() => props.summaryAmountValue > 0 ? 9 : 8) +// Zurück führt zur Zahlungsart (10), sofern dieser Schritt gezeigt wurde; bei 0 € direkt zu Allergien (9). +const backTarget = computed(() => props.summaryAmountValue > 0 ? 10 : 9) + +// Gewählte Teilnahmeoptionen für die Zusammenfassung (label statt value anzeigen). +const selectedParticipationOptions = computed(() => + (props.event.participationOptions ?? []).map(question => { + const value = props.formData.participationOptions[question.key] + const option = (question.options ?? []).find(o => o.value === value) + return {label: question.label, value: option?.label ?? ''} + }).filter(entry => entry.value !== '') +) const canSubmit = computed(() => props.formData.summary_information_correct @@ -115,6 +124,11 @@ function eatingHabit() { {{ participationGroup() }} + + {{ option.label }}: + {{ option.value }} + + Foto-Erlaubnis: diff --git a/app/Models/Event.php b/app/Models/Event.php index ffce8ee..6a7eb7e 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -87,6 +87,7 @@ class Event extends InstancedModel 'vat_pricing_mode', 'tax_exemption_reason', 'tax_exemption_note', + 'participation_options', ]; @@ -114,6 +115,8 @@ class Event extends InstancedModel 'tax_liable' => 'boolean', 'vat_rate' => 'integer', + + 'participation_options' => 'array', ]; public function tenant(): BelongsTo diff --git a/app/Models/EventParticipant.php b/app/Models/EventParticipant.php index 23622a7..b61aca1 100644 --- a/app/Models/EventParticipant.php +++ b/app/Models/EventParticipant.php @@ -14,6 +14,7 @@ use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\EventPaymentModule; use App\EventPaymentModules\EventPaymentModuleRegistry; use App\Scopes\InstancedModel; +use Illuminate\Database\Eloquent\Relations\HasMany; class EventParticipant extends InstancedModel @@ -103,6 +104,12 @@ class EventParticipant extends InstancedModel return $this->belongsTo(Event::class); } + /** Gewählte Teilnahmeoptionen (event-spezifische Pflicht-Auswahl) dieser Anmeldung. */ + public function selectedOptions(): HasMany + { + return $this->hasMany(EventParticipantOption::class, 'event_participant_id'); + } + public function user() { return $this->belongsTo(User::class); diff --git a/app/Models/EventParticipantOption.php b/app/Models/EventParticipantOption.php new file mode 100644 index 0000000..726d76d --- /dev/null +++ b/app/Models/EventParticipantOption.php @@ -0,0 +1,31 @@ +belongsTo(Event::class); + } + + public function participant(): BelongsTo + { + return $this->belongsTo(EventParticipant::class, 'event_participant_id'); + } +} diff --git a/app/Resources/EventParticipantOptionResource.php b/app/Resources/EventParticipantOptionResource.php new file mode 100644 index 0000000..3a9d995 --- /dev/null +++ b/app/Resources/EventParticipantOptionResource.php @@ -0,0 +1,24 @@ + $this->resource->question_key, + 'questionLabel' => $this->resource->question_label, + 'value' => $this->resource->value, + 'valueLabel' => $this->resource->value_label, + ]; + } +} diff --git a/app/Resources/EventParticipantResource.php b/app/Resources/EventParticipantResource.php index c5225a7..666e043 100644 --- a/app/Resources/EventParticipantResource.php +++ b/app/Resources/EventParticipantResource.php @@ -96,6 +96,8 @@ class EventParticipantResource extends JsonResource 'eventName' => $this->resource->event()->first()->name, 'arrivalDateReadable' => $this->resource->arrival_date->format('d.m.Y'), 'departureDateReadable' => $this->resource->departure_date->format('d.m.Y'), + 'participationOptions' => $this->resource->selectedOptions()->get()->map( + fn($option) => new EventParticipantOptionResource($option)->toArray($request))->toArray(), ] ); } diff --git a/app/Resources/EventResource.php b/app/Resources/EventResource.php index 8da535c..aa59d21 100644 --- a/app/Resources/EventResource.php +++ b/app/Resources/EventResource.php @@ -138,6 +138,9 @@ class EventResource extends JsonResource{ $returnArray['paymentMethods'] = $this->event->paymentMethods()->get()->map( fn($paymentMethod) => new AvailablePaymentMethodResource($paymentMethod)->toArray($request))->toArray(); + // Veranstaltungsspezifische Pflicht-Auswahlfragen (Teilnahmeoptionen); leeres Array => Schritt entfällt. + $returnArray['participationOptions'] = $this->event->participation_options ?? []; + $returnArray['participationTypes'] = []; diff --git a/database/migrations/2026_08_12_140010_add_participation_options_to_events.php b/database/migrations/2026_08_12_140010_add_participation_options_to_events.php new file mode 100644 index 0000000..b07e30b --- /dev/null +++ b/database/migrations/2026_08_12_140010_add_participation_options_to_events.php @@ -0,0 +1,23 @@ +json('participation_options')->nullable(); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn('participation_options'); + }); + } +}; diff --git a/database/migrations/2026_08_12_140020_create_event_participant_options.php b/database/migrations/2026_08_12_140020_create_event_participant_options.php new file mode 100644 index 0000000..b16e2f7 --- /dev/null +++ b/database/migrations/2026_08_12_140020_create_event_participant_options.php @@ -0,0 +1,35 @@ +id(); + $table->string('tenant'); + + $table->foreignId('event_id')->constrained('events', 'id')->cascadeOnDelete()->cascadeOnUpdate(); + $table->foreignId('event_participant_id')->constrained('event_participants', 'id')->cascadeOnDelete()->cascadeOnUpdate(); + + $table->string('question_key'); + $table->string('question_label'); + $table->string('value'); + $table->string('value_label'); + + $table->timestamps(); + + $table->foreign('tenant')->references('slug')->on('tenants')->restrictOnDelete()->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_participant_options'); + } +};