Spielerpositionen, Statistiken, Fahrgemeinschaften, Spielfeld-Visualisierung

- PlayerPosition Enum (7 Handball-Positionen) mit Label/ShortLabel
- Spielerstatistik pro Spiel (Tore, Würfe, TW-Paraden, Bemerkung)
- Position-Dropdown in Spieler-Editor und Event-Stats-Formular
- Statistik-Seite: TW zuerst, Trennlinie, Feldspieler, Position-Badges
- Spielfeld-SVG mit Ampel-Performance (grün/gelb/rot)
- Anklickbare Spieler im Spielfeld öffnen Detail-Modal
- Fahrgemeinschaften (Anbieten, Zuordnen, Zurückziehen)
- Übersetzungen in allen 6 Sprachen (de, en, pl, ru, ar, tr)
- .gitignore für Laravel hinzugefügt
- Demo-Daten mit Positionen und Statistiken

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rhino
2026-03-02 11:47:34 +01:00
parent 2e24a40d68
commit ad60e7a9f9
46 changed files with 2041 additions and 86 deletions

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/installed
/storage/setup-token
/vendor
.env
.env.backup
.env.production
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/.fleet
/.idea
/.vscode
/.claude
database/database.sqlite

126
CLAUDE.md
View File

@@ -3,6 +3,7 @@
## Projekt-Typ
Laravel 12 WebApp zur Verwaltung von Handball-Teams, Spielern, Eltern, Terminen und Dateien.
PHP 8.2+, SQLite/MySQL, Blade + Alpine.js + Tailwind CSS, Quill.js WYSIWYG-Editor.
Deployment: Shared Hosting (all-inkl.com, FTP only, kein SSH).
## Architektur
@@ -10,14 +11,14 @@ PHP 8.2+, SQLite/MySQL, Blade + Alpine.js + Tailwind CSS, Quill.js WYSIWYG-Edito
- `app/Models/` — Eloquent Models (User, Player, Team, Event, Setting, etc.)
- `app/Http/Controllers/Admin/` — Admin-Bereich (UserController, SettingsController, etc.)
- `app/Http/Controllers/Auth/` — Login, Register, ForgotPassword, ResetPassword
- `app/Http/Middleware/` — InstallerMiddleware (Setup-Token), SetLocaleMiddleware, SecurityHeadersMiddleware, ActiveUserMiddleware, DsgvoConsentMiddleware
- `app/Http/Middleware/` — InstallerMiddleware, SetLocaleMiddleware, SecurityHeadersMiddleware, ActiveUserMiddleware, DsgvoConsentMiddleware, StaffMiddleware, AdminOnlyMiddleware
- `app/Services/` — HtmlSanitizerService (HTMLPurifier), GeocodingService, SupportApiService
- `app/Enums/` — UserRole, EventType, EventStatus, ParticipantStatus, CateringStatus
- `app/Notifications/` — ResetPasswordNotification (Custom, mit Setting-Template)
- `resources/views/` — Blade-Templates (layouts: admin, guest, app)
- `lang/{de,en,pl,ru,ar,tr}/` — 6 Sprachen: Deutsch, Englisch, Polnisch, Russisch, Arabisch, Turkisch
- `resources/views/` — Blade-Templates (layouts: admin, guest, app, installer)
- `lang/{de,en,pl,ru,ar,tr}/` — 6 Sprachen: Deutsch, Englisch, Polnisch, Russisch, Arabisch, Tuerkisch
- `database/migrations/` — Nummeriert mit Prefix 0001-0035
- `database/seeders/` — AdminSeeder (benotigt ADMIN_EMAIL + ADMIN_PASSWORD in .env)
- `database/seeders/` — AdminSeeder, DemoSeeder, FaqSeeder
### Wichtige Patterns
- **Settings**: Key-Value Store in `settings` Tabelle. `Setting::get($key)` mit 1-Stunden-Cache, `Setting::set($key, $value)` mit Cache-Invalidierung
@@ -27,43 +28,92 @@ PHP 8.2+, SQLite/MySQL, Blade + Alpine.js + Tailwind CSS, Quill.js WYSIWYG-Edito
- **Rollen**: Admin, Coach, ParentRep, User (Enum `UserRole`)
- **Soft-Deletes**: User und Player (7 Tage Wiederherstellung)
- **Activity-Log**: `ActivityLog::log()` / `ActivityLog::logWithChanges()` fuer Audit-Trail
- **User-Model**: Nutzt `Notifiable` Trait (erforderlich fuer Passwort-Reset-Notifications)
- **.env-Manipulation**: `updateEnvValues()` Helper in InstallerController und SettingsController
### Multi-Language
- 6 Sprachen: de, en, pl, ru, ar, tr
- Translation-Dateien: `lang/{locale}/admin.php`, `auth_ui.php`, `passwords.php`, `ui.php`, `events.php`, `profile.php`, `validation.php`
- Locale wird per SetLocaleMiddleware aus `$user->locale` oder Session gesetzt
- RTL-Support fuer Arabisch (ar)
- **Alle Uebersetzungsschluessel muessen in ALLEN 6 Sprachen hinzugefuegt werden**
### Installer (5-Step Wizard)
1. Systemcheck (PHP-Version, Extensions, Berechtigungen)
2. Datenbank (SQLite/MySQL Konfiguration)
3. Einstellungen (App-Name, Admin-Account)
4. E-Mail (SMTP-Konfiguration mit Verbindungstest, oder Log-Modus zum Ueberspringen)
5. Abschluss (Finalize, Demo-Daten, Lizenz)
- `InstallerMiddleware` prueft `storage/installed` Datei
- Setup-Token-Schutz: Token in `storage/setup-token`, SHA256 im Laravel-Log
- SMTP-Test nutzt `Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport`
- PW-Reset-Standardtexte fuer 6 Sprachen in `getDefaultPasswordResetTexts()`
- Session-basierte Datenpersistenz zwischen Schritten
### Password Reset Flow
1. User klickt "Passwort vergessen?" auf Login-Seite
2. ForgotPasswordController sendet Reset-Link via Laravel Password Broker
3. ResetPasswordNotification laedt optionalen Custom-Template-Text aus `Setting::get('password_reset_email_{locale}')`
3. ResetPasswordNotification laedt Custom-Template aus `Setting::get('password_reset_email_{locale}')`
4. Platzhalter: `{name}`, `{link}`, `{app_name}`
5. Fallback auf Standard-Laravel-Template mit Translation-Keys (`passwords.reset_*`)
### Installer
- InstallerMiddleware prueft `storage/installed` Datei
- Setup-Token-Schutz: Token in `storage/setup-token`, wird beim ersten Zugriff generiert und in Laravel-Log geschrieben
- Nach Installation wird `storage/installed` erstellt
### Admin Settings Tabs
- **Allgemein**: App-Name, Slogan, Favicon, Saison
- **E-Mail**: SMTP-Konfiguration mit Verbindungstest (schreibt in .env, `Artisan::call('config:clear')`)
- **Rechtliches**: Impressum + Datenschutz + PW-Reset-E-Mail pro Sprache (Quill-Editor mit Flaggen-Selector)
- **Event-Standards**: Min-Werte fuer Spieler, Catering, Zeitnehmer pro Event-Typ
- **Dateikategorien**: CRUD fuer File-Categories
- **Sichtbarkeit**: Feature-Toggles
- **Lizenz**: License-Key-Validierung via SupportApiService
- **Wartung**: Demo-Daten loeschen, Factory-Reset
### Security
### Lazy Quill-Initialisierung (Rechtliches-Tab)
- Quill-Editoren werden erst bei Klick auf die Sprach-Flagge erstellt (`initLocaleEditors(locale)`)
- Verhindert Datenverlust: Nicht-initialisierte Locales behalten ihre Server-Werte in Hidden-Inputs
- `$watch('legalLocale')` + `$nextTick()` fuer verzoegerte Erstellung
- `syncEditors()` synchronisiert nur tatsaechlich initialisierte Editoren
### Route-Struktur (Middleware)
- Installer: `throttle:10,1`, ohne SetLocaleMiddleware/ActiveUserMiddleware
- Oeffentlich: Login, Register, Legal-Pages, Locale-Switch
- User-Bereich: `auth` Middleware
- Admin-Bereich: `auth` + `admin` + `prefix('admin')`
- Staff-Routes (Admin + Coach): `staff` Middleware
- Admin-Only-Routes: `admin-only` Middleware (Settings, Locations, Support)
## Security
### Middleware-Stack
- CSP und Permissions-Policy via SecurityHeadersMiddleware (inkl. COOP-Header)
- `StaffMiddleware` — prueft Admin oder Coach Rolle
- `AdminOnlyMiddleware` — prueft explizit Admin-Rolle (Route-Level-Schutz)
- Rate-Limiting auf Auth-Routes, User-Actions, Geocoding, Installer
### Schutzmassnahmen
- Honeypot-Feld auf Login/Register-Formularen
- Rate-Limiting auf Auth-Routes (`throttle:login`)
- DSGVO-Consent-System mit Datei-Upload und Admin-Bestaetigung
- Factory Reset benoetigt Passwort + Bestaetigung "RESET-BESTAETIGT"
- **File-Autorisierung**: `authorizeFileAccess()` in FileController prueft Team-Zugehoerigkeit und aktive Kategorien
- **File-Autorisierung**: `authorizeFileAccess()` in FileController prueft Team-Zugehoerigkeit
- **Settings-Whitelist**: SettingsController akzeptiert nur bekannte Keys + validierte Locale-Suffixe
- **SSRF-Schutz**: GeocodingService mit Host-Whitelist (nominatim.openstreetmap.org, photon.komoot.io), HTTPS-only, Timeout 5s
- **Installer-Sicherheit**: Admin-Passwort wird sofort gehasht (nicht Klartext in Session), Setup-Token nur als SHA256 geloggt
- **SSRF-Schutz**: GeocodingService mit Host-Whitelist, HTTPS-only, DNS-Rebinding-Check, Timeout 5s
- **Installer-Sicherheit**: Admin-Passwort sofort gehasht, Setup-Token nur als SHA256 geloggt
- **Path-Traversal-Schutz**: DSGVO-Datei-Downloads pruefen `str_starts_with('dsgvo/')`
- **HTML-Sanitisierung**: Alle `{!! !!}`-Ausgaben (Slogan, Settings-Editor, PWA-Banner) durch `HtmlSanitizerService::sanitize()` geschuetzt
- **HTML-Sanitisierung**: Alle User-Content-Ausgaben durch HtmlSanitizerService geschuetzt
- **Invitation-Tokens**: SHA-256 gehasht in DB gespeichert
- **E-Mail-Normalisierung**: Lowercase-Vergleich bei Login und Password-Reset
- **Team-Scoping**: Coach/ParentRep sehen nur ihre zugewiesenen Teams
### Security-Audit-Historie (Maerz 2026)
6 Audits durchgefuehrt, Score: 6.5 → 9.5/10
- 95 Findings insgesamt, 88 behoben, 3 akzeptierte Risiken, 4 verbleibende Niedrig/Info
- Akzeptierte Risiken: MIME-Spoofing (F05), Enum-Reuse (F20), CSP unsafe-inline (W04, CDN-Abhaengigkeit)
## Conventions
- Controller-Methoden: resourceful (index, create, store, show, edit, update, destroy)
- Blade-Components: `<x-layouts.admin>`, `<x-layouts.guest>`, `<x-layouts.app>`
- Blade-Components: `<x-layouts.admin>`, `<x-layouts.guest>`, `<x-layouts.app>`, `<x-layouts.installer>`
- Alpine.js fuer interaktive UI-Elemente
- Quill.js v1.3.7 fuer WYSIWYG-Editoren (via CDN)
- Quill.js v1.3.7 fuer WYSIWYG-Editoren (via CDN mit SRI-Hashes)
- Keine Tests vorhanden — bei Aenderungen manuell testen
- Commit-Sprache: Deutsch oder Englisch
- Alle Uebersetzungsschluessel muessen in ALLEN 6 Sprachen hinzugefuegt werden
@@ -84,38 +134,14 @@ Immer in allen 6 Dateien: `lang/de/`, `lang/en/`, `lang/pl/`, `lang/ru/`, `lang/
2. Migration fuer alle 6 Locales
3. In SettingsController `$localeSettings` Array aufnehmen
4. In View mit Sprach-Flaggen-Selector anzeigen
5. In `update()` mit `str_starts_with()` und `updateOrCreate()` behandeln
5. In `update()` mit Whitelist und `updateOrCreate()` behandeln
6. **Wichtig**: Neuen Key zur Whitelist in `SettingsController::update()` hinzufuegen ($allowedLocaleKeys)
## Security Audit & Haertung (Maerz 2026)
### Audit-Report
- **Datei**: `security-audit-2026-03.html` — Vollstaendiger HTML-Report mit allen Findings und Fix-Dokumentation
- **Score**: 6.5 → 8.5 / 10 nach Haertung
- **Ergebnis**: 20 Findings identifiziert, 19 behoben, 1 als akzeptiertes Risiko
### Behobene Schwachstellen (nach Datei)
| Datei | Findings | Aenderungen |
|-------|----------|-------------|
| `FileController.php` | F01 (Kritisch) | `authorizeFileAccess()` — IDOR-Schutz fuer Downloads/Previews |
| `admin/settings/edit.blade.php` | F02 (Kritisch) | Alle `{!! !!}` durch `HtmlSanitizerService::sanitize()` |
| `layouts/app.blade.php`, `guest.blade.php` | F03 (Kritisch) | Slogan-Ausgabe sanitisiert |
| `Admin/SettingsController.php` | F04 (Hoch) | Settings-Key-Whitelist in `update()` |
| `Services/GeocodingService.php` | F06 (Hoch) | ALLOWED_HOSTS, HTTPS-only, SHA256-Cache, Timeout |
| `EventController.php` | F07 (Hoch) | `accessibleTeamIds()`, EventType-Validierung, int-Cast |
| `InstallerController.php` | F08 (Mittel) | Passwort sofort gehasht (`installer.admin_password_hash`) |
| `InstallerMiddleware.php` | F09 (Mittel) | Token als SHA256 geloggt, chmod 0600 |
| `SecurityHeadersMiddleware.php` | F10, F19 | COOP-Header hinzugefuegt |
| `.env.example` | F11 (Mittel) | SESSION_SECURE_COOKIE + SESSION_SAME_SITE Defaults |
| `Admin/TeamController.php` | F12 (Mittel) | Ziel-Team aktiv-Pruefung in `updatePlayerTeam()` |
| `Admin/ActivityLogController.php` | F13, F16 | whereRaw→Eloquent, `canViewActivityLog()` statt `id !== 1` |
| `CommentController.php` | F14 (Mittel) | `e()` entfernt (Doppel-Escaping behoben) |
| `pwa-install-banner.blade.php` | F15 (Mittel) | Translation-Ausgabe sanitisiert |
| `ProfileController.php` | F17 (Niedrig) | DSGVO Path-Prefix-Check |
| `Auth/ResetPasswordController.php` | F18 (Niedrig) | `Password::min(8)->letters()->numbers()` |
### Akzeptierte Restrisiken
- **F05** (MIME-Spoofing): UUID-Dateinamen + privater Storage + Autorisierung mitigieren das Risiko
- **F20** (EventTimekeeper Enum): CateringStatus und TimekeeperStatus haben identische Werte
- **CSP unsafe-inline/eval**: CDN-Abhaengigkeit (Tailwind/Alpine.js/Quill.js) erfordert dies — langfristig lokale Assets empfohlen
### Neue Admin-Route hinzufuegen
1. Route in `routes/web.php` im passenden Middleware-Block:
- Alle Admin-Panel-Nutzer: direkt unter `admin` Prefix
- Staff (Admin + Coach): unter `staff` Middleware
- Nur Admin: unter `admin-only` Middleware
2. Controller-Methode mit Autorisierungs-Pruefung
3. View erstellen
4. Translation-Keys fuer alle 6 Sprachen

View File

@@ -31,6 +31,11 @@ enum EventType: string
return !in_array($this, [self::AwayGame, self::Meeting]);
}
public function hasCarpool(): bool
{
return $this !== self::Meeting;
}
public function hasPlayerParticipants(): bool
{
return $this !== self::Meeting;

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Enums;
enum PlayerPosition: string
{
case Torwart = 'torwart';
case LinksAussen = 'links_aussen';
case RechtsAussen = 'rechts_aussen';
case RueckraumLinks = 'rueckraum_links';
case RueckraumMitte = 'rueckraum_mitte';
case RueckraumRechts = 'rueckraum_rechts';
case Kreislaeufer = 'kreislaeufer';
public function label(): string
{
return __("ui.enums.player_position.{$this->value}");
}
public function shortLabel(): string
{
return __("ui.enums.player_position_short.{$this->value}");
}
public function isGoalkeeper(): bool
{
return $this === self::Torwart;
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -6,10 +6,12 @@ use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Enums\ParticipantStatus;
use App\Enums\PlayerPosition;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Event;
use App\Models\EventCatering;
use App\Models\EventPlayerStat;
use App\Models\EventTimekeeper;
use App\Models\File;
use App\Models\FileCategory;
@@ -120,13 +122,16 @@ class EventController extends Controller
$participantRelations = $event->type === EventType::Meeting
? ['participants.user']
: ['participants.player'];
$event->load(array_merge($participantRelations, ['caterings', 'timekeepers', 'files.category']));
$event->load(array_merge($participantRelations, ['caterings', 'timekeepers', 'files.category', 'playerStats']));
$assignedCatering = $event->caterings->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
$assignedTimekeeper = $event->timekeepers->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
$knownLocations = Location::orderBy('name')->get();
$fileCategories = FileCategory::active()->ordered()->with(['files' => fn ($q) => $q->latest()])->get();
return view('admin.events.edit', compact('event', 'teams', 'types', 'statuses', 'teamParents', 'assignedCatering', 'assignedTimekeeper', 'eventDefaults', 'knownLocations', 'fileCategories'));
// Spielerstatistik-Daten für Spieltypen
$playerStatsMap = $event->playerStats->keyBy('player_id');
return view('admin.events.edit', compact('event', 'teams', 'types', 'statuses', 'teamParents', 'assignedCatering', 'assignedTimekeeper', 'eventDefaults', 'knownLocations', 'fileCategories', 'playerStatsMap'));
}
public function update(Request $request, Event $event): RedirectResponse
@@ -210,6 +215,58 @@ class EventController extends Controller
->with('success', __('admin.event_restored'));
}
public function updateStats(Request $request, Event $event): RedirectResponse
{
if (! $event->type->isGameType()) {
abort(404);
}
$request->validate([
'stats' => ['required', 'array'],
'stats.*.is_goalkeeper' => ['nullable'],
'stats.*.position' => ['nullable', 'string', \Illuminate\Validation\Rule::in(PlayerPosition::values())],
'stats.*.goalkeeper_saves' => ['nullable', 'integer', 'min:0', 'max:999'],
'stats.*.goalkeeper_shots' => ['nullable', 'integer', 'min:0', 'max:999'],
'stats.*.goals' => ['nullable', 'integer', 'min:0', 'max:999'],
'stats.*.shots' => ['nullable', 'integer', 'min:0', 'max:999'],
'stats.*.note' => ['nullable', 'string', 'max:500'],
]);
$stats = $request->input('stats', []);
foreach ($stats as $playerId => $data) {
$position = ! empty($data['position']) ? $data['position'] : null;
$isGk = $position === 'torwart' || ! empty($data['is_goalkeeper']);
$goals = isset($data['goals']) && $data['goals'] !== '' ? (int) $data['goals'] : null;
$shots = isset($data['shots']) && $data['shots'] !== '' ? (int) $data['shots'] : null;
$gkSaves = $isGk && isset($data['goalkeeper_saves']) && $data['goalkeeper_saves'] !== '' ? (int) $data['goalkeeper_saves'] : null;
$gkShots = $isGk && isset($data['goalkeeper_shots']) && $data['goalkeeper_shots'] !== '' ? (int) $data['goalkeeper_shots'] : null;
$note = ! empty($data['note']) ? trim($data['note']) : null;
// Leere Einträge löschen
if (! $isGk && $goals === null && $shots === null && $note === null && $position === null) {
EventPlayerStat::where('event_id', $event->id)->where('player_id', $playerId)->delete();
continue;
}
EventPlayerStat::updateOrCreate(
['event_id' => $event->id, 'player_id' => (int) $playerId],
[
'is_goalkeeper' => $isGk,
'position' => $position,
'goalkeeper_saves' => $gkSaves,
'goalkeeper_shots' => $gkShots,
'goals' => $goals,
'shots' => $shots,
'note' => $note,
]
);
}
return redirect()->route('admin.events.edit', $event)
->with('success', __('events.stats_saved'));
}
private function validateEvent(Request $request): array
{
$request->validate([

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin;
use App\Enums\PlayerPosition;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Event;
@@ -78,6 +79,7 @@ class PlayerController extends Controller
}],
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
'position' => ['nullable', 'string', \Illuminate\Validation\Rule::in(PlayerPosition::values())],
'is_active' => ['boolean'],
'photo_permission' => ['boolean'],
'notes' => ['nullable', 'string', 'max:2000'],
@@ -120,6 +122,7 @@ class PlayerController extends Controller
}],
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
'position' => ['nullable', 'string', \Illuminate\Validation\Rule::in(PlayerPosition::values())],
'photo_permission' => ['boolean'],
'notes' => ['nullable', 'string', 'max:2000'],
'profile_picture' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,gif,webp'],

View File

@@ -91,28 +91,35 @@ class SettingsController extends Controller
abort(403, 'Nur Admins koennen Einstellungen aendern.');
}
// Favicon-Upload verarbeiten (vor der normalen Settings-Schleife)
if ($request->hasFile('favicon')) {
$request->validate([
'favicon' => 'file|mimes:ico,png,svg,jpg,jpeg,gif,webp|max:512',
]);
// Bild-Uploads verarbeiten (vor der normalen Settings-Schleife)
$imageUploads = [
'favicon' => ['setting' => 'app_favicon', 'dir' => 'favicon', 'max' => 512],
'logo_login' => ['setting' => 'app_logo_login', 'dir' => 'logos', 'max' => 1024],
'logo_app' => ['setting' => 'app_logo_app', 'dir' => 'logos', 'max' => 1024],
];
// Altes Favicon löschen
$oldFavicon = Setting::get('app_favicon');
if ($oldFavicon) {
Storage::disk('public')->delete($oldFavicon);
}
foreach ($imageUploads as $field => $config) {
if ($request->hasFile($field)) {
$request->validate([
$field => 'file|mimes:ico,png,svg,jpg,jpeg,gif,webp|max:' . $config['max'],
]);
$file = $request->file('favicon');
$filename = Str::uuid() . '.' . $file->guessExtension();
$path = $file->storeAs('favicon', $filename, 'public');
Setting::set('app_favicon', $path);
} elseif ($request->has('remove_favicon')) {
$oldFavicon = Setting::get('app_favicon');
if ($oldFavicon) {
Storage::disk('public')->delete($oldFavicon);
$oldFile = Setting::get($config['setting']);
if ($oldFile) {
Storage::disk('public')->delete($oldFile);
}
$file = $request->file($field);
$filename = Str::uuid() . '.' . $file->guessExtension();
$path = $file->storeAs($config['dir'], $filename, 'public');
Setting::set($config['setting'], $path);
} elseif ($request->has("remove_{$field}")) {
$oldFile = Setting::get($config['setting']);
if ($oldFile) {
Storage::disk('public')->delete($oldFile);
}
Setting::set($config['setting'], null);
}
Setting::set('app_favicon', null);
}
$inputSettings = $request->input('settings', []);
@@ -310,6 +317,9 @@ class SettingsController extends Controller
// Löschreihenfolge beachtet FK-Constraints
DB::table('activity_logs')->delete();
DB::table('comments')->delete();
DB::table('event_player_stats')->delete();
DB::table('event_carpool_passengers')->delete();
DB::table('event_carpools')->delete();
DB::table('event_participants')->delete();
DB::table('event_catering')->delete();
DB::table('event_timekeepers')->delete();
@@ -364,6 +374,7 @@ class SettingsController extends Controller
Storage::disk('private')->deleteDirectory('files');
Storage::disk('public')->deleteDirectory('avatars');
Storage::disk('public')->deleteDirectory('favicon');
Storage::disk('public')->deleteDirectory('logos');
Storage::disk('public')->deleteDirectory('dsgvo');
// 2. FK-Constraints deaktivieren (DB-agnostisch)
@@ -376,7 +387,8 @@ class SettingsController extends Controller
// 3. Alle Tabellen leeren
$tables = [
'activity_logs', 'comments', 'event_participants',
'activity_logs', 'comments', 'event_player_stats',
'event_carpool_passengers', 'event_carpools', 'event_participants',
'event_catering', 'event_timekeepers', 'event_faq',
'event_file', 'events', 'parent_player', 'players',
'team_user', 'team_file', 'teams',

View File

@@ -6,15 +6,18 @@ use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Enums\ParticipantStatus;
use App\Enums\PlayerPosition;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\EventCatering;
use App\Models\EventParticipant;
use App\Models\EventPlayerStat;
use App\Models\EventTimekeeper;
use App\Models\Player;
use App\Models\Setting;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
@@ -113,6 +116,34 @@ class StatisticsController extends Controller
$gameIds = $games->pluck('id');
$totalGames = $games->count();
// Tore pro Spieler aus event_player_stats
$goalsByPlayer = EventPlayerStat::whereIn('event_id', $gameIds)
->select('player_id', DB::raw('COALESCE(SUM(goals), 0) as total_goals'))
->groupBy('player_id')
->pluck('total_goals', 'player_id');
// Häufigste Position pro Spieler aus event_player_stats
$positionCounts = EventPlayerStat::whereIn('event_id', $gameIds)
->whereNotNull('position')
->select('player_id', 'position', DB::raw('COUNT(*) as cnt'))
->groupBy('player_id', 'position')
->get()
->groupBy('player_id')
->map(fn ($g) => $g->sortByDesc('cnt')->first()->position);
// Aggregierte Performance-Daten pro Spieler
$playerAggStats = EventPlayerStat::whereIn('event_id', $gameIds)
->select(
'player_id',
DB::raw('COALESCE(SUM(goals), 0) as total_goals_agg'),
DB::raw('COALESCE(SUM(shots), 0) as total_shots_agg'),
DB::raw('COALESCE(SUM(goalkeeper_saves), 0) as total_gk_saves'),
DB::raw('COALESCE(SUM(goalkeeper_shots), 0) as total_gk_shots')
)
->groupBy('player_id')
->get()
->keyBy('player_id');
$playerRanking = collect();
if ($totalGames > 0) {
$playerRanking = EventParticipant::select('player_id', DB::raw('COUNT(*) as total_assigned'), DB::raw('SUM(CASE WHEN status = \'yes\' THEN 1 ELSE 0 END) as games_played'))
@@ -120,27 +151,67 @@ class StatisticsController extends Controller
->whereNotNull('player_id')
->groupBy('player_id')
->get()
->map(function ($row) use ($totalGames) {
->map(function ($row) use ($totalGames, $goalsByPlayer, $positionCounts, $playerAggStats) {
$player = Player::withTrashed()->find($row->player_id);
if (!$player) {
return null;
}
// Primäre Position: häufigste aus Stats, Fallback auf player.position
$primaryPosition = $positionCounts->get($row->player_id) ?? $player->position;
$isPrimaryGk = $primaryPosition?->isGoalkeeper() ?? false;
// Performance-Rate + Ampelfarbe berechnen
$agg = $playerAggStats->get($row->player_id);
$performanceRate = null;
$performanceColor = 'gray';
if ($agg) {
if ($isPrimaryGk) {
// Torwart: Fangquote
if ($agg->total_gk_shots > 0) {
$performanceRate = round(($agg->total_gk_saves / $agg->total_gk_shots) * 100, 1);
$performanceColor = $performanceRate >= 40 ? 'green' : ($performanceRate >= 25 ? 'yellow' : 'red');
}
} else {
// Feldspieler: Trefferquote
if ($agg->total_shots_agg > 0) {
$performanceRate = round(($agg->total_goals_agg / $agg->total_shots_agg) * 100, 1);
$performanceColor = $performanceRate >= 50 ? 'green' : ($performanceRate >= 30 ? 'yellow' : 'red');
}
}
}
return (object) [
'player' => $player,
'games_played' => (int) $row->games_played,
'total_assigned' => (int) $row->total_assigned,
'total_games' => $totalGames,
'total_goals' => (int) ($goalsByPlayer[$row->player_id] ?? 0),
'rate' => $row->total_assigned > 0
? round(($row->games_played / $row->total_assigned) * 100)
: 0,
'primary_position' => $primaryPosition,
'is_primary_gk' => $isPrimaryGk,
'performance_rate' => $performanceRate,
'performance_color' => $performanceColor,
];
})
->filter()
->sortByDesc('games_played')
->sortBy([
// Torwarte zuerst, dann Feldspieler
['is_primary_gk', 'desc'],
['games_played', 'desc'],
])
->values();
}
// Spielfeld-Aufstellung: Bester Spieler pro Position (meiste Spiele)
$courtPlayers = $playerRanking
->filter(fn ($e) => $e->primary_position !== null)
->groupBy(fn ($e) => $e->primary_position->value)
->map(fn ($group) => $group->sortByDesc('games_played')->first());
// ── Eltern-Engagement-Rangliste ────────────────────────
// Alle publizierten Events (nicht nur Spiele) mit gleichen Team/Datum-Filtern
$allEventsQuery = Event::where('status', EventStatus::Published);
@@ -202,8 +273,58 @@ class StatisticsController extends Controller
return view('admin.statistics.index', compact(
'games', 'teams', 'wins', 'losses', 'draws', 'winRate', 'totalWithScore',
'chartWinLoss', 'chartPlayerParticipation', 'chartParentInvolvement',
'playerRanking', 'totalGames',
'playerRanking', 'totalGames', 'courtPlayers',
'parentRanking', 'totalCateringEvents', 'totalTimekeeperEvents'
));
}
public function playerDetail(Player $player): JsonResponse
{
if (!Setting::isFeatureVisibleFor('statistics', auth()->user())) {
abort(403);
}
$stats = EventPlayerStat::where('player_id', $player->id)
->with(['event' => fn ($q) => $q->select('id', 'title', 'opponent', 'score_home', 'score_away', 'type', 'start_at', 'team_id')])
->whereHas('event', fn ($q) => $q->where('status', EventStatus::Published))
->get()
->sortByDesc(fn ($s) => $s->event->start_at)
->values();
$totalGoals = $stats->sum('goals');
$totalShots = $stats->sum('shots');
$gkGames = $stats->where('is_goalkeeper', true);
$totalGkSaves = $gkGames->sum('goalkeeper_saves');
$totalGkShots = $gkGames->sum('goalkeeper_shots');
return response()->json([
'player' => [
'name' => $player->full_name,
'avatar' => $player->getAvatarUrl(),
'initials' => $player->getInitials(),
'position' => $player->position?->label(),
],
'summary' => [
'total_goals' => $totalGoals,
'total_shots' => $totalShots,
'hit_rate' => $totalShots > 0 ? round(($totalGoals / $totalShots) * 100, 1) : null,
'gk_appearances' => $gkGames->count(),
'total_saves' => $totalGkSaves,
'total_gk_shots' => $totalGkShots,
'save_rate' => $totalGkShots > 0 ? round(($totalGkSaves / $totalGkShots) * 100, 1) : null,
],
'games' => $stats->map(fn ($s) => [
'date' => $s->event->start_at->format('d.m.Y'),
'opponent' => $s->event->opponent ?? '',
'score' => $s->event->score_home !== null ? $s->event->score_home . ':' . ($s->event->score_away ?? '?') : '',
'position' => $s->position?->shortLabel(),
'goals' => $s->goals,
'shots' => $s->shots,
'is_goalkeeper' => $s->is_goalkeeper,
'goalkeeper_saves' => $s->goalkeeper_saves,
'goalkeeper_shots' => $s->goalkeeper_shots,
'note' => $s->note,
])->toArray(),
]);
}
}

View File

@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers;
use App\Enums\EventStatus;
use App\Models\ActivityLog;
use App\Models\Event;
use App\Models\EventCarpool;
use App\Models\EventCarpoolPassenger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CarpoolController extends Controller
{
public function offer(Request $request, Event $event): RedirectResponse
{
$this->authorizeCarpool($event);
$request->validate([
'seats' => 'required|integer|min:1|max:9',
'note' => 'nullable|string|max:255',
]);
$carpool = EventCarpool::where('event_id', $event->id)
->where('user_id', auth()->id())
->first();
$isNew = !$carpool;
if (!$carpool) {
$carpool = new EventCarpool(['event_id' => $event->id, 'seats' => $request->seats, 'note' => $request->note]);
$carpool->user_id = auth()->id();
} else {
$carpool->seats = $request->seats;
$carpool->note = $request->note;
}
// Sitzplaetze duerfen nicht unter aktuelle Passagieranzahl fallen
if (!$isNew && $carpool->passengers()->count() > $request->seats) {
return redirect(route('events.show', $event) . '#carpool')
->withErrors(['seats' => __('events.carpool_seats_too_few')]);
}
$carpool->save();
ActivityLog::log(
$isNew ? 'created' : 'updated',
__('admin.log_carpool_offer', ['event' => $event->title, 'seats' => $request->seats]),
'Event',
$event->id
);
return redirect(route('events.show', $event) . '#carpool');
}
public function withdraw(Request $request, Event $event): RedirectResponse
{
$this->authorizeCarpool($event);
$carpool = EventCarpool::where('event_id', $event->id)
->where('user_id', auth()->id())
->firstOrFail();
$passengerCount = $carpool->passengers()->count();
$carpool->delete();
ActivityLog::log(
'deleted',
__('admin.log_carpool_withdrawn', ['event' => $event->title, 'passengers' => $passengerCount]),
'Event',
$event->id
);
return redirect(route('events.show', $event) . '#carpool');
}
public function join(Request $request, Event $event): RedirectResponse
{
$this->authorizeCarpool($event);
$request->validate([
'carpool_id' => 'required|integer',
'player_id' => 'required|integer',
]);
$carpool = EventCarpool::where('id', $request->carpool_id)
->where('event_id', $event->id)
->firstOrFail();
$user = auth()->user();
$player = $user->children()->where('players.id', $request->player_id)->first();
if (!$player && !$user->isAdmin()) {
abort(403);
}
// Pruefen ob noch Plaetze frei
if ($carpool->passengers()->count() >= $carpool->seats) {
return redirect(route('events.show', $event) . '#carpool')
->withErrors(['carpool' => __('events.carpool_full')]);
}
// Pruefen ob Spieler nicht bereits in dieser Fahrt
if (EventCarpoolPassenger::where('carpool_id', $carpool->id)->where('player_id', $request->player_id)->exists()) {
return redirect(route('events.show', $event) . '#carpool');
}
$passenger = new EventCarpoolPassenger(['carpool_id' => $carpool->id, 'player_id' => $request->player_id]);
$passenger->added_by = auth()->id();
$passenger->save();
$playerName = $player ? $player->full_name : "Spieler #{$request->player_id}";
ActivityLog::log(
'created',
__('admin.log_carpool_joined', ['player' => $playerName, 'driver' => $carpool->driver->name, 'event' => $event->title]),
'Event',
$event->id
);
return redirect(route('events.show', $event) . '#carpool');
}
public function leave(Request $request, Event $event): RedirectResponse
{
$this->authorizeCarpool($event);
$request->validate([
'carpool_id' => 'required|integer',
'player_id' => 'required|integer',
]);
$passenger = EventCarpoolPassenger::where('carpool_id', $request->carpool_id)
->where('player_id', $request->player_id)
->firstOrFail();
$user = auth()->user();
if ($passenger->added_by !== $user->id && !$user->isAdmin()) {
abort(403);
}
$carpool = $passenger->carpool;
$playerName = $passenger->player->full_name ?? "Spieler #{$request->player_id}";
$passenger->delete();
ActivityLog::log(
'deleted',
__('admin.log_carpool_left', ['player' => $playerName, 'driver' => $carpool->driver->name, 'event' => $event->title]),
'Event',
$event->id
);
return redirect(route('events.show', $event) . '#carpool');
}
private function authorizeCarpool(Event $event): void
{
$user = auth()->user();
if ($event->status === EventStatus::Cancelled) {
abort(403);
}
if (!$event->type->hasCarpool()) {
abort(403);
}
if (!$user->canAccessAdminPanel()) {
if ($event->status === EventStatus::Draft) {
abort(403);
}
if (!$user->accessibleTeamIds()->contains($event->team_id)) {
abort(403);
}
}
}
}

View File

@@ -67,7 +67,7 @@ class EventController extends Controller
}
// Kinder einmal laden, für Zugriffsprüfung + Teilnahme-Buttons
$userChildren = $user->children()->select('players.id', 'players.team_id')->get();
$userChildren = $user->children()->select('players.id', 'players.team_id', 'players.first_name', 'players.last_name')->get();
// Zugriffsbeschraenkung: User muss Zugang zum Team haben (ueber accessibleTeamIds)
if (!$user->canAccessAdminPanel()) {
@@ -90,6 +90,10 @@ class EventController extends Controller
if ($event->type->hasTimekeepers()) {
$relations[] = 'timekeepers.user';
}
if ($event->type->hasCarpool()) {
$relations[] = 'carpools.driver';
$relations[] = 'carpools.passengers.player';
}
$event->load($relations);
$userChildIds = $userChildren->pluck('id');
@@ -104,6 +108,11 @@ class EventController extends Controller
? $event->timekeepers->where('user_id', $user->id)->first()
: null;
// Eigene Fahrgemeinschaft
$myCarpool = $event->type->hasCarpool()
? $event->carpools->where('user_id', $user->id)->first()
: null;
// Catering/Zeitnehmer-Verlauf für Staff (chronologische Statusänderungen)
$cateringHistory = collect();
$timekeeperHistory = collect();
@@ -123,6 +132,6 @@ class EventController extends Controller
);
}
return view('events.show', compact('event', 'userChildIds', 'myCatering', 'myTimekeeper', 'cateringHistory', 'timekeeperHistory'));
return view('events.show', compact('event', 'userChildIds', 'userChildren', 'myCatering', 'myTimekeeper', 'myCarpool', 'cateringHistory', 'timekeeperHistory'));
}
}

View File

@@ -113,6 +113,16 @@ class Event extends Model
return $this->hasMany(EventTimekeeper::class);
}
public function carpools(): HasMany
{
return $this->hasMany(EventCarpool::class);
}
public function playerStats(): HasMany
{
return $this->hasMany(EventPlayerStat::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EventCarpool extends Model
{
protected $fillable = ['event_id', 'seats', 'note'];
protected $casts = [
'seats' => 'integer',
];
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function driver(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function passengers(): HasMany
{
return $this->hasMany(EventCarpoolPassenger::class, 'carpool_id');
}
public function remainingSeats(): int
{
return $this->seats - $this->passengers->count();
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventCarpoolPassenger extends Model
{
public $timestamps = false;
protected $fillable = ['carpool_id', 'player_id'];
protected static function booted(): void
{
static::creating(function (self $model) {
$model->created_at = $model->freshTimestamp();
});
}
public function carpool(): BelongsTo
{
return $this->belongsTo(EventCarpool::class, 'carpool_id');
}
public function player(): BelongsTo
{
return $this->belongsTo(Player::class);
}
public function addedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'added_by');
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Models;
use App\Enums\PlayerPosition;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventPlayerStat extends Model
{
protected $fillable = [
'event_id',
'player_id',
'is_goalkeeper',
'position',
'goalkeeper_saves',
'goalkeeper_shots',
'goals',
'shots',
'note',
];
protected $casts = [
'is_goalkeeper' => 'boolean',
'position' => PlayerPosition::class,
'goalkeeper_saves' => 'integer',
'goalkeeper_shots' => 'integer',
'goals' => 'integer',
'shots' => 'integer',
];
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function player(): BelongsTo
{
return $this->belongsTo(Player::class);
}
/**
* Fangquote in Prozent (nur für Torwart).
*/
public function saveRate(): ?float
{
if (! $this->is_goalkeeper || ! $this->goalkeeper_shots || $this->goalkeeper_shots === 0) {
return null;
}
return round(($this->goalkeeper_saves / $this->goalkeeper_shots) * 100, 1);
}
/**
* Trefferquote in Prozent.
*/
public function hitRate(): ?float
{
if (! $this->shots || $this->shots === 0) {
return null;
}
return round(($this->goals / $this->shots) * 100, 1);
}
/**
* Prüft ob der Eintrag leer ist (keine relevanten Daten).
*/
public function isEmpty(): bool
{
return ! $this->is_goalkeeper
&& ! $this->goalkeeper_saves
&& ! $this->goalkeeper_shots
&& ! $this->goals
&& ! $this->shots
&& ! $this->note;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\PlayerPosition;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -17,6 +18,7 @@ class Player extends Model
'last_name',
'birth_year',
'jersey_number',
'position',
'is_active',
'photo_permission',
'notes',
@@ -28,6 +30,7 @@ class Player extends Model
return [
'is_active' => 'boolean',
'photo_permission' => 'boolean',
'position' => PlayerPosition::class,
];
}

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('event_carpools', function (Blueprint $table) {
$table->id();
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->tinyInteger('seats')->unsigned();
$table->string('note', 255)->nullable();
$table->timestamps();
$table->unique(['event_id', 'user_id']);
});
Schema::create('event_carpool_passengers', function (Blueprint $table) {
$table->id();
$table->foreignId('carpool_id')->constrained('event_carpools')->cascadeOnDelete();
$table->foreignId('player_id')->constrained()->cascadeOnDelete();
$table->foreignId('added_by')->constrained('users')->cascadeOnDelete();
$table->timestamp('created_at')->nullable();
$table->unique(['carpool_id', 'player_id']);
});
}
public function down(): void
{
Schema::dropIfExists('event_carpool_passengers');
Schema::dropIfExists('event_carpools');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('event_player_stats', function (Blueprint $table) {
$table->id();
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
$table->foreignId('player_id')->constrained()->cascadeOnDelete();
$table->boolean('is_goalkeeper')->default(false);
$table->unsignedSmallInteger('goalkeeper_saves')->nullable();
$table->unsignedSmallInteger('goalkeeper_shots')->nullable();
$table->unsignedSmallInteger('goals')->nullable();
$table->unsignedSmallInteger('shots')->nullable();
$table->string('note', 500)->nullable();
$table->timestamps();
$table->unique(['event_id', 'player_id']);
});
}
public function down(): void
{
Schema::dropIfExists('event_player_stats');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('players', function (Blueprint $table) {
$table->string('position', 20)->nullable()->after('jersey_number');
});
Schema::table('event_player_stats', function (Blueprint $table) {
$table->string('position', 20)->nullable()->after('is_goalkeeper');
});
}
public function down(): void
{
Schema::table('players', function (Blueprint $table) {
$table->dropColumn('position');
});
Schema::table('event_player_stats', function (Blueprint $table) {
$table->dropColumn('position');
});
}
};

View File

@@ -6,12 +6,16 @@ use App\Enums\CateringStatus;
use App\Enums\EventStatus;
use App\Enums\EventType;
use App\Enums\ParticipantStatus;
use App\Enums\PlayerPosition;
use App\Enums\UserRole;
use App\Models\ActivityLog;
use App\Models\Comment;
use App\Models\Event;
use App\Models\EventCarpool;
use App\Models\EventCarpoolPassenger;
use App\Models\EventCatering;
use App\Models\EventParticipant;
use App\Models\EventPlayerStat;
use App\Models\EventTimekeeper;
use App\Models\Faq;
use App\Models\Location;
@@ -43,6 +47,8 @@ class DemoDataSeeder extends Seeder
$this->seedCatering($events, $parentUsers);
$this->seedTimekeepers($events, $parentUsers);
$this->seedComments($events, $admin, $coach, $parentUsers);
$this->seedPlayerStats($events, $players);
$this->seedCarpools($events, $parentUsers, $players);
$this->seedFaqs($admin);
$this->seedActivityLogs($admin, $coach, $team, $events);
$this->seedSoftDeletedRecords($team);
@@ -200,9 +206,27 @@ class DemoDataSeeder extends Seeder
private function seedPlayers(array $data, Team $team): array
{
// Positionsverteilung: Index → Position
$positions = [
0 => PlayerPosition::Torwart,
1 => PlayerPosition::LinksAussen,
2 => PlayerPosition::RueckraumMitte,
3 => PlayerPosition::RueckraumLinks,
4 => PlayerPosition::RechtsAussen,
5 => PlayerPosition::RueckraumRechts,
6 => PlayerPosition::Kreislaeufer,
7 => PlayerPosition::LinksAussen,
8 => PlayerPosition::RechtsAussen,
9 => PlayerPosition::RueckraumLinks,
10 => PlayerPosition::RueckraumMitte,
11 => PlayerPosition::RueckraumRechts,
12 => PlayerPosition::Kreislaeufer,
13 => PlayerPosition::Torwart, // Ersatz-TW
];
$players = [];
$jerseyNr = 1;
foreach ($data as [$childFirst, $lastName, $parents]) {
foreach ($data as $idx => [$childFirst, $lastName, $parents]) {
$player = Player::firstOrCreate(
['first_name' => $childFirst, 'last_name' => $lastName, 'team_id' => $team->id],
[
@@ -210,8 +234,13 @@ class DemoDataSeeder extends Seeder
'jersey_number' => $jerseyNr,
'is_active' => true,
'photo_permission' => true,
'position' => $positions[$idx] ?? null,
]
);
// Position auch bei bestehenden Spielern setzen
if ($player->position === null && isset($positions[$idx])) {
$player->update(['position' => $positions[$idx]]);
}
$players["{$childFirst} {$lastName}"] = $player;
$jerseyNr++;
}
@@ -601,6 +630,122 @@ class DemoDataSeeder extends Seeder
}
}
// ─── Spielerstatistiken ─────────────────────────────────
// Heimspiel (Index 2, Ergebnis 15:12) — Stats für zugesagte Spieler
private function seedPlayerStats(array $events, array $players): void
{
$homeGame = $events[2]; // Heimspiel vs. TSV Beispielburg (15:12)
$playerList = array_values($players);
// Nur die ersten 18 Spieler haben beim Heimspiel zugesagt (siehe seedParticipants)
$statsData = [
// [index, is_goalkeeper, gk_saves, gk_shots, goals, shots, note, position]
[0, true, 8, 20, 0, 0, 'Starke Leistung im Tor', 'torwart'],
[1, false, null, null, 3, 6, 'Drei Tempogegenstöße', 'links_aussen'],
[2, false, null, null, 2, 5, 'Guter Aufbau', 'rueckraum_mitte'],
[3, false, null, null, 2, 4, null, 'rueckraum_links'],
[4, false, null, null, 1, 3, 'Schnelle Beine', 'rechts_aussen'],
[5, false, null, null, 2, 5, null, 'rueckraum_rechts'],
[6, false, null, null, 1, 2, 'Erste Tore der Saison!', 'kreislaeufer'],
[7, false, null, null, 1, 4, null, 'links_aussen'],
[8, false, null, null, 1, 3, null, 'rechts_aussen'],
[9, false, null, null, 1, 2, null, 'rueckraum_links'],
[10, false, null, null, 1, 3, 'Stark in der Abwehr', 'rueckraum_mitte'],
[11, false, null, null, 0, 2, null, 'rueckraum_rechts'],
[12, false, null, null, 0, 1, null, 'kreislaeufer'],
[13, false, null, null, 0, 1, null, 'torwart'],
[14, false, null, null, 0, 2, 'Erste Spielminuten', null],
[15, false, null, null, 0, 0, null, null],
[16, false, null, null, 0, 1, null, null],
[17, false, null, null, 0, 0, null, null],
];
foreach ($statsData as [$idx, $isGk, $gkSaves, $gkShots, $goals, $shots, $note, $position]) {
if (!isset($playerList[$idx])) {
continue;
}
EventPlayerStat::updateOrCreate(
['event_id' => $homeGame->id, 'player_id' => $playerList[$idx]->id],
[
'is_goalkeeper' => $isGk,
'goalkeeper_saves' => $gkSaves,
'goalkeeper_shots' => $gkShots,
'goals' => $goals,
'shots' => $shots,
'note' => $note,
'position' => $position,
]
);
}
}
// ─── Fahrgemeinschaften ──────────────────────────────────
// Auswärtsspiel (Index 3) + Turnier (Index 4)
private function seedCarpools(array $events, array $parentUsers, array $players): void
{
$p = array_values($parentUsers);
$pl = array_values($players);
// Auswärtsspiel (Index 3): 2 Fahrgemeinschaften
$carpool1 = EventCarpool::where('event_id', $events[3]->id)->where('user_id', $p[0]->id)->first();
if (!$carpool1) {
$carpool1 = new EventCarpool(['event_id' => $events[3]->id, 'seats' => 3, 'note' => 'Treffpunkt Parkplatz, 9:30 Uhr']);
$carpool1->user_id = $p[0]->id;
$carpool1->save();
}
// 2 Kinder zuordnen
foreach ([0, 1] as $childIdx) {
if (isset($pl[$childIdx])) {
$pass = EventCarpoolPassenger::where('carpool_id', $carpool1->id)->where('player_id', $pl[$childIdx]->id)->first();
if (!$pass) {
$pass = new EventCarpoolPassenger(['carpool_id' => $carpool1->id, 'player_id' => $pl[$childIdx]->id]);
$pass->added_by = $p[0]->id;
$pass->save();
}
}
}
$carpool2 = EventCarpool::where('event_id', $events[3]->id)->where('user_id', $p[2]->id)->first();
if (!$carpool2) {
$carpool2 = new EventCarpool(['event_id' => $events[3]->id, 'seats' => 2, 'note' => 'Abfahrt 9:15 Uhr ab Vereinsheim']);
$carpool2->user_id = $p[2]->id;
$carpool2->save();
}
// Voll belegt — 2 Kinder
foreach ([2, 3] as $childIdx) {
if (isset($pl[$childIdx])) {
$pass = EventCarpoolPassenger::where('carpool_id', $carpool2->id)->where('player_id', $pl[$childIdx]->id)->first();
if (!$pass) {
$pass = new EventCarpoolPassenger(['carpool_id' => $carpool2->id, 'player_id' => $pl[$childIdx]->id]);
$pass->added_by = $p[2]->id;
$pass->save();
}
}
}
// Turnier (Index 4): 1 Fahrgemeinschaft
$carpool3 = EventCarpool::where('event_id', $events[4]->id)->where('user_id', $p[5]->id)->first();
if (!$carpool3) {
$carpool3 = new EventCarpool(['event_id' => $events[4]->id, 'seats' => 4, 'note' => 'Großer Van, Abfahrt 8:00 Uhr']);
$carpool3->user_id = $p[5]->id;
$carpool3->save();
}
// 1 Kind zugeordnet
if (isset($pl[5])) {
$pass = EventCarpoolPassenger::where('carpool_id', $carpool3->id)->where('player_id', $pl[5]->id)->first();
if (!$pass) {
$pass = new EventCarpoolPassenger(['carpool_id' => $carpool3->id, 'player_id' => $pl[5]->id]);
$pass->added_by = $p[5]->id;
$pass->save();
}
}
}
// ─── FAQs ──────────────────────────────────────────────
private function seedFaqs(User $admin): void

View File

@@ -307,6 +307,10 @@ return [
'log_participant_changed' => 'تم تغيير حالة المشاركة لـ ":event" إلى :status',
'log_catering_changed' => 'تم تغيير حالة التموين لـ ":event" إلى :status',
'log_timekeeper_changed' => 'تم تغيير حالة الميقاتي لـ ":event" إلى :status',
'log_carpool_offer' => 'تم عرض رحلة لـ ":event" (:seats مقاعد)',
'log_carpool_withdrawn' => 'تم سحب الرحلة لـ ":event" (تم إزالة :passengers ركاب)',
'log_carpool_joined' => ':player يركب مع :driver (الموعد: ":event")',
'log_carpool_left' => 'تم إزالة :player من رحلة :driver (الموعد: ":event")',
'log_comment_created' => 'تم إضافة تعليق إلى ":event"',
'log_comment_deleted' => 'تم حذف تعليق من ":event"',
'log_file_uploaded' => 'تم رفع الملف ":name"',
@@ -356,6 +360,15 @@ return [
'favicon_uploaded' => 'تم تحديث الأيقونة.',
'favicon_removed' => 'تم إزالة الأيقونة.',
// Logos
'logo_login_label' => 'شعار تسجيل الدخول',
'logo_login_desc' => 'يُعرض في صفحة تسجيل الدخول فوق اسم التطبيق.',
'logo_app_label' => 'شعار التطبيق (شريط التنقل)',
'logo_app_desc' => 'يُعرض في شريط التنقل بجانب اسم التطبيق.',
'logo_current' => 'الشعار الحالي',
'logo_remove' => 'إزالة الشعار',
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (الحد الأقصى 1 ميجابايت)',
// Undo / Revert
'log_revert' => 'تراجع',
'log_revert_confirm' => 'هل تريد حقاً التراجع عن هذا الإجراء؟',
@@ -546,4 +559,21 @@ return [
'mail_test_button' => 'اختبار الاتصال',
'mail_testing' => 'جاري اختبار الاتصال...',
'mail_test_success' => 'اتصال SMTP ناجح!',
// إحصائيات اللاعبين
'stats_player_detail' => 'تفاصيل اللاعب',
'stats_total_goals' => 'إجمالي الأهداف',
'stats_total_shots' => 'إجمالي التسديدات',
'stats_gk_appearances' => 'مباريات كحارس',
'stats_total_saves' => 'إجمالي التصديات',
'stats_close' => 'إغلاق',
'player_goals' => 'أهداف',
// المراكز والملعب
'position' => 'المركز',
'court_visualization' => 'تشكيلة الملعب',
'court_no_data' => 'لا توجد بيانات',
'performance_good' => 'جيد',
'performance_average' => 'متوسط',
'performance_below' => 'أقل من المتوسط',
];

View File

@@ -31,6 +31,25 @@ return [
'timekeeper_short' => 'ميقاتي',
'no_timekeeper_yet' => 'لم يتم تعيين ميقاتي بعد.',
'timekeeper_updated' => 'تم تحديث حالة الميقاتي.',
// مشاركة السيارات
'carpool' => 'مشاركة السيارات',
'carpool_offer' => 'عرض رحلة',
'carpool_update' => 'تحديث الرحلة',
'carpool_seats' => 'المقاعد المتاحة',
'carpool_seats_count' => ':free من :total مقاعد متاحة',
'carpool_seats_too_few' => 'عدد الركاب المسجلين أكبر من عدد المقاعد الجديد.',
'carpool_note_placeholder' => 'مثلاً نقطة اللقاء، وقت المغادرة...',
'carpool_withdraw' => 'سحب الرحلة',
'carpool_withdraw_confirm' => 'هل تريد حقاً سحب الرحلة؟ سيتم إزالة جميع الركاب.',
'carpool_join' => 'تعيين',
'carpool_leave' => 'إزالة',
'carpool_full' => 'جميع المقاعد مشغولة',
'no_carpool_yet' => 'لا توجد عروض مشاركة سيارات بعد.',
'carpool_my_offer' => 'رحلتي',
'carpool_driver' => 'السائق',
'carpool_passengers' => 'الركاب',
'comments' => 'التعليقات',
'comment_placeholder' => 'اكتب تعليقاً...',
'no_comments' => 'لا توجد تعليقات بعد.',
@@ -57,6 +76,22 @@ return [
'score_away' => 'الفريق الضيف',
'vs' => 'ضد',
// إحصائيات اللاعبين
'stats' => 'إحصائيات اللاعبين',
'stats_save' => 'حفظ الإحصائيات',
'stats_saved' => 'تم حفظ الإحصائيات.',
'stats_goalkeeper' => 'حارس',
'stats_goalkeeper_long' => 'حارس المرمى',
'stats_saves' => 'تصديات',
'stats_shots_on_goal' => 'تسديدات على المرمى',
'stats_goals' => 'أهداف',
'stats_shots' => 'تسديدات',
'stats_note' => 'ملاحظة',
'stats_hit_rate' => 'نسبة الإصابة',
'stats_save_rate' => 'نسبة التصدي',
'stats_no_data' => 'لا توجد بيانات إحصائية.',
'stats_position' => 'المركز',
// Staff visibility
'signed_up' => 'سجّل',
'withdrawn' => 'انسحب',

View File

@@ -82,6 +82,24 @@ return [
'parent_rep' => 'ممثل أولياء الأمور',
'user' => 'ولي أمر',
],
'player_position' => [
'torwart' => 'حارس مرمى',
'links_aussen' => 'جناح أيسر',
'rechts_aussen' => 'جناح أيمن',
'rueckraum_links' => 'ظهير أيسر',
'rueckraum_mitte' => 'صانع ألعاب',
'rueckraum_rechts' => 'ظهير أيمن',
'kreislaeufer' => 'دوّار',
],
'player_position_short' => [
'torwart' => 'حر',
'links_aussen' => 'جأ',
'rechts_aussen' => 'جي',
'rueckraum_links' => 'ظأ',
'rueckraum_mitte' => 'صأ',
'rueckraum_rechts' => 'ظي',
'kreislaeufer' => 'دو',
],
],
'locales' => [
'de' => 'Deutsch',

View File

@@ -338,6 +338,10 @@ return [
'log_participant_changed' => 'Teilnahme-Status für ":event" geändert auf :status',
'log_catering_changed' => 'Catering-Status für ":event" geändert auf :status',
'log_timekeeper_changed' => 'Zeitnehmer-Status für ":event" geändert auf :status',
'log_carpool_offer' => 'Fahrt für ":event" angeboten (:seats Plätze)',
'log_carpool_withdrawn' => 'Fahrt für ":event" zurückgezogen (:passengers Mitfahrer entfernt)',
'log_carpool_joined' => ':player fährt bei :driver mit (Event: ":event")',
'log_carpool_left' => ':player wurde von Fahrt bei :driver entfernt (Event: ":event")',
'log_comment_created' => 'Kommentar zu ":event" hinzugefügt',
'log_comment_deleted' => 'Kommentar zu ":event" gelöscht',
'log_file_uploaded' => 'Datei ":name" hochgeladen',
@@ -389,6 +393,15 @@ return [
'favicon_uploaded' => 'Favicon wurde aktualisiert.',
'favicon_removed' => 'Favicon wurde entfernt.',
// Logos
'logo_login_label' => 'Login-Logo',
'logo_login_desc' => 'Wird auf der Anmeldeseite oberhalb des App-Namens angezeigt.',
'logo_app_label' => 'App-Logo (Navigation)',
'logo_app_desc' => 'Wird in der Navigationsleiste neben dem App-Namen angezeigt.',
'logo_current' => 'Aktuelles Logo',
'logo_remove' => 'Logo entfernen',
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (max. 1 MB)',
// Undo / Revert
'log_revert' => 'Rückgängig',
'log_revert_confirm' => 'Möchtest du diese Aktion wirklich rückgängig machen?',
@@ -566,7 +579,7 @@ return [
// E-Mail Tab
'settings_tab_mail' => 'E-Mail',
'mail_config_title' => 'E-Mail-Konfiguration',
'mail_config_hint' => 'SMTP-Einstellungen fuer den E-Mail-Versand (z.B. Passwort-Reset, Benachrichtigungen).',
'mail_config_hint' => 'SMTP-Einstellungen für den E-Mail-Versand (z.B. Passwort-Reset, Benachrichtigungen).',
'mail_mailer_label' => 'Versandmethode',
'mail_log_mode' => 'Kein Versand (Log)',
'mail_host_label' => 'SMTP-Host',
@@ -575,11 +588,28 @@ return [
'mail_password_label' => 'Passwort',
'mail_from_address_label' => 'Absender-Adresse',
'mail_from_name_label' => 'Absender-Name',
'mail_encryption_label' => 'Verschluesselung',
'mail_encryption_label' => 'Verschlüsselung',
'mail_encryption_none' => 'Keine',
'mail_save' => 'Mail-Einstellungen speichern',
'mail_saved' => 'Mail-Einstellungen wurden gespeichert.',
'mail_test_button' => 'Verbindung testen',
'mail_testing' => 'Teste Verbindung...',
'mail_test_success' => 'SMTP-Verbindung erfolgreich!',
// Spielerstatistik
'stats_player_detail' => 'Spielerdetails',
'stats_total_goals' => 'Gesamttore',
'stats_total_shots' => 'Gesamtwürfe',
'stats_gk_appearances' => 'TW-Einsätze',
'stats_total_saves' => 'Gesamtparaden',
'stats_close' => 'Schließen',
'player_goals' => 'Tore',
// Positionen & Spielfeld
'position' => 'Position',
'court_visualization' => 'Spielfeld-Aufstellung',
'court_no_data' => 'Keine Daten',
'performance_good' => 'Gut',
'performance_average' => 'Mittel',
'performance_below' => 'Unterdurchschnittlich',
];

View File

@@ -40,6 +40,24 @@ return [
'no_timekeeper_yet' => 'Noch kein Zeitnehmer eingetragen.',
'timekeeper_updated' => 'Zeitnehmer-Status aktualisiert.',
// Fahrgemeinschaften
'carpool' => 'Fahrgemeinschaften',
'carpool_offer' => 'Fahrt anbieten',
'carpool_update' => 'Fahrt aktualisieren',
'carpool_seats' => 'Freie Plätze',
'carpool_seats_count' => ':free von :total Plätzen frei',
'carpool_seats_too_few' => 'Es sind bereits mehr Mitfahrer zugeordnet als die neue Sitzplatzanzahl.',
'carpool_note_placeholder' => 'z.B. Treffpunkt, Abfahrtszeit...',
'carpool_withdraw' => 'Fahrt zurückziehen',
'carpool_withdraw_confirm' => 'Fahrt wirklich zurückziehen? Alle Mitfahrer werden entfernt.',
'carpool_join' => 'Zuordnen',
'carpool_leave' => 'Entfernen',
'carpool_full' => 'Alle Plätze belegt',
'no_carpool_yet' => 'Noch keine Fahrgemeinschaften angeboten.',
'carpool_my_offer' => 'Meine Fahrt',
'carpool_driver' => 'Fahrer/in',
'carpool_passengers' => 'Mitfahrer',
// Kommentare
'comments' => 'Kommentare',
'comment_placeholder' => 'Kommentar schreiben...',
@@ -71,6 +89,22 @@ return [
'score_away' => 'Gast',
'vs' => 'vs.',
// Spielerstatistik
'stats' => 'Spielerstatistik',
'stats_save' => 'Statistik speichern',
'stats_saved' => 'Statistik gespeichert.',
'stats_goalkeeper' => 'TW',
'stats_goalkeeper_long' => 'Torwart',
'stats_saves' => 'Gehalten',
'stats_shots_on_goal' => 'Würfe aufs Tor',
'stats_goals' => 'Tore',
'stats_shots' => 'Torwürfe',
'stats_note' => 'Bemerkung',
'stats_hit_rate' => 'Trefferquote',
'stats_save_rate' => 'Fangquote',
'stats_no_data' => 'Keine Statistikdaten.',
'stats_position' => 'Position',
// Staff-Sichtbarkeit
'signed_up' => 'zugesagt',
'withdrawn' => 'abgemeldet',

View File

@@ -97,6 +97,24 @@ return [
'parent_rep' => 'Elternvertretung',
'user' => 'Elternteil',
],
'player_position' => [
'torwart' => 'Torwart',
'links_aussen' => 'Linksaußen',
'rechts_aussen' => 'Rechtsaußen',
'rueckraum_links' => 'Rückraum Links',
'rueckraum_mitte' => 'Rückraum Mitte',
'rueckraum_rechts' => 'Rückraum Rechts',
'kreislaeufer' => 'Kreisläufer',
],
'player_position_short' => [
'torwart' => 'TW',
'links_aussen' => 'LA',
'rechts_aussen' => 'RA',
'rueckraum_links' => 'RL',
'rueckraum_mitte' => 'RM',
'rueckraum_rechts' => 'RR',
'kreislaeufer' => 'KL',
],
],
// Sprachen

View File

@@ -306,6 +306,10 @@ return [
'log_participant_changed' => 'Participation status for ":event" changed to :status',
'log_catering_changed' => 'Catering status for ":event" changed to :status',
'log_timekeeper_changed' => 'Timekeeper status for ":event" changed to :status',
'log_carpool_offer' => 'Ride offered for ":event" (:seats seats)',
'log_carpool_withdrawn' => 'Ride for ":event" withdrawn (:passengers passengers removed)',
'log_carpool_joined' => ':player rides with :driver (Event: ":event")',
'log_carpool_left' => ':player removed from ride with :driver (Event: ":event")',
'log_comment_created' => 'Comment added to ":event"',
'log_comment_deleted' => 'Comment deleted from ":event"',
'log_file_uploaded' => 'File ":name" uploaded',
@@ -355,6 +359,15 @@ return [
'favicon_uploaded' => 'Favicon has been updated.',
'favicon_removed' => 'Favicon has been removed.',
// Logos
'logo_login_label' => 'Login Logo',
'logo_login_desc' => 'Displayed on the login page above the app name.',
'logo_app_label' => 'App Logo (Navigation)',
'logo_app_desc' => 'Displayed in the navigation bar next to the app name.',
'logo_current' => 'Current logo',
'logo_remove' => 'Remove logo',
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (max. 1 MB)',
// Undo / Revert
'log_revert' => 'Undo',
'log_revert_confirm' => 'Are you sure you want to undo this action?',
@@ -545,4 +558,21 @@ return [
'mail_test_button' => 'Test Connection',
'mail_testing' => 'Testing connection...',
'mail_test_success' => 'SMTP connection successful!',
// Player statistics
'stats_player_detail' => 'Player details',
'stats_total_goals' => 'Total goals',
'stats_total_shots' => 'Total shots',
'stats_gk_appearances' => 'GK appearances',
'stats_total_saves' => 'Total saves',
'stats_close' => 'Close',
'player_goals' => 'Goals',
// Positions & Court
'position' => 'Position',
'court_visualization' => 'Court Formation',
'court_no_data' => 'No data',
'performance_good' => 'Good',
'performance_average' => 'Average',
'performance_below' => 'Below average',
];

View File

@@ -30,6 +30,25 @@ return [
'timekeeper_short' => 'Timekeeper',
'no_timekeeper_yet' => 'No timekeeper assigned yet.',
'timekeeper_updated' => 'Timekeeper status updated.',
// Carpooling
'carpool' => 'Carpooling',
'carpool_offer' => 'Offer a ride',
'carpool_update' => 'Update ride',
'carpool_seats' => 'Available seats',
'carpool_seats_count' => ':free of :total seats available',
'carpool_seats_too_few' => 'There are already more passengers assigned than the new seat count.',
'carpool_note_placeholder' => 'e.g. meeting point, departure time...',
'carpool_withdraw' => 'Withdraw ride',
'carpool_withdraw_confirm' => 'Really withdraw ride? All passengers will be removed.',
'carpool_join' => 'Assign',
'carpool_leave' => 'Remove',
'carpool_full' => 'All seats taken',
'no_carpool_yet' => 'No carpooling offers yet.',
'carpool_my_offer' => 'My ride',
'carpool_driver' => 'Driver',
'carpool_passengers' => 'Passengers',
'comments' => 'Comments',
'comment_placeholder' => 'Write a comment...',
'no_comments' => 'No comments yet.',
@@ -56,6 +75,22 @@ return [
'score_away' => 'Away',
'vs' => 'vs.',
// Player statistics
'stats' => 'Player Statistics',
'stats_save' => 'Save statistics',
'stats_saved' => 'Statistics saved.',
'stats_goalkeeper' => 'GK',
'stats_goalkeeper_long' => 'Goalkeeper',
'stats_saves' => 'Saves',
'stats_shots_on_goal' => 'Shots on goal',
'stats_goals' => 'Goals',
'stats_shots' => 'Shots',
'stats_note' => 'Note',
'stats_hit_rate' => 'Hit rate',
'stats_save_rate' => 'Save rate',
'stats_no_data' => 'No statistics data.',
'stats_position' => 'Position',
// Staff visibility
'signed_up' => 'signed up',
'withdrawn' => 'withdrawn',

View File

@@ -81,6 +81,24 @@ return [
'parent_rep' => 'Parent Representative',
'user' => 'Parent',
],
'player_position' => [
'torwart' => 'Goalkeeper',
'links_aussen' => 'Left Wing',
'rechts_aussen' => 'Right Wing',
'rueckraum_links' => 'Left Back',
'rueckraum_mitte' => 'Centre Back',
'rueckraum_rechts' => 'Right Back',
'kreislaeufer' => 'Pivot',
],
'player_position_short' => [
'torwart' => 'GK',
'links_aussen' => 'LW',
'rechts_aussen' => 'RW',
'rueckraum_links' => 'LB',
'rueckraum_mitte' => 'CB',
'rueckraum_rechts' => 'RB',
'kreislaeufer' => 'PV',
],
],
'locales' => [
'de' => 'Deutsch',

View File

@@ -307,6 +307,10 @@ return [
'log_participant_changed' => 'Status uczestnictwa dla ":event" zmieniony na :status',
'log_catering_changed' => 'Status cateringu dla ":event" zmieniony na :status',
'log_timekeeper_changed' => 'Status chronometrażysty dla ":event" zmieniony na :status',
'log_carpool_offer' => 'Przejazd dla ":event" zaoferowany (:seats miejsc)',
'log_carpool_withdrawn' => 'Przejazd dla ":event" wycofany (:passengers pasażerów usuniętych)',
'log_carpool_joined' => ':player jedzie z :driver (Termin: ":event")',
'log_carpool_left' => ':player usunięty z przejazdu z :driver (Termin: ":event")',
'log_comment_created' => 'Dodano komentarz do ":event"',
'log_comment_deleted' => 'Usunięto komentarz z ":event"',
'log_file_uploaded' => 'Plik ":name" przesłany',
@@ -356,6 +360,15 @@ return [
'favicon_uploaded' => 'Ikona została zaktualizowana.',
'favicon_removed' => 'Ikona została usunięta.',
// Logos
'logo_login_label' => 'Logo logowania',
'logo_login_desc' => 'Wyświetlane na stronie logowania nad nazwą aplikacji.',
'logo_app_label' => 'Logo aplikacji (nawigacja)',
'logo_app_desc' => 'Wyświetlane na pasku nawigacji obok nazwy aplikacji.',
'logo_current' => 'Aktualne logo',
'logo_remove' => 'Usuń logo',
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (maks. 1 MB)',
// Undo / Revert
'log_revert' => 'Cofnij',
'log_revert_confirm' => 'Czy na pewno chcesz cofnąć tę akcję?',
@@ -546,4 +559,21 @@ return [
'mail_test_button' => 'Testuj połączenie',
'mail_testing' => 'Testowanie połączenia...',
'mail_test_success' => 'Połączenie SMTP udane!',
// Statystyki graczy
'stats_player_detail' => 'Szczegóły gracza',
'stats_total_goals' => 'Łączne bramki',
'stats_total_shots' => 'Łączne rzuty',
'stats_gk_appearances' => 'Występy jako bramkarz',
'stats_total_saves' => 'Łączne obrony',
'stats_close' => 'Zamknij',
'player_goals' => 'Bramki',
// Pozycje i boisko
'position' => 'Pozycja',
'court_visualization' => 'Ustawienie na boisku',
'court_no_data' => 'Brak danych',
'performance_good' => 'Dobrze',
'performance_average' => 'Średnio',
'performance_below' => 'Poniżej średniej',
];

View File

@@ -31,6 +31,25 @@ return [
'timekeeper_short' => 'Sędzia czasu',
'no_timekeeper_yet' => 'Brak przypisanego chronometrażysty.',
'timekeeper_updated' => 'Status chronometrażysty zaktualizowany.',
// Wspólne przejazdy
'carpool' => 'Wspólne przejazdy',
'carpool_offer' => 'Zaoferuj przejazd',
'carpool_update' => 'Zaktualizuj przejazd',
'carpool_seats' => 'Wolne miejsca',
'carpool_seats_count' => ':free z :total miejsc wolnych',
'carpool_seats_too_few' => 'Przypisano już więcej pasażerów niż nowa liczba miejsc.',
'carpool_note_placeholder' => 'np. miejsce spotkania, godzina odjazdu...',
'carpool_withdraw' => 'Wycofaj przejazd',
'carpool_withdraw_confirm' => 'Naprawdę wycofać przejazd? Wszyscy pasażerowie zostaną usunięci.',
'carpool_join' => 'Przypisz',
'carpool_leave' => 'Usuń',
'carpool_full' => 'Wszystkie miejsca zajęte',
'no_carpool_yet' => 'Brak ofert wspólnych przejazdów.',
'carpool_my_offer' => 'Mój przejazd',
'carpool_driver' => 'Kierowca',
'carpool_passengers' => 'Pasażerowie',
'comments' => 'Komentarze',
'comment_placeholder' => 'Napisz komentarz...',
'no_comments' => 'Brak komentarzy.',
@@ -57,6 +76,22 @@ return [
'score_away' => 'Goście',
'vs' => 'vs.',
// Statystyki graczy
'stats' => 'Statystyki graczy',
'stats_save' => 'Zapisz statystyki',
'stats_saved' => 'Statystyki zapisane.',
'stats_goalkeeper' => 'BR',
'stats_goalkeeper_long' => 'Bramkarz',
'stats_saves' => 'Obrony',
'stats_shots_on_goal' => 'Strzały na bramkę',
'stats_goals' => 'Bramki',
'stats_shots' => 'Rzuty',
'stats_note' => 'Uwaga',
'stats_hit_rate' => 'Skuteczność',
'stats_save_rate' => 'Skuteczność obron',
'stats_no_data' => 'Brak danych statystycznych.',
'stats_position' => 'Pozycja',
// Staff visibility
'signed_up' => 'zapisany',
'withdrawn' => 'wypisany',

View File

@@ -82,6 +82,24 @@ return [
'parent_rep' => 'Przedstawiciel rodziców',
'user' => 'Rodzic',
],
'player_position' => [
'torwart' => 'Bramkarz',
'links_aussen' => 'Lewe skrzydło',
'rechts_aussen' => 'Prawe skrzydło',
'rueckraum_links' => 'Lewy rozgrywający',
'rueckraum_mitte' => 'Środkowy rozgrywający',
'rueckraum_rechts' => 'Prawy rozgrywający',
'kreislaeufer' => 'Kołowy',
],
'player_position_short' => [
'torwart' => 'BR',
'links_aussen' => 'LS',
'rechts_aussen' => 'PS',
'rueckraum_links' => 'LR',
'rueckraum_mitte' => 'ŚR',
'rueckraum_rechts' => 'PR',
'kreislaeufer' => 'KO',
],
],
'locales' => [
'de' => 'Deutsch',

View File

@@ -325,6 +325,10 @@ return [
'log_participant_changed' => 'Статус участия для ":event" изменён на :status',
'log_catering_changed' => 'Статус кейтеринга для ":event" изменён на :status',
'log_timekeeper_changed' => 'Статус хронометриста для ":event" изменён на :status',
'log_carpool_offer' => 'Поездка для ":event" предложена (:seats мест)',
'log_carpool_withdrawn' => 'Поездка для ":event" отменена (:passengers пассажиров удалено)',
'log_carpool_joined' => ':player едет с :driver (Мероприятие: ":event")',
'log_carpool_left' => ':player удалён из поездки с :driver (Мероприятие: ":event")',
'log_comment_created' => 'Комментарий добавлен к ":event"',
'log_comment_deleted' => 'Комментарий удалён из ":event"',
'log_file_uploaded' => 'Файл ":name" загружен',
@@ -374,6 +378,15 @@ return [
'favicon_uploaded' => 'Фавикон обновлён.',
'favicon_removed' => 'Фавикон удалён.',
// Logos
'logo_login_label' => 'Логотип входа',
'logo_login_desc' => 'Отображается на странице входа над названием приложения.',
'logo_app_label' => 'Логотип приложения (навигация)',
'logo_app_desc' => 'Отображается в панели навигации рядом с названием приложения.',
'logo_current' => 'Текущий логотип',
'logo_remove' => 'Удалить логотип',
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (макс. 1 МБ)',
// Undo / Revert
'log_revert' => 'Отменить',
'log_revert_confirm' => 'Вы действительно хотите отменить это действие?',
@@ -564,4 +577,21 @@ return [
'mail_test_button' => 'Проверить соединение',
'mail_testing' => 'Проверка соединения...',
'mail_test_success' => 'SMTP-соединение успешно!',
// Статистика игроков
'stats_player_detail' => 'Детали игрока',
'stats_total_goals' => 'Всего голов',
'stats_total_shots' => 'Всего бросков',
'stats_gk_appearances' => 'Игры вратарём',
'stats_total_saves' => 'Всего отражений',
'stats_close' => 'Закрыть',
'player_goals' => 'Голы',
// Позиции и площадка
'position' => 'Позиция',
'court_visualization' => 'Расстановка на площадке',
'court_no_data' => 'Нет данных',
'performance_good' => 'Хорошо',
'performance_average' => 'Средне',
'performance_below' => 'Ниже среднего',
];

View File

@@ -40,6 +40,24 @@ return [
'no_timekeeper_yet' => 'Хронометрист ещё не назначен.',
'timekeeper_updated' => 'Статус хронометриста обновлён.',
// Совместные поездки
'carpool' => 'Совместные поездки',
'carpool_offer' => 'Предложить поездку',
'carpool_update' => 'Обновить поездку',
'carpool_seats' => 'Свободные места',
'carpool_seats_count' => ':free из :total мест свободно',
'carpool_seats_too_few' => 'Пассажиров уже больше, чем новое количество мест.',
'carpool_note_placeholder' => 'напр. место встречи, время отправления...',
'carpool_withdraw' => 'Отменить поездку',
'carpool_withdraw_confirm' => 'Действительно отменить поездку? Все пассажиры будут удалены.',
'carpool_join' => 'Назначить',
'carpool_leave' => 'Удалить',
'carpool_full' => 'Все места заняты',
'no_carpool_yet' => 'Пока нет предложений по совместным поездкам.',
'carpool_my_offer' => 'Моя поездка',
'carpool_driver' => 'Водитель',
'carpool_passengers' => 'Пассажиры',
// Комментарии
'comments' => 'Комментарии',
'comment_placeholder' => 'Написать комментарий...',
@@ -71,6 +89,22 @@ return [
'score_away' => 'Гости',
'vs' => 'против',
// Статистика игроков
'stats' => 'Статистика игроков',
'stats_save' => 'Сохранить статистику',
'stats_saved' => 'Статистика сохранена.',
'stats_goalkeeper' => 'ВР',
'stats_goalkeeper_long' => 'Вратарь',
'stats_saves' => 'Отражено',
'stats_shots_on_goal' => 'Броски по воротам',
'stats_goals' => 'Голы',
'stats_shots' => 'Броски',
'stats_note' => 'Примечание',
'stats_hit_rate' => 'Процент попаданий',
'stats_save_rate' => 'Процент отражений',
'stats_no_data' => 'Нет данных статистики.',
'stats_position' => 'Позиция',
// Staff visibility
'signed_up' => 'записался',
'withdrawn' => 'отказался',

View File

@@ -82,6 +82,24 @@ return [
'parent_rep' => 'Представитель родителей',
'user' => 'Родитель',
],
'player_position' => [
'torwart' => 'Вратарь',
'links_aussen' => 'Левый крайний',
'rechts_aussen' => 'Правый крайний',
'rueckraum_links' => 'Левый полусредний',
'rueckraum_mitte' => 'Центральный разыгрывающий',
'rueckraum_rechts' => 'Правый полусредний',
'kreislaeufer' => 'Линейный',
],
'player_position_short' => [
'torwart' => 'ВР',
'links_aussen' => 'ЛК',
'rechts_aussen' => 'ПК',
'rueckraum_links' => 'ЛП',
'rueckraum_mitte' => 'ЦР',
'rueckraum_rechts' => 'ПП',
'kreislaeufer' => 'ЛН',
],
],
'locales' => [
'de' => 'Deutsch',

View File

@@ -325,6 +325,10 @@ return [
'log_participant_changed' => '":event" için katılım durumu :status olarak değiştirildi',
'log_catering_changed' => '":event" için ikram durumu :status olarak değiştirildi',
'log_timekeeper_changed' => '":event" için zaman tutucu durumu :status olarak değiştirildi',
'log_carpool_offer' => '":event" için yolculuk teklif edildi (:seats koltuk)',
'log_carpool_withdrawn' => '":event" için yolculuk geri çekildi (:passengers yolcu kaldırıldı)',
'log_carpool_joined' => ':player, :driver ile gidiyor (Etkinlik: ":event")',
'log_carpool_left' => ':player, :driver ile yolculuktan kaldırıldı (Etkinlik: ":event")',
'log_comment_created' => '":event" için yorum eklendi',
'log_comment_deleted' => '":event" için yorum silindi',
'log_file_uploaded' => '":name" dosyası yüklendi',
@@ -374,6 +378,15 @@ return [
'favicon_uploaded' => 'Favicon güncellendi.',
'favicon_removed' => 'Favicon kaldırıldı.',
// Logos
'logo_login_label' => 'Giriş Logosu',
'logo_login_desc' => 'Giriş sayfasında uygulama adının üstünde görüntülenir.',
'logo_app_label' => 'Uygulama Logosu (Gezinme)',
'logo_app_desc' => 'Gezinme çubuğunda uygulama adının yanında görüntülenir.',
'logo_current' => 'Mevcut logo',
'logo_remove' => 'Logoyu kaldır',
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (maks. 1 MB)',
// Undo / Revert
'log_revert' => 'Geri al',
'log_revert_confirm' => 'Bu işlemi gerçekten geri almak istiyor musunuz?',
@@ -564,4 +577,21 @@ return [
'mail_test_button' => 'Bağlantıyı Test Et',
'mail_testing' => 'Bağlantı test ediliyor...',
'mail_test_success' => 'SMTP bağlantısı başarılı!',
// Oyuncu istatistikleri
'stats_player_detail' => 'Oyuncu detayları',
'stats_total_goals' => 'Toplam goller',
'stats_total_shots' => 'Toplam atışlar',
'stats_gk_appearances' => 'Kaleci maçları',
'stats_total_saves' => 'Toplam kurtarışlar',
'stats_close' => 'Kapat',
'player_goals' => 'Goller',
// Pozisyonlar ve Saha
'position' => 'Pozisyon',
'court_visualization' => 'Saha Dizilişi',
'court_no_data' => 'Veri yok',
'performance_good' => 'İyi',
'performance_average' => 'Orta',
'performance_below' => 'Ortalamanın altı',
];

View File

@@ -31,6 +31,25 @@ return [
'timekeeper_short' => 'Zaman Tutucusu',
'no_timekeeper_yet' => 'Henüz zaman tutucusu atanmadı.',
'timekeeper_updated' => 'Zaman tutucusu durumu güncellendi.',
// Araç paylaşımı
'carpool' => 'Araç Paylaşımı',
'carpool_offer' => 'Yolculuk teklif et',
'carpool_update' => 'Yolculuğu güncelle',
'carpool_seats' => 'Boş koltuklar',
'carpool_seats_count' => ':free / :total koltuk boş',
'carpool_seats_too_few' => 'Zaten yeni koltuk sayısından fazla yolcu atanmış.',
'carpool_note_placeholder' => 'örn. buluşma noktası, kalkış saati...',
'carpool_withdraw' => 'Yolculuğu geri çek',
'carpool_withdraw_confirm' => 'Yolculuğu gerçekten geri çekmek istiyor musunuz? Tüm yolcular kaldırılacak.',
'carpool_join' => 'Ata',
'carpool_leave' => 'Kaldır',
'carpool_full' => 'Tüm koltuklar dolu',
'no_carpool_yet' => 'Henüz araç paylaşımı teklifi yok.',
'carpool_my_offer' => 'Benim yolculuğum',
'carpool_driver' => 'Sürücü',
'carpool_passengers' => 'Yolcular',
'comments' => 'Yorumlar',
'comment_placeholder' => 'Yorum yaz...',
'no_comments' => 'Henüz yorum yok.',
@@ -57,6 +76,22 @@ return [
'score_away' => 'Deplasman',
'vs' => 'vs.',
// Oyuncu istatistikleri
'stats' => 'Oyuncu İstatistikleri',
'stats_save' => 'İstatistikleri kaydet',
'stats_saved' => 'İstatistikler kaydedildi.',
'stats_goalkeeper' => 'KL',
'stats_goalkeeper_long' => 'Kaleci',
'stats_saves' => 'Kurtarışlar',
'stats_shots_on_goal' => 'Kaleye atışlar',
'stats_goals' => 'Goller',
'stats_shots' => 'Atışlar',
'stats_note' => 'Not',
'stats_hit_rate' => 'İsabet oranı',
'stats_save_rate' => 'Kurtarış oranı',
'stats_no_data' => 'İstatistik verisi yok.',
'stats_position' => 'Pozisyon',
// Staff visibility
'signed_up' => 'kaydoldu',
'withdrawn' => 'çekildi',

View File

@@ -82,6 +82,24 @@ return [
'parent_rep' => 'Veli Temsilcisi',
'user' => 'Veli',
],
'player_position' => [
'torwart' => 'Kaleci',
'links_aussen' => 'Sol Kanat',
'rechts_aussen' => 'Sağ Kanat',
'rueckraum_links' => 'Sol Çapraz',
'rueckraum_mitte' => 'Orta Çapraz',
'rueckraum_rechts' => 'Sağ Çapraz',
'kreislaeufer' => 'Pivot',
],
'player_position_short' => [
'torwart' => 'KL',
'links_aussen' => 'SK',
'rechts_aussen' => 'SĞK',
'rueckraum_links' => 'SÇ',
'rueckraum_mitte' => 'OÇ',
'rueckraum_rechts' => 'SĞÇ',
'kreislaeufer' => 'PV',
],
],
'locales' => [
'de' => 'Deutsch',

View File

@@ -357,6 +357,106 @@
</div>
@endif
{{-- Spielerstatistik (nur Spieltypen mit zugesagten Spielern) --}}
@if ($event->type->isGameType())
@php
$confirmedPlayers = $event->participants
->where('status', \App\Enums\ParticipantStatus::Yes)
->whereNotNull('player_id')
->sortBy(fn($p) => $p->player->last_name ?? '');
@endphp
@if ($confirmedPlayers->isNotEmpty())
<div class="bg-white rounded-lg shadow p-6 max-w-4xl mt-6">
<h2 class="text-lg font-semibold mb-1">{{ __('events.stats') }}</h2>
<p class="text-xs text-gray-500 mb-4">{{ __('events.stats_confirmed_only') }}</p>
<form method="POST" action="{{ route('admin.events.update-stats', $event) }}">
@csrf
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="text-left px-2 py-2 font-medium text-gray-600">{{ __('admin.nav_players') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-28">{{ __('events.stats_position') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-10" title="{{ __('events.stats_goalkeeper_long') }}">{{ __('events.stats_goalkeeper') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_shots_on_goal') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_saves') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_shots') }}</th>
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_goals') }}</th>
<th class="text-left px-2 py-2 font-medium text-gray-600">{{ __('events.stats_note') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach ($confirmedPlayers as $participant)
@php
$pid = $participant->player_id;
$stat = $playerStatsMap[$pid] ?? null;
@endphp
<tr x-data="{ isGk: {{ $stat && $stat->is_goalkeeper ? 'true' : 'false' }} }">
<td class="px-2 py-2 font-medium text-gray-900 whitespace-nowrap">
{{ $participant->player->full_name }}
</td>
<td class="px-2 py-2">
<select name="stats[{{ $pid }}][position]"
class="w-full px-1 py-1 border border-gray-300 rounded text-sm"
@change="isGk = ($event.target.value === 'torwart')">
<option value=""></option>
@foreach (\App\Enums\PlayerPosition::cases() as $pos)
<option value="{{ $pos->value }}"
{{ ($stat?->position?->value ?? $participant->player->position?->value) === $pos->value ? 'selected' : '' }}>
{{ $pos->shortLabel() }}
</option>
@endforeach
</select>
</td>
<td class="px-2 py-2 text-center">
<input type="checkbox" name="stats[{{ $pid }}][is_goalkeeper]" value="1"
x-model="isGk"
class="rounded border-gray-300 text-blue-600">
</td>
<td class="px-2 py-2 text-center" x-show="isGk" x-cloak>
<input type="number" name="stats[{{ $pid }}][goalkeeper_shots]" min="0" max="999"
value="{{ $stat?->goalkeeper_shots }}"
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center" x-show="!isGk"><span class="text-gray-300"></span></td>
<td class="px-2 py-2 text-center" x-show="isGk" x-cloak>
<input type="number" name="stats[{{ $pid }}][goalkeeper_saves]" min="0" max="999"
value="{{ $stat?->goalkeeper_saves }}"
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center" x-show="!isGk"><span class="text-gray-300"></span></td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][shots]" min="0" max="999"
value="{{ $stat?->shots }}"
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2 text-center">
<input type="number" name="stats[{{ $pid }}][goals]" min="0" max="999"
value="{{ $stat?->goals }}"
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
</td>
<td class="px-2 py-2">
<input type="text" name="stats[{{ $pid }}][note]" maxlength="500"
value="{{ $stat?->note }}"
placeholder="{{ __('events.stats_note') }}..."
class="w-full px-2 py-1 border border-gray-300 rounded text-sm">
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="mt-4">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('events.stats_save') }}
</button>
</div>
</form>
</div>
@endif
@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>

View File

@@ -44,6 +44,16 @@
</div>
</div>
<div class="mb-4">
<label for="position" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.position') }}</label>
<select name="position" id="position" class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">{{ __('admin.please_select') }}</option>
@foreach (\App\Enums\PlayerPosition::cases() as $pos)
<option value="{{ $pos->value }}" {{ old('position') === $pos->value ? 'selected' : '' }}>{{ $pos->label() }}</option>
@endforeach
</select>
</div>
<div class="mb-4 space-y-2">
<label class="flex items-center">
<input type="hidden" name="is_active" value="0">

View File

@@ -74,6 +74,16 @@
</div>
</div>
<div class="mb-4">
<label for="position" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.position') }}</label>
<select name="position" id="position" class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">{{ __('admin.please_select') }}</option>
@foreach (\App\Enums\PlayerPosition::cases() as $pos)
<option value="{{ $pos->value }}" {{ old('position', $player->position?->value) === $pos->value ? 'selected' : '' }}>{{ $pos->label() }}</option>
@endforeach
</select>
</div>
<div class="mb-4">
<label class="flex items-center">
<input type="hidden" name="photo_permission" value="0">

View File

@@ -95,6 +95,50 @@
<p class="mt-1.5 text-xs text-gray-400">{{ __('admin.favicon_hint') }}</p>
</div>
{{-- Logo Login --}}
<div class="bg-white rounded-lg shadow p-6 mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-1">{{ __('admin.logo_login_label') }}</label>
<p class="text-xs text-gray-400 mb-3">{{ __('admin.logo_login_desc') }}</p>
@php $currentLogoLogin = \App\Models\Setting::get('app_logo_login'); @endphp
@if ($currentLogoLogin)
<div class="flex items-center gap-4 mb-3 p-3 bg-gray-50 rounded-md border border-gray-200">
<img src="{{ asset('storage/' . $currentLogoLogin) }}" alt="Login-Logo" class="h-16 max-w-[200px] object-contain">
<div class="flex flex-col gap-1">
<span class="text-sm text-gray-500">{{ __('admin.logo_current') }}</span>
<label class="flex items-center gap-1.5 text-sm text-red-600 cursor-pointer">
<input type="checkbox" name="remove_logo_login" value="1" class="rounded border-gray-300">
{{ __('admin.logo_remove') }}
</label>
</div>
</div>
@endif
<input type="file" name="logo_login" accept=".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.logo_hint') }}</p>
</div>
{{-- Logo App (Navbar) --}}
<div class="bg-white rounded-lg shadow p-6 mb-6">
<label class="block text-sm font-semibold text-gray-700 mb-1">{{ __('admin.logo_app_label') }}</label>
<p class="text-xs text-gray-400 mb-3">{{ __('admin.logo_app_desc') }}</p>
@php $currentLogoApp = \App\Models\Setting::get('app_logo_app'); @endphp
@if ($currentLogoApp)
<div class="flex items-center gap-4 mb-3 p-3 bg-gray-50 rounded-md border border-gray-200">
<img src="{{ asset('storage/' . $currentLogoApp) }}" alt="App-Logo" class="h-10 max-w-[200px] object-contain">
<div class="flex flex-col gap-1">
<span class="text-sm text-gray-500">{{ __('admin.logo_current') }}</span>
<label class="flex items-center gap-1.5 text-sm text-red-600 cursor-pointer">
<input type="checkbox" name="remove_logo_app" value="1" class="rounded border-gray-300">
{{ __('admin.logo_remove') }}
</label>
</div>
</div>
@endif
<input type="file" name="logo_app" accept=".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.logo_hint') }}</p>
</div>
{{-- Richtext-Settings (Slogan mit Mini-Quill) --}}
@foreach ($settings as $key => $setting)
@if ($setting->type === 'richtext')

View File

@@ -142,6 +142,7 @@
{{-- Spieler-Rangliste (nur Staff) --}}
@if (auth()->user()->isStaff() && $playerRanking->isNotEmpty())
<div x-data="playerDetailModal()">
<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>
@@ -153,15 +154,23 @@
<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.position') }}</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.player_goals') }}</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">
@php $separatorShown = false; @endphp
@foreach ($playerRanking as $index => $entry)
<tr class="hover:bg-gray-50">
@if (!$separatorShown && !$entry->is_primary_gk)
@php $separatorShown = true; @endphp
@if ($index > 0)
<tr><td colspan="7" class="px-4 py-1"><hr class="border-gray-300"></td></tr>
@endif
@endif
<tr class="hover:bg-gray-50 cursor-pointer" @click="openModal({{ $entry->player->id }})">
<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())
@@ -169,10 +178,25 @@
@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>
<span class="text-blue-600 hover:underline {{ $entry->player->trashed() ? 'text-gray-400 line-through' : '' }}">{{ $entry->player->full_name }}</span>
</td>
<td class="px-4 py-2 text-center">
@if ($entry->primary_position)
<span class="inline-block px-1.5 py-0.5 rounded text-xs font-medium {{ $entry->is_primary_gk ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-700' }}">
{{ $entry->primary_position->shortLabel() }}
</span>
@else
<span class="text-gray-300"></span>
@endif
</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">
@if ($entry->total_goals > 0)
<span class="font-medium text-green-600">{{ $entry->total_goals }}</span>
@else
<span class="text-gray-300">0</span>
@endif
</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 }}%
@@ -188,7 +212,229 @@
</tbody>
</table>
</div>
</div>
{{-- Spielfeld-Aufstellung --}}
@if ($courtPlayers->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.court_visualization') }}</h3>
</div>
<div class="p-4 flex justify-center">
@php
// Positionskoordinaten auf dem Spielfeld (viewBox 0 0 400 320)
$courtPositions = [
'torwart' => ['x' => 200, 'y' => 280],
'links_aussen' => ['x' => 55, 'y' => 185],
'rechts_aussen' => ['x' => 345, 'y' => 185],
'rueckraum_links' => ['x' => 115, 'y' => 105],
'rueckraum_mitte' => ['x' => 200, 'y' => 75],
'rueckraum_rechts' => ['x' => 285, 'y' => 105],
'kreislaeufer' => ['x' => 200, 'y' => 180],
];
$colorMap = [
'green' => ['fill' => '#22c55e', 'text' => '#fff'],
'yellow' => ['fill' => '#eab308', 'text' => '#fff'],
'red' => ['fill' => '#ef4444', 'text' => '#fff'],
'gray' => ['fill' => '#9ca3af', 'text' => '#fff'],
];
@endphp
<svg viewBox="0 0 400 320" class="w-full max-w-lg" xmlns="http://www.w3.org/2000/svg">
{{-- Spielfeld-Hintergrund --}}
<rect x="0" y="0" width="400" height="320" rx="8" fill="#16a34a" />
<rect x="10" y="10" width="380" height="300" rx="4" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.5" />
{{-- Mittellinie --}}
<line x1="10" y1="160" x2="390" y2="160" stroke="#fff" stroke-width="1" opacity="0.3" />
{{-- Tor (unten) --}}
<rect x="155" y="298" width="90" height="12" rx="2" fill="none" stroke="#fff" stroke-width="2" opacity="0.7" />
{{-- 6m-Torraum (Halbkreis) --}}
<path d="M 120 310 Q 120 230 200 220 Q 280 230 280 310" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.5" />
{{-- 9m-Freiwurflinie (gestrichelt) --}}
<path d="M 80 310 Q 80 200 200 185 Q 320 200 320 310" fill="none" stroke="#fff" stroke-width="1" stroke-dasharray="6,4" opacity="0.35" />
{{-- 7m-Markierung --}}
<line x1="193" y1="248" x2="207" y2="248" stroke="#fff" stroke-width="2" opacity="0.5" />
{{-- Spieler-Positionen --}}
@foreach ($courtPositions as $posValue => $coords)
@php
$entry = $courtPlayers->get($posValue);
$color = $entry ? $colorMap[$entry->performance_color] : $colorMap['gray'];
$posEnum = \App\Enums\PlayerPosition::tryFrom($posValue);
@endphp
<g @if ($entry) @click="openModal({{ $entry->player->id }})" style="cursor: pointer;" @endif>
<circle cx="{{ $coords['x'] }}" cy="{{ $coords['y'] }}" r="22" fill="{{ $color['fill'] }}" opacity="0.9" stroke="#fff" stroke-width="1.5" />
@if ($entry)
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] - 4 }}" text-anchor="middle" fill="{{ $color['text'] }}" font-size="11" font-weight="bold" style="pointer-events: none;">
{{ $entry->player->jersey_number ?? $entry->player->getInitials() }}
</text>
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] + 8 }}" text-anchor="middle" fill="{{ $color['text'] }}" font-size="7" style="pointer-events: none;">
{{ Str::limit($entry->player->first_name, 8, '') }}
</text>
@else
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] + 4 }}" text-anchor="middle" fill="{{ $color['text'] }}" font-size="9" font-weight="bold">
{{ $posEnum?->shortLabel() }}
</text>
@endif
{{-- Positions-Kürzel unter dem Kreis --}}
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] + 35 }}" text-anchor="middle" fill="#fff" font-size="8" opacity="0.7" style="pointer-events: none;">
{{ $posEnum?->shortLabel() }}
</text>
</g>
@endforeach
</svg>
</div>
{{-- Legende --}}
<div class="px-4 pb-4 flex flex-wrap gap-4 justify-center text-xs text-gray-600">
<div class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-green-500"></span>
{{ __('admin.performance_good') }}
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-yellow-500"></span>
{{ __('admin.performance_average') }}
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-red-500"></span>
{{ __('admin.performance_below') }}
</div>
<div class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-gray-400"></span>
{{ __('admin.court_no_data') }}
</div>
</div>
</div>
@endif
{{-- Spieler-Detail-Modal --}}
<div x-show="show" x-cloak x-transition.opacity class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" @click.self="show = false" @keydown.escape.window="show = false">
<div class="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] overflow-hidden" @click.stop>
{{-- Header --}}
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
<div class="flex items-center gap-3">
<template x-if="data && data.player.avatar">
<img :src="data.player.avatar" class="w-10 h-10 rounded-full object-cover">
</template>
<template x-if="data && !data.player.avatar">
<div class="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-sm font-semibold" x-text="data ? data.player.initials : ''"></div>
</template>
<div>
<h3 class="font-semibold text-gray-900" x-text="data ? data.player.name : ''"></h3>
<p class="text-xs text-gray-500">
<span x-text="data && data.player.position ? data.player.position + ' · ' : ''"></span>{{ __('admin.stats_player_detail') }}
</p>
</div>
</div>
<button @click="show = false" class="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
{{-- Zusammenfassung --}}
<template x-if="data">
<div class="px-5 py-4 border-b border-gray-100">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="text-center p-2 bg-green-50 rounded-lg">
<div class="text-lg font-bold text-green-700" x-text="data.summary.total_goals"></div>
<div class="text-xs text-green-600">{{ __('admin.stats_total_goals') }}</div>
</div>
<div class="text-center p-2 bg-blue-50 rounded-lg">
<div class="text-lg font-bold text-blue-700" x-text="data.summary.total_shots"></div>
<div class="text-xs text-blue-600">{{ __('admin.stats_total_shots') }}</div>
</div>
<div class="text-center p-2 bg-amber-50 rounded-lg">
<div class="text-lg font-bold text-amber-700" x-text="data.summary.hit_rate !== null ? data.summary.hit_rate + '%' : ''"></div>
<div class="text-xs text-amber-600">{{ __('events.stats_hit_rate') }}</div>
</div>
<div class="text-center p-2 bg-purple-50 rounded-lg">
<div class="text-lg font-bold text-purple-700" x-text="data.summary.gk_appearances"></div>
<div class="text-xs text-purple-600">{{ __('admin.stats_gk_appearances') }}</div>
</div>
</div>
<template x-if="data.summary.gk_appearances > 0">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-3">
<div class="text-center p-2 bg-indigo-50 rounded-lg">
<div class="text-lg font-bold text-indigo-700" x-text="data.summary.total_saves"></div>
<div class="text-xs text-indigo-600">{{ __('admin.stats_total_saves') }}</div>
</div>
<div class="text-center p-2 bg-gray-50 rounded-lg">
<div class="text-lg font-bold text-gray-700" x-text="data.summary.total_gk_shots"></div>
<div class="text-xs text-gray-500">{{ __('events.stats_shots_on_goal') }}</div>
</div>
<div class="text-center p-2 bg-teal-50 rounded-lg">
<div class="text-lg font-bold text-teal-700" x-text="data.summary.save_rate !== null ? data.summary.save_rate + '%' : ''"></div>
<div class="text-xs text-teal-600">{{ __('events.stats_save_rate') }}</div>
</div>
</div>
</template>
</div>
</template>
{{-- Spiel-Liste --}}
<div class="overflow-y-auto" style="max-height: 40vh;">
<template x-if="loading">
<div class="flex items-center justify-center py-8 text-gray-400">
<svg class="animate-spin w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
</div>
</template>
<template x-if="data && data.games.length > 0">
<table class="w-full text-sm">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500">{{ __('admin.date') }}</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.opponent') }}</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.score') }}</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_position') }}</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_goals') }}</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_shots') }}</th>
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_goalkeeper') }}</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_note') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="game in data.games" :key="game.date + game.opponent">
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap text-gray-600" x-text="game.date"></td>
<td class="px-4 py-2" x-text="game.opponent"></td>
<td class="px-4 py-2 text-center font-medium" x-text="game.score"></td>
<td class="px-4 py-2 text-center">
<span x-text="game.position ?? ''" :class="game.position ? 'text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded' : 'text-gray-300'"></span>
</td>
<td class="px-4 py-2 text-center">
<span x-text="game.goals ?? ''" :class="game.goals > 0 ? 'font-medium text-green-600' : 'text-gray-400'"></span>
</td>
<td class="px-4 py-2 text-center text-gray-500" x-text="game.shots ?? ''"></td>
<td class="px-4 py-2 text-center">
<template x-if="game.is_goalkeeper">
<span class="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded" x-text="(game.goalkeeper_saves ?? 0) + '/' + (game.goalkeeper_shots ?? 0)"></span>
</template>
<template x-if="!game.is_goalkeeper">
<span class="text-gray-300"></span>
</template>
</td>
<td class="px-4 py-2 text-gray-500 text-xs" x-text="game.note ?? ''"></td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="data && data.games.length === 0">
<div class="py-8 text-center text-gray-400 text-sm">{{ __('events.stats_no_data') }}</div>
</template>
</div>
{{-- Footer --}}
<div class="px-5 py-3 border-t border-gray-200 text-right">
<button @click="show = false" class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200">{{ __('admin.stats_close') }}</button>
</div>
</div>
</div>
</div>{{-- /x-data playerDetailModal --}}
@endif
{{-- Eltern-Engagement-Rangliste --}}
@@ -255,6 +501,34 @@
@endif
@endif
@push('scripts')
<script>
function playerDetailModal() {
return {
show: false,
loading: false,
data: null,
async openModal(playerId) {
this.show = true;
this.loading = true;
this.data = null;
try {
const resp = await fetch(@js(url('/admin/statistics/player')) + '/' + playerId, {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
});
if (resp.ok) {
this.data = await resp.json();
}
} catch (e) {
// Fehler still ignorieren
}
this.loading = false;
}
};
}
</script>
@endpush
@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>

View File

@@ -28,8 +28,9 @@
<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">
@php $logoApp = \App\Models\Setting::get('app_logo_app'); @endphp
<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">
<img src="{{ $logoApp ? asset('storage/' . $logoApp) : asset('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">

View File

@@ -24,8 +24,9 @@
<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">
@php $logoLogin = \App\Models\Setting::get('app_logo_login'); @endphp
<a href="{{ auth()->check() ? route('dashboard') : route('login') }}">
<img src="/images/logo_sg_woelfe.png" alt="Logo" class="mx-auto h-24 mb-3">
<img src="{{ $logoLogin ? asset('storage/' . $logoLogin) : asset('images/logo_sg_woelfe.png') }}" alt="Logo" class="mx-auto h-24 mb-3 object-contain">
</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

View File

@@ -390,6 +390,149 @@
</div>
@endif
{{-- Fahrgemeinschaften --}}
@if ($event->type->hasCarpool())
<div id="carpool" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-3">{{ __('events.carpool') }}</h2>
@if ($event->status !== \App\Enums\EventStatus::Cancelled && !auth()->user()->isDsgvoRestricted())
{{-- Eigenes Angebot --}}
@if ($myCarpool)
<div class="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex items-center gap-2 mb-3">
<span class="text-sm font-semibold text-blue-700">{{ __('events.carpool_my_offer') }}</span>
</div>
<form method="POST" action="{{ route('carpool.offer', $event) }}" class="flex flex-col sm:flex-row gap-3 mb-2">
@csrf
<div class="flex items-center gap-2">
<label for="carpool-seats" class="text-sm text-gray-700 whitespace-nowrap">{{ __('events.carpool_seats') }}:</label>
<input type="number" name="seats" id="carpool-seats" min="1" max="9" value="{{ $myCarpool->seats }}"
class="w-16 px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<input type="text" name="note" placeholder="{{ __('events.carpool_note_placeholder') }}" value="{{ $myCarpool->note }}"
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<button type="submit" class="px-3 py-1.5 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 whitespace-nowrap">
{{ __('events.carpool_update') }}
</button>
</form>
<form method="POST" action="{{ route('carpool.withdraw', $event) }}" class="inline"
onsubmit="return confirm(@js(__('events.carpool_withdraw_confirm')))">
@csrf
<button type="submit" class="text-sm text-red-600 hover:text-red-800 hover:underline">
{{ __('events.carpool_withdraw') }}
</button>
</form>
</div>
@else
<form method="POST" action="{{ route('carpool.offer', $event) }}" class="mb-4">
@csrf
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex items-center gap-2">
<label for="carpool-seats-new" class="text-sm text-gray-700 whitespace-nowrap">{{ __('events.carpool_seats') }}:</label>
<input type="number" name="seats" id="carpool-seats-new" min="1" max="9" value="3"
class="w-16 px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<input type="text" name="note" placeholder="{{ __('events.carpool_note_placeholder') }}"
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<button type="submit" class="px-3 py-1.5 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 whitespace-nowrap">
{{ __('events.carpool_offer') }}
</button>
</div>
</form>
@endif
@error('seats') <p class="text-red-600 text-xs mb-3">{{ $message }}</p> @enderror
@error('carpool') <p class="text-red-600 text-xs mb-3">{{ $message }}</p> @enderror
@elseif (auth()->user()->isDsgvoRestricted())
<p class="text-sm text-orange-600 mb-4">{{ __('ui.dsgvo_restricted_hint') }}</p>
@endif
{{-- Liste aller Fahrten --}}
@if ($event->carpools->isNotEmpty())
<div class="space-y-3">
@foreach ($event->carpools as $carpool)
@php
$isOwn = $carpool->user_id === auth()->id();
$passengerCount = $carpool->passengers->count();
$remaining = $carpool->seats - $passengerCount;
$fillPercent = $carpool->seats > 0 ? ($passengerCount / $carpool->seats) * 100 : 0;
$assignedChildIds = $carpool->passengers->pluck('player_id')->toArray();
$assignableChildren = $userChildIds->filter(fn ($id) => !in_array($id, $assignedChildIds));
@endphp
<div class="p-4 border rounded-lg {{ $isOwn ? 'border-blue-300 bg-blue-50/30' : 'border-gray-200' }}">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<img src="{{ $carpool->driver->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0">
<span class="text-sm font-semibold text-gray-900">{{ $carpool->driver->name }}</span>
@if ($isOwn)
<span class="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">{{ __('events.carpool_my_offer') }}</span>
@endif
</div>
<div class="text-sm text-gray-600">
{{ __('events.carpool_seats_count', ['free' => $remaining, 'total' => $carpool->seats]) }}
</div>
</div>
{{-- Fortschrittsbalken --}}
<div class="w-full bg-gray-200 rounded-full h-1.5 mb-2">
<div class="h-1.5 rounded-full transition-all {{ $fillPercent >= 100 ? 'bg-red-500' : ($fillPercent >= 60 ? 'bg-yellow-500' : 'bg-green-500') }}"
style="width: {{ min($fillPercent, 100) }}%"></div>
</div>
@if ($carpool->note)
<p class="text-xs text-gray-500 mb-2">{{ $carpool->note }}</p>
@endif
{{-- Passagiere --}}
@if ($carpool->passengers->isNotEmpty())
<div class="flex flex-wrap gap-1.5 mb-2">
@foreach ($carpool->passengers as $passenger)
<span class="inline-flex items-center gap-1 bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded-full">
{{ $passenger->player->full_name }}
@if ($event->status !== \App\Enums\EventStatus::Cancelled && ($passenger->added_by === auth()->id() || auth()->user()->isAdmin()))
<form method="POST" action="{{ route('carpool.leave', $event) }}" class="inline">
@csrf
<input type="hidden" name="carpool_id" value="{{ $carpool->id }}">
<input type="hidden" name="player_id" value="{{ $passenger->player_id }}">
<button type="submit" class="text-red-400 hover:text-red-600 ml-0.5" title="{{ __('events.carpool_leave') }}">
<svg class="w-3.5 h-3.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>
</form>
@endif
</span>
@endforeach
</div>
@endif
{{-- Kinder zuordnen --}}
@if ($event->status !== \App\Enums\EventStatus::Cancelled && !auth()->user()->isDsgvoRestricted() && $remaining > 0 && $assignableChildren->isNotEmpty())
<div class="flex flex-wrap gap-1.5">
@foreach ($assignableChildren as $childId)
@php $child = $userChildren->firstWhere('id', $childId); @endphp
@if ($child)
<form method="POST" action="{{ route('carpool.join', $event) }}" class="inline">
@csrf
<input type="hidden" name="carpool_id" value="{{ $carpool->id }}">
<input type="hidden" name="player_id" value="{{ $childId }}">
<button type="submit" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-green-300 text-green-700 bg-green-50 hover:bg-green-100 transition">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
{{ $child->full_name }}
</button>
</form>
@endif
@endforeach
</div>
@elseif ($remaining <= 0 && !$isOwn)
<p class="text-xs text-red-500">{{ __('events.carpool_full') }}</p>
@endif
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-500">{{ __('events.no_carpool_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>

View File

@@ -4,6 +4,7 @@ use App\Http\Controllers\Auth\ForgotPasswordController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\RegisterController;
use App\Http\Controllers\Auth\ResetPasswordController;
use App\Http\Controllers\CarpoolController;
use App\Http\Controllers\CateringController;
use App\Http\Controllers\TimekeeperController;
use App\Http\Controllers\CommentController;
@@ -129,6 +130,10 @@ Route::middleware(['auth'])->group(function () {
Route::post('/events/{event}/catering', [CateringController::class, 'update'])->name('catering.update')->middleware(['throttle:user-actions', 'dsgvo']);
Route::post('/events/{event}/timekeeper', [TimekeeperController::class, 'update'])->name('timekeeper.update')->middleware(['throttle:user-actions', 'dsgvo']);
Route::post('/events/{event}/comments', [CommentController::class, 'store'])->name('comments.store')->middleware(['throttle:user-actions', 'dsgvo']);
Route::post('/events/{event}/carpool/offer', [CarpoolController::class, 'offer'])->name('carpool.offer')->middleware(['throttle:user-actions', 'dsgvo']);
Route::post('/events/{event}/carpool/withdraw', [CarpoolController::class, 'withdraw'])->name('carpool.withdraw')->middleware(['throttle:user-actions', 'dsgvo']);
Route::post('/events/{event}/carpool/join', [CarpoolController::class, 'join'])->name('carpool.join')->middleware(['throttle:user-actions', 'dsgvo']);
Route::post('/events/{event}/carpool/leave', [CarpoolController::class, 'leave'])->name('carpool.leave')->middleware(['throttle:user-actions', 'dsgvo']);
Route::get('/files', [FileController::class, 'index'])->name('files.index');
Route::get('/files/{file}/download', [FileController::class, 'download'])->name('files.download');
@@ -152,6 +157,7 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
// --- Fuer alle Admin-Panel-Nutzer (Admin, Coach, ParentRep) ---
Route::get('/', [AdminDashboardController::class, 'index'])->name('dashboard');
Route::get('statistics', [StatisticsController::class, 'index'])->name('statistics.index');
Route::get('statistics/player/{player}', [StatisticsController::class, 'playerDetail'])->name('statistics.player-detail');
// Events (Leseansicht fuer alle Admin-Panel-Nutzer)
Route::get('events', [AdminEventController::class, 'index'])->name('events.index');
@@ -168,6 +174,7 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
Route::put('events/{event}', [AdminEventController::class, 'update'])->name('events.update');
Route::delete('events/{event}', [AdminEventController::class, 'destroy'])->name('events.destroy');
Route::patch('events/{event}/participant', [AdminEventController::class, 'updateParticipant'])->name('events.update-participant');
Route::post('events/{event}/stats', [AdminEventController::class, 'updateStats'])->name('events.update-stats');
Route::put('events/{event}/restore', [AdminEventController::class, 'restore'])->name('events.restore');
// Aktivitaetslog (Staff-Level + canViewActivityLog-Pruefung im Controller)