This commit is contained in:
2026-04-11 12:12:07 +02:00
parent 3bc6e2e080
commit 5b3c5ebb2f
92 changed files with 10948 additions and 35 deletions

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# ── Binaires ─────────────────────────────────────────────────────────────────
*.exe
*.exe~
*.dll
*.so
*.dylib
RentalManager
# ── Go ────────────────────────────────────────────────────────────────────────
/vendor/
*.test
*.out
/dist/
# ── Données applicatives ──────────────────────────────────────────────────────
/data/rental.db
/data/rental.db-wal
/data/rental.db-shm
/data/*.db
/data/documents/
# ── Frontend ──────────────────────────────────────────────────────────────────
frontend/node_modules/
frontend/.svelte-kit/
frontend/build/
frontend/.env
frontend/.env.local
frontend/.env.*.local
# ── Web embed (build artifact) ────────────────────────────────────────────────
web/dist/
# ── Environnement ─────────────────────────────────────────────────────────────
.env
.env.local
.env.production
# ── OS ────────────────────────────────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
# ── Éditeurs ──────────────────────────────────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
*~
# ── Logs ──────────────────────────────────────────────────────────────────────
*.log

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o rental-manager ./cmd/server
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ .
RUN npm run build
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/rental-manager .
COPY --from=frontend-builder /app/frontend/build ./frontend/build
RUN mkdir -p /app/data/documents
EXPOSE 8080
ENV TZ=Europe/Paris
CMD ["./rental-manager"]

133
Makefile Normal file
View File

@@ -0,0 +1,133 @@
# ── Rental Manager — Makefile ─────────────────────────────────────────────────
APP = RentalManager
GO_CMD = ./cmd/server
DATA_DIR = ./data
FRONTEND_DIR = ./frontend
ifeq ($(OS),Windows_NT)
BINARY = $(APP).exe
else
BINARY = $(APP)
endif
.PHONY: help build frontend backend dev-back dev-front \
clean clean-db clean-all deps tidy test \
docker-build docker-up docker-down docker-logs docker-restart \
status version data
help: ## Affiche cette aide
@echo ""
@echo " Rental Manager"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
@echo ""
# ── Build complet ─────────────────────────────────────────────────────────────
build: frontend backend ## Build complet (frontend + backend)
frontend: ## Build le frontend et copie dans web/build
@echo "Building frontend..."
cd $(FRONTEND_DIR) && npm run build
@if exist "web\build" rmdir /s /q "web\build"
mkdir web\build
xcopy /E /I /Y frontend\build web\build
@echo "Frontend built successfully"
backend: ## Compile le binaire Go
@echo "Building backend..."
ifeq ($(OS),Windows_NT)
go build -ldflags="-s -w -H windowsgui" -o $(BINARY) $(GO_CMD)
else
CGO_ENABLED=0 go build -ldflags="-s -w -H windowsgui" -o $(BINARY) $(GO_CMD)
endif
@echo "Backend built: ./$(BINARY)"
# ── Développement ─────────────────────────────────────────────────────────────
dev-back: data ## Lance le backend Go (port 9000)
ifeq ($(OS),Windows_NT)
set PORT=9000& go run $(GO_CMD)
else
PORT=9000 go run $(GO_CMD)
endif
dev-front: ## Lance le frontend Svelte (port 5173)
cd $(FRONTEND_DIR) && npm run dev
# ── Dépendances ───────────────────────────────────────────────────────────────
deps: ## Installe toutes les dépendances
go mod tidy
cd $(FRONTEND_DIR) && npm install
tidy: ## go mod tidy
go mod tidy
# ── Docker ────────────────────────────────────────────────────────────────────
docker-build: ## Build l'image Docker
docker compose build
docker-up: ## Démarre en production (détaché)
docker compose up -d
docker-down: ## Arrête les conteneurs
docker compose down
docker-logs: ## Logs en temps réel
docker compose logs -f
docker-restart: ## Redémarre le conteneur
docker compose restart
status: ## État des conteneurs
docker compose ps
# ── Base de données ───────────────────────────────────────────────────────────
data: ## Crée les dossiers data/
ifeq ($(OS),Windows_NT)
@if not exist "$(DATA_DIR)\documents" mkdir "$(DATA_DIR)\documents"
else
@mkdir -p $(DATA_DIR)/documents
endif
clean-db: ## Supprime la base SQLite (repart de zéro)
ifeq ($(OS),Windows_NT)
@del /f "$(DATA_DIR)\rental.db" 2>nul || echo Fichier absent
else
@rm -f $(DATA_DIR)/rental.db && echo "Base supprimee"
endif
# ── Nettoyage ─────────────────────────────────────────────────────────────────
clean: ## Supprime les artefacts de build
ifeq ($(OS),Windows_NT)
@if exist "$(BINARY)" del /f "$(BINARY)"
@if exist "frontend\build" rmdir /s /q "frontend\build"
@if exist "frontend\.svelte-kit" rmdir /s /q "frontend\.svelte-kit"
@if exist "web\build" rmdir /s /q "web\build"
else
@rm -f $(BINARY)
@rm -rf frontend/build frontend/.svelte-kit web/build
endif
clean-all: clean ## Supprime aussi node_modules
ifeq ($(OS),Windows_NT)
@if exist "frontend\node_modules" rmdir /s /q "frontend\node_modules"
else
@rm -rf frontend/node_modules
endif
# ── Tests & info ──────────────────────────────────────────────────────────────
test: ## Lance les tests Go
go test ./...
version: ## Versions des outils
@go version
@node --version
@npm --version

125
README.md
View File

@@ -1,76 +1,131 @@
# Rental Manager
Gestion comptable de biens locatifs — Go + SvelteKit + SQLite.
Application de gestion comptable de biens locatifs — Go + SvelteKit + SQLite.
Interface web embarquée, exécutable unique, sans dépendances externes.
## Fonctionnalités
- **Biens immobiliers** — gestion multi-biens (longue durée & Airbnb)
- **Transactions** — revenus & dépenses par bien, catégories fiscales
- Import QIF/QFX depuis votre banque
- Ventilation sur plusieurs biens (split)
- Ventilation mixte revenu/dépense
- Fusion de prélèvements scindés en deux par la banque
- **Prêts immobiliers** — tableaux d'amortissement, split automatique intérêts/capital
- **Calendrier** — grille mensuelle avec sync iCal Airbnb automatique (toutes les heures)
- **Documents** — pièces jointes classées par année/catégorie, export ZIP
- **Export fiscal** — CSV annuel par bien (revenus, charges déductibles)
- **Catégories** — personnalisables, avec indicateur déductibilité fiscale
- **Multi-utilisateurs** — authentification par session
## Stack
- **Backend** : Go 1.22, gorilla/mux, mattn/go-sqlite3
- **Frontend** : SvelteKit + TailwindCSS + Chart.js
- **BDD** : SQLite (WAL mode)
- **Déploiement** : Docker Compose (ESXi / NAS)
| Composant | Technologie |
|-----------|-------------|
| Backend | Go 1.25, `gorilla/mux`, `modernc.org/sqlite` |
| Frontend | SvelteKit + TailwindCSS + Chart.js + lucide-svelte |
| Base de données | SQLite (mode WAL) |
| Build Windows | `go build -ldflags="-H windowsgui"` |
| Déploiement | Docker Compose |
## Lancement développement
## Développement
```bash
# Backend
cd rental-manager
# Backend (port 9000)
go mod tidy
go run ./cmd/server
# Frontend (autre terminal)
# Frontend (autre terminal, port 5173)
cd frontend
npm install
npm run dev
```
## Déploiement production (ESXi)
Le frontend dev proxy les appels `/api/*` vers `:9000`.
## Build production (Windows)
```bat
build.bat
```
Génère `RentalManager.exe` — interface web embarquée dans le binaire, sans console visible.
## Déploiement Docker (ESXi / NAS)
```bash
docker compose up -d --build
```
L'application est accessible sur `http://<IP>:8080`
Application accessible sur `http://<IP>:8080`.
## Structure
```
cmd/server/main.go Entrypoint Go
cmd/server/main.go Point d'entrée — routes, middlewares, démarrage
internal/
auth/ Authentification, sessions, middleware
property/ Biens immobiliers
transaction/ Revenus & dépenses
calendar/ Événements d'occupation
document/ Pièces jointes
auth/ Authentification, sessions cookie, middleware
property/ Biens immobiliers (CRUD, URL iCal)
transaction/ Revenus & dépenses, split, ventilation mixte
calendar/ Événements d'occupation (CRUD)
ical/ Sync flux iCal Airbnb — goroutine horaire
document/ Upload pièces jointes, export ZIP année/catégorie
fiscal/ Export CSV annuel
ical/ Sync flux iCal Airbnb (goroutine)
loan/ Prêts immobiliers, tableaux d'amortissement, split auto
category/ Catégories de transactions
importer/ Import QIF/QFX bancaire
db/ SQLite init + migrations
data/
rental.db Base SQLite (créée au démarrage)
documents/ Fichiers uploadés (property_id/année/)
web/ Frontend SvelteKit embarqué (embed.FS)
frontend/src/
routes/ Pages SvelteKit
lib/stores/api.js Client API + stores Svelte
routes/
+page.svelte Tableau de bord (graphiques, KPI)
transactions/ Liste & création de transactions
import/ Import bancaire QIF
calendar/ Calendrier mensuel
documents/ Gestion des pièces jointes
loans/ Prêts & amortissement
properties/ Gestion des biens
categories/ Catégories
fiscal/ Export fiscal
profile/ Profil & mot de passe
lib/stores/api.js Client HTTP centralisé
data/
rental.db Base SQLite (créée au premier démarrage)
documents/ Fichiers uploadés
```
## Variables d'environnement
| Variable | Défaut | Description |
|----------|--------|-------------|
| PORT | 9000 | Port d'écoute |
| TZ | Europe/Paris | Timezone |
| `PORT` | `9000` | Port d'écoute |
| `TZ` | `Europe/Paris` | Timezone |
## Premiers pas après démarrage
## Premiers pas
1. Créer le premier utilisateur (endpoint à ajouter ou via SQLite direct)
2. Ajouter les deux biens (apartement longue durée + Airbnb)
3. Renseigner l'URL iCal Airbnb dans la fiche du bien Airbnb
4. La sync démarre automatiquement toutes les heures
1. Lancer l'application — un compte `admin@rental.local` / `admin1234` est créé automatiquement
2. Changer le mot de passe dans **Profil**
3. Créer vos biens dans **Biens**
4. Pour les biens Airbnb : coller l'URL iCal dans la fiche bien
_(Airbnb → Annonce → Paramètres → Calendrier → Exporter le calendrier)_
5. Configurer vos **Catégories** si besoin
6. Importer vos relevés bancaires via **Import**
## Sync iCal Airbnb
Airbnb expose un flux iCal par annonce :
`Annonce → Paramètres → Calendrier → Exporter le calendrier`
La goroutine `ical.Service` synchronise automatiquement toutes les heures.
Un bouton **Synchroniser** dans le calendrier force la sync immédiate.
Les résultats sont loggués dans la table `ical_sync_log`.
Coller l'URL `.ics` dans la fiche du bien. La goroutine `ical.Service`
synchronise automatiquement toutes les heures et loggue les résultats
dans `ical_sync_log`.
À chaque sync, les anciens événements Airbnb de la propriété sont supprimés et remplacés (ce qui gère aussi les annulations de réservations).
## Import bancaire
Formats supportés : **QIF**, **QFX**
_(Espace client → Mes comptes → Télécharger → Format QIF)_
Fonctionnalités :
- Détection automatique des lignes déjà importées
- Ventilation sur plusieurs biens
- Ventilation mixte revenu + dépense (ex: loyer reçu + appel de fonds)
- **Fusion** de deux prélèvements scindés par la banque → split intérêts/capital via tableau d'amortissement

10
build.bat Normal file
View File

@@ -0,0 +1,10 @@
@echo off
echo Building frontend...
cd frontend
call npm run build
cd ..
echo Building server...
go build -ldflags="-H windowsgui" -o RentalManager.exe ./cmd/server
echo Done: RentalManager.exe

219
cmd/server/main.go Normal file
View File

@@ -0,0 +1,219 @@
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"strings"
"github.com/gorilla/mux"
"github.com/f4bpo/rental-manager/internal/auth"
"github.com/f4bpo/rental-manager/internal/calendar"
"github.com/f4bpo/rental-manager/internal/category"
"github.com/f4bpo/rental-manager/internal/db"
"github.com/f4bpo/rental-manager/internal/document"
"github.com/f4bpo/rental-manager/internal/fiscal"
"github.com/f4bpo/rental-manager/internal/ical"
"github.com/f4bpo/rental-manager/internal/importer"
"github.com/f4bpo/rental-manager/internal/loan"
"github.com/f4bpo/rental-manager/internal/property"
"github.com/f4bpo/rental-manager/internal/transaction"
"github.com/f4bpo/rental-manager/web"
)
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if strings.Contains(origin, "localhost") {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
database, err := db.Init("./data/rental.db")
if err != nil {
log.Fatalf("failed to init database: %v", err)
}
defer database.Close()
if err := db.Migrate(database); err != nil {
log.Fatalf("failed to run migrations: %v", err)
}
propertyStore := property.NewStore(database)
transactionStore := transaction.NewStore(database)
documentStore := document.NewStore(database)
documentStore.Migrate()
calendarStore := calendar.NewStore(database)
userStore := auth.NewStore(database)
categoryStore := category.NewStore(database)
if userStore.Count() == 0 {
u, err := userStore.Create("admin@rental.local", "Administrateur", "admin1234")
if err != nil {
log.Printf("⚠ impossible de créer l'utilisateur par défaut: %v", err)
} else {
log.Printf("✓ Utilisateur par défaut créé — email: %s / mdp: admin1234", u.Email)
}
}
loanStore := loan.NewStore(database)
loanStore.Migrate()
// Seed tableaux d'amortissement si pas encore importés
loans, _ := loanStore.ListLoans("")
if len(loans) == 0 {
log.Println(" Tableaux d'amortissement non encore configurés.")
log.Println(" Ajoutez vos prêts dans la page Prêts et uploadez les échéances.")
}
icalService := ical.NewService(calendarStore, propertyStore)
icalService.StartSync()
authHandler := auth.NewHandler(userStore)
propertyHandler := property.NewHandler(propertyStore)
transactionHandler := transaction.NewHandler(transactionStore)
documentHandler := document.NewHandler(documentStore, "./data/documents")
calendarHandler := calendar.NewHandler(calendarStore)
fiscalHandler := fiscal.NewHandler(transactionStore, documentStore)
categoryHandler := category.NewHandler(categoryStore)
importHandler := importer.NewHandler(database)
loanHandler := loan.NewHandler(loanStore)
r := mux.NewRouter()
r.Use(corsMiddleware)
api := r.PathPrefix("/api").Subrouter()
api.HandleFunc("/auth/login", authHandler.Login).Methods("POST", "OPTIONS")
api.HandleFunc("/auth/logout", authHandler.Logout).Methods("POST", "OPTIONS")
api.HandleFunc("/auth/register", func(w http.ResponseWriter, r *http.Request) {
if userStore.Count() > 0 {
auth.Middleware(userStore)(http.HandlerFunc(authHandler.Register)).ServeHTTP(w, r)
return
}
authHandler.Register(w, r)
}).Methods("POST", "OPTIONS")
protected := api.NewRoute().Subrouter()
protected.Use(auth.Middleware(userStore))
// Profil & utilisateurs
protected.HandleFunc("/me", authHandler.Me).Methods("GET")
protected.HandleFunc("/me", authHandler.UpdateProfile).Methods("PUT")
protected.HandleFunc("/me/password", authHandler.UpdatePassword).Methods("PUT")
protected.HandleFunc("/users", authHandler.ListUsers).Methods("GET")
protected.HandleFunc("/users/{id}", authHandler.DeleteUser).Methods("DELETE")
// Catégories
protected.HandleFunc("/categories", categoryHandler.List).Methods("GET")
protected.HandleFunc("/categories", categoryHandler.Create).Methods("POST")
protected.HandleFunc("/categories/{id}", categoryHandler.Update).Methods("PUT")
protected.HandleFunc("/categories/{id}", categoryHandler.Delete).Methods("DELETE")
// Biens
protected.HandleFunc("/properties", propertyHandler.List).Methods("GET")
protected.HandleFunc("/properties", propertyHandler.Create).Methods("POST")
protected.HandleFunc("/properties/{id}", propertyHandler.Get).Methods("GET")
protected.HandleFunc("/properties/{id}", propertyHandler.Update).Methods("PUT")
protected.HandleFunc("/properties/{id}", propertyHandler.Delete).Methods("DELETE")
// Transactions
protected.HandleFunc("/transactions", transactionHandler.List).Methods("GET")
protected.HandleFunc("/transactions", transactionHandler.Create).Methods("POST")
protected.HandleFunc("/transactions/summary", transactionHandler.Summary).Methods("GET")
protected.HandleFunc("/transactions/monthly", transactionHandler.Monthly).Methods("GET")
protected.HandleFunc("/transactions/categories", transactionHandler.CategoryBreakdown).Methods("GET")
protected.HandleFunc("/transactions/{id}", transactionHandler.Get).Methods("GET")
protected.HandleFunc("/transactions/{id}", transactionHandler.Update).Methods("PUT")
protected.HandleFunc("/transactions/{id}", transactionHandler.Delete).Methods("DELETE")
protected.HandleFunc("/transactions/{id}/split", transactionHandler.SplitTransaction).Methods("POST")
// Import QIF
protected.HandleFunc("/import/preview", importHandler.Preview).Methods("POST")
protected.HandleFunc("/import/check", importHandler.Check).Methods("POST")
protected.HandleFunc("/import/qif", importHandler.Import).Methods("POST")
// Documents
protected.HandleFunc("/documents", documentHandler.List).Methods("GET")
protected.HandleFunc("/documents", documentHandler.Upload).Methods("POST")
protected.HandleFunc("/documents/export", documentHandler.Export).Methods("GET")
protected.HandleFunc("/documents/{id}", documentHandler.Get).Methods("GET")
protected.HandleFunc("/documents/{id}", documentHandler.Delete).Methods("DELETE")
protected.HandleFunc("/documents/{id}/download", documentHandler.Download).Methods("GET")
// Calendrier
protected.HandleFunc("/calendar", calendarHandler.List).Methods("GET")
protected.HandleFunc("/calendar", calendarHandler.CreateEvent).Methods("POST")
protected.HandleFunc("/calendar/stats", calendarHandler.Stats).Methods("GET")
protected.HandleFunc("/calendar/sync", func(w http.ResponseWriter, r *http.Request) {
results := icalService.SyncAll()
if results == nil {
results = []ical.SyncResult{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}).Methods("POST")
protected.HandleFunc("/calendar/{id}", calendarHandler.UpdateEvent).Methods("PUT")
protected.HandleFunc("/calendar/{id}", calendarHandler.DeleteEvent).Methods("DELETE")
// Prêts immobiliers
protected.HandleFunc("/loans", loanHandler.ListLoans).Methods("GET")
protected.HandleFunc("/loans", loanHandler.CreateLoan).Methods("POST")
// Routes statiques AVANT les routes avec {id}
protected.HandleFunc("/loans/split", loanHandler.GetSplitForAmount).Methods("GET")
protected.HandleFunc("/loans/upload-pdf", loanHandler.UploadPDF).Methods("POST")
protected.HandleFunc("/loans/create", loanHandler.CreateLoanManual).Methods("POST")
// Routes avec paramètre {id}
protected.HandleFunc("/loans/{id}", loanHandler.DeleteLoan).Methods("DELETE")
protected.HandleFunc("/loans/{id}/lines", loanHandler.GetLines).Methods("GET")
protected.HandleFunc("/loans/{id}/lines", loanHandler.UploadLines).Methods("POST")
protected.HandleFunc("/loans/{id}/split", loanHandler.SplitByDate).Methods("GET")
protected.HandleFunc("/loans/{id}/summary", loanHandler.AnnualSummary).Methods("GET")
protected.HandleFunc("/loans/{id}/reload", loanHandler.ReloadLines).Methods("POST")
// Export fiscal
protected.HandleFunc("/fiscal/export", fiscalHandler.Export).Methods("GET")
protected.HandleFunc("/fiscal/summary", fiscalHandler.Summary).Methods("GET")
// Arrêt du serveur
protected.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
go func() { os.Exit(0) }()
}).Methods("POST")
// Frontend embarqué
assets, err := web.Assets()
if err != nil {
log.Fatalf("embed frontend: %v", err)
}
fileServer := http.FileServer(http.FS(assets))
r.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := assets.Open(path); err != nil {
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
})
port := os.Getenv("PORT")
if port == "" {
port = "9000"
}
log.Printf("🏠 Rental Manager démarré sur http://localhost:%s", port)
log.Fatal(http.ListenAndServe(":"+port, r))
}

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
rental-manager:
build: .
container_name: rental-manager
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/app/data # SQLite + documents persistants
environment:
- PORT=8080
- TZ=Europe/Paris
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/api/properties"]
interval: 30s
timeout: 5s
retries: 3

2522
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

22
go.mod Normal file
View File

@@ -0,0 +1,22 @@
module github.com/f4bpo/rental-manager
go 1.25.0
require (
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.24.0
modernc.org/sqlite v1.47.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

28
go.sum Normal file
View File

@@ -0,0 +1,28 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=

316
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,316 @@
package auth
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
"golang.org/x/crypto/bcrypt"
)
// ── Models ────────────────────────────────────────────────────────────────────
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
type contextKey string
const userKey contextKey = "user"
// ── Store ─────────────────────────────────────────────────────────────────────
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) GetByEmail(email string) (*User, string, error) {
var u User
var hash string
err := s.db.QueryRow(
`SELECT id, email, name, password_hash, created_at FROM users WHERE email=?`, email,
).Scan(&u.ID, &u.Email, &u.Name, &hash, &u.CreatedAt)
return &u, hash, err
}
func (s *Store) GetByID(id string) (*User, error) {
var u User
err := s.db.QueryRow(
`SELECT id, email, name, created_at FROM users WHERE id=?`, id,
).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
return &u, err
}
func (s *Store) List() ([]User, error) {
rows, err := s.db.Query(`SELECT id, email, name, created_at FROM users ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
return users, nil
}
func (s *Store) Create(email, name, password string) (*User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
u := &User{ID: uuid.NewString(), Email: email, Name: name}
_, err = s.db.Exec(
`INSERT INTO users (id, email, name, password_hash) VALUES (?,?,?,?)`,
u.ID, u.Email, u.Name, string(hash),
)
return u, err
}
func (s *Store) UpdateProfile(id, email, name string) error {
_, err := s.db.Exec(`UPDATE users SET email=?, name=? WHERE id=?`, email, name, id)
return err
}
func (s *Store) UpdatePassword(id, newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.db.Exec(`UPDATE users SET password_hash=? WHERE id=?`, string(hash), id)
return err
}
func (s *Store) Delete(id string) error {
_, err := s.db.Exec(`DELETE FROM users WHERE id=?`, id)
return err
}
func (s *Store) Count() int {
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n
}
func (s *Store) CheckPassword(id, password string) bool {
var hash string
s.db.QueryRow(`SELECT password_hash FROM users WHERE id=?`, id).Scan(&hash)
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// Sessions en mémoire
var sessions = map[string]string{}
func (s *Store) CreateSession(userID string) string {
token := uuid.NewString()
sessions[token] = userID
return token
}
func (s *Store) GetUserFromToken(token string) (*User, error) {
id, ok := sessions[token]
if !ok {
return nil, sql.ErrNoRows
}
return s.GetByID(id)
}
func (s *Store) DeleteSession(token string) {
delete(sessions, token)
}
// ── Handler ───────────────────────────────────────────────────────────────────
type Handler struct{ store *Store }
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
user, hash, err := h.store.GetByEmail(body.Email)
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.Password)) != nil {
http.Error(w, "identifiants incorrects", http.StatusUnauthorized)
return
}
token := h.store.CreateSession(user.ID)
http.SetCookie(w, &http.Cookie{
Name: "session", Value: token, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(30 * 24 * time.Hour),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"user": user, "token": token})
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session")
if err == nil {
h.store.DeleteSession(c.Value)
}
http.SetCookie(w, &http.Cookie{Name: "session", MaxAge: -1, Path: "/"})
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userKey).(*User)
respond(w, user)
}
// Register — public si aucun user, sinon auth requise
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.Email == "" || body.Name == "" || body.Password == "" {
http.Error(w, "email, nom et mot de passe requis", http.StatusBadRequest)
return
}
if len(body.Password) < 6 {
http.Error(w, "mot de passe trop court (6 caractères minimum)", http.StatusBadRequest)
return
}
user, err := h.store.Create(body.Email, body.Name, body.Password)
if err != nil {
http.Error(w, "email déjà utilisé", http.StatusConflict)
return
}
token := h.store.CreateSession(user.ID)
http.SetCookie(w, &http.Cookie{
Name: "session", Value: token, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(30 * 24 * time.Hour),
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{"user": user, "token": token})
}
// UpdateProfile — modifier nom + email
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userKey).(*User)
var body struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.Email == "" || body.Name == "" {
http.Error(w, "email et nom requis", http.StatusBadRequest)
return
}
if err := h.store.UpdateProfile(user.ID, body.Email, body.Name); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
user.Email = body.Email
user.Name = body.Name
respond(w, user)
}
// UpdatePassword — changer son mot de passe
func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userKey).(*User)
var body struct {
Current string `json:"current_password"`
New string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if !h.store.CheckPassword(user.ID, body.Current) {
http.Error(w, "mot de passe actuel incorrect", http.StatusUnauthorized)
return
}
if len(body.New) < 6 {
http.Error(w, "nouveau mot de passe trop court (6 caractères minimum)", http.StatusBadRequest)
return
}
if err := h.store.UpdatePassword(user.ID, body.New); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListUsers — liste tous les utilisateurs (admin)
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.store.List()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if users == nil {
users = []User{}
}
respond(w, users)
}
// DeleteUser — supprimer un utilisateur (ne peut pas se supprimer soi-même)
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
me := r.Context().Value(userKey).(*User)
id := mux.Vars(r)["id"]
if id == me.ID {
http.Error(w, "impossible de supprimer son propre compte", http.StatusBadRequest)
return
}
if err := h.store.Delete(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── Middleware ────────────────────────────────────────────────────────────────
func Middleware(store *Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := ""
if c, err := r.Cookie("session"); err == nil {
token = c.Value
}
if token == "" {
token = r.Header.Get("Authorization")
}
user, err := store.GetUserFromToken(token)
if err != nil {
http.Error(w, "non autorisé", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func respond(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,207 @@
package calendar
import (
"database/sql"
"encoding/json"
"net/http"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
// ── Models ────────────────────────────────────────────────────────────────────
type Event struct {
ID string `json:"id"`
PropertyID string `json:"property_id"`
Title string `json:"title"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Source string `json:"source"` // airbnb | manual
IcalUID string `json:"ical_uid,omitempty"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
// Joint
PropertyName string `json:"property_name,omitempty"`
}
type OccupancyStats struct {
PropertyID string `json:"property_id"`
PropertyName string `json:"property_name"`
Month string `json:"month"`
TotalDays int `json:"total_days"`
OccupiedDays int `json:"occupied_days"`
OccupancyRate float64 `json:"occupancy_rate"`
TotalRevenue float64 `json:"total_revenue"`
AvgNightlyRate float64 `json:"avg_nightly_rate"`
}
// ── Store ─────────────────────────────────────────────────────────────────────
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) List(propertyID, from, to string) ([]Event, error) {
query := `
SELECT e.id, e.property_id, COALESCE(e.title,''),
substr(e.start_date,1,10) as start_date,
substr(e.end_date,1,10) as end_date,
e.source, COALESCE(e.ical_uid,''), COALESCE(e.notes,''), e.created_at, p.name
FROM calendar_events e
JOIN properties p ON p.id = e.property_id
WHERE 1=1`
args := []any{}
if propertyID != "" {
query += " AND e.property_id=?"
args = append(args, propertyID)
}
if from != "" {
query += " AND e.end_date >= ?"
args = append(args, from)
}
if to != "" {
query += " AND e.start_date <= ?"
args = append(args, to)
}
query += " ORDER BY e.start_date"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
if err := rows.Scan(&e.ID, &e.PropertyID, &e.Title, &e.StartDate, &e.EndDate,
&e.Source, &e.IcalUID, &e.Notes, &e.CreatedAt, &e.PropertyName); err != nil {
return nil, err
}
events = append(events, e)
}
return events, nil
}
func (s *Store) DeleteIcalEvents(propertyID string) error {
_, err := s.db.Exec(`DELETE FROM calendar_events WHERE property_id=? AND source='airbnb'`, propertyID)
return err
}
func (s *Store) InsertFromIcal(e *Event) error {
e.ID = uuid.NewString()
_, err := s.db.Exec(
`INSERT INTO calendar_events (id, property_id, title, start_date, end_date, source, ical_uid, notes)
VALUES (?,?,?,?,?,?,?,?)`,
e.ID, e.PropertyID, e.Title, e.StartDate, e.EndDate, "airbnb", e.IcalUID, e.Notes,
)
return err
}
func (s *Store) Create(e *Event) error {
e.ID = uuid.NewString()
_, err := s.db.Exec(
`INSERT INTO calendar_events (id, property_id, title, start_date, end_date, source, notes) VALUES (?,?,?,?,?,?,?)`,
e.ID, e.PropertyID, e.Title, e.StartDate, e.EndDate, e.Source, e.Notes,
)
return err
}
func (s *Store) Update(e *Event) error {
_, err := s.db.Exec(
`UPDATE calendar_events SET title=?, start_date=?, end_date=?, notes=? WHERE id=? AND source='manual'`,
e.Title, e.StartDate, e.EndDate, e.Notes, e.ID,
)
return err
}
func (s *Store) Delete(id string) error {
_, err := s.db.Exec(`DELETE FROM calendar_events WHERE id=? AND source='manual'`, id)
return err
}
func (s *Store) LogSync(propertyID, status string, imported int, errMsg string) {
s.db.Exec(
`INSERT INTO ical_sync_log (id, property_id, status, events_imported, error_message) VALUES (?,?,?,?,?)`,
uuid.NewString(), propertyID, status, imported, nullStr(errMsg),
)
}
// ── Handler ───────────────────────────────────────────────────────────────────
type Handler struct{ store *Store }
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
events, err := h.store.List(q.Get("property_id"), q.Get("from"), q.Get("to"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if events == nil {
events = []Event{}
}
respond(w, events)
}
func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) {
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
e.Source = "manual"
if err := h.store.Create(&e); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
respond(w, e)
}
func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
e.ID = mux.Vars(r)["id"]
if err := h.store.Update(&e); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, e)
}
func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
if err := h.store.Delete(mux.Vars(r)["id"]); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
// TODO: calcul taux d'occupation par mois
respond(w, []OccupancyStats{})
}
func (h *Handler) TriggerSync(w http.ResponseWriter, r *http.Request) {
// Le service iCal expose un endpoint pour forcer la sync
respond(w, map[string]string{"status": "sync triggered"})
}
func respond(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}

View File

@@ -0,0 +1,140 @@
package category
import (
"database/sql"
"encoding/json"
"net/http"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
type Category struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // income | expense
TaxDeductible bool `json:"tax_deductible"`
Description string `json:"description"`
}
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) List(txType string) ([]Category, error) {
query := `SELECT id, name, type, tax_deductible, COALESCE(description,'') FROM categories WHERE 1=1`
args := []any{}
if txType != "" {
query += " AND type=?"
args = append(args, txType)
}
query += " ORDER BY type, name"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var cats []Category
for rows.Next() {
var c Category
var td int
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &td, &c.Description); err != nil {
return nil, err
}
c.TaxDeductible = td == 1
cats = append(cats, c)
}
return cats, nil
}
func (s *Store) Create(c *Category) error {
c.ID = uuid.NewString()
td := 0
if c.TaxDeductible {
td = 1
}
_, err := s.db.Exec(
`INSERT INTO categories (id, name, type, tax_deductible, description) VALUES (?,?,?,?,?)`,
c.ID, c.Name, c.Type, td, c.Description,
)
return err
}
func (s *Store) Update(c *Category) error {
td := 0
if c.TaxDeductible {
td = 1
}
_, err := s.db.Exec(
`UPDATE categories SET name=?, type=?, tax_deductible=?, description=? WHERE id=?`,
c.Name, c.Type, td, c.Description, c.ID,
)
return err
}
func (s *Store) Delete(id string) error {
s.db.Exec(`UPDATE transactions SET category_id=NULL WHERE category_id=?`, id)
_, err := s.db.Exec(`DELETE FROM categories WHERE id=?`, id)
return err
}
type Handler struct{ store *Store }
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
cats, err := h.store.List(r.URL.Query().Get("type"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if cats == nil {
cats = []Category{}
}
respond(w, cats)
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var c Category
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if c.Name == "" || (c.Type != "income" && c.Type != "expense") {
http.Error(w, "nom et type (income/expense) requis", http.StatusBadRequest)
return
}
if err := h.store.Create(&c); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
respond(w, c)
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var c Category
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
c.ID = mux.Vars(r)["id"]
if err := h.store.Update(&c); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, c)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.store.Delete(mux.Vars(r)["id"]); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func respond(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}

150
internal/db/db.go Normal file
View File

@@ -0,0 +1,150 @@
package db
import (
"database/sql"
"log"
_ "modernc.org/sqlite"
)
func Init(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path+"?_foreign_keys=on&_journal_mode=WAL")
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
log.Printf("✓ SQLite connecté : %s", path)
return db, nil
}
func Migrate(db *sql.DB) error {
migrations := []string{
sqlCreateUsers,
sqlCreateProperties,
sqlCreateCalendarEvents,
sqlCreateCategories,
sqlCreateTransactions,
sqlCreateDocuments,
sqlCreateFiscalExports,
sqlCreateIcalSyncLog,
sqlSeedCategories,
}
for _, m := range migrations {
if _, err := db.Exec(m); err != nil {
return err
}
}
log.Println("✓ Migrations appliquées")
return nil
}
const sqlCreateUsers = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const sqlCreateProperties = `
CREATE TABLE IF NOT EXISTS properties (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
address TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('airbnb','longterm')),
bank_account TEXT,
ical_url TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const sqlCreateCalendarEvents = `
CREATE TABLE IF NOT EXISTS calendar_events (
id TEXT PRIMARY KEY,
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
title TEXT,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
source TEXT NOT NULL CHECK(source IN ('airbnb','manual')),
ical_uid TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(property_id, ical_uid)
);`
const sqlCreateCategories = `
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('income','expense')),
tax_deductible INTEGER DEFAULT 0,
description TEXT
);`
const sqlCreateTransactions = `
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
category_id TEXT REFERENCES categories(id),
type TEXT NOT NULL CHECK(type IN ('income','expense')),
amount REAL NOT NULL,
date DATE NOT NULL,
description TEXT,
created_by TEXT REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const sqlCreateDocuments = `
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
transaction_id TEXT REFERENCES transactions(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
mime_type TEXT,
fiscal_year INTEGER,
uploaded_by TEXT REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const sqlCreateFiscalExports = `
CREATE TABLE IF NOT EXISTS fiscal_exports (
id TEXT PRIMARY KEY,
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
fiscal_year INTEGER NOT NULL,
file_path TEXT NOT NULL,
generated_by TEXT REFERENCES users(id),
generated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const sqlCreateIcalSyncLog = `
CREATE TABLE IF NOT EXISTS ical_sync_log (
id TEXT PRIMARY KEY,
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL CHECK(status IN ('ok','error')),
events_imported INTEGER DEFAULT 0,
error_message TEXT
);`
// Catégories de base prêtes à l'emploi
const sqlSeedCategories = `
INSERT OR IGNORE INTO categories (id, name, type, tax_deductible) VALUES
('cat-loyer', 'Loyer perçu', 'income', 0),
('cat-airbnb', 'Revenu Airbnb', 'income', 0),
('cat-charges', 'Charges copropriété', 'expense', 1),
('cat-travaux', 'Travaux & réparations', 'expense', 1),
('cat-assurance', 'Assurance', 'expense', 1),
('cat-taxe', 'Taxe foncière', 'expense', 1),
('cat-interets', 'Intérêts emprunt', 'expense', 1),
('cat-menage', 'Ménage & entretien', 'expense', 1),
('cat-gestion', 'Frais de gestion', 'expense', 1),
('cat-electricite', 'Électricité', 'expense', 1),
('cat-eau', 'Eau', 'expense', 1),
('cat-internet', 'Internet', 'expense', 1),
('cat-autre-dep', 'Autre dépense', 'expense', 0),
('cat-autre-rev', 'Autre revenu', 'income', 0);`

View File

@@ -0,0 +1,330 @@
package document
import (
"archive/zip"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
// ── Model ─────────────────────────────────────────────────────────────────────
type Document struct {
ID string `json:"id"`
PropertyID string `json:"property_id"`
TransactionID string `json:"transaction_id,omitempty"`
Filename string `json:"filename"`
OriginalName string `json:"original_name"`
FilePath string `json:"-"`
MimeType string `json:"mime_type"`
FiscalYear int `json:"fiscal_year"`
Category string `json:"category"`
UploadedBy string `json:"uploaded_by"`
CreatedAt time.Time `json:"created_at"`
// Joint
PropertyName string `json:"property_name,omitempty"`
}
// ── Store ─────────────────────────────────────────────────────────────────────
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// Migrate ajoute la colonne category si elle n'existe pas encore.
func (s *Store) Migrate() {
s.db.Exec(`ALTER TABLE documents ADD COLUMN doc_month INTEGER NOT NULL DEFAULT 0`)
s.db.Exec(`ALTER TABLE documents ADD COLUMN category TEXT NOT NULL DEFAULT ''`)
}
func (s *Store) List(propertyID string, fiscalYear int, category string) ([]Document, error) {
query := `
SELECT d.id, d.property_id, COALESCE(d.transaction_id,''), d.filename, d.original_name,
d.file_path, COALESCE(d.mime_type,''), COALESCE(d.fiscal_year,0),
COALESCE(d.category,''),
COALESCE(d.uploaded_by,''), d.created_at, p.name
FROM documents d
JOIN properties p ON p.id = d.property_id
WHERE 1=1`
args := []any{}
if propertyID != "" {
query += " AND d.property_id=?"
args = append(args, propertyID)
}
if fiscalYear > 0 {
query += " AND d.fiscal_year=?"
args = append(args, fiscalYear)
}
if category != "" {
query += " AND d.category=?"
args = append(args, category)
}
query += " ORDER BY d.fiscal_year DESC, d.created_at DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var docs []Document
for rows.Next() {
var d Document
if err := rows.Scan(&d.ID, &d.PropertyID, &d.TransactionID, &d.Filename, &d.OriginalName,
&d.FilePath, &d.MimeType, &d.FiscalYear, &d.Category,
&d.UploadedBy, &d.CreatedAt, &d.PropertyName); err != nil {
return nil, err
}
docs = append(docs, d)
}
return docs, nil
}
func (s *Store) Get(id string) (*Document, error) {
var d Document
err := s.db.QueryRow(`
SELECT d.id, d.property_id, COALESCE(d.transaction_id,''), d.filename, d.original_name,
d.file_path, COALESCE(d.mime_type,''), COALESCE(d.fiscal_year,0),
COALESCE(d.category,''),
COALESCE(d.uploaded_by,''), d.created_at, p.name
FROM documents d
JOIN properties p ON p.id = d.property_id
WHERE d.id=?`, id,
).Scan(&d.ID, &d.PropertyID, &d.TransactionID, &d.Filename, &d.OriginalName,
&d.FilePath, &d.MimeType, &d.FiscalYear, &d.Category,
&d.UploadedBy, &d.CreatedAt, &d.PropertyName)
return &d, err
}
func (s *Store) Create(d *Document) error {
_, err := s.db.Exec(
`INSERT INTO documents (id, property_id, transaction_id, filename, original_name, file_path, mime_type, fiscal_year, category, uploaded_by) VALUES (?,?,?,?,?,?,?,?,?,?)`,
d.ID, d.PropertyID, nullStr(d.TransactionID), d.Filename, d.OriginalName, d.FilePath,
d.MimeType, nullInt(d.FiscalYear), nullStr(d.Category), nullStr(d.UploadedBy),
)
return err
}
func (s *Store) Delete(id string) (string, error) {
var path string
s.db.QueryRow(`SELECT file_path FROM documents WHERE id=?`, id).Scan(&path)
_, err := s.db.Exec(`DELETE FROM documents WHERE id=?`, id)
return path, err
}
func (s *Store) GetByPropertyAndYear(propertyID string, year int) ([]Document, error) {
return s.List(propertyID, year, "")
}
// ── Handler ───────────────────────────────────────────────────────────────────
type Handler struct {
store *Store
dataDir string
}
func NewHandler(store *Store, dataDir string) *Handler {
os.MkdirAll(dataDir, 0755)
return &Handler{store: store, dataDir: dataDir}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
var year int
fmt.Sscanf(q.Get("fiscal_year"), "%d", &year)
docs, err := h.store.List(q.Get("property_id"), year, q.Get("category"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if docs == nil {
docs = []Document{}
}
respond(w, docs)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
d, err := h.store.Get(mux.Vars(r)["id"])
if err == sql.ErrNoRows {
http.Error(w, "not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, d)
}
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
r.ParseMultipartForm(32 << 20)
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "fichier requis", http.StatusBadRequest)
return
}
defer file.Close()
id := uuid.NewString()
ext := filepath.Ext(header.Filename)
filename := id + ext
propertyID := r.FormValue("property_id")
year := r.FormValue("fiscal_year")
dir := filepath.Join(h.dataDir, propertyID, year)
os.MkdirAll(dir, 0755)
destPath := filepath.Join(dir, filename)
dest, err := os.Create(destPath)
if err != nil {
http.Error(w, "erreur création fichier", http.StatusInternalServerError)
return
}
defer dest.Close()
io.Copy(dest, file)
var fiscalYear int
fmt.Sscanf(year, "%d", &fiscalYear)
d := &Document{
ID: id,
PropertyID: propertyID,
TransactionID: r.FormValue("transaction_id"),
Filename: filename,
OriginalName: header.Filename,
FilePath: destPath,
MimeType: header.Header.Get("Content-Type"),
FiscalYear: fiscalYear,
Category: r.FormValue("category"),
UploadedBy: r.FormValue("uploaded_by"),
}
if err := h.store.Create(d); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
respond(w, d)
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
d, err := h.store.Get(mux.Vars(r)["id"])
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, d.OriginalName))
if d.MimeType != "" {
w.Header().Set("Content-Type", d.MimeType)
}
http.ServeFile(w, r, d.FilePath)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
path, err := h.store.Delete(mux.Vars(r)["id"])
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if path != "" {
os.Remove(path)
}
w.WriteHeader(http.StatusNoContent)
}
// Export génère un ZIP avec la structure : année/catégorie/fichier
func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
propertyID := q.Get("property_id")
yearStr := q.Get("year")
var year int
fmt.Sscanf(yearStr, "%d", &year)
if year == 0 {
year = time.Now().Year()
yearStr = fmt.Sprintf("%d", year)
}
docs, err := h.store.List(propertyID, year, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(docs) == 0 {
http.Error(w, "aucun document pour ces critères", http.StatusNotFound)
return
}
propLabel := "tous"
if propertyID != "" {
if docs[0].PropertyName != "" {
propLabel = sanitizeName(docs[0].PropertyName)
} else {
propLabel = propertyID[:8]
}
}
filename := fmt.Sprintf("documents_%s_%s.zip", propLabel, yearStr)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
zw := zip.NewWriter(w)
defer zw.Close()
for _, d := range docs {
catDir := "Sans catégorie"
if strings.TrimSpace(d.Category) != "" {
catDir = sanitizeName(d.Category)
}
zipPath := filepath.Join(yearStr, catDir, d.OriginalName)
zipPath = strings.ReplaceAll(zipPath, "\\", "/")
f, err := os.Open(d.FilePath)
if err != nil {
continue
}
entry, err := zw.Create(zipPath)
if err != nil {
f.Close()
continue
}
io.Copy(entry, f)
f.Close()
}
}
func sanitizeName(s string) string {
replacer := strings.NewReplacer(
"/", "-", "\\", "-", ":", "-", "*", "-",
"?", "-", "\"", "-", "<", "-", ">", "-", "|", "-",
)
return strings.TrimSpace(replacer.Replace(s))
}
func respond(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func nullStr(s string) any {
if s == "" || strings.TrimSpace(s) == "" {
return nil
}
return s
}
func nullInt(i int) any {
if i == 0 {
return nil
}
return i
}

91
internal/fiscal/fiscal.go Normal file
View File

@@ -0,0 +1,91 @@
package fiscal
import (
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/f4bpo/rental-manager/internal/document"
"github.com/f4bpo/rental-manager/internal/transaction"
)
type Handler struct {
txStore *transaction.Store
docStore *document.Store
}
func NewHandler(txStore *transaction.Store, docStore *document.Store) *Handler {
return &Handler{txStore: txStore, docStore: docStore}
}
func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
year := q.Get("year")
if year == "" {
year = strconv.Itoa(time.Now().Year())
}
summaries, err := h.txStore.GetSummary(q.Get("property_id"), year, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if summaries == nil {
summaries = []transaction.Summary{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(summaries)
}
func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
propertyID := q.Get("property_id")
yearStr := q.Get("year")
if yearStr == "" {
yearStr = strconv.Itoa(time.Now().Year())
}
// List() gère correctement propertyID vide et year en string
txs, err := h.txStore.List(propertyID, "", yearStr, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
propLabel := propertyID
if propLabel == "" {
propLabel = "tous"
}
filename := fmt.Sprintf("export_fiscal_%s_%s.csv", propLabel, yearStr)
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Write([]byte{0xEF, 0xBB, 0xBF}) // BOM UTF-8 pour Excel
cw := csv.NewWriter(w)
cw.Comma = ';'
cw.Write([]string{"Date", "Type", "Catégorie", "Description", "Montant (€)", "Bien"})
var totalIncome, totalExpense float64
for _, t := range txs {
typeLabel := "Revenu"
if t.Type == "expense" {
typeLabel = "Dépense"
totalExpense += t.Amount
} else {
totalIncome += t.Amount
}
cw.Write([]string{
t.Date, typeLabel, t.CategoryName,
t.Description, fmt.Sprintf("%.2f", t.Amount), t.PropertyName,
})
}
cw.Write([]string{})
cw.Write([]string{"", "", "", "TOTAL REVENUS", fmt.Sprintf("%.2f", totalIncome), ""})
cw.Write([]string{"", "", "", "TOTAL DÉPENSES", fmt.Sprintf("%.2f", totalExpense), ""})
cw.Write([]string{"", "", "", "BÉNÉFICE NET", fmt.Sprintf("%.2f", totalIncome-totalExpense), ""})
cw.Flush()
}

204
internal/ical/ical.go Normal file
View File

@@ -0,0 +1,204 @@
package ical
import (
"bufio"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/f4bpo/rental-manager/internal/calendar"
"github.com/f4bpo/rental-manager/internal/property"
)
type Service struct {
calStore *calendar.Store
propStore *property.Store
}
func NewService(calStore *calendar.Store, propStore *property.Store) *Service {
return &Service{calStore: calStore, propStore: propStore}
}
// StartSync lance une goroutine qui synchronise toutes les heures
func (s *Service) StartSync() {
go func() {
s.syncAll()
ticker := time.NewTicker(1 * time.Hour)
for range ticker.C {
s.syncAll()
}
}()
}
func (s *Service) SyncProperty(prop *property.Property) (int, error) {
if prop.IcalURL == "" {
return 0, fmt.Errorf("pas d'URL iCal pour %s", prop.Name)
}
req, err := http.NewRequest("GET", prop.IcalURL, nil)
if err != nil {
return 0, fmt.Errorf("erreur création requête iCal: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; RentalManager/1.0)")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("erreur fetch iCal: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("iCal HTTP %d pour %s", resp.StatusCode, prop.IcalURL)
}
events, err := parseIcal(resp, prop.ID)
if err != nil {
return 0, err
}
// Supprimer les anciens événements iCal pour repartir d'une base propre
// (gère aussi les réservations annulées et les doublons)
if err := s.calStore.DeleteIcalEvents(prop.ID); err != nil {
return 0, fmt.Errorf("erreur nettoyage calendrier: %w", err)
}
imported := 0
for _, e := range events {
if err := s.calStore.InsertFromIcal(&e); err != nil {
log.Printf("ical: erreur insert event %s: %v", e.IcalUID, err)
continue
}
imported++
}
return imported, nil
}
type SyncResult struct {
Property string `json:"property"`
Imported int `json:"imported"`
Error string `json:"error,omitempty"`
}
func (s *Service) SyncAll() []SyncResult {
props, err := s.propStore.ListWithIcal()
if err != nil {
log.Printf("ical sync: erreur liste propriétés: %v", err)
return []SyncResult{{Property: "system", Error: err.Error()}}
}
log.Printf("ical sync: %d propriété(s) avec URL iCal trouvée(s)", len(props))
if len(props) == 0 {
return []SyncResult{{Property: "system", Error: "aucune propriété avec URL iCal configurée"}}
}
var results []SyncResult
for _, p := range props {
n, err := s.SyncProperty(&p)
if err != nil {
log.Printf("ical sync [%s]: erreur: %v", p.Name, err)
s.calStore.LogSync(p.ID, "error", 0, err.Error())
results = append(results, SyncResult{Property: p.Name, Error: err.Error()})
} else {
log.Printf("ical sync [%s]: %d événements importés", p.Name, n)
s.calStore.LogSync(p.ID, "ok", n, "")
results = append(results, SyncResult{Property: p.Name, Imported: n})
}
}
return results
}
func (s *Service) syncAll() { s.SyncAll() }
// parseIcal parse un flux iCal et retourne les événements VEVENT
func parseIcal(resp *http.Response, propertyID string) ([]calendar.Event, error) {
// Unfold iCal lines (RFC 5545: continuation lines start with space or tab)
lines := unfoldIcal(resp)
var events []calendar.Event
var current *calendar.Event
for _, line := range lines {
line = strings.TrimRight(line, "\r\n")
if line == "" {
continue
}
switch {
case line == "BEGIN:VEVENT":
current = &calendar.Event{PropertyID: propertyID, Source: "airbnb"}
case line == "END:VEVENT" && current != nil:
if current.IcalUID != "" && current.StartDate != "" && current.EndDate != "" {
events = append(events, *current)
} else {
log.Printf("ical: VEVENT ignoré (uid=%q start=%q end=%q)", current.IcalUID, current.StartDate, current.EndDate)
}
current = nil
case current != nil:
key, value, found := strings.Cut(line, ":")
if !found {
continue
}
// Gérer les paramètres du type DTSTART;VALUE=DATE:20240601
baseKey := strings.SplitN(key, ";", 2)[0]
switch baseKey {
case "UID":
current.IcalUID = value
case "SUMMARY":
current.Title = unescapeIcal(value)
case "DTSTART":
current.StartDate = parseIcalDate(value)
case "DTEND":
current.EndDate = parseIcalDate(value)
case "DESCRIPTION":
current.Notes = unescapeIcal(value)
}
}
}
log.Printf("ical: %d événements parsés pour la propriété %s", len(events), propertyID)
return events, nil
}
// unfoldIcal joint les lignes repliées (RFC 5545 §3.1)
func unfoldIcal(resp *http.Response) []string {
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer
var lines []string
for scanner.Scan() {
text := scanner.Text()
if len(lines) > 0 && len(text) > 0 && (text[0] == ' ' || text[0] == '\t') {
// Continuation line: append to previous
lines[len(lines)-1] += strings.TrimLeft(text, " \t")
} else {
lines = append(lines, text)
}
}
return lines
}
func parseIcalDate(s string) string {
// Extraire uniquement la partie date (avant tout T, +, -)
// pour gérer : YYYYMMDD, YYYYMMDDTHHMMSSZ, YYYYMMDDTHHMMSS, YYYYMMDDTHHMMSS+HHMM, etc.
raw := strings.SplitN(s, "T", 2)[0] // prend "YYYYMMDD" avant le T éventuel
raw = strings.SplitN(raw, "+", 2)[0]
raw = strings.SplitN(raw, "Z", 2)[0]
if len(raw) == 8 {
return fmt.Sprintf("%s-%s-%s", raw[0:4], raw[4:6], raw[6:8])
}
// Déjà au format YYYY-MM-DD
if len(s) >= 10 && s[4] == '-' {
return s[:10]
}
return s
}
func unescapeIcal(s string) string {
s = strings.ReplaceAll(s, `\n`, "\n")
s = strings.ReplaceAll(s, `\,`, ",")
s = strings.ReplaceAll(s, `\;`, ";")
s = strings.ReplaceAll(s, `\\`, `\`)
return s
}

307
internal/importer/qif.go Normal file
View File

@@ -0,0 +1,307 @@
package importer
import (
"bufio"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/google/uuid"
)
// ── Modèles ───────────────────────────────────────────────────────────────────
type QIFTransaction struct {
Date string `json:"date"`
Amount float64 `json:"amount"`
Payee string `json:"payee"`
Memo string `json:"memo"`
Type string `json:"type"` // income | expense
CategoryID string `json:"category_id"`
PropertyID string `json:"property_id"`
Description string `json:"description"`
}
type ImportResult struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
}
// ── Parser QIF ────────────────────────────────────────────────────────────────
func ParseQIF(r io.Reader) ([]QIFTransaction, error) {
var transactions []QIFTransaction
var current QIFTransaction
inTransaction := false
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(line) == 0 {
continue
}
if strings.HasPrefix(line, "!") {
continue
}
code := string(line[0])
value := ""
if len(line) > 1 {
value = strings.TrimSpace(line[1:])
}
switch code {
case "D":
inTransaction = true
current.Date = parseQIFDate(value)
case "T", "U":
amount, err := parseQIFAmount(value)
if err == nil {
current.Amount = amount
if amount >= 0 {
current.Type = "income"
} else {
current.Type = "expense"
current.Amount = -amount
}
}
case "P":
current.Payee = value
case "M":
current.Memo = value
case "^":
if inTransaction && current.Date != "" {
desc := current.Payee
if current.Memo != "" && current.Memo != current.Payee {
if desc != "" {
desc += " — " + current.Memo
} else {
desc = current.Memo
}
}
current.Description = desc
transactions = append(transactions, current)
current = QIFTransaction{}
inTransaction = false
}
}
}
if inTransaction && current.Date != "" {
desc := current.Payee
if current.Memo != "" && current.Memo != current.Payee {
if desc != "" {
desc += " — " + current.Memo
} else {
desc = current.Memo
}
}
current.Description = desc
transactions = append(transactions, current)
}
return transactions, scanner.Err()
}
// parseQIFDate priorité DD/MM/YYYY (format français)
func parseQIFDate(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "'", "/")
// Détecter séparateur
sep := "/"
if strings.Contains(s, "-") && !strings.Contains(s, "/") {
sep = "-"
}
parts := strings.Split(s, sep)
if len(parts) != 3 {
return s
}
p0 := strings.TrimSpace(parts[0])
p1 := strings.TrimSpace(parts[1])
p2 := strings.TrimSpace(parts[2])
// YYYY-MM-DD ou YYYY/MM/DD
if len(p0) == 4 {
return fmt.Sprintf("%s-%s-%s", p0, zeroPad(p1), zeroPad(p2))
}
// DD/MM/YYYY ou DD/MM/YY
year := ""
day := p0
month := p1
if len(p2) == 4 {
year = p2
} else if len(p2) == 2 {
y, _ := strconv.Atoi(p2)
if y <= 50 {
year = fmt.Sprintf("20%02d", y)
} else {
year = fmt.Sprintf("19%02d", y)
}
}
if year == "" {
return s
}
// Si p0 > 12, c'est forcément le jour (format français DD/MM)
d, _ := strconv.Atoi(p0)
m, _ := strconv.Atoi(p1)
if d > 12 {
// Clairement DD/MM/YYYY
return fmt.Sprintf("%s-%s-%s", year, zeroPad(month), zeroPad(day))
}
if m > 12 {
// Clairement MM/DD → p1 est le jour
return fmt.Sprintf("%s-%s-%s", year, zeroPad(day), zeroPad(month))
}
// Ambiguïté : on assume DD/MM (format français)
return fmt.Sprintf("%s-%s-%s", year, zeroPad(month), zeroPad(day))
}
func zeroPad(s string) string {
if len(s) == 1 {
return "0" + s
}
return s
}
// parseQIFAmount gère 1.234,56 et 1,234.56 et -1234.56
func parseQIFAmount(s string) (float64, error) {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, "€", "")
s = strings.ReplaceAll(s, "$", "")
if strings.Contains(s, ",") && strings.Contains(s, ".") {
if strings.LastIndex(s, ",") > strings.LastIndex(s, ".") {
s = strings.ReplaceAll(s, ".", "")
s = strings.ReplaceAll(s, ",", ".")
} else {
s = strings.ReplaceAll(s, ",", "")
}
} else if strings.Contains(s, ",") {
s = strings.ReplaceAll(s, ",", ".")
}
return strconv.ParseFloat(s, 64)
}
// ── Store & Handler ───────────────────────────────────────────────────────────
type Handler struct{ db *sql.DB }
func NewHandler(db *sql.DB) *Handler { return &Handler{db: db} }
// Check retourne pour chaque transaction si elle existe déjà en base
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
var payload []QIFTransaction
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
exists := make([]bool, len(payload))
for i, t := range payload {
// 1. Correspondance exacte (date + montant + type)
var exactCount int
h.db.QueryRow(
`SELECT COUNT(*) FROM transactions WHERE date=? AND amount=? AND type=?`,
t.Date, t.Amount, t.Type,
).Scan(&exactCount)
if exactCount > 0 {
exists[i] = true
continue
}
// 2. Cas split en 2 parts (intérêts + capital) : chercher une paire dont la somme = montant
var pairCount int
h.db.QueryRow(`
SELECT COUNT(*) FROM transactions t1
JOIN transactions t2 ON t1.date=t2.date AND t1.id<t2.id AND t1.type=t2.type
WHERE t1.date=? AND t1.type=? AND ABS(t1.amount+t2.amount-?)<0.10`,
t.Date, t.Type, t.Amount,
).Scan(&pairCount)
exists[i] = pairCount > 0
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(exists)
}
func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) {
r.ParseMultipartForm(10 << 20)
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "fichier requis", http.StatusBadRequest)
return
}
defer file.Close()
txs, err := ParseQIF(file)
if err != nil {
http.Error(w, fmt.Sprintf("erreur parsing QIF: %v", err), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(txs)
}
func (h *Handler) Import(w http.ResponseWriter, r *http.Request) {
var payload struct {
PropertyID string `json:"property_id"`
Transactions []QIFTransaction `json:"transactions"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if payload.PropertyID == "" {
http.Error(w, "property_id requis", http.StatusBadRequest)
return
}
result := ImportResult{}
for _, t := range payload.Transactions {
result.Total++
// Déduplication : même date + montant + type + description + bien
var existing int
h.db.QueryRow(
`SELECT COUNT(*) FROM transactions WHERE date=? AND amount=? AND type=?`,
t.Date, t.Amount, t.Type,
).Scan(&existing)
if existing > 0 {
result.Skipped++
continue
}
var catID interface{}
if t.CategoryID != "" {
catID = t.CategoryID
}
_, err := h.db.Exec(
`INSERT INTO transactions (id, property_id, category_id, type, amount, date, description) VALUES (?,?,?,?,?,?,?)`,
uuid.NewString(), t.PropertyID, catID, t.Type, t.Amount, t.Date, t.Description,
)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", t.Date, err))
} else {
result.Imported++
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}

416
internal/loan/loan.go Normal file
View File

@@ -0,0 +1,416 @@
package loan
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
// ── Modèles ───────────────────────────────────────────────────────────────────
type Loan struct {
ID string `json:"id"`
PropertyID string `json:"property_id"`
Label string `json:"label"`
Reference string `json:"reference"`
InitialAmount float64 `json:"initial_amount"`
MonthlyPayment float64 `json:"monthly_payment"`
PropertyName string `json:"property_name,omitempty"`
}
type LoanLine struct {
ID string `json:"id"`
LoanID string `json:"loan_id"`
Rank int `json:"rank"`
DueDate string `json:"due_date"`
TotalAmount float64 `json:"total_amount"`
Capital float64 `json:"capital"`
Interest float64 `json:"interest"`
RemainingCapital float64 `json:"remaining_capital"`
}
// ── Store ─────────────────────────────────────────────────────────────────────
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) Migrate() error {
if _, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS loans (
id TEXT PRIMARY KEY,
property_id TEXT NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
label TEXT NOT NULL,
reference TEXT,
initial_amount REAL NOT NULL,
monthly_payment REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS loan_lines (
id TEXT PRIMARY KEY,
loan_id TEXT NOT NULL REFERENCES loans(id) ON DELETE CASCADE,
rank INTEGER NOT NULL,
due_date DATE NOT NULL,
total_amount REAL,
capital REAL NOT NULL DEFAULT 0,
interest REAL NOT NULL DEFAULT 0,
remaining_capital REAL NOT NULL DEFAULT 0,
UNIQUE(loan_id, rank)
);`); err != nil {
return err
}
return s.patchSeedLines()
}
// patchSeedLines recharge les lignes depuis le seed pour tout prêt dont
// le nombre de lignes en base est inférieur au seed (données manquantes).
func (s *Store) patchSeedLines() error {
rows, err := s.db.Query(`SELECT id, COALESCE(reference,'') FROM loans`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id, ref string
rows.Scan(&id, &ref)
var seedLines []LoanLine
switch {
case strings.Contains(ref, "781495"):
seedLines = GetLoan781495Lines()
case strings.Contains(ref, "781728"):
seedLines = GetLoan781728Lines()
default:
continue
}
var count int
s.db.QueryRow(`SELECT COUNT(*) FROM loan_lines WHERE loan_id=?`, id).Scan(&count)
if count < len(seedLines) {
s.InsertLines(id, seedLines)
}
}
return nil
}
func (s *Store) ListLoans(propertyID string) ([]Loan, error) {
query := `SELECT l.id, l.property_id, l.label, COALESCE(l.reference,''),
l.initial_amount, l.monthly_payment, p.name
FROM loans l JOIN properties p ON p.id = l.property_id WHERE 1=1`
args := []any{}
if propertyID != "" {
query += " AND l.property_id=?"
args = append(args, propertyID)
}
rows, err := s.db.Query(query+" ORDER BY l.label", args...)
if err != nil {
return nil, err
}
defer rows.Close()
var loans []Loan
for rows.Next() {
var l Loan
rows.Scan(&l.ID, &l.PropertyID, &l.Label, &l.Reference,
&l.InitialAmount, &l.MonthlyPayment, &l.PropertyName)
loans = append(loans, l)
}
return loans, nil
}
func (s *Store) CreateLoan(l *Loan) error {
l.ID = uuid.NewString()
_, err := s.db.Exec(
`INSERT INTO loans (id, property_id, label, reference, initial_amount, monthly_payment) VALUES (?,?,?,?,?,?)`,
l.ID, l.PropertyID, l.Label, l.Reference, l.InitialAmount, l.MonthlyPayment,
)
return err
}
func (s *Store) DeleteLoan(id string) error {
_, err := s.db.Exec(`DELETE FROM loans WHERE id=?`, id)
return err
}
func (s *Store) InsertLines(loanID string, lines []LoanLine) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
// Supprimer les anciennes lignes
tx.Exec(`DELETE FROM loan_lines WHERE loan_id=?`, loanID)
stmt, err := tx.Prepare(`INSERT INTO loan_lines (id, loan_id, rank, due_date, total_amount, capital, interest, remaining_capital)
VALUES (?,?,?,?,?,?,?,?)`)
if err != nil {
tx.Rollback()
return err
}
defer stmt.Close()
for _, l := range lines {
if _, err := stmt.Exec(uuid.NewString(), loanID, l.Rank, l.DueDate,
l.TotalAmount, l.Capital, l.Interest, l.RemainingCapital); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
// GetLineByDate retourne la ligne d'amortissement pour une date donnée
func (s *Store) GetLineByDate(loanID, date string) (*LoanLine, error) {
var l LoanLine
err := s.db.QueryRow(`SELECT id, loan_id, rank, due_date, total_amount, capital, interest, remaining_capital
FROM loan_lines WHERE loan_id=? AND due_date=?`, loanID, date).
Scan(&l.ID, &l.LoanID, &l.Rank, &l.DueDate, &l.TotalAmount, &l.Capital, &l.Interest, &l.RemainingCapital)
if err != nil {
return nil, err
}
return &l, nil
}
// GetLinesByYear retourne toutes les lignes d'une année
func (s *Store) GetLinesByYear(loanID, year string) ([]LoanLine, error) {
rows, err := s.db.Query(`SELECT id, loan_id, rank, due_date, total_amount, capital, interest, remaining_capital
FROM loan_lines WHERE loan_id=? AND strftime('%Y', due_date)=?
ORDER BY due_date`, loanID, year)
if err != nil {
return nil, err
}
defer rows.Close()
var lines []LoanLine
for rows.Next() {
var l LoanLine
rows.Scan(&l.ID, &l.LoanID, &l.Rank, &l.DueDate, &l.TotalAmount, &l.Capital, &l.Interest, &l.RemainingCapital)
lines = append(lines, l)
}
return lines, nil
}
func (s *Store) GetLines(loanID string) ([]LoanLine, error) {
rows, err := s.db.Query(`SELECT id, loan_id, rank, due_date, total_amount, capital, interest, remaining_capital
FROM loan_lines WHERE loan_id=? ORDER BY rank`, loanID)
if err != nil {
return nil, err
}
defer rows.Close()
var lines []LoanLine
for rows.Next() {
var l LoanLine
rows.Scan(&l.ID, &l.LoanID, &l.Rank, &l.DueDate, &l.TotalAmount, &l.Capital, &l.Interest, &l.RemainingCapital)
lines = append(lines, l)
}
return lines, nil
}
// ── Handler ───────────────────────────────────────────────────────────────────
type Handler struct{ store *Store }
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
func (h *Handler) ListLoans(w http.ResponseWriter, r *http.Request) {
loans, err := h.store.ListLoans(r.URL.Query().Get("property_id"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if loans == nil {
loans = []Loan{}
}
respond(w, loans)
}
func (h *Handler) CreateLoan(w http.ResponseWriter, r *http.Request) {
var l Loan
if err := json.NewDecoder(r.Body).Decode(&l); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if err := h.store.CreateLoan(&l); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
respond(w, l)
}
func (h *Handler) DeleteLoan(w http.ResponseWriter, r *http.Request) {
if err := h.store.DeleteLoan(mux.Vars(r)["id"]); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UploadLines : reçoit le tableau d'amortissement sous forme JSON
func (h *Handler) UploadLines(w http.ResponseWriter, r *http.Request) {
loanID := mux.Vars(r)["id"]
var lines []LoanLine
if err := json.NewDecoder(r.Body).Decode(&lines); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if err := h.store.InsertLines(loanID, lines); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, map[string]int{"imported": len(lines)})
}
func (h *Handler) GetLines(w http.ResponseWriter, r *http.Request) {
loanID := mux.Vars(r)["id"]
year := r.URL.Query().Get("year")
var lines []LoanLine
var err error
if year != "" {
lines, err = h.store.GetLinesByYear(loanID, year)
} else {
lines, err = h.store.GetLines(loanID)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if lines == nil {
lines = []LoanLine{}
}
respond(w, lines)
}
// SplitByDate : retourne la décomposition capital/intérêts pour une date
func (h *Handler) SplitByDate(w http.ResponseWriter, r *http.Request) {
loanID := mux.Vars(r)["id"]
date := r.URL.Query().Get("date")
if date == "" {
http.Error(w, "date requise (YYYY-MM-DD)", http.StatusBadRequest)
return
}
line, err := h.store.GetLineByDate(loanID, date)
if err == sql.ErrNoRows {
http.Error(w, fmt.Sprintf("aucune échéance trouvée pour le %s", date), http.StatusNotFound)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, line)
}
// AnnualSummary : résumé annuel intérêts/capital pour la liasse fiscale
func (h *Handler) AnnualSummary(w http.ResponseWriter, r *http.Request) {
loanID := mux.Vars(r)["id"]
year := r.URL.Query().Get("year")
if year == "" {
year = strconv.Itoa(2026)
}
lines, err := h.store.GetLinesByYear(loanID, year)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var totalCapital, totalInterest, totalPayment float64
for _, l := range lines {
totalCapital += l.Capital
totalInterest += l.Interest
totalPayment += l.TotalAmount
}
respond(w, map[string]any{
"loan_id": loanID,
"year": year,
"months": len(lines),
"total_payment": totalPayment,
"total_capital": totalCapital,
"total_interest": totalInterest,
})
}
// ReloadLines recharge les lignes d'amortissement depuis les données embarquées
func (h *Handler) ReloadLines(w http.ResponseWriter, r *http.Request) {
loanID := mux.Vars(r)["id"]
var ref string
if err := h.store.db.QueryRow(`SELECT COALESCE(reference,'') FROM loans WHERE id=?`, loanID).Scan(&ref); err != nil {
http.Error(w, "prêt introuvable", http.StatusNotFound)
return
}
var lines []LoanLine
switch {
case strings.Contains(ref, "781495"):
lines = GetLoan781495Lines()
case strings.Contains(ref, "781728"):
lines = GetLoan781728Lines()
default:
http.Error(w, "aucune donnée embarquée pour ce prêt", http.StatusNotFound)
return
}
if err := h.store.InsertLines(loanID, lines); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, map[string]int{"reloaded": len(lines)})
}
func respond(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// GetSplitForAmount retourne la décomposition capital/intérêts pour un montant et une date
// Cherche dans tous les prêts la ligne correspondant à la date
func (h *Handler) GetSplitForAmount(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
http.Error(w, "date requise", http.StatusBadRequest)
return
}
var count int
h.store.db.QueryRow(`SELECT COUNT(*) FROM loan_lines`).Scan(&count)
if count == 0 {
http.Error(w, "aucune ligne d'amortissement en base — ajoutez les prêts dans la page Prêts", http.StatusNotFound)
return
}
rows, err := h.store.db.Query(`
SELECT ll.loan_id, ll.rank, ll.capital, ll.interest, ll.total_amount,
l.reference, l.label, l.property_id
FROM loan_lines ll
JOIN loans l ON l.id = ll.loan_id
WHERE strftime('%Y-%m', ll.due_date) = strftime('%Y-%m', ?) AND ll.capital > 0
ORDER BY ll.loan_id`, date)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
type SplitResult struct {
LoanID string `json:"loan_id"`
LoanRef string `json:"loan_ref"`
LoanLabel string `json:"loan_label"`
PropertyID string `json:"property_id"`
Rank int `json:"rank"`
Capital float64 `json:"capital"`
Interest float64 `json:"interest"`
Total float64 `json:"total"`
}
var results []SplitResult
for rows.Next() {
var s SplitResult
if err := rows.Scan(&s.LoanID, &s.Rank, &s.Capital, &s.Interest, &s.Total,
&s.LoanRef, &s.LoanLabel, &s.PropertyID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
results = append(results, s)
}
if results == nil {
results = []SplitResult{}
}
respond(w, results)
}

269
internal/loan/pdf_parser.go Normal file
View File

@@ -0,0 +1,269 @@
package loan
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"strings"
"github.com/gorilla/mux"
)
// UploadPDF reçoit un PDF, tente de le parser via Python si disponible,
// sinon crée le prêt avec saisie manuelle
func (h *Handler) UploadPDF(w http.ResponseWriter, r *http.Request) {
r.ParseMultipartForm(20 << 20)
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "fichier PDF requis", http.StatusBadRequest)
return
}
defer file.Close()
propertyID := r.FormValue("property_id")
if propertyID == "" {
http.Error(w, "property_id requis", http.StatusBadRequest)
return
}
labelInput := r.FormValue("label")
var buf bytes.Buffer
buf.ReadFrom(file)
pdfBytes := buf.Bytes()
// Tenter l'extraction Python (optionnel)
ref, initialAmount, monthly := extractInfoFallback(pdfBytes)
label := labelInput
if label == "" {
if ref != "" {
label = fmt.Sprintf("Prêt %s", ref)
} else {
label = "Prêt immobilier"
}
}
loan := &Loan{
PropertyID: propertyID,
Label: label,
Reference: ref,
InitialAmount: initialAmount,
MonthlyPayment: monthly,
}
if err := h.store.CreateLoan(loan); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Tenter le parsing des lignes via Python
lines, parseErr := parseLinesWithPython(pdfBytes)
linesImported := 0
if parseErr == nil && len(lines) > 0 {
h.store.InsertLines(loan.ID, lines)
linesImported = len(lines)
} else {
// Pas de Python : essayer les données embarquées selon la référence
switch {
case strings.Contains(ref, "781495"):
lines = GetLoan781495Lines()
case strings.Contains(ref, "781728"):
lines = GetLoan781728Lines()
}
if len(lines) > 0 {
h.store.InsertLines(loan.ID, lines)
linesImported = len(lines)
}
}
respond(w, map[string]any{
"loan": loan,
"lines_imported": linesImported,
"python_used": parseErr == nil,
})
}
// extractInfoFallback tente d'extraire les infos via Python, retourne des zéros si indisponible
func extractInfoFallback(pdfBytes []byte) (ref string, initialAmount, monthly float64) {
script := `
import sys, json, pdfplumber, io, re
data = sys.stdin.buffer.read()
result = {'reference': '', 'initial_amount': 0, 'monthly': 0}
with pdfplumber.open(io.BytesIO(data)) as pdf:
text = pdf.pages[0].extract_text() or ''
m = re.search(r'cr.dit\s*:\s*([\w]+)', text)
if m: result['reference'] = m.group(1).strip()
m = re.search(r'Montant du pr.t\s*:\s*([\d\s,\.]+)\s*EUR', text)
if m:
try: result['initial_amount'] = float(m.group(1).strip().replace(' ','').replace('\u202f','').replace(',','.'))
except: pass
for page in pdf.pages:
for table in (page.extract_tables() or []):
if not table: continue
for row in table[1:]:
if not row or not row[2]: continue
vals=[v.strip() for v in str(row[2]).split('\n') if v.strip()]
if len(vals)>=3 and len(set(vals[:3]))==1:
try:
result['monthly']=float(vals[0].replace(' ','').replace('\u202f','').replace(',','.'))
break
except: pass
if result['monthly']: break
if result['monthly']: break
print(json.dumps(result))
`
py := pythonBin()
if py == "" {
return
}
cmd := exec.Command(py, "-c", script)
cmd.Stdin = bytes.NewReader(pdfBytes)
var out bytes.Buffer
cmd.Stdout = &out
if cmd.Run() != nil {
return
}
var info struct {
Reference string `json:"reference"`
InitialAmount float64 `json:"initial_amount"`
Monthly float64 `json:"monthly"`
}
if json.Unmarshal(out.Bytes(), &info) != nil {
return
}
r := info.Reference
if idx := strings.Index(r, "/"); idx > 0 {
r = strings.TrimSpace(r[:idx])
}
return r, info.InitialAmount, info.Monthly
}
// parseLinesWithPython tente le parsing complet via pdfplumber
func parseLinesWithPython(pdfBytes []byte) ([]LoanLine, error) {
py := pythonBin()
if py == "" {
return nil, fmt.Errorf("python non disponible")
}
script := `
import sys,json,pdfplumber,io
def pa(s):
if not s or not s.strip(): return 0.0
try: return float(s.strip().replace(' ','').replace('\u202f','').replace('\xa0','').replace(',','.'))
except: return 0.0
def pd(s):
s=s.strip()
if '/' in s:
p=s.split('/')
if len(p)==3: return f"{p[2]}-{p[1]}-{p[0]}"
return s
lines=[]
with pdfplumber.open(io.BytesIO(sys.stdin.buffer.read())) as pdf:
for page in pdf.pages:
for table in (page.extract_tables() or []):
if not table or len(table)<2: continue
if not table[0] or 'RANG' not in str(table[0][0]): continue
for row in table[1:]:
if not row or not row[0]: continue
ranks=str(row[0]).split('\n'); dates=str(row[1]).split('\n') if row[1] else []
tots=str(row[2]).split('\n') if row[2] else []; caps=str(row[3]).split('\n') if row[3] else []
ints=str(row[4]).split('\n') if row[4] else []; rems=str(row[5]).split('\n') if row[5] else []
for i,rs in enumerate(ranks):
rs=rs.strip()
if not rs or not rs.isdigit(): continue
c=pa(caps[i] if i<len(caps) else '0')
if c==0: continue
lines.append({'rank':int(rs),'due_date':pd(dates[i].strip() if i<len(dates) else ''),
'total_amount':pa(tots[i] if i<len(tots) else '0'),'capital':c,
'interest':pa(ints[i] if i<len(ints) else '0'),
'remaining_capital':pa(rems[i] if i<len(rems) else '0')})
print(json.dumps(lines))
`
cmd := exec.Command(py, "-c", script)
cmd.Stdin = bytes.NewReader(pdfBytes)
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, err
}
var raw []struct {
Rank int `json:"rank"`
DueDate string `json:"due_date"`
TotalAmount float64 `json:"total_amount"`
Capital float64 `json:"capital"`
Interest float64 `json:"interest"`
RemainingCapital float64 `json:"remaining_capital"`
}
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
return nil, err
}
lines := make([]LoanLine, len(raw))
for i, r := range raw {
lines[i] = LoanLine{Rank: r.Rank, DueDate: r.DueDate, TotalAmount: r.TotalAmount,
Capital: r.Capital, Interest: r.Interest, RemainingCapital: r.RemainingCapital}
}
return lines, nil
}
func pythonBin() string {
for _, name := range []string{"python3", "python"} {
if _, err := exec.LookPath(name); err == nil {
return name
}
}
return ""
}
func respondPDF(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// CreateLoanManual crée un prêt depuis un formulaire JSON
func (h *Handler) CreateLoanManual(w http.ResponseWriter, r *http.Request) {
var payload struct {
PropertyID string `json:"property_id"`
Label string `json:"label"`
Reference string `json:"reference"`
InitialAmount float64 `json:"initial_amount"`
MonthlyPayment float64 `json:"monthly_payment"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
loan := &Loan{
PropertyID: payload.PropertyID,
Label: payload.Label,
Reference: payload.Reference,
InitialAmount: payload.InitialAmount,
MonthlyPayment: payload.MonthlyPayment,
}
if err := h.store.CreateLoan(loan); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Charger les données embarquées selon la référence
var lines []LoanLine
switch {
case strings.Contains(payload.Reference, "781495"):
lines = GetLoan781495Lines()
case strings.Contains(payload.Reference, "781728"):
lines = GetLoan781728Lines()
}
linesImported := 0
if len(lines) > 0 {
h.store.InsertLines(loan.ID, lines)
linesImported = len(lines)
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{
"loan": loan,
"lines_imported": linesImported,
})
}
var _ = mux.Vars // éviter unused import

474
internal/loan/seed.go Normal file
View File

@@ -0,0 +1,474 @@
package loan
// Données extraites des tableaux d'amortissement Caisse d'Epargne CEPAC
// Prêt 781495E : 183 765€ — mensualité 1 084,75€ — taux 3,7%
// Prêt 781728E : 122 946€ — mensualité 725,74€ — taux 3,7%
// Phase amortissement uniquement (rang 8 → rang 247)
// Prêt 781495E : 216 lignes
func GetLoan781495Lines() []LoanLine {
return []LoanLine{
{Rank: 8, DueDate: "2024-04-05", TotalAmount: 1084.75, Capital: 518.14, Interest: 566.61, RemainingCapital: 183246.86},
{Rank: 9, DueDate: "2024-05-05", TotalAmount: 1084.75, Capital: 519.74, Interest: 565.01, RemainingCapital: 182727.12},
{Rank: 10, DueDate: "2024-06-05", TotalAmount: 1084.75, Capital: 521.34, Interest: 563.41, RemainingCapital: 182205.78},
{Rank: 11, DueDate: "2024-07-05", TotalAmount: 1084.75, Capital: 522.95, Interest: 561.8, RemainingCapital: 181682.83},
{Rank: 12, DueDate: "2024-08-05", TotalAmount: 1084.75, Capital: 524.56, Interest: 560.19, RemainingCapital: 181158.27},
{Rank: 13, DueDate: "2024-09-05", TotalAmount: 1084.75, Capital: 526.18, Interest: 558.57, RemainingCapital: 180632.09},
{Rank: 14, DueDate: "2024-10-05", TotalAmount: 1084.75, Capital: 527.8, Interest: 556.95, RemainingCapital: 180104.29},
{Rank: 15, DueDate: "2024-11-05", TotalAmount: 1084.75, Capital: 529.43, Interest: 555.32, RemainingCapital: 179574.86},
{Rank: 16, DueDate: "2024-12-05", TotalAmount: 1084.75, Capital: 531.06, Interest: 553.69, RemainingCapital: 179043.8},
{Rank: 17, DueDate: "2025-01-05", TotalAmount: 1084.75, Capital: 532.7, Interest: 552.05, RemainingCapital: 178511.1},
{Rank: 18, DueDate: "2025-02-05", TotalAmount: 1084.75, Capital: 534.34, Interest: 550.41, RemainingCapital: 177976.76},
{Rank: 19, DueDate: "2025-03-05", TotalAmount: 1084.75, Capital: 535.99, Interest: 548.76, RemainingCapital: 177440.77},
{Rank: 20, DueDate: "2025-04-05", TotalAmount: 1084.75, Capital: 537.64, Interest: 547.11, RemainingCapital: 176903.13},
{Rank: 21, DueDate: "2025-05-05", TotalAmount: 1084.75, Capital: 539.3, Interest: 545.45, RemainingCapital: 176363.83},
{Rank: 22, DueDate: "2025-06-05", TotalAmount: 1084.75, Capital: 540.96, Interest: 543.79, RemainingCapital: 175822.87},
{Rank: 23, DueDate: "2025-07-05", TotalAmount: 1084.75, Capital: 542.63, Interest: 542.12, RemainingCapital: 175280.24},
{Rank: 24, DueDate: "2025-08-05", TotalAmount: 1084.75, Capital: 544.3, Interest: 540.45, RemainingCapital: 174735.94},
{Rank: 25, DueDate: "2025-09-05", TotalAmount: 1084.75, Capital: 545.98, Interest: 538.77, RemainingCapital: 174189.96},
{Rank: 26, DueDate: "2025-10-05", TotalAmount: 1084.75, Capital: 547.66, Interest: 537.09, RemainingCapital: 173642.3},
{Rank: 27, DueDate: "2025-11-05", TotalAmount: 1084.75, Capital: 549.35, Interest: 535.4, RemainingCapital: 173092.95},
{Rank: 28, DueDate: "2025-12-05", TotalAmount: 1084.75, Capital: 551.05, Interest: 533.7, RemainingCapital: 172541.9},
{Rank: 29, DueDate: "2026-01-05", TotalAmount: 1084.75, Capital: 552.75, Interest: 532.00, RemainingCapital: 171989.15},
{Rank: 30, DueDate: "2026-02-05", TotalAmount: 1084.75, Capital: 554.45, Interest: 530.30, RemainingCapital: 171434.70},
{Rank: 31, DueDate: "2026-03-05", TotalAmount: 1084.75, Capital: 556.16, Interest: 528.59, RemainingCapital: 170878.54},
{Rank: 32, DueDate: "2026-04-05", TotalAmount: 1084.75, Capital: 557.87, Interest: 526.88, RemainingCapital: 170320.67},
{Rank: 33, DueDate: "2026-05-05", TotalAmount: 1084.75, Capital: 559.59, Interest: 525.16, RemainingCapital: 169761.08},
{Rank: 34, DueDate: "2026-06-05", TotalAmount: 1084.75, Capital: 561.32, Interest: 523.43, RemainingCapital: 169199.76},
{Rank: 35, DueDate: "2026-07-05", TotalAmount: 1084.75, Capital: 563.05, Interest: 521.70, RemainingCapital: 168636.71},
{Rank: 36, DueDate: "2026-08-05", TotalAmount: 1084.75, Capital: 564.79, Interest: 519.96, RemainingCapital: 168071.92},
{Rank: 37, DueDate: "2026-09-05", TotalAmount: 1084.75, Capital: 566.53, Interest: 518.22, RemainingCapital: 167505.39},
{Rank: 38, DueDate: "2026-10-05", TotalAmount: 1084.75, Capital: 568.28, Interest: 516.47, RemainingCapital: 166937.11},
{Rank: 39, DueDate: "2026-11-05", TotalAmount: 1084.75, Capital: 570.03, Interest: 514.72, RemainingCapital: 166367.08},
{Rank: 40, DueDate: "2026-12-05", TotalAmount: 1084.75, Capital: 571.78, Interest: 512.97, RemainingCapital: 165795.30},
{Rank: 41, DueDate: "2027-01-05", TotalAmount: 1084.75, Capital: 573.55, Interest: 511.2, RemainingCapital: 165221.75},
{Rank: 42, DueDate: "2027-02-05", TotalAmount: 1084.75, Capital: 575.32, Interest: 509.43, RemainingCapital: 164646.43},
{Rank: 43, DueDate: "2027-03-05", TotalAmount: 1084.75, Capital: 577.09, Interest: 507.66, RemainingCapital: 164069.34},
{Rank: 44, DueDate: "2027-04-05", TotalAmount: 1084.75, Capital: 578.87, Interest: 505.88, RemainingCapital: 163490.47},
{Rank: 45, DueDate: "2027-05-05", TotalAmount: 1084.75, Capital: 580.65, Interest: 504.1, RemainingCapital: 162909.82},
{Rank: 46, DueDate: "2027-06-05", TotalAmount: 1084.75, Capital: 582.44, Interest: 502.31, RemainingCapital: 162327.38},
{Rank: 47, DueDate: "2027-07-05", TotalAmount: 1084.75, Capital: 584.24, Interest: 500.51, RemainingCapital: 161743.14},
{Rank: 48, DueDate: "2027-08-05", TotalAmount: 1084.75, Capital: 586.04, Interest: 498.71, RemainingCapital: 161157.1},
{Rank: 49, DueDate: "2027-09-05", TotalAmount: 1084.75, Capital: 587.85, Interest: 496.9, RemainingCapital: 160569.25},
{Rank: 50, DueDate: "2027-10-05", TotalAmount: 1084.75, Capital: 589.66, Interest: 495.09, RemainingCapital: 159979.59},
{Rank: 51, DueDate: "2027-11-05", TotalAmount: 1084.75, Capital: 591.48, Interest: 493.27, RemainingCapital: 159388.11},
{Rank: 52, DueDate: "2027-12-05", TotalAmount: 1084.75, Capital: 593.3, Interest: 491.45, RemainingCapital: 158794.81},
{Rank: 53, DueDate: "2028-01-05", TotalAmount: 1084.75, Capital: 595.13, Interest: 489.62, RemainingCapital: 158199.68},
{Rank: 54, DueDate: "2028-02-05", TotalAmount: 1084.75, Capital: 596.97, Interest: 487.78, RemainingCapital: 157602.71},
{Rank: 55, DueDate: "2028-03-05", TotalAmount: 1084.75, Capital: 598.81, Interest: 485.94, RemainingCapital: 157003.9},
{Rank: 56, DueDate: "2028-04-05", TotalAmount: 1084.75, Capital: 600.65, Interest: 484.1, RemainingCapital: 156403.25},
{Rank: 57, DueDate: "2028-05-05", TotalAmount: 1084.75, Capital: 602.51, Interest: 482.24, RemainingCapital: 155800.74},
{Rank: 58, DueDate: "2028-06-05", TotalAmount: 1084.75, Capital: 604.36, Interest: 480.39, RemainingCapital: 155196.38},
{Rank: 59, DueDate: "2028-07-05", TotalAmount: 1084.75, Capital: 606.23, Interest: 478.52, RemainingCapital: 154590.15},
{Rank: 60, DueDate: "2028-08-05", TotalAmount: 1084.75, Capital: 608.1, Interest: 476.65, RemainingCapital: 153982.05},
{Rank: 61, DueDate: "2028-09-05", TotalAmount: 1084.75, Capital: 609.97, Interest: 474.78, RemainingCapital: 153372.08},
{Rank: 62, DueDate: "2028-10-05", TotalAmount: 1084.75, Capital: 611.85, Interest: 472.9, RemainingCapital: 152760.23},
{Rank: 63, DueDate: "2028-11-05", TotalAmount: 1084.75, Capital: 613.74, Interest: 471.01, RemainingCapital: 152146.49},
{Rank: 64, DueDate: "2028-12-05", TotalAmount: 1084.75, Capital: 615.63, Interest: 469.12, RemainingCapital: 151530.86},
{Rank: 65, DueDate: "2029-01-05", TotalAmount: 1084.75, Capital: 617.53, Interest: 467.22, RemainingCapital: 150913.33},
{Rank: 66, DueDate: "2029-02-05", TotalAmount: 1084.75, Capital: 619.43, Interest: 465.32, RemainingCapital: 150293.9},
{Rank: 67, DueDate: "2029-03-05", TotalAmount: 1084.75, Capital: 621.34, Interest: 463.41, RemainingCapital: 149672.56},
{Rank: 68, DueDate: "2029-04-05", TotalAmount: 1084.75, Capital: 623.26, Interest: 461.49, RemainingCapital: 149049.3},
{Rank: 69, DueDate: "2029-05-05", TotalAmount: 1084.75, Capital: 625.18, Interest: 459.57, RemainingCapital: 148424.12},
{Rank: 70, DueDate: "2029-06-05", TotalAmount: 1084.75, Capital: 627.11, Interest: 457.64, RemainingCapital: 147797.01},
{Rank: 71, DueDate: "2029-07-05", TotalAmount: 1084.75, Capital: 629.04, Interest: 455.71, RemainingCapital: 147167.97},
{Rank: 72, DueDate: "2029-08-05", TotalAmount: 1084.75, Capital: 630.98, Interest: 453.77, RemainingCapital: 146536.99},
{Rank: 73, DueDate: "2029-09-05", TotalAmount: 1084.75, Capital: 632.93, Interest: 451.82, RemainingCapital: 145904.06},
{Rank: 74, DueDate: "2029-10-05", TotalAmount: 1084.75, Capital: 634.88, Interest: 449.87, RemainingCapital: 145269.18},
{Rank: 75, DueDate: "2029-11-05", TotalAmount: 1084.75, Capital: 636.84, Interest: 447.91, RemainingCapital: 144632.34},
{Rank: 76, DueDate: "2029-12-05", TotalAmount: 1084.75, Capital: 638.8, Interest: 445.95, RemainingCapital: 143993.54},
{Rank: 77, DueDate: "2030-01-05", TotalAmount: 1084.75, Capital: 640.77, Interest: 443.98, RemainingCapital: 143352.77},
{Rank: 78, DueDate: "2030-02-05", TotalAmount: 1084.75, Capital: 642.75, Interest: 442.0, RemainingCapital: 142710.02},
{Rank: 79, DueDate: "2030-03-05", TotalAmount: 1084.75, Capital: 644.73, Interest: 440.02, RemainingCapital: 142065.29},
{Rank: 80, DueDate: "2030-04-05", TotalAmount: 1084.75, Capital: 646.72, Interest: 438.03, RemainingCapital: 141418.57},
{Rank: 89, DueDate: "2031-01-05", TotalAmount: 1084.75, Capital: 664.88, Interest: 419.87, RemainingCapital: 135507.66},
{Rank: 90, DueDate: "2031-02-05", TotalAmount: 1084.75, Capital: 666.93, Interest: 417.82, RemainingCapital: 134840.73},
{Rank: 91, DueDate: "2031-03-05", TotalAmount: 1084.75, Capital: 668.99, Interest: 415.76, RemainingCapital: 134171.74},
{Rank: 92, DueDate: "2031-04-05", TotalAmount: 1084.75, Capital: 671.05, Interest: 413.7, RemainingCapital: 133500.69},
{Rank: 93, DueDate: "2031-05-05", TotalAmount: 1084.75, Capital: 673.12, Interest: 411.63, RemainingCapital: 132827.57},
{Rank: 94, DueDate: "2031-06-05", TotalAmount: 1084.75, Capital: 675.2, Interest: 409.55, RemainingCapital: 132152.37},
{Rank: 95, DueDate: "2031-07-05", TotalAmount: 1084.75, Capital: 677.28, Interest: 407.47, RemainingCapital: 131475.09},
{Rank: 96, DueDate: "2031-08-05", TotalAmount: 1084.75, Capital: 679.37, Interest: 405.38, RemainingCapital: 130795.72},
{Rank: 97, DueDate: "2031-09-05", TotalAmount: 1084.75, Capital: 681.46, Interest: 403.29, RemainingCapital: 130114.26},
{Rank: 98, DueDate: "2031-10-05", TotalAmount: 1084.75, Capital: 683.56, Interest: 401.19, RemainingCapital: 129430.7},
{Rank: 99, DueDate: "2031-11-05", TotalAmount: 1084.75, Capital: 685.67, Interest: 399.08, RemainingCapital: 128745.03},
{Rank: 100, DueDate: "2031-12-05", TotalAmount: 1084.75, Capital: 687.79, Interest: 396.96, RemainingCapital: 128057.24},
{Rank: 101, DueDate: "2032-01-05", TotalAmount: 1084.75, Capital: 689.91, Interest: 394.84, RemainingCapital: 127367.33},
{Rank: 102, DueDate: "2032-02-05", TotalAmount: 1084.75, Capital: 692.03, Interest: 392.72, RemainingCapital: 126675.3},
{Rank: 103, DueDate: "2032-03-05", TotalAmount: 1084.75, Capital: 694.17, Interest: 390.58, RemainingCapital: 125981.13},
{Rank: 104, DueDate: "2032-04-05", TotalAmount: 1084.75, Capital: 696.31, Interest: 388.44, RemainingCapital: 125284.82},
{Rank: 105, DueDate: "2032-05-05", TotalAmount: 1084.75, Capital: 698.46, Interest: 386.29, RemainingCapital: 124586.36},
{Rank: 106, DueDate: "2032-06-05", TotalAmount: 1084.75, Capital: 700.61, Interest: 384.14, RemainingCapital: 123885.75},
{Rank: 107, DueDate: "2032-07-05", TotalAmount: 1084.75, Capital: 702.77, Interest: 381.98, RemainingCapital: 123182.98},
{Rank: 108, DueDate: "2032-08-05", TotalAmount: 1084.75, Capital: 704.94, Interest: 379.81, RemainingCapital: 122478.04},
{Rank: 109, DueDate: "2032-09-05", TotalAmount: 1084.75, Capital: 707.11, Interest: 377.64, RemainingCapital: 121770.93},
{Rank: 110, DueDate: "2032-10-05", TotalAmount: 1084.75, Capital: 709.29, Interest: 375.46, RemainingCapital: 121061.64},
{Rank: 111, DueDate: "2032-11-05", TotalAmount: 1084.75, Capital: 711.48, Interest: 373.27, RemainingCapital: 120350.16},
{Rank: 112, DueDate: "2032-12-05", TotalAmount: 1084.75, Capital: 713.67, Interest: 371.08, RemainingCapital: 119636.49},
{Rank: 113, DueDate: "2033-01-05", TotalAmount: 1084.75, Capital: 715.87, Interest: 368.88, RemainingCapital: 118920.62},
{Rank: 114, DueDate: "2033-02-05", TotalAmount: 1084.75, Capital: 718.08, Interest: 366.67, RemainingCapital: 118202.54},
{Rank: 115, DueDate: "2033-03-05", TotalAmount: 1084.75, Capital: 720.29, Interest: 364.46, RemainingCapital: 117482.25},
{Rank: 116, DueDate: "2033-04-05", TotalAmount: 1084.75, Capital: 722.51, Interest: 362.24, RemainingCapital: 116759.74},
{Rank: 117, DueDate: "2033-05-05", TotalAmount: 1084.75, Capital: 724.74, Interest: 360.01, RemainingCapital: 116035.0},
{Rank: 118, DueDate: "2033-06-05", TotalAmount: 1084.75, Capital: 726.98, Interest: 357.77, RemainingCapital: 115308.02},
{Rank: 119, DueDate: "2033-07-05", TotalAmount: 1084.75, Capital: 729.22, Interest: 355.53, RemainingCapital: 114578.8},
{Rank: 120, DueDate: "2033-08-05", TotalAmount: 1084.75, Capital: 731.47, Interest: 353.28, RemainingCapital: 113847.33},
{Rank: 121, DueDate: "2033-09-05", TotalAmount: 1084.75, Capital: 733.72, Interest: 351.03, RemainingCapital: 113113.61},
{Rank: 122, DueDate: "2033-10-05", TotalAmount: 1084.75, Capital: 735.98, Interest: 348.77, RemainingCapital: 112377.63},
{Rank: 123, DueDate: "2033-11-05", TotalAmount: 1084.75, Capital: 738.25, Interest: 346.5, RemainingCapital: 111639.38},
{Rank: 124, DueDate: "2033-12-05", TotalAmount: 1084.75, Capital: 740.53, Interest: 344.22, RemainingCapital: 110898.85},
{Rank: 125, DueDate: "2034-01-05", TotalAmount: 1084.75, Capital: 742.81, Interest: 341.94, RemainingCapital: 110156.04},
{Rank: 126, DueDate: "2034-02-05", TotalAmount: 1084.75, Capital: 745.1, Interest: 339.65, RemainingCapital: 109410.94},
{Rank: 127, DueDate: "2034-03-05", TotalAmount: 1084.75, Capital: 747.4, Interest: 337.35, RemainingCapital: 108663.54},
{Rank: 128, DueDate: "2034-04-05", TotalAmount: 1084.75, Capital: 749.7, Interest: 335.05, RemainingCapital: 107913.84},
{Rank: 129, DueDate: "2034-05-05", TotalAmount: 1084.75, Capital: 752.02, Interest: 332.73, RemainingCapital: 107161.82},
{Rank: 130, DueDate: "2034-06-05", TotalAmount: 1084.75, Capital: 754.33, Interest: 330.42, RemainingCapital: 106407.49},
{Rank: 131, DueDate: "2034-07-05", TotalAmount: 1084.75, Capital: 756.66, Interest: 328.09, RemainingCapital: 105650.83},
{Rank: 132, DueDate: "2034-08-05", TotalAmount: 1084.75, Capital: 758.99, Interest: 325.76, RemainingCapital: 104891.84},
{Rank: 137, DueDate: "2035-01-05", TotalAmount: 1084.75, Capital: 770.77, Interest: 313.98, RemainingCapital: 101061.62},
{Rank: 138, DueDate: "2035-02-05", TotalAmount: 1084.75, Capital: 773.14, Interest: 311.61, RemainingCapital: 100288.48},
{Rank: 139, DueDate: "2035-03-05", TotalAmount: 1084.75, Capital: 775.53, Interest: 309.22, RemainingCapital: 99512.95},
{Rank: 140, DueDate: "2035-04-05", TotalAmount: 1084.75, Capital: 777.92, Interest: 306.83, RemainingCapital: 98735.03},
{Rank: 141, DueDate: "2035-05-05", TotalAmount: 1084.75, Capital: 780.32, Interest: 304.43, RemainingCapital: 97954.71},
{Rank: 142, DueDate: "2035-06-05", TotalAmount: 1084.75, Capital: 782.72, Interest: 302.03, RemainingCapital: 97171.99},
{Rank: 143, DueDate: "2035-07-05", TotalAmount: 1084.75, Capital: 785.14, Interest: 299.61, RemainingCapital: 96386.85},
{Rank: 144, DueDate: "2035-08-05", TotalAmount: 1084.75, Capital: 787.56, Interest: 297.19, RemainingCapital: 95599.29},
{Rank: 145, DueDate: "2035-09-05", TotalAmount: 1084.75, Capital: 789.99, Interest: 294.76, RemainingCapital: 94809.3},
{Rank: 146, DueDate: "2035-10-05", TotalAmount: 1084.75, Capital: 792.42, Interest: 292.33, RemainingCapital: 94016.88},
{Rank: 147, DueDate: "2035-11-05", TotalAmount: 1084.75, Capital: 794.86, Interest: 289.89, RemainingCapital: 93222.02},
{Rank: 148, DueDate: "2035-12-05", TotalAmount: 1084.75, Capital: 797.32, Interest: 287.43, RemainingCapital: 92424.7},
{Rank: 149, DueDate: "2036-01-05", TotalAmount: 1084.75, Capital: 799.77, Interest: 284.98, RemainingCapital: 91624.93},
{Rank: 150, DueDate: "2036-02-05", TotalAmount: 1084.75, Capital: 802.24, Interest: 282.51, RemainingCapital: 90822.69},
{Rank: 151, DueDate: "2036-03-05", TotalAmount: 1084.75, Capital: 804.71, Interest: 280.04, RemainingCapital: 90017.98},
{Rank: 152, DueDate: "2036-04-05", TotalAmount: 1084.75, Capital: 807.19, Interest: 277.56, RemainingCapital: 89210.79},
{Rank: 153, DueDate: "2036-05-05", TotalAmount: 1084.75, Capital: 809.68, Interest: 275.07, RemainingCapital: 88401.11},
{Rank: 154, DueDate: "2036-06-05", TotalAmount: 1084.75, Capital: 812.18, Interest: 272.57, RemainingCapital: 87588.93},
{Rank: 155, DueDate: "2036-07-05", TotalAmount: 1084.75, Capital: 814.68, Interest: 270.07, RemainingCapital: 86774.25},
{Rank: 156, DueDate: "2036-08-05", TotalAmount: 1084.75, Capital: 817.2, Interest: 267.55, RemainingCapital: 85957.05},
{Rank: 157, DueDate: "2036-09-05", TotalAmount: 1084.75, Capital: 819.72, Interest: 265.03, RemainingCapital: 85137.33},
{Rank: 158, DueDate: "2036-10-05", TotalAmount: 1084.75, Capital: 822.24, Interest: 262.51, RemainingCapital: 84315.09},
{Rank: 159, DueDate: "2036-11-05", TotalAmount: 1084.75, Capital: 824.78, Interest: 259.97, RemainingCapital: 83490.31},
{Rank: 160, DueDate: "2036-12-05", TotalAmount: 1084.75, Capital: 827.32, Interest: 257.43, RemainingCapital: 82662.99},
{Rank: 161, DueDate: "2037-01-05", TotalAmount: 1084.75, Capital: 829.87, Interest: 254.88, RemainingCapital: 81833.12},
{Rank: 162, DueDate: "2037-02-05", TotalAmount: 1084.75, Capital: 832.43, Interest: 252.32, RemainingCapital: 81000.69},
{Rank: 163, DueDate: "2037-03-05", TotalAmount: 1084.75, Capital: 835.0, Interest: 249.75, RemainingCapital: 80165.69},
{Rank: 164, DueDate: "2037-04-05", TotalAmount: 1084.75, Capital: 837.57, Interest: 247.18, RemainingCapital: 79328.12},
{Rank: 165, DueDate: "2037-05-05", TotalAmount: 1084.75, Capital: 840.15, Interest: 244.6, RemainingCapital: 78487.97},
{Rank: 166, DueDate: "2037-06-05", TotalAmount: 1084.75, Capital: 842.75, Interest: 242.0, RemainingCapital: 77645.22},
{Rank: 167, DueDate: "2037-07-05", TotalAmount: 1084.75, Capital: 845.34, Interest: 239.41, RemainingCapital: 76799.88},
{Rank: 168, DueDate: "2037-08-05", TotalAmount: 1084.75, Capital: 847.95, Interest: 236.8, RemainingCapital: 75951.93},
{Rank: 169, DueDate: "2037-09-05", TotalAmount: 1084.75, Capital: 850.56, Interest: 234.19, RemainingCapital: 75101.37},
{Rank: 170, DueDate: "2037-10-05", TotalAmount: 1084.75, Capital: 853.19, Interest: 231.56, RemainingCapital: 74248.18},
{Rank: 171, DueDate: "2037-11-05", TotalAmount: 1084.75, Capital: 855.82, Interest: 228.93, RemainingCapital: 73392.36},
{Rank: 172, DueDate: "2037-12-05", TotalAmount: 1084.75, Capital: 858.46, Interest: 226.29, RemainingCapital: 72533.9},
{Rank: 173, DueDate: "2038-01-05", TotalAmount: 1084.75, Capital: 861.1, Interest: 223.65, RemainingCapital: 71672.8},
{Rank: 174, DueDate: "2038-02-05", TotalAmount: 1084.75, Capital: 863.76, Interest: 220.99, RemainingCapital: 70809.04},
{Rank: 175, DueDate: "2038-03-05", TotalAmount: 1084.75, Capital: 866.42, Interest: 218.33, RemainingCapital: 69942.62},
{Rank: 176, DueDate: "2038-04-05", TotalAmount: 1084.75, Capital: 869.09, Interest: 215.66, RemainingCapital: 69073.53},
{Rank: 177, DueDate: "2038-05-05", TotalAmount: 1084.75, Capital: 871.77, Interest: 212.98, RemainingCapital: 68201.76},
{Rank: 178, DueDate: "2038-06-05", TotalAmount: 1084.75, Capital: 874.46, Interest: 210.29, RemainingCapital: 67327.3},
{Rank: 179, DueDate: "2038-07-05", TotalAmount: 1084.75, Capital: 877.16, Interest: 207.59, RemainingCapital: 66450.14},
{Rank: 180, DueDate: "2038-08-05", TotalAmount: 1084.75, Capital: 879.86, Interest: 204.89, RemainingCapital: 65570.28},
{Rank: 181, DueDate: "2038-09-05", TotalAmount: 1084.75, Capital: 882.57, Interest: 202.18, RemainingCapital: 64687.71},
{Rank: 182, DueDate: "2038-10-05", TotalAmount: 1084.75, Capital: 885.3, Interest: 199.45, RemainingCapital: 63802.41},
{Rank: 183, DueDate: "2038-11-05", TotalAmount: 1084.75, Capital: 888.03, Interest: 196.72, RemainingCapital: 62914.38},
{Rank: 184, DueDate: "2038-12-05", TotalAmount: 1084.75, Capital: 890.76, Interest: 193.99, RemainingCapital: 62023.62},
{Rank: 185, DueDate: "2039-01-05", TotalAmount: 1084.75, Capital: 893.51, Interest: 191.24, RemainingCapital: 61130.11},
{Rank: 186, DueDate: "2039-02-05", TotalAmount: 1084.75, Capital: 896.27, Interest: 188.48, RemainingCapital: 60233.84},
{Rank: 187, DueDate: "2039-03-05", TotalAmount: 1084.75, Capital: 899.03, Interest: 185.72, RemainingCapital: 59334.81},
{Rank: 188, DueDate: "2039-04-05", TotalAmount: 1084.75, Capital: 901.8, Interest: 182.95, RemainingCapital: 58433.01},
{Rank: 189, DueDate: "2039-05-05", TotalAmount: 1084.75, Capital: 904.58, Interest: 180.17, RemainingCapital: 57528.43},
{Rank: 190, DueDate: "2039-06-05", TotalAmount: 1084.75, Capital: 907.37, Interest: 177.38, RemainingCapital: 56621.06},
{Rank: 191, DueDate: "2039-07-05", TotalAmount: 1084.75, Capital: 910.17, Interest: 174.58, RemainingCapital: 55710.89},
{Rank: 192, DueDate: "2039-08-05", TotalAmount: 1084.75, Capital: 912.97, Interest: 171.78, RemainingCapital: 54797.92},
{Rank: 193, DueDate: "2039-09-05", TotalAmount: 1084.75, Capital: 915.79, Interest: 168.96, RemainingCapital: 53882.13},
{Rank: 194, DueDate: "2039-10-05", TotalAmount: 1084.75, Capital: 918.61, Interest: 166.14, RemainingCapital: 52963.52},
{Rank: 195, DueDate: "2039-11-05", TotalAmount: 1084.75, Capital: 921.45, Interest: 163.3, RemainingCapital: 52042.07},
{Rank: 196, DueDate: "2039-12-05", TotalAmount: 1084.75, Capital: 924.29, Interest: 160.46, RemainingCapital: 51117.78},
{Rank: 197, DueDate: "2040-01-05", TotalAmount: 1084.75, Capital: 927.14, Interest: 157.61, RemainingCapital: 50190.64},
{Rank: 198, DueDate: "2040-02-05", TotalAmount: 1084.75, Capital: 930.0, Interest: 154.75, RemainingCapital: 49260.64},
{Rank: 199, DueDate: "2040-03-05", TotalAmount: 1084.75, Capital: 932.86, Interest: 151.89, RemainingCapital: 48327.78},
{Rank: 200, DueDate: "2040-04-05", TotalAmount: 1084.75, Capital: 935.74, Interest: 149.01, RemainingCapital: 47392.04},
{Rank: 201, DueDate: "2040-05-05", TotalAmount: 1084.75, Capital: 938.62, Interest: 146.13, RemainingCapital: 46453.42},
{Rank: 202, DueDate: "2040-06-05", TotalAmount: 1084.75, Capital: 941.52, Interest: 143.23, RemainingCapital: 45511.9},
{Rank: 203, DueDate: "2040-07-05", TotalAmount: 1084.75, Capital: 944.42, Interest: 140.33, RemainingCapital: 44567.48},
{Rank: 204, DueDate: "2040-08-05", TotalAmount: 1084.75, Capital: 947.33, Interest: 137.42, RemainingCapital: 43620.15},
{Rank: 205, DueDate: "2040-09-05", TotalAmount: 1084.75, Capital: 950.25, Interest: 134.5, RemainingCapital: 42669.9},
{Rank: 206, DueDate: "2040-10-05", TotalAmount: 1084.75, Capital: 953.18, Interest: 131.57, RemainingCapital: 41716.72},
{Rank: 207, DueDate: "2040-11-05", TotalAmount: 1084.75, Capital: 956.12, Interest: 128.63, RemainingCapital: 40760.6},
{Rank: 208, DueDate: "2040-12-05", TotalAmount: 1084.75, Capital: 959.07, Interest: 125.68, RemainingCapital: 39801.53},
{Rank: 209, DueDate: "2041-01-05", TotalAmount: 1084.75, Capital: 962.03, Interest: 122.72, RemainingCapital: 38839.5},
{Rank: 210, DueDate: "2041-02-05", TotalAmount: 1084.75, Capital: 964.99, Interest: 119.76, RemainingCapital: 37874.51},
{Rank: 211, DueDate: "2041-03-05", TotalAmount: 1084.75, Capital: 967.97, Interest: 116.78, RemainingCapital: 36906.54},
{Rank: 212, DueDate: "2041-04-05", TotalAmount: 1084.75, Capital: 970.95, Interest: 113.8, RemainingCapital: 35935.59},
{Rank: 213, DueDate: "2041-05-05", TotalAmount: 1084.75, Capital: 973.95, Interest: 110.8, RemainingCapital: 34961.64},
{Rank: 214, DueDate: "2041-06-05", TotalAmount: 1084.75, Capital: 976.95, Interest: 107.8, RemainingCapital: 33984.69},
{Rank: 215, DueDate: "2041-07-05", TotalAmount: 1084.75, Capital: 979.96, Interest: 104.79, RemainingCapital: 33004.73},
{Rank: 216, DueDate: "2041-08-05", TotalAmount: 1084.75, Capital: 982.99, Interest: 101.76, RemainingCapital: 32021.74},
{Rank: 217, DueDate: "2041-09-05", TotalAmount: 1084.75, Capital: 986.02, Interest: 98.73, RemainingCapital: 31035.72},
{Rank: 218, DueDate: "2041-10-05", TotalAmount: 1084.75, Capital: 989.06, Interest: 95.69, RemainingCapital: 30046.66},
{Rank: 219, DueDate: "2041-11-05", TotalAmount: 1084.75, Capital: 992.11, Interest: 92.64, RemainingCapital: 29054.55},
{Rank: 220, DueDate: "2041-12-05", TotalAmount: 1084.75, Capital: 995.17, Interest: 89.58, RemainingCapital: 28059.38},
{Rank: 221, DueDate: "2042-01-05", TotalAmount: 1084.75, Capital: 998.23, Interest: 86.52, RemainingCapital: 27061.15},
{Rank: 222, DueDate: "2042-02-05", TotalAmount: 1084.75, Capital: 1001.31, Interest: 83.44, RemainingCapital: 26059.84},
{Rank: 223, DueDate: "2042-03-05", TotalAmount: 1084.75, Capital: 1004.4, Interest: 80.35, RemainingCapital: 25055.44},
{Rank: 224, DueDate: "2042-04-05", TotalAmount: 1084.75, Capital: 1007.5, Interest: 77.25, RemainingCapital: 24047.94},
{Rank: 225, DueDate: "2042-05-05", TotalAmount: 1084.75, Capital: 1010.6, Interest: 74.15, RemainingCapital: 23037.34},
{Rank: 226, DueDate: "2042-06-05", TotalAmount: 1084.75, Capital: 1013.72, Interest: 71.03, RemainingCapital: 22023.62},
{Rank: 227, DueDate: "2042-07-05", TotalAmount: 1084.75, Capital: 1016.84, Interest: 67.91, RemainingCapital: 21006.78},
{Rank: 228, DueDate: "2042-08-05", TotalAmount: 1084.75, Capital: 1019.98, Interest: 64.77, RemainingCapital: 19986.8},
{Rank: 229, DueDate: "2042-09-05", TotalAmount: 1084.75, Capital: 1023.12, Interest: 61.63, RemainingCapital: 18963.68},
{Rank: 230, DueDate: "2042-10-05", TotalAmount: 1084.75, Capital: 1026.28, Interest: 58.47, RemainingCapital: 17937.4},
{Rank: 231, DueDate: "2042-11-05", TotalAmount: 1084.75, Capital: 1029.44, Interest: 55.31, RemainingCapital: 16907.96},
{Rank: 232, DueDate: "2042-12-05", TotalAmount: 1084.75, Capital: 1032.62, Interest: 52.13, RemainingCapital: 15875.34},
{Rank: 233, DueDate: "2043-01-05", TotalAmount: 1084.75, Capital: 1035.8, Interest: 48.95, RemainingCapital: 14839.54},
{Rank: 234, DueDate: "2043-02-05", TotalAmount: 1084.75, Capital: 1038.99, Interest: 45.76, RemainingCapital: 13800.55},
{Rank: 235, DueDate: "2043-03-05", TotalAmount: 1084.75, Capital: 1042.2, Interest: 42.55, RemainingCapital: 12758.35},
{Rank: 236, DueDate: "2043-04-05", TotalAmount: 1084.75, Capital: 1045.41, Interest: 39.34, RemainingCapital: 11712.94},
{Rank: 237, DueDate: "2043-05-05", TotalAmount: 1084.75, Capital: 1048.64, Interest: 36.11, RemainingCapital: 10664.3},
{Rank: 238, DueDate: "2043-06-05", TotalAmount: 1084.75, Capital: 1051.87, Interest: 32.88, RemainingCapital: 9612.43},
{Rank: 239, DueDate: "2043-07-05", TotalAmount: 1084.75, Capital: 1055.11, Interest: 29.64, RemainingCapital: 8557.32},
{Rank: 240, DueDate: "2043-08-05", TotalAmount: 1084.75, Capital: 1058.36, Interest: 26.39, RemainingCapital: 7498.96},
{Rank: 241, DueDate: "2043-09-05", TotalAmount: 1084.75, Capital: 1061.63, Interest: 23.12, RemainingCapital: 6437.33},
{Rank: 242, DueDate: "2043-10-05", TotalAmount: 1084.75, Capital: 1064.9, Interest: 19.85, RemainingCapital: 5372.43},
{Rank: 243, DueDate: "2043-11-05", TotalAmount: 1084.75, Capital: 1068.19, Interest: 16.56, RemainingCapital: 4304.24},
{Rank: 244, DueDate: "2043-12-05", TotalAmount: 1084.75, Capital: 1071.48, Interest: 13.27, RemainingCapital: 3232.76},
{Rank: 245, DueDate: "2044-01-05", TotalAmount: 1084.75, Capital: 1074.78, Interest: 9.97, RemainingCapital: 2157.98},
{Rank: 246, DueDate: "2044-02-05", TotalAmount: 1084.75, Capital: 1078.1, Interest: 6.65, RemainingCapital: 1079.88},
{Rank: 247, DueDate: "2044-03-05", TotalAmount: 1084.75, Capital: 1079.88, Interest: 4.87, RemainingCapital: 0.0},
}
}
// Prêt 781728E : 216 lignes
func GetLoan781728Lines() []LoanLine {
return []LoanLine{
{Rank: 8, DueDate: "2024-04-05", TotalAmount: 725.74, Capital: 346.66, Interest: 379.08, RemainingCapital: 122599.34},
{Rank: 9, DueDate: "2024-05-05", TotalAmount: 725.74, Capital: 347.73, Interest: 378.01, RemainingCapital: 122251.61},
{Rank: 10, DueDate: "2024-06-05", TotalAmount: 725.74, Capital: 348.8, Interest: 376.94, RemainingCapital: 121902.81},
{Rank: 11, DueDate: "2024-07-05", TotalAmount: 725.74, Capital: 349.87, Interest: 375.87, RemainingCapital: 121552.94},
{Rank: 12, DueDate: "2024-08-05", TotalAmount: 725.74, Capital: 350.95, Interest: 374.79, RemainingCapital: 121201.99},
{Rank: 13, DueDate: "2024-09-05", TotalAmount: 725.74, Capital: 352.03, Interest: 373.71, RemainingCapital: 120849.96},
{Rank: 14, DueDate: "2024-10-05", TotalAmount: 725.74, Capital: 353.12, Interest: 372.62, RemainingCapital: 120496.84},
{Rank: 15, DueDate: "2024-11-05", TotalAmount: 725.74, Capital: 354.21, Interest: 371.53, RemainingCapital: 120142.63},
{Rank: 16, DueDate: "2024-12-05", TotalAmount: 725.74, Capital: 355.3, Interest: 370.44, RemainingCapital: 119787.33},
{Rank: 17, DueDate: "2025-01-05", TotalAmount: 725.74, Capital: 356.4, Interest: 369.34, RemainingCapital: 119430.93},
{Rank: 18, DueDate: "2025-02-05", TotalAmount: 725.74, Capital: 357.49, Interest: 368.25, RemainingCapital: 119073.44},
{Rank: 19, DueDate: "2025-03-05", TotalAmount: 725.74, Capital: 358.6, Interest: 367.14, RemainingCapital: 118714.84},
{Rank: 20, DueDate: "2025-04-05", TotalAmount: 725.74, Capital: 359.7, Interest: 366.04, RemainingCapital: 118355.14},
{Rank: 21, DueDate: "2025-05-05", TotalAmount: 725.74, Capital: 360.81, Interest: 364.93, RemainingCapital: 117994.33},
{Rank: 22, DueDate: "2025-06-05", TotalAmount: 725.74, Capital: 361.92, Interest: 363.82, RemainingCapital: 117632.41},
{Rank: 23, DueDate: "2025-07-05", TotalAmount: 725.74, Capital: 363.04, Interest: 362.7, RemainingCapital: 117269.37},
{Rank: 24, DueDate: "2025-08-05", TotalAmount: 725.74, Capital: 364.16, Interest: 361.58, RemainingCapital: 116905.21},
{Rank: 25, DueDate: "2025-09-05", TotalAmount: 725.74, Capital: 365.28, Interest: 360.46, RemainingCapital: 116539.93},
{Rank: 26, DueDate: "2025-10-05", TotalAmount: 725.74, Capital: 366.41, Interest: 359.33, RemainingCapital: 116173.52},
{Rank: 27, DueDate: "2025-11-05", TotalAmount: 725.74, Capital: 367.54, Interest: 358.2, RemainingCapital: 115805.98},
{Rank: 28, DueDate: "2025-12-05", TotalAmount: 725.74, Capital: 368.67, Interest: 357.07, RemainingCapital: 115437.31},
{Rank: 29, DueDate: "2026-01-05", TotalAmount: 725.74, Capital: 369.81, Interest: 355.93, RemainingCapital: 115067.50},
{Rank: 30, DueDate: "2026-02-05", TotalAmount: 725.74, Capital: 370.95, Interest: 354.79, RemainingCapital: 114696.55},
{Rank: 31, DueDate: "2026-03-05", TotalAmount: 725.74, Capital: 372.09, Interest: 353.65, RemainingCapital: 114324.46},
{Rank: 32, DueDate: "2026-04-05", TotalAmount: 725.74, Capital: 373.24, Interest: 352.50, RemainingCapital: 113951.22},
{Rank: 33, DueDate: "2026-05-05", TotalAmount: 725.74, Capital: 374.39, Interest: 351.35, RemainingCapital: 113576.83},
{Rank: 34, DueDate: "2026-06-05", TotalAmount: 725.74, Capital: 375.54, Interest: 350.20, RemainingCapital: 113201.29},
{Rank: 35, DueDate: "2026-07-05", TotalAmount: 725.74, Capital: 376.70, Interest: 349.04, RemainingCapital: 112824.59},
{Rank: 36, DueDate: "2026-08-05", TotalAmount: 725.74, Capital: 377.86, Interest: 347.88, RemainingCapital: 112446.73},
{Rank: 37, DueDate: "2026-09-05", TotalAmount: 725.74, Capital: 379.03, Interest: 346.71, RemainingCapital: 112067.70},
{Rank: 38, DueDate: "2026-10-05", TotalAmount: 725.74, Capital: 380.20, Interest: 345.54, RemainingCapital: 111687.50},
{Rank: 39, DueDate: "2026-11-05", TotalAmount: 725.74, Capital: 381.37, Interest: 344.37, RemainingCapital: 111306.13},
{Rank: 40, DueDate: "2026-12-05", TotalAmount: 725.74, Capital: 382.55, Interest: 343.19, RemainingCapital: 110923.58},
{Rank: 41, DueDate: "2027-01-05", TotalAmount: 725.74, Capital: 383.73, Interest: 342.01, RemainingCapital: 110539.85},
{Rank: 42, DueDate: "2027-02-05", TotalAmount: 725.74, Capital: 384.91, Interest: 340.83, RemainingCapital: 110154.94},
{Rank: 43, DueDate: "2027-03-05", TotalAmount: 725.74, Capital: 386.1, Interest: 339.64, RemainingCapital: 109768.84},
{Rank: 44, DueDate: "2027-04-05", TotalAmount: 725.74, Capital: 387.29, Interest: 338.45, RemainingCapital: 109381.55},
{Rank: 45, DueDate: "2027-05-05", TotalAmount: 725.74, Capital: 388.48, Interest: 337.26, RemainingCapital: 108993.07},
{Rank: 46, DueDate: "2027-06-05", TotalAmount: 725.74, Capital: 389.68, Interest: 336.06, RemainingCapital: 108603.39},
{Rank: 47, DueDate: "2027-07-05", TotalAmount: 725.74, Capital: 390.88, Interest: 334.86, RemainingCapital: 108212.51},
{Rank: 48, DueDate: "2027-08-05", TotalAmount: 725.74, Capital: 392.08, Interest: 333.66, RemainingCapital: 107820.43},
{Rank: 49, DueDate: "2027-09-05", TotalAmount: 725.74, Capital: 393.29, Interest: 332.45, RemainingCapital: 107427.14},
{Rank: 50, DueDate: "2027-10-05", TotalAmount: 725.74, Capital: 394.51, Interest: 331.23, RemainingCapital: 107032.63},
{Rank: 51, DueDate: "2027-11-05", TotalAmount: 725.74, Capital: 395.72, Interest: 330.02, RemainingCapital: 106636.91},
{Rank: 52, DueDate: "2027-12-05", TotalAmount: 725.74, Capital: 396.94, Interest: 328.8, RemainingCapital: 106239.97},
{Rank: 53, DueDate: "2028-01-05", TotalAmount: 725.74, Capital: 398.17, Interest: 327.57, RemainingCapital: 105841.8},
{Rank: 54, DueDate: "2028-02-05", TotalAmount: 725.74, Capital: 399.39, Interest: 326.35, RemainingCapital: 105442.41},
{Rank: 55, DueDate: "2028-03-05", TotalAmount: 725.74, Capital: 400.63, Interest: 325.11, RemainingCapital: 105041.78},
{Rank: 56, DueDate: "2028-04-05", TotalAmount: 725.74, Capital: 401.86, Interest: 323.88, RemainingCapital: 104639.92},
{Rank: 57, DueDate: "2028-05-05", TotalAmount: 725.74, Capital: 403.1, Interest: 322.64, RemainingCapital: 104236.82},
{Rank: 58, DueDate: "2028-06-05", TotalAmount: 725.74, Capital: 404.34, Interest: 321.4, RemainingCapital: 103832.48},
{Rank: 59, DueDate: "2028-07-05", TotalAmount: 725.74, Capital: 405.59, Interest: 320.15, RemainingCapital: 103426.89},
{Rank: 60, DueDate: "2028-08-05", TotalAmount: 725.74, Capital: 406.84, Interest: 318.9, RemainingCapital: 103020.05},
{Rank: 61, DueDate: "2028-09-05", TotalAmount: 725.74, Capital: 408.09, Interest: 317.65, RemainingCapital: 102611.96},
{Rank: 62, DueDate: "2028-10-05", TotalAmount: 725.74, Capital: 409.35, Interest: 316.39, RemainingCapital: 102202.61},
{Rank: 63, DueDate: "2028-11-05", TotalAmount: 725.74, Capital: 410.62, Interest: 315.12, RemainingCapital: 101791.99},
{Rank: 64, DueDate: "2028-12-05", TotalAmount: 725.74, Capital: 411.88, Interest: 313.86, RemainingCapital: 101380.11},
{Rank: 65, DueDate: "2029-01-05", TotalAmount: 725.74, Capital: 413.15, Interest: 312.59, RemainingCapital: 100966.96},
{Rank: 66, DueDate: "2029-02-05", TotalAmount: 725.74, Capital: 414.43, Interest: 311.31, RemainingCapital: 100552.53},
{Rank: 67, DueDate: "2029-03-05", TotalAmount: 725.74, Capital: 415.7, Interest: 310.04, RemainingCapital: 100136.83},
{Rank: 68, DueDate: "2029-04-05", TotalAmount: 725.74, Capital: 416.98, Interest: 308.76, RemainingCapital: 99719.85},
{Rank: 69, DueDate: "2029-05-05", TotalAmount: 725.74, Capital: 418.27, Interest: 307.47, RemainingCapital: 99301.58},
{Rank: 70, DueDate: "2029-06-05", TotalAmount: 725.74, Capital: 419.56, Interest: 306.18, RemainingCapital: 98882.02},
{Rank: 71, DueDate: "2029-07-05", TotalAmount: 725.74, Capital: 420.85, Interest: 304.89, RemainingCapital: 98461.17},
{Rank: 72, DueDate: "2029-08-05", TotalAmount: 725.74, Capital: 422.15, Interest: 303.59, RemainingCapital: 98039.02},
{Rank: 73, DueDate: "2029-09-05", TotalAmount: 725.74, Capital: 423.45, Interest: 302.29, RemainingCapital: 97615.57},
{Rank: 74, DueDate: "2029-10-05", TotalAmount: 725.74, Capital: 424.76, Interest: 300.98, RemainingCapital: 97190.81},
{Rank: 75, DueDate: "2029-11-05", TotalAmount: 725.74, Capital: 426.07, Interest: 299.67, RemainingCapital: 96764.74},
{Rank: 76, DueDate: "2029-12-05", TotalAmount: 725.74, Capital: 427.38, Interest: 298.36, RemainingCapital: 96337.36},
{Rank: 77, DueDate: "2030-01-05", TotalAmount: 725.74, Capital: 428.7, Interest: 297.04, RemainingCapital: 95908.66},
{Rank: 78, DueDate: "2030-02-05", TotalAmount: 725.74, Capital: 430.02, Interest: 295.72, RemainingCapital: 95478.64},
{Rank: 79, DueDate: "2030-03-05", TotalAmount: 725.74, Capital: 431.35, Interest: 294.39, RemainingCapital: 95047.29},
{Rank: 80, DueDate: "2030-04-05", TotalAmount: 725.74, Capital: 432.68, Interest: 293.06, RemainingCapital: 94614.61},
{Rank: 89, DueDate: "2031-01-05", TotalAmount: 725.74, Capital: 444.83, Interest: 280.91, RemainingCapital: 90659.99},
{Rank: 90, DueDate: "2031-02-05", TotalAmount: 725.74, Capital: 446.21, Interest: 279.53, RemainingCapital: 90213.78},
{Rank: 91, DueDate: "2031-03-05", TotalAmount: 725.74, Capital: 447.58, Interest: 278.16, RemainingCapital: 89766.2},
{Rank: 92, DueDate: "2031-04-05", TotalAmount: 725.74, Capital: 448.96, Interest: 276.78, RemainingCapital: 89317.24},
{Rank: 93, DueDate: "2031-05-05", TotalAmount: 725.74, Capital: 450.35, Interest: 275.39, RemainingCapital: 88866.89},
{Rank: 94, DueDate: "2031-06-05", TotalAmount: 725.74, Capital: 451.73, Interest: 274.01, RemainingCapital: 88415.16},
{Rank: 95, DueDate: "2031-07-05", TotalAmount: 725.74, Capital: 453.13, Interest: 272.61, RemainingCapital: 87962.03},
{Rank: 96, DueDate: "2031-08-05", TotalAmount: 725.74, Capital: 454.52, Interest: 271.22, RemainingCapital: 87507.51},
{Rank: 97, DueDate: "2031-09-05", TotalAmount: 725.74, Capital: 455.93, Interest: 269.81, RemainingCapital: 87051.58},
{Rank: 98, DueDate: "2031-10-05", TotalAmount: 725.74, Capital: 457.33, Interest: 268.41, RemainingCapital: 86594.25},
{Rank: 99, DueDate: "2031-11-05", TotalAmount: 725.74, Capital: 458.74, Interest: 267.0, RemainingCapital: 86135.51},
{Rank: 100, DueDate: "2031-12-05", TotalAmount: 725.74, Capital: 460.16, Interest: 265.58, RemainingCapital: 85675.35},
{Rank: 101, DueDate: "2032-01-05", TotalAmount: 725.74, Capital: 461.57, Interest: 264.17, RemainingCapital: 85213.78},
{Rank: 102, DueDate: "2032-02-05", TotalAmount: 725.74, Capital: 463.0, Interest: 262.74, RemainingCapital: 84750.78},
{Rank: 103, DueDate: "2032-03-05", TotalAmount: 725.74, Capital: 464.43, Interest: 261.31, RemainingCapital: 84286.35},
{Rank: 104, DueDate: "2032-04-05", TotalAmount: 725.74, Capital: 465.86, Interest: 259.88, RemainingCapital: 83820.49},
{Rank: 105, DueDate: "2032-05-05", TotalAmount: 725.74, Capital: 467.29, Interest: 258.45, RemainingCapital: 83353.2},
{Rank: 106, DueDate: "2032-06-05", TotalAmount: 725.74, Capital: 468.73, Interest: 257.01, RemainingCapital: 82884.47},
{Rank: 107, DueDate: "2032-07-05", TotalAmount: 725.74, Capital: 470.18, Interest: 255.56, RemainingCapital: 82414.29},
{Rank: 108, DueDate: "2032-08-05", TotalAmount: 725.74, Capital: 471.63, Interest: 254.11, RemainingCapital: 81942.66},
{Rank: 109, DueDate: "2032-09-05", TotalAmount: 725.74, Capital: 473.08, Interest: 252.66, RemainingCapital: 81469.58},
{Rank: 110, DueDate: "2032-10-05", TotalAmount: 725.74, Capital: 474.54, Interest: 251.2, RemainingCapital: 80995.04},
{Rank: 111, DueDate: "2032-11-05", TotalAmount: 725.74, Capital: 476.01, Interest: 249.73, RemainingCapital: 80519.03},
{Rank: 112, DueDate: "2032-12-05", TotalAmount: 725.74, Capital: 477.47, Interest: 248.27, RemainingCapital: 80041.56},
{Rank: 113, DueDate: "2033-01-05", TotalAmount: 725.74, Capital: 478.95, Interest: 246.79, RemainingCapital: 79562.61},
{Rank: 114, DueDate: "2033-02-05", TotalAmount: 725.74, Capital: 480.42, Interest: 245.32, RemainingCapital: 79082.19},
{Rank: 115, DueDate: "2033-03-05", TotalAmount: 725.74, Capital: 481.9, Interest: 243.84, RemainingCapital: 78600.29},
{Rank: 116, DueDate: "2033-04-05", TotalAmount: 725.74, Capital: 483.39, Interest: 242.35, RemainingCapital: 78116.9},
{Rank: 117, DueDate: "2033-05-05", TotalAmount: 725.74, Capital: 484.88, Interest: 240.86, RemainingCapital: 77632.02},
{Rank: 118, DueDate: "2033-06-05", TotalAmount: 725.74, Capital: 486.37, Interest: 239.37, RemainingCapital: 77145.65},
{Rank: 119, DueDate: "2033-07-05", TotalAmount: 725.74, Capital: 487.87, Interest: 237.87, RemainingCapital: 76657.78},
{Rank: 120, DueDate: "2033-08-05", TotalAmount: 725.74, Capital: 489.38, Interest: 236.36, RemainingCapital: 76168.4},
{Rank: 121, DueDate: "2033-09-05", TotalAmount: 725.74, Capital: 490.89, Interest: 234.85, RemainingCapital: 75677.51},
{Rank: 122, DueDate: "2033-10-05", TotalAmount: 725.74, Capital: 492.4, Interest: 233.34, RemainingCapital: 75185.11},
{Rank: 123, DueDate: "2033-11-05", TotalAmount: 725.74, Capital: 493.92, Interest: 231.82, RemainingCapital: 74691.19},
{Rank: 124, DueDate: "2033-12-05", TotalAmount: 725.74, Capital: 495.44, Interest: 230.3, RemainingCapital: 74195.75},
{Rank: 125, DueDate: "2034-01-05", TotalAmount: 725.74, Capital: 496.97, Interest: 228.77, RemainingCapital: 73698.78},
{Rank: 126, DueDate: "2034-02-05", TotalAmount: 725.74, Capital: 498.5, Interest: 227.24, RemainingCapital: 73200.28},
{Rank: 127, DueDate: "2034-03-05", TotalAmount: 725.74, Capital: 500.04, Interest: 225.7, RemainingCapital: 72700.24},
{Rank: 128, DueDate: "2034-04-05", TotalAmount: 725.74, Capital: 501.58, Interest: 224.16, RemainingCapital: 72198.66},
{Rank: 129, DueDate: "2034-05-05", TotalAmount: 725.74, Capital: 503.13, Interest: 222.61, RemainingCapital: 71695.53},
{Rank: 130, DueDate: "2034-06-05", TotalAmount: 725.74, Capital: 504.68, Interest: 221.06, RemainingCapital: 71190.85},
{Rank: 131, DueDate: "2034-07-05", TotalAmount: 725.74, Capital: 506.23, Interest: 219.51, RemainingCapital: 70684.62},
{Rank: 132, DueDate: "2034-08-05", TotalAmount: 725.74, Capital: 507.8, Interest: 217.94, RemainingCapital: 70176.82},
{Rank: 137, DueDate: "2035-01-05", TotalAmount: 725.74, Capital: 515.67, Interest: 210.07, RemainingCapital: 67614.26},
{Rank: 138, DueDate: "2035-02-05", TotalAmount: 725.74, Capital: 517.26, Interest: 208.48, RemainingCapital: 67097.0},
{Rank: 139, DueDate: "2035-03-05", TotalAmount: 725.74, Capital: 518.86, Interest: 206.88, RemainingCapital: 66578.14},
{Rank: 140, DueDate: "2035-04-05", TotalAmount: 725.74, Capital: 520.46, Interest: 205.28, RemainingCapital: 66057.68},
{Rank: 141, DueDate: "2035-05-05", TotalAmount: 725.74, Capital: 522.06, Interest: 203.68, RemainingCapital: 65535.62},
{Rank: 142, DueDate: "2035-06-05", TotalAmount: 725.74, Capital: 523.67, Interest: 202.07, RemainingCapital: 65011.95},
{Rank: 143, DueDate: "2035-07-05", TotalAmount: 725.74, Capital: 525.29, Interest: 200.45, RemainingCapital: 64486.66},
{Rank: 144, DueDate: "2035-08-05", TotalAmount: 725.74, Capital: 526.91, Interest: 198.83, RemainingCapital: 63959.75},
{Rank: 145, DueDate: "2035-09-05", TotalAmount: 725.74, Capital: 528.53, Interest: 197.21, RemainingCapital: 63431.22},
{Rank: 146, DueDate: "2035-10-05", TotalAmount: 725.74, Capital: 530.16, Interest: 195.58, RemainingCapital: 62901.06},
{Rank: 147, DueDate: "2035-11-05", TotalAmount: 725.74, Capital: 531.8, Interest: 193.94, RemainingCapital: 62369.26},
{Rank: 148, DueDate: "2035-12-05", TotalAmount: 725.74, Capital: 533.43, Interest: 192.31, RemainingCapital: 61835.83},
{Rank: 149, DueDate: "2036-01-05", TotalAmount: 725.74, Capital: 535.08, Interest: 190.66, RemainingCapital: 61300.75},
{Rank: 150, DueDate: "2036-02-05", TotalAmount: 725.74, Capital: 536.73, Interest: 189.01, RemainingCapital: 60764.02},
{Rank: 151, DueDate: "2036-03-05", TotalAmount: 725.74, Capital: 538.38, Interest: 187.36, RemainingCapital: 60225.64},
{Rank: 152, DueDate: "2036-04-05", TotalAmount: 725.74, Capital: 540.04, Interest: 185.7, RemainingCapital: 59685.6},
{Rank: 153, DueDate: "2036-05-05", TotalAmount: 725.74, Capital: 541.71, Interest: 184.03, RemainingCapital: 59143.89},
{Rank: 154, DueDate: "2036-06-05", TotalAmount: 725.74, Capital: 543.38, Interest: 182.36, RemainingCapital: 58600.51},
{Rank: 155, DueDate: "2036-07-05", TotalAmount: 725.74, Capital: 545.06, Interest: 180.68, RemainingCapital: 58055.45},
{Rank: 156, DueDate: "2036-08-05", TotalAmount: 725.74, Capital: 546.74, Interest: 179.0, RemainingCapital: 57508.71},
{Rank: 157, DueDate: "2036-09-05", TotalAmount: 725.74, Capital: 548.42, Interest: 177.32, RemainingCapital: 56960.29},
{Rank: 158, DueDate: "2036-10-05", TotalAmount: 725.74, Capital: 550.11, Interest: 175.63, RemainingCapital: 56410.18},
{Rank: 159, DueDate: "2036-11-05", TotalAmount: 725.74, Capital: 551.81, Interest: 173.93, RemainingCapital: 55858.37},
{Rank: 160, DueDate: "2036-12-05", TotalAmount: 725.74, Capital: 553.51, Interest: 172.23, RemainingCapital: 55304.86},
{Rank: 161, DueDate: "2037-01-05", TotalAmount: 725.74, Capital: 555.22, Interest: 170.52, RemainingCapital: 54749.64},
{Rank: 162, DueDate: "2037-02-05", TotalAmount: 725.74, Capital: 556.93, Interest: 168.81, RemainingCapital: 54192.71},
{Rank: 163, DueDate: "2037-03-05", TotalAmount: 725.74, Capital: 558.65, Interest: 167.09, RemainingCapital: 53634.06},
{Rank: 164, DueDate: "2037-04-05", TotalAmount: 725.74, Capital: 560.37, Interest: 165.37, RemainingCapital: 53073.69},
{Rank: 165, DueDate: "2037-05-05", TotalAmount: 725.74, Capital: 562.1, Interest: 163.64, RemainingCapital: 52511.59},
{Rank: 166, DueDate: "2037-06-05", TotalAmount: 725.74, Capital: 563.83, Interest: 161.91, RemainingCapital: 51947.76},
{Rank: 167, DueDate: "2037-07-05", TotalAmount: 725.74, Capital: 565.57, Interest: 160.17, RemainingCapital: 51382.19},
{Rank: 168, DueDate: "2037-08-05", TotalAmount: 725.74, Capital: 567.31, Interest: 158.43, RemainingCapital: 50814.88},
{Rank: 169, DueDate: "2037-09-05", TotalAmount: 725.74, Capital: 569.06, Interest: 156.68, RemainingCapital: 50245.82},
{Rank: 170, DueDate: "2037-10-05", TotalAmount: 725.74, Capital: 570.82, Interest: 154.92, RemainingCapital: 49675.0},
{Rank: 171, DueDate: "2037-11-05", TotalAmount: 725.74, Capital: 572.58, Interest: 153.16, RemainingCapital: 49102.42},
{Rank: 172, DueDate: "2037-12-05", TotalAmount: 725.74, Capital: 574.34, Interest: 151.4, RemainingCapital: 48528.08},
{Rank: 173, DueDate: "2038-01-05", TotalAmount: 725.74, Capital: 576.11, Interest: 149.63, RemainingCapital: 47951.97},
{Rank: 174, DueDate: "2038-02-05", TotalAmount: 725.74, Capital: 577.89, Interest: 147.85, RemainingCapital: 47374.08},
{Rank: 175, DueDate: "2038-03-05", TotalAmount: 725.74, Capital: 579.67, Interest: 146.07, RemainingCapital: 46794.41},
{Rank: 176, DueDate: "2038-04-05", TotalAmount: 725.74, Capital: 581.46, Interest: 144.28, RemainingCapital: 46212.95},
{Rank: 177, DueDate: "2038-05-05", TotalAmount: 725.74, Capital: 583.25, Interest: 142.49, RemainingCapital: 45629.7},
{Rank: 178, DueDate: "2038-06-05", TotalAmount: 725.74, Capital: 585.05, Interest: 140.69, RemainingCapital: 45044.65},
{Rank: 179, DueDate: "2038-07-05", TotalAmount: 725.74, Capital: 586.85, Interest: 138.89, RemainingCapital: 44457.8},
{Rank: 180, DueDate: "2038-08-05", TotalAmount: 725.74, Capital: 588.66, Interest: 137.08, RemainingCapital: 43869.14},
{Rank: 181, DueDate: "2038-09-05", TotalAmount: 725.74, Capital: 590.48, Interest: 135.26, RemainingCapital: 43278.66},
{Rank: 182, DueDate: "2038-10-05", TotalAmount: 725.74, Capital: 592.3, Interest: 133.44, RemainingCapital: 42686.36},
{Rank: 183, DueDate: "2038-11-05", TotalAmount: 725.74, Capital: 594.12, Interest: 131.62, RemainingCapital: 42092.24},
{Rank: 184, DueDate: "2038-12-05", TotalAmount: 725.74, Capital: 595.96, Interest: 129.78, RemainingCapital: 41496.28},
{Rank: 185, DueDate: "2039-01-05", TotalAmount: 725.74, Capital: 597.79, Interest: 127.95, RemainingCapital: 40898.49},
{Rank: 186, DueDate: "2039-02-05", TotalAmount: 725.74, Capital: 599.64, Interest: 126.1, RemainingCapital: 40298.85},
{Rank: 187, DueDate: "2039-03-05", TotalAmount: 725.74, Capital: 601.49, Interest: 124.25, RemainingCapital: 39697.36},
{Rank: 188, DueDate: "2039-04-05", TotalAmount: 725.74, Capital: 603.34, Interest: 122.4, RemainingCapital: 39094.02},
{Rank: 189, DueDate: "2039-05-05", TotalAmount: 725.74, Capital: 605.2, Interest: 120.54, RemainingCapital: 38488.82},
{Rank: 190, DueDate: "2039-06-05", TotalAmount: 725.74, Capital: 607.07, Interest: 118.67, RemainingCapital: 37881.75},
{Rank: 191, DueDate: "2039-07-05", TotalAmount: 725.74, Capital: 608.94, Interest: 116.8, RemainingCapital: 37272.81},
{Rank: 192, DueDate: "2039-08-05", TotalAmount: 725.74, Capital: 610.82, Interest: 114.92, RemainingCapital: 36661.99},
{Rank: 193, DueDate: "2039-09-05", TotalAmount: 725.74, Capital: 612.7, Interest: 113.04, RemainingCapital: 36049.29},
{Rank: 194, DueDate: "2039-10-05", TotalAmount: 725.74, Capital: 614.59, Interest: 111.15, RemainingCapital: 35434.7},
{Rank: 195, DueDate: "2039-11-05", TotalAmount: 725.74, Capital: 616.48, Interest: 109.26, RemainingCapital: 34818.22},
{Rank: 196, DueDate: "2039-12-05", TotalAmount: 725.74, Capital: 618.38, Interest: 107.36, RemainingCapital: 34199.84},
{Rank: 197, DueDate: "2040-01-05", TotalAmount: 725.74, Capital: 620.29, Interest: 105.45, RemainingCapital: 33579.55},
{Rank: 198, DueDate: "2040-02-05", TotalAmount: 725.74, Capital: 622.2, Interest: 103.54, RemainingCapital: 32957.35},
{Rank: 199, DueDate: "2040-03-05", TotalAmount: 725.74, Capital: 624.12, Interest: 101.62, RemainingCapital: 32333.23},
{Rank: 200, DueDate: "2040-04-05", TotalAmount: 725.74, Capital: 626.05, Interest: 99.69, RemainingCapital: 31707.18},
{Rank: 201, DueDate: "2040-05-05", TotalAmount: 725.74, Capital: 627.98, Interest: 97.76, RemainingCapital: 31079.2},
{Rank: 202, DueDate: "2040-06-05", TotalAmount: 725.74, Capital: 629.91, Interest: 95.83, RemainingCapital: 30449.29},
{Rank: 203, DueDate: "2040-07-05", TotalAmount: 725.74, Capital: 631.85, Interest: 93.89, RemainingCapital: 29817.44},
{Rank: 204, DueDate: "2040-08-05", TotalAmount: 725.74, Capital: 633.8, Interest: 91.94, RemainingCapital: 29183.64},
{Rank: 205, DueDate: "2040-09-05", TotalAmount: 725.74, Capital: 635.76, Interest: 89.98, RemainingCapital: 28547.88},
{Rank: 206, DueDate: "2040-10-05", TotalAmount: 725.74, Capital: 637.72, Interest: 88.02, RemainingCapital: 27910.16},
{Rank: 207, DueDate: "2040-11-05", TotalAmount: 725.74, Capital: 639.68, Interest: 86.06, RemainingCapital: 27270.48},
{Rank: 208, DueDate: "2040-12-05", TotalAmount: 725.74, Capital: 641.66, Interest: 84.08, RemainingCapital: 26628.82},
{Rank: 209, DueDate: "2041-01-05", TotalAmount: 725.74, Capital: 643.63, Interest: 82.11, RemainingCapital: 25985.19},
{Rank: 210, DueDate: "2041-02-05", TotalAmount: 725.74, Capital: 645.62, Interest: 80.12, RemainingCapital: 25339.57},
{Rank: 211, DueDate: "2041-03-05", TotalAmount: 725.74, Capital: 647.61, Interest: 78.13, RemainingCapital: 24691.96},
{Rank: 212, DueDate: "2041-04-05", TotalAmount: 725.74, Capital: 649.61, Interest: 76.13, RemainingCapital: 24042.35},
{Rank: 213, DueDate: "2041-05-05", TotalAmount: 725.74, Capital: 651.61, Interest: 74.13, RemainingCapital: 23390.74},
{Rank: 214, DueDate: "2041-06-05", TotalAmount: 725.74, Capital: 653.62, Interest: 72.12, RemainingCapital: 22737.12},
{Rank: 215, DueDate: "2041-07-05", TotalAmount: 725.74, Capital: 655.63, Interest: 70.11, RemainingCapital: 22081.49},
{Rank: 216, DueDate: "2041-08-05", TotalAmount: 725.74, Capital: 657.66, Interest: 68.08, RemainingCapital: 21423.83},
{Rank: 217, DueDate: "2041-09-05", TotalAmount: 725.74, Capital: 659.68, Interest: 66.06, RemainingCapital: 20764.15},
{Rank: 218, DueDate: "2041-10-05", TotalAmount: 725.74, Capital: 661.72, Interest: 64.02, RemainingCapital: 20102.43},
{Rank: 219, DueDate: "2041-11-05", TotalAmount: 725.74, Capital: 663.76, Interest: 61.98, RemainingCapital: 19438.67},
{Rank: 220, DueDate: "2041-12-05", TotalAmount: 725.74, Capital: 665.8, Interest: 59.94, RemainingCapital: 18772.87},
{Rank: 221, DueDate: "2042-01-05", TotalAmount: 725.74, Capital: 667.86, Interest: 57.88, RemainingCapital: 18105.01},
{Rank: 222, DueDate: "2042-02-05", TotalAmount: 725.74, Capital: 669.92, Interest: 55.82, RemainingCapital: 17435.09},
{Rank: 223, DueDate: "2042-03-05", TotalAmount: 725.74, Capital: 671.98, Interest: 53.76, RemainingCapital: 16763.11},
{Rank: 224, DueDate: "2042-04-05", TotalAmount: 725.74, Capital: 674.05, Interest: 51.69, RemainingCapital: 16089.06},
{Rank: 225, DueDate: "2042-05-05", TotalAmount: 725.74, Capital: 676.13, Interest: 49.61, RemainingCapital: 15412.93},
{Rank: 226, DueDate: "2042-06-05", TotalAmount: 725.74, Capital: 678.22, Interest: 47.52, RemainingCapital: 14734.71},
{Rank: 227, DueDate: "2042-07-05", TotalAmount: 725.74, Capital: 680.31, Interest: 45.43, RemainingCapital: 14054.4},
{Rank: 228, DueDate: "2042-08-05", TotalAmount: 725.74, Capital: 682.41, Interest: 43.33, RemainingCapital: 13371.99},
{Rank: 229, DueDate: "2042-09-05", TotalAmount: 725.74, Capital: 684.51, Interest: 41.23, RemainingCapital: 12687.48},
{Rank: 230, DueDate: "2042-10-05", TotalAmount: 725.74, Capital: 686.62, Interest: 39.12, RemainingCapital: 12000.86},
{Rank: 231, DueDate: "2042-11-05", TotalAmount: 725.74, Capital: 688.74, Interest: 37.0, RemainingCapital: 11312.12},
{Rank: 232, DueDate: "2042-12-05", TotalAmount: 725.74, Capital: 690.86, Interest: 34.88, RemainingCapital: 10621.26},
{Rank: 233, DueDate: "2043-01-05", TotalAmount: 725.74, Capital: 692.99, Interest: 32.75, RemainingCapital: 9928.27},
{Rank: 234, DueDate: "2043-02-05", TotalAmount: 725.74, Capital: 695.13, Interest: 30.61, RemainingCapital: 9233.14},
{Rank: 235, DueDate: "2043-03-05", TotalAmount: 725.74, Capital: 697.27, Interest: 28.47, RemainingCapital: 8535.87},
{Rank: 236, DueDate: "2043-04-05", TotalAmount: 725.74, Capital: 699.42, Interest: 26.32, RemainingCapital: 7836.45},
{Rank: 237, DueDate: "2043-05-05", TotalAmount: 725.74, Capital: 701.58, Interest: 24.16, RemainingCapital: 7134.87},
{Rank: 238, DueDate: "2043-06-05", TotalAmount: 725.74, Capital: 703.74, Interest: 22.0, RemainingCapital: 6431.13},
{Rank: 239, DueDate: "2043-07-05", TotalAmount: 725.74, Capital: 705.91, Interest: 19.83, RemainingCapital: 5725.22},
{Rank: 240, DueDate: "2043-08-05", TotalAmount: 725.74, Capital: 708.09, Interest: 17.65, RemainingCapital: 5017.13},
{Rank: 241, DueDate: "2043-09-05", TotalAmount: 725.74, Capital: 710.27, Interest: 15.47, RemainingCapital: 4306.86},
{Rank: 242, DueDate: "2043-10-05", TotalAmount: 725.74, Capital: 712.46, Interest: 13.28, RemainingCapital: 3594.4},
{Rank: 243, DueDate: "2043-11-05", TotalAmount: 725.74, Capital: 714.66, Interest: 11.08, RemainingCapital: 2879.74},
{Rank: 244, DueDate: "2043-12-05", TotalAmount: 725.74, Capital: 716.86, Interest: 8.88, RemainingCapital: 2162.88},
{Rank: 245, DueDate: "2044-01-05", TotalAmount: 725.74, Capital: 719.07, Interest: 6.67, RemainingCapital: 1443.81},
{Rank: 246, DueDate: "2044-02-05", TotalAmount: 725.74, Capital: 721.29, Interest: 4.45, RemainingCapital: 722.52},
{Rank: 247, DueDate: "2044-03-05", TotalAmount: 725.74, Capital: 722.52, Interest: 3.22, RemainingCapital: 0.0},
}
}

View File

@@ -0,0 +1,166 @@
package property
import (
"database/sql"
"encoding/json"
"net/http"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
// ── Model ─────────────────────────────────────────────────────────────────────
type Property struct {
ID string `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Type string `json:"type"` // airbnb | longterm
BankAccount string `json:"bank_account"`
IcalURL string `json:"ical_url"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
}
// ── Store ─────────────────────────────────────────────────────────────────────
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) List() ([]Property, error) {
rows, err := s.db.Query(`SELECT id, name, address, type, COALESCE(bank_account,''), COALESCE(ical_url,''), COALESCE(notes,''), created_at FROM properties ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var props []Property
for rows.Next() {
var p Property
if err := rows.Scan(&p.ID, &p.Name, &p.Address, &p.Type, &p.BankAccount, &p.IcalURL, &p.Notes, &p.CreatedAt); err != nil {
return nil, err
}
props = append(props, p)
}
return props, nil
}
func (s *Store) Get(id string) (*Property, error) {
var p Property
err := s.db.QueryRow(
`SELECT id, name, address, type, COALESCE(bank_account,''), COALESCE(ical_url,''), COALESCE(notes,''), created_at FROM properties WHERE id=?`, id,
).Scan(&p.ID, &p.Name, &p.Address, &p.Type, &p.BankAccount, &p.IcalURL, &p.Notes, &p.CreatedAt)
return &p, err
}
func (s *Store) Create(p *Property) error {
p.ID = uuid.NewString()
_, err := s.db.Exec(
`INSERT INTO properties (id, name, address, type, bank_account, ical_url, notes) VALUES (?,?,?,?,?,?,?)`,
p.ID, p.Name, p.Address, p.Type, p.BankAccount, p.IcalURL, p.Notes,
)
return err
}
func (s *Store) Update(p *Property) error {
_, err := s.db.Exec(
`UPDATE properties SET name=?, address=?, type=?, bank_account=?, ical_url=?, notes=? WHERE id=?`,
p.Name, p.Address, p.Type, p.BankAccount, p.IcalURL, p.Notes, p.ID,
)
return err
}
func (s *Store) Delete(id string) error {
_, err := s.db.Exec(`DELETE FROM properties WHERE id=?`, id)
return err
}
func (s *Store) ListWithIcal() ([]Property, error) {
rows, err := s.db.Query(`SELECT id, name, address, type, COALESCE(bank_account,''), COALESCE(ical_url,''), COALESCE(notes,''), created_at FROM properties WHERE ical_url IS NOT NULL AND ical_url != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
var props []Property
for rows.Next() {
var p Property
if err := rows.Scan(&p.ID, &p.Name, &p.Address, &p.Type, &p.BankAccount, &p.IcalURL, &p.Notes, &p.CreatedAt); err != nil {
return nil, err
}
props = append(props, p)
}
return props, nil
}
// ── Handler ───────────────────────────────────────────────────────────────────
type Handler struct{ store *Store }
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
props, err := h.store.List()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if props == nil {
props = []Property{}
}
respond(w, props)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
p, err := h.store.Get(id)
if err == sql.ErrNoRows {
http.Error(w, "not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, p)
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var p Property
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if err := h.store.Create(&p); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
respond(w, p)
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var p Property
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
p.ID = mux.Vars(r)["id"]
if err := h.store.Update(&p); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, p)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.store.Delete(mux.Vars(r)["id"]); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func respond(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,483 @@
package transaction
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
// ── Models ────────────────────────────────────────────────────────────────────
type Transaction struct {
ID string `json:"id"`
PropertyID string `json:"property_id"`
CategoryID string `json:"category_id"`
Type string `json:"type"` // income | expense
Amount float64 `json:"amount"`
Date string `json:"date"`
Description string `json:"description"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
// Champs joints
CategoryName string `json:"category_name,omitempty"`
PropertyName string `json:"property_name,omitempty"`
}
type Summary struct {
PropertyID string `json:"property_id"`
PropertyName string `json:"property_name"`
Year int `json:"year"`
TotalIncome float64 `json:"total_income"`
TotalExpense float64 `json:"total_expense"`
Balance float64 `json:"balance"`
}
// ── Store ─────────────────────────────────────────────────────────────────────
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) List(propertyID, txType, year, month string) ([]Transaction, error) {
query := `
SELECT t.id, t.property_id, COALESCE(t.category_id,''), t.type, t.amount, strftime('%Y-%m-%d', t.date) as date,
COALESCE(t.description,''), COALESCE(t.created_by,''), t.created_at,
COALESCE(c.name,''), p.name
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN properties p ON p.id = t.property_id
WHERE 1=1`
args := []any{}
if propertyID != "" {
query += " AND t.property_id = ?"
args = append(args, propertyID)
}
if txType != "" {
query += " AND t.type = ?"
args = append(args, txType)
}
if year != "" {
query += " AND strftime('%Y', t.date) = ?"
args = append(args, year)
}
if month != "" {
query += " AND strftime('%m', t.date) = ?"
args = append(args, fmt.Sprintf("%02s", month))
}
query += " ORDER BY t.date DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var txs []Transaction
for rows.Next() {
var t Transaction
if err := rows.Scan(&t.ID, &t.PropertyID, &t.CategoryID, &t.Type, &t.Amount,
&t.Date, &t.Description, &t.CreatedBy, &t.CreatedAt, &t.CategoryName, &t.PropertyName); err != nil {
return nil, err
}
txs = append(txs, t)
}
return txs, nil
}
func (s *Store) Get(id string) (*Transaction, error) {
var t Transaction
err := s.db.QueryRow(`
SELECT t.id, t.property_id, COALESCE(t.category_id,''), t.type, t.amount, strftime('%Y-%m-%d', t.date) as date,
COALESCE(t.description,''), COALESCE(t.created_by,''), t.created_at,
COALESCE(c.name,''), p.name
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN properties p ON p.id = t.property_id
WHERE t.id=?`, id,
).Scan(&t.ID, &t.PropertyID, &t.CategoryID, &t.Type, &t.Amount,
&t.Date, &t.Description, &t.CreatedBy, &t.CreatedAt, &t.CategoryName, &t.PropertyName)
return &t, err
}
func (s *Store) Create(t *Transaction) error {
t.ID = uuid.NewString()
_, err := s.db.Exec(
`INSERT INTO transactions (id, property_id, category_id, type, amount, date, description, created_by) VALUES (?,?,?,?,?,?,?,?)`,
t.ID, t.PropertyID, nullStr(t.CategoryID), t.Type, t.Amount, t.Date, t.Description, nullStr(t.CreatedBy),
)
return err
}
func (s *Store) Update(t *Transaction) error {
_, err := s.db.Exec(
`UPDATE transactions SET property_id=?, category_id=?, type=?, amount=?, date=?, description=? WHERE id=?`,
t.PropertyID, nullStr(t.CategoryID), t.Type, t.Amount, t.Date, t.Description, t.ID,
)
return err
}
func (s *Store) Delete(id string) error {
_, err := s.db.Exec(`DELETE FROM transactions WHERE id=?`, id)
return err
}
func (s *Store) GetSummary(propertyID, year, month string) ([]Summary, error) {
query := `
SELECT t.property_id, p.name,
strftime('%Y', t.date) as yr,
SUM(CASE WHEN t.type='income' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type='expense' THEN t.amount ELSE 0 END) as expense
FROM transactions t
JOIN properties p ON p.id = t.property_id
WHERE 1=1`
args := []any{}
if propertyID != "" {
query += " AND t.property_id=?"
args = append(args, propertyID)
}
if year != "" {
query += " AND strftime('%Y', t.date)=?"
args = append(args, year)
}
if month != "" {
query += " AND strftime('%m', t.date)=?"
args = append(args, fmt.Sprintf("%02s", month))
}
query += " GROUP BY t.property_id, yr ORDER BY yr DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var summaries []Summary
for rows.Next() {
var s Summary
var yr string
if err := rows.Scan(&s.PropertyID, &s.PropertyName, &yr, &s.TotalIncome, &s.TotalExpense); err != nil {
return nil, err
}
s.Balance = s.TotalIncome - s.TotalExpense
summaries = append(summaries, s)
}
return summaries, nil
}
func (s *Store) GetByPropertyAndYear(propertyID string, year int) ([]Transaction, error) {
rows, err := s.db.Query(`
SELECT t.id, t.property_id, COALESCE(t.category_id,''), t.type, t.amount, strftime('%Y-%m-%d', t.date) as date,
COALESCE(t.description,''), COALESCE(t.created_by,''), t.created_at,
COALESCE(c.name,''), p.name
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN properties p ON p.id = t.property_id
WHERE t.property_id=? AND strftime('%Y', t.date)=?
ORDER BY t.date`,
propertyID, year,
)
if err != nil {
return nil, err
}
defer rows.Close()
var txs []Transaction
for rows.Next() {
var t Transaction
if err := rows.Scan(&t.ID, &t.PropertyID, &t.CategoryID, &t.Type, &t.Amount,
&t.Date, &t.Description, &t.CreatedBy, &t.CreatedAt, &t.CategoryName, &t.PropertyName); err != nil {
return nil, err
}
txs = append(txs, t)
}
return txs, nil
}
// ── Handler ───────────────────────────────────────────────────────────────────
type Handler struct{ store *Store }
func NewHandler(store *Store) *Handler { return &Handler{store: store} }
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
txs, err := h.store.List(q.Get("property_id"), q.Get("type"), q.Get("year"), q.Get("month"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if txs == nil {
txs = []Transaction{}
}
respond(w, txs)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
t, err := h.store.Get(mux.Vars(r)["id"])
if err == sql.ErrNoRows {
http.Error(w, "not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, t)
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var t Transaction
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if err := h.store.Create(&t); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
respond(w, t)
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var t Transaction
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
t.ID = mux.Vars(r)["id"]
if err := h.store.Update(&t); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, t)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.store.Delete(mux.Vars(r)["id"]); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
summaries, err := h.store.GetSummary(q.Get("property_id"), q.Get("year"), q.Get("month"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if summaries == nil {
summaries = []Summary{}
}
respond(w, summaries)
}
func respond(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}
// ── Données mensuelles pour graphiques ───────────────────────────────────────
type MonthlyData struct {
Month string `json:"month"` // "2026-01"
Income float64 `json:"income"`
Expense float64 `json:"expense"`
Balance float64 `json:"balance"`
}
type CategoryBreakdown struct {
Category string `json:"category"`
Amount float64 `json:"amount"`
Type string `json:"type"`
}
func (s *Store) GetMonthlyData(propertyID, year string) ([]MonthlyData, error) {
query := `
SELECT strftime('%Y-%m', t.date) as month,
SUM(CASE WHEN t.type='income' THEN t.amount ELSE 0 END) as income,
SUM(CASE WHEN t.type='expense' THEN t.amount ELSE 0 END) as expense
FROM transactions t
WHERE strftime('%Y', t.date) = ?`
args := []any{year}
if propertyID != "" {
query += " AND t.property_id = ?"
args = append(args, propertyID)
}
query += " GROUP BY month ORDER BY month"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
// Pré-remplir les 12 mois avec zéros
data := make(map[string]*MonthlyData)
for m := 1; m <= 12; m++ {
key := fmt.Sprintf("%s-%02d", year, m)
data[key] = &MonthlyData{Month: key}
}
for rows.Next() {
var d MonthlyData
if err := rows.Scan(&d.Month, &d.Income, &d.Expense); err != nil {
return nil, err
}
d.Balance = d.Income - d.Expense
data[d.Month] = &d
}
// Retourner dans l'ordre
result := make([]MonthlyData, 12)
for m := 1; m <= 12; m++ {
key := fmt.Sprintf("%s-%02d", year, m)
result[m-1] = *data[key]
}
return result, nil
}
func (s *Store) GetCategoryBreakdown(propertyID, year, month, txType string) ([]CategoryBreakdown, error) {
query := `
SELECT COALESCE(c.name, 'Sans catégorie') as category,
SUM(t.amount) as amount,
t.type
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
WHERE strftime('%Y', t.date) = ?`
args := []any{year}
if propertyID != "" {
query += " AND t.property_id = ?"
args = append(args, propertyID)
}
if month != "" {
query += " AND strftime('%m', t.date) = ?"
args = append(args, fmt.Sprintf("%02s", month))
}
if txType != "" {
query += " AND t.type = ?"
args = append(args, txType)
}
query += " GROUP BY category, t.type ORDER BY amount DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var result []CategoryBreakdown
for rows.Next() {
var d CategoryBreakdown
if err := rows.Scan(&d.Category, &d.Amount, &d.Type); err != nil {
return nil, err
}
result = append(result, d)
}
return result, nil
}
func (h *Handler) Monthly(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
year := q.Get("year")
if year == "" {
year = fmt.Sprintf("%d", time.Now().Year())
}
data, err := h.store.GetMonthlyData(q.Get("property_id"), year)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
respond(w, data)
}
func (h *Handler) CategoryBreakdown(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
year := q.Get("year")
if year == "" {
year = fmt.Sprintf("%d", time.Now().Year())
}
data, err := h.store.GetCategoryBreakdown(q.Get("property_id"), year, q.Get("month"), q.Get("type"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if data == nil {
data = []CategoryBreakdown{}
}
respond(w, data)
}
// ── Split transaction ─────────────────────────────────────────────────────────
type SplitRequest struct {
SourceID string `json:"source_id"`
Splits []Split `json:"splits"`
}
type Split struct {
PropertyID string `json:"property_id"`
CategoryID string `json:"category_id"`
Type string `json:"type"` // income | expense — si vide, hérite du type source
Amount float64 `json:"amount"`
Description string `json:"description"`
}
func (h *Handler) SplitTransaction(w http.ResponseWriter, r *http.Request) {
var req SplitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if len(req.Splits) < 2 {
http.Error(w, "au moins 2 parts requises", http.StatusBadRequest)
return
}
// Récupérer la transaction source
source, err := h.store.Get(req.SourceID)
if err != nil {
http.Error(w, "transaction source introuvable", http.StatusNotFound)
return
}
// Créer les nouvelles transactions
created := []Transaction{}
for _, s := range req.Splits {
txType := source.Type
if s.Type == "income" || s.Type == "expense" {
txType = s.Type
}
t := &Transaction{
PropertyID: s.PropertyID,
CategoryID: source.CategoryID,
Type: txType,
Amount: s.Amount,
Date: source.Date,
Description: s.Description,
}
if s.CategoryID != "" {
t.CategoryID = s.CategoryID
}
if err := h.store.Create(t); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
created = append(created, *t)
}
// Supprimer la transaction source
h.store.Delete(req.SourceID)
respond(w, created)
}

1
web/build/_app/env.js Normal file
View File

@@ -0,0 +1 @@
export const env={}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{s as u,a as l,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as f,m as _,n as k,o as v,p as N}from"./G8UKEBBn.js";import{I as G,g as I,a as M}from"./C1tDbyYL.js";function S(c){let e;const s=c[2].default,o=m(s,c,c[3],null);return{c(){o&&o.c()},l(n){o&&o.l(n)},m(n,t){o&&o.m(n,t),e=!0},p(n,t){o&&o.p&&(!e||t&8)&&p(o,s,n,n[3],e?g(s,n[3],t,null):d(n[3]),null)},i(n){e||(_(o,n),e=!0)},o(n){f(o,n),e=!1},d(n){o&&o.d(n)}}}function j(c){let e,s;const o=[{name:"git-fork"},c[1],{iconNode:c[0]}];let n={$$slots:{default:[S]},$$scope:{ctx:c}};for(let t=0;t<o.length;t+=1)n=l(n,o[t]);return e=new G({props:n}),{c(){N(e.$$.fragment)},l(t){v(e.$$.fragment,t)},m(t,a){k(e,t,a),s=!0},p(t,[a]){const r=a&3?I(o,[o[0],a&2&&M(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(r.$$scope={dirty:a,ctx:t}),e.$set(r)},i(t){s||(_(e.$$.fragment,t),s=!0)},o(t){f(e.$$.fragment,t),s=!1},d(t){b(e,t)}}}function q(c,e,s){let{$$slots:o={},$$scope:n}=e;const t=[["circle",{cx:"12",cy:"18",r:"3"}],["circle",{cx:"6",cy:"6",r:"3"}],["circle",{cx:"18",cy:"6",r:"3"}],["path",{d:"M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9"}],["path",{d:"M12 12v3"}]];return c.$$set=a=>{s(1,e=l(l({},e),i(a))),"$$scope"in a&&s(3,n=a.$$scope)},e=i(e),[t,e,o,n]}class y extends ${constructor(e){super(),h(this,e,q,j,u,{})}}export{y as G};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{s as _,a as i,e as c,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as N,l as f,m as u,n as b,o as T,p as I}from"./G8UKEBBn.js";import{I as S,g as j,a as k}from"./C1tDbyYL.js";function q(a){let e;const s=a[2].default,o=m(s,a,a[3],null);return{c(){o&&o.c()},l(t){o&&o.l(t)},m(t,n){o&&o.m(t,n),e=!0},p(t,n){o&&o.p&&(!e||n&8)&&p(o,s,t,t[3],e?g(s,t[3],n,null):d(t[3]),null)},i(t){e||(u(o,t),e=!0)},o(t){f(o,t),e=!1},d(t){o&&o.d(t)}}}function v(a){let e,s;const o=[{name:"trending-down"},a[1],{iconNode:a[0]}];let t={$$slots:{default:[q]},$$scope:{ctx:a}};for(let n=0;n<o.length;n+=1)t=i(t,o[n]);return e=new S({props:t}),{c(){I(e.$$.fragment)},l(n){T(e.$$.fragment,n)},m(n,l){b(e,n,l),s=!0},p(n,[l]){const r=l&3?j(o,[o[0],l&2&&k(n[1]),l&1&&{iconNode:n[0]}]):{};l&8&&(r.$$scope={dirty:l,ctx:n}),e.$set(r)},i(n){s||(u(e.$$.fragment,n),s=!0)},o(n){f(e.$$.fragment,n),s=!1},d(n){N(e,n)}}}function C(a,e,s){let{$$slots:o={},$$scope:t}=e;const n=[["polyline",{points:"22 17 13.5 8.5 8.5 13.5 2 7"}],["polyline",{points:"16 17 22 17 22 11"}]];return a.$$set=l=>{s(1,e=i(i({},e),c(l))),"$$scope"in l&&s(3,t=l.$$scope)},e=c(e),[n,e,o,t]}class F extends ${constructor(e){super(),h(this,e,C,v,_,{})}}function w(a){let e;const s=a[2].default,o=m(s,a,a[3],null);return{c(){o&&o.c()},l(t){o&&o.l(t)},m(t,n){o&&o.m(t,n),e=!0},p(t,n){o&&o.p&&(!e||n&8)&&p(o,s,t,t[3],e?g(s,t[3],n,null):d(t[3]),null)},i(t){e||(u(o,t),e=!0)},o(t){f(o,t),e=!1},d(t){o&&o.d(t)}}}function z(a){let e,s;const o=[{name:"trending-up"},a[1],{iconNode:a[0]}];let t={$$slots:{default:[w]},$$scope:{ctx:a}};for(let n=0;n<o.length;n+=1)t=i(t,o[n]);return e=new S({props:t}),{c(){I(e.$$.fragment)},l(n){T(e.$$.fragment,n)},m(n,l){b(e,n,l),s=!0},p(n,[l]){const r=l&3?j(o,[o[0],l&2&&k(n[1]),l&1&&{iconNode:n[0]}]):{};l&8&&(r.$$scope={dirty:l,ctx:n}),e.$set(r)},i(n){s||(u(e.$$.fragment,n),s=!0)},o(n){f(e.$$.fragment,n),s=!1},d(n){N(e,n)}}}function A(a,e,s){let{$$slots:o={},$$scope:t}=e;const n=[["polyline",{points:"22 7 13.5 15.5 8.5 10.5 2 17"}],["polyline",{points:"16 7 22 7 22 13"}]];return a.$$set=l=>{s(1,e=i(i({},e),c(l))),"$$scope"in l&&s(3,t=l.$$scope)},e=c(e),[n,e,o,t]}class G extends ${constructor(e){super(),h(this,e,A,z,_,{})}}export{G as T,F as a};

View File

@@ -0,0 +1 @@
import{s as u,a as r,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as h,i as $,k as b,l as _,m as f,n as v,o as M,p as N}from"./G8UKEBBn.js";import{I,g as S,a as T}from"./C1tDbyYL.js";function V(l){let e;const o=l[2].default,s=m(o,l,l[3],null);return{c(){s&&s.c()},l(n){s&&s.l(n)},m(n,t){s&&s.m(n,t),e=!0},p(n,t){s&&s.p&&(!e||t&8)&&p(s,o,n,n[3],e?g(o,n[3],t,null):d(n[3]),null)},i(n){e||(f(s,n),e=!0)},o(n){_(s,n),e=!1},d(n){s&&s.d(n)}}}function j(l){let e,o;const s=[{name:"trash-2"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[V]},$$scope:{ctx:l}};for(let t=0;t<s.length;t+=1)n=r(n,s[t]);return e=new I({props:n}),{c(){N(e.$$.fragment)},l(t){M(e.$$.fragment,t)},m(t,a){v(e,t,a),o=!0},p(t,[a]){const c=a&3?S(s,[s[0],a&2&&T(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(c.$$scope={dirty:a,ctx:t}),e.$set(c)},i(t){o||(f(e.$$.fragment,t),o=!0)},o(t){_(e.$$.fragment,t),o=!1},d(t){b(e,t)}}}function k(l,e,o){let{$$slots:s={},$$scope:n}=e;const t=[["path",{d:"M3 6h18"}],["path",{d:"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"}],["path",{d:"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"}],["line",{x1:"10",x2:"10",y1:"11",y2:"17"}],["line",{x1:"14",x2:"14",y1:"11",y2:"17"}]];return l.$$set=a=>{o(1,e=r(r({},e),i(a))),"$$scope"in a&&o(3,n=a.$$scope)},e=i(e),[t,e,s,n]}class H extends h{constructor(e){super(),$(this,e,k,j,u,{})}}export{H as T};

View File

@@ -0,0 +1 @@
import{s as u,a as r,e as c,b as d,u as m,g as p,d as h}from"./DHedsUi_.js";import{S as g,i as $,k as y,l as _,m as f,n as M,o as b,p as C}from"./G8UKEBBn.js";import{I as N,g as x,a as I}from"./C1tDbyYL.js";function S(l){let e;const s=l[2].default,a=d(s,l,l[3],null);return{c(){a&&a.c()},l(n){a&&a.l(n)},m(n,t){a&&a.m(n,t),e=!0},p(n,t){a&&a.p&&(!e||t&8)&&m(a,s,n,n[3],e?h(s,n[3],t,null):p(n[3]),null)},i(n){e||(f(a,n),e=!0)},o(n){_(a,n),e=!1},d(n){a&&a.d(n)}}}function j(l){let e,s;const a=[{name:"calendar-days"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[S]},$$scope:{ctx:l}};for(let t=0;t<a.length;t+=1)n=r(n,a[t]);return e=new N({props:n}),{c(){C(e.$$.fragment)},l(t){b(e.$$.fragment,t)},m(t,o){M(e,t,o),s=!0},p(t,[o]){const i=o&3?x(a,[a[0],o&2&&I(t[1]),o&1&&{iconNode:t[0]}]):{};o&8&&(i.$$scope={dirty:o,ctx:t}),e.$set(i)},i(t){s||(f(e.$$.fragment,t),s=!0)},o(t){_(e.$$.fragment,t),s=!1},d(t){y(e,t)}}}function k(l,e,s){let{$$slots:a={},$$scope:n}=e;const t=[["rect",{width:"18",height:"18",x:"3",y:"4",rx:"2",ry:"2"}],["line",{x1:"16",x2:"16",y1:"2",y2:"6"}],["line",{x1:"8",x2:"8",y1:"2",y2:"6"}],["line",{x1:"3",x2:"21",y1:"10",y2:"10"}],["path",{d:"M8 14h.01"}],["path",{d:"M12 14h.01"}],["path",{d:"M16 14h.01"}],["path",{d:"M8 18h.01"}],["path",{d:"M12 18h.01"}],["path",{d:"M16 18h.01"}]];return l.$$set=o=>{s(1,e=r(r({},e),c(o))),"$$scope"in o&&s(3,n=o.$$scope)},e=c(e),[t,e,a,n]}class z extends g{constructor(e){super(),$(this,e,k,j,u,{})}}export{z as C};

View File

@@ -0,0 +1 @@
import{s as _,a as r,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as v,l as u,m as f,n as b,o as M,p as N}from"./G8UKEBBn.js";import{I,g as S,a as U}from"./C1tDbyYL.js";function j(l){let e;const a=l[2].default,n=m(a,l,l[3],null);return{c(){n&&n.c()},l(s){n&&n.l(s)},m(s,t){n&&n.m(s,t),e=!0},p(s,t){n&&n.p&&(!e||t&8)&&p(n,a,s,s[3],e?g(a,s[3],t,null):d(s[3]),null)},i(s){e||(f(n,s),e=!0)},o(s){u(n,s),e=!1},d(s){n&&n.d(s)}}}function k(l){let e,a;const n=[{name:"users"},l[1],{iconNode:l[0]}];let s={$$slots:{default:[j]},$$scope:{ctx:l}};for(let t=0;t<n.length;t+=1)s=r(s,n[t]);return e=new I({props:s}),{c(){N(e.$$.fragment)},l(t){M(e.$$.fragment,t)},m(t,o){b(e,t,o),a=!0},p(t,[o]){const c=o&3?S(n,[n[0],o&2&&U(t[1]),o&1&&{iconNode:t[0]}]):{};o&8&&(c.$$scope={dirty:o,ctx:t}),e.$set(c)},i(t){a||(f(e.$$.fragment,t),a=!0)},o(t){u(e.$$.fragment,t),a=!1},d(t){v(e,t)}}}function q(l,e,a){let{$$slots:n={},$$scope:s}=e;const t=[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"}],["circle",{cx:"9",cy:"7",r:"4"}],["path",{d:"M22 21v-2a4 4 0 0 0-3-3.87"}],["path",{d:"M16 3.13a4 4 0 0 1 0 7.75"}]];return l.$$set=o=>{a(1,e=r(r({},e),i(o))),"$$scope"in o&&a(3,s=o.$$scope)},e=i(e),[t,e,n,s]}class A extends ${constructor(e){super(),h(this,e,q,k,_,{})}}export{A as U};

View File

@@ -0,0 +1 @@
import{s as _,a as r,e as c,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as u,m as f,n as v,o as N,p as I}from"./G8UKEBBn.js";import{I as S,g as U,a as j}from"./C1tDbyYL.js";function k(l){let e;const s=l[2].default,o=m(s,l,l[3],null);return{c(){o&&o.c()},l(n){o&&o.l(n)},m(n,t){o&&o.m(n,t),e=!0},p(n,t){o&&o.p&&(!e||t&8)&&p(o,s,n,n[3],e?g(s,n[3],t,null):d(n[3]),null)},i(n){e||(f(o,n),e=!0)},o(n){u(o,n),e=!1},d(n){o&&o.d(n)}}}function q(l){let e,s;const o=[{name:"upload"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[k]},$$scope:{ctx:l}};for(let t=0;t<o.length;t+=1)n=r(n,o[t]);return e=new S({props:n}),{c(){I(e.$$.fragment)},l(t){N(e.$$.fragment,t)},m(t,a){v(e,t,a),s=!0},p(t,[a]){const i=a&3?U(o,[o[0],a&2&&j(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(i.$$scope={dirty:a,ctx:t}),e.$set(i)},i(t){s||(f(e.$$.fragment,t),s=!0)},o(t){u(e.$$.fragment,t),s=!1},d(t){b(e,t)}}}function C(l,e,s){let{$$slots:o={},$$scope:n}=e;const t=[["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["polyline",{points:"17 8 12 3 7 8"}],["line",{x1:"12",x2:"12",y1:"3",y2:"15"}]];return l.$$set=a=>{s(1,e=r(r({},e),c(a))),"$$scope"in a&&s(3,n=a.$$scope)},e=c(e),[t,e,o,n]}class z extends ${constructor(e){super(),h(this,e,C,q,_,{})}}export{z as U};

View File

@@ -0,0 +1 @@
import{s as _,a as r,e as c,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as f,m as u,n as v,o as N,p as D}from"./G8UKEBBn.js";import{I,g as S,a as j}from"./C1tDbyYL.js";function k(l){let e;const s=l[2].default,o=m(s,l,l[3],null);return{c(){o&&o.c()},l(n){o&&o.l(n)},m(n,t){o&&o.m(n,t),e=!0},p(n,t){o&&o.p&&(!e||t&8)&&p(o,s,n,n[3],e?g(s,n[3],t,null):d(n[3]),null)},i(n){e||(u(o,n),e=!0)},o(n){f(o,n),e=!1},d(n){o&&o.d(n)}}}function q(l){let e,s;const o=[{name:"download"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[k]},$$scope:{ctx:l}};for(let t=0;t<o.length;t+=1)n=r(n,o[t]);return e=new I({props:n}),{c(){D(e.$$.fragment)},l(t){N(e.$$.fragment,t)},m(t,a){v(e,t,a),s=!0},p(t,[a]){const i=a&3?S(o,[o[0],a&2&&j(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(i.$$scope={dirty:a,ctx:t}),e.$set(i)},i(t){s||(u(e.$$.fragment,t),s=!0)},o(t){f(e.$$.fragment,t),s=!1},d(t){b(e,t)}}}function w(l,e,s){let{$$slots:o={},$$scope:n}=e;const t=[["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["polyline",{points:"7 10 12 15 17 10"}],["line",{x1:"12",x2:"12",y1:"15",y2:"3"}]];return l.$$set=a=>{s(1,e=r(r({},e),c(a))),"$$scope"in a&&s(3,n=a.$$scope)},e=c(e),[t,e,o,n]}class y extends ${constructor(e){super(),h(this,e,w,q,_,{})}}export{y as D};

View File

@@ -0,0 +1,6 @@
import{r as U,s as G,b as V,a as N,u as X,g as Y,d as Z,k as B,e as J}from"./DHedsUi_.js";import{m as H,l as L,S as p,i as x,d as b,x as $,I as W,a as M,b as ee,J as O,e as Q,w as S,K as R}from"./G8UKEBBn.js";function K(s){return(s==null?void 0:s.length)!==void 0?s:Array.from(s)}function ie(s,e){s.d(1),e.delete(s.key)}function oe(s,e){L(s,1,1,()=>{e.delete(s.key)})}function ae(s,e,o,i,n,a,h,u,c,d,_,k){let t=s.length,l=a.length,f=t;const v={};for(;f--;)v[s[f].key]=f;const w=[],z=new Map,j=new Map,A=[];for(f=l;f--;){const r=k(n,a,f),m=o(r);let g=h.get(m);g?A.push(()=>g.p(r,e)):(g=d(m,r),g.c()),z.set(m,w[f]=g),m in v&&j.set(m,Math.abs(f-v[m]))}const E=new Set,q=new Set;function C(r){H(r,1),r.m(u,_),h.set(r.key,r),_=r.first,l--}for(;t&&l;){const r=w[l-1],m=s[t-1],g=r.key,y=m.key;r===m?(_=r.first,t--,l--):z.has(y)?!h.has(g)||E.has(g)?C(r):q.has(y)?t--:j.get(g)>j.get(y)?(q.add(g),C(r)):(E.add(y),t--):(c(m,h),t--)}for(;t--;){const r=s[t];z.has(r.key)||c(r,h)}for(;l;)C(w[l-1]);return U(A),w}function T(s,e){const o={},i={},n={$$scope:1};let a=s.length;for(;a--;){const h=s[a],u=e[a];if(u){for(const c in h)c in u||(i[c]=1);for(const c in u)n[c]||(o[c]=u[c],n[c]=1);s[a]=u}else for(const c in h)n[c]=1}for(const h in i)h in o||(o[h]=void 0);return o}function fe(s){return typeof s=="object"&&s!==null?s:{}}/**
* @license lucide-svelte v0.303.0 - ISC
This source code is licensed under the ISC license.
See the LICENSE file in the root directory of this source tree.
*/const P={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};function D(s,e,o){const i=s.slice();return i[10]=e[o][0],i[11]=e[o][1],i}function I(s){let e,o=[s[11]],i={};for(let n=0;n<o.length;n+=1)i=N(i,o[n]);return{c(){e=R(s[10]),this.h()},l(n){e=O(n,s[10],{}),Q(e).forEach(b),this.h()},h(){W(e,i)},m(n,a){M(n,e,a)},p(n,a){W(e,i=T(o,[a&32&&n[11]]))},d(n){n&&b(e)}}}function F(s){let e=s[10],o,i=s[10]&&I(s);return{c(){i&&i.c(),o=S()},l(n){i&&i.l(n),o=S()},m(n,a){i&&i.m(n,a),M(n,o,a)},p(n,a){n[10]?e?G(e,n[10])?(i.d(1),i=I(n),e=n[10],i.c(),i.m(o.parentNode,o)):i.p(n,a):(i=I(n),e=n[10],i.c(),i.m(o.parentNode,o)):e&&(i.d(1),i=null,e=n[10])},d(n){n&&b(o),i&&i.d(n)}}}function te(s){let e,o,i,n,a,h=K(s[5]),u=[];for(let t=0;t<h.length;t+=1)u[t]=F(D(s,h,t));const c=s[9].default,d=V(c,s,s[8],null);let _=[P,s[6],{width:s[2]},{height:s[2]},{stroke:s[1]},{"stroke-width":i=s[4]?Number(s[3])*24/Number(s[2]):s[3]},{class:n=`lucide-icon lucide lucide-${s[0]} ${s[7].class??""}`}],k={};for(let t=0;t<_.length;t+=1)k=N(k,_[t]);return{c(){e=R("svg");for(let t=0;t<u.length;t+=1)u[t].c();o=S(),d&&d.c(),this.h()},l(t){e=O(t,"svg",{width:!0,height:!0,stroke:!0,"stroke-width":!0,class:!0});var l=Q(e);for(let f=0;f<u.length;f+=1)u[f].l(l);o=S(),d&&d.l(l),l.forEach(b),this.h()},h(){W(e,k)},m(t,l){M(t,e,l);for(let f=0;f<u.length;f+=1)u[f]&&u[f].m(e,null);ee(e,o),d&&d.m(e,null),a=!0},p(t,[l]){if(l&32){h=K(t[5]);let f;for(f=0;f<h.length;f+=1){const v=D(t,h,f);u[f]?u[f].p(v,l):(u[f]=F(v),u[f].c(),u[f].m(e,o))}for(;f<u.length;f+=1)u[f].d(1);u.length=h.length}d&&d.p&&(!a||l&256)&&X(d,c,t,t[8],a?Z(c,t[8],l,null):Y(t[8]),null),W(e,k=T(_,[P,l&64&&t[6],(!a||l&4)&&{width:t[2]},(!a||l&4)&&{height:t[2]},(!a||l&2)&&{stroke:t[1]},(!a||l&28&&i!==(i=t[4]?Number(t[3])*24/Number(t[2]):t[3]))&&{"stroke-width":i},(!a||l&129&&n!==(n=`lucide-icon lucide lucide-${t[0]} ${t[7].class??""}`))&&{class:n}]))},i(t){a||(H(d,t),a=!0)},o(t){L(d,t),a=!1},d(t){t&&b(e),$(u,t),d&&d.d(t)}}}function se(s,e,o){const i=["name","color","size","strokeWidth","absoluteStrokeWidth","iconNode"];let n=B(e,i),{$$slots:a={},$$scope:h}=e,{name:u}=e,{color:c="currentColor"}=e,{size:d=24}=e,{strokeWidth:_=2}=e,{absoluteStrokeWidth:k=!1}=e,{iconNode:t}=e;return s.$$set=l=>{o(7,e=N(N({},e),J(l))),o(6,n=B(e,i)),"name"in l&&o(0,u=l.name),"color"in l&&o(1,c=l.color),"size"in l&&o(2,d=l.size),"strokeWidth"in l&&o(3,_=l.strokeWidth),"absoluteStrokeWidth"in l&&o(4,k=l.absoluteStrokeWidth),"iconNode"in l&&o(5,t=l.iconNode),"$$scope"in l&&o(8,h=l.$$scope)},e=J(e),[u,c,d,_,k,t,n,e,h,a]}class ue extends p{constructor(e){super(),x(this,e,se,te,G,{name:0,color:1,size:2,strokeWidth:3,absoluteStrokeWidth:4,iconNode:5})}}export{ue as I,fe as a,ie as d,K as e,T as g,oe as o,ae as u};

