- Fix: Notifiable-Trait zum User-Model hinzugefuegt (behebt notify()-500er) - Installer: SMTP-Verbindungstest mit EsmtpTransport + Ueberspringen-Link - Admin: Neuer E-Mail-Tab mit SMTP-Konfiguration + Verbindungstest - Admin: Lazy Quill-Initialisierung (nur sichtbare Locale wird geladen) - Uebersetzungen: 17 neue Mail-Keys in allen 6 Sprachen Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
593 lines
37 KiB
PHP
Executable File
593 lines
37 KiB
PHP
Executable File
<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)) }" x-init="document.getElementById('type').addEventListener('change', (e) => currentType = e.target.value);">
|
||
@csrf
|
||
@method('PUT')
|
||
|
||
<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>
|
||
<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>
|
||
|
||
<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>
|
||
|
||
{{-- 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>
|
||
<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>
|
||
<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>
|
||
</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) --}}
|
||
<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>
|
||
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.catering_assignment') }}</th>
|
||
<th class="text-center py-2 font-medium text-gray-600">{{ __('admin.timekeeper_assignment') }}</th>
|
||
</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>
|
||
<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>
|
||
<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>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
{{-- 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 }} · {{ $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') }} ↓
|
||
</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>
|
||
</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>
|
||
<a href="{{ route('admin.events.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
|
||
</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">✓</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">✓</span>
|
||
</div>
|
||
</div>
|
||
@endforeach
|
||
@endif
|
||
</div>
|
||
</div>
|
||
@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;
|
||
}
|
||
});
|
||
}
|
||
};
|
||
}
|
||
|
||
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>
|