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:
272
app/Http/Controllers/Admin/FinanceController.php
Normal file
272
app/Http/Controllers/Admin/FinanceController.php
Normal 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)),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user