Basic signup for events

This commit is contained in:
2026-03-21 21:02:15 +01:00
parent 23af267896
commit b8341890d3
74 changed files with 4046 additions and 947 deletions

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Domains\Event\Actions\CertificateOfConductionCheck;
use App\Enumerations\EfzStatus;
use Illuminate\Support\Facades\Http;
class CertificateOfConductionCheckCommand {
function __construct(public CertificateOfConductionCheckRequest $request)
{}
public function execute() : CertificateOfConductionCheckResponse {
$response = new CertificateOfConductionCheckResponse();
$localGroup = str_replace('Stamm ', '', $this->request->participant->localGroup()->first()->name);
$apiResponse = Http::acceptJson()
->asJson()
->withoutVerifying()
->post(env('COC_CHECK_URL'), [
'firstName' => trim($this->request->participant->firstname),
'lastName' => $this->request->participant->lastname,
'nickname' => $this->request->participant->nickname,
'address' => trim($this->request->participant->address_1 . ' ' . $this->request->participant->address_2),
'zip' => $this->request->participant->zip,
'city' => $this->request->participant->city,
'birthday' => $this->request->participant->birthday->format('Y-m-d'),
'email' => $this->request->participant->email_1,
'localGroup' => $localGroup,
'checkForDate' => $this->request->participant->departure_date->format('Y-m-d'),
]);
if (! $apiResponse->successful()) {
return $response;
}
$responseParsed = $apiResponse->json();
if (($responseParsed['status'] ?? null) !== '200/ok') {
return $response;
}
$response->status = match ($responseParsed['efzStatus']) {
'NOT_REQUIRED' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
'CHECKED_VALID' => EfzStatus::EFZ_STATUS_CHECKED_VALID,
'CHECKED_INVALID' => EfzStatus::EFZ_STATUS_CHECKED_INVALID,
default => EfzStatus::EFZ_STATUS_NOT_CHECKED
};
return $response;
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domains\Event\Actions\CertificateOfConductionCheck;
use App\Models\EventParticipant;
class CertificateOfConductionCheckRequest {
function __construct(public EventParticipant $participant)
{
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Event\Actions\CertificateOfConductionCheck;
use App\Enumerations\EfzStatus;
class CertificateOfConductionCheckResponse {
public string $status;
public function __construct()
{
$this->status = EfzStatus::EFZ_STATUS_NOT_CHECKED;
}
}

View File

@@ -10,7 +10,7 @@ class SetParticipationFeesCommand {
$this->request = $request;
}
public function excetute() : SetParticipationFeesResponse {
public function execute() : SetParticipationFeesResponse {
$response = new SetParticipationFeesResponse();
$this->cleanBefore();
@@ -19,7 +19,9 @@ class SetParticipationFeesCommand {
'type' => $this->request->participationFeeFirst['type'],
'name' => $this->request->participationFeeFirst['name'],
'description' => $this->request->participationFeeFirst['description'],
'amount' => $this->request->participationFeeFirst['amount']->getAmount()
'amount_standard' => $this->request->participationFeeFirst['amount_standard']->getAmount(),
'amount_reduced' => null !== $this->request->participationFeeFirst['amount_reduced'] ? $this->request->participationFeeFirst['amount_reduced']->getAmount() : null,
'amount_solidarity' => null !== $this->request->participationFeeFirst['amount_solidarity'] ? $this->request->participationFeeFirst['amount_solidarity']->getAmount() : null,
]))->save();
if ($this->request->participationFeeSecond !== null) {
@@ -28,7 +30,9 @@ class SetParticipationFeesCommand {
'type' => $this->request->participationFeeSecond['type'],
'name' => $this->request->participationFeeSecond['name'],
'description' => $this->request->participationFeeSecond['description'],
'amount' => $this->request->participationFeeSecond['amount']->getAmount()
'amount_standard' => $this->request->participationFeeSecond['amount_standard']->getAmount(),
'amount_reduced' => null !== $this->request->participationFeeSecond['amount_reduced'] ? $this->request->participationFeeSecond['amount_reduced']->getAmount() : null,
'amount_solidarity' => null !== $this->request->participationFeeSecond['amount_solidarity'] ? $this->request->participationFeeSecond['amount_solidarity']->getAmount() : null,
]))->save();
}
@@ -38,7 +42,9 @@ class SetParticipationFeesCommand {
'type' => $this->request->participationFeeThird['type'],
'name' => $this->request->participationFeeThird['name'],
'description' => $this->request->participationFeeThird['description'],
'amount' => $this->request->participationFeeThird['amount']->getAmount()
'amount_standard' => $this->request->participationFeeThird['amount_standard']->getAmount(),
'amount_reduced' => null !== $this->request->participationFeeThird['amount_reduced'] ? $this->request->participationFeeThird['amount_reduced']->getAmount() : null,
'amount_solidarity' => null !== $this->request->participationFeeThird['amount_solidarity'] ? $this->request->participationFeeThird['amount_solidarity']->getAmount() : null,
]))->save();
}
@@ -48,7 +54,9 @@ class SetParticipationFeesCommand {
'type' => $this->request->participationFeeFourth['type'],
'name' => $this->request->participationFeeFourth['name'],
'description' => $this->request->participationFeeFourth['description'],
'amount' => $this->request->participationFeeFourth['amount']->getAmount()
'amount_standard' => $this->request->participationFeeFourth['amount_standard']->getAmount(),
'amount_reduced' => null !== $this->request->participationFeeFourth['amount_reduced'] ? $this->request->participationFeeFourth['amount_reduced']->getAmount() : null,
'amount_solidarity' => null !== $this->request->participationFeeFourth['amount_solidarity'] ? $this->request->participationFeeFourth['amount_solidarity']->getAmount() : null,
]))->save();
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Domains\Event\Actions\SignUp;
use App\Enumerations\EfzStatus;
use App\ValueObjects\Age;
use Illuminate\Support\Str;
class SignUpCommand {
public function __construct(public SignUpRequest $request) {
}
public function execute() : SignUpResponse {
$response = new SignUpResponse();
$participantAge = new Age($this->request->birthday);
$response->participant = $this->request->event->participants()->create(
[
'tenant' => $this->request->event->tenant,
'user_id' => $this->request->user_id,
'identifier' => Str::random(10),
'firstname' => $this->request->firstname,
'lastname' => $this->request->lastname,
'nickname' => $this->request->nickname,
'participation_type' => $this->request->participationType,
'local_group' => $this->request->localGroup->slug,
'birthday' => $this->request->birthday,
'address_1' => $this->request->address_1,
'address_2' => $this->request->address_2,
'postcode' => $this->request->postcode,
'city' => $this->request->city,
'email_1' => $this->request->email_1,
'email_2' => $this->request->email_2,
'phone_1' => $this->request->phone_1,
'phone_2' => $this->request->phone_2,
'contact_person' => $this->request->contact_person,
'allergies' => $this->request->allergies,
'intolerances' => $this->request->intolerances,
'medications' => $this->request->medications,
'tetanus_vaccination' => $this->request->tetanus_vaccination,
'eating_habit' => $this->request->eating_habit,
'swimming_permission' => $participantAge->isfullAged() ? 'SWIMMING_PERMISSION_ALLOWED' : $this->request->swimming_permission,
'first_aid_permission' => $participantAge->isfullAged() ? 'FIRST_AID_PERMISSION_ALLOWED' : $this->request->first_aid_permission,
'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_date' => $this->request->arrival,
'departure_date' => $this->request->departure,
'arrival_eating' => $this->request->arrival_eating,
'departure_eating' => $this->request->departure_eating,
'notes' => $this->request->notes,
'amount' => $this->request->amount,
'payment_purpose' => $this->request->event->name . ' - Beitrag ' . $this->request->firstname . ' ' . $this->request->lastname,
'efz_status' => $participantAge->isfullAged() ? EfzStatus::EFZ_STATUS_NOT_CHECKED : EfzStatus::EFZ_STATUS_NOT_REQUIRED,
]
);
$response->success = true;
return $response;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Event\Actions\SignUp;
use App\Enumerations\ParticipationType;
use App\Models\Event;
use App\Models\Tenant;
use App\ValueObjects\Amount;
use DateTime;
class SignUpRequest {
function __construct(
public Event $event,
public ?int $user_id,
public string $firstname,
public string $lastname,
public ?string $nickname,
public string $participationType,
public Tenant $localGroup,
public DateTime $birthday,
public string $address_1,
public string $address_2,
public string $postcode,
public string $city,
public string $email_1,
public ?string $phone_1,
public ?string $email_2,
public ?string $phone_2,
public ?string $contact_person,
public ?string $allergies,
public ?string $intolerances,
public ?string $medications,
public ?DateTime $tetanus_vaccination,
public string $eating_habit,
public ?string $swimming_permission,
public ?string $first_aid_permission,
public bool $foto_socialmedia,
public bool $foto_print,
public bool $foto_webseite,
public bool $foto_partner,
public bool $foto_intern,
public DateTime $arrival,
public DateTime $departure,
public int $arrival_eating,
public int $departure_eating,
public ?string $notes,
public Amount $amount
) {
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Event\Actions\SignUp;
use App\Models\EventParticipant;
class SignUpResponse {
public bool $success;
public ?EventParticipant $participant;
public function __construct() {
$this->success = false;
$this->participant = null;
}
}

View File

@@ -20,6 +20,8 @@ class UpdateEventCommand {
$this->request->event->alcoholics_age = $this->request->alcoholicsAge;
$this->request->event->support_per_person = $this->request->supportPerPerson;
$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->save();
$this->request->event->resetAllowedEatingHabits();

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class AvailableEventsController extends CommonController
{
public function __invoke(Request $request) : Response {
$events = [];
foreach ($this->events->getAvailable(false) as $event) {
$events[] = $event->toResource()->toArray($request);
};
$inertiaProvider = new InertiaProvider('Event/ListAvailable', ['events' => $events]);
return $inertiaProvider->render();
}
}

View File

@@ -87,7 +87,7 @@ class CreateController extends CommonController {
if ($wasSuccessful) {
return response()->json([
'status' => 'success',
'event' => new EventResource($costUnitUpdateRequest->event)->toArray()
'event' => new EventResource($costUnitUpdateRequest->event)->toArray($request)
]);
} else {
return response()->json([

View File

@@ -23,9 +23,9 @@ class DetailsController extends CommonController {
return new InertiaProvider('Event/Details', ['event' => $event])->render();
}
public function summary(int $eventId) : JsonResponse {
public function summary(int $eventId, Request $request) : JsonResponse {
$event = $this->events->getById($eventId);
return response()->json(['event' => new EventResource($event)->toArray()]);
return response()->json(['event' => $event->toResource()->toArray($request)]);
}
public function updateCommonSettings(int $eventId, Request $request) : JsonResponse {
@@ -78,7 +78,9 @@ class DetailsController extends CommonController {
'type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
'name' => 'Teilnehmer',
'description' => $request->input('pft_1_description'),
'amount' => Amount::fromString($request->input('pft_1_amount'))
'amount_standard' => Amount::fromString($request->input('pft_1_amount_standard')),
'amount_reduced' => null !== $request->input('pft_1_amount_reduced') ? Amount::fromString($request->input('pft_1_amount_reduced')) : null,
'amount_solidarity' => null !== $request->input('pft_1_amount_solidarity') ? Amount::fromString($request->input('pft_1_amount_solidarity')) : null
];
$participationFeeRequest = new SetParticipationFeesRequest($event, $participationFeeFirst);
@@ -88,7 +90,9 @@ class DetailsController extends CommonController {
'type' => ParticipationType::PARTICIPATION_TYPE_TEAM,
'name' => $event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_FIXED ? 'Kernteam' : 'Solidaritätsbeitrag',
'description' => $request->input('pft_2_description'),
'amount' => Amount::fromString($request->input('pft_2_amount'))
'amount_standard' => Amount::fromString($request->input('pft_2_amount_standard')),
'amount_reduced' => null !== $request->input('pft_2_amount_reduced') ? Amount::fromString($request->input('pft_2_amount_reduced')) : null,
'amount_solidarity' => null !== $request->input('pft_2_amount_solidarity') ? Amount::fromString($request->input('pft_2_amount_solidarity')) : null
];
}
@@ -97,7 +101,9 @@ class DetailsController extends CommonController {
'type' => ParticipationType::PARTICIPATION_TYPE_VOLUNTEER,
'name' => $event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_FIXED ? 'Unterstützende' : 'Reduzierter Beitrag',
'description' => $event->participation_fee_type !== ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY ? $request->input('pft_3_description') : 'Nach Verfügbarkeit',
'amount' => Amount::fromString($request->input('pft_3_amount'))
'amount_standard' => Amount::fromString($request->input('pft_3_amount_standard')),
'amount_reduced' => null !== $request->input('pft_3_amount_reduced') ? Amount::fromString($request->input('pft_3_amount_reduced')) : null,
'amount_solidarity' => null !== $request->input('pft_3_amount_solidarity') ? Amount::fromString($request->input('pft_3_amount_solidarity')) : null
];
}
@@ -106,12 +112,14 @@ class DetailsController extends CommonController {
'type' => ParticipationType::PARTICIPATION_TYPE_OTHER,
'name' => 'Sonstige',
'description' => $request->input('pft_4_description'),
'amount' => Amount::fromString($request->input('pft_4_amount'))
'amount_standard' => Amount::fromString($request->input('pft_4_amount_standard')),
'amount_reduced' => null !== $request->input('pft_4_amount_reduced') ? Amount::fromString($request->input('pft_4_amount_reduced')) : null,
'amount_solidarity' => null !== $request->input('pft_4_amount_solidarity') ? Amount::fromString($request->input('pft_4_amount_solidarity')) : null
];
}
$participationFeeCommand = new SetParticipationFeesCommand($participationFeeRequest);
$response = $participationFeeCommand->excetute();
$response = $participationFeeCommand->execute();
return response()->json(['status' => $response->success ? 'success' : 'error']);
}

View File

@@ -0,0 +1,171 @@
<?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\SignUp\SignUpCommand;
use App\Domains\Event\Actions\SignUp\SignUpRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Providers\InertiaProvider;
use App\Resources\EventResource;
use App\Resources\UserResource;
use App\Scopes\CommonController;
use App\ValueObjects\Amount;
use http\Env\Response;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SignupController extends CommonController {
public function __invoke(int $eventId, Request $request) {
$availableEvents = [];
foreach ($this->events->getAvailable(false) as $event) {
$availableEvents[] = $event->toResource()->toArray($request);
};
$event = $this->events->getById($eventId, false)?->toResource()->toArray($request);
$participantData = [
'firstname' => '',
'lastname' => '',
];
if (auth()->check()) {
$user = new UserResource(auth()->user())->toArray($request);
$participantData = [
'id' => $user['id'],
'firstname' => $user['firstname'],
'lastname' => $user['lastname'],
'nickname' => $user['nickname'],
'email' => $user['email'],
'phone' => $user['phone'],
'postcode' => $user['postcode'],
'city' => $user['city'],
'address_1' => $user['address_1'],
'address_2' => $user['address_2'],
'birthday' => $user['birthday'],
'localGroup' => $user['localGroup'],
'allergies' => $user['allergies'],
'intolerances' => $user['intolerances'],
'eating_habit' => $user['eating_habits'],
'medications' => $user['medications'],
'tetanusVaccination' => $user['tetanus_vaccination'],
];
}
$inertiaProvider = new InertiaProvider('Event/Signup', [
'event' => $event,
'availableEvents' => $availableEvents,
'localGroups' => $event['contributingLocalGroups'],
'participantData' => $participantData,
]);
return $inertiaProvider->render();
}
public function signUp(int $eventId, Request $request) {
$event = $this->events->getById($eventId, false);
$eventResource = $event->toResource();
$registrationData = $request->input('registration_data');
$arrival = \DateTime::createFromFormat('Y-m-d', $registrationData['arrival']);
$departure = \DateTime::createFromFormat('Y-m-d', $registrationData['departure']);
$tetanusVaccination = $registrationData['tetanusVaccination'] ? \DateTime::createFromFormat('Y-m-d', $registrationData['tetanusVaccination']) : null;
// Steps:
// 1. Check, if bereits angemeldet
//
$amount = $eventResource->calculateAmount(
$registrationData['participationType'],
$registrationData['beitrag'],
$arrival,
$departure
);
$signupRequest = new SignUpRequest(
$event,$registrationData['userId'],
$registrationData['vorname'],
$registrationData['nachname'],
$registrationData['pfadiname'],
$registrationData['participationType'],
Tenant::findOrFail($registrationData['localGroup']),
\DateTime::createFromFormat('Y-m-d', $registrationData['geburtsdatum']),
$registrationData['address1'],
$registrationData['address2'],
$registrationData['plz'],
$registrationData['ort'],
$registrationData['email_1'],
$registrationData['telefon_1'],
$registrationData['email_2'],
$registrationData['telefon_2'],
$registrationData['ansprechpartner'],
$registrationData['allergien'],
$registrationData['intolerances'],
$registrationData['medikamente'],
$tetanusVaccination,
$registrationData['essgewohnheit'],
$registrationData['badeerlaubnis'],
$registrationData['first_aid'],
$registrationData['foto']['socialmedia'],
$registrationData['foto']['print'],
$registrationData['foto']['webseite'],
$registrationData['foto']['partner'],
$registrationData['foto']['intern'],
$arrival,
$departure,
$registrationData['anreise_essen'],
$registrationData['abreise_essen'],
$registrationData['anmerkungen'],
$amount
);
$signupCommand = new SignUpCommand($signupRequest);
$signupResponse = $signupCommand->execute();
// 4. Addons registrieren
$certificateOfConductionCheckRequest = new CertificateOfConductionCheckRequest($signupResponse->participant);
$certificateOfConductionCheckCommand = new CertificateOfConductionCheckCommand($certificateOfConductionCheckRequest);
$certificateOfConductionCheckResponse = $certificateOfConductionCheckCommand->execute();
$signupResponse->participant->efz_status = $certificateOfConductionCheckResponse->status;
$signupResponse->participant->save();
// 6. E-Mail senden & Bestätigung senden
return response()->json(
[
'participant' => $signupResponse->participant->toResource()->toArray($request),
'status' => 'success',
]
);
dd($eventId, $registrationData, $amount);
}
public function calculateAmount(int $eventId, Request $request, bool $forDisplay = true) : JsonResponse | float {
$event = $this->events->getById($eventId, false)->toResource();
return response()->json(['amount' =>
$event->calculateAmount(
$request->input('participationType'),
$request->input('beitrag'),
\DateTime::createFromFormat('Y-m-d', $request->input('arrival')),
\DateTime::createFromFormat('Y-m-d', $request->input('departure'))
)->toString()
]);
}
}

View File

@@ -2,6 +2,7 @@
use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController;
use App\Domains\Event\Controllers\SignupController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
@@ -9,6 +10,9 @@ Route::prefix('api/v1')
->group(function () {
Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('event')->group(function () {
Route::post('{eventId}/calculate-amount', [SignupController::class, 'calculateAmount']);
Route::post('{eventId}/signup', [SignupController::class, 'signUp']);
Route::middleware(['auth'])->group(function () {
Route::post('/create', [CreateController::class, 'doCreate']);

View File

@@ -1,12 +1,19 @@
<?php
use App\Domains\Event\Controllers\AvailableEventsController;
use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController;
use App\Domains\Event\Controllers\SignupController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('event')->group(function () {
Route::get('/available-events', AvailableEventsController::class);
Route::get('/{eventId}/signup', SignupController::class);
Route::middleware(['auth'])->group(function () {
Route::get('/new', CreateController::class);
Route::get('/details/{eventId}', DetailsController::class);

View File

@@ -0,0 +1,102 @@
<script setup>
import ShadowedBox from "../../../../Views/Components/ShadowedBox.vue";
const props = defineProps({
events: Array,
})
console.log(props.events)
</script>
<template>
<div style="width: 95%; margin: 20px auto;">
<div v-if="props.events.length === 0" style="text-align: center; color: #6b7280; padding: 40px 0;">
Aktuell sind keine Veranstaltungen verfügbar.
</div>
<shadowed-box
v-for="event in props.events"
:key="event.id"
style="padding: 24px; margin-bottom: 20px;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 12px;">
<div>
<h2 style="margin: 0 0 4px 0; font-size: 1.25rem;">{{ event.name }}</h2>
<span style="color: #6b7280; font-size: 0.9rem;">{{ event.postalCode }} {{ event.location }}</span>
</div>
<span
v-if="event.registrationAllowed"
style="background: #d1fae5; color: #065f46; padding: 4px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; white-space: nowrap;"
>
Anmeldung offen
</span>
<span
v-else
style="background: #fee2e2; color: #991b1b; padding: 4px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; white-space: nowrap;"
>
Anmeldung geschlossen
</span>
</div>
<hr style="margin: 16px 0; border: none; border-top: 1px solid #e5e7eb;" />
<table style="width: 100%; border-collapse: collapse;">
<tr>
<th style="text-align: left; padding: 6px 12px 6px 0; width: 220px; color: #374151; font-weight: 600;">Zeitraum</th>
<td style="padding: 6px 0; color: #111827;">{{ event.eventBegin }} {{ event.eventEnd }} ({{ event.duration }} Tage)</td>
</tr>
<tr>
<th style="text-align: left; padding: 6px 12px 6px 0; width: 220px; color: #374151; font-weight: 600;">Veranstaltungsort</th>
<td style="padding: 6px 0; color: #111827;">{{ event.postalCode }} {{ event.location }}</td>
</tr>
<tr>
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Frühbuchen bis</th>
<td style="padding: 6px 0; color: #111827;">{{ event.earlyBirdEnd.formatted }}</td>
</tr>
<tr>
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Anmeldeschluss</th>
<td style="padding: 6px 0; color: #111827;">{{ event.registrationFinalEnd.formatted }}</td>
</tr>
<tr v-if="event.email">
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Kontakt</th>
<td style="padding: 6px 0;">
<a :href="'mailto:' + event.email" style="color: #2563eb;">{{ event.email }}</a>
</td>
</tr>
</table>
<div style="margin-top: 20px; display: flex; justify-content: flex-end;">
<a
:href="'/event/' + event.id + '/signup'"
style="
display: inline-block;
padding: 10px 24px;
background-color: #2563eb;
color: white;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
font-size: 0.95rem;
opacity: 1;
transition: background-color 0.2s;
"
:style="{ opacity: event.registrationAllowed ? '1' : '0.5', pointerEvents: event.registrationAllowed ? 'auto' : 'none' }"
>
Zur Anmeldung
</a>
</div>
</shadowed-box>
</div><div style="width: 95%; margin: 20px auto;">
<div v-if="props.events.length === 0" style="text-align: center; color: #6b7280; padding: 40px 0;">
Aktuell sind keine Veranstaltungen verfügbar.
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -16,19 +16,27 @@
const errors = reactive({})
const formData = reactive({
"pft_1_active": true,
"pft_1_amount": props.event.participationFee_1.amount,
"pft_1_amount_standard": props.event.participationFee_1.amount_standard_edit,
"pft_1_amount_reduced": props.event.participationFee_1.amount_reduced_edit,
"pft_1_amount_solidarity": props.event.participationFee_1.amount_solidarity_edit,
"pft_1_description": props.event.participationFee_1.description,
"pft_2_active": props.event.participationFee_2.active,
"pft_2_amount": props.event.participationFee_2.amount,
"pft_2_amount_standard": props.event.participationFee_2.amount_standard_edit,
"pft_2_amount_reduced": props.event.participationFee_2.amount_reduced_edit,
"pft_2_amount_solidarity": props.event.participationFee_2.amount_solidarity_edit,
"pft_2_description": props.event.participationFee_2.description,
"pft_3_active": props.event.participationFee_3.active,
"pft_3_amount": props.event.participationFee_3.amount,
"pft_3_amount_standard": props.event.participationFee_3.amount_standard_edit,
"pft_3_amount_reduced": props.event.participationFee_3.amount_reduced_edit,
"pft_3_amount_solidarity": props.event.participationFee_3.amount_solidarity_edit,
"pft_3_description": props.event.participationFee_3.description,
"pft_4_active": props.event.participationFee_4.active,
"pft_4_amount": props.event.participationFee_4.amount,
"pft_4_amount_standard": props.event.participationFee_4.amount_standard_edit,
"pft_4_amount_reduced": props.event.participationFee_4.amount_reduced_edit,
"pft_4_amount_solidarity": props.event.participationFee_4.amount_solidarity_edit,
"pft_4_description": props.event.participationFee_4.description,
'maxAmount': props.event.maxAmount,
@@ -38,17 +46,17 @@
function validateInput() {
var noErrors = true;
if (formData.pft_1_description === '' && !props.event.solidarityPayment) {
if (formData.pft_1_description === '') {
errors.pft_1_description = 'Eine Beschreibung für diese Gruppe ist erforderlich';
noErrors = false;
}
if (formData.pft_2_description === '' && formData.pft_2_active && !props.event.solidarityPayment) {
if (formData.pft_2_description === '' && formData.pft_2_active) {
errors.pft_2_description = 'Eine Beschreibung für diese Gruppe ist erforderlich';
noErrors = false;
}
if (formData.pft_3_description === '' && formData.pft_3_active && !props.event.solidarityPayment) {
if (formData.pft_3_description === '' && formData.pft_3_active) {
errors.pft_3_description = 'Eine Beschreibung für diese Gruppe ist erforderlich';
noErrors = false;
}
@@ -73,19 +81,27 @@
body: {
event_id: props.event.id,
pft_1_active: formData.pft_1_active,
pft_1_amount: formData.pft_1_amount,
pft_1_amount_standard: formData.pft_1_amount_standard,
pft_1_amount_reduced: formData.pft_1_amount_reduced,
pft_1_amount_solidarity: formData.pft_1_amount_solidarity,
pft_1_description: formData.pft_1_description,
pft_2_active: formData.pft_2_active,
pft_2_amount: formData.pft_2_amount,
pft_2_amount_standard: formData.pft_2_amount_standard,
pft_2_amount_reduced: formData.pft_2_amount_reduced,
pft_2_amount_solidarity: formData.pft_2_amount_solidarity,
pft_2_description: formData.pft_2_description,
pft_3_active: formData.pft_3_active,
pft_3_amount: formData.pft_3_amount,
pft_3_amount_standard: formData.pft_3_amount_standard,
pft_3_amount_reduced: formData.pft_3_amount_reduced,
pft_3_amount_solidarity: formData.pft_3_amount_solidarity,
pft_3_description: formData.pft_3_description,
pft_4_active: formData.pft_4_active,
pft_4_amount: formData.pft_4_amount,
pft_4_amount_standard: formData.pft_4_amount_standard,
pft_4_amount_reduced: formData.pft_4_amount_reduced,
pft_4_amount_solidarity: formData.pft_4_amount_solidarity,
pft_4_description: formData.pft_4_description,
maxAmount: formData.maxAmount,
@@ -114,29 +130,41 @@
<template>
<table style="width: 100%;">
<tr>
<td>Aktiv</td>
<td>Preisgruppe</td>
<td>Betrag</td>
<td>Beschreibung</td>
<td><h4>Aktiv</h4></td>
<td><h4>Preisgruppe</h4></td>
<td v-if="!props.event.solidarityPayment"><h4>Betrag</h4></td>
<td v-else><h4>Regulärer Beitrag</h4></td>
<td v-if="props.event.solidarityPayment"><h4>Reduzierter Beitrag</h4></td>
<td v-if="props.event.solidarityPayment"><h4>Solidaritätsbeitrag</h4></td>
<td><h4>Beschreibung</h4></td>
</tr>
<tr style="height: 65px; vertical-align: top">
<td>
<input type="checkbox" v-model="formData.participationFeeType_1" checked disabled/>
</td>
<td v-if="props.event.solidarityPayment">
Regulärer Beitrag
</td>
<td v-else>
<td>
Teilnehmende
</td>
<td>
<AmountInput v-model="formData.pft_1_amount" class="width-small" @blur="recalculateMaxAmount(formData.pft_1_amount)" />
<label v-if="props.event.payPerDay"> Euro / Tag</label>
<label v-else> Euro Gesamt</label>
<AmountInput v-model="formData.pft_1_amount_standard" class="width-small" @blur="recalculateMaxAmount(formData.pft_1_amount_standard)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment">
<AmountInput v-model="formData.pft_1_amount_reduced" class="width-small" @blur="recalculateMaxAmount(formData.pft_1_amount_reduced)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment">
<AmountInput v-model="formData.pft_1_amount_solidarity" class="width-small" @blur="recalculateMaxAmount(formData.pft_1_amount_solidarity)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td>
<input v-if="!props.event.solidarityPayment" type="text" v-model="formData.pft_1_description" style="width: 300px;" />
<label v-else></label>
<input type="text" v-model="formData.pft_1_description" style="width: 300px;" />
<ErrorText :message="errors.pft_1_description" />
</td>
</tr>
@@ -145,24 +173,32 @@
<td>
<input id="use_pft_2" type="checkbox" v-model="formData.pft_2_active" :checked="formData.pft_2_active" />
</td>
<td v-if="props.event.solidarityPayment">
<label for="use_pft_2" style="cursor: default">
Solidaritätsbeitrag
</label>
</td>
<td v-else>
<td>
<label for="use_pft_2" style="cursor: default">
Kernteam
</label>
</td>
<td v-if="formData.pft_2_active">
<AmountInput v-model="formData.pft_2_amount" class="width-small" @blur="recalculateMaxAmount(formData.pft_2_amount)" />
<label v-if="props.event.payPerDay"> Euro / Tag</label>
<label v-else> Euro Gesamt</label>
<AmountInput v-model="formData.pft_2_amount_standard" class="width-small" @blur="recalculateMaxAmount(formData.pft_2_amount_standard)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_2_active">
<AmountInput v-model="formData.pft_2_amount_reduced" class="width-small" @blur="recalculateMaxAmount(formData.pft_2_amount_reduced)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_2_active">
<AmountInput v-model="formData.pft_2_amount_solidarity" class="width-small" @blur="recalculateMaxAmount(formData.pft_2_amount_solidarity)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="formData.pft_2_active">
<input v-if="!props.event.solidarityPayment" type="text" v-model="formData.pft_2_description" style="width: 300px;" />
<label v-else></label>
<input type="text" v-model="formData.pft_2_description" style="width: 300px;" />
<ErrorText :message="errors.pft_2_description" />
</td>
</tr>
@@ -171,29 +207,36 @@
<td>
<input id="use_pft_3" type="checkbox" v-model="formData.pft_3_active" :checked="formData.pft_3_active" />
</td>
<td v-if="props.event.solidarityPayment">
<label for="use_pft_3" style="cursor: default">
Reduzierter Beitrag
</label>
</td>
<td v-else>
<td>
<label for="use_pft_3" style="cursor: default">
Unterstützende
</label>
</td>
<td v-if="formData.pft_3_active">
<AmountInput v-model="formData.pft_3_amount" class="width-small" @blur="recalculateMaxAmount(formData.pft_3_amount)" />
<label v-if="props.event.payPerDay"> Euro / Tag</label>
<label v-else> Euro Gesamt</label>
<AmountInput v-model="formData.pft_3_amount_standard" class="width-small" @blur="recalculateMaxAmount(formData.pft_3_amount_standard)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_3_active">
<AmountInput v-model="formData.pft_3_amount_reduced" class="width-small" @blur="recalculateMaxAmount(formData.pft_3_amount_reduced)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_3_active">
<AmountInput v-model="formData.pft_3_amount_solidarity" class="width-small" @blur="recalculateMaxAmount(formData.pft_3_amount_solidarity)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="formData.pft_3_active">
<input v-if="!props.event.solidarityPayment" type="text" v-model="formData.pft_3_description" style="width: 300px;" />
<label v-else>Nach Verfügbarkeit</label>
<input type="text" v-model="formData.pft_3_description" style="width: 300px;" />
<ErrorText :message="errors.pft_3_description" />
</td>
</tr>
<tr style="height: 65px; vertical-align: top;" v-if="!props.event.solidarityPayment">
<tr style="height: 65px; vertical-align: top;">
<td>
<input id="use_pft_4" type="checkbox" v-model="formData.pft_4_active" :checked="formData.pft_4_active" />
</td>
@@ -203,10 +246,23 @@
</label>
</td>
<td v-if="formData.pft_4_active">
<AmountInput v-model="formData.pft_4_amount" class="width-small" @blur="recalculateMaxAmount(formData.pft_4_amount)" />
<label v-if="props.event.payPerDay"> Euro / Tag</label>
<label v-else> Euro Gesamt</label>
<AmountInput v-model="formData.pft_4_amount_standard" class="width-small" @blur="recalculateMaxAmount(formData.pft_4_amount_standard)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_4_active">
<AmountInput v-model="formData.pft_4_amount_reduced" class="width-small" @blur="recalculateMaxAmount(formData.pft_4_amount_reduced)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="props.event.solidarityPayment && formData.pft_4_active">
<AmountInput v-model="formData.pft_4_amount_solidarity" class="width-small" @blur="recalculateMaxAmount(formData.pft_4_amount_solidarity)" />
<label style="font-size: 10pt;" v-if="props.event.payPerDay"> Euro / Tag</label>
<label style="font-size: 10pt;" v-else> Euro Gesamt</label>
</td>
<td v-if="formData.pft_4_active">
<input type="text" v-model="formData.pft_4_description" style="width: 300px;" />
<ErrorText :message="errors.pft_4_description" />

View File

@@ -43,7 +43,11 @@ const steps = [
<template>
<div>
<!-- Nach Submit -->
<SubmitSuccess v-if="submitResult?.type === 'success'" :data="submitResult.data" />
<SubmitSuccess
v-if="submitResult?.status === 'success'"
:participant="submitResult?.participant"
:event="event"
/>
<SubmitAlreadyExists v-else-if="submitResult?.type === 'exists'" :data="submitResult.data" />
<template v-else>

View File

@@ -0,0 +1,12 @@
<script setup>
defineProps({ data: Object })
</script>
<template>
<div style="padding: 20px 0;">
<h3>{{ data.nicename }}</h3>
<p>{{ data.text_1 }}</p>
<p>{{ data.text_2 }}</p>
<a :href="data.email_link" style="color: #2563eb;">{{ data.email_text }}</a>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script setup>
const props = defineProps({
participant: Object,
event: Object,
})
console.log(props.event)
console.log(props.participant)
</script>
<template>
<div style="padding: 20px 0;">
<h3>Hallo {{ props.participant.nicename }},</h3>
<p>Vielen Dank für dein Interesse an der Veranstaltung {{event.name}}<br />Wir haben folgende Daten erhalten:</p>
<table class="form-table" style="margin-bottom: 20px;">
<tr><td>Anreise:</td><td>{{ props.participant.arrival }}</td></tr>
<tr><td>Abreise:</td><td>{{ props.participant.departure }}</td></tr>
<tr><td>Teilnahmegruppe:</td><td>{{ props.participant.participation_group }}</td></tr>
</table>
<div v-if="props.participant.efz_status === 'NOT_CHECKED'" style="font-weight: bold; color: #b45309; margin-bottom: 20px;">
Dein erweitertes Führungszeugnis konnte nicht automatisch geprüft werden. Bitte kontaktiere die Aktionsleitung.
</div>
<div v-else-if="props.participant.efz_status === 'CHECKED_INVALID'" style="font-weight: bold; color: #dc2626; margin-bottom: 20px;">
Du hast noch kein erweitertes Führungszeugnis hinterlegt. Bitte reiche es umgehend ein.
</div>
<template v-if="props.participant.needs_payment">
<table class="form-table" style="margin-bottom: 16px;">
<tr>
<td>Kontoinhaber:</td><td>{{ props.event.accountOwner }}</td>
<td rowspan="4" style="vertical-align: top; padding-left: 20px;" v-if="props.participant.identifier !== ''">
<img :src="'/print-girocode/' + props.participant.identifier" alt="GiroCode" style="max-width: 180px;" />
<span style="width: 180px; text-align: center; display: block; font-size: 0.8rem; color: #6b7280; margin-top: 4px;">Giro-Code</span>
</td>
</tr>
<tr><td>IBAN:</td><td>{{ props.event.accountIban }}</td></tr>
<tr><td>Verwendungszweck:</td><td>{{ props.participant.payment_purpose }}</td></tr>
<tr><td>Betrag:</td><td><strong>{{ props.participant.amount_left_string }}</strong></td></tr>
</table>
<p>
Bitte beachte, dass deine Anmeldung erst nach Zahlungseiongang vollständig ist.<br />
Wenn dieser nicht bis zum {{ props.event.registrationFinalEnd.formatted }} erfolgt, kann deine Anmeldung storniert werden.<br /><br />
Solltest du den Beitrag bis zu diesem Datum nicht oder nur teilweise überweisen können, kontaktiere bitte die Aktionsleitung, damit wir eine gemeinsame Lösiung finden können.
</p>
</template>
<p v-else>
Du musst keinen Beitrag überweisen. Deine Anmeldung ist bestätigt.
</p>
<p>
Du erhältst innerhalb von 2 Stunden eine E-Mail mit weiteren Informationen.<br />
Kontakt: <a :href="'mailto:' + props.event.email" style="color: #2563eb;">{{ props.event.email }}</a>
</p>
</div>
</template>

View File

@@ -0,0 +1,98 @@
import { ref, reactive, computed } from 'vue'
import axios from 'axios'
export function useSignupForm(event, participantData) {
const currentStep = ref(1)
const submitting = ref(false)
const summaryLoading = ref(false)
const submitResult = ref(null) // null | { type: 'success'|'exists', data: {} }
const selectedAddons = reactive({})
console.log(participantData)
const formData = reactive({
eatingHabit: 'EATING_HABIT_VEGAN',
userId: participantData.id,
eventId: event.id,
vorname: participantData.firstname ?? '',
nachname: participantData.lastname ?? '',
pfadiname: participantData.nickname ?? '',
localGroup: participantData.localGroup ?? '-1',
geburtsdatum: participantData.birthday ?? '',
address1: participantData.address_1 ?? '',
address2: participantData.address_2 ?? '',
plz: participantData.postcode ?? '',
ort: participantData.city ?? '',
telefon_1: participantData.phone ?? '',
email_1: participantData.email ?? '',
participationType: '',
ansprechpartner: '',
telefon_2: '',
email_2: '',
badeerlaubnis: '-1',
first_aid: '-1',
participant_group: '',
beitrag: 'regular',
arrival: event.eventBeginInternal?.split('T')[0] ?? '',
departure: event.eventEndInternal?.split('T')[0] ?? '',
anreise_essen: '1',
abreise_essen: '2',
foto: { socialmedia: false, print: false, webseite: false, partner: false, intern: false },
allergien: participantData.allergies ?? '',
intolerances: participantData.intolerances ?? '',
medikamente: participantData.medications ?? '',
tetanusVaccination: participantData.tetanusVaccination ?? '',
essgewohnheit: 'vegetarian',
anmerkungen: '',
summary_information_correct: false,
summary_accept_terms: false,
legal_accepted: false,
payment: false,
})
const summaryAmount = ref('')
const goToStep = async (step) => {
if (step === 9) {
summaryLoading.value = true
summaryAmount.value = ''
try {
const res = await axios.post('/api/v1/event/' + event.id + '/calculate-amount', {
arrival: formData.arrival,
departure: formData.departure,
event_id: event.id,
participation_group: formData.participant_group,
selected_amount: formData.beitrag,
addons: selectedAddons,
participationType: formData.participationType,
beitrag: formData.beitrag,
})
summaryAmount.value = res.data.amount
} finally {
summaryLoading.value = false
}
}
currentStep.value = step
}
const submit = async () => {
if (!formData.summary_information_correct || !formData.summary_accept_terms || !formData.legal_accepted || !formData.payment) {
return
}
submitting.value = true
try {
const res = await axios.post('/api/v1/event/'+ event.id + '/signup', {
addons: selectedAddons,
registration_data: { ...formData },
})
submitResult.value = {
status: res.data.status,
participant: res.data.participant,
}
} finally {
submitting.value = false
}
}
return { currentStep, goToStep, formData, selectedAddons, submit, submitting, submitResult, summaryLoading, summaryAmount }
}

View File

@@ -0,0 +1,45 @@
<script setup>
const props = defineProps({ formData: Object, event: Object, selectedAddons: Object })
const emit = defineEmits(['next', 'back'])
</script>
<template>
<div>
<!-- Solidarbeitrag-Auswahl -->
<div v-if="event.solidarityPayment" style="margin-bottom: 20px;">
<h3>Beitrag</h3>
<label v-if="event.participationFee_1?.active" style="display: block; margin-bottom: 8px;">
<input type="radio" v-model="formData.beitrag" value="reduced" />
{{ event.participationFee_1.name }} ({{ event.participationFee_1.amount }} )
</label>
<label style="display: block; margin-bottom: 8px;">
<input type="radio" v-model="formData.beitrag" value="regular" />
{{ event.participationFee_2?.name ?? 'Regulärer Beitrag' }} ({{ event.participationFee_2?.amount }} )
</label>
<label v-if="event.participationFee_3?.active" style="display: block; margin-bottom: 8px;">
<input type="radio" v-model="formData.beitrag" value="social" />
{{ event.participationFee_3.name }} ({{ event.participationFee_3.amount }} )
</label>
</div>
<!-- Addons -->
<div v-if="event.addons?.length > 0">
<h3>Zusatzoptionen</h3>
<div v-for="addon in event.addons" :key="addon.id" style="margin-bottom: 16px; padding: 12px; background: #f8fafc; border-radius: 8px;">
<label style="display: flex; gap: 12px; cursor: pointer;">
<input type="checkbox" v-model="selectedAddons[addon.id]" style="margin-top: 4px;" />
<span>
<strong>{{ addon.name }}</strong>
<span style="display: block; color: #6b7280; font-size: 0.875rem;">Betrag: {{ addon.amount }}</span>
<span style="display: block; color: #374151; font-size: 0.875rem; margin-top: 4px;">{{ addon.description }}</span>
</span>
</label>
</div>
</div>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back', 5)"> Zurück</button>
<button type="button" class="btn-primary" @click="emit('next', 7)">Weiter </button>
</div>
</div>
</template>

View File

@@ -0,0 +1,103 @@
<script setup>
defineProps({ event: Object })
const emit = defineEmits(['next'])
</script>
<template>
<div>
<h3 style="margin: 0 0 6px 0; color: #111827;">Wer nimmt teil?</h3>
<p style="margin: 0 0 24px 0; color: #6b7280; font-size: 0.95rem;">Bitte wähle deine Altersgruppe aus.</p>
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<!-- Kind / Jugendliche:r -->
<div class="age-card" @click="emit('next', 2)">
<div class="age-card__badge">
<img :src="'/images/children.png'" alt="Abzeichen Kind" class="age-card__img" onerror="this.style.display='none'" />
<div class="age-card__badge-fallback">👦</div>
</div>
<div class="age-card__body">
<h4 class="age-card__title">Mein Kind anmelden:</h4>
<p class="age-card__desc">Mein Kind ist <strong>jünger als {{ event.alcoholicsAge }} Jahre.</strong></p>
</div>
</div>
<!-- Erwachsene:r -->
<div class="age-card" @click="emit('next', 3)">
<div class="age-card__badge">
<img :src="'/images/adults.png'" alt="Abzeichen Erwachsene" class="age-card__img" onerror="this.style.display='none'" />
<div class="age-card__badge-fallback">🧑</div>
</div>
<div class="age-card__body">
<h4 class="age-card__title">Mich selbst anmelden</h4>
<p class="age-card__desc">Ich bin <strong>{{ event.alcoholicsAge }} Jahre oder älter</strong>.</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.age-card {
flex: 1;
min-width: 220px;
display: flex;
flex-direction: column;
align-items: center;
background: #f8fafc;
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 28px 20px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
text-align: center;
}
.age-card:hover {
border-color: #2563eb;
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.12);
transform: translateY(-2px);
}
.age-card__badge {
position: relative;
width: 350px;
height: 200px;
margin-bottom: 16px;
}
.age-card__img {
width: 350px;
height: 200px;
object-fit: contain;
}
.age-card__badge-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
background: #e0f2fe;
border-radius: 50%;
}
/* Fallback ausblenden wenn Bild geladen ist */
.age-card__img:not([style*="display:none"]) + .age-card__badge-fallback {
display: none;
}
.age-card__body { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.age-card__title { margin: 0; font-size: 1.1rem; font-weight: 700; color: #111827; }
.age-card__desc { margin: 0; font-size: 0.9rem; color: #374151; }
.age-card__hint { margin: 0; font-size: 0.8rem; color: #6b7280; }
.age-card__cta {
margin-top: 10px;
display: inline-block;
padding: 6px 18px;
background: #2563eb;
color: white;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,52 @@
<script setup>
const props = defineProps({ formData: Object, event: Object })
const emit = defineEmits(['next', 'back'])
</script>
<template>
<div>
<h3>Allergien & Ernährung</h3>
<table class="form-table">
<tr><td>Allergien:</td><td><input type="text" v-model="props.formData.allergien" /></td></tr>
<tr>
<td>
Letzte Teranus-Impfung:
<span style="display: block; font-size: 0.8rem; color: #6b7280; margin-top: 4px;">Lass das Feld frei, wenn die Information nicht vorliegt oder du diese nicht mitteilen willst</span>
</td><td><input type="date" v-model="props.formData.tetanusVaccination" /></td></tr>
<tr><td>Unverträglichkeiten:</td><td><input type="text" v-model="props.formData.intolerances" /></td></tr>
<tr>
<td>
Medikamente:<br />
<span style="display: block; font-size: 0.8rem; color: #6b7280; margin-top: 4px;">Bitte in ausreichender Menge mitbringen</span>
</td>
<td>
<input type="text" v-model="props.formData.medikamente" />
</td>
</tr>
<tr>
<td>Ernährungsweise:</td>
<td>
<select v-model="props.formData.eatingHabit">
<option
v-for="eatingHabit in props.event.eatingHabits"
:value="eatingHabit.data.slug">{{eatingHabit.data.name}}</option>
</select>
</td>
</tr>
<tr>
<td>Anmerkungen:</td>
<td><textarea rows="5" v-model="props.formData.anmerkungen" style="width: 100%;"></textarea></td>
</tr>
<tr>
<td colspan="2" class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back', 7)"> Zurück</button>
<button type="button" class="btn-primary" @click="emit('next', 9)">Weiter </button>
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
const props = defineProps({ formData: Object, event: Object })
const emit = defineEmits(['next', 'back'])
const next = () => {
const arrival = new Date(props.formData.arrival)
arrival.setHours(0,0,0,0);
const departure = new Date(props.formData.departure)
const eventStart = new Date(props.event.eventBeginInternal)
const eventEnd = new Date(props.event.eventEndInternal)
arrival.setHours(0,0,0,0);
departure.setHours(0,0,0,0);
eventStart.setHours(0,0,0,0);
eventEnd.setHours(0,0,0,0);
if (arrival < eventStart) {
alert('Bitte gültige Anreise angeben innerhalb des Veranstaltungszeitraums wählen.')
return
}
if (arrival > eventEnd) {
alert('Bitte gültige Abreise angeben innerhalb des Veranstaltungszeitraums wählen.')
return
}
if (departure < arrival) {
alert('Abreise kann niht vor der Anreise liegen. Bitte korrigieren.')
return
}
const hasAddons = (props.event.addons?.length > 0) || props.event.solidarityPayment
emit('next', 5)
}
const back = () => emit('back', 3)
</script>
<template>
<div>
<h3>An- und Abreise</h3>
<table class="form-table">
<tr>
<td>Anreise:</td>
<td>
<input type="date" v-model="formData.arrival" /><br />
<select v-model="formData.anreise_essen" style="margin-top: 6px;">
<option value="1">Vor dem Abendessen</option>
<option value="2">Vor dem Mittagessen</option>
<option value="3">Vor dem Frühstück</option>
<option value="4">Keine Mahlzeit</option>
</select>
</td>
</tr>
<tr>
<td>Abreise:</td>
<td>
<input type="date" v-model="formData.departure" /><br />
<select v-model="formData.abreise_essen" style="margin-top: 6px;">
<option value="1">Nach dem Frühstück</option>
<option value="2">Nach dem Mittagessen</option>
<option value="3">Nach dem Abendessen</option>
<option value="4">Keine Mahlzeit</option>
</select>
</td>
</tr>
<tr>
<td colspan="2" class="btn-row">
<button type="button" class="btn-secondary" @click="back"> Zurück</button>
<button type="button" class="btn-primary" @click="next">Weiter </button>
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,125 @@
<script setup>
import ErrorText from "../../../../../../Views/Components/ErrorText.vue";
import {reactive} from "vue";
const props = defineProps({ formData: Object, event: Object })
const emit = defineEmits(['next', 'back'])
const errors = reactive({
ansprechpartner: '',
telefon_2: '',
email_2: '',
badeerlaubnis: '',
first_aid: '',
})
const next = () => {
errors.ansprechpartner = ''
errors.telefon_2 = ''
errors.email_2 = ''
errors.badeerlaubnis = ''
errors.first_aid = ''
let hasError = false
if (!props.formData.ansprechpartner) {
errors.ansprechpartner = 'Bitte eine Kontaktperson angeben.'
hasError = true
}
if (!props.formData.telefon_2) {
errors.telefon_2 = 'Bitte eine Telefonnummer angeben.'
hasError = true
}
if (!props.formData.email_2) {
errors.email_2 = 'Bitte eine E-Mail-Adresse angeben.'
hasError = true
}
if (props.formData.badeerlaubnis === '-1') {
errors.badeerlaubnis = 'Bitte triff eine Entscheidung. Bist du dir unsicher, kontaktiere bitte die Aktionsleitung'
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 (hasError) {
return
}
emit('next', 3)
}
</script>
<template>
<div>
<h3>Kontaktperson</h3>
<table class="form-table">
<tr>
<td>Name (Nachname, Vorname):</td>
<td>
<input type="text" v-model="formData.ansprechpartner" />
<ErrorText :message="errors.ansprechpartner" />
</td>
</tr>
<tr>
<td>Telefon:</td>
<td>
<input type="text" v-model="formData.telefon_2" />
<ErrorText :message="errors.telefon_2" />
</td>
</tr>
<tr>
<td>E-Mail:</td>
<td>
<input type="text" v-model="formData.email_2" />
<ErrorText :message="errors.email_2" />
</td>
</tr>
<tr>
<td>Badeerlaubnis:</td>
<td>
<select v-model="formData.badeerlaubnis">
<option value="-1">Bitte wählen</option>
<option
v-for="swimmingPermission in props.event.swimmingPermissions"
:value="swimmingPermission.slug">{{swimmingPermission.name}}</option>
</select>
<ErrorText :message="errors.badeerlaubnis" />
</td>
</tr>
<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 props.event.firstAidPermissions"
: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>
<td></td>
<td>
</td>
</tr>
<tr>
<td colspan="2" class="btn-row">
<button type="button" class="btn-primary" @click="next">Weiter </button>
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,124 @@
<script setup>
import {reactive} from "vue";
const props = defineProps({ formData: Object, localGroups: Array })
const emit = defineEmits(['next', 'back'])
const errors = reactive({
vorname: '',
nachname: '',
geburtsdatum: '',
localGroup: '',
address1: '',
plz: '',
ort: '',
telefon_1: '',
email_1: '',
})
const next = () => {
errors.vorname = ''
errors.nachname = ''
errors.geburtsdatum = ''
errors.localGroup = ''
errors.address1 = ''
errors.plz = ''
errors.ort = ''
errors.telefon_1 = ''
errors.email_1 = ''
let hasError = false
if (!props.formData.vorname) {
errors.vorname = 'Bitte den Vornamen angeben.'
hasError = true
}
if (!props.formData.nachname) {
errors.nachname = 'Bitte den Nachnamen angeben.'
hasError = true
}
if (!props.formData.geburtsdatum) {
errors.geburtsdatum = 'Bitte das Geburtsdatum angeben.'
hasError = true
}
if (props.formData.localGroup === '-1') {
errors.localGroup = 'Bitte den Stamm auswählen.'
hasError = true
}
if (!props.formData.address1) {
errors.address1 = 'Bitte die Adresse angeben.'
hasError = true
}
if (!props.formData.plz) {
errors.plz = 'Bitte die Postleitzahl angeben.'
hasError = true
}
if (!props.formData.ort) {
errors.ort = 'Bitte den Ort angeben.'
hasError = true
}
if (!props.formData.email_1) {
errors.email_1 = 'Bitte eine E-Mail-Adresse angeben.'
hasError = true
}
if (hasError) {
return
}
emit('next', 4)
}
</script>
<template>
<div>
<h3>Persönliche Daten</h3>
<table class="form-table">
<tr><td>Vorname:</td><td><input type="text" v-model="props.formData.vorname" /></td></tr>
<tr><td>Nachname:</td><td><input type="text" v-model="props.formData.nachname" /></td></tr>
<tr><td>Pfadiname:</td><td><input type="text" v-model="props.formData.pfadiname" /></td></tr>
<tr>
<td>Stamm:</td>
<td>
<select v-model="props.formData.localGroup">
<option value="-1">Bitte wählen</option>
<option v-for="lg in localGroups" :key="lg.id" :value="lg.id">{{ lg.name }}</option>
</select>
</td>
</tr>
<tr><td>Geburtsdatum:</td><td><input type="date" v-model="props.formData.geburtsdatum" /></td></tr>
<tr>
<td>Adresse:</td>
<td>
<input type="text" v-model="props.formData.address1" />
</td>
</tr>
<tr>
<td></td>
<td>
<input type="text" v-model="props.formData.address2" />
</td>
</tr>
<tr>
<td>PLZ, Ort:</td>
<td>
<input maxlength="5" type="text" v-model="props.formData.plz" style="width: 100px; margin-right: 8px;" />
<input type="text" v-model="props.formData.ort" style="width: calc(100% - 110px);" />
</td>
</tr>
<tr><td>Telefon:</td><td><input type="text" v-model="props.formData.telefon_1" /></td></tr>
<tr><td>E-Mail:</td><td><input type="text" v-model="props.formData.email_1" /></td></tr>
<tr>
<td colspan="2" class="btn-row">
<button type="button" class="btn-primary" @click="next">Weiter </button>
</td>
</tr>
</table>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
const props = defineProps({ formData: Object, event: Object })
const emit = defineEmits(['next', 'back'])
const acceptAll = () => {
Object.keys(props.formData.foto).forEach(k => props.formData.foto[k] = true)
emit('next', 8)
}
const back = () => {
const hasAddons = (props.event.addons?.length > 0) || props.event.solidarityPayment
emit('back', hasAddons ? 6 : 5)
}
</script>
<template>
<div>
<h3>Fotoerlaubnis</h3>
<div v-for="[key, label] in [['socialmedia','Social Media'],['print','Printmedien'],['webseite','Webseite'],['partner','Partnerorganisationen'],['intern','Interne Zwecke']]"
:key="key"
style="margin-bottom: 10px;">
<label style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.foto[key]" />
{{ label }}
</label>
</div>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="back"> Zurück</button>
<button type="button" class="btn-primary" style="background: #059669;" @click="acceptAll">Alle akzeptieren & weiter</button>
<button type="button" class="btn-primary" @click="emit('next', 8)">Weiter </button>
</div>
</div>
</template>

View File

@@ -0,0 +1,130 @@
<script setup>
import { watch } from "vue";
const props = defineProps({ formData: Object, event: Object })
const emit = defineEmits(['next', 'back'])
watch(
() => props.formData.participationType,
(value) => {
if (!value) {
props.formData.beitrag = 'standard'
return
}
props.formData.beitrag = 'standard'
}
)
const nextStep = () => {
const hasAddons = (props.event.addons?.length ?? 0) > 0
emit('next', hasAddons ? 6 : 7)
}
</script>
<template>
<div>
<h3 v-if="event.solidarityPayment">Solidarbeitrag Teilnahmegruppe</h3>
<h3 v-else>Ich nehme teil als ...</h3>
<table style="width: 100%;">
<tr
v-for="participationType in props.event.participationTypes"
:key="participationType.type.slug"
style="vertical-align: top;"
>
<td style="width: 50px; padding-top: 6px;">
<input
:id="participationType.type.slug"
v-model="props.formData.participationType"
type="radio"
:value="participationType.type.slug"
/>
</td>
<td style="padding-bottom: 16px;">
<label :for="participationType.type.slug" style="line-height: 1.5; font-weight: 600; cursor: pointer;">
{{ participationType.type.name }}
</label><br />
<label
:for="participationType.type.slug"
style="line-height: 1.5; padding-left: 15px; font-style: italic; color: #606060; cursor: pointer;"
>
{{ participationType.description }}
</label>
<div
v-if="props.formData.participationType === participationType.type.slug"
style="margin-top: 10px; margin-left: 15px; padding: 12px 14px; background: #f8fafc; border-left: 3px solid #2563eb; border-radius: 6px;"
>
<template
v-if="participationType.amount_reduced !== null || participationType.amount_solidarity !== null"
>
<div style="margin-bottom: 8px; font-size: 0.95rem; font-weight: 600; color: #374151;">
Beitrag auswählen
</div>
<label style="display: block; margin-bottom: 8px; cursor: pointer;">
<input type="radio" v-model="props.formData.beitrag" value="standard" />
Standardbeitrag
<span style="color: #606060;">
({{ participationType.amount_standard.readable }}
<template v-if="props.event.payPerDay">/ Tag</template>
<template v-else>Gesamt</template>)
</span>
</label>
<label
v-if="participationType.amount_reduced !== null"
style="display: block; margin-bottom: 8px; cursor: pointer;"
>
<input type="radio" v-model="props.formData.beitrag" value="reduced" />
Reduzierter Beitrag
<span style="color: #606060;">
({{ participationType.amount_reduced.readable }}
<template v-if="props.event.payPerDay">/ Tag</template>
<template v-else>Gesamt</template>)
</span>
</label>
<label
v-if="participationType.amount_solidarity !== null"
style="display: block; margin-bottom: 0; cursor: pointer;"
>
<input type="radio" v-model="props.formData.beitrag" value="solidarity" />
Solidaritätsbeitrag
<span style="color: #606060;">
({{ participationType.amount_solidarity.readable }}
<template v-if="props.event.payPerDay">/ Tag</template>
<template v-else>Gesamt</template>)
</span>
</label>
</template>
<template v-else>
<div style="font-size: 0.9rem; color: #606060;">
Standardbeitrag:
<strong>{{ participationType.amount_standard.readable }}</strong>
<template v-if="props.event.payPerDay">/ Tag</template>
<template v-else>Gesamt</template>
</div>
</template>
</div>
</td>
</tr>
</table>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back', 3)"> Zurück</button>
<button
type="button"
v-if="props.formData.participationType"
class="btn-primary"
@click="nextStep"
>
Weiter
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup>
import {format, parseISO} from "date-fns";
const props = defineProps({
formData: Object,
event: Object,
summaryAmount: String,
summaryLoading: Boolean,
submitting: Boolean,
})
const emit = defineEmits(['back', 'submit'])
function formatDate(dateString) {
if (!dateString) return ''
return format(parseISO(dateString), 'dd.MM.yyyy')
}
</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>Veranstaltung:</td><td><strong>{{ event.name }}</strong></td></tr>
<tr><td>Anreise:</td><td>{{ formatDate(formData.arrival) }}</td></tr>
<tr><td>Abreise:</td><td>{{ formatDate(formData.departure) }}</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 style="display: flex; gap: 10px; cursor: pointer;">
<input type="checkbox" v-model="formData.payment" />
Ich bestätige, den Betrag von <strong>{{ summaryAmount }}</strong> zu überweisen.
</label>
</div>
<div class="btn-row">
<button type="button" class="btn-secondary" @click="emit('back', 8)"> Zurück</button>
<button
type="submit"
class="btn-primary"
:disabled="!formData.summary_information_correct || !formData.summary_accept_terms || !formData.legal_accepted || !formData.payment || submitting"
style="background: #059669;"
>
{{ submitting ? 'Wird gesendet…' : 'Jetzt anmelden ✓' }}
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Models\EventParticipant;
use App\Providers\GiroCodeProvider;
use Illuminate\Http\Request;
class GiroCodeGetController {
public function __invoke(string $participantToken, Request $request)
{
$participant = EventParticipant::where(['identifier' => $participantToken])->first();
if (null === $participant) {
return response()->json(['message' => 'Participant not found'], 404);
}
$amount = $participant->amount;
$amount->subtractAmount($participant->amount_paid);
$girocodeProvider = new GiroCodeProvider(
$participant->event()->first()->account_owner,
$participant->event()->first()->account_iban,
$participant->amount->getAmount(),
$participant->payment_purpose
);
return response($girocodeProvider->create(), 200,
['Content-Type' => 'image/png']
);
}
}

View File

@@ -7,12 +7,14 @@ use App\Casts\AmountCast;
use App\Enumerations\EatingHabit;
use App\Enumerations\ParticipationFeeType;
use App\RelationModels\EventParticipationFee;
use App\Resources\EventResource;
use App\Scopes\CommonModel;
use App\Scopes\InstancedModel;
use DateTime;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property string $tenant
@@ -53,6 +55,7 @@ class Event extends InstancedModel
'tenant',
'cost_unit_id',
'name',
'identifier',
'location',
'postal_code',
'email',
@@ -169,4 +172,5 @@ class Event extends InstancedModel
public function participants() : hasMany {
return $this->hasMany(EventParticipant::class);
}
}

View File

@@ -20,6 +20,7 @@ class EventParticipant extends InstancedModel
'tenant',
'event_id',
'user_id',
'identifier',
'firstname',
'lastname',
@@ -42,6 +43,8 @@ class EventParticipant extends InstancedModel
'allergies',
'intolerances',
'medications',
'tetanus_vaccination',
'eating_habits',
'swimming_permission',
'first_aid_permission',
@@ -60,12 +63,14 @@ class EventParticipant extends InstancedModel
'notes',
'amount',
'amount_paid',
'payment_purpose',
'efz_status',
'unregistered_at',
];
protected $casts = [
'birthday' => 'datetime',
'tetanus_vaccination' => 'datetime',
'arrival_date' => 'datetime',
'departure_date' => 'datetime',
'unregistered_at' => 'datetime',
@@ -131,5 +136,21 @@ class EventParticipant extends InstancedModel
return $this->belongsTo(EfzStatus::class, 'efz_status', 'slug');
}
public function getOfficialName() : string {
return sprintf('%1$s %2$s', $this->firstname, $this->lastname);
}
public function getFullname() : string {
return sprintf('%1$1s %2$s %3$s',
$this->firstname,
$this->lastname,
$this->nickname !== null ? '(' . $this->nickname . ')' : '',
)
|>trim(...);
}
public function getNicename() : string {
return $this->nickname ?? $this->firstname;
}
}

View File

@@ -66,6 +66,7 @@ class User extends Authenticatable
'medications',
'allergies',
'intolerances',
'tetanus_vaccination',
'eating_habits',
'swimming_permission',
'first_aid_permission',

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Providers;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class GiroCodeProvider {
function __construct(private string $recipient, private string $iban, private float $amount, private string $paymentPurpose) {}
public function create() {
$data = "BCD\n".
"001\n".
"1\n".
"SCT\n".
"\n".
"$this->recipient\n".
"$this->iban\n".
"EUR$this->amount\n".
"\n".
"$this->paymentPurpose";
return
QrCode::format('png')->size(300)->generate($data)
;
}
}

View File

@@ -135,7 +135,7 @@ class GlobalDataProvider {
}
$navigation['common'][] = ['url' => '/invoice/new', 'display' => 'Neue Abrechnung'];
$navigation['common'][] = ['url' => '/available-events', 'display' => 'Verfügbare Veranstaltungen'];
$navigation['common'][] = ['url' => '/event/available-events', 'display' => 'Verfügbare Veranstaltungen'];
return $navigation;
}

View File

@@ -4,6 +4,7 @@ namespace App\RelationModels;
use App\Casts\AmountCast;
use App\Enumerations\ParticipationFeeType;
use App\Enumerations\ParticipationType;
use App\Models\Tenant;
use App\Scopes\CommonModel;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,14 +13,19 @@ class EventParticipationFee extends CommonModel
{
protected $table = 'event_participation_fees';
protected $fillable = ['tenant', 'type', 'name', 'description', 'amount'];
protected $casts = ['amount' => AmountCast::class];
protected $fillable = ['tenant', 'type', 'name', 'description', 'amount_standard', 'amount_reduced', 'amount_solidarity', 'active'];
protected $casts = [
'amount_standard' => AmountCast::class,
'amount_reduced' => AmountCast::class,
'amount_solidarity' => AmountCast::class,
];
public function tenant() : BelongsTo {
return $this->belongsTo(Tenant::class, 'tenant', 'slug');
}
public function type() : BelongsTo {
return $this->belongsTo(ParticipationFeeType::class, 'type', 'slug');
return $this->belongsTo(ParticipationType::class, 'type', 'slug');
}
}

View File

@@ -7,6 +7,7 @@ use App\Models\CostUnit;
use App\Models\Event;
use App\Resources\CostUnitResource;
use Illuminate\Database\Eloquent\Collection;
use Psy\Readline\Hoa\EventBucket;
class EventRepository {
public function listAll() : array {
@@ -16,6 +17,17 @@ class EventRepository {
}
public function getAvailable(bool $accessCheck = true) : array {
return $this->getEventsByCriteria([
'archived' => false,
'registration_allowed' => true
],$accessCheck);
}
public function getForRegistration(int $id) : ?Event {
$events = self::getEventsByCriteria(['id' => $id, 'registration_allowed' => true]);
}
public function getById(int $id, bool $accessCheck = true) : ?Event {
$events = self::getEventsByCriteria(['id' => $id], $accessCheck);
return $events[0] ?? null;
@@ -50,7 +62,7 @@ class EventRepository {
/** @var Event $event */
foreach (Event::where($criteria)->get() as $event) {
if ($event->eventManagers()->where('user_id', $user->id)->exists() || $canSeeAll || !$accessCheck) {
if ($canSeeAll || !$accessCheck || $event->eventManagers()->where('user_id', $user->id)->exists()) {
$visibleEvents[] = $event;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Resources;
use App\Models\EventParticipant;
use Illuminate\Http\Resources\Json\JsonResource;
class EventParticipantResource extends JsonResource
{
function __construct(EventParticipant $participant)
{
parent::__construct($participant);
}
public function toArray($request) : array
{
$event = $this->resource->event;
return array_merge(
$this->resource->toArray(),
[
'needs_payment' => $this->resource->amount->getAmount() > 0 && $event->pay_direct,
'nicename' => $this->resource->getNicename(),
'arrival' => $this->resource->arrival_date->format('d.m.Y'),
'departure' => $this->resource->departure_date->format('d.m.Y'),
'amount_left_string' => $this->resource->amount->toString(),
]
);
}
}

View File

@@ -5,17 +5,20 @@ namespace App\Resources;
use App\Enumerations\ParticipationFeeType;
use App\Models\Event;
use App\ValueObjects\Amount;
use DateTime;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventResource {
class EventResource extends JsonResource{
private Event $event;
public function __construct(Event $event) {
$this->event = $event;
}
public function toArray() : array {
$duration = $this->event->end_date->diff($this->event->start_date)->d + 1;
public function toArray(Request $request) : array
{
$duration = $this->event->end_date->diff($this->event->start_date)->days + 1;
$returnArray = [
'id' => $this->event->id,
@@ -25,13 +28,31 @@ class EventResource {
'email' => $this->event->email,
'accountOwner' => $this->event->account_owner,
'accountIban' => $this->event->account_iban,
'accountOwner' => $this->event->account_owner,
'accountIban' => $this->event->account_iban,
'alcoholicsAge' => $this->event->alcoholics_age,
'sendWeeklyReports' => $this->event->send_weekly_report,
'registrationAllowed' => $this->event->registration_allowed,
'earlyBirdEnd' => ['internal' => $this->event->early_bird_end->format('Y-m-d'), 'formatted' => $this->event->early_bird_end->format('d.m.Y')],
'registrationFinalEnd' => ['internal' => $this->event->registration_final_end->format('Y-m-d'), 'formatted' => $this->event->registration_final_end->format('d.m.Y')],
'refundAfterEarlyBirdEnd' => 100 - $this->event->early_bird_end_amount_increase,
];
$returnArray['swimmingPermissions'] = \App\Enumerations\SwimmingPermission::query()
->get()
->map(fn ($permission) => [
'slug' => $permission->slug,
'name' => $permission->name,
])
->toArray();
$returnArray['firstAidPermissions'] = \App\Enumerations\FirstAidPermission::query()
->get()
->map(fn ($permission) => [
'slug' => $permission->slug,
'name' => $permission->name,
])
->toArray();
$returnArray['costUnit'] = new CostUnitResource($this->event->costUnit()->first())->toArray(true);
@@ -58,52 +79,122 @@ class EventResource {
$returnArray['managers'] = $this->event->eventManagers()->get()->map(fn($user) => new UserResource($user))->toArray();
$returnArray['supportPersonCalced'] = $this->event->support_per_person->toString();
$returnArray['contributingLocalGroups'] = $this->event->localGroups()->get()->map(fn($localGroup) => new LocalGroupResource($localGroup))->toArray();
$returnArray['contributingLocalGroups'] = $this->event->localGroups
->map(fn ($localGroup) => new LocalGroupResource($localGroup)->toArray($request))
->toArray();
$returnArray['eatingHabits'] = $this->event->eatingHabits()->get()->map(
fn($eatingHabit) => new EatingHabitResource($eatingHabit))->toArray();
$returnArray['participationTypes'] = [];
$multiplier = $this->getMultiplier();
for ($i = 1; $i <= 4; $i++) {
$returnArray['participationFee_' . $i] = [
'active' => false,
'name' => '',
'description' => '',
'amount' => '0,00',
'type' => null
'amount_standard' => null,
'amount_reduced' => null,
'amount_solidarity' => null,
'type' => null,
];
if ($this->event->{'participation_fee_' . $i} === null) {
continue;
}
$participationFee = $this->event->{'participationFee' . $i}()->first();
$returnArray['participationFee_' . $i] = [
$feeType = [
'active' => true,
'amount' => $this->event->{'participationFee' . $i}()->first()->amount->getFormattedAmount(),
'name' => $this->event->{'participationFee' . $i}->first()->name,
'description' => $this->event->{'participationFee' . $i}()->first()->description,
'type' => $this->event->{'participationFee' . $i}->first()->type
'amount_standard_edit' => $participationFee->amount_standard->getFormattedAmount(),
'amount_standard' => [
'internal' => [
'amount' => $participationFee->amount_standard->getAmount(),
'currency' => $participationFee->amount_standard->getCurrency(),
],
'readable' => $participationFee->amount_standard->multiply($multiplier)->toString(),
],
'amount_reduced_edit' => $participationFee->amount_reduced === null ? null : $participationFee->amount_reduced->getFormattedAmount(),
'amount_reduced' => $participationFee->amount_reduced === null
? null
: [
'internal' => [
'amount' => $participationFee->amount_reduced->getAmount(),
'currency' => $participationFee->amount_reduced->getCurrency(),
],
'readable' => $participationFee->amount_reduced->multiply($multiplier)->toString(),
],
'amount_solidarity_edit' => $participationFee->amount_solidarity === null ? null : $participationFee->amount_solidarity->getFormattedAmount(),
'amount_solidarity' => $participationFee->amount_solidarity === null
? null
: [
'internal' => [
'amount' => $participationFee->amount_solidarity->getAmount(),
'currency' => $participationFee->amount_solidarity->getCurrency(),
],
'readable' => $participationFee->amount_solidarity->multiply($multiplier)->toString(),
],
'name' => $participationFee->name,
'description' => $participationFee->description,
'type' => (new ParticipationTypeResource($participationFee->type()->first()))->toArray($request),
];
if ($this->event->participation_fee_type === ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY) {
$returnArray['participationFee_1' . $i]['description'] = '';
$returnArray['participationFee_2' . $i]['description'] = '';
$returnArray['participationFee_3' . $i]['description'] = 'Nach Verfügbarkeit';
}
$returnArray['participationFee_' . $i] = $feeType;
$returnArray['participationTypes'][] = $feeType;
}
return $returnArray;
}
public function getMultiplier() : float {
$earlyBirdEnd = $this->event->early_bird_end;
if ($earlyBirdEnd > now()) {
return 1;
}
return 1 + $this->event->early_bird_end_amount_increase / 100;
}
public function calculateIncomes() : Amount {
$amount = new Amount(0, 'Euro');
$amount->addAmount($this->event->support_flat);
return $amount;
}
public function calculateAmount(
string $participationType,
string $feeType,
DateTime $arrival,
DateTime $departure
) : Amount {
$fee = collect([
$this->event->participationFee1,
$this->event->participationFee2,
$this->event->participationFee3,
$this->event->participationFee4,
])->filter(fn ($participationFee) => $participationFee !== null)
->first(fn ($participationFee) => $participationFee->type === $participationType);
if ($fee === null) {
return new Amount(0, 'Euro');
}
/** @var Amount $basicFee */
$basicFee = $fee['amount_' . $feeType];
$basicFee = $basicFee->multiply($this->getMultiplier());
if ($this->event->pay_per_day) {
$days = $arrival->diff($departure)->days;
$basicFee = $basicFee->multiply($days);
}
return $basicFee;
}
}

View File

@@ -6,19 +6,17 @@ use App\Models\Tenant;
use Illuminate\Http\Resources\Json\JsonResource;
class LocalGroupResource extends JsonResource {
private Tenant $tenant;
public function __construct(Tenant $tenant) {
$this->tenant = $tenant;
parent::__construct($tenant);
}
public function toArray($request) : array {
return [
'id' => $this->tenant->id,
'name' => $this->tenant->name,
'email' => $this->tenant->email,
'city' => $this->tenant->city,
'postalcode'=> $this->tenant->postcode
'id' => $this->resource->id,
'name' => $this->resource->name,
'email' => $this->resource->email,
'city' => $this->resource->city,
'postalcode' => $this->resource->postcode,
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Resources;
use App\RelationModels\EventParticipationFee;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ParticipationFeeTypeResource extends JsonResource
{
public function __construct(private EventParticipationFee $participationFee) {}
public function toArray(Request $request): array
{
$return = [
'type' => new ParticipationTypeResource($this->participationFee->type()->first())->toArray($request),
'name' => $this->participationFee->name,
'description' => $this->participationFee->description,
'amount_standard' => ['internal' => $this->participationFee->amount_standard, 'readable' => $this->participationFee->amount_standard->toString()],
'amount_reduced' => ['internal' => null, 'readable' => null],
'amount_solidarity' => ['internal' => null, 'readable' => null],
];
if ($this->participationFee->amount_reduced !== null) {
$return['amount_reduced'] = ['internal' => $this->participationFee->amount_reduced, 'readable' => $this->participationFee->amount_reduced->toString()];
}
if ($this->participationFee->amount_solidarity !== null) {
$return['amount_solidarity'] = ['internal' => $this->participationFee->amount_solidarity, 'readable' => $this->participationFee->amount_solidarity->toString()];
}
return $return;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Resources;
use App\Enumerations\ParticipationType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ParticipationTypeResource extends JsonResource
{
public function __construct(private ParticipationType $participationType) {}
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return ['slug' => $this->participationType->slug, 'name' => $this->participationType->name];
}
}

View File

@@ -17,8 +17,9 @@ class UserResource extends JsonResource {
$this->user->toArray(),
[
'nicename' => $this->user->getNicename(),
'fullname' => $this->user->getFullName()
]);
'fullname' => $this->user->getFullName(),
'localGroup' => $this->user->localGroup()->id,
]);
unset($data['password']);
unset($data['remember_token']);
@@ -39,6 +40,7 @@ class UserResource extends JsonResource {
'firstname' => $this->user->firstname,
'lastname' => $this->user->lastname,
'localGroup' => $this->user->localGroup()->name,
'localGroupId' => $this->user->localGroup()->id,
];
}
}

View File

@@ -3,8 +3,18 @@
namespace App\Scopes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Resources\Json\JsonResource;
abstract class CommonModel extends Model
{
public function toResource(?string $resourceClass = null) : JsonResource {
$modelClass = class_basename($this); // z.B. "Event"
$resourceClass = "App\\Resources\\{$modelClass}Resource";
if (!class_exists($resourceClass)) {
throw new \RuntimeException("Resource {$resourceClass} not found.");
}
return new $resourceClass($this);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Scopes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Resources\Json\JsonResource;
abstract class InstancedModel extends Model
{
@@ -10,4 +11,15 @@ abstract class InstancedModel extends Model
{
static::addGlobalScope(new SiteScope());
}
public function toResource(?string $resourceClass = null) : JsonResource {
$modelClass = class_basename($this); // z.B. "Event"
$resourceClass = "App\\Resources\\{$modelClass}Resource";
if (!class_exists($resourceClass)) {
throw new \RuntimeException("Resource {$resourceClass} not found.");
}
return new $resourceClass($this);
}
}

16
app/ValueObjects/Age.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
namespace App\ValueObjects;
class Age {
function __construct(private \DateTime $birthday) {
}
public function getAge() : int {
return (new \DateTime())->diff($this->birthday)->y;
}
public function isfullAged() : bool {
return $this->getAge() >= 18;
}
}

View File

@@ -45,6 +45,11 @@ class Amount {
$this->amount -= $amount->getAmount();
}
public function multiply(float $factor) : Amount {
$this->amount *= $factor;
return $this;
}
public function getFormattedAmount() : string {
$value = number_format( round( $this->amount, 2 ), 2, ',', '.' );
return $value