Add mobile bottom tab bar and redesign mobile dashboard

Import the Finlio Mobile design: a fixed bottom tab bar
(Accueil/Plan/Suivi/Flux) replaces the duplicated links in the mobile
hamburger menu, and the dashboard gets a mobile-first layout — hero
"reste à dépenser" card, revenus/épargne tiles, and a spending-by-
category list with progress bars — built from the same $sections data
the desktop tables use. Desktop dashboard is untouched.

The hero card uses a fixed dark background (independent of the
light/dark token swap) so its white text holds contrast in both
themes, matching how the existing -fill tokens are documented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jeremy bayse
2026-08-02 11:16:44 +02:00
co-authored by Claude Sonnet 5
parent a77be2dfba
commit d97cc41ee2
5 changed files with 142 additions and 40 deletions
@@ -0,0 +1,41 @@
@php
$tabs = [
['route' => 'dashboard', 'label' => 'Accueil', 'icon' => 'home'],
['route' => 'plan', 'label' => 'Plan', 'icon' => 'grid'],
['route' => 'suivi', 'label' => 'Suivi', 'icon' => 'list'],
['route' => 'sankey', 'label' => 'Flux', 'icon' => 'chart'],
];
@endphp
<nav aria-label="Navigation principale" class="sm:hidden fixed inset-x-0 bottom-0 z-40 pb-[max(env(safe-area-inset-bottom),8px)] pt-2 px-4 bg-surface/90 backdrop-blur border-t border-border">
<div class="flex justify-around">
@foreach ($tabs as $tab)
@continue (! Route::has($tab['route']))
@php $active = request()->routeIs($tab['route']); @endphp
<a
href="{{ route($tab['route']) }}"
wire:navigate
aria-current="{{ $active ? 'page' : 'false' }}"
class="flex flex-col items-center gap-0.5 px-3 py-1 rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {{ $active ? 'text-brand' : 'text-text-muted' }}"
>
<span class="w-6 h-6 flex items-center justify-center rounded-lg {{ $active ? 'bg-brand text-surface' : '' }}">
@switch($tab['icon'])
@case('home')
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" aria-hidden="true"><path d="M3 11l9-8 9 8" /><path d="M5 10v10h14V10" /></svg>
@break
@case('grid')
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></svg>
@break
@case('list')
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" aria-hidden="true"><path d="M8 6h13M8 12h13M8 18h13" /><path d="M3 6h.01M3 12h.01M3 18h.01" /></svg>
@break
@case('chart')
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" aria-hidden="true"><path d="M3 3v18h18" /><path d="M7 15l4-6 4 3 5-8" /></svg>
@break
@endswitch
</span>
<span class="text-[10px] font-semibold leading-none">{{ $tab['label'] }}</span>
</a>
@endforeach
</div>
</nav>
+3 -1
View File
@@ -19,7 +19,7 @@
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased bg-surface-alt text-text">
<div class="min-h-screen bg-surface-alt pb-12">
<div class="min-h-screen bg-surface-alt pb-24 sm:pb-12">
<div class="px-4 pt-4 sm:px-6 lg:px-8">
<livewire:layout.navigation />
</div>
@@ -39,6 +39,8 @@
</main>
</div>
<x-bottom-tab-bar />
@stack('scripts')
</body>
</html>
@@ -1,4 +1,84 @@
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 space-y-6">
@php
$colors = [
'revenu' => ['bg' => 'bg-positive-fill', 'row' => 'bg-positive-surface/40'],
'depense' => ['bg' => 'bg-negative-fill', 'row' => 'bg-negative-surface/40'],
'epargne' => ['bg' => 'bg-info-fill', 'row' => 'bg-info-surface/40'],
];
// Ordered to alternate light/dark shades so adjacent donut slices stay distinguishable
// even without relying on the border stroke (WCAG 1.4.11 graphical-object contrast).
$chartPalettes = [
'revenu' => ['#15803d', '#4ade80', '#166534', '#86efac', '#16a34a', '#22c55e'],
'depense' => ['#991b1b', '#fb923c', '#c2410c', '#fdba74', '#dc2626', '#f97316'],
'epargne' => ['#1e40af', '#93c5fd', '#1d4ed8', '#bfdbfe', '#2563eb', '#60a5fa'],
];
// Mobile category dots cycle through a same-hue tonal ramp so adjacent rows
// stay distinguishable (WCAG 1.4.11), independent of the desktop chart palette.
$mobileDotRamp = ['#8c491a', '#b2622d', '#d67f48', '#f6a06b'];
$depenseRows = $sections['depense']['rows']->sortByDesc('tracked')->values();
@endphp
<div>
{{-- Mobile: hero "reste à dépenser" card + tiles + spending-by-category list --}}
<div class="sm:hidden max-w-md mx-auto px-4 py-6 space-y-5">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-display text-text">Bonjour {{ Str::of(auth()->user()->name)->before(' ') }}</h1>
<span class="w-9 h-9 rounded-full bg-positive-fill text-surface flex items-center justify-center text-xs font-bold shrink-0">
{{ Str::of(auth()->user()->name)->explode(' ')->map(fn ($p) => mb_substr($p, 0, 1))->take(2)->implode('') }}
</span>
</div>
<div class="rounded-3xl shadow p-5 flex flex-col gap-2 text-white" style="background-color: #643312">
<span class="text-xs uppercase tracking-wide text-white/75">Reste à dépenser · {{ \Illuminate\Support\Carbon::create($year, (int) ($period === 'year' ? now()->month : $period))->translatedFormat('F') }}</span>
<span class="font-display text-3xl">{{ number_format($sections['depense']['totals']['reste'], 2, ',', ' ') }} </span>
@php
$depTotals = $sections['depense']['totals'];
$spentPct = $depTotals['budget'] > 0 ? min(100, round($depTotals['tracked'] / $depTotals['budget'] * 100)) : 0;
@endphp
<div class="h-2 rounded-full bg-white/20 overflow-hidden" role="img" aria-label="{{ $spentPct }}% du budget dépenses utilisé">
<div class="h-full rounded-full bg-white/80" style="width: {{ $spentPct }}%"></div>
</div>
<span class="text-xs text-white/70">{{ number_format($depTotals['tracked'], 2, ',', ' ') }} dépensés sur {{ number_format($depTotals['budget'], 2, ',', ' ') }} prévus</span>
</div>
<div class="flex gap-2.5">
<div class="flex-1 rounded-2xl shadow-sm p-4 flex flex-col gap-1 bg-positive-surface">
<span class="text-[10px] uppercase tracking-wide text-positive">Revenus</span>
<span class="font-display text-lg text-positive">{{ number_format($sections['revenu']['totals']['tracked'], 2, ',', ' ') }} </span>
</div>
<div class="flex-1 rounded-2xl shadow-sm p-4 flex flex-col gap-1 bg-surface">
<span class="text-[10px] uppercase tracking-wide text-text-muted">Épargne</span>
<span class="font-display text-lg text-text">{{ number_format($sections['epargne']['totals']['tracked'], 2, ',', ' ') }} </span>
</div>
</div>
<div class="flex items-center justify-between">
<h4 class="text-base font-semibold text-text">Dépenses par catégorie</h4>
</div>
@if ($depenseRows->isEmpty())
<p class="text-xs text-text-muted py-8 text-center">Aucune catégorie de dépenses.</p>
@else
<div class="flex flex-col gap-2.5">
@foreach ($depenseRows as $i => $row)
<div class="rounded-2xl shadow-sm p-3 flex items-center gap-3 bg-surface">
<span class="w-10 h-10 rounded-full shrink-0" style="background-color: {{ $mobileDotRamp[$i % count($mobileDotRamp)] }}" aria-hidden="true"></span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-sm text-text truncate">{{ $row['category']->name }}</div>
<div class="h-1.5 rounded-full bg-surface-alt overflow-hidden mt-1.5" role="img" aria-label="{{ $row['pct'] }}% du budget utilisé">
<div class="h-full rounded-full" style="width: {{ $row['pct'] }}%; background-color: {{ $mobileDotRamp[$i % count($mobileDotRamp)] }}"></div>
</div>
</div>
<span class="font-bold text-sm text-text shrink-0">{{ number_format($row['tracked'], 0, ',', ' ') }} </span>
</div>
@endforeach
</div>
@endif
</div>
{{-- Desktop / tablet: full analysis tables + donut charts --}}
<div class="hidden sm:block max-w-7xl mx-auto py-6 px-4 sm:px-6 space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-3xl font-display text-text">Tableau de bord</h1>
<div class="flex items-center gap-2">
@@ -27,22 +107,6 @@
<p class="font-display text-2xl text-negative">{{ number_format($sections['depense']['totals']['reste'], 2, ',', ' ') }} </p>
</div>
@php
$colors = [
'revenu' => ['bg' => 'bg-positive-fill', 'row' => 'bg-positive-surface/40'],
'depense' => ['bg' => 'bg-negative-fill', 'row' => 'bg-negative-surface/40'],
'epargne' => ['bg' => 'bg-info-fill', 'row' => 'bg-info-surface/40'],
];
// Ordered to alternate light/dark shades so adjacent donut slices stay distinguishable
// even without relying on the border stroke (WCAG 1.4.11 graphical-object contrast).
$chartPalettes = [
'revenu' => ['#15803d', '#4ade80', '#166534', '#86efac', '#16a34a', '#22c55e'],
'depense' => ['#991b1b', '#fb923c', '#c2410c', '#fdba74', '#dc2626', '#f97316'],
'epargne' => ['#1e40af', '#93c5fd', '#1d4ed8', '#bfdbfe', '#2563eb', '#60a5fa'],
];
@endphp
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-surface shadow rounded-3xl overflow-hidden">
<div class="px-5 pt-4 pb-1">
@@ -116,31 +116,12 @@ new class extends Component
</div>
</div>
{{-- Primary routes (Tableau de bord/Plan/Suivi/Flux) live in the bottom tab
bar on mobile now this menu only needs account/settings links. --}}
<!-- Responsive Navigation Menu -->
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden mt-2 bg-surface rounded-3xl shadow-sm px-2">
<div class="pt-2 pb-3 space-y-1">
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')" wire:navigate>
{{ __('Tableau de bord') }}
</x-responsive-nav-link>
@if (Route::has('plan'))
<x-responsive-nav-link :href="route('plan')" :active="request()->routeIs('plan')" wire:navigate>
{{ __('Plan') }}
</x-responsive-nav-link>
@endif
@if (Route::has('suivi'))
<x-responsive-nav-link :href="route('suivi')" :active="request()->routeIs('suivi')" wire:navigate>
{{ __('Suivi') }}
</x-responsive-nav-link>
@endif
@if (Route::has('sankey'))
<x-responsive-nav-link :href="route('sankey')" :active="request()->routeIs('sankey')" wire:navigate>
{{ __('Flux') }}
</x-responsive-nav-link>
@endif
</div>
<!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-border">
<div class="pt-2 pb-1">
<div class="px-4">
<div class="font-medium text-base text-text" x-data="{{ json_encode(['name' => auth()->user()->name]) }}" x-text="name" x-on:profile-updated.window="name = $event.detail.name"></div>
<div class="font-medium text-sm text-text-muted">{{ auth()->user()->email }}</div>