Feature-Toggles, Administration, wiederkehrende Events und Event-Serien

- Administration & Rollenmanagement: Neuer Admin-Bereich mit Feature-Toggles
  und Sichtbarkeitseinstellungen pro Rolle (11 Toggles, 24 Visibility-Settings)
- AdministrationController mit eigenem Settings-Tab, aus SettingsController extrahiert
- Feature-Toggle-Guards in Controllers (Invitation, File, ListGenerator, Comment)
  und Views (events/show, events/edit, events/create)
- Setting::isFeatureEnabled() und isFeatureVisibleFor() Hilfsmethoden
- Wiederkehrende Trainings: Täglich/Wöchentlich/2-Wöchentlich mit Ende per
  Datum oder Anzahl (max. 52), Vorschau im Formular
- Event-Serien: Verknüpfung über event_series_id (UUID), Modal-Dialog beim
  Speichern und Löschen mit Optionen "nur dieses" / "alle folgenden"
- Löschen-Button direkt in der Event-Bearbeitung mit Serien-Dialog
- DemoDataSeeder: 4 Trainings als Serie mit gemeinsamer event_series_id
- Übersetzungen in allen 6 Sprachen (de, en, pl, ru, ar, tr)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rhino
2026-03-03 08:38:45 +01:00
parent 0990e4249c
commit 8ccadbe89f
27 changed files with 1968 additions and 698 deletions

View File

@@ -0,0 +1,345 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Setting;
use App\Services\HtmlSanitizerService;
use App\Services\SupportApiService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class AdministrationController extends Controller
{
public function __construct(private HtmlSanitizerService $sanitizer) {}
public function index(): View
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
$allSettings = Setting::all()->keyBy('key');
// Feature-Toggle-Settings
$featureSettings = $allSettings->filter(fn ($s) => str_starts_with($s->key, 'feature_'));
// Visibility-Settings
$visibilitySettings = $allSettings->filter(fn ($s) => str_starts_with($s->key, 'visibility_'));
// Mail-Konfiguration
$mailConfig = [
'mailer' => config('mail.default'),
'host' => config('mail.mailers.smtp.host'),
'port' => config('mail.mailers.smtp.port'),
'username' => config('mail.mailers.smtp.username'),
'password' => config('mail.mailers.smtp.password'),
'encryption' => config('mail.mailers.smtp.scheme', 'tls'),
'from_address' => config('mail.from.address'),
'from_name' => config('mail.from.name'),
];
// Lizenz & Support
$supportService = app(SupportApiService::class);
$isRegistered = $supportService->isRegistered();
$installationId = $isRegistered ? ($supportService->readInstalled()['installation_id'] ?? null) : null;
$updateInfo = Cache::get('support.update_check');
// License-Key
$licenseKey = $allSettings['license_key']->value ?? '';
// Aktivitätslog (letzte 100 Einträge)
$recentLogs = ActivityLog::with('user')->latest('created_at')->limit(100)->get();
return view('admin.administration.index', compact(
'featureSettings', 'visibilitySettings', 'mailConfig',
'isRegistered', 'installationId', 'updateInfo', 'licenseKey',
'recentLogs'
));
}
public function updateFeatures(Request $request): RedirectResponse
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
$inputSettings = $request->input('settings', []);
$oldValues = Setting::whereIn('key', array_keys($inputSettings))->pluck('value', 'key')->toArray();
foreach ($inputSettings as $key => $value) {
// Nur feature_ und visibility_ Keys akzeptieren
if (!str_starts_with($key, 'feature_') && !str_starts_with($key, 'visibility_')) {
continue;
}
$setting = Setting::where('key', $key)->first();
if ($setting) {
$setting->update(['value' => $value]);
} else {
$newSetting = new Setting([
'label' => $key,
'type' => 'number',
'value' => $value,
]);
$newSetting->key = $key;
$newSetting->save();
}
}
Setting::clearCache();
$newValues = Setting::whereIn('key', array_keys($inputSettings))->pluck('value', 'key')->toArray();
ActivityLog::logWithChanges('updated', __('admin.features_saved'), 'Setting', null, $oldValues, $newValues);
return back()->with('success', __('admin.features_saved'));
}
public function updateLicense(Request $request): RedirectResponse
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
$request->validate([
'license_key' => ['nullable', 'string', 'max:255'],
]);
$oldValue = Setting::get('license_key');
$newValue = strip_tags($request->input('license_key', ''));
Setting::set('license_key', $newValue);
if ($newValue && $newValue !== $oldValue) {
$supportService = app(SupportApiService::class);
$result = $supportService->validateLicense($newValue);
if ($result && !($result['valid'] ?? false)) {
session()->flash('warning', __('admin.license_invalid'));
}
}
ActivityLog::logWithChanges('updated', __('admin.log_settings_updated'), 'Setting', null,
['license_key' => $oldValue], ['license_key' => $newValue]);
return back()->with('success', __('admin.settings_saved'));
}
public function updateMail(Request $request): RedirectResponse
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
$mailer = $request->input('mail_mailer', 'log');
if ($mailer === 'smtp') {
$request->validate([
'mail_host' => 'required|string|max:255',
'mail_port' => 'required|integer|min:1|max:65535',
'mail_username' => 'required|string|max:255',
'mail_password' => 'required|string|max:255',
'mail_from_address' => 'required|email|max:255',
'mail_from_name' => 'nullable|string|max:255',
'mail_encryption' => 'required|in:tls,ssl,none',
]);
$encryption = $request->input('mail_encryption');
$this->updateEnvValues([
'MAIL_MAILER' => 'smtp',
'MAIL_HOST' => $request->input('mail_host'),
'MAIL_PORT' => $request->input('mail_port'),
'MAIL_USERNAME' => $request->input('mail_username'),
'MAIL_PASSWORD' => $request->input('mail_password'),
'MAIL_FROM_ADDRESS' => $request->input('mail_from_address'),
'MAIL_FROM_NAME' => $request->input('mail_from_name', config('app.name')),
'MAIL_SCHEME' => $encryption === 'none' ? '' : $encryption,
]);
} else {
$this->updateEnvValues([
'MAIL_MAILER' => 'log',
]);
}
Artisan::call('config:clear');
return back()->with('success', __('admin.mail_saved'))->withFragment('mail');
}
public function testMail(Request $request): \Illuminate\Http\JsonResponse
{
if (!auth()->user()->isAdmin()) {
return response()->json(['success' => false, 'message' => 'Keine Berechtigung.'], 403);
}
$request->validate([
'mail_host' => 'required|string|max:255',
'mail_port' => 'required|integer|min:1|max:65535',
'mail_username' => 'required|string|max:255',
'mail_password' => 'required|string|max:255',
'mail_encryption' => 'required|in:tls,ssl,none',
]);
try {
$encryption = $request->input('mail_encryption');
$tls = ($encryption !== 'none');
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
$request->input('mail_host'),
(int) $request->input('mail_port'),
$tls,
);
$transport->setUsername($request->input('mail_username'));
$transport->setPassword($request->input('mail_password'));
$transport->start();
$transport->stop();
return response()->json(['success' => true, 'message' => __('admin.mail_test_success')]);
} catch (\Throwable $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()]);
}
}
public function destroyDemoData(Request $request): RedirectResponse
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
$request->validate([
'password' => ['required', 'current_password'],
]);
DB::table('activity_logs')->delete();
DB::table('comments')->delete();
DB::table('event_player_stats')->delete();
DB::table('event_carpool_passengers')->delete();
DB::table('event_carpools')->delete();
DB::table('event_participants')->delete();
DB::table('event_catering')->delete();
DB::table('event_timekeepers')->delete();
DB::table('event_faq')->delete();
DB::table('event_file')->delete();
DB::table('events')->delete();
DB::table('parent_player')->delete();
DB::table('players')->delete();
DB::table('team_user')->delete();
DB::table('team_file')->delete();
DB::table('teams')->delete();
DB::table('invitation_players')->delete();
DB::table('invitations')->delete();
DB::table('locations')->delete();
DB::table('faq')->delete();
DB::table('users')->where('id', '!=', auth()->id())->delete();
$files = DB::table('files')->get();
foreach ($files as $file) {
Storage::disk('private')->delete($file->path);
}
DB::table('files')->delete();
$adminAvatar = auth()->user()->profile_picture;
foreach (Storage::disk('public')->files('avatars') as $avatarFile) {
if ($adminAvatar && str_contains($avatarFile, $adminAvatar)) {
continue;
}
Storage::disk('public')->delete($avatarFile);
}
ActivityLog::log('deleted', __('admin.demo_data_deleted'));
return redirect()->route('admin.administration.index')
->with('success', __('admin.demo_data_deleted'));
}
public function factoryReset(Request $request): RedirectResponse
{
if (!auth()->user()->isAdmin()) {
abort(403);
}
$request->validate([
'password' => ['required', 'current_password'],
'confirmation' => ['required', 'in:RESET-BESTÄTIGT'],
]);
Storage::disk('private')->deleteDirectory('files');
Storage::disk('public')->deleteDirectory('avatars');
Storage::disk('public')->deleteDirectory('favicon');
Storage::disk('public')->deleteDirectory('logos');
Storage::disk('public')->deleteDirectory('dsgvo');
$driver = DB::getDriverName();
if ($driver === 'sqlite') {
DB::statement('PRAGMA foreign_keys = OFF;');
} else {
DB::statement('SET FOREIGN_KEY_CHECKS = 0;');
}
$tables = [
'activity_logs', 'comments', 'event_player_stats',
'event_carpool_passengers', 'event_carpools', 'event_participants',
'event_catering', 'event_timekeepers', 'event_faq',
'event_file', 'events', 'parent_player', 'players',
'team_user', 'team_file', 'teams',
'invitation_players', 'invitations', 'locations',
'faq', 'files', 'file_categories', 'settings',
'users', 'sessions', 'cache', 'cache_locks',
];
foreach ($tables as $table) {
DB::table($table)->delete();
}
if ($driver === 'sqlite') {
DB::statement('PRAGMA foreign_keys = ON;');
} else {
DB::statement('SET FOREIGN_KEY_CHECKS = 1;');
}
$installedFile = storage_path('installed');
if (file_exists($installedFile)) {
unlink($installedFile);
}
Artisan::call('cache:clear');
Artisan::call('config:clear');
Artisan::call('view:clear');
Artisan::call('route:clear');
auth()->logout();
request()->session()->invalidate();
request()->session()->regenerateToken();
return redirect()->route('install.requirements');
}
private function updateEnvValues(array $values): void
{
$envPath = base_path('.env');
$envContent = file_get_contents($envPath);
foreach ($values as $key => $value) {
if ($value === '' || $value === null) {
$replacement = "# {$key}=";
} else {
$quotedValue = str_contains($value, ' ') || str_contains($value, '#')
? '"' . str_replace('"', '\\"', $value) . '"'
: $value;
$replacement = "{$key}={$quotedValue}";
}
if (preg_match("/^#?\s*{$key}=.*/m", $envContent)) {
$envContent = preg_replace("/^#?\s*{$key}=.*/m", $replacement, $envContent);
} else {
$envContent .= "\n{$replacement}";
}
}
file_put_contents($envPath, $envContent);
}
}

View File

