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:
jeremy bayse
2026-07-25 22:39:42 +02:00
co-authored by Claude Sonnet 5
commit d8dc57a5df
164 changed files with 19880 additions and 0 deletions
@@ -0,0 +1,199 @@
<div>
{{-- Mobile: quick single-purpose entry form --}}
<div
class="sm:hidden max-w-md mx-auto px-4 py-6 space-y-5"
x-data="{ toast: false }"
x-on:transaction-added.window="toast = true; setTimeout(() => toast = false, 2000)"
>
<h1 class="text-xl font-semibold text-text">Nouvelle opération</h1>
<form wire:submit="addTransaction" class="space-y-5">
<fieldset>
<legend class="text-sm font-medium text-text-muted mb-2">Catégorie</legend>
<div class="space-y-4">
@foreach (['revenu' => ['label' => 'Revenus', 'ring' => 'ring-positive', 'active' => 'bg-positive-fill text-white border-positive-fill'], 'depense' => ['label' => 'Dépenses', 'ring' => 'ring-negative', 'active' => 'bg-negative-fill text-white border-negative-fill'], 'epargne' => ['label' => 'Épargne', 'ring' => 'ring-info', 'active' => 'bg-info-fill text-white border-info-fill']] as $typeValue => $meta)
@php $typeCategories = $categories->where('type', $typeValue); @endphp
@if ($typeCategories->isNotEmpty())
<div>
<p class="text-xs text-text-muted mb-1.5">{{ $meta['label'] }}</p>
<div class="grid grid-cols-2 gap-2">
@foreach ($typeCategories as $category)
<button
type="button"
wire:click="$set('categoryId', {{ $category->id }})"
aria-pressed="{{ (string) $categoryId === (string) $category->id ? 'true' : 'false' }}"
class="min-h-[52px] px-3 py-2 rounded-lg border text-sm font-medium text-left transition focus:outline-none focus-visible:ring-2 {{ $meta['ring'] }}
{{ (string) $categoryId === (string) $category->id ? $meta['active'] : 'bg-surface border-border-strong text-text' }}"
>
{{ $category->name }}
</button>
@endforeach
</div>
</div>
@endif
@endforeach
</div>
<x-input-error :messages="$errors->get('categoryId')" class="mt-2" />
</fieldset>
<div>
<label for="mobile-amount" class="text-sm font-medium text-text-muted">Montant</label>
<div class="mt-1 relative">
<input
wire:model="amount"
id="mobile-amount"
type="number"
inputmode="decimal"
step="0.01"
placeholder="0.00"
class="w-full text-3xl font-semibold text-center py-4 rounded-lg bg-surface border-border-strong text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<span class="absolute right-4 top-1/2 -translate-y-1/2 text-2xl text-text-muted"></span>
</div>
<x-input-error :messages="$errors->get('amount')" class="mt-2" />
</div>
<div>
<label for="mobile-details" class="text-sm font-medium text-text-muted">Détails (optionnel)</label>
<input
wire:model="details"
id="mobile-details"
type="text"
class="mt-1 w-full py-3 px-3 rounded-lg bg-surface border-border-strong text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<button
type="submit"
class="w-full py-4 rounded-lg bg-brand text-white text-lg font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Ajouter
</button>
</form>
<div
x-show="toast"
x-cloak
x-transition
role="status"
class="fixed bottom-6 left-1/2 -translate-x-1/2 bg-positive-fill text-white text-sm font-medium px-4 py-2 rounded-full shadow-lg"
>
Opération ajoutée
</div>
</div>
{{-- Desktop / tablet: full table view --}}
<div class="hidden sm:block max-w-6xl mx-auto py-6 px-4 sm:px-6 space-y-6">
<h1 class="text-xl font-semibold text-text">Suivi</h1>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div class="bg-surface shadow rounded-lg p-4">
<p class="text-xs text-text-muted">Date du jour</p>
<p class="mt-1 text-lg font-semibold text-text">{{ now()->translatedFormat('d M Y') }}</p>
</div>
<div class="bg-surface shadow rounded-lg p-4">
<p class="text-xs text-text-muted">Dernier enreg.</p>
<p class="mt-1 text-lg font-semibold text-brand">{{ $lastEntry?->date?->translatedFormat('d M Y') ?? '—' }}</p>
</div>
<div class="bg-surface shadow rounded-lg p-4">
<p class="text-xs text-text-muted">Nbr enreg.</p>
<p class="mt-1 text-lg font-semibold text-brand">{{ $nbrTotal }}</p>
<p class="text-xs text-text-muted">{{ $nbrThisYear }} cette année</p>
</div>
<div class="bg-surface shadow rounded-lg p-4">
<p class="text-xs text-text-muted">Affectation</p>
<p class="mt-1 text-lg font-semibold text-text">{{ number_format($unaffected, 2, ',', ' ') }} </p>
<p class="text-xs text-text-muted">revenus restent à affecter</p>
</div>
</div>
<div class="bg-surface shadow rounded-lg p-4">
<form wire:submit="addTransaction" class="grid grid-cols-1 sm:grid-cols-6 gap-3 items-end">
<div>
<x-input-label for="date" value="Date" />
<x-text-input wire:model="date" id="date" type="date" class="block mt-1 w-full text-sm" />
</div>
<div>
<x-input-label for="categoryId" value="Catégorie" />
<select wire:model="categoryId" id="categoryId" class="block mt-1 w-full rounded-md bg-surface border-border-strong text-text text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">
<option value=""></option>
@foreach ($categories as $category)
<option value="{{ $category->id }}">{{ $category->type->label() }} {{ $category->name }}</option>
@endforeach
</select>
<x-input-error :messages="$errors->get('categoryId')" class="mt-1" />
</div>
<div>
<x-input-label for="amount" value="Montant" />
<x-text-input wire:model="amount" id="amount" type="number" step="0.01" class="block mt-1 w-full text-sm" />
<x-input-error :messages="$errors->get('amount')" class="mt-1" />
</div>
<div class="sm:col-span-2">
<x-input-label for="details" value="Détails" />
<x-text-input wire:model="details" id="details" type="text" class="block mt-1 w-full text-sm" />
</div>
<div>
<x-input-label for="dateEffective" value="Date effective" />
<x-text-input wire:model="dateEffective" id="dateEffective" type="date" class="block mt-1 w-full text-sm" />
</div>
<div class="sm:col-span-6">
<x-primary-button>Ajouter</x-primary-button>
</div>
</form>
</div>
@php
$amountColor = fn ($t) => match ($t->category->type->value) {
'depense' => 'text-negative',
'epargne' => 'text-info',
default => 'text-positive',
};
$amountPrefix = fn ($t) => $t->category->type->value === 'depense' ? '' : '+';
@endphp
<div class="bg-surface shadow rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<caption class="sr-only">Historique des transactions du foyer</caption>
<thead>
<tr class="bg-surface-alt text-xs text-text-muted uppercase">
<th scope="col" class="text-left px-4 py-2">Date</th>
<th scope="col" class="text-left px-4 py-2">Type</th>
<th scope="col" class="text-left px-4 py-2">Catégorie</th>
<th scope="col" class="text-right px-4 py-2">Montant</th>
<th scope="col" class="text-left px-4 py-2">Détails</th>
<th scope="col" class="text-right px-4 py-2">Balance</th>
<th scope="col" class="text-left px-4 py-2">Date effective</th>
<th scope="col" class="px-4 py-2"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@forelse ($transactions as $transaction)
<tr class="border-t border-border/60">
<td class="px-4 py-2 whitespace-nowrap text-text">{{ $transaction->date->translatedFormat('d M Y') }}</td>
<td class="px-4 py-2 text-text">{{ $transaction->category->type->label() }}</td>
<td class="px-4 py-2 text-text">{{ $transaction->category->name }}</td>
<td class="px-4 py-2 text-right font-medium {{ $amountColor($transaction) }}">
{{ $amountPrefix($transaction) }}{{ number_format($transaction->amount, 2, ',', ' ') }}
</td>
<td class="px-4 py-2 text-text-muted">{{ $transaction->details }}</td>
<td class="px-4 py-2 text-right font-semibold {{ ($running[$transaction->id] ?? 0) < 0 ? 'text-negative' : 'text-text' }}">
{{ number_format($running[$transaction->id] ?? 0, 2, ',', ' ') }}
</td>
<td class="px-4 py-2 whitespace-nowrap text-text-muted">{{ $transaction->date_effective->translatedFormat('d M Y') }}</td>
<td class="px-4 py-2 text-right">
<button wire:click="deleteTransaction({{ $transaction->id }})" wire:confirm="Supprimer cet enregistrement ?" aria-label="Supprimer l'opération {{ $transaction->category->name }} du {{ $transaction->date->translatedFormat('d M Y') }}" class="text-xs text-danger hover:underline rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">
Suppr.
</button>
</td>
</tr>
@empty
<tr><td colspan="8" class="px-4 py-3 text-sm text-text-muted">Aucun enregistrement.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>