Laravel 11 + Livewire 3/Volt + Alpine.js + Tailwind app for household budget planning, transaction tracking, and financial dashboards. - Multi-tenant households with member invites - Plan: monthly/yearly budget per category with overrides - Suivi: transaction log with running balance (desktop + mobile quick-entry) - Dashboard: budget vs tracked breakdown + Chart.js donuts - Flux: Sankey diagram of income allocation - WCAG 2.1 AA color tokens, dark mode, accessible tables/forms - 50 Pest tests Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
111 lines
3.4 KiB
PHP
111 lines
3.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Livewire\Dashboard;
|
|
|
|
use App\Enums\CategoryType;
|
|
use App\Models\Category;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Component;
|
|
|
|
#[Layout('layouts.app')]
|
|
class Index extends Component
|
|
{
|
|
public int $year;
|
|
|
|
public string $period = 'year'; // 'year' or '1'..'12'
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->year = (int) now()->year;
|
|
$this->period = (string) now()->month;
|
|
}
|
|
|
|
private function budgetFor(Category $category): float
|
|
{
|
|
$lines = $category->budgetLines()->where('year', $this->year)->get();
|
|
|
|
if ($this->period === 'year') {
|
|
$default = (float) ($lines->firstWhere('month', null)->amount ?? 0);
|
|
$total = 0.0;
|
|
for ($m = 1; $m <= 12; $m++) {
|
|
$override = $lines->firstWhere('month', $m);
|
|
$total += $override ? (float) $override->amount : $default;
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
$month = (int) $this->period;
|
|
$override = $lines->firstWhere('month', $month);
|
|
|
|
return $override ? (float) $override->amount : (float) ($lines->firstWhere('month', null)->amount ?? 0);
|
|
}
|
|
|
|
private function trackedFor(Category $category): float
|
|
{
|
|
$query = $category->transactions()->whereYear('date_effective', $this->year);
|
|
|
|
if ($this->period !== 'year') {
|
|
$query->whereMonth('date_effective', (int) $this->period);
|
|
}
|
|
|
|
return (float) $query->sum('amount');
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
$household = auth()->user()->currentHousehold;
|
|
|
|
$sections = collect(CategoryType::cases())->mapWithKeys(function (CategoryType $type) use ($household) {
|
|
$rows = $household->categories()
|
|
->where('type', $type)
|
|
->orderBy('position')
|
|
->get()
|
|
->map(function (Category $category) {
|
|
$budget = $this->budgetFor($category);
|
|
$tracked = $this->trackedFor($category);
|
|
|
|
return [
|
|
'category' => $category,
|
|
'tracked' => $tracked,
|
|
'budget' => $budget,
|
|
'pct' => $budget > 0 ? min(100, round($tracked / $budget * 100)) : 0,
|
|
'reste' => max(0, $budget - $tracked),
|
|
'manque' => max(0, $tracked - $budget),
|
|
];
|
|
});
|
|
|
|
return [$type->value => [
|
|
'rows' => $rows,
|
|
'totals' => [
|
|
'tracked' => $rows->sum('tracked'),
|
|
'budget' => $rows->sum('budget'),
|
|
'reste' => $rows->sum('reste'),
|
|
'manque' => $rows->sum('manque'),
|
|
],
|
|
]];
|
|
});
|
|
|
|
$chartData = $sections->mapWithKeys(function ($section, $key) {
|
|
$top = $section['rows']
|
|
->filter(fn ($row) => $row['tracked'] > 0)
|
|
->sortByDesc('tracked')
|
|
->take(6)
|
|
->values();
|
|
|
|
return [$key => [
|
|
'labels' => $top->pluck('category.name')->values(),
|
|
'data' => $top->pluck('tracked')->values(),
|
|
]];
|
|
});
|
|
|
|
return view('livewire.dashboard.index', [
|
|
'types' => CategoryType::cases(),
|
|
'sections' => $sections,
|
|
'chartData' => $chartData,
|
|
]);
|
|
}
|
|
}
|