Finanzverwaltung und Saison-System

Neues Einnahmen-/Ausgaben-Modul mit Kategorie-Filter, Monats-Charts und
Saison-basierter Filterung. Saison-Verwaltung im Admin-Bereich mit
Möglichkeit zum Wechsel der aktuellen Saison.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rhino
2026-03-02 23:48:20 +01:00
parent 480e2284ba
commit 4eaf2368af
18 changed files with 1270 additions and 1 deletions

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Enums;
enum FinanceCategory: string
{
case Catering = 'catering';
case Sponsoring = 'sponsoring';
case Membership = 'membership';
case TournamentFees = 'tournament_fees';
case Equipment = 'equipment';
case Transport = 'transport';
case VenueRental = 'venue_rental';
case TrainingMaterial = 'training_material';
case Events = 'events';
case Other = 'other';
public function label(): string
{
return __("ui.enums.finance_category.{$this->value}");
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

19
app/Enums/FinanceType.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum FinanceType: string
{
case Income = 'income';
case Expense = 'expense';
public function label(): string
{
return __("ui.enums.finance_type.{$this->value}");
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,272 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\FinanceCategory;
use App\Enums\FinanceType;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Finance;
use App\Models\Season;
use App\Models\Setting;
use App\Models\Team;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class FinanceController extends Controller
{
public function index(Request $request): View
{
if (!Setting::isFeatureVisibleFor('finances', auth()->user())) {
abort(403);
}
$request->validate([
'season_id' => ['nullable', 'integer', 'exists:seasons,id'],
'year' => ['nullable', 'integer', 'min:2020', 'max:2099'],
'team_id' => ['nullable', 'integer', 'exists:teams,id'],
'type' => ['nullable', 'string', Rule::in(FinanceType::values())],
'category' => ['nullable', 'string', Rule::in(FinanceCategory::values())],
]);
$query = Finance::with(['team', 'creator']);
// Saison-Filter (hat Vorrang vor Jahr)
$activeSeason = null;
if ($request->filled('season_id')) {
$activeSeason = Season::find($request->season_id);
if ($activeSeason) {
$query->whereBetween('date', [$activeSeason->start_date, $activeSeason->end_date]);
}
} elseif ($request->filled('year')) {
$query->whereYear('date', $request->year);
} else {
// Default: aktuelle Saison falls vorhanden
$activeSeason = Season::current();
if ($activeSeason) {
$query->whereBetween('date', [$activeSeason->start_date, $activeSeason->end_date]);
}
}
if ($request->filled('team_id')) {
$query->where('team_id', $request->team_id);
}
if ($request->filled('type')) {
$query->where('type', $request->type);
}
if ($request->filled('category')) {
$query->where('category', $request->category);
}
// Summen berechnen (gleiche Filter wie Hauptquery)
$statsQuery = clone $query;
$totalIncome = (clone $statsQuery)->where('type', FinanceType::Income)->sum('amount');
$totalExpense = (clone $statsQuery)->where('type', FinanceType::Expense)->sum('amount');
$balance = $totalIncome - $totalExpense;
// Monatliche Aggregation fuer Chart
$monthlyRaw = (clone $statsQuery)
->select(
DB::raw("strftime('%Y', date) as y"),
DB::raw("strftime('%m', date) as m"),
'type',
DB::raw('SUM(amount) as total')
)
->groupBy('y', 'm', 'type')
->orderBy('y')
->orderBy('m')
->get();
$chartMonthly = $this->buildMonthlyChart($monthlyRaw);
// Kategorie-Aufschluesselung fuer Chart
$categoryRaw = (clone $statsQuery)
->select('type', 'category', DB::raw('SUM(amount) as total'))
->groupBy('type', 'category')
->get();
$chartCategories = $this->buildCategoryChart($categoryRaw);
// Paginierte Liste
$finances = $query->orderByDesc('date')->orderByDesc('created_at')->paginate(25)->withQueryString();
$teams = Team::orderBy('name')->get();
$seasons = Season::orderByDesc('start_date')->get();
// Verfuegbare Jahre
$years = Finance::selectRaw("strftime('%Y', date) as y")
->distinct()
->orderByDesc('y')
->pluck('y')
->toArray();
return view('admin.finances.index', compact(
'finances', 'totalIncome', 'totalExpense', 'balance',
'chartMonthly', 'chartCategories',
'teams', 'seasons', 'years', 'activeSeason'
));
}
public function create(): View
{
if (!Setting::isFeatureVisibleFor('finances', auth()->user())) {
abort(403);
}
$teams = Team::orderBy('name')->get();
return view('admin.finances.create', compact('teams'));
}
public function store(Request $request): RedirectResponse
{
if (!Setting::isFeatureVisibleFor('finances', auth()->user())) {
abort(403);
}
$validated = $request->validate([
'team_id' => ['nullable', 'integer', 'exists:teams,id'],
'type' => ['required', 'string', Rule::in(FinanceType::values())],
'category' => ['required', 'string', Rule::in(FinanceCategory::values())],
'title' => ['required', 'string', 'max:150'],
'amount' => ['required', 'numeric', 'min:0.01', 'max:999999.99'],
'date' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:1000'],
]);
$validated['amount'] = (int) round($validated['amount'] * 100);
$finance = new Finance($validated);
$finance->created_by = auth()->id();
$finance->save();
ActivityLog::logAction('finance_created', $finance, "{$validated['type']}: {$validated['title']}");
return redirect()->route('admin.finances.index')
->with('success', __('admin.finance_created'));
}
public function edit(Finance $finance): View
{
if (!Setting::isFeatureVisibleFor('finances', auth()->user())) {
abort(403);
}
$teams = Team::orderBy('name')->get();
return view('admin.finances.edit', compact('finance', 'teams'));
}
public function update(Request $request, Finance $finance): RedirectResponse
{
if (!Setting::isFeatureVisibleFor('finances', auth()->user())) {
abort(403);
}
$validated = $request->validate([
'team_id' => ['nullable', 'integer', 'exists:teams,id'],
'type' => ['required', 'string', Rule::in(FinanceType::values())],
'category' => ['required', 'string', Rule::in(FinanceCategory::values())],
'title' => ['required', 'string', 'max:150'],
'amount' => ['required', 'numeric', 'min:0.01', 'max:999999.99'],
'date' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:1000'],
]);
$validated['amount'] = (int) round($validated['amount'] * 100);
$before = $finance->only(['type', 'category', 'title', 'amount', 'date']);
$finance->update($validated);
ActivityLog::logAction('finance_updated', $finance, "{$validated['type']}: {$validated['title']}", [
'before' => $before,
'after' => $finance->only(['type', 'category', 'title', 'amount', 'date']),
]);
return redirect()->route('admin.finances.index')
->with('success', __('admin.finance_updated'));
}
public function destroy(Finance $finance): RedirectResponse
{
if (!Setting::isFeatureVisibleFor('finances', auth()->user())) {
abort(403);
}
ActivityLog::logAction('finance_deleted', $finance, "{$finance->type->value}: {$finance->title}");
$finance->delete();
return redirect()->route('admin.finances.index')
->with('success', __('admin.finance_deleted'));
}
private function buildMonthlyChart($monthlyRaw): array
{
$months = [];
foreach ($monthlyRaw as $row) {
$key = $row->y . '-' . $row->m;
if (!isset($months[$key])) {
$months[$key] = ['income' => 0, 'expense' => 0];
}
$type = $row->type instanceof FinanceType ? $row->type->value : $row->type;
$months[$key][$type] = (int) $row->total;
}
$labels = [];
$income = [];
$expense = [];
foreach ($months as $key => $data) {
[$y, $m] = explode('-', $key);
$labels[] = str_pad($m, 2, '0', STR_PAD_LEFT) . '/' . $y;
$income[] = round($data['income'] / 100, 2);
$expense[] = round($data['expense'] / 100, 2);
}
return [
'labels' => $labels,
'income' => $income,
'expense' => $expense,
];
}
private function buildCategoryChart($categoryRaw): array
{
$incomeCategories = [];
$expenseCategories = [];
foreach ($categoryRaw as $row) {
$cat = $row->category instanceof FinanceCategory ? $row->category : FinanceCategory::tryFrom($row->category);
$label = $cat ? $cat->label() : $row->category;
$amount = round((int) $row->total / 100, 2);
$type = $row->type instanceof FinanceType ? $row->type->value : $row->type;
if ($type === 'income') {
$incomeCategories[$label] = $amount;
} else {
$expenseCategories[$label] = $amount;
}
}
$colors = ['#305f3f', '#3e7750', '#579469', '#7fb38e', '#afd0b8', '#d6e7da', '#806130', '#99783a', '#c2a86e', '#dbc9a2'];
$redColors = ['#76403b', '#8f504b', '#a86b67', '#c3918e', '#dbbcba', '#eddddc', '#57486c', '#6b5a84', '#84729e', '#a596ba'];
return [
'income' => [
'labels' => array_keys($incomeCategories),
'data' => array_values($incomeCategories),
'colors' => array_slice($colors, 0, count($incomeCategories)),
],
'expense' => [
'labels' => array_keys($expenseCategories),
'data' => array_values($expenseCategories),
'colors' => array_slice($redColors, 0, count($expenseCategories)),
],
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Finance;
use App\Models\Season;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class SeasonController extends Controller
{
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:50'],
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after:start_date'],
'is_current' => ['nullable', 'boolean'],
]);
$validated['is_current'] = !empty($validated['is_current']);
if ($validated['is_current']) {
Season::where('is_current', true)->update(['is_current' => false]);
}
Season::create($validated);
return redirect()->route('admin.settings.edit', ['tab' => 'seasons'])
->with('success', __('admin.season_created'));
}
public function update(Request $request, Season $season): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:50'],
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after:start_date'],
'is_current' => ['nullable', 'boolean'],
]);
$validated['is_current'] = !empty($validated['is_current']);
if ($validated['is_current']) {
Season::where('is_current', true)->where('id', '!=', $season->id)->update(['is_current' => false]);
}
$season->update($validated);
return redirect()->route('admin.settings.edit', ['tab' => 'seasons'])
->with('success', __('admin.season_updated'));
}
public function destroy(Season $season): RedirectResponse
{
$hasData = Finance::whereBetween('date', [$season->start_date, $season->end_date])->exists();
if ($hasData) {
return redirect()->route('admin.settings.edit', ['tab' => 'seasons'])
->with('error', __('admin.season_has_data'));
}
$season->delete();
return redirect()->route('admin.settings.edit', ['tab' => 'seasons'])
->with('success', __('admin.season_deleted'));
}
}

38
app/Models/Finance.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use App\Enums\FinanceCategory;
use App\Enums\FinanceType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Finance extends Model
{
protected $fillable = ['team_id', 'type', 'category', 'title', 'amount', 'date', 'notes'];
protected function casts(): array
{
return [
'type' => FinanceType::class,
'category' => FinanceCategory::class,
'date' => 'date',
'amount' => 'integer',
];
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function getFormattedAmountAttribute(): string
{
return number_format($this->amount / 100, 2, ',', '.') . ' €';
}
}

34
app/Models/Season.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Season extends Model
{
protected $fillable = ['name', 'start_date', 'end_date', 'is_current'];
protected function casts(): array
{
return [
'start_date' => 'date',
'end_date' => 'date',
'is_current' => 'boolean',
];
}
public function scopeCurrent($query)
{
return $query->where('is_current', true);
}
public static function current(): ?self
{
return static::where('is_current', true)->first();
}
public static function options(): array
{
return static::orderByDesc('start_date')->pluck('name', 'id')->toArray();
}
}

View File

@@ -0,0 +1,25 @@
<?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('seasons', function (Blueprint $table) {
$table->id();
$table->string('name', 50);
$table->date('start_date');
$table->date('end_date');
$table->boolean('is_current')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('seasons');
}
};

View File

@@ -0,0 +1,32 @@
<?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('finances', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->nullable()->constrained()->nullOnDelete();
$table->string('type', 10);
$table->string('category', 30);
$table->string('title', 150);
$table->integer('amount');
$table->date('date');
$table->text('notes')->nullable();
$table->foreignId('created_by')->constrained('users');
$table->timestamps();
$table->index(['team_id', 'date']);
$table->index(['type', 'date']);
});
}
public function down(): void
{
Schema::dropIfExists('finances');
}
};

