Add budget progress gauge inside mobile category buttons
Each depense/epargne category button on the mobile quick-entry form now shows a fill bar plus percentage of its monthly budget already tracked, so the user sees at a glance which categories are close to their limit while picking one. Categories without a budget defined in Plan show no gauge (avoids a misleading 0%). Capped at 100% — overspend detail already lives on the dashboard's "Manque" column. 3 new Pest tests for the percentage calculation (normal, capped, no-budget cases). 61/61 tests pass, Pint clean, verified on mobile against production data. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5861fc49fc
commit
613220f43a
@@ -6,6 +6,7 @@ namespace App\Livewire\Suivi;
|
|||||||
|
|
||||||
use App\Enums\CategoryType;
|
use App\Enums\CategoryType;
|
||||||
use App\Models\BudgetLine;
|
use App\Models\BudgetLine;
|
||||||
|
use App\Models\Category;
|
||||||
use App\Models\RecurringTransaction;
|
use App\Models\RecurringTransaction;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
@@ -108,6 +109,32 @@ class Index extends Component
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Budget planifié pour cette catégorie sur le mois en cours, en résolvant
|
||||||
|
* l'override du mois s'il existe sinon le montant par défaut de l'année.
|
||||||
|
*/
|
||||||
|
private function budgetForCurrentMonth(Category $category, int $year, int $month): ?float
|
||||||
|
{
|
||||||
|
$lines = $category->budgetLines()->where('year', $year)->get();
|
||||||
|
|
||||||
|
$override = $lines->firstWhere('month', $month);
|
||||||
|
if ($override) {
|
||||||
|
return (float) $override->amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
$default = $lines->firstWhere('month', null);
|
||||||
|
|
||||||
|
return $default ? (float) $default->amount : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function trackedForCategoryThisMonth(Category $category, int $year, int $month): float
|
||||||
|
{
|
||||||
|
return (float) $category->transactions()
|
||||||
|
->whereYear('date_effective', $year)
|
||||||
|
->whereMonth('date_effective', $month)
|
||||||
|
->sum('amount');
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$household = auth()->user()->currentHousehold;
|
$household = auth()->user()->currentHousehold;
|
||||||
@@ -159,10 +186,27 @@ class Index extends Component
|
|||||||
->sortBy('day_of_month')
|
->sortBy('day_of_month')
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
|
$categories = $household->categories()->orderBy('type')->orderBy('position')->get();
|
||||||
|
|
||||||
|
$categoryProgress = $categories
|
||||||
|
->whereIn('type', [CategoryType::Depense, CategoryType::Epargne])
|
||||||
|
->mapWithKeys(function (Category $category) use ($year, $month) {
|
||||||
|
$budget = $this->budgetForCurrentMonth($category, $year, $month);
|
||||||
|
|
||||||
|
if ($budget === null || $budget <= 0) {
|
||||||
|
return [$category->id => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracked = $this->trackedForCategoryThisMonth($category, $year, $month);
|
||||||
|
|
||||||
|
return [$category->id => min(100, round($tracked / $budget * 100))];
|
||||||
|
});
|
||||||
|
|
||||||
return view('livewire.suivi.index', [
|
return view('livewire.suivi.index', [
|
||||||
'transactions' => $transactions,
|
'transactions' => $transactions,
|
||||||
'running' => $running,
|
'running' => $running,
|
||||||
'categories' => $household->categories()->orderBy('type')->orderBy('position')->get(),
|
'categories' => $categories,
|
||||||
|
'categoryProgress' => $categoryProgress,
|
||||||
'nbrThisYear' => $transactions->filter(fn (Transaction $t) => $t->date->year === $year)->count(),
|
'nbrThisYear' => $transactions->filter(fn (Transaction $t) => $t->date->year === $year)->count(),
|
||||||
'nbrTotal' => $transactions->count(),
|
'nbrTotal' => $transactions->count(),
|
||||||
'lastEntry' => $transactions->first(),
|
'lastEntry' => $transactions->first(),
|
||||||
|
|||||||
@@ -55,14 +55,30 @@
|
|||||||
<p class="text-xs text-text-muted mb-1.5">{{ $meta['label'] }}</p>
|
<p class="text-xs text-text-muted mb-1.5">{{ $meta['label'] }}</p>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
@foreach ($typeCategories as $category)
|
@foreach ($typeCategories as $category)
|
||||||
|
@php
|
||||||
|
$isActive = (string) $categoryId === (string) $category->id;
|
||||||
|
$progress = $categoryProgress[$category->id] ?? null;
|
||||||
|
@endphp
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
wire:click="$set('categoryId', {{ $category->id }})"
|
wire:click="$set('categoryId', {{ $category->id }})"
|
||||||
aria-pressed="{{ (string) $categoryId === (string) $category->id ? 'true' : 'false' }}"
|
aria-pressed="{{ $isActive ? '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'] }}
|
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'] }}
|
||||||
{{ (string) $categoryId === (string) $category->id ? $meta['active'] : 'bg-surface border-border-strong text-text' }}"
|
{{ $isActive ? $meta['active'] : 'bg-surface border-border-strong text-text' }}"
|
||||||
>
|
>
|
||||||
{{ $category->name }}
|
@if ($progress !== null)
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="absolute inset-y-0 left-0 {{ $isActive ? 'bg-white/25' : 'bg-text/10' }}"
|
||||||
|
style="width: {{ $progress }}%"
|
||||||
|
></span>
|
||||||
|
@endif
|
||||||
|
<span class="relative 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 }}%</span>
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -184,6 +184,54 @@ it('shows the recurrence again the following month after being confirmed', funct
|
|||||||
expect($component->viewData('pendingRecurrences')->pluck('id'))->toContain($recurring->id);
|
expect($component->viewData('pendingRecurrences')->pluck('id'))->toContain($recurring->id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('computes the percentage of budget already tracked for a category this month', function (): void {
|
||||||
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$household = $user->currentHousehold;
|
||||||
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||||
|
|
||||||
|
$category->budgetLines()->create(['household_id' => $household->id, 'year' => 2026, 'month' => null, 'amount' => 1000]);
|
||||||
|
$household->transactions()->create([
|
||||||
|
'category_id' => $category->id, 'user_id' => $user->id,
|
||||||
|
'date' => '2026-03-05', 'date_effective' => '2026-03-05', 'amount' => 600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(Index::class);
|
||||||
|
|
||||||
|
expect($component->viewData('categoryProgress')[$category->id])->toBe(60.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the category progress percentage at 100', function (): void {
|
||||||
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$household = $user->currentHousehold;
|
||||||
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||||
|
|
||||||
|
$category->budgetLines()->create(['household_id' => $household->id, 'year' => 2026, 'month' => null, 'amount' => 1000]);
|
||||||
|
$household->transactions()->create([
|
||||||
|
'category_id' => $category->id, 'user_id' => $user->id,
|
||||||
|
'date' => '2026-03-05', 'date_effective' => '2026-03-05', 'amount' => 1500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(Index::class);
|
||||||
|
|
||||||
|
expect($component->viewData('categoryProgress')[$category->id])->toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no progress for a category without a budget defined', function (): void {
|
||||||
|
$this->travelTo(now()->setDate(2026, 3, 10));
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$household = $user->currentHousehold;
|
||||||
|
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(Index::class);
|
||||||
|
|
||||||
|
expect($component->viewData('categoryProgress')[$category->id])->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not show another household transactions', function (): void {
|
it('does not show another household transactions', function (): void {
|
||||||
$ownerA = User::factory()->create();
|
$ownerA = User::factory()->create();
|
||||||
$ownerB = User::factory()->create();
|
$ownerB = User::factory()->create();
|
||||||
|
|||||||
Reference in New Issue
Block a user