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

@@ -16,6 +16,7 @@ use App\Models\EventTimekeeper;
use App\Models\File; use App\Models\File;
use App\Models\FileCategory; use App\Models\FileCategory;
use App\Models\Location; use App\Models\Location;
use App\Models\Season;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Team; use App\Models\Team;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -55,11 +56,20 @@ class EventController extends Controller
$query->where('status', $request->status); $query->where('status', $request->status);
} }
// Saison-Filter
if ($request->filled('season_id')) {
$season = Season::find($request->season_id);
if ($season) {
$query->whereBetween('start_at', [$season->start_date, $season->end_date->endOfDay()]);
}
}
$events = $query->paginate(20)->withQueryString(); $events = $query->paginate(20)->withQueryString();
$teams = Team::active()->orderBy('name')->get(); $teams = Team::active()->orderBy('name')->get();
$seasons = Season::orderByDesc('start_date')->get();
$trashedEvents = Event::onlyTrashed()->with('team')->latest('deleted_at')->get(); $trashedEvents = Event::onlyTrashed()->with('team')->latest('deleted_at')->get();
return view('admin.events.index', compact('events', 'teams', 'trashedEvents')); return view('admin.events.index', compact('events', 'teams', 'seasons', 'trashedEvents'));
} }
public function create(): View public function create(): View
@@ -229,6 +239,11 @@ class EventController extends Controller
'stats.*.goalkeeper_shots' => ['nullable', 'integer', 'min:0', 'max:999'], 'stats.*.goalkeeper_shots' => ['nullable', 'integer', 'min:0', 'max:999'],
'stats.*.goals' => ['nullable', 'integer', 'min:0', 'max:999'], 'stats.*.goals' => ['nullable', 'integer', 'min:0', 'max:999'],
'stats.*.shots' => ['nullable', 'integer', 'min:0', 'max:999'], 'stats.*.shots' => ['nullable', 'integer', 'min:0', 'max:999'],
'stats.*.penalty_goals' => ['nullable', 'integer', 'min:0', 'max:99'],
'stats.*.penalty_shots' => ['nullable', 'integer', 'min:0', 'max:99'],
'stats.*.yellow_cards' => ['nullable', 'integer', 'min:0', 'max:3'],
'stats.*.two_minute_suspensions' => ['nullable', 'integer', 'min:0', 'max:3'],
'stats.*.playing_time_minutes' => ['nullable', 'integer', 'min:0', 'max:90'],
'stats.*.note' => ['nullable', 'string', 'max:500'], 'stats.*.note' => ['nullable', 'string', 'max:500'],
]); ]);
@@ -242,9 +257,17 @@ class EventController extends Controller
$gkSaves = $isGk && isset($data['goalkeeper_saves']) && $data['goalkeeper_saves'] !== '' ? (int) $data['goalkeeper_saves'] : null; $gkSaves = $isGk && isset($data['goalkeeper_saves']) && $data['goalkeeper_saves'] !== '' ? (int) $data['goalkeeper_saves'] : null;
$gkShots = $isGk && isset($data['goalkeeper_shots']) && $data['goalkeeper_shots'] !== '' ? (int) $data['goalkeeper_shots'] : null; $gkShots = $isGk && isset($data['goalkeeper_shots']) && $data['goalkeeper_shots'] !== '' ? (int) $data['goalkeeper_shots'] : null;
$note = ! empty($data['note']) ? trim($data['note']) : null; $note = ! empty($data['note']) ? trim($data['note']) : null;
$penaltyGoals = isset($data['penalty_goals']) && $data['penalty_goals'] !== '' ? (int) $data['penalty_goals'] : null;
$penaltyShots = isset($data['penalty_shots']) && $data['penalty_shots'] !== '' ? (int) $data['penalty_shots'] : null;
$yellowCards = isset($data['yellow_cards']) && $data['yellow_cards'] !== '' ? (int) $data['yellow_cards'] : null;
$twoMinSuspensions = isset($data['two_minute_suspensions']) && $data['two_minute_suspensions'] !== '' ? (int) $data['two_minute_suspensions'] : null;
$playingTime = isset($data['playing_time_minutes']) && $data['playing_time_minutes'] !== '' ? (int) $data['playing_time_minutes'] : null;
// Leere Einträge löschen // Leere Einträge löschen
if (! $isGk && $goals === null && $shots === null && $note === null && $position === null) { $hasData = $isGk || $goals !== null || $shots !== null || $note !== null || $position !== null
|| $penaltyGoals !== null || $penaltyShots !== null || $yellowCards !== null
|| $twoMinSuspensions !== null || $playingTime !== null;
if (! $hasData) {
EventPlayerStat::where('event_id', $event->id)->where('player_id', $playerId)->delete(); EventPlayerStat::where('event_id', $event->id)->where('player_id', $playerId)->delete();
continue; continue;
} }
@@ -258,6 +281,11 @@ class EventController extends Controller
'goalkeeper_shots' => $gkShots, 'goalkeeper_shots' => $gkShots,
'goals' => $goals, 'goals' => $goals,
'shots' => $shots, 'shots' => $shots,
'penalty_goals' => $penaltyGoals,
'penalty_shots' => $penaltyShots,
'yellow_cards' => $yellowCards,
'two_minute_suspensions' => $twoMinSuspensions,
'playing_time_minutes' => $playingTime,
'note' => $note, 'note' => $note,
] ]
); );

View File

@@ -14,6 +14,7 @@ use App\Models\EventParticipant;
use App\Models\EventPlayerStat; use App\Models\EventPlayerStat;
use App\Models\EventTimekeeper; use App\Models\EventTimekeeper;
use App\Models\Player; use App\Models\Player;
use App\Models\Season;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
@@ -32,6 +33,7 @@ class StatisticsController extends Controller
$request->validate([ $request->validate([
'team_id' => ['nullable', 'integer', 'exists:teams,id'], 'team_id' => ['nullable', 'integer', 'exists:teams,id'],
'season_id' => ['nullable', 'integer', 'exists:seasons,id'],
'from' => ['nullable', 'date'], 'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'], 'to' => ['nullable', 'date'],
]); ]);
@@ -49,12 +51,20 @@ class StatisticsController extends Controller
$query->where('team_id', $request->team_id); $query->where('team_id', $request->team_id);
} }
if ($request->filled('from')) { // Saison-Filter (hat Vorrang vor from/to)
$query->where('start_at', '>=', $request->from); $activeSeason = null;
} if ($request->filled('season_id')) {
$activeSeason = Season::find($request->season_id);
if ($request->filled('to')) { if ($activeSeason) {
$query->where('start_at', '<=', $request->to . ' 23:59:59'); $query->whereBetween('start_at', [$activeSeason->start_date, $activeSeason->end_date->endOfDay()]);
}
} elseif ($request->filled('from') || $request->filled('to')) {
if ($request->filled('from')) {
$query->where('start_at', '>=', $request->from);
}
if ($request->filled('to')) {
$query->where('start_at', '<=', $request->to . ' 23:59:59');
}
} }
$games = $query->orderByDesc('start_at')->get(); $games = $query->orderByDesc('start_at')->get();
@@ -93,7 +103,7 @@ class StatisticsController extends Controller
$chartWinLoss = [ $chartWinLoss = [
'labels' => [__('admin.wins'), __('admin.losses'), __('admin.draws')], 'labels' => [__('admin.wins'), __('admin.losses'), __('admin.draws')],
'data' => [$wins, $losses, $draws], 'data' => [$wins, $losses, $draws],
'colors' => ['#22c55e', '#ef4444', '#9ca3af'], 'colors' => ['#3e7750', '#8f504b', '#8e9db3'],
]; ];
// Spieler-Teilnahme pro Spiel (nur die letzten 15 Spiele) // Spieler-Teilnahme pro Spiel (nur die letzten 15 Spiele)
@@ -138,7 +148,12 @@ class StatisticsController extends Controller
DB::raw('COALESCE(SUM(goals), 0) as total_goals_agg'), DB::raw('COALESCE(SUM(goals), 0) as total_goals_agg'),
DB::raw('COALESCE(SUM(shots), 0) as total_shots_agg'), DB::raw('COALESCE(SUM(shots), 0) as total_shots_agg'),
DB::raw('COALESCE(SUM(goalkeeper_saves), 0) as total_gk_saves'), DB::raw('COALESCE(SUM(goalkeeper_saves), 0) as total_gk_saves'),
DB::raw('COALESCE(SUM(goalkeeper_shots), 0) as total_gk_shots') DB::raw('COALESCE(SUM(goalkeeper_shots), 0) as total_gk_shots'),
DB::raw('COALESCE(SUM(penalty_goals), 0) as total_penalty_goals'),
DB::raw('COALESCE(SUM(penalty_shots), 0) as total_penalty_shots'),
DB::raw('COALESCE(SUM(yellow_cards), 0) as total_yellow_cards'),
DB::raw('COALESCE(SUM(two_minute_suspensions), 0) as total_suspensions'),
DB::raw('AVG(playing_time_minutes) as avg_playing_time')
) )
->groupBy('player_id') ->groupBy('player_id')
->get() ->get()
@@ -195,6 +210,11 @@ class StatisticsController extends Controller
'is_primary_gk' => $isPrimaryGk, 'is_primary_gk' => $isPrimaryGk,
'performance_rate' => $performanceRate, 'performance_rate' => $performanceRate,
'performance_color' => $performanceColor, 'performance_color' => $performanceColor,
'total_penalty_goals' => $agg ? (int) $agg->total_penalty_goals : 0,
'total_penalty_shots' => $agg ? (int) $agg->total_penalty_shots : 0,
'total_yellow_cards' => $agg ? (int) $agg->total_yellow_cards : 0,
'total_suspensions' => $agg ? (int) $agg->total_suspensions : 0,
'avg_playing_time' => $agg && $agg->avg_playing_time ? (int) round($agg->avg_playing_time) : null,
]; ];
}) })
->filter() ->filter()
@@ -270,11 +290,14 @@ class StatisticsController extends Controller
$totalCateringEvents = $cateringEventIds->count(); $totalCateringEvents = $cateringEventIds->count();
$totalTimekeeperEvents = $timekeeperEventIds->count(); $totalTimekeeperEvents = $timekeeperEventIds->count();
$seasons = Season::orderByDesc('start_date')->get();
return view('admin.statistics.index', compact( return view('admin.statistics.index', compact(
'games', 'teams', 'wins', 'losses', 'draws', 'winRate', 'totalWithScore', 'games', 'teams', 'wins', 'losses', 'draws', 'winRate', 'totalWithScore',
'chartWinLoss', 'chartPlayerParticipation', 'chartParentInvolvement', 'chartWinLoss', 'chartPlayerParticipation', 'chartParentInvolvement',
'playerRanking', 'totalGames', 'courtPlayers', 'playerRanking', 'totalGames', 'courtPlayers',
'parentRanking', 'totalCateringEvents', 'totalTimekeeperEvents' 'parentRanking', 'totalCateringEvents', 'totalTimekeeperEvents',
'seasons', 'activeSeason'
)); ));
} }
@@ -296,6 +319,12 @@ class StatisticsController extends Controller
$gkGames = $stats->where('is_goalkeeper', true); $gkGames = $stats->where('is_goalkeeper', true);
$totalGkSaves = $gkGames->sum('goalkeeper_saves'); $totalGkSaves = $gkGames->sum('goalkeeper_saves');
$totalGkShots = $gkGames->sum('goalkeeper_shots'); $totalGkShots = $gkGames->sum('goalkeeper_shots');
$totalPenaltyGoals = $stats->sum('penalty_goals');
$totalPenaltyShots = $stats->sum('penalty_shots');
$totalYellowCards = $stats->sum('yellow_cards');
$totalSuspensions = $stats->sum('two_minute_suspensions');
$playingTimeStats = $stats->whereNotNull('playing_time_minutes');
$avgPlayingTime = $playingTimeStats->count() > 0 ? (int) round($playingTimeStats->avg('playing_time_minutes')) : null;
return response()->json([ return response()->json([
'player' => [ 'player' => [
@@ -312,6 +341,12 @@ class StatisticsController extends Controller
'total_saves' => $totalGkSaves, 'total_saves' => $totalGkSaves,
'total_gk_shots' => $totalGkShots, 'total_gk_shots' => $totalGkShots,
'save_rate' => $totalGkShots > 0 ? round(($totalGkSaves / $totalGkShots) * 100, 1) : null, 'save_rate' => $totalGkShots > 0 ? round(($totalGkSaves / $totalGkShots) * 100, 1) : null,
'total_penalty_goals' => $totalPenaltyGoals,
'total_penalty_shots' => $totalPenaltyShots,
'penalty_rate' => $totalPenaltyShots > 0 ? round(($totalPenaltyGoals / $totalPenaltyShots) * 100, 1) : null,
'total_yellow_cards' => $totalYellowCards,
'total_suspensions' => $totalSuspensions,
'avg_playing_time' => $avgPlayingTime,
], ],
'games' => $stats->map(fn ($s) => [ 'games' => $stats->map(fn ($s) => [
'date' => $s->event->start_at->format('d.m.Y'), 'date' => $s->event->start_at->format('d.m.Y'),
@@ -320,6 +355,11 @@ class StatisticsController extends Controller
'position' => $s->position?->shortLabel(), 'position' => $s->position?->shortLabel(),
'goals' => $s->goals, 'goals' => $s->goals,
'shots' => $s->shots, 'shots' => $s->shots,
'penalty_goals' => $s->penalty_goals,
'penalty_shots' => $s->penalty_shots,
'yellow_cards' => $s->yellow_cards,
'two_minute_suspensions' => $s->two_minute_suspensions,
'playing_time_minutes' => $s->playing_time_minutes,
'is_goalkeeper' => $s->is_goalkeeper, 'is_goalkeeper' => $s->is_goalkeeper,
'goalkeeper_saves' => $s->goalkeeper_saves, 'goalkeeper_saves' => $s->goalkeeper_saves,
'goalkeeper_shots' => $s->goalkeeper_shots, 'goalkeeper_shots' => $s->goalkeeper_shots,

View File

@@ -17,6 +17,11 @@ class EventPlayerStat extends Model
'goalkeeper_shots', 'goalkeeper_shots',
'goals', 'goals',
'shots', 'shots',
'penalty_goals',
'penalty_shots',
'yellow_cards',
'two_minute_suspensions',
'playing_time_minutes',
'note', 'note',
]; ];
@@ -27,6 +32,11 @@ class EventPlayerStat extends Model
'goalkeeper_shots' => 'integer', 'goalkeeper_shots' => 'integer',
'goals' => 'integer', 'goals' => 'integer',
'shots' => 'integer', 'shots' => 'integer',
'penalty_goals' => 'integer',
'penalty_shots' => 'integer',
'yellow_cards' => 'integer',
'two_minute_suspensions' => 'integer',
'playing_time_minutes' => 'integer',
]; ];
public function event(): BelongsTo public function event(): BelongsTo
@@ -63,6 +73,18 @@ class EventPlayerStat extends Model
return round(($this->goals / $this->shots) * 100, 1); return round(($this->goals / $this->shots) * 100, 1);
} }
/**
* 7-Meter-Quote in Prozent.
*/
public function penaltyRate(): ?float
{
if (! $this->penalty_shots || $this->penalty_shots === 0) {
return null;
}
return round(($this->penalty_goals / $this->penalty_shots) * 100, 1);
}
/** /**
* Prüft ob der Eintrag leer ist (keine relevanten Daten). * Prüft ob der Eintrag leer ist (keine relevanten Daten).
*/ */
@@ -73,6 +95,11 @@ class EventPlayerStat extends Model
&& ! $this->goalkeeper_shots && ! $this->goalkeeper_shots
&& ! $this->goals && ! $this->goals
&& ! $this->shots && ! $this->shots
&& ! $this->penalty_goals
&& ! $this->penalty_shots
&& ! $this->yellow_cards
&& ! $this->two_minute_suspensions
&& ! $this->playing_time_minutes
&& ! $this->note; && ! $this->note;
} }
} }

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('event_player_stats', function (Blueprint $table) {
$table->unsignedSmallInteger('penalty_goals')->nullable()->after('shots');
$table->unsignedSmallInteger('penalty_shots')->nullable()->after('penalty_goals');
$table->unsignedTinyInteger('yellow_cards')->nullable()->after('penalty_shots');
$table->unsignedTinyInteger('two_minute_suspensions')->nullable()->after('yellow_cards');
$table->unsignedSmallInteger('playing_time_minutes')->nullable()->after('two_minute_suspensions');
});
}
public function down(): void
{
Schema::table('event_player_stats', function (Blueprint $table) {
$table->dropColumn(['penalty_goals', 'penalty_shots', 'yellow_cards', 'two_minute_suspensions', 'playing_time_minutes']);
});
}
};

