up
This commit is contained in:
+51
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
Generated
+2522
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html { font-family: 'Inter', system-ui, sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const env={}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{s as u,a as l,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as f,m as _,n as k,o as v,p as N}from"./G8UKEBBn.js";import{I as G,g as I,a as M}from"./C1tDbyYL.js";function S(c){let e;const s=c[2].default,o=m(s,c,c[3],null);return{c(){o&&o.c()},l(n){o&&o.l(n)},m(n,t){o&&o.m(n,t),e=!0},p(n,t){o&&o.p&&(!e||t&8)&&p(o,s,n,n[3],e?g(s,n[3],t,null):d(n[3]),null)},i(n){e||(_(o,n),e=!0)},o(n){f(o,n),e=!1},d(n){o&&o.d(n)}}}function j(c){let e,s;const o=[{name:"git-fork"},c[1],{iconNode:c[0]}];let n={$$slots:{default:[S]},$$scope:{ctx:c}};for(let t=0;t<o.length;t+=1)n=l(n,o[t]);return e=new G({props:n}),{c(){N(e.$$.fragment)},l(t){v(e.$$.fragment,t)},m(t,a){k(e,t,a),s=!0},p(t,[a]){const r=a&3?I(o,[o[0],a&2&&M(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(r.$$scope={dirty:a,ctx:t}),e.$set(r)},i(t){s||(_(e.$$.fragment,t),s=!0)},o(t){f(e.$$.fragment,t),s=!1},d(t){b(e,t)}}}function q(c,e,s){let{$$slots:o={},$$scope:n}=e;const t=[["circle",{cx:"12",cy:"18",r:"3"}],["circle",{cx:"6",cy:"6",r:"3"}],["circle",{cx:"18",cy:"6",r:"3"}],["path",{d:"M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9"}],["path",{d:"M12 12v3"}]];return c.$$set=a=>{s(1,e=l(l({},e),i(a))),"$$scope"in a&&s(3,n=a.$$scope)},e=i(e),[t,e,o,n]}class y extends ${constructor(e){super(),h(this,e,q,j,u,{})}}export{y as G};
|
||||
File diff suppressed because one or more lines are too long
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -0,0 +1 @@
|
||||
import{w as o}from"./oVx0hpG8.js";import{h as p}from"./DHedsUi_.js";const d=o(null),u=o(null),n="/api";async function a(e,t,r,c=!1){const i=p(u),l={};i&&(l.Authorization=i),r&&!c&&(l["Content-Type"]="application/json");const s=await fetch(`${n}${t}`,{method:e,headers:l,body:c?r:r?JSON.stringify(r):void 0,credentials:"include"});if(s.status===401){d.set(null),u.set(null),window.location.href="/login";return}if(!s.ok){const E=await s.text();throw new Error(E||`HTTP ${s.status}`)}return s.status===204?null:s.json()}const m={auth:{login:(e,t)=>a("POST","/auth/login",{email:e,password:t}),logout:()=>a("POST","/auth/logout"),register:e=>a("POST","/auth/register",e),me:()=>a("GET","/me"),updateProfile:e=>a("PUT","/me",e),updatePassword:e=>a("PUT","/me/password",e)},users:{list:()=>a("GET","/users"),delete:e=>a("DELETE",`/users/${e}`)},categories:{list:(e={})=>a("GET",`/categories?${new URLSearchParams(e)}`),create:e=>a("POST","/categories",e),update:(e,t)=>a("PUT",`/categories/${e}`,t),delete:e=>a("DELETE",`/categories/${e}`)},properties:{list:()=>a("GET","/properties"),get:e=>a("GET",`/properties/${e}`),create:e=>a("POST","/properties",e),update:(e,t)=>a("PUT",`/properties/${e}`,t),delete:e=>a("DELETE",`/properties/${e}`)},transactions:{list:(e={})=>a("GET",`/transactions?${new URLSearchParams(e)}`),create:e=>a("POST","/transactions",e),update:(e,t)=>a("PUT",`/transactions/${e}`,t),delete:e=>a("DELETE",`/transactions/${e}`),split:(e,t)=>a("POST",`/transactions/${e}/split`,t),summary:(e={})=>a("GET",`/transactions/summary?${new URLSearchParams(e)}`),monthly:(e={})=>a("GET",`/transactions/monthly?${new URLSearchParams(e)}`),categories:(e={})=>a("GET",`/transactions/categories?${new URLSearchParams(e)}`)},calendar:{list:(e={})=>a("GET",`/calendar?${new URLSearchParams(e)}`),createEvent:e=>a("POST","/calendar",e),updateEvent:(e,t)=>a("PUT",`/calendar/${e}`,t),deleteEvent:e=>a("DELETE",`/calendar/${e}`),stats:(e={})=>a("GET",`/calendar/stats?${new URLSearchParams(e)}`),sync:e=>a("POST",`/calendar/sync/${e}`)},documents:{list:(e={})=>a("GET",`/documents?${new URLSearchParams(e)}`),upload:e=>a("POST","/documents",e,!0),download:e=>`${n}/documents/${e}/download`,delete:e=>a("DELETE",`/documents/${e}`),exportUrl:(e={})=>`${n}/documents/export?${new URLSearchParams(e)}`},loans:{list:(e={})=>a("GET",`/loans?${new URLSearchParams(e)}`),createWithData:e=>a("POST","/loans/create",e),create:e=>a("POST","/loans",e),delete:e=>a("DELETE",`/loans/${e}`),lines:(e,t={})=>a("GET",`/loans/${e}/lines?${new URLSearchParams(t)}`),annualSummary:(e,t={})=>a("GET",`/loans/${e}/summary?${new URLSearchParams(t)}`),uploadLines:(e,t)=>a("POST",`/loans/${e}/lines`,t),splitByDate:(e,t)=>a("GET",`/loans/${e}/split?date=${t}`),splitForDate:e=>a("GET",`/loans/split?date=${e}`)},fiscal:{summary:(e={})=>a("GET",`/fiscal/summary?${new URLSearchParams(e)}`),exportUrl:(e={})=>`${n}/fiscal/export?${new URLSearchParams(e)}`}},$=o([]),T=o(null);async function h(){const e=await m.properties.list();$.set(e||[]),!p(T)&&(e==null?void 0:e.length)>0&&T.set(e[0])}export{m as a,u as b,d as c,h as l,$ as p};
|
||||
File diff suppressed because one or more lines are too long
@@ -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};
|
||||
@@ -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};
|
||||
@@ -0,0 +1 @@
|
||||
import{s as _,a as c,e as i,b as m,u as p,g as d,d as g}from"./DHedsUi_.js";import{S as $,i as h,k as b,l as f,m as u,n as k,o as C,p as N}from"./G8UKEBBn.js";import{I,g as S,a as j}from"./C1tDbyYL.js";function q(l){let e;const o=l[2].default,s=m(o,l,l[3],null);return{c(){s&&s.c()},l(n){s&&s.l(n)},m(n,t){s&&s.m(n,t),e=!0},p(n,t){s&&s.p&&(!e||t&8)&&p(s,o,n,n[3],e?g(o,n[3],t,null):d(n[3]),null)},i(n){e||(u(s,n),e=!0)},o(n){f(s,n),e=!1},d(n){s&&s.d(n)}}}function v(l){let e,o;const s=[{name:"check"},l[1],{iconNode:l[0]}];let n={$$slots:{default:[q]},$$scope:{ctx:l}};for(let t=0;t<s.length;t+=1)n=c(n,s[t]);return e=new I({props:n}),{c(){N(e.$$.fragment)},l(t){C(e.$$.fragment,t)},m(t,a){k(e,t,a),o=!0},p(t,[a]){const r=a&3?S(s,[s[0],a&2&&j(t[1]),a&1&&{iconNode:t[0]}]):{};a&8&&(r.$$scope={dirty:a,ctx:t}),e.$set(r)},i(t){o||(u(e.$$.fragment,t),o=!0)},o(t){f(e.$$.fragment,t),o=!1},d(t){b(e,t)}}}function M(l,e,o){let{$$slots:s={},$$scope:n}=e;const t=[["path",{d:"M20 6 9 17l-5-5"}]];return l.$$set=a=>{o(1,e=c(c({},e),i(a))),"$$scope"in a&&o(3,n=a.$$scope)},e=i(e),[t,e,s,n]}class D extends ${constructor(e){super(),h(this,e,M,v,_,{})}}export{D as C};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{l as o,a as r}from"../chunks/6cgBTYw-.js";export{o as load_css,r as start};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{s as x,n as u,c as S}from"../chunks/DHedsUi_.js";import{S as j,i as q,d as c,s as h,a as _,b as d,c as v,e as g,f as b,g as y,h as E,t as $,j as C}from"../chunks/G8UKEBBn.js";import{p as H}from"../chunks/CEKnDucw.js";function P(p){var f;let a,s=p[0].status+"",r,o,n,i=((f=p[0].error)==null?void 0:f.message)+"",m;return{c(){a=E("h1"),r=$(s),o=C(),n=E("p"),m=$(i)},l(e){a=v(e,"H1",{});var t=g(a);r=b(t,s),t.forEach(c),o=y(e),n=v(e,"P",{});var l=g(n);m=b(l,i),l.forEach(c)},m(e,t){_(e,a,t),d(a,r),_(e,o,t),_(e,n,t),d(n,m)},p(e,[t]){var l;t&1&&s!==(s=e[0].status+"")&&h(r,s),t&1&&i!==(i=((l=e[0].error)==null?void 0:l.message)+"")&&h(m,i)},i:u,o:u,d(e){e&&(c(a),c(o),c(n))}}}function k(p,a,s){let r;return S(p,H,o=>s(0,r=o)),[r]}class B extends j{constructor(a){super(),q(this,a,k,P,x,{})}}export{B as component};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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};
|
||||
@@ -0,0 +1 @@
|
||||
{"version":"1775902055626"}
|
||||
@@ -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>
|
||||
@@ -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