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>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
<div class="max-w-5xl 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">Répartition des flux</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<label for="sankey-year" class="text-xs text-text-muted">Année</label>
|
||||
<select id="sankey-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="sankey-period" class="text-xs text-text-muted">Période</label>
|
||||
<select id="sankey-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>
|
||||
|
||||
<div class="bg-surface shadow rounded-lg p-4">
|
||||
@if (! $hasData)
|
||||
<p class="text-sm text-text-muted py-12 text-center">Aucun revenu suivi sur cette période.</p>
|
||||
@else
|
||||
<div
|
||||
wire:ignore
|
||||
x-data="sankeyChart(@js($flows))"
|
||||
x-init="init()"
|
||||
wire:key="sankey-{{ $year }}-{{ $period }}"
|
||||
class="h-[420px] sm:h-[520px]"
|
||||
>
|
||||
<canvas x-ref="canvas" role="img" aria-label="Diagramme de flux : répartition des revenus vers les dépenses et l'épargne"></canvas>
|
||||
</div>
|
||||
|
||||
<table class="sr-only">
|
||||
<caption>Répartition chiffrée des revenus vers les dépenses et l'épargne</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Catégorie</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Montant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($flows as $flow)
|
||||
<tr>
|
||||
<td>{{ $flow['to'] }}</td>
|
||||
<td>{{ $flow['type'] === 'depense' ? 'Dépense' : ($flow['type'] === 'epargne' ? 'Épargne' : 'Non affecté') }}</td>
|
||||
<td>{{ number_format($flow['flow'], 2, ',', ' ') }} €</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-border grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-text-muted">Revenus</p>
|
||||
<p class="font-semibold text-positive">{{ number_format($totalRevenue, 2, ',', ' ') }} €</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-muted">Dépenses + épargne</p>
|
||||
<p class="font-semibold text-text">{{ number_format($totalOutflow, 2, ',', ' ') }} €</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-muted">Non affecté</p>
|
||||
<p class="font-semibold {{ $unallocated > 0 ? 'text-info' : 'text-text' }}">{{ number_format($unallocated, 2, ',', ' ') }} €</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@once
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('sankeyChart', (flows) => ({
|
||||
chart: null,
|
||||
init() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const surface = `rgb(${style.getPropertyValue('--color-surface').trim()})`;
|
||||
const text = `rgb(${style.getPropertyValue('--color-text').trim()})`;
|
||||
const colors = {
|
||||
depense: `rgb(${style.getPropertyValue('--color-negative-fill').trim()})`,
|
||||
epargne: `rgb(${style.getPropertyValue('--color-info-fill').trim()})`,
|
||||
unallocated: `rgb(${style.getPropertyValue('--color-border-strong').trim()})`,
|
||||
};
|
||||
const nodeColor = `rgb(${style.getPropertyValue('--color-brand').trim()})`;
|
||||
|
||||
this.chart = new Chart(this.$refs.canvas, {
|
||||
type: 'sankey',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: flows.map(f => ({ from: f.from, to: f.to, flow: f.flow })),
|
||||
colorFrom: () => nodeColor,
|
||||
colorTo: (ctx) => {
|
||||
const flow = flows[ctx.dataIndex];
|
||||
return flow ? colors[flow.type] : nodeColor;
|
||||
},
|
||||
colorMode: 'gradient',
|
||||
borderColor: surface,
|
||||
borderWidth: 1,
|
||||
color: text,
|
||||
font: { size: 12 },
|
||||
labels: Object.fromEntries(flows.map(f => [f.to, f.to])),
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
const d = ctx.raw;
|
||||
return `${d.from} → ${d.to}: ${d.flow.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@endonce
|
||||
Reference in New Issue
Block a user