- 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>
490 lines
20 KiB
PHP
Executable File
490 lines
20 KiB
PHP
Executable File
<?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\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;
|
|
use App\Models\Location;
|
|
use App\Models\Setting;
|
|
use App\Models\Team;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use App\Services\HtmlSanitizerService;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\View\View;
|
|
|
|
class EventController extends Controller
|
|
{
|
|
public function __construct(private HtmlSanitizerService $sanitizer) {}
|
|
|
|
public function index(Request $request): View
|
|
{
|
|
$query = Event::with(['team', 'participants'])
|
|
->withCount([
|
|
'caterings as caterings_yes_count' => fn ($q) => $q->where('status', 'yes'),
|
|
'timekeepers as timekeepers_yes_count' => fn ($q) => $q->where('status', 'yes'),
|
|
])
|
|
->latest('start_at');
|
|
|
|
// Team-Scoping: Coach/ParentRep sehen nur eigene Teams (V04)
|
|
$user = auth()->user();
|
|
if (!$user->isAdmin()) {
|
|
$teamIds = $user->isCoach()
|
|
? $user->coachTeams()->pluck('teams.id')
|
|
: $user->accessibleTeamIds();
|
|
$query->whereIn('team_id', $teamIds);
|
|
}
|
|
|
|
if ($request->filled('team_id')) {
|
|
$query->forTeam($request->team_id);
|
|
}
|
|
|
|
if ($request->filled('status')) {
|
|
$query->where('status', $request->status);
|
|
}
|
|
|
|
$events = $query->paginate(20)->withQueryString();
|
|
$teams = Team::active()->orderBy('name')->get();
|
|
$trashedEvents = Event::onlyTrashed()->with('team')->latest('deleted_at')->get();
|
|
|
|
return view('admin.events.index', compact('events', 'teams', 'trashedEvents'));
|
|
}
|
|
|
|
public function create(): View
|
|
{
|
|
$teams = Team::active()->orderBy('name')->get();
|
|
$types = EventType::cases();
|
|
$statuses = EventStatus::cases();
|
|
$teamParents = $this->getTeamParents();
|
|
$eventDefaults = $this->getEventDefaults();
|
|
$knownLocations = Location::orderBy('name')->get();
|
|
|
|
$fileCategories = FileCategory::active()->ordered()->with(['files' => fn ($q) => $q->latest()])->get();
|
|
|
|
return view('admin.events.create', compact('teams', 'types', 'statuses', 'teamParents', 'eventDefaults', 'knownLocations', 'fileCategories'));
|
|
}
|
|
|
|
public function store(Request $request): RedirectResponse
|
|
{
|
|
$validated = $this->validateEvent($request);
|
|
|
|
$validated['description_html'] = $this->sanitizer->sanitize($validated['description_html'] ?? '');
|
|
$this->normalizeMinFields($validated);
|
|
|
|
$event = Event::create($validated);
|
|
$event->created_by = $request->user()->id;
|
|
$event->updated_by = $request->user()->id;
|
|
$event->save();
|
|
|
|
$this->createParticipantsForTeam($event);
|
|
$this->syncAssignments($event, $request);
|
|
$this->saveKnownLocation($validated, $request->input('location_name'));
|
|
$this->syncEventFiles($event, $request);
|
|
|
|
ActivityLog::logWithChanges('created', __('admin.log_event_created', ['title' => $event->title]), 'Event', $event->id, null, ['title' => $event->title, 'team' => $event->team->name ?? '', 'type' => $event->type->value, 'status' => $event->status->value]);
|
|
|
|
return redirect()->route('admin.events.index')
|
|
->with('success', __('admin.event_created'));
|
|
}
|
|
|
|
public function edit(Event $event): View
|
|
{
|
|
// Team-Scoping: Nicht-Admins dürfen nur Events ihrer Teams sehen (V04)
|
|
$user = auth()->user();
|
|
if (!$user->isAdmin()) {
|
|
$teamIds = $user->isCoach()
|
|
? $user->coachTeams()->pluck('teams.id')->toArray()
|
|
: $user->accessibleTeamIds()->toArray();
|
|
if (!in_array($event->team_id, $teamIds)) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
$teams = Team::active()->orderBy('name')->get();
|
|
$types = EventType::cases();
|
|
$statuses = EventStatus::cases();
|
|
$teamParents = $this->getTeamParents();
|
|
$eventDefaults = $this->getEventDefaults();
|
|
|
|
$event->syncParticipants(auth()->id());
|
|
$participantRelations = $event->type === EventType::Meeting
|
|
? ['participants.user']
|
|
: ['participants.player'];
|
|
$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();
|
|
|
|
// 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
|
|
{
|
|
$validated = $this->validateEvent($request);
|
|
|
|
$validated['description_html'] = $this->sanitizer->sanitize($validated['description_html'] ?? '');
|
|
$this->normalizeMinFields($validated);
|
|
|
|
$oldData = ['title' => $event->title, 'team_id' => $event->team_id, 'type' => $event->type->value, 'status' => $event->status->value, 'start_at' => $event->start_at?->toDateTimeString()];
|
|
|
|
$oldTeamId = $event->team_id;
|
|
$event->update($validated);
|
|
$event->updated_by = $request->user()->id;
|
|
$event->save();
|
|
|
|
if ($oldTeamId !== (int) $validated['team_id']) {
|
|
$event->participants()->delete();
|
|
$this->createParticipantsForTeam($event);
|
|
} else {
|
|
$event->syncParticipants($request->user()->id);
|
|
}
|
|
|
|
$this->syncAssignments($event, $request);
|
|
$this->saveKnownLocation($validated, $request->input('location_name'));
|
|
$this->syncEventFiles($event, $request);
|
|
|
|
$newData = ['title' => $event->title, 'team_id' => $event->team_id, 'type' => $event->type->value, 'status' => $event->status->value, 'start_at' => $event->start_at?->toDateTimeString()];
|
|
ActivityLog::logWithChanges('updated', __('admin.log_event_updated', ['title' => $event->title]), 'Event', $event->id, $oldData, $newData);
|
|
|
|
return redirect()->route('admin.events.index')
|
|
->with('success', __('admin.event_updated'));
|
|
}
|
|
|
|
public function updateParticipant(Request $request, Event $event)
|
|
{
|
|
$validated = $request->validate([
|
|
'participant_id' => ['required', 'integer'],
|
|
'status' => ['required', 'in:yes,no,unknown'],
|
|
]);
|
|
|
|
$participant = $event->participants()->where('id', $validated['participant_id'])->firstOrFail();
|
|
$oldStatus = $participant->status->value;
|
|
|
|
$participant->status = $validated['status'];
|
|
$participant->set_by_user_id = $request->user()->id;
|
|
$participant->responded_at = now();
|
|
$participant->save();
|
|
|
|
$participantLabel = $participant->user_id
|
|
? ($participant->user?->name ?? '')
|
|
: ($participant->player?->full_name ?? '');
|
|
ActivityLog::logWithChanges('participant_status_changed', __('admin.log_participant_changed', ['event' => $event->title, 'status' => $validated['status']]), 'Event', $event->id, ['status' => $oldStatus, 'player' => $participantLabel], ['status' => $validated['status']]);
|
|
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
public function destroy(Event $event): RedirectResponse
|
|
{
|
|
ActivityLog::logWithChanges('deleted', __('admin.log_event_deleted', ['title' => $event->title]), 'Event', $event->id, ['title' => $event->title, 'team' => $event->team->name ?? ''], null);
|
|
|
|
$event->deleted_by = auth()->id();
|
|
$event->save();
|
|
$event->delete();
|
|
|
|
return redirect()->route('admin.events.index')
|
|
->with('success', __('admin.event_deleted'));
|
|
}
|
|
|
|
public function restore(int $id): RedirectResponse
|
|
{
|
|
$event = Event::onlyTrashed()->findOrFail($id);
|
|
|
|
$event->deleted_by = null;
|
|
$event->save();
|
|
$event->restore();
|
|
|
|
ActivityLog::logWithChanges('restored', __('admin.log_event_restored', ['title' => $event->title]), 'Event', $event->id, null, ['title' => $event->title, 'team' => $event->team->name ?? '']);
|
|
|
|
return redirect()->route('admin.events.index')
|
|
->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([
|
|
'catering_users' => ['nullable', 'array'],
|
|
'catering_users.*' => ['integer', 'exists:users,id'],
|
|
'timekeeper_users' => ['nullable', 'array'],
|
|
'timekeeper_users.*' => ['integer', 'exists:users,id'],
|
|
'existing_files' => ['nullable', 'array'],
|
|
'existing_files.*' => ['integer', 'exists:files,id'],
|
|
'new_files' => ['nullable', 'array'],
|
|
'new_files.*' => ['file', 'max:10240', 'mimes:pdf,docx,xlsx,jpg,jpeg,png,gif,webp'],
|
|
'new_file_categories' => ['nullable', 'array'],
|
|
'new_file_categories.*' => ['integer', 'exists:file_categories,id'],
|
|
]);
|
|
|
|
$validated = $request->validate([
|
|
'team_id' => ['required', 'integer', 'exists:teams,id', function ($attribute, $value, $fail) {
|
|
$team = Team::find($value);
|
|
if (!$team || !$team->is_active) {
|
|
$fail(__('validation.exists', ['attribute' => $attribute]));
|
|
}
|
|
}],
|
|
'type' => ['required', 'in:' . implode(',', array_column(EventType::cases(), 'value'))],
|
|
'title' => ['required', 'string', 'max:255'],
|
|
'start_date' => ['required', 'date'],
|
|
'start_time' => ['required', 'date_format:H:i'],
|
|
'status' => ['required', 'in:' . implode(',', array_column(EventStatus::cases(), 'value'))],
|
|
'location_name' => ['nullable', 'string', 'max:255'],
|
|
'address_text' => ['nullable', 'string', 'max:500'],
|
|
'location_lat' => ['nullable', 'numeric', 'between:-90,90'],
|
|
'location_lng' => ['nullable', 'numeric', 'between:-180,180'],
|
|
'description_html' => ['nullable', 'string', 'max:50000'],
|
|
'min_players' => ['nullable', 'integer', 'min:0', 'max:30'],
|
|
'min_catering' => ['nullable', 'integer', 'min:0', 'max:8'],
|
|
'min_timekeepers' => ['nullable', 'integer', 'min:0', 'max:8'],
|
|
'opponent' => ['nullable', 'string', 'max:100'],
|
|
'score_home' => ['nullable', 'integer', 'min:0', 'max:99'],
|
|
'score_away' => ['nullable', 'integer', 'min:0', 'max:99'],
|
|
]);
|
|
|
|
// Datum und Uhrzeit zusammenführen
|
|
$validated['start_at'] = $validated['start_date'] . ' ' . $validated['start_time'];
|
|
$validated['end_at'] = null;
|
|
unset($validated['start_date'], $validated['start_time']);
|
|
|
|
return $validated;
|
|
}
|
|
|
|
private function createParticipantsForTeam(Event $event): void
|
|
{
|
|
if ($event->type === EventType::Meeting) {
|
|
$event->syncMeetingParticipants(auth()->id());
|
|
return;
|
|
}
|
|
|
|
$activePlayers = $event->team->activePlayers;
|
|
$userId = auth()->id();
|
|
|
|
$records = $activePlayers->map(fn ($player) => [
|
|
'event_id' => $event->id,
|
|
'player_id' => $player->id,
|
|
'status' => ParticipantStatus::Unknown->value,
|
|
'set_by_user_id' => $userId,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
])->toArray();
|
|
|
|
if (!empty($records)) {
|
|
$event->participants()->insert($records);
|
|
}
|
|
}
|
|
|
|
private function syncAssignments(Event $event, Request $request): void
|
|
{
|
|
// Auswärtsspiele und Besprechungen haben kein Catering/Zeitnehmer
|
|
if (!$event->type->hasCatering() && !$event->type->hasTimekeepers()) {
|
|
return;
|
|
}
|
|
|
|
$cateringUsers = $request->input('catering_users', []);
|
|
$timekeeperUsers = $request->input('timekeeper_users', []);
|
|
|
|
// Catering: set assigned users to Yes, remove unassigned admin-set entries
|
|
$event->caterings()->whereNotIn('user_id', $cateringUsers)->where('status', CateringStatus::Yes)->delete();
|
|
foreach ($cateringUsers as $userId) {
|
|
$catering = EventCatering::where('event_id', $event->id)->where('user_id', $userId)->first();
|
|
if (!$catering) {
|
|
$catering = new EventCatering(['event_id' => $event->id]);
|
|
$catering->user_id = $userId;
|
|
}
|
|
$catering->status = CateringStatus::Yes;
|
|
$catering->save();
|
|
}
|
|
|
|
// Timekeeper: same pattern
|
|
$event->timekeepers()->whereNotIn('user_id', $timekeeperUsers)->where('status', CateringStatus::Yes)->delete();
|
|
foreach ($timekeeperUsers as $userId) {
|
|
$timekeeper = EventTimekeeper::where('event_id', $event->id)->where('user_id', $userId)->first();
|
|
if (!$timekeeper) {
|
|
$timekeeper = new EventTimekeeper(['event_id' => $event->id]);
|
|
$timekeeper->user_id = $userId;
|
|
}
|
|
$timekeeper->status = CateringStatus::Yes;
|
|
$timekeeper->save();
|
|
}
|
|
}
|
|
|
|
private function normalizeMinFields(array &$validated): void
|
|
{
|
|
foreach (['min_players', 'min_catering', 'min_timekeepers'] as $field) {
|
|
$validated[$field] = isset($validated[$field]) && $validated[$field] !== '' ? (int) $validated[$field] : null;
|
|
}
|
|
|
|
// Auswärtsspiele und Besprechungen: kein Catering/Zeitnehmer
|
|
$type = EventType::tryFrom($validated['type'] ?? '');
|
|
if ($type && !$type->hasCatering()) {
|
|
$validated['min_catering'] = null;
|
|
}
|
|
if ($type && !$type->hasTimekeepers()) {
|
|
$validated['min_timekeepers'] = null;
|
|
}
|
|
|
|
// Nicht-Spiel-Typen: kein Gegner/Ergebnis
|
|
if ($type && !$type->isGameType()) {
|
|
$validated['opponent'] = null;
|
|
$validated['score_home'] = null;
|
|
$validated['score_away'] = null;
|
|
}
|
|
}
|
|
|
|
private function getEventDefaults(): array
|
|
{
|
|
$defaults = [];
|
|
foreach (['home_game', 'away_game', 'training', 'tournament', 'meeting', 'other'] as $type) {
|
|
$defaults[$type] = [
|
|
'min_players' => Setting::get("default_min_players_{$type}"),
|
|
'min_catering' => Setting::get("default_min_catering_{$type}"),
|
|
'min_timekeepers' => Setting::get("default_min_timekeepers_{$type}"),
|
|
];
|
|
}
|
|
return $defaults;
|
|
}
|
|
|
|
private function getTeamParents(): array
|
|
{
|
|
return Team::active()->with(['players' => fn ($q) => $q->active(), 'players.parents' => fn ($q) => $q->active()])
|
|
->get()
|
|
->mapWithKeys(fn ($team) => [
|
|
$team->id => $team->players->flatMap(fn ($p) => $p->parents)->unique('id')
|
|
->map(fn ($u) => ['id' => $u->id, 'name' => $u->name])
|
|
->values()
|
|
->toArray(),
|
|
])
|
|
->toArray();
|
|
}
|
|
|
|
private function syncEventFiles(Event $event, Request $request): void
|
|
{
|
|
// Attach existing files from library
|
|
$existingFileIds = $request->input('existing_files', []);
|
|
|
|
// Upload new files
|
|
$newFileIds = [];
|
|
$newFiles = $request->file('new_files', []);
|
|
$newCategories = $request->input('new_file_categories', []);
|
|
|
|
foreach ($newFiles as $index => $uploadedFile) {
|
|
if (!$uploadedFile || !$uploadedFile->isValid()) {
|
|
continue;
|
|
}
|
|
|
|
$categoryId = $newCategories[$index] ?? null;
|
|
if (!$categoryId) {
|
|
continue;
|
|
}
|
|
|
|
$extension = $uploadedFile->guessExtension();
|
|
$storedName = Str::uuid() . '.' . $extension;
|
|
|
|
Storage::disk('local')->putFileAs('files', $uploadedFile, $storedName);
|
|
|
|
$file = new File([
|
|
'file_category_id' => $categoryId,
|
|
'original_name' => $uploadedFile->getClientOriginalName(),
|
|
'mime_type' => $uploadedFile->getClientMimeType(),
|
|
'size' => $uploadedFile->getSize(),
|
|
]);
|
|
$file->stored_name = $storedName;
|
|
$file->disk = 'private';
|
|
$file->uploaded_by = auth()->id();
|
|
$file->save();
|
|
|
|
$newFileIds[] = $file->id;
|
|
}
|
|
|
|
// Merge existing + new file IDs and sync
|
|
$allFileIds = array_merge(
|
|
array_map('intval', $existingFileIds),
|
|
$newFileIds
|
|
);
|
|
|
|
$event->files()->sync($allFileIds);
|
|
}
|
|
|
|
private function saveKnownLocation(array $validated, ?string $locationName): void
|
|
{
|
|
if (empty($locationName) || empty($validated['address_text'])) {
|
|
return;
|
|
}
|
|
|
|
Location::updateOrCreate(
|
|
['name' => $locationName],
|
|
[
|
|
'address_text' => $validated['address_text'],
|
|
'location_lat' => $validated['location_lat'] ?? null,
|
|
'location_lng' => $validated['location_lng'] ?? null,
|
|
]
|
|
);
|
|
}
|
|
}
|