Initial commit: SuiviCPT personal finance tracker
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>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class Logout
|
||||
{
|
||||
/**
|
||||
* Log the current user out of the application.
|
||||
*/
|
||||
public function __invoke(): void
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
Session::invalidate();
|
||||
Session::regenerateToken();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Categories;
|
||||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Category;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
public array $newName = ['revenu' => '', 'depense' => '', 'epargne' => ''];
|
||||
|
||||
public ?int $editingId = null;
|
||||
|
||||
public string $editingName = '';
|
||||
|
||||
public function add(string $type): void
|
||||
{
|
||||
$this->validate([
|
||||
"newName.{$type}" => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
if (! in_array($type, ['revenu', 'depense', 'epargne'], true)) {
|
||||
abort(422);
|
||||
}
|
||||
|
||||
$household = auth()->user()->currentHousehold;
|
||||
|
||||
$position = $household->categories()->where('type', $type)->max('position') + 1;
|
||||
|
||||
$household->categories()->create([
|
||||
'name' => $this->newName[$type],
|
||||
'type' => $type,
|
||||
'position' => $position,
|
||||
]);
|
||||
|
||||
$this->newName[$type] = '';
|
||||
}
|
||||
|
||||
public function startEdit(int $categoryId): void
|
||||
{
|
||||
$category = Category::findOrFail($categoryId);
|
||||
$this->authorize('update', $category);
|
||||
|
||||
$this->editingId = $category->id;
|
||||
$this->editingName = $category->name;
|
||||
}
|
||||
|
||||
public function saveEdit(): void
|
||||
{
|
||||
$category = Category::findOrFail($this->editingId);
|
||||
$this->authorize('update', $category);
|
||||
|
||||
$this->validate([
|
||||
'editingName' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$category->update(['name' => $this->editingName]);
|
||||
|
||||
$this->editingId = null;
|
||||
}
|
||||
|
||||
public function cancelEdit(): void
|
||||
{
|
||||
$this->editingId = null;
|
||||
}
|
||||
|
||||
public function delete(int $categoryId): void
|
||||
{
|
||||
$category = Category::findOrFail($categoryId);
|
||||
$this->authorize('delete', $category);
|
||||
|
||||
$category->delete();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$household = auth()->user()->currentHousehold;
|
||||
|
||||
$categories = $household->categories()->orderBy('position')->get()->groupBy(fn (Category $category) => $category->type->value);
|
||||
|
||||
return view('livewire.categories.index', [
|
||||
'types' => CategoryType::cases(),
|
||||
'categories' => $categories,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Forms;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Form;
|
||||
|
||||
class LoginForm extends Form
|
||||
{
|
||||
#[Validate('required|string|email')]
|
||||
public string $email = '';
|
||||
|
||||
#[Validate('required|string')]
|
||||
public string $password = '';
|
||||
|
||||
#[Validate('boolean')]
|
||||
public bool $remember = false;
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only(['email', 'password']), $this->remember)) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'form.email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the authentication request is not rate limited.
|
||||
*/
|
||||
protected function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout(request()));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'form.email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication rate limiting throttle key.
|
||||
*/
|
||||
protected function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Household;
|
||||
|
||||
use App\Models\HouseholdInvite;
|
||||
use App\Models\User;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.guest')]
|
||||
class AcceptInvite extends Component
|
||||
{
|
||||
public string $token;
|
||||
|
||||
public ?HouseholdInvite $invite = null;
|
||||
|
||||
public bool $invalid = false;
|
||||
|
||||
public function mount(string $token): void
|
||||
{
|
||||
$this->token = $token;
|
||||
|
||||
$invite = HouseholdInvite::with('household')->where('token', $token)->first();
|
||||
|
||||
if (! $invite || $invite->isExpired() || $invite->isAccepted()) {
|
||||
$this->invalid = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->invite = $invite;
|
||||
|
||||
$existingUser = User::where('email', $invite->email)->first();
|
||||
|
||||
if ($existingUser && auth()->check() && auth()->id() === $existingUser->id) {
|
||||
$this->accept();
|
||||
}
|
||||
}
|
||||
|
||||
public function accept(): void
|
||||
{
|
||||
if (! $this->invite || ! auth()->check()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user->email !== $this->invite->email) {
|
||||
$this->invalid = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user->households()->syncWithoutDetaching([
|
||||
$this->invite->household_id => ['role' => $this->invite->role],
|
||||
]);
|
||||
|
||||
$this->invite->update(['accepted_at' => now()]);
|
||||
$user->forceFill(['current_household_id' => $this->invite->household_id])->save();
|
||||
|
||||
$this->redirect(route('dashboard', absolute: false), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.household.accept-invite');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Household;
|
||||
|
||||
use App\Models\Household;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.guest')]
|
||||
class Create extends Component
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$household = Household::create([
|
||||
'name' => $this->name,
|
||||
'owner_id' => $user->id,
|
||||
]);
|
||||
|
||||
$user->households()->attach($household->id, ['role' => 'owner']);
|
||||
$user->forceFill(['current_household_id' => $household->id])->save();
|
||||
|
||||
$this->redirect(route('dashboard', absolute: false), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.household.create');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Household;
|
||||
|
||||
use App\Mail\HouseholdInviteMail;
|
||||
use App\Models\HouseholdInvite;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class Settings extends Component
|
||||
{
|
||||
public string $inviteEmail = '';
|
||||
|
||||
public string $inviteRole = 'member';
|
||||
|
||||
public function invite(): void
|
||||
{
|
||||
$household = auth()->user()->currentHousehold;
|
||||
|
||||
$this->authorize('manageMembers', $household);
|
||||
|
||||
$this->validate([
|
||||
'inviteEmail' => ['required', 'email', 'max:255'],
|
||||
'inviteRole' => ['required', 'in:owner,member'],
|
||||
]);
|
||||
|
||||
$invite = HouseholdInvite::create([
|
||||
'household_id' => $household->id,
|
||||
'invited_by' => auth()->id(),
|
||||
'email' => $this->inviteEmail,
|
||||
'token' => Str::random(48),
|
||||
'role' => $this->inviteRole,
|
||||
'expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
Mail::to($this->inviteEmail)->send(new HouseholdInviteMail($invite));
|
||||
|
||||
$this->reset('inviteEmail');
|
||||
$this->dispatch('invite-sent');
|
||||
}
|
||||
|
||||
public function revokeInvite(int $inviteId): void
|
||||
{
|
||||
$household = auth()->user()->currentHousehold;
|
||||
|
||||
$this->authorize('manageMembers', $household);
|
||||
|
||||
$household->invites()->whereKey($inviteId)->whereNull('accepted_at')->delete();
|
||||
}
|
||||
|
||||
public function removeMember(int $userId): void
|
||||
{
|
||||
$household = auth()->user()->currentHousehold;
|
||||
|
||||
$this->authorize('manageMembers', $household);
|
||||
|
||||
if ($userId === $household->owner_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$household->members()->detach($userId);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$household = auth()->user()->currentHousehold()->with(['members', 'invites' => fn ($q) => $q->whereNull('accepted_at')])->first();
|
||||
|
||||
return view('livewire.household.settings', [
|
||||
'household' => $household,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Plan;
|
||||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\BudgetLine;
|
||||
use App\Models\Category;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
public int $year;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->year = (int) now()->year;
|
||||
}
|
||||
|
||||
public function setYear(int $year): void
|
||||
{
|
||||
$this->year = $year;
|
||||
}
|
||||
|
||||
public function updateAmount(int $categoryId, ?int $month, string $amount): void
|
||||
{
|
||||
$category = Category::findOrFail($categoryId);
|
||||
$household = auth()->user()->currentHousehold;
|
||||
|
||||
if ($category->household_id !== $household->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$amount = is_numeric($amount) ? (float) $amount : 0.0;
|
||||
|
||||
BudgetLine::updateOrCreate(
|
||||
[
|
||||
'household_id' => $household->id,
|
||||
'category_id' => $categoryId,
|
||||
'year' => $this->year,
|
||||
'month' => $month,
|
||||
],
|
||||
['amount' => $amount],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string, float> keyed 'default',1..12
|
||||
*/
|
||||
private function resolveRow(Category $category): array
|
||||
{
|
||||
$lines = $category->budgetLines()->where('year', $this->year)->get();
|
||||
$default = (float) ($lines->firstWhere('month', null)->amount ?? 0);
|
||||
|
||||
$row = ['default' => $default];
|
||||
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$override = $lines->firstWhere('month', $m);
|
||||
$row[$m] = $override ? (float) $override->amount : $default;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$household = auth()->user()->currentHousehold;
|
||||
|
||||
$sections = collect(CategoryType::cases())->mapWithKeys(function (CategoryType $type) use ($household) {
|
||||
$categories = $household->categories()
|
||||
->where('type', $type)
|
||||
->orderBy('position')
|
||||
->get()
|
||||
->map(fn (Category $category) => [
|
||||
'category' => $category,
|
||||
'row' => $this->resolveRow($category),
|
||||
]);
|
||||
|
||||
$totals = ['default' => 0.0];
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$totals[$m] = 0.0;
|
||||
}
|
||||
foreach ($categories as $entry) {
|
||||
foreach ($entry['row'] as $key => $value) {
|
||||
$totals[$key] += $value;
|
||||
}
|
||||
}
|
||||
|
||||
return [$type->value => ['categories' => $categories, 'totals' => $totals]];
|
||||
});
|
||||
|
||||
$remaining = [];
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$remaining[$m] = $sections['revenu']['totals'][$m]
|
||||
- $sections['depense']['totals'][$m]
|
||||
- $sections['epargne']['totals'][$m];
|
||||
}
|
||||
$sections['revenu'] = [...$sections['revenu'], 'remaining' => $remaining];
|
||||
|
||||
return view('livewire.plan.index', [
|
||||
'types' => CategoryType::cases(),
|
||||
'sections' => $sections,
|
||||
'months' => ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Sankey;
|
||||
|
||||
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 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;
|
||||
|
||||
$revenueCategories = $household->categories()->where('type', CategoryType::Revenu)->orderBy('position')->get();
|
||||
$totalRevenue = $revenueCategories->sum(fn (Category $c) => $this->trackedFor($c));
|
||||
|
||||
$outflowCategories = $household->categories()
|
||||
->whereIn('type', [CategoryType::Depense, CategoryType::Epargne])
|
||||
->orderBy('type')
|
||||
->orderBy('position')
|
||||
->get()
|
||||
->map(fn (Category $category) => [
|
||||
'category' => $category,
|
||||
'tracked' => $this->trackedFor($category),
|
||||
])
|
||||
->filter(fn ($row) => $row['tracked'] > 0)
|
||||
->values();
|
||||
|
||||
$totalOutflow = $outflowCategories->sum('tracked');
|
||||
$unallocated = max(0, $totalRevenue - $totalOutflow);
|
||||
|
||||
$flows = $outflowCategories->map(fn ($row) => [
|
||||
'from' => 'Revenus',
|
||||
'to' => $row['category']->name,
|
||||
'flow' => (float) $row['tracked'],
|
||||
'type' => $row['category']->type->value,
|
||||
]);
|
||||
|
||||
if ($unallocated > 0) {
|
||||
$flows->push([
|
||||
'from' => 'Revenus',
|
||||
'to' => 'Non affecté',
|
||||
'flow' => $unallocated,
|
||||
'type' => 'unallocated',
|
||||
]);
|
||||
}
|
||||
|
||||
return view('livewire.sankey.index', [
|
||||
'flows' => $flows->values(),
|
||||
'totalRevenue' => $totalRevenue,
|
||||
'totalOutflow' => $totalOutflow,
|
||||
'unallocated' => $unallocated,
|
||||
'hasData' => $totalRevenue > 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Suivi;
|
||||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\BudgetLine;
|
||||
use App\Models\Transaction;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
public string $date;
|
||||
|
||||
public string $dateEffective;
|
||||
|
||||
public ?int $categoryId = null;
|
||||
|
||||
public string $amount = '';
|
||||
|
||||
public string $details = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->date = now()->toDateString();
|
||||
$this->dateEffective = now()->toDateString();
|
||||
}
|
||||
|
||||
public function addTransaction(): void
|
||||
{
|
||||
$this->validate([
|
||||
'date' => ['required', 'date'],
|
||||
'dateEffective' => ['required', 'date'],
|
||||
'categoryId' => ['required', 'exists:categories,id'],
|
||||
'amount' => ['required', 'numeric'],
|
||||
'details' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$household = auth()->user()->currentHousehold;
|
||||
$category = $household->categories()->findOrFail($this->categoryId);
|
||||
|
||||
$household->transactions()->create([
|
||||
'category_id' => $category->id,
|
||||
'user_id' => auth()->id(),
|
||||
'date' => $this->date,
|
||||
'date_effective' => $this->dateEffective,
|
||||
'amount' => $this->amount,
|
||||
'details' => $this->details,
|
||||
]);
|
||||
|
||||
$this->reset(['categoryId', 'amount', 'details']);
|
||||
$this->date = now()->toDateString();
|
||||
$this->dateEffective = now()->toDateString();
|
||||
|
||||
$this->dispatch('transaction-added');
|
||||
}
|
||||
|
||||
public function deleteTransaction(int $transactionId): void
|
||||
{
|
||||
$household = auth()->user()->currentHousehold;
|
||||
$transaction = $household->transactions()->findOrFail($transactionId);
|
||||
$transaction->delete();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$household = auth()->user()->currentHousehold;
|
||||
|
||||
$transactions = $household->transactions()
|
||||
->with('category')
|
||||
->orderByDesc('date')
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
$chronological = $transactions->sortBy(['date', 'id'])->values();
|
||||
|
||||
$running = [];
|
||||
$balance = 0.0;
|
||||
foreach ($chronological as $t) {
|
||||
$sign = $t->category->type === CategoryType::Revenu ? 1 : -1;
|
||||
$balance += (float) $t->amount * $sign;
|
||||
$running[$t->id] = $balance;
|
||||
}
|
||||
|
||||
$year = (int) now()->year;
|
||||
$month = (int) now()->month;
|
||||
|
||||
$revenueCategoryIds = $household->categories()->where('type', CategoryType::Revenu)->pluck('id');
|
||||
|
||||
$revenueBudget = (float) BudgetLine::whereIn('category_id', $revenueCategoryIds)
|
||||
->where('year', $year)
|
||||
->where(fn ($q) => $q->where('month', $month)->orWhereNull('month'))
|
||||
->get()
|
||||
->groupBy('category_id')
|
||||
->map(fn ($lines) => $lines->firstWhere('month', $month)?->amount ?? $lines->firstWhere('month', null)?->amount ?? 0)
|
||||
->sum();
|
||||
|
||||
$revenueTracked = (float) $household->transactions()
|
||||
->whereHas('category', fn ($q) => $q->where('type', CategoryType::Revenu))
|
||||
->whereYear('date_effective', $year)
|
||||
->whereMonth('date_effective', $month)
|
||||
->sum('amount');
|
||||
|
||||
$unaffected = max(0, $revenueBudget - $revenueTracked);
|
||||
|
||||
return view('livewire.suivi.index', [
|
||||
'transactions' => $transactions,
|
||||
'running' => $running,
|
||||
'categories' => $household->categories()->orderBy('type')->orderBy('position')->get(),
|
||||
'nbrThisYear' => $transactions->filter(fn (Transaction $t) => $t->date->year === $year)->count(),
|
||||
'nbrTotal' => $transactions->count(),
|
||||
'lastEntry' => $transactions->first(),
|
||||
'unaffected' => $unaffected,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user