From 8ccadbe89f2092ffc2b7643e0cbfdad58fb2ab97 Mon Sep 17 00:00:00 2001 From: Rhino Date: Tue, 3 Mar 2026 08:38:45 +0100 Subject: [PATCH] Feature-Toggles, Administration, wiederkehrende Events und Event-Serien MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Admin/AdministrationController.php | 345 +++++++++++ .../Controllers/Admin/CommentController.php | 5 + .../Controllers/Admin/EventController.php | 160 ++++- app/Http/Controllers/Admin/FileController.php | 17 + .../Admin/InvitationController.php | 17 + .../Admin/ListGeneratorController.php | 9 + .../Controllers/Admin/SettingsController.php | 268 +-------- app/Models/Event.php | 13 + app/Models/Setting.php | 15 +- ..._01_000000_add_feature_toggle_settings.php | 81 +++ .../0042_01_01_000000_add_event_series_id.php | 23 + database/seeders/DemoDataSeeder.php | 28 +- database/seeders/SettingsSeeder.php | 42 ++ lang/ar/admin.php | 53 ++ lang/de/admin.php | 53 ++ lang/en/admin.php | 53 ++ lang/pl/admin.php | 53 ++ lang/ru/admin.php | 53 ++ lang/tr/admin.php | 53 ++ .../admin/administration/index.blade.php | 558 ++++++++++++++++++ resources/views/admin/events/create.blade.php | 104 ++++ resources/views/admin/events/edit.blade.php | 164 ++++- resources/views/admin/events/index.blade.php | 34 +- resources/views/admin/settings/edit.blade.php | 411 +------------ .../views/components/layouts/admin.blade.php | 32 +- resources/views/events/show.blade.php | 8 +- routes/web.php | 14 +- 27 files changed, 1968 insertions(+), 698 deletions(-) create mode 100644 app/Http/Controllers/Admin/AdministrationController.php create mode 100644 database/migrations/0041_01_01_000000_add_feature_toggle_settings.php create mode 100644 database/migrations/0042_01_01_000000_add_event_series_id.php create mode 100644 resources/views/admin/administration/index.blade.php diff --git a/app/Http/Controllers/Admin/AdministrationController.php b/app/Http/Controllers/Admin/AdministrationController.php new file mode 100644 index 0000000..352d389 --- /dev/null +++ b/app/Http/Controllers/Admin/AdministrationController.php @@ -0,0 +1,345 @@ +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); + } +} diff --git a/app/Http/Controllers/Admin/CommentController.php b/app/Http/Controllers/Admin/CommentController.php index 33b566d..4fb42aa 100755 --- a/app/Http/Controllers/Admin/CommentController.php +++ b/app/Http/Controllers/Admin/CommentController.php @@ -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(); diff --git a/app/Http/Controllers/Admin/EventController.php b/app/Http/Controllers/Admin/EventController.php index 1e77612..0d38d77 100755 --- a/app/Http/Controllers/Admin/EventController.php +++ b/app/Http/Controllers/Admin/EventController.php @@ -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); + } } diff --git a/app/Http/Controllers/Admin/FileController.php b/app/Http/Controllers/Admin/FileController.php index fcd7e66..dc5bcef 100644 --- a/app/Http/Controllers/Admin/FileController.php +++ b/app/Http/Controllers/Admin/FileController.php @@ -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); diff --git a/app/Http/Controllers/Admin/InvitationController.php b/app/Http/Controllers/Admin/InvitationController.php index 1556132..e1cf843 100755 --- a/app/Http/Controllers/Admin/InvitationController.php +++ b/app/Http/Controllers/Admin/InvitationController.php @@ -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')); } diff --git a/app/Http/Controllers/Admin/ListGeneratorController.php b/app/Http/Controllers/Admin/ListGeneratorController.php index d4b7279..5b82b41 100644 --- a/app/Http/Controllers/Admin/ListGeneratorController.php +++ b/app/Http/Controllers/Admin/ListGeneratorController.php @@ -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', diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php index e08aaf3..0093359 100755 --- a/app/Http/Controllers/Admin/SettingsController.php +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -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'); - } } diff --git a/app/Models/Event.php b/app/Models/Event.php index af1b0c3..548c775 100755 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -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'); + } } diff --git a/app/Models/Setting.php b/app/Models/Setting.php index e3daffe..a9aea17 100755 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -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; } diff --git a/database/migrations/0041_01_01_000000_add_feature_toggle_settings.php b/database/migrations/0041_01_01_000000_add_feature_toggle_settings.php new file mode 100644 index 0000000..c17dbb2 --- /dev/null +++ b/database/migrations/0041_01_01_000000_add_feature_toggle_settings.php @@ -0,0 +1,81 @@ + '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(); + } + } + } +}; diff --git a/database/migrations/0042_01_01_000000_add_event_series_id.php b/database/migrations/0042_01_01_000000_add_event_series_id.php new file mode 100644 index 0000000..4896f83 --- /dev/null +++ b/database/migrations/0042_01_01_000000_add_event_series_id.php @@ -0,0 +1,23 @@ +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'); + }); + } +}; diff --git a/database/seeders/DemoDataSeeder.php b/database/seeders/DemoDataSeeder.php index af5920e..e07232a 100755 --- a/database/seeders/DemoDataSeeder.php +++ b/database/seeders/DemoDataSeeder.php @@ -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' => '

