Invoices can be uploaded

This commit is contained in:
2026-02-11 15:40:06 +01:00
parent bccfc11687
commit ee7fc637f1
47 changed files with 1751 additions and 67 deletions

View File

@@ -6,6 +6,7 @@ use App\Domains\CostUnit\Actions\CreateCostUnit\CreateCostUnitCommand;
use App\Domains\CostUnit\Actions\CreateCostUnit\CreateCostUnitRequest; use App\Domains\CostUnit\Actions\CreateCostUnit\CreateCostUnitRequest;
use App\Enumerations\CostUnitType; use App\Enumerations\CostUnitType;
use App\Models\CostUnit; use App\Models\CostUnit;
use App\Providers\FlashMessageProvider;
use App\Providers\InertiaProvider; use App\Providers\InertiaProvider;
use App\Scopes\CommonController; use App\Scopes\CommonController;
use App\ValueObjects\Amount; use App\ValueObjects\Amount;
@@ -23,12 +24,12 @@ class CreateController extends CommonController{
$request->get('cost_unit_name'), $request->get('cost_unit_name'),
CostUnitType::COST_UNIT_TYPE_RUNNING_JOB, CostUnitType::COST_UNIT_TYPE_RUNNING_JOB,
Amount::fromString($request->get('distance_allowance')), Amount::fromString($request->get('distance_allowance')),
$request->get('mail_on_new') $request->get('mailOnNew')
); );
$createCostUnitCommand = new CreateCostUnitCommand($createCostUnitRequest); $createCostUnitCommand = new CreateCostUnitCommand($createCostUnitRequest);
$result = $createCostUnitCommand->execute(); $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([]); return response()->json([]);
} }

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\CostUnit\Controllers;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
class DistanceAllowanceController extends CommonController {
public function __invoke(int $costUnitId) : JsonResponse {
$distanceAllowance = 0.00;
$costUnit = $this->costUnits->getById($costUnitId, true);
if (null !== $costUnit) {
$distanceAllowance = $costUnit->distance_allowance;
}
return response()->json([
'distanceAllowance' => $distanceAllowance
]);
}
}

View File

@@ -1,21 +1,21 @@
<?php <?php
use App\Domains\CostUnit\Controllers\ChangeStateController; use App\Domains\CostUnit\Controllers\ChangeStateController;
use App\Domains\CostUnit\Controllers\CreateController; use App\Domains\CostUnit\Controllers\CreateController;
use App\Domains\CostUnit\Controllers\DistanceAllowanceController;
use App\Domains\CostUnit\Controllers\EditController; use App\Domains\CostUnit\Controllers\EditController;
use App\Domains\CostUnit\Controllers\ListController; use App\Domains\CostUnit\Controllers\ListController;
use App\Domains\CostUnit\Controllers\TreasurersEditController; use App\Domains\CostUnit\Controllers\TreasurersEditController;
use App\Domains\UserManagement\Controllers\EmailVerificationController;
use App\Domains\UserManagement\Controllers\RegistrationController;
use App\Domains\UserManagement\Controllers\ResetPasswordController;
use App\Middleware\IdentifyTenant; use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::prefix('api/v1') Route::prefix('api/v1')
->group(function () { ->group(function () {
Route::middleware(IdentifyTenant::class)->group(function () { Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('cost-unit')->group(function () { Route::prefix('cost-unit')->group(function () {
Route::get('/get-distance-allowance/{costUnitId}', DistanceAllowanceController::class);
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
Route::post('/create-running-job', [CreateController::class, 'createCostUnitRunningJob']); 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('/closed-cost-units', [ListController::class, 'listClosedCostUnits']);
Route::get('/archived-cost-units', [ListController::class, 'listArchivedCostUnits']); Route::get('/archived-cost-units', [ListController::class, 'listArchivedCostUnits']);
}); });
}); });
}); });
}); });

View File

