8 Commits
Author SHA1 Message Date
th.guenther 9e9ea0cfd5 Fixed bug selecting eating habits 2026-10-04 14:36:15 +02:00
th.guenther bcebe2ebed Verbesserte EÜR-Abrechnung 2026-10-01 13:40:51 +02:00
th.guenther a43fd9cedf CoC Checks now optional 2026-09-29 14:58:21 +02:00
th.guenther a5e4e55df5 Support oft parent groups 2026-09-29 14:35:33 +02:00
th.guentherandClaude Opus 5.5 c4ca101594 endeavour: .env-Rechte, Socket-Prüfung und HTTPS-Proxy korrigiert
- deploy.sh setzt die .env auf root:mareike-docker 0640 und prüft DB_SOCKET/Socket vor dem Build
- optionale shared/deploy.conf für server-spezifische Einstellungen (z. B. MAREIKE_DB_SOCKET_DIR)
- nginx: absolute_redirect off, damit keine http://-Redirects entstehen
- Anleitung: Proxy per mod_rewrite nur unter HTTPS, keine KeyHelp-Weiterleitung, lv auf der Hauptdomain

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 14:30:42 +02:00
th.guenther 985c9ce07e Deployment-Rules for second server 2026-09-26 11:51:49 +02:00
th.guenther 790e8e1dbc Fix im Anmeldungs-Ende 2026-09-11 13:23:53 +02:00
th.guenther ef6284d5cc Fehler in Abrechnungsende behoben 2026-09-11 11:26:35 +02:00
118 changed files with 3730 additions and 160 deletions

No files matched your search

