Compare commits
5
Commits
985c9ce07e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e9ea0cfd5 | ||
|
|
bcebe2ebed | ||
|
|
a43fd9cedf | ||
|
|
a5e4e55df5 | ||
|
|
c4ca101594 |
No files matched your search
@@ -16,6 +16,32 @@ 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.
|
||||
|
||||
## eFZ-Prüfung pro Veranstaltung
|
||||
|
||||
`events.coc_check_required` (Default `true`, Checkbox in `CommonSettings.vue`, Resource-Feld `cocCheckRequired`). Ist
|
||||
sie aus, liefert `CertificateOfConductionCheckCommand` ohne API-Aufruf `not_required`, und `SignUpCommand` belegt
|
||||
`efz_status` mit `not_required` vor. Wirkt nur auf neue Anmeldungen, bestehende Status bleiben.
|
||||
|
||||
## 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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+6
@@ -12,6 +12,12 @@ class CertificateOfConductionCheckCommand {
|
||||
public function execute() : CertificateOfConductionCheckResponse {
|
||||
$response = new CertificateOfConductionCheckResponse();
|
||||
|
||||
// Verlangt die Veranstaltung kein eFZ, entfällt die Prüfung -- ohne Aufruf der Prüf-API.
|
||||
if (!$this->request->participant->event()->first()?->coc_check_required) {
|
||||
$response->status = EfzStatus::EFZ_STATUS_NOT_REQUIRED;
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Der Stamm ist optional (die Kurzanmeldung erhebt ihn nicht, in der Verwaltung kann er entfernt werden).
|
||||
$localGroup = str_replace('Stamm ', '', $this->request->participant->localGroup()->first()?->name ?? '');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+33
-5
@@ -5,6 +5,7 @@ namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
|
||||
use App\Enumerations\ParticipationType;
|
||||
use App\Models\CostUnit;
|
||||
use App\Models\Event;
|
||||
use App\Models\Invoice;
|
||||
use App\Providers\PdfGenerateAndDownloadProvider;
|
||||
use App\Repositories\CostUnitRepository;
|
||||
use App\ValueObjects\Amount;
|
||||
@@ -52,8 +53,9 @@ class CreateIncomeSurplusStatementCommand
|
||||
|
||||
$eventData = $this->event->toResource()->toArray(new Request());
|
||||
|
||||
$income = $this->buildIncome($eventData, $otherIncome);
|
||||
$expenses = $this->buildExpenses($costUnit);
|
||||
$donations = $this->buildExpenses($costUnit, donationsOnly: true);
|
||||
$income = $this->buildIncome($eventData, $otherIncome, $donations['total']);
|
||||
|
||||
$result = new Amount($income['total']->getAmount() - $expenses['total']->getAmount(), 'Euro');
|
||||
|
||||
@@ -62,6 +64,7 @@ class CreateIncomeSurplusStatementCommand
|
||||
'createdAt' => new \DateTime()->format('d.m.Y'),
|
||||
'income' => $income,
|
||||
'expenses' => $expenses,
|
||||
'donations' => $donations,
|
||||
'result' => $result,
|
||||
'money' => self::money(...),
|
||||
])->render();
|
||||
@@ -70,6 +73,7 @@ class CreateIncomeSurplusStatementCommand
|
||||
$response->filename = 'EUER-' . $this->event->identifier . '.pdf';
|
||||
$response->income = $income;
|
||||
$response->expenses = $expenses;
|
||||
$response->donations = $donations;
|
||||
$response->result = $result;
|
||||
$response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait');
|
||||
|
||||
@@ -87,7 +91,7 @@ class CreateIncomeSurplusStatementCommand
|
||||
*
|
||||
* @return array{categories: array<int, array{name: string, total: Amount, entries: array<int, array{name: string, amount: Amount}>}>, total: Amount}
|
||||
*/
|
||||
private function buildIncome(array $eventData, float $otherIncome): array
|
||||
private function buildIncome(array $eventData, float $otherIncome, Amount $donations): array
|
||||
{
|
||||
// Beiträge aller Teilnahmearten in einer Zeile: Für die Mittelverwendung zählt, was an Beiträgen
|
||||
// hereingekommen ist, nicht von wem.
|
||||
@@ -118,6 +122,8 @@ class CreateIncomeSurplusStatementCommand
|
||||
'name' => 'Fördermittel (' . self::money($supportRate) . ' € p.P./Tag)',
|
||||
'amount' => new Amount($eventData['supportPerson']['amount']->getAmount(), 'Euro'),
|
||||
],
|
||||
// Gespendete Auslagen: als Ausgabe ihres Typs gebucht, hier die Gegenbuchung.
|
||||
['name' => 'Spenden', 'amount' => $donations],
|
||||
];
|
||||
|
||||
$categories = [
|
||||
@@ -134,14 +140,17 @@ class CreateIncomeSurplusStatementCommand
|
||||
/**
|
||||
* Die Ausgabenseite: eine Zeile je Ausgabentyp, dazu die Belege für die Anlage.
|
||||
*
|
||||
* Mit `$donationsOnly` nur die gespendeten Auslagen in derselben Gliederung -- für die Anlage „Spenden"
|
||||
* und die Summe unter den Förderungen.
|
||||
*
|
||||
* @return array{groups: array<int, array{name: string, sum: Amount, rows: array<int, array{number: string, date: string, purpose: string, amount: Amount}>}>, total: Amount}
|
||||
*/
|
||||
private function buildExpenses(CostUnit $costUnit): array
|
||||
private function buildExpenses(CostUnit $costUnit, bool $donationsOnly = false): array
|
||||
{
|
||||
$groups = [];
|
||||
$total = new Amount(0, 'Euro');
|
||||
|
||||
foreach ($this->costUnits->groupExpensesByType($costUnit) as $group) {
|
||||
foreach ($this->costUnits->groupExpensesByType($costUnit, $donationsOnly) as $group) {
|
||||
$rows = [];
|
||||
|
||||
foreach ($group['invoices'] as $invoice) {
|
||||
@@ -150,7 +159,7 @@ class CreateIncomeSurplusStatementCommand
|
||||
'date' => $invoice->created_at?->format('d.m.Y') ?? '',
|
||||
// Ohne die Anmerkung: dort steht, was die Kassenwart*in beim Korrigieren notiert hat,
|
||||
// und das gehört auf den Beleg, nicht in den Zweck.
|
||||
'purpose' => $invoice->purposeText(),
|
||||
'purpose' => self::purpose($invoice),
|
||||
'amount' => Amount::fromString($invoice->amount),
|
||||
];
|
||||
}
|
||||
@@ -167,6 +176,25 @@ class CreateIncomeSurplusStatementCommand
|
||||
return ['groups' => $groups, 'total' => $total];
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Zweck eines Belegs für die Anlage.
|
||||
*
|
||||
* Erfasster oder abgeleiteter Zahlungsgrund wie in der Beleg-Übersicht. Nur wenn es keinen gibt --
|
||||
* ältere Abrechnungen ohne jede Zweckangabe --, steht dort ersatzweise, wem was erstattet wurde. Bewusst
|
||||
* hier und nicht in {@see Invoice::purposeText()}: Dort würde der Ersatztext als erfasster Zahlungsgrund
|
||||
* erscheinen und beim Korrigieren als Änderung protokolliert.
|
||||
*/
|
||||
private static function purpose(Invoice $invoice): string
|
||||
{
|
||||
$purpose = $invoice->purposeText();
|
||||
|
||||
if (trim($purpose) !== '') {
|
||||
return $purpose;
|
||||
}
|
||||
|
||||
return trim('Auslagenerstattung ' . $invoice->contact_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Betrag in deutscher Schreibweise: Punkt als Tausender-, Komma als Dezimaltrennzeichen.
|
||||
*
|
||||
|
||||
+7
@@ -29,5 +29,12 @@ class CreateIncomeSurplusStatementResponse
|
||||
*/
|
||||
public array $expenses = [];
|
||||
|
||||
/**
|
||||
* Die gespendeten Auslagen, gegliedert wie die Ausgaben -- die Gegenbuchung zur Zeile „Spenden".
|
||||
*
|
||||
* @var array{groups: array<int, array{name: string, sum: Amount, rows: array<int, array<string, mixed>>}>, total: Amount}|array{}
|
||||
*/
|
||||
public array $donations = [];
|
||||
|
||||
public ?Amount $result = null;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -37,11 +37,7 @@ class SignUpCommand {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$eatingHabit = match ($this->request->eating_habit) {
|
||||
'vegan' => EatingHabit::EATING_HABIT_VEGAN,
|
||||
'vegetarian' => EatingHabit::EATING_HABIT_VEGETARIAN,
|
||||
default => EatingHabit::EATING_HABIT_OMNIVOR,
|
||||
};
|
||||
$eatingHabit = $this->resolveEatingHabit();
|
||||
|
||||
$participantAge = new Age($this->request->birthday);
|
||||
|
||||
@@ -61,6 +57,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,
|
||||
@@ -94,7 +91,9 @@ class SignUpCommand {
|
||||
'payment_purpose' => $this->request->event->name . ' - Beitrag ' . $this->request->firstname . ' ' . $this->request->lastname,
|
||||
'payment_method' => $this->request->paymentMethod,
|
||||
'payment_options' => $paymentOptions,
|
||||
'efz_status' => $participantAge->isfullAged() ? EfzStatus::EFZ_STATUS_NOT_CHECKED : EfzStatus::EFZ_STATUS_NOT_REQUIRED,
|
||||
'efz_status' => $participantAge->isfullAged() && $this->request->event->coc_check_required
|
||||
? EfzStatus::EFZ_STATUS_NOT_CHECKED
|
||||
: EfzStatus::EFZ_STATUS_NOT_REQUIRED,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -108,6 +107,21 @@ class SignUpCommand {
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ernährungsweise auflösen. Übernommen wird nur ein Slug, den die Veranstaltung anbietet; ohne Angabe (z. B. bei
|
||||
* der Kurzanmeldung) oder bei einem nicht angebotenen Wert gilt "Omnivor".
|
||||
*/
|
||||
private function resolveEatingHabit(): string
|
||||
{
|
||||
$slug = $this->request->eating_habit;
|
||||
|
||||
if ($slug !== '' && $this->request->event->eatingHabits()->where('slug', $slug)->exists()) {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
return EatingHabit::EATING_HABIT_OMNIVOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badeerlaubnis auflösen. Volljährige brauchen keine Erlaubnis. Für Minderjährige gilt die getroffene Auswahl;
|
||||
* fehlt sie -- weil die Veranstaltung die Badeerlaubnis gar nicht abfragt oder keine Wahl getroffen wurde --,
|
||||
|
||||
@@ -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,15 @@ 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;
|
||||
$this->request->event->coc_check_required = $this->request->cocCheckRequired;
|
||||
|
||||
// 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;
|
||||
@@ -21,13 +22,18 @@ class UpdateEventRequest {
|
||||
public bool $registrationAllowed;
|
||||
public bool $shortRegistration;
|
||||
public bool $swimmingPermissionRequired;
|
||||
public bool $cocCheckRequired;
|
||||
public Amount $flatSupport;
|
||||
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, bool $cocCheckRequired = true)
|
||||
{
|
||||
$this->participantGroupType = $participantGroupType;
|
||||
$this->cocCheckRequired = $cocCheckRequired;
|
||||
$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,8 @@ class DetailsController extends CommonController {
|
||||
(bool)$request->input('swimmingPermissionRequired', true),
|
||||
$request->input('street'),
|
||||
$request->input('houseNumber'),
|
||||
ParticipantGroupType::tryFrom((string) $request->input('participantGroupType')),
|
||||
(bool)$request->input('cocCheckRequired', true),
|
||||
);
|
||||
|
||||
$eventUpdateCommand = new UpdateEventCommand($eventUpdateRequest);
|
||||
@@ -185,6 +191,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 +215,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 +230,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'],
|
||||
@@ -133,7 +143,7 @@ class SignupController extends CommonController {
|
||||
$registrationData['intolerances'],
|
||||
$registrationData['medikamente'],
|
||||
$tetanusVaccination,
|
||||
$registrationData['essgewohnheit'],
|
||||
$registrationData['eatingHabit'] ?? '',
|
||||
$registrationData['badeerlaubnis'],
|
||||
$registrationData['first_aid'],
|
||||
$registrationData['foto']['socialmedia'],
|
||||
@@ -153,6 +163,7 @@ class SignupController extends CommonController {
|
||||
$addonResolution['items'],
|
||||
$registrationData['beitrag'],
|
||||
$siblingReduction,
|
||||
$participantGroup instanceof ParentGroup ? $participantGroup : null,
|
||||
);
|
||||
|
||||
$signupCommand = new SignUpCommand($signupRequest);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -39,8 +44,10 @@ const emit = defineEmits(['close'])
|
||||
registrationAllowed: props.event.registrationAllowed,
|
||||
shortRegistration: props.event.shortRegistration,
|
||||
swimmingPermissionRequired: props.event.swimmingPermissionRequired,
|
||||
cocCheckRequired: props.event.cocCheckRequired,
|
||||
flatSupport: props.event.flatSupportEdit,
|
||||
supportPerson: props.event.supportPersonIndex,
|
||||
participantGroupType: props.event.participantGroupType ?? 'local_group',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -72,10 +79,12 @@ const emit = defineEmits(['close'])
|
||||
registrationAllowed: formData.registrationAllowed,
|
||||
shortRegistration: formData.shortRegistration,
|
||||
swimmingPermissionRequired: formData.swimmingPermissionRequired,
|
||||
cocCheckRequired: formData.cocCheckRequired,
|
||||
flatSupport: formData.flatSupport,
|
||||
supportPerson: formData.supportPerson,
|
||||
contributingLocalGroups: contributingLocalGroups.value,
|
||||
eatingHabits: eatingHabits.value,
|
||||
participantGroupType: formData.participantGroupType,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -173,6 +182,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>
|
||||
@@ -215,6 +236,16 @@ const emit = defineEmits(['close'])
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<input type="checkbox" v-model="formData.cocCheckRequired" id="cocCheckRequired" />
|
||||
<label for="cocCheckRequired">Erweitertes Führungszeugnis (eFZ) prüfen</label><br />
|
||||
<span style="font-size: 0.8rem; color: #6b7280;">
|
||||
Ist die Prüfung deaktiviert, wird das eFZ bei neuen Anmeldungen als „Nicht erforderlich" hinterlegt.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -223,7 +254,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" />
|
||||
|
||||
@@ -13,8 +13,13 @@ export function useSignupForm(event, participantData) {
|
||||
// Für die Aktion freigeschaltete Zahlungsmethoden; ist genau eine aktiv, wird sie vorausgewählt.
|
||||
const activeMethods = (event.paymentMethods ?? []).filter(m => m.active)
|
||||
|
||||
// Vorbelegung mit der Ernährungsweise aus dem Profil, sofern die Veranstaltung sie anbietet, sonst die erste Option.
|
||||
const eatingHabitSlugs = (event.eatingHabits ?? []).map(h => h.data.slug)
|
||||
|
||||
const formData = reactive({
|
||||
eatingHabit: 'EATING_HABIT_VEGAN',
|
||||
eatingHabit: eatingHabitSlugs.includes(participantData.eating_habit)
|
||||
? participantData.eating_habit
|
||||
: (eatingHabitSlugs[0] ?? ''),
|
||||
paymentMethod: activeMethods.length === 1 ? activeMethods[0].slug : null,
|
||||
paymentOptions: {},
|
||||
// Antworten auf die event-spezifischen Teilnahmeoptionen: { [question.key]: option.value }
|
||||
@@ -24,7 +29,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 ?? '',
|
||||
@@ -50,7 +58,6 @@ export function useSignupForm(event, participantData) {
|
||||
intolerances: participantData.intolerances ?? '',
|
||||
medikamente: participantData.medications ?? '',
|
||||
tetanusVaccination: participantData.tetanusVaccination ?? '',
|
||||
essgewohnheit: 'vegetarian',
|
||||
anmerkungen: '',
|
||||
summary_information_correct: false,
|
||||
summary_accept_terms: false,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -49,7 +51,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
* @property boolean $archived
|
||||
* @property boolean $short_registration
|
||||
* @property boolean $swimming_permission_required
|
||||
* @property boolean $coc_check_required
|
||||
* @property string|null $invoice_key
|
||||
* @property string $participant_group_type
|
||||
*/
|
||||
class Event extends InstancedModel
|
||||
{
|
||||
@@ -99,8 +103,10 @@ class Event extends InstancedModel
|
||||
|
||||
'short_registration',
|
||||
'swimming_permission_required',
|
||||
'coc_check_required',
|
||||
|
||||
'invoice_key',
|
||||
'participant_group_type',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -133,6 +139,7 @@ class Event extends InstancedModel
|
||||
|
||||
'short_registration' => 'boolean',
|
||||
'swimming_permission_required' => 'boolean',
|
||||
'coc_check_required' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -186,6 +193,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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)),
|
||||
]);
|
||||
|
||||
@@ -172,9 +172,13 @@ class CostUnitRepository {
|
||||
* damit immer dieselbe Gliederung, und eine fehlende Zeile lässt sich nicht mit einer vergessenen
|
||||
* verwechseln.
|
||||
*
|
||||
* Gespendete Auslagen zählen hier mit: Für die Einnahmen-Überschuss-Rechnung ist die Spende eine Ausgabe
|
||||
* ihres Typs und zugleich eine Einnahme. Mit `$donationsOnly` liefert die Methode genau diesen Spendenanteil
|
||||
* in derselben Gliederung -- die Gegenbuchungen lassen sich so ihrer Ausgabenkategorie zuordnen.
|
||||
*
|
||||
* @return array<string, array{type: InvoiceType, invoices: array<int, Invoice>, sum: Amount}>
|
||||
*/
|
||||
public function groupExpensesByType(CostUnit $costUnit) : array {
|
||||
public function groupExpensesByType(CostUnit $costUnit, bool $donationsOnly = false) : array {
|
||||
$groups = [];
|
||||
|
||||
foreach (InvoiceType::countingAsExpense() as $invoiceType) {
|
||||
@@ -186,7 +190,11 @@ class CostUnitRepository {
|
||||
}
|
||||
|
||||
foreach ($costUnit->invoices()->orderBy('invoice_number')->get() as $invoice) {
|
||||
if (!self::countsAsExpense($invoice) || !isset($groups[$invoice->type])) {
|
||||
if (
|
||||
!self::isBooked($invoice) ||
|
||||
!isset($groups[$invoice->type]) ||
|
||||
($donationsOnly && !$invoice->donation)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -201,14 +209,22 @@ class CostUnitRepository {
|
||||
* Ob ein Beleg als Ausgabe zählt.
|
||||
*
|
||||
* Abgelehnt und gelöscht sind keine Ausgabe, weil kein Geld fließt. Dasselbe gilt für eine gespendete
|
||||
* Auslage: Auf die Auszahlung wurde verzichtet, die Kostenstelle gibt nichts aus.
|
||||
* Auslage: Auf die Auszahlung wurde verzichtet, die Kostenstelle gibt nichts aus. Die Einnahmen-Überschuss-
|
||||
* Rechnung sieht das anders und bucht die Spende als Ausgabe und Einnahme, siehe
|
||||
* {@see self::groupExpensesByType()}.
|
||||
*/
|
||||
private static function countsAsExpense(Invoice $invoice) : bool {
|
||||
return self::isBooked($invoice) && !$invoice->donation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob ein Beleg überhaupt gebucht ist -- abgelehnt und gelöscht sind es nicht.
|
||||
*/
|
||||
private static function isBooked(Invoice $invoice) : bool {
|
||||
return !in_array($invoice->status, [
|
||||
InvoiceStatus::INVOICE_STATUS_DENIED,
|
||||
InvoiceStatus::INVOICE_STATUS_DELETED,
|
||||
], true)
|
||||
&& !$invoice->donation;
|
||||
InvoiceStatus::INVOICE_STATUS_DENIED,
|
||||
InvoiceStatus::INVOICE_STATUS_DELETED,
|
||||
], true);
|
||||
}
|
||||
|
||||
public function sumupEstimatedByInvoiceType(CostUnit $costUnit, InvoiceType $invoiceType) : Amount {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -84,6 +85,7 @@ class EventResource extends JsonResource{
|
||||
// Steuert im Frontend, welcher Anmelde-Wizard gerendert wird, und ob die Badeerlaubnis abgefragt wird.
|
||||
$returnArray['shortRegistration'] = (bool)$this->event->short_registration;
|
||||
$returnArray['swimmingPermissionRequired'] = (bool)$this->event->swimming_permission_required;
|
||||
$returnArray['cocCheckRequired'] = (bool)$this->event->coc_check_required;
|
||||
|
||||
$returnArray['siblingReduction'] = $this->event->sibling_reduction ?? true;
|
||||
$returnArray['costUnit'] = new CostUnitResource($this->event->costUnit()->first())->toArray(true);
|
||||
@@ -149,6 +151,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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
// Ob bei Anmeldungen das erweiterte Führungszeugnis geprüft wird. Ist die Prüfung aus, erhalten neue
|
||||
// Anmeldungen immer „Nicht erforderlich". Default true, damit bestehende Veranstaltungen sich
|
||||
// unverändert verhalten.
|
||||
$table->boolean('coc_check_required')->default(true);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->dropColumn('coc_check_required');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,13 @@
|
||||
set -euo pipefail
|
||||
|
||||
MAREIKE_BASE="${MAREIKE_BASE:-/opt/mareike}"
|
||||
|
||||
# Server-spezifische Einstellungen, z. B. MAREIKE_DB_SOCKET_DIR=/var/lib/mysql
|
||||
if [[ -f "$MAREIKE_BASE/shared/deploy.conf" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$MAREIKE_BASE/shared/deploy.conf"
|
||||
fi
|
||||
|
||||
MAREIKE_USER="${MAREIKE_USER:-mareike-docker}"
|
||||
SRC="$MAREIKE_BASE/src"
|
||||
|
||||
@@ -48,6 +55,28 @@ if [[ ! -f "$MAREIKE_ENV_FILE" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Der Container läuft als $MAREIKE_USER und muss die eingebundene .env lesen können.
|
||||
# Ist sie unlesbar, ignoriert Laravel sie still und fällt z. B. auf SQLite zurück.
|
||||
chown "root:$MAREIKE_GID" "$MAREIKE_ENV_FILE"
|
||||
chmod 0640 "$MAREIKE_ENV_FILE"
|
||||
|
||||
# Der Container erreicht die KeyHelp-MariaDB nur über den Socket im eingebundenen Verzeichnis /run/mysqld.
|
||||
DB_SOCKET="$( (grep -E '^DB_SOCKET=' "$MAREIKE_ENV_FILE" || true) | tail -n 1 | cut -d= -f2- | tr -d "\"' \r")"
|
||||
if [[ -z "$DB_SOCKET" ]]; then
|
||||
echo "DB_SOCKET fehlt in $MAREIKE_ENV_FILE (erwartet: DB_SOCKET=/run/mysqld/mysqld.sock)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(dirname "$DB_SOCKET")" != "/run/mysqld" ]]; then
|
||||
echo "DB_SOCKET muss in /run/mysqld/ liegen (Mount-Punkt im Container), ist aber: $DB_SOCKET" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -S "$MAREIKE_DB_SOCKET_DIR/$(basename "$DB_SOCKET")" ]]; then
|
||||
echo "Kein MariaDB-Socket unter $MAREIKE_DB_SOCKET_DIR/$(basename "$DB_SOCKET")." >&2
|
||||
echo "Pfad prüfen mit: mysql -NBe 'SELECT @@socket;'" >&2
|
||||
echo "Liegt er woanders, MAREIKE_DB_SOCKET_DIR in $MAREIKE_BASE/shared/deploy.conf setzen." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Quellcode auf '$REF' bringen"
|
||||
git -C "$SRC" fetch --tags --prune --force origin
|
||||
if git -C "$SRC" show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
||||
@@ -70,6 +99,11 @@ done
|
||||
echo "==> Images bauen"
|
||||
compose build --pull
|
||||
|
||||
if ! compose run --rm --no-deps app php -r 'exit(is_readable(".env") ? 0 : 1);'; then
|
||||
echo ".env ist im Container nicht lesbar – Besitzer/Rechte von $MAREIKE_ENV_FILE prüfen." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Datenbank migrieren"
|
||||
compose run --rm --no-deps app php artisan migrate --force
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
# Erzeugen mit: echo "base64:$(openssl rand -base64 32)" – danach NIE mehr ändern (verschlüsselte Daten!)
|
||||
APP_KEY=
|
||||
# Domain des Dach-Tenants "lv"
|
||||
APP_URL=https://<lv-subdomain>.meine-anmeldung.digital
|
||||
# Die Hauptdomain ist der Dach-Tenant "lv"; Gruppen laufen als <gruppe>.meine-anmeldung.digital
|
||||
APP_URL=https://meine-anmeldung.digital
|
||||
# Kein ASSET_URL: Assets kommen von der jeweiligen Tenant-Domain selbst.
|
||||
|
||||
APP_LOCALE=de
|
||||
|
||||
@@ -17,6 +17,10 @@ server {
|
||||
index index.php;
|
||||
|
||||
server_tokens off;
|
||||
|
||||
# nginx sieht hinter dem Proxy nur http. Eigene Redirects (z. B. /tinymce → /tinymce/) daher relativ halten,
|
||||
# sonst landet der Browser auf http://.
|
||||
absolute_redirect off;
|
||||
client_max_body_size 70m;
|
||||
|
||||
location / {
|
||||
|
||||
+75
-22
@@ -51,7 +51,8 @@ Browser ──HTTPS──▶ KeyHelp-Webserver (Apache/nginx, Let's Encrypt, ein
|
||||
/opt/mareike/
|
||||
├── bin/deploy.sh Kopie von docker/endeavour/deploy.sh
|
||||
├── src/ Git-Checkout (wird bei jedem Deploy aktualisiert)
|
||||
├── shared/.env Konfiguration mit Zugangsdaten (nur root lesbar)
|
||||
├── shared/.env Konfiguration mit Zugangsdaten (root, Gruppe mareike-docker lesbar, 0640)
|
||||
├── shared/deploy.conf optional: Server-Einstellungen für deploy.sh (z. B. MAREIKE_DB_SOCKET_DIR)
|
||||
└── storage/ Laravel-storage/ (Logs, Sessions, erzeugte PDFs/ZIPs) – persistent
|
||||
```
|
||||
|
||||
@@ -152,8 +153,18 @@ install -m 0750 /opt/mareike/src/docker/endeavour/deploy.sh /opt/mareike/bin/dep
|
||||
mysql -e "SELECT @@socket;" # erwartet: /run/mysqld/mysqld.sock
|
||||
```
|
||||
|
||||
Liegt der Socket woanders, in `/opt/mareike/bin/deploy.sh` die Variable `MAREIKE_DB_SOCKET_DIR` auf das Verzeichnis
|
||||
setzen und `DB_SOCKET` in der `.env` anpassen.
|
||||
Liegt der Socket woanders, z. B. unter `/var/lib/mysql/mysql.sock`, das **Verzeichnis** in
|
||||
`/opt/mareike/shared/deploy.conf` eintragen:
|
||||
|
||||
```bash
|
||||
echo 'MAREIKE_DB_SOCKET_DIR=/var/lib/mysql' > /opt/mareike/shared/deploy.conf
|
||||
```
|
||||
|
||||
In der `.env` bleibt das Verzeichnis `/run/mysqld`, denn so heißt der Mount-Punkt im Container. Nur der Dateiname
|
||||
wird übernommen, im Beispiel also `DB_SOCKET=/run/mysqld/mysql.sock`.
|
||||
|
||||
`deploy.sh` prüft vor jedem Build, ob `DB_SOCKET` gesetzt ist und der Socket auf dem Host existiert. Wenn nicht,
|
||||
bricht es mit einem Hinweis ab.
|
||||
|
||||
Weil die Datenbank von KeyHelp verwaltet wird, ist sie automatisch im **KeyHelp-Backup** enthalten.
|
||||
|
||||
@@ -162,7 +173,7 @@ Weil die Datenbank von KeyHelp verwaltet wird, ist sie automatisch im **KeyHelp-
|
||||
## 5. `.env` anlegen
|
||||
|
||||
```bash
|
||||
install -m 0600 /opt/mareike/src/docker/endeavour/env.production.example /opt/mareike/shared/.env
|
||||
install -m 0640 -o root -g mareike-docker /opt/mareike/src/docker/endeavour/env.production.example /opt/mareike/shared/.env
|
||||
echo "base64:$(openssl rand -base64 32)" # Ergebnis als APP_KEY eintragen
|
||||
nano /opt/mareike/shared/.env
|
||||
```
|
||||
@@ -225,7 +236,7 @@ angelegt wird.
|
||||
App\Models\Tenant::create([
|
||||
'slug' => 'lv',
|
||||
'name' => '<Name des Verbands>',
|
||||
'url' => '<lv-subdomain>.meine-anmeldung.digital',
|
||||
'url' => 'meine-anmeldung.digital',
|
||||
'email' => '<kontakt@…>',
|
||||
'email_finance' => '<finanzen@…>',
|
||||
'account_name' => '<Kontoinhaber>',
|
||||
@@ -249,7 +260,7 @@ global und werden für jeden aktiven Tenant ausgeführt.
|
||||
|
||||
### 6.4 Erste Administratorin / ersten Administrator
|
||||
|
||||
1. Auf `https://<lv-subdomain>.meine-anmeldung.digital` ganz normal registrieren. Voraussetzung: Abschnitt 7 ist
|
||||
1. Auf `https://meine-anmeldung.digital` ganz normal registrieren. Voraussetzung: Abschnitt 7 ist
|
||||
erledigt und der Mailversand funktioniert.
|
||||
2. Das Konto zur Administration hochstufen:
|
||||
|
||||
@@ -261,20 +272,36 @@ global und werden für jeden aktiven Tenant ausgeführt.
|
||||
|
||||
## 7. Tenant-Domain in KeyHelp einrichten (je Tenant)
|
||||
|
||||
Diesen Ablauf für den Dach-Tenant `lv` und später für jede weitere Gruppe wiederholen.
|
||||
Domain-Schema:
|
||||
|
||||
### 7.1 Einmalig: Proxy-Module aktivieren (Apache)
|
||||
- Dach-Tenant `lv`: die **Hauptdomain** `meine-anmeldung.digital`. Sie existiert in KeyHelp bereits samt
|
||||
Let's-Encrypt-Zertifikat.
|
||||
- Gruppen: `<gruppe>.meine-anmeldung.digital`, jede als eigene Domain in KeyHelp mit eigenem Zertifikat.
|
||||
|
||||
Den folgenden Ablauf für die Hauptdomain und später für jede Gruppe wiederholen.
|
||||
|
||||
> **Wichtig:** Nicht die KeyHelp-Funktion **„Weiterleitung“** benutzen. Sie schickt den *Browser* auf
|
||||
> `http://127.0.0.1:8080`, und das kann nicht funktionieren. Gebraucht wird ein **Proxy**: KeyHelp holt die Seite
|
||||
> intern vom Container ab und liefert sie selbst per HTTPS mit seinem Zertifikat aus. Das geschieht ausschließlich über
|
||||
> die Webserver-Direktiven unten.
|
||||
|
||||
### 7.1 Einmalig: Module aktivieren (Apache)
|
||||
|
||||
```bash
|
||||
a2enmod proxy proxy_http headers
|
||||
a2enmod proxy proxy_http headers rewrite
|
||||
systemctl reload apache2
|
||||
```
|
||||
|
||||
### 7.2 Domain anlegen
|
||||
### 7.2 Domain einrichten
|
||||
|
||||
1. KeyHelp → **Domains** → neue Domain für das mareike-Konto, z. B. `wilde-moehre.meine-anmeldung.digital`.
|
||||
Den DNS-Eintrag (A/AAAA) auf endeavour setzen, falls KeyHelp den DNS nicht selbst verwaltet.
|
||||
2. **SSL/TLS:** Let's Encrypt aktivieren und **HTTPS erzwingen** einschalten.
|
||||
1. **Hauptdomain:** In KeyHelp die vorhandene Domain `meine-anmeldung.digital` öffnen. Eine eventuell eingerichtete
|
||||
*Weiterleitung* entfernen.
|
||||
**Gruppe:** KeyHelp → **Domains** → neue Domain für das mareike-Konto anlegen, z. B.
|
||||
`wilde-moehre.meine-anmeldung.digital`. Den DNS-Eintrag (A/AAAA) auf endeavour setzen, falls KeyHelp den DNS nicht
|
||||
selbst verwaltet.
|
||||
2. **SSL/TLS:** Let's Encrypt aktiv und **HTTPS erzwingen** eingeschaltet. Bei der Hauptdomain muss
|
||||
`www.meine-anmeldung.digital` im Zertifikat enthalten sein, sonst gibt es beim www-Redirect eine
|
||||
Zertifikatswarnung.
|
||||
3. PHP wird für diese Domain nicht gebraucht.
|
||||
4. In den Domain-Einstellungen im Feld für **individuelle Webserver-Direktiven** (nur als Admin sichtbar, die
|
||||
Bezeichnung variiert je nach KeyHelp-Version) Folgendes eintragen:
|
||||
@@ -282,17 +309,34 @@ systemctl reload apache2
|
||||
**Apache** (KeyHelp-Standard):
|
||||
|
||||
```apache
|
||||
RewriteEngine On
|
||||
# Let's Encrypt von KeyHelp nicht anfassen
|
||||
RewriteRule ^/?\.well-known/acme-challenge/ - [L]
|
||||
# www → ohne www
|
||||
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
|
||||
RewriteRule ^/?(.*)$ https://%1/$1 [R=301,L,NE]
|
||||
# alles ohne TLS auf https umleiten
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteRule ^/?(.*)$ https://%{HTTP_HOST}/$1 [R=301,L,NE]
|
||||
# unter https an den Container durchreichen
|
||||
RewriteRule ^/?(.*)$ http://127.0.0.1:8080/$1 [P,L,NE]
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
ProxyPass /.well-known/acme-challenge/ !
|
||||
ProxyPass / http://127.0.0.1:8080/
|
||||
ProxyPassReverse / http://127.0.0.1:8080/
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
Header always set Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on'"
|
||||
```
|
||||
|
||||
Die Regeln funktionieren unabhängig davon, ob KeyHelp die Direktiven in den Port-80- oder den Port-443-vHost
|
||||
schreibt. Ohne TLS wird immer auf `https://` umgeleitet, nur unter HTTPS wird an den Container weitergereicht. Das
|
||||
`http://127.0.0.1:8080` ist dabei die **interne** Verbindung zum Container, der Browser sieht sie nie.
|
||||
|
||||
**nginx** (falls KeyHelp auf nginx läuft):
|
||||
|
||||
```nginx
|
||||
if ($scheme != "https") {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
@@ -301,9 +345,6 @@ systemctl reload apache2
|
||||
client_max_body_size 70m;
|
||||
}
|
||||
```
|
||||
|
||||
Die Ausnahme für `/.well-known/acme-challenge/` sorgt dafür, dass KeyHelp die Zertifikate weiter selbst verlängern
|
||||
kann.
|
||||
5. Speichern und die Domain aufrufen. Solange der Tenant noch nicht existiert, zeigt mareike **404 „Tenant not
|
||||
found“**. Das ist ein gutes Zeichen, denn der Proxy funktioniert.
|
||||
|
||||
@@ -324,7 +365,7 @@ Tenants:
|
||||
|
||||
```bash
|
||||
cat > /etc/cron.d/mareike <<'EOF'
|
||||
* * * * * root curl -fsS -o /dev/null -H "Host: <lv-subdomain>.meine-anmeldung.digital" http://127.0.0.1:8080/execute-crons
|
||||
* * * * * root curl -fsS -o /dev/null -H "Host: meine-anmeldung.digital" http://127.0.0.1:8080/execute-crons
|
||||
EOF
|
||||
```
|
||||
|
||||
@@ -378,14 +419,26 @@ Enthält ein Release Datenbankänderungen, deshalb vorher ein DB-Backup ziehen.
|
||||
|
||||
| Symptom | Ursache / Lösung |
|
||||
|---|---|
|
||||
| `Database file at path […database.sqlite] does not exist` | Die `.env` wird im Container nicht gelesen, Laravel fällt dann auf SQLite zurück. Rechte prüfen (0640, Gruppe `mareike-docker`) und ob `MAREIKE_ENV_FILE` auf die richtige Datei zeigt. |
|
||||
| Browser landet auf `http://` | In KeyHelp ist eine *Weiterleitung* statt der Proxy-Direktiven eingerichtet, oder es sind noch die alten `ProxyPass`-Direktiven ohne HTTPS-Umleitung aktiv (Abschnitt 7.2). Woher die http-Umleitung kommt, zeigen die drei `curl`-Befehle darunter. |
|
||||
| 404 „Tenant not found“ | Der Host passt nicht zu `tenants.url` (Tippfehler, `https://` oder `/` im Eintrag), der Tenant ist nicht `has_active_instance`, oder der Proxy reicht den Host nicht durch (`ProxyPreserveHost On` fehlt). |
|
||||
| 502/503 vom KeyHelp-Webserver | Container laufen nicht (`deploy.sh compose ps`) oder die Firewall wurde neu geladen, dann hilft `systemctl restart docker`. |
|
||||
| Seite lädt ohne CSS, Mixed Content | `ASSET_URL` ist gesetzt oder die nginx-Config im Container wurde nicht übernommen (`fastcgi_param HTTPS on`). |
|
||||
| `SQLSTATE[HY000] [2002]` | Socket nicht erreichbar. `SELECT @@socket` prüfen, `MAREIKE_DB_SOCKET_DIR` bzw. `DB_SOCKET` anpassen, nach einem MariaDB-Neustart den Stack mit `deploy.sh compose restart app` neu starten. |
|
||||
| `SQLSTATE[HY000] [2002] No such file or directory` | Im Container liegt kein Socket unter `DB_SOCKET`. Entweder fehlt `DB_SOCKET` in der `.env`, oder der Socket liegt auf dem Host nicht in `/run/mysqld`. Pfad mit `mysql -NBe "SELECT @@socket;"` prüfen und ggf. `MAREIKE_DB_SOCKET_DIR` in `shared/deploy.conf` setzen (Abschnitt 4). Nach einem MariaDB-Neustart hilft oft `deploy.sh compose restart app`. |
|
||||
| `Permission denied` in storage/ | Besitzer von `/opt/mareike/storage` muss `mareike-docker` sein, der nächste `deploy.sh`-Lauf korrigiert das. |
|
||||
| Upload bricht ab | Grenzen prüfen: `MAX_INVOICE_FILE_SIZE` in der `.env`, 64 MB in `php.ini`, 70 MB in `nginx.conf`. |
|
||||
| Mails kommen nicht an | `MAIL_*` prüfen, Log in `storage/logs/`. Absenderadresse muss zum Postfach passen (SPF/DKIM der Domain in KeyHelp). |
|
||||
|
||||
Woher kommt eine Umleitung auf `http://`?
|
||||
|
||||
```bash
|
||||
curl -sI http://meine-anmeldung.digital/ | grep -iE '^(HTTP|Location)' # erwartet: 301 → https://meine-anmeldung.digital/
|
||||
curl -sI https://meine-anmeldung.digital/ | grep -iE '^(HTTP|Location)' # erwartet: 302 → https://meine-anmeldung.digital/login
|
||||
curl -sI -H 'Host: meine-anmeldung.digital' http://127.0.0.1:8080/ | grep -i '^Location' # Container direkt: https://…/login
|
||||
```
|
||||
|
||||
Zeigt der dritte Befehl `https://`, der zweite aber `http://`, liegt es an den KeyHelp-Einstellungen, nicht an mareike.
|
||||
|
||||
Logs:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -186,8 +186,8 @@
|
||||
|
||||
<div class="footnote">
|
||||
Berücksichtigt sind nur tatsächlich vereinnahmte Beiträge und erfasste Belege. Nicht enthalten sind
|
||||
offene Beiträge, Budget- und Schätzwerte, erstattete Beiträge sowie Auslagen, auf deren Auszahlung
|
||||
verzichtet wurde.
|
||||
offene Beiträge, Budget- und Schätzwerte sowie erstattete Beiträge. Gespendete Auslagen sind bei den
|
||||
Ausgaben enthalten und zugleich als Spenden unter den Förderungen ausgewiesen.
|
||||
@if($event->tax_liable)
|
||||
<br />Alle Beträge sind Bruttobeträge inklusive {{ $event->vat_rate }} % Umsatzsteuer.
|
||||
@endif
|
||||
@@ -203,33 +203,27 @@
|
||||
<h1>Anlage: Belege</h1>
|
||||
<div class="subline">{{ $event->name }} · Stand: {{ $createdAt }}</div>
|
||||
|
||||
@foreach($documentedGroups as $group)
|
||||
<h3>{{ $group['name'] }}</h3>
|
||||
<table class="receipts">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 22mm;">Beleg-Nr.</th>
|
||||
<th style="width: 18mm;">Datum</th>
|
||||
<th>Zweck</th>
|
||||
<th class="value">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($group['rows'] as $row)
|
||||
<tr>
|
||||
<td>{{ $row['number'] }}</td>
|
||||
<td>{{ $row['date'] }}</td>
|
||||
<td>{{ $row['purpose'] }}</td>
|
||||
<td class="value">{{ $money($row['amount']) }} €</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
<tr class="group-sum">
|
||||
<td colspan="3">Zwischensumme {{ $group['name'] }}</td>
|
||||
<td class="value">{{ $money($group['sum']) }} €</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@endforeach
|
||||
@include('pdfs.partials.receipt-groups', ['groups' => $documentedGroups])
|
||||
@endif
|
||||
|
||||
@php
|
||||
$donatedGroups = array_filter($donations['groups'], fn ($group) => count($group['rows']) > 0);
|
||||
@endphp
|
||||
|
||||
@if(count($donatedGroups) > 0)
|
||||
<div class="page-break"></div>
|
||||
|
||||
<h1>Anlage: Spenden</h1>
|
||||
<div class="subline">{{ $event->name }} · Stand: {{ $createdAt }}</div>
|
||||
|
||||
@include('pdfs.partials.receipt-groups', ['groups' => $donatedGroups])
|
||||
|
||||
<table class="statement">
|
||||
<tr class="sum">
|
||||
<td>Summe Spenden</td>
|
||||
<td class="value">{{ $money($donations['total']) }} €</td>
|
||||
</tr>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
</body>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{{-- Belege je Ausgabentyp mit Zwischensumme; nur Gruppen mit Belegen übergeben. --}}
|
||||
@foreach($groups as $group)
|
||||
<h3>{{ $group['name'] }}</h3>
|
||||
<table class="receipts">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 22mm;">Beleg-Nr.</th>
|
||||
<th style="width: 18mm;">Datum</th>
|
||||
<th>Zweck</th>
|
||||
<th class="value">Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($group['rows'] as $row)
|
||||
<tr>
|
||||
<td>{{ $row['number'] }}</td>
|
||||
<td>{{ $row['date'] }}</td>
|
||||
<td>{{ $row['purpose'] }}</td>
|
||||
<td class="value">{{ $money($row['amount']) }} €</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
<tr class="group-sum">
|
||||
<td colspan="3">Zwischensumme {{ $group['name'] }}</td>
|
||||
<td class="value">{{ $money($group['sum']) }} €</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@endforeach
|
||||
@@ -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>
|
||||
|
||||
@@ -202,11 +202,95 @@ class EventIncomeSurplusStatementTest extends TestCase
|
||||
// Kein Geldfluss, also keine Ausgabe:
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 999.0, InvoiceStatus::INVOICE_STATUS_DENIED);
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 888.0, InvoiceStatus::INVOICE_STATUS_DELETED);
|
||||
|
||||
// Eine Spende ist Ausgabe ihres Typs (und zugleich Einnahme unter den Förderungen):
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 777.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
|
||||
|
||||
$this->assertEqualsWithDelta(150.0, $this->group('Programmkosten')['sum']->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(927.0, $this->group('Programmkosten')['sum']->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(30.0, $this->group('Verpflegungskosten')['sum']->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(180.0, $this->statement()->expenses['total']->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(957.0, $this->statement()->expenses['total']->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Spenden
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_without_donations_the_donation_line_is_zero(): void
|
||||
{
|
||||
$this->makeEvent();
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
|
||||
|
||||
$this->assertEqualsWithDelta(0.0, $this->entry('Spenden')->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(0.0, $this->statement()->donations['total']->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
public function test_a_donation_is_an_expense_of_its_type_and_funding_income(): void
|
||||
{
|
||||
$this->makeEvent();
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
|
||||
$resultWithoutDonation = $this->statement()->result->getAmount();
|
||||
|
||||
$donated = $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 40.0, InvoiceStatus::INVOICE_STATUS_APPROVED, donation: true);
|
||||
|
||||
$statement = $this->statement();
|
||||
$program = $this->group('Programmkosten');
|
||||
|
||||
$this->assertEqualsWithDelta(140.0, $program['sum']->getAmount(), 0.001);
|
||||
$this->assertContains($donated->invoice_number, array_column($program['rows'], 'number'));
|
||||
|
||||
// Gegenbuchung unter den Förderungen (ohne Anmeldung keine Fördermittel, also nur die Spende).
|
||||
$this->assertSame('Förderungen', $statement->income['categories'][1]['name']);
|
||||
$this->assertEqualsWithDelta(40.0, $this->entry('Spenden')->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(40.0, $statement->income['categories'][1]['total']->getAmount(), 0.001);
|
||||
|
||||
// Ausgabe und Einnahme heben sich auf.
|
||||
$this->assertEqualsWithDelta($resultWithoutDonation, $statement->result->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
public function test_donations_are_grouped_like_expenses(): void
|
||||
{
|
||||
$this->makeEvent();
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
|
||||
$programDonation = $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 25.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 15.0, InvoiceStatus::INVOICE_STATUS_EXPORTED, donation: true);
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_CATERING, 60.0, InvoiceStatus::INVOICE_STATUS_APPROVED, donation: true);
|
||||
|
||||
$donations = $this->statement()->donations;
|
||||
|
||||
// Dieselbe Gliederung wie die Ausgaben, auch Typen ohne Spende stehen darin.
|
||||
$this->assertSame(
|
||||
array_column($this->statement()->expenses['groups'], 'name'),
|
||||
array_column($donations['groups'], 'name')
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(40.0, $this->donationGroup('Programmkosten')['sum']->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(60.0, $this->donationGroup('Verpflegungskosten')['sum']->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(100.0, $donations['total']->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(100.0, $this->entry('Spenden')->getAmount(), 0.001);
|
||||
|
||||
// Derselbe Beleg steht als Ausgabe und als Spende.
|
||||
$this->assertContains($programDonation->invoice_number, array_column($this->group('Programmkosten')['rows'], 'number'));
|
||||
$this->assertContains($programDonation->invoice_number, array_column($this->donationGroup('Programmkosten')['rows'], 'number'));
|
||||
|
||||
// Nicht gespendete Belege stehen nicht in der Spenden-Anlage.
|
||||
$this->assertCount(2, $this->donationGroup('Programmkosten')['rows']);
|
||||
}
|
||||
|
||||
public function test_donations_list_only_booked_donations(): void
|
||||
{
|
||||
$this->makeEvent();
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 20.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 999.0, InvoiceStatus::INVOICE_STATUS_DENIED, donation: true);
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 888.0, InvoiceStatus::INVOICE_STATUS_DELETED, donation: true);
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0, InvoiceStatus::INVOICE_STATUS_EXPORTED, donation: true);
|
||||
|
||||
$donations = $this->statement()->donations;
|
||||
|
||||
$this->assertEqualsWithDelta(20.0, $donations['total']->getAmount(), 0.001);
|
||||
$this->assertCount(1, $this->donationGroup('Programmkosten')['rows']);
|
||||
$this->assertNotContains('Beitragserstattung', array_column($donations['groups'], 'name'));
|
||||
}
|
||||
|
||||
public function test_a_refund_is_no_expense(): void
|
||||
@@ -311,12 +395,34 @@ class EventIncomeSurplusStatementTest extends TestCase
|
||||
public function test_the_comment_is_no_longer_part_of_the_purpose(): void
|
||||
{
|
||||
// Die Anmerkung trägt, was die Kassenwart*in beim Korrigieren notiert hat -- sie steht auf dem
|
||||
// Beleg-PDF und hat im Zweck nichts zu suchen. Bestandsbelege ohne erfassten Zahlungsgrund haben
|
||||
// hier deshalb eine leere Zelle; nachtragen lässt er sich beim Korrigieren.
|
||||
// Beleg-PDF und hat im Zweck nichts zu suchen.
|
||||
$this->makeEvent();
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
|
||||
|
||||
$this->assertSame('', $this->group('Programmkosten')['rows'][0]['purpose']);
|
||||
$this->assertStringNotContainsString('Materialkauf', $this->group('Programmkosten')['rows'][0]['purpose']);
|
||||
}
|
||||
|
||||
public function test_an_empty_purpose_falls_back_to_the_refund_of_the_contact(): void
|
||||
{
|
||||
// Ältere Abrechnungen ohne jede Zweckangabe: ersatzweise, wem erstattet wurde -- als Ausgabe wie als Spende.
|
||||
$this->makeEvent();
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW);
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 30.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
|
||||
$this->makeInvoice(
|
||||
InvoiceType::INVOICE_TYPE_PROGRAM,
|
||||
50.0,
|
||||
InvoiceStatus::INVOICE_STATUS_NEW,
|
||||
purpose: 'Material für den Bastelnachmittag'
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
['Auslagenerstattung Mika Muster', 'Auslagenerstattung Mika Muster', 'Material für den Bastelnachmittag'],
|
||||
array_column($this->group('Programmkosten')['rows'], 'purpose')
|
||||
);
|
||||
$this->assertSame(
|
||||
['Auslagenerstattung Mika Muster'],
|
||||
array_column($this->donationGroup('Programmkosten')['rows'], 'purpose')
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -388,6 +494,20 @@ class EventIncomeSurplusStatementTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_a_donation_still_does_not_count_in_the_expense_overview(): void
|
||||
{
|
||||
// Die Bildschirm-Übersicht kennt keine Spenden-Einnahme -- dort bleibt die Spende außen vor.
|
||||
$this->makeEvent();
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 777.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true);
|
||||
|
||||
$amount = new CostUnitRepository()->sumupByInvoiceType(
|
||||
$this->costUnit->fresh(),
|
||||
InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PROGRAM)->first()
|
||||
);
|
||||
|
||||
$this->assertEqualsWithDelta(0.0, $amount->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Helfer
|
||||
@@ -435,6 +555,18 @@ class EventIncomeSurplusStatementTest extends TestCase
|
||||
$this->fail('Ausgaben-Gruppe "' . $name . '" fehlt.');
|
||||
}
|
||||
|
||||
/** @return array{name: string, sum: Amount, rows: array<int, array<string, mixed>>} */
|
||||
private function donationGroup(string $name): array
|
||||
{
|
||||
foreach ($this->statement()->donations['groups'] as $group) {
|
||||
if ($group['name'] === $name) {
|
||||
return $group;
|
||||
}
|
||||
}
|
||||
|
||||
$this->fail('Spenden-Gruppe "' . $name . '" fehlt.');
|
||||
}
|
||||
|
||||
private function makeEvent(): Event
|
||||
{
|
||||
$fee = EventParticipationFee::create([
|
||||
|
||||
@@ -40,6 +40,7 @@ class InvoiceNumberingTest extends TestCase
|
||||
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']);
|
||||
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
|
||||
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
|
||||
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
|
||||
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
|
||||
@@ -167,7 +168,7 @@ class InvoiceNumberingTest extends TestCase
|
||||
$event, null, $firstname, 'Muster', null, ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
|
||||
$this->wildeMoehre, new DateTime('2000-01-01'), 'Weg 1', null, '00000', 'Stadt',
|
||||
strtolower($firstname) . '@example.com', '123', null, null, null, null, null, null, null,
|
||||
'vegan', null, null, false, false, false, false, false,
|
||||
'', null, null, false, false, false, false, false,
|
||||
new DateTime('2026-07-16'), new DateTime('2026-07-16'), 0, 0, null,
|
||||
new Amount(50.0, 'Euro'), PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||
))->execute();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,25 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConductionCheckCommand;
|
||||
use App\Domains\Event\Actions\CertificateOfConductionCheck\CertificateOfConductionCheckRequest;
|
||||
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpCommand;
|
||||
use App\Domains\Event\Actions\ShortSignUp\ShortSignUpRequest;
|
||||
use App\Enumerations\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;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -55,6 +60,7 @@ class ShortSignUpTest extends TestCase
|
||||
bool $swimmingPermissionRequired = true,
|
||||
?float $amountReduced = 30.0,
|
||||
?float $amountSolidarity = 70.0,
|
||||
bool $cocCheckRequired = true,
|
||||
): Event
|
||||
{
|
||||
$event = Event::create([
|
||||
@@ -64,6 +70,7 @@ class ShortSignUpTest extends TestCase
|
||||
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
|
||||
'pay_per_day' => false, 'pay_direct' => false,
|
||||
'short_registration' => true, 'swimming_permission_required' => $swimmingPermissionRequired,
|
||||
'coc_check_required' => $cocCheckRequired,
|
||||
]);
|
||||
|
||||
if ($withFee) {
|
||||
@@ -215,6 +222,43 @@ class ShortSignUpTest extends TestCase
|
||||
$this->assertSame('maria@example.com', $response->participant->email_2);
|
||||
}
|
||||
|
||||
public function test_volljaehrige_ohne_efz_pruefung_brauchen_kein_efz(): void
|
||||
{
|
||||
Http::fake();
|
||||
$event = $this->createEvent(cocCheckRequired: false);
|
||||
|
||||
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
|
||||
|
||||
$this->assertTrue($response->success, $response->message ?? '');
|
||||
$this->assertSame(EfzStatus::EFZ_STATUS_NOT_REQUIRED, $response->participant->efz_status);
|
||||
|
||||
$cocResponse = new CertificateOfConductionCheckCommand(
|
||||
new CertificateOfConductionCheckRequest($response->participant)
|
||||
)->execute();
|
||||
|
||||
$this->assertSame(EfzStatus::EFZ_STATUS_NOT_REQUIRED, $cocResponse->status);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_volljaehrige_mit_efz_pruefung_werden_geprueft(): void
|
||||
{
|
||||
putenv('COC_CHECK_URL=https://coc.test/check');
|
||||
Http::fake(['*' => Http::response(['status' => '200/ok', 'efzStatus' => 'CHECKED_VALID'])]);
|
||||
$event = $this->createEvent();
|
||||
|
||||
$response = new ShortSignUpCommand($this->shortRequest($event))->execute();
|
||||
|
||||
$this->assertTrue($response->success, $response->message ?? '');
|
||||
$this->assertSame(EfzStatus::EFZ_STATUS_NOT_CHECKED, $response->participant->efz_status);
|
||||
|
||||
$cocResponse = new CertificateOfConductionCheckCommand(
|
||||
new CertificateOfConductionCheckRequest($response->participant)
|
||||
)->execute();
|
||||
|
||||
$this->assertSame(EfzStatus::EFZ_STATUS_CHECKED_VALID, $cocResponse->status);
|
||||
Http::assertSentCount(1);
|
||||
}
|
||||
|
||||
public function test_u18_uebernimmt_gewaehlte_badeerlaubnis(): void
|
||||
{
|
||||
$event = $this->createEvent();
|
||||
@@ -294,7 +338,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 +346,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 +482,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']);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domains\Event\Actions\SignUp\SignUpCommand;
|
||||
use App\Domains\Event\Actions\SignUp\SignUpRequest;
|
||||
use App\Enumerations\EatingHabit;
|
||||
use App\Enumerations\EfzStatus;
|
||||
use App\Enumerations\FirstAidPermission;
|
||||
use App\Enumerations\ParticipationType;
|
||||
use App\Enumerations\SwimmingPermission;
|
||||
use App\Models\Event;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Models\Tenant;
|
||||
use App\ValueObjects\Amount;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Die bei der Anmeldung gewählte Ernährungsweise muss gespeichert werden -- früher landete immer "Vegetarisch",
|
||||
* weil der Controller ein verwaistes Formularfeld las.
|
||||
*/
|
||||
class SignUpEatingHabitTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->tenant = Tenant::create([
|
||||
'slug' => 'tv', 'name' => 'Test', 'email' => 't@example.com', 'email_finance' => 'f@example.com',
|
||||
'url' => 'test.local', 'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
|
||||
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true, 'has_active_instance' => true,
|
||||
]);
|
||||
app()->instance('tenant', $this->tenant);
|
||||
|
||||
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
|
||||
DB::table('participation_fee_types')->insert(['slug' => 'FIXED', 'name' => 'Fixed', 'created_at' => now(), 'updated_at' => now()]);
|
||||
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
|
||||
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
|
||||
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
|
||||
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
|
||||
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
|
||||
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGAN, 'name' => 'Vegan']);
|
||||
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGETARIAN, 'name' => 'Vegetarisch']);
|
||||
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
|
||||
}
|
||||
|
||||
private function createEvent(array $eatingHabitSlugs): Event
|
||||
{
|
||||
$event = Event::create([
|
||||
'tenant' => $this->tenant->slug, 'name' => 'Event', 'identifier' => 'evt-1', 'location' => 'Ort',
|
||||
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-08-01', 'end_date' => '2026-08-05',
|
||||
'early_bird_end' => '2026-07-01', 'registration_final_end' => '2026-07-20', 'early_bird_end_amount_increase' => 0,
|
||||
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
|
||||
'pay_per_day' => false, 'pay_direct' => false,
|
||||
]);
|
||||
$event->eatingHabits()->attach(EatingHabit::whereIn('slug', $eatingHabitSlugs)->pluck('id')->all());
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function signUp(Event $event, string $eatingHabit): string
|
||||
{
|
||||
$request = new SignUpRequest(
|
||||
$event, null, 'Hans', 'Muster', null, ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
|
||||
$this->tenant, new \DateTime('2000-01-01'), 'Weg 1', null, '00000', 'Stadt',
|
||||
'h@example.com', '123', null, null, null, null, null, null, null,
|
||||
$eatingHabit, null, null, false, false, false, false, false,
|
||||
new \DateTime('2026-08-01'), new \DateTime('2026-08-05'), 0, 0, null,
|
||||
new Amount(50.0, ' Euro'), PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, [],
|
||||
);
|
||||
|
||||
$response = new SignUpCommand($request)->execute();
|
||||
$this->assertTrue($response->success);
|
||||
|
||||
return $response->participant->fresh()->eating_habit;
|
||||
}
|
||||
|
||||
public function test_chosen_vegan_is_stored(): void
|
||||
{
|
||||
$event = $this->createEvent([EatingHabit::EATING_HABIT_VEGAN, EatingHabit::EATING_HABIT_VEGETARIAN]);
|
||||
|
||||
$this->assertSame(EatingHabit::EATING_HABIT_VEGAN, $this->signUp($event, EatingHabit::EATING_HABIT_VEGAN));
|
||||
}
|
||||
|
||||
public function test_chosen_vegetarian_is_stored(): void
|
||||
{
|
||||
$event = $this->createEvent([EatingHabit::EATING_HABIT_VEGAN, EatingHabit::EATING_HABIT_VEGETARIAN]);
|
||||
|
||||
$this->assertSame(EatingHabit::EATING_HABIT_VEGETARIAN, $this->signUp($event, EatingHabit::EATING_HABIT_VEGETARIAN));
|
||||
}
|
||||
|
||||
public function test_habit_not_offered_by_event_falls_back_to_omnivor(): void
|
||||
{
|
||||
$event = $this->createEvent([EatingHabit::EATING_HABIT_VEGETARIAN]);
|
||||
|
||||
$this->assertSame(EatingHabit::EATING_HABIT_OMNIVOR, $this->signUp($event, EatingHabit::EATING_HABIT_VEGAN));
|
||||
}
|
||||
|
||||
public function test_empty_habit_falls_back_to_omnivor(): void
|
||||
{
|
||||
$event = $this->createEvent([EatingHabit::EATING_HABIT_VEGAN]);
|
||||
|
||||
$this->assertSame(EatingHabit::EATING_HABIT_OMNIVOR, $this->signUp($event, ''));
|
||||
}
|
||||
}
|
||||
@@ -48,13 +48,16 @@ class SignUpPaymentOptionsTest extends TestCase
|
||||
|
||||
private function createEvent(): Event
|
||||
{
|
||||
return Event::create([
|
||||
$event = Event::create([
|
||||
'tenant' => $this->tenant->slug, 'name' => 'Event', 'identifier' => 'evt-1', 'location' => 'Ort',
|
||||
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-08-01', 'end_date' => '2026-08-05',
|
||||
'early_bird_end' => '2026-07-01', 'registration_final_end' => '2026-07-20', 'early_bird_end_amount_increase' => 0,
|
||||
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
|
||||
'pay_per_day' => false, 'pay_direct' => false,
|
||||
]);
|
||||
$event->eatingHabits()->attach(EatingHabit::where('slug', EatingHabit::EATING_HABIT_VEGAN)->first()->id);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function signUpRequest(Event $event, array $paymentOptions): SignUpRequest
|
||||
@@ -63,7 +66,7 @@ class SignUpPaymentOptionsTest extends TestCase
|
||||
$event, null, 'Hans', 'Muster', null, ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
|
||||
$this->tenant, new \DateTime('2000-01-01'), 'Weg 1', null, '00000', 'Stadt',
|
||||
'h@example.com', '123', null, null, null, null, null, null, null,
|
||||
'vegan', null, null, false, false, false, false, false,
|
||||
EatingHabit::EATING_HABIT_VEGAN, null, null, false, false, false, false, false,
|
||||
new \DateTime('2026-08-01'), new \DateTime('2026-08-05'), 0, 0, null,
|
||||
new Amount(50.0, ' Euro'), PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, $paymentOptions,
|
||||
);
|
||||
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user