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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user