diff --git a/app/Domains/Dashboard/Views/Partials/Widgets/MyParticipations.vue b/app/Domains/Dashboard/Views/Partials/Widgets/MyParticipations.vue index 08254f2..c0dd4f9 100644 --- a/app/Domains/Dashboard/Views/Partials/Widgets/MyParticipations.vue +++ b/app/Domains/Dashboard/Views/Partials/Widgets/MyParticipations.vue @@ -33,7 +33,7 @@ function navigateTo(url) { - {{participation.event.postal_code}} {{participation.event.location}}
+ {{participation.eventAddress}}
In Kalender importieren diff --git a/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php b/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php index 2203507..8b3fd18 100644 --- a/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php +++ b/app/Domains/Event/Actions/CreateEvent/CreateEventCommand.php @@ -38,6 +38,8 @@ class CreateEventCommand { 'name' => $this->request->name, 'identifier' => Str::random(10), 'location' => $this->request->location, + 'street' => $this->normalizeOptional($this->request->street), + 'house_number' => $this->normalizeOptional($this->request->houseNumber), 'postal_code' => $this->request->postalCode, 'email' => $this->request->email, 'start_date' => $this->request->begin, @@ -107,6 +109,17 @@ class CreateEventCommand { return $response; } + /** + * Straße und Hausnummer sind optional. Ein leer gelassenes Eingabefeld liefert einen leeren String -- + * gespeichert wird dafür `null`, damit „nicht angegeben" nur eine Darstellung hat. + */ + private function normalizeOptional(?string $value): ?string + { + $value = trim((string) $value); + + return '' === $value ? null : $value; + } + /** * Event-Teil der Rechnungsnummer, z.B. `WM-V-20260701`: Tenant-Präfix, Dokumentart „V" für * Veranstaltung, dann ohne Trenner Jahr, Monat des Beginns und die laufende Nummer der diff --git a/app/Domains/Event/Actions/CreateEvent/CreateEventRequest.php b/app/Domains/Event/Actions/CreateEvent/CreateEventRequest.php index dbf5ed5..8ff42f8 100644 --- a/app/Domains/Event/Actions/CreateEvent/CreateEventRequest.php +++ b/app/Domains/Event/Actions/CreateEvent/CreateEventRequest.php @@ -19,8 +19,10 @@ class CreateEventRequest { public string $accountOwner; public string $accountIban; public bool $payPerDay; + public ?string $street; + public ?string $houseNumber; - public function __construct(string $name, string $location, string $postalCode, string $email, DateTime $begin, DateTime $end, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $earlyBirdEndAmountIncrease, ParticipationFeeType $participationFeeType, string $accountOwner, string $accountIban, bool $payPerDay) { + public function __construct(string $name, string $location, string $postalCode, string $email, DateTime $begin, DateTime $end, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $earlyBirdEndAmountIncrease, ParticipationFeeType $participationFeeType, string $accountOwner, string $accountIban, bool $payPerDay, ?string $street = null, ?string $houseNumber = null) { $this->name = $name; $this->location = $location; $this->postalCode = $postalCode; @@ -34,5 +36,7 @@ class CreateEventRequest { $this->accountOwner = $accountOwner; $this->accountIban = $accountIban; $this->payPerDay = $payPerDay; + $this->street = $street; + $this->houseNumber = $houseNumber; } } diff --git a/app/Domains/Event/Actions/GenerateIcal/GenerateIcalCommand.php b/app/Domains/Event/Actions/GenerateIcal/GenerateIcalCommand.php index b5ca0c9..425a554 100644 --- a/app/Domains/Event/Actions/GenerateIcal/GenerateIcalCommand.php +++ b/app/Domains/Event/Actions/GenerateIcal/GenerateIcalCommand.php @@ -18,7 +18,7 @@ class GenerateIcalCommand $dtEnd = $event->end_date->copy()->addDay()->format('Ymd'); $now = now()->format('Ymd\THis\Z'); $summary = $this->escapeIcal($event->name); - $location = $this->escapeIcal(trim($event->postal_code . ' ' . $event->location)); + $location = $this->escapeIcal($event->getFullAddress()); $description = $this->escapeIcal('Teilnahme als: ' . $participant->getOfficialName()); $icalContent = implode("\r\n", [ diff --git a/app/Domains/Event/Actions/UpdateEvent/UpdateEventCommand.php b/app/Domains/Event/Actions/UpdateEvent/UpdateEventCommand.php index 24bd1de..b6ce8d4 100644 --- a/app/Domains/Event/Actions/UpdateEvent/UpdateEventCommand.php +++ b/app/Domains/Event/Actions/UpdateEvent/UpdateEventCommand.php @@ -13,6 +13,8 @@ class UpdateEventCommand { $this->request->event->name = $this->request->eventName; $this->request->event->location = $this->request->eventLocation; + $this->request->event->street = $this->normalizeOptional($this->request->street); + $this->request->event->house_number = $this->normalizeOptional($this->request->houseNumber); $this->request->event->postal_code = $this->request->postalCode; $this->request->event->email = $this->request->email; $this->request->event->early_bird_end = $this->request->earlyBirdEnd; @@ -42,4 +44,15 @@ class UpdateEventCommand { return $response; } + + /** + * Straße und Hausnummer sind optional. Ein geleertes Eingabefeld liefert einen leeren String -- + * gespeichert wird dafür `null`, damit „nicht angegeben" nur eine Darstellung hat. + */ + private function normalizeOptional(?string $value): ?string + { + $value = trim((string) $value); + + return '' === $value ? null : $value; + } } diff --git a/app/Domains/Event/Actions/UpdateEvent/UpdateEventRequest.php b/app/Domains/Event/Actions/UpdateEvent/UpdateEventRequest.php index 4ce1ff2..2b34a50 100644 --- a/app/Domains/Event/Actions/UpdateEvent/UpdateEventRequest.php +++ b/app/Domains/Event/Actions/UpdateEvent/UpdateEventRequest.php @@ -10,6 +10,8 @@ class UpdateEventRequest { public Event $event; public string $eventName; public string $eventLocation; + public ?string $street; + public ?string $houseNumber; public string $postalCode; public string $email; public DateTime $earlyBirdEnd; @@ -24,11 +26,13 @@ class UpdateEventRequest { 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, bool $shortRegistration = false, bool $swimmingPermissionRequired = true) + 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, ?string $street = null, ?string $houseNumber = null) { $this->event = $event; $this->eventName = $eventName; $this->eventLocation = $eventLocation; + $this->street = $street; + $this->houseNumber = $houseNumber; $this->postalCode = $postalCode; $this->email = $email; $this->earlyBirdEnd = $earlyBirdEnd; diff --git a/app/Domains/Event/Controllers/ArchivedEventsController.php b/app/Domains/Event/Controllers/ArchivedEventsController.php index 24ef421..e20a9f6 100644 --- a/app/Domains/Event/Controllers/ArchivedEventsController.php +++ b/app/Domains/Event/Controllers/ArchivedEventsController.php @@ -17,6 +17,7 @@ class ArchivedEventsController extends CommonController 'name' => $event->name, 'location' => $event->location, 'postalCode' => $event->postal_code, + 'fullAddress' => $event->getFullAddress(), 'eventBegin' => $event->start_date->format('d.m.Y'), 'eventEnd' => $event->end_date->format('d.m.Y'), ]; diff --git a/app/Domains/Event/Controllers/CreateController.php b/app/Domains/Event/Controllers/CreateController.php index ca7da14..8472a16 100644 --- a/app/Domains/Event/Controllers/CreateController.php +++ b/app/Domains/Event/Controllers/CreateController.php @@ -56,7 +56,9 @@ class CreateController extends CommonController { $participationFeeType, $request->input('eventAccount'), $request->input('eventIban'), - $payPerDay + $payPerDay, + $request->input('eventStreet'), + $request->input('eventHouseNumber') ); $wasSuccessful = false; diff --git a/app/Domains/Event/Controllers/DetailsController.php b/app/Domains/Event/Controllers/DetailsController.php index da02064..dafa548 100644 --- a/app/Domains/Event/Controllers/DetailsController.php +++ b/app/Domains/Event/Controllers/DetailsController.php @@ -63,6 +63,8 @@ class DetailsController extends CommonController { $eatingHabits, (bool)$request->input('shortRegistration', false), (bool)$request->input('swimmingPermissionRequired', true), + $request->input('street'), + $request->input('houseNumber'), ); $eventUpdateCommand = new UpdateEventCommand($eventUpdateRequest); diff --git a/app/Domains/Event/Views/ArchivedEvents.vue b/app/Domains/Event/Views/ArchivedEvents.vue index 3a167fc..caa4967 100644 --- a/app/Domains/Event/Views/ArchivedEvents.vue +++ b/app/Domains/Event/Views/ArchivedEvents.vue @@ -47,7 +47,7 @@ async function unarchiveEvent(eventId) {

{{ event.name }}

- {{ event.postalCode }} {{ event.location }} + {{ event.fullAddress }}  ·  {{ event.eventBegin }} – {{ event.eventEnd }} diff --git a/app/Domains/Event/Views/Create.vue b/app/Domains/Event/Views/Create.vue index 310cd5d..d6f7aad 100644 --- a/app/Domains/Event/Views/Create.vue +++ b/app/Domains/Event/Views/Create.vue @@ -23,6 +23,8 @@ eventName: '', eventPostalCode: '', eventLocation: '', + eventStreet: '', + eventHouseNumber: '', eventEmail: props.emailAddress ? props.emailAddress : '', eventBegin: '', eventEnd: '', @@ -145,6 +147,8 @@ eventName: formData.eventName, eventPostalCode: formData.eventPostalCode, eventLocation: formData.eventLocation, + eventStreet: formData.eventStreet, + eventHouseNumber: formData.eventHouseNumber, eventEmail: formData.eventEmail, eventBegin: formData.eventBegin, eventEnd: formData.eventEnd, @@ -195,6 +199,16 @@ + + Straße (optional) + + + + + Hausnummer (optional) + + + Postleitzahl des Veranstaltungsorts diff --git a/app/Domains/Event/Views/Partials/AvailableEvents.vue b/app/Domains/Event/Views/Partials/AvailableEvents.vue index bedd127..ab00175 100644 --- a/app/Domains/Event/Views/Partials/AvailableEvents.vue +++ b/app/Domains/Event/Views/Partials/AvailableEvents.vue @@ -22,7 +22,7 @@ const props = defineProps({

{{ event.name }}

- {{ event.postalCode }} {{ event.location }} + {{ event.fullAddress }}
Veranstaltungsort - {{ event.postalCode }} {{ event.location }} + {{ event.fullAddress }} Frühbuchen bis diff --git a/app/Domains/Event/Views/Partials/CommonSettings.vue b/app/Domains/Event/Views/Partials/CommonSettings.vue index c8ebf7d..c46e2ea 100644 --- a/app/Domains/Event/Views/Partials/CommonSettings.vue +++ b/app/Domains/Event/Views/Partials/CommonSettings.vue @@ -27,6 +27,8 @@ const emit = defineEmits(['close']) contributingLocalGroups: contributingLocalGroups.value, eventName: props.event.name, eventLocation: props.event.location, + street: props.event.street ?? '', + houseNumber: props.event.houseNumber ?? '', postalCode: props.event.postalCode, email: props.event.email, earlyBirdEnd: props.event.earlyBirdEnd.internal, @@ -59,6 +61,8 @@ const emit = defineEmits(['close']) body: { eventName: formData.eventName, eventLocation: formData.eventLocation, + street: formData.street, + houseNumber: formData.houseNumber, postalCode: formData.postalCode, email: formData.email, earlyBirdEnd: formData.earlyBirdEnd, @@ -109,6 +113,20 @@ const emit = defineEmits(['close']) + + Straße (optional) + + + + + + + Hausnummer (optional) + + + + + Postleitzahl des Veranstaltungsorts diff --git a/app/Domains/Event/Views/Signup.vue b/app/Domains/Event/Views/Signup.vue index b3af474..031c4d8 100644 --- a/app/Domains/Event/Views/Signup.vue +++ b/app/Domains/Event/Views/Signup.vue @@ -38,7 +38,7 @@ function close() {
@@ -49,7 +49,7 @@ function close() {

- 📍 {{ props.event.postalCode }} {{ props.event.location }} + 📍 {{ props.event.fullAddress }}

diff --git a/app/Models/Event.php b/app/Models/Event.php index 3fe8098..68e6a4b 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property int $cost_unit_id * @property string $name * @property string $location + * @property string|null $street + * @property string|null $house_number * @property string $postal_code * @property string $email * @property DateTime $start_date @@ -59,6 +61,8 @@ class Event extends InstancedModel 'name', 'identifier', 'location', + 'street', + 'house_number', 'postal_code', 'email', 'start_date', @@ -131,6 +135,18 @@ class Event extends InstancedModel 'swimming_permission_required' => 'boolean', ]; + /** + * Anschrift des Veranstaltungsorts in einer Zeile. Straße und Hausnummer sind optional -- fehlen sie, + * bleibt es bei der bisherigen Ausgabe "PLZ Ort". + */ + public function getFullAddress(): string + { + $city = trim($this->postal_code . ' ' . $this->location); + $street = trim($this->street . ' ' . $this->house_number); + + return '' === $street ? $city : $street . ', ' . $city; + } + public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class, 'tenant', 'slug'); diff --git a/app/Resources/EventParticipantResource.php b/app/Resources/EventParticipantResource.php index 9a679bb..83c6b24 100644 --- a/app/Resources/EventParticipantResource.php +++ b/app/Resources/EventParticipantResource.php @@ -109,6 +109,7 @@ class EventParticipantResource extends JsonResource default => 'Nicht geprüft', }, 'eventName' => $this->resource->event()->first()->name, + 'eventAddress' => $event->getFullAddress(), 'arrivalDateReadable' => $this->resource->arrival_date->format('d.m.Y'), 'departureDateReadable' => $this->resource->departure_date->format('d.m.Y'), 'participationOptions' => $this->resource->selectedOptions()->get()->map( diff --git a/app/Resources/EventResource.php b/app/Resources/EventResource.php index ac06764..c4858ff 100644 --- a/app/Resources/EventResource.php +++ b/app/Resources/EventResource.php @@ -30,7 +30,12 @@ class EventResource extends JsonResource{ 'url' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier . '/signup', 'urlShort' => 'https://' . currentTenant()->url . '/event/' . $this->event->identifier, 'location' => $this->event->location, + 'street' => $this->event->street, + 'houseNumber' => $this->event->house_number, 'postalCode' => $this->event->postal_code, + // Fertig formatierte Anschrift für alle Anzeigen -- die Einzelfelder brauchen nur die + // Bearbeitungsformulare. + 'fullAddress' => $this->event->getFullAddress(), 'email' => $this->event->email, 'accountOwner' => $this->event->account_owner, 'accountIban' => $this->event->account_iban, diff --git a/database/migrations/2026_09_16_140010_add_street_and_house_number_to_events.php b/database/migrations/2026_09_16_140010_add_street_and_house_number_to_events.php new file mode 100644 index 0000000..59cc5dc --- /dev/null +++ b/database/migrations/2026_09_16_140010_add_street_and_house_number_to_events.php @@ -0,0 +1,26 @@ +string('street')->nullable()->after('location'); + $table->string('house_number')->nullable()->after('street'); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn(['street', 'house_number']); + }); + } +}; diff --git a/tests/Feature/EventAddressTest.php b/tests/Feature/EventAddressTest.php new file mode 100644 index 0000000..1d401a6 --- /dev/null +++ b/tests/Feature/EventAddressTest.php @@ -0,0 +1,177 @@ +tenant = Tenant::create([ + 'slug' => 'tv', + 'name' => 'Test', + 'email' => 't@example.com', + 'email_finance' => 'finance@example.com', + 'url' => 'test.local', + 'account_name' => 'Test e.V.', + 'account_iban' => 'DE00', + 'account_bic' => 'XY', + 'city' => 'Stadt', + 'postcode' => '00000', + 'is_active_local_group' => true, + 'has_active_instance' => true, + 'tax_liable' => false, + 'vat_rate' => 0, + 'vat_pricing_mode' => 'inclusive', + ]); + + app()->instance('tenant', $this->tenant); + + DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']); + DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']); + DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']); + EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGAN, 'name' => 'Vegan']); + EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGETARIAN, 'name' => 'Vegetarisch']); + } + + private function createEvent(?string $street = null, ?string $houseNumber = null): Event + { + $response = new CreateEventCommand(new CreateEventRequest( + name: 'Sommerlager', + location: 'Leipzig', + postalCode: '04103', + email: 'e@example.com', + begin: new DateTime('2026-09-01'), + end: new DateTime('2026-09-05'), + earlyBirdEnd: new DateTime('2026-08-20'), + registrationFinalEnd: new DateTime('2026-08-25'), + earlyBirdEndAmountIncrease: 0, + participationFeeType: ParticipationFeeType::where('slug', 'fixed')->first(), + accountOwner: 'Owner', + accountIban: 'DE00', + payPerDay: false, + street: $street, + houseNumber: $houseNumber, + ))->execute(); + + $this->assertTrue($response->success); + + return $response->event->fresh(); + } + + private function updateEvent(Event $event, ?string $street, ?string $houseNumber): Event + { + $response = new UpdateEventCommand(new UpdateEventRequest( + event: $event, + eventName: $event->name, + eventLocation: $event->location, + postalCode: $event->postal_code, + email: $event->email, + earlyBirdEnd: new DateTime('2026-08-20'), + registrationFinalEnd: new DateTime('2026-08-25'), + alcoholicsAge: 16, + sendWeeklyReports: true, + registrationAllowed: true, + flatSupport: Amount::fromString('0,00'), + supportPerPerson: Amount::fromString('0,00'), + contributingLocalGroups: [], + eatingHabits: [], + street: $street, + houseNumber: $houseNumber, + ))->execute(); + + $this->assertTrue($response->success); + + return $event->fresh(); + } + + public function test_anlage_speichert_strasse_und_hausnummer(): void + { + $event = $this->createEvent('Musterweg', '12'); + + $this->assertSame('Musterweg', $event->street); + $this->assertSame('12', $event->house_number); + } + + public function test_anlage_ohne_angabe_laesst_beide_felder_leer(): void + { + $event = $this->createEvent(); + + $this->assertNull($event->street); + $this->assertNull($event->house_number); + } + + public function test_leere_eingaben_werden_bei_der_anlage_zu_null(): void + { + $event = $this->createEvent('', ' '); + + $this->assertNull($event->street); + $this->assertNull($event->house_number); + } + + public function test_einstellungen_ergaenzen_und_leeren_die_anschrift(): void + { + $event = $this->updateEvent($this->createEvent(), 'Musterweg', '12'); + + $this->assertSame('Musterweg', $event->street); + $this->assertSame('12', $event->house_number); + + $event = $this->updateEvent($event, '', ''); + + $this->assertNull($event->street); + $this->assertNull($event->house_number); + } + + public function test_anschrift_enthaelt_strasse_nur_wenn_sie_gefuellt_ist(): void + { + $this->assertSame('Musterweg 12, 04103 Leipzig', $this->createEvent('Musterweg', '12')->getFullAddress()); + $this->assertSame('04103 Leipzig', $this->createEvent()->getFullAddress()); + } + + public function test_resource_liefert_einzelfelder_und_fertige_anschrift(): void + { + $event = $this->createEvent('Musterweg', '12'); + + // Die Resource gibt auch die Kostenstelle aus; im Betrieb legt der Controller sie direkt nach + // der Veranstaltung an. + $event->cost_unit_id = CostUnit::create([ + 'tenant' => $this->tenant->slug, + 'name' => 'Sommerlager', + 'type' => CostUnitType::COST_UNIT_TYPE_EVENT, + 'distance_allowance' => 0.25, + 'mail_on_new' => false, + 'allow_new' => true, + 'archived' => false, + ])->id; + $event->save(); + + $resource = new EventResource($event)->toArray(new Request()); + + $this->assertSame('Musterweg', $resource['street']); + $this->assertSame('12', $resource['houseNumber']); + $this->assertSame('Musterweg 12, 04103 Leipzig', $resource['fullAddress']); + } +}