Files
suivicpt/resources/views/livewire/sankey/index.blade.php
T
jeremy bayseandClaude Sonnet 5 a77be2dfba Fix intermittent chart rendering on dashboard and flux
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>
2026-08-02 11:04:59 +02:00

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