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:
@@ -31,6 +31,11 @@ enum EventType: string
|
||||
return !in_array($this, [self::AwayGame, self::Meeting]);
|
||||
}
|
||||
|
||||
public function hasCarpool(): bool
|
||||
{
|
||||
return $this !== self::Meeting;
|
||||
}
|
||||
|
||||
public function hasPlayerParticipants(): bool
|
||||
{
|
||||
return $this !== self::Meeting;
|
||||
|
||||
34
app/Enums/PlayerPosition.php
Normal file
34
app/Enums/PlayerPosition.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PlayerPosition: string
|
||||
{
|
||||
case Torwart = 'torwart';
|
||||
case LinksAussen = 'links_aussen';
|
||||
case RechtsAussen = 'rechts_aussen';
|
||||
case RueckraumLinks = 'rueckraum_links';
|
||||
case RueckraumMitte = 'rueckraum_mitte';
|
||||
case RueckraumRechts = 'rueckraum_rechts';
|
||||
case Kreislaeufer = 'kreislaeufer';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return __("ui.enums.player_position.{$this->value}");
|
||||
}
|
||||
|
||||
public function shortLabel(): string
|
||||
{
|
||||
return __("ui.enums.player_position_short.{$this->value}");
|
||||
}
|
||||
|
||||
public function isGoalkeeper(): bool
|
||||
{
|
||||
return $this === self::Torwart;
|
||||
}
|
||||
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ 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\ActivityLog;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCatering;
|
||||
use App\Models\EventPlayerStat;
|
||||
use App\Models\EventTimekeeper;
|
||||
use App\Models\File;
|
||||
use App\Models\FileCategory;
|
||||
@@ -120,13 +122,16 @@ class EventController extends Controller
|
||||
$participantRelations = $event->type === EventType::Meeting
|
||||
? ['participants.user']
|
||||
: ['participants.player'];
|
||||
$event->load(array_merge($participantRelations, ['caterings', 'timekeepers', 'files.category']));
|
||||
$event->load(array_merge($participantRelations, ['caterings', 'timekeepers', 'files.category', 'playerStats']));
|
||||
$assignedCatering = $event->caterings->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
|
||||
$assignedTimekeeper = $event->timekeepers->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
|
||||
$knownLocations = Location::orderBy('name')->get();
|
||||
$fileCategories = FileCategory::active()->ordered()->with(['files' => fn ($q) => $q->latest()])->get();
|
||||
|
||||
return view('admin.events.edit', compact('event', 'teams', 'types', 'statuses', 'teamParents', 'assignedCatering', 'assignedTimekeeper', 'eventDefaults', 'knownLocations', 'fileCategories'));
|
||||
// Spielerstatistik-Daten für Spieltypen
|
||||
$playerStatsMap = $event->playerStats->keyBy('player_id');
|
||||
|
||||
return view('admin.events.edit', compact('event', 'teams', 'types', 'statuses', 'teamParents', 'assignedCatering', 'assignedTimekeeper', 'eventDefaults', 'knownLocations', 'fileCategories', 'playerStatsMap'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Event $event): RedirectResponse
|
||||
@@ -210,6 +215,58 @@ class EventController extends Controller
|
||||
->with('success', __('admin.event_restored'));
|
||||
}
|
||||
|
||||
public function updateStats(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
if (! $event->type->isGameType()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'stats' => ['required', 'array'],
|
||||
'stats.*.is_goalkeeper' => ['nullable'],
|
||||
'stats.*.position' => ['nullable', 'string', \Illuminate\Validation\Rule::in(PlayerPosition::values())],
|
||||
'stats.*.goalkeeper_saves' => ['nullable', 'integer', 'min:0', 'max:999'],
|
||||
'stats.*.goalkeeper_shots' => ['nullable', 'integer', 'min:0', 'max:999'],
|
||||
'stats.*.goals' => ['nullable', 'integer', 'min:0', 'max:999'],
|
||||
'stats.*.shots' => ['nullable', 'integer', 'min:0', 'max:999'],
|
||||
'stats.*.note' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$stats = $request->input('stats', []);
|
||||
|
||||
foreach ($stats as $playerId => $data) {
|
||||
$position = ! empty($data['position']) ? $data['position'] : null;
|
||||
$isGk = $position === 'torwart' || ! empty($data['is_goalkeeper']);
|
||||
$goals = isset($data['goals']) && $data['goals'] !== '' ? (int) $data['goals'] : null;
|
||||
$shots = isset($data['shots']) && $data['shots'] !== '' ? (int) $data['shots'] : 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;
|
||||
$note = ! empty($data['note']) ? trim($data['note']) : null;
|
||||
|
||||
// Leere Einträge löschen
|
||||
if (! $isGk && $goals === null && $shots === null && $note === null && $position === null) {
|
||||
EventPlayerStat::where('event_id', $event->id)->where('player_id', $playerId)->delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
EventPlayerStat::updateOrCreate(
|
||||
['event_id' => $event->id, 'player_id' => (int) $playerId],
|
||||
[
|
||||
'is_goalkeeper' => $isGk,
|
||||
'position' => $position,
|
||||
'goalkeeper_saves' => $gkSaves,
|
||||
'goalkeeper_shots' => $gkShots,
|
||||
'goals' => $goals,
|
||||
'shots' => $shots,
|
||||
'note' => $note,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.events.edit', $event)
|
||||
->with('success', __('events.stats_saved'));
|
||||
}
|
||||
|
||||
private function validateEvent(Request $request): array
|
||||
{
|
||||
$request->validate([
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\PlayerPosition;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
@@ -78,6 +79,7 @@ class PlayerController extends Controller
|
||||
}],
|
||||
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
|
||||
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
|
||||
'position' => ['nullable', 'string', \Illuminate\Validation\Rule::in(PlayerPosition::values())],
|
||||
'is_active' => ['boolean'],
|
||||
'photo_permission' => ['boolean'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
@@ -120,6 +122,7 @@ class PlayerController extends Controller
|
||||
}],
|
||||
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
|
||||
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
|
||||
'position' => ['nullable', 'string', \Illuminate\Validation\Rule::in(PlayerPosition::values())],
|
||||
'photo_permission' => ['boolean'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
'profile_picture' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,gif,webp'],
|
||||
|
||||
@@ -91,28 +91,35 @@ class SettingsController extends Controller
|
||||
abort(403, 'Nur Admins koennen Einstellungen aendern.');
|
||||
}
|
||||
|
||||
// Favicon-Upload verarbeiten (vor der normalen Settings-Schleife)
|
||||
if ($request->hasFile('favicon')) {
|
||||
$request->validate([
|
||||
'favicon' => 'file|mimes:ico,png,svg,jpg,jpeg,gif,webp|max:512',
|
||||
]);
|
||||
// Bild-Uploads verarbeiten (vor der normalen Settings-Schleife)
|
||||
$imageUploads = [
|
||||
'favicon' => ['setting' => 'app_favicon', 'dir' => 'favicon', 'max' => 512],
|
||||
'logo_login' => ['setting' => 'app_logo_login', 'dir' => 'logos', 'max' => 1024],
|
||||
'logo_app' => ['setting' => 'app_logo_app', 'dir' => 'logos', 'max' => 1024],
|
||||
];
|
||||
|
||||
// Altes Favicon löschen
|
||||
$oldFavicon = Setting::get('app_favicon');
|
||||
if ($oldFavicon) {
|
||||
Storage::disk('public')->delete($oldFavicon);
|
||||
}
|
||||
foreach ($imageUploads as $field => $config) {
|
||||
if ($request->hasFile($field)) {
|
||||
$request->validate([
|
||||
$field => 'file|mimes:ico,png,svg,jpg,jpeg,gif,webp|max:' . $config['max'],
|
||||
]);
|
||||
|
||||
$file = $request->file('favicon');
|
||||
$filename = Str::uuid() . '.' . $file->guessExtension();
|
||||
$path = $file->storeAs('favicon', $filename, 'public');
|
||||
Setting::set('app_favicon', $path);
|
||||
} elseif ($request->has('remove_favicon')) {
|
||||
$oldFavicon = Setting::get('app_favicon');
|
||||
if ($oldFavicon) {
|
||||
Storage::disk('public')->delete($oldFavicon);
|
||||
$oldFile = Setting::get($config['setting']);
|
||||
if ($oldFile) {
|
||||
Storage::disk('public')->delete($oldFile);
|
||||
}
|
||||
|
||||
$file = $request->file($field);
|
||||
$filename = Str::uuid() . '.' . $file->guessExtension();
|
||||
$path = $file->storeAs($config['dir'], $filename, 'public');
|
||||
Setting::set($config['setting'], $path);
|
||||
} elseif ($request->has("remove_{$field}")) {
|
||||
$oldFile = Setting::get($config['setting']);
|
||||
if ($oldFile) {
|
||||
Storage::disk('public')->delete($oldFile);
|
||||
}
|
||||
Setting::set($config['setting'], null);
|
||||
}
|
||||
Setting::set('app_favicon', null);
|
||||
}
|
||||
|
||||
$inputSettings = $request->input('settings', []);
|
||||
@@ -310,6 +317,9 @@ class SettingsController extends Controller
|
||||
// Löschreihenfolge beachtet FK-Constraints
|
||||
DB::table('activity_logs')->delete();
|
||||
DB::table('comments')->delete();
|
||||
DB::table('event_player_stats')->delete();
|
||||
DB::table('event_carpool_passengers')->delete();
|
||||
DB::table('event_carpools')->delete();
|
||||
DB::table('event_participants')->delete();
|
||||
DB::table('event_catering')->delete();
|
||||
DB::table('event_timekeepers')->delete();
|
||||
@@ -364,6 +374,7 @@ class SettingsController extends Controller
|
||||
Storage::disk('private')->deleteDirectory('files');
|
||||
Storage::disk('public')->deleteDirectory('avatars');
|
||||
Storage::disk('public')->deleteDirectory('favicon');
|
||||
Storage::disk('public')->deleteDirectory('logos');
|
||||
Storage::disk('public')->deleteDirectory('dsgvo');
|
||||
|
||||
// 2. FK-Constraints deaktivieren (DB-agnostisch)
|
||||
@@ -376,7 +387,8 @@ class SettingsController extends Controller
|
||||
|
||||
// 3. Alle Tabellen leeren
|
||||
$tables = [
|
||||
'activity_logs', 'comments', 'event_participants',
|
||||
'activity_logs', 'comments', 'event_player_stats',
|
||||
'event_carpool_passengers', 'event_carpools', 'event_participants',
|
||||
'event_catering', 'event_timekeepers', 'event_faq',
|
||||
'event_file', 'events', 'parent_player', 'players',
|
||||
'team_user', 'team_file', 'teams',
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
176
app/Http/Controllers/CarpoolController.php
Normal file
176
app/Http/Controllers/CarpoolController.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCarpool;
|
||||
use App\Models\EventCarpoolPassenger;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CarpoolController extends Controller
|
||||
{
|
||||
public function offer(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$this->authorizeCarpool($event);
|
||||
|
||||
$request->validate([
|
||||
'seats' => 'required|integer|min:1|max:9',
|
||||
'note' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$carpool = EventCarpool::where('event_id', $event->id)
|
||||
->where('user_id', auth()->id())
|
||||
->first();
|
||||
|
||||
$isNew = !$carpool;
|
||||
|
||||
if (!$carpool) {
|
||||
$carpool = new EventCarpool(['event_id' => $event->id, 'seats' => $request->seats, 'note' => $request->note]);
|
||||
$carpool->user_id = auth()->id();
|
||||
} else {
|
||||
$carpool->seats = $request->seats;
|
||||
$carpool->note = $request->note;
|
||||
}
|
||||
|
||||
// Sitzplaetze duerfen nicht unter aktuelle Passagieranzahl fallen
|
||||
if (!$isNew && $carpool->passengers()->count() > $request->seats) {
|
||||
return redirect(route('events.show', $event) . '#carpool')
|
||||
->withErrors(['seats' => __('events.carpool_seats_too_few')]);
|
||||
}
|
||||
|
||||
$carpool->save();
|
||||
|
||||
ActivityLog::log(
|
||||
$isNew ? 'created' : 'updated',
|
||||
__('admin.log_carpool_offer', ['event' => $event->title, 'seats' => $request->seats]),
|
||||
'Event',
|
||||
$event->id
|
||||
);
|
||||
|
||||
return redirect(route('events.show', $event) . '#carpool');
|
||||
}
|
||||
|
||||
public function withdraw(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$this->authorizeCarpool($event);
|
||||
|
||||
$carpool = EventCarpool::where('event_id', $event->id)
|
||||
->where('user_id', auth()->id())
|
||||
->firstOrFail();
|
||||
|
||||
$passengerCount = $carpool->passengers()->count();
|
||||
$carpool->delete();
|
||||
|
||||
ActivityLog::log(
|
||||
'deleted',
|
||||
__('admin.log_carpool_withdrawn', ['event' => $event->title, 'passengers' => $passengerCount]),
|
||||
'Event',
|
||||
$event->id
|
||||
);
|
||||
|
||||
return redirect(route('events.show', $event) . '#carpool');
|
||||
}
|
||||
|
||||
public function join(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$this->authorizeCarpool($event);
|
||||
|
||||
$request->validate([
|
||||
'carpool_id' => 'required|integer',
|
||||
'player_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
$carpool = EventCarpool::where('id', $request->carpool_id)
|
||||
->where('event_id', $event->id)
|
||||
->firstOrFail();
|
||||
|
||||
$user = auth()->user();
|
||||
$player = $user->children()->where('players.id', $request->player_id)->first();
|
||||
|
||||
if (!$player && !$user->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Pruefen ob noch Plaetze frei
|
||||
if ($carpool->passengers()->count() >= $carpool->seats) {
|
||||
return redirect(route('events.show', $event) . '#carpool')
|
||||
->withErrors(['carpool' => __('events.carpool_full')]);
|
||||
}
|
||||
|
||||
// Pruefen ob Spieler nicht bereits in dieser Fahrt
|
||||
if (EventCarpoolPassenger::where('carpool_id', $carpool->id)->where('player_id', $request->player_id)->exists()) {
|
||||
return redirect(route('events.show', $event) . '#carpool');
|
||||
}
|
||||
|
||||
$passenger = new EventCarpoolPassenger(['carpool_id' => $carpool->id, 'player_id' => $request->player_id]);
|
||||
$passenger->added_by = auth()->id();
|
||||
$passenger->save();
|
||||
|
||||
$playerName = $player ? $player->full_name : "Spieler #{$request->player_id}";
|
||||
ActivityLog::log(
|
||||
'created',
|
||||
__('admin.log_carpool_joined', ['player' => $playerName, 'driver' => $carpool->driver->name, 'event' => $event->title]),
|
||||
'Event',
|
||||
$event->id
|
||||
);
|
||||
|
||||
return redirect(route('events.show', $event) . '#carpool');
|
||||
}
|
||||
|
||||
public function leave(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
$this->authorizeCarpool($event);
|
||||
|
||||
$request->validate([
|
||||
'carpool_id' => 'required|integer',
|
||||
'player_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
$passenger = EventCarpoolPassenger::where('carpool_id', $request->carpool_id)
|
||||
->where('player_id', $request->player_id)
|
||||
->firstOrFail();
|
||||
|
||||
$user = auth()->user();
|
||||
if ($passenger->added_by !== $user->id && !$user->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$carpool = $passenger->carpool;
|
||||
$playerName = $passenger->player->full_name ?? "Spieler #{$request->player_id}";
|
||||
$passenger->delete();
|
||||
|
||||
ActivityLog::log(
|
||||
'deleted',
|
||||
__('admin.log_carpool_left', ['player' => $playerName, 'driver' => $carpool->driver->name, 'event' => $event->title]),
|
||||
'Event',
|
||||
$event->id
|
||||
);
|
||||
|
||||
return redirect(route('events.show', $event) . '#carpool');
|
||||
}
|
||||
|
||||
private function authorizeCarpool(Event $event): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($event->status === EventStatus::Cancelled) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$event->type->hasCarpool()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (!$user->canAccessAdminPanel()) {
|
||||
if ($event->status === EventStatus::Draft) {
|
||||
abort(403);
|
||||
}
|
||||
if (!$user->accessibleTeamIds()->contains($event->team_id)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ class EventController extends Controller
|
||||
}
|
||||
|
||||
// Kinder einmal laden, für Zugriffsprüfung + Teilnahme-Buttons
|
||||
$userChildren = $user->children()->select('players.id', 'players.team_id')->get();
|
||||
$userChildren = $user->children()->select('players.id', 'players.team_id', 'players.first_name', 'players.last_name')->get();
|
||||
|
||||
// Zugriffsbeschraenkung: User muss Zugang zum Team haben (ueber accessibleTeamIds)
|
||||
if (!$user->canAccessAdminPanel()) {
|
||||
@@ -90,6 +90,10 @@ class EventController extends Controller
|
||||
if ($event->type->hasTimekeepers()) {
|
||||
$relations[] = 'timekeepers.user';
|
||||
}
|
||||
if ($event->type->hasCarpool()) {
|
||||
$relations[] = 'carpools.driver';
|
||||
$relations[] = 'carpools.passengers.player';
|
||||
}
|
||||
$event->load($relations);
|
||||
|
||||
$userChildIds = $userChildren->pluck('id');
|
||||
@@ -104,6 +108,11 @@ class EventController extends Controller
|
||||
? $event->timekeepers->where('user_id', $user->id)->first()
|
||||
: null;
|
||||
|
||||
// Eigene Fahrgemeinschaft
|
||||
$myCarpool = $event->type->hasCarpool()
|
||||
? $event->carpools->where('user_id', $user->id)->first()
|
||||
: null;
|
||||
|
||||
// Catering/Zeitnehmer-Verlauf für Staff (chronologische Statusänderungen)
|
||||
$cateringHistory = collect();
|
||||
$timekeeperHistory = collect();
|
||||
@@ -123,6 +132,6 @@ class EventController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
return view('events.show', compact('event', 'userChildIds', 'myCatering', 'myTimekeeper', 'cateringHistory', 'timekeeperHistory'));
|
||||
return view('events.show', compact('event', 'userChildIds', 'userChildren', 'myCatering', 'myTimekeeper', 'myCarpool', 'cateringHistory', 'timekeeperHistory'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,16 @@ class Event extends Model
|
||||
return $this->hasMany(EventTimekeeper::class);
|
||||
}
|
||||
|
||||
public function carpools(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventCarpool::class);
|
||||
}
|
||||
|
||||
public function playerStats(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventPlayerStat::class);
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
|
||||
36
app/Models/EventCarpool.php
Normal file
36
app/Models/EventCarpool.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class EventCarpool extends Model
|
||||
{
|
||||
protected $fillable = ['event_id', 'seats', 'note'];
|
||||
|
||||
protected $casts = [
|
||||
'seats' => 'integer',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function driver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function passengers(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventCarpoolPassenger::class, 'carpool_id');
|
||||
}
|
||||
|
||||
public function remainingSeats(): int
|
||||
{
|
||||
return $this->seats - $this->passengers->count();
|
||||
}
|
||||
}
|
||||
35
app/Models/EventCarpoolPassenger.php
Normal file
35
app/Models/EventCarpoolPassenger.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EventCarpoolPassenger extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['carpool_id', 'player_id'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $model) {
|
||||
$model->created_at = $model->freshTimestamp();
|
||||
});
|
||||
}
|
||||
|
||||
public function carpool(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EventCarpool::class, 'carpool_id');
|
||||
}
|
||||
|
||||
public function player(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Player::class);
|
||||
}
|
||||
|
||||
public function addedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'added_by');
|
||||
}
|
||||
}
|
||||
78
app/Models/EventPlayerStat.php
Normal file
78
app/Models/EventPlayerStat.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PlayerPosition;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EventPlayerStat extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'player_id',
|
||||
'is_goalkeeper',
|
||||
'position',
|
||||
'goalkeeper_saves',
|
||||
'goalkeeper_shots',
|
||||
'goals',
|
||||
'shots',
|
||||
'note',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_goalkeeper' => 'boolean',
|
||||
'position' => PlayerPosition::class,
|
||||
'goalkeeper_saves' => 'integer',
|
||||
'goalkeeper_shots' => 'integer',
|
||||
'goals' => 'integer',
|
||||
'shots' => 'integer',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function player(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Player::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fangquote in Prozent (nur für Torwart).
|
||||
*/
|
||||
public function saveRate(): ?float
|
||||
{
|
||||
if (! $this->is_goalkeeper || ! $this->goalkeeper_shots || $this->goalkeeper_shots === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(($this->goalkeeper_saves / $this->goalkeeper_shots) * 100, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trefferquote in Prozent.
|
||||
*/
|
||||
public function hitRate(): ?float
|
||||
{
|
||||
if (! $this->shots || $this->shots === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(($this->goals / $this->shots) * 100, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der Eintrag leer ist (keine relevanten Daten).
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return ! $this->is_goalkeeper
|
||||
&& ! $this->goalkeeper_saves
|
||||
&& ! $this->goalkeeper_shots
|
||||
&& ! $this->goals
|
||||
&& ! $this->shots
|
||||
&& ! $this->note;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PlayerPosition;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
@@ -17,6 +18,7 @@ class Player extends Model
|
||||
'last_name',
|
||||
'birth_year',
|
||||
'jersey_number',
|
||||
'position',
|
||||
'is_active',
|
||||
'photo_permission',
|
||||
'notes',
|
||||
@@ -28,6 +30,7 @@ class Player extends Model
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'photo_permission' => 'boolean',
|
||||
'position' => PlayerPosition::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user