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

@@ -6,15 +6,18 @@ use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Enums\ParticipantStatus;
use App\Enums\PlayerPosition;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\EventCatering;
use App\Models\EventParticipant;
use App\Models\EventPlayerStat;
use App\Models\EventTimekeeper;
use App\Models\Player;
use App\Models\Setting;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
@@ -113,6 +116,34 @@ class StatisticsController extends Controller
$gameIds = $games->pluck('id');
$totalGames = $games->count();
// Tore pro Spieler aus event_player_stats
$goalsByPlayer = EventPlayerStat::whereIn('event_id', $gameIds)
->select('player_id', DB::raw('COALESCE(SUM(goals), 0) as total_goals'))
->groupBy('player_id')
->pluck('total_goals', 'player_id');
// Häufigste Position pro Spieler aus event_player_stats
$positionCounts = EventPlayerStat::whereIn('event_id', $gameIds)
->whereNotNull('position')
->select('player_id', 'position', DB::raw('COUNT(*) as cnt'))
->groupBy('player_id', 'position')
->get()
->groupBy('player_id')
->map(fn ($g) => $g->sortByDesc('cnt')->first()->position);
// Aggregierte Performance-Daten pro Spieler
$playerAggStats = EventPlayerStat::whereIn('event_id', $gameIds)
->select(
'player_id',
DB::raw('COALESCE(SUM(goals), 0) as total_goals_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_shots), 0) as total_gk_shots')
)
->groupBy('player_id')
->get()
->keyBy('player_id');
$playerRanking = collect();
if ($totalGames > 0) {
$playerRanking = EventParticipant::select('player_id', DB::raw('COUNT(*) as total_assigned'), DB::raw('SUM(CASE WHEN status = \'yes\' THEN 1 ELSE 0 END) as games_played'))
@@ -120,27 +151,67 @@ class StatisticsController extends Controller
->whereNotNull('player_id')
->groupBy('player_id')
->get()
->map(function ($row) use ($totalGames) {
->map(function ($row) use ($totalGames, $goalsByPlayer, $positionCounts, $playerAggStats) {
$player = Player::withTrashed()->find($row->player_id);
if (!$player) {
return null;
}
// Primäre Position: häufigste aus Stats, Fallback auf player.position
$primaryPosition = $positionCounts->get($row->player_id) ?? $player->position;
$isPrimaryGk = $primaryPosition?->isGoalkeeper() ?? false;
// Performance-Rate + Ampelfarbe berechnen
$agg = $playerAggStats->get($row->player_id);
$performanceRate = null;
$performanceColor = 'gray';
if ($agg) {
if ($isPrimaryGk) {
// Torwart: Fangquote
if ($agg->total_gk_shots > 0) {
$performanceRate = round(($agg->total_gk_saves / $agg->total_gk_shots) * 100, 1);
$performanceColor = $performanceRate >= 40 ? 'green' : ($performanceRate >= 25 ? 'yellow' : 'red');
}
} else {
// Feldspieler: Trefferquote
if ($agg->total_shots_agg > 0) {
$performanceRate = round(($agg->total_goals_agg / $agg->total_shots_agg) * 100, 1);
$performanceColor = $performanceRate >= 50 ? 'green' : ($performanceRate >= 30 ? 'yellow' : 'red');
}
}
}
return (object) [
'player' => $player,
'games_played' => (int) $row->games_played,
'total_assigned' => (int) $row->total_assigned,
'total_games' => $totalGames,
'total_goals' => (int) ($goalsByPlayer[$row->player_id] ?? 0),
'rate' => $row->total_assigned > 0
? round(($row->games_played / $row->total_assigned) * 100)
: 0,
'primary_position' => $primaryPosition,
'is_primary_gk' => $isPrimaryGk,
'performance_rate' => $performanceRate,
'performance_color' => $performanceColor,
];
})
->filter()
->sortByDesc('games_played')
->sortBy([
// Torwarte zuerst, dann Feldspieler
['is_primary_gk', 'desc'],
['games_played', 'desc'],
])
->values();
}
// Spielfeld-Aufstellung: Bester Spieler pro Position (meiste Spiele)
$courtPlayers = $playerRanking
->filter(fn ($e) => $e->primary_position !== null)
->groupBy(fn ($e) => $e->primary_position->value)
->map(fn ($group) => $group->sortByDesc('games_played')->first());
// ── Eltern-Engagement-Rangliste ────────────────────────
// Alle publizierten Events (nicht nur Spiele) mit gleichen Team/Datum-Filtern
$allEventsQuery = Event::where('status', EventStatus::Published);
@@ -202,8 +273,58 @@ class StatisticsController extends Controller
return view('admin.statistics.index', compact(
'games', 'teams', 'wins', 'losses', 'draws', 'winRate', 'totalWithScore',
'chartWinLoss', 'chartPlayerParticipation', 'chartParentInvolvement',
'playerRanking', 'totalGames',
'playerRanking', 'totalGames', 'courtPlayers',
'parentRanking', 'totalCateringEvents', 'totalTimekeeperEvents'
));
}
public function playerDetail(Player $player): JsonResponse
{
if (!Setting::isFeatureVisibleFor('statistics', auth()->user())) {
abort(403);
}
$stats = EventPlayerStat::where('player_id', $player->id)
->with(['event' => fn ($q) => $q->select('id', 'title', 'opponent', 'score_home', 'score_away', 'type', 'start_at', 'team_id')])
->whereHas('event', fn ($q) => $q->where('status', EventStatus::Published))
->get()
->sortByDesc(fn ($s) => $s->event->start_at)
->values();
$totalGoals = $stats->sum('goals');
$totalShots = $stats->sum('shots');
$gkGames = $stats->where('is_goalkeeper', true);
$totalGkSaves = $gkGames->sum('goalkeeper_saves');
$totalGkShots = $gkGames->sum('goalkeeper_shots');
return response()->json([
'player' => [
'name' => $player->full_name,
'avatar' => $player->getAvatarUrl(),
'initials' => $player->getInitials(),
'position' => $player->position?->label(),
],
'summary' => [
'total_goals' => $totalGoals,
'total_shots' => $totalShots,
'hit_rate' => $totalShots > 0 ? round(($totalGoals / $totalShots) * 100, 1) : null,
'gk_appearances' => $gkGames->count(),
'total_saves' => $totalGkSaves,
'total_gk_shots' => $totalGkShots,
'save_rate' => $totalGkShots > 0 ? round(($totalGkSaves / $totalGkShots) * 100, 1) : null,
],
'games' => $stats->map(fn ($s) => [
'date' => $s->event->start_at->format('d.m.Y'),
'opponent' => $s->event->opponent ?? '',
'score' => $s->event->score_home !== null ? $s->event->score_home . ':' . ($s->event->score_away ?? '?') : '',
'position' => $s->position?->shortLabel(),
'goals' => $s->goals,
'shots' => $s->shots,
'is_goalkeeper' => $s->is_goalkeeper,
'goalkeeper_saves' => $s->goalkeeper_saves,
'goalkeeper_shots' => $s->goalkeeper_shots,
'note' => $s->note,
])->toArray(),
]);
}
}