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>
This commit is contained in:
Rhino
2026-03-02 07:30:37 +01:00
commit 2e24a40d68
9633 changed files with 1300799 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
<x-layouts.admin :title="__('admin.create_team')">
<div class="mb-6">
<h1 class="text-2xl font-bold">{{ __('admin.new_team') }}</h1>
</div>
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
<form method="POST" action="{{ route('admin.teams.store') }}">
@csrf
<div class="mb-4">
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.team_name') }} *</label>
<input type="text" name="name" id="name" value="{{ old('name') }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 @error('name') border-red-500 @enderror">
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="year_group" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.year_group') }}</label>
<input type="text" name="year_group" id="year_group" value="{{ old('year_group') }}" placeholder="{{ __('admin.year_group_placeholder') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-6">
<label class="flex items-center">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" {{ old('is_active', '1') == '1' ? 'checked' : '' }}
class="rounded border-gray-300 mr-2">
<span class="text-sm text-gray-700">{{ __('admin.team_is_active') }}</span>
</label>
</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">
{{ __('admin.create_team') }}
</button>
<a href="{{ route('admin.teams.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
</div>
</form>
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,286 @@
<x-layouts.admin :title="__('admin.edit_team') . ': ' . $team->name">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.edit_team') }}: {{ $team->name }}</h1>
<form method="POST" action="{{ route('admin.teams.update', $team) }}" enctype="multipart/form-data">
@csrf
@method('PUT')
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
{{-- ══ LINKS ══════════════════════════════════════════ --}}
<div class="space-y-6">
{{-- Card: Stammdaten --}}
<div class="bg-white rounded-lg shadow p-6">
<div class="mb-4">
<label for="name" class="block text-sm font-semibold text-gray-700 mb-1">{{ __('admin.team_name') }} *</label>
<input type="text" name="name" id="name" value="{{ old('name', $team->name) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="year_group" class="block text-sm font-semibold text-gray-700 mb-1">{{ __('admin.year_group') }}</label>
<input type="text" name="year_group" id="year_group" value="{{ old('year_group', $team->year_group) }}"
placeholder="{{ __('admin.year_group_placeholder') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="mb-6">
<label class="flex items-center gap-2">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" {{ old('is_active', $team->is_active) ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">{{ __('admin.team_is_active') }}</span>
</label>
</div>
<div class="flex gap-3">
<button type="submit" class="bg-blue-600 text-white px-5 py-2 rounded-md hover:bg-blue-700 font-medium text-sm">{{ __('ui.save') }}</button>
<a href="{{ route('admin.teams.index') }}" class="bg-gray-200 text-gray-700 px-5 py-2 rounded-md hover:bg-gray-300 text-sm">{{ __('ui.cancel') }}</a>
</div>
</div>
{{-- Card: Notizen --}}
<div class="bg-white rounded-lg shadow p-6">
<label for="notes" class="block text-sm font-semibold text-gray-700 mb-2">{{ __('admin.team_notes') }}</label>
<textarea name="notes" id="notes" rows="6"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="{{ __('admin.team_notes_placeholder') }}">{{ old('notes', $team->notes) }}</textarea>
@error('notes')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
{{-- ══ RECHTS ═════════════════════════════════════════ --}}
<div class="space-y-6">
{{-- Card: Trainer --}}
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.team_coaches') }}</h2>
@php
$selectedCoachIds = collect(old('coach_ids', $team->coaches->pluck('id')->toArray()))->map(fn ($v) => (int) $v)->toArray();
@endphp
@if ($allCoaches->isEmpty())
<p class="text-sm text-gray-500">{{ __('admin.no_coaches_available') }}</p>
@else
<div class="space-y-2">
@foreach ($allCoaches as $coach)
<label class="flex items-center gap-3 py-1 cursor-pointer hover:bg-gray-50 -mx-2 px-2 rounded">
<input type="checkbox" name="coach_ids[]" value="{{ $coach->id }}"
{{ in_array($coach->id, $selectedCoachIds) ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<div class="flex items-center gap-2">
@if ($coach->getAvatarUrl())
<img src="{{ $coach->getAvatarUrl() }}" alt="" class="w-7 h-7 rounded-full object-cover">
@else
<div class="w-7 h-7 rounded-full bg-blue-100 text-blue-700 text-xs font-semibold flex items-center justify-center">
{{ $coach->getInitials() }}
</div>
@endif
<span class="text-sm text-gray-900">{{ $coach->name }}</span>
</div>
</label>
@endforeach
</div>
@endif
</div>
{{-- Card: Spieler --}}
<div class="bg-white rounded-lg shadow p-6" x-data="playerTeamSwitcher()">
<h2 class="text-sm font-semibold text-gray-700 mb-3">
{{ __('admin.team_players') }}
<span class="font-normal text-gray-400">({{ $team->players->count() }})</span>
</h2>
@if ($team->players->isEmpty())
<p class="text-sm text-gray-500">{{ __('admin.no_players_yet') }}</p>
@else
<div class="divide-y divide-gray-100">
@foreach ($team->players as $player)
<div class="py-2 flex items-center justify-between gap-2" data-player-row="{{ $player->id }}">
<div class="flex items-center gap-2 min-w-0">
@if ($player->getAvatarUrl())
<img src="{{ $player->getAvatarUrl() }}" alt="" class="w-8 h-8 rounded-full object-cover flex-shrink-0">
@else
<div class="w-8 h-8 rounded-full bg-gray-100 text-gray-600 text-xs font-semibold flex items-center justify-center flex-shrink-0">
{{ $player->getInitials() }}
</div>
@endif
<div class="min-w-0">
<a href="{{ route('admin.players.edit', $player) }}" class="text-sm font-medium text-gray-900 hover:text-blue-600 truncate block">
{{ $player->full_name }}
</a>
<span class="text-xs text-gray-400">#{{ $player->jersey_number ?? '' }}</span>
</div>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<select @change="switchTeam({{ $player->id }}, $event.target.value, $event.target)"
class="text-xs px-2 py-1 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500">
@foreach ($allTeams as $t)
<option value="{{ $t->id }}" {{ $t->id === $team->id ? 'selected' : '' }}>{{ $t->name }}</option>
@endforeach
</select>
<span x-show="savingId === {{ $player->id }}" class="text-xs text-gray-400">...</span>
<span x-show="savedId === {{ $player->id }}" x-cloak class="text-xs text-green-600">&#10003;</span>
<span x-show="errorId === {{ $player->id }}" x-cloak class="text-xs text-red-600">!</span>
</div>
</div>
@endforeach
</div>
@endif
</div>
{{-- Card: Elternvertretung --}}
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-sm font-semibold text-gray-700 mb-1">{{ __('admin.team_parent_reps') }}</h2>
<p class="text-xs text-gray-400 mb-3">{{ __('admin.team_parent_reps_hint') }}</p>
@if ($parentReps->isEmpty())
<p class="text-sm text-gray-500">{{ __('admin.no_parent_reps') }}</p>
@else
<div class="space-y-2">
@foreach ($parentReps as $rep)
<div class="flex items-center gap-2">
@if ($rep->getAvatarUrl())
<img src="{{ $rep->getAvatarUrl() }}" alt="" class="w-7 h-7 rounded-full object-cover">
@else
<div class="w-7 h-7 rounded-full bg-green-100 text-green-700 text-xs font-semibold flex items-center justify-center">
{{ $rep->getInitials() }}
</div>
@endif
<div>
<span class="text-sm font-medium text-gray-900">{{ $rep->name }}</span>
<span class="text-xs text-gray-400 block">{{ $rep->email }}</span>
</div>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
{{-- ══ FULL-WIDTH: Dateien ════════════════════════════════ --}}
<div class="mt-6 bg-white rounded-lg shadow p-6" x-data="{ showPicker: false, newFileCount: 0 }">
<h2 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.event_files') }}</h2>
{{-- Angehängte Dateien --}}
@if ($team->files->isNotEmpty())
<div class="mb-3 space-y-1">
<p class="text-xs font-semibold text-gray-500 mb-1">{{ __('admin.attached_files') }}</p>
@foreach ($team->files as $file)
<label class="flex items-center gap-2 py-1 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">
<span class="truncate">{{ $file->original_name }}</span>
<span class="text-xs text-gray-400 whitespace-nowrap">({{ $file->category->name ?? '' }} &middot; {{ $file->humanSize() }})</span>
<button type="button" class="ml-auto text-xs text-blue-600 hover:underline flex-shrink-0"
@click="$dispatch('open-file-preview', @js($file->previewData()))">
{{ __('ui.preview') }}
</button>
</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 = $team->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 $libFile)
@if (!in_array($libFile->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="{{ $libFile->id }}" class="rounded border-gray-300">
{{ $libFile->original_name }}
<span class="text-xs text-gray-400">({{ $libFile->humanSize() }})</span>
</label>
@endif
@endforeach
@endif
@endforeach
</div>
{{-- Neue Dateien hochladen --}}
<div class="space-y-2">
<template x-for="i in newFileCount" :key="i">
<div class="flex flex-wrap items-center gap-2">
<input type="file" :name="'new_files[' + (i-1) + ']'" accept=".pdf,.docx,.xlsx,.jpg,.jpeg,.png,.gif,.webp"
class="flex-1 min-w-[200px] text-sm px-3 py-1.5 border border-gray-300 rounded-md file:mr-3 file:py-1 file:px-3 file:rounded-md file:border-0 file:text-sm file:bg-gray-100 file:text-gray-700">
<select :name="'new_file_categories[' + (i-1) + ']'" required
class="px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500">
<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>
</form>
{{-- File preview modal --}}
<x-file-preview-modal />
@push('scripts')
<script>
function playerTeamSwitcher() {
return {
savingId: null,
savedId: null,
errorId: null,
async switchTeam(playerId, newTeamId, selectEl) {
if (parseInt(newTeamId) === @js($team->id)) return;
this.savingId = playerId;
this.savedId = null;
this.errorId = null;
try {
const res = await fetch(@js(route('admin.teams.update-player-team', $team)), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ player_id: playerId, new_team_id: newTeamId }),
});
if (res.ok) {
this.savedId = playerId;
this.savingId = null;
setTimeout(() => window.location.reload(), 500);
} else {
this.errorId = playerId;
this.savingId = null;
selectEl.value = @js($team->id);
setTimeout(() => { if (this.errorId === playerId) this.errorId = null; }, 3000);
}
} catch (e) {
this.errorId = playerId;
this.savingId = null;
selectEl.value = @js($team->id);
console.error(e);
}
}
};
}
</script>
@endpush
</x-layouts.admin>

View File

@@ -0,0 +1,51 @@
<x-layouts.admin :title="__('admin.teams_title')">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{{ __('admin.teams_title') }}</h1>
<a href="{{ route('admin.teams.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('admin.new_team') }}
</a>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('ui.name') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.year_group') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.nav_players') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.nav_events') }}</th>
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.status') }}</th>
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.action') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($teams as $team)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{{ $team->name }}</td>
<td class="px-4 py-3 text-gray-600">{{ $team->year_group ?? '' }}</td>
<td class="px-4 py-3 text-center text-gray-600">{{ $team->players_count }}</td>
<td class="px-4 py-3 text-center text-gray-600">{{ $team->events_count }}</td>
<td class="px-4 py-3 text-center">
@if ($team->is_active)
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">{{ __('admin.active') }}</span>
@else
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">{{ __('admin.inactive') }}</span>
@endif
</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('admin.teams.edit', $team) }}" class="text-blue-600 hover:underline text-sm">{{ __('ui.edit') }}</a>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500">{{ __('admin.no_teams_yet') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $teams->links() }}
</div>
</x-layouts.admin>