Training mit Schwerpunkt Passspiel. Bitte pünktlich kommen!

', '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' => '

Wöchentliches Training. Bitte Hallenschuhe und ausreichend Trinken mitbringen.

', + 'created_by' => $admin->id, + ] + ); + } + return $events; } diff --git a/database/seeders/SettingsSeeder.php b/database/seeders/SettingsSeeder.php index db78555..9f6f7a9 100755 --- a/database/seeders/SettingsSeeder.php +++ b/database/seeders/SettingsSeeder.php @@ -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) { diff --git a/lang/ar/admin.php b/lang/ar/admin.php index 47eb871..0df94a0 100755 --- a/lang/ar/admin.php +++ b/lang/ar/admin.php @@ -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 أحداث تالية.', ]; diff --git a/lang/de/admin.php b/lang/de/admin.php index 57801df..321b4d8 100755 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -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.', ]; diff --git a/lang/en/admin.php b/lang/en/admin.php index cf7afac..6f520e5 100755 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -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.', ]; diff --git a/lang/pl/admin.php b/lang/pl/admin.php index d046b3e..2ba8d4d 100755 --- a/lang/pl/admin.php +++ b/lang/pl/admin.php @@ -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.', ]; diff --git a/lang/ru/admin.php b/lang/ru/admin.php index 35a151a..b09e86d 100755 --- a/lang/ru/admin.php +++ b/lang/ru/admin.php @@ -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 последующих обновлены.', ]; diff --git a/lang/tr/admin.php b/lang/tr/admin.php index 4fb704a..a352875 100755 --- a/lang/tr/admin.php +++ b/lang/tr/admin.php @@ -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.', ]; diff --git a/resources/views/admin/administration/index.blade.php b/resources/views/admin/administration/index.blade.php new file mode 100644 index 0000000..1b31457 --- /dev/null +++ b/resources/views/admin/administration/index.blade.php @@ -0,0 +1,558 @@ + +
+

{{ __('admin.admin_title') }}

+ + {{-- Tab Navigation --}} +
+ +
+ + {{-- ============================================================ --}} + {{-- Tab: Rollenmanagement --}} + {{-- ============================================================ --}} +
+
+ @csrf + @method('PUT') + +
+

{{ __('admin.features_description') }}

+ + @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 + +
+ @foreach ($features as $featureKey => $featureLabel) + @php + $globalKey = "feature_{$featureKey}"; + $globalValue = $featureSettings[$globalKey]->value ?? '1'; + @endphp +
+ {{-- Global Toggle --}} +
+

{{ $featureLabel }}

