From 70a57d10f53fd4bcceda21ef550b75db02443e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCnrher?= Date: Thu, 10 Sep 2026 11:11:20 +0200 Subject: [PATCH] Kurzanmeldungen --- .../CertificateOfConductionCheckCommand.php | 3 +- .../ShortSignUp/ShortSignUpCommand.php | 142 ++++++++ .../ShortSignUp/ShortSignUpRequest.php | 47 +++ .../ShortSignUp/ShortSignUpResponse.php | 17 + .../Event/Actions/SignUp/SignUpCommand.php | 27 +- .../Event/Actions/SignUp/SignUpRequest.php | 3 +- .../UpdateEvent/UpdateEventCommand.php | 2 + .../UpdateEvent/UpdateEventRequest.php | 6 +- .../UpdateParticipantCommand.php | 3 +- .../UpdateParticipantRequest.php | 3 +- .../Event/Controllers/DetailsController.php | 4 +- .../ShortCalculateAmountController.php | 36 ++ .../Controllers/ShortSignupController.php | 130 +++++++ app/Domains/Event/Routes/api.php | 5 + .../Event/Views/Partials/CommonSettings.vue | 25 ++ .../Event/Views/Partials/ParticipantData.vue | 1 + .../ShortSignUpForm/ShortSignupForm.vue | 138 ++++++++ .../composables/shortStepFlow.js | 37 ++ .../composables/useShortSignupForm.js | 155 ++++++++ .../ShortSignUpForm/steps/ShortStepPerson.vue | 218 ++++++++++++ .../steps/ShortStepSummary.vue | 187 ++++++++++ .../Views/Partials/SignUpForm/SignupForm.vue | 169 +-------- .../Views/Partials/SignUpForm/signupForm.css | 167 +++++++++ .../SignUpForm/steps/StepContactPerson.vue | 4 +- app/Domains/Event/Views/Signup.vue | 8 +- app/Models/Event.php | 22 ++ app/Resources/EventParticipantResource.php | 13 +- app/Resources/EventResource.php | 4 + ...40010_add_short_registration_to_events.php | 27 ++ phpunit.xml | 3 + tests/Feature/ShortSignUpTest.php | 331 ++++++++++++++++++ 31 files changed, 1753 insertions(+), 184 deletions(-) create mode 100644 app/Domains/Event/Actions/ShortSignUp/ShortSignUpCommand.php create mode 100644 app/Domains/Event/Actions/ShortSignUp/ShortSignUpRequest.php create mode 100644 app/Domains/Event/Actions/ShortSignUp/ShortSignUpResponse.php create mode 100644 app/Domains/Event/Controllers/ShortCalculateAmountController.php create mode 100644 app/Domains/Event/Controllers/ShortSignupController.php create mode 100644 app/Domains/Event/Views/Partials/ShortSignUpForm/ShortSignupForm.vue create mode 100644 app/Domains/Event/Views/Partials/ShortSignUpForm/composables/shortStepFlow.js create mode 100644 app/Domains/Event/Views/Partials/ShortSignUpForm/composables/useShortSignupForm.js create mode 100644 app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepPerson.vue create mode 100644 app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepSummary.vue create mode 100644 app/Domains/Event/Views/Partials/SignUpForm/signupForm.css create mode 100644 database/migrations/2026_09_15_140010_add_short_registration_to_events.php create mode 100644 tests/Feature/ShortSignUpTest.php diff --git a/app/Domains/Event/Actions/CertificateOfConductionCheck/CertificateOfConductionCheckCommand.php b/app/Domains/Event/Actions/CertificateOfConductionCheck/CertificateOfConductionCheckCommand.php index 79b5945..1824432 100644 --- a/app/Domains/Event/Actions/CertificateOfConductionCheck/CertificateOfConductionCheckCommand.php +++ b/app/Domains/Event/Actions/CertificateOfConductionCheck/CertificateOfConductionCheckCommand.php @@ -12,7 +12,8 @@ class CertificateOfConductionCheckCommand { public function execute() : 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() ->asJson() diff --git a/app/Domains/Event/Actions/ShortSignUp/ShortSignUpCommand.php b/app/Domains/Event/Actions/ShortSignUp/ShortSignUpCommand.php new file mode 100644 index 0000000..cb187f0 --- /dev/null +++ b/app/Domains/Event/Actions/ShortSignUp/ShortSignUpCommand.php @@ -0,0 +1,142 @@ +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; + } +} diff --git a/app/Domains/Event/Actions/ShortSignUp/ShortSignUpRequest.php b/app/Domains/Event/Actions/ShortSignUp/ShortSignUpRequest.php new file mode 100644 index 0000000..472846b --- /dev/null +++ b/app/Domains/Event/Actions/ShortSignUp/ShortSignUpRequest.php @@ -0,0 +1,47 @@ + value ]. */ + public array $participationOptions = [], + ) { + } +} diff --git a/app/Domains/Event/Actions/ShortSignUp/ShortSignUpResponse.php b/app/Domains/Event/Actions/ShortSignUp/ShortSignUpResponse.php new file mode 100644 index 0000000..b6864f4 --- /dev/null +++ b/app/Domains/Event/Actions/ShortSignUp/ShortSignUpResponse.php @@ -0,0 +1,17 @@ +success = false; + $this->participant = null; + } + +} diff --git a/app/Domains/Event/Actions/SignUp/SignUpCommand.php b/app/Domains/Event/Actions/SignUp/SignUpCommand.php index fb9ee6d..5f55320 100644 --- a/app/Domains/Event/Actions/SignUp/SignUpCommand.php +++ b/app/Domains/Event/Actions/SignUp/SignUpCommand.php @@ -4,6 +4,7 @@ namespace App\Domains\Event\Actions\SignUp; use App\Enumerations\EatingHabit; use App\Enumerations\EfzStatus; +use App\Enumerations\SwimmingPermission; use App\EventPaymentModules\EventPaymentModuleRegistry; use App\ValueObjects\Age; use Illuminate\Support\Facades\DB; @@ -59,7 +60,7 @@ class SignUpCommand { 'participation_type' => $this->request->participationType, 'fee_type' => $this->request->feeType, 'sibling_reduction' => $this->request->siblingReduction, - 'local_group' => $this->request->localGroup->slug, + 'local_group' => $this->request->localGroup?->slug, 'birthday' => $this->request->birthday, 'address_1' => $this->request->address_1, 'address_2' => $this->request->address_2, @@ -75,9 +76,7 @@ class SignUpCommand { 'medications' => $this->request->medications, 'tetanus_vaccination' => $this->request->tetanus_vaccination, 'eating_habit' => $eatingHabit, - 'swimming_permission' => ($participantAge->isfullAged() || $this->request->swimming_permission === '-1') - ? 'SWIMMING_PERMISSION_ALLOWED' - : $this->request->swimming_permission, + 'swimming_permission' => $this->resolveSwimmingPermission($participantAge), 'first_aid_permission' => ($participantAge->isfullAged() || $this->request->first_aid_permission === '-1') ? 'FIRST_AID_PERMISSION_ALLOWED' : $this->request->first_aid_permission, @@ -109,6 +108,26 @@ class SignUpCommand { 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 * Rechnungsnummer. Die Event-Zeile wird gesperrt, damit gleichzeitige Anmeldungen derselben diff --git a/app/Domains/Event/Actions/SignUp/SignUpRequest.php b/app/Domains/Event/Actions/SignUp/SignUpRequest.php index f92a49c..a106cfd 100644 --- a/app/Domains/Event/Actions/SignUp/SignUpRequest.php +++ b/app/Domains/Event/Actions/SignUp/SignUpRequest.php @@ -15,7 +15,8 @@ class SignUpRequest { public string $lastname, public ?string $nickname, public string $participationType, - public Tenant $localGroup, + /** Stamm des Teilnehmers; in der Kurzanmeldung nicht erhoben und deshalb optional. */ + public ?Tenant $localGroup, public DateTime $birthday, public string $address_1, public ?string $address_2, diff --git a/app/Domains/Event/Actions/UpdateEvent/UpdateEventCommand.php b/app/Domains/Event/Actions/UpdateEvent/UpdateEventCommand.php index 1acebf3..24bd1de 100644 --- a/app/Domains/Event/Actions/UpdateEvent/UpdateEventCommand.php +++ b/app/Domains/Event/Actions/UpdateEvent/UpdateEventCommand.php @@ -22,6 +22,8 @@ class UpdateEventCommand { $this->request->event->support_flat = $this->request->flatSupport; $this->request->event->send_weekly_report = $this->request->sendWeeklyReports; $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->resetAllowedEatingHabits(); diff --git a/app/Domains/Event/Actions/UpdateEvent/UpdateEventRequest.php b/app/Domains/Event/Actions/UpdateEvent/UpdateEventRequest.php index da811b4..4ce1ff2 100644 --- a/app/Domains/Event/Actions/UpdateEvent/UpdateEventRequest.php +++ b/app/Domains/Event/Actions/UpdateEvent/UpdateEventRequest.php @@ -17,12 +17,14 @@ class UpdateEventRequest { public int $alcoholicsAge; public bool $sendWeeklyReports; public bool $registrationAllowed; + public bool $shortRegistration; + public bool $swimmingPermissionRequired; public Amount $flatSupport; public Amount $supportPerPerson; public array $contributingLocalGroups; 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->eventName = $eventName; @@ -34,6 +36,8 @@ class UpdateEventRequest { $this->alcoholicsAge = $alcoholicsAge; $this->sendWeeklyReports = $sendWeeklyReports; $this->registrationAllowed = $registrationAllowed; + $this->shortRegistration = $shortRegistration; + $this->swimmingPermissionRequired = $swimmingPermissionRequired; $this->flatSupport = $flatSupport; $this->supportPerPerson = $supportPerPerson; $this->contributingLocalGroups = $contributingLocalGroups; diff --git a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php index 1f614c8..ad12a81 100644 --- a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php +++ b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantCommand.php @@ -31,7 +31,8 @@ class UpdateParticipantCommand { $p->address_2 = $this->request->address_2; $p->postcode = $this->request->postcode; $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->email_1 = $this->request->email_1; $p->phone_1 = $this->request->phone_1; diff --git a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php index ccda46d..6cce1bd 100644 --- a/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php +++ b/app/Domains/Event/Actions/UpdateParticipant/UpdateParticipantRequest.php @@ -14,7 +14,8 @@ class UpdateParticipantRequest { public ?string $address_2, public string $postcode, 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 $email_1, public string $phone_1, diff --git a/app/Domains/Event/Controllers/DetailsController.php b/app/Domains/Event/Controllers/DetailsController.php index fc3f735..da02064 100644 --- a/app/Domains/Event/Controllers/DetailsController.php +++ b/app/Domains/Event/Controllers/DetailsController.php @@ -60,7 +60,9 @@ class DetailsController extends CommonController { $flatSupport, $supportPerPerson, $contributinLocalGroups, - $eatingHabits + $eatingHabits, + (bool)$request->input('shortRegistration', false), + (bool)$request->input('swimmingPermissionRequired', true), ); $eventUpdateCommand = new UpdateEventCommand($eventUpdateRequest); diff --git a/app/Domains/Event/Controllers/ShortCalculateAmountController.php b/app/Domains/Event/Controllers/ShortCalculateAmountController.php new file mode 100644 index 0000000..03a4999 --- /dev/null +++ b/app/Domains/Event/Controllers/ShortCalculateAmountController.php @@ -0,0 +1,36 @@ +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(), + ]); + } +} diff --git a/app/Domains/Event/Controllers/ShortSignupController.php b/app/Domains/Event/Controllers/ShortSignupController.php new file mode 100644 index 0000000..fd26191 --- /dev/null +++ b/app/Domains/Event/Controllers/ShortSignupController.php @@ -0,0 +1,130 @@ +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', + ]); + } +} diff --git a/app/Domains/Event/Routes/api.php b/app/Domains/Event/Routes/api.php index 60a49ae..6b23f96 100644 --- a/app/Domains/Event/Routes/api.php +++ b/app/Domains/Event/Routes/api.php @@ -16,6 +16,8 @@ use App\Domains\Event\Controllers\ParticipantReSignOnController; use App\Domains\Event\Controllers\ParticipantSignOffController; use App\Domains\Event\Controllers\ParticipantUpdateController; 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\Middleware\IdentifyTenant; use Illuminate\Support\Facades\Route; @@ -27,6 +29,9 @@ Route::prefix('api/v1') Route::post('{eventId}/calculate-amount', [SignupController::class, 'calculateAmount']); 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::post('/create', [CreateController::class, 'doCreate']); diff --git a/app/Domains/Event/Views/Partials/CommonSettings.vue b/app/Domains/Event/Views/Partials/CommonSettings.vue index bed9e87..c8ebf7d 100644 --- a/app/Domains/Event/Views/Partials/CommonSettings.vue +++ b/app/Domains/Event/Views/Partials/CommonSettings.vue @@ -35,6 +35,8 @@ const emit = defineEmits(['close']) eatingHabits: eatingHabits.value, sendWeeklyReports: props.event.sendWeeklyReports, registrationAllowed: props.event.registrationAllowed, + shortRegistration: props.event.shortRegistration, + swimmingPermissionRequired: props.event.swimmingPermissionRequired, flatSupport: props.event.flatSupportEdit, supportPerson: props.event.supportPersonIndex, }) @@ -64,6 +66,8 @@ const emit = defineEmits(['close']) alcoholicsAge: formData.alcoholicsAge, sendWeeklyReports: formData.sendWeeklyReports, registrationAllowed: formData.registrationAllowed, + shortRegistration: formData.shortRegistration, + swimmingPermissionRequired: formData.swimmingPermissionRequired, flatSupport: formData.flatSupport, supportPerson: formData.supportPerson, contributingLocalGroups: contributingLocalGroups.value, @@ -172,6 +176,27 @@ const emit = defineEmits(['close']) + + + + +
+ + Fragt nur die nötigsten Daten ab (Name, Kontakt, Allergien, Foto-Erlaubnis). + Nicht erhobene Angaben wie Anschrift und An-/Abreise werden automatisch gefüllt. + + + + + + + +
+ + Ist die Abfrage deaktiviert, wird für Minderjährige „Keine Badeerlaubnis" hinterlegt. + + + diff --git a/app/Domains/Event/Views/Partials/ParticipantData.vue b/app/Domains/Event/Views/Partials/ParticipantData.vue index eb5b6b6..15ad0e3 100644 --- a/app/Domains/Event/Views/Partials/ParticipantData.vue +++ b/app/Domains/Event/Views/Partials/ParticipantData.vue @@ -233,6 +233,7 @@ function saveParticipant() { {{ props.participant.localgroup }} diff --git a/app/Domains/Event/Views/Partials/ShortSignUpForm/ShortSignupForm.vue b/app/Domains/Event/Views/Partials/ShortSignUpForm/ShortSignupForm.vue new file mode 100644 index 0000000..c009637 --- /dev/null +++ b/app/Domains/Event/Views/Partials/ShortSignUpForm/ShortSignupForm.vue @@ -0,0 +1,138 @@ + + + + + + diff --git a/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/shortStepFlow.js b/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/shortStepFlow.js new file mode 100644 index 0000000..465b472 --- /dev/null +++ b/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/shortStepFlow.js @@ -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 +} diff --git a/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/useShortSignupForm.js b/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/useShortSignupForm.js new file mode 100644 index 0000000..b591bd1 --- /dev/null +++ b/app/Domains/Event/Views/Partials/ShortSignUpForm/composables/useShortSignupForm.js @@ -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, + } +} diff --git a/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepPerson.vue b/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepPerson.vue new file mode 100644 index 0000000..0b3e625 --- /dev/null +++ b/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepPerson.vue @@ -0,0 +1,218 @@ + + + diff --git a/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepSummary.vue b/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepSummary.vue new file mode 100644 index 0000000..504eb19 --- /dev/null +++ b/app/Domains/Event/Views/Partials/ShortSignUpForm/steps/ShortStepSummary.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue b/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue index 63f5f4c..e051dd2 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/SignupForm.vue @@ -122,170 +122,5 @@ const steps = [ - + + diff --git a/app/Domains/Event/Views/Partials/SignUpForm/signupForm.css b/app/Domains/Event/Views/Partials/SignUpForm/signupForm.css new file mode 100644 index 0000000..b00723c --- /dev/null +++ b/app/Domains/Event/Views/Partials/SignUpForm/signupForm.css @@ -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; + } +} diff --git a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepContactPerson.vue b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepContactPerson.vue index 5b6367c..3e36d3b 100644 --- a/app/Domains/Event/Views/Partials/SignUpForm/steps/StepContactPerson.vue +++ b/app/Domains/Event/Views/Partials/SignUpForm/steps/StepContactPerson.vue @@ -42,7 +42,7 @@ const next = () => { 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' hasError = true } @@ -86,7 +86,7 @@ const next = () => { - + Badeerlaubnis: