20 Commits

Author SHA1 Message Date
th.guenther 998a799c3a Fixed issue Crons running for tenants 2026-05-23 19:27:20 +02:00
th.guenther ef8f3ebe6c Small improvements 2026-05-23 19:26:59 +02:00
th.guenther 92976fbf27 New Responsive design 2026-05-23 18:55:41 +02:00
th.guenther e7e7f039b8 New Responsive design 2026-05-23 18:08:35 +02:00
th.guenther 0d436d8190 New Responsive design 2026-05-23 18:08:27 +02:00
th.guenther 3fdbaf0285 Release 2026-05-23 15:58:06 +02:00
th.guenther 8a049efe49 Release 2026-05-14 17:59:22 +02:00
th.guenther a62b2214c4 Merge pull request 'Comments for invoices' (#3) from dev-4.2.0 into main
Comments for invoices
Bugfix: Birthday can be stored
Bugfix: Cronjobs  run correctly
2026-05-14 17:00:56 +02:00
th.guenther f00e665a03 Release 2026-05-14 16:59:25 +02:00
th.guenther 454d83de2e Fixed cronjobs 2026-05-14 16:58:41 +02:00
th.guenther 30cc0b79c5 Bugfix: Birthday can be stored 2026-05-14 16:17:44 +02:00
th.guenther 775d9158a6 Comments for invoices 2026-05-14 16:16:36 +02:00
th.guenther c60429ad28 Merge pull request 'Direct payments for invoices' (#2) from devl-4.1.0 into main
Reviewed-on: #2
2026-05-12 16:05:02 +02:00
th.guenther 0cf9602958 Direct payments for invoices
Events can be moved to archive and moved back
Fixed validation
2026-05-12 16:04:15 +02:00
th.guenther e2fb616565 Fixed issue tetanus vaccination 2026-05-04 14:08:06 +02:00
th.guenther faaf12b6a4 Minor improvements 2026-05-02 00:45:30 +02:00
th.guenther 1a69a20750 Minor improvements 2026-05-02 00:33:43 +02:00
th.guenther 71cb09f051 Improvement for Uploading of receipts 2026-05-02 00:21:54 +02:00
th.guenther b668195e21 Merge remote-tracking branch 'origin/main' 2026-05-01 20:41:56 +02:00
th.guenther 94934027fa Last bugfixes 2026-05-01 20:41:07 +02:00
79 changed files with 3144 additions and 792 deletions
@@ -3,6 +3,7 @@
import InvoiceDetails from "../../../Invoice/Views/Partials/invoiceDetails/InvoiceDetails.vue"; import InvoiceDetails from "../../../Invoice/Views/Partials/invoiceDetails/InvoiceDetails.vue";
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js"; import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
import {ref} from "vue"; import {ref} from "vue";
import {toast} from "vue3-toastify";
const props = defineProps({ const props = defineProps({
data: Object data: Object
@@ -12,7 +13,7 @@
const invoice = ref(null) const invoice = ref(null)
const show_invoice = ref(false) const show_invoice = ref(false)
const localData = ref(props.data) const localData = ref(props.data)
console.log(props.data)
async function openInvoiceDetails(invoiceId) { async function openInvoiceDetails(invoiceId) {
const url = '/api/v1/invoice/details/' + invoiceId const url = '/api/v1/invoice/details/' + invoiceId
@@ -40,6 +41,50 @@
} }
} }
async function exportPayouts() {
toast.info('Der Export wird nun gestartet. Bitte verlasse diese Seite nicht, bis der Export abgeschlossen ist.')
const response = await fetch('/api/v1/core/retrieve-global-data');
const data = await response.json();
const exportUrl = '/api/v1/cost-unit/' + props.data.costUnit.id + '/export-payouts';
try {
if (data.tenant.download_exports) {
const response = await fetch(exportUrl, {
headers: { "Content-Type": "application/json" },
});
if (!response.ok) throw new Error('Fehler beim Export (ZIP)');
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 = "Abrechnungen-Sippenstunden.zip";
document.body.appendChild(a);
a.click();
setTimeout(() => {
window.URL.revokeObjectURL(downloadUrl);
document.body.removeChild(a);
}, 100);
} else {
const response = await request(exportUrl, {
method: "GET",
});
toast.success(response.message);
}
reload()
} catch (err) {
toast.error('Beim Export der Abrechnungen ist ein Fehler aufgetreten.');
}
}
</script> </script>
<template> <template>
@@ -56,7 +101,7 @@
</td> </td>
<td style="width: 150px;"> <td style="width: 150px;">
<Icon v-if="invoice.donation" name="hand-holding-dollar" style="color: #ffffff; background-color: green" /> <Icon v-if="invoice.donation" name="hand-holding-dollar" style="color: #ffffff; background-color: green" />
<Icon v-if="invoice.alreadyPaid" name="comments-dollar" style="color: #ffffff; background-color: green" /> <Icon v-if="invoice.externalPayment" name="comments-dollar" style="color: #ffffff; background-color: green" />
</td> </td>
<td> <td>
{{invoice.contactName}}<br /> {{invoice.contactName}}<br />
@@ -68,6 +113,13 @@
<input type="button" value="Abrechnung Anzeigen" @click="openInvoiceDetails(invoice.id)" /> <input type="button" value="Abrechnung Anzeigen" @click="openInvoiceDetails(invoice.id)" />
</td> </td>
</tr> </tr>
<tr v-if="props.data.endpoint === 'approved'">
<td colspan="5"></td>
<td>
<a style="font-size: 10pt;" class="link" @click="exportPayouts()">Genehmigte Abrechnungen exportieren</a>
</td>
</tr>
</table> </table>
<p v-else>Es sind keine Abrechnungen in dieser Kategorie vorhanden.</p> <p v-else>Es sind keine Abrechnungen in dieser Kategorie vorhanden.</p>
+302 -97
View File
@@ -56,132 +56,337 @@ const submit = async () => {
<template> <template>
<AppLayout title='Persönliche Daten'> <AppLayout title='Persönliche Daten'>
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px; overflow-x: hidden;"> <shadowed-box class="personal-data-box">
<div class="max-w-2xl mx-auto p-6">
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<table class="form-table" style="width: 90%; margin: 10px;"> <!-- Sektion: Stammdaten -->
<fieldset class="pd-fieldset">
<legend>Stammdaten</legend>
<!-- Nicht veränderbare Felder --> <div class="pd-grid">
<tr> <div class="pd-field pd-field--readonly">
<td style="width: 200px; padding: 5px;">Vorname:</td> <label>Vorname</label>
<td><span class="text-gray-700">{{ personalData.firstname }}</span></td> <div class="pd-readonly">{{ personalData.firstname }}</div>
</tr> </div>
<tr>
<td style="width: 200px; padding: 5px;">Nachname:</td>
<td><span class="text-gray-700">{{ personalData.lastname }}</span></td>
</tr>
<div class="pd-field pd-field--readonly">
<label>Nachname</label>
<div class="pd-readonly">{{ personalData.lastname }}</div>
</div>
<!-- Veränderbare Felder --> <div class="pd-field">
<tr> <label for="nickname">Pfadiname</label>
<td>Pfadiname:</td> <input id="nickname" type="text" v-model="form.nickname" />
<td><input type="text" v-model="form.nickname" /></td> </div>
</tr>
<tr> <div class="pd-field">
<td>E-Mail:</td> <label for="birthday">Geburtsdatum</label>
<td><input type="email" v-model="form.email" /></td> <input id="birthday" type="date" v-model="form.birthday" />
</tr> </div>
<tr> </div>
<td>Telefon:</td> </fieldset>
<td><input type="text" v-model="form.phone" /></td>
</tr> <!-- Sektion: Kontakt -->
<tr> <fieldset class="pd-fieldset">
<td>Straße / Hausnummer:</td> <legend>Kontakt</legend>
<td><input type="text" v-model="form.address1" /></td>
</tr> <div class="pd-grid">
<tr> <div class="pd-field">
<td>Adresszusatz:</td> <label for="email">E-Mail</label>
<td><input type="text" v-model="form.address2" /></td> <input id="email" type="email" v-model="form.email" />
</tr> </div>
<tr>
<td>PLZ:</td> <div class="pd-field">
<td><input type="text" v-model="form.postcode" /></td> <label for="phone">Telefon</label>
</tr> <input id="phone" type="text" v-model="form.phone" />
<tr> </div>
<td>Ort:</td>
<td><input type="text" v-model="form.city" /></td> <div class="pd-field pd-field--full">
</tr> <label for="address1">Straße / Hausnummer</label>
<tr> <input id="address1" type="text" v-model="form.address1" />
<td style="width: 200px; padding: 5px;">Geburtsdatum:</td> </div>
<td><input type="date" v-model="form.birthday" /></td>
</tr> <div class="pd-field pd-field--full">
<tr> <label for="address2">Adresszusatz</label>
<td>Medikamente:</td> <input id="address2" type="text" v-model="form.address2" />
<td><input type="text" v-model="form.medications" /></td> </div>
</tr>
<tr> <div class="pd-field pd-field--narrow">
<td>Allergien:</td> <label for="postcode">PLZ</label>
<td><input type="text" v-model="form.allergies" /></td> <input id="postcode" type="text" v-model="form.postcode" maxlength="5" />
</tr> </div>
<tr>
<td>Unverträglichkeiten:</td> <div class="pd-field pd-field--wide">
<td><input type="text" v-model="form.intolerances" /></td> <label for="city">Ort</label>
</tr> <input id="city" type="text" v-model="form.city" />
<tr> </div>
<td>Letzte Tetanus-Impfung:</td> </div>
<td><input type="date" v-model="form.tetanusVaccination" /></td> </fieldset>
</tr>
<tr> <!-- Sektion: Gesundheit -->
<td>Ernährungsgewohnheiten:</td> <fieldset class="pd-fieldset">
<td> <legend>Gesundheit</legend>
<select v-model="form.eatingHabits">
<div class="pd-grid">
<div class="pd-field pd-field--full">
<label for="medications">Medikamente</label>
<input id="medications" type="text" v-model="form.medications" />
</div>
<div class="pd-field pd-field--full">
<label for="allergies">Allergien</label>
<input id="allergies" type="text" v-model="form.allergies" />
</div>
<div class="pd-field pd-field--full">
<label for="intolerances">Unverträglichkeiten</label>
<input id="intolerances" type="text" v-model="form.intolerances" />
</div>
<div class="pd-field">
<label for="tetanus">Letzte Tetanus-Impfung</label>
<input id="tetanus" type="date" v-model="form.tetanusVaccination" />
</div>
<div class="pd-field">
<label for="eating">Ernährungsgewohnheiten</label>
<select id="eating" v-model="form.eatingHabits">
<option value="EATING_HABIT_VEGAN">Vegan</option> <option value="EATING_HABIT_VEGAN">Vegan</option>
<option value="EATING_HABIT_VEGETARIAN">Vegetarisch</option> <option value="EATING_HABIT_VEGETARIAN">Vegetarisch</option>
<option value="EATING_HABIT_OMNIVOR">Omnivor</option> <option value="EATING_HABIT_OMNIVOR">Omnivor</option>
</select> </select>
</td> </div>
</tr> </div>
<tr> </fieldset>
<td>Badeerlaubnis:</td>
<td> <!-- Sektion: Erlaubnisse -->
<select v-model="form.swimmingPermission"> <fieldset class="pd-fieldset">
<legend>Erlaubnisse</legend>
<div class="pd-grid">
<div class="pd-field pd-field--full">
<label for="swimming">Badeerlaubnis</label>
<select id="swimming" v-model="form.swimmingPermission">
<option value="SWIMMING_PERMISSION_ALLOWED">Erteilt, kann schwimmen</option> <option value="SWIMMING_PERMISSION_ALLOWED">Erteilt, kann schwimmen</option>
<option value="SWIMMING_PERMISSION_LIMITED">Erteilt, kann nicht schwimmen</option> <option value="SWIMMING_PERMISSION_LIMITED">Erteilt, kann nicht schwimmen</option>
<option value="SWIMMING_PERMISSION_DENIED">Nicht erteilt</option> <option value="SWIMMING_PERMISSION_DENIED">Nicht erteilt</option>
</select> </select>
</td> </div>
</tr>
<tr> <div class="pd-field pd-field--full">
<td>Erste-Hilfe-Erlaubnis:</td> <label for="firstaid">Erste-Hilfe-Erlaubnis</label>
<td> <select id="firstaid" v-model="form.firstAidPermission">
<select v-model="form.firstAidPermission">
<option value="FIRST_AID_PERMISSION_ALLOWED">Erweiterte Erste Hilfe erlaubt</option> <option value="FIRST_AID_PERMISSION_ALLOWED">Erweiterte Erste Hilfe erlaubt</option>
<option value="FIRST_AID_PERMISSION_DENIED">Erweiterte Erste Hilfe verweigert</option> <option value="FIRST_AID_PERMISSION_DENIED">Erweiterte Erste Hilfe verweigert</option>
</select> </select>
</td> </div>
</tr> </div>
<tr> </fieldset>
<td>Kontoinhaber*in:</td>
<td><input type="text" v-model="form.bankAccountOwner" /></td>
</tr>
<tr>
<td>IBAN:</td>
<td><IbanInput v-model="form.bankAccountIban" /></td>
</tr>
<tr> <!-- Sektion: Bankverbindung -->
<td colspan="2" class="btn-row" style="padding-top: 20px;"> <fieldset class="pd-fieldset">
<button type="submit" class="button" :disabled="saving"> <legend>Bankverbindung</legend>
<div class="pd-grid">
<div class="pd-field pd-field--full">
<label for="owner">Kontoinhaber*in</label>
<input id="owner" type="text" v-model="form.bankAccountOwner" />
</div>
<div class="pd-field pd-field--full">
<label for="iban">IBAN</label>
<IbanInput id="iban" v-model="form.bankAccountIban" />
</div>
</div>
</fieldset>
<div class="pd-actions">
<button type="submit" class="button pd-submit" :disabled="saving">
{{ saving ? 'Wird gespeichert…' : 'Speichern' }} {{ saving ? 'Wird gespeichert…' : 'Speichern' }}
</button> </button>
</td>
</tr>
</table>
</form>
</div> </div>
</form>
</shadowed-box> </shadowed-box>
</AppLayout> </AppLayout>
</template> </template>
<style scoped> <style scoped>
textarea { .personal-data-box {
width: 95%;
margin: 20px auto;
padding: 20px;
overflow-x: hidden;
}
.pd-fieldset {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px 20px 20px;
margin-bottom: 18px;
background-color: #ffffff;
box-shadow: 0 0 8px #efefef;
}
.pd-fieldset legend {
font-weight: 700;
font-size: 1rem;
padding: 4px 12px;
background-color: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #1d4899;
}
/* Grid: 2-spaltig auf Desktop */
.pd-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 20px;
}
.pd-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.pd-field--full {
grid-column: 1 / -1;
}
.pd-field--narrow {
grid-column: span 1;
max-width: 180px;
}
.pd-field--wide {
grid-column: span 1;
}
.pd-field label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
}
.pd-field input,
.pd-field select {
width: 100%; width: 100%;
padding: 6px 10px; padding: 8px 10px;
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 6px; border-radius: 6px;
font-size: 0.95rem; font-size: 0.95rem;
box-sizing: border-box; box-sizing: border-box;
resize: vertical; background-color: #ffffff;
}
.pd-field input:focus,
.pd-field select:focus {
outline: none;
border-color: #1d4899;
}
.pd-readonly {
padding: 8px 10px;
border: 1px dashed #e5e7eb;
border-radius: 6px;
background-color: #f9fafb;
color: #6b7280;
font-size: 0.95rem;
}
.pd-actions {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
.pd-submit {
padding: 10px 28px;
font-weight: 600;
cursor: pointer;
border: 1px solid #809dd5;
background-color: #ffffff;
}
.pd-submit:hover:not(:disabled) {
background-color: #1d4899;
color: #ffffff;
}
.pd-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ─── Tablet (6401023px) ─── */
@media (max-width: 1023px) {
.personal-data-box {
width: 100%;
margin: 10px auto;
padding: 14px;
}
.pd-fieldset {
padding: 12px 14px 16px;
}
.pd-grid {
gap: 12px 16px;
}
}
/* ─── Smartphone (< 640px) ─── */
@media (max-width: 639px) {
.personal-data-box {
width: 100%;
margin: 0;
padding: 10px;
border-radius: 0;
}
.pd-fieldset {
padding: 10px 12px 14px;
margin-bottom: 14px;
}
.pd-fieldset legend {
font-size: 0.9rem;
padding: 3px 10px;
}
/* Grid: 1-spaltig auf Smartphone */
.pd-grid {
grid-template-columns: 1fr;
gap: 10px;
}
/* Auch "narrow" und "wide" werden volle Breite */
.pd-field--narrow,
.pd-field--wide,
.pd-field--full {
grid-column: 1 / -1;
max-width: 100%;
}
.pd-field label {
font-size: 0.82rem;
}
.pd-field input,
.pd-field select {
padding: 10px;
font-size: 1rem; /* iOS Zoom-Prevention bei >=16px */
}
.pd-actions {
justify-content: stretch;
}
.pd-submit {
width: 100%;
padding: 14px;
}
} }
</style> </style>
@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Event\Actions\ArchiveEvent;
class ArchiveEventCommand {
public ArchiveEventRequest $request;
public function __construct(ArchiveEventRequest $request) {
$this->request = $request;
}
public function execute(): ArchiveEventResponse {
$response = new ArchiveEventResponse();
$this->request->event->archived = true;
$this->request->event->save();
$response->success = true;
return $response;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Domains\Event\Actions\ArchiveEvent;
use App\Models\Event;
class ArchiveEventRequest {
public Event $event;
public function __construct(Event $event) {
$this->event = $event;
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Domains\Event\Actions\ArchiveEvent;
class ArchiveEventResponse {
public bool $success;
public function __construct() {
$this->success = false;
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Event\Actions\UnarchiveEvent;
class UnarchiveEventCommand {
public UnarchiveEventRequest $request;
public function __construct(UnarchiveEventRequest $request) {
$this->request = $request;
}
public function execute(): UnarchiveEventResponse {
$response = new UnarchiveEventResponse();
$this->request->event->archived = false;
$this->request->event->save();
$response->success = true;
return $response;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Domains\Event\Actions\UnarchiveEvent;
use App\Models\Event;
class UnarchiveEventRequest {
public Event $event;
public function __construct(Event $event) {
$this->event = $event;
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Domains\Event\Actions\UnarchiveEvent;
class UnarchiveEventResponse {
public bool $success;
public function __construct() {
$this->success = false;
}
}
@@ -18,7 +18,7 @@ class UpdateParticipantRequest {
public string $birthday, public string $birthday,
public string $email_1, public string $email_1,
public string $phone_1, public string $phone_1,
public string $contact_person, public ?string $contact_person,
public ?string $email_2, public ?string $email_2,
public ?string $phone_2, public ?string $phone_2,
public string $arrival, public string $arrival,
@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Providers\InertiaProvider;
use App\Scopes\CommonController;
use Illuminate\Http\Request;
use Inertia\Response;
class ArchivedEventsController extends CommonController
{
public function __invoke(Request $request): Response {
$events = [];
foreach ($this->events->getEventsByCriteria(['archived' => true]) as $event) {
$events[] = [
'id' => $event->id,
'name' => $event->name,
'location' => $event->location,
'postalCode' => $event->postal_code,
'eventBegin' => $event->start_date->format('d.m.Y'),
'eventEnd' => $event->end_date->format('d.m.Y'),
];
}
return (new InertiaProvider('Event/ArchivedEvents', ['events' => $events]))->render();
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Actions\ArchiveEvent\ArchiveEventCommand;
use App\Domains\Event\Actions\ArchiveEvent\ArchiveEventRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EventArchiveController extends CommonController
{
public function __invoke(string $eventId, Request $request): JsonResponse {
$event = $this->events->getByIdentifier($eventId);
$archiveEventRequest = new ArchiveEventRequest($event);
$archiveEventCommand = new ArchiveEventCommand($archiveEventRequest);
$response = $archiveEventCommand->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->success ? 'Das Event wurde erfolgreich archiviert.' : 'Beim Archivieren des Events ist ein Fehler aufgetreten.'
]);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Event\Controllers;
use App\Domains\Event\Actions\UnarchiveEvent\UnarchiveEventCommand;
use App\Domains\Event\Actions\UnarchiveEvent\UnarchiveEventRequest;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EventUnarchiveController extends CommonController
{
public function __invoke(int $eventId, Request $request): JsonResponse {
$event = $this->events->getById($eventId);
$unarchiveRequest = new UnarchiveEventRequest($event);
$unarchiveCommand = new UnarchiveEventCommand($unarchiveRequest);
$response = $unarchiveCommand->execute();
return response()->json(['status' => $response->success ? 'success' : 'error']);
}
}
@@ -47,7 +47,5 @@ class ParticipantPaymentController extends CommonController
'class' => $amountLeft->getAmount() != 0 ? 'not-paid' : 'paid', 'class' => $amountLeft->getAmount() != 0 ? 'not-paid' : 'paid',
] ]
]); ]);
dd($participant);
} }
} }
+5 -2
View File
@@ -2,6 +2,8 @@
use App\Domains\Event\Controllers\CreateController; use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController; use App\Domains\Event\Controllers\DetailsController;
use App\Domains\Event\Controllers\EventArchiveController;
use App\Domains\Event\Controllers\EventUnarchiveController;
use App\Domains\Event\Controllers\MailCompose\ByGroupController; use App\Domains\Event\Controllers\MailCompose\ByGroupController;
use App\Domains\Event\Controllers\MailCompose\SendController; use App\Domains\Event\Controllers\MailCompose\SendController;
use App\Domains\Event\Controllers\ParticipantController; use App\Domains\Event\Controllers\ParticipantController;
@@ -26,6 +28,7 @@ Route::prefix('api/v1')
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
Route::post('/create', [CreateController::class, 'doCreate']); Route::post('/create', [CreateController::class, 'doCreate']);
Route::prefix('{eventIdentifier}/mailing')->group(function () { Route::prefix('{eventIdentifier}/mailing')->group(function () {
Route::post('/compose/to-group/{groupType}', ByGroupController::class); Route::post('/compose/to-group/{groupType}', ByGroupController::class);
Route::post('/send', SendController::class); Route::post('/send', SendController::class);
@@ -37,8 +40,8 @@ Route::prefix('api/v1')
Route::get('/summary', [DetailsController::class, 'summary']); Route::get('/summary', [DetailsController::class, 'summary']);
Route::get('/participants/{listType}', [DetailsController::class, 'listParticipants']); Route::get('/participants/{listType}', [DetailsController::class, 'listParticipants']);
Route::post('/unarchive', EventUnarchiveController::class);
Route::get('/archive', EventArchiveController::class);
Route::post('/event-managers', [DetailsController::class, 'updateEventManagers']); Route::post('/event-managers', [DetailsController::class, 'updateEventManagers']);
Route::post('/participation-fees', [DetailsController::class, 'updateParticipationFees']); Route::post('/participation-fees', [DetailsController::class, 'updateParticipationFees']);
Route::post('/common-settings', [DetailsController::class, 'updateCommonSettings']); Route::post('/common-settings', [DetailsController::class, 'updateCommonSettings']);
+2
View File
@@ -1,5 +1,6 @@
<?php <?php
use App\Domains\Event\Controllers\ArchivedEventsController;
use App\Domains\Event\Controllers\AvailableEventsController; use App\Domains\Event\Controllers\AvailableEventsController;
use App\Domains\Event\Controllers\CreateController; use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController; use App\Domains\Event\Controllers\DetailsController;
@@ -24,5 +25,6 @@ Route::middleware(IdentifyTenant::class)->group(function () {
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
Route::get('/create-event', CreateController::class); Route::get('/create-event', CreateController::class);
Route::get('/archived-events', ArchivedEventsController::class);
}); });
}); });
@@ -0,0 +1,76 @@
<script setup>
import { ref } from 'vue';
import AppLayout from "../../../../resources/js/layouts/AppLayout.vue";
import ShadowedBox from "../../../Views/Components/ShadowedBox.vue";
import { toast } from 'vue3-toastify';
import axios from 'axios';
const props = defineProps({
events: Array,
});
const events = ref([...props.events]);
async function unarchiveEvent(eventId) {
try {
const response = await axios.post(`/api/v1/event/details/${eventId}/unarchive`);
if (response.data.status === 'success') {
events.value = events.value.filter(e => e.id !== eventId);
toast.success('Veranstaltung wurde aus dem Archiv geholt.');
} else {
toast.error('Fehler beim Wiederherstellen der Veranstaltung.');
}
} catch {
toast.error('Ein unerwarteter Fehler ist aufgetreten.');
}
}
</script>
<template>
<AppLayout title="Archivierte Veranstaltungen">
<div style="width: 95%; margin: 20px auto;">
<h1 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 20px;">Archivierte Veranstaltungen</h1>
<div
v-if="events.length === 0"
style="text-align: center; color: #6b7280; padding: 40px 0;"
>
Es sind keine archivierten Veranstaltungen vorhanden.
</div>
<ShadowedBox
v-for="event in events"
:key="event.id"
style="padding: 20px; margin-bottom: 16px;"
>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;">
<div>
<h2 style="margin: 0 0 4px 0; font-size: 1.1rem; font-weight: 600;">{{ event.name }}</h2>
<span style="color: #6b7280; font-size: 0.875rem;">
{{ event.postalCode }} {{ event.location }}
&nbsp;·&nbsp;
{{ event.eventBegin }} {{ event.eventEnd }}
</span>
</div>
<button
@click="unarchiveEvent(event.id)"
style="
padding: 8px 20px;
background-color: #f59e0b;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
white-space: nowrap;
"
>
Aus dem Archiv holen
</button>
</div>
</ShadowedBox>
</div>
</AppLayout>
</template>
@@ -8,67 +8,160 @@ const props = defineProps({
</script> </script>
<template> <template>
<div style="width: 95%; margin: 20px auto;"> <div class="available-events-wrapper">
<div v-if="props.events.length === 0" style="text-align: center; color: #6b7280; padding: 40px 0;"> <div v-if="props.events.length === 0" class="available-events-empty">
Aktuell sind keine Veranstaltungen verfügbar. Aktuell sind keine Veranstaltungen verfügbar.
</div> </div>
<shadowed-box <shadowed-box
v-for="event in props.events" v-for="event in props.events"
:key="event.id" :key="event.id"
style="padding: 24px; margin-bottom: 20px;" class="available-event-card"
> >
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 12px;"> <div class="available-event-header">
<div> <div>
<h2 style="margin: 0 0 4px 0; font-size: 1.25rem;">{{ event.name }}</h2> <h2 class="available-event-title">{{ event.name }}</h2>
<span style="color: #6b7280; font-size: 0.9rem;">{{ event.postalCode }} {{ event.location }}</span> <span class="available-event-location">{{ event.postalCode }} {{ event.location }}</span>
</div> </div>
<span <span
v-if="event.registrationAllowed" v-if="event.registrationAllowed"
style="background: #d1fae5; color: #065f46; padding: 4px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; white-space: nowrap;" class="available-event-badge available-event-badge--open"
> >
Anmeldung offen Anmeldung offen
</span> </span>
<span <span
v-else v-else
style="background: #fee2e2; color: #991b1b; padding: 4px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; white-space: nowrap;" class="available-event-badge available-event-badge--closed"
> >
Anmeldung geschlossen Anmeldung geschlossen
</span> </span>
</div> </div>
<hr style="margin: 16px 0; border: none; border-top: 1px solid #e5e7eb;" /> <hr class="available-event-divider" />
<table style="width: 100%; border-collapse: collapse;"> <table class="available-event-table">
<tr> <tr>
<th style="text-align: left; padding: 6px 12px 6px 0; width: 220px; color: #374151; font-weight: 600;">Zeitraum</th> <th>Zeitraum</th>
<td style="padding: 6px 0; color: #111827;">{{ event.eventBegin }} {{ event.eventEnd }} ({{ event.duration }} Tage)</td> <td>{{ event.eventBegin }} {{ event.eventEnd }} ({{ event.duration }} Tage)</td>
</tr> </tr>
<tr> <tr>
<th style="text-align: left; padding: 6px 12px 6px 0; width: 220px; color: #374151; font-weight: 600;">Veranstaltungsort</th> <th>Veranstaltungsort</th>
<td style="padding: 6px 0; color: #111827;">{{ event.postalCode }} {{ event.location }}</td> <td>{{ event.postalCode }} {{ event.location }}</td>
</tr> </tr>
<tr> <tr>
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Frühbuchen bis</th> <th>Frühbuchen bis</th>
<td style="padding: 6px 0; color: #111827;">{{ event.earlyBirdEnd.formatted }}</td> <td>{{ event.earlyBirdEnd.formatted }}</td>
</tr> </tr>
<tr> <tr>
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Anmeldeschluss</th> <th>Anmeldeschluss</th>
<td style="padding: 6px 0; color: #111827;">{{ event.registrationFinalEnd.formatted }}</td> <td>{{ event.registrationFinalEnd.formatted }}</td>
</tr> </tr>
<tr v-if="event.email"> <tr v-if="event.email">
<th style="text-align: left; padding: 6px 12px 6px 0; color: #374151; font-weight: 600;">Kontakt</th> <th>Kontakt</th>
<td style="padding: 6px 0;"> <td>
<a :href="'mailto:' + event.email" style="color: #2563eb;">{{ event.email }}</a> <a :href="'mailto:' + event.email" class="available-event-mail">{{ event.email }}</a>
</td> </td>
</tr> </tr>
</table> </table>
<div style="margin-top: 20px; display: flex; justify-content: flex-end;"> <div class="available-event-actions">
<a <a
:href="'/event/' + event.identifier + '/signup'" :href="'/event/' + event.identifier + '/signup'"
style=" class="available-event-button"
:style="{ opacity: event.registrationAllowed ? '1' : '0.5', pointerEvents: event.registrationAllowed ? 'auto' : 'none' }"
>
Zur Anmeldung
</a>
</div>
</shadowed-box>
</div>
</template>
<style scoped>
.available-events-wrapper {
width: 95%;
margin: 20px auto;
}
.available-events-empty {
text-align: center;
color: #6b7280;
padding: 40px 0;
}
.available-event-card {
padding: 24px;
margin-bottom: 20px;
}
.available-event-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
}
.available-event-title {
margin: 0 0 4px 0;
font-size: 1.25rem;
}
.available-event-location {
color: #6b7280;
font-size: 0.9rem;
}
.available-event-badge {
padding: 4px 12px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
}
.available-event-badge--open { background: #d1fae5; color: #065f46; }
.available-event-badge--closed { background: #fee2e2; color: #991b1b; }
.available-event-divider {
margin: 16px 0;
border: none;
border-top: 1px solid #e5e7eb;
}
.available-event-table {
width: 100%;
border-collapse: collapse;
}
.available-event-table th {
text-align: left;
padding: 6px 12px 6px 0;
width: 220px;
color: #374151;
font-weight: 600;
vertical-align: top;
}
.available-event-table td {
padding: 6px 0;
color: #111827;
word-break: break-word;
}
.available-event-mail {
color: #2563eb;
word-break: break-all;
}
.available-event-actions {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.available-event-button {
display: inline-block; display: inline-block;
padding: 10px 24px; padding: 10px 24px;
background-color: #2563eb; background-color: #2563eb;
@@ -77,24 +170,66 @@ const props = defineProps({
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
opacity: 1;
transition: background-color 0.2s; transition: background-color 0.2s;
" }
:style="{ opacity: event.registrationAllowed ? '1' : '0.5', pointerEvents: event.registrationAllowed ? 'auto' : 'none' }"
>
Zur Anmeldung
</a>
</div>
</shadowed-box>
</div><div style="width: 95%; margin: 20px auto;"> /* ─── Tablet ─── */
@media (max-width: 1023px) {
.available-events-wrapper {
width: 100%;
padding: 0 10px;
}
<div v-if="props.events.length === 0" style="text-align: center; color: #6b7280; padding: 40px 0;"> .available-event-table th {
Aktuell sind keine Veranstaltungen verfügbar. width: 160px;
</div> }
</div> }
</template>
<style scoped> /* ─── Smartphone ─── */
@media (max-width: 639px) {
.available-event-card {
padding: 16px;
margin-bottom: 14px;
}
.available-event-title {
font-size: 1.05rem;
}
/* Tabelle vertikal stapeln: Label über Wert */
.available-event-table,
.available-event-table tbody,
.available-event-table tr,
.available-event-table th,
.available-event-table td {
display: block;
width: 100% !important;
}
.available-event-table tr {
margin-bottom: 8px;
}
.available-event-table th {
padding: 4px 0 2px 0;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6b7280;
}
.available-event-table td {
padding: 0 0 4px 0;
}
.available-event-actions {
justify-content: stretch;
}
.available-event-button {
width: 100%;
text-align: center;
padding: 12px 24px;
}
}
</style> </style>
+59 -3
View File
@@ -56,6 +56,16 @@
mailCompose.value = true mailCompose.value = true
} }
async function archiveEvent() {
const response = await fetch("/api/v1/event/details/" + props.data.event.identifier + "/archive" );
const data = await response.json();
if (data.status === 'success') {
toast.success(data.message)
} else {
toast.error(data.message)
}
}
async function sendPaymentReminder() { async function sendPaymentReminder() {
toast.info("Die Nachrichten werden gesendet. Bitte verlasse diese Seite nicht."); toast.info("Die Nachrichten werden gesendet. Bitte verlasse diese Seite nicht.");
const response = await fetch("/api/v1/event/" + props.data.event.identifier + "/send-payment-reminder/" ); const response = await fetch("/api/v1/event/" + props.data.event.identifier + "/send-payment-reminder/" );
@@ -69,12 +79,37 @@
</script> </script>
<template> <template>
<div class="smartphone-actions">
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/first-aid-list'">
<input type="button" value="Erste-Hilfe-Liste (PDF)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/csv/participant-list'">
<input type="button" value="Teili-Liste (CSV)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/kitchen-list'">
<input type="button" value="Küchenübersicht (PDF)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/amount-list'">
<input type="button" value="Beitragsliste (PDF)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'">
<input type="button" value="Getränkeliste (PDF)" />
</a><br/>
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
<input type="button" value="Foto-Erlaubnis (PDF)" />
</a><br/>
</div>
<ParticipationFees v-if="displayData === 'participationFees'" :event="dynamicProps.event" @close="showMain" /> <ParticipationFees v-if="displayData === 'participationFees'" :event="dynamicProps.event" @close="showMain" />
<CommonSettings v-else-if="displayData === 'commonSettings'" :event="dynamicProps.event" @close="showMain" /> <CommonSettings v-else-if="displayData === 'commonSettings'" :event="dynamicProps.event" @close="showMain" />
<EventManagement v-else-if="displayData === 'eventManagement'" :event="dynamicProps.event" @close="showMain" /> <EventManagement v-else-if="displayData === 'eventManagement'" :event="dynamicProps.event" @close="showMain" />
<div class="event-flexbox" v-else> <div class="event-flexbox" v-else>
<div class="event-flexbox-row top"> <div class="event-flexbox-row top">
<div class="left"><ParticipationSummary v-if="dynamicProps.event" :event="dynamicProps.event" /></div> <div class="left"><ParticipationSummary v-if="dynamicProps.event" :event="dynamicProps.event" /></div>
@@ -108,6 +143,8 @@
</div> </div>
</div> </div>
<div> <div>
<table> <table>
<tr> <tr>
@@ -137,7 +174,7 @@
<tr> <tr>
<th>Anmelde-URL</th> <th>Anmelde-URL</th>
<td> <td>
{{dynamicProps.event.url}}<br /> {{dynamicProps.event.urlShort}}<br />
<img :src="'/print-event-code/' + dynamicProps.event.identifier" alt="Event Code" style="width: 150px; height: 150px; margin-top: 20px;" /> <img :src="'/print-event-code/' + dynamicProps.event.identifier" alt="Event Code" style="width: 150px; height: 150px; margin-top: 20px;" />
</td> </td>
</tr> </tr>
@@ -150,6 +187,7 @@
<label style="font-size: 9pt;" class="link" @click="showEventManagement">Veranstaltungsleitung</label> &nbsp; <label style="font-size: 9pt;" class="link" @click="showEventManagement">Veranstaltungsleitung</label> &nbsp;
<label style="font-size: 9pt;" class="link" @click="showParticipationFees">Teilnahmegebühren</label> <label style="font-size: 9pt;" class="link" @click="showParticipationFees">Teilnahmegebühren</label>
<a style="font-size: 9pt;" class="link" :href="'/cost-unit/' + props.data.event.costUnit.id">Ausgabenübersicht</a> <a style="font-size: 9pt;" class="link" :href="'/cost-unit/' + props.data.event.costUnit.id">Ausgabenübersicht</a>
<a v-if="!dynamicProps.event.registrationAllowed && !dynamicProps.event.archived" style="color: #ff0000; font-size: 9pt;" class="link" @click="archiveEvent">Archivieren</a>
</div> </div>
</div> </div>
@@ -230,4 +268,22 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.smartphone-actions {
display: none;
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
.smartphone-actions {
display: block;
width: 100%;
text-align: center;
}
.event-flexbox {
display: none;
}
}
</style> </style>
@@ -86,7 +86,7 @@ watch(
form.medications = participant?.medications ?? ''; form.medications = participant?.medications ?? '';
form.extendedFirstAid = participant?.first_aid_permission ?? ''; form.extendedFirstAid = participant?.first_aid_permission ?? '';
form.swimmingPermission = participant?.swimming_permission ?? ''; form.swimmingPermission = participant?.swimming_permission ?? '';
form.tetanusVaccination = participant?.tetanus_vaccination ?? ''; form.tetanusVaccination = participant?.tetanusVaccinationEdit ?? '';
form.notes = participant?.notes ?? ''; form.notes = participant?.notes ?? '';
form.amountPaid = participant?.amountPaid.short ?? ''; form.amountPaid = participant?.amountPaid.short ?? '';
form.amountExpected = participant?.amountExpected.short ?? ''; form.amountExpected = participant?.amountExpected.short ?? '';
@@ -301,7 +301,7 @@ function mailToGroup(groupKey) {
<h2>{{ event?.name ?? "Veranstaltung" }}</h2> <h2>{{ event?.name ?? "Veranstaltung" }}</h2>
<div :key="groupKey"> <div :key="groupKey">
<div> <div>
<table style="width: 95%; margin: 20px auto; border-collapse: collapse;" v-for="[groupKey, participants] in getGroupEntries"> <table class="participants-table" v-for="[groupKey, participants] in getGroupEntries">
<thead> <thead>
<tr> <tr>
<th colspan="4" style="background: linear-gradient(to bottom, #fff, #f6f7f7); font-weight: bold"> <th colspan="4" style="background: linear-gradient(to bottom, #fff, #f6f7f7); font-weight: bold">
@@ -309,10 +309,10 @@ function mailToGroup(groupKey) {
</th> </th>
</tr> </tr>
<tr style="background: linear-gradient(to bottom, #fff, #f6f7f7);"> <tr style="background: linear-gradient(to bottom, #fff, #f6f7f7);">
<th>Name</th> <th class="pl-name">Name</th>
<th>Beitrag</th> <th class="pl-amount">Beitrag</th>
<th>E-Mail-Adresse</th> <th class="pl-email">E-Mail-Adresse</th>
<th>Telefon</th> <th class="pl-phone">Telefon</th>
</tr> </tr>
</thead> </thead>
@@ -323,7 +323,7 @@ function mailToGroup(groupKey) {
> >
<tr :class="getRowClass(participant)" :id="'participant-' + participant.identifier + '-common'"> <tr :class="getRowClass(participant)" :id="'participant-' + participant.identifier + '-common'">
<td :id="'participant-' + participant.identifier +'-name'" <td :id="'participant-' + participant.identifier +'-name'"
style="width: 300px;" class="pl-name"
:class="participant.efz_status === 'checked_invalid' ? 'efz-invalid' : :class="participant.efz_status === 'checked_invalid' ? 'efz-invalid' :
participant.efz_status === 'not_checked' ? 'efz-not-checked' : ''"> participant.efz_status === 'not_checked' ? 'efz-not-checked' : ''">
<div :id="'participant-' + participant.identifier +'-fullname'" v-html="participant.fullname" /><br /> <div :id="'participant-' + participant.identifier +'-fullname'" v-html="participant.fullname" /><br />
@@ -335,7 +335,7 @@ function mailToGroup(groupKey) {
<span :id="'participant-' + participant.identifier +'-coc-action'" v-if="participant.efz_status !== 'checked_valid' && participant.efz_status !== 'not_required'" class="link" style="color: #3cb62e; font-size: 11pt;" @click="markCocExisting(participant)">Vorhanden?</span> <span :id="'participant-' + participant.identifier +'-coc-action'" v-if="participant.efz_status !== 'checked_valid' && participant.efz_status !== 'not_required'" class="link" style="color: #3cb62e; font-size: 11pt;" @click="markCocExisting(participant)">Vorhanden?</span>
</td> </td>
<td :id="'participant-' + participant.identifier +'-payment'" :class="participant.amount_left_value != 0 && !participant.unregistered ? 'not-paid' : ''" style="width: 275px; '"> <td class="pl-amount" :id="'participant-' + participant.identifier +'-payment'" :class="participant.amount_left_value != 0 && !participant.unregistered ? 'not-paid' : ''">
Gezahlt: <label :id="'participant-' + participant.identifier + '-paid'">{{ participant?.amountPaid.readable }}</label> /<br /> Gezahlt: <label :id="'participant-' + participant.identifier + '-paid'">{{ participant?.amountPaid.readable }}</label> /<br />
Gesamt: <label :id="'participant-' + participant.identifier + '-expected'">{{ participant?.amountExpected.readable }}</label> Gesamt: <label :id="'participant-' + participant.identifier + '-expected'">{{ participant?.amountExpected.readable }}</label>
<br /><br /> <br /><br />
@@ -345,12 +345,12 @@ function mailToGroup(groupKey) {
</span> </span>
</td> </td>
<td> <td class="pl-email">
<label :id="'participant-' + participant.identifier +'-email_1'" class="block-label">{{ participant?.email_1 ?? "-" }}</label> <label :id="'participant-' + participant.identifier +'-email_1'" class="block-label">{{ participant?.email_1 ?? "-" }}</label>
<label :id="'participant-' + participant.identifier +'-email_2'" class="block-label">{{ participant.email_2 }}</label> <label :id="'participant-' + participant.identifier +'-email_2'" class="block-label">{{ participant.email_2 }}</label>
</td> </td>
<td> <td class="pl-phone">
<label :id="'participant-' + participant.identifier +'-phone_1'" class="block-label">{{ participant?.phone_1 }}</label> <label :id="'participant-' + participant.identifier +'-phone_1'" class="block-label">{{ participant?.phone_1 }}</label>
<label :id="'participant-' + participant.identifier +'-phone_2'" class="block-label">{{ participant?.phone_2 }}</label> <label :id="'participant-' + participant.identifier +'-phone_2'" class="block-label">{{ participant?.phone_2 }}</label>
</td> </td>
@@ -359,7 +359,7 @@ function mailToGroup(groupKey) {
</tr> </tr>
<tr class="participant-meta-row" :id="'participant-' + participant.identifier + '-meta'"> <tr class="participant-meta-row" :id="'participant-' + participant.identifier + '-meta'">
<td colspan="5" style="height: 15px !important; font-size: 9pt; background-color: #ffffff; border-top-style: none;"> <td colspan="5" class="pl-meta">
<label :id="'participant-' + participant.identifier +'-localgroup'"> <label :id="'participant-' + participant.identifier +'-localgroup'">
{{ participant?.localgroup ?? "-" }} {{ participant?.localgroup ?? "-" }}
</label> | </label> |
@@ -385,8 +385,8 @@ function mailToGroup(groupKey) {
</tr> </tr>
</template> </template>
<tr> <tr class="pl-summary-row">
<td colspan="3" style="font-weight: normal;"> <td colspan="3" class="pl-age-summary">
0 - 5 Jahre: <strong>{{ getAgeCounts(participants)['0-5'] ?? 0 }}</strong> | 0 - 5 Jahre: <strong>{{ getAgeCounts(participants)['0-5'] ?? 0 }}</strong> |
6-11 Jahre: <strong>{{ getAgeCounts(participants)['6-11'] ?? 0 }}</strong> | 6-11 Jahre: <strong>{{ getAgeCounts(participants)['6-11'] ?? 0 }}</strong> |
12-15 Jahre: <strong>{{ getAgeCounts(participants)['12-15'] ?? 0 }}</strong> | 12-15 Jahre: <strong>{{ getAgeCounts(participants)['12-15'] ?? 0 }}</strong> |
@@ -394,7 +394,7 @@ function mailToGroup(groupKey) {
18 - 27 Jahre: <strong>{{ getAgeCounts(participants)['18-27'] ?? 0 }}</strong> | 18 - 27 Jahre: <strong>{{ getAgeCounts(participants)['18-27'] ?? 0 }}</strong> |
27 Jahre und älter: <strong>{{ getAgeCounts(participants)['27+'] ?? 0 }}</strong> 27 Jahre und älter: <strong>{{ getAgeCounts(participants)['27+'] ?? 0 }}</strong>
</td> </td>
<td> <td class="pl-summary-action">
<input type="button" class="button" @click="mailToGroup(groupKey)" value="E-Mail an Gruppe senden" /> <input type="button" class="button" @click="mailToGroup(groupKey)" value="E-Mail an Gruppe senden" />
</td> </td>
</tr> </tr>
@@ -458,6 +458,12 @@ function mailToGroup(groupKey) {
</template> </template>
<style scoped> <style scoped>
.participants-table {
width: 95%;
margin: 20px auto;
border-collapse: collapse;
}
table { table {
margin-bottom: 60px !important; margin-bottom: 60px !important;
} }
@@ -482,7 +488,19 @@ function mailToGroup(groupKey) {
tr th:after { tr th:after {
content: ""; content: "";
}
/* Spaltenbreiten auf Desktop */
.pl-name { width: 300px; }
.pl-amount { width: 275px; }
.pl-email { width: auto; }
.pl-phone { width: auto; }
.pl-meta {
height: 15px !important;
font-size: 9pt;
background-color: #ffffff;
border-top-style: none;
} }
tr:nth-child(even) { tr:nth-child(even) {
@@ -492,8 +510,6 @@ function mailToGroup(groupKey) {
border-color: #e5e7eb; border-color: #e5e7eb;
} }
tr:nth-child(odd) { tr:nth-child(odd) {
background-color: #ffffff; background-color: #ffffff;
border-style: solid; border-style: solid;
@@ -535,4 +551,100 @@ function mailToGroup(groupKey) {
.block-label { .block-label {
display: block; display: block;
} }
/* ─── Tablet ─── */
@media (max-width: 1023px) {
.participants-table {
width: 100%;
}
.pl-name { width: 40%; }
.pl-amount { width: 25%; }
.pl-email,
.pl-phone { width: 17.5%; }
tr td {
padding: 8px;
font-size: 10pt;
}
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
.participants-table {
width: 100%;
font-size: 0.85rem;
}
/* Auf Smartphone werden Beitrag und E-Mail ausgeblendet,
Name + Telefon bleiben sichtbar */
.pl-amount,
.pl-email {
display: none !important;
}
.pl-name {
width: 65% !important;
display: table-cell;
padding: 10px 8px !important;
height: auto;
vertical-align: top;
}
.pl-phone {
width: 35% !important;
display: table-cell;
padding: 10px 6px !important;
height: auto;
vertical-align: top;
font-size: 0.8rem;
word-break: break-all;
}
.pl-phone .block-label {
margin-bottom: 4px;
}
/* Header-Zeile entsprechend anpassen */
thead tr:nth-child(2) th.pl-name { width: 65% !important; }
thead tr:nth-child(2) th.pl-phone { width: 35% !important; }
/* Meta-Zeile (Adresse, Aktionen) bleibt sichtbar, aber kompakter */
.pl-meta {
padding: 8px !important;
font-size: 0.78rem !important;
line-height: 1.6;
word-break: break-word;
}
/* Summary-Zeile (Altersverteilung) auf Mobile: stapeln */
.pl-summary-row {
display: flex !important;
flex-direction: column;
}
.pl-summary-row td {
display: block;
width: 100% !important;
height: auto !important;
}
.pl-age-summary {
font-size: 0.78rem !important;
line-height: 1.8;
padding: 8px !important;
}
.pl-summary-action input.button {
width: 100%;
padding: 10px;
}
/* tr-Border auf Mobile angleichen */
tr td {
height: auto;
padding: 10px 8px;
padding-top: 12px;
}
}
</style> </style>
@@ -68,8 +68,6 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<th colspan="2" style="border-width: 1px; border-bottom-style: solid">Gesamt</th> <th colspan="2" style="border-width: 1px; border-bottom-style: solid">Gesamt</th>
<td style="font-weight: bold; border-width: 1px; border-bottom-style: solid"> <td style="font-weight: bold; border-width: 1px; border-bottom-style: solid">
@@ -117,7 +115,6 @@
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
@@ -131,22 +128,22 @@
.participant-flexbox-row { .participant-flexbox-row {
display: flex; display: flex;
gap: 10px; /* Abstand zwischen den Spalten */ gap: 20px;
flex-wrap: wrap;
} }
.participant-flexbox-row.top .left { .participant-flexbox-row.top .left,
flex: 0 0 50%; .participant-flexbox-row.top .right {
padding: 10px; flex: 1 1 280px;
}
.participant-flexbox.top .right {
flex: 0 0 50%;
padding: 10px; padding: 10px;
min-width: 0;
} }
.participant-income-table, .participant-income-table,
.event-payment-table { .event-payment-table {
width: 475px; width: 100%;
max-width: 475px;
table-layout: auto;
} }
.participant-income-table th { .participant-income-table th {
@@ -162,4 +159,5 @@
.event-payment-table { .event-payment-table {
width: 100%; width: 100%;
} }
</style> </style>
@@ -50,43 +50,37 @@ const steps = [
<template v-else> <template v-else>
<!-- Fortschrittsleiste (ab Step 2) --> <!-- Fortschrittsleiste (ab Step 2) -->
<div v-if="currentStep > 1" style="margin-bottom: 28px;"> <div v-if="currentStep > 1" class="signup-progress">
<div style="display: flex; gap: 6px; flex-wrap: wrap; align-items: center;"> <div class="signup-progress-pills">
<template v-for="(s, index) in steps.filter(s => s.step > 1)" :key="s.step"> <template v-for="(s, index) in steps.filter(s => s.step > 1)" :key="s.step">
<!-- Trennlinie zwischen Pills --> <div v-if="index > 0" class="signup-progress-separator"></div>
<div v-if="index > 0" style="flex-shrink: 0; width: 16px; height: 2px; background: #e5e7eb; border-radius: 1px;"></div>
<div <div
:style="{ class="signup-pill"
padding: '5px 14px', :class="{
borderRadius: '999px', 'signup-pill--active': currentStep === s.step,
fontSize: '0.78rem', 'signup-pill--done': currentStep > s.step,
fontWeight: '600', 'signup-pill--upcoming': currentStep < s.step,
whiteSpace: 'nowrap',
border: '2px solid',
borderColor: currentStep === s.step ? '#2563eb' : currentStep > s.step ? '#bbf7d0' : '#e5e7eb',
background: currentStep === s.step ? '#2563eb' : currentStep > s.step ? '#f0fdf4' : '#f9fafb',
color: currentStep === s.step ? 'white' : currentStep > s.step ? '#15803d' : '#9ca3af',
cursor: currentStep > s.step ? 'pointer' : 'default',
}" }"
@click="currentStep > s.step ? goToStep(s.step) : null" @click="currentStep > s.step ? goToStep(s.step) : null"
> >
<span v-if="currentStep > s.step" style="margin-right: 4px;"></span> <span v-if="currentStep > s.step" class="signup-pill__check"></span>
{{ s.label }} {{ s.label }}
</div> </div>
</template> </template>
</div> </div>
<!-- Fortschrittsbalken --> <!-- Fortschrittsbalken -->
<div style="margin-top: 10px; height: 3px; background: #e5e7eb; border-radius: 2px; overflow: hidden;"> <div class="signup-progress-bar">
<div <div
:style="{ class="signup-progress-bar__fill"
height: '100%', :style="{ width: ((currentStep - 2) / (steps.length - 2) * 100) + '%' }"
background: 'linear-gradient(90deg, #2563eb, #3b82f6)',
borderRadius: '2px',
width: ((currentStep - 2) / (steps.length - 2) * 100) + '%',
transition: 'width 0.3s ease',
}"
></div> ></div>
</div> </div>
<!-- Mobile Step-Anzeige -->
<div class="signup-progress-mobile">
Schritt {{ currentStep - 1 }} von {{ steps.length - 1 }}:
<strong>{{ steps.find(s => s.step === currentStep)?.label }}</strong>
</div>
</div> </div>
<!-- Steps --> <!-- Steps -->
@@ -115,11 +109,87 @@ const steps = [
</template> </template>
<style> <style>
/* ─── Progress (Step-Pills) ─── */
.signup-progress {
margin-bottom: 28px;
}
.signup-progress-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.signup-progress-separator {
flex-shrink: 0;
width: 16px;
height: 2px;
background: #e5e7eb;
border-radius: 1px;
}
.signup-pill {
padding: 5px 14px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
border: 2px solid;
cursor: default;
}
.signup-pill__check { margin-right: 4px; }
.signup-pill--active {
border-color: #2563eb;
background: #2563eb;
color: white;
}
.signup-pill--done {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
cursor: pointer;
}
.signup-pill--upcoming {
border-color: #e5e7eb;
background: #f9fafb;
color: #9ca3af;
}
.signup-progress-bar {
margin-top: 10px;
height: 3px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.signup-progress-bar__fill {
height: 100%;
background: linear-gradient(90deg, #2563eb, #3b82f6);
border-radius: 2px;
transition: width 0.3s ease;
}
.signup-progress-mobile {
display: none;
margin-top: 8px;
font-size: 0.9rem;
color: #374151;
}
/* ─── Form-Table ─── */
.form-table { width: 100%; border-collapse: collapse; } .form-table { width: 100%; border-collapse: collapse; }
.form-table td { padding: 8px 12px 8px 0; vertical-align: top; } .form-table td { padding: 8px 12px 8px 0; vertical-align: top; }
.form-table td:first-child { width: 220px; color: #374151; font-weight: 500; } .form-table td:first-child { width: 220px; color: #374151; font-weight: 500; }
.form-table input[type="text"], .form-table input[type="text"],
.form-table input[type="date"], .form-table input[type="date"],
.form-table input[type="email"],
.form-table input[type="number"],
.form-table select, .form-table select,
.form-table textarea { .form-table textarea {
width: 100%; width: 100%;
@@ -129,7 +199,8 @@ const steps = [
font-size: 0.95rem; font-size: 0.95rem;
box-sizing: border-box; box-sizing: border-box;
} }
.btn-row { display: flex; gap: 10px; padding-top: 16px; }
.btn-row { display: flex; gap: 10px; padding-top: 16px; flex-wrap: wrap; }
.btn-primary { .btn-primary {
padding: 8px 20px; padding: 8px 20px;
background: #2563eb; background: #2563eb;
@@ -149,4 +220,58 @@ const steps = [
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
/* ─── Tablet ─── */
@media (max-width: 1023px) {
.form-table td:first-child {
width: 160px;
}
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
/* Pills auf Mobile: kompakter, Trennstriche ausblenden */
.signup-progress-pills {
display: none;
}
.signup-progress-mobile {
display: block;
}
/* Form-Table: Label oberhalb des Feldes */
.form-table,
.form-table tbody,
.form-table tr {
display: block;
width: 100%;
}
.form-table td {
display: block;
width: 100% !important;
padding: 4px 0;
}
.form-table td:first-child {
width: 100% !important;
font-weight: 600;
color: #374151;
padding-top: 10px;
}
.form-table td[colspan] {
width: 100% !important;
}
.btn-row {
flex-direction: column-reverse;
gap: 8px;
}
.btn-primary,
.btn-secondary {
width: 100%;
padding: 12px 20px;
}
}
</style> </style>
@@ -9,7 +9,7 @@ const emit = defineEmits(['next'])
<h3 style="margin: 0 0 6px 0; color: #111827;">Wer nimmt teil?</h3> <h3 style="margin: 0 0 6px 0; color: #111827;">Wer nimmt teil?</h3>
<p style="margin: 0 0 24px 0; color: #6b7280; font-size: 0.95rem;">Bitte wähle deine Altersgruppe aus.</p> <p style="margin: 0 0 24px 0; color: #6b7280; font-size: 0.95rem;">Bitte wähle deine Altersgruppe aus.</p>
<div style="display: flex; gap: 20px; flex-wrap: wrap;"> <div class="age-card-row">
<!-- Kind / Jugendliche:r --> <!-- Kind / Jugendliche:r -->
<div class="age-card" @click="emit('next', 2)"> <div class="age-card" @click="emit('next', 2)">
@@ -40,9 +40,15 @@ const emit = defineEmits(['next'])
</template> </template>
<style scoped> <style scoped>
.age-card-row {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.age-card { .age-card {
flex: 1; flex: 1 1 280px;
min-width: 220px; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -62,13 +68,14 @@ const emit = defineEmits(['next'])
.age-card__badge { .age-card__badge {
position: relative; position: relative;
width: 350px; width: 100%;
height: 200px; max-width: 350px;
aspect-ratio: 350 / 200;
margin-bottom: 16px; margin-bottom: 16px;
} }
.age-card__img { .age-card__img {
width: 350px; width: 100%;
height: 200px; height: 100%;
object-fit: contain; object-fit: contain;
} }
.age-card__badge-fallback { .age-card__badge-fallback {
@@ -100,4 +107,43 @@ const emit = defineEmits(['next'])
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
} }
/* ─── Smartphone ─── */
@media (max-width: 639px) {
.age-card-row {
flex-direction: column;
gap: 12px;
}
.age-card {
flex: 1 1 100%;
padding: 18px 14px;
flex-direction: row;
text-align: left;
align-items: center;
gap: 14px;
}
.age-card__badge {
flex: 0 0 90px;
width: 90px;
max-width: 90px;
aspect-ratio: 1;
margin-bottom: 0;
}
.age-card__body {
flex: 1;
align-items: flex-start;
text-align: left;
}
.age-card__title {
font-size: 1rem;
}
.age-card__desc {
font-size: 0.85rem;
}
}
</style> </style>
@@ -116,8 +116,10 @@ const next = () => {
<tr> <tr>
<td>PLZ, Ort:</td> <td>PLZ, Ort:</td>
<td> <td>
<input maxlength="5" type="text" v-model="props.formData.plz" style="width: 100px; margin-right: 8px;" /> <div class="plz-ort-row">
<input type="text" v-model="props.formData.ort" style="width: calc(100% - 110px);" /> <input maxlength="5" type="text" v-model="props.formData.plz" class="plz-input" placeholder="PLZ" />
<input type="text" v-model="props.formData.ort" class="ort-input" placeholder="Ort" />
</div>
<ErrorText :message="errors.plz" /> <ErrorText :message="errors.plz" />
<ErrorText :message="errors.ort" /> <ErrorText :message="errors.ort" />
</td> </td>
@@ -132,3 +134,28 @@ const next = () => {
</table> </table>
</div> </div>
</template> </template>
<style scoped>
.plz-ort-row {
display: flex;
gap: 8px;
width: 100%;
}
.plz-input {
flex: 0 0 100px;
width: 100px;
}
.ort-input {
flex: 1;
min-width: 0;
}
@media (max-width: 639px) {
.plz-input {
flex: 0 0 80px;
width: 80px;
}
}
</style>
+181 -35
View File
@@ -26,78 +26,78 @@ function close() {
<AppLayout title="Für Veranstaltung anmelden"> <AppLayout title="Für Veranstaltung anmelden">
<FullScreenModal :show="showSignup" @close="close"> <FullScreenModal :show="showSignup" @close="close">
<shadowed-box style="width: 95%; margin: 30px auto; padding: 0; overflow: hidden; border-radius: 12px;"> <shadowed-box class="signup-box">
<!-- Header --> <!-- Header -->
<div v-if="props.event.registrationAllowed" style="background: linear-gradient(135deg, #1e40af, #3b82f6); padding: 28px 32px; color: white;"> <div v-if="props.event.registrationAllowed" class="signup-header signup-header--open">
<div style="display: flex; align-items: center; gap: 14px; flex-wrap: wrap;"> <div class="signup-header-row">
<h2 style="margin: 0; font-size: 1.5rem; font-weight: 700;">{{ props.event.name }}</h2> <h2 class="signup-event-title">{{ props.event.name }}</h2>
<span style="background: rgba(255,255,255,0.2); color: white; padding: 4px 14px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; white-space: nowrap; border: 1px solid rgba(255,255,255,0.4);"> <span class="signup-badge signup-badge--open">
Anmeldung offen Anmeldung offen
</span> </span>
</div> </div>
<p style="margin: 8px 0 0 0; opacity: 0.85; font-size: 0.95rem;"> <p class="signup-event-location">
📍 {{ props.event.postalCode }} {{ props.event.location }} 📍 {{ props.event.postalCode }} {{ props.event.location }}
</p> </p>
</div> </div>
<div v-else style="background: linear-gradient(135deg, #991b1b, #dc2626); padding: 28px 32px; color: white;"> <div v-else class="signup-header signup-header--closed">
<div style="display: flex; align-items: center; gap: 14px; flex-wrap: wrap;"> <div class="signup-header-row">
<h2 style="margin: 0; font-size: 1.5rem; font-weight: 700;">{{ props.event.name }}</h2> <h2 class="signup-event-title">{{ props.event.name }}</h2>
<span style="background: rgba(255,0,0,0.2); color: #fecaca; padding: 4px 14px; border-radius: 999px; font-size: 0.8rem; font-weight: 600; white-space: nowrap; border: 1px solid rgba(255,100,100,0.4);"> <span class="signup-badge signup-badge--closed">
Anmeldung geschlossen Anmeldung geschlossen
</span> </span>
</div> </div>
<p style="margin: 8px 0 0 0; opacity: 0.85; font-size: 0.95rem;"> <p class="signup-event-location">
📍 {{ props.event.postalCode }} {{ props.event.location }} 📍 {{ props.event.postalCode }} {{ props.event.location }}
</p> </p>
</div> </div>
<!-- Body --> <!-- Body -->
<div style="padding: 28px 32px;"> <div class="signup-body">
<!-- Info-Grid --> <!-- Info-Grid -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px;"> <div class="signup-info-grid">
<!-- Zeile 1 links: Zeitraum --> <!-- Zeitraum -->
<div style="background: #f8fafc; border-radius: 8px; padding: 16px;"> <div class="info-card">
<div style="font-size: 0.75rem; color: #6b7280; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px;">Veranstaltungszeitraum</div> <div class="info-card__label">Veranstaltungszeitraum</div>
<div style="font-size: 1rem; color: #111827; font-weight: 500;">{{ props.event.eventBegin }} {{ props.event.eventEnd }}</div> <div class="info-card__value">{{ props.event.eventBegin }} {{ props.event.eventEnd }}</div>
<div style="font-size: 0.85rem; color: #6b7280;">{{ props.event.duration }} Tage</div> <div class="info-card__sub">{{ props.event.duration }} Tage</div>
</div> </div>
<!-- Zeile 1 rechts: Erstattungsrichtlinien --> <!-- Erstattungsrichtlinien -->
<div style="background: #f0f9ff; border-radius: 8px; padding: 16px; border-left: 3px solid #0ea5e9;"> <div class="info-card info-card--accent">
<div style="font-size: 0.75rem; color: #0369a1; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px;">Erstattungsrichtlinien</div> <div class="info-card__label info-card__label--accent">Erstattungsrichtlinien</div>
<div style="color: #0c4a6e; font-size: 0.875rem; line-height: 1.8;"> <div class="info-card__list">
<div>100 % bis {{ props.event.earlyBirdEnd.formatted }}</div> <div>100 % bis {{ props.event.earlyBirdEnd.formatted }}</div>
<div>{{ props.event.refundAfterEarlyBirdEnd }} % bis {{ props.event.registrationFinalEnd.formatted }}</div> <div>{{ props.event.refundAfterEarlyBirdEnd }} % bis {{ props.event.registrationFinalEnd.formatted }}</div>
<div>Danach nur bei Nachrückplätzen</div> <div>Danach nur bei Nachrückplätzen</div>
</div> </div>
</div> </div>
<!-- Zeile 2 links: Anmeldeschluss --> <!-- Anmeldeschluss -->
<div style="background: #f8fafc; border-radius: 8px; padding: 16px;"> <div class="info-card">
<div style="font-size: 0.75rem; color: #6b7280; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px;">Anmeldeschluss</div> <div class="info-card__label">Anmeldeschluss</div>
<div style="font-size: 1rem; color: #111827; font-weight: 500; margin-bottom: 20px;">{{ props.event.registrationFinalEnd.formatted }}</div> <div class="info-card__value" style="margin-bottom: 16px;">{{ props.event.registrationFinalEnd.formatted }}</div>
<div style="font-size: 0.75rem; color: #366a34; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px;">Frühbucher bis</div> <div class="info-card__label info-card__label--green">Frühbucher bis</div>
<div style="font-size: 1rem; color: #366a34; font-weight: 500;">{{ props.event.earlyBirdEnd.formatted }}</div> <div class="info-card__value" style="color: #366a34;">{{ props.event.earlyBirdEnd.formatted }}</div>
</div> </div>
<!-- Zeile 2 rechts: Kontakt --> <!-- Kontakt -->
<div style="background: #f8fafc; border-radius: 8px; padding: 16px;"> <div class="info-card">
<div style="font-size: 0.75rem; color: #6b7280; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px;">Kontakt</div> <div class="info-card__label">Kontakt</div>
<p style="margin: 0 0 6px 0; color: #374151; font-size: 0.9rem; line-height: 1.6;"> <p class="info-card__text">
Hast du Fragen zur Veranstaltung oder deiner Anmeldung? Kontaktiere uns per E-Mail: Hast du Fragen zur Veranstaltung oder deiner Anmeldung? Kontaktiere uns per E-Mail:
</p> </p>
<a :href="'mailto:' + props.event.email" style="font-size: 0.95rem; color: #2563eb; text-decoration: none; font-weight: 500;">{{ props.event.email }}</a> <a :href="'mailto:' + props.event.email" class="info-card__link">{{ props.event.email }}</a>
</div> </div>
</div> </div>
</div> </div>
<hr class="signup-divider" />
<hr style="margin: 0 32px; border: none; border-top: 1px solid #e5e7eb;" /> <div class="signup-body">
<div style="padding: 28px 32px;">
<SignupForm <SignupForm
:event="props.event" :event="props.event"
:participantData="props.participantData ?? {}" :participantData="props.participantData ?? {}"
@@ -115,5 +115,151 @@ function close() {
</template> </template>
<style scoped> <style scoped>
.signup-box {
width: 95%;
margin: 30px auto;
padding: 0;
overflow: hidden;
border-radius: 12px;
}
/* Header */
.signup-header {
padding: 28px 32px;
color: white;
}
.signup-header--open { background: linear-gradient(135deg, #1e40af, #3b82f6); }
.signup-header--closed { background: linear-gradient(135deg, #991b1b, #dc2626); }
.signup-header-row {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.signup-event-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
.signup-badge {
padding: 4px 14px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
border: 1px solid rgba(255,255,255,0.4);
}
.signup-badge--open { background: rgba(255,255,255,0.2); color: white; }
.signup-badge--closed { background: rgba(255,0,0,0.2); color: #fecaca; }
.signup-event-location {
margin: 8px 0 0 0;
opacity: 0.85;
font-size: 0.95rem;
}
/* Body */
.signup-body { padding: 28px 32px; }
.signup-divider { margin: 0 32px; border: none; border-top: 1px solid #e5e7eb; }
/* Info-Grid */
.signup-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.info-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
}
.info-card--accent {
background: #f0f9ff;
border-left: 3px solid #0ea5e9;
}
.info-card__label {
font-size: 0.75rem;
color: #6b7280;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.info-card__label--accent { color: #0369a1; margin-bottom: 8px; }
.info-card__label--green { color: #366a34; margin-top: 4px; }
.info-card__value {
font-size: 1rem;
color: #111827;
font-weight: 500;
}
.info-card__sub {
font-size: 0.85rem;
color: #6b7280;
}
.info-card__list {
color: #0c4a6e;
font-size: 0.875rem;
line-height: 1.8;
}
.info-card__text {
margin: 0 0 6px 0;
color: #374151;
font-size: 0.9rem;
line-height: 1.6;
}
.info-card__link {
font-size: 0.95rem;
color: #2563eb;
text-decoration: none;
font-weight: 500;
word-break: break-all;
}
/* ─── Tablet (6401023px) ─── */
@media (max-width: 1023px) {
.signup-box {
width: 100%;
margin: 10px auto;
}
.signup-header,
.signup-body { padding: 20px 18px; }
.signup-divider { margin: 0 18px; }
.signup-info-grid {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.signup-event-title { font-size: 1.25rem; }
}
/* ─── Smartphone (< 640px) ─── */
@media (max-width: 639px) {
.signup-box {
width: 100%;
margin: 0;
border-radius: 0;
}
.signup-header,
.signup-body { padding: 16px 12px; }
.signup-divider { margin: 0 12px; }
.signup-event-title { font-size: 1.1rem; }
.signup-event-location { font-size: 0.85rem; }
.signup-badge { font-size: 0.7rem; padding: 3px 10px; }
.signup-info-grid {
grid-template-columns: 1fr;
gap: 10px;
margin-bottom: 16px;
}
.info-card { padding: 12px; }
.info-card__value { font-size: 0.95rem; }
.info-card__list { font-size: 0.82rem; }
}
</style> </style>
@@ -42,6 +42,8 @@ class CreateInvoiceCommand {
'travel_reason' => $this->request->travelReason, 'travel_reason' => $this->request->travelReason,
'passengers' => $this->request->passengers, 'passengers' => $this->request->passengers,
'transportation' => $this->request->transportations, 'transportation' => $this->request->transportations,
'payment_purpose' => $this->request->paymentPurpose,
'comment' => $this->request->notices,
'document_filename' => $this->request->receiptFile !== null ? $this->request->receiptFile->fullPath : null, 'document_filename' => $this->request->receiptFile !== null ? $this->request->receiptFile->fullPath : null,
]); ]);
@@ -23,6 +23,8 @@ class CreateInvoiceRequest {
public bool $isDonation; public bool $isDonation;
public ?int $userId; public ?int $userId;
public ?string $travelReason; public ?string $travelReason;
public ?string $paymentPurpose;
public ?string $notices;
public function __construct( public function __construct(
@@ -43,6 +45,8 @@ class CreateInvoiceRequest {
?int $passengers = null, ?int $passengers = null,
?int $transportations, ?int $transportations,
?string $travelReason = null, ?string $travelReason = null,
?string $paymentPurpose = null,
?string $notices = null,
) { ) {
$this->costUnit = $costUnit; $this->costUnit = $costUnit;
@@ -62,6 +66,8 @@ class CreateInvoiceRequest {
$this->isDonation = $isDonation; $this->isDonation = $isDonation;
$this->userId = $userId; $this->userId = $userId;
$this->travelReason = $travelReason; $this->travelReason = $travelReason;
$this->paymentPurpose = $paymentPurpose;
$this->notices = $notices;
if ($accountIban === 'undefined') { if ($accountIban === 'undefined') {
$this->accountIban = null; $this->accountIban = null;
@@ -60,7 +60,6 @@ class CreateInvoiceReceiptCommand {
storage_path('app/private/' .$filename) . '.zip', storage_path('app/private/' .$filename) . '.zip',
ZipArchive::CREATE | ZipArchive::OVERWRITE ZipArchive::CREATE | ZipArchive::OVERWRITE
); );
$zip->addFile(storage_path('app/private/' . $tmpFileName ), 'antrag.pdf'); $zip->addFile(storage_path('app/private/' . $tmpFileName ), 'antrag.pdf');
$zip->addFile(storage_path('app/private/' . $receipt), 'beleg.pdf'); $zip->addFile(storage_path('app/private/' . $receipt), 'beleg.pdf');
@@ -114,6 +113,9 @@ HTML;
<tr><td>Name:</td><td>%3\$s</td></tr> <tr><td>Name:</td><td>%3\$s</td></tr>
<tr><td>E-Mail:</td><td>%4\$s</td></tr> <tr><td>E-Mail:</td><td>%4\$s</td></tr>
<tr><td>Telefon:</td><td>%5\$s</td></tr> <tr><td>Telefon:</td><td>%5\$s</td></tr>
<tr><td>Anmerkungen:</td><td>%16\$s</td></tr>
<tr><td>Name der Kostenstelle:</td><td>%6\$s</td></tr> <tr><td>Name der Kostenstelle:</td><td>%6\$s</td></tr>
<tr><td>Zahlungsgrund:</td><td>%7\$s</td></tr> <tr><td>Zahlungsgrund:</td><td>%7\$s</td></tr>
<tr><td>Wird der Betrag gespendet:</td><td>%8\$s</td></tr> <tr><td>Wird der Betrag gespendet:</td><td>%8\$s</td></tr>
@@ -186,7 +188,8 @@ HTML;
$invoiceReadable['createdAt'], $invoiceReadable['createdAt'],
$invoiceReadable['approvedAt'], $invoiceReadable['approvedAt'],
$invoiceReadable['approvedBy'], $invoiceReadable['approvedBy'],
$changes $changes,
$invoiceReadable['comment'],
); );
} }
@@ -8,13 +8,13 @@ use App\Models\Invoice;
use App\ValueObjects\Amount; use App\ValueObjects\Amount;
class UpdateInvoiceRequest { class UpdateInvoiceRequest {
public string $comment; public ?string $comment;
public InvoiceType $invoiceType; public InvoiceType $invoiceType;
public CostUnit $costUnit; public CostUnit $costUnit;
public Invoice $invoice; public Invoice $invoice;
public Amount $amount; public Amount $amount;
public function __construct(Invoice $invoice, string $comment, InvoiceType $invoiceType, CostUnit $costUnit, Amount $amount) { public function __construct(Invoice $invoice, ?string $comment, InvoiceType $invoiceType, CostUnit $costUnit, Amount $amount) {
$this->comment = $comment; $this->comment = $comment;
$this->invoiceType = $invoiceType; $this->invoiceType = $invoiceType;
$this->costUnit = $costUnit; $this->costUnit = $costUnit;
@@ -47,7 +47,10 @@ class EditController extends CommonController{
$invoice->travel_direction, $invoice->travel_direction,
$invoice->distance, $invoice->distance,
$invoice->passengers, $invoice->passengers,
$invoice->transportation $invoice->transportation,
$invoice->travel_reason,
$invoice->payment_purpose,
$invoice->comment,
); );
$invoiceCreationCommand = new CreateInvoiceCommand($createInvoiceRequest); $invoiceCreationCommand = new CreateInvoiceCommand($createInvoiceRequest);
@@ -86,7 +89,7 @@ class EditController extends CommonController{
$updateInvoiceRequest = new UpdateInvoiceRequest( $updateInvoiceRequest = new UpdateInvoiceRequest(
$invoice, $invoice,
$modifyData['reason_of_correction'] ?? 'Abrechnungskorrektur', $modifyData['notices'],
$invoiceType, $invoiceType,
$newCostUnit, $newCostUnit,
$newAmount $newAmount
@@ -34,102 +34,4 @@ class NewInvoiceController extends CommonController {
]); ]);
return $inertiaProvider->render(); 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->input('amount'))->getAmount();
$distance = null;
} else {
$distance = Amount::fromString($request->input('amount'))->getRoundedAmount();
$amount = $distance * $costUnit->distance_allowance;
}
$createInvoiceRequest = new CreateInvoiceRequest(
$costUnit,
$request->input('name'),
InvoiceType::INVOICE_TYPE_TRAVELLING,
$amount,
$uploadedFile,
'donation' === $request->input('decision') ? true : false,
$this->users->getCurrentUserDetails()['userId'],
$request->input('email'),
$request->input('telephone'),
$request->input('accountOwner'),
$request->input('accountIban'),
null,
$request->input('otherText'),
$distance,
$request->input('havePassengers'),
$request->input('materialTransportation'),
$request->input('travelReason'),
);
break;
default:
$createInvoiceRequest = new CreateInvoiceRequest(
$costUnit,
$request->input('name'),
$invoiceType,
Amount::fromString($request->input('amount'))->getAmount(),
$uploadedFile,
'donation' === $request->input('decision') ? true : false,
$this->users->getCurrentUserDetails()['userId'],
$request->input('email'),
$request->input('telephone'),
$request->input('accountOwner'),
$request->input('accountIban'),
$request->input('otherText'),
null,
null,
$request->input('havePassengers'),
$request->input('materialTransportation'),
null
);
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'
]);
}
}
} }
@@ -0,0 +1,132 @@
<?php
namespace App\Domains\Invoice\Controllers;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceCommand;
use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest;
use App\Enumerations\InvoiceType;
use App\Providers\FlashMessageProvider;
use App\Providers\UploadFileProvider;
use App\Scopes\CommonController;
use App\ValueObjects\Amount;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class SaveInvoiceController extends CommonController
{
public function __invoke(Request $request, int $costUnitId, string $invoiceType) : JsonResponse {
$costUnit = $this->costUnits->getById($costUnitId, true);
$paymentPurpose = $request->input('paymentPurpose') ?? null;
$notices = $request->input('notices') ?? null;
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')) {
$maxFileSize = env('MAX_INVOICE_FILE_SIZE', 16);
$validation = sprintf('%1$s|%2$s|max:%3$s',
'required',
'mimes:pdf',
$maxFileSize * 1024
);
$validator = Validator::make($request->all(), [
'receipt' => $validation
]);
if ($validator->fails()) {
return response()->json([
'status' => 'error',
'message' => sprintf(
'Der Beleg konnte nicht hochgeladen werden. Bitte beachte die Maximale Dateigröße von %1$s MB sowie die Tatsache, dass ausschließlich PDF-Dateien akzeptiert werden.',
$maxFileSize
)
]);
}
$uploadFileProvider = new UploadFileProvider($request->file('receipt'), $costUnit);
$uploadedFile = $uploadFileProvider->saveUploadedFile();
}
switch ($invoiceType) {
case InvoiceType::INVOICE_TYPE_TRAVELLING:
if ($uploadedFile !== null) {
$amount = Amount::fromString($request->input('amount'))->getAmount();
$distance = null;
} else {
$distance = Amount::fromString($request->input('amount'))->getRoundedAmount();
$amount = $distance * $costUnit->distance_allowance;
}
$createInvoiceRequest = new CreateInvoiceRequest(
$costUnit,
$request->input('name'),
InvoiceType::INVOICE_TYPE_TRAVELLING,
$amount,
$uploadedFile,
'donation' === $request->input('decision') ? true : false,
$this->users->getCurrentUserDetails()['userId'],
$request->input('email'),
$request->input('telephone'),
$request->input('accountOwner'),
$request->input('accountIban'),
null,
$request->input('otherText'),
$distance,
$request->input('havePassengers'),
$request->input('materialTransportation'),
$request->input('travelReason'),
null,
$notices
);
break;
default:
$createInvoiceRequest = new CreateInvoiceRequest(
$costUnit,
$request->input('name'),
$invoiceType,
Amount::fromString($request->input('amount'))->getAmount(),
$uploadedFile,
'donation' === $request->input('decision') ? true : false,
$this->users->getCurrentUserDetails()['userId'],
$request->input('email'),
$request->input('telephone'),
$request->input('accountOwner'),
$request->input('accountIban'),
$request->input('otherText'),
null,
null,
$request->input('havePassengers'),
$request->input('materialTransportation'),
null,
$paymentPurpose,
$notices
);
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'
]);
}
}
}
+2 -1
View File
@@ -6,13 +6,14 @@ use App\Domains\Invoice\Controllers\ChangeStateController;
use App\Domains\Invoice\Controllers\EditController; use App\Domains\Invoice\Controllers\EditController;
use App\Domains\Invoice\Controllers\ListMyInvoicesController; use App\Domains\Invoice\Controllers\ListMyInvoicesController;
use App\Domains\Invoice\Controllers\NewInvoiceController; use App\Domains\Invoice\Controllers\NewInvoiceController;
use App\Domains\Invoice\Controllers\SaveInvoiceController;
use App\Domains\Invoice\Controllers\ShowInvoiceController; use App\Domains\Invoice\Controllers\ShowInvoiceController;
use App\Middleware\IdentifyTenant; use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(IdentifyTenant::class)->group(function () { Route::middleware(IdentifyTenant::class)->group(function () {
Route::prefix('api/v1/invoice')->group(function () { Route::prefix('api/v1/invoice')->group(function () {
Route::post('/new/{costUnitId}/{invoiceType}', [NewInvoiceController::class, 'saveInvoice']); Route::post('/new/{costUnitId}/{invoiceType}', SaveInvoiceController::class);
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
Route::get('/details/{invoiceId}', ShowInvoiceController::class); Route::get('/details/{invoiceId}', ShowInvoiceController::class);
Route::get('/showReceipt/{invoiceId}', [ShowInvoiceController::class, 'showReceipt']); Route::get('/showReceipt/{invoiceId}', [ShowInvoiceController::class, 'showReceipt']);
+20 -1
View File
@@ -5,6 +5,7 @@ import Modal from "../../../Views/Components/Modal.vue";
import {onMounted, reactive, ref} from "vue"; import {onMounted, reactive, ref} from "vue";
import ExpenseAccounting from "./Partials/newInvoice/expense-accounting.vue"; import ExpenseAccounting from "./Partials/newInvoice/expense-accounting.vue";
import TravelExpenseAccounting from "./Partials/newInvoice/travel-expense-accounting.vue"; import TravelExpenseAccounting from "./Partials/newInvoice/travel-expense-accounting.vue";
import DirectPayment from "./Partials/newInvoice/direct-payment.vue";
const props = defineProps({ const props = defineProps({
currentEvents: Object, currentEvents: Object,
@@ -42,6 +43,24 @@ const invoiceType = ref('');
</div> </div>
</div> </div>
<div
v-if="eventId === 0"
class="invoice-type-layer" style="width: calc(100% - 60px); margin: auto"
@click="isOpen = true;invoiceType='direct-payment'"
>
<TextResource textName="NEW_DIRECT_PAYMENT" />
</div>
<DirectPayment v-if="invoiceType === 'direct-payment' && eventId !== 0"
:eventId="eventId"
:userName="props.userName"
:userEmail="props.userEmail"
:userTelephone="props.userTelephone"
:userAccountIban="props.userAccountIban"
:userAccountOwner="props.userAccountOwner"
:userId="props.userId"
/>
<ExpenseAccounting v-if="invoiceType === 'expense-accounting' && eventId !== 0" <ExpenseAccounting v-if="invoiceType === 'expense-accounting' && eventId !== 0"
:eventId="eventId" :eventId="eventId"
:userName="props.userName" :userName="props.userName"
@@ -62,7 +81,7 @@ const invoiceType = ref('');
:userId="props.userId" :userId="props.userId"
/> />
<Modal :show="isOpen" title="Veranstaltung auswählen" @close="isOpen = false"> <Modal :show="isOpen" title="Veranstaltung auswählen" @close="isOpen = false" width="450px">
<select v-model="eventId" @change="isOpen=false" style="width: 100%"> <select v-model="eventId" @change="isOpen=false" style="width: 100%">
<option value="0" disabled>Bitte auswählen</option> <option value="0" disabled>Bitte auswählen</option>
<optgroup label="Laufende Tätigkeiten"> <optgroup label="Laufende Tätigkeiten">
@@ -20,7 +20,7 @@ const formData = reactive({
type_internal: props.newInvoice.internalType || '', type_internal: props.newInvoice.internalType || '',
cost_unit: props.newInvoice.costUnitId || '', cost_unit: props.newInvoice.costUnitId || '',
amount: props.newInvoice.amountPlain || '', amount: props.newInvoice.amountPlain || '',
reason_of_correction: '', notices: props.newInvoice.comments || '',
}) })
const submitForm = () => { const submitForm = () => {
@@ -71,9 +71,13 @@ onMounted(async () => {
</tr> </tr>
<tr> <tr>
<td>Grund der Korrektur:</td> <td>Anmerkungen:</td>
<td> <td>
<input type="text" v-model="formData.reason_of_correction" class="width-half-full" /> <textarea
id="notices"
name="notices" v-model="formData.notices"
maxlength="128"
style="font-size: 14pt; width: 550px;" />
</td> </td>
</tr> </tr>
@@ -97,7 +97,7 @@ const emit = defineEmits(["accept", "deny", "fix", "reopen"])
<td>{{props.data.accountIban}}</td> <td>{{props.data.accountIban}}</td>
<td>Buchungsinformationen:</td> <td>Buchungsinformationen:</td>
<td v-if="props.data.donation">Als Spende gebucht</td> <td v-if="props.data.donation">Als Spende gebucht</td>
<td v-else-if="props.data.alreadyPaid">Beleg ohne Auszahlung</td> <td v-else-if="props.data.externalPayment">Rechnungszahlung</td>
<td v-else>Klassische Auszahlung</td> <td v-else>Klassische Auszahlung</td>
</tr> </tr>
<tr> <tr>
@@ -81,7 +81,7 @@ const emit = defineEmits(["accept", "deny", "fix", "reopen"])
<td>{{props.data.accountIban}}</td> <td>{{props.data.accountIban}}</td>
<td>Buchungsinformationen:</td> <td>Buchungsinformationen:</td>
<td v-if="props.data.donation">Als Spende gebucht</td> <td v-if="props.data.donation">Als Spende gebucht</td>
<td v-else-if="props.data.alreadyPaid">Beleg ohne Auszahlung</td> <td v-else-if="props.data.externalPayment">Rechnungszahlung</td>
<td v-else>Klassische Auszahlung</td> <td v-else>Klassische Auszahlung</td>
</tr> </tr>
<tr> <tr>
@@ -56,7 +56,7 @@
</td> </td>
<td style="width: 150px;"> <td style="width: 150px;">
<Icon v-if="invoice.donation" name="hand-holding-dollar" style="color: #ffffff; background-color: green" /> <Icon v-if="invoice.donation" name="hand-holding-dollar" style="color: #ffffff; background-color: green" />
<Icon v-if="invoice.alreadyPaid" name="comments-dollar" style="color: #ffffff; background-color: green" /> <Icon v-if="invoice.externalPayment" name="comments-dollar" style="color: #ffffff; background-color: green" />
</td> </td>
<td> <td>
{{invoice.contactName}}<br /> {{invoice.contactName}}<br />
@@ -0,0 +1,126 @@
<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";
import PaymentData from "./payment-data.vue";
const data = defineProps({
eventId: Number,
userName: String,
userEmail: String,
userTelephone: String,
userAccountIban: 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;">Was ist der Anlass der Rechnung</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 />
<PaymentData
v-if="finalStep"
:eventId="data.eventId"
:invoice-type="invoiceType"
:amount="amount"
:other-text="otherText"
:userName="data.userName"
:userEmail="data.userEmail"
:userTelephone="data.userTelephone"
:userAccountIban="data.userAccountIban"
:userAccountOwner="data.userAccountOwner"
:receipt="receipt"
travelReason=""
@close="finalStep = false"
/>
</template>
<style scoped>
</style>
@@ -0,0 +1,161 @@
<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";
import ErrorText from "../../../../../Views/Components/ErrorText.vue";
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,
userAccountIban: String,
havePassengers: Number,
materialTransportation: Boolean,
travelReason: String,
})
const finalStep = ref(true)
const userName = ref(props.userName)
const userEmail = ref(props.userEmail)
const userTelephone = ref(props.userTelephone)
const userIban = ref('')
const userAccountOwner = ref('')
const paymentPurpose = ref('')
const sending = ref(false)
const success = ref(false)
const decision = ref('')
const errorMsg = ref('')
const confirmation = ref(null)
const receiptError = ref('')
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('paymentPurpose', paymentPurpose.value)
formData.append('havePassengers', props.havePassengers ? 1 : 0)
formData.append('materialTransportation', props.materialTransportation ? 1 : 0)
formData.append('travelReason', props.travelReason)
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 = '/';
} else {
receiptError.value = response.message;
}
} catch (err) {
toast.error(result.message);
} finally {
sending.value = false
}
}
</script>
<template>
<Modal :show="finalStep" title='Bitte gib deine Daten ein' @close="emit('close')" width="600px">
<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 v-if="userName !== ''">
<strong>E-Mail-Adresse (Für Rückfragen):</strong>
</label><br />
<input
v-if="userName !== ''"
type="email"
name="contact_email"
v-model="userEmail"
style="font-size: 14pt; width: 550px;" /><br /><br />
<label v-if="userName !== ''">
<strong>Telefonnummer (für Rückfragen):</strong>
</label><br />
<input
v-if="userName !== ''"
type="text"
id="contact_telephone"
name="contact_telephone" v-model="userTelephone"
style="font-size: 14pt; width: 550px;" /><br /><br />
<span id="confirm_payment" v-if="userEmail !== '' && userTelephone !== ''">
<label>
<strong>Zahlungsempfänger*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 />
<label>
<strong>Überweisungstext (laut Rechnung):</strong>
</label><br />
<input
type="text"
id="payment_purpose"
name="payment_purpose"
v-model="paymentPurpose"
style="font-size: 14pt; width: 550px;"
/><br /><br />
<ErrorText :message="receiptError" />
<span v-if="paymentPurpose != '' && 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_DIRECT_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>
@@ -6,6 +6,7 @@ import {useAjax} from "../../../../../../resources/js/components/ajaxHandler.js"
import TextResource from "../../../../../Views/Components/TextResource.vue"; import TextResource from "../../../../../Views/Components/TextResource.vue";
import {invoiceCheckContactName} from "../../../../../../resources/js/components/InvoiceUploadChecks.js"; import {invoiceCheckContactName} from "../../../../../../resources/js/components/InvoiceUploadChecks.js";
import {toast} from "vue3-toastify"; import {toast} from "vue3-toastify";
import ErrorText from "../../../../../Views/Components/ErrorText.vue";
const { request } = useAjax(); const { request } = useAjax();
@@ -39,7 +40,8 @@ const success = ref(false)
const decision = ref('') const decision = ref('')
const errorMsg = ref('') const errorMsg = ref('')
const confirmation = ref(null) const confirmation = ref(null)
const receiptError = ref('')
const notices = ref('')
async function sendData() { async function sendData() {
if (!userName.value) return if (!userName.value) return
@@ -61,6 +63,7 @@ async function sendData() {
formData.append('havePassengers', props.havePassengers ? 1 : 0) formData.append('havePassengers', props.havePassengers ? 1 : 0)
formData.append('materialTransportation', props.materialTransportation ? 1 : 0) formData.append('materialTransportation', props.materialTransportation ? 1 : 0)
formData.append('travelReason', props.travelReason) formData.append('travelReason', props.travelReason)
formData.append('notices', notices.value)
if (props.receipt) { if (props.receipt) {
formData.append('receipt', props.receipt) formData.append('receipt', props.receipt)
@@ -75,6 +78,8 @@ async function sendData() {
if (response.status === 'success') { if (response.status === 'success') {
window.location.href = '/'; window.location.href = '/';
} else {
receiptError.value = response.message;
} }
} catch (err) { } catch (err) {
toast.error(result.message); toast.error(result.message);
@@ -87,7 +92,7 @@ async function sendData() {
</script> </script>
<template> <template>
<Modal :show="finalStep" title='Bitte gib deine Daten ein' @close="emit('close')"> <Modal :show="finalStep" title='Bitte gib deine Daten ein' @close="emit('close')" width="600px">
<label> <label>
<strong>Dein Name Name (kein Pfadiname):</strong> <strong>Dein Name Name (kein Pfadiname):</strong>
</label><br /> </label><br />
@@ -95,8 +100,8 @@ async function sendData() {
type="text" type="text"
@keyup="invoiceCheckContactName();" @keyup="invoiceCheckContactName();"
id="contact_name" id="contact_name"
name="contact_name" v-model="userName" class="refund-input"
style="font-size: 14pt; width: 550px;" /><br /><br /> name="contact_name" v-model="userName" /><br /><br />
<label v-if="userName !== ''"> <label v-if="userName !== ''">
<strong>E-Mail-Adresse (Für Rückfragen):</strong> <strong>E-Mail-Adresse (Für Rückfragen):</strong>
@@ -104,9 +109,9 @@ async function sendData() {
<input <input
v-if="userName !== ''" v-if="userName !== ''"
type="email" type="email"
class="refund-input"
name="contact_email" name="contact_email"
v-model="userEmail" v-model="userEmail" /><br /><br />
style="font-size: 14pt; width: 550px;" /><br /><br />
<label v-if="userName !== ''"> <label v-if="userName !== ''">
<strong>Telefonnummer (für Rückfragen):</strong> <strong>Telefonnummer (für Rückfragen):</strong>
@@ -114,17 +119,26 @@ async function sendData() {
<input <input
v-if="userName !== ''" v-if="userName !== ''"
type="text" type="text"
class="refund-input"
id="contact_telephone" id="contact_telephone"
name="contact_telephone" v-model="userTelephone" name="contact_telephone" v-model="userTelephone" /><br /><br />
style="font-size: 14pt; width: 550px;" /><br /><br />
<label v-if="userName !== ''">
<strong>Weitere Informationen zur Abrechnung (Max. 128 Zeichen):</strong>
</label><br />
<textarea
id="notices"
class="refund-input"
name="notices" v-model="notices"
maxlength="128" /><br /><br />
<span id="decision" v-if="userEmail !== '' && userTelephone !== '' && decision === ''"> <span id="decision" v-if="userEmail !== '' && userTelephone !== '' && decision === ''">
<label><br /> <label><br />
<strong>Möchtest du den Betrag spenden?</strong> <strong>Möchtest du den Betrag spenden?</strong>
</label><br /> </label><br />
<input type="button" style="border-radius: 0; width: 100px;" @click="decision='donation'" value="Ja" /> <input type="button" class="refund-choice" @click="decision='donation'" value="Ja" />
<input type="button" style="border-radius: 0; width: 100px;" @click="decision='payout'" value="Nein" /> <input type="button" class="refund-choice" @click="decision='payout'" value="Nein" />
</span> </span>
<span id="confirm_donation" v-if="decision === 'donation'"> <span id="confirm_donation" v-if="decision === 'donation'">
@@ -137,13 +151,13 @@ async function sendData() {
<span id="confirm_payment" v-if="decision === 'payout'"> <span id="confirm_payment" v-if="decision === 'payout'">
<label> <label>
<strong>Konto-Inhaber*in:</strong></label><br /> <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 /> <input type="text" name="account_owner" id="account_owner" class="refund-input" v-model="userAccountOwner" /><br /><br />
<label> <label>
<strong>IBAN:</strong> <strong>IBAN:</strong>
</label><br /> </label><br />
<IbanInput id="account_iban" name="account_iban" v-model="userIban" style="font-size: 14pt; width: 550px;" /><br /><br /> <IbanInput id="account_iban" name="account_iban" class="refund-input" v-model="userIban" /><br /><br />
<ErrorText :message="receiptError" />
<span v-if="userAccountOwner != '' && userIban && userIban.length === 27"><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"> <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 /> <TextResource belongsTo="confirmation_radio_payment" textName="CONFIRMATION_PAYMENT" /><br /><br />
@@ -154,6 +168,26 @@ async function sendData() {
</template> </template>
<style scoped> <style scoped>
/* optional styling */ .refund-input {
</style> font-size: 14pt;
width: 100%;
max-width: 550px;
box-sizing: border-box;
}
.refund-choice {
border-radius: 0;
width: 100px;
}
@media (max-width: 639px) {
.refund-input {
font-size: 12pt;
max-width: 100%;
}
.refund-choice {
width: calc(50% - 6px);
}
}
</style>
@@ -33,7 +33,7 @@ class LoginController extends CommonController {
'password.required' => 'Bitte gib dein Passwort ein.', 'password.required' => 'Bitte gib dein Passwort ein.',
]); ]);
$user = $this->users->findByUsername($request->get('username')); $user = $this->users->findByUsername($request->input('username'));
if ($user !== null && $user->password === null) { if ($user !== null && $user->password === null) {
return redirect()->intended('/register/verifyEmail'); return redirect()->intended('/register/verifyEmail');
} }
+59 -8
View File
@@ -34,24 +34,24 @@ function resetPassword() {
<AppLayout title='Anmelden' :user="props.user" :navbar="props.navbar" :tenant="props.tenant" :currentPath="props.currentPath"> <AppLayout title='Anmelden' :user="props.user" :navbar="props.navbar" :tenant="props.tenant" :currentPath="props.currentPath">
<form method="POST" action="/login"> <form method="POST" action="/login">
<input type="hidden" name="_token" :value="csrfToken" /> <input type="hidden" name="_token" :value="csrfToken" />
<shadowed-box style="width: 50%; margin: 150px auto; padding: 20px;"> <shadowed-box class="login-box">
<table> <table class="login-table">
<tr> <tr>
<th>Anmeldename</th> <th>Anmeldename</th>
<td> <td>
<input type="text" name="username" id="username"></input> <input type="text" name="username" id="username" />
</td> </td>
</tr> </tr>
<tr> <tr>
<th>Passwort</th> <th>Passwort</th>
<td><input type="password" name="password" id="password"></input></td> <td><input type="password" name="password" id="password" /></td>
</tr> </tr>
<tr> <tr>
<td colspan="2"> <td colspan="2" class="login-buttons">
<input type="submit" value="Anmelden" style="margin-top: 20px;" /> <input type="submit" value="Anmelden" />
<input type="button" @click="resetPassword" style="margin-top: 20px; margin-left: 20px;" value="Passwort vergessen" /> <input type="button" @click="resetPassword" value="Passwort vergessen" />
</td> </td>
</tr> </tr>
</table> </table>
@@ -61,8 +61,59 @@ function resetPassword() {
</template> </template>
<style> <style>
th { .login-table th {
width: 100px; width: 100px;
} }
.login-box {
width: 50%;
margin: 150px auto;
padding: 20px;
}
.login-buttons input {
margin-top: 20px;
margin-right: 10px;
}
/* Tablet */
@media (max-width: 1023px) {
.login-box {
width: 70% !important;
margin: 80px auto !important;
}
}
/* Smartphone */
@media (max-width: 639px) {
.login-box {
width: 95% !important;
margin: 30px auto !important;
padding: 15px !important;
}
.login-table,
.login-table tbody,
.login-table tr,
.login-table th,
.login-table td {
display: block;
width: 100%;
}
.login-table th {
padding-bottom: 4px;
text-align: left;
}
.login-table th::after {
content: none;
}
.login-buttons input {
width: 100%;
margin-top: 10px;
margin-right: 0;
}
}
</style> </style>
+5
View File
@@ -15,6 +15,11 @@ class InvoiceType extends CommonModel {
public const INVOICE_TYPE_CATERING = 'catering'; public const INVOICE_TYPE_CATERING = 'catering';
public const INVOICE_TYPE_LOGSTIC = 'logistic';
public const INVOICE_TYPE_TECHNICAL = 'technical';
public const INVOICE_TYPE_MANAGEMENT = 'management';
protected $fillable = [ protected $fillable = [
'slug', 'slug',
+2 -1
View File
@@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Scopes\CommonModel; use App\Scopes\CommonModel;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
class CronTask extends CommonModel class CronTask extends CommonModel
{ {
@@ -11,6 +12,6 @@ class CronTask extends CommonModel
protected $dates = ['last_run']; protected $dates = ['last_run'];
protected $casts = [ protected $casts = [
'last_run' => 'datetime', 'last_run' => AsArrayObject::class,
]; ];
} }
+1
View File
@@ -52,6 +52,7 @@ class Invoice extends InstancedModel
'contact_phone', 'contact_phone',
'contact_bank_owner', 'contact_bank_owner',
'contact_bank_iban', 'contact_bank_iban',
'payment_purpose',
'amount', 'amount',
'donation', 'donation',
'distance', 'distance',
+13 -7
View File
@@ -17,7 +17,6 @@ class CronTaskHandleProvider extends CommonController
$now = Carbon::now(); $now = Carbon::now();
$tenants = Tenant::where('has_active_instance', true)->get(); $tenants = Tenant::where('has_active_instance', true)->get();
foreach ($tenants as $tenant) { foreach ($tenants as $tenant) {
app()->instance('tenant', $tenant); app()->instance('tenant', $tenant);
$this->runTenantTasks($tenant, $now); $this->runTenantTasks($tenant, $now);
@@ -31,7 +30,6 @@ class CronTaskHandleProvider extends CommonController
private function runTenantTasks(Tenant $tenant, Carbon $now) { private function runTenantTasks(Tenant $tenant, Carbon $now) {
$tasks = CronTask::all(); $tasks = CronTask::all();
foreach ($tasks as $task) { foreach ($tasks as $task) {
// --- Every-Time Tasks --- // --- Every-Time Tasks ---
@@ -41,10 +39,14 @@ class CronTaskHandleProvider extends CommonController
// --- Daily Tasks --- // --- Daily Tasks ---
if ($task->execution_type === CronTaskType::CRON_TASK_TYPE_DAILY) { if ($task->execution_type === CronTaskType::CRON_TASK_TYPE_DAILY) {
$scheduledTime = $task->schedule_time; $tenantLastRun = $task->last_run?->get($tenant->slug);
$alreadyRunToday = $task->last_run?->isToday() ?? false;
if (!$alreadyRunToday && $now->format('H:i') === $scheduledTime) { $scheduledTime = \DateTime::createFromFormat('Y-m-d H:i:s', date('Y-m-d ') . $task->schedule_time);
$now = Carbon::now();
$alreadyRunToday = $tenantLastRun == null ? true : $tenantLastRun->isToday() ?? false;
if (!$alreadyRunToday && $now >= $scheduledTime) {
$this->runTask($task); $this->runTask($task);
} }
} }
@@ -56,14 +58,18 @@ class CronTaskHandleProvider extends CommonController
$logger = $this->taskLogger($task->name, app('tenant')); $logger = $this->taskLogger($task->name, app('tenant'));
app()->instance('taskLogger', $logger); app()->instance('taskLogger', $logger);
$taskClass = "\\App\\Tasks\\" . $task->name; $taskClass = "\\App\\Tasks\\" . $task->name;
if (class_exists($taskClass)) { if (class_exists($taskClass)) {
$instance = new $taskClass(); $instance = new $taskClass();
$instance->handle(); $instance->handle();
$lastRun = $task->last_run;
if ($lastRun === null) {
$lastRun = [];
}
$lastRun[app('tenant')->slug] = now();
// Update last_run // Update last_run
$task->last_run = now(); $task->last_run = $lastRun;
$task->save(); $task->save();
} }
} }
+7 -7
View File
@@ -25,12 +25,13 @@ class GlobalDataProvider {
'navbar' => $this->generateNavbar(), 'navbar' => $this->generateNavbar(),
'tenant' => app('tenant'), 'tenant' => app('tenant'),
'activeUsers' => $this->getActiveUsers(), 'activeUsers' => $this->getActiveUsers(),
'version' => config('app.version'),
]); ]);
} }
public function getAllInvoiceTypes() : JsonResponse { public function getAllInvoiceTypes() : JsonResponse {
$invoiceTypes = []; $invoiceTypes = [];
foreach (InvoiceType::all() as $invoiceType) { foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
if ( if (
$invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER $invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER
) { ) {
@@ -52,10 +53,9 @@ class GlobalDataProvider {
public function getInvoiceTypes() : JsonResponse { public function getInvoiceTypes() : JsonResponse {
$invoiceTypes = []; $invoiceTypes = [];
foreach (InvoiceType::all() as $invoiceType) { foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
if ( if (
$invoiceType->slug === InvoiceType::INVOICE_TYPE_TRAVELLING || $invoiceType->slug === InvoiceType::INVOICE_TYPE_TRAVELLING
$invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER
) { ) {
continue; continue;
} }
@@ -66,8 +66,6 @@ class GlobalDataProvider {
]; ];
} }
$invoiceTypes[] = ['slug' => InvoiceType::INVOICE_TYPE_OTHER, 'name' => 'Sonstige Kosten'];
return response()->json([ return response()->json([
'invoiceTypes' => $invoiceTypes 'invoiceTypes' => $invoiceTypes
]); ]);
@@ -110,6 +108,7 @@ class GlobalDataProvider {
'common' => [], 'common' => [],
'costunits' => [], 'costunits' => [],
'events' => [], 'events' => [],
'eventControl' => [],
]; ];
$navigation['personal'][] = ['url' => '/', 'display' => 'Home']; $navigation['personal'][] = ['url' => '/', 'display' => 'Home'];
@@ -129,7 +128,8 @@ class GlobalDataProvider {
$navigation['events'][] = ['url' => '/event/details/' . $event->identifier, 'display' => $event->name]; $navigation['events'][] = ['url' => '/event/details/' . $event->identifier, 'display' => $event->name];
} }
$navigation['events'][] = ['url' => '/create-event', 'display' => 'Neue Veranstaltung']; $navigation['eventControl'][] = ['url' => '/archived-events', 'display' => 'Archivierte Veranstaltungen'];
$navigation['eventControl'][] = ['url' => '/create-event', 'display' => 'Neue Veranstaltung'];
} }
} }
+2 -2
View File
@@ -25,7 +25,7 @@ class InvoiceCsvFileProvider {
'"Kontoinhaber*in"', '"Kontoinhaber*in"',
'"Betrag"', '"Betrag"',
'"Spende"', '"Spende"',
'"Beleg ohne Auszahlung"', '"Buchungstext"'
]); ]);
foreach ($this->invoices as $invoice) { foreach ($this->invoices as $invoice) {
@@ -38,7 +38,7 @@ class InvoiceCsvFileProvider {
'"' . $invoiceReadable['accountOwner'] . '"', '"' . $invoiceReadable['accountOwner'] . '"',
'"' . $invoiceReadable['amountPlain'] . '"', '"' . $invoiceReadable['amountPlain'] . '"',
'"' . $invoiceReadable['donation'] . '"', '"' . $invoiceReadable['donation'] . '"',
'"' . $invoiceReadable['alreadyPaid'] . '"' '"' . $invoiceReadable['paymentPurpose'] . '"'
]); ]);
$csvArray[] = $csvLine; $csvArray[] = $csvLine;
+5 -1
View File
@@ -3,8 +3,10 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Invoice; use App\Models\Invoice;
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;
@@ -89,6 +91,8 @@ class PainFileProvider {
$pmt_inf->appendChild($dbtr_agt); $pmt_inf->appendChild($dbtr_agt);
foreach ($this->invoices as $index => $invoice) { foreach ($this->invoices as $index => $invoice) {
$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');
@@ -112,7 +116,7 @@ class PainFileProvider {
$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', 'Auslagenerstattung Rechnungsnummer ' . $invoice['invoice_number'])); $rmt_inf->appendChild($doc->createElement('Ustrd', $invoiceResource['paymentPurpose']));
$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);
+1 -7
View File
@@ -16,12 +16,6 @@ class WebDavProvider {
public function uploadFile(string $fileName) : bool { public function uploadFile(string $fileName) : bool {
$baseDir = storage_path('app/private/'); $baseDir = storage_path('app/private/');
return $this->webDavClient->upload_file($baseDir . $fileName, $this->workingDirectory . '/'. return $this->webDavClient->upload_file($baseDir . $fileName, $this->workingDirectory . '/');
basename($fileName)
);
} }
} }
+4 -2
View File
@@ -23,12 +23,14 @@ class CostUnitRepository {
public function getCurrentEvents() : array { public function getCurrentEvents(bool $disableAccessCheck = false) : array {
return $this->getCostUnitsByCriteria([ return $this->getCostUnitsByCriteria([
'allow_new' => true, 'allow_new' => true,
'type' => CostUnitType::COST_UNIT_TYPE_EVENT, 'type' => CostUnitType::COST_UNIT_TYPE_EVENT,
'archived' => false 'archived' => false
]); ],
true,
$disableAccessCheck);
} }
public function getRunningJobs() : array { public function getRunningJobs() : array {
@@ -88,7 +88,7 @@ class EventParticipantRepository {
public function getParticipantsWithIntolerances(Event $event, Request $request) : array { public function getParticipantsWithIntolerances(Event $event, Request $request) : array {
$participants = []; $participants = [];
foreach ($event->participants()->whereNotNull('intolerances')->whereNot('intolerances' , '=', '')->get() as $participant) { foreach ($event->participants()->orderBy('lastname')->orderBy('firstname')->whereNotNull('intolerances')->whereNot('intolerances' , '=', '')->get() as $participant) {
$participants[] = $participant->toResource()->toArray($request); $participants[] = $participant->toResource()->toArray($request);
}; };
@@ -97,7 +97,7 @@ class EventParticipantRepository {
public function getKitchenOverview(Event $event) : array { public function getKitchenOverview(Event $event) : array {
$data = []; $data = [];
$participants = $event->participants()->get(); $participants = $event->participants()->orderBy('lastname')->orderBy('firstname')->get();
for ($cur_date = $event->start_date; $cur_date <= $event->end_date; $cur_date->modify('+1 day')) { for ($cur_date = $event->start_date; $cur_date <= $event->end_date; $cur_date->modify('+1 day')) {
+1 -1
View File
@@ -90,7 +90,7 @@ class EventRepository {
$visibleEvents = []; $visibleEvents = [];
/** @var Event $event */ /** @var Event $event */
foreach (Event::where($criteria)->get() as $event) { foreach (Event::where($criteria)->orderBy('start_date')->get() as $event) {
if ($canSeeAll || !$accessCheck || $event->eventManagers()->where('user_id', $user->id)->exists()) { if ($canSeeAll || !$accessCheck || $event->eventManagers()->where('user_id', $user->id)->exists()) {
$visibleEvents[] = $event; $visibleEvents[] = $event;
+1
View File
@@ -93,6 +93,7 @@ class UserRepository {
'first_aid_permission' => $request->firstAidPermission, 'first_aid_permission' => $request->firstAidPermission,
'bank_account_owner' => $request->bankAccountOwner, 'bank_account_owner' => $request->bankAccountOwner,
'bank_account_iban' => $request->bankAccountIban, 'bank_account_iban' => $request->bankAccountIban,
'birthday' => $request->birthday,
]); ]);
} }
} }
+1 -1
View File
@@ -31,7 +31,7 @@ class CostUnitResource {
$amounts = []; $amounts = [];
$overAllAmount = new Amount(0, 'Euro'); $overAllAmount = new Amount(0, 'Euro');
foreach (InvoiceType::all() as $invoiceType) { foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
$overAllAmount->addAmount($costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType)); $overAllAmount->addAmount($costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType));
$amounts[$invoiceType->slug]['string'] = $costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType)->toString(); $amounts[$invoiceType->slug]['string'] = $costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType)->toString();
$amounts[$invoiceType->slug]['name'] = $invoiceType->name; $amounts[$invoiceType->slug]['name'] = $invoiceType->name;
@@ -52,6 +52,7 @@ class EventParticipantResource extends JsonResource
'swimmingPermission' => $this->resource->swimmingPermission()->first()->short, 'swimmingPermission' => $this->resource->swimmingPermission()->first()->short,
'extendedFirstAid' => $this->resource->firstAidPermission()->first()->name, 'extendedFirstAid' => $this->resource->firstAidPermission()->first()->name,
'tetanusVaccination' => $this->resource->tetanus_vaccination?->format('d.m.Y') ?? 'Unbekannt', 'tetanusVaccination' => $this->resource->tetanus_vaccination?->format('d.m.Y') ?? 'Unbekannt',
'tetanusVaccinationEdit' => $this->resource->tetanus_vaccination?->format('Y-m-d') ?? null,
'presenceDays' => ['real' => $presenceDays, 'support' => $presenceDaysSupport], 'presenceDays' => ['real' => $presenceDays, 'support' => $presenceDaysSupport],
'participationType' => ParticipationType::where(['slug' => $this->resource->participation_type])->first()->name, 'participationType' => ParticipationType::where(['slug' => $this->resource->participation_type])->first()->name,
'needs_payment' => $this->resource->amount->getAmount() > 0 && $event->pay_direct && $this->resource->amount_paid?->getAmount() < $this->resource->amount->getAmount(), 'needs_payment' => $this->resource->amount->getAmount() > 0 && $event->pay_direct && $this->resource->amount_paid?->getAmount() < $this->resource->amount->getAmount(),
+2 -2
View File
@@ -26,16 +26,16 @@ class EventResource extends JsonResource{
'name' => $this->event->name, 'name' => $this->event->name,
'identifier' => $this->event->identifier, 'identifier' => $this->event->identifier,
'url' => 'https://' . app('tenant')->url . '/event/' . $this->event->identifier . '/signup', 'url' => 'https://' . app('tenant')->url . '/event/' . $this->event->identifier . '/signup',
'urlShort' => 'https://' . app('tenant')->url . '/event/' . $this->event->identifier,
'location' => $this->event->location, 'location' => $this->event->location,
'postalCode' => $this->event->postal_code, 'postalCode' => $this->event->postal_code,
'email' => $this->event->email, 'email' => $this->event->email,
'accountOwner' => $this->event->account_owner, 'accountOwner' => $this->event->account_owner,
'accountIban' => $this->event->account_iban, 'accountIban' => $this->event->account_iban,
'accountOwner' => $this->event->account_owner,
'accountIban' => $this->event->account_iban,
'alcoholicsAge' => $this->event->alcoholics_age, 'alcoholicsAge' => $this->event->alcoholics_age,
'sendWeeklyReports' => $this->event->send_weekly_report, 'sendWeeklyReports' => $this->event->send_weekly_report,
'registrationAllowed' => $this->event->registration_allowed, 'registrationAllowed' => $this->event->registration_allowed,
'archived' => $this->event->archived,
'earlyBirdEnd' => ['internal' => $this->event->early_bird_end->format('Y-m-d'), 'formatted' => $this->event->early_bird_end->format('d.m.Y')], 'earlyBirdEnd' => ['internal' => $this->event->early_bird_end->format('Y-m-d'), 'formatted' => $this->event->early_bird_end->format('d.m.Y')],
'registrationFinalEnd' => ['internal' => $this->event->registration_final_end->format('Y-m-d'), 'formatted' => $this->event->registration_final_end->format('d.m.Y')], 'registrationFinalEnd' => ['internal' => $this->event->registration_final_end->format('Y-m-d'), 'formatted' => $this->event->registration_final_end->format('d.m.Y')],
'refundAfterEarlyBirdEnd' => 100 - $this->event->early_bird_end_amount_increase, 'refundAfterEarlyBirdEnd' => 100 - $this->event->early_bird_end_amount_increase,
+3 -2
View File
@@ -38,7 +38,8 @@ class InvoiceResource {
$returnData['amount'] = Amount::fromString($this->invoice->amount, ' Euro')->toString(); $returnData['amount'] = Amount::fromString($this->invoice->amount, ' Euro')->toString();
$returnData['id'] = $this->invoice->id; $returnData['id'] = $this->invoice->id;
$returnData['donation'] = $this->invoice->donation; $returnData['donation'] = $this->invoice->donation;
$returnData['alreadyPaid'] = !$this->invoice->donation && null === $this->invoice->contact_bank_iban; $returnData['externalPayment'] = null !== $this->invoice->payment_purpose;
$returnData['paymentPurpose'] = $this->invoice->payment_purpose ?? 'Auslagenerstattung Rechnungsnummer ' . $returnData['invoiceNumber'];
$returnData['accountOwner'] = $this->invoice->contact_bank_owner ?? '--'; $returnData['accountOwner'] = $this->invoice->contact_bank_owner ?? '--';
$returnData['accountIban'] = $this->invoice->contact_bank_iban ?? '--'; $returnData['accountIban'] = $this->invoice->contact_bank_iban ?? '--';
$returnData['status'] = $this->invoice->status; $returnData['status'] = $this->invoice->status;
@@ -67,7 +68,7 @@ class InvoiceResource {
$returnData['approvedBy'] = $this->invoice->approvedBy()->first()->username ?? '--'; $returnData['approvedBy'] = $this->invoice->approvedBy()->first()->username ?? '--';
} }
$returnData['alreadyPaid'] = $returnData['alreadyPaid'] == '' ? 0 : 1; $returnData['externalPayment'] = $returnData['externalPayment'] == '' ? 0 : 1;
return $returnData; return $returnData;
} }
+33 -4
View File
@@ -2,19 +2,48 @@
namespace App\Tasks; namespace App\Tasks;
use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsCommand;
use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsRequest;
use App\Domains\CostUnit\Actions\ChangeCostUnitState\ChangeCostUnitStateCommand;
use App\Domains\CostUnit\Actions\ChangeCostUnitState\ChangeCostUnitStateRequest;
use App\Models\CostUnit; use App\Models\CostUnit;
use App\Repositories\CostUnitRepository; use App\Repositories\CostUnitRepository;
use App\ValueObjects\Amount;
class CloseCostUnit implements CronTask { class CloseCostUnit implements CronTask {
public function handle(): void public function handle(): void
{ {
$now = now(); $now = now();
$costUnitRepository = new CostUnitRepository(); $costUnitRepository = new CostUnitRepository();
$now = now();
$billingResetEnd = clone $now;
$billingResetEnd->add(new \DateInterval('P6W'));
/** @var CostUnit $costUnit */ /** @var CostUnit $costUnit */
foreach ($costUnitRepository->getCurrentEvents() as $costUnit) { foreach ($costUnitRepository->getCurrentEvents(true) as $costUnit) {
if (\DateTime::createFromFormat('Y-m-d', $costUnit->billing_deadline) < $now ) { $billingEnd = $costUnit['billing_deadline'];
$costUnit->allow_new = false;
$costUnit->save(); if ($billingEnd === null) {
$billingEnd = \DateTime::createFromFormat('Y-m-d H:i:s', $billingResetEnd)->format('Y-m-d H:i:s');
new ChangeCostUnitDetailsCommand(new ChangeCostUnitDetailsRequest(
CostUnit::where('id', $costUnit['id'])->first(),
Amount::fromString($costUnit['distance_allowance']),
$costUnit['mail_on_new'],
$billingResetEnd
))->execute();
}
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
$billingEndTime->setTime(0,0,0);
$billingEndTime->add(new \DateInterval('P1D'));
if ($billingEndTime < $now) {
new ChangeCostUnitStateCommand(
new ChangeCostUnitStateRequest(
CostUnit::where('id', $costUnit['id'])->first(),false, false
)
)->execute();
} }
} }
} }
+2 -1
View File
@@ -12,7 +12,8 @@ class CloseEvent implements CronTask {
$eventRepository = new EventRepository(); $eventRepository = new EventRepository();
/** @var Event $event */ /** @var Event $event */
foreach ($eventRepository->getAvailable(false) as $event) { foreach ($eventRepository->getAvailable(false) as $event) {
if ($event->registration_final_end < $now ) { echo $event->id . $event->tenant .'<br>';
if ($event->registration_final_end <= $now ) {
$event->registration_allowed = false; $event->registration_allowed = false;
$event->save(); $event->save();
} }
+5 -3
View File
@@ -16,16 +16,17 @@ use Psr\Log\LoggerInterface;
class NotifyTeam implements CronTask { class NotifyTeam implements CronTask {
public function handle(): void public function handle(): void
{ {
if (date('w') !== 0) { if ((int)date('w') !== 0) {
// return; return;
} }
$eventRepository = new EventRepository(); $eventRepository = new EventRepository();
/** @var Event $event */ /** @var Event $event */
foreach ($eventRepository->getAvailable(false) as $event) { foreach ($eventRepository->getAvailable(false) as $event) {
foreach (Tenant::all() as $tenant) { foreach (Tenant::all() as $tenant) {
echo $tenant->slug . PHP_EOL;
$participants = $event->participants()->where('local_group', $tenant->slug)->whereNull('unregistered_at')->get(); $participants = $event->participants()->where('local_group', $tenant->slug)->whereNull('unregistered_at')->get();
}
if ($participants->isEmpty()) { if ($participants->isEmpty()) {
continue; continue;
@@ -39,3 +40,4 @@ class NotifyTeam implements CronTask {
} }
} }
} }
}
+45
View File
@@ -126,6 +126,14 @@ onUnmounted(() => {
right: 15px; right: 15px;
cursor: pointer; cursor: pointer;
font-size: 22px; font-size: 22px;
z-index: 10;
background: rgba(255,255,255,0.9);
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
} }
/* Animation */ /* Animation */
@@ -146,4 +154,41 @@ onUnmounted(() => {
transform: scale(0.98); transform: scale(0.98);
opacity: 0; opacity: 0;
} }
/* ─── Tablet ─── */
@media (max-width: 1023px) {
.full-screen-modal-content {
top: 10px;
bottom: 10px;
left: 10px;
right: 10px;
}
.full-screen-modal-body {
padding: 14px;
}
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
.full-screen-modal-content {
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: 0;
}
.full-screen-modal-body {
padding: 10px;
}
.full-screen-modal-close {
top: 8px;
right: 8px;
font-size: 26px;
width: 36px;
height: 36px;
}
}
</style> </style>
+38 -9
View File
@@ -47,15 +47,11 @@ function close() {
emit('close') emit('close')
} }
/**
* 1. ESC-Key schließt Modal
*/
function handleKeyDown(e) { function handleKeyDown(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
close() close()
} }
// 2. Focus-Trap
if (e.key === 'Tab' && modalRef.value) { if (e.key === 'Tab' && modalRef.value) {
const focusable = modalRef.value.querySelectorAll( const focusable = modalRef.value.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
@@ -75,16 +71,13 @@ function handleKeyDown(e) {
} }
} }
/**
* 3. Body-Scroll sperren wenn Modal offen
*/
watch( watch(
() => props.show, () => props.show,
async (newVal) => { async (newVal) => {
if (newVal) { if (newVal) {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
await nextTick() await nextTick()
modalRef.value?.focus() // Setzt Focus ins Modal modalRef.value?.focus()
} else { } else {
document.body.style.overflow = '' document.body.style.overflow = ''
} }
@@ -96,7 +89,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = '' // fallback cleanup document.body.style.overflow = ''
}) })
</script> </script>
@@ -109,6 +102,7 @@ onUnmounted(() => {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 10px;
} }
.modal-content { .modal-content {
background: white; background: white;
@@ -120,6 +114,11 @@ onUnmounted(() => {
outline: none; outline: none;
} }
.modal-body {
max-height: 600px;
overflow: auto;
}
/* Animation */ /* Animation */
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
@@ -138,4 +137,34 @@ onUnmounted(() => {
transform: scale(0.95); transform: scale(0.95);
opacity: 0; opacity: 0;
} }
/* ─── Tablet ─── */
@media (max-width: 1023px) {
.modal-content {
width: 95% !important;
max-width: 95vw;
}
.modal-body {
max-height: 70vh;
}
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
.modal-overlay {
align-items: flex-end;
padding: 0;
}
.modal-content {
width: 100% !important;
max-width: 100% !important;
border-radius: 16px 16px 0 0;
}
.modal-body {
max-height: 75vh;
}
}
</style> </style>
+45 -9
View File
@@ -5,7 +5,6 @@ const props = defineProps({
tabs: { tabs: {
type: Array, type: Array,
required: true, required: true,
// [{ title: "Titel", component: Component, endpoint: "/wp-json/..." }]
}, },
subTabIndex: { subTabIndex: {
type: Number, type: Number,
@@ -14,9 +13,9 @@ const props = defineProps({
} }
}) })
const activeTab = ref(null) // aktuell ausgewählter Tab const activeTab = ref(null)
const tabData = ref({}) // Daten für jeden Tab const tabData = ref({})
const tabComponent = shallowRef(null) // Komponente für aktuellen Tab const tabComponent = shallowRef(null)
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
@@ -34,13 +33,13 @@ async function selectTab(index) {
?.getAttribute('content') ?.getAttribute('content')
const response = await fetch(tab.endpoint, { const response = await fetch(tab.endpoint, {
method: 'GET', // oder POST, PUT, DELETE method: 'GET',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'X-CSRF-TOKEN': csrfToken, 'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
}, },
credentials: 'same-origin', // wichtig für Session/Auth credentials: 'same-origin',
}) })
if (!response.ok) throw new Error("Fehler: " + response.status) if (!response.ok) throw new Error("Fehler: " + response.status)
@@ -56,7 +55,6 @@ async function selectTab(index) {
} }
} }
// ersten Tab automatisch laden
onMounted(() => { onMounted(() => {
if (props.tabs.length > 0) { if (props.tabs.length > 0) {
selectTab(props.subTabIndex) selectTab(props.subTabIndex)
@@ -66,7 +64,6 @@ onMounted(() => {
<template> <template>
<div class="tabs"> <div class="tabs">
<!-- Tab Header -->
<div class="tab-header"> <div class="tab-header">
<button <button
v-for="(tab, index) in tabs" v-for="(tab, index) in tabs"
@@ -78,7 +75,6 @@ onMounted(() => {
</button> </button>
</div> </div>
<!-- Tab Content -->
<div class="tab-content" id="tab-content"> <div class="tab-content" id="tab-content">
<div v-if="loading"> Lädt...</div> <div v-if="loading"> Lädt...</div>
<div v-else-if="error"> {{ error }}</div> <div v-else-if="error"> {{ error }}</div>
@@ -101,12 +97,17 @@ onMounted(() => {
.tab-header { .tab-header {
display: flex; display: flex;
border-bottom: 1px solid #ddd; border-bottom: 1px solid #ddd;
flex-wrap: wrap;
gap: 2px;
background: #f8f8f8;
} }
.tab-button { .tab-button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
cursor: pointer; cursor: pointer;
border: none; border: none;
background: #f8f8f8; background: #f8f8f8;
white-space: nowrap;
font-size: 0.95rem;
} }
.tab-button.active { .tab-button.active {
font-weight: bold; font-weight: bold;
@@ -116,5 +117,40 @@ onMounted(() => {
.tab-content { .tab-content {
padding: 1rem; padding: 1rem;
width: 100%; width: 100%;
overflow-x: auto;
}
/* ─── Tablet ─── */
@media (max-width: 1023px) {
.tab-button {
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
}
}
/* ─── Smartphone ─── */
@media (max-width: 639px) {
.tab-header {
flex-direction: column;
flex-wrap: nowrap;
}
.tab-button {
width: 100%;
text-align: left;
padding: 0.65rem 1rem;
border-bottom: 1px solid #e5e7eb;
font-size: 0.9rem;
}
.tab-button.active {
border-bottom: none;
border-left: 4px solid #0073aa;
background: #ffffff;
}
.tab-content {
padding: 0.75rem;
}
} }
</style> </style>
+1 -1
View File
@@ -19,7 +19,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<label :for="props.belongsTo">{{contentData.content}}</label> <label :for="props.belongsTo" v-html="contentData.content"></label>
</template> </template>
<style scoped> <style scoped>
@@ -46,4 +46,12 @@ import UpcomingEvents from "../../../Domains/Dashboard/Views/Partials/Widgets/Up
display: none; display: none;
} }
@media (max-width: 639px) {
.widget-container {
display: none;
}
}
</style> </style>
+1
View File
@@ -123,4 +123,5 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'), 'store' => env('APP_MAINTENANCE_STORE', 'database'),
], ],
'version' => trim(file_get_contents(base_path('version'))),
]; ];
@@ -12,7 +12,7 @@ return new class extends Migration {
Schema::create('page_texts', function (Blueprint $table) { Schema::create('page_texts', function (Blueprint $table) {
$table->id(); $table->id();
$table->string('name'); $table->string('name');
$table->string('content'); $table->longText('content');
$table->timestamps(); $table->timestamps();
}); });
} }
@@ -27,7 +27,7 @@ return new class extends Migration {
$table->string('lastname'); $table->string('lastname');
$table->string('nickname')->nullable(); $table->string('nickname')->nullable();
$table->string('participation_type'); $table->string('participation_type');
$table->string('local_group'); $table->string('local_group')->nullable();
$table->date('birthday'); $table->date('birthday');
$table->string('address_1'); $table->string('address_1');
$table->string('address_2')->nullable(); $table->string('address_2')->nullable();
@@ -0,0 +1,21 @@
<?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::table('invoice_types', function (Blueprint $table) {
$table->integer('sort_order')->default(1)->after('name');
});
}
public function down(): void
{
}
};
@@ -0,0 +1,21 @@
<?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::table('invoices', function (Blueprint $table) {
$table->string('payment_purpose')->nullable()->default(null)->after('contact_bank_iban');
});
}
public function down(): void
{
}
};
Executable → Regular
+4 -3
View File
@@ -12,6 +12,8 @@ install -d -m 0755 -o $USER -g $GROUPNAME \
storage/app/private \ storage/app/private \
storage/logs storage/logs
php artisan migrate --force
rm .dockerignore rm .dockerignore
rm .editorconfig rm .editorconfig
rm .env.example rm .env.example
@@ -24,12 +26,11 @@ rm docker-compose.prod
rm justfile rm justfile
rm phpunit.xml rm phpunit.xml
rm -rf node_modules rm -rf node_modules
rm composer.json
rm composer.lock rm composer.lock
rm package-lock.json rm package-lock.json
rm package.json rm package.json
rm -rf docker rm -rf docker
rm -rf database
php artisan migrate --force rm -rf app/Installer
exec "php-fpm" exec "php-fpm"
+129 -15
View File
@@ -16,7 +16,6 @@
html { html {
background-color: #FAFAFB !important; background-color: #FAFAFB !important;
font-family: Aleo; font-family: Aleo;
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
@@ -39,35 +38,32 @@ h1, h2, h3, h4, h5, h6 {
} }
.header { .header {
height: 80px; /* Höhe anpassen */ height: 80px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
width: calc(100% - 40px); width: 100%;
} }
.header .anonymous-actions { .header .anonymous-actions {
position: relative; position: relative;
right: calc(-100% + 330px); right: calc(-100% + 300px);
width: 350px; width: 450px;
overflow: hidden; overflow: hidden;
border-radius: 50px 0 0 50px; border-radius: 50px 0 0 50px;
background-color: #FAFAFB !important;
text-align: right; text-align: right;
padding: 10px; padding: 10px;
top: -75px; top: -20px;
} }
.header .user-info { .header .user-info {
position: relative; position: relative;
right: calc(-100% + 275px); right: calc(-100% + 190px);
width: 295px; width: 195px;
overflow: hidden; overflow: hidden;
border-radius: 50px 0 0 50px; border-radius: 50px 0 0 50px;
background-color: #FAFAFB !important;
text-align: right; text-align: right;
padding: 10px; padding: 10px;
top: -75px; top: -20px;
} }
.header .left-side { .header .left-side {
@@ -78,7 +74,6 @@ h1, h2, h3, h4, h5, h6 {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); box-shadow: 0 1px 3px rgba(0,0,0,0.1);
width: 100%; width: 100%;
margin-top: -5px; margin-top: -5px;
} }
.flexbox { .flexbox {
@@ -88,8 +83,6 @@ h1, h2, h3, h4, h5, h6 {
padding: 0; padding: 0;
gap: 1px; gap: 1px;
margin: 0; margin: 0;
} }
/* Layout */ /* Layout */
@@ -182,3 +175,124 @@ th:after {
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration: none;
} }
/*
RESPONSIVE TABLET (640px 1023px)
*/
@media (max-width: 1023px) {
.app-layout {
margin: 0 !important;
height: 100vh;
border-radius: 0;
}
.main {
margin: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.header {
display: flex;
align-items: center;
flex-direction: row;
justify-content: space-between;
height: 60px;
padding: 0;
width: 100%;
}
.header .anonymous-actions,
.header .user-info {
position: static !important;
right: auto !important;
top: auto !important;
width: auto !important;
border-radius: 0 !important;
background-color: transparent !important;
padding: 0 10px !important;
text-align: right;
}
.header .left-side {
flex: 1;
height: auto;
padding: 0 15px;
margin-top: 0 !important;
box-shadow: none;
display: flex;
flex-direction: column;
justify-content: center;
width: auto;
}
.header .left-side h1 {
font-size: 1.1rem;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#show_username {
position: static !important;
top: auto !important;
font-size: 0.78rem;
text-align: left;
width: auto !important;
}
.content {
padding: 20px 15px !important;
}
.footer table {
font-size: 9pt;
}
.footer table td:first-child {
width: auto;
}
}
/*
RESPONSIVE SMARTPHONE (< 640px)
*/
@media (max-width: 639px) {
.header {
height: 56px;
}
.header .left-side h1 {
font-size: 0.95rem;
}
#show_username {
display: none !important;
}
.header .anonymous-actions a,
.header .user-info a {
padding: 8px 8px !important;
font-size: 0.8rem;
}
.content {
padding: 12px 8px !important;
}
.footer table,
.footer table tbody,
.footer table tr,
.footer table td {
display: block;
width: 100% !important;
text-align: center;
line-height: 1.8em;
}
.footer {
font-size: 9pt;
padding: 6px;
}
}
+93
View File
@@ -38,3 +38,96 @@
font-weight: bolder; font-weight: bolder;
font-size: 12pt; font-size: 12pt;
} }
/*
RESPONSIVE TABLET (640px 1023px)
*/
@media (max-width: 1023px) {
.costunit-list table {
width: 100% !important;
font-size: 0.88rem;
}
.costunit-list table tr td input[type="button"] {
width: 100%;
margin-bottom: 6px;
}
.costunit-list table tr th {
width: 120px !important;
font-size: 0.88rem;
}
}
/*
RESPONSIVE SMARTPHONE (< 640px)
*/
@media (max-width: 639px) {
.costunit-list table {
width: 100% !important;
padding: 8px !important;
}
.costunit-list table thead,
.costunit-list table tbody,
.costunit-list table tfoot,
.costunit-list table tr,
.costunit-list table td,
.costunit-list table th {
display: block !important;
width: 100% !important;
}
.costunit-list table td[rowspan] {
width: 100% !important;
}
.costunit-list table tr {
display: flex !important;
flex-wrap: wrap;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.costunit-list table tr th {
flex: 0 0 140px;
font-size: 0.82rem !important;
width: 140px !important;
padding-right: 8px;
}
.costunit-list table tr td {
flex: 1;
font-size: 0.82rem;
padding-right: 0;
}
.costunit-list table thead tr td {
font-size: 1rem !important;
padding: 6px 0 !important;
border-bottom: 1px solid #ddd;
margin-bottom: 8px;
}
.costunit-list table tr td:last-child {
flex: 0 0 100%;
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 8px;
}
.costunit-list table tr td input[type="button"] {
width: 100% !important;
margin: 0 !important;
font-size: 0.85rem;
padding: 10px;
}
.costunit-list .link {
display: inline-block;
margin: 4px 0;
font-size: 0.85rem;
}
}
+89 -4
View File
@@ -4,7 +4,7 @@
padding: 20px; padding: 20px;
} }
.invoice-main-flexbox div { .invoice-type-layer {
flex: 1; flex: 1;
padding: 10px; padding: 10px;
border: 1px solid #ccc; border: 1px solid #ccc;
@@ -15,7 +15,7 @@
cursor: pointer; cursor: pointer;
} }
.invoice-main-flexbox div:hover { .invoice-type-layer:hover {
background-color: #FAE39C; background-color: #FAE39C;
} }
@@ -40,7 +40,6 @@ fieldset legend {
border-radius: 10px; border-radius: 10px;
} }
.invoice-list-table { .invoice-list-table {
width: 90%; width: 90%;
margin: 20px auto; margin: 20px auto;
@@ -59,7 +58,93 @@ fieldset legend {
border-right: 1px solid #c3c4c7; border-right: 1px solid #c3c4c7;
} }
.invoice-list-table tr:first-child td { .invoice-list-table tr:first-child td {
background: linear-gradient(to bottom, #fff, #f6f7f7); background: linear-gradient(to bottom, #fff, #f6f7f7);
} }
/*
RESPONSIVE TABLET (640px 1023px)
*/
@media (max-width: 1023px) {
.invoice-main-flexbox {
flex-wrap: wrap;
gap: 15px;
padding: 15px;
}
.invoice-type-layer {
flex: 1 1 calc(50% - 15px);
min-width: 180px;
min-height: 150px;
}
fieldset {
margin: 10px 10px;
padding: 12px 20px;
}
.invoice-list-table {
width: 100%;
font-size: 0.9rem;
}
}
/*
RESPONSIVE SMARTPHONE (< 640px)
*/
@media (max-width: 639px) {
.invoice-main-flexbox {
flex-direction: column;
padding: 10px;
gap: 10px;
}
.invoice-type-layer {
flex: 1 1 100%;
min-height: 100px;
font-size: 0.9rem;
padding: 12px;
}
fieldset {
margin: 8px 4px;
padding: 10px 12px;
}
fieldset legend {
padding: 6px 12px;
font-size: 0.9rem;
}
.invoice-list-table {
width: 100%;
font-size: 0.82rem;
}
.invoice-list-table tr {
display: block;
margin-bottom: 10px;
border: 1px solid #c3c4c7;
border-radius: 6px;
padding: 6px;
}
.invoice-list-table td {
display: block;
width: 100% !important;
border: none !important;
padding: 4px 6px;
}
.invoice-list-table tr:first-child td {
background: linear-gradient(to bottom, #fff, #f6f7f7);
border-bottom: 1px solid #ddd !important;
font-weight: bold;
}
.invoice-list-table input[type="button"],
.invoice-list-table .button {
width: 100%;
margin-top: 6px;
}
}
+358 -106
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import {reactive, onMounted} from 'vue'; import {reactive, onMounted, ref} 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";
@@ -13,6 +13,7 @@ const globalProps = reactive({
common: [], common: [],
costunits: [], costunits: [],
events: [], events: [],
eventControl: [],
}, },
tenant: '', tenant: '',
user: null, user: null,
@@ -22,6 +23,16 @@ const globalProps = reactive({
message: '' message: ''
}); });
const sidebarOpen = ref(false);
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value;
}
function closeSidebar() {
sidebarOpen.value = false;
}
onMounted(async () => { onMounted(async () => {
const response = await fetch('/api/v1/core/retrieve-global-data'); const response = await fetch('/api/v1/core/retrieve-global-data');
const data = await response.json(); const data = await response.json();
@@ -51,119 +62,110 @@ const props = defineProps({
title: { type: String, default: 'App' }, title: { type: String, default: 'App' },
flash: { type: Object, default: () => ({}) } flash: { type: Object, default: () => ({}) }
}); });
</script> </script>
<template> <template>
<div class="app-layout"> <div class="app-layout">
<!-- Sidebar --> <!-- Mobile Overlay -->
<!-- Main content --> <div class="sidebar-overlay" :class="{ active: sidebarOpen }" @click="closeSidebar"></div>
<div class="main"> <div class="main">
<!-- Header --> <!-- Header -->
<div class="flexbox">
<div class="sidebar">
<div class="logo">
<img src="../../../public/images/logo.png" alt="Logo" />
</div>
<nav class="nav">
<ul class="nav-links" v-if="globalProps.navbar.personal.length > 0">
<li v-for="navlink in globalProps.navbar.personal">
<a
:class="{ navlink_active: navlink.url.endsWith(currentPath) }"
:href="navlink.url"
>{{navlink.display}}</a>
</li>
</ul>
<ul class="nav-links" v-if="globalProps.navbar.common.length > 0">
<li v-for="navlink in globalProps.navbar.common">
<a
:class="{ navlink_active: navlink.url.endsWith(currentPath) }"
:href="navlink.url"
>{{navlink.display}}</a>
</li>
</ul>
<ul class="nav-links" v-if="globalProps.navbar.costunits.length > 0">
<li v-for="navlink in globalProps.navbar.costunits">
<a
:class="{ navlink_active: navlink.url.endsWith(currentPath) }"
:href="navlink.url"
>{{navlink.display}}</a>
</li>
</ul>
<ul class="nav-links" v-if="globalProps.navbar.events.length > 0">
<li v-for="navlink in globalProps.navbar.events">
<a
:class="{ navlink_active: navlink.url.endsWith(currentPath) }"
:href="navlink.url"
>{{navlink.display}}</a>
</li>
</ul>
</nav>
</div>
<div class="main">
<div class="header"> <div class="header">
<button class="hamburger-btn" @click="toggleSidebar" aria-label="Menü öffnen">
<span></span>
<span></span>
<span></span>
</button>
<div class="left-side"> <div class="left-side">
<h1>{{ props.title }}</h1> <h1>{{ props.title }}</h1>
<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>
<div class="user-info" v-if="globalProps.user !== null">
<div class="header-actions" v-if="globalProps.user !== null">
<div class="user-info">
<a href="/messages" class="header-link-anonymous" title="Meine Nachrichten"> <a href="/messages" class="header-link-anonymous" title="Meine Nachrichten">
<Icon name="envelope" /> <Icon name="envelope" />
</a> </a>
<a href="/profile" class="header-link-anonymous" title="Mein Profil"> <a href="/profile" class="header-link-anonymous" title="Mein Profil">
<Icon name="user" /> <Icon name="user" />
</a> </a>
<a href="/logout" class="header-link-anonymous-logout" title="Abmelden"> <a href="/logout" class="header-link-anonymous-logout" title="Abmelden">
<Icon name="lock" /> <Icon name="lock" />
</a> </a>
</div> </div>
<div class="anonymous-actions" v-else> </div>
<div class="anonymous-header-actions-mark" v-else>
<div class="anonymous-actions">
<a href="/register" class="header-link-anonymous">Registrieren</a> <a href="/register" class="header-link-anonymous">Registrieren</a>
<a href="/login" class="header-link-anonymous">Anmelden</a> <a href="/login" class="header-link-anonymous">Anmelden</a>
</div> </div>
</div>
</div> </div>
<!-- Flexbox: Sidebar + Content -->
<div class="flexbox">
<div class="sidebar" :class="{ 'sidebar-open': sidebarOpen }">
<div class="logo">
<img src="../../../public/images/logo.png" alt="Logo" />
</div>
<nav class="nav">
<ul class="nav-links" v-if="globalProps.navbar.personal.length > 0">
<li v-for="navlink in globalProps.navbar.personal">
<a :class="{ navlink_active: navlink.url.endsWith(currentPath) }"
:href="navlink.url" @click="closeSidebar">{{ navlink.display }}</a>
</li>
</ul>
<ul class="nav-links" v-if="globalProps.navbar.common.length > 0">
<li v-for="navlink in globalProps.navbar.common">
<a :class="{ navlink_active: navlink.url.endsWith(currentPath) }"
:href="navlink.url" @click="closeSidebar">{{ navlink.display }}</a>
</li>
</ul>
<ul class="nav-links" v-if="globalProps.navbar.costunits.length > 0">
<li v-for="navlink in globalProps.navbar.costunits">
<a :class="{ navlink_active: navlink.url.endsWith(currentPath) }"
:href="navlink.url" @click="closeSidebar">{{ navlink.display }}</a>
</li>
</ul>
<ul class="nav-links" v-if="globalProps.navbar.events.length > 0">
<li v-for="navlink in globalProps.navbar.events">
<a :class="{ navlink_active: navlink.url.endsWith(currentPath) }"
:href="navlink.url" @click="closeSidebar">{{ navlink.display }}</a>
</li>
</ul>
<ul class="nav-links" v-if="globalProps.navbar.eventControl && globalProps.navbar.eventControl.length > 0">
<li v-for="navlink in globalProps.navbar.eventControl">
<a :class="{ navlink_active: navlink.url.endsWith(currentPath) }"
:href="navlink.url" @click="closeSidebar">{{ navlink.display }}</a>
</li>
</ul>
</nav>
</div>
<div class="content-area">
<global-widgets :user="globalProps.user" :tenant="globalProps.tenant" v-if="globalProps.user !== null" /> <global-widgets :user="globalProps.user" :tenant="globalProps.tenant" v-if="globalProps.user !== null" />
<div class="content"> <div class="content">
<slot /> <slot />
</div> </div>
</div> </div>
</div> </div>
<!-- Footer --> <!-- Footer -->
<footer class="footer"> <footer class="footer">
<table> <div class="footer-inner">
<tr> <span>Version {{ globalProps.version }}</span>
<td>Version 4.0.0</td> <span class="footer-hide-mobile">mareike Modernes Anmeldesystem und richtig einfache Kostenerfassung</span>
<td> <span>Impressum</span>
mareike - Mdodernes Anmeldesystem und richtig einfache Kostenerfassung <span>Datenschutzerklärung</span>
</td> <span>&copy; 2022 2026</span>
<td> </div>
Impressum
</td>
<td>
Datenschutzerklärung
</td>
<td>
&copy 2022 - 2026
</td>
</tr>
</table>
</footer> </footer>
</div> </div>
<!-- Toaster -->
<transition name="fade"> <transition name="fade">
<div v-if="flash.message" class="toaster"> <div v-if="flash.message" class="toaster">
{{ flash.message }} {{ flash.message }}
@@ -173,6 +175,55 @@ const props = defineProps({
</template> </template>
<style scoped> <style scoped>
/* ─── Header ─── */
.header {
display: flex;
align-items: center;
height: 80px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 0;
position: relative;
z-index: 50;
flex-shrink: 0;
}
.left-side {
flex: 1;
padding: 0 20px;
overflow: hidden;
}
.left-side h1 {
margin: 0;
font-size: 1.4rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#show_username {
display: block;
font-weight: bold;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.anonymous-header-actions-mark {
display: flex;
align-items: center;
flex-shrink: 0;
width: 300px;
}
.header-actions {
display: flex;
align-items: center;
flex-shrink: 0;
width: 200px;
}
.header-link-anonymous, .header-link-anonymous,
.header-link-anonymous-logout { .header-link-anonymous-logout {
@@ -180,13 +231,8 @@ const props = defineProps({
font-weight: bold; font-weight: bold;
text-decoration: none; text-decoration: none;
background-color: #ffffff; background-color: #ffffff;
padding: 10px 30px; padding: 10px 20px;
margin: 0 -20px 0 30px; display: inline-block;
overflow: hidden !important;
}
.header-link-anonymous-logout {
padding-right: 35px !important;
} }
.header-link-anonymous:hover { .header-link-anonymous:hover {
@@ -199,45 +245,110 @@ const props = defineProps({
color: #ffffff; color: #ffffff;
} }
.nav-links { /* ─── Hamburger ─── */
border-bottom: 1px solid #ddd; .hamburger-btn {
width: 285px; display: none;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
width: 50px;
height: 80px;
border: none;
background: transparent;
cursor: pointer;
flex-shrink: 0;
padding: 0 12px;
} }
.nav-links li a { .hamburger-btn span {
color: #b6b6b6;
background-color: #fff;
padding: 20px 25px;
display: block; display: block;
text-decoration: none; width: 24px;
width: calc(100% - 50px); height: 3px;
font-weight: bold; background-color: #333;
margin-bottom: 0px; border-radius: 2px;
transition: all 0.2s;
} }
/* ─── Layout ─── */
.app-layout {
display: flex;
height: 100vh;
background: #f0f2f5;
font-family: sans-serif;
overflow: hidden;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
margin: 20px;
box-shadow: 20px 20px 15px rgba(0, 0, 0, 0.1);
border-radius: 0 10px 0 0;
}
.flexbox {
display: flex;
flex: 1;
background-color: #FAFAFB;
overflow: hidden;
gap: 1px;
}
/* ─── Sidebar ─── */
.sidebar {
flex-basis: 275px;
flex-shrink: 0;
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
background-color: #ffffff;
overflow-y: auto;
transition: transform 0.3s ease;
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 99;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
}
.logo img {
width: 135px;
height: 70px;
object-fit: contain;
}
/* ─── Nav ─── */
.nav { .nav {
flex: 1; flex: 1;
margin-left: -10px;
width: 275px;
} }
.nav ul { .nav ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0;
border-bottom: 1px solid #ddd;
} }
.nav li { .nav-links li a {
margin-bottom: 10px; color: #b6b6b6;
} background-color: #fff;
padding: 16px 25px;
.nav a {
text-decoration: none;
color: #333;
padding: 8px 12px;
display: block; display: block;
transition: background 0.2s; text-decoration: none;
font-weight: bold;
} }
.nav a:hover { .nav a:hover {
@@ -247,6 +358,147 @@ const props = defineProps({
.navlink_active { .navlink_active {
background-color: #fae39c !important; background-color: #fae39c !important;
color: #1d4899 !important; /* Dunklere Schrift beim Hover */ color: #1d4899 !important;
}
/* ─── Content ─── */
.content-area {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
.content {
padding: 30px 20px;
flex: 1;
}
/* ─── Footer ─── */
.footer {
background: #666666;
border-top: 1px solid #ddd;
color: #ffffff;
padding: 10px 15px;
font-size: 11pt;
font-weight: bold;
flex-shrink: 0;
}
.footer-inner {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
justify-content: space-between;
align-items: center;
}
/*
TABLET (640px 1023px)
*/
@media (max-width: 1023px) {
.app-layout {
margin: 0;
height: 100vh;
}
.main {
margin: 0;
border-radius: 0;
box-shadow: none;
}
.hamburger-btn {
display: flex;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
transform: translateX(-100%);
width: 260px;
flex-basis: 260px;
}
.sidebar.sidebar-open {
transform: translateX(0);
}
.sidebar-overlay.active {
display: block;
}
.header-link-anonymous,
.header-link-anonymous-logout {
padding: 10px 12px;
font-size: 0.9rem;
}
.left-side h1 {
font-size: 1.1rem;
}
}
/*
SMARTPHONE (< 640px)
*/
@media (max-width: 639px) {
.header {
height: 60px;
}
.hamburger-btn {
height: 60px;
}
.left-side h1 {
font-size: 1rem;
}
#show_username {
display: none;
}
.anonymous-actions {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.8rem;
width: 250px !important;
}
.anonymous-header-actions-mark {
width: 100%;
}
.header-link-anonymous,
.header-link-anonymous-logout {
padding: 6px 8px;
font-size: 0.75rem;
display: inline;
}
.footer-hide-mobile {
display: none;
}
.footer-inner {
justify-content: center;
font-size: 9pt;
gap: 6px 12px;
}
.content {
padding: 15px 10px;
}
.sidebar {
width: 240px;
flex-basis: 240px;
}
} }
</style> </style>
+7
View File
@@ -23,6 +23,13 @@ require_once __DIR__ . '/../app/Domains/Event/Routes/web.php';
require_once __DIR__ . '/../app/Domains/Event/Routes/api.php'; require_once __DIR__ . '/../app/Domains/Event/Routes/api.php';
Route::get('/LKvDUqWl', function () {
return redirect('/event/LKvDUqWl');
});
Route::get('/LKvDUqWl/', function () {
return redirect('/event/LKvDUqWl');
});
Route::get('/execute-crons', [CronTaskHandleProvider::class, 'run']); Route::get('/execute-crons', [CronTaskHandleProvider::class, 'run']);
Route::get('/print-girocode/{participantToken}', GiroCodeGetController::class); Route::get('/print-girocode/{participantToken}', GiroCodeGetController::class);
+1 -1
View File
@@ -1 +1 @@
4.0.0 4.3.0