Stand: SMTP-Test, Admin-Mail-Tab, Notifiable-Fix, Lazy-Quill

- Fix: Notifiable-Trait zum User-Model hinzugefuegt (behebt notify()-500er)
- Installer: SMTP-Verbindungstest mit EsmtpTransport + Ueberspringen-Link
- Admin: Neuer E-Mail-Tab mit SMTP-Konfiguration + Verbindungstest
- Admin: Lazy Quill-Initialisierung (nur sichtbare Locale wird geladen)
- Uebersetzungen: 17 neue Mail-Keys in allen 6 Sprachen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rhino
2026-03-02 07:30:37 +01:00
commit 2e24a40d68
9633 changed files with 1300799 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
<x-layouts.admin :title="__('admin.activity_log_title')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.activity_log_title') }}</h1>
{{-- Filter --}}
<form method="GET" class="bg-white rounded-lg shadow p-4 mb-6">
<div class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.log_category') }}</label>
<select name="category" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="">{{ __('admin.log_all_categories') }}</option>
<option value="auth" {{ request('category') === 'auth' ? 'selected' : '' }}>{{ __('admin.log_cat_auth') }}</option>
<option value="users" {{ request('category') === 'users' ? 'selected' : '' }}>{{ __('admin.log_cat_users') }}</option>
<option value="players" {{ request('category') === 'players' ? 'selected' : '' }}>{{ __('admin.log_cat_players') }}</option>
<option value="events" {{ request('category') === 'events' ? 'selected' : '' }}>{{ __('admin.log_cat_events') }}</option>
<option value="files" {{ request('category') === 'files' ? 'selected' : '' }}>{{ __('admin.log_cat_files') }}</option>
<option value="settings" {{ request('category') === 'settings' ? 'selected' : '' }}>{{ __('admin.log_cat_settings') }}</option>
<option value="dsgvo" {{ request('category') === 'dsgvo' ? 'selected' : '' }}>{{ __('admin.log_cat_dsgvo') }}</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.log_from') }}</label>
<input type="date" name="from" value="{{ request('from') }}" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.log_to') }}</label>
<input type="date" name="to" value="{{ request('to') }}" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div class="flex gap-2">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700">{{ __('admin.log_filter') }}</button>
<a href="{{ route('admin.activity-logs.index') }}" class="bg-gray-200 text-gray-700 px-4 py-2 rounded-md text-sm font-medium hover:bg-gray-300">{{ __('admin.log_reset') }}</a>
</div>
</div>
</form>
{{-- Tabelle --}}
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.log_time') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.log_user') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.log_action') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.log_description') }}</th>
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.log_ip') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($logs 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',
'parent_assigned' => 'bg-blue-100 text-blue-800',
'parent_removed' => 'bg-red-100 text-red-800',
'participant_status_changed' => 'bg-yellow-100 text-yellow-800',
'status_changed' => 'bg-yellow-100 text-yellow-800',
'uploaded' => 'bg-blue-100 text-blue-800',
'reverted' => 'bg-orange-100 text-orange-800',
'bot_blocked' => 'bg-red-100 text-red-800',
'dsgvo_consent_uploaded' => 'bg-blue-100 text-blue-800',
'dsgvo_consent_confirmed' => 'bg-green-100 text-green-800',
'dsgvo_consent_revoked' => 'bg-yellow-100 text-yellow-800',
'dsgvo_consent_removed' => 'bg-orange-100 text-orange-800',
'dsgvo_consent_rejected' => 'bg-red-100 text-red-800',
'account_self_deleted' => 'bg-red-100 text-red-800',
'child_auto_deactivated' => 'bg-red-100 text-red-800',
];
$color = $actionColors[$log->action] ?? 'bg-gray-100 text-gray-800';
$hasDetails = !empty($log->properties);
@endphp
<tr class="hover:bg-gray-50" x-data="{ open: false }">
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">{{ $log->created_at->format('d.m.Y H:i') }}</td>
<td class="px-4 py-3 text-gray-900">{{ $log->user?->name ?? __('admin.log_system') }}</td>
<td class="px-4 py-3 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-4 py-3 text-gray-600">
<div>{{ $log->description }}</div>
@if ($hasDetails)
<button @click="open = !open" class="text-xs text-blue-600 hover:text-blue-800 mt-1 flex items-center gap-1">
<svg class="w-3 h-3 transition-transform" :class="open ? 'rotate-90' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ __('admin.log_details') }}
</button>
<div x-show="open" x-cloak x-transition class="mt-2 bg-gray-50 rounded-md p-3 text-xs space-y-1">
@php
$oldData = $log->properties['old'] ?? [];
$newData = $log->properties['new'] ?? [];
$allKeys = array_unique(array_merge(array_keys($oldData), array_keys($newData)));
@endphp
@if (count($allKeys) > 0)
<table class="w-full">
<thead>
<tr class="text-gray-500">
<th class="text-left font-medium pr-3 pb-1">{{ __('admin.log_field') }}</th>
<th class="text-left font-medium pr-3 pb-1">{{ __('admin.log_old_value') }}</th>
<th class="text-left font-medium pb-1">{{ __('admin.log_new_value') }}</th>
</tr>
</thead>
<tbody>
@foreach ($allKeys as $key)
<tr>
<td class="pr-3 py-0.5 font-medium text-gray-600">{{ $key }}</td>
<td class="pr-3 py-0.5 {{ isset($oldData[$key]) ? 'text-red-600 line-through' : 'text-gray-400' }}">{{ $oldData[$key] ?? '' }}</td>
<td class="py-0.5 {{ isset($newData[$key]) ? 'text-green-700' : 'text-gray-400' }}">{{ $newData[$key] ?? '' }}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
@endif
</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<span class="text-gray-400 text-xs">{{ $log->ip_address }}</span>
@php
$revertableActions = ['deleted', 'toggled_active', 'role_changed', 'status_changed', 'participant_status_changed'];
$canRevert = in_array($log->action, $revertableActions) && $log->model_id;
@endphp
@if ($canRevert)
<form method="POST" action="{{ route('admin.activity-logs.revert', $log) }}"
onsubmit="return confirm(@js(__('admin.log_revert_confirm')))" class="inline ml-2">
@csrf
<button type="submit" class="text-xs text-orange-600 hover:text-orange-800 font-medium">
{{ __('admin.log_revert') }}
</button>
</form>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-8 text-center text-gray-500">{{ __('admin.log_empty') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">{{ $logs->links() }}</div>
</x-layouts.admin>

View File

@@ -0,0 +1,198 @@
<x-layouts.admin :title="__('admin.dashboard_title')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.dashboard_title') }}</h1>
{{-- Update-Hinweis --}}
@if ($hasUpdate ?? false)
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-3">
<svg class="w-6 h-6 text-blue-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<p class="text-sm font-medium text-blue-800">{{ __('admin.update_available', ['version' => $updateVersion]) }}</p>
<a href="{{ route('admin.settings.edit') }}#license" class="text-xs text-blue-600 underline">{{ __('admin.update_details') }}</a>
</div>
</div>
@endif
{{-- Statistik-Karten --}}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">{{ __('admin.stat_users') }}</p>
<p class="text-2xl font-bold text-gray-900">{{ $stats['users'] }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">{{ __('admin.stat_players') }}</p>
<p class="text-2xl font-bold text-gray-900">{{ $stats['players'] }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">{{ __('admin.stat_upcoming') }}</p>
<p class="text-2xl font-bold text-gray-900">{{ $stats['upcoming_events'] }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">{{ __('admin.stat_invitations') }}</p>
<p class="text-2xl font-bold text-gray-900">{{ $stats['open_invitations'] }}</p>
</div>
</div>
{{-- DSGVO Bestätigung ausstehend --}}
@if ($pendingDsgvoUsers->isNotEmpty())
<div class="bg-orange-50 border border-orange-200 rounded-lg shadow p-6 mb-8" x-data="{ openModal: null }">
<div class="flex items-center gap-2 mb-4">
<svg class="w-5 h-5 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<h2 class="text-lg font-semibold text-orange-800">{{ __('admin.dsgvo_pending_title') }}</h2>
<span class="bg-orange-200 text-orange-800 text-xs font-medium rounded-full px-2 py-0.5">{{ __('admin.dsgvo_pending_count', ['count' => $pendingDsgvoUsers->count()]) }}</span>
</div>
<div class="divide-y divide-orange-100">
@foreach ($pendingDsgvoUsers as $dsgvoUser)
<div class="py-3 flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-3">
<img src="{{ $dsgvoUser->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-8 h-8 rounded-full object-cover flex-shrink-0">
<div>
<span class="text-sm font-medium text-gray-900">{{ $dsgvoUser->name }}</span>
<span class="text-xs text-gray-500 block">{{ $dsgvoUser->email }}</span>
</div>
</div>
<button @click="openModal = {{ $dsgvoUser->id }}"
class="bg-blue-600 text-white text-xs px-3 py-1.5 rounded-md hover:bg-blue-700 transition-colors">
{{ __('admin.dsgvo_view_document') }}
</button>
</div>
{{-- Modal für Dokument-Ansicht + Bestätigung/Ablehnung --}}
<template x-teleport="body">
<div x-show="openModal === {{ $dsgvoUser->id }}" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="openModal = null">
<div class="fixed inset-0 bg-black/50" @click="openModal = null"></div>
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col z-10">
{{-- Modal Header --}}
<div class="flex items-center justify-between p-4 border-b">
<div>
<h3 class="text-lg font-semibold text-gray-900">{{ __('admin.dsgvo_consent_document') }}</h3>
<p class="text-sm text-gray-500">{{ $dsgvoUser->name }} ({{ $dsgvoUser->email }})</p>
</div>
<button @click="openModal = null" class="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
{{-- Modal Body: Dokument-Vorschau --}}
<div class="flex-1 overflow-auto p-4">
@php
$filePath = $dsgvoUser->dsgvo_consent_file;
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
@endphp
@if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp']))
<img src="{{ route('admin.users.view-dsgvo-consent', $dsgvoUser) }}" alt="DSGVO Consent" class="max-w-full rounded-lg mx-auto">
@elseif ($ext === 'pdf')
<iframe src="{{ route('admin.users.view-dsgvo-consent', $dsgvoUser) }}" class="w-full h-96 rounded-lg border"></iframe>
@else
<p class="text-sm text-gray-500 text-center py-8">{{ __('admin.dsgvo_preview_not_available') }}</p>
@endif
</div>
{{-- Modal Footer: Aktionen --}}
<div class="flex items-center justify-end gap-3 p-4 border-t bg-gray-50 rounded-b-lg">
<form method="POST" action="{{ route('admin.users.dsgvo-reject', $dsgvoUser) }}"
onsubmit="return confirm(@js(__('admin.dsgvo_reject_confirm')))">
@csrf
@method('PUT')
<button type="submit" class="bg-red-600 text-white text-sm px-4 py-2 rounded-md hover:bg-red-700 transition-colors">
{{ __('admin.dsgvo_reject') }}
</button>
</form>
<form method="POST" action="{{ route('admin.users.dsgvo-toggle', $dsgvoUser) }}">
@csrf
@method('PUT')
<button type="submit" class="bg-green-600 text-white text-sm px-4 py-2 rounded-md hover:bg-green-700 transition-colors">
{{ __('admin.dsgvo_confirm') }}
</button>
</form>
</div>
</div>
</div>
</template>
@endforeach
</div>
</div>
@endif
{{-- Quick-Links --}}
<div class="bg-white rounded-lg shadow p-6 mb-8">
<h2 class="text-lg font-semibold mb-3">{{ __('admin.quick_links') }}</h2>
<div class="flex flex-wrap gap-3">
<a href="{{ route('admin.events.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700">{{ __('admin.new_event') }}</a>
<a href="{{ route('admin.players.create') }}" class="bg-green-600 text-white px-4 py-2 rounded text-sm hover:bg-green-700">{{ __('admin.new_player') }}</a>
<a href="{{ route('admin.invitations.create') }}" class="bg-purple-600 text-white px-4 py-2 rounded text-sm hover:bg-purple-700">{{ __('admin.new_invitation') }}</a>
<a href="{{ route('admin.teams.create') }}" class="bg-gray-600 text-white px-4 py-2 rounded text-sm hover:bg-gray-700">{{ __('admin.new_team') }}</a>
</div>
</div>
{{-- Events mit offenen Rückmeldungen --}}
@if ($eventsWithOpenResponses->isNotEmpty())
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold mb-3">{{ __('admin.open_responses_title') }}</h2>
<div class="divide-y divide-gray-100">
@foreach ($eventsWithOpenResponses as $event)
<div class="py-3 flex items-center justify-between">
<div>
<a href="{{ route('admin.events.edit', $event) }}" class="text-sm font-medium text-blue-600 hover:underline">{{ $event->title }}</a>
<p class="text-xs text-gray-500">{{ $event->team->name }} {{ $event->start_at->translatedFormat(__('ui.date_format')) }}</p>
</div>
<span class="text-sm text-orange-600 font-medium">{{ __('admin.x_open', ['count' => $event->open_count]) }}</span>
</div>
@endforeach
</div>
</div>
@endif
{{-- DSGVO-Ereignisse --}}
@if ($dsgvoEvents->isNotEmpty())
<div class="bg-white rounded-lg shadow p-6 mt-8">
<h2 class="text-lg font-semibold mb-3">{{ __('admin.dsgvo_events_title') }}</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-2 font-medium text-gray-700 text-xs">{{ __('admin.log_time') }}</th>
<th class="text-left px-4 py-2 font-medium text-gray-700 text-xs">{{ __('admin.log_user') }}</th>
<th class="text-center px-4 py-2 font-medium text-gray-700 text-xs">{{ __('admin.log_action') }}</th>
<th class="text-left px-4 py-2 font-medium text-gray-700 text-xs">{{ __('admin.log_description') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($dsgvoEvents as $logEntry)
@php
$dsgvoColors = [
'dsgvo_consent_uploaded' => 'bg-blue-100 text-blue-800',
'dsgvo_consent_confirmed' => 'bg-green-100 text-green-800',
'dsgvo_consent_revoked' => 'bg-yellow-100 text-yellow-800',
'dsgvo_consent_removed' => 'bg-orange-100 text-orange-800',
'dsgvo_consent_rejected' => 'bg-red-100 text-red-800',
'account_self_deleted' => 'bg-red-100 text-red-800',
'child_auto_deactivated' => 'bg-red-100 text-red-800',
];
@endphp
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 text-xs text-gray-500 whitespace-nowrap">
{{ $logEntry->created_at->translatedFormat(__('ui.date_format_short')) }}
</td>
<td class="px-4 py-2 text-xs text-gray-900">
{{ $logEntry->user?->name ?? __('admin.log_system') }}
</td>
<td class="px-4 py-2 text-center">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $dsgvoColors[$logEntry->action] ?? 'bg-gray-100 text-gray-800' }}">
{{ __('admin.dsgvo_action_' . $logEntry->action) }}
</span>
</td>
<td class="px-4 py-2 text-xs text-gray-600">{{ $logEntry->description }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</x-layouts.admin>

View File

@@ -0,0 +1,452 @@
<x-layouts.admin :title="__('admin.new_event_title')">
@push('styles')
<link href="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.snow.css" rel="stylesheet" integrity="sha384-cPa8kzsYWhqpAfWOLWYIw3V0BhPi/m3lrd8tBTPxr2NrYCHRVZ7xy1cEoRGOM/03" crossorigin="anonymous">
<style>
.ql-editor { min-height: 120px; }
.ql-toolbar.ql-snow { border-radius: 0.375rem 0.375rem 0 0; }
.ql-container.ql-snow { border-radius: 0 0 0.375rem 0.375rem; }
</style>
@endpush
<h1 class="text-2xl font-bold mb-6">{{ __('admin.new_event_title') }}</h1>
<div class="bg-white rounded-lg shadow p-6 max-w-2xl">
<form method="POST" action="{{ route('admin.events.store') }}" id="eventForm" enctype="multipart/form-data"
x-data="{ currentType: @js(old('type', '')) }" x-init="$watch('currentType', () => {}); document.getElementById('type').addEventListener('change', (e) => currentType = e.target.value); currentType = document.getElementById('type').value;">
@csrf
<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>
<select name="team_id" id="team_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">{{ __('admin.please_select') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ old('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
@error('team_id')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="type" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.type') }} *</label>
<select name="type" id="type" required class="w-full px-3 py-2 border border-gray-300 rounded-md" x-model="currentType">
@foreach ($types as $t)
<option value="{{ $t->value }}" {{ old('type') === $t->value ? 'selected' : '' }}>{{ $t->label() }}</option>
@endforeach
</select>
@error('type')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
</div>
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.event_title') }} *</label>
<input type="text" name="title" id="title" value="{{ old('title') }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('title') border-red-500 @enderror">
@error('title')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Gegner (nur für Spiel-Typen) --}}
<div class="mb-4" x-show="['home_game', 'away_game'].includes(currentType)" x-cloak>
<label for="opponent" class="block text-sm font-medium text-gray-700 mb-1">{{ __('events.opponent') }}</label>
<input type="text" name="opponent" id="opponent" value="{{ old('opponent') }}" maxlength="100"
class="w-full px-3 py-2 border border-gray-300 rounded-md" placeholder="{{ __('events.opponent') }}">
@error('opponent')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Ergebnis (nur für Spiel-Typen) --}}
<div class="mb-4" x-show="['home_game', 'away_game'].includes(currentType)" x-cloak>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('events.score') }}</label>
<div class="flex items-center gap-2">
<input type="number" name="score_home" value="{{ old('score_home') }}" min="0" max="99" placeholder="{{ __('events.score_home') }}"
class="w-20 px-3 py-2 border border-gray-300 rounded-md text-center">
<span class="text-gray-500 font-medium">:</span>
<input type="number" name="score_away" value="{{ old('score_away') }}" min="0" max="99" placeholder="{{ __('events.score_away') }}"
class="w-20 px-3 py-2 border border-gray-300 rounded-md text-center">
</div>
@error('score_home')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
@error('score_away')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.date') }} *</label>
<input type="date" name="start_date" id="start_date" value="{{ old('start_date') }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('start_date') border-red-500 @enderror">
@error('start_date')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="start_time" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.time') }} *</label>
<input type="time" name="start_time" id="start_time" value="{{ old('start_time') }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('start_time') border-red-500 @enderror">
@error('start_time')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
</div>
<div class="mb-4">
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.status') }} *</label>
<select name="status" id="status" required class="w-full px-3 py-2 border border-gray-300 rounded-md">
@foreach ($statuses as $s)
<option value="{{ $s->value }}" {{ old('status', 'draft') === $s->value ? 'selected' : '' }}>{{ $s->label() }}</option>
@endforeach
</select>
@error('status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</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">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.min_requirements') }}</h3>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs text-gray-600 mb-1" x-text="playersLabel"></label>
<select name="min_players" x-model="minPlayers" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
<option value="">--</option>
@for ($n = 1; $n <= 30; $n++)
<option value="{{ $n }}" :hidden="{{ $n }} > playersMax" :disabled="{{ $n }} > playersMax">{{ $n }}</option>
@endfor
</select>
</div>
<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">
<option value="">--</option>
@for ($n = 1; $n <= 8; $n++)
<option value="{{ $n }}" :hidden="{{ $n }} > cateringMax" :disabled="{{ $n }} > cateringMax">{{ $n }}</option>
@endfor
</select>
</div>
<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">
<option value="">--</option>
@for ($n = 1; $n <= 8; $n++)
<option value="{{ $n }}" :hidden="{{ $n }} > timekeepersMax" :disabled="{{ $n }} > timekeepersMax">{{ $n }}</option>
@endfor
</select>
</div>
</div>
</div>
</div>
{{-- Ort & Adress-Suche --}}
<div class="mb-4" x-data="addressSearch()">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.location_address') }}</label>
{{-- Ortsname mit bekannten Orten --}}
<div class="mb-2 relative">
<label for="location_name" class="block text-xs text-gray-500 mb-1">{{ __('admin.location_name_hint') }}</label>
<input type="text" name="location_name" id="location_name" x-model="locationName"
@input.debounce.200ms="filterKnown()" @focus="filterKnown()" @keydown.escape="knownMatches = []"
class="w-full px-3 py-2 border border-gray-300 rounded-md" placeholder="{{ __('admin.location_name_placeholder') }}" autocomplete="off">
<div x-show="knownMatches.length > 0" x-cloak @click.outside="knownMatches = []"
class="absolute z-10 mt-1 w-full bg-white border border-blue-300 rounded-md shadow-lg max-h-48 overflow-y-auto">
<div class="px-3 py-1.5 bg-blue-50 text-xs font-medium text-blue-700 border-b">{{ __('admin.known_locations') }}</div>
<template x-for="(loc, idx) in knownMatches" :key="idx">
<button type="button" @click="selectKnown(loc)" class="w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100">
<span class="block text-sm font-medium text-gray-900" x-text="loc.name"></span>
<span class="block text-xs text-gray-500" x-text="loc.address_text || ''" x-show="loc.address_text"></span>
</button>
</template>
</div>
</div>
{{-- Photon Adress-Suche --}}
<div class="relative">
<label class="block text-xs text-gray-500 mb-1">{{ __('admin.search_address') }}</label>
<input type="text" x-model="query" @input.debounce.300ms="search()" @keydown.escape="results = []"
placeholder="{{ __('admin.search_address_hint') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md" autocomplete="off">
<div x-show="loading" x-cloak class="absolute right-3 top-8">
<svg class="animate-spin h-4 w-4 text-gray-400" 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>
</div>
<div x-show="results.length > 0" x-cloak @click.outside="results = []"
class="absolute z-10 mt-1 w-full bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
<template x-for="(r, idx) in results" :key="idx">
<button type="button" @click="select(r)" class="w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100">
<span class="block text-sm font-medium text-gray-900" x-text="r.title"></span>
<span class="block text-xs text-gray-500" x-text="r.subtitle"></span>
</button>
</template>
</div>
</div>
<input type="hidden" name="address_text" id="address_text" value="{{ old('address_text') }}" x-ref="addressText">
<input type="hidden" name="location_lat" id="location_lat" value="{{ old('location_lat') }}" x-ref="lat">
<input type="hidden" name="location_lng" id="location_lng" value="{{ old('location_lng') }}" x-ref="lng">
<p x-show="selectedAddress" x-text="selectedAddress" class="mt-1 text-xs text-green-700"></p>
@error('address_text')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Beschreibung mit Quill --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.description') }}</label>
<div id="quill-editor" class="bg-white">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize(old('description_html', '')) !!}</div>
<input type="hidden" name="description_html" id="description_html" value="{{ old('description_html', '') }}">
@error('description_html')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Catering/Zeitnehmer-Zuweisungen (nicht für away_game/meeting) --}}
<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">
<div class="border border-gray-200 rounded-md p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.assignments') }}</h3>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 font-medium text-gray-600">{{ __('ui.name') }}</th>
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.catering_assignment') }}</th>
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.timekeeper_assignment') }}</th>
</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>
<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>
<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>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
{{-- Dateien --}}
<div class="mb-4" x-data="{ showPicker: false, newFileCount: 0 }">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.event_files') }}</label>
{{-- Aus Bibliothek anhängen --}}
<button type="button" @click="showPicker = !showPicker" class="text-sm text-blue-600 hover:text-blue-800 mb-2">
{{ __('admin.attach_from_library') }} &darr;
</button>
<div x-show="showPicker" x-cloak class="border border-gray-200 rounded-md p-3 mb-3 max-h-48 overflow-y-auto">
@foreach ($fileCategories as $cat)
@if ($cat->files->isNotEmpty())
<p class="text-xs font-semibold text-gray-500 mt-2 first:mt-0 mb-1">{{ $cat->name }}</p>
@foreach ($cat->files as $file)
<label class="flex items-center gap-2 py-0.5 text-sm text-gray-700 hover:bg-gray-50 px-1 rounded">
<input type="checkbox" name="existing_files[]" value="{{ $file->id }}" class="rounded border-gray-300">
{{ $file->original_name }} <span class="text-xs text-gray-400">({{ $file->humanSize() }})</span>
</label>
@endforeach
@endif
@endforeach
</div>
{{-- Neue Datei hochladen --}}
<div class="space-y-2">
<template x-for="i in newFileCount" :key="i">
<div class="flex items-center gap-2">
<input type="file" :name="'new_files[' + (i-1) + ']'" accept=".pdf,.docx,.xlsx,.jpg,.jpeg,.png,.gif,.webp"
class="flex-1 text-sm text-gray-600 file:mr-2 file:py-1 file:px-3 file:rounded-md file:border-0 file:text-sm file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
<select :name="'new_file_categories[' + (i-1) + ']'" required class="px-2 py-1 border border-gray-300 rounded-md text-sm">
<option value="">{{ __('admin.select_category') }}</option>
@foreach ($fileCategories as $cat)
<option value="{{ $cat->id }}">{{ $cat->name }}</option>
@endforeach
</select>
</div>
</template>
</div>
<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">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('ui.create') }}</button>
<a href="{{ route('admin.events.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
</div>
</form>
</div>
{{-- Quill JS --}}
<script src="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.min.js" integrity="sha384-QUJ+ckWz1M+a7w0UfG1sEn4pPrbQwSxGm/1TIPyioqXBrwuT9l4f9gdHWLDLbVWI" crossorigin="anonymous"></script>
<script>
// Quill Editor
const quill = new Quill('#quill-editor', {
theme: 'snow',
modules: {
toolbar: [
[{ 'header': [2, 3, 4, false] }],
['bold', 'italic', 'underline'],
[{ 'color': ['#000000', '#e60000', '#ff9900', '#008a00', '#0066cc', '#9933ff', '#ffffff', '#888888'] },
{ 'background': ['', '#ffd6d6', '#fff3cd', '#d4edda', '#cce5ff', '#e8d5f5'] }],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['blockquote', 'link'],
['clean']
]
},
placeholder: @js(__('admin.description_placeholder'))
});
// Sofort nach Init den hidden Input befüllen
document.getElementById('description_html').value = quill.root.innerHTML;
// Sync Quill content to hidden field on form submit
document.getElementById('eventForm').addEventListener('submit', function () {
document.getElementById('description_html').value = quill.root.innerHTML;
});
// Assignment data for Catering/Timekeeper
function assignmentData() {
const teamParents = @js($teamParents);
const assignedCatering = @js(collect(old('catering_users', []))->map(fn($id) => (int) $id)->values()->toArray());
const assignedTimekeeper = @js(collect(old('timekeeper_users', []))->map(fn($id) => (int) $id)->values()->toArray());
return {
parents: [],
assignedCatering,
assignedTimekeeper,
listenTeamChange() {
const sel = document.getElementById('team_id');
this.parents = teamParents[sel.value] || [];
sel.addEventListener('change', () => {
this.parents = teamParents[sel.value] || [];
});
}
};
}
// Typen ohne Catering/Zeitnehmer
const noCateringTypes = ['away_game', 'meeting'];
// Minimum requirements with dynamic dropdowns
function minRequirementsData() {
const defaults = @js($eventDefaults);
const hasOldInput = @js(old('_token') !== null);
const toStr = v => (v != null && v !== '') ? String(v) : '';
const oldValues = {
min_players: toStr(@js(old('min_players', ''))),
min_catering: toStr(@js(old('min_catering', ''))),
min_timekeepers: toStr(@js(old('min_timekeepers', ''))),
};
const ranges = {
home_game: { players: 14, catering: 4, timekeepers: 4 },
away_game: { players: 14, catering: 0, timekeepers: 0 },
training: { players: 30, catering: 4, timekeepers: 4 },
tournament: { players: 14, catering: 8, timekeepers: 8 },
meeting: { players: 20, catering: 0, timekeepers: 0 },
};
const initialType = @js(old('type', ''));
return {
currentType: initialType,
minPlayers: oldValues.min_players,
minCatering: oldValues.min_catering,
minTimekeepers: oldValues.min_timekeepers,
get showDropdowns() { return this.currentType && this.currentType !== 'other'; },
get showCatering() { return !noCateringTypes.includes(this.currentType); },
get showTimekeepers() { return !noCateringTypes.includes(this.currentType); },
get playersLabel() { return this.currentType === 'meeting' ? @js(__('admin.min_users')) : @js(__('admin.min_players')); },
get playersMax() { const r = ranges[this.currentType]; return r ? r.players : 0; },
get cateringMax() { const r = ranges[this.currentType]; return r ? r.catering : 0; },
get timekeepersMax() { const r = ranges[this.currentType]; return r ? r.timekeepers : 0; },
listenTypeChange() {
const sel = document.getElementById('type');
this.currentType = sel.value;
if (!hasOldInput) {
this.applyDefaults(sel.value);
}
sel.addEventListener('change', () => {
this.currentType = sel.value;
this.applyDefaults(sel.value);
});
},
applyDefaults(type) {
const d = defaults[type];
this.minPlayers = toStr(d?.min_players);
this.minCatering = toStr(d?.min_catering);
this.minTimekeepers = toStr(d?.min_timekeepers);
}
};
}
// Address search with Photon API (OpenStreetMap) + known locations
function addressSearch() {
const knownLocations = @js($knownLocations);
return {
locationName: @js(old('location_name', '')),
query: '',
results: [],
loading: false,
selectedAddress: @js(old('address_text', '')),
knownMatches: [],
_abortCtrl: null,
filterKnown() {
const input = this.locationName.trim().toLowerCase();
if (input.length < 1) { this.knownMatches = knownLocations.slice(0, 8); return; }
const words = input.split(/\s+/);
this.knownMatches = knownLocations.filter(loc => {
const haystack = (loc.name + ' ' + (loc.address_text || '')).toLowerCase();
return words.every(w => haystack.includes(w));
}).slice(0, 8);
},
selectKnown(loc) {
this.locationName = loc.name;
this.$refs.addressText.value = loc.address_text || '';
this.$refs.lat.value = loc.location_lat || '';
this.$refs.lng.value = loc.location_lng || '';
this.selectedAddress = loc.address_text || '';
this.query = loc.address_text || '';
this.knownMatches = [];
},
formatFeature(f) {
const p = f.properties;
const street = [p.street, p.housenumber].filter(Boolean).join(' ');
const effectiveStreet = street || (p.name && p.name !== p.city ? p.name : '');
const cityLine = [p.postcode, p.city].filter(Boolean).join(' ');
const address = [effectiveStreet, cityLine].filter(Boolean).join(', ');
const name = p.name || '';
const isPlace = name && name !== effectiveStreet && name !== p.city && name !== p.street;
return {
title: isPlace ? name : (address || name || ''),
subtitle: isPlace ? address : (cityLine || p.state || ''),
address: address || name || '',
name: isPlace ? name : '',
lat: f.geometry.coordinates[1],
lon: f.geometry.coordinates[0],
};
},
async search() {
const q = this.query.trim();
if (q.length < 2) { this.results = []; return; }
if (this._abortCtrl) this._abortCtrl.abort();
this._abortCtrl = new AbortController();
this.loading = true;
try {
const params = new URLSearchParams({
q: q, lang: 'de', limit: '7',
lat: '51.4', lon: '7.5',
bbox: '5.87,50.32,9.46,52.53',
});
const resp = await fetch('https://photon.komoot.io/api/?' + params, {
signal: this._abortCtrl.signal
});
const data = await resp.json();
this.results = (data.features || []).map(f => this.formatFeature(f));
} catch (e) {
if (e.name !== 'AbortError') this.results = [];
} finally {
this.loading = false;
}
},
select(r) {
this.$refs.addressText.value = r.address;
this.$refs.lat.value = r.lat;
this.$refs.lng.value = r.lon;
this.selectedAddress = r.address;
this.query = r.address;
this.results = [];
if (!this.locationName && r.name) {
this.locationName = r.name;
}
}
};
}
</script>
</x-layouts.admin>

View File

