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 ## Projekt-Typ
Laravel 12 WebApp zur Verwaltung von Handball-Teams, Spielern, Eltern, Terminen und Dateien. 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. 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 ## 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/Models/` — Eloquent Models (User, Player, Team, Event, Setting, etc.)
- `app/Http/Controllers/Admin/` — Admin-Bereich (UserController, SettingsController, etc.) - `app/Http/Controllers/Admin/` — Admin-Bereich (UserController, SettingsController, etc.)
- `app/Http/Controllers/Auth/` — Login, Register, ForgotPassword, ResetPassword - `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/Services/` — HtmlSanitizerService (HTMLPurifier), GeocodingService, SupportApiService
- `app/Enums/` — UserRole, EventType, EventStatus, ParticipantStatus, CateringStatus - `app/Enums/` — UserRole, EventType, EventStatus, ParticipantStatus, CateringStatus
- `app/Notifications/` — ResetPasswordNotification (Custom, mit Setting-Template) - `app/Notifications/` — ResetPasswordNotification (Custom, mit Setting-Template)
- `resources/views/` — Blade-Templates (layouts: admin, guest, app) - `resources/views/` — Blade-Templates (layouts: admin, guest, app, installer)
- `lang/{de,en,pl,ru,ar,tr}/` — 6 Sprachen: Deutsch, Englisch, Polnisch, Russisch, Arabisch, Turkisch - `lang/{de,en,pl,ru,ar,tr}/` — 6 Sprachen: Deutsch, Englisch, Polnisch, Russisch, Arabisch, Tuerkisch
- `database/migrations/` — Nummeriert mit Prefix 0001-0035 - `database/migrations/` — Nummeriert mit Prefix 0001-0035
- `database/seeders/` — AdminSeeder (benotigt ADMIN_EMAIL + ADMIN_PASSWORD in .env) - `database/seeders/` — AdminSeeder, DemoSeeder, FaqSeeder
### Wichtige Patterns ### Wichtige Patterns
- **Settings**: Key-Value Store in `settings` Tabelle. `Setting::get($key)` mit 1-Stunden-Cache, `Setting::set($key, $value)` mit Cache-Invalidierung - **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`) - **Rollen**: Admin, Coach, ParentRep, User (Enum `UserRole`)
- **Soft-Deletes**: User und Player (7 Tage Wiederherstellung) - **Soft-Deletes**: User und Player (7 Tage Wiederherstellung)
- **Activity-Log**: `ActivityLog::log()` / `ActivityLog::logWithChanges()` fuer Audit-Trail - **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 ### Multi-Language
- 6 Sprachen: de, en, pl, ru, ar, tr - 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` - 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 - Locale wird per SetLocaleMiddleware aus `$user->locale` oder Session gesetzt
- RTL-Support fuer Arabisch (ar) - 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 ### Password Reset Flow
1. User klickt "Passwort vergessen?" auf Login-Seite 1. User klickt "Passwort vergessen?" auf Login-Seite
2. ForgotPasswordController sendet Reset-Link via Laravel Password Broker 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}` 4. Platzhalter: `{name}`, `{link}`, `{app_name}`
5. Fallback auf Standard-Laravel-Template mit Translation-Keys (`passwords.reset_*`) 5. Fallback auf Standard-Laravel-Template mit Translation-Keys (`passwords.reset_*`)
### Installer ### Admin Settings Tabs
- InstallerMiddleware prueft `storage/installed` Datei - **Allgemein**: App-Name, Slogan, Favicon, Saison
- Setup-Token-Schutz: Token in `storage/setup-token`, wird beim ersten Zugriff generiert und in Laravel-Log geschrieben - **E-Mail**: SMTP-Konfiguration mit Verbindungstest (schreibt in .env, `Artisan::call('config:clear')`)
- Nach Installation wird `storage/installed` erstellt - **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) - 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 - Honeypot-Feld auf Login/Register-Formularen
- Rate-Limiting auf Auth-Routes (`throttle:login`)
- DSGVO-Consent-System mit Datei-Upload und Admin-Bestaetigung - DSGVO-Consent-System mit Datei-Upload und Admin-Bestaetigung
- Factory Reset benoetigt Passwort + Bestaetigung "RESET-BESTAETIGT" - 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 - **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 - **SSRF-Schutz**: GeocodingService mit Host-Whitelist, HTTPS-only, DNS-Rebinding-Check, Timeout 5s
- **Installer-Sicherheit**: Admin-Passwort wird sofort gehasht (nicht Klartext in Session), Setup-Token nur als SHA256 geloggt - **Installer-Sicherheit**: Admin-Passwort sofort gehasht, Setup-Token nur als SHA256 geloggt
- **Path-Traversal-Schutz**: DSGVO-Datei-Downloads pruefen `str_starts_with('dsgvo/')` - **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 ## Conventions
- Controller-Methoden: resourceful (index, create, store, show, edit, update, destroy) - 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 - 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 - Keine Tests vorhanden — bei Aenderungen manuell testen
- Commit-Sprache: Deutsch oder Englisch - Commit-Sprache: Deutsch oder Englisch
- Alle Uebersetzungsschluessel muessen in ALLEN 6 Sprachen hinzugefuegt werden - 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 2. Migration fuer alle 6 Locales
3. In SettingsController `$localeSettings` Array aufnehmen 3. In SettingsController `$localeSettings` Array aufnehmen
4. In View mit Sprach-Flaggen-Selector anzeigen 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) 6. **Wichtig**: Neuen Key zur Whitelist in `SettingsController::update()` hinzufuegen ($allowedLocaleKeys)
## Security Audit & Haertung (Maerz 2026) ### Neue Admin-Route hinzufuegen
1. Route in `routes/web.php` im passenden Middleware-Block:
### Audit-Report - Alle Admin-Panel-Nutzer: direkt unter `admin` Prefix
- **Datei**: `security-audit-2026-03.html` — Vollstaendiger HTML-Report mit allen Findings und Fix-Dokumentation - Staff (Admin + Coach): unter `staff` Middleware
- **Score**: 6.5 → 8.5 / 10 nach Haertung - Nur Admin: unter `admin-only` Middleware
- **Ergebnis**: 20 Findings identifiziert, 19 behoben, 1 als akzeptiertes Risiko 2. Controller-Methode mit Autorisierungs-Pruefung
3. View erstellen
### Behobene Schwachstellen (nach Datei) 4. Translation-Keys fuer alle 6 Sprachen
| 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

View File

@@ -31,6 +31,11 @@ enum EventType: string
return !in_array($this, [self::AwayGame, self::Meeting]); return !in_array($this, [self::AwayGame, self::Meeting]);
} }
public function hasCarpool(): bool
{
return $this !== self::Meeting;
}
public function hasPlayerParticipants(): bool public function hasPlayerParticipants(): bool
{ {
return $this !== self::Meeting; 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\EventStatus;
use App\Enums\EventType; use App\Enums\EventType;
use App\Enums\ParticipantStatus; use App\Enums\ParticipantStatus;
use App\Enums\PlayerPosition;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Event; use App\Models\Event;
use App\Models\EventCatering; use App\Models\EventCatering;
use App\Models\EventPlayerStat;
use App\Models\EventTimekeeper; use App\Models\EventTimekeeper;
use App\Models\File; use App\Models\File;
use App\Models\FileCategory; use App\Models\FileCategory;
@@ -120,13 +122,16 @@ class EventController extends Controller
$participantRelations = $event->type === EventType::Meeting $participantRelations = $event->type === EventType::Meeting
? ['participants.user'] ? ['participants.user']
: ['participants.player']; : ['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(); $assignedCatering = $event->caterings->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
$assignedTimekeeper = $event->timekeepers->where('status', CateringStatus::Yes)->pluck('user_id')->toArray(); $assignedTimekeeper = $event->timekeepers->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
$knownLocations = Location::orderBy('name')->get(); $knownLocations = Location::orderBy('name')->get();
$fileCategories = FileCategory::active()->ordered()->with(['files' => fn ($q) => $q->latest()])->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 public function update(Request $request, Event $event): RedirectResponse
@@ -210,6 +215,58 @@ class EventController extends Controller
->with('success', __('admin.event_restored')); ->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 private function validateEvent(Request $request): array
{ {
$request->validate([ $request->validate([

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Enums\PlayerPosition;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Event; use App\Models\Event;
@@ -78,6 +79,7 @@ class PlayerController extends Controller
}], }],
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'], 'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'], 'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
'position' => ['nullable', 'string', \Illuminate\Validation\Rule::in(PlayerPosition::values())],
'is_active' => ['boolean'], 'is_active' => ['boolean'],
'photo_permission' => ['boolean'], 'photo_permission' => ['boolean'],
'notes' => ['nullable', 'string', 'max:2000'], 'notes' => ['nullable', 'string', 'max:2000'],
@@ -120,6 +122,7 @@ class PlayerController extends Controller
}], }],
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'], 'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'], 'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
'position' => ['nullable', 'string', \Illuminate\Validation\Rule::in(PlayerPosition::values())],
'photo_permission' => ['boolean'], 'photo_permission' => ['boolean'],
'notes' => ['nullable', 'string', 'max:2000'], 'notes' => ['nullable', 'string', 'max:2000'],
'profile_picture' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,gif,webp'], '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.'); abort(403, 'Nur Admins koennen Einstellungen aendern.');
} }
// Favicon-Upload verarbeiten (vor der normalen Settings-Schleife) // Bild-Uploads verarbeiten (vor der normalen Settings-Schleife)
if ($request->hasFile('favicon')) { $imageUploads = [
$request->validate([ 'favicon' => ['setting' => 'app_favicon', 'dir' => 'favicon', 'max' => 512],
'favicon' => 'file|mimes:ico,png,svg,jpg,jpeg,gif,webp|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 foreach ($imageUploads as $field => $config) {
$oldFavicon = Setting::get('app_favicon'); if ($request->hasFile($field)) {
if ($oldFavicon) { $request->validate([
Storage::disk('public')->delete($oldFavicon); $field => 'file|mimes:ico,png,svg,jpg,jpeg,gif,webp|max:' . $config['max'],
} ]);
$file = $request->file('favicon'); $oldFile = Setting::get($config['setting']);
$filename = Str::uuid() . '.' . $file->guessExtension(); if ($oldFile) {
$path = $file->storeAs('favicon', $filename, 'public'); Storage::disk('public')->delete($oldFile);
Setting::set('app_favicon', $path); }
} elseif ($request->has('remove_favicon')) {
$oldFavicon = Setting::get('app_favicon'); $file = $request->file($field);
if ($oldFavicon) { $filename = Str::uuid() . '.' . $file->guessExtension();
Storage::disk('public')->delete($oldFavicon); $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', []); $inputSettings = $request->input('settings', []);
@@ -310,6 +317,9 @@ class SettingsController extends Controller
// Löschreihenfolge beachtet FK-Constraints // Löschreihenfolge beachtet FK-Constraints
DB::table('activity_logs')->delete(); DB::table('activity_logs')->delete();
DB::table('comments')->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_participants')->delete();
DB::table('event_catering')->delete(); DB::table('event_catering')->delete();
DB::table('event_timekeepers')->delete(); DB::table('event_timekeepers')->delete();
@@ -364,6 +374,7 @@ class SettingsController extends Controller
Storage::disk('private')->deleteDirectory('files'); Storage::disk('private')->deleteDirectory('files');
Storage::disk('public')->deleteDirectory('avatars'); Storage::disk('public')->deleteDirectory('avatars');
Storage::disk('public')->deleteDirectory('favicon'); Storage::disk('public')->deleteDirectory('favicon');
Storage::disk('public')->deleteDirectory('logos');
Storage::disk('public')->deleteDirectory('dsgvo'); Storage::disk('public')->deleteDirectory('dsgvo');
// 2. FK-Constraints deaktivieren (DB-agnostisch) // 2. FK-Constraints deaktivieren (DB-agnostisch)
@@ -376,7 +387,8 @@ class SettingsController extends Controller
// 3. Alle Tabellen leeren // 3. Alle Tabellen leeren
$tables = [ $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_catering', 'event_timekeepers', 'event_faq',
'event_file', 'events', 'parent_player', 'players', 'event_file', 'events', 'parent_player', 'players',
'team_user', 'team_file', 'teams', 'team_user', 'team_file', 'teams',

View File

@@ -6,15 +6,18 @@ use App\Enums\CateringStatus;
use App\Enums\EventStatus; use App\Enums\EventStatus;
use App\Enums\EventType; use App\Enums\EventType;
use App\Enums\ParticipantStatus; use App\Enums\ParticipantStatus;
use App\Enums\PlayerPosition;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Event; use App\Models\Event;
use App\Models\EventCatering; use App\Models\EventCatering;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\EventPlayerStat;
use App\Models\EventTimekeeper; use App\Models\EventTimekeeper;
use App\Models\Player; use App\Models\Player;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\View\View; use Illuminate\View\View;
@@ -113,6 +116,34 @@ class StatisticsController extends Controller
$gameIds = $games->pluck('id'); $gameIds = $games->pluck('id');
$totalGames = $games->count(); $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(); $playerRanking = collect();
if ($totalGames > 0) { 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')) $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') ->whereNotNull('player_id')
->groupBy('player_id') ->groupBy('player_id')
->get() ->get()
->map(function ($row) use ($totalGames) { ->map(function ($row) use ($totalGames, $goalsByPlayer, $positionCounts, $playerAggStats) {
$player = Player::withTrashed()->find($row->player_id); $player = Player::withTrashed()->find($row->player_id);
if (!$player) { if (!$player) {
return null; 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) [ return (object) [
'player' => $player, 'player' => $player,
'games_played' => (int) $row->games_played, 'games_played' => (int) $row->games_played,
'total_assigned' => (int) $row->total_assigned, 'total_assigned' => (int) $row->total_assigned,
'total_games' => $totalGames, 'total_games' => $totalGames,
'total_goals' => (int) ($goalsByPlayer[$row->player_id] ?? 0),
'rate' => $row->total_assigned > 0 'rate' => $row->total_assigned > 0
? round(($row->games_played / $row->total_assigned) * 100) ? round(($row->games_played / $row->total_assigned) * 100)
: 0, : 0,
'primary_position' => $primaryPosition,
'is_primary_gk' => $isPrimaryGk,
'performance_rate' => $performanceRate,
'performance_color' => $performanceColor,
]; ];
}) })
->filter() ->filter()
->sortByDesc('games_played') ->sortBy([
// Torwarte zuerst, dann Feldspieler
['is_primary_gk', 'desc'],
['games_played', 'desc'],
])
->values(); ->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 ──────────────────────── // ── Eltern-Engagement-Rangliste ────────────────────────
// Alle publizierten Events (nicht nur Spiele) mit gleichen Team/Datum-Filtern // Alle publizierten Events (nicht nur Spiele) mit gleichen Team/Datum-Filtern
$allEventsQuery = Event::where('status', EventStatus::Published); $allEventsQuery = Event::where('status', EventStatus::Published);
@@ -202,8 +273,58 @@ class StatisticsController extends Controller
return view('admin.statistics.index', compact( return view('admin.statistics.index', compact(
'games', 'teams', 'wins', 'losses', 'draws', 'winRate', 'totalWithScore', 'games', 'teams', 'wins', 'losses', 'draws', 'winRate', 'totalWithScore',
'chartWinLoss', 'chartPlayerParticipation', 'chartParentInvolvement', 'chartWinLoss', 'chartPlayerParticipation', 'chartParentInvolvement',
'playerRanking', 'totalGames', 'playerRanking', 'totalGames', 'courtPlayers',
'parentRanking', 'totalCateringEvents', 'totalTimekeeperEvents' '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 // 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) // Zugriffsbeschraenkung: User muss Zugang zum Team haben (ueber accessibleTeamIds)
if (!$user->canAccessAdminPanel()) { if (!$user->canAccessAdminPanel()) {
@@ -90,6 +90,10 @@ class EventController extends Controller
if ($event->type->hasTimekeepers()) { if ($event->type->hasTimekeepers()) {
$relations[] = 'timekeepers.user'; $relations[] = 'timekeepers.user';
} }
if ($event->type->hasCarpool()) {
$relations[] = 'carpools.driver';
$relations[] = 'carpools.passengers.player';
}
$event->load($relations); $event->load($relations);
$userChildIds = $userChildren->pluck('id'); $userChildIds = $userChildren->pluck('id');
@@ -104,6 +108,11 @@ class EventController extends Controller
? $event->timekeepers->where('user_id', $user->id)->first() ? $event->timekeepers->where('user_id', $user->id)->first()
: null; : 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) // Catering/Zeitnehmer-Verlauf für Staff (chronologische Statusänderungen)
$cateringHistory = collect(); $cateringHistory = collect();
$timekeeperHistory = 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); 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 public function comments(): HasMany
{ {
return $this->hasMany(Comment::class); 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; namespace App\Models;
use App\Enums\PlayerPosition;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -17,6 +18,7 @@ class Player extends Model
'last_name', 'last_name',
'birth_year', 'birth_year',
'jersey_number', 'jersey_number',
'position',
'is_active', 'is_active',
'photo_permission', 'photo_permission',
'notes', 'notes',
@@ -28,6 +30,7 @@ class Player extends Model
return [ return [
'is_active' => 'boolean', 'is_active' => 'boolean',
'photo_permission' => '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\EventStatus;
use App\Enums\EventType; use App\Enums\EventType;
use App\Enums\ParticipantStatus; use App\Enums\ParticipantStatus;
use App\Enums\PlayerPosition;
use App\Enums\UserRole; use App\Enums\UserRole;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Comment; use App\Models\Comment;
use App\Models\Event; use App\Models\Event;
use App\Models\EventCarpool;
use App\Models\EventCarpoolPassenger;
use App\Models\EventCatering; use App\Models\EventCatering;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\EventPlayerStat;
use App\Models\EventTimekeeper; use App\Models\EventTimekeeper;
use App\Models\Faq; use App\Models\Faq;
use App\Models\Location; use App\Models\Location;
@@ -43,6 +47,8 @@ class DemoDataSeeder extends Seeder
$this->seedCatering($events, $parentUsers); $this->seedCatering($events, $parentUsers);
$this->seedTimekeepers($events, $parentUsers); $this->seedTimekeepers($events, $parentUsers);
$this->seedComments($events, $admin, $coach, $parentUsers); $this->seedComments($events, $admin, $coach, $parentUsers);
$this->seedPlayerStats($events, $players);
$this->seedCarpools($events, $parentUsers, $players);
$this->seedFaqs($admin); $this->seedFaqs($admin);
$this->seedActivityLogs($admin, $coach, $team, $events); $this->seedActivityLogs($admin, $coach, $team, $events);
$this->seedSoftDeletedRecords($team); $this->seedSoftDeletedRecords($team);
@@ -200,9 +206,27 @@ class DemoDataSeeder extends Seeder
private function seedPlayers(array $data, Team $team): array 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 = []; $players = [];
$jerseyNr = 1; $jerseyNr = 1;
foreach ($data as [$childFirst, $lastName, $parents]) { foreach ($data as $idx => [$childFirst, $lastName, $parents]) {
$player = Player::firstOrCreate( $player = Player::firstOrCreate(
['first_name' => $childFirst, 'last_name' => $lastName, 'team_id' => $team->id], ['first_name' => $childFirst, 'last_name' => $lastName, 'team_id' => $team->id],
[ [
@@ -210,8 +234,13 @@ class DemoDataSeeder extends Seeder
'jersey_number' => $jerseyNr, 'jersey_number' => $jerseyNr,
'is_active' => true, 'is_active' => true,
'photo_permission' => 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; $players["{$childFirst} {$lastName}"] = $player;
$jerseyNr++; $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 ────────────────────────────────────────────── // ─── FAQs ──────────────────────────────────────────────
private function seedFaqs(User $admin): void private function seedFaqs(User $admin): void

View File

@@ -307,6 +307,10 @@ return [
'log_participant_changed' => 'تم تغيير حالة المشاركة لـ ":event" إلى :status', 'log_participant_changed' => 'تم تغيير حالة المشاركة لـ ":event" إلى :status',
'log_catering_changed' => 'تم تغيير حالة التموين لـ ":event" إلى :status', 'log_catering_changed' => 'تم تغيير حالة التموين لـ ":event" إلى :status',
'log_timekeeper_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_created' => 'تم إضافة تعليق إلى ":event"',
'log_comment_deleted' => 'تم حذف تعليق من ":event"', 'log_comment_deleted' => 'تم حذف تعليق من ":event"',
'log_file_uploaded' => 'تم رفع الملف ":name"', 'log_file_uploaded' => 'تم رفع الملف ":name"',
@@ -356,6 +360,15 @@ return [
'favicon_uploaded' => 'تم تحديث الأيقونة.', 'favicon_uploaded' => 'تم تحديث الأيقونة.',
'favicon_removed' => 'تم إزالة الأيقونة.', '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 // Undo / Revert
'log_revert' => 'تراجع', 'log_revert' => 'تراجع',
'log_revert_confirm' => 'هل تريد حقاً التراجع عن هذا الإجراء؟', 'log_revert_confirm' => 'هل تريد حقاً التراجع عن هذا الإجراء؟',
@@ -546,4 +559,21 @@ return [
'mail_test_button' => 'اختبار الاتصال', 'mail_test_button' => 'اختبار الاتصال',
'mail_testing' => 'جاري اختبار الاتصال...', 'mail_testing' => 'جاري اختبار الاتصال...',
'mail_test_success' => 'اتصال SMTP ناجح!', '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' => 'ميقاتي', 'timekeeper_short' => 'ميقاتي',
'no_timekeeper_yet' => 'لم يتم تعيين ميقاتي بعد.', 'no_timekeeper_yet' => 'لم يتم تعيين ميقاتي بعد.',
'timekeeper_updated' => 'تم تحديث حالة الميقاتي.', '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' => 'التعليقات', 'comments' => 'التعليقات',
'comment_placeholder' => 'اكتب تعليقاً...', 'comment_placeholder' => 'اكتب تعليقاً...',
'no_comments' => 'لا توجد تعليقات بعد.', 'no_comments' => 'لا توجد تعليقات بعد.',
@@ -57,6 +76,22 @@ return [
'score_away' => 'الفريق الضيف', 'score_away' => 'الفريق الضيف',
'vs' => 'ضد', '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 // Staff visibility
'signed_up' => 'سجّل', 'signed_up' => 'سجّل',
'withdrawn' => 'انسحب', 'withdrawn' => 'انسحب',

View File

@@ -82,6 +82,24 @@ return [
'parent_rep' => 'ممثل أولياء الأمور', 'parent_rep' => 'ممثل أولياء الأمور',
'user' => 'ولي أمر', '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' => [ 'locales' => [
'de' => 'Deutsch', 'de' => 'Deutsch',

View File

@@ -338,6 +338,10 @@ return [
'log_participant_changed' => 'Teilnahme-Status für ":event" geändert auf :status', '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_catering_changed' => 'Catering-Status für ":event" geändert auf :status',
'log_timekeeper_changed' => 'Zeitnehmer-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_created' => 'Kommentar zu ":event" hinzugefügt',
'log_comment_deleted' => 'Kommentar zu ":event" gelöscht', 'log_comment_deleted' => 'Kommentar zu ":event" gelöscht',
'log_file_uploaded' => 'Datei ":name" hochgeladen', 'log_file_uploaded' => 'Datei ":name" hochgeladen',
@@ -389,6 +393,15 @@ return [
'favicon_uploaded' => 'Favicon wurde aktualisiert.', 'favicon_uploaded' => 'Favicon wurde aktualisiert.',
'favicon_removed' => 'Favicon wurde entfernt.', '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 // Undo / Revert
'log_revert' => 'Rückgängig', 'log_revert' => 'Rückgängig',
'log_revert_confirm' => 'Möchtest du diese Aktion wirklich rückgängig machen?', 'log_revert_confirm' => 'Möchtest du diese Aktion wirklich rückgängig machen?',
@@ -566,7 +579,7 @@ return [
// E-Mail Tab // E-Mail Tab
'settings_tab_mail' => 'E-Mail', 'settings_tab_mail' => 'E-Mail',
'mail_config_title' => 'E-Mail-Konfiguration', '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_mailer_label' => 'Versandmethode',
'mail_log_mode' => 'Kein Versand (Log)', 'mail_log_mode' => 'Kein Versand (Log)',
'mail_host_label' => 'SMTP-Host', 'mail_host_label' => 'SMTP-Host',
@@ -575,11 +588,28 @@ return [
'mail_password_label' => 'Passwort', 'mail_password_label' => 'Passwort',
'mail_from_address_label' => 'Absender-Adresse', 'mail_from_address_label' => 'Absender-Adresse',
'mail_from_name_label' => 'Absender-Name', 'mail_from_name_label' => 'Absender-Name',
'mail_encryption_label' => 'Verschluesselung', 'mail_encryption_label' => 'Verschlüsselung',
'mail_encryption_none' => 'Keine', 'mail_encryption_none' => 'Keine',
'mail_save' => 'Mail-Einstellungen speichern', 'mail_save' => 'Mail-Einstellungen speichern',
'mail_saved' => 'Mail-Einstellungen wurden gespeichert.', 'mail_saved' => 'Mail-Einstellungen wurden gespeichert.',
'mail_test_button' => 'Verbindung testen', 'mail_test_button' => 'Verbindung testen',
'mail_testing' => 'Teste Verbindung...', 'mail_testing' => 'Teste Verbindung...',
'mail_test_success' => 'SMTP-Verbindung erfolgreich!', '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.', 'no_timekeeper_yet' => 'Noch kein Zeitnehmer eingetragen.',
'timekeeper_updated' => 'Zeitnehmer-Status aktualisiert.', '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 // Kommentare
'comments' => 'Kommentare', 'comments' => 'Kommentare',
'comment_placeholder' => 'Kommentar schreiben...', 'comment_placeholder' => 'Kommentar schreiben...',
@@ -71,6 +89,22 @@ return [
'score_away' => 'Gast', 'score_away' => 'Gast',
'vs' => 'vs.', '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 // Staff-Sichtbarkeit
'signed_up' => 'zugesagt', 'signed_up' => 'zugesagt',
'withdrawn' => 'abgemeldet', 'withdrawn' => 'abgemeldet',

View File

@@ -97,6 +97,24 @@ return [
'parent_rep' => 'Elternvertretung', 'parent_rep' => 'Elternvertretung',
'user' => 'Elternteil', '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 // Sprachen

View File

@@ -306,6 +306,10 @@ return [
'log_participant_changed' => 'Participation status for ":event" changed to :status', 'log_participant_changed' => 'Participation status for ":event" changed to :status',
'log_catering_changed' => 'Catering 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_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_created' => 'Comment added to ":event"',
'log_comment_deleted' => 'Comment deleted from ":event"', 'log_comment_deleted' => 'Comment deleted from ":event"',
'log_file_uploaded' => 'File ":name" uploaded', 'log_file_uploaded' => 'File ":name" uploaded',
@@ -355,6 +359,15 @@ return [
'favicon_uploaded' => 'Favicon has been updated.', 'favicon_uploaded' => 'Favicon has been updated.',
'favicon_removed' => 'Favicon has been removed.', '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 // Undo / Revert
'log_revert' => 'Undo', 'log_revert' => 'Undo',
'log_revert_confirm' => 'Are you sure you want to undo this action?', 'log_revert_confirm' => 'Are you sure you want to undo this action?',
@@ -545,4 +558,21 @@ return [
'mail_test_button' => 'Test Connection', 'mail_test_button' => 'Test Connection',
'mail_testing' => 'Testing connection...', 'mail_testing' => 'Testing connection...',
'mail_test_success' => 'SMTP connection successful!', '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', 'timekeeper_short' => 'Timekeeper',
'no_timekeeper_yet' => 'No timekeeper assigned yet.', 'no_timekeeper_yet' => 'No timekeeper assigned yet.',
'timekeeper_updated' => 'Timekeeper status updated.', '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', 'comments' => 'Comments',
'comment_placeholder' => 'Write a comment...', 'comment_placeholder' => 'Write a comment...',
'no_comments' => 'No comments yet.', 'no_comments' => 'No comments yet.',
@@ -56,6 +75,22 @@ return [
'score_away' => 'Away', 'score_away' => 'Away',
'vs' => 'vs.', '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 // Staff visibility
'signed_up' => 'signed up', 'signed_up' => 'signed up',
'withdrawn' => 'withdrawn', 'withdrawn' => 'withdrawn',

View File

@@ -81,6 +81,24 @@ return [
'parent_rep' => 'Parent Representative', 'parent_rep' => 'Parent Representative',
'user' => 'Parent', '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' => [ 'locales' => [
'de' => 'Deutsch', 'de' => 'Deutsch',

View File

@@ -307,6 +307,10 @@ return [
'log_participant_changed' => 'Status uczestnictwa dla ":event" zmieniony na :status', 'log_participant_changed' => 'Status uczestnictwa dla ":event" zmieniony na :status',
'log_catering_changed' => 'Status cateringu 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_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_created' => 'Dodano komentarz do ":event"',
'log_comment_deleted' => 'Usunięto komentarz z ":event"', 'log_comment_deleted' => 'Usunięto komentarz z ":event"',
'log_file_uploaded' => 'Plik ":name" przesłany', 'log_file_uploaded' => 'Plik ":name" przesłany',
@@ -356,6 +360,15 @@ return [
'favicon_uploaded' => 'Ikona została zaktualizowana.', 'favicon_uploaded' => 'Ikona została zaktualizowana.',
'favicon_removed' => 'Ikona została usunięta.', '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 // Undo / Revert
'log_revert' => 'Cofnij', 'log_revert' => 'Cofnij',
'log_revert_confirm' => 'Czy na pewno chcesz cofnąć tę akcję?', 'log_revert_confirm' => 'Czy na pewno chcesz cofnąć tę akcję?',
@@ -546,4 +559,21 @@ return [
'mail_test_button' => 'Testuj połączenie', 'mail_test_button' => 'Testuj połączenie',
'mail_testing' => 'Testowanie połączenia...', 'mail_testing' => 'Testowanie połączenia...',
'mail_test_success' => 'Połączenie SMTP udane!', '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', 'timekeeper_short' => 'Sędzia czasu',
'no_timekeeper_yet' => 'Brak przypisanego chronometrażysty.', 'no_timekeeper_yet' => 'Brak przypisanego chronometrażysty.',
'timekeeper_updated' => 'Status chronometrażysty zaktualizowany.', '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', 'comments' => 'Komentarze',
'comment_placeholder' => 'Napisz komentarz...', 'comment_placeholder' => 'Napisz komentarz...',
'no_comments' => 'Brak komentarzy.', 'no_comments' => 'Brak komentarzy.',
@@ -57,6 +76,22 @@ return [
'score_away' => 'Goście', 'score_away' => 'Goście',
'vs' => 'vs.', '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 // Staff visibility
'signed_up' => 'zapisany', 'signed_up' => 'zapisany',
'withdrawn' => 'wypisany', 'withdrawn' => 'wypisany',

View File

@@ -82,6 +82,24 @@ return [
'parent_rep' => 'Przedstawiciel rodziców', 'parent_rep' => 'Przedstawiciel rodziców',
'user' => 'Rodzic', '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' => [ 'locales' => [
'de' => 'Deutsch', 'de' => 'Deutsch',

View File

@@ -325,6 +325,10 @@ return [
'log_participant_changed' => 'Статус участия для ":event" изменён на :status', 'log_participant_changed' => 'Статус участия для ":event" изменён на :status',
'log_catering_changed' => 'Статус кейтеринга для ":event" изменён на :status', 'log_catering_changed' => 'Статус кейтеринга для ":event" изменён на :status',
'log_timekeeper_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_created' => 'Комментарий добавлен к ":event"',
'log_comment_deleted' => 'Комментарий удалён из ":event"', 'log_comment_deleted' => 'Комментарий удалён из ":event"',
'log_file_uploaded' => 'Файл ":name" загружен', 'log_file_uploaded' => 'Файл ":name" загружен',
@@ -374,6 +378,15 @@ return [
'favicon_uploaded' => 'Фавикон обновлён.', 'favicon_uploaded' => 'Фавикон обновлён.',
'favicon_removed' => 'Фавикон удалён.', '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 // Undo / Revert
'log_revert' => 'Отменить', 'log_revert' => 'Отменить',
'log_revert_confirm' => 'Вы действительно хотите отменить это действие?', 'log_revert_confirm' => 'Вы действительно хотите отменить это действие?',
@@ -564,4 +577,21 @@ return [
'mail_test_button' => 'Проверить соединение', 'mail_test_button' => 'Проверить соединение',
'mail_testing' => 'Проверка соединения...', 'mail_testing' => 'Проверка соединения...',
'mail_test_success' => 'SMTP-соединение успешно!', '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' => 'Хронометрист ещё не назначен.', 'no_timekeeper_yet' => 'Хронометрист ещё не назначен.',
'timekeeper_updated' => 'Статус хронометриста обновлён.', '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' => 'Комментарии', 'comments' => 'Комментарии',
'comment_placeholder' => 'Написать комментарий...', 'comment_placeholder' => 'Написать комментарий...',
@@ -71,6 +89,22 @@ return [
'score_away' => 'Гости', 'score_away' => 'Гости',
'vs' => 'против', '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 // Staff visibility
'signed_up' => 'записался', 'signed_up' => 'записался',
'withdrawn' => 'отказался', 'withdrawn' => 'отказался',

View File

@@ -82,6 +82,24 @@ return [
'parent_rep' => 'Представитель родителей', 'parent_rep' => 'Представитель родителей',
'user' => 'Родитель', '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' => [ 'locales' => [
'de' => 'Deutsch', '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_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_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_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_created' => '":event" için yorum eklendi',
'log_comment_deleted' => '":event" için yorum silindi', 'log_comment_deleted' => '":event" için yorum silindi',
'log_file_uploaded' => '":name" dosyası yüklendi', 'log_file_uploaded' => '":name" dosyası yüklendi',
@@ -374,6 +378,15 @@ return [
'favicon_uploaded' => 'Favicon güncellendi.', 'favicon_uploaded' => 'Favicon güncellendi.',
'favicon_removed' => 'Favicon kaldırıldı.', '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 // Undo / Revert
'log_revert' => 'Geri al', 'log_revert' => 'Geri al',
'log_revert_confirm' => 'Bu işlemi gerçekten geri almak istiyor musunuz?', '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_test_button' => 'Bağlantıyı Test Et',
'mail_testing' => 'Bağlantı test ediliyor...', 'mail_testing' => 'Bağlantı test ediliyor...',
'mail_test_success' => 'SMTP bağlantısı başarılı!', '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', 'timekeeper_short' => 'Zaman Tutucusu',
'no_timekeeper_yet' => 'Henüz zaman tutucusu atanmadı.', 'no_timekeeper_yet' => 'Henüz zaman tutucusu atanmadı.',
'timekeeper_updated' => 'Zaman tutucusu durumu güncellendi.', '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', 'comments' => 'Yorumlar',
'comment_placeholder' => 'Yorum yaz...', 'comment_placeholder' => 'Yorum yaz...',
'no_comments' => 'Henüz yorum yok.', 'no_comments' => 'Henüz yorum yok.',
@@ -57,6 +76,22 @@ return [
'score_away' => 'Deplasman', 'score_away' => 'Deplasman',
'vs' => 'vs.', '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 // Staff visibility
'signed_up' => 'kaydoldu', 'signed_up' => 'kaydoldu',
'withdrawn' => 'çekildi', 'withdrawn' => 'çekildi',

View File

@@ -82,6 +82,24 @@ return [
'parent_rep' => 'Veli Temsilcisi', 'parent_rep' => 'Veli Temsilcisi',
'user' => 'Veli', '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' => [ 'locales' => [
'de' => 'Deutsch', 'de' => 'Deutsch',

View File

@@ -357,6 +357,106 @@
</div> </div>
@endif @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 --}} {{-- 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 src="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.min.js" integrity="sha384-QUJ+ckWz1M+a7w0UfG1sEn4pPrbQwSxGm/1TIPyioqXBrwuT9l4f9gdHWLDLbVWI" crossorigin="anonymous"></script>
<script> <script>

View File

@@ -44,6 +44,16 @@
</div> </div>
</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"> <div class="mb-4 space-y-2">
<label class="flex items-center"> <label class="flex items-center">
<input type="hidden" name="is_active" value="0"> <input type="hidden" name="is_active" value="0">

View File

@@ -74,6 +74,16 @@
</div> </div>
</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"> <div class="mb-4">
<label class="flex items-center"> <label class="flex items-center">
<input type="hidden" name="photo_permission" value="0"> <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> <p class="mt-1.5 text-xs text-gray-400">{{ __('admin.favicon_hint') }}</p>
</div> </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) --}} {{-- Richtext-Settings (Slogan mit Mini-Quill) --}}
@foreach ($settings as $key => $setting) @foreach ($settings as $key => $setting)
@if ($setting->type === 'richtext') @if ($setting->type === 'richtext')

View File

@@ -142,6 +142,7 @@
{{-- Spieler-Rangliste (nur Staff) --}} {{-- Spieler-Rangliste (nur Staff) --}}
@if (auth()->user()->isStaff() && $playerRanking->isNotEmpty()) @if (auth()->user()->isStaff() && $playerRanking->isNotEmpty())
<div x-data="playerDetailModal()">
<div class="bg-white rounded-lg shadow overflow-hidden mt-6"> <div class="bg-white rounded-lg shadow overflow-hidden mt-6">
<div class="px-4 py-3 border-b border-gray-200"> <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> <h3 class="text-sm font-semibold text-gray-700">{{ __('admin.player_ranking_title') }}</h3>
@@ -153,15 +154,23 @@
<tr> <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 w-8">#</th>
<th class="text-left px-4 py-2.5 font-medium text-gray-600">{{ __('admin.nav_players') }}</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_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="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> <th class="px-4 py-2.5 font-medium text-gray-600 w-32"></th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@php $separatorShown = false; @endphp
@foreach ($playerRanking as $index => $entry) @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 text-gray-400">{{ $index + 1 }}</td>
<td class="px-4 py-2 flex items-center gap-2"> <td class="px-4 py-2 flex items-center gap-2">
@if ($entry->player->getAvatarUrl()) @if ($entry->player->getAvatarUrl())
@@ -169,10 +178,25 @@
@else @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> <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 @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>
<td class="px-4 py-2 text-center font-medium">{{ $entry->games_played }}</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"> <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') }}"> <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 }}% {{ $entry->rate }}%
@@ -188,7 +212,229 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</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 @endif
{{-- Eltern-Engagement-Rangliste --}} {{-- Eltern-Engagement-Rangliste --}}
@@ -255,6 +501,34 @@
@endif @endif
@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) @if ($totalWithScore > 0)
@push('scripts') @push('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-vsrfeLOOY6KuIYKDlmVH5UiBmgIdB1oEf7p01YgWHuqmOHfZr374+odEv96n9tNC" crossorigin="anonymous"></script> <script 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="max-w-5xl mx-auto px-4">
<div class="flex justify-between h-14"> <div class="flex justify-between h-14">
<div class="flex items-center space-x-6 rtl:space-x-reverse"> <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"> <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')) }} {{ \App\Models\Setting::get('app_name', config('app.name')) }}
</a> </a>
<div class="hidden sm:flex items-center space-x-6 rtl:space-x-reverse"> <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"> <main class="flex-1 flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md"> <div class="w-full max-w-md">
<div class="text-center mb-8"> <div class="text-center mb-8">
@php $logoLogin = \App\Models\Setting::get('app_logo_login'); @endphp
<a href="{{ auth()->check() ? route('dashboard') : route('login') }}"> <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> </a>
<h1 class="text-2xl font-bold text-gray-900">{{ \App\Models\Setting::get('app_name', config('app.name')) }}</h1> <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 @php $slogan = \App\Models\Setting::get('app_slogan'); @endphp

View File

@@ -390,6 +390,149 @@
</div> </div>
@endif @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 --}} {{-- Kommentare --}}
<div id="comments" class="bg-white rounded-lg shadow p-6 mb-6"> <div id="comments" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-3">{{ __('events.comments') }}</h2> <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\LoginController;
use App\Http\Controllers\Auth\RegisterController; use App\Http\Controllers\Auth\RegisterController;
use App\Http\Controllers\Auth\ResetPasswordController; use App\Http\Controllers\Auth\ResetPasswordController;
use App\Http\Controllers\CarpoolController;
use App\Http\Controllers\CateringController; use App\Http\Controllers\CateringController;
use App\Http\Controllers\TimekeeperController; use App\Http\Controllers\TimekeeperController;
use App\Http\Controllers\CommentController; 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}/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}/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}/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', [FileController::class, 'index'])->name('files.index');
Route::get('/files/{file}/download', [FileController::class, 'download'])->name('files.download'); 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) --- // --- Fuer alle Admin-Panel-Nutzer (Admin, Coach, ParentRep) ---
Route::get('/', [AdminDashboardController::class, 'index'])->name('dashboard'); Route::get('/', [AdminDashboardController::class, 'index'])->name('dashboard');
Route::get('statistics', [StatisticsController::class, 'index'])->name('statistics.index'); 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) // Events (Leseansicht fuer alle Admin-Panel-Nutzer)
Route::get('events', [AdminEventController::class, 'index'])->name('events.index'); 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::put('events/{event}', [AdminEventController::class, 'update'])->name('events.update');
Route::delete('events/{event}', [AdminEventController::class, 'destroy'])->name('events.destroy'); Route::delete('events/{event}', [AdminEventController::class, 'destroy'])->name('events.destroy');
Route::patch('events/{event}/participant', [AdminEventController::class, 'updateParticipant'])->name('events.update-participant'); 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'); Route::put('events/{event}/restore', [AdminEventController::class, 'restore'])->name('events.restore');
// Aktivitaetslog (Staff-Level + canViewActivityLog-Pruefung im Controller) // Aktivitaetslog (Staff-Level + canViewActivityLog-Pruefung im Controller)