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

@@ -91,6 +91,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">
@@ -105,6 +160,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">
@@ -114,6 +170,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">
@@ -123,6 +181,7 @@
@endfor
</select>
</div>
@endif
</div>
</div>
</div>
@@ -184,6 +243,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">
@@ -193,24 +253,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>
@@ -218,6 +286,7 @@
</div>
</template>
</div>
@endif
{{-- Dateien --}}
<div class="mb-4" x-data="{ showPicker: false, newFileCount: 0 }">
@@ -294,6 +363,41 @@
document.getElementById('description_html').value = quill.root.innerHTML;
});
// 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);
}
};
}
// Assignment data for Catering/Timekeeper
function assignmentData() {
const teamParents = @js($teamParents);