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();
}
}