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

2522
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "rental-manager-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^3.1.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"svelte": "^4.2.19",
"tailwindcss": "^3.3.6",
"vite": "^5.0.3"
},
"dependencies": {
"chart.js": "^4.4.0",
"lucide-svelte": "^0.303.0"
},
"overrides": {
"svelte": "^4.2.19"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

8
frontend/src/app.css Normal file
View File

@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html { font-family: 'Inter', system-ui, sans-serif; }
* { box-sizing: border-box; }
}

13
frontend/src/app.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rental Manager</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,112 @@
import { writable, get } from 'svelte/store';
export const currentUser = writable(null);
export const authToken = writable(null);
const BASE = '/api';
async function request(method, path, body, isFormData = false) {
const token = get(authToken);
const headers = {};
if (token) headers['Authorization'] = token;
if (body && !isFormData) headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: isFormData ? body : body ? JSON.stringify(body) : undefined,
credentials: 'include',
});
if (res.status === 401) {
currentUser.set(null);
authToken.set(null);
window.location.href = '/login';
return;
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
if (res.status === 204) return null;
return res.json();
}
export const api = {
auth: {
login: (email, password) => request('POST', '/auth/login', { email, password }),
logout: () => request('POST', '/auth/logout'),
register: (data) => request('POST', '/auth/register', data),
me: () => request('GET', '/me'),
updateProfile: (data) => request('PUT', '/me', data),
updatePassword: (data) => request('PUT', '/me/password', data),
},
users: {
list: () => request('GET', '/users'),
delete: (id) => request('DELETE', `/users/${id}`),
},
categories: {
list: (params = {}) => request('GET', `/categories?${new URLSearchParams(params)}`),
create: (data) => request('POST', '/categories', data),
update: (id, data) => request('PUT', `/categories/${id}`, data),
delete: (id) => request('DELETE', `/categories/${id}`),
},
properties: {
list: () => request('GET', '/properties'),
get: (id) => request('GET', `/properties/${id}`),
create: (data) => request('POST', '/properties', data),
update: (id, data) => request('PUT', `/properties/${id}`, data),
delete: (id) => request('DELETE', `/properties/${id}`),
},
transactions: {
list: (params = {}) => request('GET', `/transactions?${new URLSearchParams(params)}`),
create: (data) => request('POST', '/transactions', data),
update: (id, data) => request('PUT', `/transactions/${id}`, data),
delete: (id) => request('DELETE', `/transactions/${id}`),
split: (id, data) => request('POST', `/transactions/${id}/split`, data),
summary: (params = {}) => request('GET', `/transactions/summary?${new URLSearchParams(params)}`),
monthly: (params = {}) => request('GET', `/transactions/monthly?${new URLSearchParams(params)}`),
categories: (params = {}) => request('GET', `/transactions/categories?${new URLSearchParams(params)}`),
},
calendar: {
list: (params = {}) => request('GET', `/calendar?${new URLSearchParams(params)}`),
createEvent: (data) => request('POST', '/calendar', data),
updateEvent: (id, data) => request('PUT', `/calendar/${id}`, data),
deleteEvent: (id) => request('DELETE', `/calendar/${id}`),
stats: (params = {}) => request('GET', `/calendar/stats?${new URLSearchParams(params)}`),
sync: (propertyId) => request('POST', `/calendar/sync/${propertyId}`),
},
documents: {
list: (params = {}) => request('GET', `/documents?${new URLSearchParams(params)}`),
upload: (formData) => request('POST', '/documents', formData, true),
download: (id) => `${BASE}/documents/${id}/download`,
delete: (id) => request('DELETE', `/documents/${id}`),
exportUrl: (params = {}) => `${BASE}/documents/export?${new URLSearchParams(params)}`,
},
loans: {
list: (params = {}) => request('GET', `/loans?${new URLSearchParams(params)}`),
createWithData: (data) => request('POST', '/loans/create', data),
create: (data) => request('POST', '/loans', data),
delete: (id) => request('DELETE', `/loans/${id}`),
lines: (id, params = {}) => request('GET', `/loans/${id}/lines?${new URLSearchParams(params)}`),
annualSummary: (id, params = {}) => request('GET', `/loans/${id}/summary?${new URLSearchParams(params)}`),
uploadLines: (id, lines) => request('POST', `/loans/${id}/lines`, lines),
splitByDate: (id, date) => request('GET', `/loans/${id}/split?date=${date}`),
splitForDate: (date) => request('GET', `/loans/split?date=${date}`),
},
fiscal: {
summary: (params = {}) => request('GET', `/fiscal/summary?${new URLSearchParams(params)}`),
exportUrl: (params = {}) => `${BASE}/fiscal/export?${new URLSearchParams(params)}`,
},
};
export const properties = writable([]);
export const selectedProperty = writable(null);
export async function loadProperties() {
const props = await api.properties.list();
properties.set(props || []);
const sel = get(selectedProperty);
if (!sel && props?.length > 0) selectedProperty.set(props[0]);
}

View File

@@ -0,0 +1,2 @@
export const prerender = false;
export const ssr = false;

View File

