Feature-Toggles, Administration, wiederkehrende Events und Event-Serien

- 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>
This commit is contained in:
Rhino
2026-03-03 08:38:45 +01:00
parent 0990e4249c
commit 8ccadbe89f
27 changed files with 1968 additions and 698 deletions

View File

@@ -22,6 +22,7 @@ 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;
@@ -105,8 +106,14 @@ class EventController extends Controller
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', __('admin.event_created'));
->with('success', $message);
}
public function edit(Event $event): View
@@ -172,8 +179,24 @@ class EventController extends Controller
$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', __('admin.event_updated'));
->with('success', $message);
}
public function updateParticipant(Request $request, Event $event)
@@ -199,16 +222,34 @@ class EventController extends Controller
return response()->json(['success' => true]);
}
public function destroy(Event $event): RedirectResponse
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', __('admin.event_deleted'));
->with('success', $message);
}
public function restore(int $id): RedirectResponse
@@ -333,6 +374,10 @@ class EventController extends Controller
'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
@@ -340,6 +385,9 @@ class EventController extends Controller
$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;
}
@@ -514,4 +562,108 @@ class EventController extends Controller
]
);
}
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);
}
}