View File

@@ -566,6 +566,9 @@ return [
'stats_total_shots' => 'إجمالي التسديدات', 'stats_total_shots' => 'إجمالي التسديدات',
'stats_gk_appearances' => 'مباريات كحارس', 'stats_gk_appearances' => 'مباريات كحارس',
'stats_total_saves' => 'إجمالي التصديات', 'stats_total_saves' => 'إجمالي التصديات',
'stats_penalties' => 'رميات جزاء',
'stats_cards' => 'عقوبات',
'stats_avg_time' => '⌀ وقت اللعب',
'stats_close' => 'إغلاق', 'stats_close' => 'إغلاق',
'player_goals' => 'أهداف', 'player_goals' => 'أهداف',
@@ -576,4 +579,52 @@ return [
'performance_good' => 'جيد', 'performance_good' => 'جيد',
'performance_average' => 'متوسط', 'performance_average' => 'متوسط',
'performance_below' => 'أقل من المتوسط', 'performance_below' => 'أقل من المتوسط',
// المالية
'nav_finances' => 'المالية',
'finances_title' => 'المالية',
'new_finance' => 'إدخال جديد',
'finance_edit' => 'تعديل الإدخال',
'finance_created' => 'تم إنشاء الإدخال بنجاح.',
'finance_updated' => 'تم تحديث الإدخال بنجاح.',
'finance_deleted' => 'تم حذف الإدخال بنجاح.',
'finance_income' => 'إيراد',
'finance_expense' => 'مصروف',
'finance_type' => 'النوع',
'finance_category' => 'الفئة',
'finance_title' => 'العنوان',
'finance_amount' => 'المبلغ',
'finance_date' => 'التاريخ',
'finance_notes' => 'ملاحظات',
'finance_no_team' => 'بدون فريق (عام)',
'finance_total_income' => 'إجمالي الإيرادات',
'finance_total_expense' => 'إجمالي المصروفات',
'finance_balance' => 'الرصيد',
'finance_no_entries' => 'لا توجد إدخالات بعد.',
'finance_chart_monthly' => 'نظرة شهرية',
'finance_chart_categories' => 'التوزيع حسب الفئة',
'finance_confirm_delete' => 'هل تريد حذف هذا الإدخال حقاً؟',
'finance_all_types' => 'جميع الأنواع',
'finance_all_categories' => 'جميع الفئات',
// المواسم
'season' => 'الموسم',
'seasons_title' => 'المواسم',
'season_name' => 'الاسم',
'season_start' => 'تاريخ البداية',
'season_end' => 'تاريخ النهاية',
'season_current' => 'الموسم الحالي',
'season_created' => 'تم إنشاء الموسم.',
'season_updated' => 'تم تحديث الموسم.',
'season_deleted' => 'تم حذف الموسم.',
'season_has_data' => 'لا يمكن حذف الموسم (توجد بيانات مالية).',
'season_confirm_delete' => 'هل تريد حذف هذا الموسم حقاً؟',
'all_seasons' => 'جميع المواسم',
'no_seasons_yet' => 'لا توجد مواسم بعد.',
'new_season' => 'إنشاء موسم جديد',
'settings_tab_seasons' => 'المواسم',
'filter_year' => 'السنة',
// الرؤية
'visibility_feature_finances' => 'المالية',
]; ];

