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,269 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Event;
use App\Models\Player;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PlayerController extends Controller
{
public function index(Request $request)
{
$query = Player::with(['team', 'parents']);
// Team-Scoping: Coach sieht nur Spieler eigener Teams (V04)
$user = auth()->user();
if (!$user->isAdmin()) {
$teamIds = $user->isCoach()
? $user->coachTeams()->pluck('teams.id')
: $user->accessibleTeamIds();
$query->whereIn('team_id', $teamIds);
}
if ($request->filled('team_id')) {
$query->where('team_id', $request->team_id);
}
// Sortierung
$sortable = ['name', 'team', 'jersey_number', 'is_active', 'created_at'];
$sort = in_array($request->input('sort'), $sortable) ? $request->input('sort') : 'created_at';
$direction = $request->input('direction') === 'asc' ? 'asc' : 'desc';
match ($sort) {
'name' => $query->orderBy('last_name', $direction)->orderBy('first_name', $direction),
'team' => $query->orderBy(
Team::select('name')->whereColumn('teams.id', 'players.team_id'), $direction
),
'jersey_number' => $query->orderBy(DB::raw('CASE WHEN jersey_number IS NULL THEN 1 ELSE 0 END'))->orderBy('jersey_number', $direction),
'is_active' => $query->orderBy('is_active', $direction),
default => $query->orderBy('created_at', $direction),
};
$players = $query->paginate(20)->withQueryString();
$teams = Team::active()->orderBy('name')->get();
$trashedPlayers = Player::onlyTrashed()
->with('team')
->where('deleted_at', '>=', now()->subDays(7))
->latest('deleted_at')
->get();
return view('admin.players.index', compact('players', 'teams', 'trashedPlayers', 'sort', 'direction'));
}
public function create()
{
$teams = Team::active()->orderBy('name')->get();
return view('admin.players.create', compact('teams'));
}
public function store(Request $request)
{
$validated = $request->validate([
'first_name' => ['required', 'string', 'max:100'],
'last_name' => ['required', 'string', 'max:100'],
'team_id' => ['required', 'integer', 'exists:teams,id', function ($attribute, $value, $fail) {
$team = Team::find($value);
if (!$team || !$team->is_active) {
$fail(__('validation.exists', ['attribute' => $attribute]));
}
}],
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
'is_active' => ['boolean'],
'photo_permission' => ['boolean'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$validated['is_active'] = $request->boolean('is_active', true);
$validated['photo_permission'] = $request->boolean('photo_permission');
$player = Player::create($validated);
if ($player->is_active) {
Event::syncParticipantsForTeam($player->team_id, auth()->id());
}
ActivityLog::logWithChanges('created', __('admin.log_player_created', ['name' => $player->full_name]), 'Player', $player->id, null, ['name' => $player->full_name, 'team' => $player->team->name ?? $player->team_id]);
return redirect()->route('admin.players.index')
->with('success', __('admin.player_created'));
}
public function edit(Player $player)
{
$player->load('parents');
$teams = Team::active()->orderBy('name')->get();
$users = User::active()->orderBy('name')->get();
return view('admin.players.edit', compact('player', 'teams', 'users'));
}
public function update(Request $request, Player $player)
{
$validated = $request->validate([
'first_name' => ['required', 'string', 'max:100'],
'last_name' => ['required', 'string', 'max:100'],
'team_id' => ['required', 'integer', 'exists:teams,id', function ($attribute, $value, $fail) {
$team = Team::find($value);
if (!$team || !$team->is_active) {
$fail(__('validation.exists', ['attribute' => $attribute]));
}
}],
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
'photo_permission' => ['boolean'],
'notes' => ['nullable', 'string', 'max:2000'],
'profile_picture' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,gif,webp'],
]);
$validated['photo_permission'] = $request->boolean('photo_permission');
// Handle profile picture upload
if ($request->hasFile('profile_picture')) {
if ($player->profile_picture) {
Storage::disk('public')->delete($player->profile_picture);
}
$file = $request->file('profile_picture');
$storedName = 'avatars/' . Str::uuid() . '.' . $file->guessExtension();
Storage::disk('public')->putFileAs('', $file, $storedName);
$validated['profile_picture'] = $storedName;
} else {
unset($validated['profile_picture']);
}
$oldData = ['first_name' => $player->first_name, 'last_name' => $player->last_name, 'team_id' => $player->team_id, 'birth_year' => $player->birth_year, 'jersey_number' => $player->jersey_number, 'photo_permission' => $player->photo_permission];
$oldTeamId = $player->team_id;
$player->update($validated);
// Sync: neues Team bekommt den Spieler, bei Team-Wechsel auch altes Team
if ($player->is_active) {
Event::syncParticipantsForTeam($player->team_id, auth()->id());
}
if ($oldTeamId !== (int) $validated['team_id']) {
Event::syncParticipantsForTeam($oldTeamId, auth()->id());
}
$newData = ['first_name' => $player->first_name, 'last_name' => $player->last_name, 'team_id' => $player->team_id, 'birth_year' => $player->birth_year, 'jersey_number' => $player->jersey_number, 'photo_permission' => $player->photo_permission];
ActivityLog::logWithChanges('updated', __('admin.log_player_updated', ['name' => $player->full_name]), 'Player', $player->id, $oldData, $newData);
return redirect()->route('admin.players.index')
->with('success', __('admin.player_updated'));
}
public function quickUpdate(Request $request, Player $player)
{
$validated = $request->validate([
'team_id' => ['sometimes', 'integer', 'exists:teams,id', function ($attribute, $value, $fail) {
$team = Team::find($value);
if (!$team || !$team->is_active) {
$fail(__('validation.exists', ['attribute' => $attribute]));
}
}],
'is_active' => ['sometimes', 'boolean'],
'photo_permission' => ['sometimes', 'boolean'],
]);
$oldTeamId = $player->team_id;
$player->update($validated);
// Sync future events when team or active status changes
if (isset($validated['team_id']) || isset($validated['is_active'])) {
if ($player->is_active) {
Event::syncParticipantsForTeam($player->team_id, auth()->id());
}
if (isset($validated['team_id']) && $oldTeamId !== (int) $validated['team_id']) {
Event::syncParticipantsForTeam($oldTeamId, auth()->id());
}
}
return response()->json(['success' => true]);
}
public function assignParent(Request $request, Player $player)
{
$validated = $request->validate([
'parent_id' => ['required', 'exists:users,id'],
'relationship_label' => ['nullable', 'string', 'max:50'],
]);
$player->parents()->syncWithoutDetaching([
$validated['parent_id'] => [
'relationship_label' => $validated['relationship_label'] ?? null,
'created_at' => now(),
],
]);
$parent = User::find($validated['parent_id']);
ActivityLog::logWithChanges('parent_assigned', __('admin.log_parent_assigned', ['parent' => $parent?->name, 'player' => $player->full_name]), 'Player', $player->id, null, ['parent' => $parent?->name, 'player' => $player->full_name]);
return back()->with('success', __('admin.parent_assigned'));
}
public function removeParent(Player $player, User $user)
{
$player->parents()->detach($user->id);
ActivityLog::logWithChanges('parent_removed', __('admin.log_parent_removed', ['parent' => $user->name, 'player' => $player->full_name]), 'Player', $player->id, ['parent' => $user->name, 'player' => $player->full_name], null);
return back()->with('success', __('admin.parent_removed'));
}
public function removePicture(Player $player): RedirectResponse
{
if ($player->profile_picture) {
Storage::disk('public')->delete($player->profile_picture);
$player->update(['profile_picture' => null]);
}
return back()->with('success', __('admin.picture_removed'));
}
public function toggleActive(Player $player): RedirectResponse
{
$oldActive = $player->is_active;
$player->update(['is_active' => !$player->is_active]);
// Sync future events
Event::syncParticipantsForTeam($player->team_id, auth()->id());
$status = $player->is_active ? __('admin.activated') : __('admin.deactivated');
ActivityLog::logWithChanges('toggled_active', __('admin.log_player_toggled', ['name' => $player->full_name, 'status' => $status]), 'Player', $player->id, ['is_active' => $oldActive], ['is_active' => $player->is_active]);
return back()->with('success', __('admin.player_toggled', ['status' => $status]));
}
public function destroy(Player $player): RedirectResponse
{
$player->delete();
ActivityLog::logWithChanges('deleted', __('admin.log_player_deleted', ['name' => $player->full_name]), 'Player', $player->id, ['name' => $player->full_name, 'team' => $player->team->name ?? ''], null);
return redirect()->route('admin.players.index')->with('success', __('admin.player_deleted'));
}
public function restore(int $id): RedirectResponse
{
$player = Player::onlyTrashed()->findOrFail($id);
if (! $player->isRestorable()) {
return back()->with('error', __('admin.restore_expired'));
}
$player->restore();
ActivityLog::logWithChanges('restored', __('admin.log_player_restored', ['name' => $player->full_name]), 'Player', $player->id, null, ['name' => $player->full_name]);
return back()->with('success', __('admin.player_restored'));
}
}