Stand: SMTP-Test, Admin-Mail-Tab, Notifiable-Fix, Lazy-Quill
- Fix: Notifiable-Trait zum User-Model hinzugefuegt (behebt notify()-500er) - Installer: SMTP-Verbindungstest mit EsmtpTransport + Ueberspringen-Link - Admin: Neuer E-Mail-Tab mit SMTP-Konfiguration + Verbindungstest - Admin: Lazy Quill-Initialisierung (nur sichtbare Locale wird geladen) - Uebersetzungen: 17 neue Mail-Keys in allen 6 Sprachen Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
432
app/Http/Controllers/Admin/EventController.php
Executable file
432
app/Http/Controllers/Admin/EventController.php
Executable file
@@ -0,0 +1,432 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\CateringStatus;
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventType;
|
||||
use App\Enums\ParticipantStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCatering;
|
||||
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']));
|
||||
$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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user