Erweiterte Spielerstatistiken: 7-Meter, Strafen, Spielzeit

Neue Metriken für Jugendhandball: 7m-Würfe/-Tore, Gelbe Karten,
2-Minuten-Strafen und Spielzeit. Migration, Model, Controller, Views
und Übersetzungen (6 Sprachen) vollständig implementiert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rhino
2026-03-02 23:50:03 +01:00
parent ee89141628
commit f24f7f12a3
12 changed files with 303 additions and 31 deletions

View File

@@ -383,6 +383,11 @@
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_saves') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_shots') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_goals') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-16" title="{{ __('events.stats_penalty_shots') }}">7m-W</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-16" title="{{ __('events.stats_penalty_goals') }}">7m-T</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-14" title="{{ __('events.stats_yellow_cards') }}">{{ __('events.stats_yellow_cards') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-14" title="{{ __('events.stats_two_min') }}">{{ __('events.stats_two_min') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-16" title="{{ __('events.stats_playing_time') }}">{{ __('events.stats_playing_time_short') }}</th>
<th class="text-left px-2 py-2 font-medium text-gray-600">{{ __('events.stats_note') }}</th>
</tr>
</thead>
@@ -436,6 +441,31 @@
value="{{ $stat?->goals }}"
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][penalty_shots]" min="0" max="99"
value="{{ $stat?->penalty_shots }}"
class="w-14 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][penalty_goals]" min="0" max="99"
value="{{ $stat?->penalty_goals }}"
class="w-14 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][yellow_cards]" min="0" max="3"
value="{{ $stat?->yellow_cards }}"
class="w-12 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][two_minute_suspensions]" min="0" max="3"
value="{{ $stat?->two_minute_suspensions }}"
class="w-12 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][playing_time_minutes]" min="0" max="90"
value="{{ $stat?->playing_time_minutes }}"
class="w-14 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2">
<input type="text" name="stats[{{ $pid }}][note]" maxlength="500"
value="{{ $stat?->note }}"

View File

