Files
RentalManager/frontend/src/routes/+page.svelte
T
2026-04-19 12:59:18 +02:00

472 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>