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