+ +
+ + {{-- Pro-Rolle Toggles --}} +
+ @foreach ($roles as $roleKey => $roleLabel) + @php + $settingKey = "visibility_{$featureKey}_{$roleKey}"; + $currentValue = $visibilitySettings[$settingKey]->value ?? '1'; + @endphp + + @endforeach +
+
+ @endforeach +
+
+ +
+ + + {{ __('ui.cancel') }} + +
+
+
+ + {{-- ============================================================ --}} + {{-- Tab: E-Mail --}} + {{-- ============================================================ --}} +
+
+ @csrf + @method('PUT') + +
+

{{ __('admin.mail_config_title') }}

+

{{ __('admin.mail_config_hint') }}

+ + {{-- Versandmethode --}} +
+ +
+ + +
+
+ + {{-- SMTP-Felder --}} +
+
+
+ + + @error('mail_host')

{{ $message }}

@enderror +
+
+ + + @error('mail_port')

{{ $message }}

@enderror +
+
+
+
+ + + @error('mail_username')

{{ $message }}

@enderror +
+
+ + + @error('mail_password')

{{ $message }}

@enderror +
+
+
+
+ + + @error('mail_from_address')

{{ $message }}

@enderror +
+
+ + +
+
+
+ + +
+ + {{-- SMTP-Test --}} +
+ +

+
+
+
+ +
+ +
+
+
+ + {{-- ============================================================ --}} + {{-- Tab: Lizenz & Support --}} + {{-- ============================================================ --}} +
+ {{-- License Key --}} +
+ @csrf + @method('PUT') + +
+

{{ __('admin.license_title') }}

+

{{ __('admin.license_description') }}

+ + + + + {{-- Registration Status --}} +
+

{{ __('admin.registration_status') }}

+ @if ($isRegistered) +
+ + + + {{ __('admin.registration_active') }} +
+

Installation-ID: {{ $installationId }}

+ @else +
+ + + + {{ __('admin.registration_inactive') }} +
+ @endif +
+ +
+ +
+
+
+ + {{-- Registration (nur wenn nicht registriert) --}} + @if (!$isRegistered) +
+

{{ __('admin.support_not_registered') }}

+
+ @csrf + +
+
+ @endif + + {{-- System Info --}} +
+

{{ __('admin.version_info') }}

+
+
App-Version:
+
{{ config('app.version') }}
+
PHP:
+
{{ PHP_VERSION }}
+
Laravel:
+
{{ app()->version() }}
+
Datenbank:
+
{{ config('database.default') }}
+
+ + @if ($updateInfo && version_compare($updateInfo['latest_version'] ?? '0', config('app.version'), '>')) +
+

+ {{ __('admin.update_available', ['version' => $updateInfo['latest_version']]) }} +

+ @if ($updateInfo['changelog'] ?? null) +

{{ $updateInfo['changelog'] }}

+ @endif + @if (($updateInfo['download_url'] ?? null) && str_starts_with($updateInfo['download_url'], 'https://')) + + {{ __('admin.download_update') }} + + @endif +
+ @endif +
+
+ + {{-- ============================================================ --}} + {{-- Tab: Wartung --}} + {{-- ============================================================ --}} +
+
+

{{ __('admin.demo_data_delete_title') }}

+

{{ __('admin.demo_data_delete_description') }}

+ +
+
+

{{ __('admin.demo_data_deletes') }}

+
    +
  • {{ __('admin.stat_users') }} ({{ __('admin.demo_data_except_admin') }})
  • +
  • {{ __('admin.nav_teams') }}
  • +
  • {{ __('admin.nav_players') }}
  • +
  • {{ __('admin.nav_events') }}
  • +
  • Kommentare
  • +
  • {{ __('admin.nav_locations') }}
  • +
  • {{ __('admin.nav_files') }}
  • +
  • {{ __('admin.activity_log_title') }}
  • +
+
+
+

{{ __('admin.demo_data_keeps') }}

+
    +
  • {{ __('admin.demo_data_keeps_admin') }}
  • +
  • {{ __('admin.nav_settings') }}
  • +
  • {{ __('admin.settings_tab_categories') }}
  • +
