61 Commits
Author SHA1 Message Date
th.guenther c17facf063 Added tax support 2026-08-06 11:49:58 +02:00
th.guenther 6b33c1b4dd Added tax support 2026-08-06 11:49:39 +02:00
th.guenther 3b47979265 Merge pull request 'Dev 4.6.1' (#13) from dev-4.6.1 into main
Reviewed-on: #13
2026-08-05 19:30:18 +02:00
th.guenther b15573cda6 Overview of active payment methode 2026-08-05 19:12:57 +02:00
th.guenther 67c4e69f7b Overview of active payment methode 2026-08-05 19:12:49 +02:00
th.guenther 7f8417e915 Small improvements 2026-08-05 18:08:04 +02:00
th.guenther f8dba18854 Icons for payment methods 2026-08-05 17:41:35 +02:00
th.guenther daff70b4fe Payment methode now selectable 2026-08-05 16:53:35 +02:00
th.guenther 7919d04dc3 More improvements 2026-08-05 11:09:22 +02:00
th.guenther cea00f9679 Merge pull request 'Development 4.6.0' (#12) from development-4.6.0 into main
Reviewed-on: #12
2026-08-05 11:07:57 +02:00
th.guenther a0d26648ee More improvements 2026-08-05 10:55:06 +02:00
th.guenther e55cdd1988 Moved bank transfer logic to special module 2026-08-05 10:00:02 +02:00
th.guenther d1ef092bc3 Small design fixes 2026-07-29 15:58:57 +02:00
th.guenther 78f4d813f9 Configurations for payment modules 2026-07-29 15:49:26 +02:00
th.guenther 8d6a4041c1 Configurations for payment modules 2026-07-29 13:10:35 +02:00
th.guenther 29db141b84 Configurations for payment modules 2026-07-29 12:37:48 +02:00
th.guenther ab3d526217 Basic implementation of payment methods 2026-07-28 16:09:38 +02:00
th.guenther 6c9281516e Basic implementation of payment methods 2026-07-28 15:50:58 +02:00
th.guenther afc91545a9 Merge pull request 'Small fixes' (#11) from dev-4.5.3 into main
Reviewed-on: #11
2026-06-26 22:06:46 +02:00
th.guenther 12d98b4d7e Small fixes 2026-06-26 22:06:15 +02:00
th.guenther 6c7fe56579 Merge pull request 'Fixed permission management' (#10) from dev-4.5.2 into main
Reviewed-on: #10
2026-06-23 18:46:50 +02:00
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
252 changed files with 11914 additions and 1027 deletions
+2
View File
@@ -56,6 +56,8 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
APP_ADMIN_MAIL=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
+17
View File
@@ -0,0 +1,17 @@
# CLAUDE.md
Laravel 12 + Inertia/Vue, mandantenfähig (custom `tenant`-Scoping über `app('tenant')`, gesetzt in
`app/Middleware/IdentifyTenant.php`). PHP 8.5 — Tests/Artisan laufen im Container:
`docker exec mareike-mareike-app-1 php artisan test`.
## Projektkonventionen (verbindlich)
Die verbindlichen Konventionen (Actions/Request-Command-Response, Controller, Repositories, Models/Resources, Tenant,
Routing, Mails) stehen in `.ai/conventions.md` und werden hier eingebunden — **immer beachten**:
@.ai/conventions.md
## Bereichs-Dokumentation
- [Zahlungsmodule](app/EventPaymentModules/CLAUDE.md) — interface-gesteuerte Zahlungsarten (Optionen, Config,
Anmelde-Zusammenfassung, GiroCode/Fähigkeits-Interfaces, neues Modul hinzufügen).
@@ -0,0 +1,54 @@
<?php
namespace App\Domains\Admin\Actions\CreateTenant;
use App\Models\AvailablePaymentMethod;
use App\Models\PaymentMethod;
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,
]);
$paymentMethodDefaults = PaymentMethod::defaults();
foreach (PaymentMethod::all() as $paymentMethod) {
$defaults = $paymentMethodDefaults[$paymentMethod->slug] ?? ['name' => $paymentMethod->slug, 'description' => null, 'configuration' => []];
AvailablePaymentMethod::create([
'tenant' => $tenant->slug,
'slug' => $paymentMethod->slug,
'name' => $defaults['name'],
'description' => $defaults['description'],
'active' => true,
'configuration' => PaymentMethod::sanitizeConfiguration($paymentMethod->slug, $defaults['configuration'] ?? []),
]);
}
$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,32 @@
<?php
namespace App\Domains\Admin\Actions\RemovePaymentMethodUsage;
use App\Mail\EventReportMails\PaymentMethodsExhaustedMail;
use App\Models\AvailablePaymentMethod;
use Illuminate\Support\Facades\Mail;
class RemovePaymentMethodUsageAction
{
public function execute(AvailablePaymentMethod $availablePaymentMethod): void
{
foreach ($availablePaymentMethod->events()->get() as $event) {
$event->paymentMethods()->detach($availablePaymentMethod->slug);
$remainingActive = $event->paymentMethods()->where('active', true)->count();
if ($remainingActive === 0 && $event->registration_allowed) {
$event->registration_allowed = false;
$event->save();
$recipients = $event->eventManagers;
if ($event->costUnit !== null) {
$recipients = $recipients->merge($event->costUnit->treasurers);
}
foreach ($recipients->unique('id') as $recipient) {
Mail::to($recipient->email)->send(new PaymentMethodsExhaustedMail($event));
}
}
}
}
}
@@ -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,47 @@
<?php
namespace App\Domains\Admin\Actions\UpdateAvailablePaymentMethod;
use App\Domains\Admin\Actions\RemovePaymentMethodUsage\RemovePaymentMethodUsageAction;
use App\Models\PaymentMethod;
class UpdateAvailablePaymentMethodAction
{
public function __construct(private UpdateAvailablePaymentMethodRequest $request)
{
}
public function execute(): UpdateAvailablePaymentMethodResponse
{
$response = new UpdateAvailablePaymentMethodResponse();
$paymentMethod = $this->request->availablePaymentMethod;
$wasActive = $paymentMethod->active;
// Nur bekannte Options-Keys übernehmen -- das Options-Schema des Zahlungsmoduls ist die Autorität.
$configuration = PaymentMethod::sanitizeConfiguration($paymentMethod->slug, $this->request->configuration);
// Guard: Aktivierung nur erlaubt, wenn alle Pflicht-Optionen befüllt sind.
if ($this->request->active && !PaymentMethod::isConfigurationComplete($paymentMethod->slug, $configuration)) {
$response->success = false;
$response->message = 'Bitte zuerst alle Pflichtfelder ausfüllen, bevor die Zahlungsmethode aktiviert wird.';
return $response;
}
$paymentMethod->update([
'name' => $this->request->name,
'description' => $this->request->description,
'active' => $this->request->active,
'configuration' => $configuration,
]);
if ($wasActive && !$this->request->active) {
(new RemovePaymentMethodUsageAction())->execute($paymentMethod);
}
$response->success = true;
$response->message = 'Zahlungsmethode wurde gespeichert.';
return $response;
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Admin\Actions\UpdateAvailablePaymentMethod;
use App\Models\AvailablePaymentMethod;
class UpdateAvailablePaymentMethodRequest
{
/**
* @param array<string, mixed> $configuration
*/
public function __construct(
public AvailablePaymentMethod $availablePaymentMethod,
public string $name,
public ?string $description,
public bool $active,
public array $configuration = [],
) {
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateAvailablePaymentMethod;
class UpdateAvailablePaymentMethodResponse
{
public bool $success = false;
public string $message = '';
}
@@ -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,62 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantPayment;
use App\Models\AvailablePaymentMethod;
use App\Models\PaymentMethod;
use App\Scopes\SiteScope;
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,
]);
$this->syncTransferPaymentConfiguration();
$response->success = true;
$response->message = 'IBAN-Informationen wurden gespeichert.';
return $response;
}
/**
* Übernimmt die eingegebenen IBAN-Daten direkt in die Tenant-Config der Überweisungs-Zahlungsart
* (Kontoinhaber/IBAN/BIC), damit sie nicht doppelt gepflegt werden müssen. Bestehende weitere
* Config-Werte (z.B. Symbol) bleiben erhalten. Der Ziel-Tenant kann ein verwalteter sein, daher
* ohne SiteScope explizit auf den Tenant-Slug gefiltert.
*/
private function syncTransferPaymentConfiguration(): void
{
$slug = PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION;
$method = AvailablePaymentMethod::withoutGlobalScope(SiteScope::class)
->where('tenant', $this->request->tenant->slug)
->where('slug', $slug)
->first();
if ($method === null) {
return;
}
$method->configuration = PaymentMethod::sanitizeConfiguration($slug, array_merge(
$method->configuration ?? [],
[
'account_owner' => $this->request->accountName,
'iban' => $this->request->accountIban,
'bic' => $this->request->accountBic,
],
));
$method->save();
}
}
@@ -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,64 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantTax;
use App\Enumerations\TaxExemptionReason;
class UpdateTenantTaxAction
{
public function __construct(private UpdateTenantTaxRequest $request)
{
}
public function execute(): UpdateTenantTaxResponse
{
$response = new UpdateTenantTaxResponse();
if ($this->request->taxLiable) {
$this->request->tenant->update([
'tax_liable' => true,
'vat_rate' => $this->clampVatRate($this->request->vatRate),
'tax_exemption_reason' => null,
'tax_exemption_note' => null,
]);
$response->success = true;
$response->message = 'Steuerinformationen wurden gespeichert.';
return $response;
}
$reason = TaxExemptionReason::tryFrom((string)$this->request->exemptionReason);
if ($reason === null) {
$response->message = 'Bitte einen gültigen Befreiungsgrund auswählen.';
return $response;
}
$note = trim((string)$this->request->exemptionNote);
if ($reason->requiresNote() && $note === '') {
$response->message = 'Bitte einen Text für den Befreiungsgrund angeben.';
return $response;
}
$this->request->tenant->update([
'tax_liable' => false,
'vat_rate' => 0,
'tax_exemption_reason' => $reason->value,
'tax_exemption_note' => $reason->requiresNote() ? $note : null,
]);
$response->success = true;
$response->message = 'Steuerinformationen wurden gespeichert.';
return $response;
}
private function clampVatRate(int $vatRate): int
{
return max(0, min(100, $vatRate));
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantTax;
use App\Models\Tenant;
class UpdateTenantTaxRequest
{
public function __construct(
public Tenant $tenant,
public bool $taxLiable,
public int $vatRate,
public ?string $exemptionReason,
public ?string $exemptionNote,
)
{
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantTax;
class UpdateTenantTaxResponse
{
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,25 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Enumerations\TaxExemptionReason;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ManagedTenantTaxGetController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
return response()->json([
'tax_liable' => $tenant->tax_liable,
'vat_rate' => $tenant->vat_rate,
'tax_exemption_reason' => $tenant->tax_exemption_reason,
'tax_exemption_note' => $tenant->tax_exemption_note,
'exemption_reasons' => TaxExemptionReason::options(),
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/tax',
]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxAction;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ManagedTenantTaxUpdateController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
$action = new UpdateTenantTaxAction(new UpdateTenantTaxRequest(
tenant: $tenant,
taxLiable: $request->boolean('tax_liable'),
vatRate: (int)$request->input('vat_rate', 0),
exemptionReason: $request->input('tax_exemption_reason'),
exemptionNote: $request->input('tax_exemption_note'),
));
$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,21 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Models\AvailablePaymentMethod;
use App\Resources\AvailablePaymentMethodResource;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantPaymentMethodsGetController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
return response()->json([
'paymentMethods' => AvailablePaymentMethod::all()
->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)),
'saveEndpoint' => '/api/v1/admin/tenant/payment-methods',
]);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateAvailablePaymentMethod\UpdateAvailablePaymentMethodAction;
use App\Domains\Admin\Actions\UpdateAvailablePaymentMethod\UpdateAvailablePaymentMethodRequest;
use App\Models\AvailablePaymentMethod;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantPaymentMethodsUpdateController extends CommonController
{
public function __invoke(int $id, Request $request): JsonResponse
{
$availablePaymentMethod = AvailablePaymentMethod::findOrFail($id);
$action = new UpdateAvailablePaymentMethodAction(new UpdateAvailablePaymentMethodRequest(
availablePaymentMethod: $availablePaymentMethod,
name: $request->input('name'),
description: $request->input('description'),
active: (bool) $request->input('active'),
configuration: (array) $request->input('configuration', []),
));
$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\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,23 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Enumerations\TaxExemptionReason;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantTaxGetController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
return response()->json([
'tax_liable' => $this->tenant->tax_liable,
'vat_rate' => $this->tenant->vat_rate,
'tax_exemption_reason' => $this->tenant->tax_exemption_reason,
'tax_exemption_note' => $this->tenant->tax_exemption_note,
'exemption_reasons' => TaxExemptionReason::options(),
'saveEndpoint' => '/api/v1/admin/tenant/tax',
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxAction;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TenantTaxUpdateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$action = new UpdateTenantTaxAction(new UpdateTenantTaxRequest(
tenant: $this->tenant,
taxLiable: $request->boolean('tax_liable'),
vatRate: (int)$request->input('vat_rate', 0),
exemptionReason: $request->input('tax_exemption_reason'),
exemptionNote: $request->input('tax_exemption_note'),
));
$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();
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
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\ManagedTenantTaxGetController;
use App\Domains\Admin\Controllers\ManagedTenantTaxUpdateController;
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\TenantPaymentMethodsGetController;
use App\Domains\Admin\Controllers\TenantPaymentMethodsUpdateController;
use App\Domains\Admin\Controllers\TenantPaymentUpdateController;
use App\Domains\Admin\Controllers\TenantTaxGetController;
use App\Domains\Admin\Controllers\TenantTaxUpdateController;
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\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('/payment-methods', TenantPaymentMethodsGetController::class);
Route::post('/payment-methods/{id}', TenantPaymentMethodsUpdateController::class);
Route::get('/impress', TenantImpressGetController::class);
Route::post('/impress', TenantImpressUpdateController::class);
Route::get('/gdpr', TenantGdprGetController::class);
Route::post('/gdpr', TenantGdprUpdateController::class);
Route::get('/tax', TenantTaxGetController::class);
Route::post('/tax', TenantTaxUpdateController::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);
Route::get('/tax', ManagedTenantTaxGetController::class);
Route::post('/tax', ManagedTenantTaxUpdateController::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,264 @@
<script setup>
import {computed, ref} from 'vue';
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
import TextEditor from "../../../../Views/Components/TextEditor.vue";
import RichSelectBox from "../../../../Views/Components/RichSelectBox.vue";
import {PAYMENT_METHOD_ICONS} from "../../../../../resources/js/constants/paymentMethodIcons.js";
import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
import {toast} from "vue3-toastify";
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
})
const { request } = useAjax()
const paymentMethods = ref(props.data.paymentMethods ?? [])
const editing = ref(null)
const form = ref({ name: '', description: '', active: true, configuration: {} })
// Sind alle Pflichtfelder des aktuell bearbeiteten Moduls befüllt? Der Server ist die
// eigentliche Autorität (Guard), das hier dient nur der Bedienführung.
const requiredComplete = computed(() => {
if (!editing.value) return true
return (editing.value.optionsSchema ?? [])
.filter(option => option.required)
.every(option => {
const value = form.value.configuration[option.name]
return value !== undefined && value !== null && value !== ''
})
})
function edit(paymentMethod) {
editing.value = paymentMethod
const configuration = {}
for (const option of paymentMethod.optionsSchema ?? []) {
configuration[option.name] = paymentMethod.configuration?.[option.name] ?? ''
}
form.value = {
name: paymentMethod.name,
description: paymentMethod.description ?? '',
active: paymentMethod.active,
configuration,
}
}
function close() {
editing.value = null
}
async function save() {
const response = await request('/api/v1/admin/tenant/payment-methods/' + editing.value.id, {
method: 'POST',
body: form.value,
})
if (response && response.status === 'success') {
toast.success(response.message)
Object.assign(editing.value, form.value)
close()
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
</script>
<template>
<table class="payment-method-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th class="empty"></th>
</tr>
</thead>
<tbody>
<tr v-for="paymentMethod in paymentMethods" :key="paymentMethod.id">
<td>{{ paymentMethod.name }}</td>
<td>
<span :class="paymentMethod.active ? 'badge-active' : 'badge-inactive'">
{{ paymentMethod.active ? 'Aktiv' : 'Inaktiv' }}
</span>
</td>
<td>
<button class="btn-edit" @click="edit(paymentMethod)">Bearbeiten</button>
</td>
</tr>
</tbody>
</table>
<FullScreenModal :show="editing !== null" @close="close">
<template v-if="editing">
<h2>Zahlungsmodul bearbeiten</h2>
<table class="data-table">
<tr><th>Name</th><td><input type="text" v-model="form.name" class="form-input" /></td></tr>
<tr><th>Beschreibung</th><td><textarea v-model="form.description" class="form-input"></textarea></td></tr>
<tr v-for="option in editing.optionsSchema ?? []" :key="option.name">
<th>{{ option.label }}<span v-if="option.required" class="required-marker">*</span></th>
<td>
<RichSelectBox v-if="option.type === 'icon'" v-model="form.configuration[option.name]"
:options="PAYMENT_METHOD_ICONS" placeholder="Symbol wählen…"/>
<TextEditor v-else-if="option.type === 'richtext'" v-model="form.configuration[option.name]"/>
<input v-else type="text" v-model="form.configuration[option.name]" class="form-input"/>
<span v-if="option.hint" class="option-hint">{{ option.hint }}</span>
</td>
</tr>
<tr>
<th>Aktiv</th>
<td>
<input type="checkbox" v-model="form.active" :disabled="!requiredComplete && !form.active" />
<span v-if="!requiredComplete" class="hint">Bitte zuerst alle Pflichtfelder (*) ausfüllen, um zu aktivieren.</span>
</td>
</tr>
</table>
<div class="btn-group">
<button class="btn-save" @click="save">Speichern</button>
<button class="btn-cancel" @click="close">Abbrechen</button>
</div>
</template>
</FullScreenModal>
</template>
<style scoped>
.payment-method-table {
width: 100%;
border-collapse: collapse;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
}
.payment-method-table thead th {
text-align: left;
padding: 10px 16px;
background-color: #f9fafb;
color: #374151;
font-weight: bold;
border-bottom: 2px solid #d1d5db;
}
.payment-method-table tbody td {
padding: 10px 16px;
border-bottom: 1px solid #e5e7eb;
}
.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 {
padding: 6px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
background-color: #1d4899;
color: #ffffff;
}
.btn-edit:hover {
background-color: #163a7a;
}
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
.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;
}
.required-marker {
color: #ef4444;
margin-left: 2px;
}
.hint {
display: block;
margin-top: 4px;
font-size: 0.8rem;
color: #b45309;
}
.option-hint {
display: block;
margin-top: 4px;
font-size: 0.8rem;
color: #6b7280;
}
.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>
@@ -0,0 +1,223 @@
<script setup>
import {computed, ref} from 'vue';
import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
import RichSelectBox from "../../../../Views/Components/RichSelectBox.vue";
import {toast} from "vue3-toastify";
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
})
const {request} = useAjax()
const editing = ref(false)
const reasons = computed(() => props.data.exemption_reasons ?? [])
const liableOptions = [
{value: '1', label: 'Ja Umsatzsteuer wird erhoben'},
{value: '0', label: 'Nein umsatzsteuerbefreit'},
]
const form = ref({
tax_liable: props.data.tax_liable ? '1' : '0',
vat_rate: props.data.vat_rate ?? 0,
tax_exemption_reason: props.data.tax_exemption_reason ?? '',
tax_exemption_note: props.data.tax_exemption_note ?? '',
})
const isLiable = computed(() => form.value.tax_liable === '1')
const isCustomReason = computed(() => form.value.tax_exemption_reason === 'custom')
const selectedReason = computed(
() => reasons.value.find(r => r.value === form.value.tax_exemption_reason) ?? null
)
// Vorschau des Pflichthinweises nach § 14 Abs. 4 Nr. 8 UStG, der später auf der Rechnung erscheint.
const invoiceHint = computed(() => {
if (isLiable.value) {
return `Umsatzsteuerpflichtig USt-Satz: ${form.value.vat_rate || 0} %`
}
if (isCustomReason.value) {
return form.value.tax_exemption_note?.trim() || '—'
}
return selectedReason.value?.text || '—'
})
const reasonLabel = computed(() => selectedReason.value?.label ?? '—')
async function save() {
const saveUrl = props.data.saveEndpoint ?? '/api/v1/admin/tenant/tax'
const response = await request(saveUrl, {
method: 'POST',
body: {
tax_liable: isLiable.value,
vat_rate: Number(form.value.vat_rate) || 0,
tax_exemption_reason: isLiable.value ? null : form.value.tax_exemption_reason,
tax_exemption_note: form.value.tax_exemption_note,
},
})
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>Steuerpflicht</th>
<td>{{ isLiable ? 'Ja Umsatzsteuer wird erhoben' : 'Nein umsatzsteuerbefreit' }}</td>
</tr>
<tr v-if="isLiable">
<th>USt-Satz</th>
<td>{{ form.vat_rate || 0 }} %</td>
</tr>
<tr v-else>
<th>Befreiungsgrund</th>
<td>{{ reasonLabel }}</td>
</tr>
<tr>
<th>Rechnungshinweis</th>
<td>{{ invoiceHint }}</td>
</tr>
</table>
<button class="btn-edit" @click="editing = true">Bearbeiten</button>
</div>
<div v-else>
<table class="data-table">
<tr>
<th>Steuerpflicht</th>
<td>
<RichSelectBox v-model="form.tax_liable" :options="liableOptions"/>
</td>
</tr>
<tr v-if="isLiable">
<th>USt-Satz (%)</th>
<td><input type="number" min="0" max="100" v-model.number="form.vat_rate" class="form-input"/></td>
</tr>
<template v-else>
<tr>
<th>Befreiungsgrund</th>
<td>
<RichSelectBox
v-model="form.tax_exemption_reason"
:options="reasons"
placeholder="Grund auswählen…"
/>
</td>
</tr>
<tr v-if="isCustomReason">
<th>Freitext</th>
<td>
<textarea
v-model="form.tax_exemption_note"
class="form-input"
rows="3"
placeholder="Hinweistext für die Rechnung…"
></textarea>
</td>
</tr>
</template>
<tr>
<th>Rechnungshinweis</th>
<td class="hint-preview">{{ invoiceHint }}</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;
vertical-align: top;
}
.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;
font-family: inherit;
}
.hint-preview {
color: #6b7280;
font-style: italic;
}
.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>
+121
View File
@@ -0,0 +1,121 @@
<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";
import TenantPaymentMethods from "./Partials/TenantPaymentMethods.vue";
import TenantTaxInfo from "./Partials/TenantTaxInfo.vue";
const props = defineProps({
tenant: Object,
})
const tabs = [
{
title: 'Kontaktdaten',
component: TenantContact,
endpoint: '/api/v1/admin/tenant/contact',
},
{
title: 'IBAN-Informationen',
component: TenantPayment,
endpoint: '/api/v1/admin/tenant/payment',
},
{
title: 'Zahlungsmodule',
component: TenantPaymentMethods,
endpoint: '/api/v1/admin/tenant/payment-methods',
},
{
title: 'Steuerinformationen',
component: TenantTaxInfo,
endpoint: '/api/v1/admin/tenant/tax',
},
{
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>
+58
View File
@@ -0,0 +1,58 @@
<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";
import TenantTaxInfo from "./Partials/TenantTaxInfo.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: 'IBAN-Informationen',
component: TenantPayment,
endpoint: '/api/v1/admin/tenants/' + slug + '/payment',
},
{
title: 'Steuerinformationen',
component: TenantTaxInfo,
endpoint: '/api/v1/admin/tenants/' + slug + '/tax',
},
{
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>

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