Spielerpositionen, Statistiken, Fahrgemeinschaften, Spielfeld-Visualisierung

- PlayerPosition Enum (7 Handball-Positionen) mit Label/ShortLabel
- Spielerstatistik pro Spiel (Tore, Würfe, TW-Paraden, Bemerkung)
- Position-Dropdown in Spieler-Editor und Event-Stats-Formular
- Statistik-Seite: TW zuerst, Trennlinie, Feldspieler, Position-Badges
- Spielfeld-SVG mit Ampel-Performance (grün/gelb/rot)
- Anklickbare Spieler im Spielfeld öffnen Detail-Modal
- Fahrgemeinschaften (Anbieten, Zuordnen, Zurückziehen)
- Übersetzungen in allen 6 Sprachen (de, en, pl, ru, ar, tr)
- .gitignore für Laravel hinzugefügt
- Demo-Daten mit Positionen und Statistiken

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rhino
2026-03-02 11:47:34 +01:00
parent 2e24a40d68
commit ad60e7a9f9
46 changed files with 2041 additions and 86 deletions

View File

@@ -142,6 +142,7 @@
{{-- Spieler-Rangliste (nur Staff) --}}
@if (auth()->user()->isStaff() && $playerRanking->isNotEmpty())
<div x-data="playerDetailModal()">
<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>
@@ -153,15 +154,23 @@
<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.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.games_assigned') }}</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">{{ __('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">
@php $separatorShown = false; @endphp
@foreach ($playerRanking as $index => $entry)
<tr class="hover:bg-gray-50">
@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>
@endif
@endif
<tr class="hover:bg-gray-50 cursor-pointer" @click="openModal({{ $entry->player->id }})">
<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())
@@ -169,10 +178,25 @@
@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>
<span class="text-blue-600 hover:underline {{ $entry->player->trashed() ? 'text-gray-400 line-through' : '' }}">{{ $entry->player->full_name }}</span>
</td>
<td class="px-4 py-2 text-center">
@if ($entry->primary_position)
<span class="inline-block px-1.5 py-0.5 rounded text-xs font-medium {{ $entry->is_primary_gk ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-700' }}">
{{ $entry->primary_position->shortLabel() }}
</span>
@else
<span class="text-gray-300"></span>
@endif
</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">
@if ($entry->total_goals > 0)
<span class="font-medium text-green-600">{{ $entry->total_goals }}</span>
@else
<span class="text-gray-300">0</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 }}%
@@ -188,7 +212,229 @@
</tbody>
</table>
</div>
</div>
{{-- Spielfeld-Aufstellung --}}
@if ($courtPlayers->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.court_visualization') }}</h3>
</div>
<div class="p-4 flex justify-center">
@php
// Positionskoordinaten auf dem Spielfeld (viewBox 0 0 400 320)
$courtPositions = [
'torwart' => ['x' => 200, 'y' => 280],
'links_aussen' => ['x' => 55, 'y' => 185],
'rechts_aussen' => ['x' => 345, 'y' => 185],
'rueckraum_links' => ['x' => 115, 'y' => 105],
'rueckraum_mitte' => ['x' => 200, 'y' => 75],
'rueckraum_rechts' => ['x' => 285, 'y' => 105],
'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'],
];
@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" />
{{-- Mittellinie --}}
<line x1="10" y1="160" x2="390" y2="160" stroke="#fff" stroke-width="1" opacity="0.3" />
{{-- Tor (unten) --}}
<rect x="155" y="298" width="90" height="12" rx="2" fill="none" stroke="#fff" stroke-width="2" opacity="0.7" />
{{-- 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" />
{{-- 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" />
{{-- 7m-Markierung --}}
<line x1="193" y1="248" x2="207" y2="248" stroke="#fff" stroke-width="2" opacity="0.5" />
{{-- Spieler-Positionen --}}
@foreach ($courtPositions as $posValue => $coords)
@php
$entry = $courtPlayers->get($posValue);
$color = $entry ? $colorMap[$entry->performance_color] : $colorMap['gray'];
$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" />
@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() }}
</text>
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] + 8 }}" text-anchor="middle" fill="{{ $color['text'] }}" font-size="7" style="pointer-events: none;">
{{ Str::limit($entry->player->first_name, 8, '') }}
</text>
@else
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] + 4 }}" text-anchor="middle" fill="{{ $color['text'] }}" font-size="9" font-weight="bold">
{{ $posEnum?->shortLabel() }}
</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;">
{{ $posEnum?->shortLabel() }}
</text>
</g>
@endforeach
</svg>
</div>
{{-- Legende --}}
<div class="px-4 pb-4 flex flex-wrap gap-4 justify-center text-xs text-gray-600">
<div class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-green-500"></span>
{{ __('admin.performance_good') }}
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-yellow-500"></span>
{{ __('admin.performance_average') }}
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-red-500"></span>
{{ __('admin.performance_below') }}
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-gray-400"></span>
{{ __('admin.court_no_data') }}
</div>
</div>
</div>
@endif
{{-- Spieler-Detail-Modal --}}
<div x-show="show" x-cloak x-transition.opacity class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" @click.self="show = false" @keydown.escape.window="show = false">
<div class="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] overflow-hidden" @click.stop>
{{-- Header --}}
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
<div class="flex items-center gap-3">
<template x-if="data && data.player.avatar">
<img :src="data.player.avatar" class="w-10 h-10 rounded-full object-cover">
</template>
<template x-if="data && !data.player.avatar">
<div class="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-sm font-semibold" x-text="data ? data.player.initials : ''"></div>
</template>
<div>
<h3 class="font-semibold text-gray-900" x-text="data ? data.player.name : ''"></h3>
<p class="text-xs text-gray-500">
<span x-text="data && data.player.position ? data.player.position + ' · ' : ''"></span>{{ __('admin.stats_player_detail') }}
</p>
</div>
</div>
<button @click="show = false" class="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
{{-- Zusammenfassung --}}
<template x-if="data">
<div class="px-5 py-4 border-b border-gray-100">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="text-center p-2 bg-green-50 rounded-lg">
<div class="text-lg font-bold text-green-700" x-text="data.summary.total_goals"></div>
<div class="text-xs text-green-600">{{ __('admin.stats_total_goals') }}</div>
</div>
<div class="text-center p-2 bg-blue-50 rounded-lg">
<div class="text-lg font-bold text-blue-700" x-text="data.summary.total_shots"></div>
<div class="text-xs text-blue-600">{{ __('admin.stats_total_shots') }}</div>
</div>
<div class="text-center p-2 bg-amber-50 rounded-lg">
<div class="text-lg font-bold text-amber-700" x-text="data.summary.hit_rate !== null ? data.summary.hit_rate + '%' : ''"></div>
<div class="text-xs text-amber-600">{{ __('events.stats_hit_rate') }}</div>
</div>
<div class="text-center p-2 bg-purple-50 rounded-lg">
<div class="text-lg font-bold text-purple-700" x-text="data.summary.gk_appearances"></div>
<div class="text-xs text-purple-600">{{ __('admin.stats_gk_appearances') }}</div>
</div>
</div>
<template x-if="data.summary.gk_appearances > 0">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-3">
<div class="text-center p-2 bg-indigo-50 rounded-lg">
<div class="text-lg font-bold text-indigo-700" x-text="data.summary.total_saves"></div>
<div class="text-xs text-indigo-600">{{ __('admin.stats_total_saves') }}</div>
</div>
<div class="text-center p-2 bg-gray-50 rounded-lg">
<div class="text-lg font-bold text-gray-700" x-text="data.summary.total_gk_shots"></div>
<div class="text-xs text-gray-500">{{ __('events.stats_shots_on_goal') }}</div>
</div>
<div class="text-center p-2 bg-teal-50 rounded-lg">
<div class="text-lg font-bold text-teal-700" x-text="data.summary.save_rate !== null ? data.summary.save_rate + '%' : ''"></div>
<div class="text-xs text-teal-600">{{ __('events.stats_save_rate') }}</div>
</div>
</div>
</template>
</div>
</template>
{{-- Spiel-Liste --}}
<div class="overflow-y-auto" style="max-height: 40vh;">
<template x-if="loading">
<div class="flex items-center justify-center py-8 text-gray-400">
<svg class="animate-spin w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
</div>
</template>
<template x-if="data && data.games.length > 0">
<table class="w-full text-sm">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500">{{ __('admin.date') }}</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.opponent') }}</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.score') }}</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_position') }}</th>
<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-left px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_note') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="game in data.games" :key="game.date + game.opponent">
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap text-gray-600" x-text="game.date"></td>
<td class="px-4 py-2" x-text="game.opponent"></td>
<td class="px-4 py-2 text-center font-medium" x-text="game.score"></td>
<td class="px-4 py-2 text-center">
<span x-text="game.position ?? ''" :class="game.position ? 'text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded' : 'text-gray-300'"></span>
</td>
<td class="px-4 py-2 text-center">
<span x-text="game.goals ?? ''" :class="game.goals > 0 ? 'font-medium text-green-600' : 'text-gray-400'"></span>
</td>
<td class="px-4 py-2 text-center text-gray-500" x-text="game.shots ?? ''"></td>
<td class="px-4 py-2 text-center">
<template x-if="game.is_goalkeeper">
<span class="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded" x-text="(game.goalkeeper_saves ?? 0) + '/' + (game.goalkeeper_shots ?? 0)"></span>
</template>
<template x-if="!game.is_goalkeeper">
<span class="text-gray-300"></span>
</template>
</td>
<td class="px-4 py-2 text-gray-500 text-xs" x-text="game.note ?? ''"></td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="data && data.games.length === 0">
<div class="py-8 text-center text-gray-400 text-sm">{{ __('events.stats_no_data') }}</div>
</template>
</div>
{{-- Footer --}}
<div class="px-5 py-3 border-t border-gray-200 text-right">
<button @click="show = false" class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200">{{ __('admin.stats_close') }}</button>
</div>
</div>
</div>
</div>{{-- /x-data playerDetailModal --}}
@endif
{{-- Eltern-Engagement-Rangliste --}}
@@ -255,6 +501,34 @@
@endif
@endif
@push('scripts')
<script>
function playerDetailModal() {
return {
show: false,
loading: false,
data: null,
async openModal(playerId) {
this.show = true;
this.loading = true;
this.data = null;
try {
const resp = await fetch(@js(url('/admin/statistics/player')) + '/' + playerId, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
});
if (resp.ok) {
this.data = await resp.json();
}
} catch (e) {
// Fehler still ignorieren
}
this.loading = false;
}
};
}
</script>
@endpush
@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>