From ee7fc637f1806877fc8a4836c38096acdb3c2d42 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnther?=
Date: Wed, 11 Feb 2026 15:40:06 +0100
Subject: [PATCH] Invoices can be uploaded
---
.../CostUnit/Controllers/CreateController.php | 5 +-
.../DistanceAllowanceController.php | 20 ++
app/Domains/CostUnit/Routes/api.php | 12 +-
app/Domains/CostUnit/Routes/web.php | 4 -
app/Domains/Dashboard/Views/Dashboard.vue | 3 +-
.../CreateInvoice/CreateInvoiceCommand.php | 63 ++++++
.../CreateInvoice/CreateInvoiceRequest.php | 64 ++++++
.../CreateInvoice/CreateInvoiceResponse.php | 15 ++
.../Controllers/NewInvoiceController.php | 138 ++++++++++++
app/Domains/Invoice/Routes/api.php | 25 +++
app/Domains/Invoice/Routes/web.php | 22 ++
app/Domains/Invoice/Views/CreateInvoice.vue | 29 ---
app/Domains/Invoice/Views/NewInvoice.vue | 79 +++++++
.../newInvoice/expense-accounting.vue | 124 +++++++++++
.../Views/Partials/newInvoice/refund-data.vue | 157 ++++++++++++++
.../newInvoice/travel-expense-accounting.vue | 158 ++++++++++++++
app/Domains/UserManagement/Routes/web.php | 6 -
app/Enumerations/InvoiceStatus.php | 20 ++
app/Enumerations/InvoiceType.php | 23 ++
app/Installer/ProductionDataSeeder.php | 37 ++++
app/Models/Invoice.php | 68 ++++++
app/Models/PageText.php | 9 +
app/Models/User.php | 12 +-
app/Providers/AuthCheckProvider.php | 7 +-
app/Providers/FlashMessageProvider.php | 11 +
app/Providers/GlobalDataProvider.php | 57 ++++-
app/Providers/InertiaProvider.php | 5 -
app/Providers/UploadFileProvider.php | 52 +++++
app/Repositories/CostUnitRepository.php | 19 +-
app/Repositories/PageTextRepository.php | 19 ++
app/Repositories/UserRepository.php | 30 +++
app/Scopes/CommonController.php | 4 +
app/ValueObjects/Amount.php | 4 +
app/ValueObjects/InvoiceFile.php | 9 +
app/Views/Components/IbanInput.vue | 56 +++++
app/Views/Components/InfoIcon.vue | 202 ++++++++++++++++++
app/Views/Components/NumericInput.vue | 17 ++
app/Views/Components/TextResource.vue | 29 +++
.../2026_01_30_140002_create_users_table.php | 1 +
.../2026_01_30_140010_create_invoices.php | 68 ++++++
.../2026_01_30_140010_create_page_texts.php | 24 +++
public/css/invoices.css | 41 ++++
.../js/components/InvoiceUploadChecks.js | 23 ++
resources/js/components/ajaxHandler.js | 12 +-
resources/js/layouts/AppLayout.vue | 18 +-
resources/views/app.blade.php | 1 +
routes/web.php | 16 ++
47 files changed, 1751 insertions(+), 67 deletions(-)
create mode 100644 app/Domains/CostUnit/Controllers/DistanceAllowanceController.php
create mode 100644 app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceCommand.php
create mode 100644 app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceRequest.php
create mode 100644 app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceResponse.php
create mode 100644 app/Domains/Invoice/Controllers/NewInvoiceController.php
create mode 100644 app/Domains/Invoice/Routes/api.php
create mode 100644 app/Domains/Invoice/Routes/web.php
delete mode 100644 app/Domains/Invoice/Views/CreateInvoice.vue
create mode 100644 app/Domains/Invoice/Views/NewInvoice.vue
create mode 100644 app/Domains/Invoice/Views/Partials/newInvoice/expense-accounting.vue
create mode 100644 app/Domains/Invoice/Views/Partials/newInvoice/refund-data.vue
create mode 100644 app/Domains/Invoice/Views/Partials/newInvoice/travel-expense-accounting.vue
create mode 100644 app/Enumerations/InvoiceStatus.php
create mode 100644 app/Enumerations/InvoiceType.php
create mode 100644 app/Models/Invoice.php
create mode 100644 app/Models/PageText.php
create mode 100644 app/Providers/FlashMessageProvider.php
create mode 100644 app/Providers/UploadFileProvider.php
create mode 100644 app/Repositories/PageTextRepository.php
create mode 100644 app/ValueObjects/InvoiceFile.php
create mode 100644 app/Views/Components/IbanInput.vue
create mode 100644 app/Views/Components/InfoIcon.vue
create mode 100644 app/Views/Components/NumericInput.vue
create mode 100644 app/Views/Components/TextResource.vue
create mode 100644 database/migrations/2026_01_30_140010_create_invoices.php
create mode 100644 database/migrations/2026_01_30_140010_create_page_texts.php
create mode 100644 public/css/invoices.css
create mode 100644 resources/js/components/InvoiceUploadChecks.js
diff --git a/app/Domains/CostUnit/Controllers/CreateController.php b/app/Domains/CostUnit/Controllers/CreateController.php
index 8229fd4..d4c6dbe 100644
--- a/app/Domains/CostUnit/Controllers/CreateController.php
+++ b/app/Domains/CostUnit/Controllers/CreateController.php
@@ -6,6 +6,7 @@ use App\Domains\CostUnit\Actions\CreateCostUnit\CreateCostUnitCommand;
use App\Domains\CostUnit\Actions\CreateCostUnit\CreateCostUnitRequest;
use App\Enumerations\CostUnitType;
use App\Models\CostUnit;
+use App\Providers\FlashMessageProvider;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use App\ValueObjects\Amount;
@@ -23,12 +24,12 @@ class CreateController extends CommonController{
$request->get('cost_unit_name'),
CostUnitType::COST_UNIT_TYPE_RUNNING_JOB,
Amount::fromString($request->get('distance_allowance')),
- $request->get('mail_on_new')
+ $request->get('mailOnNew')
);
$createCostUnitCommand = new CreateCostUnitCommand($createCostUnitRequest);
$result = $createCostUnitCommand->execute();
- session()->put('message', 'Die laufende Tätigkeit wurde erfolgreich angelegt.');
+ new FlashMessageProvider('Die laufende Tätigkeit wurde erfolgreich angelegt.', 'success');
return response()->json([]);
}
diff --git a/app/Domains/CostUnit/Controllers/DistanceAllowanceController.php b/app/Domains/CostUnit/Controllers/DistanceAllowanceController.php
new file mode 100644
index 0000000..f081966
--- /dev/null
+++ b/app/Domains/CostUnit/Controllers/DistanceAllowanceController.php
@@ -0,0 +1,20 @@
+costUnits->getById($costUnitId, true);
+ if (null !== $costUnit) {
+ $distanceAllowance = $costUnit->distance_allowance;
+ }
+
+ return response()->json([
+ 'distanceAllowance' => $distanceAllowance
+ ]);
+ }
+}
diff --git a/app/Domains/CostUnit/Routes/api.php b/app/Domains/CostUnit/Routes/api.php
index bd8b4b5..38e9186 100644
--- a/app/Domains/CostUnit/Routes/api.php
+++ b/app/Domains/CostUnit/Routes/api.php
@@ -1,21 +1,21 @@
group(function () {
Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('cost-unit')->group(function () {
+
+ Route::get('/get-distance-allowance/{costUnitId}', DistanceAllowanceController::class);
+
+
Route::middleware(['auth'])->group(function () {
Route::post('/create-running-job', [CreateController::class, 'createCostUnitRunningJob']);
@@ -41,8 +41,6 @@ Route::prefix('api/v1')
Route::get('/closed-cost-units', [ListController::class, 'listClosedCostUnits']);
Route::get('/archived-cost-units', [ListController::class, 'listArchivedCostUnits']);
});
-
-
});
});
});
diff --git a/app/Domains/CostUnit/Routes/web.php b/app/Domains/CostUnit/Routes/web.php
index ed6271c..715d4c3 100644
--- a/app/Domains/CostUnit/Routes/web.php
+++ b/app/Domains/CostUnit/Routes/web.php
@@ -1,17 +1,13 @@
group(function () {
Route::prefix('cost-unit')->group(function () {
diff --git a/app/Domains/Dashboard/Views/Dashboard.vue b/app/Domains/Dashboard/Views/Dashboard.vue
index 90371d0..ee947b6 100644
--- a/app/Domains/Dashboard/Views/Dashboard.vue
+++ b/app/Domains/Dashboard/Views/Dashboard.vue
@@ -1,7 +1,8 @@
diff --git a/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceCommand.php b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceCommand.php
new file mode 100644
index 0000000..4d527e8
--- /dev/null
+++ b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceCommand.php
@@ -0,0 +1,63 @@
+request = $request;
+ }
+
+ public function execute() : CreateInvoiceResponse {
+ $response = new CreateInvoiceResponse();
+
+ if ($this->request->accountIban === 'undefined') {
+ $this->request->accountIban = null;
+ }
+
+ $invoice = Invoice::create([
+ 'tenant' => app('tenant')->slug,
+ 'cost_unit_id' => $this->request->costUnit->id,
+ 'invoice_number' => $this->generateInvoiceNumber(),
+ 'status' => InvoiceStatus::INVOICE_STATUS_NEW,
+ 'type' => $this->request->invoiceType,
+ 'type_other' => $this->request->invoiceTypeExtended,
+ 'donation' => $this->request->isDonation,
+ 'userId' => $this->request->userId,
+ 'contact_name' => $this->request->contactName,
+ 'contact_email' => $this->request->contactEmail,
+ 'contact_phone' => $this->request->contactPhone,
+ 'contact_bank_owner' => $this->request->accountOwner,
+ 'contact_bank_iban' => $this->request->accountIban,
+ 'amount' => $this->request->totalAmount,
+ 'distance' => $this->request->distance,
+ 'travel_direction' => $this->request->travelRoute,
+ 'passengers' => $this->request->passengers,
+ 'transportation' => $this->request->transportations,
+ 'document_filename' => $this->request->receiptFile !== null ? $this->request->receiptFile->path : null,
+ ]);
+
+ if ($invoice !== null) {
+ $response->success = true;
+ $response->invoice = $invoice;
+ }
+
+ return $response;
+ }
+
+
+ private function generateInvoiceNumber() : string {
+ $lastInvoiceNumber = Invoice::query()
+ ->where('tenant', app('tenant')->slug)
+ ->whereYear('created_at', date('Y'))
+ ->count();
+
+ $invoiceNumber = $lastInvoiceNumber + 1;
+ $invoiceNumber = str_pad($invoiceNumber, 4, '0', STR_PAD_LEFT);
+ return sprintf('%1$s-%2$s', date('Y'), $invoiceNumber);
+ }
+}
diff --git a/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceRequest.php b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceRequest.php
new file mode 100644
index 0000000..27cb211
--- /dev/null
+++ b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceRequest.php
@@ -0,0 +1,64 @@
+costUnit = $costUnit;
+ $this->contactName = $contactName;
+ $this->invoiceType = $invoiceType;
+ $this->invoiceTypeExtended = $invoiceTypeExtended;
+ $this->travelRoute = $travelRoute;
+ $this->distance = $distance;
+ $this->passengers = $passengers;
+ $this->transportations = $transportations;
+ $this->receiptFile = $receiptFile;
+ $this->contactEmail = $contactEmail;
+ $this->contactPhone = $contactPhone;
+ $this->accountOwner = $accountOwner;
+ $this->accountIban = $accountIban;
+ $this->totalAmount = $totalAmount;
+ $this->isDonation = $isDonation;
+ $this->userId = $userId;
+ }
+
+}
diff --git a/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceResponse.php b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceResponse.php
new file mode 100644
index 0000000..357fed9
--- /dev/null
+++ b/app/Domains/Invoice/Actions/CreateInvoice/CreateInvoiceResponse.php
@@ -0,0 +1,15 @@
+success = false;
+ $this->invoice = null;
+ }
+}
diff --git a/app/Domains/Invoice/Controllers/NewInvoiceController.php b/app/Domains/Invoice/Controllers/NewInvoiceController.php
new file mode 100644
index 0000000..d8613a1
--- /dev/null
+++ b/app/Domains/Invoice/Controllers/NewInvoiceController.php
@@ -0,0 +1,138 @@
+users->getCurrentUserDetails();
+
+ $runningJobs = $this->costUnits->getCostUnitsForNewInvoice(CostUnitType::COST_UNIT_TYPE_RUNNING_JOB);
+ $currentEvents = $this->costUnits->getCostUnitsForNewInvoice(CostUnitType::COST_UNIT_TYPE_EVENT);
+
+
+ $inertiaProvider = new InertiaProvider('Invoice/NewInvoice', [
+ 'userName' => $userData['userName'],
+ 'userEmail' => $userData['userEmail'],
+ 'userTelephone' => $userData['userTelephone'],
+ 'userAccountOwner' => $userData['userAccountOwner'],
+ 'userAccountIban' => $userData['userAccountIban'],
+ 'runningJobs' => $runningJobs,
+ 'currentEvents' => $currentEvents,
+ ]);
+ return $inertiaProvider->render();
+ }
+
+ public function saveInvoice(Request $request, int $costUnitId, string $invoiceType) : JsonResponse {
+ $costUnit = $this->costUnits->getById($costUnitId, true);
+ if (null === $costUnit) {
+ return response()->json([
+ 'status' => 'error',
+ 'message' => 'Beim Speichern ist ein Fehler aufgetreten. Bitte starte den Vorgang erneut.'
+ ]);
+ }
+
+ $uploadedFile = null;
+ if (null !== $request->file('receipt')) {
+ $validation = sprintf('%1$s|%2$s|max:%3$s',
+ 'required',
+ 'mimes:pdf',
+ env('MAX_INVOICE_FILE_SIZE', 16)*10
+ );
+
+ $request->validate([
+ 'receipt' => $validation
+ ]);
+
+ $uploadFileProvider = new UploadFileProvider($request->file('receipt'), $costUnit);
+ $uploadedFile = $uploadFileProvider->saveUploadedFile();
+ }
+
+ switch ($invoiceType) {
+ case InvoiceType::INVOICE_TYPE_TRAVELLING:
+
+ if ($uploadedFile !== null) {
+ $amount = Amount::fromString($request->get('amount'))->getAmount();
+ $distance = null;
+ } else {
+ $distance = Amount::fromString($request->get('amount'))->getRoundedAmount();
+ $amount = $distance * $costUnit->distance_allowance;
+
+ }
+
+ $createInvoiceRequest = new CreateInvoiceRequest(
+ $costUnit,
+ $request->get('name'),
+ InvoiceType::INVOICE_TYPE_TRAVELLING,
+ $amount,
+ $uploadedFile,
+ 'donation' === $request->get('decision') ? true : false,
+ $this->users->getCurrentUserDetails()['userId'],
+ $request->get('contactEmail'),
+ $request->get('telephone'),
+ $request->get('accountOwner'),
+ $request->get('accountIban'),
+ null,
+ $request->get('otherText'),
+ $distance,
+ $request->get('havePassengers'),
+ $request->get('materialTransportation'),
+ );
+
+ break;
+
+ default:
+ $createInvoiceRequest = new CreateInvoiceRequest(
+ $costUnit,
+ $request->get('name'),
+ $invoiceType,
+ Amount::fromString($request->get('amount'))->getAmount(),
+ $uploadedFile,
+ 'donation' === $request->get('decision') ? true : false,
+ $this->users->getCurrentUserDetails()['userId'],
+ $request->get('contactEmail'),
+ $request->get('telephone'),
+ $request->get('accountOwner'),
+ $request->get('accountIban'),
+ $request->get('otherText'),
+ null,
+ null,
+ $request->get('havePassengers'),
+ $request->get('materialTransportation'),
+ );
+
+ break;
+ }
+
+ $command = new CreateInvoiceCommand($createInvoiceRequest);
+ $response = $command->execute();
+ if ($response->success) {
+ new FlashMessageProvider(
+ 'Die Abrechnung wurde erfolgreich angelegt.' . PHP_EOL . PHP_EOL . 'Sollten wir Rückfragen haben, melden wir uns bei dir',
+ 'success'
+ );
+
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => 'Alright'
+ ]);
+ }
+
+
+
+ dd($request->all());
+ }
+}
diff --git a/app/Domains/Invoice/Routes/api.php b/app/Domains/Invoice/Routes/api.php
new file mode 100644
index 0000000..ea6710c
--- /dev/null
+++ b/app/Domains/Invoice/Routes/api.php
@@ -0,0 +1,25 @@
+group(function () {
+ Route::prefix('api/v1/invoice')->group(function () {
+ Route::post('/new/{costUnitId}/{invoiceType}', [NewInvoiceController::class, 'saveInvoice']);
+
+
+
+
+
+
+ Route::middleware(['auth'])->group(function () {
+ Route::get('/create', [CreateController::class, 'showForm']);
+ });
+
+
+
+ });
+});
diff --git a/app/Domains/Invoice/Routes/web.php b/app/Domains/Invoice/Routes/web.php
new file mode 100644
index 0000000..4667b70
--- /dev/null
+++ b/app/Domains/Invoice/Routes/web.php
@@ -0,0 +1,22 @@
+group(function () {
+ Route::prefix('invoice')->group(function () {
+ Route::get('/new', NewInvoiceController::class);
+
+
+
+ Route::middleware(['auth'])->group(function () {
+ Route::get('/create', [CreateController::class, 'showForm']);
+ });
+
+
+
+ });
+});
diff --git a/app/Domains/Invoice/Views/CreateInvoice.vue b/app/Domains/Invoice/Views/CreateInvoice.vue
deleted file mode 100644
index 01f0f5f..0000000
--- a/app/Domains/Invoice/Views/CreateInvoice.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
-
- Dashboard Content
- Test 1!
- Hier wird mal eine Rechnung erstellt.
- Wenn es geht oder auch nicht
- {{props.tenant}}
-
-
-
-
-
-
-
-
diff --git a/app/Domains/Invoice/Views/NewInvoice.vue b/app/Domains/Invoice/Views/NewInvoice.vue
new file mode 100644
index 0000000..b75767d
--- /dev/null
+++ b/app/Domains/Invoice/Views/NewInvoice.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Domains/Invoice/Views/Partials/newInvoice/expense-accounting.vue b/app/Domains/Invoice/Views/Partials/newInvoice/expense-accounting.vue
new file mode 100644
index 0000000..d58c907
--- /dev/null
+++ b/app/Domains/Invoice/Views/Partials/newInvoice/expense-accounting.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Domains/Invoice/Views/Partials/newInvoice/refund-data.vue b/app/Domains/Invoice/Views/Partials/newInvoice/refund-data.vue
new file mode 100644
index 0000000..5f42d40
--- /dev/null
+++ b/app/Domains/Invoice/Views/Partials/newInvoice/refund-data.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Domains/Invoice/Views/Partials/newInvoice/travel-expense-accounting.vue b/app/Domains/Invoice/Views/Partials/newInvoice/travel-expense-accounting.vue
new file mode 100644
index 0000000..440d515
--- /dev/null
+++ b/app/Domains/Invoice/Views/Partials/newInvoice/travel-expense-accounting.vue
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Domains/UserManagement/Routes/web.php b/app/Domains/UserManagement/Routes/web.php
index b7ce619..70ed29e 100644
--- a/app/Domains/UserManagement/Routes/web.php
+++ b/app/Domains/UserManagement/Routes/web.php
@@ -6,7 +6,6 @@ use App\Domains\UserManagement\Controllers\LogOutController;
use App\Domains\UserManagement\Controllers\RegistrationController;
use App\Domains\UserManagement\Controllers\ResetPasswordController;
use App\Middleware\IdentifyTenant;
-use App\Providers\GlobalDataProvider;
use Illuminate\Support\Facades\Route;
Route::middleware(IdentifyTenant::class)->group(function () {
@@ -23,11 +22,6 @@ Route::middleware(IdentifyTenant::class)->group(function () {
Route::post('/logout', [LogoutController::class, 'logout']);
});
-
- Route::prefix('api/v1/') ->group(function () {
- Route::get('/retreive-global-data', GlobalDataProvider::class);
- });
-
});
diff --git a/app/Enumerations/InvoiceStatus.php b/app/Enumerations/InvoiceStatus.php
new file mode 100644
index 0000000..b8ab0b7
--- /dev/null
+++ b/app/Enumerations/InvoiceStatus.php
@@ -0,0 +1,20 @@
+installEatingHabits();
$this->installFirstAidPermissions();
$this->installTenants();
+ $this->installInvoiceMetaData();
+
}
private function installUserRoles() {
@@ -68,4 +72,37 @@ class ProductionDataSeeder {
'has_active_instance' => true,
]);
}
+
+ private function installInvoiceMetaData() {
+ InvoiceType::create([
+ 'slug' => InvoiceType::INVOICE_TYPE_TRAVELLING,
+ 'name' => 'Reisekosten'
+ ]);
+
+ InvoiceType::create([
+ 'slug' => InvoiceType::INVOICE_TYPE_PROGRAM,
+ 'name' => 'Programmkosten'
+ ]);
+
+ InvoiceType::create([
+ 'slug' => InvoiceType::INVOICE_TYPE_ACCOMMODATION,
+ 'name' => 'Unterkunftskosten'
+ ]);
+
+ InvoiceType::create([
+ 'slug' => InvoiceType::INVOICE_TYPE_CATERING,
+ 'name' => 'Verpflegungskosten',
+ ]);
+
+ InvoiceType::create([
+ 'slug' => InvoiceType::INVOICE_TYPE_OTHER,
+ 'name' => 'Sonstige Kosten'
+ ]);
+
+ InvoiceStatus::create(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
+ InvoiceStatus::create(['slug' => InvoiceStatus::INVOICE_STATUS_APPROVED]);
+ InvoiceStatus::create(['slug' => InvoiceStatus::INVOICE_STATUS_EXPORTED]);
+ InvoiceStatus::create(['slug' => InvoiceStatus::INVOICE_STATUS_DENIED]);
+ InvoiceStatus::create(['slug' => InvoiceStatus::INVOICE_STAUTS_DELETED]);
+ }
}
diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php
new file mode 100644
index 0000000..4356ebf
--- /dev/null
+++ b/app/Models/Invoice.php
@@ -0,0 +1,68 @@
+firstname, $this->lastname);
+ }
+
public function getFullname() : string {
return sprintf('%1$1s %2$s %3$s',
$this->firstname,
+ $this->lastname,
$this->nickname !== null ? '(' . $this->nickname . ')' : '',
- $this->lastname
- );
+ )
+ |>trim(...);
}
public function getNicename() : string {
diff --git a/app/Providers/AuthCheckProvider.php b/app/Providers/AuthCheckProvider.php
index 4cb38a7..c695f77 100644
--- a/app/Providers/AuthCheckProvider.php
+++ b/app/Providers/AuthCheckProvider.php
@@ -2,6 +2,8 @@
namespace App\Providers;
+use App\Enumerations\UserRole;
+
class AuthCheckProvider {
public function checkLoggedIn() : bool {
if (!auth()->check()) {
@@ -14,8 +16,11 @@ class AuthCheckProvider {
return $user->active;
}
+ if ($user->user_role_main === UserRole::USER_ROLE_ADMIN) {
+ return true;
+ }
- return $user->active && $tenant->slug === $user->tenant;
+ return $user->active && $tenant->slug === $user->local_group;
}
public function getUserRole() : ?string {
diff --git a/app/Providers/FlashMessageProvider.php b/app/Providers/FlashMessageProvider.php
new file mode 100644
index 0000000..487885c
--- /dev/null
+++ b/app/Providers/FlashMessageProvider.php
@@ -0,0 +1,11 @@
+put('message',
+ serialize(['messageType' => $messageType, 'message' => $message])
+ );
+ }
+}
diff --git a/app/Providers/GlobalDataProvider.php b/app/Providers/GlobalDataProvider.php
index 41047a0..d3033e4 100644
--- a/app/Providers/GlobalDataProvider.php
+++ b/app/Providers/GlobalDataProvider.php
@@ -2,9 +2,13 @@
namespace App\Providers;
+use App\Enumerations\InvoiceType;
use App\Enumerations\UserRole;
use App\Models\User;
+use App\Repositories\PageTextRepository;
use App\Resources\UserResource;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
class GlobalDataProvider {
private ?User $user;
@@ -20,6 +24,57 @@ class GlobalDataProvider {
]);
}
+ public function getInvoiceTypes() : JsonResponse {
+ $invoiceTypes = [];
+ foreach (InvoiceType::all() as $invoiceType) {
+ if (
+ $invoiceType->slug === InvoiceType::INVOICE_TYPE_TRAVELLING ||
+ $invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER
+ ) {
+ continue;
+ }
+
+ $invoiceTypes[] = [
+ 'slug' => $invoiceType->slug,
+ 'name' => $invoiceType->name
+ ];
+ }
+
+ $invoiceTypes[] = ['slug' => InvoiceType::INVOICE_TYPE_OTHER, 'name' => 'Sonstige Kosten'];
+
+ return response()->json([
+ 'invoiceTypes' => $invoiceTypes
+ ]);
+ }
+
+ public function getMessages() : JsonResponse {
+ $messageContainer = [
+ 'message' => '',
+ 'type' => ''
+ ];
+
+ $message = session()->get('message');
+
+ if (null !== $message) {
+ $message = session()->get('message');
+ session()->forget('message');
+
+ if('' !== $message ) {
+ $messageContainer = unserialize($message);
+ }
+ }
+
+ return response()->json($messageContainer);
+ }
+
+ public function getTextResourceText(string $textResource) : JsonResponse {
+ $pageTextRepository = new PageTextRepository();
+
+ return response()->json([
+ 'content' => $pageTextRepository->getPageText( $textResource)
+ ]);
+ }
+
private function generateNavbar() : array {
$navigation = [
'personal' => [],
@@ -42,7 +97,7 @@ class GlobalDataProvider {
}
}
- $navigation['common'][] = ['url' => '/capture-invoice', 'display' => 'Neue Abrechnung'];
+ $navigation['common'][] = ['url' => '/invoice/new', 'display' => 'Neue Abrechnung'];
$navigation['common'][] = ['url' => '/available-events', 'display' => 'Verfügbare Veranstaltungen'];
return $navigation;
diff --git a/app/Providers/InertiaProvider.php b/app/Providers/InertiaProvider.php
index 0ae5ebf..69734ad 100644
--- a/app/Providers/InertiaProvider.php
+++ b/app/Providers/InertiaProvider.php
@@ -22,11 +22,6 @@ final class InertiaProvider
}
public function render() : Response {
- if (null !== session()->get('message')) {
- $this->props['message'] = session()->get('message');
- session()->forget('message');
- }
-
$this->props['availableLocalGroups'] = Tenant::where(['is_active_local_group' => true])->get();
return Inertia::render(
diff --git a/app/Providers/UploadFileProvider.php b/app/Providers/UploadFileProvider.php
new file mode 100644
index 0000000..e0b950f
--- /dev/null
+++ b/app/Providers/UploadFileProvider.php
@@ -0,0 +1,52 @@
+file = $file;
+ $this->costUnit = $costUnit;
+ }
+
+ public function saveUploadedFile() : ?InvoiceFile {
+ try {
+ $directory = sprintf(
+ '%1$s/invoices/%2$s',
+ app('tenant')->slug,
+ $this->costUnit->id
+ );
+
+ $filename = $this->normalizeFilename($this->file->getClientOriginalName());
+
+ $path = $this->file->storeAs(
+ $directory,
+ $filename
+ );
+
+ $invoiceFile = new InvoiceFile();
+ $invoiceFile->filename = $filename;
+ $invoiceFile->path = $path;
+ return $invoiceFile;
+ } catch (\Exception $e) {
+ return null;
+ }
+ }
+
+ private function normalizeFilename(string $filename) : string {
+ return strtolower($filename)
+ |> function (string $filename) : string { return str_replace(' ', '_', $filename); }
+ |> function (string $filename) : string { return str_replace('ä', 'ae', $filename); }
+ |> function (string $filename) : string { return str_replace('ö', 'oe', $filename); }
+ |> function (string $filename) : string { return str_replace('ü', 'ue', $filename); }
+ |> function (string $filename) : string { return str_replace('ß', 'ss', $filename); };
+
+ }
+}
diff --git a/app/Repositories/CostUnitRepository.php b/app/Repositories/CostUnitRepository.php
index d3b5fb3..90b6de8 100644
--- a/app/Repositories/CostUnitRepository.php
+++ b/app/Repositories/CostUnitRepository.php
@@ -8,6 +8,17 @@ use App\Models\CostUnit;
use App\Resources\CostUnitResource;
class CostUnitRepository {
+ public function getCostUnitsForNewInvoice(string $type) : array {
+ return $this->getCostUnitsByCriteria([
+ 'allow_new' => true,
+ 'type' => $type,
+ 'archived' => false
+ ], true, true);
+ }
+
+
+
+
public function getCurrentEvents() : array {
return $this->getCostUnitsByCriteria([
'allow_new' => true,
@@ -38,8 +49,8 @@ class CostUnitRepository {
]);
}
- public function getById(int $id) : ?CostUnit {
- $costUnits = self::getCostUnitsByCriteria(['id' => $id], false);
+ public function getById(int $id, bool $disableAccessCheck = false) : ?CostUnit {
+ $costUnits = self::getCostUnitsByCriteria(['id' => $id], false, $disableAccessCheck);
if (count($costUnits) === 0) {
return null;
}
@@ -47,7 +58,7 @@ class CostUnitRepository {
}
- public function getCostUnitsByCriteria(array $criteria, bool $forDisplay = true) : array {
+ public function getCostUnitsByCriteria(array $criteria, bool $forDisplay = true, $disableAccessCheck = false) : array {
$tenant = app('tenant');
$canSeeAll = false;
@@ -71,7 +82,7 @@ class CostUnitRepository {
$visibleCostUnits = [];
/** @var CostUnit $costUnit */
foreach (Costunit::where($criteria)->get() as $costUnit) {
- if ($costUnit->tresurers()->where('user_id', $user->id)->exists() || $canSeeAll) {
+ if ($costUnit->tresurers()->where('user_id', $user->id)->exists() || $canSeeAll || $disableAccessCheck) {
if ($forDisplay) {
$visibleCostUnits[] = new CostUnitResource($costUnit)->toArray(request());
} else {
diff --git a/app/Repositories/PageTextRepository.php b/app/Repositories/PageTextRepository.php
new file mode 100644
index 0000000..dd03841
--- /dev/null
+++ b/app/Repositories/PageTextRepository.php
@@ -0,0 +1,19 @@
+ $name])->first();
+ if (null === $pageText) {
+ PageText::create(['name' => $name, 'content' => '']);
+
+
+ return strtoupper($name);
+ }
+ return $pageText->content;
+ }
+}
diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php
index a96bfb6..48788ae 100644
--- a/app/Repositories/UserRepository.php
+++ b/app/Repositories/UserRepository.php
@@ -17,4 +17,34 @@ class UserRepository {
return $token === $user->activation_token;
}
+
+ public function getCurrentUserDetails() : array {
+ $user = auth()->user();
+
+ $return = [
+ 'userId' => null,
+ 'userName' => '',
+ 'userEmail' => '',
+ 'userTelephone' => '',
+ 'userAccountOwner' => '',
+ 'userAccountIban' => '',
+ ];
+
+ if (null !== auth()->user()) {
+ $return = [
+ 'userId' => $user->id,
+ 'userName' => trim($user->getOfficialName()),
+ 'userEmail' => trim($user->email),
+ 'userTelephone' => trim($user->phone),
+ 'userAccountOwner' => trim($user->bank_account_owner),
+ 'userAccountIban' => trim($user->bank_account_iban),
+ ];
+
+ if ($return['userAccountOwner'] === '') {
+ $return['userAccountOwner'] = $return['userName'];
+ }
+ }
+
+ return $return;
+ }
}
diff --git a/app/Scopes/CommonController.php b/app/Scopes/CommonController.php
index 92efb66..cf04fc5 100644
--- a/app/Scopes/CommonController.php
+++ b/app/Scopes/CommonController.php
@@ -4,15 +4,19 @@ namespace App\Scopes;
use App\Providers\AuthCheckProvider;
use App\Repositories\CostUnitRepository;
+use App\Repositories\PageTextRepository;
use App\Repositories\UserRepository;
abstract class CommonController {
protected UserRepository $users;
protected CostUnitRepository $costUnits;
+ protected PageTextRepository $pageTexts;
+
public function __construct() {
$this->users = new UserRepository();
$this->costUnits = new CostUnitRepository();
+ $this->pageTexts = new PageTextRepository();
}
protected function checkAuth() {
diff --git a/app/ValueObjects/Amount.php b/app/ValueObjects/Amount.php
index 11d6256..411f91d 100644
--- a/app/ValueObjects/Amount.php
+++ b/app/ValueObjects/Amount.php
@@ -22,6 +22,10 @@ class Amount {
return $this->amount;
}
+ public function getRoundedAmount() : int {
+ return round($this->amount);
+ }
+
public function getCurrency() : string {
return $this->currency;
}
diff --git a/app/ValueObjects/InvoiceFile.php b/app/ValueObjects/InvoiceFile.php
new file mode 100644
index 0000000..55b2a3a
--- /dev/null
+++ b/app/ValueObjects/InvoiceFile.php
@@ -0,0 +1,9 @@
+
+const model = defineModel({ type: String, default: '' })
+
+function onInput(e) {
+ let val = e.target.value
+
+ // alles in Großbuchstaben
+ val = val.toUpperCase()
+
+ // nur Buchstaben, Ziffern und Leerzeichen erlauben
+ val = val.replace(/[^A-Z0-9 ]/g, '')
+
+ // ohne Leerzeichen prüfen
+ const compact = val.replace(/\s+/g, '')
+
+ // max 2 Buchstaben + 20 Ziffern
+ const letters = compact.slice(0, 2).replace(/[^A-Z]/g, '')
+ const digits = compact.slice(2).replace(/[^0-9]/g, '').slice(0, 20)
+
+ // neu zusammensetzen (z. B. alle 4 Zeichen ein Leerzeichen für Lesbarkeit)
+ const formatted = (letters + digits).replace(/(.{4})/g, '$1 ').trim()
+
+ model.value = formatted
+}
+
+function onKeypress(e) {
+ const key = e.key
+
+ // immer erlaubt: Leerzeichen
+ if (key === ' ') return
+
+ const compact = model.value.replace(/\s+/g, '')
+
+ if (compact.length < 2) {
+ // in den ersten 2 Stellen nur Buchstaben
+ if (/[A-Za-z]/.test(key)) return
+ e.preventDefault()
+ return
+ }
+
+ // danach nur Ziffern bis 20 erlaubt
+ if (/[0-9]/.test(key) && compact.length < 22) return
+
+ e.preventDefault()
+}
+
+
+
+
+
diff --git a/app/Views/Components/InfoIcon.vue b/app/Views/Components/InfoIcon.vue
new file mode 100644
index 0000000..aca4e99
--- /dev/null
+++ b/app/Views/Components/InfoIcon.vue
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/Components/NumericInput.vue b/app/Views/Components/NumericInput.vue
new file mode 100644
index 0000000..fdcb10e
--- /dev/null
+++ b/app/Views/Components/NumericInput.vue
@@ -0,0 +1,17 @@
+
+
+
+
+ {
+ if (!/[0-9]/.test($event.key)) {
+ $event.preventDefault()
+ }
+ }"
+ />
+
diff --git a/app/Views/Components/TextResource.vue b/app/Views/Components/TextResource.vue
new file mode 100644
index 0000000..cd49ad5
--- /dev/null
+++ b/app/Views/Components/TextResource.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/database/migrations/2026_01_30_140002_create_users_table.php b/database/migrations/2026_01_30_140002_create_users_table.php
index 0a9c1ce..3e14a46 100644
--- a/database/migrations/2026_01_30_140002_create_users_table.php
+++ b/database/migrations/2026_01_30_140002_create_users_table.php
@@ -60,6 +60,7 @@ return new class extends Migration
$table->string('eating_habits')->nullable();
$table->string('swimming_permission')->nullable();
$table->string('first_aid_permission')->nullable();
+ $table->string('bank_account_owner')->nullable();
$table->string('bank_account_iban')->nullable();
$table->string('activation_token')->nullable();
$table->dateTime('activation_token_expires_at')->nullable()->default(date('Y-m-d H:i:s', strtotime('+3 days')));
diff --git a/database/migrations/2026_01_30_140010_create_invoices.php b/database/migrations/2026_01_30_140010_create_invoices.php
new file mode 100644
index 0000000..bc95432
--- /dev/null
+++ b/database/migrations/2026_01_30_140010_create_invoices.php
@@ -0,0 +1,68 @@
+string('slug')->primary();
+ $table->string('name');
+ $table->timestamps();
+ });
+
+ Schema::create('invoice_status', function (Blueprint $table) {
+ $table->string('slug')->primary();
+ $table->timestamps();
+ });
+
+ Schema::create('invoices', function (Blueprint $table) {
+ $table->id();
+ $table->string('tenant');
+ $table->foreignId('cost_unit_id')->constrained('cost_units', 'id')->cascadeOnDelete()->cascadeOnUpdate();
+ $table->string('invoice_number');
+ $table->string('status');
+ $table->string('type');
+ $table->string('type_other')->nullable();
+ $table->boolean('donation')->default(false);
+ $table->foreignId('user_id')->nullable()->constrained('users', 'id')->cascadeOnDelete()->cascadeOnUpdate();
+ $table->string('contact_name');
+ $table->string('contact_email')->nullable();
+ $table->string('contact_phone')->nullable();
+ $table->string('contact_bank_owner')->nullable();
+ $table->string('contact_bank_iban')->nullable();
+ $table->float('amount', 2);
+ $table->integer('distance')->nullable();
+ $table->string('comment')->nullable();
+ $table->string('changes')->nullable();
+ $table->string('travel_direction')->nullable();
+ $table->boolean('passengers')->nullable();
+ $table->boolean('transportation')->nullable();
+ $table->string('document_filename')->nullable();
+ $table->foreignId('approved_by')->nullable()->constrained('users', 'id')->cascadeOnDelete()->cascadeOnUpdate();
+ $table->dateTime('approved_at')->nullable();
+ $table->boolean('upload_required')->default(false);
+ $table->foreignId('denied_by')->nullable()->constrained('users', 'id')->cascadeOnDelete()->cascadeOnUpdate();
+ $table->dateTime('denied_at')->nullable();
+ $table->string('denied_reason')->nullable();
+
+ $table->foreign('tenant')->references('slug')->on('tenants')->cascadeOnDelete()->cascadeOnUpdate();
+ $table->foreign('type')->references('slug')->on('invoice_types')->cascadeOnDelete()->cascadeOnUpdate();
+ $table->foreign('status')->references('slug')->on('invoice_status')->cascadeOnDelete()->cascadeOnUpdate();
+
+ $table->timestamps();
+ });
+
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('invoices');
+ Schema::dropIfExists('invoice_status');
+ Schema::dropIfExists('invoice_types');
+ }
+};
diff --git a/database/migrations/2026_01_30_140010_create_page_texts.php b/database/migrations/2026_01_30_140010_create_page_texts.php
new file mode 100644
index 0000000..39fc8b5
--- /dev/null
+++ b/database/migrations/2026_01_30_140010_create_page_texts.php
@@ -0,0 +1,24 @@
+id();
+ $table->string('name');
+ $table->string('content');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('page_texts');
+ }
+};
diff --git a/public/css/invoices.css b/public/css/invoices.css
new file mode 100644
index 0000000..dcf1bfa
--- /dev/null
+++ b/public/css/invoices.css
@@ -0,0 +1,41 @@
+.invoice-main-flexbox {
+ display: flex;
+ gap: 20px;
+ padding: 20px;
+}
+
+.invoice-main-flexbox div {
+ flex: 1;
+ padding: 10px;
+ border: 1px solid #ccc;
+ border-radius: 10px;
+ background-color: #ffffff;
+ min-height: 200px;
+ box-shadow: 0 0 10px #ccc;
+ cursor: pointer;
+}
+
+.invoice-main-flexbox div:hover {
+ background-color: #FAE39C;
+}
+
+fieldset {
+ background-color: #ffffff;
+ border-color: #ccc;
+ border-width: 1px;
+ border-radius: 10px;
+ margin: 10px 20px;
+ padding: 15px 50px 15px 30px;
+ box-shadow: 0 0 10px #ccc;
+}
+
+fieldset legend {
+ border-left-style: solid;
+ border-left-width: 20px;
+ border-color: #ccc;
+ border-bottom-style: solid;
+ border-bottom-width: 1px;
+ padding: 10px 25px;
+ background-color: #ffffff;
+ border-radius: 10px;
+}
diff --git a/resources/js/components/InvoiceUploadChecks.js b/resources/js/components/InvoiceUploadChecks.js
new file mode 100644
index 0000000..bf5c8e8
--- /dev/null
+++ b/resources/js/components/InvoiceUploadChecks.js
@@ -0,0 +1,23 @@
+export function checkFilesize(fieldId) {
+ const maxFileSize = 64;
+
+
+ if (document.getElementById(fieldId).files[0].size <= maxFileSize * 1024 * 1024) {
+ return true;
+ } else {
+ alert('Die hochzuladende Datei darf die Größe von 64 MB nicht überschreiten');
+ return false;
+ }
+}
+
+export function invoiceCheckContactName() {
+ const contact_name_val = document.getElementById('contact_name').value.trim() !== '';
+ const payment = document.getElementById('confirm_payment');
+
+ if (contact_name_val && document.getElementById('account_owner').value === '') {
+ document.getElementById('account_owner').value = document.getElementById('contact_name').value.trim();
+
+ } else {
+ payment.style.display = 'none';
+ }
+}
diff --git a/resources/js/components/ajaxHandler.js b/resources/js/components/ajaxHandler.js
index a459869..8132a49 100644
--- a/resources/js/components/ajaxHandler.js
+++ b/resources/js/components/ajaxHandler.js
@@ -8,6 +8,8 @@ export function useAjax() {
async function request(url, options = {}) {
loading.value = true
+ const isFormData = options.body instanceof FormData
+
error.value = null
data.value = null
@@ -15,14 +17,19 @@ export function useAjax() {
const response = await fetch(url, {
method: options.method || "GET",
headers: {
- "Content-Type": "application/json",
'X-CSRF-TOKEN': csrfToken,
+ ...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(options.headers || {}),
},
- body: options.body ? JSON.stringify(options.body) : null,
+ body: isFormData
+ ? options.body // ✅ FormData direkt
+ : options.body
+ ? JSON.stringify(options.body)
+ : null,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
+
const result = await response.json()
data.value = result
return result
@@ -35,6 +42,7 @@ export function useAjax() {
}
}
+
async function download(url, options = {}) {
loading.value = true
error.value = null
diff --git a/resources/js/layouts/AppLayout.vue b/resources/js/layouts/AppLayout.vue
index 644116f..c28c1f1 100644
--- a/resources/js/layouts/AppLayout.vue
+++ b/resources/js/layouts/AppLayout.vue
@@ -18,14 +18,26 @@ const globalProps = reactive({
user: null,
currentPath: '/',
errors: {},
- availableLocalGroups: []
+ availableLocalGroups: [],
+ message: ''
});
-
onMounted(async () => {
- const response = await fetch('/api/v1/retreive-global-data');
+ const response = await fetch('/api/v1/core/retrieve-global-data');
const data = await response.json();
Object.assign(globalProps, data);
+
+ const messageResponse = await request('/api/v1/core/retrieve-messages', {
+ method: 'GET',
+ })
+
+ if (messageResponse.message !== '') {
+ if (messageResponse.messageType === 'success') {
+ toast.success(messageResponse.message)
+ } else {
+ toast.error(messageResponse.message)
+ }
+ }
});
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php
index 795dbc7..7058306 100644
--- a/resources/views/app.blade.php
+++ b/resources/views/app.blade.php
@@ -6,6 +6,7 @@
+
diff --git a/routes/web.php b/routes/web.php
index 95052ec..389c5a7 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -3,16 +3,32 @@
use App\Domains\Dashboard\Controllers\DashboardController;
use App\Http\Controllers\TestRenderInertiaProvider;
use App\Middleware\IdentifyTenant;
+use App\Providers\GlobalDataProvider;
use Illuminate\Support\Facades\Route;
require_once __DIR__ . '/../app/Domains/UserManagement/Routes/web.php';
require_once __DIR__ . '/../app/Domains/CostUnit/Routes/web.php';
require_once __DIR__ . '/../app/Domains/CostUnit/Routes/api.php';
+require_once __DIR__ . '/../app/Domains/Invoice/Routes/web.php';
+require_once __DIR__ . '/../app/Domains/Invoice/Routes/api.php';
+
+
+
Route::middleware(IdentifyTenant::class)->group(function () {
Route::get('/', DashboardController::class);
+ Route::prefix('api/v1/core') ->group(function () {
+ Route::get('/retrieve-global-data', GlobalDataProvider::class);
+ Route::get('/retrieve-text-resource/{textResource}', [GlobalDataProvider::class, 'getTextResourceText']);
+ Route::get('/retrieve-messages', [GlobalDataProvider::class, 'getMessages']);
+ Route::get('/retrieve-invoice-types', [GlobalDataProvider::class, 'getInvoiceTypes']);
+
+ });
+
+
+
Route::middleware(['auth'])->group(function () {