@@ -0,0 +1,133 @@
<script>
import '../app.css';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { currentUser, authToken, api } from '$lib/stores/api.js';
import {
LayoutDashboard, Building2, CreditCard, Upload,
CalendarDays, FileText, Download, LogOut,
User, Users, Tag, PowerOff
} from 'lucide-svelte';
let ready = false;
const nav = [
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/properties', label: 'Biens', icon: Building2 },
{ href: '/transactions', label: 'Transactions', icon: CreditCard },
{ href: '/categories', label: 'Catégories', icon: Tag },
{ href: '/calendar', label: 'Calendrier', icon: CalendarDays },
{ href: '/documents', label: 'Documents', icon: FileText },
{ href: '/fiscal', label: 'Export fiscal', icon: Download },
{ href: '/loans', label: 'Prêts', icon: Building2 },
{ href: '/import', label: 'Import bancaire',icon: Upload },
];
const navBottom = [
{ href: '/users', label: 'Utilisateurs', icon: Users },
{ href: '/profile', label: 'Mon profil', icon: User },
];
onMount(async () => {
// Ne pas tenter de restaurer la session sur la page login
if ($page.url.pathname === '/login') {
ready = true;
return;
}
if (!$currentUser) {
try {
const user = await api.auth.me();
if (user) {
currentUser.set(user);
} else {
goto('/login');
return;
}
} catch (_) {
goto('/login');
return;
}
}
ready = true;
});
// Quand on navigue vers /login, marquer ready immédiatement
$: if ($page.url.pathname === '/login') {
ready = true;
}
async function logout() {
await api.auth.logout();
currentUser.set(null);
authToken.set(null);
goto('/login');
}
async function shutdown() {
if (!confirm('Arrêter l\'application ?')) return;
try { await fetch('/api/shutdown', { method: 'POST', credentials: 'include' }); } catch (_) {}
}
function active(href) {
return $page.url.pathname === href;
}
</script>
{#if !ready}
<div class="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center">
<div class="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if $currentUser}
<div class="flex h-screen bg-gray-50 dark:bg-gray-950">
<aside class="hidden md:flex flex-col w-60 bg-white dark:bg-gray-900 border-r border-gray-100 dark:border-gray-800 shrink-0">
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-800">
<span class="text-base font-semibold text-gray-900 dark:text-white">🏠 Mes Locations</span>
</div>
<nav class="flex-1 px-3 py-3 space-y-0.5 overflow-y-auto">
{#each nav as item}
<a href={item.href}
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors
{active(item.href)
? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-200'}">
<svelte:component this={item.icon} size={17}/>
{item.label}
</a>
{/each}
</nav>
<div class="px-3 py-3 border-t border-gray-100 dark:border-gray-800 space-y-0.5">
{#each navBottom as item}
<a href={item.href}
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors
{active(item.href)
? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-200'}">
<svelte:component this={item.icon} size={17}/>
{item.label}
</a>
{/each}
<div class="flex items-center gap-3 px-3 py-2 mt-1">
<div class="w-7 h-7 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-xs font-semibold text-blue-700 dark:text-blue-300 shrink-0">
{$currentUser.name?.[0]?.toUpperCase() ?? '?'}
</div>
<span class="text-xs text-gray-500 dark:text-gray-400 truncate flex-1">{$currentUser.name}</span>
</div>
<button on:click={logout}
class="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-gray-500 dark:text-gray-400 hover:bg-red-50 dark:hover:bg-red-950/50 hover:text-red-600 dark:hover:text-red-400 transition-colors">
<LogOut size={17}/> Déconnexion
</button>
<button on:click={shutdown}
class="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-gray-500 dark:text-gray-400 hover:bg-red-50 dark:hover:bg-red-950/50 hover:text-red-600 dark:hover:text-red-400 transition-colors">
<PowerOff size={17}/> Quitter l'application
</button>
</div>
</aside>
<main class="flex-1 overflow-auto">
<slot/>
</main>
</div>
{:else}
<slot/>
{/if}

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>

View File

@@ -0,0 +1,275 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/stores/api.js';
import { CalendarDays, ChevronLeft, ChevronRight, Plus, X, Check, RefreshCw } from 'lucide-svelte';
let properties = [];
let events = [];
let loading = true;
let filterProperty = '';
let showForm = false;
let error = '';
let today = new Date();
let viewYear = today.getFullYear();
let viewMonth = today.getMonth(); // 0-indexed
const empty = () => ({
property_id: '', title: '',
start_date: today.toISOString().slice(0,10),
end_date: today.toISOString().slice(0,10),
notes: ''
});
let form = empty();
onMount(async () => {
properties = await api.properties.list() || [];
await load();
});
async function load() {
loading = true;
const from = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-01`;
const lastDay = new Date(viewYear, viewMonth + 1, 0).getDate();
const to = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${lastDay}`;
const params = { from, to };
if (filterProperty) params.property_id = filterProperty;
events = await api.calendar.list(params) || [];
loading = false;
}
function prevMonth() {
if (viewMonth === 0) { viewMonth = 11; viewYear--; } else { viewMonth--; }
load();
}
function nextMonth() {
if (viewMonth === 11) { viewMonth = 0; viewYear++; } else { viewMonth++; }
load();
}
// Génère les jours du mois pour la grille
$: calendarDays = (() => {
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const days = [];
// Padding début (lundi = 0)
const offset = (firstDay === 0 ? 6 : firstDay - 1);
for (let i = 0; i < offset; i++) days.push(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
return days;
})();
function isToday(day) {
return day === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear();
}
// Calcule pour chaque jour du mois : l'événement correspondant (ou null)
// iCal (airbnb) : DTEND exclusif → ds < end_date
// Manuel : date départ incluse → ds <= end_date
$: eventByDay = (() => {
const map = {};
const mStr = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const ds = `${mStr}-${String(d).padStart(2, '0')}`;
map[d] = events.find(e =>
ds >= e.start_date && (e.source === 'airbnb' ? ds < e.end_date : ds <= e.end_date)
) || null;
}
return map;
})();
$: occupiedDays = Object.values(eventByDay).filter(Boolean).length;
$: daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
$: occupancyRate = daysInMonth > 0 ? Math.round((occupiedDays / daysInMonth) * 100) : 0;
// Séjours dont l'arrivée est dans le mois affiché (pas les séjours démarrés avant)
$: sejoursThisMonth = (() => {
const monthStr = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`;
return events.filter(e => e.start_date.startsWith(monthStr)).length;
})();
const monthNames = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
const dayNames = ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim'];
const sourceBg = {
airbnb: 'bg-orange-100 dark:bg-orange-900',
manual: 'bg-blue-100 dark:bg-blue-900'
};
let syncing = false;
let syncMsg = '';
let syncError = '';
async function syncCalendars() {
syncing = true;
syncMsg = '';
syncError = '';
try {
const res = await fetch('/api/calendar/sync', { method: 'POST', credentials: 'include' });
const results = await res.json();
const total = results.reduce((s, r) => s + (r.imported || 0), 0);
const errs = results.filter(r => r.error);
if (errs.length > 0) {
syncError = errs.map(r => `${r.property}: ${r.error}`).join(' | ');
} else {
syncMsg = `${total} événement(s) importé(s)`;
}
await load();
} catch (e) {
syncError = 'Erreur de synchronisation: ' + e.message;
}
syncing = false;
}
async function saveEvent() {
error = '';
if (!form.property_id || !form.start_date || !form.end_date) { error = 'Bien et dates requis.'; return; }
try {
await api.calendar.createEvent(form);
showForm = false;
await load();
} catch (e) { error = e.message; }
}
</script>
<div class="p-6 max-w-5xl mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<CalendarDays size={22} class="text-gray-400"/>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Calendrier</h1>
</div>
<div class="flex gap-2">
<button on:click={syncCalendars} disabled={syncing}
class="flex items-center gap-2 px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50">
<RefreshCw size={15} class={syncing ? 'animate-spin' : ''}/> Synchroniser
</button>
<button on:click={() => { showForm = true; form = empty(); error = ''; }}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
<Plus size={16}/> Ajouter occupation
</button>
</div>
</div>
{#if syncMsg}
<p class="text-sm text-green-600 dark:text-green-400 mb-3">{syncMsg}</p>
{/if}
{#if syncError}
<p class="text-sm text-red-500 dark:text-red-400 mb-3">{syncError}</p>
{/if}
<!-- Filtres + navigation -->
<div class="flex flex-wrap items-center gap-3 mb-5">
<select bind:value={filterProperty} on:change={load}
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>
<div class="flex items-center gap-2 ml-auto">
<button on:click={prevMonth} class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 transition-colors"><ChevronLeft size={18}/></button>
<span class="text-base font-semibold text-gray-900 dark:text-white w-36 text-center">{monthNames[viewMonth]} {viewYear}</span>
<button on:click={nextMonth} class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 transition-colors"><ChevronRight size={18}/></button>
</div>
</div>
<!-- Stats bar -->
<div class="grid grid-cols-3 gap-3 mb-5">
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
<p class="text-xs text-gray-500 dark:text-gray-400">Jours occupés</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{occupiedDays} <span class="text-sm font-normal text-gray-400">/ {daysInMonth}</span></p>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
<p class="text-xs text-gray-500 dark:text-gray-400">Taux d'occupation</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{occupancyRate}%</p>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
<p class="text-xs text-gray-500 dark:text-gray-400">Séjours ce mois</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{sejoursThisMonth}</p>
</div>
</div>
<!-- Calendrier grille -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
<!-- Jours de la semaine -->
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-800">
{#each dayNames as d}
<div class="py-3 text-center text-xs font-medium text-gray-400 dark:text-gray-500">{d}</div>
{/each}
</div>
<!-- Jours -->
<div class="grid grid-cols-7">
{#each calendarDays as day, i}
{@const event = eventByDay[day] ?? null}
{@const occupied = !!event}
<div class="border-b border-r border-gray-50 dark:border-gray-800/50 min-h-[72px] p-2 relative
{!day ? 'bg-gray-50/50 dark:bg-gray-800/20' : ''}
{occupied ? (event.source === 'airbnb' ? 'bg-orange-50 dark:bg-orange-950/30' : 'bg-blue-50 dark:bg-blue-950/30') : ''}">
{#if day}
<span class="text-xs font-medium {isToday(day) ? 'bg-blue-600 text-white w-6 h-6 flex items-center justify-center rounded-full' : 'text-gray-700 dark:text-gray-300'}">
{day}
</span>
{#if event && event.start_date === `${viewYear}-${String(viewMonth+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`}
<div class="mt-1 text-xs px-1.5 py-0.5 rounded font-medium truncate
{event.source === 'airbnb' ? 'bg-orange-200 text-orange-800 dark:bg-orange-900 dark:text-orange-200' : 'bg-blue-200 text-blue-800 dark:bg-blue-900 dark:text-blue-200'}">
{event.title || (event.source === 'airbnb' ? 'Airbnb' : 'Locataire')}
</div>
{/if}
{/if}
</div>
{/each}
</div>
</div>
<!-- Légende -->
<div class="flex gap-4 mt-3 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-orange-200 dark:bg-orange-900"></span>Airbnb (sync auto)</span>
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-blue-200 dark:bg-blue-900"></span>Manuel</span>
</div>
</div>
<!-- Modal -->
{#if showForm}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" on:click|self={() => showForm = false}>
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h2 class="font-semibold text-gray-900 dark:text-white">Ajouter une période d'occupation</h2>
<button on:click={() => showForm = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
</div>
<div class="px-6 py-5 space-y-4">
{#if error}<p class="text-red-500 text-sm">{error}</p>{/if}
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Bien *</label>
<select bind:value={form.property_id}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Sélectionner...</option>
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Intitulé</label>
<input bind:value={form.title} placeholder="Ex: Famille Dupont"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Arrivée *</label>
<input type="date" bind:value={form.start_date}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Départ *</label>
<input type="date" bind:value={form.end_date}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
</div>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
<button on:click={() => showForm = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
<button on:click={saveEvent}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
<Check size={15}/> Ajouter
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,223 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/stores/api.js';
import { Tag, Plus, Pencil, Trash2, X, Check, AlertCircle } from 'lucide-svelte';
let categories = [];
let loading = true;
let showForm = false;
let editingId = null;
let error = '';
const empty = () => ({ name: '', type: 'expense', tax_deductible: false, description: '' });
let form = empty();
onMount(load);
async function load() {
loading = true;
categories = await api.categories.list() || [];
loading = false;
}
function openCreate() { form = empty(); editingId = null; showForm = true; error = ''; }
function openEdit(c) { form = { ...c }; editingId = c.id; showForm = true; error = ''; }
function cancel() { showForm = false; error = ''; }
async function save() {
error = '';
if (!form.name) { error = 'Le nom est requis.'; return; }
try {
if (editingId) await api.categories.update(editingId, form);
else await api.categories.create(form);
showForm = false;
await load();
} catch (e) { error = e.message; }
}
async function remove(id, name) {
if (!confirm(`Supprimer la catégorie "${name}" ?\nLes transactions associées perdront leur catégorie.`)) return;
await api.categories.delete(id);
await load();
}
// Grouper par type
$: incomes = categories.filter(c => c.type === 'income');
$: expenses = categories.filter(c => c.type === 'expense');
const typeBadge = {
income: 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300',
expense: 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300',
};
const typeLabel = { income: 'Revenu', expense: 'Dépense' };
</script>
<div class="p-6 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<Tag size={22} class="text-gray-400"/>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Catégories</h1>
</div>
<button on:click={openCreate}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
<Plus size={16}/> Nouvelle catégorie
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Les catégories permettent de classer vos revenus et dépenses pour la comptabilité et la liasse fiscale.
Les catégories déductibles fiscalement sont signalées pour l'export annuel.
</p>
{#if loading}
<div class="space-y-2">
{#each [1,2,3] as _}<div class="h-12 bg-gray-100 dark:bg-gray-800 rounded-lg animate-pulse"/>{/each}
</div>
{:else}
<!-- Dépenses -->
<div class="mb-6">
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-red-400 inline-block"></span>
Dépenses ({expenses.length})
</h2>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
{#if expenses.length === 0}
<p class="text-sm text-gray-400 text-center py-6">Aucune catégorie de dépense.</p>
{:else}
{#each expenses as c, i (c.id)}
<div class="flex items-center gap-4 px-4 py-3
{i > 0 ? 'border-t border-gray-50 dark:border-gray-800' : ''}
hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">{c.name}</span>
{#if c.tax_deductible}
<span class="text-xs px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300 font-medium">
Déductible
</span>
{/if}
</div>
{#if c.description}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{c.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 shrink-0">
<button on:click={() => openEdit(c)}
class="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<Pencil size={14}/>
</button>
<button on:click={() => remove(c.id, c.name)}
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
<Trash2 size={14}/>
</button>
</div>
</div>
{/each}
{/if}
</div>
</div>
<!-- Revenus -->
<div>
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-green-400 inline-block"></span>
Revenus ({incomes.length})
</h2>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
{#if incomes.length === 0}
<p class="text-sm text-gray-400 text-center py-6">Aucune catégorie de revenu.</p>
{:else}
{#each incomes as c, i (c.id)}
<div class="flex items-center gap-4 px-4 py-3
{i > 0 ? 'border-t border-gray-50 dark:border-gray-800' : ''}
hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<div class="flex-1 min-w-0">
<span class="text-sm font-medium text-gray-900 dark:text-white">{c.name}</span>
{#if c.description}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{c.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 shrink-0">
<button on:click={() => openEdit(c)}
class="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<Pencil size={14}/>
</button>
<button on:click={() => remove(c.id, c.name)}
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
<Trash2 size={14}/>
</button>
</div>
</div>
{/each}
{/if}
</div>
</div>
{/if}
</div>
<!-- Modal -->
{#if showForm}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h2 class="font-semibold text-gray-900 dark:text-white">
{editingId ? 'Modifier la catégorie' : 'Nouvelle catégorie'}
</h2>
<button on:click={cancel} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<X size={18}/>
</button>
</div>
<div class="px-6 py-5 space-y-4">
{#if error}
<div class="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400">
<AlertCircle size={13}/> {error}
</div>
{/if}
<!-- Type -->
<div class="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<button on:click={() => form.type = 'expense'}
class="flex-1 py-2 text-sm font-medium transition-colors
{form.type === 'expense' ? 'bg-red-500 text-white' : 'text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800'}">
Dépense
</button>
<button on:click={() => form.type = 'income'}
class="flex-1 py-2 text-sm font-medium transition-colors
{form.type === 'income' ? 'bg-green-600 text-white' : 'text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800'}">
Revenu
</button>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nom *</label>
<input bind:value={form.name} placeholder="Ex: Charges copropriété"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Description</label>
<input bind:value={form.description} placeholder="Description optionnelle..."
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
{#if form.type === 'expense'}
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" bind:checked={form.tax_deductible}
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"/>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Déductible fiscalement</span>
<p class="text-xs text-gray-400 dark:text-gray-500">Apparaîtra en évidence dans l'export fiscal annuel</p>
</div>
</label>
{/if}
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
<button on:click={cancel} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
<button on:click={save}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
<Check size={15}/> {editingId ? 'Enregistrer' : 'Créer'}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,239 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/stores/api.js';
import { FileText, Upload, Trash2, Download, FolderOpen, Archive } from 'lucide-svelte';
let documents = [];
let properties = [];
let loading = true;
let uploading = false;
let error = '';
let filterProperty = '';
let filterYear = String(new Date().getFullYear());
let filterCategory = '';
let dragover = false;
let fileInput;
let uploadPropertyId = '';
let uploadYear = String(new Date().getFullYear());
let uploadCategory = '';
// Export
let exportProperty = '';
let exportYear = String(new Date().getFullYear());
const years = Array.from({ length: 5 }, (_, i) => String(new Date().getFullYear() - i));
onMount(async () => {
properties = await api.properties.list() || [];
if (properties.length > 0) {
uploadPropertyId = properties[0].id;
exportProperty = properties[0].id;
}
await load();
});
async function load() {
loading = true;
const params = {};
if (filterProperty) params.property_id = filterProperty;
if (filterYear) params.fiscal_year = filterYear;
if (filterCategory) params.category = filterCategory;
documents = await api.documents.list(params) || [];
loading = false;
}
async function handleFiles(files) {
if (!uploadPropertyId) { error = 'Sélectionnez un bien avant d\'uploader.'; return; }
error = '';
uploading = true;
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
fd.append('property_id', uploadPropertyId);
fd.append('fiscal_year', uploadYear);
fd.append('category', uploadCategory);
try { await api.documents.upload(fd); }
catch (e) { error = `Erreur upload "${file.name}": ${e.message}`; }
}
uploading = false;
await load();
}
function onDrop(e) {
dragover = false;
handleFiles([...e.dataTransfer.files]);
}
function onFileInput(e) {
handleFiles([...e.target.files]);
e.target.value = '';
}
async function remove(id, name) {
if (!confirm(`Supprimer "${name}" ?`)) return;
await api.documents.delete(id);
await load();
}
function download(id) {
window.open(api.documents.download(id), '_blank');
}
function doExport() {
const params = { year: exportYear };
if (exportProperty) params.property_id = exportProperty;
window.open(api.documents.exportUrl(params), '_blank');
}
const mimeIcon = (mime) => {
if (!mime) return '📄';
if (mime.includes('pdf')) return '📕';
if (mime.includes('image')) return '🖼️';
if (mime.includes('spreadsheet') || mime.includes('excel') || mime.includes('csv')) return '📊';
if (mime.includes('word') || mime.includes('document')) return '📝';
return '📄';
};
const fmtDate = (d) => new Date(d).toLocaleDateString('fr-FR');
// Catégories distinctes présentes dans la liste pour le filtre
$: availableCategories = [...new Set(documents.map(d => d.category).filter(Boolean))].sort();
// Grouper par catégorie
$: grouped = (() => {
const byCat = {};
for (const d of documents) {
const cKey = d.category || 'Sans catégorie';
if (!byCat[cKey]) byCat[cKey] = [];
byCat[cKey].push(d);
}
return Object.entries(byCat).sort(([a], [b]) => a.localeCompare(b));
})();
</script>
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between gap-3 mb-6">
<div class="flex items-center gap-3">
<FileText size={22} class="text-gray-400"/>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Documents</h1>
</div>
<!-- Export ZIP -->
<div class="flex items-center gap-2 bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 px-4 py-2">
<Archive size={15} class="text-gray-400 shrink-0"/>
<span class="text-sm text-gray-600 dark:text-gray-400 shrink-0">Export ZIP</span>
<select bind:value={exportProperty}
class="px-2 py-1 rounded 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">
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
</select>
<select bind:value={exportYear}
class="px-2 py-1 rounded 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">
{#each years as y}<option value={y}>{y}</option>{/each}
</select>
<button on:click={doExport}
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors">
Télécharger
</button>
</div>
</div>
<!-- Zone d'upload -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-5 mb-5">
<h2 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Ajouter des documents</h2>
<div class="flex flex-wrap gap-3 mb-4">
<select bind:value={uploadPropertyId}
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 properties as p}<option value={p.id}>{p.name}</option>{/each}
</select>
<select bind:value={uploadYear}
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}>Année {y}</option>{/each}
</select>
<input
bind:value={uploadCategory}
placeholder="Catégorie (ex: Loyers, Travaux…)"
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 min-w-48"/>
</div>
{#if error}<p class="text-red-500 text-sm mb-3">{error}</p>{/if}
<!-- Drop zone -->
<div
on:dragover|preventDefault={() => dragover = true}
on:dragleave={() => dragover = false}
on:drop|preventDefault={onDrop}
on:click={() => fileInput.click()}
class="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors
{dragover ? 'border-blue-400 bg-blue-50 dark:bg-blue-950/30' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}">
{#if uploading}
<div class="text-blue-600 dark:text-blue-400 text-sm">⏳ Upload en cours...</div>
{:else}
<Upload size={24} class="mx-auto mb-2 text-gray-300 dark:text-gray-600"/>
<p class="text-sm text-gray-500 dark:text-gray-400">Glissez vos fichiers ici ou <span class="text-blue-600 dark:text-blue-400">cliquez pour choisir</span></p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">PDF, images, tableurs acceptés</p>
{/if}
</div>
<input bind:this={fileInput} type="file" multiple class="hidden" on:change={onFileInput}
accept=".pdf,.jpg,.jpeg,.png,.xlsx,.csv,.doc,.docx"/>
</div>
<!-- Filtres -->
<div class="flex flex-wrap gap-3 mb-5">
<select bind:value={filterProperty} on:change={load}
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={filterYear} on:change={load}
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="">Toutes années</option>
{#each years as y}<option value={y}>{y}</option>{/each}
</select>
<select bind:value={filterCategory} on:change={load}
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="">Toutes catégories</option>
{#each availableCategories as c}<option value={c}>{c}</option>{/each}
</select>
</div>
<!-- Liste groupée par mois → catégorie -->
{#if loading}
<div class="space-y-2">
{#each [1,2,3] as _}<div class="h-12 bg-gray-100 dark:bg-gray-800 rounded-lg animate-pulse"/>{/each}
</div>
{:else if documents.length === 0}
<div class="text-center py-16 text-gray-400">
<FolderOpen size={40} class="mx-auto mb-3 opacity-30"/>
<p>Aucun document pour ces filtres.</p>
</div>
{:else}
{#each grouped as [catName, docs]}
<div class="mb-4">
<p class="text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wider mb-2 ml-1">{catName}</p>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
{#each docs as d, i (d.id)}
<div class="flex items-center gap-4 px-4 py-3 {i > 0 ? 'border-t border-gray-50 dark:border-gray-800' : ''} hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<span class="text-xl shrink-0">{mimeIcon(d.mime_type)}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{d.original_name}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{d.property_name} · {fmtDate(d.created_at)}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<button on:click={() => download(d.id)}
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors">
<Download size={14}/>
</button>
<button on:click={() => remove(d.id, d.original_name)}
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
<Trash2 size={14}/>
</button>
</div>
</div>
{/each}
</div>
</div>
{/each}
{/if}
</div>

View File

@@ -0,0 +1,126 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/stores/api.js';
import { Download, TrendingUp, TrendingDown, Minus, FileSpreadsheet } from 'lucide-svelte';
let properties = [];
let summary = [];
let loading = true;
let filterProperty = '';
let filterYear = String(new Date().getFullYear());
const years = Array.from({ length: 5 }, (_, i) => String(new Date().getFullYear() - i));
const fmt = (n) => Number(n || 0).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €';
onMount(async () => {
properties = await api.properties.list() || [];
await load();
});
async function load() {
loading = true;
const params = { year: filterYear };
if (filterProperty) params.property_id = filterProperty;
summary = await api.fiscal.summary(params) || [];
loading = false;
}
function exportCSV() {
const params = { year: filterYear };
if (filterProperty) params.property_id = filterProperty;
window.open(api.fiscal.exportUrl(params), '_blank');
}
$: grandTotal = summary.reduce((acc, s) => ({
income: acc.income + (s.total_income || 0),
expense: acc.expense + (s.total_expense || 0),
balance: acc.balance + (s.balance || 0),
}), { income: 0, expense: 0, balance: 0 });
</script>
<div class="p-6 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<FileSpreadsheet size={22} class="text-gray-400"/>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Export fiscal</h1>
</div>
<button on:click={exportCSV}
class="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium transition-colors">
<Download size={16}/> Exporter CSV
</button>
</div>
<div class="bg-blue-50 dark:bg-blue-950/30 border border-blue-100 dark:border-blue-900 rounded-xl p-4 mb-6 text-sm text-blue-700 dark:text-blue-300">
L'export CSV est formaté pour Excel (séparateur point-virgule, encodage UTF-8 avec BOM).
Il reprend toutes les transactions de l'année sélectionnée avec leur catégorie,
et inclut le total des revenus, dépenses et le bénéfice net.
</div>
<!-- Filtres -->
<div class="flex flex-wrap gap-3 mb-6">
<select bind:value={filterProperty} on:change={load}
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={filterYear} on:change={load}
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>
</div>
<!-- Récapitulatif -->
{#if loading}
<div class="space-y-3">
{#each [1,2] as _}<div class="h-32 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>{/each}
</div>
{:else if summary.length === 0}
<p class="text-center text-gray-400 py-12">Aucune donnée pour cette sélection.</p>
{:else}
<div class="space-y-4 mb-6">
{#each summary as s}
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-50 dark:border-gray-800">
<h2 class="font-semibold text-gray-900 dark:text-white">{s.property_name}</h2>
<p class="text-xs text-gray-400 mt-0.5">Exercice {s.year || filterYear}</p>
</div>
<div class="grid grid-cols-3 divide-x divide-gray-50 dark:divide-gray-800">
<div class="px-5 py-4">
<div class="flex items-center gap-1.5 text-xs text-gray-500 mb-1"><TrendingUp size={12} class="text-green-500"/> Revenus</div>
<p class="text-lg font-semibold text-green-600">{fmt(s.total_income)}</p>
</div>
<div class="px-5 py-4">
<div class="flex items-center gap-1.5 text-xs text-gray-500 mb-1"><TrendingDown size={12} class="text-red-500"/> Dépenses</div>
<p class="text-lg font-semibold text-red-500">{fmt(s.total_expense)}</p>
</div>
<div class="px-5 py-4">
<div class="flex items-center gap-1.5 text-xs text-gray-500 mb-1"><Minus size={12}/> Bénéfice net</div>
<p class="text-lg font-semibold {s.balance >= 0 ? 'text-green-600' : 'text-red-500'}">{fmt(s.balance)}</p>
</div>
</div>
</div>
{/each}
</div>
<!-- Total consolidé -->
{#if summary.length > 1}
<div class="bg-gray-900 dark:bg-gray-800 rounded-xl p-5 text-white">
<h3 class="text-sm font-medium text-gray-400 mb-4">Total consolidé — {filterYear}</h3>
<div class="grid grid-cols-3 gap-4">
<div>
<p class="text-xs text-gray-400 mb-1">Revenus totaux</p>
<p class="text-xl font-semibold text-green-400">{fmt(grandTotal.income)}</p>
</div>
<div>
<p class="text-xs text-gray-400 mb-1">Dépenses totales</p>
<p class="text-xl font-semibold text-red-400">{fmt(grandTotal.expense)}</p>
</div>
<div>
<p class="text-xs text-gray-400 mb-1">Bénéfice net</p>
<p class="text-xl font-semibold {grandTotal.balance >= 0 ? 'text-green-400' : 'text-red-400'}">{fmt(grandTotal.balance)}</p>
</div>
</div>
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,896 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/stores/api.js';
import { Upload, FileSpreadsheet, Check, AlertCircle, Tag, X, GitFork, Link2 } from 'lucide-svelte';
let properties = [];
let categories = [];
let transactions = [];
let step = 'upload';
let loading = false;
let error = '';
let result = null;
let fileInput;
let dragover = false;
let defaultProperty = '';
// Split state
let splitIdx = null; // index de la transaction en cours de split
let splitParts = [];
let splitError = '';
let loans = [];
// Montants de mensualités connus — pour détecter les lignes de prêt
$: loanMonthlyAmounts = loans.map(l => l.monthly_payment);
function isLoanPayment(amount) {
return loanMonthlyAmounts.some(m => Math.abs(m - amount) < 0.10);
}
onMount(async () => {
[properties, categories, loans] = await Promise.all([
api.properties.list(),
api.categories.list(),
api.loans.list(),
]);
properties = properties || [];
categories = categories || [];
loans = loans || [];
if (properties.length > 0) defaultProperty = properties[0].id;
});
function catsFor(type) {
return categories.filter(c => c.type === type);
}
async function handleFile(file) {
if (!file) return;
if (!file.name.toLowerCase().match(/\.(qif|qfx)$/)) { error = 'Fichier QIF ou QFX requis.'; return; }
error = '';
loading = true;
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/import/preview', { method: 'POST', body: fd, credentials: 'include' });
if (!res.ok) throw new Error(await res.text());
const raw = (await res.json()) || [];
transactions = raw.map(t => ({
...t,
status: 'import',
property_id: defaultProperty,
category_id: '',
splits: null,
agencyFee: 0,
alreadyImported: false,
}));
// Vérifier lesquelles existent déjà en base
try {
const checkRes = await fetch('/api/import/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(raw),
});
if (checkRes.ok) {
const exists = await checkRes.json();
transactions = transactions.map((t, i) => ({
...t,
alreadyImported: exists[i] === true,
status: exists[i] ? 'ignore' : 'import',
}));
}
} catch(_) {}
step = 'preview';
} catch (e) { error = e.message; }
loading = false;
}
function onDrop(e) { dragover = false; handleFile(e.dataTransfer.files[0]); }
function onFileInput(e) { handleFile(e.target.files[0]); e.target.value = ''; }
function assignAllProperty(pid) {
if (!pid) return;
transactions = transactions.map(t =>
t.status === 'import' ? { ...t, property_id: pid } : t
);
}
// ── Split ────────────────────────────────────────────────────────────────
function openSplit(idx) {
const t = transactions[idx];
splitIdx = idx;
splitError = '';
// Parts initiales : 50/50 sur les deux biens disponibles
splitParts = properties.slice(0, 2).map((p) => ({
property_id: p.id,
category_id: t.category_id || '',
type: t.type,
amount: parseFloat((t.amount / 2).toFixed(2)),
description: t.description || '',
pct: 50,
}));
if (splitParts.length < 2) {
splitParts.push({ property_id: '', category_id: '', type: t.type, amount: parseFloat((t.amount / 2).toFixed(2)), description: t.description || '', pct: 50 });
}
}
function closeSplit() { splitIdx = null; splitParts = []; splitError = ''; }
function updateSplitPct(i, val) {
const total = transactions[splitIdx].amount;
val = Math.min(100, Math.max(0, parseFloat(val) || 0));
splitParts[i].pct = val;
splitParts[i].amount = parseFloat((total * val / 100).toFixed(2));
if (splitParts.length === 2) {
const other = 100 - val;
splitParts[1-i].pct = other;
splitParts[1-i].amount = parseFloat((total * other / 100).toFixed(2));
}
splitParts = [...splitParts];
}
function updateSplitAmount(i, val) {
const total = transactions[splitIdx].amount;
val = Math.abs(parseFloat(val) || 0);
splitParts[i].amount = val;
splitParts[i].pct = parseFloat((val / total * 100).toFixed(1));
if (splitParts.length === 2) {
const j = 1 - i;
const srcType = transactions[splitIdx].type;
const allSameType = splitParts.every(p => p.type === srcType);
let otherAmount;
if (allSameType) {
// Mode homogène : other = total - val
otherAmount = parseFloat((total - val).toFixed(2));
} else {
// Mode mixte : maintenir net = total
// Si la part modifiée est même type que source → autre (opposé) = val - total
// Si la part modifiée est type opposé à source → autre (même type) = total + val
otherAmount = splitParts[i].type === srcType
? parseFloat((val - total).toFixed(2))
: parseFloat((total + val).toFixed(2));
otherAmount = Math.max(0, otherAmount);
}
splitParts[j].amount = otherAmount;
splitParts[j].pct = parseFloat((otherAmount / total * 100).toFixed(1));
}
splitParts = [...splitParts];
}
$: splitSource = splitIdx !== null ? transactions[splitIdx] : null;
$: splitNetExpense = splitParts.filter(p => p.type === 'expense').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
$: splitNetIncome = splitParts.filter(p => p.type === 'income').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
$: splitNet = splitSource ? parseFloat((splitSource.type === 'income'
? splitNetIncome - splitNetExpense
: splitNetExpense - splitNetIncome
).toFixed(2)) : 0;
$: splitDiff = splitSource ? parseFloat((splitNet - splitSource.amount).toFixed(2)) : 0;
// Helpers catégories prêt
function findInterestCat() {
return categories.find(c => {
const n = c.name.toLowerCase();
return n.includes('intérêt') || n.includes('interet');
});
}
function findCapitalCat() {
return categories.find(c => {
const n = c.name.toLowerCase();
return n.includes('capital') || n.includes('remboursement');
});
}
function findManagementCat() {
return categories.find(c => {
const n = c.name.toLowerCase();
return n.includes('gestion') || n.includes('honoraire') || n.includes('agence');
});
}
function buildSplitParts(results, tAmount) {
const interestCat = findInterestCat();
const capitalCat = findCapitalCat();
const parts = [];
for (const r of results) {
parts.push({
property_id: r.property_id || defaultProperty,
category_id: interestCat?.id || '',
type: 'expense',
amount: parseFloat(r.interest.toFixed(2)),
description: 'Intérêts ' + r.loan_ref + ' — éch. ' + r.rank,
pct: parseFloat((r.interest / tAmount * 100).toFixed(1)),
});
parts.push({
property_id: r.property_id || defaultProperty,
category_id: capitalCat?.id || '',
type: 'expense',
amount: parseFloat(r.capital.toFixed(2)),
description: 'Capital ' + r.loan_ref + ' — éch. ' + r.rank,
pct: parseFloat((r.capital / tAmount * 100).toFixed(1)),
});
}
return { parts, interestCat, capitalCat };
}
// Auto-split : API d'abord, sélecteur manuel en fallback
async function autoSplit(idx) {
const t = transactions[idx];
splitIdx = idx;
splitParts = [];
splitError = '';
try {
const results = await api.loans.splitForDate(t.date);
if (results && results.length > 0) {
// Ne garder que le(s) prêt(s) dont le total correspond au montant de la transaction
const matched = results.filter(r => Math.abs(r.total - t.amount) < 0.10);
const toUse = matched.length > 0 ? matched : results;
const { parts, interestCat, capitalCat } = buildSplitParts(toUse, t.amount);
splitParts = parts;
if (!interestCat) splitError = '⚠ Créez une catégorie "Intérêts emprunt".';
if (!capitalCat) splitError += (splitError ? ' ' : '') + '⚠ Créez une catégorie "Remboursement emprunt".';
return; // succès, pas besoin du sélecteur
}
} catch(e) { /* fallback */ }
// Fallback : ouvrir le sélecteur manuel
openLoanPicker(idx);
}
// Sélecteur de ligne d'amortissement (fallback manuel)
let loanPickerIdx = null;
let loanPickerData = [];
let loanPickerLoading = false;
async function openLoanPicker(idx) {
loanPickerIdx = idx;
loanPickerData = [];
loanPickerLoading = true;
splitIdx = null;
const t = transactions[idx];
const year = t.date ? t.date.split('-')[0] : String(new Date().getFullYear());
const matching = loans.filter(l => Math.abs(l.monthly_payment - t.amount) < 0.10);
const toLoad = matching.length > 0 ? matching : loans;
loanPickerData = await Promise.all(
toLoad.map(async loan => {
const lines = await api.loans.lines(loan.id, { year });
return { loan, lines: lines || [] };
})
);
loanPickerLoading = false;
}
function closeLoanPicker() { loanPickerIdx = null; loanPickerData = []; }
function selectLoanLine(lr, line) {
const t = transactions[loanPickerIdx];
const { parts, interestCat, capitalCat } = buildSplitParts(
[{ property_id: lr.loan.property_id, loan_ref: lr.loan.reference || lr.loan.label, rank: line.rank, interest: line.interest, capital: line.capital }],
t.amount
);
splitIdx = loanPickerIdx;
splitParts = parts;
splitError = '';
if (!interestCat) splitError = '⚠ Créez une catégorie "Intérêts emprunt".';
if (!capitalCat) splitError += (splitError ? ' ' : '') + '⚠ Créez une catégorie "Remboursement emprunt".';
closeLoanPicker();
}
function applySplit() {
splitError = '';
if (Math.abs(splitDiff) > 0.01) { splitError = 'Le total ne correspond pas au montant.'; return; }
for (const p of splitParts) {
if (!p.property_id) { splitError = 'Chaque part doit avoir un bien.'; return; }
if (!p.category_id) { splitError = 'Chaque part doit avoir une catégorie.'; return; }
}
// Stocker les parts dans la transaction et la marquer "split"
transactions[splitIdx] = {
...transactions[splitIdx],
status: 'split',
splits: splitParts.map(p => ({ ...p })),
};
transactions = [...transactions];
closeSplit();
}
function removeSplit(idx) {
transactions[idx] = { ...transactions[idx], status: 'import', splits: null };
transactions = [...transactions];
}
// ── Fusion de deux prélèvements ───────────────────────────────────────────
let mergeAnchorIdx = null;
function startMerge(idx) {
if (mergeAnchorIdx === null) {
mergeAnchorIdx = idx;
} else if (mergeAnchorIdx === idx) {
mergeAnchorIdx = null; // annuler
} else {
// Fusionner
const t1 = transactions[mergeAnchorIdx];
const t2 = transactions[idx];
const combinedAbs = Math.abs(t1.amount) + Math.abs(t2.amount);
const newAmount = t1.amount < 0 ? -combinedAbs : combinedAbs;
transactions[mergeAnchorIdx] = {
...t1,
status: 'import',
amount: newAmount,
splits: null,
_mergedWithIdx: idx,
_origAmount: t1.amount,
description: t1.description,
};
transactions[idx] = { ...t2, status: 'absorbed' };
transactions = [...transactions];
mergeAnchorIdx = null;
}
}
function unmerge(idx) {
const t = transactions[idx];
if (t._mergedWithIdx == null) return;
transactions[t._mergedWithIdx] = { ...transactions[t._mergedWithIdx], status: 'import' };
transactions[idx] = { ...t, amount: t._origAmount, _mergedWithIdx: null, _origAmount: null, status: 'import', splits: null };
transactions = [...transactions];
}
// ── Import ────────────────────────────────────────────────────────────────
async function doImport() {
error = '';
// N'importer que les transactions complètes (bien + catégorie, ou split)
const ready = transactions.filter(t =>
t.status === 'split' || (t.status === 'import' && t.property_id && t.category_id)
);
if (ready.length === 0) { error = 'Aucune transaction prête à importer.'; return; }
const toImport = ready;
loading = true;
try {
// Construire la liste finale — les splits sont éclatés en plusieurs transactions
const flat = [];
for (const t of toImport) {
if (t.status === 'split' && t.splits) {
for (const s of t.splits) {
flat.push({ ...t, property_id: s.property_id, category_id: s.category_id, type: s.type || t.type, amount: s.amount, description: s.description });
}
} else {
flat.push(t);
}
// Frais de gestion agence : créer une dépense déductible complémentaire
if ((t.agencyFee || 0) > 0) {
const mgmtCat = findManagementCat();
flat.push({
...t,
type: 'expense',
amount: parseFloat(t.agencyFee),
category_id: mgmtCat?.id || '',
description: 'Frais de gestion — ' + (t.description || ''),
splits: null,
});
}
}
// Grouper par property_id
const byProperty = flat.reduce((acc, t) => {
if (!acc[t.property_id]) acc[t.property_id] = [];
acc[t.property_id].push(t);
return acc;
}, {});
let totalImported = 0, totalSkipped = 0, allErrors = [];
for (const [pid, txs] of Object.entries(byProperty)) {
const res = await fetch('/api/import/qif', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ property_id: pid, transactions: txs }),
});
if (!res.ok) throw new Error(await res.text());
const r = await res.json();
totalImported += r.imported;
totalSkipped += r.skipped;
if (r.errors) allErrors = [...allErrors, ...r.errors];
}
result = { imported: totalImported, skipped: totalSkipped, errors: allErrors };
// Supprimer les transactions importées, garder les incomplètes à l'écran
transactions = transactions.filter(t =>
!(t.status === 'split' || (t.status === 'import' && t.property_id && t.category_id))
);
if (transactions.length === 0) {
step = 'done';
} else {
error = `✓ ${totalImported} importée${totalImported > 1 ? 's' : ''}${totalSkipped > 0 ? ` · ${totalSkipped} ignorée${totalSkipped > 1 ? 's' : ''}` : ''}${transactions.filter(t=>t.status!=='ignore').length} transaction${transactions.filter(t=>t.status!=='ignore').length > 1 ? 's' : ''} à compléter`;
}
} catch (e) { error = e.message; }
loading = false;
}
function reset() { step = 'upload'; transactions = []; result = null; error = ''; closeSplit(); }
$: toImport = transactions.filter(t => t.status === 'import' || t.status === 'split');
$: ignored = transactions.filter(t => t.status === 'ignore');
$: readyCount = transactions.filter(t => t.status === 'split' || (t.status === 'import' && t.property_id && t.category_id)).length;
$: uncatCount = transactions.filter(t => t.status === 'import' && !t.category_id).length;
$: missingPropCount = transactions.filter(t => t.status === 'import' && !t.property_id).length;
const fmt = (n) => Number(n).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €';
const fmtDate = (d) => { if (!d) return '—'; const p = d.split('-'); return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : d; };
</script>
<div class="p-6 max-w-6xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<FileSpreadsheet size={22} class="text-gray-400"/>
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Import bancaire</h1>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-0.5">Importez vos relevés QIF — ignorez les virements personnels</p>
</div>
</div>
<!-- Étapes -->
<div class="flex items-center gap-2 mb-8 text-xs font-medium">
{#each [['upload','Fichier'], ['preview','Vérification'], ['done','Terminé']] as [s, label], i}
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold
{step === s ? 'bg-blue-600 text-white' :
(i === 0 && step !== 'upload') || (i === 1 && step === 'done') ? 'bg-green-500 text-white' :
'bg-gray-100 dark:bg-gray-800 text-gray-400'}">
{(i === 0 && step !== 'upload') || (i === 1 && step === 'done') ? '✓' : i + 1}
</div>
<span class="{step === s ? 'text-gray-900 dark:text-white' : 'text-gray-400'}">{label}</span>
</div>
{#if i < 2}<div class="flex-1 h-px bg-gray-100 dark:bg-gray-800"/>{/if}
{/each}
</div>
{#if error}
<div class="flex items-center gap-2 text-sm px-4 py-3 rounded-lg mb-4 bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400">
<AlertCircle size={14}/> {error}
</div>
{/if}
<!-- Étape 1 -->
{#if step === 'upload'}
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-6">
<div
on:dragover|preventDefault={() => dragover = true}
on:dragleave={() => dragover = false}
on:drop|preventDefault={onDrop}
on:click={() => fileInput.click()}
role="button" tabindex="0"
on:keydown={(e) => e.key === 'Enter' && fileInput.click()}
class="border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors
{dragover ? 'border-blue-400 bg-blue-50 dark:bg-blue-950/30' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}">
{#if loading}
<div class="flex items-center justify-center gap-2 text-blue-600">
<div class="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"/>
Analyse du fichier...
</div>
{:else}
<Upload size={28} class="mx-auto mb-3 text-gray-300 dark:text-gray-600"/>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Glissez votre fichier QIF ici</p>
<p class="text-xs text-gray-400 mt-1">ou cliquez pour choisir — formats .qif, .qfx</p>
{/if}
</div>
<input bind:this={fileInput} type="file" accept=".qif,.qfx" class="hidden" on:change={onFileInput}/>
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-950/30 rounded-lg text-xs text-blue-600 dark:text-blue-400">
<p class="font-medium text-sm text-blue-700 dark:text-blue-300 mb-1">Comment exporter depuis votre banque ?</p>
Espace client → Mes comptes → Télécharger / Exporter → Format QIF.
Vous pourrez assigner chaque ligne à un appartement, la ventiler sur plusieurs biens (✂️), ou l'ignorer.
</div>
</div>
<!-- Étape 2 -->
{:else if step === 'preview'}
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-4 mb-3">
<div class="flex flex-wrap items-center gap-3">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{toImport.length} à importer</span>
{#if ignored.length > 0}
<span class="text-gray-400 text-sm">· {ignored.length} ignorée{ignored.length > 1 ? 's' : ''}</span>
{/if}
{#if missingPropCount > 0}
<span class="flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400">
<AlertCircle size={11}/> {missingPropCount} sans bien — import bloqué
</span>
{/if}
{#if uncatCount > 0}
<span class="flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-300">
<Tag size={11}/> {uncatCount} sans catégorie — import bloqué
</span>
{/if}
<div class="flex items-center gap-2 ml-auto flex-wrap">
<span class="text-xs text-gray-500 dark:text-gray-400">Assigner tous à :</span>
<select on:change={(e) => { assignAllProperty(e.target.value); e.target.value = ''; }}
class="px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Choisir...</option>
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
</select>
<button on:click={reset} class="px-3 py-1.5 text-xs border border-gray-200 dark:border-gray-700 text-gray-500 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
Recommencer
</button>
<button on:click={doImport} disabled={loading || readyCount === 0}
class="flex items-center gap-2 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
{#if loading}<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"/>{:else}<Check size={14}/>{/if}
Importer {readyCount}{readyCount < toImport.length ? ` / ${toImport.length}` : ''}
</button>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50">
<tr class="text-xs text-gray-500 dark:text-gray-400">
<th class="px-3 py-3 w-24 text-center font-medium">Action</th>
<th class="text-left px-3 py-3 font-medium w-24">Date</th>
<th class="text-left px-3 py-3 font-medium">Description</th>
<th class="text-left px-3 py-3 font-medium w-40">Bien <span class="text-red-400">*</span></th>
<th class="text-left px-3 py-3 font-medium w-44">Catégorie</th>
<th class="text-right px-3 py-3 font-medium w-24" title="Frais de gestion locative déduits par l'agence">Frais agence</th>
<th class="text-right px-3 py-3 font-medium w-28">Montant</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50 dark:divide-gray-800">
{#each transactions as t, i (i)}
{#if t.status !== 'absorbed'}
<!-- Ligne normale ou ventilée -->
<tr class="transition-colors
{t.alreadyImported ? 'opacity-40 bg-gray-50 dark:bg-gray-800/30' : ''}
{!t.alreadyImported && t.status === 'ignore' ? 'opacity-30' : ''}
{t.status === 'split' ? 'bg-purple-50/40 dark:bg-purple-950/10' : ''}
{t._mergedWithIdx != null ? 'bg-amber-50/30 dark:bg-amber-950/10' : ''}
{mergeAnchorIdx === i ? 'ring-2 ring-inset ring-amber-400' : ''}
{!t.alreadyImported && t.status === 'import' && !t.property_id ? 'bg-red-50/40 dark:bg-red-950/10' :
!t.alreadyImported && t.status === 'import' && !t.category_id ? 'bg-amber-50/40 dark:bg-amber-950/10' :
!t.alreadyImported && t.status === 'import' ? 'hover:bg-gray-50 dark:hover:bg-gray-800/40' : ''}">
<!-- Boutons action -->
<td class="px-3 py-2 text-center">
<div class="inline-flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<button on:click={() => transactions[i].status = transactions[i].status === 'ignore' ? 'import' : (transactions[i].status === 'split' ? 'split' : 'import')}
title="Importer"
class="px-2.5 py-1 text-xs transition-colors
{t.status === 'import' || t.status === 'split' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'}">
<Check size={12}/>
</button>
{#if t.status !== 'ignore' && isLoanPayment(t.amount)}
<button on:click={() => autoSplit(i)}
title="Split automatique intérêts/capital"
class="px-2.5 py-1 text-xs border-l border-gray-200 dark:border-gray-700 transition-colors
{t.status === 'split' ? 'bg-purple-600 text-white' : 'text-yellow-600 bg-yellow-50 hover:bg-yellow-100 dark:bg-yellow-950/30 dark:hover:bg-yellow-950/50'}">
✂ auto
</button>
{:else}
<button on:click={() => openSplit(i)}
title="Ventiler sur plusieurs biens"
class="px-2.5 py-1 text-xs border-l border-gray-200 dark:border-gray-700 transition-colors
{t.status === 'split' ? 'bg-purple-600 text-white' : 'text-gray-400 hover:bg-purple-50 dark:hover:bg-purple-900'}">
<GitFork size={12}/>
</button>
{/if}
<!-- Bouton fusion -->
{#if t._mergedWithIdx != null}
<button on:click={() => unmerge(i)}
title="Défusionner"
class="px-2.5 py-1 text-xs border-l border-gray-200 dark:border-gray-700 bg-amber-500 text-white hover:bg-amber-600 transition-colors">
<Link2 size={12}/>
</button>
{:else}
<button on:click={() => startMerge(i)}
title={mergeAnchorIdx === null ? 'Fusionner avec une autre ligne' : mergeAnchorIdx === i ? 'Annuler fusion' : 'Fusionner avec cette ligne'}
class="px-2.5 py-1 text-xs border-l border-gray-200 dark:border-gray-700 transition-colors
{mergeAnchorIdx === i ? 'bg-amber-500 text-white' : mergeAnchorIdx !== null ? 'bg-green-500 text-white hover:bg-green-600' : 'text-gray-400 hover:bg-amber-50 dark:hover:bg-amber-950/30'}">
<Link2 size={12}/>
</button>
{/if}
<button on:click={() => transactions[i].status = 'ignore'}
title="Ignorer"
class="px-2.5 py-1 text-xs border-l border-gray-200 dark:border-gray-700 transition-colors
{t.status === 'ignore' ? 'bg-gray-500 text-white' : 'text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'}">
<X size={12}/>
</button>
</div>
</td>
<td class="px-3 py-2 text-gray-400 dark:text-gray-500 text-xs whitespace-nowrap">
{fmtDate(t.date)}
{#if t.alreadyImported}
<div class="text-xs text-gray-400 italic">déjà importée</div>
{/if}
</td>
<td class="px-3 py-2">
{#if t.status === 'split'}
<!-- Afficher les parts -->
<div class="space-y-0.5">
{#each t.splits as s}
<div class="flex items-center gap-1 text-xs">
<span class="text-purple-600 dark:text-purple-400 font-medium">{fmt(s.amount)}</span>
<span class="text-gray-400">{properties.find(p => p.id === s.property_id)?.name || '?'}</span>
</div>
{/each}
<button on:click={() => removeSplit(i)} class="text-xs text-red-400 hover:text-red-600">× annuler ventilation</button>
</div>
{:else}
<input bind:value={transactions[i].description}
disabled={t.status === 'ignore'}
placeholder="Description..."
class="w-full px-2 py-1 rounded border border-transparent hover:border-gray-200 dark:hover:border-gray-700 focus:border-blue-400 bg-transparent focus:bg-white dark:focus:bg-gray-800 text-gray-900 dark:text-white text-xs focus:outline-none transition-colors min-w-[180px] disabled:cursor-not-allowed"/>
{/if}
</td>
<td class="px-3 py-2">
{#if t.status === 'import'}
<select bind:value={transactions[i].property_id}
class="w-full px-2 py-1 rounded border text-xs
{!t.property_id ? 'border-red-300 bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-300' : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'}
focus:outline-none focus:ring-1 focus:ring-blue-500">
<option value="">— Choisir —</option>
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
</select>
{:else if t.status === 'split'}
<span class="text-xs text-purple-600 dark:text-purple-400 font-medium">Ventilée</span>
{:else}
<span class="text-xs text-gray-300 dark:text-gray-600 italic">ignorée</span>
{/if}
</td>
<td class="px-3 py-2">
{#if t.status === 'import'}
<select bind:value={transactions[i].category_id}
class="w-full px-2 py-1 rounded border text-xs
{!t.category_id ? 'border-amber-300 bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-300' : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'}
focus:outline-none focus:ring-1 focus:ring-blue-500">
<option value="">— Sans catégorie —</option>
{#each catsFor(t.type) as c}
<option value={c.id}>{c.name}{c.tax_deductible ? ' ✓' : ''}</option>
{/each}
</select>
{/if}
</td>
<td class="px-3 py-2 text-right">
{#if t.status === 'import' && t.type === 'income'}
<input
type="number" min="0" step="0.01"
placeholder="0,00"
value={t.agencyFee || ''}
on:change={(e) => { transactions[i].agencyFee = parseFloat(e.target.value) || 0; transactions = [...transactions]; }}
class="w-20 px-2 py-1 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-xs text-right focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{:else}
<span class="text-xs text-gray-300"></span>
{/if}
</td>
<td class="px-3 py-2 text-right font-semibold whitespace-nowrap text-xs
{t.type === 'income' ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}">
{t.type === 'income' ? '+' : ''}{fmt(t.amount)}
{#if t._mergedWithIdx != null}
<div class="text-xs font-normal text-amber-600 dark:text-amber-400">⛓ 2 lignes fusionnées</div>
{/if}
{#if (t.agencyFee || 0) > 0}
<div class="text-xs font-normal text-orange-500">{fmt(t.agencyFee)} frais</div>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
</div>
<div class="mt-3 text-xs text-gray-400 dark:text-gray-500 flex flex-wrap gap-4">
<span class="flex items-center gap-1.5"><span class="inline-flex items-center justify-center w-5 h-5 rounded bg-blue-600 text-white"><Check size={10}/></span>Importer</span>
<span class="flex items-center gap-1.5"><span class="inline-flex items-center justify-center w-5 h-5 rounded bg-purple-600 text-white"><GitFork size={10}/></span>Ventiler sur plusieurs biens</span>
<span class="flex items-center gap-1.5"><span class="inline-flex items-center justify-center w-5 h-5 rounded bg-gray-500 text-white"><X size={10}/></span>Ignorer</span>
<span class="flex items-center gap-1.5"><span class="inline-flex items-center justify-center w-5 h-5 rounded bg-amber-500 text-white"><Link2 size={10}/></span>Fusionner 2 prélèvements → 1 mensualité</span>
</div>
<!-- Étape 3 -->
{:else if step === 'done'}
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-10 text-center">
<div class="w-14 h-14 bg-green-100 dark:bg-green-950 rounded-full flex items-center justify-center mx-auto mb-4">
<Check size={28} class="text-green-600 dark:text-green-400"/>
</div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Import terminé</h2>
<p class="text-gray-500 dark:text-gray-400 text-sm mb-6">
{result.imported} transaction{result.imported > 1 ? 's' : ''} importée{result.imported > 1 ? 's' : ''}
{#if result.skipped > 0}· {result.skipped} ignorée{result.skipped > 1 ? 's' : ''}{/if}
</p>
{#if result.errors?.length > 0}
<div class="text-left mb-6 p-3 bg-red-50 dark:bg-red-950/30 rounded-lg text-xs text-red-600">
{#each result.errors as e}<p>{e}</p>{/each}
</div>
{/if}
<div class="flex justify-center gap-3">
<button on:click={reset} class="px-4 py-2 text-sm border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
Nouvel import
</button>
<a href="/transactions" class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
Voir les transactions →
</a>
</div>
</div>
{/if}
</div>
<!-- Modal sélection ligne d'amortissement -->
{#if loanPickerIdx !== null}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-2xl shadow-xl border border-gray-100 dark:border-gray-800 flex flex-col max-h-[80vh]">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<div>
<h2 class="font-semibold text-gray-900 dark:text-white">Choisir la ligne d'amortissement</h2>
<p class="text-xs text-gray-400 mt-0.5">
{transactions[loanPickerIdx]?.description || '—'} ·
<span class="font-medium text-red-500">{fmt(transactions[loanPickerIdx]?.amount)}</span>
</p>
</div>
<button on:click={closeLoanPicker} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
</div>
<div class="overflow-y-auto flex-1 px-6 py-4 space-y-5">
{#if loanPickerLoading}
<div class="text-center py-10 text-gray-400 text-sm">Chargement...</div>
{:else}
{#each loanPickerData as lr}
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
{lr.loan.label}{lr.loan.property_name || ''}
</p>
{#if lr.lines.length === 0}
<p class="text-xs text-gray-400 italic">Aucune ligne pour cette année.</p>
{:else}
<table class="w-full text-xs">
<thead>
<tr class="text-gray-400 border-b border-gray-100 dark:border-gray-800">
<th class="text-left pb-1.5 pr-3">Éch.</th>
<th class="text-left pb-1.5 pr-3">Date</th>
<th class="text-right pb-1.5 pr-3">Capital</th>
<th class="text-right pb-1.5 pr-3 text-blue-500">Intérêts</th>
<th class="text-right pb-1.5">Mensualité</th>
</tr>
</thead>
<tbody>
{#each lr.lines as line}
<tr class="border-b border-gray-50 dark:border-gray-800 hover:bg-blue-50 dark:hover:bg-blue-950/20 cursor-pointer"
on:click={() => selectLoanLine(lr, line)}>
<td class="py-2 pr-3 text-gray-400">#{line.rank}</td>
<td class="py-2 pr-3">{fmtDate(line.due_date.substring(0,10))}</td>
<td class="py-2 pr-3 text-right font-medium">{fmt(line.capital)}</td>
<td class="py-2 pr-3 text-right text-blue-600 dark:text-blue-400">{fmt(line.interest)}</td>
<td class="py-2 text-right text-gray-500">{fmt(line.total_amount)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/each}
{/if}
</div>
</div>
</div>
{/if}
<!-- Modal split -->
{#if splitIdx !== null && splitSource}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-xl shadow-xl border border-gray-100 dark:border-gray-800">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<div>
<h2 class="font-semibold text-gray-900 dark:text-white">Ventiler la transaction</h2>
<p class="text-xs text-gray-400 mt-0.5">
{splitSource.description || '—'} ·
<span class="font-medium {splitSource.type === 'income' ? 'text-green-600' : 'text-red-500'}">
{fmt(splitSource.amount)}
</span>
</p>
</div>
<button on:click={closeSplit} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
</div>
<div class="px-6 py-5 space-y-4">
{#if splitError}<p class="text-red-500 text-sm">{splitError}</p>{/if}
<p class="text-xs text-gray-500 dark:text-gray-400">Répartissez le montant entre plusieurs biens. Chaque part doit avoir un bien et une catégorie.</p>
{#each splitParts as part, i}
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 space-y-3">
<div class="flex items-center justify-between">
<!-- Toggle type par part -->
<div class="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<button on:click={() => { splitParts[i].type = 'income'; splitParts[i].category_id = ''; splitParts = [...splitParts]; }}
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'income' ? 'bg-green-600 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
Revenu
</button>
<button on:click={() => { splitParts[i].type = 'expense'; splitParts[i].category_id = ''; splitParts = [...splitParts]; }}
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'expense' ? 'bg-red-500 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
Dépense
</button>
</div>
<span class="text-sm font-semibold {part.type === 'income' ? 'text-green-600' : 'text-red-500'}">{part.type === 'income' ? '+' : ''}{fmt(Math.abs(part.amount))}</span>
</div>
<!-- Slider (uniquement si tous même type) -->
{#if splitParts.every(p => p.type === splitSource.type)}
<div class="flex items-center gap-3">
<input type="range" min="0" max="100" step="0.5" value={part.pct}
on:input={(e) => updateSplitPct(i, e.target.value)} class="flex-1 accent-blue-600"/>
<div class="flex items-center gap-1">
<input type="number" min="0" max="100" step="0.5" value={part.pct}
on:change={(e) => updateSplitPct(i, e.target.value)}
class="w-14 px-2 py-1 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs text-right focus:outline-none focus:ring-2 focus:ring-blue-500"/>
<span class="text-xs text-gray-400">%</span>
</div>
</div>
{/if}
<div class="grid grid-cols-3 gap-2">
<div>
<label for="split-prop-{i}" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Montant (€)</label>
<input type="number" min="0" step="0.01" value={part.amount}
on:change={(e) => updateSplitAmount(i, e.target.value)}
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Bien *</label>
<select bind:value={splitParts[i].property_id}
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Choisir...</option>
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
</select>
</div>
<div>
<label for="split-cat-{i}" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Catégorie</label>
<select id="split-cat-{i}" bind:value={splitParts[i].category_id}
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value=""></option>
{#each catsFor(part.type) as c}
<option value={c.id}>{c.name}{c.tax_deductible ? ' ✓' : ''}</option>
{/each}
</select>
</div>
</div>
<div>
<label for="split-desc-{i}" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Description</label>
<input id="split-desc-{i}" bind:value={splitParts[i].description}
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
</div>
{/each}
<!-- Net check -->
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg px-4 py-3 text-xs space-y-1">
<div class="flex justify-between">
<span class="text-green-600">Revenus</span>
<span class="font-medium text-green-600">+{fmt(splitNetIncome)}</span>
</div>
<div class="flex justify-between">
<span class="text-red-500">Dépenses</span>
<span class="font-medium text-red-500">{fmt(splitNetExpense)}</span>
</div>
<div class="flex justify-between border-t border-gray-200 dark:border-gray-700 pt-1 mt-1">
<span class="font-semibold text-gray-700 dark:text-gray-300">
Net ({splitSource.type === 'income' ? 'revenus dépenses' : 'dépenses revenus'})
</span>
<span class="font-semibold {Math.abs(splitDiff) <= 0.01 ? 'text-green-600' : 'text-red-500'}">
{fmt(splitNet)}
{#if Math.abs(splitDiff) <= 0.01}
<span class="text-green-500 ml-1"></span>
{:else}
<span class="text-red-400 ml-1">{fmt(splitSource.amount)}</span>
{/if}
</span>
</div>
</div>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
<button on:click={closeSplit} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
<button on:click={applySplit} disabled={Math.abs(splitDiff) > 0.01}
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
<GitFork size={15}/> Ventiler
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,350 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/stores/api.js';
import { Building2, Trash2, X, Check, AlertCircle } from 'lucide-svelte';
let loans = [];
let properties = [];
let categories = [];
let loading = true;
let showUpload = false;
let showDetail = null;
let uploading = false;
let error = '';
let yearDetail = String(new Date().getFullYear());
let lines = [];
let annualSummary = null;
// Les deux prêts connus — données embarquées dans le backend
const KNOWN_LOANS = [
{ reference: '781495E', label: 'Prêt CE 781495E', initial_amount: 183765, monthly_payment: 1084.75 },
{ reference: '781728E', label: 'Prêt CE 781728E', initial_amount: 122946, monthly_payment: 725.74 },
];
let selectedLoan = KNOWN_LOANS[0];
let selectedPropertyId = '';
const years = Array.from({ length: 8 }, (_, i) => String(2024 + i));
const fmt = (n) => Number(n || 0).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €';
const fmtDate = (d) => { if (!d) return '—'; const p = d.split('-'); return `${p[2]}/${p[1]}/${p[0]}`; };
onMount(async () => {
[properties, categories] = await Promise.all([
api.properties.list(),
api.categories.list(),
]);
properties = properties || [];
categories = categories || [];
if (properties.length > 0) selectedPropertyId = properties[0].id;
await load();
});
async function load() {
loading = true;
loans = await api.loans.list() || [];
loading = false;
}
async function openDetail(loan) {
showDetail = loan;
await loadDetail();
}
async function loadDetail() {
if (!showDetail) return;
[lines, annualSummary] = await Promise.all([
api.loans.lines(showDetail.id, { year: yearDetail }),
api.loans.annualSummary(showDetail.id, { year: yearDetail }),
]);
lines = lines || [];
annualSummary = annualSummary || null;
}
async function addLoan() {
if (!selectedPropertyId) { error = 'Sélectionnez un bien.'; return; }
error = '';
uploading = true;
try {
const result = await api.loans.createWithData({
property_id: selectedPropertyId,
label: selectedLoan.label,
reference: selectedLoan.reference,
initial_amount: selectedLoan.initial_amount,
monthly_payment: selectedLoan.monthly_payment,
});
showUpload = false;
await load();
} catch (e) { error = e.message; }
uploading = false;
}
async function deleteLoan(id, label) {
if (!confirm(`Supprimer le prêt "${label}" ?`)) return;
await api.loans.delete(id);
if (showDetail?.id === id) showDetail = null;
await load();
}
async function createTransactions() {
if (!showDetail || !lines.length) return;
const interestCat = categories.find(c =>
c.name.toLowerCase().includes('intérêt') || c.name.toLowerCase().includes('interet')
);
const capitalCat = categories.find(c => c.name.toLowerCase().includes('capital'));
if (!interestCat) {
alert('Créez d\'abord une catégorie "Intérêts emprunt" (déductible) dans la page Catégories.');
return;
}
let created = 0;
for (const line of lines) {
if (line.capital <= 0) continue;
await api.transactions.create({
property_id: showDetail.property_id,
category_id: interestCat.id,
type: 'expense',
amount: line.interest,
date: line.due_date,
description: `Intérêts ${showDetail.reference} — échéance ${line.rank}`,
});
if (capitalCat) {
await api.transactions.create({
property_id: showDetail.property_id,
category_id: capitalCat.id,
type: 'expense',
amount: line.capital,
date: line.due_date,
description: `Capital ${showDetail.reference} — échéance ${line.rank}`,
});
}
created++;
}
alert(`✓ ${created} échéances créées en transactions pour ${yearDetail}.`);
}
// Prêts déjà ajoutés (pour désactiver le doublon)
$: existingRefs = loans.map(l => l.reference);
</script>
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<Building2 size={22} class="text-gray-400"/>
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Prêts immobiliers</h1>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-0.5">Tableau d'amortissement — décomposition intérêts / capital</p>
</div>
</div>
<button on:click={() => { showUpload = true; error = ''; }}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
<Building2 size={15}/> Ajouter un prêt
</button>
</div>
<div class="bg-blue-50 dark:bg-blue-950/30 border border-blue-100 dark:border-blue-900 rounded-xl p-4 mb-6 text-sm text-blue-700 dark:text-blue-300">
<p class="font-medium mb-1">Pourquoi gérer les prêts ici ?</p>
<p class="text-xs text-blue-600 dark:text-blue-400">
Vos remboursements mensuels mélangent <strong>capital</strong> (non déductible) et <strong>intérêts</strong> (déductibles fiscalement).
Ce module connaît la décomposition exacte pour chaque mois et peut créer automatiquement
les transactions séparées pour la liasse fiscale.
</p>
</div>
{#if loading}
<div class="space-y-3">
{#each [1,2] as _}<div class="h-24 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>{/each}
</div>
{:else if loans.length === 0}
<div class="text-center py-16 text-gray-400">
<Building2 size={40} class="mx-auto mb-3 opacity-30"/>
<p class="text-sm mb-4">Aucun prêt configuré.</p>
<button on:click={() => showUpload = true}
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
Ajouter un prêt
</button>
</div>
{:else}
<div class="space-y-3">
{#each loans as loan (loan.id)}
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-5 flex items-center justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<h2 class="font-semibold text-gray-900 dark:text-white">{loan.label}</h2>
<span class="text-xs text-gray-400 font-mono bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{loan.reference}</span>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">{loan.property_name}</p>
<div class="flex gap-6 text-xs text-gray-400 dark:text-gray-500">
<span>Capital initial : <strong class="text-gray-700 dark:text-gray-300">{fmt(loan.initial_amount)}</strong></span>
<span>Mensualité : <strong class="text-gray-700 dark:text-gray-300">{fmt(loan.monthly_payment)}</strong></span>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button on:click={() => openDetail(loan)}
class="px-3 py-1.5 text-sm border border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-400 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors">
Voir échéances
</button>
<button on:click={() => deleteLoan(loan.id, loan.label)}
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
<Trash2 size={15}/>
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Modal ajout prêt -->
{#if showUpload}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h2 class="font-semibold text-gray-900 dark:text-white">Ajouter un prêt</h2>
<button on:click={() => showUpload = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
</div>
<div class="px-6 py-5 space-y-5">
{#if error}
<div class="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400">
<AlertCircle size={13}/> {error}
</div>
{/if}
<!-- Choix du prêt -->
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Prêt *</label>
<div class="space-y-2">
{#each KNOWN_LOANS as loan}
<label class="flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-colors
{selectedLoan.reference === loan.reference
? 'border-blue-400 bg-blue-50 dark:bg-blue-950/30'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}
{existingRefs.includes(loan.reference) ? 'opacity-40 cursor-not-allowed' : ''}">
<input type="radio" bind:group={selectedLoan} value={loan}
disabled={existingRefs.includes(loan.reference)}
class="accent-blue-600"/>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white">{loan.reference}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">
Capital {fmt(loan.initial_amount)} · Mensualité {fmt(loan.monthly_payment)}
</p>
</div>
{#if existingRefs.includes(loan.reference)}
<span class="text-xs text-gray-400">Déjà ajouté</span>
{/if}
</label>
{/each}
</div>
</div>
<!-- Choix du bien -->
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bien associé *</label>
<select bind:value={selectedPropertyId}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
</select>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500">
Les 216 échéances (2024→2044) sont déjà intégrées et seront chargées automatiquement.
</p>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
<button on:click={() => showUpload = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
<button on:click={addLoan} disabled={uploading}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
{#if uploading}
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"/>
{:else}
<Check size={15}/>
{/if}
Ajouter
</button>
</div>
</div>
</div>
{/if}
<!-- Modal détail échéances -->
{#if showDetail}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-xl border border-gray-100 dark:border-gray-800">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800 shrink-0">
<div>
<h2 class="font-semibold text-gray-900 dark:text-white">{showDetail.label}</h2>
<p class="text-xs text-gray-400 mt-0.5">{showDetail.property_name} · {showDetail.reference}</p>
</div>
<div class="flex items-center gap-3">
<select bind:value={yearDetail} on:change={loadDetail}
class="px-3 py-1.5 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>
<button on:click={() => showDetail = null} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
</div>
</div>
{#if annualSummary && annualSummary.months > 0}
<div class="grid grid-cols-4 gap-4 px-6 py-4 border-b border-gray-100 dark:border-gray-800 shrink-0 bg-gray-50 dark:bg-gray-800/50">
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Échéances</p>
<p class="text-xl font-semibold text-gray-900 dark:text-white">{annualSummary.months}</p>
</div>
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Total payé</p>
<p class="text-xl font-semibold text-gray-700 dark:text-gray-300">{fmt(annualSummary.total_payment)}</p>
</div>
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Intérêts <span class="text-blue-500 font-medium">✓ déductibles</span></p>
<p class="text-xl font-semibold text-blue-600 dark:text-blue-400">{fmt(annualSummary.total_interest)}</p>
</div>
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Capital remboursé</p>
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">{fmt(annualSummary.total_capital)}</p>
</div>
</div>
{/if}
<div class="flex-1 overflow-auto">
{#if lines.length === 0}
<div class="text-center py-12 text-gray-400 text-sm">Aucune échéance pour {yearDetail}.</div>
{:else}
<table class="w-full text-sm">
<thead class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<tr class="text-xs text-gray-500 dark:text-gray-400">
<th class="text-left px-4 py-3 font-medium">Rang</th>
<th class="text-left px-4 py-3 font-medium">Échéance</th>
<th class="text-right px-4 py-3 font-medium">Mensualité</th>
<th class="text-right px-4 py-3 font-medium text-blue-600 dark:text-blue-400">Intérêts ✓</th>
<th class="text-right px-4 py-3 font-medium">Capital</th>
<th class="text-right px-4 py-3 font-medium text-gray-400">Capital restant</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50 dark:divide-gray-800">
{#each lines as l (l.id)}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<td class="px-4 py-2.5 text-gray-400 text-xs">{l.rank}</td>
<td class="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{fmtDate(l.due_date)}</td>
<td class="px-4 py-2.5 text-right text-gray-600 dark:text-gray-400">{fmt(l.total_amount)}</td>
<td class="px-4 py-2.5 text-right font-semibold text-blue-600 dark:text-blue-400">{fmt(l.interest)}</td>
<td class="px-4 py-2.5 text-right text-gray-500 dark:text-gray-400">{fmt(l.capital)}</td>
<td class="px-4 py-2.5 text-right text-xs text-gray-400 dark:text-gray-500">{fmt(l.remaining_capital)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<div class="flex items-center justify-between px-6 py-4 border-t border-gray-100 dark:border-gray-800 shrink-0">
<p class="text-xs text-gray-400 dark:text-gray-500">
Les intérêts en bleu sont déductibles — nécessite catégorie "Intérêts emprunt".
</p>
{#if lines.length > 0}
<button on:click={createTransactions}
class="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium transition-colors">
<Check size={14}/> Créer les transactions {yearDetail}
</button>
{/if}
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,36 @@
<script>
import { goto } from '$app/navigation';
import { api, currentUser, authToken } from '$lib/stores/api.js';
let email = '', password = '', error = '';
async function submit() {
error = '';
try {
const res = await api.auth.login(email, password);
authToken.set(res.token);
currentUser.set(res.user);
goto('/');
} catch (e) {
error = e.message;
}
}
</script>
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 w-full max-w-sm border border-gray-100 dark:border-gray-800 shadow-sm">
<h1 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">🏠 Mes Locations</h1>
{#if error}<p class="text-red-500 text-sm mb-4">{error}</p>{/if}
<div class="space-y-4">
<input type="email" placeholder="Email" bind:value={email}
class="w-full px-4 py-2.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
<input type="password" placeholder="Mot de passe" bind:value={password}
on:keydown={(e) => e.key === 'Enter' && submit()}
class="w-full px-4 py-2.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
<button on:click={submit}
class="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
Se connecter
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,137 @@
<script>
import { onMount } from 'svelte';
import { api, currentUser } from '$lib/stores/api.js';
import { User, KeyRound, Check, AlertCircle } from 'lucide-svelte';
let profile = { email: '', name: '' };
let passwords = { current_password: '', new_password: '', confirm: '' };
let profileMsg = null; // { type: 'success'|'error', text }
let passwordMsg = null;
let savingProfile = false;
let savingPassword = false;
onMount(() => {
const u = $currentUser;
if (u) profile = { email: u.email, name: u.name };
});
async function saveProfile() {
profileMsg = null;
savingProfile = true;
try {
const updated = await api.auth.updateProfile(profile);
currentUser.set(updated);
profileMsg = { type: 'success', text: 'Profil mis à jour.' };
} catch (e) {
profileMsg = { type: 'error', text: e.message };
}
savingProfile = false;
}
async function savePassword() {
passwordMsg = null;
if (passwords.new_password !== passwords.confirm) {
passwordMsg = { type: 'error', text: 'Les mots de passe ne correspondent pas.' };
return;
}
if (passwords.new_password.length < 6) {
passwordMsg = { type: 'error', text: 'Minimum 6 caractères.' };
return;
}
savingPassword = true;
try {
await api.auth.updatePassword({
current_password: passwords.current_password,
new_password: passwords.new_password,
});
passwords = { current_password: '', new_password: '', confirm: '' };
passwordMsg = { type: 'success', text: 'Mot de passe modifié.' };
} catch (e) {
passwordMsg = { type: 'error', text: e.message };
}
savingPassword = false;
}
</script>
<div class="p-6 max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<User size={22} class="text-gray-400"/>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Mon profil</h1>
</div>
<!-- Informations -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-6 mb-5">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
<User size={15}/> Informations personnelles
</h2>
{#if profileMsg}
<div class="flex items-center gap-2 text-sm px-4 py-3 rounded-lg mb-4
{profileMsg.type === 'success'
? 'bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-300'}">
<AlertCircle size={14}/> {profileMsg.text}
</div>
{/if}
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nom affiché</label>
<input bind:value={profile.name}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Adresse email</label>
<input type="email" bind:value={profile.email}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div class="flex justify-end">
<button on:click={saveProfile} disabled={savingProfile}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
<Check size={15}/> {savingProfile ? 'Enregistrement...' : 'Enregistrer'}
</button>
</div>
</div>
</div>
<!-- Mot de passe -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-6">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
<KeyRound size={15}/> Changer le mot de passe
</h2>
{#if passwordMsg}
<div class="flex items-center gap-2 text-sm px-4 py-3 rounded-lg mb-4
{passwordMsg.type === 'success'
? 'bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-300'}">
<AlertCircle size={14}/> {passwordMsg.text}
</div>
{/if}
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mot de passe actuel</label>
<input type="password" bind:value={passwords.current_password} autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nouveau mot de passe</label>
<input type="password" bind:value={passwords.new_password} autocomplete="new-password"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Confirmer le nouveau mot de passe</label>
<input type="password" bind:value={passwords.confirm} autocomplete="new-password"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div class="flex justify-end">
<button on:click={savePassword} disabled={savingPassword}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
<KeyRound size={15}/> {savingPassword ? 'Modification...' : 'Changer le mot de passe'}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,174 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/stores/api.js';
import { Building2, Plus, Pencil, Trash2, RefreshCw, X, Check } from 'lucide-svelte';
let properties = [];
let loading = true;
let showForm = false;
let editingId = null;
let syncingId = null;
let error = '';
const empty = () => ({ name: '', address: '', type: 'airbnb', bank_account: '', ical_url: '', notes: '' });
let form = empty();
onMount(load);
async function load() {
loading = true;
properties = await api.properties.list() || [];
loading = false;
}
function openCreate() { form = empty(); editingId = null; showForm = true; error = ''; }
function openEdit(p) { form = { ...p }; editingId = p.id; showForm = true; error = ''; }
function cancel() { showForm = false; error = ''; }
async function save() {
error = '';
try {
if (editingId) await api.properties.update(editingId, form);
else await api.properties.create(form);
showForm = false;
await load();
} catch (e) { error = e.message; }
}
async function remove(id, name) {
if (!confirm(`Supprimer "${name}" ? Toutes les données associées seront perdues.`)) return;
await api.properties.delete(id);
await load();
}
async function sync(id) {
syncingId = id;
await api.calendar.sync(id);
syncingId = null;
}
const typeLabel = { airbnb: 'Airbnb', longterm: 'Longue durée' };
const typeBadge = {
airbnb: 'bg-orange-50 text-orange-700 dark:bg-orange-950 dark:text-orange-300',
longterm: 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'
};
</script>
<div class="p-6 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<Building2 size={22} class="text-gray-400"/>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Biens</h1>
</div>
<button on:click={openCreate}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
<Plus size={16}/> Ajouter un bien
</button>
</div>
{#if loading}
<div class="space-y-3">
{#each [1,2] as _}
<div class="h-28 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>
{/each}
</div>
{:else if properties.length === 0}
<div class="text-center py-16 text-gray-400">
<Building2 size={40} class="mx-auto mb-3 opacity-30"/>
<p>Aucun bien. Commencez par en ajouter un.</p>
</div>
{:else}
<div class="space-y-3">
{#each properties as p (p.id)}
<div class="bg-white dark:bg-gray-900 rounded-xl p-5 border border-gray-100 dark:border-gray-800 flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<h2 class="font-semibold text-gray-900 dark:text-white text-base">{p.name}</h2>
<span class="text-xs px-2 py-0.5 rounded-full font-medium {typeBadge[p.type]}">{typeLabel[p.type]}</span>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">{p.address}</p>
<div class="flex flex-wrap gap-4 text-xs text-gray-400 dark:text-gray-500">
{#if p.bank_account}<span>🏦 {p.bank_account}</span>{/if}
{#if p.ical_url}<span class="text-green-600 dark:text-green-400">✓ iCal configuré</span>{/if}
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
{#if p.type === 'airbnb' && p.ical_url}
<button on:click={() => sync(p.id)} title="Synchroniser iCal"
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors">
<RefreshCw size={16} class="{syncingId === p.id ? 'animate-spin' : ''}"/>
</button>
{/if}
<button on:click={() => openEdit(p)}
class="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<Pencil size={16}/>
</button>
<button on:click={() => remove(p.id, p.name)}
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
<Trash2 size={16}/>
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{#if showForm}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" on:click|self={cancel}>
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-lg shadow-xl border border-gray-100 dark:border-gray-800">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h2 class="font-semibold text-gray-900 dark:text-white">{editingId ? 'Modifier le bien' : 'Nouveau bien'}</h2>
<button on:click={cancel} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"><X size={18}/></button>
</div>
<div class="px-6 py-5 space-y-4">
{#if error}<p class="text-red-500 text-sm">{error}</p>{/if}
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nom du bien *</label>
<input bind:value={form.name} placeholder="Ex: Appartement Paris 11e"
class="input col-span-2 w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Adresse *</label>
<input bind:value={form.address} placeholder="12 rue de la Paix, 75001 Paris"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Type *</label>
<select bind:value={form.type}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="airbnb">Airbnb</option>
<option value="longterm">Longue durée</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Compte bancaire</label>
<input bind:value={form.bank_account} placeholder="FR76 xxxx xxxx"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
{#if form.type === 'airbnb'}
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">URL iCal Airbnb</label>
<input bind:value={form.ical_url} placeholder="https://www.airbnb.fr/calendar/ical/..."
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
<p class="text-xs text-gray-400 mt-1">Airbnb → Annonce → Paramètres → Calendrier → Exporter</p>
</div>
{/if}
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes</label>
<textarea bind:value={form.notes} rows={2} placeholder="Informations complémentaires..."
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"/>
</div>
</div>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
<button on:click={cancel} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 transition-colors">Annuler</button>
<button on:click={save}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
<Check size={15}/> {editingId ? 'Enregistrer' : 'Créer'}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,704 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/stores/api.js';
import { CreditCard, Plus, Trash2, X, Check, TrendingUp, TrendingDown, Pencil, GitFork, Layers } from 'lucide-svelte';
let transactions = [];
let properties = [];
let categories = [];
let loading = true;
let showForm = false;
let showSplit = false;
let showMixed = false;
let editingId = null;
let splitSource = null;
let error = '';
let splitError = '';
let mixedError = '';
let filterProperty = '';
let filterType = '';
let filterYear = String(new Date().getFullYear());
const years = Array.from({ length: 5 }, (_, i) => String(new Date().getFullYear() - i));
const empty = () => ({
property_id: '', category_id: '', type: 'expense',
amount: '', date: new Date().toISOString().slice(0, 10),
description: ''
});
let form = empty();
// Split state
let splitParts = [];
// Ventilation mixte state
const emptyPart = () => ({
type: 'expense', property_id: '', category_id: '',
amount: '', description: ''
});
let mixedDate = new Date().toISOString().slice(0, 10);
let mixedParts = [
{ ...emptyPart(), type: 'income' },
{ ...emptyPart(), type: 'expense' },
];
onMount(async () => {
[properties, categories] = await Promise.all([
api.properties.list(),
api.categories.list(),
]);
properties = properties || [];
categories = categories || [];
await load();
});
async function load() {
loading = true;
const params = {};
if (filterProperty) params.property_id = filterProperty;
if (filterType) params.type = filterType;
if (filterYear) params.year = filterYear;
transactions = await api.transactions.list(params) || [];
loading = false;
}
$: filteredCategories = categories.filter(c => c.type === form.type);
// ── Formulaire création/édition ───────────────────────────────────────────
function openCreate() { form = empty(); editingId = null; showForm = true; error = ''; }
function openEdit(t) {
form = {
property_id: t.property_id,
category_id: t.category_id || '',
type: t.type,
amount: t.amount,
date: t.date,
description: t.description || '',
};
editingId = t.id;
showForm = true;
error = '';
}
function cancel() { showForm = false; error = ''; editingId = null; }
async function save() {
error = '';
if (!form.property_id || !form.amount || !form.date) {
error = 'Bien, montant et date sont requis.';
return;
}
try {
const data = { ...form, amount: parseFloat(form.amount) };
if (editingId) await api.transactions.update(editingId, data);
else await api.transactions.create(data);
showForm = false;
editingId = null;
await load();
} catch (e) { error = e.message; }
}
async function remove(id) {
if (!confirm('Supprimer cette transaction ?')) return;
await api.transactions.delete(id);
await load();
}
// ── Ventilation mixte (sans source) ─────────────────────────────────────
function openMixed() {
mixedDate = new Date().toISOString().slice(0, 10);
mixedParts = [
{ ...emptyPart(), type: 'income', property_id: properties[0]?.id || '' },
{ ...emptyPart(), type: 'expense', property_id: properties[0]?.id || '' },
];
mixedError = '';
showMixed = true;
}
function addMixedPart() {
mixedParts = [...mixedParts, { ...emptyPart(), property_id: properties[0]?.id || '' }];
}
function removeMixedPart(i) {
mixedParts = mixedParts.filter((_, idx) => idx !== i);
}
$: mixedIncome = mixedParts.filter(p => p.type === 'income').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
$: mixedExpense = mixedParts.filter(p => p.type === 'expense').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
$: mixedNet = parseFloat((mixedExpense - mixedIncome).toFixed(2));
async function doMixed() {
mixedError = '';
for (const p of mixedParts) {
if (!p.property_id) { mixedError = 'Chaque ligne doit avoir un bien.'; return; }
if (!p.amount || parseFloat(p.amount) <= 0) { mixedError = 'Chaque ligne doit avoir un montant > 0.'; return; }
}
try {
for (const p of mixedParts) {
await api.transactions.create({
property_id: p.property_id,
category_id: p.category_id || '',
type: p.type,
amount: parseFloat(p.amount),
date: mixedDate,
description: p.description,
});
}
showMixed = false;
await load();
} catch (e) { mixedError = e.message; }
}
// ── Split (transaction existante) ─────────────────────────────────────────
function openSplit(t) {
splitSource = t;
splitError = '';
const total = t.amount;
splitParts = properties.slice(0, 2).map((p, i) => ({
property_id: p.id,
category_id: t.category_id || '',
type: t.type,
amount: parseFloat((total / 2).toFixed(2)),
description: t.description || '',
pct: 50,
}));
if (splitParts.length < 2) {
splitParts.push({
property_id: properties[0]?.id || '',
category_id: t.category_id || '',
type: t.type,
amount: parseFloat((total / 2).toFixed(2)),
description: t.description || '',
pct: 50,
});
}
showSplit = true;
}
function updatePct(idx, newPct) {
const total = splitSource.amount;
newPct = Math.min(100, Math.max(0, parseFloat(newPct) || 0));
if (splitParts.length === 2) {
const other = 100 - newPct;
splitParts[idx].pct = newPct;
splitParts[idx].amount = parseFloat((total * newPct / 100).toFixed(2));
splitParts[1 - idx].pct = other;
splitParts[1 - idx].amount = parseFloat((total * other / 100).toFixed(2));
} else {
splitParts[idx].pct = newPct;
splitParts[idx].amount = parseFloat((total * newPct / 100).toFixed(2));
}
splitParts = [...splitParts];
}
function updateAmount(idx, newAmount) {
const total = splitSource.amount;
newAmount = Math.abs(parseFloat(newAmount) || 0);
splitParts[idx].amount = newAmount;
splitParts[idx].pct = parseFloat((newAmount / total * 100).toFixed(1));
if (splitParts.length === 2) {
const j = 1 - idx;
const srcType = splitSource.type;
const allSameType = splitParts.every(p => p.type === srcType);
let otherAmount;
if (allSameType) {
otherAmount = parseFloat((total - newAmount).toFixed(2));
} else {
otherAmount = splitParts[idx].type === srcType
? parseFloat((newAmount - total).toFixed(2))
: parseFloat((total + newAmount).toFixed(2));
otherAmount = Math.max(0, otherAmount);
}
splitParts[j].amount = otherAmount;
splitParts[j].pct = parseFloat((otherAmount / total * 100).toFixed(1));
}
splitParts = [...splitParts];
}
// Validation : net des parts doit égaler le montant source
// Si source = expense : sum(expenses) - sum(incomes) = source.amount
// Si source = income : sum(incomes) - sum(expenses) = source.amount
$: splitNetExpense = splitParts.filter(p => p.type === 'expense').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
$: splitNetIncome = splitParts.filter(p => p.type === 'income').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
$: splitNet = parseFloat((splitSource?.type === 'income'
? splitNetIncome - splitNetExpense
: splitNetExpense - splitNetIncome
).toFixed(2));
$: splitDiff = parseFloat((splitNet - (splitSource?.amount || 0)).toFixed(2));
$: splitOk = Math.abs(splitDiff) <= 0.01;
async function doSplit() {
splitError = '';
if (!splitOk) {
const sign = splitSource.type === 'income' ? 'Revenus Dépenses' : 'Dépenses Revenus';
splitError = `${sign} = ${fmt(splitNet)}${fmt(splitSource.amount)}`;
return;
}
for (const p of splitParts) {
if (!p.property_id) { splitError = 'Chaque part doit avoir un bien.'; return; }
}
try {
await api.transactions.split(splitSource.id, {
source_id: splitSource.id,
splits: splitParts.map(p => ({
property_id: p.property_id,
category_id: p.category_id,
type: p.type,
amount: parseFloat(p.amount),
description: p.description,
})),
});
showSplit = false;
splitSource = null;
await load();
} catch (e) { splitError = e.message; }
}
// ── Utils ─────────────────────────────────────────────────────────────────
function rowClass(t) {
if (!t.category_id) return 'bg-amber-50/50 dark:bg-amber-950/10 hover:bg-amber-50 dark:hover:bg-amber-950/20';
return 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
}
const fmt = (n) => Number(n).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €';
const fmtDate = (d) => { if (!d) return '—'; const p = d.split('-'); return p.length === 3 ? p[2]+'/'+p[1]+'/'+p[0] : d; };
const catsFor = (type) => categories.filter(c => c.type === type);
$: totalIncome = transactions.filter(t => t.type === 'income').reduce((s, t) => s + t.amount, 0);
$: totalExpense = transactions.filter(t => t.type === 'expense').reduce((s, t) => s + t.amount, 0);
$: balance = totalIncome - totalExpense;
const selectClass = 'w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500';
</script>
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<CreditCard size={22} class="text-gray-400"/>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Transactions</h1>
</div>
<div class="flex gap-2">
<button on:click={openMixed}
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors">
<Layers size={15}/> Ventilation mixte
</button>
<button on:click={openCreate}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
<Plus size={16}/> Nouvelle transaction
</button>
</div>
</div>
<!-- KPIs -->
<div class="grid grid-cols-3 gap-3 mb-6">
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mb-1"><TrendingUp size={12} class="text-green-500"/> Revenus</p>
<p class="text-xl font-semibold text-green-600">{fmt(totalIncome)}</p>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mb-1"><TrendingDown size={12} class="text-red-500"/> Dépenses</p>
<p class="text-xl font-semibold text-red-500">{fmt(totalExpense)}</p>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Solde net</p>
<p class="text-xl font-semibold {balance >= 0 ? 'text-green-600' : 'text-red-500'}">{fmt(balance)}</p>
</div>
</div>
<!-- Filtres -->
<div class="flex flex-wrap gap-3 mb-4 items-center">
<select bind:value={filterProperty} on:change={load}
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={filterType} on:change={load}
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 types</option>
<option value="income">Revenus</option>
<option value="expense">Dépenses</option>
</select>
<select bind:value={filterYear} on:change={load}
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>
{#if transactions.some(t => !t.category_id)}
<span class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400 ml-2">
<span class="w-2.5 h-2.5 rounded-sm bg-amber-200 dark:bg-amber-800 inline-block"></span>
Sans catégorie
</span>
{/if}
</div>
<!-- Table -->
{#if loading}
<div class="space-y-2">
{#each [1,2,3,4,5] as _}<div class="h-12 bg-gray-100 dark:bg-gray-800 rounded-lg animate-pulse"/>{/each}
</div>
{:else if transactions.length === 0}
<div class="text-center py-16 text-gray-400">
<CreditCard size={40} class="mx-auto mb-3 opacity-30"/>
<p>Aucune transaction pour ces filtres.</p>
</div>
{:else}
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="border-b border-gray-100 dark:border-gray-800">
<tr class="text-xs text-gray-500 dark:text-gray-400">
<th class="text-left px-4 py-3 font-medium">Date</th>
<th class="text-left px-4 py-3 font-medium">Description</th>
<th class="text-left px-4 py-3 font-medium">Catégorie</th>
<th class="text-left px-4 py-3 font-medium">Bien</th>
<th class="text-right px-4 py-3 font-medium">Montant</th>
<th class="px-4 py-3 w-24"/>
</tr>
</thead>
<tbody class="divide-y divide-gray-50 dark:divide-gray-800">
{#each transactions as t (t.id)}
<tr class="transition-colors {rowClass(t)}">
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 whitespace-nowrap">{fmtDate(t.date)}</td>
<td class="px-4 py-3 text-gray-900 dark:text-white max-w-xs truncate">{t.description || '—'}</td>
<td class="px-4 py-3">
{#if t.category_name}
<span class="text-xs px-2 py-0.5 rounded-full font-medium
{t.type === 'expense' ? 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300' : 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300'}">
{t.category_name}
</span>
{:else}
<span class="text-xs px-2 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300">
Sans catégorie
</span>
{/if}
</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">{t.property_name}</td>
<td class="px-4 py-3 text-right font-semibold whitespace-nowrap
{t.type === 'income' ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}">
{t.type === 'income' ? '+' : ''}{fmt(t.amount)}
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1">
<button on:click={() => openSplit(t)} title="Ventiler"
class="p-1.5 text-gray-400 hover:text-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-950 transition-colors">
<GitFork size={13}/>
</button>
<button on:click={() => openEdit(t)} title="Modifier"
class="p-1.5 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors">
<Pencil size={13}/>
</button>
<button on:click={() => remove(t.id)} title="Supprimer"
class="p-1.5 text-gray-400 hover:text-red-500 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
<Trash2 size={13}/>
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Modal création/édition -->
{#if showForm}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h2 class="font-semibold text-gray-900 dark:text-white">{editingId ? 'Modifier' : 'Nouvelle transaction'}</h2>
<button on:click={cancel} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
</div>
<div class="px-6 py-5 space-y-4">
{#if error}<p class="text-red-500 text-sm">{error}</p>{/if}
<div class="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<button on:click={() => { form.type = 'expense'; form.category_id = ''; }}
class="flex-1 py-2 text-sm font-medium transition-colors {form.type === 'expense' ? 'bg-red-500 text-white' : 'text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800'}">Dépense</button>
<button on:click={() => { form.type = 'income'; form.category_id = ''; }}
class="flex-1 py-2 text-sm font-medium transition-colors {form.type === 'income' ? 'bg-green-600 text-white' : 'text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800'}">Revenu</button>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bien *</label>
<select bind:value={form.property_id}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Sélectionner...</option>
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Catégorie</label>
<select bind:value={form.category_id}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Sans catégorie</option>
{#each filteredCategories as c}<option value={c.id}>{c.name}{c.tax_deductible ? ' ✓' : ''}</option>{/each}
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Montant (€) *</label>
<input type="number" step="0.01" min="0" bind:value={form.amount} placeholder="0.00"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date *</label>
<input type="date" bind:value={form.date}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Description</label>
<input bind:value={form.description} placeholder="Ex: Facture plombier, loyer janvier..."
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
<button on:click={cancel} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
<button on:click={save}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
<Check size={15}/> {editingId ? 'Enregistrer' : 'Créer'}
</button>
</div>
</div>
</div>
{/if}
<!-- Modal ventilation mixte -->
{#if showMixed}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-2xl shadow-xl border border-gray-100 dark:border-gray-800 max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<div>
<h2 class="font-semibold text-gray-900 dark:text-white">Ventilation mixte</h2>
<p class="text-xs text-gray-400 mt-0.5">Ex : loyer (revenu) + appel de fonds (dépense) → net débit bancaire</p>
</div>
<button on:click={() => showMixed = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
</div>
<div class="px-6 py-4 overflow-y-auto flex-1 space-y-4">
{#if mixedError}<p class="text-red-500 text-sm">{mixedError}</p>{/if}
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date</label>
<input type="date" bind:value={mixedDate}
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
{#each mixedParts as part, i}
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 space-y-3">
<div class="flex items-center justify-between">
<div class="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<button on:click={() => { mixedParts[i].type = 'income'; mixedParts[i].category_id = ''; mixedParts = [...mixedParts]; }}
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'income' ? 'bg-green-600 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
Revenu
</button>
<button on:click={() => { mixedParts[i].type = 'expense'; mixedParts[i].category_id = ''; mixedParts = [...mixedParts]; }}
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'expense' ? 'bg-red-500 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
Dépense
</button>
</div>
{#if mixedParts.length > 2}
<button on:click={() => removeMixedPart(i)} class="text-gray-400 hover:text-red-500"><X size={14}/></button>
{/if}
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="block text-xs text-gray-500 mb-1">Bien *</label>
<select bind:value={mixedParts[i].property_id} class={selectClass}>
<option value="">Choisir...</option>
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Catégorie</label>
<select bind:value={mixedParts[i].category_id} class={selectClass}>
<option value=""></option>
{#each catsFor(part.type) as c}<option value={c.id}>{c.name}</option>{/each}
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Montant (€) *</label>
<input type="number" step="0.01" min="0" bind:value={mixedParts[i].amount} placeholder="0.00"
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Description</label>
<input bind:value={mixedParts[i].description} placeholder="Ex : Loyer janvier, Appel de fonds Q1…"
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
</div>
{/each}
<button on:click={addMixedPart}
class="flex items-center gap-1.5 text-xs text-blue-600 dark:text-blue-400 hover:underline">
<Plus size={13}/> Ajouter une ligne
</button>
<!-- Récap net -->
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg px-4 py-3 text-xs space-y-1">
<div class="flex justify-between">
<span class="text-green-600">Revenus</span>
<span class="font-medium text-green-600">+{fmt(mixedIncome)}</span>
</div>
<div class="flex justify-between">
<span class="text-red-500">Dépenses</span>
<span class="font-medium text-red-500">{fmt(mixedExpense)}</span>
</div>
<div class="flex justify-between border-t border-gray-200 dark:border-gray-700 pt-1 mt-1">
<span class="font-semibold text-gray-700 dark:text-gray-300">Net bancaire</span>
<span class="font-semibold {mixedNet >= 0 ? 'text-red-500' : 'text-green-600'}">
{mixedNet >= 0 ? '' : '+'}{fmt(Math.abs(mixedNet))}
</span>
</div>
</div>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
<button on:click={() => showMixed = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
<button on:click={doMixed}
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors">
<Layers size={15}/> Créer {mixedParts.length} transactions
</button>
</div>
</div>
</div>
{/if}
<!-- Modal split (transaction existante) -->
{#if showSplit && splitSource}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-xl shadow-xl border border-gray-100 dark:border-gray-800 max-h-[90vh] flex flex-col">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<div>
<h2 class="font-semibold text-gray-900 dark:text-white">Ventiler la transaction</h2>
<p class="text-xs text-gray-400 mt-0.5">
{splitSource.description || '—'} ·
<span class="font-medium {splitSource.type === 'income' ? 'text-green-600' : 'text-red-500'}">
{splitSource.type === 'income' ? '+' : ''}{fmt(splitSource.amount)}
</span>
</p>
</div>
<button on:click={() => showSplit = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
</div>
<div class="px-6 py-5 space-y-4 overflow-y-auto flex-1">
{#if splitError}
<p class="text-red-500 text-sm">{splitError}</p>
{/if}
<p class="text-xs text-gray-500 dark:text-gray-400">
La transaction originale sera supprimée. Chaque part peut être un revenu ou une dépense — le net doit égaler le montant original.
</p>
{#each splitParts as part, i}
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 space-y-3">
<div class="flex items-center justify-between">
<!-- Toggle type -->
<div class="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<button on:click={() => { splitParts[i].type = 'income'; splitParts[i].category_id = ''; splitParts = [...splitParts]; }}
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'income' ? 'bg-green-600 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
Revenu
</button>
<button on:click={() => { splitParts[i].type = 'expense'; splitParts[i].category_id = ''; splitParts = [...splitParts]; }}
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'expense' ? 'bg-red-500 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
Dépense
</button>
</div>
<span class="text-sm font-semibold {part.type === 'income' ? 'text-green-600' : 'text-red-500'}">
{part.type === 'income' ? '+' : ''}{fmt(Math.abs(part.amount))}
</span>
</div>
<!-- Slider (uniquement si tous même type que source) -->
{#if splitParts.every(p => p.type === splitSource.type)}
<div class="flex items-center gap-3">
<input type="range" min="0" max="100" step="0.5"
value={part.pct}
on:input={(e) => updatePct(i, e.target.value)}
class="flex-1 accent-blue-600"/>
<div class="flex items-center gap-1">
<input type="number" min="0" max="100" step="0.5"
value={part.pct}
on:change={(e) => updatePct(i, e.target.value)}
class="w-14 px-2 py-1 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs text-right focus:outline-none"/>
<span class="text-xs text-gray-400">%</span>
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Montant (€)</label>
<input type="number" min="0" step="0.01"
value={part.amount}
on:change={(e) => updateAmount(i, e.target.value)}
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Bien *</label>
<select bind:value={splitParts[i].property_id} class={selectClass}>
<option value="">Choisir...</option>
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Catégorie</label>
<select bind:value={splitParts[i].category_id} class={selectClass}>
<option value="">Sans catégorie</option>
{#each catsFor(part.type) as c}
<option value={c.id}>{c.name}</option>
{/each}
</select>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Description</label>
<input bind:value={splitParts[i].description} class={selectClass}/>
</div>
</div>
</div>
{/each}
<!-- Net check -->
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg px-4 py-3 text-xs space-y-1">
<div class="flex justify-between">
<span class="text-green-600">Revenus</span>
<span class="font-medium text-green-600">+{fmt(splitNetIncome)}</span>
</div>
<div class="flex justify-between">
<span class="text-red-500">Dépenses</span>
<span class="font-medium text-red-500">{fmt(splitNetExpense)}</span>
</div>
<div class="flex justify-between border-t border-gray-200 dark:border-gray-700 pt-1 mt-1">
<span class="font-semibold text-gray-700 dark:text-gray-300">
Net ({splitSource.type === 'income' ? 'revenus dépenses' : 'dépenses revenus'})
</span>
<span class="font-semibold {splitOk ? 'text-green-600' : 'text-red-500'}">
{fmt(splitNet)}
{#if splitOk}
<span class="text-green-500 ml-1"></span>
{:else}
<span class="text-red-400 ml-1">{fmt(splitSource.amount)}</span>
{/if}
</span>
</div>
</div>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
<button on:click={() => showSplit = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
<button on:click={doSplit} disabled={!splitOk}
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
<GitFork size={15}/> Ventiler
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,157 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/stores/api.js';
import { Users, Plus, Trash2, X, Check, AlertCircle } from 'lucide-svelte';
let users = [];
let loading = true;
let showForm = false;
let error = '';
let successMsg = '';
let form = { email: '', name: '', password: '', confirm: '' };
onMount(load);
async function load() {
loading = true;
users = await api.users.list() || [];
loading = false;
}
function openForm() {
form = { email: '', name: '', password: '', confirm: '' };
error = '';
showForm = true;
}
async function create() {
error = '';
if (!form.email || !form.name || !form.password) { error = 'Tous les champs sont requis.'; return; }
if (form.password !== form.confirm) { error = 'Les mots de passe ne correspondent pas.'; return; }
if (form.password.length < 6) { error = 'Minimum 6 caractères.'; return; }
try {
await api.auth.register({ email: form.email, name: form.name, password: form.password });
showForm = false;
successMsg = `Compte "${form.name}" créé avec succès.`;
setTimeout(() => successMsg = '', 4000);
await load();
} catch (e) {
error = e.message;
}
}
async function remove(id, name) {
if (!confirm(`Supprimer le compte de "${name}" ?`)) return;
try {
await api.users.delete(id);
await load();
} catch (e) {
alert(e.message);
}
}
const fmtDate = (d) => new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' });
</script>
<div class="p-6 max-w-2xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<Users size={22} class="text-gray-400"/>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Utilisateurs</h1>
</div>
<button on:click={openForm}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
<Plus size={16}/> Ajouter un membre
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">
Ajoutez les membres de votre famille qui peuvent accéder à l'application.
Chacun a son propre compte et mot de passe.
</p>
{#if successMsg}
<div class="flex items-center gap-2 text-sm px-4 py-3 rounded-lg mb-4 bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-300">
<Check size={14}/> {successMsg}
</div>
{/if}
{#if loading}
<div class="space-y-2">
{#each [1,2] as _}<div class="h-16 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>{/each}
</div>
{:else}
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
{#each users as u, i (u.id)}
<div class="flex items-center gap-4 px-5 py-4
{i > 0 ? 'border-t border-gray-50 dark:border-gray-800' : ''}">
<!-- Avatar -->
<div class="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-sm font-semibold text-blue-700 dark:text-blue-300 shrink-0">
{u.name?.[0]?.toUpperCase() ?? '?'}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white">{u.name}</p>
<p class="text-xs text-gray-400 dark:text-gray-500">{u.email} · Depuis le {fmtDate(u.created_at)}</p>
</div>
{#if i === 0}
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 font-medium shrink-0">
Admin
</span>
{:else}
<button on:click={() => remove(u.id, u.name)}
class="p-2 text-gray-300 hover:text-red-500 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors shrink-0">
<Trash2 size={15}/>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Modal -->
{#if showForm}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" on:click|self={() => showForm = false}>
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h2 class="font-semibold text-gray-900 dark:text-white">Ajouter un membre</h2>
<button on:click={() => showForm = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
</div>
<div class="px-6 py-5 space-y-4">
{#if error}
<div class="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400">
<AlertCircle size={13}/> {error}
</div>
{/if}
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nom affiché</label>
<input bind:value={form.name} placeholder="Ex: Marie Dupont"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
<input type="email" bind:value={form.email} placeholder="marie@exemple.fr"
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mot de passe</label>
<input type="password" bind:value={form.password}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Confirmer le mot de passe</label>
<input type="password" bind:value={form.confirm}
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
</div>
</div>
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
<button on:click={() => showForm = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
<button on:click={create}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
<Check size={15}/> Créer le compte
</button>
</div>
</div>
</div>
{/if}

20
frontend/svelte.config.js Normal file
View File

@@ -0,0 +1,20 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
}),
},
vitePlugin: {
onwarn: (warning, handler) => {
if (warning.code.startsWith('a11y-')) return;
handler(warning);
},
},
};
export default config;

View File

@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'media',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};

14
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': {
target: 'http://localhost:9000',
changeOrigin: true,
}
}
}
});