@@ -3,7 +3,7 @@
{{-- 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">
<form method="GET" action="{{ route('admin.statistics.index') }}" class="flex flex-wrap items-end gap-4" x-data="{ useSeason: {{ request()->filled('season_id') || (!request()->filled('from') && !request()->filled('to')) ? 'true' : 'false' }} }">
<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">
@@ -13,16 +13,29 @@
@endforeach
</select>
</div>
@if ($seasons->isNotEmpty())
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.season') }}</label>
<select name="season_id" class="px-3 py-2 border border-gray-300 rounded-md text-sm" @change="useSeason = $el.value !== ''">
<option value=""></option>
@foreach ($seasons as $season)
<option value="{{ $season->id }}" {{ request('season_id', $activeSeason?->id) == $season->id ? 'selected' : '' }}>
{{ $season->name }}{{ $season->is_current ? ' ●' : '' }}
</option>
@endforeach
</select>
</div>
@endif
<div x-show="!useSeason" x-cloak>
<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>
<div x-show="!useSeason" x-cloak>
<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']))
@if (request()->hasAny(['team_id', 'from', 'to', 'season_id']))
<a href="{{ route('admin.statistics.index') }}" class="text-sm text-gray-500 hover:underline">{{ __('admin.filter_reset') }}</a>
@endif
</form>
@@ -157,6 +170,9 @@
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.position') }}</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.player_goals') }}</th>
<th class="text-center px-4 py-2.5 font-medium text-gray-600" title="{{ __('admin.stats_penalties') }}">7m</th>
<th class="text-center px-4 py-2.5 font-medium text-gray-600" title="{{ __('admin.stats_cards') }}">{{ __('admin.stats_cards') }}</th>
<th class="text-center px-4 py-2.5 font-medium text-gray-600" title="{{ __('admin.stats_avg_time') }}">{{ __('admin.stats_avg_time') }}</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>
@@ -167,7 +183,7 @@
@if (!$separatorShown && !$entry->is_primary_gk)
@php $separatorShown = true; @endphp
@if ($index > 0)
<tr><td colspan="7" class="px-4 py-1"><hr class="border-gray-300"></td></tr>
<tr><td colspan="10" class="px-4 py-1"><hr class="border-gray-300"></td></tr>
@endif
@endif
<tr class="hover:bg-gray-50 cursor-pointer" @click="openModal({{ $entry->player->id }})">
@@ -197,6 +213,32 @@
<span class="text-gray-300">0</span>
@endif
</td>
<td class="px-4 py-2 text-center">
@if ($entry->total_penalty_shots > 0)
<span class="text-xs" title="{{ $entry->total_penalty_goals }}/{{ $entry->total_penalty_shots }} ({{ $entry->total_penalty_shots > 0 ? round(($entry->total_penalty_goals / $entry->total_penalty_shots) * 100) : 0 }}%)">{{ $entry->total_penalty_goals }}/{{ $entry->total_penalty_shots }}</span>
@else
<span class="text-gray-300"></span>
@endif
</td>
<td class="px-4 py-2 text-center">
@if ($entry->total_yellow_cards > 0 || $entry->total_suspensions > 0)
@if ($entry->total_yellow_cards > 0)
<span class="inline-block w-3.5 h-4.5 bg-yellow-400 rounded-sm text-[9px] font-bold text-yellow-900 leading-[18px] text-center" title="{{ $entry->total_yellow_cards }}× Gelbe Karte">{{ $entry->total_yellow_cards }}</span>
@endif
@if ($entry->total_suspensions > 0)
<span class="inline-block px-1 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-700" title="{{ $entry->total_suspensions }}× 2-Min">{{ $entry->total_suspensions }}×2'</span>
@endif
@else
<span class="text-gray-300"></span>
@endif
</td>
<td class="px-4 py-2 text-center">
@if ($entry->avg_playing_time)
<span class="text-xs text-gray-600">{{ $entry->avg_playing_time }}'</span>
@else
<span class="text-gray-300"></span>
@endif
</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 }}%
@@ -234,31 +276,31 @@
'kreislaeufer' => ['x' => 200, 'y' => 180],
];
$colorMap = [
'green' => ['fill' => '#22c55e', 'text' => '#fff'],
'yellow' => ['fill' => '#eab308', 'text' => '#fff'],
'red' => ['fill' => '#ef4444', 'text' => '#fff'],
'gray' => ['fill' => '#9ca3af', 'text' => '#fff'],
'green' => ['fill' => '#305f3f', 'text' => '#fff'],
'yellow' => ['fill' => '#806130', 'text' => '#fff'],
'red' => ['fill' => '#76403b', 'text' => '#fff'],
'gray' => ['fill' => '#667788', 'text' => '#fff'],
];
@endphp
<svg viewBox="0 0 400 320" class="w-full max-w-lg" xmlns="http://www.w3.org/2000/svg">
{{-- Spielfeld-Hintergrund --}}
<rect x="0" y="0" width="400" height="320" rx="8" fill="#16a34a" />
<rect x="10" y="10" width="380" height="300" rx="4" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.5" />
<rect x="0" y="0" width="400" height="320" rx="8" fill="#BBFEC3" />
<rect x="10" y="10" width="380" height="300" rx="4" fill="none" stroke="#000" stroke-width="1.5" opacity="0.4" />
{{-- Mittellinie --}}
<line x1="10" y1="160" x2="390" y2="160" stroke="#fff" stroke-width="1" opacity="0.3" />
<line x1="10" y1="160" x2="390" y2="160" stroke="#000" stroke-width="1" opacity="0.25" />
{{-- Tor (unten) --}}
<rect x="155" y="298" width="90" height="12" rx="2" fill="none" stroke="#fff" stroke-width="2" opacity="0.7" />
<rect x="155" y="298" width="90" height="12" rx="2" fill="none" stroke="#000" stroke-width="2" opacity="0.5" />
{{-- 6m-Torraum (Halbkreis) --}}
<path d="M 120 310 Q 120 230 200 220 Q 280 230 280 310" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.5" />
<path d="M 120 310 Q 120 230 200 220 Q 280 230 280 310" fill="none" stroke="#000" stroke-width="1.5" opacity="0.4" />
{{-- 9m-Freiwurflinie (gestrichelt) --}}
<path d="M 80 310 Q 80 200 200 185 Q 320 200 320 310" fill="none" stroke="#fff" stroke-width="1" stroke-dasharray="6,4" opacity="0.35" />
<path d="M 80 310 Q 80 200 200 185 Q 320 200 320 310" fill="none" stroke="#000" stroke-width="1" stroke-dasharray="6,4" opacity="0.3" />
{{-- 7m-Markierung --}}
<line x1="193" y1="248" x2="207" y2="248" stroke="#fff" stroke-width="2" opacity="0.5" />
<line x1="193" y1="248" x2="207" y2="248" stroke="#000" stroke-width="2" opacity="0.4" />
{{-- Spieler-Positionen --}}
@foreach ($courtPositions as $posValue => $coords)
@@ -268,7 +310,7 @@
$posEnum = \App\Enums\PlayerPosition::tryFrom($posValue);
@endphp
<g @if ($entry) @click="openModal({{ $entry->player->id }})" style="cursor: pointer;" @endif>
<circle cx="{{ $coords['x'] }}" cy="{{ $coords['y'] }}" r="22" fill="{{ $color['fill'] }}" opacity="0.9" stroke="#fff" stroke-width="1.5" />
<circle cx="{{ $coords['x'] }}" cy="{{ $coords['y'] }}" r="22" fill="{{ $color['fill'] }}" opacity="0.9" stroke="#1a3a1a" stroke-width="1.5" />
@if ($entry)
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] - 4 }}" text-anchor="middle" fill="{{ $color['text'] }}" font-size="11" font-weight="bold" style="pointer-events: none;">
{{ $entry->player->jersey_number ?? $entry->player->getInitials() }}
@@ -282,7 +324,7 @@
</text>
@endif
{{-- Positions-Kürzel unter dem Kreis --}}
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] + 35 }}" text-anchor="middle" fill="#fff" font-size="8" opacity="0.7" style="pointer-events: none;">
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] + 35 }}" text-anchor="middle" fill="#1a3a1a" font-size="8" opacity="0.6" style="pointer-events: none;">
{{ $posEnum?->shortLabel() }}
</text>
</g>
@@ -372,6 +414,32 @@
</div>
</div>
</template>
{{-- Erweiterte Statistiken --}}
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-3">
<template x-if="data.summary.total_penalty_shots > 0">
<div class="text-center p-2 bg-orange-50 rounded-lg">
<div class="text-lg font-bold text-orange-700" x-text="data.summary.total_penalty_goals + '/' + data.summary.total_penalty_shots"></div>
<div class="text-xs text-orange-600">7-Meter <span x-show="data.summary.penalty_rate !== null" x-text="'(' + data.summary.penalty_rate + '%)'"></span></div>
</div>
</template>
<template x-if="data.summary.total_yellow_cards > 0 || data.summary.total_suspensions > 0">
<div class="text-center p-2 bg-red-50 rounded-lg">
<div class="text-lg font-bold text-red-700">
<span x-show="data.summary.total_yellow_cards > 0" x-text="data.summary.total_yellow_cards + '× Gelb'"></span>
<span x-show="data.summary.total_yellow_cards > 0 && data.summary.total_suspensions > 0">, </span>
<span x-show="data.summary.total_suspensions > 0" x-text="data.summary.total_suspensions + '× 2-Min'"></span>
</div>
<div class="text-xs text-red-600">{{ __('events.stats_cards') }}</div>
</div>
</template>
<template x-if="data.summary.avg_playing_time !== null">
<div class="text-center p-2 bg-cyan-50 rounded-lg">
<div class="text-lg font-bold text-cyan-700" x-text="Math.round(data.summary.avg_playing_time) + ' Min.'"></div>
<div class="text-xs text-cyan-600"> {{ __('events.stats_playing_time') }}</div>
</div>
</template>
</div>
</div>
</template>
@@ -393,6 +461,9 @@
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_goals') }}</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_shots') }}</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_goalkeeper') }}</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">7m</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_cards') }}</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">Min.</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_note') }}</th>
</tr>
</thead>
@@ -417,6 +488,20 @@
<span class="text-gray-300"></span>
</template>
</td>
<td class="px-4 py-2 text-center">
<template x-if="game.penalty_shots > 0">
<span class="text-xs text-orange-700" x-text="game.penalty_goals + '/' + game.penalty_shots"></span>
</template>
<template x-if="!game.penalty_shots || game.penalty_shots === 0">
<span class="text-gray-300"></span>
</template>
</td>
<td class="px-4 py-2 text-center">
<span x-show="game.yellow_cards > 0" class="text-xs bg-yellow-100 text-yellow-800 px-1 py-0.5 rounded mr-0.5" x-text="game.yellow_cards + '×🟡'"></span>
<span x-show="game.two_minute_suspensions > 0" class="text-xs bg-red-100 text-red-700 px-1 py-0.5 rounded" x-text="game.two_minute_suspensions + '×2m'"></span>
<span x-show="!game.yellow_cards && !game.two_minute_suspensions" class="text-gray-300"></span>
</td>
<td class="px-4 py-2 text-center text-gray-500" x-text="game.playing_time_minutes ? game.playing_time_minutes + '\'' : ''"></td>
<td class="px-4 py-2 text-gray-500 text-xs" x-text="game.note ?? ''"></td>
</tr>
</template>
@@ -564,7 +649,7 @@
datasets: [{
label: @js(__('admin.nav_players')),
data: playerData.data,
backgroundColor: '#3b82f6',
backgroundColor: '#4a5e7a',
borderRadius: 3,
}]
},
@@ -586,13 +671,13 @@
{
label: @js(__('events.catering_short')),
data: parentData.catering,
backgroundColor: '#f59e0b',
backgroundColor: '#99783a',
borderRadius: 3,
},
{
label: @js(__('events.timekeeper_short')),
data: parentData.timekeepers,
backgroundColor: '#8b5cf6',
backgroundColor: '#6b5a84',
borderRadius: 3,
}
]