Files
WebAPP/resources/views/admin/events/create.blade.php
Rhino 2e24a40d68 Stand: SMTP-Test, Admin-Mail-Tab, Notifiable-Fix, Lazy-Quill
- 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>
2026-03-02 07:30:37 +01:00

453 lines
28 KiB
PHP
Executable File
Raw Permalink 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>
{{-- 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" 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) --}}
<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>
{{-- 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;
});
// 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>