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
|
## Vue d'ensemble du projet
|
||||||
- Lire `a-lire.md`, `PROJECT_CONTEXT.md`, `outils_dev_pref.md`.
|
|
||||||
- Lire `docs/ARCHITECTURE.md` avant toute décision technique.
|
|
||||||
|
|
||||||
## 2. Gestion des zones
|
**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.
|
||||||
- Ne jamais supprimer `<A REMPLIR - PROJET>`.
|
|
||||||
- Ajouter un exemple guidant avec la mention "a supprimer".
|
|
||||||
- Utiliser `<A COMPLETER PAR AGENT>` pour les choix techniques.
|
|
||||||
|
|
||||||
## 3. Documentation avant code
|
### Domaines couverts
|
||||||
- Aucun code tant que `product/VISION.md` et `product/BACKLOG.md` ne sont pas complétés.
|
- Bricolage
|
||||||
- Toute décision structurante → ajouter un ADR dans `docs/adr/`.
|
- Informatique
|
||||||
|
- Électronique
|
||||||
|
- Cuisine
|
||||||
|
|
||||||
## 4. Style d’écriture
|
### Fonctionnalités principales
|
||||||
- Français uniquement.
|
- Catalogue d'objets avec caractéristiques détaillées
|
||||||
- Éviter les acronymes seuls : toujours expliquer (ex: "MFA = authentification multi‑facteurs").
|
- Gestion des photos, notices et factures
|
||||||
- Commentaires courts et utiles.
|
- Suivi des prix et du stock
|
||||||
|
- Gestion de l'état (en utilisation / en stock)
|
||||||
## 5. Découpage des tâches
|
- Localisation précise (lieu, meuble, tiroir, boîte)
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Exemple (a supprimer)
|
## Architecture du projet
|
||||||
- "Ajouter ADR-0002 pour le choix de base de données."
|
|
||||||
|
### 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é
|
## Résumé
|
||||||
- But du projet : <A REMPLIR - PROJET> (exemple: réduire les erreurs d'inventaire — 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 : <A REMPLIR - PROJET> (exemple: tableau de bord + alertes — a supprimer)
|
- 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
|
## Parties prenantes
|
||||||
- Décideur : <A REMPLIR - PROJET> (exemple: Directeur des opérations — a supprimer)
|
- Décideur : Utilisateur final (propriétaire du domicile) <!-- complété par codex -->
|
||||||
- Utilisateurs finaux : <A REMPLIR - PROJET> (exemple: équipe logistique — a supprimer)
|
- Utilisateurs finaux : Utilisateur unique (mono-utilisateur) avec possibilité d'évolution vers usage familial <!-- complété par codex -->
|
||||||
|
|
||||||
## Contraintes
|
## Contraintes
|
||||||
- Budget / délai : <A REMPLIR - PROJET> (exemple: 3 mois / 20k€ — a supprimer)
|
- Budget / délai : Projet personnel, développement itératif sans contrainte de délai strict <!-- complété par codex -->
|
||||||
- Légalité / conformité : <A REMPLIR - PROJET> (exemple: RGPD — a supprimer)
|
- Légalité / conformité : Aucune contrainte légale (usage personnel, pas de données tierces) <!-- complété par codex -->
|
||||||
- Tech imposée : <A REMPLIR - PROJET> (exemple: PostgreSQL — a supprimer)
|
- Tech imposée : Python (backend) et React (frontend) selon préférences, SQLite pour simplicité mono-utilisateur <!-- complété par codex -->
|
||||||
|
|
||||||
## Références
|
## Références
|
||||||
- Vision : `product/VISION.md`
|
- Vision : `product/VISION.md`
|
||||||
|
|||||||
17
README.md
17
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
|
## Objectif
|
||||||
- Problème : <A REMPLIR - PROJET> (exemple: suivi manuel sur tableur — 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 : <A REMPLIR - PROJET> (exemple: PME logistique — a supprimer)
|
- Public cible : Particulier gérant son domicile, domaines bricolage/informatique/électronique/cuisine <!-- complété par codex -->
|
||||||
|
|
||||||
## Démarrage rapide
|
## Démarrage rapide
|
||||||
- Prérequis : <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 : <A COMPLETER PAR AGENT>
|
- Lancer en local : `docker-compose up` ou `make dev` (en développement : backend sur :8000, frontend sur :5173) <!-- complété par codex -->
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
- Contexte : `PROJECT_CONTEXT.md`
|
- Contexte : `PROJECT_CONTEXT.md`
|
||||||
@@ -26,8 +26,3 @@ Résumé court : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer
|
|||||||
- Workflow : `docs/WORKFLOW.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`.
|
|
||||||
|
|||||||
28
a-lire.md
28
a-lire.md
@@ -18,28 +18,28 @@ Ce dépôt est basé sur le template **template-webapp**.
|
|||||||
|
|
||||||
## 1. Description du projet
|
## 1. Description du projet
|
||||||
|
|
||||||
- Nom du projet : <A REMPLIR - PROJET> (exemple: StockPilot — a supprimer)
|
- Nom du projet : HomeStock <!-- complété par codex -->
|
||||||
- Type de webapp : <A REMPLIR - PROJET> (exemple: SaaS B2B — a supprimer)
|
- Type de webapp : Application web self-hosted mono-utilisateur <!-- complété par codex -->
|
||||||
- Public cible : <A REMPLIR - PROJET> (exemple: PME logistique — a supprimer)
|
- Public cible : Particulier gérant son inventaire domestique <!-- complété par codex -->
|
||||||
- Objectif principal : <A REMPLIR - PROJET> (exemple: suivi des stocks en temps réel — a supprimer)
|
- 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
|
## 2. Contraintes fortes
|
||||||
|
|
||||||
- Self-hosted / Cloud : <A REMPLIR - PROJET> (exemple: Self-hosted — a supprimer)
|
- Self-hosted / Cloud : Self-hosted (déploiement local sur le réseau domestique) <!-- complété par codex -->
|
||||||
- Mono-utilisateur / Multi-utilisateur : <A REMPLIR - PROJET> (exemple: Multi-utilisateur — a supprimer)
|
- Mono-utilisateur / Multi-utilisateur : Mono-utilisateur <!-- complété par codex -->
|
||||||
- Données sensibles : oui / non
|
- Données sensibles : Non (données personnelles mais non sensibles : inventaire domestique, factures)
|
||||||
- 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)
|
- 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)
|
## 3. Stack envisagée (indicative)
|
||||||
|
|
||||||
- Frontend : <A COMPLETER PAR AGENT>
|
- Frontend : React 18+ avec Vite, TanStack Query (React Query), React Router, TailwindCSS <!-- complété par codex -->
|
||||||
- Backend : <A COMPLETER PAR AGENT>
|
- Backend : Python 3.11+ avec FastAPI, Pydantic pour validation, SQLAlchemy comme ORM <!-- complété par codex -->
|
||||||
- Base de données : <A COMPLETER PAR AGENT>
|
- Base de données : SQLite (fichier local, adapté au mono-utilisateur) <!-- complété par codex -->
|
||||||
- Stockage fichiers : <A COMPLETER PAR AGENT>
|
- Stockage fichiers : Système de fichiers local (dossier uploads/ avec organisation par type) <!-- complété par codex -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,7 +60,3 @@ Ce dépôt est basé sur le template **template-webapp**.
|
|||||||
- Documenter les outils dans `outils_dev_pref.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
|
## Objectif du backend
|
||||||
- Problème métier couvert : <A REMPLIR - PROJET> (exemple: suivi manuel sur tableur — 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 : <A COMPLETER PAR AGENT>
|
- 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 : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
- Hors périmètre : Rendu frontend (SPA indépendante), authentification complexe (MVP mono-utilisateur), analytics avancées <!-- complété par codex -->
|
||||||
|
|
||||||
## Interfaces
|
## Interfaces
|
||||||
- API publique (API = Interface de Programmation) : <A COMPLETER PAR AGENT>
|
- 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 : <A COMPLETER PAR AGENT>
|
- 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 : <A REMPLIR - PROJET> (exemple: ERP existant — a supprimer)
|
- Intégrations externes : Aucune intégration externe, système autonome <!-- complété par codex -->
|
||||||
|
|
||||||
## Données
|
## Données
|
||||||
- Base(s) utilisée(s) : <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é : <A COMPLETER PAR AGENT>
|
- 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 : <A COMPLETER PAR AGENT>
|
- 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
|
## Architecture interne
|
||||||
- Style (monolithe modulaire, hexagonal, etc.) : <A COMPLETER PAR AGENT>
|
- Style : Monolithe modulaire avec séparation claire routers → services → repositories (3-layer architecture) <!-- complété par codex -->
|
||||||
- Modules principaux : <A COMPLETER PAR AGENT>
|
- 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 : <A COMPLETER PAR AGENT>
|
- 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
|
## Qualité & exploitation
|
||||||
- Observabilité (logs/metrics/traces = journaux/mesures/traces) : <A COMPLETER PAR AGENT>
|
- Observabilité : Logs structurés avec loguru (format JSON), endpoint `/health` pour healthcheck, pas de tracing distribué (monolithe) <!-- complété par codex -->
|
||||||
- Tests (unitaires/intégration) : <A COMPLETER PAR AGENT>
|
- 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 : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
- 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
|
## Conventions
|
||||||
- Organisation du code : <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 : <A COMPLETER PAR AGENT>
|
- 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 : <A COMPLETER PAR AGENT>
|
- 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és principales
|
||||||
- Entité : <A COMPLETER PAR AGENT>
|
|
||||||
- Champs : <A COMPLETER PAR AGENT>
|
### Item (objet d'inventaire)
|
||||||
- Contraintes : <A COMPLETER PAR AGENT>
|
- 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
|
## 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 -->
|
||||||
|
|
||||||
|
## 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 -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Exemple (a supprimer)
|
|
||||||
- `User` 1..N `Order`.
|
|
||||||
|
|||||||
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
|
## 1. Vue d'ensemble
|
||||||
- Objectif produit : <A REMPLIR - PROJET> (exemple: améliorer la traçabilité — a supprimer)
|
- 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 (web, mobile, API) : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
- Type d'app : Application web full-stack (API REST + SPA) avec déploiement conteneurisé <!-- complété par codex -->
|
||||||
- Contraintes fortes : <A REMPLIR - PROJET> (exemple: déploiement on-premise — a supprimer)
|
- Contraintes fortes : Self-hosted sur réseau local, mono-utilisateur, simplicité de déploiement et maintenance <!-- complété par codex -->
|
||||||
|
|
||||||
## 2. Principes d’architecture
|
## 2. Principes d'architecture
|
||||||
- Principes non négociables : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
- 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 : <A COMPLETER PAR AGENT>
|
- 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 (performance, sécurité, scalabilité) : <A REMPLIR - PROJET> (exemple: sécurité et performance — a supprimer)
|
- Qualités prioritaires : Simplicité d'utilisation > Performance > Maintenabilité > Évolutivité (mono-utilisateur donc pas de scalabilité horizontale) <!-- complété par codex -->
|
||||||
|
|
||||||
## 3. Architecture logique
|
## 3. Architecture logique
|
||||||
- Modules principaux : <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 : <A COMPLETER PAR AGENT>
|
- 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 : <A COMPLETER PAR AGENT>
|
- 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
|
## 4. Architecture technique
|
||||||
- Langages & frameworks : <A COMPLETER PAR AGENT>
|
- Langages & frameworks : Backend = Python 3.11+ avec FastAPI + SQLAlchemy + Pydantic, Frontend = TypeScript + React 18+ + Vite + TailwindCSS <!-- complété par codex -->
|
||||||
- Base de données : <A COMPLETER PAR AGENT>
|
- Base de données : SQLite (fichier local homestock.db) avec FTS5 pour recherche full-text, migrations via Alembic <!-- complété par codex -->
|
||||||
- Stockage fichiers : <A COMPLETER PAR AGENT>
|
- 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 (cloud/self-hosted) : <A REMPLIR - PROJET> (exemple: self-hosted — a supprimer)
|
- 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
|
## 5. Flux de données
|
||||||
- Flux principaux (lecture/écriture) : <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 : <A REMPLIR - PROJET> (exemple: ERP existant — a supprimer)
|
- Intégrations externes : Aucune intégration externe prévue, système autonome et déconnecté <!-- complété par codex -->
|
||||||
- Gestion des événements/asynchronisme : <A COMPLETER PAR AGENT>
|
- 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é
|
## 6. Sécurité
|
||||||
- Authentification/autorisation : <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 : <A REMPLIR - PROJET> (exemple: emails + historiques de paiement — a supprimer)
|
- 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 : <A COMPLETER PAR AGENT>
|
- 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é
|
## 7. Observabilité
|
||||||
- Logs (journaux) : <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 (mesures) : <A COMPLETER PAR AGENT>
|
- Metrics : Optionnelles pour MVP, possibilité ajout Prometheus + Grafana plus tard (nb items, espace disque uploads/, temps réponse API) <!-- complété par codex -->
|
||||||
- Alerting (alertes) : <A COMPLETER PAR AGENT>
|
- 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
|
## 8. Conventions de code
|
||||||
- Organisation des dossiers : <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 : <A COMPLETER PAR AGENT>
|
- 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 : <A COMPLETER PAR AGENT>
|
- 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
|
## 9. Évolution & dette
|
||||||
- Zones à risque : <A REMPLIR - PROJET> (exemple: montée en charge — 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 : <A REMPLIR - PROJET> (exemple: reporting avancé — a supprimer)
|
- 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
|
## 1. Nommage
|
||||||
- Variables / fonctions : <A COMPLETER PAR AGENT>
|
- Variables / fonctions : Backend = snake_case (Python PEP8), Frontend = camelCase (TypeScript/JavaScript standard) <!-- complété par codex -->
|
||||||
- Fichiers / dossiers : <A COMPLETER PAR AGENT>
|
- 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 : <A COMPLETER PAR AGENT>
|
- 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
|
## 2. Formatage
|
||||||
- Formatter / linter : <A COMPLETER PAR AGENT>
|
- Formatter / linter : Backend = ruff (format + lint) + mypy (types), Frontend = Prettier + ESLint + TypeScript strict <!-- complété par codex -->
|
||||||
- Règles principales : <A COMPLETER PAR AGENT>
|
- 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
|
## 3. Tests
|
||||||
- Nommage 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 : <A COMPLETER PAR AGENT>
|
- 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
|
## 4. Documentation
|
||||||
- Doc obligatoire : <A REMPLIR - PROJET> (exemple: README + ARCHITECTURE — a supprimer)
|
- Doc obligatoire : README.md (démarrage), ARCHITECTURE.md (structure technique), fichiers CONTEXT (backend/frontend), ADR pour décisions structurantes <!-- complété par codex -->
|
||||||
- ADR (Architecture Decision Record) : <A COMPLETER PAR AGENT>
|
- 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
|
## 1. Branches
|
||||||
- Convention de nommage : <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 : <A REMPLIR - PROJET> (exemple: main — a supprimer)
|
- Branches protégées : `main` (production-ready) <!-- complété par codex -->
|
||||||
- Politique de merge : <A COMPLETER PAR AGENT>
|
- Politique de merge : Squash merge vers main, fast-forward interdit, historique linéaire privilégié <!-- complété par codex -->
|
||||||
|
|
||||||
## 2. Commits
|
## 2. Commits
|
||||||
- Convention (ex: conventional commits) : <A COMPLETER PAR AGENT>
|
- Convention : Conventional Commits (feat/fix/docs/refactor/test/chore), format: `type(scope): message` en français <!-- complété par codex -->
|
||||||
- Granularité attendue : <A REMPLIR - PROJET> (exemple: 1 feature par PR — a supprimer)
|
- Granularité attendue : 1 commit par changement logique, 1 feature complète par PR avec tests associés <!-- complété par codex -->
|
||||||
|
|
||||||
## 3. Pull Requests
|
## 3. Pull Requests
|
||||||
- Template PR : <A COMPLETER PAR AGENT>
|
- Template PR : Titre court (<70 car), description avec ## Changements, ## Tests, ## Checklist, référence REQ-XXX <!-- complété par codex -->
|
||||||
- Relectures requises : <A REMPLIR - PROJET> (exemple: 1 review — a supprimer)
|
- Relectures requises : Aucune (projet solo), possibilité de review par Claude Code avant merge <!-- complété par codex -->
|
||||||
- Checklist obligatoire : `docs/PR_CHECKLIST.md`
|
- Checklist obligatoire : `docs/PR_CHECKLIST.md`
|
||||||
|
|
||||||
## 4. CI/CD
|
## 4. CI/CD
|
||||||
- Pipeline minimal (lint/test/build) : <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 : <A COMPLETER PAR AGENT>
|
- Vérifications bloquantes : Lint sans erreur, tests unitaires passent, build Docker réussit, couverture >70% sur nouveau code <!-- complété par codex -->
|
||||||
|
|
||||||
## 5. Releases
|
## 5. Releases
|
||||||
- Versioning (semver = versionnage sémantique) : <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 : <A COMPLETER PAR AGENT>
|
- 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`
|
- Release notes : `product/RELEASE_NOTES.md`
|
||||||
|
|
||||||
## 6. Qualité
|
## 6. Qualité
|
||||||
- Definition of Done (définition de terminé) : <A REMPLIR - PROJET> (exemple: tests + doc — 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 : <A COMPLETER PAR AGENT>
|
- 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 : <A REMPLIR - PROJET> (exemple: si impact sur l’API — a supprimer)
|
- Mises à jour doc : Obligatoire si changement API (OpenAPI), architecture (ADR), ou contrats (data_model.md) <!-- complété par codex -->
|
||||||
|
|
||||||
## 7. Hotfix / Urgence
|
## 7. Hotfix / Urgence
|
||||||
- Procédure : <A COMPLETER PAR AGENT>
|
- Procédure : Branche `hotfix/description` depuis main, fix minimal, tests rapides, merge direct main, tag patch version <!-- complété par codex -->
|
||||||
- Responsables : <A REMPLIR - PROJET> (exemple: lead dev — a supprimer)
|
- Responsables : Développeur principal (projet solo), pas de process complexe pour mono-utilisateur <!-- complété par codex -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,8 +55,3 @@ Il sert de référence aux agents et aux contributeurs.
|
|||||||
- Passerelle : 10.0.0.1
|
- 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
|
## Objectif du frontend
|
||||||
- Parcours utilisateur principaux : <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 : <A COMPLETER PAR AGENT>
|
- 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 : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
- 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
|
## Interfaces
|
||||||
- API consommées (API = Interface de Programmation) : <A COMPLETER PAR AGENT>
|
- 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 : <A COMPLETER PAR AGENT>
|
- Authentification/autorisation : Optionnelle, si activée = gestion session cookie automatique par navigateur, redirection login si 401 <!-- complété par codex -->
|
||||||
- Intégrations externes : <A REMPLIR - PROJET> (exemple: ERP existant — a supprimer)
|
- Intégrations externes : Aucune intégration externe, consomme uniquement le backend HomeStock <!-- complété par codex -->
|
||||||
|
|
||||||
## Architecture UI
|
## Architecture UI
|
||||||
- Framework : <A COMPLETER PAR AGENT>
|
- Framework : React 18+ avec TypeScript, Vite comme bundler et dev server <!-- complété par codex -->
|
||||||
- Structure des pages : <A COMPLETER PAR AGENT>
|
- 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 : <A COMPLETER PAR AGENT>
|
- 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 (bibliothèque de composants) : <A COMPLETER PAR AGENT>
|
- 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é
|
## Qualité & accessibilité
|
||||||
- Performance attendue : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
|
- 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é (a11y = accessibilité web) : <A COMPLETER PAR AGENT>
|
- Accessibilité : Accessibilité de base (ARIA labels, navigation clavier, contraste WCAG AA), pas de certification stricte WCAG AAA (usage personnel) <!-- complété par codex -->
|
||||||
- Tests (unitaires/E2E = tests de bout en bout) : <A COMPLETER PAR AGENT>
|
- 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
|
## Conventions
|
||||||
- Organisation du code : <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 : <A COMPLETER PAR AGENT>
|
- 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 : <A COMPLETER PAR AGENT>
|
- 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