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:
345
app/Http/Controllers/Admin/AdministrationController.php
Normal file
345
app/Http/Controllers/Admin/AdministrationController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 أحداث تالية.',
|
||||
];
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
@@ -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 последующих обновлены.',
|
||||
];
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
558
resources/views/admin/administration/index.blade.php
Normal file
558
resources/views/admin/administration/index.blade.php
Normal 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Ä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') }} →
|
||||
</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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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Ä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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">← {{ __('events.back_to_list') }}</a>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user