View File

@@ -602,6 +602,9 @@ return [
'stats_total_shots' => 'Gesamtwürfe', 'stats_total_shots' => 'Gesamtwürfe',
'stats_gk_appearances' => 'TW-Einsätze', 'stats_gk_appearances' => 'TW-Einsätze',
'stats_total_saves' => 'Gesamtparaden', 'stats_total_saves' => 'Gesamtparaden',
'stats_penalties' => '7-Meter',
'stats_cards' => 'Strafen',
'stats_avg_time' => '⌀ Spielzeit',
'stats_close' => 'Schließen', 'stats_close' => 'Schließen',
'player_goals' => 'Tore', 'player_goals' => 'Tore',
@@ -612,4 +615,52 @@ return [
'performance_good' => 'Gut', 'performance_good' => 'Gut',
'performance_average' => 'Mittel', 'performance_average' => 'Mittel',
'performance_below' => 'Unterdurchschnittlich', 'performance_below' => 'Unterdurchschnittlich',
// Finanzen
'nav_finances' => 'Finanzen',
'finances_title' => 'Finanzen',
'new_finance' => 'Neuer Eintrag',
'finance_edit' => 'Eintrag bearbeiten',
'finance_created' => 'Eintrag erfolgreich erstellt.',
'finance_updated' => 'Eintrag erfolgreich aktualisiert.',
'finance_deleted' => 'Eintrag erfolgreich gelöscht.',
'finance_income' => 'Einnahme',
'finance_expense' => 'Ausgabe',
'finance_type' => 'Typ',
'finance_category' => 'Kategorie',
'finance_title' => 'Bezeichnung',
'finance_amount' => 'Betrag',
'finance_date' => 'Datum',
'finance_notes' => 'Notizen',
'finance_no_team' => 'Kein Team (vereinsübergreifend)',
'finance_total_income' => 'Einnahmen gesamt',
'finance_total_expense' => 'Ausgaben gesamt',
'finance_balance' => 'Bilanz',
'finance_no_entries' => 'Noch keine Einträge vorhanden.',
'finance_chart_monthly' => 'Monatliche Übersicht',
'finance_chart_categories' => 'Aufschlüsselung nach Kategorie',
'finance_confirm_delete' => 'Diesen Eintrag wirklich löschen?',
'finance_all_types' => 'Alle Typen',
'finance_all_categories' => 'Alle Kategorien',
// Saisons
'season' => 'Saison',
'seasons_title' => 'Saisons',
'season_name' => 'Name',
'season_start' => 'Startdatum',
'season_end' => 'Enddatum',
'season_current' => 'Aktuelle Saison',
'season_created' => 'Saison erstellt.',
'season_updated' => 'Saison aktualisiert.',
'season_deleted' => 'Saison gelöscht.',
'season_has_data' => 'Saison kann nicht gelöscht werden (Finanzdaten vorhanden).',
'season_confirm_delete' => 'Diese Saison wirklich löschen?',
'all_seasons' => 'Alle Saisons',
'no_seasons_yet' => 'Noch keine Saisons angelegt.',
'new_season' => 'Neue Saison anlegen',
'settings_tab_seasons' => 'Saisons',
'filter_year' => 'Jahr',
// Sichtbarkeit
'visibility_feature_finances' => 'Finanzen',
]; ];