View File

@@ -89,6 +89,12 @@ return [
'stats_note' => 'ملاحظة', 'stats_note' => 'ملاحظة',
'stats_hit_rate' => 'نسبة الإصابة', 'stats_hit_rate' => 'نسبة الإصابة',
'stats_save_rate' => 'نسبة التصدي', 'stats_save_rate' => 'نسبة التصدي',
'stats_penalty_shots' => 'رميات 7م',
'stats_penalty_goals' => 'أهداف 7م',
'stats_yellow_cards' => 'بطاقة صفراء',
'stats_two_min' => '2 دقيقة',
'stats_playing_time_short' => 'دقيقة',
'stats_cards' => 'عقوبات',
'stats_no_data' => 'لا توجد بيانات إحصائية.', 'stats_no_data' => 'لا توجد بيانات إحصائية.',
'stats_position' => 'المركز', 'stats_position' => 'المركز',

View File

@@ -102,6 +102,12 @@ return [
'stats_note' => 'Bemerkung', 'stats_note' => 'Bemerkung',
'stats_hit_rate' => 'Trefferquote', 'stats_hit_rate' => 'Trefferquote',
'stats_save_rate' => 'Fangquote', 'stats_save_rate' => 'Fangquote',
'stats_penalty_shots' => '7m-Würfe',
'stats_penalty_goals' => '7m-Tore',
'stats_yellow_cards' => 'Gelbe K.',
'stats_two_min' => '2-Min',
'stats_playing_time_short' => 'Min.',
'stats_cards' => 'Strafen',
'stats_no_data' => 'Keine Statistikdaten.', 'stats_no_data' => 'Keine Statistikdaten.',
'stats_position' => 'Position', 'stats_position' => 'Position',

View File

@@ -88,6 +88,12 @@ return [
'stats_note' => 'Note', 'stats_note' => 'Note',
'stats_hit_rate' => 'Hit rate', 'stats_hit_rate' => 'Hit rate',
'stats_save_rate' => 'Save rate', 'stats_save_rate' => 'Save rate',
'stats_penalty_shots' => 'Pen. shots',
'stats_penalty_goals' => 'Pen. goals',
'stats_yellow_cards' => 'Yellow C.',
'stats_two_min' => '2-Min',
'stats_playing_time_short' => 'Min.',
'stats_cards' => 'Cards',
'stats_no_data' => 'No statistics data.', 'stats_no_data' => 'No statistics data.',
'stats_position' => 'Position', 'stats_position' => 'Position',

View File

@@ -89,6 +89,12 @@ return [
'stats_note' => 'Uwaga', 'stats_note' => 'Uwaga',
'stats_hit_rate' => 'Skuteczność', 'stats_hit_rate' => 'Skuteczność',
'stats_save_rate' => 'Skuteczność obron', 'stats_save_rate' => 'Skuteczność obron',
'stats_penalty_shots' => 'Rzuty 7m',
'stats_penalty_goals' => 'Bramki 7m',
'stats_yellow_cards' => 'Żółte k.',
'stats_two_min' => '2-Min',
'stats_playing_time_short' => 'Min.',
'stats_cards' => 'Kary',
'stats_no_data' => 'Brak danych statystycznych.', 'stats_no_data' => 'Brak danych statystycznych.',
'stats_position' => 'Pozycja', 'stats_position' => 'Pozycja',

View File

@@ -102,6 +102,12 @@ return [
'stats_note' => 'Примечание', 'stats_note' => 'Примечание',
'stats_hit_rate' => 'Процент попаданий', 'stats_hit_rate' => 'Процент попаданий',
'stats_save_rate' => 'Процент отражений', 'stats_save_rate' => 'Процент отражений',
'stats_penalty_shots' => 'Бр. 7м',
'stats_penalty_goals' => 'Голы 7м',
'stats_yellow_cards' => 'Жёлт. к.',
'stats_two_min' => '2 мин',
'stats_playing_time_short' => 'Мин.',
'stats_cards' => 'Наказания',
'stats_no_data' => 'Нет данных статистики.', 'stats_no_data' => 'Нет данных статистики.',
'stats_position' => 'Позиция', 'stats_position' => 'Позиция',

View File

@@ -89,6 +89,12 @@ return [
'stats_note' => 'Not', 'stats_note' => 'Not',
'stats_hit_rate' => 'İsabet oranı', 'stats_hit_rate' => 'İsabet oranı',
'stats_save_rate' => 'Kurtarış oranı', 'stats_save_rate' => 'Kurtarış oranı',
'stats_penalty_shots' => '7m atış',
'stats_penalty_goals' => '7m gol',
'stats_yellow_cards' => 'Sarı k.',
'stats_two_min' => '2 dk',
'stats_playing_time_short' => 'Dk.',
'stats_cards' => 'Cezalar',
'stats_no_data' => 'İstatistik verisi yok.', 'stats_no_data' => 'İstatistik verisi yok.',
'stats_position' => 'Pozisyon', 'stats_position' => 'Pozisyon',

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_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_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-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> <th class="text-left px-2 py-2 font-medium text-gray-600">{{ __('events.stats_note') }}</th>
</tr> </tr>
</thead> </thead>
@@ -436,6 +441,31 @@
value="{{ $stat?->goals }}" value="{{ $stat?->goals }}"
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm"> class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td> </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"> <td class="px-2 py-2">
<input type="text" name="stats[{{ $pid }}][note]" maxlength="500" <input type="text" name="stats[{{ $pid }}][note]" maxlength="500"
value="{{ $stat?->note }}" value="{{ $stat?->note }}"

View File

@@ -3,7 +3,7 @@
{{-- Filter --}} {{-- Filter --}}
<div class="bg-white rounded-lg shadow p-4 mb-6"> <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> <div>
<label for="team_id" class="block text-xs font-medium text-gray-600 mb-1">{{ __('ui.team') }}</label> <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"> <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 @endforeach
</select> </select>
</div> </div>
@if ($seasons->isNotEmpty())
<div> <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> <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"> <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> <div x-show="!useSeason" x-cloak>
<label for="to" class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.filter_to') }}</label> <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"> <input type="date" name="to" id="to" value="{{ request('to') }}" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
</div> </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> <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> <a href="{{ route('admin.statistics.index') }}" class="text-sm text-gray-500 hover:underline">{{ __('admin.filter_reset') }}</a>
@endif @endif
</form> </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.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_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">{{ __('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="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> <th class="px-4 py-2.5 font-medium text-gray-600 w-32"></th>
</tr> </tr>
@@ -167,7 +183,7 @@
@if (!$separatorShown && !$entry->is_primary_gk) @if (!$separatorShown && !$entry->is_primary_gk)
@php $separatorShown = true; @endphp @php $separatorShown = true; @endphp
@if ($index > 0) @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
@endif @endif
<tr class="hover:bg-gray-50 cursor-pointer" @click="openModal({{ $entry->player->id }})"> <tr class="hover:bg-gray-50 cursor-pointer" @click="openModal({{ $entry->player->id }})">
@@ -197,6 +213,32 @@
<span class="text-gray-300">0</span> <span class="text-gray-300">0</span>
@endif @endif
</td> </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"> <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') }}"> <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 }}% {{ $entry->rate }}%
@@ -234,31 +276,31 @@
'kreislaeufer' => ['x' => 200, 'y' => 180], 'kreislaeufer' => ['x' => 200, 'y' => 180],
]; ];
$colorMap = [ $colorMap = [
'green' => ['fill' => '#22c55e', 'text' => '#fff'], 'green' => ['fill' => '#305f3f', 'text' => '#fff'],
'yellow' => ['fill' => '#eab308', 'text' => '#fff'], 'yellow' => ['fill' => '#806130', 'text' => '#fff'],
'red' => ['fill' => '#ef4444', 'text' => '#fff'], 'red' => ['fill' => '#76403b', 'text' => '#fff'],
'gray' => ['fill' => '#9ca3af', 'text' => '#fff'], 'gray' => ['fill' => '#667788', 'text' => '#fff'],
]; ];
@endphp @endphp
<svg viewBox="0 0 400 320" class="w-full max-w-lg" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 400 320" class="w-full max-w-lg" xmlns="http://www.w3.org/2000/svg">
{{-- Spielfeld-Hintergrund --}} {{-- Spielfeld-Hintergrund --}}
<rect x="0" y="0" width="400" height="320" rx="8" fill="#16a34a" /> <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="#fff" stroke-width="1.5" opacity="0.5" /> <rect x="10" y="10" width="380" height="300" rx="4" fill="none" stroke="#000" stroke-width="1.5" opacity="0.4" />
{{-- Mittellinie --}} {{-- 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) --}} {{-- 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) --}} {{-- 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) --}} {{-- 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 --}} {{-- 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 --}} {{-- Spieler-Positionen --}}
@foreach ($courtPositions as $posValue => $coords) @foreach ($courtPositions as $posValue => $coords)
@@ -268,7 +310,7 @@
$posEnum = \App\Enums\PlayerPosition::tryFrom($posValue); $posEnum = \App\Enums\PlayerPosition::tryFrom($posValue);
@endphp @endphp
<g @if ($entry) @click="openModal({{ $entry->player->id }})" style="cursor: pointer;" @endif> <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) @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;"> <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() }} {{ $entry->player->jersey_number ?? $entry->player->getInitials() }}
@@ -282,7 +324,7 @@
</text> </text>
@endif @endif
{{-- Positions-Kürzel unter dem Kreis --}} {{-- 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() }} {{ $posEnum?->shortLabel() }}
</text> </text>
</g> </g>
@@ -372,6 +414,32 @@
</div> </div>
</div> </div>
</template> </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> </div>
</template> </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_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_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">{{ __('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> <th class="text-left px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_note') }}</th>
</tr> </tr>
</thead> </thead>
@@ -417,6 +488,20 @@
<span class="text-gray-300"></span> <span class="text-gray-300"></span>
</template> </template>
</td> </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> <td class="px-4 py-2 text-gray-500 text-xs" x-text="game.note ?? ''"></td>
</tr> </tr>
</template> </template>
@@ -564,7 +649,7 @@
datasets: [{ datasets: [{
label: @js(__('admin.nav_players')), label: @js(__('admin.nav_players')),
data: playerData.data, data: playerData.data,
backgroundColor: '#3b82f6', backgroundColor: '#4a5e7a',
borderRadius: 3, borderRadius: 3,
}] }]
}, },
@@ -586,13 +671,13 @@
{ {
label: @js(__('events.catering_short')), label: @js(__('events.catering_short')),
data: parentData.catering, data: parentData.catering,
backgroundColor: '#f59e0b', backgroundColor: '#99783a',
borderRadius: 3, borderRadius: 3,
}, },
{ {
label: @js(__('events.timekeeper_short')), label: @js(__('events.timekeeper_short')),
data: parentData.timekeepers, data: parentData.timekeepers,
backgroundColor: '#8b5cf6', backgroundColor: '#6b5a84',
borderRadius: 3, borderRadius: 3,
} }
] ]