+26
View File
@@ -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();
}
}
+19
View File
@@ -5,6 +5,8 @@ use App\Domains\Admin\Controllers\DocumentAssetUpdateController;
use App\Domains\Admin\Controllers\DocumentTemplatesGetController;
use App\Domains\Admin\Controllers\DocumentTemplatesPreviewController;
use App\Domains\Admin\Controllers\DocumentTemplatesUpdateController;
use App\Domains\Admin\Controllers\GlobalSettingsGetController;
use App\Domains\Admin\Controllers\GlobalSettingsUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantContactGetController;
use App\Domains\Admin\Controllers\ManagedTenantContactUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantGdprGetController;
@@ -15,6 +17,10 @@ use App\Domains\Admin\Controllers\ManagedTenantPaymentGetController;
use App\Domains\Admin\Controllers\ManagedTenantPaymentUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantTaxGetController;
use App\Domains\Admin\Controllers\ManagedTenantTaxUpdateController;
use App\Domains\Admin\Controllers\ParentGroupCreateController;
use App\Domains\Admin\Controllers\ParentGroupDeleteController;
use App\Domains\Admin\Controllers\ParentGroupListApiController;
use App\Domains\Admin\Controllers\ParentGroupUpdateController;
use App\Domains\Admin\Controllers\TenantContactGetController;
use App\Domains\Admin\Controllers\TenantContactUpdateController;
use App\Domains\Admin\Controllers\TenantCreateController;
@@ -76,6 +82,12 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
Route::post('/assets', DocumentAssetUpdateController::class);
Route::delete('/assets/{name}', DocumentAssetDeleteController::class);
});
// Bezeichnungen der Gruppen-Ebenen gelten ebenfalls app-weit.
Route::prefix('api/v1/admin/settings')->group(function () {
Route::get('/', GlobalSettingsGetController::class);
Route::post('/', GlobalSettingsUpdateController::class);
});
});
Route::middleware(LvOnlyMiddleware::class)->group(function () {
@@ -97,5 +109,12 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
Route::post('/tax', ManagedTenantTaxUpdateController::class);
});
});
Route::prefix('api/v1/admin/parent-groups')->group(function () {
Route::get('/list', ParentGroupListApiController::class);
Route::post('/create', ParentGroupCreateController::class);
Route::post('/{id}', ParentGroupUpdateController::class)->whereNumber('id');
Route::delete('/{id}', ParentGroupDeleteController::class)->whereNumber('id');
});
});
});
+4
View File
@@ -2,6 +2,8 @@
use App\Domains\Admin\Controllers\AdminDashboardController;
use App\Domains\Admin\Controllers\DocumentTemplatesPageController;
use App\Domains\Admin\Controllers\GlobalSettingsPageController;
use App\Domains\Admin\Controllers\ParentGroupListPageController;
use App\Domains\Admin\Controllers\TenantEditPageController;
use App\Domains\Admin\Controllers\TenantListPageController;
use App\Domains\Admin\Controllers\TenantPageController;
@@ -21,11 +23,13 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
// App-weit gültige Dokumentvorlagen -- nur für Hauptadministrator*innen.
Route::middleware(MainAdminRoleMiddleware::class)->group(function () {
Route::get('/document-templates', DocumentTemplatesPageController::class);
Route::get('/settings', GlobalSettingsPageController::class);
});
Route::middleware(LvOnlyMiddleware::class)->group(function () {
Route::get('/tenants', TenantListPageController::class);
Route::get('/tenants/{slug}', TenantEditPageController::class);
Route::get('/parent-groups', ParentGroupListPageController::class);
});
});
});
+116
View File
@@ -0,0 +1,116 @@
<script setup>
import { ref, onMounted } from 'vue';
import AdminAppLayout from "../../../../resources/js/layouts/AdminAppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const { request } = useAjax()
const form = ref({
localGroup: '',
localGroupPlural: '',
parentGroup: '',
parentGroupPlural: '',
})
onMounted(async () => {
const response = await request('/api/v1/admin/settings')
Object.assign(form.value, response?.groupLabels ?? {})
})
async function save() {
const response = await request('/api/v1/admin/settings', {
method: 'POST',
body: form.value,
})
if (response && response.status === 'success') {
toast.success(response.message)
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
</script>
<template>
<AdminAppLayout title="Einstellungen">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<h3>Bezeichnungen der Gruppen</h3>
<p class="hint">
Gelten für alle Mandanten: in Anmeldungen, Teilnehmendenlisten, Exporten und in der Verwaltung.
</p>
<table class="data-table">
<thead>
<tr>
<th></th>
<th>Einzahl</th>
<th>Mehrzahl</th>
</tr>
</thead>
<tbody>
<tr>
<th>Gruppe (Mandant)</th>
<td><input type="text" v-model="form.localGroup" class="form-input" placeholder="z. B. Stamm" /></td>
<td><input type="text" v-model="form.localGroupPlural" class="form-input" placeholder="z. B. Stämme" /></td>
</tr>
<tr>
<th>Übergeordnete Gruppe</th>
<td><input type="text" v-model="form.parentGroup" class="form-input" placeholder="z. B. Bund" /></td>
<td><input type="text" v-model="form.parentGroupPlural" class="form-input" placeholder="z. B. Bünde" /></td>
</tr>
</tbody>
</table>
<button class="btn-save" @click="save">Speichern</button>
</shadowed-box>
</AdminAppLayout>
</template>
<style scoped>
.hint {
color: #6b7280;
font-size: 0.9rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 8px 12px;
color: #374151;
border-bottom: 1px solid #e5e7eb;
}
.data-table td {
padding: 8px 12px;
border-bottom: 1px solid #e5e7eb;
}
.form-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
}
.btn-save {
margin-top: 15px;
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
background-color: #16a34a;
color: #ffffff;
}
.btn-save:hover {
background-color: #15803d;
}
</style>
+249
View File
@@ -0,0 +1,249 @@
<script setup>
import { ref, onMounted } from 'vue';
import AdminAppLayout from "../../../../resources/js/layouts/AdminAppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const props = defineProps({
groupLabels: {
type: Object,
default: () => ({ localGroupPlural: 'Stämme', parentGroup: 'Bund', parentGroupPlural: 'Bünde' }),
},
})
const { request } = useAjax()
const parentGroups = ref([])
const newName = ref('')
const showCreate = ref(false)
// ID des Eintrags, der gerade umbenannt wird, samt Entwurf des neuen Namens.
const editingId = ref(null)
const editName = ref('')
onMounted(async () => {
await loadParentGroups()
})
async function loadParentGroups() {
const response = await request('/api/v1/admin/parent-groups/list')
parentGroups.value = response?.parentGroups ?? []
}
async function createParentGroup() {
const response = await request('/api/v1/admin/parent-groups/create', {
method: 'POST',
body: { name: newName.value },
})
if (response && response.status === 'success') {
toast.success(response.message)
newName.value = ''
showCreate.value = false
await loadParentGroups()
} else {
toast.error(response?.message ?? 'Fehler beim Anlegen')
}
}
function startEditing(parentGroup) {
editingId.value = parentGroup.id
editName.value = parentGroup.name
}
async function saveName(parentGroup) {
const response = await request('/api/v1/admin/parent-groups/' + parentGroup.id, {
method: 'POST',
body: { name: editName.value },
})
if (response && response.status === 'success') {
toast.success(response.message)
editingId.value = null
await loadParentGroups()
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
async function deleteParentGroup(parentGroup) {
if (!confirm('„' + parentGroup.name + '" wirklich löschen?')) {
return
}
const response = await request('/api/v1/admin/parent-groups/' + parentGroup.id, {
method: 'DELETE',
})
if (response && response.status === 'success') {
toast.success(response.message)
await loadParentGroups()
} else {
toast.error(response?.message ?? 'Fehler beim Löschen')
}
}
</script>
<template>
<AdminAppLayout :title="props.groupLabels.parentGroupPlural">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<table class="parent-group-table">
<thead>
<tr>
<th>Name</th>
<th>Zugeordnete {{ props.groupLabels.localGroupPlural }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="parentGroup in parentGroups" :key="parentGroup.id">
<td>
<input v-if="editingId === parentGroup.id" type="text" v-model="editName" class="form-input"
@keyup.enter="saveName(parentGroup)" />
<span v-else>{{ parentGroup.name }}</span>
</td>
<td>{{ parentGroup.tenantCount }}</td>
<td class="actions">
<template v-if="editingId === parentGroup.id">
<button class="btn-save" @click="saveName(parentGroup)">Speichern</button>
<button class="btn-cancel" @click="editingId = null">Abbrechen</button>
</template>
<template v-else>
<button class="btn-edit" @click="startEditing(parentGroup)">Umbenennen</button>
<button v-if="parentGroup.deletable" class="btn-delete" @click="deleteParentGroup(parentGroup)">Löschen</button>
</template>
</td>
</tr>
</tbody>
</table>
<p class="hint">
Löschen ist nur möglich, solange keine {{ props.groupLabels.localGroupPlural }} und keine Anmeldungen
zugeordnet sind. „Ohne Zuordnung" bleibt immer bestehen.
</p>
<div style="margin-top: 20px;">
<button v-if="!showCreate" class="btn-create" @click="showCreate = true">Neuen Eintrag anlegen</button>
<div v-if="showCreate" class="create-form">
<h3>Neuen Eintrag anlegen ({{ props.groupLabels.parentGroup }})</h3>
<input type="text" v-model="newName" class="form-input" placeholder="Name"
@keyup.enter="createParentGroup" />
<div class="btn-group">
<button class="btn-save" @click="createParentGroup">Anlegen</button>
<button class="btn-cancel" @click="showCreate = false">Abbrechen</button>
</div>
</div>
</div>
</shadowed-box>
</AdminAppLayout>
</template>
<style scoped>
.parent-group-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
}
.parent-group-table thead th {
text-align: left;
padding: 10px 16px;
background-color: #f9fafb;
color: #374151;
font-weight: bold;
border-bottom: 2px solid #d1d5db;
}
.parent-group-table tbody td {
padding: 10px 16px;
border-bottom: 1px solid #e5e7eb;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.hint {
margin-top: 12px;
color: #6b7280;
font-size: 0.9rem;
}
.create-form {
margin-top: 15px;
padding: 20px;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f9fafb;
}
.create-form h3 {
margin: 0 0 15px 0;
color: #374151;
}
.form-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
}
.btn-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn-create, .btn-save, .btn-cancel, .btn-edit, .btn-delete {
padding: 6px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
}
.btn-create, .btn-edit {
background-color: #1d4899;
color: #ffffff;
}
.btn-create:hover, .btn-edit:hover {
background-color: #163a7a;
}
.btn-save {
background-color: #16a34a;
color: #ffffff;
}
.btn-save:hover {
background-color: #15803d;
}
.btn-cancel {
background-color: #e5e7eb;
color: #374151;
}
.btn-cancel:hover {
background-color: #d1d5db;
}
.btn-delete {
background-color: #dc2626;
color: #ffffff;
}
.btn-delete:hover {
background-color: #b91c1c;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
@@ -17,8 +17,16 @@ const form = ref({
name: props.data.name ?? '',
slug: props.data.slug ?? '',
url: props.data.url ?? '',
parent_group_id: props.data.parent_group_id ?? null,
})
// Übergeordnete Gruppe (z. B. „Bund") -- Pflichtangabe, Auswahl aus der LV-seitig gepflegten Liste.
const parentGroups = props.data.parentGroups ?? []
const parentGroupLabel = props.data.parentGroupLabel ?? 'Bund'
const parentGroupName = computed(() =>
parentGroups.find(g => g.id === form.value.parent_group_id)?.name ?? '—'
)
async function save() {
const saveUrl = props.data.saveEndpoint ?? '/api/v1/admin/tenants/' + props.data.slug + '/general'
const response = await request(saveUrl, {
@@ -39,6 +47,7 @@ async function save() {
<div v-if="!editing">
<table class="data-table">
<tr><th>Name:</th><td>{{ form.name }}</td></tr>
<tr><th>{{ parentGroupLabel }}:</th><td>{{ parentGroupName }}</td></tr>
<tr><th>Slug:</th><td>{{ form.slug }}</td></tr>
<tr><th>URL:</th><td>{{ form.url }}</td></tr>
<tr><th>Status:</th><td>
@@ -54,6 +63,14 @@ async function save() {
<th>Name:</th>
<td><input type="text" v-model="form.name" class="form-input" /></td>
</tr>
<tr>
<th>{{ parentGroupLabel }}:</th>
<td>
<select v-model="form.parent_group_id" class="form-input">
<option v-for="group in parentGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</td>
</tr>
<tr>
<th>Slug:</th>
<td><input type="text" v-model="form.slug" class="form-input" /></td>
+31 -5
View File
@@ -5,6 +5,13 @@ import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const props = defineProps({
groupLabels: {
type: Object,
default: () => ({ localGroup: 'Stamm', localGroupPlural: 'Stämme', parentGroup: 'Bund' }),
},
})
const { request } = useAjax()
function openTenant(slug) {
@@ -12,12 +19,16 @@ function openTenant(slug) {
}
const tenants = ref([])
const parentGroups = ref([])
const showCreate = ref(false)
const createForm = ref({
const emptyForm = () => ({
name: '',
slug: '',
url: '',
// Pflichtangabe; vorbelegt mit „Ohne Zuordnung", sobald die Liste geladen ist.
parent_group_id: parentGroups.value.find(g => g.isFallback)?.id ?? null,
})
const createForm = ref(emptyForm())
onMounted(async () => {
await loadTenants()
@@ -33,6 +44,11 @@ async function loadTenants() {
})
const data = await response.json()
tenants.value = data.tenants
parentGroups.value = data.parentGroups ?? []
if (createForm.value.parent_group_id === null) {
createForm.value.parent_group_id = parentGroups.value.find(g => g.isFallback)?.id ?? null
}
}
async function createTenant() {
@@ -44,7 +60,7 @@ async function createTenant() {
if (response && response.status === 'success') {
toast.success(response.message)
showCreate.value = false
createForm.value = { name: '', slug: '', url: '' }
createForm.value = emptyForm()
await loadTenants()
} else {
toast.error(response?.message ?? 'Fehler beim Anlegen')
@@ -53,12 +69,13 @@ async function createTenant() {
</script>
<template>
<AdminAppLayout title="Stämme">
<AdminAppLayout :title="props.groupLabels.localGroupPlural">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<table class="tenant-table">
<thead>
<tr>
<th>Name</th>
<th>{{ props.groupLabels.parentGroup }}</th>
<th>Slug</th>
<th>URL</th>
<th>Status</th>
@@ -67,6 +84,7 @@ async function createTenant() {
<tbody>
<tr v-for="tenant in tenants" :key="tenant.slug" class="tenant-row" @click="openTenant(tenant.slug)">
<td>{{ tenant.name }}</td>
<td>{{ tenant.parentGroup ?? '—' }}</td>
<td>{{ tenant.slug }}</td>
<td>{{ tenant.url }}</td>
<td>
@@ -79,15 +97,23 @@ async function createTenant() {
</table>
<div style="margin-top: 20px;">
<button v-if="!showCreate" class="btn-create" @click="showCreate = true">Neuen Stamm anlegen</button>
<button v-if="!showCreate" class="btn-create" @click="showCreate = true">Neuen Eintrag anlegen ({{ props.groupLabels.localGroup }})</button>
<div v-if="showCreate" class="create-form">
<h3>Neuen Stamm anlegen</h3>
<h3>Neuen Eintrag anlegen ({{ props.groupLabels.localGroup }})</h3>
<table class="form-table">
<tr>
<th>Name</th>
<td><input type="text" v-model="createForm.name" class="form-input" /></td>
</tr>
<tr>
<th>{{ props.groupLabels.parentGroup }}</th>
<td>
<select v-model="createForm.parent_group_id" class="form-input">
<option v-for="group in parentGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</td>
</tr>
<tr>
<th>Slug</th>
<td><input type="text" v-model="createForm.slug" class="form-input" /></td>
@@ -14,7 +14,7 @@ class ChangeCostUnitDetailsCommand {
$this->request->costUnit->distance_allowance = $this->request->distanceAllowance->getAmount();
$this->request->costUnit->mail_on_new = $this->request->mailOnNew;
$this->request->costUnit->billing_deadline = $this->request->billingDeadline;
$this->request->costUnit->billing_deadline = $this->request->billingDeadline?->format('Y-m-d');
$response->success = $this->request->costUnit->save();
return $response;
@@ -17,7 +17,7 @@ class CreateCostUnitCommand {
'name' => $this->request->name,
'tenant' => currentTenant()->slug,
'type' => $this->request->type,
'billing_deadline' => $this->request->billingDeadline,
'billing_deadline' => $this->request->billingDeadline?->format('Y-m-d'),
'distance_allowance' => $this->request->distanceAllowance->getAmount(),
'mail_on_new' => $this->request->mailOnNew,
'allow_new' => true,
@@ -37,7 +37,10 @@ class EditController extends CommonController{
$saveParams = $request->get('formData');
$distanceAllowance = Amount::fromString($saveParams['distanceAllowance']);
$billingDeadline = isset($saveParams['billingDeadline']) ? \DateTime::createFromFormat('Y-m-d', $saveParams['billingDeadline']) : null;
$rawBillingDeadline = $saveParams['billingDeadline'] ?? null;
$billingDeadline = !empty($rawBillingDeadline)
? (\DateTime::createFromFormat('!Y-m-d', $rawBillingDeadline) ?: null)
: null;
$request = new ChangeCostUnitDetailsRequest($costUnit, $distanceAllowance, $saveParams['mailOnNew'], $billingDeadline);
$command = new ChangeCostUnitDetailsCommand($request);
@@ -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;
@@ -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.
*
@@ -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);
+12
View File
@@ -15,6 +15,7 @@
'eventIban': String,
"eventPayPerDay": Boolean,
"participationFeeType": String,
"participantGroupTypes": {type: Array, default: () => []},
})
const errors = reactive({})
@@ -35,6 +36,7 @@
eventIban: props.eventIban ? props.eventIban : '',
eventPayPerDay: props.eventPayPerDay ? props.eventPayPerDay : false,
eventParticipationFeeType: props.participationFeeType ? props.participationFeeType : 'fixed',
eventParticipantGroupType: 'local_group',
});
watch(
@@ -159,6 +161,7 @@
eventIban: formData.eventIban,
eventPayPerDay: formData.eventPayPerDay,
eventParticipationFeeType: formData.eventParticipationFeeType,
eventParticipantGroupType: formData.eventParticipantGroupType,
}
});
@@ -256,6 +259,15 @@
</td>
</tr>
<tr style="vertical-align: top;">
<th class="width-medium pr-20 height-50">Bei der Anmeldung abfragen</th>
<td class="height-50">
<select v-model="formData.eventParticipantGroupType" class="width-half-full">
<option v-for="type in props.participantGroupTypes" :key="type.value" :value="type.value">{{ type.label }}</option>
</select>
</td>
</tr>
<tr style="vertical-align: top;">
<th class="width-medium pr-20 height-50">Preiserhöhung nach Early-Bird-Phase</th>
+2 -1
View File
@@ -10,6 +10,7 @@ import Overview from "./Partials/Overview.vue";
const props = defineProps({
event: Object,
participantGroupLabel: {type: String, default: 'Stamm'},
})
const tabs = [
@@ -24,7 +25,7 @@ const tabs = [
endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/all',
},
{
title: 'Teilis nach Stamm',
title: 'Teilis nach ' + props.participantGroupLabel,
component: ParticipantsList,
endpoint: "/api/v1/event/details/" + props.event.identifier + '/participants/by-local-group',
},
@@ -16,8 +16,13 @@ const emit = defineEmits(['close'])
const dynmicProps = reactive({
localGroups: [],
eatingHabits:[],
participantGroupTypes: [],
localGroupLabelPlural: 'Stämme',
});
// Umstellen zwischen Local und Parent Group nur, solange es keine Anmeldungen gibt.
const participantGroupTypeLocked = (props.event.totalParticipantCount ?? 0) > 0
const contributingLocalGroups = ref([])
const eatingHabits = ref([]);
@@ -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>
@@ -26,6 +26,18 @@ class SaveInvoiceController extends CommonController
]);
}
// Sicherheitsnetz hinter der Oberfläche: Wer gereist ist, steht im Zahlungsgrund auf Beleg und EüR und
// ist bei Fahrtkosten Pflicht. Geprüft wird hier und nicht im Command, weil auch Abrechnungskorrekturen
// durch den Command laufen -- sie bringen ihren Zahlungsgrund mit, aber keine Personen. Und vor dem
// Upload, damit eine abgelehnte Einreichung keinen verwaisten Beleg hinterlässt.
if ($invoiceType === InvoiceType::INVOICE_TYPE_TRAVELLING
&& Validator::make($request->all(), ['travellers' => 'required|string'])->fails()) {
return response()->json([
'status' => 'error',
'message' => 'Bitte gib an, welche Personen gereist sind.'
]);
}
$uploadedFile = null;
if (null !== $request->file('receipt')) {
$maxFileSize = env('MAX_INVOICE_FILE_SIZE', 16);
@@ -41,10 +41,11 @@ const travelReasonComplete = computed(() =>
/**
* Wer gereist ist, steht später als Zahlungsgrund in der Belegliste, auf der EüR und auf dem Beleg. Wer
* den Beleg einreicht, war meist selbst dabei -- deshalb der eigene Name als Vorschlag. Ohne Login
* bleibt das Feld leer, und es hält den Ablauf auch dann nicht auf: Ein Name ist eine Hilfe, keine
* Bedingung.
* bleibt das Feld leer; weiter geht es erst, wenn jemand eingetragen ist -- wie beim Reisegrund.
*/
const travellers = ref(data.userName || '');
const travellersComplete = computed(() => travellers.value.trim() !== '');
const have_receipt = ref('')
const amount = ref(0.00);
const invoiceType = ref(null);
@@ -121,7 +122,7 @@ function handleFileChange(event) {
/>
</fieldset><br /><br />
<fieldset v-if="travelReasonComplete">
<fieldset v-if="travelReasonComplete && travellersComplete">
<legend><span style="font-weight: bolder;">Bist du mit dem ÖPNV gefahren oder besitzt du einen Beleg</span></legend>
<input type="button" style="border-radius: 0; width: 100px;" @click="have_receipt='yes'" value="Ja" />
<input type="button" style="border-radius: 0; width: 100px;" @click="getDistanceAllowance" value="Nein" />
@@ -129,7 +130,8 @@ function handleFileChange(event) {
<br /><br />
<fieldset v-if="have_receipt === 'yes'">
<!-- Auch nach "Ja"/"Nein" gilt die Pflicht: Wer die Personen wieder löscht, kann nicht einreichen. -->
<fieldset v-if="travellersComplete && have_receipt === 'yes'">
<legend><span style="font-weight: bolder;">Wie hoch ist der Betrag?</span></legend>
<AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro
@@ -161,7 +163,7 @@ function handleFileChange(event) {
/>
</fieldset>
<fieldset v-else-if="distanceAllowance != null">
<fieldset v-else-if="travellersComplete && distanceAllowance != null">
<legend><span style="font-weight: bolder;">Reiseinformationen</span></legend>
Gesamtlänge des Reisewegs:
<NumericInput
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Enumerations;
use App\Models\GlobalSetting;
use App\Repositories\GlobalSettingRepository;
/**
* Wonach eine Veranstaltung bei der Anmeldung fragt: nach der Local Group (Mandant, im UI z. B. „Stamm") oder nach
* der Parent Group (übergeordnete Gruppe, im UI z. B. „Bund"). Die Bezeichnungen kommen aus den globalen
* Einstellungen.
*/
enum ParticipantGroupType: string
{
case LocalGroup = 'local_group';
case ParentGroup = 'parent_group';
public function label(): string
{
return match ($this) {
self::LocalGroup => self::setting(GlobalSetting::LOCAL_GROUP_LABEL, 'Stamm'),
self::ParentGroup => self::setting(GlobalSetting::PARENT_GROUP_LABEL, 'Bund'),
};
}
public function pluralLabel(): string
{
return match ($this) {
self::LocalGroup => self::setting(GlobalSetting::LOCAL_GROUP_LABEL_PLURAL, 'Stämme'),
self::ParentGroup => self::setting(GlobalSetting::PARENT_GROUP_LABEL_PLURAL, 'Bünde'),
};
}
/**
* Optionen für das Frontend: [['value' => ..., 'label' => ...], ...].
*
* @return array<int, array{value: string, label: string}>
*/
public static function options(): array
{
return array_map(
static fn (self $type): array => [
'value' => $type->value,
'label' => $type->label(),
],
self::cases(),
);
}
/** Typ zu einem gespeicherten Wert; Unbekanntes fällt auf die Local Group zurück. */
public static function fromStored(?string $value): self
{
return self::tryFrom((string) $value) ?? self::LocalGroup;
}
/**
* Alle Bezeichnungen fürs Frontend (Shared Prop).
*
* @return array{localGroup: string, localGroupPlural: string, parentGroup: string, parentGroupPlural: string}
*/
public static function labels(): array
{
return [
'localGroup' => self::LocalGroup->label(),
'localGroupPlural' => self::LocalGroup->pluralLabel(),
'parentGroup' => self::ParentGroup->label(),
'parentGroupPlural' => self::ParentGroup->pluralLabel(),
];
}
private static function setting(string $key, string $default): string
{
return app(GlobalSettingRepository::class)->get($key, $default);
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* @property string $name
* @property string $type
* @property string $billing_deadline
* @property string|null $billing_deadline Y-m-d, Abrechnungsschluss einschließlich des ganzen Tages
* @property string $distance_allowance
* @property boolean $mail_on_new
* @property boolean $allow_new
+34
View File
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Casts\AmountCast;
use App\Enumerations\EatingHabit;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\ParticipationFeeType;
use App\RelationModels\EventParticipationFee;
use App\RelationModels\EventPaymentMethods;
@@ -13,6 +14,7 @@ use DateTime;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* @property string $tenant
@@ -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');
}
+17
View File
@@ -38,6 +38,7 @@ class EventParticipant extends InstancedModel
'fee_type',
'sibling_reduction',
'local_group',
'parent_group_id',
'birthday',
'address_1',
@@ -143,6 +144,22 @@ class EventParticipant extends InstancedModel
return $this->belongsTo(Tenant::class, 'local_group', 'slug');
}
/** Parent Group -- nur bei Veranstaltungen gesetzt, die danach statt nach der Local Group fragen. */
public function parentGroup()
{
return $this->belongsTo(ParentGroup::class, 'parent_group_id');
}
/** Anzeigename der bei der Anmeldung gewählten Gruppe, je nachdem, wonach die Veranstaltung fragt. */
public function participantGroupName(Event $event) : string
{
if ($event->asksForParentGroup()) {
return $this->parentGroup?->name ?? 'Keine Angabe';
}
return $this->localGroup?->name ?? 'Nicht im LV';
}
public function participationType()
{
return $this->belongsTo(ParticipationType::class, 'participation_type', 'slug');
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use App\Scopes\CommonModel;
/**
* App-weite Einstellung als Schlüssel/Wert-Paar (z. B. die Bezeichnungen „Stamm"/„Bund").
* Nicht mandantengebunden; gelesen wird über {@see \App\Repositories\GlobalSettingRepository}.
*
* @property string $key
* @property string $value
*/
class GlobalSetting extends CommonModel
{
public const string LOCAL_GROUP_LABEL = 'local_group_label';
public const string LOCAL_GROUP_LABEL_PLURAL = 'local_group_label_plural';
public const string PARENT_GROUP_LABEL = 'parent_group_label';
public const string PARENT_GROUP_LABEL_PLURAL = 'parent_group_label_plural';
protected $table = 'global_settings';
protected $primaryKey = 'key';
protected $keyType = 'string';
public $incrementing = false;
protected $fillable = [
'key',
'value',
];
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use App\Scopes\CommonModel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* Übergeordnete Gruppe (Parent Group, im UI z. B. „Bund"). Mehrere Mandanten (Local Groups) gehören zur selben
* Parent Group. Nicht mandantengebunden -- die Liste gilt app-weit.
*
* @property int $id
* @property string $name
* @property boolean $is_fallback
*/
class ParentGroup extends CommonModel
{
protected $table = 'parent_groups';
protected $fillable = [
'name',
'is_fallback',
];
protected $casts = [
'is_fallback' => 'boolean',
];
/** Die Gruppe „Ohne Zuordnung" -- Vorgabe für Mandanten, denen keine Gruppe zugewiesen wurde. */
public static function fallback() : self {
return self::where('is_fallback', true)->orderBy('id')->firstOrFail();
}
/** Anzeigereihenfolge: alphabetisch, „Ohne Zuordnung" (Fallback) immer zuletzt. */
public function scopeOrdered(Builder $query) : Builder {
return $query->orderBy('is_fallback')->orderBy('name');
}
/**
* Dieselbe Reihenfolge wie {@see scopeOrdered()} für bereits geladene Gruppen.
*
* @param Collection<int, self> $parentGroups
* @return Collection<int, self>
*/
public static function sortForDisplay(Collection $parentGroups) : Collection {
return $parentGroups
->sort(static fn (self $a, self $b): int =>
[$a->is_fallback, mb_strtolower($a->name)] <=> [$b->is_fallback, mb_strtolower($b->name)])
->values();
}
public function tenants() : HasMany {
return $this->hasMany(Tenant::class, 'parent_group_id');
}
public function eventParticipants() : HasMany {
return $this->hasMany(EventParticipant::class, 'parent_group_id');
}
}
+9
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Scopes\CommonModel;
use App\Support\Text;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property string $slug
@@ -35,6 +36,7 @@ use App\Support\Text;
* @property string|null $tax_exemption_reason
* @property string|null $tax_exemption_note
* @property string $vat_pricing_mode
* @property int $parent_group_id
*/
class Tenant extends CommonModel
{
@@ -46,9 +48,15 @@ class Tenant extends CommonModel
{
static::creating(static function (self $tenant): void {
$tenant->invoice_prefix ??= strtoupper((string) $tenant->slug);
// Die Parent Group ist Pflicht; ohne Angabe landet der Mandant bei „Ohne Zuordnung".
$tenant->parent_group_id ??= ParentGroup::fallback()->id;
});
}
public function parentGroup() : BelongsTo {
return $this->belongsTo(ParentGroup::class, 'parent_group_id');
}
public static function getTempDirectory() : string {
return currentTenant()->slug . '/temp-data/';
}
@@ -95,6 +103,7 @@ class Tenant extends CommonModel
'tax_number',
'vat_id',
'invoice_prefix',
'parent_group_id',
];
protected $casts = [
+3 -1
View File
@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Repositories\GlobalSettingRepository;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
@@ -12,7 +13,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
// Eine Instanz je Request, damit die Einstellungen nur einmal gelesen werden.
$this->app->scoped(GlobalSettingRepository::class);
}
/**
+5
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Enumerations\EatingHabit;
use App\Enumerations\InvoiceType;
use App\Enumerations\ParticipantGroupType;
use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\Enumerations\TravelReason;
@@ -45,6 +46,8 @@ class GlobalDataProvider {
'currentEvent' => $this->getCurrentEventData(),
'canAccessAdmin' => $canAccessAdmin,
'isMainAdmin' => $isMainAdmin,
// Bezeichnungen der Gruppen-Ebenen (z. B. Stamm/Stämme, Bund/Bünde) aus den globalen Einstellungen.
'groupLabels' => ParticipantGroupType::labels(),
]);
}
@@ -222,6 +225,8 @@ class GlobalDataProvider {
[
'localGroups' => Tenant::where(['is_active_local_group' => true])->get(),
'eatingHabits' => EatingHabit::all(),
'participantGroupTypes' => ParticipantGroupType::options(),
'localGroupLabelPlural' => ParticipantGroupType::LocalGroup->pluralLabel(),
'paymentMethods' => AvailablePaymentMethod::where('active', true)->get()
->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)),
]);
+23 -7
View File
@@ -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();
}
}
+1
View File
@@ -49,6 +49,7 @@ class CostUnitResource {
$data = array_merge(
$this->costUnit->toArray(),
[
'billingDeadline' => $this->costUnit->billing_deadline,
'distanceAllowanceSmall' => new Amount($this->costUnit->distance_allowance, '')->toString(),
'distanceAllowanceFull' => new Amount($this->costUnit->distance_allowance, ' Euro')->toString(),
'totalAmount' => new Amount($totalAmount, ' Euro')->toString(),
+6 -1
View File
@@ -51,7 +51,12 @@ class EventParticipantResource extends JsonResource
'unregisteredAt' => $this->resource->unregistered_at?->format('d.m.Y'),
'fullname' => $this->resource->getFullName(),
'age' => new Age($this->resource->birthday)->getAge(),
'localgroup' => $this->resource->localGroup()?->first()?->name ?? 'Nicht im LV',
// Die bei der Anmeldung gewählte Gruppe: Local Group oder Parent Group, je nach Veranstaltung.
'participantGroup' => $this->resource->participantGroupName($event),
// Wert für die Auswahl in der Verwaltung: Slug der Local Group bzw. ID der Parent Group.
'participantGroupValue' => $event->asksForParentGroup()
? ($this->resource->parent_group_id !== null ? (string) $this->resource->parent_group_id : '')
: ($this->resource->local_group ?? ''),
// Die Zuordnungen sind nullable: die Kurzanmeldung erhebt nicht alles, und in der Verwaltung
// lassen sich Werte leeren. Ohne Fallback fielen Teilnehmerliste und Detailansicht komplett aus.
'swimmingPermission' => $this->resource->swimmingPermission()->first()?->short ?? 'Unbekannt',
+17 -2
View File
@@ -6,6 +6,7 @@ use App\Enumerations\ParticipationFeeType;
use App\Enumerations\ParticipationType;
use App\Enumerations\VatPricingMode;
use App\Models\Event;
use App\Models\ParentGroup;
use App\Support\DateRange;
use App\ValueObjects\Amount;
use DateTime;
@@ -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();
@@ -239,8 +254,8 @@ class EventResource extends JsonResource{
}
public function getMultiplier() : float {
$earlyBirdEnd = $this->event->early_bird_end;
if ($earlyBirdEnd > now()) {
// Der Frühbucherpreis gilt einschließlich des ganzen Stichtags.
if (now() <= $this->event->early_bird_end->copy()->endOfDay()) {
return 1;
}
+6
View File
@@ -2,6 +2,7 @@
namespace App\Scopes;
use App\Domains\Admin\Repositories\AdminParentGroupRepository;
use App\Domains\Admin\Repositories\AdminTenantRepository;
use App\Domains\Admin\Repositories\AdminUserRepository;
use App\Models\Tenant;
@@ -10,6 +11,7 @@ use App\Repositories\CostUnitRepository;
use App\Repositories\EstimatesRepository;
use App\Repositories\EventParticipantRepository;
use App\Repositories\EventRepository;
use App\Repositories\GlobalSettingRepository;
use App\Repositories\InvoiceRepository;
use App\Repositories\PageTextRepository;
use App\Repositories\ParticipantRefundRepository;
@@ -31,6 +33,8 @@ abstract class CommonController {
protected PaymentMethodRepository $paymentMethods;
protected AdminUserRepository $adminUsers;
protected AdminTenantRepository $adminTenants;
protected AdminParentGroupRepository $adminParentGroups;
protected GlobalSettingRepository $globalSettings;
public function __construct() {
$this->tenant = currentTenant();
@@ -45,6 +49,8 @@ abstract class CommonController {
$this->paymentMethods = new PaymentMethodRepository();
$this->adminUsers = new AdminUserRepository();
$this->adminTenants = new AdminTenantRepository();
$this->adminParentGroups = new AdminParentGroupRepository();
$this->globalSettings = app(GlobalSettingRepository::class);
}
protected function checkAuth() {
+6 -9
View File
@@ -15,7 +15,6 @@ use Illuminate\Support\Facades\Mail;
class CloseCostUnit implements CronTask {
public function handle(): void
{
$now = now();
$costUnitRepository = new CostUnitRepository();
$now = now();
$billingResetEnd = clone $now;
@@ -25,7 +24,7 @@ class CloseCostUnit implements CronTask {
$billingEnd = $costUnit['billing_deadline'];
if ($billingEnd === null) {
$billingEnd = \DateTime::createFromFormat('Y-m-d H:i:s', $billingResetEnd)->format('Y-m-d H:i:s');
$billingEnd = $billingResetEnd->format('Y-m-d');
new ChangeCostUnitDetailsCommand(new ChangeCostUnitDetailsRequest(
CostUnit::where('id', $costUnit['id'])->first(),
Amount::fromString($costUnit['distance_allowance']),
@@ -34,9 +33,9 @@ class CloseCostUnit implements CronTask {
))->execute();
}
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
$billingEndTime = \DateTime::createFromFormat('!Y-m-d', $billingEnd);
if (false === $billingEndTime) {
$billingEndTime = \DateTime::createFromFormat('Y-m-d', $billingEnd);
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
}
if (false === $billingEndTime) {
@@ -56,12 +55,10 @@ class CloseCostUnit implements CronTask {
continue;
}
$billingEndTime->setTime(0,0,0);
$billingEndTime->add(new \DateInterval('P1D'));
// Der Abrechnungsschluss gilt einschließlich des ganzen Tages: bis 23:59:59 darf eingereicht werden.
$billingEndTime->setTime(23, 59, 59);
if ($billingEndTime < $now) {
if ($now > $billingEndTime) {
new ChangeCostUnitStateCommand(
new ChangeCostUnitStateRequest(
CostUnit::where('id', $costUnit['id'])->first(),false, false
+2 -1
View File
@@ -12,7 +12,8 @@ class CloseEvent implements CronTask {
$eventRepository = new EventRepository();
/** @var Event $event */
foreach ($eventRepository->getAvailable(false) as $event) {
if ($event->registration_final_end <= $now ) {
// Der Anmeldeschluss gilt einschließlich des ganzen Tages: bis 23:59:59 darf man sich anmelden.
if ($now > $event->registration_final_end->copy()->endOfDay()) {
$event->registration_allowed = false;
$event->save();
}
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Der Abrechnungsschluss ist ein Tag, keine Uhrzeit: eingereicht werden darf bis einschließlich 23:59 des Stichtags.
* Die Uhrzeit, die bisher mitgespeichert wurde, hat der Cron ohnehin verworfen -- das Abschneiden ändert kein
* Verhalten. Erst normalisieren, dann den Typ ändern, damit das ALTER nichts abschneiden muss.
*/
return new class extends Migration {
public function up(): void
{
DB::table('cost_units')
->whereNotNull('billing_deadline')
->update(['billing_deadline' => DB::raw('DATE(billing_deadline)')]);
Schema::table('cost_units', function (Blueprint $table) {
$table->date('billing_deadline')->nullable()->change();
});
}
public function down(): void
{
Schema::table('cost_units', function (Blueprint $table) {
$table->dateTime('billing_deadline')->nullable()->change();
});
}
};
@@ -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');
});
}
};
+104
View File
@@ -0,0 +1,104 @@
# Produktions-Image für endeavour (KeyHelp-Server, siehe docs/implement-endeavour.md).
# Unabhängig von docker/prod.Dockerfile, das der bestehende Server weiter nutzt.
#
# Targets:
# app – PHP-FPM mit Code, vendor/ und gebauten Assets
# web – nginx, liefert public/ aus und reicht PHP an "app" weiter
# ---------------------------------------------------------------------------
# base: PHP 8.5 mit den Extensions, die mareike braucht
# ---------------------------------------------------------------------------
FROM php:8.5-fpm-alpine AS base
ARG UID=2000
ARG GID=2000
RUN apk add --no-cache \
imagemagick \
libpng \
libxml2 \
libzip \
oniguruma \
&& apk add --no-cache --virtual .build-deps \
$PHPIZE_DEPS \
imagemagick-dev \
libpng-dev \
libxml2-dev \
libzip-dev \
oniguruma-dev \
&& pecl install imagick \
&& docker-php-ext-enable imagick \
&& docker-php-ext-install mysqli pdo_mysql mbstring zip exif pcntl gd \
&& apk del .build-deps \
&& rm -rf /tmp/pear
# Nutzer mit der UID/GID des Host-Nutzers, damit das eingebundene storage/ beschreibbar ist.
# Existiert die GID im Image schon, wird die vorhandene Gruppe verwendet.
RUN if getent group "${GID}" > /dev/null; then \
GROUP_NAME="$(getent group "${GID}" | cut -d: -f1)"; \
else \
addgroup -S -g "${GID}" mareike && GROUP_NAME=mareike; \
fi \
&& adduser -S -D -H -u "${UID}" -G "${GROUP_NAME}" mareike
COPY --chmod=0755 docker/php/composer.phar /usr/bin/composer
COPY docker/endeavour/php.ini /usr/local/etc/php/conf.d/zz-mareike.ini
WORKDIR /var/www/html
# ---------------------------------------------------------------------------
# vendor: Composer-Abhängigkeiten (ohne Dev-Pakete)
# ---------------------------------------------------------------------------
FROM base AS vendor
RUN apk add --no-cache git unzip
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress
# ---------------------------------------------------------------------------
# assets: Vite-Build (Vite importiert aus app/, daher der volle Quellcode)
# ---------------------------------------------------------------------------
FROM node:24-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
# ---------------------------------------------------------------------------
# app: PHP-FPM
# ---------------------------------------------------------------------------
FROM base AS app
COPY . .
COPY --from=vendor /var/www/html/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
# Der Code gehört root; beschreibbar sind nur storage/ und bootstrap/cache/.
RUN composer dump-autoload --optimize --no-dev --no-interaction \
&& mkdir -p \
storage/app/private \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
storage/temp \
&& chown -R mareike: storage bootstrap/cache
USER mareike
EXPOSE 9000
CMD ["php-fpm"]
# ---------------------------------------------------------------------------
# web: nginx vor PHP-FPM
# ---------------------------------------------------------------------------
FROM nginx:stable-alpine AS web
COPY docker/endeavour/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=app /var/www/html/public /var/www/html/public
EXPOSE 80
+36
View File
@@ -0,0 +1,36 @@
# Build-Kontext für docker/endeavour/Dockerfile.
# BuildKit nutzt diese Datei anstelle der .dockerignore im Projektroot, die für den bestehenden Server
# unverändert bleibt. Wichtig: keine .env ins Image – sie wird zur Laufzeit eingebunden.
.git
.ai
.junie
.claude
.idea
.fleet
.vscode
.zed
.env
.env.*
node_modules
vendor
public/build
public/hot
public/storage
storage/*
bootstrap/cache/*.php
tests
.phpunit.cache
.phpunit.result.cache
docker/ssl
docker/logs
docker-compose.yaml
_ide_helper.php
_ide_helper_models.php
gitlog.log
+42
View File
@@ -0,0 +1,42 @@
# Compose-Stack für endeavour (KeyHelp-Server, siehe docs/implement-endeavour.md).
# Wird über /opt/mareike/bin/deploy.sh gesteuert, das die MAREIKE_*-Variablen setzt.
name: mareike
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
app:
build:
context: ../..
dockerfile: docker/endeavour/Dockerfile
target: app
args:
UID: ${MAREIKE_UID:-2000}
GID: ${MAREIKE_GID:-2000}
image: mareike-endeavour-app:latest
restart: unless-stopped
volumes:
- ${MAREIKE_ENV_FILE:-/opt/mareike/shared/.env}:/var/www/html/.env:ro
- ${MAREIKE_STORAGE:-/opt/mareike/storage}:/var/www/html/storage
# Socket der KeyHelp-MariaDB auf dem Host (DB_SOCKET=/run/mysqld/mysqld.sock)
- ${MAREIKE_DB_SOCKET_DIR:-/run/mysqld}:/run/mysqld
logging: *logging
web:
build:
context: ../..
dockerfile: docker/endeavour/Dockerfile
target: web
image: mareike-endeavour-web:latest
restart: unless-stopped
depends_on:
- app
ports:
# Nur lokal erreichbar – nach außen geht es ausschließlich über den KeyHelp-Webserver.
- "127.0.0.1:${MAREIKE_HTTP_PORT:-8080}:80"
logging: *logging
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
#
# Deployment von mareike auf endeavour (KeyHelp-Server), siehe docs/implement-endeavour.md.
#
# Liegt auf dem Server als KOPIE unter /opt/mareike/bin/deploy.sh – nicht direkt aus src/ ausführen,
# weil der git-Checkout sonst das laufende Skript austauscht.
#
# Aufruf (als root):
# deploy.sh [REF] Tag, Branch oder Commit ausrollen (Default: main)
# deploy.sh artisan … php artisan im laufenden app-Container
# deploy.sh compose … docker compose mit der endeavour-Konfiguration (z. B. "compose logs -f")
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"
export MAREIKE_ENV_FILE="$MAREIKE_BASE/shared/.env"
export MAREIKE_STORAGE="$MAREIKE_BASE/storage"
export MAREIKE_HTTP_PORT="${MAREIKE_HTTP_PORT:-8080}"
export MAREIKE_DB_SOCKET_DIR="${MAREIKE_DB_SOCKET_DIR:-/run/mysqld}"
MAREIKE_UID="$(id -u "$MAREIKE_USER")"
MAREIKE_GID="$(id -g "$MAREIKE_USER")"
export MAREIKE_UID MAREIKE_GID
compose() {
docker compose -f "$SRC/docker/endeavour/compose.yaml" "$@"
}
case "${1:-}" in
artisan)
shift
compose exec app php artisan "$@"
exit
;;
compose)
shift
compose "$@"
exit
;;
esac
REF="${1:-main}"
if [[ ! -f "$MAREIKE_ENV_FILE" ]]; then
echo "Fehlt: $MAREIKE_ENV_FILE (Vorlage: docker/endeavour/env.production.example)" >&2
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
git -C "$SRC" checkout --force -B "$REF" "origin/$REF"
else
git -C "$SRC" checkout --force --detach "$REF"
fi
git -C "$SRC" clean -fd
echo "==> storage/ vorbereiten"
for dir in \
"" \
app app/private app/public \
framework framework/cache framework/cache/data framework/sessions framework/views \
logs temp
do
install -d -m 0775 -o "$MAREIKE_USER" -g "$MAREIKE_GID" "$MAREIKE_STORAGE/$dir"
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
echo "==> Container starten"
compose up -d --remove-orphans
compose exec -T app php artisan view:clear
docker image prune -f > /dev/null
echo "==> mareike $(cat "$SRC/version") ($(git -C "$SRC" rev-parse --short HEAD)) läuft."
+65
View File
@@ -0,0 +1,65 @@
# Vorlage für /opt/mareike/shared/.env auf endeavour.
# Nach dem Kopieren alle <...>-Platzhalter ersetzen. Diese Datei kommt NIE ins Image und NIE ins Repo.
# Keine ${...}-Verweise auf andere Variablen verwenden, Werte ausschreiben.
APP_NAME=mareike
APP_ENV=production
APP_DEBUG=false
# Erzeugen mit: echo "base64:$(openssl rand -base64 32)" – danach NIE mehr ändern (verschlüsselte Daten!)
APP_KEY=
# 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
APP_FALLBACK_LOCALE=de
LOG_CHANNEL=stack
LOG_STACK=daily
LOG_DAILY_DAYS=30
LOG_LEVEL=warning
# KeyHelp-MariaDB auf dem Host, per Unix-Socket (Verzeichnis /run/mysqld ist in den Container gemountet)
DB_CONNECTION=mysql
DB_HOST=localhost
DB_SOCKET=/run/mysqld/mysqld.sock
DB_DATABASE=<datenbankname-aus-keyhelp>
DB_USERNAME=<datenbanknutzer-aus-keyhelp>
DB_PASSWORD=<passwort>
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_SECURE_COOKIE=true
SESSION_DOMAIN=null
CACHE_STORE=database
QUEUE_CONNECTION=sync
FILESYSTEM_DISK=local
# Versand über ein KeyHelp-Postfach (STARTTLS auf 587)
MAIL_MAILER=smtp
MAIL_SCHEME=smtp
MAIL_HOST=<hostname-des-mailservers>
MAIL_PORT=587
MAIL_USERNAME=<noreply@meine-anmeldung.digital>
MAIL_PASSWORD=<passwort>
MAIL_FROM_ADDRESS=<noreply@meine-anmeldung.digital>
MAIL_FROM_NAME=mareike
# Empfänger der Admin-Benachrichtigungen (z. B. neue Registrierungen)
APP_ADMIN_MAIL=<admin@meine-anmeldung.digital>
APP_ADMIN_NAME="<Name>"
# Maximale Größe hochgeladener Belege in MB (php.ini erlaubt bis 64)
MAX_INVOICE_FILE_SIZE=16
# WebDAV-Ablage: unbedingt ein EIGENER Ordner, getrennt vom bestehenden Server
WEBDAV_HOST=<https://cloud.example.org/remote.php/dav/files/<nutzer>/<eigener-ordner>/>
WEBDAV_USER=<nutzer>
WEBDAV_PASS=<app-passwort>
# Prüfdienst für erweiterte Führungszeugnisse
COC_CHECK_URL=<https://efzcheck.example.org/api/v1/check-efz>
# MUSS leer bleiben: "bdp-lv-sachsen" würde beim Seeden die Sachsen-Tenants und deren Personen einspielen.
PROVIDER=
+58
View File
@@ -0,0 +1,58 @@
# nginx im Container "web" auf endeavour.
# TLS terminiert der KeyHelp-Webserver auf dem Host und reicht per Proxy an 127.0.0.1:8080 weiter.
# Echte Client-IP aus X-Forwarded-For übernehmen, aber nur von Docker-internen Adressen
# (der Proxy auf dem Host erreicht den Container über das Bridge-Gateway).
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
server {
listen 80 default_server;
server_name _;
root /var/www/html/public;
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 / {
try_files $uri $uri/ /index.php?$query_string;
}
# Vite-Assets sind versioniert (Hash im Dateinamen) und dürfen lange gecacht werden.
location /build/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# Nach außen läuft alles über HTTPS (KeyHelp). So erzeugt Laravel https-URLs,
# ohne dass im App-Code trustProxies gesetzt werden muss.
fastcgi_param HTTPS on;
fastcgi_param REQUEST_SCHEME https;
fastcgi_param SERVER_PORT 443;
fastcgi_read_timeout 300;
}
location ~ /\.(?!well-known) {
deny all;
}
}
+20
View File
@@ -0,0 +1,20 @@
; PHP-Einstellungen für endeavour (Container "app").
; Uploads: Obergrenze für MAX_INVOICE_FILE_SIZE (MB, Default 16) in der .env, POST etwas größer für die
; Formulardaten.
upload_max_filesize = 64M
post_max_size = 70M
memory_limit = 512M
max_execution_time = 120
expose_php = Off
date.timezone = Europe/Berlin
; OPcache ist ab PHP 8.5 fest eingebaut. Zeitstempel werden weiter geprüft, weil Blade kompilierte
; Views in storage/ unter gleichem Dateinamen neu schreibt.
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 2
+463
View File
@@ -0,0 +1,463 @@
# mareike auf endeavour ausrollen (KeyHelp-Server mit Docker)
Diese Anleitung beschreibt, wie mareike **zusätzlich** zum bestehenden Server auf *endeavour* läuft. Endeavour ist ein
Server, der mit KeyHelp verwaltet wird. Die Parent-Domain ist `meine-anmeldung.digital`.
KeyHelp selbst nutzt kein Docker. mareike läuft deshalb in zwei Containern **neben** KeyHelp. KeyHelp übernimmt
weiter, was es gut kann: Domains, Let's-Encrypt-Zertifikate, MariaDB, Postfächer und Backups.
> **Der bestehende Server bleibt unberührt.** Für endeavour gibt es eigene Dateien unter `docker/endeavour/`.
> `docker-compose.prod`, `docker/prod.Dockerfile`, `docker/run-mareike.sh`, `docker/nginx/default.conf` und
> `.dockerignore` werden nicht verändert und von endeavour nicht benutzt.
---
## 1. Überblick
```
Browser ──HTTPS──▶ KeyHelp-Webserver (Apache/nginx, Let's Encrypt, eine Domain je Tenant)
│ Proxy, Host-Header bleibt erhalten
▼
127.0.0.1:8080 ──▶ Container "web" (nginx, liefert public/ aus)
│ FastCGI
▼
Container "app" (PHP 8.5-FPM, mareike)
│ Unix-Socket /run/mysqld/mysqld.sock
▼
KeyHelp-MariaDB (Host)
```
- **Tenant-Erkennung:** `IdentifyTenant` sucht den Tenant über `tenants.url` = Host der Anfrage. Deshalb muss der
Proxy den Host-Header unverändert weitergeben, und jede Tenant-Domain braucht exakt diesen Eintrag in `tenants.url`.
- **HTTPS:** TLS endet bei KeyHelp. Die nginx im Container meldet PHP trotzdem `HTTPS=on`, damit Laravel `https://`-Links
erzeugt. Eine Code-Änderung ist dafür nicht nötig.
- **Port 8080** ist nur an `127.0.0.1` gebunden und von außen nicht erreichbar.
### Dateien im Repo
| Datei | Zweck |
|---|---|
| `docker/endeavour/Dockerfile` | Multi-Stage-Build. Target `app` enthält PHP-FPM, Code, vendor/ und Assets, Target `web` enthält nginx und public/. |
| `docker/endeavour/Dockerfile.dockerignore` | Eigener Build-Kontext. Unter anderem kommt **keine `.env` ins Image**. |
| `docker/endeavour/compose.yaml` | Stack mit den Services `app` und `web`. |
| `docker/endeavour/nginx.conf` | nginx im Container, inkl. `HTTPS on` und echter Client-IP. |
| `docker/endeavour/php.ini` | Upload-Größen, Speicher, OPcache. |
| `docker/endeavour/env.production.example` | Vorlage für die `.env` auf dem Server. |
| `docker/endeavour/deploy.sh` | Deploy-Skript: pullen, bauen, migrieren, starten. |
### Verzeichnisse auf endeavour
```
/opt/mareike/
├── bin/deploy.sh Kopie von docker/endeavour/deploy.sh
├── src/ Git-Checkout (wird bei jedem Deploy aktualisiert)
├── 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
```
---
## 2. Docker installieren
Als root auf endeavour. Das Beispiel gilt für Debian, bei Ubuntu steht in den Pfaden `ubuntu` statt `debian`.
```bash
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```
Log-Rotation für alle Container (`/etc/docker/daemon.json`):
```json
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "5" }
}
```
Docker soll erst **nach** MariaDB starten, weil der Container das Socket-Verzeichnis `/run/mysqld` einbindet:
```bash
mkdir -p /etc/systemd/system/docker.service.d
cat > /etc/systemd/system/docker.service.d/after-mariadb.conf <<'EOF'
[Unit]
After=mariadb.service
Wants=mariadb.service
EOF
systemctl daemon-reload
systemctl restart docker
docker run --rm hello-world
```
> **KeyHelp-Firewall:** Docker legt eigene iptables-Regeln an. Lädt KeyHelp seine Firewall neu, können diese Regeln
> verloren gehen. Die Container sind dann nicht mehr erreichbar (502 im Browser). Abhilfe ist
> `systemctl restart docker`. Weil mareike nur an `127.0.0.1` lauscht, öffnet Docker nach außen keine Ports.
---
## 3. Systemnutzer, Verzeichnisse, Repository
Die Container laufen mit der UID eines eigenen Host-Nutzers, damit `storage/` sauber beschreibbar ist. Der Name weicht
bewusst von den KeyHelp-Konten ab, weil KeyHelp für seine Konten selbst Systemnutzer anlegt.
```bash
getent passwd 2000 || useradd --system --uid 2000 --user-group --no-create-home --shell /usr/sbin/nologin mareike-docker
# Ist UID 2000 belegt, eine andere freie UID wählen. deploy.sh liest sie automatisch aus.
install -d -m 0755 /opt/mareike /opt/mareike/bin
install -d -m 0700 /opt/mareike/shared
install -d -m 0775 -o mareike-docker -g mareike-docker /opt/mareike/storage
```
Deploy-Key für das Repository anlegen (nur Lesezugriff):
```bash
ssh-keygen -t ed25519 -f /root/.ssh/mareike_deploy -N "" -C "endeavour-deploy"
cat /root/.ssh/mareike_deploy.pub # in Gitea beim Repo th.guenther/mareike als Deploy-Key (read-only) eintragen
cat >> /root/.ssh/config <<'EOF'
Host git.zahlenlabyrinth.de
Port 9922
User git
IdentityFile /root/.ssh/mareike_deploy
IdentitiesOnly yes
EOF
git clone ssh://git@git.zahlenlabyrinth.de:9922/th.guenther/mareike.git /opt/mareike/src
install -m 0750 /opt/mareike/src/docker/endeavour/deploy.sh /opt/mareike/bin/deploy.sh
```
> Ändert sich `docker/endeavour/deploy.sh` im Repo, danach den `install`-Befehl erneut ausführen.
---
## 4. Datenbank in KeyHelp anlegen
1. Im KeyHelp-Panel ein Konto für mareike anlegen oder ein vorhandenes wählen, z. B. `anmeldung`. Unter diesem Konto
liegen später auch die Domains.
2. Unter **Datenbanken** eine neue MariaDB-Datenbank mit Benutzer anlegen. Der Benutzer ist `@localhost`. Den
Remote-Zugriff **nicht** aktivieren, denn der Container verbindet sich über den Unix-Socket und gilt damit als
`localhost`.
3. Den Socket-Pfad prüfen:
```bash
mysql -e "SELECT @@socket;" # erwartet: /run/mysqld/mysqld.sock
```
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.
---
## 5. `.env` anlegen
```bash
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
```
Die wichtigsten Punkte:
- `APP_KEY` wird einmal erzeugt und dann **nie wieder geändert**, weil Laravel damit verschlüsselte Daten sonst nicht
mehr lesen kann.
- `APP_URL` ist die Domain des Dach-Tenants `lv` (siehe Abschnitt 6).
- `DB_*` sind die Daten aus Schritt 4. `DB_HOST=localhost` und `DB_SOCKET` bleiben wie in der Vorlage.
- `MAIL_*`: Am einfachsten ist ein Postfach in KeyHelp, etwa `noreply@meine-anmeldung.digital`. Als Host den
Mail-Hostnamen des Servers eintragen, mit Port 587.
- `WEBDAV_*`: **eigener Zielordner**, damit sich endeavour und der bestehende Server nicht gegenseitig Dateien
überschreiben.
- `PROVIDER` **muss leer bleiben.** Mit `bdp-lv-sachsen` würde `db:seed` die Sachsen-Tenants samt Personendaten
einspielen.
- `ASSET_URL` nicht setzen, dann kommen die Assets von der jeweiligen Tenant-Domain.
- In der Datei keine `${VAR}`-Verweise verwenden, sondern alle Werte ausschreiben.
---
## 6. Erstinstallation
### 6.1 Erstes Deployment
```bash
/opt/mareike/bin/deploy.sh main # oder einen Release-Tag, z. B. 4.9.1
```
Das Skript holt den Code, baut beide Images, legt die Tabellen per `migrate` an und startet die Container. Der erste
Build dauert ein paar Minuten.
Kurztest:
```bash
/opt/mareike/bin/deploy.sh compose ps
curl -sI http://127.0.0.1:8080/ | head -1 # 404 ist hier richtig: es gibt noch keinen Tenant
```
### 6.2 Stammdaten einspielen
```bash
/opt/mareike/bin/deploy.sh artisan db:seed --force
```
Mit leerem `PROVIDER` läuft nur der `ProductionDataSeeder`. Er legt Rollen, Status, Zahlungsarten, Kostenstellentypen
und die Befreiungsgründe an. Den Befehl **nur einmal** ausführen, ein zweiter Lauf würde Duplikate anlegen.
### 6.3 Dach-Tenant `lv` und Cron-Aufgaben anlegen
Der Slug `lv` ist im Code fest als Dach-Ebene verdrahtet (`LvOnlyMiddleware`, Registrierung, Tenant-Verwaltung).
endeavour braucht deshalb genau einen Tenant mit diesem Slug. Seine `url` ist die Domain, die in Abschnitt 7 in KeyHelp
angelegt wird.
```bash
/opt/mareike/bin/deploy.sh artisan tinker
```
```php
App\Models\Tenant::create([
'slug' => 'lv',
'name' => '<Name des Verbands>',
'url' => 'meine-anmeldung.digital',
'email' => '<kontakt@…>',
'email_finance' => '<finanzen@…>',
'account_name' => '<Kontoinhaber>',
'account_iban' => '<IBAN>',
'account_bic' => '<BIC>',
'city' => '<Ort>',
'postcode' => '<PLZ>',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
use App\Enumerations\CronTaskType;
App\Models\CronTask::create(['name' => 'UploadInvoices', 'execution_type' => CronTaskType::CRON_TASK_TYPE_REALTIME]);
App\Models\CronTask::create(['name' => 'CloseCostUnit', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '00:05']);
App\Models\CronTask::create(['name' => 'CloseEvent', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '00:10']);
App\Models\CronTask::create(['name' => 'NotifyTeam', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '20:00']);
```
Die Cron-Aufgaben entsprechen denen des bestehenden Servers (`BdpLvSachsenDataSeeder::installCronTasks()`). Sie gelten
global und werden für jeden aktiven Tenant ausgeführt.
### 6.4 Erste Administratorin / ersten Administrator
1. Auf `https://meine-anmeldung.digital` ganz normal registrieren. Voraussetzung: Abschnitt 7 ist
erledigt und der Mailversand funktioniert.
2. Das Konto zur Administration hochstufen:
```bash
mysql <datenbankname> -e "UPDATE users SET user_role_main='ROLE_ADMINISTRATOR', user_role_local_group='ROLE_GROUP_LEADER', active=1 WHERE email='<adresse>';"
```
---
## 7. Tenant-Domain in KeyHelp einrichten (je Tenant)
Domain-Schema:
- 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 rewrite
systemctl reload apache2
```
### 7.2 Domain einrichten
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:
**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
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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
client_max_body_size 70m;
}
```
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.
### 7.3 Tenant in mareike anlegen
Im Dach-Tenant `lv` unter der Tenant-Verwaltung den neuen Tenant anlegen. Als URL **exakt** die Domain ohne `https://`
und ohne Schrägstrich eintragen, z. B. `wilde-moehre.meine-anmeldung.digital`.
Für den Tenant `lv` selbst ist das bereits in Schritt 6.3 passiert.
---
## 8. Cron einrichten
mareike hat keinen Laravel-Scheduler. Die Aufgaben laufen über die URL `/execute-crons`, die für **alle** aktiven
Tenants arbeitet. Sie muss einmal pro Minute aufgerufen werden, dabei genügt der Host-Header eines beliebigen aktiven
Tenants:
```bash
cat > /etc/cron.d/mareike <<'EOF'
* * * * * root curl -fsS -o /dev/null -H "Host: meine-anmeldung.digital" http://127.0.0.1:8080/execute-crons
EOF
```
Kontrolle: `/opt/mareike/storage/logs/` enthält die Task-Logs.
---
## 9. Updates ausrollen
```bash
/opt/mareike/bin/deploy.sh 4.9.2 # Release-Tag
/opt/mareike/bin/deploy.sh main # oder Branch
```
Das Skript läuft in dieser Reihenfolge:
1. `git fetch` und Checkout des Standes.
2. `storage/`-Ordner prüfen.
3. Images neu bauen, dabei werden Basis-Images mit Sicherheitsupdates frisch gezogen.
4. `migrate --force` ausführen.
5. Container neu starten.
6. Kompilierte Views leeren.
7. Alte Images aufräumen.
Nützliche Kurzbefehle:
```bash
/opt/mareike/bin/deploy.sh compose ps
/opt/mareike/bin/deploy.sh compose logs -f web
/opt/mareike/bin/deploy.sh artisan migrate:status
/opt/mareike/bin/deploy.sh artisan tinker
```
**Rollback:** Den vorherigen Tag ausrollen (`deploy.sh 4.9.1`). Migrationen werden dabei **nicht** zurückgedreht.
Enthält ein Release Datenbankänderungen, deshalb vorher ein DB-Backup ziehen.
---
## 10. Backup
| Was | Wie |
|---|---|
| Datenbank | Über das KeyHelp-Backup, weil die DB von KeyHelp verwaltet wird. |
| `/opt/mareike/storage` | **Nicht** im KeyHelp-Backup. Zusätzlich sichern, z. B. per borg/restic oder rsync auf das Backup-Ziel. |
| `/opt/mareike/shared/.env` | Ebenfalls zusätzlich sichern, besonders wegen des `APP_KEY`. |
| Code und Images | Nicht nötig, lassen sich jederzeit aus dem Repo neu bauen. |
---
## 11. Fehlersuche
| 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] 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
/opt/mareike/bin/deploy.sh compose logs --tail=200 app web
ls -lt /opt/mareike/storage/logs/
```
---
## 12. Abgrenzung zum bestehenden Server
| | bestehender Server | endeavour |
|---|---|---|
| Dockerfile | `docker/prod.Dockerfile` | `docker/endeavour/Dockerfile` |
| Compose | `docker-compose.prod` | `docker/endeavour/compose.yaml` |
| nginx | `docker/nginx/default.conf` | `docker/endeavour/nginx.conf` |
| Build-Kontext | `.dockerignore` | `docker/endeavour/Dockerfile.dockerignore` |
| Datenbank | eigene | KeyHelp-MariaDB auf endeavour |
| Tenants | `*.mareike.sachsen.pfadfinden.de` | `*.meine-anmeldung.digital` |
Beide Server nutzen denselben Code-Stand aus demselben Repository, haben aber **getrennte** Datenbanken, `.env`,
WebDAV-Ordner und Tenants. Änderungen an `docker/endeavour/` wirken nur auf endeavour.
+8 -1
View File
@@ -15,6 +15,7 @@ const globalProps = reactive({
message: '',
currentEvent: null,
version: '',
groupLabels: {localGroupPlural: 'Stämme', parentGroupPlural: 'Bünde'},
});
const sidebarOpen = ref(false);
@@ -107,7 +108,10 @@ const props = defineProps({
</ul>
<ul class="nav-links">
<li v-if="globalProps.tenant.slug === 'lv'">
<a href="/admin/tenants" @click="closeSidebar">Stämme</a>
<a href="/admin/tenants" @click="closeSidebar">{{ globalProps.groupLabels.localGroupPlural }}</a>
</li>
<li v-if="globalProps.tenant.slug === 'lv'">
<a href="/admin/parent-groups" @click="closeSidebar">{{ globalProps.groupLabels.parentGroupPlural }}</a>
</li>
<li>
<a href="/admin/users" @click="closeSidebar">Benutzer*innen</a>
@@ -117,6 +121,9 @@ const props = defineProps({
<li>
<a href="/admin/document-templates" @click="closeSidebar">Rechnungsvorlage</a>
</li>
<li>
<a href="/admin/settings" @click="closeSidebar">Einstellungen</a>
</li>
</ul>
</nav>
</div>
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more