View File

@@ -565,6 +565,9 @@ return [
'stats_total_shots' => 'Total shots', 'stats_total_shots' => 'Total shots',
'stats_gk_appearances' => 'GK appearances', 'stats_gk_appearances' => 'GK appearances',
'stats_total_saves' => 'Total saves', 'stats_total_saves' => 'Total saves',
'stats_penalties' => 'Penalties',
'stats_cards' => 'Cards',
'stats_avg_time' => 'Avg. time',
'stats_close' => 'Close', 'stats_close' => 'Close',
'player_goals' => 'Goals', 'player_goals' => 'Goals',
@@ -575,4 +578,52 @@ return [
'performance_good' => 'Good', 'performance_good' => 'Good',
'performance_average' => 'Average', 'performance_average' => 'Average',
'performance_below' => 'Below average', 'performance_below' => 'Below average',
// Finances
'nav_finances' => 'Finances',
'finances_title' => 'Finances',
'new_finance' => 'New Entry',
'finance_edit' => 'Edit Entry',
'finance_created' => 'Entry created successfully.',
'finance_updated' => 'Entry updated successfully.',
'finance_deleted' => 'Entry deleted successfully.',
'finance_income' => 'Income',
'finance_expense' => 'Expense',
'finance_type' => 'Type',
'finance_category' => 'Category',
'finance_title' => 'Title',
'finance_amount' => 'Amount',
'finance_date' => 'Date',
'finance_notes' => 'Notes',
'finance_no_team' => 'No team (club-wide)',
'finance_total_income' => 'Total Income',
'finance_total_expense' => 'Total Expenses',
'finance_balance' => 'Balance',
'finance_no_entries' => 'No entries yet.',
'finance_chart_monthly' => 'Monthly Overview',
'finance_chart_categories' => 'Breakdown by Category',
'finance_confirm_delete' => 'Really delete this entry?',
'finance_all_types' => 'All Types',
'finance_all_categories' => 'All Categories',
// Seasons
'season' => 'Season',
'seasons_title' => 'Seasons',
'season_name' => 'Name',
'season_start' => 'Start Date',
'season_end' => 'End Date',
'season_current' => 'Current Season',
'season_created' => 'Season created.',
'season_updated' => 'Season updated.',
'season_deleted' => 'Season deleted.',
'season_has_data' => 'Season cannot be deleted (financial data exists).',
'season_confirm_delete' => 'Really delete this season?',
'all_seasons' => 'All Seasons',
'no_seasons_yet' => 'No seasons created yet.',
'new_season' => 'Create New Season',
'settings_tab_seasons' => 'Seasons',
'filter_year' => 'Year',
// Visibility
'visibility_feature_finances' => 'Finances',
]; ];

View File

@@ -566,6 +566,9 @@ return [
'stats_total_shots' => 'Łączne rzuty', 'stats_total_shots' => 'Łączne rzuty',
'stats_gk_appearances' => 'Występy jako bramkarz', 'stats_gk_appearances' => 'Występy jako bramkarz',
'stats_total_saves' => 'Łączne obrony', 'stats_total_saves' => 'Łączne obrony',
'stats_penalties' => 'Rzuty karne',
'stats_cards' => 'Kary',
'stats_avg_time' => '⌀ Czas gry',
'stats_close' => 'Zamknij', 'stats_close' => 'Zamknij',
'player_goals' => 'Bramki', 'player_goals' => 'Bramki',
@@ -576,4 +579,52 @@ return [
'performance_good' => 'Dobrze', 'performance_good' => 'Dobrze',
'performance_average' => 'Średnio', 'performance_average' => 'Średnio',
'performance_below' => 'Poniżej średniej', 'performance_below' => 'Poniżej średniej',
// Finanse
'nav_finances' => 'Finanse',
'finances_title' => 'Finanse',
'new_finance' => 'Nowy wpis',
'finance_edit' => 'Edytuj wpis',
'finance_created' => 'Wpis został utworzony.',
'finance_updated' => 'Wpis został zaktualizowany.',
'finance_deleted' => 'Wpis został usunięty.',
'finance_income' => 'Przychód',
'finance_expense' => 'Wydatek',
'finance_type' => 'Typ',
'finance_category' => 'Kategoria',
'finance_title' => 'Nazwa',
'finance_amount' => 'Kwota',
'finance_date' => 'Data',
'finance_notes' => 'Notatki',
'finance_no_team' => 'Bez zespołu (ogólne)',
'finance_total_income' => 'Suma przychodów',
'finance_total_expense' => 'Suma wydatków',
'finance_balance' => 'Bilans',
'finance_no_entries' => 'Brak wpisów.',
'finance_chart_monthly' => 'Przegląd miesięczny',
'finance_chart_categories' => 'Podział na kategorie',
'finance_confirm_delete' => 'Czy na pewno usunąć ten wpis?',
'finance_all_types' => 'Wszystkie typy',
'finance_all_categories' => 'Wszystkie kategorie',
// Sezony
'season' => 'Sezon',
'seasons_title' => 'Sezony',
'season_name' => 'Nazwa',
'season_start' => 'Data rozpoczęcia',
'season_end' => 'Data zakończenia',
'season_current' => 'Bieżący sezon',
'season_created' => 'Sezon utworzony.',
'season_updated' => 'Sezon zaktualizowany.',
'season_deleted' => 'Sezon usunięty.',
'season_has_data' => 'Nie można usunąć sezonu (istnieją dane finansowe).',
'season_confirm_delete' => 'Czy na pewno usunąć ten sezon?',
'all_seasons' => 'Wszystkie sezony',
'no_seasons_yet' => 'Brak sezonów.',
'new_season' => 'Utwórz nowy sezon',
'settings_tab_seasons' => 'Sezony',
'filter_year' => 'Rok',
// Widoczność
'visibility_feature_finances' => 'Finanse',
]; ];

View File

