Support oft parent groups

This commit is contained in:
2026-09-29 14:35:33 +02:00
parent c4ca101594
commit a5e4e55df5
86 changed files with 1936 additions and 80 deletions
+20
View File
@@ -16,6 +16,26 @@ Routing, Mails) stehen in `.ai/conventions.md` und werden hier eingebunden — *
- [Zahlungsmodule](app/EventPaymentModules/CLAUDE.md) — interface-gesteuerte Zahlungsarten (Optionen, Config,
Anmelde-Zusammenfassung, GiroCode/Fähigkeits-Interfaces, neues Modul hinzufügen).
## Local Group / Parent Group (Stamm / Bund)
Technisch heißt es **Local Group** (Mandant/Tenant) und **Parent Group** (übergeordnete Gruppe); „Stamm"/„Bund" sind nur
UI-Bezeichnungen.
- **ParentGroup** (`App\Models\ParentGroup`, Tabelle `parent_groups`, app-weit): `tenants.parent_group_id` ist Pflicht
(n:1). `is_fallback` markiert „Ohne Zuordnung" — `Tenant::booted()` setzt sie, wenn nichts angegeben ist; sie ist nie
löschbar. Andere nur, solange weder Tenant noch Anmeldung sie nutzt (`AdminParentGroupRepository::isInUse()`).
Verwaltung LV-only unter `/admin/parent-groups`; Zuordnung am Tenant im Tab „Allgemeines" und bei der Anlage.
- **Event**: `participant_group_type` (Enum `App\Enumerations\ParticipantGroupType`: `local_group` | `parent_group`),
gewählt bei der Anlage, in den Einstellungen änderbar nur ohne Anmeldungen. **Einzige Quelle für die Auswahl**:
`Event::participantGroupOptions()` — teilnehmende Stämme oder deren Bünde (distinct). Gespeichert wird
`event_participants.local_group` (Slug) **oder** `event_participants.parent_group_id`.
- **Anzeige**: `EventParticipant::participantGroupName()` → Resource-Feld `participantGroup`; `EventResource` liefert
`participantGroupType`, `participantGroupLabel`, `participantGroupOptions` (`{id, name, value}`).
- **Bezeichnungen** in `global_settings` (Model `GlobalSetting`, Repository `GlobalSettingRepository`, im Container
`scoped` — immer über `app(...)` beziehen), Singular + Plural je Ebene, gepflegt von Hauptadmins unter
`/admin/settings`. Gelesen über `ParticipantGroupType::label()/pluralLabel()/labels()`. Texte ohne Artikel formulieren
(„Bitte bei „{label}" eine Auswahl treffen."), weil der Artikel vom Wort abhängt.
## Umsatzsteuer / Steuerinformationen
Rechtlich korrekter Begriff ist **Umsatzsteuer (USt)**, nicht MwSt. Ziel: tagesbasierte Anmelde-Rechnungen („Teilnahme
@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Admin\Actions\CreateParentGroup;
use App\Enumerations\ParticipantGroupType;
use App\Models\ParentGroup;
class CreateParentGroupAction
{
public function __construct(private CreateParentGroupRequest $request)
{
}
public function execute(): CreateParentGroupResponse
{
$response = new CreateParentGroupResponse();
$name = trim($this->request->name);
if ($name === '') {
$response->message = 'Bitte einen Namen angeben.';
return $response;
}
$response->parentGroup = ParentGroup::create([
'name' => $name,
'is_fallback' => false,
]);
$response->success = true;
$response->message = sprintf('%s „%s" wurde angelegt.', ParticipantGroupType::ParentGroup->label(), $name);
return $response;
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Domains\Admin\Actions\CreateParentGroup;
class CreateParentGroupRequest
{
public function __construct(
public string $name,
) {
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Domains\Admin\Actions\CreateParentGroup;
use App\Models\ParentGroup;
class CreateParentGroupResponse
{
public bool $success = false;
public string $message = '';
public ?ParentGroup $parentGroup = null;
}
@@ -2,6 +2,7 @@
namespace App\Domains\Admin\Actions\CreateTenant;
use App\Enumerations\ParticipantGroupType;
use App\Models\AvailablePaymentMethod;
use App\Models\PaymentMethod;
use App\Models\Tenant;
@@ -16,10 +17,17 @@ class CreateTenantAction
{
$response = new CreateTenantResponse();
if ($this->request->parentGroup === null) {
$response->message = sprintf('Bitte bei „%s" eine Auswahl treffen.', ParticipantGroupType::ParentGroup->label());
return $response;
}
$tenant = Tenant::create([
'name' => $this->request->name,
'slug' => $this->request->slug,
'url' => $this->request->url,
'parent_group_id' => $this->request->parentGroup->id,
'email' => '',
'email_finance' => '',
'account_name' => '',
@@ -46,7 +54,7 @@ class CreateTenantAction
}
$response->success = true;
$response->message = 'Stamm wurde angelegt.';
$response->message = sprintf('%s wurde angelegt.', ParticipantGroupType::LocalGroup->label());
$response->slug = $tenant->slug;
return $response;
@@ -2,12 +2,16 @@
namespace App\Domains\Admin\Actions\CreateTenant;
use App\Models\ParentGroup;
class CreateTenantRequest
{
public function __construct(
public string $name,
public string $slug,
public string $url,
/** Pflicht; null heißt, die übermittelte ID war unbekannt. */
public ?ParentGroup $parentGroup,
) {
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Domains\Admin\Actions\DeleteParentGroup;
class DeleteParentGroupAction
{
public function __construct(private DeleteParentGroupRequest $request)
{
}
public function execute(): DeleteParentGroupResponse
{
$response = new DeleteParentGroupResponse();
// Die Fallback-Gruppe nimmt Mandanten ohne Zuordnung auf und muss deshalb bestehen bleiben.
if ($this->request->parentGroup->is_fallback) {
$response->message = sprintf('„%s" kann nicht gelöscht werden.', $this->request->parentGroup->name);
return $response;
}
if ($this->request->inUse) {
$response->message = sprintf(
'„%s" ist noch zugeordnet und kann deshalb nicht gelöscht werden.',
$this->request->parentGroup->name,
);
return $response;
}
$this->request->parentGroup->delete();
$response->success = true;
$response->message = sprintf('„%s" wurde gelöscht.', $this->request->parentGroup->name);
return $response;
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Admin\Actions\DeleteParentGroup;
use App\Models\ParentGroup;
class DeleteParentGroupRequest
{
public function __construct(
public ParentGroup $parentGroup,
/** Ob die Gruppe noch von einem Mandanten oder einer Anmeldung verwendet wird. */
public bool $inUse,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\DeleteParentGroup;
class DeleteParentGroupResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Admin\Actions\RenameParentGroup;
class RenameParentGroupAction
{
public function __construct(private RenameParentGroupRequest $request)
{
}
public function execute(): RenameParentGroupResponse
{
$response = new RenameParentGroupResponse();
$name = trim($this->request->name);
if ($name === '') {
$response->message = 'Bitte einen Namen angeben.';
return $response;
}
// Umbenennen ist jederzeit möglich: Mandanten und Anmeldungen verweisen über die ID.
$this->request->parentGroup->update(['name' => $name]);
$response->success = true;
$response->message = 'Der Name wurde gespeichert.';
return $response;
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Admin\Actions\RenameParentGroup;
use App\Models\ParentGroup;
class RenameParentGroupRequest
{
public function __construct(
public ParentGroup $parentGroup,
public string $name,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\RenameParentGroup;
class RenameParentGroupResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,38 @@
<?php
namespace App\Domains\Admin\Actions\UpdateGlobalSettings;
use App\Models\GlobalSetting;
class UpdateGlobalSettingsAction
{
public function __construct(private UpdateGlobalSettingsRequest $request)
{
}
public function execute(): UpdateGlobalSettingsResponse
{
$response = new UpdateGlobalSettingsResponse();
$values = [
GlobalSetting::LOCAL_GROUP_LABEL => trim($this->request->localGroupLabel),
GlobalSetting::LOCAL_GROUP_LABEL_PLURAL => trim($this->request->localGroupLabelPlural),
GlobalSetting::PARENT_GROUP_LABEL => trim($this->request->parentGroupLabel),
GlobalSetting::PARENT_GROUP_LABEL_PLURAL => trim($this->request->parentGroupLabelPlural),
];
// Eine leere Bezeichnung ließe Überschriften und Auswahlfelder ohne Beschriftung zurück.
if (in_array('', $values, true)) {
$response->message = 'Bitte alle Bezeichnungen ausfüllen.';
return $response;
}
$this->request->globalSettings->setMany($values);
$response->success = true;
$response->message = 'Einstellungen wurden gespeichert.';
return $response;
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Admin\Actions\UpdateGlobalSettings;
use App\Repositories\GlobalSettingRepository;
class UpdateGlobalSettingsRequest
{
public function __construct(
public GlobalSettingRepository $globalSettings,
public string $localGroupLabel,
public string $localGroupLabelPlural,
public string $parentGroupLabel,
public string $parentGroupLabelPlural,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateGlobalSettings;
class UpdateGlobalSettingsResponse
{
public bool $success = false;
public string $message = '';
}
@@ -2,6 +2,8 @@
namespace App\Domains\Admin\Actions\UpdateTenantGeneral;
use App\Enumerations\ParticipantGroupType;
class UpdateTenantGeneralAction
{
public function __construct(private UpdateTenantGeneralRequest $request)
@@ -12,10 +14,17 @@ class UpdateTenantGeneralAction
{
$response = new UpdateTenantGeneralResponse();
if ($this->request->parentGroup === null) {
$response->message = sprintf('Bitte bei „%s" eine Auswahl treffen.', ParticipantGroupType::ParentGroup->label());
return $response;
}
$this->request->tenant->update([
'name' => $this->request->name,
'slug' => $this->request->slug,
'url' => $this->request->url,
'parent_group_id' => $this->request->parentGroup->id,
]);
$response->success = true;
@@ -2,6 +2,7 @@
namespace App\Domains\Admin\Actions\UpdateTenantGeneral;
use App\Models\ParentGroup;
use App\Models\Tenant;
class UpdateTenantGeneralRequest
@@ -11,6 +12,8 @@ class UpdateTenantGeneralRequest
public string $name,
public string $slug,
public string $url,
/** Pflicht; null heißt, die übermittelte ID war unbekannt. */
public ?ParentGroup $parentGroup,
) {
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Enumerations\ParticipantGroupType;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GlobalSettingsGetController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
return response()->json([
'groupLabels' => ParticipantGroupType::labels(),
]);
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class GlobalSettingsPageController extends CommonController
{
public function __invoke(Request $request): Response
{
return new InertiaProvider('Admin/GlobalSettings', [])->render();
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateGlobalSettings\UpdateGlobalSettingsAction;
use App\Domains\Admin\Actions\UpdateGlobalSettings\UpdateGlobalSettingsRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GlobalSettingsUpdateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$response = new UpdateGlobalSettingsAction(new UpdateGlobalSettingsRequest(
globalSettings: $this->globalSettings,
localGroupLabel: (string) $request->input('localGroup'),
localGroupLabelPlural: (string) $request->input('localGroupPlural'),
parentGroupLabel: (string) $request->input('parentGroup'),
parentGroupLabelPlural: (string) $request->input('parentGroupPlural'),
))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\CreateParentGroup\CreateParentGroupAction;
use App\Domains\Admin\Actions\CreateParentGroup\CreateParentGroupRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ParentGroupCreateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$response = new CreateParentGroupAction(new CreateParentGroupRequest(
name: (string) $request->input('name'),
))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\DeleteParentGroup\DeleteParentGroupAction;
use App\Domains\Admin\Actions\DeleteParentGroup\DeleteParentGroupRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ParentGroupDeleteController extends CommonController
{
public function __invoke(int $id, Request $request): JsonResponse
{
$parentGroup = $this->adminParentGroups->findById($id);
if ($parentGroup === null) {
return response()->json(['status' => 'error', 'message' => 'Eintrag nicht gefunden.'], 404);
}
$response = new DeleteParentGroupAction(new DeleteParentGroupRequest(
parentGroup: $parentGroup,
inUse: $this->adminParentGroups->isInUse($parentGroup),
))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ParentGroupListApiController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$parentGroups = $this->adminParentGroups->all()->map(fn ($parentGroup) => [
'id' => $parentGroup->id,
'name' => $parentGroup->name,
'isFallback' => $parentGroup->is_fallback,
'tenantCount' => $parentGroup->tenants_count,
'deletable' => !$parentGroup->is_fallback && !$this->adminParentGroups->isInUse($parentGroup),
]);
return response()->json(['parentGroups' => $parentGroups]);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Enumerations\ParticipantGroupType;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class ParentGroupListPageController extends CommonController
{
public function __invoke(Request $request): Response
{
return new InertiaProvider('Admin/ParentGroupList', [
'groupLabels' => ParticipantGroupType::labels(),
])->render();
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\RenameParentGroup\RenameParentGroupAction;
use App\Domains\Admin\Actions\RenameParentGroup\RenameParentGroupRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ParentGroupUpdateController extends CommonController
{
public function __invoke(int $id, Request $request): JsonResponse
{
$parentGroup = $this->adminParentGroups->findById($id);
if ($parentGroup === null) {
return response()->json(['status' => 'error', 'message' => 'Eintrag nicht gefunden.'], 404);
}
$response = new RenameParentGroupAction(new RenameParentGroupRequest(
parentGroup: $parentGroup,
name: (string) $request->input('name'),
))->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -16,6 +16,7 @@ class TenantCreateController extends CommonController
name: $request->input('name'),
slug: $request->input('slug'),
url: $request->input('url'),
parentGroup: $this->adminParentGroups->findById((int) $request->input('parent_group_id')),
));
$response = $action->execute();
@@ -2,6 +2,7 @@
namespace App\Domains\Admin\Controllers;
use App\Enumerations\ParticipantGroupType;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -17,6 +18,12 @@ class TenantGeneralGetController extends CommonController
'slug' => $tenant->slug,
'url' => $tenant->url,
'is_active_local_group' => $tenant->is_active_local_group,
'parent_group_id' => $tenant->parent_group_id,
'parentGroups' => $this->adminParentGroups->all()->map(fn ($parentGroup) => [
'id' => $parentGroup->id,
'name' => $parentGroup->name,
]),
'parentGroupLabel' => ParticipantGroupType::ParentGroup->label(),
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/general',
]);
}
@@ -19,6 +19,7 @@ class TenantGeneralUpdateController extends CommonController
name: $request->input('name'),
slug: $request->input('slug'),
url: $request->input('url'),
parentGroup: $this->adminParentGroups->findById((int) $request->input('parent_group_id')),
));
$response = $action->execute();
@@ -18,9 +18,17 @@ class TenantListApiController extends CommonController
'slug' => $tenant->slug,
'url' => $tenant->url,
'is_active_local_group' => $tenant->is_active_local_group,
'parentGroup' => $tenant->parentGroup?->name,
];
});
return response()->json(['tenants' => $mapped]);
// Auswahl für das Anlage-Formular (Pflichtangabe).
$parentGroups = $this->adminParentGroups->all()->map(fn ($parentGroup) => [
'id' => $parentGroup->id,
'name' => $parentGroup->name,
'isFallback' => $parentGroup->is_fallback,
]);
return response()->json(['tenants' => $mapped, 'parentGroups' => $parentGroups]);
}
}
@@ -2,6 +2,7 @@
namespace App\Domains\Admin\Controllers;
use App\Enumerations\ParticipantGroupType;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
@@ -11,7 +12,9 @@ class TenantListPageController extends CommonController
{
public function __invoke(Request $request): Response
{
$inertiaProvider = new InertiaProvider('Admin/TenantList', []);
$inertiaProvider = new InertiaProvider('Admin/TenantList', [
'groupLabels' => ParticipantGroupType::labels(),
]);
return $inertiaProvider->render();
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Admin\Repositories;
use App\Models\ParentGroup;
use Illuminate\Database\Eloquent\Collection;
class AdminParentGroupRepository
{
/** Alle Parent Groups, alphabetisch mit „Ohne Zuordnung" am Ende, mit der Zahl zugeordneter Mandanten. */
public function all(): Collection
{
return ParentGroup::withCount('tenants')
->ordered()
->get();
}
public function findById(int $id): ?ParentGroup
{
return ParentGroup::find($id);
}
/**
* Ob die Parent Group noch verwendet wird -- von einem Mandanten oder von einer Anmeldung. Anmeldungen sind
* mandantengebunden; gezählt wird hier bewusst über alle Mandanten hinweg.
*/
public function isInUse(ParentGroup $parentGroup): bool
{
return $parentGroup->tenants()->exists()
|| $parentGroup->eventParticipants()->withoutGlobalScopes()->exists();
}
}
+19
View File
@@ -5,6 +5,8 @@ use App\Domains\Admin\Controllers\DocumentAssetUpdateController;
use App\Domains\Admin\Controllers\DocumentTemplatesGetController;
use App\Domains\Admin\Controllers\DocumentTemplatesPreviewController;
use App\Domains\Admin\Controllers\DocumentTemplatesUpdateController;
use App\Domains\Admin\Controllers\GlobalSettingsGetController;
use App\Domains\Admin\Controllers\GlobalSettingsUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantContactGetController;
use App\Domains\Admin\Controllers\ManagedTenantContactUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantGdprGetController;
@@ -15,6 +17,10 @@ use App\Domains\Admin\Controllers\ManagedTenantPaymentGetController;
use App\Domains\Admin\Controllers\ManagedTenantPaymentUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantTaxGetController;
use App\Domains\Admin\Controllers\ManagedTenantTaxUpdateController;
use App\Domains\Admin\Controllers\ParentGroupCreateController;
use App\Domains\Admin\Controllers\ParentGroupDeleteController;
use App\Domains\Admin\Controllers\ParentGroupListApiController;
use App\Domains\Admin\Controllers\ParentGroupUpdateController;
use App\Domains\Admin\Controllers\TenantContactGetController;
use App\Domains\Admin\Controllers\TenantContactUpdateController;
use App\Domains\Admin\Controllers\TenantCreateController;
@@ -76,6 +82,12 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
Route::post('/assets', DocumentAssetUpdateController::class);
Route::delete('/assets/{name}', DocumentAssetDeleteController::class);
});
// Bezeichnungen der Gruppen-Ebenen gelten ebenfalls app-weit.
Route::prefix('api/v1/admin/settings')->group(function () {
Route::get('/', GlobalSettingsGetController::class);
Route::post('/', GlobalSettingsUpdateController::class);
});
});
Route::middleware(LvOnlyMiddleware::class)->group(function () {
@@ -97,5 +109,12 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
Route::post('/tax', ManagedTenantTaxUpdateController::class);
});
});
Route::prefix('api/v1/admin/parent-groups')->group(function () {
Route::get('/list', ParentGroupListApiController::class);
Route::post('/create', ParentGroupCreateController::class);
Route::post('/{id}', ParentGroupUpdateController::class)->whereNumber('id');
Route::delete('/{id}', ParentGroupDeleteController::class)->whereNumber('id');
});
});
});
+4
View File
@@ -2,6 +2,8 @@
use App\Domains\Admin\Controllers\AdminDashboardController;
use App\Domains\Admin\Controllers\DocumentTemplatesPageController;
use App\Domains\Admin\Controllers\GlobalSettingsPageController;
use App\Domains\Admin\Controllers\ParentGroupListPageController;
use App\Domains\Admin\Controllers\TenantEditPageController;
use App\Domains\Admin\Controllers\TenantListPageController;
use App\Domains\Admin\Controllers\TenantPageController;
@@ -21,11 +23,13 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
// App-weit gültige Dokumentvorlagen -- nur für Hauptadministrator*innen.
Route::middleware(MainAdminRoleMiddleware::class)->group(function () {
Route::get('/document-templates', DocumentTemplatesPageController::class);
Route::get('/settings', GlobalSettingsPageController::class);
});
Route::middleware(LvOnlyMiddleware::class)->group(function () {
Route::get('/tenants', TenantListPageController::class);
Route::get('/tenants/{slug}', TenantEditPageController::class);
Route::get('/parent-groups', ParentGroupListPageController::class);
});
});
});
+116
View File
@@ -0,0 +1,116 @@
<script setup>
import { ref, onMounted } from 'vue';
import AdminAppLayout from "../../../../resources/js/layouts/AdminAppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const { request } = useAjax()
const form = ref({
localGroup: '',
localGroupPlural: '',
parentGroup: '',
parentGroupPlural: '',
})
onMounted(async () => {
const response = await request('/api/v1/admin/settings')
Object.assign(form.value, response?.groupLabels ?? {})
})
async function save() {
const response = await request('/api/v1/admin/settings', {
method: 'POST',
body: form.value,
})
if (response && response.status === 'success') {
toast.success(response.message)
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
</script>
<template>
<AdminAppLayout title="Einstellungen">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<h3>Bezeichnungen der Gruppen</h3>
<p class="hint">
Gelten für alle Mandanten: in Anmeldungen, Teilnehmendenlisten, Exporten und in der Verwaltung.
</p>
<table class="data-table">
<thead>
<tr>
<th></th>
<th>Einzahl</th>
<th>Mehrzahl</th>
</tr>
</thead>
<tbody>
<tr>
<th>Gruppe (Mandant)</th>
<td><input type="text" v-model="form.localGroup" class="form-input" placeholder="z. B. Stamm" /></td>
<td><input type="text" v-model="form.localGroupPlural" class="form-input" placeholder="z. B. Stämme" /></td>
</tr>
<tr>
<th>Übergeordnete Gruppe</th>
<td><input type="text" v-model="form.parentGroup" class="form-input" placeholder="z. B. Bund" /></td>
<td><input type="text" v-model="form.parentGroupPlural" class="form-input" placeholder="z. B. Bünde" /></td>
</tr>
</tbody>
</table>
<button class="btn-save" @click="save">Speichern</button>
</shadowed-box>
</AdminAppLayout>
</template>
<style scoped>
.hint {
color: #6b7280;
font-size: 0.9rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 8px 12px;
color: #374151;
border-bottom: 1px solid #e5e7eb;
}
.data-table td {
padding: 8px 12px;
border-bottom: 1px solid #e5e7eb;
}
.form-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
}
.btn-save {
margin-top: 15px;
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
background-color: #16a34a;
color: #ffffff;
}
.btn-save:hover {
background-color: #15803d;
}
</style>
+249
View File
@@ -0,0 +1,249 @@
<script setup>
import { ref, onMounted } from 'vue';
import AdminAppLayout from "../../../../resources/js/layouts/AdminAppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const props = defineProps({
groupLabels: {
type: Object,
default: () => ({ localGroupPlural: 'Stämme', parentGroup: 'Bund', parentGroupPlural: 'Bünde' }),
},
})
const { request } = useAjax()
const parentGroups = ref([])
const newName = ref('')
const showCreate = ref(false)
// ID des Eintrags, der gerade umbenannt wird, samt Entwurf des neuen Namens.
const editingId = ref(null)
const editName = ref('')
onMounted(async () => {
await loadParentGroups()
})
async function loadParentGroups() {
const response = await request('/api/v1/admin/parent-groups/list')
parentGroups.value = response?.parentGroups ?? []
}
async function createParentGroup() {
const response = await request('/api/v1/admin/parent-groups/create', {
method: 'POST',
body: { name: newName.value },
})
if (response && response.status === 'success') {
toast.success(response.message)
newName.value = ''
showCreate.value = false
await loadParentGroups()
} else {
toast.error(response?.message ?? 'Fehler beim Anlegen')
}
}
function startEditing(parentGroup) {
editingId.value = parentGroup.id
editName.value = parentGroup.name
}
async function saveName(parentGroup) {
const response = await request('/api/v1/admin/parent-groups/' + parentGroup.id, {
method: 'POST',
body: { name: editName.value },
})
if (response && response.status === 'success') {
toast.success(response.message)
editingId.value = null
await loadParentGroups()
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
async function deleteParentGroup(parentGroup) {
if (!confirm('„' + parentGroup.name + '" wirklich löschen?')) {
return
}
const response = await request('/api/v1/admin/parent-groups/' + parentGroup.id, {
method: 'DELETE',
})
if (response && response.status === 'success') {
toast.success(response.message)
await loadParentGroups()
} else {
toast.error(response?.message ?? 'Fehler beim Löschen')
}
}
</script>
<template>
<AdminAppLayout :title="props.groupLabels.parentGroupPlural">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<table class="parent-group-table">
<thead>
<tr>
<th>Name</th>
<th>Zugeordnete {{ props.groupLabels.localGroupPlural }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="parentGroup in parentGroups" :key="parentGroup.id">
<td>
<input v-if="editingId === parentGroup.id" type="text" v-model="editName" class="form-input"
@keyup.enter="saveName(parentGroup)" />
<span v-else>{{ parentGroup.name }}</span>
</td>
<td>{{ parentGroup.tenantCount }}</td>
<td class="actions">
<template v-if="editingId === parentGroup.id">
<button class="btn-save" @click="saveName(parentGroup)">Speichern</button>
<button class="btn-cancel" @click="editingId = null">Abbrechen</button>
</template>
<template v-else>
<button class="btn-edit" @click="startEditing(parentGroup)">Umbenennen</button>
<button v-if="parentGroup.deletable" class="btn-delete" @click="deleteParentGroup(parentGroup)">Löschen</button>
</template>
</td>
</tr>
</tbody>
</table>
<p class="hint">
Löschen ist nur möglich, solange keine {{ props.groupLabels.localGroupPlural }} und keine Anmeldungen
zugeordnet sind. „Ohne Zuordnung" bleibt immer bestehen.
</p>
<div style="margin-top: 20px;">
<button v-if="!showCreate" class="btn-create" @click="showCreate = true">Neuen Eintrag anlegen</button>
<div v-if="showCreate" class="create-form">
<h3>Neuen Eintrag anlegen ({{ props.groupLabels.parentGroup }})</h3>
<input type="text" v-model="newName" class="form-input" placeholder="Name"
@keyup.enter="createParentGroup" />
<div class="btn-group">
<button class="btn-save" @click="createParentGroup">Anlegen</button>
<button class="btn-cancel" @click="showCreate = false">Abbrechen</button>
</div>
</div>
</div>
</shadowed-box>
</AdminAppLayout>
</template>
<style scoped>
.parent-group-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
}
.parent-group-table thead th {
text-align: left;
padding: 10px 16px;
background-color: #f9fafb;
color: #374151;
font-weight: bold;
border-bottom: 2px solid #d1d5db;
}
.parent-group-table tbody td {
padding: 10px 16px;
border-bottom: 1px solid #e5e7eb;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.hint {
margin-top: 12px;
color: #6b7280;
font-size: 0.9rem;
}
.create-form {
margin-top: 15px;
padding: 20px;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f9fafb;
}
.create-form h3 {
margin: 0 0 15px 0;
color: #374151;
}
.form-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
}
.btn-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn-create, .btn-save, .btn-cancel, .btn-edit, .btn-delete {
padding: 6px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
}
.btn-create, .btn-edit {
background-color: #1d4899;
color: #ffffff;
}
.btn-create:hover, .btn-edit:hover {
background-color: #163a7a;
}
.btn-save {
background-color: #16a34a;
color: #ffffff;
}
.btn-save:hover {
background-color: #15803d;
}
.btn-cancel {
background-color: #e5e7eb;
color: #374151;
}
.btn-cancel:hover {
background-color: #d1d5db;
}
.btn-delete {
background-color: #dc2626;
color: #ffffff;
}
.btn-delete:hover {
background-color: #b91c1c;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
@@ -17,8 +17,16 @@ const form = ref({
name: props.data.name ?? '',
slug: props.data.slug ?? '',
url: props.data.url ?? '',
parent_group_id: props.data.parent_group_id ?? null,
})
// Übergeordnete Gruppe (z. B. „Bund") -- Pflichtangabe, Auswahl aus der LV-seitig gepflegten Liste.
const parentGroups = props.data.parentGroups ?? []
const parentGroupLabel = props.data.parentGroupLabel ?? 'Bund'
const parentGroupName = computed(() =>
parentGroups.find(g => g.id === form.value.parent_group_id)?.name ?? '—'
)
async function save() {
const saveUrl = props.data.saveEndpoint ?? '/api/v1/admin/tenants/' + props.data.slug + '/general'
const response = await request(saveUrl, {
@@ -39,6 +47,7 @@ async function save() {
<div v-if="!editing">
<table class="data-table">
<tr><th>Name:</th><td>{{ form.name }}</td></tr>
<tr><th>{{ parentGroupLabel }}:</th><td>{{ parentGroupName }}</td></tr>
<tr><th>Slug:</th><td>{{ form.slug }}</td></tr>
<tr><th>URL:</th><td>{{ form.url }}</td></tr>
<tr><th>Status:</th><td>
@@ -54,6 +63,14 @@ async function save() {
<th>Name:</th>
<td><input type="text" v-model="form.name" class="form-input" /></td>
</tr>
<tr>
<th>{{ parentGroupLabel }}:</th>
<td>
<select v-model="form.parent_group_id" class="form-input">
<option v-for="group in parentGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</td>
</tr>
<tr>
<th>Slug:</th>
<td><input type="text" v-model="form.slug" class="form-input" /></td>
+31 -5
View File
@@ -5,6 +5,13 @@ import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const props = defineProps({
groupLabels: {
type: Object,
default: () => ({ localGroup: 'Stamm', localGroupPlural: 'Stämme', parentGroup: 'Bund' }),
},
})
const { request } = useAjax()
function openTenant(slug) {
@@ -12,12 +19,16 @@ function openTenant(slug) {
}
const tenants = ref([])
const parentGroups = ref([])
const showCreate = ref(false)
const createForm = ref({
const emptyForm = () => ({
name: '',
slug: '',
url: '',
// Pflichtangabe; vorbelegt mit „Ohne Zuordnung", sobald die Liste geladen ist.
parent_group_id: parentGroups.value.find(g => g.isFallback)?.id ?? null,
})
const createForm = ref(emptyForm())
onMounted(async () => {
await loadTenants()
@@ -33,6 +44,11 @@ async function loadTenants() {
})
const data = await response.json()
tenants.value = data.tenants
parentGroups.value = data.parentGroups ?? []
if (createForm.value.parent_group_id === null) {
createForm.value.parent_group_id = parentGroups.value.find(g => g.isFallback)?.id ?? null
}
}
async function createTenant() {
@@ -44,7 +60,7 @@ async function createTenant() {
if (response && response.status === 'success') {
toast.success(response.message)
showCreate.value = false
createForm.value = { name: '', slug: '', url: '' }
createForm.value = emptyForm()
await loadTenants()
} else {
toast.error(response?.message ?? 'Fehler beim Anlegen')
@@ -53,12 +69,13 @@ async function createTenant() {
</script>
<template>
<AdminAppLayout title="Stämme">
<AdminAppLayout :title="props.groupLabels.localGroupPlural">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<table class="tenant-table">
<thead>
<tr>
<th>Name</th>
<th>{{ props.groupLabels.parentGroup }}</th>
<th>Slug</th>
<th>URL</th>
<th>Status</th>
@@ -67,6 +84,7 @@ async function createTenant() {
<tbody>
<tr v-for="tenant in tenants" :key="tenant.slug" class="tenant-row" @click="openTenant(tenant.slug)">
<td>{{ tenant.name }}</td>
<td>{{ tenant.parentGroup ?? '—' }}</td>
<td>{{ tenant.slug }}</td>
<td>{{ tenant.url }}</td>
<td>
@@ -79,15 +97,23 @@ async function createTenant() {
</table>
<div style="margin-top: 20px;">
<button v-if="!showCreate" class="btn-create" @click="showCreate = true">Neuen Stamm anlegen</button>
<button v-if="!showCreate" class="btn-create" @click="showCreate = true">Neuen Eintrag anlegen ({{ props.groupLabels.localGroup }})</button>
<div v-if="showCreate" class="create-form">
<h3>Neuen Stamm anlegen</h3>
<h3>Neuen Eintrag anlegen ({{ props.groupLabels.localGroup }})</h3>
<table class="form-table">
<tr>
<th>Name</th>
<td><input type="text" v-model="createForm.name" class="form-input" /></td>
</tr>
<tr>
<th>{{ props.groupLabels.parentGroup }}</th>
<td>
<select v-model="createForm.parent_group_id" class="form-input">
<option v-for="group in parentGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</td>
</tr>
<tr>
<th>Slug</th>
<td><input type="text" v-model="createForm.slug" class="form-input" /></td>
@@ -62,6 +62,7 @@ class CreateEventCommand {
'vat_pricing_mode' => $tenant->vat_pricing_mode,
'tax_exemption_reason' => $tenant->tax_exemption_reason,
'tax_exemption_note' => $tenant->tax_exemption_note,
'participant_group_type' => $this->request->participantGroupType->value,
// Den Event-Teil der Rechnungsnummer einfrieren: Tenant-Präfix und Startdatum sind später
// änderbar, eine herausgegebene Rechnung würde sonst zu einer anderen Nummer gehören.
//
@@ -2,6 +2,7 @@
namespace App\Domains\Event\Actions\CreateEvent;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationFeeType;
use DateTime;
@@ -21,8 +22,11 @@ class CreateEventRequest {
public bool $payPerDay;
public ?string $street;
public ?string $houseNumber;
/** Wonach bei der Anmeldung gefragt wird: Local Group oder Parent Group. */
public ParticipantGroupType $participantGroupType;
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) {
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, ParticipantGroupType $participantGroupType = ParticipantGroupType::LocalGroup) {
$this->participantGroupType = $participantGroupType;
$this->name = $name;
$this->location = $location;
$this->postalCode = $postalCode;
@@ -4,6 +4,8 @@ namespace App\Domains\Event\Actions\ShortSignUp;
use App\Domains\Event\Actions\SignUp\SignUpCommand;
use App\Domains\Event\Actions\SignUp\SignUpRequest;
use App\Models\ParentGroup;
use App\Models\Tenant;
use App\ValueObjects\Age;
/**
@@ -53,18 +55,21 @@ class ShortSignUpCommand {
return $response;
}
// Stamm: bei genau einem teilnehmenden Stamm wird dieser gesetzt, bei mehreren muss gewählt werden,
// ohne teilnehmende Stämme bleibt die Zuordnung leer.
$localGroups = $this->request->event->localGroups;
$localGroup = null;
// Gruppe (Local oder Parent Group, je nach Veranstaltung): bei genau einer Option wird diese gesetzt, bei
// mehreren muss gewählt werden, ohne Optionen bleibt die Zuordnung leer.
$groupOptions = $this->request->event->participantGroupOptions();
$participantGroup = null;
if ($localGroups->count() === 1) {
$localGroup = $localGroups->first();
} elseif ($localGroups->count() > 1) {
$localGroup = $localGroups->firstWhere('id', $this->request->localGroupId);
if ($groupOptions->count() === 1) {
$participantGroup = $groupOptions->first();
} elseif ($groupOptions->count() > 1) {
$participantGroup = $groupOptions->firstWhere('id', $this->request->localGroupId);
if ($localGroup === null) {
$response->message = 'Bitte einen Stamm auswählen.';
if ($participantGroup === null) {
$response->message = sprintf(
'Bitte bei „%s" eine Auswahl treffen.',
$this->request->event->participantGroupType()->label(),
);
return $response;
}
}
@@ -87,7 +92,7 @@ class ShortSignUpCommand {
lastname: $this->request->lastname,
nickname: $this->request->nickname,
participationType: $participationType,
localGroup: $localGroup,
localGroup: $participantGroup instanceof Tenant ? $participantGroup : null,
birthday: $this->request->birthday,
address_1: self::PLACEHOLDER,
address_2: null,
@@ -123,6 +128,7 @@ class ShortSignUpCommand {
addonItems: [],
feeType: $this->request->feeType,
siblingReduction: false,
parentGroup: $participantGroup instanceof ParentGroup ? $participantGroup : null,
);
$signUpResponse = new SignUpCommand($signUpRequest)->execute();
@@ -61,6 +61,7 @@ class SignUpCommand {
'fee_type' => $this->request->feeType,
'sibling_reduction' => $this->request->siblingReduction,
'local_group' => $this->request->localGroup?->slug,
'parent_group_id' => $this->request->parentGroup?->id,
'birthday' => $this->request->birthday,
'address_1' => $this->request->address_1,
'address_2' => $this->request->address_2,
@@ -3,6 +3,7 @@
namespace App\Domains\Event\Actions\SignUp;
use App\Models\Event;
use App\Models\ParentGroup;
use App\Models\Tenant;
use App\ValueObjects\Amount;
use DateTime;
@@ -55,6 +56,8 @@ class SignUpRequest {
public ?string $feeType = null,
/** Ob die 50-%-Geschwisterermäßigung in `amount` eingerechnet ist. */
public bool $siblingReduction = false,
/** Parent Group -- nur bei Veranstaltungen, die danach statt nach der Local Group fragen. */
public ?ParentGroup $parentGroup = null,
) {
}
}
@@ -2,6 +2,8 @@
namespace App\Domains\Event\Actions\UpdateEvent;
use App\Repositories\EventParticipantRepository;
class UpdateEventCommand {
public UpdateEventRequest $request;
public function __construct(UpdateEventRequest $request) {
@@ -26,6 +28,14 @@ class UpdateEventCommand {
$this->request->event->registration_allowed = $this->request->registrationAllowed;
$this->request->event->short_registration = $this->request->shortRegistration;
$this->request->event->swimming_permission_required = $this->request->swimmingPermissionRequired;
// Umstellen zwischen Local und Parent Group nur ohne Anmeldungen -- sonst stünden in einer Veranstaltung
// beide Arten von Antworten nebeneinander.
if ($this->request->participantGroupType !== null
&& !new EventParticipantRepository()->existsForEvent($this->request->event)) {
$this->request->event->participant_group_type = $this->request->participantGroupType->value;
}
$this->request->event->save();
$this->request->event->resetAllowedEatingHabits();
@@ -2,6 +2,7 @@
namespace App\Domains\Event\Actions\UpdateEvent;
use App\Enumerations\ParticipantGroupType;
use App\Models\Event;
use App\ValueObjects\Amount;
use DateTime;
@@ -25,9 +26,12 @@ class UpdateEventRequest {
public Amount $supportPerPerson;
public array $contributingLocalGroups;
public array $eatingHabits;
/** Wonach bei der Anmeldung gefragt wird; null = unverändert. Greift nur, solange es keine Anmeldungen gibt. */
public ?ParticipantGroupType $participantGroupType;
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)
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, ?ParticipantGroupType $participantGroupType = null)
{
$this->participantGroupType = $participantGroupType;
$this->event = $event;
$this->eventName = $eventName;
$this->eventLocation = $eventLocation;
@@ -31,9 +31,8 @@ class UpdateParticipantCommand {
$p->address_2 = $this->request->address_2;
$p->postcode = $this->request->postcode;
$p->city = $this->request->city;
// Leerer String würde als Fremdschlüssel scheitern -- kein Stamm heißt null.
$p->local_group = $this->request->localgroup ?: null;
$p->birthday = DateTime::createFromFormat('Y-m-d', $this->request->birthday);
$this->applyParticipantGroup($p);
$p->birthday = DateTime::createFromFormat('Y-m-d', $this->request->birthday);
$p->email_1 = $this->request->email_1;
$p->phone_1 = $this->request->phone_1;
$p->contact_person = $this->request->contact_person;
@@ -87,6 +86,29 @@ class UpdateParticipantCommand {
return $this->response;
}
/**
* Übernimmt die gewählte Gruppe -- je nach Veranstaltung die Local Group (Slug) oder die Parent Group (ID).
* Angenommen wird nur, was die Veranstaltung zur Auswahl stellt; leer heißt keine Angabe (null, ein leerer
* String würde als Fremdschlüssel scheitern).
*/
private function applyParticipantGroup(EventParticipant $participant): void
{
$event = $participant->event;
$value = trim((string) $this->request->participantGroup);
if ($event->asksForParentGroup()) {
$participant->parent_group_id = $value === ''
? null
: $event->participantGroupOptions()->firstWhere('id', (int) $value)?->id;
return;
}
$participant->local_group = $value === ''
? null
: $event->participantGroupOptions()->firstWhere('slug', $value)?->slug;
}
/**
* Ersetzt die zugeordneten Zusätze (Event-Addons) anhand der Auswahl. Bewusst **ohne Preis/Betrag**
* (amount 0): der Teilnahmebeitrag wird in den Details ohnehin manuell gesetzt. Es wird nur der Titel als
@@ -14,8 +14,11 @@ class UpdateParticipantRequest {
public ?string $address_2,
public string $postcode,
public string $city,
/** Stamm-Slug; leer/null bei Anmeldungen, die keinen Stamm erfasst haben (Kurzanmeldung). */
public ?string $localgroup,
/**
* Gewählte Gruppe: Slug der Local Group bzw. ID der Parent Group, je nachdem, wonach die Veranstaltung
* fragt. Leer/null = keine Angabe.
*/
public ?string $participantGroup,
public string $birthday,
public string $email_1,
public string $phone_1,
@@ -9,6 +9,7 @@ use App\Domains\Event\Actions\CreateEvent\CreateEventRequest;
use App\Domains\Event\Actions\SetCostUnit\SetCostUnitCommand;
use App\Domains\Event\Actions\SetCostUnit\SetCostUnitRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationFeeType;
use App\Providers\InertiaProvider;
use App\Resources\EventResource;
@@ -28,6 +29,7 @@ class CreateController extends CommonController {
'participationFeeType' => $this->tenant->slug === 'lv' ?
ParticipationFeeType::PARTICIPATION_FEE_TYPE_FIXED :
ParticipationFeeType::PARTICIPATION_FEE_TYPE_SOLIDARITY,
'participantGroupTypes' => ParticipantGroupType::options(),
])->render();
}
@@ -58,7 +60,8 @@ class CreateController extends CommonController {
$request->input('eventIban'),
$payPerDay,
$request->input('eventStreet'),
$request->input('eventHouseNumber')
$request->input('eventHouseNumber'),
ParticipantGroupType::fromStored($request->input('eventParticipantGroupType')),
);
$wasSuccessful = false;
@@ -14,6 +14,7 @@ use App\Domains\Event\Actions\UpdateEvent\UpdateEventCommand;
use App\Domains\Event\Actions\UpdateEvent\UpdateEventRequest;
use App\Domains\Event\Actions\UpdateManagers\UpdateManagersCommand;
use App\Domains\Event\Actions\UpdateManagers\UpdateManagersRequest;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationFeeType;
use App\Enumerations\ParticipationType;
use App\Providers\InertiaProvider;
@@ -27,7 +28,10 @@ use Illuminate\Http\Response;
class DetailsController extends CommonController {
public function __invoke(string $eventId) {
$event = $this->events->getByIdentifier($eventId);
return new InertiaProvider('Event/Details', ['event' => $event])->render();
return new InertiaProvider('Event/Details', [
'event' => $event,
'participantGroupLabel' => $event->participantGroupType()->label(),
])->render();
}
public function summary(int $eventId, Request $request) : JsonResponse {
@@ -65,6 +69,7 @@ class DetailsController extends CommonController {
(bool)$request->input('swimmingPermissionRequired', true),
$request->input('street'),
$request->input('houseNumber'),
ParticipantGroupType::tryFrom((string) $request->input('participantGroupType')),
);
$eventUpdateCommand = new UpdateEventCommand($eventUpdateRequest);
@@ -185,6 +190,7 @@ class DetailsController extends CommonController {
'rows' => $participants,
'kitchenRequirements' => $kitchenOverview,
'participantsForKitchenList' => $this->eventParticipants->getParticipantsWithIntolerances($event, $request),
'participantGroupLabel' => $event->participantGroupType()->label(),
])->render();
$pdf = PdfGenerateAndDownloadProvider::fromHtml(
@@ -208,6 +214,9 @@ class DetailsController extends CommonController {
$csv = view('csvs.' . $listType, [
'event' => $event->name,
'rows' => $participants,
// Die Anschriftsspalten beschreiben immer die Local Group, die Gruppenspalte die gewählte Gruppe.
'localGroupLabel' => ParticipantGroupType::LocalGroup->label(),
'participantGroupLabel' => $event->participantGroupType()->label(),
])->render();
return response($csv, 200, [
@@ -220,7 +229,7 @@ class DetailsController extends CommonController {
$event = $this->events->getByIdentifier($eventId);
switch ($listType) {
case 'by-local-group':
$participants = $this->eventParticipants->groupByLocalGroup($event, $request);
$participants = $this->eventParticipants->groupByParticipantGroup($event, $request);
break;
case 'by-participation-group':
$participants = $this->eventParticipants->groupByParticipationType($event, $request);
@@ -12,7 +12,7 @@ class ByGroupController extends CommonController
$recipients = [];
switch ($groupType) {
case 'by-local-group':
$participants = $this->eventParticipants->groupByLocalGroup($event, $request, $request->input('groupName'));
$participants = $this->eventParticipants->groupByParticipantGroup($event, $request, $request->input('groupName'));
$recipients = $this->eventParticipants->getMailAddresses($participants[$request->input('groupName')]);
break;
case 'by-participation-group':
@@ -22,7 +22,7 @@ class ParticipantUpdateController extends CommonController {
address_2: $request->input('address_2'),
postcode: $request->input('postcode'),
city: $request->input('city'),
localgroup: $request->input('localgroup'),
participantGroup: $request->input('participantGroup'),
birthday: $request->input('birthday'),
email_1: $request->input('email_1'),
phone_1: $request->input('phone_1'),
@@ -7,6 +7,7 @@ use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConducti
use App\Domains\Event\Actions\SignUp\SignUpCommand;
use App\Domains\Event\Actions\SignUp\SignUpRequest;
use App\Mail\ParticipantParticipationMails\EventSignUpSuccessfullMail;
use App\Models\ParentGroup;
use App\Models\Tenant;
use App\Providers\DoubleCheckEventRegistrationProvider;
use App\Providers\InertiaProvider;
@@ -65,7 +66,7 @@ class SignupController extends CommonController {
$inertiaProvider = new InertiaProvider('Event/Signup', [
'event' => $event,
'availableEvents' => $availableEvents,
'localGroups' => $event['contributingLocalGroups'],
'localGroups' => $event['participantGroupOptions'] ?? [],
'participantData' => $participantData,
]);
return $inertiaProvider->render();
@@ -111,6 +112,15 @@ class SignupController extends CommonController {
$addonResolution = $eventResource->resolveAddons($selectedAddonKeys, $arrival, $departure);
$amount->addAmount($addonResolution['total']);
// Gewählt wird eine der Optionen der Veranstaltung (Local oder Parent Group); anderes wird abgelehnt.
$participantGroup = $event->participantGroupOptions()->firstWhere('id', (int)($registrationData['localGroup'] ?? 0));
if ($participantGroup === null) {
return response()->json([
'status' => 'error',
'message' => sprintf('Bitte bei „%s" eine Auswahl treffen.', $event->participantGroupType()->label()),
], 422);
}
$signupRequest = new SignUpRequest(
$event,
$registrationData['userId'] ?? null,
@@ -118,7 +128,7 @@ class SignupController extends CommonController {
$registrationData['nachname'],
$registrationData['pfadiname'],
$registrationData['participationType'],
Tenant::findOrFail($registrationData['localGroup']),
$participantGroup instanceof Tenant ? $participantGroup : null,
\DateTime::createFromFormat('Y-m-d', $registrationData['geburtsdatum']),
$registrationData['address1'],
$registrationData['address2'],
@@ -153,6 +163,7 @@ class SignupController extends CommonController {
$addonResolution['items'],
$registrationData['beitrag'],
$siblingReduction,
$participantGroup instanceof ParentGroup ? $participantGroup : null,
);
$signupCommand = new SignUpCommand($signupRequest);
+12
View File
@@ -15,6 +15,7 @@
'eventIban': String,
"eventPayPerDay": Boolean,
"participationFeeType": String,
"participantGroupTypes": {type: Array, default: () => []},
})
const errors = reactive({})
@@ -35,6 +36,7 @@
eventIban: props.eventIban ? props.eventIban : '',
eventPayPerDay: props.eventPayPerDay ? props.eventPayPerDay : false,
eventParticipationFeeType: props.participationFeeType ? props.participationFeeType : 'fixed',
eventParticipantGroupType: 'local_group',
});
watch(
@@ -159,6 +161,7 @@
eventIban: formData.eventIban,
eventPayPerDay: formData.eventPayPerDay,
eventParticipationFeeType: formData.eventParticipationFeeType,
eventParticipantGroupType: formData.eventParticipantGroupType,
}
});
@@ -256,6 +259,15 @@
</td>
</tr>
<tr style="vertical-align: top;">
<th class="width-medium pr-20 height-50">Bei der Anmeldung abfragen</th>
<td class="height-50">
<select v-model="formData.eventParticipantGroupType" class="width-half-full">
<option v-for="type in props.participantGroupTypes" :key="type.value" :value="type.value">{{ type.label }}</option>
</select>
</td>
</tr>
<tr style="vertical-align: top;">
<th class="width-medium pr-20 height-50">Preiserhöhung nach Early-Bird-Phase</th>
+2 -1
View File
@@ -10,6 +10,7 @@ import Overview from "./Partials/Overview.vue";
const props = defineProps({
event: Object,
participantGroupLabel: {type: String, default: 'Stamm'},
})
const tabs = [
@@ -24,7 +25,7 @@ const tabs = [
endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/all',
},
{
title: 'Teilis nach Stamm',
title: 'Teilis nach ' + props.participantGroupLabel,
component: ParticipantsList,
endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/by-local-group',
},
@@ -16,8 +16,13 @@ const emit = defineEmits(['close'])
const dynmicProps = reactive({
localGroups: [],
eatingHabits:[],
participantGroupTypes: [],
localGroupLabelPlural: 'Stämme',
});
// Umstellen zwischen Local und Parent Group nur, solange es keine Anmeldungen gibt.
const participantGroupTypeLocked = (props.event.totalParticipantCount ?? 0) > 0
const contributingLocalGroups = ref([])
const eatingHabits = ref([]);
@@ -41,6 +46,7 @@ const emit = defineEmits(['close'])
swimmingPermissionRequired: props.event.swimmingPermissionRequired,
flatSupport: props.event.flatSupportEdit,
supportPerson: props.event.supportPersonIndex,
participantGroupType: props.event.participantGroupType ?? 'local_group',
})
onMounted(async () => {
@@ -76,6 +82,7 @@ const emit = defineEmits(['close'])
supportPerson: formData.supportPerson,
contributingLocalGroups: contributingLocalGroups.value,
eatingHabits: eatingHabits.value,
participantGroupType: formData.participantGroupType,
}
})
@@ -173,6 +180,18 @@ const emit = defineEmits(['close'])
</td>
</tr>
<tr>
<th>Bei der Anmeldung abfragen</th>
<td>
<select v-model="formData.participantGroupType" class="width-full" :disabled="participantGroupTypeLocked">
<option v-for="type in dynmicProps.participantGroupTypes" :key="type.value" :value="type.value">{{ type.label }}</option>
</select>
<span v-if="participantGroupTypeLocked" style="font-size: 0.8rem; color: #6b7280;">
Nicht mehr änderbar, da bereits Anmeldungen vorliegen.
</span>
</td>
</tr>
<tr>
<th>Mindestalter für Alkoholkonsum</th>
<td>
@@ -223,7 +242,7 @@ const emit = defineEmits(['close'])
<div class="right">
<table>
<tr>
<th>Teilnehmende Stämme</th>
<th>Teilnehmende {{ dynmicProps.localGroupLabelPlural }}</th>
</tr>
<tr v-for="localGroup in dynmicProps.localGroups">
<td>
@@ -42,7 +42,7 @@ const form = reactive({
address_2: '',
postcode: '',
city: '',
localgroup: '',
participantGroup: '',
birthday: '',
email_1: '',
phone_1: '',
@@ -71,6 +71,9 @@ const form = reactive({
// Teilnahmeoptionen des Events (Definition mit Titel/Frage/Optionen).
const participationQuestions = computed(() => staticProps.event?.participationOptions ?? []);
// Bezeichnung der gewählten Gruppe (z. B. „Stamm" oder „Bund"), je nach Einstellung der Veranstaltung.
const localGroupLabel = computed(() => staticProps.event?.participantGroupLabel ?? 'Stamm');
// Zusatzoptionen (Event-Addons) des Events.
const eventAddons = computed(() => staticProps.event?.addons ?? []);
@@ -95,7 +98,7 @@ watch(
form.address_2 = participant?.address_2 ?? '';
form.postcode = participant?.postcode ?? '';
form.city = participant?.city ?? '';
form.localgroup = participant?.local_group ?? '';
form.participantGroup = participant?.participantGroupValue ?? '';
form.birthday = participant?.birthdayDate ?? '';
form.email_1 = participant?.email_1 ?? '';
form.phone_1 = participant?.phone_1 ?? '';
@@ -229,12 +232,12 @@ function saveParticipant() {
</tr>
<tr>
<th>Stamm</th>
<th>{{ localGroupLabel }}</th>
<td>
<span v-if="!staticProps.editMode">{{ props.participant.localgroup }}</span>
<select v-else v-model="form.localgroup">
<option value="">Kein Stamm</option>
<option v-for="group in staticProps.event.contributingLocalGroups" :key="group.id" :value="group.slug">{{ group.name }}</option>
<span v-if="!staticProps.editMode">{{ props.participant.participantGroup }}</span>
<select v-else v-model="form.participantGroup">
<option value="">Keine Angabe</option>
<option v-for="group in staticProps.event.participantGroupOptions" :key="group.id" :value="group.value">{{ group.name }}</option>
</select>
</td>
</tr>
@@ -162,7 +162,7 @@ async function saveParticipant(formData) {
document.getElementById('participant-' + data.participant.identifier + '-fullname').innerHTML = data.participant.fullname;
document.getElementById('participant-' + data.participant.identifier + '-birthday').innerText = data.participant.birthday;
document.getElementById('participant-' + data.participant.identifier + '-age').innerText = data.participant.age;
document.getElementById('participant-' + data.participant.identifier + '-localgroup').innerText = data.participant.localgroup;
document.getElementById('participant-' + data.participant.identifier + '-localgroup').innerText = data.participant.participantGroup;
document.getElementById('participant-' + data.participant.identifier + '-arrival').innerText = data.participant.arrival;
document.getElementById('participant-' + data.participant.identifier + '-departure').innerText = data.participant.departure;
document.getElementById('participant-' + data.participant.identifier + '-email_1').innerText = data.participant.email_1;
@@ -641,7 +641,7 @@ function mailToGroup(groupKey) {
<tr class="participant-meta-row" :id="'participant-' + participant.identifier + '-meta'">
<td colspan="5" class="pl-meta">
<label :id="'participant-' + participant.identifier +'-localgroup'">
{{ participant?.localgroup ?? "-" }}
{{ participant?.participantGroup ?? "-" }}
</label> |
<strong> Anreise: </strong>
<label :id="'participant-' + participant.identifier +'-arrival'">
@@ -13,10 +13,12 @@ export function useShortSignupForm(event, participantData) {
// Gibt es nur eine Zahlungsart, wird sie vorausgewählt und der Zahlungsschritt entfällt.
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
// Bei genau einem teilnehmenden Stamm wird dieser gesetzt, ohne danach zu fragen; erst ab zwei
// Stämmen erscheint das Auswahlfeld.
const localGroups = event.contributingLocalGroups ?? []
// Auswahl je nach Veranstaltung: teilnehmende Stämme oder deren Bünde. Bei genau einer Option wird diese
// gesetzt, ohne danach zu fragen; erst ab zwei Optionen erscheint das Auswahlfeld.
const localGroups = event.participantGroupOptions ?? []
const needsLocalGroup = localGroups.length > 1
// Vorbelegung mit dem Stamm des eingeloggten Nutzers nur, wenn er zur Auswahl gehört (bei Bund-Abfrage nie).
const preselectedGroup = localGroups.some(g => g.id === participantData.localGroup) ? participantData.localGroup : '-1'
const formData = reactive({
userId: participantData.id,
@@ -26,7 +28,7 @@ export function useShortSignupForm(event, participantData) {
geburtsdatum: participantData.birthday ?? '',
email_1: participantData.email ?? '',
telefon_1: participantData.phone ?? '',
localGroup: localGroups.length === 1 ? localGroups[0].id : (participantData.localGroup ?? '-1'),
localGroup: localGroups.length === 1 ? localGroups[0].id : preselectedGroup,
ansprechpartner: '',
email_2: '',
allergien: participantData.allergies ?? '',
@@ -11,6 +11,9 @@ const props = defineProps({
})
const emit = defineEmits(['next'])
// Bezeichnung der Auswahl (z. B. „Stamm" oder „Bund"), je nach Einstellung der Veranstaltung.
const localGroupLabel = props.event?.participantGroupLabel ?? 'Stamm'
const errors = reactive({
vorname: '',
nachname: '',
@@ -46,7 +49,7 @@ const next = () => {
}
if (props.needsLocalGroup && (!props.formData.localGroup || props.formData.localGroup === '-1')) {
errors.localGroup = 'Bitte einen Stamm auswählen.'
errors.localGroup = 'Bitte bei „' + localGroupLabel + '" eine Auswahl treffen.'
hasError = true
}
@@ -122,7 +125,7 @@ const next = () => {
</td>
</tr>
<tr v-if="needsLocalGroup">
<td>Stamm:</td>
<td>{{ localGroupLabel }}:</td>
<td>
<select v-model="formData.localGroup">
<option value="-1">Bitte wählen</option>
@@ -43,9 +43,9 @@ const selectedParticipationOptions = computed(() =>
}).filter(entry => entry.value !== '')
)
// Der Stamm steht entweder fest (genau ein teilnehmender Stamm) oder wurde auf Seite 1 gewählt.
// Die Gruppe (Stamm oder Bund) steht entweder fest (genau eine Option) oder wurde auf Seite 1 gewählt.
const localGroupName = computed(() =>
(props.event.contributingLocalGroups ?? []).find(g => g.id === props.formData.localGroup)?.name ?? null
(props.event.participantGroupOptions ?? []).find(g => g.id === props.formData.localGroup)?.name ?? null
)
const permissionName = (list, slug) =>
@@ -84,7 +84,7 @@ const canSubmit = computed(() =>
<td>{{ formData.telefon_1 }}</td>
</tr>
<tr v-if="localGroupName">
<td>Stamm:</td>
<td>{{ event.participantGroupLabel ?? 'Stamm' }}:</td>
<td>{{ localGroupName }}</td>
</tr>
@@ -92,7 +92,7 @@ const steps = [
<form @submit.prevent="submit">
<StepAge v-if="currentStep === 1" :event="event" @next="goToStep" />
<StepContactPerson v-if="currentStep === 2" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
<StepPersonalData v-if="currentStep === 3" :formData="formData" :localGroups="localGroups" @next="goToStep" @back="goToStep" />
<StepPersonalData v-if="currentStep === 3" :formData="formData" :event="event" :localGroups="localGroups" @next="goToStep" @back="goToStep" />
<StepArrival v-if="currentStep === 4" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
<StepRegistrationMode v-if="currentStep === 5" :formData="formData" :event="event" @next="goToStep" @back="goToStep" />
<StepAddons v-if="currentStep === 6" :formData="formData" :event="event" :selectedAddons="selectedAddons" @next="goToStep" @back="goToStep" />
@@ -24,7 +24,10 @@ export function useSignupForm(event, participantData) {
vorname: participantData.firstname ?? '',
nachname: participantData.lastname ?? '',
pfadiname: participantData.nickname ?? '',
localGroup: participantData.localGroup ?? '-1',
// Vorbelegung mit dem Stamm des eingeloggten Nutzers nur, wenn er zur Auswahl gehört (bei Bund-Abfrage nie).
localGroup: (event.participantGroupOptions ?? []).some(g => g.id === participantData.localGroup)
? participantData.localGroup
: '-1',
geburtsdatum: participantData.birthday ?? '',
address1: participantData.address_1 ?? '',
address2: participantData.address_2 ?? '',
@@ -2,9 +2,12 @@
import {reactive} from "vue";
import ErrorText from "../../../../../../Views/Components/ErrorText.vue";
const props = defineProps({ formData: Object, localGroups: Array })
const props = defineProps({ formData: Object, event: Object, localGroups: Array })
const emit = defineEmits(['next', 'back'])
// Bezeichnung der Auswahl (z. B. „Stamm" oder „Bund"), je nach Einstellung der Veranstaltung.
const localGroupLabel = props.event?.participantGroupLabel ?? 'Stamm'
const errors = reactive({
vorname: '',
nachname: '',
@@ -46,7 +49,7 @@ const next = () => {
}
if (props.formData.localGroup === '-1') {
errors.localGroup = 'Bitte den Stamm auswählen.'
errors.localGroup = 'Bitte bei „' + localGroupLabel + '" eine Auswahl treffen.'
hasError = true
}
@@ -90,7 +93,7 @@ const next = () => {
<tr><td>Nachname:</td><td><input type="text" v-model="props.formData.nachname" /><ErrorText :message="errors.nachname"/></td></tr>
<tr><td>Pfadiname:</td><td><input type="text" v-model="props.formData.pfadiname" /></td></tr>
<tr>
<td>Stamm:</td>
<td>{{ localGroupLabel }}:</td>
<td>
<select v-model="props.formData.localGroup">
<option value="-1">Bitte wählen</option>
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Enumerations;
use App\Models\GlobalSetting;
use App\Repositories\GlobalSettingRepository;
/**
* Wonach eine Veranstaltung bei der Anmeldung fragt: nach der Local Group (Mandant, im UI z. B. „Stamm") oder nach
* der Parent Group (übergeordnete Gruppe, im UI z. B. „Bund"). Die Bezeichnungen kommen aus den globalen
* Einstellungen.
*/
enum ParticipantGroupType: string
{
case LocalGroup = 'local_group';
case ParentGroup = 'parent_group';
public function label(): string
{
return match ($this) {
self::LocalGroup => self::setting(GlobalSetting::LOCAL_GROUP_LABEL, 'Stamm'),
self::ParentGroup => self::setting(GlobalSetting::PARENT_GROUP_LABEL, 'Bund'),
};
}
public function pluralLabel(): string
{
return match ($this) {
self::LocalGroup => self::setting(GlobalSetting::LOCAL_GROUP_LABEL_PLURAL, 'Stämme'),
self::ParentGroup => self::setting(GlobalSetting::PARENT_GROUP_LABEL_PLURAL, 'Bünde'),
};
}
/**
* Optionen für das Frontend: [['value' => ..., 'label' => ...], ...].
*
* @return array<int, array{value: string, label: string}>
*/
public static function options(): array
{
return array_map(
static fn (self $type): array => [
'value' => $type->value,
'label' => $type->label(),
],
self::cases(),
);
}
/** Typ zu einem gespeicherten Wert; Unbekanntes fällt auf die Local Group zurück. */
public static function fromStored(?string $value): self
{
return self::tryFrom((string) $value) ?? self::LocalGroup;
}
/**
* Alle Bezeichnungen fürs Frontend (Shared Prop).
*
* @return array{localGroup: string, localGroupPlural: string, parentGroup: string, parentGroupPlural: string}
*/
public static function labels(): array
{
return [
'localGroup' => self::LocalGroup->label(),
'localGroupPlural' => self::LocalGroup->pluralLabel(),
'parentGroup' => self::ParentGroup->label(),
'parentGroupPlural' => self::ParentGroup->pluralLabel(),
];
}
private static function setting(string $key, string $default): string
{
return app(GlobalSettingRepository::class)->get($key, $default);
}
}
+31
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Casts\AmountCast;
use App\Enumerations\EatingHabit;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationFeeType;
use App\RelationModels\EventParticipationFee;
use App\RelationModels\EventPaymentMethods;
@@ -13,6 +14,7 @@ use DateTime;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* @property string $tenant
@@ -50,6 +52,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property boolean $short_registration
* @property boolean $swimming_permission_required
* @property string|null $invoice_key
* @property string $participant_group_type
*/
class Event extends InstancedModel
{
@@ -101,6 +104,7 @@ class Event extends InstancedModel
'swimming_permission_required',
'invoice_key',
'participant_group_type',
];
protected $casts = [
@@ -186,6 +190,33 @@ class Event extends InstancedModel
return $this->belongsToMany(Tenant::class, 'event_local_groups', 'event_id', 'local_group_id');
}
public function participantGroupType() : ParticipantGroupType {
return ParticipantGroupType::fromStored($this->participant_group_type);
}
public function asksForParentGroup() : bool {
return $this->participantGroupType() === ParticipantGroupType::ParentGroup;
}
/**
* Was bei der Anmeldung zur Auswahl steht: die teilnehmenden Local Groups oder -- fragt die Veranstaltung nach der
* Parent Group -- deren Parent Groups, jede nur einmal (alphabetisch, „Ohne Zuordnung" zuletzt).
* Einzige Quelle für Anzeige und Prüfung der Auswahl.
*
* @return Collection<int, Tenant>|Collection<int, ParentGroup>
*/
public function participantGroupOptions() : Collection {
$localGroups = $this->localGroups()->with('parentGroup')->get();
if (!$this->asksForParentGroup()) {
return $localGroups;
}
return ParentGroup::sortForDisplay(
$localGroups->pluck('parentGroup')->filter()->unique('id')
);
}
public function eatingHabits() : BelongsToMany{
return $this->belongsToMany(EatingHabit::class, 'event_allowed_eating_habits', 'event_id', 'eating_habit_id');
}
+17
View File
@@ -38,6 +38,7 @@ class EventParticipant extends InstancedModel
'fee_type',
'sibling_reduction',
'local_group',
'parent_group_id',
'birthday',
'address_1',
@@ -143,6 +144,22 @@ class EventParticipant extends InstancedModel
return $this->belongsTo(Tenant::class, 'local_group', 'slug');
}
/** Parent Group -- nur bei Veranstaltungen gesetzt, die danach statt nach der Local Group fragen. */
public function parentGroup()
{
return $this->belongsTo(ParentGroup::class, 'parent_group_id');
}
/** Anzeigename der bei der Anmeldung gewählten Gruppe, je nachdem, wonach die Veranstaltung fragt. */
public function participantGroupName(Event $event) : string
{
if ($event->asksForParentGroup()) {
return $this->parentGroup?->name ?? 'Keine Angabe';
}
return $this->localGroup?->name ?? 'Nicht im LV';
}
public function participationType()
{
return $this->belongsTo(ParticipationType::class, 'participation_type', 'slug');
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use App\Scopes\CommonModel;
/**
* App-weite Einstellung als Schlüssel/Wert-Paar (z. B. die Bezeichnungen „Stamm"/„Bund").
* Nicht mandantengebunden; gelesen wird über {@see \App\Repositories\GlobalSettingRepository}.
*
* @property string $key
* @property string $value
*/
class GlobalSetting extends CommonModel
{
public const string LOCAL_GROUP_LABEL = 'local_group_label';
public const string LOCAL_GROUP_LABEL_PLURAL = 'local_group_label_plural';
public const string PARENT_GROUP_LABEL = 'parent_group_label';
public const string PARENT_GROUP_LABEL_PLURAL = 'parent_group_label_plural';
protected $table = 'global_settings';
protected $primaryKey = 'key';
protected $keyType = 'string';
public $incrementing = false;
protected $fillable = [
'key',
'value',
];
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use App\Scopes\CommonModel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* Übergeordnete Gruppe (Parent Group, im UI z. B. „Bund"). Mehrere Mandanten (Local Groups) gehören zur selben
* Parent Group. Nicht mandantengebunden -- die Liste gilt app-weit.
*
* @property int $id
* @property string $name
* @property boolean $is_fallback
*/
class ParentGroup extends CommonModel
{
protected $table = 'parent_groups';
protected $fillable = [
'name',
'is_fallback',
];
protected $casts = [
'is_fallback' => 'boolean',
];
/** Die Gruppe „Ohne Zuordnung" -- Vorgabe für Mandanten, denen keine Gruppe zugewiesen wurde. */
public static function fallback() : self {
return self::where('is_fallback', true)->orderBy('id')->firstOrFail();
}
/** Anzeigereihenfolge: alphabetisch, „Ohne Zuordnung" (Fallback) immer zuletzt. */
public function scopeOrdered(Builder $query) : Builder {
return $query->orderBy('is_fallback')->orderBy('name');
}
/**
* Dieselbe Reihenfolge wie {@see scopeOrdered()} für bereits geladene Gruppen.
*
* @param Collection<int, self> $parentGroups
* @return Collection<int, self>
*/
public static function sortForDisplay(Collection $parentGroups) : Collection {
return $parentGroups
->sort(static fn (self $a, self $b): int =>
[$a->is_fallback, mb_strtolower($a->name)] <=> [$b->is_fallback, mb_strtolower($b->name)])
->values();
}
public function tenants() : HasMany {
return $this->hasMany(Tenant::class, 'parent_group_id');
}
public function eventParticipants() : HasMany {
return $this->hasMany(EventParticipant::class, 'parent_group_id');
}
}
+9
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Scopes\CommonModel;
use App\Support\Text;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property string $slug
@@ -35,6 +36,7 @@ use App\Support\Text;
* @property string|null $tax_exemption_reason
* @property string|null $tax_exemption_note
* @property string $vat_pricing_mode
* @property int $parent_group_id
*/
class Tenant extends CommonModel
{
@@ -46,9 +48,15 @@ class Tenant extends CommonModel
{
static::creating(static function (self $tenant): void {
$tenant->invoice_prefix ??= strtoupper((string) $tenant->slug);
// Die Parent Group ist Pflicht; ohne Angabe landet der Mandant bei „Ohne Zuordnung".
$tenant->parent_group_id ??= ParentGroup::fallback()->id;
});
}
public function parentGroup() : BelongsTo {
return $this->belongsTo(ParentGroup::class, 'parent_group_id');
}
public static function getTempDirectory() : string {
return currentTenant()->slug . '/temp-data/';
}
@@ -95,6 +103,7 @@ class Tenant extends CommonModel
'tax_number',
'vat_id',
'invoice_prefix',
'parent_group_id',
];
protected $casts = [
+3 -1
View File
@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Repositories\GlobalSettingRepository;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
@@ -12,7 +13,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
// Eine Instanz je Request, damit die Einstellungen nur einmal gelesen werden.
$this->app->scoped(GlobalSettingRepository::class);
}
/**
+5
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Enumerations\EatingHabit;
use App\Enumerations\InvoiceType;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\Enumerations\TravelReason;
@@ -45,6 +46,8 @@ class GlobalDataProvider {
'currentEvent' => $this->getCurrentEventData(),
'canAccessAdmin' => $canAccessAdmin,
'isMainAdmin' => $isMainAdmin,
// Bezeichnungen der Gruppen-Ebenen (z. B. Stamm/Stämme, Bund/Bünde) aus den globalen Einstellungen.
'groupLabels' => ParticipantGroupType::labels(),
]);
}
@@ -222,6 +225,8 @@ class GlobalDataProvider {
[
'localGroups' => Tenant::where(['is_active_local_group' => true])->get(),
'eatingHabits' => EatingHabit::all(),
'participantGroupTypes' => ParticipantGroupType::options(),
'localGroupLabelPlural' => ParticipantGroupType::LocalGroup->pluralLabel(),
'paymentMethods' => AvailablePaymentMethod::where('active', true)->get()
->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)),
]);
@@ -73,12 +73,18 @@ class EventParticipantRepository {
->first();
}
public function groupByLocalGroup(Event $event, Request $request, ?string $filter = null) : array {
/** Ob es zur Veranstaltung schon Anmeldungen gibt -- abgemeldete eingeschlossen. */
public function existsForEvent(Event $event) : bool {
return EventParticipant::where('event_id', $event->id)->exists();
}
/** Gruppiert nach der bei der Anmeldung gewählten Gruppe (Local oder Parent Group, je nach Veranstaltung). */
public function groupByParticipantGroup(Event $event, Request $request, ?string $filter = null) : array {
$allParticipants = $this->getForList($event, $request);
$participants = [];
foreach ($allParticipants as $participant) {
if ($filter === null || $participant['localgroup'] === $filter) {
$participants[$participant['localgroup']][] = $participant;
if ($filter === null || $participant['participantGroup'] === $filter) {
$participants[$participant['participantGroup']][] = $participant;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Repositories;
use App\Models\GlobalSetting;
/**
* Zugriff auf die app-weiten Einstellungen. Im Container als `scoped` registriert -- über
* `app(GlobalSettingRepository::class)` beziehen, dann werden die Werte nur einmal pro Request gelesen
* (die Bezeichnungen werden je Teili-Zeile einer Liste gebraucht).
*/
class GlobalSettingRepository {
/** @var array<string, string>|null */
private ?array $values = null;
public function get(string $key, string $default) : string {
$value = $this->all()[$key] ?? null;
return ($value === null || trim($value) === '') ? $default : $value;
}
/** @param array<string, string> $values */
public function setMany(array $values) : void {
foreach ($values as $key => $value) {
GlobalSetting::updateOrCreate(['key' => $key], ['value' => $value]);
}
$this->values = null;
}
/** @return array<string, string> */
private function all() : array {
return $this->values ??= GlobalSetting::pluck('value', 'key')->all();
}
}
+6 -1
View File
@@ -51,7 +51,12 @@ class EventParticipantResource extends JsonResource
'unregisteredAt' => $this->resource->unregistered_at?->format('d.m.Y'),
'fullname' => $this->resource->getFullName(),
'age' => new Age($this->resource->birthday)->getAge(),
'localgroup' => $this->resource->localGroup()?->first()?->name ?? 'Nicht im LV',
// Die bei der Anmeldung gewählte Gruppe: Local Group oder Parent Group, je nach Veranstaltung.
'participantGroup' => $this->resource->participantGroupName($event),
// Wert für die Auswahl in der Verwaltung: Slug der Local Group bzw. ID der Parent Group.
'participantGroupValue' => $event->asksForParentGroup()
? ($this->resource->parent_group_id !== null ? (string) $this->resource->parent_group_id : '')
: ($this->resource->local_group ?? ''),
// Die Zuordnungen sind nullable: die Kurzanmeldung erhebt nicht alles, und in der Verwaltung
// lassen sich Werte leeren. Ohne Fallback fielen Teilnehmerliste und Detailansicht komplett aus.
'swimmingPermission' => $this->resource->swimmingPermission()->first()?->short ?? 'Unbekannt',
+14
View File
@@ -6,6 +6,7 @@ use App\Enumerations\ParticipationFeeType;
use App\Enumerations\ParticipationType;
use App\Enumerations\VatPricingMode;
use App\Models\Event;
use App\Models\ParentGroup;
use App\Support\DateRange;
use App\ValueObjects\Amount;
use DateTime;
@@ -149,6 +150,19 @@ class EventResource extends JsonResource{
$returnArray['contributingLocalGroups'] = $this->event->localGroups
->map(fn ($localGroup) => new LocalGroupResource($localGroup)->toArray($request))
->toArray();
// Wonach bei der Anmeldung gefragt wird (Local oder Parent Group), mit Bezeichnung und Auswahl.
// `id` wird bei der Anmeldung übermittelt, `value` ist der in der Verwaltung gepflegte Wert
// (Slug der Local Group bzw. ID der Parent Group).
$returnArray['participantGroupType'] = $this->event->participantGroupType()->value;
$returnArray['participantGroupLabel'] = $this->event->participantGroupType()->label();
$returnArray['participantGroupOptions'] = $this->event->participantGroupOptions()
->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'value' => $group instanceof ParentGroup ? (string) $group->id : $group->slug,
])
->values()
->toArray();
$returnArray['eatingHabits'] = $this->event->eatingHabits()->get()->map(
fn($eatingHabit) => new EatingHabitResource($eatingHabit))->toArray();
+6
View File
@@ -2,6 +2,7 @@
namespace App\Scopes;
use App\Domains\Admin\Repositories\AdminParentGroupRepository;
use App\Domains\Admin\Repositories\AdminTenantRepository;
use App\Domains\Admin\Repositories\AdminUserRepository;
use App\Models\Tenant;
@@ -10,6 +11,7 @@ use App\Repositories\CostUnitRepository;
use App\Repositories\EstimatesRepository;
use App\Repositories\EventParticipantRepository;
use App\Repositories\EventRepository;
use App\Repositories\GlobalSettingRepository;
use App\Repositories\InvoiceRepository;
use App\Repositories\PageTextRepository;
use App\Repositories\ParticipantRefundRepository;
@@ -31,6 +33,8 @@ abstract class CommonController {
protected PaymentMethodRepository $paymentMethods;
protected AdminUserRepository $adminUsers;
protected AdminTenantRepository $adminTenants;
protected AdminParentGroupRepository $adminParentGroups;
protected GlobalSettingRepository $globalSettings;
public function __construct() {
$this->tenant = currentTenant();
@@ -45,6 +49,8 @@ abstract class CommonController {
$this->paymentMethods = new PaymentMethodRepository();
$this->adminUsers = new AdminUserRepository();
$this->adminTenants = new AdminTenantRepository();
$this->adminParentGroups = new AdminParentGroupRepository();
$this->globalSettings = app(GlobalSettingRepository::class);
}
protected function checkAuth() {
@@ -0,0 +1,102 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Übergeordnete Gruppen (Parent Groups, im UI z. B. „Bund"): jeder Mandant (Local Group) gehört zu genau einer.
* Eine Veranstaltung fragt bei der Anmeldung entweder nach der Local Group oder nach der Parent Group.
*
* Die Vorbelegung steht hier und nicht im Seeder: der Fremdschlüssel am Tenant ist Pflicht, bestehende
* Installationen brauchen die Fallback-Gruppe also schon beim Migrieren.
*
* Dazu die globalen Einstellungen mit den Bezeichnungen der beiden Ebenen -- aus demselben Grund vorbelegt.
*/
public function up(): void
{
Schema::create('parent_groups', function (Blueprint $table) {
$table->id();
$table->string('name');
// Die Gruppe für Mandanten ohne Zuordnung; sie ist umbenennbar, aber nie löschbar.
$table->boolean('is_fallback')->default(false);
$table->timestamps();
});
$now = now();
$fallbackId = DB::table('parent_groups')->insertGetId([
'name' => 'Ohne Zuordnung',
'is_fallback' => true,
'created_at' => $now,
'updated_at' => $now,
]);
foreach (['BdP', 'DPV', 'DPSG', 'VCP'] as $name) {
DB::table('parent_groups')->insert([
'name' => $name,
'is_fallback' => false,
'created_at' => $now,
'updated_at' => $now,
]);
}
Schema::table('tenants', function (Blueprint $table) {
$table->foreignId('parent_group_id')->nullable()->constrained('parent_groups')->restrictOnDelete();
});
DB::table('tenants')->update(['parent_group_id' => $fallbackId]);
Schema::table('tenants', function (Blueprint $table) {
$table->foreignId('parent_group_id')->nullable(false)->change();
});
Schema::table('events', function (Blueprint $table) {
$table->string('participant_group_type')->default('local_group');
});
Schema::table('event_participants', function (Blueprint $table) {
$table->foreignId('parent_group_id')->nullable()->constrained('parent_groups')->restrictOnDelete();
});
Schema::create('global_settings', function (Blueprint $table) {
$table->string('key')->primary();
$table->text('value');
$table->timestamps();
});
foreach ([
'local_group_label' => 'Stamm',
'local_group_label_plural' => 'Stämme',
'parent_group_label' => 'Bund',
'parent_group_label_plural' => 'Bünde',
] as $key => $value) {
DB::table('global_settings')->insert([
'key' => $key,
'value' => $value,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
public function down(): void
{
Schema::dropIfExists('global_settings');
Schema::table('event_participants', function (Blueprint $table) {
$table->dropConstrainedForeignId('parent_group_id');
});
Schema::table('events', function (Blueprint $table) {
$table->dropColumn('participant_group_type');
});
Schema::table('tenants', function (Blueprint $table) {
$table->dropConstrainedForeignId('parent_group_id');
});
Schema::dropIfExists('parent_groups');
}
};
+8 -1
View File
@@ -15,6 +15,7 @@ const globalProps = reactive({
message: '',
currentEvent: null,
version: '',
groupLabels: {localGroupPlural: 'Stämme', parentGroupPlural: 'Bünde'},
});
const sidebarOpen = ref(false);
@@ -107,7 +108,10 @@ const props = defineProps({
</ul>
<ul class="nav-links">
<li v-if="globalProps.tenant.slug === 'lv'">
<a href="/admin/tenants" @click="closeSidebar">Stämme</a>
<a href="/admin/tenants" @click="closeSidebar">{{ globalProps.groupLabels.localGroupPlural }}</a>
</li>
<li v-if="globalProps.tenant.slug === 'lv'">
<a href="/admin/parent-groups" @click="closeSidebar">{{ globalProps.groupLabels.parentGroupPlural }}</a>
</li>
<li>
<a href="/admin/users" @click="closeSidebar">Benutzer*innen</a>
@@ -117,6 +121,9 @@ const props = defineProps({
<li>
<a href="/admin/document-templates" @click="closeSidebar">Rechnungsvorlage</a>
</li>
<li>
<a href="/admin/settings" @click="closeSidebar">Einstellungen</a>
</li>
</ul>
</nav>
</div>
@@ -1,10 +1,12 @@
@php
$rows = $rows ?? [];
$localGroupLabel = $localGroupLabel ?? 'Stamm';
$participantGroupLabel = $participantGroupLabel ?? $localGroupLabel;
@endphp
"Name", "Vorname", "Geburtsdatum", "Postleitzahl (Privat), "Wohnort (Privat)", "Bundesland (Privat)", "Alter", "Postleitzahl (Stamm)", "Wohnort (Stamm)", "Bundesland (Stamm)","Stamm", "Teilnahmegruppe", "Anreise", "Abreise", "Beitrag gezahlt", "Beitrag"
"Name", "Vorname", "Geburtsdatum", "Postleitzahl (Privat)", "Wohnort (Privat)", "Bundesland (Privat)", "Alter", "Postleitzahl ({{ $localGroupLabel }})", "Wohnort ({{ $localGroupLabel }})", "Bundesland ({{ $localGroupLabel }})","{{ $participantGroupLabel }}", "Teilnahmegruppe", "Anreise", "Abreise", "Beitrag gezahlt", "Beitrag"
@foreach($rows as $index => $row)
"{{ $row['lastname']}}","{{ $row['firstname'] }}","{{ $row['birthday'] }}","{{ $row['postcode'] }}","{{ $row['city'] }}","{{ $row['state'] }}","{{ $row['age'] }}","{{ $row['localGroupPostcode'] }}","{{ $row['localGroupCity'] }}","{{ $row['localGroupState'] }}","{{ $row['localgroup'] }}","{{ $row['participationType'] }}","{{ $row['arrival'] }}","{{ $row['departure'] }}","{{ $row['amountPaid']['readable'] }}","{{ $row['amountExpected']['readable'] }}"
"{{ $row['lastname']}}","{{ $row['firstname'] }}","{{ $row['birthday'] }}","{{ $row['postcode'] }}","{{ $row['city'] }}","{{ $row['state'] }}","{{ $row['age'] }}","{{ $row['localGroupPostcode'] }}","{{ $row['localGroupCity'] }}","{{ $row['localGroupState'] }}","{{ $row['participantGroup'] }}","{{ $row['participationType'] }}","{{ $row['arrival'] }}","{{ $row['departure'] }}","{{ $row['amountPaid']['readable'] }}","{{ $row['amountExpected']['readable'] }}"
@endforeach
+2 -2
View File
@@ -50,7 +50,7 @@
<thead>
<tr>
<th style="width: 2cm;">Name, Alter</th>
<th style="width: 2cm;">Stamm</th>
<th style="width: 2cm;">{{ $participantGroupLabel ?? 'Stamm' }}</th>
<th style="width: 2.5cm;">Teilnahmegruppe</th>
<th style="width: 3.5cm;">Anreise<br />Abreise</th>
<th style="width: 3.5cm;">Beitrag</th>
@@ -61,7 +61,7 @@
@foreach($chunk as $index => $row)
<tr>
<td>{{ $row['lastname']}},<br />{{ $row['firstname'] }}<br /> {{$row['age'] ?? ''}} Jahre</td>
<td>{{ $row['localgroup'] ?? '' }}</td>
<td>{{ $row['participantGroup'] ?? '' }}</td>
<td>{{ $row['participationType'] ?? '' }}</td>
<td>{{ $row['arrival'] ?? '' }}<br />{{$row['departure']}}<br />{{$row['presenceDays']['real']}} Tage</td>
<td>{{ $row['amountPaid']['readable'] ?? '' }} / {{ $row['amountExpected']['readable'] ?? '' }}</td>
+2 -2
View File
@@ -50,7 +50,7 @@
<thead>
<tr>
<th style="width: 2cm;">Name, Alter</th>
<th style="width: 2cm;">Stamm</th>
<th style="width: 2cm;">{{ $participantGroupLabel ?? 'Stamm' }}</th>
<th style="width: 3cm;">Softdrinks 1</th>
<th style="width: 3cm;">Softdrinks 2</th>
<th style="width: 3cm;">Softdrinks 3</th>
@@ -63,7 +63,7 @@
@foreach($chunk as $index => $row)
<tr>
<td>{{ $row['lastname']}},<br />{{ $row['firstname'] }}<br /> {{$row['age'] ?? ''}} Jahre</td>
<td>{{ $row['localgroup'] ?? '' }}</td>
<td>{{ $row['participantGroup'] ?? '' }}</td>
<td></td>
<td></td>
<td></td>
@@ -50,7 +50,7 @@
<thead>
<tr>
<th style="width: 2cm;">Name, Alter</th>
<th style="width: 2cm;">Stamm</th>
<th style="width: 2cm;">{{ $participantGroupLabel ?? 'Stamm' }}</th>
<th style="width: 2.5cm;">Bade-Erlaubnis</th>
<th style="width: 3.5cm;">Allergien /<br />Unvertr.</th>
<th style="width: 3.5cm;">Medikamente</th>
@@ -63,7 +63,7 @@
@foreach($chunk as $index => $row)
<tr>
<td>{{ $row['lastname']}},<br />{{ $row['firstname'] }}<br /> {{$row['age'] ?? ''}} Jahre</td>
<td>{{ $row['localgroup'] ?? '' }}</td>
<td>{{ $row['participantGroup'] ?? '' }}</td>
<td>{{ $row['swimmingPermission'] ?? '' }}</td>
<td>{{ $row['allergies'] ?? '' }}<br />{{$row['intolerances']}}</td>
<td>{{ $row['medications'] ?? '' }}</td>
+2 -2
View File
@@ -99,7 +99,7 @@
<thead>
<tr>
<th style="width: 2cm;">Name, Alter</th>
<th style="width: 2cm;">Stamm</th>
<th style="width: 2cm;">{{ $participantGroupLabel ?? 'Stamm' }}</th>
<th style="width: 2.5cm;">Teilnahmegruppe</th>
<th style="width: 3.5cm;">Anreise<br/>Abreise</th>
<th style="width: 3.5cm;">Unverträglichkeiten</th>
@@ -110,7 +110,7 @@
@foreach($chunk as $index => $row)
<tr>
<td>{{ $row['lastname']}},<br/>{{ $row['firstname'] }}<br/> {{$row['age'] ?? ''}} Jahre</td>
<td>{{ $row['localgroup'] ?? '' }}</td>
<td>{{ $row['participantGroup'] ?? '' }}</td>
<td>{{ $row['participationType'] ?? '' }}</td>
<td>{{ $row['arrival'] ?? '' }}<br/>{{$row['departure']}}<br/>{{$row['presenceDays']['real']}} Tage</td>
<td>{{ $row['intolerances'] }}</td>
@@ -50,7 +50,7 @@
<thead>
<tr>
<th style="width: 2cm;">Name, Alter</th>
<th style="width: 2cm;">Stamm</th>
<th style="width: 2cm;">{{ $participantGroupLabel ?? 'Stamm' }}</th>
<th style="width: 3.0cm;">Anreise<br />Abreise</th>
<th style="width: 3.0cm;">Fotos<br />Printmedien</th>
<th style="width: 3.0cm;">Fotos<br />Webseite</th>
@@ -64,7 +64,7 @@
@foreach($chunk as $index => $row)
<tr>
<td>{{ $row['lastname']}},<br />{{ $row['firstname'] }}<br /> {{$row['age'] ?? ''}} Jahre</td>
<td>{{ $row['localgroup'] ?? '' }}</td>
<td>{{ $row['participantGroup'] ?? '' }}</td>
<td>{{ $row['arrival'] ?? '' }}<br />{{$row['departure']}}<br />{{$row['presenceDays']['real']}} Tage</td>
<td style="text-align:center; vertical-align: middle; font-weight: bold;">{{ $row['foto_print'] ? 'Ja' : 'Nein' }}</td>
<td style="text-align:center; vertical-align: middle; font-weight: bold;">{{ $row['foto_webseite'] ? 'Ja' : 'Nein' }}</td>
+307
View File
@@ -0,0 +1,307 @@
<?php
namespace Tests\Feature;
use App\Domains\Admin\Actions\CreateParentGroup\CreateParentGroupAction;
use App\Domains\Admin\Actions\CreateParentGroup\CreateParentGroupRequest;
use App\Domains\Admin\Actions\DeleteParentGroup\DeleteParentGroupAction;
use App\Domains\Admin\Actions\DeleteParentGroup\DeleteParentGroupRequest;
use App\Domains\Admin\Actions\RenameParentGroup\RenameParentGroupAction;
use App\Domains\Admin\Actions\RenameParentGroup\RenameParentGroupRequest;
use App\Domains\Admin\Actions\UpdateGlobalSettings\UpdateGlobalSettingsAction;
use App\Domains\Admin\Actions\UpdateGlobalSettings\UpdateGlobalSettingsRequest;
use App\Domains\Admin\Repositories\AdminParentGroupRepository;
use App\Domains\Event\Actions\CreateEvent\CreateEventCommand;
use App\Domains\Event\Actions\CreateEvent\CreateEventRequest;
use App\Domains\Event\Actions\UpdateEvent\UpdateEventCommand;
use App\Domains\Event\Actions\UpdateEvent\UpdateEventRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\SwimmingPermission;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationFeeType;
use App\Models\Event;
use App\Models\ParentGroup;
use App\Models\Tenant;
use App\Repositories\GlobalSettingRepository;
use App\ValueObjects\Amount;
use DateTime;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ParentGroupTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private AdminParentGroupRepository $repository;
protected function setUp(): void
{
parent::setUp();
$this->tenant = $this->createTenant('tv', 'Test');
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']);
$this->repository = new AdminParentGroupRepository();
}
private function createTenant(string $slug, string $name, ?ParentGroup $parentGroup = null): Tenant
{
return Tenant::create([
'slug' => $slug, 'name' => $name, 'email' => $slug . '@example.com',
'email_finance' => $slug . '-f@example.com', 'url' => $slug . '.local',
'account_name' => $name, 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true,
'has_active_instance' => true, 'tax_liable' => false, 'vat_rate' => 0, 'vat_pricing_mode' => 'inclusive',
'parent_group_id' => $parentGroup?->id,
]);
}
private function parentGroup(string $name): ParentGroup
{
return ParentGroup::where('name', $name)->firstOrFail();
}
private function createEvent(ParticipantGroupType $type = ParticipantGroupType::LocalGroup): 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,
participantGroupType: $type,
))->execute();
$this->assertTrue($response->success);
return $response->event->fresh();
}
private function updateGroupType(Event $event, ParticipantGroupType $type): Event
{
new UpdateEventCommand(new UpdateEventRequest(
$event, $event->name, $event->location, $event->postal_code, $event->email,
new DateTime('2026-08-20'), new DateTime('2026-08-25'), 16, true, true,
Amount::fromString('0,00'), Amount::fromString('0,00'),
$event->localGroups->pluck('id')->all(), [],
participantGroupType: $type,
))->execute();
return $event->fresh();
}
// --- Vorbelegung und Tenant --------------------------------------------------------------------------
public function test_vorbelegung_ist_vorhanden(): void
{
$this->assertEqualsCanonicalizing(
['Ohne Zuordnung', 'BdP', 'DPV', 'DPSG', 'VCP'],
ParentGroup::pluck('name')->all(),
);
$this->assertSame('Ohne Zuordnung', ParentGroup::fallback()->name);
}
public function test_neuer_tenant_ohne_angabe_landet_bei_ohne_zuordnung(): void
{
$this->assertSame(ParentGroup::fallback()->id, $this->tenant->fresh()->parent_group_id);
}
// --- Verwaltung ----------------------------------------------------------------------------------------
public function test_anlegen_und_umbenennen(): void
{
$created = new CreateParentGroupAction(new CreateParentGroupRequest(' Pfadfinderbund '))->execute();
$this->assertTrue($created->success);
$this->assertSame('Pfadfinderbund', $created->parentGroup->name);
$renamed = new RenameParentGroupAction(new RenameParentGroupRequest($created->parentGroup, 'Neuer Name'))->execute();
$this->assertTrue($renamed->success);
$this->assertSame('Neuer Name', $created->parentGroup->fresh()->name);
}
public function test_leerer_name_wird_abgelehnt(): void
{
$this->assertFalse(new CreateParentGroupAction(new CreateParentGroupRequest(' '))->execute()->success);
$this->assertFalse(
new RenameParentGroupAction(new RenameParentGroupRequest($this->parentGroup('BdP'), ''))->execute()->success
);
$this->assertSame('BdP', $this->parentGroup('BdP')->name);
}
public function test_unbenutzte_gruppe_ist_loeschbar(): void
{
$dpv = $this->parentGroup('DPV');
$response = new DeleteParentGroupAction(new DeleteParentGroupRequest($dpv, $this->repository->isInUse($dpv)))->execute();
$this->assertTrue($response->success);
$this->assertNull(ParentGroup::find($dpv->id));
}
public function test_zugeordnete_gruppe_ist_nicht_loeschbar(): void
{
$bdp = $this->parentGroup('BdP');
$this->createTenant('stamm-a', 'Stamm A', $bdp);
$this->assertTrue($this->repository->isInUse($bdp));
$response = new DeleteParentGroupAction(new DeleteParentGroupRequest($bdp, $this->repository->isInUse($bdp)))->execute();
$this->assertFalse($response->success);
$this->assertNotNull(ParentGroup::find($bdp->id));
}
public function test_fallback_ist_nie_loeschbar(): void
{
$fallback = ParentGroup::fallback();
$response = new DeleteParentGroupAction(new DeleteParentGroupRequest($fallback, false))->execute();
$this->assertFalse($response->success);
$this->assertNotNull(ParentGroup::find($fallback->id));
}
// --- Bezeichnungen -------------------------------------------------------------------------------------
public function test_bezeichnungen_haben_vorgaben(): void
{
$this->assertSame(
['localGroup' => 'Stamm', 'localGroupPlural' => 'Stämme', 'parentGroup' => 'Bund', 'parentGroupPlural' => 'Bünde'],
ParticipantGroupType::labels(),
);
}
public function test_geaenderte_bezeichnung_wirkt_auf_enum_und_event(): void
{
$response = new UpdateGlobalSettingsAction(new UpdateGlobalSettingsRequest(
app(GlobalSettingRepository::class), 'Ortsgruppe', 'Ortsgruppen', 'Verband', 'Verbände',
))->execute();
$this->assertTrue($response->success);
$this->assertSame('Verband', ParticipantGroupType::ParentGroup->label());
$this->assertSame('Ortsgruppen', ParticipantGroupType::LocalGroup->pluralLabel());
}
public function test_leere_bezeichnung_wird_abgelehnt(): void
{
$response = new UpdateGlobalSettingsAction(new UpdateGlobalSettingsRequest(
app(GlobalSettingRepository::class), 'Ortsgruppe', '', 'Verband', 'Verbände',
))->execute();
$this->assertFalse($response->success);
$this->assertSame('Stamm', ParticipantGroupType::LocalGroup->label());
}
public function test_fehlende_einstellung_faellt_auf_vorgabe_zurueck(): void
{
DB::table('global_settings')->delete();
$this->assertSame('Bund', app(GlobalSettingRepository::class)->get('parent_group_label', 'Bund'));
$this->assertSame('Stämme', ParticipantGroupType::LocalGroup->pluralLabel());
}
// --- Event -------------------------------------------------------------------------------------------
public function test_anlage_speichert_abfrage_nach_bund(): void
{
$this->assertSame('parent_group', $this->createEvent(ParticipantGroupType::ParentGroup)->participant_group_type);
$this->assertSame('local_group', $this->createEvent()->participant_group_type);
}
public function test_optionen_sind_die_buende_der_teilnehmenden_staemme(): void
{
$event = $this->createEvent(ParticipantGroupType::ParentGroup);
$event->localGroups()->sync([
$this->createTenant('stamm-a', 'Stamm A', $this->parentGroup('VCP'))->id,
$this->createTenant('stamm-b', 'Stamm B', $this->parentGroup('BdP'))->id,
$this->createTenant('stamm-c', 'Stamm C', $this->parentGroup('BdP'))->id,
]);
// Jeder Bund nur einmal, alphabetisch.
$this->assertSame(['BdP', 'VCP'], $event->participantGroupOptions()->pluck('name')->all());
}
public function test_optionen_listen_ohne_zuordnung_zuletzt(): void
{
$event = $this->createEvent(ParticipantGroupType::ParentGroup);
$event->localGroups()->sync([
$this->createTenant('stamm-a', 'Stamm A', $this->parentGroup('VCP'))->id,
$this->createTenant('stamm-b', 'Stamm B', ParentGroup::fallback())->id,
$this->createTenant('stamm-c', 'Stamm C', $this->parentGroup('BdP'))->id,
]);
$this->assertSame(['BdP', 'VCP', 'Ohne Zuordnung'], $event->participantGroupOptions()->pluck('name')->all());
}
public function test_verwaltung_listet_alphabetisch_mit_ohne_zuordnung_zuletzt(): void
{
new CreateParentGroupAction(new CreateParentGroupRequest('Pfadfinderbund'))->execute();
$this->assertSame(
['BdP', 'DPSG', 'DPV', 'Pfadfinderbund', 'VCP', 'Ohne Zuordnung'],
$this->repository->all()->pluck('name')->all(),
);
}
public function test_optionen_sind_bei_stamm_abfrage_die_staemme(): void
{
$event = $this->createEvent();
$event->localGroups()->sync([$this->createTenant('stamm-a', 'Stamm A', $this->parentGroup('BdP'))->id]);
$this->assertSame(['Stamm A'], $event->participantGroupOptions()->pluck('name')->all());
}
public function test_umstellen_ist_ohne_anmeldungen_moeglich(): void
{
$event = $this->updateGroupType($this->createEvent(), ParticipantGroupType::ParentGroup);
$this->assertSame('parent_group', $event->participant_group_type);
}
public function test_umstellen_ist_nach_der_ersten_anmeldung_gesperrt(): void
{
$event = $this->createEvent();
// Eine Anmeldung genügt -- direkt geschrieben, der Anmeldeprozess ist hier nicht Gegenstand.
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
DB::table('event_participants')->insert([
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
'swimming_permission' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED,
'first_aid_permission' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED,
'tenant' => $this->tenant->slug, 'event_id' => $event->id, 'identifier' => 'p-1',
'firstname' => 'Hans', 'lastname' => 'Muster', 'participation_type' => 'participant',
'birthday' => '2000-01-01', 'address_1' => 'Weg 1', 'postcode' => '04103', 'city' => 'Leipzig',
'email_1' => 'h@example.com', 'phone_1' => '123', 'payment_purpose' => 'Beitrag',
'eating_habit' => EatingHabit::EATING_HABIT_VEGAN,
'arrival_date' => '2026-09-01', 'departure_date' => '2026-09-05',
'arrival_eating' => 1, 'departure_eating' => 2, 'amount' => 0,
'created_at' => now(), 'updated_at' => now(),
]);
$event = $this->updateGroupType($event, ParticipantGroupType::ParentGroup);
$this->assertSame('local_group', $event->participant_group_type);
}
}
+65 -2
View File
@@ -7,10 +7,12 @@ use App\Domains\Event\Actions\ShortSignUp\ShortSignUpRequest;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\ParentGroup;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\RelationModels\EventParticipationFee;
@@ -294,7 +296,7 @@ class ShortSignUpTest extends TestCase
$this->assertSame(0, EventParticipant::count());
}
private function createLocalGroup(string $slug, string $name): Tenant
private function createLocalGroup(string $slug, string $name, ?ParentGroup $parentGroup = null): Tenant
{
return Tenant::create([
'slug' => $slug, 'name' => $name, 'email' => $slug . '@example.com',
@@ -302,9 +304,70 @@ class ShortSignUpTest extends TestCase
'account_name' => $name, 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true,
'has_active_instance' => false,
'parent_group_id' => $parentGroup?->id,
]);
}
public function test_bund_abfrage_ordnet_einzigen_bund_ohne_auswahl_zu(): void
{
$event = $this->createEvent();
$event->update(['participant_group_type' => ParticipantGroupType::ParentGroup->value]);
$bdp = ParentGroup::where('name', 'BdP')->firstOrFail();
// Zwei Stämme desselben Bundes ergeben genau eine Option.
$event->localGroups()->attach([
$this->createLocalGroup('stamm-a', 'Stamm A', $bdp)->id,
$this->createLocalGroup('stamm-b', 'Stamm B', $bdp)->id,
]);
$event->refresh();
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame($bdp->id, $response->participant->parent_group_id);
$this->assertNull($response->participant->local_group);
$this->assertSame('BdP', $response->participant->toResource()->toArray(request())['participantGroup']);
}
public function test_bund_abfrage_uebernimmt_gewaehlten_bund(): void
{
$event = $this->createEvent();
$event->update(['participant_group_type' => ParticipantGroupType::ParentGroup->value]);
$bdp = ParentGroup::where('name', 'BdP')->firstOrFail();
$vcp = ParentGroup::where('name', 'VCP')->firstOrFail();
$event->localGroups()->attach([
$this->createLocalGroup('stamm-a', 'Stamm A', $bdp)->id,
$this->createLocalGroup('stamm-b', 'Stamm B', $vcp)->id,
]);
$event->refresh();
$response = new ShortSignUpCommand($this->shortRequest($event, [
'localGroupId' => $vcp->id,
]))->execute();
$this->assertTrue($response->success, $response->message ?? '');
$this->assertSame($vcp->id, $response->participant->parent_group_id);
}
public function test_bund_abfrage_lehnt_nicht_beteiligten_bund_ab(): void
{
$event = $this->createEvent();
$event->update(['participant_group_type' => ParticipantGroupType::ParentGroup->value]);
$event->localGroups()->attach([
$this->createLocalGroup('stamm-a', 'Stamm A', ParentGroup::where('name', 'BdP')->firstOrFail())->id,
$this->createLocalGroup('stamm-b', 'Stamm B', ParentGroup::where('name', 'VCP')->firstOrFail())->id,
]);
$event->refresh();
// Der Bund existiert, aber kein teilnehmender Stamm gehört zu ihm.
$response = new ShortSignUpCommand($this->shortRequest($event, [
'localGroupId' => ParentGroup::where('name', 'DPSG')->firstOrFail()->id,
]))->execute();
$this->assertFalse($response->success);
$this->assertSame('Bitte bei „Bund" eine Auswahl treffen.', $response->message);
$this->assertSame(0, EventParticipant::count());
}
public function test_einziger_stamm_wird_ohne_auswahl_zugeordnet(): void
{
$event = $this->createEvent();
@@ -377,7 +440,7 @@ class ShortSignUpTest extends TestCase
// Regressionswächter für Teilnehmerliste, Detailansicht und CSV-Export.
$array = $response->participant->toResource()->toArray(request());
$this->assertSame('Nicht im LV', $array['localgroup']);
$this->assertSame('Nicht im LV', $array['participantGroup']);
$this->assertSame('Erteilt', $array['swimmingPermission']);
$this->assertSame('Zugestimmt', $array['extendedFirstAid']);
$this->assertSame('Omnivor', $array['eatingHabit']);
+1 -1
View File
@@ -1 +1 @@
4.9.2
4.10.0