+
+
+ +
+

{{ __('admin.demo_data_delete_warning') }}

+
+ +
+ @csrf + @method('DELETE') +
+ + +
+ +
+
+ + {{-- Factory Reset --}} +
+

{{ __('admin.factory_reset_title') }}

+

{{ __('admin.factory_reset_description') }}

+ +
+

{{ __('admin.factory_reset_deletes') }}

+
    +
  • {{ __('admin.factory_reset_item_users') }}
  • +
  • {{ __('admin.factory_reset_item_data') }}
  • +
  • {{ __('admin.factory_reset_item_settings') }}
  • +
  • {{ __('admin.factory_reset_item_files') }}
  • +
+
+ +
+

{{ __('admin.factory_reset_warning') }}

+
+ +
+ @csrf + @method('DELETE') +
+ + +
+
+ + +

{{ __('admin.factory_reset_confirmation_hint') }}

+
+ +
+
+
+ + {{-- ============================================================ --}} + {{-- Tab: Aktivitätslog --}} + {{-- ============================================================ --}} +
+
+
+

{{ __('admin.activity_recent') }}

+ + {{ __('admin.activity_log_title') }} → + +
+ +
+ + + + + + + + + + + @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 + + + + + + + @empty + + + + @endforelse + +
{{ __('admin.log_time') }}{{ __('admin.log_user') }}{{ __('admin.log_action') }}{{ __('admin.log_description') }}
{{ $log->created_at->format('d.m. H:i') }}{{ $log->user?->name ?? __('admin.log_system') }} + {{ $log->action }} + {{ $log->description }}
{{ __('admin.log_empty') }}
+
+
+
+
+ + @push('scripts') + + @endpush +
diff --git a/resources/views/admin/events/create.blade.php b/resources/views/admin/events/create.blade.php index e4533de..b4b5483 100755 --- a/resources/views/admin/events/create.blade.php +++ b/resources/views/admin/events/create.blade.php @@ -91,6 +91,61 @@ @error('status')

{{ $message }}

@enderror + {{-- Wiederholung (nur Training) --}} +
+
+

{{ __('admin.recurrence') }}

+ +
+ + +
+ +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ {{ __('admin.recurrence_max_warning', ['max' => 52]) }} +
+
+
+ + + + + +
+ {{-- Mindestanforderungen --}}
@@ -105,6 +160,7 @@ @endfor
+ @if (\App\Models\Setting::isFeatureEnabled('catering'))
+ @endif + @if (\App\Models\Setting::isFeatureEnabled('timekeepers'))
+ @endif
@@ -184,6 +243,7 @@ {{-- Catering/Zeitnehmer-Zuweisungen (nicht für away_game/meeting) --}} + @if (\App\Models\Setting::isFeatureEnabled('catering') || \App\Models\Setting::isFeatureEnabled('timekeepers'))
+ @endif {{-- Dateien --}}
@@ -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); diff --git a/resources/views/admin/events/edit.blade.php b/resources/views/admin/events/edit.blade.php index 925528e..b20848f 100755 --- a/resources/views/admin/events/edit.blade.php +++ b/resources/views/admin/events/edit.blade.php @@ -16,6 +16,12 @@ @csrf @method('PUT') + @if ($event->isPartOfSeries()) +
+ {{ __('admin.series_hint', ['count' => $event->followingSeriesEvents()->count()]) }} +
+ @endif +
@@ -92,6 +98,61 @@ @error('status')

{{ $message }}

@enderror
+ {{-- Wiederholung (nur Training) --}} +
+
+

{{ __('admin.recurrence') }}