@@ -1,17 +1,13 @@
<?php <?php
use App\Domains\CostUnit\Controllers\CreateController; use App\Domains\CostUnit\Controllers\CreateController;
use App\Domains\CostUnit\Controllers\ListController; use App\Domains\CostUnit\Controllers\ListController;
use App\Domains\Dashboard\Controllers\DashboardController;
use App\Domains\UserManagement\Controllers\EmailVerificationController; use App\Domains\UserManagement\Controllers\EmailVerificationController;
use App\Domains\UserManagement\Controllers\LoginController; use App\Domains\UserManagement\Controllers\LoginController;
use App\Domains\UserManagement\Controllers\LogOutController; use App\Domains\UserManagement\Controllers\LogOutController;
use App\Domains\UserManagement\Controllers\RegistrationController; use App\Domains\UserManagement\Controllers\RegistrationController;
use App\Domains\UserManagement\Controllers\ResetPasswordController; use App\Domains\UserManagement\Controllers\ResetPasswordController;
use App\Http\Controllers\TestRenderInertiaProvider;
use App\Middleware\IdentifyTenant; use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::middleware(IdentifyTenant::class)->group(function () { Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('cost-unit')->group(function () { Route::prefix('cost-unit')->group(function () {

View File

@@ -1,7 +1,8 @@
<script setup> <script setup>
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue' import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue"; import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import {onMounted} from "vue";
import {toast} from "vue3-toastify";
</script> </script>
<template> <template>

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Domains\Invoice\Actions\CreateInvoice;
use App\Enumerations\InvoiceStatus;
use App\Models\Invoice;
class CreateInvoiceCommand {
private CreateInvoiceRequest $request;
public function __construct(CreateInvoiceRequest $request) {
$this->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);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Domains\Invoice\Actions\CreateInvoice;
use App\Models\CostUnit;
use App\ValueObjects\InvoiceFile;
class CreateInvoiceRequest {
public CostUnit $costUnit;
public string $contactName;
public ?string $contactEmail;
public ?string $contactPhone;
public ?string $accountOwner;
public ?string $accountIban;
public string $invoiceType;
public ?string $invoiceTypeExtended;
public ?string $travelRoute;
public ?int $distance;
public ?int $passengers;
public ?int $transportations;
public ?InvoiceFile $receiptFile;
public float $totalAmount;
public bool $isDonation;
public ?int $userId;
public function __construct(
CostUnit $costUnit,
string $contactName,
string $invoiceType,
float $totalAmount,
?InvoiceFile $receiptFile,
bool $isDonation,
?int $userId = null,
?string $contactEmail = null,
?string $contactPhone = null,
?string $accountOwner = null,
?string $accountIban = null,
?string $invoiceTypeExtended = null,
?string $travelRoute = null,
?int $distance = null,
?int $passengers = null,
?int $transportations,
) {
$this->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;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Invoice\Actions\CreateInvoice;
use App\Models\Invoice;
class CreateInvoiceResponse {
public bool $success;
public ?Invoice $invoice;
public function __construct() {
$this->success = false;
$this->invoice = null;
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Domains\Invoice\Controllers;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceCommand;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\InvoiceType;
use App\Models\CostUnit;
use App\Providers\FlashMessageProvider;
use App\Providers\InertiaProvider;
use App\Providers\UploadFileProvider;
use App\Scopes\CommonController;
use App\ValueObjects\Amount;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NewInvoiceController extends CommonController {
public function __invoke() {
$userData = $this->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());
}
}

View File

@@ -0,0 +1,25 @@
<?php
use App\Domains\CostUnit\Controllers\DistanceAllowanceController;
use App\Domains\CostUnit\Controllers\ListController;
use App\Domains\Invoice\Controllers\NewInvoiceController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::middleware(IdentifyTenant::class)->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']);
});
});
});

View File

@@ -0,0 +1,22 @@
<?php
use App\Domains\CostUnit\Controllers\CreateController;
use App\Domains\CostUnit\Controllers\ListController;
use App\Domains\Invoice\Controllers\NewInvoiceController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('invoice')->group(function () {
Route::get('/new', NewInvoiceController::class);
Route::middleware(['auth'])->group(function () {
Route::get('/create', [CreateController::class, 'showForm']);
});
});
});

View File

@@ -1,29 +0,0 @@
<script setup>
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
const props = defineProps({
navbar: Object,
tenant: String,
user: Object,
currentPath: String,
})
</script>
<template>
<AppLayout title='Neue Abrechnung' :user="props.user" :navbar="props.navbar" :tenant="props.tenant" :currentPath="props.currentPath">
<!-- Alles hier wird in den Slot von AppLayout eingefügt -->
<h2>Dashboard Content</h2>
<p>Test 1!
Hier wird mal eine Rechnung erstellt.
Wenn es geht oder auch nicht</p>
{{props.tenant}}
<button @click="$toast.success('Hallo vom Dashboard!')">Test Toaster</button>
<button @click="$toast.error('Soe sieht ein Fehler aus')">Error Toaster</button>
</AppLayout>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,79 @@
<script setup>
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
import TextResource from "../../../Views/Components/TextResource.vue";
import Modal from "../../../Views/Components/Modal.vue";
import {onMounted, reactive, ref} from "vue";
import ExpenseAccounting from "./Partials/newInvoice/expense-accounting.vue";
import TravelExpenseAccounting from "./Partials/newInvoice/travel-expense-accounting.vue";
const props = defineProps({
currentEvents: Object,
runningJobs: Object,
userId: Number,
userName: String,
userEmail: String,
userTelephone: String,
userIban: String,
userAccountOwner: String,
})
const isOpen = ref(false)
const eventId = ref(0);
const invoiceType = ref('');
</script>
<template>
<AppLayout title='Neue Abrechnung'>
<div class="invoice-main-flexbox" v-if="eventId === 0">
<div
class="invoice-type-layer"
@click="isOpen = true;invoiceType='expense-accounting'"
>
<TextResource textName="NEW_COMMON_COST_EXPENSE_DESCRIPTION" />
</div>
<div
class="invoice-type-layer"
@click="isOpen = true;invoiceType='travel-expense-accounting'"
>
<TextResource textName="NEW_TRAVEL_COST_EXPENSE_DESCRIPTION" />
</div>
</div>
<ExpenseAccounting v-if="invoiceType === 'expense-accounting' && eventId !== 0"
:eventId="eventId"
:userName="props.userName"
:userEmail="props.userEmail"
:userTelephone="props.userTelephone"
:userIban="props.userIban"
:userAccountOwner="props.userAccountOwner"
:userId="props.userId"
/>
<TravelExpenseAccounting v-else-if="invoiceType === 'travel-expense-accounting' && eventId !== 0"
:eventId="eventId"
:userName="props.userName"
:userEmail="props.userEmail"
:userTelephone="props.userTelephone"
:userIban="props.userIban"
:userAccountOwner="props.userAccountOwner"
:userId="props.userId"
/>
<Modal :show="isOpen" title="Veranstaltung auswählen" @close="isOpen = false">
<select v-model="eventId" @change="isOpen=false" style="width: 100%">
<option value="0" disabled>Bitte auswählen</option>
<optgroup label="Laufende Tätigkeiten">
<option :value="event.id" v-for="event in props.runningJobs">{{ event.name }}</option>
</optgroup>
<optgroup label="Veranstaltungen">
<option :value="event.id" v-for="event in props.currentEvents">{{ event.name }}</option>
</optgroup>
</select>
</Modal>
</AppLayout>
</template>

View File

@@ -0,0 +1,124 @@
<script setup>
import { ref, onMounted, reactive } from 'vue'
import {checkFilesize} from "../../../../../../resources/js/components/InvoiceUploadChecks.js";
import RefundData from "./refund-data.vue";
import AmountInput from "../../../../../Views/Components/AmountInput.vue";
import {useAjax} from "../../../../../../resources/js/components/ajaxHandler.js";
import InfoIcon from '../../../../../Views/Components/InfoIcon.vue'
import {toast} from "vue3-toastify";
const data = defineProps({
eventId: Number,
userName: String,
userEmail: String,
userTelephone: String,
userIban: String,
userAccountOwner: String,
})
const { request } = useAjax();
const amount = ref(0.00);
const invoiceType = ref(null);
const otherText = ref('');
const receipt = ref(null)
const finalStep = ref(false)
const invoiceTypeCollection = reactive({
invoiceTypes: {}
});
onMounted(async () => {
const response = await fetch('/api/v1/core/retrieve-invoice-types');
const data = await response.json();
Object.assign(invoiceTypeCollection, data);
});
function handleFileChange(event) {
if (checkFilesize('receipt')) {
receipt.value = event.target.files[0]
finalStep.value = true
} else {
event.target.value = null
}
}
</script>
<template>
<fieldset>
<legend><span style="font-weight: bolder;">Wofür hast du den Betrag ausgegeben</span></legend>
<p v-for="availableInvoiceType in invoiceTypeCollection.invoiceTypes">
<input
name="invpice_type"
type="radio"
:value="availableInvoiceType.slug"
:id="'invoice_type_' + availableInvoiceType.slug"
v-model="invoiceType"
>
<label :for="'invoice_type_' + availableInvoiceType.slug">{{ availableInvoiceType.name }}</label>
<InfoIcon :text="'INFO_INVOICE_TYPE_' + availableInvoiceType.slug" /><br />
</p>
<label for="invoice_type_other">
<input
type="text"
class="width-full"
name="kostengruppe_sonstiges"
placeholder="Sonstige"
for="invoice_type_other"
v-model="otherText"
@focus="invoiceType = 'other'"
/>
</label>
</fieldset><br /><br />
<fieldset>
<legend><span style="font-weight: bolder;">Wie hoch ist der Betrag</span></legend>
<AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro
<info-icon></info-icon><br /><br />
<input
v-if="amount != '' && invoiceType !== null"
class="mareike-button"
onclick="document.getElementById('receipt').click();"
type="button"
value="Beleg auswählen und fortfahren" />
<input accept="application/pdf" type="file" id="receipt" name="receipt" @change="handleFileChange"
style="display: none"/>
</fieldset><br />
<RefundData
v-if="finalStep"
:eventId="data.eventId"
:invoice-type="invoiceType"
:amount="amount"
:other-text="otherText"
:userName="data.userName"
:userEmail="data.userEmail"
:userTelephone="data.userTelephone"
:userIban="data.userIban"
:userAccountOwner="data.userAccountOwner"
:receipt="receipt"
@close="finalStep = false"
/>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,157 @@
<script setup>
import { ref } from 'vue'
import Modal from "../../../../../Views/Components/Modal.vue";
import IbanInput from "../../../../../Views/Components/IbanInput.vue";
import {useAjax} from "../../../../../../resources/js/components/ajaxHandler.js";
import TextResource from "../../../../../Views/Components/TextResource.vue";
import {invoiceCheckContactName} from "../../../../../../resources/js/components/InvoiceUploadChecks.js";
import {toast} from "vue3-toastify";
const { request } = useAjax();
const emit = defineEmits(['close'])
const props = defineProps({
eventId: Number,
invoiceType: String,
amount: [String, Number],
otherText: String,
receipt: File,
userName: String,
userEmail: String,
userTelephone: String,
userAccountOwner: String,
userIban: String,
havePassengers: Number,
materialTransportation: Boolean,
})
console.log(props.receipt)
const finalStep = ref(true)
const userName = ref(props.userName)
const userEmail = ref(props.userEmail)
const userTelephone = ref(props.userTelephone)
const userIban = ref(props.userIban)
const userAccountOwner = ref(props.userAccountOwner)
const sending = ref(false)
const success = ref(false)
const decision = ref('')
const errorMsg = ref('')
const confirmation = ref(null)
async function sendData() {
if (!userName.value) return
sending.value = true
errorMsg.value = ''
success.value = false
const formData = new FormData()
formData.append('name', userName.value)
formData.append('email', userEmail.value)
formData.append('telephone', userTelephone.value)
formData.append('amount', props.amount)
formData.append('otherText', props.otherText)
formData.append('decision', decision.value)
formData.append('accountOwner', userAccountOwner.value)
formData.append('accountIban', userIban.value)
formData.append('havePassengers', props.havePassengers ? 1 : 0)
formData.append('materialTransportation', props.materialTransportation ? 1 : 0)
if (props.receipt) {
formData.append('receipt', props.receipt)
}
try {
const response = await request('/api/v1/invoice/new/' + props.eventId + '/' + props.invoiceType, {
method: 'POST',
body: formData
})
if (response.status === 'success') {
window.location.href = '/';
}
} catch (err) {
toast.error(result.message);
} finally {
sending.value = false
}
}
</script>
<template>
<Modal :show="finalStep" title='Bitte gib deine Daten ein' @close="emit('close')">
<label>
<strong>Dein Name Name (kein Pfadiname):</strong>
</label><br />
<input
type="text"
@keyup="invoiceCheckContactName();"
id="contact_name"
name="contact_name" v-model="userName"
style="font-size: 14pt; width: 550px;" /><br /><br />
<label>
<strong>E-Mail-Adresse (Für Rückfragen):</strong>
</label><br />
<input
type="email"
name="contact_email"
v-model="userEmail"
style="font-size: 14pt; width: 550px;" /><br /><br />
<label>
<strong>Telefonnummer (für Rückfragen):</strong>
</label><br />
<input
type="text"
id="contact_telephone"
name="contact_telephone" v-model="userTelephone"
style="font-size: 14pt; width: 550px;" /><br /><br />
<span id="decision" v-if="userName !== '' && decision === ''">
<label><br />
<strong>Möchtest du den Betrag spenden?</strong>
</label><br />
<input type="button" style="border-radius: 0; width: 100px;" @click="decision='donation'" value="Ja" />
<input type="button" style="border-radius: 0; width: 100px;" @click="decision='payout'" value="Nein" />
</span>
<span id="confirm_donation" v-if="decision === 'donation'">
<input type="radio" name="confirmation_radio" value="donation" id="confirmation_radio_donation" v-model="confirmation">
<TextResource belongsTo="confirmation_radio_donation" textName="CONFIRMATION_DONATE" />
<br /><br />
<input type="button" class="mareike-button" v-if="confirmation !== null && !sending" @click="sendData" value="Beleg einreichen" />
</span>
<span id="confirm_payment" v-if="decision === 'payout'">
<label>
<strong>Konto-Inhaber*in:</strong></label><br />
<input type="text" name="account_owner" id="account_owner" v-model="userAccountOwner" style="font-size: 14pt; width: 550px;" /><br /><br />
<label>
<strong>IBAN:</strong>
</label><br />
<IbanInput id="account_iban" name="account_iban" v-model="userIban" style="font-size: 14pt; width: 550px;" /><br /><br />
<span v-if="userAccountOwner != '' && userIban && userIban.length === 27"><br />
<input type="radio" name="confirmation_radio" value="payment" id="confirmation_radio_payment" v-model="confirmation">
<TextResource belongsTo="confirmation_radio_payment" textName="CONFIRMATION_PAYMENT" /><br /><br />
<input type="button" v-if="confirmation !== null && !sending" @click="sendData" value="Beleg einreichen" />
</span>
</span>
</Modal>
</template>
<style scoped>
/* optional styling */
</style>

View File

@@ -0,0 +1,158 @@
<script setup>
import { ref, onMounted, reactive } from 'vue'
import {checkFilesize} from "../../../../../../resources/js/components/InvoiceUploadChecks.js";
import RefundData from "./refund-data.vue";
import NumericInput from "../../../../../Views/Components/NumericInput.vue";
import AmountInput from "../../../../../Views/Components/AmountInput.vue";
import {useAjax} from "../../../../../../resources/js/components/ajaxHandler.js";
const data = defineProps({
eventId: Number,
userName: String,
userEmail: String,
userTelephone: String,
userIban: String,
userAccountOwner: String,
})
const { request } = useAjax();
const distanceAllowance = ref(null);
const travelDirection = ref(null);
const have_receipt = ref('')
const havePassengers = ref(false);
const materialTransportation = ref(false);
const amount = ref(0.00);
const invoiceType = ref(null);
const otherText = ref('');
const receipt = ref(null)
const finalStep = ref(false)
async function getDistanceAllowance() {
const tempData = await request('/api/v1/cost-unit/get-distance-allowance/' + data.eventId, {
method: "GET",
});
distanceAllowance.value = tempData.distanceAllowance;
have_receipt.value = 'no';
}
function handleFileChange(event) {
if (checkFilesize('receipt')) {
receipt.value = event.target.files[0]
finalStep.value = true
} else {
event.target.value = null
}
}
</script>
<template>
<fieldset>
<legend><span style="font-weight: bolder;">Gib deine Reisesetrecke an</span></legend>
<input
type="text"
name="travel-direction"
v-model="travelDirection"
/>
</fieldset><br /><br />
<fieldset v-if="travelDirection !== null">
<legend><span style="font-weight: bolder;">Bist du mit dem ÖPNV gefahren oder besitzt du einen Beleg</span></legend>
<input type="button" style="border-radius: 0; width: 100px;" @click="have_receipt='yes'" value="Ja" />
<input type="button" style="border-radius: 0; width: 100px;" @click="getDistanceAllowance" value="Nein" />
</fieldset>
<br /><br />
<fieldset v-if="have_receipt === 'yes'">
<legend><span style="font-weight: bolder;">Wie hoch ist der Betrag?</span></legend>
<AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro
<br /><br />
<input type="button"
v-if="amount !== null && have_receipt === 'yes' && amount != '0'"
value="Beleg auswählen und fortfahren"
onclick="document.getElementById('receipt').click();"
/>
<input accept="application/pdf" type="file" id="receipt" name="receipt" @change="handleFileChange"
style="display: none"/>
<RefundData
v-if="finalStep && have_receipt === 'yes'"
:eventId="data.eventId"
invoice-type="travelling"
:amount="amount"
:other-text="travelDirection"
:materialTransportation="materialTransportation"
:havePassengers="havePassengers"
:userName="data.userName"
:userEmail="data.userEmail"
:userTelephone="data.userTelephone"
:userIban="data.userIban"
:userAccountOwner="data.userAccountOwner"
:receipt="receipt"
@close="finalStep = false"
/>
</fieldset>
<fieldset v-else-if="distanceAllowance != null">
<legend><span style="font-weight: bolder;">Reiseinformationen</span></legend>
Gesamtlänge des Reisewegs:
<NumericInput
class="width-small"
name="total_distance"
v-model="amount"
/> km<br />
<span style="font-weight: normal">({{ amount }} km x {{distanceAllowance.toFixed(2).replace('.', ',')}} Euro / km = <strong>{{ (amount * distanceAllowance).toFixed(2).replace('.', ',') }} Euro</strong>)</span>
<br /><br />
<input
type="checkbox"
name="havePassengers"
v-model="havePassengers"
id="havePassengers"
/> <label style="margin-bottom: 20px;" for="havePassengers">Ich habe Personen mitgenommen</label>
<br />
<input
type="checkbox"
name="materialTransportation"
v-model="materialTransportation"
id="materialTransportation"
/> <label style="margin-bottom: 20px;" for="materialTransportation">Ich habe Material transportiert</label>
<br /><br />
<input
v-if="amount !== null && have_receipt === 'no' && amount != '0'"
@click="finalStep = true;"
type="button"
value="Pauschalbetrag abrechnen" />
<RefundData
v-if="finalStep && have_receipt === 'no'"
:eventId="data.eventId"
invoice-type="travelling"
:amount="amount"
:other-text="travelDirection"
:materialTransportation="materialTransportation"
:havePassengers="havePassengers"
:userName="data.userName"
:userEmail="data.userEmail"
:userTelephone="data.userTelephone"
:userIban="data.userIban"
:userAccountOwner="data.userAccountOwner"
@close="finalStep = false"
/>
</fieldset>
</template>
<style scoped>
</style>

View File

@@ -6,7 +6,6 @@ use App\Domains\UserManagement\Controllers\LogOutController;
use App\Domains\UserManagement\Controllers\RegistrationController; use App\Domains\UserManagement\Controllers\RegistrationController;
use App\Domains\UserManagement\Controllers\ResetPasswordController; use App\Domains\UserManagement\Controllers\ResetPasswordController;
use App\Middleware\IdentifyTenant; use App\Middleware\IdentifyTenant;
use App\Providers\GlobalDataProvider;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(IdentifyTenant::class)->group(function () { Route::middleware(IdentifyTenant::class)->group(function () {
@@ -23,11 +22,6 @@ Route::middleware(IdentifyTenant::class)->group(function () {
Route::post('/logout', [LogoutController::class, 'logout']); Route::post('/logout', [LogoutController::class, 'logout']);
}); });
Route::prefix('api/v1/') ->group(function () {
Route::get('/retreive-global-data', GlobalDataProvider::class);
});
}); });

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Enumerations;
use App\Scopes\CommonModel;
class InvoiceStatus extends CommonModel
{
public const INVOICE_STATUS_NEW = 'new';
public const INVOICE_STATUS_APPROVED = 'approved';
public const INVOICE_STATUS_DENIED = 'denied';
public const INVOICE_STATUS_EXPORTED = 'exported';
public const INVOICE_STAUTS_DELETED = 'deleted';
protected $table = 'invoice_status';
protected $fillable = [
'slug',
];
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Enumerations;
use App\Scopes\CommonModel;
class InvoiceType extends CommonModel {
public const INVOICE_TYPE_TRAVELLING = 'travelling';
public const INVOICE_TYPE_PROGRAM = 'program';
public const INVOICE_TYPE_OTHER = 'other';
public const INVOICE_TYPE_ACCOMMODATION = 'accommodation';
public const INVOICE_TYPE_CATERING = 'catering';
protected $fillable = [
'slug',
'name',
];
}

View File

@@ -5,6 +5,8 @@ namespace App\Installer;
use App\Enumerations\CostUnitType; use App\Enumerations\CostUnitType;
use App\Enumerations\EatingHabit; use App\Enumerations\EatingHabit;
use App\Enumerations\FirstAidPermission; use App\Enumerations\FirstAidPermission;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType;
use App\Enumerations\SwimmingPermission; use App\Enumerations\SwimmingPermission;
use App\Enumerations\UserRole; use App\Enumerations\UserRole;
use App\Models\Tenant; use App\Models\Tenant;
@@ -17,6 +19,8 @@ class ProductionDataSeeder {
$this->installEatingHabits(); $this->installEatingHabits();
$this->installFirstAidPermissions(); $this->installFirstAidPermissions();
$this->installTenants(); $this->installTenants();
$this->installInvoiceMetaData();
} }
private function installUserRoles() { private function installUserRoles() {
@@ -68,4 +72,37 @@ class ProductionDataSeeder {
'has_active_instance' => true, '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]);
}
} }

