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

@@ -16,6 +16,12 @@
@csrf
@method('PUT')
@if ($event->isPartOfSeries())
<div class="mb-4 bg-blue-50 border border-blue-200 rounded-md px-4 py-2 text-sm text-blue-700">
{{ __('admin.series_hint', ['count' => $event->followingSeriesEvents()->count()]) }}
</div>
@endif
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.team') }} *</label>
@@ -92,6 +98,61 @@
@error('status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Wiederholung (nur Training) --}}
<div class="mb-4" x-data="recurrenceData()" x-show="isTraining" x-cloak>
<div class="border border-gray-200 rounded-md p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.recurrence') }}</h3>
<div class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence') }}</label>
<select x-model="recurrence" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
<option value="none">{{ __('admin.recurrence_none') }}</option>
<option value="daily">{{ __('admin.recurrence_daily') }}</option>
<option value="weekly">{{ __('admin.recurrence_weekly') }}</option>
<option value="biweekly">{{ __('admin.recurrence_biweekly') }}</option>
</select>
</div>
<div x-show="recurrence !== 'none'" x-cloak>
<div class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence_end_type') }}</label>
<div class="flex gap-4">
<label class="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer">
<input type="radio" x-model="endType" value="date" class="text-blue-600">
{{ __('admin.recurrence_end_date') }}
</label>
<label class="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer">
<input type="radio" x-model="endType" value="count" class="text-blue-600">
{{ __('admin.recurrence_end_count') }}
</label>
</div>
</div>
<div x-show="endType === 'date'" class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence_end_date') }}</label>
<input type="date" x-model="endDate" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div x-show="endType === 'count'" class="mb-3">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.recurrence_count_label') }}</label>
<input type="number" x-model.number="count" min="1" max="52" class="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<div x-show="previewCount > 0" class="text-sm text-blue-600 bg-blue-50 rounded-md px-3 py-2">
<span x-text="previewText"></span>
</div>
<div x-show="previewCount >= 52" class="text-xs text-orange-600 mt-1">
{{ __('admin.recurrence_max_warning', ['max' => 52]) }}
</div>
</div>
</div>
<input type="hidden" name="recurrence" :value="recurrence">
<input type="hidden" name="recurrence_end_type" :value="endType">
<input type="hidden" name="recurrence_end_date" :value="endDate">
<input type="hidden" name="recurrence_count" :value="count">
</div>
{{-- Mindestanforderungen --}}
<div class="mb-4" x-data="minRequirementsData()" x-init="listenTypeChange()">
<div x-show="showDropdowns" x-cloak class="border border-gray-200 rounded-md p-4">
@@ -106,6 +167,7 @@
@endfor
</select>
</div>
@if (\App\Models\Setting::isFeatureEnabled('catering'))
<div x-show="showCatering">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.min_catering') }}</label>
<select name="min_catering" x-model="minCatering" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
@@ -115,6 +177,8 @@
@endfor
</select>
</div>
@endif
@if (\App\Models\Setting::isFeatureEnabled('timekeepers'))
<div x-show="showTimekeepers">
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.min_timekeepers') }}</label>
<select name="min_timekeepers" x-model="minTimekeepers" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
@@ -124,6 +188,7 @@
@endfor
</select>
</div>
@endif
</div>
</div>
</div>
@@ -185,6 +250,7 @@
</div>
{{-- Catering/Zeitnehmer-Zuweisungen (nicht für away_game/meeting) --}}
@if (\App\Models\Setting::isFeatureEnabled('catering') || \App\Models\Setting::isFeatureEnabled('timekeepers'))
<div class="mb-4" x-data="assignmentData()" x-init="listenTeamChange()"
x-show="!['away_game', 'meeting'].includes(currentType)" x-cloak>
<template x-if="parents.length > 0">
@@ -194,24 +260,32 @@
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 font-medium text-gray-600">{{ __('ui.name') }}</th>
@if (\App\Models\Setting::isFeatureEnabled('catering'))
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.catering_assignment') }}</th>
@endif
@if (\App\Models\Setting::isFeatureEnabled('timekeepers'))
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.timekeeper_assignment') }}</th>
@endif
</tr>
</thead>
<tbody>
<template x-for="p in parents" :key="p.id">
<tr class="border-b border-gray-100">
<td class="py-2" x-text="p.name"></td>
@if (\App\Models\Setting::isFeatureEnabled('catering'))
<td class="py-2 text-center">
<input type="checkbox" name="catering_users[]" :value="p.id"
:checked="assignedCatering.includes(p.id)"
class="rounded border-gray-300 text-blue-600">
</td>
@endif
@if (\App\Models\Setting::isFeatureEnabled('timekeepers'))
<td class="py-2 text-center">
<input type="checkbox" name="timekeeper_users[]" :value="p.id"
:checked="assignedTimekeeper.includes(p.id)"
class="rounded border-gray-300 text-blue-600">
</td>
@endif
</tr>
</template>
</tbody>
@@ -219,6 +293,7 @@
</div>
</template>
</div>
@endif
{{-- Dateien --}}
<div class="mb-4" x-data="{ showPicker: false, newFileCount: 0 }">
@@ -277,9 +352,57 @@
<button type="button" @click="newFileCount++" class="mt-2 text-sm text-blue-600 hover:text-blue-800">+ {{ __('admin.upload_new_file') }}</button>
</div>
<div class="flex items-center gap-3">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('ui.save') }}</button>
<div class="flex items-center gap-3 flex-wrap">
@if ($event->isPartOfSeries() && $event->followingSeriesEvents()->count() > 0)
{{-- Serien-Event: Modal-Dialog beim Speichern --}}
<div x-data="{ showSaveModal: false }" class="contents">
<button type="button" @click="showSaveModal = true" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('ui.save') }}</button>
<div x-show="showSaveModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @keydown.escape.window="showSaveModal = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm mx-4" @click.outside="showSaveModal = false">
<h3 class="font-semibold text-gray-900 mb-2">{{ __('admin.save_series_title') }}</h3>
<p class="text-sm text-gray-600 mb-4">{{ __('admin.save_series_description', ['count' => $event->followingSeriesEvents()->count()]) }}</p>
<div class="flex flex-col gap-2">
<button type="submit" class="w-full text-left px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-md font-medium">{{ __('admin.save_only_this') }}</button>
<button type="submit" name="update_following" value="1" class="w-full text-left px-3 py-2 text-sm bg-green-100 hover:bg-green-200 text-green-700 rounded-md font-medium">{{ __('admin.save_this_and_following') }}</button>
<button type="button" @click="showSaveModal = false" class="text-sm text-gray-500 hover:underline mt-1">{{ __('ui.cancel') }}</button>
</div>
</div>
</div>
</div>
@else
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('ui.save') }}</button>
@endif
<a href="{{ route('admin.events.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
{{-- Löschen-Button --}}
@if ($event->isPartOfSeries() && $event->followingSeriesEvents()->count() > 0)
<div x-data="{ showDeleteModal: false }" class="ml-auto">
<button type="button" @click="showDeleteModal = true" class="text-red-600 hover:text-red-800 text-sm font-medium">{{ __('ui.delete') }}</button>
<div x-show="showDeleteModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @keydown.escape.window="showDeleteModal = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm mx-4" @click.outside="showDeleteModal = false">
<h3 class="font-semibold text-gray-900 mb-2">{{ __('admin.delete_series_title') }}</h3>
<p class="text-sm text-gray-600 mb-4">{{ __('admin.delete_series_description') }}</p>
<div class="flex flex-col gap-2">
<form method="POST" action="{{ route('admin.events.destroy', $event) }}">
@csrf @method('DELETE')
<button type="submit" class="w-full text-left px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-md">{{ __('admin.delete_only_this') }}</button>
</form>
<form method="POST" action="{{ route('admin.events.destroy', $event) }}">
@csrf @method('DELETE')
<input type="hidden" name="delete_following" value="1">
<button type="submit" class="w-full text-left px-3 py-2 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-md">{{ __('admin.delete_this_and_following') }}</button>
</form>
<button type="button" @click="showDeleteModal = false" class="text-sm text-gray-500 hover:underline mt-1">{{ __('ui.cancel') }}</button>
</div>
</div>
</div>
</div>
@else
<form method="POST" action="{{ route('admin.events.destroy', $event) }}" class="ml-auto inline" onsubmit="return confirm(@js(__('admin.confirm_delete_event')))">
@csrf @method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-800 text-sm font-medium">{{ __('ui.delete') }}</button>
</form>
@endif
</div>
</form>
</div>
@@ -358,7 +481,7 @@
@endif
{{-- Spielerstatistik (nur Spieltypen mit zugesagten Spielern) --}}
@if ($event->type->isGameType())
@if (\App\Models\Setting::isFeatureEnabled('player_stats') && $event->type->isGameType())
@php
$confirmedPlayers = $event->participants
->where('status', \App\Enums\ParticipantStatus::Yes)
@@ -564,6 +687,41 @@
};
}
// Recurrence data for Training events
function recurrenceData() {
const typeSel = document.getElementById('type');
const dateSel = document.getElementById('start_date');
const previewTpl = @js(__('admin.recurrence_preview', ['count' => '__COUNT__']));
return {
recurrence: @js(old('recurrence', 'none')),
endType: @js(old('recurrence_end_type', 'date')),
endDate: @js(old('recurrence_end_date', '')),
count: @js((int) old('recurrence_count', 10)),
isTraining: typeSel.value === 'training',
init() {
typeSel.addEventListener('change', () => {
this.isTraining = typeSel.value === 'training';
if (!this.isTraining) this.recurrence = 'none';
});
},
get startDate() { return dateSel.value; },
get previewCount() {
if (this.recurrence === 'none') return 0;
if (this.endType === 'count') return Math.min(Math.max(1, this.count || 0), 52);
if (!this.startDate || !this.endDate) return 0;
const days = { daily: 1, weekly: 7, biweekly: 14 };
const start = new Date(this.startDate);
const end = new Date(this.endDate);
const diff = Math.floor((end - start) / (1000 * 60 * 60 * 24));
if (diff <= 0) return 0;
return Math.min(Math.floor(diff / days[this.recurrence]), 52);
},
get previewText() {
return previewTpl.replace('__COUNT__', this.previewCount);
}
};
}
function assignmentData() {
const teamParents = @js($teamParents);
const assignedCatering = @js($assignedCatering);