Additional event informations can be added
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\SetParticipationOptions;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SetParticipationOptionsCommand
|
||||
{
|
||||
public function __construct(private SetParticipationOptionsRequest $request)
|
||||
{
|
||||
}
|
||||
|
||||
public function execute(): SetParticipationOptionsResponse
|
||||
{
|
||||
$response = new SetParticipationOptionsResponse();
|
||||
|
||||
$this->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<int, array<string, mixed>> $questions
|
||||
* @return array<int, array{key: string, label: string, options: array<int, array{value: string, label: string}>}>
|
||||
*/
|
||||
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<int, array<string, mixed>> $options
|
||||
* @return array<int, array{value: string, label: string}>
|
||||
*/
|
||||
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<int, string> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\SetParticipationOptions;
|
||||
|
||||
use App\Models\Event;
|
||||
|
||||
class SetParticipationOptionsRequest
|
||||
{
|
||||
public Event $event;
|
||||
|
||||
/** @var array<int, array<string, mixed>> Rohe Fragen-Definition aus dem Admin-Panel. */
|
||||
public array $questions;
|
||||
|
||||
public function __construct(Event $event, array $questions)
|
||||
{
|
||||
$this->event = $event;
|
||||
$this->questions = $questions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\SetParticipationOptions;
|
||||
|
||||
class SetParticipationOptionsResponse
|
||||
{
|
||||
public bool $success;
|
||||
public ?string $message;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->success = false;
|
||||
$this->message = null;
|
||||
}
|
||||
}
|
||||
@@ -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<int, array{question_key: string, question_label: string, value: string, value_label: string}>|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<int, array{question_key: string, question_label: string, value: string, value_label: string}> $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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -142,6 +142,7 @@ class SignupController extends CommonController {
|
||||
$amount,
|
||||
$registrationData['paymentMethod'] ?? null,
|
||||
(array)($registrationData['paymentOptions'] ?? []),
|
||||
(array)($registrationData['participationOptions'] ?? []),
|
||||
);
|
||||
|
||||
$signupCommand = new SignUpCommand($signupRequest);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup>
|
||||
import {onMounted, reactive, ref} from "vue";
|
||||
import ParticipationFees from "./ParticipationFees.vue";
|
||||
import ParticipationSummary from "./ParticipationSummary.vue";
|
||||
import CommonSettings from "./CommonSettings.vue";
|
||||
import EventManagement from "./EventManagement.vue";
|
||||
import Modal from "../../../../Views/Components/Modal.vue";
|
||||
import MailCompose from "./MailCompose.vue";
|
||||
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
||||
import {toast} from "vue3-toastify";
|
||||
import {onMounted, reactive, ref} from "vue";
|
||||
import ParticipationFees from "./ParticipationFees.vue";
|
||||
import ParticipationOptions from "./ParticipationOptions.vue";
|
||||
import ParticipationSummary from "./ParticipationSummary.vue";
|
||||
import CommonSettings from "./CommonSettings.vue";
|
||||
import EventManagement from "./EventManagement.vue";
|
||||
import Modal from "../../../../Views/Components/Modal.vue";
|
||||
import MailCompose from "./MailCompose.vue";
|
||||
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
||||
import {toast} from "vue3-toastify";
|
||||
|
||||
const props = defineProps({
|
||||
const props = defineProps({
|
||||
data: Object,
|
||||
})
|
||||
|
||||
@@ -36,6 +37,10 @@
|
||||
displayData.value = 'participationFees';
|
||||
}
|
||||
|
||||
async function showParticipationOptions() {
|
||||
displayData.value = 'participationOptions';
|
||||
}
|
||||
|
||||
async function showEventManagement() {
|
||||
displayData.value = 'eventManagement';
|
||||
}
|
||||
@@ -107,6 +112,8 @@
|
||||
|
||||
|
||||
<ParticipationFees v-if="displayData === 'participationFees'" :event="dynamicProps.event" @close="showMain" />
|
||||
<ParticipationOptions v-else-if="displayData === 'participationOptions'" :event="dynamicProps.event"
|
||||
@close="showMain"/>
|
||||
<CommonSettings v-else-if="displayData === 'commonSettings'" :event="dynamicProps.event" @close="showMain" />
|
||||
<EventManagement v-else-if="displayData === 'eventManagement'" :event="dynamicProps.event" @close="showMain" />
|
||||
|
||||
@@ -185,7 +192,8 @@
|
||||
<div class="event-flexbox-row bottom">
|
||||
<label style="font-size: 9pt;" class="link" @click="showCommonSettings">Allgemeine Einstellungen</label>
|
||||
<label style="font-size: 9pt;" class="link" @click="showEventManagement">Veranstaltungsleitung</label>
|
||||
<label style="font-size: 9pt;" class="link" @click="showParticipationFees">Teilnahmegebühren</label>
|
||||
<label style="font-size: 9pt;" class="link" @click="showParticipationFees">Teilnahmegebühren</label>
|
||||
<label style="font-size: 9pt;" class="link" @click="showParticipationOptions">Teilnahmeoptionen</label>
|
||||
<a style="font-size: 9pt;" class="link" :href="'/budget/' + props.data.event.costUnit.id">Budget bearbeiten</a>
|
||||
<a style="font-size: 9pt;" class="link" :href="'/cost-unit/' + props.data.event.costUnit.id">Ausgabenübersicht</a>
|
||||
<a v-if="!dynamicProps.event.registrationAllowed && !dynamicProps.event.archived" style="color: #ff0000; font-size: 9pt;" class="link" @click="archiveEvent">Archivieren</a>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup>
|
||||
import {ref} from "vue";
|
||||
import {toast} from "vue3-toastify";
|
||||
import {request} from "../../../../../resources/js/components/HttpClient.js";
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const props = defineProps({
|
||||
event: Object,
|
||||
})
|
||||
|
||||
// Fragen-Definition dieses Events: [ { key, label, options: [ { value, label } ] } ].
|
||||
// key/value bleiben für bestehende Einträge erhalten (Bestandsschutz für bereits gespeicherte Antworten);
|
||||
// neue Einträge bekommen serverseitig beim Speichern einen stabilen Slug.
|
||||
const questions = ref((props.event.participationOptions ?? []).map(q => ({
|
||||
key: q.key ?? '',
|
||||
label: q.label ?? '',
|
||||
options: (q.options ?? []).map(o => ({value: o.value ?? '', label: o.label ?? ''})),
|
||||
})))
|
||||
|
||||
function addQuestion() {
|
||||
questions.value.push({key: '', label: '', options: [{value: '', label: ''}, {value: '', label: ''}]})
|
||||
}
|
||||
|
||||
function removeQuestion(index) {
|
||||
questions.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function addOption(question) {
|
||||
question.options.push({value: '', label: ''})
|
||||
}
|
||||
|
||||
function removeOption(question, index) {
|
||||
question.options.splice(index, 1)
|
||||
}
|
||||
|
||||
function validate() {
|
||||
for (const question of questions.value) {
|
||||
if (question.label.trim() === '') {
|
||||
toast.error('Bitte für jede Frage eine Beschriftung angeben.')
|
||||
return false
|
||||
}
|
||||
|
||||
const validOptions = question.options.filter(o => o.label.trim() !== '')
|
||||
if (validOptions.length < 1) {
|
||||
toast.error('Jede Frage braucht mindestens eine Auswahlmöglichkeit.')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!validate()) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await request('/api/v1/event/details/' + props.event.id + '/participation-options', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
questions: questions.value,
|
||||
}
|
||||
})
|
||||
|
||||
if (response.status !== 'success') {
|
||||
toast.error(response.message ?? 'Die Teilnahmeoptionen konnten nicht gespeichert werden.')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Die Teilnahmeoptionen wurden gespeichert')
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="participation-options">
|
||||
<h3>Teilnahmeoptionen</h3>
|
||||
<p style="color: #6b7280; font-size: 0.9rem;">
|
||||
Pflicht-Auswahlfragen für die Anmeldung (z. B. Kurs oder Stufe). Je Frage ist genau eine Option wählbar.
|
||||
Ohne Fragen wird dieser Schritt in der Anmeldung übersprungen.
|
||||
</p>
|
||||
|
||||
<div v-for="(question, qIndex) in questions" :key="qIndex" class="question-card">
|
||||
<div class="question-head">
|
||||
<input
|
||||
type="text"
|
||||
v-model="question.label"
|
||||
class="width-full"
|
||||
placeholder="Frage, z. B. „Welchen Kurs möchtest du belegen?“"
|
||||
/>
|
||||
<label class="link remove-link" @click="removeQuestion(qIndex)">Frage entfernen</label>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
<div v-for="(option, oIndex) in question.options" :key="oIndex" class="option-row">
|
||||
<input
|
||||
type="text"
|
||||
v-model="option.label"
|
||||
class="width-full"
|
||||
placeholder="Auswahlmöglichkeit"
|
||||
/>
|
||||
<label class="link remove-link" @click="removeOption(question, oIndex)">✕</label>
|
||||
</div>
|
||||
<label class="link" @click="addOption(question)">+ Option hinzufügen</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px;">
|
||||
<label class="link" @click="addQuestion">+ Frage hinzufügen</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<input type="button" value="Speichern" @click="save"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.question-card {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.question-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.options {
|
||||
margin-top: 12px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.width-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.remove-link {
|
||||
white-space: nowrap;
|
||||
color: #ef4444;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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'},
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -93,12 +95,16 @@ const steps = [
|
||||
<StepArrival v-if="currentStep === 4" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
|
||||
<StepRegistrationMode v-if="currentStep === 5" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
|
||||
<StepAddons v-if="currentStep === 6" :formData="formData" :event="event" :selectedAddons="selectedAddons" @next="goToStep" @back="goToStep" />
|
||||
<StepPhotoPermissions v-if="currentStep === 7" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
|
||||
<StepAllergies v-if="currentStep === 8" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
|
||||
<StepPaymentMethod v-if="currentStep === 9" :formData="formData" :event="event" @next="goToStep"
|
||||
<StepParticipationOptions v-if="currentStep === 7" :formData="formData" :event="event" @next="goToStep"
|
||||
@back="goToStep"/>
|
||||
<StepPhotoPermissions v-if="currentStep === 8" :formData="formData" :event="event" @next="goToStep"
|
||||
@back="goToStep"/>
|
||||
<StepAllergies v-if="currentStep === 9" :formData="formData" :event="event" @next="goToStep"
|
||||
@back="goToStep"/>
|
||||
<StepPaymentMethod v-if="currentStep === 10" :formData="formData" :event="event" @next="goToStep"
|
||||
@back="goToStep"/>
|
||||
<StepSummary
|
||||
v-if="currentStep === 10"
|
||||
v-if="currentStep === 11"
|
||||
:formData="formData"
|
||||
:event="event"
|
||||
:summaryAmount="summaryAmount"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Zentrale Schrittreihenfolge des Anmelde-Wizards. Einige Schritte sind optional und werden
|
||||
// übersprungen, wenn sie für das Event nicht zutreffen (Zusatzoptionen, Teilnahmeoptionen).
|
||||
// Der Zahlungsschritt wird separat in useSignupForm anhand des berechneten Betrags übersprungen.
|
||||
export const STEP_AGE = 1
|
||||
export const STEP_CONTACT = 2
|
||||
export const STEP_PERSONAL = 3
|
||||
export const STEP_ARRIVAL = 4
|
||||
export const STEP_REGISTRATION = 5
|
||||
export const STEP_ADDONS = 6
|
||||
export const STEP_PARTICIPATION_OPTIONS = 7
|
||||
export const STEP_PHOTO = 8
|
||||
export const STEP_ALLERGIES = 9
|
||||
export const STEP_PAYMENT = 10
|
||||
export const STEP_SUMMARY = 11
|
||||
|
||||
// Ob ein (optionaler) Schritt für dieses Event angezeigt wird.
|
||||
export function stepVisible(event, step) {
|
||||
if (step === STEP_ADDONS) {
|
||||
return (event.addons?.length ?? 0) > 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
import {nextVisibleFrom, STEP_ADDONS} from "../composables/stepFlow.js";
|
||||
|
||||
const props = defineProps({ formData: Object, event: Object, selectedAddons: Object })
|
||||
const emit = defineEmits(['next', 'back'])
|
||||
|
||||
const next = () => emit('next', nextVisibleFrom(props.event, STEP_ADDONS))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,7 +43,7 @@ const emit = defineEmits(['next', 'back'])
|
||||
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-secondary" @click="emit('back', 5)">← Zurück</button>
|
||||
<button type="button" class="btn-primary" @click="emit('next', 7)">Weiter →</button>
|
||||
<button type="button" class="btn-primary" @click="next">Weiter →</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -43,8 +43,8 @@ const emit = defineEmits(['next', 'back'])
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" class="btn-row">
|
||||
<button type="button" class="btn-secondary" @click="emit('back', 7)">← Zurück</button>
|
||||
<button type="button" class="btn-primary" @click="emit('next', 9)">Weiter →</button>
|
||||
<button type="button" class="btn-secondary" @click="emit('back', 8)">← Zurück</button>
|
||||
<button type="button" class="btn-primary" @click="emit('next', 10)">Weiter →</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
import {nextVisibleFrom, prevVisibleFrom, STEP_PARTICIPATION_OPTIONS} from "../composables/stepFlow.js";
|
||||
|
||||
const props = defineProps({formData: Object, event: Object})
|
||||
const emit = defineEmits(['next', 'back'])
|
||||
|
||||
const questions = computed(() => props.event.participationOptions ?? [])
|
||||
|
||||
// Pflichtfeld: "Weiter" erst möglich, wenn jede Frage beantwortet ist.
|
||||
const canProceed = computed(() =>
|
||||
questions.value.every(q => {
|
||||
const value = props.formData.participationOptions[q.key]
|
||||
return value !== undefined && value !== null && String(value).trim() !== ''
|
||||
})
|
||||
)
|
||||
|
||||
const back = () => emit('back', prevVisibleFrom(props.event, STEP_PARTICIPATION_OPTIONS))
|
||||
const next = () => emit('next', nextVisibleFrom(props.event, STEP_PARTICIPATION_OPTIONS))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3>Teilnahmeoptionen</h3>
|
||||
<p style="color: #6b7280; font-size: 0.95rem; margin-bottom: 20px;">
|
||||
Bitte triff für jede Auswahl eine Entscheidung.
|
||||
</p>
|
||||
|
||||
<table class="form-table">
|
||||
<tr v-for="question in questions" :key="question.key">
|
||||
<td>{{ question.label }}:</td>
|
||||
<td>
|
||||
<select v-model="formData.participationOptions[question.key]">
|
||||
<option value="">Bitte wählen</option>
|
||||
<option v-for="option in question.options" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-secondary" @click="back">← Zurück</button>
|
||||
<button type="button" class="btn-primary" :disabled="!canProceed" @click="next">Weiter →</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -78,8 +78,8 @@ const canProceed = computed(() => {
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-secondary" @click="emit('back', 8)">← Zurück</button>
|
||||
<button type="button" class="btn-primary" :disabled="!canProceed" @click="emit('next', 10)">Weiter →
|
||||
<button type="button" class="btn-secondary" @click="emit('back', 9)">← Zurück</button>
|
||||
<button type="button" class="btn-primary" :disabled="!canProceed" @click="emit('next', 11)">Weiter →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<script setup>
|
||||
import {prevVisibleFrom, STEP_ALLERGIES, STEP_PHOTO} from "../composables/stepFlow.js";
|
||||
|
||||
const props = defineProps({ formData: Object, event: Object })
|
||||
const emit = defineEmits(['next', 'back'])
|
||||
|
||||
const acceptAll = () => {
|
||||
Object.keys(props.formData.foto).forEach(k => props.formData.foto[k] = true)
|
||||
emit('next', 8)
|
||||
emit('next', STEP_ALLERGIES)
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
const hasAddons = (props.event.addons?.length > 0) || props.event.solidarityPayment
|
||||
emit('back', hasAddons ? 6 : 5)
|
||||
}
|
||||
const back = () => emit('back', prevVisibleFrom(props.event, STEP_PHOTO))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,7 +26,7 @@ const back = () => {
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-secondary" @click="back">← Zurück</button>
|
||||
<button type="button" class="btn-primary" style="background: #059669;" @click="acceptAll">Alle akzeptieren & weiter</button>
|
||||
<button type="button" class="btn-primary" @click="emit('next', 8)">Weiter →</button>
|
||||
<button type="button" class="btn-primary" @click="emit('next', 9)">Weiter →</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { watch } from "vue";
|
||||
import {watch} from "vue";
|
||||
import {nextVisibleFrom, STEP_REGISTRATION} from "../composables/stepFlow.js";
|
||||
|
||||
const props = defineProps({ formData: Object, event: Object })
|
||||
const emit = defineEmits(['next', 'back'])
|
||||
@@ -17,8 +18,7 @@ watch(
|
||||
)
|
||||
|
||||
const nextStep = () => {
|
||||
const hasAddons = (props.event.addons?.length ?? 0) > 0
|
||||
emit('next', hasAddons ? 6 : 7)
|
||||
emit('next', nextVisibleFrom(props.event, STEP_REGISTRATION))
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<td>{{ participationGroup() }}</td>
|
||||
</tr>
|
||||
|
||||
<tr v-for="option in selectedParticipationOptions" :key="option.label">
|
||||
<td>{{ option.label }}:</td>
|
||||
<td>{{ option.value }}</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>Foto-Erlaubnis:</td>
|
||||
<td>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Scopes\InstancedModel;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EventParticipantOption extends InstancedModel
|
||||
{
|
||||
protected $table = 'event_participant_options';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant',
|
||||
'event_id',
|
||||
'event_participant_id',
|
||||
'question_key',
|
||||
'question_label',
|
||||
'value',
|
||||
'value_label',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function participant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EventParticipant::class, 'event_participant_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resources;
|
||||
|
||||
use App\Models\EventParticipantOption;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventParticipantOptionResource extends JsonResource
|
||||
{
|
||||
function __construct(EventParticipantOption $option)
|
||||
{
|
||||
parent::__construct($option);
|
||||
}
|
||||
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'questionKey' => $this->resource->question_key,
|
||||
'questionLabel' => $this->resource->question_label,
|
||||
'value' => $this->resource->value,
|
||||
'valueLabel' => $this->resource->value_label,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user