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,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum CategoryType: string
|
||||
{
|
||||
case Revenu = 'revenu';
|
||||
case Depense = 'depense';
|
||||
case Epargne = 'epargne';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Revenu => 'Revenus',
|
||||
self::Depense => 'Depenses',
|
||||
self::Epargne => 'Epargne',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum HouseholdRole: string
|
||||
{
|
||||
case Owner = 'owner';
|
||||
case Member = 'member';
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureHouseholdSelected
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user && ! $user->current_household_id) {
|
||||
$firstHousehold = $user->households()->first();
|
||||
|
||||
if ($firstHousehold) {
|
||||
$user->forceFill(['current_household_id' => $firstHousehold->id])->save();
|
||||
} else {
|
||||
return redirect()->route('households.create');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\HouseholdInvite;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class HouseholdInviteMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly HouseholdInvite $invite,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Invitation à rejoindre '.$this->invite->household->name,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.household-invite',
|
||||
with: [
|
||||
'householdName' => $this->invite->household->name,
|
||||
'inviterName' => $this->invite->inviter->name,
|
||||
'acceptUrl' => route('register', ['invite' => $this->invite->token]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BudgetLine extends Model
|
||||
{
|
||||
protected $fillable = ['household_id', 'category_id', 'year', 'month', 'amount'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function household(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Household::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function scopeForYear(Builder $query, int $year): Builder
|
||||
{
|
||||
return $query->where('year', $year);
|
||||
}
|
||||
|
||||
public function scopeDefaultLine(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('month');
|
||||
}
|
||||
|
||||
public function scopeForMonth(Builder $query, int $month): Builder
|
||||
{
|
||||
return $query->where('month', $month);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\CategoryType;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
protected $fillable = ['household_id', 'name', 'type', 'position'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => CategoryType::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function household(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Household::class);
|
||||
}
|
||||
|
||||
public function budgetLines(): HasMany
|
||||
{
|
||||
return $this->hasMany(BudgetLine::class);
|
||||
}
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class);
|
||||
}
|
||||
|
||||
public function scopeOfType(Builder $query, CategoryType $type): Builder
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Household extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'owner_id'];
|
||||
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_id');
|
||||
}
|
||||
|
||||
public function members(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'household_user')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function categories(): HasMany
|
||||
{
|
||||
return $this->hasMany(Category::class);
|
||||
}
|
||||
|
||||
public function budgetLines(): HasMany
|
||||
{
|
||||
return $this->hasMany(BudgetLine::class);
|
||||
}
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class);
|
||||
}
|
||||
|
||||
public function invites(): HasMany
|
||||
{
|
||||
return $this->hasMany(HouseholdInvite::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HouseholdInvite extends Model
|
||||
{
|
||||
protected $fillable = ['household_id', 'invited_by', 'email', 'token', 'role', 'accepted_at', 'expires_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'accepted_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function household(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Household::class);
|
||||
}
|
||||
|
||||
public function inviter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by');
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isAccepted(): bool
|
||||
{
|
||||
return $this->accepted_at !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Transaction extends Model
|
||||
{
|
||||
protected $fillable = ['household_id', 'category_id', 'user_id', 'date', 'date_effective', 'amount', 'details'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date',
|
||||
'date_effective' => 'date',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function household(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Household::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'current_household_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function households(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Household::class, 'household_user')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function currentHousehold(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Household::class, 'current_household_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
|
||||
class CategoryPolicy
|
||||
{
|
||||
public function update(User $user, Category $category): bool
|
||||
{
|
||||
return $category->household_id === $user->current_household_id;
|
||||
}
|
||||
|
||||
public function delete(User $user, Category $category): bool
|
||||
{
|
||||
return $category->household_id === $user->current_household_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Household;
|
||||
use App\Models\User;
|
||||
|
||||
class HouseholdPolicy
|
||||
{
|
||||
public function view(User $user, Household $household): bool
|
||||
{
|
||||
return $household->members()->whereKey($user->id)->exists();
|
||||
}
|
||||
|
||||
public function manageMembers(User $user, Household $household): bool
|
||||
{
|
||||
return $household->members()
|
||||
->whereKey($user->id)
|
||||
->wherePivot('role', 'owner')
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
class VoltServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Volt::mount([
|
||||
config('livewire.view_path', resource_path('views/livewire')),
|
||||
resource_path('views/pages'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AppLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.app');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GuestLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.guest');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user