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>
200 lines
12 KiB
PHP
200 lines
12 KiB
PHP
<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>
|