68
app/Models/Invoice.php Normal file
View File

@@ -0,0 +1,68 @@
<?php
namespace App\Models;
use App\Scopes\InstancedModel;
/**
* @property string $tenant
* @property string $cost_unit_id
* @property string $invoice_number
* @property string $status
* @property string $type
* @property string $type_other
* @property boolean $donation
* @property string $user_id
* @property string $contact_name
* @property string $contact_email
* @property string $contact_phone
* @property string $contact_bank_owner
* @property string $contact_bank_iban
* @property float $amount
* @property integer $distance
* @property string $comment
* @property string $changes
* @property string $travel_direction
* @property boolean $passengers
* @property boolean $transportation
* @property string $document_filename
* @property string $approved_by
* @property string $approved_at
* @property boolean $upload_required
* @property string $denied_by
* @property string $denied_at
* @property string $denied_reason
*/
class Invoice extends InstancedModel
{
protected $fillable = [
'tenant',
'cost_unit_id',
'invoice_number',
'status',
'type',
'type_other',
'donation',
'user_id',
'contact_name',
'contact_email',
'contact_phone',
'contact_bank_owner',
'contact_bank_iban',
'amount',
'donation',
'distance',
'comment',
'changes',
'travel_direction',
'passengers',
'transportation',
'document_filename',
'approved_by',
'approved_at',
'upload_required',
'denied_by',
'denied_at',
'denied_reason',
];
}

