472 lines
20 KiB
Svelte
472 lines
20 KiB
Svelte
<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 && (e.source === 'airbnb' ? ds < e.end_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>
|