8 Commits

20 changed files with 522 additions and 96 deletions
@@ -4,14 +4,13 @@ namespace App\Domains\CostUnit\Controllers;
use App\Domains\Invoice\Actions\ChangeStatus\ChangeStatusCommand; use App\Domains\Invoice\Actions\ChangeStatus\ChangeStatusCommand;
use App\Domains\Invoice\Actions\ChangeStatus\ChangeStatusRequest; use App\Domains\Invoice\Actions\ChangeStatus\ChangeStatusRequest;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest;
use App\Domains\Invoice\Actions\CreateInvoiceReceipt\CreateInvoiceReceiptCommand; use App\Domains\Invoice\Actions\CreateInvoiceReceipt\CreateInvoiceReceiptCommand;
use App\Domains\Invoice\Actions\CreateInvoiceReceipt\CreateInvoiceReceiptRequest; use App\Domains\Invoice\Actions\CreateInvoiceReceipt\CreateInvoiceReceiptRequest;
use App\Enumerations\InvoiceStatus; use App\Enumerations\InvoiceStatus;
use App\Models\SepaPaymentElement;
use App\Models\Tenant; use App\Models\Tenant;
use App\Providers\FileWriteProvider; use App\Providers\FileWriteProvider;
use App\Providers\InvoiceCsvFileProvider; use App\Providers\InvoiceCsvFileProvider;
use App\Providers\PainFileProvider;
use App\Providers\WebDavProvider; use App\Providers\WebDavProvider;
use App\Providers\ZipArchiveFileProvider; use App\Providers\ZipArchiveFileProvider;
use App\Scopes\CommonController; use App\Scopes\CommonController;
@@ -25,24 +24,19 @@ class ExportController extends CommonController {
$webdavProvider = new WebDavProvider(WebDavProvider::INVOICE_PREFIX . $this->tenant->url . '/' . $costUnit->name); $webdavProvider = new WebDavProvider(WebDavProvider::INVOICE_PREFIX . $this->tenant->url . '/' . $costUnit->name);
$painFileData = $this->painData($invoicesForExport); $this->createSepaPaymentElements($invoicesForExport, $costUnit);
$csvData = $this->csvData($invoicesForExport); $csvData = $this->csvData($invoicesForExport);
$filePrefix = Tenant::getTempDirectory(); $filePrefix = Tenant::getTempDirectory();
$painFileWriteProvider = new FileWriteProvider($filePrefix . 'abrechnungen-' . date('Y-m-d_H-i') . '-sepa.xml', $painFileData);
$painFileWriteProvider->writeToFile();
$csvFileWriteProvider = new FileWriteProvider($filePrefix . 'abrechnungen-' . date('Y-m-d_H-i') . '.csv', $csvData); $csvFileWriteProvider = new FileWriteProvider($filePrefix . 'abrechnungen-' . date('Y-m-d_H-i') . '.csv', $csvData);
$csvFileWriteProvider->writeToFile(); $csvFileWriteProvider->writeToFile();
if ($this->tenant->upload_exports) { if ($this->tenant->upload_exports) {
$webdavProvider->uploadFile($painFileWriteProvider->fileName);
$webdavProvider->uploadFile($csvFileWriteProvider->fileName); $webdavProvider->uploadFile($csvFileWriteProvider->fileName);
} }
$downloadZipArchiveFiles = [ $downloadZipArchiveFiles = [
$painFileWriteProvider->fileName,
$csvFileWriteProvider->fileName $csvFileWriteProvider->fileName
]; ];
@@ -72,7 +66,6 @@ class ExportController extends CommonController {
Storage::delete($file); Storage::delete($file);
} }
Storage::delete($painFileWriteProvider->fileName);
Storage::delete($csvFileWriteProvider->fileName); Storage::delete($csvFileWriteProvider->fileName);
return response()->download( return response()->download(
@@ -82,24 +75,28 @@ class ExportController extends CommonController {
); );
} }
Storage::delete($painFileWriteProvider->fileName);
Storage::delete($csvFileWriteProvider->fileName); Storage::delete($csvFileWriteProvider->fileName);
return response()->json([ return response()->json([
'message' => 'Die Abrechnungen wurden exportiert.' . PHP_EOL .'Die Belege werden asynchron auf dem Webdav-Server hinterlegt.' . PHP_EOL . PHP_EOL . 'Sollten diese in 15 Minuten nicht vollständig sein, kontaktiere den Adminbistrator.' 'message' => 'Die Abrechnungen wurden exportiert.' . PHP_EOL . 'Die SEPA-Überweisungsdatei kann über den Tab "Globale Aktionen" in der Kostenstellenübersicht erzeugt werden.' . PHP_EOL . PHP_EOL . 'Die Belege werden asynchron auf dem Webdav-Server hinterlegt.' . PHP_EOL . 'Sollten diese in 15 Minuten nicht vollständig sein, kontaktiere den Administrator.'
]); ]);
} }
private function painData(array $invoices) : string { private function createSepaPaymentElements(array $invoices, $costUnit): void
$invoicesForPainFile = []; {
foreach ($invoices as $invoice) { foreach ($invoices as $invoice) {
if ($invoice->contact_bank_owner !== null && $invoice->contact_bank_iban !== '' && !$invoice->donation) { if ($invoice->contact_bank_owner !== null && $invoice->contact_bank_iban !== '' && !$invoice->donation) {
$invoicesForPainFile[] = $invoice; SepaPaymentElement::create([
'tenant' => $this->tenant->slug,
'invoice_id' => $invoice->id,
'cost_unit_id' => $costUnit->id,
'amount' => $invoice->amount,
'recipient_name' => $invoice->contact_bank_owner,
'recipient_iban' => $invoice->contact_bank_iban,
'payment_purpose' => $invoice->payment_purpose ?? 'Auslagenerstattung Rechnungsnummer ' . $invoice->invoice_number,
]);
} }
} }
$painFileProvider = new PainFileProvider($this->tenant->account_iban, $this->tenant->account_name, $this->tenant->account_bic, $invoicesForPainFile);
return $painFileProvider->createPainFileContent();
} }
public function csvData(array $invoices) : string { public function csvData(array $invoices) : string {
@@ -107,4 +104,3 @@ class ExportController extends CommonController {
return $csvDateProvider->createCsvFileContent(); return $csvDateProvider->createCsvFileContent();
} }
} }
@@ -0,0 +1,82 @@
<?php
namespace App\Domains\CostUnit\Controllers;
use App\Enumerations\UserRole;
use App\Models\SepaPaymentElement;
use App\Models\Tenant;
use App\Providers\AuthCheckProvider;
use App\Providers\FileWriteProvider;
use App\Providers\PainFileProvider;
use App\Scopes\CommonController;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class GlobalSepaExportController extends CommonController {
private function checkAuthorization(): void
{
$authCheck = new AuthCheckProvider();
$role = $authCheck->getUserRole();
if (!in_array($role, [UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER], true)) {
abort(403);
}
}
public function getGlobalActions()
{
$this->checkAuthorization();
$pendingElements = SepaPaymentElement::where('exported', false)->get();
$pendingCount = $pendingElements->count();
$pendingAmount = number_format($pendingElements->sum('amount'), 2, ',', '.');
return response()->json([
'pending_count' => $pendingCount,
'pending_amount' => $pendingAmount,
]);
}
public function exportSepaFile()
{
$this->checkAuthorization();
return DB::transaction(function () {
$elements = SepaPaymentElement::where('exported', false)->lockForUpdate()->get();
if ($elements->isEmpty()) {
return response()->json([
'message' => 'Es gibt keine ausstehenden SEPA-Überweisungen.'
], 404);
}
$painFileProvider = new PainFileProvider(
$this->tenant->account_iban,
$this->tenant->account_name,
$this->tenant->account_bic,
$elements->all()
);
$painContent = $painFileProvider->createPainFileContent();
$filePrefix = Tenant::getTempDirectory();
$fileName = $filePrefix . 'sepa-pain-' . date('Y-m-d_H-i') . '.xml';
$fileWriteProvider = new FileWriteProvider($fileName, $painContent);
$fileWriteProvider->writeToFile();
$elements->each(function (SepaPaymentElement $element) {
$element->update([
'exported' => true,
'exported_at' => now(),
]);
});
$filePath = storage_path('app/private/' . $fileName);
return response()->download($filePath, basename($fileName), [
'Content-Type' => 'application/xml',
])->deleteFileAfterSend(true);
});
}
}
+4
View File
@@ -4,6 +4,7 @@ use App\Domains\CostUnit\Controllers\CreateController;
use App\Domains\CostUnit\Controllers\DistanceAllowanceController; use App\Domains\CostUnit\Controllers\DistanceAllowanceController;
use App\Domains\CostUnit\Controllers\EditController; use App\Domains\CostUnit\Controllers\EditController;
use App\Domains\CostUnit\Controllers\ExportController; use App\Domains\CostUnit\Controllers\ExportController;
use App\Domains\CostUnit\Controllers\GlobalSepaExportController;
use App\Domains\CostUnit\Controllers\ListController; use App\Domains\CostUnit\Controllers\ListController;
use App\Domains\CostUnit\Controllers\OpenController; use App\Domains\CostUnit\Controllers\OpenController;
use App\Domains\CostUnit\Controllers\TreasurersEditController; use App\Domains\CostUnit\Controllers\TreasurersEditController;
@@ -43,6 +44,9 @@ Route::prefix('api/v1')
Route::get('/global-actions', [GlobalSepaExportController::class, 'getGlobalActions']);
Route::get('/export-sepa-file', [GlobalSepaExportController::class, 'exportSepaFile']);
Route::prefix('open')->group(function () { Route::prefix('open')->group(function () {
Route::get('/current-events', [ListController::class, 'listCurrentEvents']); Route::get('/current-events', [ListController::class, 'listCurrentEvents']);
Route::get('/current-running-jobs', [ListController::class, 'listCurrentRunningJobs']); Route::get('/current-running-jobs', [ListController::class, 'listCurrentRunningJobs']);
+8
View File
@@ -7,6 +7,7 @@ import TabbedPage from "../../../Views/Components/TabbedPage.vue";
import {toast} from "vue3-toastify"; import {toast} from "vue3-toastify";
import ListCostUnits from "./Partials/ListCostUnits.vue"; import ListCostUnits from "./Partials/ListCostUnits.vue";
import GlobalActions from "./Partials/GlobalActions.vue";
const props = defineProps({ const props = defineProps({
message: String, message: String,
@@ -63,6 +64,13 @@ const tabs = [
deep_jump_id: initialCostUnitId, deep_jump_id: initialCostUnitId,
deep_jump_id_sub: initialInvoiceId, deep_jump_id_sub: initialInvoiceId,
}, },
{
title: 'Globale Aktionen',
component: GlobalActions,
endpoint: "/api/v1/cost-unit/global-actions",
deep_jump_id: 0,
deep_jump_id_sub: 0,
},
] ]
onMounted(() => { onMounted(() => {
@@ -0,0 +1,112 @@
<script setup>
import {ref} from 'vue'
import LoadingModal from "../../../../Views/Components/LoadingModal.vue";
import {toast} from "vue3-toastify";
const props = defineProps({
data: {
type: [Array, Object],
default: () => ({})
},
deep_jump_id: {
type: Number,
default: 0
},
deep_jump_id_sub: {
type: Number,
default: 0
}
})
const showLoading = ref(false)
async function exportSepaFile() {
showLoading.value = true;
try {
const response = await fetch('/api/v1/cost-unit/export-sepa-file', {
headers: {"Content-Type": "application/json"},
});
if (!response.ok) {
if (response.status === 404) {
const data = await response.json();
toast.info(data.message);
} else {
throw new Error('Fehler beim Erzeugen der SEPA-Datei');
}
showLoading.value = false;
return;
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = downloadUrl;
a.download = "sepa-pain-" + new Date().toISOString().slice(0, 10) + ".xml";
document.body.appendChild(a);
a.click();
setTimeout(() => {
window.URL.revokeObjectURL(downloadUrl);
document.body.removeChild(a);
}, 100);
toast.success('SEPA-Datei wurde erfolgreich erzeugt.');
showLoading.value = false;
} catch (err) {
showLoading.value = false;
toast.error('Beim Erzeugen der SEPA-Datei ist ein Fehler aufgetreten.');
}
}
</script>
<template>
<div>
<h2>Globale Aktionen</h2>
<div style="margin: 20px 0;">
<p v-if="props.data.pending_count > 0">
Es gibt <strong>{{ props.data.pending_count }}</strong> ausstehende SEPA-Überweisungen
(Gesamtbetrag: <strong>{{ props.data.pending_amount }} Euro</strong>).
</p>
<p v-else>
Keine ausstehenden SEPA-Überweisungen vorhanden.
</p>
</div>
<button
class="action-button"
:disabled="!props.data.pending_count || props.data.pending_count === 0"
@click="exportSepaFile"
>
Erzeuge SEPA-File
</button>
<loading-modal v-if="showLoading" />
</div>
</template>
<style scoped>
.action-button {
padding: 10px 20px;
background-color: #0073aa;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.action-button:hover:not(:disabled) {
background-color: #005a87;
}
.action-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
</style>
@@ -64,7 +64,7 @@ const props = defineProps({
<th style="padding-bottom: 20px" colspan="2">Förderung</th> <th style="padding-bottom: 20px" colspan="2">Förderung</th>
<td style="padding-bottom: 20px" colspan="2"> <td style="padding-bottom: 20px" colspan="2">
{{ props.event.supportPerson.readable }}<br /> {{ props.event.supportPerson.readable }}<br />
<label style="font-size: 9pt;">({{ props.event.supportPersonIndex }} / Tag p.P.)</label> <label style="font-size: 9pt;">({{ props.event.supportPersonValue }} / Tag p.P.)</label>
</td> </td>
</tr> </tr>
@@ -98,7 +98,7 @@ const props = defineProps({
<tr> <tr>
<th style="padding-top: 20px; font-size: 12pt !important;" colspan="2">Budget</th> <th style="padding-top: 20px; font-size: 12pt !important;" colspan="2">Budget</th>
<td v-if="props.event.totalBalance.expected.value >= 0" style="color: #4caf50; font-weight: bold; padding-top: 20px; font-size: 12pt !important;"> <td v-if="props.event.totalBalance.estimated.value >= 0" style="color: #4caf50; font-weight: bold; padding-top: 20px; font-size: 12pt !important;">
{{ props.event.totalBalance.estimated.readable }} {{ props.event.totalBalance.estimated.readable }}
</td> </td>
<td v-else style="color: #f44336; font-weight: bold; padding-top: 20px; font-size: 12pt !important;"> <td v-else style="color: #f44336; font-weight: bold; padding-top: 20px; font-size: 12pt !important;">
@@ -30,7 +30,7 @@ class CreateInvoiceCommand {
'type' => $this->request->invoiceType, 'type' => $this->request->invoiceType,
'type_other' => $this->request->invoiceTypeExtended, 'type_other' => $this->request->invoiceTypeExtended,
'donation' => $this->request->isDonation, 'donation' => $this->request->isDonation,
'user_id' => $this->request->userId, 'user_id' => $this->request->paymentPurpose === null ? $this->request->userId : null,
'contact_name' => $this->request->contactName, 'contact_name' => $this->request->contactName,
'contact_email' => $this->request->contactEmail, 'contact_email' => $this->request->contactEmail,
'contact_phone' => $this->request->contactPhone, 'contact_phone' => $this->request->contactPhone,
@@ -2,6 +2,7 @@
namespace App\Domains\UserManagement\Controllers; namespace App\Domains\UserManagement\Controllers;
use App\Enumerations\UserRole;
use App\Providers\InertiaProvider; use App\Providers\InertiaProvider;
use App\Scopes\CommonController; use App\Scopes\CommonController;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -38,20 +39,44 @@ class LoginController extends CommonController {
return redirect()->intended('/register/verifyEmail'); return redirect()->intended('/register/verifyEmail');
} }
#$credentials = ['username' => 'development', 'password' => 'development'];
if (!Auth::attempt($credentials)) { if (!Auth::attempt($credentials)) {
return back()->withErrors([ return back()->withErrors([
'username' => 'Diese Zugangsdaten sind ungültig.', 'username' => 'Diese Zugangsdaten sind ungültig.',
]); ]);
} }
$request->session()->regenerate();
$user = Auth::user(); $user = Auth::user();
$tenant = app('tenant');
// Auf "lv" darf sich grundsätzlich jeder aktive Nutzer einloggen.
// Auf Sub-Tenants gilt:
// - Der Nutzer muss dem Tenant zugeordnet sein (local_group)
// - ODER er hat "Bundesrecht über Landesrecht":
// user_role_main === ROLE_ADMINISTRATOR -> Login auf jedem Sub-Tenant erlaubt.
$isMainAdmin = $user->user_role_main === UserRole::USER_ROLE_ADMIN;
$isMemberOfTenant = $tenant->slug === $user->local_group;
# dd($user->firstname . ' ' . $user->lastname); if ($tenant->slug !== 'lv' && !$isMainAdmin && !$isMemberOfTenant) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return back()->withErrors([
'username' => 'Diese Zugangsdaten sind für diesen Stamm nicht gültig.',
]);
}
if (!$user->active) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return back()->withErrors([
'username' => 'Dieses Benutzerkonto ist nicht aktiv.',
]);
}
$request->session()->regenerate();
return redirect()->intended('/'); return redirect()->intended('/');
} }
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Scopes\InstancedModel;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property string $id
* @property string $tenant
* @property int $invoice_id
* @property int $cost_unit_id
* @property float $amount
* @property string $recipient_name
* @property string $recipient_iban
* @property string $payment_purpose
* @property bool $exported
* @property \DateTime|null $exported_at
*/
class SepaPaymentElement extends InstancedModel
{
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'tenant',
'invoice_id',
'cost_unit_id',
'amount',
'recipient_name',
'recipient_iban',
'payment_purpose',
'exported',
'exported_at',
];
protected $casts = [
'exported' => 'boolean',
'exported_at' => 'datetime',
];
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public function costUnit(): BelongsTo
{
return $this->belongsTo(CostUnit::class);
}
}
+33 -3
View File
@@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use App\Enumerations\UserRole; use App\Enumerations\UserRole;
use App\Models\User;
class AuthCheckProvider { class AuthCheckProvider {
public function checkLoggedIn() : bool { public function checkLoggedIn() : bool {
@@ -16,7 +17,7 @@ class AuthCheckProvider {
return $user->active; return $user->active;
} }
if ($user->user_role_main === UserRole::USER_ROLE_ADMIN) { if ($this->isMainAdministrator($user)) {
return true; return true;
} }
@@ -28,10 +29,39 @@ class AuthCheckProvider {
return null; return null;
} }
$user = auth()->user();
if (app('tenant')->slug === 'lv') { if (app('tenant')->slug === 'lv') {
return auth()->user()->user_role_main; return $user->user_role_main;
} }
return auth()->user()->user_role_local_group; // "Bundesrecht steht über Landesrecht":
// Ein ROLE_ADMINISTRATOR auf LV-Ebene ist auf jedem Sub-Tenant automatisch Administrator,
// unabhängig von user_role_local_group.
if ($this->isMainAdministrator($user)) {
return UserRole::USER_ROLE_ADMIN;
}
return $user->user_role_local_group;
}
/**
* Gibt true zurück, wenn der Nutzer auf LV-Ebene Administrator ist.
* Diese Rolle hebt das lokale Rechtesystem für alle Sub-Tenants auf.
*/
public function isMainAdministrator(?User $user = null) : bool {
$user ??= auth()->user();
return $user !== null
&& $user->user_role_main === UserRole::USER_ROLE_ADMIN;
}
/**
* Bequemer Helper für die Berechtigungs-Checks im gesamten System.
* Gibt true zurück, wenn der aktuell eingeloggte Nutzer im Kontext des
* aktuellen Tenants effektiv Administrator ist.
*/
public function isAdministrator() : bool {
return $this->getUserRole() === UserRole::USER_ROLE_ADMIN;
} }
} }
+43 -25
View File
@@ -26,28 +26,7 @@ class GlobalDataProvider {
'tenant' => app('tenant'), 'tenant' => app('tenant'),
'activeUsers' => $this->getActiveUsers(), 'activeUsers' => $this->getActiveUsers(),
'version' => config('app.version'), 'version' => config('app.version'),
]); 'currentEvent' => $this->getCurrentEventData(),
}
public function getAllInvoiceTypes() : JsonResponse {
$invoiceTypes = [];
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
if (
$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
]); ]);
} }
@@ -99,10 +78,47 @@ class GlobalDataProvider {
]); ]);
} }
private function getCurrentEventData() : ?array {
if (null === $this->user) {
return null;
}
$currentEvent = new EventRepository()->getMyCurrentEvent();
if (null === $currentEvent) {
return null;
}
return [
'identifier' => $currentEvent->identifier,
'name' => $currentEvent->name,
];
}
public function getAllInvoiceTypes() : JsonResponse {
$invoiceTypes = [];
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
if (
$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
]);
}
private function generateNavbar() : array { private function generateNavbar() : array {
$eventRepository = new EventRepository(); $eventRepository = new EventRepository();
$navigation = [ $navigation = [
'personal' => [], 'personal' => [],
'common' => [], 'common' => [],
@@ -116,9 +132,11 @@ class GlobalDataProvider {
$navigation['personal'][] = ['url' => '/personal-data', 'display' => 'Meine Daten']; $navigation['personal'][] = ['url' => '/personal-data', 'display' => 'Meine Daten'];
$navigation['personal'][] = ['url' => '/messages', 'display' => 'Meine Nachrichten']; $navigation['personal'][] = ['url' => '/messages', 'display' => 'Meine Nachrichten'];
$authCheck = new AuthCheckProvider();
$effectiveRole = $authCheck->getUserRole();
if ( if (
in_array($this->user->user_role_local_group, [UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER] ) || in_array($effectiveRole, [UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER], true)
$this->user->user_role_main === UserRole::USER_ROLE_ADMIN
) { ) {
$navigation['costunits'][] = ['url' => '/cost-unit/list', 'display' => 'Kostenstellen']; $navigation['costunits'][] = ['url' => '/cost-unit/list', 'display' => 'Kostenstellen'];
$navigation['costunits'][] = ['url' => '/cost-unit/create', 'display' => 'Neue laufende Tätigkeit']; $navigation['costunits'][] = ['url' => '/cost-unit/create', 'display' => 'Neue laufende Tätigkeit'];
+13 -17
View File
@@ -2,25 +2,23 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Invoice; use App\Models\SepaPaymentElement;
use App\Resources\InvoiceResource;
use DOMDocument; use DOMDocument;
use Exception; use Exception;
use Illuminate\Http\Request;
class PainFileProvider { class PainFileProvider {
public string $senderIban; public string $senderIban;
public string $senderName; public string $senderName;
public string $senderBic; public string $senderBic;
/* @var Invoice[] */ /** @var SepaPaymentElement[] */
public array $invoices; public array $elements;
public function __construct(string $senderIban, string $senderName, string $senderBic, array $invoices) { public function __construct(string $senderIban, string $senderName, string $senderBic, array $elements) {
$this->senderIban = $senderIban; $this->senderIban = $senderIban;
$this->senderName = $senderName; $this->senderName = $senderName;
$this->senderBic = $senderBic; $this->senderBic = $senderBic;
$this->invoices = $invoices; $this->elements = $elements;
} }
public function createPainFileContent() : string { public function createPainFileContent() : string {
@@ -46,9 +44,9 @@ class PainFileProvider {
$grp_hdr->appendChild($doc->createElement('MsgId', uniqid('MSG'))); $grp_hdr->appendChild($doc->createElement('MsgId', uniqid('MSG')));
$grp_hdr->appendChild($doc->createElement('CreDtTm', date('c'))); $grp_hdr->appendChild($doc->createElement('CreDtTm', date('c')));
$grp_hdr->appendChild($doc->createElement('NbOfTxs', count($this->invoices))); $grp_hdr->appendChild($doc->createElement('NbOfTxs', count($this->elements)));
$totalAmount = array_sum(array_column($this->invoices, 'amount')); $totalAmount = array_sum(array_map(fn(SepaPaymentElement $e) => $e->amount, $this->elements));
$grp_hdr->appendChild($doc->createElement('CtrlSum', number_format($totalAmount, 2, '.', ''))); $grp_hdr->appendChild($doc->createElement('CtrlSum', number_format($totalAmount, 2, '.', '')));
$initg_pty = $doc->createElement('InitgPty'); $initg_pty = $doc->createElement('InitgPty');
@@ -62,7 +60,7 @@ class PainFileProvider {
$pmt_inf->appendChild($doc->createElement('PmtInfId', uniqid('PMT'))); $pmt_inf->appendChild($doc->createElement('PmtInfId', uniqid('PMT')));
$pmt_inf->appendChild($doc->createElement('PmtMtd', 'TRF')); $pmt_inf->appendChild($doc->createElement('PmtMtd', 'TRF'));
$pmt_inf->appendChild($doc->createElement('BtchBookg', 'false')); $pmt_inf->appendChild($doc->createElement('BtchBookg', 'false'));
$pmt_inf->appendChild($doc->createElement('NbOfTxs', count($this->invoices))); $pmt_inf->appendChild($doc->createElement('NbOfTxs', count($this->elements)));
$pmt_inf->appendChild($doc->createElement('CtrlSum', number_format($totalAmount, 2, '.', ''))); $pmt_inf->appendChild($doc->createElement('CtrlSum', number_format($totalAmount, 2, '.', '')));
$pmt_tp_inf = $doc->createElement('PmtTpInf'); $pmt_tp_inf = $doc->createElement('PmtTpInf');
@@ -90,9 +88,7 @@ class PainFileProvider {
$dbtr_agt->appendChild($id); $dbtr_agt->appendChild($id);
$pmt_inf->appendChild($dbtr_agt); $pmt_inf->appendChild($dbtr_agt);
foreach ($this->invoices as $index => $invoice) { foreach ($this->elements as $index => $element) {
$invoiceResource = new InvoiceResource($invoice)->toArray(new Request());
$cdt_trf_tx_inf = $doc->createElement('CdtTrfTxInf'); $cdt_trf_tx_inf = $doc->createElement('CdtTrfTxInf');
$pmt_id = $doc->createElement('PmtId'); $pmt_id = $doc->createElement('PmtId');
@@ -100,23 +96,23 @@ class PainFileProvider {
$cdt_trf_tx_inf->appendChild($pmt_id); $cdt_trf_tx_inf->appendChild($pmt_id);
$amt = $doc->createElement('Amt'); $amt = $doc->createElement('Amt');
$instd_amt = $doc->createElement('InstdAmt', number_format($invoice['amount'], 2, '.', '')); $instd_amt = $doc->createElement('InstdAmt', number_format($element->amount, 2, '.', ''));
$instd_amt->setAttribute('Ccy', 'EUR'); $instd_amt->setAttribute('Ccy', 'EUR');
$amt->appendChild($instd_amt); $amt->appendChild($instd_amt);
$cdt_trf_tx_inf->appendChild($amt); $cdt_trf_tx_inf->appendChild($amt);
$cdtr = $doc->createElement('Cdtr'); $cdtr = $doc->createElement('Cdtr');
$cdtr->appendChild($doc->createElement('Nm', $invoice['contact_bank_owner'])); $cdtr->appendChild($doc->createElement('Nm', $element->recipient_name));
$cdt_trf_tx_inf->appendChild($cdtr); $cdt_trf_tx_inf->appendChild($cdtr);
$cdtr_acct = $doc->createElement('CdtrAcct'); $cdtr_acct = $doc->createElement('CdtrAcct');
$cdtr_id = $doc->createElement('Id'); $cdtr_id = $doc->createElement('Id');
$cdtr_id->appendChild($doc->createElement('IBAN', str_replace(' ', '', $invoice['contact_bank_iban']))); $cdtr_id->appendChild($doc->createElement('IBAN', str_replace(' ', '', $element->recipient_iban)));
$cdtr_acct->appendChild($cdtr_id); $cdtr_acct->appendChild($cdtr_id);
$cdt_trf_tx_inf->appendChild($cdtr_acct); $cdt_trf_tx_inf->appendChild($cdtr_acct);
$rmt_inf = $doc->createElement('RmtInf'); $rmt_inf = $doc->createElement('RmtInf');
$rmt_inf->appendChild($doc->createElement('Ustrd', $invoiceResource['paymentPurpose'])); $rmt_inf->appendChild($doc->createElement('Ustrd', $element->payment_purpose));
$cdt_trf_tx_inf->appendChild($rmt_inf); $cdt_trf_tx_inf->appendChild($rmt_inf);
$pmt_inf->appendChild($cdt_trf_tx_inf); $pmt_inf->appendChild($cdt_trf_tx_inf);
+11 -5
View File
@@ -2,6 +2,7 @@
namespace App\Providers; namespace App\Providers;
use App\Enumerations\UserRole;
use Illuminate\Auth\EloquentUserProvider; use Illuminate\Auth\EloquentUserProvider;
class TenantUserProvider extends EloquentUserProvider class TenantUserProvider extends EloquentUserProvider
@@ -18,15 +19,20 @@ class TenantUserProvider extends EloquentUserProvider
} }
} }
// Auf "lv" gilt grundsätzlich keine local_group-Einschränkung.
if (app('tenant')->slug === 'lv') { if (app('tenant')->slug === 'lv') {
return $query->first(); return $query->first();
} }
$query->where([ // Auf Sub-Tenants:
'local_group' => app('tenant')->slug, // - Entweder gehört der Nutzer zum aktuellen Tenant (local_group)
'active' => true // - ODER er ist auf LV-Ebene Administrator
// -> "Bundesrecht steht über Landesrecht": Login überall möglich.
]); $query->where('active', true)
->where(function ($q) {
$q->where('local_group', app('tenant')->slug)
->orWhere('user_role_main', UserRole::USER_ROLE_ADMIN);
});
return $query->first(); return $query->first();
} }
+3 -2
View File
@@ -7,6 +7,7 @@ use App\Enumerations\InvoiceStatus;
use App\Enumerations\InvoiceType; use App\Enumerations\InvoiceType;
use App\Enumerations\UserRole; use App\Enumerations\UserRole;
use App\Models\CostUnit; use App\Models\CostUnit;
use App\Providers\AuthCheckProvider;
use App\Resources\CostUnitResource; use App\Resources\CostUnitResource;
use App\ValueObjects\Amount; use App\ValueObjects\Amount;
use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Capsule\Manager as Capsule;
@@ -75,8 +76,8 @@ class CostUnitRepository {
} else { } else {
if ($tenant->slug !== 'lv') { if ($tenant->slug !== 'lv') {
if ( if (
$user->user_role_main === UserRole::USER_ROLE_ADMIN || new AuthCheckProvider()->isAdministrator() ||
in_array($user->user_role_local_group, [UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_ADMIN]) $user->user_role_local_group === UserRole::USER_ROLE_ADMIN
) { ) {
$canSeeAll = true; $canSeeAll = true;
} }
+18 -2
View File
@@ -6,6 +6,7 @@ use App\Enumerations\ParticipationType;
use App\Enumerations\UserRole; use App\Enumerations\UserRole;
use App\Models\CostUnit; use App\Models\CostUnit;
use App\Models\Event; use App\Models\Event;
use App\Providers\AuthCheckProvider;
use App\Resources\CostUnitResource; use App\Resources\CostUnitResource;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -19,13 +20,25 @@ class EventRepository {
} }
public function getMyCurrentEvent() : ?Event {
$events = $this->getEventsByCriteria([
['archived', '=', false],
['start_date', '<=', now()],
['end_date', '>=', now()],
], true);
if (count($events) !== 1) {
return null;
}
return $events[0];
}
public function getUpcoming(int $maxCount = 5, bool $accessCheck = true) : array { public function getUpcoming(int $maxCount = 5, bool $accessCheck = true) : array {
$events = []; $events = [];
foreach ( $this->getEventsByCriteria([ foreach ( $this->getEventsByCriteria([
'archived' => false, 'archived' => false,
],$accessCheck) as $event) { ],$accessCheck) as $event) {
if ($event->start_date > now()) { if ($event->end_date >= now()) {
$event = $event->toResource()->toArray(new Request()); $event = $event->toResource()->toArray(new Request());
$events[] = $event; $events[] = $event;
@@ -72,7 +85,10 @@ class EventRepository {
if (!$accessCheck) { if (!$accessCheck) {
$canSeeAll = true; $canSeeAll = true;
} else { } else {
if ($tenant->slug !== 'lv') { if (
new AuthCheckProvider()->isAdministrator() ||
$user->user_role_local_group === UserRole::USER_ROLE_ADMIN
) {
if ( if (
$user->user_role_main === UserRole::USER_ROLE_ADMIN || $user->user_role_main === UserRole::USER_ROLE_ADMIN ||
in_array($user->user_role_local_group, [UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_ADMIN]) in_array($user->user_role_local_group, [UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_ADMIN])
+5 -8
View File
@@ -26,17 +26,14 @@ class EventParticipantResource extends JsonResource
} }
$presenceDays = $this->resource->arrival_date->diff($this->resource->departure_date)->days; $presenceDays = $this->resource->arrival_date->diff($this->resource->departure_date)->days;
$presenceDaysSupport = $presenceDays; if ($presenceDays === 0) {
$presenceDays = 1;
if ($presenceDaysSupport === 0) { $presenceDaysSupport = 1;
$presenceDaysSupport = 1;
} else { } else {
$presenceDaysSupport = $presenceDaysSupport - 1; $presenceDaysSupport = $presenceDays;
$presenceDays++;
} }
return array_merge( return array_merge(
$this->resource->toArray(), $this->resource->toArray(),
[ [
+1
View File
@@ -89,6 +89,7 @@ class EventResource extends JsonResource{
$returnArray['totalParticipantCount'] = $this->event->participants()->count(); $returnArray['totalParticipantCount'] = $this->event->participants()->count();
$returnArray['supportPersonIndex'] = $this->event->support_per_person->toString(); $returnArray['supportPersonIndex'] = $this->event->support_per_person->toString();
$returnArray['supportPersonValue'] = $this->event->support_per_person->getAmount();
$returnArray['supportPerson'] = $this->calculateSupportPerPerson($returnArray['participants']); $returnArray['supportPerson'] = $this->calculateSupportPerPerson($returnArray['participants']);
$returnArray['income'] = $this->calculateIncomes($returnArray['participants'], $returnArray['supportPerson']['amount']); $returnArray['income'] = $this->calculateIncomes($returnArray['participants'], $returnArray['supportPerson']['amount']);
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('sepa_payment_elements', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('tenant');
$table->foreignId('invoice_id')->constrained('invoices', 'id')->restrictOnDelete()->cascadeOnUpdate();
$table->foreignId('cost_unit_id')->constrained('cost_units', 'id')->restrictOnDelete()->cascadeOnUpdate();
$table->float('amount', 2);
$table->string('recipient_name');
$table->string('recipient_iban');
$table->string('payment_purpose');
$table->boolean('exported')->default(false);
$table->dateTime('exported_at')->nullable();
$table->foreign('tenant')->references('slug')->on('tenants')->restrictOnDelete()->cascadeOnUpdate();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('sepa_payment_elements');
}
};
+56 -7
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import {reactive, onMounted, ref} from 'vue'; import {reactive, onMounted, ref, computed} from 'vue';
import Icon from "../../../app/Views/Components/Icon.vue"; import Icon from "../../../app/Views/Components/Icon.vue";
import GlobalWidgets from "../../../app/Views/Partials/GlobalWidgets/GlobalWidgets.vue"; import GlobalWidgets from "../../../app/Views/Partials/GlobalWidgets/GlobalWidgets.vue";
import {toast} from "vue3-toastify"; import {toast} from "vue3-toastify";
@@ -20,7 +20,8 @@ const globalProps = reactive({
currentPath: '/', currentPath: '/',
errors: {}, errors: {},
availableLocalGroups: [], availableLocalGroups: [],
message: '' message: '',
currentEvent: null,
}); });
const sidebarOpen = ref(false); const sidebarOpen = ref(false);
@@ -51,13 +52,15 @@ onMounted(async () => {
} }
}); });
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const showCurrentEventLink = computed(() => {
if (!globalProps.currentEvent) {
return false;
}
return currentPath !== '/event/details/' + globalProps.currentEvent.identifier;
});
const props = defineProps({ const props = defineProps({
title: { type: String, default: 'App' }, title: { type: String, default: 'App' },
flash: { type: Object, default: () => ({}) } flash: { type: Object, default: () => ({}) }
@@ -84,6 +87,16 @@ const props = defineProps({
<label id="show_username" v-if="globalProps.user !== null">Willkommen, {{ globalProps.user.nicename }}</label> <label id="show_username" v-if="globalProps.user !== null">Willkommen, {{ globalProps.user.nicename }}</label>
</div> </div>
<a
v-if="showCurrentEventLink"
:href="'/event/details/' + globalProps.currentEvent.identifier"
class="current-event-link"
:title="'Zur Veranstaltung: ' + globalProps.currentEvent.name"
>
<Icon name="calendar-day" />
<span class="current-event-link-label">{{ globalProps.currentEvent.name }}</span>
</a>
<div class="header-actions" v-if="globalProps.user !== null"> <div class="header-actions" v-if="globalProps.user !== null">
<div class="user-info"> <div class="user-info">
<a href="/messages" class="header-link-anonymous" title="Meine Nachrichten"> <a href="/messages" class="header-link-anonymous" title="Meine Nachrichten">
@@ -394,6 +407,34 @@ const props = defineProps({
align-items: center; align-items: center;
} }
/* ─── Direktlink zum aktuellen Event ─── */
.current-event-link {
display: none; /* per Default ausgeblendet nur auf Mobile sichtbar */
align-items: center;
gap: 6px;
color: #1d4899;
font-weight: bold;
text-decoration: none;
padding: 6px 10px;
border-radius: 4px;
margin-right: 10px;
max-width: 50%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-event-link:hover {
background-color: #1d4899;
color: #ffffff;
}
.current-event-link-label {
overflow: hidden;
text-overflow: ellipsis;
}
/* ═══════════════════════════════════════════ /* ═══════════════════════════════════════════
TABLET (640px 1023px) TABLET (640px 1023px)
═══════════════════════════════════════════ */ ═══════════════════════════════════════════ */
@@ -455,6 +496,14 @@ const props = defineProps({
height: 60px; height: 60px;
} }
.current-event-link {
display: inline-flex;
}
.current-event-link-label {
max-width: 120px;
}
.left-side h1 { .left-side h1 {
font-size: 1rem; font-size: 1rem;
} }
+1 -1
View File
@@ -1 +1 @@
4.4.0 4.4.1