Files
WebAPP/app/Http/Controllers/Admin/StatisticsController.php
Rhino ad60e7a9f9 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>
2026-03-02 11:47:34 +01:00

331 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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(),
]);
}
}