Files
suivicpt/resources/views/livewire/suivi/index.blade.php
T
jeremy bayseandClaude Sonnet 5 b78c314569 Add recurring transactions with monthly confirmation flow
New "Récurrences" section lets users define recurring depense/epargne
models (name, category, default amount, day of month). Nothing is
created automatically: from the day a recurrence is due, it appears
as a pending proposal in Suivi (desktop + mobile) with an editable
amount, so variable bills (electricity, etc.) can be adjusted before
confirming. Dismissing a proposal skips it for the current month
without creating a transaction, and it reappears the following month.

- recurring_transactions table (household/category scoped, active flag,
  last_generated_year/month to prevent duplicate generation)
- transactions.recurring_transaction_id nullable FK to trace origin
- RecurringTransactionPolicy mirrors CategoryPolicy tenant scoping
- 10 new Pest tests covering CRUD, tenant isolation, and the
  pending/confirm/dismiss lifecycle across month boundaries (travelTo)

58/58 tests pass, Pint clean, verified end-to-end against production
data (test recurrence created and cleaned up).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 09:10:16 +02:00

281 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>
@if ($pendingRecurrences->isNotEmpty())
<div class="space-y-3">
<p class="text-sm font-medium text-text-muted">À confirmer ce mois-ci</p>
@foreach ($pendingRecurrences as $recurring)
<div class="bg-surface-alt border border-border-strong rounded-lg p-3" wire:key="pending-mobile-{{ $recurring->id }}">
<p class="text-sm text-text">{{ $recurring->name }} <span class="text-text-muted"> {{ $recurring->category->name }}</span></p>
<div class="mt-2 flex items-center gap-2">
<label for="pending-amount-mobile-{{ $recurring->id }}" class="sr-only">Montant pour {{ $recurring->name }}</label>
<input
x-ref="amount{{ $recurring->id }}"
id="pending-amount-mobile-{{ $recurring->id }}"
type="number"
step="0.01"
value="{{ $recurring->amount }}"
class="flex-1 bg-surface border-border-strong text-text rounded-md text-sm py-1.5"
/>
<button
type="button"
x-on:click="$wire.confirmRecurrence({{ $recurring->id }}, $refs.amount{{ $recurring->id }}.value)"
class="text-xs text-white bg-brand px-3 py-1.5 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
Confirmer
</button>
<button
type="button"
wire:click="dismissRecurrence({{ $recurring->id }})"
wire:confirm="Ignorer cette récurrence pour ce mois ?"
class="text-xs text-text-muted hover:underline rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
Ignorer
</button>
</div>
</div>
@endforeach
</div>
@endif
<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>
@if ($pendingRecurrences->isNotEmpty())
<div class="bg-surface shadow rounded-lg overflow-hidden">
<div class="px-4 py-2 bg-brand">
<h2 class="text-white font-semibold text-sm">À confirmer ce mois-ci</h2>
</div>
<ul class="divide-y divide-border">
@foreach ($pendingRecurrences as $recurring)
<li class="px-4 py-3 flex items-center justify-between gap-3" wire:key="pending-desktop-{{ $recurring->id }}">
<div class="min-w-0">
<p class="text-sm text-text">{{ $recurring->name }} <span class="text-text-muted"> {{ $recurring->category->name }}</span></p>
<p class="text-xs text-text-muted">le {{ $recurring->day_of_month }} de chaque mois</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<label for="pending-amount-{{ $recurring->id }}" class="sr-only">Montant pour {{ $recurring->name }}</label>
<input
x-ref="amountDesktop{{ $recurring->id }}"
id="pending-amount-{{ $recurring->id }}"
type="number"
step="0.01"
value="{{ $recurring->amount }}"
class="w-28 bg-surface border-border-strong text-text rounded-md text-sm py-1"
/>
<button
type="button"
x-on:click="$wire.confirmRecurrence({{ $recurring->id }}, $refs.amountDesktop{{ $recurring->id }}.value)"
class="text-xs text-white bg-brand px-3 py-1.5 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
Confirmer
</button>
<button
type="button"
wire:click="dismissRecurrence({{ $recurring->id }})"
wire:confirm="Ignorer cette récurrence pour ce mois ?"
class="text-xs text-text-muted hover:underline rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
Ignorer
</button>
</div>
</li>
@endforeach
</ul>
</div>
@endif
<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>