@@ -584,6 +584,9 @@ return [
'stats_total_shots' => 'Всего бросков', 'stats_total_shots' => 'Всего бросков',
'stats_gk_appearances' => 'Игры вратарём', 'stats_gk_appearances' => 'Игры вратарём',
'stats_total_saves' => 'Всего отражений', 'stats_total_saves' => 'Всего отражений',
'stats_penalties' => 'Пенальти',
'stats_cards' => 'Наказания',
'stats_avg_time' => '⌀ Время',
'stats_close' => 'Закрыть', 'stats_close' => 'Закрыть',
'player_goals' => 'Голы', 'player_goals' => 'Голы',
@@ -594,4 +597,52 @@ return [
'performance_good' => 'Хорошо', 'performance_good' => 'Хорошо',
'performance_average' => 'Средне', 'performance_average' => 'Средне',
'performance_below' => 'Ниже среднего', 'performance_below' => 'Ниже среднего',
// Финансы
'nav_finances' => 'Финансы',
'finances_title' => 'Финансы',
'new_finance' => 'Новая запись',
'finance_edit' => 'Редактировать запись',
'finance_created' => 'Запись успешно создана.',
'finance_updated' => 'Запись успешно обновлена.',
'finance_deleted' => 'Запись успешно удалена.',
'finance_income' => 'Доход',
'finance_expense' => 'Расход',
'finance_type' => 'Тип',
'finance_category' => 'Категория',
'finance_title' => 'Название',
'finance_amount' => 'Сумма',
'finance_date' => 'Дата',
'finance_notes' => 'Заметки',
'finance_no_team' => 'Без команды (общее)',
'finance_total_income' => 'Всего доходов',
'finance_total_expense' => 'Всего расходов',
'finance_balance' => 'Баланс',
'finance_no_entries' => 'Записей пока нет.',
'finance_chart_monthly' => 'Месячный обзор',
'finance_chart_categories' => 'По категориям',
'finance_confirm_delete' => 'Действительно удалить эту запись?',
'finance_all_types' => 'Все типы',
'finance_all_categories' => 'Все категории',
// Сезоны
'season' => 'Сезон',
'seasons_title' => 'Сезоны',
'season_name' => 'Название',
'season_start' => 'Дата начала',
'season_end' => 'Дата окончания',
'season_current' => 'Текущий сезон',
'season_created' => 'Сезон создан.',
'season_updated' => 'Сезон обновлён.',
'season_deleted' => 'Сезон удалён.',
'season_has_data' => 'Сезон нельзя удалить (есть финансовые данные).',
'season_confirm_delete' => 'Действительно удалить этот сезон?',
'all_seasons' => 'Все сезоны',
'no_seasons_yet' => 'Сезоны ещё не созданы.',
'new_season' => 'Создать новый сезон',
'settings_tab_seasons' => 'Сезоны',
'filter_year' => 'Год',
// Видимость
'visibility_feature_finances' => 'Финансы',
]; ];

View File

@@ -584,6 +584,9 @@ return [
'stats_total_shots' => 'Toplam atışlar', 'stats_total_shots' => 'Toplam atışlar',
'stats_gk_appearances' => 'Kaleci maçları', 'stats_gk_appearances' => 'Kaleci maçları',
'stats_total_saves' => 'Toplam kurtarışlar', 'stats_total_saves' => 'Toplam kurtarışlar',
'stats_penalties' => 'Penaltılar',
'stats_cards' => 'Cezalar',
'stats_avg_time' => '⌀ Süre',
'stats_close' => 'Kapat', 'stats_close' => 'Kapat',
'player_goals' => 'Goller', 'player_goals' => 'Goller',
@@ -594,4 +597,52 @@ return [
'performance_good' => 'İyi', 'performance_good' => 'İyi',
'performance_average' => 'Orta', 'performance_average' => 'Orta',
'performance_below' => 'Ortalamanın altı', 'performance_below' => 'Ortalamanın altı',
// Finans
'nav_finances' => 'Finans',
'finances_title' => 'Finans',
'new_finance' => 'Yeni Kayıt',
'finance_edit' => 'Kaydı Düzenle',
'finance_created' => 'Kayıt başarıyla oluşturuldu.',
'finance_updated' => 'Kayıt başarıyla güncellendi.',
'finance_deleted' => 'Kayıt başarıyla silindi.',
'finance_income' => 'Gelir',
'finance_expense' => 'Gider',
'finance_type' => 'Tür',
'finance_category' => 'Kategori',
'finance_title' => 'Başlık',
'finance_amount' => 'Tutar',
'finance_date' => 'Tarih',
'finance_notes' => 'Notlar',
'finance_no_team' => 'Takım yok (genel)',
'finance_total_income' => 'Toplam Gelir',
'finance_total_expense' => 'Toplam Gider',
'finance_balance' => 'Bilanço',
'finance_no_entries' => 'Henüz kayıt yok.',
'finance_chart_monthly' => 'Aylık Genel Bakış',
'finance_chart_categories' => 'Kategoriye Göre Dağılım',
'finance_confirm_delete' => 'Bu kaydı gerçekten silmek istiyor musunuz?',
'finance_all_types' => 'Tüm Türler',
'finance_all_categories' => 'Tüm Kategoriler',
// Sezonlar
'season' => 'Sezon',
'seasons_title' => 'Sezonlar',
'season_name' => 'Ad',
'season_start' => 'Başlangıç Tarihi',
'season_end' => 'Bitiş Tarihi',
'season_current' => 'Mevcut Sezon',
'season_created' => 'Sezon oluşturuldu.',
'season_updated' => 'Sezon güncellendi.',
'season_deleted' => 'Sezon silindi.',
'season_has_data' => 'Sezon silinemez (finansal veriler mevcut).',
'season_confirm_delete' => 'Bu sezonu gerçekten silmek istiyor musunuz?',
'all_seasons' => 'Tüm Sezonlar',
'no_seasons_yet' => 'Henüz sezon oluşturulmamış.',
'new_season' => 'Yeni Sezon Oluştur',
'settings_tab_seasons' => 'Sezonlar',
'filter_year' => 'Yıl',
// Görünürlük
'visibility_feature_finances' => 'Finans',
]; ];

View File

