This commit is contained in:
2026-04-11 12:12:07 +02:00
parent 3bc6e2e080
commit 5b3c5ebb2f
92 changed files with 10948 additions and 35 deletions
+471
View File
@@ -0,0 +1,471 @@
<script>
import { onMount } from 'svelte';
import { api, loadProperties, properties } from '$lib/stores/api.js';
import { TrendingUp, TrendingDown, CalendarDays, RefreshCw } from 'lucide-svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
let summary = [];
let recentTx = [];
let calEvents = [];
let monthlyData = [];
let categoryExpenses = [];
let loading = true;
let categoryView = 'chart'; // 'chart' | 'table'
let selectedYear = new Date().getFullYear();
let selectedProperty = '';
let selectedMonth = '';
const years = Array.from({ length: 4 }, (_, i) => new Date().getFullYear() - i);
const months = [
{ value: '', label: 'Tous les mois' },
{ value: '1', label: 'Janvier' },
{ value: '2', label: 'Février' },
{ value: '3', label: 'Mars' },
{ value: '4', label: 'Avril' },
{ value: '5', label: 'Mai' },
{ value: '6', label: 'Juin' },
{ value: '7', label: 'Juillet' },
{ value: '8', label: 'Août' },
{ value: '9', label: 'Septembre' },
{ value: '10', label: 'Octobre' },
{ value: '11', label: 'Novembre' },
{ value: '12', label: 'Décembre' },
];
// Refs canvas
let barCanvas, donutCanvas;
let barChart, donutChart;
const fmt = (n) => Number(n || 0).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €';
const fmtDate = (d) => new Date(d + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' });
const monthLabels = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'];
onMount(async () => {
await loadProperties();
await reload();
});
async function reload() {
loading = true;
const today = new Date();
const from = new Date(today.getFullYear(), today.getMonth(), 1).toISOString().slice(0,10);
const to = new Date(today.getFullYear(), today.getMonth()+1, 0).toISOString().slice(0,10);
const params = { year: selectedYear };
if (selectedProperty) params.property_id = selectedProperty;
if (selectedMonth) params.month = selectedMonth;
const txListParams = { year: selectedYear };
if (selectedProperty) txListParams.property_id = selectedProperty;
if (selectedMonth) txListParams.month = selectedMonth;
[summary, recentTx, calEvents, monthlyData, categoryExpenses] = await Promise.all([
api.transactions.summary(params),
api.transactions.list({ ...txListParams }),
api.calendar.list({ from, to, ...(selectedProperty ? { property_id: selectedProperty } : {}) }),
api.transactions.monthly({ year: selectedYear, ...(selectedProperty ? { property_id: selectedProperty } : {}) }),
api.transactions.categories({ ...params, type: 'expense' }),
]);
summary = summary || [];
recentTx = (recentTx || []).slice(0, 8);
calEvents = calEvents || [];
monthlyData = monthlyData || [];
categoryExpenses = categoryExpenses || [];
loading = false;
await renderCharts();
}
async function renderCharts() {
// Petit délai pour que les canvas soient dans le DOM
await new Promise(r => setTimeout(r, 50));
// ── Graphique barres : revenus vs dépenses par mois ──────────────────────
if (barCanvas) {
if (barChart) barChart.destroy();
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const gridColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)';
const textColor = isDark ? '#9ca3af' : '#6b7280';
barChart = new Chart(barCanvas, {
type: 'bar',
data: {
labels: monthLabels,
datasets: [
{
label: 'Revenus',
data: monthlyData.map(m => m.income),
backgroundColor: 'rgba(34,197,94,0.75)',
borderRadius: 5,
borderSkipped: false,
},
{
label: 'Dépenses',
data: monthlyData.map(m => m.expense),
backgroundColor: 'rgba(239,68,68,0.7)',
borderRadius: 5,
borderSkipped: false,
},
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: textColor, boxWidth: 12, font: { size: 12 } }
},
tooltip: {
callbacks: {
label: (ctx) => ` ${ctx.dataset.label} : ${Number(ctx.raw).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`
}
}
},
scales: {
x: {
grid: { color: gridColor },
ticks: { color: textColor, font: { size: 11 } }
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { size: 11 },
callback: (v) => v.toLocaleString('fr-FR') + ' €'
},
beginAtZero: true,
}
}
}
});
}
// ── Donut : répartition dépenses par catégorie ────────────────────────────
if (categoryView === 'chart' && donutCanvas && categoryExpenses.length > 0) {
if (donutChart) donutChart.destroy();
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const textColor = isDark ? '#9ca3af' : '#6b7280';
donutChart = new Chart(donutCanvas, {
type: 'doughnut',
data: {
labels: categoryExpenses.map(c => c.category),
datasets: [{
data: categoryExpenses.map(c => c.amount),
backgroundColor: palette.slice(0, categoryExpenses.length),
borderWidth: 2,
borderColor: isDark ? '#111827' : '#ffffff',
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
position: 'right',
labels: {
color: textColor,
boxWidth: 10,
font: { size: 11 },
padding: 10,
}
},
tooltip: {
callbacks: {
label: (ctx) => {
const total = ctx.dataset.data.reduce((a,b) => a+b, 0);
const pct = total > 0 ? ((ctx.raw / total) * 100).toFixed(1) : 0;
return ` ${Number(ctx.raw).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} € (${pct}%)`;
}
}
}
}
}
});
}
}
const palette = [
'#3b82f6','#ef4444','#f59e0b','#10b981','#8b5cf6',
'#ec4899','#06b6d4','#84cc16','#f97316','#6366f1',
];
$: totalIncome = summary.reduce((s, x) => s + (x.total_income || 0), 0);
$: totalExpense = summary.reduce((s, x) => s + (x.total_expense || 0), 0);
$: totalBalance = totalIncome - totalExpense;
$: totalCatExpense = categoryExpenses.reduce((s, c) => s + (c.amount || 0), 0);
// Calcul taux occupation mois en cours (Airbnb)
$: occupancyRate = (() => {
const today = new Date();
const days = new Date(today.getFullYear(), today.getMonth()+1, 0).getDate();
let occupied = 0;
for (let d = 1; d <= days; d++) {
const ds = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
if (calEvents.some(e => ds >= e.start_date && ds < e.end_date)) occupied++;
}
return days > 0 ? Math.round((occupied/days)*100) : 0;
})();
// Re-render donut when switching views
async function switchCategoryView(v) {
categoryView = v;
if (v === 'chart') {
await new Promise(r => setTimeout(r, 50));
await renderCharts();
}
}
$: periodLabel = selectedMonth
? `${months.find(m => m.value === selectedMonth)?.label} ${selectedYear}`
: String(selectedYear);
</script>
<div class="p-6 max-w-6xl mx-auto">
<!-- Header + filtres -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Dashboard</h1>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-0.5">Vue d'ensemble de vos locations</p>
</div>
<div class="flex items-center gap-3 flex-wrap">
<select bind:value={selectedProperty} on:change={reload}
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Tous les biens</option>
{#each $properties as p}<option value={p.id}>{p.name}</option>{/each}
</select>
<select bind:value={selectedYear} on:change={reload}
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
{#each years as y}<option value={y}>{y}</option>{/each}
</select>
<select bind:value={selectedMonth} on:change={reload}
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
{#each months as m}<option value={m.value}>{m.label}</option>{/each}
</select>
<button on:click={reload} class="p-2 rounded-lg border border-gray-200 dark:border-gray-700 text-gray-500 hover:text-blue-600 hover:border-blue-300 transition-colors">
<RefreshCw size={16} class={loading ? 'animate-spin' : ''}/>
</button>
</div>
</div>
{#if loading}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
{#each [1,2,3,4] as _}<div class="h-24 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>{/each}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
{#each [1,2,3] as _}<div class="h-64 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>{/each}
</div>
{:else}
<!-- KPIs -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-2">
<TrendingUp size={13} class="text-green-500"/> Revenus {periodLabel}
</div>
<p class="text-xl font-semibold text-green-600 dark:text-green-400">{fmt(totalIncome)}</p>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-2">
<TrendingDown size={13} class="text-red-500"/> Dépenses {periodLabel}
</div>
<p class="text-xl font-semibold text-red-500 dark:text-red-400">{fmt(totalExpense)}</p>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">Bénéfice net</div>
<p class="text-xl font-semibold {totalBalance >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}">{fmt(totalBalance)}</p>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-2">
<CalendarDays size={13}/> Occupation ce mois
</div>
<p class="text-xl font-semibold text-blue-600 dark:text-blue-400">{occupancyRate}%</p>
</div>
</div>
<!-- Graphiques ligne 1 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5 mb-5">
<!-- Barres revenus/dépenses -->
<div class="lg:col-span-2 bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">Revenus & dépenses par mois — {selectedYear}</h2>
<div style="height: 340px; position: relative;">
{#if monthlyData.every(m => m.income === 0 && m.expense === 0)}
<div class="flex items-center justify-center h-full text-sm text-gray-400">Aucune donnée pour {selectedYear}</div>
{:else}
<canvas bind:this={barCanvas}></canvas>
{/if}
</div>
</div>
<!-- Dépenses par catégorie : chart ou tableau -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Dépenses par catégorie</h2>
<div class="flex gap-1">
<button
on:click={() => switchCategoryView('chart')}
class="px-2 py-1 rounded text-xs transition-colors {categoryView === 'chart'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}">
Donut
</button>
<button
on:click={() => switchCategoryView('table')}
class="px-2 py-1 rounded text-xs transition-colors {categoryView === 'table'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}">
Tableau
</button>
</div>
</div>
{#if categoryExpenses.length === 0}
<div class="flex items-center justify-center h-48 text-sm text-gray-400">Aucune dépense</div>
{:else if categoryView === 'chart'}
<div style="height: 310px; position: relative;">
<canvas bind:this={donutCanvas}></canvas>
</div>
{:else}
<!-- Tableau catégories -->
<div class="overflow-y-auto" style="max-height: 310px;">
<table class="w-full text-xs">
<thead>
<tr class="text-gray-400 dark:text-gray-500 border-b border-gray-100 dark:border-gray-800">
<th class="text-left pb-2 font-medium">Catégorie</th>
<th class="text-right pb-2 font-medium">Montant</th>
<th class="text-right pb-2 font-medium">%</th>
</tr>
</thead>
<tbody>
{#each categoryExpenses as c, i}
<tr class="border-b border-gray-50 dark:border-gray-800 last:border-0">
<td class="py-2 pr-2">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background:{palette[i % palette.length]}"></span>
<span class="text-gray-700 dark:text-gray-300 truncate">{c.category}</span>
</div>
</td>
<td class="py-2 text-right font-medium text-red-500 dark:text-red-400 whitespace-nowrap">{fmt(c.amount)}</td>
<td class="py-2 text-right text-gray-400 whitespace-nowrap">
{totalCatExpense > 0 ? ((c.amount / totalCatExpense) * 100).toFixed(1) : 0}%
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="pt-2 text-gray-500 dark:text-gray-400 font-medium">Total</td>
<td class="pt-2 text-right font-semibold text-red-500 dark:text-red-400 whitespace-nowrap">{fmt(totalCatExpense)}</td>
<td class="pt-2 text-right text-gray-400">100%</td>
</tr>
</tfoot>
</table>
</div>
{/if}
</div>
</div>
<!-- Ligne 2 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mb-5">
<!-- Par bien -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-4">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Par bien — {periodLabel}</h2>
{#if summary.length === 0}
<p class="text-sm text-gray-400 text-center py-4">Aucune donnée</p>
{:else}
<div class="space-y-3">
{#each summary as s}
<div>
<div class="flex justify-between items-baseline mb-1.5">
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">{s.property_name}</span>
<span class="text-sm font-semibold {s.balance >= 0 ? 'text-green-600' : 'text-red-500'}">{fmt(s.balance)}</span>
</div>
<!-- Barre proportionnelle -->
<div class="h-2 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden flex gap-0.5">
{#if s.total_income > 0 || s.total_expense > 0}
{@const total = s.total_income + s.total_expense}
<div class="bg-green-400 dark:bg-green-500 transition-all"
style="width:{(s.total_income/total*100).toFixed(1)}%"/>
<div class="bg-red-400 dark:bg-red-500 transition-all"
style="width:{(s.total_expense/total*100).toFixed(1)}%"/>
{/if}
</div>
<div class="flex justify-between text-xs mt-1.5 text-gray-400">
<span class="text-green-600 dark:text-green-400">{fmt(s.total_income)}</span>
<span class="text-red-500 dark:text-red-400">{fmt(s.total_expense)}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Transactions récentes -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-4">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Dernières transactions</h2>
{#if recentTx.length === 0}
<p class="text-sm text-gray-400 text-center py-4">Aucune transaction</p>
{:else}
<div class="space-y-1">
{#each recentTx as t (t.id)}
<div class="flex items-center justify-between py-2 border-b border-gray-50 dark:border-gray-800 last:border-0">
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-800 dark:text-gray-200 truncate">{t.description || t.category_name || '—'}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{fmtDate(t.date)} · {t.property_name}</p>
</div>
<span class="text-sm font-semibold shrink-0 ml-4 {t.type === 'income' ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}">
{t.type === 'income' ? '+' : ''}{fmt(t.amount)}
</span>
</div>
{/each}
</div>
<a href="/transactions" class="block text-center text-xs text-blue-600 dark:text-blue-400 mt-3 hover:underline">
Voir toutes →
</a>
{/if}
</div>
</div>
<!-- Occupations ce mois -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-5">
<div class="flex items-center gap-2 mb-4">
<CalendarDays size={16} class="text-gray-400"/>
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Occupations ce mois</h2>
</div>
{#if calEvents.length === 0}
<p class="text-sm text-gray-400 text-center py-4">Aucune occupation ce mois-ci</p>
{:else}
<div class="divide-y divide-gray-50 dark:divide-gray-800">
{#each calEvents as e (e.id)}
<div class="flex items-center justify-between py-3">
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">
{e.title || (e.source === 'airbnb' ? 'Réservation Airbnb' : 'Occupation manuelle')}
</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{e.property_name}</p>
</div>
<div class="text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">{fmtDate(e.start_date)}{fmtDate(e.end_date)}</p>
<span class="text-xs px-2 py-0.5 rounded-full font-medium mt-1 inline-block
{e.source === 'airbnb'
? 'bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300'
: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300'}">
{e.source === 'airbnb' ? 'Airbnb' : 'Manuel'}
</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>