Dev 4.9.0 #17

Merged
th.guenther merged 6 commits from dev-4.9.0 into main 2026-09-10 19:23:18 +02:00
31 changed files with 1753 additions and 184 deletions
Showing only changes of commit 70a57d10f5 - Show all commits
@@ -12,7 +12,8 @@ class CertificateOfConductionCheckCommand {
public function execute() : CertificateOfConductionCheckResponse { public function execute() : CertificateOfConductionCheckResponse {
$response = new CertificateOfConductionCheckResponse(); $response = new CertificateOfConductionCheckResponse();
$localGroup = str_replace('Stamm ', '', $this->request->participant->localGroup()->first()->name); // Der Stamm ist optional (die Kurzanmeldung erhebt ihn nicht, in der Verwaltung kann er entfernt werden).
$localGroup = str_replace('Stamm ', '', $this->request->participant->localGroup()->first()?->name ?? '');
$apiResponse = Http::acceptJson() $apiResponse = Http::acceptJson()
->asJson() ->asJson()
@@ -0,0 +1,142 @@
<?php
namespace App\Domains\Event\Actions\ShortSignUp;
use App\Domains\Event\Actions\SignUp\SignUpCommand;
use App\Domains\Event\Actions\SignUp\SignUpRequest;
use App\ValueObjects\Age;
/**
* Verkürzte Anmeldung: ergänzt die nicht erhobenen Pflichtangaben um Platzhalter und übergibt an die reguläre
* Anmelde-Action. Dadurch gelten für Kurzanmeldungen dieselben Regeln wie für jede andere Anmeldung
* (Transaktion, laufende Rechnungsnummer, eFZ-Vorbelegung, Snapshot der Teilnahmeoptionen).
*/
class ShortSignUpCommand {
/** Platzhalter für Angaben, die die Kurzanmeldung nicht erhebt. */
private const string PLACEHOLDER = 'Nicht erforderlich';
/**
* Die Postleitzahl bekommt bewusst keinen Text-Platzhalter: aus ihr wird in Teilnehmerliste und CSV-Export
* das Bundesland abgeleitet.
*/
private const string PLACEHOLDER_POSTCODE = '00000';
public function __construct(public ShortSignUpRequest $request) {
}
public function execute() : ShortSignUpResponse {
$response = new ShortSignUpResponse();
// Ohne konfigurierte Teilnahmegruppe gäbe es keinen gültigen `participation_type` -- sauber abbrechen,
// statt in einen Fremdschlüssel-Fehler zu laufen.
$participationType = $this->request->event->firstParticipationTypeSlug();
if ($participationType === null) {
$response->message = 'Für diese Veranstaltung ist noch keine Teilnahmegruppe hinterlegt.';
return $response;
}
$participantAge = new Age($this->request->birthday);
// Bei Minderjährigen ist die Kontaktperson Pflicht; der Client prüft das ebenfalls, verlassen wird sich
// darauf nicht.
if (!$participantAge->isfullAged()
&& ($this->request->contactPerson === null || $this->request->contactEmail === null)) {
$response->message = 'Für Minderjährige werden Name und E-Mail-Adresse einer Kontaktperson benötigt.';
return $response;
}
// Stamm: bei genau einem teilnehmenden Stamm wird dieser gesetzt, bei mehreren muss gewählt werden,
// ohne teilnehmende Stämme bleibt die Zuordnung leer.
$localGroups = $this->request->event->localGroups;
$localGroup = null;
if ($localGroups->count() === 1) {
$localGroup = $localGroups->first();
} elseif ($localGroups->count() > 1) {
$localGroup = $localGroups->firstWhere('id', $this->request->localGroupId);
if ($localGroup === null) {
$response->message = 'Bitte einen Stamm auswählen.';
return $response;
}
}
$eventResource = $this->request->event->toResource();
// Zeitraum, Gruppe und Beitragsstufe stehen bei der Kurzanmeldung fest -- es gibt nichts zu wählen.
$amount = $eventResource->calculateAmount(
$participationType,
'standard',
$this->request->event->start_date,
$this->request->event->end_date,
false
);
$signUpRequest = new SignUpRequest(
event: $this->request->event,
user_id: $this->request->user_id,
firstname: $this->request->firstname,
lastname: $this->request->lastname,
nickname: $this->request->nickname,
participationType: $participationType,
localGroup: $localGroup,
birthday: $this->request->birthday,
address_1: self::PLACEHOLDER,
address_2: null,
postcode: self::PLACEHOLDER_POSTCODE,
city: self::PLACEHOLDER,
email_1: $this->request->email,
phone_1: $this->request->phone,
email_2: $this->request->contactEmail,
phone_2: null,
contact_person: $this->request->contactPerson,
allergies: $this->request->allergies,
intolerances: $this->request->intolerances,
medications: null,
tetanus_vaccination: null,
// Kein Wert der Zuordnung -- der SignUpCommand fällt damit auf "Omnivor" zurück.
eating_habit: '',
swimming_permission: $this->resolveSwimmingPermission($participantAge),
first_aid_permission: $participantAge->isfullAged() ? '-1' : ($this->request->firstAidPermission ?? '-1'),
foto_socialmedia: $this->request->foto_socialmedia,
foto_print: $this->request->foto_print,
foto_webseite: $this->request->foto_webseite,
foto_partner: $this->request->foto_partner,
foto_intern: $this->request->foto_intern,
arrival: $this->request->event->start_date,
departure: $this->request->event->end_date,
arrival_eating: 1,
departure_eating: 2,
notes: null,
amount: $amount,
paymentMethod: $this->request->paymentMethod,
paymentOptions: $this->request->paymentOptions,
participationOptions: $this->request->participationOptions,
addonItems: [],
feeType: 'standard',
siblingReduction: false,
);
$signUpResponse = new SignUpCommand($signUpRequest)->execute();
$response->success = $signUpResponse->success;
$response->participant = $signUpResponse->participant;
$response->message = $signUpResponse->message;
return $response;
}
/**
* Bei Volljährigen und wenn die Veranstaltung die Badeerlaubnis nicht abfragt, entscheidet der SignUpCommand
* (volljährig => erteilt, nicht abgefragt => keine). Nur die getroffene Auswahl Minderjähriger wird
* durchgereicht.
*/
private function resolveSwimmingPermission(Age $participantAge) : ?string
{
if ($participantAge->isfullAged() || !$this->request->event->swimming_permission_required) {
return null;
}
return $this->request->swimmingPermission;
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Domains\Event\Actions\ShortSignUp;
use App\Models\Event;
use DateTime;
/**
* Eingabedaten der verkürzten Anmeldung -- ausschließlich das, was der Kurz-Wizard tatsächlich erhebt.
* Alle übrigen Pflichtangaben des Teilnehmers setzt der Command auf Platzhalter.
*/
class ShortSignUpRequest {
function __construct(
public Event $event,
public ?int $user_id,
public string $firstname,
public string $lastname,
public ?string $nickname,
public DateTime $birthday,
public string $email,
public string $phone,
/**
* Tenant-ID des Stamms. Nur gefüllt, wenn die Veranstaltung mehrere teilnehmende Stämme hat --
* bei genau einem wird dieser gesetzt, ohne zu fragen.
*/
public ?int $localGroupId,
/** Kontaktperson: nur bei Minderjährigen erhoben. */
public ?string $contactPerson,
public ?string $contactEmail,
public ?string $allergies,
public ?string $intolerances,
/** Slug aus `first_aid_permissions`; nur bei Minderjährigen erhoben. */
public ?string $firstAidPermission,
/** Slug aus `swimming_permissions`; nur bei Minderjährigen und nur wenn die Veranstaltung sie abfragt. */
public ?string $swimmingPermission,
public bool $foto_socialmedia,
public bool $foto_print,
public bool $foto_webseite,
public bool $foto_partner,
public bool $foto_intern,
public ?string $paymentMethod,
public array $paymentOptions = [],
/** Antworten auf die Teilnahmeoptionen: [ question_key => value ]. */
public array $participationOptions = [],
) {
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Event\Actions\ShortSignUp;
use App\Models\EventParticipant;
class ShortSignUpResponse {
public bool $success;
public ?EventParticipant $participant;
public ?string $message = null;
public function __construct() {
$this->success = false;
$this->participant = null;
}
}
@@ -4,6 +4,7 @@ namespace App\Domains\Event\Actions\SignUp;
use App\Enumerations\EatingHabit; use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus; use App\Enumerations\EfzStatus;
use App\Enumerations\SwimmingPermission;
use App\EventPaymentModules\EventPaymentModuleRegistry; use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\ValueObjects\Age; use App\ValueObjects\Age;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -59,7 +60,7 @@ class SignUpCommand {
'participation_type' => $this->request->participationType, 'participation_type' => $this->request->participationType,
'fee_type' => $this->request->feeType, 'fee_type' => $this->request->feeType,
'sibling_reduction' => $this->request->siblingReduction, 'sibling_reduction' => $this->request->siblingReduction,
'local_group' => $this->request->localGroup->slug, 'local_group' => $this->request->localGroup?->slug,
'birthday' => $this->request->birthday, 'birthday' => $this->request->birthday,
'address_1' => $this->request->address_1, 'address_1' => $this->request->address_1,
'address_2' => $this->request->address_2, 'address_2' => $this->request->address_2,
@@ -75,9 +76,7 @@ class SignUpCommand {
'medications' => $this->request->medications, 'medications' => $this->request->medications,
'tetanus_vaccination' => $this->request->tetanus_vaccination, 'tetanus_vaccination' => $this->request->tetanus_vaccination,
'eating_habit' => $eatingHabit, 'eating_habit' => $eatingHabit,
'swimming_permission' => ($participantAge->isfullAged() || $this->request->swimming_permission === '-1') 'swimming_permission' => $this->resolveSwimmingPermission($participantAge),
? 'SWIMMING_PERMISSION_ALLOWED'
: $this->request->swimming_permission,
'first_aid_permission' => ($participantAge->isfullAged() || $this->request->first_aid_permission === '-1') 'first_aid_permission' => ($participantAge->isfullAged() || $this->request->first_aid_permission === '-1')
? 'FIRST_AID_PERMISSION_ALLOWED' ? 'FIRST_AID_PERMISSION_ALLOWED'
: $this->request->first_aid_permission, : $this->request->first_aid_permission,
@@ -109,6 +108,26 @@ class SignUpCommand {
return $response; return $response;
} }
/**
* Badeerlaubnis auflösen. Volljährige brauchen keine Erlaubnis. Für Minderjährige gilt die getroffene Auswahl;
* fehlt sie -- weil die Veranstaltung die Badeerlaubnis gar nicht abfragt oder keine Wahl getroffen wurde --,
* wird bewusst "Keine" hinterlegt und nicht etwa stillschweigend eine Erlaubnis angenommen.
*/
private function resolveSwimmingPermission(Age $participantAge): string
{
if ($participantAge->isfullAged()) {
return SwimmingPermission::SWIMMING_PERMISSION_ALLOWED;
}
if (!$this->request->event->swimming_permission_required
|| $this->request->swimming_permission === null
|| $this->request->swimming_permission === '-1') {
return SwimmingPermission::SWIMMING_PERMISSION_DENIED;
}
return $this->request->swimming_permission;
}
/** /**
* Nächste laufende Nummer des Teilis innerhalb der Veranstaltung -- der letzte Block der * Nächste laufende Nummer des Teilis innerhalb der Veranstaltung -- der letzte Block der
* Rechnungsnummer. Die Event-Zeile wird gesperrt, damit gleichzeitige Anmeldungen derselben * Rechnungsnummer. Die Event-Zeile wird gesperrt, damit gleichzeitige Anmeldungen derselben
@@ -15,7 +15,8 @@ class SignUpRequest {
public string $lastname, public string $lastname,
public ?string $nickname, public ?string $nickname,
public string $participationType, public string $participationType,
public Tenant $localGroup, /** Stamm des Teilnehmers; in der Kurzanmeldung nicht erhoben und deshalb optional. */
public ?Tenant $localGroup,
public DateTime $birthday, public DateTime $birthday,
public string $address_1, public string $address_1,
public ?string $address_2, public ?string $address_2,
@@ -22,6 +22,8 @@ class UpdateEventCommand {
$this->request->event->support_flat = $this->request->flatSupport; $this->request->event->support_flat = $this->request->flatSupport;
$this->request->event->send_weekly_report = $this->request->sendWeeklyReports; $this->request->event->send_weekly_report = $this->request->sendWeeklyReports;
$this->request->event->registration_allowed = $this->request->registrationAllowed; $this->request->event->registration_allowed = $this->request->registrationAllowed;
$this->request->event->short_registration = $this->request->shortRegistration;
$this->request->event->swimming_permission_required = $this->request->swimmingPermissionRequired;
$this->request->event->save(); $this->request->event->save();
$this->request->event->resetAllowedEatingHabits(); $this->request->event->resetAllowedEatingHabits();
@@ -17,12 +17,14 @@ class UpdateEventRequest {
public int $alcoholicsAge; public int $alcoholicsAge;
public bool $sendWeeklyReports; public bool $sendWeeklyReports;
public bool $registrationAllowed; public bool $registrationAllowed;
public bool $shortRegistration;
public bool $swimmingPermissionRequired;
public Amount $flatSupport; public Amount $flatSupport;
public Amount $supportPerPerson; public Amount $supportPerPerson;
public array $contributingLocalGroups; public array $contributingLocalGroups;
public array $eatingHabits; public array $eatingHabits;
public function __construct(Event $event, string $eventName, string $eventLocation, string $postalCode, string $email, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $alcoholicsAge, bool $sendWeeklyReports, bool $registrationAllowed, Amount $flatSupport, Amount $supportPerPerson, array $contributingLocalGroups, array $eatingHabits) public function __construct(Event $event, string $eventName, string $eventLocation, string $postalCode, string $email, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $alcoholicsAge, bool $sendWeeklyReports, bool $registrationAllowed, Amount $flatSupport, Amount $supportPerPerson, array $contributingLocalGroups, array $eatingHabits, bool $shortRegistration = false, bool $swimmingPermissionRequired = true)
{ {
$this->event = $event; $this->event = $event;
$this->eventName = $eventName; $this->eventName = $eventName;
@@ -34,6 +36,8 @@ class UpdateEventRequest {
$this->alcoholicsAge = $alcoholicsAge; $this->alcoholicsAge = $alcoholicsAge;
$this->sendWeeklyReports = $sendWeeklyReports; $this->sendWeeklyReports = $sendWeeklyReports;
$this->registrationAllowed = $registrationAllowed; $this->registrationAllowed = $registrationAllowed;
$this->shortRegistration = $shortRegistration;
$this->swimmingPermissionRequired = $swimmingPermissionRequired;
$this->flatSupport = $flatSupport; $this->flatSupport = $flatSupport;
$this->supportPerPerson = $supportPerPerson; $this->supportPerPerson = $supportPerPerson;
$this->contributingLocalGroups = $contributingLocalGroups; $this->contributingLocalGroups = $contributingLocalGroups;
@@ -31,7 +31,8 @@ class UpdateParticipantCommand {
$p->address_2 = $this->request->address_2; $p->address_2 = $this->request->address_2;
$p->postcode = $this->request->postcode; $p->postcode = $this->request->postcode;
$p->city = $this->request->city; $p->city = $this->request->city;
$p->local_group = $this->request->localgroup; // Leerer String würde als Fremdschlüssel scheitern -- kein Stamm heißt null.
$p->local_group = $this->request->localgroup ?: null;
$p->birthday = DateTime::createFromFormat('Y-m-d', $this->request->birthday); $p->birthday = DateTime::createFromFormat('Y-m-d', $this->request->birthday);
$p->email_1 = $this->request->email_1; $p->email_1 = $this->request->email_1;
$p->phone_1 = $this->request->phone_1; $p->phone_1 = $this->request->phone_1;
@@ -14,7 +14,8 @@ class UpdateParticipantRequest {
public ?string $address_2, public ?string $address_2,
public string $postcode, public string $postcode,
public string $city, public string $city,
public string $localgroup, /** Stamm-Slug; leer/null bei Anmeldungen, die keinen Stamm erfasst haben (Kurzanmeldung). */
public ?string $localgroup,
public string $birthday, public string $birthday,
public string $email_1, public string $email_1,
public string $phone_1, public string $phone_1,
@@ -60,7 +60,9 @@ class DetailsController extends CommonController {
$flatSupport, $flatSupport,
$supportPerPerson, $supportPerPerson,
$contributinLocalGroups, $contributinLocalGroups,
$eatingHabits $eatingHabits,
(bool)$request->input('shortRegistration', false),
(bool)$request->input('swimmingPermissionRequired', true),
); );
$eventUpdateCommand = new UpdateEventCommand($eventUpdateRequest); $eventUpdateCommand = new UpdateEventCommand($eventUpdateRequest);
@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Scopes\CommonController;
use App\ValueObjects\Amount;
use Illuminate\Http\JsonResponse;
/**
* Beitrag einer Kurzanmeldung. Teilnahmegruppe, Beitragsstufe und Zeitraum stehen fest, deshalb braucht der
* Endpunkt keine Eingaben -- und der Betrag lässt sich vom Client nicht beeinflussen.
*
* `amountValue` steuert im Frontend, ob der Schritt "Zahlungsart" gezeigt wird.
*/
class ShortCalculateAmountController extends CommonController {
public function __invoke(int $eventId) : JsonResponse {
$event = $this->events->getById($eventId, false);
$participationType = $event->firstParticipationTypeSlug();
$amount = $participationType === null
? new Amount(0, 'Euro')
: $event->toResource()->calculateAmount(
$participationType,
'standard',
$event->start_date,
$event->end_date,
false
);
return response()->json([
'amount' => $amount->toString(),
'amountValue' => $amount->getAmount(),
]);
}
}
@@ -0,0 +1,130 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConductionCheckCommand;
use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConductionCheckRequest;
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpCommand;
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpRequest;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\SwimmingPermission;
use App\Mail\ParticipantParticipationMails\EventSignUpSuccessfullMail;
use App\Providers\DoubleCheckEventRegistrationProvider;
use App\Scopes\CommonController;
use DateTime;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
/**
* Nimmt die verkürzte Anmeldung entgegen. Öffentlich erreichbar, deshalb werden die Eingaben hier -- anders als
* im langen Prozess -- validiert, bevor sie die Action erreichen.
*/
class ShortSignupController extends CommonController {
public function __invoke(int $eventId, Request $request) : JsonResponse {
$event = $this->events->getById($eventId, false);
if (!$event->registration_allowed || new DateTime() > $event->start_date) {
return response()->json(['status' => 'closed'], 403);
}
// Die Kurzanmeldung darf nicht als Abkürzung um den langen Prozess herum benutzt werden.
if (!$event->short_registration) {
return response()->json(['status' => 'closed'], 403);
}
$validator = Validator::make($request->all(), [
'firstname' => 'required|string|max:255',
'lastname' => 'required|string|max:255',
'nickname' => 'nullable|string|max:255',
'birthday' => 'required|date_format:Y-m-d|before:today',
'email' => 'required|email|max:255',
'phone' => 'required|string|max:255',
// Gegen die teilnehmenden Stämme prüft der Command -- hier reicht die Typprüfung.
'localGroup' => 'nullable|integer',
'contactPerson' => 'nullable|string|max:255',
'contactEmail' => 'nullable|email|max:255',
'allergies' => 'nullable|string|max:255',
'intolerances' => 'nullable|string|max:255',
'firstAidPermission' => ['nullable', 'string', 'in:' . implode(',', FirstAidPermission::query()->pluck('slug')->toArray())],
'swimmingPermission' => ['nullable', 'string', 'in:' . implode(',', SwimmingPermission::query()->pluck('slug')->toArray())],
'foto' => 'array',
'participationOptions' => 'array',
'paymentMethod' => 'nullable|string',
'paymentOptions' => 'array',
]);
if ($validator->fails()) {
return response()->json(['status' => 'error', 'message' => $validator->errors()->first()], 422);
}
$birthday = DateTime::createFromFormat('Y-m-d', $request->input('birthday'));
$doubleCheck = new DoubleCheckEventRegistrationProvider(
$event,
$request->input('firstname'),
$request->input('lastname'),
$request->input('email'),
$birthday
);
if ($doubleCheck->isRegistered()) {
return response()->json(['status' => 'exists']);
}
$foto = (array)$request->input('foto', []);
$shortSignUpRequest = new ShortSignUpRequest(
event: $event,
user_id: currentUser()?->id,
firstname: $request->input('firstname'),
lastname: $request->input('lastname'),
nickname: $request->input('nickname'),
birthday: $birthday,
email: $request->input('email'),
phone: $request->input('phone'),
localGroupId: $request->input('localGroup') !== null ? (int)$request->input('localGroup') : null,
contactPerson: $request->input('contactPerson') ?: null,
contactEmail: $request->input('contactEmail') ?: null,
allergies: $request->input('allergies') ?: null,
intolerances: $request->input('intolerances') ?: null,
firstAidPermission: $request->input('firstAidPermission') ?: null,
swimmingPermission: $request->input('swimmingPermission') ?: null,
foto_socialmedia: (bool)($foto['socialmedia'] ?? false),
foto_print: (bool)($foto['print'] ?? false),
foto_webseite: (bool)($foto['webseite'] ?? false),
foto_partner: (bool)($foto['partner'] ?? false),
foto_intern: (bool)($foto['intern'] ?? false),
paymentMethod: $request->input('paymentMethod') ?: null,
paymentOptions: (array)$request->input('paymentOptions', []),
participationOptions: (array)$request->input('participationOptions', []),
);
$shortSignUpResponse = new ShortSignUpCommand($shortSignUpRequest)->execute();
if (!$shortSignUpResponse->success) {
return response()->json(['status' => 'error', 'message' => $shortSignUpResponse->message], 422);
}
$participant = $shortSignUpResponse->participant;
$cocResponse = new CertificateOfConductionCheckCommand(
new CertificateOfConductionCheckRequest($participant)
)->execute();
$participant->efz_status = $cocResponse->status;
$participant->save();
Mail::to($participant->email_1)->send(new EventSignUpSuccessfullMail(participant: $participant));
if ($participant->email_2 !== null) {
Mail::to($participant->email_2)->send(new EventSignUpSuccessfullMail(participant: $participant));
}
return response()->json([
'participant' => $participant->toResource()->toArray($request),
'status' => 'success',
]);
}
}
+5
View File
@@ -16,6 +16,8 @@ use App\Domains\Event\Controllers\ParticipantReSignOnController;
use App\Domains\Event\Controllers\ParticipantSignOffController; use App\Domains\Event\Controllers\ParticipantSignOffController;
use App\Domains\Event\Controllers\ParticipantUpdateController; use App\Domains\Event\Controllers\ParticipantUpdateController;
use App\Domains\Event\Controllers\PaymentReminderController; use App\Domains\Event\Controllers\PaymentReminderController;
use App\Domains\Event\Controllers\ShortCalculateAmountController;
use App\Domains\Event\Controllers\ShortSignupController;
use App\Domains\Event\Controllers\SignupController; use App\Domains\Event\Controllers\SignupController;
use App\Middleware\IdentifyTenant; use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -27,6 +29,9 @@ Route::prefix('api/v1')
Route::post('{eventId}/calculate-amount', [SignupController::class, 'calculateAmount']); Route::post('{eventId}/calculate-amount', [SignupController::class, 'calculateAmount']);
Route::post('{eventId}/signup', [SignupController::class, 'signUp']); Route::post('{eventId}/signup', [SignupController::class, 'signUp']);
Route::post('{eventId}/short-calculate-amount', ShortCalculateAmountController::class);
Route::post('{eventId}/short-signup', ShortSignupController::class);
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
Route::post('/create', [CreateController::class, 'doCreate']); Route::post('/create', [CreateController::class, 'doCreate']);
@@ -35,6 +35,8 @@ const emit = defineEmits(['close'])
eatingHabits: eatingHabits.value, eatingHabits: eatingHabits.value,
sendWeeklyReports: props.event.sendWeeklyReports, sendWeeklyReports: props.event.sendWeeklyReports,
registrationAllowed: props.event.registrationAllowed, registrationAllowed: props.event.registrationAllowed,
shortRegistration: props.event.shortRegistration,
swimmingPermissionRequired: props.event.swimmingPermissionRequired,
flatSupport: props.event.flatSupportEdit, flatSupport: props.event.flatSupportEdit,
supportPerson: props.event.supportPersonIndex, supportPerson: props.event.supportPersonIndex,
}) })
@@ -64,6 +66,8 @@ const emit = defineEmits(['close'])
alcoholicsAge: formData.alcoholicsAge, alcoholicsAge: formData.alcoholicsAge,
sendWeeklyReports: formData.sendWeeklyReports, sendWeeklyReports: formData.sendWeeklyReports,
registrationAllowed: formData.registrationAllowed, registrationAllowed: formData.registrationAllowed,
shortRegistration: formData.shortRegistration,
swimmingPermissionRequired: formData.swimmingPermissionRequired,
flatSupport: formData.flatSupport, flatSupport: formData.flatSupport,
supportPerson: formData.supportPerson, supportPerson: formData.supportPerson,
contributingLocalGroups: contributingLocalGroups.value, contributingLocalGroups: contributingLocalGroups.value,
@@ -172,6 +176,27 @@ const emit = defineEmits(['close'])
<label for="registrationAllowed">Veranstaltung ist für Anmeldungen geöffnet</label> <label for="registrationAllowed">Veranstaltung ist für Anmeldungen geöffnet</label>
</td> </td>
</tr> </tr>
<tr>
<td colspan="2">
<input type="checkbox" v-model="formData.shortRegistration" id="shortRegistration" />
<label for="shortRegistration">Verkürzte Anmeldung verwenden</label><br />
<span style="font-size: 0.8rem; color: #6b7280;">
Fragt nur die nötigsten Daten ab (Name, Kontakt, Allergien, Foto-Erlaubnis).
Nicht erhobene Angaben wie Anschrift und An-/Abreise werden automatisch gefüllt.
</span>
</td>
</tr>
<tr>
<td colspan="2">
<input type="checkbox" v-model="formData.swimmingPermissionRequired" id="swimmingPermissionRequired" />
<label for="swimmingPermissionRequired">Badeerlaubnis abfragen</label><br />
<span style="font-size: 0.8rem; color: #6b7280;">
Ist die Abfrage deaktiviert, wird für Minderjährige Keine Badeerlaubnis" hinterlegt.
</span>
</td>
</tr>
</table> </table>
@@ -233,6 +233,7 @@ function saveParticipant() {
<td> <td>
<span v-if="!staticProps.editMode">{{ props.participant.localgroup }}</span> <span v-if="!staticProps.editMode">{{ props.participant.localgroup }}</span>
<select v-else v-model="form.localgroup"> <select v-else v-model="form.localgroup">
<option value="">Kein Stamm</option>
<option v-for="group in staticProps.event.contributingLocalGroups" :key="group.id" :value="group.slug">{{ group.name }}</option> <option v-for="group in staticProps.event.contributingLocalGroups" :key="group.id" :value="group.slug">{{ group.name }}</option>
</select> </select>
</td> </td>
@@ -0,0 +1,138 @@
<script setup>
import {computed} from 'vue'
import {useShortSignupForm} from './composables/useShortSignupForm.js'
import {
SHORT_STEP_PERSON,
SHORT_STEP_PARTICIPATION_OPTIONS,
SHORT_STEP_PHOTO,
SHORT_STEP_PAYMENT,
SHORT_STEP_SUMMARY,
stepVisible,
} from './composables/shortStepFlow.js'
import ShortStepPerson from './steps/ShortStepPerson.vue'
import ShortStepSummary from './steps/ShortStepSummary.vue'
import StepParticipationOptions from '../SignUpForm/steps/StepParticipationOptions.vue'
import StepPhotoPermissions from '../SignUpForm/steps/StepPhotoPermissions.vue'
import StepPaymentMethod from '../SignUpForm/steps/StepPaymentMethod.vue'
import SubmitSuccess from '../SignUpForm/after-submit/SubmitSuccess.vue'
import SubmitAlreadyExists from '../SignUpForm/after-submit/SubmitAlreadyExists.vue'
const props = defineProps({
event: Object,
participantData: Object,
})
const {
currentStep, goToStep, goNext, goBack, formData, isMinor, localGroups, needsLocalGroup,
submit, submitting, submitResult, submitError,
summaryLoading, summaryAmount, summaryAmountValue,
} = useShortSignupForm(props.event, props.participantData)
// Die aus dem langen Prozess übernommenen Schritte emittieren ihre Zielnummer aus jenem Flow mit. Hier zählt
// nur, dass "weiter" bzw. "zurück" gedrückt wurde -- die Reihenfolge kennt dieser Host.
const onNext = () => goNext()
const onBack = () => goBack()
const allSteps = [
{step: SHORT_STEP_PERSON, label: 'Person'},
{step: SHORT_STEP_PARTICIPATION_OPTIONS, label: 'Teilnahmeoptionen'},
{step: SHORT_STEP_PHOTO, label: 'Fotoerlaubnis'},
{step: SHORT_STEP_PAYMENT, label: 'Zahlungsart'},
{step: SHORT_STEP_SUMMARY, label: 'Zusammenfassung'},
]
// Nur die tatsächlich vorkommenden Schritte anzeigen, sonst zeigt die Leiste Schritte, die nie erscheinen.
const steps = computed(() =>
allSteps.filter(s => stepVisible(props.event, s.step, summaryAmountValue.value))
)
const currentIndex = computed(() => steps.value.findIndex(s => s.step === currentStep.value))
</script>
<template>
<div>
<!-- Nach Submit -->
<SubmitSuccess
v-if="submitResult?.status === 'success'"
:participant="submitResult?.participant"
:event="event"
/>
<SubmitAlreadyExists v-else-if="submitResult?.status === 'exists'" :event="event" />
<template v-else>
<!-- Fortschrittsleiste -->
<div class="signup-progress">
<div class="signup-progress-pills">
<template v-for="(s, index) in steps" :key="s.step">
<div v-if="index > 0" class="signup-progress-separator"></div>
<div
class="signup-pill"
:class="{
'signup-pill--active': currentStep === s.step,
'signup-pill--done': currentStep > s.step,
'signup-pill--upcoming': currentStep < s.step,
}"
@click="currentStep > s.step ? goToStep(s.step) : null"
>
<span v-if="currentStep > s.step" class="signup-pill__check"></span>
{{ s.label }}
</div>
</template>
</div>
<div class="signup-progress-bar">
<div
class="signup-progress-bar__fill"
:style="{ width: (steps.length > 1 ? (currentIndex / (steps.length - 1)) * 100 : 100) + '%' }"
></div>
</div>
<div class="signup-progress-mobile">
Schritt {{ currentIndex + 1 }} von {{ steps.length }}:
<strong>{{ steps.find(s => s.step === currentStep)?.label }}</strong>
</div>
</div>
<!-- Steps -->
<form @submit.prevent="submit">
<ShortStepPerson
v-if="currentStep === SHORT_STEP_PERSON"
:formData="formData" :event="event" :isMinor="isMinor"
:localGroups="localGroups" :needsLocalGroup="needsLocalGroup"
@next="onNext"
/>
<StepParticipationOptions
v-if="currentStep === SHORT_STEP_PARTICIPATION_OPTIONS"
:formData="formData" :event="event"
@next="onNext" @back="onBack"
/>
<StepPhotoPermissions
v-if="currentStep === SHORT_STEP_PHOTO"
:formData="formData" :event="event"
@next="onNext" @back="onBack"
/>
<StepPaymentMethod
v-if="currentStep === SHORT_STEP_PAYMENT"
:formData="formData" :event="event"
@next="onNext" @back="onBack"
/>
<ShortStepSummary
v-if="currentStep === SHORT_STEP_SUMMARY"
:formData="formData"
:event="event"
:isMinor="isMinor"
:summaryAmount="summaryAmount"
:summaryAmountValue="summaryAmountValue"
:summaryLoading="summaryLoading"
:submitting="submitting"
:submitError="submitError"
@back="onBack"
@submit="submit"
/>
</form>
</template>
</div>
</template>
<!-- Unscoped und mit dem langen Anmeldeprozess geteilt. -->
<style src="../SignUpForm/signupForm.css"></style>
@@ -0,0 +1,37 @@
// Schrittreihenfolge der Kurzanmeldung. Zwei Schritte sind optional: die Teilnahmeoptionen entfallen, wenn für
// die Veranstaltung keine definiert sind, die Zahlungsart entfällt bei Betrag 0 oder wenn es ohnehin nur eine
// Zahlungsart gibt (die wird dann vorausgewählt).
export const SHORT_STEP_PERSON = 1
export const SHORT_STEP_PARTICIPATION_OPTIONS = 2
export const SHORT_STEP_PHOTO = 3
export const SHORT_STEP_PAYMENT = 4
export const SHORT_STEP_SUMMARY = 5
export function stepVisible(event, step, amountValue) {
if (step === SHORT_STEP_PARTICIPATION_OPTIONS) {
return (event.participationOptions?.length ?? 0) > 0
}
if (step === SHORT_STEP_PAYMENT) {
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
return amountValue > 0 && activeMethods.length > 1
}
return true
}
export function nextVisibleFrom(event, step, amountValue) {
let candidate = step + 1
while (candidate < SHORT_STEP_SUMMARY && !stepVisible(event, candidate, amountValue)) {
candidate++
}
return candidate
}
export function prevVisibleFrom(event, step, amountValue) {
let candidate = step - 1
while (candidate > SHORT_STEP_PERSON && !stepVisible(event, candidate, amountValue)) {
candidate--
}
return candidate
}
@@ -0,0 +1,155 @@
import {computed, reactive, ref} from 'vue'
import axios from 'axios'
import {differenceInYears, parseISO} from 'date-fns'
import {SHORT_STEP_PERSON, nextVisibleFrom, prevVisibleFrom} from './shortStepFlow.js'
export function useShortSignupForm(event, participantData) {
const currentStep = ref(SHORT_STEP_PERSON)
const submitting = ref(false)
const summaryLoading = ref(false)
const submitResult = ref(null) // null | { status: 'success'|'exists', participant: {} }
const submitError = ref('')
// Gibt es nur eine Zahlungsart, wird sie vorausgewählt und der Zahlungsschritt entfällt.
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
// Bei genau einem teilnehmenden Stamm wird dieser gesetzt, ohne danach zu fragen; erst ab zwei
// Stämmen erscheint das Auswahlfeld.
const localGroups = event.contributingLocalGroups ?? []
const needsLocalGroup = localGroups.length > 1
const formData = reactive({
userId: participantData.id,
vorname: participantData.firstname ?? '',
nachname: participantData.lastname ?? '',
pfadiname: participantData.nickname ?? '',
geburtsdatum: participantData.birthday ?? '',
email_1: participantData.email ?? '',
telefon_1: participantData.phone ?? '',
localGroup: localGroups.length === 1 ? localGroups[0].id : (participantData.localGroup ?? '-1'),
ansprechpartner: '',
email_2: '',
allergien: participantData.allergies ?? '',
intolerances: participantData.intolerances ?? '',
first_aid: '-1',
badeerlaubnis: '-1',
// Antworten auf die event-spezifischen Teilnahmeoptionen: { [question.key]: option.value }
participationOptions: {},
foto: {socialmedia: false, print: false, webseite: false, partner: false, intern: false},
paymentMethod: activeMethods.length === 1 ? activeMethods[0].slug : null,
paymentOptions: {},
summary_information_correct: false,
summary_accept_terms: false,
legal_accepted: false,
payment: false,
})
// Minderjährigkeit steuert Kontaktperson, Erweiterte Erste Hilfe und Badeerlaubnis. Die Grenze von 18 Jahren
// entspricht Age::isfullAged() im Backend.
const isMinor = computed(() => {
if (!formData.geburtsdatum) {
return false
}
return differenceInYears(new Date(), parseISO(formData.geburtsdatum)) < 18
})
const summaryAmount = ref('')
const summaryAmountValue = ref(0)
const amountLoaded = ref(false)
// Der Betrag steht für die ganze Anmeldung fest (feste Teilnahmegruppe, fester Zeitraum) und wird deshalb
// genau einmal geholt. Er entscheidet, ob der Zahlungsschritt überhaupt vorkommt.
const ensureAmount = async () => {
if (amountLoaded.value) {
return
}
summaryLoading.value = true
try {
const res = await axios.post('/api/v1/event/' + event.id + '/short-calculate-amount')
summaryAmount.value = res.data.amount
summaryAmountValue.value = Number(res.data.amountValue ?? 0)
amountLoaded.value = true
if (summaryAmountValue.value <= 0) {
formData.paymentMethod = null
formData.paymentOptions = {}
}
} finally {
summaryLoading.value = false
}
}
const goToStep = (step) => {
currentStep.value = step
}
const goNext = async () => {
await ensureAmount()
goToStep(nextVisibleFrom(event, currentStep.value, summaryAmountValue.value))
}
const goBack = async () => {
await ensureAmount()
goToStep(prevVisibleFrom(event, currentStep.value, summaryAmountValue.value))
}
const submit = async () => {
if (!formData.summary_information_correct || !formData.summary_accept_terms || !formData.legal_accepted) {
return
}
submitting.value = true
submitError.value = ''
try {
const res = await axios.post('/api/v1/event/' + event.id + '/short-signup', {
firstname: formData.vorname,
lastname: formData.nachname,
nickname: formData.pfadiname || null,
birthday: formData.geburtsdatum,
email: formData.email_1,
phone: formData.telefon_1,
localGroup: formData.localGroup !== '-1' ? formData.localGroup : null,
contactPerson: isMinor.value ? formData.ansprechpartner : null,
contactEmail: isMinor.value ? formData.email_2 : null,
allergies: formData.allergien || null,
intolerances: formData.intolerances || null,
firstAidPermission: isMinor.value && formData.first_aid !== '-1' ? formData.first_aid : null,
swimmingPermission: isMinor.value && formData.badeerlaubnis !== '-1' ? formData.badeerlaubnis : null,
foto: formData.foto,
participationOptions: formData.participationOptions,
paymentMethod: formData.paymentMethod,
paymentOptions: formData.paymentOptions,
})
submitResult.value = {
status: res.data.status,
participant: res.data.participant,
}
} catch (error) {
submitError.value = error.response?.data?.message
?? 'Die Anmeldung konnte nicht gespeichert werden. Bitte versuche es später erneut.'
} finally {
submitting.value = false
}
}
return {
currentStep,
goToStep,
goNext,
goBack,
formData,
isMinor,
localGroups,
needsLocalGroup,
submit,
submitting,
submitResult,
submitError,
summaryLoading,
summaryAmount,
summaryAmountValue,
ensureAmount,
}
}
@@ -0,0 +1,218 @@
<script setup>
import ErrorText from "../../../../../../Views/Components/ErrorText.vue";
import {reactive} from "vue";
const props = defineProps({
formData: Object,
event: Object,
isMinor: Boolean,
localGroups: {type: Array, default: () => []},
needsLocalGroup: Boolean,
})
const emit = defineEmits(['next'])
const errors = reactive({
vorname: '',
nachname: '',
geburtsdatum: '',
localGroup: '',
email_1: '',
telefon_1: '',
ansprechpartner: '',
email_2: '',
first_aid: '',
badeerlaubnis: '',
})
// Nur sichtbare Felder werden geprüft -- Kontaktperson und Erlaubnisse gibt es bei Volljährigen nicht.
const next = () => {
Object.keys(errors).forEach(key => errors[key] = '')
let hasError = false
if (!props.formData.vorname) {
errors.vorname = 'Bitte einen Vornamen angeben.'
hasError = true
}
if (!props.formData.nachname) {
errors.nachname = 'Bitte einen Nachnamen angeben.'
hasError = true
}
if (!props.formData.geburtsdatum) {
errors.geburtsdatum = 'Bitte ein Geburtsdatum angeben.'
hasError = true
}
if (props.needsLocalGroup && (!props.formData.localGroup || props.formData.localGroup === '-1')) {
errors.localGroup = 'Bitte einen Stamm auswählen.'
hasError = true
}
if (!props.formData.email_1) {
errors.email_1 = 'Bitte eine E-Mail-Adresse angeben.'
hasError = true
}
if (!props.formData.telefon_1) {
errors.telefon_1 = 'Bitte eine Telefonnummer angeben.'
hasError = true
}
if (props.isMinor) {
if (!props.formData.ansprechpartner) {
errors.ansprechpartner = 'Bitte eine Kontaktperson angeben.'
hasError = true
}
if (!props.formData.email_2) {
errors.email_2 = 'Bitte eine E-Mail-Adresse der Kontaktperson angeben.'
hasError = true
}
if (props.formData.first_aid === '-1') {
errors.first_aid = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung.'
hasError = true
}
if (props.event.swimmingPermissionRequired && props.formData.badeerlaubnis === '-1') {
errors.badeerlaubnis = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung.'
hasError = true
}
}
if (hasError) {
return
}
emit('next')
}
</script>
<template>
<div>
<h3>Angaben zur Person</h3>
<table class="form-table">
<tr>
<td>Vorname:</td>
<td>
<input type="text" v-model="formData.vorname" />
<ErrorText :message="errors.vorname" />
</td>
</tr>
<tr>
<td>Pfadiname:</td>
<td>
<input type="text" v-model="formData.pfadiname" />
</td>
</tr>
<tr>
<td>Nachname:</td>
<td>
<input type="text" v-model="formData.nachname" />
<ErrorText :message="errors.nachname" />
</td>
</tr>
<tr>
<td>Geburtsdatum:</td>
<td>
<input type="date" v-model="formData.geburtsdatum" />
<ErrorText :message="errors.geburtsdatum" />
</td>
</tr>
<tr v-if="needsLocalGroup">
<td>Stamm:</td>
<td>
<select v-model="formData.localGroup">
<option value="-1">Bitte wählen</option>
<option v-for="group in localGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
<ErrorText :message="errors.localGroup" />
</td>
</tr>
<tr>
<td>E-Mail:</td>
<td>
<input type="email" v-model="formData.email_1" />
<ErrorText :message="errors.email_1" />
</td>
</tr>
<tr>
<td>Telefon:</td>
<td>
<input type="text" v-model="formData.telefon_1" />
<ErrorText :message="errors.telefon_1" />
</td>
</tr>
<template v-if="isMinor">
<tr>
<td>Kontaktperson (Nachname, Vorname):</td>
<td>
<input type="text" v-model="formData.ansprechpartner" />
<ErrorText :message="errors.ansprechpartner" />
</td>
</tr>
<tr>
<td>E-Mail der Kontaktperson:</td>
<td>
<input type="email" v-model="formData.email_2" />
<ErrorText :message="errors.email_2" />
</td>
</tr>
</template>
<tr>
<td>Allergien:</td>
<td>
<input type="text" v-model="formData.allergien" />
</td>
</tr>
<tr>
<td>Unverträglichkeiten:</td>
<td>
<input type="text" v-model="formData.intolerances" />
</td>
</tr>
<template v-if="isMinor">
<tr>
<td>Erweiterte Erste Hilfe erlaubt:</td>
<td>
<select v-model="formData.first_aid">
<option value="-1">Bitte wählen</option>
<option
v-for="firstAidPermission in event.firstAidPermissions"
:key="firstAidPermission.slug"
:value="firstAidPermission.slug">{{ firstAidPermission.name }}</option>
</select><br />
<span style="font-size: 0.8rem; color: #6b7280;">
Nicht dringend-notwendige Erste-Hilfe-Maßnahmen, beinhaltet das Entfernen von Zecken und Splittern sowie das Kleben von Pflastern.
</span>
<ErrorText :message="errors.first_aid" />
</td>
</tr>
<tr v-if="event.swimmingPermissionRequired">
<td>Badeerlaubnis:</td>
<td>
<select v-model="formData.badeerlaubnis">
<option value="-1">Bitte wählen</option>
<option
v-for="swimmingPermission in event.swimmingPermissions"
:key="swimmingPermission.slug"
:value="swimmingPermission.slug">{{ swimmingPermission.name }}</option>
</select>
<ErrorText :message="errors.badeerlaubnis" />
</td>
</tr>
</template>
<tr>
<td colspan="2" class="btn-row">
<button type="button" class="btn-primary" @click="next">Weiter </button>
</td>
</tr>
</table>
</div>
</template>
@@ -0,0 +1,187 @@
<script setup>
import {computed} from "vue";
const PAYMENT_ACCOUNT_TRANSACTION = 'PAYMENT_ACCOUNT_TRANSACTION'
const props = defineProps({
formData: Object,
event: Object,
isMinor: Boolean,
summaryAmount: String,
summaryAmountValue: {type: Number, default: 0},
summaryLoading: Boolean,
submitting: Boolean,
submitError: {type: String, default: ''},
})
const emit = defineEmits(['back', 'submit'])
const selectedMethod = computed(() =>
(props.event.paymentMethods ?? []).find(m => m.slug === props.formData.paymentMethod) ?? null
)
// Die Überweisungs-Bestätigung wird nur bei Banküberweisung verlangt.
const requiresPaymentConfirmation = computed(() =>
props.formData.paymentMethod === PAYMENT_ACCOUNT_TRANSACTION
)
// Bestätigungstext ist ein admin-konfigurierbarer Freitext ({amount}-Platzhalter), sonst Default.
const paymentConfirmationText = computed(() => {
const configured = selectedMethod.value?.configuration?.summary_confirmation_text
const template = (configured && String(configured).trim() !== '')
? configured
: 'Ich bestätige, den Betrag von {amount} zu überweisen.'
return template.replaceAll('{amount}', props.summaryAmount ?? '')
})
// Gewählte Teilnahmeoptionen für die Zusammenfassung (Label statt Wert 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.title || question.label, value: option?.label ?? ''}
}).filter(entry => entry.value !== '')
)
// Der Stamm steht entweder fest (genau ein teilnehmender Stamm) oder wurde auf Seite 1 gewählt.
const localGroupName = computed(() =>
(props.event.contributingLocalGroups ?? []).find(g => g.id === props.formData.localGroup)?.name ?? null
)
const permissionName = (list, slug) =>
(list ?? []).find(entry => entry.slug === slug)?.name ?? '—'
const canSubmit = computed(() =>
props.formData.summary_information_correct
&& props.formData.summary_accept_terms
&& props.formData.legal_accepted
&& (!requiresPaymentConfirmation.value || props.formData.payment)
&& !props.submitting
)
</script>
<template>
<div>
<h3>Zusammenfassung</h3>
<div v-if="summaryLoading" style="color: #6b7280; padding: 20px 0;">Wird geladen</div>
<div v-else>
<table class="form-table" style="margin-bottom: 20px;">
<tr>
<td>Dein Name:</td>
<td>{{ formData.vorname }} {{ formData.nachname }}</td>
</tr>
<tr v-if="formData.pfadiname">
<td>Pfadiname:</td>
<td>{{ formData.pfadiname }}</td>
</tr>
<tr>
<td>Deine E-Mail:</td>
<td>{{ formData.email_1 }}</td>
</tr>
<tr>
<td>Deine Telefonnummer:</td>
<td>{{ formData.telefon_1 }}</td>
</tr>
<tr v-if="localGroupName">
<td>Stamm:</td>
<td>{{ localGroupName }}</td>
</tr>
<template v-if="isMinor">
<tr>
<td>Kontaktperson:</td>
<td>{{ formData.ansprechpartner }}</td>
</tr>
<tr>
<td>E-Mail der Kontaktperson:</td>
<td>{{ formData.email_2 }}</td>
</tr>
<tr>
<td>Erweiterte Erste Hilfe:</td>
<td>{{ permissionName(event.firstAidPermissions, formData.first_aid) }}</td>
</tr>
<tr v-if="event.swimmingPermissionRequired">
<td>Badeerlaubnis:</td>
<td>{{ permissionName(event.swimmingPermissions, formData.badeerlaubnis) }}</td>
</tr>
</template>
<tr>
<td>Allergien:</td>
<td>{{ formData.allergien || 'Keine Angabe' }}</td>
</tr>
<tr>
<td>Unverträglichkeiten:</td>
<td>{{ formData.intolerances || 'Keine Angabe' }}</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>
<strong>Social Media:</strong> {{ formData.foto.socialmedia ? 'Ja' : 'Nein' }},
<strong>Printmedien:</strong> {{ formData.foto.print ? 'Ja' : 'Nein' }},
<strong>Webseite:</strong> {{ formData.foto.webseite ? 'Ja' : 'Nein' }},
<strong>Partnerorganisationen:</strong> {{ formData.foto.partner ? 'Ja' : 'Nein' }},
<strong>Interne Zwecke:</strong> {{ formData.foto.intern ? 'Ja' : 'Nein' }}
</td>
</tr>
<tr><td>Veranstaltung:</td><td><strong>{{ event.name }}</strong></td></tr>
<tr><td>Zeitraum:</td><td>{{ event.eventBegin }} {{ event.eventEnd }}</td></tr>
</table>
<h4 style="margin: 0 0 8px 0;">Kostenübersicht</h4>
<table class="form-table cost-table" style="margin-bottom: 20px;">
<tr class="cost-total">
<td><strong>Teilnahmebeitrag:</strong></td>
<td><strong>{{ summaryAmount }}</strong></td>
</tr>
</table>
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px;">
<label style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.summary_information_correct" />
Ich bestätige, dass alle Angaben korrekt sind.
</label>
<label style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.summary_accept_terms" />
Ich akzeptiere die Teilnahmebedingungen.
</label>
<label style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.legal_accepted" />
Ich stimme der Datenschutzerklärung zu.
</label>
<label v-if="requiresPaymentConfirmation" style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.payment" />
{{ paymentConfirmationText }}
</label>
</div>
<p v-if="submitError" style="color: #991b1b; font-weight: 600;">{{ submitError }}</p>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back')"> Zurück</button>
<button
type="submit"
class="btn-primary"
:disabled="!canSubmit"
style="background: #059669;"
>
{{ submitting ? 'Wird gesendet…' : 'Jetzt anmelden ✓' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.cost-table .cost-total td {
border-top: 1px solid #d1d5db;
padding-top: 8px;
}
</style>
@@ -122,170 +122,5 @@ const steps = [
</div> </div>
</template> </template>
<style> <!-- Unscoped und mit der Kurzanmeldung geteilt (ShortSignupForm.vue bindet dieselbe Datei ein). -->
/* ─── Progress (Step-Pills) ─── */ <style src="./signupForm.css"></style>
.signup-progress {
margin-bottom: 28px;
}
.signup-progress-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.signup-progress-separator {
flex-shrink: 0;
width: 16px;
height: 2px;
background: #e5e7eb;
border-radius: 1px;
}
.signup-pill {
padding: 5px 14px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
border: 2px solid;
cursor: default;
}
.signup-pill__check { margin-right: 4px; }
.signup-pill--active {
border-color: #2563eb;
background: #2563eb;
color: white;
}
.signup-pill--done {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
cursor: pointer;
}
.signup-pill--upcoming {
border-color: #e5e7eb;
background: #f9fafb;
color: #9ca3af;
}
.signup-progress-bar {
margin-top: 10px;
height: 3px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.signup-progress-bar__fill {
height: 100%;
background: linear-gradient(90deg, #2563eb, #3b82f6);
border-radius: 2px;
transition: width 0.3s ease;
}
.signup-progress-mobile {
display: none;
margin-top: 8px;
font-size: 0.9rem;
color: #374151;
}
/* ─── Form-Table ─── */
.form-table { width: 100%; border-collapse: collapse; }
.form-table td { padding: 8px 12px 8px 0; vertical-align: top; }
.form-table td:first-child { width: 220px; color: #374151; font-weight: 500; }
.form-table input[type="text"],
.form-table input[type="date"],
.form-table input[type="email"],
.form-table input[type="number"],
.form-table select,
.form-table textarea {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
}
.btn-row { display: flex; gap: 10px; padding-top: 16px; flex-wrap: wrap; }
.btn-primary {
padding: 8px 20px;
background: #2563eb;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
padding: 8px 20px;
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
/* ─── Tablet ─── */
@media (max-width: 1023px) {
.form-table td:first-child {
width: 160px;
}
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
/* Pills auf Mobile: kompakter, Trennstriche ausblenden */
.signup-progress-pills {
display: none;
}
.signup-progress-mobile {
display: block;
}
/* Form-Table: Label oberhalb des Feldes */
.form-table,
.form-table tbody,
.form-table tr {
display: block;
width: 100%;
}
.form-table td {
display: block;
width: 100% !important;
padding: 4px 0;
}
.form-table td:first-child {
width: 100% !important;
font-weight: 600;
color: #374151;
padding-top: 10px;
}
.form-table td[colspan] {
width: 100% !important;
}
.btn-row {
flex-direction: column-reverse;
gap: 8px;
}
.btn-primary,
.btn-secondary {
width: 100%;
padding: 12px 20px;
}
}
</style>
@@ -0,0 +1,167 @@
/* Geteilte Styles der Anmelde-Wizards (langer Prozess und Kurzanmeldung). */
/* ─── Progress (Step-Pills) ─── */
.signup-progress {
margin-bottom: 28px;
}
.signup-progress-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.signup-progress-separator {
flex-shrink: 0;
width: 16px;
height: 2px;
background: #e5e7eb;
border-radius: 1px;
}
.signup-pill {
padding: 5px 14px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
border: 2px solid;
cursor: default;
}
.signup-pill__check { margin-right: 4px; }
.signup-pill--active {
border-color: #2563eb;
background: #2563eb;
color: white;
}
.signup-pill--done {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
cursor: pointer;
}
.signup-pill--upcoming {
border-color: #e5e7eb;
background: #f9fafb;
color: #9ca3af;
}
.signup-progress-bar {
margin-top: 10px;
height: 3px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.signup-progress-bar__fill {
height: 100%;
background: linear-gradient(90deg, #2563eb, #3b82f6);
border-radius: 2px;
transition: width 0.3s ease;
}
.signup-progress-mobile {
display: none;
margin-top: 8px;
font-size: 0.9rem;
color: #374151;
}
/* ─── Form-Table ─── */
.form-table { width: 100%; border-collapse: collapse; }
.form-table td { padding: 8px 12px 8px 0; vertical-align: top; }
.form-table td:first-child { width: 220px; color: #374151; font-weight: 500; }
.form-table input[type="text"],
.form-table input[type="date"],
.form-table input[type="email"],
.form-table input[type="number"],
.form-table select,
.form-table textarea {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
}
.btn-row { display: flex; gap: 10px; padding-top: 16px; flex-wrap: wrap; }
.btn-primary {
padding: 8px 20px;
background: #2563eb;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
padding: 8px 20px;
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
/* ─── Tablet ─── */
@media (max-width: 1023px) {
.form-table td:first-child {
width: 160px;
}
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
/* Pills auf Mobile: kompakter, Trennstriche ausblenden */
.signup-progress-pills {
display: none;
}
.signup-progress-mobile {
display: block;
}
/* Form-Table: Label oberhalb des Feldes */
.form-table,
.form-table tbody,
.form-table tr {
display: block;
width: 100%;
}
.form-table td {
display: block;
width: 100% !important;
padding: 4px 0;
}
.form-table td:first-child {
width: 100% !important;
font-weight: 600;
color: #374151;
padding-top: 10px;
}
.form-table td[colspan] {
width: 100% !important;
}
.btn-row {
flex-direction: column-reverse;
gap: 8px;
}
.btn-primary,
.btn-secondary {
width: 100%;
padding: 12px 20px;
}
}
@@ -42,7 +42,7 @@ const next = () => {
hasError = true hasError = true
} }
if (props.formData.badeerlaubnis === '-1') { if (props.event.swimmingPermissionRequired && props.formData.badeerlaubnis === '-1') {
errors.badeerlaubnis = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung' errors.badeerlaubnis = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung'
hasError = true hasError = true
} }
@@ -86,7 +86,7 @@ const next = () => {
<ErrorText :message="errors.email_2" /> <ErrorText :message="errors.email_2" />
</td> </td>
</tr> </tr>
<tr> <tr v-if="event.swimmingPermissionRequired">
<td>Badeerlaubnis:</td> <td>Badeerlaubnis:</td>
<td> <td>
<select v-model="formData.badeerlaubnis"> <select v-model="formData.badeerlaubnis">
+7 -1
View File
@@ -3,6 +3,7 @@
import AppLayout from "../../../../resources/js/layouts/AppLayout.vue"; import AppLayout from "../../../../resources/js/layouts/AppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue"; import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import SignupForm from './Partials/SignUpForm/SignupForm.vue' import SignupForm from './Partials/SignUpForm/SignupForm.vue'
import ShortSignupForm from './Partials/ShortSignUpForm/ShortSignupForm.vue'
import FullScreenModal from "../../../Views/Components/FullScreenModal.vue"; import FullScreenModal from "../../../Views/Components/FullScreenModal.vue";
import AvailableEvents from "./Partials/AvailableEvents.vue"; import AvailableEvents from "./Partials/AvailableEvents.vue";
import {ref} from "vue"; import {ref} from "vue";
@@ -98,8 +99,13 @@ function close() {
<hr class="signup-divider" /> <hr class="signup-divider" />
<div class="signup-body"> <div class="signup-body">
<ShortSignupForm
v-if="props.event.registrationAllowed && props.event.shortRegistration"
:event="props.event"
:participantData="props.participantData ?? {}"
/>
<SignupForm <SignupForm
v-if="props.event.registrationAllowed" v-else-if="props.event.registrationAllowed"
:event="props.event" :event="props.event"
:participantData="props.participantData ?? {}" :participantData="props.participantData ?? {}"
:localGroups="props.localGroups ?? []" :localGroups="props.localGroups ?? []"
+22
View File
@@ -45,6 +45,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property float $support_flat * @property float $support_flat
* @property int $alcoholics_age * @property int $alcoholics_age
* @property boolean $archived * @property boolean $archived
* @property boolean $short_registration
* @property boolean $swimming_permission_required
* @property string|null $invoice_key * @property string|null $invoice_key
*/ */
class Event extends InstancedModel class Event extends InstancedModel
@@ -91,6 +93,9 @@ class Event extends InstancedModel
'participation_options', 'participation_options',
'addons', 'addons',
'short_registration',
'swimming_permission_required',
'invoice_key', 'invoice_key',
]; ];
@@ -121,6 +126,9 @@ class Event extends InstancedModel
'participation_options' => 'array', 'participation_options' => 'array',
'addons' => 'array', 'addons' => 'array',
'short_registration' => 'boolean',
'swimming_permission_required' => 'boolean',
]; ];
public function tenant(): BelongsTo public function tenant(): BelongsTo
@@ -205,4 +213,18 @@ class Event extends InstancedModel
return $this->hasMany(EventParticipant::class); return $this->hasMany(EventParticipant::class);
} }
/**
* Slug der ersten konfigurierten Teilnahmegruppe. Die Kurzanmeldung fragt die Gruppe nicht ab und ordnet
* jede Anmeldung dieser Gruppe zu. Null, wenn für die Veranstaltung noch keine Gebühr hinterlegt ist --
* dann ist eine Kurzanmeldung nicht möglich, weil `participation_type` am Teilnehmer Pflicht ist.
*/
public function firstParticipationTypeSlug() : ?string {
return collect([
$this->participationFee1,
$this->participationFee2,
$this->participationFee3,
$this->participationFee4,
])->first(fn ($participationFee) => $participationFee !== null)?->type;
}
} }
+9 -4
View File
@@ -52,12 +52,15 @@ class EventParticipantResource extends JsonResource
'fullname' => $this->resource->getFullName(), 'fullname' => $this->resource->getFullName(),
'age' => new Age($this->resource->birthday)->getAge(), 'age' => new Age($this->resource->birthday)->getAge(),
'localgroup' => $this->resource->localGroup()?->first()?->name ?? 'Nicht im LV', 'localgroup' => $this->resource->localGroup()?->first()?->name ?? 'Nicht im LV',
'swimmingPermission' => $this->resource->swimmingPermission()->first()->short, // Die Zuordnungen sind nullable: die Kurzanmeldung erhebt nicht alles, und in der Verwaltung
'extendedFirstAid' => $this->resource->firstAidPermission()->first()->name, // lassen sich Werte leeren. Ohne Fallback fielen Teilnehmerliste und Detailansicht komplett aus.
'swimmingPermission' => $this->resource->swimmingPermission()->first()?->short ?? 'Unbekannt',
'extendedFirstAid' => $this->resource->firstAidPermission()->first()?->name ?? 'Unbekannt',
'tetanusVaccination' => $this->resource->tetanus_vaccination?->format('d.m.Y') ?? 'Unbekannt', 'tetanusVaccination' => $this->resource->tetanus_vaccination?->format('d.m.Y') ?? 'Unbekannt',
'tetanusVaccinationEdit' => $this->resource->tetanus_vaccination?->format('Y-m-d') ?? null, 'tetanusVaccinationEdit' => $this->resource->tetanus_vaccination?->format('Y-m-d') ?? null,
'presenceDays' => ['real' => $presenceDays, 'support' => $presenceDaysSupport], 'presenceDays' => ['real' => $presenceDays, 'support' => $presenceDaysSupport],
'participationType' => ParticipationType::where(['slug' => $this->resource->participation_type])->first()->name, 'participationType' => ParticipationType::where(['slug' => $this->resource->participation_type])->first()?->name
?? $this->resource->participation_type,
'needs_payment' => $this->resource->amount->getAmount() > 0 'needs_payment' => $this->resource->amount->getAmount() > 0
&& $this->resource->payment_method === PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION && $this->resource->payment_method === PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION
&& $this->resource->amount_paid?->getAmount() < $this->resource->amount->getAmount(), && $this->resource->amount_paid?->getAmount() < $this->resource->amount->getAmount(),
@@ -87,7 +90,7 @@ class EventParticipantResource extends JsonResource
'state' => config('postCode.map.' . $this->resource->postcode), 'state' => config('postCode.map.' . $this->resource->postcode),
'localGroupState' => null !== $this->resource->localGroup()->first()?->postcode ? config('postCode.map.' . $this->resource->postcode) : '--', 'localGroupState' => null !== $this->resource->localGroup()->first()?->postcode ? config('postCode.map.' . $this->resource->postcode) : '--',
'birthday' => $this->resource->birthday->format('d.m.Y'), 'birthday' => $this->resource->birthday->format('d.m.Y'),
'eatingHabit' => EatingHabit::where('slug', $this->resource->eating_habit)->first()->name, 'eatingHabit' => EatingHabit::where('slug', $this->resource->eating_habit)->first()?->name ?? 'Unbekannt',
'paymentMethod' => $this->resource->payment_method !== null 'paymentMethod' => $this->resource->payment_method !== null
? AvailablePaymentMethod::withoutGlobalScopes()->where('tenant', $this->resource->tenant)->where('slug', $this->resource->payment_method)->first()?->name ?? $this->resource->payment_method ? AvailablePaymentMethod::withoutGlobalScopes()->where('tenant', $this->resource->tenant)->where('slug', $this->resource->payment_method)->first()?->name ?? $this->resource->payment_method
: null, : null,
@@ -96,12 +99,14 @@ class EventParticipantResource extends JsonResource
EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'bg-green', EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'bg-green',
EfzStatus::EFZ_STATUS_NOT_CHECKED => 'bg-yellow', EfzStatus::EFZ_STATUS_NOT_CHECKED => 'bg-yellow',
EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'bg-red', EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'bg-red',
default => 'bg-yellow',
}, },
'efzStatusReadable' => match($this->resource->efz_status) { 'efzStatusReadable' => match($this->resource->efz_status) {
EfzStatus::EFZ_STATUS_CHECKED_VALID => 'Gültig', EfzStatus::EFZ_STATUS_CHECKED_VALID => 'Gültig',
EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'Nicht eingereicht', EfzStatus::EFZ_STATUS_CHECKED_INVALID => 'Nicht eingereicht',
EfzStatus::EFZ_STATUS_NOT_CHECKED => 'Nicht geprüft', EfzStatus::EFZ_STATUS_NOT_CHECKED => 'Nicht geprüft',
EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'Nicht erforderlich', EfzStatus::EFZ_STATUS_NOT_REQUIRED => 'Nicht erforderlich',
default => 'Nicht geprüft',
}, },
'eventName' => $this->resource->event()->first()->name, 'eventName' => $this->resource->event()->first()->name,
'arrivalDateReadable' => $this->resource->arrival_date->format('d.m.Y'), 'arrivalDateReadable' => $this->resource->arrival_date->format('d.m.Y'),
+4
View File
@@ -76,6 +76,10 @@ class EventResource extends JsonResource{
$returnArray['nameShort'] = substr($returnArray['nameShort'], 8, 13) . '...'; $returnArray['nameShort'] = substr($returnArray['nameShort'], 8, 13) . '...';
} }
// Steuert im Frontend, welcher Anmelde-Wizard gerendert wird, und ob die Badeerlaubnis abgefragt wird.
$returnArray['shortRegistration'] = (bool)$this->event->short_registration;
$returnArray['swimmingPermissionRequired'] = (bool)$this->event->swimming_permission_required;
$returnArray['siblingReduction'] = $this->event->sibling_reduction ?? true; $returnArray['siblingReduction'] = $this->event->sibling_reduction ?? true;
$returnArray['costUnit'] = new CostUnitResource($this->event->costUnit()->first())->toArray(true); $returnArray['costUnit'] = new CostUnitResource($this->event->costUnit()->first())->toArray(true);
$returnArray['solidarityPayment'] = $this->event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY; $returnArray['solidarityPayment'] = $this->event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY;
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
// Verkürzter Anmeldeprozess: fragt nur die nötigsten Daten ab, fehlende Pflichtangaben werden
// beim Speichern mit Platzhaltern gefüllt. Bestandsveranstaltungen bleiben beim langen Prozess.
$table->boolean('short_registration')->default(false);
// Ob die Badeerlaubnis überhaupt abgefragt wird -- wirkt in beiden Anmeldeprozessen. Default true,
// damit bestehende Veranstaltungen sich unverändert verhalten.
$table->boolean('swimming_permission_required')->default(true);
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropColumn(['short_registration', 'swimming_permission_required']);
});
}
};
+3
View File
@@ -18,6 +18,9 @@
</include> </include>
</source> </source>
<php> <php>
<!-- config/postCode.php ist sehr groß und wird je Test neu geladen; mit dem PHP-Default von 128M
reißt die Suite ab einer gewissen Testanzahl das Limit. -->
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
+331
View File
@@ -0,0 +1,331 @@
<?php
namespace Tests\Feature;
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpCommand;
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpRequest;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\RelationModels\EventParticipationFee;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class ShortSignUpTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
Mail::fake();
$this->tenant = Tenant::create([
'slug' => 'tv', 'name' => 'Test', 'email' => 't@example.com', 'email_finance' => 'f@example.com',
'url' => 'test.local', 'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true, 'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
DB::table('participation_fee_types')->insert(['slug' => 'FIXED', 'name' => 'Fixed', 'created_at' => now(), 'updated_at' => now()]);
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_DENIED, 'name' => 'Darf nicht baden', 'short' => 'Keine']);
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_DENIED, 'name' => 'Abgelehnt', 'description' => '']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
}
private function createEvent(bool $withFee = true, bool $swimmingPermissionRequired = true): Event
{
$event = Event::create([
'tenant' => $this->tenant->slug, 'name' => 'Event', 'identifier' => 'evt-1', 'location' => 'Ort',
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-08-01', 'end_date' => '2026-08-05',
'early_bird_end' => '2026-07-01', 'registration_final_end' => '2026-07-20', 'early_bird_end_amount_increase' => 0,
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
'pay_per_day' => false, 'pay_direct' => false,
'short_registration' => true, 'swimming_permission_required' => $swimmingPermissionRequired,
]);
if ($withFee) {
$fee = EventParticipationFee::create([
'tenant' => $this->tenant->slug, 'type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
'name' => 'Teilnahme', 'description' => '', 'amount_standard' => 50.0,
'amount_reduced' => 30.0, 'amount_solidarity' => 70.0,
]);
$event->participation_fee_1 = $fee->id;
$event->save();
$event->refresh();
}
return $event;
}
private function shortRequest(Event $event, array $overrides = []): ShortSignUpRequest
{
return new ShortSignUpRequest(
event: $event,
user_id: null,
firstname: $overrides['firstname'] ?? 'Hans',
lastname: $overrides['lastname'] ?? 'Muster',
nickname: $overrides['nickname'] ?? null,
// Volljährig, sofern nicht anders angegeben.
birthday: $overrides['birthday'] ?? new \DateTime('2000-01-01'),
email: $overrides['email'] ?? 'h@example.com',
phone: $overrides['phone'] ?? '123',
localGroupId: $overrides['localGroupId'] ?? null,
contactPerson: $overrides['contactPerson'] ?? null,
contactEmail: $overrides['contactEmail'] ?? null,
allergies: $overrides['allergies'] ?? null,
intolerances: $overrides['intolerances'] ?? null,
firstAidPermission: $overrides['firstAidPermission'] ?? null,
swimmingPermission: $overrides['swimmingPermission'] ?? null,
foto_socialmedia: true,
foto_print: false,
foto_webseite: false,
foto_partner: false,
foto_intern: false,
paymentMethod: $overrides['paymentMethod'] ?? PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
paymentOptions: [],
participationOptions: $overrides['participationOptions'] ?? [],
);
}
public function test_kurzanmeldung_fuellt_nicht_erhobene_pflichtfelder(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$participant = $response->participant;
$this->assertSame('Nicht erforderlich', $participant->address_1);
$this->assertSame('Nicht erforderlich', $participant->city);
$this->assertSame('00000', $participant->postcode);
$this->assertNull($participant->local_group);
$this->assertNull($participant->address_2);
$this->assertNull($participant->medications);
// Zeitraum und Mahlzeiten kommen aus der Veranstaltung.
$this->assertSame('2026-08-01', $participant->arrival_date->format('Y-m-d'));
$this->assertSame('2026-08-05', $participant->departure_date->format('Y-m-d'));
$this->assertSame(1, $participant->arrival_eating);
$this->assertSame(2, $participant->departure_eating);
// Gruppe und Beitragsstufe stehen fest.
$this->assertSame(ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, $participant->participation_type);
$this->assertSame('standard', $participant->fee_type);
$this->assertSame(50.0, $participant->amount->getAmount());
$this->assertSame(EatingHabit::EATING_HABIT_OMNIVOR, $participant->eating_habit);
// Volljährige brauchen keine Erlaubnisse.
$this->assertSame(SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, $participant->swimming_permission);
$this->assertSame(FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, $participant->first_aid_permission);
$this->assertSame(1, $participant->invoice_sequence);
$this->assertTrue($participant->foto_socialmedia);
$this->assertFalse($participant->foto_print);
}
public function test_u18_ohne_abgefragte_badeerlaubnis_bekommt_keine(): void
{
$event = $this->createEvent(swimmingPermissionRequired: false);
$response = new ShortSignUpCommand($this->shortRequest($event, [
'birthday' => new \DateTime('2015-01-01'),
'contactPerson' => 'Muster, Maria',
'contactEmail' => 'maria@example.com',
'firstAidPermission' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED,
]))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame(SwimmingPermission::SWIMMING_PERMISSION_DENIED, $response->participant->swimming_permission);
$this->assertSame(EfzStatus::EFZ_STATUS_NOT_REQUIRED, $response->participant->efz_status);
$this->assertSame('Muster, Maria', $response->participant->contact_person);
$this->assertSame('maria@example.com', $response->participant->email_2);
}
public function test_u18_uebernimmt_gewaehlte_badeerlaubnis(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event, [
'birthday' => new \DateTime('2015-01-01'),
'contactPerson' => 'Muster, Maria',
'contactEmail' => 'maria@example.com',
'firstAidPermission' => FirstAidPermission::FIRST_AID_PERMISSION_DENIED,
'swimmingPermission' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED,
]))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame(SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, $response->participant->swimming_permission);
$this->assertSame(FirstAidPermission::FIRST_AID_PERMISSION_DENIED, $response->participant->first_aid_permission);
}
public function test_u18_ohne_kontaktperson_wird_abgelehnt(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event, [
'birthday' => new \DateTime('2015-01-01'),
]))->execute();
$this->assertFalse($response->success);
$this->assertSame(0, EventParticipant::count());
}
public function test_ohne_teilnahmegruppe_wird_abgelehnt(): void
{
$event = $this->createEvent(withFee: false);
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertFalse($response->success);
$this->assertNotNull($response->message);
$this->assertSame(0, EventParticipant::count());
}
public function test_teilnahmeoptionen_werden_gespeichert(): void
{
$event = $this->createEvent();
$event->participation_options = [[
'key' => 'shirt',
'title' => 'T-Shirt',
'label' => 'T-Shirt-Größe',
'options' => [['value' => 'm', 'label' => 'M'], ['value' => 'l', 'label' => 'L']],
]];
$event->save();
$response = new ShortSignUpCommand($this->shortRequest($event, [
'participationOptions' => ['shirt' => 'l'],
]))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$options = $response->participant->selectedOptions()->get();
$this->assertCount(1, $options);
$this->assertSame('shirt', $options->first()->question_key);
$this->assertSame('L', $options->first()->value_label);
}
public function test_unbeantwortete_teilnahmeoption_wird_abgelehnt(): void
{
$event = $this->createEvent();
$event->participation_options = [[
'key' => 'shirt',
'title' => 'T-Shirt',
'label' => 'T-Shirt-Größe',
'options' => [['value' => 'm', 'label' => 'M']],
]];
$event->save();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertFalse($response->success);
$this->assertSame(0, EventParticipant::count());
}
private function createLocalGroup(string $slug, string $name): Tenant
{
return Tenant::create([
'slug' => $slug, 'name' => $name, 'email' => $slug . '@example.com',
'email_finance' => $slug . '-f@example.com', 'url' => $slug . '.local',
'account_name' => $name, 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true,
'has_active_instance' => false,
]);
}
public function test_einziger_stamm_wird_ohne_auswahl_zugeordnet(): void
{
$event = $this->createEvent();
$group = $this->createLocalGroup('stamm-a', 'Stamm A');
$event->localGroups()->attach($group->id);
$event->refresh();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame('stamm-a', $response->participant->local_group);
}
public function test_bei_mehreren_staemmen_wird_die_auswahl_uebernommen(): void
{
$event = $this->createEvent();
$first = $this->createLocalGroup('stamm-a', 'Stamm A');
$second = $this->createLocalGroup('stamm-b', 'Stamm B');
$event->localGroups()->attach([$first->id, $second->id]);
$event->refresh();
$response = new ShortSignUpCommand($this->shortRequest($event, [
'localGroupId' => $second->id,
]))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame('stamm-b', $response->participant->local_group);
}
public function test_bei_mehreren_staemmen_ist_die_auswahl_pflicht(): void
{
$event = $this->createEvent();
$first = $this->createLocalGroup('stamm-a', 'Stamm A');
$second = $this->createLocalGroup('stamm-b', 'Stamm B');
$event->localGroups()->attach([$first->id, $second->id]);
$event->refresh();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertFalse($response->success);
$this->assertSame(0, EventParticipant::count());
}
public function test_nicht_teilnehmender_stamm_wird_abgelehnt(): void
{
$event = $this->createEvent();
$first = $this->createLocalGroup('stamm-a', 'Stamm A');
$second = $this->createLocalGroup('stamm-b', 'Stamm B');
$event->localGroups()->attach([$first->id, $second->id]);
$event->refresh();
// Stamm existiert, nimmt aber nicht an der Veranstaltung teil.
$foreign = $this->createLocalGroup('stamm-c', 'Stamm C');
$response = new ShortSignUpCommand($this->shortRequest($event, [
'localGroupId' => $foreign->id,
]))->execute();
$this->assertFalse($response->success);
$this->assertSame(0, EventParticipant::count());
}
public function test_resource_ueberlebt_kurzanmeldung(): void
{
$event = $this->createEvent();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertTrue($response->success, $response->message ?? '');
// Regressionswächter für Teilnehmerliste, Detailansicht und CSV-Export.
$array = $response->participant->toResource()->toArray(request());
$this->assertSame('Nicht im LV', $array['localgroup']);
$this->assertSame('Erteilt', $array['swimmingPermission']);
$this->assertSame('Zugestimmt', $array['extendedFirstAid']);
$this->assertSame('Omnivor', $array['eatingHabit']);
$this->assertSame('Teilnehmende', $array['participationType']);
}
}