From ad60e7a9f9362b31c2904077d2ef37dcb4843818 Mon Sep 17 00:00:00 2001 From: Rhino Date: Mon, 2 Mar 2026 11:47:34 +0100 Subject: [PATCH] Spielerpositionen, Statistiken, Fahrgemeinschaften, Spielfeld-Visualisierung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 22 ++ CLAUDE.md | 126 ++++---- app/Enums/EventType.php | 5 + app/Enums/PlayerPosition.php | 34 +++ .../Controllers/Admin/EventController.php | 61 +++- .../Controllers/Admin/PlayerController.php | 3 + .../Controllers/Admin/SettingsController.php | 52 ++-- .../Admin/StatisticsController.php | 127 +++++++- app/Http/Controllers/CarpoolController.php | 176 +++++++++++ app/Http/Controllers/EventController.php | 13 +- app/Models/Event.php | 10 + app/Models/EventCarpool.php | 36 +++ app/Models/EventCarpoolPassenger.php | 35 +++ app/Models/EventPlayerStat.php | 78 +++++ app/Models/Player.php | 3 + ...01_000000_create_event_carpools_tables.php | 36 +++ ...000000_create_event_player_stats_table.php | 31 ++ ...0000_add_position_to_players_and_stats.php | 30 ++ database/seeders/DemoDataSeeder.php | 147 ++++++++- lang/ar/admin.php | 30 ++ lang/ar/events.php | 35 +++ lang/ar/ui.php | 18 ++ lang/de/admin.php | 34 ++- lang/de/events.php | 34 +++ lang/de/ui.php | 18 ++ lang/en/admin.php | 30 ++ lang/en/events.php | 35 +++ lang/en/ui.php | 18 ++ lang/pl/admin.php | 30 ++ lang/pl/events.php | 35 +++ lang/pl/ui.php | 18 ++ lang/ru/admin.php | 30 ++ lang/ru/events.php | 34 +++ lang/ru/ui.php | 18 ++ lang/tr/admin.php | 30 ++ lang/tr/events.php | 35 +++ lang/tr/ui.php | 18 ++ resources/views/admin/events/edit.blade.php | 100 +++++++ .../views/admin/players/create.blade.php | 10 + resources/views/admin/players/edit.blade.php | 10 + resources/views/admin/settings/edit.blade.php | 44 +++ .../views/admin/statistics/index.blade.php | 282 +++++++++++++++++- .../views/components/layouts/app.blade.php | 3 +- .../views/components/layouts/guest.blade.php | 3 +- resources/views/events/show.blade.php | 143 +++++++++ routes/web.php | 7 + 46 files changed, 2041 insertions(+), 86 deletions(-) create mode 100644 .gitignore create mode 100644 app/Enums/PlayerPosition.php create mode 100644 app/Http/Controllers/CarpoolController.php create mode 100644 app/Models/EventCarpool.php create mode 100644 app/Models/EventCarpoolPassenger.php create mode 100644 app/Models/EventPlayerStat.php create mode 100644 database/migrations/0036_01_01_000000_create_event_carpools_tables.php create mode 100644 database/migrations/0037_01_01_000000_create_event_player_stats_table.php create mode 100644 database/migrations/0038_01_01_000000_add_position_to_players_and_stats.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4f0d79 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index c0f5787..21b79d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: ``, ``, `` +- Blade-Components: ``, ``, ``, `` - 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 diff --git a/app/Enums/EventType.php b/app/Enums/EventType.php index f9e8a93..0010bb4 100755 --- a/app/Enums/EventType.php +++ b/app/Enums/EventType.php @@ -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; diff --git a/app/Enums/PlayerPosition.php b/app/Enums/PlayerPosition.php new file mode 100644 index 0000000..c17be03 --- /dev/null +++ b/app/Enums/PlayerPosition.php @@ -0,0 +1,34 @@ +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'); + } +} diff --git a/app/Http/Controllers/Admin/EventController.php b/app/Http/Controllers/Admin/EventController.php index f9886f8..150873b 100755 --- a/app/Http/Controllers/Admin/EventController.php +++ b/app/Http/Controllers/Admin/EventController.php @@ -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([ diff --git a/app/Http/Controllers/Admin/PlayerController.php b/app/Http/Controllers/Admin/PlayerController.php index 16464ec..c9101af 100755 --- a/app/Http/Controllers/Admin/PlayerController.php +++ b/app/Http/Controllers/Admin/PlayerController.php @@ -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'], diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php index ebe9ad5..7ce02b0 100755 --- a/app/Http/Controllers/Admin/SettingsController.php +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -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', diff --git a/app/Http/Controllers/Admin/StatisticsController.php b/app/Http/Controllers/Admin/StatisticsController.php index d65c85e..6efff85 100644 --- a/app/Http/Controllers/Admin/StatisticsController.php +++ b/app/Http/Controllers/Admin/StatisticsController.php @@ -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(), + ]); + } } diff --git a/app/Http/Controllers/CarpoolController.php b/app/Http/Controllers/CarpoolController.php new file mode 100644 index 0000000..310aa25 --- /dev/null +++ b/app/Http/Controllers/CarpoolController.php @@ -0,0 +1,176 @@ +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); + } + } + } +} diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index ccc20de..1136c5a 100755 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -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')); } } diff --git a/app/Models/Event.php b/app/Models/Event.php index 5a3c16b..af1b0c3 100755 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -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); diff --git a/app/Models/EventCarpool.php b/app/Models/EventCarpool.php new file mode 100644 index 0000000..817104f --- /dev/null +++ b/app/Models/EventCarpool.php @@ -0,0 +1,36 @@ + '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(); + } +} diff --git a/app/Models/EventCarpoolPassenger.php b/app/Models/EventCarpoolPassenger.php new file mode 100644 index 0000000..2dfda9b --- /dev/null +++ b/app/Models/EventCarpoolPassenger.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/app/Models/EventPlayerStat.php b/app/Models/EventPlayerStat.php new file mode 100644 index 0000000..7cf6079 --- /dev/null +++ b/app/Models/EventPlayerStat.php @@ -0,0 +1,78 @@ + '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; + } +} diff --git a/app/Models/Player.php b/app/Models/Player.php index bf99e6f..be9939d 100755 --- a/app/Models/Player.php +++ b/app/Models/Player.php @@ -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, ]; } diff --git a/database/migrations/0036_01_01_000000_create_event_carpools_tables.php b/database/migrations/0036_01_01_000000_create_event_carpools_tables.php new file mode 100644 index 0000000..41b7f9f --- /dev/null +++ b/database/migrations/0036_01_01_000000_create_event_carpools_tables.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/database/migrations/0037_01_01_000000_create_event_player_stats_table.php b/database/migrations/0037_01_01_000000_create_event_player_stats_table.php new file mode 100644 index 0000000..9fe4a47 --- /dev/null +++ b/database/migrations/0037_01_01_000000_create_event_player_stats_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/migrations/0038_01_01_000000_add_position_to_players_and_stats.php b/database/migrations/0038_01_01_000000_add_position_to_players_and_stats.php new file mode 100644 index 0000000..973b888 --- /dev/null +++ b/database/migrations/0038_01_01_000000_add_position_to_players_and_stats.php @@ -0,0 +1,30 @@ +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'); + }); + } +}; diff --git a/database/seeders/DemoDataSeeder.php b/database/seeders/DemoDataSeeder.php index 6393672..3459698 100755 --- a/database/seeders/DemoDataSeeder.php +++ b/database/seeders/DemoDataSeeder.php @@ -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 diff --git a/lang/ar/admin.php b/lang/ar/admin.php index e74f897..14afbfe 100755 --- a/lang/ar/admin.php +++ b/lang/ar/admin.php @@ -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' => 'أقل من المتوسط', ]; diff --git a/lang/ar/events.php b/lang/ar/events.php index cd4ff23..2a12ee2 100755 --- a/lang/ar/events.php +++ b/lang/ar/events.php @@ -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' => 'انسحب', diff --git a/lang/ar/ui.php b/lang/ar/ui.php index eaff07b..06fe6b3 100755 --- a/lang/ar/ui.php +++ b/lang/ar/ui.php @@ -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', diff --git a/lang/de/admin.php b/lang/de/admin.php index decacfe..90b2b8d 100755 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -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', ]; diff --git a/lang/de/events.php b/lang/de/events.php index 3865629..c391ca8 100755 --- a/lang/de/events.php +++ b/lang/de/events.php @@ -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', diff --git a/lang/de/ui.php b/lang/de/ui.php index 1980d91..e0437d2 100755 --- a/lang/de/ui.php +++ b/lang/de/ui.php @@ -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 diff --git a/lang/en/admin.php b/lang/en/admin.php index 2bb6873..be44493 100755 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -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', ]; diff --git a/lang/en/events.php b/lang/en/events.php index ece188a..7c41607 100755 --- a/lang/en/events.php +++ b/lang/en/events.php @@ -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', diff --git a/lang/en/ui.php b/lang/en/ui.php index d318a55..213e394 100755 --- a/lang/en/ui.php +++ b/lang/en/ui.php @@ -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', diff --git a/lang/pl/admin.php b/lang/pl/admin.php index 9180a31..5a329e4 100755 --- a/lang/pl/admin.php +++ b/lang/pl/admin.php @@ -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', ]; diff --git a/lang/pl/events.php b/lang/pl/events.php index 959198f..abb8902 100755 --- a/lang/pl/events.php +++ b/lang/pl/events.php @@ -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', diff --git a/lang/pl/ui.php b/lang/pl/ui.php index 488ef54..ad4ed38 100755 --- a/lang/pl/ui.php +++ b/lang/pl/ui.php @@ -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', diff --git a/lang/ru/admin.php b/lang/ru/admin.php index 6112718..4d7f8fd 100755 --- a/lang/ru/admin.php +++ b/lang/ru/admin.php @@ -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' => 'Ниже среднего', ]; diff --git a/lang/ru/events.php b/lang/ru/events.php index a764dd7..0558496 100755 --- a/lang/ru/events.php +++ b/lang/ru/events.php @@ -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' => 'отказался', diff --git a/lang/ru/ui.php b/lang/ru/ui.php index e86bf48..9a31d1d 100755 --- a/lang/ru/ui.php +++ b/lang/ru/ui.php @@ -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', diff --git a/lang/tr/admin.php b/lang/tr/admin.php index afa5ede..0df3fbd 100755 --- a/lang/tr/admin.php +++ b/lang/tr/admin.php @@ -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ı', ]; diff --git a/lang/tr/events.php b/lang/tr/events.php index 13d373b..34843b7 100755 --- a/lang/tr/events.php +++ b/lang/tr/events.php @@ -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', diff --git a/lang/tr/ui.php b/lang/tr/ui.php index 757994a..4f92bad 100755 --- a/lang/tr/ui.php +++ b/lang/tr/ui.php @@ -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', diff --git a/resources/views/admin/events/edit.blade.php b/resources/views/admin/events/edit.blade.php index 52f9c0d..f26c15d 100755 --- a/resources/views/admin/events/edit.blade.php +++ b/resources/views/admin/events/edit.blade.php @@ -357,6 +357,106 @@ @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()) +
+

{{ __('events.stats') }}

+

{{ __('events.stats_confirmed_only') }}

+ +
+ @csrf +
+ + + + + + + + + + + + + + + @foreach ($confirmedPlayers as $participant) + @php + $pid = $participant->player_id; + $stat = $playerStatsMap[$pid] ?? null; + @endphp + + + + + + + + + + + + + @endforeach + +
{{ __('admin.nav_players') }}{{ __('events.stats_position') }}{{ __('events.stats_goalkeeper') }}{{ __('events.stats_shots_on_goal') }}{{ __('events.stats_saves') }}{{ __('events.stats_shots') }}{{ __('events.stats_goals') }}{{ __('events.stats_note') }}
+ {{ $participant->player->full_name }} + + + + + + + + + + + + + + +
+
+
+ +
+
+
+ @endif + @endif + {{-- Quill JS --}} + @endpush + @if ($totalWithScore > 0) @push('scripts') diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index 4808331..5e85824 100755 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -28,8 +28,9 @@
+ @php $logoApp = \App\Models\Setting::get('app_logo_app'); @endphp - Logo + Logo {{ \App\Models\Setting::get('app_name', config('app.name')) }}