+ +
+ + +
+ +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ {{ __('admin.recurrence_max_warning', ['max' => 52]) }} +
+
+
+ + + + + +
+ {{-- Mindestanforderungen --}}
@@ -106,6 +167,7 @@ @endfor
+ @if (\App\Models\Setting::isFeatureEnabled('catering'))
+ @endif + @if (\App\Models\Setting::isFeatureEnabled('timekeepers'))
+ @endif
@@ -185,6 +250,7 @@ {{-- Catering/Zeitnehmer-Zuweisungen (nicht für away_game/meeting) --}} + @if (\App\Models\Setting::isFeatureEnabled('catering') || \App\Models\Setting::isFeatureEnabled('timekeepers'))
+ @endif {{-- Dateien --}}
@@ -277,9 +352,57 @@
-
- +
+ @if ($event->isPartOfSeries() && $event->followingSeriesEvents()->count() > 0) + {{-- Serien-Event: Modal-Dialog beim Speichern --}} +
+ +
+
+

{{ __('admin.save_series_title') }}

+

{{ __('admin.save_series_description', ['count' => $event->followingSeriesEvents()->count()]) }}

+
+ + + +
+
+
+
+ @else + + @endif {{ __('ui.cancel') }} + + {{-- Löschen-Button --}} + @if ($event->isPartOfSeries() && $event->followingSeriesEvents()->count() > 0) +
+ +
+
+

{{ __('admin.delete_series_title') }}

+

{{ __('admin.delete_series_description') }}

+
+
+ @csrf @method('DELETE') + +
+
+ @csrf @method('DELETE') + + +
+ +
+
+
+
+ @else +
+ @csrf @method('DELETE') + +
+ @endif
@@ -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); diff --git a/resources/views/admin/events/index.blade.php b/resources/views/admin/events/index.blade.php index 2acca47..a37da11 100755 --- a/resources/views/admin/events/index.blade.php +++ b/resources/views/admin/events/index.blade.php @@ -74,11 +74,35 @@
{{ __('ui.edit') }} -
- @csrf - @method('DELETE') - -
+ @if ($event->isPartOfSeries()) +
+ +
+
+

{{ __('admin.delete_series_title') }}

+

{{ __('admin.delete_series_description') }}

