27 Commits

Author SHA1 Message Date
th.guenther bc60461dac Improvements 2026-06-22 21:07:53 +02:00
th.guenther 6848fbd95f Merge pull request 'Development 4.4.2' (#8) from development-4.4.2 into main
Fixed Login for Superuser

Display Events in Widgets until their end date is reached
Prevent linking invoice to user account if foreign payment
Direct link to current event in mobile mode
New SEPA logic
Added legal data
Added admin layout
Admin function for tenant
Tenant management
User management
2026-06-21 23:31:26 +02:00
th.guenther 012ebb6538 Prepared for release 2026-06-21 22:13:23 +02:00
th.guenther c1ef1d71ad Code Styling 2026-06-21 22:12:05 +02:00
th.guenther aebb2f9aaa User management 2026-06-21 21:56:35 +02:00
th.guenther cfc7c7eee2 Tenant management 2026-06-21 20:46:16 +02:00
th.guenther 1b9384dad1 Admin function for tenant 2026-06-21 18:02:01 +02:00
th.guenther fed54514c8 Admin function for tenant 2026-06-21 18:00:20 +02:00
th.guenther 12f05ceb09 Added admin layout 2026-06-21 16:45:24 +02:00
th.guenther 5f56ef94a6 Added admin layout 2026-06-21 16:44:58 +02:00
th.guenther 79bb186234 Added admin layout 2026-06-21 16:44:01 +02:00
th.guenther a012c16425 Added admin layout 2026-06-21 16:42:37 +02:00
th.guenther a8205a4f96 Added legal data 2026-06-21 15:18:26 +02:00
th.guenther dbcebbb2c4 Merge pull request 'New SEPA logic' (#7) from new-sepa-procedure into development-4.4.2
Reviewed-on: #7
2026-06-21 01:29:57 +02:00
th.guenther 63c7b8dfb1 New SEPA logic 2026-06-21 01:28:28 +02:00
th.guenther e9aa66a860 Direct liunk to current event in mobile mode 2026-06-21 00:35:25 +02:00
th.guenther 51c4055c47 Prevent linking invoice to user account if foreign payment 2026-06-20 23:20:42 +02:00
th.guenther 2348663fd8 Display Events in Widgets until their end date is reached 2026-06-20 18:06:29 +02:00
th.guenther a83cec94ab Fixed Login for Superuser 2026-06-20 18:02:00 +02:00
th.guenther 710e27c344 Small Bugfixes 2026-05-26 20:29:01 +02:00
th.guenther 83bbd6f7d3 Merge pull request 'Displaying estimates' (#6) from dev-4.4.0 into main
Implemented Event Budgets
2026-05-26 18:13:35 +02:00
th.guenther 28ffbdb696 Implemented Event Budget 2026-05-26 18:12:42 +02:00
th.guenther 575fb27018 Displaying estimates 2026-05-26 11:07:59 +02:00
th.guenther fe3429cd4e Displaying estimates 2026-05-26 11:07:33 +02:00
th.guenther 551b592b3b Fix 2026-05-24 20:06:01 +02:00
th.guenther 6ed0a5b93a DB update 2026-05-23 21:42:21 +02:00
th.guenther 97fd7cd0da Merge pull request 'Small design improvements' (#5) from dev-4.3.1 into main
Small design Improvements
2026-05-23 21:40:54 +02:00
132 changed files with 5097 additions and 122 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>
@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Budget\Actions\CreateEstimate;
use App\Models\CostUnitEstimate;
class CreateEstimateAction {
private CreateEstimateResponse $response;
public function __construct(private CreateEstimateRequest $request) {
}
public function execute(): CreateEstimateResponse {
$this->response = new CreateEstimateResponse();
$amount = [];
switch ($this->request->amountType) {
case 'flat':
$amount['flat_amount'] = $this->request->amount;
break;
case 'per_person':
$amount['amount_by_user'] = $this->request->amount;
break;
}
if ($this->request->estimateId === 0) {
$estimate = CostUnitEstimate::create(array_merge([
'tenant' => app('tenant')->slug,
'cost_unit_id' => $this->request->costUnit->id,
'type' => $this->request->estimateType,
'description' => $this->request->description,
], $amount));
} else {
$estimate = CostUnitEstimate::find($this->request->estimateId);
$estimate->update(array_merge([
'tenant' => app('tenant')->slug,
'cost_unit_id' => $this->request->costUnit->id,
'type' => $this->request->estimateType,
'description' => $this->request->description,
], $amount));
}
if ($estimate !== null) {
$this->response->estimateId = $estimate->id;
$this->response->success = true;
}
return $this->response;
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Budget\Actions\CreateEstimate;
use App\Enumerations\InvoiceType;
use App\Models\CostUnit;
use App\ValueObjects\Amount;
class CreateEstimateRequest {
function __construct(
public string $amountType,
public string $description,
public Amount $amount,
public string $estimateType,
public CostUnit $costUnit,
public int $estimateId,
) {
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Domains\Budget\Actions\CreateEstimate;
class CreateEstimateResponse {
public bool $success;
public ?int $estimateId;
public function __construct() {
$this->success = false;
$this->estimateId = null;
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Budget\Actions\DeleteEstimate;
class DeleteEstimateAction {
public function __construct(private DeleteEstimateRequest $request) {
}
public function execute() : DeleteEstimateResponse {
$response = new DeleteEstimateResponse();
$this->request->estimate->delete();
$response->success = true;
return $response;
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Domains\Budget\Actions\DeleteEstimate;
use App\Models\CostUnitEstimate;
class DeleteEstimateRequest {
public function __construct(public CostUnitEstimate $estimate)
{
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Domains\Budget\Actions\DeleteEstimate;
class DeleteEstimateResponse {
public bool $success;
public function __construct()
{
$this->success = false;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Budget\Controllers;
use App\Domains\Budget\Actions\CreateEstimate\CreateEstimateAction;
use App\Domains\Budget\Actions\CreateEstimate\CreateEstimateRequest;
use App\Domains\Budget\Actions\DeleteEstimate\DeleteEstimateAction;
use App\Domains\Budget\Actions\DeleteEstimate\DeleteEstimateRequest;
use App\Domains\CostUnit\Actions\CreateCostUnit\CreateCostUnitRequest;
use App\Scopes\CommonController;
use App\ValueObjects\Amount;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DeleteController extends CommonController
{
public function __invoke(int $costUnitId, int $estimateId, Request $request) : JsonResponse {
$estimate = $this->estimates->getById($estimateId);
if ($estimate === null) {
return response()->json([
'status' => 'error',
'message' => 'Estimate not found'
], 404);
}
$deleteEstimateResponse =
new DeleteEstimateAction(request: new DeleteEstimateRequest($estimate)
)->execute();
if ($deleteEstimateResponse->success) {
return response()->json([
'status' => 'success',
'message' => 'Der Eintrag wurde erfolgreich gelöscht.'
]);
} else {
return response()->json([
'status' => 'error',
'message' => 'Beim Löschen des Eintrags ist ein Fehler aufgetreten.'
]);
}
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Budget\Controllers;
use App\Enumerations\InvoiceType;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ListController extends CommonController
{
public function __invoke(int $costUnitId, string $estimateType, Request $request) : JsonResponse {
$costUnit = $this->costUnits->getById($costUnitId);
$estimates = $this->estimates->getEstimates($costUnit, $estimateType);
return response()->json([
'status' => 'success',
'costUnitId' => $costUnitId,
'title' => InvoiceType::where('slug', $estimateType)->first()->name,
'estimateType' => $estimateType,
'estimates' => $estimates,
'totalAmountString' => $this->estimates->getTotalAmount($costUnit, $estimateType)->toString(),
]);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Budget\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class MainController extends CommonController
{
public function __invoke(int $costUnitId, Request $request) : Response
{
$inertiaProvider = new InertiaProvider('Budget/List', [
'cost_unit_id' => $costUnitId
]);
return $inertiaProvider->render();
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Domains\Budget\Controllers;
use App\Domains\Budget\Actions\CreateEstimate\CreateEstimateAction;
use App\Domains\Budget\Actions\CreateEstimate\CreateEstimateRequest;
use App\Domains\CostUnit\Actions\CreateCostUnit\CreateCostUnitRequest;
use App\Scopes\CommonController;
use App\ValueObjects\Amount;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SaveController extends CommonController
{
public function __invoke(int $costUnitId, Request $request) : JsonResponse {
$costUnit = $this->costUnits->getById($costUnitId);
if ($costUnit === null) {
return response()->json([
'status' => 'error',
'message' => 'Cost unit not found'
], 404);
}
$createCostUniResponse =
new CreateEstimateAction(request: new CreateEstimateRequest(
description: $request->input('description'),
amount: Amount::fromString($request->input('amount')),
amountType: $request->input('amount_type'),
estimateType: $request->input('estimateType'),
costUnit: $costUnit,
estimateId: $request->input('estimateId'),
))->execute();
if ($createCostUniResponse->success) {
return response()->json([
'status' => 'success',
'message' => 'Der Eintrag wurde erfolgreich angelegt.'
]);
} else {
return response()->json([
'status' => 'error',
'message' => 'Beim Anlegen des Eintrags ist ein Fehler aufgetreten.'
]);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
use App\Domains\Budget\Controllers\DeleteController;
use App\Domains\Budget\Controllers\SaveController;
use App\Domains\Budget\Controllers\ListController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::prefix('api/v1')->group(function () {
Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('budget')->group(function () {
Route::middleware(['auth'])->group(function () {
Route::prefix('/{costUnitId}')->group(function () {
Route::get('/list/{estimateType}', ListController::class);
Route::get('{estimateId}/delete', DeleteController::class);
Route::post('/save-estimate', SaveController::class);
});
});
});
});
});
+20
View File
@@ -0,0 +1,20 @@
<?php
use App\Domains\Budget\Controllers\MainController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('budget')->group(function () {
Route::middleware(['auth'])->group(function () {
Route::prefix('/{costUnitId}')->group(function() {
Route::get('/', MainController::class);
});
});
});
});
@@ -0,0 +1,100 @@
<script setup>
import Modal from "../../../Views/Components/Modal.vue";
import {reactive, ref} from "vue";
import AmountInput from "../../../Views/Components/AmountInput.vue";
import {toast} from "vue3-toastify";
import {useAjax} from "../../../../resources/js/components/ajaxHandler.js";
const { request } = useAjax()
const props = defineProps({
showAddEstimate: Boolean,
type: String,
title: String,
costUnitId: Number,
amount: Number,
amount_type: String,
estimateId: Number,
description: String,
})
console.log(props)
const form = reactive({
amount_type: props.amount_type,
amount: props.amount,
description: props.description,
})
async function save() {
const data = await request('/api/v1/budget/' + props.costUnitId + '/save-estimate', {
method: "POST",
body: {
estimateId: props.estimateId,
amount_type: form.amount_type,
amount: form.amount,
description: form.description,
estimateType: props.type,
}
});
if (data.status === 'success') {
toast.success(data.message);
} else {
toast.error(data.message);
}
emit('closeAddEstimate')
}
const emit = defineEmits(['closeAddEstimate'])
</script>
<template>
<Modal
:show="showAddEstimate"
@close="emit('closeAddEstimate')"
title="Ausgabenschätzung hinzufügen"
width="600px"
>
<table>
<tr>
<th>Kostenstelle</th>
<td>{{title}}</td>
</tr>
<tr>
<th>Verwendungszweck</th>
<td><input type="text" v-model="form.description" style="width: 250px;" /></td>
</tr>
<tr>
<th>Betrag</th>
<td><AmountInput v-model="form.amount" style="width: 100px;" /> Euro</td>
</tr>
<tr>
<th>Kostentyp</th>
<td style="vertical-align: top;">
<input type="radio" v-model="form.amount_type" value="flat"
id="amount_type_flat" />
<label for="amount_type_flat">Pauschal</label><br />
<input type="radio" v-model="form.amount_type" value="per_person" id="amount_type_per_person" />
<label for="amount_type_per_person">Pro Person</label><br />
</td>
</tr>
<tr>
<td colspan="2">
<input type="button" value="Speichern" class="button" @click="save" />
</td>
</tr>
</table>
</Modal>
</template>
<style scoped>
</style>
+93
View File
@@ -0,0 +1,93 @@
<script setup>
import {reactive, inject, onMounted} from 'vue';
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue';
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import TabbedPage from "../../../Views/Components/TabbedPage.vue";
import {toast} from "vue3-toastify";
import ListBudgets from "./ListBudgetTypes.vue";
const props = defineProps({
message: String,
data: {
type: [Array, Object],
default: () => []
},
cost_unit_id: {
type: Number,
default: 0
},
})
// Prüfen, ob ein ?id= Parameter in der URL übergeben wurde
const urlParams = new URLSearchParams(window.location.search)
const initialCostUnitId = props.cost_unit_id
const tabs = [
{
title: 'Verpflegung',
component: ListBudgets,
endpoint: "/api/v1/budget/" + props.cost_unit_id + "/list/catering",
deep_jump_id: initialCostUnitId,
},
{
title: 'Unterkunft',
component: ListBudgets,
endpoint: "/api/v1/budget/" + props.cost_unit_id + "/list/accommodation",
deep_jump_id: initialCostUnitId,
},
{
title: 'Programm',
component: ListBudgets,
endpoint: "/api/v1/budget/" + props.cost_unit_id + "/list/program",
deep_jump_id: initialCostUnitId,
},
{
title: 'Logistik',
component: ListBudgets,
endpoint: "/api/v1/budget/" + props.cost_unit_id + "/list/logistic",
deep_jump_id: initialCostUnitId,
},
{
title: 'Technik',
component: ListBudgets,
endpoint: "/api/v1/budget/" + props.cost_unit_id + "/list/technical",
deep_jump_id: initialCostUnitId,
},
{
title: 'Reisekosten',
component: ListBudgets,
endpoint: "/api/v1/budget/" + props.cost_unit_id + "/list/travelling",
deep_jump_id: initialCostUnitId,
},
{
title: 'Verwaltung',
component: ListBudgets,
endpoint: "/api/v1/budget/" + props.cost_unit_id + "/list/management",
deep_jump_id: initialCostUnitId,
},
{
title: 'Sonstiges',
component: ListBudgets,
endpoint: "/api/v1/budget/" + props.cost_unit_id + "/list/other",
deep_jump_id: initialCostUnitId,
},
]
onMounted(() => {
if (undefined !== props.message) {
toast.success(props.message)
}
})
</script>
<template>
<AppLayout title="Veranstaltungsbudget">
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;">
<tabbed-page :tabs="tabs" :initial-tab-id="initialCostUnitId" />
</shadowed-box>
</AppLayout>
</template>
@@ -0,0 +1,121 @@
<script setup>
import {createApp, ref} from 'vue'
import LoadingModal from "../../../Views/Components/LoadingModal.vue";
import { useAjax } from "../../../../resources/js/components/ajaxHandler.js";
import {toast} from "vue3-toastify";
import AddOrUpdateEstimate from "./AddOrUpdateEstimate.vue";
const props = defineProps({
data: {
type: [Array, Object],
default: () => []
},
})
const localData = ref(props.data)
const showAddEstimate = ref(false)
const estimateId = ref(null)
const description = ref(null)
const amount = ref(null)
const amountType = ref(null)
const { data, loading, error, request, download } = useAjax()
async function reload() {
const url = "/api/v1/budget/" + props.data.costUnitId + "/list/" + props.data.estimateType
try {
const response = await fetch(url, { method: 'GET' })
if (!response.ok) throw new Error('Fehler beim Laden')
const result = await response.json()
localData.value = result
} catch (err) {
console.error('Error fetching estimates:', err)
}
}
async function openAddEstimate() {
estimateId.value = 0
amount.value = 0.00
amountType.value = 'flat'
description.value = ''
showAddEstimate.value = true
}
async function openEditEstimate(localEstimateId, localDescription, localAmount, localAmountType, localEstimateType) {
estimateId.value = localEstimateId
description.value = localDescription
amount.value = localAmount
amountType.value = localAmountType
console.log(localEstimateId, localDescription, localAmount, localAmountType, localEstimateType)
console.log(estimateId.value, description.value, amount.value, amountType.value, localEstimateType)
showAddEstimate.value = true
}
async function deleteEstimate(currentEstimateId) {
const data = await request('/api/v1/budget/' + props.data.costUnitId + '/' + currentEstimateId + '/delete', {
method: "GET",
});
if (data.status === 'success') {
toast.success(data.message);
reload()
} else {
toast.error(data.message);
}
}
</script>
<template>
<div v-if="localData.estimates && localData.estimates.length > 0">
<h2>{{ props.data.title }}</h2>
<h3>Gesamtkosten: {{ localData.totalAmountString }}</h3>
<span v-for="estimate in localData.estimates">
<table style="width: 100%;">
<tr><th style="width: 200px;">
{{ estimate.title }}
</th>
<td>{{ estimate.singleAmountString }}</td>
</tr>
<tr>
<td></td>
<td style="padding-bottom: 30px">
<label class="link" style="font-size: 10pt; margin-right: 20px;" @click="openEditEstimate(estimate.id, estimate.title, estimate.amountValue, estimate.amountType, props.data.estimateType)">Bearbeiten</label>
<label class="link" style="font-size: 10pt; margin-right: 20px; color: #ff0000;" @click="deleteEstimate(estimate.id)">Löschen</label>
</td>
</tr>
</table>
</span>
</div>
<div v-else>
<strong style="width: 100%; text-align: center; display: block; margin-top: 20px;">
Noch keine geschätzten Ausgaben vorhanden
</strong>
</div>
<label class="link" @click="openAddEstimate()">
Hinzufügen
</label>
<LoadingModal :show="showLoading" />
<AddOrUpdateEstimate
:amount="amount"
:amount_type="amountType"
:description="description"
:estimateId="estimateId"
:costUnitId="props.data.costUnitId"
:title="props.data.title"
:type="props.data.estimateType"
:showAddEstimate="showAddEstimate"
v-if="showAddEstimate"
@closeAddEstimate="showAddEstimate = false; reload()" />
</template>
<style scoped>
.costunit-list {
width: 96% !important;
}
</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>
@@ -13,7 +13,6 @@
const invoice = ref(null)
const show_invoice = ref(false)
const localData = ref(props.data)
console.log(props.data)
async function openInvoiceDetails(invoiceId) {
const url = '/api/v1/invoice/details/' + invoiceId
@@ -112,8 +112,8 @@
<div class="event-flexbox" v-else>
<div class="event-flexbox-row top">
<div class="left"><ParticipationSummary v-if="dynamicProps.event" :event="dynamicProps.event" /></div>
<div class="right">
<div class="actions-left"><ParticipationSummary v-if="dynamicProps.event" :event="dynamicProps.event" /></div>
<div class="actions-right">
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/first-aid-list'">
<input type="button" value="Erste-Hilfe-Liste (PDF)" />
</a><br/>
@@ -186,6 +186,7 @@
<label style="font-size: 9pt;" class="link" @click="showCommonSettings">Allgemeine Einstellungen</label> &nbsp;
<label style="font-size: 9pt;" class="link" @click="showEventManagement">Veranstaltungsleitung</label> &nbsp;
<label style="font-size: 9pt;" class="link" @click="showParticipationFees">Teilnahmegebühren</label>
<a style="font-size: 9pt;" class="link" :href="'/budget/' + props.data.event.costUnit.id">Budget bearbeiten</a>
<a style="font-size: 9pt;" class="link" :href="'/cost-unit/' + props.data.event.costUnit.id">Ausgabenübersicht</a>
<a v-if="!dynamicProps.event.registrationAllowed && !dynamicProps.event.archived" style="color: #ff0000; font-size: 9pt;" class="link" @click="archiveEvent">Archivieren</a>
</div>
@@ -248,13 +249,13 @@
gap: 10px; /* Abstand zwischen den Spalten */
}
.event-flexbox-row.top .left {
.event-flexbox-row.top .actions-left {
flex: 0 0 calc(100% - 300px);
padding: 10px;
}
.event-flexbox-row.top .right {
flex: 0 0 250px;
.event-flexbox-row.top .actions-right {
flex: 0 0 200px;
padding: 10px;
}
@@ -263,7 +264,7 @@
padding: 10px;
}
.event-flexbox-row.top .right input[type="button"] {
.event-flexbox-row.top .actions-right input[type="button"] {
width: 100% !important;
margin-bottom: 10px;
}
@@ -64,7 +64,7 @@ const props = defineProps({
<th style="padding-bottom: 20px" colspan="2">Förderung</th>
<td style="padding-bottom: 20px" colspan="2">
{{ props.event.supportPerson.readable }}<br />
<label style="font-size: 9pt;">({{ props.event.supportPersonIndex }} / Tag p.P.)</label>
<label style="font-size: 9pt;">({{ props.event.supportPersonValue }} / Tag p.P.)</label>
</td>
</tr>
@@ -96,19 +96,31 @@ const props = defineProps({
</td>
</tr>
<tr>
<th style="padding-top: 20px; font-size: 12pt !important;" colspan="2">Budget</th>
<td v-if="props.event.totalBalance.estimated.value >= 0" style="color: #4caf50; font-weight: bold; padding-top: 20px; font-size: 12pt !important;">
{{ props.event.totalBalance.estimated.readable }}
</td>
<td v-else style="color: #f44336; font-weight: bold; padding-top: 20px; font-size: 12pt !important;">
{{props.event.totalBalance.estimated.readable}}
</td>
</tr>
</table>
</div>
<div class="right">
<h3>Ausgaben</h3>
<table class="event-payment-table" style="font-size: 10pt;">
<table class="event-payment-table" style="font-size: 10pt; width:100%">
<tr v-for="amount in props.event.costUnit.amounts">
<th>{{amount.name}}</th>
<td>{{amount.string}}</td>
<td>({{ amount.estimatedString }}) </td>
</tr>
<tr>
<th style="color:#f44336; border-width: 1px; border-top-style: solid; ">Gesamt</th>
<td style="color:#f44336; border-width: 1px; border-top-style: solid; font-weight: bold">{{props.event.costUnit.overAllAmount.text}}</td>
<td style="color:#f44336; border-width: 1px; border-top-style: solid; font-weight: bold; padding-right: 20px;">{{props.event.costUnit.overAllAmount.text}}</td>
<td style="color:#f44336; border-width: 1px; border-top-style: solid; font-weight: bold">({{props.event.costUnit.overAllEstimatedAmount.text}}))</td>
</tr>
</table>
</div>
@@ -121,7 +133,7 @@ const props = defineProps({
.participant-flexbox {
display: flex;
flex-direction: column;
gap: 10px;
gap: 20px;
width: 95%;
margin: 20px auto 0;
}
@@ -135,7 +147,7 @@ const props = defineProps({
.participant-flexbox-row.top .left,
.participant-flexbox-row.top .right {
padding: 10px;
padding: 20px;
min-width: 0;
}
@@ -54,7 +54,7 @@ function eatingHabit() {
<table class="form-table" style="margin-bottom: 20px;">
<tr>
<td>Dein Name:</td>
<td>{{props.formData.vorname}} {{props.formData.vorname}}</td>
<td>{{props.formData.vorname}} {{props.formData.nachname}}</td>
</tr>
<tr>
@@ -4,6 +4,7 @@ namespace App\Domains\Invoice\Actions\CreateInvoice;
use App\Enumerations\InvoiceStatus;
use App\Mail\InvoiceMails\InvoiceMailsNewInvoiceMail;
use App\Mail\InvoiceMails\InvoiceMailsSubmittedConfirmationMail;
use App\Mail\ParticipantParticipationMails\EventSignUpSuccessfullMail;
use App\Models\Invoice;
use Illuminate\Support\Facades\Mail;
@@ -30,7 +31,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,
@@ -50,6 +51,13 @@ class CreateInvoiceCommand {
if ($invoice !== null) {
$response->success = true;
$response->invoice = $invoice;
if ($invoice->contact_email !== null) {
Mail::to($invoice->contact_email)->send(new InvoiceMailsSubmittedConfirmationMail(
invoice: $invoice,
costUnit: $this->request->costUnit,
));
}
}
if ($this->request->costUnit->mail_on_new) {
@@ -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('/');
}
@@ -0,0 +1,50 @@
<?php
namespace App\Mail\InvoiceMails;
use App\Models\CostUnit;
use App\Models\Invoice;
use App\ValueObjects\Amount;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
class InvoiceMailsSubmittedConfirmationMail extends Mailable {
public function __construct(
private Invoice $invoice,
private CostUnit $costUnit,
)
{
//
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Deine Abrechnung wurde erfolgreich eingereicht',
);
}
public function content(): Content
{
return new Content(
view: 'emails.invoices.submitted_confirmation',
with: [
'contactName' => $this->invoice->contact_name,
'costUnitName' => $this->costUnit->name,
'invoiceAmount' => Amount::fromString($this->invoice->amount)->toString(),
'invoiceType' => $this->invoice->invoiceType()->name,
'invoiceNumber' => $this->invoice->invoice_number,
],
);
}
/**
* @return array<int, Attachment>
*/
public function attachments(): array
{
return [];
}
}

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