Files
WebAPP/resources/views/admin/events/edit.blade.php
Rhino c0287367c0 Event-Thumbnails: Vorschaubilder mit Auto-Resize und Typ-Logos
- Migration: thumbnail-Spalte in events-Tabelle
- Event-Model: imageUrl() liefert Custom-Thumbnail oder Standard-Logo
  je Event-Typ (Logo_Training.png, Logo_Heimspiel.png, etc.)
- Thumbnail-Upload neben Typ-Auswahl bei Erstellen/Bearbeiten
  mit Live-Vorschau und Entfernen-Button
- Automatische Skalierung auf max. FullHD (1920x1080) via GD
  und Speicherung als JPEG (Qualität 85)
- Event-Listen (App + Admin): Logo/Thumbnail links im Terminblock
- Übersetzungen in allen 6 Sprachen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:45:35 +01:00

908 lines
61 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<x-layouts.admin :title="__('admin.edit_event_title')">
@push('styles')
<link href="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.snow.css" rel="stylesheet" integrity="sha384-cPa8kzsYWhqpAfWOLWYIw3V0BhPi/m3lrd8tBTPxr2NrYCHRVZ7xy1cEoRGOM/03" crossorigin="anonymous">
<style>
.ql-editor { min-height: 120px; }
.ql-toolbar.ql-snow { border-radius: 0.375rem 0.375rem 0 0; }
.ql-container.ql-snow { border-radius: 0 0 0.375rem 0.375rem; }
</style>
@endpush
<h1 class="text-2xl font-bold mb-6">{{ __('admin.edit_event_title') }}</h1>
<div class="bg-white rounded-lg shadow p-6 max-w-2xl">
<form method="POST" action="{{ route('admin.events.update', $event) }}" id="eventForm" enctype="multipart/form-data"
x-data="{ currentType: @js(old('type', $event->type->value)), thumbnailPreview: @js($event->thumbnail ? $event->imageUrl() : null) }" x-init="document.getElementById('type').addEventListener('change', (e) => currentType = e.target.value);">
@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="flex gap-4 mb-4">
<div class="flex-1 grid grid-cols-2 gap-4">
<div>
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.team') }} *</label>
<select name="team_id" id="team_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">{{ __('admin.please_select') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ old('team_id', $event->team_id) == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
@error('team_id')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="type" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.type') }} *</label>
<select name="type" id="type" required class="w-full px-3 py-2 border border-gray-300 rounded-md" x-model="currentType">
@foreach ($types as $t)
<option value="{{ $t->value }}" {{ old('type', $event->type->value) === $t->value ? 'selected' : '' }}>{{ $t->label() }}</option>
@endforeach
</select>
@error('type')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
</div>
{{-- Thumbnail --}}
<div class="shrink-0">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.event_thumbnail') }}</label>
<div class="relative w-24 h-24 border-2 border-dashed border-gray-300 rounded-lg overflow-hidden cursor-pointer hover:border-blue-400 transition-colors"
@click="$refs.thumbnailInput.click()">
<template x-if="thumbnailPreview">
<img :src="thumbnailPreview" class="w-full h-full object-cover">
</template>
<template x-if="!thumbnailPreview">
<div class="flex flex-col items-center justify-center h-full text-gray-400">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span class="text-[10px] mt-1">{{ __('admin.upload_thumbnail') }}</span>
</div>
</template>
<template x-if="thumbnailPreview">
<button type="button" @click.stop="thumbnailPreview = null; $refs.thumbnailInput.value = ''; $refs.removeThumbnail.value = '1'"
class="absolute top-0.5 right-0.5 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs hover:bg-red-600">×</button>
</template>
</div>
<input type="file" name="thumbnail" x-ref="thumbnailInput" class="hidden" accept="image/jpeg,image/png,image/gif,image/webp"
@change="if ($refs.thumbnailInput.files[0]) { $refs.removeThumbnail.value = ''; const r = new FileReader(); r.onload = (e) => thumbnailPreview = e.target.result; r.readAsDataURL($refs.thumbnailInput.files[0]); }">
<input type="hidden" name="remove_thumbnail" x-ref="removeThumbnail" value="">
@error('thumbnail')<p class="mt-1 text-sm text-red-600 text-xs">{{ $message }}</p>@enderror
</div>
</div>
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.event_title') }} *</label>
<input type="text" name="title" id="title" value="{{ old('title', $event->title) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('title') border-red-500 @enderror">
@error('title')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Gegner (nur für Spiel-Typen) --}}
<div class="mb-4" x-show="['home_game', 'away_game'].includes(currentType)" x-cloak>
<label for="opponent" class="block text-sm font-medium text-gray-700 mb-1">{{ __('events.opponent') }}</label>
<input type="text" name="opponent" id="opponent" value="{{ old('opponent', $event->opponent) }}" maxlength="100"
class="w-full px-3 py-2 border border-gray-300 rounded-md" placeholder="{{ __('events.opponent') }}">
@error('opponent')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Ergebnis (nur für Spiel-Typen) --}}
<div class="mb-4" x-show="['home_game', 'away_game'].includes(currentType)" x-cloak>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('events.score') }}</label>
<div class="flex items-center gap-2">
<input type="number" name="score_home" value="{{ old('score_home', $event->score_home) }}" min="0" max="99" placeholder="{{ __('events.score_home') }}"
class="w-20 px-3 py-2 border border-gray-300 rounded-md text-center">
<span class="text-gray-500 font-medium">:</span>
<input type="number" name="score_away" value="{{ old('score_away', $event->score_away) }}" min="0" max="99" placeholder="{{ __('events.score_away') }}"
class="w-20 px-3 py-2 border border-gray-300 rounded-md text-center">
</div>
@error('score_home')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
@error('score_away')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.date') }} *</label>
<input type="date" name="start_date" id="start_date" value="{{ old('start_date', $event->start_at->format('Y-m-d')) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('start_date') border-red-500 @enderror">
@error('start_date')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="start_time" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.time') }} *</label>
<input type="time" name="start_time" id="start_time" value="{{ old('start_time', $event->start_at->format('H:i')) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('start_time') border-red-500 @enderror">
@error('start_time')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
</div>
<div class="mb-4">
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.status') }} *</label>
<select name="status" id="status" required class="w-full px-3 py-2 border border-gray-300 rounded-md">
@foreach ($statuses as $s)
<option value="{{ $s->value }}" {{ old('status', $event->status->value) === $s->value ? 'selected' : '' }}>{{ $s->label() }}</option>
@endforeach
</select>
@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">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.min_requirements') }}</h3>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs text-gray-600 mb-1" x-text="playersLabel"></label>
<select name="min_players" x-model="minPlayers" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
<option value="">--</option>
@for ($n = 1; $n <= 30; $n++)
<option value="{{ $n }}" :hidden="{{ $n }} > playersMax" :disabled="{{ $n }} > playersMax">{{ $n }}</option>
@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">
<option value="">--</option>
@for ($n = 1; $n <= 8; $n++)
<option value="{{ $n }}" :hidden="{{ $n }} > cateringMax" :disabled="{{ $n }} > cateringMax">{{ $n }}</option>
@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">
<option value="">--</option>
@for ($n = 1; $n <= 8; $n++)
<option value="{{ $n }}" :hidden="{{ $n }} > timekeepersMax" :disabled="{{ $n }} > timekeepersMax">{{ $n }}</option>
@endfor
</select>
</div>
@endif
</div>
</div>
</div>
{{-- Ort & Adress-Suche --}}
<div class="mb-4" x-data="addressSearch()">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.location_address') }}</label>
{{-- Ortsname mit bekannten Orten --}}
<div class="mb-2 relative">
<label for="location_name" class="block text-xs text-gray-500 mb-1">{{ __('admin.location_name_hint') }}</label>
<input type="text" name="location_name" id="location_name" x-model="locationName"
@input.debounce.200ms="filterKnown()" @focus="filterKnown()" @keydown.escape="knownMatches = []"
class="w-full px-3 py-2 border border-gray-300 rounded-md" placeholder="{{ __('admin.location_name_placeholder') }}" autocomplete="off">
<div x-show="knownMatches.length > 0" x-cloak @click.outside="knownMatches = []"
class="absolute z-10 mt-1 w-full bg-white border border-blue-300 rounded-md shadow-lg max-h-48 overflow-y-auto">
<div class="px-3 py-1.5 bg-blue-50 text-xs font-medium text-blue-700 border-b">{{ __('admin.known_locations') }}</div>
<template x-for="(loc, idx) in knownMatches" :key="idx">
<button type="button" @click="selectKnown(loc)" class="w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100">
<span class="block text-sm font-medium text-gray-900" x-text="loc.name"></span>
<span class="block text-xs text-gray-500" x-text="loc.address_text || ''" x-show="loc.address_text"></span>
</button>
</template>
</div>
</div>
{{-- Photon Adress-Suche --}}
<div class="relative">
<label class="block text-xs text-gray-500 mb-1">{{ __('admin.search_address') }}</label>
<input type="text" x-model="query" @input.debounce.300ms="search()" @keydown.escape="results = []"
placeholder="{{ __('admin.search_address_hint') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md" autocomplete="off">
<div x-show="loading" x-cloak class="absolute right-3 top-8">
<svg class="animate-spin h-4 w-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
</div>
<div x-show="results.length > 0" x-cloak @click.outside="results = []"
class="absolute z-10 mt-1 w-full bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
<template x-for="(r, idx) in results" :key="idx">
<button type="button" @click="select(r)" class="w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100">
<span class="block text-sm font-medium text-gray-900" x-text="r.title"></span>
<span class="block text-xs text-gray-500" x-text="r.subtitle"></span>
</button>
</template>
</div>
</div>
<input type="hidden" name="address_text" id="address_text" x-ref="addressText" value="{{ old('address_text', $event->address_text) }}">
<input type="hidden" name="location_lat" id="location_lat" x-ref="lat" value="{{ old('location_lat', $event->location_lat) }}">
<input type="hidden" name="location_lng" id="location_lng" x-ref="lng" value="{{ old('location_lng', $event->location_lng) }}">
<p x-show="selectedAddress" x-text="selectedAddress" class="mt-1 text-xs text-green-700"></p>
@error('address_text')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Beschreibung mit Quill --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.description') }}</label>
<div id="quill-editor" class="bg-white">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize(old('description_html', $event->description_html ?? '')) !!}</div>
<input type="hidden" name="description_html" id="description_html" value="{{ old('description_html', $event->description_html) }}">
@error('description_html')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</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">
<div class="border border-gray-200 rounded-md p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.assignments') }}</h3>
<table class="w-full text-sm">
<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>
</table>
</div>
</template>
</div>
@endif
{{-- Dateien --}}
<div class="mb-4" x-data="{ showPicker: false, newFileCount: 0 }">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.event_files') }}</label>
{{-- Bereits angehängte Dateien --}}
@if ($event->files->isNotEmpty())
<div class="mb-3 space-y-1">
<p class="text-xs font-semibold text-gray-500">{{ __('admin.attached_files') }}</p>
@foreach ($event->files as $file)
<label class="flex items-center gap-2 py-0.5 text-sm text-gray-700 bg-blue-50 px-2 rounded">
<input type="checkbox" name="existing_files[]" value="{{ $file->id }}" checked class="rounded border-gray-300">
{{ $file->original_name }}
<span class="text-xs text-gray-400">({{ $file->category->name }} &middot; {{ $file->humanSize() }})</span>
</label>
@endforeach
</div>
@endif
{{-- Aus Bibliothek anhängen --}}
<button type="button" @click="showPicker = !showPicker" class="text-sm text-blue-600 hover:text-blue-800 mb-2">
{{ __('admin.attach_from_library') }} &darr;
</button>
<div x-show="showPicker" x-cloak class="border border-gray-200 rounded-md p-3 mb-3 max-h-48 overflow-y-auto">
@php $attachedIds = $event->files->pluck('id')->toArray(); @endphp
@foreach ($fileCategories as $cat)
@if ($cat->files->isNotEmpty())
<p class="text-xs font-semibold text-gray-500 mt-2 first:mt-0 mb-1">{{ $cat->name }}</p>
@foreach ($cat->files as $file)
@if (!in_array($file->id, $attachedIds))
<label class="flex items-center gap-2 py-0.5 text-sm text-gray-700 hover:bg-gray-50 px-1 rounded">
<input type="checkbox" name="existing_files[]" value="{{ $file->id }}" class="rounded border-gray-300">
{{ $file->original_name }} <span class="text-xs text-gray-400">({{ $file->humanSize() }})</span>
</label>
@endif
@endforeach
@endif
@endforeach
</div>
{{-- Neue Datei hochladen --}}
<div class="space-y-2">
<template x-for="i in newFileCount" :key="i">
<div class="flex items-center gap-2">
<input type="file" :name="'new_files[' + (i-1) + ']'" accept=".pdf,.docx,.xlsx,.jpg,.jpeg,.png,.gif,.webp"
class="flex-1 text-sm text-gray-600 file:mr-2 file:py-1 file:px-3 file:rounded-md file:border-0 file:text-sm file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
<select :name="'new_file_categories[' + (i-1) + ']'" required class="px-2 py-1 border border-gray-300 rounded-md text-sm">
<option value="">{{ __('admin.select_category') }}</option>
@foreach ($fileCategories as $cat)
<option value="{{ $cat->id }}">{{ $cat->name }}</option>
@endforeach
</select>
</div>
</template>
</div>
<button type="button" @click="newFileCount++" class="mt-2 text-sm text-blue-600 hover:text-blue-800">+ {{ __('admin.upload_new_file') }}</button>
<p class="mt-1 text-xs text-gray-400">{{ __('admin.allowed_file_types') }} · {{ __('admin.max_file_size') }}</p>
</div>
<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>
{{-- Teilnehmer-Verwaltung --}}
@if ($event->participants->isNotEmpty())
@php
$isMeeting = $event->type === \App\Enums\EventType::Meeting;
$yesCount = $event->participants->where('status', \App\Enums\ParticipantStatus::Yes)->count();
$noCount = $event->participants->where('status', \App\Enums\ParticipantStatus::No)->count();
$openCount = $event->participants->where('status', \App\Enums\ParticipantStatus::Unknown)->count();
@endphp
<div class="bg-white rounded-lg shadow p-6 max-w-2xl mt-6" x-data="participantsSection()">
<h2 class="text-lg font-semibold mb-3">{{ __('events.participants') }}</h2>
<div class="flex gap-4 text-sm mb-4">
<span class="text-green-700 font-medium"><span x-text="counts.yes"></span> {{ __('events.confirmations') }}</span>
<span class="text-red-700 font-medium"><span x-text="counts.no"></span> {{ __('events.rejections') }}</span>
<span class="text-gray-500"><span x-text="counts.open"></span> {{ __('events.open_responses') }}</span>
</div>
<div class="divide-y divide-gray-100">
@if ($isMeeting)
@foreach ($event->participants->sortBy(fn($p) => $p->user->name ?? '') as $participant)
<div class="py-2 flex items-center justify-between gap-2">
<span class="text-sm font-medium text-gray-900">{{ $participant->user->name ?? '' }}</span>
<div class="flex items-center gap-1">
<button type="button" @click="setStatus({{ $participant->id }}, 'yes')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'yes' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100'">
{{ __('ui.yes') }}
</button>
<button type="button" @click="setStatus({{ $participant->id }}, 'no')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'no' ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100'">
{{ __('ui.no') }}
</button>
<button type="button" @click="setStatus({{ $participant->id }}, 'unknown')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'unknown' ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'">
{{ __('ui.open') }}
</button>
<span x-show="savingId === {{ $participant->id }}" class="text-xs text-gray-400 ml-1">...</span>
<span x-show="savedId === {{ $participant->id }}" x-cloak class="text-xs text-green-600 ml-1">&#10003;</span>
</div>
</div>
@endforeach
@else
@foreach ($event->participants->sortBy('player.last_name') as $participant)
<div class="py-2 flex items-center justify-between gap-2">
<span class="text-sm font-medium text-gray-900">{{ $participant->player->full_name ?? '' }}</span>
<div class="flex items-center gap-1">
<button type="button" @click="setStatus({{ $participant->id }}, 'yes')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'yes' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100'">
{{ __('ui.yes') }}
</button>
<button type="button" @click="setStatus({{ $participant->id }}, 'no')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'no' ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100'">
{{ __('ui.no') }}
</button>
<button type="button" @click="setStatus({{ $participant->id }}, 'unknown')"
class="px-2 py-1 text-xs rounded-md transition-colors"
:class="statuses['{{ $participant->id }}'] === 'unknown' ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'">
{{ __('ui.open') }}
</button>
<span x-show="savingId === {{ $participant->id }}" class="text-xs text-gray-400 ml-1">...</span>
<span x-show="savedId === {{ $participant->id }}" x-cloak class="text-xs text-green-600 ml-1">&#10003;</span>
</div>
</div>
@endforeach
@endif
</div>
</div>
@endif
{{-- Spielerstatistik (nur Spieltypen mit zugesagten Spielern) --}}
@if (\App\Models\Setting::isFeatureEnabled('player_stats') && $event->type->isGameType())
@php
$confirmedPlayers = $event->participants
->where('status', \App\Enums\ParticipantStatus::Yes)
->whereNotNull('player_id')
->sortBy(fn($p) => $p->player->last_name ?? '');
@endphp
@if ($confirmedPlayers->isNotEmpty())
<div class="bg-white rounded-lg shadow p-6 max-w-4xl mt-6">
<h2 class="text-lg font-semibold mb-1">{{ __('events.stats') }}</h2>
<p class="text-xs text-gray-500 mb-4">{{ __('events.stats_confirmed_only') }}</p>
<form method="POST" action="{{ route('admin.events.update-stats', $event) }}">
@csrf
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-2 py-2 font-medium text-gray-600">{{ __('admin.nav_players') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-28">{{ __('events.stats_position') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-10" title="{{ __('events.stats_goalkeeper_long') }}">{{ __('events.stats_goalkeeper') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_shots_on_goal') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_saves') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_shots') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_goals') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-16" title="{{ __('events.stats_penalty_shots') }}">7m-W</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-16" title="{{ __('events.stats_penalty_goals') }}">7m-T</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-14" title="{{ __('events.stats_yellow_cards') }}">{{ __('events.stats_yellow_cards') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-14" title="{{ __('events.stats_two_min') }}">{{ __('events.stats_two_min') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-16" title="{{ __('events.stats_playing_time') }}">{{ __('events.stats_playing_time_short') }}</th>
<th class="text-left px-2 py-2 font-medium text-gray-600">{{ __('events.stats_note') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($confirmedPlayers as $participant)
@php
$pid = $participant->player_id;
$stat = $playerStatsMap[$pid] ?? null;
@endphp
<tr x-data="{ isGk: {{ $stat && $stat->is_goalkeeper ? 'true' : 'false' }} }">
<td class="px-2 py-2 font-medium text-gray-900 whitespace-nowrap">
{{ $participant->player->full_name }}
</td>
<td class="px-2 py-2">
<select name="stats[{{ $pid }}][position]"
class="w-full px-1 py-1 border border-gray-300 rounded text-sm"
@change="isGk = ($event.target.value === 'torwart')">
<option value=""></option>
@foreach (\App\Enums\PlayerPosition::cases() as $pos)
<option value="{{ $pos->value }}"
{{ ($stat?->position?->value ?? $participant->player->position?->value) === $pos->value ? 'selected' : '' }}>
{{ $pos->shortLabel() }}
</option>
@endforeach
</select>
</td>
<td class="px-2 py-2 text-center">
<input type="checkbox" name="stats[{{ $pid }}][is_goalkeeper]" value="1"
x-model="isGk"
class="rounded border-gray-300 text-blue-600">
</td>
<td class="px-2 py-2 text-center" x-show="isGk" x-cloak>
<input type="number" name="stats[{{ $pid }}][goalkeeper_shots]" min="0" max="999"
value="{{ $stat?->goalkeeper_shots }}"
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center" x-show="!isGk"><span class="text-gray-300"></span></td>
<td class="px-2 py-2 text-center" x-show="isGk" x-cloak>
<input type="number" name="stats[{{ $pid }}][goalkeeper_saves]" min="0" max="999"
value="{{ $stat?->goalkeeper_saves }}"
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center" x-show="!isGk"><span class="text-gray-300"></span></td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][shots]" min="0" max="999"
value="{{ $stat?->shots }}"
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][goals]" min="0" max="999"
value="{{ $stat?->goals }}"
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][penalty_shots]" min="0" max="99"
value="{{ $stat?->penalty_shots }}"
class="w-14 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][penalty_goals]" min="0" max="99"
value="{{ $stat?->penalty_goals }}"
class="w-14 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][yellow_cards]" min="0" max="3"
value="{{ $stat?->yellow_cards }}"
class="w-12 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][two_minute_suspensions]" min="0" max="3"
value="{{ $stat?->two_minute_suspensions }}"
class="w-12 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][playing_time_minutes]" min="0" max="90"
value="{{ $stat?->playing_time_minutes }}"
class="w-14 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2">
<input type="text" name="stats[{{ $pid }}][note]" maxlength="500"
value="{{ $stat?->note }}"
placeholder="{{ __('events.stats_note') }}..."
class="w-full px-2 py-1 border border-gray-300 rounded text-sm">
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('events.stats_save') }}
</button>
</div>
</form>
</div>
@endif
@endif
{{-- Quill JS --}}
<script src="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.min.js" integrity="sha384-QUJ+ckWz1M+a7w0UfG1sEn4pPrbQwSxGm/1TIPyioqXBrwuT9l4f9gdHWLDLbVWI" crossorigin="anonymous"></script>
<script>
const quill = new Quill('#quill-editor', {
theme: 'snow',
modules: {
toolbar: [
[{ 'header': [2, 3, 4, false] }],
['bold', 'italic', 'underline'],
[{ 'color': ['#000000', '#e60000', '#ff9900', '#008a00', '#0066cc', '#9933ff', '#ffffff', '#888888'] },
{ 'background': ['', '#ffd6d6', '#fff3cd', '#d4edda', '#cce5ff', '#e8d5f5'] }],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
['blockquote', 'link'],
['clean']
]
},
placeholder: @js(__('admin.description_placeholder'))
});
// Sofort nach Init den hidden Input befüllen
document.getElementById('description_html').value = quill.root.innerHTML;
document.getElementById('eventForm').addEventListener('submit', function () {
document.getElementById('description_html').value = quill.root.innerHTML;
});
// Typen ohne Catering/Zeitnehmer
const noCateringTypes = ['away_game', 'meeting'];
function minRequirementsData() {
const defaults = @js($eventDefaults);
const toStr = v => (v != null && v !== '') ? String(v) : '';
const existing = {
min_players: toStr(@js(old('min_players', $event->min_players))),
min_catering: toStr(@js(old('min_catering', $event->min_catering))),
min_timekeepers: toStr(@js(old('min_timekeepers', $event->min_timekeepers))),
};
const originalType = @js(old('type', $event->type->value));
const ranges = {
home_game: { players: 14, catering: 4, timekeepers: 4 },
away_game: { players: 14, catering: 0, timekeepers: 0 },
training: { players: 30, catering: 4, timekeepers: 4 },
tournament: { players: 14, catering: 8, timekeepers: 8 },
meeting: { players: 20, catering: 0, timekeepers: 0 },
};
return {
currentType: originalType,
minPlayers: existing.min_players,
minCatering: existing.min_catering,
minTimekeepers: existing.min_timekeepers,
get showDropdowns() { return this.currentType && this.currentType !== 'other'; },
get showCatering() { return !noCateringTypes.includes(this.currentType); },
get showTimekeepers() { return !noCateringTypes.includes(this.currentType); },
get playersLabel() { return this.currentType === 'meeting' ? @js(__('admin.min_users')) : @js(__('admin.min_players')); },
get playersMax() { const r = ranges[this.currentType]; return r ? r.players : 0; },
get cateringMax() { const r = ranges[this.currentType]; return r ? r.catering : 0; },
get timekeepersMax() { const r = ranges[this.currentType]; return r ? r.timekeepers : 0; },
listenTypeChange() {
const sel = document.getElementById('type');
this.currentType = sel.value;
sel.addEventListener('change', () => {
this.currentType = sel.value;
if (sel.value !== originalType) {
const d = defaults[sel.value];
this.minPlayers = toStr(d?.min_players);
this.minCatering = toStr(d?.min_catering);
this.minTimekeepers = toStr(d?.min_timekeepers);
} else {
this.minPlayers = existing.min_players;
this.minCatering = existing.min_catering;
this.minTimekeepers = existing.min_timekeepers;
}
});
}
};
}
// 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);
const assignedTimekeeper = @js($assignedTimekeeper);
return {
parents: [],
assignedCatering,
assignedTimekeeper,
listenTeamChange() {
const sel = document.getElementById('team_id');
this.parents = teamParents[sel.value] || [];
sel.addEventListener('change', () => {
this.parents = teamParents[sel.value] || [];
});
}
};
}
function participantsSection() {
const initStatuses = @js($event->participants->mapWithKeys(fn($p) => [$p->id => $p->status->value]));
const vals = Object.values(initStatuses);
return {
counts: {
yes: vals.filter(s => s === 'yes').length,
no: vals.filter(s => s === 'no').length,
open: vals.filter(s => s === 'unknown').length,
},
statuses: initStatuses,
savingId: null,
savedId: null,
async setStatus(id, newStatus) {
const oldStatus = this.statuses[id];
if (oldStatus === newStatus) return;
this.statuses[id] = newStatus;
this.savingId = id;
this.savedId = null;
this.adjustCount(oldStatus, -1);
this.adjustCount(newStatus, 1);
try {
const res = await fetch(`{{ route('admin.events.update-participant', $event) }}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ participant_id: id, status: newStatus }),
});
if (res.ok) {
this.savedId = id;
setTimeout(() => { if (this.savedId === id) this.savedId = null; }, 1500);
} else {
this.statuses[id] = oldStatus;
this.adjustCount(newStatus, -1);
this.adjustCount(oldStatus, 1);
}
} catch (e) {
this.statuses[id] = oldStatus;
this.adjustCount(newStatus, -1);
this.adjustCount(oldStatus, 1);
console.error(e);
} finally {
if (this.savingId === id) this.savingId = null;
}
},
adjustCount(status, delta) {
if (status === 'yes') this.counts.yes += delta;
else if (status === 'no') this.counts.no += delta;
else this.counts.open += delta;
}
};
}
function addressSearch() {
const knownLocations = @js($knownLocations);
return {
locationName: @js(old('location_name', $event->location_name ?? '')),
query: '',
results: [],
loading: false,
selectedAddress: @js(old('address_text', $event->address_text ?? '')),
knownMatches: [],
_abortCtrl: null,
filterKnown() {
const input = this.locationName.trim().toLowerCase();
if (input.length < 1) { this.knownMatches = knownLocations.slice(0, 8); return; }
const words = input.split(/\s+/);
this.knownMatches = knownLocations.filter(loc => {
const haystack = (loc.name + ' ' + (loc.address_text || '')).toLowerCase();
return words.every(w => haystack.includes(w));
}).slice(0, 8);
},
selectKnown(loc) {
this.locationName = loc.name;
this.$refs.addressText.value = loc.address_text || '';
this.$refs.lat.value = loc.location_lat || '';
this.$refs.lng.value = loc.location_lng || '';
this.selectedAddress = loc.address_text || '';
this.query = loc.address_text || '';
this.knownMatches = [];
},
formatFeature(f) {
const p = f.properties;
const street = [p.street, p.housenumber].filter(Boolean).join(' ');
const effectiveStreet = street || (p.name && p.name !== p.city ? p.name : '');
const cityLine = [p.postcode, p.city].filter(Boolean).join(' ');
const address = [effectiveStreet, cityLine].filter(Boolean).join(', ');
const name = p.name || '';
const isPlace = name && name !== effectiveStreet && name !== p.city && name !== p.street;
return {
title: isPlace ? name : (address || name || ''),
subtitle: isPlace ? address : (cityLine || p.state || ''),
address: address || name || '',
name: isPlace ? name : '',
lat: f.geometry.coordinates[1],
lon: f.geometry.coordinates[0],
};
},
async search() {
const q = this.query.trim();
if (q.length < 2) { this.results = []; return; }
if (this._abortCtrl) this._abortCtrl.abort();
this._abortCtrl = new AbortController();
this.loading = true;
try {
const params = new URLSearchParams({
q: q, lang: 'de', limit: '7',
lat: '51.4', lon: '7.5',
bbox: '5.87,50.32,9.46,52.53',
});
const resp = await fetch('https://photon.komoot.io/api/?' + params, {
signal: this._abortCtrl.signal
});
const data = await resp.json();
this.results = (data.features || []).map(f => this.formatFeature(f));
} catch (e) {
if (e.name !== 'AbortError') this.results = [];
} finally {
this.loading = false;
}
},
select(r) {
this.$refs.addressText.value = r.address;
this.$refs.lat.value = r.lat;
this.$refs.lng.value = r.lon;
this.selectedAddress = r.address;
this.query = r.address;
this.results = [];
if (!this.locationName && r.name) {
this.locationName = r.name;
}
}
};
}
</script>
</x-layouts.admin>