Adds a "payé par" field to transactions and recurring transactions: either a household member or null (shared/common expense). No per- member account balances — this is purely a "who paid" label, the household's overall balance is unchanged and unaffected by any filtering. - paid_by_user_id nullable FK on transactions and recurring_transactions - Suivi: "payé par" selector on both entry forms (desktop dropdown, mobile buttons), a filter above the transaction table, a "payé par" column, and a summary tile showing this month's depenses grouped by payer (including Commun) - Recurring transactions carry paid_by_user_id onto the transaction they generate when confirmed - Running balance is computed from all transactions regardless of the payer filter, so it never gets skewed by the active filter 11 new Pest tests (assignment, common fallback, non-member rejection, filter isolation from the balance calc, per-payer summary, recurring propagation). 69/69 total pass, Pint clean, verified end-to-end on production data (desktop + mobile, test row created and removed). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
368 lines
22 KiB
PHP
368 lines
22 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>
|
||
|
||
@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)
|
||
@php
|
||
$isActive = (string) $categoryId === (string) $category->id;
|
||
$progress = $categoryProgress[$category->id] ?? null;
|
||
@endphp
|
||
<button
|
||
type="button"
|
||
wire:click="$set('categoryId', {{ $category->id }})"
|
||
aria-pressed="{{ $isActive ? 'true' : 'false' }}"
|
||
class="relative min-h-[52px] px-3 py-2 rounded-lg border text-sm font-medium text-left transition overflow-hidden focus:outline-none focus-visible:ring-2 {{ $meta['ring'] }}
|
||
{{ $isActive ? $meta['active'] : 'bg-surface border-border-strong text-text' }}"
|
||
>
|
||
@if ($progress !== null)
|
||
<span
|
||
aria-hidden="true"
|
||
class="absolute inset-y-0 left-0 {{ $isActive ? 'bg-white/25' : 'bg-text/10' }}"
|
||
style="width: {{ $progress['pct'] }}%"
|
||
></span>
|
||
@endif
|
||
<span class="relative flex flex-col gap-0.5">
|
||
<span class="flex items-center justify-between gap-2">
|
||
<span>{{ $category->name }}</span>
|
||
@if ($progress !== null)
|
||
<span class="text-xs font-normal {{ $isActive ? 'text-white/80' : 'text-text-muted' }}">{{ $progress['pct'] }}%</span>
|
||
@endif
|
||
</span>
|
||
@if ($progress !== null)
|
||
<span class="text-xs font-normal {{ $isActive ? 'text-white/70' : 'text-text-muted' }}">
|
||
{{ number_format($progress['remaining'], 0, ',', ' ') }} € restants
|
||
</span>
|
||
@endif
|
||
</span>
|
||
</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>
|
||
|
||
@if ($members->isNotEmpty())
|
||
<fieldset>
|
||
<legend class="text-sm font-medium text-text-muted mb-2">Payé par</legend>
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<button
|
||
type="button"
|
||
wire:click="$set('paidByUserId', '')"
|
||
aria-pressed="{{ $paidByUserId === '' ? 'true' : 'false' }}"
|
||
class="min-h-[44px] px-3 py-2 rounded-lg border text-sm font-medium transition focus:outline-none focus-visible:ring-2 ring-brand
|
||
{{ $paidByUserId === '' ? 'bg-brand text-white border-brand' : 'bg-surface border-border-strong text-text' }}"
|
||
>
|
||
Commun
|
||
</button>
|
||
@foreach ($members as $member)
|
||
<button
|
||
type="button"
|
||
wire:click="$set('paidByUserId', '{{ $member->id }}')"
|
||
aria-pressed="{{ (string) $paidByUserId === (string) $member->id ? 'true' : 'false' }}"
|
||
class="min-h-[44px] px-3 py-2 rounded-lg border text-sm font-medium transition focus:outline-none focus-visible:ring-2 ring-brand
|
||
{{ (string) $paidByUserId === (string) $member->id ? 'bg-brand text-white border-brand' : 'bg-surface border-border-strong text-text' }}"
|
||
>
|
||
{{ $member->name }}
|
||
</button>
|
||
@endforeach
|
||
</div>
|
||
</fieldset>
|
||
@endif
|
||
|
||
<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 ($paidBySummary->isNotEmpty())
|
||
<div class="bg-surface shadow rounded-lg p-4">
|
||
<p class="text-xs text-text-muted mb-2">Dépenses ce mois-ci, par personne</p>
|
||
<div class="flex flex-wrap gap-4">
|
||
@foreach ($paidBySummary as $entry)
|
||
<div>
|
||
<p class="text-xs text-text-muted">{{ $entry['label'] }}</p>
|
||
<p class="text-sm font-semibold text-negative">{{ number_format($entry['total'], 2, ',', ' ') }} €</p>
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
</div>
|
||
@endif
|
||
|
||
@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>
|
||
<x-input-label for="paidByUserId" value="Payé par" />
|
||
<select wire:model="paidByUserId" id="paidByUserId" 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="">Commun</option>
|
||
@foreach ($members as $member)
|
||
<option value="{{ $member->id }}">{{ $member->name }}</option>
|
||
@endforeach
|
||
</select>
|
||
</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="flex items-center gap-2">
|
||
<label for="filterPaidBy" class="text-xs text-text-muted">Payé par</label>
|
||
<select wire:model.live="filterPaidBy" id="filterPaidBy" class="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="">Tout le monde</option>
|
||
<option value="common">Commun</option>
|
||
@foreach ($members as $member)
|
||
<option value="{{ $member->id }}">{{ $member->name }}</option>
|
||
@endforeach
|
||
</select>
|
||
</div>
|
||
|
||
<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-left px-4 py-2">Payé par</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-text-muted">{{ $transaction->paidBy?->name ?? 'Commun' }}</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="9" class="px-4 py-3 text-sm text-text-muted">Aucun enregistrement.</td></tr>
|
||
@endforelse
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|