@@ -5,12 +5,17 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Comment;
use App\Models\Setting;
use Illuminate\Http\RedirectResponse;
class CommentController extends Controller
{
public function softDelete(Comment $comment): RedirectResponse
{
if (!Setting::isFeatureVisibleFor('comments', auth()->user())) {
abort(403);
}
$comment->deleted_at = now();
$comment->deleted_by = auth()->id();
$comment->save();

View File

@@ -22,6 +22,7 @@ use App\Models\Team;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\Services\HtmlSanitizerService;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -105,8 +106,14 @@ class EventController extends Controller
ActivityLog::logWithChanges('created', __('admin.log_event_created', ['title' => $event->title]), 'Event', $event->id, null, ['title' => $event->title, 'team' => $event->team->name ?? '', 'type' => $event->type->value, 'status' => $event->status->value]);
$recurringCount = $this->generateRecurringEvents($event, $request);
$message = $recurringCount > 0
? __('admin.recurrence_created', ['count' => $recurringCount + 1])
: __('admin.event_created');
return redirect()->route('admin.events.index')
->with('success', __('admin.event_created'));
->with('success', $message);
}
public function edit(Event $event): View
@@ -172,8 +179,24 @@ class EventController extends Controller
$newData = ['title' => $event->title, 'team_id' => $event->team_id, 'type' => $event->type->value, 'status' => $event->status->value, 'start_at' => $event->start_at?->toDateTimeString()];
ActivityLog::logWithChanges('updated', __('admin.log_event_updated', ['title' => $event->title]), 'Event', $event->id, $oldData, $newData);
// Serien-Events: Alle folgenden aktualisieren
$updatedFollowing = 0;
if ($request->input('update_following') === '1' && $event->isPartOfSeries()) {
$updatedFollowing = $this->updateFollowingSeriesEvents($event, $request);
}
$recurringCount = $this->generateRecurringEvents($event, $request);
if ($recurringCount > 0) {
$message = __('admin.recurrence_created', ['count' => $recurringCount + 1]);
} elseif ($updatedFollowing > 0) {
$message = __('admin.series_events_updated', ['count' => $updatedFollowing]);
} else {
$message = __('admin.event_updated');
}
return redirect()->route('admin.events.index')
->with('success', __('admin.event_updated'));
->with('success', $message);
}
public function updateParticipant(Request $request, Event $event)
@@ -199,16 +222,34 @@ class EventController extends Controller
return response()->json(['success' => true]);
}
public function destroy(Event $event): RedirectResponse
public function destroy(Request $request, Event $event): RedirectResponse
{
$deletedCount = 1;
// Serien-Events: Alle folgenden auch löschen
if ($request->input('delete_following') === '1' && $event->isPartOfSeries()) {
$followingEvents = $event->followingSeriesEvents()->get();
foreach ($followingEvents as $futureEvent) {
ActivityLog::logWithChanges('deleted', __('admin.log_event_deleted', ['title' => $futureEvent->title]), 'Event', $futureEvent->id, ['title' => $futureEvent->title, 'team' => $futureEvent->team->name ?? ''], null);
$futureEvent->deleted_by = auth()->id();
$futureEvent->save();
$futureEvent->delete();
$deletedCount++;
}
}
ActivityLog::logWithChanges('deleted', __('admin.log_event_deleted', ['title' => $event->title]), 'Event', $event->id, ['title' => $event->title, 'team' => $event->team->name ?? ''], null);
$event->deleted_by = auth()->id();
$event->save();
$event->delete();
$message = $deletedCount > 1
? __('admin.series_events_deleted', ['count' => $deletedCount])
: __('admin.event_deleted');
return redirect()->route('admin.events.index')
->with('success', __('admin.event_deleted'));
->with('success', $message);
}
public function restore(int $id): RedirectResponse
@@ -333,6 +374,10 @@ class EventController extends Controller
'opponent' => ['nullable', 'string', 'max:100'],
'score_home' => ['nullable', 'integer', 'min:0', 'max:99'],
'score_away' => ['nullable', 'integer', 'min:0', 'max:99'],
'recurrence' => ['nullable', 'in:none,daily,weekly,biweekly'],
'recurrence_end_type' => ['nullable', 'in:date,count'],
'recurrence_end_date' => ['nullable', 'date'],
'recurrence_count' => ['nullable', 'integer', 'min:1', 'max:52'],
]);
// Datum und Uhrzeit zusammenführen
@@ -340,6 +385,9 @@ class EventController extends Controller
$validated['end_at'] = null;
unset($validated['start_date'], $validated['start_time']);
// Recurrence-Felder aus validated entfernen (werden separat verarbeitet)
unset($validated['recurrence'], $validated['recurrence_end_type'], $validated['recurrence_end_date'], $validated['recurrence_count']);
return $validated;
}
@@ -514,4 +562,108 @@ class EventController extends Controller
]
);
}
private function updateFollowingSeriesEvents(Event $event, Request $request): int
{
$followingEvents = $event->followingSeriesEvents()->get();
if ($followingEvents->isEmpty()) {
return 0;
}
$newTime = Carbon::parse($event->start_at);
foreach ($followingEvents as $futureEvent) {
$futureEvent->title = $event->title;
$futureEvent->location_name = $event->location_name;
$futureEvent->address_text = $event->address_text;
$futureEvent->location_lat = $event->location_lat;
$futureEvent->location_lng = $event->location_lng;
$futureEvent->description_html = $event->description_html;
$futureEvent->min_players = $event->min_players;
$futureEvent->min_catering = $event->min_catering;
$futureEvent->min_timekeepers = $event->min_timekeepers;
// Uhrzeit anpassen (Datum behalten)
$futureDate = Carbon::parse($futureEvent->start_at);
$futureEvent->start_at = $futureDate->setTime($newTime->hour, $newTime->minute);
$futureEvent->updated_by = auth()->id();
$futureEvent->save();
// Catering/Zeitnehmer-Zuweisungen neu synchen
$this->syncAssignments($futureEvent, $request);
}
return $followingEvents->count();
}
private function generateRecurringEvents(Event $baseEvent, Request $request): int
{
$recurrence = $request->input('recurrence', 'none');
if ($recurrence === 'none' || $baseEvent->type !== EventType::Training) {
return 0;
}
$interval = match ($recurrence) {
'daily' => 1,
'weekly' => 7,
'biweekly' => 14,
default => 0,
};
if ($interval === 0) {
return 0;
}
// Termine berechnen
$dates = [];
$startDate = Carbon::parse($baseEvent->start_at);
$endType = $request->input('recurrence_end_type', 'count');
if ($endType === 'date') {
$endDate = Carbon::parse($request->input('recurrence_end_date'));
$current = $startDate->copy()->addDays($interval);
while ($current->lte($endDate) && count($dates) < 52) {
$dates[] = $current->copy();
$current->addDays($interval);
}
} else {
$count = min((int) $request->input('recurrence_count', 1), 52);
for ($i = 1; $i <= $count; $i++) {
$dates[] = $startDate->copy()->addDays($interval * $i);
}
}
if (empty($dates)) {
return 0;
}
// Series-ID zuweisen
$seriesId = (string) Str::uuid();
$baseEvent->event_series_id = $seriesId;
$baseEvent->save();
// Basis-Dateien zum Verlinken
$fileIds = $baseEvent->files()->pluck('files.id')->toArray();
foreach ($dates as $date) {
$newEvent = $baseEvent->replicate(['id', 'created_at', 'updated_at', 'deleted_at', 'deleted_by']);
$newEvent->start_at = $date;
$newEvent->created_by = auth()->id();
$newEvent->updated_by = auth()->id();
$newEvent->save();
// Teilnehmer erstellen
$this->createParticipantsForTeam($newEvent);
// Catering/Zeitnehmer-Zuweisungen kopieren
$this->syncAssignments($newEvent, $request);
// Dateien verlinken (nur bestehende, keine neuen Uploads)
if (!empty($fileIds)) {
$newEvent->files()->attach($fileIds);
}
}
return count($dates);
}
}

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\File;
use App\Models\FileCategory;
use App\Models\Setting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@@ -16,6 +17,10 @@ class FileController extends Controller
{
public function index(Request $request): View
{
if (!Setting::isFeatureVisibleFor('files', auth()->user())) {
abort(403);
}
$categories = FileCategory::ordered()->withCount('files')->get();
$activeCategory = $request->query('category');
@@ -32,12 +37,20 @@ class FileController extends Controller
public function create(): View
{
if (!Setting::isFeatureVisibleFor('files', auth()->user())) {
abort(403);
}
$categories = FileCategory::active()->ordered()->get();
return view('admin.files.create', compact('categories'));
}
public function store(Request $request): RedirectResponse
{
if (!Setting::isFeatureVisibleFor('files', auth()->user())) {
abort(403);
}
$request->validate([
'file' => ['required', 'file', 'max:10240', 'mimes:pdf,docx,xlsx,jpg,jpeg,png,gif,webp'],
'file_category_id' => ['required', 'exists:file_categories,id'],
@@ -68,6 +81,10 @@ class FileController extends Controller
public function destroy(File $file): RedirectResponse
{
if (!Setting::isFeatureVisibleFor('files', auth()->user())) {
abort(403);
}
// Path-Traversal-Schutz (V15)
if (str_contains($file->stored_name, '..') || str_contains($file->stored_name, '/')) {
abort(403);

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Invitation;
use App\Models\Player;
use App\Models\Setting;
use App\Services\InvitationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -17,6 +18,10 @@ class InvitationController extends Controller
public function index(): View
{
if (!Setting::isFeatureVisibleFor('invitations', auth()->user())) {
abort(403);
}
$invitations = Invitation::with(['creator', 'players.team'])
->latest('created_at')
->paginate(20);
@@ -26,6 +31,10 @@ class InvitationController extends Controller
public function create(): View
{
if (!Setting::isFeatureVisibleFor('invitations', auth()->user())) {
abort(403);
}
$players = Player::with('team')->active()->orderBy('last_name')->get();
return view('admin.invitations.create', compact('players'));
@@ -33,6 +42,10 @@ class InvitationController extends Controller
public function store(Request $request): RedirectResponse
{
if (!Setting::isFeatureVisibleFor('invitations', auth()->user())) {
abort(403);
}
$validated = $request->validate([
'email' => ['nullable', 'email', 'max:255'],
'expires_in_days' => ['required', 'integer', 'min:1', 'max:90'],
@@ -52,6 +65,10 @@ class InvitationController extends Controller
public function destroy(Invitation $invitation): RedirectResponse
{
if (!Setting::isFeatureVisibleFor('invitations', auth()->user())) {
abort(403);
}
if ($invitation->isAccepted()) {
return back()->with('error', __('admin.invitation_already_used'));
}

View File

@@ -7,6 +7,7 @@ use App\Models\ActivityLog;
use App\Models\File;
use App\Models\FileCategory;
use App\Models\Player;
use App\Models\Setting;
use App\Models\Team;
use App\Models\User;
use Barryvdh\DomPDF\Facade\Pdf;
@@ -19,6 +20,10 @@ class ListGeneratorController extends Controller
{
public function create(): View
{
if (!Setting::isFeatureVisibleFor('list_generator', auth()->user())) {
abort(403);
}
$teams = Team::where('is_active', true)->orderBy('name')->get();
return view('admin.list-generator.create', compact('teams'));
@@ -26,6 +31,10 @@ class ListGeneratorController extends Controller
public function store(Request $request): View
{
if (!Setting::isFeatureVisibleFor('list_generator', auth()->user())) {
abort(403);
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'subtitle' => 'nullable|string|max:255',

View File

@@ -8,12 +8,8 @@ use App\Models\FileCategory;
use App\Models\Season;
use App\Models\Setting;
use App\Services\HtmlSanitizerService;
use App\Services\SupportApiService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
@@ -39,12 +35,10 @@ class SettingsController extends Controller
}
}
// Visibility-Settings separieren
$visibilitySettings = $allSettings->filter(fn ($s) => str_starts_with($s->key, 'visibility_'));
$settings = $allSettings->filter(fn ($s) =>
!str_starts_with($s->key, 'default_min_') &&
!str_starts_with($s->key, 'visibility_') &&
!str_starts_with($s->key, 'feature_') &&
!str_starts_with($s->key, 'impressum_html_') &&
!str_starts_with($s->key, 'datenschutz_html_') &&
!str_starts_with($s->key, 'password_reset_email_')
@@ -62,29 +56,11 @@ class SettingsController extends Controller
];
}
// Support-API-Status (nur für Admin-Tab)
$supportService = app(SupportApiService::class);
$isRegistered = $supportService->isRegistered();
$installationId = $isRegistered ? ($supportService->readInstalled()['installation_id'] ?? null) : null;
$updateInfo = Cache::get('support.update_check');
$mailConfig = [
'mailer' => config('mail.default'),
'host' => config('mail.mailers.smtp.host'),
'port' => config('mail.mailers.smtp.port'),
'username' => config('mail.mailers.smtp.username'),
'password' => config('mail.mailers.smtp.password'),
'encryption' => config('mail.mailers.smtp.scheme', 'tls'),
'from_address' => config('mail.from.address'),
'from_name' => config('mail.from.name'),
];
$seasons = Season::orderByDesc('start_date')->get();
return view('admin.settings.edit', compact(
'settings', 'eventDefaults', 'fileCategories', 'visibilitySettings',
'isRegistered', 'installationId', 'updateInfo',
'availableLocales', 'localeSettings', 'mailConfig', 'seasons'
'settings', 'eventDefaults', 'fileCategories',
'availableLocales', 'localeSettings', 'seasons'
));
}
@@ -129,7 +105,7 @@ class SettingsController extends Controller
// Whitelist: Nur erlaubte Setting-Keys akzeptieren
$allowedLocales = ['de', 'en', 'pl', 'ru', 'ar', 'tr'];
$allowedPrefixes = ['default_min_', 'visibility_'];
$allowedPrefixes = ['default_min_'];
$allowedLocaleKeys = [];
foreach ($allowedLocales as $loc) {
$allowedLocaleKeys[] = "impressum_html_{$loc}";
@@ -178,7 +154,7 @@ class SettingsController extends Controller
$localeSetting->save();
}
} elseif ($isAllowedPrefix) {
// Event-Defaults / Visibility: upsert — anlegen wenn nicht vorhanden
// Event-Defaults: upsert — anlegen wenn nicht vorhanden
$prefixSetting = new Setting([
'label' => $key,
'type' => 'number',
@@ -194,240 +170,6 @@ class SettingsController extends Controller
$newValues = Setting::whereIn('key', array_keys($inputSettings))->pluck('value', 'key')->toArray();
ActivityLog::logWithChanges('updated', __('admin.log_settings_updated'), 'Setting', null, $oldValues, $newValues);
// License key validation when changed
$newLicenseKey = $inputSettings['license_key'] ?? null;
$oldLicenseKey = $oldValues['license_key'] ?? null;
if ($newLicenseKey && $newLicenseKey !== $oldLicenseKey) {
$supportService = app(SupportApiService::class);
$result = $supportService->validateLicense($newLicenseKey);
if ($result && !($result['valid'] ?? false)) {
session()->flash('warning', __('admin.license_invalid'));
}
}
return back()->with('success', __('admin.settings_saved'));
}
public function updateMail(Request $request): RedirectResponse
{
if (! auth()->user()->isAdmin()) {
abort(403);
}
$mailer = $request->input('mail_mailer', 'log');
if ($mailer === 'smtp') {
$request->validate([
'mail_host' => 'required|string|max:255',
'mail_port' => 'required|integer|min:1|max:65535',
'mail_username' => 'required|string|max:255',
'mail_password' => 'required|string|max:255',
'mail_from_address' => 'required|email|max:255',
'mail_from_name' => 'nullable|string|max:255',
'mail_encryption' => 'required|in:tls,ssl,none',
]);
$encryption = $request->input('mail_encryption');
$this->updateEnvValues([
'MAIL_MAILER' => 'smtp',
'MAIL_HOST' => $request->input('mail_host'),
'MAIL_PORT' => $request->input('mail_port'),
'MAIL_USERNAME' => $request->input('mail_username'),
'MAIL_PASSWORD' => $request->input('mail_password'),
'MAIL_FROM_ADDRESS' => $request->input('mail_from_address'),
'MAIL_FROM_NAME' => $request->input('mail_from_name', config('app.name')),
'MAIL_SCHEME' => $encryption === 'none' ? '' : $encryption,
]);
} else {
$this->updateEnvValues([
'MAIL_MAILER' => 'log',
]);
}
Artisan::call('config:clear');
return back()->with('success', __('admin.mail_saved'))->withFragment('mail');
}
public function testMail(Request $request): \Illuminate\Http\JsonResponse
{
if (! auth()->user()->isAdmin()) {
return response()->json(['success' => false, 'message' => 'Keine Berechtigung.'], 403);
}
$request->validate([
'mail_host' => 'required|string|max:255',
'mail_port' => 'required|integer|min:1|max:65535',
'mail_username' => 'required|string|max:255',
'mail_password' => 'required|string|max:255',
'mail_encryption' => 'required|in:tls,ssl,none',
]);
try {
$encryption = $request->input('mail_encryption');
$tls = ($encryption !== 'none');
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
$request->input('mail_host'),
(int) $request->input('mail_port'),
$tls,
);
$transport->setUsername($request->input('mail_username'));
$transport->setPassword($request->input('mail_password'));
$transport->start();
$transport->stop();
return response()->json(['success' => true, 'message' => __('admin.mail_test_success')]);
} catch (\Throwable $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()]);
}
}
private function updateEnvValues(array $values): void
{
$envPath = base_path('.env');
$envContent = file_get_contents($envPath);
foreach ($values as $key => $value) {
if ($value === '' || $value === null) {
$replacement = "# {$key}=";
} else {
$quotedValue = str_contains($value, ' ') || str_contains($value, '#')
? '"' . str_replace('"', '\\"', $value) . '"'
: $value;
$replacement = "{$key}={$quotedValue}";
}
if (preg_match("/^#?\s*{$key}=.*/m", $envContent)) {
$envContent = preg_replace("/^#?\s*{$key}=.*/m", $replacement, $envContent);
} else {
$envContent .= "\n{$replacement}";
}
}
file_put_contents($envPath, $envContent);
}
public function destroyDemoData(Request $request): RedirectResponse
{
if (! auth()->user()->isAdmin()) {
abort(403);
}
$request->validate([
'password' => ['required', 'current_password'],
]);
// Löschreihenfolge beachtet FK-Constraints
DB::table('activity_logs')->delete();
DB::table('comments')->delete();
DB::table('event_player_stats')->delete();
DB::table('event_carpool_passengers')->delete();
DB::table('event_carpools')->delete();
DB::table('event_participants')->delete();
DB::table('event_catering')->delete();
DB::table('event_timekeepers')->delete();
DB::table('event_faq')->delete();
DB::table('event_file')->delete();
DB::table('events')->delete();
DB::table('parent_player')->delete();
DB::table('players')->delete();
DB::table('team_user')->delete();
DB::table('team_file')->delete();
DB::table('teams')->delete();
DB::table('invitation_players')->delete();
DB::table('invitations')->delete();
DB::table('locations')->delete();
DB::table('faq')->delete();
DB::table('users')->where('id', '!=', auth()->id())->delete();
// Hochgeladene Dateien aus Storage entfernen + DB-Einträge löschen
$files = DB::table('files')->get();
foreach ($files as $file) {
Storage::disk('private')->delete($file->path);
}
DB::table('files')->delete();
// Profilbilder-Ordner leeren (Admin-Bild bleibt via DB erhalten)
$adminAvatar = auth()->user()->profile_picture;
foreach (Storage::disk('public')->files('avatars') as $avatarFile) {
if ($adminAvatar && str_contains($avatarFile, $adminAvatar)) {
continue;
}
Storage::disk('public')->delete($avatarFile);
}
ActivityLog::log('deleted', __('admin.demo_data_deleted'));
return redirect()->route('admin.settings.edit')
->with('success', __('admin.demo_data_deleted'));
}
public function factoryReset(Request $request): RedirectResponse
{
if (! auth()->user()->isAdmin()) {
abort(403);
}
$request->validate([
'password' => ['required', 'current_password'],
'confirmation' => ['required', 'in:RESET-BESTÄTIGT'],
]);
// 1. Alle hochgeladenen Dateien entfernen
Storage::disk('private')->deleteDirectory('files');
Storage::disk('public')->deleteDirectory('avatars');
Storage::disk('public')->deleteDirectory('favicon');
Storage::disk('public')->deleteDirectory('logos');
Storage::disk('public')->deleteDirectory('dsgvo');
// 2. FK-Constraints deaktivieren (DB-agnostisch)
$driver = DB::getDriverName();
if ($driver === 'sqlite') {
DB::statement('PRAGMA foreign_keys = OFF;');
} else {
DB::statement('SET FOREIGN_KEY_CHECKS = 0;');
}
// 3. Alle Tabellen leeren
$tables = [
'activity_logs', 'comments', 'event_player_stats',
'event_carpool_passengers', 'event_carpools', 'event_participants',
'event_catering', 'event_timekeepers', 'event_faq',
'event_file', 'events', 'parent_player', 'players',
'team_user', 'team_file', 'teams',
'invitation_players', 'invitations', 'locations',
'faq', 'files', 'file_categories', 'settings',
'users', 'sessions', 'cache', 'cache_locks',
];
foreach ($tables as $table) {
DB::table($table)->delete();
}
// 4. FK-Constraints reaktivieren
if ($driver === 'sqlite') {
DB::statement('PRAGMA foreign_keys = ON;');
} else {
DB::statement('SET FOREIGN_KEY_CHECKS = 1;');
}
// 5. storage/installed entfernen → Installer-Modus aktivieren
$installedFile = storage_path('installed');
if (file_exists($installedFile)) {
unlink($installedFile);
}
// 6. Caches leeren
Artisan::call('cache:clear');
Artisan::call('config:clear');
Artisan::call('view:clear');
Artisan::call('route:clear');
// 7. Session invalidieren + Logout
auth()->logout();
request()->session()->invalidate();
request()->session()->regenerateToken();
// 8. Redirect zum Installer
return redirect()->route('install.requirements');
}
}

View File

@@ -24,6 +24,7 @@ class Event extends Model
'start_at',
'end_at',
'status',
'event_series_id',
'location_name',
'address_text',
'location_lat',
@@ -298,4 +299,16 @@ class Event extends Model
{
return $query->where('team_id', $teamId);
}
public function isPartOfSeries(): bool
{
return $this->event_series_id !== null;
}
public function followingSeriesEvents()
{
return static::where('event_series_id', $this->event_series_id)
->where('start_at', '>', $this->start_at)
->orderBy('start_at');
}
}

View File

@@ -30,12 +30,25 @@ class Setting extends Model
}
}
/**
* Prüft ob ein Feature global aktiviert ist (Master-Schalter).
*/
public static function isFeatureEnabled(string $feature): bool
{
return static::get("feature_{$feature}", '1') === '1';
}
/**
* Prüft ob ein Feature für den gegebenen User sichtbar ist.
* Admin sieht immer alles.
* Prüft zuerst den globalen Schalter, dann die Rollen-Sichtbarkeit.
* Admin sieht alles, solange das Feature global aktiviert ist.
*/
public static function isFeatureVisibleFor(string $feature, User $user): bool
{
if (!static::isFeatureEnabled($feature)) {
return false;
}
if ($user->isAdmin()) {
return true;
}

View File

@@ -0,0 +1,81 @@
<?php
use App\Models\Setting;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
// Globale Feature-Toggles (Master-Schalter)
$featureToggles = [
['key' => 'feature_statistics', 'label' => 'Feature: Statistiken', 'type' => 'number', 'value' => '1'],
['key' => 'feature_finances', 'label' => 'Feature: Finanzen', 'type' => 'number', 'value' => '1'],
['key' => 'feature_catering', 'label' => 'Feature: Catering', 'type' => 'number', 'value' => '1'],
['key' => 'feature_timekeepers', 'label' => 'Feature: Zeitnehmer', 'type' => 'number', 'value' => '1'],
['key' => 'feature_carpools', 'label' => 'Feature: Fahrgemeinschaften', 'type' => 'number', 'value' => '1'],
['key' => 'feature_comments', 'label' => 'Feature: Kommentare', 'type' => 'number', 'value' => '1'],
['key' => 'feature_files', 'label' => 'Feature: Dateien', 'type' => 'number', 'value' => '1'],
['key' => 'feature_faqs', 'label' => 'Feature: FAQs', 'type' => 'number', 'value' => '1'],
['key' => 'feature_list_generator', 'label' => 'Feature: Listenerstellung', 'type' => 'number', 'value' => '1'],
['key' => 'feature_invitations', 'label' => 'Feature: Einladungen', 'type' => 'number', 'value' => '1'],
['key' => 'feature_player_stats', 'label' => 'Feature: Spielerstatistiken', 'type' => 'number', 'value' => '1'],
];
foreach ($featureToggles as $toggle) {
if (!Setting::where('key', $toggle['key'])->exists()) {
$setting = new Setting([
'label' => $toggle['label'],
'type' => $toggle['type'],
'value' => $toggle['value'],
]);
$setting->key = $toggle['key'];
$setting->save();
}
}
// Neue pro-Rolle Visibility-Settings (zusätzlich zu bestehenden)
$newVisibility = [
'catering', 'timekeepers', 'carpools', 'comments',
'files', 'faqs', 'list_generator', 'invitations', 'player_stats',
];
foreach ($newVisibility as $feature) {
foreach (['coach', 'parent_rep'] as $role) {
$key = "visibility_{$feature}_{$role}";
if (!Setting::where('key', $key)->exists()) {
$setting = new Setting([
'label' => ucfirst(str_replace('_', ' ', $feature)) . ': ' . ucfirst(str_replace('_', ' ', $role)),
'type' => 'number',
'value' => '1',
]);
$setting->key = $key;
$setting->save();
}
}
}
}
public function down(): void
{
$featureKeys = [
'feature_statistics', 'feature_finances', 'feature_catering',
'feature_timekeepers', 'feature_carpools', 'feature_comments',
'feature_files', 'feature_faqs', 'feature_list_generator',
'feature_invitations', 'feature_player_stats',
];
Setting::whereIn('key', $featureKeys)->delete();
$newVisibility = [
'catering', 'timekeepers', 'carpools', 'comments',
'files', 'faqs', 'list_generator', 'invitations', 'player_stats',
];
foreach ($newVisibility as $feature) {
foreach (['coach', 'parent_rep'] as $role) {
Setting::where('key', "visibility_{$feature}_{$role}")->delete();
}
}
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
$table->string('event_series_id', 36)->nullable()->after('status')->index();
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropIndex(['event_series_id']);
$table->dropColumn('event_series_id');
});
}
};

View File

@@ -29,6 +29,7 @@ use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class DemoDataSeeder extends Seeder
{
@@ -324,7 +325,8 @@ class DemoDataSeeder extends Seeder
]
);
// 1: Training (Zukunft)
// 1: Training (Zukunft) — Teil einer Trainingsserie
$trainingSeriesId = (string) Str::uuid();
$events[] = Event::updateOrCreate(
['title' => 'Training nächste Woche', 'team_id' => $team->id, 'start_at' => now()->next('Tuesday')->setTime(17, 0)],
[
@@ -338,6 +340,7 @@ class DemoDataSeeder extends Seeder
'min_players' => 12,
'min_catering' => 1,
'min_timekeepers' => 1,
'event_series_id' => $trainingSeriesId,
'description_html' => '<p>Training mit Schwerpunkt Passspiel. Bitte pünktlich kommen!</p>',
'created_by' => $admin->id,
]
@@ -571,6 +574,29 @@ class DemoDataSeeder extends Seeder
]
);
// Serien-Folgetermine: 3 weitere Trainings (gleiche event_series_id wie Event 1)
for ($i = 1; $i <= 3; $i++) {
$tuesday = now()->next('Tuesday')->addWeeks($i);
Event::updateOrCreate(
['title' => 'Training Dienstag (Serie)', 'team_id' => $team->id, 'start_at' => $tuesday->copy()->setTime(17, 0)],
[
'type' => EventType::Training,
'end_at' => $tuesday->copy()->setTime(18, 30),
'status' => EventStatus::Published,
'location_name' => $locations[0]->name,
'address_text' => $locations[0]->address_text,
'location_lat' => $locations[0]->location_lat,
'location_lng' => $locations[0]->location_lng,
'min_players' => 12,
'min_catering' => 1,
'min_timekeepers' => 1,
'event_series_id' => $trainingSeriesId,
'description_html' => '<p>Wöchentliches Training. Bitte <strong>Hallenschuhe</strong> und ausreichend Trinken mitbringen.</p>',
'created_by' => $admin->id,
]
);
}
return $events;
}

View File

@@ -223,6 +223,30 @@ HTML;
]);
}
// Globale Feature-Toggles (Master-Schalter)
$featureToggles = [
['key' => 'feature_statistics', 'label' => 'Feature: Statistiken'],
['key' => 'feature_finances', 'label' => 'Feature: Finanzen'],
['key' => 'feature_catering', 'label' => 'Feature: Catering'],
['key' => 'feature_timekeepers', 'label' => 'Feature: Zeitnehmer'],
['key' => 'feature_carpools', 'label' => 'Feature: Fahrgemeinschaften'],
['key' => 'feature_comments', 'label' => 'Feature: Kommentare'],
['key' => 'feature_files', 'label' => 'Feature: Dateien'],
['key' => 'feature_faqs', 'label' => 'Feature: FAQs'],
['key' => 'feature_list_generator', 'label' => 'Feature: Listenerstellung'],
['key' => 'feature_invitations', 'label' => 'Feature: Einladungen'],
['key' => 'feature_player_stats', 'label' => 'Feature: Spielerstatistiken'],
];
foreach ($featureToggles as $toggle) {
$existing = Setting::where('key', $toggle['key'])->first();
if ($existing) {
$existing->update(['label' => $toggle['label'], 'type' => 'number']);
} else {
$this->createSetting(array_merge($toggle, ['type' => 'number', 'value' => '1']));
}
}
// Sichtbarkeits-Einstellungen (pro Feature pro Rolle)
$visibilitySettings = [
['key' => 'visibility_statistics_coach', 'label' => 'Statistik: Trainer', 'type' => 'number', 'value' => '1'],
@@ -231,6 +255,24 @@ HTML;
['key' => 'visibility_finances_parent_rep', 'label' => 'Finanzen: Elternvertretung', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_catering_history_coach', 'label' => 'Catering-Verlauf: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_catering_history_parent_rep', 'label' => 'Catering-Verlauf: Elternvertretung', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_catering_coach', 'label' => 'Catering: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_catering_parent_rep', 'label' => 'Catering: Elternvertretung', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_timekeepers_coach', 'label' => 'Zeitnehmer: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_timekeepers_parent_rep', 'label' => 'Zeitnehmer: Elternvertretung', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_carpools_coach', 'label' => 'Fahrgemeinschaften: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_carpools_parent_rep', 'label' => 'Fahrgemeinschaften: Elternvertretung', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_comments_coach', 'label' => 'Kommentare: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_comments_parent_rep', 'label' => 'Kommentare: Elternvertretung', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_files_coach', 'label' => 'Dateien: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_files_parent_rep', 'label' => 'Dateien: Elternvertretung', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_faqs_coach', 'label' => 'FAQs: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_faqs_parent_rep', 'label' => 'FAQs: Elternvertretung', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_list_generator_coach', 'label' => 'Listenerstellung: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_list_generator_parent_rep', 'label' => 'Listenerstellung: Elternvertretung', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_invitations_coach', 'label' => 'Einladungen: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_invitations_parent_rep', 'label' => 'Einladungen: Elternvertretung', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_player_stats_coach', 'label' => 'Spielerstatistiken: Trainer', 'type' => 'number', 'value' => '1'],
['key' => 'visibility_player_stats_parent_rep', 'label' => 'Spielerstatistiken: Elternvertretung', 'type' => 'number', 'value' => '1'],
];
foreach ($visibilitySettings as $setting) {

View File

@@ -627,4 +627,57 @@ return [
// الرؤية
'visibility_feature_finances' => 'المالية',
// Administration
'nav_administration' => 'الإدارة',
'admin_title' => 'الإدارة',
'admin_tab_features' => 'إدارة الأدوار',
'admin_tab_mail' => 'البريد الإلكتروني',
'admin_tab_license' => 'الترخيص والدعم',
'admin_tab_maintenance' => 'الصيانة',
'admin_tab_activity' => 'سجل النشاط',
'features_description' => 'تفعيل أو تعطيل الميزات بشكل عام والتحكم في الرؤية لكل دور. الوظائف الأساسية (الأحداث، الفرق، اللاعبون، المستخدمون) نشطة دائمًا.',
'feature_enabled' => 'مفعّل',
'feature_disabled' => 'معطّل',
'feature_statistics' => 'الإحصائيات',
'feature_finances' => 'المالية',
'feature_catering' => 'التموين',
'feature_timekeepers' => 'ضبط الوقت',
'feature_carpools' => 'مشاركة السيارات',
'feature_comments' => 'التعليقات',
'feature_files' => 'الملفات',
'feature_faqs' => 'الأسئلة الشائعة',
'feature_list_generator' => 'مولد القوائم',
'feature_invitations' => 'الدعوات',
'feature_player_stats' => 'إحصائيات اللاعبين',
'features_saved' => 'تم حفظ إدارة الأدوار.',
'activity_recent' => 'الأنشطة الأخيرة',
// التكرار
'recurrence' => 'التكرار',
'recurrence_none' => 'بدون تكرار',
'recurrence_daily' => 'يومياً',
'recurrence_weekly' => 'أسبوعياً',
'recurrence_biweekly' => 'كل أسبوعين',
'recurrence_end_type' => 'نهاية التكرار',
'recurrence_end_date' => 'حتى تاريخ',
'recurrence_end_count' => 'عدد التكرارات',
'recurrence_count_label' => 'العدد (بدون الحدث الأول)',
'recurrence_preview' => 'سيتم إنشاء :count أحداث إضافية',
'recurrence_max_warning' => 'الحد الأقصى :max حدث',
'recurrence_created' => 'تم إنشاء :count أحداث تدريبية.',
// سلسلة الأحداث
'save_following' => 'تحديث جميع الأحداث التالية',
'series_hint' => 'هذا الحدث جزء من سلسلة. هناك :count أحداث قادمة أخرى.',
'save_series_title' => 'حفظ التغييرات',
'save_series_description' => 'هذا الحدث جزء من سلسلة تحتوي على :count مواعيد قادمة أخرى. هل تريد تطبيق التغييرات على الأحداث التالية أيضًا؟',
'save_only_this' => 'حفظ هذا الحدث فقط',
'save_this_and_following' => 'تحديث هذا وجميع الأحداث التالية',
'delete_series_title' => 'حذف سلسلة الأحداث',
'delete_series_description' => 'هذا الحدث ينتمي إلى سلسلة متكررة. ماذا تريد حذفه؟',
'delete_only_this' => 'حذف هذا الحدث فقط',
'delete_this_and_following' => 'حذف هذا وجميع الأحداث التالية',
'series_events_deleted' => 'تم حذف :count أحداث.',
'series_events_updated' => 'تم تحديث هذا الحدث و :count أحداث تالية.',
];

View File

@@ -663,4 +663,57 @@ return [
// Sichtbarkeit
'visibility_feature_finances' => 'Finanzen',
// Administration
'nav_administration' => 'Administration',
'admin_title' => 'Administration',
'admin_tab_features' => 'Rollenmanagement',
'admin_tab_mail' => 'E-Mail',
'admin_tab_license' => 'Lizenz & Support',
'admin_tab_maintenance' => 'Wartung',
'admin_tab_activity' => 'Aktivitätslog',
'features_description' => 'Aktiviere oder deaktiviere Funktionen global und steuere die Sichtbarkeit pro Rolle. Basis-Funktionen (Events, Teams, Spieler, Benutzer) sind immer aktiv.',
'feature_enabled' => 'Aktiviert',
'feature_disabled' => 'Deaktiviert',
'feature_statistics' => 'Statistiken',
'feature_finances' => 'Finanzen',
'feature_catering' => 'Catering',
'feature_timekeepers' => 'Zeitnehmer',
'feature_carpools' => 'Fahrgemeinschaften',
'feature_comments' => 'Kommentare',
'feature_files' => 'Dateien',
'feature_faqs' => 'FAQs',
'feature_list_generator' => 'Listenerstellung',
'feature_invitations' => 'Einladungen',
'feature_player_stats' => 'Spielerstatistiken',
'features_saved' => 'Rollenmanagement gespeichert.',
'activity_recent' => 'Letzte Aktivitäten',
// Wiederholung
'recurrence' => 'Wiederholung',
'recurrence_none' => 'Keine Wiederholung',
'recurrence_daily' => 'Täglich',
'recurrence_weekly' => 'Wöchentlich',
'recurrence_biweekly' => 'Alle 2 Wochen',
'recurrence_end_type' => 'Ende der Wiederholung',
'recurrence_end_date' => 'Bis Datum',
'recurrence_end_count' => 'Anzahl Wiederholungen',
'recurrence_count_label' => 'Anzahl (ohne erstes Event)',
'recurrence_preview' => ':count weitere Events werden erstellt',
'recurrence_max_warning' => 'Maximal :max Events möglich',
'recurrence_created' => ':count Trainings-Events erstellt.',
// Event-Serien
'save_following' => 'Alle folgenden aktualisieren',
'series_hint' => 'Dieses Event ist Teil einer Serie. Es gibt :count weitere Folgetermine.',
'save_series_title' => 'Änderungen speichern',
'save_series_description' => 'Dieses Event gehört zu einer Serie mit :count weiteren Folgeterminen. Sollen die Änderungen auch auf die Folgetermine angewendet werden?',
'save_only_this' => 'Nur dieses Event speichern',
'save_this_and_following' => 'Dieses und alle folgenden Events aktualisieren',
'delete_series_title' => 'Event-Serie löschen',
'delete_series_description' => 'Dieses Event gehört zu einer wiederkehrenden Serie. Was möchten Sie löschen?',
'delete_only_this' => 'Nur dieses Event löschen',
'delete_this_and_following' => 'Dieses und alle folgenden Events löschen',
'series_events_deleted' => ':count Events gelöscht.',
'series_events_updated' => 'Dieses Event und :count Folgetermine aktualisiert.',
];

View File

@@ -626,4 +626,57 @@ return [
// Visibility
'visibility_feature_finances' => 'Finances',
// Administration
'nav_administration' => 'Administration',
'admin_title' => 'Administration',
'admin_tab_features' => 'Role Management',
'admin_tab_mail' => 'Email',
'admin_tab_license' => 'License & Support',
'admin_tab_maintenance' => 'Maintenance',
'admin_tab_activity' => 'Activity Log',
'features_description' => 'Enable or disable features globally and control visibility per role. Base features (Events, Teams, Players, Users) are always active.',
'feature_enabled' => 'Enabled',
'feature_disabled' => 'Disabled',
'feature_statistics' => 'Statistics',
'feature_finances' => 'Finances',
'feature_catering' => 'Catering',
'feature_timekeepers' => 'Timekeepers',
'feature_carpools' => 'Carpools',
'feature_comments' => 'Comments',
'feature_files' => 'Files',
'feature_faqs' => 'FAQs',
'feature_list_generator' => 'List Generator',
'feature_invitations' => 'Invitations',
'feature_player_stats' => 'Player Statistics',
'features_saved' => 'Role management saved.',
'activity_recent' => 'Recent Activities',
// Recurrence
'recurrence' => 'Recurrence',
'recurrence_none' => 'No recurrence',
'recurrence_daily' => 'Daily',
'recurrence_weekly' => 'Weekly',
'recurrence_biweekly' => 'Every 2 weeks',
'recurrence_end_type' => 'End of recurrence',
'recurrence_end_date' => 'Until date',
'recurrence_end_count' => 'Number of repetitions',
'recurrence_count_label' => 'Count (excluding first event)',
'recurrence_preview' => ':count additional events will be created',
'recurrence_max_warning' => 'Maximum :max events possible',
'recurrence_created' => ':count training events created.',
// Event Series
'save_following' => 'Update all following',
'series_hint' => 'This event is part of a series. There are :count more upcoming events.',
'save_series_title' => 'Save changes',
'save_series_description' => 'This event is part of a series with :count more upcoming events. Should the changes also be applied to the following events?',
'save_only_this' => 'Save only this event',
'save_this_and_following' => 'Update this and all following events',
'delete_series_title' => 'Delete event series',
'delete_series_description' => 'This event belongs to a recurring series. What would you like to delete?',
'delete_only_this' => 'Delete only this event',
'delete_this_and_following' => 'Delete this and all following events',
'series_events_deleted' => ':count events deleted.',
'series_events_updated' => 'This event and :count following events updated.',
];

View File

@@ -627,4 +627,57 @@ return [
// Widoczność
'visibility_feature_finances' => 'Finanse',
// Administration
'nav_administration' => 'Administracja',
'admin_title' => 'Administracja',
'admin_tab_features' => 'Zarządzanie rolami',
'admin_tab_mail' => 'E-mail',
'admin_tab_license' => 'Licencja i wsparcie',
'admin_tab_maintenance' => 'Konserwacja',
'admin_tab_activity' => 'Dziennik aktywności',
'features_description' => 'Włącz lub wyłącz funkcje globalnie i kontroluj widoczność dla poszczególnych ról. Funkcje podstawowe (wydarzenia, zespoły, zawodnicy, użytkownicy) są zawsze aktywne.',
'feature_enabled' => 'Włączony',
'feature_disabled' => 'Wyłączony',
'feature_statistics' => 'Statystyki',
'feature_finances' => 'Finanse',
'feature_catering' => 'Catering',
'feature_timekeepers' => 'Chronometrażyści',
'feature_carpools' => 'Wspólne przejazdy',
'feature_comments' => 'Komentarze',
'feature_files' => 'Pliki',
'feature_faqs' => 'FAQ',
'feature_list_generator' => 'Generator list',
'feature_invitations' => 'Zaproszenia',
'feature_player_stats' => 'Statystyki zawodników',
'features_saved' => 'Zarządzanie rolami zapisane.',
'activity_recent' => 'Ostatnie aktywności',
// Powtarzalność
'recurrence' => 'Powtarzalność',
'recurrence_none' => 'Bez powtarzania',
'recurrence_daily' => 'Codziennie',
'recurrence_weekly' => 'Co tydzień',
'recurrence_biweekly' => 'Co 2 tygodnie',
'recurrence_end_type' => 'Koniec powtarzania',
'recurrence_end_date' => 'Do daty',
'recurrence_end_count' => 'Liczba powtórzeń',
'recurrence_count_label' => 'Ilość (bez pierwszego wydarzenia)',
'recurrence_preview' => 'Zostanie utworzonych :count dodatkowych wydarzeń',
'recurrence_max_warning' => 'Maksymalnie :max wydarzeń',
'recurrence_created' => 'Utworzono :count wydarzeń treningowych.',
// Serie wydarzeń
'save_following' => 'Zaktualizuj wszystkie następne',
'series_hint' => 'To wydarzenie jest częścią serii. Istnieje :count kolejnych terminów.',
'save_series_title' => 'Zapisz zmiany',
'save_series_description' => 'To wydarzenie jest częścią serii z :count kolejnymi terminami. Czy zmiany mają być zastosowane również do następnych wydarzeń?',
'save_only_this' => 'Zapisz tylko to wydarzenie',
'save_this_and_following' => 'Zaktualizuj to i wszystkie następne wydarzenia',
'delete_series_title' => 'Usuń serię wydarzeń',
'delete_series_description' => 'To wydarzenie należy do powtarzającej się serii. Co chcesz usunąć?',
'delete_only_this' => 'Usuń tylko to wydarzenie',
'delete_this_and_following' => 'Usuń to i wszystkie następne wydarzenia',
'series_events_deleted' => ':count wydarzeń usunięto.',
'series_events_updated' => 'To wydarzenie i :count następnych zaktualizowano.',
];

View File

@@ -645,4 +645,57 @@ return [
// Видимость
'visibility_feature_finances' => 'Финансы',
// Administration
'nav_administration' => 'Администрирование',
'admin_title' => 'Администрирование',
'admin_tab_features' => 'Управление ролями',
'admin_tab_mail' => 'Эл. почта',
'admin_tab_license' => 'Лицензия и поддержка',
'admin_tab_maintenance' => 'Обслуживание',
'admin_tab_activity' => 'Журнал активности',
'features_description' => 'Включайте или отключайте функции глобально и управляйте видимостью по ролям. Базовые функции (события, команды, игроки, пользователи) всегда активны.',
'feature_enabled' => 'Включено',
'feature_disabled' => 'Отключено',
'feature_statistics' => 'Статистика',
'feature_finances' => 'Финансы',
'feature_catering' => 'Кейтеринг',
'feature_timekeepers' => 'Хронометристы',
'feature_carpools' => 'Совместные поездки',
'feature_comments' => 'Комментарии',
'feature_files' => 'Файлы',
'feature_faqs' => 'FAQ',
'feature_list_generator' => 'Генератор списков',
'feature_invitations' => 'Приглашения',
'feature_player_stats' => 'Статистика игроков',
'features_saved' => 'Управление ролями сохранено.',
'activity_recent' => 'Последние действия',
// Повторение
'recurrence' => 'Повторение',
'recurrence_none' => 'Без повторения',
'recurrence_daily' => 'Ежедневно',
'recurrence_weekly' => 'Еженедельно',
'recurrence_biweekly' => 'Каждые 2 недели',
'recurrence_end_type' => 'Конец повторения',
'recurrence_end_date' => 'До даты',
'recurrence_end_count' => 'Количество повторений',
'recurrence_count_label' => 'Количество (без первого события)',
'recurrence_preview' => 'Будет создано :count дополнительных событий',
'recurrence_max_warning' => 'Максимум :max событий',
'recurrence_created' => 'Создано :count тренировочных событий.',
// Серии событий
'save_following' => 'Обновить все последующие',
'series_hint' => 'Это событие является частью серии. Есть ещё :count предстоящих событий.',
'save_series_title' => 'Сохранить изменения',
'save_series_description' => 'Это событие является частью серии с ещё :count предстоящими событиями. Применить изменения также к следующим событиям?',
'save_only_this' => 'Сохранить только это событие',
'save_this_and_following' => 'Обновить это и все следующие события',
'delete_series_title' => 'Удалить серию событий',
'delete_series_description' => 'Это событие принадлежит повторяющейся серии. Что вы хотите удалить?',
'delete_only_this' => 'Удалить только это событие',
'delete_this_and_following' => 'Удалить это и все последующие события',
'series_events_deleted' => ':count событий удалено.',
'series_events_updated' => 'Это событие и :count последующих обновлены.',
];

View File

@@ -645,4 +645,57 @@ return [
// Görünürlük
'visibility_feature_finances' => 'Finans',
// Administration
'nav_administration' => 'Yönetim',
'admin_title' => 'Yönetim',
'admin_tab_features' => 'Rol Yönetimi',
'admin_tab_mail' => 'E-posta',
'admin_tab_license' => 'Lisans ve Destek',
'admin_tab_maintenance' => 'Bakım',
'admin_tab_activity' => 'Etkinlik Günlüğü',
'features_description' => 'Özellikleri genel olarak etkinleştirin veya devre dışı bırakın ve rol bazında görünürlüğü kontrol edin. Temel özellikler (etkinlikler, takımlar, oyuncular, kullanıcılar) her zaman aktiftir.',
'feature_enabled' => 'Etkin',
'feature_disabled' => 'Devre dışı',
'feature_statistics' => 'İstatistikler',
'feature_finances' => 'Finans',
'feature_catering' => 'İkram',
'feature_timekeepers' => 'Zaman Tutucular',
'feature_carpools' => 'Araç Paylaşımı',
'feature_comments' => 'Yorumlar',
'feature_files' => 'Dosyalar',
'feature_faqs' => 'SSS',
'feature_list_generator' => 'Liste Oluşturucu',
'feature_invitations' => 'Davetler',
'feature_player_stats' => 'Oyuncu İstatistikleri',
'features_saved' => 'Rol yönetimi kaydedildi.',
'activity_recent' => 'Son Etkinlikler',
// Tekrarlama
'recurrence' => 'Tekrarlama',
'recurrence_none' => 'Tekrarlama yok',
'recurrence_daily' => 'Günlük',
'recurrence_weekly' => 'Haftalık',
'recurrence_biweekly' => 'Her 2 haftada bir',
'recurrence_end_type' => 'Tekrarlama sonu',
'recurrence_end_date' => 'Tarihe kadar',
'recurrence_end_count' => 'Tekrar sayısı',
'recurrence_count_label' => 'Sayı (ilk etkinlik hariç)',
'recurrence_preview' => ':count ek etkinlik oluşturulacak',
'recurrence_max_warning' => 'Maksimum :max etkinlik',
'recurrence_created' => ':count antrenman etkinliği oluşturuldu.',
// Etkinlik Serileri
'save_following' => 'Tüm sonrakileri güncelle',
'series_hint' => 'Bu etkinlik bir serinin parçası. :count adet daha gelecek etkinlik var.',
'save_series_title' => 'Değişiklikleri kaydet',
'save_series_description' => 'Bu etkinlik, :count gelecek etkinliği olan bir serinin parçasıdır. Değişiklikler sonraki etkinliklere de uygulanmalı mı?',
'save_only_this' => 'Yalnızca bu etkinliği kaydet',
'save_this_and_following' => 'Bu ve sonraki tüm etkinlikleri güncelle',
'delete_series_title' => 'Etkinlik serisini sil',
'delete_series_description' => 'Bu etkinlik tekrarlayan bir seriye ait. Ne silmek istiyorsunuz?',
'delete_only_this' => 'Sadece bu etkinliği sil',
'delete_this_and_following' => 'Bu ve sonraki tüm etkinlikleri sil',
'series_events_deleted' => ':count etkinlik silindi.',
'series_events_updated' => 'Bu etkinlik ve :count sonraki etkinlik güncellendi.',
];

View File

@@ -0,0 +1,558 @@
<x-layouts.admin :title="__('admin.admin_title')">
<div x-data="administrationPage()" x-init="init()">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.admin_title') }}</h1>
{{-- Tab Navigation --}}
<div class="border-b border-gray-200 mb-6 -mx-4 px-4 overflow-x-auto">
<nav class="flex -mb-px gap-1" role="tablist">
<button type="button" @click="tab = 'features'"
:class="tab === 'features' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
{{ __('admin.admin_tab_features') }}
</button>
<button type="button" @click="tab = 'mail'"
:class="tab === 'mail' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
{{ __('admin.admin_tab_mail') }}
</button>
<button type="button" @click="tab = 'license'"
:class="tab === 'license' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
{{ __('admin.admin_tab_license') }}
</button>
<button type="button" @click="tab = 'maintenance'"
:class="tab === 'maintenance' ? 'border-red-500 text-red-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
{{ __('admin.admin_tab_maintenance') }}
</button>
<button type="button" @click="tab = 'activity'"
:class="tab === 'activity' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
{{ __('admin.admin_tab_activity') }}
</button>
</nav>
</div>
{{-- ============================================================ --}}
{{-- Tab: Rollenmanagement --}}
{{-- ============================================================ --}}
<div x-show="tab === 'features'" role="tabpanel">
<form method="POST" action="{{ route('admin.administration.update-features') }}">
@csrf
@method('PUT')
<div class="bg-white rounded-lg shadow p-6 mb-6">
<p class="text-sm text-gray-500 mb-5">{{ __('admin.features_description') }}</p>
@php
$features = [
'statistics' => __('admin.feature_statistics'),
'finances' => __('admin.feature_finances'),
'catering' => __('admin.feature_catering'),
'timekeepers' => __('admin.feature_timekeepers'),
'carpools' => __('admin.feature_carpools'),
'comments' => __('admin.feature_comments'),
'files' => __('admin.feature_files'),
'faqs' => __('admin.feature_faqs'),
'list_generator' => __('admin.feature_list_generator'),
'invitations' => __('admin.feature_invitations'),
'player_stats' => __('admin.feature_player_stats'),
];
$roles = [
'coach' => __('ui.enums.user_role.coach'),
'parent_rep' => __('ui.enums.user_role.parent_rep'),
];
@endphp
<div class="space-y-4">
@foreach ($features as $featureKey => $featureLabel)
@php
$globalKey = "feature_{$featureKey}";
$globalValue = $featureSettings[$globalKey]->value ?? '1';
@endphp
<div class="border border-gray-200 rounded-md p-4" x-data="{ enabled: {{ $globalValue === '1' ? 'true' : 'false' }} }">
{{-- Global Toggle --}}
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700">{{ $featureLabel }}</h3>
<label class="flex items-center gap-2 cursor-pointer">
<span class="text-xs font-medium" :class="enabled ? 'text-green-600' : 'text-gray-400'" x-text="enabled ? '{{ __("admin.feature_enabled") }}' : '{{ __("admin.feature_disabled") }}'"></span>
<input type="hidden" name="settings[{{ $globalKey }}]" :value="enabled ? '1' : '0'">
<button type="button" @click="enabled = !enabled"
:class="enabled ? 'bg-blue-600' : 'bg-gray-300'"
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<span :class="enabled ? 'translate-x-5' : 'translate-x-0'"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out mt-0.5 ml-0.5 rtl:ml-0 rtl:mr-0.5"></span>
</button>
</label>
</div>
{{-- Pro-Rolle Toggles --}}
<div class="flex flex-wrap gap-6 pl-4 rtl:pl-0 rtl:pr-4 border-l-2 rtl:border-l-0 rtl:border-r-2 border-gray-200"
:class="{ 'opacity-40 pointer-events-none': !enabled }">
@foreach ($roles as $roleKey => $roleLabel)
@php
$settingKey = "visibility_{$featureKey}_{$roleKey}";
$currentValue = $visibilitySettings[$settingKey]->value ?? '1';
@endphp
<label class="flex items-center gap-3 cursor-pointer" x-data="{ on: {{ $currentValue === '1' ? 'true' : 'false' }} }">
<input type="hidden" name="settings[{{ $settingKey }}]" :value="on ? '1' : '0'">
<button type="button" @click="on = !on"
:class="on ? 'bg-blue-600' : 'bg-gray-300'"
class="relative inline-flex h-5 w-9 flex-shrink-0 rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">
<span :class="on ? 'translate-x-4' : 'translate-x-0'"
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out mt-0.5 ml-0.5 rtl:ml-0 rtl:mr-0.5"></span>
</button>
<span class="text-sm text-gray-700">{{ $roleLabel }}</span>
</label>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
<div class="flex gap-3">
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 font-medium">
{{ __('ui.save') }}
</button>
<a href="{{ route('admin.dashboard') }}" class="bg-gray-200 text-gray-700 px-6 py-2 rounded-md hover:bg-gray-300">
{{ __('ui.cancel') }}
</a>
</div>
</form>
</div>
{{-- ============================================================ --}}
{{-- Tab: E-Mail --}}
{{-- ============================================================ --}}
<div x-show="tab === 'mail'" role="tabpanel">
<form method="POST" action="{{ route('admin.administration.update-mail') }}"
x-data="{
mailMailer: @js($mailConfig['mailer'] ?? 'log'),
mailTesting: false,
mailTestResult: false,
mailTestSuccess: false,
mailTestMessage: '',
async testSmtp() {
this.mailTesting = true;
this.mailTestResult = false;
try {
const res = await fetch('{{ route("admin.administration.test-mail") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
body: JSON.stringify({
mail_host: this.$refs.mailHost.value,
mail_port: this.$refs.mailPort.value,
mail_username: this.$refs.mailUsername.value,
mail_password: this.$refs.mailPassword.value,
mail_encryption: this.$refs.mailEncryption.value,
}),
});
const data = await res.json();
this.mailTestSuccess = data.success;
this.mailTestMessage = data.message;
} catch (e) {
this.mailTestSuccess = false;
this.mailTestMessage = 'Netzwerkfehler: ' + e.message;
}
this.mailTesting = false;
this.mailTestResult = true;
}
}">
@csrf
@method('PUT')
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h3 class="text-base font-semibold text-gray-800 mb-1">{{ __('admin.mail_config_title') }}</h3>
<p class="text-sm text-gray-500 mb-5">{{ __('admin.mail_config_hint') }}</p>
{{-- Versandmethode --}}
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.mail_mailer_label') }}</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mail_mailer" value="smtp" x-model="mailMailer"
class="text-blue-600 focus:ring-blue-500">
<span class="text-sm">SMTP</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mail_mailer" value="log" x-model="mailMailer"
class="text-blue-600 focus:ring-blue-500">
<span class="text-sm">{{ __('admin.mail_log_mode') }}</span>
</label>
</div>
</div>
{{-- SMTP-Felder --}}
<div x-show="mailMailer === 'smtp'" x-cloak class="space-y-4 p-4 bg-gray-50 border border-gray-200 rounded-md">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_host_label') }}</label>
<input type="text" name="mail_host" x-ref="mailHost"
value="{{ $mailConfig['host'] }}"
placeholder="z.B. smtp.strato.de"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('mail_host') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_port_label') }}</label>
<input type="number" name="mail_port" x-ref="mailPort"
value="{{ $mailConfig['port'] }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('mail_port') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_username_label') }}</label>
<input type="text" name="mail_username" x-ref="mailUsername"
value="{{ $mailConfig['username'] }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('mail_username') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_password_label') }}</label>
<input type="password" name="mail_password" x-ref="mailPassword"
value="{{ $mailConfig['password'] }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('mail_password') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_from_address_label') }}</label>
<input type="email" name="mail_from_address"
value="{{ $mailConfig['from_address'] }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('mail_from_address') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_from_name_label') }} <span class="text-gray-400 font-normal">(optional)</span></label>
<input type="text" name="mail_from_name"
value="{{ $mailConfig['from_name'] }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_encryption_label') }}</label>
<select name="mail_encryption" x-ref="mailEncryption"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@php $enc = $mailConfig['encryption'] ?? 'tls'; @endphp
<option value="tls" {{ $enc === 'tls' ? 'selected' : '' }}>TLS (Port 587)</option>
<option value="ssl" {{ $enc === 'ssl' ? 'selected' : '' }}>SSL (Port 465)</option>
<option value="none" {{ !in_array($enc, ['tls', 'ssl']) ? 'selected' : '' }}>{{ __('admin.mail_encryption_none') }}</option>
</select>
</div>
{{-- SMTP-Test --}}
<div class="pt-3 border-t border-gray-200">
<button type="button" @click="testSmtp()"
:disabled="mailTesting"
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-md hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-wait inline-flex items-center gap-2">
<template x-if="mailTesting">
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</template>
<span x-text="mailTesting ? '{{ __("admin.mail_testing") }}' : '{{ __("admin.mail_test_button") }}'"></span>
</button>
<p x-show="mailTestResult" x-cloak x-text="mailTestMessage"
:class="mailTestSuccess ? 'text-green-600' : 'text-red-600'"
class="text-sm mt-2"></p>
</div>
</div>
</div>
<div class="flex justify-end">
<button type="submit"
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
{{ __('admin.mail_save') }}
</button>
</div>
</form>
</div>
{{-- ============================================================ --}}
{{-- Tab: Lizenz & Support --}}
{{-- ============================================================ --}}
<div x-show="tab === 'license'" role="tabpanel">
{{-- License Key --}}
<form method="POST" action="{{ route('admin.administration.update-license') }}">
@csrf
@method('PUT')
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-1">{{ __('admin.license_title') }}</h2>
<p class="text-sm text-gray-500 mb-4">{{ __('admin.license_description') }}</p>
<label for="setting-license_key" class="block text-sm font-semibold text-gray-700 mb-2">{{ __('admin.license_key_label') }}</label>
<input type="text" name="license_key" id="setting-license_key"
value="{{ $licenseKey }}"
placeholder="XXXX-XXXX-XXXX-XXXX"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
{{-- Registration Status --}}
<div class="mt-6 pt-4 border-t border-gray-200">
<h3 class="text-sm font-semibold text-gray-700 mb-2">{{ __('admin.registration_status') }}</h3>
@if ($isRegistered)
<div class="flex items-center gap-2 text-sm text-green-700">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ __('admin.registration_active') }}
</div>
<p class="text-xs text-gray-500 mt-1">Installation-ID: <span class="font-mono">{{ $installationId }}</span></p>
@else
<div class="flex items-center gap-2 text-sm text-gray-500 mb-3">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ __('admin.registration_inactive') }}
</div>
@endif
</div>
<div class="flex justify-end mt-4">
<button type="submit"
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
{{ __('ui.save') }}
</button>
</div>
</div>
</form>
{{-- Registration (nur wenn nicht registriert) --}}
@if (!$isRegistered)
<div class="bg-white rounded-lg shadow p-6 mb-6">
<p class="text-sm text-gray-600 mb-3">{{ __('admin.support_not_registered') }}</p>
<form method="POST" action="{{ route('admin.support.register') }}">
@csrf
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded-md text-sm hover:bg-green-700">
{{ __('admin.register_now') }}
</button>
</form>
</div>
@endif
{{-- System Info --}}
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.version_info') }}</h3>
<dl class="grid grid-cols-2 gap-y-2 gap-x-4 text-sm">
<dt class="text-gray-500">App-Version:</dt>
<dd class="text-gray-800 font-mono">{{ config('app.version') }}</dd>
<dt class="text-gray-500">PHP:</dt>
<dd class="text-gray-800 font-mono">{{ PHP_VERSION }}</dd>
<dt class="text-gray-500">Laravel:</dt>
<dd class="text-gray-800 font-mono">{{ app()->version() }}</dd>
<dt class="text-gray-500">Datenbank:</dt>
<dd class="text-gray-800 font-mono">{{ config('database.default') }}</dd>
</dl>
@if ($updateInfo && version_compare($updateInfo['latest_version'] ?? '0', config('app.version'), '>'))
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p class="text-sm font-medium text-blue-800">
{{ __('admin.update_available', ['version' => $updateInfo['latest_version']]) }}
</p>
@if ($updateInfo['changelog'] ?? null)
<p class="text-xs text-blue-600 mt-1">{{ $updateInfo['changelog'] }}</p>
@endif
@if (($updateInfo['download_url'] ?? null) && str_starts_with($updateInfo['download_url'], 'https://'))
<a href="{{ $updateInfo['download_url'] }}" target="_blank" rel="noopener"
class="inline-block mt-2 text-sm text-blue-700 underline">
{{ __('admin.download_update') }}
</a>
@endif
</div>
@endif
</div>
</div>
{{-- ============================================================ --}}
{{-- Tab: Wartung --}}
{{-- ============================================================ --}}
<div x-show="tab === 'maintenance'" role="tabpanel">
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{{ __('admin.demo_data_delete_title') }}</h2>
<p class="text-sm text-gray-600 mb-4">{{ __('admin.demo_data_delete_description') }}</p>
<div class="grid sm:grid-cols-2 gap-4 mb-5">
<div class="border border-red-200 bg-red-50 rounded-md p-4">
<h3 class="text-sm font-semibold text-red-700 mb-2">{{ __('admin.demo_data_deletes') }}</h3>
<ul class="text-sm text-red-600 space-y-1 list-disc list-inside">
<li>{{ __('admin.stat_users') }} ({{ __('admin.demo_data_except_admin') }})</li>
<li>{{ __('admin.nav_teams') }}</li>
<li>{{ __('admin.nav_players') }}</li>
<li>{{ __('admin.nav_events') }}</li>
<li>Kommentare</li>
<li>{{ __('admin.nav_locations') }}</li>
<li>{{ __('admin.nav_files') }}</li>
<li>{{ __('admin.activity_log_title') }}</li>
</ul>
</div>
<div class="border border-green-200 bg-green-50 rounded-md p-4">
<h3 class="text-sm font-semibold text-green-700 mb-2">{{ __('admin.demo_data_keeps') }}</h3>
<ul class="text-sm text-green-600 space-y-1 list-disc list-inside">
<li>{{ __('admin.demo_data_keeps_admin') }}</li>
<li>{{ __('admin.nav_settings') }}</li>
<li>{{ __('admin.settings_tab_categories') }}</li>
</ul>
</div>
</div>
<div class="border border-red-300 bg-red-50 rounded-md p-4 mb-5">
<p class="text-sm text-red-700 font-medium">{{ __('admin.demo_data_delete_warning') }}</p>
</div>
<form method="POST" action="{{ route('admin.administration.destroy-demo-data') }}"
onsubmit="return confirm(@js(__('admin.demo_data_delete_confirm')))">
@csrf
@method('DELETE')
<div class="mb-4">
<label for="demo-delete-password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_password_label') }}</label>
<input type="password" name="password" id="demo-delete-password" required autocomplete="current-password"
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500">
</div>
<button type="submit"
class="px-5 py-2.5 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 transition">
{{ __('admin.demo_data_delete_button') }}
</button>
</form>
</div>
{{-- Factory Reset --}}
<div class="bg-white rounded-lg shadow p-6 mt-6 border-2 border-red-300">
<h2 class="text-lg font-semibold text-red-700 mb-4">{{ __('admin.factory_reset_title') }}</h2>
<p class="text-sm text-gray-600 mb-4">{{ __('admin.factory_reset_description') }}</p>
<div class="border border-red-200 bg-red-50 rounded-md p-4 mb-5">
<h3 class="text-sm font-semibold text-red-700 mb-2">{{ __('admin.factory_reset_deletes') }}</h3>
<ul class="text-sm text-red-600 space-y-1 list-disc list-inside">
<li>{{ __('admin.factory_reset_item_users') }}</li>
<li>{{ __('admin.factory_reset_item_data') }}</li>
<li>{{ __('admin.factory_reset_item_settings') }}</li>
<li>{{ __('admin.factory_reset_item_files') }}</li>
</ul>
</div>
<div class="bg-red-100 border border-red-300 rounded-md p-4 mb-5">
<p class="text-sm text-red-800 font-bold">{{ __('admin.factory_reset_warning') }}</p>
</div>
<form method="POST" action="{{ route('admin.administration.factory-reset') }}"
onsubmit="return confirm(@js(__('admin.factory_reset_confirm')))">
@csrf
@method('DELETE')
<div class="mb-4">
<label for="factory-reset-password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_password_label') }}</label>
<input type="password" name="password" id="factory-reset-password" required autocomplete="current-password"
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500">
</div>
<div class="mb-5">
<label for="factory-reset-confirmation" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_confirmation_label') }}</label>
<input type="text" name="confirmation" id="factory-reset-confirmation" required
placeholder="RESET-BEST&Auml;TIGT"
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-2 focus:ring-red-500 focus:border-red-500">
<p class="mt-1 text-xs text-gray-500">{{ __('admin.factory_reset_confirmation_hint') }}</p>
</div>
<button type="submit"
class="px-5 py-2.5 text-sm font-medium text-white bg-red-700 rounded-md hover:bg-red-800 transition">
{{ __('admin.factory_reset_button') }}
</button>
</form>
</div>
</div>
{{-- ============================================================ --}}
{{-- Tab: Aktivitätslog --}}
{{-- ============================================================ --}}
<div x-show="tab === 'activity'" role="tabpanel">
<div class="bg-white rounded-lg shadow p-6 mb-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">{{ __('admin.activity_recent') }}</h2>
<a href="{{ route('admin.activity-logs.index') }}" class="text-sm text-blue-600 hover:text-blue-800 font-medium">
{{ __('admin.activity_log_title') }} &rarr;
</a>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-3 py-2 font-medium text-gray-700">{{ __('admin.log_time') }}</th>
<th class="text-left px-3 py-2 font-medium text-gray-700">{{ __('admin.log_user') }}</th>
<th class="text-center px-3 py-2 font-medium text-gray-700">{{ __('admin.log_action') }}</th>
<th class="text-left px-3 py-2 font-medium text-gray-700">{{ __('admin.log_description') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($recentLogs as $log)
@php
$actionColors = [
'login' => 'bg-green-100 text-green-800',
'logout' => 'bg-gray-100 text-gray-800',
'login_failed' => 'bg-red-100 text-red-800',
'registered' => 'bg-blue-100 text-blue-800',
'created' => 'bg-blue-100 text-blue-800',
'updated' => 'bg-yellow-100 text-yellow-800',
'deleted' => 'bg-red-100 text-red-800',
'restored' => 'bg-green-100 text-green-800',
'toggled_active' => 'bg-yellow-100 text-yellow-800',
'role_changed' => 'bg-purple-100 text-purple-800',
'password_reset' => 'bg-orange-100 text-orange-800',
'reverted' => 'bg-orange-100 text-orange-800',
];
$color = $actionColors[$log->action] ?? 'bg-gray-100 text-gray-800';
@endphp
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-gray-500 whitespace-nowrap">{{ $log->created_at->format('d.m. H:i') }}</td>
<td class="px-3 py-2 text-gray-900">{{ $log->user?->name ?? __('admin.log_system') }}</td>
<td class="px-3 py-2 text-center">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $color }}">{{ $log->action }}</span>
</td>
<td class="px-3 py-2 text-gray-600 truncate max-w-xs">{{ $log->description }}</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-3 py-6 text-center text-gray-400">{{ __('admin.log_empty') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
function administrationPage() {
return {
tab: 'features',
init() {
const validTabs = ['features', 'mail', 'license', 'maintenance', 'activity'];
const urlTab = new URLSearchParams(window.location.search).get('tab');
const hash = window.location.hash.replace('#', '');
const stored = sessionStorage.getItem('admin_tab');
if (validTabs.includes(urlTab)) {
this.tab = urlTab;
} else if (validTabs.includes(hash)) {
this.tab = hash;
} else if (validTabs.includes(stored)) {
this.tab = stored;
}
this.$watch('tab', val => {
sessionStorage.setItem('admin_tab', val);
history.replaceState(null, '', '#' + val);
});
},
};
}
</script>
@endpush
</x-layouts.admin>

