Stand: SMTP-Test, Admin-Mail-Tab, Notifiable-Fix, Lazy-Quill
- Fix: Notifiable-Trait zum User-Model hinzugefuegt (behebt notify()-500er) - Installer: SMTP-Verbindungstest mit EsmtpTransport + Ueberspringen-Link - Admin: Neuer E-Mail-Tab mit SMTP-Konfiguration + Verbindungstest - Admin: Lazy Quill-Initialisierung (nur sichtbare Locale wird geladen) - Uebersetzungen: 17 neue Mail-Keys in allen 6 Sprachen Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
69
app/Models/ActivityLog.php
Normal file
69
app/Models/ActivityLog.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ActivityLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'action',
|
||||
'model_type',
|
||||
'model_id',
|
||||
'description',
|
||||
'properties',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'properties' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class)->withTrashed();
|
||||
}
|
||||
|
||||
public static function log(
|
||||
string $action,
|
||||
string $description,
|
||||
?string $modelType = null,
|
||||
?int $modelId = null,
|
||||
?array $properties = null,
|
||||
): self {
|
||||
$log = new static();
|
||||
$log->user_id = auth()->id();
|
||||
$log->action = $action;
|
||||
$log->model_type = $modelType;
|
||||
$log->model_id = $modelId;
|
||||
$log->description = $description;
|
||||
$log->properties = $properties;
|
||||
$log->ip_address = request()->ip();
|
||||
$log->created_at = now();
|
||||
$log->save();
|
||||
return $log;
|
||||
}
|
||||
|
||||
public static function logWithChanges(
|
||||
string $action,
|
||||
string $description,
|
||||
?string $modelType = null,
|
||||
?int $modelId = null,
|
||||
?array $old = null,
|
||||
?array $new = null,
|
||||
): self {
|
||||
$properties = null;
|
||||
if ($old !== null || $new !== null) {
|
||||
$properties = array_filter(['old' => $old, 'new' => $new], fn ($v) => $v !== null);
|
||||
}
|
||||
return static::log($action, $description, $modelType, $modelId, $properties ?: null);
|
||||
}
|
||||
}
|
||||
46
app/Models/Comment.php
Executable file
46
app/Models/Comment.php
Executable file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Comment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'body',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function deletedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'deleted_by')->withTrashed();
|
||||
}
|
||||
|
||||
public function isDeleted(): bool
|
||||
{
|
||||
return $this->deleted_at !== null;
|
||||
}
|
||||
|
||||
public function scopeVisible($query)
|
||||
{
|
||||
return $query->whereNull('deleted_at');
|
||||
}
|
||||
}
|
||||
291
app/Models/Event.php
Executable file
291
app/Models/Event.php
Executable file
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CateringStatus;
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventType;
|
||||
use App\Enums\ParticipantStatus;
|
||||
use App\Enums\UserRole;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Event extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'type',
|
||||
'title',
|
||||
'start_at',
|
||||
'end_at',
|
||||
'status',
|
||||
'location_name',
|
||||
'address_text',
|
||||
'location_lat',
|
||||
'location_lng',
|
||||
'description_html',
|
||||
'min_players',
|
||||
'min_catering',
|
||||
'min_timekeepers',
|
||||
'opponent',
|
||||
'score_home',
|
||||
'score_away',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => EventType::class,
|
||||
'status' => EventStatus::class,
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
'location_lat' => 'float',
|
||||
'location_lng' => 'float',
|
||||
'min_players' => 'integer',
|
||||
'min_catering' => 'integer',
|
||||
'min_timekeepers' => 'integer',
|
||||
'score_home' => 'integer',
|
||||
'score_away' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function hasCoordinates(): bool
|
||||
{
|
||||
return $this->location_lat !== null && $this->location_lng !== null;
|
||||
}
|
||||
|
||||
public function hasScore(): bool
|
||||
{
|
||||
return $this->type->isGameType() && ($this->score_home !== null || $this->score_away !== null);
|
||||
}
|
||||
|
||||
public function scoreDisplay(): ?string
|
||||
{
|
||||
if (!$this->hasScore()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ($this->score_home ?? '?') . ':' . ($this->score_away ?? '?');
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by')->withTrashed();
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by')->withTrashed();
|
||||
}
|
||||
|
||||
public function deletedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'deleted_by')->withTrashed();
|
||||
}
|
||||
|
||||
public function isRestorable(): bool
|
||||
{
|
||||
return $this->trashed() && $this->deleted_at->diffInDays(now()) <= 30;
|
||||
}
|
||||
|
||||
public function participants(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventParticipant::class);
|
||||
}
|
||||
|
||||
public function caterings(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventCatering::class);
|
||||
}
|
||||
|
||||
public function timekeepers(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventTimekeeper::class);
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
public function faqs(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Faq::class, 'event_faq');
|
||||
}
|
||||
|
||||
public function files(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(File::class, 'event_file')->withPivot('created_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all set minimums are met.
|
||||
* Returns: true = all met, false = at least one not met, null = no minimums set.
|
||||
*/
|
||||
public function minimumsStatus(): ?bool
|
||||
{
|
||||
$hasAny = false;
|
||||
$allMet = true;
|
||||
|
||||
if ($this->min_players !== null) {
|
||||
$hasAny = true;
|
||||
if ($this->type === EventType::Meeting) {
|
||||
// Für Besprechungen: User mit Zusage zählen (user_id-basiert)
|
||||
$count = $this->participants
|
||||
->where('status', ParticipantStatus::Yes)
|
||||
->whereNotNull('user_id')
|
||||
->count();
|
||||
} else {
|
||||
$count = $this->participants->where('status', ParticipantStatus::Yes)->count();
|
||||
}
|
||||
if ($count < $this->min_players) {
|
||||
$allMet = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Catering/Zeitnehmer nur für Typen die es unterstützen
|
||||
if ($this->type->hasCatering() && $this->min_catering !== null) {
|
||||
$hasAny = true;
|
||||
$cateringYes = $this->caterings_yes_count
|
||||
?? $this->caterings->where('status', CateringStatus::Yes)->count();
|
||||
if ($cateringYes < $this->min_catering) {
|
||||
$allMet = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->type->hasTimekeepers() && $this->min_timekeepers !== null) {
|
||||
$hasAny = true;
|
||||
$timekeeperYes = $this->timekeepers_yes_count
|
||||
?? $this->timekeepers->where('status', CateringStatus::Yes)->count();
|
||||
if ($timekeeperYes < $this->min_timekeepers) {
|
||||
$allMet = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $hasAny ? $allMet : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add missing active team players as participants (idempotent).
|
||||
* For meetings, delegates to syncMeetingParticipants().
|
||||
*/
|
||||
public function syncParticipants(int $userId): void
|
||||
{
|
||||
if ($this->type === EventType::Meeting) {
|
||||
$this->syncMeetingParticipants($userId);
|
||||
return;
|
||||
}
|
||||
|
||||
$activePlayerIds = $this->team->activePlayers()->pluck('id');
|
||||
$existingPlayerIds = $this->participants()->pluck('player_id');
|
||||
|
||||
$missingPlayerIds = $activePlayerIds->diff($existingPlayerIds);
|
||||
|
||||
if ($missingPlayerIds->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$records = $missingPlayerIds->map(fn ($playerId) => [
|
||||
'event_id' => $this->id,
|
||||
'player_id' => $playerId,
|
||||
'status' => ParticipantStatus::Unknown->value,
|
||||
'set_by_user_id' => $userId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
])->toArray();
|
||||
|
||||
$this->participants()->insert($records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync meeting participants: eligible users for this event's team.
|
||||
* Eligible = parents with children in team + coaches (excluding admin ID 1).
|
||||
*/
|
||||
public function syncMeetingParticipants(int $setByUserId): void
|
||||
{
|
||||
$teamId = $this->team_id;
|
||||
|
||||
// Users with active children in this team
|
||||
$parentUserIds = DB::table('parent_player')
|
||||
->join('players', 'parent_player.player_id', '=', 'players.id')
|
||||
->where('players.team_id', $teamId)
|
||||
->whereNull('players.deleted_at')
|
||||
->where('players.is_active', true)
|
||||
->join('users', 'parent_player.parent_id', '=', 'users.id')
|
||||
->whereNull('users.deleted_at')
|
||||
->where('users.is_active', true)
|
||||
->pluck('parent_player.parent_id')
|
||||
->unique();
|
||||
|
||||
// Coaches — nur die dem Team zugeordneten
|
||||
$coachIds = User::where('role', UserRole::Coach->value)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('coachTeams', fn ($q) => $q->where('teams.id', $teamId))
|
||||
->pluck('id');
|
||||
|
||||
// Admins aus Meeting-Teilnehmern ausschließen (rollenbasiert statt ID-basiert, V12)
|
||||
$adminIds = User::where('role', UserRole::Admin)->pluck('id');
|
||||
$eligibleUserIds = $parentUserIds->merge($coachIds)->unique()->diff($adminIds);
|
||||
|
||||
$existingUserIds = $this->participants()->whereNotNull('user_id')->pluck('user_id');
|
||||
$missingUserIds = $eligibleUserIds->diff($existingUserIds);
|
||||
|
||||
if ($missingUserIds->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$records = $missingUserIds->map(fn ($userId) => [
|
||||
'event_id' => $this->id,
|
||||
'player_id' => null,
|
||||
'user_id' => $userId,
|
||||
'status' => ParticipantStatus::Unknown->value,
|
||||
'set_by_user_id' => $setByUserId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
])->toArray();
|
||||
|
||||
$this->participants()->insert($records);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync participants for all future events of a team.
|
||||
*/
|
||||
public static function syncParticipantsForTeam(int $teamId, int $userId): void
|
||||
{
|
||||
$futureEvents = static::where('team_id', $teamId)
|
||||
->where('start_at', '>=', now())
|
||||
->get();
|
||||
|
||||
foreach ($futureEvents as $event) {
|
||||
$event->syncParticipants($userId);
|
||||
}
|
||||
}
|
||||
|
||||
public function scopePublished($query)
|
||||
{
|
||||
return $query->whereIn('status', [EventStatus::Published, EventStatus::Cancelled]);
|
||||
}
|
||||
|
||||
public function scopeUpcoming($query)
|
||||
{
|
||||
return $query->where('start_at', '>=', now())->orderBy('start_at');
|
||||
}
|
||||
|
||||
public function scopeForTeam($query, int $teamId)
|
||||
{
|
||||
return $query->where('team_id', $teamId);
|
||||
}
|
||||
}
|
||||
35
app/Models/EventCatering.php
Executable file
35
app/Models/EventCatering.php
Executable file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CateringStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EventCatering extends Model
|
||||
{
|
||||
protected $table = 'event_catering';
|
||||
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'status',
|
||||
'note',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => CateringStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class)->withTrashed();
|
||||
}
|
||||
}
|
||||
56
app/Models/EventParticipant.php
Executable file
56
app/Models/EventParticipant.php
Executable file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ParticipantStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EventParticipant extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'player_id',
|
||||
'user_id',
|
||||
'status',
|
||||
'note',
|
||||
'responded_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => ParticipantStatus::class,
|
||||
'responded_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function player(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Player::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function setByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'set_by_user_id')->withTrashed();
|
||||
}
|
||||
|
||||
public function participantName(): string
|
||||
{
|
||||
if ($this->user_id) {
|
||||
return $this->user?->name ?? __('ui.unknown');
|
||||
}
|
||||
|
||||
return $this->player?->full_name ?? __('ui.unknown');
|
||||
}
|
||||
}
|
||||
26
app/Models/EventTimekeeper.php
Executable file
26
app/Models/EventTimekeeper.php
Executable file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CateringStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EventTimekeeper extends Model
|
||||
{
|
||||
protected $fillable = ['event_id', 'status'];
|
||||
|
||||
protected $casts = [
|
||||
'status' => CateringStatus::class,
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class)->withTrashed();
|
||||
}
|
||||
}
|
||||
34
app/Models/Faq.php
Executable file
34
app/Models/Faq.php
Executable file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Faq extends Model
|
||||
{
|
||||
protected $table = 'faq';
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'category',
|
||||
'content_html',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by')->withTrashed();
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by')->withTrashed();
|
||||
}
|
||||
|
||||
public function events(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Event::class, 'event_faq');
|
||||
}
|
||||
}
|
||||
115
app/Models/File.php
Normal file
115
app/Models/File.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class File extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'file_category_id',
|
||||
'original_name',
|
||||
'mime_type',
|
||||
'size',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FileCategory::class, 'file_category_id');
|
||||
}
|
||||
|
||||
public function uploader(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by')->withTrashed();
|
||||
}
|
||||
|
||||
public function events(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Event::class, 'event_file')->withPivot('created_at');
|
||||
}
|
||||
|
||||
public function teams(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Team::class, 'team_file')->withPivot('created_at');
|
||||
}
|
||||
|
||||
public function getStoragePath(): string
|
||||
{
|
||||
return 'files/' . $this->stored_name;
|
||||
}
|
||||
|
||||
public function isImage(): bool
|
||||
{
|
||||
return str_starts_with($this->mime_type, 'image/');
|
||||
}
|
||||
|
||||
public function isPdf(): bool
|
||||
{
|
||||
return $this->mime_type === 'application/pdf';
|
||||
}
|
||||
|
||||
public function isHtml(): bool
|
||||
{
|
||||
return $this->mime_type === 'text/html';
|
||||
}
|
||||
|
||||
public function humanSize(): string
|
||||
{
|
||||
$bytes = $this->size;
|
||||
if ($bytes >= 1048576) {
|
||||
return round($bytes / 1048576, 1) . ' MB';
|
||||
}
|
||||
return round($bytes / 1024) . ' KB';
|
||||
}
|
||||
|
||||
public function iconType(): string
|
||||
{
|
||||
return match (true) {
|
||||
str_contains($this->mime_type, 'pdf') => 'pdf',
|
||||
str_contains($this->mime_type, 'wordprocessingml') => 'word',
|
||||
str_contains($this->mime_type, 'spreadsheetml') => 'excel',
|
||||
$this->isImage() => 'image',
|
||||
$this->isHtml() => 'html',
|
||||
default => 'file',
|
||||
};
|
||||
}
|
||||
|
||||
public function extension(): string
|
||||
{
|
||||
return pathinfo($this->original_name, PATHINFO_EXTENSION);
|
||||
}
|
||||
|
||||
public function previewData(): array
|
||||
{
|
||||
$hasPreview = $this->isImage() || $this->isPdf();
|
||||
|
||||
return [
|
||||
'name' => $this->original_name,
|
||||
'category' => $this->category->name ?? '',
|
||||
'size' => $this->humanSize(),
|
||||
'downloadUrl' => route('files.download', $this),
|
||||
'previewUrl' => $hasPreview ? route('files.preview', $this) : null,
|
||||
'isImage' => $this->isImage(),
|
||||
'isPdf' => $this->isPdf(),
|
||||
'isHtml' => $this->isHtml(),
|
||||
'iconBg' => match ($this->iconType()) {
|
||||
'pdf' => 'bg-red-100 text-red-600',
|
||||
'word' => 'bg-blue-100 text-blue-600',
|
||||
'excel' => 'bg-green-100 text-green-600',
|
||||
'image' => 'bg-purple-100 text-purple-600',
|
||||
'html' => 'bg-orange-100 text-orange-600',
|
||||
default => 'bg-gray-100 text-gray-600',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
49
app/Models/FileCategory.php
Normal file
49
app/Models/FileCategory.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FileCategory extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'sort_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (FileCategory $category) {
|
||||
if (empty($category->slug)) {
|
||||
$category->slug = Str::slug($category->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(File::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('name');
|
||||
}
|
||||
}
|
||||
52
app/Models/Invitation.php
Executable file
52
app/Models/Invitation.php
Executable file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Invitation extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'expires_at',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
'accepted_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by')->withTrashed();
|
||||
}
|
||||
|
||||
public function players(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Player::class, 'invitation_players');
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->accepted_at === null && $this->expires_at->isFuture();
|
||||
}
|
||||
|
||||
public function isAccepted(): bool
|
||||
{
|
||||
return $this->accepted_at !== null;
|
||||
}
|
||||
|
||||
public function scopeValid($query)
|
||||
{
|
||||
return $query->whereNull('accepted_at')->where('expires_at', '>', now());
|
||||
}
|
||||
}
|
||||
10
app/Models/Location.php
Executable file
10
app/Models/Location.php
Executable file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Location extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'address_text', 'location_lat', 'location_lng'];
|
||||
}
|
||||
77
app/Models/Player.php
Executable file
77
app/Models/Player.php
Executable file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Player extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'birth_year',
|
||||
'jersey_number',
|
||||
'is_active',
|
||||
'photo_permission',
|
||||
'notes',
|
||||
'profile_picture',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'photo_permission' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function getFullNameAttribute(): string
|
||||
{
|
||||
return "{$this->first_name} {$this->last_name}";
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function parents(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'parent_player', 'player_id', 'parent_id')
|
||||
->withPivot('relationship_label', 'created_at');
|
||||
}
|
||||
|
||||
public function participations(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventParticipant::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function getAvatarUrl(): ?string
|
||||
{
|
||||
if ($this->profile_picture) {
|
||||
return asset('storage/' . $this->profile_picture);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getInitials(): string
|
||||
{
|
||||
return mb_strtoupper(mb_substr($this->first_name, 0, 1) . mb_substr($this->last_name, 0, 1));
|
||||
}
|
||||
|
||||
public function isRestorable(): bool
|
||||
{
|
||||
return $this->trashed() && $this->deleted_at->diffInDays(now()) < 7;
|
||||
}
|
||||
}
|
||||
47
app/Models/Setting.php
Executable file
47
app/Models/Setting.php
Executable file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $fillable = ['label', 'type', 'value'];
|
||||
|
||||
public static function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return Cache::remember("setting.{$key}", 3600, function () use ($key, $default) {
|
||||
return static::where('key', $key)->value('value') ?? $default;
|
||||
});
|
||||
}
|
||||
|
||||
public static function set(string $key, ?string $value): void
|
||||
{
|
||||
static::where('key', $key)->update(['value' => $value]);
|
||||
Cache::forget("setting.{$key}");
|
||||
}
|
||||
|
||||
public static function clearCache(): void
|
||||
{
|
||||
$keys = static::pluck('key');
|
||||
foreach ($keys as $key) {
|
||||
Cache::forget("setting.{$key}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Feature für den gegebenen User sichtbar ist.
|
||||
* Admin sieht immer alles.
|
||||
*/
|
||||
public static function isFeatureVisibleFor(string $feature, User $user): bool
|
||||
{
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$key = "visibility_{$feature}_{$user->role->value}";
|
||||
|
||||
return static::get($key, '1') === '1';
|
||||
}
|
||||
}
|
||||
67
app/Models/Team.php
Executable file
67
app/Models/Team.php
Executable file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class Team extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'year_group',
|
||||
'is_active',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function players(): HasMany
|
||||
{
|
||||
return $this->hasMany(Player::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
public function activePlayers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Player::class)->where('is_active', true);
|
||||
}
|
||||
|
||||
public function coaches(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'team_user')
|
||||
->withPivot('created_at');
|
||||
}
|
||||
|
||||
public function files(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(File::class, 'team_file')
|
||||
->withPivot('created_at');
|
||||
}
|
||||
|
||||
public function parentReps(): Collection
|
||||
{
|
||||
return User::where('role', UserRole::ParentRep)
|
||||
->where('is_active', true)
|
||||
->whereHas('children', fn ($q) => $q->where('team_id', $this->id))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
202
app/Models/User.php
Executable file
202
app/Models/User.php
Executable file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use App\Notifications\ResetPasswordNotification;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, Notifiable, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'password',
|
||||
'locale',
|
||||
'profile_picture',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'role' => UserRole::class,
|
||||
'is_active' => 'boolean',
|
||||
'last_login_at' => 'datetime',
|
||||
'dsgvo_accepted_at' => 'datetime',
|
||||
'dsgvo_notice_accepted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function sendPasswordResetNotification($token): void
|
||||
{
|
||||
$this->notify(new ResetPasswordNotification($token));
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === UserRole::Admin;
|
||||
}
|
||||
|
||||
public function isCoach(): bool
|
||||
{
|
||||
return $this->role === UserRole::Coach;
|
||||
}
|
||||
|
||||
public function isParentRep(): bool
|
||||
{
|
||||
return $this->role === UserRole::ParentRep;
|
||||
}
|
||||
|
||||
public function isStaff(): bool
|
||||
{
|
||||
return in_array($this->role, [UserRole::Admin, UserRole::Coach]);
|
||||
}
|
||||
|
||||
public function canAccessAdminPanel(): bool
|
||||
{
|
||||
return $this->isStaff() || $this->isParentRep();
|
||||
}
|
||||
|
||||
public function canViewActivityLog(): bool
|
||||
{
|
||||
return $this->id === 1 && $this->isAdmin();
|
||||
}
|
||||
|
||||
public function children(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Player::class, 'parent_player', 'parent_id', 'player_id')
|
||||
->withPivot('relationship_label', 'created_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-IDs, auf die der User Zugriff hat (über seine Kinder).
|
||||
* Direkte DB-Query ohne Model-Hydration.
|
||||
*/
|
||||
public function accessibleTeamIds(): Collection
|
||||
{
|
||||
return $this->children()->distinct()->pluck('players.team_id');
|
||||
}
|
||||
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
public function caterings(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventCatering::class);
|
||||
}
|
||||
|
||||
public function createdInvitations(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invitation::class, 'created_by');
|
||||
}
|
||||
|
||||
public function coachTeams(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Team::class, 'team_user')
|
||||
->withPivot('created_at');
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function getAvatarUrl(): ?string
|
||||
{
|
||||
if ($this->profile_picture) {
|
||||
return asset('storage/' . $this->profile_picture);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getInitials(): string
|
||||
{
|
||||
$parts = explode(' ', trim($this->name));
|
||||
if (count($parts) >= 2) {
|
||||
return mb_strtoupper(mb_substr($parts[0], 0, 1) . mb_substr(end($parts), 0, 1));
|
||||
}
|
||||
return mb_strtoupper(mb_substr($this->name, 0, 2));
|
||||
}
|
||||
|
||||
public function isRestorable(): bool
|
||||
{
|
||||
return $this->trashed() && $this->deleted_at->diffInDays(now()) < 7;
|
||||
}
|
||||
|
||||
public function isDsgvoRestricted(): bool
|
||||
{
|
||||
if ($this->role !== UserRole::User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->isDsgvoConfirmed();
|
||||
}
|
||||
|
||||
public function needsDsgvoBanner(): bool
|
||||
{
|
||||
if ($this->role !== UserRole::User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->isDsgvoConfirmed();
|
||||
}
|
||||
|
||||
public function dsgvoBannerState(): ?string
|
||||
{
|
||||
if ($this->role !== UserRole::User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->dsgvo_consent_file === null) {
|
||||
return 'upload_required';
|
||||
}
|
||||
|
||||
if ($this->dsgvo_accepted_at === null) {
|
||||
return 'pending_confirmation';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function hasDsgvoConsent(): bool
|
||||
{
|
||||
return $this->dsgvo_consent_file !== null;
|
||||
}
|
||||
|
||||
public function isDsgvoConfirmed(): bool
|
||||
{
|
||||
return $this->dsgvo_accepted_at !== null;
|
||||
}
|
||||
|
||||
public function dsgvoAcceptedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'dsgvo_accepted_by')->withTrashed();
|
||||
}
|
||||
|
||||
public function getOrphanedChildren(): Collection
|
||||
{
|
||||
return $this->children()
|
||||
->withCount('parents')
|
||||
->get()
|
||||
->filter(fn (Player $child) => $child->parents_count <= 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user