9
app/Models/PageText.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
namespace App\Models;
use App\Scopes\CommonModel;
class PageText extends CommonModel{
protected $fillable = ['name', 'content'];
}

View File

@@ -7,6 +7,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
/** /**
* @property int $id
* @property string $username * @property string $username
* @property string $local_group * @property string $local_group
* @property string $firstname * @property string $firstname
@@ -26,6 +27,7 @@ use Illuminate\Notifications\Notifiable;
* @property string $eating_habits * @property string $eating_habits
* @property string $swimming_permission * @property string $swimming_permission
* @property string $first_aid_permission * @property string $first_aid_permission
* @property string $bank_account_owner
* @property string $bank_account_iban * @property string $bank_account_iban
* @property string $password * @property string $password
* @property boolean $active * @property boolean $active
@@ -67,6 +69,7 @@ class User extends Authenticatable
'eating_habits', 'eating_habits',
'swimming_permission', 'swimming_permission',
'first_aid_permission', 'first_aid_permission',
'$bank_account_owner',
'bank_account_iban', 'bank_account_iban',
'password', 'password',
'active', 'active',
@@ -93,12 +96,17 @@ class User extends Authenticatable
]; ];
} }
public function getOfficialName() : string {
return sprintf('%1$s %2$s', $this->firstname, $this->lastname);
}
public function getFullname() : string { public function getFullname() : string {
return sprintf('%1$1s %2$s %3$s', return sprintf('%1$1s %2$s %3$s',
$this->firstname, $this->firstname,
$this->lastname,
$this->nickname !== null ? '(' . $this->nickname . ')' : '', $this->nickname !== null ? '(' . $this->nickname . ')' : '',
$this->lastname )
); |>trim(...);
} }
public function getNicename() : string { public function getNicename() : string {

View File

@@ -2,6 +2,8 @@
namespace App\Providers; namespace App\Providers;
use App\Enumerations\UserRole;
class AuthCheckProvider { class AuthCheckProvider {
public function checkLoggedIn() : bool { public function checkLoggedIn() : bool {
if (!auth()->check()) { if (!auth()->check()) {
@@ -14,8 +16,11 @@ class AuthCheckProvider {
return $user->active; 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 { public function getUserRole() : ?string {

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Providers;
class FlashMessageProvider {
public function __construct(string $message, string $messageType) {
session()->put('message',
serialize(['messageType' => $messageType, 'message' => $message])
);
}
}

View File

@@ -2,9 +2,13 @@
namespace App\Providers; namespace App\Providers;
use App\Enumerations\InvoiceType;
use App\Enumerations\UserRole; use App\Enumerations\UserRole;
use App\Models\User; use App\Models\User;
use App\Repositories\PageTextRepository;
use App\Resources\UserResource; use App\Resources\UserResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GlobalDataProvider { class GlobalDataProvider {
private ?User $user; 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 { private function generateNavbar() : array {
$navigation = [ $navigation = [
'personal' => [], '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']; $navigation['common'][] = ['url' => '/available-events', 'display' => 'Verfügbare Veranstaltungen'];
return $navigation; return $navigation;

View File

@@ -22,11 +22,6 @@ final class InertiaProvider
} }
public function render() : Response { 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(); $this->props['availableLocalGroups'] = Tenant::where(['is_active_local_group' => true])->get();
return Inertia::render( return Inertia::render(

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Providers;
use App\Models\CostUnit;
use App\ValueObjects\InvoiceFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class UploadFileProvider {
private UploadedFile $file;
private CostUnit $costUnit;
public function __construct(UploadedFile $file, CostUnit $costUnit) {
$this->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); };
}
}

View File

@@ -8,6 +8,17 @@ use App\Models\CostUnit;
use App\Resources\CostUnitResource; use App\Resources\CostUnitResource;
class CostUnitRepository { class CostUnitRepository {
public function getCostUnitsForNewInvoice(string $type) : array {
return $this->getCostUnitsByCriteria([
'allow_new' => true,
'type' => $type,
'archived' => false
], true, true);
}
public function getCurrentEvents() : array { public function getCurrentEvents() : array {
return $this->getCostUnitsByCriteria([ return $this->getCostUnitsByCriteria([
'allow_new' => true, 'allow_new' => true,
@@ -38,8 +49,8 @@ class CostUnitRepository {
]); ]);
} }
public function getById(int $id) : ?CostUnit { public function getById(int $id, bool $disableAccessCheck = false) : ?CostUnit {
$costUnits = self::getCostUnitsByCriteria(['id' => $id], false); $costUnits = self::getCostUnitsByCriteria(['id' => $id], false, $disableAccessCheck);
if (count($costUnits) === 0) { if (count($costUnits) === 0) {
return null; 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'); $tenant = app('tenant');
$canSeeAll = false; $canSeeAll = false;
@@ -71,7 +82,7 @@ class CostUnitRepository {
$visibleCostUnits = []; $visibleCostUnits = [];
/** @var CostUnit $costUnit */ /** @var CostUnit $costUnit */
foreach (Costunit::where($criteria)->get() as $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) { if ($forDisplay) {
$visibleCostUnits[] = new CostUnitResource($costUnit)->toArray(request()); $visibleCostUnits[] = new CostUnitResource($costUnit)->toArray(request());
} else { } else {

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Repositories;
use App\Models\PageText;
class PageTextRepository {
public function getPageText(string $name) : string {
$name = strtoupper($name);
$pageText = PageText::where(['name' => $name])->first();
if (null === $pageText) {
PageText::create(['name' => $name, 'content' => '']);
return strtoupper($name);
}
return $pageText->content;
}
}

View File

@@ -17,4 +17,34 @@ class UserRepository {
return $token === $user->activation_token; 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;
}
} }

