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'])); $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')); } 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')); } 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, ] ); } }