@@ -0,0 +1,94 @@
<x-layouts.admin :title="__('admin.new_finance')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.new_finance') }}</h1>
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
<form method="POST" action="{{ route('admin.finances.store') }}">
@csrf
{{-- Typ --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.finance_type') }} *</label>
<div class="flex gap-4">
<label class="flex items-center gap-2">
<input type="radio" name="type" value="income" {{ old('type', 'income') === 'income' ? 'checked' : '' }} class="text-green-600 focus:ring-green-500">
<span class="text-sm text-gray-700">{{ __('admin.finance_income') }}</span>
</label>
<label class="flex items-center gap-2">
<input type="radio" name="type" value="expense" {{ old('type') === 'expense' ? 'checked' : '' }} class="text-red-600 focus:ring-red-500">
<span class="text-sm text-gray-700">{{ __('admin.finance_expense') }}</span>
</label>
</div>
@error('type')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Kategorie --}}
<div class="mb-4">
<label for="category" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.finance_category') }} *</label>
<select name="category" id="category" required class="w-full px-3 py-2 border border-gray-300 rounded-md @error('category') border-red-500 @enderror">
<option value="">{{ __('admin.please_select') }}</option>
@foreach (\App\Enums\FinanceCategory::cases() as $cat)
<option value="{{ $cat->value }}" {{ old('category') === $cat->value ? 'selected' : '' }}>{{ $cat->label() }}</option>
@endforeach
</select>
@error('category')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Titel --}}
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.finance_title') }} *</label>
<input type="text" name="title" id="title" value="{{ old('title') }}" required maxlength="150"
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('title') border-red-500 @enderror">
@error('title')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Betrag + Datum --}}
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="amount" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.finance_amount') }} *</label>
<div class="relative">
<input type="number" name="amount" id="amount" value="{{ old('amount') }}" required step="0.01" min="0.01" max="999999.99"
class="w-full px-3 py-2 border border-gray-300 rounded-md pr-8 @error('amount') border-red-500 @enderror">
<span class="absolute right-3 top-2.5 text-sm text-gray-400"></span>
</div>
@error('amount')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="date" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.finance_date') }} *</label>
<input type="date" name="date" id="date" value="{{ old('date', now()->format('Y-m-d')) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('date') border-red-500 @enderror">
@error('date')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
</div>
{{-- Team --}}
<div class="mb-4">
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.team') }}</label>
<select name="team_id" id="team_id" class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">{{ __('admin.finance_no_team') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ old('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
@error('team_id')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Notizen --}}
<div class="mb-4">
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.finance_notes') }}</label>
<textarea name="notes" id="notes" rows="2" maxlength="1000"
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('notes') border-red-500 @enderror">{{ old('notes') }}</textarea>
@error('notes')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Buttons --}}
<div class="flex items-center gap-3">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('ui.create') }}
</button>
<a href="{{ route('admin.finances.index') }}" class="text-sm text-gray-600 hover:underline">
{{ __('ui.cancel') }}
</a>
</div>
</form>
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,95 @@
<x-layouts.admin :title="__('admin.finance_edit')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.finance_edit') }}</h1>
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
<form method="POST" action="{{ route('admin.finances.update', $finance) }}">
@csrf
@method('PUT')
{{-- Typ --}}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ __('admin.finance_type') }} *</label>
<div class="flex gap-4">
<label class="flex items-center gap-2">
<input type="radio" name="type" value="income" {{ old('type', $finance->type->value) === 'income' ? 'checked' : '' }} class="text-green-600 focus:ring-green-500">
<span class="text-sm text-gray-700">{{ __('admin.finance_income') }}</span>
</label>
<label class="flex items-center gap-2">
<input type="radio" name="type" value="expense" {{ old('type', $finance->type->value) === 'expense' ? 'checked' : '' }} class="text-red-600 focus:ring-red-500">
<span class="text-sm text-gray-700">{{ __('admin.finance_expense') }}</span>
</label>
</div>
@error('type')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Kategorie --}}
<div class="mb-4">
<label for="category" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.finance_category') }} *</label>
<select name="category" id="category" required class="w-full px-3 py-2 border border-gray-300 rounded-md @error('category') border-red-500 @enderror">
<option value="">{{ __('admin.please_select') }}</option>
@foreach (\App\Enums\FinanceCategory::cases() as $cat)
<option value="{{ $cat->value }}" {{ old('category', $finance->category->value) === $cat->value ? 'selected' : '' }}>{{ $cat->label() }}</option>
@endforeach
</select>
@error('category')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Titel --}}
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.finance_title') }} *</label>
<input type="text" name="title" id="title" value="{{ old('title', $finance->title) }}" required maxlength="150"
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('title') border-red-500 @enderror">
@error('title')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Betrag + Datum --}}
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label for="amount" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.finance_amount') }} *</label>
<div class="relative">
<input type="number" name="amount" id="amount" value="{{ old('amount', number_format($finance->amount / 100, 2, '.', '')) }}" required step="0.01" min="0.01" max="999999.99"
class="w-full px-3 py-2 border border-gray-300 rounded-md pr-8 @error('amount') border-red-500 @enderror">
<span class="absolute right-3 top-2.5 text-sm text-gray-400"></span>
</div>
@error('amount')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
<div>
<label for="date" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.finance_date') }} *</label>
<input type="date" name="date" id="date" value="{{ old('date', $finance->date->format('Y-m-d')) }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('date') border-red-500 @enderror">
@error('date')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
</div>
{{-- Team --}}
<div class="mb-4">
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('ui.team') }}</label>
<select name="team_id" id="team_id" class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">{{ __('admin.finance_no_team') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ old('team_id', $finance->team_id) == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
@error('team_id')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Notizen --}}
<div class="mb-4">
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.finance_notes') }}</label>
<textarea name="notes" id="notes" rows="2" maxlength="1000"
class="w-full px-3 py-2 border border-gray-300 rounded-md @error('notes') border-red-500 @enderror">{{ old('notes', $finance->notes) }}</textarea>
@error('notes')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div>
{{-- Buttons --}}
<div class="flex items-center gap-3">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">
{{ __('ui.save') }}
</button>
<a href="{{ route('admin.finances.index') }}" class="text-sm text-gray-600 hover:underline">
{{ __('ui.cancel') }}
</a>
</div>
</form>
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,243 @@
<x-layouts.admin :title="__('admin.finances_title')">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{{ __('admin.finances_title') }}</h1>
<a href="{{ route('admin.finances.create') }}" class="inline-flex items-center gap-1.5 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
{{ __('admin.new_finance') }}
</a>
</div>
{{-- Filter --}}
<form method="GET" action="{{ route('admin.finances.index') }}" class="bg-white rounded-lg shadow p-4 mb-6">
<div class="flex flex-wrap items-end gap-3">
{{-- Saison --}}
<div>
<label class="block text-xs text-gray-500 mb-1">{{ __('admin.season') }}</label>
<select name="season_id" class="px-2 py-1.5 border border-gray-300 rounded-md text-sm" onchange="this.form.submit()">
<option value="">{{ __('admin.all_seasons') }}</option>
@foreach ($seasons as $season)
<option value="{{ $season->id }}" {{ request('season_id', $activeSeason?->id) == $season->id ? 'selected' : '' }}>
{{ $season->name }}{{ $season->is_current ? ' ●' : '' }}
</option>
@endforeach
</select>
</div>
{{-- Jahr --}}
<div>
<label class="block text-xs text-gray-500 mb-1">{{ __('admin.filter_year') }}</label>
<select name="year" class="px-2 py-1.5 border border-gray-300 rounded-md text-sm" onchange="this.form.submit()">
<option value=""></option>
@foreach ($years as $y)
<option value="{{ $y }}" {{ request('year') == $y ? 'selected' : '' }}>{{ $y }}</option>
@endforeach
</select>
</div>
{{-- Team --}}
<div>
<label class="block text-xs text-gray-500 mb-1">{{ __('ui.team') }}</label>
<select name="team_id" class="px-2 py-1.5 border border-gray-300 rounded-md text-sm" onchange="this.form.submit()">
<option value="">{{ __('ui.all_teams') }}</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}" {{ request('team_id') == $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
</div>
{{-- Typ --}}
<div>
<label class="block text-xs text-gray-500 mb-1">{{ __('admin.finance_type') }}</label>
<select name="type" class="px-2 py-1.5 border border-gray-300 rounded-md text-sm" onchange="this.form.submit()">
<option value="">{{ __('admin.finance_all_types') }}</option>
<option value="income" {{ request('type') === 'income' ? 'selected' : '' }}>{{ __('admin.finance_income') }}</option>
<option value="expense" {{ request('type') === 'expense' ? 'selected' : '' }}>{{ __('admin.finance_expense') }}</option>
</select>
</div>
{{-- Kategorie --}}
<div>
<label class="block text-xs text-gray-500 mb-1">{{ __('admin.finance_category') }}</label>
<select name="category" class="px-2 py-1.5 border border-gray-300 rounded-md text-sm" onchange="this.form.submit()">
<option value="">{{ __('admin.finance_all_categories') }}</option>
@foreach (\App\Enums\FinanceCategory::cases() as $cat)
<option value="{{ $cat->value }}" {{ request('category') === $cat->value ? 'selected' : '' }}>{{ $cat->label() }}</option>
@endforeach
</select>
</div>
</div>
</form>
{{-- Bilanz-Cards --}}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="bg-green-50 rounded-lg shadow p-4 text-center">
<div class="text-xs font-medium text-green-600 uppercase tracking-wider mb-1">{{ __('admin.finance_total_income') }}</div>
<div class="text-2xl font-bold text-green-700">{{ number_format($totalIncome / 100, 2, ',', '.') }} </div>
</div>
<div class="bg-red-50 rounded-lg shadow p-4 text-center">
<div class="text-xs font-medium text-red-600 uppercase tracking-wider mb-1">{{ __('admin.finance_total_expense') }}</div>
<div class="text-2xl font-bold text-red-700">{{ number_format($totalExpense / 100, 2, ',', '.') }} </div>
</div>
<div class="{{ $balance >= 0 ? 'bg-green-50' : 'bg-red-50' }} rounded-lg shadow p-4 text-center">
<div class="text-xs font-medium {{ $balance >= 0 ? 'text-green-600' : 'text-red-600' }} uppercase tracking-wider mb-1">{{ __('admin.finance_balance') }}</div>
<div class="text-2xl font-bold {{ $balance >= 0 ? 'text-green-700' : 'text-red-700' }}">
{{ $balance >= 0 ? '+' : '' }}{{ number_format($balance / 100, 2, ',', '.') }}
</div>
</div>
</div>
{{-- Charts --}}
@if ($totalIncome > 0 || $totalExpense > 0)
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{{-- Monatliche Uebersicht --}}
@if (!empty($chartMonthly['labels']))
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.finance_chart_monthly') }}</h3>
<div class="relative" style="height: 250px;">
<canvas id="chartMonthly"></canvas>
</div>
</div>
@endif
{{-- Kategorie-Aufschluesselung --}}
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ __('admin.finance_chart_categories') }}</h3>
<div class="grid grid-cols-2 gap-4">
@if (!empty($chartCategories['income']['data']))
<div>
<p class="text-xs text-center text-green-600 font-medium mb-2">{{ __('admin.finance_income') }}</p>
<div class="relative" style="height: 200px;">
<canvas id="chartCatIncome"></canvas>
</div>
</div>
@endif
@if (!empty($chartCategories['expense']['data']))
<div>
<p class="text-xs text-center text-red-600 font-medium mb-2">{{ __('admin.finance_expense') }}</p>
<div class="relative" style="height: 200px;">
<canvas id="chartCatExpense"></canvas>
</div>
</div>
@endif
</div>
</div>
</div>
@endif
{{-- Tabelle --}}
<div class="bg-white rounded-lg shadow overflow-hidden overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-600">{{ __('admin.finance_date') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">{{ __('admin.finance_type') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">{{ __('admin.finance_category') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">{{ __('ui.team') }}</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">{{ __('admin.finance_title') }}</th>
<th class="text-right px-4 py-3 font-medium text-gray-600">{{ __('admin.finance_amount') }}</th>
<th class="text-right px-4 py-3 font-medium text-gray-600">{{ __('admin.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($finances as $entry)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 whitespace-nowrap">{{ $entry->date->format('d.m.Y') }}</td>
<td class="px-4 py-3">
@if ($entry->type === \App\Enums\FinanceType::Income)
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">{{ __('admin.finance_income') }}</span>
@else
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">{{ __('admin.finance_expense') }}</span>
@endif
</td>
<td class="px-4 py-3">{{ $entry->category->label() }}</td>
<td class="px-4 py-3 text-gray-500">{{ $entry->team?->name ?? '' }}</td>
<td class="px-4 py-3">
{{ $entry->title }}
@if ($entry->notes)
<span class="text-gray-400 text-xs ml-1" title="{{ $entry->notes }}">💬</span>
@endif
</td>
<td class="px-4 py-3 text-right font-medium whitespace-nowrap {{ $entry->type === \App\Enums\FinanceType::Income ? 'text-green-700' : 'text-red-700' }}">
{{ $entry->type === \App\Enums\FinanceType::Income ? '+' : '-' }}{{ $entry->formatted_amount }}
</td>
<td class="px-4 py-3 text-right whitespace-nowrap space-x-2">
<a href="{{ route('admin.finances.edit', $entry) }}" class="text-blue-600 hover:underline text-sm">{{ __('ui.edit') }}</a>
<form method="POST" action="{{ route('admin.finances.destroy', $entry) }}" class="inline" onsubmit="return confirm(@js(__('admin.finance_confirm_delete')))">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline text-sm">{{ __('ui.delete') }}</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500">{{ __('admin.finance_no_entries') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Pagination --}}
@if ($finances->hasPages())
<div class="mt-4">{{ $finances->links() }}</div>
@endif
{{-- Chart.js --}}
@if ($totalIncome > 0 || $totalExpense > 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>
<script>
document.addEventListener('DOMContentLoaded', function() {
const monthlyData = @js($chartMonthly);
const catData = @js($chartCategories);
// Monatliche Uebersicht
if (monthlyData.labels.length && document.getElementById('chartMonthly')) {
new Chart(document.getElementById('chartMonthly'), {
type: 'bar',
data: {
labels: monthlyData.labels,
datasets: [
{ label: @js(__('admin.finance_income')), data: monthlyData.income, backgroundColor: '#3e7750', borderRadius: 3 },
{ label: @js(__('admin.finance_expense')), data: monthlyData.expense, backgroundColor: '#8f504b', borderRadius: 3 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: true, ticks: { callback: v => v + ' €' } } },
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } } }
}
});
}
// Kategorie-Charts
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, font: { size: 10 } } } }
};
if (catData.income.data.length && document.getElementById('chartCatIncome')) {
new Chart(document.getElementById('chartCatIncome'), {
type: 'doughnut',
data: { labels: catData.income.labels, datasets: [{ data: catData.income.data, backgroundColor: catData.income.colors }] },
options: doughnutOptions
});
}
if (catData.expense.data.length && document.getElementById('chartCatExpense')) {
new Chart(document.getElementById('chartCatExpense'), {
type: 'doughnut',
data: { labels: catData.expense.labels, datasets: [{ data: catData.expense.data, backgroundColor: catData.expense.colors }] },
options: doughnutOptions
});
}
});
</script>
@endpush
@endif
</x-layouts.admin>

View File

@@ -30,6 +30,8 @@ use App\Http\Controllers\Admin\SettingsController;
use App\Http\Controllers\Admin\ListGeneratorController; use App\Http\Controllers\Admin\ListGeneratorController;
use App\Http\Controllers\Admin\StatisticsController; use App\Http\Controllers\Admin\StatisticsController;
use App\Http\Controllers\Admin\SupportController; use App\Http\Controllers\Admin\SupportController;
use App\Http\Controllers\Admin\FinanceController;
use App\Http\Controllers\Admin\SeasonController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
// ------------------------------------------------------- // -------------------------------------------------------
@@ -77,7 +79,7 @@ Route::get('/club-logo', function () {
} }
// 2. Fallback: statisches Logo // 2. Fallback: statisches Logo
$fallback = public_path('images/logo_woelfe.png'); $fallback = public_path('images/vereinos_logo.png');
if (file_exists($fallback)) { if (file_exists($fallback)) {
return response()->file($fallback, [ return response()->file($fallback, [
'Cache-Control' => 'public, max-age=86400', 'Cache-Control' => 'public, max-age=86400',
@@ -159,6 +161,14 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
Route::get('statistics', [StatisticsController::class, 'index'])->name('statistics.index'); Route::get('statistics', [StatisticsController::class, 'index'])->name('statistics.index');
Route::get('statistics/player/{player}', [StatisticsController::class, 'playerDetail'])->name('statistics.player-detail'); Route::get('statistics/player/{player}', [StatisticsController::class, 'playerDetail'])->name('statistics.player-detail');
// Finanzen (fuer alle Admin-Panel-Nutzer mit Feature-Sichtbarkeit)
Route::get('finances', [FinanceController::class, 'index'])->name('finances.index');
Route::get('finances/create', [FinanceController::class, 'create'])->name('finances.create');
Route::post('finances', [FinanceController::class, 'store'])->name('finances.store');
Route::get('finances/{finance}/edit', [FinanceController::class, 'edit'])->name('finances.edit');
Route::put('finances/{finance}', [FinanceController::class, 'update'])->name('finances.update');
Route::delete('finances/{finance}', [FinanceController::class, 'destroy'])->name('finances.destroy');
// Events (Leseansicht fuer alle Admin-Panel-Nutzer) // Events (Leseansicht fuer alle Admin-Panel-Nutzer)
Route::get('events', [AdminEventController::class, 'index'])->name('events.index'); Route::get('events', [AdminEventController::class, 'index'])->name('events.index');
Route::get('events/{event}/edit', [AdminEventController::class, 'edit'])->name('events.edit'); Route::get('events/{event}/edit', [AdminEventController::class, 'edit'])->name('events.edit');
@@ -231,6 +241,11 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
Route::delete('settings/demo-data', [SettingsController::class, 'destroyDemoData'])->name('settings.destroy-demo-data')->middleware('throttle:5,1'); Route::delete('settings/demo-data', [SettingsController::class, 'destroyDemoData'])->name('settings.destroy-demo-data')->middleware('throttle:5,1');
Route::delete('settings/factory-reset', [SettingsController::class, 'factoryReset'])->name('settings.factory-reset')->middleware('throttle:3,1'); Route::delete('settings/factory-reset', [SettingsController::class, 'factoryReset'])->name('settings.factory-reset')->middleware('throttle:3,1');
// Saisons
Route::post('seasons', [SeasonController::class, 'store'])->name('seasons.store');
Route::put('seasons/{season}', [SeasonController::class, 'update'])->name('seasons.update');
Route::delete('seasons/{season}', [SeasonController::class, 'destroy'])->name('seasons.destroy');
// Bekannte Orte // Bekannte Orte
Route::get('locations', [LocationController::class, 'index'])->name('locations.index'); Route::get('locations', [LocationController::class, 'index'])->name('locations.index');
Route::post('locations', [LocationController::class, 'store'])->name('locations.store'); Route::post('locations', [LocationController::class, 'store'])->name('locations.store');