@@ -0,0 +1,592 @@
<x-layouts.admin :title="__('admin.edit_event_title')">
@push('styles')
<link href="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.snow.css" rel="stylesheet" integrity="sha384-cPa8kzsYWhqpAfWOLWYIw3V0BhPi/m3lrd8tBTPxr2NrYCHRVZ7xy1cEoRGOM/03" crossorigin="anonymous">
<style>
.ql-editor { min-height: 120px; }
.ql-toolbar.ql-snow { border-radius: 0.375rem 0.375rem 0 0; }
.ql-container.ql-snow { border-radius: 0 0 0.375rem 0.375rem; }
</style>
@endpush
<h1 class="text-2xl font-bold mb-6">{{ __('admin.edit_event_title') }}</h1>
<div class="bg-white rounded-lg shadow p-6 max-w-2xl">
<form method="POST" action="{{ route('admin.events.update', $event) }}" id="eventForm" enctype="multipart/form-data"
x-data="{ currentType: @js(old('type', $event->type->value)) }" x-init="document.getElementById('type').addEventListener('change', (e) => currentType = e.target.value);">
@csrf
@method('PUT')
<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>
<select name="team_id" id="team_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">{{ __('admin.please_select') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ old('team_id', $event->team_id) == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
@error('team_id')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="type" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.type') }} *</label>
<select name="type" id="type" required class="w-full px-3 py-2 border border-gray-300 rounded-md" x-model="currentType">
@foreach ($types as $t)
<option value="{{ $t->value }}" {{ old('type', $event->type->value) === $t->value ? 'selected' : '' }}>{{ $t->label() }}</option>
@endforeach
</select>
@error('type')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
</div>
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.event_title') }} *</label>
<input type="text" name="title" id="title" value="{{ old('title', $event->title) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('title') border-red-500 @enderror">
@error('title')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Gegner (nur für Spiel-Typen) --}}
<div class="mb-4" x-show="['home_game', 'away_game'].includes(currentType)" x-cloak>
<label for="opponent" class="block text-sm font-medium text-gray-700 mb-1">{{ __('events.opponent') }}</label>
<input type="text" name="opponent" id="opponent" value="{{ old('opponent', $event->opponent) }}" maxlength="100"
class="w-full px-3 py-2 border border-gray-300 rounded-md" placeholder="{{ __('events.opponent') }}">
@error('opponent')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Ergebnis (nur für Spiel-Typen) --}}
<div class="mb-4" x-show="['home_game', 'away_game'].includes(currentType)" x-cloak>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('events.score') }}</label>
<div class="flex items-center gap-2">
<input type="number" name="score_home" value="{{ old('score_home', $event->score_home) }}" min="0" max="99" placeholder="{{ __('events.score_home') }}"
class="w-20 px-3 py-2 border border-gray-300 rounded-md text-center">
<span class="text-gray-500 font-medium">:</span>
<input type="number" name="score_away" value="{{ old('score_away', $event->score_away) }}" min="0" max="99" placeholder="{{ __('events.score_away') }}"
class="w-20 px-3 py-2 border border-gray-300 rounded-md text-center">
</div>
@error('score_home')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
@error('score_away')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.date') }} *</label>
<input type="date" name="start_date" id="start_date" value="{{ old('start_date', $event->start_at->format('Y-m-d')) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('start_date') border-red-500 @enderror">
@error('start_date')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="start_time" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.time') }} *</label>
<input type="time" name="start_time" id="start_time" value="{{ old('start_time', $event->start_at->format('H:i')) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('start_time') border-red-500 @enderror">
@error('start_time')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
</div>
<div class="mb-4">
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.status') }} *</label>
<select name="status" id="status" required class="w-full px-3 py-2 border border-gray-300 rounded-md">
@foreach ($statuses as $s)
<option value="{{ $s->value }}" {{ old('status', $event->status->value) === $s->value ? 'selected' : '' }}>{{ $s->label() }}</option>
@endforeach
</select>
@error('status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</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">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.min_requirements') }}</h3>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs text-gray-600 mb-1" x-text="playersLabel"></label>
<select name="min_players" x-model="minPlayers" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
<option value="">--</option>
@for ($n = 1; $n <= 30; $n++)
<option value="{{ $n }}" :hidden="{{ $n }} > playersMax" :disabled="{{ $n }} > playersMax">{{ $n }}</option>
@endfor
</select>
</div>
<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">
<option value="">--</option>
@for ($n = 1; $n <= 8; $n++)
<option value="{{ $n }}" :hidden="{{ $n }} > cateringMax" :disabled="{{ $n }} > cateringMax">{{ $n }}</option>
@endfor
</select>
</div>
<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">
<option value="">--</option>
@for ($n = 1; $n <= 8; $n++)
<option value="{{ $n }}" :hidden="{{ $n }} > timekeepersMax" :disabled="{{ $n }} > timekeepersMax">{{ $n }}</option>
@endfor
</select>
</div>
</div>
</div>
</div>
{{-- Ort & Adress-Suche --}}
<div class="mb-4" x-data="addressSearch()">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.location_address') }}</label>
{{-- Ortsname mit bekannten Orten --}}
<div class="mb-2 relative">
<label for="location_name" class="block text-xs text-gray-500 mb-1">{{ __('admin.location_name_hint') }}</label>
<input type="text" name="location_name" id="location_name" x-model="locationName"
@input.debounce.200ms="filterKnown()" @focus="filterKnown()" @keydown.escape="knownMatches = []"
class="w-full px-3 py-2 border border-gray-300 rounded-md" placeholder="{{ __('admin.location_name_placeholder') }}" autocomplete="off">
<div x-show="knownMatches.length > 0" x-cloak @click.outside="knownMatches = []"
class="absolute z-10 mt-1 w-full bg-white border border-blue-300 rounded-md shadow-lg max-h-48 overflow-y-auto">
<div class="px-3 py-1.5 bg-blue-50 text-xs font-medium text-blue-700 border-b">{{ __('admin.known_locations') }}</div>
<template x-for="(loc, idx) in knownMatches" :key="idx">
<button type="button" @click="selectKnown(loc)" class="w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100">
<span class="block text-sm font-medium text-gray-900" x-text="loc.name"></span>
<span class="block text-xs text-gray-500" x-text="loc.address_text || ''" x-show="loc.address_text"></span>
</button>
</template>
</div>
</div>
{{-- Photon Adress-Suche --}}
<div class="relative">
<label class="block text-xs text-gray-500 mb-1">{{ __('admin.search_address') }}</label>
<input type="text" x-model="query" @input.debounce.300ms="search()" @keydown.escape="results = []"
placeholder="{{ __('admin.search_address_hint') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md" autocomplete="off">
<div x-show="loading" x-cloak class="absolute right-3 top-8">
<svg class="animate-spin h-4 w-4 text-gray-400" 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>
</div>
<div x-show="results.length > 0" x-cloak @click.outside="results = []"
class="absolute z-10 mt-1 w-full bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
<template x-for="(r, idx) in results" :key="idx">
<button type="button" @click="select(r)" class="w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100">
<span class="block text-sm font-medium text-gray-900" x-text="r.title"></span>
<span class="block text-xs text-gray-500" x-text="r.subtitle"></span>
</button>
</template>
</div>
</div>
<input type="hidden" name="address_text" id="address_text" x-ref="addressText" value="{{ old('address_text', $event->address_text) }}">
<input type="hidden" name="location_lat" id="location_lat" x-ref="lat" value="{{ old('location_lat', $event->location_lat) }}">
<input type="hidden" name="location_lng" id="location_lng" x-ref="lng" value="{{ old('location_lng', $event->location_lng) }}">
<p x-show="selectedAddress" x-text="selectedAddress" class="mt-1 text-xs text-green-700"></p>
@error('address_text')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Beschreibung mit Quill --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.description') }}</label>
<div id="quill-editor" class="bg-white">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize(old('description_html', $event->description_html ?? '')) !!}</div>
<input type="hidden" name="description_html" id="description_html" value="{{ old('description_html', $event->description_html) }}">
@error('description_html')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Catering/Zeitnehmer-Zuweisungen (nicht für away_game/meeting) --}}
<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">
<div class="border border-gray-200 rounded-md p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.assignments') }}</h3>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 font-medium text-gray-600">{{ __('ui.name') }}</th>
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.catering_assignment') }}</th>
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.timekeeper_assignment') }}</th>
</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>
<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>
<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>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
{{-- Dateien --}}
<div class="mb-4" x-data="{ showPicker: false, newFileCount: 0 }">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.event_files') }}</label>
{{-- Bereits angehängte Dateien --}}
@if ($event->files->isNotEmpty())
<div class="mb-3 space-y-1">
<p class="text-xs font-semibold text-gray-500">{{ __('admin.attached_files') }}</p>
@foreach ($event->files as $file)
<label class="flex items-center gap-2 py-0.5 text-sm text-gray-700 bg-blue-50 px-2 rounded">
<input type="checkbox" name="existing_files[]" value="{{ $file->id }}" checked class="rounded border-gray-300">
{{ $file->original_name }}
<span class="text-xs text-gray-400">({{ $file->category->name }} &middot; {{ $file->humanSize() }})</span>
</label>
@endforeach
</div>
@endif
{{-- Aus Bibliothek anhängen --}}
<button type="button" @click="showPicker = !showPicker" class="text-sm text-blue-600 hover:text-blue-800 mb-2">
{{ __('admin.attach_from_library') }} &darr;
</button>
<div x-show="showPicker" x-cloak class="border border-gray-200 rounded-md p-3 mb-3 max-h-48 overflow-y-auto">
@php $attachedIds = $event->files->pluck('id')->toArray(); @endphp
@foreach ($fileCategories as $cat)
@if ($cat->files->isNotEmpty())
<p class="text-xs font-semibold text-gray-500 mt-2 first:mt-0 mb-1">{{ $cat->name }}</p>
@foreach ($cat->files as $file)
@if (!in_array($file->id, $attachedIds))
<label class="flex items-center gap-2 py-0.5 text-sm text-gray-700 hover:bg-gray-50 px-1 rounded">
<input type="checkbox" name="existing_files[]" value="{{ $file->id }}" class="rounded border-gray-300">
{{ $file->original_name }} <span class="text-xs text-gray-400">({{ $file->humanSize() }})</span>
</label>
@endif
@endforeach
@endif
@endforeach
</div>
{{-- Neue Datei hochladen --}}
<div class="space-y-2">
<template x-for="i in newFileCount" :key="i">
<div class="flex items-center gap-2">
<input type="file" :name="'new_files[' + (i-1) + ']'" accept=".pdf,.docx,.xlsx,.jpg,.jpeg,.png,.gif,.webp"
class="flex-1 text-sm text-gray-600 file:mr-2 file:py-1 file:px-3 file:rounded-md file:border-0 file:text-sm file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
<select :name="'new_file_categories[' + (i-1) + ']'" required class="px-2 py-1 border border-gray-300 rounded-md text-sm">
<option value="">{{ __('admin.select_category') }}</option>
@foreach ($fileCategories as $cat)
<option value="{{ $cat->id }}">{{ $cat->name }}</option>
@endforeach
</select>
</div>
</template>
</div>
<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">
<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>
<a href="{{ route('admin.events.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
</div>
</form>
</div>
{{-- Teilnehmer-Verwaltung --}}
@if ($event->participants->isNotEmpty())
@php
$isMeeting = $event->type === \App\Enums\EventType::Meeting;
$yesCount = $event->participants->where('status', \App\Enums\ParticipantStatus::Yes)->count();
$noCount = $event->participants->where('status', \App\Enums\ParticipantStatus::No)->count();
$openCount = $event->participants->where('status', \App\Enums\ParticipantStatus::Unknown)->count();
@endphp
<div class="bg-white rounded-lg shadow p-6 max-w-2xl mt-6" x-data="participantsSection()">
<h2 class="text-lg font-semibold mb-3">{{ __('events.participants') }}</h2>
<div class="flex gap-4 text-sm mb-4">
<span class="text-green-700 font-medium"><span x-text="counts.yes"></span> {{ __('events.confirmations') }}</span>
<span class="text-red-700 font-medium"><span x-text="counts.no"></span> {{ __('events.rejections') }}</span>
<span class="text-gray-500"><span x-text="counts.open"></span> {{ __('events.open_responses') }}</span>
</div>
<div class="divide-y divide-gray-100">
@if ($isMeeting)
@foreach ($event->participants->sortBy(fn($p) => $p->user->name ?? '') as $participant)
<div class="py-2 flex items-center justify-between gap-2">
<span class="text-sm font-medium text-gray-900">{{ $participant->user->name ?? '' }}</span>
<div class="flex items-center gap-1">
<button type="button" @click="setStatus({{ $participant->id }}, 'yes')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'yes' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100'">
{{ __('ui.yes') }}
</button>
<button type="button" @click="setStatus({{ $participant->id }}, 'no')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'no' ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100'">
{{ __('ui.no') }}
</button>
<button type="button" @click="setStatus({{ $participant->id }}, 'unknown')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'unknown' ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'">
{{ __('ui.open') }}
</button>
<span x-show="savingId === {{ $participant->id }}" class="text-xs text-gray-400 ml-1">...</span>
<span x-show="savedId === {{ $participant->id }}" x-cloak class="text-xs text-green-600 ml-1">&#10003;</span>
</div>
</div>
@endforeach
@else
@foreach ($event->participants->sortBy('player.last_name') as $participant)
<div class="py-2 flex items-center justify-between gap-2">
<span class="text-sm font-medium text-gray-900">{{ $participant->player->full_name ?? '' }}</span>
<div class="flex items-center gap-1">
<button type="button" @click="setStatus({{ $participant->id }}, 'yes')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'yes' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100'">
{{ __('ui.yes') }}
</button>
<button type="button" @click="setStatus({{ $participant->id }}, 'no')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'no' ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100'">
{{ __('ui.no') }}
</button>
<button type="button" @click="setStatus({{ $participant->id }}, 'unknown')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'unknown' ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'">
{{ __('ui.open') }}
</button>
<span x-show="savingId === {{ $participant->id }}" class="text-xs text-gray-400 ml-1">...</span>
<span x-show="savedId === {{ $participant->id }}" x-cloak class="text-xs text-green-600 ml-1">&#10003;</span>
</div>
</div>
@endforeach
@endif
</div>
</div>
@endif
{{-- Quill JS --}}
<script src="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.min.js" integrity="sha384-QUJ+ckWz1M+a7w0UfG1sEn4pPrbQwSxGm/1TIPyioqXBrwuT9l4f9gdHWLDLbVWI" crossorigin="anonymous"></script>
<script>
const quill = new Quill('#quill-editor', {
theme: 'snow',
modules: {
toolbar: [
[{ 'header': [2, 3, 4, false] }],
['bold', 'italic', 'underline'],
[{ 'color': ['#000000', '#e60000', '#ff9900', '#008a00', '#0066cc', '#9933ff', '#ffffff', '#888888'] },
{ 'background': ['', '#ffd6d6', '#fff3cd', '#d4edda', '#cce5ff', '#e8d5f5'] }],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['blockquote', 'link'],
['clean']
]
},
placeholder: @js(__('admin.description_placeholder'))
});
// Sofort nach Init den hidden Input befüllen
document.getElementById('description_html').value = quill.root.innerHTML;
document.getElementById('eventForm').addEventListener('submit', function () {
document.getElementById('description_html').value = quill.root.innerHTML;
});
// Typen ohne Catering/Zeitnehmer
const noCateringTypes = ['away_game', 'meeting'];
function minRequirementsData() {
const defaults = @js($eventDefaults);
const toStr = v => (v != null && v !== '') ? String(v) : '';
const existing = {
min_players: toStr(@js(old('min_players', $event->min_players))),
min_catering: toStr(@js(old('min_catering', $event->min_catering))),
min_timekeepers: toStr(@js(old('min_timekeepers', $event->min_timekeepers))),
};
const originalType = @js(old('type', $event->type->value));
const ranges = {
home_game: { players: 14, catering: 4, timekeepers: 4 },
away_game: { players: 14, catering: 0, timekeepers: 0 },
training: { players: 30, catering: 4, timekeepers: 4 },
tournament: { players: 14, catering: 8, timekeepers: 8 },
meeting: { players: 20, catering: 0, timekeepers: 0 },
};
return {
currentType: originalType,
minPlayers: existing.min_players,
minCatering: existing.min_catering,
minTimekeepers: existing.min_timekeepers,
get showDropdowns() { return this.currentType && this.currentType !== 'other'; },
get showCatering() { return !noCateringTypes.includes(this.currentType); },
get showTimekeepers() { return !noCateringTypes.includes(this.currentType); },
get playersLabel() { return this.currentType === 'meeting' ? @js(__('admin.min_users')) : @js(__('admin.min_players')); },
get playersMax() { const r = ranges[this.currentType]; return r ? r.players : 0; },
get cateringMax() { const r = ranges[this.currentType]; return r ? r.catering : 0; },
get timekeepersMax() { const r = ranges[this.currentType]; return r ? r.timekeepers : 0; },
listenTypeChange() {
const sel = document.getElementById('type');
this.currentType = sel.value;
sel.addEventListener('change', () => {
this.currentType = sel.value;
if (sel.value !== originalType) {
const d = defaults[sel.value];
this.minPlayers = toStr(d?.min_players);
this.minCatering = toStr(d?.min_catering);
this.minTimekeepers = toStr(d?.min_timekeepers);
} else {
this.minPlayers = existing.min_players;
this.minCatering = existing.min_catering;
this.minTimekeepers = existing.min_timekeepers;
}
});
}
};
}
function assignmentData() {
const teamParents = @js($teamParents);
const assignedCatering = @js($assignedCatering);
const assignedTimekeeper = @js($assignedTimekeeper);
return {
parents: [],
assignedCatering,
assignedTimekeeper,
listenTeamChange() {
const sel = document.getElementById('team_id');
this.parents = teamParents[sel.value] || [];
sel.addEventListener('change', () => {
this.parents = teamParents[sel.value] || [];
});
}
};
}
function participantsSection() {
const initStatuses = @js($event->participants->mapWithKeys(fn($p) => [$p->id => $p->status->value]));
const vals = Object.values(initStatuses);
return {
counts: {
yes: vals.filter(s => s === 'yes').length,
no: vals.filter(s => s === 'no').length,
open: vals.filter(s => s === 'unknown').length,
},
statuses: initStatuses,
savingId: null,
savedId: null,
async setStatus(id, newStatus) {
const oldStatus = this.statuses[id];
if (oldStatus === newStatus) return;
this.statuses[id] = newStatus;
this.savingId = id;
this.savedId = null;
this.adjustCount(oldStatus, -1);
this.adjustCount(newStatus, 1);
try {
const res = await fetch(`{{ route('admin.events.update-participant', $event) }}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ participant_id: id, status: newStatus }),
});
if (res.ok) {
this.savedId = id;
setTimeout(() => { if (this.savedId === id) this.savedId = null; }, 1500);
} else {
this.statuses[id] = oldStatus;
this.adjustCount(newStatus, -1);
this.adjustCount(oldStatus, 1);
}
} catch (e) {
this.statuses[id] = oldStatus;
this.adjustCount(newStatus, -1);
this.adjustCount(oldStatus, 1);
console.error(e);
} finally {
if (this.savingId === id) this.savingId = null;
}
},
adjustCount(status, delta) {
if (status === 'yes') this.counts.yes += delta;
else if (status === 'no') this.counts.no += delta;
else this.counts.open += delta;
}
};
}
function addressSearch() {
const knownLocations = @js($knownLocations);
return {
locationName: @js(old('location_name', $event->location_name ?? '')),
query: '',
results: [],
loading: false,
selectedAddress: @js(old('address_text', $event->address_text ?? '')),
knownMatches: [],
_abortCtrl: null,
filterKnown() {
const input = this.locationName.trim().toLowerCase();
if (input.length < 1) { this.knownMatches = knownLocations.slice(0, 8); return; }
const words = input.split(/\s+/);
this.knownMatches = knownLocations.filter(loc => {
const haystack = (loc.name + ' ' + (loc.address_text || '')).toLowerCase();
return words.every(w => haystack.includes(w));
}).slice(0, 8);
},
selectKnown(loc) {
this.locationName = loc.name;
this.$refs.addressText.value = loc.address_text || '';
this.$refs.lat.value = loc.location_lat || '';
this.$refs.lng.value = loc.location_lng || '';
this.selectedAddress = loc.address_text || '';
this.query = loc.address_text || '';
this.knownMatches = [];
},
formatFeature(f) {
const p = f.properties;
const street = [p.street, p.housenumber].filter(Boolean).join(' ');
const effectiveStreet = street || (p.name && p.name !== p.city ? p.name : '');
const cityLine = [p.postcode, p.city].filter(Boolean).join(' ');
const address = [effectiveStreet, cityLine].filter(Boolean).join(', ');
const name = p.name || '';
const isPlace = name && name !== effectiveStreet && name !== p.city && name !== p.street;
return {
title: isPlace ? name : (address || name || ''),
subtitle: isPlace ? address : (cityLine || p.state || ''),
address: address || name || '',
name: isPlace ? name : '',
lat: f.geometry.coordinates[1],
lon: f.geometry.coordinates[0],
};
},
async search() {
const q = this.query.trim();
if (q.length < 2) { this.results = []; return; }
if (this._abortCtrl) this._abortCtrl.abort();
this._abortCtrl = new AbortController();
this.loading = true;
try {
const params = new URLSearchParams({
q: q, lang: 'de', limit: '7',
lat: '51.4', lon: '7.5',
bbox: '5.87,50.32,9.46,52.53',
});
const resp = await fetch('https://photon.komoot.io/api/?' + params, {
signal: this._abortCtrl.signal
});
const data = await resp.json();
this.results = (data.features || []).map(f => this.formatFeature(f));
} catch (e) {
if (e.name !== 'AbortError') this.results = [];
} finally {
this.loading = false;
}
},
select(r) {
this.$refs.addressText.value = r.address;
this.$refs.lat.value = r.lat;
this.$refs.lng.value = r.lon;
this.selectedAddress = r.address;
this.query = r.address;
this.results = [];
if (!this.locationName && r.name) {
this.locationName = r.name;
}
}
};
}
</script>
</x-layouts.admin>

View File

@@ -0,0 +1,124 @@
<x-layouts.admin :title="__('admin.events_title')">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{{ __('admin.events_title') }}</h1>
<a href="{{ route('admin.events.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('admin.new_event') }}
</a>
</div>
{{-- Filter --}}
<form method="GET" class="mb-4 flex gap-3">
<select name="team_id" onchange="this.form.submit()" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="">{{ __('ui.all_teams') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ request('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
<select name="status" onchange="this.form.submit()" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="">{{ __('admin.all_status') }}</option>
@foreach (\App\Enums\EventStatus::cases() as $status)
<option value="{{ $status->value }}" {{ request('status') === $status->value ? 'selected' : '' }}>{{ $status->label() }}</option>
@endforeach
</select>
</form>
{{-- Event-Karten --}}
@forelse ($events as $event)
@php
$minStatus = $event->minimumsStatus();
$bgClass = match($minStatus) { true => 'bg-green-50 border-green-200', false => 'bg-red-50 border-red-200', default => 'bg-white border-gray-200' };
@endphp
<div class="{{ $bgClass }} border rounded-lg shadow-sm p-4 mb-3">
<div class="flex flex-col lg:flex-row lg:items-center gap-4">
{{-- Links: Typ, Team, Titel, Datum, Ort, Status --}}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<x-event-type-badge :type="$event->type" />
<span class="text-xs text-gray-500">{{ $event->team->name }}</span>
@if ($event->status === \App\Enums\EventStatus::Published)
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">{{ $event->status->label() }}</span>
@elseif ($event->status === \App\Enums\EventStatus::Cancelled)
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">{{ $event->status->label() }}</span>
@else
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">{{ $event->status->label() }}</span>
@endif
</div>
<h3 class="font-semibold text-gray-900 {{ $event->status === \App\Enums\EventStatus::Cancelled ? 'line-through text-gray-400' : '' }}">
{{ $event->title }}
</h3>
<p class="text-sm text-gray-600">
{{ $event->start_at->translatedFormat(__('ui.date_format')) }} {{ __('ui.clock') }}
@if ($event->end_at)
{{ $event->end_at->format('H:i') }} {{ __('ui.clock') }}
@endif
</p>
@if ($event->location_name)
<p class="text-sm text-gray-500">{{ $event->location_name }}</p>
@endif
</div>
{{-- Mitte: Ampel-Boxen --}}
<div class="shrink-0">
<x-event-status-boxes :event="$event" />
</div>
{{-- Rechts: Aktionen --}}
<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>
<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>
</div>
</div>
</div>
</div>
@empty
<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">
{{ __('admin.no_events_yet') }}
</div>
@endforelse
<div class="mt-4">{{ $events->links() }}</div>
{{-- Papierkorb --}}
@if ($trashedEvents->isNotEmpty())
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-700 mb-3">{{ __('admin.trash') }}</h2>
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto border border-red-200">
<table class="w-full text-sm">
<thead class="bg-red-50 border-b border-red-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.event_title') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.nav_teams') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.deleted_at') }}</th>
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($trashedEvents as $event)
<tr class="hover:bg-red-50/50">
<td class="px-4 py-3 font-medium text-gray-500">{{ $event->title }}</td>
<td class="px-4 py-3 text-gray-400">{{ $event->team?->name ?? '' }}</td>
<td class="px-4 py-3 text-center text-xs text-gray-500">{{ $event->deleted_at->diffForHumans() }}</td>
<td class="px-4 py-3 text-right">
@if ($event->isRestorable())
<form method="POST" action="{{ route('admin.events.restore', $event->id) }}" class="inline">
@csrf
@method('PUT')
<button type="submit" class="text-xs text-green-600 hover:text-green-800 font-medium">{{ __('admin.restore') }}</button>
</form>
@else
<span class="text-xs text-gray-400">{{ __('admin.restore_expired') }}</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</x-layouts.admin>

View File

@@ -0,0 +1,49 @@
<x-layouts.admin :title="__('admin.upload_file')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.upload_file') }}</h1>
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
<form method="POST" action="{{ route('admin.files.store') }}" enctype="multipart/form-data">
@csrf
<div class="mb-4">
<label for="file_category_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.file_category') }} *</label>
<select name="file_category_id" id="file_category_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">{{ __('admin.select_category') }}</option>
@foreach ($categories as $cat)
<option value="{{ $cat->id }}" {{ old('file_category_id') == $cat->id ? 'selected' : '' }}>{{ $cat->name }}</option>
@endforeach
</select>
@error('file_category_id')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="mb-4">
<label for="file" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.upload_file') }} *</label>
<div x-data="{ fileName: '', dragging: false }" class="relative">
<div
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; $refs.fileInput.files = $event.dataTransfer.files; fileName = $refs.fileInput.files[0]?.name || ''"
:class="dragging ? 'border-blue-400 bg-blue-50' : 'border-gray-300'"
class="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition-colors"
@click="$refs.fileInput.click()">
<svg class="mx-auto h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
<p class="mt-2 text-sm text-gray-600" x-show="!fileName">{{ __('admin.allowed_file_types') }}</p>
<p class="mt-1 text-xs text-gray-500" x-show="!fileName">{{ __('admin.max_file_size') }}</p>
<p class="mt-2 text-sm font-medium text-blue-600" x-show="fileName" x-text="fileName"></p>
</div>
<input type="file" name="file" id="file" x-ref="fileInput" class="hidden"
accept=".pdf,.docx,.xlsx,.jpg,.jpeg,.png,.gif,.webp"
@change="fileName = $refs.fileInput.files[0]?.name || ''">
</div>
@error('file')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="flex items-center gap-3">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('admin.upload_file') }}</button>
<a href="{{ route('admin.files.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
</div>
</form>
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,89 @@
<x-layouts.admin :title="__('admin.files_title')">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{{ __('admin.files_title') }}</h1>
<a href="{{ route('admin.files.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('admin.upload_file') }}
</a>
</div>
{{-- Kategorie-Tabs --}}
<div class="flex flex-wrap gap-2 mb-6">
<a href="{{ route('admin.files.index') }}"
class="px-3 py-1.5 rounded-md text-sm font-medium {{ !$activeCategory ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
{{ __('ui.all') }} ({{ $categories->sum('files_count') }})
</a>
@foreach ($categories as $category)
<a href="{{ route('admin.files.index', ['category' => $category->slug]) }}"
class="px-3 py-1.5 rounded-md text-sm font-medium {{ $activeCategory === $category->slug ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }} {{ !$category->is_active ? 'opacity-50' : '' }}">
{{ $category->name }} ({{ $category->files_count }})
</a>
@endforeach
</div>
{{-- Datei-Liste --}}
@if ($files->isEmpty())
<div class="bg-white rounded-lg shadow p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m6.75 12H9.75m3 0h3m-1.5-3H12m-3 3V18m0-3h.008v.008H9v-.008zm0 3h.008v.008H9V18z" />
</svg>
<p class="mt-3 text-sm text-gray-500">{{ $activeCategory ? __('admin.no_files_yet') : __('admin.no_files_at_all') }}</p>
</div>
@else
<div class="grid gap-3" x-data>
@foreach ($files as $file)
<div class="bg-white rounded-lg shadow px-4 sm:px-5 py-4 flex items-center gap-3 sm:gap-4 min-w-0">
{{-- Clickable file area --}}
<div @click="$dispatch('open-file-preview', @js($file->previewData()))"
class="flex items-center gap-3 min-w-0 flex-1 cursor-pointer hover:opacity-80 transition-opacity">
{{-- Thumbnail oder Icon --}}
@if ($file->isImage())
<img src="{{ route('files.preview', $file) }}" alt="" class="flex-shrink-0 w-10 h-10 rounded-lg object-cover bg-gray-100" loading="lazy">
@else
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center
{{ match($file->iconType()) {
'pdf' => 'bg-red-100 text-red-600',
'word' => 'bg-blue-100 text-blue-600',
'excel' => 'bg-green-100 text-green-600',
default => 'bg-gray-100 text-gray-600',
} }}">
@if ($file->iconType() === 'pdf')
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
@elseif ($file->iconType() === 'word')
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM9 13h6v1H9v-1zm0 2h6v1H9v-1zm0 2h4v1H9v-1z"/></svg>
@elseif ($file->iconType() === 'excel')
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM9 13h2v2H9v-2zm0 3h2v2H9v-2zm3-3h2v2h-2v-2zm0 3h2v2h-2v-2z"/></svg>
@else
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
@endif
</div>
@endif
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ $file->original_name }}</p>
<div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-gray-500 mt-0.5">
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">{{ $file->category->name }}</span>
<span>{{ $file->humanSize() }}</span>
<span class="hidden sm:inline">{{ $file->uploader->name ?? '' }}</span>
<span class="hidden sm:inline">{{ $file->created_at->translatedFormat(__('ui.date_format_short')) }}</span>
</div>
</div>
</div>
{{-- Admin Actions --}}
<div class="flex items-center gap-2 flex-shrink-0">
<a href="{{ route('files.download', $file) }}" class="text-xs text-blue-600 hover:text-blue-800 font-medium">{{ __('ui.download') }}</a>
<form method="POST" action="{{ route('admin.files.destroy', $file) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_file')))">
@csrf
@method('DELETE')
<button type="submit" class="text-xs text-red-600 hover:text-red-800">{{ __('ui.delete') }}</button>
</form>
</div>
</div>
@endforeach
</div>
<div class="mt-4">{{ $files->links() }}</div>
@endif
<x-file-preview-modal />
</x-layouts.admin>

View File

@@ -0,0 +1,49 @@
<x-layouts.admin :title="__('admin.create_invitation')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.new_invitation') }}</h1>
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
<form method="POST" action="{{ route('admin.invitations.store') }}">
@csrf
<div class="mb-4">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.email_optional') }}</label>
<input type="email" name="email" id="email" value="{{ old('email') }}"
placeholder="{{ __('admin.email_optional_hint') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('email') border-red-500 @enderror">
@error('email')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="mb-4">
<label for="expires_in_days" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.valid_for_days') }} *</label>
<input type="number" name="expires_in_days" id="expires_in_days" value="{{ old('expires_in_days', 7) }}" min="1" max="90" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('expires_in_days') border-red-500 @enderror">
@error('expires_in_days')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.assign_players') }}</label>
<p class="text-xs text-gray-500 mb-2">{{ __('admin.player_assignment_hint') }}</p>
<div class="border border-gray-300 rounded-md max-h-60 overflow-y-auto p-2 space-y-1">
@forelse ($players as $player)
<label class="flex items-center px-2 py-1 hover:bg-gray-50 rounded cursor-pointer">
<input type="checkbox" name="player_ids[]" value="{{ $player->id }}"
{{ in_array($player->id, old('player_ids', [])) ? 'checked' : '' }}
class="rounded border-gray-300 mr-2">
<span class="text-sm">{{ $player->full_name }}</span>
<span class="text-xs text-gray-400 ml-auto">{{ $player->team->name }}</span>
</label>
@empty
<p class="text-sm text-gray-400 px-2 py-1">{{ __('admin.no_active_players') }}</p>
@endforelse
</div>
@error('player_ids')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
@error('player_ids.*')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="flex items-center gap-3">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('admin.create_invitation') }}</button>
<a href="{{ route('admin.invitations.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
</div>
</form>
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,113 @@
<x-layouts.admin :title="__('admin.invitations_title')">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{{ __('admin.invitations_title') }}</h1>
<a href="{{ route('admin.invitations.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('admin.new_invitation') }}
</a>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.invite_link') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('ui.email') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.nav_players') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.status') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.created_label') }}</th>
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.action') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($invitations as $invitation)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3" x-data="{ copied: false }">
<div class="flex items-center gap-2">
<code class="text-xs bg-gray-100 px-2 py-1 rounded truncate max-w-[200px]" x-ref="link">{{ route('register', $invitation->token) }}</code>
<button
type="button"
@click="copyToClipboard('{{ route('register', $invitation->token) }}', () => { copied = true; setTimeout(() => copied = false, 2000) })"
class="inline-flex items-center gap-1 px-2 py-1 rounded border text-xs font-medium transition-colors"
:class="copied ? 'bg-green-50 border-green-300 text-green-700' : 'bg-white border-gray-300 text-blue-600 hover:bg-blue-50 hover:border-blue-400'"
>
<svg x-show="!copied" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
<svg x-show="copied" x-cloak class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
<span x-text="copied ? '{{ __('admin.copied') }}' : '{{ __('admin.copy_link') }}'"></span>
</button>
</div>
</td>
<td class="px-4 py-3 text-gray-600">
{{ $invitation->email ?? '' }}
</td>
<td class="px-4 py-3 text-xs text-gray-600">
@foreach ($invitation->players as $player)
{{ $player->full_name }} ({{ $player->team->name }})@if (!$loop->last), @endif
@endforeach
@if ($invitation->players->isEmpty())
<span class="text-gray-400">{{ __('admin.no_assignment') }}</span>
@endif
</td>
<td class="px-4 py-3 text-center">
@if ($invitation->isAccepted())
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">{{ __('admin.used') }}</span>
@elseif ($invitation->isValid())
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">{{ __('admin.pending') }}</span>
@else
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">{{ __('admin.expired') }}</span>
@endif
</td>
<td class="px-4 py-3 text-xs text-gray-500">
{{ $invitation->created_at->translatedFormat(__('ui.date_format_short')) }}<br>
<span class="text-gray-400">{{ __('admin.created_by') }} {{ $invitation->creator->name }}</span><br>
<span class="text-gray-400">{{ __('admin.valid_until') }} {{ $invitation->expires_at->translatedFormat(__('ui.date_format_date')) }}</span>
</td>
<td class="px-4 py-3 text-right">
@if (!$invitation->isAccepted())
<form method="POST" action="{{ route('admin.invitations.destroy', $invitation) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_invitation')))">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800 text-xs">{{ __('ui.delete') }}</button>
</form>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500">{{ __('admin.no_invitations_yet') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">{{ $invitations->links() }}</div>
@push('scripts')
<script>
function copyToClipboard(text, onSuccess) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(onSuccess).catch(function () {
fallbackCopy(text, onSuccess);
});
} else {
fallbackCopy(text, onSuccess);
}
}
function fallbackCopy(text, onSuccess) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
onSuccess();
} catch (e) {
console.error('Copy failed', e);
}
document.body.removeChild(ta);
}
</script>
@endpush
</x-layouts.admin>

View File

@@ -0,0 +1,166 @@
<x-layouts.admin :title="__('admin.list_generator_title')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.list_generator_title') }}</h1>
<form method="POST" action="{{ route('admin.list-generator.store') }}"
x-data="{
source: @js(old('source', 'players')),
customColumns: @js(old('custom_columns', [])),
addColumn() { this.customColumns.push('') },
removeColumn(i) { this.customColumns.splice(i, 1) }
}">
@csrf
<div class="bg-white rounded-lg shadow p-6 space-y-5">
{{-- Betreff --}}
<div>
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.list_title') }} *</label>
<input type="text" name="title" id="title" value="{{ old('title') }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm @error('title') border-red-500 @enderror"
placeholder="{{ __('admin.list_title') }}">
@error('title')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Untertitel --}}
<div>
<label for="subtitle" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.list_subtitle') }}</label>
<input type="text" name="subtitle" id="subtitle" value="{{ old('subtitle') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="{{ __('admin.list_subtitle') }}">
</div>
{{-- Notizen --}}
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.list_notes') }}</label>
<textarea name="notes" id="notes" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="{{ __('admin.list_notes') }}">{{ old('notes') }}</textarea>
</div>
{{-- Team-Filter --}}
<div>
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.nav_teams') }}</label>
<select name="team_id" id="team_id" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="">{{ __('admin.list_all_teams') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ old('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
</div>
{{-- Zeilen-Quelle --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.list_source') }} *</label>
<div class="flex flex-wrap gap-4">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="radio" name="source" value="players" x-model="source" class="text-blue-600">
<span class="text-sm">{{ __('admin.list_source_players') }}</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="radio" name="source" value="parents" x-model="source" class="text-blue-600">
<span class="text-sm">{{ __('admin.list_source_parents') }}</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="radio" name="source" value="freetext" x-model="source" class="text-blue-600">
<span class="text-sm">{{ __('admin.list_source_freetext') }}</span>
</label>
</div>
@error('source')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Freitext-Feld --}}
<div x-show="source === 'freetext'" x-cloak>
<label for="freetext_rows" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.list_source_freetext') }}</label>
<textarea name="freetext_rows" id="freetext_rows" rows="6"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
placeholder="{{ __('admin.list_freetext_hint') }}">{{ old('freetext_rows') }}</textarea>
@error('freetext_rows')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Spalten: Spieler --}}
<div x-show="source === 'players'" x-cloak>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.list_columns') }}</label>
<div class="flex flex-wrap gap-x-6 gap-y-2">
<label class="inline-flex items-center gap-2">
<input type="checkbox" checked disabled class="text-blue-600 rounded">
<span class="text-sm text-gray-600">{{ __('ui.name') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="columns[]" value="team" {{ in_array('team', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
<span class="text-sm">{{ __('admin.nav_teams') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="columns[]" value="jersey_number" {{ in_array('jersey_number', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
<span class="text-sm">{{ __('admin.jersey_number') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="columns[]" value="birth_year" {{ in_array('birth_year', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
<span class="text-sm">{{ __('admin.birth_year') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="columns[]" value="parents" {{ in_array('parents', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
<span class="text-sm">{{ __('admin.parents') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="columns[]" value="photo_permission" {{ in_array('photo_permission', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
<span class="text-sm">{{ __('admin.photo_permission') }}</span>
</label>
</div>
</div>
{{-- Spalten: Eltern --}}
<div x-show="source === 'parents'" x-cloak>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.list_columns') }}</label>
<div class="flex flex-wrap gap-x-6 gap-y-2">
<label class="inline-flex items-center gap-2">
<input type="checkbox" checked disabled class="text-blue-600 rounded">
<span class="text-sm text-gray-600">{{ __('ui.name') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="columns[]" value="team" {{ in_array('team', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
<span class="text-sm">{{ __('admin.nav_teams') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="columns[]" value="email" {{ in_array('email', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
<span class="text-sm">{{ __('ui.email') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="columns[]" value="phone" {{ in_array('phone', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
<span class="text-sm">{{ __('admin.phone') }}</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="columns[]" value="children" {{ in_array('children', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
<span class="text-sm">{{ __('admin.children') }}</span>
</label>
</div>
</div>
{{-- Zusätzliche Spalten --}}
<div x-show="source !== ''" x-cloak>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.list_custom_columns') }}</label>
<div class="space-y-2">
<template x-for="(col, index) in customColumns" :key="index">
<div class="flex items-center gap-2">
<input type="text" :name="'custom_columns[' + index + ']'" x-model="customColumns[index]"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
:placeholder="'{{ __('admin.list_column_name') }}'">
<button type="button" @click="removeColumn(index)" class="text-red-500 hover:text-red-700 p-1">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</template>
</div>
<button type="button" @click="addColumn()" class="mt-2 text-sm text-blue-600 hover:text-blue-800 inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
{{ __('admin.list_add_column') }}
</button>
</div>
{{-- Submit --}}
<div class="pt-2">
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('admin.list_generate') }}
</button>
</div>
</div>
</form>
</x-layouts.admin>

View File

@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>{{ $title }}</title>
<style>
@page {
size: {{ $orientation ?? 'portrait' }};
margin: 10mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'DejaVu Sans', Helvetica, Arial, sans-serif;
font-size: {{ $fontSize ?? 10 }}pt;
color: #374151;
background: #f3f4f6;
}
/* Card container */
.card {
background: #ffffff;
border-radius: 8px;
overflow: hidden;
}
/* Dark header */
.card-header {
background: #1f2937;
padding: 14px 20px;
}
.card-header h1 {
font-size: {{ max(($fontSize ?? 10) + 4, 13) }}pt;
font-weight: 700;
color: #ffffff;
margin: 0;
}
.card-header .subtitle {
font-size: {{ max(($fontSize ?? 10), 9) }}pt;
color: #d1d5db;
margin-top: 2px;
}
/* Notes bar */
.notes-bar {
padding: 8px 20px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
font-size: {{ max(($fontSize ?? 10) - 1, 8) }}pt;
color: #4b5563;
}
/* Table */
table {
width: 100%;
border-collapse: collapse;
}
th {
background: #f3f4f6;
border-bottom: 1px solid #e5e7eb;
padding: 7px 12px;
text-align: left;
font-size: {{ max(($fontSize ?? 10) - 2, 7) }}pt;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 6px 12px;
border-bottom: 1px solid #f3f4f6;
font-size: {{ $fontSize ?? 10 }}pt;
color: #374151;
}
tr:nth-child(even) td {
background: #fafbfc;
}
.row-num {
color: #9ca3af;
font-size: {{ max(($fontSize ?? 10) - 2, 7) }}pt;
width: 28px;
font-family: monospace;
}
.empty-cell {
color: #d1d5db;
}
.link {
color: #2563eb;
text-decoration: none;
}
/* Footer bar */
.card-footer {
padding: 8px 20px;
background: #f9fafb;
border-top: 1px solid #e5e7eb;
font-size: {{ max(($fontSize ?? 10) - 3, 7) }}pt;
color: #9ca3af;
}
.card-footer table { border: none; }
.card-footer td {
border: none;
padding: 0;
background: none;
font-size: {{ max(($fontSize ?? 10) - 3, 7) }}pt;
color: #9ca3af;
}
.card-footer .right {
text-align: right;
}
</style>
</head>
<body>
<div class="card">
{{-- Dark header --}}
<div class="card-header">
<h1>{{ $title }}</h1>
@if ($subtitle)
<div class="subtitle">{{ $subtitle }}</div>
@endif
</div>
{{-- Notes --}}
@if ($notes)
<div class="notes-bar">{{ $notes }}</div>
@endif
{{-- Data table --}}
<table>
<thead>
<tr>
<th class="row-num">#</th>
@foreach ($columns as $key => $header)
<th>{{ $header }}</th>
@endforeach
</tr>
</thead>
<tbody>
@forelse ($rows as $i => $row)
<tr>
<td class="row-num">{{ $i + 1 }}</td>
@foreach ($columns as $key => $header)
<td>
@if (($key === 'email') && !empty($row[$key]))
<a class="link" href="mailto:{{ $row[$key] }}">{{ $row[$key] }}</a>
@elseif (($key === 'phone') && !empty($row[$key]) && $row[$key] !== '')
<a class="link" href="tel:{{ $row[$key] }}">{{ $row[$key] }}</a>
@elseif (($row[$key] ?? '') === '')
<span class="empty-cell">&mdash;</span>
@else
{{ $row[$key] }}
@endif
</td>
@endforeach
</tr>
@empty
<tr>
<td colspan="{{ count($columns) + 1 }}" style="text-align: center; color: #9ca3af; padding: 20px;">
{{ __('admin.no_entries') }}
</td>
</tr>
@endforelse
</tbody>
</table>
{{-- Footer bar --}}
<div class="card-footer">
<table>
<tr>
<td>{{ count($rows) }} {{ __('admin.list_entries_count') }}</td>
<td class="right">{{ __('admin.list_generated_at') }}: {{ $generatedAt->translatedFormat('d.m.Y, H:i') }} Uhr</td>
</tr>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,90 @@
<x-layouts.admin :title="__('admin.list_result_title')">
{{-- Success Banner --}}
<div class="no-print mb-5 bg-green-50 border border-green-200 rounded-lg p-4 flex items-start gap-3">
<svg class="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" 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>
<div>
<p class="text-sm font-medium text-green-800">{{ __('admin.list_result_title') }}</p>
<p class="text-xs text-green-600 mt-0.5">{{ __('admin.list_saved_info', ['name' => $file->original_name]) }}</p>
</div>
</div>
{{-- Action Buttons --}}
<div class="no-print mb-5 flex flex-wrap items-center gap-3">
<a href="{{ route('files.download', $file) }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium inline-flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
{{ __('admin.list_download_pdf') }}
</a>
<a href="{{ route('admin.list-generator.create') }}" class="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-50 text-sm font-medium inline-flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
{{ __('admin.list_new') }}
</a>
<a href="{{ route('admin.files.index', ['category' => 'allgemein']) }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
{{ __('admin.list_to_library') }}
</a>
</div>
{{-- Preview Card --}}
<div class="bg-white rounded-lg shadow overflow-hidden">
{{-- Card Header --}}
<div class="bg-gray-800 px-6 py-4">
<h1 class="text-xl font-bold text-white">{{ $title }}</h1>
@if ($subtitle)
<p class="text-gray-300 text-sm mt-0.5">{{ $subtitle }}</p>
@endif
</div>
{{-- Notes --}}
@if ($notes)
<div class="px-6 py-3 bg-gray-50 border-b border-gray-200">
<p class="text-sm text-gray-600">{{ $notes }}</p>
</div>
@endif
{{-- Table --}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-100 border-b border-gray-200">
<th class="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-10">#</th>
@foreach ($columns as $key => $header)
<th class="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{{ $header }}</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($rows as $i => $row)
<tr class="hover:bg-blue-50/50 transition-colors">
<td class="px-4 py-2 text-gray-400 text-xs font-mono">{{ $i + 1 }}</td>
@foreach ($columns as $key => $header)
<td class="px-4 py-2 {{ ($row[$key] ?? '') === '' ? 'text-gray-300' : 'text-gray-700' }}">
@if (($key === 'email') && !empty($row[$key]))
<a href="mailto:{{ $row[$key] }}" class="text-blue-600 hover:underline">{{ $row[$key] }}</a>
@elseif (($key === 'phone') && !empty($row[$key]) && $row[$key] !== '')
<a href="tel:{{ $row[$key] }}" class="text-blue-600 hover:underline">{{ $row[$key] }}</a>
@elseif (($row[$key] ?? '') === '')
<span class="text-gray-300">&mdash;</span>
@else
{{ $row[$key] }}
@endif
</td>
@endforeach
</tr>
@empty
<tr>
<td colspan="{{ count($columns) + 1 }}" class="px-4 py-8 text-center text-gray-400">
{{ __('admin.no_entries') }}
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Card Footer --}}
<div class="px-6 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
<span class="text-xs text-gray-400">{{ count($rows) }} {{ __('admin.list_entries_count') }}</span>
<span class="text-xs text-gray-400">{{ __('admin.list_generated_at') }}: {{ now()->translatedFormat(__('ui.date_format')) }}</span>
</div>
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,196 @@
<x-layouts.admin :title="__('admin.locations_title')">
@push('styles')
<style>
.photon-dropdown { position: absolute; z-index: 10; margin-top: 0.25rem; width: 100%; background: white; border: 1px solid #d1d5db; border-radius: 0.375rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,.1); max-height: 15rem; overflow-y: auto; }
</style>
@endpush
<h1 class="text-2xl font-bold mb-6">{{ __('admin.locations_title') }}</h1>
{{-- Neuen Ort anlegen --}}
<div class="bg-white rounded-lg shadow p-6 mb-6 max-w-3xl" x-data="locationForm()">
<h2 class="text-lg font-semibold mb-4">{{ __('admin.location_add') }}</h2>
<form method="POST" action="{{ route('admin.locations.store') }}">
@csrf
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.location_name_label') }} *</label>
<input type="text" name="name" value="{{ old('name') }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="{{ __('admin.location_name_placeholder') }}">
@error('name')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
</div>
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.search_address') }}</label>
<input type="text" x-model="query" @input.debounce.300ms="search()" @keydown.escape="results = []"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="{{ __('admin.search_address_hint') }}" autocomplete="off">
<div x-show="results.length > 0" x-cloak @click.outside="results = []" class="photon-dropdown">
<template x-for="(r, idx) in results" :key="idx">
<button type="button" @click="select(r)" class="w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 text-sm">
<span class="block font-medium text-gray-900" x-text="r.title"></span>
<span class="block text-xs text-gray-500" x-text="r.subtitle"></span>
</button>
</template>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.address') }}</label>
<input type="text" name="address_text" x-model="addressText"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="{{ __('admin.address_manual_hint') }}">
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Lat</label>
<input type="text" name="location_lat" x-model="lat" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" readonly>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Lng</label>
<input type="text" name="location_lng" x-model="lng" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" readonly>
</div>
</div>
</div>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('admin.location_save') }}</button>
</form>
</div>
{{-- Bestehende Orte --}}
@if ($locations->isNotEmpty())
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto max-w-3xl">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.location_name_label') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.address') }}</th>
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($locations as $location)
<tr class="hover:bg-gray-50" x-data="{ editing: false }">
{{-- Anzeige-Modus --}}
<template x-if="!editing">
<td class="px-4 py-3 font-medium text-gray-900">{{ $location->name }}</td>
</template>
<template x-if="!editing">
<td class="px-4 py-3 text-gray-600">
{{ $location->address_text ?: '' }}
@if ($location->location_lat)
<span class="text-xs text-gray-400 ml-1">({{ number_format($location->location_lat, 4) }}, {{ number_format($location->location_lng, 4) }})</span>
@endif
</td>
</template>
<template x-if="!editing">
<td class="px-4 py-3 text-right whitespace-nowrap">
<button type="button" @click="editing = true" class="text-xs text-blue-600 hover:text-blue-800 mr-2">{{ __('ui.edit') }}</button>
<form method="POST" action="{{ route('admin.locations.destroy', $location) }}" class="inline" onsubmit="return confirm(@js(__('admin.location_confirm_delete')))">
@csrf
@method('DELETE')
<button type="submit" class="text-xs text-red-600 hover:text-red-800">{{ __('ui.delete') }}</button>
</form>
</td>
</template>
{{-- Bearbeitungs-Modus --}}
<template x-if="editing">
<td colspan="3" class="px-4 py-3" x-data="locationForm(@js($location->address_text ?? ''), @js($location->location_lat), @js($location->location_lng))">
<form method="POST" action="{{ route('admin.locations.update', $location) }}">
@csrf
@method('PUT')
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 items-end">
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.location_name_label') }}</label>
<input type="text" name="name" value="{{ $location->name }}" required class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
</div>
<div class="relative">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.search_address') }}</label>
<input type="text" x-model="query" @input.debounce.300ms="search()" @keydown.escape="results = []"
class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm" autocomplete="off">
<div x-show="results.length > 0" x-cloak @click.outside="results = []" class="photon-dropdown">
<template x-for="(r, idx) in results" :key="idx">
<button type="button" @click="select(r)" class="w-full text-left px-2 py-1.5 hover:bg-blue-50 border-b border-gray-100 text-xs">
<span class="block font-medium" x-text="r.title"></span>
<span class="block text-gray-500" x-text="r.subtitle"></span>
</button>
</template>
</div>
</div>
<div>
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.address') }}</label>
<input type="text" name="address_text" x-model="addressText" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
<input type="hidden" name="location_lat" x-model="lat">
<input type="hidden" name="location_lng" x-model="lng">
</div>
<div class="flex gap-2">
<button type="submit" class="bg-blue-600 text-white px-3 py-1.5 rounded-md text-xs font-medium hover:bg-blue-700">{{ __('ui.save') }}</button>
<button type="button" @click="editing = false" class="text-xs text-gray-600 hover:underline">{{ __('ui.cancel') }}</button>
</div>
</div>
</form>
</td>
</template>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-sm text-gray-500">{{ __('admin.locations_empty') }}</p>
@endif
@push('scripts')
<script>
function locationForm(initAddress = '', initLat = '', initLng = '') {
return {
query: '',
results: [],
addressText: initAddress || '',
lat: initLat || '',
lng: initLng || '',
_abortCtrl: null,
formatFeature(f) {
const p = f.properties;
const street = [p.street, p.housenumber].filter(Boolean).join(' ');
const effectiveStreet = street || (p.name && p.name !== p.city ? p.name : '');
const cityLine = [p.postcode, p.city].filter(Boolean).join(' ');
const address = [effectiveStreet, cityLine].filter(Boolean).join(', ');
const name = p.name || '';
const isPlace = name && name !== effectiveStreet && name !== p.city && name !== p.street;
return {
title: isPlace ? name : (address || name || ''),
subtitle: isPlace ? address : (cityLine || p.state || ''),
address: address || name || '',
lat: f.geometry.coordinates[1],
lon: f.geometry.coordinates[0],
};
},
async search() {
const q = this.query.trim();
if (q.length < 2) { this.results = []; return; }
if (this._abortCtrl) this._abortCtrl.abort();
this._abortCtrl = new AbortController();
try {
const params = new URLSearchParams({
q: q, lang: 'de', limit: '7',
lat: '51.4', lon: '7.5',
bbox: '5.87,50.32,9.46,52.53',
});
const resp = await fetch('https://photon.komoot.io/api/?' + params, { signal: this._abortCtrl.signal });
const data = await resp.json();
this.results = (data.features || []).map(f => this.formatFeature(f));
} catch (e) {
if (e.name !== 'AbortError') this.results = [];
}
},
select(r) {
this.addressText = r.address;
this.lat = r.lat;
this.lng = r.lon;
this.query = r.address;
this.results = [];
}
};
}
</script>
@endpush
</x-layouts.admin>

View File

@@ -0,0 +1,71 @@
<x-layouts.admin :title="__('admin.new_player')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.new_player') }}</h1>
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
<form method="POST" action="{{ route('admin.players.store') }}">
@csrf
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.first_name') }} *</label>
<input type="text" name="first_name" id="first_name" value="{{ old('first_name') }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('first_name') border-red-500 @enderror">
@error('first_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.last_name') }} *</label>
<input type="text" name="last_name" id="last_name" value="{{ old('last_name') }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('last_name') border-red-500 @enderror">
@error('last_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
</div>
<div class="mb-4">
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-1">Team *</label>
<select name="team_id" id="team_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">{{ __('admin.please_select') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ old('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
@error('team_id')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="birth_year" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.birth_year') }}</label>
<input type="number" name="birth_year" id="birth_year" value="{{ old('birth_year') }}" min="2000" max="2030"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div>
<label for="jersey_number" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.jersey_number') }}</label>
<input type="number" name="jersey_number" id="jersey_number" value="{{ old('jersey_number') }}" min="1" max="99"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
</div>
<div class="mb-4 space-y-2">
<label class="flex items-center">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" {{ old('is_active', '1') == '1' ? 'checked' : '' }} class="rounded border-gray-300 mr-2">
<span class="text-sm text-gray-700">{{ __('admin.active') }}</span>
</label>
<label class="flex items-center">
<input type="hidden" name="photo_permission" value="0">
<input type="checkbox" name="photo_permission" value="1" {{ old('photo_permission') ? 'checked' : '' }} class="rounded border-gray-300 mr-2">
<span class="text-sm text-gray-700">{{ __('admin.photo_permission') }}</span>
</label>
</div>
<div class="mb-4">
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.notes') }}</label>
<textarea name="notes" id="notes" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-md">{{ old('notes') }}</textarea>
</div>
<div class="flex items-center gap-3">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('ui.create') }}</button>
<a href="{{ route('admin.players.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
</div>
</form>
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,192 @@
<x-layouts.admin :title="__('admin.players_title')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.players_title') }}: {{ $player->full_name }}</h1>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Spieler-Daten --}}
<div class="bg-white rounded-lg shadow p-6">
<h2 class="font-semibold mb-4">{{ __('admin.player_data') }}</h2>
<form method="POST" action="{{ route('admin.players.update', $player) }}" enctype="multipart/form-data">
@csrf
@method('PUT')
{{-- Profilbild --}}
<div class="mb-5" x-data="{ preview: null }">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.profile_picture') }}</label>
<div class="flex items-center gap-4">
<div class="relative">
@if ($player->getAvatarUrl())
<img src="{{ $player->getAvatarUrl() }}" alt="{{ $player->full_name }}" class="w-14 h-14 rounded-full object-cover border-2 border-gray-200" x-show="!preview">
@else
<div class="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center text-gray-500 font-semibold border-2 border-gray-200" x-show="!preview">
{{ $player->getInitials() }}
</div>
@endif
<img :src="preview" x-show="preview" class="w-14 h-14 rounded-full object-cover border-2 border-blue-400" x-cloak>
</div>
<div class="flex flex-col gap-1">
<label class="cursor-pointer bg-gray-100 text-gray-700 px-3 py-1.5 rounded-md text-xs hover:bg-gray-200 inline-block">
{{ __('admin.upload_picture') }}
<input type="file" name="profile_picture" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden"
@change="if ($event.target.files[0]) { preview = URL.createObjectURL($event.target.files[0]) }">
</label>
<span class="text-xs text-gray-400">{{ __('admin.max_picture_size') }}</span>
@error('profile_picture')
<p class="text-red-600 text-xs">{{ $message }}</p>
@enderror
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.first_name') }} *</label>
<input type="text" name="first_name" id="first_name" value="{{ old('first_name', $player->first_name) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('first_name') border-red-500 @enderror">
@error('first_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.last_name') }} *</label>
<input type="text" name="last_name" id="last_name" value="{{ old('last_name', $player->last_name) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('last_name') border-red-500 @enderror">
@error('last_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
</div>
<div class="mb-4">
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-1">Team *</label>
<select name="team_id" id="team_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md">
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ old('team_id', $player->team_id) == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="birth_year" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.birth_year') }}</label>
<input type="number" name="birth_year" id="birth_year" value="{{ old('birth_year', $player->birth_year) }}" min="2000" max="2030"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div>
<label for="jersey_number" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.jersey_number') }}</label>
<input type="number" name="jersey_number" id="jersey_number" value="{{ old('jersey_number', $player->jersey_number) }}" min="1" max="99"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
</div>
<div class="mb-4">
<label class="flex items-center">
<input type="hidden" name="photo_permission" value="0">
<input type="checkbox" name="photo_permission" value="1" {{ old('photo_permission', $player->photo_permission) ? 'checked' : '' }} class="rounded border-gray-300 mr-2">
<span class="text-sm text-gray-700">{{ __('admin.photo_permission') }}</span>
</label>
</div>
<div class="mb-4">
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.notes') }}</label>
<textarea name="notes" id="notes" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-md">{{ old('notes', $player->notes) }}</textarea>
</div>
<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>
</form>
@if ($player->getAvatarUrl())
<div class="mt-3 pt-3 border-t">
<form method="POST" action="{{ route('admin.players.remove-picture', $player) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_file')))">
@csrf
@method('DELETE')
<button type="submit" class="text-xs text-red-500 hover:text-red-700">{{ __('admin.remove_picture') }}</button>
</form>
</div>
@endif
</div>
{{-- Eltern-Zuordnung --}}
<div class="bg-white rounded-lg shadow p-6">
<h2 class="font-semibold mb-4">{{ __('admin.parent_assignment') }}</h2>
@if ($player->parents->isNotEmpty())
<div class="mb-4 space-y-2">
@foreach ($player->parents as $parent)
<div class="flex items-center justify-between bg-gray-50 rounded px-3 py-2">
<div>
<span class="font-medium text-sm">{{ $parent->name }}</span>
@if ($parent->pivot->relationship_label)
<span class="text-xs text-gray-500">({{ $parent->pivot->relationship_label }})</span>
@endif
<span class="text-xs text-gray-400 block">{{ $parent->email }}</span>
</div>
<form method="POST" action="{{ route('admin.players.remove-parent', [$player, $parent]) }}" onsubmit="return confirm(@js(__('admin.confirm_remove_parent')))">
@csrf
@method('DELETE')
<button type="submit" class="text-red-500 hover:text-red-700 text-xs">{{ __('admin.remove') }}</button>
</form>
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-500 mb-4">{{ __('admin.no_parents_yet') }}</p>
@endif
<form method="POST" action="{{ route('admin.players.assign-parent', $player) }}" class="border-t pt-4">
@csrf
<div class="mb-3">
<label for="parent_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.add_parent') }}</label>
<select name="parent_id" id="parent_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="">{{ __('admin.select_user') }}</option>
@foreach ($users as $user)
<option value="{{ $user->id }}">{{ $user->name }} ({{ $user->email }})</option>
@endforeach
</select>
</div>
<div class="mb-3">
<label for="relationship_label" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.relationship_label') }}</label>
<input type="text" name="relationship_label" id="relationship_label" placeholder="{{ __('admin.relationship_placeholder') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<button type="submit" class="bg-gray-800 text-white px-3 py-1.5 rounded-md hover:bg-gray-900 text-sm">{{ __('admin.assign') }}</button>
</form>
</div>
</div>
{{-- Spieler deaktivieren / löschen --}}
<div class="mt-6 bg-white rounded-lg shadow p-6 border border-red-200">
<h2 class="font-semibold text-red-700 mb-2">{{ __('admin.danger_zone') }}</h2>
{{-- Deaktivieren / Aktivieren --}}
<div class="flex items-center justify-between py-3">
<div>
<p class="text-sm font-medium text-gray-900">{{ __('admin.player_status_label') }}</p>
<p class="text-xs text-gray-500">
{{ $player->is_active ? __('admin.deactivate_player_hint') : __('admin.activate_player_hint') }}
</p>
</div>
<form method="POST" action="{{ route('admin.players.toggle-active', $player) }}">
@csrf
@method('PUT')
<button type="submit" class="px-4 py-2 rounded-md text-sm font-medium {{ $player->is_active ? 'bg-yellow-500 text-white hover:bg-yellow-600' : 'bg-green-600 text-white hover:bg-green-700' }}">
{{ $player->is_active ? __('admin.deactivate') : __('admin.activate') }}
</button>
</form>
</div>
{{-- Löschen --}}
<div class="flex items-center justify-between py-3 border-t border-red-100">
<div>
<p class="text-sm font-medium text-gray-900">{{ __('admin.delete_player') }}</p>
<p class="text-xs text-gray-500">{{ __('admin.delete_player_hint') }}</p>
</div>
<form method="POST" action="{{ route('admin.players.destroy', $player) }}" onsubmit="return confirm(@js(__('admin.confirm_delete_player')))">
@csrf
@method('DELETE')
<button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 text-sm font-medium">
{{ __('admin.delete') }}
</button>
</form>
</div>
</div>
<div class="mt-4">
<a href="{{ route('admin.players.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('admin.back_to_list') }}</a>
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,231 @@
<x-layouts.admin :title="__('admin.players_title')">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{{ __('admin.players_title') }}</h1>
<a href="{{ route('admin.players.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('admin.new_player') }}
</a>
</div>
{{-- Filter --}}
<form method="GET" class="mb-4">
@if (request('sort'))
<input type="hidden" name="sort" value="{{ request('sort') }}">
<input type="hidden" name="direction" value="{{ request('direction') }}">
@endif
<select name="team_id" onchange="this.form.submit()" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="">{{ __('ui.all_teams') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ request('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
</form>
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'name', 'direction' => ($sort === 'name' && $direction === 'asc') ? 'desc' : 'asc']) }}"
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'name' ? 'text-blue-600' : '' }}">
{{ __('ui.name') }}
@if ($sort === 'name')
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
@if ($direction === 'asc')
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
@else
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
@endif
</svg>
@endif
</a>
</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'team', 'direction' => ($sort === 'team' && $direction === 'asc') ? 'desc' : 'asc']) }}"
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'team' ? 'text-blue-600' : '' }}">
{{ __('admin.nav_teams') }}
@if ($sort === 'team')
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
@if ($direction === 'asc')
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
@else
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
@endif
</svg>
@endif
</a>
</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'jersey_number', 'direction' => ($sort === 'jersey_number' && $direction === 'asc') ? 'desc' : 'asc']) }}"
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'jersey_number' ? 'text-blue-600' : '' }}">
{{ __('admin.nr') }}
@if ($sort === 'jersey_number')
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
@if ($direction === 'asc')
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
@else
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
@endif
</svg>
@endif
</a>
</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.parents') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.photo') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'is_active', 'direction' => ($sort === 'is_active' && $direction === 'asc') ? 'desc' : 'asc']) }}"
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'is_active' ? 'text-blue-600' : '' }}">
{{ __('admin.status') }}
@if ($sort === 'is_active')
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
@if ($direction === 'asc')
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
@else
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
@endif
</svg>
@endif
</a>
</th>
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.action') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($players as $player)
<tr class="hover:bg-gray-50" x-data="playerRow({{ $player->id }}, '{{ $player->team_id }}', '{{ $player->photo_permission ? 1 : 0 }}')">
<td class="px-4 py-3 font-medium text-gray-900">
<a href="{{ route('admin.players.edit', $player) }}" class="flex items-center gap-2 hover:underline">
<img src="{{ $player->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0">
{{ $player->full_name }}
</a>
</td>
<td class="px-4 py-3">
<select
x-model="teamId"
@change="save({ team_id: teamId })"
class="px-2 py-1 border border-gray-200 rounded text-xs bg-white focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ $player->team_id == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
</td>
<td class="px-4 py-3 text-center text-gray-600">{{ $player->jersey_number ?? '' }}</td>
<td class="px-4 py-3 text-gray-600 text-xs">
@foreach ($player->parents as $parent)
{{ $parent->name }}{{ $parent->pivot->relationship_label ? " ({$parent->pivot->relationship_label})" : '' }}@if (!$loop->last), @endif
@endforeach
@if ($player->parents->isEmpty())
<span class="text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3 text-center">
<select
x-model="photo"
@change="save({ photo_permission: photo })"
class="px-2 py-1 border border-gray-200 rounded text-xs bg-white focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
:class="photo == '1' ? 'text-green-700' : 'text-red-600'"
>
<option value="1" {{ $player->photo_permission ? 'selected' : '' }}>{{ __('ui.yes') }}</option>
<option value="0" {{ !$player->photo_permission ? 'selected' : '' }}>{{ __('ui.no') }}</option>
</select>
</td>
<td class="px-4 py-3 text-center">
@if ($player->is_active)
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">{{ __('admin.active') }}</span>
@else
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">{{ __('admin.inactive') }}</span>
@endif
</td>
<td class="px-4 py-3 text-right space-x-2">
<span x-show="saving" class="text-xs text-gray-400">...</span>
<span x-show="saved" x-cloak class="text-xs text-green-600">&#10003;</span>
<a x-show="!saving && !saved" href="{{ route('admin.players.edit', $player) }}" class="text-blue-600 hover:underline text-sm">{{ __('ui.edit') }}</a>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500">{{ __('admin.no_players_yet') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">{{ $players->links() }}</div>
{{-- Papierkorb --}}
@if ($trashedPlayers->isNotEmpty())
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-700 mb-3">{{ __('admin.trash') }}</h2>
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto border border-red-200">
<table class="w-full text-sm">
<thead class="bg-red-50 border-b border-red-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('ui.name') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.nav_teams') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.deleted_at') }}</th>
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($trashedPlayers as $player)
<tr class="hover:bg-red-50/50">
<td class="px-4 py-3 font-medium text-gray-500">
<div class="flex items-center gap-2">
<img src="{{ $player->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0 opacity-50">
{{ $player->full_name }}
</div>
</td>
<td class="px-4 py-3 text-gray-400">{{ $player->team?->name ?? '' }}</td>
<td class="px-4 py-3 text-center text-xs text-gray-500">{{ $player->deleted_at->diffForHumans() }}</td>
<td class="px-4 py-3 text-right">
<form method="POST" action="{{ route('admin.players.restore', $player->id) }}" class="inline">
@csrf
@method('PUT')
<button type="submit" class="text-xs text-green-600 hover:text-green-800 font-medium">{{ __('admin.restore') }}</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
@push('scripts')
<script>
function playerRow(playerId, initialTeam, initialPhoto) {
return {
teamId: initialTeam,
photo: initialPhoto,
saving: false,
saved: false,
async save(data) {
this.saving = true;
this.saved = false;
try {
const res = await fetch(`/admin/players/${playerId}/quick-update`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify(data),
});
if (res.ok) {
this.saved = true;
setTimeout(() => this.saved = false, 1500);
}
} catch (e) {
console.error(e);
} finally {
this.saving = false;
}
}
};
}
</script>
@endpush
</x-layouts.admin>

View File

@@ -0,0 +1,825 @@
<x-layouts.admin :title="__('admin.settings_title')">
<div x-data="settingsPage()" x-init="init()">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.settings_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 = 'general'"
:class="tab === 'general' ? '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_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">
{{ __('admin.settings_tab_legal') }}
</button>
<button type="button" @click="tab = 'defaults'"
:class="tab === 'defaults' ? '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_defaults') }}
</button>
<button type="button" @click="tab = 'categories'"
:class="tab === 'categories' ? '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_categories') }}
</button>
@if (auth()->user()->isAdmin())
<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>
<form id="settings-form" method="POST" action="{{ route('admin.settings.update') }}" enctype="multipart/form-data" @submit="syncEditors()">
@csrf
@method('PUT')
{{-- Tab: Allgemein --}}
<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')
<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
type="text"
id="setting-{{ $key }}"
name="settings[{{ $key }}]"
value="{{ $setting->value }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
@endif
@endforeach
{{-- Favicon --}}
<div class="bg-white rounded-lg shadow p-6 mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-2">{{ __('admin.favicon_label') }}</label>
@php $currentFavicon = \App\Models\Setting::get('app_favicon'); @endphp
@if ($currentFavicon)
<div class="flex items-center gap-3 mb-3">
<img src="{{ asset('storage/' . $currentFavicon) }}" alt="Favicon" class="w-8 h-8 object-contain border border-gray-200 rounded">
<span class="text-sm text-gray-500">{{ __('admin.favicon_current') }}</span>
<label class="flex items-center gap-1.5 text-sm text-red-600 cursor-pointer">
<input type="checkbox" name="remove_favicon" value="1" class="rounded border-gray-300">
{{ __('admin.favicon_remove') }}
</label>
</div>
@endif
<input
type="file"
name="favicon"
accept=".ico,.png,.svg,.jpg,.jpeg,.gif,.webp"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm file:mr-3 file:py-1 file:px-3 file:rounded-md file:border-0 file:text-sm file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200"
>
<p class="mt-1.5 text-xs text-gray-400">{{ __('admin.favicon_hint') }}</p>
</div>
{{-- Richtext-Settings (Slogan mit Mini-Quill) --}}
@foreach ($settings as $key => $setting)
@if ($setting->type === 'richtext')
<div class="bg-white rounded-lg shadow p-6 mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-1">{{ $setting->label }}</label>
<p class="text-xs text-gray-400 mb-3">{{ __('admin.slogan_hint') }}</p>
<div id="slogan-editor-{{ $key }}" class="bg-white" style="min-height: 60px;">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($setting->value ?? '') !!}</div>
<input type="hidden" name="settings[{{ $key }}]" id="slogan-input-{{ $key }}" value="{{ $setting->value }}">
</div>
@endif
@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 --}}
<div class="flex items-center gap-1 mb-6 bg-white rounded-lg shadow px-4 py-3">
<span class="text-sm font-medium text-gray-600 mr-3">{{ __('admin.legal_language_label') }}:</span>
@php
$localeFlags = ['de' => "\u{1F1E9}\u{1F1EA}", 'en' => "\u{1F1EC}\u{1F1E7}", 'pl' => "\u{1F1F5}\u{1F1F1}", 'ru' => "\u{1F1F7}\u{1F1FA}", 'ar' => "\u{1F1F8}\u{1F1E6}", 'tr' => "\u{1F1F9}\u{1F1F7}"];
$localeNames = ['de' => 'DE', 'en' => 'EN', 'pl' => 'PL', 'ru' => 'RU', 'ar' => 'AR', 'tr' => 'TR'];
@endphp
@foreach ($availableLocales as $loc)
<button type="button"
@click="legalLocale = @js($loc)"
:class="legalLocale === @js($loc) ? 'bg-blue-600 text-white shadow-sm' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors cursor-pointer">
{{ $localeFlags[$loc] }} {{ $localeNames[$loc] }}
</button>
@endforeach
</div>
{{-- Impressum pro Sprache --}}
@foreach ($availableLocales as $loc)
@php $impKey = "impressum_html_{$loc}"; @endphp
<div x-show="legalLocale === '{{ $loc }}'" class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex items-center justify-between mb-3">
<label class="block text-sm font-semibold text-gray-700">{{ __('admin.legal_impressum_label') }} ({{ strtoupper($loc) }})</label>
<button type="button"
@click="toggleHtml(@js($impKey))"
:class="htmlMode[@js($impKey)] ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="text-xs px-2.5 py-1 rounded font-mono transition-colors cursor-pointer">
&lt;/&gt; HTML
</button>
</div>
<div :class="htmlMode[@js($impKey)] && 'hidden'">
<div id="editor-{{ $impKey }}" class="bg-white" style="min-height: 250px;">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($localeSettings[$loc]['impressum_html'] ?? '') !!}</div>
</div>
<textarea x-show="htmlMode[@js($impKey)]" x-cloak id="html-{{ $impKey }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono leading-relaxed"
style="min-height: 350px;" spellcheck="false"></textarea>
<p x-show="htmlMode[@js($impKey)]" x-cloak class="mt-2 text-xs text-gray-400">{{ __('admin.html_anchor_hint') }}</p>
<input type="hidden" name="settings[{{ $impKey }}]" id="input-{{ $impKey }}" value="{{ $localeSettings[$loc]['impressum_html'] }}">
</div>
@endforeach
{{-- Datenschutz pro Sprache --}}
@foreach ($availableLocales as $loc)
@php $dsKey = "datenschutz_html_{$loc}"; @endphp
<div x-show="legalLocale === '{{ $loc }}'" class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex items-center justify-between mb-3">
<label class="block text-sm font-semibold text-gray-700">{{ __('admin.legal_datenschutz_label') }} ({{ strtoupper($loc) }})</label>
<button type="button"
@click="toggleHtml(@js($dsKey))"
:class="htmlMode[@js($dsKey)] ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="text-xs px-2.5 py-1 rounded font-mono transition-colors cursor-pointer">
&lt;/&gt; HTML
</button>
</div>
<div :class="htmlMode[@js($dsKey)] && 'hidden'">
<div id="editor-{{ $dsKey }}" class="bg-white" style="min-height: 250px;">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($localeSettings[$loc]['datenschutz_html'] ?? '') !!}</div>
</div>
<textarea x-show="htmlMode[@js($dsKey)]" x-cloak id="html-{{ $dsKey }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono leading-relaxed"
style="min-height: 350px;" spellcheck="false"></textarea>
<p x-show="htmlMode[@js($dsKey)]" x-cloak class="mt-2 text-xs text-gray-400">{{ __('admin.html_anchor_hint') }}</p>
<input type="hidden" name="settings[{{ $dsKey }}]" id="input-{{ $dsKey }}" value="{{ $localeSettings[$loc]['datenschutz_html'] }}">
</div>
@endforeach
{{-- Passwort-Reset E-Mail pro Sprache --}}
@foreach ($availableLocales as $loc)
@php $prKey = "password_reset_email_{$loc}"; @endphp
<div x-show="legalLocale === '{{ $loc }}'" class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex items-center justify-between mb-3">
<label class="block text-sm font-semibold text-gray-700">{{ __('admin.legal_password_reset_email_label') }} ({{ strtoupper($loc) }})</label>
<button type="button"
@click="toggleHtml(@js($prKey))"
:class="htmlMode[@js($prKey)] ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="text-xs px-2.5 py-1 rounded font-mono transition-colors cursor-pointer">
&lt;/&gt; HTML
</button>
</div>
<div :class="htmlMode[@js($prKey)] && 'hidden'">
<div id="editor-{{ $prKey }}" class="bg-white" style="min-height: 150px;">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($localeSettings[$loc]['password_reset_email'] ?? '') !!}</div>
</div>
<textarea x-show="htmlMode[@js($prKey)]" x-cloak id="html-{{ $prKey }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono leading-relaxed"
style="min-height: 200px;" spellcheck="false"></textarea>
<p class="mt-2 text-xs text-gray-400">{{ __('admin.password_reset_email_hint') }}</p>
<input type="hidden" name="settings[{{ $prKey }}]" id="input-{{ $prKey }}" value="{{ $localeSettings[$loc]['password_reset_email'] }}">
</div>
@endforeach
</div>
{{-- Tab: Event-Defaults --}}
<div x-show="tab === 'defaults'" role="tabpanel">
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm text-gray-500 mb-5">{{ __('admin.event_defaults_description') }}</p>
@php
$noCateringTypes = ['away_game', 'meeting'];
@endphp
<div class="space-y-4">
@foreach (['home_game', 'away_game', 'training', 'tournament', 'meeting'] as $eventType)
<div class="border border-gray-200 rounded-md p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __("ui.enums.event_type.{$eventType}") }}</h3>
<div class="grid grid-cols-3 gap-2 sm:gap-3">
@foreach (['players', 'catering', 'timekeepers'] as $field)
@php
$key = "default_min_{$field}_{$eventType}";
$currentVal = $eventDefaults[$key] ?? null;
$isDisabled = in_array($eventType, $noCateringTypes) && in_array($field, ['catering', 'timekeepers']);
$label = $eventType === 'meeting' && $field === 'players' ? __('admin.min_users') : __("admin.min_{$field}");
@endphp
<div>
<label class="block text-xs text-gray-600 mb-1 truncate" title="{{ $label }}">{{ $label }}</label>
@if ($isDisabled)
<div class="w-full px-2 py-1.5 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50">{{ __('admin.not_applicable') }}</div>
@else
<select name="settings[{{ $key }}]" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">--</option>
@for ($i = 0; $i <= 20; $i++)
<option value="{{ $i }}" {{ $currentVal !== null && (string) $currentVal === (string) $i ? 'selected' : '' }}>{{ $i }}</option>
@endfor
</select>
@endif
</div>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
</div>
{{-- 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'),
'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">
<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>
{{-- Registration (außerhalb der Settings-Form, nur auf Lizenz-Tab) --}}
@if (auth()->user()->isAdmin() && !$isRegistered)
<div x-show="tab === 'license'" class="mt-6">
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm text-gray-600 mb-3">{{ __('admin.support_not_registered') }}</p>
<form method="POST" action="{{ route('admin.support.register') }}">
@csrf
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded-md text-sm hover:bg-green-700">
{{ __('admin.register_now') }}
</button>
</form>
</div>
</div>
@endif
{{-- Tab: Wartung (nur Admin, eigenes Formular) --}}
@if (auth()->user()->isAdmin())
<div x-show="tab === 'maintenance'" role="tabpanel">
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">{{ __('admin.demo_data_delete_title') }}</h2>
<p class="text-sm text-gray-600 mb-4">{{ __('admin.demo_data_delete_description') }}</p>
<div class="grid sm:grid-cols-2 gap-4 mb-5">
<div class="border border-red-200 bg-red-50 rounded-md p-4">
<h3 class="text-sm font-semibold text-red-700 mb-2">{{ __('admin.demo_data_deletes') }}</h3>
<ul class="text-sm text-red-600 space-y-1 list-disc list-inside">
<li>{{ __('admin.stat_users') }} ({{ __('admin.demo_data_except_admin') }})</li>
<li>{{ __('admin.nav_teams') }}</li>
<li>{{ __('admin.nav_players') }}</li>
<li>{{ __('admin.nav_events') }}</li>
<li>Kommentare</li>
<li>{{ __('admin.nav_locations') }}</li>
<li>{{ __('admin.nav_files') }}</li>
<li>{{ __('admin.activity_log_title') }}</li>
</ul>
</div>
<div class="border border-green-200 bg-green-50 rounded-md p-4">
<h3 class="text-sm font-semibold text-green-700 mb-2">{{ __('admin.demo_data_keeps') }}</h3>
<ul class="text-sm text-green-600 space-y-1 list-disc list-inside">
<li>{{ __('admin.demo_data_keeps_admin') }}</li>
<li>{{ __('admin.nav_settings') }}</li>
<li>{{ __('admin.settings_tab_categories') }}</li>
</ul>
</div>
</div>
<div class="border border-red-300 bg-red-50 rounded-md p-4 mb-5">
<p class="text-sm text-red-700 font-medium">{{ __('admin.demo_data_delete_warning') }}</p>
</div>
<form method="POST" action="{{ route('admin.settings.destroy-demo-data') }}"
onsubmit="return confirm(@js(__('admin.demo_data_delete_confirm')))">
@csrf
@method('DELETE')
<div class="mb-4">
<label for="demo-delete-password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_password_label') }}</label>
<input type="password" name="password" id="demo-delete-password" required autocomplete="current-password"
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500">
</div>
<button type="submit"
class="px-5 py-2.5 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 transition">
{{ __('admin.demo_data_delete_button') }}
</button>
</form>
</div>
{{-- Factory Reset (nur Admin) --}}
@if (auth()->user()->isAdmin())
<div class="bg-white rounded-lg shadow p-6 mt-6 border-2 border-red-300">
<h2 class="text-lg font-semibold text-red-700 mb-4">{{ __('admin.factory_reset_title') }}</h2>
<p class="text-sm text-gray-600 mb-4">{{ __('admin.factory_reset_description') }}</p>
<div class="border border-red-200 bg-red-50 rounded-md p-4 mb-5">
<h3 class="text-sm font-semibold text-red-700 mb-2">{{ __('admin.factory_reset_deletes') }}</h3>
<ul class="text-sm text-red-600 space-y-1 list-disc list-inside">
<li>{{ __('admin.factory_reset_item_users') }}</li>
<li>{{ __('admin.factory_reset_item_data') }}</li>
<li>{{ __('admin.factory_reset_item_settings') }}</li>
<li>{{ __('admin.factory_reset_item_files') }}</li>
</ul>
</div>
<div class="bg-red-100 border border-red-300 rounded-md p-4 mb-5">
<p class="text-sm text-red-800 font-bold">{{ __('admin.factory_reset_warning') }}</p>
</div>
<form method="POST" action="{{ route('admin.settings.factory-reset') }}"
onsubmit="return confirm(@js(__('admin.factory_reset_confirm')))">
@csrf
@method('DELETE')
<div class="mb-4">
<label for="factory-reset-password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_password_label') }}</label>
<input type="password" name="password" id="factory-reset-password" required autocomplete="current-password"
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500">
</div>
<div class="mb-5">
<label for="factory-reset-confirmation" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_confirmation_label') }}</label>
<input type="text" name="confirmation" id="factory-reset-confirmation" required
placeholder="RESET-BEST&Auml;TIGT"
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-2 focus:ring-red-500 focus:border-red-500">
<p class="mt-1 text-xs text-gray-500">{{ __('admin.factory_reset_confirmation_hint') }}</p>
</div>
<button type="submit"
class="px-5 py-2.5 text-sm font-medium text-white bg-red-700 rounded-md hover:bg-red-800 transition">
{{ __('admin.factory_reset_button') }}
</button>
</form>
</div>
@endif
</div>
@endif
{{-- Tab: Dateikategorien (eigene Formulare) --}}
<div x-show="tab === 'categories'" role="tabpanel">
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm text-gray-500 mb-5">{{ __('admin.file_categories_description') }}</p>
@if ($fileCategories->isNotEmpty())
<div class="space-y-2 mb-5">
@foreach ($fileCategories as $cat)
<div class="flex flex-wrap items-center gap-2 sm:gap-3 border border-gray-200 rounded-md px-3 py-2.5">
<form method="POST" action="{{ route('admin.file-categories.update', $cat) }}" class="flex flex-wrap items-center gap-2 sm:gap-3 flex-1 min-w-0">
@csrf
@method('PUT')
<input type="text" name="name" value="{{ $cat->name }}" required
class="flex-1 min-w-[140px] px-2 py-1.5 border border-gray-300 rounded text-sm">
<label class="flex items-center gap-1.5 text-xs text-gray-600">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" {{ $cat->is_active ? 'checked' : '' }} class="rounded border-gray-300">
{{ __('admin.active') }}
</label>
<span class="text-xs text-gray-400 tabular-nums">{{ $cat->files_count }} {{ __('admin.nav_files') }}</span>
<button type="submit" class="text-xs text-blue-600 hover:text-blue-800 font-medium">{{ __('ui.save') }}</button>
</form>
<form method="POST" action="{{ route('admin.file-categories.destroy', $cat) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_category')))">
@csrf
@method('DELETE')
<button type="submit" class="text-xs font-medium {{ $cat->files_count === 0 ? 'text-red-600 hover:text-red-800' : 'text-gray-300 cursor-not-allowed' }}"
{{ $cat->files_count > 0 ? 'disabled' : '' }}>{{ __('ui.delete') }}</button>
</form>
</div>
@endforeach
</div>
@endif
<form method="POST" action="{{ route('admin.file-categories.store') }}" class="flex items-center gap-3 border-t border-gray-200 pt-4">
@csrf
<input type="text" name="name" placeholder="{{ __('admin.category_name') }}" required
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm">
<button type="submit" class="bg-gray-800 text-white px-3 py-2 rounded-md hover:bg-gray-900 text-sm whitespace-nowrap">{{ __('admin.new_category') }}</button>
</form>
</div>
</div>
</div>
@push('styles')
<link href="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.snow.css" rel="stylesheet" integrity="sha384-cPa8kzsYWhqpAfWOLWYIw3V0BhPi/m3lrd8tBTPxr2NrYCHRVZ7xy1cEoRGOM/03" crossorigin="anonymous">
<style>
.ql-editor { min-height: 200px; }
.ql-toolbar.ql-snow { border-radius: 0.375rem 0.375rem 0 0; }
.ql-container.ql-snow { border-radius: 0 0 0.375rem 0.375rem; }
[id^="slogan-editor-"] .ql-editor { min-height: 60px; }
[id^="editor-password_reset_email_"] .ql-editor { min-height: 120px; }
</style>
@endpush
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.min.js" integrity="sha384-QUJ+ckWz1M+a7w0UfG1sEn4pPrbQwSxGm/1TIPyioqXBrwuT9l4f9gdHWLDLbVWI" crossorigin="anonymous"></script>
<script>
function settingsPage() {
return {
tab: 'general',
legalLocale: 'de',
editorsInitialized: false,
sloganInitialized: false,
initializedLocales: [],
editors: {},
sloganEditors: {},
htmlMode: {},
toolbarOptions: [
[{ 'header': [2, 3, 4, false] }],
['bold', 'italic', 'underline'],
[{ 'color': ['#000000', '#e60000', '#ff9900', '#008a00', '#0066cc', '#9933ff', '#ffffff', '#888888'] },
{ 'background': ['', '#ffd6d6', '#fff3cd', '#d4edda', '#cce5ff', '#e8d5f5'] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
['blockquote', 'link'],
['clean']
],
init() {
const validTabs = ['general', 'mail', 'legal', 'defaults', 'categories', 'visibility', 'license', 'maintenance'];
const hash = window.location.hash.replace('#', '');
const stored = sessionStorage.getItem('settings_tab');
if (validTabs.includes(hash)) {
this.tab = hash;
} else if (validTabs.includes(stored)) {
this.tab = stored;
}
this.$watch('tab', val => {
sessionStorage.setItem('settings_tab', val);
history.replaceState(null, '', '#' + val);
});
// Bei Locale-Wechsel: Editoren fuer neue Locale lazy initialisieren
this.$watch('legalLocale', (locale) => {
if (this.editorsInitialized) {
this.$nextTick(() => this.initLocaleEditors(locale));
}
});
},
initSloganEditors() {
if (this.sloganInitialized) return;
const miniToolbar = [['bold', 'italic']];
@foreach ($settings as $key => $setting)
@if ($setting->type === 'richtext')
this.sloganEditors[@js($key)] = new Quill('#slogan-editor-' + @js($key), {
theme: 'snow',
modules: { toolbar: miniToolbar }
});
document.getElementById('slogan-input-' + @js($key)).value = this.sloganEditors[@js($key)].root.innerHTML;
@endif
@endforeach
this.sloganInitialized = true;
},
// Nur die aktuelle Locale initialisieren (sichtbare Editoren)
initEditors() {
if (this.editorsInitialized) return;
this.initLocaleEditors(this.legalLocale);
this.editorsInitialized = true;
},
initLocaleEditors(locale) {
if (this.initializedLocales.includes(locale)) return;
const editorTypes = ['impressum_html', 'datenschutz_html', 'password_reset_email'];
editorTypes.forEach(type => {
const key = type + '_' + locale;
const el = document.getElementById('editor-' + key);
if (el) {
this.editors[key] = new Quill('#editor-' + key, {
theme: 'snow',
modules: { toolbar: this.toolbarOptions }
});
this.htmlMode[key] = false;
// Content aus Hidden-Input laden
const input = document.getElementById('input-' + key);
if (input && input.value) {
this.editors[key].root.innerHTML = input.value;
}
}
});
this.initializedLocales.push(locale);
},
toggleHtml(key) {
if (!this.editors[key]) return;
if (!this.htmlMode[key]) {
const html = this.editors[key].root.innerHTML;
document.getElementById('html-' + key).value = this.formatHtml(html);
this.htmlMode[key] = true;
} else {
const html = document.getElementById('html-' + key).value;
this.editors[key].root.innerHTML = html;
this.htmlMode[key] = false;
}
},
formatHtml(html) {
return html
.replace(/<(h[2-4]|p|ul|ol|blockquote)/g, '\n<$1')
.replace(/<li>/g, '\n <li>')
.trimStart();
},
syncEditors() {
// Nur initialisierte Editoren synchronisieren
// Nicht-initialisierte Locales: Hidden-Inputs behalten Server-Werte
for (const [key, editor] of Object.entries(this.editors)) {
if (this.htmlMode[key]) {
document.getElementById('input-' + key).value = document.getElementById('html-' + key).value;
} else {
document.getElementById('input-' + key).value = editor.root.innerHTML;
}
}
// Sync slogan editors (richtext type)
for (const [key, editor] of Object.entries(this.sloganEditors)) {
document.getElementById('slogan-input-' + key).value = editor.root.innerHTML;
}
}
};
}
</script>
@endpush
</x-layouts.admin>

View File

@@ -0,0 +1,337 @@
<x-layouts.admin :title="__('admin.statistics_title')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.statistics_title') }}</h1>
{{-- Filter --}}
<div class="bg-white rounded-lg shadow p-4 mb-6">
<form method="GET" action="{{ route('admin.statistics.index') }}" class="flex flex-wrap items-end gap-4">
<div>
<label for="team_id" class="block text-xs font-medium text-gray-600 mb-1">{{ __('ui.team') }}</label>
<select name="team_id" id="team_id" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="">{{ __('admin.all_teams') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ request('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
</div>
<div>
<label for="from" class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.filter_from') }}</label>
<input type="date" name="from" id="from" value="{{ request('from') }}" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div>
<label for="to" class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.filter_to') }}</label>
<input type="date" name="to" id="to" value="{{ request('to') }}" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md text-sm hover:bg-blue-700">{{ __('admin.filter_apply') }}</button>
@if (request()->hasAny(['team_id', 'from', 'to']))
<a href="{{ route('admin.statistics.index') }}" class="text-sm text-gray-500 hover:underline">{{ __('admin.filter_reset') }}</a>
@endif
</form>
</div>
@if ($games->isEmpty())
<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">
{{ __('admin.no_games_yet') }}
</div>
@else
{{-- Statistik-Cards --}}
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6">
<div class="bg-white rounded-lg shadow p-4 text-center">
<div class="text-2xl font-bold text-gray-900">{{ $games->count() }}</div>
<div class="text-xs text-gray-500 mt-1">{{ __('admin.total_games') }}</div>
</div>
<div class="bg-white rounded-lg shadow p-4 text-center">
<div class="text-2xl font-bold text-green-600">{{ $wins }}</div>
<div class="text-xs text-gray-500 mt-1">{{ __('admin.wins') }}</div>
</div>
<div class="bg-white rounded-lg shadow p-4 text-center">
<div class="text-2xl font-bold text-red-600">{{ $losses }}</div>
<div class="text-xs text-gray-500 mt-1">{{ __('admin.losses') }}</div>
</div>
<div class="bg-white rounded-lg shadow p-4 text-center">
<div class="text-2xl font-bold text-gray-500">{{ $draws }}</div>
<div class="text-xs text-gray-500 mt-1">{{ __('admin.draws') }}</div>
</div>
<div class="bg-white rounded-lg shadow p-4 text-center">
<div class="text-2xl font-bold text-blue-600">{{ $winRate }}%</div>
<div class="text-xs text-gray-500 mt-1">{{ __('admin.win_rate') }}</div>
</div>
</div>
{{-- Charts --}}
@if ($totalWithScore > 0)
<div class="grid grid-cols-1 {{ auth()->user()->isStaff() ? 'lg:grid-cols-3' : 'lg:grid-cols-2' }} gap-6 mb-6">
{{-- Siege/Niederlagen Pie Chart --}}
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.chart_win_loss') }}</h3>
<div class="relative" style="height: 220px;">
<canvas id="chartWinLoss"></canvas>
</div>
</div>
{{-- Spieler-Teilnahme Bar Chart (nur Staff) --}}
@if (auth()->user()->isStaff())
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.chart_player_participation') }}</h3>
<div class="relative" style="height: 220px;">
<canvas id="chartPlayers"></canvas>
</div>
</div>
@endif
{{-- Eltern-Engagement Bar Chart --}}
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.chart_parent_involvement') }}</h3>
<div class="relative" style="height: 220px;">
<canvas id="chartParents"></canvas>
</div>
</div>
</div>
@endif
{{-- Spiel-Tabelle --}}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-600">{{ __('admin.date') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">{{ __('ui.team') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-600">{{ __('ui.type') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">{{ __('events.opponent') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-600">{{ __('events.score') }}</th>
@if (auth()->user()->isStaff())
<th class="text-center px-4 py-3 font-medium text-gray-600">{{ __('admin.nav_players') }}</th>
@endif
<th class="text-center px-4 py-3 font-medium text-gray-600">{{ __('events.catering_short') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-600">{{ __('events.timekeeper_short') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($games as $game)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2.5 whitespace-nowrap">
<a href="{{ route('admin.events.edit', $game) }}" class="text-blue-600 hover:underline">
{{ $game->start_at->translatedFormat(__('ui.date_format_short')) }}
</a>
</td>
<td class="px-4 py-2.5">{{ $game->team->name }}</td>
<td class="px-4 py-2.5 text-center">
<span class="inline-block px-1.5 py-0.5 rounded text-xs font-medium {{ $game->type === \App\Enums\EventType::HomeGame ? 'bg-blue-100 text-blue-800' : 'bg-indigo-100 text-indigo-800' }}">
{{ $game->type === \App\Enums\EventType::HomeGame ? __('admin.home_short') : __('admin.away_short') }}
</span>
</td>
<td class="px-4 py-2.5">{{ $game->opponent ?? '' }}</td>
<td class="px-4 py-2.5 text-center font-medium">
@if ($game->hasScore())
{{ $game->scoreDisplay() }}
@else
<span class="text-gray-400"></span>
@endif
</td>
@if (auth()->user()->isStaff())
<td class="px-4 py-2.5 text-center">{{ $game->players_yes_count }}</td>
@endif
<td class="px-4 py-2.5 text-center">{{ $game->type->hasCatering() ? $game->caterings_yes_count : '' }}</td>
<td class="px-4 py-2.5 text-center">{{ $game->type->hasTimekeepers() ? $game->timekeepers_yes_count : '' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
{{-- Spieler-Rangliste (nur Staff) --}}
@if (auth()->user()->isStaff() && $playerRanking->isNotEmpty())
<div class="bg-white rounded-lg shadow overflow-hidden mt-6">
<div class="px-4 py-3 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">{{ __('admin.player_ranking_title') }}</h3>
<p class="text-xs text-gray-500 mt-0.5">{{ __('admin.player_ranking_desc', ['count' => $totalGames]) }}</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-4 py-2.5 font-medium text-gray-600 w-8">#</th>
<th class="text-left px-4 py-2.5 font-medium text-gray-600">{{ __('admin.nav_players') }}</th>
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.games_played') }}</th>
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.games_assigned') }}</th>
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.participation_rate') }}</th>
<th class="px-4 py-2.5 font-medium text-gray-600 w-32"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($playerRanking as $index => $entry)
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 text-gray-400">{{ $index + 1 }}</td>
<td class="px-4 py-2 flex items-center gap-2">
@if ($entry->player->getAvatarUrl())
<img src="{{ $entry->player->getAvatarUrl() }}" alt="" class="w-6 h-6 rounded-full object-cover">
@else
<div class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs font-semibold">{{ $entry->player->getInitials() }}</div>
@endif
<span class="{{ $entry->player->trashed() ? 'text-gray-400 line-through' : '' }}">{{ $entry->player->full_name }}</span>
</td>
<td class="px-4 py-2 text-center font-medium">{{ $entry->games_played }}</td>
<td class="px-4 py-2 text-center text-gray-500">{{ $entry->total_assigned }}</td>
<td class="px-4 py-2 text-center">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $entry->rate >= 75 ? 'bg-green-100 text-green-800' : ($entry->rate >= 50 ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800') }}">
{{ $entry->rate }}%
</span>
</td>
<td class="px-4 py-2">
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div class="h-1.5 rounded-full {{ $entry->rate >= 75 ? 'bg-green-500' : ($entry->rate >= 50 ? 'bg-yellow-500' : 'bg-red-500') }}" style="width: {{ $entry->rate }}%"></div>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
{{-- Eltern-Engagement-Rangliste --}}
@if ($parentRanking->isNotEmpty())
<div class="bg-white rounded-lg shadow overflow-hidden mt-6">
<div class="px-4 py-3 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">{{ __('admin.parent_ranking_title') }}</h3>
<p class="text-xs text-gray-500 mt-0.5">{{ __('admin.parent_ranking_desc', ['catering' => $totalCateringEvents, 'timekeeper' => $totalTimekeeperEvents]) }}</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-4 py-2.5 font-medium text-gray-600 w-8">#</th>
<th class="text-left px-4 py-2.5 font-medium text-gray-600">{{ __('admin.nav_users') }}</th>
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('events.catering_short') }}</th>
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('events.timekeeper_short') }}</th>
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.total_contributions') }}</th>
<th class="px-4 py-2.5 font-medium text-gray-600 w-32"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($parentRanking as $index => $entry)
@php
$maxTotal = $parentRanking->first()->total;
$barWidth = $maxTotal > 0 ? round(($entry->total / $maxTotal) * 100) : 0;
@endphp
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 text-gray-400">{{ $index + 1 }}</td>
<td class="px-4 py-2 flex items-center gap-2">
@if ($entry->user->getAvatarUrl())
<img src="{{ $entry->user->getAvatarUrl() }}" alt="" class="w-6 h-6 rounded-full object-cover">
@else
<div class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs font-semibold">{{ $entry->user->getInitials() }}</div>
@endif
<span class="{{ $entry->user->trashed() ? 'text-gray-400 line-through' : '' }}">{{ $entry->user->name }}</span>
</td>
<td class="px-4 py-2 text-center">
@if ($entry->catering_count > 0)
<span class="text-amber-600 font-medium">{{ $entry->catering_count }}</span>
@else
<span class="text-gray-300">0</span>
@endif
</td>
<td class="px-4 py-2 text-center">
@if ($entry->timekeeper_count > 0)
<span class="text-purple-600 font-medium">{{ $entry->timekeeper_count }}</span>
@else
<span class="text-gray-300">0</span>
@endif
</td>
<td class="px-4 py-2 text-center font-bold">{{ $entry->total }}</td>
<td class="px-4 py-2">
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div class="h-1.5 rounded-full bg-blue-500" style="width: {{ $barWidth }}%"></div>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
@endif
@if ($totalWithScore > 0)
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-vsrfeLOOY6KuIYKDlmVH5UiBmgIdB1oEf7p01YgWHuqmOHfZr374+odEv96n9tNC" crossorigin="anonymous"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const winLossData = @js($chartWinLoss);
const parentData = @js($chartParentInvolvement);
// Pie Chart: Siege/Niederlagen
new Chart(document.getElementById('chartWinLoss'), {
type: 'doughnut',
data: {
labels: winLossData.labels,
datasets: [{
data: winLossData.data,
backgroundColor: winLossData.colors,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } } }
}
});
// Bar Chart: Spieler-Teilnahme (nur Staff)
@if (auth()->user()->isStaff())
const playerData = @js($chartPlayerParticipation);
new Chart(document.getElementById('chartPlayers'), {
type: 'bar',
data: {
labels: playerData.labels,
datasets: [{
label: @js(__('admin.nav_players')),
data: playerData.data,
backgroundColor: '#3b82f6',
borderRadius: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
plugins: { legend: { display: false } }
}
});
@endif
// Bar Chart: Eltern-Engagement
new Chart(document.getElementById('chartParents'), {
type: 'bar',
data: {
labels: parentData.labels,
datasets: [
{
label: @js(__('events.catering_short')),
data: parentData.catering,
backgroundColor: '#f59e0b',
borderRadius: 3,
},
{
label: @js(__('events.timekeeper_short')),
data: parentData.timekeepers,
backgroundColor: '#8b5cf6',
borderRadius: 3,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } } }
}
});
});
</script>
@endpush
@endif
</x-layouts.admin>

View File

@@ -0,0 +1,134 @@
<x-layouts.admin :title="__('admin.support_title')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.support_title') }}</h1>
{{-- Nicht registriert: Hinweis --}}
@if (!$registered)
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 mb-6">
<div class="flex items-start gap-3">
<svg class="w-6 h-6 text-yellow-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<h2 class="text-lg font-semibold text-yellow-800 mb-1">{{ __('admin.support_not_registered') }}</h2>
<p class="text-sm text-yellow-700 mb-4">{{ __('admin.support_register_hint') }}</p>
<form method="POST" action="{{ route('admin.support.register') }}">
@csrf
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('admin.register_now') }}
</button>
</form>
</div>
</div>
</div>
@else
{{-- Ticket erstellen --}}
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">{{ __('admin.support_new_ticket') }}</h2>
<form method="POST" action="{{ route('admin.support.store') }}">
@csrf
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="subject" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.support_subject') }}</label>
<input type="text" name="subject" id="subject" value="{{ old('subject') }}" required maxlength="255"
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('subject')
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
@enderror
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.support_category') }}</label>
<select name="category" id="category" required
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">
<option value="bug" {{ old('category') === 'bug' ? 'selected' : '' }}>{{ __('admin.support_category_bug') }}</option>
<option value="feature" {{ old('category') === 'feature' ? 'selected' : '' }}>{{ __('admin.support_category_feature') }}</option>
<option value="question" {{ old('category') === 'question' ? 'selected' : '' }}>{{ __('admin.support_category_question') }}</option>
<option value="other" {{ old('category') === 'other' ? 'selected' : '' }}>{{ __('admin.support_category_other') }}</option>
</select>
@error('category')
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
@enderror
</div>
</div>
<div class="mb-4">
<label for="message" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.support_message') }}</label>
<textarea name="message" id="message" rows="4" required maxlength="5000"
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">{{ old('message') }}</textarea>
@error('message')
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">{{ __('admin.support_system_info_note') }}</p>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('admin.support_submit') }}
</button>
</div>
</form>
</div>
{{-- Ticket-Liste --}}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="px-6 py-4 border-b">
<h2 class="text-lg font-semibold">{{ __('admin.support_title') }}</h2>
</div>
@if (count($tickets) > 0)
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">#</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.support_subject') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.support_status') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.support_category') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.support_created_at') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.support_last_reply') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($tickets as $ticket)
@php
$statusColors = [
'open' => 'bg-green-100 text-green-800',
'in_progress' => 'bg-blue-100 text-blue-800',
'waiting' => 'bg-yellow-100 text-yellow-800',
'closed' => 'bg-gray-100 text-gray-800',
];
$categoryColors = [
'bug' => 'bg-red-100 text-red-800',
'feature' => 'bg-purple-100 text-purple-800',
'question' => 'bg-blue-100 text-blue-800',
'other' => 'bg-gray-100 text-gray-800',
];
@endphp
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-500">{{ $ticket['id'] ?? '-' }}</td>
<td class="px-4 py-3">
<a href="{{ route('admin.support.show', $ticket['id'] ?? 0) }}" class="text-blue-600 hover:underline font-medium">
{{ $ticket['subject'] ?? '-' }}
</a>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $statusColors[$ticket['status'] ?? ''] ?? 'bg-gray-100 text-gray-800' }}">
{{ __('admin.support_status_' . ($ticket['status'] ?? 'open')) }}
</span>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $categoryColors[$ticket['category'] ?? ''] ?? 'bg-gray-100 text-gray-800' }}">
{{ __('admin.support_category_' . ($ticket['category'] ?? 'other')) }}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ $ticket['created_at'] ?? '-' }}</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ $ticket['last_reply_at'] ?? '-' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="px-6 py-8 text-center text-gray-500 text-sm">
{{ __('admin.support_no_tickets') }}
</div>
@endif
</div>
@endif
</x-layouts.admin>

View File

@@ -0,0 +1,92 @@
<x-layouts.admin :title="($ticket['subject'] ?? __('admin.support_title'))">
<div class="mb-6">
<a href="{{ route('admin.support.index') }}" class="text-sm text-blue-600 hover:underline">&larr; {{ __('admin.support_back_to_list') }}</a>
</div>
{{-- Ticket-Header --}}
<div class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-xl font-bold text-gray-900">{{ $ticket['subject'] ?? '-' }}</h1>
<p class="text-sm text-gray-500 mt-1">
#{{ $ticket['id'] ?? '-' }} &middot; {{ $ticket['created_at'] ?? '-' }}
</p>
</div>
<div class="flex items-center gap-2">
@php
$statusColors = [
'open' => 'bg-green-100 text-green-800',
'in_progress' => 'bg-blue-100 text-blue-800',
'waiting' => 'bg-yellow-100 text-yellow-800',
'closed' => 'bg-gray-100 text-gray-800',
];
$categoryColors = [
'bug' => 'bg-red-100 text-red-800',
'feature' => 'bg-purple-100 text-purple-800',
'question' => 'bg-blue-100 text-blue-800',
'other' => 'bg-gray-100 text-gray-800',
];
$status = $ticket['status'] ?? 'open';
$category = $ticket['category'] ?? 'other';
@endphp
<span class="inline-block px-2.5 py-1 rounded-full text-xs font-medium {{ $statusColors[$status] ?? 'bg-gray-100 text-gray-800' }}">
{{ __('admin.support_status_' . $status) }}
</span>
<span class="inline-block px-2.5 py-1 rounded-full text-xs font-medium {{ $categoryColors[$category] ?? 'bg-gray-100 text-gray-800' }}">
{{ __('admin.support_category_' . $category) }}
</span>
</div>
</div>
</div>
{{-- Nachrichten-Thread --}}
<div class="space-y-4 mb-6">
@foreach (($ticket['messages'] ?? []) as $msg)
@php
$isSupport = ($msg['sender'] ?? '') === 'support';
@endphp
<div class="rounded-lg p-4 {{ $isSupport ? 'bg-blue-50 border border-blue-200 ml-0 mr-4 sm:mr-12' : 'bg-white border border-gray-200 ml-4 sm:ml-12 mr-0' }}">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium {{ $isSupport ? 'text-blue-800' : 'text-gray-900' }}">
{{ $isSupport ? __('admin.support_sender_support') : __('admin.support_sender_you') }}
</span>
<span class="text-xs text-gray-500">{{ $msg['created_at'] ?? '' }}</span>
</div>
<div class="text-sm text-gray-700 whitespace-pre-wrap">{{ $msg['message'] ?? '' }}</div>
</div>
@endforeach
@if (empty($ticket['messages'] ?? []))
<div class="text-center text-gray-500 text-sm py-8">
{{ __('admin.support_no_messages') }}
</div>
@endif
</div>
{{-- Antwort-Formular --}}
@if (($ticket['status'] ?? 'open') !== 'closed')
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold mb-4">{{ __('admin.support_reply') }}</h2>
<form method="POST" action="{{ route('admin.support.reply', $ticket['id'] ?? 0) }}">
@csrf
<div class="mb-4">
<textarea name="message" rows="4" required maxlength="5000"
placeholder="{{ __('admin.support_reply_placeholder') }}"
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">{{ old('message') }}</textarea>
@error('message')
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex justify-end">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('admin.support_send_reply') }}
</button>
</div>
</form>
</div>
@else
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center text-sm text-gray-500">
{{ __('admin.support_ticket_closed') }}
</div>
@endif
</x-layouts.admin>

View File

@@ -0,0 +1,42 @@
<x-layouts.admin :title="__('admin.create_team')">
<div class="mb-6">
<h1 class="text-2xl font-bold">{{ __('admin.new_team') }}</h1>
</div>
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
<form method="POST" action="{{ route('admin.teams.store') }}">
@csrf
<div class="mb-4">
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.team_name') }} *</label>
<input type="text" name="name" id="name" value="{{ old('name') }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 @error('name') border-red-500 @enderror">
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="year_group" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.year_group') }}</label>
<input type="text" name="year_group" id="year_group" value="{{ old('year_group') }}" placeholder="{{ __('admin.year_group_placeholder') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-6">
<label class="flex items-center">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" {{ old('is_active', '1') == '1' ? 'checked' : '' }}
class="rounded border-gray-300 mr-2">
<span class="text-sm text-gray-700">{{ __('admin.team_is_active') }}</span>
</label>
</div>
<div class="flex items-center gap-3">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('admin.create_team') }}
</button>
<a href="{{ route('admin.teams.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
</div>
</form>
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,286 @@
<x-layouts.admin :title="__('admin.edit_team') . ': ' . $team->name">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.edit_team') }}: {{ $team->name }}</h1>
<form method="POST" action="{{ route('admin.teams.update', $team) }}" enctype="multipart/form-data">
@csrf
@method('PUT')
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
{{-- ══ LINKS ══════════════════════════════════════════ --}}
<div class="space-y-6">
{{-- Card: Stammdaten --}}
<div class="bg-white rounded-lg shadow p-6">
<div class="mb-4">
<label for="name" class="block text-sm font-semibold text-gray-700 mb-1">{{ __('admin.team_name') }} *</label>
<input type="text" name="name" id="name" value="{{ old('name', $team->name) }}" required
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('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="year_group" class="block text-sm font-semibold text-gray-700 mb-1">{{ __('admin.year_group') }}</label>
<input type="text" name="year_group" id="year_group" value="{{ old('year_group', $team->year_group) }}"
placeholder="{{ __('admin.year_group_placeholder') }}"
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 class="mb-6">
<label class="flex items-center gap-2">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" {{ old('is_active', $team->is_active) ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">{{ __('admin.team_is_active') }}</span>
</label>
</div>
<div class="flex gap-3">
<button type="submit" class="bg-blue-600 text-white px-5 py-2 rounded-md hover:bg-blue-700 font-medium text-sm">{{ __('ui.save') }}</button>
<a href="{{ route('admin.teams.index') }}" class="bg-gray-200 text-gray-700 px-5 py-2 rounded-md hover:bg-gray-300 text-sm">{{ __('ui.cancel') }}</a>
</div>
</div>
{{-- Card: Notizen --}}
<div class="bg-white rounded-lg shadow p-6">
<label for="notes" class="block text-sm font-semibold text-gray-700 mb-2">{{ __('admin.team_notes') }}</label>
<textarea name="notes" id="notes" rows="6"
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"
placeholder="{{ __('admin.team_notes_placeholder') }}">{{ old('notes', $team->notes) }}</textarea>
@error('notes')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
{{-- ══ RECHTS ═════════════════════════════════════════ --}}
<div class="space-y-6">
{{-- Card: Trainer --}}
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.team_coaches') }}</h2>
@php
$selectedCoachIds = collect(old('coach_ids', $team->coaches->pluck('id')->toArray()))->map(fn ($v) => (int) $v)->toArray();
@endphp
@if ($allCoaches->isEmpty())
<p class="text-sm text-gray-500">{{ __('admin.no_coaches_available') }}</p>
@else
<div class="space-y-2">
@foreach ($allCoaches as $coach)
<label class="flex items-center gap-3 py-1 cursor-pointer hover:bg-gray-50 -mx-2 px-2 rounded">
<input type="checkbox" name="coach_ids[]" value="{{ $coach->id }}"
{{ in_array($coach->id, $selectedCoachIds) ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<div class="flex items-center gap-2">
@if ($coach->getAvatarUrl())
<img src="{{ $coach->getAvatarUrl() }}" alt="" class="w-7 h-7 rounded-full object-cover">
@else
<div class="w-7 h-7 rounded-full bg-blue-100 text-blue-700 text-xs font-semibold flex items-center justify-center">
{{ $coach->getInitials() }}
</div>
@endif
<span class="text-sm text-gray-900">{{ $coach->name }}</span>
</div>
</label>
@endforeach
</div>
@endif
</div>
{{-- Card: Spieler --}}
<div class="bg-white rounded-lg shadow p-6" x-data="playerTeamSwitcher()">
<h2 class="text-sm font-semibold text-gray-700 mb-3">
{{ __('admin.team_players') }}
<span class="font-normal text-gray-400">({{ $team->players->count() }})</span>
</h2>
@if ($team->players->isEmpty())
<p class="text-sm text-gray-500">{{ __('admin.no_players_yet') }}</p>
@else
<div class="divide-y divide-gray-100">
@foreach ($team->players as $player)
<div class="py-2 flex items-center justify-between gap-2" data-player-row="{{ $player->id }}">
<div class="flex items-center gap-2 min-w-0">
@if ($player->getAvatarUrl())
<img src="{{ $player->getAvatarUrl() }}" alt="" class="w-8 h-8 rounded-full object-cover flex-shrink-0">
@else
<div class="w-8 h-8 rounded-full bg-gray-100 text-gray-600 text-xs font-semibold flex items-center justify-center flex-shrink-0">
{{ $player->getInitials() }}
</div>
@endif
<div class="min-w-0">
<a href="{{ route('admin.players.edit', $player) }}" class="text-sm font-medium text-gray-900 hover:text-blue-600 truncate block">
{{ $player->full_name }}
</a>
<span class="text-xs text-gray-400">#{{ $player->jersey_number ?? '' }}</span>
</div>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<select @change="switchTeam({{ $player->id }}, $event.target.value, $event.target)"
class="text-xs px-2 py-1 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500">
@foreach ($allTeams as $t)
<option value="{{ $t->id }}" {{ $t->id === $team->id ? 'selected' : '' }}>{{ $t->name }}</option>
@endforeach
</select>
<span x-show="savingId === {{ $player->id }}" class="text-xs text-gray-400">...</span>
<span x-show="savedId === {{ $player->id }}" x-cloak class="text-xs text-green-600">&#10003;</span>
<span x-show="errorId === {{ $player->id }}" x-cloak class="text-xs text-red-600">!</span>
</div>
</div>
@endforeach
</div>
@endif
</div>
{{-- Card: Elternvertretung --}}
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-sm font-semibold text-gray-700 mb-1">{{ __('admin.team_parent_reps') }}</h2>
<p class="text-xs text-gray-400 mb-3">{{ __('admin.team_parent_reps_hint') }}</p>
@if ($parentReps->isEmpty())
<p class="text-sm text-gray-500">{{ __('admin.no_parent_reps') }}</p>
@else
<div class="space-y-2">
@foreach ($parentReps as $rep)
<div class="flex items-center gap-2">
@if ($rep->getAvatarUrl())
<img src="{{ $rep->getAvatarUrl() }}" alt="" class="w-7 h-7 rounded-full object-cover">
@else
<div class="w-7 h-7 rounded-full bg-green-100 text-green-700 text-xs font-semibold flex items-center justify-center">
{{ $rep->getInitials() }}
</div>
@endif
<div>
<span class="text-sm font-medium text-gray-900">{{ $rep->name }}</span>
<span class="text-xs text-gray-400 block">{{ $rep->email }}</span>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
{{-- ══ FULL-WIDTH: Dateien ════════════════════════════════ --}}
<div class="mt-6 bg-white rounded-lg shadow p-6" x-data="{ showPicker: false, newFileCount: 0 }">
<h2 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.event_files') }}</h2>
{{-- Angehängte Dateien --}}
@if ($team->files->isNotEmpty())
<div class="mb-3 space-y-1">
<p class="text-xs font-semibold text-gray-500 mb-1">{{ __('admin.attached_files') }}</p>
@foreach ($team->files as $file)
<label class="flex items-center gap-2 py-1 text-sm text-gray-700 bg-blue-50 px-2 rounded">
<input type="checkbox" name="existing_files[]" value="{{ $file->id }}" checked class="rounded border-gray-300">
<span class="truncate">{{ $file->original_name }}</span>
<span class="text-xs text-gray-400 whitespace-nowrap">({{ $file->category->name ?? '' }} &middot; {{ $file->humanSize() }})</span>
<button type="button" class="ml-auto text-xs text-blue-600 hover:underline flex-shrink-0"
@click="$dispatch('open-file-preview', @js($file->previewData()))">
{{ __('ui.preview') }}
</button>
</label>
@endforeach
</div>
@endif
{{-- Aus Bibliothek anhängen --}}
<button type="button" @click="showPicker = !showPicker" class="text-sm text-blue-600 hover:text-blue-800 mb-2">
{{ __('admin.attach_from_library') }} &darr;
</button>
<div x-show="showPicker" x-cloak class="border border-gray-200 rounded-md p-3 mb-3 max-h-48 overflow-y-auto">
@php $attachedIds = $team->files->pluck('id')->toArray(); @endphp
@foreach ($fileCategories as $cat)
@if ($cat->files->isNotEmpty())
<p class="text-xs font-semibold text-gray-500 mt-2 first:mt-0 mb-1">{{ $cat->name }}</p>
@foreach ($cat->files as $libFile)
@if (!in_array($libFile->id, $attachedIds))
<label class="flex items-center gap-2 py-0.5 text-sm text-gray-700 hover:bg-gray-50 px-1 rounded">
<input type="checkbox" name="existing_files[]" value="{{ $libFile->id }}" class="rounded border-gray-300">
{{ $libFile->original_name }}
<span class="text-xs text-gray-400">({{ $libFile->humanSize() }})</span>
</label>
@endif
@endforeach
@endif
@endforeach
</div>
{{-- Neue Dateien hochladen --}}
<div class="space-y-2">
<template x-for="i in newFileCount" :key="i">
<div class="flex flex-wrap items-center gap-2">
<input type="file" :name="'new_files[' + (i-1) + ']'" accept=".pdf,.docx,.xlsx,.jpg,.jpeg,.png,.gif,.webp"
class="flex-1 min-w-[200px] text-sm px-3 py-1.5 border border-gray-300 rounded-md file:mr-3 file:py-1 file:px-3 file:rounded-md file:border-0 file:text-sm file:bg-gray-100 file:text-gray-700">
<select :name="'new_file_categories[' + (i-1) + ']'" required
class="px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500">
<option value="">{{ __('admin.select_category') }}</option>
@foreach ($fileCategories as $cat)
<option value="{{ $cat->id }}">{{ $cat->name }}</option>
@endforeach
</select>
</div>
</template>
</div>
<button type="button" @click="newFileCount++" class="mt-2 text-sm text-blue-600 hover:text-blue-800">
+ {{ __('admin.upload_new_file') }}
</button>
</div>
</form>
{{-- File preview modal --}}
<x-file-preview-modal />
@push('scripts')
<script>
function playerTeamSwitcher() {
return {
savingId: null,
savedId: null,
errorId: null,
async switchTeam(playerId, newTeamId, selectEl) {
if (parseInt(newTeamId) === @js($team->id)) return;
this.savingId = playerId;
this.savedId = null;
this.errorId = null;
try {
const res = await fetch(@js(route('admin.teams.update-player-team', $team)), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ player_id: playerId, new_team_id: newTeamId }),
});
if (res.ok) {
this.savedId = playerId;
this.savingId = null;
setTimeout(() => window.location.reload(), 500);
} else {
this.errorId = playerId;
this.savingId = null;
selectEl.value = @js($team->id);
setTimeout(() => { if (this.errorId === playerId) this.errorId = null; }, 3000);
}
} catch (e) {
this.errorId = playerId;
this.savingId = null;
selectEl.value = @js($team->id);
console.error(e);
}
}
};
}
</script>
@endpush
</x-layouts.admin>

View File

@@ -0,0 +1,51 @@
<x-layouts.admin :title="__('admin.teams_title')">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{{ __('admin.teams_title') }}</h1>
<a href="{{ route('admin.teams.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('admin.new_team') }}
</a>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('ui.name') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.year_group') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.nav_players') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.nav_events') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.status') }}</th>
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.action') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($teams as $team)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{{ $team->name }}</td>
<td class="px-4 py-3 text-gray-600">{{ $team->year_group ?? '' }}</td>
<td class="px-4 py-3 text-center text-gray-600">{{ $team->players_count }}</td>
<td class="px-4 py-3 text-center text-gray-600">{{ $team->events_count }}</td>
<td class="px-4 py-3 text-center">
@if ($team->is_active)
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">{{ __('admin.active') }}</span>
@else
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">{{ __('admin.inactive') }}</span>
@endif
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('admin.teams.edit', $team) }}" class="text-blue-600 hover:underline text-sm">{{ __('ui.edit') }}</a>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500">{{ __('admin.no_teams_yet') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $teams->links() }}
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,255 @@
<x-layouts.admin :title="__('admin.edit_user')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.edit_user') }}: {{ $user->name }}</h1>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Benutzerdaten --}}
<div class="bg-white rounded-lg shadow p-6">
<h2 class="font-semibold mb-4">{{ __('admin.user_data') }}</h2>
<form method="POST" action="{{ route('admin.users.update', $user) }}" enctype="multipart/form-data">
@csrf
@method('PUT')
{{-- Profilbild --}}
<div class="mb-5" x-data="{ preview: null }">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.profile_picture') }}</label>
<div class="flex items-center gap-4">
<div class="relative">
@if ($user->getAvatarUrl())
<img src="{{ $user->getAvatarUrl() }}" alt="{{ $user->name }}" class="w-14 h-14 rounded-full object-cover border-2 border-gray-200" x-show="!preview">
@else
<div class="w-14 h-14 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-semibold border-2 border-gray-200" x-show="!preview">
{{ $user->getInitials() }}
</div>
@endif
<img :src="preview" x-show="preview" class="w-14 h-14 rounded-full object-cover border-2 border-blue-400" x-cloak>
</div>
<div class="flex flex-col gap-1">
<label class="cursor-pointer bg-gray-100 text-gray-700 px-3 py-1.5 rounded-md text-xs hover:bg-gray-200 inline-block">
{{ __('admin.upload_picture') }}
<input type="file" name="profile_picture" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden"
@change="if ($event.target.files[0]) { preview = URL.createObjectURL($event.target.files[0]) }">
</label>
<span class="text-xs text-gray-400">{{ __('admin.max_picture_size') }}</span>
@error('profile_picture')
<p class="text-red-600 text-xs">{{ $message }}</p>
@enderror
</div>
</div>
</div>
<div class="mb-4">
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.name') }} *</label>
<input type="text" name="name" id="name" value="{{ old('name', $user->name) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('name') border-red-500 @enderror">
@error('name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="mb-4">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.email') }} *</label>
<input type="email" name="email" id="email" value="{{ old('email', $user->email) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('email') border-red-500 @enderror">
@error('email')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="mb-4">
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.phone') }}</label>
<input type="tel" name="phone" id="phone" value="{{ old('phone', $user->phone) }}"
placeholder="+49..."
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('phone') border-red-500 @enderror">
@error('phone')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="mb-4">
<label for="role" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.role') }}</label>
<select name="role" id="role" class="w-full px-3 py-2 border border-gray-300 rounded-md {{ $user->id === auth()->id() ? 'bg-gray-100 text-gray-500' : '' }}" {{ $user->id === auth()->id() ? 'disabled' : '' }}>
<option value="user" {{ old('role', $user->role->value) === 'user' ? 'selected' : '' }}>{{ __('ui.enums.user_role.user') }}</option>
<option value="parent_rep" {{ old('role', $user->role->value) === 'parent_rep' ? 'selected' : '' }}>{{ __('ui.enums.user_role.parent_rep') }}</option>
<option value="coach" {{ old('role', $user->role->value) === 'coach' ? 'selected' : '' }}>{{ __('ui.enums.user_role.coach') }}</option>
@if (auth()->user()->isAdmin())
<option value="admin" {{ old('role', $user->role->value) === 'admin' ? 'selected' : '' }}>{{ __('ui.enums.user_role.admin') }}</option>
@endif
</select>
@if ($user->id === auth()->id())
<p class="mt-1 text-xs text-gray-500">{{ __('admin.cannot_edit_own_role') }}</p>
@endif
</div>
<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>
</form>
@if ($user->getAvatarUrl())
<div class="mt-3 pt-3 border-t">
<form method="POST" action="{{ route('admin.users.remove-picture', $user) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_file')))">
@csrf
@method('DELETE')
<button type="submit" class="text-xs text-red-500 hover:text-red-700">{{ __('admin.remove_picture') }}</button>
</form>
</div>
@endif
</div>
{{-- Passwort-Reset --}}
<div class="bg-white rounded-lg shadow p-6">
<h2 class="font-semibold mb-4">{{ __('admin.reset_password') }}</h2>
@if (session('new_password'))
<div class="bg-yellow-50 border border-yellow-300 rounded-md p-4 mb-4">
<p class="text-sm font-medium text-yellow-800 mb-2">{{ __('admin.new_password_label') }}</p>
<div class="flex items-center gap-2" x-data="{ copied: false }">
<code class="bg-white border border-yellow-200 rounded px-3 py-2 text-sm font-mono flex-1 select-all">{{ session('new_password') }}</code>
<button @click="navigator.clipboard.writeText(@js(session('new_password'))); copied = true; setTimeout(() => copied = false, 2000)"
class="px-3 py-2 text-xs bg-yellow-600 text-white rounded-md hover:bg-yellow-700">
<span x-show="!copied">{{ __('admin.copy') }}</span>
<span x-show="copied" x-cloak>{{ __('admin.copied') }}</span>
</button>
</div>
<p class="text-xs text-yellow-700 mt-2">{{ __('admin.password_only_visible_now') }}</p>
</div>
@endif
@if ($user->id === auth()->id())
<p class="text-sm text-gray-500">{{ __('admin.cannot_reset_own_password') }}</p>
@else
<p class="text-sm text-gray-600 mb-4">{{ __('admin.reset_password_hint') }}</p>
<form method="POST" action="{{ route('admin.users.reset-password', $user) }}" onsubmit="return confirm(@js(__('admin.reset_password_confirm')))">
@csrf
@method('PUT')
<button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 text-sm font-medium">
{{ __('admin.reset_password') }}
</button>
</form>
@endif
{{-- Zusatzinfos --}}
<div class="mt-6 pt-4 border-t text-xs text-gray-500 space-y-1">
<p>{{ __('admin.last_login') }}: {{ $user->last_login_at ? $user->last_login_at->diffForHumans() : __('admin.never') }}</p>
<p>{{ __('admin.registered_at') }}: {{ $user->created_at->translatedFormat(__('ui.date_format')) }}</p>
</div>
</div>
</div>
{{-- DSGVO-Einverständniserklärung --}}
<div class="mt-6 bg-white rounded-lg shadow p-6" x-data="{ dsgvoModal: false }">
<h2 class="font-semibold mb-4">{{ __('admin.dsgvo_title') }}</h2>
@if ($user->hasDsgvoConsent())
@php
$ext = strtolower(pathinfo($user->dsgvo_consent_file, PATHINFO_EXTENSION));
$dsgvoIsPdf = $ext === 'pdf';
$dsgvoIsImage = in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp']);
@endphp
<div class="flex items-center gap-3 mb-4">
<button type="button" @click="dsgvoModal = true"
class="text-sm text-blue-600 hover:text-blue-800 font-medium cursor-pointer">
{{ __('admin.dsgvo_view_document') }}
</button>
</div>
{{-- DSGVO-Vorschau-Modal --}}
<div x-show="dsgvoModal" x-cloak @keydown.escape.window="dsgvoModal = false" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div x-show="dsgvoModal"
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@click="dsgvoModal = false" class="fixed inset-0 bg-black/60"></div>
<div x-show="dsgvoModal"
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
@click.outside="dsgvoModal = false"
class="relative bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden {{ $dsgvoIsPdf ? 'w-full max-w-3xl max-h-[92vh]' : 'w-full max-w-lg max-h-[90vh]' }}">
<div class="flex items-center justify-between px-5 py-3 border-b">
<h3 class="font-semibold text-gray-900">{{ __('admin.dsgvo_title') }} {{ $user->name }}</h3>
<button @click="dsgvoModal = false" class="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="flex-1 overflow-y-auto {{ $dsgvoIsPdf ? 'p-0' : 'p-5' }}">
@if ($dsgvoIsImage)
<div class="flex justify-center bg-gray-50 rounded-lg p-2">
<img src="{{ route('admin.users.view-dsgvo-consent', $user) }}" alt="{{ __('admin.dsgvo_title') }}" class="max-w-full max-h-[70vh] rounded object-contain">
</div>
@elseif ($dsgvoIsPdf)
<iframe src="{{ route('admin.users.view-dsgvo-consent', $user) }}" class="w-full border-0" style="height: 75vh;"></iframe>
@endif
</div>
<div class="px-5 py-3 border-t bg-gray-50 flex justify-end">
<button @click="dsgvoModal = false" class="text-sm text-gray-500 hover:text-gray-700">{{ __('ui.close') }}</button>
</div>
</div>
</div>
<form method="POST" action="{{ route('admin.users.dsgvo-toggle', $user) }}">
@csrf
@method('PUT')
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ __('admin.dsgvo_consent_label') }}</p>
<p class="text-xs text-gray-500">
@if ($user->isDsgvoConfirmed())
{{ __('admin.dsgvo_confirmed_info', [
'name' => $user->dsgvoAcceptedBy?->name ?? '—',
'date' => $user->dsgvo_accepted_at->translatedFormat(__('ui.date_format_short'))
]) }}
@else
{{ __('admin.dsgvo_not_confirmed') }}
@endif
</p>
</div>
<button type="submit" class="px-4 py-2 rounded-md text-sm font-medium
{{ $user->isDsgvoConfirmed()
? 'bg-yellow-500 text-white hover:bg-yellow-600'
: 'bg-green-600 text-white hover:bg-green-700' }}">
{{ $user->isDsgvoConfirmed() ? __('admin.dsgvo_revoke') : __('admin.dsgvo_confirm') }}
</button>
</div>
</form>
@else
<p class="text-sm text-gray-500">{{ __('admin.dsgvo_no_document') }}</p>
@endif
</div>
{{-- Benutzer deaktivieren / löschen --}}
@if ($user->id !== auth()->id())
<div class="mt-6 bg-white rounded-lg shadow p-6 border border-red-200">
<h2 class="font-semibold text-red-700 mb-2">{{ __('admin.danger_zone') }}</h2>
{{-- Deaktivieren / Aktivieren --}}
<div class="flex items-center justify-between py-3">
<div>
<p class="text-sm font-medium text-gray-900">{{ __('admin.user_status_label') }}</p>
<p class="text-xs text-gray-500">
{{ $user->is_active ? __('admin.deactivate_user_hint') : __('admin.activate_user_hint') }}
</p>
</div>
<form method="POST" action="{{ route('admin.users.toggle-active', $user) }}">
@csrf
@method('PUT')
<button type="submit" class="px-4 py-2 rounded-md text-sm font-medium {{ $user->is_active ? 'bg-yellow-500 text-white hover:bg-yellow-600' : 'bg-green-600 text-white hover:bg-green-700' }}">
{{ $user->is_active ? __('admin.deactivate') : __('admin.activate') }}
</button>
</form>
</div>
{{-- Löschen --}}
@if ($user->id !== 1)
<div class="flex items-center justify-between py-3 border-t border-red-100">
<div>
<p class="text-sm font-medium text-gray-900">{{ __('admin.delete_user') }}</p>
<p class="text-xs text-gray-500">{{ __('admin.delete_user_hint') }}</p>
</div>
<form method="POST" action="{{ route('admin.users.destroy', $user) }}" onsubmit="return confirm(@js(__('admin.confirm_delete_user')))">
@csrf
@method('DELETE')
<button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 text-sm font-medium">
{{ __('admin.delete') }}
</button>
</form>
</div>
@endif
</div>
@endif
<div class="mt-4">
<a href="{{ route('admin.users.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('admin.back_to_list') }}</a>
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,201 @@
<x-layouts.admin :title="__('admin.users_title')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.users_title') }}</h1>
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'name', 'direction' => ($sort === 'name' && $direction === 'asc') ? 'desc' : 'asc']) }}"
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'name' ? 'text-blue-600' : '' }}">
{{ __('ui.name') }}
@if ($sort === 'name')
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
@if ($direction === 'asc')
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
@else
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
@endif
</svg>
@endif
</a>
</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'email', 'direction' => ($sort === 'email' && $direction === 'asc') ? 'desc' : 'asc']) }}"
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'email' ? 'text-blue-600' : '' }}">
{{ __('ui.email') }}
@if ($sort === 'email')
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
@if ($direction === 'asc')
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
@else
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
@endif
</svg>
@endif
</a>
</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.phone') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'role', 'direction' => ($sort === 'role' && $direction === 'asc') ? 'desc' : 'asc']) }}"
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'role' ? 'text-blue-600' : '' }}">
{{ __('ui.role') }}
@if ($sort === 'role')
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
@if ($direction === 'asc')
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
@else
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
@endif
</svg>
@endif
</a>
</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.children') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.dsgvo_short') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'last_login_at', 'direction' => ($sort === 'last_login_at' && $direction === 'asc') ? 'desc' : 'asc']) }}"
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'last_login_at' ? 'text-blue-600' : '' }}">
{{ __('admin.last_login') }}
@if ($sort === 'last_login_at')
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
@if ($direction === 'asc')
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
@else
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
@endif
</svg>
@endif
</a>
</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'is_active', 'direction' => ($sort === 'is_active' && $direction === 'asc') ? 'desc' : 'asc']) }}"
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'is_active' ? 'text-blue-600' : '' }}">
{{ __('admin.status') }}
@if ($sort === 'is_active')
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
@if ($direction === 'asc')
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
@else
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
@endif
</svg>
@endif
</a>
</th>
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($users as $user)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">
<div class="flex items-center gap-2">
<img src="{{ $user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0">
{{ $user->name }}
</div>
</td>
<td class="px-4 py-3 text-gray-600">{{ $user->email }}</td>
<td class="px-4 py-3 text-gray-600 text-sm whitespace-nowrap">
@if ($user->phone)
<a href="tel:{{ $user->phone }}" class="hover:text-blue-600">{{ $user->phone }}</a>
@else
<span class="text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3 text-center">
@if ($user->id !== auth()->id())
<form method="POST" action="{{ route('admin.users.role', $user) }}" class="inline">
@csrf
@method('PUT')
<select name="role" onchange="this.form.submit()" class="text-xs border border-gray-300 rounded-md px-2 py-1">
<option value="user" {{ $user->role === \App\Enums\UserRole::User ? 'selected' : '' }}>{{ __('ui.enums.user_role.user') }}</option>
<option value="parent_rep" {{ $user->role === \App\Enums\UserRole::ParentRep ? 'selected' : '' }}>{{ __('ui.enums.user_role.parent_rep') }}</option>
<option value="coach" {{ $user->role === \App\Enums\UserRole::Coach ? 'selected' : '' }}>{{ __('ui.enums.user_role.coach') }}</option>
@if (auth()->user()->isAdmin())
<option value="admin" {{ $user->role === \App\Enums\UserRole::Admin ? 'selected' : '' }}>{{ __('ui.enums.user_role.admin') }}</option>
@endif
</select>
</form>
@else
<span class="text-xs font-medium text-gray-500">{{ __('ui.enums.user_role.' . $user->role->value) }} {{ __('admin.you_suffix') }}</span>
@endif
</td>
<td class="px-4 py-3 text-xs text-gray-600">
@foreach ($user->children as $child)
{{ $child->first_name }}@if (!$loop->last), @endif
@endforeach
@if ($user->children->isEmpty())
<span class="text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3 text-center">
@if ($user->isDsgvoConfirmed())
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800" title="{{ __('admin.dsgvo_confirmed_tooltip') }}">&#10003;</span>
@elseif ($user->hasDsgvoConsent())
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800" title="{{ __('admin.dsgvo_pending_tooltip') }}">!</span>
@else
<span class="text-gray-400" title="{{ __('admin.dsgvo_missing_tooltip') }}"></span>
@endif
</td>
<td class="px-4 py-3 text-center text-xs text-gray-500">
{{ $user->last_login_at ? $user->last_login_at->diffForHumans() : __('admin.never') }}
</td>
<td class="px-4 py-3 text-center">
@if ($user->is_active)
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">{{ __('admin.active') }}</span>
@else
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">{{ __('admin.deactivated_label') }}</span>
@endif
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('admin.users.edit', $user) }}" class="text-xs text-blue-600 hover:text-blue-800">{{ __('admin.edit') }}</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4">{{ $users->links() }}</div>
{{-- Papierkorb --}}
@if ($trashedUsers->isNotEmpty())
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-700 mb-3">{{ __('admin.trash') }}</h2>
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto border border-red-200">
<table class="w-full text-sm">
<thead class="bg-red-50 border-b border-red-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('ui.name') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('ui.email') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.deleted_at') }}</th>
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($trashedUsers as $user)
<tr class="hover:bg-red-50/50">
<td class="px-4 py-3 font-medium text-gray-500">
<div class="flex items-center gap-2">
<img src="{{ $user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0 opacity-50">
{{ $user->name }}
</div>
</td>
<td class="px-4 py-3 text-gray-400">{{ $user->email }}</td>
<td class="px-4 py-3 text-center text-xs text-gray-500">{{ $user->deleted_at->diffForHumans() }}</td>
<td class="px-4 py-3 text-right">
<form method="POST" action="{{ route('admin.users.restore', $user->id) }}" class="inline">
@csrf
@method('PUT')
<button type="submit" class="text-xs text-green-600 hover:text-green-800 font-medium">{{ __('admin.restore') }}</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</x-layouts.admin>

View File

@@ -0,0 +1,44 @@
<x-layouts.guest :title="__('auth_ui.forgot_password_title')">
<h2 class="text-lg font-bold text-center mb-2">{{ __('auth_ui.forgot_password_title') }}</h2>
<p class="text-sm text-gray-500 text-center mb-6">{{ __('auth_ui.forgot_password_description') }}</p>
@if (session('status'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-md text-sm text-green-700">
{{ session('status') }}
</div>
@endif
<form method="POST" action="{{ route('password.email') }}">
@csrf
<div class="mb-4">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.email_label') }}</label>
<input
type="email"
name="email"
id="email"
value="{{ old('email') }}"
required
autofocus
autocomplete="email"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('email') border-red-500 @enderror"
>
@error('email')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
>
{{ __('auth_ui.send_reset_link') }}
</button>
</form>
<p class="mt-4 text-sm text-center">
<a href="{{ route('login') }}" class="text-blue-600 hover:text-blue-800 underline">
{{ __('auth_ui.back_to_login') }}
</a>
</p>
</x-layouts.guest>

View File

@@ -0,0 +1,62 @@
<x-layouts.guest :title="__('auth_ui.login_title')">
<h2 class="text-lg font-bold text-center mb-6">{{ __('auth_ui.login_title') }}</h2>
<form method="POST" action="{{ route('login') }}">
@csrf
{{-- Honeypot --}}
<div class="hidden" aria-hidden="true">
<input type="text" name="website" value="" tabindex="-1" autocomplete="off">
</div>
<div class="mb-4">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.email_label') }}</label>
<input
type="email"
name="email"
id="email"
value="{{ old('email') }}"
required
autofocus
autocomplete="email"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('email') border-red-500 @enderror"
>
@error('email')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<div class="flex items-center justify-between mb-1">
<label for="password" class="block text-sm font-medium text-gray-700">{{ __('auth_ui.password_label') }}</label>
<a href="{{ route('password.request') }}" class="text-xs text-blue-600 hover:text-blue-800 underline">{{ __('auth_ui.forgot_password_link') }}</a>
</div>
<input
type="password"
name="password"
id="password"
required
autocomplete="current-password"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
<div class="mb-6 flex items-center">
<input type="checkbox" name="remember" id="remember" class="rounded border-gray-300 mr-2 rtl:ml-2 rtl:mr-0">
<label for="remember" class="text-sm text-gray-600">{{ __('auth_ui.remember_me') }}</label>
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
>
{{ __('auth_ui.login_button') }}
</button>
</form>
<p class="mt-4 text-xs text-center text-gray-400 leading-relaxed">
<svg class="inline w-3.5 h-3.5 -mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
{{ __('auth_ui.cookie_notice') }}
<a href="/datenschutz#cookies" class="underline hover:text-gray-600">{{ __('auth_ui.cookie_notice_link') }}</a>
</p>
</x-layouts.guest>

View File

@@ -0,0 +1,62 @@
<x-layouts.guest :title="__('auth_ui.register_title')">
<h2 class="text-xl font-bold text-center mb-6">{{ __('auth_ui.register_title') }}</h2>
@if ($invitation->players->isNotEmpty())
<div class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p class="text-sm text-blue-800 font-medium mb-1">{{ __('auth_ui.children_assigned') }}</p>
<ul class="text-sm text-blue-700">
@foreach ($invitation->players as $player)
<li>{{ $player->full_name }} ({{ $player->team->name }})</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('register', $invitation->token) }}">
@csrf
{{-- Honeypot --}}
<div class="hidden" aria-hidden="true">
<input type="text" name="website" value="" tabindex="-1" autocomplete="off">
</div>
<div class="mb-4">
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.name_label') }} *</label>
<input type="text" name="name" id="name" value="{{ old('name') }}" required autofocus
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 @error('name') border-red-500 @enderror">
@error('name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="mb-4">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.email_label') }} *</label>
<input type="email" name="email" id="email" value="{{ old('email', $invitation->email) }}" required
{{ $invitation->email ? 'readonly' : '' }}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 @error('email') border-red-500 @enderror {{ $invitation->email ? 'bg-gray-100' : '' }}">
@error('email')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
@if ($invitation->email)
<p class="mt-1 text-xs text-gray-500">{{ __('auth_ui.email_fixed_by_invitation') }}</p>
@endif
</div>
<div class="mb-4">
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.password_label') }} * <span class="font-normal text-gray-400">{{ __('auth_ui.password_min') }}</span></label>
<input type="password" name="password" id="password" required minlength="8"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 @error('password') border-red-500 @enderror">
@error('password')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="mb-6">
<label for="password_confirmation" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.password_confirm_label') }} *</label>
<input type="password" name="password_confirmation" id="password_confirmation" required minlength="8"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<button type="submit" class="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 font-medium">
{{ __('auth_ui.create_account') }}
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500">
{{ __('auth_ui.already_registered') }} <a href="{{ route('login') }}" class="text-blue-600 hover:underline">{{ __('auth_ui.go_to_login') }}</a>
</p>
</x-layouts.guest>

View File

@@ -0,0 +1,65 @@
<x-layouts.guest :title="__('auth_ui.reset_password_title')">
<h2 class="text-lg font-bold text-center mb-6">{{ __('auth_ui.reset_password_title') }}</h2>
<form method="POST" action="{{ route('password.update') }}">
@csrf
<input type="hidden" name="token" value="{{ $token }}">
<div class="mb-4">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.email_label') }}</label>
<input
type="email"
name="email"
id="email"
value="{{ old('email', $email) }}"
required
autocomplete="email"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('email') border-red-500 @enderror"
>
@error('email')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.new_password_label') }}</label>
<input
type="password"
name="password"
id="password"
required
autocomplete="new-password"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('password') border-red-500 @enderror"
>
<p class="mt-1 text-xs text-gray-400">{{ __('auth_ui.password_min') }}</p>
@error('password')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="password_confirmation" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.confirm_password_label') }}</label>
<input
type="password"
name="password_confirmation"
id="password_confirmation"
required
autocomplete="new-password"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
>
{{ __('auth_ui.reset_password_button') }}
</button>
</form>
<p class="mt-4 text-sm text-center">
<a href="{{ route('login') }}" class="text-blue-600 hover:text-blue-800 underline">
{{ __('auth_ui.back_to_login') }}
</a>
</p>
</x-layouts.guest>

View File

@@ -0,0 +1,78 @@
@props(['event'])
@php
use App\Enums\ParticipantStatus;
use App\Enums\CateringStatus;
use App\Enums\EventType;
$isMeeting = $event->type === EventType::Meeting;
$yesCount = $event->participants->where('status', ParticipantStatus::Yes)->count();
$noCount = $event->participants->where('status', ParticipantStatus::No)->count();
$openCount = $event->participants->where('status', ParticipantStatus::Unknown)->count();
// withCount-Attribute nutzen wenn vorhanden (Index-Views), sonst Collection filtern (Show-View)
$hasCatering = $event->type->hasCatering();
$hasTimekeepers = $event->type->hasTimekeepers();
$cateringYes = $hasCatering ? ($event->caterings_yes_count ?? $event->caterings->where('status', CateringStatus::Yes)->count()) : 0;
$timekeeperYes = $hasTimekeepers ? ($event->timekeepers_yes_count ?? $event->timekeepers->where('status', CateringStatus::Yes)->count()) : 0;
// Individual minimum status
if ($event->min_players !== null) {
$playersMet = $yesCount >= $event->min_players;
} else {
$playersMet = null;
}
$cateringMet = ($hasCatering && $event->min_catering !== null) ? $cateringYes >= $event->min_catering : null;
$timekeepersMet = ($hasTimekeepers && $event->min_timekeepers !== null) ? $timekeeperYes >= $event->min_timekeepers : null;
// Box size classes
$boxClass = 'inline-flex items-center justify-center w-9 h-9 rounded text-sm font-bold';
$participantsLabel = $isMeeting ? __('admin.nav_users') : __('admin.nav_players');
@endphp
<div class="flex items-start gap-4 sm:gap-6">
{{-- Spieler / Benutzer --}}
<div class="text-center">
<div class="text-[10px] font-semibold text-gray-500 uppercase tracking-wide mb-1">{{ $participantsLabel }}</div>
<div class="flex gap-0.5">
@if ($playersMet === true)
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $yesCount }}</span>
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $noCount }}</span>
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $openCount }}</span>
@else
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $yesCount }}</span>
<span class="{{ $boxClass }} bg-red-500 text-white">{{ $noCount }}</span>
<span class="{{ $boxClass }} bg-gray-300 text-gray-700">{{ $openCount }}</span>
@endif
</div>
</div>
@if ($hasCatering)
{{-- Catering --}}
<div class="text-center">
<div class="text-[10px] font-semibold text-gray-500 uppercase tracking-wide mb-1">{{ __('events.catering_short') }}</div>
@if ($cateringMet === true)
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $cateringYes }}</span>
@elseif ($cateringMet === false)
<span class="{{ $boxClass }} bg-red-500 text-white">{{ $cateringYes }}</span>
@else
<span class="{{ $boxClass }} bg-gray-200 text-gray-600">{{ $cateringYes }}</span>
@endif
</div>
@endif
@if ($hasTimekeepers)
{{-- Zeitnehmer --}}
<div class="text-center">
<div class="text-[10px] font-semibold text-gray-500 uppercase tracking-wide mb-1">{{ __('events.timekeeper_short') }}</div>
@if ($timekeepersMet === true)
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $timekeeperYes }}</span>
@elseif ($timekeepersMet === false)
<span class="{{ $boxClass }} bg-red-500 text-white">{{ $timekeeperYes }}</span>
@else
<span class="{{ $boxClass }} bg-gray-200 text-gray-600">{{ $timekeeperYes }}</span>
@endif
</div>
@endif
</div>

View File

@@ -0,0 +1,18 @@
@props(['type'])
@php
$colors = match($type->value ?? $type) {
'home_game' => 'bg-blue-100 text-blue-800',
'away_game' => 'bg-indigo-100 text-indigo-800',
'training' => 'bg-green-100 text-green-800',
'tournament' => 'bg-purple-100 text-purple-800',
'meeting' => 'bg-yellow-100 text-yellow-800',
'other' => 'bg-gray-100 text-gray-800',
default => 'bg-gray-100 text-gray-800',
};
$label = method_exists($type, 'label') ? $type->label() : $type;
@endphp
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium {{ $colors }}">
{{ $label }}
</span>

View File

@@ -0,0 +1,109 @@
{{-- File Preview Modal (Alpine.js) --}}
<div x-data="filePreviewModal()" x-cloak @open-file-preview.window="openFile($event.detail)" @keydown.escape.window="close()">
{{-- Backdrop --}}
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="close()"
class="fixed inset-0 bg-black/60 z-40"></div>
{{-- Modal --}}
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="fixed inset-0 flex items-center justify-center z-50 p-4 sm:p-6">
<div @click.away="close()"
class="bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden"
:class="(file.isPdf || file.isHtml) ? 'w-full max-w-3xl max-h-[92vh]' : 'w-full max-w-lg max-h-[90vh]'">
{{-- Header --}}
<div class="flex items-center justify-between px-5 py-3 border-b min-w-0">
<h3 class="font-semibold text-gray-900 truncate pr-4" x-text="file.name"></h3>
<button @click="close()" class="flex-shrink-0 text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
{{-- Content --}}
<div class="flex-1 overflow-y-auto p-5" :class="(file.isPdf || file.isHtml) ? 'p-0' : 'p-5'">
{{-- Image Preview --}}
<template x-if="file.isImage && file.previewUrl">
<div class="flex justify-center mb-4 bg-gray-50 rounded-lg p-2 mx-5 mt-5">
<img :src="file.previewUrl" :alt="file.name" class="max-w-full max-h-80 rounded object-contain" loading="lazy">
</div>
</template>
{{-- PDF Preview --}}
<template x-if="file.isPdf && file.previewUrl">
<iframe :src="file.previewUrl" class="w-full border-0" style="height: 70vh;"></iframe>
</template>
{{-- HTML Preview --}}
<template x-if="file.isHtml && file.previewUrl">
<iframe :src="file.previewUrl" class="w-full border-0" style="height: 70vh;"></iframe>
</template>
{{-- Non-Image/Non-PDF/Non-HTML: Large Icon --}}
<template x-if="!file.isImage && !file.isPdf && !file.isHtml">
<div class="flex justify-center mb-4 mx-5 mt-5">
<div class="w-20 h-20 rounded-xl flex items-center justify-center" :class="file.iconBg">
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
</div>
</div>
</template>
{{-- File Info (nicht bei PDF/HTML, da dort der iframe den Platz braucht) --}}
<template x-if="!file.isPdf && !file.isHtml">
<div class="space-y-2 text-center px-5 pb-2">
<div>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700" x-text="file.category"></span>
</div>
<p class="text-sm text-gray-500" x-text="file.size"></p>
</div>
</template>
</div>
{{-- Footer --}}
<div class="px-5 py-3 border-t bg-gray-50 flex justify-between items-center">
<div class="flex items-center gap-3">
<button @click="close()" class="text-sm text-gray-500 hover:text-gray-700">{{ __('ui.close') }}</button>
<template x-if="file.isPdf || file.isHtml">
<span class="text-xs text-gray-400">
<span x-text="file.category"></span> &middot; <span x-text="file.size"></span>
</span>
</template>
</div>
<a :href="file.downloadUrl" class="inline-flex items-center gap-1.5 bg-blue-600 text-white px-4 py-2 rounded-md text-sm hover:bg-blue-700">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
{{ __('ui.download') }}
</a>
</div>
</div>
</div>
</div>
<script>
function filePreviewModal() {
return {
open: false,
file: { name: '', category: '', size: '', downloadUrl: '', previewUrl: null, isImage: false, isPdf: false, isHtml: false, iconBg: '' },
openFile(detail) {
this.file = detail;
this.open = true;
document.body.style.overflow = 'hidden';
},
close() {
this.open = false;
document.body.style.overflow = '';
}
}
}
</script>

View File

@@ -0,0 +1,17 @@
@props(['type' => 'success', 'message'])
@php
$classes = match($type) {
'success' => 'bg-green-50 border-green-400 text-green-800',
'error' => 'bg-red-50 border-red-400 text-red-800',
'warning' => 'bg-yellow-50 border-yellow-400 text-yellow-800',
default => 'bg-blue-50 border-blue-400 text-blue-800',
};
@endphp
<div class="mb-4 p-3 border-l-4 rounded {{ $classes }}" x-data="{ show: true }" x-show="show">
<div class="flex justify-between items-center">
<p class="text-sm">{{ $message }}</p>
<button @click="show = false" class="ml-4 text-lg leading-none opacity-50 hover:opacity-100">&times;</button>
</div>
</div>

View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ __('ui.admin') }} - {{ $title ?? \App\Models\Setting::get('app_name', config('app.name')) }}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js" integrity="sha384-9Ax3MmS9AClxJyd5/zafcXXjxmwFhZCdsT6HJoJjarvCaAkJlk5QDzjLJm+Wdx5F" crossorigin="anonymous"></script>
@php $favicon = \App\Models\Setting::get('app_favicon'); @endphp
@if ($favicon)
<link rel="icon" href="{{ asset('storage/' . $favicon) }}">
@else
<link rel="icon" href="{{ asset('favicon.ico') }}">
@endif
{{-- PWA --}}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1f2937">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="SG Wölfe">
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png">
@stack('styles')
</head>
<body class="min-h-screen bg-gray-100 flex flex-col">
{{-- Admin Navigation --}}
<nav class="bg-gray-800 text-white" x-data="{ open: false, mgmt: false }">
<div class="max-w-6xl mx-auto px-4">
<div class="flex justify-between h-14">
<div class="flex items-center space-x-6 rtl:space-x-reverse">
<a href="{{ route('admin.dashboard') }}" class="flex items-center gap-2 font-bold">
<img src="/images/logo_woelfe.png" alt="Logo" class="h-8 w-8 object-contain">
{{ __('ui.admin') }}
</a>
{{-- Desktop nav links (ab lg sichtbar) --}}
<div class="hidden lg:flex items-center space-x-5 rtl:space-x-reverse">
<a href="{{ route('admin.events.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.events.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_events') }}</a>
@if (\App\Models\Setting::isFeatureVisibleFor('statistics', auth()->user()))
<a href="{{ route('admin.statistics.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.statistics.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_statistics') }}</a>
@endif
@if (auth()->user()->isStaff())
<a href="{{ route('admin.teams.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.teams.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_teams') }}</a>
<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
<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>
@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' : '' }}">
{{ __('admin.nav_verwaltung') }}
@if (\Illuminate\Support\Facades\Cache::has('support.update_check'))
<span class="w-2 h-2 bg-blue-400 rounded-full"></span>
@endif
<svg class="w-3 h-3 transition-transform" :class="mgmt ? 'rotate-180' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</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>
<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>
<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>
<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>
@endif
</div>
</div>
@endif
</div>
</div>
{{-- Mobile/Tablet menu button --}}
<div class="flex items-center lg:hidden">
<button @click="open = !open" class="text-gray-300 hover:text-white">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
<path x-show="open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="hidden lg:flex items-center space-x-4 rtl:space-x-reverse">
<a href="{{ route('dashboard') }}" class="flex items-center gap-2 text-sm text-gray-300 hover:text-white">
@if (auth()->user()->getAvatarUrl())
<img src="{{ auth()->user()->getAvatarUrl() }}" alt="{{ auth()->user()->name }}" class="w-7 h-7 rounded-full object-cover border border-gray-600">
@else
<div class="w-7 h-7 rounded-full bg-gray-600 flex items-center justify-center text-gray-200 text-xs font-semibold">
{{ auth()->user()->getInitials() }}
</div>
@endif
{{ __('ui.back_to_app') }}
</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-sm text-gray-400 hover:text-white">{{ __('ui.logout') }}</button>
</form>
</div>
</div>
</div>
{{-- Mobile/Tablet menu --}}
<div x-show="open" x-cloak class="lg:hidden border-t border-gray-700 px-4 py-2 space-y-1">
<a href="{{ route('admin.events.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_events') }}</a>
@if (\App\Models\Setting::isFeatureVisibleFor('statistics', auth()->user()))
<a href="{{ route('admin.statistics.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_statistics') }}</a>
@endif
@if (auth()->user()->isStaff())
<a href="{{ route('admin.teams.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_teams') }}</a>
<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
<a href="{{ route('admin.files.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_files') }}</a>
@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>
<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>
<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>
<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>
@endif
</div>
@endif
<a href="{{ route('dashboard') }}" class="block py-2 text-sm text-gray-300">{{ __('ui.back_to_app') }}</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="block py-2 text-sm text-gray-400">{{ __('ui.logout') }}</button>
</form>
</div>
</nav>
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-6">
@if (session('success'))
<x-flash-message type="success" :message="session('success')" />
@endif
@if (session('error'))
<x-flash-message type="error" :message="session('error')" />
@endif
{{ $slot }}
</main>
<footer class="text-center py-2 text-xs text-gray-400">v{{ config('app.version') }}</footer>
@stack('scripts')
{{-- Service Worker --}}
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.catch((err) => { console.error('SW:', err); });
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? \App\Models\Setting::get('app_name', config('app.name')) }}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js" integrity="sha384-9Ax3MmS9AClxJyd5/zafcXXjxmwFhZCdsT6HJoJjarvCaAkJlk5QDzjLJm+Wdx5F" crossorigin="anonymous"></script>
@php $favicon = \App\Models\Setting::get('app_favicon'); @endphp
@if ($favicon)
<link rel="icon" href="{{ asset('storage/' . $favicon) }}">
@else
<link rel="icon" href="{{ asset('favicon.ico') }}">
@endif
{{-- PWA --}}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1f2937">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="SG Wölfe">
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png">
@stack('styles')
</head>
<body class="min-h-screen bg-gray-100 flex flex-col">
{{-- Navigation --}}
<nav class="bg-white shadow" x-data="{ open: false }">
<div class="max-w-5xl mx-auto px-4">
<div class="flex justify-between h-14">
<div class="flex items-center space-x-6 rtl:space-x-reverse">
<a href="{{ route('dashboard') }}" class="flex items-center gap-2 font-bold text-gray-900">
<img src="/images/logo_woelfe.png" alt="Logo" class="h-8 w-8 object-contain">
{{ \App\Models\Setting::get('app_name', config('app.name')) }}
</a>
<div class="hidden sm:flex items-center space-x-6 rtl:space-x-reverse">
<a href="{{ route('dashboard') }}" class="text-sm text-gray-600 hover:text-gray-900 {{ request()->routeIs('dashboard') ? 'font-semibold text-gray-900' : '' }}">{{ __('ui.dashboard') }}</a>
<a href="{{ route('events.index') }}" class="text-sm text-gray-600 hover:text-gray-900 {{ request()->routeIs('events.*') ? 'font-semibold text-gray-900' : '' }}">{{ __('ui.events') }}</a>
<a href="{{ route('files.index') }}" class="text-sm text-gray-600 hover:text-gray-900 {{ request()->routeIs('files.*') ? 'font-semibold text-gray-900' : '' }}">{{ __('ui.files') }}</a>
@if (auth()->user()->canAccessAdminPanel())
@php $pendingDsgvoCount = \App\Models\User::where('role', 'user')->whereNotNull('dsgvo_consent_file')->whereNull('dsgvo_accepted_at')->count(); @endphp
<a href="{{ route('admin.dashboard') }}" class="text-sm text-red-600 hover:text-red-800 {{ request()->routeIs('admin.*') ? 'font-semibold' : '' }}">
{{ __('ui.admin') }}
@if ($pendingDsgvoCount > 0)
<span class="bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5 ml-1">{{ $pendingDsgvoCount }}</span>
@endif
</a>
@endif
</div>
</div>
{{-- Mobile menu button --}}
<div class="flex items-center sm:hidden">
<button @click="open = !open" class="text-gray-600 hover:text-gray-900">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
<path x-show="open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{{-- Desktop right side --}}
<div class="hidden sm:flex items-center space-x-4 rtl:space-x-reverse">
<a href="{{ route('profile.edit') }}" class="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900">
@if (auth()->user()->getAvatarUrl())
<img src="{{ auth()->user()->getAvatarUrl() }}" alt="{{ auth()->user()->name }}" class="w-7 h-7 rounded-full object-cover">
@else
<div class="w-7 h-7 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 text-xs font-semibold">
{{ auth()->user()->getInitials() }}
</div>
@endif
{{ auth()->user()->name }}
</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-sm text-gray-500 hover:text-gray-700">{{ __('ui.logout') }}</button>
</form>
</div>
</div>
</div>
{{-- Mobile menu --}}
<div x-show="open" x-cloak class="sm:hidden border-t border-gray-200 px-4 py-2 space-y-1">
<a href="{{ route('dashboard') }}" class="block py-2 text-sm text-gray-700">{{ __('ui.dashboard') }}</a>
<a href="{{ route('events.index') }}" class="block py-2 text-sm text-gray-700">{{ __('ui.events') }}</a>
<a href="{{ route('files.index') }}" class="block py-2 text-sm text-gray-700">{{ __('ui.files') }}</a>
@if (auth()->user()->canAccessAdminPanel())
<a href="{{ route('admin.dashboard') }}" class="block py-2 text-sm text-red-600">
{{ __('ui.admin') }}
@if ($pendingDsgvoCount > 0)
<span class="bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5 ml-1">{{ $pendingDsgvoCount }}</span>
@endif
</a>
@endif
<a href="{{ route('profile.edit') }}" class="flex items-center gap-2 py-2 text-sm text-gray-700">
@if (auth()->user()->getAvatarUrl())
<img src="{{ auth()->user()->getAvatarUrl() }}" alt="{{ auth()->user()->name }}" class="w-6 h-6 rounded-full object-cover">
@else
<div class="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 text-xs font-semibold">
{{ auth()->user()->getInitials() }}
</div>
@endif
{{ __('ui.profile') }}
</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="block py-2 text-sm text-gray-500">{{ __('ui.logout') }}</button>
</form>
</div>
</nav>
{{-- DSGVO-Hinweis-Banner (2 Zustände) --}}
@php $dsgvoBannerState = auth()->user()->dsgvoBannerState(); @endphp
@if ($dsgvoBannerState === 'upload_required')
<div class="bg-yellow-50 border-b-2 border-yellow-400">
<div class="max-w-5xl mx-auto px-4 py-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-0.5">
<svg class="w-6 h-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-yellow-800">{{ __('ui.dsgvo_banner_title') }}</h3>
<p class="text-sm text-yellow-700 mt-1">{{ __('ui.dsgvo_banner_text') }}</p>
<a href="{{ route('profile.edit') }}#dsgvo-consent"
class="inline-flex items-center gap-1.5 mt-3 bg-yellow-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-yellow-700 transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
{{ __('ui.dsgvo_banner_action') }}
</a>
</div>
</div>
</div>
</div>
@elseif ($dsgvoBannerState === 'pending_confirmation')
<div class="bg-blue-50 border-b-2 border-blue-400">
<div class="max-w-5xl mx-auto px-4 py-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-0.5">
<svg class="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-semibold text-blue-800">{{ __('ui.dsgvo_banner_pending_title') }}</h3>
<p class="text-sm text-blue-700 mt-1">{{ __('ui.dsgvo_banner_pending_text') }}</p>
</div>
</div>
</div>
</div>
@endif
<main class="flex-1 max-w-5xl mx-auto w-full px-4 py-6">
@if (session('success'))
<x-flash-message type="success" :message="session('success')" />
@endif
@if (session('error'))
<x-flash-message type="error" :message="session('error')" />
@endif
{{ $slot }}
</main>
<footer class="text-center py-4 text-sm text-gray-500 border-t border-gray-200">
@php $slogan = \App\Models\Setting::get('app_slogan'); @endphp
@if ($slogan)
<div class="mb-2 text-xs text-gray-400">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($slogan) !!}</div>
@endif
<a href="/impressum" class="hover:underline">{{ __('ui.footer_impressum') }}</a>
<span class="mx-2">|</span>
<a href="/datenschutz" class="hover:underline">{{ __('ui.footer_privacy') }}</a>
</footer>
@include('components.pwa-install-banner')
@stack('scripts')
{{-- Service Worker --}}
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.catch((err) => { console.error('SW:', err); });
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{ $title ?? \App\Models\Setting::get('app_name', config('app.name')) }}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js" integrity="sha384-9Ax3MmS9AClxJyd5/zafcXXjxmwFhZCdsT6HJoJjarvCaAkJlk5QDzjLJm+Wdx5F" crossorigin="anonymous"></script>
@php $favicon = \App\Models\Setting::get('app_favicon'); @endphp
@if ($favicon)
<link rel="icon" href="{{ asset('storage/' . $favicon) }}">
@else
<link rel="icon" href="{{ asset('favicon.ico') }}">
@endif
{{-- PWA --}}
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1f2937">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="SG Wölfe">
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png">
</head>
<body class="min-h-screen bg-gray-100 flex flex-col">
<main class="flex-1 flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<a href="{{ auth()->check() ? route('dashboard') : route('login') }}">
<img src="/images/logo_sg_woelfe.png" alt="Logo" class="mx-auto h-24 mb-3">
</a>
<h1 class="text-2xl font-bold text-gray-900">{{ \App\Models\Setting::get('app_name', config('app.name')) }}</h1>
@php $slogan = \App\Models\Setting::get('app_slogan'); @endphp
@if ($slogan)
<div class="text-sm text-gray-500 mt-1">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($slogan) !!}</div>
@endif
</div>
@if (session('error'))
<x-flash-message type="error" :message="session('error')" />
@endif
@if (session('success'))
<x-flash-message type="success" :message="session('success')" />
@endif
<div class="bg-white rounded-lg shadow-md p-6">
{{ $slot }}
</div>
</div>
</main>
<footer class="text-center py-4 text-sm text-gray-500">
<div class="mb-2">
<a href="/impressum" class="hover:underline">{{ __('ui.footer_impressum') }}</a>
<span class="mx-2">|</span>
<a href="/datenschutz" class="hover:underline">{{ __('ui.footer_privacy') }}</a>
</div>
@php
$flags = ['de' => "\u{1F1E9}\u{1F1EA}", 'en' => "\u{1F1EC}\u{1F1E7}", 'pl' => "\u{1F1F5}\u{1F1F1}", 'ru' => "\u{1F1F7}\u{1F1FA}", 'ar' => "\u{1F1F8}\u{1F1E6}", 'tr' => "\u{1F1F9}\u{1F1F7}"];
@endphp
<div class="flex justify-center gap-2">
@foreach ($flags as $code => $flag)
<form method="POST" action="{{ route('locale.switch') }}" class="inline">
@csrf
<input type="hidden" name="locale" value="{{ $code }}">
<button type="submit"
class="text-xl leading-none transition-all {{ $code === app()->getLocale() ? 'scale-110' : 'opacity-50 hover:opacity-80' }}"
title="{{ __('ui.locales.' . $code) }}">
{{ $flag }}
</button>
</form>
@endforeach
</div>
</footer>
{{-- Service Worker --}}
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.catch((err) => { console.error('SW:', err); });
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Installation Handball WebApp</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js" integrity="sha384-9Ax3MmS9AClxJyd5/zafcXXjxmwFhZCdsT6HJoJjarvCaAkJlk5QDzjLJm+Wdx5F" crossorigin="anonymous"></script>
<link rel="icon" href="{{ asset('favicon.ico') }}">
</head>
<body class="min-h-screen bg-gray-100 flex flex-col">
<main class="flex-1 flex items-center justify-center px-4 py-12">
<div class="w-full max-w-2xl">
{{-- Logo --}}
<div class="text-center mb-6">
<img src="/images/logo_sg_woelfe.png" alt="Logo" class="mx-auto h-20 mb-3">
<h1 class="text-xl font-bold text-gray-900">Installation</h1>
</div>
{{-- Progress indicator --}}
<div class="flex justify-center items-center mb-8">
@php
$stepLabels = ['Systemcheck', 'Datenbank', 'Einstellungen', 'E-Mail', 'Abschluss'];
@endphp
@for ($i = 1; $i <= 5; $i++)
<div class="flex items-center">
<div class="flex flex-col items-center">
<div class="w-9 h-9 rounded-full flex items-center justify-center text-sm font-semibold
{{ $i < $currentStep ? 'bg-green-500 text-white' : '' }}
{{ $i === $currentStep ? 'bg-blue-600 text-white ring-2 ring-blue-300' : '' }}
{{ $i > $currentStep ? 'bg-gray-200 text-gray-400' : '' }}">
@if ($i < $currentStep)
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
@else
{{ $i }}
@endif
</div>
<span class="text-xs mt-1 {{ $i === $currentStep ? 'text-blue-600 font-medium' : 'text-gray-400' }}">{{ $stepLabels[$i - 1] }}</span>
</div>
@if ($i < 5)
<div class="w-12 sm:w-20 h-0.5 mx-1 mb-5 {{ $i < $currentStep ? 'bg-green-500' : 'bg-gray-200' }}"></div>
@endif
</div>
@endfor
</div>
{{-- Flash messages --}}
@if (session('error'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
{{ session('error') }}
</div>
@endif
@if (session('success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-md text-sm text-green-700">
{{ session('success') }}
</div>
@endif
{{-- Step content --}}
<div class="bg-white rounded-lg shadow-md p-6">
{{ $slot }}
</div>
</div>
</main>
<footer class="text-center py-3 text-xs text-gray-400">
Handball WebApp &mdash; Installation
</footer>
</body>
</html>

View File

@@ -0,0 +1,129 @@
{{-- PWA Install Banner (Android + iOS) --}}
<div id="pwa-install-banner" style="display: none;">
<style>
#pwa-install-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #1f2937;
color: white;
padding: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
z-index: 9999;
box-shadow: 0 -2px 10px rgba(0,0,0,0.15);
}
#pwa-install-banner .pwa-text {
flex: 1;
font-size: 0.9rem;
line-height: 1.4;
}
#pwa-install-banner .pwa-text strong {
display: block;
font-size: 1rem;
margin-bottom: 0.2rem;
}
#pwa-install-banner .pwa-ios-steps {
font-size: 0.82rem;
margin-top: 0.3rem;
color: rgba(255,255,255,0.85);
}
#pwa-install-banner .pwa-ios-steps .pwa-icon {
display: inline-block;
vertical-align: middle;
font-size: 1.05rem;
}
#pwa-install-banner button {
border: none;
padding: 0.6rem 1.2rem;
border-radius: 0.4rem;
font-size: 0.9rem;
cursor: pointer;
white-space: nowrap;
}
#pwa-install-banner .pwa-install-btn {
background: white;
color: #1f2937;
font-weight: 600;
}
#pwa-install-banner .pwa-dismiss-btn {
background: transparent;
color: rgba(255,255,255,0.8);
padding: 0.6rem;
font-size: 1.2rem;
}
</style>
<div class="pwa-text">
<strong id="pwa-title">{{ __('ui.pwa_install_title') }}</strong>
<span id="pwa-desc">{{ __('ui.pwa_install_text') }}</span>
<div id="pwa-ios-steps" class="pwa-ios-steps" style="display: none;">
{!! app(\App\Services\HtmlSanitizerService::class)->sanitize(__('ui.pwa_ios_steps')) !!}
</div>
</div>
<button class="pwa-install-btn" id="pwa-install-btn">{{ __('ui.pwa_install_btn') }}</button>
<button class="pwa-dismiss-btn" id="pwa-dismiss-btn">&times;</button>
</div>
<script>
(function() {
let deferredPrompt;
const banner = document.getElementById('pwa-install-banner');
const installBtn = document.getElementById('pwa-install-btn');
const dismissBtn = document.getElementById('pwa-dismiss-btn');
const iosSteps = document.getElementById('pwa-ios-steps');
const dismissed = localStorage.getItem('pwa-install-dismissed');
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
// Reset nach 30 Tagen
if (dismissed && (Date.now() - parseInt(dismissed)) > 30 * 24 * 60 * 60 * 1000) {
localStorage.removeItem('pwa-install-dismissed');
}
// Bereits installiert → nichts anzeigen
if (isStandalone) return;
// iOS-Erkennung (iPhone, iPad, iPod)
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (isIOS) {
// iOS: Manuellen Hinweis zeigen (kein beforeinstallprompt)
if (!dismissed) {
installBtn.style.display = 'none';
iosSteps.style.display = 'block';
banner.style.display = 'flex';
}
} else {
// Android/Chrome: beforeinstallprompt abfangen
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
if (!dismissed) {
banner.style.display = 'flex';
}
});
}
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
await deferredPrompt.userChoice;
deferredPrompt = null;
banner.style.display = 'none';
});
dismissBtn.addEventListener('click', () => {
banner.style.display = 'none';
localStorage.setItem('pwa-install-dismissed', Date.now());
});
})();
</script>

View File

@@ -0,0 +1,15 @@
@props(['status'])
@php
$colors = match($status->value ?? $status) {
'yes' => 'bg-green-100 text-green-800',
'no' => 'bg-red-100 text-red-800',
'unknown' => 'bg-gray-100 text-gray-600',
default => 'bg-gray-100 text-gray-600',
};
$label = method_exists($status, 'label') ? $status->label() : $status;
@endphp
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $colors }}">
{{ $label }}
</span>

View File

@@ -0,0 +1,13 @@
@props(['event'])
@php
$yes = $event->participants->where('status', \App\Enums\ParticipantStatus::Yes)->count();
$no = $event->participants->where('status', \App\Enums\ParticipantStatus::No)->count();
$open = $event->participants->where('status', \App\Enums\ParticipantStatus::Unknown)->count();
@endphp
<span class="inline-flex items-center gap-1 text-xs font-semibold tabular-nums" title="{{ __('events.confirmations') }}: {{ $yes }} / {{ __('events.rejections') }}: {{ $no }} / {{ __('events.open_responses') }}: {{ $open }}">
<span class="text-green-600">{{ $yes }}</span>
<span class="text-red-500">{{ $no }}</span>
<span class="text-gray-400">{{ $open }}</span>
</span>

View File

@@ -0,0 +1,316 @@
<x-layouts.app :title="__('ui.dashboard')">
<h1 class="text-2xl font-bold mb-6">{{ __('events.hello_user', ['name' => auth()->user()->name]) }}</h1>
{{-- Kalender --}}
<div x-data="calendarApp()" class="mb-8">
{{-- Header: View-Toggle + Navigation --}}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<h2 class="text-lg font-semibold">{{ __('events.calendar') }}</h2>
<div class="flex items-center gap-2">
<button @click="goToToday()" class="px-3 py-1.5 text-xs border border-gray-300 rounded-md hover:bg-gray-50">{{ __('events.today') }}</button>
<div class="flex bg-gray-100 rounded-md p-0.5">
<button @click="view = 'month'" :class="view === 'month' ? 'bg-white shadow text-gray-900' : 'text-gray-500 hover:text-gray-700'" class="px-3 py-1.5 text-xs font-medium rounded-md transition">{{ __('events.month_view') }}</button>
<button @click="view = 'year'" :class="view === 'year' ? 'bg-white shadow text-gray-900' : 'text-gray-500 hover:text-gray-700'" class="px-3 py-1.5 text-xs font-medium rounded-md transition">{{ __('events.year_view') }}</button>
</div>
</div>
</div>
{{-- ===== MONATSANSICHT ===== --}}
<template x-if="view === 'month'">
<div class="bg-white rounded-lg shadow">
{{-- Monats-Navigation --}}
<div class="flex items-center justify-between px-4 py-3 border-b">
<button @click="prevMonth()" class="p-1 hover:bg-gray-100 rounded-md">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</button>
<h3 class="text-base font-semibold text-gray-900" x-text="monthName + ' ' + currentYear"></h3>
<button @click="nextMonth()" class="p-1 hover:bg-gray-100 rounded-md">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</button>
</div>
{{-- Wochentage --}}
<div class="grid grid-cols-7 border-b">
<template x-for="day in weekDayNames" :key="day">
<div class="px-1 py-2 text-center text-xs font-medium text-gray-500 uppercase" x-text="day"></div>
</template>
</div>
{{-- Tage --}}
<div class="grid grid-cols-7">
<template x-for="(day, index) in calendarDays" :key="index">
<div
:class="{
'bg-gray-50 text-gray-400': !day.currentMonth,
'bg-blue-50': day.isToday,
'min-h-[80px] sm:min-h-[100px]': true
}"
class="border-b border-r p-1 text-xs"
>
<div class="flex items-center justify-between mb-0.5">
<span
:class="day.isToday ? 'bg-blue-600 text-white w-6 h-6 flex items-center justify-center rounded-full text-xs font-bold' : 'text-gray-700 px-1'"
x-text="day.dayNum"
></span>
</div>
<div class="space-y-0.5">
<template x-for="evt in eventsForDate(day.dateStr)" :key="evt.id">
<a :href="evt.url" class="flex items-center gap-0.5 truncate rounded px-1 py-0.5 text-[10px] leading-tight font-medium hover:opacity-80" :class="eventBgClass(evt.type)">
<span x-text="evt.time + ' ' + evt.typeLabel"></span>
<span class="ml-auto flex gap-px shrink-0 tabular-nums">
<span class="text-green-700" x-text="evt.tl.y"></span>
<span class="text-red-700" x-text="evt.tl.n"></span>
<span class="opacity-50" x-text="evt.tl.o"></span>
</span>
</a>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
{{-- ===== JAHRESANSICHT ===== --}}
<template x-if="view === 'year'">
<div>
{{-- Jahres-Navigation --}}
<div class="flex items-center justify-center gap-4 mb-4">
<button @click="currentYear--" class="p-1 hover:bg-gray-100 rounded-md">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</button>
<h3 class="text-base font-semibold text-gray-900" x-text="currentYear"></h3>
<button @click="currentYear++" class="p-1 hover:bg-gray-100 rounded-md">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</button>
</div>
{{-- 12 Mini-Monate --}}
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<template x-for="m in 12" :key="m">
<div class="bg-white rounded-lg shadow p-3 cursor-pointer hover:shadow-md transition-shadow" @click="currentMonth = m - 1; view = 'month'">
<h4 class="text-xs font-semibold text-gray-700 mb-2" x-text="getMonthName(m - 1)"></h4>
{{-- Mini-Wochentage --}}
<div class="grid grid-cols-7 gap-px mb-1">
<template x-for="wd in weekDayLetters" :key="wd">
<div class="text-center text-[9px] text-gray-400" x-text="wd"></div>
</template>
</div>
{{-- Mini-Tage --}}
<div class="grid grid-cols-7 gap-px">
<template x-for="(d, i) in miniMonthDays(m - 1)" :key="i">
<div class="flex items-center justify-center h-4 w-full">
<template x-if="d.dayNum">
<span
class="w-3.5 h-3.5 flex items-center justify-center rounded-full text-[8px] leading-none"
:class="d.isToday ? 'bg-blue-600 text-white font-bold' : (d.eventType ? eventDotClass(d.eventType) : 'text-gray-600')"
x-text="d.dayNum"
></span>
</template>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
<script>
function calendarApp() {
const today = new Date();
return {
view: 'month',
currentMonth: today.getMonth(),
currentYear: today.getFullYear(),
events: @js($calendarEvents),
locale: document.documentElement.lang || 'de',
// Index der Events nach Datum für schnellen Zugriff
_eventIndex: null,
get eventIndex() {
if (!this._eventIndex) {
this._eventIndex = {};
this.events.forEach(e => {
if (!this._eventIndex[e.date]) this._eventIndex[e.date] = [];
this._eventIndex[e.date].push(e);
});
}
return this._eventIndex;
},
get monthName() {
return this.getMonthName(this.currentMonth);
},
getMonthName(month) {
return new Date(2024, month).toLocaleDateString(this.locale, { month: 'long' });
},
get weekDayNames() {
const days = [];
// Montag = 1 als erster Tag
for (let i = 1; i <= 7; i++) {
const d = new Date(2024, 0, i); // 2024-01-01 ist Montag
days.push(d.toLocaleDateString(this.locale, { weekday: 'short' }));
}
return days;
},
get weekDayLetters() {
return this.weekDayNames.map(d => d.charAt(0).toUpperCase());
},
get calendarDays() {
const year = this.currentYear;
const month = this.currentMonth;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Wochentag des 1. (0=So, 1=Mo, ..., 6=Sa) → Umrechnung auf Mo=0
let startWeekDay = firstDay.getDay() - 1;
if (startWeekDay < 0) startWeekDay = 6;
const days = [];
// Tage vom Vormonat
const prevMonthLast = new Date(year, month, 0);
for (let i = startWeekDay - 1; i >= 0; i--) {
const dayNum = prevMonthLast.getDate() - i;
const d = new Date(year, month - 1, dayNum);
days.push({
dayNum: dayNum,
dateStr: this.formatDate(d),
currentMonth: false,
isToday: this.isToday(d)
});
}
// Tage des aktuellen Monats
for (let d = 1; d <= lastDay.getDate(); d++) {
const date = new Date(year, month, d);
days.push({
dayNum: d,
dateStr: this.formatDate(date),
currentMonth: true,
isToday: this.isToday(date)
});
}
// Tage des nächsten Monats (Rest auffüllen bis Vielfaches von 7)
const remaining = 7 - (days.length % 7);
if (remaining < 7) {
for (let d = 1; d <= remaining; d++) {
const date = new Date(year, month + 1, d);
days.push({
dayNum: d,
dateStr: this.formatDate(date),
currentMonth: false,
isToday: this.isToday(date)
});
}
}
return days;
},
miniMonthDays(month) {
const year = this.currentYear;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
let startWeekDay = firstDay.getDay() - 1;
if (startWeekDay < 0) startWeekDay = 6;
const days = [];
// Leere Zellen vor dem 1.
for (let i = 0; i < startWeekDay; i++) {
days.push({ dayNum: 0, eventType: null, isToday: false });
}
// Tage
for (let d = 1; d <= lastDay.getDate(); d++) {
const date = new Date(year, month, d);
const dateStr = this.formatDate(date);
const dayEvents = this.eventIndex[dateStr];
days.push({
dayNum: d,
eventType: dayEvents ? dayEvents[0].type : null,
isToday: this.isToday(date)
});
}
return days;
},
eventsForDate(dateStr) {
return this.eventIndex[dateStr] || [];
},
formatDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return y + '-' + m + '-' + d;
},
isToday(date) {
return date.getFullYear() === today.getFullYear()
&& date.getMonth() === today.getMonth()
&& date.getDate() === today.getDate();
},
prevMonth() {
if (this.currentMonth === 0) {
this.currentMonth = 11;
this.currentYear--;
} else {
this.currentMonth--;
}
},
nextMonth() {
if (this.currentMonth === 11) {
this.currentMonth = 0;
this.currentYear++;
} else {
this.currentMonth++;
}
},
goToToday() {
this.currentMonth = today.getMonth();
this.currentYear = today.getFullYear();
},
// CSS-Klassen für Events in der Monatsansicht
eventBgClass(type) {
const map = {
home_game: 'bg-blue-100 text-blue-800',
away_game: 'bg-indigo-100 text-indigo-800',
training: 'bg-green-100 text-green-800',
tournament: 'bg-purple-100 text-purple-800',
meeting: 'bg-yellow-100 text-yellow-800',
other: 'bg-gray-100 text-gray-800'
};
return map[type] || 'bg-gray-100 text-gray-800';
},
// CSS-Klassen für Punkte in der Jahresansicht
eventDotClass(type) {
const map = {
home_game: 'bg-blue-500 text-white',
away_game: 'bg-indigo-500 text-white',
training: 'bg-green-500 text-white',
tournament: 'bg-purple-500 text-white',
meeting: 'bg-yellow-500 text-white',
other: 'bg-gray-400 text-white'
};
return map[type] || 'bg-gray-400 text-white';
}
};
}
</script>
</x-layouts.app>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{ __('ui.error_403_title') }}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gray-100 flex items-center justify-center">
<div class="text-center px-6">
<h1 class="text-6xl font-bold text-gray-300">403</h1>
<h2 class="text-xl font-semibold text-gray-700 mt-4">{{ __('ui.error_403_title') }}</h2>
<p class="text-gray-500 mt-2">{{ __('ui.error_403_text') }}</p>
<a href="{{ url('/dashboard') }}" class="inline-block mt-6 bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 text-sm">{{ __('ui.back_to_dashboard') }}</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{ __('ui.error_404_title') }}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gray-100 flex items-center justify-center">
<div class="text-center px-6">
<h1 class="text-6xl font-bold text-gray-300">404</h1>
<h2 class="text-xl font-semibold text-gray-700 mt-4">{{ __('ui.error_404_title') }}</h2>
<p class="text-gray-500 mt-2">{{ __('ui.error_404_text') }}</p>
<a href="{{ url('/dashboard') }}" class="inline-block mt-6 bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 text-sm">{{ __('ui.back_to_dashboard') }}</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{ __('ui.error_500_title') }}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-gray-100 flex items-center justify-center">
<div class="text-center px-6">
<h1 class="text-6xl font-bold text-gray-300">500</h1>
<h2 class="text-xl font-semibold text-gray-700 mt-4">{{ __('ui.error_500_title') }}</h2>
<p class="text-gray-500 mt-2">{{ __('ui.error_500_text') }}</p>
<a href="{{ url('/dashboard') }}" class="inline-block mt-6 bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 text-sm">{{ __('ui.back_to_dashboard') }}</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<x-layouts.app :title="__('events.title')">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6">
<h1 class="text-2xl font-bold">{{ __('events.title') }}</h1>
</div>
{{-- Filter --}}
<form method="GET" action="{{ route('events.index') }}" class="bg-white rounded-lg shadow p-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-4 gap-3">
<div>
<label for="team_id" class="block text-xs font-medium text-gray-600 mb-1">{{ __('ui.team') }}</label>
<select name="team_id" id="team_id" class="w-full rounded-md border-gray-300 text-sm">
<option value="">{{ __('ui.all_teams') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ request('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
</div>
<div>
<label for="type" class="block text-xs font-medium text-gray-600 mb-1">{{ __('ui.type') }}</label>
<select name="type" id="type" class="w-full rounded-md border-gray-300 text-sm">
<option value="">{{ __('ui.all_types') }}</option>
@foreach (\App\Enums\EventType::cases() as $type)
<option value="{{ $type->value }}" {{ request('type') === $type->value ? 'selected' : '' }}>{{ $type->label() }}</option>
@endforeach
</select>
</div>
<div>
<label for="period" class="block text-xs font-medium text-gray-600 mb-1">{{ __('ui.period') }}</label>
<select name="period" id="period" class="w-full rounded-md border-gray-300 text-sm">
<option value="upcoming" {{ request('period', 'upcoming') === 'upcoming' ? 'selected' : '' }}>{{ __('ui.upcoming') }}</option>
<option value="past" {{ request('period') === 'past' ? 'selected' : '' }}>{{ __('ui.past') }}</option>
</select>
</div>
<div class="flex items-end">
<button type="submit" class="w-full bg-blue-600 text-white rounded-md px-4 py-2 text-sm hover:bg-blue-700">{{ __('ui.filter') }}</button>
</div>
</div>
</form>
{{-- Event-Liste --}}
@if ($events->isEmpty())
<div class="bg-white rounded-lg shadow p-6 text-center text-gray-500">
{{ __('events.no_events') }}
</div>
@else
<div class="space-y-3">
@foreach ($events as $event)
@php
$minStatus = $event->minimumsStatus();
$bgClass = match($minStatus) { true => 'bg-green-50', false => 'bg-red-50', default => 'bg-white' };
@endphp
<a href="{{ route('events.show', $event) }}" class="block {{ $bgClass }} rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<x-event-type-badge :type="$event->type" />
<span class="text-xs text-gray-500">{{ $event->team->name }}</span>
@if ($event->status === \App\Enums\EventStatus::Cancelled)
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">{{ __('events.cancelled_label') }}</span>
@elseif ($event->status === \App\Enums\EventStatus::Draft)
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">{{ __('events.draft_label') }}</span>
@endif
</div>
<h3 class="font-semibold {{ $event->status === \App\Enums\EventStatus::Cancelled ? 'line-through text-gray-400' : 'text-gray-900' }}">
{{ $event->title }}
</h3>
<p class="text-sm text-gray-600">
{{ $event->start_at->translatedFormat(__('ui.date_format')) }} {{ __('ui.clock') }}
@if ($event->end_at)
{{ $event->end_at->format('H:i') }} {{ __('ui.clock') }}
@endif
</p>
@if ($event->location_name)
<p class="text-sm text-gray-500 mt-1">{{ $event->location_name }}</p>
@endif
</div>
<div class="shrink-0">
<x-event-status-boxes :event="$event" />
</div>
</div>
</a>
@endforeach
</div>
<div class="mt-6">
{{ $events->links() }}
</div>
@endif
</x-layouts.app>

View File

@@ -0,0 +1,476 @@
<x-layouts.app :title="$event->title">
{{-- Cancelled Banner --}}
@if ($event->status === \App\Enums\EventStatus::Cancelled)
<div class="bg-red-600 text-white text-center py-3 px-4 rounded-lg mb-6 font-semibold">
{{ __('events.cancelled_banner') }}
</div>
@endif
@if ($event->status === \App\Enums\EventStatus::Draft)
<div class="bg-yellow-500 text-white text-center py-3 px-4 rounded-lg mb-6 font-semibold">
{{ __('events.draft_banner') }}
</div>
@endif
{{-- Header --}}
<div class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex items-start justify-between flex-wrap gap-2">
<div>
<div class="flex items-center gap-2 mb-2">
<x-event-type-badge :type="$event->type" />
<span class="text-xs text-gray-500">{{ $event->team->name }}</span>
</div>
<h1 class="text-2xl font-bold {{ $event->status === \App\Enums\EventStatus::Cancelled ? 'line-through text-gray-400' : 'text-gray-900' }}">
{{ $event->title }}
</h1>
@if ($event->type->isGameType() && $event->opponent)
<p class="text-sm text-gray-600 mt-1">
{{ __('events.vs') }} {{ $event->opponent }}
@if ($event->hasScore())
<span class="font-semibold ml-2">{{ $event->scoreDisplay() }}</span>
@endif
</p>
@endif
</div>
@if (auth()->user()->canAccessAdminPanel())
<a href="{{ route('admin.events.edit', $event) }}" class="text-sm text-blue-600 hover:underline">{{ __('ui.edit') }}</a>
@endif
</div>
<div class="mt-4 space-y-2 text-sm text-gray-700">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span>
{{ $event->start_at->translatedFormat(__('ui.date_format_long')) }} {{ __('ui.clock') }}
@if ($event->end_at)
{{ $event->end_at->format('H:i') }} {{ __('ui.clock') }}
@endif
</span>
</div>
@if ($event->location_name || $event->address_text)
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-gray-400 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<div>
@if ($event->location_name)
<span class="font-medium">{{ $event->location_name }}</span><br>
@endif
@if ($event->address_text)
<span class="text-gray-500">{{ $event->address_text }}</span>
@endif
</div>
</div>
@endif
</div>
{{-- Navigation Button --}}
@if ($event->hasCoordinates())
<div class="mt-4">
<a href="https://www.openstreetmap.org/directions?engine=graphhopper_car&route=&to={{ $event->location_lat }}%2C{{ $event->location_lng }}"
class="hidden sm:inline-flex items-center gap-1 bg-blue-600 text-white text-sm px-4 py-2 rounded-md hover:bg-blue-700"
target="_blank" rel="noopener">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
{{ __('events.plan_route') }}
</a>
<a href="geo:{{ $event->location_lat }},{{ $event->location_lng }}"
class="sm:hidden inline-flex items-center gap-1 bg-blue-600 text-white text-sm px-4 py-2 rounded-md hover:bg-blue-700">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
{{ __('events.start_navigation') }}
</a>
</div>
@endif
</div>
{{-- Karte --}}
@if ($event->hasCoordinates())
<div class="bg-white rounded-lg shadow mb-6 overflow-hidden relative z-0">
<div id="map" class="h-64 w-full"></div>
</div>
@endif
{{-- Beschreibung --}}
@if ($event->description_html)
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-3">{{ __('events.description') }}</h2>
<div class="prose prose-sm max-w-none text-gray-700">
{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($event->description_html) !!}
</div>
</div>
@endif
{{-- Dateien --}}
@if ($event->files->isNotEmpty())
<div id="files" class="bg-white rounded-lg shadow p-6 mb-6 overflow-hidden">
<h2 class="text-lg font-semibold mb-3">{{ __('events.files') }}</h2>
<div class="grid gap-2" x-data>
@foreach ($event->files->sortBy('category.name') as $file)
<div @click="$dispatch('open-file-preview', @js($file->previewData()))"
class="flex items-center gap-3 border border-gray-100 rounded-md px-3 sm:px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors min-w-0">
@if ($file->isImage())
<img src="{{ route('files.preview', $file) }}" alt="" class="flex-shrink-0 w-9 h-9 rounded-lg object-cover bg-gray-100" loading="lazy">
@elseif ($file->isPdf())
<div class="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center bg-red-100 text-red-600 font-bold text-xs">
PDF
</div>
@else
<div class="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center
{{ match($file->iconType()) {
'word' => 'bg-blue-100 text-blue-600',
'excel' => 'bg-green-100 text-green-600',
default => 'bg-gray-100 text-gray-600',
} }}">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
</div>
@endif
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">{{ $file->original_name }}</p>
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-gray-500 mt-0.5">
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">{{ $file->category->name }}</span>
<span>{{ $file->humanSize() }}</span>
</div>
</div>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</div>
@endforeach
</div>
</div>
<x-file-preview-modal />
@endif
{{-- Teilnehmer --}}
<div id="participants" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-3">{{ __('events.participants') }}</h2>
@php
$yesCount = $event->participants->where('status', \App\Enums\ParticipantStatus::Yes)->count();
$noCount = $event->participants->where('status', \App\Enums\ParticipantStatus::No)->count();
$openCount = $event->participants->where('status', \App\Enums\ParticipantStatus::Unknown)->count();
@endphp
<div class="flex gap-4 text-sm mb-4">
<span class="text-green-700 font-medium">{{ $yesCount }} {{ __('events.confirmations') }}</span>
<span class="text-red-700 font-medium">{{ $noCount }} {{ __('events.rejections') }}</span>
<span class="text-gray-500">{{ $openCount }} {{ __('events.open_responses') }}</span>
</div>
<div class="divide-y divide-gray-100">
@if ($event->type === \App\Enums\EventType::Meeting)
{{-- Besprechung: User-basierte Teilnehmer --}}
@foreach ($event->participants->sortBy(fn($p) => $p->user->name ?? '') as $participant)
<div class="py-2 flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-2">
<img src="{{ $participant->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
<span class="text-sm font-medium text-gray-900">{{ $participant->user->name ?? '' }}</span>
</div>
@if (!auth()->user()->isDsgvoRestricted())
@if ($participant->user_id === auth()->id() || auth()->user()->canAccessAdminPanel())
@if ($event->status !== \App\Enums\EventStatus::Cancelled)
<form method="POST" action="{{ route('participants.update', $event) }}" class="flex gap-1">
@csrf
<input type="hidden" name="user_id" value="{{ $participant->user_id }}">
<button type="submit" name="status" value="yes"
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::Yes ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100' }}">
{{ __('ui.yes') }}
</button>
<button type="submit" name="status" value="no"
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::No ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100' }}">
{{ __('ui.no') }}
</button>
<button type="submit" name="status" value="unknown"
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::Unknown ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }}">
{{ __('ui.open') }}
</button>
</form>
@endif
@endif
@endif
</div>
@endforeach
@else
{{-- Reguläre Events: Spieler-basierte Teilnehmer --}}
@foreach ($event->participants->sortBy('player.last_name') as $participant)
<div class="py-2 flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-2">
<img src="{{ $participant->player->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
<span class="text-sm font-medium text-gray-900">{{ $participant->player->full_name }}</span>
</div>
@if (!auth()->user()->isDsgvoRestricted())
@if ($userChildIds->contains($participant->player_id) || auth()->user()->canAccessAdminPanel())
@if ($event->status !== \App\Enums\EventStatus::Cancelled)
<form method="POST" action="{{ route('participants.update', $event) }}" class="flex gap-1">
@csrf
<input type="hidden" name="player_id" value="{{ $participant->player_id }}">
<button type="submit" name="status" value="yes"
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::Yes ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100' }}">
{{ __('ui.yes') }}
</button>
<button type="submit" name="status" value="no"
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::No ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100' }}">
{{ __('ui.no') }}
</button>
<button type="submit" name="status" value="unknown"
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::Unknown ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }}">
{{ __('ui.open') }}
</button>
</form>
@endif
@endif
@endif
</div>
@endforeach
@endif
</div>
</div>
{{-- Catering --}}
@if ($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>
@if ($event->status !== \App\Enums\EventStatus::Cancelled && !auth()->user()->isDsgvoRestricted())
<form method="POST" action="{{ route('catering.update', $event) }}" class="mb-4">
@csrf
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex gap-1">
<button type="submit" name="status" value="yes"
class="px-3 py-1.5 text-sm rounded-md {{ $myCatering && $myCatering->status === \App\Enums\CateringStatus::Yes ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100' }}">
{{ __('events.bring_something') }}
</button>
<button type="submit" name="status" value="no"
class="px-3 py-1.5 text-sm rounded-md {{ $myCatering && $myCatering->status === \App\Enums\CateringStatus::No ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100' }}">
{{ __('events.bring_nothing') }}
</button>
</div>
<input type="text" name="note" placeholder="{{ __('events.catering_note_placeholder') }}" value="{{ $myCatering?->note }}"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</form>
@elseif (auth()->user()->isDsgvoRestricted())
<p class="text-sm text-orange-600 mb-4">{{ __('ui.dsgvo_restricted_hint') }}</p>
@endif
@php
$cateringsWithContribution = $event->caterings->where('status', \App\Enums\CateringStatus::Yes);
$cateringsWithdrawn = (auth()->user()->canAccessAdminPanel() && \App\Models\Setting::isFeatureVisibleFor('catering_history', auth()->user()))
? $event->caterings->where('status', \App\Enums\CateringStatus::No)
: collect();
@endphp
@if ($cateringsWithContribution->isNotEmpty() || $cateringsWithdrawn->isNotEmpty())
<div class="divide-y divide-gray-100">
@foreach ($cateringsWithContribution as $catering)
<div class="py-2 text-sm flex items-center gap-2">
<img src="{{ $catering->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
<span class="font-medium text-gray-900">{{ $catering->user->name }}</span>
@if ($catering->note)
<span class="text-gray-600"> {{ $catering->note }}</span>
@endif
</div>
@endforeach
@foreach ($cateringsWithdrawn as $catering)
@php
$userHistory = $cateringHistory->filter(
fn ($log) => ($log->properties['new']['user_id'] ?? null) == $catering->user_id
);
@endphp
@if ($userHistory->isNotEmpty())
{{-- Chronologische Verlaufseinträge --}}
@foreach ($userHistory as $log)
@php $newStatus = $log->properties['new']['status'] ?? 'unknown'; @endphp
<div class="py-2 text-sm flex items-center justify-between gap-2 opacity-40">
<div class="flex items-center gap-2">
<img src="{{ $catering->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
<span class="font-medium text-gray-400 {{ $newStatus === 'no' ? 'line-through' : '' }}">{{ $catering->user->name }}</span>
<span class="text-xs text-gray-400">({{ $newStatus === 'yes' ? __('events.signed_up') : __('events.withdrawn') }})</span>
</div>
@if (auth()->user()->isStaff())
<span class="text-xs text-gray-400 whitespace-nowrap">{{ $log->created_at->format('d.m.Y, H:i') }}</span>
@endif
</div>
@endforeach
@else
{{-- Fallback ohne Verlaufsdaten --}}
<div class="py-2 text-sm flex items-center justify-between gap-2 opacity-40">
<div class="flex items-center gap-2">
<img src="{{ $catering->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
<span class="font-medium text-gray-400 line-through">{{ $catering->user->name }}</span>
<span class="text-xs text-gray-400">({{ __('events.withdrawn') }})</span>
</div>
@if (auth()->user()->isStaff())
<span class="text-xs text-gray-400 whitespace-nowrap">{{ $catering->updated_at->format('d.m.Y, H:i') }}</span>
@endif
</div>
@endif
@endforeach
</div>
@else
<p class="text-sm text-gray-500">{{ __('events.no_catering_yet') }}</p>
@endif
</div>
@endif
{{-- Zeitnehmer --}}
@if ($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>
@if ($event->status !== \App\Enums\EventStatus::Cancelled && !auth()->user()->isDsgvoRestricted())
<form method="POST" action="{{ route('timekeeper.update', $event) }}" class="mb-4">
@csrf
<div class="flex gap-1">
<button type="submit" name="status" value="yes"
class="px-3 py-1.5 text-sm rounded-md {{ $myTimekeeper && $myTimekeeper->status === \App\Enums\CateringStatus::Yes ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100' }}">
{{ __('events.timekeeper_yes') }}
</button>
<button type="submit" name="status" value="no"
class="px-3 py-1.5 text-sm rounded-md {{ $myTimekeeper && $myTimekeeper->status === \App\Enums\CateringStatus::No ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100' }}">
{{ __('events.timekeeper_no') }}
</button>
</div>
</form>
@elseif (auth()->user()->isDsgvoRestricted())
<p class="text-sm text-orange-600 mb-4">{{ __('ui.dsgvo_restricted_hint') }}</p>
@endif
@php
$timekeepersYes = $event->timekeepers->where('status', \App\Enums\CateringStatus::Yes);
$timekeepersWithdrawn = (auth()->user()->canAccessAdminPanel() && \App\Models\Setting::isFeatureVisibleFor('catering_history', auth()->user()))
? $event->timekeepers->where('status', \App\Enums\CateringStatus::No)
: collect();
@endphp
@if ($timekeepersYes->isNotEmpty() || $timekeepersWithdrawn->isNotEmpty())
<div class="divide-y divide-gray-100">
@foreach ($timekeepersYes as $tk)
<div class="py-2 text-sm flex items-center gap-2">
<img src="{{ $tk->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
<span class="font-medium text-gray-900">{{ $tk->user->name }}</span>
</div>
@endforeach
@foreach ($timekeepersWithdrawn as $tk)
@php
$tkHistory = $timekeeperHistory->filter(
fn ($log) => ($log->properties['new']['user_id'] ?? null) == $tk->user_id
);
@endphp
@if ($tkHistory->isNotEmpty())
@foreach ($tkHistory as $log)
@php $newStatus = $log->properties['new']['status'] ?? 'unknown'; @endphp
<div class="py-2 text-sm flex items-center justify-between gap-2 opacity-40">
<div class="flex items-center gap-2">
<img src="{{ $tk->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
<span class="font-medium text-gray-400 {{ $newStatus === 'no' ? 'line-through' : '' }}">{{ $tk->user->name }}</span>
<span class="text-xs text-gray-400">({{ $newStatus === 'yes' ? __('events.signed_up') : __('events.withdrawn') }})</span>
</div>
@if (auth()->user()->isStaff())
<span class="text-xs text-gray-400 whitespace-nowrap">{{ $log->created_at->format('d.m.Y, H:i') }}</span>
@endif
</div>
@endforeach
@else
<div class="py-2 text-sm flex items-center justify-between gap-2 opacity-40">
<div class="flex items-center gap-2">
<img src="{{ $tk->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
<span class="font-medium text-gray-400 line-through">{{ $tk->user->name }}</span>
<span class="text-xs text-gray-400">({{ __('events.withdrawn') }})</span>
</div>
@if (auth()->user()->isStaff())
<span class="text-xs text-gray-400 whitespace-nowrap">{{ $tk->updated_at->format('d.m.Y, H:i') }}</span>
@endif
</div>
@endif
@endforeach
</div>
@else
<p class="text-sm text-gray-500">{{ __('events.no_timekeeper_yet') }}</p>
@endif
</div>
@endif
{{-- Kommentare --}}
<div id="comments" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-3">{{ __('events.comments') }}</h2>
@if ($event->status !== \App\Enums\EventStatus::Cancelled && !auth()->user()->isDsgvoRestricted())
<form method="POST" action="{{ route('comments.store', $event) }}" class="mb-4">
@csrf
<div class="flex gap-2">
<input type="text" name="body" placeholder="{{ __('events.comment_placeholder') }}" required
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" maxlength="1000">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md text-sm hover:bg-blue-700">{{ __('ui.send') }}</button>
</div>
@error('body')
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
@enderror
</form>
@elseif (auth()->user()->isDsgvoRestricted())
<p class="text-sm text-orange-600 mb-4">{{ __('ui.dsgvo_restricted_hint') }}</p>
@endif
@if ($event->comments->isEmpty())
<p class="text-sm text-gray-500">{{ __('events.no_comments') }}</p>
@else
<div class="space-y-3">
@foreach ($event->comments->sortByDesc('created_at') as $comment)
@if ($comment->isDeleted() && !auth()->user()->isStaff())
@continue
@endif
<div class="flex justify-between items-start gap-2 {{ $comment->isDeleted() ? 'opacity-50' : '' }}">
<div class="flex gap-2">
<img src="{{ $comment->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0 mt-0.5">
<div>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900">{{ $comment->user->name }}</span>
<span class="text-xs text-gray-400">{{ $comment->created_at->diffForHumans() }}</span>
</div>
@if ($comment->isDeleted())
<p class="text-sm text-gray-400 italic">{{ $comment->body }} <span class="text-xs">({{ __('events.deleted_label') }})</span></p>
@else
<p class="text-sm text-gray-700 mt-0.5">{{ $comment->body }}</p>
@endif
</div>
</div>
@if (!$comment->isDeleted() && auth()->user()->isStaff())
<form method="POST" action="{{ route('admin.comments.destroy', $comment) }}" onsubmit="return confirm(@js(__('events.confirm_delete_comment')))">
@csrf
@method('DELETE')
<button type="submit" class="text-xs text-red-500 hover:text-red-700">{{ __('ui.delete') }}</button>
</form>
@endif
</div>
@endforeach
</div>
@endif
</div>
<div class="mb-6">
<a href="{{ route('events.index') }}" class="text-sm text-blue-600 hover:underline">&larr; {{ __('events.back_to_list') }}</a>
</div>
{{-- Leaflet.js Karte --}}
@if ($event->hasCoordinates())
@push('styles')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
@endpush
@push('scripts')
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const map = L.map('map').setView([@js($event->location_lat), @js($event->location_lng)], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
const popupText = document.createElement('span');
popupText.textContent = @json($event->location_name ?? $event->title);
L.marker([@js($event->location_lat), @js($event->location_lng)])
.addTo(map)
.bindPopup(popupText)
.openPopup();
});
</script>
@endpush
@endif
</x-layouts.app>

View File

@@ -0,0 +1,71 @@
<x-layouts.app :title="__('ui.files')">
<h1 class="text-2xl font-bold mb-6">{{ __('ui.files') }}</h1>
{{-- Kategorie-Tabs --}}
<div class="flex flex-wrap gap-2 mb-6">
<a href="{{ route('files.index') }}"
class="px-3 py-1.5 rounded-md text-sm font-medium {{ !$activeCategory ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
{{ __('ui.all') }} ({{ $categories->sum('files_count') }})
</a>
@foreach ($categories as $category)
<a href="{{ route('files.index', ['category' => $category->slug]) }}"
class="px-3 py-1.5 rounded-md text-sm font-medium {{ $activeCategory === $category->slug ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
{{ $category->name }} ({{ $category->files_count }})
</a>
@endforeach
</div>
@if ($files->isEmpty())
<div class="bg-white rounded-lg shadow p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m6.75 12H9.75m3 0h3m-1.5-3H12m-3 3V18m0-3h.008v.008H9v-.008zm0 3h.008v.008H9V18z" />
</svg>
<p class="mt-3 text-sm text-gray-500">{{ $activeCategory ? __('admin.no_files_yet') : __('admin.no_files_at_all') }}</p>
</div>
@else
<div class="grid gap-3" x-data>
@foreach ($files as $file)
<div @click="$dispatch('open-file-preview', @js($file->previewData()))"
class="bg-white rounded-lg shadow px-4 sm:px-5 py-4 flex items-center gap-3 sm:gap-4 hover:bg-gray-50 cursor-pointer transition-colors min-w-0">
{{-- Thumbnail oder Icon --}}
@if ($file->isImage())
<img src="{{ route('files.preview', $file) }}" alt="" class="flex-shrink-0 w-10 h-10 rounded-lg object-cover bg-gray-100" loading="lazy">
@else
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center
{{ match($file->iconType()) {
'pdf' => 'bg-red-100 text-red-600',
'word' => 'bg-blue-100 text-blue-600',
'excel' => 'bg-green-100 text-green-600',
default => 'bg-gray-100 text-gray-600',
} }}">
@if ($file->iconType() === 'pdf')
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
@elseif ($file->iconType() === 'word')
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM9 13h6v1H9v-1zm0 2h6v1H9v-1zm0 2h4v1H9v-1z"/></svg>
@elseif ($file->iconType() === 'excel')
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM9 13h2v2H9v-2zm0 3h2v2H9v-2zm3-3h2v2h-2v-2zm0 3h2v2h-2v-2z"/></svg>
@else
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
@endif
</div>
@endif
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">{{ $file->original_name }}</p>
<div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-gray-500 mt-0.5">
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">{{ $file->category->name }}</span>
<span>{{ $file->humanSize() }}</span>
<span class="hidden sm:inline">{{ $file->created_at->translatedFormat(__('ui.date_format_short')) }}</span>
</div>
</div>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</div>
@endforeach
</div>
<div class="mt-4">{{ $files->links() }}</div>
@endif
<x-file-preview-modal />
</x-layouts.app>

View File

@@ -0,0 +1,86 @@
<x-layouts.installer :currentStep="3">
<h2 class="text-lg font-semibold text-gray-900 mb-4">App-Einstellungen</h2>
<p class="text-sm text-gray-600 mb-4">Gib den Namen deines Vereins und die Administrator-Zugangsdaten ein.</p>
<form method="POST" action="{{ route('install.app.store') }}">
@csrf
{{-- App settings --}}
<div class="space-y-4 mb-6">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Verein</h3>
<div>
<label for="app_name" class="block text-sm font-medium text-gray-700 mb-1">Vereinsname / App-Name *</label>
<input type="text" name="app_name" id="app_name" value="{{ old('app_name', session('installer.app_name', '')) }}"
required maxlength="100" placeholder="z.B. SG Musterstadt Handball"
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('app_name') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="app_slogan" class="block text-sm font-medium text-gray-700 mb-1">Slogan</label>
<input type="text" name="app_slogan" id="app_slogan" value="{{ old('app_slogan', session('installer.app_slogan', '')) }}"
maxlength="255" placeholder="z.B. Gemeinsam stark — auf und neben dem Spielfeld"
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">
<p class="mt-1 text-xs text-gray-400">Optional. Wird auf der Login-Seite und im Footer angezeigt.</p>
@error('app_slogan') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="app_url" class="block text-sm font-medium text-gray-700 mb-1">App-URL *</label>
<input type="url" name="app_url" id="app_url" value="{{ old('app_url', session('installer.app_url', request()->getSchemeAndHttpHost())) }}"
required placeholder="https://handball.example.com"
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">
<p class="mt-1 text-xs text-gray-400">Die URL, unter der die App erreichbar ist.</p>
@error('app_url') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
</div>
{{-- Admin credentials --}}
<div class="space-y-4 border-t border-gray-200 pt-5">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Administrator-Konto</h3>
<div>
<label for="admin_name" class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input type="text" name="admin_name" id="admin_name" value="{{ old('admin_name', session('installer.admin_name', '')) }}"
required maxlength="255" placeholder="Vor- und Nachname"
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('admin_name') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="admin_email" class="block text-sm font-medium text-gray-700 mb-1">E-Mail *</label>
<input type="email" name="admin_email" id="admin_email" value="{{ old('admin_email', session('installer.admin_email', '')) }}"
required maxlength="255" placeholder="admin@example.com"
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('admin_email') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="admin_password" class="block text-sm font-medium text-gray-700 mb-1">Passwort *</label>
<input type="password" name="admin_password" id="admin_password"
required minlength="8" placeholder="Mindestens 8 Zeichen"
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('admin_password') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="admin_password_confirmation" class="block text-sm font-medium text-gray-700 mb-1">Passwort bestätigen *</label>
<input type="password" name="admin_password_confirmation" id="admin_password_confirmation"
required minlength="8" placeholder="Passwort wiederholen"
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 class="mt-6 flex justify-between items-center">
<a href="{{ route('install.database') }}"
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition">
Zurück
</a>
<button type="submit"
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
Weiter
</button>
</div>
</form>
</x-layouts.installer>

View File

@@ -0,0 +1,85 @@
<x-layouts.installer :currentStep="2">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Datenbank einrichten</h2>
<p class="text-sm text-gray-600 mb-4">Wähle den Datenbanktyp und gib die Verbindungsdaten ein.</p>
<form method="POST" action="{{ route('install.database.store') }}" x-data="{ driver: @js(old('db_driver', $dbDriver)), submitting: false }" @submit="submitting = true">
@csrf
{{-- Driver selection --}}
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 mb-2">Datenbanktyp</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="db_driver" value="sqlite" x-model="driver"
class="text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-800">SQLite</span>
<span class="text-xs text-gray-400">(Empfohlen für Einzelbetrieb)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="db_driver" value="mysql" x-model="driver"
class="text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-800">MySQL</span>
</label>
</div>
</div>
{{-- SQLite info --}}
<div x-show="driver === 'sqlite'" x-cloak class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md text-sm text-blue-700">
SQLite speichert alle Daten in einer einzelnen Datei. Ideal für kleine bis mittlere Installationen.
Es werden keine weiteren Angaben benötigt.
</div>
{{-- MySQL fields --}}
<div x-show="driver === 'mysql'" x-cloak class="space-y-4 mb-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="db_host" class="block text-sm font-medium text-gray-700 mb-1">Host</label>
<input type="text" name="db_host" id="db_host" value="{{ old('db_host', '127.0.0.1') }}"
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('db_host') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="db_port" class="block text-sm font-medium text-gray-700 mb-1">Port</label>
<input type="number" name="db_port" id="db_port" value="{{ old('db_port', '3306') }}"
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('db_port') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
</div>
<div>
<label for="db_database" class="block text-sm font-medium text-gray-700 mb-1">Datenbankname</label>
<input type="text" name="db_database" id="db_database" value="{{ old('db_database') }}"
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('db_database') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="db_username" class="block text-sm font-medium text-gray-700 mb-1">Benutzername</label>
<input type="text" name="db_username" id="db_username" value="{{ old('db_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('db_username') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="db_password" class="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
<input type="password" name="db_password" id="db_password" value="{{ old('db_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('db_password') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
</div>
<div class="mt-6 flex justify-between items-center">
<a href="{{ route('install.requirements') }}"
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition">
Zurück
</a>
<button type="submit" :disabled="submitting"
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-wait flex items-center gap-2">
<template x-if="submitting">
<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="submitting ? 'Migrationen werden ausgeführt...' : 'Datenbank einrichten'"></span>
</button>
</div>
</form>
</x-layouts.installer>

View File

@@ -0,0 +1,154 @@
<x-layouts.installer :currentStep="5">
@if ($installed ?? false)
{{-- ══════ SUCCESS PAGE ══════ --}}
<div class="text-center mb-4">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900">Installation erfolgreich!</h2>
<p class="text-sm text-gray-600 mt-1">Die Handball WebApp ist einsatzbereit.</p>
</div>
{{-- Credentials table --}}
<div class="bg-gray-50 rounded-md border border-gray-200 p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Zugangsdaten</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-1.5 px-2 font-medium text-gray-600">Rolle</th>
<th class="text-left py-1.5 px-2 font-medium text-gray-600">E-Mail</th>
<th class="text-left py-1.5 px-2 font-medium text-gray-600">Passwort</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-100 bg-blue-50">
<td class="py-1.5 px-2 font-medium text-gray-800">Administrator</td>
<td class="py-1.5 px-2 text-gray-700">{{ $adminEmail }}</td>
<td class="py-1.5 px-2 text-gray-500 italic">Dein gewähltes Passwort</td>
</tr>
@if ($installDemo ?? false)
<tr class="border-b border-gray-100">
<td class="py-1.5 px-2 font-medium text-gray-800">Trainer</td>
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">trainer@handball.local</td>
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">trainer1234</td>
</tr>
<tr class="border-b border-gray-100">
<td class="py-1.5 px-2 font-medium text-gray-800">Elternvertretung</td>
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">elternvertretung@handball.local</td>
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">eltern1234</td>
</tr>
<tr class="border-b border-gray-100">
<td class="py-1.5 px-2 text-gray-800">Eltern (Beispiel)</td>
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">mary.parker@handball.local</td>
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">eltern1234</td>
</tr>
<tr>
<td class="py-1.5 px-2 text-gray-800">Eltern (Beispiel)</td>
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">tony.stark@handball.local</td>
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">eltern1234</td>
</tr>
@endif
</tbody>
</table>
</div>
</div>
@if ($installDemo ?? false)
<p class="text-xs text-gray-500 mb-4">
Die Beispieldaten umfassen ein Demo-Team mit 27 Spielern, 35 Eltern, 8 Events und weitere
Testdaten. Alle Demo-Eltern nutzen das Passwort <code class="bg-gray-100 px-1 rounded">eltern1234</code>.
</p>
@endif
<div class="text-center">
<a href="/login"
class="inline-block px-6 py-2.5 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 transition">
Zum Login
</a>
</div>
@else
{{-- ══════ FINALIZE FORM ══════ --}}
<h2 class="text-lg font-semibold text-gray-900 mb-4">Installation abschließen</h2>
{{-- Summary --}}
<div class="bg-gray-50 rounded-md border border-gray-200 p-4 mb-5">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Zusammenfassung</h3>
<dl class="grid grid-cols-2 gap-y-2 gap-x-4 text-sm">
<dt class="text-gray-500">App-Name:</dt>
<dd class="text-gray-800 font-medium">{{ $appName }}</dd>
<dt class="text-gray-500">Administrator:</dt>
<dd class="text-gray-800">{{ $adminName }} ({{ $adminEmail }})</dd>
<dt class="text-gray-500">Datenbank:</dt>
<dd class="text-gray-800">{{ $dbDriver === 'mysql' ? 'MySQL' : 'SQLite' }}</dd>
</dl>
</div>
<form method="POST" action="{{ route('install.finalize.store') }}" x-data="{ submitting: false }" @submit="submitting = true">
@csrf
{{-- Demo data checkbox --}}
<div class="mb-5 p-4 bg-blue-50 border border-blue-200 rounded-md">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" name="install_demo" value="1" checked
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<div>
<span class="text-sm font-medium text-gray-800">Beispieldaten installieren</span>
<p class="text-xs text-gray-600 mt-1">
Erstellt ein Demo-Team mit 27 Spielern, 35 Eltern-Accounts, 8 Events
(Training, Heimspiel, Auswärtsspiel, Turnier, Besprechung, etc.),
Catering-Einträge, Zeitnehmer, Kommentare und weitere Testdaten.
Ideal, um alle Funktionen der App kennenzulernen.
</p>
</div>
</label>
</div>
{{-- Registration opt-in --}}
<div class="mb-5 p-4 bg-green-50 border border-green-200 rounded-md">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" name="register_installation" value="1" checked
class="mt-0.5 rounded border-gray-300 text-green-600 focus:ring-green-500">
<div>
<span class="text-sm font-medium text-gray-800">Installation registrieren</span>
<p class="text-xs text-gray-600 mt-1">
Registriert diese Installation beim Entwickler. Es werden nur technische Daten
übermittelt (App-Name, URL, PHP-Version, App-Version). Keine persönlichen Daten.
Ermöglicht Update-Benachrichtigungen und Support-Anfragen.
</p>
</div>
</label>
</div>
{{-- License key (optional) --}}
<div class="mb-5">
<label for="license_key" class="block text-sm font-medium text-gray-700 mb-1">
Lizenzschlüssel <span class="text-gray-400 font-normal">(optional)</span>
</label>
<input type="text" name="license_key" id="license_key"
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">
<p class="text-xs text-gray-400 mt-1">Falls vorhanden. Kann auch später in den Einstellungen eingetragen werden.</p>
</div>
<div class="flex justify-between items-center">
<a href="{{ route('install.mail') }}"
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition">
Zurück
</a>
<button type="submit" :disabled="submitting"
class="px-5 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-wait flex items-center gap-2">
<template x-if="submitting">
<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="submitting ? 'Installation läuft...' : 'Installation abschließen'"></span>
</button>
</div>
</form>
@endif
</x-layouts.installer>

View File

@@ -0,0 +1,237 @@
<x-layouts.installer :currentStep="4">
<h2 class="text-lg font-semibold text-gray-900 mb-2">E-Mail-Konfiguration</h2>
<p class="text-sm text-gray-600 mb-5">
Damit Funktionen wie "Passwort vergessen" funktionieren, muss ein E-Mail-Versand konfiguriert werden.
Du kannst dies auch spaeter in den Admin-Einstellungen aendern.
</p>
<form method="POST" action="{{ route('install.mail.store') }}"
x-data="{
mailMode: '{{ old('mail_mode', 'smtp') }}',
editor: null,
testing: false,
testResult: false,
testSuccess: false,
testMessage: '',
syncEditor() {
if (this.editor) {
document.getElementById('input-pw-reset-de').value = this.editor.root.innerHTML;
}
},
async testSmtp() {
this.testing = true;
this.testResult = false;
try {
const res = await fetch('{{ route("install.test-mail") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
body: JSON.stringify({
mail_host: document.getElementById('mail_host').value,
mail_port: document.getElementById('mail_port').value,
mail_username: document.getElementById('mail_username').value,
mail_password: document.getElementById('mail_password').value,
mail_encryption: document.getElementById('mail_encryption').value,
}),
});
const data = await res.json();
this.testSuccess = data.success;
this.testMessage = data.message;
} catch (e) {
this.testSuccess = false;
this.testMessage = 'Netzwerkfehler: ' + e.message;
}
this.testing = false;
this.testResult = true;
}
}"
@submit="syncEditor()">
@csrf
{{-- Mail-Modus --}}
<div class="mb-5">
<label class="block text-sm font-semibold text-gray-700 mb-3">Versandmethode</label>
<div class="space-y-2">
<label class="flex items-start gap-3 p-3 border rounded-md cursor-pointer transition"
:class="mailMode === 'smtp' ? 'border-blue-400 bg-blue-50' : 'border-gray-200 hover:bg-gray-50'">
<input type="radio" name="mail_mode" value="smtp" x-model="mailMode"
class="mt-0.5 text-blue-600 focus:ring-blue-500">
<div>
<span class="text-sm font-medium text-gray-800">SMTP-Server</span>
<p class="text-xs text-gray-500 mt-0.5">E-Mails werden ueber einen SMTP-Server versendet (empfohlen).</p>
</div>
</label>
<label class="flex items-start gap-3 p-3 border rounded-md cursor-pointer transition"
:class="mailMode === 'log' ? 'border-blue-400 bg-blue-50' : 'border-gray-200 hover:bg-gray-50'">
<input type="radio" name="mail_mode" value="log" x-model="mailMode"
class="mt-0.5 text-blue-600 focus:ring-blue-500">
<div>
<span class="text-sm font-medium text-gray-800">Kein Versand (Log)</span>
<p class="text-xs text-gray-500 mt-0.5">E-Mails werden nur ins Log geschrieben. Passwort-Reset funktioniert dann nicht.</p>
</div>
</label>
</div>
</div>
{{-- SMTP-Felder --}}
<div x-show="mailMode === 'smtp'" x-cloak class="space-y-4 mb-5 p-4 bg-gray-50 border border-gray-200 rounded-md">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="mail_host" class="block text-sm font-medium text-gray-700 mb-1">SMTP-Host</label>
<input type="text" name="mail_host" id="mail_host" value="{{ old('mail_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 for="mail_port" class="block text-sm font-medium text-gray-700 mb-1">Port</label>
<input type="number" name="mail_port" id="mail_port" value="{{ old('mail_port', '587') }}"
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-2 gap-4">
<div>
<label for="mail_username" class="block text-sm font-medium text-gray-700 mb-1">Benutzername</label>
<input type="text" name="mail_username" id="mail_username" value="{{ old('mail_username') }}"
placeholder="z.B. noreply@deinverein.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_username') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="mail_password" class="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
<input type="password" name="mail_password" id="mail_password" value="{{ old('mail_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-2 gap-4">
<div>
<label for="mail_from_address" class="block text-sm font-medium text-gray-700 mb-1">Absender-Adresse</label>
<input type="email" name="mail_from_address" id="mail_from_address" value="{{ old('mail_from_address') }}"
placeholder="z.B. noreply@deinverein.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_from_address') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="mail_from_name" class="block text-sm font-medium text-gray-700 mb-1">Absender-Name <span class="text-gray-400 font-normal">(optional)</span></label>
<input type="text" name="mail_from_name" id="mail_from_name" value="{{ old('mail_from_name') }}"
placeholder="z.B. SG Woelfe Handball"
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 for="mail_encryption" class="block text-sm font-medium text-gray-700 mb-1">Verschluesselung</label>
<select name="mail_encryption" id="mail_encryption"
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">
<option value="tls" {{ old('mail_encryption', 'tls') === 'tls' ? 'selected' : '' }}>TLS (Port 587, empfohlen)</option>
<option value="ssl" {{ old('mail_encryption') === 'ssl' ? 'selected' : '' }}>SSL (Port 465)</option>
<option value="none" {{ old('mail_encryption') === 'none' ? 'selected' : '' }}>Keine</option>
</select>
</div>
{{-- SMTP-Test --}}
<div class="pt-2 border-t border-gray-200">
<button type="button" @click="testSmtp()"
:disabled="testing"
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="testing">
<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="testing ? 'Teste Verbindung...' : 'Verbindung testen'"></span>
</button>
<p x-show="testResult" x-cloak x-text="testMessage"
:class="testSuccess ? 'text-green-600' : 'text-red-600'"
class="text-sm mt-2"></p>
</div>
</div>
{{-- Passwort-Reset E-Mail Template --}}
<div class="mb-5">
<h3 class="text-sm font-semibold text-gray-700 mb-1">Passwort-Reset E-Mail (Deutsch)</h3>
<p class="text-xs text-gray-500 mb-3">
Dieser Text wird versendet, wenn ein Benutzer sein Passwort zuruecksetzen moechte.
Platzhalter: <code class="bg-gray-100 px-1 rounded">{name}</code>,
<code class="bg-gray-100 px-1 rounded">{app_name}</code>.
Der Reset-Link wird automatisch als Button angefuegt.
</p>
<div id="editor-pw-reset" class="bg-white border border-gray-300 rounded-md" style="min-height: 150px;">{!! $defaultPwResetDe !!}</div>
<input type="hidden" name="password_reset_email_de" id="input-pw-reset-de" value="{{ old('password_reset_email_de', $defaultPwResetDe) }}">
<p class="text-xs text-gray-400 mt-2">Texte fuer weitere Sprachen werden automatisch erstellt und koennen spaeter in den Einstellungen angepasst werden.</p>
</div>
@if ($errors->any())
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
<ul class="text-sm text-red-700 list-disc list-inside">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="flex justify-between items-center">
<a href="{{ route('install.app') }}"
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition">
Zurueck
</a>
<div class="flex items-center gap-3">
<button type="submit"
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
Weiter
</button>
</div>
</div>
</form>
{{-- Ueberspringen-Link (separates Formular) --}}
<div class="text-center mt-3">
<form method="POST" action="{{ route('install.mail.store') }}" class="inline">
@csrf
<input type="hidden" name="mail_mode" value="log">
<button type="submit" class="text-xs text-gray-400 hover:text-gray-600 underline transition">
Schritt ueberspringen (kein E-Mail-Versand)
</button>
</form>
</div>
{{-- Quill Editor --}}
<link href="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.snow.css" rel="stylesheet"
integrity="sha384-cPa8kzsYWhqpAfWOLWYIw3V0BhPi/m3lrd8tBTPxr2NrYCHRVZ7xy1cEoRGOM/03" crossorigin="anonymous">
<style>
#editor-pw-reset .ql-editor { min-height: 120px; }
.ql-toolbar.ql-snow { border-radius: 0.375rem 0.375rem 0 0; border-color: #d1d5db; }
.ql-container.ql-snow { border-radius: 0 0 0.375rem 0.375rem; border-color: #d1d5db; }
</style>
<script src="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.min.js"
integrity="sha384-QUJ+ckWz1M+a7w0UfG1sEn4pPrbQwSxGm/1TIPyioqXBrwuT9l4f9gdHWLDLbVWI" crossorigin="anonymous"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const editorEl = document.getElementById('editor-pw-reset');
if (!editorEl) return;
const quill = new Quill('#editor-pw-reset', {
theme: 'snow',
modules: {
toolbar: [
[{ 'header': [2, 3, false] }],
['bold', 'italic', 'underline'],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
['link'],
['clean']
]
}
});
// Speichere Referenz fuer Alpine.js syncEditor()
const component = document.querySelector('[x-data]').__x.$data;
component.editor = quill;
});
</script>
</x-layouts.installer>

View File

@@ -0,0 +1,79 @@
<x-layouts.installer :currentStep="1">
@if (! session('setup_token_hash'))
{{-- Token-Eingabe: User muss zuerst das Setup-Token eingeben --}}
<h2 class="text-lg font-semibold text-gray-900 mb-4">Setup-Token</h2>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p class="text-sm text-amber-700 mb-3">
Zum Schutz der Installation wird ein Setup-Token benoetigt.
Du findest es in der Datei <code class="bg-amber-100 px-1 rounded font-mono text-xs">storage/setup-token</code>
auf dem Server (per FTP oder Dateimanager).
</p>
<form method="GET" action="{{ route('install.requirements') }}" class="flex gap-2">
<input type="text" name="token"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
placeholder="Token eingeben..." required autofocus>
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
Bestaetigen
</button>
</form>
</div>
@else
<h2 class="text-lg font-semibold text-gray-900 mb-4">Systemvoraussetzungen</h2>
<p class="text-sm text-gray-600 mb-4">Bitte stelle sicher, dass alle Voraussetzungen erfüllt sind, bevor du fortfährst.</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 px-2 font-medium text-gray-700">Prüfung</th>
<th class="text-left py-2 px-2 font-medium text-gray-700">Status</th>
<th class="text-center py-2 px-1 font-medium text-gray-700 w-10">OK</th>
</tr>
</thead>
<tbody>
@foreach ($checks as $check)
<tr class="border-b border-gray-100 {{ $check['passed'] ? '' : ($check['required'] ? 'bg-red-50' : 'bg-yellow-50') }}">
<td class="py-2 px-2 text-gray-800">
{{ $check['name'] }}
@if (! $check['required'])
<span class="text-xs text-gray-400">(optional)</span>
@endif
</td>
<td class="py-2 px-2 text-gray-500">{{ $check['current'] }}</td>
<td class="py-2 px-1 text-center">
@if ($check['passed'])
<svg class="w-5 h-5 text-green-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
@elseif ($check['required'])
<svg class="w-5 h-5 text-red-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
@else
<svg class="w-5 h-5 text-yellow-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-6 flex justify-between items-center">
<a href="{{ route('install.requirements') }}"
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition">
Erneut prüfen
</a>
@if ($allPassed)
<a href="{{ route('install.database') }}"
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
Weiter
</a>
@else
<span class="px-5 py-2 text-sm font-medium text-white bg-gray-300 rounded-md cursor-not-allowed">
Weiter
</span>
@endif
</div>
@endif
</x-layouts.installer>

View File

@@ -0,0 +1,20 @@
<x-layouts.guest :title="__('ui.privacy')">
<h2 class="text-xl font-bold mb-4">{{ __('ui.privacy') }}</h2>
<div class="prose prose-sm text-gray-700 space-y-4 text-sm">
@php
$locale = app()->getLocale();
$content = \App\Models\Setting::get('datenschutz_html_' . $locale)
?: \App\Models\Setting::get('datenschutz_html_de')
?: \App\Models\Setting::get('datenschutz_html', '<p>[Datenschutzerklärung noch nicht hinterlegt]</p>');
@endphp
{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($content) !!}
</div>
<div class="mt-6 flex justify-end">
<a href="{{ auth()->check() ? route('dashboard') : route('login') }}"
class="inline-flex items-center gap-1 bg-gray-200 text-gray-700 px-4 py-2 rounded-md text-sm hover:bg-gray-300">
&larr; {{ __('ui.back') }}
</a>
</div>
</x-layouts.guest>

View File

@@ -0,0 +1,20 @@
<x-layouts.guest :title="__('ui.impressum')">
<h2 class="text-xl font-bold mb-4">{{ __('ui.impressum') }}</h2>
<div class="prose prose-sm text-gray-700 space-y-3">
@php
$locale = app()->getLocale();
$content = \App\Models\Setting::get('impressum_html_' . $locale)
?: \App\Models\Setting::get('impressum_html_de')
?: \App\Models\Setting::get('impressum_html', '<p>[Impressum noch nicht hinterlegt]</p>');
@endphp
{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($content) !!}
</div>
<div class="mt-6 flex justify-end">
<a href="{{ auth()->check() ? route('dashboard') : route('login') }}"
class="inline-flex items-center gap-1 bg-gray-200 text-gray-700 px-4 py-2 rounded-md text-sm hover:bg-gray-300">
&larr; {{ __('ui.back') }}
</a>
</div>
</x-layouts.guest>

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline SG Wölfe Handball</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1f2937">
<link rel="icon" href="/images/icon-192x192.png">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f3f4f6;
color: #374151;
padding: 2rem;
text-align: center;
}
.container { max-width: 400px; }
.logo { width: 80px; height: 80px; margin: 0 auto 1.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.75rem; color: #1f2937; }
p { color: #6b7280; line-height: 1.6; margin-bottom: 1.5rem; }
button {
background: #1f2937;
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
transition: background 0.15s;
}
button:hover { background: #374151; }
button:active { background: #111827; }
</style>
</head>
<body>
<div class="container">
<img src="/images/icon-192x192.png" alt="SG Wölfe" class="logo">
<h1>Keine Internetverbindung</h1>
<p>
Die Seite kann gerade nicht geladen werden.
Bitte pr&uuml;fe deine Verbindung und versuche es erneut.
</p>
<button onclick="window.location.reload()">Erneut versuchen</button>
</div>
</body>
</html>

View File

@@ -0,0 +1,261 @@
<x-layouts.app :title="__('profile.title')">
<h1 class="text-2xl font-bold mb-6">{{ __('profile.title') }}</h1>
<div class="bg-white rounded-lg shadow p-6 mb-6">
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
@csrf
@method('PUT')
{{-- Profilbild --}}
<div class="mb-6" x-data="{ preview: null }">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('profile.profile_picture') }}</label>
<div class="flex items-center gap-4">
<div class="relative">
@if ($user->getAvatarUrl())
<img src="{{ $user->getAvatarUrl() }}" alt="{{ $user->name }}" class="w-16 h-16 rounded-full object-cover border-2 border-gray-200" x-show="!preview">
@else
<div class="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-semibold text-lg border-2 border-gray-200" x-show="!preview">
{{ $user->getInitials() }}
</div>
@endif
<img :src="preview" x-show="preview" class="w-16 h-16 rounded-full object-cover border-2 border-blue-400" x-cloak>
</div>
<div class="flex flex-col gap-1">
<label class="cursor-pointer bg-gray-100 text-gray-700 px-3 py-1.5 rounded-md text-sm hover:bg-gray-200 inline-block">
{{ __('profile.upload_picture') }}
<input type="file" name="profile_picture" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden"
@change="if ($event.target.files[0]) { preview = URL.createObjectURL($event.target.files[0]) }">
</label>
<span class="text-xs text-gray-400">{{ __('profile.max_picture_size') }}</span>
@error('profile_picture')
<p class="text-red-600 text-xs">{{ $message }}</p>
@enderror
</div>
</div>
</div>
<div class="mb-4">
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('profile.name_label') }}</label>
<input type="text" name="name" id="name" value="{{ old('name', $user->name) }}" required
class="w-full rounded border-gray-300 text-sm">
@error('name')
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('profile.email_label') }}</label>
<p class="text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded">{{ $user->email }}</p>
<p class="text-xs text-gray-400 mt-1">{{ __('profile.email_readonly') }}</p>
</div>
<div class="mb-4">
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">{{ __('profile.phone_label') }}</label>
<input type="tel" name="phone" id="phone" value="{{ old('phone', $user->phone) }}"
placeholder="+49..."
class="w-full rounded border-gray-300 text-sm">
@error('phone')
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('profile.role_label') }}</label>
<p class="text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded">{{ __('ui.enums.user_role.' . $user->role->value) }}</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('profile.language_label') }}</label>
@php
$flags = ['de' => "\u{1F1E9}\u{1F1EA}", 'en' => "\u{1F1EC}\u{1F1E7}", 'pl' => "\u{1F1F5}\u{1F1F1}", 'ru' => "\u{1F1F7}\u{1F1FA}", 'ar' => "\u{1F1F8}\u{1F1E6}", 'tr' => "\u{1F1F9}\u{1F1F7}"];
@endphp
<div class="flex items-center gap-3" x-data="{ locale: @js(old('locale', $user->locale)) }">
<input type="hidden" name="locale" :value="locale">
@foreach ($flags as $code => $flag)
<button type="button" @click="locale = @js($code)"
:class="locale === @js($code) ? 'ring-2 ring-blue-500 ring-offset-1 scale-110' : 'opacity-50 hover:opacity-80'"
class="text-2xl leading-none transition-all rounded cursor-pointer"
title="{{ __('ui.locales.' . $code) }}">
{{ $flag }}
</button>
@endforeach
</div>
</div>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700">{{ __('ui.save') }}</button>
</form>
@if ($user->getAvatarUrl())
<div class="mt-3 pt-3 border-t">
<form method="POST" action="{{ route('profile.remove-picture') }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_file')))">
@csrf
@method('DELETE')
<button type="submit" class="text-xs text-red-500 hover:text-red-700">{{ __('profile.remove_picture') }}</button>
</form>
</div>
@endif
</div>
{{-- DSGVO-Einverständniserklärung --}}
<div id="dsgvo-consent" class="bg-white rounded-lg shadow p-6 mb-6" x-data="{ dsgvoModal: false }">
<h2 class="text-lg font-semibold mb-2">{{ __('profile.dsgvo_title') }}</h2>
<p class="text-sm text-gray-600 mb-4">{{ __('profile.dsgvo_description') }}</p>
@if ($user->dsgvo_consent_file)
@php
$ext = strtolower(pathinfo($user->dsgvo_consent_file, PATHINFO_EXTENSION));
$dsgvoIsPdf = $ext === 'pdf';
$dsgvoIsImage = in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp']);
@endphp
{{-- Status-Banner --}}
<div class="flex items-center gap-3 p-3 rounded-md mb-4
{{ $user->isDsgvoConfirmed() ? 'bg-green-50 border border-green-200' : 'bg-yellow-50 border border-yellow-200' }}">
<div class="flex-1">
<p class="text-sm font-medium {{ $user->isDsgvoConfirmed() ? 'text-green-800' : 'text-yellow-800' }}">
@if ($user->isDsgvoConfirmed())
{{ __('profile.dsgvo_confirmed') }}
<span class="text-xs font-normal block mt-0.5">
{{ __('profile.dsgvo_confirmed_by', [
'name' => $user->dsgvoAcceptedBy?->name ?? '—',
'date' => $user->dsgvo_accepted_at->translatedFormat(__('ui.date_format'))
]) }}
</span>
@else
{{ __('profile.dsgvo_pending') }}
@endif
</p>
</div>
<button type="button" @click="dsgvoModal = true"
class="text-sm text-blue-600 hover:text-blue-800 font-medium cursor-pointer">
{{ __('profile.dsgvo_view') }}
</button>
</div>
{{-- DSGVO-Vorschau-Modal --}}
<div x-show="dsgvoModal" x-cloak @keydown.escape.window="dsgvoModal = false" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div x-show="dsgvoModal"
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@click="dsgvoModal = false" class="fixed inset-0 bg-black/60"></div>
<div x-show="dsgvoModal"
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
@click.outside="dsgvoModal = false"
class="relative bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden {{ $dsgvoIsPdf ? 'w-full max-w-3xl max-h-[92vh]' : 'w-full max-w-lg max-h-[90vh]' }}">
<div class="flex items-center justify-between px-5 py-3 border-b">
<h3 class="font-semibold text-gray-900">{{ __('profile.dsgvo_title') }}</h3>
<button @click="dsgvoModal = false" class="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="flex-1 overflow-y-auto {{ $dsgvoIsPdf ? 'p-0' : 'p-5' }}">
@if ($dsgvoIsImage)
<div class="flex justify-center bg-gray-50 rounded-lg p-2">
<img src="{{ route('profile.dsgvo-consent') }}" alt="{{ __('profile.dsgvo_title') }}" class="max-w-full max-h-[70vh] rounded object-contain">
</div>
@elseif ($dsgvoIsPdf)
<iframe src="{{ route('profile.dsgvo-consent') }}" class="w-full border-0" style="height: 75vh;"></iframe>
@endif
</div>
<div class="px-5 py-3 border-t bg-gray-50 flex justify-end">
<button @click="dsgvoModal = false" class="text-sm text-gray-500 hover:text-gray-700">{{ __('ui.close') }}</button>
</div>
</div>
</div>
{{-- Ersetzen --}}
<form method="POST" action="{{ route('profile.upload-dsgvo-consent') }}" enctype="multipart/form-data" class="mb-3">
@csrf
<div class="flex items-center gap-3">
<label class="cursor-pointer bg-gray-100 text-gray-700 px-3 py-1.5 rounded-md text-sm hover:bg-gray-200">
{{ __('profile.dsgvo_replace') }}
<input type="file" name="dsgvo_consent_file"
accept="application/pdf,image/jpeg,image/png,image/gif,image/webp"
class="hidden" onchange="this.form.submit()">
</label>
<span class="text-xs text-gray-400">{{ __('profile.dsgvo_file_hint') }}</span>
</div>
@error('dsgvo_consent_file')
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
@enderror
</form>
{{-- Entfernen --}}
<form method="POST" action="{{ route('profile.remove-dsgvo-consent') }}"
onsubmit="return confirm(@js(__('profile.dsgvo_confirm_remove')))">
@csrf
@method('DELETE')
<button type="submit" class="text-xs text-red-500 hover:text-red-700">
{{ __('profile.dsgvo_remove') }}
</button>
</form>
@else
{{-- Upload-Formular --}}
<form method="POST" action="{{ route('profile.upload-dsgvo-consent') }}" enctype="multipart/form-data">
@csrf
<div class="flex items-center gap-3">
<label class="cursor-pointer bg-blue-600 text-white px-4 py-2 rounded-md text-sm hover:bg-blue-700">
{{ __('profile.dsgvo_upload') }}
<input type="file" name="dsgvo_consent_file"
accept="application/pdf,image/jpeg,image/png,image/gif,image/webp"
class="hidden" onchange="this.form.submit()">
</label>
<span class="text-xs text-gray-400">{{ __('profile.dsgvo_file_hint') }}</span>
</div>
@error('dsgvo_consent_file')
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
@enderror
</form>
@endif
</div>
{{-- Zugeordnete Kinder --}}
@if ($user->children->isNotEmpty())
<div class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-3">{{ __('profile.my_children') }}</h2>
<div class="divide-y divide-gray-100">
@foreach ($user->children as $child)
<div class="py-2 flex items-center justify-between">
<div>
<span class="text-sm font-medium text-gray-900">{{ $child->full_name }}</span>
@if ($child->pivot->relationship_label)
<span class="text-xs text-gray-500 ml-1 rtl:mr-1 rtl:ml-0">({{ $child->pivot->relationship_label }})</span>
@endif
</div>
<span class="text-xs text-gray-500">{{ $child->team->name ?? '—' }}</span>
</div>
@endforeach
</div>
</div>
@endif
{{-- Gefahrenzone: Account löschen (nur für Eltern) --}}
@if ($user->role === \App\Enums\UserRole::User)
<div class="bg-white rounded-lg shadow p-6 border border-red-200">
<h2 class="font-semibold text-red-700 mb-2">{{ __('profile.danger_zone') }}</h2>
<p class="text-sm text-gray-600 mb-3">{{ __('profile.delete_account_hint') }}</p>
@php $orphanedChildren = $user->getOrphanedChildren(); @endphp
@if ($orphanedChildren->isNotEmpty())
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-3 mb-4">
<p class="text-sm text-yellow-800 font-medium">{{ __('profile.delete_warning_children') }}</p>
<ul class="list-disc list-inside text-sm text-yellow-700 mt-1">
@foreach ($orphanedChildren as $child)
<li>{{ $child->full_name }}</li>
@endforeach
</ul>
</div>
@endif
<form method="POST" action="{{ route('profile.destroy') }}"
onsubmit="return confirm(@js(__('profile.delete_confirm')))">
@csrf
@method('DELETE')
<button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 text-sm font-medium">
{{ __('profile.delete_account') }}
</button>
</form>
</div>
@endif
</x-layouts.app>

277
resources/views/welcome.blade.php Executable file

File diff suppressed because one or more lines are too long