+
+
+ @csrf @method('DELETE') + +
+
+ @csrf @method('DELETE') + + +
+ +
+
+
+
+ @else +
+ @csrf + @method('DELETE') + +
+ @endif
diff --git a/resources/views/admin/settings/edit.blade.php b/resources/views/admin/settings/edit.blade.php index af484c3..9caa8c3 100755 --- a/resources/views/admin/settings/edit.blade.php +++ b/resources/views/admin/settings/edit.blade.php @@ -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') }} - - - - @endif @@ -63,7 +43,7 @@
{{-- 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')
- {{-- Tab: E-Mail --}} -
-
- @csrf - @method('PUT') - -
-

{{ __('admin.mail_config_title') }}

-

{{ __('admin.mail_config_hint') }}

- - {{-- Versandmethode --}} -
- -
- - -
-
- - {{-- SMTP-Felder --}} -
-
-
- - - @error('mail_host')

{{ $message }}

@enderror -
-
- - - @error('mail_port')

{{ $message }}

@enderror -
-
-
-
- - - @error('mail_username')

{{ $message }}

@enderror -
-
- - - @error('mail_password')

{{ $message }}

@enderror -
-
-
-
- - - @error('mail_from_address')

{{ $message }}

@enderror -
-
- - -
-
-
- - -
- - {{-- SMTP-Test --}} -
- -

-
-
-
- -
- -
-
-
- {{-- Tab: Rechtliches — Multi-Language mit Flaggen --}}
{{-- Sprach-Flaggen-Leiste --}} @@ -556,126 +383,8 @@
@endif - {{-- Tab: Sichtbarkeit (nur Admin) --}} - @if (auth()->user()->isAdmin()) -
-
-

{{ __('admin.visibility_description') }}

- - @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 - -
- @foreach ($features as $featureKey => $featureLabel) -
-

{{ $featureLabel }}

-
- @foreach ($roles as $roleKey => $roleLabel) - @php - $settingKey = "visibility_{$featureKey}_{$roleKey}"; - $currentValue = $visibilitySettings[$settingKey]->value ?? '1'; - @endphp - - @endforeach -
-
- @endforeach -
-
-
- @endif - - {{-- Tab: Lizenz & Support (nur Admin) --}} - @if (auth()->user()->isAdmin()) -
- {{-- License Key --}} -
-

{{ __('admin.license_title') }}

-

{{ __('admin.license_description') }}

- - - - - {{-- Registration Status --}} -
-

{{ __('admin.registration_status') }}

- @if ($isRegistered) -
- - - - {{ __('admin.registration_active') }} -
-

Installation-ID: {{ $installationId }}

- @else -
- - - - {{ __('admin.registration_inactive') }} -
- @endif -
-
- - {{-- System Info --}} -
-

{{ __('admin.version_info') }}

-
-
App-Version:
-
{{ config('app.version') }}
-
PHP:
-
{{ PHP_VERSION }}
-
Laravel:
-
{{ app()->version() }}
-
Datenbank:
-
{{ config('database.default') }}
-
- - @if ($updateInfo && version_compare($updateInfo['latest_version'] ?? '0', config('app.version'), '>')) -
-

- {{ __('admin.update_available', ['version' => $updateInfo['latest_version']]) }} -

- @if ($updateInfo['changelog'] ?? null) -

{{ $updateInfo['changelog'] }}

- @endif - @if (($updateInfo['download_url'] ?? null) && str_starts_with($updateInfo['download_url'], 'https://')) - - {{ __('admin.download_update') }} - - @endif -
- @endif -
- -
- @endif - - {{-- Save/Cancel (sichtbar auf allen Form-Tabs, nicht auf Wartung) --}} -
+ {{-- Save/Cancel (sichtbar auf allen Form-Tabs) --}} +
@@ -685,118 +394,6 @@
- {{-- Registration (außerhalb der Settings-Form, nur auf Lizenz-Tab) --}} - @if (auth()->user()->isAdmin() && !$isRegistered) -
-
-

{{ __('admin.support_not_registered') }}

-
- @csrf - -
-
-
- @endif - - {{-- Tab: Wartung (nur Admin, eigenes Formular) --}} - @if (auth()->user()->isAdmin()) -
-
-

{{ __('admin.demo_data_delete_title') }}

-

{{ __('admin.demo_data_delete_description') }}

- -
-
-

{{ __('admin.demo_data_deletes') }}

-
    -
  • {{ __('admin.stat_users') }} ({{ __('admin.demo_data_except_admin') }})
  • -
  • {{ __('admin.nav_teams') }}
  • -
  • {{ __('admin.nav_players') }}
  • -
  • {{ __('admin.nav_events') }}
  • -
  • Kommentare
  • -
  • {{ __('admin.nav_locations') }}
  • -
  • {{ __('admin.nav_files') }}
  • -
  • {{ __('admin.activity_log_title') }}
  • -
-
-
-

{{ __('admin.demo_data_keeps') }}

-
    -
  • {{ __('admin.demo_data_keeps_admin') }}
  • -
  • {{ __('admin.nav_settings') }}
  • -
  • {{ __('admin.settings_tab_categories') }}
  • -
-
-
- -
-

{{ __('admin.demo_data_delete_warning') }}

-
- -
- @csrf - @method('DELETE') -
- - -
- -
-
- - {{-- Factory Reset (nur Admin) --}} - @if (auth()->user()->isAdmin()) -
-

{{ __('admin.factory_reset_title') }}

-

{{ __('admin.factory_reset_description') }}

- -
-

{{ __('admin.factory_reset_deletes') }}

-
    -
  • {{ __('admin.factory_reset_item_users') }}
  • -
  • {{ __('admin.factory_reset_item_data') }}
  • -
  • {{ __('admin.factory_reset_item_settings') }}
  • -
  • {{ __('admin.factory_reset_item_files') }}
  • -
-
- -
-

{{ __('admin.factory_reset_warning') }}

-
- -
- @csrf - @method('DELETE') -
- - -
-
- - -

{{ __('admin.factory_reset_confirmation_hint') }}

-
- -
-
- @endif -
- @endif - {{-- Tab: Dateikategorien (eigene Formulare) --}}
@@ -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'); diff --git a/resources/views/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php index 32aab70..a5023cd 100755 --- a/resources/views/components/layouts/admin.blade.php +++ b/resources/views/components/layouts/admin.blade.php @@ -48,12 +48,14 @@ {{ __('admin.nav_players') }} {{ __('admin.nav_users') }} @endif - {{ __('admin.nav_files') }} + @if (\App\Models\Setting::isFeatureVisibleFor('files', auth()->user())) + {{ __('admin.nav_files') }} + @endif @if (auth()->user()->isStaff()) {{ __('admin.nav_locations') }} {{-- Verwaltung-Dropdown --}}
-
{{ __('admin.nav_settings') }} - {{ __('admin.nav_invitations') }} - {{ __('admin.nav_list_generator') }} + @if (auth()->user()->isAdmin()) + {{ __('admin.nav_administration') }} + @endif + @if (\App\Models\Setting::isFeatureVisibleFor('invitations', auth()->user())) + {{ __('admin.nav_invitations') }} + @endif + @if (\App\Models\Setting::isFeatureVisibleFor('list_generator', auth()->user())) + {{ __('admin.nav_list_generator') }} + @endif {{ __('admin.nav_support') }} @if (auth()->user()->canViewActivityLog()) {{ __('admin.nav_activity_log') }} @@ -117,14 +126,23 @@ {{ __('admin.nav_players') }} {{ __('admin.nav_users') }} @endif - {{ __('admin.nav_files') }} + @if (\App\Models\Setting::isFeatureVisibleFor('files', auth()->user())) + {{ __('admin.nav_files') }} + @endif @if (auth()->user()->isStaff()) {{ __('admin.nav_locations') }}
{{ __('admin.nav_verwaltung') }} {{ __('admin.nav_settings') }} - {{ __('admin.nav_invitations') }} - {{ __('admin.nav_list_generator') }} + @if (auth()->user()->isAdmin()) + {{ __('admin.nav_administration') }} + @endif + @if (\App\Models\Setting::isFeatureVisibleFor('invitations', auth()->user())) + {{ __('admin.nav_invitations') }} + @endif + @if (\App\Models\Setting::isFeatureVisibleFor('list_generator', auth()->user())) + {{ __('admin.nav_list_generator') }} + @endif {{ __('admin.nav_support') }} @if (auth()->user()->canViewActivityLog()) {{ __('admin.nav_activity_log') }} diff --git a/resources/views/events/show.blade.php b/resources/views/events/show.blade.php index 2c8de5b..d9e26d2 100755 --- a/resources/views/events/show.blade.php +++ b/resources/views/events/show.blade.php @@ -226,7 +226,7 @@
{{-- Catering --}} - @if ($event->type->hasCatering()) + @if (\App\Models\Setting::isFeatureEnabled('catering') && $event->type->hasCatering())

{{ __('events.catering') }}

@@ -313,7 +313,7 @@ @endif {{-- Zeitnehmer --}} - @if ($event->type->hasTimekeepers()) + @if (\App\Models\Setting::isFeatureEnabled('timekeepers') && $event->type->hasTimekeepers())

{{ __('events.timekeeper') }}

@@ -391,7 +391,7 @@ @endif {{-- Fahrgemeinschaften --}} - @if ($event->type->hasCarpool()) + @if (\App\Models\Setting::isFeatureEnabled('carpools') && $event->type->hasCarpool())

{{ __('events.carpool') }}

@@ -534,6 +534,7 @@ @endif {{-- Kommentare --}} + @if (\App\Models\Setting::isFeatureEnabled('comments'))

{{ __('events.comments') }}

@@ -588,6 +589,7 @@
@endif
+ @endif
← {{ __('events.back_to_list') }} diff --git a/routes/web.php b/routes/web.php index 283f957..625d069 100755 --- a/routes/web.php +++ b/routes/web.php @@ -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');