Development 4.4.2 #8

Merged
th.guenther merged 17 commits from development-4.4.2 into main 2026-06-21 23:31:27 +02:00
101 changed files with 4120 additions and 96 deletions
@@ -0,0 +1,38 @@
<?php
namespace App\Domains\Admin\Actions\CreateTenant;
use App\Models\Tenant;
class CreateTenantAction
{
public function __construct(private CreateTenantRequest $request)
{
}
public function execute(): CreateTenantResponse
{
$response = new CreateTenantResponse();
$tenant = Tenant::create([
'name' => $this->request->name,
'slug' => $this->request->slug,
'url' => $this->request->url,
'email' => '',
'email_finance' => '',
'account_name' => '',
'account_iban' => '',
'account_bic' => '',
'city' => '',
'postcode' => '',
'is_active_local_group' => true,
'has_active_instance' => true,
]);
$response->success = true;
$response->message = 'Stamm wurde angelegt.';
$response->slug = $tenant->slug;
return $response;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Domains\Admin\Actions\CreateTenant;
class CreateTenantRequest
{
public function __construct(
public string $name,
public string $slug,
public string $url,
) {
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Domains\Admin\Actions\CreateTenant;
class CreateTenantResponse
{
public bool $success = false;
public string $message = '';
public ?string $slug = null;
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Admin\Actions\ToggleUserActive;
class ToggleUserActiveAction
{
public function __construct(private ToggleUserActiveRequest $request)
{
}
public function execute(): ToggleUserActiveResponse
{
$response = new ToggleUserActiveResponse();
if ($this->request->currentUserId === $this->request->user->id) {
$response->message = 'Du kannst dich nicht selbst deaktivieren.';
return $response;
}
$this->request->user->update(['active' => !$this->request->user->active]);
$status = $this->request->user->active ? 'aktiviert' : 'deaktiviert';
$response->success = true;
$response->message = 'Benutzer*in wurde ' . $status . '.';
$response->active = $this->request->user->active;
return $response;
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Admin\Actions\ToggleUserActive;
use App\Models\User;
class ToggleUserActiveRequest
{
public function __construct(
public User $user,
public int $currentUserId,
) {
}
}
@@ -0,0 +1,10 @@
<?php
namespace App\Domains\Admin\Actions\ToggleUserActive;
class ToggleUserActiveResponse
{
public bool $success = false;
public string $message = '';
public ?bool $active = null;
}
@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantContact;
class UpdateTenantContactAction
{
public function __construct(private UpdateTenantContactRequest $request)
{
}
public function execute(): UpdateTenantContactResponse
{
$response = new UpdateTenantContactResponse();
$this->request->tenant->update([
'email' => $this->request->email,
'email_finance' => $this->request->emailFinance,
'postcode' => $this->request->postcode,
'city' => $this->request->city,
]);
$response->success = true;
$response->message = 'Kontaktdaten wurden gespeichert.';
return $response;
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantContact;
use App\Models\Tenant;
class UpdateTenantContactRequest
{
public function __construct(
public Tenant $tenant,
public string $email,
public string $emailFinance,
public string $postcode,
public string $city,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantContact;
class UpdateTenantContactResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantGdpr;
class UpdateTenantGdprAction
{
public function __construct(private UpdateTenantGdprRequest $request)
{
}
public function execute(): UpdateTenantGdprResponse
{
$response = new UpdateTenantGdprResponse();
$this->request->tenant->update([
'gdpr_text' => $this->request->gdprText,
]);
$response->success = true;
$response->message = 'Datenschutzerklärung wurde gespeichert.';
return $response;
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantGdpr;
use App\Models\Tenant;
class UpdateTenantGdprRequest
{
public function __construct(
public Tenant $tenant,
public string $gdprText,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantGdpr;
class UpdateTenantGdprResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantGeneral;
class UpdateTenantGeneralAction
{
public function __construct(private UpdateTenantGeneralRequest $request)
{
}
public function execute(): UpdateTenantGeneralResponse
{
$response = new UpdateTenantGeneralResponse();
$this->request->tenant->update([
'name' => $this->request->name,
'slug' => $this->request->slug,
'url' => $this->request->url,
]);
$response->success = true;
$response->message = 'Allgemeine Daten wurden gespeichert.';
return $response;
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantGeneral;
use App\Models\Tenant;
class UpdateTenantGeneralRequest
{
public function __construct(
public Tenant $tenant,
public string $name,
public string $slug,
public string $url,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantGeneral;
class UpdateTenantGeneralResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantImpress;
class UpdateTenantImpressAction
{
public function __construct(private UpdateTenantImpressRequest $request)
{
}
public function execute(): UpdateTenantImpressResponse
{
$response = new UpdateTenantImpressResponse();
$this->request->tenant->update([
'impress_text' => $this->request->impressText,
]);
$response->success = true;
$response->message = 'Impressum wurde gespeichert.';
return $response;
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantImpress;
use App\Models\Tenant;
class UpdateTenantImpressRequest
{
public function __construct(
public Tenant $tenant,
public string $impressText,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantImpress;
class UpdateTenantImpressResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantPayment;
class UpdateTenantPaymentAction
{
public function __construct(private UpdateTenantPaymentRequest $request)
{
}
public function execute(): UpdateTenantPaymentResponse
{
$response = new UpdateTenantPaymentResponse();
$this->request->tenant->update([
'account_iban' => $this->request->accountIban,
'account_bic' => $this->request->accountBic,
'account_name' => $this->request->accountName,
]);
$response->success = true;
$response->message = 'Bezahldaten wurden gespeichert.';
return $response;
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantPayment;
use App\Models\Tenant;
class UpdateTenantPaymentRequest
{
public function __construct(
public Tenant $tenant,
public string $accountIban,
public string $accountBic,
public string $accountName,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantPayment;
class UpdateTenantPaymentResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,39 @@
<?php
namespace App\Domains\Admin\Actions\UpdateUser;
class UpdateUserAction
{
public function __construct(private UpdateUserRequest $request)
{
}
public function execute(): UpdateUserResponse
{
$response = new UpdateUserResponse();
$allowedFields = [
'firstname', 'lastname', 'nickname', 'email', 'phone', 'birthday',
'membership_id', 'address_1', 'address_2', 'postcode', 'city',
'eating_habits', 'swimming_permission', 'first_aid_permission',
'bank_account_owner', 'bank_account_iban',
'medications', 'allergies', 'intolerances',
'user_role_local_group',
];
if ($this->request->isLvTenant) {
$allowedFields[] = 'local_group';
if (!$this->request->isOwnUser) {
$allowedFields[] = 'user_role_main';
}
}
$data = array_intersect_key($this->request->data, array_flip($allowedFields));
$this->request->user->update($data);
$response->success = true;
$response->message = 'Benutzerdaten wurden gespeichert.';
return $response;
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Admin\Actions\UpdateUser;
use App\Models\User;
class UpdateUserRequest
{
public function __construct(
public User $user,
public array $data,
public bool $isOwnUser,
public bool $isLvTenant,
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateUser;
class UpdateUserResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class AdminDashboardController extends CommonController
{
public function __invoke(Request $request): Response
{
$inertiaProvider = new InertiaProvider('Admin/Dashboard', []);
return $inertiaProvider->render();
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ManagedTenantContactGetController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
return response()->json([
'email' => $tenant->email,
'email_finance' => $tenant->email_finance,
'postcode' => $tenant->postcode,
'city' => $tenant->city,
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/contact',
]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantContact\UpdateTenantContactAction;
use App\Domains\Admin\Actions\UpdateTenantContact\UpdateTenantContactRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ManagedTenantContactUpdateController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
$action = new UpdateTenantContactAction(new UpdateTenantContactRequest(
tenant: $tenant,
email: $request->input('email'),
emailFinance: $request->input('email_finance'),
postcode: $request->input('postcode'),
city: $request->input('city'),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ManagedTenantGdprGetController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
return response()->json([
'gdpr_text' => $tenant->gdpr_text ?? '',
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/gdpr',
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantGdpr\UpdateTenantGdprAction;
use App\Domains\Admin\Actions\UpdateTenantGdpr\UpdateTenantGdprRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ManagedTenantGdprUpdateController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
$action = new UpdateTenantGdprAction(new UpdateTenantGdprRequest(
tenant: $tenant,
gdprText: $request->input('gdpr_text'),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ManagedTenantImpressGetController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
return response()->json([
'impress_text' => $tenant->impress_text ?? '',
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/impress',
]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantImpress\UpdateTenantImpressAction;
use App\Domains\Admin\Actions\UpdateTenantImpress\UpdateTenantImpressRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ManagedTenantImpressUpdateController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
$action = new UpdateTenantImpressAction(new UpdateTenantImpressRequest(
tenant: $tenant,
impressText: $request->input('impress_text'),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ManagedTenantPaymentGetController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
return response()->json([
'account_iban' => $tenant->account_iban,
'account_bic' => $tenant->account_bic,
'account_name' => $tenant->account_name,
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/payment',
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantPayment\UpdateTenantPaymentAction;
use App\Domains\Admin\Actions\UpdateTenantPayment\UpdateTenantPaymentRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ManagedTenantPaymentUpdateController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
$action = new UpdateTenantPaymentAction(new UpdateTenantPaymentRequest(
tenant: $tenant,
accountIban: $request->input('account_iban'),
accountBic: $request->input('account_bic'),
accountName: $request->input('account_name'),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantContactGetController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
return response()->json([
'email' => $this->tenant->email,
'email_finance' => $this->tenant->email_finance,
'postcode' => $this->tenant->postcode,
'city' => $this->tenant->city,
'saveEndpoint' => '/api/v1/admin/tenant/contact',
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantContact\UpdateTenantContactAction;
use App\Domains\Admin\Actions\UpdateTenantContact\UpdateTenantContactRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantContactUpdateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$action = new UpdateTenantContactAction(new UpdateTenantContactRequest(
tenant: $this->tenant,
email: $request->input('email'),
emailFinance: $request->input('email_finance'),
postcode: $request->input('postcode'),
city: $request->input('city'),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\CreateTenant\CreateTenantAction;
use App\Domains\Admin\Actions\CreateTenant\CreateTenantRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantCreateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$action = new CreateTenantAction(new CreateTenantRequest(
name: $request->input('name'),
slug: $request->input('slug'),
url: $request->input('url'),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
'slug' => $response->slug,
]);
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class TenantEditPageController extends CommonController
{
public function __invoke(string $slug, Request $request): Response
{
$tenant = $this->adminTenants->findBySlug($slug);
$inertiaProvider = new InertiaProvider('Admin/TenantEdit', [
'tenant' => [
'name' => $tenant->name,
'slug' => $tenant->slug,
'url' => $tenant->url,
'is_active_local_group' => $tenant->is_active_local_group,
],
]);
return $inertiaProvider->render();
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantGdprGetController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
return response()->json([
'gdpr_text' => $this->tenant->gdpr_text ?? '',
'saveEndpoint' => '/api/v1/admin/tenant/gdpr',
]);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantGdpr\UpdateTenantGdprAction;
use App\Domains\Admin\Actions\UpdateTenantGdpr\UpdateTenantGdprRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantGdprUpdateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$action = new UpdateTenantGdprAction(new UpdateTenantGdprRequest(
tenant: $this->tenant,
gdprText: $request->input('gdpr_text'),
));
$response = $action->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 TenantGeneralGetController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
return response()->json([
'name' => $tenant->name,
'slug' => $tenant->slug,
'url' => $tenant->url,
'is_active_local_group' => $tenant->is_active_local_group,
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/general',
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantGeneral\UpdateTenantGeneralAction;
use App\Domains\Admin\Actions\UpdateTenantGeneral\UpdateTenantGeneralRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantGeneralUpdateController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
$action = new UpdateTenantGeneralAction(new UpdateTenantGeneralRequest(
tenant: $tenant,
name: $request->input('name'),
slug: $request->input('slug'),
url: $request->input('url'),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantImpressGetController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
return response()->json([
'impress_text' => $this->tenant->impress_text ?? '',
'saveEndpoint' => '/api/v1/admin/tenant/impress',
]);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantImpress\UpdateTenantImpressAction;
use App\Domains\Admin\Actions\UpdateTenantImpress\UpdateTenantImpressRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantImpressUpdateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$action = new UpdateTenantImpressAction(new UpdateTenantImpressRequest(
tenant: $this->tenant,
impressText: $request->input('impress_text'),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantListApiController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$tenants = $this->adminTenants->getActiveTenants();
$mapped = $tenants->map(function ($tenant) {
return [
'name' => $tenant->name,
'slug' => $tenant->slug,
'url' => $tenant->url,
'is_active_local_group' => $tenant->is_active_local_group,
];
});
return response()->json(['tenants' => $mapped]);
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class TenantListPageController extends CommonController
{
public function __invoke(Request $request): Response
{
$inertiaProvider = new InertiaProvider('Admin/TenantList', []);
return $inertiaProvider->render();
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class TenantPageController extends CommonController
{
public function __invoke(Request $request): Response
{
$inertiaProvider = new InertiaProvider('Admin/TenantData', [
'tenant' => [
'name' => $this->tenant->name,
'slug' => $this->tenant->slug,
'url' => $this->tenant->url,
'is_active_local_group' => $this->tenant->is_active_local_group,
],
]);
return $inertiaProvider->render();
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantPaymentGetController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
return response()->json([
'account_iban' => $this->tenant->account_iban,
'account_bic' => $this->tenant->account_bic,
'account_name' => $this->tenant->account_name,
'saveEndpoint' => '/api/v1/admin/tenant/payment',
]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantPayment\UpdateTenantPaymentAction;
use App\Domains\Admin\Actions\UpdateTenantPayment\UpdateTenantPaymentRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantPaymentUpdateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$action = new UpdateTenantPaymentAction(new UpdateTenantPaymentRequest(
tenant: $this->tenant,
accountIban: $request->input('account_iban'),
accountBic: $request->input('account_bic'),
accountName: $request->input('account_name'),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Enumerations\UserRole;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserDetailGetController extends CommonController
{
public function __invoke(int $id, Request $request): JsonResponse
{
$user = $this->adminUsers->findById($id);
$userData = $user->toArray();
unset($userData['password'], $userData['remember_token'], $userData['activation_token'], $userData['activation_token_expires_at']);
$tenantNames = $this->adminTenants->getTenantNames();
$userData['nicename'] = $user->getNicename();
$userData['fullname'] = $user->getFullName();
$userData['local_group_name'] = $tenantNames[$user->local_group] ?? $user->local_group;
return response()->json([
'user' => $userData,
'isOwnUser' => auth()->id() === $user->id,
'isLvTenant' => $this->tenant->slug === 'lv',
'userRoles' => UserRole::all()->map(fn($role) => ['slug' => $role->slug, 'name' => $role->name]),
'localGroups' => $this->adminTenants->getActiveLocalGroups()->map(fn($t) => ['slug' => $t->slug, 'name' => $t->name]),
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserListApiController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$tenantNames = $this->adminTenants->getTenantNames();
$users = $this->adminUsers->getListForTenant($this->tenant->slug);
$mapped = $users->map(function ($user) use ($tenantNames) {
return [
'id' => $user->id,
'firstname' => $user->firstname,
'lastname' => $user->lastname,
'nickname' => $user->nickname,
'local_group' => $user->local_group,
'local_group_name' => $tenantNames[$user->local_group] ?? $user->local_group,
'active' => $user->active,
];
});
return response()->json(['users' => $mapped]);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class UserListPageController extends CommonController
{
public function __invoke(Request $request): Response
{
$inertiaProvider = new InertiaProvider('Admin/UserList', [
'isLvTenant' => $this->tenant->slug === 'lv',
]);
return $inertiaProvider->render();
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\UserManagement\Actions\GenerateActivationToken\GenerateActivationTokenCommand;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserResetPasswordController extends CommonController
{
public function __invoke(int $id, Request $request): JsonResponse
{
$user = $this->adminUsers->findById($id);
if (!$user->email) {
return response()->json([
'status' => 'error',
'message' => 'Benutzer*in hat keine E-Mail-Adresse hinterlegt.',
]);
}
$command = new GenerateActivationTokenCommand($user);
$command->execute();
return response()->json([
'status' => 'success',
'message' => 'Passwort-Reset-Mail wurde gesendet.',
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\ToggleUserActive\ToggleUserActiveAction;
use App\Domains\Admin\Actions\ToggleUserActive\ToggleUserActiveRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserToggleActiveController extends CommonController
{
public function __invoke(int $id, Request $request): JsonResponse
{
$user = $this->adminUsers->findById($id);
$action = new ToggleUserActiveAction(new ToggleUserActiveRequest(
user: $user,
currentUserId: auth()->id(),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
'active' => $response->active,
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateUser\UpdateUserAction;
use App\Domains\Admin\Actions\UpdateUser\UpdateUserRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserUpdateController extends CommonController
{
public function __invoke(int $id, Request $request): JsonResponse
{
$user = $this->adminUsers->findById($id);
$action = new UpdateUserAction(new UpdateUserRequest(
user: $user,
data: $request->all(),
isOwnUser: auth()->id() === $user->id,
isLvTenant: $this->tenant->slug === 'lv',
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Admin\Repositories;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as SupportCollection;
class AdminTenantRepository
{
public function findBySlug(string $slug): Tenant
{
return Tenant::where('slug', $slug)->firstOrFail();
}
public function getActiveTenants(): Collection
{
return Tenant::where('has_active_instance', true)->get();
}
public function getTenantNames(): SupportCollection
{
return Tenant::pluck('name', 'slug');
}
public function getActiveLocalGroups(): Collection
{
return Tenant::where('is_active_local_group', true)->get();
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Admin\Repositories;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
class AdminUserRepository
{
public function findById(int $id): User
{
return User::findOrFail($id);
}
public function getListForTenant(string $tenantSlug): Collection
{
$query = User::query();
if ($tenantSlug !== 'lv') {
$query->where('local_group', $tenantSlug);
}
return $query->orderBy('lastname')->orderBy('firstname')->get();
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
use App\Domains\Admin\Controllers\UserDetailGetController;
use App\Domains\Admin\Controllers\UserListApiController;
use App\Domains\Admin\Controllers\UserResetPasswordController;
use App\Domains\Admin\Controllers\UserToggleActiveController;
use App\Domains\Admin\Controllers\UserUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantContactGetController;
use App\Domains\Admin\Controllers\ManagedTenantContactUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantGdprGetController;
use App\Domains\Admin\Controllers\ManagedTenantGdprUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantImpressGetController;
use App\Domains\Admin\Controllers\ManagedTenantImpressUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantPaymentGetController;
use App\Domains\Admin\Controllers\ManagedTenantPaymentUpdateController;
use App\Domains\Admin\Controllers\TenantContactGetController;
use App\Domains\Admin\Controllers\TenantContactUpdateController;
use App\Domains\Admin\Controllers\TenantCreateController;
use App\Domains\Admin\Controllers\TenantGdprGetController;
use App\Domains\Admin\Controllers\TenantGdprUpdateController;
use App\Domains\Admin\Controllers\TenantGeneralGetController;
use App\Domains\Admin\Controllers\TenantGeneralUpdateController;
use App\Domains\Admin\Controllers\TenantImpressGetController;
use App\Domains\Admin\Controllers\TenantImpressUpdateController;
use App\Domains\Admin\Controllers\TenantListApiController;
use App\Domains\Admin\Controllers\TenantPaymentGetController;
use App\Domains\Admin\Controllers\TenantPaymentUpdateController;
use App\Middleware\AdminRoleMiddleware;
use App\Middleware\IdentifyTenant;
use App\Middleware\LvOnlyMiddleware;
use Illuminate\Support\Facades\Route;
Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->group(function () {
Route::prefix('api/v1/admin/tenant')->group(function () {
Route::get('/contact', TenantContactGetController::class);
Route::post('/contact', TenantContactUpdateController::class);
Route::get('/payment', TenantPaymentGetController::class);
Route::post('/payment', TenantPaymentUpdateController::class);
Route::get('/impress', TenantImpressGetController::class);
Route::post('/impress', TenantImpressUpdateController::class);
Route::get('/gdpr', TenantGdprGetController::class);
Route::post('/gdpr', TenantGdprUpdateController::class);
});
Route::prefix('api/v1/admin/users')->group(function () {
Route::get('/list', UserListApiController::class);
Route::get('/{id}', UserDetailGetController::class);
Route::post('/{id}', UserUpdateController::class);
Route::post('/{id}/toggle-active', UserToggleActiveController::class);
Route::post('/{id}/reset-password', UserResetPasswordController::class);
});
Route::middleware(LvOnlyMiddleware::class)->group(function () {
Route::prefix('api/v1/admin/tenants')->group(function () {
Route::get('/list', TenantListApiController::class);
Route::post('/create', TenantCreateController::class);
Route::prefix('/{slug}')->group(function () {
Route::get('/general', TenantGeneralGetController::class);
Route::post('/general', TenantGeneralUpdateController::class);
Route::get('/contact', ManagedTenantContactGetController::class);
Route::post('/contact', ManagedTenantContactUpdateController::class);
Route::get('/payment', ManagedTenantPaymentGetController::class);
Route::post('/payment', ManagedTenantPaymentUpdateController::class);
Route::get('/impress', ManagedTenantImpressGetController::class);
Route::post('/impress', ManagedTenantImpressUpdateController::class);
Route::get('/gdpr', ManagedTenantGdprGetController::class);
Route::post('/gdpr', ManagedTenantGdprUpdateController::class);
});
});
});
});
+24
View File
@@ -0,0 +1,24 @@
<?php
use App\Domains\Admin\Controllers\AdminDashboardController;
use App\Domains\Admin\Controllers\TenantEditPageController;
use App\Domains\Admin\Controllers\TenantListPageController;
use App\Domains\Admin\Controllers\TenantPageController;
use App\Domains\Admin\Controllers\UserListPageController;
use App\Middleware\AdminRoleMiddleware;
use App\Middleware\IdentifyTenant;
use App\Middleware\LvOnlyMiddleware;
use Illuminate\Support\Facades\Route;
Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->group(function () {
Route::prefix('admin')->group(function () {
Route::get('/', AdminDashboardController::class);
Route::get('/tenant', TenantPageController::class);
Route::get('/users', UserListPageController::class);
Route::middleware(LvOnlyMiddleware::class)->group(function () {
Route::get('/tenants', TenantListPageController::class);
Route::get('/tenants/{slug}', TenantEditPageController::class);
});
});
});
+15
View File
@@ -0,0 +1,15 @@
<script setup>
import AdminAppLayout from "../../../../resources/js/layouts/AdminAppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
</script>
<template>
<AdminAppLayout title="Administration">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<h2>Administration</h2>
</shadowed-box>
</AdminAppLayout>
</template>
<style scoped>
</style>
@@ -0,0 +1,146 @@
<script setup>
import { ref } from 'vue';
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
})
const { request } = useAjax()
const editing = ref(false)
const form = ref({
email: props.data.email ?? '',
email_finance: props.data.email_finance ?? '',
postcode: props.data.postcode ?? '',
city: props.data.city ?? '',
})
async function save() {
const saveUrl = props.data.saveEndpoint ?? '/api/v1/admin/tenant/contact'
const response = await request(saveUrl, {
method: 'POST',
body: form.value,
})
if (response && response.status === 'success') {
toast.success(response.message)
editing.value = false
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
</script>
<template>
<div v-if="!editing">
<table class="data-table">
<tr><th>Email:</th><td>{{ form.email }}</td></tr>
<tr><th>Email Schatzmeister*in:</th><td>{{ form.email_finance }}</td></tr>
<tr><th>Postleitzahl:</th><td>{{ form.postcode }}</td></tr>
<tr><th>Ort:</th><td>{{ form.city }}</td></tr>
</table>
<button class="btn-edit" @click="editing = true">Bearbeiten</button>
</div>
<div v-else>
<table class="data-table">
<tr>
<th>Email:</th>
<td><input type="email" v-model="form.email" class="form-input" /></td>
</tr>
<tr>
<th>Email Schatzmeister*in:</th>
<td><input type="email" v-model="form.email_finance" class="form-input" /></td>
</tr>
<tr>
<th>Postleitzahl:</th>
<td><input type="text" v-model="form.postcode" class="form-input" /></td>
</tr>
<tr>
<th>Ort:</th>
<td><input type="text" v-model="form.city" class="form-input" /></td>
</tr>
</table>
<div class="btn-group">
<button class="btn-save" @click="save">Speichern</button>
<button class="btn-cancel" @click="editing = false">Abbrechen</button>
</div>
</div>
</template>
<style scoped>
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 8px 12px;
width: 200px;
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-edit, .btn-save, .btn-cancel {
margin-top: 15px;
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
}
.btn-edit {
background-color: #1d4899;
color: #ffffff;
}
.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;
margin-left: 10px;
}
.btn-cancel:hover {
background-color: #d1d5db;
}
.btn-group {
display: flex;
gap: 10px;
}
</style>
@@ -0,0 +1,87 @@
<script setup>
import { ref } from 'vue';
import TextEditor from "../../../../Views/Components/TextEditor.vue";
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
import gdprTemplate from "../../../../../resources/templates/gdpr-template.html?raw";
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
})
const { request } = useAjax()
const content = ref(props.data.gdpr_text ?? '')
function autoGenerate() {
if (content.value && content.value.trim() !== '') {
toast.error('Der Editor ist nicht leer. Bitte leere den Inhalt zuerst, um die Vorlage zu verwenden.')
return
}
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
content.value = gdprTemplate.replace('[Datum]', today)
}
async function save() {
const saveUrl = props.data.saveEndpoint ?? '/api/v1/admin/tenant/gdpr'
const response = await request(saveUrl, {
method: 'POST',
body: { gdpr_text: content.value },
})
if (response && response.status === 'success') {
toast.success(response.message)
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
</script>
<template>
<div>
<TextEditor v-model="content" />
<div class="btn-group">
<button class="btn-save" @click="save">Speichern</button>
<button class="btn-generate" @click="autoGenerate">Auto-generieren</button>
</div>
</div>
</template>
<style scoped>
.btn-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn-save, .btn-generate {
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
}
.btn-save {
background-color: #16a34a;
color: #ffffff;
}
.btn-save:hover {
background-color: #15803d;
}
.btn-generate {
background-color: #1d4899;
color: #ffffff;
}
.btn-generate:hover {
background-color: #163a7a;
}
</style>
@@ -0,0 +1,157 @@
<script setup>
import { ref } from 'vue';
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
})
const { request } = useAjax()
const editing = ref(false)
const form = ref({
name: props.data.name ?? '',
slug: props.data.slug ?? '',
url: props.data.url ?? '',
})
async function save() {
const saveUrl = props.data.saveEndpoint ?? '/api/v1/admin/tenants/' + props.data.slug + '/general'
const response = await request(saveUrl, {
method: 'POST',
body: form.value,
})
if (response && response.status === 'success') {
toast.success(response.message)
editing.value = false
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
</script>
<template>
<div v-if="!editing">
<table class="data-table">
<tr><th>Name:</th><td>{{ form.name }}</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>
<span class="badge-active">Aktiv</span>
</td></tr>
</table>
<button class="btn-edit" @click="editing = true">Bearbeiten</button>
</div>
<div v-else>
<table class="data-table">
<tr>
<th>Name:</th>
<td><input type="text" v-model="form.name" class="form-input" /></td>
</tr>
<tr>
<th>Slug:</th>
<td><input type="text" v-model="form.slug" class="form-input" /></td>
</tr>
<tr>
<th>URL:</th>
<td><input type="text" v-model="form.url" class="form-input" /></td>
</tr>
<tr><th>Status:</th><td>
<span class="badge-active">Aktiv</span>
</td></tr>
</table>
<div class="btn-group">
<button class="btn-save" @click="save">Speichern</button>
<button class="btn-cancel" @click="editing = false">Abbrechen</button>
</div>
</div>
</template>
<style scoped>
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 8px 12px;
width: 200px;
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;
}
.badge-active {
display: inline-block;
padding: 3px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: bold;
color: #166534;
background-color: #dcfce7;
border: 1px solid #22c55e;
}
.btn-edit, .btn-save, .btn-cancel {
margin-top: 15px;
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
}
.btn-edit {
background-color: #1d4899;
color: #ffffff;
}
.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;
margin-left: 10px;
}
.btn-cancel:hover {
background-color: #d1d5db;
}
.btn-group {
display: flex;
gap: 10px;
}
</style>
@@ -0,0 +1,56 @@
<script setup>
import { ref } from 'vue';
import TextEditor from "../../../../Views/Components/TextEditor.vue";
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
})
const { request } = useAjax()
const content = ref(props.data.impress_text ?? '')
async function save() {
const saveUrl = props.data.saveEndpoint ?? '/api/v1/admin/tenant/impress'
const response = await request(saveUrl, {
method: 'POST',
body: { impress_text: content.value },
})
if (response && response.status === 'success') {
toast.success(response.message)
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
</script>
<template>
<div>
<TextEditor v-model="content" />
<button class="btn-save" @click="save">Speichern</button>
</div>
</template>
<style scoped>
.btn-save {
margin-top: 15px;
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
background-color: #16a34a;
color: #ffffff;
}
.btn-save:hover {
background-color: #15803d;
}
</style>
@@ -0,0 +1,140 @@
<script setup>
import { ref } from 'vue';
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
})
const { request } = useAjax()
const editing = ref(false)
const form = ref({
account_iban: props.data.account_iban ?? '',
account_bic: props.data.account_bic ?? '',
account_name: props.data.account_name ?? '',
})
async function save() {
const saveUrl = props.data.saveEndpoint ?? '/api/v1/admin/tenant/payment'
const response = await request(saveUrl, {
method: 'POST',
body: form.value,
})
if (response && response.status === 'success') {
toast.success(response.message)
editing.value = false
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
</script>
<template>
<div v-if="!editing">
<table class="data-table">
<tr><th>IBAN:</th><td>{{ form.account_iban }}</td></tr>
<tr><th>BIC:</th><td>{{ form.account_bic }}</td></tr>
<tr><th>Name Kontoinhaber:</th><td>{{ form.account_name }}</td></tr>
</table>
<button class="btn-edit" @click="editing = true">Bearbeiten</button>
</div>
<div v-else>
<table class="data-table">
<tr>
<th>IBAN:</th>
<td><input type="text" v-model="form.account_iban" class="form-input" /></td>
</tr>
<tr>
<th>BIC:</th>
<td><input type="text" v-model="form.account_bic" class="form-input" /></td>
</tr>
<tr>
<th>Name Kontoinhaber:</th>
<td><input type="text" v-model="form.account_name" class="form-input" /></td>
</tr>
</table>
<div class="btn-group">
<button class="btn-save" @click="save">Speichern</button>
<button class="btn-cancel" @click="editing = false">Abbrechen</button>
</div>
</div>
</template>
<style scoped>
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 8px 12px;
width: 200px;
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-edit, .btn-save, .btn-cancel {
margin-top: 15px;
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
}
.btn-edit {
background-color: #1d4899;
color: #ffffff;
}
.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;
margin-left: 10px;
}
.btn-cancel:hover {
background-color: #d1d5db;
}
.btn-group {
display: flex;
gap: 10px;
}
</style>
@@ -0,0 +1,331 @@
<script setup>
import { ref } from 'vue';
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import { toast } from "vue3-toastify";
const props = defineProps({
data: Object,
})
const emit = defineEmits(['updated', 'closed'])
const { request } = useAjax()
const editing = ref(false)
const user = props.data.user
const isOwnUser = props.data.isOwnUser
const isLvTenant = props.data.isLvTenant
const userRoles = props.data.userRoles
const localGroups = props.data.localGroups
const form = ref({
firstname: user.firstname ?? '',
lastname: user.lastname ?? '',
nickname: user.nickname ?? '',
email: user.email ?? '',
phone: user.phone ?? '',
birthday: user.birthday ?? '',
membership_id: user.membership_id ?? '',
address_1: user.address_1 ?? '',
address_2: user.address_2 ?? '',
postcode: user.postcode ?? '',
city: user.city ?? '',
medications: user.medications ?? '',
allergies: user.allergies ?? '',
intolerances: user.intolerances ?? '',
bank_account_owner: user.bank_account_owner ?? '',
bank_account_iban: user.bank_account_iban ?? '',
user_role_local_group: user.user_role_local_group ?? '',
user_role_main: user.user_role_main ?? '',
local_group: user.local_group ?? '',
})
async function save() {
const response = await request('/api/v1/admin/users/' + user.id, {
method: 'POST',
body: form.value,
})
if (response && response.status === 'success') {
toast.success(response.message)
editing.value = false
emit('updated')
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
async function toggleActive() {
const response = await request('/api/v1/admin/users/' + user.id + '/toggle-active', {
method: 'POST',
})
if (response && response.status === 'success') {
toast.success(response.message)
emit('updated')
} else {
toast.error(response?.message ?? 'Fehler')
}
}
async function resetPassword() {
const response = await request('/api/v1/admin/users/' + user.id + '/reset-password', {
method: 'POST',
})
if (response && response.status === 'success') {
toast.success(response.message)
} else {
toast.error(response?.message ?? 'Fehler')
}
}
function getRoleName(slug) {
const role = userRoles.find(r => r.slug === slug)
return role ? role.name : slug
}
function getGroupName(slug) {
const group = localGroups.find(g => g.slug === slug)
return group ? group.name : slug
}
</script>
<template>
<div>
<div class="detail-header">
<h3>{{ user.firstname }} {{ user.lastname }}</h3>
<div class="detail-actions">
<button v-if="!editing" class="btn-edit" @click="editing = true">Bearbeiten</button>
<button class="btn-reset" @click="resetPassword">Passwort zurücksetzen</button>
<button v-if="!isOwnUser" :class="user.active ? 'btn-deactivate' : 'btn-activate'" @click="toggleActive">
{{ user.active ? 'Deaktivieren' : 'Aktivieren' }}
</button>
<button class="btn-close" @click="emit('closed')">Schließen</button>
</div>
</div>
<div v-if="!editing">
<table class="data-table">
<tr><th>Anmeldename:</th><td>{{ user.username }}</td></tr>
<tr><th>Vorname:</th><td>{{ form.firstname }}</td></tr>
<tr><th>Nachname:</th><td>{{ form.lastname }}</td></tr>
<tr><th>Nickname:</th><td>{{ form.nickname }}</td></tr>
<tr><th>E-Mail:</th><td>{{ form.email }}</td></tr>
<tr><th>Telefon:</th><td>{{ form.phone }}</td></tr>
<tr><th>Geburtstag:</th><td>{{ form.birthday }}</td></tr>
<tr><th>Mitgliedsnummer:</th><td>{{ form.membership_id }}</td></tr>
<tr><th>Adresse 1:</th><td>{{ form.address_1 }}</td></tr>
<tr><th>Adresse 2:</th><td>{{ form.address_2 }}</td></tr>
<tr><th>PLZ:</th><td>{{ form.postcode }}</td></tr>
<tr><th>Ort:</th><td>{{ form.city }}</td></tr>
<tr><th>Medikamente:</th><td>{{ form.medications }}</td></tr>
<tr><th>Allergien:</th><td>{{ form.allergies }}</td></tr>
<tr><th>Unverträglichkeiten:</th><td>{{ form.intolerances }}</td></tr>
<tr><th>Kontoinhaber:</th><td>{{ form.bank_account_owner }}</td></tr>
<tr><th>IBAN:</th><td>{{ form.bank_account_iban }}</td></tr>
<tr><th>Stamm:</th><td>{{ getGroupName(form.local_group) }}</td></tr>
<tr><th>Rolle (Stamm):</th><td>{{ getRoleName(form.user_role_local_group) }}</td></tr>
<tr><th>Rolle (LV):</th><td>{{ getRoleName(form.user_role_main) }}</td></tr>
<tr><th>Status:</th><td>
<span :class="user.active ? 'badge-active' : 'badge-inactive'">
{{ user.active ? 'Aktiv' : 'Inaktiv' }}
</span>
</td></tr>
</table>
</div>
<div v-else>
<table class="data-table">
<tr><th>Anmeldename:</th><td>{{ user.username }}</td></tr>
<tr><th>Vorname:</th><td><input type="text" v-model="form.firstname" class="form-input" /></td></tr>
<tr><th>Nachname:</th><td><input type="text" v-model="form.lastname" class="form-input" /></td></tr>
<tr><th>Nickname:</th><td><input type="text" v-model="form.nickname" class="form-input" /></td></tr>
<tr><th>E-Mail:</th><td><input type="email" v-model="form.email" class="form-input" /></td></tr>
<tr><th>Telefon:</th><td><input type="text" v-model="form.phone" class="form-input" /></td></tr>
<tr><th>Geburtstag:</th><td><input type="date" v-model="form.birthday" class="form-input" /></td></tr>
<tr><th>Mitgliedsnummer:</th><td><input type="text" v-model="form.membership_id" class="form-input" /></td></tr>
<tr><th>Adresse 1:</th><td><input type="text" v-model="form.address_1" class="form-input" /></td></tr>
<tr><th>Adresse 2:</th><td><input type="text" v-model="form.address_2" class="form-input" /></td></tr>
<tr><th>PLZ:</th><td><input type="text" v-model="form.postcode" class="form-input" /></td></tr>
<tr><th>Ort:</th><td><input type="text" v-model="form.city" class="form-input" /></td></tr>
<tr><th>Medikamente:</th><td><input type="text" v-model="form.medications" class="form-input" /></td></tr>
<tr><th>Allergien:</th><td><input type="text" v-model="form.allergies" class="form-input" /></td></tr>
<tr><th>Unverträglichkeiten:</th><td><input type="text" v-model="form.intolerances" class="form-input" /></td></tr>
<tr><th>Kontoinhaber:</th><td><input type="text" v-model="form.bank_account_owner" class="form-input" /></td></tr>
<tr><th>IBAN:</th><td><input type="text" v-model="form.bank_account_iban" class="form-input" /></td></tr>
<tr><th>Stamm:</th><td>
<select v-if="isLvTenant" v-model="form.local_group" class="form-input">
<option v-for="group in localGroups" :key="group.slug" :value="group.slug">{{ group.name }}</option>
</select>
<span v-else>{{ getGroupName(form.local_group) }}</span>
</td></tr>
<tr><th>Rolle (Stamm):</th><td>
<select v-model="form.user_role_local_group" class="form-input">
<option v-for="role in userRoles" :key="role.slug" :value="role.slug">{{ role.name }}</option>
</select>
</td></tr>
<tr><th>Rolle (LV):</th><td>
<select v-if="isLvTenant && !isOwnUser" v-model="form.user_role_main" class="form-input">
<option v-for="role in userRoles" :key="role.slug" :value="role.slug">{{ role.name }}</option>
</select>
<span v-else>{{ getRoleName(form.user_role_main) }}</span>
</td></tr>
</table>
<div class="btn-group">
<button class="btn-save" @click="save">Speichern</button>
<button class="btn-cancel" @click="editing = false">Abbrechen</button>
</div>
</div>
</div>
</template>
<style scoped>
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
}
.detail-header h3 {
margin: 0;
color: #1f2937;
}
.detail-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 6px 12px;
width: 180px;
color: #374151;
border-bottom: 1px solid #e5e7eb;
font-size: 0.9rem;
}
.data-table td {
padding: 6px 12px;
border-bottom: 1px solid #e5e7eb;
}
.form-input {
width: 100%;
padding: 5px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
box-sizing: border-box;
}
.badge-active {
display: inline-block;
padding: 3px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: bold;
color: #166534;
background-color: #dcfce7;
border: 1px solid #22c55e;
}
.badge-inactive {
display: inline-block;
padding: 3px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: bold;
color: #991b1b;
background-color: #fee2e2;
border: 1px solid #ef4444;
}
.btn-edit, .btn-save, .btn-cancel, .btn-reset, .btn-deactivate, .btn-activate, .btn-close {
padding: 6px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.85rem;
}
.btn-edit {
background-color: #1d4899;
color: #ffffff;
}
.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-reset {
background-color: #f59e0b;
color: #ffffff;
}
.btn-reset:hover {
background-color: #d97706;
}
.btn-deactivate {
background-color: #ef4444;
color: #ffffff;
}
.btn-deactivate:hover {
background-color: #dc2626;
}
.btn-activate {
background-color: #16a34a;
color: #ffffff;
}
.btn-activate:hover {
background-color: #15803d;
}
.btn-close {
background-color: #6b7280;
color: #ffffff;
}
.btn-close:hover {
background-color: #4b5563;
}
.btn-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<script setup>
import AdminAppLayout from "../../../../resources/js/layouts/AdminAppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import TabbedPage from "../../../Views/Components/TabbedPage.vue";
import TenantContact from "./Partials/TenantContact.vue";
import TenantPayment from "./Partials/TenantPayment.vue";
import TenantImpress from "./Partials/TenantImpress.vue";
import TenantGdpr from "./Partials/TenantGdpr.vue";
const props = defineProps({
tenant: Object,
})
const tabs = [
{
title: 'Kontaktdaten',
component: TenantContact,
endpoint: '/api/v1/admin/tenant/contact',
},
{
title: 'Bezahldaten',
component: TenantPayment,
endpoint: '/api/v1/admin/tenant/payment',
},
{
title: 'Impressum',
component: TenantImpress,
endpoint: '/api/v1/admin/tenant/impress',
},
{
title: 'Datenschutzerklärung',
component: TenantGdpr,
endpoint: '/api/v1/admin/tenant/gdpr',
},
]
</script>
<template>
<AdminAppLayout :title="props.tenant.slug === 'lv' ? 'LV-Daten' : 'Stammesdaten'">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<table class="tenant-header">
<tr><th>Name</th><td>{{ props.tenant.name }}</td></tr>
<tr><th>Slug</th><td>{{ props.tenant.slug }}</td></tr>
<tr><th>mareike-URL:</th><td>{{ props.tenant.url }}</td></tr>
<tr><th>Status</th><td>
<span :class="props.tenant.is_active_local_group ? 'badge-active' : 'badge-inactive'">
{{ props.tenant.is_active_local_group ? 'Aktiv' : 'Inaktiv' }}
</span>
</td></tr>
</table>
<div style="margin-top: 30px;">
<tabbed-page :tabs="tabs" />
</div>
</shadowed-box>
</AdminAppLayout>
</template>
<style scoped>
.tenant-header {
width: 100%;
border-collapse: collapse;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
}
.tenant-header th {
text-align: left;
padding: 10px 16px;
width: 200px;
color: #374151;
font-weight: bold;
background-color: #f9fafb;
border-bottom: 1px solid #d1d5db;
}
.tenant-header td {
padding: 10px 16px;
border-bottom: 1px solid #d1d5db;
}
.tenant-header tr:last-child th,
.tenant-header tr:last-child td {
border-bottom: none;
}
.badge-active {
display: inline-block;
padding: 3px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: bold;
color: #166534;
background-color: #dcfce7;
border: 1px solid #22c55e;
}
.badge-inactive {
display: inline-block;
padding: 3px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: bold;
color: #991b1b;
background-color: #fee2e2;
border: 1px solid #ef4444;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<script setup>
import AdminAppLayout from "../../../../resources/js/layouts/AdminAppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import TabbedPage from "../../../Views/Components/TabbedPage.vue";
import TenantGeneral from "./Partials/TenantGeneral.vue";
import TenantContact from "./Partials/TenantContact.vue";
import TenantPayment from "./Partials/TenantPayment.vue";
import TenantImpress from "./Partials/TenantImpress.vue";
import TenantGdpr from "./Partials/TenantGdpr.vue";
const props = defineProps({
tenant: Object,
})
const slug = props.tenant.slug
const tabs = [
{
title: 'Allgemeines',
component: TenantGeneral,
endpoint: '/api/v1/admin/tenants/' + slug + '/general',
},
{
title: 'Kontaktdaten',
component: TenantContact,
endpoint: '/api/v1/admin/tenants/' + slug + '/contact',
},
{
title: 'Bezahldaten',
component: TenantPayment,
endpoint: '/api/v1/admin/tenants/' + slug + '/payment',
},
{
title: 'Impressum',
component: TenantImpress,
endpoint: '/api/v1/admin/tenants/' + slug + '/impress',
},
{
title: 'Datenschutzerklärung',
component: TenantGdpr,
endpoint: '/api/v1/admin/tenants/' + slug + '/gdpr',
},
]
</script>
<template>
<AdminAppLayout :title="props.tenant.name">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<tabbed-page :tabs="tabs" />
</shadowed-box>
</AdminAppLayout>
</template>
+248
View File
@@ -0,0 +1,248 @@
<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()
function openTenant(slug) {
window.location.href = '/admin/tenants/' + slug
}
const tenants = ref([])
const showCreate = ref(false)
const createForm = ref({
name: '',
slug: '',
url: '',
})
onMounted(async () => {
await loadTenants()
})
async function loadTenants() {
const response = await fetch('/api/v1/admin/tenants/list', {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
const data = await response.json()
tenants.value = data.tenants
}
async function createTenant() {
const response = await request('/api/v1/admin/tenants/create', {
method: 'POST',
body: createForm.value,
})
if (response && response.status === 'success') {
toast.success(response.message)
showCreate.value = false
createForm.value = { name: '', slug: '', url: '' }
await loadTenants()
} else {
toast.error(response?.message ?? 'Fehler beim Anlegen')
}
}
</script>
<template>
<AdminAppLayout title="Stämme">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<table class="tenant-table">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>URL</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="tenant in tenants" :key="tenant.slug" class="tenant-row" @click="openTenant(tenant.slug)">
<td>{{ tenant.name }}</td>
<td>{{ tenant.slug }}</td>
<td>{{ tenant.url }}</td>
<td>
<span :class="tenant.is_active_local_group ? 'badge-active' : 'badge-inactive'">
{{ tenant.is_active_local_group ? 'Aktiv' : 'Inaktiv' }}
</span>
</td>
</tr>
</tbody>
</table>
<div style="margin-top: 20px;">
<button v-if="!showCreate" class="btn-create" @click="showCreate = true">Neuen Stamm anlegen</button>
<div v-if="showCreate" class="create-form">
<h3>Neuen Stamm anlegen</h3>
<table class="form-table">
<tr>
<th>Name</th>
<td><input type="text" v-model="createForm.name" class="form-input" /></td>
</tr>
<tr>
<th>Slug</th>
<td><input type="text" v-model="createForm.slug" class="form-input" /></td>
</tr>
<tr>
<th>URL</th>
<td><input type="text" v-model="createForm.url" class="form-input" /></td>
</tr>
</table>
<div class="btn-group">
<button class="btn-save" @click="createTenant">Anlegen</button>
<button class="btn-cancel" @click="showCreate = false">Abbrechen</button>
</div>
</div>
</div>
</shadowed-box>
</AdminAppLayout>
</template>
<style scoped>
.tenant-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
}
.tenant-table thead th {
text-align: left;
padding: 10px 16px;
background-color: #f9fafb;
color: #374151;
font-weight: bold;
border-bottom: 2px solid #d1d5db;
}
.tenant-table tbody td {
padding: 10px 16px;
border-bottom: 1px solid #e5e7eb;
}
.tenant-row {
cursor: pointer;
}
.tenant-row:hover {
background-color: #f3f4f6;
}
.badge-active {
display: inline-block;
padding: 3px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: bold;
color: #166534;
background-color: #dcfce7;
border: 1px solid #22c55e;
}
.badge-inactive {
display: inline-block;
padding: 3px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: bold;
color: #991b1b;
background-color: #fee2e2;
border: 1px solid #ef4444;
}
.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-table {
width: 100%;
border-collapse: collapse;
}
.form-table th {
text-align: left;
padding: 8px 12px;
width: 100px;
color: #374151;
}
.form-table td {
padding: 8px 12px;
}
.form-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
}
.btn-create {
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
background-color: #1d4899;
color: #ffffff;
}
.btn-create:hover {
background-color: #163a7a;
}
.btn-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn-save, .btn-cancel {
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
}
.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;
}
</style>
+167
View File
@@ -0,0 +1,167 @@
<script setup>
import { ref, onMounted } from 'vue';
import AdminAppLayout from "../../../../resources/js/layouts/AdminAppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import FullScreenModal from "../../../Views/Components/FullScreenModal.vue";
import UserDetail from "./Partials/UserDetail.vue";
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
const { request } = useAjax()
const props = defineProps({
isLvTenant: Boolean,
})
const users = ref([])
const selectedUserId = ref(null)
const userDetail = ref(null)
onMounted(async () => {
await loadUsers()
})
async function loadUsers() {
const response = await fetch('/api/v1/admin/users/list', {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
const data = await response.json()
users.value = data.users
}
async function selectUser(userId) {
if (selectedUserId.value === userId) {
selectedUserId.value = null
userDetail.value = null
return
}
selectedUserId.value = userId
const response = await fetch('/api/v1/admin/users/' + userId, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
userDetail.value = await response.json()
}
function onUserUpdated() {
loadUsers()
if (selectedUserId.value) {
selectUser(selectedUserId.value)
}
}
function onDetailClosed() {
selectedUserId.value = null
userDetail.value = null
loadUsers()
}
</script>
<template>
<AdminAppLayout title="Benutzer*innen">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<table class="user-table">
<thead>
<tr>
<th>Nachname</th>
<th>Vorname</th>
<th>Pfadiname</th>
<th v-if="props.isLvTenant">Stamm</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users"
:key="user.id"
:class="['user-row', { 'user-row-selected': selectedUserId === user.id }]"
@click="selectUser(user.id)">
<td>{{ user.lastname }}</td>
<td>{{ user.firstname }}</td>
<td>{{ user.nickname }}</td>
<td v-if="props.isLvTenant">{{ user.local_group_name }}</td>
<td>
<span :class="user.active ? 'badge-active' : 'badge-inactive'">
{{ user.active ? 'Aktiv' : 'Inaktiv' }}
</span>
</td>
</tr>
</tbody>
</table>
</shadowed-box>
<FullScreenModal :show="userDetail !== null" @close="onDetailClosed">
<UserDetail
v-if="userDetail"
:data="userDetail"
@updated="onUserUpdated"
@closed="onDetailClosed"
/>
</FullScreenModal>
</AdminAppLayout>
</template>
<style scoped>
.user-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
}
.user-table thead th {
text-align: left;
padding: 10px 16px;
background-color: #f9fafb;
color: #374151;
font-weight: bold;
border-bottom: 2px solid #d1d5db;
}
.user-table tbody td {
padding: 10px 16px;
border-bottom: 1px solid #e5e7eb;
}
.user-row {
cursor: pointer;
}
.user-row:hover {
background-color: #f3f4f6;
}
.user-row-selected {
background-color: #eff6ff;
}
.badge-active {
display: inline-block;
padding: 3px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: bold;
color: #166534;
background-color: #dcfce7;
border: 1px solid #22c55e;
}
.badge-inactive {
display: inline-block;
padding: 3px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: bold;
color: #991b1b;
background-color: #fee2e2;
border: 1px solid #ef4444;
}
</style>
@@ -4,14 +4,13 @@ namespace App\Domains\CostUnit\Controllers;
use App\Domains\Invoice\Actions\ChangeStatus\ChangeStatusCommand;
use App\Domains\Invoice\Actions\ChangeStatus\ChangeStatusRequest;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest;
use App\Domains\Invoice\Actions\CreateInvoiceReceipt\CreateInvoiceReceiptCommand;
use App\Domains\Invoice\Actions\CreateInvoiceReceipt\CreateInvoiceReceiptRequest;
use App\Enumerations\InvoiceStatus;
use App\Models\SepaPaymentElement;
use App\Models\Tenant;
use App\Providers\FileWriteProvider;
use App\Providers\InvoiceCsvFileProvider;
use App\Providers\PainFileProvider;
use App\Providers\WebDavProvider;
use App\Providers\ZipArchiveFileProvider;
use App\Scopes\CommonController;
@@ -25,24 +24,19 @@ class ExportController extends CommonController {
$webdavProvider = new WebDavProvider(WebDavProvider::INVOICE_PREFIX . $this->tenant->url . '/' . $costUnit->name);
$painFileData = $this->painData($invoicesForExport);
$this->createSepaPaymentElements($invoicesForExport, $costUnit);
$csvData = $this->csvData($invoicesForExport);
$filePrefix = Tenant::getTempDirectory();
$painFileWriteProvider = new FileWriteProvider($filePrefix . 'abrechnungen-' . date('Y-m-d_H-i') . '-sepa.xml', $painFileData);
$painFileWriteProvider->writeToFile();
$csvFileWriteProvider = new FileWriteProvider($filePrefix . 'abrechnungen-' . date('Y-m-d_H-i') . '.csv', $csvData);
$csvFileWriteProvider->writeToFile();
if ($this->tenant->upload_exports) {
$webdavProvider->uploadFile($painFileWriteProvider->fileName);
$webdavProvider->uploadFile($csvFileWriteProvider->fileName);
}
$downloadZipArchiveFiles = [
$painFileWriteProvider->fileName,
$csvFileWriteProvider->fileName
];
@@ -72,7 +66,6 @@ class ExportController extends CommonController {
Storage::delete($file);
}
Storage::delete($painFileWriteProvider->fileName);
Storage::delete($csvFileWriteProvider->fileName);
return response()->download(
@@ -82,24 +75,28 @@ class ExportController extends CommonController {
);
}
Storage::delete($painFileWriteProvider->fileName);
Storage::delete($csvFileWriteProvider->fileName);
return response()->json([
'message' => 'Die Abrechnungen wurden exportiert.' . PHP_EOL .'Die Belege werden asynchron auf dem Webdav-Server hinterlegt.' . PHP_EOL . PHP_EOL . 'Sollten diese in 15 Minuten nicht vollständig sein, kontaktiere den Adminbistrator.'
'message' => 'Die Abrechnungen wurden exportiert.' . PHP_EOL . 'Die SEPA-Überweisungsdatei kann über den Tab "Globale Aktionen" in der Kostenstellenübersicht erzeugt werden.' . PHP_EOL . PHP_EOL . 'Die Belege werden asynchron auf dem Webdav-Server hinterlegt.' . PHP_EOL . 'Sollten diese in 15 Minuten nicht vollständig sein, kontaktiere den Administrator.'
]);
}
private function painData(array $invoices) : string {
$invoicesForPainFile = [];
private function createSepaPaymentElements(array $invoices, $costUnit): void
{
foreach ($invoices as $invoice) {
if ($invoice->contact_bank_owner !== null && $invoice->contact_bank_iban !== '' && !$invoice->donation) {
$invoicesForPainFile[] = $invoice;
SepaPaymentElement::create([
'tenant' => $this->tenant->slug,
'invoice_id' => $invoice->id,
'cost_unit_id' => $costUnit->id,
'amount' => $invoice->amount,
'recipient_name' => $invoice->contact_bank_owner,
'recipient_iban' => $invoice->contact_bank_iban,
'payment_purpose' => $invoice->payment_purpose ?? 'Auslagenerstattung Rechnungsnummer ' . $invoice->invoice_number,
]);
}
}
$painFileProvider = new PainFileProvider($this->tenant->account_iban, $this->tenant->account_name, $this->tenant->account_bic, $invoicesForPainFile);
return $painFileProvider->createPainFileContent();
}
public function csvData(array $invoices) : string {
@@ -107,4 +104,3 @@ class ExportController extends CommonController {
return $csvDateProvider->createCsvFileContent();
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Domains\CostUnit\Controllers;
use App\Enumerations\UserRole;
use App\Models\SepaPaymentElement;
use App\Models\Tenant;
use App\Providers\AuthCheckProvider;
use App\Providers\FileWriteProvider;
use App\Providers\PainFileProvider;
use App\Scopes\CommonController;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class GlobalSepaExportController extends CommonController {
private function checkAuthorization(): void
{
$authCheck = new AuthCheckProvider();
$role = $authCheck->getUserRole();
if (!in_array($role, [UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER], true)) {
abort(403);
}
}
public function getGlobalActions()
{
$this->checkAuthorization();
$pendingElements = SepaPaymentElement::where('exported', false)->get();
$pendingCount = $pendingElements->count();
$pendingAmount = number_format($pendingElements->sum('amount'), 2, ',', '.');
return response()->json([
'pending_count' => $pendingCount,
'pending_amount' => $pendingAmount,
]);
}
public function exportSepaFile()
{
$this->checkAuthorization();
return DB::transaction(function () {
$elements = SepaPaymentElement::where('exported', false)->lockForUpdate()->get();
if ($elements->isEmpty()) {
return response()->json([
'message' => 'Es gibt keine ausstehenden SEPA-Überweisungen.'
], 404);
}
$painFileProvider = new PainFileProvider(
$this->tenant->account_iban,
$this->tenant->account_name,
$this->tenant->account_bic,
$elements->all()
);
$painContent = $painFileProvider->createPainFileContent();
$filePrefix = Tenant::getTempDirectory();
$fileName = $filePrefix . 'sepa-pain-' . date('Y-m-d_H-i') . '.xml';
$fileWriteProvider = new FileWriteProvider($fileName, $painContent);
$fileWriteProvider->writeToFile();
$elements->each(function (SepaPaymentElement $element) {
$element->update([
'exported' => true,
'exported_at' => now(),
]);
});
$filePath = storage_path('app/private/' . $fileName);
return response()->download($filePath, basename($fileName), [
'Content-Type' => 'application/xml',
])->deleteFileAfterSend(true);
});
}
}
+4
View File
@@ -4,6 +4,7 @@ use App\Domains\CostUnit\Controllers\CreateController;
use App\Domains\CostUnit\Controllers\DistanceAllowanceController;
use App\Domains\CostUnit\Controllers\EditController;
use App\Domains\CostUnit\Controllers\ExportController;
use App\Domains\CostUnit\Controllers\GlobalSepaExportController;
use App\Domains\CostUnit\Controllers\ListController;
use App\Domains\CostUnit\Controllers\OpenController;
use App\Domains\CostUnit\Controllers\TreasurersEditController;
@@ -43,6 +44,9 @@ Route::prefix('api/v1')
Route::get('/global-actions', [GlobalSepaExportController::class, 'getGlobalActions']);
Route::get('/export-sepa-file', [GlobalSepaExportController::class, 'exportSepaFile']);
Route::prefix('open')->group(function () {
Route::get('/current-events', [ListController::class, 'listCurrentEvents']);
Route::get('/current-running-jobs', [ListController::class, 'listCurrentRunningJobs']);
+8
View File
@@ -7,6 +7,7 @@ import TabbedPage from "../../../Views/Components/TabbedPage.vue";
import {toast} from "vue3-toastify";
import ListCostUnits from "./Partials/ListCostUnits.vue";
import GlobalActions from "./Partials/GlobalActions.vue";
const props = defineProps({
message: String,
@@ -63,6 +64,13 @@ const tabs = [
deep_jump_id: initialCostUnitId,
deep_jump_id_sub: initialInvoiceId,
},
{
title: 'Globale Aktionen',
component: GlobalActions,
endpoint: "/api/v1/cost-unit/global-actions",
deep_jump_id: 0,
deep_jump_id_sub: 0,
},
]
onMounted(() => {
@@ -0,0 +1,112 @@
<script setup>
import {ref} from 'vue'
import LoadingModal from "../../../../Views/Components/LoadingModal.vue";
import {toast} from "vue3-toastify";
const props = defineProps({
data: {
type: [Array, Object],
default: () => ({})
},
deep_jump_id: {
type: Number,
default: 0
},
deep_jump_id_sub: {
type: Number,
default: 0
}
})
const showLoading = ref(false)
async function exportSepaFile() {
showLoading.value = true;
try {
const response = await fetch('/api/v1/cost-unit/export-sepa-file', {
headers: {"Content-Type": "application/json"},
});
if (!response.ok) {
if (response.status === 404) {
const data = await response.json();
toast.info(data.message);
} else {
throw new Error('Fehler beim Erzeugen der SEPA-Datei');
}
showLoading.value = false;
return;
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = downloadUrl;
a.download = "sepa-pain-" + new Date().toISOString().slice(0, 10) + ".xml";
document.body.appendChild(a);
a.click();
setTimeout(() => {
window.URL.revokeObjectURL(downloadUrl);
document.body.removeChild(a);
}, 100);
toast.success('SEPA-Datei wurde erfolgreich erzeugt.');
showLoading.value = false;
} catch (err) {
showLoading.value = false;
toast.error('Beim Erzeugen der SEPA-Datei ist ein Fehler aufgetreten.');
}
}
</script>
<template>
<div>
<h2>Globale Aktionen</h2>
<div style="margin: 20px 0;">
<p v-if="props.data.pending_count > 0">
Es gibt <strong>{{ props.data.pending_count }}</strong> ausstehende SEPA-Überweisungen
(Gesamtbetrag: <strong>{{ props.data.pending_amount }} Euro</strong>).
</p>
<p v-else>
Keine ausstehenden SEPA-Überweisungen vorhanden.
</p>
</div>
<button
class="action-button"
:disabled="!props.data.pending_count || props.data.pending_count === 0"
@click="exportSepaFile"
>
Erzeuge SEPA-File
</button>
<loading-modal v-if="showLoading" />
</div>
</template>
<style scoped>
.action-button {
padding: 10px 20px;
background-color: #0073aa;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.action-button:hover:not(:disabled) {
background-color: #005a87;
}
.action-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
</style>
@@ -30,7 +30,7 @@ class CreateInvoiceCommand {
'type' => $this->request->invoiceType,
'type_other' => $this->request->invoiceTypeExtended,
'donation' => $this->request->isDonation,
'user_id' => $this->request->userId,
'user_id' => $this->request->paymentPurpose === null ? $this->request->userId : null,
'contact_name' => $this->request->contactName,
'contact_email' => $this->request->contactEmail,
'contact_phone' => $this->request->contactPhone,
@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Legal\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class GdprController extends CommonController
{
public function __invoke(Request $request): Response
{
$inertiaProvider = new InertiaProvider('Legal/LegalPage', [
'title' => 'Datenschutzerklärung',
'content' => $this->tenant->gdpr_text ?? '',
]);
return $inertiaProvider->render();
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Legal\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class ImpressController extends CommonController
{
public function __invoke(Request $request): Response
{
$inertiaProvider = new InertiaProvider('Legal/LegalPage', [
'title' => 'Impressum',
'content' => $this->tenant->impress_text ?? '',
]);
return $inertiaProvider->render();
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
use App\Domains\Legal\Controllers\ImpressController;
use App\Domains\Legal\Controllers\GdprController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::middleware(IdentifyTenant::class)->group(function () {
Route::get('/impress', ImpressController::class);
Route::get('/gdpr', GdprController::class);
});
+83
View File
@@ -0,0 +1,83 @@
<script setup>
import AppLayout from "../../../../resources/js/layouts/AppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
const props = defineProps({
title: String,
content: String,
})
</script>
<template>
<AppLayout :title="props.title">
<ShadowedBox class="legal-page-box">
<h2 class="legal-page-title">{{ props.title }}</h2>
<div class="legal-page-content" v-html="props.content"></div>
</ShadowedBox>
</AppLayout>
</template>
<style scoped>
.legal-page-box {
width: 95%;
margin: 20px auto;
padding: 20px;
overflow-x: hidden;
}
.legal-page-title {
font-size: 1.5rem;
margin: 0 0 20px 0;
color: #333;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 12px;
}
.legal-page-content {
line-height: 1.7;
color: #374151;
word-wrap: break-word;
overflow-wrap: break-word;
}
.legal-page-content :deep(img) {
max-width: 100%;
height: auto;
}
.legal-page-content :deep(table) {
width: 100%;
overflow-x: auto;
display: block;
}
.legal-page-content :deep(a) {
color: #2563eb;
word-break: break-all;
}
.legal-page-content :deep(h1),
.legal-page-content :deep(h2),
.legal-page-content :deep(h3) {
margin-top: 1.5em;
margin-bottom: 0.5em;
color: #1f2937;
}
.legal-page-content :deep(p) {
margin-bottom: 1em;
}
@media (max-width: 639px) {
.legal-page-box {
width: 100%;
margin: 0;
padding: 16px 12px;
border-radius: 0;
}
.legal-page-title {
font-size: 1.2rem;
}
}
</style>
@@ -2,6 +2,7 @@
namespace App\Domains\UserManagement\Controllers;
use App\Enumerations\UserRole;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
@@ -38,20 +39,44 @@ class LoginController extends CommonController {
return redirect()->intended('/register/verifyEmail');
}
#$credentials = ['username' => 'development', 'password' => 'development'];
if (!Auth::attempt($credentials)) {
return back()->withErrors([
'username' => 'Diese Zugangsdaten sind ungültig.',
]);
}
$request->session()->regenerate();
$user = Auth::user();
$tenant = app('tenant');
// Auf "lv" darf sich grundsätzlich jeder aktive Nutzer einloggen.
// Auf Sub-Tenants gilt:
// - Der Nutzer muss dem Tenant zugeordnet sein (local_group)
// - ODER er hat "Bundesrecht über Landesrecht":
// user_role_main === ROLE_ADMINISTRATOR -> Login auf jedem Sub-Tenant erlaubt.
$isMainAdmin = $user->user_role_main === UserRole::USER_ROLE_ADMIN;
$isMemberOfTenant = $tenant->slug === $user->local_group;
# dd($user->firstname . ' ' . $user->lastname);
if ($tenant->slug !== 'lv' && !$isMainAdmin && !$isMemberOfTenant) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return back()->withErrors([
'username' => 'Diese Zugangsdaten sind für diesen Stamm nicht gültig.',
]);
}
if (!$user->active) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return back()->withErrors([
'username' => 'Dieses Benutzerkonto ist nicht aktiv.',
]);
}
$request->session()->regenerate();
return redirect()->intended('/');
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Middleware;
use App\Enumerations\UserRole;
use App\Providers\AuthCheckProvider;
use Closure;
class AdminRoleMiddleware
{
public function handle($request, Closure $next)
{
if (!auth()->check()) {
return redirect('/login')->with('message', 'Du musst eingeloggt sein.');
}
$authCheck = new AuthCheckProvider();
$role = $authCheck->getUserRole();
if (!in_array($role, [UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER], true)) {
return redirect('/')->with('message', 'Du bist dazu nicht berechtigt.');
}
return $next($request);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Middleware;
use Closure;
class LvOnlyMiddleware
{
public function handle($request, Closure $next)
{
if (app('tenant')->slug !== 'lv') {
return redirect('/admin')->with('message', 'Diese Funktion ist nur auf LV-Ebene verfügbar.');
}
return $next($request);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Scopes\InstancedModel;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property string $id
* @property string $tenant
* @property int $invoice_id
* @property int $cost_unit_id
* @property float $amount
* @property string $recipient_name
* @property string $recipient_iban
* @property string $payment_purpose
* @property bool $exported
* @property \DateTime|null $exported_at
*/
class SepaPaymentElement extends InstancedModel
{
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'tenant',
'invoice_id',
'cost_unit_id',
'amount',
'recipient_name',
'recipient_iban',
'payment_purpose',
'exported',
'exported_at',
];
protected $casts = [
'exported' => 'boolean',
'exported_at' => 'datetime',
];
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public function costUnit(): BelongsTo
{
return $this->belongsTo(CostUnit::class);
}
}
+33 -3
View File
@@ -3,6 +3,7 @@
namespace App\Providers;
use App\Enumerations\UserRole;
use App\Models\User;
class AuthCheckProvider {
public function checkLoggedIn() : bool {
@@ -16,7 +17,7 @@ class AuthCheckProvider {
return $user->active;
}
if ($user->user_role_main === UserRole::USER_ROLE_ADMIN) {
if ($this->isMainAdministrator($user)) {
return true;
}
@@ -28,10 +29,39 @@ class AuthCheckProvider {
return null;
}
$user = auth()->user();
if (app('tenant')->slug === 'lv') {
return auth()->user()->user_role_main;
return $user->user_role_main;
}
return auth()->user()->user_role_local_group;
// "Bundesrecht steht über Landesrecht":
// Ein ROLE_ADMINISTRATOR auf LV-Ebene ist auf jedem Sub-Tenant automatisch Administrator,
// unabhängig von user_role_local_group.
if ($this->isMainAdministrator($user)) {
return UserRole::USER_ROLE_ADMIN;
}
return $user->user_role_local_group;
}
/**
* Gibt true zurück, wenn der Nutzer auf LV-Ebene Administrator ist.
* Diese Rolle hebt das lokale Rechtesystem für alle Sub-Tenants auf.
*/
public function isMainAdministrator(?User $user = null) : bool {
$user ??= auth()->user();
return $user !== null
&& $user->user_role_main === UserRole::USER_ROLE_ADMIN;
}
/**
* Bequemer Helper für die Berechtigungs-Checks im gesamten System.
* Gibt true zurück, wenn der aktuell eingeloggte Nutzer im Kontext des
* aktuellen Tenants effektiv Administrator ist.
*/
public function isAdministrator() : bool {
return $this->getUserRole() === UserRole::USER_ROLE_ADMIN;
}
}
+51 -25
View File
@@ -20,34 +20,21 @@ class GlobalDataProvider {
public function __invoke() {
$this->user = auth()->user();
$canAccessAdmin = false;
if (null !== $this->user) {
$authCheck = new AuthCheckProvider();
$effectiveRole = $authCheck->getUserRole();
$canAccessAdmin = in_array($effectiveRole, [UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER], true);
}
return response()->json([
'user' => null !== $this->user ? new UserResource($this->user)->toArray(request()) : null,
'navbar' => $this->generateNavbar(),
'tenant' => app('tenant'),
'activeUsers' => $this->getActiveUsers(),
'version' => config('app.version'),
]);
}
public function getAllInvoiceTypes() : JsonResponse {
$invoiceTypes = [];
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
if (
$invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER
) {
continue;
}
$invoiceTypes[] = [
'slug' => $invoiceType->slug,
'name' => $invoiceType->name
];
}
$invoiceTypes[] = ['slug' => InvoiceType::INVOICE_TYPE_OTHER, 'name' => 'Sonstige Kosten'];
return response()->json([
'invoiceTypes' => $invoiceTypes
'currentEvent' => $this->getCurrentEventData(),
'canAccessAdmin' => $canAccessAdmin,
]);
}
@@ -99,10 +86,47 @@ class GlobalDataProvider {
]);
}
private function getCurrentEventData() : ?array {
if (null === $this->user) {
return null;
}
$currentEvent = new EventRepository()->getMyCurrentEvent();
if (null === $currentEvent) {
return null;
}
return [
'identifier' => $currentEvent->identifier,
'name' => $currentEvent->name,
];
}
public function getAllInvoiceTypes() : JsonResponse {
$invoiceTypes = [];
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
if (
$invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER
) {
continue;
}
$invoiceTypes[] = [
'slug' => $invoiceType->slug,
'name' => $invoiceType->name
];
}
$invoiceTypes[] = ['slug' => InvoiceType::INVOICE_TYPE_OTHER, 'name' => 'Sonstige Kosten'];
return response()->json([
'invoiceTypes' => $invoiceTypes
]);
}
private function generateNavbar() : array {
$eventRepository = new EventRepository();
$navigation = [
'personal' => [],
'common' => [],
@@ -116,9 +140,11 @@ class GlobalDataProvider {
$navigation['personal'][] = ['url' => '/personal-data', 'display' => 'Meine Daten'];
$navigation['personal'][] = ['url' => '/messages', 'display' => 'Meine Nachrichten'];
$authCheck = new AuthCheckProvider();
$effectiveRole = $authCheck->getUserRole();
if (
in_array($this->user->user_role_local_group, [UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER] ) ||
$this->user->user_role_main === UserRole::USER_ROLE_ADMIN
in_array($effectiveRole, [UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER], true)
) {
$navigation['costunits'][] = ['url' => '/cost-unit/list', 'display' => 'Kostenstellen'];
$navigation['costunits'][] = ['url' => '/cost-unit/create', 'display' => 'Neue laufende Tätigkeit'];
+13 -17
View File
@@ -2,25 +2,23 @@
namespace App\Providers;
use App\Models\Invoice;
use App\Resources\InvoiceResource;
use App\Models\SepaPaymentElement;
use DOMDocument;
use Exception;
use Illuminate\Http\Request;
class PainFileProvider {
public string $senderIban;
public string $senderName;
public string $senderBic;
/* @var Invoice[] */
public array $invoices;
/** @var SepaPaymentElement[] */
public array $elements;
public function __construct(string $senderIban, string $senderName, string $senderBic, array $invoices) {
public function __construct(string $senderIban, string $senderName, string $senderBic, array $elements) {
$this->senderIban = $senderIban;
$this->senderName = $senderName;
$this->senderBic = $senderBic;
$this->invoices = $invoices;
$this->elements = $elements;
}
public function createPainFileContent() : string {
@@ -46,9 +44,9 @@ class PainFileProvider {
$grp_hdr->appendChild($doc->createElement('MsgId', uniqid('MSG')));
$grp_hdr->appendChild($doc->createElement('CreDtTm', date('c')));
$grp_hdr->appendChild($doc->createElement('NbOfTxs', count($this->invoices)));
$grp_hdr->appendChild($doc->createElement('NbOfTxs', count($this->elements)));
$totalAmount = array_sum(array_column($this->invoices, 'amount'));
$totalAmount = array_sum(array_map(fn(SepaPaymentElement $e) => $e->amount, $this->elements));
$grp_hdr->appendChild($doc->createElement('CtrlSum', number_format($totalAmount, 2, '.', '')));
$initg_pty = $doc->createElement('InitgPty');
@@ -62,7 +60,7 @@ class PainFileProvider {
$pmt_inf->appendChild($doc->createElement('PmtInfId', uniqid('PMT')));
$pmt_inf->appendChild($doc->createElement('PmtMtd', 'TRF'));
$pmt_inf->appendChild($doc->createElement('BtchBookg', 'false'));
$pmt_inf->appendChild($doc->createElement('NbOfTxs', count($this->invoices)));
$pmt_inf->appendChild($doc->createElement('NbOfTxs', count($this->elements)));
$pmt_inf->appendChild($doc->createElement('CtrlSum', number_format($totalAmount, 2, '.', '')));
$pmt_tp_inf = $doc->createElement('PmtTpInf');
@@ -90,9 +88,7 @@ class PainFileProvider {
$dbtr_agt->appendChild($id);
$pmt_inf->appendChild($dbtr_agt);
foreach ($this->invoices as $index => $invoice) {
$invoiceResource = new InvoiceResource($invoice)->toArray(new Request());
foreach ($this->elements as $index => $element) {
$cdt_trf_tx_inf = $doc->createElement('CdtTrfTxInf');
$pmt_id = $doc->createElement('PmtId');
@@ -100,23 +96,23 @@ class PainFileProvider {
$cdt_trf_tx_inf->appendChild($pmt_id);
$amt = $doc->createElement('Amt');
$instd_amt = $doc->createElement('InstdAmt', number_format($invoice['amount'], 2, '.', ''));
$instd_amt = $doc->createElement('InstdAmt', number_format($element->amount, 2, '.', ''));
$instd_amt->setAttribute('Ccy', 'EUR');
$amt->appendChild($instd_amt);
$cdt_trf_tx_inf->appendChild($amt);
$cdtr = $doc->createElement('Cdtr');
$cdtr->appendChild($doc->createElement('Nm', $invoice['contact_bank_owner']));
$cdtr->appendChild($doc->createElement('Nm', $element->recipient_name));
$cdt_trf_tx_inf->appendChild($cdtr);
$cdtr_acct = $doc->createElement('CdtrAcct');
$cdtr_id = $doc->createElement('Id');
$cdtr_id->appendChild($doc->createElement('IBAN', str_replace(' ', '', $invoice['contact_bank_iban'])));
$cdtr_id->appendChild($doc->createElement('IBAN', str_replace(' ', '', $element->recipient_iban)));
$cdtr_acct->appendChild($cdtr_id);
$cdt_trf_tx_inf->appendChild($cdtr_acct);
$rmt_inf = $doc->createElement('RmtInf');
$rmt_inf->appendChild($doc->createElement('Ustrd', $invoiceResource['paymentPurpose']));
$rmt_inf->appendChild($doc->createElement('Ustrd', $element->payment_purpose));
$cdt_trf_tx_inf->appendChild($rmt_inf);
$pmt_inf->appendChild($cdt_trf_tx_inf);
+11 -5
View File
@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Enumerations\UserRole;
use Illuminate\Auth\EloquentUserProvider;
class TenantUserProvider extends EloquentUserProvider
@@ -18,15 +19,20 @@ class TenantUserProvider extends EloquentUserProvider
}
}
// Auf "lv" gilt grundsätzlich keine local_group-Einschränkung.
if (app('tenant')->slug === 'lv') {
return $query->first();
}
$query->where([
'local_group' => app('tenant')->slug,
'active' => true
]);
// Auf Sub-Tenants:
// - Entweder gehört der Nutzer zum aktuellen Tenant (local_group)
// - ODER er ist auf LV-Ebene Administrator
// -> "Bundesrecht steht über Landesrecht": Login überall möglich.
$query->where('active', true)
->where(function ($q) {
$q->where('local_group', app('tenant')->slug)
->orWhere('user_role_main', UserRole::USER_ROLE_ADMIN);
});
return $query->first();
}
+3 -2
View File
@@ -7,6 +7,7 @@ use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\UserRole;
use App\Models\CostUnit;
use App\Providers\AuthCheckProvider;
use App\Resources\CostUnitResource;
use App\ValueObjects\Amount;
use Illuminate\Database\Capsule\Manager as Capsule;
@@ -75,8 +76,8 @@ class CostUnitRepository {
} else {
if ($tenant->slug !== 'lv') {
if (
$user->user_role_main === UserRole::USER_ROLE_ADMIN ||
in_array($user->user_role_local_group, [UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_ADMIN])
new AuthCheckProvider()->isAdministrator() ||
$user->user_role_local_group === UserRole::USER_ROLE_ADMIN
) {
$canSeeAll = true;
}
+18 -2
View File
@@ -6,6 +6,7 @@ use App\Enumerations\ParticipationType;
use App\Enumerations\UserRole;
use App\Models\CostUnit;
use App\Models\Event;
use App\Providers\AuthCheckProvider;
use App\Resources\CostUnitResource;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
@@ -19,13 +20,25 @@ class EventRepository {
}
public function getMyCurrentEvent() : ?Event {
$events = $this->getEventsByCriteria([
['archived', '=', false],
['start_date', '<=', now()],
['end_date', '>=', now()],
], true);
if (count($events) !== 1) {
return null;
}
return $events[0];
}
public function getUpcoming(int $maxCount = 5, bool $accessCheck = true) : array {
$events = [];
foreach ( $this->getEventsByCriteria([
'archived' => false,
],$accessCheck) as $event) {
if ($event->start_date > now()) {
if ($event->end_date >= now()) {
$event = $event->toResource()->toArray(new Request());
$events[] = $event;
@@ -72,7 +85,10 @@ class EventRepository {
if (!$accessCheck) {
$canSeeAll = true;
} else {
if ($tenant->slug !== 'lv') {
if (
new AuthCheckProvider()->isAdministrator() ||
$user->user_role_local_group === UserRole::USER_ROLE_ADMIN
) {
if (
$user->user_role_main === UserRole::USER_ROLE_ADMIN ||
in_array($user->user_role_local_group, [UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_ADMIN])
+6
View File
@@ -2,6 +2,8 @@
namespace App\Scopes;
use App\Domains\Admin\Repositories\AdminTenantRepository;
use App\Domains\Admin\Repositories\AdminUserRepository;
use App\Models\Tenant;
use App\Providers\AuthCheckProvider;
use App\Repositories\CostUnitRepository;
@@ -23,6 +25,8 @@ abstract class CommonController {
protected EventRepository $events;
protected EventParticipantRepository $eventParticipants;
protected EstimatesRepository $estimates;
protected AdminUserRepository $adminUsers;
protected AdminTenantRepository $adminTenants;
public function __construct() {
$this->tenant = app('tenant');
@@ -33,6 +37,8 @@ abstract class CommonController {
$this->events = new EventRepository();
$this->eventParticipants = new EventParticipantRepository();
$this->estimates = new EstimatesRepository();
$this->adminUsers = new AdminUserRepository();
$this->adminTenants = new AdminTenantRepository();
}
protected function checkAuth() {
@@ -0,0 +1,23 @@
<?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('tenants', function (Blueprint $table) {
$table->longText('gdpr_text')->nullable()->change();
$table->longText('impress_text')->nullable()->change();
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->string('gdpr_text')->nullable()->change();
$table->string('impress_text')->nullable()->change();
});
}
};
@@ -0,0 +1,31 @@
<?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::create('sepa_payment_elements', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('tenant');
$table->foreignId('invoice_id')->constrained('invoices', 'id')->restrictOnDelete()->cascadeOnUpdate();
$table->foreignId('cost_unit_id')->constrained('cost_units', 'id')->restrictOnDelete()->cascadeOnUpdate();
$table->float('amount', 2);
$table->string('recipient_name');
$table->string('recipient_iban');
$table->string('payment_purpose');
$table->boolean('exported')->default(false);
$table->dateTime('exported_at')->nullable();
$table->foreign('tenant')->references('slug')->on('tenants')->restrictOnDelete()->cascadeOnUpdate();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('sepa_payment_elements');
}
};
+4 -4
View File
@@ -57,8 +57,8 @@ h1, h2, h3, h4, h5, h6 {
.header .user-info {
position: relative;
right: calc(-100% + 190px);
width: 195px;
right: calc(-100% + 250px);
width: 240px;
overflow: hidden;
border-radius: 50px 0 0 50px;
text-align: right;
@@ -108,11 +108,11 @@ h1, h2, h3, h4, h5, h6 {
display: flex;
align-items: center;
justify-content: center;
height: 180px;
}
.logo img {
width: 135px !important;
height: 70px !important;
width: 100% !important;
}
.footer {
Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

+459
View File
@@ -0,0 +1,459 @@
<script setup>
import {reactive, onMounted, ref, computed} from 'vue';
import Icon from "../../../app/Views/Components/Icon.vue";
import {toast} from "vue3-toastify";
import {useAjax} from "../components/ajaxHandler.js";
const { request } = useAjax()
const globalProps = reactive({
tenant: '',
user: null,
currentPath: '/',
errors: {},
availableLocalGroups: [],
message: '',
currentEvent: null,
version: '',
});
const sidebarOpen = ref(false);
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value;
}
function closeSidebar() {
sidebarOpen.value = false;
}
onMounted(async () => {
const response = await fetch('/api/v1/core/retrieve-global-data');
const data = await response.json();
Object.assign(globalProps, data);
const messageResponse = await request('/api/v1/core/retrieve-messages', {
method: 'GET',
})
if (messageResponse.message !== '') {
if (messageResponse.messageType === 'success') {
toast.success(messageResponse.message)
} else {
toast.error(messageResponse.message)
}
}
});
const currentPath = window.location.pathname;
const props = defineProps({
title: { type: String, default: 'Administration' },
flash: { type: Object, default: () => ({}) }
});
</script>
<template>
<div class="app-layout">
<!-- Mobile Overlay -->
<div class="sidebar-overlay" :class="{ active: sidebarOpen }" @click="closeSidebar"></div>
<div class="main">
<!-- Header -->
<div class="header">
<button class="hamburger-btn" @click="toggleSidebar" aria-label="Menü öffnen">
<span></span>
<span></span>
<span></span>
</button>
<div class="left-side">
<h1>{{ props.title }}</h1>
<label id="show_username" v-if="globalProps.user !== null">Willkommen, {{ globalProps.user.nicename }}</label>
</div>
<div class="header-actions" v-if="globalProps.user !== null">
<div class="user-info">
<a href="/" class="header-link-anonymous" title="Zurück zur Anwendung">
<Icon name="desktop" />
</a>
<a href="/messages" class="header-link-anonymous" title="Meine Nachrichten">
<Icon name="envelope" />
</a>
<a href="/profile" class="header-link-anonymous" title="Mein Profil">
<Icon name="user" />
</a>
<a href="/logout" class="header-link-anonymous-logout" title="Abmelden">
<Icon name="lock" />
</a>
</div>
</div>
</div>
<!-- Flexbox: Sidebar + Content -->
<div class="flexbox">
<div class="sidebar" :class="{ 'sidebar-open': sidebarOpen }">
<div class="logo">
<img src="../../../public/images/mareike.png" alt="Logo" />
</div>
<nav class="nav">
<ul class="nav-links">
<li>
<a href="/admin/tenant" @click="closeSidebar">
{{ globalProps.tenant.slug === 'lv' ? 'LV-Daten' : 'Stammesdaten' }}
</a>
</li>
</ul>
<ul class="nav-links">
<li v-if="globalProps.tenant.slug === 'lv'">
<a href="/admin/tenants" @click="closeSidebar">Stämme</a>
</li>
<li>
<a href="/admin/users" @click="closeSidebar">Benutzer*innen</a>
</li>
</ul>
</nav>
</div>
<div class="content-area">
<div class="content">
<slot />
</div>
</div>
</div>
<!-- Footer -->
<footer class="footer">
<div class="footer-inner">
<span>Version {{ globalProps.version }}</span>
<span class="footer-hide-mobile">mareike Modernes Anmeldesystem und richtig einfache Kostenerfassung</span>
<a href="/impress" class="footer-link">Impressum</a>
<a href="/gdpr" class="footer-link">Datenschutzerklärung</a>
<span>&copy; 2022 2026</span>
</div>
</footer>
</div>
<transition name="fade">
<div v-if="flash.message" class="toaster">
{{ flash.message }}
</div>
</transition>
</div>
</template>
<style scoped>
/* ─── Header ─── */
.header {
display: flex;
align-items: center;
height: 80px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 0;
position: relative;
z-index: 50;
flex-shrink: 0;
}
.left-side {
flex: 1;
padding: 0 20px;
overflow: hidden;
}
.left-side h1 {
margin: 0;
font-size: 1.4rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#show_username {
display: block;
font-weight: bold;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions {
display: flex;
align-items: center;
flex-shrink: 0;
width: 260px;
}
.header-link-anonymous,
.header-link-anonymous-logout {
color: #000000;
font-weight: bold;
text-decoration: none;
background-color: #ffffff;
padding: 10px 20px;
display: inline-block;
}
.header-link-anonymous:hover {
background-color: #1d4899;
color: #ffffff;
}
.header-link-anonymous-logout:hover {
background-color: #ff0000;
color: #ffffff;
}
/* ─── Hamburger ─── */
.hamburger-btn {
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
width: 50px;
height: 80px;
border: none;
background: transparent;
cursor: pointer;
flex-shrink: 0;
padding: 0 12px;
}
.hamburger-btn span {
display: block;
width: 24px;
height: 3px;
background-color: #333;
border-radius: 2px;
transition: all 0.2s;
}
/* ─── Layout ─── */
.app-layout {
display: flex;
height: 100vh;
background: #f0f2f5;
font-family: sans-serif;
overflow: hidden;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
margin: 20px;
box-shadow: 20px 20px 15px rgba(0, 0, 0, 0.1);
border-radius: 0 10px 0 0;
}
.flexbox {
display: flex;
flex: 1;
background-color: #FAFAFB;
overflow: hidden;
gap: 1px;
}
/* ─── Sidebar ─── */
.sidebar {
flex-basis: 275px;
flex-shrink: 0;
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
background-color: #ffffff;
overflow-y: auto;
transition: transform 0.3s ease;
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 99;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
height: 180px;
width: 100%;
margin-bottom: 30px;
}
.logo img {
object-fit: contain;
}
/* ─── Nav ─── */
.nav {
flex: 1;
}
.nav ul {
list-style: none;
padding: 0;
margin: 0;
border-bottom: 1px solid #ddd;
}
.nav-links li a {
color: #b6b6b6;
background-color: #fff;
padding: 16px 25px;
display: block;
text-decoration: none;
font-weight: bold;
}
.nav a:hover {
background-color: #1d4899;
color: #ffffff;
}
/* ─── Content ─── */
.content-area {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
.content {
padding: 30px 20px;
flex: 1;
}
/* ─── Footer ─── */
.footer {
background: #666666;
border-top: 1px solid #ddd;
color: #ffffff;
padding: 10px 15px;
font-size: 11pt;
font-weight: bold;
flex-shrink: 0;
}
.footer-inner {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
justify-content: space-between;
align-items: center;
}
.footer-link {
color: #ffffff;
text-decoration: none;
}
.footer-link:hover {
text-decoration: underline;
}
/* ═══════════════════════════════════════════
TABLET (640px 1023px)
═══════════════════════════════════════════ */
@media (max-width: 1023px) {
.app-layout {
margin: 0;
height: 100vh;
}
.main {
margin: 0;
border-radius: 0;
box-shadow: none;
}
.hamburger-btn {
display: flex;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
transform: translateX(-100%);
width: 260px;
flex-basis: 260px;
}
.sidebar.sidebar-open {
transform: translateX(0);
}
.sidebar-overlay.active {
display: block;
}
.header-link-anonymous,
.header-link-anonymous-logout {
padding: 10px 12px;
font-size: 0.9rem;
}
.left-side h1 {
font-size: 1.1rem;
}
}
/* ═══════════════════════════════════════════
SMARTPHONE (< 640px)
═══════════════════════════════════════════ */
@media (max-width: 639px) {
.header {
height: 60px;
}
.hamburger-btn {
height: 60px;
}
.left-side h1 {
font-size: 1rem;
}
#show_username {
display: none;
}
.header-link-anonymous,
.header-link-anonymous-logout {
padding: 6px 8px;
font-size: 0.75rem;
display: inline;
}
.footer-hide-mobile {
display: none;
}
.footer-inner {
justify-content: center;
font-size: 9pt;
gap: 6px 12px;
}
.content {
padding: 15px 10px;
}
.sidebar {
width: 240px;
flex-basis: 240px;
}
}
</style>
+78 -13
View File
@@ -1,5 +1,5 @@
<script setup>
import {reactive, onMounted, ref} from 'vue';
import {reactive, onMounted, ref, computed} from 'vue';
import Icon from "../../../app/Views/Components/Icon.vue";
import GlobalWidgets from "../../../app/Views/Partials/GlobalWidgets/GlobalWidgets.vue";
import {toast} from "vue3-toastify";
@@ -20,7 +20,8 @@ const globalProps = reactive({
currentPath: '/',
errors: {},
availableLocalGroups: [],
message: ''
message: '',
currentEvent: null,
});
const sidebarOpen = ref(false);
@@ -51,13 +52,17 @@ onMounted(async () => {
}
});
const currentPath = window.location.pathname;
const showCurrentEventLink = computed(() => {
if (!globalProps.currentEvent) {
return false;
}
return currentPath !== '/event/details/' + globalProps.currentEvent.identifier;
});
const canAccessAdmin = computed(() => globalProps.canAccessAdmin ?? false);
const props = defineProps({
title: { type: String, default: 'App' },
flash: { type: Object, default: () => ({}) }
@@ -84,8 +89,21 @@ const props = defineProps({
<label id="show_username" v-if="globalProps.user !== null">Willkommen, {{ globalProps.user.nicename }}</label>
</div>
<a
v-if="showCurrentEventLink"
:href="'/event/details/' + globalProps.currentEvent.identifier"
class="current-event-link"
:title="'Zur Veranstaltung: ' + globalProps.currentEvent.name"
>
<Icon name="calendar-day" />
<span class="current-event-link-label">{{ globalProps.currentEvent.name }}</span>
</a>
<div class="header-actions" v-if="globalProps.user !== null">
<div class="user-info">
<a v-if="canAccessAdmin" href="/admin" class="header-link-anonymous" title="Administration">
<Icon name="gear" />
</a>
<a href="/messages" class="header-link-anonymous" title="Meine Nachrichten">
<Icon name="envelope" />
</a>
@@ -110,7 +128,7 @@ const props = defineProps({
<div class="flexbox">
<div class="sidebar" :class="{ 'sidebar-open': sidebarOpen }">
<div class="logo">
<img src="../../../public/images/logo.png" alt="Logo" />
<img src="../../../public/images/mareike.png" alt="Logo" />
</div>
<nav class="nav">
<ul class="nav-links" v-if="globalProps.navbar.personal.length > 0">
@@ -159,8 +177,8 @@ const props = defineProps({
<div class="footer-inner">
<span>Version {{ globalProps.version }}</span>
<span class="footer-hide-mobile">mareike Modernes Anmeldesystem und richtig einfache Kostenerfassung</span>
<span>Impressum</span>
<span>Datenschutzerklärung</span>
<a href="/impress" class="footer-link">Impressum</a>
<a href="/gdpr" class="footer-link">Datenschutzerklärung</a>
<span>&copy; 2022 2026</span>
</div>
</footer>
@@ -222,7 +240,7 @@ const props = defineProps({
display: flex;
align-items: center;
flex-shrink: 0;
width: 200px;
width: 260px;
}
.header-link-anonymous,
@@ -322,11 +340,13 @@ const props = defineProps({
align-items: center;
justify-content: center;
padding: 10px 0;
height: 180px;
width: 100%;
margin-bottom: 30px;
}
.logo img {
width: 135px;
height: 70px;
width: 100%;
object-fit: contain;
}
@@ -394,6 +414,43 @@ const props = defineProps({
align-items: center;
}
.footer-link {
color: #ffffff;
text-decoration: none;
}
.footer-link:hover {
text-decoration: underline;
}
/* ─── Direktlink zum aktuellen Event ─── */
.current-event-link {
display: none; /* per Default ausgeblendet nur auf Mobile sichtbar */
align-items: center;
gap: 6px;
color: #1d4899;
font-weight: bold;
text-decoration: none;
padding: 6px 10px;
border-radius: 4px;
margin-right: 10px;
max-width: 50%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-event-link:hover {
background-color: #1d4899;
color: #ffffff;
}
.current-event-link-label {
overflow: hidden;
text-overflow: ellipsis;
}
/* ═══════════════════════════════════════════
TABLET (640px 1023px)
═══════════════════════════════════════════ */
@@ -455,6 +512,14 @@ const props = defineProps({
height: 60px;
}
.current-event-link {
display: inline-flex;
}
.current-event-link-label {
max-width: 120px;
}
.left-side h1 {
font-size: 1rem;
}
+54
View File
@@ -0,0 +1,54 @@
<h3>1. Verantwortlicher</h3>
<p>Verantwortlich für die Datenverarbeitung auf dieser Website ist:<br>
<strong>[Name des Vereins]</strong><br>
[Straße und Hausnummer]<br>
[PLZ Ort]<br>
E-Mail: [E-Mail-Adresse]</p>
<p>Der Verein hat keinen Datenschutzbeauftragten bestellt, da die Voraussetzungen nach Art. 37 DSGVO nicht vorliegen.</p>
<h3>2. Erhebung und Speicherung personenbezogener Daten</h3>
<p>Beim Besuch dieser Website werden durch den Webserver automatisch folgende Daten in Server-Logfiles gespeichert:</p>
<ul>
<li>IP-Adresse des anfragenden Rechners</li>
<li>Datum und Uhrzeit des Zugriffs</li>
<li>Aufgerufene Seite bzw. Name der abgerufenen Datei</li>
<li>Verwendeter Browser und Betriebssystem</li>
</ul>
<p>Diese Daten sind nicht bestimmten Personen zuordenbar. Eine Zusammenführung mit anderen Datenquellen findet nicht statt. Die Daten werden nach einer statistischen Auswertung gelöscht.</p>
<h3>3. Registrierung und Nutzerkonto</h3>
<p>Zur Nutzung bestimmter Funktionen ist eine Registrierung erforderlich. Dabei werden folgende Daten erhoben:</p>
<ul>
<li>Vor- und Nachname</li>
<li>E-Mail-Adresse</li>
<li>Weitere freiwillige Angaben je nach Funktion (z.&nbsp;B. Geburtsdatum, Telefonnummer, Adresse)</li>
</ul>
<p>Die Verarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung bzw. vorvertragliche Maßnahmen) sowie Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Vereinsverwaltung).</p>
<h3>4. Cookies</h3>
<p>Diese Website verwendet ausschließlich technisch notwendige Cookies, die für den Betrieb der Seite erforderlich sind (z.&nbsp;B. Session-Cookies zur Authentifizierung). Diese Cookies werden nach Ende der Browser-Sitzung automatisch gelöscht.</p>
<p>Es werden keine Tracking-Cookies, Analyse-Cookies oder Cookies von Drittanbietern eingesetzt. Eine Einwilligung ist für technisch notwendige Cookies nicht erforderlich (§ 25 Abs. 2 TDDDG).</p>
<h3>5. Keine Weitergabe an Dritte und keine externen Dienste</h3>
<p>Es werden keine externen Tools, Analysedienste, Social-Media-Plugins oder Content-Delivery-Networks eingebunden. Sämtliche Daten werden ausschließlich auf unserem eigenen Server verarbeitet.</p>
<p>Eine Weitergabe personenbezogener Daten an Dritte erfolgt nur, wenn dies zur Vertragserfüllung erforderlich ist (z.&nbsp;B. Weitergabe von Teilnehmerdaten an Veranstaltungsorte) oder eine gesetzliche Verpflichtung besteht.</p>
<h3>6. Deine Rechte</h3>
<p>Du hast jederzeit das Recht auf:</p>
<ul>
<li><strong>Auskunft</strong> über deine bei uns gespeicherten Daten (Art. 15 DSGVO)</li>
<li><strong>Berichtigung</strong> unrichtiger Daten (Art. 16 DSGVO)</li>
<li><strong>Löschung</strong> deiner Daten (Art. 17 DSGVO)</li>
<li><strong>Einschränkung der Verarbeitung</strong> (Art. 18 DSGVO)</li>
<li><strong>Datenübertragbarkeit</strong> (Art. 20 DSGVO)</li>
<li><strong>Widerspruch</strong> gegen die Verarbeitung (Art. 21 DSGVO)</li>
</ul>
<p>Zur Ausübung deiner Rechte genügt eine formlose Mitteilung an die oben genannte E-Mail-Adresse.</p>
<h3>7. Beschwerderecht</h3>
<p>Du hast das Recht, dich bei einer Datenschutz-Aufsichtsbehörde über die Verarbeitung deiner personenbezogenen Daten zu beschweren.</p>
<h3>8. Aktualität dieser Datenschutzerklärung</h3>
<p>Diese Datenschutzerklärung ist aktuell gültig.<br>
Stand: [Datum]</p>
+1
View File
@@ -7,6 +7,7 @@
<link rel="stylesheet" href="/css/modalBox.css" />
<link rel="stylesheet" href="/css/costunits.css" />
<link rel="stylesheet" href="/css/invoices.css" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content="{{ csrf_token() }}">
+3
View File
@@ -23,6 +23,9 @@ require_once __DIR__ . '/../app/Domains/Event/Routes/web.php';
require_once __DIR__ . '/../app/Domains/Event/Routes/api.php';
require_once __DIR__ . '/../app/Domains/Budget/Routes/web.php';
require_once __DIR__ . '/../app/Domains/Budget/Routes/api.php';
require_once __DIR__ . '/../app/Domains/Legal/Routes/web.php';
require_once __DIR__ . '/../app/Domains/Admin/Routes/web.php';
require_once __DIR__ . '/../app/Domains/Admin/Routes/api.php';
Route::get('/LKvDUqWl', function () {

Some files were not shown because too many files have changed in this diff Show More