- 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>
331 lines
14 KiB
PHP
331 lines
14 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Admin;
|
||
|
||
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;
|
||
|
||
class StatisticsController extends Controller
|
||
{
|
||
public function index(Request $request): View
|
||
{
|
||
if (!Setting::isFeatureVisibleFor('statistics', auth()->user())) {
|
||
abort(403);
|
||
}
|
||
|
||
$request->validate([
|
||
'team_id' => ['nullable', 'integer', 'exists:teams,id'],
|
||
'from' => ['nullable', 'date'],
|
||
'to' => ['nullable', 'date'],
|
||
]);
|
||
|
||
$query = Event::with(['team'])
|
||
->withCount([
|
||
'participants as players_yes_count' => fn ($q) => $q->where('status', 'yes'),
|
||
'caterings as caterings_yes_count' => fn ($q) => $q->where('status', 'yes'),
|
||
'timekeepers as timekeepers_yes_count' => fn ($q) => $q->where('status', 'yes'),
|
||
])
|
||
->whereIn('type', [EventType::HomeGame, EventType::AwayGame])
|
||
->where('status', EventStatus::Published);
|
||
|
||
if ($request->filled('team_id')) {
|
||
$query->where('team_id', $request->team_id);
|
||
}
|
||
|
||
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();
|
||
|
||
// Statistiken berechnen
|
||
$gamesWithScore = $games->filter(fn ($g) => $g->score_home !== null && $g->score_away !== null);
|
||
|
||
$wins = 0;
|
||
$losses = 0;
|
||
$draws = 0;
|
||
|
||
foreach ($gamesWithScore as $game) {
|
||
if ($game->type === EventType::HomeGame) {
|
||
if ($game->score_home > $game->score_away) {
|
||
$wins++;
|
||
} elseif ($game->score_home < $game->score_away) {
|
||
$losses++;
|
||
} else {
|
||
$draws++;
|
||
}
|
||
} else {
|
||
if ($game->score_away > $game->score_home) {
|
||
$wins++;
|
||
} elseif ($game->score_away < $game->score_home) {
|
||
$losses++;
|
||
} else {
|
||
$draws++;
|
||
}
|
||
}
|
||
}
|
||
|
||
$totalWithScore = $gamesWithScore->count();
|
||
$winRate = $totalWithScore > 0 ? round(($wins / $totalWithScore) * 100) : 0;
|
||
|
||
// Chart-Daten
|
||
$chartWinLoss = [
|
||
'labels' => [__('admin.wins'), __('admin.losses'), __('admin.draws')],
|
||
'data' => [$wins, $losses, $draws],
|
||
'colors' => ['#22c55e', '#ef4444', '#9ca3af'],
|
||
];
|
||
|
||
// Spieler-Teilnahme pro Spiel (nur die letzten 15 Spiele)
|
||
$recentGames = $games->take(15)->reverse()->values();
|
||
$chartPlayerParticipation = [
|
||
'labels' => $recentGames->map(fn ($g) => $g->start_at->format('d.m.'))->toArray(),
|
||
'data' => $recentGames->map(fn ($g) => $g->players_yes_count)->toArray(),
|
||
];
|
||
|
||
// Eltern-Engagement (Catering + Zeitnehmer)
|
||
$chartParentInvolvement = [
|
||
'labels' => $recentGames->map(fn ($g) => $g->start_at->format('d.m.'))->toArray(),
|
||
'catering' => $recentGames->map(fn ($g) => $g->caterings_yes_count)->toArray(),
|
||
'timekeepers' => $recentGames->map(fn ($g) => $g->timekeepers_yes_count)->toArray(),
|
||
];
|
||
|
||
$teams = Team::where('is_active', true)->orderBy('name')->get();
|
||
|
||
// ── Spieler-Rangliste ──────────────────────────────────
|
||
$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'))
|
||
->whereIn('event_id', $gameIds)
|
||
->whereNotNull('player_id')
|
||
->groupBy('player_id')
|
||
->get()
|
||
->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()
|
||
->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);
|
||
|
||
if ($request->filled('team_id')) {
|
||
$allEventsQuery->where('team_id', $request->team_id);
|
||
}
|
||
if ($request->filled('from')) {
|
||
$allEventsQuery->where('start_at', '>=', $request->from);
|
||
}
|
||
if ($request->filled('to')) {
|
||
$allEventsQuery->where('start_at', '<=', $request->to . ' 23:59:59');
|
||
}
|
||
|
||
$allEventIds = $allEventsQuery->pluck('id');
|
||
|
||
// Catering-Events (nur Typen die Catering haben)
|
||
$cateringEventIds = $allEventsQuery->clone()
|
||
->whereNotIn('type', [EventType::AwayGame, EventType::Meeting])
|
||
->pluck('id');
|
||
|
||
// Zeitnehmer-Events (identisch wie Catering)
|
||
$timekeeperEventIds = $cateringEventIds;
|
||
|
||
$cateringCounts = EventCatering::select('user_id', DB::raw('COUNT(*) as count'))
|
||
->whereIn('event_id', $cateringEventIds)
|
||
->where('status', CateringStatus::Yes)
|
||
->groupBy('user_id')
|
||
->pluck('count', 'user_id');
|
||
|
||
$timekeeperCounts = EventTimekeeper::select('user_id', DB::raw('COUNT(*) as count'))
|
||
->whereIn('event_id', $timekeeperEventIds)
|
||
->where('status', CateringStatus::Yes)
|
||
->groupBy('user_id')
|
||
->pluck('count', 'user_id');
|
||
|
||
$parentUserIds = $cateringCounts->keys()->merge($timekeeperCounts->keys())->unique();
|
||
|
||
$parentRanking = User::withTrashed()
|
||
->whereIn('id', $parentUserIds)
|
||
->get()
|
||
->map(function ($user) use ($cateringCounts, $timekeeperCounts) {
|
||
$catering = $cateringCounts->get($user->id, 0);
|
||
$timekeeper = $timekeeperCounts->get($user->id, 0);
|
||
|
||
return (object) [
|
||
'user' => $user,
|
||
'catering_count' => $catering,
|
||
'timekeeper_count' => $timekeeper,
|
||
'total' => $catering + $timekeeper,
|
||
];
|
||
})
|
||
->sortByDesc('total')
|
||
->values();
|
||
|
||
$totalCateringEvents = $cateringEventIds->count();
|
||
$totalTimekeeperEvents = $timekeeperEventIds->count();
|
||
|
||
return view('admin.statistics.index', compact(
|
||
'games', 'teams', 'wins', 'losses', 'draws', 'winRate', 'totalWithScore',
|
||
'chartWinLoss', 'chartPlayerParticipation', 'chartParentInvolvement',
|
||
'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(),
|
||
]);
|
||
}
|
||
}
|