View File

@@ -4,15 +4,19 @@ namespace App\Scopes;
use App\Providers\AuthCheckProvider; use App\Providers\AuthCheckProvider;
use App\Repositories\CostUnitRepository; use App\Repositories\CostUnitRepository;
use App\Repositories\PageTextRepository;
use App\Repositories\UserRepository; use App\Repositories\UserRepository;
abstract class CommonController { abstract class CommonController {
protected UserRepository $users; protected UserRepository $users;
protected CostUnitRepository $costUnits; protected CostUnitRepository $costUnits;
protected PageTextRepository $pageTexts;
public function __construct() { public function __construct() {
$this->users = new UserRepository(); $this->users = new UserRepository();
$this->costUnits = new CostUnitRepository(); $this->costUnits = new CostUnitRepository();
$this->pageTexts = new PageTextRepository();
} }
protected function checkAuth() { protected function checkAuth() {

View File

@@ -22,6 +22,10 @@ class Amount {
return $this->amount; return $this->amount;
} }
public function getRoundedAmount() : int {
return round($this->amount);
}
public function getCurrency() : string { public function getCurrency() : string {
return $this->currency; return $this->currency;
} }

View File

@@ -0,0 +1,9 @@
<?php
namespace App\ValueObjects;
class InvoiceFile {
public string $filename;
public string $fullPath;
}

View File

@@ -0,0 +1,56 @@
<script setup>
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()
}
</script>
<template>
<input
maxlength="27"
type="text"
:value="model"
@input="onInput"
@keypress="onKeypress"
/>
</template>

View File

@@ -0,0 +1,202 @@
<!-- InfoIcon.vue -->
<template>
<span
class="info-icon-wrapper"
role="button"
:aria-label="ariaLabel"
tabindex="0"
@mouseenter="open"
@mouseleave="close"
@focus="open"
@blur="close"
@keydown="onKeydown"
>
<slot name="icon">
<!-- default info SVG -->
<svg class="info-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.08"></circle>
<path d="M11 17h2v-6h-2v6zm0-8h2V7h-2v2z" fill="currentColor"></path>
</svg>
</slot>
<transition name="fade-scale">
<div
v-if="visible"
class="tooltip"
:class="positionClass"
role="tooltip"
:id="tooltipId"
>
<div class="tooltip-inner" v-html="text"></div>
<!-- small arrow -->
<div class="tooltip-arrow" aria-hidden="true"></div>
</div>
</transition>
</span>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
text: { type: String, required: true }, // Tooltiptext (HTML erlaubt)
position: { type: String, default: 'top' }, // top | right | bottom | left
delay: { type: Number, default: 80 }, // ms - delay für Öffnen/Schließen (klein)
ariaLabel: { type: String, default: 'Info' }, // aria-label für das Icon (z.B. "Mehr Informationen")
})
const visible = ref(false)
let openTimer = null
let closeTimer = null
const tooltipId = `info-icon-tooltip-${Math.round(Math.random()*1e6)}`
const positionClass = computed(() => {
const p = props.position
return `pos-${p}`
})
function open() {
clearTimeout(closeTimer)
openTimer = setTimeout(() => (visible.value = true), props.delay)
}
function close() {
clearTimeout(openTimer)
closeTimer = setTimeout(() => (visible.value = false), props.delay)
}
function onKeydown(e) {
if (e.key === 'Escape' || e.key === 'Esc') {
visible.value = false
e.stopPropagation()
} else if (e.key === 'Enter' || e.key === ' ') {
// toggle on Enter / Space
e.preventDefault()
visible.value = !visible.value
}
}
</script>
<style scoped>
.info-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
line-height: 0;
cursor: help;
outline: none;
}
/* focus ring */
.info-icon-wrapper:focus {
box-shadow: 0 0 0 4px rgba(50,115,220,0.12);
border-radius: 6px;
}
/* SVG sizing */
.info-icon {
display: block;
width: 18px;
height: 18px;
}
/* Tooltip baseline */
/* Tooltip baseline */
.tooltip {
position: absolute;
z-index: 999;
min-width: 180px; /* optional, sorgt für nicht zu kleinen Tooltip */
font-size: 13px;
line-height: 1.3;
padding: 8px 10px;
background: #59a3da;
color: #fff;
border-radius: 6px;
box-shadow: 0 6px 20px rgba(0,0,0,0.2);
transform-origin: center;
pointer-events: none;
white-space: normal; /* erlaubt Umbruch */
word-break: break-word; /* lange Wörter umbrechen */
}
/* Arrow */
.tooltip-arrow {
position: absolute;
width: 10px;
height: 10px;
transform: rotate(45deg);
background: inherit;
box-shadow: inherit;
filter: blur(0);
}
/* Positions */
.pos-top {
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
}
.pos-top .tooltip-arrow {
bottom: -5px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
}
.pos-bottom {
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
}
.pos-bottom .tooltip-arrow {
top: -5px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
}
.pos-left {
right: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
}
.pos-left .tooltip-arrow {
right: -5px;
top: 50%;
transform: translateY(-50%) rotate(45deg);
}
.pos-right {
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
}
.pos-right .tooltip-arrow {
left: -5px;
top: 50%;
transform: translateY(-50%) rotate(45deg);
}
/* inner tooltip text styling */
.tooltip-inner {
white-space: normal;
word-break: break-word;
}
/* enter/leave animation */
.fade-scale-enter-active,
.fade-scale-leave-active {
transition: opacity 160ms ease, transform 160ms ease;
}
.fade-scale-enter-from,
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.95);
}
.fade-scale-enter-to,
.fade-scale-leave-from {
opacity: 1;
transform: scale(1);
}
</style>

