window.Chart is set by app.js, a deferred module script whose execution isn't ordered relative to Livewire's own script that fires alpine:init. When Livewire won the race, Alpine's x-init called new Chart() before window.Chart existed, silently failing with no console output and leaving the canvas blank. Retry init() until window.Chart is available instead of assuming it's ready. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
140 lines
7.1 KiB
PHP
140 lines
7.1 KiB
PHP
<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-3xl font-display text-text">Répartition des flux</h1>
|
|
<div class="flex items-center gap-2">
|
|
<div>
|
|
<label for="sankey-year" class="sr-only">Année</label>
|
|
<select id="sankey-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="sankey-period" class="sr-only">Période</label>
|
|
<select id="sankey-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 p-6">
|
|
@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() {
|
|
// 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;
|
|
}
|
|
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
|