View File

@@ -0,0 +1 @@
import{s as _,a as r,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as f,m as u,n as N,o as v,p as I}from"./G8UKEBBn.js";import{I as M,g as S,a as T}from"./C1tDbyYL.js";function j(l){let e;const o=l[2].default,s=m(o,l,l[3],null);return{c(){s&&s.c()},l(n){s&&s.l(n)},m(n,t){s&&s.m(n,t),e=!0},p(n,t){s&&s.p&&(!e||t&8)&&p(s,o,n,n[3],e?g(o,n[3],t,null):d(n[3]),null)},i(n){e||(u(s,n),e=!0)},o(n){f(s,n),e=!1},d(n){s&&s.d(n)}}}function k(l){let e,o;const s=[{name:"tag"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[j]},$$scope:{ctx:l}};for(let t=0;t<s.length;t+=1)n=r(n,s[t]);return e=new M({props:n}),{c(){I(e.$$.fragment)},l(t){v(e.$$.fragment,t)},m(t,a){N(e,t,a),o=!0},p(t,[a]){const c=a&3?S(s,[s[0],a&2&&T(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(c.$$scope={dirty:a,ctx:t}),e.$set(c)},i(t){o||(u(e.$$.fragment,t),o=!0)},o(t){f(e.$$.fragment,t),o=!1},d(t){b(e,t)}}}function q(l,e,o){let{$$slots:s={},$$scope:n}=e;const t=[["path",{d:"M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z"}],["path",{d:"M7 7h.01"}]];return l.$$set=a=>{o(1,e=r(r({},e),i(a))),"$$scope"in a&&o(3,n=a.$$scope)},e=i(e),[t,e,s,n]}class Z extends ${constructor(e){super(),h(this,e,q,k,_,{})}}export{Z as T};

View File

@@ -0,0 +1 @@
import{s as e}from"./6cgBTYw-.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};

View File

@@ -0,0 +1 @@
import{s as _,a as r,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as u,m as f,n as v,o as N,p as I}from"./G8UKEBBn.js";import{I as S,g as U,a as j}from"./C1tDbyYL.js";function k(l){let e;const o=l[2].default,n=m(o,l,l[3],null);return{c(){n&&n.c()},l(s){n&&n.l(s)},m(s,t){n&&n.m(s,t),e=!0},p(s,t){n&&n.p&&(!e||t&8)&&p(n,o,s,s[3],e?g(o,s[3],t,null):d(s[3]),null)},i(s){e||(f(n,s),e=!0)},o(s){u(n,s),e=!1},d(s){n&&n.d(s)}}}function q(l){let e,o;const n=[{name:"user"},l[1],{iconNode:l[0]}];let s={$$slots:{default:[k]},$$scope:{ctx:l}};for(let t=0;t<n.length;t+=1)s=r(s,n[t]);return e=new S({props:s}),{c(){I(e.$$.fragment)},l(t){N(e.$$.fragment,t)},m(t,a){v(e,t,a),o=!0},p(t,[a]){const c=a&3?U(n,[n[0],a&2&&j(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(c.$$scope={dirty:a,ctx:t}),e.$set(c)},i(t){o||(f(e.$$.fragment,t),o=!0)},o(t){u(e.$$.fragment,t),o=!1},d(t){b(e,t)}}}function C(l,e,o){let{$$slots:n={},$$scope:s}=e;const t=[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"}],["circle",{cx:"12",cy:"7",r:"4"}]];return l.$$set=a=>{o(1,e=r(r({},e),i(a))),"$$scope"in a&&o(3,s=a.$$scope)},e=i(e),[t,e,n,s]}class A extends ${constructor(e){super(),h(this,e,C,q,_,{})}}export{A as U};

View File

@@ -0,0 +1 @@
import{s as _,a as r,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as u,m as f,n as N,o as v,p as I}from"./G8UKEBBn.js";import{I as M,g as P,a as S}from"./C1tDbyYL.js";function j(l){let e;const o=l[2].default,n=m(o,l,l[3],null);return{c(){n&&n.c()},l(s){n&&n.l(s)},m(s,t){n&&n.m(s,t),e=!0},p(s,t){n&&n.p&&(!e||t&8)&&p(n,o,s,s[3],e?g(o,s[3],t,null):d(s[3]),null)},i(s){e||(f(n,s),e=!0)},o(s){u(n,s),e=!1},d(s){n&&n.d(s)}}}function k(l){let e,o;const n=[{name:"plus"},l[1],{iconNode:l[0]}];let s={$$slots:{default:[j]},$$scope:{ctx:l}};for(let t=0;t<n.length;t+=1)s=r(s,n[t]);return e=new M({props:s}),{c(){I(e.$$.fragment)},l(t){v(e.$$.fragment,t)},m(t,a){N(e,t,a),o=!0},p(t,[a]){const c=a&3?P(n,[n[0],a&2&&S(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(c.$$scope={dirty:a,ctx:t}),e.$set(c)},i(t){o||(f(e.$$.fragment,t),o=!0)},o(t){u(e.$$.fragment,t),o=!1},d(t){b(e,t)}}}function q(l,e,o){let{$$slots:n={},$$scope:s}=e;const t=[["path",{d:"M5 12h14"}],["path",{d:"M12 5v14"}]];return l.$$set=a=>{o(1,e=r(r({},e),i(a))),"$$scope"in a&&o(3,s=a.$$scope)},e=i(e),[t,e,n,s]}class B extends ${constructor(e){super(),h(this,e,q,k,_,{})}}export{B as P};

View File

@@ -0,0 +1 @@
import{s as u,a as i,e as c,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as y,l as f,m as _,n as b,o as N,p as v}from"./G8UKEBBn.js";import{I as x,g as F,a as I}from"./C1tDbyYL.js";function S(l){let e;const o=l[2].default,s=m(o,l,l[3],null);return{c(){s&&s.c()},l(n){s&&s.l(n)},m(n,t){s&&s.m(n,t),e=!0},p(n,t){s&&s.p&&(!e||t&8)&&p(s,o,n,n[3],e?g(o,n[3],t,null):d(n[3]),null)},i(n){e||(_(s,n),e=!0)},o(n){f(s,n),e=!1},d(n){s&&s.d(n)}}}function j(l){let e,o;const s=[{name:"file-text"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[S]},$$scope:{ctx:l}};for(let t=0;t<s.length;t+=1)n=i(n,s[t]);return e=new x({props:n}),{c(){v(e.$$.fragment)},l(t){N(e.$$.fragment,t)},m(t,a){b(e,t,a),o=!0},p(t,[a]){const r=a&3?F(s,[s[0],a&2&&I(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(r.$$scope={dirty:a,ctx:t}),e.$set(r)},i(t){o||(_(e.$$.fragment,t),o=!0)},o(t){f(e.$$.fragment,t),o=!1},d(t){y(e,t)}}}function k(l,e,o){let{$$slots:s={},$$scope:n}=e;const t=[["path",{d:"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"}],["polyline",{points:"14 2 14 8 20 8"}],["line",{x1:"16",x2:"8",y1:"13",y2:"13"}],["line",{x1:"16",x2:"8",y1:"17",y2:"17"}],["line",{x1:"10",x2:"8",y1:"9",y2:"9"}]];return l.$$set=a=>{o(1,e=i(i({},e),c(a))),"$$scope"in a&&o(3,n=a.$$scope)},e=c(e),[t,e,s,n]}class H extends ${constructor(e){super(),h(this,e,k,j,u,{})}}export{H as F};

View File

@@ -0,0 +1 @@
import{s as u,a as r,e as c,b as p,u as m,g as d,d as h}from"./DHedsUi_.js";import{S as g,i as $,k as M,l as f,m as _,n as b,o as N,p as v}from"./G8UKEBBn.js";import{I as F,g as I,a as S}from"./C1tDbyYL.js";function j(l){let e;const n=l[2].default,a=p(n,l,l[3],null);return{c(){a&&a.c()},l(s){a&&a.l(s)},m(s,t){a&&a.m(s,t),e=!0},p(s,t){a&&a.p&&(!e||t&8)&&m(a,n,s,s[3],e?h(n,s[3],t,null):d(s[3]),null)},i(s){e||(_(a,s),e=!0)},o(s){f(a,s),e=!1},d(s){a&&a.d(s)}}}function k(l){let e,n;const a=[{name:"file-spreadsheet"},l[1],{iconNode:l[0]}];let s={$$slots:{default:[j]},$$scope:{ctx:l}};for(let t=0;t<a.length;t+=1)s=r(s,a[t]);return e=new F({props:s}),{c(){v(e.$$.fragment)},l(t){N(e.$$.fragment,t)},m(t,o){b(e,t,o),n=!0},p(t,[o]){const i=o&3?I(a,[a[0],o&2&&S(t[1]),o&1&&{iconNode:t[0]}]):{};o&8&&(i.$$scope={dirty:o,ctx:t}),e.$set(i)},i(t){n||(_(e.$$.fragment,t),n=!0)},o(t){f(e.$$.fragment,t),n=!1},d(t){M(e,t)}}}function q(l,e,n){let{$$slots:a={},$$scope:s}=e;const t=[["path",{d:"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"}],["polyline",{points:"14 2 14 8 20 8"}],["path",{d:"M8 13h2"}],["path",{d:"M8 17h2"}],["path",{d:"M14 13h2"}],["path",{d:"M14 17h2"}]];return l.$$set=o=>{n(1,e=r(r({},e),c(o))),"$$scope"in o&&n(3,s=o.$$scope)},e=c(e),[t,e,a,s]}class L extends g{constructor(e){super(),$(this,e,q,k,u,{})}}export{L as F};

View File

@@ -0,0 +1 @@
import{s as _,a as r,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as f,m as u,n as N,o as I,p as P}from"./G8UKEBBn.js";import{I as S,g as j,a as k}from"./C1tDbyYL.js";function q(l){let e;const o=l[2].default,s=m(o,l,l[3],null);return{c(){s&&s.c()},l(n){s&&s.l(n)},m(n,t){s&&s.m(n,t),e=!0},p(n,t){s&&s.p&&(!e||t&8)&&p(s,o,n,n[3],e?g(o,n[3],t,null):d(n[3]),null)},i(n){e||(u(s,n),e=!0)},o(n){f(s,n),e=!1},d(n){s&&s.d(n)}}}function v(l){let e,o;const s=[{name:"pencil"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[q]},$$scope:{ctx:l}};for(let t=0;t<s.length;t+=1)n=r(n,s[t]);return e=new S({props:n}),{c(){P(e.$$.fragment)},l(t){I(e.$$.fragment,t)},m(t,a){N(e,t,a),o=!0},p(t,[a]){const c=a&3?j(s,[s[0],a&2&&k(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(c.$$scope={dirty:a,ctx:t}),e.$set(c)},i(t){o||(u(e.$$.fragment,t),o=!0)},o(t){f(e.$$.fragment,t),o=!1},d(t){b(e,t)}}}function C(l,e,o){let{$$slots:s={},$$scope:n}=e;const t=[["path",{d:"M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"}],["path",{d:"m15 5 4 4"}]];return l.$$set=a=>{o(1,e=r(r({},e),i(a))),"$$scope"in a&&o(3,n=a.$$scope)},e=i(e),[t,e,s,n]}class z extends ${constructor(e){super(),h(this,e,C,v,_,{})}}export{z as P};

View File

@@ -0,0 +1 @@
import{s as _,a as r,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as f,m as u,n as N,o as I,p as S}from"./G8UKEBBn.js";import{I as j,g as k,a as q}from"./C1tDbyYL.js";function v(l){let e;const o=l[2].default,s=m(o,l,l[3],null);return{c(){s&&s.c()},l(n){s&&s.l(n)},m(n,t){s&&s.m(n,t),e=!0},p(n,t){s&&s.p&&(!e||t&8)&&p(s,o,n,n[3],e?g(o,n[3],t,null):d(n[3]),null)},i(n){e||(u(s,n),e=!0)},o(n){f(s,n),e=!1},d(n){s&&s.d(n)}}}function C(l){let e,o;const s=[{name:"x"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[v]},$$scope:{ctx:l}};for(let t=0;t<s.length;t+=1)n=r(n,s[t]);return e=new j({props:n}),{c(){S(e.$$.fragment)},l(t){I(e.$$.fragment,t)},m(t,a){N(e,t,a),o=!0},p(t,[a]){const c=a&3?k(s,[s[0],a&2&&q(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(c.$$scope={dirty:a,ctx:t}),e.$set(c)},i(t){o||(u(e.$$.fragment,t),o=!0)},o(t){f(e.$$.fragment,t),o=!1},d(t){b(e,t)}}}function M(l,e,o){let{$$slots:s={},$$scope:n}=e;const t=[["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]];return l.$$set=a=>{o(1,e=r(r({},e),i(a))),"$$scope"in a&&o(3,n=a.$$scope)},e=i(e),[t,e,s,n]}class B extends ${constructor(e){super(),h(this,e,M,C,_,{})}}export{B as X};

View File

@@ -0,0 +1 @@
import{s as f,a as i,e as c,b as m,u as p,g as d,d as h}from"./DHedsUi_.js";import{S as g,i as $,k as M,l as u,m as _,n as b,o as v,p as N}from"./G8UKEBBn.js";import{I as B,g as I,a as S}from"./C1tDbyYL.js";function j(l){let e;const s=l[2].default,n=m(s,l,l[3],null);return{c(){n&&n.c()},l(a){n&&n.l(a)},m(a,t){n&&n.m(a,t),e=!0},p(a,t){n&&n.p&&(!e||t&8)&&p(n,s,a,a[3],e?h(s,a[3],t,null):d(a[3]),null)},i(a){e||(_(n,a),e=!0)},o(a){u(n,a),e=!1},d(a){n&&n.d(a)}}}function k(l){let e,s;const n=[{name:"building-2"},l[1],{iconNode:l[0]}];let a={$$slots:{default:[j]},$$scope:{ctx:l}};for(let t=0;t<n.length;t+=1)a=i(a,n[t]);return e=new B({props:a}),{c(){N(e.$$.fragment)},l(t){v(e.$$.fragment,t)},m(t,o){b(e,t,o),s=!0},p(t,[o]){const r=o&3?I(n,[n[0],o&2&&S(t[1]),o&1&&{iconNode:t[0]}]):{};o&8&&(r.$$scope={dirty:o,ctx:t}),e.$set(r)},i(t){s||(_(e.$$.fragment,t),s=!0)},o(t){u(e.$$.fragment,t),s=!1},d(t){M(e,t)}}}function q(l,e,s){let{$$slots:n={},$$scope:a}=e;const t=[["path",{d:"M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"}],["path",{d:"M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"}],["path",{d:"M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"}],["path",{d:"M10 6h4"}],["path",{d:"M10 10h4"}],["path",{d:"M10 14h4"}],["path",{d:"M10 18h4"}]];return l.$$set=o=>{s(1,e=i(i({},e),c(o))),"$$scope"in o&&s(3,a=o.$$scope)},e=c(e),[t,e,n,a]}class Z extends g{constructor(e){super(),$(this,e,q,k,f,{})}}export{Z as B};

View File

@@ -0,0 +1 @@
function x(){}function k(t,n){for(const e in n)t[e]=n[e];return t}function w(t){return t()}function z(){return Object.create(null)}function v(t){t.forEach(w)}function S(t){return typeof t=="function"}function A(t,n){return t!=t?n==n:t!==n||t&&typeof t=="object"||typeof t=="function"}function F(t){return Object.keys(t).length===0}function d(t,...n){if(t==null){for(const o of n)o(void 0);return x}const e=t.subscribe(...n);return e.unsubscribe?()=>e.unsubscribe():e}function M(t){let n;return d(t,e=>n=e)(),n}function P(t,n,e){t.$$.on_destroy.push(d(n,e))}function U(t,n,e,o){if(t){const u=p(t,n,e,o);return t[0](u)}}function p(t,n,e,o){return t[1]&&o?k(e.ctx.slice(),t[1](o(n))):e.ctx}function B(t,n,e,o){return t[2],n.dirty}function C(t,n,e,o,u,m){if(u){const y=p(n,e,o,m);t.p(y,u)}}function D(t){if(t.ctx.length>32){const n=[],e=t.ctx.length/32;for(let o=0;o<e;o++)n[o]=-1;return n}return-1}function G(t){const n={};for(const e in t)e[0]!=="$"&&(n[e]=t[e]);return n}function H(t,n){const e={};n=new Set(n);for(const o in t)!n.has(o)&&o[0]!=="$"&&(e[o]=t[o]);return e}let a;function i(t){a=t}function b(){if(!a)throw new Error("Function called outside component initialization");return a}function I(t){b().$$.on_mount.push(t)}function J(t){b().$$.after_update.push(t)}const s=[],_=[];let r=[];const h=[],g=Promise.resolve();let f=!1;function j(){f||(f=!0,g.then(O))}function K(){return j(),g}function E(t){r.push(t)}const l=new Set;let c=0;function O(){if(c!==0)return;const t=a;do{try{for(;c<s.length;){const n=s[c];c++,i(n),q(n.$$)}}catch(n){throw s.length=0,c=0,n}for(i(null),s.length=0,c=0;_.length;)_.pop()();for(let n=0;n<r.length;n+=1){const e=r[n];l.has(e)||(l.add(e),e())}r.length=0}while(s.length);for(;h.length;)h.pop()();f=!1,l.clear(),i(t)}function q(t){if(t.fragment!==null){t.update(),v(t.before_update);const n=t.dirty;t.dirty=[-1],t.fragment&&t.fragment.p(t.ctx,n),t.after_update.forEach(E)}}function L(t){const n=[],e=[];r.forEach(o=>t.indexOf(o)===-1?n.push(o):e.push(o)),e.forEach(o=>o()),r=n}export{j as A,k as a,U as b,P as c,B as d,G as e,E as f,D as g,M as h,_ as i,J as j,H as k,S as l,F as m,x as n,I as o,a as p,z as q,v as r,A as s,K as t,C as u,O as v,i as w,L as x,w as y,s as z};

View File

@@ -0,0 +1 @@
import{s as u,a as r,e as i,b as m,u as d,g as p,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as _,m as f,n as C,o as N,p as I}from"./G8UKEBBn.js";import{I as S,g as j,a as k}from"./C1tDbyYL.js";function q(l){let e;const o=l[2].default,s=m(o,l,l[3],null);return{c(){s&&s.c()},l(n){s&&s.l(n)},m(n,t){s&&s.m(n,t),e=!0},p(n,t){s&&s.p&&(!e||t&8)&&d(s,o,n,n[3],e?g(o,n[3],t,null):p(n[3]),null)},i(n){e||(f(s,n),e=!0)},o(n){_(s,n),e=!1},d(n){s&&s.d(n)}}}function v(l){let e,o;const s=[{name:"credit-card"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[q]},$$scope:{ctx:l}};for(let t=0;t<s.length;t+=1)n=r(n,s[t]);return e=new S({props:n}),{c(){I(e.$$.fragment)},l(t){N(e.$$.fragment,t)},m(t,a){C(e,t,a),o=!0},p(t,[a]){const c=a&3?j(s,[s[0],a&2&&k(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(c.$$scope={dirty:a,ctx:t}),e.$set(c)},i(t){o||(f(e.$$.fragment,t),o=!0)},o(t){_(e.$$.fragment,t),o=!1},d(t){b(e,t)}}}function w(l,e,o){let{$$slots:s={},$$scope:n}=e;const t=[["rect",{width:"20",height:"14",x:"2",y:"5",rx:"2"}],["line",{x1:"2",x2:"22",y1:"10",y2:"10"}]];return l.$$set=a=>{o(1,e=r(r({},e),i(a))),"$$scope"in a&&o(3,n=a.$$scope)},e=i(e),[t,e,s,n]}class B extends ${constructor(e){super(),h(this,e,w,v,u,{})}}export{B as C};

View File

@@ -0,0 +1 @@
import{s as u,a as r,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as h,i as $,k as M,l as f,m as _,n as b,o as v,p as N}from"./G8UKEBBn.js";import{I,g as L,a as R}from"./C1tDbyYL.js";function S(l){let e;const a=l[2].default,n=m(a,l,l[3],null);return{c(){n&&n.c()},l(s){n&&n.l(s)},m(s,t){n&&n.m(s,t),e=!0},p(s,t){n&&n.p&&(!e||t&8)&&p(n,a,s,s[3],e?g(a,s[3],t,null):d(s[3]),null)},i(s){e||(_(n,s),e=!0)},o(s){f(n,s),e=!1},d(s){n&&n.d(s)}}}function j(l){let e,a;const n=[{name:"refresh-cw"},l[1],{iconNode:l[0]}];let s={$$slots:{default:[S]},$$scope:{ctx:l}};for(let t=0;t<n.length;t+=1)s=r(s,n[t]);return e=new I({props:s}),{c(){N(e.$$.fragment)},l(t){v(e.$$.fragment,t)},m(t,o){b(e,t,o),a=!0},p(t,[o]){const c=o&3?L(n,[n[0],o&2&&R(t[1]),o&1&&{iconNode:t[0]}]):{};o&8&&(c.$$scope={dirty:o,ctx:t}),e.$set(c)},i(t){a||(_(e.$$.fragment,t),a=!0)},o(t){f(e.$$.fragment,t),a=!1},d(t){M(e,t)}}}function k(l,e,a){let{$$slots:n={},$$scope:s}=e;const t=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{d:"M21 3v5h-5"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{d:"M8 16H3v5"}]];return l.$$set=o=>{a(1,e=r(r({},e),i(o))),"$$scope"in o&&a(3,s=o.$$scope)},e=i(e),[t,e,n,s]}class H extends h{constructor(e){super(),$(this,e,k,j,u,{})}}export{H as R};

View File

@@ -0,0 +1 @@
import{w as o}from"./oVx0hpG8.js";import{h as p}from"./DHedsUi_.js";const d=o(null),u=o(null),n="/api";async function a(e,t,r,c=!1){const i=p(u),l={};i&&(l.Authorization=i),r&&!c&&(l["Content-Type"]="application/json");const s=await fetch(`${n}${t}`,{method:e,headers:l,body:c?r:r?JSON.stringify(r):void 0,credentials:"include"});if(s.status===401){d.set(null),u.set(null),window.location.href="/login";return}if(!s.ok){const E=await s.text();throw new Error(E||`HTTP ${s.status}`)}return s.status===204?null:s.json()}const m={auth:{login:(e,t)=>a("POST","/auth/login",{email:e,password:t}),logout:()=>a("POST","/auth/logout"),register:e=>a("POST","/auth/register",e),me:()=>a("GET","/me"),updateProfile:e=>a("PUT","/me",e),updatePassword:e=>a("PUT","/me/password",e)},users:{list:()=>a("GET","/users"),delete:e=>a("DELETE",`/users/${e}`)},categories:{list:(e={})=>a("GET",`/categories?${new URLSearchParams(e)}`),create:e=>a("POST","/categories",e),update:(e,t)=>a("PUT",`/categories/${e}`,t),delete:e=>a("DELETE",`/categories/${e}`)},properties:{list:()=>a("GET","/properties"),get:e=>a("GET",`/properties/${e}`),create:e=>a("POST","/properties",e),update:(e,t)=>a("PUT",`/properties/${e}`,t),delete:e=>a("DELETE",`/properties/${e}`)},transactions:{list:(e={})=>a("GET",`/transactions?${new URLSearchParams(e)}`),create:e=>a("POST","/transactions",e),update:(e,t)=>a("PUT",`/transactions/${e}`,t),delete:e=>a("DELETE",`/transactions/${e}`),split:(e,t)=>a("POST",`/transactions/${e}/split`,t),summary:(e={})=>a("GET",`/transactions/summary?${new URLSearchParams(e)}`),monthly:(e={})=>a("GET",`/transactions/monthly?${new URLSearchParams(e)}`),categories:(e={})=>a("GET",`/transactions/categories?${new URLSearchParams(e)}`)},calendar:{list:(e={})=>a("GET",`/calendar?${new URLSearchParams(e)}`),createEvent:e=>a("POST","/calendar",e),updateEvent:(e,t)=>a("PUT",`/calendar/${e}`,t),deleteEvent:e=>a("DELETE",`/calendar/${e}`),stats:(e={})=>a("GET",`/calendar/stats?${new URLSearchParams(e)}`),sync:e=>a("POST",`/calendar/sync/${e}`)},documents:{list:(e={})=>a("GET",`/documents?${new URLSearchParams(e)}`),upload:e=>a("POST","/documents",e,!0),download:e=>`${n}/documents/${e}/download`,delete:e=>a("DELETE",`/documents/${e}`),exportUrl:(e={})=>`${n}/documents/export?${new URLSearchParams(e)}`},loans:{list:(e={})=>a("GET",`/loans?${new URLSearchParams(e)}`),createWithData:e=>a("POST","/loans/create",e),create:e=>a("POST","/loans",e),delete:e=>a("DELETE",`/loans/${e}`),lines:(e,t={})=>a("GET",`/loans/${e}/lines?${new URLSearchParams(t)}`),annualSummary:(e,t={})=>a("GET",`/loans/${e}/summary?${new URLSearchParams(t)}`),uploadLines:(e,t)=>a("POST",`/loans/${e}/lines`,t),splitByDate:(e,t)=>a("GET",`/loans/${e}/split?date=${t}`),splitForDate:e=>a("GET",`/loans/split?date=${e}`)},fiscal:{summary:(e={})=>a("GET",`/fiscal/summary?${new URLSearchParams(e)}`),exportUrl:(e={})=>`${n}/fiscal/export?${new URLSearchParams(e)}`}},$=o([]),T=o(null);async function h(){const e=await m.properties.list();$.set(e||[]),!p(T)&&(e==null?void 0:e.length)>0&&T.set(e[0])}export{m as a,u as b,d as c,h as l,$ as p};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{s as u,a as r,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as _,m as f,n as N,o as y,p as A}from"./G8UKEBBn.js";import{I,g as S,a as j}from"./C1tDbyYL.js";function k(l){let e;const o=l[2].default,s=m(o,l,l[3],null);return{c(){s&&s.c()},l(n){s&&s.l(n)},m(n,t){s&&s.m(n,t),e=!0},p(n,t){s&&s.p&&(!e||t&8)&&p(s,o,n,n[3],e?g(o,n[3],t,null):d(n[3]),null)},i(n){e||(f(s,n),e=!0)},o(n){_(s,n),e=!1},d(n){s&&s.d(n)}}}function q(l){let e,o;const s=[{name:"alert-circle"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[k]},$$scope:{ctx:l}};for(let t=0;t<s.length;t+=1)n=r(n,s[t]);return e=new I({props:n}),{c(){A(e.$$.fragment)},l(t){y(e.$$.fragment,t)},m(t,a){N(e,t,a),o=!0},p(t,[a]){const c=a&3?S(s,[s[0],a&2&&j(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(c.$$scope={dirty:a,ctx:t}),e.$set(c)},i(t){o||(f(e.$$.fragment,t),o=!0)},o(t){_(e.$$.fragment,t),o=!1},d(t){b(e,t)}}}function v(l,e,o){let{$$slots:s={},$$scope:n}=e;const t=[["circle",{cx:"12",cy:"12",r:"10"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16"}]];return l.$$set=a=>{o(1,e=r(r({},e),i(a))),"$$scope"in a&&o(3,n=a.$$scope)},e=i(e),[t,e,s,n]}class D extends ${constructor(e){super(),h(this,e,v,q,u,{})}}export{D as A};

View File

@@ -0,0 +1 @@
import{n as f,s as l}from"./DHedsUi_.js";const e=[];function h(n,b=f){let i;const o=new Set;function r(t){if(l(n,t)&&(n=t,i)){const c=!e.length;for(const s of o)s[1](),e.push(s,n);if(c){for(let s=0;s<e.length;s+=2)e[s][0](e[s+1]);e.length=0}}}function u(t){r(t(n))}function p(t,c=f){const s=[t,c];return o.add(s),o.size===1&&(i=b(r,u)||f),t(n),()=>{o.delete(s),o.size===0&&i&&(i(),i=null)}}return{set:r,update:u,subscribe:p}}export{h as w};

View File

@@ -0,0 +1 @@
import{s as _,a as c,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as f,m as u,n as k,o as C,p as N}from"./G8UKEBBn.js";import{I,g as S,a as j}from"./C1tDbyYL.js";function q(l){let e;const o=l[2].default,s=m(o,l,l[3],null);return{c(){s&&s.c()},l(n){s&&s.l(n)},m(n,t){s&&s.m(n,t),e=!0},p(n,t){s&&s.p&&(!e||t&8)&&p(s,o,n,n[3],e?g(o,n[3],t,null):d(n[3]),null)},i(n){e||(u(s,n),e=!0)},o(n){f(s,n),e=!1},d(n){s&&s.d(n)}}}function v(l){let e,o;const s=[{name:"check"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[q]},$$scope:{ctx:l}};for(let t=0;t<s.length;t+=1)n=c(n,s[t]);return e=new I({props:n}),{c(){N(e.$$.fragment)},l(t){C(e.$$.fragment,t)},m(t,a){k(e,t,a),o=!0},p(t,[a]){const r=a&3?S(s,[s[0],a&2&&j(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(r.$$scope={dirty:a,ctx:t}),e.$set(r)},i(t){o||(u(e.$$.fragment,t),o=!0)},o(t){f(e.$$.fragment,t),o=!1},d(t){b(e,t)}}}function M(l,e,o){let{$$slots:s={},$$scope:n}=e;const t=[["path",{d:"M20 6 9 17l-5-5"}]];return l.$$set=a=>{o(1,e=c(c({},e),i(a))),"$$scope"in a&&o(3,n=a.$$scope)},e=i(e),[t,e,s,n]}class D extends ${constructor(e){super(),h(this,e,M,v,_,{})}}export{D as C};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/6cgBTYw-.js";export{o as load_css,r as start};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{s as x,n as u,c as S}from"../chunks/DHedsUi_.js";import{S as j,i as q,d as c,s as h,a as _,b as d,c as v,e as g,f as b,g as y,h as E,t as $,j as C}from"../chunks/G8UKEBBn.js";import{p as H}from"../chunks/CEKnDucw.js";function P(p){var f;let a,s=p[0].status+"",r,o,n,i=((f=p[0].error)==null?void 0:f.message)+"",m;return{c(){a=E("h1"),r=$(s),o=C(),n=E("p"),m=$(i)},l(e){a=v(e,"H1",{});var t=g(a);r=b(t,s),t.forEach(c),o=y(e),n=v(e,"P",{});var l=g(n);m=b(l,i),l.forEach(c)},m(e,t){_(e,a,t),d(a,r),_(e,o,t),_(e,n,t),d(n,m)},p(e,[t]){var l;t&1&&s!==(s=e[0].status+"")&&h(r,s),t&1&&i!==(i=((l=e[0].error)==null?void 0:l.message)+"")&&h(m,i)},i:u,o:u,d(e){e&&(c(a),c(o),c(n))}}}function k(p,a,s){let r;return S(p,H,o=>s(0,r=o)),[r]}class B extends j{constructor(a){super(),q(this,a,k,P,x,{})}}export{B as component};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{s as M,n as D,r as q}from"../chunks/DHedsUi_.js";import{S as z,i as B,d as v,z as w,a as V,b as c,r as x,u as d,c as h,e as I,v as N,g as C,h as m,j as E,s as H,f as L,t as O}from"../chunks/G8UKEBBn.js";import{g as A}from"../chunks/6cgBTYw-.js";import{a as F,b as G,c as J}from"../chunks/DjUyxgK6.js";function S(n){let s,e;return{c(){s=m("p"),e=O(n[2]),this.h()},l(t){s=h(t,"P",{class:!0});var l=I(s);e=L(l,n[2]),l.forEach(v),this.h()},h(){d(s,"class","text-red-500 text-sm mb-4")},m(t,l){V(t,s,l),c(s,e)},p(t,l){l&4&&H(e,t[2])},d(t){t&&v(s)}}}function K(n){let s,e,t,l="🏠 Mes Locations",g,b,i,u,k,r,T,p,U="Se connecter",P,j,a=n[2]&&S(n);return{c(){s=m("div"),e=m("div"),t=m("h1"),t.textContent=l,g=E(),a&&a.c(),b=E(),i=m("div"),u=m("input"),k=E(),r=m("input"),T=E(),p=m("button"),p.textContent=U,this.h()},l(o){s=h(o,"DIV",{class:!0});var f=I(s);e=h(f,"DIV",{class:!0});var y=I(e);t=h(y,"H1",{class:!0,"data-svelte-h":!0}),N(t)!=="svelte-b0jrt"&&(t.textContent=l),g=C(y),a&&a.l(y),b=C(y),i=h(y,"DIV",{class:!0});var _=I(i);u=h(_,"INPUT",{type:!0,placeholder:!0,class:!0}),k=C(_),r=h(_,"INPUT",{type:!0,placeholder:!0,class:!0}),T=C(_),p=h(_,"BUTTON",{class:!0,"data-svelte-h":!0}),N(p)!=="svelte-frpi80"&&(p.textContent=U),_.forEach(v),y.forEach(v),f.forEach(v),this.h()},h(){d(t,"class","text-xl font-semibold text-gray-900 dark:text-white mb-6"),d(u,"type","email"),d(u,"placeholder","Email"),d(u,"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"),d(r,"type","password"),d(r,"placeholder","Mot de passe"),d(r,"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"),d(p,"class","w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"),d(i,"class","space-y-4"),d(e,"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"),d(s,"class","min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950")},m(o,f){V(o,s,f),c(s,e),c(e,t),c(e,g),a&&a.m(e,null),c(e,b),c(e,i),c(i,u),w(u,n[0]),c(i,k),c(i,r),w(r,n[1]),c(i,T),c(i,p),P||(j=[x(u,"input",n[4]),x(r,"input",n[5]),x(r,"keydown",n[6]),x(p,"click",n[3])],P=!0)},p(o,[f]){o[2]?a?a.p(o,f):(a=S(o),a.c(),a.m(e,b)):a&&(a.d(1),a=null),f&1&&u.value!==o[0]&&w(u,o[0]),f&2&&r.value!==o[1]&&w(r,o[1])},i:D,o:D,d(o){o&&v(s),a&&a.d(),P=!1,q(j)}}}function Q(n,s,e){let t="",l="",g="";async function b(){e(2,g="");try{const r=await F.auth.login(t,l);G.set(r.token),J.set(r.user),A("/")}catch(r){e(2,g=r.message)}}function i(){t=this.value,e(0,t)}function u(){l=this.value,e(1,l)}return[t,l,g,b,i,u,r=>r.key==="Enter"&&b()]}class Z extends z{constructor(s){super(),B(this,s,Q,K,M,{})}}export{Z as component};

View File

@@ -0,0 +1 @@
{"version":"1775902055626"}

36
web/build/index.html Normal file
View File

@@ -0,0 +1,36 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rental Manager</title>
<link href="/_app/immutable/entry/start.C3I7xU3P.js" rel="modulepreload">
<link href="/_app/immutable/chunks/6cgBTYw-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DHedsUi_.js" rel="modulepreload">
<link href="/_app/immutable/chunks/oVx0hpG8.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.CdLU2Zc4.js" rel="modulepreload">
<link href="/_app/immutable/chunks/G8UKEBBn.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_80oik8 = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.C3I7xU3P.js"),
import("/_app/immutable/entry/app.CdLU2Zc4.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

13
web/embed.go Normal file
View File

@@ -0,0 +1,13 @@
package web
import (
"embed"
"io/fs"
)
//go:embed all:build
var StaticFiles embed.FS
func Assets() (fs.FS, error) {
return fs.Sub(StaticFiles, "build")
}