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>
257 lines
16 KiB
PHP
257 lines
16 KiB
PHP
@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">
|
|
<div>
|
|
<label for="dashboard-year" class="sr-only">Année</label>
|
|
<select id="dashboard-year" wire:model.live="year" class="block rounded-full bg-surface border-border-strong text-text text-sm px-4 py-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">
|
|
@foreach (range($year - 3, $year + 3) as $y)
|
|
<option value="{{ $y }}">{{ $y }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="dashboard-period" class="sr-only">Période</label>
|
|
<select id="dashboard-period" wire:model.live="period" class="block rounded-full bg-surface border-border-strong text-text text-sm px-4 py-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">
|
|
<option value="year">Année complète</option>
|
|
@foreach (['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'] as $i => $label)
|
|
<option value="{{ $i + 1 }}">{{ $label }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-surface shadow rounded-3xl px-6 py-5 inline-flex flex-col gap-1">
|
|
<p class="text-xs text-text-muted uppercase tracking-wide">Reste à dépenser</p>
|
|
<p class="font-display text-2xl text-negative">{{ number_format($sections['depense']['totals']['reste'], 2, ',', ' ') }} €</p>
|
|
</div>
|
|
|
|
<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">
|
|
<h2 class="font-semibold text-base text-text">Analyse</h2>
|
|
</div>
|
|
<div class="divide-y divide-border">
|
|
@foreach ($types as $type)
|
|
@php $section = $sections[$type->value]; @endphp
|
|
<div class="px-5 pb-5">
|
|
<div class="{{ $colors[$type->value]['bg'] }} px-4 py-2 rounded-2xl mb-2">
|
|
<h3 class="text-white text-sm font-bold">{{ $type->label() }}</h3>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full text-sm">
|
|
<caption class="sr-only">Suivi budgétaire {{ Str::lower($type->label()) }}</caption>
|
|
<thead>
|
|
<tr class="text-xs text-text-muted">
|
|
<th scope="col" class="text-left px-4 py-1.5">Catégorie</th>
|
|
<th scope="col" class="text-right px-2 py-1.5">Tracked</th>
|
|
<th scope="col" class="text-right px-2 py-1.5">Budget</th>
|
|
<th scope="col" class="text-center px-2 py-1.5">% Compl.</th>
|
|
<th scope="col" class="text-right px-2 py-1.5">Reste</th>
|
|
<th scope="col" class="text-right px-2 py-1.5">Manque</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach ($section['rows'] as $row)
|
|
<tr class="border-t border-border/60 text-text">
|
|
<th scope="row" class="px-4 py-1.5 whitespace-nowrap font-normal text-left">{{ $row['category']->name }}</th>
|
|
<td class="px-2 py-1.5 text-right">{{ number_format($row['tracked'], 2, ',', ' ') }} €</td>
|
|
<td class="px-2 py-1.5 text-right">{{ number_format($row['budget'], 2, ',', ' ') }} €</td>
|
|
<td class="px-2 py-1.5">
|
|
<div class="flex items-center gap-1">
|
|
<div class="w-10 h-1.5 bg-surface-alt rounded-full overflow-hidden" role="img" aria-label="{{ $row['pct'] }}% du budget utilisé">
|
|
<div class="h-full {{ $colors[$type->value]['bg'] }}" style="width: {{ $row['pct'] }}%"></div>
|
|
</div>
|
|
<span class="text-xs">{{ $row['pct'] }}%</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-2 py-1.5 text-right">{{ number_format($row['reste'], 2, ',', ' ') }} €</td>
|
|
<td class="px-2 py-1.5 text-right {{ $row['manque'] > 0 ? 'text-danger font-semibold' : '' }}">
|
|
@if ($row['manque'] > 0)
|
|
<span aria-hidden="true">⚠ </span><span class="sr-only">Dépassement : </span>
|
|
@endif
|
|
{{ number_format($row['manque'], 2, ',', ' ') }} €
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
<tr class="border-t border-border font-semibold {{ $colors[$type->value]['row'] }} text-text">
|
|
<th scope="row" class="px-4 py-1.5 font-semibold text-left">Total</th>
|
|
<td class="px-2 py-1.5 text-right">{{ number_format($section['totals']['tracked'], 2, ',', ' ') }} €</td>
|
|
<td class="px-2 py-1.5 text-right">{{ number_format($section['totals']['budget'], 2, ',', ' ') }} €</td>
|
|
<td class="px-2 py-1.5"></td>
|
|
<td class="px-2 py-1.5 text-right">{{ number_format($section['totals']['reste'], 2, ',', ' ') }} €</td>
|
|
<td class="px-2 py-1.5 text-right">{{ number_format($section['totals']['manque'], 2, ',', ' ') }} €</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-6">
|
|
@foreach ($types as $type)
|
|
@php $top = $chartData[$type->value]; @endphp
|
|
<div class="bg-surface shadow rounded-3xl p-5">
|
|
<h3 class="text-sm font-semibold mb-4 text-text">Catégorie de <span class="{{ $type->value === 'revenu' ? 'text-positive' : ($type->value === 'depense' ? 'text-negative' : 'text-info') }}">{{ Str::lower($type->label()) }}</span> (suivis)</h3>
|
|
|
|
@if ($top['labels']->isEmpty())
|
|
<p class="text-xs text-text-muted py-8 text-center">Aucun montant suivi.</p>
|
|
@else
|
|
<div
|
|
wire:ignore
|
|
x-data="donutChart(@js($top['labels']), @js($top['data']), @js($chartPalettes[$type->value]))"
|
|
x-init="init()"
|
|
wire:key="chart-{{ $type->value }}-{{ $year }}-{{ $period }}"
|
|
class="flex items-center gap-4"
|
|
>
|
|
<div class="w-32 h-32 shrink-0">
|
|
<canvas x-ref="canvas" role="img" aria-label="Répartition {{ Str::lower($type->label()) }} par catégorie"></canvas>
|
|
</div>
|
|
<ul class="text-xs space-y-1 flex-1">
|
|
@foreach ($top['labels'] as $i => $label)
|
|
<li class="flex items-center justify-between gap-2 text-text">
|
|
<span class="flex items-center gap-1.5">
|
|
<span class="inline-block w-2.5 h-2.5 rounded-full ring-1 ring-border-strong" style="background-color: {{ $chartPalettes[$type->value][$i % 6] }}"></span>
|
|
{{ $label }}
|
|
</span>
|
|
<span>{{ number_format($top['data'][$i], 2, ',', ' ') }} €</span>
|
|
</li>
|
|
@endforeach
|
|
</ul>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="mt-2 pt-2 border-t border-border flex justify-between text-xs font-semibold text-text">
|
|
<span>Total</span>
|
|
<span>{{ number_format($sections[$type->value]['totals']['tracked'], 2, ',', ' ') }} €</span>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@once
|
|
@push('scripts')
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('donutChart', (labels, data, palette) => ({
|
|
chart: null,
|
|
init() {
|
|
// window.Chart comes from a separately deferred module script
|
|
// (app.js) that isn't guaranteed to have run yet when Alpine
|
|
// boots, so wait for it instead of racing.
|
|
if (typeof window.Chart === 'undefined') {
|
|
setTimeout(() => this.init(), 20);
|
|
return;
|
|
}
|
|
// Visible stroke between slices so adjacent segments stay
|
|
// distinguishable even when their fill colors are close (WCAG 1.4.11).
|
|
const surface = getComputedStyle(document.documentElement).getPropertyValue('--color-surface').trim();
|
|
this.chart = new Chart(this.$refs.canvas, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
data: data,
|
|
backgroundColor: labels.map((_, i) => palette[i % palette.length]),
|
|
borderColor: `rgb(${surface})`,
|
|
borderWidth: 2,
|
|
}],
|
|
},
|
|
options: {
|
|
plugins: { legend: { display: false } },
|
|
cutout: '70%',
|
|
},
|
|
});
|
|
},
|
|
}));
|
|
});
|
|
</script>
|
|
@endpush
|
|
@endonce
|
|
</div>
|