generated from gilles/template-webapp
claude code
This commit is contained in:
62
.env.example
62
.env.example
@@ -0,0 +1,62 @@
|
||||
# HomeStock - Variables d'environnement
|
||||
# Copier ce fichier vers .env et ajuster les valeurs
|
||||
|
||||
# === Backend Configuration ===
|
||||
|
||||
# Application
|
||||
APP_NAME=HomeStock
|
||||
APP_VERSION=0.1.0
|
||||
ENVIRONMENT=development # development | production
|
||||
DEBUG=true
|
||||
LOG_LEVEL=DEBUG # DEBUG | INFO | WARNING | ERROR
|
||||
|
||||
# Server
|
||||
BACKEND_HOST=0.0.0.0
|
||||
BACKEND_PORT=8000
|
||||
BACKEND_RELOAD=true # Hot reload en dev
|
||||
|
||||
# Database
|
||||
DATABASE_URL=sqlite:///./data/homestock.db
|
||||
# Pour PostgreSQL (future migration) :
|
||||
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/homestock
|
||||
|
||||
# CORS (Cross-Origin Resource Sharing)
|
||||
CORS_ORIGINS=http://localhost:5173,http://10.0.0.50:5173
|
||||
CORS_ALLOW_CREDENTIALS=false
|
||||
|
||||
# File Storage
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_UPLOAD_SIZE_MB=50
|
||||
ALLOWED_EXTENSIONS=jpg,jpeg,png,gif,pdf,doc,docx
|
||||
|
||||
# Search (FTS5)
|
||||
SEARCH_MIN_QUERY_LENGTH=2
|
||||
|
||||
# === Frontend Configuration ===
|
||||
|
||||
# API URL
|
||||
VITE_API_BASE_URL=http://localhost:8000/api/v1
|
||||
|
||||
# App Config
|
||||
VITE_APP_NAME=HomeStock
|
||||
VITE_APP_VERSION=0.1.0
|
||||
|
||||
# === Docker Configuration ===
|
||||
|
||||
# Network
|
||||
NETWORK_SUBNET=10.0.0.0/22
|
||||
BACKEND_IP=10.0.0.50
|
||||
FRONTEND_IP=10.0.0.51
|
||||
|
||||
# Ports
|
||||
BACKEND_EXTERNAL_PORT=8000
|
||||
FRONTEND_EXTERNAL_PORT=5173
|
||||
|
||||
# === Development ===
|
||||
|
||||
# Python
|
||||
PYTHONUNBUFFERED=1
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Node
|
||||
NODE_ENV=development
|
||||
119
.gitignore
vendored
119
.gitignore
vendored
@@ -0,0 +1,119 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
*.manifest
|
||||
*.spec
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.hypothesis/
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.pyre/
|
||||
.pytype/
|
||||
cython_debug/
|
||||
|
||||
# Node.js / Frontend
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
dist/
|
||||
dist-ssr/
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/*.db
|
||||
data/*.sqlite
|
||||
|
||||
# Uploads (fichiers utilisateur)
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
!uploads/photos/.gitkeep
|
||||
!uploads/notices/.gitkeep
|
||||
!uploads/factures/.gitkeep
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
*.log.*
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
coverage/
|
||||
.coverage
|
||||
|
||||
# Build artifacts
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
|
||||
# Alembic
|
||||
alembic/versions/*.pyc
|
||||
|
||||
229
CLAUDE.md
229
CLAUDE.md
@@ -1,37 +1,212 @@
|
||||
# Consignes strictes — Claude Code
|
||||
# CLAUDE.md
|
||||
|
||||
Ces consignes sont obligatoires pour toute intervention avec Claude Code.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
---
|
||||
|
||||
## 1. Lecture préalable obligatoire
|
||||
- Lire `a-lire.md`, `PROJECT_CONTEXT.md`, `outils_dev_pref.md`.
|
||||
- Lire `docs/ARCHITECTURE.md` avant toute décision technique.
|
||||
## Vue d'ensemble du projet
|
||||
|
||||
## 2. Gestion des zones
|
||||
- Ne jamais supprimer `<A REMPLIR - PROJET>`.
|
||||
- Ajouter un exemple guidant avec la mention "a supprimer".
|
||||
- Utiliser `<A COMPLETER PAR AGENT>` pour les choix techniques.
|
||||
**HomeStock** est une webapp de gestion d'inventaire domestique (style Homebox) permettant de cataloguer et gérer les équipements, matériels et consommables présents au domicile.
|
||||
|
||||
## 3. Documentation avant code
|
||||
- Aucun code tant que `product/VISION.md` et `product/BACKLOG.md` ne sont pas complétés.
|
||||
- Toute décision structurante → ajouter un ADR dans `docs/adr/`.
|
||||
### Domaines couverts
|
||||
- Bricolage
|
||||
- Informatique
|
||||
- Électronique
|
||||
- Cuisine
|
||||
|
||||
## 4. Style d’écriture
|
||||
- Français uniquement.
|
||||
- Éviter les acronymes seuls : toujours expliquer (ex: "MFA = authentification multi‑facteurs").
|
||||
- Commentaires courts et utiles.
|
||||
|
||||
## 5. Découpage des tâches
|
||||
- Toute tâche doit être dans `tasks/`.
|
||||
- Une tâche = un objectif clair + critères d’acceptation.
|
||||
- Mentionner les fichiers autorisés.
|
||||
|
||||
## 6. Qualité
|
||||
- Indiquer les tests attendus même si non exécutés.
|
||||
- Mettre à jour la documentation impactée.
|
||||
### Fonctionnalités principales
|
||||
- Catalogue d'objets avec caractéristiques détaillées
|
||||
- Gestion des photos, notices et factures
|
||||
- Suivi des prix et du stock
|
||||
- Gestion de l'état (en utilisation / en stock)
|
||||
- Localisation précise (lieu, meuble, tiroir, boîte)
|
||||
|
||||
---
|
||||
|
||||
## Exemple (a supprimer)
|
||||
- "Ajouter ADR-0002 pour le choix de base de données."
|
||||
## Architecture du projet
|
||||
|
||||
### Structure globale
|
||||
Le projet suit une architecture **monolithe modulaire** avec séparation frontend/backend :
|
||||
|
||||
```
|
||||
/
|
||||
├── backend/ # API et logique métier
|
||||
├── frontend/ # Interface utilisateur
|
||||
├── contracts/ # Contrats d'API (OpenAPI) et modèles de données
|
||||
├── docs/ # Documentation technique et ADR
|
||||
├── product/ # Documentation produit (vision, backlog, roadmap)
|
||||
├── tasks/ # Fichiers de tâches pour le découpage du travail
|
||||
└── scripts/ # Scripts de développement et maintenance
|
||||
```
|
||||
|
||||
### Fichiers de contexte clés
|
||||
- [a-lire.md](a-lire.md) : Instructions d'initialisation du projet
|
||||
- [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md) : Contexte global et contraintes
|
||||
- [outils_dev_pref.md](outils_dev_pref.md) : Environnement et préférences de développement
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) : **À lire avant toute décision technique**
|
||||
- [backend/CONTEXT.md](backend/CONTEXT.md) : Contexte spécifique au backend
|
||||
- [frontend/CONTEXT.md](frontend/CONTEXT.md) : Contexte spécifique au frontend
|
||||
|
||||
---
|
||||
|
||||
## Workflow de développement
|
||||
|
||||
### 1. Documentation d'abord
|
||||
**IMPORTANT** : Aucun code ne doit être écrit tant que la documentation n'est pas complétée :
|
||||
- [product/VISION.md](product/VISION.md) doit être rempli
|
||||
- [product/BACKLOG.md](product/BACKLOG.md) doit contenir les exigences (REQ-XXX)
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) doit être lu et compris
|
||||
|
||||
### 2. Gestion des zones à remplir
|
||||
Le projet utilise un système de marqueurs pour identifier les sections à compléter :
|
||||
|
||||
- `<A REMPLIR - PROJET>` : À compléter par l'utilisateur selon le contexte du projet
|
||||
- **Ne jamais supprimer** ce marqueur sans le remplacer
|
||||
- Ajouter un exemple guidant avec la mention "(exemple: ... — a supprimer)"
|
||||
|
||||
- `<A COMPLETER PAR AGENT>` : À compléter par Claude Code lors des décisions techniques
|
||||
- Remplacer par le contenu approprié
|
||||
- Ajouter "complété par codex" en commentaire après remplissage
|
||||
|
||||
### 3. Décisions architecturales (ADR)
|
||||
Toute décision structurante doit être documentée dans un ADR (Architecture Decision Record) :
|
||||
- Créer un nouveau fichier dans [docs/adr/](docs/adr/)
|
||||
- Utiliser le template [docs/adr/TEMPLATE.md](docs/adr/TEMPLATE.md)
|
||||
- Exemples : choix de base de données, framework, pattern d'authentification, etc.
|
||||
|
||||
### 4. Découpage des tâches
|
||||
- Toute tâche de développement doit avoir un fichier dans [tasks/](tasks/)
|
||||
- Utiliser le template [tasks/TEMPLATE.md](tasks/TEMPLATE.md)
|
||||
- Une tâche = un objectif clair + critères d'acceptation + fichiers concernés
|
||||
|
||||
---
|
||||
|
||||
## Commandes de développement
|
||||
|
||||
### Démarrage
|
||||
```bash
|
||||
make dev # Lancer l'environnement de développement
|
||||
docker-compose up # Alternative avec Docker
|
||||
```
|
||||
|
||||
### Tests et qualité
|
||||
```bash
|
||||
./scripts/test.sh # Exécuter les tests
|
||||
./scripts/lint.sh # Linter le code
|
||||
./scripts/fmt.sh # Formater le code
|
||||
```
|
||||
|
||||
### Base de données
|
||||
```bash
|
||||
./scripts/db/backup.sh # Sauvegarder la base de données
|
||||
./scripts/db/restore.sh # Restaurer une sauvegarde
|
||||
```
|
||||
|
||||
**Note** : Ces scripts sont actuellement vides et doivent être complétés lors de l'implémentation.
|
||||
|
||||
---
|
||||
|
||||
## Conventions de code
|
||||
|
||||
### Langue
|
||||
- **Français uniquement** pour toute la documentation et les commentaires
|
||||
- Toujours expliquer les acronymes : "JWT = jeton d'authentification", "RGPD = Règlement Général sur la Protection des Données"
|
||||
- Les noms de variables/fonctions peuvent être en anglais selon les conventions du langage
|
||||
|
||||
### Style de documentation
|
||||
- Commentaires courts et utiles uniquement
|
||||
- Éviter les répétitions entre code et documentation
|
||||
- Privilégier la clarté à la concision
|
||||
|
||||
### Organisation du code
|
||||
- Suivre l'organisation définie dans [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||
- Respecter les conventions du [docs/STYLEGUIDE.md](docs/STYLEGUIDE.md)
|
||||
- Maintenir la séparation frontend/backend stricte
|
||||
|
||||
---
|
||||
|
||||
## Contrats d'API
|
||||
|
||||
Le répertoire [contracts/](contracts/) contient les spécifications d'API :
|
||||
- [contracts/openapi.yaml](contracts/openapi.yaml) : Spécification OpenAPI
|
||||
- [contracts/data_model.md](contracts/data_model.md) : Modèle de données
|
||||
- [contracts/errors.md](contracts/errors.md) : Gestion des erreurs
|
||||
- [contracts/pagination.md](contracts/pagination.md) : Convention de pagination
|
||||
- [contracts/import/](contracts/import/) : Schémas d'import/export
|
||||
|
||||
**Important** : Toute modification d'API doit mettre à jour ces contrats.
|
||||
|
||||
---
|
||||
|
||||
## Environnement technique
|
||||
|
||||
### Configuration
|
||||
- OS : Debian 13
|
||||
- IDE : VS Code
|
||||
- Conteneurs : Docker 29.1.5+
|
||||
- Git : Gitea (https://gitea.maison43.duckdns.org/)
|
||||
- Timezone : Europe/Paris
|
||||
- Réseau local : 10.0.0.0/22
|
||||
|
||||
### Stack technique (à finaliser)
|
||||
- **Frontend** : À définir dans [frontend/CONTEXT.md](frontend/CONTEXT.md)
|
||||
- **Backend** : Python ou Go (préférence indiquée dans [outils_dev_pref.md](outils_dev_pref.md))
|
||||
- **Base de données** : À définir via ADR
|
||||
- **Stockage fichiers** : À définir (photos, notices, factures)
|
||||
|
||||
### Thème UI
|
||||
- Préférences : Gruvbox, Dark, Vintage - Monokai Dark
|
||||
- À définir : couleurs dominantes, style UI, accessibilité
|
||||
|
||||
---
|
||||
|
||||
## Processus de contribution
|
||||
|
||||
### Pull Requests
|
||||
- Consulter [docs/PR_CHECKLIST.md](docs/PR_CHECKLIST.md) avant de créer une PR
|
||||
- Suivre le workflow défini dans [docs/WORKFLOW.md](docs/WORKFLOW.md)
|
||||
- Une PR = une fonctionnalité / un fix
|
||||
|
||||
### Qualité
|
||||
- Indiquer les tests attendus même si non implémentés
|
||||
- Mettre à jour toute documentation impactée
|
||||
- Vérifier que les marqueurs `<A REMPLIR>` ne sont pas supprimés par erreur
|
||||
|
||||
### Sécurité
|
||||
- Consulter [docs/SECURITY.md](docs/SECURITY.md)
|
||||
- Pas de secrets en clair dans le code
|
||||
- Respecter les contraintes RGPD si applicable
|
||||
|
||||
---
|
||||
|
||||
## Ordre des opérations pour démarrer
|
||||
|
||||
1. **Compléter la documentation de base**
|
||||
- Remplir [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md)
|
||||
- Compléter [product/VISION.md](product/VISION.md)
|
||||
- Définir les premières exigences dans [product/BACKLOG.md](product/BACKLOG.md)
|
||||
|
||||
2. **Définir l'architecture technique**
|
||||
- Compléter [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||
- Créer les ADR pour les choix techniques majeurs
|
||||
- Finaliser [backend/CONTEXT.md](backend/CONTEXT.md) et [frontend/CONTEXT.md](frontend/CONTEXT.md)
|
||||
|
||||
3. **Configurer l'environnement**
|
||||
- Compléter [outils_dev_pref.md](outils_dev_pref.md)
|
||||
- Configurer les scripts de développement
|
||||
- Mettre en place Docker et docker-compose
|
||||
|
||||
4. **Démarrer l'implémentation**
|
||||
- Créer les tâches dans [tasks/](tasks/)
|
||||
- Suivre le workflow défini
|
||||
- Documenter les décisions au fur et à mesure
|
||||
|
||||
---
|
||||
|
||||
## Références
|
||||
|
||||
- **Documentation produit** : [product/](product/)
|
||||
- **Documentation technique** : [docs/](docs/)
|
||||
- **Tâches** : [tasks/](tasks/)
|
||||
- **Opérations** : [docs/OPERATIONS.md](docs/OPERATIONS.md)
|
||||
- **Déploiement** : [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
|
||||
|
||||
133
Makefile
133
Makefile
@@ -0,0 +1,133 @@
|
||||
.PHONY: help dev test lint fmt clean docker-up docker-down install migrate
|
||||
|
||||
# Variables
|
||||
BACKEND_DIR = backend
|
||||
FRONTEND_DIR = frontend
|
||||
PYTHON = python3
|
||||
UV = uv
|
||||
NPM = npm
|
||||
|
||||
help: ## Affiche cette aide
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
# === Développement ===
|
||||
|
||||
dev: ## Lance l'environnement de développement complet (backend + frontend)
|
||||
docker compose up --build
|
||||
|
||||
dev-backend: ## Lance uniquement le backend en mode dev
|
||||
cd $(BACKEND_DIR) && $(UV) run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
dev-frontend: ## Lance uniquement le frontend en mode dev
|
||||
cd $(FRONTEND_DIR) && $(NPM) run dev
|
||||
|
||||
# === Installation ===
|
||||
|
||||
install: install-backend install-frontend ## Installe toutes les dépendances
|
||||
|
||||
install-backend: ## Installe les dépendances Python (backend)
|
||||
cd $(BACKEND_DIR) && $(UV) sync
|
||||
|
||||
install-frontend: ## Installe les dépendances Node (frontend)
|
||||
cd $(FRONTEND_DIR) && $(NPM) install
|
||||
|
||||
# === Tests ===
|
||||
|
||||
test: test-backend test-frontend ## Lance tous les tests
|
||||
|
||||
test-backend: ## Lance les tests backend (pytest)
|
||||
cd $(BACKEND_DIR) && $(UV) run pytest
|
||||
|
||||
test-backend-cov: ## Lance les tests backend avec couverture
|
||||
cd $(BACKEND_DIR) && $(UV) run pytest --cov=app --cov-report=html --cov-report=term
|
||||
|
||||
test-frontend: ## Lance les tests frontend (vitest)
|
||||
cd $(FRONTEND_DIR) && $(NPM) run test
|
||||
|
||||
# === Linting & Formatage ===
|
||||
|
||||
lint: lint-backend lint-frontend ## Lance tous les linters
|
||||
|
||||
lint-backend: ## Lint le code backend (ruff + mypy)
|
||||
cd $(BACKEND_DIR) && $(UV) run ruff check .
|
||||
cd $(BACKEND_DIR) && $(UV) run mypy .
|
||||
|
||||
lint-frontend: ## Lint le code frontend (eslint)
|
||||
cd $(FRONTEND_DIR) && $(NPM) run lint
|
||||
|
||||
fmt: fmt-backend fmt-frontend ## Formate tout le code
|
||||
|
||||
fmt-backend: ## Formate le code backend (ruff)
|
||||
cd $(BACKEND_DIR) && $(UV) run ruff check --fix .
|
||||
cd $(BACKEND_DIR) && $(UV) run ruff format .
|
||||
|
||||
fmt-frontend: ## Formate le code frontend (prettier)
|
||||
cd $(FRONTEND_DIR) && $(NPM) run fmt
|
||||
|
||||
# === Base de données ===
|
||||
|
||||
migrate: ## Crée et applique une nouvelle migration Alembic
|
||||
cd $(BACKEND_DIR) && $(UV) run alembic revision --autogenerate -m "$(msg)"
|
||||
cd $(BACKEND_DIR) && $(UV) run alembic upgrade head
|
||||
|
||||
migrate-up: ## Applique les migrations en attente
|
||||
cd $(BACKEND_DIR) && $(UV) run alembic upgrade head
|
||||
|
||||
migrate-down: ## Rollback de la dernière migration
|
||||
cd $(BACKEND_DIR) && $(UV) run alembic downgrade -1
|
||||
|
||||
migrate-history: ## Affiche l'historique des migrations
|
||||
cd $(BACKEND_DIR) && $(UV) run alembic history
|
||||
|
||||
db-reset: ## Réinitialise la base de données (ATTENTION: supprime toutes les données!)
|
||||
rm -f data/homestock.db
|
||||
$(MAKE) migrate-up
|
||||
|
||||
# === Docker ===
|
||||
|
||||
docker-up: ## Démarre les conteneurs Docker
|
||||
docker compose up -d
|
||||
|
||||
docker-down: ## Arrête les conteneurs Docker
|
||||
docker compose down
|
||||
|
||||
docker-logs: ## Affiche les logs des conteneurs
|
||||
docker compose logs -f
|
||||
|
||||
docker-rebuild: ## Rebuild et redémarre les conteneurs
|
||||
docker compose up --build -d
|
||||
|
||||
docker-clean: ## Nettoie les conteneurs, volumes et images Docker
|
||||
docker compose down -v
|
||||
docker system prune -f
|
||||
|
||||
# === Nettoyage ===
|
||||
|
||||
clean: ## Nettoie les fichiers temporaires
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name ".mypy_cache" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name "node_modules" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name "dist" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete
|
||||
rm -rf $(BACKEND_DIR)/htmlcov
|
||||
rm -rf $(FRONTEND_DIR)/coverage
|
||||
|
||||
clean-all: clean docker-clean ## Nettoie tout (fichiers + Docker)
|
||||
rm -rf data/homestock.db
|
||||
rm -rf uploads/*
|
||||
|
||||
# === Utilitaires ===
|
||||
|
||||
shell-backend: ## Ouvre un shell Python dans l'environnement backend
|
||||
cd $(BACKEND_DIR) && $(UV) run python
|
||||
|
||||
shell-db: ## Ouvre un shell SQLite sur la base de données
|
||||
sqlite3 data/homestock.db
|
||||
|
||||
check: lint test ## Vérifie la qualité du code (lint + tests)
|
||||
|
||||
init: install migrate-up ## Initialise le projet pour la première fois
|
||||
@echo "✅ Projet initialisé avec succès!"
|
||||
@echo "👉 Lancez 'make dev' pour démarrer le développement"
|
||||
|
||||
@@ -12,17 +12,17 @@ Il sert aux agents pour comprendre les objectifs et les contraintes.
|
||||
---
|
||||
|
||||
## Résumé
|
||||
- But du projet : <A REMPLIR - PROJET> (exemple: réduire les erreurs d'inventaire — a supprimer)
|
||||
- Résultat attendu : <A REMPLIR - PROJET> (exemple: tableau de bord + alertes — a supprimer)
|
||||
- But du projet : Remplacer la gestion manuelle et désorganisée de l'inventaire domestique par une solution structurée et interrogeable <!-- complété par codex -->
|
||||
- Résultat attendu : Application web permettant de retrouver rapidement n'importe quel objet, son emplacement exact, ses documents associés (factures, notices) et son état (stock, en utilisation) <!-- complété par codex -->
|
||||
|
||||
## Parties prenantes
|
||||
- Décideur : <A REMPLIR - PROJET> (exemple: Directeur des opérations — a supprimer)
|
||||
- Utilisateurs finaux : <A REMPLIR - PROJET> (exemple: équipe logistique — a supprimer)
|
||||
- Décideur : Utilisateur final (propriétaire du domicile) <!-- complété par codex -->
|
||||
- Utilisateurs finaux : Utilisateur unique (mono-utilisateur) avec possibilité d'évolution vers usage familial <!-- complété par codex -->
|
||||
|
||||
## Contraintes
|
||||
- Budget / délai : <A REMPLIR - PROJET> (exemple: 3 mois / 20k€ — a supprimer)
|
||||
- Légalité / conformité : <A REMPLIR - PROJET> (exemple: RGPD — a supprimer)
|
||||
- Tech imposée : <A REMPLIR - PROJET> (exemple: PostgreSQL — a supprimer)
|
||||
- Budget / délai : Projet personnel, développement itératif sans contrainte de délai strict <!-- complété par codex -->
|
||||
- Légalité / conformité : Aucune contrainte légale (usage personnel, pas de données tierces) <!-- complété par codex -->
|
||||
- Tech imposée : Python (backend) et React (frontend) selon préférences, SQLite pour simplicité mono-utilisateur <!-- complété par codex -->
|
||||
|
||||
## Références
|
||||
- Vision : `product/VISION.md`
|
||||
|
||||
19
README.md
19
README.md
@@ -1,6 +1,6 @@
|
||||
# <Nom du projet>
|
||||
# HomeStock
|
||||
|
||||
Résumé court : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
||||
Application web self-hosted de gestion d'inventaire domestique permettant de cataloguer équipements, matériels et consommables avec localisation précise et archivage de documents. <!-- complété par codex -->
|
||||
|
||||
---
|
||||
|
||||
@@ -11,12 +11,12 @@ Résumé court : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer
|
||||
---
|
||||
|
||||
## Objectif
|
||||
- Problème : <A REMPLIR - PROJET> (exemple: suivi manuel sur tableur — a supprimer)
|
||||
- Public cible : <A REMPLIR - PROJET> (exemple: PME logistique — a supprimer)
|
||||
- Problème : Gestion désorganisée de l'inventaire domestique, difficulté à retrouver les objets, perte de notices/factures, absence de suivi des stocks <!-- complété par codex -->
|
||||
- Public cible : Particulier gérant son domicile, domaines bricolage/informatique/électronique/cuisine <!-- complété par codex -->
|
||||
|
||||
## Démarrage rapide
|
||||
- Prérequis : <A COMPLETER PAR AGENT>
|
||||
- Lancer en local : <A COMPLETER PAR AGENT>
|
||||
- Prérequis : Docker 29.1.5+, Docker Compose, ou Python 3.11+ + Node.js 20+ pour développement sans Docker <!-- complété par codex -->
|
||||
- Lancer en local : `docker-compose up` ou `make dev` (en développement : backend sur :8000, frontend sur :5173) <!-- complété par codex -->
|
||||
|
||||
## Documentation
|
||||
- Contexte : `PROJECT_CONTEXT.md`
|
||||
@@ -25,9 +25,4 @@ Résumé court : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer
|
||||
- Backlog : `product/BACKLOG.md`
|
||||
- Workflow : `docs/WORKFLOW.md`
|
||||
|
||||
---
|
||||
|
||||
## Exemple (a supprimer)
|
||||
- Résumé : application web interne pour gérer des stocks.
|
||||
- Problème : suivi manuel sur tableur.
|
||||
- Lancer en local : `make dev`.
|
||||
---
|
||||
30
a-lire.md
30
a-lire.md
@@ -18,28 +18,28 @@ Ce dépôt est basé sur le template **template-webapp**.
|
||||
|
||||
## 1. Description du projet
|
||||
|
||||
- Nom du projet : <A REMPLIR - PROJET> (exemple: StockPilot — a supprimer)
|
||||
- Type de webapp : <A REMPLIR - PROJET> (exemple: SaaS B2B — a supprimer)
|
||||
- Public cible : <A REMPLIR - PROJET> (exemple: PME logistique — a supprimer)
|
||||
- Objectif principal : <A REMPLIR - PROJET> (exemple: suivi des stocks en temps réel — a supprimer)
|
||||
- Nom du projet : HomeStock <!-- complété par codex -->
|
||||
- Type de webapp : Application web self-hosted mono-utilisateur <!-- complété par codex -->
|
||||
- Public cible : Particulier gérant son inventaire domestique <!-- complété par codex -->
|
||||
- Objectif principal : Cataloguer et gérer l'inventaire complet de la maison (équipements, matériels, consommables) avec suivi précis des emplacements, documents et états <!-- complété par codex -->
|
||||
|
||||
---
|
||||
|
||||
## 2. Contraintes fortes
|
||||
|
||||
- Self-hosted / Cloud : <A REMPLIR - PROJET> (exemple: Self-hosted — a supprimer)
|
||||
- Mono-utilisateur / Multi-utilisateur : <A REMPLIR - PROJET> (exemple: Multi-utilisateur — a supprimer)
|
||||
- Données sensibles : oui / non
|
||||
- Contraintes légales (RGPD = Règlement Général sur la Protection des Données, etc.) : <A REMPLIR - PROJET> (exemple: RGPD + hébergement UE — a supprimer)
|
||||
- Self-hosted / Cloud : Self-hosted (déploiement local sur le réseau domestique) <!-- complété par codex -->
|
||||
- Mono-utilisateur / Multi-utilisateur : Mono-utilisateur <!-- complété par codex -->
|
||||
- Données sensibles : Non (données personnelles mais non sensibles : inventaire domestique, factures)
|
||||
- Contraintes légales : Aucune contrainte légale stricte (usage personnel, pas de traitement de données tierces) <!-- complété par codex -->
|
||||
|
||||
---
|
||||
|
||||
## 3. Stack envisagée (indicative)
|
||||
|
||||
- Frontend : <A COMPLETER PAR AGENT>
|
||||
- Backend : <A COMPLETER PAR AGENT>
|
||||
- Base de données : <A COMPLETER PAR AGENT>
|
||||
- Stockage fichiers : <A COMPLETER PAR AGENT>
|
||||
- Frontend : React 18+ avec Vite, TanStack Query (React Query), React Router, TailwindCSS <!-- complété par codex -->
|
||||
- Backend : Python 3.11+ avec FastAPI, Pydantic pour validation, SQLAlchemy comme ORM <!-- complété par codex -->
|
||||
- Base de données : SQLite (fichier local, adapté au mono-utilisateur) <!-- complété par codex -->
|
||||
- Stockage fichiers : Système de fichiers local (dossier uploads/ avec organisation par type) <!-- complété par codex -->
|
||||
|
||||
---
|
||||
|
||||
@@ -59,8 +59,4 @@ Ce dépôt est basé sur le template **template-webapp**.
|
||||
- Créer les premières REQ (exigences) dans `product/BACKLOG.md`
|
||||
- Documenter les outils dans `outils_dev_pref.md`
|
||||
|
||||
---
|
||||
|
||||
## Exemple (a supprimer)
|
||||
- Type de webapp : SaaS B2B.
|
||||
- Contraintes : RGPD + hébergement UE.
|
||||
---
|
||||
18
amelioration.md
Normal file
18
amelioration.md
Normal file
@@ -0,0 +1,18 @@
|
||||
- [ ] ajout d'un bouton setting qui donne acces aux parametres du frontend et de backend (separer le 2)
|
||||
- [ ] ajout du theme gruvbox dark vintage
|
||||
- [ ] ajout style icon material design ou fa
|
||||
- [ ] ajout image et thumbnail dans item
|
||||
- [ ] ajout notice pdf dans item
|
||||
- [ ] ajout url dans item
|
||||
- [ ] ajout caracteristique dans item
|
||||
- [ ] ajout status integre dans item
|
||||
- [ ] ajout boutique pdf dans item
|
||||
- [ ] ajout menu people pour gerer les pret
|
||||
- [ ] ajout people si pret selectionné pdf dans item
|
||||
- [ ] popup ajout item plus large
|
||||
- [ ] app responsive avec mode laptop et smartphone
|
||||
- [ ] si status integre selectionné, on peut selectionner un objet parent, ex une carte pci express est integrer dans un desktop
|
||||
- [ ] ajout composant dans item il peut etre assimilié a enfant mais et adaptable: ex un pc desktop peut avoir 3 emplacement pcie, 4 emplcement sata, 5 usb, 4 memoire, ... (brainstorming)
|
||||
- [ ] import fichier json depuis un store ( status non assigné)
|
||||
|
||||
|
||||
@@ -12,38 +12,33 @@ Tout ce qui est indiqué ici est la référence pour les agents backend.
|
||||
---
|
||||
|
||||
## Objectif du backend
|
||||
- Problème métier couvert : <A REMPLIR - PROJET> (exemple: suivi manuel sur tableur — a supprimer)
|
||||
- Responsabilités principales : <A COMPLETER PAR AGENT>
|
||||
- Hors périmètre : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
||||
- Problème métier couvert : Centraliser et structurer les données d'inventaire domestique avec recherche efficace et gestion de fichiers <!-- complété par codex -->
|
||||
- Responsabilités principales : API REST CRUD (items, locations, categories), upload/stockage fichiers, recherche full-text, validation données, génération OpenAPI <!-- complété par codex -->
|
||||
- Hors périmètre : Rendu frontend (SPA indépendante), authentification complexe (MVP mono-utilisateur), analytics avancées <!-- complété par codex -->
|
||||
|
||||
## Interfaces
|
||||
- API publique (API = Interface de Programmation) : <A COMPLETER PAR AGENT>
|
||||
- Authentification/autorisation : <A COMPLETER PAR AGENT>
|
||||
- Intégrations externes : <A REMPLIR - PROJET> (exemple: ERP existant — a supprimer)
|
||||
- API publique : REST JSON à `/api/v1/`, OpenAPI 3.0 auto-générée à `/docs`, endpoints principaux = items, locations, categories, documents, search <!-- complété par codex -->
|
||||
- Authentification/autorisation : Optionnelle pour MVP (déploiement local), si activée = session cookie basique, pas de rôles complexes (mono-utilisateur) <!-- complété par codex -->
|
||||
- Intégrations externes : Aucune intégration externe, système autonome <!-- complété par codex -->
|
||||
|
||||
## Données
|
||||
- Base(s) utilisée(s) : <A COMPLETER PAR AGENT>
|
||||
- Modèle de données clé : <A COMPLETER PAR AGENT>
|
||||
- Stratégie de migration : <A COMPLETER PAR AGENT>
|
||||
- Base(s) utilisée(s) : SQLite (fichier homestock.db) avec extension FTS5 pour recherche full-text <!-- complété par codex -->
|
||||
- Modèle de données clé : Item (objet principal), Location (hiérarchie lieu/meuble/tiroir), Category (domaine), Document (fichier), relations Many-to-One et Many-to-Many <!-- complété par codex -->
|
||||
- Stratégie de migration : Alembic pour migrations versionnées, auto-génération à partir des modèles SQLAlchemy, migrations réversibles up/down <!-- complété par codex -->
|
||||
|
||||
## Architecture interne
|
||||
- Style (monolithe modulaire, hexagonal, etc.) : <A COMPLETER PAR AGENT>
|
||||
- Modules principaux : <A COMPLETER PAR AGENT>
|
||||
- Couche d’accès aux données : <A COMPLETER PAR AGENT>
|
||||
- Style : Monolithe modulaire avec séparation claire routers → services → repositories (3-layer architecture) <!-- complété par codex -->
|
||||
- Modules principaux : `routers/` (endpoints FastAPI), `services/` (logique métier), `models/` (ORM SQLAlchemy), `schemas/` (Pydantic validation), `repositories/` (accès BDD) <!-- complété par codex -->
|
||||
- Couche d'accès aux données : Repository pattern avec SQLAlchemy async sessions, abstraction BDD pour faciliter tests et évolution <!-- complété par codex -->
|
||||
|
||||
## Qualité & exploitation
|
||||
- Observabilité (logs/metrics/traces = journaux/mesures/traces) : <A COMPLETER PAR AGENT>
|
||||
- Tests (unitaires/intégration) : <A COMPLETER PAR AGENT>
|
||||
- Performance attendue : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
||||
- Observabilité : Logs structurés avec loguru (format JSON), endpoint `/health` pour healthcheck, pas de tracing distribué (monolithe) <!-- complété par codex -->
|
||||
- Tests : pytest avec tests unitaires (services/), tests intégration (routers/ avec TestClient), fixtures pour BDD test, couverture 70%+ sur logique métier <!-- complété par codex -->
|
||||
- Performance attendue : Réponse API <200ms pour GET simple, <500ms pour recherche full-text, upload fichiers <5s pour 10MB, pas de contrainte scalabilité (mono-utilisateur) <!-- complété par codex -->
|
||||
|
||||
## Conventions
|
||||
- Organisation du code : <A COMPLETER PAR AGENT>
|
||||
- Nommage : <A COMPLETER PAR AGENT>
|
||||
- Gestion erreurs : <A COMPLETER PAR AGENT>
|
||||
- Organisation du code : `backend/app/` racine, sous-dossiers par responsabilité (routers/, services/, models/, schemas/, repositories/), un fichier par entité <!-- complété par codex -->
|
||||
- Nommage : snake_case pour tout (variables, fonctions, fichiers), préfixes get_/create_/update_/delete_ pour CRUD, suffixes _service/_repository selon couche <!-- complété par codex -->
|
||||
- Gestion erreurs : HTTPException FastAPI pour erreurs API avec codes standards (400/404/500), exceptions métier custom héritant de BaseException, logging erreurs avec contexte <!-- complété par codex -->
|
||||
|
||||
---
|
||||
|
||||
## Exemple (a supprimer)
|
||||
- Style : monolithe modulaire avec modules `users`, `billing`, `catalog`.
|
||||
- API : REST `/api/v1` + JWT (Jeton d’authentification).
|
||||
- DB : PostgreSQL, migrations via outils natifs.
|
||||
---
|
||||
@@ -0,0 +1,35 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Image de base Python 3.11
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Variables d'environnement Python
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Installer uv (modern Python package manager)
|
||||
RUN pip install uv
|
||||
|
||||
# Répertoire de travail
|
||||
WORKDIR /app
|
||||
|
||||
# Copier les fichiers de dépendances
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
# Installer les dépendances (prod + dev pour hot-reload)
|
||||
RUN uv sync
|
||||
|
||||
# Copier le code source
|
||||
COPY . .
|
||||
|
||||
# Créer les répertoires nécessaires
|
||||
RUN mkdir -p /app/data /app/uploads
|
||||
|
||||
# Exposer le port FastAPI
|
||||
EXPOSE 8000
|
||||
|
||||
# Commande par défaut (peut être overridée par docker-compose)
|
||||
# Note: --reload est activé pour le développement
|
||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
71
backend/README.md
Normal file
71
backend/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# HomeStock Backend
|
||||
|
||||
Backend API pour la gestion d'inventaire domestique.
|
||||
|
||||
## Stack technique
|
||||
|
||||
- **FastAPI** : Framework web moderne et performant
|
||||
- **SQLAlchemy 2.0+** : ORM avec support asynchrone
|
||||
- **SQLite** : Base de données embarquée
|
||||
- **Alembic** : Migrations de base de données
|
||||
- **Pydantic** : Validation des données
|
||||
- **Loguru** : Logging avancé
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Avec uv (recommandé)
|
||||
uv sync
|
||||
|
||||
# Ou avec pip
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Lancement
|
||||
|
||||
```bash
|
||||
# Mode développement (avec hot-reload)
|
||||
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# Ou via le Makefile depuis la racine
|
||||
make dev-backend
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Lancer les tests
|
||||
uv run pytest
|
||||
|
||||
# Avec couverture
|
||||
uv run pytest --cov=app --cov-report=html
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
```bash
|
||||
# Créer une nouvelle migration
|
||||
uv run alembic revision --autogenerate -m "Description"
|
||||
|
||||
# Appliquer les migrations
|
||||
uv run alembic upgrade head
|
||||
|
||||
# Rollback
|
||||
uv run alembic downgrade -1
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── core/ # Configuration, database, logging
|
||||
│ ├── models/ # Modèles SQLAlchemy
|
||||
│ ├── schemas/ # Schémas Pydantic
|
||||
│ ├── routers/ # Endpoints API
|
||||
│ ├── services/ # Logique métier
|
||||
│ ├── repositories/ # Accès données
|
||||
│ └── utils/ # Utilitaires
|
||||
├── alembic/ # Migrations
|
||||
└── tests/ # Tests
|
||||
```
|
||||
69
backend/alembic.ini
Normal file
69
backend/alembic.ini
Normal file
@@ -0,0 +1,69 @@
|
||||
# Configuration Alembic pour les migrations de base de données
|
||||
# Documentation : https://alembic.sqlalchemy.org/en/latest/tutorial.html
|
||||
|
||||
[alembic]
|
||||
# Chemin vers le dossier des migrations
|
||||
script_location = alembic
|
||||
|
||||
# Template utilisé pour générer les fichiers de migration
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# prepend_sys_path = .
|
||||
|
||||
# Fuseau horaire pour les timestamps des migrations
|
||||
# timezone = UTC
|
||||
|
||||
# Longueur maximale des caractères pour le slug du nom de fichier de migration
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# Nom de la branche principale (pour multi-branches)
|
||||
# version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# URL de connexion à la base de données
|
||||
# Note : Cette valeur sera overridée par env.py qui lit depuis config.py
|
||||
sqlalchemy.url = sqlite:///./data/homestock.db
|
||||
|
||||
# Encode pour les migrations Python
|
||||
# output_encoding = utf-8
|
||||
|
||||
[post_write_hooks]
|
||||
# Hook pour formatter automatiquement les migrations avec ruff
|
||||
hooks = ruff
|
||||
ruff.type = console_scripts
|
||||
ruff.entrypoint = ruff
|
||||
ruff.options = format REVISION_SCRIPT_FILENAME
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
99
backend/alembic/env.py
Normal file
99
backend/alembic/env.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Environnement Alembic pour les migrations de base de données.
|
||||
|
||||
Ce fichier configure et exécute les migrations SQLAlchemy avec Alembic.
|
||||
Il supporte les migrations synchrones et asynchrones.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import de la configuration et des modèles
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
|
||||
# Import explicite de tous les modèles pour autogenerate
|
||||
import app.models # noqa: F401
|
||||
|
||||
# Configuration Alembic
|
||||
config = context.config
|
||||
|
||||
# Interpréter le fichier de configuration pour le logging Python
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Métadonnées des modèles pour autogenerate
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Override de l'URL de connexion depuis settings
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Exécute les migrations en mode 'offline'.
|
||||
|
||||
Configure le contexte avec uniquement une URL sans créer d'Engine.
|
||||
Les commandes SQL sont émises vers un fichier script au lieu d'être
|
||||
exécutées directement sur la base de données.
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True, # Détecte les changements de types
|
||||
compare_server_default=True, # Détecte les changements de valeurs par défaut
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""Exécute les migrations avec une connexion donnée."""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Exécute les migrations en mode asynchrone.
|
||||
|
||||
Crée un moteur asynchrone et exécute les migrations.
|
||||
"""
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Exécute les migrations en mode 'online'.
|
||||
|
||||
Crée un Engine et associe une connexion au contexte.
|
||||
"""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Applique la migration (passage à la version suivante)."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Annule la migration (retour à la version précédente)."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Initial migration: create all tables
|
||||
|
||||
Revision ID: 8ba5962640dd
|
||||
Revises:
|
||||
Create Date: 2026-01-27 21:22:27.022127
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '8ba5962640dd'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Applique la migration (passage à la version suivante)."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('categories',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('icon', sa.String(length=50), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True)
|
||||
op.create_table('locations',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('type', sa.Enum('ROOM', 'FURNITURE', 'DRAWER', 'BOX', name='locationtype', native_enum=False, length=20), nullable=False),
|
||||
sa.Column('parent_id', sa.Integer(), nullable=True),
|
||||
sa.Column('path', sa.String(length=500), nullable=False),
|
||||
sa.Column('description', sa.String(length=500), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['locations.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_locations_name'), 'locations', ['name'], unique=False)
|
||||
op.create_index(op.f('ix_locations_parent_id'), 'locations', ['parent_id'], unique=False)
|
||||
op.create_index(op.f('ix_locations_path'), 'locations', ['path'], unique=False)
|
||||
op.create_table('items',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('quantity', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.Enum('IN_STOCK', 'IN_USE', 'BROKEN', 'SOLD', 'LENT', name='itemstatus', native_enum=False, length=20), nullable=False),
|
||||
sa.Column('brand', sa.String(length=100), nullable=True),
|
||||
sa.Column('model', sa.String(length=100), nullable=True),
|
||||
sa.Column('serial_number', sa.String(length=100), nullable=True),
|
||||
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('purchase_date', sa.Date(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||
sa.Column('location_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['location_id'], ['locations.id'], ondelete='RESTRICT'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('serial_number')
|
||||
)
|
||||
op.create_index(op.f('ix_items_category_id'), 'items', ['category_id'], unique=False)
|
||||
op.create_index(op.f('ix_items_location_id'), 'items', ['location_id'], unique=False)
|
||||
op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False)
|
||||
op.create_table('documents',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('filename', sa.String(length=255), nullable=False),
|
||||
sa.Column('original_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('type', sa.Enum('PHOTO', 'MANUAL', 'INVOICE', 'WARRANTY', 'OTHER', name='documenttype', native_enum=False, length=20), nullable=False),
|
||||
sa.Column('mime_type', sa.String(length=100), nullable=False),
|
||||
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||
sa.Column('file_path', sa.String(length=500), nullable=False),
|
||||
sa.Column('description', sa.String(length=500), nullable=True),
|
||||
sa.Column('item_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['item_id'], ['items.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('filename')
|
||||
)
|
||||
op.create_index(op.f('ix_documents_item_id'), 'documents', ['item_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Annule la migration (retour à la version précédente)."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_documents_item_id'), table_name='documents')
|
||||
op.drop_table('documents')
|
||||
op.drop_index(op.f('ix_items_name'), table_name='items')
|
||||
op.drop_index(op.f('ix_items_location_id'), table_name='items')
|
||||
op.drop_index(op.f('ix_items_category_id'), table_name='items')
|
||||
op.drop_table('items')
|
||||
op.drop_index(op.f('ix_locations_path'), table_name='locations')
|
||||
op.drop_index(op.f('ix_locations_parent_id'), table_name='locations')
|
||||
op.drop_index(op.f('ix_locations_name'), table_name='locations')
|
||||
op.drop_table('locations')
|
||||
op.drop_index(op.f('ix_categories_name'), table_name='categories')
|
||||
op.drop_table('categories')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add_url_to_items
|
||||
|
||||
Revision ID: ee8035073398
|
||||
Revises: 8ba5962640dd
|
||||
Create Date: 2026-01-28 18:17:51.225223
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ee8035073398'
|
||||
down_revision = '8ba5962640dd'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Applique la migration (passage à la version suivante)."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('items', sa.Column('url', sa.String(length=500), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Annule la migration (retour à la version précédente)."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('items', 'url')
|
||||
# ### end Alembic commands ###
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
121
backend/app/core/config.py
Normal file
121
backend/app/core/config.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Configuration de l'application HomeStock.
|
||||
|
||||
Utilise Pydantic Settings pour charger et valider les variables d'environnement.
|
||||
Documentation : https://docs.pydantic.dev/latest/concepts/pydantic_settings/
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration globale de l'application.
|
||||
|
||||
Les valeurs sont chargées depuis les variables d'environnement ou le fichier .env.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# === Application ===
|
||||
APP_NAME: str = Field(default="HomeStock", description="Nom de l'application")
|
||||
APP_VERSION: str = Field(default="0.1.0", description="Version de l'application")
|
||||
ENVIRONMENT: Literal["development", "production"] = Field(
|
||||
default="development", description="Environnement d'exécution"
|
||||
)
|
||||
DEBUG: bool = Field(default=True, description="Mode debug")
|
||||
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(
|
||||
default="DEBUG", description="Niveau de log"
|
||||
)
|
||||
|
||||
# === Serveur ===
|
||||
BACKEND_HOST: str = Field(default="0.0.0.0", description="Host du serveur backend")
|
||||
BACKEND_PORT: int = Field(default=8000, description="Port du serveur backend")
|
||||
BACKEND_RELOAD: bool = Field(
|
||||
default=True, description="Hot reload en développement"
|
||||
)
|
||||
|
||||
# === Base de données ===
|
||||
DATABASE_URL: str = Field(
|
||||
default="sqlite+aiosqlite:///./data/homestock.db",
|
||||
description="URL de connexion à la base de données",
|
||||
)
|
||||
|
||||
@field_validator("DATABASE_URL")
|
||||
@classmethod
|
||||
def validate_database_url(cls, v: str) -> str:
|
||||
"""Valide et normalise l'URL de la base de données."""
|
||||
# Pour SQLite, s'assurer que le driver async est utilisé
|
||||
if v.startswith("sqlite:///"):
|
||||
return v.replace("sqlite:///", "sqlite+aiosqlite:///")
|
||||
return v
|
||||
|
||||
# === CORS ===
|
||||
CORS_ORIGINS: str = Field(
|
||||
default="http://localhost:5173,http://10.0.0.50:5173",
|
||||
description="Origines autorisées pour CORS (séparées par des virgules)",
|
||||
)
|
||||
CORS_ALLOW_CREDENTIALS: bool = Field(
|
||||
default=False, description="Autorise les credentials CORS"
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
"""Retourne la liste des origines CORS autorisées."""
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||
|
||||
# === Stockage fichiers ===
|
||||
UPLOAD_DIR: str = Field(default="./uploads", description="Répertoire des uploads")
|
||||
MAX_UPLOAD_SIZE_MB: int = Field(
|
||||
default=50, description="Taille max des uploads en Mo"
|
||||
)
|
||||
ALLOWED_EXTENSIONS: str = Field(
|
||||
default="jpg,jpeg,png,gif,pdf,doc,docx",
|
||||
description="Extensions de fichiers autorisées (séparées par des virgules)",
|
||||
)
|
||||
|
||||
@property
|
||||
def allowed_extensions_list(self) -> list[str]:
|
||||
"""Retourne la liste des extensions autorisées."""
|
||||
return [ext.strip().lower() for ext in self.ALLOWED_EXTENSIONS.split(",")]
|
||||
|
||||
@property
|
||||
def max_upload_size_bytes(self) -> int:
|
||||
"""Retourne la taille max en octets."""
|
||||
return self.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
|
||||
# === Recherche ===
|
||||
SEARCH_MIN_QUERY_LENGTH: int = Field(
|
||||
default=2, description="Longueur minimale des requêtes de recherche"
|
||||
)
|
||||
|
||||
# === Propriétés calculées ===
|
||||
@property
|
||||
def is_development(self) -> bool:
|
||||
"""Indique si l'environnement est en développement."""
|
||||
return self.ENVIRONMENT == "development"
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
"""Indique si l'environnement est en production."""
|
||||
return self.ENVIRONMENT == "production"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Retourne l'instance singleton de la configuration.
|
||||
|
||||
Utilise lru_cache pour ne charger la configuration qu'une seule fois.
|
||||
"""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Instance globale de configuration
|
||||
settings = get_settings()
|
||||
99
backend/app/core/database.py
Normal file
99
backend/app/core/database.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Configuration de la base de données avec SQLAlchemy.
|
||||
|
||||
Utilise SQLAlchemy 2.0+ avec support asynchrone (aiosqlite pour SQLite).
|
||||
Documentation : https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Classe de base pour tous les modèles SQLAlchemy."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Création du moteur asynchrone
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG, # Log SQL en mode debug
|
||||
future=True,
|
||||
# Pool de connexions pour SQLite
|
||||
pool_pre_ping=True, # Vérifie la connexion avant utilisation
|
||||
)
|
||||
|
||||
|
||||
# Configuration spécifique pour SQLite (activation des foreign keys)
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_conn: Any, connection_record: Any) -> None:
|
||||
"""Active les contraintes de clés étrangères pour SQLite.
|
||||
|
||||
SQLite désactive par défaut les foreign keys, il faut les activer manuellement.
|
||||
"""
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
# Session factory pour créer des sessions asynchrones
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False, # Ne pas expirer les objets après commit
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Générateur de session de base de données pour FastAPI.
|
||||
|
||||
Utilisé comme dépendance FastAPI pour injecter une session dans les routes.
|
||||
|
||||
Usage:
|
||||
@router.get("/items")
|
||||
async def get_items(db: AsyncSession = Depends(get_db)):
|
||||
...
|
||||
|
||||
Yields:
|
||||
AsyncSession: Session SQLAlchemy asynchrone
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initialise la base de données.
|
||||
|
||||
Crée toutes les tables définies dans les modèles.
|
||||
À utiliser uniquement en développement ou pour les tests.
|
||||
En production, utiliser Alembic pour les migrations.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Ferme proprement les connexions à la base de données.
|
||||
|
||||
À appeler lors de l'arrêt de l'application.
|
||||
"""
|
||||
await engine.dispose()
|
||||
105
backend/app/core/logging.py
Normal file
105
backend/app/core/logging.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Configuration du système de logging avec Loguru.
|
||||
|
||||
Loguru est une bibliothèque de logging moderne et simple à utiliser.
|
||||
Documentation : https://loguru.readthedocs.io/
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Supprimer les handlers par défaut de loguru
|
||||
logger.remove()
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure le système de logging pour l'application.
|
||||
|
||||
- En développement : logs dans stdout + fichier debug
|
||||
- En production : logs dans fichiers avec rotation
|
||||
"""
|
||||
# Format de log avec couleurs pour stdout
|
||||
log_format = (
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
||||
"<level>{level: <8}</level> | "
|
||||
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
|
||||
"<level>{message}</level>"
|
||||
)
|
||||
|
||||
# Format de log sans couleurs pour fichiers
|
||||
log_format_file = (
|
||||
"{time:YYYY-MM-DD HH:mm:ss.SSS} | "
|
||||
"{level: <8} | "
|
||||
"{name}:{function}:{line} - "
|
||||
"{message}"
|
||||
)
|
||||
|
||||
# === Configuration commune ===
|
||||
# Handler pour stdout (console)
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
format=log_format,
|
||||
level=settings.LOG_LEVEL,
|
||||
colorize=True,
|
||||
backtrace=True, # Affiche le traceback complet des exceptions
|
||||
diagnose=settings.DEBUG, # Affiche les valeurs des variables en debug
|
||||
)
|
||||
|
||||
# Créer le répertoire des logs s'il n'existe pas
|
||||
log_dir = Path("logs")
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
# === Configuration par environnement ===
|
||||
if settings.is_development:
|
||||
# En développement : logs détaillés dans un fichier
|
||||
logger.add(
|
||||
"logs/homestock_dev.log",
|
||||
format=log_format_file,
|
||||
level="DEBUG",
|
||||
rotation="10 MB", # Rotation tous les 10 Mo
|
||||
retention="7 days", # Garde les logs 7 jours
|
||||
compression="zip", # Compression des logs archivés
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
)
|
||||
else:
|
||||
# En production : logs normaux + logs d'erreurs séparés
|
||||
logger.add(
|
||||
"logs/homestock.log",
|
||||
format=log_format_file,
|
||||
level="INFO",
|
||||
rotation="50 MB",
|
||||
retention="30 days",
|
||||
compression="zip",
|
||||
backtrace=True,
|
||||
diagnose=False,
|
||||
)
|
||||
|
||||
# Fichier séparé pour les erreurs
|
||||
logger.add(
|
||||
"logs/homestock_errors.log",
|
||||
format=log_format_file,
|
||||
level="ERROR",
|
||||
rotation="50 MB",
|
||||
retention="90 days", # Garde les erreurs plus longtemps
|
||||
compression="zip",
|
||||
backtrace=True,
|
||||
diagnose=False,
|
||||
)
|
||||
|
||||
logger.info(f"Logging configuré (level={settings.LOG_LEVEL}, env={settings.ENVIRONMENT})")
|
||||
|
||||
|
||||
def get_logger(name: str) -> "logger":
|
||||
"""Retourne un logger avec un nom spécifique.
|
||||
|
||||
Args:
|
||||
name: Nom du logger (généralement __name__ du module)
|
||||
|
||||
Returns:
|
||||
Logger configuré avec le nom spécifié
|
||||
"""
|
||||
return logger.bind(name=name)
|
||||
126
backend/app/main.py
Normal file
126
backend/app/main.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Point d'entrée de l'application HomeStock.
|
||||
|
||||
Application FastAPI pour la gestion d'inventaire domestique.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import close_db, init_db
|
||||
from app.core.logging import setup_logging
|
||||
|
||||
# Configuration du logging
|
||||
setup_logging()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> Any:
|
||||
"""Gère le cycle de vie de l'application.
|
||||
|
||||
- Startup : Initialise la base de données
|
||||
- Shutdown : Ferme les connexions proprement
|
||||
"""
|
||||
logger.info("Démarrage de l'application HomeStock")
|
||||
logger.info(f"Environnement: {settings.ENVIRONMENT}")
|
||||
logger.info(f"Version: {settings.APP_VERSION}")
|
||||
|
||||
# Initialisation de la base de données
|
||||
# Note: En production, utiliser Alembic pour les migrations
|
||||
if settings.is_development:
|
||||
logger.debug("Initialisation de la base de données (mode développement)")
|
||||
await init_db()
|
||||
|
||||
yield
|
||||
|
||||
# Nettoyage
|
||||
logger.info("Arrêt de l'application HomeStock")
|
||||
await close_db()
|
||||
|
||||
|
||||
# Création de l'application FastAPI
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description="API pour la gestion d'inventaire domestique",
|
||||
version=settings.APP_VERSION,
|
||||
docs_url="/api/docs" if settings.is_development else None, # Swagger UI
|
||||
redoc_url="/api/redoc" if settings.is_development else None, # ReDoc
|
||||
openapi_url="/api/openapi.json" if settings.is_development else None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# === Configuration CORS ===
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
|
||||
allow_methods=["*"], # Autorise toutes les méthodes (GET, POST, PUT, DELETE, etc.)
|
||||
allow_headers=["*"], # Autorise tous les headers
|
||||
)
|
||||
|
||||
|
||||
# === Routes de santé ===
|
||||
@app.get("/", tags=["Health"])
|
||||
async def root() -> dict[str, str]:
|
||||
"""Route racine - Vérification de l'état de l'API."""
|
||||
return {
|
||||
"app": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health() -> dict[str, str]:
|
||||
"""Endpoint de santé pour monitoring."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"environment": settings.ENVIRONMENT,
|
||||
}
|
||||
|
||||
|
||||
# === Gestion globale des erreurs ===
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Any, exc: Exception) -> JSONResponse:
|
||||
"""Gestionnaire global des exceptions non capturées.
|
||||
|
||||
Log l'erreur et retourne une réponse JSON standardisée.
|
||||
"""
|
||||
logger.error(f"Erreur non gérée: {exc}", exc_info=True)
|
||||
|
||||
# En production, masquer les détails de l'erreur
|
||||
detail = str(exc) if settings.is_development else "Erreur interne du serveur"
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": detail,
|
||||
"type": "internal_server_error",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# === Enregistrement des routers ===
|
||||
from app.routers import categories_router, items_router, locations_router
|
||||
|
||||
app.include_router(categories_router, prefix="/api/v1")
|
||||
app.include_router(locations_router, prefix="/api/v1")
|
||||
app.include_router(items_router, prefix="/api/v1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.BACKEND_HOST,
|
||||
port=settings.BACKEND_PORT,
|
||||
reload=settings.BACKEND_RELOAD,
|
||||
log_level=settings.LOG_LEVEL.lower(),
|
||||
)
|
||||
19
backend/app/models/__init__.py
Normal file
19
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Package des modèles SQLAlchemy.
|
||||
|
||||
Importe tous les modèles pour qu'ils soient disponibles pour Alembic.
|
||||
"""
|
||||
|
||||
from app.models.category import Category
|
||||
from app.models.document import Document, DocumentType
|
||||
from app.models.item import Item, ItemStatus
|
||||
from app.models.location import Location, LocationType
|
||||
|
||||
__all__ = [
|
||||
"Category",
|
||||
"Location",
|
||||
"LocationType",
|
||||
"Item",
|
||||
"ItemStatus",
|
||||
"Document",
|
||||
"DocumentType",
|
||||
]
|
||||
61
backend/app/models/category.py
Normal file
61
backend/app/models/category.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Modèle SQLAlchemy pour les catégories d'objets.
|
||||
|
||||
Les catégories permettent de classer les objets par domaine d'utilisation
|
||||
(bricolage, informatique, électronique, cuisine, etc.).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.item import Item
|
||||
|
||||
|
||||
class Category(Base):
|
||||
"""Catégorie d'objets.
|
||||
|
||||
Attributes:
|
||||
id: Identifiant unique auto-incrémenté
|
||||
name: Nom de la catégorie (ex: "Bricolage", "Informatique")
|
||||
description: Description optionnelle de la catégorie
|
||||
color: Couleur hexadécimale pour l'affichage (ex: "#3b82f6")
|
||||
icon: Nom d'icône optionnel (ex: "wrench", "computer")
|
||||
created_at: Date/heure de création (auto)
|
||||
updated_at: Date/heure de dernière modification (auto)
|
||||
items: Relation vers les objets de cette catégorie
|
||||
"""
|
||||
|
||||
__tablename__ = "categories"
|
||||
|
||||
# Colonnes
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Format: #RRGGBB
|
||||
icon: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relations
|
||||
items: Mapped[list["Item"]] = relationship(
|
||||
"Item", back_populates="category", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Représentation string de la catégorie."""
|
||||
return f"<Category(id={self.id}, name='{self.name}')>"
|
||||
85
backend/app/models/document.py
Normal file
85
backend/app/models/document.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Modèle SQLAlchemy pour les documents attachés aux objets.
|
||||
|
||||
Les documents peuvent être des photos, notices d'utilisation, factures, etc.
|
||||
Ils sont stockés sur le système de fichiers local.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.item import Item
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class DocumentType(str, enum.Enum):
|
||||
"""Type de document."""
|
||||
|
||||
PHOTO = "photo" # Photo de l'objet
|
||||
MANUAL = "manual" # Notice d'utilisation
|
||||
INVOICE = "invoice" # Facture d'achat
|
||||
WARRANTY = "warranty" # Garantie
|
||||
OTHER = "other" # Autre
|
||||
|
||||
|
||||
class Document(Base):
|
||||
"""Document attaché à un objet.
|
||||
|
||||
Attributes:
|
||||
id: Identifiant unique auto-incrémenté
|
||||
filename: Nom du fichier sur le disque (UUID + extension)
|
||||
original_name: Nom original du fichier uploadé
|
||||
type: Type de document (photo/manual/invoice/warranty/other)
|
||||
mime_type: Type MIME (ex: "image/jpeg", "application/pdf")
|
||||
size_bytes: Taille du fichier en octets
|
||||
file_path: Chemin relatif du fichier (ex: "uploads/photos/uuid.jpg")
|
||||
description: Description optionnelle
|
||||
item_id: ID de l'objet associé (FK)
|
||||
created_at: Date/heure de création (auto)
|
||||
updated_at: Date/heure de dernière modification (auto)
|
||||
item: Relation vers l'objet
|
||||
"""
|
||||
|
||||
__tablename__ = "documents"
|
||||
|
||||
# Colonnes
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
filename: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
original_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
type: Mapped[DocumentType] = mapped_column(
|
||||
Enum(DocumentType, native_enum=False, length=20), nullable=False
|
||||
)
|
||||
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Foreign Key
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relations
|
||||
item: Mapped["Item"] = relationship("Item", back_populates="documents")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Représentation string du document."""
|
||||
return f"<Document(id={self.id}, type={self.type.value}, filename='{self.filename}')>"
|
||||
113
backend/app/models/item.py
Normal file
113
backend/app/models/item.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Modèle SQLAlchemy pour les objets de l'inventaire.
|
||||
|
||||
Les objets (items) sont l'entité centrale de l'application.
|
||||
Chaque objet appartient à une catégorie et est situé à un emplacement.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.category import Category
|
||||
from app.models.document import Document
|
||||
from app.models.location import Location
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class ItemStatus(str, enum.Enum):
|
||||
"""Statut d'un objet."""
|
||||
|
||||
IN_STOCK = "in_stock" # En stock (non utilisé)
|
||||
IN_USE = "in_use" # En cours d'utilisation
|
||||
BROKEN = "broken" # Cassé/HS
|
||||
SOLD = "sold" # Vendu
|
||||
LENT = "lent" # Prêté
|
||||
|
||||
|
||||
class Item(Base):
|
||||
"""Objet de l'inventaire domestique.
|
||||
|
||||
Attributes:
|
||||
id: Identifiant unique auto-incrémenté
|
||||
name: Nom de l'objet (ex: "Perceuse sans fil Bosch")
|
||||
description: Description détaillée optionnelle
|
||||
quantity: Quantité en stock (défaut: 1)
|
||||
price: Prix d'achat optionnel
|
||||
purchase_date: Date d'achat optionnelle
|
||||
status: Statut de l'objet (in_stock/in_use/broken/sold/lent)
|
||||
brand: Marque optionnelle (ex: "Bosch", "Samsung")
|
||||
model: Modèle optionnel (ex: "PSR 18 LI-2")
|
||||
serial_number: Numéro de série optionnel
|
||||
notes: Notes libres
|
||||
category_id: ID de la catégorie (FK)
|
||||
location_id: ID de l'emplacement (FK)
|
||||
created_at: Date/heure de création (auto)
|
||||
updated_at: Date/heure de dernière modification (auto)
|
||||
category: Relation vers la catégorie
|
||||
location: Relation vers l'emplacement
|
||||
documents: Relation vers les documents (photos, notices, factures)
|
||||
"""
|
||||
|
||||
__tablename__ = "items"
|
||||
|
||||
# Colonnes principales
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
status: Mapped[ItemStatus] = mapped_column(
|
||||
Enum(ItemStatus, native_enum=False, length=20),
|
||||
nullable=False,
|
||||
default=ItemStatus.IN_STOCK,
|
||||
)
|
||||
|
||||
# Informations produit
|
||||
brand: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
model: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
serial_number: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True)
|
||||
url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Lien vers page produit
|
||||
|
||||
# Informations achat
|
||||
price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||
purchase_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
# Notes
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Foreign Keys
|
||||
category_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("categories.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
location_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("locations.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relations
|
||||
category: Mapped["Category"] = relationship("Category", back_populates="items")
|
||||
location: Mapped["Location"] = relationship("Location", back_populates="items")
|
||||
documents: Mapped[list["Document"]] = relationship(
|
||||
"Document", back_populates="item", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Représentation string de l'objet."""
|
||||
return f"<Item(id={self.id}, name='{self.name}', status={self.status.value})>"
|
||||
97
backend/app/models/location.py
Normal file
97
backend/app/models/location.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Modèle SQLAlchemy pour les emplacements de stockage.
|
||||
|
||||
Les emplacements sont organisés de manière hiérarchique :
|
||||
pièce → meuble → tiroir → boîte
|
||||
Exemple : Garage → Étagère A → Tiroir 2 → Boîte visserie
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.item import Item
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class LocationType(str, enum.Enum):
|
||||
"""Type d'emplacement dans la hiérarchie."""
|
||||
|
||||
ROOM = "room" # Pièce (ex: Garage, Cuisine)
|
||||
FURNITURE = "furniture" # Meuble (ex: Étagère, Armoire)
|
||||
DRAWER = "drawer" # Tiroir
|
||||
BOX = "box" # Boîte/Bac de rangement
|
||||
|
||||
|
||||
class Location(Base):
|
||||
"""Emplacement de stockage hiérarchique.
|
||||
|
||||
Attributes:
|
||||
id: Identifiant unique auto-incrémenté
|
||||
name: Nom de l'emplacement (ex: "Garage", "Étagère A")
|
||||
type: Type d'emplacement (room/furniture/drawer/box)
|
||||
parent_id: ID du parent (None si racine)
|
||||
path: Chemin complet calculé (ex: "Garage > Étagère A > Tiroir 2")
|
||||
description: Description optionnelle
|
||||
created_at: Date/heure de création (auto)
|
||||
updated_at: Date/heure de dernière modification (auto)
|
||||
parent: Relation vers le parent
|
||||
children: Relation vers les enfants
|
||||
items: Relation vers les objets à cet emplacement
|
||||
"""
|
||||
|
||||
__tablename__ = "locations"
|
||||
|
||||
# Colonnes
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
type: Mapped[LocationType] = mapped_column(
|
||||
Enum(LocationType, native_enum=False, length=20), nullable=False
|
||||
)
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("locations.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
path: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relations
|
||||
parent: Mapped["Location | None"] = relationship(
|
||||
"Location", remote_side=[id], back_populates="children"
|
||||
)
|
||||
children: Mapped[list["Location"]] = relationship(
|
||||
"Location", back_populates="parent", cascade="all, delete-orphan"
|
||||
)
|
||||
items: Mapped[list["Item"]] = relationship(
|
||||
"Item", back_populates="location", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Représentation string de l'emplacement."""
|
||||
return f"<Location(id={self.id}, name='{self.name}', type={self.type.value})>"
|
||||
|
||||
def calculate_path(self) -> str:
|
||||
"""Calcule le chemin complet de l'emplacement.
|
||||
|
||||
Returns:
|
||||
Chemin complet (ex: "Garage > Étagère A > Tiroir 2")
|
||||
"""
|
||||
if self.parent is None:
|
||||
return self.name
|
||||
return f"{self.parent.calculate_path()} > {self.name}"
|
||||
15
backend/app/repositories/__init__.py
Normal file
15
backend/app/repositories/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Package des repositories pour l'accès aux données."""
|
||||
|
||||
from app.repositories.base import BaseRepository
|
||||
from app.repositories.category import CategoryRepository
|
||||
from app.repositories.document import DocumentRepository
|
||||
from app.repositories.item import ItemRepository
|
||||
from app.repositories.location import LocationRepository
|
||||
|
||||
__all__ = [
|
||||
"BaseRepository",
|
||||
"CategoryRepository",
|
||||
"LocationRepository",
|
||||
"ItemRepository",
|
||||
"DocumentRepository",
|
||||
]
|
||||
154
backend/app/repositories/base.py
Normal file
154
backend/app/repositories/base.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Repository de base générique.
|
||||
|
||||
Fournit les opérations CRUD de base pour tous les modèles.
|
||||
"""
|
||||
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=Base)
|
||||
|
||||
|
||||
class BaseRepository(Generic[ModelType]):
|
||||
"""Repository générique avec opérations CRUD de base.
|
||||
|
||||
Attributes:
|
||||
model: Classe du modèle SQLAlchemy
|
||||
db: Session de base de données
|
||||
"""
|
||||
|
||||
def __init__(self, model: type[ModelType], db: AsyncSession) -> None:
|
||||
"""Initialise le repository.
|
||||
|
||||
Args:
|
||||
model: Classe du modèle SQLAlchemy
|
||||
db: Session de base de données async
|
||||
"""
|
||||
self.model = model
|
||||
self.db = db
|
||||
|
||||
async def get(self, id: int) -> ModelType | None:
|
||||
"""Récupère un élément par son ID.
|
||||
|
||||
Args:
|
||||
id: Identifiant de l'élément
|
||||
|
||||
Returns:
|
||||
L'élément trouvé ou None
|
||||
"""
|
||||
result = await self.db.execute(select(self.model).where(self.model.id == id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all(
|
||||
self, skip: int = 0, limit: int = 100, **filters: Any
|
||||
) -> list[ModelType]:
|
||||
"""Récupère tous les éléments avec pagination et filtres optionnels.
|
||||
|
||||
Args:
|
||||
skip: Nombre d'éléments à sauter
|
||||
limit: Nombre max d'éléments à retourner
|
||||
**filters: Filtres additionnels (ex: status="active")
|
||||
|
||||
Returns:
|
||||
Liste des éléments
|
||||
"""
|
||||
query = select(self.model)
|
||||
|
||||
# Appliquer les filtres
|
||||
for field, value in filters.items():
|
||||
if value is not None and hasattr(self.model, field):
|
||||
query = query.where(getattr(self.model, field) == value)
|
||||
|
||||
query = query.offset(skip).limit(limit)
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count(self, **filters: Any) -> int:
|
||||
"""Compte le nombre d'éléments avec filtres optionnels.
|
||||
|
||||
Args:
|
||||
**filters: Filtres additionnels
|
||||
|
||||
Returns:
|
||||
Nombre total d'éléments
|
||||
"""
|
||||
query = select(func.count(self.model.id))
|
||||
|
||||
for field, value in filters.items():
|
||||
if value is not None and hasattr(self.model, field):
|
||||
query = query.where(getattr(self.model, field) == value)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return result.scalar_one()
|
||||
|
||||
async def create(self, **data: Any) -> ModelType:
|
||||
"""Crée un nouvel élément.
|
||||
|
||||
Args:
|
||||
**data: Données de l'élément
|
||||
|
||||
Returns:
|
||||
L'élément créé
|
||||
"""
|
||||
instance = self.model(**data)
|
||||
self.db.add(instance)
|
||||
await self.db.flush()
|
||||
await self.db.refresh(instance)
|
||||
return instance
|
||||
|
||||
async def update(self, id: int, **data: Any) -> ModelType | None:
|
||||
"""Met à jour un élément existant.
|
||||
|
||||
Args:
|
||||
id: Identifiant de l'élément
|
||||
**data: Données à mettre à jour (seules les valeurs non-None)
|
||||
|
||||
Returns:
|
||||
L'élément mis à jour ou None si non trouvé
|
||||
"""
|
||||
instance = await self.get(id)
|
||||
if instance is None:
|
||||
return None
|
||||
|
||||
for field, value in data.items():
|
||||
if value is not None and hasattr(instance, field):
|
||||
setattr(instance, field, value)
|
||||
|
||||
await self.db.flush()
|
||||
await self.db.refresh(instance)
|
||||
return instance
|
||||
|
||||
async def delete(self, id: int) -> bool:
|
||||
"""Supprime un élément.
|
||||
|
||||
Args:
|
||||
id: Identifiant de l'élément
|
||||
|
||||
Returns:
|
||||
True si supprimé, False si non trouvé
|
||||
"""
|
||||
instance = await self.get(id)
|
||||
if instance is None:
|
||||
return False
|
||||
|
||||
await self.db.delete(instance)
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
async def exists(self, id: int) -> bool:
|
||||
"""Vérifie si un élément existe.
|
||||
|
||||
Args:
|
||||
id: Identifiant de l'élément
|
||||
|
||||
Returns:
|
||||
True si existe, False sinon
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(func.count(self.model.id)).where(self.model.id == id)
|
||||
)
|
||||
return result.scalar_one() > 0
|
||||
85
backend/app/repositories/category.py
Normal file
85
backend/app/repositories/category.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Repository pour les catégories."""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.category import Category
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class CategoryRepository(BaseRepository[Category]):
|
||||
"""Repository pour les opérations sur les catégories."""
|
||||
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
"""Initialise le repository."""
|
||||
super().__init__(Category, db)
|
||||
|
||||
async def get_by_name(self, name: str) -> Category | None:
|
||||
"""Récupère une catégorie par son nom.
|
||||
|
||||
Args:
|
||||
name: Nom de la catégorie
|
||||
|
||||
Returns:
|
||||
La catégorie trouvée ou None
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Category).where(Category.name == name)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_with_item_count(self, id: int) -> tuple[Category, int] | None:
|
||||
"""Récupère une catégorie avec le nombre d'objets.
|
||||
|
||||
Args:
|
||||
id: ID de la catégorie
|
||||
|
||||
Returns:
|
||||
Tuple (catégorie, nombre d'objets) ou None
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Category).options(selectinload(Category.items)).where(Category.id == id)
|
||||
)
|
||||
category = result.scalar_one_or_none()
|
||||
if category is None:
|
||||
return None
|
||||
return category, len(category.items)
|
||||
|
||||
async def get_all_with_item_count(
|
||||
self, skip: int = 0, limit: int = 100
|
||||
) -> list[tuple[Category, int]]:
|
||||
"""Récupère toutes les catégories avec le nombre d'objets.
|
||||
|
||||
Args:
|
||||
skip: Offset
|
||||
limit: Limite
|
||||
|
||||
Returns:
|
||||
Liste de tuples (catégorie, nombre d'objets)
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Category)
|
||||
.options(selectinload(Category.items))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.order_by(Category.name)
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
return [(cat, len(cat.items)) for cat in categories]
|
||||
|
||||
async def name_exists(self, name: str, exclude_id: int | None = None) -> bool:
|
||||
"""Vérifie si un nom de catégorie existe déjà.
|
||||
|
||||
Args:
|
||||
name: Nom à vérifier
|
||||
exclude_id: ID à exclure (pour les mises à jour)
|
||||
|
||||
Returns:
|
||||
True si le nom existe déjà
|
||||
"""
|
||||
query = select(func.count(Category.id)).where(Category.name == name)
|
||||
if exclude_id is not None:
|
||||
query = query.where(Category.id != exclude_id)
|
||||
result = await self.db.execute(query)
|
||||
return result.scalar_one() > 0
|
||||
113
backend/app/repositories/document.py
Normal file
113
backend/app/repositories/document.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Repository pour les documents attachés."""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.document import Document, DocumentType
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class DocumentRepository(BaseRepository[Document]):
|
||||
"""Repository pour les opérations sur les documents."""
|
||||
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
"""Initialise le repository."""
|
||||
super().__init__(Document, db)
|
||||
|
||||
async def get_by_item(self, item_id: int) -> list[Document]:
|
||||
"""Récupère tous les documents d'un objet.
|
||||
|
||||
Args:
|
||||
item_id: ID de l'objet
|
||||
|
||||
Returns:
|
||||
Liste des documents
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Document)
|
||||
.where(Document.item_id == item_id)
|
||||
.order_by(Document.type, Document.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_item_and_type(
|
||||
self, item_id: int, type: DocumentType
|
||||
) -> list[Document]:
|
||||
"""Récupère les documents d'un objet par type.
|
||||
|
||||
Args:
|
||||
item_id: ID de l'objet
|
||||
type: Type de document
|
||||
|
||||
Returns:
|
||||
Liste des documents
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Document)
|
||||
.where(Document.item_id == item_id, Document.type == type)
|
||||
.order_by(Document.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_filename(self, filename: str) -> Document | None:
|
||||
"""Récupère un document par son nom de fichier.
|
||||
|
||||
Args:
|
||||
filename: Nom du fichier (UUID)
|
||||
|
||||
Returns:
|
||||
Le document trouvé ou None
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Document).where(Document.filename == filename)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def count_by_item(self, item_id: int) -> int:
|
||||
"""Compte le nombre de documents d'un objet.
|
||||
|
||||
Args:
|
||||
item_id: ID de l'objet
|
||||
|
||||
Returns:
|
||||
Nombre de documents
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(Document.id)).where(Document.item_id == item_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
async def get_photos(self, item_id: int) -> list[Document]:
|
||||
"""Récupère les photos d'un objet.
|
||||
|
||||
Args:
|
||||
item_id: ID de l'objet
|
||||
|
||||
Returns:
|
||||
Liste des photos
|
||||
"""
|
||||
return await self.get_by_item_and_type(item_id, DocumentType.PHOTO)
|
||||
|
||||
async def get_invoices(self, item_id: int) -> list[Document]:
|
||||
"""Récupère les factures d'un objet.
|
||||
|
||||
Args:
|
||||
item_id: ID de l'objet
|
||||
|
||||
Returns:
|
||||
Liste des factures
|
||||
"""
|
||||
return await self.get_by_item_and_type(item_id, DocumentType.INVOICE)
|
||||
|
||||
async def get_manuals(self, item_id: int) -> list[Document]:
|
||||
"""Récupère les notices d'un objet.
|
||||
|
||||
Args:
|
||||
item_id: ID de l'objet
|
||||
|
||||
Returns:
|
||||
Liste des notices
|
||||
"""
|
||||
return await self.get_by_item_and_type(item_id, DocumentType.MANUAL)
|
||||
247
backend/app/repositories/item.py
Normal file
247
backend/app/repositories/item.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Repository pour les objets d'inventaire."""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.item import Item, ItemStatus
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class ItemRepository(BaseRepository[Item]):
|
||||
"""Repository pour les opérations sur les objets."""
|
||||
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
"""Initialise le repository."""
|
||||
super().__init__(Item, db)
|
||||
|
||||
async def get_with_relations(self, id: int) -> Item | None:
|
||||
"""Récupère un objet avec ses relations (catégorie, emplacement, documents).
|
||||
|
||||
Args:
|
||||
id: ID de l'objet
|
||||
|
||||
Returns:
|
||||
L'objet avec ses relations ou None
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Item)
|
||||
.options(
|
||||
selectinload(Item.category),
|
||||
selectinload(Item.location),
|
||||
selectinload(Item.documents),
|
||||
)
|
||||
.where(Item.id == id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all_with_relations(
|
||||
self, skip: int = 0, limit: int = 100
|
||||
) -> list[Item]:
|
||||
"""Récupère tous les objets avec leurs relations.
|
||||
|
||||
Args:
|
||||
skip: Offset
|
||||
limit: Limite
|
||||
|
||||
Returns:
|
||||
Liste des objets avec relations
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Item)
|
||||
.options(
|
||||
selectinload(Item.category),
|
||||
selectinload(Item.location),
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.order_by(Item.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
category_id: int | None = None,
|
||||
location_id: int | None = None,
|
||||
status: ItemStatus | None = None,
|
||||
min_price: Decimal | None = None,
|
||||
max_price: Decimal | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[Item]:
|
||||
"""Recherche des objets avec filtres.
|
||||
|
||||
Args:
|
||||
query: Texte de recherche (nom, description, marque, modèle)
|
||||
category_id: Filtre par catégorie
|
||||
location_id: Filtre par emplacement
|
||||
status: Filtre par statut
|
||||
min_price: Prix minimum
|
||||
max_price: Prix maximum
|
||||
skip: Offset
|
||||
limit: Limite
|
||||
|
||||
Returns:
|
||||
Liste des objets correspondants
|
||||
"""
|
||||
stmt = select(Item).options(
|
||||
selectinload(Item.category),
|
||||
selectinload(Item.location),
|
||||
)
|
||||
|
||||
# Recherche textuelle
|
||||
if query:
|
||||
search_term = f"%{query}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Item.name.ilike(search_term),
|
||||
Item.description.ilike(search_term),
|
||||
Item.brand.ilike(search_term),
|
||||
Item.model.ilike(search_term),
|
||||
Item.notes.ilike(search_term),
|
||||
)
|
||||
)
|
||||
|
||||
# Filtres
|
||||
if category_id is not None:
|
||||
stmt = stmt.where(Item.category_id == category_id)
|
||||
if location_id is not None:
|
||||
stmt = stmt.where(Item.location_id == location_id)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Item.status == status)
|
||||
if min_price is not None:
|
||||
stmt = stmt.where(Item.price >= min_price)
|
||||
if max_price is not None:
|
||||
stmt = stmt.where(Item.price <= max_price)
|
||||
|
||||
stmt = stmt.offset(skip).limit(limit).order_by(Item.name)
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count_filtered(
|
||||
self,
|
||||
query: str | None = None,
|
||||
category_id: int | None = None,
|
||||
location_id: int | None = None,
|
||||
status: ItemStatus | None = None,
|
||||
min_price: Decimal | None = None,
|
||||
max_price: Decimal | None = None,
|
||||
) -> int:
|
||||
"""Compte les objets avec filtres.
|
||||
|
||||
Returns:
|
||||
Nombre d'objets correspondants
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
stmt = select(func.count(Item.id))
|
||||
|
||||
if query:
|
||||
search_term = f"%{query}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Item.name.ilike(search_term),
|
||||
Item.description.ilike(search_term),
|
||||
Item.brand.ilike(search_term),
|
||||
Item.model.ilike(search_term),
|
||||
Item.notes.ilike(search_term),
|
||||
)
|
||||
)
|
||||
|
||||
if category_id is not None:
|
||||
stmt = stmt.where(Item.category_id == category_id)
|
||||
if location_id is not None:
|
||||
stmt = stmt.where(Item.location_id == location_id)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Item.status == status)
|
||||
if min_price is not None:
|
||||
stmt = stmt.where(Item.price >= min_price)
|
||||
if max_price is not None:
|
||||
stmt = stmt.where(Item.price <= max_price)
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
async def get_by_category(
|
||||
self, category_id: int, skip: int = 0, limit: int = 100
|
||||
) -> list[Item]:
|
||||
"""Récupère les objets d'une catégorie.
|
||||
|
||||
Args:
|
||||
category_id: ID de la catégorie
|
||||
skip: Offset
|
||||
limit: Limite
|
||||
|
||||
Returns:
|
||||
Liste des objets
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Item)
|
||||
.where(Item.category_id == category_id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.order_by(Item.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_location(
|
||||
self, location_id: int, skip: int = 0, limit: int = 100
|
||||
) -> list[Item]:
|
||||
"""Récupère les objets d'un emplacement.
|
||||
|
||||
Args:
|
||||
location_id: ID de l'emplacement
|
||||
skip: Offset
|
||||
limit: Limite
|
||||
|
||||
Returns:
|
||||
Liste des objets
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Item)
|
||||
.where(Item.location_id == location_id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.order_by(Item.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_status(
|
||||
self, status: ItemStatus, skip: int = 0, limit: int = 100
|
||||
) -> list[Item]:
|
||||
"""Récupère les objets par statut.
|
||||
|
||||
Args:
|
||||
status: Statut recherché
|
||||
skip: Offset
|
||||
limit: Limite
|
||||
|
||||
Returns:
|
||||
Liste des objets
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Item)
|
||||
.where(Item.status == status)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.order_by(Item.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_serial_number(self, serial_number: str) -> Item | None:
|
||||
"""Récupère un objet par son numéro de série.
|
||||
|
||||
Args:
|
||||
serial_number: Numéro de série
|
||||
|
||||
Returns:
|
||||
L'objet trouvé ou None
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Item).where(Item.serial_number == serial_number)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
171
backend/app/repositories/location.py
Normal file
171
backend/app/repositories/location.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Repository pour les emplacements."""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.location import Location, LocationType
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class LocationRepository(BaseRepository[Location]):
|
||||
"""Repository pour les opérations sur les emplacements."""
|
||||
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
"""Initialise le repository."""
|
||||
super().__init__(Location, db)
|
||||
|
||||
async def get_with_children(self, id: int) -> Location | None:
|
||||
"""Récupère un emplacement avec ses enfants.
|
||||
|
||||
Args:
|
||||
id: ID de l'emplacement
|
||||
|
||||
Returns:
|
||||
L'emplacement avec ses enfants ou None
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Location)
|
||||
.options(selectinload(Location.children))
|
||||
.where(Location.id == id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_root_locations(self) -> list[Location]:
|
||||
"""Récupère tous les emplacements racine (sans parent).
|
||||
|
||||
Returns:
|
||||
Liste des emplacements racine
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Location)
|
||||
.where(Location.parent_id.is_(None))
|
||||
.order_by(Location.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_children(self, parent_id: int) -> list[Location]:
|
||||
"""Récupère les enfants directs d'un emplacement.
|
||||
|
||||
Args:
|
||||
parent_id: ID du parent
|
||||
|
||||
Returns:
|
||||
Liste des enfants
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Location)
|
||||
.where(Location.parent_id == parent_id)
|
||||
.order_by(Location.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_by_type(self, type: LocationType) -> list[Location]:
|
||||
"""Récupère tous les emplacements d'un type donné.
|
||||
|
||||
Args:
|
||||
type: Type d'emplacement
|
||||
|
||||
Returns:
|
||||
Liste des emplacements
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Location)
|
||||
.where(Location.type == type)
|
||||
.order_by(Location.path)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_full_tree(self) -> list[Location]:
|
||||
"""Récupère l'arborescence complète des emplacements.
|
||||
|
||||
Returns:
|
||||
Liste des emplacements racine avec enfants chargés récursivement
|
||||
"""
|
||||
# Charger tous les emplacements avec leurs enfants
|
||||
result = await self.db.execute(
|
||||
select(Location)
|
||||
.options(selectinload(Location.children))
|
||||
.order_by(Location.path)
|
||||
)
|
||||
all_locations = list(result.scalars().all())
|
||||
|
||||
# Retourner seulement les racines (les enfants sont déjà chargés)
|
||||
return [loc for loc in all_locations if loc.parent_id is None]
|
||||
|
||||
async def get_with_item_count(self, id: int) -> tuple[Location, int] | None:
|
||||
"""Récupère un emplacement avec le nombre d'objets.
|
||||
|
||||
Args:
|
||||
id: ID de l'emplacement
|
||||
|
||||
Returns:
|
||||
Tuple (emplacement, nombre d'objets) ou None
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Location)
|
||||
.options(selectinload(Location.items))
|
||||
.where(Location.id == id)
|
||||
)
|
||||
location = result.scalar_one_or_none()
|
||||
if location is None:
|
||||
return None
|
||||
return location, len(location.items)
|
||||
|
||||
async def create_with_path(
|
||||
self,
|
||||
name: str,
|
||||
type: LocationType,
|
||||
parent_id: int | None = None,
|
||||
description: str | None = None,
|
||||
) -> Location:
|
||||
"""Crée un emplacement avec calcul automatique du chemin.
|
||||
|
||||
Args:
|
||||
name: Nom de l'emplacement
|
||||
type: Type d'emplacement
|
||||
parent_id: ID du parent (None si racine)
|
||||
description: Description optionnelle
|
||||
|
||||
Returns:
|
||||
L'emplacement créé
|
||||
"""
|
||||
# Calculer le chemin
|
||||
if parent_id is None:
|
||||
path = name
|
||||
else:
|
||||
parent = await self.get(parent_id)
|
||||
if parent is None:
|
||||
path = name
|
||||
else:
|
||||
path = f"{parent.path} > {name}"
|
||||
|
||||
return await self.create(
|
||||
name=name,
|
||||
type=type,
|
||||
parent_id=parent_id,
|
||||
path=path,
|
||||
description=description,
|
||||
)
|
||||
|
||||
async def update_paths_recursive(self, location: Location) -> None:
|
||||
"""Met à jour récursivement les chemins après modification.
|
||||
|
||||
Args:
|
||||
location: Emplacement modifié
|
||||
"""
|
||||
# Mettre à jour le chemin de cet emplacement
|
||||
if location.parent_id is None:
|
||||
location.path = location.name
|
||||
else:
|
||||
parent = await self.get(location.parent_id)
|
||||
if parent:
|
||||
location.path = f"{parent.path} > {location.name}"
|
||||
else:
|
||||
location.path = location.name
|
||||
|
||||
# Mettre à jour les enfants
|
||||
children = await self.get_children(location.id)
|
||||
for child in children:
|
||||
child.path = f"{location.path} > {child.name}"
|
||||
await self.update_paths_recursive(child)
|
||||
11
backend/app/routers/__init__.py
Normal file
11
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Package des routers API."""
|
||||
|
||||
from app.routers.categories import router as categories_router
|
||||
from app.routers.items import router as items_router
|
||||
from app.routers.locations import router as locations_router
|
||||
|
||||
__all__ = [
|
||||
"categories_router",
|
||||
"locations_router",
|
||||
"items_router",
|
||||
]
|
||||
168
backend/app/routers/categories.py
Normal file
168
backend/app/routers/categories.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Router API pour les catégories."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.repositories.category import CategoryRepository
|
||||
from app.schemas.category import (
|
||||
CategoryCreate,
|
||||
CategoryResponse,
|
||||
CategoryUpdate,
|
||||
CategoryWithItemCount,
|
||||
)
|
||||
from app.schemas.common import PaginatedResponse, SuccessResponse
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["Categories"])
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse[CategoryWithItemCount])
|
||||
async def list_categories(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> PaginatedResponse[CategoryWithItemCount]:
|
||||
"""Liste toutes les catégories avec le nombre d'objets."""
|
||||
repo = CategoryRepository(db)
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
categories_with_count = await repo.get_all_with_item_count(skip=skip, limit=page_size)
|
||||
total = await repo.count()
|
||||
|
||||
items = [
|
||||
CategoryWithItemCount(
|
||||
id=cat.id,
|
||||
name=cat.name,
|
||||
description=cat.description,
|
||||
color=cat.color,
|
||||
icon=cat.icon,
|
||||
created_at=cat.created_at,
|
||||
updated_at=cat.updated_at,
|
||||
item_count=count,
|
||||
)
|
||||
for cat, count in categories_with_count
|
||||
]
|
||||
|
||||
return PaginatedResponse.create(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/{category_id}", response_model=CategoryWithItemCount)
|
||||
async def get_category(
|
||||
category_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CategoryWithItemCount:
|
||||
"""Récupère une catégorie par son ID."""
|
||||
repo = CategoryRepository(db)
|
||||
result = await repo.get_with_item_count(category_id)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Catégorie {category_id} non trouvée",
|
||||
)
|
||||
|
||||
category, item_count = result
|
||||
return CategoryWithItemCount(
|
||||
id=category.id,
|
||||
name=category.name,
|
||||
description=category.description,
|
||||
color=category.color,
|
||||
icon=category.icon,
|
||||
created_at=category.created_at,
|
||||
updated_at=category.updated_at,
|
||||
item_count=item_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_category(
|
||||
data: CategoryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CategoryResponse:
|
||||
"""Crée une nouvelle catégorie."""
|
||||
repo = CategoryRepository(db)
|
||||
|
||||
# Vérifier si le nom existe déjà
|
||||
if await repo.name_exists(data.name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Une catégorie avec le nom '{data.name}' existe déjà",
|
||||
)
|
||||
|
||||
category = await repo.create(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
color=data.color,
|
||||
icon=data.icon,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.put("/{category_id}", response_model=CategoryResponse)
|
||||
async def update_category(
|
||||
category_id: int,
|
||||
data: CategoryUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CategoryResponse:
|
||||
"""Met à jour une catégorie."""
|
||||
repo = CategoryRepository(db)
|
||||
|
||||
# Vérifier si la catégorie existe
|
||||
existing = await repo.get(category_id)
|
||||
if existing is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Catégorie {category_id} non trouvée",
|
||||
)
|
||||
|
||||
# Vérifier si le nouveau nom existe déjà (si changement de nom)
|
||||
if data.name and data.name != existing.name:
|
||||
if await repo.name_exists(data.name, exclude_id=category_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Une catégorie avec le nom '{data.name}' existe déjà",
|
||||
)
|
||||
|
||||
category = await repo.update(
|
||||
category_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
color=data.color,
|
||||
icon=data.icon,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.delete("/{category_id}", response_model=SuccessResponse)
|
||||
async def delete_category(
|
||||
category_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SuccessResponse:
|
||||
"""Supprime une catégorie."""
|
||||
repo = CategoryRepository(db)
|
||||
|
||||
# Vérifier si la catégorie existe
|
||||
result = await repo.get_with_item_count(category_id)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Catégorie {category_id} non trouvée",
|
||||
)
|
||||
|
||||
category, item_count = result
|
||||
|
||||
# Empêcher la suppression si des objets utilisent cette catégorie
|
||||
if item_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Impossible de supprimer : {item_count} objet(s) utilisent cette catégorie",
|
||||
)
|
||||
|
||||
await repo.delete(category_id)
|
||||
await db.commit()
|
||||
|
||||
return SuccessResponse(message="Catégorie supprimée avec succès", id=category_id)
|
||||
264
backend/app/routers/items.py
Normal file
264
backend/app/routers/items.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""Router API pour les objets d'inventaire."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.item import ItemStatus
|
||||
from app.repositories.category import CategoryRepository
|
||||
from app.repositories.item import ItemRepository
|
||||
from app.repositories.location import LocationRepository
|
||||
from app.schemas.common import PaginatedResponse, SuccessResponse
|
||||
from app.schemas.item import (
|
||||
ItemCreate,
|
||||
ItemResponse,
|
||||
ItemUpdate,
|
||||
ItemWithRelations,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/items", tags=["Items"])
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse[ItemWithRelations])
|
||||
async def list_items(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
search: str | None = Query(default=None, min_length=2),
|
||||
category_id: int | None = None,
|
||||
location_id: int | None = None,
|
||||
status: ItemStatus | None = None,
|
||||
min_price: Decimal | None = None,
|
||||
max_price: Decimal | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> PaginatedResponse[ItemWithRelations]:
|
||||
"""Liste les objets avec filtres et pagination."""
|
||||
repo = ItemRepository(db)
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
items = await repo.search(
|
||||
query=search or "",
|
||||
category_id=category_id,
|
||||
location_id=location_id,
|
||||
status=status,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
)
|
||||
|
||||
total = await repo.count_filtered(
|
||||
query=search,
|
||||
category_id=category_id,
|
||||
location_id=location_id,
|
||||
status=status,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
)
|
||||
|
||||
result_items = [ItemWithRelations.model_validate(item) for item in items]
|
||||
return PaginatedResponse.create(items=result_items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=ItemWithRelations)
|
||||
async def get_item(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ItemWithRelations:
|
||||
"""Récupère un objet par son ID avec ses relations."""
|
||||
repo = ItemRepository(db)
|
||||
item = await repo.get_with_relations(item_id)
|
||||
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Objet {item_id} non trouvé",
|
||||
)
|
||||
|
||||
return ItemWithRelations.model_validate(item)
|
||||
|
||||
|
||||
@router.post("", response_model=ItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_item(
|
||||
data: ItemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ItemResponse:
|
||||
"""Crée un nouvel objet."""
|
||||
item_repo = ItemRepository(db)
|
||||
category_repo = CategoryRepository(db)
|
||||
location_repo = LocationRepository(db)
|
||||
|
||||
# Vérifier que la catégorie existe
|
||||
if not await category_repo.exists(data.category_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Catégorie {data.category_id} non trouvée",
|
||||
)
|
||||
|
||||
# Vérifier que l'emplacement existe
|
||||
if not await location_repo.exists(data.location_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Emplacement {data.location_id} non trouvé",
|
||||
)
|
||||
|
||||
# Vérifier l'unicité du numéro de série si fourni
|
||||
if data.serial_number:
|
||||
existing = await item_repo.get_by_serial_number(data.serial_number)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Un objet avec le numéro de série '{data.serial_number}' existe déjà",
|
||||
)
|
||||
|
||||
item = await item_repo.create(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
quantity=data.quantity,
|
||||
status=data.status,
|
||||
brand=data.brand,
|
||||
model=data.model,
|
||||
serial_number=data.serial_number,
|
||||
price=data.price,
|
||||
purchase_date=data.purchase_date,
|
||||
notes=data.notes,
|
||||
category_id=data.category_id,
|
||||
location_id=data.location_id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return ItemResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.put("/{item_id}", response_model=ItemResponse)
|
||||
async def update_item(
|
||||
item_id: int,
|
||||
data: ItemUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ItemResponse:
|
||||
"""Met à jour un objet."""
|
||||
item_repo = ItemRepository(db)
|
||||
category_repo = CategoryRepository(db)
|
||||
location_repo = LocationRepository(db)
|
||||
|
||||
# Vérifier que l'objet existe
|
||||
existing = await item_repo.get(item_id)
|
||||
if existing is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Objet {item_id} non trouvé",
|
||||
)
|
||||
|
||||
# Vérifier la catégorie si changée
|
||||
if data.category_id is not None and data.category_id != existing.category_id:
|
||||
if not await category_repo.exists(data.category_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Catégorie {data.category_id} non trouvée",
|
||||
)
|
||||
|
||||
# Vérifier l'emplacement si changé
|
||||
if data.location_id is not None and data.location_id != existing.location_id:
|
||||
if not await location_repo.exists(data.location_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Emplacement {data.location_id} non trouvé",
|
||||
)
|
||||
|
||||
# Vérifier l'unicité du numéro de série si changé
|
||||
if data.serial_number and data.serial_number != existing.serial_number:
|
||||
existing_with_serial = await item_repo.get_by_serial_number(data.serial_number)
|
||||
if existing_with_serial and existing_with_serial.id != item_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Un objet avec le numéro de série '{data.serial_number}' existe déjà",
|
||||
)
|
||||
|
||||
item = await item_repo.update(
|
||||
item_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
quantity=data.quantity,
|
||||
status=data.status,
|
||||
brand=data.brand,
|
||||
model=data.model,
|
||||
serial_number=data.serial_number,
|
||||
price=data.price,
|
||||
purchase_date=data.purchase_date,
|
||||
notes=data.notes,
|
||||
category_id=data.category_id,
|
||||
location_id=data.location_id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return ItemResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/{item_id}", response_model=SuccessResponse)
|
||||
async def delete_item(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SuccessResponse:
|
||||
"""Supprime un objet et ses documents associés."""
|
||||
repo = ItemRepository(db)
|
||||
|
||||
# Vérifier que l'objet existe
|
||||
if not await repo.exists(item_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Objet {item_id} non trouvé",
|
||||
)
|
||||
|
||||
await repo.delete(item_id)
|
||||
await db.commit()
|
||||
|
||||
return SuccessResponse(message="Objet supprimé avec succès", id=item_id)
|
||||
|
||||
|
||||
@router.patch("/{item_id}/status", response_model=ItemResponse)
|
||||
async def update_item_status(
|
||||
item_id: int,
|
||||
new_status: ItemStatus,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ItemResponse:
|
||||
"""Met à jour le statut d'un objet."""
|
||||
repo = ItemRepository(db)
|
||||
|
||||
item = await repo.update(item_id, status=new_status)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Objet {item_id} non trouvé",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return ItemResponse.model_validate(item)
|
||||
|
||||
|
||||
@router.patch("/{item_id}/location", response_model=ItemResponse)
|
||||
async def move_item(
|
||||
item_id: int,
|
||||
new_location_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ItemResponse:
|
||||
"""Déplace un objet vers un nouvel emplacement."""
|
||||
item_repo = ItemRepository(db)
|
||||
location_repo = LocationRepository(db)
|
||||
|
||||
# Vérifier que le nouvel emplacement existe
|
||||
if not await location_repo.exists(new_location_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Emplacement {new_location_id} non trouvé",
|
||||
)
|
||||
|
||||
item = await item_repo.update(item_id, location_id=new_location_id)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Objet {item_id} non trouvé",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return ItemResponse.model_validate(item)
|
||||
249
backend/app/routers/locations.py
Normal file
249
backend/app/routers/locations.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""Router API pour les emplacements."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.location import LocationType
|
||||
from app.repositories.location import LocationRepository
|
||||
from app.schemas.common import PaginatedResponse, SuccessResponse
|
||||
from app.schemas.location import (
|
||||
LocationCreate,
|
||||
LocationResponse,
|
||||
LocationTree,
|
||||
LocationUpdate,
|
||||
LocationWithItemCount,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/locations", tags=["Locations"])
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse[LocationResponse])
|
||||
async def list_locations(
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
parent_id: int | None = None,
|
||||
type: LocationType | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> PaginatedResponse[LocationResponse]:
|
||||
"""Liste les emplacements avec filtres optionnels."""
|
||||
repo = LocationRepository(db)
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
filters = {}
|
||||
if parent_id is not None:
|
||||
filters["parent_id"] = parent_id
|
||||
if type is not None:
|
||||
filters["type"] = type
|
||||
|
||||
locations = await repo.get_all(skip=skip, limit=page_size, **filters)
|
||||
total = await repo.count(**filters)
|
||||
|
||||
items = [LocationResponse.model_validate(loc) for loc in locations]
|
||||
return PaginatedResponse.create(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/tree", response_model=list[LocationTree])
|
||||
async def get_location_tree(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[LocationTree]:
|
||||
"""Récupère l'arborescence complète des emplacements."""
|
||||
repo = LocationRepository(db)
|
||||
|
||||
# Récupérer tous les emplacements
|
||||
all_locations = await repo.get_all(skip=0, limit=1000)
|
||||
|
||||
# Construire un dictionnaire pour un accès rapide
|
||||
loc_dict: dict[int, LocationTree] = {}
|
||||
for loc in all_locations:
|
||||
loc_dict[loc.id] = LocationTree(
|
||||
id=loc.id,
|
||||
name=loc.name,
|
||||
type=loc.type,
|
||||
path=loc.path,
|
||||
children=[],
|
||||
item_count=0,
|
||||
)
|
||||
|
||||
# Construire l'arborescence
|
||||
roots: list[LocationTree] = []
|
||||
for loc in all_locations:
|
||||
tree_node = loc_dict[loc.id]
|
||||
if loc.parent_id is None:
|
||||
roots.append(tree_node)
|
||||
elif loc.parent_id in loc_dict:
|
||||
loc_dict[loc.parent_id].children.append(tree_node)
|
||||
|
||||
return roots
|
||||
|
||||
|
||||
@router.get("/roots", response_model=list[LocationResponse])
|
||||
async def get_root_locations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[LocationResponse]:
|
||||
"""Récupère les emplacements racine (pièces)."""
|
||||
repo = LocationRepository(db)
|
||||
locations = await repo.get_root_locations()
|
||||
return [LocationResponse.model_validate(loc) for loc in locations]
|
||||
|
||||
|
||||
@router.get("/{location_id}", response_model=LocationWithItemCount)
|
||||
async def get_location(
|
||||
location_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> LocationWithItemCount:
|
||||
"""Récupère un emplacement par son ID."""
|
||||
repo = LocationRepository(db)
|
||||
result = await repo.get_with_item_count(location_id)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Emplacement {location_id} non trouvé",
|
||||
)
|
||||
|
||||
location, item_count = result
|
||||
return LocationWithItemCount(
|
||||
id=location.id,
|
||||
name=location.name,
|
||||
type=location.type,
|
||||
parent_id=location.parent_id,
|
||||
path=location.path,
|
||||
description=location.description,
|
||||
created_at=location.created_at,
|
||||
updated_at=location.updated_at,
|
||||
item_count=item_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{location_id}/children", response_model=list[LocationResponse])
|
||||
async def get_location_children(
|
||||
location_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[LocationResponse]:
|
||||
"""Récupère les enfants directs d'un emplacement."""
|
||||
repo = LocationRepository(db)
|
||||
|
||||
# Vérifier que le parent existe
|
||||
if not await repo.exists(location_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Emplacement {location_id} non trouvé",
|
||||
)
|
||||
|
||||
children = await repo.get_children(location_id)
|
||||
return [LocationResponse.model_validate(child) for child in children]
|
||||
|
||||
|
||||
@router.post("", response_model=LocationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_location(
|
||||
data: LocationCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> LocationResponse:
|
||||
"""Crée un nouvel emplacement."""
|
||||
repo = LocationRepository(db)
|
||||
|
||||
# Vérifier que le parent existe si spécifié
|
||||
if data.parent_id is not None:
|
||||
if not await repo.exists(data.parent_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Emplacement parent {data.parent_id} non trouvé",
|
||||
)
|
||||
|
||||
location = await repo.create_with_path(
|
||||
name=data.name,
|
||||
type=data.type,
|
||||
parent_id=data.parent_id,
|
||||
description=data.description,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return LocationResponse.model_validate(location)
|
||||
|
||||
|
||||
@router.put("/{location_id}", response_model=LocationResponse)
|
||||
async def update_location(
|
||||
location_id: int,
|
||||
data: LocationUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> LocationResponse:
|
||||
"""Met à jour un emplacement."""
|
||||
repo = LocationRepository(db)
|
||||
|
||||
# Vérifier que l'emplacement existe
|
||||
existing = await repo.get(location_id)
|
||||
if existing is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Emplacement {location_id} non trouvé",
|
||||
)
|
||||
|
||||
# Vérifier que le nouveau parent existe si spécifié
|
||||
if data.parent_id is not None and data.parent_id != existing.parent_id:
|
||||
if data.parent_id == location_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Un emplacement ne peut pas être son propre parent",
|
||||
)
|
||||
if not await repo.exists(data.parent_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Emplacement parent {data.parent_id} non trouvé",
|
||||
)
|
||||
|
||||
# Mettre à jour
|
||||
location = await repo.update(
|
||||
location_id,
|
||||
name=data.name,
|
||||
type=data.type,
|
||||
parent_id=data.parent_id,
|
||||
description=data.description,
|
||||
)
|
||||
|
||||
# Recalculer les chemins si le nom ou le parent a changé
|
||||
if data.name or data.parent_id is not None:
|
||||
await repo.update_paths_recursive(location)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return LocationResponse.model_validate(location)
|
||||
|
||||
|
||||
@router.delete("/{location_id}", response_model=SuccessResponse)
|
||||
async def delete_location(
|
||||
location_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SuccessResponse:
|
||||
"""Supprime un emplacement."""
|
||||
repo = LocationRepository(db)
|
||||
|
||||
# Vérifier que l'emplacement existe
|
||||
result = await repo.get_with_item_count(location_id)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Emplacement {location_id} non trouvé",
|
||||
)
|
||||
|
||||
location, item_count = result
|
||||
|
||||
# Empêcher la suppression si des objets utilisent cet emplacement
|
||||
if item_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Impossible de supprimer : {item_count} objet(s) utilisent cet emplacement",
|
||||
)
|
||||
|
||||
# Vérifier s'il y a des enfants
|
||||
children = await repo.get_children(location_id)
|
||||
if children:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Impossible de supprimer : cet emplacement a {len(children)} sous-emplacement(s)",
|
||||
)
|
||||
|
||||
await repo.delete(location_id)
|
||||
await db.commit()
|
||||
|
||||
return SuccessResponse(message="Emplacement supprimé avec succès", id=location_id)
|
||||
68
backend/app/schemas/__init__.py
Normal file
68
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Package des schémas Pydantic."""
|
||||
|
||||
from app.schemas.category import (
|
||||
CategoryCreate,
|
||||
CategoryResponse,
|
||||
CategoryUpdate,
|
||||
CategoryWithItemCount,
|
||||
)
|
||||
from app.schemas.common import (
|
||||
ErrorResponse,
|
||||
PaginatedResponse,
|
||||
PaginationParams,
|
||||
SuccessResponse,
|
||||
)
|
||||
from app.schemas.document import (
|
||||
DocumentCreate,
|
||||
DocumentResponse,
|
||||
DocumentUpdate,
|
||||
DocumentUploadResponse,
|
||||
)
|
||||
from app.schemas.item import (
|
||||
ItemCreate,
|
||||
ItemFilter,
|
||||
ItemResponse,
|
||||
ItemSummary,
|
||||
ItemUpdate,
|
||||
ItemWithRelations,
|
||||
)
|
||||
from app.schemas.location import (
|
||||
LocationCreate,
|
||||
LocationResponse,
|
||||
LocationTree,
|
||||
LocationUpdate,
|
||||
LocationWithChildren,
|
||||
LocationWithItemCount,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Category
|
||||
"CategoryCreate",
|
||||
"CategoryUpdate",
|
||||
"CategoryResponse",
|
||||
"CategoryWithItemCount",
|
||||
# Location
|
||||
"LocationCreate",
|
||||
"LocationUpdate",
|
||||
"LocationResponse",
|
||||
"LocationWithChildren",
|
||||
"LocationWithItemCount",
|
||||
"LocationTree",
|
||||
# Item
|
||||
"ItemCreate",
|
||||
"ItemUpdate",
|
||||
"ItemResponse",
|
||||
"ItemWithRelations",
|
||||
"ItemSummary",
|
||||
"ItemFilter",
|
||||
# Document
|
||||
"DocumentCreate",
|
||||
"DocumentUpdate",
|
||||
"DocumentResponse",
|
||||
"DocumentUploadResponse",
|
||||
# Common
|
||||
"PaginationParams",
|
||||
"PaginatedResponse",
|
||||
"ErrorResponse",
|
||||
"SuccessResponse",
|
||||
]
|
||||
48
backend/app/schemas/category.py
Normal file
48
backend/app/schemas/category.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Schémas Pydantic pour les catégories.
|
||||
|
||||
Définit les schémas de validation pour les requêtes et réponses API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
"""Schéma de base pour les catégories."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Nom de la catégorie")
|
||||
description: str | None = Field(None, max_length=1000, description="Description optionnelle")
|
||||
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Couleur hex (#RRGGBB)")
|
||||
icon: str | None = Field(None, max_length=50, description="Nom de l'icône")
|
||||
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
"""Schéma pour la création d'une catégorie."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
"""Schéma pour la mise à jour d'une catégorie (tous les champs optionnels)."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
description: str | None = Field(None, max_length=1000)
|
||||
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
icon: str | None = Field(None, max_length=50)
|
||||
|
||||
|
||||
class CategoryResponse(CategoryBase):
|
||||
"""Schéma de réponse pour une catégorie."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CategoryWithItemCount(CategoryResponse):
|
||||
"""Schéma de réponse avec le nombre d'objets."""
|
||||
|
||||
item_count: int = Field(default=0, description="Nombre d'objets dans cette catégorie")
|
||||
60
backend/app/schemas/common.py
Normal file
60
backend/app/schemas/common.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Schémas Pydantic communs.
|
||||
|
||||
Définit les schémas réutilisables (pagination, erreurs, etc.).
|
||||
"""
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""Paramètres de pagination."""
|
||||
|
||||
page: int = Field(default=1, ge=1, description="Numéro de page (commence à 1)")
|
||||
page_size: int = Field(default=20, ge=1, le=100, description="Nombre d'éléments par page")
|
||||
|
||||
@property
|
||||
def offset(self) -> int:
|
||||
"""Calcule l'offset pour la requête SQL."""
|
||||
return (self.page - 1) * self.page_size
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""Réponse paginée générique."""
|
||||
|
||||
items: list[T]
|
||||
total: int = Field(..., description="Nombre total d'éléments")
|
||||
page: int = Field(..., description="Page actuelle")
|
||||
page_size: int = Field(..., description="Taille de la page")
|
||||
pages: int = Field(..., description="Nombre total de pages")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls, items: list[T], total: int, page: int, page_size: int
|
||||
) -> "PaginatedResponse[T]":
|
||||
"""Crée une réponse paginée."""
|
||||
pages = (total + page_size - 1) // page_size if page_size > 0 else 0
|
||||
return cls(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Schéma de réponse d'erreur."""
|
||||
|
||||
detail: str = Field(..., description="Message d'erreur")
|
||||
type: str = Field(..., description="Type d'erreur")
|
||||
|
||||
|
||||
class SuccessResponse(BaseModel):
|
||||
"""Schéma de réponse de succès."""
|
||||
|
||||
message: str = Field(..., description="Message de succès")
|
||||
id: int | None = Field(None, description="ID de l'élément concerné")
|
||||
63
backend/app/schemas/document.py
Normal file
63
backend/app/schemas/document.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Schémas Pydantic pour les documents attachés.
|
||||
|
||||
Définit les schémas de validation pour les requêtes et réponses API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.document import DocumentType
|
||||
|
||||
|
||||
class DocumentBase(BaseModel):
|
||||
"""Schéma de base pour les documents."""
|
||||
|
||||
type: DocumentType = Field(..., description="Type de document")
|
||||
description: str | None = Field(None, max_length=500, description="Description optionnelle")
|
||||
|
||||
|
||||
class DocumentCreate(DocumentBase):
|
||||
"""Schéma pour la création d'un document (métadonnées seulement).
|
||||
|
||||
Le fichier est uploadé séparément via multipart/form-data.
|
||||
"""
|
||||
|
||||
item_id: int = Field(..., description="ID de l'objet associé")
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
"""Schéma pour la mise à jour d'un document."""
|
||||
|
||||
type: DocumentType | None = None
|
||||
description: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
"""Schéma de réponse pour un document."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
filename: str
|
||||
original_name: str
|
||||
type: DocumentType
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
file_path: str
|
||||
description: str | None
|
||||
item_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DocumentUploadResponse(BaseModel):
|
||||
"""Schéma de réponse après upload d'un document."""
|
||||
|
||||
id: int
|
||||
filename: str
|
||||
original_name: str
|
||||
type: DocumentType
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
message: str = "Document uploadé avec succès"
|
||||
98
backend/app/schemas/item.py
Normal file
98
backend/app/schemas/item.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Schémas Pydantic pour les objets d'inventaire.
|
||||
|
||||
Définit les schémas de validation pour les requêtes et réponses API.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.item import ItemStatus
|
||||
from app.schemas.category import CategoryResponse
|
||||
from app.schemas.location import LocationResponse
|
||||
|
||||
|
||||
class ItemBase(BaseModel):
|
||||
"""Schéma de base pour les objets."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=200, description="Nom de l'objet")
|
||||
description: str | None = Field(None, description="Description détaillée")
|
||||
quantity: int = Field(default=1, ge=0, description="Quantité en stock")
|
||||
status: ItemStatus = Field(default=ItemStatus.IN_STOCK, description="Statut de l'objet")
|
||||
brand: str | None = Field(None, max_length=100, description="Marque")
|
||||
model: str | None = Field(None, max_length=100, description="Modèle")
|
||||
serial_number: str | None = Field(None, max_length=100, description="Numéro de série")
|
||||
url: str | None = Field(None, max_length=500, description="Lien vers page produit")
|
||||
price: Decimal | None = Field(None, ge=0, decimal_places=2, description="Prix d'achat")
|
||||
purchase_date: date | None = Field(None, description="Date d'achat")
|
||||
notes: str | None = Field(None, description="Notes libres")
|
||||
|
||||
|
||||
class ItemCreate(ItemBase):
|
||||
"""Schéma pour la création d'un objet."""
|
||||
|
||||
category_id: int = Field(..., description="ID de la catégorie")
|
||||
location_id: int = Field(..., description="ID de l'emplacement")
|
||||
|
||||
|
||||
class ItemUpdate(BaseModel):
|
||||
"""Schéma pour la mise à jour d'un objet (tous les champs optionnels)."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
quantity: int | None = Field(None, ge=0)
|
||||
status: ItemStatus | None = None
|
||||
brand: str | None = Field(None, max_length=100)
|
||||
model: str | None = Field(None, max_length=100)
|
||||
serial_number: str | None = Field(None, max_length=100)
|
||||
url: str | None = Field(None, max_length=500)
|
||||
price: Decimal | None = Field(None, ge=0)
|
||||
purchase_date: date | None = None
|
||||
notes: str | None = None
|
||||
category_id: int | None = None
|
||||
location_id: int | None = None
|
||||
|
||||
|
||||
class ItemResponse(ItemBase):
|
||||
"""Schéma de réponse pour un objet."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
category_id: int
|
||||
location_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ItemWithRelations(ItemResponse):
|
||||
"""Schéma de réponse avec les relations (catégorie et emplacement)."""
|
||||
|
||||
category: CategoryResponse
|
||||
location: LocationResponse
|
||||
|
||||
|
||||
class ItemSummary(BaseModel):
|
||||
"""Schéma résumé pour les listes d'objets."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
quantity: int
|
||||
status: ItemStatus
|
||||
brand: str | None
|
||||
category_id: int
|
||||
location_id: int
|
||||
|
||||
|
||||
class ItemFilter(BaseModel):
|
||||
"""Schéma pour filtrer les objets."""
|
||||
|
||||
category_id: int | None = None
|
||||
location_id: int | None = None
|
||||
status: ItemStatus | None = None
|
||||
search: str | None = Field(None, min_length=2, description="Recherche textuelle")
|
||||
min_price: Decimal | None = None
|
||||
max_price: Decimal | None = None
|
||||
70
backend/app/schemas/location.py
Normal file
70
backend/app/schemas/location.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Schémas Pydantic pour les emplacements.
|
||||
|
||||
Définit les schémas de validation pour les requêtes et réponses API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.location import LocationType
|
||||
|
||||
|
||||
class LocationBase(BaseModel):
|
||||
"""Schéma de base pour les emplacements."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Nom de l'emplacement")
|
||||
type: LocationType = Field(..., description="Type d'emplacement")
|
||||
description: str | None = Field(None, max_length=500, description="Description optionnelle")
|
||||
|
||||
|
||||
class LocationCreate(LocationBase):
|
||||
"""Schéma pour la création d'un emplacement."""
|
||||
|
||||
parent_id: int | None = Field(None, description="ID du parent (None si racine)")
|
||||
|
||||
|
||||
class LocationUpdate(BaseModel):
|
||||
"""Schéma pour la mise à jour d'un emplacement (tous les champs optionnels)."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
type: LocationType | None = None
|
||||
description: str | None = Field(None, max_length=500)
|
||||
parent_id: int | None = None
|
||||
|
||||
|
||||
class LocationResponse(LocationBase):
|
||||
"""Schéma de réponse pour un emplacement."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
parent_id: int | None
|
||||
path: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class LocationWithChildren(LocationResponse):
|
||||
"""Schéma de réponse avec les enfants."""
|
||||
|
||||
children: list["LocationWithChildren"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LocationWithItemCount(LocationResponse):
|
||||
"""Schéma de réponse avec le nombre d'objets."""
|
||||
|
||||
item_count: int = Field(default=0, description="Nombre d'objets à cet emplacement")
|
||||
|
||||
|
||||
class LocationTree(BaseModel):
|
||||
"""Schéma pour l'arborescence complète des emplacements."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
type: LocationType
|
||||
path: str
|
||||
children: list["LocationTree"] = Field(default_factory=list)
|
||||
item_count: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
142
backend/pyproject.toml
Normal file
142
backend/pyproject.toml
Normal file
@@ -0,0 +1,142 @@
|
||||
[project]
|
||||
name = "homestock-backend"
|
||||
version = "0.1.0"
|
||||
description = "HomeStock - Backend API pour gestion d'inventaire domestique"
|
||||
authors = [
|
||||
{ name = "Gilles", email = "gilles@example.com" }
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
|
||||
dependencies = [
|
||||
# FastAPI
|
||||
"fastapi>=0.109.0",
|
||||
"uvicorn[standard]>=0.27.0",
|
||||
"python-multipart>=0.0.6", # Pour upload fichiers
|
||||
|
||||
# Database
|
||||
"sqlalchemy>=2.0.25",
|
||||
"alembic>=1.13.1",
|
||||
"aiosqlite>=0.19.0", # Async SQLite
|
||||
|
||||
# Validation
|
||||
"pydantic>=2.5.3",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"email-validator>=2.1.0",
|
||||
|
||||
# Logging
|
||||
"loguru>=0.7.2",
|
||||
|
||||
# Utils
|
||||
"python-dotenv>=1.0.0",
|
||||
"httpx>=0.26.0", # Pour tests API
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
# Testing
|
||||
"pytest>=7.4.4",
|
||||
"pytest-asyncio>=0.23.3",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
|
||||
# Linting & Formatting
|
||||
"ruff>=0.1.14",
|
||||
"mypy>=1.8.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
# === Ruff Configuration ===
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (handled by formatter)
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
"C901", # too complex
|
||||
]
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
"__init__.py" = ["F401"] # unused imports in __init__
|
||||
|
||||
[tool.ruff.isort]
|
||||
known-first-party = ["app"]
|
||||
|
||||
# === MyPy Configuration ===
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_any_generics = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_no_return = true
|
||||
warn_unreachable = true
|
||||
strict_equality = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"sqlalchemy.*",
|
||||
"alembic.*",
|
||||
"loguru.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
# === Pytest Configuration ===
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "7.0"
|
||||
addopts = [
|
||||
"-ra",
|
||||
"--strict-markers",
|
||||
"--cov=app",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=html",
|
||||
]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
# === Coverage Configuration ===
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"*/__init__.py",
|
||||
"*/alembic/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
precision = 2
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if TYPE_CHECKING:",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if __name__ == .__main__.:",
|
||||
]
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
0
backend/tests/integration/__init__.py
Normal file
0
backend/tests/integration/__init__.py
Normal file
0
backend/tests/unit/__init__.py
Normal file
0
backend/tests/unit/__init__.py
Normal file
1286
backend/uv.lock
generated
Normal file
1286
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,15 +10,33 @@ Décrit les entités clés et leurs relations.
|
||||
---
|
||||
|
||||
## Entités principales
|
||||
- Entité : <A COMPLETER PAR AGENT>
|
||||
- Champs : <A COMPLETER PAR AGENT>
|
||||
- Contraintes : <A COMPLETER PAR AGENT>
|
||||
|
||||
### Item (objet d'inventaire)
|
||||
- Champs : `id` (PK), `name` (string, requis), `description` (text, optionnel), `quantity` (int, défaut 1), `price` (decimal, optionnel), `purchase_date` (date, optionnel), `status` (enum: in_stock/in_use/broken/sold, défaut in_stock), `location_id` (FK Location), `category_id` (FK Category), `created_at`, `updated_at` <!-- complété par codex -->
|
||||
- Contraintes : `name` unique par location, `quantity` >= 0, `price` >= 0, index sur `name` + `status` pour recherche <!-- complété par codex -->
|
||||
|
||||
### Location (emplacement physique hiérarchique)
|
||||
- Champs : `id` (PK), `name` (string, requis), `type` (enum: room/furniture/drawer/box), `parent_id` (FK Location, self-reference, optionnel), `path` (string, chemin complet calculé), `created_at`, `updated_at` <!-- complété par codex -->
|
||||
- Contraintes : `name` unique par `parent_id`, pas de cycle dans hiérarchie (validation applicative), index sur `path` pour recherche hiérarchique <!-- complété par codex -->
|
||||
|
||||
### Category (domaine/catégorie)
|
||||
- Champs : `id` (PK), `name` (string, requis, unique), `slug` (string, requis, unique), `description` (text, optionnel), `color` (string hex, optionnel), `icon` (string, optionnel), `created_at`, `updated_at` <!-- complété par codex -->
|
||||
- Contraintes : `name` et `slug` uniques, slug kebab-case, catégories prédéfinies (bricolage, informatique, électronique, cuisine) + possibilité ajout <!-- complété par codex -->
|
||||
|
||||
### Document (fichier attaché)
|
||||
- Champs : `id` (PK), `item_id` (FK Item, requis), `type` (enum: photo/notice/invoice/other), `filename` (string, requis), `filepath` (string, chemin relatif dans uploads/), `mime_type` (string), `size_bytes` (int), `uploaded_at` <!-- complété par codex -->
|
||||
- Contraintes : `filepath` unique, index sur `item_id`, taille max 50MB par fichier, types MIME autorisés (images, PDF) <!-- complété par codex -->
|
||||
|
||||
## Relations
|
||||
- Relation : <A COMPLETER PAR AGENT>
|
||||
- Cardinalité : <A COMPLETER PAR AGENT>
|
||||
|
||||
---
|
||||
- **Item N..1 Location** : Un item est dans une location, une location contient plusieurs items (CASCADE DELETE optionnel) <!-- complété par codex -->
|
||||
- **Item N..1 Category** : Un item appartient à une catégorie, une catégorie contient plusieurs items (RESTRICT DELETE) <!-- complété par codex -->
|
||||
- **Item 1..N Document** : Un item a plusieurs documents, un document appartient à un item (CASCADE DELETE) <!-- complété par codex -->
|
||||
- **Location N..1 Location (self)** : Hiérarchie parent/enfant (garage → étagère → boîte), `parent_id` NULL pour racine (RESTRICT DELETE pour éviter orphelins) <!-- complété par codex -->
|
||||
|
||||
## Exemple (a supprimer)
|
||||
- `User` 1..N `Order`.
|
||||
## Indexation recherche (SQLite FTS5)
|
||||
|
||||
- **fts_items** : Table virtuelle FTS5 sur `Item.name` + `Item.description` + `Category.name` + `Location.path` pour recherche full-text performante <!-- complété par codex -->
|
||||
- Synchronisation : Triggers SQLite pour maintenir FTS5 à jour lors des INSERT/UPDATE/DELETE sur Item <!-- complété par codex -->
|
||||
|
||||
---
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
@@ -0,0 +1,48 @@
|
||||
services:
|
||||
# Backend FastAPI
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: homestock-backend
|
||||
ports:
|
||||
- "${BACKEND_EXTERNAL_PORT}:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
environment:
|
||||
- APP_NAME=${APP_NAME}
|
||||
- ENVIRONMENT=${ENVIRONMENT}
|
||||
- DEBUG=${DEBUG}
|
||||
- LOG_LEVEL=${LOG_LEVEL}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS}
|
||||
- UPLOAD_DIR=${UPLOAD_DIR}
|
||||
- MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB}
|
||||
restart: unless-stopped
|
||||
command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Frontend React + Vite
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: homestock-frontend
|
||||
ports:
|
||||
- "${FRONTEND_EXTERNAL_PORT}:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
- VITE_APP_NAME=${VITE_APP_NAME}
|
||||
- VITE_APP_VERSION=${VITE_APP_VERSION}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
|
||||
volumes:
|
||||
data:
|
||||
uploads:
|
||||
|
||||
@@ -11,54 +11,49 @@ Il sert de base aux décisions techniques, aux ADR et au découpage des tâches.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vue d’ensemble
|
||||
- Objectif produit : <A REMPLIR - PROJET> (exemple: améliorer la traçabilité — a supprimer)
|
||||
- Type d’app (web, mobile, API) : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
||||
- Contraintes fortes : <A REMPLIR - PROJET> (exemple: déploiement on-premise — a supprimer)
|
||||
## 1. Vue d'ensemble
|
||||
- Objectif produit : Gérer l'inventaire complet d'un domicile avec recherche, localisation précise et archivage de documents <!-- complété par codex -->
|
||||
- Type d'app : Application web full-stack (API REST + SPA) avec déploiement conteneurisé <!-- complété par codex -->
|
||||
- Contraintes fortes : Self-hosted sur réseau local, mono-utilisateur, simplicité de déploiement et maintenance <!-- complété par codex -->
|
||||
|
||||
## 2. Principes d’architecture
|
||||
- Principes non négociables : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
||||
- Principes d’évolution : <A COMPLETER PAR AGENT>
|
||||
- Qualités prioritaires (performance, sécurité, scalabilité) : <A REMPLIR - PROJET> (exemple: sécurité et performance — a supprimer)
|
||||
## 2. Principes d'architecture
|
||||
- Principes non négociables : Monolithe modulaire, séparation stricte frontend/backend, documentation avant implémentation, ADR pour décisions structurantes <!-- complété par codex -->
|
||||
- Principes d'évolution : Architecture permettant une évolution vers multi-utilisateurs sans refonte complète, modules indépendants testables séparément <!-- complété par codex -->
|
||||
- Qualités prioritaires : Simplicité d'utilisation > Performance > Maintenabilité > Évolutivité (mono-utilisateur donc pas de scalabilité horizontale) <!-- complété par codex -->
|
||||
|
||||
## 3. Architecture logique
|
||||
- Modules principaux : <A COMPLETER PAR AGENT>
|
||||
- Responsabilités par module : <A COMPLETER PAR AGENT>
|
||||
- Frontend/Backend séparation : <A COMPLETER PAR AGENT>
|
||||
- Modules principaux : `items` (objets/équipements), `locations` (emplacements physiques), `categories` (domaines: bricolage, informatique, etc.), `documents` (photos, notices, factures), `search` (recherche full-text) <!-- complété par codex -->
|
||||
- Responsabilités par module : `items` = CRUD objets + état + prix, `locations` = hiérarchie lieux (meuble>tiroir>boîte), `categories` = classification, `documents` = upload/stockage fichiers, `search` = indexation et recherche <!-- complété par codex -->
|
||||
- Frontend/Backend séparation : SPA React (frontend) communique uniquement via API REST avec FastAPI (backend), pas de SSR (Server-Side Rendering), API documentée par OpenAPI <!-- complété par codex -->
|
||||
|
||||
## 4. Architecture technique
|
||||
- Langages & frameworks : <A COMPLETER PAR AGENT>
|
||||
- Base de données : <A COMPLETER PAR AGENT>
|
||||
- Stockage fichiers : <A COMPLETER PAR AGENT>
|
||||
- Infra cible (cloud/self-hosted) : <A REMPLIR - PROJET> (exemple: self-hosted — a supprimer)
|
||||
- Langages & frameworks : Backend = Python 3.11+ avec FastAPI + SQLAlchemy + Pydantic, Frontend = TypeScript + React 18+ + Vite + TailwindCSS <!-- complété par codex -->
|
||||
- Base de données : SQLite (fichier local homestock.db) avec FTS5 pour recherche full-text, migrations via Alembic <!-- complété par codex -->
|
||||
- Stockage fichiers : Système de fichiers local, dossier `uploads/` organisé par type (photos/, notices/, factures/), chemin relatif stocké en BDD <!-- complété par codex -->
|
||||
- Infra cible : Self-hosted via Docker Compose, réseau local 10.0.0.0/22, reverse proxy optionnel (Traefik/Nginx) <!-- complété par codex -->
|
||||
|
||||
## 5. Flux de données
|
||||
- Flux principaux (lecture/écriture) : <A COMPLETER PAR AGENT>
|
||||
- Intégrations externes : <A REMPLIR - PROJET> (exemple: ERP existant — a supprimer)
|
||||
- Gestion des événements/asynchronisme : <A COMPLETER PAR AGENT>
|
||||
- Flux principaux : Lecture = GET items avec filtres (catégorie, localisation, état) + recherche full-text, Écriture = POST/PUT/DELETE items + upload fichiers multipart/form-data <!-- complété par codex -->
|
||||
- Intégrations externes : Aucune intégration externe prévue, système autonome et déconnecté <!-- complété par codex -->
|
||||
- Gestion des événements/asynchronisme : Upload fichiers en asynchrone (FastAPI background tasks), pas d'événements temps réel pour MVP (possibilité WebSocket future) <!-- complété par codex -->
|
||||
|
||||
## 6. Sécurité
|
||||
- Authentification/autorisation : <A COMPLETER PAR AGENT>
|
||||
- Données sensibles : <A REMPLIR - PROJET> (exemple: emails + historiques de paiement — a supprimer)
|
||||
- Traçabilité/audit : <A COMPLETER PAR AGENT>
|
||||
- Authentification/autorisation : Optionnelle pour MVP mono-utilisateur, si activée = login simple (username/password) + session cookie, pas de JWT pour simplicité <!-- complété par codex -->
|
||||
- Données sensibles : Factures (montants, dates d'achat), localisations précises d'objets de valeur → chiffrement au repos optionnel, accès réseau local uniquement <!-- complété par codex -->
|
||||
- Traçabilité/audit : Pas d'audit trail strict pour MVP, timestamps created_at/updated_at sur entités principales, logs applicatifs pour debug <!-- complété par codex -->
|
||||
|
||||
## 7. Observabilité
|
||||
- Logs (journaux) : <A COMPLETER PAR AGENT>
|
||||
- Metrics (mesures) : <A COMPLETER PAR AGENT>
|
||||
- Alerting (alertes) : <A COMPLETER PAR AGENT>
|
||||
- Logs : Python logging avec rotation (loguru recommandé), niveau INFO en production, DEBUG en dev, format JSON pour parsing <!-- complété par codex -->
|
||||
- Metrics : Optionnelles pour MVP, possibilité ajout Prometheus + Grafana plus tard (nb items, espace disque uploads/, temps réponse API) <!-- complété par codex -->
|
||||
- Alerting : Pas d'alerting pour MVP mono-utilisateur, monitoring manuel via logs et health check endpoint `/health` <!-- complété par codex -->
|
||||
|
||||
## 8. Conventions de code
|
||||
- Organisation des dossiers : <A COMPLETER PAR AGENT>
|
||||
- Standards de code : <A COMPLETER PAR AGENT>
|
||||
- Tests obligatoires : <A COMPLETER PAR AGENT>
|
||||
- Organisation des dossiers : Backend = `backend/app/` (routers/, models/, schemas/, services/), Frontend = `frontend/src/` (components/, pages/, hooks/, api/) <!-- complété par codex -->
|
||||
- Standards de code : Backend = ruff format + mypy strict, Frontend = ESLint + Prettier, nommage snake_case (Python) et camelCase (TS), français pour commentaires <!-- complété par codex -->
|
||||
- Tests obligatoires : Backend = tests unitaires (services/) + tests intégration (API), Frontend = tests unitaires (utils/hooks), couverture minimum 70% sur logique métier <!-- complété par codex -->
|
||||
|
||||
## 9. Évolution & dette
|
||||
- Zones à risque : <A REMPLIR - PROJET> (exemple: montée en charge — a supprimer)
|
||||
- Améliorations prévues : <A REMPLIR - PROJET> (exemple: reporting avancé — a supprimer)
|
||||
- Zones à risque : Recherche full-text sur SQLite (limitations si >10k items), stockage fichiers local (backup manuel nécessaire), authentification simpliste <!-- complété par codex -->
|
||||
- Améliorations prévues : Migration SQLite→PostgreSQL si besoin, stockage fichiers vers MinIO/S3, multi-utilisateurs avec RBAC (contrôle d'accès par rôle), API mobile, import/export CSV <!-- complété par codex -->
|
||||
|
||||
---
|
||||
|
||||
## Exemple (a supprimer)
|
||||
- Modules : `auth`, `users`, `billing`, `catalog`.
|
||||
- DB : PostgreSQL + migrations.
|
||||
- Auth : JWT (jeton) + RBAC (contrôle d’accès par rôle).
|
||||
---
|
||||
@@ -11,24 +11,20 @@ Ce guide définit les conventions de code et de documentation.
|
||||
---
|
||||
|
||||
## 1. Nommage
|
||||
- Variables / fonctions : <A COMPLETER PAR AGENT>
|
||||
- Fichiers / dossiers : <A COMPLETER PAR AGENT>
|
||||
- API endpoints : <A COMPLETER PAR AGENT>
|
||||
- Variables / fonctions : Backend = snake_case (Python PEP8), Frontend = camelCase (TypeScript/JavaScript standard) <!-- complété par codex -->
|
||||
- Fichiers / dossiers : Backend = snake_case (item_service.py), Frontend = PascalCase pour composants (ItemCard.tsx), kebab-case pour autres (use-items.ts) <!-- complété par codex -->
|
||||
- API endpoints : REST kebab-case `/api/v1/items`, `/api/v1/item-categories`, pluriel pour collections, singulier pour ressource unique <!-- complété par codex -->
|
||||
|
||||
## 2. Formatage
|
||||
- Formatter / linter : <A COMPLETER PAR AGENT>
|
||||
- Règles principales : <A COMPLETER PAR AGENT>
|
||||
- Formatter / linter : Backend = ruff (format + lint) + mypy (types), Frontend = Prettier + ESLint + TypeScript strict <!-- complété par codex -->
|
||||
- Règles principales : Indentation 4 espaces (Python), 2 espaces (TS/JS), ligne max 100 caractères, trailing commas, quotes doubles <!-- complété par codex -->
|
||||
|
||||
## 3. Tests
|
||||
- Nommage des tests : <A COMPLETER PAR AGENT>
|
||||
- Structure des tests : <A COMPLETER PAR AGENT>
|
||||
- Nommage des tests : Backend = `test_<fonction>_<scenario>.py`, Frontend = `<module>.test.ts`, fonctions de test descriptives `test_create_item_with_valid_data` <!-- complété par codex -->
|
||||
- Structure des tests : Pattern AAA (Arrange/Act/Assert), fixtures pytest pour setup, mocks minimaux, tests isolés et reproductibles <!-- complété par codex -->
|
||||
|
||||
## 4. Documentation
|
||||
- Doc obligatoire : <A REMPLIR - PROJET> (exemple: README + ARCHITECTURE — a supprimer)
|
||||
- ADR (Architecture Decision Record) : <A COMPLETER PAR AGENT>
|
||||
- Doc obligatoire : README.md (démarrage), ARCHITECTURE.md (structure technique), fichiers CONTEXT (backend/frontend), ADR pour décisions structurantes <!-- complété par codex -->
|
||||
- ADR : Suivre template docs/adr/TEMPLATE.md, numérotation séquentielle (0001, 0002...), statut (proposé/accepté/obsolète), contexte + décision + conséquences <!-- complété par codex -->
|
||||
|
||||
---
|
||||
|
||||
## Exemple (a supprimer)
|
||||
- Formatter : `prettier` + `eslint`.
|
||||
- Tests : `feature_x.test.ts`.
|
||||
---
|
||||
@@ -12,36 +12,36 @@ Il sert de référence aux agents et aux contributeurs.
|
||||
---
|
||||
|
||||
## 1. Branches
|
||||
- Convention de nommage : <A COMPLETER PAR AGENT>
|
||||
- Branches protégées : <A REMPLIR - PROJET> (exemple: main — a supprimer)
|
||||
- Politique de merge : <A COMPLETER PAR AGENT>
|
||||
- Convention de nommage : `feature/REQ-XXX-description`, `fix/bug-description`, `docs/update-xxx`, `refactor/module-name` <!-- complété par codex -->
|
||||
- Branches protégées : `main` (production-ready) <!-- complété par codex -->
|
||||
- Politique de merge : Squash merge vers main, fast-forward interdit, historique linéaire privilégié <!-- complété par codex -->
|
||||
|
||||
## 2. Commits
|
||||
- Convention (ex: conventional commits) : <A COMPLETER PAR AGENT>
|
||||
- Granularité attendue : <A REMPLIR - PROJET> (exemple: 1 feature par PR — a supprimer)
|
||||
- Convention : Conventional Commits (feat/fix/docs/refactor/test/chore), format: `type(scope): message` en français <!-- complété par codex -->
|
||||
- Granularité attendue : 1 commit par changement logique, 1 feature complète par PR avec tests associés <!-- complété par codex -->
|
||||
|
||||
## 3. Pull Requests
|
||||
- Template PR : <A COMPLETER PAR AGENT>
|
||||
- Relectures requises : <A REMPLIR - PROJET> (exemple: 1 review — a supprimer)
|
||||
- Template PR : Titre court (<70 car), description avec ## Changements, ## Tests, ## Checklist, référence REQ-XXX <!-- complété par codex -->
|
||||
- Relectures requises : Aucune (projet solo), possibilité de review par Claude Code avant merge <!-- complété par codex -->
|
||||
- Checklist obligatoire : `docs/PR_CHECKLIST.md`
|
||||
|
||||
## 4. CI/CD
|
||||
- Pipeline minimal (lint/test/build) : <A COMPLETER PAR AGENT>
|
||||
- Vérifications bloquantes : <A COMPLETER PAR AGENT>
|
||||
- Pipeline minimal : Gitea Actions avec steps: lint (ruff, eslint) → test (pytest, vitest) → build (Docker images) → optionnel deploy <!-- complété par codex -->
|
||||
- Vérifications bloquantes : Lint sans erreur, tests unitaires passent, build Docker réussit, couverture >70% sur nouveau code <!-- complété par codex -->
|
||||
|
||||
## 5. Releases
|
||||
- Versioning (semver = versionnage sémantique) : <A COMPLETER PAR AGENT>
|
||||
- Tagging : <A COMPLETER PAR AGENT>
|
||||
- Versioning : Semver (semantic versioning) v0.x.y pour pré-release, v1.0.0 pour première version stable, tags Git annotés <!-- complété par codex -->
|
||||
- Tagging : Tag après merge dans main, format `vX.Y.Z`, signé GPG si possible <!-- complété par codex -->
|
||||
- Release notes : `product/RELEASE_NOTES.md`
|
||||
|
||||
## 6. Qualité
|
||||
- Definition of Done (définition de terminé) : <A REMPLIR - PROJET> (exemple: tests + doc — a supprimer)
|
||||
- Tests obligatoires : <A COMPLETER PAR AGENT>
|
||||
- Mises à jour doc : <A REMPLIR - PROJET> (exemple: si impact sur l’API — a supprimer)
|
||||
- Definition of Done : Feature implémentée + tests unitaires + tests intégration API + documentation mise à jour (si impact) + CI passe <!-- complété par codex -->
|
||||
- Tests obligatoires : Tests unitaires sur logique métier (services/), tests intégration sur endpoints API, couverture minimum 70% <!-- complété par codex -->
|
||||
- Mises à jour doc : Obligatoire si changement API (OpenAPI), architecture (ADR), ou contrats (data_model.md) <!-- complété par codex -->
|
||||
|
||||
## 7. Hotfix / Urgence
|
||||
- Procédure : <A COMPLETER PAR AGENT>
|
||||
- Responsables : <A REMPLIR - PROJET> (exemple: lead dev — a supprimer)
|
||||
- Procédure : Branche `hotfix/description` depuis main, fix minimal, tests rapides, merge direct main, tag patch version <!-- complété par codex -->
|
||||
- Responsables : Développeur principal (projet solo), pas de process complexe pour mono-utilisateur <!-- complété par codex -->
|
||||
|
||||
---
|
||||
|
||||
@@ -54,9 +54,4 @@ Il sert de référence aux agents et aux contributeurs.
|
||||
- Réseau local : 10.0.0.0/22
|
||||
- Passerelle : 10.0.0.1
|
||||
|
||||
---
|
||||
|
||||
## Exemple (a supprimer)
|
||||
- Branches : `main`, `develop`, `feat/*`, `fix/*`.
|
||||
- Commits : Conventional Commits.
|
||||
- CI : lint + tests + build.
|
||||
---
|
||||
192
docs/adr/0001-choix-stack-technique.md
Normal file
192
docs/adr/0001-choix-stack-technique.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# ADR-0001 — Choix de la stack technique globale
|
||||
|
||||
- Statut : accepted
|
||||
- Date : 2026-01-27
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
HomeStock est une application web self-hosted de gestion d'inventaire domestique destinée à un usage mono-utilisateur. Les contraintes principales sont :
|
||||
|
||||
- **Simplicité de déploiement** : L'application doit être facile à installer et maintenir sur un réseau local
|
||||
- **Usage mono-utilisateur** : Pas besoin de scalabilité horizontale ni de gestion complexe de concurrence
|
||||
- **Self-hosted** : Déploiement local, pas de dépendance cloud
|
||||
- **Développement rapide** : Besoin d'un MVP fonctionnel rapidement avec des technologies modernes et productives
|
||||
- **Maintenance à long terme** : Stack stable avec un bon écosystème et une documentation solide
|
||||
|
||||
Le projet nécessite :
|
||||
- Une API REST robuste pour le backend
|
||||
- Une interface utilisateur moderne et réactive
|
||||
- Une base de données adaptée au mono-utilisateur
|
||||
- Un système de stockage de fichiers (photos, notices, factures)
|
||||
|
||||
---
|
||||
|
||||
## Décision
|
||||
|
||||
Nous avons choisi la stack technique suivante :
|
||||
|
||||
### Backend
|
||||
- **Langage** : Python 3.11+
|
||||
- **Framework API** : FastAPI
|
||||
- **ORM** : SQLAlchemy 2.0+ avec Alembic pour migrations
|
||||
- **Validation** : Pydantic (intégré à FastAPI)
|
||||
|
||||
### Frontend
|
||||
- **Langage** : TypeScript
|
||||
- **Framework UI** : React 18+
|
||||
- **Bundler** : Vite 5+
|
||||
- **Gestion état** : TanStack Query (React Query) pour l'état serveur, Context API pour l'état local
|
||||
- **Styling** : TailwindCSS avec palette Gruvbox dark
|
||||
|
||||
### Base de données
|
||||
- **SGBD** : SQLite avec extension FTS5 pour recherche full-text
|
||||
- **Fichier** : `homestock.db` en local
|
||||
|
||||
### Stockage fichiers
|
||||
- **Système** : Système de fichiers local
|
||||
- **Organisation** : Dossier `uploads/` avec sous-dossiers par type (photos/, notices/, factures/)
|
||||
- **Référencement** : Chemins relatifs stockés en base de données
|
||||
|
||||
### Déploiement
|
||||
- **Conteneurisation** : Docker Compose
|
||||
- **Réseau** : Réseau local 10.0.0.0/22
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considérées
|
||||
|
||||
### Backend
|
||||
1. **Django + Django REST Framework**
|
||||
- ✅ Framework très complet avec admin intégré
|
||||
- ✅ ORM mature et puissant
|
||||
- ❌ Plus lourd et verbeux que FastAPI
|
||||
- ❌ Performance inférieure en async
|
||||
- **Rejeté** : Trop de fonctionnalités inutiles pour notre cas d'usage mono-utilisateur
|
||||
|
||||
2. **Go avec Gin/Echo**
|
||||
- ✅ Très performant, binaire standalone
|
||||
- ✅ Faible consommation mémoire
|
||||
- ❌ Écosystème moins riche pour gestion fichiers/images
|
||||
- ❌ Développement plus verbeux, moins rapide
|
||||
- **Rejeté** : Performance non critique pour mono-utilisateur, productivité Python préférée
|
||||
|
||||
3. **Node.js avec Express/Fastify**
|
||||
- ✅ Écosystème npm très riche
|
||||
- ✅ Même langage que le frontend (JavaScript/TypeScript)
|
||||
- ❌ Gestion async plus complexe qu'avec FastAPI
|
||||
- ❌ Préférence personnelle pour Python
|
||||
- **Rejeté** : Moins d'expérience, Python préféré pour backend
|
||||
|
||||
### Frontend
|
||||
1. **Vue.js 3**
|
||||
- ✅ Simple à apprendre, Composition API moderne
|
||||
- ✅ Excellente documentation
|
||||
- ❌ Écosystème légèrement moins riche que React
|
||||
- **Rejeté** : React préféré pour son écosystème et l'expérience existante
|
||||
|
||||
2. **Svelte/SvelteKit**
|
||||
- ✅ Très léger, compilation au build
|
||||
- ✅ Syntaxe simple et élégante
|
||||
- ❌ Écosystème moins mature
|
||||
- ❌ Moins de ressources et bibliothèques tierces
|
||||
- **Rejeté** : Écosystème trop jeune pour un projet à long terme
|
||||
|
||||
### Base de données
|
||||
1. **PostgreSQL**
|
||||
- ✅ Très robuste, fonctionnalités avancées
|
||||
- ✅ Meilleure recherche full-text (GIN indexes)
|
||||
- ❌ Serveur séparé à gérer, plus complexe
|
||||
- ❌ Overhead pour mono-utilisateur avec faible volume
|
||||
- **Rejeté** : Trop complexe pour un usage mono-utilisateur avec <10k items attendus
|
||||
|
||||
2. **MySQL/MariaDB**
|
||||
- ✅ Populaire, bien documenté
|
||||
- ✅ Bonnes performances
|
||||
- ❌ Recherche full-text moins performante
|
||||
- ❌ Serveur séparé à gérer
|
||||
- **Rejeté** : Même raison que PostgreSQL, SQLite suffit largement
|
||||
|
||||
### Stockage fichiers
|
||||
1. **MinIO (S3-compatible)**
|
||||
- ✅ Scalable, versioning, métadonnées riches
|
||||
- ✅ Compatible S3, facilite migration future
|
||||
- ❌ Service supplémentaire à déployer et maintenir
|
||||
- ❌ Overhead pour mono-utilisateur
|
||||
- **Rejeté** : Trop complexe pour le besoin, système fichiers local suffit
|
||||
|
||||
2. **Stockage en base (BLOB)**
|
||||
- ✅ Pas de fichiers externes à gérer
|
||||
- ✅ Transactions ACID sur fichiers
|
||||
- ❌ Performance dégradée avec fichiers volumineux
|
||||
- ❌ Backup et restauration plus complexes
|
||||
- ❌ Taille de la base de données augmente rapidement
|
||||
- **Rejeté** : Mauvaise pratique, problèmes de performance prévisibles
|
||||
|
||||
---
|
||||
|
||||
## Conséquences
|
||||
|
||||
### Positives
|
||||
1. **Simplicité de déploiement** : Un seul `docker-compose up` lance l'ensemble de la stack
|
||||
2. **Performance suffisante** : FastAPI async + SQLite suffisent largement pour mono-utilisateur
|
||||
3. **Développement rapide** : FastAPI + React offrent une excellente productivité
|
||||
4. **Pas de serveur BDD externe** : SQLite embarqué, un seul fichier à sauvegarder
|
||||
5. **Auto-documentation API** : FastAPI génère automatiquement OpenAPI/Swagger
|
||||
6. **Typage fort** : TypeScript (frontend) + Pydantic (backend) réduisent les erreurs
|
||||
7. **Écosystèmes matures** : Python et React ont d'excellentes bibliothèques tierces
|
||||
8. **Recherche performante** : FTS5 SQLite offre une recherche full-text native et rapide
|
||||
|
||||
### Négatives
|
||||
1. **Limitations SQLite** : Pas de scalabilité horizontale (non critique pour mono-utilisateur)
|
||||
2. **Recherche limitée** : FTS5 moins puissant qu'Elasticsearch (acceptable pour <10k items)
|
||||
3. **Stockage fichiers manuel** : Pas de métadonnées avancées ni versioning
|
||||
4. **Backup manuel** : Nécessite scripts de backup pour fichiers + BDD
|
||||
5. **Migration future** : Si passage multi-utilisateurs, migration vers PostgreSQL nécessaire
|
||||
|
||||
### Neutres
|
||||
1. **Deux langages** : Python (backend) + TypeScript (frontend) nécessitent deux environnements
|
||||
2. **Complexité initiale React** : Courbe d'apprentissage pour développement React moderne
|
||||
|
||||
---
|
||||
|
||||
## Impacts techniques
|
||||
|
||||
### Organisation du code
|
||||
- **Monorepo** : Backend et frontend dans le même dépôt
|
||||
- **Structure backend** : `backend/app/` avec routers/, models/, schemas/, services/, repositories/
|
||||
- **Structure frontend** : `frontend/src/` avec components/, pages/, hooks/, api/
|
||||
|
||||
### Dépendances principales
|
||||
- **Backend** : fastapi, uvicorn, sqlalchemy, alembic, pydantic, python-multipart, loguru
|
||||
- **Frontend** : react, react-router-dom, @tanstack/react-query, tailwindcss, axios
|
||||
|
||||
### Développement
|
||||
- **Tests** : pytest (backend), vitest (frontend)
|
||||
- **Lint/Format** : ruff + mypy (backend), eslint + prettier (frontend)
|
||||
- **CI/CD** : Gitea Actions avec lint → test → build → deploy
|
||||
|
||||
### Déploiement
|
||||
- **Docker** : 3 services (backend, frontend, reverse proxy optionnel)
|
||||
- **Volumes** : Persistance pour `homestock.db` et `uploads/`
|
||||
- **Ports** : Backend :8000, Frontend :5173 (dev) ou :80/:443 (prod avec reverse proxy)
|
||||
|
||||
### Évolutivité future
|
||||
- **Migration PostgreSQL** : Possible via Alembic migrations si besoin
|
||||
- **Stockage S3** : Abstraction stockage permet ajout MinIO sans refonte
|
||||
- **Multi-utilisateurs** : Architecture modulaire facilite ajout authentification/RBAC
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
Cette stack a été choisie en janvier 2026 pour un MVP mono-utilisateur. Les choix privilégient la simplicité et la rapidité de développement sur la scalabilité et les fonctionnalités avancées qui ne sont pas nécessaires pour le cas d'usage initial.
|
||||
|
||||
La stack permet une évolution progressive vers multi-utilisateurs et stockage cloud si besoin, mais ces aspects sont volontairement exclus du MVP pour réduire la complexité initiale.
|
||||
|
||||
Les alternatives (PostgreSQL, MinIO) restent envisageables pour des évolutions futures et sont documentées dans la section "Améliorations prévues" de [docs/ARCHITECTURE.md](../ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
**Contributeurs** : Gilles (décideur) + Claude Code (architecte)
|
||||
@@ -1,24 +0,0 @@
|
||||
# ADR-0001 — Exemple (a supprimer)
|
||||
|
||||
- Statut : accepted
|
||||
- Date : 2026-01-27
|
||||
|
||||
## Contexte
|
||||
- Besoin d’un framework API simple et maintenable.
|
||||
|
||||
## Décision
|
||||
- Utiliser un monolithe modulaire.
|
||||
|
||||
## Alternatives considérées
|
||||
- Microservices.
|
||||
- Serverless (exécution sans serveur dédié).
|
||||
|
||||
## Conséquences
|
||||
- Déploiement plus simple.
|
||||
- Risque de croissance du monolithe.
|
||||
|
||||
## Impacts techniques
|
||||
- Découper par domaines.
|
||||
|
||||
## Notes
|
||||
- Revoir après 6 mois.
|
||||
235
docs/adr/0002-architecture-monolithe-modulaire.md
Normal file
235
docs/adr/0002-architecture-monolithe-modulaire.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# ADR-0002 — Architecture monolithe modulaire
|
||||
|
||||
- Statut : accepted
|
||||
- Date : 2026-01-27
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
HomeStock nécessite une architecture logicielle adaptée à un projet mono-utilisateur self-hosted avec les contraintes suivantes :
|
||||
|
||||
- **Simplicité de déploiement** : L'application doit pouvoir être déployée facilement avec une seule commande
|
||||
- **Maintenance réduite** : Pas d'équipe DevOps, maintenance manuelle par l'utilisateur
|
||||
- **Développement solo** : Un seul développeur, besoin de cohérence et de simplicité
|
||||
- **Évolutivité future** : Possibilité d'évoluer vers multi-utilisateurs sans refonte complète
|
||||
- **Modules logiques distincts** : items, locations, categories, documents, search
|
||||
|
||||
Le projet démarre avec un périmètre limité (MVP) mais doit pouvoir évoluer progressivement. La question architecturale centrale est : comment organiser le code pour maximiser la maintenabilité tout en gardant une complexité opérationnelle minimale ?
|
||||
|
||||
---
|
||||
|
||||
## Décision
|
||||
|
||||
Nous adoptons une **architecture monolithe modulaire** avec les caractéristiques suivantes :
|
||||
|
||||
### Définition
|
||||
- **Un seul dépôt Git** (monorepo) contenant backend et frontend
|
||||
- **Un seul processus backend** (serveur FastAPI unique)
|
||||
- **Modules logiques séparés** avec responsabilités clairement définies
|
||||
- **Communication intra-application** via appels de fonctions directs
|
||||
- **Base de données unique** partagée entre tous les modules
|
||||
|
||||
### Organisation backend (`backend/app/`)
|
||||
```
|
||||
backend/app/
|
||||
├── main.py # Point d'entrée FastAPI
|
||||
├── core/ # Configuration, logging, database
|
||||
│ ├── config.py
|
||||
│ ├── database.py
|
||||
│ └── logging.py
|
||||
├── models/ # Modèles SQLAlchemy (un fichier par entité)
|
||||
│ ├── item.py
|
||||
│ ├── location.py
|
||||
│ ├── category.py
|
||||
│ └── document.py
|
||||
├── schemas/ # Schémas Pydantic (validation API)
|
||||
│ ├── item.py
|
||||
│ ├── location.py
|
||||
│ └── ...
|
||||
├── routers/ # Endpoints API (un fichier par ressource)
|
||||
│ ├── items.py
|
||||
│ ├── locations.py
|
||||
│ ├── categories.py
|
||||
│ ├── documents.py
|
||||
│ └── search.py
|
||||
├── services/ # Logique métier (orchestration)
|
||||
│ ├── item_service.py
|
||||
│ ├── location_service.py
|
||||
│ └── ...
|
||||
├── repositories/ # Accès données (abstraction BDD)
|
||||
│ ├── item_repository.py
|
||||
│ ├── location_repository.py
|
||||
│ └── ...
|
||||
└── utils/ # Utilitaires transverses
|
||||
├── files.py
|
||||
└── search.py
|
||||
```
|
||||
|
||||
### Organisation frontend (`frontend/src/`)
|
||||
```
|
||||
frontend/src/
|
||||
├── main.tsx # Point d'entrée React
|
||||
├── App.tsx # Composant racine + routing
|
||||
├── components/ # Composants réutilisables
|
||||
│ ├── common/ # Boutons, inputs, modales...
|
||||
│ ├── items/ # Composants spécifiques items
|
||||
│ └── locations/ # Composants spécifiques locations
|
||||
├── pages/ # Pages complètes (routes)
|
||||
│ ├── Dashboard.tsx
|
||||
│ ├── ItemList.tsx
|
||||
│ ├── ItemDetail.tsx
|
||||
│ └── ...
|
||||
├── hooks/ # Custom hooks React
|
||||
│ ├── useItems.ts
|
||||
│ ├── useLocations.ts
|
||||
│ └── ...
|
||||
├── api/ # Client API (fetch/axios)
|
||||
│ ├── items.ts
|
||||
│ ├── locations.ts
|
||||
│ └── ...
|
||||
└── utils/ # Utilitaires helpers
|
||||
└── formatting.ts
|
||||
```
|
||||
|
||||
### Principes d'organisation
|
||||
1. **Séparation en couches backend** : routers → services → repositories → models
|
||||
2. **Dépendances unidirectionnelles** : Les couches supérieures dépendent des inférieures uniquement
|
||||
3. **Modules auto-contenus** : Chaque module (items, locations, etc.) a ses propres fichiers dans chaque couche
|
||||
4. **Pas de dépendances circulaires** entre modules
|
||||
5. **Code partagé** dans `core/` et `utils/`
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considérées
|
||||
|
||||
### 1. Microservices
|
||||
**Description** : Séparer l'application en plusieurs services indépendants (service items, service locations, service search, etc.)
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Scalabilité horizontale indépendante par service
|
||||
- ✅ Isolation des pannes (un service down ≠ tout down)
|
||||
- ✅ Possibilité d'utiliser des technologies différentes par service
|
||||
- ✅ Déploiement indépendant de chaque service
|
||||
|
||||
**Inconvénients** :
|
||||
- ❌ Complexité opérationnelle énorme (orchestration, networking, monitoring)
|
||||
- ❌ Nécessite API Gateway, service discovery, circuit breakers
|
||||
- ❌ Communication réseau entre services (latence, sérialisations multiples)
|
||||
- ❌ Transactions distribuées complexes (saga pattern)
|
||||
- ❌ Debugging et traçabilité difficiles
|
||||
- ❌ Overhead infrastructure (plusieurs conteneurs, load balancers, etc.)
|
||||
|
||||
**Verdict** : ❌ **Rejeté** - Complexité totalement disproportionnée pour un projet mono-utilisateur avec ~1 req/seconde max. Les bénéfices de scalabilité ne s'appliquent pas au cas d'usage.
|
||||
|
||||
### 2. Monolithe non structuré (big ball of mud)
|
||||
**Description** : Tout le code dans quelques gros fichiers sans organisation claire
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Démarrage très rapide
|
||||
- ✅ Pas de contraintes architecturales
|
||||
|
||||
**Inconvénients** :
|
||||
- ❌ Maintenance cauchemardesque après quelques mois
|
||||
- ❌ Code fortement couplé, difficile à tester
|
||||
- ❌ Évolution impossible sans refonte complète
|
||||
- ❌ Onboarding difficile pour contributeurs futurs
|
||||
|
||||
**Verdict** : ❌ **Rejeté** - Dette technique garantie, inacceptable même pour un projet solo
|
||||
|
||||
### 3. Architecture hexagonale (ports & adapters)
|
||||
**Description** : Isolation stricte du domaine métier avec ports (interfaces) et adapters (implémentations)
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Isolation forte du domaine métier
|
||||
- ✅ Testabilité maximale (mocks faciles)
|
||||
- ✅ Changement de base de données transparent
|
||||
|
||||
**Inconvénients** :
|
||||
- ❌ Boilerplate important (interfaces, adapters, DTOs multiples)
|
||||
- ❌ Courbe d'apprentissage élevée
|
||||
- ❌ Overhead pour un projet simple comme HomeStock
|
||||
|
||||
**Verdict** : ⚠️ **Rejeté mais inspirant** - Trop complexe pour notre besoin, mais on garde l'idée de séparation en couches (repository pattern)
|
||||
|
||||
### 4. Monolithe avec modules faiblement couplés (notre choix)
|
||||
**Description** : Un seul déploiement avec modules logiques bien séparés et communication via interfaces claires
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Simplicité opérationnelle (un seul processus, un seul conteneur)
|
||||
- ✅ Transactions ACID simples (même base de données)
|
||||
- ✅ Pas de latence réseau entre modules
|
||||
- ✅ Refactoring et debugging faciles
|
||||
- ✅ Évolution progressive possible (extraction en microservices si vraiment nécessaire)
|
||||
- ✅ Maintenance réduite
|
||||
|
||||
**Inconvénients** :
|
||||
- ⚠️ Scalabilité horizontale limitée (non critique pour mono-utilisateur)
|
||||
- ⚠️ Restart complet nécessaire pour tout changement (acceptable)
|
||||
|
||||
**Verdict** : ✅ **Choisi** - Meilleur équilibre complexité/bénéfices pour notre contexte
|
||||
|
||||
---
|
||||
|
||||
## Conséquences
|
||||
|
||||
### Positives
|
||||
1. **Déploiement simple** : `docker-compose up` lance tout le système
|
||||
2. **Transactions ACID** : Toutes les opérations en base profitent des transactions SQLite
|
||||
3. **Performance** : Pas de latence réseau entre modules, appels de fonctions directs
|
||||
4. **Debugging facile** : Stack traces complètes, un seul processus à inspecter
|
||||
5. **Refactoring sans risque** : IDE et linters détectent les erreurs lors de renommages
|
||||
6. **Tests simples** : Tests d'intégration faciles (toute l'app dans le même process)
|
||||
7. **Maintenance réduite** : Un seul service à monitorer, logs centralisés naturellement
|
||||
|
||||
### Négatives
|
||||
1. **Scalabilité limitée** : Impossible de scaler un module indépendamment (non pertinent pour mono-utilisateur)
|
||||
2. **Couplage temporel** : Si un module plante, tout plante (acceptable avec bonne gestion erreurs)
|
||||
3. **Restart complet** : Modification = restart de toute l'app (dev avec hot-reload, prod peu de déploiements)
|
||||
|
||||
### Mitigations
|
||||
- **Pour éviter le couplage fort** : Respecter strictement les couches (routers → services → repositories)
|
||||
- **Pour faciliter évolution future** : Interfaces claires entre modules, pas de dépendances circulaires
|
||||
- **Pour améliorer résilience** : Gestion d'erreurs robuste, retry logic, circuit breaker patterns si nécessaire
|
||||
|
||||
---
|
||||
|
||||
## Impacts techniques
|
||||
|
||||
### Développement
|
||||
- **Pattern 3-layer** : Chaque feature touche 3 fichiers minimum (router, service, repository)
|
||||
- **Imports Python** : Imports absolus depuis `app.` pour éviter imports relatifs fragiles
|
||||
- **Tests** : Tests unitaires par couche + tests d'intégration API end-to-end
|
||||
|
||||
### Déploiement
|
||||
- **Docker** : Un seul conteneur backend (+ conteneur frontend séparé pour SPA)
|
||||
- **Scaling** : Scale vertical uniquement (augmenter CPU/RAM du conteneur)
|
||||
- **Rollback** : Simple (redéploiement image Docker précédente)
|
||||
|
||||
### Évolution future
|
||||
Si le projet nécessite vraiment des microservices (multi-utilisateurs massif, équipes séparées) :
|
||||
1. **Extraction progressive** : Modules déjà bien séparés, extraction facilitée
|
||||
2. **API interne → API externe** : Appels de fonctions deviennent appels HTTP
|
||||
3. **Base partagée → bases séparées** : Migrations Alembic facilitent séparation BDD
|
||||
|
||||
Cette architecture n'empêche pas l'évolution, elle la retarde jusqu'à ce qu'elle soit vraiment nécessaire (principe YAGNI = You Ain't Gonna Need It).
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
Cette décision s'inscrit dans la philosophie "Start with a monolith" popularisée par Martin Fowler et confirmée par de nombreux retours d'expérience (Shopify, GitHub, Stack Overflow ont démarré en monolithe).
|
||||
|
||||
Pour HomeStock, projet mono-utilisateur avec ~1 req/sec max en pic, un monolithe modulaire est largement suffisant et restera probablement optimal même à long terme.
|
||||
|
||||
La complexité des microservices n'apporterait aucun bénéfice tangible et augmenterait drastiquement les coûts de développement et maintenance.
|
||||
|
||||
**Principe appliqué** : "Choose boring technology" - privilégier les architectures éprouvées et simples sur les architectures à la mode mais complexes.
|
||||
|
||||
---
|
||||
|
||||
**Contributeurs** : Gilles (décideur) + Claude Code (architecte)
|
||||
|
||||
**Références** :
|
||||
- Martin Fowler - "Monolith First" : https://martinfowler.com/bliki/MonolithFirst.html
|
||||
- Sam Newman - "Building Microservices" (recommande de démarrer en monolithe)
|
||||
259
docs/adr/0003-recherche-full-text-sqlite-fts5.md
Normal file
259
docs/adr/0003-recherche-full-text-sqlite-fts5.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# ADR-0003 — Recherche full-text avec SQLite FTS5
|
||||
|
||||
- Statut : accepted
|
||||
- Date : 2026-01-27
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
HomeStock nécessite une fonctionnalité de recherche rapide et efficace pour retrouver des objets dans l'inventaire. Les utilisateurs doivent pouvoir rechercher par :
|
||||
|
||||
- Nom de l'objet (ex: "perceuse")
|
||||
- Description (ex: "outil électrique sans fil")
|
||||
- Catégorie (ex: "bricolage")
|
||||
- Localisation (ex: "garage", "étagère A")
|
||||
|
||||
**Contraintes identifiées** :
|
||||
- **Volume de données** : ~1000-5000 items attendus initialement, maximum 10000 items à long terme
|
||||
- **Performance** : Résultats de recherche en <200ms souhaités
|
||||
- **Simplicité** : Pas de serveur externe supplémentaire à gérer
|
||||
- **Pertinence** : Recherche "fuzzy" pas nécessaire, recherche exacte par mots-clés suffit
|
||||
- **Langue** : Recherche en français uniquement
|
||||
|
||||
Le choix de la solution de recherche impacte directement l'expérience utilisateur (fonctionnalité critique) et l'architecture technique (composant additionnel ou intégré).
|
||||
|
||||
---
|
||||
|
||||
## Décision
|
||||
|
||||
Nous utilisons **SQLite FTS5 (Full-Text Search 5)** pour la recherche full-text avec les caractéristiques suivantes :
|
||||
|
||||
### Configuration
|
||||
- **Table virtuelle FTS5** : `fts_items` contenant les champs indexés
|
||||
- **Champs indexés** : `Item.name`, `Item.description`, `Category.name`, `Location.path`
|
||||
- **Tokenizer** : `unicode61` (support Unicode, casse insensible)
|
||||
- **Synchronisation** : Triggers SQLite pour maintenir FTS5 à jour lors des INSERT/UPDATE/DELETE
|
||||
|
||||
### Exemple de structure
|
||||
```sql
|
||||
CREATE VIRTUAL TABLE fts_items USING fts5(
|
||||
item_id UNINDEXED,
|
||||
name,
|
||||
description,
|
||||
category_name,
|
||||
location_path,
|
||||
tokenize = 'unicode61'
|
||||
);
|
||||
|
||||
-- Triggers pour synchronisation automatique
|
||||
CREATE TRIGGER items_after_insert AFTER INSERT ON items BEGIN
|
||||
INSERT INTO fts_items(item_id, name, description, category_name, location_path)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.name,
|
||||
NEW.description,
|
||||
(SELECT name FROM categories WHERE id = NEW.category_id),
|
||||
(SELECT path FROM locations WHERE id = NEW.location_id)
|
||||
);
|
||||
END;
|
||||
|
||||
-- Triggers similaires pour UPDATE et DELETE
|
||||
```
|
||||
|
||||
### Requêtes de recherche
|
||||
```sql
|
||||
-- Recherche simple
|
||||
SELECT items.* FROM items
|
||||
JOIN fts_items ON items.id = fts_items.item_id
|
||||
WHERE fts_items MATCH 'perceuse';
|
||||
|
||||
-- Recherche avec boost (priorité sur le nom)
|
||||
SELECT items.* FROM items
|
||||
JOIN fts_items ON items.id = fts_items.item_id
|
||||
WHERE fts_items MATCH '{name}: perceuse OR {description}: perceuse'
|
||||
ORDER BY rank;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considérées
|
||||
|
||||
### 1. Elasticsearch
|
||||
**Description** : Moteur de recherche distribué dédié, standard de l'industrie
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Recherche extrêmement performante et flexible
|
||||
- ✅ Recherche fuzzy, phonétique, synonymes
|
||||
- ✅ Agrégations et analytics avancées
|
||||
- ✅ Scalable horizontalement
|
||||
|
||||
**Inconvénients** :
|
||||
- ❌ Serveur Java lourd (1-2GB RAM minimum)
|
||||
- ❌ Complexité opérationnelle importante
|
||||
- ❌ Nécessite synchronisation BDD → Elasticsearch
|
||||
- ❌ Overhead massif pour 1000-10000 items
|
||||
- ❌ Configuration complexe pour français
|
||||
|
||||
**Verdict** : ❌ **Rejeté** - Tuer une mouche avec un bazooka. Elasticsearch est conçu pour des millions de documents et des cas d'usage complexes (facettes, agrégations, etc.) dont HomeStock n'a pas besoin.
|
||||
|
||||
### 2. MeiliSearch
|
||||
**Description** : Moteur de recherche moderne, léger, orienté UX
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Très rapide et pertinent out-of-the-box
|
||||
- ✅ API REST simple
|
||||
- ✅ Typo-tolerance native
|
||||
- ✅ Plus léger qu'Elasticsearch (~50MB RAM)
|
||||
- ✅ Configuration minimale
|
||||
|
||||
**Inconvénients** :
|
||||
- ❌ Service externe supplémentaire à déployer
|
||||
- ❌ Synchronisation BDD → MeiliSearch nécessaire
|
||||
- ❌ Overhead pour petit volume de données
|
||||
- ❌ Dépendance additionnelle à maintenir
|
||||
|
||||
**Verdict** : ⚠️ **Rejeté mais intéressant** - Excellente solution technique mais ajoute de la complexité pour un bénéfice limité sur <10k items. À considérer si passage à >50k items.
|
||||
|
||||
### 3. PostgreSQL Full-Text Search (pg_trgm + GIN)
|
||||
**Description** : Recherche full-text native de PostgreSQL
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Très performant avec index GIN
|
||||
- ✅ Recherche trigram (similitude)
|
||||
- ✅ Support langues naturelles (stemming français)
|
||||
- ✅ Pas de service externe
|
||||
|
||||
**Inconvénients** :
|
||||
- ❌ Nécessite PostgreSQL (vs SQLite choisi dans ADR-0001)
|
||||
- ❌ Serveur BDD à gérer
|
||||
- ❌ Complexité setup (dictionnaires français, configuration)
|
||||
|
||||
**Verdict** : ❌ **Rejeté** - Excellente solution technique mais nécessite PostgreSQL. Si migration vers PostgreSQL (cas multi-utilisateurs), reconsidérer.
|
||||
|
||||
### 4. LIKE '%keyword%' en SQL
|
||||
**Description** : Recherche basique avec opérateur LIKE
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Extrêmement simple
|
||||
- ✅ Aucune dépendance
|
||||
|
||||
**Inconvénients** :
|
||||
- ❌ Performance catastrophique (full table scan)
|
||||
- ❌ Pas de pertinence/ranking
|
||||
- ❌ Impossible de rechercher sur plusieurs champs efficacement
|
||||
- ❌ Pas d'opérateurs (AND, OR, NOT)
|
||||
|
||||
**Verdict** : ❌ **Rejeté** - Inacceptable même pour 1000 items, expérience utilisateur dégradée
|
||||
|
||||
### 5. SQLite FTS5 (notre choix)
|
||||
**Description** : Module de recherche full-text intégré à SQLite
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Intégré à SQLite, pas de service externe
|
||||
- ✅ Performance excellente pour <100k documents
|
||||
- ✅ Support opérateurs (AND, OR, NOT, NEAR, phrases)
|
||||
- ✅ Ranking par pertinence (BM25)
|
||||
- ✅ Triggers pour synchronisation automatique
|
||||
- ✅ Tokenizer Unicode (support français)
|
||||
- ✅ Footprint mémoire minimal
|
||||
|
||||
**Inconvénients** :
|
||||
- ⚠️ Pas de recherche fuzzy native (typos)
|
||||
- ⚠️ Pas de stemming français parfait
|
||||
- ⚠️ Limitations si >100k documents (acceptable pour notre cas)
|
||||
|
||||
**Verdict** : ✅ **Choisi** - Solution optimale pour notre contexte : performance suffisante, simplicité maximale, pas de dépendance externe
|
||||
|
||||
---
|
||||
|
||||
## Conséquences
|
||||
|
||||
### Positives
|
||||
1. **Simplicité architecture** : Pas de service externe, tout dans SQLite
|
||||
2. **Performance suffisante** : FTS5 recherche <50ms sur 10k items
|
||||
3. **Synchronisation automatique** : Triggers maintiennent l'index à jour
|
||||
4. **Zéro configuration** : FTS5 inclus dans SQLite depuis version 3.9.0 (2015)
|
||||
5. **Backup simplifié** : Index FTS5 dans le même fichier .db que les données
|
||||
6. **Opérateurs avancés** : Support AND, OR, NOT, recherche par phrase
|
||||
7. **Ranking pertinent** : Algorithme BM25 pour trier par pertinence
|
||||
|
||||
### Négatives
|
||||
1. **Pas de typo-tolerance** : "perceuze" ne trouvera pas "perceuse"
|
||||
2. **Stemming limité** : "perçant" et "percer" non reliés automatiquement
|
||||
3. **Performance dégradée >100k items** : Limite théorique (très au-delà de notre besoin)
|
||||
4. **Pas d'agrégations** : Impossible de faire des facettes de recherche complexes
|
||||
5. **Recherche française limitée** : Pas de dictionnaire français sophistiqué
|
||||
|
||||
### Mitigations
|
||||
- **Typo-tolerance** : Implémenter suggestions de correction côté application si besoin (Levenshtein distance)
|
||||
- **Stemming** : Ajouter mots-clés/tags aux items pour améliorer découvrabilité
|
||||
- **Performance** : Si >100k items, migrer vers PostgreSQL pg_trgm ou MeiliSearch
|
||||
- **Agrégations** : Implémenter filtres côté application (par catégorie, localisation, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Impacts techniques
|
||||
|
||||
### Développement
|
||||
- **Migration Alembic** : Créer table virtuelle FTS5 + triggers lors du setup initial
|
||||
- **Service de recherche** : `search_service.py` encapsule la logique FTS5
|
||||
- **API endpoint** : `GET /api/v1/search?q=keyword` retourne items matchant
|
||||
|
||||
### Performance attendue
|
||||
- **<1000 items** : <10ms
|
||||
- **1000-5000 items** : <50ms
|
||||
- **5000-10000 items** : <150ms
|
||||
- **>10000 items** : Dégrédation possible, monitoring nécessaire
|
||||
|
||||
### Structure de code
|
||||
```python
|
||||
# services/search_service.py
|
||||
class SearchService:
|
||||
async def search_items(self, query: str, limit: int = 50):
|
||||
"""Recherche full-text avec FTS5"""
|
||||
# Sanitize query (échapper caractères spéciaux)
|
||||
safe_query = self._escape_fts5_query(query)
|
||||
|
||||
# Query FTS5 avec ranking
|
||||
sql = """
|
||||
SELECT items.*,
|
||||
rank AS relevance
|
||||
FROM items
|
||||
JOIN fts_items ON items.id = fts_items.item_id
|
||||
WHERE fts_items MATCH :query
|
||||
ORDER BY rank
|
||||
LIMIT :limit
|
||||
"""
|
||||
|
||||
return await self.db.execute(sql, {"query": safe_query, "limit": limit})
|
||||
```
|
||||
|
||||
### Évolution future
|
||||
Si les limitations deviennent bloquantes :
|
||||
1. **Migration PostgreSQL** : Activer pg_trgm pour recherche similitude
|
||||
2. **Ajout MeiliSearch** : Service séparé pour recherche avancée (garder SQLite pour données)
|
||||
3. **Hybrid search** : Combiner FTS5 (rapide) + recherche sémantique externe (typos, synonymes)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
SQLite FTS5 est utilisé par de nombreux projets à succès pour des cas d'usage similaires :
|
||||
- **Firefox** : Recherche dans l'historique et favoris
|
||||
- **Apple Mail** : Indexation locale des emails
|
||||
- **VS Code** : Recherche dans l'historique de commandes
|
||||
|
||||
Pour HomeStock avec <10k items attendus, FTS5 est largement suffisant et offre le meilleur ratio performance/simplicité.
|
||||
|
||||
La décision de ne pas utiliser Elasticsearch ou MeiliSearch n'est pas dogmatique : si le projet évolue vers >50k items ou nécessite recherche fuzzy sophistiquée, migration possible sans refonte majeure (API de recherche reste identique, seule l'implémentation change).
|
||||
|
||||
**Principe appliqué** : "Use the simplest tool that works" - Ne pas sur-architecturer pour des besoins hypothétiques futurs.
|
||||
|
||||
---
|
||||
|
||||
**Contributeurs** : Gilles (décideur) + Claude Code (architecte)
|
||||
|
||||
**Références** :
|
||||
- SQLite FTS5 documentation : https://www.sqlite.org/fts5.html
|
||||
- BM25 ranking algorithm : https://en.wikipedia.org/wiki/Okapi_BM25
|
||||
226
docs/adr/0004-pas-authentification-reseau-local.md
Normal file
226
docs/adr/0004-pas-authentification-reseau-local.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# ADR-0004 — Pas d'authentification (réseau local uniquement)
|
||||
|
||||
- Statut : accepted
|
||||
- Date : 2026-01-27
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
HomeStock est une application web self-hosted destinée à un usage mono-utilisateur sur un réseau local domestique. La question de la sécurité d'accès se pose :
|
||||
|
||||
**Contraintes du projet** :
|
||||
- **Déploiement** : Réseau local 10.0.0.0/22, pas d'exposition Internet prévue
|
||||
- **Utilisateurs** : Mono-utilisateur (propriétaire du domicile)
|
||||
- **Données** : Inventaire personnel, factures (non sensibles au sens RGPD)
|
||||
- **Accès physique** : Réseau domestique, appareils de confiance uniquement
|
||||
|
||||
**Questions à résoudre** :
|
||||
1. Faut-il implémenter un système d'authentification (login/password) ?
|
||||
2. Si oui, quel niveau de sécurité est nécessaire ?
|
||||
3. Quels sont les risques réels d'un accès non authentifié sur réseau local ?
|
||||
|
||||
L'authentification ajoute de la complexité (gestion sessions, hash passwords, UI login) et peut dégrader l'expérience utilisateur (saisie répétée de credentials) pour un bénéfice de sécurité potentiellement faible dans ce contexte.
|
||||
|
||||
---
|
||||
|
||||
## Décision
|
||||
|
||||
**Nous ne déployons PAS de système d'authentification pour le MVP.** L'application est accessible librement sur le réseau local sans login ni password.
|
||||
|
||||
### Justification
|
||||
1. **Périmètre réseau contrôlé** : Réseau domestique 10.0.0.0/22, tous les appareils sont de confiance
|
||||
2. **Pas d'exposition Internet** : Application non accessible depuis l'extérieur, pas de port forwarding
|
||||
3. **Mono-utilisateur** : Pas de besoin de gestion de sessions ou de rôles
|
||||
4. **Simplicité UX** : Accès immédiat à l'application, pas de friction login
|
||||
5. **Données non critiques** : Inventaire domestique et factures, pas de données bancaires ou médicales
|
||||
|
||||
### Périmètre de sécurité
|
||||
- ✅ **Réseau local uniquement** : Application bind sur IP privée, pas 0.0.0.0
|
||||
- ✅ **HTTPS optionnel** : TLS pour chiffrement en transit si certificat local disponible
|
||||
- ✅ **Firewall** : Ports fermés en dehors du réseau local (configuration routeur)
|
||||
- ❌ **Pas de login/password** : Accès direct à l'application
|
||||
- ❌ **Pas de sessions** : Pas de gestion de tokens ou cookies d'authentification
|
||||
- ❌ **Pas de RBAC** : Pas de rôles ou permissions (mono-utilisateur)
|
||||
|
||||
### Configuration réseau recommandée
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
ports:
|
||||
- "10.0.0.X:8000:8000" # Bind IP privée uniquement, pas 0.0.0.0:8000
|
||||
environment:
|
||||
- ALLOWED_HOSTS=10.0.0.0/22 # Whitelist réseau local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considérées
|
||||
|
||||
### 1. Authentification basique (login/password)
|
||||
**Description** : Page de connexion avec username/password, session cookie
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Protection contre accès non autorisé sur réseau local
|
||||
- ✅ Traçabilité des accès (logs)
|
||||
- ✅ Possibilité multi-utilisateurs future facilitée
|
||||
|
||||
**Inconvénients** :
|
||||
- ❌ Complexité additionnelle (hash passwords, sessions, CSRF, refresh tokens)
|
||||
- ❌ UX dégradée (saisie credentials à chaque accès)
|
||||
- ❌ Gestion mot de passe oublié nécessaire
|
||||
- ❌ Sécurité illusoire si appareil compromis (cookies volés)
|
||||
|
||||
**Verdict** : ❌ **Rejeté pour MVP** - Bénéfice limité sur réseau local de confiance, complexité injustifiée
|
||||
|
||||
### 2. Authentification par IP (whitelist)
|
||||
**Description** : Autoriser uniquement certaines IPs du réseau local
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Simple à implémenter (middleware FastAPI)
|
||||
- ✅ Pas d'UI login
|
||||
|
||||
**Inconvénients** :
|
||||
- ❌ Gestion fastidieuse des IPs autorisées
|
||||
- ❌ DHCP complique le suivi (IPs dynamiques)
|
||||
- ❌ Pas de granularité utilisateur
|
||||
|
||||
**Verdict** : ❌ **Rejeté** - Complexité sans bénéfice majeur, réseau déjà isolé
|
||||
|
||||
### 3. Authentification par certificat client (mTLS)
|
||||
**Description** : Certificats X.509 pour authentifier les appareils
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Sécurité forte
|
||||
- ✅ Pas de password à mémoriser
|
||||
|
||||
**Inconvénients** :
|
||||
- ❌ Complexité extrême (PKI, distribution certificats)
|
||||
- ❌ Configuration browser complexe
|
||||
- ❌ Totalement surdimensionné pour le cas d'usage
|
||||
|
||||
**Verdict** : ❌ **Rejeté** - Overkill absolu
|
||||
|
||||
### 4. Pas d'authentification (notre choix)
|
||||
**Description** : Accès libre sur réseau local
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Simplicité maximale (pas de code auth)
|
||||
- ✅ UX optimale (accès instantané)
|
||||
- ✅ Maintenance réduite
|
||||
- ✅ Adapté au contexte mono-utilisateur réseau local
|
||||
|
||||
**Inconvénients** :
|
||||
- ⚠️ Accès libre pour tout appareil sur réseau local
|
||||
- ⚠️ Pas de traçabilité utilisateur
|
||||
- ⚠️ Si exposition Internet accidentelle = vulnérabilité
|
||||
|
||||
**Verdict** : ✅ **Choisi** - Solution adaptée au contexte avec mitigations appropriées
|
||||
|
||||
---
|
||||
|
||||
## Conséquences
|
||||
|
||||
### Positives
|
||||
1. **Simplicité code** : Pas de code d'authentification, sessions, CSRF protection
|
||||
2. **UX fluide** : Accès immédiat, pas de friction login
|
||||
3. **Pas de gestion passwords** : Pas de hash, reset password, rotation
|
||||
4. **Maintenance réduite** : Pas de vulnérabilités auth à monitorer (OWASP Top 10)
|
||||
5. **Performance** : Pas de vérification session sur chaque requête
|
||||
|
||||
### Négatives et risques
|
||||
1. **Exposition accidentelle Internet** : Si port forwarding activé par erreur → accès public
|
||||
2. **Appareil compromis sur réseau** : Malware sur laptop/smartphone pourrait accéder à l'app
|
||||
3. **Visiteurs réseau** : Invités connectés au WiFi domestique peuvent accéder à l'app
|
||||
4. **Pas de traçabilité** : Impossible de savoir qui a modifié/supprimé des données
|
||||
|
||||
### Mitigations implémentées
|
||||
1. **Bind IP privée uniquement** : Backend écoute sur 10.0.0.X:8000, pas 0.0.0.0
|
||||
2. **Firewall routeur** : Ports 8000/5173 fermés en WAN, ouverts LAN uniquement
|
||||
3. **HTTPS optionnel** : Certificat auto-signé pour chiffrement en transit
|
||||
4. **Logging applicatif** : Logs des actions (créer/modifier/supprimer items) avec IP source
|
||||
5. **Backup régulier** : Scripts de backup pour récupération en cas d'actions malveillantes
|
||||
6. **Documentation claire** : Avertissement dans README sur importance isolation réseau
|
||||
|
||||
### Mitigations à envisager (post-MVP)
|
||||
1. **Mode "invité" simple** : Code PIN basique si besoin de partager l'accès temporairement
|
||||
2. **Alerte exposition** : Check au startup si l'app est accessible depuis Internet (API externe)
|
||||
3. **Read-only mode** : Mode lecture seule pour appareils moins fiables
|
||||
|
||||
---
|
||||
|
||||
## Impacts techniques
|
||||
|
||||
### Code simplifié
|
||||
Pas de code d'authentification signifie :
|
||||
- Pas de modèle `User` en base
|
||||
- Pas de hash passwords (bcrypt, argon2)
|
||||
- Pas de gestion sessions/tokens
|
||||
- Pas de middleware CSRF protection
|
||||
- Pas d'endpoints `/login`, `/logout`, `/register`
|
||||
- Pas d'UI login/signup
|
||||
|
||||
### Configuration Docker
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
ports:
|
||||
- "10.0.0.50:8000:8000" # IP privée fixe uniquement
|
||||
environment:
|
||||
- ALLOWED_ORIGINS=http://10.0.0.50:5173,http://localhost:5173
|
||||
- CORS_ALLOW_CREDENTIALS=false # Pas de cookies
|
||||
```
|
||||
|
||||
### Logging renforcé
|
||||
Même sans auth, logger les actions pour traçabilité :
|
||||
```python
|
||||
# Exemple log
|
||||
logger.info(
|
||||
"Item created",
|
||||
extra={
|
||||
"item_id": item.id,
|
||||
"item_name": item.name,
|
||||
"source_ip": request.client.host,
|
||||
"user_agent": request.headers.get("user-agent")
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Évolution future
|
||||
Si exposition Internet devient nécessaire (accès depuis extérieur) :
|
||||
1. **Ajouter authentification** : Login simple (username/password) + session cookie
|
||||
2. **Reverse proxy avec auth** : Traefik BasicAuth ou OAuth2 Proxy
|
||||
3. **VPN** : Accès via WireGuard/Tailscale (recommandé)
|
||||
4. **Cloudflare Access** : Zero-trust avec auth externe
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
Cette décision est **contextuelle** et adaptée à HomeStock :
|
||||
- ✅ Réseau local domestique contrôlé
|
||||
- ✅ Mono-utilisateur
|
||||
- ✅ Données non critiques
|
||||
|
||||
Elle serait **inappropriée** pour :
|
||||
- ❌ Application exposée sur Internet
|
||||
- ❌ Multi-utilisateurs
|
||||
- ❌ Données sensibles (bancaires, médicales, secrets)
|
||||
- ❌ Environnement professionnel
|
||||
|
||||
**Analogie** : C'est comme ne pas mettre de serrure sur la porte de sa chambre dans sa propre maison (réseau local) vs mettre une serrure sur la porte d'entrée (exposition Internet).
|
||||
|
||||
Si le contexte change (exposition Internet, ajout utilisateurs), cette décision devra être revisitée et l'authentification ajoutée. L'architecture modulaire (ADR-0002) facilite cet ajout futur sans refonte majeure.
|
||||
|
||||
**Principe appliqué** : "Security proportionate to risk" - Ne pas sur-sécuriser quand le risque est faible et contrôlé.
|
||||
|
||||
---
|
||||
|
||||
**Contributeurs** : Gilles (décideur) + Claude Code (architecte)
|
||||
|
||||
**Avertissement important** : Cette configuration est sécurisée UNIQUEMENT si le réseau local est isolé d'Internet. Vérifier régulièrement que :
|
||||
1. Aucun port forwarding n'est configuré sur le routeur
|
||||
2. Aucun service de tunnel (ngrok, etc.) n'est actif
|
||||
3. Le firewall du routeur est correctement configuré
|
||||
@@ -12,34 +12,29 @@ Tout ce qui est indiqué ici est la référence pour les agents frontend.
|
||||
---
|
||||
|
||||
## Objectif du frontend
|
||||
- Parcours utilisateur principaux : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
||||
- Responsabilités principales : <A COMPLETER PAR AGENT>
|
||||
- Hors périmètre : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
||||
- Parcours utilisateur principaux : Lister/rechercher items, consulter détails item avec documents, ajouter/éditer item avec upload fichiers, naviguer hiérarchie locations <!-- complété par codex -->
|
||||
- Responsabilités principales : Interface utilisateur responsive, formulaires validation, gestion état local/serveur, upload fichiers, recherche temps réel, affichage photos/documents <!-- complété par codex -->
|
||||
- Hors périmètre : Logique métier (déléguée au backend), stockage local complexe (cache géré par React Query), authentification (gérée par backend) <!-- complété par codex -->
|
||||
|
||||
## Interfaces
|
||||
- API consommées (API = Interface de Programmation) : <A COMPLETER PAR AGENT>
|
||||
- Authentification/autorisation : <A COMPLETER PAR AGENT>
|
||||
- Intégrations externes : <A REMPLIR - PROJET> (exemple: ERP existant — a supprimer)
|
||||
- API consommées : Backend REST à `/api/v1/` (items, locations, categories, documents, search), client généré depuis OpenAPI ou fetch/axios manuel <!-- complété par codex -->
|
||||
- Authentification/autorisation : Optionnelle, si activée = gestion session cookie automatique par navigateur, redirection login si 401 <!-- complété par codex -->
|
||||
- Intégrations externes : Aucune intégration externe, consomme uniquement le backend HomeStock <!-- complété par codex -->
|
||||
|
||||
## Architecture UI
|
||||
- Framework : <A COMPLETER PAR AGENT>
|
||||
- Structure des pages : <A COMPLETER PAR AGENT>
|
||||
- Gestion d’état : <A COMPLETER PAR AGENT>
|
||||
- Design system / UI kit (bibliothèque de composants) : <A COMPLETER PAR AGENT>
|
||||
- Framework : React 18+ avec TypeScript, Vite comme bundler et dev server <!-- complété par codex -->
|
||||
- Structure des pages : `/` dashboard, `/items` liste items, `/items/:id` détail item, `/items/new` création, `/locations` gestion locations, routing avec React Router v6 <!-- complété par codex -->
|
||||
- Gestion d'état : TanStack Query (React Query) pour état serveur + cache, Context API pour état UI global (theme, navigation), useState/useReducer pour état local <!-- complété par codex -->
|
||||
- Design system / UI kit : TailwindCSS pour styling, composants custom inspirés de shadcn/ui (pas de dépendance lourde), palette Gruvbox dark <!-- complété par codex -->
|
||||
|
||||
## Qualité & accessibilité
|
||||
- Performance attendue : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
||||
- Accessibilité (a11y = accessibilité web) : <A COMPLETER PAR AGENT>
|
||||
- Tests (unitaires/E2E = tests de bout en bout) : <A COMPLETER PAR AGENT>
|
||||
- Performance attendue : FCP (First Contentful Paint) <1.5s, TTI (Time To Interactive) <3s sur réseau local, bundle JS <500KB gzippé <!-- complété par codex -->
|
||||
- Accessibilité : Accessibilité de base (ARIA labels, navigation clavier, contraste WCAG AA), pas de certification stricte WCAG AAA (usage personnel) <!-- complété par codex -->
|
||||
- Tests : Vitest pour tests unitaires (hooks, utils), Playwright optionnel pour tests E2E critiques (création item, upload fichier), pas de couverture exhaustive <!-- complété par codex -->
|
||||
|
||||
## Conventions
|
||||
- Organisation du code : <A COMPLETER PAR AGENT>
|
||||
- Nommage : <A COMPLETER PAR AGENT>
|
||||
- Gestion erreurs : <A COMPLETER PAR AGENT>
|
||||
- Organisation du code : `frontend/src/` racine, sous-dossiers components/ (composants réutilisables), pages/ (vues complètes), hooks/ (custom hooks), api/ (clients API), utils/ (helpers) <!-- complété par codex -->
|
||||
- Nommage : PascalCase pour composants/fichiers React, camelCase pour variables/fonctions, préfixes use pour hooks custom, suffixes Page pour pages complètes <!-- complété par codex -->
|
||||
- Gestion erreurs : Error boundaries React pour erreurs render, gestion erreurs API via React Query (onError callbacks), toast notifications pour erreurs utilisateur, fallback UI gracieux <!-- complété par codex -->
|
||||
|
||||
---
|
||||
|
||||
## Exemple (a supprimer)
|
||||
- Framework : React + Vite.
|
||||
- Pages : `dashboard`, `settings`, `billing`.
|
||||
- État : Zustand + React Query.
|
||||
---
|
||||
@@ -0,0 +1,23 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Image de base Node.js 20
|
||||
FROM node:20-alpine
|
||||
|
||||
# Répertoire de travail
|
||||
WORKDIR /app
|
||||
|
||||
# Copier les fichiers de dépendances
|
||||
COPY package*.json ./
|
||||
|
||||
# Installer les dépendances
|
||||
RUN npm install
|
||||
|
||||
# Copier le code source
|
||||
COPY . .
|
||||
|
||||
# Exposer le port Vite (dev server)
|
||||
EXPOSE 5173
|
||||
|
||||
# Commande par défaut (peut être overridée par docker-compose)
|
||||
# Note: --host 0.0.0.0 permet l'accès depuis l'extérieur du conteneur
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="HomeStock - Gestion d'inventaire domestique" />
|
||||
<title>HomeStock - Inventaire Domestique</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
58
frontend/package.json
Normal file
58
frontend/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "homestock-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "HomeStock - Frontend pour gestion d'inventaire domestique",
|
||||
"author": "Gilles",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:fix": "eslint . --ext ts,tsx --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"@tanstack/react-query": "^5.17.19",
|
||||
"@tanstack/react-query-devtools": "^5.17.19",
|
||||
"axios": "^1.6.5",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"react-icons": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.33",
|
||||
"prettier": "^3.2.4",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11",
|
||||
"vitest": "^1.2.1",
|
||||
"@vitest/ui": "^1.2.1",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/jest-dom": "^6.2.0",
|
||||
"@testing-library/user-event": "^14.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
588
frontend/src/App.tsx
Normal file
588
frontend/src/App.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
import { useState } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
|
||||
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from '@/hooks/useCategories'
|
||||
import { useLocationTree, useCreateLocation, useUpdateLocation, useDeleteLocation } from '@/hooks/useLocations'
|
||||
import { useItems, useCreateItem, useUpdateItem, useDeleteItem } from '@/hooks/useItems'
|
||||
import { ItemList, ItemForm } from '@/components/items'
|
||||
import { CategoryForm } from '@/components/categories'
|
||||
import { LocationForm } from '@/components/locations'
|
||||
import {
|
||||
Loading,
|
||||
ErrorMessage,
|
||||
ConfirmDialog,
|
||||
IconAdd,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconHome,
|
||||
IconInventory,
|
||||
IconCategory,
|
||||
IconLocation,
|
||||
IconRoom,
|
||||
IconFurniture,
|
||||
IconDrawer,
|
||||
IconBox,
|
||||
} from '@/components/common'
|
||||
import {
|
||||
LOCATION_TYPE_LABELS,
|
||||
LocationTree,
|
||||
Category,
|
||||
CategoryWithItemCount,
|
||||
Location,
|
||||
Item,
|
||||
LocationType,
|
||||
} from '@/api'
|
||||
|
||||
// Mapping des icônes par type d'emplacement
|
||||
const LOCATION_TYPE_ICONS: Record<LocationType, React.ReactNode> = {
|
||||
room: <IconRoom className="w-5 h-5" />,
|
||||
furniture: <IconFurniture className="w-5 h-5" />,
|
||||
drawer: <IconDrawer className="w-5 h-5" />,
|
||||
box: <IconBox className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo et titre */}
|
||||
<Link to="/" className="flex items-center">
|
||||
<h1 className="text-2xl font-bold text-primary-600">
|
||||
HomeStock
|
||||
</h1>
|
||||
<span className="ml-3 text-sm text-gray-500">
|
||||
Inventaire Domestique
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex space-x-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<IconHome className="w-5 h-5" />
|
||||
Accueil
|
||||
</Link>
|
||||
<Link
|
||||
to="/items"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<IconInventory className="w-5 h-5" />
|
||||
Objets
|
||||
</Link>
|
||||
<Link
|
||||
to="/locations"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<IconLocation className="w-5 h-5" />
|
||||
Emplacements
|
||||
</Link>
|
||||
<Link
|
||||
to="/categories"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<IconCategory className="w-5 h-5" />
|
||||
Catégories
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Contenu principal */}
|
||||
<main className="container-main">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/items" element={<ItemsPage />} />
|
||||
<Route path="/locations" element={<LocationsPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-gray-200 mt-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
HomeStock v{import.meta.env.VITE_APP_VERSION || '0.1.0'} -
|
||||
Gestion d'inventaire domestique
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
// === Page d'accueil avec statistiques ===
|
||||
function HomePage() {
|
||||
const { data: categoriesData, isLoading: loadingCategories } = useCategories(1, 100)
|
||||
const { data: itemsData, isLoading: loadingItems } = useItems(1, 1)
|
||||
const { data: locationsData, isLoading: loadingLocations } = useLocationTree()
|
||||
|
||||
const isLoading = loadingCategories || loadingItems || loadingLocations
|
||||
|
||||
// Compter les emplacements
|
||||
const countLocations = (tree: LocationTree[]): number => {
|
||||
return tree.reduce((acc, loc) => acc + 1 + countLocations(loc.children), 0)
|
||||
}
|
||||
|
||||
const stats = {
|
||||
items: itemsData?.total || 0,
|
||||
categories: categoriesData?.total || 0,
|
||||
locations: locationsData ? countLocations(locationsData) : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-center py-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Bienvenue sur HomeStock
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600">
|
||||
Gérez votre inventaire domestique facilement
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Statistiques */}
|
||||
{isLoading ? (
|
||||
<Loading message="Chargement des statistiques..." size="sm" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
<Link to="/items" className="card card-hover text-center">
|
||||
<div className="text-4xl font-bold text-primary-600">{stats.items}</div>
|
||||
<div className="text-gray-600 mt-2">Objets</div>
|
||||
</Link>
|
||||
<Link to="/categories" className="card card-hover text-center">
|
||||
<div className="text-4xl font-bold text-secondary-600">{stats.categories}</div>
|
||||
<div className="text-gray-600 mt-2">Catégories</div>
|
||||
</Link>
|
||||
<Link to="/locations" className="card card-hover text-center">
|
||||
<div className="text-4xl font-bold text-gray-700">{stats.locations}</div>
|
||||
<div className="text-gray-600 mt-2">Emplacements</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Catégories */}
|
||||
{categoriesData && categoriesData.items.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">Catégories</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categoriesData.items.map((cat) => (
|
||||
<span
|
||||
key={cat.id}
|
||||
className="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: cat.color ? `${cat.color}20` : '#f3f4f6',
|
||||
color: cat.color || '#374151',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full mr-2"
|
||||
style={{ backgroundColor: cat.color || '#6b7280' }}
|
||||
/>
|
||||
{cat.name}
|
||||
<span className="ml-2 text-xs opacity-70">({cat.item_count})</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// === Page des objets ===
|
||||
function ItemsPage() {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<Item | null>(null)
|
||||
const [deletingItem, setDeletingItem] = useState<Item | null>(null)
|
||||
|
||||
const { data: categoriesData } = useCategories(1, 100)
|
||||
const { data: locationsData } = useLocationTree()
|
||||
|
||||
const createItem = useCreateItem()
|
||||
const updateItem = useUpdateItem()
|
||||
const deleteItem = useDeleteItem()
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingItem(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEdit = (item: Item) => {
|
||||
setEditingItem(item)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
if (editingItem) {
|
||||
await updateItem.mutateAsync({ id: editingItem.id, data })
|
||||
} else {
|
||||
await createItem.mutateAsync(data)
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingItem(null)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deletingItem) {
|
||||
await deleteItem.mutateAsync(deletingItem.id)
|
||||
setDeletingItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Mes Objets</h2>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<IconAdd className="w-5 h-5" />
|
||||
Nouvel objet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ItemList
|
||||
onItemClick={(id) => {
|
||||
// TODO: ouvrir le détail de l'objet
|
||||
console.log('Item clicked:', id)
|
||||
}}
|
||||
onItemEdit={handleEdit}
|
||||
onItemDelete={setDeletingItem}
|
||||
/>
|
||||
|
||||
{/* Formulaire création/édition */}
|
||||
<ItemForm
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false)
|
||||
setEditingItem(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
item={editingItem}
|
||||
categories={categoriesData?.items || []}
|
||||
locations={locationsData || []}
|
||||
isLoading={createItem.isPending || updateItem.isPending}
|
||||
/>
|
||||
|
||||
{/* Confirmation suppression */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletingItem}
|
||||
onClose={() => setDeletingItem(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Supprimer l'objet"
|
||||
message={`Êtes-vous sûr de vouloir supprimer "${deletingItem?.name}" ? Cette action est irréversible.`}
|
||||
confirmText="Supprimer"
|
||||
isLoading={deleteItem.isPending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// === Page des emplacements ===
|
||||
function LocationsPage() {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingLocation, setEditingLocation] = useState<Location | null>(null)
|
||||
const [deletingLocation, setDeletingLocation] = useState<LocationTree | null>(null)
|
||||
const [defaultParentId, setDefaultParentId] = useState<number | null>(null)
|
||||
|
||||
const { data, isLoading, error, refetch } = useLocationTree()
|
||||
const createLocation = useCreateLocation()
|
||||
const updateLocation = useUpdateLocation()
|
||||
const deleteLocation = useDeleteLocation()
|
||||
|
||||
const handleCreate = (parentId: number | null = null) => {
|
||||
setEditingLocation(null)
|
||||
setDefaultParentId(parentId)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEdit = (location: LocationTree) => {
|
||||
// Convertir LocationTree en Location pour l'édition
|
||||
setEditingLocation({
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
type: location.type,
|
||||
path: location.path,
|
||||
parent_id: null, // On ne peut pas extraire ça de LocationTree
|
||||
description: null,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
})
|
||||
setDefaultParentId(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async (formData: any) => {
|
||||
if (editingLocation) {
|
||||
await updateLocation.mutateAsync({ id: editingLocation.id, data: formData })
|
||||
} else {
|
||||
await createLocation.mutateAsync(formData)
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingLocation(null)
|
||||
setDefaultParentId(null)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deletingLocation) {
|
||||
await deleteLocation.mutateAsync(deletingLocation.id)
|
||||
setDeletingLocation(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <Loading message="Chargement des emplacements..." />
|
||||
if (error) return <ErrorMessage message="Erreur lors du chargement" onRetry={refetch} />
|
||||
|
||||
const renderTree = (locations: LocationTree[], level = 0) => {
|
||||
return locations.map((loc) => (
|
||||
<div key={loc.id} style={{ marginLeft: level * 24 }}>
|
||||
<div className="flex items-center py-2 px-3 hover:bg-gray-50 rounded-lg group">
|
||||
<span className="text-gray-400 mr-2">
|
||||
{LOCATION_TYPE_ICONS[loc.type]}
|
||||
</span>
|
||||
<span className="font-medium text-gray-900">{loc.name}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
({LOCATION_TYPE_LABELS[loc.type]})
|
||||
</span>
|
||||
{loc.item_count > 0 && (
|
||||
<span className="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
{loc.item_count} objet(s)
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="ml-auto opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<button
|
||||
onClick={() => handleCreate(loc.id)}
|
||||
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
|
||||
title="Ajouter un sous-emplacement"
|
||||
>
|
||||
<IconAdd className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(loc)}
|
||||
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
|
||||
title="Modifier"
|
||||
>
|
||||
<IconEdit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingLocation(loc)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="Supprimer"
|
||||
>
|
||||
<IconDelete className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loc.children.length > 0 && renderTree(loc.children, level + 1)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Mes Emplacements</h2>
|
||||
<button onClick={() => handleCreate()} className="btn btn-primary flex items-center gap-2">
|
||||
<IconAdd className="w-5 h-5" />
|
||||
Nouvel emplacement
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data && data.length > 0 ? (
|
||||
<div className="card">{renderTree(data)}</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<p className="text-gray-600 text-center py-8">
|
||||
Aucun emplacement créé. Commencez par créer une pièce.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulaire création/édition */}
|
||||
<LocationForm
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false)
|
||||
setEditingLocation(null)
|
||||
setDefaultParentId(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
location={editingLocation}
|
||||
locations={data || []}
|
||||
defaultParentId={defaultParentId}
|
||||
isLoading={createLocation.isPending || updateLocation.isPending}
|
||||
/>
|
||||
|
||||
{/* Confirmation suppression */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletingLocation}
|
||||
onClose={() => setDeletingLocation(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Supprimer l'emplacement"
|
||||
message={`Êtes-vous sûr de vouloir supprimer "${deletingLocation?.name}" ? ${
|
||||
deletingLocation && deletingLocation.children.length > 0
|
||||
? 'Attention: cet emplacement contient des sous-emplacements qui seront aussi supprimés.'
|
||||
: ''
|
||||
}`}
|
||||
confirmText="Supprimer"
|
||||
isLoading={deleteLocation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// === Page des catégories ===
|
||||
function CategoriesPage() {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingCategory, setEditingCategory] = useState<CategoryWithItemCount | null>(null)
|
||||
const [deletingCategory, setDeletingCategory] = useState<CategoryWithItemCount | null>(null)
|
||||
|
||||
const { data, isLoading, error, refetch } = useCategories(1, 100)
|
||||
const createCategory = useCreateCategory()
|
||||
const updateCategory = useUpdateCategory()
|
||||
const deleteCategory = useDeleteCategory()
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingCategory(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEdit = (category: CategoryWithItemCount) => {
|
||||
setEditingCategory(category)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async (formData: any) => {
|
||||
if (editingCategory) {
|
||||
await updateCategory.mutateAsync({ id: editingCategory.id, data: formData })
|
||||
} else {
|
||||
await createCategory.mutateAsync(formData)
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingCategory(null)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deletingCategory) {
|
||||
await deleteCategory.mutateAsync(deletingCategory.id)
|
||||
setDeletingCategory(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <Loading message="Chargement des catégories..." />
|
||||
if (error) return <ErrorMessage message="Erreur lors du chargement" onRetry={refetch} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Mes Catégories</h2>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<IconAdd className="w-5 h-5" />
|
||||
Nouvelle catégorie
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data && data.items.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.items.map((category) => (
|
||||
<div key={category.id} className="card group">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full mr-3"
|
||||
style={{ backgroundColor: category.color || '#6b7280' }}
|
||||
/>
|
||||
<h3 className="font-semibold text-gray-900">{category.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<button
|
||||
onClick={() => handleEdit(category)}
|
||||
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
|
||||
title="Modifier"
|
||||
>
|
||||
<IconEdit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingCategory(category)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="Supprimer"
|
||||
disabled={category.item_count > 0}
|
||||
>
|
||||
<IconDelete className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{category.description && (
|
||||
<p className="text-sm text-gray-600 mb-3">{category.description}</p>
|
||||
)}
|
||||
<div className="text-sm text-gray-500">
|
||||
{category.item_count} objet(s)
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<p className="text-gray-600 text-center py-8">
|
||||
Aucune catégorie créée. Commencez par en créer une.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulaire création/édition */}
|
||||
<CategoryForm
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false)
|
||||
setEditingCategory(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
category={editingCategory}
|
||||
isLoading={createCategory.isPending || updateCategory.isPending}
|
||||
/>
|
||||
|
||||
{/* Confirmation suppression */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletingCategory}
|
||||
onClose={() => setDeletingCategory(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Supprimer la catégorie"
|
||||
message={
|
||||
deletingCategory?.item_count && deletingCategory.item_count > 0
|
||||
? `Impossible de supprimer "${deletingCategory.name}" car elle contient ${deletingCategory.item_count} objet(s). Déplacez ou supprimez d'abord ces objets.`
|
||||
: `Êtes-vous sûr de vouloir supprimer "${deletingCategory?.name}" ?`
|
||||
}
|
||||
confirmText="Supprimer"
|
||||
isLoading={deleteCategory.isPending}
|
||||
variant={deletingCategory?.item_count && deletingCategory.item_count > 0 ? 'warning' : 'danger'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// === Page 404 ===
|
||||
function NotFoundPage() {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">404</h2>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Page non trouvée
|
||||
</p>
|
||||
<Link to="/" className="btn btn-primary">
|
||||
Retour à l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
50
frontend/src/api/categories.ts
Normal file
50
frontend/src/api/categories.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* API pour les catégories
|
||||
*/
|
||||
|
||||
import { apiClient, PaginatedResponse, SuccessResponse } from './client'
|
||||
import { Category, CategoryCreate, CategoryUpdate, CategoryWithItemCount } from './types'
|
||||
|
||||
export const categoriesApi = {
|
||||
/**
|
||||
* Liste toutes les catégories
|
||||
*/
|
||||
async getAll(page = 1, pageSize = 20): Promise<PaginatedResponse<CategoryWithItemCount>> {
|
||||
const response = await apiClient.get<PaginatedResponse<CategoryWithItemCount>>('/categories', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère une catégorie par son ID
|
||||
*/
|
||||
async getById(id: number): Promise<CategoryWithItemCount> {
|
||||
const response = await apiClient.get<CategoryWithItemCount>(`/categories/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Crée une nouvelle catégorie
|
||||
*/
|
||||
async create(data: CategoryCreate): Promise<Category> {
|
||||
const response = await apiClient.post<Category>('/categories', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Met à jour une catégorie
|
||||
*/
|
||||
async update(id: number, data: CategoryUpdate): Promise<Category> {
|
||||
const response = await apiClient.put<Category>(`/categories/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime une catégorie
|
||||
*/
|
||||
async delete(id: number): Promise<SuccessResponse> {
|
||||
const response = await apiClient.delete<SuccessResponse>(`/categories/${id}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
41
frontend/src/api/client.ts
Normal file
41
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Client API Axios configuré pour HomeStock
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
// URL de base de l'API
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1'
|
||||
|
||||
// Instance Axios configurée
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 10000, // 10 secondes
|
||||
})
|
||||
|
||||
// Intercepteur pour logger les erreurs
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.error('API Error:', error.response?.data || error.message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Types de réponse paginée
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
// Types communs
|
||||
export interface SuccessResponse {
|
||||
message: string
|
||||
id?: number
|
||||
}
|
||||
9
frontend/src/api/index.ts
Normal file
9
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Point d'entrée pour toutes les APIs
|
||||
*/
|
||||
|
||||
export * from './client'
|
||||
export * from './types'
|
||||
export { categoriesApi } from './categories'
|
||||
export { locationsApi } from './locations'
|
||||
export { itemsApi } from './items'
|
||||
83
frontend/src/api/items.ts
Normal file
83
frontend/src/api/items.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* API pour les objets d'inventaire
|
||||
*/
|
||||
|
||||
import { apiClient, PaginatedResponse, SuccessResponse } from './client'
|
||||
import { Item, ItemCreate, ItemFilter, ItemStatus, ItemUpdate, ItemWithRelations } from './types'
|
||||
|
||||
export const itemsApi = {
|
||||
/**
|
||||
* Liste tous les objets avec filtres
|
||||
*/
|
||||
async getAll(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
filters?: ItemFilter
|
||||
): Promise<PaginatedResponse<ItemWithRelations>> {
|
||||
const response = await apiClient.get<PaginatedResponse<ItemWithRelations>>('/items', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: filters?.search,
|
||||
category_id: filters?.category_id,
|
||||
location_id: filters?.location_id,
|
||||
status: filters?.status,
|
||||
min_price: filters?.min_price,
|
||||
max_price: filters?.max_price,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère un objet par son ID
|
||||
*/
|
||||
async getById(id: number): Promise<ItemWithRelations> {
|
||||
const response = await apiClient.get<ItemWithRelations>(`/items/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Crée un nouvel objet
|
||||
*/
|
||||
async create(data: ItemCreate): Promise<Item> {
|
||||
const response = await apiClient.post<Item>('/items', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Met à jour un objet
|
||||
*/
|
||||
async update(id: number, data: ItemUpdate): Promise<Item> {
|
||||
const response = await apiClient.put<Item>(`/items/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime un objet
|
||||
*/
|
||||
async delete(id: number): Promise<SuccessResponse> {
|
||||
const response = await apiClient.delete<SuccessResponse>(`/items/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Change le statut d'un objet
|
||||
*/
|
||||
async updateStatus(id: number, status: ItemStatus): Promise<Item> {
|
||||
const response = await apiClient.patch<Item>(`/items/${id}/status`, null, {
|
||||
params: { new_status: status },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Déplace un objet vers un nouvel emplacement
|
||||
*/
|
||||
async move(id: number, locationId: number): Promise<Item> {
|
||||
const response = await apiClient.patch<Item>(`/items/${id}/location`, null, {
|
||||
params: { new_location_id: locationId },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
91
frontend/src/api/locations.ts
Normal file
91
frontend/src/api/locations.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* API pour les emplacements
|
||||
*/
|
||||
|
||||
import { apiClient, PaginatedResponse, SuccessResponse } from './client'
|
||||
import {
|
||||
Location,
|
||||
LocationCreate,
|
||||
LocationTree,
|
||||
LocationType,
|
||||
LocationUpdate,
|
||||
LocationWithItemCount,
|
||||
} from './types'
|
||||
|
||||
export const locationsApi = {
|
||||
/**
|
||||
* Liste tous les emplacements
|
||||
*/
|
||||
async getAll(
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
parentId?: number,
|
||||
type?: LocationType
|
||||
): Promise<PaginatedResponse<Location>> {
|
||||
const response = await apiClient.get<PaginatedResponse<Location>>('/locations', {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
parent_id: parentId,
|
||||
type,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère l'arborescence complète
|
||||
*/
|
||||
async getTree(): Promise<LocationTree[]> {
|
||||
const response = await apiClient.get<LocationTree[]>('/locations/tree')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère les emplacements racine
|
||||
*/
|
||||
async getRoots(): Promise<Location[]> {
|
||||
const response = await apiClient.get<Location[]>('/locations/roots')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère un emplacement par son ID
|
||||
*/
|
||||
async getById(id: number): Promise<LocationWithItemCount> {
|
||||
const response = await apiClient.get<LocationWithItemCount>(`/locations/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère les enfants d'un emplacement
|
||||
*/
|
||||
async getChildren(id: number): Promise<Location[]> {
|
||||
const response = await apiClient.get<Location[]>(`/locations/${id}/children`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Crée un nouvel emplacement
|
||||
*/
|
||||
async create(data: LocationCreate): Promise<Location> {
|
||||
const response = await apiClient.post<Location>('/locations', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Met à jour un emplacement
|
||||
*/
|
||||
async update(id: number, data: LocationUpdate): Promise<Location> {
|
||||
const response = await apiClient.put<Location>(`/locations/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime un emplacement
|
||||
*/
|
||||
async delete(id: number): Promise<SuccessResponse> {
|
||||
const response = await apiClient.delete<SuccessResponse>(`/locations/${id}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
165
frontend/src/api/types.ts
Normal file
165
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Types TypeScript pour les entités HomeStock
|
||||
*/
|
||||
|
||||
// === Catégories ===
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
color: string | null
|
||||
icon: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CategoryWithItemCount extends Category {
|
||||
item_count: number
|
||||
}
|
||||
|
||||
export interface CategoryCreate {
|
||||
name: string
|
||||
description?: string | null
|
||||
color?: string | null
|
||||
icon?: string | null
|
||||
}
|
||||
|
||||
export interface CategoryUpdate {
|
||||
name?: string
|
||||
description?: string | null
|
||||
color?: string | null
|
||||
icon?: string | null
|
||||
}
|
||||
|
||||
// === Emplacements ===
|
||||
export type LocationType = 'room' | 'furniture' | 'drawer' | 'box'
|
||||
|
||||
export interface Location {
|
||||
id: number
|
||||
name: string
|
||||
type: LocationType
|
||||
parent_id: number | null
|
||||
path: string
|
||||
description: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface LocationWithItemCount extends Location {
|
||||
item_count: number
|
||||
}
|
||||
|
||||
export interface LocationTree {
|
||||
id: number
|
||||
name: string
|
||||
type: LocationType
|
||||
path: string
|
||||
children: LocationTree[]
|
||||
item_count: number
|
||||
}
|
||||
|
||||
export interface LocationCreate {
|
||||
name: string
|
||||
type: LocationType
|
||||
parent_id?: number | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface LocationUpdate {
|
||||
name?: string
|
||||
type?: LocationType
|
||||
parent_id?: number | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
// === Objets ===
|
||||
export type ItemStatus = 'in_stock' | 'in_use' | 'broken' | 'sold' | 'lent'
|
||||
|
||||
export interface Item {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
quantity: number
|
||||
status: ItemStatus
|
||||
brand: string | null
|
||||
model: string | null
|
||||
serial_number: string | null
|
||||
url: string | null
|
||||
price: string | null
|
||||
purchase_date: string | null
|
||||
notes: string | null
|
||||
category_id: number
|
||||
location_id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ItemWithRelations extends Item {
|
||||
category: Category
|
||||
location: Location
|
||||
}
|
||||
|
||||
export interface ItemCreate {
|
||||
name: string
|
||||
description?: string | null
|
||||
quantity?: number
|
||||
status?: ItemStatus
|
||||
brand?: string | null
|
||||
model?: string | null
|
||||
serial_number?: string | null
|
||||
url?: string | null
|
||||
price?: number | null
|
||||
purchase_date?: string | null
|
||||
notes?: string | null
|
||||
category_id: number
|
||||
location_id: number
|
||||
}
|
||||
|
||||
export interface ItemUpdate {
|
||||
name?: string
|
||||
description?: string | null
|
||||
quantity?: number
|
||||
status?: ItemStatus
|
||||
brand?: string | null
|
||||
model?: string | null
|
||||
serial_number?: string | null
|
||||
url?: string | null
|
||||
price?: number | null
|
||||
purchase_date?: string | null
|
||||
notes?: string | null
|
||||
category_id?: number
|
||||
location_id?: number
|
||||
}
|
||||
|
||||
export interface ItemFilter {
|
||||
search?: string
|
||||
category_id?: number
|
||||
location_id?: number
|
||||
status?: ItemStatus
|
||||
min_price?: number
|
||||
max_price?: number
|
||||
}
|
||||
|
||||
// === Labels pour l'affichage ===
|
||||
export const LOCATION_TYPE_LABELS: Record<LocationType, string> = {
|
||||
room: 'Pièce',
|
||||
furniture: 'Meuble',
|
||||
drawer: 'Tiroir',
|
||||
box: 'Boîte',
|
||||
}
|
||||
|
||||
export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = {
|
||||
in_stock: 'En stock',
|
||||
in_use: 'En utilisation',
|
||||
broken: 'Cassé',
|
||||
sold: 'Vendu',
|
||||
lent: 'Prêté',
|
||||
}
|
||||
|
||||
export const ITEM_STATUS_COLORS: Record<ItemStatus, string> = {
|
||||
in_stock: 'bg-green-100 text-green-800',
|
||||
in_use: 'bg-blue-100 text-blue-800',
|
||||
broken: 'bg-red-100 text-red-800',
|
||||
sold: 'bg-gray-100 text-gray-800',
|
||||
lent: 'bg-yellow-100 text-yellow-800',
|
||||
}
|
||||
179
frontend/src/components/categories/CategoryForm.tsx
Normal file
179
frontend/src/components/categories/CategoryForm.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Formulaire de création/édition de catégorie
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Category, CategoryCreate, CategoryUpdate } from '@/api/types'
|
||||
import { Modal } from '@/components/common'
|
||||
|
||||
interface CategoryFormProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: CategoryCreate | CategoryUpdate) => void
|
||||
category?: Category | null
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
'#22c55e', // green
|
||||
'#14b8a6', // teal
|
||||
'#3b82f6', // blue
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
'#6b7280', // gray
|
||||
]
|
||||
|
||||
export function CategoryForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
category,
|
||||
isLoading = false,
|
||||
}: CategoryFormProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [color, setColor] = useState('#3b82f6')
|
||||
|
||||
const isEditing = !!category
|
||||
|
||||
// Remplir le formulaire si édition
|
||||
useEffect(() => {
|
||||
if (category) {
|
||||
setName(category.name)
|
||||
setDescription(category.description || '')
|
||||
setColor(category.color || '#3b82f6')
|
||||
} else {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setColor('#3b82f6')
|
||||
}
|
||||
}, [category, isOpen])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
color: color || null,
|
||||
}
|
||||
|
||||
onSubmit(data)
|
||||
}
|
||||
|
||||
const isValid = name.trim().length > 0
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? 'Modifier la catégorie' : 'Nouvelle catégorie'}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Nom */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nom <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Ex: Électronique, Bricolage..."
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="input min-h-[80px]"
|
||||
placeholder="Description optionnelle..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Couleur */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Couleur
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-transform hover:scale-110 ${
|
||||
color === c ? 'border-gray-900 scale-110' : 'border-transparent'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-8 h-8 rounded cursor-pointer"
|
||||
title="Couleur personnalisée"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prévisualisation */}
|
||||
<div className="pt-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Prévisualisation
|
||||
</label>
|
||||
<span
|
||||
className="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: color ? `${color}20` : '#f3f4f6',
|
||||
color: color || '#374151',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full mr-2"
|
||||
style={{ backgroundColor: color || '#6b7280' }}
|
||||
/>
|
||||
{name || 'Nom de la catégorie'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isLoading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
5
frontend/src/components/categories/index.ts
Normal file
5
frontend/src/components/categories/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Export des composants catégories
|
||||
*/
|
||||
|
||||
export { CategoryForm } from './CategoryForm'
|
||||
30
frontend/src/components/common/Badge.tsx
Normal file
30
frontend/src/components/common/Badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Composant Badge
|
||||
*/
|
||||
|
||||
type BadgeVariant = 'primary' | 'success' | 'warning' | 'danger' | 'gray' | 'custom'
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode
|
||||
variant?: BadgeVariant
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
primary: 'bg-primary-100 text-primary-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
gray: 'bg-gray-100 text-gray-800',
|
||||
custom: '',
|
||||
}
|
||||
|
||||
export function Badge({ children, variant = 'gray', className = '' }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${variantClasses[variant]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
66
frontend/src/components/common/ConfirmDialog.tsx
Normal file
66
frontend/src/components/common/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Dialogue de confirmation pour actions dangereuses
|
||||
*/
|
||||
|
||||
import { Modal } from './Modal'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
danger: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
|
||||
warning: 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500',
|
||||
info: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500',
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirmer',
|
||||
cancelText = 'Annuler',
|
||||
variant = 'danger',
|
||||
isLoading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">{message}</p>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
className={`btn text-white ${variantClasses[variant]} disabled:opacity-50`}
|
||||
>
|
||||
{isLoading ? 'Chargement...' : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/common/EmptyState.tsx
Normal file
30
frontend/src/components/common/EmptyState.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Composant pour afficher un état vide
|
||||
*/
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string
|
||||
description?: string
|
||||
action?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action, icon }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
{icon && <div className="mx-auto h-12 w-12 text-gray-400">{icon}</div>}
|
||||
<h3 className="mt-4 text-lg font-medium text-gray-900">{title}</h3>
|
||||
{description && <p className="mt-2 text-sm text-gray-500">{description}</p>}
|
||||
{action && (
|
||||
<div className="mt-6">
|
||||
<button onClick={action.onClick} className="btn btn-primary">
|
||||
{action.label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
frontend/src/components/common/ErrorMessage.tsx
Normal file
43
frontend/src/components/common/ErrorMessage.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Composant d'affichage d'erreur
|
||||
*/
|
||||
|
||||
interface ErrorMessageProps {
|
||||
message: string
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export function ErrorMessage({ message, onRetry }: ErrorMessageProps) {
|
||||
return (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-red-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-red-800">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="btn btn-sm bg-red-100 text-red-800 hover:bg-red-200"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
frontend/src/components/common/Icons.tsx
Normal file
65
frontend/src/components/common/Icons.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Icônes centralisées - Material Design Icons via react-icons
|
||||
*/
|
||||
|
||||
export {
|
||||
// Navigation & Actions
|
||||
MdAdd as IconAdd,
|
||||
MdEdit as IconEdit,
|
||||
MdDelete as IconDelete,
|
||||
MdClose as IconClose,
|
||||
MdSearch as IconSearch,
|
||||
MdSettings as IconSettings,
|
||||
MdArrowBack as IconBack,
|
||||
MdChevronRight as IconChevronRight,
|
||||
MdChevronLeft as IconChevronLeft,
|
||||
MdExpandMore as IconExpand,
|
||||
MdMoreVert as IconMore,
|
||||
|
||||
// Objets & Inventaire
|
||||
MdInventory2 as IconInventory,
|
||||
MdCategory as IconCategory,
|
||||
MdPlace as IconLocation,
|
||||
MdHome as IconHome,
|
||||
MdMeetingRoom as IconRoom,
|
||||
MdTableRestaurant as IconFurniture,
|
||||
MdInbox as IconDrawer,
|
||||
MdAllInbox as IconBox,
|
||||
|
||||
// Statuts
|
||||
MdCheckCircle as IconCheck,
|
||||
MdWarning as IconWarning,
|
||||
MdError as IconError,
|
||||
MdInfo as IconInfo,
|
||||
MdHourglassEmpty as IconPending,
|
||||
|
||||
// Documents
|
||||
MdAttachFile as IconAttachment,
|
||||
MdImage as IconImage,
|
||||
MdPictureAsPdf as IconPdf,
|
||||
MdLink as IconLink,
|
||||
MdReceipt as IconReceipt,
|
||||
MdDescription as IconDocument,
|
||||
|
||||
// Personnes
|
||||
MdPerson as IconPerson,
|
||||
MdPeople as IconPeople,
|
||||
|
||||
// Divers
|
||||
MdStar as IconStar,
|
||||
MdFavorite as IconFavorite,
|
||||
MdShoppingCart as IconCart,
|
||||
MdLocalOffer as IconTag,
|
||||
MdCalendarToday as IconCalendar,
|
||||
MdEuro as IconEuro,
|
||||
MdQrCode as IconQrCode,
|
||||
MdRefresh as IconRefresh,
|
||||
} from 'react-icons/md'
|
||||
|
||||
// Types d'emplacement avec icônes
|
||||
export const LOCATION_TYPE_ICONS = {
|
||||
room: 'MdMeetingRoom',
|
||||
furniture: 'MdTableRestaurant',
|
||||
drawer: 'MdInbox',
|
||||
box: 'MdAllInbox',
|
||||
} as const
|
||||
25
frontend/src/components/common/Loading.tsx
Normal file
25
frontend/src/components/common/Loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Composant de chargement
|
||||
*/
|
||||
|
||||
interface LoadingProps {
|
||||
message?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function Loading({ message = 'Chargement...', size = 'md' }: LoadingProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<div
|
||||
className={`${sizeClasses[size]} animate-spin rounded-full border-4 border-gray-200 border-t-primary-600`}
|
||||
/>
|
||||
{message && <p className="mt-4 text-sm text-gray-500">{message}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
frontend/src/components/common/Modal.tsx
Normal file
75
frontend/src/components/common/Modal.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Composant Modal réutilisable
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { IconClose } from './Icons'
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Fermer avec Escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
// Fermer en cliquant sur l'overlay
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
>
|
||||
<div className={`bg-white rounded-lg shadow-xl w-full ${sizeClasses[size]} max-h-[90vh] flex flex-col`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<IconClose className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4 overflow-y-auto flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
frontend/src/components/common/index.ts
Normal file
11
frontend/src/components/common/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Export des composants communs
|
||||
*/
|
||||
|
||||
export { Loading } from './Loading'
|
||||
export { ErrorMessage } from './ErrorMessage'
|
||||
export { EmptyState } from './EmptyState'
|
||||
export { Badge } from './Badge'
|
||||
export { Modal } from './Modal'
|
||||
export { ConfirmDialog } from './ConfirmDialog'
|
||||
export * from './Icons'
|
||||
110
frontend/src/components/items/ItemCard.tsx
Normal file
110
frontend/src/components/items/ItemCard.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Carte d'affichage d'un objet
|
||||
*/
|
||||
|
||||
import { ItemWithRelations, Item, ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '@/api'
|
||||
import { Badge, IconEdit, IconDelete, IconLocation } from '../common'
|
||||
|
||||
interface ItemCardProps {
|
||||
item: ItemWithRelations
|
||||
onClick?: () => void
|
||||
onEdit?: (item: Item) => void
|
||||
onDelete?: (item: Item) => void
|
||||
}
|
||||
|
||||
export function ItemCard({ item, onClick, onEdit, onDelete }: ItemCardProps) {
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onEdit?.(item)
|
||||
}
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDelete?.(item)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card card-hover cursor-pointer group"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">{item.name}</h3>
|
||||
{item.brand && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{item.brand} {item.model && `- ${item.model}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge className={ITEM_STATUS_COLORS[item.status]} variant="custom">
|
||||
{ITEM_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
|
||||
{/* Actions */}
|
||||
{(onEdit || onDelete) && (
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
|
||||
title="Modifier"
|
||||
>
|
||||
<IconEdit className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="Supprimer"
|
||||
>
|
||||
<IconDelete className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<p className="mt-2 text-sm text-gray-600 truncate-2-lines">{item.description}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
{/* Catégorie */}
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-1 rounded-md"
|
||||
style={{ backgroundColor: item.category.color ? `${item.category.color}20` : '#f3f4f6' }}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full mr-1.5"
|
||||
style={{ backgroundColor: item.category.color || '#6b7280' }}
|
||||
/>
|
||||
{item.category.name}
|
||||
</span>
|
||||
|
||||
{/* Emplacement */}
|
||||
<span className="inline-flex items-center px-2 py-1 bg-gray-100 rounded-md">
|
||||
<IconLocation className="w-3 h-3 mr-1 text-gray-400" />
|
||||
{item.location.path}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-between items-center text-sm">
|
||||
{/* Quantité */}
|
||||
<span className="text-gray-600">
|
||||
Quantité: <span className="font-medium">{item.quantity}</span>
|
||||
</span>
|
||||
|
||||
{/* Prix */}
|
||||
{item.price && (
|
||||
<span className="font-semibold text-primary-600">
|
||||
{parseFloat(item.price).toFixed(2)} €
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
369
frontend/src/components/items/ItemForm.tsx
Normal file
369
frontend/src/components/items/ItemForm.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Formulaire de création/édition d'objet
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Item,
|
||||
ItemCreate,
|
||||
ItemUpdate,
|
||||
ItemStatus,
|
||||
CategoryWithItemCount,
|
||||
LocationTree,
|
||||
ITEM_STATUS_LABELS,
|
||||
} from '@/api'
|
||||
import { Modal, IconLink } from '@/components/common'
|
||||
|
||||
interface ItemFormProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: ItemCreate | ItemUpdate) => void
|
||||
item?: Item | null
|
||||
categories: CategoryWithItemCount[]
|
||||
locations: LocationTree[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const ITEM_STATUSES: ItemStatus[] = ['in_stock', 'in_use', 'broken', 'sold', 'lent']
|
||||
|
||||
export function ItemForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
item,
|
||||
categories,
|
||||
locations,
|
||||
isLoading = false,
|
||||
}: ItemFormProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [status, setStatus] = useState<ItemStatus>('in_stock')
|
||||
const [brand, setBrand] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const [serialNumber, setSerialNumber] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [purchaseDate, setPurchaseDate] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [categoryId, setCategoryId] = useState<number | ''>('')
|
||||
const [locationId, setLocationId] = useState<number | ''>('')
|
||||
|
||||
const isEditing = !!item
|
||||
|
||||
// Aplatir l'arbre des emplacements pour le select
|
||||
const flattenLocations = (
|
||||
tree: LocationTree[],
|
||||
level = 0
|
||||
): Array<{ id: number; name: string; level: number; path: string }> => {
|
||||
const result: Array<{ id: number; name: string; level: number; path: string }> = []
|
||||
for (const loc of tree) {
|
||||
result.push({ id: loc.id, name: loc.name, level, path: loc.path })
|
||||
result.push(...flattenLocations(loc.children, level + 1))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const flatLocations = flattenLocations(locations)
|
||||
|
||||
// Remplir le formulaire si édition
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
setName(item.name)
|
||||
setDescription(item.description || '')
|
||||
setQuantity(item.quantity)
|
||||
setStatus(item.status)
|
||||
setBrand(item.brand || '')
|
||||
setModel(item.model || '')
|
||||
setSerialNumber(item.serial_number || '')
|
||||
setPrice(item.price || '')
|
||||
setPurchaseDate(item.purchase_date ? item.purchase_date.split('T')[0] : '')
|
||||
setNotes(item.notes || '')
|
||||
setCategoryId(item.category_id)
|
||||
setLocationId(item.location_id)
|
||||
} else {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setQuantity(1)
|
||||
setStatus('in_stock')
|
||||
setBrand('')
|
||||
setModel('')
|
||||
setSerialNumber('')
|
||||
setPrice('')
|
||||
setPurchaseDate('')
|
||||
setNotes('')
|
||||
setCategoryId(categories.length > 0 ? categories[0].id : '')
|
||||
setLocationId(flatLocations.length > 0 ? flatLocations[0].id : '')
|
||||
}
|
||||
}, [item, isOpen])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (categoryId === '' || locationId === '') return
|
||||
|
||||
const data: ItemCreate | ItemUpdate = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
quantity,
|
||||
status,
|
||||
brand: brand.trim() || null,
|
||||
model: model.trim() || null,
|
||||
serial_number: serialNumber.trim() || null,
|
||||
price: price ? parseFloat(price) : null,
|
||||
purchase_date: purchaseDate || null,
|
||||
notes: notes.trim() || null,
|
||||
category_id: categoryId,
|
||||
location_id: locationId,
|
||||
}
|
||||
|
||||
onSubmit(data)
|
||||
}
|
||||
|
||||
const isValid = name.trim().length > 0 && categoryId !== '' && locationId !== ''
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? "Modifier l'objet" : 'Nouvel objet'}
|
||||
size="xl"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Section principale */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Nom */}
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nom <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Ex: Perceuse Bosch, Câble HDMI..."
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Catégorie */}
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Catégorie <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : '')}
|
||||
className="input"
|
||||
required
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Emplacement */}
|
||||
<div>
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Emplacement <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="location"
|
||||
value={locationId}
|
||||
onChange={(e) => setLocationId(e.target.value ? Number(e.target.value) : '')}
|
||||
className="input"
|
||||
required
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
{flatLocations.map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
{' '.repeat(loc.level)}
|
||||
{loc.level > 0 && '└ '}
|
||||
{loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Quantité */}
|
||||
<div>
|
||||
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Quantité
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="quantity"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="input"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Statut */}
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Statut
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as ItemStatus)}
|
||||
className="input"
|
||||
>
|
||||
{ITEM_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{ITEM_STATUS_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="input min-h-[80px]"
|
||||
placeholder="Description détaillée de l'objet..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Section détails produit */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Détails produit</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Marque */}
|
||||
<div>
|
||||
<label htmlFor="brand" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Marque
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="brand"
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Ex: Bosch, Sony..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modèle */}
|
||||
<div>
|
||||
<label htmlFor="model" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Modèle
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="model"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Ex: GSR 18V-21..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* N° série */}
|
||||
<div>
|
||||
<label htmlFor="serial" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
N° de série
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serial"
|
||||
value={serialNumber}
|
||||
onChange={(e) => setSerialNumber(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Ex: SN123456..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section achat */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Informations d'achat</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Prix */}
|
||||
<div>
|
||||
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Prix (€)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="price"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
className="input"
|
||||
placeholder="0.00"
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date d'achat */}
|
||||
<div>
|
||||
<label htmlFor="purchaseDate" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date d'achat
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="purchaseDate"
|
||||
value={purchaseDate}
|
||||
onChange={(e) => setPurchaseDate(e.target.value)}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="input min-h-[60px]"
|
||||
placeholder="Notes supplémentaires..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isLoading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
122
frontend/src/components/items/ItemList.tsx
Normal file
122
frontend/src/components/items/ItemList.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Liste des objets avec recherche et filtres
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useItems } from '@/hooks'
|
||||
import { ItemFilter, ItemStatus, Item, ITEM_STATUS_LABELS } from '@/api'
|
||||
import { Loading, ErrorMessage, EmptyState } from '../common'
|
||||
import { ItemCard } from './ItemCard'
|
||||
|
||||
interface ItemListProps {
|
||||
onItemClick?: (id: number) => void
|
||||
onItemEdit?: (item: Item) => void
|
||||
onItemDelete?: (item: Item) => void
|
||||
}
|
||||
|
||||
export function ItemList({ onItemClick, onItemEdit, onItemDelete }: ItemListProps) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [filters, setFilters] = useState<ItemFilter>({})
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
|
||||
const { data, isLoading, error, refetch } = useItems(page, 20, filters)
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setFilters({ ...filters, search: searchInput || undefined })
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleStatusFilter = (status: ItemStatus | '') => {
|
||||
setFilters({ ...filters, status: status || undefined })
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
if (isLoading) return <Loading message="Chargement des objets..." />
|
||||
if (error) return <ErrorMessage message="Erreur lors du chargement des objets" onRetry={refetch} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Barre de recherche et filtres */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Rechercher un objet..."
|
||||
className="input flex-1"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Rechercher
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleStatusFilter(e.target.value as ItemStatus | '')}
|
||||
className="input w-auto"
|
||||
>
|
||||
<option value="">Tous les statuts</option>
|
||||
{Object.entries(ITEM_STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des objets */}
|
||||
{data?.items.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Aucun objet trouvé"
|
||||
description={filters.search ? "Essayez avec d'autres critères de recherche" : "Commencez par ajouter votre premier objet"}
|
||||
icon={
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data?.items.map((item) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => onItemClick?.(item.id)}
|
||||
onEdit={onItemEdit}
|
||||
onDelete={onItemDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.pages > 1 && (
|
||||
<div className="mt-6 flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="btn btn-secondary btn-sm disabled:opacity-50"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<span className="flex items-center px-4 text-sm text-gray-600">
|
||||
Page {page} sur {data.pages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
|
||||
disabled={page === data.pages}
|
||||
className="btn btn-secondary btn-sm disabled:opacity-50"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/items/index.ts
Normal file
7
frontend/src/components/items/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Export des composants items
|
||||
*/
|
||||
|
||||
export { ItemCard } from './ItemCard'
|
||||
export { ItemList } from './ItemList'
|
||||
export { ItemForm } from './ItemForm'
|
||||
198
frontend/src/components/locations/LocationForm.tsx
Normal file
198
frontend/src/components/locations/LocationForm.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Formulaire de création/édition d'emplacement
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Location, LocationCreate, LocationUpdate, LocationType, LocationTree, LOCATION_TYPE_LABELS } from '@/api'
|
||||
import { Modal, IconRoom, IconFurniture, IconDrawer, IconBox } from '@/components/common'
|
||||
|
||||
// Mapping des icônes par type
|
||||
const TYPE_ICONS: Record<LocationType, React.ReactNode> = {
|
||||
room: <IconRoom className="w-6 h-6" />,
|
||||
furniture: <IconFurniture className="w-6 h-6" />,
|
||||
drawer: <IconDrawer className="w-6 h-6" />,
|
||||
box: <IconBox className="w-6 h-6" />,
|
||||
}
|
||||
|
||||
interface LocationFormProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: LocationCreate | LocationUpdate) => void
|
||||
location?: Location | null
|
||||
locations: LocationTree[]
|
||||
isLoading?: boolean
|
||||
defaultParentId?: number | null
|
||||
}
|
||||
|
||||
const LOCATION_TYPES: LocationType[] = ['room', 'furniture', 'drawer', 'box']
|
||||
|
||||
export function LocationForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
location,
|
||||
locations,
|
||||
isLoading = false,
|
||||
defaultParentId = null,
|
||||
}: LocationFormProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<LocationType>('room')
|
||||
const [parentId, setParentId] = useState<number | null>(null)
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const isEditing = !!location
|
||||
|
||||
// Aplatir l'arbre pour le select
|
||||
const flattenLocations = (tree: LocationTree[], level = 0): Array<{ id: number; name: string; level: number; type: LocationType }> => {
|
||||
const result: Array<{ id: number; name: string; level: number; type: LocationType }> = []
|
||||
for (const loc of tree) {
|
||||
// Exclure l'emplacement en cours d'édition et ses enfants
|
||||
if (location && loc.id === location.id) continue
|
||||
result.push({ id: loc.id, name: loc.name, level, type: loc.type })
|
||||
result.push(...flattenLocations(loc.children, level + 1))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const flatLocations = flattenLocations(locations)
|
||||
|
||||
// Remplir le formulaire si édition
|
||||
useEffect(() => {
|
||||
if (location) {
|
||||
setName(location.name)
|
||||
setType(location.type)
|
||||
setParentId(location.parent_id)
|
||||
setDescription(location.description || '')
|
||||
} else {
|
||||
setName('')
|
||||
setType(defaultParentId ? 'furniture' : 'room')
|
||||
setParentId(defaultParentId)
|
||||
setDescription('')
|
||||
}
|
||||
}, [location, isOpen, defaultParentId])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
type,
|
||||
parent_id: parentId,
|
||||
description: description.trim() || null,
|
||||
}
|
||||
|
||||
onSubmit(data)
|
||||
}
|
||||
|
||||
const isValid = name.trim().length > 0
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? "Modifier l'emplacement" : 'Nouvel emplacement'}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{LOCATION_TYPES.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setType(t)}
|
||||
className={`px-3 py-2 text-sm rounded-lg border transition-colors flex flex-col items-center ${
|
||||
type === t
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-300 hover:border-gray-400 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span className="mb-1">{TYPE_ICONS[t]}</span>
|
||||
{LOCATION_TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nom */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nom <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Ex: Bureau, Armoire cuisine..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parent */}
|
||||
<div>
|
||||
<label htmlFor="parent" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Emplacement parent
|
||||
</label>
|
||||
<select
|
||||
id="parent"
|
||||
value={parentId ?? ''}
|
||||
onChange={(e) => setParentId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Aucun (racine)</option>
|
||||
{flatLocations.map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
{' '.repeat(loc.level)}
|
||||
{loc.level > 0 && '└ '}
|
||||
{loc.name} ({LOCATION_TYPE_LABELS[loc.type]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Laissez vide pour créer un emplacement racine (ex: une pièce)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="input min-h-[80px]"
|
||||
placeholder="Description optionnelle..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isLoading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
5
frontend/src/components/locations/index.ts
Normal file
5
frontend/src/components/locations/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Export des composants emplacements
|
||||
*/
|
||||
|
||||
export { LocationForm } from './LocationForm'
|
||||
7
frontend/src/hooks/index.ts
Normal file
7
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Point d'entrée pour tous les hooks
|
||||
*/
|
||||
|
||||
export * from './useCategories'
|
||||
export * from './useLocations'
|
||||
export * from './useItems'
|
||||
80
frontend/src/hooks/useCategories.ts
Normal file
80
frontend/src/hooks/useCategories.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Hooks React Query pour les catégories
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { categoriesApi, CategoryCreate, CategoryUpdate } from '@/api'
|
||||
|
||||
// Clés de cache
|
||||
export const categoryKeys = {
|
||||
all: ['categories'] as const,
|
||||
lists: () => [...categoryKeys.all, 'list'] as const,
|
||||
list: (page: number, pageSize: number) => [...categoryKeys.lists(), { page, pageSize }] as const,
|
||||
details: () => [...categoryKeys.all, 'detail'] as const,
|
||||
detail: (id: number) => [...categoryKeys.details(), id] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour récupérer la liste des catégories
|
||||
*/
|
||||
export function useCategories(page = 1, pageSize = 20) {
|
||||
return useQuery({
|
||||
queryKey: categoryKeys.list(page, pageSize),
|
||||
queryFn: () => categoriesApi.getAll(page, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour récupérer une catégorie par son ID
|
||||
*/
|
||||
export function useCategory(id: number) {
|
||||
return useQuery({
|
||||
queryKey: categoryKeys.detail(id),
|
||||
queryFn: () => categoriesApi.getById(id),
|
||||
enabled: id > 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour créer une catégorie
|
||||
*/
|
||||
export function useCreateCategory() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CategoryCreate) => categoriesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: categoryKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour mettre à jour une catégorie
|
||||
*/
|
||||
export function useUpdateCategory() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: CategoryUpdate }) =>
|
||||
categoriesApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: categoryKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: categoryKeys.detail(id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour supprimer une catégorie
|
||||
*/
|
||||
export function useDeleteCategory() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => categoriesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: categoryKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
112
frontend/src/hooks/useItems.ts
Normal file
112
frontend/src/hooks/useItems.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Hooks React Query pour les objets d'inventaire
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { itemsApi, ItemCreate, ItemFilter, ItemStatus, ItemUpdate } from '@/api'
|
||||
|
||||
// Clés de cache
|
||||
export const itemKeys = {
|
||||
all: ['items'] as const,
|
||||
lists: () => [...itemKeys.all, 'list'] as const,
|
||||
list: (page: number, pageSize: number, filters?: ItemFilter) =>
|
||||
[...itemKeys.lists(), { page, pageSize, filters }] as const,
|
||||
details: () => [...itemKeys.all, 'detail'] as const,
|
||||
detail: (id: number) => [...itemKeys.details(), id] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour récupérer la liste des objets avec filtres
|
||||
*/
|
||||
export function useItems(page = 1, pageSize = 20, filters?: ItemFilter) {
|
||||
return useQuery({
|
||||
queryKey: itemKeys.list(page, pageSize, filters),
|
||||
queryFn: () => itemsApi.getAll(page, pageSize, filters),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour récupérer un objet par son ID
|
||||
*/
|
||||
export function useItem(id: number) {
|
||||
return useQuery({
|
||||
queryKey: itemKeys.detail(id),
|
||||
queryFn: () => itemsApi.getById(id),
|
||||
enabled: id > 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour créer un objet
|
||||
*/
|
||||
export function useCreateItem() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: ItemCreate) => itemsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour mettre à jour un objet
|
||||
*/
|
||||
export function useUpdateItem() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: ItemUpdate }) => itemsApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: itemKeys.detail(id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour supprimer un objet
|
||||
*/
|
||||
export function useDeleteItem() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => itemsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour changer le statut d'un objet
|
||||
*/
|
||||
export function useUpdateItemStatus() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: number; status: ItemStatus }) =>
|
||||
itemsApi.updateStatus(id, status),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: itemKeys.detail(id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour déplacer un objet
|
||||
*/
|
||||
export function useMoveItem() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, locationId }: { id: number; locationId: number }) =>
|
||||
itemsApi.move(id, locationId),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: itemKeys.detail(id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
114
frontend/src/hooks/useLocations.ts
Normal file
114
frontend/src/hooks/useLocations.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Hooks React Query pour les emplacements
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { locationsApi, LocationCreate, LocationType, LocationUpdate } from '@/api'
|
||||
|
||||
// Clés de cache
|
||||
export const locationKeys = {
|
||||
all: ['locations'] as const,
|
||||
lists: () => [...locationKeys.all, 'list'] as const,
|
||||
list: (page: number, pageSize: number, parentId?: number, type?: LocationType) =>
|
||||
[...locationKeys.lists(), { page, pageSize, parentId, type }] as const,
|
||||
tree: () => [...locationKeys.all, 'tree'] as const,
|
||||
roots: () => [...locationKeys.all, 'roots'] as const,
|
||||
details: () => [...locationKeys.all, 'detail'] as const,
|
||||
detail: (id: number) => [...locationKeys.details(), id] as const,
|
||||
children: (id: number) => [...locationKeys.all, 'children', id] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour récupérer la liste des emplacements
|
||||
*/
|
||||
export function useLocations(page = 1, pageSize = 50, parentId?: number, type?: LocationType) {
|
||||
return useQuery({
|
||||
queryKey: locationKeys.list(page, pageSize, parentId, type),
|
||||
queryFn: () => locationsApi.getAll(page, pageSize, parentId, type),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour récupérer l'arborescence complète
|
||||
*/
|
||||
export function useLocationTree() {
|
||||
return useQuery({
|
||||
queryKey: locationKeys.tree(),
|
||||
queryFn: () => locationsApi.getTree(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour récupérer les emplacements racine
|
||||
*/
|
||||
export function useRootLocations() {
|
||||
return useQuery({
|
||||
queryKey: locationKeys.roots(),
|
||||
queryFn: () => locationsApi.getRoots(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour récupérer un emplacement par son ID
|
||||
*/
|
||||
export function useLocation(id: number) {
|
||||
return useQuery({
|
||||
queryKey: locationKeys.detail(id),
|
||||
queryFn: () => locationsApi.getById(id),
|
||||
enabled: id > 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour récupérer les enfants d'un emplacement
|
||||
*/
|
||||
export function useLocationChildren(id: number) {
|
||||
return useQuery({
|
||||
queryKey: locationKeys.children(id),
|
||||
queryFn: () => locationsApi.getChildren(id),
|
||||
enabled: id > 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour créer un emplacement
|
||||
*/
|
||||
export function useCreateLocation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: LocationCreate) => locationsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: locationKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour mettre à jour un emplacement
|
||||
*/
|
||||
export function useUpdateLocation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: LocationUpdate }) =>
|
||||
locationsApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: locationKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour supprimer un emplacement
|
||||
*/
|
||||
export function useDeleteLocation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => locationsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: locationKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
38
frontend/src/main.tsx
Normal file
38
frontend/src/main.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import App from './App'
|
||||
import './styles/index.css'
|
||||
|
||||
// Configuration de React Query
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Temps avant de considérer les données comme obsolètes
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
// Temps de cache avant garbage collection
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes (anciennement cacheTime)
|
||||
// Retry automatique en cas d'erreur
|
||||
retry: 1,
|
||||
// Refetch automatique
|
||||
refetchOnWindowFocus: false, // Désactivé pour meilleure UX en local
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
mutations: {
|
||||
// Retry automatique pour les mutations
|
||||
retry: 0, // Pas de retry pour les mutations par défaut
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Rendu de l'application
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
{/* DevTools React Query (visible uniquement en développement) */}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
182
frontend/src/styles/index.css
Normal file
182
frontend/src/styles/index.css
Normal file
@@ -0,0 +1,182 @@
|
||||
/* Styles globaux pour HomeStock */
|
||||
|
||||
/* Directives TailwindCSS */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* === Styles de base personnalisés === */
|
||||
@layer base {
|
||||
/* Reset et styles du body */
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Titres */
|
||||
h1 {
|
||||
@apply text-3xl font-bold tracking-tight;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl font-semibold tracking-tight;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl font-semibold;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-lg font-semibold;
|
||||
}
|
||||
|
||||
/* Liens */
|
||||
a {
|
||||
@apply text-primary-600 hover:text-primary-700 transition-colors;
|
||||
}
|
||||
|
||||
/* Focus visible pour accessibilité */
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Composants réutilisables === */
|
||||
@layer components {
|
||||
/* Boutons */
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 active:bg-primary-800;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 active:bg-gray-400;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 active:bg-red-800;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-3 py-1.5 text-sm;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply px-6 py-3 text-lg;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply transition-shadow hover:shadow-md;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@apply border-red-500 focus:ring-red-500;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply bg-primary-100 text-primary-800;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply bg-gray-100 text-gray-800;
|
||||
}
|
||||
|
||||
/* Conteneur principal */
|
||||
.container-main {
|
||||
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Utilitaires personnalisés === */
|
||||
@layer utilities {
|
||||
/* Scrollbar personnalisée */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded-full;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400;
|
||||
}
|
||||
|
||||
/* Transitions personnalisées */
|
||||
.transition-base {
|
||||
@apply transition-all duration-200 ease-in-out;
|
||||
}
|
||||
|
||||
/* Truncate avec tooltip */
|
||||
.truncate-2-lines {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.truncate-3-lines {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Animations === */
|
||||
@keyframes spin-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 3s linear infinite;
|
||||
}
|
||||
12
frontend/src/vite-env.d.ts
vendored
Normal file
12
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Types pour les variables d'environnement Vite
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_APP_NAME: string
|
||||
readonly VITE_APP_VERSION: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
117
frontend/tailwind.config.js
Normal file
117
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
// Palette de couleurs personnalisée pour HomeStock
|
||||
colors: {
|
||||
// Couleur primaire (bleu pour inventaire/organisation)
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
// Couleur secondaire (vert pour statut/disponibilité)
|
||||
secondary: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
950: '#052e16',
|
||||
},
|
||||
// Gris pour UI
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
950: '#030712',
|
||||
},
|
||||
},
|
||||
// Espacements supplémentaires
|
||||
spacing: {
|
||||
'128': '32rem',
|
||||
'144': '36rem',
|
||||
},
|
||||
// Typographie
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'Inter',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'Helvetica Neue',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
],
|
||||
mono: [
|
||||
'Fira Code',
|
||||
'Consolas',
|
||||
'Monaco',
|
||||
'Courier New',
|
||||
'monospace',
|
||||
],
|
||||
},
|
||||
// Animations personnalisées
|
||||
keyframes: {
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
'slide-in-right': {
|
||||
'0%': { transform: 'translateX(100%)' },
|
||||
'100%': { transform: 'translateX(0)' },
|
||||
},
|
||||
'slide-in-up': {
|
||||
'0%': { transform: 'translateY(100%)' },
|
||||
'100%': { transform: 'translateY(0)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fade-in 0.2s ease-in-out',
|
||||
'slide-in-right': 'slide-in-right 0.3s ease-out',
|
||||
'slide-in-up': 'slide-in-up 0.3s ease-out',
|
||||
},
|
||||
// Largeurs max personnalisées
|
||||
maxWidth: {
|
||||
'8xl': '88rem',
|
||||
'9xl': '96rem',
|
||||
},
|
||||
// Border radius personnalisé
|
||||
borderRadius: {
|
||||
'4xl': '2rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
// Plugin pour les formulaires (optionnel, décommenter si installé)
|
||||
// require('@tailwindcss/forms'),
|
||||
// Plugin pour la typographie (optionnel, décommenter si installé)
|
||||
// require('@tailwindcss/typography'),
|
||||
],
|
||||
}
|
||||
46
frontend/tsconfig.json
Normal file
46
frontend/tsconfig.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// === Cible et modules ===
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
// === Résolution des modules ===
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
// === Alias de chemins (correspond à vite.config.ts) ===
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@components/*": ["./src/components/*"],
|
||||
"@pages/*": ["./src/pages/*"],
|
||||
"@hooks/*": ["./src/hooks/*"],
|
||||
"@api/*": ["./src/api/*"],
|
||||
"@utils/*": ["./src/utils/*"],
|
||||
"@assets/*": ["./src/assets/*"],
|
||||
"@styles/*": ["./src/styles/*"]
|
||||
},
|
||||
|
||||
// === Type checking strict ===
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// === Autres options ===
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
72
frontend/vite.config.ts
Normal file
72
frontend/vite.config.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// Documentation Vite : https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react({
|
||||
// Support JSX automatique (pas besoin d'importer React)
|
||||
jsxRuntime: 'automatic',
|
||||
}),
|
||||
],
|
||||
|
||||
// Configuration des alias de chemins
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@components': path.resolve(__dirname, './src/components'),
|
||||
'@pages': path.resolve(__dirname, './src/pages'),
|
||||
'@hooks': path.resolve(__dirname, './src/hooks'),
|
||||
'@api': path.resolve(__dirname, './src/api'),
|
||||
'@utils': path.resolve(__dirname, './src/utils'),
|
||||
'@assets': path.resolve(__dirname, './src/assets'),
|
||||
'@styles': path.resolve(__dirname, './src/styles'),
|
||||
},
|
||||
},
|
||||
|
||||
// Configuration du serveur de développement
|
||||
server: {
|
||||
host: '0.0.0.0', // Permet l'accès depuis l'extérieur du conteneur
|
||||
port: 5173,
|
||||
strictPort: true, // Échoue si le port est déjà utilisé
|
||||
watch: {
|
||||
// Utilise polling pour Docker (nécessaire pour hot-reload)
|
||||
usePolling: true,
|
||||
},
|
||||
// Configuration CORS pour développement
|
||||
cors: true,
|
||||
},
|
||||
|
||||
// Configuration du build de production
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true, // Génère les sourcemaps pour debugging
|
||||
// Taille max des chunks (avertissement si dépassé)
|
||||
chunkSizeWarningLimit: 1000,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Chunking manuel pour optimiser le cache
|
||||
manualChunks: {
|
||||
// Vendor chunks (dépendances externes)
|
||||
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||
'query-vendor': ['@tanstack/react-query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Variables d'environnement exposées au client
|
||||
// Seules les variables préfixées par VITE_ sont exposées
|
||||
envPrefix: 'VITE_',
|
||||
|
||||
// Configuration optimisations
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react-router-dom',
|
||||
'@tanstack/react-query',
|
||||
],
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user