up
This commit is contained in:
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal 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
24
Dockerfile
Normal 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
133
Makefile
Normal 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
125
README.md
@@ -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
10
build.bat
Normal 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
219
cmd/server/main.go
Normal 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
17
docker-compose.yml
Normal 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
2522
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "rental-manager-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.5.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"svelte": "^4.2.19",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^5.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.0",
|
||||
"lucide-svelte": "^0.303.0"
|
||||
},
|
||||
"overrides": {
|
||||
"svelte": "^4.2.19"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
8
frontend/src/app.css
Normal file
8
frontend/src/app.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html { font-family: 'Inter', system-ui, sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
}
|
||||
13
frontend/src/app.html
Normal file
13
frontend/src/app.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Rental Manager</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
112
frontend/src/lib/stores/api.js
Normal file
112
frontend/src/lib/stores/api.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
|
||||
export const currentUser = writable(null);
|
||||
export const authToken = writable(null);
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
async function request(method, path, body, isFormData = false) {
|
||||
const token = get(authToken);
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = token;
|
||||
if (body && !isFormData) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: isFormData ? body : body ? JSON.stringify(body) : undefined,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
currentUser.set(null);
|
||||
authToken.set(null);
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
login: (email, password) => request('POST', '/auth/login', { email, password }),
|
||||
logout: () => request('POST', '/auth/logout'),
|
||||
register: (data) => request('POST', '/auth/register', data),
|
||||
me: () => request('GET', '/me'),
|
||||
updateProfile: (data) => request('PUT', '/me', data),
|
||||
updatePassword: (data) => request('PUT', '/me/password', data),
|
||||
},
|
||||
users: {
|
||||
list: () => request('GET', '/users'),
|
||||
delete: (id) => request('DELETE', `/users/${id}`),
|
||||
},
|
||||
categories: {
|
||||
list: (params = {}) => request('GET', `/categories?${new URLSearchParams(params)}`),
|
||||
create: (data) => request('POST', '/categories', data),
|
||||
update: (id, data) => request('PUT', `/categories/${id}`, data),
|
||||
delete: (id) => request('DELETE', `/categories/${id}`),
|
||||
},
|
||||
properties: {
|
||||
list: () => request('GET', '/properties'),
|
||||
get: (id) => request('GET', `/properties/${id}`),
|
||||
create: (data) => request('POST', '/properties', data),
|
||||
update: (id, data) => request('PUT', `/properties/${id}`, data),
|
||||
delete: (id) => request('DELETE', `/properties/${id}`),
|
||||
},
|
||||
transactions: {
|
||||
list: (params = {}) => request('GET', `/transactions?${new URLSearchParams(params)}`),
|
||||
create: (data) => request('POST', '/transactions', data),
|
||||
update: (id, data) => request('PUT', `/transactions/${id}`, data),
|
||||
delete: (id) => request('DELETE', `/transactions/${id}`),
|
||||
split: (id, data) => request('POST', `/transactions/${id}/split`, data),
|
||||
summary: (params = {}) => request('GET', `/transactions/summary?${new URLSearchParams(params)}`),
|
||||
monthly: (params = {}) => request('GET', `/transactions/monthly?${new URLSearchParams(params)}`),
|
||||
categories: (params = {}) => request('GET', `/transactions/categories?${new URLSearchParams(params)}`),
|
||||
},
|
||||
calendar: {
|
||||
list: (params = {}) => request('GET', `/calendar?${new URLSearchParams(params)}`),
|
||||
createEvent: (data) => request('POST', '/calendar', data),
|
||||
updateEvent: (id, data) => request('PUT', `/calendar/${id}`, data),
|
||||
deleteEvent: (id) => request('DELETE', `/calendar/${id}`),
|
||||
stats: (params = {}) => request('GET', `/calendar/stats?${new URLSearchParams(params)}`),
|
||||
sync: (propertyId) => request('POST', `/calendar/sync/${propertyId}`),
|
||||
},
|
||||
documents: {
|
||||
list: (params = {}) => request('GET', `/documents?${new URLSearchParams(params)}`),
|
||||
upload: (formData) => request('POST', '/documents', formData, true),
|
||||
download: (id) => `${BASE}/documents/${id}/download`,
|
||||
delete: (id) => request('DELETE', `/documents/${id}`),
|
||||
exportUrl: (params = {}) => `${BASE}/documents/export?${new URLSearchParams(params)}`,
|
||||
},
|
||||
|
||||
loans: {
|
||||
list: (params = {}) => request('GET', `/loans?${new URLSearchParams(params)}`),
|
||||
createWithData: (data) => request('POST', '/loans/create', data),
|
||||
create: (data) => request('POST', '/loans', data),
|
||||
delete: (id) => request('DELETE', `/loans/${id}`),
|
||||
lines: (id, params = {}) => request('GET', `/loans/${id}/lines?${new URLSearchParams(params)}`),
|
||||
annualSummary: (id, params = {}) => request('GET', `/loans/${id}/summary?${new URLSearchParams(params)}`),
|
||||
uploadLines: (id, lines) => request('POST', `/loans/${id}/lines`, lines),
|
||||
splitByDate: (id, date) => request('GET', `/loans/${id}/split?date=${date}`),
|
||||
splitForDate: (date) => request('GET', `/loans/split?date=${date}`),
|
||||
},
|
||||
fiscal: {
|
||||
summary: (params = {}) => request('GET', `/fiscal/summary?${new URLSearchParams(params)}`),
|
||||
exportUrl: (params = {}) => `${BASE}/fiscal/export?${new URLSearchParams(params)}`,
|
||||
},
|
||||
};
|
||||
|
||||
export const properties = writable([]);
|
||||
export const selectedProperty = writable(null);
|
||||
|
||||
export async function loadProperties() {
|
||||
const props = await api.properties.list();
|
||||
properties.set(props || []);
|
||||
const sel = get(selectedProperty);
|
||||
if (!sel && props?.length > 0) selectedProperty.set(props[0]);
|
||||
}
|
||||
2
frontend/src/routes/+layout.js
Normal file
2
frontend/src/routes/+layout.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
133
frontend/src/routes/+layout.svelte
Normal file
133
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { currentUser, authToken, api } from '$lib/stores/api.js';
|
||||
import {
|
||||
LayoutDashboard, Building2, CreditCard, Upload,
|
||||
CalendarDays, FileText, Download, LogOut,
|
||||
User, Users, Tag, PowerOff
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let ready = false;
|
||||
|
||||
const nav = [
|
||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/properties', label: 'Biens', icon: Building2 },
|
||||
{ href: '/transactions', label: 'Transactions', icon: CreditCard },
|
||||
{ href: '/categories', label: 'Catégories', icon: Tag },
|
||||
{ href: '/calendar', label: 'Calendrier', icon: CalendarDays },
|
||||
{ href: '/documents', label: 'Documents', icon: FileText },
|
||||
{ href: '/fiscal', label: 'Export fiscal', icon: Download },
|
||||
{ href: '/loans', label: 'Prêts', icon: Building2 },
|
||||
{ href: '/import', label: 'Import bancaire',icon: Upload },
|
||||
];
|
||||
|
||||
const navBottom = [
|
||||
{ href: '/users', label: 'Utilisateurs', icon: Users },
|
||||
{ href: '/profile', label: 'Mon profil', icon: User },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
// Ne pas tenter de restaurer la session sur la page login
|
||||
if ($page.url.pathname === '/login') {
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$currentUser) {
|
||||
try {
|
||||
const user = await api.auth.me();
|
||||
if (user) {
|
||||
currentUser.set(user);
|
||||
} else {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
}
|
||||
ready = true;
|
||||
});
|
||||
|
||||
// Quand on navigue vers /login, marquer ready immédiatement
|
||||
$: if ($page.url.pathname === '/login') {
|
||||
ready = true;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.auth.logout();
|
||||
currentUser.set(null);
|
||||
authToken.set(null);
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
if (!confirm('Arrêter l\'application ?')) return;
|
||||
try { await fetch('/api/shutdown', { method: 'POST', credentials: 'include' }); } catch (_) {}
|
||||
}
|
||||
|
||||
function active(href) {
|
||||
return $page.url.pathname === href;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !ready}
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center">
|
||||
<div class="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else if $currentUser}
|
||||
<div class="flex h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<aside class="hidden md:flex flex-col w-60 bg-white dark:bg-gray-900 border-r border-gray-100 dark:border-gray-800 shrink-0">
|
||||
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<span class="text-base font-semibold text-gray-900 dark:text-white">🏠 Mes Locations</span>
|
||||
</div>
|
||||
<nav class="flex-1 px-3 py-3 space-y-0.5 overflow-y-auto">
|
||||
{#each nav as item}
|
||||
<a href={item.href}
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors
|
||||
{active(item.href)
|
||||
? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 font-medium'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-200'}">
|
||||
<svelte:component this={item.icon} size={17}/>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="px-3 py-3 border-t border-gray-100 dark:border-gray-800 space-y-0.5">
|
||||
{#each navBottom as item}
|
||||
<a href={item.href}
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors
|
||||
{active(item.href)
|
||||
? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 font-medium'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-200'}">
|
||||
<svelte:component this={item.icon} size={17}/>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
<div class="flex items-center gap-3 px-3 py-2 mt-1">
|
||||
<div class="w-7 h-7 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-xs font-semibold text-blue-700 dark:text-blue-300 shrink-0">
|
||||
{$currentUser.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 truncate flex-1">{$currentUser.name}</span>
|
||||
</div>
|
||||
<button on:click={logout}
|
||||
class="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-gray-500 dark:text-gray-400 hover:bg-red-50 dark:hover:bg-red-950/50 hover:text-red-600 dark:hover:text-red-400 transition-colors">
|
||||
<LogOut size={17}/> Déconnexion
|
||||
</button>
|
||||
<button on:click={shutdown}
|
||||
class="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-gray-500 dark:text-gray-400 hover:bg-red-50 dark:hover:bg-red-950/50 hover:text-red-600 dark:hover:text-red-400 transition-colors">
|
||||
<PowerOff size={17}/> Quitter l'application
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="flex-1 overflow-auto">
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
{:else}
|
||||
<slot/>
|
||||
{/if}
|
||||
471
frontend/src/routes/+page.svelte
Normal file
471
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,471 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api, loadProperties, properties } from '$lib/stores/api.js';
|
||||
import { TrendingUp, TrendingDown, CalendarDays, RefreshCw } from 'lucide-svelte';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
Chart.register(...registerables);
|
||||
|
||||
let summary = [];
|
||||
let recentTx = [];
|
||||
let calEvents = [];
|
||||
let monthlyData = [];
|
||||
let categoryExpenses = [];
|
||||
let loading = true;
|
||||
let categoryView = 'chart'; // 'chart' | 'table'
|
||||
|
||||
let selectedYear = new Date().getFullYear();
|
||||
let selectedProperty = '';
|
||||
let selectedMonth = '';
|
||||
const years = Array.from({ length: 4 }, (_, i) => new Date().getFullYear() - i);
|
||||
const months = [
|
||||
{ value: '', label: 'Tous les mois' },
|
||||
{ value: '1', label: 'Janvier' },
|
||||
{ value: '2', label: 'Février' },
|
||||
{ value: '3', label: 'Mars' },
|
||||
{ value: '4', label: 'Avril' },
|
||||
{ value: '5', label: 'Mai' },
|
||||
{ value: '6', label: 'Juin' },
|
||||
{ value: '7', label: 'Juillet' },
|
||||
{ value: '8', label: 'Août' },
|
||||
{ value: '9', label: 'Septembre' },
|
||||
{ value: '10', label: 'Octobre' },
|
||||
{ value: '11', label: 'Novembre' },
|
||||
{ value: '12', label: 'Décembre' },
|
||||
];
|
||||
|
||||
// Refs canvas
|
||||
let barCanvas, donutCanvas;
|
||||
let barChart, donutChart;
|
||||
|
||||
const fmt = (n) => Number(n || 0).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €';
|
||||
const fmtDate = (d) => new Date(d + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' });
|
||||
|
||||
const monthLabels = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'];
|
||||
|
||||
onMount(async () => {
|
||||
await loadProperties();
|
||||
await reload();
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
loading = true;
|
||||
const today = new Date();
|
||||
const from = new Date(today.getFullYear(), today.getMonth(), 1).toISOString().slice(0,10);
|
||||
const to = new Date(today.getFullYear(), today.getMonth()+1, 0).toISOString().slice(0,10);
|
||||
|
||||
const params = { year: selectedYear };
|
||||
if (selectedProperty) params.property_id = selectedProperty;
|
||||
if (selectedMonth) params.month = selectedMonth;
|
||||
|
||||
const txListParams = { year: selectedYear };
|
||||
if (selectedProperty) txListParams.property_id = selectedProperty;
|
||||
if (selectedMonth) txListParams.month = selectedMonth;
|
||||
|
||||
[summary, recentTx, calEvents, monthlyData, categoryExpenses] = await Promise.all([
|
||||
api.transactions.summary(params),
|
||||
api.transactions.list({ ...txListParams }),
|
||||
api.calendar.list({ from, to, ...(selectedProperty ? { property_id: selectedProperty } : {}) }),
|
||||
api.transactions.monthly({ year: selectedYear, ...(selectedProperty ? { property_id: selectedProperty } : {}) }),
|
||||
api.transactions.categories({ ...params, type: 'expense' }),
|
||||
]);
|
||||
|
||||
summary = summary || [];
|
||||
recentTx = (recentTx || []).slice(0, 8);
|
||||
calEvents = calEvents || [];
|
||||
monthlyData = monthlyData || [];
|
||||
categoryExpenses = categoryExpenses || [];
|
||||
|
||||
loading = false;
|
||||
await renderCharts();
|
||||
}
|
||||
|
||||
async function renderCharts() {
|
||||
// Petit délai pour que les canvas soient dans le DOM
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
// ── Graphique barres : revenus vs dépenses par mois ──────────────────────
|
||||
if (barCanvas) {
|
||||
if (barChart) barChart.destroy();
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const gridColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)';
|
||||
const textColor = isDark ? '#9ca3af' : '#6b7280';
|
||||
|
||||
barChart = new Chart(barCanvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: monthLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Revenus',
|
||||
data: monthlyData.map(m => m.income),
|
||||
backgroundColor: 'rgba(34,197,94,0.75)',
|
||||
borderRadius: 5,
|
||||
borderSkipped: false,
|
||||
},
|
||||
{
|
||||
label: 'Dépenses',
|
||||
data: monthlyData.map(m => m.expense),
|
||||
backgroundColor: 'rgba(239,68,68,0.7)',
|
||||
borderRadius: 5,
|
||||
borderSkipped: false,
|
||||
},
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: textColor, boxWidth: 12, font: { size: 12 } }
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => ` ${ctx.dataset.label} : ${Number(ctx.raw).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: gridColor },
|
||||
ticks: { color: textColor, font: { size: 11 } }
|
||||
},
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 11 },
|
||||
callback: (v) => v.toLocaleString('fr-FR') + ' €'
|
||||
},
|
||||
beginAtZero: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Donut : répartition dépenses par catégorie ────────────────────────────
|
||||
if (categoryView === 'chart' && donutCanvas && categoryExpenses.length > 0) {
|
||||
if (donutChart) donutChart.destroy();
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const textColor = isDark ? '#9ca3af' : '#6b7280';
|
||||
|
||||
donutChart = new Chart(donutCanvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: categoryExpenses.map(c => c.category),
|
||||
datasets: [{
|
||||
data: categoryExpenses.map(c => c.amount),
|
||||
backgroundColor: palette.slice(0, categoryExpenses.length),
|
||||
borderWidth: 2,
|
||||
borderColor: isDark ? '#111827' : '#ffffff',
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
color: textColor,
|
||||
boxWidth: 10,
|
||||
font: { size: 11 },
|
||||
padding: 10,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
const total = ctx.dataset.data.reduce((a,b) => a+b, 0);
|
||||
const pct = total > 0 ? ((ctx.raw / total) * 100).toFixed(1) : 0;
|
||||
return ` ${Number(ctx.raw).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} € (${pct}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const palette = [
|
||||
'#3b82f6','#ef4444','#f59e0b','#10b981','#8b5cf6',
|
||||
'#ec4899','#06b6d4','#84cc16','#f97316','#6366f1',
|
||||
];
|
||||
|
||||
$: totalIncome = summary.reduce((s, x) => s + (x.total_income || 0), 0);
|
||||
$: totalExpense = summary.reduce((s, x) => s + (x.total_expense || 0), 0);
|
||||
$: totalBalance = totalIncome - totalExpense;
|
||||
|
||||
$: totalCatExpense = categoryExpenses.reduce((s, c) => s + (c.amount || 0), 0);
|
||||
|
||||
// Calcul taux occupation mois en cours (Airbnb)
|
||||
$: occupancyRate = (() => {
|
||||
const today = new Date();
|
||||
const days = new Date(today.getFullYear(), today.getMonth()+1, 0).getDate();
|
||||
let occupied = 0;
|
||||
for (let d = 1; d <= days; d++) {
|
||||
const ds = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||
if (calEvents.some(e => ds >= e.start_date && ds < e.end_date)) occupied++;
|
||||
}
|
||||
return days > 0 ? Math.round((occupied/days)*100) : 0;
|
||||
})();
|
||||
|
||||
// Re-render donut when switching views
|
||||
async function switchCategoryView(v) {
|
||||
categoryView = v;
|
||||
if (v === 'chart') {
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await renderCharts();
|
||||
}
|
||||
}
|
||||
|
||||
$: periodLabel = selectedMonth
|
||||
? `${months.find(m => m.value === selectedMonth)?.label} ${selectedYear}`
|
||||
: String(selectedYear);
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-6xl mx-auto">
|
||||
|
||||
<!-- Header + filtres -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Dashboard</h1>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-0.5">Vue d'ensemble de vos locations</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<select bind:value={selectedProperty} on:change={reload}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Tous les biens</option>
|
||||
{#each $properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={selectedYear} on:change={reload}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{#each years as y}<option value={y}>{y}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={selectedMonth} on:change={reload}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{#each months as m}<option value={m.value}>{m.label}</option>{/each}
|
||||
</select>
|
||||
<button on:click={reload} class="p-2 rounded-lg border border-gray-200 dark:border-gray-700 text-gray-500 hover:text-blue-600 hover:border-blue-300 transition-colors">
|
||||
<RefreshCw size={16} class={loading ? 'animate-spin' : ''}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
{#each [1,2,3,4] as _}<div class="h-24 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>{/each}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{#each [1,2,3] as _}<div class="h-64 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>{/each}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
<TrendingUp size={13} class="text-green-500"/> Revenus {periodLabel}
|
||||
</div>
|
||||
<p class="text-xl font-semibold text-green-600 dark:text-green-400">{fmt(totalIncome)}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
<TrendingDown size={13} class="text-red-500"/> Dépenses {periodLabel}
|
||||
</div>
|
||||
<p class="text-xl font-semibold text-red-500 dark:text-red-400">{fmt(totalExpense)}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">Bénéfice net</div>
|
||||
<p class="text-xl font-semibold {totalBalance >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}">{fmt(totalBalance)}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
<CalendarDays size={13}/> Occupation ce mois
|
||||
</div>
|
||||
<p class="text-xl font-semibold text-blue-600 dark:text-blue-400">{occupancyRate}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Graphiques ligne 1 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5 mb-5">
|
||||
|
||||
<!-- Barres revenus/dépenses -->
|
||||
<div class="lg:col-span-2 bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-5">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">Revenus & dépenses par mois — {selectedYear}</h2>
|
||||
<div style="height: 340px; position: relative;">
|
||||
{#if monthlyData.every(m => m.income === 0 && m.expense === 0)}
|
||||
<div class="flex items-center justify-center h-full text-sm text-gray-400">Aucune donnée pour {selectedYear}</div>
|
||||
{:else}
|
||||
<canvas bind:this={barCanvas}></canvas>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dépenses par catégorie : chart ou tableau -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Dépenses par catégorie</h2>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
on:click={() => switchCategoryView('chart')}
|
||||
class="px-2 py-1 rounded text-xs transition-colors {categoryView === 'chart'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}">
|
||||
Donut
|
||||
</button>
|
||||
<button
|
||||
on:click={() => switchCategoryView('table')}
|
||||
class="px-2 py-1 rounded text-xs transition-colors {categoryView === 'table'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}">
|
||||
Tableau
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if categoryExpenses.length === 0}
|
||||
<div class="flex items-center justify-center h-48 text-sm text-gray-400">Aucune dépense</div>
|
||||
{:else if categoryView === 'chart'}
|
||||
<div style="height: 310px; position: relative;">
|
||||
<canvas bind:this={donutCanvas}></canvas>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Tableau catégories -->
|
||||
<div class="overflow-y-auto" style="max-height: 310px;">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-400 dark:text-gray-500 border-b border-gray-100 dark:border-gray-800">
|
||||
<th class="text-left pb-2 font-medium">Catégorie</th>
|
||||
<th class="text-right pb-2 font-medium">Montant</th>
|
||||
<th class="text-right pb-2 font-medium">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each categoryExpenses as c, i}
|
||||
<tr class="border-b border-gray-50 dark:border-gray-800 last:border-0">
|
||||
<td class="py-2 pr-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background:{palette[i % palette.length]}"></span>
|
||||
<span class="text-gray-700 dark:text-gray-300 truncate">{c.category}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 text-right font-medium text-red-500 dark:text-red-400 whitespace-nowrap">{fmt(c.amount)}</td>
|
||||
<td class="py-2 text-right text-gray-400 whitespace-nowrap">
|
||||
{totalCatExpense > 0 ? ((c.amount / totalCatExpense) * 100).toFixed(1) : 0}%
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="border-t border-gray-200 dark:border-gray-700">
|
||||
<td class="pt-2 text-gray-500 dark:text-gray-400 font-medium">Total</td>
|
||||
<td class="pt-2 text-right font-semibold text-red-500 dark:text-red-400 whitespace-nowrap">{fmt(totalCatExpense)}</td>
|
||||
<td class="pt-2 text-right text-gray-400">100%</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mb-5">
|
||||
|
||||
<!-- Par bien -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-4">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Par bien — {periodLabel}</h2>
|
||||
{#if summary.length === 0}
|
||||
<p class="text-sm text-gray-400 text-center py-4">Aucune donnée</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each summary as s}
|
||||
<div>
|
||||
<div class="flex justify-between items-baseline mb-1.5">
|
||||
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">{s.property_name}</span>
|
||||
<span class="text-sm font-semibold {s.balance >= 0 ? 'text-green-600' : 'text-red-500'}">{fmt(s.balance)}</span>
|
||||
</div>
|
||||
<!-- Barre proportionnelle -->
|
||||
<div class="h-2 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden flex gap-0.5">
|
||||
{#if s.total_income > 0 || s.total_expense > 0}
|
||||
{@const total = s.total_income + s.total_expense}
|
||||
<div class="bg-green-400 dark:bg-green-500 transition-all"
|
||||
style="width:{(s.total_income/total*100).toFixed(1)}%"/>
|
||||
<div class="bg-red-400 dark:bg-red-500 transition-all"
|
||||
style="width:{(s.total_expense/total*100).toFixed(1)}%"/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-between text-xs mt-1.5 text-gray-400">
|
||||
<span class="text-green-600 dark:text-green-400">↑ {fmt(s.total_income)}</span>
|
||||
<span class="text-red-500 dark:text-red-400">↓ {fmt(s.total_expense)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Transactions récentes -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-4">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Dernières transactions</h2>
|
||||
{#if recentTx.length === 0}
|
||||
<p class="text-sm text-gray-400 text-center py-4">Aucune transaction</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each recentTx as t (t.id)}
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-50 dark:border-gray-800 last:border-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200 truncate">{t.description || t.category_name || '—'}</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">{fmtDate(t.date)} · {t.property_name}</p>
|
||||
</div>
|
||||
<span class="text-sm font-semibold shrink-0 ml-4 {t.type === 'income' ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}">
|
||||
{t.type === 'income' ? '+' : '−'}{fmt(t.amount)}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<a href="/transactions" class="block text-center text-xs text-blue-600 dark:text-blue-400 mt-3 hover:underline">
|
||||
Voir toutes →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Occupations ce mois -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-5">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<CalendarDays size={16} class="text-gray-400"/>
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Occupations ce mois</h2>
|
||||
</div>
|
||||
{#if calEvents.length === 0}
|
||||
<p class="text-sm text-gray-400 text-center py-4">Aucune occupation ce mois-ci</p>
|
||||
{:else}
|
||||
<div class="divide-y divide-gray-50 dark:divide-gray-800">
|
||||
{#each calEvents as e (e.id)}
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{e.title || (e.source === 'airbnb' ? 'Réservation Airbnb' : 'Occupation manuelle')}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">{e.property_name}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{fmtDate(e.start_date)} → {fmtDate(e.end_date)}</p>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium mt-1 inline-block
|
||||
{e.source === 'airbnb'
|
||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300'
|
||||
: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300'}">
|
||||
{e.source === 'airbnb' ? 'Airbnb' : 'Manuel'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
275
frontend/src/routes/calendar/+page.svelte
Normal file
275
frontend/src/routes/calendar/+page.svelte
Normal file
@@ -0,0 +1,275 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/stores/api.js';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Plus, X, Check, RefreshCw } from 'lucide-svelte';
|
||||
|
||||
let properties = [];
|
||||
let events = [];
|
||||
let loading = true;
|
||||
let filterProperty = '';
|
||||
let showForm = false;
|
||||
let error = '';
|
||||
|
||||
let today = new Date();
|
||||
let viewYear = today.getFullYear();
|
||||
let viewMonth = today.getMonth(); // 0-indexed
|
||||
|
||||
const empty = () => ({
|
||||
property_id: '', title: '',
|
||||
start_date: today.toISOString().slice(0,10),
|
||||
end_date: today.toISOString().slice(0,10),
|
||||
notes: ''
|
||||
});
|
||||
let form = empty();
|
||||
|
||||
onMount(async () => {
|
||||
properties = await api.properties.list() || [];
|
||||
await load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
const from = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const to = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${lastDay}`;
|
||||
const params = { from, to };
|
||||
if (filterProperty) params.property_id = filterProperty;
|
||||
events = await api.calendar.list(params) || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
if (viewMonth === 0) { viewMonth = 11; viewYear--; } else { viewMonth--; }
|
||||
load();
|
||||
}
|
||||
function nextMonth() {
|
||||
if (viewMonth === 11) { viewMonth = 0; viewYear++; } else { viewMonth++; }
|
||||
load();
|
||||
}
|
||||
|
||||
// Génère les jours du mois pour la grille
|
||||
$: calendarDays = (() => {
|
||||
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const days = [];
|
||||
// Padding début (lundi = 0)
|
||||
const offset = (firstDay === 0 ? 6 : firstDay - 1);
|
||||
for (let i = 0; i < offset; i++) days.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) days.push(d);
|
||||
return days;
|
||||
})();
|
||||
|
||||
function isToday(day) {
|
||||
return day === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear();
|
||||
}
|
||||
|
||||
// Calcule pour chaque jour du mois : l'événement correspondant (ou null)
|
||||
// iCal (airbnb) : DTEND exclusif → ds < end_date
|
||||
// Manuel : date départ incluse → ds <= end_date
|
||||
$: eventByDay = (() => {
|
||||
const map = {};
|
||||
const mStr = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`;
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const ds = `${mStr}-${String(d).padStart(2, '0')}`;
|
||||
map[d] = events.find(e =>
|
||||
ds >= e.start_date && (e.source === 'airbnb' ? ds < e.end_date : ds <= e.end_date)
|
||||
) || null;
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
$: occupiedDays = Object.values(eventByDay).filter(Boolean).length;
|
||||
$: daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
$: occupancyRate = daysInMonth > 0 ? Math.round((occupiedDays / daysInMonth) * 100) : 0;
|
||||
// Séjours dont l'arrivée est dans le mois affiché (pas les séjours démarrés avant)
|
||||
$: sejoursThisMonth = (() => {
|
||||
const monthStr = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`;
|
||||
return events.filter(e => e.start_date.startsWith(monthStr)).length;
|
||||
})();
|
||||
|
||||
const monthNames = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
const dayNames = ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim'];
|
||||
|
||||
const sourceBg = {
|
||||
airbnb: 'bg-orange-100 dark:bg-orange-900',
|
||||
manual: 'bg-blue-100 dark:bg-blue-900'
|
||||
};
|
||||
|
||||
let syncing = false;
|
||||
let syncMsg = '';
|
||||
let syncError = '';
|
||||
|
||||
async function syncCalendars() {
|
||||
syncing = true;
|
||||
syncMsg = '';
|
||||
syncError = '';
|
||||
try {
|
||||
const res = await fetch('/api/calendar/sync', { method: 'POST', credentials: 'include' });
|
||||
const results = await res.json();
|
||||
const total = results.reduce((s, r) => s + (r.imported || 0), 0);
|
||||
const errs = results.filter(r => r.error);
|
||||
if (errs.length > 0) {
|
||||
syncError = errs.map(r => `${r.property}: ${r.error}`).join(' | ');
|
||||
} else {
|
||||
syncMsg = `${total} événement(s) importé(s)`;
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
syncError = 'Erreur de synchronisation: ' + e.message;
|
||||
}
|
||||
syncing = false;
|
||||
}
|
||||
|
||||
async function saveEvent() {
|
||||
error = '';
|
||||
if (!form.property_id || !form.start_date || !form.end_date) { error = 'Bien et dates requis.'; return; }
|
||||
try {
|
||||
await api.calendar.createEvent(form);
|
||||
showForm = false;
|
||||
await load();
|
||||
} catch (e) { error = e.message; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<CalendarDays size={22} class="text-gray-400"/>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Calendrier</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button on:click={syncCalendars} disabled={syncing}
|
||||
class="flex items-center gap-2 px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50">
|
||||
<RefreshCw size={15} class={syncing ? 'animate-spin' : ''}/> Synchroniser
|
||||
</button>
|
||||
<button on:click={() => { showForm = true; form = empty(); error = ''; }}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Plus size={16}/> Ajouter occupation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if syncMsg}
|
||||
<p class="text-sm text-green-600 dark:text-green-400 mb-3">{syncMsg}</p>
|
||||
{/if}
|
||||
{#if syncError}
|
||||
<p class="text-sm text-red-500 dark:text-red-400 mb-3">{syncError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Filtres + navigation -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-5">
|
||||
<select bind:value={filterProperty} on:change={load}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Tous les biens</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<button on:click={prevMonth} class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 transition-colors"><ChevronLeft size={18}/></button>
|
||||
<span class="text-base font-semibold text-gray-900 dark:text-white w-36 text-center">{monthNames[viewMonth]} {viewYear}</span>
|
||||
<button on:click={nextMonth} class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 transition-colors"><ChevronRight size={18}/></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="grid grid-cols-3 gap-3 mb-5">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Jours occupés</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{occupiedDays} <span class="text-sm font-normal text-gray-400">/ {daysInMonth}</span></p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Taux d'occupation</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{occupancyRate}%</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Séjours ce mois</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{sejoursThisMonth}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendrier grille -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
<!-- Jours de la semaine -->
|
||||
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-800">
|
||||
{#each dayNames as d}
|
||||
<div class="py-3 text-center text-xs font-medium text-gray-400 dark:text-gray-500">{d}</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Jours -->
|
||||
<div class="grid grid-cols-7">
|
||||
{#each calendarDays as day, i}
|
||||
{@const event = eventByDay[day] ?? null}
|
||||
{@const occupied = !!event}
|
||||
<div class="border-b border-r border-gray-50 dark:border-gray-800/50 min-h-[72px] p-2 relative
|
||||
{!day ? 'bg-gray-50/50 dark:bg-gray-800/20' : ''}
|
||||
{occupied ? (event.source === 'airbnb' ? 'bg-orange-50 dark:bg-orange-950/30' : 'bg-blue-50 dark:bg-blue-950/30') : ''}">
|
||||
{#if day}
|
||||
<span class="text-xs font-medium {isToday(day) ? 'bg-blue-600 text-white w-6 h-6 flex items-center justify-center rounded-full' : 'text-gray-700 dark:text-gray-300'}">
|
||||
{day}
|
||||
</span>
|
||||
{#if event && event.start_date === `${viewYear}-${String(viewMonth+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`}
|
||||
<div class="mt-1 text-xs px-1.5 py-0.5 rounded font-medium truncate
|
||||
{event.source === 'airbnb' ? 'bg-orange-200 text-orange-800 dark:bg-orange-900 dark:text-orange-200' : 'bg-blue-200 text-blue-800 dark:bg-blue-900 dark:text-blue-200'}">
|
||||
{event.title || (event.source === 'airbnb' ? 'Airbnb' : 'Locataire')}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Légende -->
|
||||
<div class="flex gap-4 mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-orange-200 dark:bg-orange-900"></span>Airbnb (sync auto)</span>
|
||||
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-blue-200 dark:bg-blue-900"></span>Manuel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" on:click|self={() => showForm = false}>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Ajouter une période d'occupation</h2>
|
||||
<button on:click={() => showForm = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
|
||||
</div>
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
{#if error}<p class="text-red-500 text-sm">{error}</p>{/if}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Bien *</label>
|
||||
<select bind:value={form.property_id}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Sélectionner...</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Intitulé</label>
|
||||
<input bind:value={form.title} placeholder="Ex: Famille Dupont"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Arrivée *</label>
|
||||
<input type="date" bind:value={form.start_date}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Départ *</label>
|
||||
<input type="date" bind:value={form.end_date}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<button on:click={() => showForm = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
|
||||
<button on:click={saveEvent}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Check size={15}/> Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
223
frontend/src/routes/categories/+page.svelte
Normal file
223
frontend/src/routes/categories/+page.svelte
Normal file
@@ -0,0 +1,223 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/stores/api.js';
|
||||
import { Tag, Plus, Pencil, Trash2, X, Check, AlertCircle } from 'lucide-svelte';
|
||||
|
||||
let categories = [];
|
||||
let loading = true;
|
||||
let showForm = false;
|
||||
let editingId = null;
|
||||
let error = '';
|
||||
|
||||
const empty = () => ({ name: '', type: 'expense', tax_deductible: false, description: '' });
|
||||
let form = empty();
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
categories = await api.categories.list() || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function openCreate() { form = empty(); editingId = null; showForm = true; error = ''; }
|
||||
function openEdit(c) { form = { ...c }; editingId = c.id; showForm = true; error = ''; }
|
||||
function cancel() { showForm = false; error = ''; }
|
||||
|
||||
async function save() {
|
||||
error = '';
|
||||
if (!form.name) { error = 'Le nom est requis.'; return; }
|
||||
try {
|
||||
if (editingId) await api.categories.update(editingId, form);
|
||||
else await api.categories.create(form);
|
||||
showForm = false;
|
||||
await load();
|
||||
} catch (e) { error = e.message; }
|
||||
}
|
||||
|
||||
async function remove(id, name) {
|
||||
if (!confirm(`Supprimer la catégorie "${name}" ?\nLes transactions associées perdront leur catégorie.`)) return;
|
||||
await api.categories.delete(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
// Grouper par type
|
||||
$: incomes = categories.filter(c => c.type === 'income');
|
||||
$: expenses = categories.filter(c => c.type === 'expense');
|
||||
|
||||
const typeBadge = {
|
||||
income: 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300',
|
||||
expense: 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300',
|
||||
};
|
||||
const typeLabel = { income: 'Revenu', expense: 'Dépense' };
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<Tag size={22} class="text-gray-400"/>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Catégories</h1>
|
||||
</div>
|
||||
<button on:click={openCreate}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Plus size={16}/> Nouvelle catégorie
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Les catégories permettent de classer vos revenus et dépenses pour la comptabilité et la liasse fiscale.
|
||||
Les catégories déductibles fiscalement sont signalées pour l'export annuel.
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each [1,2,3] as _}<div class="h-12 bg-gray-100 dark:bg-gray-800 rounded-lg animate-pulse"/>{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Dépenses -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-red-400 inline-block"></span>
|
||||
Dépenses ({expenses.length})
|
||||
</h2>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
{#if expenses.length === 0}
|
||||
<p class="text-sm text-gray-400 text-center py-6">Aucune catégorie de dépense.</p>
|
||||
{:else}
|
||||
{#each expenses as c, i (c.id)}
|
||||
<div class="flex items-center gap-4 px-4 py-3
|
||||
{i > 0 ? 'border-t border-gray-50 dark:border-gray-800' : ''}
|
||||
hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">{c.name}</span>
|
||||
{#if c.tax_deductible}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300 font-medium">
|
||||
Déductible
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if c.description}
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{c.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button on:click={() => openEdit(c)}
|
||||
class="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<Pencil size={14}/>
|
||||
</button>
|
||||
<button on:click={() => remove(c.id, c.name)}
|
||||
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
|
||||
<Trash2 size={14}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenus -->
|
||||
<div>
|
||||
<h2 class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-green-400 inline-block"></span>
|
||||
Revenus ({incomes.length})
|
||||
</h2>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
{#if incomes.length === 0}
|
||||
<p class="text-sm text-gray-400 text-center py-6">Aucune catégorie de revenu.</p>
|
||||
{:else}
|
||||
{#each incomes as c, i (c.id)}
|
||||
<div class="flex items-center gap-4 px-4 py-3
|
||||
{i > 0 ? 'border-t border-gray-50 dark:border-gray-800' : ''}
|
||||
hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">{c.name}</span>
|
||||
{#if c.description}
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{c.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button on:click={() => openEdit(c)}
|
||||
class="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<Pencil size={14}/>
|
||||
</button>
|
||||
<button on:click={() => remove(c.id, c.name)}
|
||||
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
|
||||
<Trash2 size={14}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">
|
||||
{editingId ? 'Modifier la catégorie' : 'Nouvelle catégorie'}
|
||||
</h2>
|
||||
<button on:click={cancel} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<X size={18}/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
{#if error}
|
||||
<div class="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400">
|
||||
<AlertCircle size={13}/> {error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Type -->
|
||||
<div class="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<button on:click={() => form.type = 'expense'}
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors
|
||||
{form.type === 'expense' ? 'bg-red-500 text-white' : 'text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800'}">
|
||||
Dépense
|
||||
</button>
|
||||
<button on:click={() => form.type = 'income'}
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors
|
||||
{form.type === 'income' ? 'bg-green-600 text-white' : 'text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800'}">
|
||||
Revenu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nom *</label>
|
||||
<input bind:value={form.name} placeholder="Ex: Charges copropriété"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Description</label>
|
||||
<input bind:value={form.description} placeholder="Description optionnelle..."
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
|
||||
{#if form.type === 'expense'}
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" bind:checked={form.tax_deductible}
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"/>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Déductible fiscalement</span>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">Apparaîtra en évidence dans l'export fiscal annuel</p>
|
||||
</div>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<button on:click={cancel} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
|
||||
<button on:click={save}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Check size={15}/> {editingId ? 'Enregistrer' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
239
frontend/src/routes/documents/+page.svelte
Normal file
239
frontend/src/routes/documents/+page.svelte
Normal file
@@ -0,0 +1,239 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/stores/api.js';
|
||||
import { FileText, Upload, Trash2, Download, FolderOpen, Archive } from 'lucide-svelte';
|
||||
|
||||
let documents = [];
|
||||
let properties = [];
|
||||
let loading = true;
|
||||
let uploading = false;
|
||||
let error = '';
|
||||
|
||||
let filterProperty = '';
|
||||
let filterYear = String(new Date().getFullYear());
|
||||
let filterCategory = '';
|
||||
|
||||
let dragover = false;
|
||||
let fileInput;
|
||||
let uploadPropertyId = '';
|
||||
let uploadYear = String(new Date().getFullYear());
|
||||
let uploadCategory = '';
|
||||
|
||||
// Export
|
||||
let exportProperty = '';
|
||||
let exportYear = String(new Date().getFullYear());
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => String(new Date().getFullYear() - i));
|
||||
|
||||
onMount(async () => {
|
||||
properties = await api.properties.list() || [];
|
||||
if (properties.length > 0) {
|
||||
uploadPropertyId = properties[0].id;
|
||||
exportProperty = properties[0].id;
|
||||
}
|
||||
await load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
const params = {};
|
||||
if (filterProperty) params.property_id = filterProperty;
|
||||
if (filterYear) params.fiscal_year = filterYear;
|
||||
if (filterCategory) params.category = filterCategory;
|
||||
documents = await api.documents.list(params) || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function handleFiles(files) {
|
||||
if (!uploadPropertyId) { error = 'Sélectionnez un bien avant d\'uploader.'; return; }
|
||||
error = '';
|
||||
uploading = true;
|
||||
for (const file of files) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('property_id', uploadPropertyId);
|
||||
fd.append('fiscal_year', uploadYear);
|
||||
fd.append('category', uploadCategory);
|
||||
try { await api.documents.upload(fd); }
|
||||
catch (e) { error = `Erreur upload "${file.name}": ${e.message}`; }
|
||||
}
|
||||
uploading = false;
|
||||
await load();
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
dragover = false;
|
||||
handleFiles([...e.dataTransfer.files]);
|
||||
}
|
||||
|
||||
function onFileInput(e) {
|
||||
handleFiles([...e.target.files]);
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
async function remove(id, name) {
|
||||
if (!confirm(`Supprimer "${name}" ?`)) return;
|
||||
await api.documents.delete(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
function download(id) {
|
||||
window.open(api.documents.download(id), '_blank');
|
||||
}
|
||||
|
||||
function doExport() {
|
||||
const params = { year: exportYear };
|
||||
if (exportProperty) params.property_id = exportProperty;
|
||||
window.open(api.documents.exportUrl(params), '_blank');
|
||||
}
|
||||
|
||||
const mimeIcon = (mime) => {
|
||||
if (!mime) return '📄';
|
||||
if (mime.includes('pdf')) return '📕';
|
||||
if (mime.includes('image')) return '🖼️';
|
||||
if (mime.includes('spreadsheet') || mime.includes('excel') || mime.includes('csv')) return '📊';
|
||||
if (mime.includes('word') || mime.includes('document')) return '📝';
|
||||
return '📄';
|
||||
};
|
||||
|
||||
const fmtDate = (d) => new Date(d).toLocaleDateString('fr-FR');
|
||||
|
||||
// Catégories distinctes présentes dans la liste pour le filtre
|
||||
$: availableCategories = [...new Set(documents.map(d => d.category).filter(Boolean))].sort();
|
||||
|
||||
// Grouper par catégorie
|
||||
$: grouped = (() => {
|
||||
const byCat = {};
|
||||
for (const d of documents) {
|
||||
const cKey = d.category || 'Sans catégorie';
|
||||
if (!byCat[cKey]) byCat[cKey] = [];
|
||||
byCat[cKey].push(d);
|
||||
}
|
||||
return Object.entries(byCat).sort(([a], [b]) => a.localeCompare(b));
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between gap-3 mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<FileText size={22} class="text-gray-400"/>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Documents</h1>
|
||||
</div>
|
||||
|
||||
<!-- Export ZIP -->
|
||||
<div class="flex items-center gap-2 bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 px-4 py-2">
|
||||
<Archive size={15} class="text-gray-400 shrink-0"/>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 shrink-0">Export ZIP</span>
|
||||
<select bind:value={exportProperty}
|
||||
class="px-2 py-1 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none">
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={exportYear}
|
||||
class="px-2 py-1 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none">
|
||||
{#each years as y}<option value={y}>{y}</option>{/each}
|
||||
</select>
|
||||
<button on:click={doExport}
|
||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors">
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone d'upload -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-5 mb-5">
|
||||
<h2 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Ajouter des documents</h2>
|
||||
<div class="flex flex-wrap gap-3 mb-4">
|
||||
<select bind:value={uploadPropertyId}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={uploadYear}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{#each years as y}<option value={y}>Année {y}</option>{/each}
|
||||
</select>
|
||||
<input
|
||||
bind:value={uploadCategory}
|
||||
placeholder="Catégorie (ex: Loyers, Travaux…)"
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-48"/>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="text-red-500 text-sm mb-3">{error}</p>{/if}
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
on:dragover|preventDefault={() => dragover = true}
|
||||
on:dragleave={() => dragover = false}
|
||||
on:drop|preventDefault={onDrop}
|
||||
on:click={() => fileInput.click()}
|
||||
class="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors
|
||||
{dragover ? 'border-blue-400 bg-blue-50 dark:bg-blue-950/30' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}">
|
||||
{#if uploading}
|
||||
<div class="text-blue-600 dark:text-blue-400 text-sm">⏳ Upload en cours...</div>
|
||||
{:else}
|
||||
<Upload size={24} class="mx-auto mb-2 text-gray-300 dark:text-gray-600"/>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Glissez vos fichiers ici ou <span class="text-blue-600 dark:text-blue-400">cliquez pour choisir</span></p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">PDF, images, tableurs acceptés</p>
|
||||
{/if}
|
||||
</div>
|
||||
<input bind:this={fileInput} type="file" multiple class="hidden" on:change={onFileInput}
|
||||
accept=".pdf,.jpg,.jpeg,.png,.xlsx,.csv,.doc,.docx"/>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="flex flex-wrap gap-3 mb-5">
|
||||
<select bind:value={filterProperty} on:change={load}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Tous les biens</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={filterYear} on:change={load}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Toutes années</option>
|
||||
{#each years as y}<option value={y}>{y}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={filterCategory} on:change={load}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Toutes catégories</option>
|
||||
{#each availableCategories as c}<option value={c}>{c}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Liste groupée par mois → catégorie -->
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each [1,2,3] as _}<div class="h-12 bg-gray-100 dark:bg-gray-800 rounded-lg animate-pulse"/>{/each}
|
||||
</div>
|
||||
{:else if documents.length === 0}
|
||||
<div class="text-center py-16 text-gray-400">
|
||||
<FolderOpen size={40} class="mx-auto mb-3 opacity-30"/>
|
||||
<p>Aucun document pour ces filtres.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each grouped as [catName, docs]}
|
||||
<div class="mb-4">
|
||||
<p class="text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wider mb-2 ml-1">{catName}</p>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
{#each docs as d, i (d.id)}
|
||||
<div class="flex items-center gap-4 px-4 py-3 {i > 0 ? 'border-t border-gray-50 dark:border-gray-800' : ''} hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
<span class="text-xl shrink-0">{mimeIcon(d.mime_type)}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{d.original_name}</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">{d.property_name} · {fmtDate(d.created_at)}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button on:click={() => download(d.id)}
|
||||
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors">
|
||||
<Download size={14}/>
|
||||
</button>
|
||||
<button on:click={() => remove(d.id, d.original_name)}
|
||||
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
|
||||
<Trash2 size={14}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
126
frontend/src/routes/fiscal/+page.svelte
Normal file
126
frontend/src/routes/fiscal/+page.svelte
Normal file
@@ -0,0 +1,126 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/stores/api.js';
|
||||
import { Download, TrendingUp, TrendingDown, Minus, FileSpreadsheet } from 'lucide-svelte';
|
||||
|
||||
let properties = [];
|
||||
let summary = [];
|
||||
let loading = true;
|
||||
let filterProperty = '';
|
||||
let filterYear = String(new Date().getFullYear());
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => String(new Date().getFullYear() - i));
|
||||
const fmt = (n) => Number(n || 0).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €';
|
||||
|
||||
onMount(async () => {
|
||||
properties = await api.properties.list() || [];
|
||||
await load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
const params = { year: filterYear };
|
||||
if (filterProperty) params.property_id = filterProperty;
|
||||
summary = await api.fiscal.summary(params) || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
const params = { year: filterYear };
|
||||
if (filterProperty) params.property_id = filterProperty;
|
||||
window.open(api.fiscal.exportUrl(params), '_blank');
|
||||
}
|
||||
|
||||
$: grandTotal = summary.reduce((acc, s) => ({
|
||||
income: acc.income + (s.total_income || 0),
|
||||
expense: acc.expense + (s.total_expense || 0),
|
||||
balance: acc.balance + (s.balance || 0),
|
||||
}), { income: 0, expense: 0, balance: 0 });
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<FileSpreadsheet size={22} class="text-gray-400"/>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Export fiscal</h1>
|
||||
</div>
|
||||
<button on:click={exportCSV}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Download size={16}/> Exporter CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-950/30 border border-blue-100 dark:border-blue-900 rounded-xl p-4 mb-6 text-sm text-blue-700 dark:text-blue-300">
|
||||
L'export CSV est formaté pour Excel (séparateur point-virgule, encodage UTF-8 avec BOM).
|
||||
Il reprend toutes les transactions de l'année sélectionnée avec leur catégorie,
|
||||
et inclut le total des revenus, dépenses et le bénéfice net.
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<select bind:value={filterProperty} on:change={load}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Tous les biens</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={filterYear} on:change={load}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{#each years as y}<option value={y}>{y}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Récapitulatif -->
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each [1,2] as _}<div class="h-32 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>{/each}
|
||||
</div>
|
||||
{:else if summary.length === 0}
|
||||
<p class="text-center text-gray-400 py-12">Aucune donnée pour cette sélection.</p>
|
||||
{:else}
|
||||
<div class="space-y-4 mb-6">
|
||||
{#each summary as s}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-50 dark:border-gray-800">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">{s.property_name}</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">Exercice {s.year || filterYear}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 divide-x divide-gray-50 dark:divide-gray-800">
|
||||
<div class="px-5 py-4">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 mb-1"><TrendingUp size={12} class="text-green-500"/> Revenus</div>
|
||||
<p class="text-lg font-semibold text-green-600">{fmt(s.total_income)}</p>
|
||||
</div>
|
||||
<div class="px-5 py-4">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 mb-1"><TrendingDown size={12} class="text-red-500"/> Dépenses</div>
|
||||
<p class="text-lg font-semibold text-red-500">{fmt(s.total_expense)}</p>
|
||||
</div>
|
||||
<div class="px-5 py-4">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 mb-1"><Minus size={12}/> Bénéfice net</div>
|
||||
<p class="text-lg font-semibold {s.balance >= 0 ? 'text-green-600' : 'text-red-500'}">{fmt(s.balance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Total consolidé -->
|
||||
{#if summary.length > 1}
|
||||
<div class="bg-gray-900 dark:bg-gray-800 rounded-xl p-5 text-white">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-4">Total consolidé — {filterYear}</h3>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-gray-400 mb-1">Revenus totaux</p>
|
||||
<p class="text-xl font-semibold text-green-400">{fmt(grandTotal.income)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400 mb-1">Dépenses totales</p>
|
||||
<p class="text-xl font-semibold text-red-400">{fmt(grandTotal.expense)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400 mb-1">Bénéfice net</p>
|
||||
<p class="text-xl font-semibold {grandTotal.balance >= 0 ? 'text-green-400' : 'text-red-400'}">{fmt(grandTotal.balance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
896
frontend/src/routes/import/+page.svelte
Normal file
896
frontend/src/routes/import/+page.svelte
Normal file
@@ -0,0 +1,896 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/stores/api.js';
|
||||
import { Upload, FileSpreadsheet, Check, AlertCircle, Tag, X, GitFork, Link2 } from 'lucide-svelte';
|
||||
|
||||
let properties = [];
|
||||
let categories = [];
|
||||
let transactions = [];
|
||||
let step = 'upload';
|
||||
let loading = false;
|
||||
let error = '';
|
||||
let result = null;
|
||||
let fileInput;
|
||||
let dragover = false;
|
||||
let defaultProperty = '';
|
||||
|
||||
// Split state
|
||||
let splitIdx = null; // index de la transaction en cours de split
|
||||
let splitParts = [];
|
||||
let splitError = '';
|
||||
|
||||
let loans = [];
|
||||
|
||||
// Montants de mensualités connus — pour détecter les lignes de prêt
|
||||
$: loanMonthlyAmounts = loans.map(l => l.monthly_payment);
|
||||
|
||||
function isLoanPayment(amount) {
|
||||
return loanMonthlyAmounts.some(m => Math.abs(m - amount) < 0.10);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
[properties, categories, loans] = await Promise.all([
|
||||
api.properties.list(),
|
||||
api.categories.list(),
|
||||
api.loans.list(),
|
||||
]);
|
||||
properties = properties || [];
|
||||
categories = categories || [];
|
||||
loans = loans || [];
|
||||
if (properties.length > 0) defaultProperty = properties[0].id;
|
||||
});
|
||||
|
||||
function catsFor(type) {
|
||||
return categories.filter(c => c.type === type);
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().match(/\.(qif|qfx)$/)) { error = 'Fichier QIF ou QFX requis.'; return; }
|
||||
error = '';
|
||||
loading = true;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/import/preview', { method: 'POST', body: fd, credentials: 'include' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const raw = (await res.json()) || [];
|
||||
transactions = raw.map(t => ({
|
||||
...t,
|
||||
status: 'import',
|
||||
property_id: defaultProperty,
|
||||
category_id: '',
|
||||
splits: null,
|
||||
agencyFee: 0,
|
||||
alreadyImported: false,
|
||||
}));
|
||||
// Vérifier lesquelles existent déjà en base
|
||||
try {
|
||||
const checkRes = await fetch('/api/import/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(raw),
|
||||
});
|
||||
if (checkRes.ok) {
|
||||
const exists = await checkRes.json();
|
||||
transactions = transactions.map((t, i) => ({
|
||||
...t,
|
||||
alreadyImported: exists[i] === true,
|
||||
status: exists[i] ? 'ignore' : 'import',
|
||||
}));
|
||||
}
|
||||
} catch(_) {}
|
||||
step = 'preview';
|
||||
} catch (e) { error = e.message; }
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function onDrop(e) { dragover = false; handleFile(e.dataTransfer.files[0]); }
|
||||
function onFileInput(e) { handleFile(e.target.files[0]); e.target.value = ''; }
|
||||
|
||||
function assignAllProperty(pid) {
|
||||
if (!pid) return;
|
||||
transactions = transactions.map(t =>
|
||||
t.status === 'import' ? { ...t, property_id: pid } : t
|
||||
);
|
||||
}
|
||||
|
||||
// ── Split ────────────────────────────────────────────────────────────────
|
||||
|
||||
function openSplit(idx) {
|
||||
const t = transactions[idx];
|
||||
splitIdx = idx;
|
||||
splitError = '';
|
||||
// Parts initiales : 50/50 sur les deux biens disponibles
|
||||
splitParts = properties.slice(0, 2).map((p) => ({
|
||||
property_id: p.id,
|
||||
category_id: t.category_id || '',
|
||||
type: t.type,
|
||||
amount: parseFloat((t.amount / 2).toFixed(2)),
|
||||
description: t.description || '',
|
||||
pct: 50,
|
||||
}));
|
||||
if (splitParts.length < 2) {
|
||||
splitParts.push({ property_id: '', category_id: '', type: t.type, amount: parseFloat((t.amount / 2).toFixed(2)), description: t.description || '', pct: 50 });
|
||||
}
|
||||
}
|
||||
|
||||
function closeSplit() { splitIdx = null; splitParts = []; splitError = ''; }
|
||||
|
||||
function updateSplitPct(i, val) {
|
||||
const total = transactions[splitIdx].amount;
|
||||
val = Math.min(100, Math.max(0, parseFloat(val) || 0));
|
||||
splitParts[i].pct = val;
|
||||
splitParts[i].amount = parseFloat((total * val / 100).toFixed(2));
|
||||
if (splitParts.length === 2) {
|
||||
const other = 100 - val;
|
||||
splitParts[1-i].pct = other;
|
||||
splitParts[1-i].amount = parseFloat((total * other / 100).toFixed(2));
|
||||
}
|
||||
splitParts = [...splitParts];
|
||||
}
|
||||
|
||||
function updateSplitAmount(i, val) {
|
||||
const total = transactions[splitIdx].amount;
|
||||
val = Math.abs(parseFloat(val) || 0);
|
||||
splitParts[i].amount = val;
|
||||
splitParts[i].pct = parseFloat((val / total * 100).toFixed(1));
|
||||
if (splitParts.length === 2) {
|
||||
const j = 1 - i;
|
||||
const srcType = transactions[splitIdx].type;
|
||||
const allSameType = splitParts.every(p => p.type === srcType);
|
||||
let otherAmount;
|
||||
if (allSameType) {
|
||||
// Mode homogène : other = total - val
|
||||
otherAmount = parseFloat((total - val).toFixed(2));
|
||||
} else {
|
||||
// Mode mixte : maintenir net = total
|
||||
// Si la part modifiée est même type que source → autre (opposé) = val - total
|
||||
// Si la part modifiée est type opposé à source → autre (même type) = total + val
|
||||
otherAmount = splitParts[i].type === srcType
|
||||
? parseFloat((val - total).toFixed(2))
|
||||
: parseFloat((total + val).toFixed(2));
|
||||
otherAmount = Math.max(0, otherAmount);
|
||||
}
|
||||
splitParts[j].amount = otherAmount;
|
||||
splitParts[j].pct = parseFloat((otherAmount / total * 100).toFixed(1));
|
||||
}
|
||||
splitParts = [...splitParts];
|
||||
}
|
||||
|
||||
$: splitSource = splitIdx !== null ? transactions[splitIdx] : null;
|
||||
$: splitNetExpense = splitParts.filter(p => p.type === 'expense').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
|
||||
$: splitNetIncome = splitParts.filter(p => p.type === 'income').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
|
||||
$: splitNet = splitSource ? parseFloat((splitSource.type === 'income'
|
||||
? splitNetIncome - splitNetExpense
|
||||
: splitNetExpense - splitNetIncome
|
||||
).toFixed(2)) : 0;
|
||||
$: splitDiff = splitSource ? parseFloat((splitNet - splitSource.amount).toFixed(2)) : 0;
|
||||
|
||||
|
||||
// Helpers catégories prêt
|
||||
function findInterestCat() {
|
||||
return categories.find(c => {
|
||||
const n = c.name.toLowerCase();
|
||||
return n.includes('intérêt') || n.includes('interet');
|
||||
});
|
||||
}
|
||||
function findCapitalCat() {
|
||||
return categories.find(c => {
|
||||
const n = c.name.toLowerCase();
|
||||
return n.includes('capital') || n.includes('remboursement');
|
||||
});
|
||||
}
|
||||
function findManagementCat() {
|
||||
return categories.find(c => {
|
||||
const n = c.name.toLowerCase();
|
||||
return n.includes('gestion') || n.includes('honoraire') || n.includes('agence');
|
||||
});
|
||||
}
|
||||
|
||||
function buildSplitParts(results, tAmount) {
|
||||
const interestCat = findInterestCat();
|
||||
const capitalCat = findCapitalCat();
|
||||
const parts = [];
|
||||
for (const r of results) {
|
||||
parts.push({
|
||||
property_id: r.property_id || defaultProperty,
|
||||
category_id: interestCat?.id || '',
|
||||
type: 'expense',
|
||||
amount: parseFloat(r.interest.toFixed(2)),
|
||||
description: 'Intérêts ' + r.loan_ref + ' — éch. ' + r.rank,
|
||||
pct: parseFloat((r.interest / tAmount * 100).toFixed(1)),
|
||||
});
|
||||
parts.push({
|
||||
property_id: r.property_id || defaultProperty,
|
||||
category_id: capitalCat?.id || '',
|
||||
type: 'expense',
|
||||
amount: parseFloat(r.capital.toFixed(2)),
|
||||
description: 'Capital ' + r.loan_ref + ' — éch. ' + r.rank,
|
||||
pct: parseFloat((r.capital / tAmount * 100).toFixed(1)),
|
||||
});
|
||||
}
|
||||
return { parts, interestCat, capitalCat };
|
||||
}
|
||||
|
||||
// Auto-split : API d'abord, sélecteur manuel en fallback
|
||||
async function autoSplit(idx) {
|
||||
const t = transactions[idx];
|
||||
splitIdx = idx;
|
||||
splitParts = [];
|
||||
splitError = '';
|
||||
try {
|
||||
const results = await api.loans.splitForDate(t.date);
|
||||
if (results && results.length > 0) {
|
||||
// Ne garder que le(s) prêt(s) dont le total correspond au montant de la transaction
|
||||
const matched = results.filter(r => Math.abs(r.total - t.amount) < 0.10);
|
||||
const toUse = matched.length > 0 ? matched : results;
|
||||
const { parts, interestCat, capitalCat } = buildSplitParts(toUse, t.amount);
|
||||
splitParts = parts;
|
||||
if (!interestCat) splitError = '⚠ Créez une catégorie "Intérêts emprunt".';
|
||||
if (!capitalCat) splitError += (splitError ? ' ' : '') + '⚠ Créez une catégorie "Remboursement emprunt".';
|
||||
return; // succès, pas besoin du sélecteur
|
||||
}
|
||||
} catch(e) { /* fallback */ }
|
||||
// Fallback : ouvrir le sélecteur manuel
|
||||
openLoanPicker(idx);
|
||||
}
|
||||
|
||||
// Sélecteur de ligne d'amortissement (fallback manuel)
|
||||
let loanPickerIdx = null;
|
||||
let loanPickerData = [];
|
||||
let loanPickerLoading = false;
|
||||
|
||||
async function openLoanPicker(idx) {
|
||||
loanPickerIdx = idx;
|
||||
loanPickerData = [];
|
||||
loanPickerLoading = true;
|
||||
splitIdx = null;
|
||||
const t = transactions[idx];
|
||||
const year = t.date ? t.date.split('-')[0] : String(new Date().getFullYear());
|
||||
const matching = loans.filter(l => Math.abs(l.monthly_payment - t.amount) < 0.10);
|
||||
const toLoad = matching.length > 0 ? matching : loans;
|
||||
loanPickerData = await Promise.all(
|
||||
toLoad.map(async loan => {
|
||||
const lines = await api.loans.lines(loan.id, { year });
|
||||
return { loan, lines: lines || [] };
|
||||
})
|
||||
);
|
||||
loanPickerLoading = false;
|
||||
}
|
||||
|
||||
function closeLoanPicker() { loanPickerIdx = null; loanPickerData = []; }
|
||||
|
||||
function selectLoanLine(lr, line) {
|
||||
const t = transactions[loanPickerIdx];
|
||||
const { parts, interestCat, capitalCat } = buildSplitParts(
|
||||
[{ property_id: lr.loan.property_id, loan_ref: lr.loan.reference || lr.loan.label, rank: line.rank, interest: line.interest, capital: line.capital }],
|
||||
t.amount
|
||||
);
|
||||
splitIdx = loanPickerIdx;
|
||||
splitParts = parts;
|
||||
splitError = '';
|
||||
if (!interestCat) splitError = '⚠ Créez une catégorie "Intérêts emprunt".';
|
||||
if (!capitalCat) splitError += (splitError ? ' ' : '') + '⚠ Créez une catégorie "Remboursement emprunt".';
|
||||
closeLoanPicker();
|
||||
}
|
||||
|
||||
function applySplit() {
|
||||
splitError = '';
|
||||
if (Math.abs(splitDiff) > 0.01) { splitError = 'Le total ne correspond pas au montant.'; return; }
|
||||
for (const p of splitParts) {
|
||||
if (!p.property_id) { splitError = 'Chaque part doit avoir un bien.'; return; }
|
||||
if (!p.category_id) { splitError = 'Chaque part doit avoir une catégorie.'; return; }
|
||||
}
|
||||
// Stocker les parts dans la transaction et la marquer "split"
|
||||
transactions[splitIdx] = {
|
||||
...transactions[splitIdx],
|
||||
status: 'split',
|
||||
splits: splitParts.map(p => ({ ...p })),
|
||||
};
|
||||
transactions = [...transactions];
|
||||
closeSplit();
|
||||
}
|
||||
|
||||
function removeSplit(idx) {
|
||||
transactions[idx] = { ...transactions[idx], status: 'import', splits: null };
|
||||
transactions = [...transactions];
|
||||
}
|
||||
|
||||
// ── Fusion de deux prélèvements ───────────────────────────────────────────
|
||||
let mergeAnchorIdx = null;
|
||||
|
||||
function startMerge(idx) {
|
||||
if (mergeAnchorIdx === null) {
|
||||
mergeAnchorIdx = idx;
|
||||
} else if (mergeAnchorIdx === idx) {
|
||||
mergeAnchorIdx = null; // annuler
|
||||
} else {
|
||||
// Fusionner
|
||||
const t1 = transactions[mergeAnchorIdx];
|
||||
const t2 = transactions[idx];
|
||||
const combinedAbs = Math.abs(t1.amount) + Math.abs(t2.amount);
|
||||
const newAmount = t1.amount < 0 ? -combinedAbs : combinedAbs;
|
||||
transactions[mergeAnchorIdx] = {
|
||||
...t1,
|
||||
status: 'import',
|
||||
amount: newAmount,
|
||||
splits: null,
|
||||
_mergedWithIdx: idx,
|
||||
_origAmount: t1.amount,
|
||||
description: t1.description,
|
||||
};
|
||||
transactions[idx] = { ...t2, status: 'absorbed' };
|
||||
transactions = [...transactions];
|
||||
mergeAnchorIdx = null;
|
||||
}
|
||||
}
|
||||
|
||||
function unmerge(idx) {
|
||||
const t = transactions[idx];
|
||||
if (t._mergedWithIdx == null) return;
|
||||
transactions[t._mergedWithIdx] = { ...transactions[t._mergedWithIdx], status: 'import' };
|
||||
transactions[idx] = { ...t, amount: t._origAmount, _mergedWithIdx: null, _origAmount: null, status: 'import', splits: null };
|
||||
transactions = [...transactions];
|
||||
}
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function doImport() {
|
||||
error = '';
|
||||
// N'importer que les transactions complètes (bien + catégorie, ou split)
|
||||
const ready = transactions.filter(t =>
|
||||
t.status === 'split' || (t.status === 'import' && t.property_id && t.category_id)
|
||||
);
|
||||
if (ready.length === 0) { error = 'Aucune transaction prête à importer.'; return; }
|
||||
const toImport = ready;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
// Construire la liste finale — les splits sont éclatés en plusieurs transactions
|
||||
const flat = [];
|
||||
for (const t of toImport) {
|
||||
if (t.status === 'split' && t.splits) {
|
||||
for (const s of t.splits) {
|
||||
flat.push({ ...t, property_id: s.property_id, category_id: s.category_id, type: s.type || t.type, amount: s.amount, description: s.description });
|
||||
}
|
||||
} else {
|
||||
flat.push(t);
|
||||
}
|
||||
// Frais de gestion agence : créer une dépense déductible complémentaire
|
||||
if ((t.agencyFee || 0) > 0) {
|
||||
const mgmtCat = findManagementCat();
|
||||
flat.push({
|
||||
...t,
|
||||
type: 'expense',
|
||||
amount: parseFloat(t.agencyFee),
|
||||
category_id: mgmtCat?.id || '',
|
||||
description: 'Frais de gestion — ' + (t.description || ''),
|
||||
splits: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Grouper par property_id
|
||||
const byProperty = flat.reduce((acc, t) => {
|
||||
if (!acc[t.property_id]) acc[t.property_id] = [];
|
||||
acc[t.property_id].push(t);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let totalImported = 0, totalSkipped = 0, allErrors = [];
|
||||
for (const [pid, txs] of Object.entries(byProperty)) {
|
||||
const res = await fetch('/api/import/qif', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ property_id: pid, transactions: txs }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const r = await res.json();
|
||||
totalImported += r.imported;
|
||||
totalSkipped += r.skipped;
|
||||
if (r.errors) allErrors = [...allErrors, ...r.errors];
|
||||
}
|
||||
result = { imported: totalImported, skipped: totalSkipped, errors: allErrors };
|
||||
// Supprimer les transactions importées, garder les incomplètes à l'écran
|
||||
transactions = transactions.filter(t =>
|
||||
!(t.status === 'split' || (t.status === 'import' && t.property_id && t.category_id))
|
||||
);
|
||||
if (transactions.length === 0) {
|
||||
step = 'done';
|
||||
} else {
|
||||
error = `✓ ${totalImported} importée${totalImported > 1 ? 's' : ''}${totalSkipped > 0 ? ` · ${totalSkipped} ignorée${totalSkipped > 1 ? 's' : ''}` : ''} — ${transactions.filter(t=>t.status!=='ignore').length} transaction${transactions.filter(t=>t.status!=='ignore').length > 1 ? 's' : ''} à compléter`;
|
||||
}
|
||||
} catch (e) { error = e.message; }
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function reset() { step = 'upload'; transactions = []; result = null; error = ''; closeSplit(); }
|
||||
|
||||
$: toImport = transactions.filter(t => t.status === 'import' || t.status === 'split');
|
||||
$: ignored = transactions.filter(t => t.status === 'ignore');
|
||||
$: readyCount = transactions.filter(t => t.status === 'split' || (t.status === 'import' && t.property_id && t.category_id)).length;
|
||||
$: uncatCount = transactions.filter(t => t.status === 'import' && !t.category_id).length;
|
||||
$: missingPropCount = transactions.filter(t => t.status === 'import' && !t.property_id).length;
|
||||
|
||||
const fmt = (n) => Number(n).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €';
|
||||
const fmtDate = (d) => { if (!d) return '—'; const p = d.split('-'); return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : d; };
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-6xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<FileSpreadsheet size={22} class="text-gray-400"/>
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Import bancaire</h1>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-0.5">Importez vos relevés QIF — ignorez les virements personnels</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étapes -->
|
||||
<div class="flex items-center gap-2 mb-8 text-xs font-medium">
|
||||
{#each [['upload','Fichier'], ['preview','Vérification'], ['done','Terminé']] as [s, label], i}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold
|
||||
{step === s ? 'bg-blue-600 text-white' :
|
||||
(i === 0 && step !== 'upload') || (i === 1 && step === 'done') ? 'bg-green-500 text-white' :
|
||||
'bg-gray-100 dark:bg-gray-800 text-gray-400'}">
|
||||
{(i === 0 && step !== 'upload') || (i === 1 && step === 'done') ? '✓' : i + 1}
|
||||
</div>
|
||||
<span class="{step === s ? 'text-gray-900 dark:text-white' : 'text-gray-400'}">{label}</span>
|
||||
</div>
|
||||
{#if i < 2}<div class="flex-1 h-px bg-gray-100 dark:bg-gray-800"/>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="flex items-center gap-2 text-sm px-4 py-3 rounded-lg mb-4 bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400">
|
||||
<AlertCircle size={14}/> {error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Étape 1 -->
|
||||
{#if step === 'upload'}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-6">
|
||||
<div
|
||||
on:dragover|preventDefault={() => dragover = true}
|
||||
on:dragleave={() => dragover = false}
|
||||
on:drop|preventDefault={onDrop}
|
||||
on:click={() => fileInput.click()}
|
||||
role="button" tabindex="0"
|
||||
on:keydown={(e) => e.key === 'Enter' && fileInput.click()}
|
||||
class="border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors
|
||||
{dragover ? 'border-blue-400 bg-blue-50 dark:bg-blue-950/30' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center gap-2 text-blue-600">
|
||||
<div class="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"/>
|
||||
Analyse du fichier...
|
||||
</div>
|
||||
{:else}
|
||||
<Upload size={28} class="mx-auto mb-3 text-gray-300 dark:text-gray-600"/>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Glissez votre fichier QIF ici</p>
|
||||
<p class="text-xs text-gray-400 mt-1">ou cliquez pour choisir — formats .qif, .qfx</p>
|
||||
{/if}
|
||||
</div>
|
||||
<input bind:this={fileInput} type="file" accept=".qif,.qfx" class="hidden" on:change={onFileInput}/>
|
||||
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-950/30 rounded-lg text-xs text-blue-600 dark:text-blue-400">
|
||||
<p class="font-medium text-sm text-blue-700 dark:text-blue-300 mb-1">Comment exporter depuis votre banque ?</p>
|
||||
Espace client → Mes comptes → Télécharger / Exporter → Format QIF.
|
||||
Vous pourrez assigner chaque ligne à un appartement, la ventiler sur plusieurs biens (✂️), ou l'ignorer.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 2 -->
|
||||
{:else if step === 'preview'}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-4 mb-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{toImport.length} à importer</span>
|
||||
{#if ignored.length > 0}
|
||||
<span class="text-gray-400 text-sm">· {ignored.length} ignorée{ignored.length > 1 ? 's' : ''}</span>
|
||||
{/if}
|
||||
{#if missingPropCount > 0}
|
||||
<span class="flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400">
|
||||
<AlertCircle size={11}/> {missingPropCount} sans bien — import bloqué
|
||||
</span>
|
||||
{/if}
|
||||
{#if uncatCount > 0}
|
||||
<span class="flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-300">
|
||||
<Tag size={11}/> {uncatCount} sans catégorie — import bloqué
|
||||
</span>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 ml-auto flex-wrap">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Assigner tous à :</span>
|
||||
<select on:change={(e) => { assignAllProperty(e.target.value); e.target.value = ''; }}
|
||||
class="px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Choisir...</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<button on:click={reset} class="px-3 py-1.5 text-xs border border-gray-200 dark:border-gray-700 text-gray-500 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
Recommencer
|
||||
</button>
|
||||
<button on:click={doImport} disabled={loading || readyCount === 0}
|
||||
class="flex items-center gap-2 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
{#if loading}<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"/>{:else}<Check size={14}/>{/if}
|
||||
Importer {readyCount}{readyCount < toImport.length ? ` / ${toImport.length}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<th class="px-3 py-3 w-24 text-center font-medium">Action</th>
|
||||
<th class="text-left px-3 py-3 font-medium w-24">Date</th>
|
||||
<th class="text-left px-3 py-3 font-medium">Description</th>
|
||||
<th class="text-left px-3 py-3 font-medium w-40">Bien <span class="text-red-400">*</span></th>
|
||||
<th class="text-left px-3 py-3 font-medium w-44">Catégorie</th>
|
||||
<th class="text-right px-3 py-3 font-medium w-24" title="Frais de gestion locative déduits par l'agence">Frais agence</th>
|
||||
<th class="text-right px-3 py-3 font-medium w-28">Montant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50 dark:divide-gray-800">
|
||||
{#each transactions as t, i (i)}
|
||||
{#if t.status !== 'absorbed'}
|
||||
<!-- Ligne normale ou ventilée -->
|
||||
<tr class="transition-colors
|
||||
{t.alreadyImported ? 'opacity-40 bg-gray-50 dark:bg-gray-800/30' : ''}
|
||||
{!t.alreadyImported && t.status === 'ignore' ? 'opacity-30' : ''}
|
||||
{t.status === 'split' ? 'bg-purple-50/40 dark:bg-purple-950/10' : ''}
|
||||
{t._mergedWithIdx != null ? 'bg-amber-50/30 dark:bg-amber-950/10' : ''}
|
||||
{mergeAnchorIdx === i ? 'ring-2 ring-inset ring-amber-400' : ''}
|
||||
{!t.alreadyImported && t.status === 'import' && !t.property_id ? 'bg-red-50/40 dark:bg-red-950/10' :
|
||||
!t.alreadyImported && t.status === 'import' && !t.category_id ? 'bg-amber-50/40 dark:bg-amber-950/10' :
|
||||
!t.alreadyImported && t.status === 'import' ? 'hover:bg-gray-50 dark:hover:bg-gray-800/40' : ''}">
|
||||
|
||||
<!-- Boutons action -->
|
||||
<td class="px-3 py-2 text-center">
|
||||
<div class="inline-flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<button on:click={() => transactions[i].status = transactions[i].status === 'ignore' ? 'import' : (transactions[i].status === 'split' ? 'split' : 'import')}
|
||||
title="Importer"
|
||||
class="px-2.5 py-1 text-xs transition-colors
|
||||
{t.status === 'import' || t.status === 'split' ? 'bg-blue-600 text-white' : 'text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'}">
|
||||
<Check size={12}/>
|
||||
</button>
|
||||
{#if t.status !== 'ignore' && isLoanPayment(t.amount)}
|
||||
<button on:click={() => autoSplit(i)}
|
||||
title="Split automatique intérêts/capital"
|
||||
class="px-2.5 py-1 text-xs border-l border-gray-200 dark:border-gray-700 transition-colors
|
||||
{t.status === 'split' ? 'bg-purple-600 text-white' : 'text-yellow-600 bg-yellow-50 hover:bg-yellow-100 dark:bg-yellow-950/30 dark:hover:bg-yellow-950/50'}">
|
||||
✂ auto
|
||||
</button>
|
||||
{:else}
|
||||
<button on:click={() => openSplit(i)}
|
||||
title="Ventiler sur plusieurs biens"
|
||||
class="px-2.5 py-1 text-xs border-l border-gray-200 dark:border-gray-700 transition-colors
|
||||
{t.status === 'split' ? 'bg-purple-600 text-white' : 'text-gray-400 hover:bg-purple-50 dark:hover:bg-purple-900'}">
|
||||
<GitFork size={12}/>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Bouton fusion -->
|
||||
{#if t._mergedWithIdx != null}
|
||||
<button on:click={() => unmerge(i)}
|
||||
title="Défusionner"
|
||||
class="px-2.5 py-1 text-xs border-l border-gray-200 dark:border-gray-700 bg-amber-500 text-white hover:bg-amber-600 transition-colors">
|
||||
<Link2 size={12}/>
|
||||
</button>
|
||||
{:else}
|
||||
<button on:click={() => startMerge(i)}
|
||||
title={mergeAnchorIdx === null ? 'Fusionner avec une autre ligne' : mergeAnchorIdx === i ? 'Annuler fusion' : 'Fusionner avec cette ligne'}
|
||||
class="px-2.5 py-1 text-xs border-l border-gray-200 dark:border-gray-700 transition-colors
|
||||
{mergeAnchorIdx === i ? 'bg-amber-500 text-white' : mergeAnchorIdx !== null ? 'bg-green-500 text-white hover:bg-green-600' : 'text-gray-400 hover:bg-amber-50 dark:hover:bg-amber-950/30'}">
|
||||
<Link2 size={12}/>
|
||||
</button>
|
||||
{/if}
|
||||
<button on:click={() => transactions[i].status = 'ignore'}
|
||||
title="Ignorer"
|
||||
class="px-2.5 py-1 text-xs border-l border-gray-200 dark:border-gray-700 transition-colors
|
||||
{t.status === 'ignore' ? 'bg-gray-500 text-white' : 'text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'}">
|
||||
<X size={12}/>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2 text-gray-400 dark:text-gray-500 text-xs whitespace-nowrap">
|
||||
{fmtDate(t.date)}
|
||||
{#if t.alreadyImported}
|
||||
<div class="text-xs text-gray-400 italic">déjà importée</div>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2">
|
||||
{#if t.status === 'split'}
|
||||
<!-- Afficher les parts -->
|
||||
<div class="space-y-0.5">
|
||||
{#each t.splits as s}
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
<span class="text-purple-600 dark:text-purple-400 font-medium">{fmt(s.amount)}</span>
|
||||
<span class="text-gray-400">→ {properties.find(p => p.id === s.property_id)?.name || '?'}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<button on:click={() => removeSplit(i)} class="text-xs text-red-400 hover:text-red-600">× annuler ventilation</button>
|
||||
</div>
|
||||
{:else}
|
||||
<input bind:value={transactions[i].description}
|
||||
disabled={t.status === 'ignore'}
|
||||
placeholder="Description..."
|
||||
class="w-full px-2 py-1 rounded border border-transparent hover:border-gray-200 dark:hover:border-gray-700 focus:border-blue-400 bg-transparent focus:bg-white dark:focus:bg-gray-800 text-gray-900 dark:text-white text-xs focus:outline-none transition-colors min-w-[180px] disabled:cursor-not-allowed"/>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2">
|
||||
{#if t.status === 'import'}
|
||||
<select bind:value={transactions[i].property_id}
|
||||
class="w-full px-2 py-1 rounded border text-xs
|
||||
{!t.property_id ? 'border-red-300 bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-300' : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'}
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<option value="">— Choisir —</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
{:else if t.status === 'split'}
|
||||
<span class="text-xs text-purple-600 dark:text-purple-400 font-medium">Ventilée</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-300 dark:text-gray-600 italic">ignorée</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2">
|
||||
{#if t.status === 'import'}
|
||||
<select bind:value={transactions[i].category_id}
|
||||
class="w-full px-2 py-1 rounded border text-xs
|
||||
{!t.category_id ? 'border-amber-300 bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-300' : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'}
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||
<option value="">— Sans catégorie —</option>
|
||||
{#each catsFor(t.type) as c}
|
||||
<option value={c.id}>{c.name}{c.tax_deductible ? ' ✓' : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2 text-right">
|
||||
{#if t.status === 'import' && t.type === 'income'}
|
||||
<input
|
||||
type="number" min="0" step="0.01"
|
||||
placeholder="0,00"
|
||||
value={t.agencyFee || ''}
|
||||
on:change={(e) => { transactions[i].agencyFee = parseFloat(e.target.value) || 0; transactions = [...transactions]; }}
|
||||
class="w-20 px-2 py-1 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-xs text-right focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-300">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="px-3 py-2 text-right font-semibold whitespace-nowrap text-xs
|
||||
{t.type === 'income' ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}">
|
||||
{t.type === 'income' ? '+' : '−'}{fmt(t.amount)}
|
||||
{#if t._mergedWithIdx != null}
|
||||
<div class="text-xs font-normal text-amber-600 dark:text-amber-400">⛓ 2 lignes fusionnées</div>
|
||||
{/if}
|
||||
{#if (t.agencyFee || 0) > 0}
|
||||
<div class="text-xs font-normal text-orange-500">−{fmt(t.agencyFee)} frais</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-gray-400 dark:text-gray-500 flex flex-wrap gap-4">
|
||||
<span class="flex items-center gap-1.5"><span class="inline-flex items-center justify-center w-5 h-5 rounded bg-blue-600 text-white"><Check size={10}/></span>Importer</span>
|
||||
<span class="flex items-center gap-1.5"><span class="inline-flex items-center justify-center w-5 h-5 rounded bg-purple-600 text-white"><GitFork size={10}/></span>Ventiler sur plusieurs biens</span>
|
||||
<span class="flex items-center gap-1.5"><span class="inline-flex items-center justify-center w-5 h-5 rounded bg-gray-500 text-white"><X size={10}/></span>Ignorer</span>
|
||||
<span class="flex items-center gap-1.5"><span class="inline-flex items-center justify-center w-5 h-5 rounded bg-amber-500 text-white"><Link2 size={10}/></span>Fusionner 2 prélèvements → 1 mensualité</span>
|
||||
</div>
|
||||
|
||||
<!-- Étape 3 -->
|
||||
{:else if step === 'done'}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-10 text-center">
|
||||
<div class="w-14 h-14 bg-green-100 dark:bg-green-950 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Check size={28} class="text-green-600 dark:text-green-400"/>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Import terminé</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mb-6">
|
||||
{result.imported} transaction{result.imported > 1 ? 's' : ''} importée{result.imported > 1 ? 's' : ''}
|
||||
{#if result.skipped > 0}· {result.skipped} ignorée{result.skipped > 1 ? 's' : ''}{/if}
|
||||
</p>
|
||||
{#if result.errors?.length > 0}
|
||||
<div class="text-left mb-6 p-3 bg-red-50 dark:bg-red-950/30 rounded-lg text-xs text-red-600">
|
||||
{#each result.errors as e}<p>{e}</p>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-center gap-3">
|
||||
<button on:click={reset} class="px-4 py-2 text-sm border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
Nouvel import
|
||||
</button>
|
||||
<a href="/transactions" class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
|
||||
Voir les transactions →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modal sélection ligne d'amortissement -->
|
||||
{#if loanPickerIdx !== null}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-2xl shadow-xl border border-gray-100 dark:border-gray-800 flex flex-col max-h-[80vh]">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div>
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Choisir la ligne d'amortissement</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
{transactions[loanPickerIdx]?.description || '—'} ·
|
||||
<span class="font-medium text-red-500">{fmt(transactions[loanPickerIdx]?.amount)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button on:click={closeLoanPicker} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 px-6 py-4 space-y-5">
|
||||
{#if loanPickerLoading}
|
||||
<div class="text-center py-10 text-gray-400 text-sm">Chargement...</div>
|
||||
{:else}
|
||||
{#each loanPickerData as lr}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
{lr.loan.label} — {lr.loan.property_name || ''}
|
||||
</p>
|
||||
{#if lr.lines.length === 0}
|
||||
<p class="text-xs text-gray-400 italic">Aucune ligne pour cette année.</p>
|
||||
{:else}
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-400 border-b border-gray-100 dark:border-gray-800">
|
||||
<th class="text-left pb-1.5 pr-3">Éch.</th>
|
||||
<th class="text-left pb-1.5 pr-3">Date</th>
|
||||
<th class="text-right pb-1.5 pr-3">Capital</th>
|
||||
<th class="text-right pb-1.5 pr-3 text-blue-500">Intérêts</th>
|
||||
<th class="text-right pb-1.5">Mensualité</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each lr.lines as line}
|
||||
<tr class="border-b border-gray-50 dark:border-gray-800 hover:bg-blue-50 dark:hover:bg-blue-950/20 cursor-pointer"
|
||||
on:click={() => selectLoanLine(lr, line)}>
|
||||
<td class="py-2 pr-3 text-gray-400">#{line.rank}</td>
|
||||
<td class="py-2 pr-3">{fmtDate(line.due_date.substring(0,10))}</td>
|
||||
<td class="py-2 pr-3 text-right font-medium">{fmt(line.capital)}</td>
|
||||
<td class="py-2 pr-3 text-right text-blue-600 dark:text-blue-400">{fmt(line.interest)}</td>
|
||||
<td class="py-2 text-right text-gray-500">{fmt(line.total_amount)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal split -->
|
||||
{#if splitIdx !== null && splitSource}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-xl shadow-xl border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div>
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Ventiler la transaction</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
{splitSource.description || '—'} ·
|
||||
<span class="font-medium {splitSource.type === 'income' ? 'text-green-600' : 'text-red-500'}">
|
||||
{fmt(splitSource.amount)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button on:click={closeSplit} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
|
||||
</div>
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
{#if splitError}<p class="text-red-500 text-sm">{splitError}</p>{/if}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Répartissez le montant entre plusieurs biens. Chaque part doit avoir un bien et une catégorie.</p>
|
||||
|
||||
{#each splitParts as part, i}
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Toggle type par part -->
|
||||
<div class="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<button on:click={() => { splitParts[i].type = 'income'; splitParts[i].category_id = ''; splitParts = [...splitParts]; }}
|
||||
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'income' ? 'bg-green-600 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
|
||||
Revenu
|
||||
</button>
|
||||
<button on:click={() => { splitParts[i].type = 'expense'; splitParts[i].category_id = ''; splitParts = [...splitParts]; }}
|
||||
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'expense' ? 'bg-red-500 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
|
||||
Dépense
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm font-semibold {part.type === 'income' ? 'text-green-600' : 'text-red-500'}">{part.type === 'income' ? '+' : '−'}{fmt(Math.abs(part.amount))}</span>
|
||||
</div>
|
||||
<!-- Slider (uniquement si tous même type) -->
|
||||
{#if splitParts.every(p => p.type === splitSource.type)}
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="range" min="0" max="100" step="0.5" value={part.pct}
|
||||
on:input={(e) => updateSplitPct(i, e.target.value)} class="flex-1 accent-blue-600"/>
|
||||
<div class="flex items-center gap-1">
|
||||
<input type="number" min="0" max="100" step="0.5" value={part.pct}
|
||||
on:change={(e) => updateSplitPct(i, e.target.value)}
|
||||
class="w-14 px-2 py-1 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs text-right focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
<span class="text-xs text-gray-400">%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label for="split-prop-{i}" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Montant (€)</label>
|
||||
<input type="number" min="0" step="0.01" value={part.amount}
|
||||
on:change={(e) => updateSplitAmount(i, e.target.value)}
|
||||
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Bien *</label>
|
||||
<select bind:value={splitParts[i].property_id}
|
||||
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Choisir...</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="split-cat-{i}" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Catégorie</label>
|
||||
<select id="split-cat-{i}" bind:value={splitParts[i].category_id}
|
||||
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">—</option>
|
||||
{#each catsFor(part.type) as c}
|
||||
<option value={c.id}>{c.name}{c.tax_deductible ? ' ✓' : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="split-desc-{i}" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Description</label>
|
||||
<input id="split-desc-{i}" bind:value={splitParts[i].description}
|
||||
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Net check -->
|
||||
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg px-4 py-3 text-xs space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-green-600">Revenus</span>
|
||||
<span class="font-medium text-green-600">+{fmt(splitNetIncome)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-red-500">Dépenses</span>
|
||||
<span class="font-medium text-red-500">−{fmt(splitNetExpense)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-gray-200 dark:border-gray-700 pt-1 mt-1">
|
||||
<span class="font-semibold text-gray-700 dark:text-gray-300">
|
||||
Net ({splitSource.type === 'income' ? 'revenus − dépenses' : 'dépenses − revenus'})
|
||||
</span>
|
||||
<span class="font-semibold {Math.abs(splitDiff) <= 0.01 ? 'text-green-600' : 'text-red-500'}">
|
||||
{fmt(splitNet)}
|
||||
{#if Math.abs(splitDiff) <= 0.01}
|
||||
<span class="text-green-500 ml-1">✓</span>
|
||||
{:else}
|
||||
<span class="text-red-400 ml-1">≠ {fmt(splitSource.amount)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<button on:click={closeSplit} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
|
||||
<button on:click={applySplit} disabled={Math.abs(splitDiff) > 0.01}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<GitFork size={15}/> Ventiler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
350
frontend/src/routes/loans/+page.svelte
Normal file
350
frontend/src/routes/loans/+page.svelte
Normal file
@@ -0,0 +1,350 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/stores/api.js';
|
||||
import { Building2, Trash2, X, Check, AlertCircle } from 'lucide-svelte';
|
||||
|
||||
let loans = [];
|
||||
let properties = [];
|
||||
let categories = [];
|
||||
let loading = true;
|
||||
let showUpload = false;
|
||||
let showDetail = null;
|
||||
let uploading = false;
|
||||
let error = '';
|
||||
let yearDetail = String(new Date().getFullYear());
|
||||
let lines = [];
|
||||
let annualSummary = null;
|
||||
|
||||
// Les deux prêts connus — données embarquées dans le backend
|
||||
const KNOWN_LOANS = [
|
||||
{ reference: '781495E', label: 'Prêt CE 781495E', initial_amount: 183765, monthly_payment: 1084.75 },
|
||||
{ reference: '781728E', label: 'Prêt CE 781728E', initial_amount: 122946, monthly_payment: 725.74 },
|
||||
];
|
||||
|
||||
let selectedLoan = KNOWN_LOANS[0];
|
||||
let selectedPropertyId = '';
|
||||
|
||||
const years = Array.from({ length: 8 }, (_, i) => String(2024 + i));
|
||||
const fmt = (n) => Number(n || 0).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €';
|
||||
const fmtDate = (d) => { if (!d) return '—'; const p = d.split('-'); return `${p[2]}/${p[1]}/${p[0]}`; };
|
||||
|
||||
onMount(async () => {
|
||||
[properties, categories] = await Promise.all([
|
||||
api.properties.list(),
|
||||
api.categories.list(),
|
||||
]);
|
||||
properties = properties || [];
|
||||
categories = categories || [];
|
||||
if (properties.length > 0) selectedPropertyId = properties[0].id;
|
||||
await load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loans = await api.loans.list() || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function openDetail(loan) {
|
||||
showDetail = loan;
|
||||
await loadDetail();
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!showDetail) return;
|
||||
[lines, annualSummary] = await Promise.all([
|
||||
api.loans.lines(showDetail.id, { year: yearDetail }),
|
||||
api.loans.annualSummary(showDetail.id, { year: yearDetail }),
|
||||
]);
|
||||
lines = lines || [];
|
||||
annualSummary = annualSummary || null;
|
||||
}
|
||||
|
||||
async function addLoan() {
|
||||
if (!selectedPropertyId) { error = 'Sélectionnez un bien.'; return; }
|
||||
error = '';
|
||||
uploading = true;
|
||||
try {
|
||||
const result = await api.loans.createWithData({
|
||||
property_id: selectedPropertyId,
|
||||
label: selectedLoan.label,
|
||||
reference: selectedLoan.reference,
|
||||
initial_amount: selectedLoan.initial_amount,
|
||||
monthly_payment: selectedLoan.monthly_payment,
|
||||
});
|
||||
showUpload = false;
|
||||
await load();
|
||||
} catch (e) { error = e.message; }
|
||||
uploading = false;
|
||||
}
|
||||
|
||||
async function deleteLoan(id, label) {
|
||||
if (!confirm(`Supprimer le prêt "${label}" ?`)) return;
|
||||
await api.loans.delete(id);
|
||||
if (showDetail?.id === id) showDetail = null;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function createTransactions() {
|
||||
if (!showDetail || !lines.length) return;
|
||||
const interestCat = categories.find(c =>
|
||||
c.name.toLowerCase().includes('intérêt') || c.name.toLowerCase().includes('interet')
|
||||
);
|
||||
const capitalCat = categories.find(c => c.name.toLowerCase().includes('capital'));
|
||||
if (!interestCat) {
|
||||
alert('Créez d\'abord une catégorie "Intérêts emprunt" (déductible) dans la page Catégories.');
|
||||
return;
|
||||
}
|
||||
let created = 0;
|
||||
for (const line of lines) {
|
||||
if (line.capital <= 0) continue;
|
||||
await api.transactions.create({
|
||||
property_id: showDetail.property_id,
|
||||
category_id: interestCat.id,
|
||||
type: 'expense',
|
||||
amount: line.interest,
|
||||
date: line.due_date,
|
||||
description: `Intérêts ${showDetail.reference} — échéance ${line.rank}`,
|
||||
});
|
||||
if (capitalCat) {
|
||||
await api.transactions.create({
|
||||
property_id: showDetail.property_id,
|
||||
category_id: capitalCat.id,
|
||||
type: 'expense',
|
||||
amount: line.capital,
|
||||
date: line.due_date,
|
||||
description: `Capital ${showDetail.reference} — échéance ${line.rank}`,
|
||||
});
|
||||
}
|
||||
created++;
|
||||
}
|
||||
alert(`✓ ${created} échéances créées en transactions pour ${yearDetail}.`);
|
||||
}
|
||||
|
||||
// Prêts déjà ajoutés (pour désactiver le doublon)
|
||||
$: existingRefs = loans.map(l => l.reference);
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<Building2 size={22} class="text-gray-400"/>
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Prêts immobiliers</h1>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-0.5">Tableau d'amortissement — décomposition intérêts / capital</p>
|
||||
</div>
|
||||
</div>
|
||||
<button on:click={() => { showUpload = true; error = ''; }}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Building2 size={15}/> Ajouter un prêt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-950/30 border border-blue-100 dark:border-blue-900 rounded-xl p-4 mb-6 text-sm text-blue-700 dark:text-blue-300">
|
||||
<p class="font-medium mb-1">Pourquoi gérer les prêts ici ?</p>
|
||||
<p class="text-xs text-blue-600 dark:text-blue-400">
|
||||
Vos remboursements mensuels mélangent <strong>capital</strong> (non déductible) et <strong>intérêts</strong> (déductibles fiscalement).
|
||||
Ce module connaît la décomposition exacte pour chaque mois et peut créer automatiquement
|
||||
les transactions séparées pour la liasse fiscale.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each [1,2] as _}<div class="h-24 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>{/each}
|
||||
</div>
|
||||
{:else if loans.length === 0}
|
||||
<div class="text-center py-16 text-gray-400">
|
||||
<Building2 size={40} class="mx-auto mb-3 opacity-30"/>
|
||||
<p class="text-sm mb-4">Aucun prêt configuré.</p>
|
||||
<button on:click={() => showUpload = true}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
Ajouter un prêt
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each loans as loan (loan.id)}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-5 flex items-center justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">{loan.label}</h2>
|
||||
<span class="text-xs text-gray-400 font-mono bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{loan.reference}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">{loan.property_name}</p>
|
||||
<div class="flex gap-6 text-xs text-gray-400 dark:text-gray-500">
|
||||
<span>Capital initial : <strong class="text-gray-700 dark:text-gray-300">{fmt(loan.initial_amount)}</strong></span>
|
||||
<span>Mensualité : <strong class="text-gray-700 dark:text-gray-300">{fmt(loan.monthly_payment)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button on:click={() => openDetail(loan)}
|
||||
class="px-3 py-1.5 text-sm border border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-400 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors">
|
||||
Voir échéances
|
||||
</button>
|
||||
<button on:click={() => deleteLoan(loan.id, loan.label)}
|
||||
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
|
||||
<Trash2 size={15}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modal ajout prêt -->
|
||||
{#if showUpload}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Ajouter un prêt</h2>
|
||||
<button on:click={() => showUpload = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
|
||||
</div>
|
||||
<div class="px-6 py-5 space-y-5">
|
||||
{#if error}
|
||||
<div class="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400">
|
||||
<AlertCircle size={13}/> {error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Choix du prêt -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Prêt *</label>
|
||||
<div class="space-y-2">
|
||||
{#each KNOWN_LOANS as loan}
|
||||
<label class="flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-colors
|
||||
{selectedLoan.reference === loan.reference
|
||||
? 'border-blue-400 bg-blue-50 dark:bg-blue-950/30'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}
|
||||
{existingRefs.includes(loan.reference) ? 'opacity-40 cursor-not-allowed' : ''}">
|
||||
<input type="radio" bind:group={selectedLoan} value={loan}
|
||||
disabled={existingRefs.includes(loan.reference)}
|
||||
class="accent-blue-600"/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{loan.reference}</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Capital {fmt(loan.initial_amount)} · Mensualité {fmt(loan.monthly_payment)}
|
||||
</p>
|
||||
</div>
|
||||
{#if existingRefs.includes(loan.reference)}
|
||||
<span class="text-xs text-gray-400">Déjà ajouté</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Choix du bien -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bien associé *</label>
|
||||
<select bind:value={selectedPropertyId}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Les 216 échéances (2024→2044) sont déjà intégrées et seront chargées automatiquement.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<button on:click={() => showUpload = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
|
||||
<button on:click={addLoan} disabled={uploading}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
{#if uploading}
|
||||
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"/>
|
||||
{:else}
|
||||
<Check size={15}/>
|
||||
{/if}
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal détail échéances -->
|
||||
{#if showDetail}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-xl border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800 shrink-0">
|
||||
<div>
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">{showDetail.label}</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{showDetail.property_name} · {showDetail.reference}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<select bind:value={yearDetail} on:change={loadDetail}
|
||||
class="px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{#each years as y}<option value={y}>{y}</option>{/each}
|
||||
</select>
|
||||
<button on:click={() => showDetail = null} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if annualSummary && annualSummary.months > 0}
|
||||
<div class="grid grid-cols-4 gap-4 px-6 py-4 border-b border-gray-100 dark:border-gray-800 shrink-0 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Échéances</p>
|
||||
<p class="text-xl font-semibold text-gray-900 dark:text-white">{annualSummary.months}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Total payé</p>
|
||||
<p class="text-xl font-semibold text-gray-700 dark:text-gray-300">{fmt(annualSummary.total_payment)}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Intérêts <span class="text-blue-500 font-medium">✓ déductibles</span></p>
|
||||
<p class="text-xl font-semibold text-blue-600 dark:text-blue-400">{fmt(annualSummary.total_interest)}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Capital remboursé</p>
|
||||
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">{fmt(annualSummary.total_capital)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#if lines.length === 0}
|
||||
<div class="text-center py-12 text-gray-400 text-sm">Aucune échéance pour {yearDetail}.</div>
|
||||
{:else}
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
|
||||
<tr class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<th class="text-left px-4 py-3 font-medium">Rang</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Échéance</th>
|
||||
<th class="text-right px-4 py-3 font-medium">Mensualité</th>
|
||||
<th class="text-right px-4 py-3 font-medium text-blue-600 dark:text-blue-400">Intérêts ✓</th>
|
||||
<th class="text-right px-4 py-3 font-medium">Capital</th>
|
||||
<th class="text-right px-4 py-3 font-medium text-gray-400">Capital restant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50 dark:divide-gray-800">
|
||||
{#each lines as l (l.id)}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
<td class="px-4 py-2.5 text-gray-400 text-xs">{l.rank}</td>
|
||||
<td class="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{fmtDate(l.due_date)}</td>
|
||||
<td class="px-4 py-2.5 text-right text-gray-600 dark:text-gray-400">{fmt(l.total_amount)}</td>
|
||||
<td class="px-4 py-2.5 text-right font-semibold text-blue-600 dark:text-blue-400">{fmt(l.interest)}</td>
|
||||
<td class="px-4 py-2.5 text-right text-gray-500 dark:text-gray-400">{fmt(l.capital)}</td>
|
||||
<td class="px-4 py-2.5 text-right text-xs text-gray-400 dark:text-gray-500">{fmt(l.remaining_capital)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-6 py-4 border-t border-gray-100 dark:border-gray-800 shrink-0">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Les intérêts en bleu sont déductibles — nécessite catégorie "Intérêts emprunt".
|
||||
</p>
|
||||
{#if lines.length > 0}
|
||||
<button on:click={createTransactions}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Check size={14}/> Créer les transactions {yearDetail}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
36
frontend/src/routes/login/+page.svelte
Normal file
36
frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, currentUser, authToken } from '$lib/stores/api.js';
|
||||
|
||||
let email = '', password = '', error = '';
|
||||
|
||||
async function submit() {
|
||||
error = '';
|
||||
try {
|
||||
const res = await api.auth.login(email, password);
|
||||
authToken.set(res.token);
|
||||
currentUser.set(res.user);
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl p-8 w-full max-w-sm border border-gray-100 dark:border-gray-800 shadow-sm">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">🏠 Mes Locations</h1>
|
||||
{#if error}<p class="text-red-500 text-sm mb-4">{error}</p>{/if}
|
||||
<div class="space-y-4">
|
||||
<input type="email" placeholder="Email" bind:value={email}
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
<input type="password" placeholder="Mot de passe" bind:value={password}
|
||||
on:keydown={(e) => e.key === 'Enter' && submit()}
|
||||
class="w-full px-4 py-2.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
<button on:click={submit}
|
||||
class="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
Se connecter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
137
frontend/src/routes/profile/+page.svelte
Normal file
137
frontend/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api, currentUser } from '$lib/stores/api.js';
|
||||
import { User, KeyRound, Check, AlertCircle } from 'lucide-svelte';
|
||||
|
||||
let profile = { email: '', name: '' };
|
||||
let passwords = { current_password: '', new_password: '', confirm: '' };
|
||||
|
||||
let profileMsg = null; // { type: 'success'|'error', text }
|
||||
let passwordMsg = null;
|
||||
let savingProfile = false;
|
||||
let savingPassword = false;
|
||||
|
||||
onMount(() => {
|
||||
const u = $currentUser;
|
||||
if (u) profile = { email: u.email, name: u.name };
|
||||
});
|
||||
|
||||
async function saveProfile() {
|
||||
profileMsg = null;
|
||||
savingProfile = true;
|
||||
try {
|
||||
const updated = await api.auth.updateProfile(profile);
|
||||
currentUser.set(updated);
|
||||
profileMsg = { type: 'success', text: 'Profil mis à jour.' };
|
||||
} catch (e) {
|
||||
profileMsg = { type: 'error', text: e.message };
|
||||
}
|
||||
savingProfile = false;
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
passwordMsg = null;
|
||||
if (passwords.new_password !== passwords.confirm) {
|
||||
passwordMsg = { type: 'error', text: 'Les mots de passe ne correspondent pas.' };
|
||||
return;
|
||||
}
|
||||
if (passwords.new_password.length < 6) {
|
||||
passwordMsg = { type: 'error', text: 'Minimum 6 caractères.' };
|
||||
return;
|
||||
}
|
||||
savingPassword = true;
|
||||
try {
|
||||
await api.auth.updatePassword({
|
||||
current_password: passwords.current_password,
|
||||
new_password: passwords.new_password,
|
||||
});
|
||||
passwords = { current_password: '', new_password: '', confirm: '' };
|
||||
passwordMsg = { type: 'success', text: 'Mot de passe modifié.' };
|
||||
} catch (e) {
|
||||
passwordMsg = { type: 'error', text: e.message };
|
||||
}
|
||||
savingPassword = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<User size={22} class="text-gray-400"/>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Mon profil</h1>
|
||||
</div>
|
||||
|
||||
<!-- Informations -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-6 mb-5">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
|
||||
<User size={15}/> Informations personnelles
|
||||
</h2>
|
||||
|
||||
{#if profileMsg}
|
||||
<div class="flex items-center gap-2 text-sm px-4 py-3 rounded-lg mb-4
|
||||
{profileMsg.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-300'}">
|
||||
<AlertCircle size={14}/> {profileMsg.text}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nom affiché</label>
|
||||
<input bind:value={profile.name}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Adresse email</label>
|
||||
<input type="email" bind:value={profile.email}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button on:click={saveProfile} disabled={savingProfile}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Check size={15}/> {savingProfile ? 'Enregistrement...' : 'Enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mot de passe -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 p-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
|
||||
<KeyRound size={15}/> Changer le mot de passe
|
||||
</h2>
|
||||
|
||||
{#if passwordMsg}
|
||||
<div class="flex items-center gap-2 text-sm px-4 py-3 rounded-lg mb-4
|
||||
{passwordMsg.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-300'}">
|
||||
<AlertCircle size={14}/> {passwordMsg.text}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mot de passe actuel</label>
|
||||
<input type="password" bind:value={passwords.current_password} autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nouveau mot de passe</label>
|
||||
<input type="password" bind:value={passwords.new_password} autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Confirmer le nouveau mot de passe</label>
|
||||
<input type="password" bind:value={passwords.confirm} autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button on:click={savePassword} disabled={savingPassword}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<KeyRound size={15}/> {savingPassword ? 'Modification...' : 'Changer le mot de passe'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
174
frontend/src/routes/properties/+page.svelte
Normal file
174
frontend/src/routes/properties/+page.svelte
Normal file
@@ -0,0 +1,174 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/stores/api.js';
|
||||
import { Building2, Plus, Pencil, Trash2, RefreshCw, X, Check } from 'lucide-svelte';
|
||||
|
||||
let properties = [];
|
||||
let loading = true;
|
||||
let showForm = false;
|
||||
let editingId = null;
|
||||
let syncingId = null;
|
||||
let error = '';
|
||||
|
||||
const empty = () => ({ name: '', address: '', type: 'airbnb', bank_account: '', ical_url: '', notes: '' });
|
||||
let form = empty();
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
properties = await api.properties.list() || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function openCreate() { form = empty(); editingId = null; showForm = true; error = ''; }
|
||||
function openEdit(p) { form = { ...p }; editingId = p.id; showForm = true; error = ''; }
|
||||
function cancel() { showForm = false; error = ''; }
|
||||
|
||||
async function save() {
|
||||
error = '';
|
||||
try {
|
||||
if (editingId) await api.properties.update(editingId, form);
|
||||
else await api.properties.create(form);
|
||||
showForm = false;
|
||||
await load();
|
||||
} catch (e) { error = e.message; }
|
||||
}
|
||||
|
||||
async function remove(id, name) {
|
||||
if (!confirm(`Supprimer "${name}" ? Toutes les données associées seront perdues.`)) return;
|
||||
await api.properties.delete(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function sync(id) {
|
||||
syncingId = id;
|
||||
await api.calendar.sync(id);
|
||||
syncingId = null;
|
||||
}
|
||||
|
||||
const typeLabel = { airbnb: 'Airbnb', longterm: 'Longue durée' };
|
||||
const typeBadge = {
|
||||
airbnb: 'bg-orange-50 text-orange-700 dark:bg-orange-950 dark:text-orange-300',
|
||||
longterm: 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<Building2 size={22} class="text-gray-400"/>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Biens</h1>
|
||||
</div>
|
||||
<button on:click={openCreate}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Plus size={16}/> Ajouter un bien
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each [1,2] as _}
|
||||
<div class="h-28 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if properties.length === 0}
|
||||
<div class="text-center py-16 text-gray-400">
|
||||
<Building2 size={40} class="mx-auto mb-3 opacity-30"/>
|
||||
<p>Aucun bien. Commencez par en ajouter un.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each properties as p (p.id)}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-5 border border-gray-100 dark:border-gray-800 flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white text-base">{p.name}</h2>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium {typeBadge[p.type]}">{typeLabel[p.type]}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">{p.address}</p>
|
||||
<div class="flex flex-wrap gap-4 text-xs text-gray-400 dark:text-gray-500">
|
||||
{#if p.bank_account}<span>🏦 {p.bank_account}</span>{/if}
|
||||
{#if p.ical_url}<span class="text-green-600 dark:text-green-400">✓ iCal configuré</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{#if p.type === 'airbnb' && p.ical_url}
|
||||
<button on:click={() => sync(p.id)} title="Synchroniser iCal"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors">
|
||||
<RefreshCw size={16} class="{syncingId === p.id ? 'animate-spin' : ''}"/>
|
||||
</button>
|
||||
{/if}
|
||||
<button on:click={() => openEdit(p)}
|
||||
class="p-2 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<Pencil size={16}/>
|
||||
</button>
|
||||
<button on:click={() => remove(p.id, p.name)}
|
||||
class="p-2 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
|
||||
<Trash2 size={16}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" on:click|self={cancel}>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-lg shadow-xl border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">{editingId ? 'Modifier le bien' : 'Nouveau bien'}</h2>
|
||||
<button on:click={cancel} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"><X size={18}/></button>
|
||||
</div>
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
{#if error}<p class="text-red-500 text-sm">{error}</p>{/if}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nom du bien *</label>
|
||||
<input bind:value={form.name} placeholder="Ex: Appartement Paris 11e"
|
||||
class="input col-span-2 w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Adresse *</label>
|
||||
<input bind:value={form.address} placeholder="12 rue de la Paix, 75001 Paris"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Type *</label>
|
||||
<select bind:value={form.type}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="airbnb">Airbnb</option>
|
||||
<option value="longterm">Longue durée</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Compte bancaire</label>
|
||||
<input bind:value={form.bank_account} placeholder="FR76 xxxx xxxx"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
{#if form.type === 'airbnb'}
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">URL iCal Airbnb</label>
|
||||
<input bind:value={form.ical_url} placeholder="https://www.airbnb.fr/calendar/ical/..."
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
<p class="text-xs text-gray-400 mt-1">Airbnb → Annonce → Paramètres → Calendrier → Exporter</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes</label>
|
||||
<textarea bind:value={form.notes} rows={2} placeholder="Informations complémentaires..."
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<button on:click={cancel} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 transition-colors">Annuler</button>
|
||||
<button on:click={save}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Check size={15}/> {editingId ? 'Enregistrer' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
704
frontend/src/routes/transactions/+page.svelte
Normal file
704
frontend/src/routes/transactions/+page.svelte
Normal file
@@ -0,0 +1,704 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/stores/api.js';
|
||||
import { CreditCard, Plus, Trash2, X, Check, TrendingUp, TrendingDown, Pencil, GitFork, Layers } from 'lucide-svelte';
|
||||
|
||||
let transactions = [];
|
||||
let properties = [];
|
||||
let categories = [];
|
||||
let loading = true;
|
||||
let showForm = false;
|
||||
let showSplit = false;
|
||||
let showMixed = false;
|
||||
let editingId = null;
|
||||
let splitSource = null;
|
||||
let error = '';
|
||||
let splitError = '';
|
||||
let mixedError = '';
|
||||
|
||||
let filterProperty = '';
|
||||
let filterType = '';
|
||||
let filterYear = String(new Date().getFullYear());
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => String(new Date().getFullYear() - i));
|
||||
|
||||
const empty = () => ({
|
||||
property_id: '', category_id: '', type: 'expense',
|
||||
amount: '', date: new Date().toISOString().slice(0, 10),
|
||||
description: ''
|
||||
});
|
||||
let form = empty();
|
||||
|
||||
// Split state
|
||||
let splitParts = [];
|
||||
|
||||
// Ventilation mixte state
|
||||
const emptyPart = () => ({
|
||||
type: 'expense', property_id: '', category_id: '',
|
||||
amount: '', description: ''
|
||||
});
|
||||
let mixedDate = new Date().toISOString().slice(0, 10);
|
||||
let mixedParts = [
|
||||
{ ...emptyPart(), type: 'income' },
|
||||
{ ...emptyPart(), type: 'expense' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
[properties, categories] = await Promise.all([
|
||||
api.properties.list(),
|
||||
api.categories.list(),
|
||||
]);
|
||||
properties = properties || [];
|
||||
categories = categories || [];
|
||||
await load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
const params = {};
|
||||
if (filterProperty) params.property_id = filterProperty;
|
||||
if (filterType) params.type = filterType;
|
||||
if (filterYear) params.year = filterYear;
|
||||
transactions = await api.transactions.list(params) || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
$: filteredCategories = categories.filter(c => c.type === form.type);
|
||||
|
||||
// ── Formulaire création/édition ───────────────────────────────────────────
|
||||
|
||||
function openCreate() { form = empty(); editingId = null; showForm = true; error = ''; }
|
||||
|
||||
function openEdit(t) {
|
||||
form = {
|
||||
property_id: t.property_id,
|
||||
category_id: t.category_id || '',
|
||||
type: t.type,
|
||||
amount: t.amount,
|
||||
date: t.date,
|
||||
description: t.description || '',
|
||||
};
|
||||
editingId = t.id;
|
||||
showForm = true;
|
||||
error = '';
|
||||
}
|
||||
|
||||
function cancel() { showForm = false; error = ''; editingId = null; }
|
||||
|
||||
async function save() {
|
||||
error = '';
|
||||
if (!form.property_id || !form.amount || !form.date) {
|
||||
error = 'Bien, montant et date sont requis.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = { ...form, amount: parseFloat(form.amount) };
|
||||
if (editingId) await api.transactions.update(editingId, data);
|
||||
else await api.transactions.create(data);
|
||||
showForm = false;
|
||||
editingId = null;
|
||||
await load();
|
||||
} catch (e) { error = e.message; }
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm('Supprimer cette transaction ?')) return;
|
||||
await api.transactions.delete(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
// ── Ventilation mixte (sans source) ─────────────────────────────────────
|
||||
|
||||
function openMixed() {
|
||||
mixedDate = new Date().toISOString().slice(0, 10);
|
||||
mixedParts = [
|
||||
{ ...emptyPart(), type: 'income', property_id: properties[0]?.id || '' },
|
||||
{ ...emptyPart(), type: 'expense', property_id: properties[0]?.id || '' },
|
||||
];
|
||||
mixedError = '';
|
||||
showMixed = true;
|
||||
}
|
||||
|
||||
function addMixedPart() {
|
||||
mixedParts = [...mixedParts, { ...emptyPart(), property_id: properties[0]?.id || '' }];
|
||||
}
|
||||
|
||||
function removeMixedPart(i) {
|
||||
mixedParts = mixedParts.filter((_, idx) => idx !== i);
|
||||
}
|
||||
|
||||
$: mixedIncome = mixedParts.filter(p => p.type === 'income').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
|
||||
$: mixedExpense = mixedParts.filter(p => p.type === 'expense').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
|
||||
$: mixedNet = parseFloat((mixedExpense - mixedIncome).toFixed(2));
|
||||
|
||||
async function doMixed() {
|
||||
mixedError = '';
|
||||
for (const p of mixedParts) {
|
||||
if (!p.property_id) { mixedError = 'Chaque ligne doit avoir un bien.'; return; }
|
||||
if (!p.amount || parseFloat(p.amount) <= 0) { mixedError = 'Chaque ligne doit avoir un montant > 0.'; return; }
|
||||
}
|
||||
try {
|
||||
for (const p of mixedParts) {
|
||||
await api.transactions.create({
|
||||
property_id: p.property_id,
|
||||
category_id: p.category_id || '',
|
||||
type: p.type,
|
||||
amount: parseFloat(p.amount),
|
||||
date: mixedDate,
|
||||
description: p.description,
|
||||
});
|
||||
}
|
||||
showMixed = false;
|
||||
await load();
|
||||
} catch (e) { mixedError = e.message; }
|
||||
}
|
||||
|
||||
// ── Split (transaction existante) ─────────────────────────────────────────
|
||||
|
||||
function openSplit(t) {
|
||||
splitSource = t;
|
||||
splitError = '';
|
||||
const total = t.amount;
|
||||
splitParts = properties.slice(0, 2).map((p, i) => ({
|
||||
property_id: p.id,
|
||||
category_id: t.category_id || '',
|
||||
type: t.type,
|
||||
amount: parseFloat((total / 2).toFixed(2)),
|
||||
description: t.description || '',
|
||||
pct: 50,
|
||||
}));
|
||||
if (splitParts.length < 2) {
|
||||
splitParts.push({
|
||||
property_id: properties[0]?.id || '',
|
||||
category_id: t.category_id || '',
|
||||
type: t.type,
|
||||
amount: parseFloat((total / 2).toFixed(2)),
|
||||
description: t.description || '',
|
||||
pct: 50,
|
||||
});
|
||||
}
|
||||
showSplit = true;
|
||||
}
|
||||
|
||||
function updatePct(idx, newPct) {
|
||||
const total = splitSource.amount;
|
||||
newPct = Math.min(100, Math.max(0, parseFloat(newPct) || 0));
|
||||
if (splitParts.length === 2) {
|
||||
const other = 100 - newPct;
|
||||
splitParts[idx].pct = newPct;
|
||||
splitParts[idx].amount = parseFloat((total * newPct / 100).toFixed(2));
|
||||
splitParts[1 - idx].pct = other;
|
||||
splitParts[1 - idx].amount = parseFloat((total * other / 100).toFixed(2));
|
||||
} else {
|
||||
splitParts[idx].pct = newPct;
|
||||
splitParts[idx].amount = parseFloat((total * newPct / 100).toFixed(2));
|
||||
}
|
||||
splitParts = [...splitParts];
|
||||
}
|
||||
|
||||
function updateAmount(idx, newAmount) {
|
||||
const total = splitSource.amount;
|
||||
newAmount = Math.abs(parseFloat(newAmount) || 0);
|
||||
splitParts[idx].amount = newAmount;
|
||||
splitParts[idx].pct = parseFloat((newAmount / total * 100).toFixed(1));
|
||||
if (splitParts.length === 2) {
|
||||
const j = 1 - idx;
|
||||
const srcType = splitSource.type;
|
||||
const allSameType = splitParts.every(p => p.type === srcType);
|
||||
let otherAmount;
|
||||
if (allSameType) {
|
||||
otherAmount = parseFloat((total - newAmount).toFixed(2));
|
||||
} else {
|
||||
otherAmount = splitParts[idx].type === srcType
|
||||
? parseFloat((newAmount - total).toFixed(2))
|
||||
: parseFloat((total + newAmount).toFixed(2));
|
||||
otherAmount = Math.max(0, otherAmount);
|
||||
}
|
||||
splitParts[j].amount = otherAmount;
|
||||
splitParts[j].pct = parseFloat((otherAmount / total * 100).toFixed(1));
|
||||
}
|
||||
splitParts = [...splitParts];
|
||||
}
|
||||
|
||||
// Validation : net des parts doit égaler le montant source
|
||||
// Si source = expense : sum(expenses) - sum(incomes) = source.amount
|
||||
// Si source = income : sum(incomes) - sum(expenses) = source.amount
|
||||
$: splitNetExpense = splitParts.filter(p => p.type === 'expense').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
|
||||
$: splitNetIncome = splitParts.filter(p => p.type === 'income').reduce((s, p) => s + (parseFloat(p.amount) || 0), 0);
|
||||
$: splitNet = parseFloat((splitSource?.type === 'income'
|
||||
? splitNetIncome - splitNetExpense
|
||||
: splitNetExpense - splitNetIncome
|
||||
).toFixed(2));
|
||||
$: splitDiff = parseFloat((splitNet - (splitSource?.amount || 0)).toFixed(2));
|
||||
$: splitOk = Math.abs(splitDiff) <= 0.01;
|
||||
|
||||
async function doSplit() {
|
||||
splitError = '';
|
||||
if (!splitOk) {
|
||||
const sign = splitSource.type === 'income' ? 'Revenus − Dépenses' : 'Dépenses − Revenus';
|
||||
splitError = `${sign} = ${fmt(splitNet)} ≠ ${fmt(splitSource.amount)}`;
|
||||
return;
|
||||
}
|
||||
for (const p of splitParts) {
|
||||
if (!p.property_id) { splitError = 'Chaque part doit avoir un bien.'; return; }
|
||||
}
|
||||
try {
|
||||
await api.transactions.split(splitSource.id, {
|
||||
source_id: splitSource.id,
|
||||
splits: splitParts.map(p => ({
|
||||
property_id: p.property_id,
|
||||
category_id: p.category_id,
|
||||
type: p.type,
|
||||
amount: parseFloat(p.amount),
|
||||
description: p.description,
|
||||
})),
|
||||
});
|
||||
showSplit = false;
|
||||
splitSource = null;
|
||||
await load();
|
||||
} catch (e) { splitError = e.message; }
|
||||
}
|
||||
|
||||
// ── Utils ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function rowClass(t) {
|
||||
if (!t.category_id) return 'bg-amber-50/50 dark:bg-amber-950/10 hover:bg-amber-50 dark:hover:bg-amber-950/20';
|
||||
return 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
|
||||
}
|
||||
|
||||
const fmt = (n) => Number(n).toLocaleString('fr-FR', { minimumFractionDigits: 2 }) + ' €';
|
||||
const fmtDate = (d) => { if (!d) return '—'; const p = d.split('-'); return p.length === 3 ? p[2]+'/'+p[1]+'/'+p[0] : d; };
|
||||
const catsFor = (type) => categories.filter(c => c.type === type);
|
||||
|
||||
$: totalIncome = transactions.filter(t => t.type === 'income').reduce((s, t) => s + t.amount, 0);
|
||||
$: totalExpense = transactions.filter(t => t.type === 'expense').reduce((s, t) => s + t.amount, 0);
|
||||
$: balance = totalIncome - totalExpense;
|
||||
|
||||
const selectClass = 'w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500';
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<CreditCard size={22} class="text-gray-400"/>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Transactions</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button on:click={openMixed}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Layers size={15}/> Ventilation mixte
|
||||
</button>
|
||||
<button on:click={openCreate}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Plus size={16}/> Nouvelle transaction
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-3 gap-3 mb-6">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mb-1"><TrendingUp size={12} class="text-green-500"/> Revenus</p>
|
||||
<p class="text-xl font-semibold text-green-600">{fmt(totalIncome)}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mb-1"><TrendingDown size={12} class="text-red-500"/> Dépenses</p>
|
||||
<p class="text-xl font-semibold text-red-500">{fmt(totalExpense)}</p>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-4 border border-gray-100 dark:border-gray-800">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Solde net</p>
|
||||
<p class="text-xl font-semibold {balance >= 0 ? 'text-green-600' : 'text-red-500'}">{fmt(balance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="flex flex-wrap gap-3 mb-4 items-center">
|
||||
<select bind:value={filterProperty} on:change={load}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Tous les biens</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={filterType} on:change={load}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Tous types</option>
|
||||
<option value="income">Revenus</option>
|
||||
<option value="expense">Dépenses</option>
|
||||
</select>
|
||||
<select bind:value={filterYear} on:change={load}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
{#each years as y}<option value={y}>{y}</option>{/each}
|
||||
</select>
|
||||
{#if transactions.some(t => !t.category_id)}
|
||||
<span class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400 ml-2">
|
||||
<span class="w-2.5 h-2.5 rounded-sm bg-amber-200 dark:bg-amber-800 inline-block"></span>
|
||||
Sans catégorie
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each [1,2,3,4,5] as _}<div class="h-12 bg-gray-100 dark:bg-gray-800 rounded-lg animate-pulse"/>{/each}
|
||||
</div>
|
||||
{:else if transactions.length === 0}
|
||||
<div class="text-center py-16 text-gray-400">
|
||||
<CreditCard size={40} class="mx-auto mb-3 opacity-30"/>
|
||||
<p>Aucune transaction pour ces filtres.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-100 dark:border-gray-800">
|
||||
<tr class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<th class="text-left px-4 py-3 font-medium">Date</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Description</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Catégorie</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Bien</th>
|
||||
<th class="text-right px-4 py-3 font-medium">Montant</th>
|
||||
<th class="px-4 py-3 w-24"/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50 dark:divide-gray-800">
|
||||
{#each transactions as t (t.id)}
|
||||
<tr class="transition-colors {rowClass(t)}">
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 whitespace-nowrap">{fmtDate(t.date)}</td>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-white max-w-xs truncate">{t.description || '—'}</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if t.category_name}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium
|
||||
{t.type === 'expense' ? 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300' : 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300'}">
|
||||
{t.category_name}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300">
|
||||
Sans catégorie
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">{t.property_name}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold whitespace-nowrap
|
||||
{t.type === 'income' ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}">
|
||||
{t.type === 'income' ? '+' : '−'}{fmt(t.amount)}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button on:click={() => openSplit(t)} title="Ventiler"
|
||||
class="p-1.5 text-gray-400 hover:text-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-950 transition-colors">
|
||||
<GitFork size={13}/>
|
||||
</button>
|
||||
<button on:click={() => openEdit(t)} title="Modifier"
|
||||
class="p-1.5 text-gray-400 hover:text-blue-600 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors">
|
||||
<Pencil size={13}/>
|
||||
</button>
|
||||
<button on:click={() => remove(t.id)} title="Supprimer"
|
||||
class="p-1.5 text-gray-400 hover:text-red-500 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors">
|
||||
<Trash2 size={13}/>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modal création/édition -->
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">{editingId ? 'Modifier' : 'Nouvelle transaction'}</h2>
|
||||
<button on:click={cancel} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
|
||||
</div>
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
{#if error}<p class="text-red-500 text-sm">{error}</p>{/if}
|
||||
<div class="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<button on:click={() => { form.type = 'expense'; form.category_id = ''; }}
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors {form.type === 'expense' ? 'bg-red-500 text-white' : 'text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800'}">Dépense</button>
|
||||
<button on:click={() => { form.type = 'income'; form.category_id = ''; }}
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors {form.type === 'income' ? 'bg-green-600 text-white' : 'text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800'}">Revenu</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bien *</label>
|
||||
<select bind:value={form.property_id}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Sélectionner...</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Catégorie</label>
|
||||
<select bind:value={form.category_id}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">Sans catégorie</option>
|
||||
{#each filteredCategories as c}<option value={c.id}>{c.name}{c.tax_deductible ? ' ✓' : ''}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Montant (€) *</label>
|
||||
<input type="number" step="0.01" min="0" bind:value={form.amount} placeholder="0.00"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date *</label>
|
||||
<input type="date" bind:value={form.date}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Description</label>
|
||||
<input bind:value={form.description} placeholder="Ex: Facture plombier, loyer janvier..."
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<button on:click={cancel} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
|
||||
<button on:click={save}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Check size={15}/> {editingId ? 'Enregistrer' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal ventilation mixte -->
|
||||
{#if showMixed}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-2xl shadow-xl border border-gray-100 dark:border-gray-800 max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div>
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Ventilation mixte</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">Ex : loyer (revenu) + appel de fonds (dépense) → net débit bancaire</p>
|
||||
</div>
|
||||
<button on:click={() => showMixed = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 overflow-y-auto flex-1 space-y-4">
|
||||
{#if mixedError}<p class="text-red-500 text-sm">{mixedError}</p>{/if}
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date</label>
|
||||
<input type="date" bind:value={mixedDate}
|
||||
class="px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
|
||||
{#each mixedParts as part, i}
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<button on:click={() => { mixedParts[i].type = 'income'; mixedParts[i].category_id = ''; mixedParts = [...mixedParts]; }}
|
||||
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'income' ? 'bg-green-600 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
|
||||
Revenu
|
||||
</button>
|
||||
<button on:click={() => { mixedParts[i].type = 'expense'; mixedParts[i].category_id = ''; mixedParts = [...mixedParts]; }}
|
||||
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'expense' ? 'bg-red-500 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
|
||||
Dépense
|
||||
</button>
|
||||
</div>
|
||||
{#if mixedParts.length > 2}
|
||||
<button on:click={() => removeMixedPart(i)} class="text-gray-400 hover:text-red-500"><X size={14}/></button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Bien *</label>
|
||||
<select bind:value={mixedParts[i].property_id} class={selectClass}>
|
||||
<option value="">Choisir...</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Catégorie</label>
|
||||
<select bind:value={mixedParts[i].category_id} class={selectClass}>
|
||||
<option value="">—</option>
|
||||
{#each catsFor(part.type) as c}<option value={c.id}>{c.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Montant (€) *</label>
|
||||
<input type="number" step="0.01" min="0" bind:value={mixedParts[i].amount} placeholder="0.00"
|
||||
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
||||
<input bind:value={mixedParts[i].description} placeholder="Ex : Loyer janvier, Appel de fonds Q1…"
|
||||
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button on:click={addMixedPart}
|
||||
class="flex items-center gap-1.5 text-xs text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<Plus size={13}/> Ajouter une ligne
|
||||
</button>
|
||||
|
||||
<!-- Récap net -->
|
||||
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg px-4 py-3 text-xs space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-green-600">Revenus</span>
|
||||
<span class="font-medium text-green-600">+{fmt(mixedIncome)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-red-500">Dépenses</span>
|
||||
<span class="font-medium text-red-500">−{fmt(mixedExpense)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-gray-200 dark:border-gray-700 pt-1 mt-1">
|
||||
<span class="font-semibold text-gray-700 dark:text-gray-300">Net bancaire</span>
|
||||
<span class="font-semibold {mixedNet >= 0 ? 'text-red-500' : 'text-green-600'}">
|
||||
{mixedNet >= 0 ? '−' : '+'}{fmt(Math.abs(mixedNet))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<button on:click={() => showMixed = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
|
||||
<button on:click={doMixed}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Layers size={15}/> Créer {mixedParts.length} transactions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal split (transaction existante) -->
|
||||
{#if showSplit && splitSource}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-xl shadow-xl border border-gray-100 dark:border-gray-800 max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div>
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Ventiler la transaction</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
{splitSource.description || '—'} ·
|
||||
<span class="font-medium {splitSource.type === 'income' ? 'text-green-600' : 'text-red-500'}">
|
||||
{splitSource.type === 'income' ? '+' : '−'}{fmt(splitSource.amount)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button on:click={() => showSplit = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5 space-y-4 overflow-y-auto flex-1">
|
||||
{#if splitError}
|
||||
<p class="text-red-500 text-sm">{splitError}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
La transaction originale sera supprimée. Chaque part peut être un revenu ou une dépense — le net doit égaler le montant original.
|
||||
</p>
|
||||
|
||||
{#each splitParts as part, i}
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Toggle type -->
|
||||
<div class="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<button on:click={() => { splitParts[i].type = 'income'; splitParts[i].category_id = ''; splitParts = [...splitParts]; }}
|
||||
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'income' ? 'bg-green-600 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
|
||||
Revenu
|
||||
</button>
|
||||
<button on:click={() => { splitParts[i].type = 'expense'; splitParts[i].category_id = ''; splitParts = [...splitParts]; }}
|
||||
class="px-3 py-1 text-xs font-medium transition-colors {part.type === 'expense' ? 'bg-red-500 text-white' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700'}">
|
||||
Dépense
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm font-semibold {part.type === 'income' ? 'text-green-600' : 'text-red-500'}">
|
||||
{part.type === 'income' ? '+' : '−'}{fmt(Math.abs(part.amount))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Slider (uniquement si tous même type que source) -->
|
||||
{#if splitParts.every(p => p.type === splitSource.type)}
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="range" min="0" max="100" step="0.5"
|
||||
value={part.pct}
|
||||
on:input={(e) => updatePct(i, e.target.value)}
|
||||
class="flex-1 accent-blue-600"/>
|
||||
<div class="flex items-center gap-1">
|
||||
<input type="number" min="0" max="100" step="0.5"
|
||||
value={part.pct}
|
||||
on:change={(e) => updatePct(i, e.target.value)}
|
||||
class="w-14 px-2 py-1 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs text-right focus:outline-none"/>
|
||||
<span class="text-xs text-gray-400">%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Montant (€)</label>
|
||||
<input type="number" min="0" step="0.01"
|
||||
value={part.amount}
|
||||
on:change={(e) => updateAmount(i, e.target.value)}
|
||||
class="w-full px-2 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Bien *</label>
|
||||
<select bind:value={splitParts[i].property_id} class={selectClass}>
|
||||
<option value="">Choisir...</option>
|
||||
{#each properties as p}<option value={p.id}>{p.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Catégorie</label>
|
||||
<select bind:value={splitParts[i].category_id} class={selectClass}>
|
||||
<option value="">Sans catégorie</option>
|
||||
{#each catsFor(part.type) as c}
|
||||
<option value={c.id}>{c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Description</label>
|
||||
<input bind:value={splitParts[i].description} class={selectClass}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Net check -->
|
||||
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg px-4 py-3 text-xs space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-green-600">Revenus</span>
|
||||
<span class="font-medium text-green-600">+{fmt(splitNetIncome)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-red-500">Dépenses</span>
|
||||
<span class="font-medium text-red-500">−{fmt(splitNetExpense)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-gray-200 dark:border-gray-700 pt-1 mt-1">
|
||||
<span class="font-semibold text-gray-700 dark:text-gray-300">
|
||||
Net ({splitSource.type === 'income' ? 'revenus − dépenses' : 'dépenses − revenus'})
|
||||
</span>
|
||||
<span class="font-semibold {splitOk ? 'text-green-600' : 'text-red-500'}">
|
||||
{fmt(splitNet)}
|
||||
{#if splitOk}
|
||||
<span class="text-green-500 ml-1">✓</span>
|
||||
{:else}
|
||||
<span class="text-red-400 ml-1">≠ {fmt(splitSource.amount)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<button on:click={() => showSplit = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
|
||||
<button on:click={doSplit} disabled={!splitOk}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<GitFork size={15}/> Ventiler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
157
frontend/src/routes/users/+page.svelte
Normal file
157
frontend/src/routes/users/+page.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/stores/api.js';
|
||||
import { Users, Plus, Trash2, X, Check, AlertCircle } from 'lucide-svelte';
|
||||
|
||||
let users = [];
|
||||
let loading = true;
|
||||
let showForm = false;
|
||||
let error = '';
|
||||
let successMsg = '';
|
||||
|
||||
let form = { email: '', name: '', password: '', confirm: '' };
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
users = await api.users.list() || [];
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function openForm() {
|
||||
form = { email: '', name: '', password: '', confirm: '' };
|
||||
error = '';
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
async function create() {
|
||||
error = '';
|
||||
if (!form.email || !form.name || !form.password) { error = 'Tous les champs sont requis.'; return; }
|
||||
if (form.password !== form.confirm) { error = 'Les mots de passe ne correspondent pas.'; return; }
|
||||
if (form.password.length < 6) { error = 'Minimum 6 caractères.'; return; }
|
||||
try {
|
||||
await api.auth.register({ email: form.email, name: form.name, password: form.password });
|
||||
showForm = false;
|
||||
successMsg = `Compte "${form.name}" créé avec succès.`;
|
||||
setTimeout(() => successMsg = '', 4000);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id, name) {
|
||||
if (!confirm(`Supprimer le compte de "${name}" ?`)) return;
|
||||
try {
|
||||
await api.users.delete(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const fmtDate = (d) => new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<Users size={22} class="text-gray-400"/>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">Utilisateurs</h1>
|
||||
</div>
|
||||
<button on:click={openForm}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Plus size={16}/> Ajouter un membre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">
|
||||
Ajoutez les membres de votre famille qui peuvent accéder à l'application.
|
||||
Chacun a son propre compte et mot de passe.
|
||||
</p>
|
||||
|
||||
{#if successMsg}
|
||||
<div class="flex items-center gap-2 text-sm px-4 py-3 rounded-lg mb-4 bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-300">
|
||||
<Check size={14}/> {successMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each [1,2] as _}<div class="h-16 bg-gray-100 dark:bg-gray-800 rounded-xl animate-pulse"/>{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
{#each users as u, i (u.id)}
|
||||
<div class="flex items-center gap-4 px-5 py-4
|
||||
{i > 0 ? 'border-t border-gray-50 dark:border-gray-800' : ''}">
|
||||
<!-- Avatar -->
|
||||
<div class="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-sm font-semibold text-blue-700 dark:text-blue-300 shrink-0">
|
||||
{u.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{u.name}</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">{u.email} · Depuis le {fmtDate(u.created_at)}</p>
|
||||
</div>
|
||||
{#if i === 0}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 font-medium shrink-0">
|
||||
Admin
|
||||
</span>
|
||||
{:else}
|
||||
<button on:click={() => remove(u.id, u.name)}
|
||||
class="p-2 text-gray-300 hover:text-red-500 rounded-lg hover:bg-red-50 dark:hover:bg-red-950 transition-colors shrink-0">
|
||||
<Trash2 size={15}/>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4" on:click|self={() => showForm = false}>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-md shadow-xl border border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Ajouter un membre</h2>
|
||||
<button on:click={() => showForm = false} class="text-gray-400 hover:text-gray-600"><X size={18}/></button>
|
||||
</div>
|
||||
<div class="px-6 py-5 space-y-4">
|
||||
{#if error}
|
||||
<div class="flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400">
|
||||
<AlertCircle size={13}/> {error}
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Nom affiché</label>
|
||||
<input bind:value={form.name} placeholder="Ex: Marie Dupont"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
|
||||
<input type="email" bind:value={form.email} placeholder="marie@exemple.fr"
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mot de passe</label>
|
||||
<input type="password" bind:value={form.password}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Confirmer le mot de passe</label>
|
||||
<input type="password" bind:value={form.confirm}
|
||||
class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<button on:click={() => showForm = false} class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400">Annuler</button>
|
||||
<button on:click={create}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Check size={15}/> Créer le compte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
20
frontend/svelte.config.js
Normal file
20
frontend/svelte.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
}),
|
||||
},
|
||||
vitePlugin: {
|
||||
onwarn: (warning, handler) => {
|
||||
if (warning.code.startsWith('a11y-')) return;
|
||||
handler(warning);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
13
frontend/tailwind.config.js
Normal file
13
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
darkMode: 'media',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
14
frontend/vite.config.js
Normal file
14
frontend/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:9000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
22
go.mod
Normal file
22
go.mod
Normal 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
28
go.sum
Normal 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
316
internal/auth/auth.go
Normal 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)
|
||||
}
|
||||
207
internal/calendar/calendar.go
Normal file
207
internal/calendar/calendar.go
Normal 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
|
||||
}
|
||||
140
internal/category/category.go
Normal file
140
internal/category/category.go
Normal 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
150
internal/db/db.go
Normal 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);`
|
||||
330
internal/document/document.go
Normal file
330
internal/document/document.go
Normal 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
91
internal/fiscal/fiscal.go
Normal 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
204
internal/ical/ical.go
Normal 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
307
internal/importer/qif.go
Normal 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
416
internal/loan/loan.go
Normal 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
269
internal/loan/pdf_parser.go
Normal 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
474
internal/loan/seed.go
Normal 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},
|
||||
}
|
||||
}
|
||||
166
internal/property/property.go
Normal file
166
internal/property/property.go
Normal 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)
|
||||
}
|
||||
483
internal/transaction/transaction.go
Normal file
483
internal/transaction/transaction.go
Normal 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
1
web/build/_app/env.js
Normal file
@@ -0,0 +1 @@
|
||||
export const env={}
|
||||
1
web/build/_app/immutable/assets/0.D9W-GzMQ.css
Normal file
1
web/build/_app/immutable/assets/0.D9W-GzMQ.css
Normal file
File diff suppressed because one or more lines are too long
1
web/build/_app/immutable/chunks/2JpTzebs.js
Normal file
1
web/build/_app/immutable/chunks/2JpTzebs.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/6cgBTYw-.js
Normal file
1
web/build/_app/immutable/chunks/6cgBTYw-.js
Normal file
File diff suppressed because one or more lines are too long
1
web/build/_app/immutable/chunks/7IRyKQzz.js
Normal file
1
web/build/_app/immutable/chunks/7IRyKQzz.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/7MWtQ_HQ.js
Normal file
1
web/build/_app/immutable/chunks/7MWtQ_HQ.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/B9ru2HtO.js
Normal file
1
web/build/_app/immutable/chunks/B9ru2HtO.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/BYJWe3D5.js
Normal file
1
web/build/_app/immutable/chunks/BYJWe3D5.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/Betcpvy2.js
Normal file
1
web/build/_app/immutable/chunks/Betcpvy2.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/BojKV9te.js
Normal file
1
web/build/_app/immutable/chunks/BojKV9te.js
Normal 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};
|
||||
6
web/build/_app/immutable/chunks/C1tDbyYL.js
Normal file
6
web/build/_app/immutable/chunks/C1tDbyYL.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/C7zepb3B.js
Normal file
1
web/build/_app/immutable/chunks/C7zepb3B.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/CEKnDucw.js
Normal file
1
web/build/_app/immutable/chunks/CEKnDucw.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/CImgxE6m.js
Normal file
1
web/build/_app/immutable/chunks/CImgxE6m.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/CLS07P5K.js
Normal file
1
web/build/_app/immutable/chunks/CLS07P5K.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/CNY58Upt.js
Normal file
1
web/build/_app/immutable/chunks/CNY58Upt.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/CUwAs-mp.js
Normal file
1
web/build/_app/immutable/chunks/CUwAs-mp.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/C_K4PjIw.js
Normal file
1
web/build/_app/immutable/chunks/C_K4PjIw.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/C_l6I72z.js
Normal file
1
web/build/_app/immutable/chunks/C_l6I72z.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/D3KWQg4I.js
Normal file
1
web/build/_app/immutable/chunks/D3KWQg4I.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/DHedsUi_.js
Normal file
1
web/build/_app/immutable/chunks/DHedsUi_.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/DIz1JgvC.js
Normal file
1
web/build/_app/immutable/chunks/DIz1JgvC.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/Db8cSFEn.js
Normal file
1
web/build/_app/immutable/chunks/Db8cSFEn.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/DjUyxgK6.js
Normal file
1
web/build/_app/immutable/chunks/DjUyxgK6.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/G8UKEBBn.js
Normal file
1
web/build/_app/immutable/chunks/G8UKEBBn.js
Normal file
File diff suppressed because one or more lines are too long
1
web/build/_app/immutable/chunks/eC_GsYA7.js
Normal file
1
web/build/_app/immutable/chunks/eC_GsYA7.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/oVx0hpG8.js
Normal file
1
web/build/_app/immutable/chunks/oVx0hpG8.js
Normal 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};
|
||||
1
web/build/_app/immutable/chunks/zQrtb5Kq.js
Normal file
1
web/build/_app/immutable/chunks/zQrtb5Kq.js
Normal 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};
|
||||
2
web/build/_app/immutable/entry/app.CdLU2Zc4.js
Normal file
2
web/build/_app/immutable/entry/app.CdLU2Zc4.js
Normal file
File diff suppressed because one or more lines are too long
1
web/build/_app/immutable/entry/start.C3I7xU3P.js
Normal file
1
web/build/_app/immutable/entry/start.C3I7xU3P.js
Normal file
@@ -0,0 +1 @@
|
||||
import{l as o,a as r}from"../chunks/6cgBTYw-.js";export{o as load_css,r as start};
|
||||
1
web/build/_app/immutable/nodes/0.DFf2-eFR.js
Normal file
1
web/build/_app/immutable/nodes/0.DFf2-eFR.js
Normal file
File diff suppressed because one or more lines are too long
1
web/build/_app/immutable/nodes/1.B1rRQsgb.js
Normal file
1
web/build/_app/immutable/nodes/1.B1rRQsgb.js
Normal 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};
|
||||
1
web/build/_app/immutable/nodes/10.B_2_uNhq.js
Normal file
1
web/build/_app/immutable/nodes/10.B_2_uNhq.js
Normal file
File diff suppressed because one or more lines are too long
1
web/build/_app/immutable/nodes/11.BwpkXZtE.js
Normal file
1
web/build/_app/immutable/nodes/11.BwpkXZtE.js
Normal file
File diff suppressed because one or more lines are too long
4
web/build/_app/immutable/nodes/12.D8a8HBoC.js
Normal file
4
web/build/_app/immutable/nodes/12.D8a8HBoC.js
Normal file
File diff suppressed because one or more lines are too long
2
web/build/_app/immutable/nodes/13.D1xscHx8.js
Normal file
2
web/build/_app/immutable/nodes/13.D1xscHx8.js
Normal file
File diff suppressed because one or more lines are too long
18
web/build/_app/immutable/nodes/2.Bm5TUCsw.js
Normal file
18
web/build/_app/immutable/nodes/2.Bm5TUCsw.js
Normal file
File diff suppressed because one or more lines are too long
1
web/build/_app/immutable/nodes/3.DMsI14gA.js
Normal file
1
web/build/_app/immutable/nodes/3.DMsI14gA.js
Normal file
File diff suppressed because one or more lines are too long
7
web/build/_app/immutable/nodes/4.CoOqOv0Y.js
Normal file
7
web/build/_app/immutable/nodes/4.CoOqOv0Y.js
Normal file
File diff suppressed because one or more lines are too long
1
web/build/_app/immutable/nodes/5.DZw7u2fg.js
Normal file
1
web/build/_app/immutable/nodes/5.DZw7u2fg.js
Normal file
File diff suppressed because one or more lines are too long
3
web/build/_app/immutable/nodes/6.BT2j549n.js
Normal file
3
web/build/_app/immutable/nodes/6.BT2j549n.js
Normal file
File diff suppressed because one or more lines are too long
10
web/build/_app/immutable/nodes/7.C8SC4dD8.js
Normal file
10
web/build/_app/immutable/nodes/7.C8SC4dD8.js
Normal file
File diff suppressed because one or more lines are too long
5
web/build/_app/immutable/nodes/8.By7Q-xW8.js
Normal file
5
web/build/_app/immutable/nodes/8.By7Q-xW8.js
Normal file
File diff suppressed because one or more lines are too long
1
web/build/_app/immutable/nodes/9.DcGrZhBA.js
Normal file
1
web/build/_app/immutable/nodes/9.DcGrZhBA.js
Normal 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};
|
||||
1
web/build/_app/version.json
Normal file
1
web/build/_app/version.json
Normal file
@@ -0,0 +1 @@
|
||||
{"version":"1775902055626"}
|
||||
36
web/build/index.html
Normal file
36
web/build/index.html
Normal 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
13
web/embed.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user