Files
suivicpt/resources/views/livewire/dashboard/index.blade.php
T
jeremy bayseandClaude Sonnet 5 d8dc57a5df Initial commit: SuiviCPT personal finance tracker
Laravel 11 + Livewire 3/Volt + Alpine.js + Tailwind app for household
budget planning, transaction tracking, and financial dashboards.

- Multi-tenant households with member invites
- Plan: monthly/yearly budget per category with overrides
- Suivi: transaction log with running balance (desktop + mobile quick-entry)
- Dashboard: budget vs tracked breakdown + Chart.js donuts
- Flux: Sankey diagram of income allocation
- WCAG 2.1 AA color tokens, dark mode, accessible tables/forms
- 50 Pest tests

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:39:42 +02:00

181 lines
11 KiB
PHP

<div class="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-xl font-semibold text-text">Tableau de bord</h1>
<div class="flex items-center gap-3">
<div>
<label for="dashboard-year" class="text-xs text-text-muted">Année</label>
<select id="dashboard-year" wire:model.live="year" class="block rounded-md bg-surface border-border-strong text-text text-sm 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="text-xs text-text-muted">Période</label>
<select id="dashboard-period" wire:model.live="period" class="block 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="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>
@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-lg overflow-hidden">
<div class="px-4 py-2 bg-text">
<h2 class="text-surface font-semibold text-sm">Analyse</h2>
</div>
<div class="divide-y divide-border">
@foreach ($types as $type)
@php $section = $sections[$type->value]; @endphp
<div>
<div class="{{ $colors[$type->value]['bg'] }} px-4 py-1.5">
<h3 class="text-white text-sm font-medium">{{ $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-lg p-4">
<h3 class="text-sm font-medium mb-2 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() {
// 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>