44 Commits

Author SHA1 Message Date
th.guenther e09987f5a8 Fixed permission management 2026-06-23 18:46:01 +02:00
th.guenther 5c514e9ff5 Merge pull request 'Improvements' (#9) from dev-4.5.1 into main
Reviewed-on: #9
2026-06-22 21:08:28 +02:00
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
th.guenther f5d7b21671 Small design improvements 2026-05-23 21:40:06 +02:00
th.guenther 444711b049 Fixes 2026-05-23 21:06:40 +02:00
th.guenther 0a7abb1389 Merge pull request 'New Release' (#4) from dev-4.3.0 into main
Responsive Design
Fixes Crons for Tenants
2026-05-23 19:28:37 +02:00
th.guenther 998a799c3a Fixed issue Crons running for tenants 2026-05-23 19:27:20 +02:00
th.guenther ef8f3ebe6c Small improvements 2026-05-23 19:26:59 +02:00
th.guenther 92976fbf27 New Responsive design 2026-05-23 18:55:41 +02:00
th.guenther e7e7f039b8 New Responsive design 2026-05-23 18:08:35 +02:00
th.guenther 0d436d8190 New Responsive design 2026-05-23 18:08:27 +02:00
th.guenther 3fdbaf0285 Release 2026-05-23 15:58:06 +02:00
th.guenther 8a049efe49 Release 2026-05-14 17:59:22 +02:00
th.guenther a62b2214c4 Merge pull request 'Comments for invoices' (#3) from dev-4.2.0 into main
Comments for invoices
Bugfix: Birthday can be stored
Bugfix: Cronjobs  run correctly
2026-05-14 17:00:56 +02:00
th.guenther f00e665a03 Release 2026-05-14 16:59:25 +02:00
th.guenther 454d83de2e Fixed cronjobs 2026-05-14 16:58:41 +02:00
th.guenther 30cc0b79c5 Bugfix: Birthday can be stored 2026-05-14 16:17:44 +02:00
th.guenther 775d9158a6 Comments for invoices 2026-05-14 16:16:36 +02:00
163 changed files with 7409 additions and 826 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\ChangeStatusCommand;
use App\Domains\Invoice\Actions\ChangeStatus\ChangeStatusRequest; 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\CreateInvoiceReceiptCommand;
use App\Domains\Invoice\Actions\CreateInvoiceReceipt\CreateInvoiceReceiptRequest; use App\Domains\Invoice\Actions\CreateInvoiceReceipt\CreateInvoiceReceiptRequest;
use App\Enumerations\InvoiceStatus; use App\Enumerations\InvoiceStatus;
use App\Models\SepaPaymentElement;
use App\Models\Tenant; use App\Models\Tenant;
use App\Providers\FileWriteProvider; use App\Providers\FileWriteProvider;
use App\Providers\InvoiceCsvFileProvider; use App\Providers\InvoiceCsvFileProvider;
use App\Providers\PainFileProvider;
use App\Providers\WebDavProvider; use App\Providers\WebDavProvider;
use App\Providers\ZipArchiveFileProvider; use App\Providers\ZipArchiveFileProvider;
use App\Scopes\CommonController; use App\Scopes\CommonController;
@@ -25,24 +24,19 @@ class ExportController extends CommonController {
$webdavProvider = new WebDavProvider(WebDavProvider::INVOICE_PREFIX . $this->tenant->url . '/' . $costUnit->name); $webdavProvider = new WebDavProvider(WebDavProvider::INVOICE_PREFIX . $this->tenant->url . '/' . $costUnit->name);
$painFileData = $this->painData($invoicesForExport); $this->createSepaPaymentElements($invoicesForExport, $costUnit);
$csvData = $this->csvData($invoicesForExport); $csvData = $this->csvData($invoicesForExport);
$filePrefix = Tenant::getTempDirectory(); $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 = new FileWriteProvider($filePrefix . 'abrechnungen-' . date('Y-m-d_H-i') . '.csv', $csvData);
$csvFileWriteProvider->writeToFile(); $csvFileWriteProvider->writeToFile();
if ($this->tenant->upload_exports) { if ($this->tenant->upload_exports) {
$webdavProvider->uploadFile($painFileWriteProvider->fileName);
$webdavProvider->uploadFile($csvFileWriteProvider->fileName); $webdavProvider->uploadFile($csvFileWriteProvider->fileName);
} }
$downloadZipArchiveFiles = [ $downloadZipArchiveFiles = [
$painFileWriteProvider->fileName,
$csvFileWriteProvider->fileName $csvFileWriteProvider->fileName
]; ];
@@ -72,7 +66,6 @@ class ExportController extends CommonController {
Storage::delete($file); Storage::delete($file);
} }
Storage::delete($painFileWriteProvider->fileName);
Storage::delete($csvFileWriteProvider->fileName); Storage::delete($csvFileWriteProvider->fileName);
return response()->download( return response()->download(
@@ -82,24 +75,28 @@ class ExportController extends CommonController {
); );
} }
Storage::delete($painFileWriteProvider->fileName);
Storage::delete($csvFileWriteProvider->fileName); Storage::delete($csvFileWriteProvider->fileName);
return response()->json([ 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 { private function createSepaPaymentElements(array $invoices, $costUnit): void
$invoicesForPainFile = []; {
foreach ($invoices as $invoice) { foreach ($invoices as $invoice) {
if ($invoice->contact_bank_owner !== null && $invoice->contact_bank_iban !== '' && !$invoice->donation) { 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 { public function csvData(array $invoices) : string {
@@ -107,4 +104,3 @@ class ExportController extends CommonController {
return $csvDateProvider->createCsvFileContent(); 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\DistanceAllowanceController;
use App\Domains\CostUnit\Controllers\EditController; use App\Domains\CostUnit\Controllers\EditController;
use App\Domains\CostUnit\Controllers\ExportController; use App\Domains\CostUnit\Controllers\ExportController;
use App\Domains\CostUnit\Controllers\GlobalSepaExportController;
use App\Domains\CostUnit\Controllers\ListController; use App\Domains\CostUnit\Controllers\ListController;
use App\Domains\CostUnit\Controllers\OpenController; use App\Domains\CostUnit\Controllers\OpenController;
use App\Domains\CostUnit\Controllers\TreasurersEditController; 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::prefix('open')->group(function () {
Route::get('/current-events', [ListController::class, 'listCurrentEvents']); Route::get('/current-events', [ListController::class, 'listCurrentEvents']);
Route::get('/current-running-jobs', [ListController::class, 'listCurrentRunningJobs']); 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 {toast} from "vue3-toastify";
import ListCostUnits from "./Partials/ListCostUnits.vue"; import ListCostUnits from "./Partials/ListCostUnits.vue";
import GlobalActions from "./Partials/GlobalActions.vue";
const props = defineProps({ const props = defineProps({
message: String, message: String,
@@ -63,6 +64,13 @@ const tabs = [
deep_jump_id: initialCostUnitId, deep_jump_id: initialCostUnitId,
deep_jump_id_sub: initialInvoiceId, 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(() => { 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 invoice = ref(null)
const show_invoice = ref(false) const show_invoice = ref(false)
const localData = ref(props.data) const localData = ref(props.data)
console.log(props.data)
async function openInvoiceDetails(invoiceId) { async function openInvoiceDetails(invoiceId) {
const url = '/api/v1/invoice/details/' + invoiceId const url = '/api/v1/invoice/details/' + invoiceId
+302 -97
View File
@@ -56,132 +56,337 @@ const submit = async () => {
<template> <template>
<AppLayout title='Persönliche Daten'> <AppLayout title='Persönliche Daten'>
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;"> <shadowed-box class="personal-data-box">
<div class="max-w-2xl mx-auto p-6">
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<table class="form-table" style="width: 90%; margin: 10px;"> <!-- Sektion: Stammdaten -->
<fieldset class="pd-fieldset">
<legend>Stammdaten</legend>
<!-- Nicht veränderbare Felder --> <div class="pd-grid">
<tr> <div class="pd-field pd-field--readonly">
<td style="width: 200px; padding: 5px;">Vorname:</td> <label>Vorname</label>
<td><span class="text-gray-700">{{ personalData.firstname }}</span></td> <div class="pd-readonly">{{ personalData.firstname }}</div>
</tr> </div>
<tr>
<td style="width: 200px; padding: 5px;">Nachname:</td>
<td><span class="text-gray-700">{{ personalData.lastname }}</span></td>
</tr>
<div class="pd-field pd-field--readonly">
<label>Nachname</label>
<div class="pd-readonly">{{ personalData.lastname }}</div>
</div>
<!-- Veränderbare Felder --> <div class="pd-field">
<tr> <label for="nickname">Pfadiname</label>
<td>Pfadiname:</td> <input id="nickname" type="text" v-model="form.nickname" />
<td><input type="text" v-model="form.nickname" /></td> </div>
</tr>
<tr> <div class="pd-field">
<td>E-Mail:</td> <label for="birthday">Geburtsdatum</label>
<td><input type="email" v-model="form.email" /></td> <input id="birthday" type="date" v-model="form.birthday" />
</tr> </div>
<tr> </div>
<td>Telefon:</td> </fieldset>
<td><input type="text" v-model="form.phone" /></td>
</tr> <!-- Sektion: Kontakt -->
<tr> <fieldset class="pd-fieldset">
<td>Straße / Hausnummer:</td> <legend>Kontakt</legend>
<td><input type="text" v-model="form.address1" /></td>
</tr> <div class="pd-grid">
<tr> <div class="pd-field">
<td>Adresszusatz:</td> <label for="email">E-Mail</label>
<td><input type="text" v-model="form.address2" /></td> <input id="email" type="email" v-model="form.email" />
</tr> </div>
<tr>
<td>PLZ:</td> <div class="pd-field">
<td><input type="text" v-model="form.postcode" /></td> <label for="phone">Telefon</label>
</tr> <input id="phone" type="text" v-model="form.phone" />
<tr> </div>
<td>Ort:</td>
<td><input type="text" v-model="form.city" /></td> <div class="pd-field pd-field--full">
</tr> <label for="address1">Straße / Hausnummer</label>
<tr> <input id="address1" type="text" v-model="form.address1" />
<td style="width: 200px; padding: 5px;">Geburtsdatum:</td> </div>
<td><input type="date" v-model="form.birthday" /></td>
</tr> <div class="pd-field pd-field--full">
<tr> <label for="address2">Adresszusatz</label>
<td>Medikamente:</td> <input id="address2" type="text" v-model="form.address2" />
<td><input type="text" v-model="form.medications" /></td> </div>
</tr>
<tr> <div class="pd-field pd-field--narrow">
<td>Allergien:</td> <label for="postcode">PLZ</label>
<td><input type="text" v-model="form.allergies" /></td> <input id="postcode" type="text" v-model="form.postcode" maxlength="5" />
</tr> </div>
<tr>
<td>Unverträglichkeiten:</td> <div class="pd-field pd-field--wide">
<td><input type="text" v-model="form.intolerances" /></td> <label for="city">Ort</label>
</tr> <input id="city" type="text" v-model="form.city" />
<tr> </div>
<td>Letzte Tetanus-Impfung:</td> </div>
<td><input type="date" v-model="form.tetanusVaccination" /></td> </fieldset>
</tr>
<tr> <!-- Sektion: Gesundheit -->
<td>Ernährungsgewohnheiten:</td> <fieldset class="pd-fieldset">
<td> <legend>Gesundheit</legend>
<select v-model="form.eatingHabits">
<div class="pd-grid">
<div class="pd-field pd-field--full">
<label for="medications">Medikamente</label>
<input id="medications" type="text" v-model="form.medications" />
</div>
<div class="pd-field pd-field--full">
<label for="allergies">Allergien</label>
<input id="allergies" type="text" v-model="form.allergies" />
</div>
<div class="pd-field pd-field--full">
<label for="intolerances">Unverträglichkeiten</label>
<input id="intolerances" type="text" v-model="form.intolerances" />
</div>
<div class="pd-field">
<label for="tetanus">Letzte Tetanus-Impfung</label>
<input id="tetanus" type="date" v-model="form.tetanusVaccination" />
</div>
<div class="pd-field">
<label for="eating">Ernährungsgewohnheiten</label>
<select id="eating" v-model="form.eatingHabits">
<option value="EATING_HABIT_VEGAN">Vegan</option> <option value="EATING_HABIT_VEGAN">Vegan</option>
<option value="EATING_HABIT_VEGETARIAN">Vegetarisch</option> <option value="EATING_HABIT_VEGETARIAN">Vegetarisch</option>
<option value="EATING_HABIT_OMNIVOR">Omnivor</option> <option value="EATING_HABIT_OMNIVOR">Omnivor</option>
</select> </select>
</td> </div>
</tr> </div>
<tr> </fieldset>
<td>Badeerlaubnis:</td>
<td> <!-- Sektion: Erlaubnisse -->
<select v-model="form.swimmingPermission"> <fieldset class="pd-fieldset">
<legend>Erlaubnisse</legend>
<div class="pd-grid">
<div class="pd-field pd-field--full">
<label for="swimming">Badeerlaubnis</label>
<select id="swimming" v-model="form.swimmingPermission">
<option value="SWIMMING_PERMISSION_ALLOWED">Erteilt, kann schwimmen</option> <option value="SWIMMING_PERMISSION_ALLOWED">Erteilt, kann schwimmen</option>
<option value="SWIMMING_PERMISSION_LIMITED">Erteilt, kann nicht schwimmen</option> <option value="SWIMMING_PERMISSION_LIMITED">Erteilt, kann nicht schwimmen</option>
<option value="SWIMMING_PERMISSION_DENIED">Nicht erteilt</option> <option value="SWIMMING_PERMISSION_DENIED">Nicht erteilt</option>
</select> </select>
</td> </div>
</tr>
<tr> <div class="pd-field pd-field--full">
<td>Erste-Hilfe-Erlaubnis:</td> <label for="firstaid">Erste-Hilfe-Erlaubnis</label>
<td> <select id="firstaid" v-model="form.firstAidPermission">
<select v-model="form.firstAidPermission">
<option value="FIRST_AID_PERMISSION_ALLOWED">Erweiterte Erste Hilfe erlaubt</option> <option value="FIRST_AID_PERMISSION_ALLOWED">Erweiterte Erste Hilfe erlaubt</option>
<option value="FIRST_AID_PERMISSION_DENIED">Erweiterte Erste Hilfe verweigert</option> <option value="FIRST_AID_PERMISSION_DENIED">Erweiterte Erste Hilfe verweigert</option>
</select> </select>
</td> </div>
</tr> </div>
<tr> </fieldset>
<td>Kontoinhaber*in:</td>
<td><input type="text" v-model="form.bankAccountOwner" /></td>
</tr>
<tr>
<td>IBAN:</td>
<td><IbanInput v-model="form.bankAccountIban" /></td>
</tr>
<tr> <!-- Sektion: Bankverbindung -->
<td colspan="2" class="btn-row" style="padding-top: 20px;"> <fieldset class="pd-fieldset">
<button type="submit" class="button" :disabled="saving"> <legend>Bankverbindung</legend>
<div class="pd-grid">
<div class="pd-field pd-field--full">
<label for="owner">Kontoinhaber*in</label>
<input id="owner" type="text" v-model="form.bankAccountOwner" />
</div>
<div class="pd-field pd-field--full">
<label for="iban">IBAN</label>
<IbanInput id="iban" v-model="form.bankAccountIban" />
</div>
</div>
</fieldset>
<div class="pd-actions">
<button type="submit" class="button pd-submit" :disabled="saving">
{{ saving ? 'Wird gespeichert…' : 'Speichern' }} {{ saving ? 'Wird gespeichert…' : 'Speichern' }}
</button> </button>
</td>
</tr>
</table>
</form>
</div> </div>
</form>
</shadowed-box> </shadowed-box>
</AppLayout> </AppLayout>
</template> </template>
<style scoped> <style scoped>
textarea { .personal-data-box {
width: 95%;
margin: 20px auto;
padding: 20px;
overflow-x: hidden;
}
.pd-fieldset {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px 20px 20px;
margin-bottom: 18px;
background-color: #ffffff;
box-shadow: 0 0 8px #efefef;
}
.pd-fieldset legend {
font-weight: 700;
font-size: 1rem;
padding: 4px 12px;
background-color: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #1d4899;
}
/* Grid: 2-spaltig auf Desktop */
.pd-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 20px;
}
.pd-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.pd-field--full {
grid-column: 1 / -1;
}
.pd-field--narrow {
grid-column: span 1;
max-width: 180px;
}
.pd-field--wide {
grid-column: span 1;
}
.pd-field label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
}
.pd-field input,
.pd-field select {
width: 100%; width: 100%;
padding: 6px 10px; padding: 8px 10px;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 6px; border-radius: 6px;
font-size: 0.95rem; font-size: 0.95rem;
box-sizing: border-box; box-sizing: border-box;
resize: vertical; background-color: #ffffff;
}
.pd-field input:focus,
.pd-field select:focus {
outline: none;
border-color: #1d4899;
}
.pd-readonly {
padding: 8px 10px;
border: 1px dashed #e5e7eb;
border-radius: 6px;
background-color: #f9fafb;
color: #6b7280;
font-size: 0.95rem;
}
.pd-actions {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
.pd-submit {
padding: 10px 28px;
font-weight: 600;
cursor: pointer;
border: 1px solid #809dd5;
background-color: #ffffff;
}
.pd-submit:hover:not(:disabled) {
background-color: #1d4899;
color: #ffffff;
}
.pd-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ─── Tablet (6401023px) ─── */
@media (max-width: 1023px) {
.personal-data-box {
width: 100%;
margin: 10px auto;
padding: 14px;
}
.pd-fieldset {
padding: 12px 14px 16px;
}
.pd-grid {
gap: 12px 16px;
}
}
/* ─── Smartphone (< 640px) ─── */
@media (max-width: 639px) {
.personal-data-box {
width: 100%;
margin: 0;
padding: 10px;
border-radius: 0;
}
.pd-fieldset {
padding: 10px 12px 14px;
margin-bottom: 14px;
}
.pd-fieldset legend {
font-size: 0.9rem;
padding: 3px 10px;
}
/* Grid: 1-spaltig auf Smartphone */
.pd-grid {
grid-template-columns: 1fr;
gap: 10px;
}
/* Auch "narrow" und "wide" werden volle Breite */
.pd-field--narrow,
.pd-field--wide,
.pd-field--full {
grid-column: 1 / -1;
max-width: 100%;
}
.pd-field label {
font-size: 0.82rem;
}
.pd-field input,
.pd-field select {
padding: 10px;
font-size: 1rem; /* iOS Zoom-Prevention bei >=16px */
}
.pd-actions {
justify-content: stretch;
}
.pd-submit {
width: 100%;
padding: 14px;
}
} }
</style> </style>
@@ -47,7 +47,5 @@ class ParticipantPaymentController extends CommonController
'class' => $amountLeft->getAmount() != 0 ? 'not-paid' : 'paid', 'class' => $amountLeft->getAmount() != 0 ? 'not-paid' : 'paid',
] ]
]); ]);
dd($participant);
} }
} }
@@ -8,67 +8,160 @@ const props = defineProps({
</script> </script>
<template> <template>
<div style="width: 95%; margin: 20px auto;"> <div class="available-events-wrapper">
<div v-if="props.events.length === 0" style="text-align: center; color: #6b7280; padding: 40px 0;"> <div v-if="props.events.length === 0" class="available-events-empty">
Aktuell sind keine Veranstaltungen verfügbar. Aktuell sind keine Veranstaltungen verfügbar.
</div> </div>
<shadowed-box <shadowed-box
v-for="event in props.events" v-for="event in props.events"
:key="event.id" :key="event.id"
style="padding: 24px; margin-bottom: 20px;" class="available-event-card"
> >
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 12px;"> <div class="available-event-header">
<div> <div>
<h2 style="margin: 0 0 4px 0; font-size: 1.25rem;">{{ event.name }}</h2> <h2 class="available-event-title">{{ event.name }}</h2>
<span style="color: #6b7280; font-size: 0.9rem;">{{ event.postalCode }} {{ event.location }}</span> <span class="available-event-location">{{ event.postalCode }} {{ event.location }}</span>
</div> </div>
<span <span
v-if="event.registrationAllowed" v-if="event.registrationAllowed"
style="background: #d1fae5; color: #065f46; padding: 4px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; white-space: nowrap;" class="available-event-badge available-event-badge--open"
> >
Anmeldung offen Anmeldung offen
</span> </span>
<span <span
v-else v-else
style="background: #fee2e2; color: #991b1b; padding: 4px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; white-space: nowrap;" class="available-event-badge available-event-badge--closed"
> >
Anmeldung geschlossen Anmeldung geschlossen
</span> </span>
</div> </div>
<hr style="margin: 16px 0; border: none; border-top: 1px solid #e5e7eb;" /> <hr class="available-event-divider" />
<table style="width: 100%; border-collapse: collapse;"> <table class="available-event-table">
<tr> <tr>
<th style="text-align: left; padding: 6px 12px 6px 0; width: 220px; color: #374151; font-weight: 600;">Zeitraum</th> <th>Zeitraum</th>
<td style="padding: 6px 0; color: #111827;">{{ event.eventBegin }} {{ event.eventEnd }} ({{ event.duration }} Tage)</td> <td>{{ event.eventBegin }} {{ event.eventEnd }} ({{ event.duration }} Tage)</td>
</tr> </tr>
<tr> <tr>
<th style="text-align: left; padding: 6px 12px 6px 0; width: 220px; color: #374151; font-weight: 600;">Veranstaltungsort</th> <th>Veranstaltungsort</th>
<td style="padding: 6px 0; color: #111827;">{{ event.postalCode }} {{ event.location }}</td> <td>{{ event.postalCode }} {{ event.location }}</td>
</tr> </tr>
<tr> <tr>
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Frühbuchen bis</th> <th>Frühbuchen bis</th>
<td style="padding: 6px 0; color: #111827;">{{ event.earlyBirdEnd.formatted }}</td> <td>{{ event.earlyBirdEnd.formatted }}</td>
</tr> </tr>
<tr> <tr>
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Anmeldeschluss</th> <th>Anmeldeschluss</th>
<td style="padding: 6px 0; color: #111827;">{{ event.registrationFinalEnd.formatted }}</td> <td>{{ event.registrationFinalEnd.formatted }}</td>
</tr> </tr>
<tr v-if="event.email"> <tr v-if="event.email">
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Kontakt</th> <th>Kontakt</th>
<td style="padding: 6px 0;"> <td>
<a :href="'mailto:' + event.email" style="color: #2563eb;">{{ event.email }}</a> <a :href="'mailto:' + event.email" class="available-event-mail">{{ event.email }}</a>
</td> </td>
</tr> </tr>
</table> </table>
<div style="margin-top: 20px; display: flex; justify-content: flex-end;"> <div class="available-event-actions">
<a <a
:href="'/event/' + event.identifier + '/signup'" :href="'/event/' + event.identifier + '/signup'"
style=" class="available-event-button"
:style="{ opacity: event.registrationAllowed ? '1' : '0.5', pointerEvents: event.registrationAllowed ? 'auto' : 'none' }"
>
Zur Anmeldung
</a>
</div>
</shadowed-box>
</div>
</template>
<style scoped>
.available-events-wrapper {
width: 95%;
margin: 20px auto;
}
.available-events-empty {
text-align: center;
color: #6b7280;
padding: 40px 0;
}
.available-event-card {
padding: 24px;
margin-bottom: 20px;
}
.available-event-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
}
.available-event-title {
margin: 0 0 4px 0;
font-size: 1.25rem;
}
.available-event-location {
color: #6b7280;
font-size: 0.9rem;
}
.available-event-badge {
padding: 4px 12px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
}
.available-event-badge--open { background: #d1fae5; color: #065f46; }
.available-event-badge--closed { background: #fee2e2; color: #991b1b; }
.available-event-divider {
margin: 16px 0;
border: none;
border-top: 1px solid #e5e7eb;
}
.available-event-table {
width: 100%;
border-collapse: collapse;
}
.available-event-table th {
text-align: left;
padding: 6px 12px 6px 0;
width: 220px;
color: #374151;
font-weight: 600;
vertical-align: top;
}
.available-event-table td {
padding: 6px 0;
color: #111827;
word-break: break-word;
}
.available-event-mail {
color: #2563eb;
word-break: break-all;
}
.available-event-actions {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.available-event-button {
display: inline-block; display: inline-block;
padding: 10px 24px; padding: 10px 24px;
background-color: #2563eb; background-color: #2563eb;
@@ -77,24 +170,66 @@ const props = defineProps({
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
opacity: 1;
transition: background-color 0.2s; transition: background-color 0.2s;
" }
:style="{ opacity: event.registrationAllowed ? '1' : '0.5', pointerEvents: event.registrationAllowed ? 'auto' : 'none' }"
>
Zur Anmeldung
</a>
</div>
</shadowed-box>
</div><div style="width: 95%; margin: 20px auto;"> /* ─── Tablet ─── */
@media (max-width: 1023px) {
.available-events-wrapper {
width: 100%;
padding: 0 10px;
}
<div v-if="props.events.length === 0" style="text-align: center; color: #6b7280; padding: 40px 0;"> .available-event-table th {
Aktuell sind keine Veranstaltungen verfügbar. width: 160px;
</div> }
</div> }
</template>
<style scoped> /* ─── Smartphone ─── */
@media (max-width: 639px) {
.available-event-card {
padding: 16px;
margin-bottom: 14px;
}
.available-event-title {
font-size: 1.05rem;
}
/* Tabelle vertikal stapeln: Label über Wert */
.available-event-table,
.available-event-table tbody,
.available-event-table tr,
.available-event-table th,
.available-event-table td {
display: block;
width: 100% !important;
}
.available-event-table tr {
margin-bottom: 8px;
}
.available-event-table th {
padding: 4px 0 2px 0;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6b7280;
}
.available-event-table td {
padding: 0 0 4px 0;
}
.available-event-actions {
justify-content: stretch;
}
.available-event-button {
width: 100%;
text-align: center;
padding: 12px 24px;
}
}
</style> </style>
+54 -8
View File
@@ -79,16 +79,41 @@
</script> </script>
<template> <template>
<div class="smartphone-actions">
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/first-aid-list'">
<input type="button" value="Erste-Hilfe-Liste (PDF)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/csv/participant-list'">
<input type="button" value="Teili-Liste (CSV)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/kitchen-list'">
<input type="button" value="Küchenübersicht (PDF)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/amount-list'">
<input type="button" value="Beitragsliste (PDF)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'">
<input type="button" value="Getränkeliste (PDF)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
<input type="button" value="Foto-Erlaubnis (PDF)" />
</a><br/>
</div>
<ParticipationFees v-if="displayData === 'participationFees'" :event="dynamicProps.event" @close="showMain" /> <ParticipationFees v-if="displayData === 'participationFees'" :event="dynamicProps.event" @close="showMain" />
<CommonSettings v-else-if="displayData === 'commonSettings'" :event="dynamicProps.event" @close="showMain" /> <CommonSettings v-else-if="displayData === 'commonSettings'" :event="dynamicProps.event" @close="showMain" />
<EventManagement v-else-if="displayData === 'eventManagement'" :event="dynamicProps.event" @close="showMain" /> <EventManagement v-else-if="displayData === 'eventManagement'" :event="dynamicProps.event" @close="showMain" />
<div class="event-flexbox" v-else> <div class="event-flexbox" v-else>
<div class="event-flexbox-row top"> <div class="event-flexbox-row top">
<div class="left"><ParticipationSummary v-if="dynamicProps.event" :event="dynamicProps.event" /></div> <div class="actions-left"><ParticipationSummary v-if="dynamicProps.event" :event="dynamicProps.event" /></div>
<div class="right"> <div class="actions-right">
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/first-aid-list'"> <a :href="'/event/details/' + props.data.event.identifier + '/pdf/first-aid-list'">
<input type="button" value="Erste-Hilfe-Liste (PDF)" /> <input type="button" value="Erste-Hilfe-Liste (PDF)" />
</a><br/> </a><br/>
@@ -118,6 +143,8 @@
</div> </div>
</div> </div>
<div> <div>
<table> <table>
<tr> <tr>
@@ -159,6 +186,7 @@
<label style="font-size: 9pt;" class="link" @click="showCommonSettings">Allgemeine Einstellungen</label> &nbsp; <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="showEventManagement">Veranstaltungsleitung</label> &nbsp;
<label style="font-size: 9pt;" class="link" @click="showParticipationFees">Teilnahmegebühren</label> <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 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> <a v-if="!dynamicProps.event.registrationAllowed && !dynamicProps.event.archived" style="color: #ff0000; font-size: 9pt;" class="link" @click="archiveEvent">Archivieren</a>
</div> </div>
@@ -221,13 +249,13 @@
gap: 10px; /* Abstand zwischen den Spalten */ gap: 10px; /* Abstand zwischen den Spalten */
} }
.event-flexbox-row.top .left { .event-flexbox-row.top .actions-left {
flex: 0 0 calc(100% - 300px); flex: 0 0 calc(100% - 300px);
padding: 10px; padding: 10px;
} }
.event-flexbox-row.top .right { .event-flexbox-row.top .actions-right {
flex: 0 0 250px; flex: 0 0 200px;
padding: 10px; padding: 10px;
} }
@@ -236,9 +264,27 @@
padding: 10px; padding: 10px;
} }
.event-flexbox-row.top .right input[type="button"] { .event-flexbox-row.top .actions-right input[type="button"] {
width: 100% !important; width: 100% !important;
margin-bottom: 10px; margin-bottom: 10px;
} }
.smartphone-actions {
display: none;
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
.smartphone-actions {
display: block;
width: 100%;
text-align: center;
}
.event-flexbox {
display: none;
}
}
</style> </style>
@@ -1,6 +1,7 @@
<script setup> <script setup>
import {onMounted, reactive, watch} from "vue"; import {onMounted, reactive, watch} from "vue";
import AmountInput from "../../../../Views/Components/AmountInput.vue"; import AmountInput from "../../../../Views/Components/AmountInput.vue";
import DialableTelephoneNumber from "../../../../Views/Components/DialableTelephoneNumber.vue";
const staticProps = defineProps({ const staticProps = defineProps({
editMode: Boolean, editMode: Boolean,
@@ -205,7 +206,7 @@ function saveParticipant() {
<tr> <tr>
<th>Telefon</th> <th>Telefon</th>
<td> <td>
<span v-if="!staticProps.editMode">{{ props.participant.phone_1 }}</span> <DialableTelephoneNumber v-if="!staticProps.editMode" :number="props.participant.phone_1"</DialableTelephoneNumber>
<input v-else v-model="form.phone_1" type="text" /> <input v-else v-model="form.phone_1" type="text" />
</td> </td>
</tr> </tr>
@@ -229,7 +230,7 @@ function saveParticipant() {
<tr> <tr>
<th>Ansprechperson Telefon</th> <th>Ansprechperson Telefon</th>
<td> <td>
<span v-if="!staticProps.editMode">{{ props.participant.phone_2 }}</span> <DialableTelephoneNumber v-if="!staticProps.editMode" :number="props.participant.phone_2"</DialableTelephoneNumber>
<input v-else v-model="form.phone_2" type="text" /> <input v-else v-model="form.phone_2" type="text" />
</td> </td>
</tr> </tr>
@@ -8,6 +8,7 @@ import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
import {format, getDay, getMonth, getYear} from "date-fns"; import {format, getDay, getMonth, getYear} from "date-fns";
import AmountInput from "../../../../Views/Components/AmountInput.vue"; import AmountInput from "../../../../Views/Components/AmountInput.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue"; import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
import DialableTelephoneNumber from "../../../../Views/Components/DialableTelephoneNumber.vue";
const props = defineProps({ const props = defineProps({
data: { data: {
@@ -301,7 +302,7 @@ function mailToGroup(groupKey) {
<h2>{{ event?.name ?? "Veranstaltung" }}</h2> <h2>{{ event?.name ?? "Veranstaltung" }}</h2>
<div :key="groupKey"> <div :key="groupKey">
<div> <div>
<table style="width: 95%; margin: 20px auto; border-collapse: collapse;" v-for="[groupKey, participants] in getGroupEntries"> <table class="participants-table" v-for="[groupKey, participants] in getGroupEntries">
<thead> <thead>
<tr> <tr>
<th colspan="4" style="background: linear-gradient(to bottom, #fff, #f6f7f7); font-weight: bold"> <th colspan="4" style="background: linear-gradient(to bottom, #fff, #f6f7f7); font-weight: bold">
@@ -309,10 +310,10 @@ function mailToGroup(groupKey) {
</th> </th>
</tr> </tr>
<tr style="background: linear-gradient(to bottom, #fff, #f6f7f7);"> <tr style="background: linear-gradient(to bottom, #fff, #f6f7f7);">
<th>Name</th> <th class="pl-name">Name</th>
<th>Beitrag</th> <th class="pl-amount">Beitrag</th>
<th>E-Mail-Adresse</th> <th class="pl-email">E-Mail-Adresse</th>
<th>Telefon</th> <th class="pl-phone">Telefon</th>
</tr> </tr>
</thead> </thead>
@@ -323,7 +324,7 @@ function mailToGroup(groupKey) {
> >
<tr :class="getRowClass(participant)" :id="'participant-' + participant.identifier + '-common'"> <tr :class="getRowClass(participant)" :id="'participant-' + participant.identifier + '-common'">
<td :id="'participant-' + participant.identifier +'-name'" <td :id="'participant-' + participant.identifier +'-name'"
style="width: 300px;" class="pl-name"
:class="participant.efz_status === 'checked_invalid' ? 'efz-invalid' : :class="participant.efz_status === 'checked_invalid' ? 'efz-invalid' :
participant.efz_status === 'not_checked' ? 'efz-not-checked' : ''"> participant.efz_status === 'not_checked' ? 'efz-not-checked' : ''">
<div :id="'participant-' + participant.identifier +'-fullname'" v-html="participant.fullname" /><br /> <div :id="'participant-' + participant.identifier +'-fullname'" v-html="participant.fullname" /><br />
@@ -335,7 +336,7 @@ function mailToGroup(groupKey) {
<span :id="'participant-' + participant.identifier +'-coc-action'" v-if="participant.efz_status !== 'checked_valid' && participant.efz_status !== 'not_required'" class="link" style="color: #3cb62e; font-size: 11pt;" @click="markCocExisting(participant)">Vorhanden?</span> <span :id="'participant-' + participant.identifier +'-coc-action'" v-if="participant.efz_status !== 'checked_valid' && participant.efz_status !== 'not_required'" class="link" style="color: #3cb62e; font-size: 11pt;" @click="markCocExisting(participant)">Vorhanden?</span>
</td> </td>
<td :id="'participant-' + participant.identifier +'-payment'" :class="participant.amount_left_value != 0 && !participant.unregistered ? 'not-paid' : ''" style="width: 275px; '"> <td class="pl-amount" :id="'participant-' + participant.identifier +'-payment'" :class="participant.amount_left_value != 0 && !participant.unregistered ? 'not-paid' : ''">
Gezahlt: <label :id="'participant-' + participant.identifier + '-paid'">{{ participant?.amountPaid.readable }}</label> /<br /> Gezahlt: <label :id="'participant-' + participant.identifier + '-paid'">{{ participant?.amountPaid.readable }}</label> /<br />
Gesamt: <label :id="'participant-' + participant.identifier + '-expected'">{{ participant?.amountExpected.readable }}</label> Gesamt: <label :id="'participant-' + participant.identifier + '-expected'">{{ participant?.amountExpected.readable }}</label>
<br /><br /> <br /><br />
@@ -345,21 +346,21 @@ function mailToGroup(groupKey) {
</span> </span>
</td> </td>
<td> <td class="pl-email">
<label :id="'participant-' + participant.identifier +'-email_1'" class="block-label">{{ participant?.email_1 ?? "-" }}</label> <label :id="'participant-' + participant.identifier +'-email_1'" class="block-label">{{ participant?.email_1 ?? "-" }}</label>
<label :id="'participant-' + participant.identifier +'-email_2'" class="block-label">{{ participant.email_2 }}</label> <label :id="'participant-' + participant.identifier +'-email_2'" class="block-label">{{ participant.email_2 }}</label>
</td> </td>
<td> <td class="pl-phone">
<label :id="'participant-' + participant.identifier +'-phone_1'" class="block-label">{{ participant?.phone_1 }}</label> <label :id="'participant-' + participant.identifier +'-phone_1'" class="block-label">P: <DialableTelephoneNumber :number="participant?.phone_1" /></label>
<label :id="'participant-' + participant.identifier +'-phone_2'" class="block-label">{{ participant?.phone_2 }}</label> <label :id="'participant-' + participant.identifier +'-phone_2'" class="block-label">K: <DialableTelephoneNumber :number="participant?.phone_2" /></label>
</td> </td>
</tr> </tr>
<tr class="participant-meta-row" :id="'participant-' + participant.identifier + '-meta'"> <tr class="participant-meta-row" :id="'participant-' + participant.identifier + '-meta'">
<td colspan="5" style="height: 15px !important; font-size: 9pt; background-color: #ffffff; border-top-style: none;"> <td colspan="5" class="pl-meta">
<label :id="'participant-' + participant.identifier +'-localgroup'"> <label :id="'participant-' + participant.identifier +'-localgroup'">
{{ participant?.localgroup ?? "-" }} {{ participant?.localgroup ?? "-" }}
</label> | </label> |
@@ -385,8 +386,8 @@ function mailToGroup(groupKey) {
</tr> </tr>
</template> </template>
<tr> <tr class="pl-summary-row">
<td colspan="3" style="font-weight: normal;"> <td colspan="3" class="pl-age-summary">
0 - 5 Jahre: <strong>{{ getAgeCounts(participants)['0-5'] ?? 0 }}</strong> | 0 - 5 Jahre: <strong>{{ getAgeCounts(participants)['0-5'] ?? 0 }}</strong> |
6-11 Jahre: <strong>{{ getAgeCounts(participants)['6-11'] ?? 0 }}</strong> | 6-11 Jahre: <strong>{{ getAgeCounts(participants)['6-11'] ?? 0 }}</strong> |
12-15 Jahre: <strong>{{ getAgeCounts(participants)['12-15'] ?? 0 }}</strong> | 12-15 Jahre: <strong>{{ getAgeCounts(participants)['12-15'] ?? 0 }}</strong> |
@@ -394,7 +395,7 @@ function mailToGroup(groupKey) {
18 - 27 Jahre: <strong>{{ getAgeCounts(participants)['18-27'] ?? 0 }}</strong> | 18 - 27 Jahre: <strong>{{ getAgeCounts(participants)['18-27'] ?? 0 }}</strong> |
27 Jahre und älter: <strong>{{ getAgeCounts(participants)['27+'] ?? 0 }}</strong> 27 Jahre und älter: <strong>{{ getAgeCounts(participants)['27+'] ?? 0 }}</strong>
</td> </td>
<td> <td class="pl-summary-action">
<input type="button" class="button" @click="mailToGroup(groupKey)" value="E-Mail an Gruppe senden" /> <input type="button" class="button" @click="mailToGroup(groupKey)" value="E-Mail an Gruppe senden" />
</td> </td>
</tr> </tr>
@@ -458,81 +459,193 @@ function mailToGroup(groupKey) {
</template> </template>
<style scoped> <style scoped>
table { .participants-table {
width: 95%;
margin: 20px auto;
border-collapse: collapse;
}
table {
margin-bottom: 60px !important; margin-bottom: 60px !important;
} }
tr { tr {
vertical-align: top; vertical-align: top;
} }
tr td { tr td {
height: 80px; height: 80px;
padding: 10px; padding: 10px;
padding-top: 20px; padding-top: 20px;
font-size: 11pt; font-size: 11pt;
line-height: 1.5; line-height: 1.5;
} }
tr th { tr th {
height: 40px; height: 40px;
padding-left: 10px; padding-left: 10px;
vertical-align: middle; vertical-align: middle;
} }
tr th:after { tr th:after {
content: ""; content: "";
}
} /* Spaltenbreiten auf Desktop */
.pl-name { width: 300px; }
.pl-amount { width: 275px; }
.pl-email { width: auto; }
.pl-phone { width: auto; }
tr:nth-child(even) { .pl-meta {
height: 15px !important;
font-size: 9pt;
background-color: #ffffff;
border-top-style: none;
}
tr:nth-child(even) {
background-color: #f9fafb; background-color: #f9fafb;
border-style: solid; border-style: solid;
border-width: 0 1px; border-width: 0 1px;
border-color: #e5e7eb; border-color: #e5e7eb;
} }
tr:nth-child(odd) {
tr:nth-child(odd) {
background-color: #ffffff; background-color: #ffffff;
border-style: solid; border-style: solid;
border-width: 5px 1px 0 1px; border-width: 5px 1px 0 1px;
border-color: #e5e7eb; border-color: #e5e7eb;
} }
tr:first-child { tr:first-child {
border-width: 1px 1px 0 1px; border-width: 1px 1px 0 1px;
} }
tr:last-child { tr:last-child {
border-width: 0 1px 1px 1px; border-width: 0 1px 1px 1px;
} }
tr:last-child td { tr:last-child td {
background: linear-gradient(to bottom, #fff, #f6f7f7); font-weight: bold; background: linear-gradient(to bottom, #fff, #f6f7f7); font-weight: bold;
height: 30px; height: 30px;
} }
.button { .button {
display: block; display: block;
font-size: 10pt; font-size: 10pt;
text-decoration: none; text-decoration: none;
} }
.not-paid { .not-paid {
color: #ff0000; background-color: #ffe6e6; color: #ff0000; background-color: #ffe6e6;
} }
.efz-invalid { .efz-invalid {
color: #ff0000; background-color: #ffe6e6; color: #ff0000; background-color: #ffe6e6;
} }
.efz-not-checked { .efz-not-checked {
color: #8D914BFF; background-color: #F4E99EFF; color: #8D914BFF; background-color: #F4E99EFF;
}
.block-label {
display: block;
}
/* ─── Tablet ─── */
@media (max-width: 1023px) {
.participants-table {
width: 100%;
} }
.block-label { .pl-name { width: 40%; }
display: block; .pl-amount { width: 25%; }
.pl-email,
.pl-phone { width: 17.5%; }
tr td {
padding: 8px;
font-size: 10pt;
} }
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
.participants-table {
width: 100%;
font-size: 0.85rem;
}
/* Auf Smartphone werden Beitrag und E-Mail ausgeblendet,
Name + Telefon bleiben sichtbar */
.pl-amount,
.pl-email {
display: none !important;
}
.pl-name {
width: 65% !important;
display: table-cell;
padding: 10px 8px !important;
height: auto;
vertical-align: top;
}
.pl-phone {
width: 35% !important;
display: table-cell;
padding: 10px 6px !important;
height: auto;
vertical-align: top;
font-size: 0.8rem;
word-break: break-all;
}
.pl-phone .block-label {
margin-bottom: 4px;
}
/* Header-Zeile entsprechend anpassen */
thead tr:nth-child(2) th.pl-name { width: 65% !important; }
thead tr:nth-child(2) th.pl-phone { width: 35% !important; }
/* Meta-Zeile (Adresse, Aktionen) bleibt sichtbar, aber kompakter */
.pl-meta {
padding: 8px !important;
font-size: 0.78rem !important;
line-height: 1.6;
word-break: break-word;
}
/* Summary-Zeile (Altersverteilung) auf Mobile: stapeln */
.pl-summary-row {
display: flex !important;
flex-direction: column;
}
.pl-summary-row td {
display: block;
width: 100% !important;
height: auto !important;
}
.pl-age-summary {
font-size: 0.78rem !important;
line-height: 1.8;
padding: 8px !important;
}
.pl-summary-action input.button {
width: 100%;
padding: 10px;
}
/* tr-Border auf Mobile angleichen */
tr td {
height: auto;
padding: 10px 8px;
padding-top: 12px;
}
}
</style> </style>
@@ -1,7 +1,7 @@
<script setup> <script setup>
const props = defineProps({ const props = defineProps({
event: Object event: Object
}) })
</script> </script>
<template> <template>
@@ -64,19 +64,17 @@
<th style="padding-bottom: 20px" colspan="2">Förderung</th> <th style="padding-bottom: 20px" colspan="2">Förderung</th>
<td style="padding-bottom: 20px" colspan="2"> <td style="padding-bottom: 20px" colspan="2">
{{ props.event.supportPerson.readable }}<br /> {{ 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> </td>
</tr> </tr>
<tr> <tr>
<th colspan="2" style="border-width: 1px; border-bottom-style: solid">Gesamt</th> <th colspan="2" style="border-width: 1px; border-top-style: solid">Gesamt</th>
<td style="font-weight: bold; border-width: 1px; border-bottom-style: solid"> <td style="font-weight: bold; border-width: 1px; border-top-style: solid">
{{ props.event.income.real.readable }} / {{ props.event.income.real.readable }} /
</td> </td>
<td style="font-weight: bold; border-width: 1px; border-bottom-style: solid"> <td style="font-weight: bold; border-width: 1px; border-top-style: solid">
{{ props.event.income.expected.readable }} {{ props.event.income.expected.readable }}
</td> </td>
</tr> </tr>
@@ -98,55 +96,66 @@
</td> </td>
</tr> </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> </table>
</div> </div>
<div class="right"> <div class="right">
<h3>Ausgaben</h3> <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"> <tr v-for="amount in props.event.costUnit.amounts">
<th>{{amount.name}}</th> <th>{{amount.name}}</th>
<td>{{amount.string}}</td> <td>{{amount.string}}</td>
<td>({{ amount.estimatedString }}) </td>
</tr> </tr>
<tr> <tr>
<th style="color:#f44336; border-width: 1px; border-bottom-style: solid; padding-top: 58px">Gesamt</th> <th style="color:#f44336; border-width: 1px; border-top-style: solid; ">Gesamt</th>
<td style="color:#f44336; border-width: 1px; border-bottom-style: solid; padding-top: 58px; 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> </tr>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.participant-flexbox { .participant-flexbox {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 20px;
width: 95%; width: 95%;
margin: 20px auto 0; margin: 20px auto 0;
} }
.participant-flexbox-row { .participant-flexbox-row {
display: flex; display: flex;
gap: 10px; /* Abstand zwischen den Spalten */ flex: 1 1;
flex-wrap: wrap;
} }
.participant-flexbox-row.top .left { .participant-flexbox-row.top .left,
flex: 0 0 50%; .participant-flexbox-row.top .right {
padding: 10px;
}
.participant-flexbox.top .right { padding: 20px;
flex: 0 0 50%; min-width: 0;
padding: 10px;
} }
.participant-income-table, .participant-income-table,
.event-payment-table { .event-payment-table {
width: 475px; width: 100%;
max-width: 475px;
table-layout: auto;
} }
.participant-income-table th { .participant-income-table th {
@@ -155,11 +164,16 @@
} }
.participant-income-table tr td:first-child { .participant-income-table tr td:first-child {
width: 25px !important; width: 50px;
font-size: 11pt;
} }
.event-payment-table { .participant-income-table tr td:first-child {
width: 100%; width: 25px !important;
font-size: 10pt;
} }
.event-payment-table th {
width: 50px;
}
</style> </style>
@@ -50,43 +50,37 @@ const steps = [
<template v-else> <template v-else>
<!-- Fortschrittsleiste (ab Step 2) --> <!-- Fortschrittsleiste (ab Step 2) -->
<div v-if="currentStep > 1" style="margin-bottom: 28px;"> <div v-if="currentStep > 1" class="signup-progress">
<div style="display: flex; gap: 6px; flex-wrap: wrap; align-items: center;"> <div class="signup-progress-pills">
<template v-for="(s, index) in steps.filter(s => s.step > 1)" :key="s.step"> <template v-for="(s, index) in steps.filter(s => s.step > 1)" :key="s.step">
<!-- Trennlinie zwischen Pills --> <div v-if="index > 0" class="signup-progress-separator"></div>
<div v-if="index > 0" style="flex-shrink: 0; width: 16px; height: 2px; background: #e5e7eb; border-radius: 1px;"></div>
<div <div
:style="{ class="signup-pill"
padding: '5px 14px', :class="{
borderRadius: '999px', 'signup-pill--active': currentStep === s.step,
fontSize: '0.78rem', 'signup-pill--done': currentStep > s.step,
fontWeight: '600', 'signup-pill--upcoming': currentStep < s.step,
whiteSpace: 'nowrap',
border: '2px solid',
borderColor: currentStep === s.step ? '#2563eb' : currentStep > s.step ? '#bbf7d0' : '#e5e7eb',
background: currentStep === s.step ? '#2563eb' : currentStep > s.step ? '#f0fdf4' : '#f9fafb',
color: currentStep === s.step ? 'white' : currentStep > s.step ? '#15803d' : '#9ca3af',
cursor: currentStep > s.step ? 'pointer' : 'default',
}" }"
@click="currentStep > s.step ? goToStep(s.step) : null" @click="currentStep > s.step ? goToStep(s.step) : null"
> >
<span v-if="currentStep > s.step" style="margin-right: 4px;"></span> <span v-if="currentStep > s.step" class="signup-pill__check"></span>
{{ s.label }} {{ s.label }}
</div> </div>
</template> </template>
</div> </div>
<!-- Fortschrittsbalken --> <!-- Fortschrittsbalken -->
<div style="margin-top: 10px; height: 3px; background: #e5e7eb; border-radius: 2px; overflow: hidden;"> <div class="signup-progress-bar">
<div <div
:style="{ class="signup-progress-bar__fill"
height: '100%', :style="{ width: ((currentStep - 2) / (steps.length - 2) * 100) + '%' }"
background: 'linear-gradient(90deg, #2563eb, #3b82f6)',
borderRadius: '2px',
width: ((currentStep - 2) / (steps.length - 2) * 100) + '%',
transition: 'width 0.3s ease',
}"
></div> ></div>
</div> </div>
<!-- Mobile Step-Anzeige -->
<div class="signup-progress-mobile">
Schritt {{ currentStep - 1 }} von {{ steps.length - 1 }}:
<strong>{{ steps.find(s => s.step === currentStep)?.label }}</strong>
</div>
</div> </div>
<!-- Steps --> <!-- Steps -->
@@ -115,11 +109,87 @@ const steps = [
</template> </template>
<style> <style>
/* ─── Progress (Step-Pills) ─── */
.signup-progress {
margin-bottom: 28px;
}
.signup-progress-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.signup-progress-separator {
flex-shrink: 0;
width: 16px;
height: 2px;
background: #e5e7eb;
border-radius: 1px;
}
.signup-pill {
padding: 5px 14px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
border: 2px solid;
cursor: default;
}
.signup-pill__check { margin-right: 4px; }
.signup-pill--active {
border-color: #2563eb;
background: #2563eb;
color: white;
}
.signup-pill--done {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
cursor: pointer;
}
.signup-pill--upcoming {
border-color: #e5e7eb;
background: #f9fafb;
color: #9ca3af;
}
.signup-progress-bar {
margin-top: 10px;
height: 3px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.signup-progress-bar__fill {
height: 100%;
background: linear-gradient(90deg, #2563eb, #3b82f6);
border-radius: 2px;
transition: width 0.3s ease;
}
.signup-progress-mobile {
display: none;
margin-top: 8px;
font-size: 0.9rem;
color: #374151;
}
/* ─── Form-Table ─── */
.form-table { width: 100%; border-collapse: collapse; } .form-table { width: 100%; border-collapse: collapse; }
.form-table td { padding: 8px 12px 8px 0; vertical-align: top; } .form-table td { padding: 8px 12px 8px 0; vertical-align: top; }
.form-table td:first-child { width: 220px; color: #374151; font-weight: 500; } .form-table td:first-child { width: 220px; color: #374151; font-weight: 500; }
.form-table input[type="text"], .form-table input[type="text"],
.form-table input[type="date"], .form-table input[type="date"],
.form-table input[type="email"],
.form-table input[type="number"],
.form-table select, .form-table select,
.form-table textarea { .form-table textarea {
width: 100%; width: 100%;
@@ -129,7 +199,8 @@ const steps = [
font-size: 0.95rem; font-size: 0.95rem;
box-sizing: border-box; box-sizing: border-box;
} }
.btn-row { display: flex; gap: 10px; padding-top: 16px; }
.btn-row { display: flex; gap: 10px; padding-top: 16px; flex-wrap: wrap; }
.btn-primary { .btn-primary {
padding: 8px 20px; padding: 8px 20px;
background: #2563eb; background: #2563eb;
@@ -149,4 +220,58 @@ const steps = [
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
/* ─── Tablet ─── */
@media (max-width: 1023px) {
.form-table td:first-child {
width: 160px;
}
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
/* Pills auf Mobile: kompakter, Trennstriche ausblenden */
.signup-progress-pills {
display: none;
}
.signup-progress-mobile {
display: block;
}
/* Form-Table: Label oberhalb des Feldes */
.form-table,
.form-table tbody,
.form-table tr {
display: block;
width: 100%;
}
.form-table td {
display: block;
width: 100% !important;
padding: 4px 0;
}
.form-table td:first-child {
width: 100% !important;
font-weight: 600;
color: #374151;
padding-top: 10px;
}
.form-table td[colspan] {
width: 100% !important;
}
.btn-row {
flex-direction: column-reverse;
gap: 8px;
}
.btn-primary,
.btn-secondary {
width: 100%;
padding: 12px 20px;
}
}
</style> </style>
@@ -9,7 +9,7 @@ const emit = defineEmits(['next'])
<h3 style="margin: 0 0 6px 0; color: #111827;">Wer nimmt teil?</h3> <h3 style="margin: 0 0 6px 0; color: #111827;">Wer nimmt teil?</h3>
<p style="margin: 0 0 24px 0; color: #6b7280; font-size: 0.95rem;">Bitte wähle deine Altersgruppe aus.</p> <p style="margin: 0 0 24px 0; color: #6b7280; font-size: 0.95rem;">Bitte wähle deine Altersgruppe aus.</p>
<div style="display: flex; gap: 20px; flex-wrap: wrap;"> <div class="age-card-row">
<!-- Kind / Jugendliche:r --> <!-- Kind / Jugendliche:r -->
<div class="age-card" @click="emit('next', 2)"> <div class="age-card" @click="emit('next', 2)">
@@ -40,9 +40,15 @@ const emit = defineEmits(['next'])
</template> </template>
<style scoped> <style scoped>
.age-card-row {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.age-card { .age-card {
flex: 1; flex: 1 1 280px;
min-width: 220px; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -62,13 +68,14 @@ const emit = defineEmits(['next'])
.age-card__badge { .age-card__badge {
position: relative; position: relative;
width: 350px; width: 100%;
height: 200px; max-width: 350px;
aspect-ratio: 350 / 200;
margin-bottom: 16px; margin-bottom: 16px;
} }
.age-card__img { .age-card__img {
width: 350px; width: 100%;
height: 200px; height: 100%;
object-fit: contain; object-fit: contain;
} }
.age-card__badge-fallback { .age-card__badge-fallback {
@@ -100,4 +107,43 @@ const emit = defineEmits(['next'])
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
} }
/* ─── Smartphone ─── */
@media (max-width: 639px) {
.age-card-row {
flex-direction: column;
gap: 12px;
}
.age-card {
flex: 1 1 100%;
padding: 18px 14px;
flex-direction: row;
text-align: left;
align-items: center;
gap: 14px;
}
.age-card__badge {
flex: 0 0 90px;
width: 90px;
max-width: 90px;
aspect-ratio: 1;
margin-bottom: 0;
}
.age-card__body {
flex: 1;
align-items: flex-start;
text-align: left;
}
.age-card__title {
font-size: 1rem;
}
.age-card__desc {
font-size: 0.85rem;
}
}
</style> </style>
@@ -116,8 +116,10 @@ const next = () => {
<tr> <tr>
<td>PLZ, Ort:</td> <td>PLZ, Ort:</td>
<td> <td>
<input maxlength="5" type="text" v-model="props.formData.plz" style="width: 100px; margin-right: 8px;" /> <div class="plz-ort-row">
<input type="text" v-model="props.formData.ort" style="width: calc(100% - 110px);" /> <input maxlength="5" type="text" v-model="props.formData.plz" class="plz-input" placeholder="PLZ" />
<input type="text" v-model="props.formData.ort" class="ort-input" placeholder="Ort" />
</div>
<ErrorText :message="errors.plz" /> <ErrorText :message="errors.plz" />
<ErrorText :message="errors.ort" /> <ErrorText :message="errors.ort" />
</td> </td>
@@ -132,3 +134,28 @@ const next = () => {
</table> </table>
</div> </div>
</template> </template>
<style scoped>
.plz-ort-row {
display: flex;
gap: 8px;
width: 100%;
}
.plz-input {
flex: 0 0 100px;
width: 100px;
}
.ort-input {
flex: 1;
min-width: 0;
}
@media (max-width: 639px) {
.plz-input {
flex: 0 0 80px;
width: 80px;
}
}
</style>

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