up
This commit is contained in:
2522
frontend/package-lock.json
generated
Normal file
2522
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
8
frontend/src/app.css
Normal file
8
frontend/src/app.css
Normal 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
13
frontend/src/app.html
Normal 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>
|
||||
112
frontend/src/lib/stores/api.js
Normal file
112
frontend/src/lib/stores/api.js
Normal 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]);
|
||||
}
|
||||
2
frontend/src/routes/+layout.js
Normal file
2
frontend/src/routes/+layout.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
133
frontend/src/routes/+layout.svelte
Normal file
133
frontend/src/routes/+layout.svelte
Normal 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}
|
||||
471
frontend/src/routes/+page.svelte
Normal file
471
frontend/src/routes/+page.svelte
Normal 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>
|
||||
275
frontend/src/routes/calendar/+page.svelte
Normal file
275
frontend/src/routes/calendar/+page.svelte
Normal 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}
|
||||
223
frontend/src/routes/categories/+page.svelte
Normal file
223
frontend/src/routes/categories/+page.svelte
Normal 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}
|
||||
239
frontend/src/routes/documents/+page.svelte
Normal file
239
frontend/src/routes/documents/+page.svelte
Normal 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>
|
||||
126
frontend/src/routes/fiscal/+page.svelte
Normal file
126
frontend/src/routes/fiscal/+page.svelte
Normal 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>
|
||||
896
frontend/src/routes/import/+page.svelte
Normal file
896
frontend/src/routes/import/+page.svelte
Normal 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}
|
||||
350
frontend/src/routes/loans/+page.svelte
Normal file
350
frontend/src/routes/loans/+page.svelte
Normal 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}
|
||||
36
frontend/src/routes/login/+page.svelte
Normal file
36
frontend/src/routes/login/+page.svelte
Normal 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>
|
||||
137
frontend/src/routes/profile/+page.svelte
Normal file
137
frontend/src/routes/profile/+page.svelte
Normal 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>
|
||||
174
frontend/src/routes/properties/+page.svelte
Normal file
174
frontend/src/routes/properties/+page.svelte
Normal 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}
|
||||
704
frontend/src/routes/transactions/+page.svelte
Normal file
704
frontend/src/routes/transactions/+page.svelte
Normal 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}
|
||||
157
frontend/src/routes/users/+page.svelte
Normal file
157
frontend/src/routes/users/+page.svelte
Normal 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
20
frontend/svelte.config.js
Normal 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;
|
||||
13
frontend/tailwind.config.js
Normal file
13
frontend/tailwind.config.js
Normal 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
14
frontend/vite.config.js
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user