Files
WebAPP/resources/views/admin/events/create.blade.php
Rhino 8ccadbe89f 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>
2026-03-03 08:38:45 +01:00

557 lines
34 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.new_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.new_event_title') }}</h1>
<div class="bg-white rounded-lg shadow p-6 max-w-2xl">
<form method="POST" action="{{ route('admin.events.store') }}" id="eventForm" enctype="multipart/form-data"
x-data="{ currentType: @js(old('type', '')) }" x-init="$watch('currentType', () => {}); document.getElementById('type').addEventListener('change', (e) => currentType = e.target.value); currentType = document.getElementById('type').value;">
@csrf
<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') == $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') === $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') }}" 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') }}" 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') }}" 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') }}" 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') }}" 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') }}" 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', 'draft') === $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" value="{{ old('address_text') }}" x-ref="addressText">
<input type="hidden" name="location_lat" id="location_lat" value="{{ old('location_lat') }}" x-ref="lat">
<input type="hidden" name="location_lng" id="location_lng" value="{{ old('location_lng') }}" x-ref="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', '')) !!}</div>
<input type="hidden" name="description_html" id="description_html" value="{{ old('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>
{{-- 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">
@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)
<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>
@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.create') }}</button>
<a href="{{ route('admin.events.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
</div>
</form>
</div>
{{-- 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>
// Quill Editor
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;
// Sync Quill content to hidden field on form submit
document.getElementById('eventForm').addEventListener('submit', function () {
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);
const assignedCatering = @js(collect(old('catering_users', []))->map(fn($id) => (int) $id)->values()->toArray());
const assignedTimekeeper = @js(collect(old('timekeeper_users', []))->map(fn($id) => (int) $id)->values()->toArray());
return {
parents: [],
assignedCatering,
assignedTimekeeper,
listenTeamChange() {
const sel = document.getElementById('team_id');
this.parents = teamParents[sel.value] || [];
sel.addEventListener('change', () => {
this.parents = teamParents[sel.value] || [];
});
}
};
}
// Typen ohne Catering/Zeitnehmer
const noCateringTypes = ['away_game', 'meeting'];
// Minimum requirements with dynamic dropdowns
function minRequirementsData() {
const defaults = @js($eventDefaults);
const hasOldInput = @js(old('_token') !== null);
const toStr = v => (v != null && v !== '') ? String(v) : '';
const oldValues = {
min_players: toStr(@js(old('min_players', ''))),
min_catering: toStr(@js(old('min_catering', ''))),
min_timekeepers: toStr(@js(old('min_timekeepers', ''))),
};
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 },
};
const initialType = @js(old('type', ''));
return {
currentType: initialType,
minPlayers: oldValues.min_players,
minCatering: oldValues.min_catering,
minTimekeepers: oldValues.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;
if (!hasOldInput) {
this.applyDefaults(sel.value);
}
sel.addEventListener('change', () => {
this.currentType = sel.value;
this.applyDefaults(sel.value);
});
},
applyDefaults(type) {
const d = defaults[type];
this.minPlayers = toStr(d?.min_players);
this.minCatering = toStr(d?.min_catering);
this.minTimekeepers = toStr(d?.min_timekeepers);
}
};
}
// Address search with Photon API (OpenStreetMap) + known locations
function addressSearch() {
const knownLocations = @js($knownLocations);
return {
locationName: @js(old('location_name', '')),
query: '',
results: [],
loading: false,
selectedAddress: @js(old('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>