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:
149
resources/views/admin/activity-logs/index.blade.php
Normal file
149
resources/views/admin/activity-logs/index.blade.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<x-layouts.admin :title="__('admin.activity_log_title')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.activity_log_title') }}</h1>
|
||||
|
||||
{{-- Filter --}}
|
||||
<form method="GET" class="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.log_category') }}</label>
|
||||
<select name="category" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">{{ __('admin.log_all_categories') }}</option>
|
||||
<option value="auth" {{ request('category') === 'auth' ? 'selected' : '' }}>{{ __('admin.log_cat_auth') }}</option>
|
||||
<option value="users" {{ request('category') === 'users' ? 'selected' : '' }}>{{ __('admin.log_cat_users') }}</option>
|
||||
<option value="players" {{ request('category') === 'players' ? 'selected' : '' }}>{{ __('admin.log_cat_players') }}</option>
|
||||
<option value="events" {{ request('category') === 'events' ? 'selected' : '' }}>{{ __('admin.log_cat_events') }}</option>
|
||||
<option value="files" {{ request('category') === 'files' ? 'selected' : '' }}>{{ __('admin.log_cat_files') }}</option>
|
||||
<option value="settings" {{ request('category') === 'settings' ? 'selected' : '' }}>{{ __('admin.log_cat_settings') }}</option>
|
||||
<option value="dsgvo" {{ request('category') === 'dsgvo' ? 'selected' : '' }}>{{ __('admin.log_cat_dsgvo') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.log_from') }}</label>
|
||||
<input type="date" name="from" value="{{ request('from') }}" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.log_to') }}</label>
|
||||
<input type="date" name="to" value="{{ request('to') }}" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700">{{ __('admin.log_filter') }}</button>
|
||||
<a href="{{ route('admin.activity-logs.index') }}" class="bg-gray-200 text-gray-700 px-4 py-2 rounded-md text-sm font-medium hover:bg-gray-300">{{ __('admin.log_reset') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Tabelle --}}
|
||||
<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">{{ __('admin.log_time') }}</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.log_user') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.log_action') }}</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.log_description') }}</th>
|
||||
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.log_ip') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse ($logs as $log)
|
||||
@php
|
||||
$actionColors = [
|
||||
'login' => 'bg-green-100 text-green-800',
|
||||
'logout' => 'bg-gray-100 text-gray-800',
|
||||
'login_failed' => 'bg-red-100 text-red-800',
|
||||
'registered' => 'bg-blue-100 text-blue-800',
|
||||
'created' => 'bg-blue-100 text-blue-800',
|
||||
'updated' => 'bg-yellow-100 text-yellow-800',
|
||||
'deleted' => 'bg-red-100 text-red-800',
|
||||
'restored' => 'bg-green-100 text-green-800',
|
||||
'toggled_active' => 'bg-yellow-100 text-yellow-800',
|
||||
'role_changed' => 'bg-purple-100 text-purple-800',
|
||||
'password_reset' => 'bg-orange-100 text-orange-800',
|
||||
'parent_assigned' => 'bg-blue-100 text-blue-800',
|
||||
'parent_removed' => 'bg-red-100 text-red-800',
|
||||
'participant_status_changed' => 'bg-yellow-100 text-yellow-800',
|
||||
'status_changed' => 'bg-yellow-100 text-yellow-800',
|
||||
'uploaded' => 'bg-blue-100 text-blue-800',
|
||||
'reverted' => 'bg-orange-100 text-orange-800',
|
||||
'bot_blocked' => 'bg-red-100 text-red-800',
|
||||
'dsgvo_consent_uploaded' => 'bg-blue-100 text-blue-800',
|
||||
'dsgvo_consent_confirmed' => 'bg-green-100 text-green-800',
|
||||
'dsgvo_consent_revoked' => 'bg-yellow-100 text-yellow-800',
|
||||
'dsgvo_consent_removed' => 'bg-orange-100 text-orange-800',
|
||||
'dsgvo_consent_rejected' => 'bg-red-100 text-red-800',
|
||||
'account_self_deleted' => 'bg-red-100 text-red-800',
|
||||
'child_auto_deactivated' => 'bg-red-100 text-red-800',
|
||||
];
|
||||
$color = $actionColors[$log->action] ?? 'bg-gray-100 text-gray-800';
|
||||
$hasDetails = !empty($log->properties);
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50" x-data="{ open: false }">
|
||||
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">{{ $log->created_at->format('d.m.Y H:i') }}</td>
|
||||
<td class="px-4 py-3 text-gray-900">{{ $log->user?->name ?? __('admin.log_system') }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $color }}">{{ $log->action }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600">
|
||||
<div>{{ $log->description }}</div>
|
||||
@if ($hasDetails)
|
||||
<button @click="open = !open" class="text-xs text-blue-600 hover:text-blue-800 mt-1 flex items-center gap-1">
|
||||
<svg class="w-3 h-3 transition-transform" :class="open ? 'rotate-90' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
{{ __('admin.log_details') }}
|
||||
</button>
|
||||
<div x-show="open" x-cloak x-transition class="mt-2 bg-gray-50 rounded-md p-3 text-xs space-y-1">
|
||||
@php
|
||||
$oldData = $log->properties['old'] ?? [];
|
||||
$newData = $log->properties['new'] ?? [];
|
||||
$allKeys = array_unique(array_merge(array_keys($oldData), array_keys($newData)));
|
||||
@endphp
|
||||
@if (count($allKeys) > 0)
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="text-gray-500">
|
||||
<th class="text-left font-medium pr-3 pb-1">{{ __('admin.log_field') }}</th>
|
||||
<th class="text-left font-medium pr-3 pb-1">{{ __('admin.log_old_value') }}</th>
|
||||
<th class="text-left font-medium pb-1">{{ __('admin.log_new_value') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($allKeys as $key)
|
||||
<tr>
|
||||
<td class="pr-3 py-0.5 font-medium text-gray-600">{{ $key }}</td>
|
||||
<td class="pr-3 py-0.5 {{ isset($oldData[$key]) ? 'text-red-600 line-through' : 'text-gray-400' }}">{{ $oldData[$key] ?? '–' }}</td>
|
||||
<td class="py-0.5 {{ isset($newData[$key]) ? 'text-green-700' : 'text-gray-400' }}">{{ $newData[$key] ?? '–' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
<span class="text-gray-400 text-xs">{{ $log->ip_address }}</span>
|
||||
@php
|
||||
$revertableActions = ['deleted', 'toggled_active', 'role_changed', 'status_changed', 'participant_status_changed'];
|
||||
$canRevert = in_array($log->action, $revertableActions) && $log->model_id;
|
||||
@endphp
|
||||
@if ($canRevert)
|
||||
<form method="POST" action="{{ route('admin.activity-logs.revert', $log) }}"
|
||||
onsubmit="return confirm(@js(__('admin.log_revert_confirm')))" class="inline ml-2">
|
||||
@csrf
|
||||
<button type="submit" class="text-xs text-orange-600 hover:text-orange-800 font-medium">
|
||||
{{ __('admin.log_revert') }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-gray-500">{{ __('admin.log_empty') }}</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $logs->links() }}</div>
|
||||
</x-layouts.admin>
|
||||
198
resources/views/admin/dashboard.blade.php
Executable file
198
resources/views/admin/dashboard.blade.php
Executable file
@@ -0,0 +1,198 @@
|
||||
<x-layouts.admin :title="__('admin.dashboard_title')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.dashboard_title') }}</h1>
|
||||
|
||||
{{-- Update-Hinweis --}}
|
||||
@if ($hasUpdate ?? false)
|
||||
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-blue-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800">{{ __('admin.update_available', ['version' => $updateVersion]) }}</p>
|
||||
<a href="{{ route('admin.settings.edit') }}#license" class="text-xs text-blue-600 underline">{{ __('admin.update_details') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Statistik-Karten --}}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<p class="text-sm text-gray-500">{{ __('admin.stat_users') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $stats['users'] }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<p class="text-sm text-gray-500">{{ __('admin.stat_players') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $stats['players'] }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<p class="text-sm text-gray-500">{{ __('admin.stat_upcoming') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $stats['upcoming_events'] }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<p class="text-sm text-gray-500">{{ __('admin.stat_invitations') }}</p>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ $stats['open_invitations'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- DSGVO — Bestätigung ausstehend --}}
|
||||
@if ($pendingDsgvoUsers->isNotEmpty())
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-lg shadow p-6 mb-8" x-data="{ openModal: null }">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<svg class="w-5 h-5 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
<h2 class="text-lg font-semibold text-orange-800">{{ __('admin.dsgvo_pending_title') }}</h2>
|
||||
<span class="bg-orange-200 text-orange-800 text-xs font-medium rounded-full px-2 py-0.5">{{ __('admin.dsgvo_pending_count', ['count' => $pendingDsgvoUsers->count()]) }}</span>
|
||||
</div>
|
||||
<div class="divide-y divide-orange-100">
|
||||
@foreach ($pendingDsgvoUsers as $dsgvoUser)
|
||||
<div class="py-3 flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="{{ $dsgvoUser->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-8 h-8 rounded-full object-cover flex-shrink-0">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $dsgvoUser->name }}</span>
|
||||
<span class="text-xs text-gray-500 block">{{ $dsgvoUser->email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="openModal = {{ $dsgvoUser->id }}"
|
||||
class="bg-blue-600 text-white text-xs px-3 py-1.5 rounded-md hover:bg-blue-700 transition-colors">
|
||||
{{ __('admin.dsgvo_view_document') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Modal für Dokument-Ansicht + Bestätigung/Ablehnung --}}
|
||||
<template x-teleport="body">
|
||||
<div x-show="openModal === {{ $dsgvoUser->id }}" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="openModal = null">
|
||||
<div class="fixed inset-0 bg-black/50" @click="openModal = null"></div>
|
||||
<div class="relative bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col z-10">
|
||||
{{-- Modal Header --}}
|
||||
<div class="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ __('admin.dsgvo_consent_document') }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ $dsgvoUser->name }} ({{ $dsgvoUser->email }})</p>
|
||||
</div>
|
||||
<button @click="openModal = null" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Modal Body: Dokument-Vorschau --}}
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
@php
|
||||
$filePath = $dsgvoUser->dsgvo_consent_file;
|
||||
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
@endphp
|
||||
@if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp']))
|
||||
<img src="{{ route('admin.users.view-dsgvo-consent', $dsgvoUser) }}" alt="DSGVO Consent" class="max-w-full rounded-lg mx-auto">
|
||||
@elseif ($ext === 'pdf')
|
||||
<iframe src="{{ route('admin.users.view-dsgvo-consent', $dsgvoUser) }}" class="w-full h-96 rounded-lg border"></iframe>
|
||||
@else
|
||||
<p class="text-sm text-gray-500 text-center py-8">{{ __('admin.dsgvo_preview_not_available') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Modal Footer: Aktionen --}}
|
||||
<div class="flex items-center justify-end gap-3 p-4 border-t bg-gray-50 rounded-b-lg">
|
||||
<form method="POST" action="{{ route('admin.users.dsgvo-reject', $dsgvoUser) }}"
|
||||
onsubmit="return confirm(@js(__('admin.dsgvo_reject_confirm')))">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<button type="submit" class="bg-red-600 text-white text-sm px-4 py-2 rounded-md hover:bg-red-700 transition-colors">
|
||||
{{ __('admin.dsgvo_reject') }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('admin.users.dsgvo-toggle', $dsgvoUser) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<button type="submit" class="bg-green-600 text-white text-sm px-4 py-2 rounded-md hover:bg-green-700 transition-colors">
|
||||
{{ __('admin.dsgvo_confirm') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Quick-Links --}}
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('admin.quick_links') }}</h2>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="{{ route('admin.events.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700">{{ __('admin.new_event') }}</a>
|
||||
<a href="{{ route('admin.players.create') }}" class="bg-green-600 text-white px-4 py-2 rounded text-sm hover:bg-green-700">{{ __('admin.new_player') }}</a>
|
||||
<a href="{{ route('admin.invitations.create') }}" class="bg-purple-600 text-white px-4 py-2 rounded text-sm hover:bg-purple-700">{{ __('admin.new_invitation') }}</a>
|
||||
<a href="{{ route('admin.teams.create') }}" class="bg-gray-600 text-white px-4 py-2 rounded text-sm hover:bg-gray-700">{{ __('admin.new_team') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Events mit offenen Rückmeldungen --}}
|
||||
@if ($eventsWithOpenResponses->isNotEmpty())
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('admin.open_responses_title') }}</h2>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach ($eventsWithOpenResponses as $event)
|
||||
<div class="py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<a href="{{ route('admin.events.edit', $event) }}" class="text-sm font-medium text-blue-600 hover:underline">{{ $event->title }}</a>
|
||||
<p class="text-xs text-gray-500">{{ $event->team->name }} — {{ $event->start_at->translatedFormat(__('ui.date_format')) }}</p>
|
||||
</div>
|
||||
<span class="text-sm text-orange-600 font-medium">{{ __('admin.x_open', ['count' => $event->open_count]) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- DSGVO-Ereignisse --}}
|
||||
@if ($dsgvoEvents->isNotEmpty())
|
||||
<div class="bg-white rounded-lg shadow p-6 mt-8">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('admin.dsgvo_events_title') }}</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2 font-medium text-gray-700 text-xs">{{ __('admin.log_time') }}</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-gray-700 text-xs">{{ __('admin.log_user') }}</th>
|
||||
<th class="text-center px-4 py-2 font-medium text-gray-700 text-xs">{{ __('admin.log_action') }}</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-gray-700 text-xs">{{ __('admin.log_description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($dsgvoEvents as $logEntry)
|
||||
@php
|
||||
$dsgvoColors = [
|
||||
'dsgvo_consent_uploaded' => 'bg-blue-100 text-blue-800',
|
||||
'dsgvo_consent_confirmed' => 'bg-green-100 text-green-800',
|
||||
'dsgvo_consent_revoked' => 'bg-yellow-100 text-yellow-800',
|
||||
'dsgvo_consent_removed' => 'bg-orange-100 text-orange-800',
|
||||
'dsgvo_consent_rejected' => 'bg-red-100 text-red-800',
|
||||
'account_self_deleted' => 'bg-red-100 text-red-800',
|
||||
'child_auto_deactivated' => 'bg-red-100 text-red-800',
|
||||
];
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2 text-xs text-gray-500 whitespace-nowrap">
|
||||
{{ $logEntry->created_at->translatedFormat(__('ui.date_format_short')) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-900">
|
||||
{{ $logEntry->user?->name ?? __('admin.log_system') }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $dsgvoColors[$logEntry->action] ?? 'bg-gray-100 text-gray-800' }}">
|
||||
{{ __('admin.dsgvo_action_' . $logEntry->action) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-600">{{ $logEntry->description }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</x-layouts.admin>
|
||||
452
resources/views/admin/events/create.blade.php
Executable file
452
resources/views/admin/events/create.blade.php
Executable file
@@ -0,0 +1,452 @@
|
||||
<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') }} ↓
|
||||
</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>
|
||||
592
resources/views/admin/events/edit.blade.php
Executable file
592
resources/views/admin/events/edit.blade.php
Executable file
@@ -0,0 +1,592 @@
|
||||
<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>
|
||||
124
resources/views/admin/events/index.blade.php
Executable file
124
resources/views/admin/events/index.blade.php
Executable file
@@ -0,0 +1,124 @@
|
||||
<x-layouts.admin :title="__('admin.events_title')">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ __('admin.events_title') }}</h1>
|
||||
<a href="{{ route('admin.events.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
|
||||
{{ __('admin.new_event') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<form method="GET" class="mb-4 flex gap-3">
|
||||
<select name="team_id" onchange="this.form.submit()" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">{{ __('ui.all_teams') }}</option>
|
||||
@foreach ($teams as $team)
|
||||
<option value="{{ $team->id }}" {{ request('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select name="status" onchange="this.form.submit()" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">{{ __('admin.all_status') }}</option>
|
||||
@foreach (\App\Enums\EventStatus::cases() as $status)
|
||||
<option value="{{ $status->value }}" {{ request('status') === $status->value ? 'selected' : '' }}>{{ $status->label() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</form>
|
||||
|
||||
{{-- Event-Karten --}}
|
||||
@forelse ($events as $event)
|
||||
@php
|
||||
$minStatus = $event->minimumsStatus();
|
||||
$bgClass = match($minStatus) { true => 'bg-green-50 border-green-200', false => 'bg-red-50 border-red-200', default => 'bg-white border-gray-200' };
|
||||
@endphp
|
||||
<div class="{{ $bgClass }} border rounded-lg shadow-sm p-4 mb-3">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center gap-4">
|
||||
{{-- Links: Typ, Team, Titel, Datum, Ort, Status --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<x-event-type-badge :type="$event->type" />
|
||||
<span class="text-xs text-gray-500">{{ $event->team->name }}</span>
|
||||
@if ($event->status === \App\Enums\EventStatus::Published)
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">{{ $event->status->label() }}</span>
|
||||
@elseif ($event->status === \App\Enums\EventStatus::Cancelled)
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">{{ $event->status->label() }}</span>
|
||||
@else
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">{{ $event->status->label() }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900 {{ $event->status === \App\Enums\EventStatus::Cancelled ? 'line-through text-gray-400' : '' }}">
|
||||
{{ $event->title }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ $event->start_at->translatedFormat(__('ui.date_format')) }} {{ __('ui.clock') }}
|
||||
@if ($event->end_at)
|
||||
– {{ $event->end_at->format('H:i') }} {{ __('ui.clock') }}
|
||||
@endif
|
||||
</p>
|
||||
@if ($event->location_name)
|
||||
<p class="text-sm text-gray-500">{{ $event->location_name }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Mitte: Ampel-Boxen --}}
|
||||
<div class="shrink-0">
|
||||
<x-event-status-boxes :event="$event" />
|
||||
</div>
|
||||
|
||||
{{-- Rechts: Aktionen --}}
|
||||
<div class="shrink-0 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a href="{{ route('admin.events.edit', $event) }}" class="text-blue-600 hover:underline text-sm">{{ __('ui.edit') }}</a>
|
||||
<form method="POST" action="{{ route('admin.events.destroy', $event) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_event')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 text-sm">{{ __('ui.delete') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
{{ __('admin.no_events_yet') }}
|
||||
</div>
|
||||
@endforelse
|
||||
|
||||
<div class="mt-4">{{ $events->links() }}</div>
|
||||
|
||||
{{-- Papierkorb --}}
|
||||
@if ($trashedEvents->isNotEmpty())
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-3">{{ __('admin.trash') }}</h2>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto border border-red-200">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-red-50 border-b border-red-200">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.event_title') }}</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.nav_teams') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.deleted_at') }}</th>
|
||||
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($trashedEvents as $event)
|
||||
<tr class="hover:bg-red-50/50">
|
||||
<td class="px-4 py-3 font-medium text-gray-500">{{ $event->title }}</td>
|
||||
<td class="px-4 py-3 text-gray-400">{{ $event->team?->name ?? '–' }}</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-gray-500">{{ $event->deleted_at->diffForHumans() }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
@if ($event->isRestorable())
|
||||
<form method="POST" action="{{ route('admin.events.restore', $event->id) }}" class="inline">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<button type="submit" class="text-xs text-green-600 hover:text-green-800 font-medium">{{ __('admin.restore') }}</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="text-xs text-gray-400">{{ __('admin.restore_expired') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</x-layouts.admin>
|
||||
49
resources/views/admin/files/create.blade.php
Normal file
49
resources/views/admin/files/create.blade.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<x-layouts.admin :title="__('admin.upload_file')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.upload_file') }}</h1>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
|
||||
<form method="POST" action="{{ route('admin.files.store') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="file_category_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.file_category') }} *</label>
|
||||
<select name="file_category_id" id="file_category_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="">{{ __('admin.select_category') }}</option>
|
||||
@foreach ($categories as $cat)
|
||||
<option value="{{ $cat->id }}" {{ old('file_category_id') == $cat->id ? 'selected' : '' }}>{{ $cat->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('file_category_id')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="file" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.upload_file') }} *</label>
|
||||
<div x-data="{ fileName: '', dragging: false }" class="relative">
|
||||
<div
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="dragging = false; $refs.fileInput.files = $event.dataTransfer.files; fileName = $refs.fileInput.files[0]?.name || ''"
|
||||
:class="dragging ? 'border-blue-400 bg-blue-50' : 'border-gray-300'"
|
||||
class="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition-colors"
|
||||
@click="$refs.fileInput.click()">
|
||||
<svg class="mx-auto h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-600" x-show="!fileName">{{ __('admin.allowed_file_types') }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500" x-show="!fileName">{{ __('admin.max_file_size') }}</p>
|
||||
<p class="mt-2 text-sm font-medium text-blue-600" x-show="fileName" x-text="fileName"></p>
|
||||
</div>
|
||||
<input type="file" name="file" id="file" x-ref="fileInput" class="hidden"
|
||||
accept=".pdf,.docx,.xlsx,.jpg,.jpeg,.png,.gif,.webp"
|
||||
@change="fileName = $refs.fileInput.files[0]?.name || ''">
|
||||
</div>
|
||||
@error('file')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</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.upload_file') }}</button>
|
||||
<a href="{{ route('admin.files.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts.admin>
|
||||
89
resources/views/admin/files/index.blade.php
Normal file
89
resources/views/admin/files/index.blade.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<x-layouts.admin :title="__('admin.files_title')">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ __('admin.files_title') }}</h1>
|
||||
<a href="{{ route('admin.files.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
|
||||
{{ __('admin.upload_file') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Kategorie-Tabs --}}
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
<a href="{{ route('admin.files.index') }}"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium {{ !$activeCategory ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
|
||||
{{ __('ui.all') }} ({{ $categories->sum('files_count') }})
|
||||
</a>
|
||||
@foreach ($categories as $category)
|
||||
<a href="{{ route('admin.files.index', ['category' => $category->slug]) }}"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium {{ $activeCategory === $category->slug ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }} {{ !$category->is_active ? 'opacity-50' : '' }}">
|
||||
{{ $category->name }} ({{ $category->files_count }})
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Datei-Liste --}}
|
||||
@if ($files->isEmpty())
|
||||
<div class="bg-white rounded-lg shadow p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m6.75 12H9.75m3 0h3m-1.5-3H12m-3 3V18m0-3h.008v.008H9v-.008zm0 3h.008v.008H9V18z" />
|
||||
</svg>
|
||||
<p class="mt-3 text-sm text-gray-500">{{ $activeCategory ? __('admin.no_files_yet') : __('admin.no_files_at_all') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid gap-3" x-data>
|
||||
@foreach ($files as $file)
|
||||
<div class="bg-white rounded-lg shadow px-4 sm:px-5 py-4 flex items-center gap-3 sm:gap-4 min-w-0">
|
||||
{{-- Clickable file area --}}
|
||||
<div @click="$dispatch('open-file-preview', @js($file->previewData()))"
|
||||
class="flex items-center gap-3 min-w-0 flex-1 cursor-pointer hover:opacity-80 transition-opacity">
|
||||
{{-- Thumbnail oder Icon --}}
|
||||
@if ($file->isImage())
|
||||
<img src="{{ route('files.preview', $file) }}" alt="" class="flex-shrink-0 w-10 h-10 rounded-lg object-cover bg-gray-100" loading="lazy">
|
||||
@else
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center
|
||||
{{ match($file->iconType()) {
|
||||
'pdf' => 'bg-red-100 text-red-600',
|
||||
'word' => 'bg-blue-100 text-blue-600',
|
||||
'excel' => 'bg-green-100 text-green-600',
|
||||
default => 'bg-gray-100 text-gray-600',
|
||||
} }}">
|
||||
@if ($file->iconType() === 'pdf')
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
|
||||
@elseif ($file->iconType() === 'word')
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM9 13h6v1H9v-1zm0 2h6v1H9v-1zm0 2h4v1H9v-1z"/></svg>
|
||||
@elseif ($file->iconType() === 'excel')
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM9 13h2v2H9v-2zm0 3h2v2H9v-2zm3-3h2v2h-2v-2zm0 3h2v2h-2v-2z"/></svg>
|
||||
@else
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ $file->original_name }}</p>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-gray-500 mt-0.5">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">{{ $file->category->name }}</span>
|
||||
<span>{{ $file->humanSize() }}</span>
|
||||
<span class="hidden sm:inline">{{ $file->uploader->name ?? '–' }}</span>
|
||||
<span class="hidden sm:inline">{{ $file->created_at->translatedFormat(__('ui.date_format_short')) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Admin Actions --}}
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<a href="{{ route('files.download', $file) }}" class="text-xs text-blue-600 hover:text-blue-800 font-medium">{{ __('ui.download') }}</a>
|
||||
<form method="POST" action="{{ route('admin.files.destroy', $file) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_file')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs text-red-600 hover:text-red-800">{{ __('ui.delete') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $files->links() }}</div>
|
||||
@endif
|
||||
|
||||
<x-file-preview-modal />
|
||||
</x-layouts.admin>
|
||||
49
resources/views/admin/invitations/create.blade.php
Executable file
49
resources/views/admin/invitations/create.blade.php
Executable file
@@ -0,0 +1,49 @@
|
||||
<x-layouts.admin :title="__('admin.create_invitation')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.new_invitation') }}</h1>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
|
||||
<form method="POST" action="{{ route('admin.invitations.store') }}">
|
||||
@csrf
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.email_optional') }}</label>
|
||||
<input type="email" name="email" id="email" value="{{ old('email') }}"
|
||||
placeholder="{{ __('admin.email_optional_hint') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('email') border-red-500 @enderror">
|
||||
@error('email')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="expires_in_days" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.valid_for_days') }} *</label>
|
||||
<input type="number" name="expires_in_days" id="expires_in_days" value="{{ old('expires_in_days', 7) }}" min="1" max="90" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('expires_in_days') border-red-500 @enderror">
|
||||
@error('expires_in_days')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.assign_players') }}</label>
|
||||
<p class="text-xs text-gray-500 mb-2">{{ __('admin.player_assignment_hint') }}</p>
|
||||
<div class="border border-gray-300 rounded-md max-h-60 overflow-y-auto p-2 space-y-1">
|
||||
@forelse ($players as $player)
|
||||
<label class="flex items-center px-2 py-1 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input type="checkbox" name="player_ids[]" value="{{ $player->id }}"
|
||||
{{ in_array($player->id, old('player_ids', [])) ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 mr-2">
|
||||
<span class="text-sm">{{ $player->full_name }}</span>
|
||||
<span class="text-xs text-gray-400 ml-auto">{{ $player->team->name }}</span>
|
||||
</label>
|
||||
@empty
|
||||
<p class="text-sm text-gray-400 px-2 py-1">{{ __('admin.no_active_players') }}</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@error('player_ids')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
@error('player_ids.*')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</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_invitation') }}</button>
|
||||
<a href="{{ route('admin.invitations.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts.admin>
|
||||
113
resources/views/admin/invitations/index.blade.php
Executable file
113
resources/views/admin/invitations/index.blade.php
Executable file
@@ -0,0 +1,113 @@
|
||||
<x-layouts.admin :title="__('admin.invitations_title')">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ __('admin.invitations_title') }}</h1>
|
||||
<a href="{{ route('admin.invitations.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
|
||||
{{ __('admin.new_invitation') }}
|
||||
</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">{{ __('admin.invite_link') }}</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('ui.email') }}</th>
|
||||
<th class="text-left 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.status') }}</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.created_label') }}</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 ($invitations as $invitation)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3" x-data="{ copied: false }">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-xs bg-gray-100 px-2 py-1 rounded truncate max-w-[200px]" x-ref="link">{{ route('register', $invitation->token) }}</code>
|
||||
<button
|
||||
type="button"
|
||||
@click="copyToClipboard('{{ route('register', $invitation->token) }}', () => { copied = true; setTimeout(() => copied = false, 2000) })"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded border text-xs font-medium transition-colors"
|
||||
:class="copied ? 'bg-green-50 border-green-300 text-green-700' : 'bg-white border-gray-300 text-blue-600 hover:bg-blue-50 hover:border-blue-400'"
|
||||
>
|
||||
<svg x-show="!copied" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
<svg x-show="copied" x-cloak class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
<span x-text="copied ? '{{ __('admin.copied') }}' : '{{ __('admin.copy_link') }}'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600">
|
||||
{{ $invitation->email ?? '–' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-600">
|
||||
@foreach ($invitation->players as $player)
|
||||
{{ $player->full_name }} ({{ $player->team->name }})@if (!$loop->last), @endif
|
||||
@endforeach
|
||||
@if ($invitation->players->isEmpty())
|
||||
<span class="text-gray-400">{{ __('admin.no_assignment') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if ($invitation->isAccepted())
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">{{ __('admin.used') }}</span>
|
||||
@elseif ($invitation->isValid())
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">{{ __('admin.pending') }}</span>
|
||||
@else
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">{{ __('admin.expired') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500">
|
||||
{{ $invitation->created_at->translatedFormat(__('ui.date_format_short')) }}<br>
|
||||
<span class="text-gray-400">{{ __('admin.created_by') }} {{ $invitation->creator->name }}</span><br>
|
||||
<span class="text-gray-400">{{ __('admin.valid_until') }} {{ $invitation->expires_at->translatedFormat(__('ui.date_format_date')) }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
@if (!$invitation->isAccepted())
|
||||
<form method="POST" action="{{ route('admin.invitations.destroy', $invitation) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_invitation')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 text-xs">{{ __('ui.delete') }}</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-500">{{ __('admin.no_invitations_yet') }}</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $invitations->links() }}</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function copyToClipboard(text, onSuccess) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess).catch(function () {
|
||||
fallbackCopy(text, onSuccess);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text, onSuccess);
|
||||
}
|
||||
}
|
||||
function fallbackCopy(text, onSuccess) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
console.error('Copy failed', e);
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-layouts.admin>
|
||||
166
resources/views/admin/list-generator/create.blade.php
Normal file
166
resources/views/admin/list-generator/create.blade.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<x-layouts.admin :title="__('admin.list_generator_title')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.list_generator_title') }}</h1>
|
||||
|
||||
<form method="POST" action="{{ route('admin.list-generator.store') }}"
|
||||
x-data="{
|
||||
source: @js(old('source', 'players')),
|
||||
customColumns: @js(old('custom_columns', [])),
|
||||
addColumn() { this.customColumns.push('') },
|
||||
removeColumn(i) { this.customColumns.splice(i, 1) }
|
||||
}">
|
||||
@csrf
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6 space-y-5">
|
||||
{{-- Betreff --}}
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.list_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 text-sm @error('title') border-red-500 @enderror"
|
||||
placeholder="{{ __('admin.list_title') }}">
|
||||
@error('title')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
{{-- Untertitel --}}
|
||||
<div>
|
||||
<label for="subtitle" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.list_subtitle') }}</label>
|
||||
<input type="text" name="subtitle" id="subtitle" value="{{ old('subtitle') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
placeholder="{{ __('admin.list_subtitle') }}">
|
||||
</div>
|
||||
|
||||
{{-- Notizen --}}
|
||||
<div>
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.list_notes') }}</label>
|
||||
<textarea name="notes" id="notes" rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
placeholder="{{ __('admin.list_notes') }}">{{ old('notes') }}</textarea>
|
||||
</div>
|
||||
|
||||
{{-- Team-Filter --}}
|
||||
<div>
|
||||
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.nav_teams') }}</label>
|
||||
<select name="team_id" id="team_id" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">{{ __('admin.list_all_teams') }}</option>
|
||||
@foreach ($teams as $team)
|
||||
<option value="{{ $team->id }}" {{ old('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Zeilen-Quelle --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.list_source') }} *</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="source" value="players" x-model="source" class="text-blue-600">
|
||||
<span class="text-sm">{{ __('admin.list_source_players') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="source" value="parents" x-model="source" class="text-blue-600">
|
||||
<span class="text-sm">{{ __('admin.list_source_parents') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="source" value="freetext" x-model="source" class="text-blue-600">
|
||||
<span class="text-sm">{{ __('admin.list_source_freetext') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
@error('source')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
{{-- Freitext-Feld --}}
|
||||
<div x-show="source === 'freetext'" x-cloak>
|
||||
<label for="freetext_rows" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.list_source_freetext') }}</label>
|
||||
<textarea name="freetext_rows" id="freetext_rows" rows="6"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
|
||||
placeholder="{{ __('admin.list_freetext_hint') }}">{{ old('freetext_rows') }}</textarea>
|
||||
@error('freetext_rows')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
{{-- Spalten: Spieler --}}
|
||||
<div x-show="source === 'players'" x-cloak>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.list_columns') }}</label>
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" checked disabled class="text-blue-600 rounded">
|
||||
<span class="text-sm text-gray-600">{{ __('ui.name') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="columns[]" value="team" {{ in_array('team', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
|
||||
<span class="text-sm">{{ __('admin.nav_teams') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="columns[]" value="jersey_number" {{ in_array('jersey_number', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
|
||||
<span class="text-sm">{{ __('admin.jersey_number') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="columns[]" value="birth_year" {{ in_array('birth_year', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
|
||||
<span class="text-sm">{{ __('admin.birth_year') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="columns[]" value="parents" {{ in_array('parents', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
|
||||
<span class="text-sm">{{ __('admin.parents') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="columns[]" value="photo_permission" {{ in_array('photo_permission', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
|
||||
<span class="text-sm">{{ __('admin.photo_permission') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Spalten: Eltern --}}
|
||||
<div x-show="source === 'parents'" x-cloak>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.list_columns') }}</label>
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" checked disabled class="text-blue-600 rounded">
|
||||
<span class="text-sm text-gray-600">{{ __('ui.name') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="columns[]" value="team" {{ in_array('team', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
|
||||
<span class="text-sm">{{ __('admin.nav_teams') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="columns[]" value="email" {{ in_array('email', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
|
||||
<span class="text-sm">{{ __('ui.email') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="columns[]" value="phone" {{ in_array('phone', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
|
||||
<span class="text-sm">{{ __('admin.phone') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="columns[]" value="children" {{ in_array('children', old('columns', [])) ? 'checked' : '' }} class="text-blue-600 rounded">
|
||||
<span class="text-sm">{{ __('admin.children') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Zusätzliche Spalten --}}
|
||||
<div x-show="source !== ''" x-cloak>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.list_custom_columns') }}</label>
|
||||
<div class="space-y-2">
|
||||
<template x-for="(col, index) in customColumns" :key="index">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" :name="'custom_columns[' + index + ']'" x-model="customColumns[index]"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
:placeholder="'{{ __('admin.list_column_name') }}'">
|
||||
<button type="button" @click="removeColumn(index)" class="text-red-500 hover:text-red-700 p-1">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" @click="addColumn()" class="mt-2 text-sm text-blue-600 hover:text-blue-800 inline-flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
{{ __('admin.list_add_column') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Submit --}}
|
||||
<div class="pt-2">
|
||||
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
|
||||
{{ __('admin.list_generate') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</x-layouts.admin>
|
||||
176
resources/views/admin/list-generator/document.blade.php
Normal file
176
resources/views/admin/list-generator/document.blade.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ $title }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: {{ $orientation ?? 'portrait' }};
|
||||
margin: 10mm;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'DejaVu Sans', Helvetica, Arial, sans-serif;
|
||||
font-size: {{ $fontSize ?? 10 }}pt;
|
||||
color: #374151;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
/* Card container */
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Dark header */
|
||||
.card-header {
|
||||
background: #1f2937;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
.card-header h1 {
|
||||
font-size: {{ max(($fontSize ?? 10) + 4, 13) }}pt;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
}
|
||||
.card-header .subtitle {
|
||||
font-size: {{ max(($fontSize ?? 10), 9) }}pt;
|
||||
color: #d1d5db;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Notes bar */
|
||||
.notes-bar {
|
||||
padding: 8px 20px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: {{ max(($fontSize ?? 10) - 1, 8) }}pt;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th {
|
||||
background: #f3f4f6;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 7px 12px;
|
||||
text-align: left;
|
||||
font-size: {{ max(($fontSize ?? 10) - 2, 7) }}pt;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
td {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
font-size: {{ $fontSize ?? 10 }}pt;
|
||||
color: #374151;
|
||||
}
|
||||
tr:nth-child(even) td {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.row-num {
|
||||
color: #9ca3af;
|
||||
font-size: {{ max(($fontSize ?? 10) - 2, 7) }}pt;
|
||||
width: 28px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.empty-cell {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.link {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Footer bar */
|
||||
.card-footer {
|
||||
padding: 8px 20px;
|
||||
background: #f9fafb;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: {{ max(($fontSize ?? 10) - 3, 7) }}pt;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.card-footer table { border: none; }
|
||||
.card-footer td {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: {{ max(($fontSize ?? 10) - 3, 7) }}pt;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.card-footer .right {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{{-- Dark header --}}
|
||||
<div class="card-header">
|
||||
<h1>{{ $title }}</h1>
|
||||
@if ($subtitle)
|
||||
<div class="subtitle">{{ $subtitle }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Notes --}}
|
||||
@if ($notes)
|
||||
<div class="notes-bar">{{ $notes }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Data table --}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="row-num">#</th>
|
||||
@foreach ($columns as $key => $header)
|
||||
<th>{{ $header }}</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($rows as $i => $row)
|
||||
<tr>
|
||||
<td class="row-num">{{ $i + 1 }}</td>
|
||||
@foreach ($columns as $key => $header)
|
||||
<td>
|
||||
@if (($key === 'email') && !empty($row[$key]))
|
||||
<a class="link" href="mailto:{{ $row[$key] }}">{{ $row[$key] }}</a>
|
||||
@elseif (($key === 'phone') && !empty($row[$key]) && $row[$key] !== '–')
|
||||
<a class="link" href="tel:{{ $row[$key] }}">{{ $row[$key] }}</a>
|
||||
@elseif (($row[$key] ?? '') === '')
|
||||
<span class="empty-cell">—</span>
|
||||
@else
|
||||
{{ $row[$key] }}
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="{{ count($columns) + 1 }}" style="text-align: center; color: #9ca3af; padding: 20px;">
|
||||
{{ __('admin.no_entries') }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{-- Footer bar --}}
|
||||
<div class="card-footer">
|
||||
<table>
|
||||
<tr>
|
||||
<td>{{ count($rows) }} {{ __('admin.list_entries_count') }}</td>
|
||||
<td class="right">{{ __('admin.list_generated_at') }}: {{ $generatedAt->translatedFormat('d.m.Y, H:i') }} Uhr</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
90
resources/views/admin/list-generator/result.blade.php
Normal file
90
resources/views/admin/list-generator/result.blade.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<x-layouts.admin :title="__('admin.list_result_title')">
|
||||
{{-- Success Banner --}}
|
||||
<div class="no-print mb-5 bg-green-50 border border-green-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-800">{{ __('admin.list_result_title') }}</p>
|
||||
<p class="text-xs text-green-600 mt-0.5">{{ __('admin.list_saved_info', ['name' => $file->original_name]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Action Buttons --}}
|
||||
<div class="no-print mb-5 flex flex-wrap items-center gap-3">
|
||||
<a href="{{ route('files.download', $file) }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium inline-flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
{{ __('admin.list_download_pdf') }}
|
||||
</a>
|
||||
<a href="{{ route('admin.list-generator.create') }}" class="bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-50 text-sm font-medium inline-flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
{{ __('admin.list_new') }}
|
||||
</a>
|
||||
<a href="{{ route('admin.files.index', ['category' => 'allgemein']) }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
{{ __('admin.list_to_library') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Preview Card --}}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
{{-- Card Header --}}
|
||||
<div class="bg-gray-800 px-6 py-4">
|
||||
<h1 class="text-xl font-bold text-white">{{ $title }}</h1>
|
||||
@if ($subtitle)
|
||||
<p class="text-gray-300 text-sm mt-0.5">{{ $subtitle }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Notes --}}
|
||||
@if ($notes)
|
||||
<div class="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
||||
<p class="text-sm text-gray-600">{{ $notes }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-100 border-b border-gray-200">
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-10">#</th>
|
||||
@foreach ($columns as $key => $header)
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{{ $header }}</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse ($rows as $i => $row)
|
||||
<tr class="hover:bg-blue-50/50 transition-colors">
|
||||
<td class="px-4 py-2 text-gray-400 text-xs font-mono">{{ $i + 1 }}</td>
|
||||
@foreach ($columns as $key => $header)
|
||||
<td class="px-4 py-2 {{ ($row[$key] ?? '') === '' ? 'text-gray-300' : 'text-gray-700' }}">
|
||||
@if (($key === 'email') && !empty($row[$key]))
|
||||
<a href="mailto:{{ $row[$key] }}" class="text-blue-600 hover:underline">{{ $row[$key] }}</a>
|
||||
@elseif (($key === 'phone') && !empty($row[$key]) && $row[$key] !== '–')
|
||||
<a href="tel:{{ $row[$key] }}" class="text-blue-600 hover:underline">{{ $row[$key] }}</a>
|
||||
@elseif (($row[$key] ?? '') === '')
|
||||
<span class="text-gray-300">—</span>
|
||||
@else
|
||||
{{ $row[$key] }}
|
||||
@endif
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="{{ count($columns) + 1 }}" class="px-4 py-8 text-center text-gray-400">
|
||||
{{ __('admin.no_entries') }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- Card Footer --}}
|
||||
<div class="px-6 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
|
||||
<span class="text-xs text-gray-400">{{ count($rows) }} {{ __('admin.list_entries_count') }}</span>
|
||||
<span class="text-xs text-gray-400">{{ __('admin.list_generated_at') }}: {{ now()->translatedFormat(__('ui.date_format')) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts.admin>
|
||||
196
resources/views/admin/locations/index.blade.php
Executable file
196
resources/views/admin/locations/index.blade.php
Executable file
@@ -0,0 +1,196 @@
|
||||
<x-layouts.admin :title="__('admin.locations_title')">
|
||||
@push('styles')
|
||||
<style>
|
||||
.photon-dropdown { position: absolute; z-index: 10; margin-top: 0.25rem; width: 100%; background: white; border: 1px solid #d1d5db; border-radius: 0.375rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,.1); max-height: 15rem; overflow-y: auto; }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.locations_title') }}</h1>
|
||||
|
||||
{{-- Neuen Ort anlegen --}}
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6 max-w-3xl" x-data="locationForm()">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ __('admin.location_add') }}</h2>
|
||||
<form method="POST" action="{{ route('admin.locations.store') }}">
|
||||
@csrf
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.location_name_label') }} *</label>
|
||||
<input type="text" name="name" value="{{ old('name') }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="{{ __('admin.location_name_placeholder') }}">
|
||||
@error('name')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.search_address') }}</label>
|
||||
<input type="text" x-model="query" @input.debounce.300ms="search()" @keydown.escape="results = []"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="{{ __('admin.search_address_hint') }}" autocomplete="off">
|
||||
<div x-show="results.length > 0" x-cloak @click.outside="results = []" class="photon-dropdown">
|
||||
<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 text-sm">
|
||||
<span class="block 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>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.address') }}</label>
|
||||
<input type="text" name="address_text" x-model="addressText"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="{{ __('admin.address_manual_hint') }}">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Lat</label>
|
||||
<input type="text" name="location_lat" x-model="lat" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Lng</label>
|
||||
<input type="text" name="location_lng" x-model="lng" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('admin.location_save') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Bestehende Orte --}}
|
||||
@if ($locations->isNotEmpty())
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto max-w-3xl">
|
||||
<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">{{ __('admin.location_name_label') }}</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.address') }}</th>
|
||||
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($locations as $location)
|
||||
<tr class="hover:bg-gray-50" x-data="{ editing: false }">
|
||||
{{-- Anzeige-Modus --}}
|
||||
<template x-if="!editing">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{{ $location->name }}</td>
|
||||
</template>
|
||||
<template x-if="!editing">
|
||||
<td class="px-4 py-3 text-gray-600">
|
||||
{{ $location->address_text ?: '–' }}
|
||||
@if ($location->location_lat)
|
||||
<span class="text-xs text-gray-400 ml-1">({{ number_format($location->location_lat, 4) }}, {{ number_format($location->location_lng, 4) }})</span>
|
||||
@endif
|
||||
</td>
|
||||
</template>
|
||||
<template x-if="!editing">
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
<button type="button" @click="editing = true" class="text-xs text-blue-600 hover:text-blue-800 mr-2">{{ __('ui.edit') }}</button>
|
||||
<form method="POST" action="{{ route('admin.locations.destroy', $location) }}" class="inline" onsubmit="return confirm(@js(__('admin.location_confirm_delete')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs text-red-600 hover:text-red-800">{{ __('ui.delete') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
{{-- Bearbeitungs-Modus --}}
|
||||
<template x-if="editing">
|
||||
<td colspan="3" class="px-4 py-3" x-data="locationForm(@js($location->address_text ?? ''), @js($location->location_lat), @js($location->location_lng))">
|
||||
<form method="POST" action="{{ route('admin.locations.update', $location) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 items-end">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.location_name_label') }}</label>
|
||||
<input type="text" name="name" value="{{ $location->name }}" required class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.search_address') }}</label>
|
||||
<input type="text" x-model="query" @input.debounce.300ms="search()" @keydown.escape="results = []"
|
||||
class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm" autocomplete="off">
|
||||
<div x-show="results.length > 0" x-cloak @click.outside="results = []" class="photon-dropdown">
|
||||
<template x-for="(r, idx) in results" :key="idx">
|
||||
<button type="button" @click="select(r)" class="w-full text-left px-2 py-1.5 hover:bg-blue-50 border-b border-gray-100 text-xs">
|
||||
<span class="block font-medium" x-text="r.title"></span>
|
||||
<span class="block text-gray-500" x-text="r.subtitle"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1">{{ __('admin.address') }}</label>
|
||||
<input type="text" name="address_text" x-model="addressText" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||
<input type="hidden" name="location_lat" x-model="lat">
|
||||
<input type="hidden" name="location_lng" x-model="lng">
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="bg-blue-600 text-white px-3 py-1.5 rounded-md text-xs font-medium hover:bg-blue-700">{{ __('ui.save') }}</button>
|
||||
<button type="button" @click="editing = false" class="text-xs text-gray-600 hover:underline">{{ __('ui.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">{{ __('admin.locations_empty') }}</p>
|
||||
@endif
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function locationForm(initAddress = '', initLat = '', initLng = '') {
|
||||
return {
|
||||
query: '',
|
||||
results: [],
|
||||
addressText: initAddress || '',
|
||||
lat: initLat || '',
|
||||
lng: initLng || '',
|
||||
_abortCtrl: null,
|
||||
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 || '',
|
||||
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();
|
||||
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 = [];
|
||||
}
|
||||
},
|
||||
select(r) {
|
||||
this.addressText = r.address;
|
||||
this.lat = r.lat;
|
||||
this.lng = r.lon;
|
||||
this.query = r.address;
|
||||
this.results = [];
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-layouts.admin>
|
||||
71
resources/views/admin/players/create.blade.php
Executable file
71
resources/views/admin/players/create.blade.php
Executable file
@@ -0,0 +1,71 @@
|
||||
<x-layouts.admin :title="__('admin.new_player')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.new_player') }}</h1>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
|
||||
<form method="POST" action="{{ route('admin.players.store') }}">
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.first_name') }} *</label>
|
||||
<input type="text" name="first_name" id="first_name" value="{{ old('first_name') }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('first_name') border-red-500 @enderror">
|
||||
@error('first_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.last_name') }} *</label>
|
||||
<input type="text" name="last_name" id="last_name" value="{{ old('last_name') }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('last_name') border-red-500 @enderror">
|
||||
@error('last_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-1">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 class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="birth_year" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.birth_year') }}</label>
|
||||
<input type="number" name="birth_year" id="birth_year" value="{{ old('birth_year') }}" min="2000" max="2030"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
</div>
|
||||
<div>
|
||||
<label for="jersey_number" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.jersey_number') }}</label>
|
||||
<input type="number" name="jersey_number" id="jersey_number" value="{{ old('jersey_number') }}" min="1" max="99"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 space-y-2">
|
||||
<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.active') }}</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="hidden" name="photo_permission" value="0">
|
||||
<input type="checkbox" name="photo_permission" value="1" {{ old('photo_permission') ? 'checked' : '' }} class="rounded border-gray-300 mr-2">
|
||||
<span class="text-sm text-gray-700">{{ __('admin.photo_permission') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.notes') }}</label>
|
||||
<textarea name="notes" id="notes" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-md">{{ old('notes') }}</textarea>
|
||||
</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.players.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts.admin>
|
||||
192
resources/views/admin/players/edit.blade.php
Executable file
192
resources/views/admin/players/edit.blade.php
Executable file
@@ -0,0 +1,192 @@
|
||||
<x-layouts.admin :title="__('admin.players_title')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.players_title') }}: {{ $player->full_name }}</h1>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Spieler-Daten --}}
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="font-semibold mb-4">{{ __('admin.player_data') }}</h2>
|
||||
<form method="POST" action="{{ route('admin.players.update', $player) }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- Profilbild --}}
|
||||
<div class="mb-5" x-data="{ preview: null }">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.profile_picture') }}</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
@if ($player->getAvatarUrl())
|
||||
<img src="{{ $player->getAvatarUrl() }}" alt="{{ $player->full_name }}" class="w-14 h-14 rounded-full object-cover border-2 border-gray-200" x-show="!preview">
|
||||
@else
|
||||
<div class="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center text-gray-500 font-semibold border-2 border-gray-200" x-show="!preview">
|
||||
{{ $player->getInitials() }}
|
||||
</div>
|
||||
@endif
|
||||
<img :src="preview" x-show="preview" class="w-14 h-14 rounded-full object-cover border-2 border-blue-400" x-cloak>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cursor-pointer bg-gray-100 text-gray-700 px-3 py-1.5 rounded-md text-xs hover:bg-gray-200 inline-block">
|
||||
{{ __('admin.upload_picture') }}
|
||||
<input type="file" name="profile_picture" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden"
|
||||
@change="if ($event.target.files[0]) { preview = URL.createObjectURL($event.target.files[0]) }">
|
||||
</label>
|
||||
<span class="text-xs text-gray-400">{{ __('admin.max_picture_size') }}</span>
|
||||
@error('profile_picture')
|
||||
<p class="text-red-600 text-xs">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.first_name') }} *</label>
|
||||
<input type="text" name="first_name" id="first_name" value="{{ old('first_name', $player->first_name) }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('first_name') border-red-500 @enderror">
|
||||
@error('first_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.last_name') }} *</label>
|
||||
<input type="text" name="last_name" id="last_name" value="{{ old('last_name', $player->last_name) }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('last_name') border-red-500 @enderror">
|
||||
@error('last_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-1">Team *</label>
|
||||
<select name="team_id" id="team_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
@foreach ($teams as $team)
|
||||
<option value="{{ $team->id }}" {{ old('team_id', $player->team_id) == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="birth_year" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.birth_year') }}</label>
|
||||
<input type="number" name="birth_year" id="birth_year" value="{{ old('birth_year', $player->birth_year) }}" min="2000" max="2030"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
</div>
|
||||
<div>
|
||||
<label for="jersey_number" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.jersey_number') }}</label>
|
||||
<input type="number" name="jersey_number" id="jersey_number" value="{{ old('jersey_number', $player->jersey_number) }}" min="1" max="99"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center">
|
||||
<input type="hidden" name="photo_permission" value="0">
|
||||
<input type="checkbox" name="photo_permission" value="1" {{ old('photo_permission', $player->photo_permission) ? 'checked' : '' }} class="rounded border-gray-300 mr-2">
|
||||
<span class="text-sm text-gray-700">{{ __('admin.photo_permission') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.notes') }}</label>
|
||||
<textarea name="notes" id="notes" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-md">{{ old('notes', $player->notes) }}</textarea>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
|
||||
@if ($player->getAvatarUrl())
|
||||
<div class="mt-3 pt-3 border-t">
|
||||
<form method="POST" action="{{ route('admin.players.remove-picture', $player) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_file')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700">{{ __('admin.remove_picture') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Eltern-Zuordnung --}}
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="font-semibold mb-4">{{ __('admin.parent_assignment') }}</h2>
|
||||
|
||||
@if ($player->parents->isNotEmpty())
|
||||
<div class="mb-4 space-y-2">
|
||||
@foreach ($player->parents as $parent)
|
||||
<div class="flex items-center justify-between bg-gray-50 rounded px-3 py-2">
|
||||
<div>
|
||||
<span class="font-medium text-sm">{{ $parent->name }}</span>
|
||||
@if ($parent->pivot->relationship_label)
|
||||
<span class="text-xs text-gray-500">({{ $parent->pivot->relationship_label }})</span>
|
||||
@endif
|
||||
<span class="text-xs text-gray-400 block">{{ $parent->email }}</span>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('admin.players.remove-parent', [$player, $parent]) }}" onsubmit="return confirm(@js(__('admin.confirm_remove_parent')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-500 hover:text-red-700 text-xs">{{ __('admin.remove') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500 mb-4">{{ __('admin.no_parents_yet') }}</p>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('admin.players.assign-parent', $player) }}" class="border-t pt-4">
|
||||
@csrf
|
||||
<div class="mb-3">
|
||||
<label for="parent_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.add_parent') }}</label>
|
||||
<select name="parent_id" id="parent_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">{{ __('admin.select_user') }}</option>
|
||||
@foreach ($users as $user)
|
||||
<option value="{{ $user->id }}">{{ $user->name }} ({{ $user->email }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="relationship_label" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.relationship_label') }}</label>
|
||||
<input type="text" name="relationship_label" id="relationship_label" placeholder="{{ __('admin.relationship_placeholder') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<button type="submit" class="bg-gray-800 text-white px-3 py-1.5 rounded-md hover:bg-gray-900 text-sm">{{ __('admin.assign') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Spieler deaktivieren / löschen --}}
|
||||
<div class="mt-6 bg-white rounded-lg shadow p-6 border border-red-200">
|
||||
<h2 class="font-semibold text-red-700 mb-2">{{ __('admin.danger_zone') }}</h2>
|
||||
|
||||
{{-- Deaktivieren / Aktivieren --}}
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ __('admin.player_status_label') }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ $player->is_active ? __('admin.deactivate_player_hint') : __('admin.activate_player_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('admin.players.toggle-active', $player) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<button type="submit" class="px-4 py-2 rounded-md text-sm font-medium {{ $player->is_active ? 'bg-yellow-500 text-white hover:bg-yellow-600' : 'bg-green-600 text-white hover:bg-green-700' }}">
|
||||
{{ $player->is_active ? __('admin.deactivate') : __('admin.activate') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Löschen --}}
|
||||
<div class="flex items-center justify-between py-3 border-t border-red-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ __('admin.delete_player') }}</p>
|
||||
<p class="text-xs text-gray-500">{{ __('admin.delete_player_hint') }}</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('admin.players.destroy', $player) }}" onsubmit="return confirm(@js(__('admin.confirm_delete_player')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 text-sm font-medium">
|
||||
{{ __('admin.delete') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ route('admin.players.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('admin.back_to_list') }}</a>
|
||||
</div>
|
||||
</x-layouts.admin>
|
||||
231
resources/views/admin/players/index.blade.php
Executable file
231
resources/views/admin/players/index.blade.php
Executable file
@@ -0,0 +1,231 @@
|
||||
<x-layouts.admin :title="__('admin.players_title')">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ __('admin.players_title') }}</h1>
|
||||
<a href="{{ route('admin.players.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
|
||||
{{ __('admin.new_player') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<form method="GET" class="mb-4">
|
||||
@if (request('sort'))
|
||||
<input type="hidden" name="sort" value="{{ request('sort') }}">
|
||||
<input type="hidden" name="direction" value="{{ request('direction') }}">
|
||||
@endif
|
||||
<select name="team_id" onchange="this.form.submit()" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">{{ __('ui.all_teams') }}</option>
|
||||
@foreach ($teams as $team)
|
||||
<option value="{{ $team->id }}" {{ request('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<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">
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'name', 'direction' => ($sort === 'name' && $direction === 'asc') ? 'desc' : 'asc']) }}"
|
||||
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'name' ? 'text-blue-600' : '' }}">
|
||||
{{ __('ui.name') }}
|
||||
@if ($sort === 'name')
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
@if ($direction === 'asc')
|
||||
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||
@else
|
||||
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
@endif
|
||||
</svg>
|
||||
@endif
|
||||
</a>
|
||||
</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'team', 'direction' => ($sort === 'team' && $direction === 'asc') ? 'desc' : 'asc']) }}"
|
||||
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'team' ? 'text-blue-600' : '' }}">
|
||||
{{ __('admin.nav_teams') }}
|
||||
@if ($sort === 'team')
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
@if ($direction === 'asc')
|
||||
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||
@else
|
||||
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
@endif
|
||||
</svg>
|
||||
@endif
|
||||
</a>
|
||||
</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'jersey_number', 'direction' => ($sort === 'jersey_number' && $direction === 'asc') ? 'desc' : 'asc']) }}"
|
||||
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'jersey_number' ? 'text-blue-600' : '' }}">
|
||||
{{ __('admin.nr') }}
|
||||
@if ($sort === 'jersey_number')
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
@if ($direction === 'asc')
|
||||
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||
@else
|
||||
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
@endif
|
||||
</svg>
|
||||
@endif
|
||||
</a>
|
||||
</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.parents') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.photo') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'is_active', 'direction' => ($sort === 'is_active' && $direction === 'asc') ? 'desc' : 'asc']) }}"
|
||||
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'is_active' ? 'text-blue-600' : '' }}">
|
||||
{{ __('admin.status') }}
|
||||
@if ($sort === 'is_active')
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
@if ($direction === 'asc')
|
||||
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||
@else
|
||||
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
@endif
|
||||
</svg>
|
||||
@endif
|
||||
</a>
|
||||
</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 ($players as $player)
|
||||
<tr class="hover:bg-gray-50" x-data="playerRow({{ $player->id }}, '{{ $player->team_id }}', '{{ $player->photo_permission ? 1 : 0 }}')">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">
|
||||
<a href="{{ route('admin.players.edit', $player) }}" class="flex items-center gap-2 hover:underline">
|
||||
<img src="{{ $player->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0">
|
||||
{{ $player->full_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<select
|
||||
x-model="teamId"
|
||||
@change="save({ team_id: teamId })"
|
||||
class="px-2 py-1 border border-gray-200 rounded text-xs bg-white focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
@foreach ($teams as $team)
|
||||
<option value="{{ $team->id }}" {{ $player->team_id == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-gray-600">{{ $player->jersey_number ?? '–' }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 text-xs">
|
||||
@foreach ($player->parents as $parent)
|
||||
{{ $parent->name }}{{ $parent->pivot->relationship_label ? " ({$parent->pivot->relationship_label})" : '' }}@if (!$loop->last), @endif
|
||||
@endforeach
|
||||
@if ($player->parents->isEmpty())
|
||||
<span class="text-gray-400">–</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<select
|
||||
x-model="photo"
|
||||
@change="save({ photo_permission: photo })"
|
||||
class="px-2 py-1 border border-gray-200 rounded text-xs bg-white focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
:class="photo == '1' ? 'text-green-700' : 'text-red-600'"
|
||||
>
|
||||
<option value="1" {{ $player->photo_permission ? 'selected' : '' }}>{{ __('ui.yes') }}</option>
|
||||
<option value="0" {{ !$player->photo_permission ? 'selected' : '' }}>{{ __('ui.no') }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if ($player->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-red-100 text-red-800">{{ __('admin.inactive') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right space-x-2">
|
||||
<span x-show="saving" class="text-xs text-gray-400">...</span>
|
||||
<span x-show="saved" x-cloak class="text-xs text-green-600">✓</span>
|
||||
<a x-show="!saving && !saved" href="{{ route('admin.players.edit', $player) }}" class="text-blue-600 hover:underline text-sm">{{ __('ui.edit') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-8 text-center text-gray-500">{{ __('admin.no_players_yet') }}</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $players->links() }}</div>
|
||||
|
||||
{{-- Papierkorb --}}
|
||||
@if ($trashedPlayers->isNotEmpty())
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-3">{{ __('admin.trash') }}</h2>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto border border-red-200">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-red-50 border-b border-red-200">
|
||||
<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.nav_teams') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.deleted_at') }}</th>
|
||||
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($trashedPlayers as $player)
|
||||
<tr class="hover:bg-red-50/50">
|
||||
<td class="px-4 py-3 font-medium text-gray-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="{{ $player->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0 opacity-50">
|
||||
{{ $player->full_name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-400">{{ $player->team?->name ?? '–' }}</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-gray-500">{{ $player->deleted_at->diffForHumans() }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<form method="POST" action="{{ route('admin.players.restore', $player->id) }}" class="inline">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<button type="submit" class="text-xs text-green-600 hover:text-green-800 font-medium">{{ __('admin.restore') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function playerRow(playerId, initialTeam, initialPhoto) {
|
||||
return {
|
||||
teamId: initialTeam,
|
||||
photo: initialPhoto,
|
||||
saving: false,
|
||||
saved: false,
|
||||
async save(data) {
|
||||
this.saving = true;
|
||||
this.saved = false;
|
||||
try {
|
||||
const res = await fetch(`/admin/players/${playerId}/quick-update`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
this.saved = true;
|
||||
setTimeout(() => this.saved = false, 1500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-layouts.admin>
|
||||
825
resources/views/admin/settings/edit.blade.php
Executable file
825
resources/views/admin/settings/edit.blade.php
Executable file
@@ -0,0 +1,825 @@
|
||||
<x-layouts.admin :title="__('admin.settings_title')">
|
||||
<div x-data="settingsPage()" x-init="init()">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.settings_title') }}</h1>
|
||||
|
||||
{{-- Tab Navigation --}}
|
||||
<div class="border-b border-gray-200 mb-6 -mx-4 px-4 overflow-x-auto">
|
||||
<nav class="flex -mb-px gap-1" role="tablist">
|
||||
<button type="button" @click="tab = 'general'"
|
||||
:class="tab === 'general' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
|
||||
{{ __('admin.settings_tab_general') }}
|
||||
</button>
|
||||
<button type="button" @click="tab = 'mail'"
|
||||
:class="tab === 'mail' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
|
||||
{{ __('admin.settings_tab_mail') }}
|
||||
</button>
|
||||
<button type="button" @click="tab = 'legal'"
|
||||
:class="tab === 'legal' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
|
||||
{{ __('admin.settings_tab_legal') }}
|
||||
</button>
|
||||
<button type="button" @click="tab = 'defaults'"
|
||||
:class="tab === 'defaults' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
|
||||
{{ __('admin.settings_tab_defaults') }}
|
||||
</button>
|
||||
<button type="button" @click="tab = 'categories'"
|
||||
:class="tab === 'categories' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
|
||||
{{ __('admin.settings_tab_categories') }}
|
||||
</button>
|
||||
@if (auth()->user()->isAdmin())
|
||||
<button type="button" @click="tab = 'visibility'"
|
||||
:class="tab === 'visibility' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
|
||||
{{ __('admin.settings_tab_visibility') }}
|
||||
</button>
|
||||
<button type="button" @click="tab = 'license'"
|
||||
:class="tab === 'license' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
|
||||
{{ __('admin.settings_tab_license') }}
|
||||
</button>
|
||||
<button type="button" @click="tab = 'maintenance'"
|
||||
:class="tab === 'maintenance' ? 'border-red-500 text-red-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
class="whitespace-nowrap px-4 py-2.5 border-b-2 text-sm font-medium transition-colors" role="tab">
|
||||
{{ __('admin.settings_tab_maintenance') }}
|
||||
</button>
|
||||
@endif
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<form id="settings-form" method="POST" action="{{ route('admin.settings.update') }}" enctype="multipart/form-data" @submit="syncEditors()">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- Tab: Allgemein --}}
|
||||
<div x-show="tab === 'general'" x-effect="if (tab === 'general' && !sloganInitialized) $nextTick(() => initSloganEditors())" role="tabpanel">
|
||||
{{-- Text-Inputs (app_name etc.) --}}
|
||||
@foreach ($settings as $key => $setting)
|
||||
@if ($setting->type !== 'html' && $setting->type !== 'richtext' && $key !== 'app_favicon' && $key !== 'statistics_enabled')
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<label for="setting-{{ $key }}" class="block text-sm font-semibold text-gray-700 mb-2">{{ $setting->label }}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="setting-{{ $key }}"
|
||||
name="settings[{{ $key }}]"
|
||||
value="{{ $setting->value }}"
|
||||
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 focus:border-blue-500"
|
||||
>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- Favicon --}}
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">{{ __('admin.favicon_label') }}</label>
|
||||
@php $currentFavicon = \App\Models\Setting::get('app_favicon'); @endphp
|
||||
@if ($currentFavicon)
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<img src="{{ asset('storage/' . $currentFavicon) }}" alt="Favicon" class="w-8 h-8 object-contain border border-gray-200 rounded">
|
||||
<span class="text-sm text-gray-500">{{ __('admin.favicon_current') }}</span>
|
||||
<label class="flex items-center gap-1.5 text-sm text-red-600 cursor-pointer">
|
||||
<input type="checkbox" name="remove_favicon" value="1" class="rounded border-gray-300">
|
||||
{{ __('admin.favicon_remove') }}
|
||||
</label>
|
||||
</div>
|
||||
@endif
|
||||
<input
|
||||
type="file"
|
||||
name="favicon"
|
||||
accept=".ico,.png,.svg,.jpg,.jpeg,.gif,.webp"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm 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 hover:file:bg-gray-200"
|
||||
>
|
||||
<p class="mt-1.5 text-xs text-gray-400">{{ __('admin.favicon_hint') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Richtext-Settings (Slogan mit Mini-Quill) --}}
|
||||
@foreach ($settings as $key => $setting)
|
||||
@if ($setting->type === 'richtext')
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{ $setting->label }}</label>
|
||||
<p class="text-xs text-gray-400 mb-3">{{ __('admin.slogan_hint') }}</p>
|
||||
<div id="slogan-editor-{{ $key }}" class="bg-white" style="min-height: 60px;">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($setting->value ?? '') !!}</div>
|
||||
<input type="hidden" name="settings[{{ $key }}]" id="slogan-input-{{ $key }}" value="{{ $setting->value }}">
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Tab: E-Mail --}}
|
||||
<div x-show="tab === 'mail'" role="tabpanel">
|
||||
<form method="POST" action="{{ route('admin.settings.update-mail') }}"
|
||||
x-data="{
|
||||
mailMailer: @js($mailConfig['mailer'] ?? 'log'),
|
||||
mailTesting: false,
|
||||
mailTestResult: false,
|
||||
mailTestSuccess: false,
|
||||
mailTestMessage: '',
|
||||
async testSmtp() {
|
||||
this.mailTesting = true;
|
||||
this.mailTestResult = false;
|
||||
try {
|
||||
const res = await fetch('{{ route("admin.settings.test-mail") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mail_host: this.$refs.mailHost.value,
|
||||
mail_port: this.$refs.mailPort.value,
|
||||
mail_username: this.$refs.mailUsername.value,
|
||||
mail_password: this.$refs.mailPassword.value,
|
||||
mail_encryption: this.$refs.mailEncryption.value,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
this.mailTestSuccess = data.success;
|
||||
this.mailTestMessage = data.message;
|
||||
} catch (e) {
|
||||
this.mailTestSuccess = false;
|
||||
this.mailTestMessage = 'Netzwerkfehler: ' + e.message;
|
||||
}
|
||||
this.mailTesting = false;
|
||||
this.mailTestResult = true;
|
||||
}
|
||||
}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h3 class="text-base font-semibold text-gray-800 mb-1">{{ __('admin.mail_config_title') }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-5">{{ __('admin.mail_config_hint') }}</p>
|
||||
|
||||
{{-- Versandmethode --}}
|
||||
<div class="mb-5">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.mail_mailer_label') }}</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="mail_mailer" value="smtp" x-model="mailMailer"
|
||||
class="text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm">SMTP</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="mail_mailer" value="log" x-model="mailMailer"
|
||||
class="text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm">{{ __('admin.mail_log_mode') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SMTP-Felder --}}
|
||||
<div x-show="mailMailer === 'smtp'" x-cloak class="space-y-4 p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_host_label') }}</label>
|
||||
<input type="text" name="mail_host" x-ref="mailHost"
|
||||
value="{{ $mailConfig['host'] }}"
|
||||
placeholder="z.B. smtp.strato.de"
|
||||
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('mail_host') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_port_label') }}</label>
|
||||
<input type="number" name="mail_port" x-ref="mailPort"
|
||||
value="{{ $mailConfig['port'] }}"
|
||||
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('mail_port') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_username_label') }}</label>
|
||||
<input type="text" name="mail_username" x-ref="mailUsername"
|
||||
value="{{ $mailConfig['username'] }}"
|
||||
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('mail_username') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_password_label') }}</label>
|
||||
<input type="password" name="mail_password" x-ref="mailPassword"
|
||||
value="{{ $mailConfig['password'] }}"
|
||||
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('mail_password') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_from_address_label') }}</label>
|
||||
<input type="email" name="mail_from_address"
|
||||
value="{{ $mailConfig['from_address'] }}"
|
||||
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('mail_from_address') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_from_name_label') }} <span class="text-gray-400 font-normal">(optional)</span></label>
|
||||
<input type="text" name="mail_from_name"
|
||||
value="{{ $mailConfig['from_name'] }}"
|
||||
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>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.mail_encryption_label') }}</label>
|
||||
<select name="mail_encryption" x-ref="mailEncryption"
|
||||
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">
|
||||
@php $enc = $mailConfig['encryption'] ?? 'tls'; @endphp
|
||||
<option value="tls" {{ $enc === 'tls' ? 'selected' : '' }}>TLS (Port 587)</option>
|
||||
<option value="ssl" {{ $enc === 'ssl' ? 'selected' : '' }}>SSL (Port 465)</option>
|
||||
<option value="none" {{ !in_array($enc, ['tls', 'ssl']) ? 'selected' : '' }}>{{ __('admin.mail_encryption_none') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- SMTP-Test --}}
|
||||
<div class="pt-3 border-t border-gray-200">
|
||||
<button type="button" @click="testSmtp()"
|
||||
:disabled="mailTesting"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-md hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-wait inline-flex items-center gap-2">
|
||||
<template x-if="mailTesting">
|
||||
<svg class="animate-spin h-4 w-4 text-white" 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>
|
||||
</template>
|
||||
<span x-text="mailTesting ? '{{ __("admin.mail_testing") }}' : '{{ __("admin.mail_test_button") }}'"></span>
|
||||
</button>
|
||||
<p x-show="mailTestResult" x-cloak x-text="mailTestMessage"
|
||||
:class="mailTestSuccess ? 'text-green-600' : 'text-red-600'"
|
||||
class="text-sm mt-2"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit"
|
||||
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
|
||||
{{ __('admin.mail_save') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Tab: Rechtliches — Multi-Language mit Flaggen --}}
|
||||
<div x-show="tab === 'legal'" x-effect="if (tab === 'legal' && !editorsInitialized) $nextTick(() => initEditors())" role="tabpanel">
|
||||
{{-- Sprach-Flaggen-Leiste --}}
|
||||
<div class="flex items-center gap-1 mb-6 bg-white rounded-lg shadow px-4 py-3">
|
||||
<span class="text-sm font-medium text-gray-600 mr-3">{{ __('admin.legal_language_label') }}:</span>
|
||||
@php
|
||||
$localeFlags = ['de' => "\u{1F1E9}\u{1F1EA}", 'en' => "\u{1F1EC}\u{1F1E7}", 'pl' => "\u{1F1F5}\u{1F1F1}", 'ru' => "\u{1F1F7}\u{1F1FA}", 'ar' => "\u{1F1F8}\u{1F1E6}", 'tr' => "\u{1F1F9}\u{1F1F7}"];
|
||||
$localeNames = ['de' => 'DE', 'en' => 'EN', 'pl' => 'PL', 'ru' => 'RU', 'ar' => 'AR', 'tr' => 'TR'];
|
||||
@endphp
|
||||
@foreach ($availableLocales as $loc)
|
||||
<button type="button"
|
||||
@click="legalLocale = @js($loc)"
|
||||
:class="legalLocale === @js($loc) ? 'bg-blue-600 text-white shadow-sm' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors cursor-pointer">
|
||||
{{ $localeFlags[$loc] }} {{ $localeNames[$loc] }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Impressum pro Sprache --}}
|
||||
@foreach ($availableLocales as $loc)
|
||||
@php $impKey = "impressum_html_{$loc}"; @endphp
|
||||
<div x-show="legalLocale === '{{ $loc }}'" class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-sm font-semibold text-gray-700">{{ __('admin.legal_impressum_label') }} ({{ strtoupper($loc) }})</label>
|
||||
<button type="button"
|
||||
@click="toggleHtml(@js($impKey))"
|
||||
:class="htmlMode[@js($impKey)] ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
class="text-xs px-2.5 py-1 rounded font-mono transition-colors cursor-pointer">
|
||||
</> HTML
|
||||
</button>
|
||||
</div>
|
||||
<div :class="htmlMode[@js($impKey)] && 'hidden'">
|
||||
<div id="editor-{{ $impKey }}" class="bg-white" style="min-height: 250px;">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($localeSettings[$loc]['impressum_html'] ?? '') !!}</div>
|
||||
</div>
|
||||
<textarea x-show="htmlMode[@js($impKey)]" x-cloak id="html-{{ $impKey }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono leading-relaxed"
|
||||
style="min-height: 350px;" spellcheck="false"></textarea>
|
||||
<p x-show="htmlMode[@js($impKey)]" x-cloak class="mt-2 text-xs text-gray-400">{{ __('admin.html_anchor_hint') }}</p>
|
||||
<input type="hidden" name="settings[{{ $impKey }}]" id="input-{{ $impKey }}" value="{{ $localeSettings[$loc]['impressum_html'] }}">
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
{{-- Datenschutz pro Sprache --}}
|
||||
@foreach ($availableLocales as $loc)
|
||||
@php $dsKey = "datenschutz_html_{$loc}"; @endphp
|
||||
<div x-show="legalLocale === '{{ $loc }}'" class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-sm font-semibold text-gray-700">{{ __('admin.legal_datenschutz_label') }} ({{ strtoupper($loc) }})</label>
|
||||
<button type="button"
|
||||
@click="toggleHtml(@js($dsKey))"
|
||||
:class="htmlMode[@js($dsKey)] ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
class="text-xs px-2.5 py-1 rounded font-mono transition-colors cursor-pointer">
|
||||
</> HTML
|
||||
</button>
|
||||
</div>
|
||||
<div :class="htmlMode[@js($dsKey)] && 'hidden'">
|
||||
<div id="editor-{{ $dsKey }}" class="bg-white" style="min-height: 250px;">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($localeSettings[$loc]['datenschutz_html'] ?? '') !!}</div>
|
||||
</div>
|
||||
<textarea x-show="htmlMode[@js($dsKey)]" x-cloak id="html-{{ $dsKey }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono leading-relaxed"
|
||||
style="min-height: 350px;" spellcheck="false"></textarea>
|
||||
<p x-show="htmlMode[@js($dsKey)]" x-cloak class="mt-2 text-xs text-gray-400">{{ __('admin.html_anchor_hint') }}</p>
|
||||
<input type="hidden" name="settings[{{ $dsKey }}]" id="input-{{ $dsKey }}" value="{{ $localeSettings[$loc]['datenschutz_html'] }}">
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
{{-- Passwort-Reset E-Mail pro Sprache --}}
|
||||
@foreach ($availableLocales as $loc)
|
||||
@php $prKey = "password_reset_email_{$loc}"; @endphp
|
||||
<div x-show="legalLocale === '{{ $loc }}'" class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-sm font-semibold text-gray-700">{{ __('admin.legal_password_reset_email_label') }} ({{ strtoupper($loc) }})</label>
|
||||
<button type="button"
|
||||
@click="toggleHtml(@js($prKey))"
|
||||
:class="htmlMode[@js($prKey)] ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
class="text-xs px-2.5 py-1 rounded font-mono transition-colors cursor-pointer">
|
||||
</> HTML
|
||||
</button>
|
||||
</div>
|
||||
<div :class="htmlMode[@js($prKey)] && 'hidden'">
|
||||
<div id="editor-{{ $prKey }}" class="bg-white" style="min-height: 150px;">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($localeSettings[$loc]['password_reset_email'] ?? '') !!}</div>
|
||||
</div>
|
||||
<textarea x-show="htmlMode[@js($prKey)]" x-cloak id="html-{{ $prKey }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono leading-relaxed"
|
||||
style="min-height: 200px;" spellcheck="false"></textarea>
|
||||
<p class="mt-2 text-xs text-gray-400">{{ __('admin.password_reset_email_hint') }}</p>
|
||||
<input type="hidden" name="settings[{{ $prKey }}]" id="input-{{ $prKey }}" value="{{ $localeSettings[$loc]['password_reset_email'] }}">
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Tab: Event-Defaults --}}
|
||||
<div x-show="tab === 'defaults'" role="tabpanel">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<p class="text-sm text-gray-500 mb-5">{{ __('admin.event_defaults_description') }}</p>
|
||||
|
||||
@php
|
||||
$noCateringTypes = ['away_game', 'meeting'];
|
||||
@endphp
|
||||
<div class="space-y-4">
|
||||
@foreach (['home_game', 'away_game', 'training', 'tournament', 'meeting'] as $eventType)
|
||||
<div class="border border-gray-200 rounded-md p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __("ui.enums.event_type.{$eventType}") }}</h3>
|
||||
<div class="grid grid-cols-3 gap-2 sm:gap-3">
|
||||
@foreach (['players', 'catering', 'timekeepers'] as $field)
|
||||
@php
|
||||
$key = "default_min_{$field}_{$eventType}";
|
||||
$currentVal = $eventDefaults[$key] ?? null;
|
||||
$isDisabled = in_array($eventType, $noCateringTypes) && in_array($field, ['catering', 'timekeepers']);
|
||||
$label = $eventType === 'meeting' && $field === 'players' ? __('admin.min_users') : __("admin.min_{$field}");
|
||||
@endphp
|
||||
<div>
|
||||
<label class="block text-xs text-gray-600 mb-1 truncate" title="{{ $label }}">{{ $label }}</label>
|
||||
@if ($isDisabled)
|
||||
<div class="w-full px-2 py-1.5 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50">{{ __('admin.not_applicable') }}</div>
|
||||
@else
|
||||
<select name="settings[{{ $key }}]" class="w-full px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">--</option>
|
||||
@for ($i = 0; $i <= 20; $i++)
|
||||
<option value="{{ $i }}" {{ $currentVal !== null && (string) $currentVal === (string) $i ? 'selected' : '' }}>{{ $i }}</option>
|
||||
@endfor
|
||||
</select>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Tab: Sichtbarkeit (nur Admin) --}}
|
||||
@if (auth()->user()->isAdmin())
|
||||
<div x-show="tab === 'visibility'" role="tabpanel">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<p class="text-sm text-gray-500 mb-5">{{ __('admin.visibility_description') }}</p>
|
||||
|
||||
@php
|
||||
$features = [
|
||||
'statistics' => __('admin.visibility_feature_statistics'),
|
||||
'catering_history' => __('admin.visibility_feature_catering_history'),
|
||||
];
|
||||
$roles = [
|
||||
'coach' => __('ui.enums.user_role.coach'),
|
||||
'parent_rep' => __('ui.enums.user_role.parent_rep'),
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@foreach ($features as $featureKey => $featureLabel)
|
||||
<div class="border border-gray-200 rounded-md p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ $featureLabel }}</h3>
|
||||
<div class="flex flex-wrap gap-6">
|
||||
@foreach ($roles as $roleKey => $roleLabel)
|
||||
@php
|
||||
$settingKey = "visibility_{$featureKey}_{$roleKey}";
|
||||
$currentValue = $visibilitySettings[$settingKey]->value ?? '1';
|
||||
@endphp
|
||||
<label class="flex items-center gap-3 cursor-pointer" x-data="{ on: {{ $currentValue === '1' ? 'true' : 'false' }} }">
|
||||
<input type="hidden" name="settings[{{ $settingKey }}]" :value="on ? '1' : '0'">
|
||||
<button type="button" @click="on = !on"
|
||||
:class="on ? 'bg-blue-600' : 'bg-gray-300'"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<span :class="on ? 'translate-x-5' : 'translate-x-0'"
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out mt-0.5 ml-0.5"></span>
|
||||
</button>
|
||||
<span class="text-sm text-gray-700">{{ $roleLabel }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Tab: Lizenz & Support (nur Admin) --}}
|
||||
@if (auth()->user()->isAdmin())
|
||||
<div x-show="tab === 'license'" role="tabpanel">
|
||||
{{-- License Key --}}
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-1">{{ __('admin.license_title') }}</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">{{ __('admin.license_description') }}</p>
|
||||
|
||||
<label for="setting-license_key" class="block text-sm font-semibold text-gray-700 mb-2">{{ __('admin.license_key_label') }}</label>
|
||||
<input type="text" name="settings[license_key]" id="setting-license_key"
|
||||
value="{{ $settings['license_key']->value ?? '' }}"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
|
||||
{{-- Registration Status --}}
|
||||
<div class="mt-6 pt-4 border-t border-gray-200">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">{{ __('admin.registration_status') }}</h3>
|
||||
@if ($isRegistered)
|
||||
<div class="flex items-center gap-2 text-sm text-green-700">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ __('admin.registration_active') }}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">Installation-ID: <span class="font-mono">{{ $installationId }}</span></p>
|
||||
@else
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-3">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ __('admin.registration_inactive') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- System Info --}}
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.version_info') }}</h3>
|
||||
<dl class="grid grid-cols-2 gap-y-2 gap-x-4 text-sm">
|
||||
<dt class="text-gray-500">App-Version:</dt>
|
||||
<dd class="text-gray-800 font-mono">{{ config('app.version') }}</dd>
|
||||
<dt class="text-gray-500">PHP:</dt>
|
||||
<dd class="text-gray-800 font-mono">{{ PHP_VERSION }}</dd>
|
||||
<dt class="text-gray-500">Laravel:</dt>
|
||||
<dd class="text-gray-800 font-mono">{{ app()->version() }}</dd>
|
||||
<dt class="text-gray-500">Datenbank:</dt>
|
||||
<dd class="text-gray-800 font-mono">{{ config('database.default') }}</dd>
|
||||
</dl>
|
||||
|
||||
@if ($updateInfo && version_compare($updateInfo['latest_version'] ?? '0', config('app.version'), '>'))
|
||||
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p class="text-sm font-medium text-blue-800">
|
||||
{{ __('admin.update_available', ['version' => $updateInfo['latest_version']]) }}
|
||||
</p>
|
||||
@if ($updateInfo['changelog'] ?? null)
|
||||
<p class="text-xs text-blue-600 mt-1">{{ $updateInfo['changelog'] }}</p>
|
||||
@endif
|
||||
@if (($updateInfo['download_url'] ?? null) && str_starts_with($updateInfo['download_url'], 'https://'))
|
||||
<a href="{{ $updateInfo['download_url'] }}" target="_blank" rel="noopener"
|
||||
class="inline-block mt-2 text-sm text-blue-700 underline">
|
||||
{{ __('admin.download_update') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Save/Cancel (sichtbar auf allen Form-Tabs, nicht auf Wartung) --}}
|
||||
<div x-show="tab !== 'categories' && tab !== 'maintenance'" class="flex gap-3 mt-6">
|
||||
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 font-medium">
|
||||
{{ __('ui.save') }}
|
||||
</button>
|
||||
<a href="{{ route('admin.dashboard') }}" class="bg-gray-200 text-gray-700 px-6 py-2 rounded-md hover:bg-gray-300">
|
||||
{{ __('ui.cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Registration (außerhalb der Settings-Form, nur auf Lizenz-Tab) --}}
|
||||
@if (auth()->user()->isAdmin() && !$isRegistered)
|
||||
<div x-show="tab === 'license'" class="mt-6">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<p class="text-sm text-gray-600 mb-3">{{ __('admin.support_not_registered') }}</p>
|
||||
<form method="POST" action="{{ route('admin.support.register') }}">
|
||||
@csrf
|
||||
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded-md text-sm hover:bg-green-700">
|
||||
{{ __('admin.register_now') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Tab: Wartung (nur Admin, eigenes Formular) --}}
|
||||
@if (auth()->user()->isAdmin())
|
||||
<div x-show="tab === 'maintenance'" role="tabpanel">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">{{ __('admin.demo_data_delete_title') }}</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">{{ __('admin.demo_data_delete_description') }}</p>
|
||||
|
||||
<div class="grid sm:grid-cols-2 gap-4 mb-5">
|
||||
<div class="border border-red-200 bg-red-50 rounded-md p-4">
|
||||
<h3 class="text-sm font-semibold text-red-700 mb-2">{{ __('admin.demo_data_deletes') }}</h3>
|
||||
<ul class="text-sm text-red-600 space-y-1 list-disc list-inside">
|
||||
<li>{{ __('admin.stat_users') }} ({{ __('admin.demo_data_except_admin') }})</li>
|
||||
<li>{{ __('admin.nav_teams') }}</li>
|
||||
<li>{{ __('admin.nav_players') }}</li>
|
||||
<li>{{ __('admin.nav_events') }}</li>
|
||||
<li>Kommentare</li>
|
||||
<li>{{ __('admin.nav_locations') }}</li>
|
||||
<li>{{ __('admin.nav_files') }}</li>
|
||||
<li>{{ __('admin.activity_log_title') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="border border-green-200 bg-green-50 rounded-md p-4">
|
||||
<h3 class="text-sm font-semibold text-green-700 mb-2">{{ __('admin.demo_data_keeps') }}</h3>
|
||||
<ul class="text-sm text-green-600 space-y-1 list-disc list-inside">
|
||||
<li>{{ __('admin.demo_data_keeps_admin') }}</li>
|
||||
<li>{{ __('admin.nav_settings') }}</li>
|
||||
<li>{{ __('admin.settings_tab_categories') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-red-300 bg-red-50 rounded-md p-4 mb-5">
|
||||
<p class="text-sm text-red-700 font-medium">{{ __('admin.demo_data_delete_warning') }}</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('admin.settings.destroy-demo-data') }}"
|
||||
onsubmit="return confirm(@js(__('admin.demo_data_delete_confirm')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<div class="mb-4">
|
||||
<label for="demo-delete-password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_password_label') }}</label>
|
||||
<input type="password" name="password" id="demo-delete-password" required autocomplete="current-password"
|
||||
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-5 py-2.5 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 transition">
|
||||
{{ __('admin.demo_data_delete_button') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Factory Reset (nur Admin) --}}
|
||||
@if (auth()->user()->isAdmin())
|
||||
<div class="bg-white rounded-lg shadow p-6 mt-6 border-2 border-red-300">
|
||||
<h2 class="text-lg font-semibold text-red-700 mb-4">{{ __('admin.factory_reset_title') }}</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">{{ __('admin.factory_reset_description') }}</p>
|
||||
|
||||
<div class="border border-red-200 bg-red-50 rounded-md p-4 mb-5">
|
||||
<h3 class="text-sm font-semibold text-red-700 mb-2">{{ __('admin.factory_reset_deletes') }}</h3>
|
||||
<ul class="text-sm text-red-600 space-y-1 list-disc list-inside">
|
||||
<li>{{ __('admin.factory_reset_item_users') }}</li>
|
||||
<li>{{ __('admin.factory_reset_item_data') }}</li>
|
||||
<li>{{ __('admin.factory_reset_item_settings') }}</li>
|
||||
<li>{{ __('admin.factory_reset_item_files') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-100 border border-red-300 rounded-md p-4 mb-5">
|
||||
<p class="text-sm text-red-800 font-bold">{{ __('admin.factory_reset_warning') }}</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('admin.settings.factory-reset') }}"
|
||||
onsubmit="return confirm(@js(__('admin.factory_reset_confirm')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<div class="mb-4">
|
||||
<label for="factory-reset-password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_password_label') }}</label>
|
||||
<input type="password" name="password" id="factory-reset-password" required autocomplete="current-password"
|
||||
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500">
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<label for="factory-reset-confirmation" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.factory_reset_confirmation_label') }}</label>
|
||||
<input type="text" name="confirmation" id="factory-reset-confirmation" required
|
||||
placeholder="RESET-BESTÄTIGT"
|
||||
class="w-full max-w-sm px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-2 focus:ring-red-500 focus:border-red-500">
|
||||
<p class="mt-1 text-xs text-gray-500">{{ __('admin.factory_reset_confirmation_hint') }}</p>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-5 py-2.5 text-sm font-medium text-white bg-red-700 rounded-md hover:bg-red-800 transition">
|
||||
{{ __('admin.factory_reset_button') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Tab: Dateikategorien (eigene Formulare) --}}
|
||||
<div x-show="tab === 'categories'" role="tabpanel">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<p class="text-sm text-gray-500 mb-5">{{ __('admin.file_categories_description') }}</p>
|
||||
|
||||
@if ($fileCategories->isNotEmpty())
|
||||
<div class="space-y-2 mb-5">
|
||||
@foreach ($fileCategories as $cat)
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3 border border-gray-200 rounded-md px-3 py-2.5">
|
||||
<form method="POST" action="{{ route('admin.file-categories.update', $cat) }}" class="flex flex-wrap items-center gap-2 sm:gap-3 flex-1 min-w-0">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="text" name="name" value="{{ $cat->name }}" required
|
||||
class="flex-1 min-w-[140px] px-2 py-1.5 border border-gray-300 rounded text-sm">
|
||||
<label class="flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<input type="checkbox" name="is_active" value="1" {{ $cat->is_active ? 'checked' : '' }} class="rounded border-gray-300">
|
||||
{{ __('admin.active') }}
|
||||
</label>
|
||||
<span class="text-xs text-gray-400 tabular-nums">{{ $cat->files_count }} {{ __('admin.nav_files') }}</span>
|
||||
<button type="submit" class="text-xs text-blue-600 hover:text-blue-800 font-medium">{{ __('ui.save') }}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('admin.file-categories.destroy', $cat) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_category')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs font-medium {{ $cat->files_count === 0 ? 'text-red-600 hover:text-red-800' : 'text-gray-300 cursor-not-allowed' }}"
|
||||
{{ $cat->files_count > 0 ? 'disabled' : '' }}>{{ __('ui.delete') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('admin.file-categories.store') }}" class="flex items-center gap-3 border-t border-gray-200 pt-4">
|
||||
@csrf
|
||||
<input type="text" name="name" placeholder="{{ __('admin.category_name') }}" required
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<button type="submit" class="bg-gray-800 text-white px-3 py-2 rounded-md hover:bg-gray-900 text-sm whitespace-nowrap">{{ __('admin.new_category') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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: 200px; }
|
||||
.ql-toolbar.ql-snow { border-radius: 0.375rem 0.375rem 0 0; }
|
||||
.ql-container.ql-snow { border-radius: 0 0 0.375rem 0.375rem; }
|
||||
[id^="slogan-editor-"] .ql-editor { min-height: 60px; }
|
||||
[id^="editor-password_reset_email_"] .ql-editor { min-height: 120px; }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
<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>
|
||||
function settingsPage() {
|
||||
return {
|
||||
tab: 'general',
|
||||
legalLocale: 'de',
|
||||
editorsInitialized: false,
|
||||
sloganInitialized: false,
|
||||
initializedLocales: [],
|
||||
editors: {},
|
||||
sloganEditors: {},
|
||||
htmlMode: {},
|
||||
toolbarOptions: [
|
||||
[{ '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']
|
||||
],
|
||||
|
||||
init() {
|
||||
const validTabs = ['general', 'mail', 'legal', 'defaults', 'categories', 'visibility', 'license', 'maintenance'];
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
const stored = sessionStorage.getItem('settings_tab');
|
||||
if (validTabs.includes(hash)) {
|
||||
this.tab = hash;
|
||||
} else if (validTabs.includes(stored)) {
|
||||
this.tab = stored;
|
||||
}
|
||||
this.$watch('tab', val => {
|
||||
sessionStorage.setItem('settings_tab', val);
|
||||
history.replaceState(null, '', '#' + val);
|
||||
});
|
||||
// Bei Locale-Wechsel: Editoren fuer neue Locale lazy initialisieren
|
||||
this.$watch('legalLocale', (locale) => {
|
||||
if (this.editorsInitialized) {
|
||||
this.$nextTick(() => this.initLocaleEditors(locale));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
initSloganEditors() {
|
||||
if (this.sloganInitialized) return;
|
||||
|
||||
const miniToolbar = [['bold', 'italic']];
|
||||
|
||||
@foreach ($settings as $key => $setting)
|
||||
@if ($setting->type === 'richtext')
|
||||
this.sloganEditors[@js($key)] = new Quill('#slogan-editor-' + @js($key), {
|
||||
theme: 'snow',
|
||||
modules: { toolbar: miniToolbar }
|
||||
});
|
||||
document.getElementById('slogan-input-' + @js($key)).value = this.sloganEditors[@js($key)].root.innerHTML;
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
this.sloganInitialized = true;
|
||||
},
|
||||
|
||||
// Nur die aktuelle Locale initialisieren (sichtbare Editoren)
|
||||
initEditors() {
|
||||
if (this.editorsInitialized) return;
|
||||
this.initLocaleEditors(this.legalLocale);
|
||||
this.editorsInitialized = true;
|
||||
},
|
||||
|
||||
initLocaleEditors(locale) {
|
||||
if (this.initializedLocales.includes(locale)) return;
|
||||
|
||||
const editorTypes = ['impressum_html', 'datenschutz_html', 'password_reset_email'];
|
||||
editorTypes.forEach(type => {
|
||||
const key = type + '_' + locale;
|
||||
const el = document.getElementById('editor-' + key);
|
||||
if (el) {
|
||||
this.editors[key] = new Quill('#editor-' + key, {
|
||||
theme: 'snow',
|
||||
modules: { toolbar: this.toolbarOptions }
|
||||
});
|
||||
this.htmlMode[key] = false;
|
||||
|
||||
// Content aus Hidden-Input laden
|
||||
const input = document.getElementById('input-' + key);
|
||||
if (input && input.value) {
|
||||
this.editors[key].root.innerHTML = input.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.initializedLocales.push(locale);
|
||||
},
|
||||
|
||||
toggleHtml(key) {
|
||||
if (!this.editors[key]) return;
|
||||
|
||||
if (!this.htmlMode[key]) {
|
||||
const html = this.editors[key].root.innerHTML;
|
||||
document.getElementById('html-' + key).value = this.formatHtml(html);
|
||||
this.htmlMode[key] = true;
|
||||
} else {
|
||||
const html = document.getElementById('html-' + key).value;
|
||||
this.editors[key].root.innerHTML = html;
|
||||
this.htmlMode[key] = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatHtml(html) {
|
||||
return html
|
||||
.replace(/<(h[2-4]|p|ul|ol|blockquote)/g, '\n<$1')
|
||||
.replace(/<li>/g, '\n <li>')
|
||||
.trimStart();
|
||||
},
|
||||
|
||||
syncEditors() {
|
||||
// Nur initialisierte Editoren synchronisieren
|
||||
// Nicht-initialisierte Locales: Hidden-Inputs behalten Server-Werte
|
||||
for (const [key, editor] of Object.entries(this.editors)) {
|
||||
if (this.htmlMode[key]) {
|
||||
document.getElementById('input-' + key).value = document.getElementById('html-' + key).value;
|
||||
} else {
|
||||
document.getElementById('input-' + key).value = editor.root.innerHTML;
|
||||
}
|
||||
}
|
||||
// Sync slogan editors (richtext type)
|
||||
for (const [key, editor] of Object.entries(this.sloganEditors)) {
|
||||
document.getElementById('slogan-input-' + key).value = editor.root.innerHTML;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
</x-layouts.admin>
|
||||
337
resources/views/admin/statistics/index.blade.php
Normal file
337
resources/views/admin/statistics/index.blade.php
Normal file
@@ -0,0 +1,337 @@
|
||||
<x-layouts.admin :title="__('admin.statistics_title')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.statistics_title') }}</h1>
|
||||
|
||||
{{-- Filter --}}
|
||||
<div class="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<form method="GET" action="{{ route('admin.statistics.index') }}" class="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label for="team_id" class="block text-xs font-medium text-gray-600 mb-1">{{ __('ui.team') }}</label>
|
||||
<select name="team_id" id="team_id" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option value="">{{ __('admin.all_teams') }}</option>
|
||||
@foreach ($teams as $team)
|
||||
<option value="{{ $team->id }}" {{ request('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="from" class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.filter_from') }}</label>
|
||||
<input type="date" name="from" id="from" value="{{ request('from') }}" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label for="to" class="block text-xs font-medium text-gray-600 mb-1">{{ __('admin.filter_to') }}</label>
|
||||
<input type="date" name="to" id="to" value="{{ request('to') }}" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md text-sm hover:bg-blue-700">{{ __('admin.filter_apply') }}</button>
|
||||
@if (request()->hasAny(['team_id', 'from', 'to']))
|
||||
<a href="{{ route('admin.statistics.index') }}" class="text-sm text-gray-500 hover:underline">{{ __('admin.filter_reset') }}</a>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if ($games->isEmpty())
|
||||
<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
{{ __('admin.no_games_yet') }}
|
||||
</div>
|
||||
@else
|
||||
{{-- Statistik-Cards --}}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-gray-900">{{ $games->count() }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ __('admin.total_games') }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-green-600">{{ $wins }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ __('admin.wins') }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-red-600">{{ $losses }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ __('admin.losses') }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-gray-500">{{ $draws }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ __('admin.draws') }}</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ $winRate }}%</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ __('admin.win_rate') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Charts --}}
|
||||
@if ($totalWithScore > 0)
|
||||
<div class="grid grid-cols-1 {{ auth()->user()->isStaff() ? 'lg:grid-cols-3' : 'lg:grid-cols-2' }} gap-6 mb-6">
|
||||
{{-- Siege/Niederlagen Pie Chart --}}
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.chart_win_loss') }}</h3>
|
||||
<div class="relative" style="height: 220px;">
|
||||
<canvas id="chartWinLoss"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Spieler-Teilnahme Bar Chart (nur Staff) --}}
|
||||
@if (auth()->user()->isStaff())
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.chart_player_participation') }}</h3>
|
||||
<div class="relative" style="height: 220px;">
|
||||
<canvas id="chartPlayers"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Eltern-Engagement Bar Chart --}}
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.chart_parent_involvement') }}</h3>
|
||||
<div class="relative" style="height: 220px;">
|
||||
<canvas id="chartParents"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Spiel-Tabelle --}}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-600">{{ __('admin.date') }}</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-600">{{ __('ui.team') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-600">{{ __('ui.type') }}</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-600">{{ __('events.opponent') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-600">{{ __('events.score') }}</th>
|
||||
@if (auth()->user()->isStaff())
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-600">{{ __('admin.nav_players') }}</th>
|
||||
@endif
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-600">{{ __('events.catering_short') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-600">{{ __('events.timekeeper_short') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($games as $game)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2.5 whitespace-nowrap">
|
||||
<a href="{{ route('admin.events.edit', $game) }}" class="text-blue-600 hover:underline">
|
||||
{{ $game->start_at->translatedFormat(__('ui.date_format_short')) }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">{{ $game->team->name }}</td>
|
||||
<td class="px-4 py-2.5 text-center">
|
||||
<span class="inline-block px-1.5 py-0.5 rounded text-xs font-medium {{ $game->type === \App\Enums\EventType::HomeGame ? 'bg-blue-100 text-blue-800' : 'bg-indigo-100 text-indigo-800' }}">
|
||||
{{ $game->type === \App\Enums\EventType::HomeGame ? __('admin.home_short') : __('admin.away_short') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">{{ $game->opponent ?? '–' }}</td>
|
||||
<td class="px-4 py-2.5 text-center font-medium">
|
||||
@if ($game->hasScore())
|
||||
{{ $game->scoreDisplay() }}
|
||||
@else
|
||||
<span class="text-gray-400">–</span>
|
||||
@endif
|
||||
</td>
|
||||
@if (auth()->user()->isStaff())
|
||||
<td class="px-4 py-2.5 text-center">{{ $game->players_yes_count }}</td>
|
||||
@endif
|
||||
<td class="px-4 py-2.5 text-center">{{ $game->type->hasCatering() ? $game->caterings_yes_count : '–' }}</td>
|
||||
<td class="px-4 py-2.5 text-center">{{ $game->type->hasTimekeepers() ? $game->timekeepers_yes_count : '–' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Spieler-Rangliste (nur Staff) --}}
|
||||
@if (auth()->user()->isStaff() && $playerRanking->isNotEmpty())
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden mt-6">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<h3 class="text-sm font-semibold text-gray-700">{{ __('admin.player_ranking_title') }}</h3>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ __('admin.player_ranking_desc', ['count' => $totalGames]) }}</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2.5 font-medium text-gray-600 w-8">#</th>
|
||||
<th class="text-left px-4 py-2.5 font-medium text-gray-600">{{ __('admin.nav_players') }}</th>
|
||||
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.games_played') }}</th>
|
||||
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.games_assigned') }}</th>
|
||||
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.participation_rate') }}</th>
|
||||
<th class="px-4 py-2.5 font-medium text-gray-600 w-32"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($playerRanking as $index => $entry)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2 text-gray-400">{{ $index + 1 }}</td>
|
||||
<td class="px-4 py-2 flex items-center gap-2">
|
||||
@if ($entry->player->getAvatarUrl())
|
||||
<img src="{{ $entry->player->getAvatarUrl() }}" alt="" class="w-6 h-6 rounded-full object-cover">
|
||||
@else
|
||||
<div class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs font-semibold">{{ $entry->player->getInitials() }}</div>
|
||||
@endif
|
||||
<span class="{{ $entry->player->trashed() ? 'text-gray-400 line-through' : '' }}">{{ $entry->player->full_name }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center font-medium">{{ $entry->games_played }}</td>
|
||||
<td class="px-4 py-2 text-center text-gray-500">{{ $entry->total_assigned }}</td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $entry->rate >= 75 ? 'bg-green-100 text-green-800' : ($entry->rate >= 50 ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800') }}">
|
||||
{{ $entry->rate }}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div class="h-1.5 rounded-full {{ $entry->rate >= 75 ? 'bg-green-500' : ($entry->rate >= 50 ? 'bg-yellow-500' : 'bg-red-500') }}" style="width: {{ $entry->rate }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Eltern-Engagement-Rangliste --}}
|
||||
@if ($parentRanking->isNotEmpty())
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden mt-6">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<h3 class="text-sm font-semibold text-gray-700">{{ __('admin.parent_ranking_title') }}</h3>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ __('admin.parent_ranking_desc', ['catering' => $totalCateringEvents, 'timekeeper' => $totalTimekeeperEvents]) }}</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2.5 font-medium text-gray-600 w-8">#</th>
|
||||
<th class="text-left px-4 py-2.5 font-medium text-gray-600">{{ __('admin.nav_users') }}</th>
|
||||
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('events.catering_short') }}</th>
|
||||
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('events.timekeeper_short') }}</th>
|
||||
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.total_contributions') }}</th>
|
||||
<th class="px-4 py-2.5 font-medium text-gray-600 w-32"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($parentRanking as $index => $entry)
|
||||
@php
|
||||
$maxTotal = $parentRanking->first()->total;
|
||||
$barWidth = $maxTotal > 0 ? round(($entry->total / $maxTotal) * 100) : 0;
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2 text-gray-400">{{ $index + 1 }}</td>
|
||||
<td class="px-4 py-2 flex items-center gap-2">
|
||||
@if ($entry->user->getAvatarUrl())
|
||||
<img src="{{ $entry->user->getAvatarUrl() }}" alt="" class="w-6 h-6 rounded-full object-cover">
|
||||
@else
|
||||
<div class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs font-semibold">{{ $entry->user->getInitials() }}</div>
|
||||
@endif
|
||||
<span class="{{ $entry->user->trashed() ? 'text-gray-400 line-through' : '' }}">{{ $entry->user->name }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
@if ($entry->catering_count > 0)
|
||||
<span class="text-amber-600 font-medium">{{ $entry->catering_count }}</span>
|
||||
@else
|
||||
<span class="text-gray-300">0</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
@if ($entry->timekeeper_count > 0)
|
||||
<span class="text-purple-600 font-medium">{{ $entry->timekeeper_count }}</span>
|
||||
@else
|
||||
<span class="text-gray-300">0</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center font-bold">{{ $entry->total }}</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div class="h-1.5 rounded-full bg-blue-500" style="width: {{ $barWidth }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if ($totalWithScore > 0)
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-vsrfeLOOY6KuIYKDlmVH5UiBmgIdB1oEf7p01YgWHuqmOHfZr374+odEv96n9tNC" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const winLossData = @js($chartWinLoss);
|
||||
const parentData = @js($chartParentInvolvement);
|
||||
|
||||
// Pie Chart: Siege/Niederlagen
|
||||
new Chart(document.getElementById('chartWinLoss'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: winLossData.labels,
|
||||
datasets: [{
|
||||
data: winLossData.data,
|
||||
backgroundColor: winLossData.colors,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } } }
|
||||
}
|
||||
});
|
||||
|
||||
// Bar Chart: Spieler-Teilnahme (nur Staff)
|
||||
@if (auth()->user()->isStaff())
|
||||
const playerData = @js($chartPlayerParticipation);
|
||||
new Chart(document.getElementById('chartPlayers'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: playerData.labels,
|
||||
datasets: [{
|
||||
label: @js(__('admin.nav_players')),
|
||||
data: playerData.data,
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 3,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
@endif
|
||||
|
||||
// Bar Chart: Eltern-Engagement
|
||||
new Chart(document.getElementById('chartParents'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: parentData.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: @js(__('events.catering_short')),
|
||||
data: parentData.catering,
|
||||
backgroundColor: '#f59e0b',
|
||||
borderRadius: 3,
|
||||
},
|
||||
{
|
||||
label: @js(__('events.timekeeper_short')),
|
||||
data: parentData.timekeepers,
|
||||
backgroundColor: '#8b5cf6',
|
||||
borderRadius: 3,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } } }
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@endif
|
||||
</x-layouts.admin>
|
||||
134
resources/views/admin/support/index.blade.php
Normal file
134
resources/views/admin/support/index.blade.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<x-layouts.admin :title="__('admin.support_title')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.support_title') }}</h1>
|
||||
|
||||
{{-- Nicht registriert: Hinweis --}}
|
||||
@if (!$registered)
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-6 h-6 text-yellow-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-yellow-800 mb-1">{{ __('admin.support_not_registered') }}</h2>
|
||||
<p class="text-sm text-yellow-700 mb-4">{{ __('admin.support_register_hint') }}</p>
|
||||
<form method="POST" action="{{ route('admin.support.register') }}">
|
||||
@csrf
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
|
||||
{{ __('admin.register_now') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
{{-- Ticket erstellen --}}
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ __('admin.support_new_ticket') }}</h2>
|
||||
<form method="POST" action="{{ route('admin.support.store') }}">
|
||||
@csrf
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="subject" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.support_subject') }}</label>
|
||||
<input type="text" name="subject" id="subject" value="{{ old('subject') }}" required maxlength="255"
|
||||
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('subject')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="category" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.support_category') }}</label>
|
||||
<select name="category" id="category" 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">
|
||||
<option value="bug" {{ old('category') === 'bug' ? 'selected' : '' }}>{{ __('admin.support_category_bug') }}</option>
|
||||
<option value="feature" {{ old('category') === 'feature' ? 'selected' : '' }}>{{ __('admin.support_category_feature') }}</option>
|
||||
<option value="question" {{ old('category') === 'question' ? 'selected' : '' }}>{{ __('admin.support_category_question') }}</option>
|
||||
<option value="other" {{ old('category') === 'other' ? 'selected' : '' }}>{{ __('admin.support_category_other') }}</option>
|
||||
</select>
|
||||
@error('category')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="message" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.support_message') }}</label>
|
||||
<textarea name="message" id="message" rows="4" required maxlength="5000"
|
||||
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">{{ old('message') }}</textarea>
|
||||
@error('message')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs text-gray-500">{{ __('admin.support_system_info_note') }}</p>
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
|
||||
{{ __('admin.support_submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Ticket-Liste --}}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<h2 class="text-lg font-semibold">{{ __('admin.support_title') }}</h2>
|
||||
</div>
|
||||
@if (count($tickets) > 0)
|
||||
<div class="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">#</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.support_subject') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.support_status') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.support_category') }}</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.support_created_at') }}</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.support_last_reply') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($tickets as $ticket)
|
||||
@php
|
||||
$statusColors = [
|
||||
'open' => 'bg-green-100 text-green-800',
|
||||
'in_progress' => 'bg-blue-100 text-blue-800',
|
||||
'waiting' => 'bg-yellow-100 text-yellow-800',
|
||||
'closed' => 'bg-gray-100 text-gray-800',
|
||||
];
|
||||
$categoryColors = [
|
||||
'bug' => 'bg-red-100 text-red-800',
|
||||
'feature' => 'bg-purple-100 text-purple-800',
|
||||
'question' => 'bg-blue-100 text-blue-800',
|
||||
'other' => 'bg-gray-100 text-gray-800',
|
||||
];
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-gray-500">{{ $ticket['id'] ?? '-' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ route('admin.support.show', $ticket['id'] ?? 0) }}" class="text-blue-600 hover:underline font-medium">
|
||||
{{ $ticket['subject'] ?? '-' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $statusColors[$ticket['status'] ?? ''] ?? 'bg-gray-100 text-gray-800' }}">
|
||||
{{ __('admin.support_status_' . ($ticket['status'] ?? 'open')) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $categoryColors[$ticket['category'] ?? ''] ?? 'bg-gray-100 text-gray-800' }}">
|
||||
{{ __('admin.support_category_' . ($ticket['category'] ?? 'other')) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500">{{ $ticket['created_at'] ?? '-' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500">{{ $ticket['last_reply_at'] ?? '-' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="px-6 py-8 text-center text-gray-500 text-sm">
|
||||
{{ __('admin.support_no_tickets') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</x-layouts.admin>
|
||||
92
resources/views/admin/support/show.blade.php
Normal file
92
resources/views/admin/support/show.blade.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<x-layouts.admin :title="($ticket['subject'] ?? __('admin.support_title'))">
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('admin.support.index') }}" class="text-sm text-blue-600 hover:underline">← {{ __('admin.support_back_to_list') }}</a>
|
||||
</div>
|
||||
|
||||
{{-- Ticket-Header --}}
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900">{{ $ticket['subject'] ?? '-' }}</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
#{{ $ticket['id'] ?? '-' }} · {{ $ticket['created_at'] ?? '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@php
|
||||
$statusColors = [
|
||||
'open' => 'bg-green-100 text-green-800',
|
||||
'in_progress' => 'bg-blue-100 text-blue-800',
|
||||
'waiting' => 'bg-yellow-100 text-yellow-800',
|
||||
'closed' => 'bg-gray-100 text-gray-800',
|
||||
];
|
||||
$categoryColors = [
|
||||
'bug' => 'bg-red-100 text-red-800',
|
||||
'feature' => 'bg-purple-100 text-purple-800',
|
||||
'question' => 'bg-blue-100 text-blue-800',
|
||||
'other' => 'bg-gray-100 text-gray-800',
|
||||
];
|
||||
$status = $ticket['status'] ?? 'open';
|
||||
$category = $ticket['category'] ?? 'other';
|
||||
@endphp
|
||||
<span class="inline-block px-2.5 py-1 rounded-full text-xs font-medium {{ $statusColors[$status] ?? 'bg-gray-100 text-gray-800' }}">
|
||||
{{ __('admin.support_status_' . $status) }}
|
||||
</span>
|
||||
<span class="inline-block px-2.5 py-1 rounded-full text-xs font-medium {{ $categoryColors[$category] ?? 'bg-gray-100 text-gray-800' }}">
|
||||
{{ __('admin.support_category_' . $category) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Nachrichten-Thread --}}
|
||||
<div class="space-y-4 mb-6">
|
||||
@foreach (($ticket['messages'] ?? []) as $msg)
|
||||
@php
|
||||
$isSupport = ($msg['sender'] ?? '') === 'support';
|
||||
@endphp
|
||||
<div class="rounded-lg p-4 {{ $isSupport ? 'bg-blue-50 border border-blue-200 ml-0 mr-4 sm:mr-12' : 'bg-white border border-gray-200 ml-4 sm:ml-12 mr-0' }}">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium {{ $isSupport ? 'text-blue-800' : 'text-gray-900' }}">
|
||||
{{ $isSupport ? __('admin.support_sender_support') : __('admin.support_sender_you') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">{{ $msg['created_at'] ?? '' }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 whitespace-pre-wrap">{{ $msg['message'] ?? '' }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@if (empty($ticket['messages'] ?? []))
|
||||
<div class="text-center text-gray-500 text-sm py-8">
|
||||
{{ __('admin.support_no_messages') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Antwort-Formular --}}
|
||||
@if (($ticket['status'] ?? 'open') !== 'closed')
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ __('admin.support_reply') }}</h2>
|
||||
<form method="POST" action="{{ route('admin.support.reply', $ticket['id'] ?? 0) }}">
|
||||
@csrf
|
||||
<div class="mb-4">
|
||||
<textarea name="message" rows="4" required maxlength="5000"
|
||||
placeholder="{{ __('admin.support_reply_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">{{ old('message') }}</textarea>
|
||||
@error('message')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
|
||||
{{ __('admin.support_send_reply') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center text-sm text-gray-500">
|
||||
{{ __('admin.support_ticket_closed') }}
|
||||
</div>
|
||||
@endif
|
||||
</x-layouts.admin>
|
||||
42
resources/views/admin/teams/create.blade.php
Executable file
42
resources/views/admin/teams/create.blade.php
Executable 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>
|
||||
286
resources/views/admin/teams/edit.blade.php
Executable file
286
resources/views/admin/teams/edit.blade.php
Executable 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">✓</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 ?? '' }} · {{ $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') }} ↓
|
||||
</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>
|
||||
51
resources/views/admin/teams/index.blade.php
Executable file
51
resources/views/admin/teams/index.blade.php
Executable 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>
|
||||
255
resources/views/admin/users/edit.blade.php
Normal file
255
resources/views/admin/users/edit.blade.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<x-layouts.admin :title="__('admin.edit_user')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.edit_user') }}: {{ $user->name }}</h1>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Benutzerdaten --}}
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="font-semibold mb-4">{{ __('admin.user_data') }}</h2>
|
||||
<form method="POST" action="{{ route('admin.users.update', $user) }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- Profilbild --}}
|
||||
<div class="mb-5" x-data="{ preview: null }">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.profile_picture') }}</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
@if ($user->getAvatarUrl())
|
||||
<img src="{{ $user->getAvatarUrl() }}" alt="{{ $user->name }}" class="w-14 h-14 rounded-full object-cover border-2 border-gray-200" x-show="!preview">
|
||||
@else
|
||||
<div class="w-14 h-14 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-semibold border-2 border-gray-200" x-show="!preview">
|
||||
{{ $user->getInitials() }}
|
||||
</div>
|
||||
@endif
|
||||
<img :src="preview" x-show="preview" class="w-14 h-14 rounded-full object-cover border-2 border-blue-400" x-cloak>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cursor-pointer bg-gray-100 text-gray-700 px-3 py-1.5 rounded-md text-xs hover:bg-gray-200 inline-block">
|
||||
{{ __('admin.upload_picture') }}
|
||||
<input type="file" name="profile_picture" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden"
|
||||
@change="if ($event.target.files[0]) { preview = URL.createObjectURL($event.target.files[0]) }">
|
||||
</label>
|
||||
<span class="text-xs text-gray-400">{{ __('admin.max_picture_size') }}</span>
|
||||
@error('profile_picture')
|
||||
<p class="text-red-600 text-xs">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.name') }} *</label>
|
||||
<input type="text" name="name" id="name" value="{{ old('name', $user->name) }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md @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="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.email') }} *</label>
|
||||
<input type="email" name="email" id="email" value="{{ old('email', $user->email) }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('email') border-red-500 @enderror">
|
||||
@error('email')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.phone') }}</label>
|
||||
<input type="tel" name="phone" id="phone" value="{{ old('phone', $user->phone) }}"
|
||||
placeholder="+49..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('phone') border-red-500 @enderror">
|
||||
@error('phone')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="role" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.role') }}</label>
|
||||
<select name="role" id="role" class="w-full px-3 py-2 border border-gray-300 rounded-md {{ $user->id === auth()->id() ? 'bg-gray-100 text-gray-500' : '' }}" {{ $user->id === auth()->id() ? 'disabled' : '' }}>
|
||||
<option value="user" {{ old('role', $user->role->value) === 'user' ? 'selected' : '' }}>{{ __('ui.enums.user_role.user') }}</option>
|
||||
<option value="parent_rep" {{ old('role', $user->role->value) === 'parent_rep' ? 'selected' : '' }}>{{ __('ui.enums.user_role.parent_rep') }}</option>
|
||||
<option value="coach" {{ old('role', $user->role->value) === 'coach' ? 'selected' : '' }}>{{ __('ui.enums.user_role.coach') }}</option>
|
||||
@if (auth()->user()->isAdmin())
|
||||
<option value="admin" {{ old('role', $user->role->value) === 'admin' ? 'selected' : '' }}>{{ __('ui.enums.user_role.admin') }}</option>
|
||||
@endif
|
||||
</select>
|
||||
@if ($user->id === auth()->id())
|
||||
<p class="mt-1 text-xs text-gray-500">{{ __('admin.cannot_edit_own_role') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
|
||||
@if ($user->getAvatarUrl())
|
||||
<div class="mt-3 pt-3 border-t">
|
||||
<form method="POST" action="{{ route('admin.users.remove-picture', $user) }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_file')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700">{{ __('admin.remove_picture') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Passwort-Reset --}}
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="font-semibold mb-4">{{ __('admin.reset_password') }}</h2>
|
||||
|
||||
@if (session('new_password'))
|
||||
<div class="bg-yellow-50 border border-yellow-300 rounded-md p-4 mb-4">
|
||||
<p class="text-sm font-medium text-yellow-800 mb-2">{{ __('admin.new_password_label') }}</p>
|
||||
<div class="flex items-center gap-2" x-data="{ copied: false }">
|
||||
<code class="bg-white border border-yellow-200 rounded px-3 py-2 text-sm font-mono flex-1 select-all">{{ session('new_password') }}</code>
|
||||
<button @click="navigator.clipboard.writeText(@js(session('new_password'))); copied = true; setTimeout(() => copied = false, 2000)"
|
||||
class="px-3 py-2 text-xs bg-yellow-600 text-white rounded-md hover:bg-yellow-700">
|
||||
<span x-show="!copied">{{ __('admin.copy') }}</span>
|
||||
<span x-show="copied" x-cloak>{{ __('admin.copied') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-yellow-700 mt-2">{{ __('admin.password_only_visible_now') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($user->id === auth()->id())
|
||||
<p class="text-sm text-gray-500">{{ __('admin.cannot_reset_own_password') }}</p>
|
||||
@else
|
||||
<p class="text-sm text-gray-600 mb-4">{{ __('admin.reset_password_hint') }}</p>
|
||||
<form method="POST" action="{{ route('admin.users.reset-password', $user) }}" onsubmit="return confirm(@js(__('admin.reset_password_confirm')))">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 text-sm font-medium">
|
||||
{{ __('admin.reset_password') }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
{{-- Zusatzinfos --}}
|
||||
<div class="mt-6 pt-4 border-t text-xs text-gray-500 space-y-1">
|
||||
<p>{{ __('admin.last_login') }}: {{ $user->last_login_at ? $user->last_login_at->diffForHumans() : __('admin.never') }}</p>
|
||||
<p>{{ __('admin.registered_at') }}: {{ $user->created_at->translatedFormat(__('ui.date_format')) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- DSGVO-Einverständniserklärung --}}
|
||||
<div class="mt-6 bg-white rounded-lg shadow p-6" x-data="{ dsgvoModal: false }">
|
||||
<h2 class="font-semibold mb-4">{{ __('admin.dsgvo_title') }}</h2>
|
||||
|
||||
@if ($user->hasDsgvoConsent())
|
||||
@php
|
||||
$ext = strtolower(pathinfo($user->dsgvo_consent_file, PATHINFO_EXTENSION));
|
||||
$dsgvoIsPdf = $ext === 'pdf';
|
||||
$dsgvoIsImage = in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp']);
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<button type="button" @click="dsgvoModal = true"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 font-medium cursor-pointer">
|
||||
{{ __('admin.dsgvo_view_document') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- DSGVO-Vorschau-Modal --}}
|
||||
<div x-show="dsgvoModal" x-cloak @keydown.escape.window="dsgvoModal = false" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div x-show="dsgvoModal"
|
||||
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
@click="dsgvoModal = false" class="fixed inset-0 bg-black/60"></div>
|
||||
<div x-show="dsgvoModal"
|
||||
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.outside="dsgvoModal = false"
|
||||
class="relative bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden {{ $dsgvoIsPdf ? 'w-full max-w-3xl max-h-[92vh]' : 'w-full max-w-lg max-h-[90vh]' }}">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b">
|
||||
<h3 class="font-semibold text-gray-900">{{ __('admin.dsgvo_title') }} — {{ $user->name }}</h3>
|
||||
<button @click="dsgvoModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto {{ $dsgvoIsPdf ? 'p-0' : 'p-5' }}">
|
||||
@if ($dsgvoIsImage)
|
||||
<div class="flex justify-center bg-gray-50 rounded-lg p-2">
|
||||
<img src="{{ route('admin.users.view-dsgvo-consent', $user) }}" alt="{{ __('admin.dsgvo_title') }}" class="max-w-full max-h-[70vh] rounded object-contain">
|
||||
</div>
|
||||
@elseif ($dsgvoIsPdf)
|
||||
<iframe src="{{ route('admin.users.view-dsgvo-consent', $user) }}" class="w-full border-0" style="height: 75vh;"></iframe>
|
||||
@endif
|
||||
</div>
|
||||
<div class="px-5 py-3 border-t bg-gray-50 flex justify-end">
|
||||
<button @click="dsgvoModal = false" class="text-sm text-gray-500 hover:text-gray-700">{{ __('ui.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('admin.users.dsgvo-toggle', $user) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ __('admin.dsgvo_consent_label') }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
@if ($user->isDsgvoConfirmed())
|
||||
{{ __('admin.dsgvo_confirmed_info', [
|
||||
'name' => $user->dsgvoAcceptedBy?->name ?? '—',
|
||||
'date' => $user->dsgvo_accepted_at->translatedFormat(__('ui.date_format_short'))
|
||||
]) }}
|
||||
@else
|
||||
{{ __('admin.dsgvo_not_confirmed') }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 rounded-md text-sm font-medium
|
||||
{{ $user->isDsgvoConfirmed()
|
||||
? 'bg-yellow-500 text-white hover:bg-yellow-600'
|
||||
: 'bg-green-600 text-white hover:bg-green-700' }}">
|
||||
{{ $user->isDsgvoConfirmed() ? __('admin.dsgvo_revoke') : __('admin.dsgvo_confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">{{ __('admin.dsgvo_no_document') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Benutzer deaktivieren / löschen --}}
|
||||
@if ($user->id !== auth()->id())
|
||||
<div class="mt-6 bg-white rounded-lg shadow p-6 border border-red-200">
|
||||
<h2 class="font-semibold text-red-700 mb-2">{{ __('admin.danger_zone') }}</h2>
|
||||
|
||||
{{-- Deaktivieren / Aktivieren --}}
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ __('admin.user_status_label') }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ $user->is_active ? __('admin.deactivate_user_hint') : __('admin.activate_user_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('admin.users.toggle-active', $user) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<button type="submit" class="px-4 py-2 rounded-md text-sm font-medium {{ $user->is_active ? 'bg-yellow-500 text-white hover:bg-yellow-600' : 'bg-green-600 text-white hover:bg-green-700' }}">
|
||||
{{ $user->is_active ? __('admin.deactivate') : __('admin.activate') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Löschen --}}
|
||||
@if ($user->id !== 1)
|
||||
<div class="flex items-center justify-between py-3 border-t border-red-100">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ __('admin.delete_user') }}</p>
|
||||
<p class="text-xs text-gray-500">{{ __('admin.delete_user_hint') }}</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('admin.users.destroy', $user) }}" onsubmit="return confirm(@js(__('admin.confirm_delete_user')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 text-sm font-medium">
|
||||
{{ __('admin.delete') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ route('admin.users.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('admin.back_to_list') }}</a>
|
||||
</div>
|
||||
</x-layouts.admin>
|
||||
201
resources/views/admin/users/index.blade.php
Executable file
201
resources/views/admin/users/index.blade.php
Executable file
@@ -0,0 +1,201 @@
|
||||
<x-layouts.admin :title="__('admin.users_title')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('admin.users_title') }}</h1>
|
||||
|
||||
<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">
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'name', 'direction' => ($sort === 'name' && $direction === 'asc') ? 'desc' : 'asc']) }}"
|
||||
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'name' ? 'text-blue-600' : '' }}">
|
||||
{{ __('ui.name') }}
|
||||
@if ($sort === 'name')
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
@if ($direction === 'asc')
|
||||
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||
@else
|
||||
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
@endif
|
||||
</svg>
|
||||
@endif
|
||||
</a>
|
||||
</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'email', 'direction' => ($sort === 'email' && $direction === 'asc') ? 'desc' : 'asc']) }}"
|
||||
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'email' ? 'text-blue-600' : '' }}">
|
||||
{{ __('ui.email') }}
|
||||
@if ($sort === 'email')
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
@if ($direction === 'asc')
|
||||
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||
@else
|
||||
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
@endif
|
||||
</svg>
|
||||
@endif
|
||||
</a>
|
||||
</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.phone') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'role', 'direction' => ($sort === 'role' && $direction === 'asc') ? 'desc' : 'asc']) }}"
|
||||
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'role' ? 'text-blue-600' : '' }}">
|
||||
{{ __('ui.role') }}
|
||||
@if ($sort === 'role')
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
@if ($direction === 'asc')
|
||||
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||
@else
|
||||
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
@endif
|
||||
</svg>
|
||||
@endif
|
||||
</a>
|
||||
</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">{{ __('admin.children') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.dsgvo_short') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'last_login_at', 'direction' => ($sort === 'last_login_at' && $direction === 'asc') ? 'desc' : 'asc']) }}"
|
||||
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'last_login_at' ? 'text-blue-600' : '' }}">
|
||||
{{ __('admin.last_login') }}
|
||||
@if ($sort === 'last_login_at')
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
@if ($direction === 'asc')
|
||||
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||
@else
|
||||
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
@endif
|
||||
</svg>
|
||||
@endif
|
||||
</a>
|
||||
</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">
|
||||
<a href="{{ request()->fullUrlWithQuery(['sort' => 'is_active', 'direction' => ($sort === 'is_active' && $direction === 'asc') ? 'desc' : 'asc']) }}"
|
||||
class="inline-flex items-center gap-1 hover:text-blue-600 {{ $sort === 'is_active' ? 'text-blue-600' : '' }}">
|
||||
{{ __('admin.status') }}
|
||||
@if ($sort === 'is_active')
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
@if ($direction === 'asc')
|
||||
<path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 6.414l-3.293 3.293a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||
@else
|
||||
<path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L10 13.586l3.293-3.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
@endif
|
||||
</svg>
|
||||
@endif
|
||||
</a>
|
||||
</th>
|
||||
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($users as $user)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="{{ $user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0">
|
||||
{{ $user->name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600">{{ $user->email }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 text-sm whitespace-nowrap">
|
||||
@if ($user->phone)
|
||||
<a href="tel:{{ $user->phone }}" class="hover:text-blue-600">{{ $user->phone }}</a>
|
||||
@else
|
||||
<span class="text-gray-400">–</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if ($user->id !== auth()->id())
|
||||
<form method="POST" action="{{ route('admin.users.role', $user) }}" class="inline">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<select name="role" onchange="this.form.submit()" class="text-xs border border-gray-300 rounded-md px-2 py-1">
|
||||
<option value="user" {{ $user->role === \App\Enums\UserRole::User ? 'selected' : '' }}>{{ __('ui.enums.user_role.user') }}</option>
|
||||
<option value="parent_rep" {{ $user->role === \App\Enums\UserRole::ParentRep ? 'selected' : '' }}>{{ __('ui.enums.user_role.parent_rep') }}</option>
|
||||
<option value="coach" {{ $user->role === \App\Enums\UserRole::Coach ? 'selected' : '' }}>{{ __('ui.enums.user_role.coach') }}</option>
|
||||
@if (auth()->user()->isAdmin())
|
||||
<option value="admin" {{ $user->role === \App\Enums\UserRole::Admin ? 'selected' : '' }}>{{ __('ui.enums.user_role.admin') }}</option>
|
||||
@endif
|
||||
</select>
|
||||
</form>
|
||||
@else
|
||||
<span class="text-xs font-medium text-gray-500">{{ __('ui.enums.user_role.' . $user->role->value) }} {{ __('admin.you_suffix') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-600">
|
||||
@foreach ($user->children as $child)
|
||||
{{ $child->first_name }}@if (!$loop->last), @endif
|
||||
@endforeach
|
||||
@if ($user->children->isEmpty())
|
||||
<span class="text-gray-400">–</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if ($user->isDsgvoConfirmed())
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800" title="{{ __('admin.dsgvo_confirmed_tooltip') }}">✓</span>
|
||||
@elseif ($user->hasDsgvoConsent())
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800" title="{{ __('admin.dsgvo_pending_tooltip') }}">!</span>
|
||||
@else
|
||||
<span class="text-gray-400" title="{{ __('admin.dsgvo_missing_tooltip') }}">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-gray-500">
|
||||
{{ $user->last_login_at ? $user->last_login_at->diffForHumans() : __('admin.never') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
@if ($user->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-red-100 text-red-800">{{ __('admin.deactivated_label') }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="{{ route('admin.users.edit', $user) }}" class="text-xs text-blue-600 hover:text-blue-800">{{ __('admin.edit') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $users->links() }}</div>
|
||||
|
||||
{{-- Papierkorb --}}
|
||||
@if ($trashedUsers->isNotEmpty())
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-3">{{ __('admin.trash') }}</h2>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto border border-red-200">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-red-50 border-b border-red-200">
|
||||
<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">{{ __('ui.email') }}</th>
|
||||
<th class="text-center px-4 py-3 font-medium text-gray-700">{{ __('admin.deleted_at') }}</th>
|
||||
<th class="text-right px-4 py-3 font-medium text-gray-700">{{ __('admin.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($trashedUsers as $user)
|
||||
<tr class="hover:bg-red-50/50">
|
||||
<td class="px-4 py-3 font-medium text-gray-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="{{ $user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0 opacity-50">
|
||||
{{ $user->name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-400">{{ $user->email }}</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-gray-500">{{ $user->deleted_at->diffForHumans() }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<form method="POST" action="{{ route('admin.users.restore', $user->id) }}" class="inline">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<button type="submit" class="text-xs text-green-600 hover:text-green-800 font-medium">{{ __('admin.restore') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</x-layouts.admin>
|
||||
44
resources/views/auth/forgot-password.blade.php
Normal file
44
resources/views/auth/forgot-password.blade.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<x-layouts.guest :title="__('auth_ui.forgot_password_title')">
|
||||
<h2 class="text-lg font-bold text-center mb-2">{{ __('auth_ui.forgot_password_title') }}</h2>
|
||||
<p class="text-sm text-gray-500 text-center mb-6">{{ __('auth_ui.forgot_password_description') }}</p>
|
||||
|
||||
@if (session('status'))
|
||||
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-md text-sm text-green-700">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('password.email') }}">
|
||||
@csrf
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.email_label') }}</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
value="{{ old('email') }}"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
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 focus:border-blue-500 @error('email') border-red-500 @enderror"
|
||||
>
|
||||
@error('email')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
|
||||
>
|
||||
{{ __('auth_ui.send_reset_link') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-sm text-center">
|
||||
<a href="{{ route('login') }}" class="text-blue-600 hover:text-blue-800 underline">
|
||||
{{ __('auth_ui.back_to_login') }}
|
||||
</a>
|
||||
</p>
|
||||
</x-layouts.guest>
|
||||
62
resources/views/auth/login.blade.php
Executable file
62
resources/views/auth/login.blade.php
Executable file
@@ -0,0 +1,62 @@
|
||||
<x-layouts.guest :title="__('auth_ui.login_title')">
|
||||
<h2 class="text-lg font-bold text-center mb-6">{{ __('auth_ui.login_title') }}</h2>
|
||||
|
||||
<form method="POST" action="{{ route('login') }}">
|
||||
@csrf
|
||||
|
||||
{{-- Honeypot --}}
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<input type="text" name="website" value="" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.email_label') }}</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
value="{{ old('email') }}"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
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 focus:border-blue-500 @error('email') border-red-500 @enderror"
|
||||
>
|
||||
@error('email')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">{{ __('auth_ui.password_label') }}</label>
|
||||
<a href="{{ route('password.request') }}" class="text-xs text-blue-600 hover:text-blue-800 underline">{{ __('auth_ui.forgot_password_link') }}</a>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
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 focus:border-blue-500"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 flex items-center">
|
||||
<input type="checkbox" name="remember" id="remember" class="rounded border-gray-300 mr-2 rtl:ml-2 rtl:mr-0">
|
||||
<label for="remember" class="text-sm text-gray-600">{{ __('auth_ui.remember_me') }}</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
|
||||
>
|
||||
{{ __('auth_ui.login_button') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-xs text-center text-gray-400 leading-relaxed">
|
||||
<svg class="inline w-3.5 h-3.5 -mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
|
||||
{{ __('auth_ui.cookie_notice') }}
|
||||
<a href="/datenschutz#cookies" class="underline hover:text-gray-600">{{ __('auth_ui.cookie_notice_link') }}</a>
|
||||
</p>
|
||||
</x-layouts.guest>
|
||||
62
resources/views/auth/register.blade.php
Executable file
62
resources/views/auth/register.blade.php
Executable file
@@ -0,0 +1,62 @@
|
||||
<x-layouts.guest :title="__('auth_ui.register_title')">
|
||||
<h2 class="text-xl font-bold text-center mb-6">{{ __('auth_ui.register_title') }}</h2>
|
||||
|
||||
@if ($invitation->players->isNotEmpty())
|
||||
<div class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p class="text-sm text-blue-800 font-medium mb-1">{{ __('auth_ui.children_assigned') }}</p>
|
||||
<ul class="text-sm text-blue-700">
|
||||
@foreach ($invitation->players as $player)
|
||||
<li>{{ $player->full_name }} ({{ $player->team->name }})</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('register', $invitation->token) }}">
|
||||
@csrf
|
||||
|
||||
{{-- Honeypot --}}
|
||||
<div class="hidden" aria-hidden="true">
|
||||
<input type="text" name="website" value="" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.name_label') }} *</label>
|
||||
<input type="text" name="name" id="name" value="{{ old('name') }}" required autofocus
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-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="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.email_label') }} *</label>
|
||||
<input type="email" name="email" id="email" value="{{ old('email', $invitation->email) }}" required
|
||||
{{ $invitation->email ? 'readonly' : '' }}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 @error('email') border-red-500 @enderror {{ $invitation->email ? 'bg-gray-100' : '' }}">
|
||||
@error('email')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
@if ($invitation->email)
|
||||
<p class="mt-1 text-xs text-gray-500">{{ __('auth_ui.email_fixed_by_invitation') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.password_label') }} * <span class="font-normal text-gray-400">{{ __('auth_ui.password_min') }}</span></label>
|
||||
<input type="password" name="password" id="password" required minlength="8"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 @error('password') border-red-500 @enderror">
|
||||
@error('password')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="password_confirmation" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.password_confirm_label') }} *</label>
|
||||
<input type="password" name="password_confirmation" id="password_confirmation" required minlength="8"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 font-medium">
|
||||
{{ __('auth_ui.create_account') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-500">
|
||||
{{ __('auth_ui.already_registered') }} <a href="{{ route('login') }}" class="text-blue-600 hover:underline">{{ __('auth_ui.go_to_login') }}</a>
|
||||
</p>
|
||||
</x-layouts.guest>
|
||||
65
resources/views/auth/reset-password.blade.php
Normal file
65
resources/views/auth/reset-password.blade.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<x-layouts.guest :title="__('auth_ui.reset_password_title')">
|
||||
<h2 class="text-lg font-bold text-center mb-6">{{ __('auth_ui.reset_password_title') }}</h2>
|
||||
|
||||
<form method="POST" action="{{ route('password.update') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="token" value="{{ $token }}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.email_label') }}</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
value="{{ old('email', $email) }}"
|
||||
required
|
||||
autocomplete="email"
|
||||
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 focus:border-blue-500 @error('email') border-red-500 @enderror"
|
||||
>
|
||||
@error('email')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.new_password_label') }}</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
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 focus:border-blue-500 @error('password') border-red-500 @enderror"
|
||||
>
|
||||
<p class="mt-1 text-xs text-gray-400">{{ __('auth_ui.password_min') }}</p>
|
||||
@error('password')
|
||||
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="password_confirmation" class="block text-sm font-medium text-gray-700 mb-1">{{ __('auth_ui.confirm_password_label') }}</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password_confirmation"
|
||||
id="password_confirmation"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
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 focus:border-blue-500"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
|
||||
>
|
||||
{{ __('auth_ui.reset_password_button') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-sm text-center">
|
||||
<a href="{{ route('login') }}" class="text-blue-600 hover:text-blue-800 underline">
|
||||
{{ __('auth_ui.back_to_login') }}
|
||||
</a>
|
||||
</p>
|
||||
</x-layouts.guest>
|
||||
78
resources/views/components/event-status-boxes.blade.php
Executable file
78
resources/views/components/event-status-boxes.blade.php
Executable file
@@ -0,0 +1,78 @@
|
||||
@props(['event'])
|
||||
|
||||
@php
|
||||
use App\Enums\ParticipantStatus;
|
||||
use App\Enums\CateringStatus;
|
||||
use App\Enums\EventType;
|
||||
|
||||
$isMeeting = $event->type === EventType::Meeting;
|
||||
$yesCount = $event->participants->where('status', ParticipantStatus::Yes)->count();
|
||||
$noCount = $event->participants->where('status', ParticipantStatus::No)->count();
|
||||
$openCount = $event->participants->where('status', ParticipantStatus::Unknown)->count();
|
||||
|
||||
// withCount-Attribute nutzen wenn vorhanden (Index-Views), sonst Collection filtern (Show-View)
|
||||
$hasCatering = $event->type->hasCatering();
|
||||
$hasTimekeepers = $event->type->hasTimekeepers();
|
||||
$cateringYes = $hasCatering ? ($event->caterings_yes_count ?? $event->caterings->where('status', CateringStatus::Yes)->count()) : 0;
|
||||
$timekeeperYes = $hasTimekeepers ? ($event->timekeepers_yes_count ?? $event->timekeepers->where('status', CateringStatus::Yes)->count()) : 0;
|
||||
|
||||
// Individual minimum status
|
||||
if ($event->min_players !== null) {
|
||||
$playersMet = $yesCount >= $event->min_players;
|
||||
} else {
|
||||
$playersMet = null;
|
||||
}
|
||||
|
||||
$cateringMet = ($hasCatering && $event->min_catering !== null) ? $cateringYes >= $event->min_catering : null;
|
||||
$timekeepersMet = ($hasTimekeepers && $event->min_timekeepers !== null) ? $timekeeperYes >= $event->min_timekeepers : null;
|
||||
|
||||
// Box size classes
|
||||
$boxClass = 'inline-flex items-center justify-center w-9 h-9 rounded text-sm font-bold';
|
||||
$participantsLabel = $isMeeting ? __('admin.nav_users') : __('admin.nav_players');
|
||||
@endphp
|
||||
|
||||
<div class="flex items-start gap-4 sm:gap-6">
|
||||
{{-- Spieler / Benutzer --}}
|
||||
<div class="text-center">
|
||||
<div class="text-[10px] font-semibold text-gray-500 uppercase tracking-wide mb-1">{{ $participantsLabel }}</div>
|
||||
<div class="flex gap-0.5">
|
||||
@if ($playersMet === true)
|
||||
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $yesCount }}</span>
|
||||
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $noCount }}</span>
|
||||
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $openCount }}</span>
|
||||
@else
|
||||
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $yesCount }}</span>
|
||||
<span class="{{ $boxClass }} bg-red-500 text-white">{{ $noCount }}</span>
|
||||
<span class="{{ $boxClass }} bg-gray-300 text-gray-700">{{ $openCount }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($hasCatering)
|
||||
{{-- Catering --}}
|
||||
<div class="text-center">
|
||||
<div class="text-[10px] font-semibold text-gray-500 uppercase tracking-wide mb-1">{{ __('events.catering_short') }}</div>
|
||||
@if ($cateringMet === true)
|
||||
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $cateringYes }}</span>
|
||||
@elseif ($cateringMet === false)
|
||||
<span class="{{ $boxClass }} bg-red-500 text-white">{{ $cateringYes }}</span>
|
||||
@else
|
||||
<span class="{{ $boxClass }} bg-gray-200 text-gray-600">{{ $cateringYes }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($hasTimekeepers)
|
||||
{{-- Zeitnehmer --}}
|
||||
<div class="text-center">
|
||||
<div class="text-[10px] font-semibold text-gray-500 uppercase tracking-wide mb-1">{{ __('events.timekeeper_short') }}</div>
|
||||
@if ($timekeepersMet === true)
|
||||
<span class="{{ $boxClass }} bg-green-500 text-white">{{ $timekeeperYes }}</span>
|
||||
@elseif ($timekeepersMet === false)
|
||||
<span class="{{ $boxClass }} bg-red-500 text-white">{{ $timekeeperYes }}</span>
|
||||
@else
|
||||
<span class="{{ $boxClass }} bg-gray-200 text-gray-600">{{ $timekeeperYes }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
18
resources/views/components/event-type-badge.blade.php
Executable file
18
resources/views/components/event-type-badge.blade.php
Executable file
@@ -0,0 +1,18 @@
|
||||
@props(['type'])
|
||||
|
||||
@php
|
||||
$colors = match($type->value ?? $type) {
|
||||
'home_game' => 'bg-blue-100 text-blue-800',
|
||||
'away_game' => 'bg-indigo-100 text-indigo-800',
|
||||
'training' => 'bg-green-100 text-green-800',
|
||||
'tournament' => 'bg-purple-100 text-purple-800',
|
||||
'meeting' => 'bg-yellow-100 text-yellow-800',
|
||||
'other' => 'bg-gray-100 text-gray-800',
|
||||
default => 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
$label = method_exists($type, 'label') ? $type->label() : $type;
|
||||
@endphp
|
||||
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium {{ $colors }}">
|
||||
{{ $label }}
|
||||
</span>
|
||||
109
resources/views/components/file-preview-modal.blade.php
Normal file
109
resources/views/components/file-preview-modal.blade.php
Normal file
@@ -0,0 +1,109 @@
|
||||
{{-- File Preview Modal (Alpine.js) --}}
|
||||
<div x-data="filePreviewModal()" x-cloak @open-file-preview.window="openFile($event.detail)" @keydown.escape.window="close()">
|
||||
{{-- Backdrop --}}
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click="close()"
|
||||
class="fixed inset-0 bg-black/60 z-40"></div>
|
||||
|
||||
{{-- Modal --}}
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="fixed inset-0 flex items-center justify-center z-50 p-4 sm:p-6">
|
||||
<div @click.away="close()"
|
||||
class="bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden"
|
||||
:class="(file.isPdf || file.isHtml) ? 'w-full max-w-3xl max-h-[92vh]' : 'w-full max-w-lg max-h-[90vh]'">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 truncate pr-4" x-text="file.name"></h3>
|
||||
<button @click="close()" class="flex-shrink-0 text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Content --}}
|
||||
<div class="flex-1 overflow-y-auto p-5" :class="(file.isPdf || file.isHtml) ? 'p-0' : 'p-5'">
|
||||
{{-- Image Preview --}}
|
||||
<template x-if="file.isImage && file.previewUrl">
|
||||
<div class="flex justify-center mb-4 bg-gray-50 rounded-lg p-2 mx-5 mt-5">
|
||||
<img :src="file.previewUrl" :alt="file.name" class="max-w-full max-h-80 rounded object-contain" loading="lazy">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- PDF Preview --}}
|
||||
<template x-if="file.isPdf && file.previewUrl">
|
||||
<iframe :src="file.previewUrl" class="w-full border-0" style="height: 70vh;"></iframe>
|
||||
</template>
|
||||
|
||||
{{-- HTML Preview --}}
|
||||
<template x-if="file.isHtml && file.previewUrl">
|
||||
<iframe :src="file.previewUrl" class="w-full border-0" style="height: 70vh;"></iframe>
|
||||
</template>
|
||||
|
||||
{{-- Non-Image/Non-PDF/Non-HTML: Large Icon --}}
|
||||
<template x-if="!file.isImage && !file.isPdf && !file.isHtml">
|
||||
<div class="flex justify-center mb-4 mx-5 mt-5">
|
||||
<div class="w-20 h-20 rounded-xl flex items-center justify-center" :class="file.iconBg">
|
||||
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- File Info (nicht bei PDF/HTML, da dort der iframe den Platz braucht) --}}
|
||||
<template x-if="!file.isPdf && !file.isHtml">
|
||||
<div class="space-y-2 text-center px-5 pb-2">
|
||||
<div>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700" x-text="file.category"></span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500" x-text="file.size"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Footer --}}
|
||||
<div class="px-5 py-3 border-t bg-gray-50 flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="close()" class="text-sm text-gray-500 hover:text-gray-700">{{ __('ui.close') }}</button>
|
||||
<template x-if="file.isPdf || file.isHtml">
|
||||
<span class="text-xs text-gray-400">
|
||||
<span x-text="file.category"></span> · <span x-text="file.size"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<a :href="file.downloadUrl" class="inline-flex items-center gap-1.5 bg-blue-600 text-white px-4 py-2 rounded-md text-sm hover:bg-blue-700">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
{{ __('ui.download') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filePreviewModal() {
|
||||
return {
|
||||
open: false,
|
||||
file: { name: '', category: '', size: '', downloadUrl: '', previewUrl: null, isImage: false, isPdf: false, isHtml: false, iconBg: '' },
|
||||
openFile(detail) {
|
||||
this.file = detail;
|
||||
this.open = true;
|
||||
document.body.style.overflow = 'hidden';
|
||||
},
|
||||
close() {
|
||||
this.open = false;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
17
resources/views/components/flash-message.blade.php
Executable file
17
resources/views/components/flash-message.blade.php
Executable file
@@ -0,0 +1,17 @@
|
||||
@props(['type' => 'success', 'message'])
|
||||
|
||||
@php
|
||||
$classes = match($type) {
|
||||
'success' => 'bg-green-50 border-green-400 text-green-800',
|
||||
'error' => 'bg-red-50 border-red-400 text-red-800',
|
||||
'warning' => 'bg-yellow-50 border-yellow-400 text-yellow-800',
|
||||
default => 'bg-blue-50 border-blue-400 text-blue-800',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="mb-4 p-3 border-l-4 rounded {{ $classes }}" x-data="{ show: true }" x-show="show">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="text-sm">{{ $message }}</p>
|
||||
<button @click="show = false" class="ml-4 text-lg leading-none opacity-50 hover:opacity-100">×</button>
|
||||
</div>
|
||||
</div>
|
||||
160
resources/views/components/layouts/admin.blade.php
Executable file
160
resources/views/components/layouts/admin.blade.php
Executable file
@@ -0,0 +1,160 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ __('ui.admin') }} - {{ $title ?? \App\Models\Setting::get('app_name', config('app.name')) }}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js" integrity="sha384-9Ax3MmS9AClxJyd5/zafcXXjxmwFhZCdsT6HJoJjarvCaAkJlk5QDzjLJm+Wdx5F" crossorigin="anonymous"></script>
|
||||
@php $favicon = \App\Models\Setting::get('app_favicon'); @endphp
|
||||
@if ($favicon)
|
||||
<link rel="icon" href="{{ asset('storage/' . $favicon) }}">
|
||||
@else
|
||||
<link rel="icon" href="{{ asset('favicon.ico') }}">
|
||||
@endif
|
||||
{{-- PWA --}}
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#1f2937">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="SG Wölfe">
|
||||
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png">
|
||||
@stack('styles')
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-100 flex flex-col">
|
||||
{{-- Admin Navigation --}}
|
||||
<nav class="bg-gray-800 text-white" x-data="{ open: false, mgmt: false }">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<div class="flex justify-between h-14">
|
||||
<div class="flex items-center space-x-6 rtl:space-x-reverse">
|
||||
<a href="{{ route('admin.dashboard') }}" class="flex items-center gap-2 font-bold">
|
||||
<img src="/images/logo_woelfe.png" alt="Logo" class="h-8 w-8 object-contain">
|
||||
{{ __('ui.admin') }}
|
||||
</a>
|
||||
{{-- Desktop nav links (ab lg sichtbar) --}}
|
||||
<div class="hidden lg:flex items-center space-x-5 rtl:space-x-reverse">
|
||||
<a href="{{ route('admin.events.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.events.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_events') }}</a>
|
||||
@if (\App\Models\Setting::isFeatureVisibleFor('statistics', auth()->user()))
|
||||
<a href="{{ route('admin.statistics.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.statistics.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_statistics') }}</a>
|
||||
@endif
|
||||
@if (auth()->user()->isStaff())
|
||||
<a href="{{ route('admin.teams.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.teams.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_teams') }}</a>
|
||||
<a href="{{ route('admin.players.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.players.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_players') }}</a>
|
||||
<a href="{{ route('admin.users.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.users.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_users') }}</a>
|
||||
@endif
|
||||
<a href="{{ route('admin.files.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.files.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_files') }}</a>
|
||||
@if (auth()->user()->isStaff())
|
||||
<a href="{{ route('admin.locations.index') }}" class="text-sm text-gray-300 hover:text-white {{ request()->routeIs('admin.locations.*') ? 'text-white font-semibold' : '' }}">{{ __('admin.nav_locations') }}</a>
|
||||
{{-- Verwaltung-Dropdown --}}
|
||||
<div class="relative" @click.away="mgmt = false">
|
||||
<button @click="mgmt = !mgmt" class="text-sm text-gray-300 hover:text-white flex items-center gap-1 {{ request()->routeIs('admin.settings.*') || request()->routeIs('admin.invitations.*') || request()->routeIs('admin.activity-logs.*') || request()->routeIs('admin.support.*') ? 'text-white font-semibold' : '' }}">
|
||||
{{ __('admin.nav_verwaltung') }}
|
||||
@if (\Illuminate\Support\Facades\Cache::has('support.update_check'))
|
||||
<span class="w-2 h-2 bg-blue-400 rounded-full"></span>
|
||||
@endif
|
||||
<svg class="w-3 h-3 transition-transform" :class="mgmt ? 'rotate-180' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
<div x-show="mgmt" x-cloak x-transition class="absolute left-0 rtl:left-auto rtl:right-0 mt-2 w-48 bg-gray-700 rounded-md shadow-lg py-1 z-50">
|
||||
<a href="{{ route('admin.settings.edit') }}" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 {{ request()->routeIs('admin.settings.*') ? 'bg-gray-600 font-semibold' : '' }}">{{ __('admin.nav_settings') }}</a>
|
||||
<a href="{{ route('admin.invitations.index') }}" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 {{ request()->routeIs('admin.invitations.*') ? 'bg-gray-600 font-semibold' : '' }}">{{ __('admin.nav_invitations') }}</a>
|
||||
<a href="{{ route('admin.list-generator.create') }}" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 {{ request()->routeIs('admin.list-generator.*') ? 'bg-gray-600 font-semibold' : '' }}">{{ __('admin.nav_list_generator') }}</a>
|
||||
<a href="{{ route('admin.support.index') }}" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 {{ request()->routeIs('admin.support.*') ? 'bg-gray-600 font-semibold' : '' }}">{{ __('admin.nav_support') }}</a>
|
||||
@if (auth()->user()->canViewActivityLog())
|
||||
<a href="{{ route('admin.activity-logs.index') }}" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 {{ request()->routeIs('admin.activity-logs.*') ? 'bg-gray-600 font-semibold' : '' }}">{{ __('admin.nav_activity_log') }}</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mobile/Tablet menu button --}}
|
||||
<div class="flex items-center lg:hidden">
|
||||
<button @click="open = !open" class="text-gray-300 hover:text-white">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path x-show="open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:flex items-center space-x-4 rtl:space-x-reverse">
|
||||
<a href="{{ route('dashboard') }}" class="flex items-center gap-2 text-sm text-gray-300 hover:text-white">
|
||||
@if (auth()->user()->getAvatarUrl())
|
||||
<img src="{{ auth()->user()->getAvatarUrl() }}" alt="{{ auth()->user()->name }}" class="w-7 h-7 rounded-full object-cover border border-gray-600">
|
||||
@else
|
||||
<div class="w-7 h-7 rounded-full bg-gray-600 flex items-center justify-center text-gray-200 text-xs font-semibold">
|
||||
{{ auth()->user()->getInitials() }}
|
||||
</div>
|
||||
@endif
|
||||
{{ __('ui.back_to_app') }}
|
||||
</a>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-sm text-gray-400 hover:text-white">{{ __('ui.logout') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mobile/Tablet menu --}}
|
||||
<div x-show="open" x-cloak class="lg:hidden border-t border-gray-700 px-4 py-2 space-y-1">
|
||||
<a href="{{ route('admin.events.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_events') }}</a>
|
||||
@if (\App\Models\Setting::isFeatureVisibleFor('statistics', auth()->user()))
|
||||
<a href="{{ route('admin.statistics.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_statistics') }}</a>
|
||||
@endif
|
||||
@if (auth()->user()->isStaff())
|
||||
<a href="{{ route('admin.teams.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_teams') }}</a>
|
||||
<a href="{{ route('admin.players.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_players') }}</a>
|
||||
<a href="{{ route('admin.users.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_users') }}</a>
|
||||
@endif
|
||||
<a href="{{ route('admin.files.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_files') }}</a>
|
||||
@if (auth()->user()->isStaff())
|
||||
<a href="{{ route('admin.locations.index') }}" class="block py-2 text-sm text-gray-300">{{ __('admin.nav_locations') }}</a>
|
||||
<div class="border-t border-gray-700 mt-1 pt-1">
|
||||
<span class="block py-1 text-xs text-gray-500 uppercase tracking-wider">{{ __('admin.nav_verwaltung') }}</span>
|
||||
<a href="{{ route('admin.settings.edit') }}" class="block py-2 text-sm text-gray-300 pl-3 rtl:pr-3 rtl:pl-0">{{ __('admin.nav_settings') }}</a>
|
||||
<a href="{{ route('admin.invitations.index') }}" class="block py-2 text-sm text-gray-300 pl-3 rtl:pr-3 rtl:pl-0">{{ __('admin.nav_invitations') }}</a>
|
||||
<a href="{{ route('admin.list-generator.create') }}" class="block py-2 text-sm text-gray-300 pl-3 rtl:pr-3 rtl:pl-0">{{ __('admin.nav_list_generator') }}</a>
|
||||
<a href="{{ route('admin.support.index') }}" class="block py-2 text-sm text-gray-300 pl-3 rtl:pr-3 rtl:pl-0">{{ __('admin.nav_support') }}</a>
|
||||
@if (auth()->user()->canViewActivityLog())
|
||||
<a href="{{ route('admin.activity-logs.index') }}" class="block py-2 text-sm text-gray-300 pl-3 rtl:pr-3 rtl:pl-0">{{ __('admin.nav_activity_log') }}</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<a href="{{ route('dashboard') }}" class="block py-2 text-sm text-gray-300">{{ __('ui.back_to_app') }}</a>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="block py-2 text-sm text-gray-400">{{ __('ui.logout') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-6">
|
||||
@if (session('success'))
|
||||
<x-flash-message type="success" :message="session('success')" />
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<x-flash-message type="error" :message="session('error')" />
|
||||
@endif
|
||||
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-2 text-xs text-gray-400">v{{ config('app.version') }}</footer>
|
||||
|
||||
@stack('scripts')
|
||||
|
||||
{{-- Service Worker --}}
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
||||
.catch((err) => { console.error('SW:', err); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
190
resources/views/components/layouts/app.blade.php
Executable file
190
resources/views/components/layouts/app.blade.php
Executable file
@@ -0,0 +1,190 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? \App\Models\Setting::get('app_name', config('app.name')) }}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js" integrity="sha384-9Ax3MmS9AClxJyd5/zafcXXjxmwFhZCdsT6HJoJjarvCaAkJlk5QDzjLJm+Wdx5F" crossorigin="anonymous"></script>
|
||||
@php $favicon = \App\Models\Setting::get('app_favicon'); @endphp
|
||||
@if ($favicon)
|
||||
<link rel="icon" href="{{ asset('storage/' . $favicon) }}">
|
||||
@else
|
||||
<link rel="icon" href="{{ asset('favicon.ico') }}">
|
||||
@endif
|
||||
{{-- PWA --}}
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#1f2937">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="SG Wölfe">
|
||||
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png">
|
||||
@stack('styles')
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-100 flex flex-col">
|
||||
{{-- Navigation --}}
|
||||
<nav class="bg-white shadow" x-data="{ open: false }">
|
||||
<div class="max-w-5xl mx-auto px-4">
|
||||
<div class="flex justify-between h-14">
|
||||
<div class="flex items-center space-x-6 rtl:space-x-reverse">
|
||||
<a href="{{ route('dashboard') }}" class="flex items-center gap-2 font-bold text-gray-900">
|
||||
<img src="/images/logo_woelfe.png" alt="Logo" class="h-8 w-8 object-contain">
|
||||
{{ \App\Models\Setting::get('app_name', config('app.name')) }}
|
||||
</a>
|
||||
<div class="hidden sm:flex items-center space-x-6 rtl:space-x-reverse">
|
||||
<a href="{{ route('dashboard') }}" class="text-sm text-gray-600 hover:text-gray-900 {{ request()->routeIs('dashboard') ? 'font-semibold text-gray-900' : '' }}">{{ __('ui.dashboard') }}</a>
|
||||
<a href="{{ route('events.index') }}" class="text-sm text-gray-600 hover:text-gray-900 {{ request()->routeIs('events.*') ? 'font-semibold text-gray-900' : '' }}">{{ __('ui.events') }}</a>
|
||||
<a href="{{ route('files.index') }}" class="text-sm text-gray-600 hover:text-gray-900 {{ request()->routeIs('files.*') ? 'font-semibold text-gray-900' : '' }}">{{ __('ui.files') }}</a>
|
||||
@if (auth()->user()->canAccessAdminPanel())
|
||||
@php $pendingDsgvoCount = \App\Models\User::where('role', 'user')->whereNotNull('dsgvo_consent_file')->whereNull('dsgvo_accepted_at')->count(); @endphp
|
||||
<a href="{{ route('admin.dashboard') }}" class="text-sm text-red-600 hover:text-red-800 {{ request()->routeIs('admin.*') ? 'font-semibold' : '' }}">
|
||||
{{ __('ui.admin') }}
|
||||
@if ($pendingDsgvoCount > 0)
|
||||
<span class="bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5 ml-1">{{ $pendingDsgvoCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mobile menu button --}}
|
||||
<div class="flex items-center sm:hidden">
|
||||
<button @click="open = !open" class="text-gray-600 hover:text-gray-900">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<path x-show="open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Desktop right side --}}
|
||||
<div class="hidden sm:flex items-center space-x-4 rtl:space-x-reverse">
|
||||
<a href="{{ route('profile.edit') }}" class="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900">
|
||||
@if (auth()->user()->getAvatarUrl())
|
||||
<img src="{{ auth()->user()->getAvatarUrl() }}" alt="{{ auth()->user()->name }}" class="w-7 h-7 rounded-full object-cover">
|
||||
@else
|
||||
<div class="w-7 h-7 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 text-xs font-semibold">
|
||||
{{ auth()->user()->getInitials() }}
|
||||
</div>
|
||||
@endif
|
||||
{{ auth()->user()->name }}
|
||||
</a>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-sm text-gray-500 hover:text-gray-700">{{ __('ui.logout') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Mobile menu --}}
|
||||
<div x-show="open" x-cloak class="sm:hidden border-t border-gray-200 px-4 py-2 space-y-1">
|
||||
<a href="{{ route('dashboard') }}" class="block py-2 text-sm text-gray-700">{{ __('ui.dashboard') }}</a>
|
||||
<a href="{{ route('events.index') }}" class="block py-2 text-sm text-gray-700">{{ __('ui.events') }}</a>
|
||||
<a href="{{ route('files.index') }}" class="block py-2 text-sm text-gray-700">{{ __('ui.files') }}</a>
|
||||
@if (auth()->user()->canAccessAdminPanel())
|
||||
<a href="{{ route('admin.dashboard') }}" class="block py-2 text-sm text-red-600">
|
||||
{{ __('ui.admin') }}
|
||||
@if ($pendingDsgvoCount > 0)
|
||||
<span class="bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5 ml-1">{{ $pendingDsgvoCount }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ route('profile.edit') }}" class="flex items-center gap-2 py-2 text-sm text-gray-700">
|
||||
@if (auth()->user()->getAvatarUrl())
|
||||
<img src="{{ auth()->user()->getAvatarUrl() }}" alt="{{ auth()->user()->name }}" class="w-6 h-6 rounded-full object-cover">
|
||||
@else
|
||||
<div class="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 text-xs font-semibold">
|
||||
{{ auth()->user()->getInitials() }}
|
||||
</div>
|
||||
@endif
|
||||
{{ __('ui.profile') }}
|
||||
</a>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="block py-2 text-sm text-gray-500">{{ __('ui.logout') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{{-- DSGVO-Hinweis-Banner (2 Zustände) --}}
|
||||
@php $dsgvoBannerState = auth()->user()->dsgvoBannerState(); @endphp
|
||||
@if ($dsgvoBannerState === 'upload_required')
|
||||
<div class="bg-yellow-50 border-b-2 border-yellow-400">
|
||||
<div class="max-w-5xl mx-auto px-4 py-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<svg class="w-6 h-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-yellow-800">{{ __('ui.dsgvo_banner_title') }}</h3>
|
||||
<p class="text-sm text-yellow-700 mt-1">{{ __('ui.dsgvo_banner_text') }}</p>
|
||||
<a href="{{ route('profile.edit') }}#dsgvo-consent"
|
||||
class="inline-flex items-center gap-1.5 mt-3 bg-yellow-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-yellow-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||||
</svg>
|
||||
{{ __('ui.dsgvo_banner_action') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($dsgvoBannerState === 'pending_confirmation')
|
||||
<div class="bg-blue-50 border-b-2 border-blue-400">
|
||||
<div class="max-w-5xl mx-auto px-4 py-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-blue-800">{{ __('ui.dsgvo_banner_pending_title') }}</h3>
|
||||
<p class="text-sm text-blue-700 mt-1">{{ __('ui.dsgvo_banner_pending_text') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<main class="flex-1 max-w-5xl mx-auto w-full px-4 py-6">
|
||||
@if (session('success'))
|
||||
<x-flash-message type="success" :message="session('success')" />
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<x-flash-message type="error" :message="session('error')" />
|
||||
@endif
|
||||
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-4 text-sm text-gray-500 border-t border-gray-200">
|
||||
@php $slogan = \App\Models\Setting::get('app_slogan'); @endphp
|
||||
@if ($slogan)
|
||||
<div class="mb-2 text-xs text-gray-400">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($slogan) !!}</div>
|
||||
@endif
|
||||
<a href="/impressum" class="hover:underline">{{ __('ui.footer_impressum') }}</a>
|
||||
<span class="mx-2">|</span>
|
||||
<a href="/datenschutz" class="hover:underline">{{ __('ui.footer_privacy') }}</a>
|
||||
</footer>
|
||||
|
||||
@include('components.pwa-install-banner')
|
||||
|
||||
@stack('scripts')
|
||||
|
||||
{{-- Service Worker --}}
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
||||
.catch((err) => { console.error('SW:', err); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
85
resources/views/components/layouts/guest.blade.php
Executable file
85
resources/views/components/layouts/guest.blade.php
Executable file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>{{ $title ?? \App\Models\Setting::get('app_name', config('app.name')) }}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js" integrity="sha384-9Ax3MmS9AClxJyd5/zafcXXjxmwFhZCdsT6HJoJjarvCaAkJlk5QDzjLJm+Wdx5F" crossorigin="anonymous"></script>
|
||||
@php $favicon = \App\Models\Setting::get('app_favicon'); @endphp
|
||||
@if ($favicon)
|
||||
<link rel="icon" href="{{ asset('storage/' . $favicon) }}">
|
||||
@else
|
||||
<link rel="icon" href="{{ asset('favicon.ico') }}">
|
||||
@endif
|
||||
{{-- PWA --}}
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#1f2937">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="SG Wölfe">
|
||||
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png">
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-100 flex flex-col">
|
||||
<main class="flex-1 flex items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<a href="{{ auth()->check() ? route('dashboard') : route('login') }}">
|
||||
<img src="/images/logo_sg_woelfe.png" alt="Logo" class="mx-auto h-24 mb-3">
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ \App\Models\Setting::get('app_name', config('app.name')) }}</h1>
|
||||
@php $slogan = \App\Models\Setting::get('app_slogan'); @endphp
|
||||
@if ($slogan)
|
||||
<div class="text-sm text-gray-500 mt-1">{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($slogan) !!}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (session('error'))
|
||||
<x-flash-message type="error" :message="session('error')" />
|
||||
@endif
|
||||
|
||||
@if (session('success'))
|
||||
<x-flash-message type="success" :message="session('success')" />
|
||||
@endif
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-4 text-sm text-gray-500">
|
||||
<div class="mb-2">
|
||||
<a href="/impressum" class="hover:underline">{{ __('ui.footer_impressum') }}</a>
|
||||
<span class="mx-2">|</span>
|
||||
<a href="/datenschutz" class="hover:underline">{{ __('ui.footer_privacy') }}</a>
|
||||
</div>
|
||||
@php
|
||||
$flags = ['de' => "\u{1F1E9}\u{1F1EA}", 'en' => "\u{1F1EC}\u{1F1E7}", 'pl' => "\u{1F1F5}\u{1F1F1}", 'ru' => "\u{1F1F7}\u{1F1FA}", 'ar' => "\u{1F1F8}\u{1F1E6}", 'tr' => "\u{1F1F9}\u{1F1F7}"];
|
||||
@endphp
|
||||
<div class="flex justify-center gap-2">
|
||||
@foreach ($flags as $code => $flag)
|
||||
<form method="POST" action="{{ route('locale.switch') }}" class="inline">
|
||||
@csrf
|
||||
<input type="hidden" name="locale" value="{{ $code }}">
|
||||
<button type="submit"
|
||||
class="text-xl leading-none transition-all {{ $code === app()->getLocale() ? 'scale-110' : 'opacity-50 hover:opacity-80' }}"
|
||||
title="{{ __('ui.locales.' . $code) }}">
|
||||
{{ $flag }}
|
||||
</button>
|
||||
</form>
|
||||
@endforeach
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{{-- Service Worker --}}
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
||||
.catch((err) => { console.error('SW:', err); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
69
resources/views/components/layouts/installer.blade.php
Normal file
69
resources/views/components/layouts/installer.blade.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Installation — Handball WebApp</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js" integrity="sha384-9Ax3MmS9AClxJyd5/zafcXXjxmwFhZCdsT6HJoJjarvCaAkJlk5QDzjLJm+Wdx5F" crossorigin="anonymous"></script>
|
||||
<link rel="icon" href="{{ asset('favicon.ico') }}">
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-100 flex flex-col">
|
||||
<main class="flex-1 flex items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-2xl">
|
||||
{{-- Logo --}}
|
||||
<div class="text-center mb-6">
|
||||
<img src="/images/logo_sg_woelfe.png" alt="Logo" class="mx-auto h-20 mb-3">
|
||||
<h1 class="text-xl font-bold text-gray-900">Installation</h1>
|
||||
</div>
|
||||
|
||||
{{-- Progress indicator --}}
|
||||
<div class="flex justify-center items-center mb-8">
|
||||
@php
|
||||
$stepLabels = ['Systemcheck', 'Datenbank', 'Einstellungen', 'E-Mail', 'Abschluss'];
|
||||
@endphp
|
||||
@for ($i = 1; $i <= 5; $i++)
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-9 h-9 rounded-full flex items-center justify-center text-sm font-semibold
|
||||
{{ $i < $currentStep ? 'bg-green-500 text-white' : '' }}
|
||||
{{ $i === $currentStep ? 'bg-blue-600 text-white ring-2 ring-blue-300' : '' }}
|
||||
{{ $i > $currentStep ? 'bg-gray-200 text-gray-400' : '' }}">
|
||||
@if ($i < $currentStep)
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
@else
|
||||
{{ $i }}
|
||||
@endif
|
||||
</div>
|
||||
<span class="text-xs mt-1 {{ $i === $currentStep ? 'text-blue-600 font-medium' : 'text-gray-400' }}">{{ $stepLabels[$i - 1] }}</span>
|
||||
</div>
|
||||
@if ($i < 5)
|
||||
<div class="w-12 sm:w-20 h-0.5 mx-1 mb-5 {{ $i < $currentStep ? 'bg-green-500' : 'bg-gray-200' }}"></div>
|
||||
@endif
|
||||
</div>
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
{{-- Flash messages --}}
|
||||
@if (session('error'))
|
||||
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
@if (session('success'))
|
||||
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-md text-sm text-green-700">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Step content --}}
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="text-center py-3 text-xs text-gray-400">
|
||||
Handball WebApp — Installation
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
129
resources/views/components/pwa-install-banner.blade.php
Normal file
129
resources/views/components/pwa-install-banner.blade.php
Normal file
@@ -0,0 +1,129 @@
|
||||
{{-- PWA Install Banner (Android + iOS) --}}
|
||||
<div id="pwa-install-banner" style="display: none;">
|
||||
<style>
|
||||
#pwa-install-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.15);
|
||||
}
|
||||
#pwa-install-banner .pwa-text {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
#pwa-install-banner .pwa-text strong {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
#pwa-install-banner .pwa-ios-steps {
|
||||
font-size: 0.82rem;
|
||||
margin-top: 0.3rem;
|
||||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
#pwa-install-banner .pwa-ios-steps .pwa-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
#pwa-install-banner button {
|
||||
border: none;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#pwa-install-banner .pwa-install-btn {
|
||||
background: white;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
#pwa-install-banner .pwa-dismiss-btn {
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,0.8);
|
||||
padding: 0.6rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="pwa-text">
|
||||
<strong id="pwa-title">{{ __('ui.pwa_install_title') }}</strong>
|
||||
<span id="pwa-desc">{{ __('ui.pwa_install_text') }}</span>
|
||||
<div id="pwa-ios-steps" class="pwa-ios-steps" style="display: none;">
|
||||
{!! app(\App\Services\HtmlSanitizerService::class)->sanitize(__('ui.pwa_ios_steps')) !!}
|
||||
</div>
|
||||
</div>
|
||||
<button class="pwa-install-btn" id="pwa-install-btn">{{ __('ui.pwa_install_btn') }}</button>
|
||||
<button class="pwa-dismiss-btn" id="pwa-dismiss-btn">×</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
let deferredPrompt;
|
||||
const banner = document.getElementById('pwa-install-banner');
|
||||
const installBtn = document.getElementById('pwa-install-btn');
|
||||
const dismissBtn = document.getElementById('pwa-dismiss-btn');
|
||||
const iosSteps = document.getElementById('pwa-ios-steps');
|
||||
|
||||
const dismissed = localStorage.getItem('pwa-install-dismissed');
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone === true;
|
||||
|
||||
// Reset nach 30 Tagen
|
||||
if (dismissed && (Date.now() - parseInt(dismissed)) > 30 * 24 * 60 * 60 * 1000) {
|
||||
localStorage.removeItem('pwa-install-dismissed');
|
||||
}
|
||||
|
||||
// Bereits installiert → nichts anzeigen
|
||||
if (isStandalone) return;
|
||||
|
||||
// iOS-Erkennung (iPhone, iPad, iPod)
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
if (isIOS) {
|
||||
// iOS: Manuellen Hinweis zeigen (kein beforeinstallprompt)
|
||||
if (!dismissed) {
|
||||
installBtn.style.display = 'none';
|
||||
iosSteps.style.display = 'block';
|
||||
banner.style.display = 'flex';
|
||||
}
|
||||
} else {
|
||||
// Android/Chrome: beforeinstallprompt abfangen
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
|
||||
if (!dismissed) {
|
||||
banner.style.display = 'flex';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
installBtn.addEventListener('click', async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
deferredPrompt.prompt();
|
||||
await deferredPrompt.userChoice;
|
||||
|
||||
deferredPrompt = null;
|
||||
banner.style.display = 'none';
|
||||
});
|
||||
|
||||
dismissBtn.addEventListener('click', () => {
|
||||
banner.style.display = 'none';
|
||||
localStorage.setItem('pwa-install-dismissed', Date.now());
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
15
resources/views/components/status-chip.blade.php
Executable file
15
resources/views/components/status-chip.blade.php
Executable file
@@ -0,0 +1,15 @@
|
||||
@props(['status'])
|
||||
|
||||
@php
|
||||
$colors = match($status->value ?? $status) {
|
||||
'yes' => 'bg-green-100 text-green-800',
|
||||
'no' => 'bg-red-100 text-red-800',
|
||||
'unknown' => 'bg-gray-100 text-gray-600',
|
||||
default => 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
$label = method_exists($status, 'label') ? $status->label() : $status;
|
||||
@endphp
|
||||
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $colors }}">
|
||||
{{ $label }}
|
||||
</span>
|
||||
13
resources/views/components/traffic-light.blade.php
Executable file
13
resources/views/components/traffic-light.blade.php
Executable file
@@ -0,0 +1,13 @@
|
||||
@props(['event'])
|
||||
|
||||
@php
|
||||
$yes = $event->participants->where('status', \App\Enums\ParticipantStatus::Yes)->count();
|
||||
$no = $event->participants->where('status', \App\Enums\ParticipantStatus::No)->count();
|
||||
$open = $event->participants->where('status', \App\Enums\ParticipantStatus::Unknown)->count();
|
||||
@endphp
|
||||
|
||||
<span class="inline-flex items-center gap-1 text-xs font-semibold tabular-nums" title="{{ __('events.confirmations') }}: {{ $yes }} / {{ __('events.rejections') }}: {{ $no }} / {{ __('events.open_responses') }}: {{ $open }}">
|
||||
<span class="text-green-600">{{ $yes }}</span>
|
||||
<span class="text-red-500">{{ $no }}</span>
|
||||
<span class="text-gray-400">{{ $open }}</span>
|
||||
</span>
|
||||
316
resources/views/dashboard.blade.php
Executable file
316
resources/views/dashboard.blade.php
Executable file
@@ -0,0 +1,316 @@
|
||||
<x-layouts.app :title="__('ui.dashboard')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('events.hello_user', ['name' => auth()->user()->name]) }}</h1>
|
||||
|
||||
{{-- Kalender --}}
|
||||
<div x-data="calendarApp()" class="mb-8">
|
||||
{{-- Header: View-Toggle + Navigation --}}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<h2 class="text-lg font-semibold">{{ __('events.calendar') }}</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="goToToday()" class="px-3 py-1.5 text-xs border border-gray-300 rounded-md hover:bg-gray-50">{{ __('events.today') }}</button>
|
||||
<div class="flex bg-gray-100 rounded-md p-0.5">
|
||||
<button @click="view = 'month'" :class="view === 'month' ? 'bg-white shadow text-gray-900' : 'text-gray-500 hover:text-gray-700'" class="px-3 py-1.5 text-xs font-medium rounded-md transition">{{ __('events.month_view') }}</button>
|
||||
<button @click="view = 'year'" :class="view === 'year' ? 'bg-white shadow text-gray-900' : 'text-gray-500 hover:text-gray-700'" class="px-3 py-1.5 text-xs font-medium rounded-md transition">{{ __('events.year_view') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ===== MONATSANSICHT ===== --}}
|
||||
<template x-if="view === 'month'">
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
{{-- Monats-Navigation --}}
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b">
|
||||
<button @click="prevMonth()" class="p-1 hover:bg-gray-100 rounded-md">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<h3 class="text-base font-semibold text-gray-900" x-text="monthName + ' ' + currentYear"></h3>
|
||||
<button @click="nextMonth()" class="p-1 hover:bg-gray-100 rounded-md">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Wochentage --}}
|
||||
<div class="grid grid-cols-7 border-b">
|
||||
<template x-for="day in weekDayNames" :key="day">
|
||||
<div class="px-1 py-2 text-center text-xs font-medium text-gray-500 uppercase" x-text="day"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Tage --}}
|
||||
<div class="grid grid-cols-7">
|
||||
<template x-for="(day, index) in calendarDays" :key="index">
|
||||
<div
|
||||
:class="{
|
||||
'bg-gray-50 text-gray-400': !day.currentMonth,
|
||||
'bg-blue-50': day.isToday,
|
||||
'min-h-[80px] sm:min-h-[100px]': true
|
||||
}"
|
||||
class="border-b border-r p-1 text-xs"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-0.5">
|
||||
<span
|
||||
:class="day.isToday ? 'bg-blue-600 text-white w-6 h-6 flex items-center justify-center rounded-full text-xs font-bold' : 'text-gray-700 px-1'"
|
||||
x-text="day.dayNum"
|
||||
></span>
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
<template x-for="evt in eventsForDate(day.dateStr)" :key="evt.id">
|
||||
<a :href="evt.url" class="flex items-center gap-0.5 truncate rounded px-1 py-0.5 text-[10px] leading-tight font-medium hover:opacity-80" :class="eventBgClass(evt.type)">
|
||||
<span x-text="evt.time + ' ' + evt.typeLabel"></span>
|
||||
<span class="ml-auto flex gap-px shrink-0 tabular-nums">
|
||||
<span class="text-green-700" x-text="evt.tl.y"></span>
|
||||
<span class="text-red-700" x-text="evt.tl.n"></span>
|
||||
<span class="opacity-50" x-text="evt.tl.o"></span>
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- ===== JAHRESANSICHT ===== --}}
|
||||
<template x-if="view === 'year'">
|
||||
<div>
|
||||
{{-- Jahres-Navigation --}}
|
||||
<div class="flex items-center justify-center gap-4 mb-4">
|
||||
<button @click="currentYear--" class="p-1 hover:bg-gray-100 rounded-md">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<h3 class="text-base font-semibold text-gray-900" x-text="currentYear"></h3>
|
||||
<button @click="currentYear++" class="p-1 hover:bg-gray-100 rounded-md">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 12 Mini-Monate --}}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<template x-for="m in 12" :key="m">
|
||||
<div class="bg-white rounded-lg shadow p-3 cursor-pointer hover:shadow-md transition-shadow" @click="currentMonth = m - 1; view = 'month'">
|
||||
<h4 class="text-xs font-semibold text-gray-700 mb-2" x-text="getMonthName(m - 1)"></h4>
|
||||
{{-- Mini-Wochentage --}}
|
||||
<div class="grid grid-cols-7 gap-px mb-1">
|
||||
<template x-for="wd in weekDayLetters" :key="wd">
|
||||
<div class="text-center text-[9px] text-gray-400" x-text="wd"></div>
|
||||
</template>
|
||||
</div>
|
||||
{{-- Mini-Tage --}}
|
||||
<div class="grid grid-cols-7 gap-px">
|
||||
<template x-for="(d, i) in miniMonthDays(m - 1)" :key="i">
|
||||
<div class="flex items-center justify-center h-4 w-full">
|
||||
<template x-if="d.dayNum">
|
||||
<span
|
||||
class="w-3.5 h-3.5 flex items-center justify-center rounded-full text-[8px] leading-none"
|
||||
:class="d.isToday ? 'bg-blue-600 text-white font-bold' : (d.eventType ? eventDotClass(d.eventType) : 'text-gray-600')"
|
||||
x-text="d.dayNum"
|
||||
></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function calendarApp() {
|
||||
const today = new Date();
|
||||
return {
|
||||
view: 'month',
|
||||
currentMonth: today.getMonth(),
|
||||
currentYear: today.getFullYear(),
|
||||
events: @js($calendarEvents),
|
||||
locale: document.documentElement.lang || 'de',
|
||||
|
||||
// Index der Events nach Datum für schnellen Zugriff
|
||||
_eventIndex: null,
|
||||
get eventIndex() {
|
||||
if (!this._eventIndex) {
|
||||
this._eventIndex = {};
|
||||
this.events.forEach(e => {
|
||||
if (!this._eventIndex[e.date]) this._eventIndex[e.date] = [];
|
||||
this._eventIndex[e.date].push(e);
|
||||
});
|
||||
}
|
||||
return this._eventIndex;
|
||||
},
|
||||
|
||||
get monthName() {
|
||||
return this.getMonthName(this.currentMonth);
|
||||
},
|
||||
|
||||
getMonthName(month) {
|
||||
return new Date(2024, month).toLocaleDateString(this.locale, { month: 'long' });
|
||||
},
|
||||
|
||||
get weekDayNames() {
|
||||
const days = [];
|
||||
// Montag = 1 als erster Tag
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
const d = new Date(2024, 0, i); // 2024-01-01 ist Montag
|
||||
days.push(d.toLocaleDateString(this.locale, { weekday: 'short' }));
|
||||
}
|
||||
return days;
|
||||
},
|
||||
|
||||
get weekDayLetters() {
|
||||
return this.weekDayNames.map(d => d.charAt(0).toUpperCase());
|
||||
},
|
||||
|
||||
get calendarDays() {
|
||||
const year = this.currentYear;
|
||||
const month = this.currentMonth;
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Wochentag des 1. (0=So, 1=Mo, ..., 6=Sa) → Umrechnung auf Mo=0
|
||||
let startWeekDay = firstDay.getDay() - 1;
|
||||
if (startWeekDay < 0) startWeekDay = 6;
|
||||
|
||||
const days = [];
|
||||
|
||||
// Tage vom Vormonat
|
||||
const prevMonthLast = new Date(year, month, 0);
|
||||
for (let i = startWeekDay - 1; i >= 0; i--) {
|
||||
const dayNum = prevMonthLast.getDate() - i;
|
||||
const d = new Date(year, month - 1, dayNum);
|
||||
days.push({
|
||||
dayNum: dayNum,
|
||||
dateStr: this.formatDate(d),
|
||||
currentMonth: false,
|
||||
isToday: this.isToday(d)
|
||||
});
|
||||
}
|
||||
|
||||
// Tage des aktuellen Monats
|
||||
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||||
const date = new Date(year, month, d);
|
||||
days.push({
|
||||
dayNum: d,
|
||||
dateStr: this.formatDate(date),
|
||||
currentMonth: true,
|
||||
isToday: this.isToday(date)
|
||||
});
|
||||
}
|
||||
|
||||
// Tage des nächsten Monats (Rest auffüllen bis Vielfaches von 7)
|
||||
const remaining = 7 - (days.length % 7);
|
||||
if (remaining < 7) {
|
||||
for (let d = 1; d <= remaining; d++) {
|
||||
const date = new Date(year, month + 1, d);
|
||||
days.push({
|
||||
dayNum: d,
|
||||
dateStr: this.formatDate(date),
|
||||
currentMonth: false,
|
||||
isToday: this.isToday(date)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return days;
|
||||
},
|
||||
|
||||
miniMonthDays(month) {
|
||||
const year = this.currentYear;
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
let startWeekDay = firstDay.getDay() - 1;
|
||||
if (startWeekDay < 0) startWeekDay = 6;
|
||||
|
||||
const days = [];
|
||||
|
||||
// Leere Zellen vor dem 1.
|
||||
for (let i = 0; i < startWeekDay; i++) {
|
||||
days.push({ dayNum: 0, eventType: null, isToday: false });
|
||||
}
|
||||
|
||||
// Tage
|
||||
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||||
const date = new Date(year, month, d);
|
||||
const dateStr = this.formatDate(date);
|
||||
const dayEvents = this.eventIndex[dateStr];
|
||||
days.push({
|
||||
dayNum: d,
|
||||
eventType: dayEvents ? dayEvents[0].type : null,
|
||||
isToday: this.isToday(date)
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
},
|
||||
|
||||
eventsForDate(dateStr) {
|
||||
return this.eventIndex[dateStr] || [];
|
||||
},
|
||||
|
||||
formatDate(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return y + '-' + m + '-' + d;
|
||||
},
|
||||
|
||||
isToday(date) {
|
||||
return date.getFullYear() === today.getFullYear()
|
||||
&& date.getMonth() === today.getMonth()
|
||||
&& date.getDate() === today.getDate();
|
||||
},
|
||||
|
||||
prevMonth() {
|
||||
if (this.currentMonth === 0) {
|
||||
this.currentMonth = 11;
|
||||
this.currentYear--;
|
||||
} else {
|
||||
this.currentMonth--;
|
||||
}
|
||||
},
|
||||
|
||||
nextMonth() {
|
||||
if (this.currentMonth === 11) {
|
||||
this.currentMonth = 0;
|
||||
this.currentYear++;
|
||||
} else {
|
||||
this.currentMonth++;
|
||||
}
|
||||
},
|
||||
|
||||
goToToday() {
|
||||
this.currentMonth = today.getMonth();
|
||||
this.currentYear = today.getFullYear();
|
||||
},
|
||||
|
||||
// CSS-Klassen für Events in der Monatsansicht
|
||||
eventBgClass(type) {
|
||||
const map = {
|
||||
home_game: 'bg-blue-100 text-blue-800',
|
||||
away_game: 'bg-indigo-100 text-indigo-800',
|
||||
training: 'bg-green-100 text-green-800',
|
||||
tournament: 'bg-purple-100 text-purple-800',
|
||||
meeting: 'bg-yellow-100 text-yellow-800',
|
||||
other: 'bg-gray-100 text-gray-800'
|
||||
};
|
||||
return map[type] || 'bg-gray-100 text-gray-800';
|
||||
},
|
||||
|
||||
// CSS-Klassen für Punkte in der Jahresansicht
|
||||
eventDotClass(type) {
|
||||
const map = {
|
||||
home_game: 'bg-blue-500 text-white',
|
||||
away_game: 'bg-indigo-500 text-white',
|
||||
training: 'bg-green-500 text-white',
|
||||
tournament: 'bg-purple-500 text-white',
|
||||
meeting: 'bg-yellow-500 text-white',
|
||||
other: 'bg-gray-400 text-white'
|
||||
};
|
||||
return map[type] || 'bg-gray-400 text-white';
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</x-layouts.app>
|
||||
17
resources/views/errors/403.blade.php
Executable file
17
resources/views/errors/403.blade.php
Executable file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>{{ __('ui.error_403_title') }}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div class="text-center px-6">
|
||||
<h1 class="text-6xl font-bold text-gray-300">403</h1>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mt-4">{{ __('ui.error_403_title') }}</h2>
|
||||
<p class="text-gray-500 mt-2">{{ __('ui.error_403_text') }}</p>
|
||||
<a href="{{ url('/dashboard') }}" class="inline-block mt-6 bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 text-sm">{{ __('ui.back_to_dashboard') }}</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
17
resources/views/errors/404.blade.php
Executable file
17
resources/views/errors/404.blade.php
Executable file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>{{ __('ui.error_404_title') }}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div class="text-center px-6">
|
||||
<h1 class="text-6xl font-bold text-gray-300">404</h1>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mt-4">{{ __('ui.error_404_title') }}</h2>
|
||||
<p class="text-gray-500 mt-2">{{ __('ui.error_404_text') }}</p>
|
||||
<a href="{{ url('/dashboard') }}" class="inline-block mt-6 bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 text-sm">{{ __('ui.back_to_dashboard') }}</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
17
resources/views/errors/500.blade.php
Executable file
17
resources/views/errors/500.blade.php
Executable file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>{{ __('ui.error_500_title') }}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div class="text-center px-6">
|
||||
<h1 class="text-6xl font-bold text-gray-300">500</h1>
|
||||
<h2 class="text-xl font-semibold text-gray-700 mt-4">{{ __('ui.error_500_title') }}</h2>
|
||||
<p class="text-gray-500 mt-2">{{ __('ui.error_500_text') }}</p>
|
||||
<a href="{{ url('/dashboard') }}" class="inline-block mt-6 bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 text-sm">{{ __('ui.back_to_dashboard') }}</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
89
resources/views/events/index.blade.php
Executable file
89
resources/views/events/index.blade.php
Executable file
@@ -0,0 +1,89 @@
|
||||
<x-layouts.app :title="__('events.title')">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ __('events.title') }}</h1>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<form method="GET" action="{{ route('events.index') }}" class="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label for="team_id" class="block text-xs font-medium text-gray-600 mb-1">{{ __('ui.team') }}</label>
|
||||
<select name="team_id" id="team_id" class="w-full rounded-md border-gray-300 text-sm">
|
||||
<option value="">{{ __('ui.all_teams') }}</option>
|
||||
@foreach ($teams as $team)
|
||||
<option value="{{ $team->id }}" {{ request('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="type" class="block text-xs font-medium text-gray-600 mb-1">{{ __('ui.type') }}</label>
|
||||
<select name="type" id="type" class="w-full rounded-md border-gray-300 text-sm">
|
||||
<option value="">{{ __('ui.all_types') }}</option>
|
||||
@foreach (\App\Enums\EventType::cases() as $type)
|
||||
<option value="{{ $type->value }}" {{ request('type') === $type->value ? 'selected' : '' }}>{{ $type->label() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="period" class="block text-xs font-medium text-gray-600 mb-1">{{ __('ui.period') }}</label>
|
||||
<select name="period" id="period" class="w-full rounded-md border-gray-300 text-sm">
|
||||
<option value="upcoming" {{ request('period', 'upcoming') === 'upcoming' ? 'selected' : '' }}>{{ __('ui.upcoming') }}</option>
|
||||
<option value="past" {{ request('period') === 'past' ? 'selected' : '' }}>{{ __('ui.past') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="w-full bg-blue-600 text-white rounded-md px-4 py-2 text-sm hover:bg-blue-700">{{ __('ui.filter') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Event-Liste --}}
|
||||
@if ($events->isEmpty())
|
||||
<div class="bg-white rounded-lg shadow p-6 text-center text-gray-500">
|
||||
{{ __('events.no_events') }}
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($events as $event)
|
||||
@php
|
||||
$minStatus = $event->minimumsStatus();
|
||||
$bgClass = match($minStatus) { true => 'bg-green-50', false => 'bg-red-50', default => 'bg-white' };
|
||||
@endphp
|
||||
<a href="{{ route('events.show', $event) }}" class="block {{ $bgClass }} rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<x-event-type-badge :type="$event->type" />
|
||||
<span class="text-xs text-gray-500">{{ $event->team->name }}</span>
|
||||
@if ($event->status === \App\Enums\EventStatus::Cancelled)
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">{{ __('events.cancelled_label') }}</span>
|
||||
@elseif ($event->status === \App\Enums\EventStatus::Draft)
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">{{ __('events.draft_label') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<h3 class="font-semibold {{ $event->status === \App\Enums\EventStatus::Cancelled ? 'line-through text-gray-400' : 'text-gray-900' }}">
|
||||
{{ $event->title }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ $event->start_at->translatedFormat(__('ui.date_format')) }} {{ __('ui.clock') }}
|
||||
@if ($event->end_at)
|
||||
– {{ $event->end_at->format('H:i') }} {{ __('ui.clock') }}
|
||||
@endif
|
||||
</p>
|
||||
@if ($event->location_name)
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $event->location_name }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<x-event-status-boxes :event="$event" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{{ $events->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</x-layouts.app>
|
||||
476
resources/views/events/show.blade.php
Executable file
476
resources/views/events/show.blade.php
Executable file
@@ -0,0 +1,476 @@
|
||||
<x-layouts.app :title="$event->title">
|
||||
{{-- Cancelled Banner --}}
|
||||
@if ($event->status === \App\Enums\EventStatus::Cancelled)
|
||||
<div class="bg-red-600 text-white text-center py-3 px-4 rounded-lg mb-6 font-semibold">
|
||||
{{ __('events.cancelled_banner') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($event->status === \App\Enums\EventStatus::Draft)
|
||||
<div class="bg-yellow-500 text-white text-center py-3 px-4 rounded-lg mb-6 font-semibold">
|
||||
{{ __('events.draft_banner') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div class="flex items-start justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<x-event-type-badge :type="$event->type" />
|
||||
<span class="text-xs text-gray-500">{{ $event->team->name }}</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold {{ $event->status === \App\Enums\EventStatus::Cancelled ? 'line-through text-gray-400' : 'text-gray-900' }}">
|
||||
{{ $event->title }}
|
||||
</h1>
|
||||
@if ($event->type->isGameType() && $event->opponent)
|
||||
<p class="text-sm text-gray-600 mt-1">
|
||||
{{ __('events.vs') }} {{ $event->opponent }}
|
||||
@if ($event->hasScore())
|
||||
<span class="font-semibold ml-2">{{ $event->scoreDisplay() }}</span>
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@if (auth()->user()->canAccessAdminPanel())
|
||||
<a href="{{ route('admin.events.edit', $event) }}" class="text-sm text-blue-600 hover:underline">{{ __('ui.edit') }}</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2 text-sm text-gray-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
<span>
|
||||
{{ $event->start_at->translatedFormat(__('ui.date_format_long')) }} {{ __('ui.clock') }}
|
||||
@if ($event->end_at)
|
||||
– {{ $event->end_at->format('H:i') }} {{ __('ui.clock') }}
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if ($event->location_name || $event->address_text)
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-gray-400 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
<div>
|
||||
@if ($event->location_name)
|
||||
<span class="font-medium">{{ $event->location_name }}</span><br>
|
||||
@endif
|
||||
@if ($event->address_text)
|
||||
<span class="text-gray-500">{{ $event->address_text }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Navigation Button --}}
|
||||
@if ($event->hasCoordinates())
|
||||
<div class="mt-4">
|
||||
<a href="https://www.openstreetmap.org/directions?engine=graphhopper_car&route=&to={{ $event->location_lat }}%2C{{ $event->location_lng }}"
|
||||
class="hidden sm:inline-flex items-center gap-1 bg-blue-600 text-white text-sm px-4 py-2 rounded-md hover:bg-blue-700"
|
||||
target="_blank" rel="noopener">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||
{{ __('events.plan_route') }}
|
||||
</a>
|
||||
<a href="geo:{{ $event->location_lat }},{{ $event->location_lng }}"
|
||||
class="sm:hidden inline-flex items-center gap-1 bg-blue-600 text-white text-sm px-4 py-2 rounded-md hover:bg-blue-700">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||
{{ __('events.start_navigation') }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Karte --}}
|
||||
@if ($event->hasCoordinates())
|
||||
<div class="bg-white rounded-lg shadow mb-6 overflow-hidden relative z-0">
|
||||
<div id="map" class="h-64 w-full"></div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Beschreibung --}}
|
||||
@if ($event->description_html)
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('events.description') }}</h2>
|
||||
<div class="prose prose-sm max-w-none text-gray-700">
|
||||
{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($event->description_html) !!}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Dateien --}}
|
||||
@if ($event->files->isNotEmpty())
|
||||
<div id="files" class="bg-white rounded-lg shadow p-6 mb-6 overflow-hidden">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('events.files') }}</h2>
|
||||
<div class="grid gap-2" x-data>
|
||||
@foreach ($event->files->sortBy('category.name') as $file)
|
||||
<div @click="$dispatch('open-file-preview', @js($file->previewData()))"
|
||||
class="flex items-center gap-3 border border-gray-100 rounded-md px-3 sm:px-4 py-3 hover:bg-gray-50 cursor-pointer transition-colors min-w-0">
|
||||
@if ($file->isImage())
|
||||
<img src="{{ route('files.preview', $file) }}" alt="" class="flex-shrink-0 w-9 h-9 rounded-lg object-cover bg-gray-100" loading="lazy">
|
||||
@elseif ($file->isPdf())
|
||||
<div class="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center bg-red-100 text-red-600 font-bold text-xs">
|
||||
PDF
|
||||
</div>
|
||||
@else
|
||||
<div class="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center
|
||||
{{ match($file->iconType()) {
|
||||
'word' => 'bg-blue-100 text-blue-600',
|
||||
'excel' => 'bg-green-100 text-green-600',
|
||||
default => 'bg-gray-100 text-gray-600',
|
||||
} }}">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ $file->original_name }}</p>
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-gray-500 mt-0.5">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">{{ $file->category->name }}</span>
|
||||
<span>{{ $file->humanSize() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-file-preview-modal />
|
||||
@endif
|
||||
|
||||
{{-- Teilnehmer --}}
|
||||
<div id="participants" class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('events.participants') }}</h2>
|
||||
|
||||
@php
|
||||
$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="flex gap-4 text-sm mb-4">
|
||||
<span class="text-green-700 font-medium">{{ $yesCount }} {{ __('events.confirmations') }}</span>
|
||||
<span class="text-red-700 font-medium">{{ $noCount }} {{ __('events.rejections') }}</span>
|
||||
<span class="text-gray-500">{{ $openCount }} {{ __('events.open_responses') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-100">
|
||||
@if ($event->type === \App\Enums\EventType::Meeting)
|
||||
{{-- Besprechung: User-basierte Teilnehmer --}}
|
||||
@foreach ($event->participants->sortBy(fn($p) => $p->user->name ?? '') as $participant)
|
||||
<div class="py-2 flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="{{ $participant->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
|
||||
<span class="text-sm font-medium text-gray-900">{{ $participant->user->name ?? '–' }}</span>
|
||||
</div>
|
||||
|
||||
@if (!auth()->user()->isDsgvoRestricted())
|
||||
@if ($participant->user_id === auth()->id() || auth()->user()->canAccessAdminPanel())
|
||||
@if ($event->status !== \App\Enums\EventStatus::Cancelled)
|
||||
<form method="POST" action="{{ route('participants.update', $event) }}" class="flex gap-1">
|
||||
@csrf
|
||||
<input type="hidden" name="user_id" value="{{ $participant->user_id }}">
|
||||
<button type="submit" name="status" value="yes"
|
||||
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::Yes ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100' }}">
|
||||
{{ __('ui.yes') }}
|
||||
</button>
|
||||
<button type="submit" name="status" value="no"
|
||||
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::No ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100' }}">
|
||||
{{ __('ui.no') }}
|
||||
</button>
|
||||
<button type="submit" name="status" value="unknown"
|
||||
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::Unknown ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }}">
|
||||
{{ __('ui.open') }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
@else
|
||||
{{-- Reguläre Events: Spieler-basierte Teilnehmer --}}
|
||||
@foreach ($event->participants->sortBy('player.last_name') as $participant)
|
||||
<div class="py-2 flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="{{ $participant->player->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
|
||||
<span class="text-sm font-medium text-gray-900">{{ $participant->player->full_name }}</span>
|
||||
</div>
|
||||
|
||||
@if (!auth()->user()->isDsgvoRestricted())
|
||||
@if ($userChildIds->contains($participant->player_id) || auth()->user()->canAccessAdminPanel())
|
||||
@if ($event->status !== \App\Enums\EventStatus::Cancelled)
|
||||
<form method="POST" action="{{ route('participants.update', $event) }}" class="flex gap-1">
|
||||
@csrf
|
||||
<input type="hidden" name="player_id" value="{{ $participant->player_id }}">
|
||||
<button type="submit" name="status" value="yes"
|
||||
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::Yes ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100' }}">
|
||||
{{ __('ui.yes') }}
|
||||
</button>
|
||||
<button type="submit" name="status" value="no"
|
||||
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::No ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100' }}">
|
||||
{{ __('ui.no') }}
|
||||
</button>
|
||||
<button type="submit" name="status" value="unknown"
|
||||
class="px-2 py-1 text-xs rounded-md {{ $participant->status === \App\Enums\ParticipantStatus::Unknown ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }}">
|
||||
{{ __('ui.open') }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Catering --}}
|
||||
@if ($event->type->hasCatering())
|
||||
<div id="catering" class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('events.catering') }}</h2>
|
||||
|
||||
@if ($event->status !== \App\Enums\EventStatus::Cancelled && !auth()->user()->isDsgvoRestricted())
|
||||
<form method="POST" action="{{ route('catering.update', $event) }}" class="mb-4">
|
||||
@csrf
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex gap-1">
|
||||
<button type="submit" name="status" value="yes"
|
||||
class="px-3 py-1.5 text-sm rounded-md {{ $myCatering && $myCatering->status === \App\Enums\CateringStatus::Yes ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100' }}">
|
||||
{{ __('events.bring_something') }}
|
||||
</button>
|
||||
<button type="submit" name="status" value="no"
|
||||
class="px-3 py-1.5 text-sm rounded-md {{ $myCatering && $myCatering->status === \App\Enums\CateringStatus::No ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100' }}">
|
||||
{{ __('events.bring_nothing') }}
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" name="note" placeholder="{{ __('events.catering_note_placeholder') }}" value="{{ $myCatering?->note }}"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</form>
|
||||
@elseif (auth()->user()->isDsgvoRestricted())
|
||||
<p class="text-sm text-orange-600 mb-4">{{ __('ui.dsgvo_restricted_hint') }}</p>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$cateringsWithContribution = $event->caterings->where('status', \App\Enums\CateringStatus::Yes);
|
||||
$cateringsWithdrawn = (auth()->user()->canAccessAdminPanel() && \App\Models\Setting::isFeatureVisibleFor('catering_history', auth()->user()))
|
||||
? $event->caterings->where('status', \App\Enums\CateringStatus::No)
|
||||
: collect();
|
||||
@endphp
|
||||
|
||||
@if ($cateringsWithContribution->isNotEmpty() || $cateringsWithdrawn->isNotEmpty())
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach ($cateringsWithContribution as $catering)
|
||||
<div class="py-2 text-sm flex items-center gap-2">
|
||||
<img src="{{ $catering->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
|
||||
<span class="font-medium text-gray-900">{{ $catering->user->name }}</span>
|
||||
@if ($catering->note)
|
||||
<span class="text-gray-600">— {{ $catering->note }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
@foreach ($cateringsWithdrawn as $catering)
|
||||
@php
|
||||
$userHistory = $cateringHistory->filter(
|
||||
fn ($log) => ($log->properties['new']['user_id'] ?? null) == $catering->user_id
|
||||
);
|
||||
@endphp
|
||||
@if ($userHistory->isNotEmpty())
|
||||
{{-- Chronologische Verlaufseinträge --}}
|
||||
@foreach ($userHistory as $log)
|
||||
@php $newStatus = $log->properties['new']['status'] ?? 'unknown'; @endphp
|
||||
<div class="py-2 text-sm flex items-center justify-between gap-2 opacity-40">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="{{ $catering->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
|
||||
<span class="font-medium text-gray-400 {{ $newStatus === 'no' ? 'line-through' : '' }}">{{ $catering->user->name }}</span>
|
||||
<span class="text-xs text-gray-400">({{ $newStatus === 'yes' ? __('events.signed_up') : __('events.withdrawn') }})</span>
|
||||
</div>
|
||||
@if (auth()->user()->isStaff())
|
||||
<span class="text-xs text-gray-400 whitespace-nowrap">{{ $log->created_at->format('d.m.Y, H:i') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
@else
|
||||
{{-- Fallback ohne Verlaufsdaten --}}
|
||||
<div class="py-2 text-sm flex items-center justify-between gap-2 opacity-40">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="{{ $catering->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
|
||||
<span class="font-medium text-gray-400 line-through">{{ $catering->user->name }}</span>
|
||||
<span class="text-xs text-gray-400">({{ __('events.withdrawn') }})</span>
|
||||
</div>
|
||||
@if (auth()->user()->isStaff())
|
||||
<span class="text-xs text-gray-400 whitespace-nowrap">{{ $catering->updated_at->format('d.m.Y, H:i') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">{{ __('events.no_catering_yet') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Zeitnehmer --}}
|
||||
@if ($event->type->hasTimekeepers())
|
||||
<div id="timekeeper" class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('events.timekeeper') }}</h2>
|
||||
|
||||
@if ($event->status !== \App\Enums\EventStatus::Cancelled && !auth()->user()->isDsgvoRestricted())
|
||||
<form method="POST" action="{{ route('timekeeper.update', $event) }}" class="mb-4">
|
||||
@csrf
|
||||
<div class="flex gap-1">
|
||||
<button type="submit" name="status" value="yes"
|
||||
class="px-3 py-1.5 text-sm rounded-md {{ $myTimekeeper && $myTimekeeper->status === \App\Enums\CateringStatus::Yes ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-green-100' }}">
|
||||
{{ __('events.timekeeper_yes') }}
|
||||
</button>
|
||||
<button type="submit" name="status" value="no"
|
||||
class="px-3 py-1.5 text-sm rounded-md {{ $myTimekeeper && $myTimekeeper->status === \App\Enums\CateringStatus::No ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-red-100' }}">
|
||||
{{ __('events.timekeeper_no') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@elseif (auth()->user()->isDsgvoRestricted())
|
||||
<p class="text-sm text-orange-600 mb-4">{{ __('ui.dsgvo_restricted_hint') }}</p>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$timekeepersYes = $event->timekeepers->where('status', \App\Enums\CateringStatus::Yes);
|
||||
$timekeepersWithdrawn = (auth()->user()->canAccessAdminPanel() && \App\Models\Setting::isFeatureVisibleFor('catering_history', auth()->user()))
|
||||
? $event->timekeepers->where('status', \App\Enums\CateringStatus::No)
|
||||
: collect();
|
||||
@endphp
|
||||
|
||||
@if ($timekeepersYes->isNotEmpty() || $timekeepersWithdrawn->isNotEmpty())
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach ($timekeepersYes as $tk)
|
||||
<div class="py-2 text-sm flex items-center gap-2">
|
||||
<img src="{{ $tk->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
|
||||
<span class="font-medium text-gray-900">{{ $tk->user->name }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
@foreach ($timekeepersWithdrawn as $tk)
|
||||
@php
|
||||
$tkHistory = $timekeeperHistory->filter(
|
||||
fn ($log) => ($log->properties['new']['user_id'] ?? null) == $tk->user_id
|
||||
);
|
||||
@endphp
|
||||
@if ($tkHistory->isNotEmpty())
|
||||
@foreach ($tkHistory as $log)
|
||||
@php $newStatus = $log->properties['new']['status'] ?? 'unknown'; @endphp
|
||||
<div class="py-2 text-sm flex items-center justify-between gap-2 opacity-40">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="{{ $tk->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
|
||||
<span class="font-medium text-gray-400 {{ $newStatus === 'no' ? 'line-through' : '' }}">{{ $tk->user->name }}</span>
|
||||
<span class="text-xs text-gray-400">({{ $newStatus === 'yes' ? __('events.signed_up') : __('events.withdrawn') }})</span>
|
||||
</div>
|
||||
@if (auth()->user()->isStaff())
|
||||
<span class="text-xs text-gray-400 whitespace-nowrap">{{ $log->created_at->format('d.m.Y, H:i') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
@else
|
||||
<div class="py-2 text-sm flex items-center justify-between gap-2 opacity-40">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="{{ $tk->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0">
|
||||
<span class="font-medium text-gray-400 line-through">{{ $tk->user->name }}</span>
|
||||
<span class="text-xs text-gray-400">({{ __('events.withdrawn') }})</span>
|
||||
</div>
|
||||
@if (auth()->user()->isStaff())
|
||||
<span class="text-xs text-gray-400 whitespace-nowrap">{{ $tk->updated_at->format('d.m.Y, H:i') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">{{ __('events.no_timekeeper_yet') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Kommentare --}}
|
||||
<div id="comments" class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('events.comments') }}</h2>
|
||||
|
||||
@if ($event->status !== \App\Enums\EventStatus::Cancelled && !auth()->user()->isDsgvoRestricted())
|
||||
<form method="POST" action="{{ route('comments.store', $event) }}" class="mb-4">
|
||||
@csrf
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="body" placeholder="{{ __('events.comment_placeholder') }}" required
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" maxlength="1000">
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md text-sm hover:bg-blue-700">{{ __('ui.send') }}</button>
|
||||
</div>
|
||||
@error('body')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</form>
|
||||
@elseif (auth()->user()->isDsgvoRestricted())
|
||||
<p class="text-sm text-orange-600 mb-4">{{ __('ui.dsgvo_restricted_hint') }}</p>
|
||||
@endif
|
||||
|
||||
@if ($event->comments->isEmpty())
|
||||
<p class="text-sm text-gray-500">{{ __('events.no_comments') }}</p>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($event->comments->sortByDesc('created_at') as $comment)
|
||||
@if ($comment->isDeleted() && !auth()->user()->isStaff())
|
||||
@continue
|
||||
@endif
|
||||
<div class="flex justify-between items-start gap-2 {{ $comment->isDeleted() ? 'opacity-50' : '' }}">
|
||||
<div class="flex gap-2">
|
||||
<img src="{{ $comment->user->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-6 h-6 rounded-full object-cover flex-shrink-0 mt-0.5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-900">{{ $comment->user->name }}</span>
|
||||
<span class="text-xs text-gray-400">{{ $comment->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
@if ($comment->isDeleted())
|
||||
<p class="text-sm text-gray-400 italic">{{ $comment->body }} <span class="text-xs">({{ __('events.deleted_label') }})</span></p>
|
||||
@else
|
||||
<p class="text-sm text-gray-700 mt-0.5">{{ $comment->body }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@if (!$comment->isDeleted() && auth()->user()->isStaff())
|
||||
<form method="POST" action="{{ route('admin.comments.destroy', $comment) }}" onsubmit="return confirm(@js(__('events.confirm_delete_comment')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700">{{ __('ui.delete') }}</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<a href="{{ route('events.index') }}" class="text-sm text-blue-600 hover:underline">← {{ __('events.back_to_list') }}</a>
|
||||
</div>
|
||||
|
||||
{{-- Leaflet.js Karte --}}
|
||||
@if ($event->hasCoordinates())
|
||||
@push('styles')
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
@endpush
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const map = L.map('map').setView([@js($event->location_lat), @js($event->location_lng)], 15);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(map);
|
||||
const popupText = document.createElement('span');
|
||||
popupText.textContent = @json($event->location_name ?? $event->title);
|
||||
L.marker([@js($event->location_lat), @js($event->location_lng)])
|
||||
.addTo(map)
|
||||
.bindPopup(popupText)
|
||||
.openPopup();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@endif
|
||||
</x-layouts.app>
|
||||
71
resources/views/files/index.blade.php
Normal file
71
resources/views/files/index.blade.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<x-layouts.app :title="__('ui.files')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('ui.files') }}</h1>
|
||||
|
||||
{{-- Kategorie-Tabs --}}
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
<a href="{{ route('files.index') }}"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium {{ !$activeCategory ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
|
||||
{{ __('ui.all') }} ({{ $categories->sum('files_count') }})
|
||||
</a>
|
||||
@foreach ($categories as $category)
|
||||
<a href="{{ route('files.index', ['category' => $category->slug]) }}"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium {{ $activeCategory === $category->slug ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
|
||||
{{ $category->name }} ({{ $category->files_count }})
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($files->isEmpty())
|
||||
<div class="bg-white rounded-lg shadow p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m6.75 12H9.75m3 0h3m-1.5-3H12m-3 3V18m0-3h.008v.008H9v-.008zm0 3h.008v.008H9V18z" />
|
||||
</svg>
|
||||
<p class="mt-3 text-sm text-gray-500">{{ $activeCategory ? __('admin.no_files_yet') : __('admin.no_files_at_all') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid gap-3" x-data>
|
||||
@foreach ($files as $file)
|
||||
<div @click="$dispatch('open-file-preview', @js($file->previewData()))"
|
||||
class="bg-white rounded-lg shadow px-4 sm:px-5 py-4 flex items-center gap-3 sm:gap-4 hover:bg-gray-50 cursor-pointer transition-colors min-w-0">
|
||||
{{-- Thumbnail oder Icon --}}
|
||||
@if ($file->isImage())
|
||||
<img src="{{ route('files.preview', $file) }}" alt="" class="flex-shrink-0 w-10 h-10 rounded-lg object-cover bg-gray-100" loading="lazy">
|
||||
@else
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center
|
||||
{{ match($file->iconType()) {
|
||||
'pdf' => 'bg-red-100 text-red-600',
|
||||
'word' => 'bg-blue-100 text-blue-600',
|
||||
'excel' => 'bg-green-100 text-green-600',
|
||||
default => 'bg-gray-100 text-gray-600',
|
||||
} }}">
|
||||
@if ($file->iconType() === 'pdf')
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
|
||||
@elseif ($file->iconType() === 'word')
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM9 13h6v1H9v-1zm0 2h6v1H9v-1zm0 2h4v1H9v-1z"/></svg>
|
||||
@elseif ($file->iconType() === 'excel')
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM9 13h2v2H9v-2zm0 3h2v2H9v-2zm3-3h2v2h-2v-2zm0 3h2v2h-2v-2z"/></svg>
|
||||
@else
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4z"/></svg>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ $file->original_name }}</p>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-gray-500 mt-0.5">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">{{ $file->category->name }}</span>
|
||||
<span>{{ $file->humanSize() }}</span>
|
||||
<span class="hidden sm:inline">{{ $file->created_at->translatedFormat(__('ui.date_format_short')) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $files->links() }}</div>
|
||||
@endif
|
||||
|
||||
<x-file-preview-modal />
|
||||
</x-layouts.app>
|
||||
86
resources/views/installer/steps/app.blade.php
Normal file
86
resources/views/installer/steps/app.blade.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<x-layouts.installer :currentStep="3">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">App-Einstellungen</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Gib den Namen deines Vereins und die Administrator-Zugangsdaten ein.</p>
|
||||
|
||||
<form method="POST" action="{{ route('install.app.store') }}">
|
||||
@csrf
|
||||
|
||||
{{-- App settings --}}
|
||||
<div class="space-y-4 mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Verein</h3>
|
||||
|
||||
<div>
|
||||
<label for="app_name" class="block text-sm font-medium text-gray-700 mb-1">Vereinsname / App-Name *</label>
|
||||
<input type="text" name="app_name" id="app_name" value="{{ old('app_name', session('installer.app_name', '')) }}"
|
||||
required maxlength="100" placeholder="z.B. SG Musterstadt Handball"
|
||||
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('app_name') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="app_slogan" class="block text-sm font-medium text-gray-700 mb-1">Slogan</label>
|
||||
<input type="text" name="app_slogan" id="app_slogan" value="{{ old('app_slogan', session('installer.app_slogan', '')) }}"
|
||||
maxlength="255" placeholder="z.B. Gemeinsam stark — auf und neben dem Spielfeld"
|
||||
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">
|
||||
<p class="mt-1 text-xs text-gray-400">Optional. Wird auf der Login-Seite und im Footer angezeigt.</p>
|
||||
@error('app_slogan') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="app_url" class="block text-sm font-medium text-gray-700 mb-1">App-URL *</label>
|
||||
<input type="url" name="app_url" id="app_url" value="{{ old('app_url', session('installer.app_url', request()->getSchemeAndHttpHost())) }}"
|
||||
required placeholder="https://handball.example.com"
|
||||
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">
|
||||
<p class="mt-1 text-xs text-gray-400">Die URL, unter der die App erreichbar ist.</p>
|
||||
@error('app_url') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Admin credentials --}}
|
||||
<div class="space-y-4 border-t border-gray-200 pt-5">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Administrator-Konto</h3>
|
||||
|
||||
<div>
|
||||
<label for="admin_name" class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input type="text" name="admin_name" id="admin_name" value="{{ old('admin_name', session('installer.admin_name', '')) }}"
|
||||
required maxlength="255" placeholder="Vor- und Nachname"
|
||||
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('admin_name') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="admin_email" class="block text-sm font-medium text-gray-700 mb-1">E-Mail *</label>
|
||||
<input type="email" name="admin_email" id="admin_email" value="{{ old('admin_email', session('installer.admin_email', '')) }}"
|
||||
required maxlength="255" placeholder="admin@example.com"
|
||||
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('admin_email') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="admin_password" class="block text-sm font-medium text-gray-700 mb-1">Passwort *</label>
|
||||
<input type="password" name="admin_password" id="admin_password"
|
||||
required minlength="8" placeholder="Mindestens 8 Zeichen"
|
||||
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('admin_password') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="admin_password_confirmation" class="block text-sm font-medium text-gray-700 mb-1">Passwort bestätigen *</label>
|
||||
<input type="password" name="admin_password_confirmation" id="admin_password_confirmation"
|
||||
required minlength="8" placeholder="Passwort wiederholen"
|
||||
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>
|
||||
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<a href="{{ route('install.database') }}"
|
||||
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition">
|
||||
Zurück
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</x-layouts.installer>
|
||||
85
resources/views/installer/steps/database.blade.php
Normal file
85
resources/views/installer/steps/database.blade.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<x-layouts.installer :currentStep="2">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Datenbank einrichten</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Wähle den Datenbanktyp und gib die Verbindungsdaten ein.</p>
|
||||
|
||||
<form method="POST" action="{{ route('install.database.store') }}" x-data="{ driver: @js(old('db_driver', $dbDriver)), submitting: false }" @submit="submitting = true">
|
||||
@csrf
|
||||
|
||||
{{-- Driver selection --}}
|
||||
<div class="mb-5">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Datenbanktyp</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="db_driver" value="sqlite" x-model="driver"
|
||||
class="text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm text-gray-800">SQLite</span>
|
||||
<span class="text-xs text-gray-400">(Empfohlen für Einzelbetrieb)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="db_driver" value="mysql" x-model="driver"
|
||||
class="text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm text-gray-800">MySQL</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SQLite info --}}
|
||||
<div x-show="driver === 'sqlite'" x-cloak class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md text-sm text-blue-700">
|
||||
SQLite speichert alle Daten in einer einzelnen Datei. Ideal für kleine bis mittlere Installationen.
|
||||
Es werden keine weiteren Angaben benötigt.
|
||||
</div>
|
||||
|
||||
{{-- MySQL fields --}}
|
||||
<div x-show="driver === 'mysql'" x-cloak class="space-y-4 mb-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="db_host" class="block text-sm font-medium text-gray-700 mb-1">Host</label>
|
||||
<input type="text" name="db_host" id="db_host" value="{{ old('db_host', '127.0.0.1') }}"
|
||||
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('db_host') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_port" class="block text-sm font-medium text-gray-700 mb-1">Port</label>
|
||||
<input type="number" name="db_port" id="db_port" value="{{ old('db_port', '3306') }}"
|
||||
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('db_port') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_database" class="block text-sm font-medium text-gray-700 mb-1">Datenbankname</label>
|
||||
<input type="text" name="db_database" id="db_database" value="{{ old('db_database') }}"
|
||||
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('db_database') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_username" class="block text-sm font-medium text-gray-700 mb-1">Benutzername</label>
|
||||
<input type="text" name="db_username" id="db_username" value="{{ old('db_username') }}"
|
||||
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('db_username') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="db_password" class="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
|
||||
<input type="password" name="db_password" id="db_password" value="{{ old('db_password') }}"
|
||||
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('db_password') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<a href="{{ route('install.requirements') }}"
|
||||
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition">
|
||||
Zurück
|
||||
</a>
|
||||
<button type="submit" :disabled="submitting"
|
||||
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-wait flex items-center gap-2">
|
||||
<template x-if="submitting">
|
||||
<svg class="animate-spin h-4 w-4 text-white" 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>
|
||||
</template>
|
||||
<span x-text="submitting ? 'Migrationen werden ausgeführt...' : 'Datenbank einrichten'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</x-layouts.installer>
|
||||
154
resources/views/installer/steps/finalize.blade.php
Normal file
154
resources/views/installer/steps/finalize.blade.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<x-layouts.installer :currentStep="5">
|
||||
@if ($installed ?? false)
|
||||
{{-- ══════ SUCCESS PAGE ══════ --}}
|
||||
<div class="text-center mb-4">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4">
|
||||
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">Installation erfolgreich!</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Die Handball WebApp ist einsatzbereit.</p>
|
||||
</div>
|
||||
|
||||
{{-- Credentials table --}}
|
||||
<div class="bg-gray-50 rounded-md border border-gray-200 p-4 mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">Zugangsdaten</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-1.5 px-2 font-medium text-gray-600">Rolle</th>
|
||||
<th class="text-left py-1.5 px-2 font-medium text-gray-600">E-Mail</th>
|
||||
<th class="text-left py-1.5 px-2 font-medium text-gray-600">Passwort</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-100 bg-blue-50">
|
||||
<td class="py-1.5 px-2 font-medium text-gray-800">Administrator</td>
|
||||
<td class="py-1.5 px-2 text-gray-700">{{ $adminEmail }}</td>
|
||||
<td class="py-1.5 px-2 text-gray-500 italic">Dein gewähltes Passwort</td>
|
||||
</tr>
|
||||
@if ($installDemo ?? false)
|
||||
<tr class="border-b border-gray-100">
|
||||
<td class="py-1.5 px-2 font-medium text-gray-800">Trainer</td>
|
||||
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">trainer@handball.local</td>
|
||||
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">trainer1234</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100">
|
||||
<td class="py-1.5 px-2 font-medium text-gray-800">Elternvertretung</td>
|
||||
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">elternvertretung@handball.local</td>
|
||||
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">eltern1234</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-100">
|
||||
<td class="py-1.5 px-2 text-gray-800">Eltern (Beispiel)</td>
|
||||
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">mary.parker@handball.local</td>
|
||||
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">eltern1234</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 px-2 text-gray-800">Eltern (Beispiel)</td>
|
||||
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">tony.stark@handball.local</td>
|
||||
<td class="py-1.5 px-2 text-gray-700 font-mono text-xs">eltern1234</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($installDemo ?? false)
|
||||
<p class="text-xs text-gray-500 mb-4">
|
||||
Die Beispieldaten umfassen ein Demo-Team mit 27 Spielern, 35 Eltern, 8 Events und weitere
|
||||
Testdaten. Alle Demo-Eltern nutzen das Passwort <code class="bg-gray-100 px-1 rounded">eltern1234</code>.
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login"
|
||||
class="inline-block px-6 py-2.5 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 transition">
|
||||
Zum Login
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
{{-- ══════ FINALIZE FORM ══════ --}}
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Installation abschließen</h2>
|
||||
|
||||
{{-- Summary --}}
|
||||
<div class="bg-gray-50 rounded-md border border-gray-200 p-4 mb-5">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Zusammenfassung</h3>
|
||||
<dl class="grid grid-cols-2 gap-y-2 gap-x-4 text-sm">
|
||||
<dt class="text-gray-500">App-Name:</dt>
|
||||
<dd class="text-gray-800 font-medium">{{ $appName }}</dd>
|
||||
<dt class="text-gray-500">Administrator:</dt>
|
||||
<dd class="text-gray-800">{{ $adminName }} ({{ $adminEmail }})</dd>
|
||||
<dt class="text-gray-500">Datenbank:</dt>
|
||||
<dd class="text-gray-800">{{ $dbDriver === 'mysql' ? 'MySQL' : 'SQLite' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('install.finalize.store') }}" x-data="{ submitting: false }" @submit="submitting = true">
|
||||
@csrf
|
||||
|
||||
{{-- Demo data checkbox --}}
|
||||
<div class="mb-5 p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="install_demo" value="1" checked
|
||||
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-800">Beispieldaten installieren</span>
|
||||
<p class="text-xs text-gray-600 mt-1">
|
||||
Erstellt ein Demo-Team mit 27 Spielern, 35 Eltern-Accounts, 8 Events
|
||||
(Training, Heimspiel, Auswärtsspiel, Turnier, Besprechung, etc.),
|
||||
Catering-Einträge, Zeitnehmer, Kommentare und weitere Testdaten.
|
||||
Ideal, um alle Funktionen der App kennenzulernen.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Registration opt-in --}}
|
||||
<div class="mb-5 p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="register_installation" value="1" checked
|
||||
class="mt-0.5 rounded border-gray-300 text-green-600 focus:ring-green-500">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-800">Installation registrieren</span>
|
||||
<p class="text-xs text-gray-600 mt-1">
|
||||
Registriert diese Installation beim Entwickler. Es werden nur technische Daten
|
||||
übermittelt (App-Name, URL, PHP-Version, App-Version). Keine persönlichen Daten.
|
||||
Ermöglicht Update-Benachrichtigungen und Support-Anfragen.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- License key (optional) --}}
|
||||
<div class="mb-5">
|
||||
<label for="license_key" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Lizenzschlüssel <span class="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input type="text" name="license_key" id="license_key"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="text-xs text-gray-400 mt-1">Falls vorhanden. Kann auch später in den Einstellungen eingetragen werden.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<a href="{{ route('install.mail') }}"
|
||||
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition">
|
||||
Zurück
|
||||
</a>
|
||||
<button type="submit" :disabled="submitting"
|
||||
class="px-5 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-wait flex items-center gap-2">
|
||||
<template x-if="submitting">
|
||||
<svg class="animate-spin h-4 w-4 text-white" 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>
|
||||
</template>
|
||||
<span x-text="submitting ? 'Installation läuft...' : 'Installation abschließen'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
</x-layouts.installer>
|
||||
237
resources/views/installer/steps/mail.blade.php
Normal file
237
resources/views/installer/steps/mail.blade.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<x-layouts.installer :currentStep="4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-2">E-Mail-Konfiguration</h2>
|
||||
<p class="text-sm text-gray-600 mb-5">
|
||||
Damit Funktionen wie "Passwort vergessen" funktionieren, muss ein E-Mail-Versand konfiguriert werden.
|
||||
Du kannst dies auch spaeter in den Admin-Einstellungen aendern.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ route('install.mail.store') }}"
|
||||
x-data="{
|
||||
mailMode: '{{ old('mail_mode', 'smtp') }}',
|
||||
editor: null,
|
||||
testing: false,
|
||||
testResult: false,
|
||||
testSuccess: false,
|
||||
testMessage: '',
|
||||
syncEditor() {
|
||||
if (this.editor) {
|
||||
document.getElementById('input-pw-reset-de').value = this.editor.root.innerHTML;
|
||||
}
|
||||
},
|
||||
async testSmtp() {
|
||||
this.testing = true;
|
||||
this.testResult = false;
|
||||
try {
|
||||
const res = await fetch('{{ route("install.test-mail") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mail_host: document.getElementById('mail_host').value,
|
||||
mail_port: document.getElementById('mail_port').value,
|
||||
mail_username: document.getElementById('mail_username').value,
|
||||
mail_password: document.getElementById('mail_password').value,
|
||||
mail_encryption: document.getElementById('mail_encryption').value,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
this.testSuccess = data.success;
|
||||
this.testMessage = data.message;
|
||||
} catch (e) {
|
||||
this.testSuccess = false;
|
||||
this.testMessage = 'Netzwerkfehler: ' + e.message;
|
||||
}
|
||||
this.testing = false;
|
||||
this.testResult = true;
|
||||
}
|
||||
}"
|
||||
@submit="syncEditor()">
|
||||
@csrf
|
||||
|
||||
{{-- Mail-Modus --}}
|
||||
<div class="mb-5">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">Versandmethode</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-start gap-3 p-3 border rounded-md cursor-pointer transition"
|
||||
:class="mailMode === 'smtp' ? 'border-blue-400 bg-blue-50' : 'border-gray-200 hover:bg-gray-50'">
|
||||
<input type="radio" name="mail_mode" value="smtp" x-model="mailMode"
|
||||
class="mt-0.5 text-blue-600 focus:ring-blue-500">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-800">SMTP-Server</span>
|
||||
<p class="text-xs text-gray-500 mt-0.5">E-Mails werden ueber einen SMTP-Server versendet (empfohlen).</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 p-3 border rounded-md cursor-pointer transition"
|
||||
:class="mailMode === 'log' ? 'border-blue-400 bg-blue-50' : 'border-gray-200 hover:bg-gray-50'">
|
||||
<input type="radio" name="mail_mode" value="log" x-model="mailMode"
|
||||
class="mt-0.5 text-blue-600 focus:ring-blue-500">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-800">Kein Versand (Log)</span>
|
||||
<p class="text-xs text-gray-500 mt-0.5">E-Mails werden nur ins Log geschrieben. Passwort-Reset funktioniert dann nicht.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SMTP-Felder --}}
|
||||
<div x-show="mailMode === 'smtp'" x-cloak class="space-y-4 mb-5 p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="mail_host" class="block text-sm font-medium text-gray-700 mb-1">SMTP-Host</label>
|
||||
<input type="text" name="mail_host" id="mail_host" value="{{ old('mail_host') }}"
|
||||
placeholder="z.B. smtp.strato.de"
|
||||
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('mail_host') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail_port" class="block text-sm font-medium text-gray-700 mb-1">Port</label>
|
||||
<input type="number" name="mail_port" id="mail_port" value="{{ old('mail_port', '587') }}"
|
||||
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('mail_port') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="mail_username" class="block text-sm font-medium text-gray-700 mb-1">Benutzername</label>
|
||||
<input type="text" name="mail_username" id="mail_username" value="{{ old('mail_username') }}"
|
||||
placeholder="z.B. noreply@deinverein.de"
|
||||
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('mail_username') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail_password" class="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
|
||||
<input type="password" name="mail_password" id="mail_password" value="{{ old('mail_password') }}"
|
||||
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('mail_password') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="mail_from_address" class="block text-sm font-medium text-gray-700 mb-1">Absender-Adresse</label>
|
||||
<input type="email" name="mail_from_address" id="mail_from_address" value="{{ old('mail_from_address') }}"
|
||||
placeholder="z.B. noreply@deinverein.de"
|
||||
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('mail_from_address') <p class="text-xs text-red-600 mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="mail_from_name" class="block text-sm font-medium text-gray-700 mb-1">Absender-Name <span class="text-gray-400 font-normal">(optional)</span></label>
|
||||
<input type="text" name="mail_from_name" id="mail_from_name" value="{{ old('mail_from_name') }}"
|
||||
placeholder="z.B. SG Woelfe Handball"
|
||||
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>
|
||||
<div>
|
||||
<label for="mail_encryption" class="block text-sm font-medium text-gray-700 mb-1">Verschluesselung</label>
|
||||
<select name="mail_encryption" id="mail_encryption"
|
||||
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">
|
||||
<option value="tls" {{ old('mail_encryption', 'tls') === 'tls' ? 'selected' : '' }}>TLS (Port 587, empfohlen)</option>
|
||||
<option value="ssl" {{ old('mail_encryption') === 'ssl' ? 'selected' : '' }}>SSL (Port 465)</option>
|
||||
<option value="none" {{ old('mail_encryption') === 'none' ? 'selected' : '' }}>Keine</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- SMTP-Test --}}
|
||||
<div class="pt-2 border-t border-gray-200">
|
||||
<button type="button" @click="testSmtp()"
|
||||
:disabled="testing"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-md hover:bg-emerald-700 transition disabled:opacity-50 disabled:cursor-wait inline-flex items-center gap-2">
|
||||
<template x-if="testing">
|
||||
<svg class="animate-spin h-4 w-4 text-white" 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>
|
||||
</template>
|
||||
<span x-text="testing ? 'Teste Verbindung...' : 'Verbindung testen'"></span>
|
||||
</button>
|
||||
<p x-show="testResult" x-cloak x-text="testMessage"
|
||||
:class="testSuccess ? 'text-green-600' : 'text-red-600'"
|
||||
class="text-sm mt-2"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Passwort-Reset E-Mail Template --}}
|
||||
<div class="mb-5">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-1">Passwort-Reset E-Mail (Deutsch)</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
Dieser Text wird versendet, wenn ein Benutzer sein Passwort zuruecksetzen moechte.
|
||||
Platzhalter: <code class="bg-gray-100 px-1 rounded">{name}</code>,
|
||||
<code class="bg-gray-100 px-1 rounded">{app_name}</code>.
|
||||
Der Reset-Link wird automatisch als Button angefuegt.
|
||||
</p>
|
||||
<div id="editor-pw-reset" class="bg-white border border-gray-300 rounded-md" style="min-height: 150px;">{!! $defaultPwResetDe !!}</div>
|
||||
<input type="hidden" name="password_reset_email_de" id="input-pw-reset-de" value="{{ old('password_reset_email_de', $defaultPwResetDe) }}">
|
||||
<p class="text-xs text-gray-400 mt-2">Texte fuer weitere Sprachen werden automatisch erstellt und koennen spaeter in den Einstellungen angepasst werden.</p>
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<ul class="text-sm text-red-700 list-disc list-inside">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<a href="{{ route('install.app') }}"
|
||||
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition">
|
||||
Zurueck
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit"
|
||||
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Ueberspringen-Link (separates Formular) --}}
|
||||
<div class="text-center mt-3">
|
||||
<form method="POST" action="{{ route('install.mail.store') }}" class="inline">
|
||||
@csrf
|
||||
<input type="hidden" name="mail_mode" value="log">
|
||||
<button type="submit" class="text-xs text-gray-400 hover:text-gray-600 underline transition">
|
||||
Schritt ueberspringen (kein E-Mail-Versand)
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Quill Editor --}}
|
||||
<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>
|
||||
#editor-pw-reset .ql-editor { min-height: 120px; }
|
||||
.ql-toolbar.ql-snow { border-radius: 0.375rem 0.375rem 0 0; border-color: #d1d5db; }
|
||||
.ql-container.ql-snow { border-radius: 0 0 0.375rem 0.375rem; border-color: #d1d5db; }
|
||||
</style>
|
||||
<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>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const editorEl = document.getElementById('editor-pw-reset');
|
||||
if (!editorEl) return;
|
||||
|
||||
const quill = new Quill('#editor-pw-reset', {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ 'header': [2, 3, false] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['link'],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Speichere Referenz fuer Alpine.js syncEditor()
|
||||
const component = document.querySelector('[x-data]').__x.$data;
|
||||
component.editor = quill;
|
||||
});
|
||||
</script>
|
||||
</x-layouts.installer>
|
||||
79
resources/views/installer/steps/requirements.blade.php
Normal file
79
resources/views/installer/steps/requirements.blade.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<x-layouts.installer :currentStep="1">
|
||||
@if (! session('setup_token_hash'))
|
||||
{{-- Token-Eingabe: User muss zuerst das Setup-Token eingeben --}}
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Setup-Token</h2>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<p class="text-sm text-amber-700 mb-3">
|
||||
Zum Schutz der Installation wird ein Setup-Token benoetigt.
|
||||
Du findest es in der Datei <code class="bg-amber-100 px-1 rounded font-mono text-xs">storage/setup-token</code>
|
||||
auf dem Server (per FTP oder Dateimanager).
|
||||
</p>
|
||||
<form method="GET" action="{{ route('install.requirements') }}" class="flex gap-2">
|
||||
<input type="text" name="token"
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Token eingeben..." required autofocus>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
|
||||
Bestaetigen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Systemvoraussetzungen</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Bitte stelle sicher, dass alle Voraussetzungen erfüllt sind, bevor du fortfährst.</p>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-2 px-2 font-medium text-gray-700">Prüfung</th>
|
||||
<th class="text-left py-2 px-2 font-medium text-gray-700">Status</th>
|
||||
<th class="text-center py-2 px-1 font-medium text-gray-700 w-10">OK</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($checks as $check)
|
||||
<tr class="border-b border-gray-100 {{ $check['passed'] ? '' : ($check['required'] ? 'bg-red-50' : 'bg-yellow-50') }}">
|
||||
<td class="py-2 px-2 text-gray-800">
|
||||
{{ $check['name'] }}
|
||||
@if (! $check['required'])
|
||||
<span class="text-xs text-gray-400">(optional)</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 px-2 text-gray-500">{{ $check['current'] }}</td>
|
||||
<td class="py-2 px-1 text-center">
|
||||
@if ($check['passed'])
|
||||
<svg class="w-5 h-5 text-green-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
@elseif ($check['required'])
|
||||
<svg class="w-5 h-5 text-red-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
@else
|
||||
<svg class="w-5 h-5 text-yellow-500 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<a href="{{ route('install.requirements') }}"
|
||||
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200 transition">
|
||||
Erneut prüfen
|
||||
</a>
|
||||
|
||||
@if ($allPassed)
|
||||
<a href="{{ route('install.database') }}"
|
||||
class="px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition">
|
||||
Weiter
|
||||
</a>
|
||||
@else
|
||||
<span class="px-5 py-2 text-sm font-medium text-white bg-gray-300 rounded-md cursor-not-allowed">
|
||||
Weiter
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@endif
|
||||
</x-layouts.installer>
|
||||
20
resources/views/legal/datenschutz.blade.php
Executable file
20
resources/views/legal/datenschutz.blade.php
Executable file
@@ -0,0 +1,20 @@
|
||||
<x-layouts.guest :title="__('ui.privacy')">
|
||||
<h2 class="text-xl font-bold mb-4">{{ __('ui.privacy') }}</h2>
|
||||
|
||||
<div class="prose prose-sm text-gray-700 space-y-4 text-sm">
|
||||
@php
|
||||
$locale = app()->getLocale();
|
||||
$content = \App\Models\Setting::get('datenschutz_html_' . $locale)
|
||||
?: \App\Models\Setting::get('datenschutz_html_de')
|
||||
?: \App\Models\Setting::get('datenschutz_html', '<p>[Datenschutzerklärung noch nicht hinterlegt]</p>');
|
||||
@endphp
|
||||
{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($content) !!}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<a href="{{ auth()->check() ? route('dashboard') : route('login') }}"
|
||||
class="inline-flex items-center gap-1 bg-gray-200 text-gray-700 px-4 py-2 rounded-md text-sm hover:bg-gray-300">
|
||||
← {{ __('ui.back') }}
|
||||
</a>
|
||||
</div>
|
||||
</x-layouts.guest>
|
||||
20
resources/views/legal/impressum.blade.php
Executable file
20
resources/views/legal/impressum.blade.php
Executable file
@@ -0,0 +1,20 @@
|
||||
<x-layouts.guest :title="__('ui.impressum')">
|
||||
<h2 class="text-xl font-bold mb-4">{{ __('ui.impressum') }}</h2>
|
||||
|
||||
<div class="prose prose-sm text-gray-700 space-y-3">
|
||||
@php
|
||||
$locale = app()->getLocale();
|
||||
$content = \App\Models\Setting::get('impressum_html_' . $locale)
|
||||
?: \App\Models\Setting::get('impressum_html_de')
|
||||
?: \App\Models\Setting::get('impressum_html', '<p>[Impressum noch nicht hinterlegt]</p>');
|
||||
@endphp
|
||||
{!! app(\App\Services\HtmlSanitizerService::class)->sanitize($content) !!}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<a href="{{ auth()->check() ? route('dashboard') : route('login') }}"
|
||||
class="inline-flex items-center gap-1 bg-gray-200 text-gray-700 px-4 py-2 rounded-md text-sm hover:bg-gray-300">
|
||||
← {{ __('ui.back') }}
|
||||
</a>
|
||||
</div>
|
||||
</x-layouts.guest>
|
||||
52
resources/views/offline.blade.php
Normal file
52
resources/views/offline.blade.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offline – SG Wölfe Handball</title>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#1f2937">
|
||||
<link rel="icon" href="/images/icon-192x192.png">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.container { max-width: 400px; }
|
||||
.logo { width: 80px; height: 80px; margin: 0 auto 1.5rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.75rem; color: #1f2937; }
|
||||
p { color: #6b7280; line-height: 1.6; margin-bottom: 1.5rem; }
|
||||
button {
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover { background: #374151; }
|
||||
button:active { background: #111827; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<img src="/images/icon-192x192.png" alt="SG Wölfe" class="logo">
|
||||
<h1>Keine Internetverbindung</h1>
|
||||
<p>
|
||||
Die Seite kann gerade nicht geladen werden.
|
||||
Bitte prüfe deine Verbindung und versuche es erneut.
|
||||
</p>
|
||||
<button onclick="window.location.reload()">Erneut versuchen</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
261
resources/views/profile/edit.blade.php
Executable file
261
resources/views/profile/edit.blade.php
Executable file
@@ -0,0 +1,261 @@
|
||||
<x-layouts.app :title="__('profile.title')">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('profile.title') }}</h1>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- Profilbild --}}
|
||||
<div class="mb-6" x-data="{ preview: null }">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('profile.profile_picture') }}</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
@if ($user->getAvatarUrl())
|
||||
<img src="{{ $user->getAvatarUrl() }}" alt="{{ $user->name }}" class="w-16 h-16 rounded-full object-cover border-2 border-gray-200" x-show="!preview">
|
||||
@else
|
||||
<div class="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-semibold text-lg border-2 border-gray-200" x-show="!preview">
|
||||
{{ $user->getInitials() }}
|
||||
</div>
|
||||
@endif
|
||||
<img :src="preview" x-show="preview" class="w-16 h-16 rounded-full object-cover border-2 border-blue-400" x-cloak>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="cursor-pointer bg-gray-100 text-gray-700 px-3 py-1.5 rounded-md text-sm hover:bg-gray-200 inline-block">
|
||||
{{ __('profile.upload_picture') }}
|
||||
<input type="file" name="profile_picture" accept="image/jpeg,image/png,image/gif,image/webp" class="hidden"
|
||||
@change="if ($event.target.files[0]) { preview = URL.createObjectURL($event.target.files[0]) }">
|
||||
</label>
|
||||
<span class="text-xs text-gray-400">{{ __('profile.max_picture_size') }}</span>
|
||||
@error('profile_picture')
|
||||
<p class="text-red-600 text-xs">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">{{ __('profile.name_label') }}</label>
|
||||
<input type="text" name="name" id="name" value="{{ old('name', $user->name) }}" required
|
||||
class="w-full rounded border-gray-300 text-sm">
|
||||
@error('name')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('profile.email_label') }}</label>
|
||||
<p class="text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded">{{ $user->email }}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">{{ __('profile.email_readonly') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">{{ __('profile.phone_label') }}</label>
|
||||
<input type="tel" name="phone" id="phone" value="{{ old('phone', $user->phone) }}"
|
||||
placeholder="+49..."
|
||||
class="w-full rounded border-gray-300 text-sm">
|
||||
@error('phone')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('profile.role_label') }}</label>
|
||||
<p class="text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded">{{ __('ui.enums.user_role.' . $user->role->value) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('profile.language_label') }}</label>
|
||||
@php
|
||||
$flags = ['de' => "\u{1F1E9}\u{1F1EA}", 'en' => "\u{1F1EC}\u{1F1E7}", 'pl' => "\u{1F1F5}\u{1F1F1}", 'ru' => "\u{1F1F7}\u{1F1FA}", 'ar' => "\u{1F1F8}\u{1F1E6}", 'tr' => "\u{1F1F9}\u{1F1F7}"];
|
||||
@endphp
|
||||
<div class="flex items-center gap-3" x-data="{ locale: @js(old('locale', $user->locale)) }">
|
||||
<input type="hidden" name="locale" :value="locale">
|
||||
@foreach ($flags as $code => $flag)
|
||||
<button type="button" @click="locale = @js($code)"
|
||||
:class="locale === @js($code) ? 'ring-2 ring-blue-500 ring-offset-1 scale-110' : 'opacity-50 hover:opacity-80'"
|
||||
class="text-2xl leading-none transition-all rounded cursor-pointer"
|
||||
title="{{ __('ui.locales.' . $code) }}">
|
||||
{{ $flag }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700">{{ __('ui.save') }}</button>
|
||||
</form>
|
||||
|
||||
@if ($user->getAvatarUrl())
|
||||
<div class="mt-3 pt-3 border-t">
|
||||
<form method="POST" action="{{ route('profile.remove-picture') }}" class="inline" onsubmit="return confirm(@js(__('admin.confirm_delete_file')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700">{{ __('profile.remove_picture') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- DSGVO-Einverständniserklärung --}}
|
||||
<div id="dsgvo-consent" class="bg-white rounded-lg shadow p-6 mb-6" x-data="{ dsgvoModal: false }">
|
||||
<h2 class="text-lg font-semibold mb-2">{{ __('profile.dsgvo_title') }}</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">{{ __('profile.dsgvo_description') }}</p>
|
||||
|
||||
@if ($user->dsgvo_consent_file)
|
||||
@php
|
||||
$ext = strtolower(pathinfo($user->dsgvo_consent_file, PATHINFO_EXTENSION));
|
||||
$dsgvoIsPdf = $ext === 'pdf';
|
||||
$dsgvoIsImage = in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp']);
|
||||
@endphp
|
||||
|
||||
{{-- Status-Banner --}}
|
||||
<div class="flex items-center gap-3 p-3 rounded-md mb-4
|
||||
{{ $user->isDsgvoConfirmed() ? 'bg-green-50 border border-green-200' : 'bg-yellow-50 border border-yellow-200' }}">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium {{ $user->isDsgvoConfirmed() ? 'text-green-800' : 'text-yellow-800' }}">
|
||||
@if ($user->isDsgvoConfirmed())
|
||||
{{ __('profile.dsgvo_confirmed') }}
|
||||
<span class="text-xs font-normal block mt-0.5">
|
||||
{{ __('profile.dsgvo_confirmed_by', [
|
||||
'name' => $user->dsgvoAcceptedBy?->name ?? '—',
|
||||
'date' => $user->dsgvo_accepted_at->translatedFormat(__('ui.date_format'))
|
||||
]) }}
|
||||
</span>
|
||||
@else
|
||||
{{ __('profile.dsgvo_pending') }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" @click="dsgvoModal = true"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 font-medium cursor-pointer">
|
||||
{{ __('profile.dsgvo_view') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- DSGVO-Vorschau-Modal --}}
|
||||
<div x-show="dsgvoModal" x-cloak @keydown.escape.window="dsgvoModal = false" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div x-show="dsgvoModal"
|
||||
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
||||
@click="dsgvoModal = false" class="fixed inset-0 bg-black/60"></div>
|
||||
<div x-show="dsgvoModal"
|
||||
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.outside="dsgvoModal = false"
|
||||
class="relative bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden {{ $dsgvoIsPdf ? 'w-full max-w-3xl max-h-[92vh]' : 'w-full max-w-lg max-h-[90vh]' }}">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b">
|
||||
<h3 class="font-semibold text-gray-900">{{ __('profile.dsgvo_title') }}</h3>
|
||||
<button @click="dsgvoModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto {{ $dsgvoIsPdf ? 'p-0' : 'p-5' }}">
|
||||
@if ($dsgvoIsImage)
|
||||
<div class="flex justify-center bg-gray-50 rounded-lg p-2">
|
||||
<img src="{{ route('profile.dsgvo-consent') }}" alt="{{ __('profile.dsgvo_title') }}" class="max-w-full max-h-[70vh] rounded object-contain">
|
||||
</div>
|
||||
@elseif ($dsgvoIsPdf)
|
||||
<iframe src="{{ route('profile.dsgvo-consent') }}" class="w-full border-0" style="height: 75vh;"></iframe>
|
||||
@endif
|
||||
</div>
|
||||
<div class="px-5 py-3 border-t bg-gray-50 flex justify-end">
|
||||
<button @click="dsgvoModal = false" class="text-sm text-gray-500 hover:text-gray-700">{{ __('ui.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ersetzen --}}
|
||||
<form method="POST" action="{{ route('profile.upload-dsgvo-consent') }}" enctype="multipart/form-data" class="mb-3">
|
||||
@csrf
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="cursor-pointer bg-gray-100 text-gray-700 px-3 py-1.5 rounded-md text-sm hover:bg-gray-200">
|
||||
{{ __('profile.dsgvo_replace') }}
|
||||
<input type="file" name="dsgvo_consent_file"
|
||||
accept="application/pdf,image/jpeg,image/png,image/gif,image/webp"
|
||||
class="hidden" onchange="this.form.submit()">
|
||||
</label>
|
||||
<span class="text-xs text-gray-400">{{ __('profile.dsgvo_file_hint') }}</span>
|
||||
</div>
|
||||
@error('dsgvo_consent_file')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</form>
|
||||
|
||||
{{-- Entfernen --}}
|
||||
<form method="POST" action="{{ route('profile.remove-dsgvo-consent') }}"
|
||||
onsubmit="return confirm(@js(__('profile.dsgvo_confirm_remove')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs text-red-500 hover:text-red-700">
|
||||
{{ __('profile.dsgvo_remove') }}
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
{{-- Upload-Formular --}}
|
||||
<form method="POST" action="{{ route('profile.upload-dsgvo-consent') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="cursor-pointer bg-blue-600 text-white px-4 py-2 rounded-md text-sm hover:bg-blue-700">
|
||||
{{ __('profile.dsgvo_upload') }}
|
||||
<input type="file" name="dsgvo_consent_file"
|
||||
accept="application/pdf,image/jpeg,image/png,image/gif,image/webp"
|
||||
class="hidden" onchange="this.form.submit()">
|
||||
</label>
|
||||
<span class="text-xs text-gray-400">{{ __('profile.dsgvo_file_hint') }}</span>
|
||||
</div>
|
||||
@error('dsgvo_consent_file')
|
||||
<p class="text-red-600 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Zugeordnete Kinder --}}
|
||||
@if ($user->children->isNotEmpty())
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('profile.my_children') }}</h2>
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach ($user->children as $child)
|
||||
<div class="py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $child->full_name }}</span>
|
||||
@if ($child->pivot->relationship_label)
|
||||
<span class="text-xs text-gray-500 ml-1 rtl:mr-1 rtl:ml-0">({{ $child->pivot->relationship_label }})</span>
|
||||
@endif
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{{ $child->team->name ?? '—' }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Gefahrenzone: Account löschen (nur für Eltern) --}}
|
||||
@if ($user->role === \App\Enums\UserRole::User)
|
||||
<div class="bg-white rounded-lg shadow p-6 border border-red-200">
|
||||
<h2 class="font-semibold text-red-700 mb-2">{{ __('profile.danger_zone') }}</h2>
|
||||
<p class="text-sm text-gray-600 mb-3">{{ __('profile.delete_account_hint') }}</p>
|
||||
|
||||
@php $orphanedChildren = $user->getOrphanedChildren(); @endphp
|
||||
@if ($orphanedChildren->isNotEmpty())
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-3 mb-4">
|
||||
<p class="text-sm text-yellow-800 font-medium">{{ __('profile.delete_warning_children') }}</p>
|
||||
<ul class="list-disc list-inside text-sm text-yellow-700 mt-1">
|
||||
@foreach ($orphanedChildren as $child)
|
||||
<li>{{ $child->full_name }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('profile.destroy') }}"
|
||||
onsubmit="return confirm(@js(__('profile.delete_confirm')))">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 text-sm font-medium">
|
||||
{{ __('profile.delete_account') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</x-layouts.app>
|
||||
277
resources/views/welcome.blade.php
Executable file
277
resources/views/welcome.blade.php
Executable file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user