View File

@@ -0,0 +1,17 @@
<!-- NumericInput.vue -->
<script setup>
const model = defineModel() // bindet v-model automatisch
</script>
<template>
<input
type="text"
:value="model"
@input="model = $event.target.value.replace(/[^0-9]/g, '')"
@keypress="($event) => {
if (!/[0-9]/.test($event.key)) {
$event.preventDefault()
}
}"
/>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import {onMounted, reactive} from "vue";
const props = defineProps({
textName: { type: String},
belongsTo: { type: String},
})
const contentData = reactive({
content: '',
});
onMounted(async () => {
const response = await fetch('/api/v1/core/retrieve-text-resource/' + props.textName);
const data = await response.json();
Object.assign(contentData, data);
console.log(contentData)
});
</script>
<template>
<label :for="props.belongsTo">{{contentData.content}}</label>
</template>
<style scoped>
</style>

View File

@@ -60,6 +60,7 @@ return new class extends Migration
$table->string('eating_habits')->nullable(); $table->string('eating_habits')->nullable();
$table->string('swimming_permission')->nullable(); $table->string('swimming_permission')->nullable();
$table->string('first_aid_permission')->nullable(); $table->string('first_aid_permission')->nullable();
$table->string('bank_account_owner')->nullable();
$table->string('bank_account_iban')->nullable(); $table->string('bank_account_iban')->nullable();
$table->string('activation_token')->nullable(); $table->string('activation_token')->nullable();
$table->dateTime('activation_token_expires_at')->nullable()->default(date('Y-m-d H:i:s', strtotime('+3 days'))); $table->dateTime('activation_token_expires_at')->nullable()->default(date('Y-m-d H:i:s', strtotime('+3 days')));

