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:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal 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
126
CLAUDE.md
@@ -3,6 +3,7 @@
|
||||
## Projekt-Typ
|
||||
Laravel 12 WebApp zur Verwaltung von Handball-Teams, Spielern, Eltern, Terminen und Dateien.
|
||||
PHP 8.2+, SQLite/MySQL, Blade + Alpine.js + Tailwind CSS, Quill.js WYSIWYG-Editor.
|
||||
Deployment: Shared Hosting (all-inkl.com, FTP only, kein SSH).
|
||||
|
||||
## Architektur
|
||||
|
||||
@@ -10,14 +11,14 @@ PHP 8.2+, SQLite/MySQL, Blade + Alpine.js + Tailwind CSS, Quill.js WYSIWYG-Edito
|
||||
- `app/Models/` — Eloquent Models (User, Player, Team, Event, Setting, etc.)
|
||||
- `app/Http/Controllers/Admin/` — Admin-Bereich (UserController, SettingsController, etc.)
|
||||
- `app/Http/Controllers/Auth/` — Login, Register, ForgotPassword, ResetPassword
|
||||
- `app/Http/Middleware/` — InstallerMiddleware (Setup-Token), SetLocaleMiddleware, SecurityHeadersMiddleware, ActiveUserMiddleware, DsgvoConsentMiddleware
|
||||
- `app/Http/Middleware/` — InstallerMiddleware, SetLocaleMiddleware, SecurityHeadersMiddleware, ActiveUserMiddleware, DsgvoConsentMiddleware, StaffMiddleware, AdminOnlyMiddleware
|
||||
- `app/Services/` — HtmlSanitizerService (HTMLPurifier), GeocodingService, SupportApiService
|
||||
- `app/Enums/` — UserRole, EventType, EventStatus, ParticipantStatus, CateringStatus
|
||||
- `app/Notifications/` — ResetPasswordNotification (Custom, mit Setting-Template)
|
||||
- `resources/views/` — Blade-Templates (layouts: admin, guest, app)
|
||||
- `lang/{de,en,pl,ru,ar,tr}/` — 6 Sprachen: Deutsch, Englisch, Polnisch, Russisch, Arabisch, Turkisch
|
||||
- `resources/views/` — Blade-Templates (layouts: admin, guest, app, installer)
|
||||
- `lang/{de,en,pl,ru,ar,tr}/` — 6 Sprachen: Deutsch, Englisch, Polnisch, Russisch, Arabisch, Tuerkisch
|
||||
- `database/migrations/` — Nummeriert mit Prefix 0001-0035
|
||||
- `database/seeders/` — AdminSeeder (benotigt ADMIN_EMAIL + ADMIN_PASSWORD in .env)
|
||||
- `database/seeders/` — AdminSeeder, DemoSeeder, FaqSeeder
|
||||
|
||||
### Wichtige Patterns
|
||||
- **Settings**: Key-Value Store in `settings` Tabelle. `Setting::get($key)` mit 1-Stunden-Cache, `Setting::set($key, $value)` mit Cache-Invalidierung
|
||||
@@ -27,43 +28,92 @@ PHP 8.2+, SQLite/MySQL, Blade + Alpine.js + Tailwind CSS, Quill.js WYSIWYG-Edito
|
||||
- **Rollen**: Admin, Coach, ParentRep, User (Enum `UserRole`)
|
||||
- **Soft-Deletes**: User und Player (7 Tage Wiederherstellung)
|
||||
- **Activity-Log**: `ActivityLog::log()` / `ActivityLog::logWithChanges()` fuer Audit-Trail
|
||||
- **User-Model**: Nutzt `Notifiable` Trait (erforderlich fuer Passwort-Reset-Notifications)
|
||||
- **.env-Manipulation**: `updateEnvValues()` Helper in InstallerController und SettingsController
|
||||
|
||||
### Multi-Language
|
||||
- 6 Sprachen: de, en, pl, ru, ar, tr
|
||||
- Translation-Dateien: `lang/{locale}/admin.php`, `auth_ui.php`, `passwords.php`, `ui.php`, `events.php`, `profile.php`, `validation.php`
|
||||
- Locale wird per SetLocaleMiddleware aus `$user->locale` oder Session gesetzt
|
||||
- RTL-Support fuer Arabisch (ar)
|
||||
- **Alle Uebersetzungsschluessel muessen in ALLEN 6 Sprachen hinzugefuegt werden**
|
||||
|
||||
### Installer (5-Step Wizard)
|
||||
1. Systemcheck (PHP-Version, Extensions, Berechtigungen)
|
||||
2. Datenbank (SQLite/MySQL Konfiguration)
|
||||
3. Einstellungen (App-Name, Admin-Account)
|
||||
4. E-Mail (SMTP-Konfiguration mit Verbindungstest, oder Log-Modus zum Ueberspringen)
|
||||
5. Abschluss (Finalize, Demo-Daten, Lizenz)
|
||||
|
||||
- `InstallerMiddleware` prueft `storage/installed` Datei
|
||||
- Setup-Token-Schutz: Token in `storage/setup-token`, SHA256 im Laravel-Log
|
||||
- SMTP-Test nutzt `Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport`
|
||||
- PW-Reset-Standardtexte fuer 6 Sprachen in `getDefaultPasswordResetTexts()`
|
||||
- Session-basierte Datenpersistenz zwischen Schritten
|
||||
|
||||
### Password Reset Flow
|
||||
1. User klickt "Passwort vergessen?" auf Login-Seite
|
||||
2. ForgotPasswordController sendet Reset-Link via Laravel Password Broker
|
||||
3. ResetPasswordNotification laedt optionalen Custom-Template-Text aus `Setting::get('password_reset_email_{locale}')`
|
||||
3. ResetPasswordNotification laedt Custom-Template aus `Setting::get('password_reset_email_{locale}')`
|
||||
4. Platzhalter: `{name}`, `{link}`, `{app_name}`
|
||||
5. Fallback auf Standard-Laravel-Template mit Translation-Keys (`passwords.reset_*`)
|
||||
|
||||
### Installer
|
||||
- InstallerMiddleware prueft `storage/installed` Datei
|
||||
- Setup-Token-Schutz: Token in `storage/setup-token`, wird beim ersten Zugriff generiert und in Laravel-Log geschrieben
|
||||
- Nach Installation wird `storage/installed` erstellt
|
||||
### Admin Settings Tabs
|
||||
- **Allgemein**: App-Name, Slogan, Favicon, Saison
|
||||
- **E-Mail**: SMTP-Konfiguration mit Verbindungstest (schreibt in .env, `Artisan::call('config:clear')`)
|
||||
- **Rechtliches**: Impressum + Datenschutz + PW-Reset-E-Mail pro Sprache (Quill-Editor mit Flaggen-Selector)
|
||||
- **Event-Standards**: Min-Werte fuer Spieler, Catering, Zeitnehmer pro Event-Typ
|
||||
- **Dateikategorien**: CRUD fuer File-Categories
|
||||
- **Sichtbarkeit**: Feature-Toggles
|
||||
- **Lizenz**: License-Key-Validierung via SupportApiService
|
||||
- **Wartung**: Demo-Daten loeschen, Factory-Reset
|
||||
|
||||
### Security
|
||||
### Lazy Quill-Initialisierung (Rechtliches-Tab)
|
||||
- Quill-Editoren werden erst bei Klick auf die Sprach-Flagge erstellt (`initLocaleEditors(locale)`)
|
||||
- Verhindert Datenverlust: Nicht-initialisierte Locales behalten ihre Server-Werte in Hidden-Inputs
|
||||
- `$watch('legalLocale')` + `$nextTick()` fuer verzoegerte Erstellung
|
||||
- `syncEditors()` synchronisiert nur tatsaechlich initialisierte Editoren
|
||||
|
||||
### Route-Struktur (Middleware)
|
||||
- Installer: `throttle:10,1`, ohne SetLocaleMiddleware/ActiveUserMiddleware
|
||||
- Oeffentlich: Login, Register, Legal-Pages, Locale-Switch
|
||||
- User-Bereich: `auth` Middleware
|
||||
- Admin-Bereich: `auth` + `admin` + `prefix('admin')`
|
||||
- Staff-Routes (Admin + Coach): `staff` Middleware
|
||||
- Admin-Only-Routes: `admin-only` Middleware (Settings, Locations, Support)
|
||||
|
||||
## Security
|
||||
|
||||
### Middleware-Stack
|
||||
- CSP und Permissions-Policy via SecurityHeadersMiddleware (inkl. COOP-Header)
|
||||
- `StaffMiddleware` — prueft Admin oder Coach Rolle
|
||||
- `AdminOnlyMiddleware` — prueft explizit Admin-Rolle (Route-Level-Schutz)
|
||||
- Rate-Limiting auf Auth-Routes, User-Actions, Geocoding, Installer
|
||||
|
||||
### Schutzmassnahmen
|
||||
- Honeypot-Feld auf Login/Register-Formularen
|
||||
- Rate-Limiting auf Auth-Routes (`throttle:login`)
|
||||
- DSGVO-Consent-System mit Datei-Upload und Admin-Bestaetigung
|
||||
- Factory Reset benoetigt Passwort + Bestaetigung "RESET-BESTAETIGT"
|
||||
- **File-Autorisierung**: `authorizeFileAccess()` in FileController prueft Team-Zugehoerigkeit und aktive Kategorien
|
||||
- **File-Autorisierung**: `authorizeFileAccess()` in FileController prueft Team-Zugehoerigkeit
|
||||
- **Settings-Whitelist**: SettingsController akzeptiert nur bekannte Keys + validierte Locale-Suffixe
|
||||
- **SSRF-Schutz**: GeocodingService mit Host-Whitelist (nominatim.openstreetmap.org, photon.komoot.io), HTTPS-only, Timeout 5s
|
||||
- **Installer-Sicherheit**: Admin-Passwort wird sofort gehasht (nicht Klartext in Session), Setup-Token nur als SHA256 geloggt
|
||||
- **SSRF-Schutz**: GeocodingService mit Host-Whitelist, HTTPS-only, DNS-Rebinding-Check, Timeout 5s
|
||||
- **Installer-Sicherheit**: Admin-Passwort sofort gehasht, Setup-Token nur als SHA256 geloggt
|
||||
- **Path-Traversal-Schutz**: DSGVO-Datei-Downloads pruefen `str_starts_with('dsgvo/')`
|
||||
- **HTML-Sanitisierung**: Alle `{!! !!}`-Ausgaben (Slogan, Settings-Editor, PWA-Banner) durch `HtmlSanitizerService::sanitize()` geschuetzt
|
||||
- **HTML-Sanitisierung**: Alle User-Content-Ausgaben durch HtmlSanitizerService geschuetzt
|
||||
- **Invitation-Tokens**: SHA-256 gehasht in DB gespeichert
|
||||
- **E-Mail-Normalisierung**: Lowercase-Vergleich bei Login und Password-Reset
|
||||
- **Team-Scoping**: Coach/ParentRep sehen nur ihre zugewiesenen Teams
|
||||
|
||||
### Security-Audit-Historie (Maerz 2026)
|
||||
6 Audits durchgefuehrt, Score: 6.5 → 9.5/10
|
||||
- 95 Findings insgesamt, 88 behoben, 3 akzeptierte Risiken, 4 verbleibende Niedrig/Info
|
||||
- Akzeptierte Risiken: MIME-Spoofing (F05), Enum-Reuse (F20), CSP unsafe-inline (W04, CDN-Abhaengigkeit)
|
||||
|
||||
## Conventions
|
||||
- Controller-Methoden: resourceful (index, create, store, show, edit, update, destroy)
|
||||
- Blade-Components: `<x-layouts.admin>`, `<x-layouts.guest>`, `<x-layouts.app>`
|
||||
- Blade-Components: `<x-layouts.admin>`, `<x-layouts.guest>`, `<x-layouts.app>`, `<x-layouts.installer>`
|
||||
- Alpine.js fuer interaktive UI-Elemente
|
||||
- Quill.js v1.3.7 fuer WYSIWYG-Editoren (via CDN)
|
||||
- Quill.js v1.3.7 fuer WYSIWYG-Editoren (via CDN mit SRI-Hashes)
|
||||
- Keine Tests vorhanden — bei Aenderungen manuell testen
|
||||
- Commit-Sprache: Deutsch oder Englisch
|
||||
- Alle Uebersetzungsschluessel muessen in ALLEN 6 Sprachen hinzugefuegt werden
|
||||
@@ -84,38 +134,14 @@ Immer in allen 6 Dateien: `lang/de/`, `lang/en/`, `lang/pl/`, `lang/ru/`, `lang/
|
||||
2. Migration fuer alle 6 Locales
|
||||
3. In SettingsController `$localeSettings` Array aufnehmen
|
||||
4. In View mit Sprach-Flaggen-Selector anzeigen
|
||||
5. In `update()` mit `str_starts_with()` und `updateOrCreate()` behandeln
|
||||
5. In `update()` mit Whitelist und `updateOrCreate()` behandeln
|
||||
6. **Wichtig**: Neuen Key zur Whitelist in `SettingsController::update()` hinzufuegen ($allowedLocaleKeys)
|
||||
|
||||
## Security Audit & Haertung (Maerz 2026)
|
||||
|
||||
### Audit-Report
|
||||
- **Datei**: `security-audit-2026-03.html` — Vollstaendiger HTML-Report mit allen Findings und Fix-Dokumentation
|
||||
- **Score**: 6.5 → 8.5 / 10 nach Haertung
|
||||
- **Ergebnis**: 20 Findings identifiziert, 19 behoben, 1 als akzeptiertes Risiko
|
||||
|
||||
### Behobene Schwachstellen (nach Datei)
|
||||
|
||||
| Datei | Findings | Aenderungen |
|
||||
|-------|----------|-------------|
|
||||
| `FileController.php` | F01 (Kritisch) | `authorizeFileAccess()` — IDOR-Schutz fuer Downloads/Previews |
|
||||
| `admin/settings/edit.blade.php` | F02 (Kritisch) | Alle `{!! !!}` durch `HtmlSanitizerService::sanitize()` |
|
||||
| `layouts/app.blade.php`, `guest.blade.php` | F03 (Kritisch) | Slogan-Ausgabe sanitisiert |
|
||||
| `Admin/SettingsController.php` | F04 (Hoch) | Settings-Key-Whitelist in `update()` |
|
||||
| `Services/GeocodingService.php` | F06 (Hoch) | ALLOWED_HOSTS, HTTPS-only, SHA256-Cache, Timeout |
|
||||
| `EventController.php` | F07 (Hoch) | `accessibleTeamIds()`, EventType-Validierung, int-Cast |
|
||||
| `InstallerController.php` | F08 (Mittel) | Passwort sofort gehasht (`installer.admin_password_hash`) |
|
||||
| `InstallerMiddleware.php` | F09 (Mittel) | Token als SHA256 geloggt, chmod 0600 |
|
||||
| `SecurityHeadersMiddleware.php` | F10, F19 | COOP-Header hinzugefuegt |
|
||||
| `.env.example` | F11 (Mittel) | SESSION_SECURE_COOKIE + SESSION_SAME_SITE Defaults |
|
||||
| `Admin/TeamController.php` | F12 (Mittel) | Ziel-Team aktiv-Pruefung in `updatePlayerTeam()` |
|
||||
| `Admin/ActivityLogController.php` | F13, F16 | whereRaw→Eloquent, `canViewActivityLog()` statt `id !== 1` |
|
||||
| `CommentController.php` | F14 (Mittel) | `e()` entfernt (Doppel-Escaping behoben) |
|
||||
| `pwa-install-banner.blade.php` | F15 (Mittel) | Translation-Ausgabe sanitisiert |
|
||||
| `ProfileController.php` | F17 (Niedrig) | DSGVO Path-Prefix-Check |
|
||||
| `Auth/ResetPasswordController.php` | F18 (Niedrig) | `Password::min(8)->letters()->numbers()` |
|
||||
|
||||
### Akzeptierte Restrisiken
|
||||
- **F05** (MIME-Spoofing): UUID-Dateinamen + privater Storage + Autorisierung mitigieren das Risiko
|
||||
- **F20** (EventTimekeeper Enum): CateringStatus und TimekeeperStatus haben identische Werte
|
||||
- **CSP unsafe-inline/eval**: CDN-Abhaengigkeit (Tailwind/Alpine.js/Quill.js) erfordert dies — langfristig lokale Assets empfohlen
|
||||
### Neue Admin-Route hinzufuegen
|
||||
1. Route in `routes/web.php` im passenden Middleware-Block:
|
||||
- Alle Admin-Panel-Nutzer: direkt unter `admin` Prefix
|
||||
- Staff (Admin + Coach): unter `staff` Middleware
|
||||
- Nur Admin: unter `admin-only` Middleware
|
||||
2. Controller-Methode mit Autorisierungs-Pruefung
|
||||
3. View erstellen
|
||||
4. Translation-Keys fuer alle 6 Sprachen
|
||||
|
||||
@@ -31,6 +31,11 @@ enum EventType: string
|
||||
return !in_array($this, [self::AwayGame, self::Meeting]);
|
||||
}
|
||||
|
||||
public function hasCarpool(): bool
|
||||
{
|
||||
return $this !== self::Meeting;
|
||||
}
|
||||
|
||||
public function hasPlayerParticipants(): bool
|
||||
{
|
||||
return $this !== self::Meeting;
|
||||
|
||||
34
app/Enums/PlayerPosition.php
Normal file
34
app/Enums/PlayerPosition.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ use App\Enums\CateringStatus;
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventType;
|
||||
use App\Enums\ParticipantStatus;
|
||||
use App\Enums\PlayerPosition;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCatering;
|
||||
use App\Models\EventPlayerStat;
|
||||
use App\Models\EventTimekeeper;
|
||||
use App\Models\File;
|
||||
use App\Models\FileCategory;
|
||||
@@ -120,13 +122,16 @@ class EventController extends Controller
|
||||
$participantRelations = $event->type === EventType::Meeting
|
||||
? ['participants.user']
|
||||
: ['participants.player'];
|
||||
$event->load(array_merge($participantRelations, ['caterings', 'timekeepers', 'files.category']));
|
||||
$event->load(array_merge($participantRelations, ['caterings', 'timekeepers', 'files.category', 'playerStats']));
|
||||
$assignedCatering = $event->caterings->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
|
||||
$assignedTimekeeper = $event->timekeepers->where('status', CateringStatus::Yes)->pluck('user_id')->toArray();
|
||||
$knownLocations = Location::orderBy('name')->get();
|
||||
$fileCategories = FileCategory::active()->ordered()->with(['files' => fn ($q) => $q->latest()])->get();
|
||||
|
||||
return view('admin.events.edit', compact('event', 'teams', 'types', 'statuses', 'teamParents', 'assignedCatering', 'assignedTimekeeper', 'eventDefaults', 'knownLocations', 'fileCategories'));
|
||||
// Spielerstatistik-Daten für Spieltypen
|
||||
$playerStatsMap = $event->playerStats->keyBy('player_id');
|
||||
|
||||
return view('admin.events.edit', compact('event', 'teams', 'types', 'statuses', 'teamParents', 'assignedCatering', 'assignedTimekeeper', 'eventDefaults', 'knownLocations', 'fileCategories', 'playerStatsMap'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Event $event): RedirectResponse
|
||||
@@ -210,6 +215,58 @@ class EventController extends Controller
|
||||
->with('success', __('admin.event_restored'));
|
||||
}
|
||||
|
||||
public function updateStats(Request $request, Event $event): RedirectResponse
|
||||
{
|
||||
if (! $event->type->isGameType()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'stats' => ['required', 'array'],
|
||||
'stats.*.is_goalkeeper' => ['nullable'],
|
||||
'stats.*.position' => ['nullable', 'string', \Illuminate\Validation\Rule::in(PlayerPosition::values())],
|
||||
'stats.*.goalkeeper_saves' => ['nullable', 'integer', 'min:0', 'max:999'],
|
||||
'stats.*.goalkeeper_shots' => ['nullable', 'integer', 'min:0', 'max:999'],
|
||||
'stats.*.goals' => ['nullable', 'integer', 'min:0', 'max:999'],
|
||||
'stats.*.shots' => ['nullable', 'integer', 'min:0', 'max:999'],
|
||||
'stats.*.note' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$stats = $request->input('stats', []);
|
||||
|
||||
foreach ($stats as $playerId => $data) {
|
||||
$position = ! empty($data['position']) ? $data['position'] : null;
|
||||
$isGk = $position === 'torwart' || ! empty($data['is_goalkeeper']);
|
||||
$goals = isset($data['goals']) && $data['goals'] !== '' ? (int) $data['goals'] : null;
|
||||
$shots = isset($data['shots']) && $data['shots'] !== '' ? (int) $data['shots'] : null;
|
||||
$gkSaves = $isGk && isset($data['goalkeeper_saves']) && $data['goalkeeper_saves'] !== '' ? (int) $data['goalkeeper_saves'] : null;
|
||||
$gkShots = $isGk && isset($data['goalkeeper_shots']) && $data['goalkeeper_shots'] !== '' ? (int) $data['goalkeeper_shots'] : null;
|
||||
$note = ! empty($data['note']) ? trim($data['note']) : null;
|
||||
|
||||
// Leere Einträge löschen
|
||||
if (! $isGk && $goals === null && $shots === null && $note === null && $position === null) {
|
||||
EventPlayerStat::where('event_id', $event->id)->where('player_id', $playerId)->delete();
|
||||
continue;
|
||||
}
|
||||
|
||||
EventPlayerStat::updateOrCreate(
|
||||
['event_id' => $event->id, 'player_id' => (int) $playerId],
|
||||
[
|
||||
'is_goalkeeper' => $isGk,
|
||||
'position' => $position,
|
||||
'goalkeeper_saves' => $gkSaves,
|
||||
'goalkeeper_shots' => $gkShots,
|
||||
'goals' => $goals,
|
||||
'shots' => $shots,
|
||||
'note' => $note,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.events.edit', $event)
|
||||
->with('success', __('events.stats_saved'));
|
||||
}
|
||||
|
||||
private function validateEvent(Request $request): array
|
||||
{
|
||||
$request->validate([
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\PlayerPosition;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Event;
|
||||
@@ -78,6 +79,7 @@ class PlayerController extends Controller
|
||||
}],
|
||||
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
|
||||
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
|
||||
'position' => ['nullable', 'string', \Illuminate\Validation\Rule::in(PlayerPosition::values())],
|
||||
'is_active' => ['boolean'],
|
||||
'photo_permission' => ['boolean'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
@@ -120,6 +122,7 @@ class PlayerController extends Controller
|
||||
}],
|
||||
'birth_year' => ['nullable', 'integer', 'min:2000', 'max:2030'],
|
||||
'jersey_number' => ['nullable', 'integer', 'min:1', 'max:99'],
|
||||
'position' => ['nullable', 'string', \Illuminate\Validation\Rule::in(PlayerPosition::values())],
|
||||
'photo_permission' => ['boolean'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
'profile_picture' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,gif,webp'],
|
||||
|
||||
@@ -91,28 +91,35 @@ class SettingsController extends Controller
|
||||
abort(403, 'Nur Admins koennen Einstellungen aendern.');
|
||||
}
|
||||
|
||||
// Favicon-Upload verarbeiten (vor der normalen Settings-Schleife)
|
||||
if ($request->hasFile('favicon')) {
|
||||
$request->validate([
|
||||
'favicon' => 'file|mimes:ico,png,svg,jpg,jpeg,gif,webp|max:512',
|
||||
]);
|
||||
// Bild-Uploads verarbeiten (vor der normalen Settings-Schleife)
|
||||
$imageUploads = [
|
||||
'favicon' => ['setting' => 'app_favicon', 'dir' => 'favicon', 'max' => 512],
|
||||
'logo_login' => ['setting' => 'app_logo_login', 'dir' => 'logos', 'max' => 1024],
|
||||
'logo_app' => ['setting' => 'app_logo_app', 'dir' => 'logos', 'max' => 1024],
|
||||
];
|
||||
|
||||
// Altes Favicon löschen
|
||||
$oldFavicon = Setting::get('app_favicon');
|
||||
if ($oldFavicon) {
|
||||
Storage::disk('public')->delete($oldFavicon);
|
||||
}
|
||||
foreach ($imageUploads as $field => $config) {
|
||||
if ($request->hasFile($field)) {
|
||||
$request->validate([
|
||||
$field => 'file|mimes:ico,png,svg,jpg,jpeg,gif,webp|max:' . $config['max'],
|
||||
]);
|
||||
|
||||
$file = $request->file('favicon');
|
||||
$filename = Str::uuid() . '.' . $file->guessExtension();
|
||||
$path = $file->storeAs('favicon', $filename, 'public');
|
||||
Setting::set('app_favicon', $path);
|
||||
} elseif ($request->has('remove_favicon')) {
|
||||
$oldFavicon = Setting::get('app_favicon');
|
||||
if ($oldFavicon) {
|
||||
Storage::disk('public')->delete($oldFavicon);
|
||||
$oldFile = Setting::get($config['setting']);
|
||||
if ($oldFile) {
|
||||
Storage::disk('public')->delete($oldFile);
|
||||
}
|
||||
|
||||
$file = $request->file($field);
|
||||
$filename = Str::uuid() . '.' . $file->guessExtension();
|
||||
$path = $file->storeAs($config['dir'], $filename, 'public');
|
||||
Setting::set($config['setting'], $path);
|
||||
} elseif ($request->has("remove_{$field}")) {
|
||||
$oldFile = Setting::get($config['setting']);
|
||||
if ($oldFile) {
|
||||
Storage::disk('public')->delete($oldFile);
|
||||
}
|
||||
Setting::set($config['setting'], null);
|
||||
}
|
||||
Setting::set('app_favicon', null);
|
||||
}
|
||||
|
||||
$inputSettings = $request->input('settings', []);
|
||||
@@ -310,6 +317,9 @@ class SettingsController extends Controller
|
||||
// Löschreihenfolge beachtet FK-Constraints
|
||||
DB::table('activity_logs')->delete();
|
||||
DB::table('comments')->delete();
|
||||
DB::table('event_player_stats')->delete();
|
||||
DB::table('event_carpool_passengers')->delete();
|
||||
DB::table('event_carpools')->delete();
|
||||
DB::table('event_participants')->delete();
|
||||
DB::table('event_catering')->delete();
|
||||
DB::table('event_timekeepers')->delete();
|
||||
@@ -364,6 +374,7 @@ class SettingsController extends Controller
|
||||
Storage::disk('private')->deleteDirectory('files');
|
||||
Storage::disk('public')->deleteDirectory('avatars');
|
||||
Storage::disk('public')->deleteDirectory('favicon');
|
||||
Storage::disk('public')->deleteDirectory('logos');
|
||||
Storage::disk('public')->deleteDirectory('dsgvo');
|
||||
|
||||
// 2. FK-Constraints deaktivieren (DB-agnostisch)
|
||||
@@ -376,7 +387,8 @@ class SettingsController extends Controller
|
||||
|
||||
// 3. Alle Tabellen leeren
|
||||
$tables = [
|
||||
'activity_logs', 'comments', 'event_participants',
|
||||
'activity_logs', 'comments', 'event_player_stats',
|
||||
'event_carpool_passengers', 'event_carpools', 'event_participants',
|
||||
'event_catering', 'event_timekeepers', 'event_faq',
|
||||
'event_file', 'events', 'parent_player', 'players',
|
||||
'team_user', 'team_file', 'teams',
|
||||
|
||||
@@ -6,15 +6,18 @@ use App\Enums\CateringStatus;
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventType;
|
||||
use App\Enums\ParticipantStatus;
|
||||
use App\Enums\PlayerPosition;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCatering;
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\EventPlayerStat;
|
||||
use App\Models\EventTimekeeper;
|
||||
use App\Models\Player;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
@@ -113,6 +116,34 @@ class StatisticsController extends Controller
|
||||
$gameIds = $games->pluck('id');
|
||||
$totalGames = $games->count();
|
||||
|
||||
// Tore pro Spieler aus event_player_stats
|
||||
$goalsByPlayer = EventPlayerStat::whereIn('event_id', $gameIds)
|
||||
->select('player_id', DB::raw('COALESCE(SUM(goals), 0) as total_goals'))
|
||||
->groupBy('player_id')
|
||||
->pluck('total_goals', 'player_id');
|
||||
|
||||
// Häufigste Position pro Spieler aus event_player_stats
|
||||
$positionCounts = EventPlayerStat::whereIn('event_id', $gameIds)
|
||||
->whereNotNull('position')
|
||||
->select('player_id', 'position', DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('player_id', 'position')
|
||||
->get()
|
||||
->groupBy('player_id')
|
||||
->map(fn ($g) => $g->sortByDesc('cnt')->first()->position);
|
||||
|
||||
// Aggregierte Performance-Daten pro Spieler
|
||||
$playerAggStats = EventPlayerStat::whereIn('event_id', $gameIds)
|
||||
->select(
|
||||
'player_id',
|
||||
DB::raw('COALESCE(SUM(goals), 0) as total_goals_agg'),
|
||||
DB::raw('COALESCE(SUM(shots), 0) as total_shots_agg'),
|
||||
DB::raw('COALESCE(SUM(goalkeeper_saves), 0) as total_gk_saves'),
|
||||
DB::raw('COALESCE(SUM(goalkeeper_shots), 0) as total_gk_shots')
|
||||
)
|
||||
->groupBy('player_id')
|
||||
->get()
|
||||
->keyBy('player_id');
|
||||
|
||||
$playerRanking = collect();
|
||||
if ($totalGames > 0) {
|
||||
$playerRanking = EventParticipant::select('player_id', DB::raw('COUNT(*) as total_assigned'), DB::raw('SUM(CASE WHEN status = \'yes\' THEN 1 ELSE 0 END) as games_played'))
|
||||
@@ -120,27 +151,67 @@ class StatisticsController extends Controller
|
||||
->whereNotNull('player_id')
|
||||
->groupBy('player_id')
|
||||
->get()
|
||||
->map(function ($row) use ($totalGames) {
|
||||
->map(function ($row) use ($totalGames, $goalsByPlayer, $positionCounts, $playerAggStats) {
|
||||
$player = Player::withTrashed()->find($row->player_id);
|
||||
if (!$player) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Primäre Position: häufigste aus Stats, Fallback auf player.position
|
||||
$primaryPosition = $positionCounts->get($row->player_id) ?? $player->position;
|
||||
$isPrimaryGk = $primaryPosition?->isGoalkeeper() ?? false;
|
||||
|
||||
// Performance-Rate + Ampelfarbe berechnen
|
||||
$agg = $playerAggStats->get($row->player_id);
|
||||
$performanceRate = null;
|
||||
$performanceColor = 'gray';
|
||||
|
||||
if ($agg) {
|
||||
if ($isPrimaryGk) {
|
||||
// Torwart: Fangquote
|
||||
if ($agg->total_gk_shots > 0) {
|
||||
$performanceRate = round(($agg->total_gk_saves / $agg->total_gk_shots) * 100, 1);
|
||||
$performanceColor = $performanceRate >= 40 ? 'green' : ($performanceRate >= 25 ? 'yellow' : 'red');
|
||||
}
|
||||
} else {
|
||||
// Feldspieler: Trefferquote
|
||||
if ($agg->total_shots_agg > 0) {
|
||||
$performanceRate = round(($agg->total_goals_agg / $agg->total_shots_agg) * 100, 1);
|
||||
$performanceColor = $performanceRate >= 50 ? 'green' : ($performanceRate >= 30 ? 'yellow' : 'red');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (object) [
|
||||
'player' => $player,
|
||||
'games_played' => (int) $row->games_played,
|
||||
'total_assigned' => (int) $row->total_assigned,
|
||||
'total_games' => $totalGames,
|
||||
'total_goals' => (int) ($goalsByPlayer[$row->player_id] ?? 0),
|
||||
'rate' => $row->total_assigned > 0
|
||||
? round(($row->games_played / $row->total_assigned) * 100)
|
||||
: 0,
|
||||
'primary_position' => $primaryPosition,
|
||||
'is_primary_gk' => $isPrimaryGk,
|
||||
'performance_rate' => $performanceRate,
|
||||
'performance_color' => $performanceColor,
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->sortByDesc('games_played')
|
||||
->sortBy([
|
||||
// Torwarte zuerst, dann Feldspieler
|
||||
['is_primary_gk', 'desc'],
|
||||
['games_played', 'desc'],
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
// Spielfeld-Aufstellung: Bester Spieler pro Position (meiste Spiele)
|
||||
$courtPlayers = $playerRanking
|
||||
->filter(fn ($e) => $e->primary_position !== null)
|
||||
->groupBy(fn ($e) => $e->primary_position->value)
|
||||
->map(fn ($group) => $group->sortByDesc('games_played')->first());
|
||||
|
||||
// ── Eltern-Engagement-Rangliste ────────────────────────
|
||||
// Alle publizierten Events (nicht nur Spiele) mit gleichen Team/Datum-Filtern
|
||||
$allEventsQuery = Event::where('status', EventStatus::Published);
|
||||
@@ -202,8 +273,58 @@ class StatisticsController extends Controller
|
||||
return view('admin.statistics.index', compact(
|
||||
'games', 'teams', 'wins', 'losses', 'draws', 'winRate', 'totalWithScore',
|
||||
'chartWinLoss', 'chartPlayerParticipation', 'chartParentInvolvement',
|
||||
'playerRanking', 'totalGames',
|
||||
'playerRanking', 'totalGames', 'courtPlayers',
|
||||
'parentRanking', 'totalCateringEvents', 'totalTimekeeperEvents'
|
||||
));
|
||||
}
|
||||
|
||||
public function playerDetail(Player $player): JsonResponse
|
||||
{
|
||||
if (!Setting::isFeatureVisibleFor('statistics', auth()->user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$stats = EventPlayerStat::where('player_id', $player->id)
|
||||
->with(['event' => fn ($q) => $q->select('id', 'title', 'opponent', 'score_home', 'score_away', 'type', 'start_at', 'team_id')])
|
||||
->whereHas('event', fn ($q) => $q->where('status', EventStatus::Published))
|
||||
->get()
|
||||
->sortByDesc(fn ($s) => $s->event->start_at)
|
||||
->values();
|
||||
|
||||
$totalGoals = $stats->sum('goals');
|
||||
$totalShots = $stats->sum('shots');
|
||||
$gkGames = $stats->where('is_goalkeeper', true);
|
||||
$totalGkSaves = $gkGames->sum('goalkeeper_saves');
|
||||
$totalGkShots = $gkGames->sum('goalkeeper_shots');
|
||||
|
||||
return response()->json([
|
||||
'player' => [
|
||||
'name' => $player->full_name,
|
||||
'avatar' => $player->getAvatarUrl(),
|
||||
'initials' => $player->getInitials(),
|
||||
'position' => $player->position?->label(),
|
||||
],
|
||||
'summary' => [
|
||||
'total_goals' => $totalGoals,
|
||||
'total_shots' => $totalShots,
|
||||
'hit_rate' => $totalShots > 0 ? round(($totalGoals / $totalShots) * 100, 1) : null,
|
||||
'gk_appearances' => $gkGames->count(),
|
||||
'total_saves' => $totalGkSaves,
|
||||
'total_gk_shots' => $totalGkShots,
|
||||
'save_rate' => $totalGkShots > 0 ? round(($totalGkSaves / $totalGkShots) * 100, 1) : null,
|
||||
],
|
||||
'games' => $stats->map(fn ($s) => [
|
||||
'date' => $s->event->start_at->format('d.m.Y'),
|
||||
'opponent' => $s->event->opponent ?? '–',
|
||||
'score' => $s->event->score_home !== null ? $s->event->score_home . ':' . ($s->event->score_away ?? '?') : '–',
|
||||
'position' => $s->position?->shortLabel(),
|
||||
'goals' => $s->goals,
|
||||
'shots' => $s->shots,
|
||||
'is_goalkeeper' => $s->is_goalkeeper,
|
||||
'goalkeeper_saves' => $s->goalkeeper_saves,
|
||||
'goalkeeper_shots' => $s->goalkeeper_shots,
|
||||
'note' => $s->note,
|
||||
])->toArray(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
176
app/Http/Controllers/CarpoolController.php
Normal file
176
app/Http/Controllers/CarpoolController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ class EventController extends Controller
|
||||
}
|
||||
|
||||
// Kinder einmal laden, für Zugriffsprüfung + Teilnahme-Buttons
|
||||
$userChildren = $user->children()->select('players.id', 'players.team_id')->get();
|
||||
$userChildren = $user->children()->select('players.id', 'players.team_id', 'players.first_name', 'players.last_name')->get();
|
||||
|
||||
// Zugriffsbeschraenkung: User muss Zugang zum Team haben (ueber accessibleTeamIds)
|
||||
if (!$user->canAccessAdminPanel()) {
|
||||
@@ -90,6 +90,10 @@ class EventController extends Controller
|
||||
if ($event->type->hasTimekeepers()) {
|
||||
$relations[] = 'timekeepers.user';
|
||||
}
|
||||
if ($event->type->hasCarpool()) {
|
||||
$relations[] = 'carpools.driver';
|
||||
$relations[] = 'carpools.passengers.player';
|
||||
}
|
||||
$event->load($relations);
|
||||
|
||||
$userChildIds = $userChildren->pluck('id');
|
||||
@@ -104,6 +108,11 @@ class EventController extends Controller
|
||||
? $event->timekeepers->where('user_id', $user->id)->first()
|
||||
: null;
|
||||
|
||||
// Eigene Fahrgemeinschaft
|
||||
$myCarpool = $event->type->hasCarpool()
|
||||
? $event->carpools->where('user_id', $user->id)->first()
|
||||
: null;
|
||||
|
||||
// Catering/Zeitnehmer-Verlauf für Staff (chronologische Statusänderungen)
|
||||
$cateringHistory = collect();
|
||||
$timekeeperHistory = collect();
|
||||
@@ -123,6 +132,6 @@ class EventController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
return view('events.show', compact('event', 'userChildIds', 'myCatering', 'myTimekeeper', 'cateringHistory', 'timekeeperHistory'));
|
||||
return view('events.show', compact('event', 'userChildIds', 'userChildren', 'myCatering', 'myTimekeeper', 'myCarpool', 'cateringHistory', 'timekeeperHistory'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,16 @@ class Event extends Model
|
||||
return $this->hasMany(EventTimekeeper::class);
|
||||
}
|
||||
|
||||
public function carpools(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventCarpool::class);
|
||||
}
|
||||
|
||||
public function playerStats(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventPlayerStat::class);
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
|
||||
36
app/Models/EventCarpool.php
Normal file
36
app/Models/EventCarpool.php
Normal 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();
|
||||
}
|
||||
}
|
||||
35
app/Models/EventCarpoolPassenger.php
Normal file
35
app/Models/EventCarpoolPassenger.php
Normal 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');
|
||||
}
|
||||
}
|
||||
78
app/Models/EventPlayerStat.php
Normal file
78
app/Models/EventPlayerStat.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PlayerPosition;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
@@ -17,6 +18,7 @@ class Player extends Model
|
||||
'last_name',
|
||||
'birth_year',
|
||||
'jersey_number',
|
||||
'position',
|
||||
'is_active',
|
||||
'photo_permission',
|
||||
'notes',
|
||||
@@ -28,6 +30,7 @@ class Player extends Model
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'photo_permission' => 'boolean',
|
||||
'position' => PlayerPosition::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -6,12 +6,16 @@ use App\Enums\CateringStatus;
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventType;
|
||||
use App\Enums\ParticipantStatus;
|
||||
use App\Enums\PlayerPosition;
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCarpool;
|
||||
use App\Models\EventCarpoolPassenger;
|
||||
use App\Models\EventCatering;
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\EventPlayerStat;
|
||||
use App\Models\EventTimekeeper;
|
||||
use App\Models\Faq;
|
||||
use App\Models\Location;
|
||||
@@ -43,6 +47,8 @@ class DemoDataSeeder extends Seeder
|
||||
$this->seedCatering($events, $parentUsers);
|
||||
$this->seedTimekeepers($events, $parentUsers);
|
||||
$this->seedComments($events, $admin, $coach, $parentUsers);
|
||||
$this->seedPlayerStats($events, $players);
|
||||
$this->seedCarpools($events, $parentUsers, $players);
|
||||
$this->seedFaqs($admin);
|
||||
$this->seedActivityLogs($admin, $coach, $team, $events);
|
||||
$this->seedSoftDeletedRecords($team);
|
||||
@@ -200,9 +206,27 @@ class DemoDataSeeder extends Seeder
|
||||
|
||||
private function seedPlayers(array $data, Team $team): array
|
||||
{
|
||||
// Positionsverteilung: Index → Position
|
||||
$positions = [
|
||||
0 => PlayerPosition::Torwart,
|
||||
1 => PlayerPosition::LinksAussen,
|
||||
2 => PlayerPosition::RueckraumMitte,
|
||||
3 => PlayerPosition::RueckraumLinks,
|
||||
4 => PlayerPosition::RechtsAussen,
|
||||
5 => PlayerPosition::RueckraumRechts,
|
||||
6 => PlayerPosition::Kreislaeufer,
|
||||
7 => PlayerPosition::LinksAussen,
|
||||
8 => PlayerPosition::RechtsAussen,
|
||||
9 => PlayerPosition::RueckraumLinks,
|
||||
10 => PlayerPosition::RueckraumMitte,
|
||||
11 => PlayerPosition::RueckraumRechts,
|
||||
12 => PlayerPosition::Kreislaeufer,
|
||||
13 => PlayerPosition::Torwart, // Ersatz-TW
|
||||
];
|
||||
|
||||
$players = [];
|
||||
$jerseyNr = 1;
|
||||
foreach ($data as [$childFirst, $lastName, $parents]) {
|
||||
foreach ($data as $idx => [$childFirst, $lastName, $parents]) {
|
||||
$player = Player::firstOrCreate(
|
||||
['first_name' => $childFirst, 'last_name' => $lastName, 'team_id' => $team->id],
|
||||
[
|
||||
@@ -210,8 +234,13 @@ class DemoDataSeeder extends Seeder
|
||||
'jersey_number' => $jerseyNr,
|
||||
'is_active' => true,
|
||||
'photo_permission' => true,
|
||||
'position' => $positions[$idx] ?? null,
|
||||
]
|
||||
);
|
||||
// Position auch bei bestehenden Spielern setzen
|
||||
if ($player->position === null && isset($positions[$idx])) {
|
||||
$player->update(['position' => $positions[$idx]]);
|
||||
}
|
||||
$players["{$childFirst} {$lastName}"] = $player;
|
||||
$jerseyNr++;
|
||||
}
|
||||
@@ -601,6 +630,122 @@ class DemoDataSeeder extends Seeder
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Spielerstatistiken ─────────────────────────────────
|
||||
// Heimspiel (Index 2, Ergebnis 15:12) — Stats für zugesagte Spieler
|
||||
|
||||
private function seedPlayerStats(array $events, array $players): void
|
||||
{
|
||||
$homeGame = $events[2]; // Heimspiel vs. TSV Beispielburg (15:12)
|
||||
$playerList = array_values($players);
|
||||
|
||||
// Nur die ersten 18 Spieler haben beim Heimspiel zugesagt (siehe seedParticipants)
|
||||
$statsData = [
|
||||
// [index, is_goalkeeper, gk_saves, gk_shots, goals, shots, note, position]
|
||||
[0, true, 8, 20, 0, 0, 'Starke Leistung im Tor', 'torwart'],
|
||||
[1, false, null, null, 3, 6, 'Drei Tempogegenstöße', 'links_aussen'],
|
||||
[2, false, null, null, 2, 5, 'Guter Aufbau', 'rueckraum_mitte'],
|
||||
[3, false, null, null, 2, 4, null, 'rueckraum_links'],
|
||||
[4, false, null, null, 1, 3, 'Schnelle Beine', 'rechts_aussen'],
|
||||
[5, false, null, null, 2, 5, null, 'rueckraum_rechts'],
|
||||
[6, false, null, null, 1, 2, 'Erste Tore der Saison!', 'kreislaeufer'],
|
||||
[7, false, null, null, 1, 4, null, 'links_aussen'],
|
||||
[8, false, null, null, 1, 3, null, 'rechts_aussen'],
|
||||
[9, false, null, null, 1, 2, null, 'rueckraum_links'],
|
||||
[10, false, null, null, 1, 3, 'Stark in der Abwehr', 'rueckraum_mitte'],
|
||||
[11, false, null, null, 0, 2, null, 'rueckraum_rechts'],
|
||||
[12, false, null, null, 0, 1, null, 'kreislaeufer'],
|
||||
[13, false, null, null, 0, 1, null, 'torwart'],
|
||||
[14, false, null, null, 0, 2, 'Erste Spielminuten', null],
|
||||
[15, false, null, null, 0, 0, null, null],
|
||||
[16, false, null, null, 0, 1, null, null],
|
||||
[17, false, null, null, 0, 0, null, null],
|
||||
];
|
||||
|
||||
foreach ($statsData as [$idx, $isGk, $gkSaves, $gkShots, $goals, $shots, $note, $position]) {
|
||||
if (!isset($playerList[$idx])) {
|
||||
continue;
|
||||
}
|
||||
EventPlayerStat::updateOrCreate(
|
||||
['event_id' => $homeGame->id, 'player_id' => $playerList[$idx]->id],
|
||||
[
|
||||
'is_goalkeeper' => $isGk,
|
||||
'goalkeeper_saves' => $gkSaves,
|
||||
'goalkeeper_shots' => $gkShots,
|
||||
'goals' => $goals,
|
||||
'shots' => $shots,
|
||||
'note' => $note,
|
||||
'position' => $position,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fahrgemeinschaften ──────────────────────────────────
|
||||
// Auswärtsspiel (Index 3) + Turnier (Index 4)
|
||||
|
||||
private function seedCarpools(array $events, array $parentUsers, array $players): void
|
||||
{
|
||||
$p = array_values($parentUsers);
|
||||
$pl = array_values($players);
|
||||
|
||||
// Auswärtsspiel (Index 3): 2 Fahrgemeinschaften
|
||||
$carpool1 = EventCarpool::where('event_id', $events[3]->id)->where('user_id', $p[0]->id)->first();
|
||||
if (!$carpool1) {
|
||||
$carpool1 = new EventCarpool(['event_id' => $events[3]->id, 'seats' => 3, 'note' => 'Treffpunkt Parkplatz, 9:30 Uhr']);
|
||||
$carpool1->user_id = $p[0]->id;
|
||||
$carpool1->save();
|
||||
}
|
||||
|
||||
// 2 Kinder zuordnen
|
||||
foreach ([0, 1] as $childIdx) {
|
||||
if (isset($pl[$childIdx])) {
|
||||
$pass = EventCarpoolPassenger::where('carpool_id', $carpool1->id)->where('player_id', $pl[$childIdx]->id)->first();
|
||||
if (!$pass) {
|
||||
$pass = new EventCarpoolPassenger(['carpool_id' => $carpool1->id, 'player_id' => $pl[$childIdx]->id]);
|
||||
$pass->added_by = $p[0]->id;
|
||||
$pass->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$carpool2 = EventCarpool::where('event_id', $events[3]->id)->where('user_id', $p[2]->id)->first();
|
||||
if (!$carpool2) {
|
||||
$carpool2 = new EventCarpool(['event_id' => $events[3]->id, 'seats' => 2, 'note' => 'Abfahrt 9:15 Uhr ab Vereinsheim']);
|
||||
$carpool2->user_id = $p[2]->id;
|
||||
$carpool2->save();
|
||||
}
|
||||
|
||||
// Voll belegt — 2 Kinder
|
||||
foreach ([2, 3] as $childIdx) {
|
||||
if (isset($pl[$childIdx])) {
|
||||
$pass = EventCarpoolPassenger::where('carpool_id', $carpool2->id)->where('player_id', $pl[$childIdx]->id)->first();
|
||||
if (!$pass) {
|
||||
$pass = new EventCarpoolPassenger(['carpool_id' => $carpool2->id, 'player_id' => $pl[$childIdx]->id]);
|
||||
$pass->added_by = $p[2]->id;
|
||||
$pass->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Turnier (Index 4): 1 Fahrgemeinschaft
|
||||
$carpool3 = EventCarpool::where('event_id', $events[4]->id)->where('user_id', $p[5]->id)->first();
|
||||
if (!$carpool3) {
|
||||
$carpool3 = new EventCarpool(['event_id' => $events[4]->id, 'seats' => 4, 'note' => 'Großer Van, Abfahrt 8:00 Uhr']);
|
||||
$carpool3->user_id = $p[5]->id;
|
||||
$carpool3->save();
|
||||
}
|
||||
|
||||
// 1 Kind zugeordnet
|
||||
if (isset($pl[5])) {
|
||||
$pass = EventCarpoolPassenger::where('carpool_id', $carpool3->id)->where('player_id', $pl[5]->id)->first();
|
||||
if (!$pass) {
|
||||
$pass = new EventCarpoolPassenger(['carpool_id' => $carpool3->id, 'player_id' => $pl[5]->id]);
|
||||
$pass->added_by = $p[5]->id;
|
||||
$pass->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FAQs ──────────────────────────────────────────────
|
||||
|
||||
private function seedFaqs(User $admin): void
|
||||
|
||||
@@ -307,6 +307,10 @@ return [
|
||||
'log_participant_changed' => 'تم تغيير حالة المشاركة لـ ":event" إلى :status',
|
||||
'log_catering_changed' => 'تم تغيير حالة التموين لـ ":event" إلى :status',
|
||||
'log_timekeeper_changed' => 'تم تغيير حالة الميقاتي لـ ":event" إلى :status',
|
||||
'log_carpool_offer' => 'تم عرض رحلة لـ ":event" (:seats مقاعد)',
|
||||
'log_carpool_withdrawn' => 'تم سحب الرحلة لـ ":event" (تم إزالة :passengers ركاب)',
|
||||
'log_carpool_joined' => ':player يركب مع :driver (الموعد: ":event")',
|
||||
'log_carpool_left' => 'تم إزالة :player من رحلة :driver (الموعد: ":event")',
|
||||
'log_comment_created' => 'تم إضافة تعليق إلى ":event"',
|
||||
'log_comment_deleted' => 'تم حذف تعليق من ":event"',
|
||||
'log_file_uploaded' => 'تم رفع الملف ":name"',
|
||||
@@ -356,6 +360,15 @@ return [
|
||||
'favicon_uploaded' => 'تم تحديث الأيقونة.',
|
||||
'favicon_removed' => 'تم إزالة الأيقونة.',
|
||||
|
||||
// Logos
|
||||
'logo_login_label' => 'شعار تسجيل الدخول',
|
||||
'logo_login_desc' => 'يُعرض في صفحة تسجيل الدخول فوق اسم التطبيق.',
|
||||
'logo_app_label' => 'شعار التطبيق (شريط التنقل)',
|
||||
'logo_app_desc' => 'يُعرض في شريط التنقل بجانب اسم التطبيق.',
|
||||
'logo_current' => 'الشعار الحالي',
|
||||
'logo_remove' => 'إزالة الشعار',
|
||||
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (الحد الأقصى 1 ميجابايت)',
|
||||
|
||||
// Undo / Revert
|
||||
'log_revert' => 'تراجع',
|
||||
'log_revert_confirm' => 'هل تريد حقاً التراجع عن هذا الإجراء؟',
|
||||
@@ -546,4 +559,21 @@ return [
|
||||
'mail_test_button' => 'اختبار الاتصال',
|
||||
'mail_testing' => 'جاري اختبار الاتصال...',
|
||||
'mail_test_success' => 'اتصال SMTP ناجح!',
|
||||
|
||||
// إحصائيات اللاعبين
|
||||
'stats_player_detail' => 'تفاصيل اللاعب',
|
||||
'stats_total_goals' => 'إجمالي الأهداف',
|
||||
'stats_total_shots' => 'إجمالي التسديدات',
|
||||
'stats_gk_appearances' => 'مباريات كحارس',
|
||||
'stats_total_saves' => 'إجمالي التصديات',
|
||||
'stats_close' => 'إغلاق',
|
||||
'player_goals' => 'أهداف',
|
||||
|
||||
// المراكز والملعب
|
||||
'position' => 'المركز',
|
||||
'court_visualization' => 'تشكيلة الملعب',
|
||||
'court_no_data' => 'لا توجد بيانات',
|
||||
'performance_good' => 'جيد',
|
||||
'performance_average' => 'متوسط',
|
||||
'performance_below' => 'أقل من المتوسط',
|
||||
];
|
||||
|
||||
@@ -31,6 +31,25 @@ return [
|
||||
'timekeeper_short' => 'ميقاتي',
|
||||
'no_timekeeper_yet' => 'لم يتم تعيين ميقاتي بعد.',
|
||||
'timekeeper_updated' => 'تم تحديث حالة الميقاتي.',
|
||||
|
||||
// مشاركة السيارات
|
||||
'carpool' => 'مشاركة السيارات',
|
||||
'carpool_offer' => 'عرض رحلة',
|
||||
'carpool_update' => 'تحديث الرحلة',
|
||||
'carpool_seats' => 'المقاعد المتاحة',
|
||||
'carpool_seats_count' => ':free من :total مقاعد متاحة',
|
||||
'carpool_seats_too_few' => 'عدد الركاب المسجلين أكبر من عدد المقاعد الجديد.',
|
||||
'carpool_note_placeholder' => 'مثلاً نقطة اللقاء، وقت المغادرة...',
|
||||
'carpool_withdraw' => 'سحب الرحلة',
|
||||
'carpool_withdraw_confirm' => 'هل تريد حقاً سحب الرحلة؟ سيتم إزالة جميع الركاب.',
|
||||
'carpool_join' => 'تعيين',
|
||||
'carpool_leave' => 'إزالة',
|
||||
'carpool_full' => 'جميع المقاعد مشغولة',
|
||||
'no_carpool_yet' => 'لا توجد عروض مشاركة سيارات بعد.',
|
||||
'carpool_my_offer' => 'رحلتي',
|
||||
'carpool_driver' => 'السائق',
|
||||
'carpool_passengers' => 'الركاب',
|
||||
|
||||
'comments' => 'التعليقات',
|
||||
'comment_placeholder' => 'اكتب تعليقاً...',
|
||||
'no_comments' => 'لا توجد تعليقات بعد.',
|
||||
@@ -57,6 +76,22 @@ return [
|
||||
'score_away' => 'الفريق الضيف',
|
||||
'vs' => 'ضد',
|
||||
|
||||
// إحصائيات اللاعبين
|
||||
'stats' => 'إحصائيات اللاعبين',
|
||||
'stats_save' => 'حفظ الإحصائيات',
|
||||
'stats_saved' => 'تم حفظ الإحصائيات.',
|
||||
'stats_goalkeeper' => 'حارس',
|
||||
'stats_goalkeeper_long' => 'حارس المرمى',
|
||||
'stats_saves' => 'تصديات',
|
||||
'stats_shots_on_goal' => 'تسديدات على المرمى',
|
||||
'stats_goals' => 'أهداف',
|
||||
'stats_shots' => 'تسديدات',
|
||||
'stats_note' => 'ملاحظة',
|
||||
'stats_hit_rate' => 'نسبة الإصابة',
|
||||
'stats_save_rate' => 'نسبة التصدي',
|
||||
'stats_no_data' => 'لا توجد بيانات إحصائية.',
|
||||
'stats_position' => 'المركز',
|
||||
|
||||
// Staff visibility
|
||||
'signed_up' => 'سجّل',
|
||||
'withdrawn' => 'انسحب',
|
||||
|
||||
@@ -82,6 +82,24 @@ return [
|
||||
'parent_rep' => 'ممثل أولياء الأمور',
|
||||
'user' => 'ولي أمر',
|
||||
],
|
||||
'player_position' => [
|
||||
'torwart' => 'حارس مرمى',
|
||||
'links_aussen' => 'جناح أيسر',
|
||||
'rechts_aussen' => 'جناح أيمن',
|
||||
'rueckraum_links' => 'ظهير أيسر',
|
||||
'rueckraum_mitte' => 'صانع ألعاب',
|
||||
'rueckraum_rechts' => 'ظهير أيمن',
|
||||
'kreislaeufer' => 'دوّار',
|
||||
],
|
||||
'player_position_short' => [
|
||||
'torwart' => 'حر',
|
||||
'links_aussen' => 'جأ',
|
||||
'rechts_aussen' => 'جي',
|
||||
'rueckraum_links' => 'ظأ',
|
||||
'rueckraum_mitte' => 'صأ',
|
||||
'rueckraum_rechts' => 'ظي',
|
||||
'kreislaeufer' => 'دو',
|
||||
],
|
||||
],
|
||||
'locales' => [
|
||||
'de' => 'Deutsch',
|
||||
|
||||
@@ -338,6 +338,10 @@ return [
|
||||
'log_participant_changed' => 'Teilnahme-Status für ":event" geändert auf :status',
|
||||
'log_catering_changed' => 'Catering-Status für ":event" geändert auf :status',
|
||||
'log_timekeeper_changed' => 'Zeitnehmer-Status für ":event" geändert auf :status',
|
||||
'log_carpool_offer' => 'Fahrt für ":event" angeboten (:seats Plätze)',
|
||||
'log_carpool_withdrawn' => 'Fahrt für ":event" zurückgezogen (:passengers Mitfahrer entfernt)',
|
||||
'log_carpool_joined' => ':player fährt bei :driver mit (Event: ":event")',
|
||||
'log_carpool_left' => ':player wurde von Fahrt bei :driver entfernt (Event: ":event")',
|
||||
'log_comment_created' => 'Kommentar zu ":event" hinzugefügt',
|
||||
'log_comment_deleted' => 'Kommentar zu ":event" gelöscht',
|
||||
'log_file_uploaded' => 'Datei ":name" hochgeladen',
|
||||
@@ -389,6 +393,15 @@ return [
|
||||
'favicon_uploaded' => 'Favicon wurde aktualisiert.',
|
||||
'favicon_removed' => 'Favicon wurde entfernt.',
|
||||
|
||||
// Logos
|
||||
'logo_login_label' => 'Login-Logo',
|
||||
'logo_login_desc' => 'Wird auf der Anmeldeseite oberhalb des App-Namens angezeigt.',
|
||||
'logo_app_label' => 'App-Logo (Navigation)',
|
||||
'logo_app_desc' => 'Wird in der Navigationsleiste neben dem App-Namen angezeigt.',
|
||||
'logo_current' => 'Aktuelles Logo',
|
||||
'logo_remove' => 'Logo entfernen',
|
||||
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (max. 1 MB)',
|
||||
|
||||
// Undo / Revert
|
||||
'log_revert' => 'Rückgängig',
|
||||
'log_revert_confirm' => 'Möchtest du diese Aktion wirklich rückgängig machen?',
|
||||
@@ -566,7 +579,7 @@ return [
|
||||
// E-Mail Tab
|
||||
'settings_tab_mail' => 'E-Mail',
|
||||
'mail_config_title' => 'E-Mail-Konfiguration',
|
||||
'mail_config_hint' => 'SMTP-Einstellungen fuer den E-Mail-Versand (z.B. Passwort-Reset, Benachrichtigungen).',
|
||||
'mail_config_hint' => 'SMTP-Einstellungen für den E-Mail-Versand (z.B. Passwort-Reset, Benachrichtigungen).',
|
||||
'mail_mailer_label' => 'Versandmethode',
|
||||
'mail_log_mode' => 'Kein Versand (Log)',
|
||||
'mail_host_label' => 'SMTP-Host',
|
||||
@@ -575,11 +588,28 @@ return [
|
||||
'mail_password_label' => 'Passwort',
|
||||
'mail_from_address_label' => 'Absender-Adresse',
|
||||
'mail_from_name_label' => 'Absender-Name',
|
||||
'mail_encryption_label' => 'Verschluesselung',
|
||||
'mail_encryption_label' => 'Verschlüsselung',
|
||||
'mail_encryption_none' => 'Keine',
|
||||
'mail_save' => 'Mail-Einstellungen speichern',
|
||||
'mail_saved' => 'Mail-Einstellungen wurden gespeichert.',
|
||||
'mail_test_button' => 'Verbindung testen',
|
||||
'mail_testing' => 'Teste Verbindung...',
|
||||
'mail_test_success' => 'SMTP-Verbindung erfolgreich!',
|
||||
|
||||
// Spielerstatistik
|
||||
'stats_player_detail' => 'Spielerdetails',
|
||||
'stats_total_goals' => 'Gesamttore',
|
||||
'stats_total_shots' => 'Gesamtwürfe',
|
||||
'stats_gk_appearances' => 'TW-Einsätze',
|
||||
'stats_total_saves' => 'Gesamtparaden',
|
||||
'stats_close' => 'Schließen',
|
||||
'player_goals' => 'Tore',
|
||||
|
||||
// Positionen & Spielfeld
|
||||
'position' => 'Position',
|
||||
'court_visualization' => 'Spielfeld-Aufstellung',
|
||||
'court_no_data' => 'Keine Daten',
|
||||
'performance_good' => 'Gut',
|
||||
'performance_average' => 'Mittel',
|
||||
'performance_below' => 'Unterdurchschnittlich',
|
||||
];
|
||||
|
||||
@@ -40,6 +40,24 @@ return [
|
||||
'no_timekeeper_yet' => 'Noch kein Zeitnehmer eingetragen.',
|
||||
'timekeeper_updated' => 'Zeitnehmer-Status aktualisiert.',
|
||||
|
||||
// Fahrgemeinschaften
|
||||
'carpool' => 'Fahrgemeinschaften',
|
||||
'carpool_offer' => 'Fahrt anbieten',
|
||||
'carpool_update' => 'Fahrt aktualisieren',
|
||||
'carpool_seats' => 'Freie Plätze',
|
||||
'carpool_seats_count' => ':free von :total Plätzen frei',
|
||||
'carpool_seats_too_few' => 'Es sind bereits mehr Mitfahrer zugeordnet als die neue Sitzplatzanzahl.',
|
||||
'carpool_note_placeholder' => 'z.B. Treffpunkt, Abfahrtszeit...',
|
||||
'carpool_withdraw' => 'Fahrt zurückziehen',
|
||||
'carpool_withdraw_confirm' => 'Fahrt wirklich zurückziehen? Alle Mitfahrer werden entfernt.',
|
||||
'carpool_join' => 'Zuordnen',
|
||||
'carpool_leave' => 'Entfernen',
|
||||
'carpool_full' => 'Alle Plätze belegt',
|
||||
'no_carpool_yet' => 'Noch keine Fahrgemeinschaften angeboten.',
|
||||
'carpool_my_offer' => 'Meine Fahrt',
|
||||
'carpool_driver' => 'Fahrer/in',
|
||||
'carpool_passengers' => 'Mitfahrer',
|
||||
|
||||
// Kommentare
|
||||
'comments' => 'Kommentare',
|
||||
'comment_placeholder' => 'Kommentar schreiben...',
|
||||
@@ -71,6 +89,22 @@ return [
|
||||
'score_away' => 'Gast',
|
||||
'vs' => 'vs.',
|
||||
|
||||
// Spielerstatistik
|
||||
'stats' => 'Spielerstatistik',
|
||||
'stats_save' => 'Statistik speichern',
|
||||
'stats_saved' => 'Statistik gespeichert.',
|
||||
'stats_goalkeeper' => 'TW',
|
||||
'stats_goalkeeper_long' => 'Torwart',
|
||||
'stats_saves' => 'Gehalten',
|
||||
'stats_shots_on_goal' => 'Würfe aufs Tor',
|
||||
'stats_goals' => 'Tore',
|
||||
'stats_shots' => 'Torwürfe',
|
||||
'stats_note' => 'Bemerkung',
|
||||
'stats_hit_rate' => 'Trefferquote',
|
||||
'stats_save_rate' => 'Fangquote',
|
||||
'stats_no_data' => 'Keine Statistikdaten.',
|
||||
'stats_position' => 'Position',
|
||||
|
||||
// Staff-Sichtbarkeit
|
||||
'signed_up' => 'zugesagt',
|
||||
'withdrawn' => 'abgemeldet',
|
||||
|
||||
@@ -97,6 +97,24 @@ return [
|
||||
'parent_rep' => 'Elternvertretung',
|
||||
'user' => 'Elternteil',
|
||||
],
|
||||
'player_position' => [
|
||||
'torwart' => 'Torwart',
|
||||
'links_aussen' => 'Linksaußen',
|
||||
'rechts_aussen' => 'Rechtsaußen',
|
||||
'rueckraum_links' => 'Rückraum Links',
|
||||
'rueckraum_mitte' => 'Rückraum Mitte',
|
||||
'rueckraum_rechts' => 'Rückraum Rechts',
|
||||
'kreislaeufer' => 'Kreisläufer',
|
||||
],
|
||||
'player_position_short' => [
|
||||
'torwart' => 'TW',
|
||||
'links_aussen' => 'LA',
|
||||
'rechts_aussen' => 'RA',
|
||||
'rueckraum_links' => 'RL',
|
||||
'rueckraum_mitte' => 'RM',
|
||||
'rueckraum_rechts' => 'RR',
|
||||
'kreislaeufer' => 'KL',
|
||||
],
|
||||
],
|
||||
|
||||
// Sprachen
|
||||
|
||||
@@ -306,6 +306,10 @@ return [
|
||||
'log_participant_changed' => 'Participation status for ":event" changed to :status',
|
||||
'log_catering_changed' => 'Catering status for ":event" changed to :status',
|
||||
'log_timekeeper_changed' => 'Timekeeper status for ":event" changed to :status',
|
||||
'log_carpool_offer' => 'Ride offered for ":event" (:seats seats)',
|
||||
'log_carpool_withdrawn' => 'Ride for ":event" withdrawn (:passengers passengers removed)',
|
||||
'log_carpool_joined' => ':player rides with :driver (Event: ":event")',
|
||||
'log_carpool_left' => ':player removed from ride with :driver (Event: ":event")',
|
||||
'log_comment_created' => 'Comment added to ":event"',
|
||||
'log_comment_deleted' => 'Comment deleted from ":event"',
|
||||
'log_file_uploaded' => 'File ":name" uploaded',
|
||||
@@ -355,6 +359,15 @@ return [
|
||||
'favicon_uploaded' => 'Favicon has been updated.',
|
||||
'favicon_removed' => 'Favicon has been removed.',
|
||||
|
||||
// Logos
|
||||
'logo_login_label' => 'Login Logo',
|
||||
'logo_login_desc' => 'Displayed on the login page above the app name.',
|
||||
'logo_app_label' => 'App Logo (Navigation)',
|
||||
'logo_app_desc' => 'Displayed in the navigation bar next to the app name.',
|
||||
'logo_current' => 'Current logo',
|
||||
'logo_remove' => 'Remove logo',
|
||||
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (max. 1 MB)',
|
||||
|
||||
// Undo / Revert
|
||||
'log_revert' => 'Undo',
|
||||
'log_revert_confirm' => 'Are you sure you want to undo this action?',
|
||||
@@ -545,4 +558,21 @@ return [
|
||||
'mail_test_button' => 'Test Connection',
|
||||
'mail_testing' => 'Testing connection...',
|
||||
'mail_test_success' => 'SMTP connection successful!',
|
||||
|
||||
// Player statistics
|
||||
'stats_player_detail' => 'Player details',
|
||||
'stats_total_goals' => 'Total goals',
|
||||
'stats_total_shots' => 'Total shots',
|
||||
'stats_gk_appearances' => 'GK appearances',
|
||||
'stats_total_saves' => 'Total saves',
|
||||
'stats_close' => 'Close',
|
||||
'player_goals' => 'Goals',
|
||||
|
||||
// Positions & Court
|
||||
'position' => 'Position',
|
||||
'court_visualization' => 'Court Formation',
|
||||
'court_no_data' => 'No data',
|
||||
'performance_good' => 'Good',
|
||||
'performance_average' => 'Average',
|
||||
'performance_below' => 'Below average',
|
||||
];
|
||||
|
||||
@@ -30,6 +30,25 @@ return [
|
||||
'timekeeper_short' => 'Timekeeper',
|
||||
'no_timekeeper_yet' => 'No timekeeper assigned yet.',
|
||||
'timekeeper_updated' => 'Timekeeper status updated.',
|
||||
|
||||
// Carpooling
|
||||
'carpool' => 'Carpooling',
|
||||
'carpool_offer' => 'Offer a ride',
|
||||
'carpool_update' => 'Update ride',
|
||||
'carpool_seats' => 'Available seats',
|
||||
'carpool_seats_count' => ':free of :total seats available',
|
||||
'carpool_seats_too_few' => 'There are already more passengers assigned than the new seat count.',
|
||||
'carpool_note_placeholder' => 'e.g. meeting point, departure time...',
|
||||
'carpool_withdraw' => 'Withdraw ride',
|
||||
'carpool_withdraw_confirm' => 'Really withdraw ride? All passengers will be removed.',
|
||||
'carpool_join' => 'Assign',
|
||||
'carpool_leave' => 'Remove',
|
||||
'carpool_full' => 'All seats taken',
|
||||
'no_carpool_yet' => 'No carpooling offers yet.',
|
||||
'carpool_my_offer' => 'My ride',
|
||||
'carpool_driver' => 'Driver',
|
||||
'carpool_passengers' => 'Passengers',
|
||||
|
||||
'comments' => 'Comments',
|
||||
'comment_placeholder' => 'Write a comment...',
|
||||
'no_comments' => 'No comments yet.',
|
||||
@@ -56,6 +75,22 @@ return [
|
||||
'score_away' => 'Away',
|
||||
'vs' => 'vs.',
|
||||
|
||||
// Player statistics
|
||||
'stats' => 'Player Statistics',
|
||||
'stats_save' => 'Save statistics',
|
||||
'stats_saved' => 'Statistics saved.',
|
||||
'stats_goalkeeper' => 'GK',
|
||||
'stats_goalkeeper_long' => 'Goalkeeper',
|
||||
'stats_saves' => 'Saves',
|
||||
'stats_shots_on_goal' => 'Shots on goal',
|
||||
'stats_goals' => 'Goals',
|
||||
'stats_shots' => 'Shots',
|
||||
'stats_note' => 'Note',
|
||||
'stats_hit_rate' => 'Hit rate',
|
||||
'stats_save_rate' => 'Save rate',
|
||||
'stats_no_data' => 'No statistics data.',
|
||||
'stats_position' => 'Position',
|
||||
|
||||
// Staff visibility
|
||||
'signed_up' => 'signed up',
|
||||
'withdrawn' => 'withdrawn',
|
||||
|
||||
@@ -81,6 +81,24 @@ return [
|
||||
'parent_rep' => 'Parent Representative',
|
||||
'user' => 'Parent',
|
||||
],
|
||||
'player_position' => [
|
||||
'torwart' => 'Goalkeeper',
|
||||
'links_aussen' => 'Left Wing',
|
||||
'rechts_aussen' => 'Right Wing',
|
||||
'rueckraum_links' => 'Left Back',
|
||||
'rueckraum_mitte' => 'Centre Back',
|
||||
'rueckraum_rechts' => 'Right Back',
|
||||
'kreislaeufer' => 'Pivot',
|
||||
],
|
||||
'player_position_short' => [
|
||||
'torwart' => 'GK',
|
||||
'links_aussen' => 'LW',
|
||||
'rechts_aussen' => 'RW',
|
||||
'rueckraum_links' => 'LB',
|
||||
'rueckraum_mitte' => 'CB',
|
||||
'rueckraum_rechts' => 'RB',
|
||||
'kreislaeufer' => 'PV',
|
||||
],
|
||||
],
|
||||
'locales' => [
|
||||
'de' => 'Deutsch',
|
||||
|
||||
@@ -307,6 +307,10 @@ return [
|
||||
'log_participant_changed' => 'Status uczestnictwa dla ":event" zmieniony na :status',
|
||||
'log_catering_changed' => 'Status cateringu dla ":event" zmieniony na :status',
|
||||
'log_timekeeper_changed' => 'Status chronometrażysty dla ":event" zmieniony na :status',
|
||||
'log_carpool_offer' => 'Przejazd dla ":event" zaoferowany (:seats miejsc)',
|
||||
'log_carpool_withdrawn' => 'Przejazd dla ":event" wycofany (:passengers pasażerów usuniętych)',
|
||||
'log_carpool_joined' => ':player jedzie z :driver (Termin: ":event")',
|
||||
'log_carpool_left' => ':player usunięty z przejazdu z :driver (Termin: ":event")',
|
||||
'log_comment_created' => 'Dodano komentarz do ":event"',
|
||||
'log_comment_deleted' => 'Usunięto komentarz z ":event"',
|
||||
'log_file_uploaded' => 'Plik ":name" przesłany',
|
||||
@@ -356,6 +360,15 @@ return [
|
||||
'favicon_uploaded' => 'Ikona została zaktualizowana.',
|
||||
'favicon_removed' => 'Ikona została usunięta.',
|
||||
|
||||
// Logos
|
||||
'logo_login_label' => 'Logo logowania',
|
||||
'logo_login_desc' => 'Wyświetlane na stronie logowania nad nazwą aplikacji.',
|
||||
'logo_app_label' => 'Logo aplikacji (nawigacja)',
|
||||
'logo_app_desc' => 'Wyświetlane na pasku nawigacji obok nazwy aplikacji.',
|
||||
'logo_current' => 'Aktualne logo',
|
||||
'logo_remove' => 'Usuń logo',
|
||||
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (maks. 1 MB)',
|
||||
|
||||
// Undo / Revert
|
||||
'log_revert' => 'Cofnij',
|
||||
'log_revert_confirm' => 'Czy na pewno chcesz cofnąć tę akcję?',
|
||||
@@ -546,4 +559,21 @@ return [
|
||||
'mail_test_button' => 'Testuj połączenie',
|
||||
'mail_testing' => 'Testowanie połączenia...',
|
||||
'mail_test_success' => 'Połączenie SMTP udane!',
|
||||
|
||||
// Statystyki graczy
|
||||
'stats_player_detail' => 'Szczegóły gracza',
|
||||
'stats_total_goals' => 'Łączne bramki',
|
||||
'stats_total_shots' => 'Łączne rzuty',
|
||||
'stats_gk_appearances' => 'Występy jako bramkarz',
|
||||
'stats_total_saves' => 'Łączne obrony',
|
||||
'stats_close' => 'Zamknij',
|
||||
'player_goals' => 'Bramki',
|
||||
|
||||
// Pozycje i boisko
|
||||
'position' => 'Pozycja',
|
||||
'court_visualization' => 'Ustawienie na boisku',
|
||||
'court_no_data' => 'Brak danych',
|
||||
'performance_good' => 'Dobrze',
|
||||
'performance_average' => 'Średnio',
|
||||
'performance_below' => 'Poniżej średniej',
|
||||
];
|
||||
|
||||
@@ -31,6 +31,25 @@ return [
|
||||
'timekeeper_short' => 'Sędzia czasu',
|
||||
'no_timekeeper_yet' => 'Brak przypisanego chronometrażysty.',
|
||||
'timekeeper_updated' => 'Status chronometrażysty zaktualizowany.',
|
||||
|
||||
// Wspólne przejazdy
|
||||
'carpool' => 'Wspólne przejazdy',
|
||||
'carpool_offer' => 'Zaoferuj przejazd',
|
||||
'carpool_update' => 'Zaktualizuj przejazd',
|
||||
'carpool_seats' => 'Wolne miejsca',
|
||||
'carpool_seats_count' => ':free z :total miejsc wolnych',
|
||||
'carpool_seats_too_few' => 'Przypisano już więcej pasażerów niż nowa liczba miejsc.',
|
||||
'carpool_note_placeholder' => 'np. miejsce spotkania, godzina odjazdu...',
|
||||
'carpool_withdraw' => 'Wycofaj przejazd',
|
||||
'carpool_withdraw_confirm' => 'Naprawdę wycofać przejazd? Wszyscy pasażerowie zostaną usunięci.',
|
||||
'carpool_join' => 'Przypisz',
|
||||
'carpool_leave' => 'Usuń',
|
||||
'carpool_full' => 'Wszystkie miejsca zajęte',
|
||||
'no_carpool_yet' => 'Brak ofert wspólnych przejazdów.',
|
||||
'carpool_my_offer' => 'Mój przejazd',
|
||||
'carpool_driver' => 'Kierowca',
|
||||
'carpool_passengers' => 'Pasażerowie',
|
||||
|
||||
'comments' => 'Komentarze',
|
||||
'comment_placeholder' => 'Napisz komentarz...',
|
||||
'no_comments' => 'Brak komentarzy.',
|
||||
@@ -57,6 +76,22 @@ return [
|
||||
'score_away' => 'Goście',
|
||||
'vs' => 'vs.',
|
||||
|
||||
// Statystyki graczy
|
||||
'stats' => 'Statystyki graczy',
|
||||
'stats_save' => 'Zapisz statystyki',
|
||||
'stats_saved' => 'Statystyki zapisane.',
|
||||
'stats_goalkeeper' => 'BR',
|
||||
'stats_goalkeeper_long' => 'Bramkarz',
|
||||
'stats_saves' => 'Obrony',
|
||||
'stats_shots_on_goal' => 'Strzały na bramkę',
|
||||
'stats_goals' => 'Bramki',
|
||||
'stats_shots' => 'Rzuty',
|
||||
'stats_note' => 'Uwaga',
|
||||
'stats_hit_rate' => 'Skuteczność',
|
||||
'stats_save_rate' => 'Skuteczność obron',
|
||||
'stats_no_data' => 'Brak danych statystycznych.',
|
||||
'stats_position' => 'Pozycja',
|
||||
|
||||
// Staff visibility
|
||||
'signed_up' => 'zapisany',
|
||||
'withdrawn' => 'wypisany',
|
||||
|
||||
@@ -82,6 +82,24 @@ return [
|
||||
'parent_rep' => 'Przedstawiciel rodziców',
|
||||
'user' => 'Rodzic',
|
||||
],
|
||||
'player_position' => [
|
||||
'torwart' => 'Bramkarz',
|
||||
'links_aussen' => 'Lewe skrzydło',
|
||||
'rechts_aussen' => 'Prawe skrzydło',
|
||||
'rueckraum_links' => 'Lewy rozgrywający',
|
||||
'rueckraum_mitte' => 'Środkowy rozgrywający',
|
||||
'rueckraum_rechts' => 'Prawy rozgrywający',
|
||||
'kreislaeufer' => 'Kołowy',
|
||||
],
|
||||
'player_position_short' => [
|
||||
'torwart' => 'BR',
|
||||
'links_aussen' => 'LS',
|
||||
'rechts_aussen' => 'PS',
|
||||
'rueckraum_links' => 'LR',
|
||||
'rueckraum_mitte' => 'ŚR',
|
||||
'rueckraum_rechts' => 'PR',
|
||||
'kreislaeufer' => 'KO',
|
||||
],
|
||||
],
|
||||
'locales' => [
|
||||
'de' => 'Deutsch',
|
||||
|
||||
@@ -325,6 +325,10 @@ return [
|
||||
'log_participant_changed' => 'Статус участия для ":event" изменён на :status',
|
||||
'log_catering_changed' => 'Статус кейтеринга для ":event" изменён на :status',
|
||||
'log_timekeeper_changed' => 'Статус хронометриста для ":event" изменён на :status',
|
||||
'log_carpool_offer' => 'Поездка для ":event" предложена (:seats мест)',
|
||||
'log_carpool_withdrawn' => 'Поездка для ":event" отменена (:passengers пассажиров удалено)',
|
||||
'log_carpool_joined' => ':player едет с :driver (Мероприятие: ":event")',
|
||||
'log_carpool_left' => ':player удалён из поездки с :driver (Мероприятие: ":event")',
|
||||
'log_comment_created' => 'Комментарий добавлен к ":event"',
|
||||
'log_comment_deleted' => 'Комментарий удалён из ":event"',
|
||||
'log_file_uploaded' => 'Файл ":name" загружен',
|
||||
@@ -374,6 +378,15 @@ return [
|
||||
'favicon_uploaded' => 'Фавикон обновлён.',
|
||||
'favicon_removed' => 'Фавикон удалён.',
|
||||
|
||||
// Logos
|
||||
'logo_login_label' => 'Логотип входа',
|
||||
'logo_login_desc' => 'Отображается на странице входа над названием приложения.',
|
||||
'logo_app_label' => 'Логотип приложения (навигация)',
|
||||
'logo_app_desc' => 'Отображается в панели навигации рядом с названием приложения.',
|
||||
'logo_current' => 'Текущий логотип',
|
||||
'logo_remove' => 'Удалить логотип',
|
||||
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (макс. 1 МБ)',
|
||||
|
||||
// Undo / Revert
|
||||
'log_revert' => 'Отменить',
|
||||
'log_revert_confirm' => 'Вы действительно хотите отменить это действие?',
|
||||
@@ -564,4 +577,21 @@ return [
|
||||
'mail_test_button' => 'Проверить соединение',
|
||||
'mail_testing' => 'Проверка соединения...',
|
||||
'mail_test_success' => 'SMTP-соединение успешно!',
|
||||
|
||||
// Статистика игроков
|
||||
'stats_player_detail' => 'Детали игрока',
|
||||
'stats_total_goals' => 'Всего голов',
|
||||
'stats_total_shots' => 'Всего бросков',
|
||||
'stats_gk_appearances' => 'Игры вратарём',
|
||||
'stats_total_saves' => 'Всего отражений',
|
||||
'stats_close' => 'Закрыть',
|
||||
'player_goals' => 'Голы',
|
||||
|
||||
// Позиции и площадка
|
||||
'position' => 'Позиция',
|
||||
'court_visualization' => 'Расстановка на площадке',
|
||||
'court_no_data' => 'Нет данных',
|
||||
'performance_good' => 'Хорошо',
|
||||
'performance_average' => 'Средне',
|
||||
'performance_below' => 'Ниже среднего',
|
||||
];
|
||||
|
||||
@@ -40,6 +40,24 @@ return [
|
||||
'no_timekeeper_yet' => 'Хронометрист ещё не назначен.',
|
||||
'timekeeper_updated' => 'Статус хронометриста обновлён.',
|
||||
|
||||
// Совместные поездки
|
||||
'carpool' => 'Совместные поездки',
|
||||
'carpool_offer' => 'Предложить поездку',
|
||||
'carpool_update' => 'Обновить поездку',
|
||||
'carpool_seats' => 'Свободные места',
|
||||
'carpool_seats_count' => ':free из :total мест свободно',
|
||||
'carpool_seats_too_few' => 'Пассажиров уже больше, чем новое количество мест.',
|
||||
'carpool_note_placeholder' => 'напр. место встречи, время отправления...',
|
||||
'carpool_withdraw' => 'Отменить поездку',
|
||||
'carpool_withdraw_confirm' => 'Действительно отменить поездку? Все пассажиры будут удалены.',
|
||||
'carpool_join' => 'Назначить',
|
||||
'carpool_leave' => 'Удалить',
|
||||
'carpool_full' => 'Все места заняты',
|
||||
'no_carpool_yet' => 'Пока нет предложений по совместным поездкам.',
|
||||
'carpool_my_offer' => 'Моя поездка',
|
||||
'carpool_driver' => 'Водитель',
|
||||
'carpool_passengers' => 'Пассажиры',
|
||||
|
||||
// Комментарии
|
||||
'comments' => 'Комментарии',
|
||||
'comment_placeholder' => 'Написать комментарий...',
|
||||
@@ -71,6 +89,22 @@ return [
|
||||
'score_away' => 'Гости',
|
||||
'vs' => 'против',
|
||||
|
||||
// Статистика игроков
|
||||
'stats' => 'Статистика игроков',
|
||||
'stats_save' => 'Сохранить статистику',
|
||||
'stats_saved' => 'Статистика сохранена.',
|
||||
'stats_goalkeeper' => 'ВР',
|
||||
'stats_goalkeeper_long' => 'Вратарь',
|
||||
'stats_saves' => 'Отражено',
|
||||
'stats_shots_on_goal' => 'Броски по воротам',
|
||||
'stats_goals' => 'Голы',
|
||||
'stats_shots' => 'Броски',
|
||||
'stats_note' => 'Примечание',
|
||||
'stats_hit_rate' => 'Процент попаданий',
|
||||
'stats_save_rate' => 'Процент отражений',
|
||||
'stats_no_data' => 'Нет данных статистики.',
|
||||
'stats_position' => 'Позиция',
|
||||
|
||||
// Staff visibility
|
||||
'signed_up' => 'записался',
|
||||
'withdrawn' => 'отказался',
|
||||
|
||||
@@ -82,6 +82,24 @@ return [
|
||||
'parent_rep' => 'Представитель родителей',
|
||||
'user' => 'Родитель',
|
||||
],
|
||||
'player_position' => [
|
||||
'torwart' => 'Вратарь',
|
||||
'links_aussen' => 'Левый крайний',
|
||||
'rechts_aussen' => 'Правый крайний',
|
||||
'rueckraum_links' => 'Левый полусредний',
|
||||
'rueckraum_mitte' => 'Центральный разыгрывающий',
|
||||
'rueckraum_rechts' => 'Правый полусредний',
|
||||
'kreislaeufer' => 'Линейный',
|
||||
],
|
||||
'player_position_short' => [
|
||||
'torwart' => 'ВР',
|
||||
'links_aussen' => 'ЛК',
|
||||
'rechts_aussen' => 'ПК',
|
||||
'rueckraum_links' => 'ЛП',
|
||||
'rueckraum_mitte' => 'ЦР',
|
||||
'rueckraum_rechts' => 'ПП',
|
||||
'kreislaeufer' => 'ЛН',
|
||||
],
|
||||
],
|
||||
'locales' => [
|
||||
'de' => 'Deutsch',
|
||||
|
||||
@@ -325,6 +325,10 @@ return [
|
||||
'log_participant_changed' => '":event" için katılım durumu :status olarak değiştirildi',
|
||||
'log_catering_changed' => '":event" için ikram durumu :status olarak değiştirildi',
|
||||
'log_timekeeper_changed' => '":event" için zaman tutucu durumu :status olarak değiştirildi',
|
||||
'log_carpool_offer' => '":event" için yolculuk teklif edildi (:seats koltuk)',
|
||||
'log_carpool_withdrawn' => '":event" için yolculuk geri çekildi (:passengers yolcu kaldırıldı)',
|
||||
'log_carpool_joined' => ':player, :driver ile gidiyor (Etkinlik: ":event")',
|
||||
'log_carpool_left' => ':player, :driver ile yolculuktan kaldırıldı (Etkinlik: ":event")',
|
||||
'log_comment_created' => '":event" için yorum eklendi',
|
||||
'log_comment_deleted' => '":event" için yorum silindi',
|
||||
'log_file_uploaded' => '":name" dosyası yüklendi',
|
||||
@@ -374,6 +378,15 @@ return [
|
||||
'favicon_uploaded' => 'Favicon güncellendi.',
|
||||
'favicon_removed' => 'Favicon kaldırıldı.',
|
||||
|
||||
// Logos
|
||||
'logo_login_label' => 'Giriş Logosu',
|
||||
'logo_login_desc' => 'Giriş sayfasında uygulama adının üstünde görüntülenir.',
|
||||
'logo_app_label' => 'Uygulama Logosu (Gezinme)',
|
||||
'logo_app_desc' => 'Gezinme çubuğunda uygulama adının yanında görüntülenir.',
|
||||
'logo_current' => 'Mevcut logo',
|
||||
'logo_remove' => 'Logoyu kaldır',
|
||||
'logo_hint' => 'PNG, SVG, JPG, GIF, WebP (maks. 1 MB)',
|
||||
|
||||
// Undo / Revert
|
||||
'log_revert' => 'Geri al',
|
||||
'log_revert_confirm' => 'Bu işlemi gerçekten geri almak istiyor musunuz?',
|
||||
@@ -564,4 +577,21 @@ return [
|
||||
'mail_test_button' => 'Bağlantıyı Test Et',
|
||||
'mail_testing' => 'Bağlantı test ediliyor...',
|
||||
'mail_test_success' => 'SMTP bağlantısı başarılı!',
|
||||
|
||||
// Oyuncu istatistikleri
|
||||
'stats_player_detail' => 'Oyuncu detayları',
|
||||
'stats_total_goals' => 'Toplam goller',
|
||||
'stats_total_shots' => 'Toplam atışlar',
|
||||
'stats_gk_appearances' => 'Kaleci maçları',
|
||||
'stats_total_saves' => 'Toplam kurtarışlar',
|
||||
'stats_close' => 'Kapat',
|
||||
'player_goals' => 'Goller',
|
||||
|
||||
// Pozisyonlar ve Saha
|
||||
'position' => 'Pozisyon',
|
||||
'court_visualization' => 'Saha Dizilişi',
|
||||
'court_no_data' => 'Veri yok',
|
||||
'performance_good' => 'İyi',
|
||||
'performance_average' => 'Orta',
|
||||
'performance_below' => 'Ortalamanın altı',
|
||||
];
|
||||
|
||||
@@ -31,6 +31,25 @@ return [
|
||||
'timekeeper_short' => 'Zaman Tutucusu',
|
||||
'no_timekeeper_yet' => 'Henüz zaman tutucusu atanmadı.',
|
||||
'timekeeper_updated' => 'Zaman tutucusu durumu güncellendi.',
|
||||
|
||||
// Araç paylaşımı
|
||||
'carpool' => 'Araç Paylaşımı',
|
||||
'carpool_offer' => 'Yolculuk teklif et',
|
||||
'carpool_update' => 'Yolculuğu güncelle',
|
||||
'carpool_seats' => 'Boş koltuklar',
|
||||
'carpool_seats_count' => ':free / :total koltuk boş',
|
||||
'carpool_seats_too_few' => 'Zaten yeni koltuk sayısından fazla yolcu atanmış.',
|
||||
'carpool_note_placeholder' => 'örn. buluşma noktası, kalkış saati...',
|
||||
'carpool_withdraw' => 'Yolculuğu geri çek',
|
||||
'carpool_withdraw_confirm' => 'Yolculuğu gerçekten geri çekmek istiyor musunuz? Tüm yolcular kaldırılacak.',
|
||||
'carpool_join' => 'Ata',
|
||||
'carpool_leave' => 'Kaldır',
|
||||
'carpool_full' => 'Tüm koltuklar dolu',
|
||||
'no_carpool_yet' => 'Henüz araç paylaşımı teklifi yok.',
|
||||
'carpool_my_offer' => 'Benim yolculuğum',
|
||||
'carpool_driver' => 'Sürücü',
|
||||
'carpool_passengers' => 'Yolcular',
|
||||
|
||||
'comments' => 'Yorumlar',
|
||||
'comment_placeholder' => 'Yorum yaz...',
|
||||
'no_comments' => 'Henüz yorum yok.',
|
||||
@@ -57,6 +76,22 @@ return [
|
||||
'score_away' => 'Deplasman',
|
||||
'vs' => 'vs.',
|
||||
|
||||
// Oyuncu istatistikleri
|
||||
'stats' => 'Oyuncu İstatistikleri',
|
||||
'stats_save' => 'İstatistikleri kaydet',
|
||||
'stats_saved' => 'İstatistikler kaydedildi.',
|
||||
'stats_goalkeeper' => 'KL',
|
||||
'stats_goalkeeper_long' => 'Kaleci',
|
||||
'stats_saves' => 'Kurtarışlar',
|
||||
'stats_shots_on_goal' => 'Kaleye atışlar',
|
||||
'stats_goals' => 'Goller',
|
||||
'stats_shots' => 'Atışlar',
|
||||
'stats_note' => 'Not',
|
||||
'stats_hit_rate' => 'İsabet oranı',
|
||||
'stats_save_rate' => 'Kurtarış oranı',
|
||||
'stats_no_data' => 'İstatistik verisi yok.',
|
||||
'stats_position' => 'Pozisyon',
|
||||
|
||||
// Staff visibility
|
||||
'signed_up' => 'kaydoldu',
|
||||
'withdrawn' => 'çekildi',
|
||||
|
||||
@@ -82,6 +82,24 @@ return [
|
||||
'parent_rep' => 'Veli Temsilcisi',
|
||||
'user' => 'Veli',
|
||||
],
|
||||
'player_position' => [
|
||||
'torwart' => 'Kaleci',
|
||||
'links_aussen' => 'Sol Kanat',
|
||||
'rechts_aussen' => 'Sağ Kanat',
|
||||
'rueckraum_links' => 'Sol Çapraz',
|
||||
'rueckraum_mitte' => 'Orta Çapraz',
|
||||
'rueckraum_rechts' => 'Sağ Çapraz',
|
||||
'kreislaeufer' => 'Pivot',
|
||||
],
|
||||
'player_position_short' => [
|
||||
'torwart' => 'KL',
|
||||
'links_aussen' => 'SK',
|
||||
'rechts_aussen' => 'SĞK',
|
||||
'rueckraum_links' => 'SÇ',
|
||||
'rueckraum_mitte' => 'OÇ',
|
||||
'rueckraum_rechts' => 'SĞÇ',
|
||||
'kreislaeufer' => 'PV',
|
||||
],
|
||||
],
|
||||
'locales' => [
|
||||
'de' => 'Deutsch',
|
||||
|
||||
@@ -357,6 +357,106 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Spielerstatistik (nur Spieltypen mit zugesagten Spielern) --}}
|
||||
@if ($event->type->isGameType())
|
||||
@php
|
||||
$confirmedPlayers = $event->participants
|
||||
->where('status', \App\Enums\ParticipantStatus::Yes)
|
||||
->whereNotNull('player_id')
|
||||
->sortBy(fn($p) => $p->player->last_name ?? '');
|
||||
@endphp
|
||||
@if ($confirmedPlayers->isNotEmpty())
|
||||
<div class="bg-white rounded-lg shadow p-6 max-w-4xl mt-6">
|
||||
<h2 class="text-lg font-semibold mb-1">{{ __('events.stats') }}</h2>
|
||||
<p class="text-xs text-gray-500 mb-4">{{ __('events.stats_confirmed_only') }}</p>
|
||||
|
||||
<form method="POST" action="{{ route('admin.events.update-stats', $event) }}">
|
||||
@csrf
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="text-left px-2 py-2 font-medium text-gray-600">{{ __('admin.nav_players') }}</th>
|
||||
<th class="text-center px-2 py-2 font-medium text-gray-600 w-28">{{ __('events.stats_position') }}</th>
|
||||
<th class="text-center px-2 py-2 font-medium text-gray-600 w-10" title="{{ __('events.stats_goalkeeper_long') }}">{{ __('events.stats_goalkeeper') }}</th>
|
||||
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_shots_on_goal') }}</th>
|
||||
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_saves') }}</th>
|
||||
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_shots') }}</th>
|
||||
<th class="text-center px-2 py-2 font-medium text-gray-600 w-20">{{ __('events.stats_goals') }}</th>
|
||||
<th class="text-left px-2 py-2 font-medium text-gray-600">{{ __('events.stats_note') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach ($confirmedPlayers as $participant)
|
||||
@php
|
||||
$pid = $participant->player_id;
|
||||
$stat = $playerStatsMap[$pid] ?? null;
|
||||
@endphp
|
||||
<tr x-data="{ isGk: {{ $stat && $stat->is_goalkeeper ? 'true' : 'false' }} }">
|
||||
<td class="px-2 py-2 font-medium text-gray-900 whitespace-nowrap">
|
||||
{{ $participant->player->full_name }}
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<select name="stats[{{ $pid }}][position]"
|
||||
class="w-full px-1 py-1 border border-gray-300 rounded text-sm"
|
||||
@change="isGk = ($event.target.value === 'torwart')">
|
||||
<option value="">–</option>
|
||||
@foreach (\App\Enums\PlayerPosition::cases() as $pos)
|
||||
<option value="{{ $pos->value }}"
|
||||
{{ ($stat?->position?->value ?? $participant->player->position?->value) === $pos->value ? 'selected' : '' }}>
|
||||
{{ $pos->shortLabel() }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center">
|
||||
<input type="checkbox" name="stats[{{ $pid }}][is_goalkeeper]" value="1"
|
||||
x-model="isGk"
|
||||
class="rounded border-gray-300 text-blue-600">
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center" x-show="isGk" x-cloak>
|
||||
<input type="number" name="stats[{{ $pid }}][goalkeeper_shots]" min="0" max="999"
|
||||
value="{{ $stat?->goalkeeper_shots }}"
|
||||
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center" x-show="!isGk"><span class="text-gray-300">–</span></td>
|
||||
<td class="px-2 py-2 text-center" x-show="isGk" x-cloak>
|
||||
<input type="number" name="stats[{{ $pid }}][goalkeeper_saves]" min="0" max="999"
|
||||
value="{{ $stat?->goalkeeper_saves }}"
|
||||
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center" x-show="!isGk"><span class="text-gray-300">–</span></td>
|
||||
<td class="px-2 py-2 text-center">
|
||||
<input type="number" name="stats[{{ $pid }}][shots]" min="0" max="999"
|
||||
value="{{ $stat?->shots }}"
|
||||
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
|
||||
</td>
|
||||
<td class="px-2 py-2 text-center">
|
||||
<input type="number" name="stats[{{ $pid }}][goals]" min="0" max="999"
|
||||
value="{{ $stat?->goals }}"
|
||||
class="w-16 px-1 py-1 border border-gray-300 rounded text-center text-sm">
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<input type="text" name="stats[{{ $pid }}][note]" maxlength="500"
|
||||
value="{{ $stat?->note }}"
|
||||
placeholder="{{ __('events.stats_note') }}..."
|
||||
class="w-full px-2 py-1 border border-gray-300 rounded text-sm">
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
|
||||
{{ __('events.stats_save') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- Quill JS --}}
|
||||
<script src="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.min.js" integrity="sha384-QUJ+ckWz1M+a7w0UfG1sEn4pPrbQwSxGm/1TIPyioqXBrwuT9l4f9gdHWLDLbVWI" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
|
||||
@@ -44,6 +44,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="position" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.position') }}</label>
|
||||
<select name="position" id="position" class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="">{{ __('admin.please_select') }}</option>
|
||||
@foreach (\App\Enums\PlayerPosition::cases() as $pos)
|
||||
<option value="{{ $pos->value }}" {{ old('position') === $pos->value ? 'selected' : '' }}>{{ $pos->label() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
|
||||
@@ -74,6 +74,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="position" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.position') }}</label>
|
||||
<select name="position" id="position" class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
||||
<option value="">{{ __('admin.please_select') }}</option>
|
||||
@foreach (\App\Enums\PlayerPosition::cases() as $pos)
|
||||
<option value="{{ $pos->value }}" {{ old('position', $player->position?->value) === $pos->value ? 'selected' : '' }}>{{ $pos->label() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center">
|
||||
<input type="hidden" name="photo_permission" value="0">
|
||||
|
||||
@@ -95,6 +95,50 @@
|
||||
<p class="mt-1.5 text-xs text-gray-400">{{ __('admin.favicon_hint') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Logo Login --}}
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{ __('admin.logo_login_label') }}</label>
|
||||
<p class="text-xs text-gray-400 mb-3">{{ __('admin.logo_login_desc') }}</p>
|
||||
@php $currentLogoLogin = \App\Models\Setting::get('app_logo_login'); @endphp
|
||||
@if ($currentLogoLogin)
|
||||
<div class="flex items-center gap-4 mb-3 p-3 bg-gray-50 rounded-md border border-gray-200">
|
||||
<img src="{{ asset('storage/' . $currentLogoLogin) }}" alt="Login-Logo" class="h-16 max-w-[200px] object-contain">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm text-gray-500">{{ __('admin.logo_current') }}</span>
|
||||
<label class="flex items-center gap-1.5 text-sm text-red-600 cursor-pointer">
|
||||
<input type="checkbox" name="remove_logo_login" value="1" class="rounded border-gray-300">
|
||||
{{ __('admin.logo_remove') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<input type="file" name="logo_login" accept=".png,.svg,.jpg,.jpeg,.gif,.webp"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm file:mr-3 file:py-1 file:px-3 file:rounded-md file:border-0 file:text-sm file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
|
||||
<p class="mt-1.5 text-xs text-gray-400">{{ __('admin.logo_hint') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Logo App (Navbar) --}}
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-1">{{ __('admin.logo_app_label') }}</label>
|
||||
<p class="text-xs text-gray-400 mb-3">{{ __('admin.logo_app_desc') }}</p>
|
||||
@php $currentLogoApp = \App\Models\Setting::get('app_logo_app'); @endphp
|
||||
@if ($currentLogoApp)
|
||||
<div class="flex items-center gap-4 mb-3 p-3 bg-gray-50 rounded-md border border-gray-200">
|
||||
<img src="{{ asset('storage/' . $currentLogoApp) }}" alt="App-Logo" class="h-10 max-w-[200px] object-contain">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm text-gray-500">{{ __('admin.logo_current') }}</span>
|
||||
<label class="flex items-center gap-1.5 text-sm text-red-600 cursor-pointer">
|
||||
<input type="checkbox" name="remove_logo_app" value="1" class="rounded border-gray-300">
|
||||
{{ __('admin.logo_remove') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<input type="file" name="logo_app" accept=".png,.svg,.jpg,.jpeg,.gif,.webp"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm file:mr-3 file:py-1 file:px-3 file:rounded-md file:border-0 file:text-sm file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
|
||||
<p class="mt-1.5 text-xs text-gray-400">{{ __('admin.logo_hint') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Richtext-Settings (Slogan mit Mini-Quill) --}}
|
||||
@foreach ($settings as $key => $setting)
|
||||
@if ($setting->type === 'richtext')
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
|
||||
{{-- Spieler-Rangliste (nur Staff) --}}
|
||||
@if (auth()->user()->isStaff() && $playerRanking->isNotEmpty())
|
||||
<div x-data="playerDetailModal()">
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden mt-6">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<h3 class="text-sm font-semibold text-gray-700">{{ __('admin.player_ranking_title') }}</h3>
|
||||
@@ -153,15 +154,23 @@
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2.5 font-medium text-gray-600 w-8">#</th>
|
||||
<th class="text-left px-4 py-2.5 font-medium text-gray-600">{{ __('admin.nav_players') }}</th>
|
||||
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.position') }}</th>
|
||||
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.games_played') }}</th>
|
||||
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.games_assigned') }}</th>
|
||||
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.player_goals') }}</th>
|
||||
<th class="text-center px-4 py-2.5 font-medium text-gray-600">{{ __('admin.participation_rate') }}</th>
|
||||
<th class="px-4 py-2.5 font-medium text-gray-600 w-32"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@php $separatorShown = false; @endphp
|
||||
@foreach ($playerRanking as $index => $entry)
|
||||
<tr class="hover:bg-gray-50">
|
||||
@if (!$separatorShown && !$entry->is_primary_gk)
|
||||
@php $separatorShown = true; @endphp
|
||||
@if ($index > 0)
|
||||
<tr><td colspan="7" class="px-4 py-1"><hr class="border-gray-300"></td></tr>
|
||||
@endif
|
||||
@endif
|
||||
<tr class="hover:bg-gray-50 cursor-pointer" @click="openModal({{ $entry->player->id }})">
|
||||
<td class="px-4 py-2 text-gray-400">{{ $index + 1 }}</td>
|
||||
<td class="px-4 py-2 flex items-center gap-2">
|
||||
@if ($entry->player->getAvatarUrl())
|
||||
@@ -169,10 +178,25 @@
|
||||
@else
|
||||
<div class="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs font-semibold">{{ $entry->player->getInitials() }}</div>
|
||||
@endif
|
||||
<span class="{{ $entry->player->trashed() ? 'text-gray-400 line-through' : '' }}">{{ $entry->player->full_name }}</span>
|
||||
<span class="text-blue-600 hover:underline {{ $entry->player->trashed() ? 'text-gray-400 line-through' : '' }}">{{ $entry->player->full_name }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
@if ($entry->primary_position)
|
||||
<span class="inline-block px-1.5 py-0.5 rounded text-xs font-medium {{ $entry->is_primary_gk ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-700' }}">
|
||||
{{ $entry->primary_position->shortLabel() }}
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-300">–</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center font-medium">{{ $entry->games_played }}</td>
|
||||
<td class="px-4 py-2 text-center text-gray-500">{{ $entry->total_assigned }}</td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
@if ($entry->total_goals > 0)
|
||||
<span class="font-medium text-green-600">{{ $entry->total_goals }}</span>
|
||||
@else
|
||||
<span class="text-gray-300">0</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {{ $entry->rate >= 75 ? 'bg-green-100 text-green-800' : ($entry->rate >= 50 ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800') }}">
|
||||
{{ $entry->rate }}%
|
||||
@@ -188,7 +212,229 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Spielfeld-Aufstellung --}}
|
||||
@if ($courtPlayers->isNotEmpty())
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden mt-6">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<h3 class="text-sm font-semibold text-gray-700">{{ __('admin.court_visualization') }}</h3>
|
||||
</div>
|
||||
<div class="p-4 flex justify-center">
|
||||
@php
|
||||
// Positionskoordinaten auf dem Spielfeld (viewBox 0 0 400 320)
|
||||
$courtPositions = [
|
||||
'torwart' => ['x' => 200, 'y' => 280],
|
||||
'links_aussen' => ['x' => 55, 'y' => 185],
|
||||
'rechts_aussen' => ['x' => 345, 'y' => 185],
|
||||
'rueckraum_links' => ['x' => 115, 'y' => 105],
|
||||
'rueckraum_mitte' => ['x' => 200, 'y' => 75],
|
||||
'rueckraum_rechts' => ['x' => 285, 'y' => 105],
|
||||
'kreislaeufer' => ['x' => 200, 'y' => 180],
|
||||
];
|
||||
$colorMap = [
|
||||
'green' => ['fill' => '#22c55e', 'text' => '#fff'],
|
||||
'yellow' => ['fill' => '#eab308', 'text' => '#fff'],
|
||||
'red' => ['fill' => '#ef4444', 'text' => '#fff'],
|
||||
'gray' => ['fill' => '#9ca3af', 'text' => '#fff'],
|
||||
];
|
||||
@endphp
|
||||
<svg viewBox="0 0 400 320" class="w-full max-w-lg" xmlns="http://www.w3.org/2000/svg">
|
||||
{{-- Spielfeld-Hintergrund --}}
|
||||
<rect x="0" y="0" width="400" height="320" rx="8" fill="#16a34a" />
|
||||
<rect x="10" y="10" width="380" height="300" rx="4" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.5" />
|
||||
|
||||
{{-- Mittellinie --}}
|
||||
<line x1="10" y1="160" x2="390" y2="160" stroke="#fff" stroke-width="1" opacity="0.3" />
|
||||
|
||||
{{-- Tor (unten) --}}
|
||||
<rect x="155" y="298" width="90" height="12" rx="2" fill="none" stroke="#fff" stroke-width="2" opacity="0.7" />
|
||||
|
||||
{{-- 6m-Torraum (Halbkreis) --}}
|
||||
<path d="M 120 310 Q 120 230 200 220 Q 280 230 280 310" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.5" />
|
||||
|
||||
{{-- 9m-Freiwurflinie (gestrichelt) --}}
|
||||
<path d="M 80 310 Q 80 200 200 185 Q 320 200 320 310" fill="none" stroke="#fff" stroke-width="1" stroke-dasharray="6,4" opacity="0.35" />
|
||||
|
||||
{{-- 7m-Markierung --}}
|
||||
<line x1="193" y1="248" x2="207" y2="248" stroke="#fff" stroke-width="2" opacity="0.5" />
|
||||
|
||||
{{-- Spieler-Positionen --}}
|
||||
@foreach ($courtPositions as $posValue => $coords)
|
||||
@php
|
||||
$entry = $courtPlayers->get($posValue);
|
||||
$color = $entry ? $colorMap[$entry->performance_color] : $colorMap['gray'];
|
||||
$posEnum = \App\Enums\PlayerPosition::tryFrom($posValue);
|
||||
@endphp
|
||||
<g @if ($entry) @click="openModal({{ $entry->player->id }})" style="cursor: pointer;" @endif>
|
||||
<circle cx="{{ $coords['x'] }}" cy="{{ $coords['y'] }}" r="22" fill="{{ $color['fill'] }}" opacity="0.9" stroke="#fff" stroke-width="1.5" />
|
||||
@if ($entry)
|
||||
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] - 4 }}" text-anchor="middle" fill="{{ $color['text'] }}" font-size="11" font-weight="bold" style="pointer-events: none;">
|
||||
{{ $entry->player->jersey_number ?? $entry->player->getInitials() }}
|
||||
</text>
|
||||
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] + 8 }}" text-anchor="middle" fill="{{ $color['text'] }}" font-size="7" style="pointer-events: none;">
|
||||
{{ Str::limit($entry->player->first_name, 8, '') }}
|
||||
</text>
|
||||
@else
|
||||
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] + 4 }}" text-anchor="middle" fill="{{ $color['text'] }}" font-size="9" font-weight="bold">
|
||||
{{ $posEnum?->shortLabel() }}
|
||||
</text>
|
||||
@endif
|
||||
{{-- Positions-Kürzel unter dem Kreis --}}
|
||||
<text x="{{ $coords['x'] }}" y="{{ $coords['y'] + 35 }}" text-anchor="middle" fill="#fff" font-size="8" opacity="0.7" style="pointer-events: none;">
|
||||
{{ $posEnum?->shortLabel() }}
|
||||
</text>
|
||||
</g>
|
||||
@endforeach
|
||||
</svg>
|
||||
</div>
|
||||
{{-- Legende --}}
|
||||
<div class="px-4 pb-4 flex flex-wrap gap-4 justify-center text-xs text-gray-600">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-green-500"></span>
|
||||
{{ __('admin.performance_good') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-yellow-500"></span>
|
||||
{{ __('admin.performance_average') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-red-500"></span>
|
||||
{{ __('admin.performance_below') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-gray-400"></span>
|
||||
{{ __('admin.court_no_data') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Spieler-Detail-Modal --}}
|
||||
<div x-show="show" x-cloak x-transition.opacity class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" @click.self="show = false" @keydown.escape.window="show = false">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] overflow-hidden" @click.stop>
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<template x-if="data && data.player.avatar">
|
||||
<img :src="data.player.avatar" class="w-10 h-10 rounded-full object-cover">
|
||||
</template>
|
||||
<template x-if="data && !data.player.avatar">
|
||||
<div class="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-sm font-semibold" x-text="data ? data.player.initials : ''"></div>
|
||||
</template>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900" x-text="data ? data.player.name : ''"></h3>
|
||||
<p class="text-xs text-gray-500">
|
||||
<span x-text="data && data.player.position ? data.player.position + ' · ' : ''"></span>{{ __('admin.stats_player_detail') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="show = false" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Zusammenfassung --}}
|
||||
<template x-if="data">
|
||||
<div class="px-5 py-4 border-b border-gray-100">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div class="text-center p-2 bg-green-50 rounded-lg">
|
||||
<div class="text-lg font-bold text-green-700" x-text="data.summary.total_goals"></div>
|
||||
<div class="text-xs text-green-600">{{ __('admin.stats_total_goals') }}</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-blue-50 rounded-lg">
|
||||
<div class="text-lg font-bold text-blue-700" x-text="data.summary.total_shots"></div>
|
||||
<div class="text-xs text-blue-600">{{ __('admin.stats_total_shots') }}</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-amber-50 rounded-lg">
|
||||
<div class="text-lg font-bold text-amber-700" x-text="data.summary.hit_rate !== null ? data.summary.hit_rate + '%' : '–'"></div>
|
||||
<div class="text-xs text-amber-600">{{ __('events.stats_hit_rate') }}</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-purple-50 rounded-lg">
|
||||
<div class="text-lg font-bold text-purple-700" x-text="data.summary.gk_appearances"></div>
|
||||
<div class="text-xs text-purple-600">{{ __('admin.stats_gk_appearances') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="data.summary.gk_appearances > 0">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 mt-3">
|
||||
<div class="text-center p-2 bg-indigo-50 rounded-lg">
|
||||
<div class="text-lg font-bold text-indigo-700" x-text="data.summary.total_saves"></div>
|
||||
<div class="text-xs text-indigo-600">{{ __('admin.stats_total_saves') }}</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-gray-50 rounded-lg">
|
||||
<div class="text-lg font-bold text-gray-700" x-text="data.summary.total_gk_shots"></div>
|
||||
<div class="text-xs text-gray-500">{{ __('events.stats_shots_on_goal') }}</div>
|
||||
</div>
|
||||
<div class="text-center p-2 bg-teal-50 rounded-lg">
|
||||
<div class="text-lg font-bold text-teal-700" x-text="data.summary.save_rate !== null ? data.summary.save_rate + '%' : '–'"></div>
|
||||
<div class="text-xs text-teal-600">{{ __('events.stats_save_rate') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Spiel-Liste --}}
|
||||
<div class="overflow-y-auto" style="max-height: 40vh;">
|
||||
<template x-if="loading">
|
||||
<div class="flex items-center justify-center py-8 text-gray-400">
|
||||
<svg class="animate-spin w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="data && data.games.length > 0">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500">{{ __('admin.date') }}</th>
|
||||
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.opponent') }}</th>
|
||||
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.score') }}</th>
|
||||
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_position') }}</th>
|
||||
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_goals') }}</th>
|
||||
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_shots') }}</th>
|
||||
<th class="text-center px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_goalkeeper') }}</th>
|
||||
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500">{{ __('events.stats_note') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="game in data.games" :key="game.date + game.opponent">
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2 whitespace-nowrap text-gray-600" x-text="game.date"></td>
|
||||
<td class="px-4 py-2" x-text="game.opponent"></td>
|
||||
<td class="px-4 py-2 text-center font-medium" x-text="game.score"></td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
<span x-text="game.position ?? '–'" :class="game.position ? 'text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded' : 'text-gray-300'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
<span x-text="game.goals ?? '–'" :class="game.goals > 0 ? 'font-medium text-green-600' : 'text-gray-400'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center text-gray-500" x-text="game.shots ?? '–'"></td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
<template x-if="game.is_goalkeeper">
|
||||
<span class="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded" x-text="(game.goalkeeper_saves ?? 0) + '/' + (game.goalkeeper_shots ?? 0)"></span>
|
||||
</template>
|
||||
<template x-if="!game.is_goalkeeper">
|
||||
<span class="text-gray-300">–</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-gray-500 text-xs" x-text="game.note ?? ''"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<template x-if="data && data.games.length === 0">
|
||||
<div class="py-8 text-center text-gray-400 text-sm">{{ __('events.stats_no_data') }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Footer --}}
|
||||
<div class="px-5 py-3 border-t border-gray-200 text-right">
|
||||
<button @click="show = false" class="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200">{{ __('admin.stats_close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>{{-- /x-data playerDetailModal --}}
|
||||
@endif
|
||||
|
||||
{{-- Eltern-Engagement-Rangliste --}}
|
||||
@@ -255,6 +501,34 @@
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function playerDetailModal() {
|
||||
return {
|
||||
show: false,
|
||||
loading: false,
|
||||
data: null,
|
||||
async openModal(playerId) {
|
||||
this.show = true;
|
||||
this.loading = true;
|
||||
this.data = null;
|
||||
try {
|
||||
const resp = await fetch(@js(url('/admin/statistics/player')) + '/' + playerId, {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.data = await resp.json();
|
||||
}
|
||||
} catch (e) {
|
||||
// Fehler still ignorieren
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@if ($totalWithScore > 0)
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js" integrity="sha384-vsrfeLOOY6KuIYKDlmVH5UiBmgIdB1oEf7p01YgWHuqmOHfZr374+odEv96n9tNC" crossorigin="anonymous"></script>
|
||||
|
||||
@@ -28,8 +28,9 @@
|
||||
<div class="max-w-5xl mx-auto px-4">
|
||||
<div class="flex justify-between h-14">
|
||||
<div class="flex items-center space-x-6 rtl:space-x-reverse">
|
||||
@php $logoApp = \App\Models\Setting::get('app_logo_app'); @endphp
|
||||
<a href="{{ route('dashboard') }}" class="flex items-center gap-2 font-bold text-gray-900">
|
||||
<img src="/images/logo_woelfe.png" alt="Logo" class="h-8 w-8 object-contain">
|
||||
<img src="{{ $logoApp ? asset('storage/' . $logoApp) : asset('images/logo_woelfe.png') }}" alt="Logo" class="h-8 w-8 object-contain">
|
||||
{{ \App\Models\Setting::get('app_name', config('app.name')) }}
|
||||
</a>
|
||||
<div class="hidden sm:flex items-center space-x-6 rtl:space-x-reverse">
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
<main class="flex-1 flex items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
@php $logoLogin = \App\Models\Setting::get('app_logo_login'); @endphp
|
||||
<a href="{{ auth()->check() ? route('dashboard') : route('login') }}">
|
||||
<img src="/images/logo_sg_woelfe.png" alt="Logo" class="mx-auto h-24 mb-3">
|
||||
<img src="{{ $logoLogin ? asset('storage/' . $logoLogin) : asset('images/logo_sg_woelfe.png') }}" alt="Logo" class="mx-auto h-24 mb-3 object-contain">
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ \App\Models\Setting::get('app_name', config('app.name')) }}</h1>
|
||||
@php $slogan = \App\Models\Setting::get('app_slogan'); @endphp
|
||||
|
||||
@@ -390,6 +390,149 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Fahrgemeinschaften --}}
|
||||
@if ($event->type->hasCarpool())
|
||||
<div id="carpool" class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('events.carpool') }}</h2>
|
||||
|
||||
@if ($event->status !== \App\Enums\EventStatus::Cancelled && !auth()->user()->isDsgvoRestricted())
|
||||
{{-- Eigenes Angebot --}}
|
||||
@if ($myCarpool)
|
||||
<div class="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-sm font-semibold text-blue-700">{{ __('events.carpool_my_offer') }}</span>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('carpool.offer', $event) }}" class="flex flex-col sm:flex-row gap-3 mb-2">
|
||||
@csrf
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="carpool-seats" class="text-sm text-gray-700 whitespace-nowrap">{{ __('events.carpool_seats') }}:</label>
|
||||
<input type="number" name="seats" id="carpool-seats" min="1" max="9" value="{{ $myCarpool->seats }}"
|
||||
class="w-16 px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<input type="text" name="note" placeholder="{{ __('events.carpool_note_placeholder') }}" value="{{ $myCarpool->note }}"
|
||||
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<button type="submit" class="px-3 py-1.5 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 whitespace-nowrap">
|
||||
{{ __('events.carpool_update') }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('carpool.withdraw', $event) }}" class="inline"
|
||||
onsubmit="return confirm(@js(__('events.carpool_withdraw_confirm')))">
|
||||
@csrf
|
||||
<button type="submit" class="text-sm text-red-600 hover:text-red-800 hover:underline">
|
||||
{{ __('events.carpool_withdraw') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<form method="POST" action="{{ route('carpool.offer', $event) }}" class="mb-4">
|
||||
@csrf
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="carpool-seats-new" class="text-sm text-gray-700 whitespace-nowrap">{{ __('events.carpool_seats') }}:</label>
|
||||
<input type="number" name="seats" id="carpool-seats-new" min="1" max="9" value="3"
|
||||
class="w-16 px-2 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<input type="text" name="note" placeholder="{{ __('events.carpool_note_placeholder') }}"
|
||||
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<button type="submit" class="px-3 py-1.5 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 whitespace-nowrap">
|
||||
{{ __('events.carpool_offer') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
@error('seats') <p class="text-red-600 text-xs mb-3">{{ $message }}</p> @enderror
|
||||
@error('carpool') <p class="text-red-600 text-xs mb-3">{{ $message }}</p> @enderror
|
||||
@elseif (auth()->user()->isDsgvoRestricted())
|
||||
<p class="text-sm text-orange-600 mb-4">{{ __('ui.dsgvo_restricted_hint') }}</p>
|
||||
@endif
|
||||
|
||||
{{-- Liste aller Fahrten --}}
|
||||
@if ($event->carpools->isNotEmpty())
|
||||
<div class="space-y-3">
|
||||
@foreach ($event->carpools as $carpool)
|
||||
@php
|
||||
$isOwn = $carpool->user_id === auth()->id();
|
||||
$passengerCount = $carpool->passengers->count();
|
||||
$remaining = $carpool->seats - $passengerCount;
|
||||
$fillPercent = $carpool->seats > 0 ? ($passengerCount / $carpool->seats) * 100 : 0;
|
||||
$assignedChildIds = $carpool->passengers->pluck('player_id')->toArray();
|
||||
$assignableChildren = $userChildIds->filter(fn ($id) => !in_array($id, $assignedChildIds));
|
||||
@endphp
|
||||
<div class="p-4 border rounded-lg {{ $isOwn ? 'border-blue-300 bg-blue-50/30' : 'border-gray-200' }}">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="{{ $carpool->driver->getAvatarUrl() ?? asset('images/profil_empty.png') }}" alt="" class="w-7 h-7 rounded-full object-cover flex-shrink-0">
|
||||
<span class="text-sm font-semibold text-gray-900">{{ $carpool->driver->name }}</span>
|
||||
@if ($isOwn)
|
||||
<span class="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">{{ __('events.carpool_my_offer') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ __('events.carpool_seats_count', ['free' => $remaining, 'total' => $carpool->seats]) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Fortschrittsbalken --}}
|
||||
<div class="w-full bg-gray-200 rounded-full h-1.5 mb-2">
|
||||
<div class="h-1.5 rounded-full transition-all {{ $fillPercent >= 100 ? 'bg-red-500' : ($fillPercent >= 60 ? 'bg-yellow-500' : 'bg-green-500') }}"
|
||||
style="width: {{ min($fillPercent, 100) }}%"></div>
|
||||
</div>
|
||||
|
||||
@if ($carpool->note)
|
||||
<p class="text-xs text-gray-500 mb-2">{{ $carpool->note }}</p>
|
||||
@endif
|
||||
|
||||
{{-- Passagiere --}}
|
||||
@if ($carpool->passengers->isNotEmpty())
|
||||
<div class="flex flex-wrap gap-1.5 mb-2">
|
||||
@foreach ($carpool->passengers as $passenger)
|
||||
<span class="inline-flex items-center gap-1 bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded-full">
|
||||
{{ $passenger->player->full_name }}
|
||||
@if ($event->status !== \App\Enums\EventStatus::Cancelled && ($passenger->added_by === auth()->id() || auth()->user()->isAdmin()))
|
||||
<form method="POST" action="{{ route('carpool.leave', $event) }}" class="inline">
|
||||
@csrf
|
||||
<input type="hidden" name="carpool_id" value="{{ $carpool->id }}">
|
||||
<input type="hidden" name="player_id" value="{{ $passenger->player_id }}">
|
||||
<button type="submit" class="text-red-400 hover:text-red-600 ml-0.5" title="{{ __('events.carpool_leave') }}">
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Kinder zuordnen --}}
|
||||
@if ($event->status !== \App\Enums\EventStatus::Cancelled && !auth()->user()->isDsgvoRestricted() && $remaining > 0 && $assignableChildren->isNotEmpty())
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach ($assignableChildren as $childId)
|
||||
@php $child = $userChildren->firstWhere('id', $childId); @endphp
|
||||
@if ($child)
|
||||
<form method="POST" action="{{ route('carpool.join', $event) }}" class="inline">
|
||||
@csrf
|
||||
<input type="hidden" name="carpool_id" value="{{ $carpool->id }}">
|
||||
<input type="hidden" name="player_id" value="{{ $childId }}">
|
||||
<button type="submit" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-green-300 text-green-700 bg-green-50 hover:bg-green-100 transition">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
{{ $child->full_name }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif ($remaining <= 0 && !$isOwn)
|
||||
<p class="text-xs text-red-500">{{ __('events.carpool_full') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500">{{ __('events.no_carpool_yet') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Kommentare --}}
|
||||
<div id="comments" class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ __('events.comments') }}</h2>
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Http\Controllers\Auth\ForgotPasswordController;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Http\Controllers\Auth\RegisterController;
|
||||
use App\Http\Controllers\Auth\ResetPasswordController;
|
||||
use App\Http\Controllers\CarpoolController;
|
||||
use App\Http\Controllers\CateringController;
|
||||
use App\Http\Controllers\TimekeeperController;
|
||||
use App\Http\Controllers\CommentController;
|
||||
@@ -129,6 +130,10 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::post('/events/{event}/catering', [CateringController::class, 'update'])->name('catering.update')->middleware(['throttle:user-actions', 'dsgvo']);
|
||||
Route::post('/events/{event}/timekeeper', [TimekeeperController::class, 'update'])->name('timekeeper.update')->middleware(['throttle:user-actions', 'dsgvo']);
|
||||
Route::post('/events/{event}/comments', [CommentController::class, 'store'])->name('comments.store')->middleware(['throttle:user-actions', 'dsgvo']);
|
||||
Route::post('/events/{event}/carpool/offer', [CarpoolController::class, 'offer'])->name('carpool.offer')->middleware(['throttle:user-actions', 'dsgvo']);
|
||||
Route::post('/events/{event}/carpool/withdraw', [CarpoolController::class, 'withdraw'])->name('carpool.withdraw')->middleware(['throttle:user-actions', 'dsgvo']);
|
||||
Route::post('/events/{event}/carpool/join', [CarpoolController::class, 'join'])->name('carpool.join')->middleware(['throttle:user-actions', 'dsgvo']);
|
||||
Route::post('/events/{event}/carpool/leave', [CarpoolController::class, 'leave'])->name('carpool.leave')->middleware(['throttle:user-actions', 'dsgvo']);
|
||||
|
||||
Route::get('/files', [FileController::class, 'index'])->name('files.index');
|
||||
Route::get('/files/{file}/download', [FileController::class, 'download'])->name('files.download');
|
||||
@@ -152,6 +157,7 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
|
||||
// --- Fuer alle Admin-Panel-Nutzer (Admin, Coach, ParentRep) ---
|
||||
Route::get('/', [AdminDashboardController::class, 'index'])->name('dashboard');
|
||||
Route::get('statistics', [StatisticsController::class, 'index'])->name('statistics.index');
|
||||
Route::get('statistics/player/{player}', [StatisticsController::class, 'playerDetail'])->name('statistics.player-detail');
|
||||
|
||||
// Events (Leseansicht fuer alle Admin-Panel-Nutzer)
|
||||
Route::get('events', [AdminEventController::class, 'index'])->name('events.index');
|
||||
@@ -168,6 +174,7 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
|
||||
Route::put('events/{event}', [AdminEventController::class, 'update'])->name('events.update');
|
||||
Route::delete('events/{event}', [AdminEventController::class, 'destroy'])->name('events.destroy');
|
||||
Route::patch('events/{event}/participant', [AdminEventController::class, 'updateParticipant'])->name('events.update-participant');
|
||||
Route::post('events/{event}/stats', [AdminEventController::class, 'updateStats'])->name('events.update-stats');
|
||||
Route::put('events/{event}/restore', [AdminEventController::class, 'restore'])->name('events.restore');
|
||||
|
||||
// Aktivitaetslog (Staff-Level + canViewActivityLog-Pruefung im Controller)
|
||||
|
||||
Reference in New Issue
Block a user