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,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'));
}
}