View File

@@ -0,0 +1,68 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('invoice_types', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('page_texts', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('content');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('page_texts');
}
};

41
public/css/invoices.css Normal file
View File

@@ -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;
}

View File

@@ -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';
}
}

View File

@@ -8,6 +8,8 @@ export function useAjax() {
async function request(url, options = {}) { async function request(url, options = {}) {
loading.value = true loading.value = true
const isFormData = options.body instanceof FormData
error.value = null error.value = null
data.value = null data.value = null
@@ -15,14 +17,19 @@ export function useAjax() {
const response = await fetch(url, { const response = await fetch(url, {
method: options.method || "GET", method: options.method || "GET",
headers: { headers: {
"Content-Type": "application/json",
'X-CSRF-TOKEN': csrfToken, 'X-CSRF-TOKEN': csrfToken,
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(options.headers || {}), ...(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}`) if (!response.ok) throw new Error(`HTTP ${response.status}`)
const result = await response.json() const result = await response.json()
data.value = result data.value = result
return result return result
@@ -35,6 +42,7 @@ export function useAjax() {
} }
} }
async function download(url, options = {}) { async function download(url, options = {}) {
loading.value = true loading.value = true
error.value = null error.value = null

View File

@@ -18,14 +18,26 @@ const globalProps = reactive({
user: null, user: null,
currentPath: '/', currentPath: '/',
errors: {}, errors: {},
availableLocalGroups: [] availableLocalGroups: [],
message: ''
}); });
onMounted(async () => { 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(); const data = await response.json();
Object.assign(globalProps, data); 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)
}
}
}); });

View File

@@ -6,6 +6,7 @@
<link rel="stylesheet" href="/css/elements.css" /> <link rel="stylesheet" href="/css/elements.css" />
<link rel="stylesheet" href="/css/modalBox.css" /> <link rel="stylesheet" href="/css/modalBox.css" />
<link rel="stylesheet" href="/css/costunits.css" /> <link rel="stylesheet" href="/css/costunits.css" />
<link rel="stylesheet" href="/css/invoices.css" />
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">

View File

@@ -3,16 +3,32 @@
use App\Domains\Dashboard\Controllers\DashboardController; use App\Domains\Dashboard\Controllers\DashboardController;
use App\Http\Controllers\TestRenderInertiaProvider; use App\Http\Controllers\TestRenderInertiaProvider;
use App\Middleware\IdentifyTenant; use App\Middleware\IdentifyTenant;
use App\Providers\GlobalDataProvider;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
require_once __DIR__ . '/../app/Domains/UserManagement/Routes/web.php'; 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/web.php';
require_once __DIR__ . '/../app/Domains/CostUnit/Routes/api.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::middleware(IdentifyTenant::class)->group(function () {
Route::get('/', DashboardController::class); 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 () { Route::middleware(['auth'])->group(function () {