- Administration & Rollenmanagement: Neuer Admin-Bereich mit Feature-Toggles und Sichtbarkeitseinstellungen pro Rolle (11 Toggles, 24 Visibility-Settings) - AdministrationController mit eigenem Settings-Tab, aus SettingsController extrahiert - Feature-Toggle-Guards in Controllers (Invitation, File, ListGenerator, Comment) und Views (events/show, events/edit, events/create) - Setting::isFeatureEnabled() und isFeatureVisibleFor() Hilfsmethoden - Wiederkehrende Trainings: Täglich/Wöchentlich/2-Wöchentlich mit Ende per Datum oder Anzahl (max. 52), Vorschau im Formular - Event-Serien: Verknüpfung über event_series_id (UUID), Modal-Dialog beim Speichern und Löschen mit Optionen "nur dieses" / "alle folgenden" - Löschen-Button direkt in der Event-Bearbeitung mit Serien-Dialog - DemoDataSeeder: 4 Trainings als Serie mit gemeinsamer event_series_id - Übersetzungen in allen 6 Sprachen (de, en, pl, ru, ar, tr) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
670 lines
28 KiB
PHP
Executable File
670 lines
28 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\Season;
|
|
use App\Models\Setting;
|
|
use App\Models\Team;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use App\Services\HtmlSanitizerService;
|
|
use Carbon\Carbon;
|
|
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);
|
|
}
|
|
|
|
// Saison-Filter
|
|
if ($request->filled('season_id')) {
|
|
$season = Season::find($request->season_id);
|
|
if ($season) {
|
|
$query->whereBetween('start_at', [$season->start_date, $season->end_date->endOfDay()]);
|
|
}
|
|
}
|
|
|
|
$events = $query->paginate(20)->withQueryString();
|
|
$teams = Team::active()->orderBy('name')->get();
|
|
$seasons = Season::orderByDesc('start_date')->get();
|
|
$trashedEvents = Event::onlyTrashed()->with('team')->latest('deleted_at')->get();
|
|
|
|
return view('admin.events.index', compact('events', 'teams', 'seasons', '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]);
|
|
|
|
$recurringCount = $this->generateRecurringEvents($event, $request);
|
|
|
|
$message = $recurringCount > 0
|
|
? __('admin.recurrence_created', ['count' => $recurringCount + 1])
|
|
: __('admin.event_created');
|
|
|
|
return redirect()->route('admin.events.index')
|
|
->with('success', $message);
|
|
}
|
|
|
|
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);
|
|
|
|
// Serien-Events: Alle folgenden aktualisieren
|
|
$updatedFollowing = 0;
|
|
if ($request->input('update_following') === '1' && $event->isPartOfSeries()) {
|
|
$updatedFollowing = $this->updateFollowingSeriesEvents($event, $request);
|
|
}
|
|
|
|
$recurringCount = $this->generateRecurringEvents($event, $request);
|
|
|
|
if ($recurringCount > 0) {
|
|
$message = __('admin.recurrence_created', ['count' => $recurringCount + 1]);
|
|
} elseif ($updatedFollowing > 0) {
|
|
$message = __('admin.series_events_updated', ['count' => $updatedFollowing]);
|
|
} else {
|
|
$message = __('admin.event_updated');
|
|
}
|
|
|
|
return redirect()->route('admin.events.index')
|
|
->with('success', $message);
|
|
}
|
|
|
|
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(Request $request, Event $event): RedirectResponse
|
|
{
|
|
$deletedCount = 1;
|
|
|
|
// Serien-Events: Alle folgenden auch löschen
|
|
if ($request->input('delete_following') === '1' && $event->isPartOfSeries()) {
|
|
$followingEvents = $event->followingSeriesEvents()->get();
|
|
foreach ($followingEvents as $futureEvent) {
|
|
ActivityLog::logWithChanges('deleted', __('admin.log_event_deleted', ['title' => $futureEvent->title]), 'Event', $futureEvent->id, ['title' => $futureEvent->title, 'team' => $futureEvent->team->name ?? ''], null);
|
|
$futureEvent->deleted_by = auth()->id();
|
|
$futureEvent->save();
|
|
$futureEvent->delete();
|
|
$deletedCount++;
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
$message = $deletedCount > 1
|
|
? __('admin.series_events_deleted', ['count' => $deletedCount])
|
|
: __('admin.event_deleted');
|
|
|
|
return redirect()->route('admin.events.index')
|
|
->with('success', $message);
|
|
}
|
|
|
|
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.*.penalty_goals' => ['nullable', 'integer', 'min:0', 'max:99'],
|
|
'stats.*.penalty_shots' => ['nullable', 'integer', 'min:0', 'max:99'],
|
|
'stats.*.yellow_cards' => ['nullable', 'integer', 'min:0', 'max:3'],
|
|
'stats.*.two_minute_suspensions' => ['nullable', 'integer', 'min:0', 'max:3'],
|
|
'stats.*.playing_time_minutes' => ['nullable', 'integer', 'min:0', 'max:90'],
|
|
'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;
|
|
$penaltyGoals = isset($data['penalty_goals']) && $data['penalty_goals'] !== '' ? (int) $data['penalty_goals'] : null;
|
|
$penaltyShots = isset($data['penalty_shots']) && $data['penalty_shots'] !== '' ? (int) $data['penalty_shots'] : null;
|
|
$yellowCards = isset($data['yellow_cards']) && $data['yellow_cards'] !== '' ? (int) $data['yellow_cards'] : null;
|
|
$twoMinSuspensions = isset($data['two_minute_suspensions']) && $data['two_minute_suspensions'] !== '' ? (int) $data['two_minute_suspensions'] : null;
|
|
$playingTime = isset($data['playing_time_minutes']) && $data['playing_time_minutes'] !== '' ? (int) $data['playing_time_minutes'] : null;
|
|
|
|
// Leere Einträge löschen
|
|
$hasData = $isGk || $goals !== null || $shots !== null || $note !== null || $position !== null
|
|
|| $penaltyGoals !== null || $penaltyShots !== null || $yellowCards !== null
|
|
|| $twoMinSuspensions !== null || $playingTime !== null;
|
|
if (! $hasData) {
|
|
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,
|
|
'penalty_goals' => $penaltyGoals,
|
|
'penalty_shots' => $penaltyShots,
|
|
'yellow_cards' => $yellowCards,
|
|
'two_minute_suspensions' => $twoMinSuspensions,
|
|
'playing_time_minutes' => $playingTime,
|
|
'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'],
|
|
'recurrence' => ['nullable', 'in:none,daily,weekly,biweekly'],
|
|
'recurrence_end_type' => ['nullable', 'in:date,count'],
|
|
'recurrence_end_date' => ['nullable', 'date'],
|
|
'recurrence_count' => ['nullable', 'integer', 'min:1', 'max:52'],
|
|
]);
|
|
|
|
// 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']);
|
|
|
|
// Recurrence-Felder aus validated entfernen (werden separat verarbeitet)
|
|
unset($validated['recurrence'], $validated['recurrence_end_type'], $validated['recurrence_end_date'], $validated['recurrence_count']);
|
|
|
|
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,
|
|
]
|
|
);
|
|
}
|
|
|
|
private function updateFollowingSeriesEvents(Event $event, Request $request): int
|
|
{
|
|
$followingEvents = $event->followingSeriesEvents()->get();
|
|
if ($followingEvents->isEmpty()) {
|
|
return 0;
|
|
}
|
|
|
|
$newTime = Carbon::parse($event->start_at);
|
|
|
|
foreach ($followingEvents as $futureEvent) {
|
|
$futureEvent->title = $event->title;
|
|
$futureEvent->location_name = $event->location_name;
|
|
$futureEvent->address_text = $event->address_text;
|
|
$futureEvent->location_lat = $event->location_lat;
|
|
$futureEvent->location_lng = $event->location_lng;
|
|
$futureEvent->description_html = $event->description_html;
|
|
$futureEvent->min_players = $event->min_players;
|
|
$futureEvent->min_catering = $event->min_catering;
|
|
$futureEvent->min_timekeepers = $event->min_timekeepers;
|
|
|
|
// Uhrzeit anpassen (Datum behalten)
|
|
$futureDate = Carbon::parse($futureEvent->start_at);
|
|
$futureEvent->start_at = $futureDate->setTime($newTime->hour, $newTime->minute);
|
|
|
|
$futureEvent->updated_by = auth()->id();
|
|
$futureEvent->save();
|
|
|
|
// Catering/Zeitnehmer-Zuweisungen neu synchen
|
|
$this->syncAssignments($futureEvent, $request);
|
|
}
|
|
|
|
return $followingEvents->count();
|
|
}
|
|
|
|
private function generateRecurringEvents(Event $baseEvent, Request $request): int
|
|
{
|
|
$recurrence = $request->input('recurrence', 'none');
|
|
if ($recurrence === 'none' || $baseEvent->type !== EventType::Training) {
|
|
return 0;
|
|
}
|
|
|
|
$interval = match ($recurrence) {
|
|
'daily' => 1,
|
|
'weekly' => 7,
|
|
'biweekly' => 14,
|
|
default => 0,
|
|
};
|
|
if ($interval === 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Termine berechnen
|
|
$dates = [];
|
|
$startDate = Carbon::parse($baseEvent->start_at);
|
|
$endType = $request->input('recurrence_end_type', 'count');
|
|
|
|
if ($endType === 'date') {
|
|
$endDate = Carbon::parse($request->input('recurrence_end_date'));
|
|
$current = $startDate->copy()->addDays($interval);
|
|
while ($current->lte($endDate) && count($dates) < 52) {
|
|
$dates[] = $current->copy();
|
|
$current->addDays($interval);
|
|
}
|
|
} else {
|
|
$count = min((int) $request->input('recurrence_count', 1), 52);
|
|
for ($i = 1; $i <= $count; $i++) {
|
|
$dates[] = $startDate->copy()->addDays($interval * $i);
|
|
}
|
|
}
|
|
|
|
if (empty($dates)) {
|
|
return 0;
|
|
}
|
|
|
|
// Series-ID zuweisen
|
|
$seriesId = (string) Str::uuid();
|
|
$baseEvent->event_series_id = $seriesId;
|
|
$baseEvent->save();
|
|
|
|
// Basis-Dateien zum Verlinken
|
|
$fileIds = $baseEvent->files()->pluck('files.id')->toArray();
|
|
|
|
foreach ($dates as $date) {
|
|
$newEvent = $baseEvent->replicate(['id', 'created_at', 'updated_at', 'deleted_at', 'deleted_by']);
|
|
$newEvent->start_at = $date;
|
|
$newEvent->created_by = auth()->id();
|
|
$newEvent->updated_by = auth()->id();
|
|
$newEvent->save();
|
|
|
|
// Teilnehmer erstellen
|
|
$this->createParticipantsForTeam($newEvent);
|
|
|
|
// Catering/Zeitnehmer-Zuweisungen kopieren
|
|
$this->syncAssignments($newEvent, $request);
|
|
|
|
// Dateien verlinken (nur bestehende, keine neuen Uploads)
|
|
if (!empty($fileIds)) {
|
|
$newEvent->files()->attach($fileIds);
|
|
}
|
|
}
|
|
|
|
return count($dates);
|
|
}
|
|
}
|