View File

@@ -91,6 +91,61 @@
@error('status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Wiederholung (nur Training) --}}
<div class="mb-4" x-data="recurrenceData()" x-show="isTraining" x-cloak>
<div class="border border-gray-200 rounded-md p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.recurrence') }}</h3>
<div class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence') }}</label>
<select x-model="recurrence" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="none">{{ __('admin.recurrence_none') }}</option>
<option value="daily">{{ __('admin.recurrence_daily') }}</option>
<option value="weekly">{{ __('admin.recurrence_weekly') }}</option>
<option value="biweekly">{{ __('admin.recurrence_biweekly') }}</option>
</select>
</div>
<div x-show="recurrence !== 'none'" x-cloak>
<div class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence_end_type') }}</label>
<div class="flex gap-4">
<label class="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer">
<input type="radio" x-model="endType" value="date" class="text-blue-600">
{{ __('admin.recurrence_end_date') }}
</label>
<label class="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer">
<input type="radio" x-model="endType" value="count" class="text-blue-600">
{{ __('admin.recurrence_end_count') }}
</label>
</div>
</div>
<div x-show="endType === 'date'" class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence_end_date') }}</label>
<input type="date" x-model="endDate" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div x-show="endType === 'count'" class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence_count_label') }}</label>
<input type="number" x-model.number="count" min="1" max="52" class="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div x-show="previewCount > 0" class="text-sm text-blue-600 bg-blue-50 rounded-md px-3 py-2">
<span x-text="previewText"></span>
</div>
<div x-show="previewCount >= 52" class="text-xs text-orange-600 mt-1">
{{ __('admin.recurrence_max_warning', ['max' => 52]) }}
</div>
</div>
</div>
<input type="hidden" name="recurrence" :value="recurrence">
<input type="hidden" name="recurrence_end_type" :value="endType">
<input type="hidden" name="recurrence_end_date" :value="endDate">
<input type="hidden" name="recurrence_count" :value="count">
</div>
{{-- Mindestanforderungen --}}
<div class="mb-4" x-data="minRequirementsData()" x-init="listenTypeChange()">
<div x-show="showDropdowns" x-cloak class="border border-gray-200 rounded-md p-4">
@@ -105,6 +160,7 @@
@endfor
</select>
</div>
@if (\App\Models\Setting::isFeatureEnabled('catering'))
<div x-show="showCatering">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.min_catering') }}</label>
<select name="min_catering" x-model="minCatering" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
@@ -114,6 +170,8 @@
@endfor
</select>
</div>
@endif
@if (\App\Models\Setting::isFeatureEnabled('timekeepers'))
<div x-show="showTimekeepers">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.min_timekeepers') }}</label>
<select name="min_timekeepers" x-model="minTimekeepers" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
@@ -123,6 +181,7 @@
@endfor
</select>
</div>
@endif
</div>
</div>
</div>
@@ -184,6 +243,7 @@
</div>
{{-- Catering/Zeitnehmer-Zuweisungen (nicht für away_game/meeting) --}}
@if (\App\Models\Setting::isFeatureEnabled('catering') || \App\Models\Setting::isFeatureEnabled('timekeepers'))
<div class="mb-4" x-data="assignmentData()" x-init="listenTeamChange()"
x-show="!['away_game', 'meeting'].includes(currentType)" x-cloak>
<template x-if="parents.length > 0">
@@ -193,24 +253,32 @@
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 font-medium text-gray-600">{{ __('ui.name') }}</th>
@if (\App\Models\Setting::isFeatureEnabled('catering'))
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.catering_assignment') }}</th>
@endif
@if (\App\Models\Setting::isFeatureEnabled('timekeepers'))
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.timekeeper_assignment') }}</th>
@endif
</tr>
</thead>
<tbody>
<template x-for="p in parents" :key="p.id">
<tr class="border-b border-gray-100">
<td class="py-2" x-text="p.name"></td>
@if (\App\Models\Setting::isFeatureEnabled('catering'))
<td class="py-2 text-center">
<input type="checkbox" name="catering_users[]" :value="p.id"
:checked="assignedCatering.includes(p.id)"
class="rounded border-gray-300 text-blue-600">
</td>
@endif
@if (\App\Models\Setting::isFeatureEnabled('timekeepers'))
<td class="py-2 text-center">
<input type="checkbox" name="timekeeper_users[]" :value="p.id"
:checked="assignedTimekeeper.includes(p.id)"
class="rounded border-gray-300 text-blue-600">
</td>
@endif
</tr>
</template>
</tbody>
@@ -218,6 +286,7 @@
</div>
</template>
</div>
@endif
{{-- Dateien --}}
<div class="mb-4" x-data="{ showPicker: false, newFileCount: 0 }">
@@ -294,6 +363,41 @@
document.getElementById('description_html').value = quill.root.innerHTML;
});
// Recurrence data for Training events
function recurrenceData() {
const typeSel = document.getElementById('type');
const dateSel = document.getElementById('start_date');
const previewTpl = @js(__('admin.recurrence_preview', ['count' => '__COUNT__']));
return {
recurrence: @js(old('recurrence', 'none')),
endType: @js(old('recurrence_end_type', 'date')),
endDate: @js(old('recurrence_end_date', '')),
count: @js((int) old('recurrence_count', 10)),
isTraining: typeSel.value === 'training',
init() {
typeSel.addEventListener('change', () => {
this.isTraining = typeSel.value === 'training';
if (!this.isTraining) this.recurrence = 'none';
});
},
get startDate() { return dateSel.value; },
get previewCount() {
if (this.recurrence === 'none') return 0;
if (this.endType === 'count') return Math.min(Math.max(1, this.count || 0), 52);
if (!this.startDate || !this.endDate) return 0;
const days = { daily: 1, weekly: 7, biweekly: 14 };
const start = new Date(this.startDate);
const end = new Date(this.endDate);
const diff = Math.floor((end - start) / (1000 * 60 * 60 * 24));
if (diff <= 0) return 0;
return Math.min(Math.floor(diff / days[this.recurrence]), 52);
},
get previewText() {
return previewTpl.replace('__COUNT__', this.previewCount);
}
};
}
// Assignment data for Catering/Timekeeper
function assignmentData() {
const teamParents = @js($teamParents);

View File

@@ -16,6 +16,12 @@
@csrf
@method('PUT')
@if ($event->isPartOfSeries())
<div class="mb-4 bg-blue-50 border border-blue-200 rounded-md px-4 py-2 text-sm text-blue-700">
{{ __('admin.series_hint', ['count' => $event->followingSeriesEvents()->count()]) }}
</div>
@endif
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.team') }} *</label>
@@ -92,6 +98,61 @@
@error('status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Wiederholung (nur Training) --}}
<div class="mb-4" x-data="recurrenceData()" x-show="isTraining" x-cloak>
<div class="border border-gray-200 rounded-md p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.recurrence') }}</h3>
<div class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence') }}</label>
<select x-model="recurrence" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="none">{{ __('admin.recurrence_none') }}</option>
<option value="daily">{{ __('admin.recurrence_daily') }}</option>
<option value="weekly">{{ __('admin.recurrence_weekly') }}</option>
<option value="biweekly">{{ __('admin.recurrence_biweekly') }}</option>
</select>
</div>
<div x-show="recurrence !== 'none'" x-cloak>
<div class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence_end_type') }}</label>
<div class="flex gap-4">
<label class="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer">
<input type="radio" x-model="endType" value="date" class="text-blue-600">
{{ __('admin.recurrence_end_date') }}
</label>
<label class="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer">
<input type="radio" x-model="endType" value="count" class="text-blue-600">
{{ __('admin.recurrence_end_count') }}
</label>
</div>
</div>
<div x-show="endType === 'date'" class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence_end_date') }}</label>
<input type="date" x-model="endDate" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div x-show="endType === 'count'" class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence_count_label') }}</label>
<input type="number" x-model.number="count" min="1" max="52" class="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div x-show="previewCount > 0" class="text-sm text-blue-600 bg-blue-50 rounded-md px-3 py-2">
<span x-text="previewText"></span>
</div>
<div x-show="previewCount >= 52" class="text-xs text-orange-600 mt-1">
{{ __('admin.recurrence_max_warning', ['max' => 52]) }}
</div>
</div>
</div>
<input type="hidden" name="recurrence" :value="recurrence">
<input type="hidden" name="recurrence_end_type" :value="endType">
<input type="hidden" name="recurrence_end_date" :value="endDate">
<input type="hidden" name="recurrence_count" :value="count">
</div>
{{-- Mindestanforderungen --}}
<div class="mb-4" x-data="minRequirementsData()" x-init="listenTypeChange()">
<div x-show="showDropdowns" x-cloak class="border border-gray-200 rounded-md p-4">
@@ -106,6 +167,7 @@
@endfor
</select>
</div>
@if (\App\Models\Setting::isFeatureEnabled('catering'))
<div x-show="showCatering">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.min_catering') }}</label>
<select name="min_catering" x-model="minCatering" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
@@ -115,6 +177,8 @@
@endfor
</select>
</div>
@endif
@if (\App\Models\Setting::isFeatureEnabled('timekeepers'))
<div x-show="showTimekeepers">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.min_timekeepers') }}</label>
<select name="min_timekeepers" x-model="minTimekeepers" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
@@ -124,6 +188,7 @@
@endfor
</select>
</div>
@endif
</div>
</div>
</div>
@@ -185,6 +250,7 @@
</div>
{{-- Catering/Zeitnehmer-Zuweisungen (nicht für away_game/meeting) --}}
@if (\App\Models\Setting::isFeatureEnabled('catering') || \App\Models\Setting::isFeatureEnabled('timekeepers'))
<div class="mb-4" x-data="assignmentData()" x-init="listenTeamChange()"
x-show="!['away_game', 'meeting'].includes(currentType)" x-cloak>
<template x-if="parents.length > 0">
@@ -194,24 +260,32 @@
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 font-medium text-gray-600">{{ __('ui.name') }}</th>
@if (\App\Models\Setting::isFeatureEnabled('catering'))
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.catering_assignment') }}</th>
@endif
@if (\App\Models\Setting::isFeatureEnabled('timekeepers'))
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.timekeeper_assignment') }}</th>
@endif
</tr>
</thead>
<tbody>
<template x-for="p in parents" :key="p.id">
<tr class="border-b border-gray-100">
<td class="py-2" x-text="p.name"></td>
@if (\App\Models\Setting::isFeatureEnabled('catering'))
<td class="py-2 text-center">
<input type="checkbox" name="catering_users[]" :value="p.id"
:checked="assignedCatering.includes(p.id)"
class="rounded border-gray-300 text-blue-600">
</td>
@endif
@if (\App\Models\Setting::isFeatureEnabled('timekeepers'))
<td class="py-2 text-center">
<input type="checkbox" name="timekeeper_users[]" :value="p.id"
:checked="assignedTimekeeper.includes(p.id)"
class="rounded border-gray-300 text-blue-600">
</td>
@endif
</tr>
</template>
</tbody>
@@ -219,6 +293,7 @@
</div>
</template>
</div>
@endif
{{-- Dateien --}}
<div class="mb-4" x-data="{ showPicker: false, newFileCount: 0 }">
@@ -277,9 +352,57 @@
<button type="button" @click="newFileCount++" class="mt-2 text-sm text-blue-600 hover:text-blue-800">+ {{ __('admin.upload_new_file') }}</button>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 flex-wrap">
@if ($event->isPartOfSeries() && $event->followingSeriesEvents()->count() > 0)
{{-- Serien-Event: Modal-Dialog beim Speichern --}}
<div x-data="{ showSaveModal: false }" class="contents">
<button type="button" @click="showSaveModal = true" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('ui.save') }}</button>
<div x-show="showSaveModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @keydown.escape.window="showSaveModal = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm mx-4" @click.outside="showSaveModal = false">
<h3 class="font-semibold text-gray-900 mb-2">{{ __('admin.save_series_title') }}</h3>
<p class="text-sm text-gray-600 mb-4">{{ __('admin.save_series_description', ['count' => $event->followingSeriesEvents()->count()]) }}</p>
<div class="flex flex-col gap-2">
<button type="submit" class="w-full text-left px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-md font-medium">{{ __('admin.save_only_this') }}</button>
<button type="submit" name="update_following" value="1" class="w-full text-left px-3 py-2 text-sm bg-green-100 hover:bg-green-200 text-green-700 rounded-md font-medium">{{ __('admin.save_this_and_following') }}</button>
<button type="button" @click="showSaveModal = false" class="text-sm text-gray-500 hover:underline mt-1">{{ __('ui.cancel') }}</button>
</div>
</div>
</div>
</div>
@else
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('ui.save') }}</button>
@endif
<a href="{{ route('admin.events.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
{{-- Löschen-Button --}}
@if ($event->isPartOfSeries() && $event->followingSeriesEvents()->count() > 0)
<div x-data="{ showDeleteModal: false }" class="ml-auto">
<button type="button" @click="showDeleteModal = true" class="text-red-600 hover:text-red-800 text-sm font-medium">{{ __('ui.delete') }}</button>
<div x-show="showDeleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @keydown.escape.window="showDeleteModal = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm mx-4" @click.outside="showDeleteModal = false">
<h3 class="font-semibold text-gray-900 mb-2">{{ __('admin.delete_series_title') }}</h3>
<p class="text-sm text-gray-600 mb-4">{{ __('admin.delete_series_description') }}</p>
<div class="flex flex-col gap-2">
<form method="POST" action="{{ route('admin.events.destroy', $event) }}">
@csrf @method('DELETE')
<button type="submit" class="w-full text-left px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-md">{{ __('admin.delete_only_this') }}</button>
</form>
<form method="POST" action="{{ route('admin.events.destroy', $event) }}">
@csrf @method('DELETE')
<input type="hidden" name="delete_following" value="1">
<button type="submit" class="w-full text-left px-3 py-2 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-md">{{ __('admin.delete_this_and_following') }}</button>
</form>
<button type="button" @click="showDeleteModal = false" class="text-sm text-gray-500 hover:underline mt-1">{{ __('ui.cancel') }}</button>
</div>
</div>
</div>
</div>
@else
<form method="POST" action="{{ route('admin.events.destroy', $event) }}" class="ml-auto inline" onsubmit="return confirm(@js(__('admin.confirm_delete_event')))">
@csrf @method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800 text-sm font-medium">{{ __('ui.delete') }}</button>
</form>
@endif
</div>
</form>
</div>
@@ -358,7 +481,7 @@
@endif
{{-- Spielerstatistik (nur Spieltypen mit zugesagten Spielern) --}}
@if ($event->type->isGameType())
@if (\App\Models\Setting::isFeatureEnabled('player_stats') && $event->type->isGameType())
@php
$confirmedPlayers = $event->participants
->where('status', \App\Enums\ParticipantStatus::Yes)
@@ -564,6 +687,41 @@
};
}
// Recurrence data for Training events
function recurrenceData() {
const typeSel = document.getElementById('type');
const dateSel = document.getElementById('start_date');
const previewTpl = @js(__('admin.recurrence_preview', ['count' => '__COUNT__']));
return {
recurrence: @js(old('recurrence', 'none')),
endType: @js(old('recurrence_end_type', 'date')),
endDate: @js(old('recurrence_end_date', '')),
count: @js((int) old('recurrence_count', 10)),
isTraining: typeSel.value === 'training',
init() {
typeSel.addEventListener('change', () => {
this.isTraining = typeSel.value === 'training';
if (!this.isTraining) this.recurrence = 'none';
});
},
get startDate() { return dateSel.value; },
get previewCount() {
if (this.recurrence === 'none') return 0;
if (this.endType === 'count') return Math.min(Math.max(1, this.count || 0), 52);
if (!this.startDate || !this.endDate) return 0;
const days = { daily: 1, weekly: 7, biweekly: 14 };
const start = new Date(this.startDate);
const end = new Date(this.endDate);
const diff = Math.floor((end - start) / (1000 * 60 * 60 * 24));
if (diff <= 0) return 0;
return Math.min(Math.floor(diff / days[this.recurrence]), 52);
},
get previewText() {
return previewTpl.replace('__COUNT__', this.previewCount);
}
};
}
function assignmentData() {
const teamParents = @js($teamParents);
const assignedCatering = @js($assignedCatering);

View File

@@ -74,11 +74,35 @@
<div class="shrink-0 text-right">
<div class="flex items-center justify-end gap-2">
<a href="{{ route('admin.events.edit', $event) }}" class="text-blue-600 hover:underline text-sm">{{ __('ui.edit') }}</a>
@if ($event->isPartOfSeries())
<div x-data="{ showDeleteModal: false }" class="inline">
<button @click="showDeleteModal = true" class="text-red-600 hover:text-red-800 text-sm">{{ __('ui.delete') }}</button>
<div x-show="showDeleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @keydown.escape.window="showDeleteModal = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm mx-4" @click.outside="showDeleteModal = false">
<h3 class="font-semibold text-gray-900 mb-2">{{ __('admin.delete_series_title') }}</h3>
<p class="text-sm text-gray-600 mb-4">{{ __('admin.delete_series_description') }}</p>
<div class="flex flex-col gap-2">
<form method="POST" action="{{ route('admin.events.destroy', $event) }}">
@csrf @method('DELETE')
<button type="submit" class="w-full text-left px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-md">{{ __('admin.delete_only_this') }}</button>
</form>
<form method="POST" action="{{ route('admin.events.destroy', $event) }}">
@csrf @method('DELETE')
<input type="hidden" name="delete_following" value="1">
<button type="submit" class="w-full text-left px-3 py-2 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-md">{{ __('admin.delete_this_and_following') }}</button>
</form>
<button @click="showDeleteModal = false" class="text-sm text-gray-500 hover:underline mt-1">{{ __('ui.cancel') }}</button>
</div>
</div>
</div>
</div>
@else
<form method="POST" action="{{ route('admin.events.destroy', $event) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_event')))">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800 text-sm">{{ __('ui.delete') }}</button>
</form>
@endif
</div>
</div>
</div>

View File

@@ -10,11 +10,6 @@
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
{{ __('admin.settings_tab_general') }}
</button>
<button type="button" @click="tab = 'mail'"
:class="tab === 'mail' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
{{ __('admin.settings_tab_mail') }}
</button>
<button type="button" @click="tab = 'legal'"
:class="tab === 'legal' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
@@ -36,21 +31,6 @@
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
{{ __('admin.settings_tab_seasons') }}
</button>
<button type="button" @click="tab = 'visibility'"
:class="tab === 'visibility' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
{{ __('admin.settings_tab_visibility') }}
</button>
<button type="button" @click="tab = 'license'"
:class="tab === 'license' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
{{ __('admin.settings_tab_license') }}
</button>
<button type="button" @click="tab = 'maintenance'"
:class="tab === 'maintenance' ? 'border-red-500 text-red-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
{{ __('admin.settings_tab_maintenance') }}
</button>
@endif
</nav>
</div>
@@ -63,7 +43,7 @@
<div x-show="tab === 'general'" x-effect="if (tab === 'general' && !sloganInitialized) $nextTick(() => initSloganEditors())" role="tabpanel">
{{-- Text-Inputs (app_name etc.) --}}
@foreach ($settings as $key => $setting)
@if ($setting->type !== 'html' && $setting->type !== 'richtext' && $key !== 'app_favicon' && $key !== 'statistics_enabled')
@if ($setting->type !== 'html' && $setting->type !== 'richtext' && $key !== 'app_favicon' && $key !== 'statistics_enabled' && $key !== 'license_key')
<div class="bg-white rounded-lg shadow p-6 mb-6">
<label for="setting-{{ $key }}" class="block text-sm font-semibold text-gray-700 mb-2">{{ $setting->label }}</label>
<input
@@ -157,159 +137,6 @@
@endforeach
</div>
{{-- Tab: E-Mail --}}
<div x-show="tab === 'mail'" role="tabpanel">
<form method="POST" action="{{ route('admin.settings.update-mail') }}"
x-data="{
mailMailer: @js($mailConfig['mailer'] ?? 'log'),
mailTesting: false,
mailTestResult: false,
mailTestSuccess: false,
mailTestMessage: '',
async testSmtp() {
this.mailTesting = true;
this.mailTestResult = false;
try {
const res = await fetch('{{ route("admin.settings.test-mail") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
body: JSON.stringify({
mail_host: this.$refs.mailHost.value,
mail_port: this.$refs.mailPort.value,
mail_username: this.$refs.mailUsername.value,
mail_password: this.$refs.mailPassword.value,
mail_encryption: this.$refs.mailEncryption.value,
}),
});
const data = await res.json();
this.mailTestSuccess = data.success;
this.mailTestMessage = data.message;
} catch (e) {
this.mailTestSuccess = false;
this.mailTestMessage = 'Netzwerkfehler: ' + e.message;
}
this.mailTesting = false;
this.mailTestResult = true;
}
}">
@csrf
@method('PUT')
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h3 class="text-base font-semibold text-gray-800 mb-1">{{ __('admin.mail_config_title') }}</h3>
<p class="text-sm text-gray-500 mb-5">{{ __('admin.mail_config_hint') }}</p>
{{-- Versandmethode --}}
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.mail_mailer_label') }}</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mail_mailer" value="smtp" x-model="mailMailer"
class="text-blue-600 focus:ring-blue-500">
<span class="text-sm">SMTP</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mail_mailer" value="log" x-model="mailMailer"
class="text-blue-600 focus:ring-blue-500">
<span class="text-sm">{{ __('admin.mail_log_mode') }}</span>
</label>
</div>
</div>
{{-- SMTP-Felder --}}
<div x-show="mailMailer === 'smtp'" x-cloak class="space-y-4 p-4 bg-gray-50 border border-gray-200 rounded-md">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_host_label') }}</label>
<input type="text" name="mail_host" x-ref="mailHost"
value="{{ $mailConfig['host'] }}"
placeholder="z.B. smtp.strato.de"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('mail_host') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_port_label') }}</label>
<input type="number" name="mail_port" x-ref="mailPort"
value="{{ $mailConfig['port'] }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('mail_port') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_username_label') }}</label>
<input type="text" name="mail_username" x-ref="mailUsername"
value="{{ $mailConfig['username'] }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('mail_username') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_password_label') }}</label>
<input type="password" name="mail_password" x-ref="mailPassword"
value="{{ $mailConfig['password'] }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('mail_password') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_from_address_label') }}</label>
<input type="email" name="mail_from_address"
value="{{ $mailConfig['from_address'] }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('mail_from_address') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_from_name_label') }} <span class="text-gray-400 font-normal">(optional)</span></label>
<input type="text" name="mail_from_name"
value="{{ $mailConfig['from_name'] }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_encryption_label') }}</label>
<select name="mail_encryption" x-ref="mailEncryption"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@php $enc = $mailConfig['encryption'] ?? 'tls'; @endphp
<option value="tls" {{ $enc === 'tls' ? 'selected' : '' }}>TLS (Port 587)</option>
<option value="ssl" {{ $enc === 'ssl' ? 'selected' : '' }}>SSL (Port 465)</option>
<option value="none" {{ !in_array($enc, ['tls', 'ssl']) ? 'selected' : '' }}>{{ __('admin.mail_encryption_none') }}</option>
</select>
</div>
{{-- SMTP-Test --}}
<div class="pt-3 border-t border-gray-200">
<button type="button" @click="testSmtp()"
:disabled="mailTesting"
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-md hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-wait inline-flex items-center gap-2">
<template x-if="mailTesting">
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</template>
<span x-text="mailTesting ? '{{ __("admin.mail_testing") }}' : '{{ __("admin.mail_test_button") }}'"></span>
</button>
<p x-show="mailTestResult" x-cloak x-text="mailTestMessage"
:class="mailTestSuccess ? 'text-green-600' : 'text-red-600'"
class="text-sm mt-2"></p>
</div>
</div>
</div>
<div class="flex justify-end">
<button type="submit"
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
{{ __('admin.mail_save') }}
</button>
</div>
</form>
</div>
{{-- Tab: Rechtliches Multi-Language mit Flaggen --}}
<div x-show="tab === 'legal'" x-effect="if (tab === 'legal' && !editorsInitialized) $nextTick(() => initEditors())" role="tabpanel">
{{-- Sprach-Flaggen-Leiste --}}
@@ -556,126 +383,8 @@
</div>
@endif
{{-- Tab: Sichtbarkeit (nur Admin) --}}
@if (auth()->user()->isAdmin())
<div x-show="tab === 'visibility'" role="tabpanel">
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm text-gray-500 mb-5">{{ __('admin.visibility_description') }}</p>
@php
$features = [
'statistics' => __('admin.visibility_feature_statistics'),
'finances' => __('admin.visibility_feature_finances'),
'catering_history' => __('admin.visibility_feature_catering_history'),
];
$roles = [
'coach' => __('ui.enums.user_role.coach'),
'parent_rep' => __('ui.enums.user_role.parent_rep'),
];
@endphp
<div class="space-y-4">
@foreach ($features as $featureKey => $featureLabel)
<div class="border border-gray-200 rounded-md p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ $featureLabel }}</h3>
<div class="flex flex-wrap gap-6">
@foreach ($roles as $roleKey => $roleLabel)
@php
$settingKey = "visibility_{$featureKey}_{$roleKey}";
$currentValue = $visibilitySettings[$settingKey]->value ?? '1';
@endphp
<label class="flex items-center gap-3 cursor-pointer" x-data="{ on: {{ $currentValue === '1' ? 'true' : 'false' }} }">
<input type="hidden" name="settings[{{ $settingKey }}]" :value="on ? '1' : '0'">
<button type="button" @click="on = !on"
:class="on ? 'bg-blue-600' : 'bg-gray-300'"
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<span :class="on ? 'translate-x-5' : 'translate-x-0'"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out mt-0.5 ml-0.5"></span>
</button>
<span class="text-sm text-gray-700">{{ $roleLabel }}</span>
</label>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
{{-- Tab: Lizenz & Support (nur Admin) --}}
@if (auth()->user()->isAdmin())
<div x-show="tab === 'license'" role="tabpanel">
{{-- License Key --}}
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-1">{{ __('admin.license_title') }}</h2>
<p class="text-sm text-gray-500 mb-4">{{ __('admin.license_description') }}</p>
<label for="setting-license_key" class="block text-sm font-semibold text-gray-700 mb-2">{{ __('admin.license_key_label') }}</label>
<input type="text" name="settings[license_key]" id="setting-license_key"
value="{{ $settings['license_key']->value ?? '' }}"
placeholder="XXXX-XXXX-XXXX-XXXX"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
{{-- Registration Status --}}
<div class="mt-6 pt-4 border-t border-gray-200">
<h3 class="text-sm font-semibold text-gray-700 mb-2">{{ __('admin.registration_status') }}</h3>
@if ($isRegistered)
<div class="flex items-center gap-2 text-sm text-green-700">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ __('admin.registration_active') }}
</div>
<p class="text-xs text-gray-500 mt-1">Installation-ID: <span class="font-mono">{{ $installationId }}</span></p>
@else
<div class="flex items-center gap-2 text-sm text-gray-500 mb-3">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ __('admin.registration_inactive') }}
</div>
@endif
</div>
</div>
{{-- System Info --}}
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.version_info') }}</h3>
<dl class="grid grid-cols-2 gap-y-2 gap-x-4 text-sm">
<dt class="text-gray-500">App-Version:</dt>
<dd class="text-gray-800 font-mono">{{ config('app.version') }}</dd>
<dt class="text-gray-500">PHP:</dt>
<dd class="text-gray-800 font-mono">{{ PHP_VERSION }}</dd>
<dt class="text-gray-500">Laravel:</dt>
<dd class="text-gray-800 font-mono">{{ app()->version() }}</dd>
<dt class="text-gray-500">Datenbank:</dt>
<dd class="text-gray-800 font-mono">{{ config('database.default') }}</dd>
</dl>
@if ($updateInfo && version_compare($updateInfo['latest_version'] ?? '0', config('app.version'), '>'))
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p class="text-sm font-medium text-blue-800">
{{ __('admin.update_available', ['version' => $updateInfo['latest_version']]) }}
</p>
@if ($updateInfo['changelog'] ?? null)
<p class="text-xs text-blue-600 mt-1">{{ $updateInfo['changelog'] }}</p>
@endif
@if (($updateInfo['download_url'] ?? null) && str_starts_with($updateInfo['download_url'], 'https://'))
<a href="{{ $updateInfo['download_url'] }}" target="_blank" rel="noopener"
class="inline-block mt-2 text-sm text-blue-700 underline">
{{ __('admin.download_update') }}
</a>
@endif
</div>
@endif
</div>
</div>
@endif
{{-- Save/Cancel (sichtbar auf allen Form-Tabs, nicht auf Wartung) --}}
<div x-show="tab !== 'categories' && tab !== 'maintenance'" class="flex gap-3 mt-6">
{{-- Save/Cancel (sichtbar auf allen Form-Tabs) --}}
<div x-show="tab !== 'categories'" class="flex gap-3 mt-6">
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 font-medium">
{{ __('ui.save') }}
</button>
@@ -685,118 +394,6 @@
</div>
</form>
{{-- Registration (außerhalb der Settings-Form, nur auf Lizenz-Tab) --}}
@if (auth()->user()->isAdmin() && !$isRegistered)
<div x-show="tab === 'license'" class="mt-6">
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm text-gray-600 mb-3">{{ __('admin.support_not_registered') }}</p>
<form method="POST" action="{{ route('admin.support.register') }}">
@csrf
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded-md text-sm hover:bg-green-700">
{{ __('admin.register_now') }}
</button>
</form>
</div>
</div>
@endif
{{-- Tab: Wartung (nur Admin, eigenes Formular) --}}
@if (auth()->user()->isAdmin())
<div x-show="tab === 'maintenance'" role="tabpanel">
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{{ __('admin.demo_data_delete_title') }}</h2>
<p class="text-sm text-gray-600 mb-4">{{ __('admin.demo_data_delete_description') }}</p>
<div class="grid sm:grid-cols-2 gap-4 mb-5">
<div class="border border-red-200 bg-red-50 rounded-md p-4">
<h3 class="text-sm font-semibold text-red-700 mb-2">{{ __('admin.demo_data_deletes') }}</h3>
<ul class="text-sm text-red-600 space-y-1 list-disc list-inside">
<li>{{ __('admin.stat_users') }} ({{ __('admin.demo_data_except_admin') }})</li>
<li>{{ __('admin.nav_teams') }}</li>
<li>{{ __('admin.nav_players') }}</li>
<li>{{ __('admin.nav_events') }}</li>
<li>Kommentare</li>
<li>{{ __('admin.nav_locations') }}</li>
<li>{{ __('admin.nav_files') }}</li>
<li>{{ __('admin.activity_log_title') }}</li>
</ul>
</div>
<div class="border border-green-200 bg-green-50 rounded-md p-4">
<h3 class="text-sm font-semibold text-green-700 mb-2">{{ __('admin.demo_data_keeps') }}</h3>
<ul class="text-sm text-green-600 space-y-1 list-disc list-inside">
<li>{{ __('admin.demo_data_keeps_admin') }}</li>
<li>{{ __('admin.nav_settings') }}</li>
<li>{{ __('admin.settings_tab_categories') }}</li>
</ul>
</div>
</div>
<div class="border border-red-300 bg-red-50 rounded-md p-4 mb-5">
<p class="text-sm text-red-700 font-medium">{{ __('admin.demo_data_delete_warning') }}</p>
</div>
<form method="POST" action="{{ route('admin.settings.destroy-demo-data') }}"
onsubmit="return confirm(@js(__('admin.demo_data_delete_confirm')))">
@csrf
@method('DELETE')
<div class="mb-4">
<label for="demo-delete-password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_password_label') }}</label>
<input type="password" name="password" id="demo-delete-password" required autocomplete="current-password"
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500">
</div>
<button type="submit"
class="px-5 py-2.5 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 transition">
{{ __('admin.demo_data_delete_button') }}
</button>
</form>
</div>
{{-- Factory Reset (nur Admin) --}}
@if (auth()->user()->isAdmin())
<div class="bg-white rounded-lg shadow p-6 mt-6 border-2 border-red-300">
<h2 class="text-lg font-semibold text-red-700 mb-4">{{ __('admin.factory_reset_title') }}</h2>
<p class="text-sm text-gray-600 mb-4">{{ __('admin.factory_reset_description') }}</p>
<div class="border border-red-200 bg-red-50 rounded-md p-4 mb-5">
<h3 class="text-sm font-semibold text-red-700 mb-2">{{ __('admin.factory_reset_deletes') }}</h3>
<ul class="text-sm text-red-600 space-y-1 list-disc list-inside">
<li>{{ __('admin.factory_reset_item_users') }}</li>
<li>{{ __('admin.factory_reset_item_data') }}</li>
<li>{{ __('admin.factory_reset_item_settings') }}</li>
<li>{{ __('admin.factory_reset_item_files') }}</li>
</ul>
</div>
<div class="bg-red-100 border border-red-300 rounded-md p-4 mb-5">
<p class="text-sm text-red-800 font-bold">{{ __('admin.factory_reset_warning') }}</p>
</div>
<form method="POST" action="{{ route('admin.settings.factory-reset') }}"
onsubmit="return confirm(@js(__('admin.factory_reset_confirm')))">
@csrf
@method('DELETE')
<div class="mb-4">
<label for="factory-reset-password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_password_label') }}</label>
<input type="password" name="password" id="factory-reset-password" required autocomplete="current-password"
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500">
</div>
<div class="mb-5">
<label for="factory-reset-confirmation" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_confirmation_label') }}</label>
<input type="text" name="confirmation" id="factory-reset-confirmation" required
placeholder="RESET-BEST&Auml;TIGT"
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-2 focus:ring-red-500 focus:border-red-500">
<p class="mt-1 text-xs text-gray-500">{{ __('admin.factory_reset_confirmation_hint') }}</p>
</div>
<button type="submit"
class="px-5 py-2.5 text-sm font-medium text-white bg-red-700 rounded-md hover:bg-red-800 transition">
{{ __('admin.factory_reset_button') }}
</button>
</form>
</div>
@endif
</div>
@endif
{{-- Tab: Dateikategorien (eigene Formulare) --}}
<div x-show="tab === 'categories'" role="tabpanel">
<div class="bg-white rounded-lg shadow p-6">
@@ -875,7 +472,7 @@
],
init() {
const validTabs = ['general', 'mail', 'legal', 'defaults', 'categories', 'seasons', 'visibility', 'license', 'maintenance'];
const validTabs = ['general', 'legal', 'defaults', 'categories', 'seasons'];
const urlTab = new URLSearchParams(window.location.search).get('tab');
const hash = window.location.hash.replace('#', '');
const stored = sessionStorage.getItem('settings_tab');

View File

@@ -48,12 +48,14 @@
<a href="{{ route('admin.players.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.players.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_players') }}</a>
<a href="{{ route('admin.users.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.users.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_users') }}</a>
@endif
@if (\App\Models\Setting::isFeatureVisibleFor('files', auth()->user()))
<a href="{{ route('admin.files.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.files.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_files') }}</a>
@endif
@if (auth()->user()->isStaff())
<a href="{{ route('admin.locations.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.locations.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_locations') }}</a>
{{-- Verwaltung-Dropdown --}}
<div class="relative" @click.away="mgmt = false">
<button @click="mgmt = !mgmt" class="text-sm text-gray-300 hover:text-white flex items-center gap-1 {{ request()->routeIs('admin.settings.*') || request()->routeIs('admin.invitations.*') || request()->routeIs('admin.activity-logs.*') || request()->routeIs('admin.support.*') ? 'text-white font-semibold' : '' }}">
<button @click="mgmt = !mgmt" class="text-sm text-gray-300 hover:text-white flex items-center gap-1 {{ request()->routeIs('admin.settings.*') || request()->routeIs('admin.administration.*') || request()->routeIs('admin.invitations.*') || request()->routeIs('admin.activity-logs.*') || request()->routeIs('admin.support.*') ? 'text-white font-semibold' : '' }}">
{{ __('admin.nav_verwaltung') }}
@if (\Illuminate\Support\Facades\Cache::has('support.update_check'))
<span class="w-2 h-2 bg-blue-400 rounded-full"></span>
@@ -62,8 +64,15 @@
</button>
<div x-show="mgmt" x-cloak x-transition class="absolute left-0 rtl:left-auto rtl:right-0 mt-2 w-48 bg-gray-700 rounded-md shadow-lg py-1 z-50">
<a href="{{ route('admin.settings.edit') }}" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 {{ request()->routeIs('admin.settings.*') ? 'bg-gray-600 font-semibold' : '' }}">{{ __('admin.nav_settings') }}</a>
@if (auth()->user()->isAdmin())
<a href="{{ route('admin.administration.index') }}" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 {{ request()->routeIs('admin.administration.*') ? 'bg-gray-600 font-semibold' : '' }}">{{ __('admin.nav_administration') }}</a>
@endif
@if (\App\Models\Setting::isFeatureVisibleFor('invitations', auth()->user()))
<a href="{{ route('admin.invitations.index') }}" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 {{ request()->routeIs('admin.invitations.*') ? 'bg-gray-600 font-semibold' : '' }}">{{ __('admin.nav_invitations') }}</a>
@endif
@if (\App\Models\Setting::isFeatureVisibleFor('list_generator', auth()->user()))
<a href="{{ route('admin.list-generator.create') }}" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 {{ request()->routeIs('admin.list-generator.*') ? 'bg-gray-600 font-semibold' : '' }}">{{ __('admin.nav_list_generator') }}</a>
@endif
<a href="{{ route('admin.support.index') }}" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 {{ request()->routeIs('admin.support.*') ? 'bg-gray-600 font-semibold' : '' }}">{{ __('admin.nav_support') }}</a>
@if (auth()->user()->canViewActivityLog())
<a href="{{ route('admin.activity-logs.index') }}" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 {{ request()->routeIs('admin.activity-logs.*') ? 'bg-gray-600 font-semibold' : '' }}">{{ __('admin.nav_activity_log') }}</a>
@@ -117,14 +126,23 @@
<a href="{{ route('admin.players.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_players') }}</a>
<a href="{{ route('admin.users.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_users') }}</a>
@endif
@if (\App\Models\Setting::isFeatureVisibleFor('files', auth()->user()))
<a href="{{ route('admin.files.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_files') }}</a>
@endif
@if (auth()->user()->isStaff())
<a href="{{ route('admin.locations.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_locations') }}</a>
<div class="border-t border-gray-700 mt-1 pt-1">
<span class="block py-1 text-xs text-gray-500 uppercase tracking-wider">{{ __('admin.nav_verwaltung') }}</span>
<a href="{{ route('admin.settings.edit') }}" class="block py-2 text-sm text-gray-300 pl-3 rtl:pr-3 rtl:pl-0">{{ __('admin.nav_settings') }}</a>
@if (auth()->user()->isAdmin())
<a href="{{ route('admin.administration.index') }}" class="block py-2 text-sm text-gray-300 pl-3 rtl:pr-3 rtl:pl-0">{{ __('admin.nav_administration') }}</a>
@endif
@if (\App\Models\Setting::isFeatureVisibleFor('invitations', auth()->user()))
<a href="{{ route('admin.invitations.index') }}" class="block py-2 text-sm text-gray-300 pl-3 rtl:pr-3 rtl:pl-0">{{ __('admin.nav_invitations') }}</a>
@endif
@if (\App\Models\Setting::isFeatureVisibleFor('list_generator', auth()->user()))
<a href="{{ route('admin.list-generator.create') }}" class="block py-2 text-sm text-gray-300 pl-3 rtl:pr-3 rtl:pl-0">{{ __('admin.nav_list_generator') }}</a>
@endif
<a href="{{ route('admin.support.index') }}" class="block py-2 text-sm text-gray-300 pl-3 rtl:pr-3 rtl:pl-0">{{ __('admin.nav_support') }}</a>
@if (auth()->user()->canViewActivityLog())
<a href="{{ route('admin.activity-logs.index') }}" class="block py-2 text-sm text-gray-300 pl-3 rtl:pr-3 rtl:pl-0">{{ __('admin.nav_activity_log') }}</a>

View File

@@ -226,7 +226,7 @@
</div>
{{-- Catering --}}
@if ($event->type->hasCatering())
@if (\App\Models\Setting::isFeatureEnabled('catering') && $event->type->hasCatering())
<div id="catering" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-3">{{ __('events.catering') }}</h2>
@@ -313,7 +313,7 @@
@endif
{{-- Zeitnehmer --}}
@if ($event->type->hasTimekeepers())
@if (\App\Models\Setting::isFeatureEnabled('timekeepers') && $event->type->hasTimekeepers())
<div id="timekeeper" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-3">{{ __('events.timekeeper') }}</h2>
@@ -391,7 +391,7 @@
@endif
{{-- Fahrgemeinschaften --}}
@if ($event->type->hasCarpool())
@if (\App\Models\Setting::isFeatureEnabled('carpools') && $event->type->hasCarpool())
<div id="carpool" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-3">{{ __('events.carpool') }}</h2>
@@ -534,6 +534,7 @@
@endif
{{-- Kommentare --}}
@if (\App\Models\Setting::isFeatureEnabled('comments'))
<div id="comments" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-3">{{ __('events.comments') }}</h2>
@@ -588,6 +589,7 @@
</div>
@endif
</div>
@endif
<div class="mb-6">
<a href="{{ route('events.index') }}" class="text-sm text-blue-600 hover:underline">&larr; {{ __('events.back_to_list') }}</a>

View File

@@ -26,6 +26,7 @@ use App\Http\Controllers\Admin\CommentController as AdminCommentController;
use App\Http\Controllers\Admin\GeocodingController;
use App\Http\Controllers\Admin\ActivityLogController;
use App\Http\Controllers\Admin\LocationController;
use App\Http\Controllers\Admin\AdministrationController;
use App\Http\Controllers\Admin\SettingsController;
use App\Http\Controllers\Admin\ListGeneratorController;
use App\Http\Controllers\Admin\StatisticsController;
@@ -236,10 +237,15 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
// Einstellungen
Route::get('settings', [SettingsController::class, 'edit'])->name('settings.edit');
Route::put('settings', [SettingsController::class, 'update'])->name('settings.update');
Route::put('settings/mail', [SettingsController::class, 'updateMail'])->name('settings.update-mail');
Route::post('settings/test-mail', [SettingsController::class, 'testMail'])->name('settings.test-mail');
Route::delete('settings/demo-data', [SettingsController::class, 'destroyDemoData'])->name('settings.destroy-demo-data')->middleware('throttle:5,1');
Route::delete('settings/factory-reset', [SettingsController::class, 'factoryReset'])->name('settings.factory-reset')->middleware('throttle:3,1');
// Administration
Route::get('administration', [AdministrationController::class, 'index'])->name('administration.index');
Route::put('administration/features', [AdministrationController::class, 'updateFeatures'])->name('administration.update-features');
Route::put('administration/license', [AdministrationController::class, 'updateLicense'])->name('administration.update-license');
Route::put('administration/mail', [AdministrationController::class, 'updateMail'])->name('administration.update-mail');
Route::post('administration/test-mail', [AdministrationController::class, 'testMail'])->name('administration.test-mail');
Route::delete('administration/demo-data', [AdministrationController::class, 'destroyDemoData'])->name('administration.destroy-demo-data')->middleware('throttle:5,1');
Route::delete('administration/factory-reset', [AdministrationController::class, 'factoryReset'])->name('administration.factory-reset')->middleware('throttle:3,1');
// Saisons
Route::post('seasons', [SeasonController::class, 'store'])->name('seasons.store');