generated from gilles/template-webapp
import ali
This commit is contained in:
@@ -1,18 +1,31 @@
|
|||||||
- [ ] ajout d'un bouton setting qui donne acces aux parametres du frontend et de backend (separer le 2)
|
- [x] ajout d'un bouton setting qui donne acces aux parametres du frontend et de backend (separer le 2)
|
||||||
- [ ] ajout du theme gruvbox dark vintage
|
- [x] ajout du theme gruvbox dark vintage
|
||||||
- [ ] ajout style icon material design ou fa
|
- [x] ajout style icon material design ou fa
|
||||||
- [ ] ajout image et thumbnail dans item
|
- [x] ajout image et thumbnail dans item
|
||||||
- [ ] ajout notice pdf dans item
|
- [x] ajout notice pdf dans item
|
||||||
- [ ] ajout url dans item
|
- [x] ajout url dans item
|
||||||
- [ ] ajout caracteristique dans item
|
- [x] ajout taille icone ui et taille icone objet dans setting frontend
|
||||||
- [ ] ajout status integre dans item
|
- [x] ajout taille police dans setting frontend
|
||||||
- [ ] ajout boutique pdf dans item
|
- [x] ajout caracteristique dans item
|
||||||
|
- [x] ajout status integre dans item
|
||||||
|
- [x] ajout boutique dans item
|
||||||
- [ ] ajout menu people pour gerer les pret
|
- [ ] ajout menu people pour gerer les pret
|
||||||
- [ ] ajout people si pret selectionné pdf dans item
|
- [ ] ajout people si pret selectionné
|
||||||
- [ ] popup ajout item plus large
|
- [x] popup ajout item plus large
|
||||||
- [ ] app responsive avec mode laptop et smartphone
|
- [x] 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
|
- [x] 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)
|
- [ ] 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 ( similaire localisation : piece, meuble, tiroir))
|
||||||
- [ ] import fichier json depuis un store ( status non assigné)
|
- [ ] import fichier json depuis un store ( status non assigné)
|
||||||
|
- [ ] ajout type equipement dans item ex: informatique: desktop, laptop; equipement informatique, acccessoire informatique, carte rpi, consommable ;electronique: composant, outillage, carte electronique; media: tv, tablet, smartphone, barre de son, ; domotique: zigbee, rf433, wifi, bluetooth, ; cuisine: cuisson, robot, froid, vaisselle => brainstorming ( similaire localisation : piece, meuble, tiroir)
|
||||||
|
- [x] ajout favicon judicieux
|
||||||
|
|
||||||
|
- [x] ajout boutique dans header (fonctionnement idem categorie)
|
||||||
|
- [ ] amelioration ajout abonnement dans header ( gerer les abonnement => faire un brainstorming)
|
||||||
|
- [ ] ajouter des filtres sur la pages objet pour trier par categorie, par localisation, par boutique
|
||||||
|
- [ ] ajouter un tri sur la pages objet par ordre croissant ou decroissant; tri aussi par prix par ordre croissant-decroissant
|
||||||
|
- [ ] ameliorer le bas de page pour naviguer plus rapidement entre les objets (Precedent, Page 5 sur 70, Suivant)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Documentation : https://docs.pydantic.dev/latest/concepts/pydantic_settings/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
@@ -72,7 +73,10 @@ class Settings(BaseSettings):
|
|||||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||||
|
|
||||||
# === Stockage fichiers ===
|
# === Stockage fichiers ===
|
||||||
UPLOAD_DIR: str = Field(default="./uploads", description="Répertoire des uploads")
|
UPLOAD_DIR: str = Field(
|
||||||
|
default="./uploads",
|
||||||
|
description="Répertoire des uploads",
|
||||||
|
)
|
||||||
MAX_UPLOAD_SIZE_MB: int = Field(
|
MAX_UPLOAD_SIZE_MB: int = Field(
|
||||||
default=50, description="Taille max des uploads en Mo"
|
default=50, description="Taille max des uploads en Mo"
|
||||||
)
|
)
|
||||||
@@ -91,6 +95,11 @@ class Settings(BaseSettings):
|
|||||||
"""Retourne la taille max en octets."""
|
"""Retourne la taille max en octets."""
|
||||||
return self.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
return self.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
@property
|
||||||
|
def upload_dir_path(self) -> Path:
|
||||||
|
"""Retourne le chemin du répertoire d'uploads comme Path."""
|
||||||
|
return Path(self.UPLOAD_DIR).resolve()
|
||||||
|
|
||||||
# === Recherche ===
|
# === Recherche ===
|
||||||
SEARCH_MIN_QUERY_LENGTH: int = Field(
|
SEARCH_MIN_QUERY_LENGTH: int = Field(
|
||||||
default=2, description="Longueur minimale des requêtes de recherche"
|
default=2, description="Longueur minimale des requêtes de recherche"
|
||||||
|
|||||||
@@ -83,13 +83,85 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
"""Initialise la base de données.
|
"""Initialise la base de données.
|
||||||
|
|
||||||
Crée toutes les tables définies dans les modèles.
|
Crée toutes les tables définies dans les modèles + FTS5 pour la recherche.
|
||||||
À utiliser uniquement en développement ou pour les tests.
|
À utiliser uniquement en développement ou pour les tests.
|
||||||
En production, utiliser Alembic pour les migrations.
|
En production, utiliser Alembic pour les migrations.
|
||||||
"""
|
"""
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
# Initialisation de la recherche full-text FTS5
|
||||||
|
await init_fts5()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_fts5() -> None:
|
||||||
|
"""Crée la table virtuelle FTS5 et les triggers de synchronisation.
|
||||||
|
|
||||||
|
FTS5 permet une recherche full-text performante sur les items.
|
||||||
|
Les triggers maintiennent l'index à jour automatiquement.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# Supprimer l'ancienne table FTS5 si elle existe (pour recréation propre)
|
||||||
|
await conn.execute(text("DROP TRIGGER IF EXISTS fts_items_insert"))
|
||||||
|
await conn.execute(text("DROP TRIGGER IF EXISTS fts_items_update"))
|
||||||
|
await conn.execute(text("DROP TRIGGER IF EXISTS fts_items_delete"))
|
||||||
|
await conn.execute(text("DROP TABLE IF EXISTS fts_items"))
|
||||||
|
|
||||||
|
# Table virtuelle FTS5 indexant nom, description, marque, modèle, notes
|
||||||
|
await conn.execute(text("""
|
||||||
|
CREATE VIRTUAL TABLE fts_items USING fts5(
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
brand,
|
||||||
|
model,
|
||||||
|
notes,
|
||||||
|
serial_number,
|
||||||
|
content='items',
|
||||||
|
content_rowid='id',
|
||||||
|
tokenize='unicode61 remove_diacritics 2'
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Trigger INSERT : ajouter dans FTS5 quand un item est créé
|
||||||
|
await conn.execute(text("""
|
||||||
|
CREATE TRIGGER fts_items_insert
|
||||||
|
AFTER INSERT ON items
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO fts_items(rowid, name, description, brand, model, notes, serial_number)
|
||||||
|
VALUES (NEW.id, NEW.name, NEW.description, NEW.brand, NEW.model, NEW.notes, NEW.serial_number);
|
||||||
|
END
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Trigger UPDATE : mettre à jour FTS5 quand un item est modifié
|
||||||
|
await conn.execute(text("""
|
||||||
|
CREATE TRIGGER fts_items_update
|
||||||
|
AFTER UPDATE ON items
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO fts_items(fts_items, rowid, name, description, brand, model, notes, serial_number)
|
||||||
|
VALUES ('delete', OLD.id, OLD.name, OLD.description, OLD.brand, OLD.model, OLD.notes, OLD.serial_number);
|
||||||
|
INSERT INTO fts_items(rowid, name, description, brand, model, notes, serial_number)
|
||||||
|
VALUES (NEW.id, NEW.name, NEW.description, NEW.brand, NEW.model, NEW.notes, NEW.serial_number);
|
||||||
|
END
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Trigger DELETE : supprimer de FTS5 quand un item est supprimé
|
||||||
|
await conn.execute(text("""
|
||||||
|
CREATE TRIGGER fts_items_delete
|
||||||
|
AFTER DELETE ON items
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO fts_items(fts_items, rowid, name, description, brand, model, notes, serial_number)
|
||||||
|
VALUES ('delete', OLD.id, OLD.name, OLD.description, OLD.brand, OLD.model, OLD.notes, OLD.serial_number);
|
||||||
|
END
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Remplir FTS5 avec les données existantes
|
||||||
|
await conn.execute(text("""
|
||||||
|
INSERT INTO fts_items(rowid, name, description, brand, model, notes, serial_number)
|
||||||
|
SELECT id, name, description, brand, model, notes, serial_number FROM items
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
async def close_db() -> None:
|
async def close_db() -> None:
|
||||||
"""Ferme proprement les connexions à la base de données.
|
"""Ferme proprement les connexions à la base de données.
|
||||||
|
|||||||
@@ -107,11 +107,14 @@ async def global_exception_handler(request: Any, exc: Exception) -> JSONResponse
|
|||||||
|
|
||||||
|
|
||||||
# === Enregistrement des routers ===
|
# === Enregistrement des routers ===
|
||||||
from app.routers import categories_router, items_router, locations_router
|
from app.routers import categories_router, documents_router, import_router, items_router, locations_router, shops_router
|
||||||
|
|
||||||
app.include_router(categories_router, prefix="/api/v1")
|
app.include_router(categories_router, prefix="/api/v1")
|
||||||
app.include_router(locations_router, prefix="/api/v1")
|
app.include_router(locations_router, prefix="/api/v1")
|
||||||
app.include_router(items_router, prefix="/api/v1")
|
app.include_router(items_router, prefix="/api/v1")
|
||||||
|
app.include_router(documents_router, prefix="/api/v1")
|
||||||
|
app.include_router(shops_router, prefix="/api/v1")
|
||||||
|
app.include_router(import_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.models.category import Category
|
|||||||
from app.models.document import Document, DocumentType
|
from app.models.document import Document, DocumentType
|
||||||
from app.models.item import Item, ItemStatus
|
from app.models.item import Item, ItemStatus
|
||||||
from app.models.location import Location, LocationType
|
from app.models.location import Location, LocationType
|
||||||
|
from app.models.shop import Shop
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Category",
|
"Category",
|
||||||
@@ -16,4 +17,5 @@ __all__ = [
|
|||||||
"ItemStatus",
|
"ItemStatus",
|
||||||
"Document",
|
"Document",
|
||||||
"DocumentType",
|
"DocumentType",
|
||||||
|
"Shop",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from datetime import date, datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Integer, Numeric, String, Text
|
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Integer, JSON, Numeric, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
|
|||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.document import Document
|
from app.models.document import Document
|
||||||
from app.models.location import Location
|
from app.models.location import Location
|
||||||
|
from app.models.shop import Shop
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ class ItemStatus(str, enum.Enum):
|
|||||||
|
|
||||||
IN_STOCK = "in_stock" # En stock (non utilisé)
|
IN_STOCK = "in_stock" # En stock (non utilisé)
|
||||||
IN_USE = "in_use" # En cours d'utilisation
|
IN_USE = "in_use" # En cours d'utilisation
|
||||||
|
INTEGRATED = "integrated" # Intégré dans un autre objet
|
||||||
BROKEN = "broken" # Cassé/HS
|
BROKEN = "broken" # Cassé/HS
|
||||||
SOLD = "sold" # Vendu
|
SOLD = "sold" # Vendu
|
||||||
LENT = "lent" # Prêté
|
LENT = "lent" # Prêté
|
||||||
@@ -79,6 +81,9 @@ class Item(Base):
|
|||||||
price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
|
price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
|
||||||
purchase_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
purchase_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
|
||||||
|
# Caractéristiques techniques (clé-valeur, ex: {"RAM": "16 Go", "CPU": "i7"})
|
||||||
|
characteristics: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=None)
|
||||||
|
|
||||||
# Notes
|
# Notes
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
@@ -89,6 +94,12 @@ class Item(Base):
|
|||||||
location_id: Mapped[int] = mapped_column(
|
location_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("locations.id", ondelete="RESTRICT"), nullable=False, index=True
|
Integer, ForeignKey("locations.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
|
parent_item_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("items.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
shop_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("shops.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
@@ -107,6 +118,13 @@ class Item(Base):
|
|||||||
documents: Mapped[list["Document"]] = relationship(
|
documents: Mapped[list["Document"]] = relationship(
|
||||||
"Document", back_populates="item", cascade="all, delete-orphan"
|
"Document", back_populates="item", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
shop: Mapped["Shop | None"] = relationship("Shop", back_populates="items")
|
||||||
|
parent_item: Mapped["Item | None"] = relationship(
|
||||||
|
"Item", remote_side=[id], foreign_keys=[parent_item_id], back_populates="children"
|
||||||
|
)
|
||||||
|
children: Mapped[list["Item"]] = relationship(
|
||||||
|
"Item", back_populates="parent_item", foreign_keys=[parent_item_id]
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""Représentation string de l'objet."""
|
"""Représentation string de l'objet."""
|
||||||
|
|||||||
56
backend/app/models/shop.py
Normal file
56
backend/app/models/shop.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Modèle SQLAlchemy pour les boutiques/magasins.
|
||||||
|
|
||||||
|
Les boutiques représentent les lieux d'achat des objets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 Shop(Base):
|
||||||
|
"""Boutique ou magasin.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Identifiant unique auto-incrémenté
|
||||||
|
name: Nom de la boutique (ex: "Amazon", "Leroy Merlin")
|
||||||
|
description: Description optionnelle
|
||||||
|
url: URL du site web de la boutique
|
||||||
|
address: Adresse physique optionnelle
|
||||||
|
created_at: Date/heure de création (auto)
|
||||||
|
updated_at: Date/heure de dernière modification (auto)
|
||||||
|
items: Relation vers les objets achetés dans cette boutique
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "shops"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), unique=True, nullable=False, index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
items: Mapped[list["Item"]] = relationship(
|
||||||
|
"Item", back_populates="shop"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Shop(id={self.id}, name='{self.name}')>"
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import or_, select
|
from sqlalchemy import or_, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ class ItemRepository(BaseRepository[Item]):
|
|||||||
selectinload(Item.category),
|
selectinload(Item.category),
|
||||||
selectinload(Item.location),
|
selectinload(Item.location),
|
||||||
selectinload(Item.documents),
|
selectinload(Item.documents),
|
||||||
|
selectinload(Item.parent_item),
|
||||||
)
|
)
|
||||||
.where(Item.id == id)
|
.where(Item.id == id)
|
||||||
)
|
)
|
||||||
@@ -55,6 +56,8 @@ class ItemRepository(BaseRepository[Item]):
|
|||||||
.options(
|
.options(
|
||||||
selectinload(Item.category),
|
selectinload(Item.category),
|
||||||
selectinload(Item.location),
|
selectinload(Item.location),
|
||||||
|
selectinload(Item.documents),
|
||||||
|
selectinload(Item.parent_item),
|
||||||
)
|
)
|
||||||
.offset(skip)
|
.offset(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -91,20 +94,24 @@ class ItemRepository(BaseRepository[Item]):
|
|||||||
stmt = select(Item).options(
|
stmt = select(Item).options(
|
||||||
selectinload(Item.category),
|
selectinload(Item.category),
|
||||||
selectinload(Item.location),
|
selectinload(Item.location),
|
||||||
|
selectinload(Item.documents),
|
||||||
|
selectinload(Item.parent_item),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Recherche textuelle
|
# Recherche full-text via FTS5
|
||||||
if query:
|
if query:
|
||||||
search_term = f"%{query}%"
|
# Échapper les caractères spéciaux FTS5 et ajouter le préfixe *
|
||||||
stmt = stmt.where(
|
safe_query = query.replace('"', '""').strip()
|
||||||
or_(
|
if safe_query:
|
||||||
Item.name.ilike(search_term),
|
# Recherche par préfixe pour résultats en temps réel
|
||||||
Item.description.ilike(search_term),
|
fts_terms = " ".join(f'"{word}"*' for word in safe_query.split() if word)
|
||||||
Item.brand.ilike(search_term),
|
stmt = stmt.where(
|
||||||
Item.model.ilike(search_term),
|
Item.id.in_(
|
||||||
Item.notes.ilike(search_term),
|
select(text("rowid")).select_from(text("fts_items")).where(
|
||||||
)
|
text("fts_items MATCH :fts_query")
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
).params(fts_query=fts_terms)
|
||||||
|
|
||||||
# Filtres
|
# Filtres
|
||||||
if category_id is not None:
|
if category_id is not None:
|
||||||
@@ -141,16 +148,16 @@ class ItemRepository(BaseRepository[Item]):
|
|||||||
stmt = select(func.count(Item.id))
|
stmt = select(func.count(Item.id))
|
||||||
|
|
||||||
if query:
|
if query:
|
||||||
search_term = f"%{query}%"
|
safe_query = query.replace('"', '""').strip()
|
||||||
stmt = stmt.where(
|
if safe_query:
|
||||||
or_(
|
fts_terms = " ".join(f'"{word}"*' for word in safe_query.split() if word)
|
||||||
Item.name.ilike(search_term),
|
stmt = stmt.where(
|
||||||
Item.description.ilike(search_term),
|
Item.id.in_(
|
||||||
Item.brand.ilike(search_term),
|
select(text("rowid")).select_from(text("fts_items")).where(
|
||||||
Item.model.ilike(search_term),
|
text("fts_items MATCH :fts_query")
|
||||||
Item.notes.ilike(search_term),
|
)
|
||||||
)
|
)
|
||||||
)
|
).params(fts_query=fts_terms)
|
||||||
|
|
||||||
if category_id is not None:
|
if category_id is not None:
|
||||||
stmt = stmt.where(Item.category_id == category_id)
|
stmt = stmt.where(Item.category_id == category_id)
|
||||||
|
|||||||
50
backend/app/repositories/shop.py
Normal file
50
backend/app/repositories/shop.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Repository pour les boutiques."""
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.shop import Shop
|
||||||
|
from app.repositories.base import BaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class ShopRepository(BaseRepository[Shop]):
|
||||||
|
"""Repository pour les opérations sur les boutiques."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
super().__init__(Shop, db)
|
||||||
|
|
||||||
|
async def get_by_name(self, name: str) -> Shop | None:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Shop).where(Shop.name == name)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_with_item_count(self, id: int) -> tuple[Shop, int] | None:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Shop).options(selectinload(Shop.items)).where(Shop.id == id)
|
||||||
|
)
|
||||||
|
shop = result.scalar_one_or_none()
|
||||||
|
if shop is None:
|
||||||
|
return None
|
||||||
|
return shop, len(shop.items)
|
||||||
|
|
||||||
|
async def get_all_with_item_count(
|
||||||
|
self, skip: int = 0, limit: int = 100
|
||||||
|
) -> list[tuple[Shop, int]]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Shop)
|
||||||
|
.options(selectinload(Shop.items))
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.order_by(Shop.name)
|
||||||
|
)
|
||||||
|
shops = result.scalars().all()
|
||||||
|
return [(shop, len(shop.items)) for shop in shops]
|
||||||
|
|
||||||
|
async def name_exists(self, name: str, exclude_id: int | None = None) -> bool:
|
||||||
|
query = select(func.count(Shop.id)).where(Shop.name == name)
|
||||||
|
if exclude_id is not None:
|
||||||
|
query = query.where(Shop.id != exclude_id)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return result.scalar_one() > 0
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
"""Package des routers API."""
|
"""Package des routers API."""
|
||||||
|
|
||||||
from app.routers.categories import router as categories_router
|
from app.routers.categories import router as categories_router
|
||||||
|
from app.routers.documents import router as documents_router
|
||||||
|
from app.routers.import_csv import router as import_router
|
||||||
from app.routers.items import router as items_router
|
from app.routers.items import router as items_router
|
||||||
from app.routers.locations import router as locations_router
|
from app.routers.locations import router as locations_router
|
||||||
|
from app.routers.shops import router as shops_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"categories_router",
|
"categories_router",
|
||||||
|
"documents_router",
|
||||||
|
"import_router",
|
||||||
"locations_router",
|
"locations_router",
|
||||||
"items_router",
|
"items_router",
|
||||||
|
"shops_router",
|
||||||
]
|
]
|
||||||
|
|||||||
249
backend/app/routers/documents.py
Normal file
249
backend/app/routers/documents.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"""Router pour les documents (upload, téléchargement, suppression)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.document import Document, DocumentType
|
||||||
|
from app.models.item import Item
|
||||||
|
from app.schemas.document import DocumentResponse, DocumentUpdate, DocumentUploadResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||||
|
|
||||||
|
# Types MIME autorisés
|
||||||
|
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||||
|
ALLOWED_PDF_TYPES = {"application/pdf"}
|
||||||
|
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_PDF_TYPES
|
||||||
|
|
||||||
|
# Taille max : 10 Mo
|
||||||
|
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload_path(doc_type: DocumentType, filename: str) -> Path:
|
||||||
|
"""Retourne le chemin complet pour un fichier uploadé."""
|
||||||
|
subdir = "photos" if doc_type == DocumentType.PHOTO else "documents"
|
||||||
|
return settings.upload_dir_path / subdir / filename
|
||||||
|
|
||||||
|
|
||||||
|
def generate_unique_filename(original_name: str) -> str:
|
||||||
|
"""Génère un nom de fichier unique avec UUID."""
|
||||||
|
ext = Path(original_name).suffix.lower()
|
||||||
|
return f"{uuid.uuid4()}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_item_exists(session: AsyncSession, item_id: int) -> Item:
|
||||||
|
"""Vérifie que l'item existe."""
|
||||||
|
item = await session.get(Item, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Item {item_id} non trouvé"
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=DocumentUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_document(
|
||||||
|
file: Annotated[UploadFile, File(description="Fichier à uploader")],
|
||||||
|
item_id: Annotated[int, Form(description="ID de l'objet associé")],
|
||||||
|
doc_type: Annotated[DocumentType, Form(description="Type de document")],
|
||||||
|
description: Annotated[str | None, Form(description="Description optionnelle")] = None,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Upload un document et l'associe à un item.
|
||||||
|
|
||||||
|
- Accepte images (JPEG, PNG, GIF, WebP) et PDF
|
||||||
|
- Taille max : 10 Mo
|
||||||
|
- Génère un nom unique pour éviter les conflits
|
||||||
|
"""
|
||||||
|
# Vérifier que l'item existe
|
||||||
|
await validate_item_exists(session, item_id)
|
||||||
|
|
||||||
|
# Vérifier le type MIME
|
||||||
|
if file.content_type not in ALLOWED_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Type de fichier non autorisé : {file.content_type}. "
|
||||||
|
f"Types acceptés : images (JPEG, PNG, GIF, WebP) et PDF"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Vérifier si le type correspond au fichier
|
||||||
|
if doc_type == DocumentType.PHOTO and file.content_type not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Le type 'photo' nécessite un fichier image"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lire le contenu du fichier
|
||||||
|
content = await file.read()
|
||||||
|
file_size = len(content)
|
||||||
|
|
||||||
|
# Vérifier la taille
|
||||||
|
if file_size > MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Fichier trop volumineux ({file_size / 1024 / 1024:.1f} Mo). "
|
||||||
|
f"Taille max : {MAX_FILE_SIZE / 1024 / 1024:.0f} Mo"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Générer un nom unique
|
||||||
|
unique_filename = generate_unique_filename(file.filename or "document")
|
||||||
|
|
||||||
|
# Déterminer le chemin de stockage
|
||||||
|
file_path = get_upload_path(doc_type, unique_filename)
|
||||||
|
|
||||||
|
# Créer le répertoire si nécessaire
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Sauvegarder le fichier
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# Créer l'entrée en base
|
||||||
|
relative_path = str(file_path.relative_to(settings.upload_dir_path.parent))
|
||||||
|
|
||||||
|
document = Document(
|
||||||
|
filename=unique_filename,
|
||||||
|
original_name=file.filename or "document",
|
||||||
|
type=doc_type,
|
||||||
|
mime_type=file.content_type or "application/octet-stream",
|
||||||
|
size_bytes=file_size,
|
||||||
|
file_path=relative_path,
|
||||||
|
description=description,
|
||||||
|
item_id=item_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(document)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
|
||||||
|
return DocumentUploadResponse(
|
||||||
|
id=document.id,
|
||||||
|
filename=document.filename,
|
||||||
|
original_name=document.original_name,
|
||||||
|
type=document.type,
|
||||||
|
mime_type=document.mime_type,
|
||||||
|
size_bytes=document.size_bytes,
|
||||||
|
message="Document uploadé avec succès"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/item/{item_id}", response_model=list[DocumentResponse])
|
||||||
|
async def get_item_documents(
|
||||||
|
item_id: int,
|
||||||
|
doc_type: DocumentType | None = None,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Récupère tous les documents d'un item."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
await validate_item_exists(session, item_id)
|
||||||
|
|
||||||
|
query = select(Document).where(Document.item_id == item_id)
|
||||||
|
|
||||||
|
if doc_type:
|
||||||
|
query = query.where(Document.type == doc_type)
|
||||||
|
|
||||||
|
query = query.order_by(Document.created_at.desc())
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
documents = result.scalars().all()
|
||||||
|
|
||||||
|
return [DocumentResponse.model_validate(doc) for doc in documents]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{document_id}", response_model=DocumentResponse)
|
||||||
|
async def get_document(
|
||||||
|
document_id: int,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Récupère les métadonnées d'un document."""
|
||||||
|
document = await session.get(Document, document_id)
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Document non trouvé"
|
||||||
|
)
|
||||||
|
return DocumentResponse.model_validate(document)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{document_id}/download")
|
||||||
|
async def download_document(
|
||||||
|
document_id: int,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Télécharge un document."""
|
||||||
|
document = await session.get(Document, document_id)
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Document non trouvé"
|
||||||
|
)
|
||||||
|
|
||||||
|
file_path = settings.upload_dir_path.parent / document.file_path
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Fichier non trouvé sur le disque"
|
||||||
|
)
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
filename=document.original_name,
|
||||||
|
media_type=document.mime_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{document_id}", response_model=DocumentResponse)
|
||||||
|
async def update_document(
|
||||||
|
document_id: int,
|
||||||
|
data: DocumentUpdate,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Met à jour les métadonnées d'un document."""
|
||||||
|
document = await session.get(Document, document_id)
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Document non trouvé"
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(document, field, value)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
|
||||||
|
return DocumentResponse.model_validate(document)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_document(
|
||||||
|
document_id: int,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Supprime un document (fichier + entrée en base)."""
|
||||||
|
document = await session.get(Document, document_id)
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Document non trouvé"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Supprimer le fichier
|
||||||
|
file_path = settings.upload_dir_path.parent / document.file_path
|
||||||
|
if file_path.exists():
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Supprimer l'entrée en base
|
||||||
|
await session.delete(document)
|
||||||
|
await session.commit()
|
||||||
344
backend/app/routers/import_csv.py
Normal file
344
backend/app/routers/import_csv.py
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
"""Router pour l'import de fichiers CSV (AliExpress)."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.item import Item, ItemStatus
|
||||||
|
from app.models.location import Location, LocationType
|
||||||
|
from app.repositories.item import ItemRepository
|
||||||
|
from app.repositories.location import LocationRepository
|
||||||
|
from app.repositories.shop import ShopRepository
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/import", tags=["Import"])
|
||||||
|
|
||||||
|
# Mapping des mois français → numéro
|
||||||
|
MOIS_FR: dict[str, int] = {
|
||||||
|
"janv.": 1, "févr.": 2, "mars": 3, "avr.": 4,
|
||||||
|
"mai": 5, "juin": 6, "juil.": 7, "août": 8,
|
||||||
|
"sept.": 9, "oct.": 10, "nov.": 11, "déc.": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date_fr(date_str: str) -> str | None:
|
||||||
|
"""Parse une date au format français AliExpress ('5 sept. 2025') → '2025-09-05'."""
|
||||||
|
if not date_str or not date_str.strip():
|
||||||
|
return None
|
||||||
|
date_str = date_str.strip()
|
||||||
|
# Format attendu : "5 sept. 2025"
|
||||||
|
match = re.match(r"(\d{1,2})\s+(\S+)\s+(\d{4})", date_str)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
day, month_str, year = match.groups()
|
||||||
|
month = MOIS_FR.get(month_str.lower())
|
||||||
|
if month is None:
|
||||||
|
# Essai avec le mois tel quel (ex: "mars" sans point)
|
||||||
|
month = MOIS_FR.get(month_str.lower().rstrip("."))
|
||||||
|
if month is None:
|
||||||
|
return None
|
||||||
|
return f"{year}-{month:02d}-{int(day):02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_price(price_str: str) -> float | None:
|
||||||
|
"""Parse un prix depuis le CSV ('37.19' ou '4,59') → float."""
|
||||||
|
if not price_str or not price_str.strip():
|
||||||
|
return None
|
||||||
|
cleaned = price_str.strip().replace(",", ".")
|
||||||
|
try:
|
||||||
|
val = float(cleaned)
|
||||||
|
return val if val > 0 else None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_quantity(qty_str: str) -> int:
|
||||||
|
"""Parse une quantité ('1.00' ou '2') → int."""
|
||||||
|
if not qty_str or not qty_str.strip():
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
return max(1, int(float(qty_str.strip())))
|
||||||
|
except ValueError:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def fix_url(url: str) -> str | None:
|
||||||
|
"""Corrige les URLs AliExpress (ajoute https: si nécessaire)."""
|
||||||
|
if not url or not url.strip():
|
||||||
|
return None
|
||||||
|
url = url.strip()
|
||||||
|
if url.startswith("//"):
|
||||||
|
return f"https:{url}"
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def parse_attributes(attr_str: str) -> dict[str, str] | None:
|
||||||
|
"""Parse les attributs AliExpress en dictionnaire."""
|
||||||
|
if not attr_str or not attr_str.strip():
|
||||||
|
return None
|
||||||
|
parts = [p.strip() for p in attr_str.split(",") if p.strip()]
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
result[f"attribut_{i + 1}"] = part
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Schémas de réponse
|
||||||
|
class ImportPreviewItem(BaseModel):
|
||||||
|
"""Un item parsé depuis le CSV, prêt pour la preview."""
|
||||||
|
index: int
|
||||||
|
name: str
|
||||||
|
price: float | None = None
|
||||||
|
quantity: int = 1
|
||||||
|
purchase_date: str | None = None
|
||||||
|
seller_name: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
image_url: str | None = None
|
||||||
|
attributes: dict[str, str] | None = None
|
||||||
|
order_id: str | None = None
|
||||||
|
order_status: str | None = None
|
||||||
|
total_price: float | None = None
|
||||||
|
is_duplicate: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ImportPreviewResponse(BaseModel):
|
||||||
|
"""Réponse de preview d'import."""
|
||||||
|
items: list[ImportPreviewItem]
|
||||||
|
total_items: int
|
||||||
|
errors: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ImportResultResponse(BaseModel):
|
||||||
|
"""Réponse après import effectif."""
|
||||||
|
items_created: int
|
||||||
|
shops_created: int
|
||||||
|
errors: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_aliexpress_csv(content: str) -> tuple[list[ImportPreviewItem], list[str]]:
|
||||||
|
"""Parse le CSV AliExpress et retourne les items + erreurs."""
|
||||||
|
items: list[ImportPreviewItem] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# Supprimer le BOM UTF-8 si présent
|
||||||
|
if content.startswith("\ufeff"):
|
||||||
|
content = content[1:]
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(content))
|
||||||
|
|
||||||
|
# Collecter les lignes totaux par order_id pour récupérer le prix réel
|
||||||
|
totals_by_order: dict[str, float] = {}
|
||||||
|
all_rows: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
for row in reader:
|
||||||
|
all_rows.append(row)
|
||||||
|
order_id = row.get("Order Id", "").strip()
|
||||||
|
item_title = row.get("Item title", "").strip()
|
||||||
|
total_price_str = row.get("Total price", "").strip()
|
||||||
|
|
||||||
|
# Ligne totaux : pas de titre d'item mais un total
|
||||||
|
if not item_title and total_price_str:
|
||||||
|
price = parse_price(total_price_str)
|
||||||
|
if price and order_id:
|
||||||
|
totals_by_order[order_id] = price
|
||||||
|
|
||||||
|
# Deuxième passe : extraire les items
|
||||||
|
index = 0
|
||||||
|
for row in all_rows:
|
||||||
|
item_title = row.get("Item title", "").strip()
|
||||||
|
if not item_title:
|
||||||
|
continue # Ignorer les lignes totaux
|
||||||
|
|
||||||
|
order_id = row.get("Order Id", "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
item = ImportPreviewItem(
|
||||||
|
index=index,
|
||||||
|
name=item_title,
|
||||||
|
price=parse_price(row.get("Item price", "")),
|
||||||
|
quantity=parse_quantity(row.get("Item quantity", "")),
|
||||||
|
purchase_date=parse_date_fr(row.get("Order date", "")),
|
||||||
|
seller_name=row.get("Store Name", "").strip() or None,
|
||||||
|
url=fix_url(row.get("Item product link", "")),
|
||||||
|
image_url=fix_url(row.get("Item image url", "")),
|
||||||
|
attributes=parse_attributes(row.get("Item attributes", "")),
|
||||||
|
order_id=order_id or None,
|
||||||
|
order_status=row.get("Order Status", "").strip() or None,
|
||||||
|
total_price=totals_by_order.get(order_id),
|
||||||
|
)
|
||||||
|
items.append(item)
|
||||||
|
index += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Ligne {index}: {e}")
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
return items, errors
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/csv/aliexpress/preview",
|
||||||
|
response_model=ImportPreviewResponse,
|
||||||
|
summary="Prévisualiser un import CSV AliExpress",
|
||||||
|
)
|
||||||
|
async def preview_aliexpress_csv(
|
||||||
|
file: Annotated[UploadFile, File(description="Fichier CSV AliExpress")],
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
) -> ImportPreviewResponse:
|
||||||
|
"""Parse le CSV et retourne une preview des items à importer."""
|
||||||
|
if not file.filename or not file.filename.lower().endswith(".csv"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Le fichier doit être un CSV (.csv)",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = content.decode("latin-1")
|
||||||
|
|
||||||
|
items, errors = parse_aliexpress_csv(text)
|
||||||
|
|
||||||
|
# Détecter les doublons par URL
|
||||||
|
for item in items:
|
||||||
|
if item.url:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Item.id).where(Item.url == item.url).limit(1)
|
||||||
|
)
|
||||||
|
if result.first():
|
||||||
|
item.is_duplicate = True
|
||||||
|
|
||||||
|
return ImportPreviewResponse(
|
||||||
|
items=items,
|
||||||
|
total_items=len(items),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/csv/aliexpress/import",
|
||||||
|
response_model=ImportResultResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Importer les items depuis un CSV AliExpress",
|
||||||
|
)
|
||||||
|
async def import_aliexpress_csv(
|
||||||
|
file: Annotated[UploadFile, File(description="Fichier CSV AliExpress")],
|
||||||
|
category_id: Annotated[int, Form(description="Catégorie par défaut")],
|
||||||
|
item_status: Annotated[str, Form(description="Statut par défaut")] = "in_stock",
|
||||||
|
selected_indices: Annotated[str, Form(description="Indices des items à importer (virgules)")] = "",
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
) -> ImportResultResponse:
|
||||||
|
"""Importe les items sélectionnés depuis le CSV."""
|
||||||
|
if not file.filename or not file.filename.lower().endswith(".csv"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Le fichier doit être un CSV (.csv)",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = content.decode("latin-1")
|
||||||
|
|
||||||
|
items, parse_errors = parse_aliexpress_csv(text)
|
||||||
|
|
||||||
|
# Filtrer par indices sélectionnés
|
||||||
|
if selected_indices.strip():
|
||||||
|
try:
|
||||||
|
selected = set(int(i.strip()) for i in selected_indices.split(",") if i.strip())
|
||||||
|
items = [item for item in items if item.index in selected]
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Format d'indices invalide",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Valider le statut
|
||||||
|
try:
|
||||||
|
status_enum = ItemStatus(item_status)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Statut invalide : {item_status}",
|
||||||
|
)
|
||||||
|
|
||||||
|
item_repo = ItemRepository(session)
|
||||||
|
shop_repo = ShopRepository(session)
|
||||||
|
errors: list[str] = []
|
||||||
|
items_created = 0
|
||||||
|
shop_created = False
|
||||||
|
|
||||||
|
# Résoudre ou créer la boutique unique "AliExpress"
|
||||||
|
aliexpress_shop = await shop_repo.get_by_name("AliExpress")
|
||||||
|
if not aliexpress_shop:
|
||||||
|
aliexpress_shop = await shop_repo.create(
|
||||||
|
name="AliExpress",
|
||||||
|
url="https://www.aliexpress.com",
|
||||||
|
)
|
||||||
|
shop_created = True
|
||||||
|
shop_id = aliexpress_shop.id
|
||||||
|
|
||||||
|
# Résoudre ou créer l'emplacement "Non assigné"
|
||||||
|
loc_result = await session.execute(
|
||||||
|
select(Location).where(Location.name == "Non assigné")
|
||||||
|
)
|
||||||
|
non_assigne_loc = loc_result.scalar_one_or_none()
|
||||||
|
if not non_assigne_loc:
|
||||||
|
loc_repo = LocationRepository(session)
|
||||||
|
non_assigne_loc = await loc_repo.create_with_path(
|
||||||
|
name="Non assigné",
|
||||||
|
type=LocationType.ROOM,
|
||||||
|
)
|
||||||
|
location_id = non_assigne_loc.id
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
# Fusionner le nom du vendeur dans les caractéristiques
|
||||||
|
characteristics = item.attributes or {}
|
||||||
|
if item.seller_name:
|
||||||
|
characteristics["vendeur"] = item.seller_name
|
||||||
|
|
||||||
|
# Convertir la date string en objet date Python
|
||||||
|
purchase_date_obj = None
|
||||||
|
if item.purchase_date:
|
||||||
|
try:
|
||||||
|
purchase_date_obj = date.fromisoformat(item.purchase_date)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Créer l'item
|
||||||
|
item_data = {
|
||||||
|
"name": item.name,
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"status": status_enum,
|
||||||
|
"price": item.price,
|
||||||
|
"purchase_date": purchase_date_obj,
|
||||||
|
"url": item.url,
|
||||||
|
"characteristics": characteristics or None,
|
||||||
|
"notes": f"Commande AliExpress #{item.order_id}" if item.order_id else None,
|
||||||
|
"category_id": category_id,
|
||||||
|
"location_id": location_id,
|
||||||
|
"shop_id": shop_id,
|
||||||
|
}
|
||||||
|
await item_repo.create(**item_data)
|
||||||
|
items_created += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{item.name}: {e}")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return ImportResultResponse(
|
||||||
|
items_created=items_created,
|
||||||
|
shops_created=1 if shop_created else 0,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
163
backend/app/routers/shops.py
Normal file
163
backend/app/routers/shops.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""Router API pour les boutiques."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.repositories.shop import ShopRepository
|
||||||
|
from app.schemas.common import PaginatedResponse, SuccessResponse
|
||||||
|
from app.schemas.shop import (
|
||||||
|
ShopCreate,
|
||||||
|
ShopResponse,
|
||||||
|
ShopUpdate,
|
||||||
|
ShopWithItemCount,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/shops", tags=["Shops"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=PaginatedResponse[ShopWithItemCount])
|
||||||
|
async def list_shops(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> PaginatedResponse[ShopWithItemCount]:
|
||||||
|
"""Liste toutes les boutiques avec le nombre d'objets."""
|
||||||
|
repo = ShopRepository(db)
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
|
||||||
|
shops_with_count = await repo.get_all_with_item_count(skip=skip, limit=page_size)
|
||||||
|
total = await repo.count()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
ShopWithItemCount(
|
||||||
|
id=shop.id,
|
||||||
|
name=shop.name,
|
||||||
|
description=shop.description,
|
||||||
|
url=shop.url,
|
||||||
|
address=shop.address,
|
||||||
|
created_at=shop.created_at,
|
||||||
|
updated_at=shop.updated_at,
|
||||||
|
item_count=count,
|
||||||
|
)
|
||||||
|
for shop, count in shops_with_count
|
||||||
|
]
|
||||||
|
|
||||||
|
return PaginatedResponse.create(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shop_id}", response_model=ShopWithItemCount)
|
||||||
|
async def get_shop(
|
||||||
|
shop_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ShopWithItemCount:
|
||||||
|
"""Récupère une boutique par son ID."""
|
||||||
|
repo = ShopRepository(db)
|
||||||
|
result = await repo.get_with_item_count(shop_id)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Boutique {shop_id} non trouvée",
|
||||||
|
)
|
||||||
|
|
||||||
|
shop, item_count = result
|
||||||
|
return ShopWithItemCount(
|
||||||
|
id=shop.id,
|
||||||
|
name=shop.name,
|
||||||
|
description=shop.description,
|
||||||
|
url=shop.url,
|
||||||
|
address=shop.address,
|
||||||
|
created_at=shop.created_at,
|
||||||
|
updated_at=shop.updated_at,
|
||||||
|
item_count=item_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ShopResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_shop(
|
||||||
|
data: ShopCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ShopResponse:
|
||||||
|
"""Crée une nouvelle boutique."""
|
||||||
|
repo = ShopRepository(db)
|
||||||
|
|
||||||
|
if await repo.name_exists(data.name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Une boutique avec le nom '{data.name}' existe déjà",
|
||||||
|
)
|
||||||
|
|
||||||
|
shop = await repo.create(
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
url=data.url,
|
||||||
|
address=data.address,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ShopResponse.model_validate(shop)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{shop_id}", response_model=ShopResponse)
|
||||||
|
async def update_shop(
|
||||||
|
shop_id: int,
|
||||||
|
data: ShopUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ShopResponse:
|
||||||
|
"""Met à jour une boutique."""
|
||||||
|
repo = ShopRepository(db)
|
||||||
|
|
||||||
|
existing = await repo.get(shop_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Boutique {shop_id} non trouvée",
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.name and data.name != existing.name:
|
||||||
|
if await repo.name_exists(data.name, exclude_id=shop_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Une boutique avec le nom '{data.name}' existe déjà",
|
||||||
|
)
|
||||||
|
|
||||||
|
shop = await repo.update(
|
||||||
|
shop_id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
url=data.url,
|
||||||
|
address=data.address,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ShopResponse.model_validate(shop)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{shop_id}", response_model=SuccessResponse)
|
||||||
|
async def delete_shop(
|
||||||
|
shop_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> SuccessResponse:
|
||||||
|
"""Supprime une boutique."""
|
||||||
|
repo = ShopRepository(db)
|
||||||
|
|
||||||
|
result = await repo.get_with_item_count(shop_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Boutique {shop_id} non trouvée",
|
||||||
|
)
|
||||||
|
|
||||||
|
shop, item_count = result
|
||||||
|
|
||||||
|
if item_count > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Impossible de supprimer : {item_count} objet(s) sont associés à cette boutique",
|
||||||
|
)
|
||||||
|
|
||||||
|
await repo.delete(shop_id)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return SuccessResponse(message="Boutique supprimée avec succès", id=shop_id)
|
||||||
@@ -5,8 +5,9 @@ Définit les schémas de validation pour les requêtes et réponses API.
|
|||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from app.models.item import ItemStatus
|
from app.models.item import ItemStatus
|
||||||
from app.schemas.category import CategoryResponse
|
from app.schemas.category import CategoryResponse
|
||||||
@@ -26,6 +27,7 @@ class ItemBase(BaseModel):
|
|||||||
url: str | None = Field(None, max_length=500, description="Lien vers page produit")
|
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")
|
price: Decimal | None = Field(None, ge=0, decimal_places=2, description="Prix d'achat")
|
||||||
purchase_date: date | None = Field(None, description="Date d'achat")
|
purchase_date: date | None = Field(None, description="Date d'achat")
|
||||||
|
characteristics: dict[str, str] | None = Field(None, description="Caractéristiques techniques (clé-valeur)")
|
||||||
notes: str | None = Field(None, description="Notes libres")
|
notes: str | None = Field(None, description="Notes libres")
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +36,8 @@ class ItemCreate(ItemBase):
|
|||||||
|
|
||||||
category_id: int = Field(..., description="ID de la catégorie")
|
category_id: int = Field(..., description="ID de la catégorie")
|
||||||
location_id: int = Field(..., description="ID de l'emplacement")
|
location_id: int = Field(..., description="ID de l'emplacement")
|
||||||
|
parent_item_id: int | None = Field(None, description="ID de l'objet parent (si intégré)")
|
||||||
|
shop_id: int | None = Field(None, description="ID de la boutique d'achat")
|
||||||
|
|
||||||
|
|
||||||
class ItemUpdate(BaseModel):
|
class ItemUpdate(BaseModel):
|
||||||
@@ -49,9 +53,12 @@ class ItemUpdate(BaseModel):
|
|||||||
url: str | None = Field(None, max_length=500)
|
url: str | None = Field(None, max_length=500)
|
||||||
price: Decimal | None = Field(None, ge=0)
|
price: Decimal | None = Field(None, ge=0)
|
||||||
purchase_date: date | None = None
|
purchase_date: date | None = None
|
||||||
|
characteristics: dict[str, str] | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
location_id: int | None = None
|
location_id: int | None = None
|
||||||
|
parent_item_id: int | None = None
|
||||||
|
shop_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class ItemResponse(ItemBase):
|
class ItemResponse(ItemBase):
|
||||||
@@ -62,6 +69,8 @@ class ItemResponse(ItemBase):
|
|||||||
id: int
|
id: int
|
||||||
category_id: int
|
category_id: int
|
||||||
location_id: int
|
location_id: int
|
||||||
|
parent_item_id: int | None = None
|
||||||
|
shop_id: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -71,6 +80,57 @@ class ItemWithRelations(ItemResponse):
|
|||||||
|
|
||||||
category: CategoryResponse
|
category: CategoryResponse
|
||||||
location: LocationResponse
|
location: LocationResponse
|
||||||
|
thumbnail_id: int | None = None
|
||||||
|
parent_item_name: str | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def extract_computed_fields(cls, data: Any) -> Any:
|
||||||
|
"""Extrait les champs calculés : thumbnail et nom du parent."""
|
||||||
|
from sqlalchemy.orm import InstanceState
|
||||||
|
|
||||||
|
thumbnail_id = None
|
||||||
|
parent_item_name = None
|
||||||
|
|
||||||
|
# Vérifier que les relations sont chargées (éviter lazy load en async)
|
||||||
|
loaded_relations: set[str] = set()
|
||||||
|
if hasattr(data, "_sa_instance_state"):
|
||||||
|
state: InstanceState = data._sa_instance_state
|
||||||
|
loaded_relations = set(state.dict.keys())
|
||||||
|
|
||||||
|
if "documents" in loaded_relations:
|
||||||
|
for doc in data.documents:
|
||||||
|
if doc.type.value == "photo":
|
||||||
|
thumbnail_id = doc.id
|
||||||
|
break
|
||||||
|
|
||||||
|
if "parent_item" in loaded_relations and data.parent_item is not None:
|
||||||
|
parent_item_name = data.parent_item.name
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if thumbnail_id:
|
||||||
|
data["thumbnail_id"] = thumbnail_id
|
||||||
|
if parent_item_name:
|
||||||
|
data["parent_item_name"] = parent_item_name
|
||||||
|
elif thumbnail_id or parent_item_name:
|
||||||
|
result = {}
|
||||||
|
for k in dir(data):
|
||||||
|
if k.startswith("_"):
|
||||||
|
continue
|
||||||
|
# Ne pas accéder aux relations non chargées
|
||||||
|
if k in ("documents", "parent_item", "children", "shop"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result[k] = getattr(data, k)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if thumbnail_id:
|
||||||
|
result["thumbnail_id"] = thumbnail_id
|
||||||
|
if parent_item_name:
|
||||||
|
result["parent_item_name"] = parent_item_name
|
||||||
|
return result
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class ItemSummary(BaseModel):
|
class ItemSummary(BaseModel):
|
||||||
|
|||||||
45
backend/app/schemas/shop.py
Normal file
45
backend/app/schemas/shop.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Schémas Pydantic pour les boutiques."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ShopBase(BaseModel):
|
||||||
|
"""Schéma de base pour les boutiques."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=200, description="Nom de la boutique")
|
||||||
|
description: str | None = Field(None, max_length=1000, description="Description optionnelle")
|
||||||
|
url: str | None = Field(None, max_length=500, description="URL du site web")
|
||||||
|
address: str | None = Field(None, description="Adresse physique")
|
||||||
|
|
||||||
|
|
||||||
|
class ShopCreate(ShopBase):
|
||||||
|
"""Schéma pour la création d'une boutique."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ShopUpdate(BaseModel):
|
||||||
|
"""Schéma pour la mise à jour d'une boutique (tous les champs optionnels)."""
|
||||||
|
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=200)
|
||||||
|
description: str | None = Field(None, max_length=1000)
|
||||||
|
url: str | None = Field(None, max_length=500)
|
||||||
|
address: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShopResponse(ShopBase):
|
||||||
|
"""Schéma de réponse pour une boutique."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ShopWithItemCount(ShopResponse):
|
||||||
|
"""Schéma de réponse avec le nombre d'objets."""
|
||||||
|
|
||||||
|
item_count: int = Field(default=0, description="Nombre d'objets achetés dans cette boutique")
|
||||||
@@ -1,24 +1,169 @@
|
|||||||
# Contrat d’erreurs API
|
# Contrat d'erreurs API
|
||||||
|
|
||||||
Ce document définit le format standard des erreurs.
|
Ce document définit le format standard des erreurs de l'API HomeStock.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Légende des zones
|
## Format des réponses d'erreur
|
||||||
- `<A COMPLETER PAR AGENT>` : à compléter par un agent spécialisé backend.
|
|
||||||
|
### Erreurs métier (HTTPException)
|
||||||
|
|
||||||
|
Toutes les erreurs métier utilisent `fastapi.HTTPException` et retournent :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Message d'erreur lisible"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- complété par codex -->
|
||||||
|
|
||||||
|
### Erreurs de validation (Pydantic / FastAPI)
|
||||||
|
|
||||||
|
Les erreurs de validation des paramètres ou du corps de requête retournent automatiquement :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
"type": "validation_error",
|
||||||
|
"loc": ["body", "name"],
|
||||||
|
"msg": "String should have at least 1 character",
|
||||||
|
"input": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- complété par codex -->
|
||||||
|
|
||||||
|
### Erreurs internes (500)
|
||||||
|
|
||||||
|
Le gestionnaire global (`main.py`) retourne :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Message technique (dev) ou 'Erreur interne du serveur' (prod)",
|
||||||
|
"type": "internal_server_error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
En développement, le message contient les détails de l'exception.
|
||||||
|
En production, le message est masqué pour la sécurité.
|
||||||
|
|
||||||
|
<!-- complété par codex -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Format
|
## Codes HTTP utilisés
|
||||||
- Structure : <A COMPLETER PAR AGENT>
|
|
||||||
- Codes d’erreur : <A COMPLETER PAR AGENT>
|
| Code | Signification | Usage |
|
||||||
- Messages utilisateurs vs techniques : <A COMPLETER PAR AGENT>
|
|------|---------------|-------|
|
||||||
|
| **200** | OK | GET, PUT, PATCH réussis |
|
||||||
|
| **201** | Created | POST réussi (création de ressource) |
|
||||||
|
| **204** | No Content | DELETE réussi (documents) |
|
||||||
|
| **400** | Bad Request | Données invalides (type de fichier, taille, auto-référence) |
|
||||||
|
| **404** | Not Found | Ressource inexistante (item, catégorie, emplacement, boutique, document, fichier physique) |
|
||||||
|
| **409** | Conflict | Conflit d'unicité (nom dupliqué, n° de série) ou dépendance empêchant la suppression |
|
||||||
|
| **422** | Unprocessable Entity | Erreur de validation Pydantic (format automatique FastAPI) |
|
||||||
|
| **500** | Internal Server Error | Exception non gérée |
|
||||||
|
|
||||||
|
<!-- complété par codex -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codes d'erreur par domaine
|
||||||
|
|
||||||
|
### Items (`/api/v1/items`)
|
||||||
|
|
||||||
|
| Code HTTP | Situation | Message |
|
||||||
|
|-----------|-----------|---------|
|
||||||
|
| 404 | Item non trouvé | `Objet {id} non trouvé` |
|
||||||
|
| 404 | Catégorie référencée inexistante | `Catégorie {id} non trouvée` |
|
||||||
|
| 404 | Emplacement référencé inexistant | `Emplacement {id} non trouvé` |
|
||||||
|
| 409 | N° de série déjà utilisé | `Un objet avec le numéro de série '{sn}' existe déjà` |
|
||||||
|
|
||||||
|
### Catégories (`/api/v1/categories`)
|
||||||
|
|
||||||
|
| Code HTTP | Situation | Message |
|
||||||
|
|-----------|-----------|---------|
|
||||||
|
| 404 | Catégorie non trouvée | `Catégorie {id} non trouvée` |
|
||||||
|
| 409 | Nom déjà utilisé | `Une catégorie avec le nom '{name}' existe déjà` |
|
||||||
|
| 409 | Suppression avec items liés | `Impossible de supprimer : {n} objet(s) utilisent cette catégorie` |
|
||||||
|
|
||||||
|
### Emplacements (`/api/v1/locations`)
|
||||||
|
|
||||||
|
| Code HTTP | Situation | Message |
|
||||||
|
|-----------|-----------|---------|
|
||||||
|
| 404 | Emplacement non trouvé | `Emplacement {id} non trouvé` |
|
||||||
|
| 404 | Parent inexistant | `Emplacement parent {id} non trouvé` |
|
||||||
|
| 400 | Auto-référence | `Un emplacement ne peut pas être son propre parent` |
|
||||||
|
| 409 | Suppression avec items liés | `Impossible de supprimer : {n} objet(s) utilisent cet emplacement` |
|
||||||
|
| 409 | Suppression avec sous-emplacements | `Impossible de supprimer : cet emplacement a {n} sous-emplacement(s)` |
|
||||||
|
|
||||||
|
### Boutiques (`/api/v1/shops`)
|
||||||
|
|
||||||
|
| Code HTTP | Situation | Message |
|
||||||
|
|-----------|-----------|---------|
|
||||||
|
| 404 | Boutique non trouvée | `Boutique {id} non trouvée` |
|
||||||
|
| 409 | Nom déjà utilisé | `Une boutique avec le nom '{name}' existe déjà` |
|
||||||
|
| 409 | Suppression avec items liés | `Impossible de supprimer : {n} objet(s) sont associés à cette boutique` |
|
||||||
|
|
||||||
|
### Documents (`/api/v1/documents`)
|
||||||
|
|
||||||
|
| Code HTTP | Situation | Message |
|
||||||
|
|-----------|-----------|---------|
|
||||||
|
| 404 | Item parent inexistant | `Item {id} non trouvé` |
|
||||||
|
| 404 | Document non trouvé | `Document non trouvé` |
|
||||||
|
| 404 | Fichier physique manquant | `Fichier non trouvé sur le disque` |
|
||||||
|
| 400 | Type MIME non autorisé | `Type de fichier non autorisé : {mime}. Types acceptés : images (JPEG, PNG, GIF, WebP) et PDF` |
|
||||||
|
| 400 | Photo sans image | `Le type 'photo' nécessite un fichier image` |
|
||||||
|
| 400 | Fichier trop volumineux | `Fichier trop volumineux ({size} Mo). Taille max : 10 Mo` |
|
||||||
|
|
||||||
|
### Import CSV (`/api/v1/import`)
|
||||||
|
|
||||||
|
| Code HTTP | Situation | Message |
|
||||||
|
|-----------|-----------|---------|
|
||||||
|
| 400 | Fichier non CSV | `Le fichier doit être un CSV (.csv)` |
|
||||||
|
| 400 | Indices de sélection invalides | `Format d'indices invalide` |
|
||||||
|
| 400 | Statut d'item invalide | `Statut invalide : {status}` |
|
||||||
|
|
||||||
|
<!-- complété par codex -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
- Codes HTTP : <A COMPLETER PAR AGENT>
|
|
||||||
- Champs obligatoires : <A COMPLETER PAR AGENT>
|
### Champs obligatoires
|
||||||
|
|
||||||
|
Toute réponse d'erreur contient au minimum le champ `detail` (string ou array).
|
||||||
|
|
||||||
|
<!-- complété par codex -->
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
- Les messages sont en **français**, destinés à l'utilisateur final
|
||||||
|
- Ils incluent le contexte nécessaire (ID de la ressource, nom dupliqué, taille du fichier)
|
||||||
|
- En production (500), le message technique est masqué
|
||||||
|
|
||||||
|
### Gestion côté client
|
||||||
|
|
||||||
|
Le frontend intercepte les erreurs via un intercepteur Axios (`api/client.ts`) qui log l'erreur dans la console. Le champ `detail` est affiché à l'utilisateur.
|
||||||
|
|
||||||
|
<!-- complété par codex -->
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Exemple (a supprimer)
|
## Schéma de réponse (schemas/common.py)
|
||||||
- `{ "error": { "code": "VALIDATION_ERROR", "message": "Email invalide" } }`
|
|
||||||
|
```python
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
detail: str # Message d'erreur
|
||||||
|
type: str # Type d'erreur (ex: "internal_server_error")
|
||||||
|
|
||||||
|
class SuccessResponse(BaseModel):
|
||||||
|
message: str # Message de succès
|
||||||
|
id: int | None # ID de l'élément concerné
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- complété par codex -->
|
||||||
|
|||||||
294
error.md
Executable file
294
error.md
Executable file
@@ -0,0 +1,294 @@
|
|||||||
|
chunk-RPCDYKBN.js?v=b7115578:21551 Download the React DevTools for a better development experience: https://reactjs.org/link/react-devtools
|
||||||
|
react-router-dom.js?v=b7115578:4436 ⚠️ React Router Future Flag Warning: React Router will begin wrapping state updates in `React.startTransition` in v7. You can use the `v7_startTransition` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_starttransition.
|
||||||
|
warnOnce @ react-router-dom.js?v=b7115578:4436
|
||||||
|
logDeprecation @ react-router-dom.js?v=b7115578:4439
|
||||||
|
logV6DeprecationWarnings @ react-router-dom.js?v=b7115578:4442
|
||||||
|
(anonymous) @ react-router-dom.js?v=b7115578:5314
|
||||||
|
commitHookEffectListMount @ chunk-RPCDYKBN.js?v=b7115578:16915
|
||||||
|
commitPassiveMountOnFiber @ chunk-RPCDYKBN.js?v=b7115578:18156
|
||||||
|
commitPassiveMountEffects_complete @ chunk-RPCDYKBN.js?v=b7115578:18129
|
||||||
|
commitPassiveMountEffects_begin @ chunk-RPCDYKBN.js?v=b7115578:18119
|
||||||
|
commitPassiveMountEffects @ chunk-RPCDYKBN.js?v=b7115578:18109
|
||||||
|
flushPassiveEffectsImpl @ chunk-RPCDYKBN.js?v=b7115578:19490
|
||||||
|
flushPassiveEffects @ chunk-RPCDYKBN.js?v=b7115578:19447
|
||||||
|
(anonymous) @ chunk-RPCDYKBN.js?v=b7115578:19328
|
||||||
|
workLoop @ chunk-RPCDYKBN.js?v=b7115578:197
|
||||||
|
flushWork @ chunk-RPCDYKBN.js?v=b7115578:176
|
||||||
|
performWorkUntilDeadline @ chunk-RPCDYKBN.js?v=b7115578:384
|
||||||
|
react-router-dom.js?v=b7115578:4436 ⚠️ React Router Future Flag Warning: Relative route resolution within Splat routes is changing in v7. You can use the `v7_relativeSplatPath` future flag to opt-in early. For more information, see https://reactrouter.com/v6/upgrading/future#v7_relativesplatpath.
|
||||||
|
warnOnce @ react-router-dom.js?v=b7115578:4436
|
||||||
|
logDeprecation @ react-router-dom.js?v=b7115578:4439
|
||||||
|
logV6DeprecationWarnings @ react-router-dom.js?v=b7115578:4445
|
||||||
|
(anonymous) @ react-router-dom.js?v=b7115578:5314
|
||||||
|
commitHookEffectListMount @ chunk-RPCDYKBN.js?v=b7115578:16915
|
||||||
|
commitPassiveMountOnFiber @ chunk-RPCDYKBN.js?v=b7115578:18156
|
||||||
|
commitPassiveMountEffects_complete @ chunk-RPCDYKBN.js?v=b7115578:18129
|
||||||
|
commitPassiveMountEffects_begin @ chunk-RPCDYKBN.js?v=b7115578:18119
|
||||||
|
commitPassiveMountEffects @ chunk-RPCDYKBN.js?v=b7115578:18109
|
||||||
|
flushPassiveEffectsImpl @ chunk-RPCDYKBN.js?v=b7115578:19490
|
||||||
|
flushPassiveEffects @ chunk-RPCDYKBN.js?v=b7115578:19447
|
||||||
|
(anonymous) @ chunk-RPCDYKBN.js?v=b7115578:19328
|
||||||
|
workLoop @ chunk-RPCDYKBN.js?v=b7115578:197
|
||||||
|
flushWork @ chunk-RPCDYKBN.js?v=b7115578:176
|
||||||
|
performWorkUntilDeadline @ chunk-RPCDYKBN.js?v=b7115578:384
|
||||||
|
items.ts:17 GET http://10.0.1.106:8000/api/v1/items?page=1&page_size=500 422 (Unprocessable Entity)
|
||||||
|
dispatchXhrRequest @ axios.js?v=b7115578:1706
|
||||||
|
xhr @ axios.js?v=b7115578:1583
|
||||||
|
dispatchRequest @ axios.js?v=b7115578:2117
|
||||||
|
_request @ axios.js?v=b7115578:2337
|
||||||
|
request @ axios.js?v=b7115578:2229
|
||||||
|
Axios.<computed> @ axios.js?v=b7115578:2356
|
||||||
|
wrap @ axios.js?v=b7115578:8
|
||||||
|
getAll @ items.ts:17
|
||||||
|
queryFn @ useItems.ts:24
|
||||||
|
fetchFn @ chunk-XKV5UAPV.js?v=b7115578:1123
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:822
|
||||||
|
start @ chunk-XKV5UAPV.js?v=b7115578:865
|
||||||
|
fetch @ chunk-XKV5UAPV.js?v=b7115578:1170
|
||||||
|
executeFetch_fn @ chunk-XKV5UAPV.js?v=b7115578:1678
|
||||||
|
onSubscribe @ chunk-XKV5UAPV.js?v=b7115578:1369
|
||||||
|
subscribe @ chunk-XKV5UAPV.js?v=b7115578:24
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:3388
|
||||||
|
subscribeToStore @ chunk-RPCDYKBN.js?v=b7115578:11984
|
||||||
|
commitHookEffectListMount @ chunk-RPCDYKBN.js?v=b7115578:16915
|
||||||
|
commitPassiveMountOnFiber @ chunk-RPCDYKBN.js?v=b7115578:18156
|
||||||
|
commitPassiveMountEffects_complete @ chunk-RPCDYKBN.js?v=b7115578:18129
|
||||||
|
commitPassiveMountEffects_begin @ chunk-RPCDYKBN.js?v=b7115578:18119
|
||||||
|
commitPassiveMountEffects @ chunk-RPCDYKBN.js?v=b7115578:18109
|
||||||
|
flushPassiveEffectsImpl @ chunk-RPCDYKBN.js?v=b7115578:19490
|
||||||
|
flushPassiveEffects @ chunk-RPCDYKBN.js?v=b7115578:19447
|
||||||
|
(anonymous) @ chunk-RPCDYKBN.js?v=b7115578:19328
|
||||||
|
workLoop @ chunk-RPCDYKBN.js?v=b7115578:197
|
||||||
|
flushWork @ chunk-RPCDYKBN.js?v=b7115578:176
|
||||||
|
performWorkUntilDeadline @ chunk-RPCDYKBN.js?v=b7115578:384
|
||||||
|
client.ts:39 API Error: {detail: Array(1)}
|
||||||
|
(anonymous) @ client.ts:39
|
||||||
|
Promise.then
|
||||||
|
_request @ axios.js?v=b7115578:2344
|
||||||
|
request @ axios.js?v=b7115578:2229
|
||||||
|
Axios.<computed> @ axios.js?v=b7115578:2356
|
||||||
|
wrap @ axios.js?v=b7115578:8
|
||||||
|
getAll @ items.ts:17
|
||||||
|
queryFn @ useItems.ts:24
|
||||||
|
fetchFn @ chunk-XKV5UAPV.js?v=b7115578:1123
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:822
|
||||||
|
start @ chunk-XKV5UAPV.js?v=b7115578:865
|
||||||
|
fetch @ chunk-XKV5UAPV.js?v=b7115578:1170
|
||||||
|
executeFetch_fn @ chunk-XKV5UAPV.js?v=b7115578:1678
|
||||||
|
onSubscribe @ chunk-XKV5UAPV.js?v=b7115578:1369
|
||||||
|
subscribe @ chunk-XKV5UAPV.js?v=b7115578:24
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:3388
|
||||||
|
subscribeToStore @ chunk-RPCDYKBN.js?v=b7115578:11984
|
||||||
|
commitHookEffectListMount @ chunk-RPCDYKBN.js?v=b7115578:16915
|
||||||
|
commitPassiveMountOnFiber @ chunk-RPCDYKBN.js?v=b7115578:18156
|
||||||
|
commitPassiveMountEffects_complete @ chunk-RPCDYKBN.js?v=b7115578:18129
|
||||||
|
commitPassiveMountEffects_begin @ chunk-RPCDYKBN.js?v=b7115578:18119
|
||||||
|
commitPassiveMountEffects @ chunk-RPCDYKBN.js?v=b7115578:18109
|
||||||
|
flushPassiveEffectsImpl @ chunk-RPCDYKBN.js?v=b7115578:19490
|
||||||
|
flushPassiveEffects @ chunk-RPCDYKBN.js?v=b7115578:19447
|
||||||
|
(anonymous) @ chunk-RPCDYKBN.js?v=b7115578:19328
|
||||||
|
workLoop @ chunk-RPCDYKBN.js?v=b7115578:197
|
||||||
|
flushWork @ chunk-RPCDYKBN.js?v=b7115578:176
|
||||||
|
performWorkUntilDeadline @ chunk-RPCDYKBN.js?v=b7115578:384
|
||||||
|
items.ts:17 GET http://10.0.1.106:8000/api/v1/items?page=1&page_size=500 422 (Unprocessable Entity)
|
||||||
|
dispatchXhrRequest @ axios.js?v=b7115578:1706
|
||||||
|
xhr @ axios.js?v=b7115578:1583
|
||||||
|
dispatchRequest @ axios.js?v=b7115578:2117
|
||||||
|
_request @ axios.js?v=b7115578:2337
|
||||||
|
request @ axios.js?v=b7115578:2229
|
||||||
|
Axios.<computed> @ axios.js?v=b7115578:2356
|
||||||
|
wrap @ axios.js?v=b7115578:8
|
||||||
|
getAll @ items.ts:17
|
||||||
|
queryFn @ useItems.ts:24
|
||||||
|
fetchFn @ chunk-XKV5UAPV.js?v=b7115578:1123
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:822
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:847
|
||||||
|
Promise.then
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:843
|
||||||
|
Promise.catch
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:826
|
||||||
|
start @ chunk-XKV5UAPV.js?v=b7115578:865
|
||||||
|
fetch @ chunk-XKV5UAPV.js?v=b7115578:1170
|
||||||
|
executeFetch_fn @ chunk-XKV5UAPV.js?v=b7115578:1678
|
||||||
|
onSubscribe @ chunk-XKV5UAPV.js?v=b7115578:1369
|
||||||
|
subscribe @ chunk-XKV5UAPV.js?v=b7115578:24
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:3388
|
||||||
|
subscribeToStore @ chunk-RPCDYKBN.js?v=b7115578:11984
|
||||||
|
commitHookEffectListMount @ chunk-RPCDYKBN.js?v=b7115578:16915
|
||||||
|
commitPassiveMountOnFiber @ chunk-RPCDYKBN.js?v=b7115578:18156
|
||||||
|
commitPassiveMountEffects_complete @ chunk-RPCDYKBN.js?v=b7115578:18129
|
||||||
|
commitPassiveMountEffects_begin @ chunk-RPCDYKBN.js?v=b7115578:18119
|
||||||
|
commitPassiveMountEffects @ chunk-RPCDYKBN.js?v=b7115578:18109
|
||||||
|
flushPassiveEffectsImpl @ chunk-RPCDYKBN.js?v=b7115578:19490
|
||||||
|
flushPassiveEffects @ chunk-RPCDYKBN.js?v=b7115578:19447
|
||||||
|
(anonymous) @ chunk-RPCDYKBN.js?v=b7115578:19328
|
||||||
|
workLoop @ chunk-RPCDYKBN.js?v=b7115578:197
|
||||||
|
flushWork @ chunk-RPCDYKBN.js?v=b7115578:176
|
||||||
|
performWorkUntilDeadline @ chunk-RPCDYKBN.js?v=b7115578:384
|
||||||
|
client.ts:39 API Error: {detail: Array(1)}
|
||||||
|
(anonymous) @ client.ts:39
|
||||||
|
Promise.then
|
||||||
|
_request @ axios.js?v=b7115578:2344
|
||||||
|
request @ axios.js?v=b7115578:2229
|
||||||
|
Axios.<computed> @ axios.js?v=b7115578:2356
|
||||||
|
wrap @ axios.js?v=b7115578:8
|
||||||
|
getAll @ items.ts:17
|
||||||
|
queryFn @ useItems.ts:24
|
||||||
|
fetchFn @ chunk-XKV5UAPV.js?v=b7115578:1123
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:822
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:847
|
||||||
|
Promise.then
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:843
|
||||||
|
Promise.catch
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:826
|
||||||
|
start @ chunk-XKV5UAPV.js?v=b7115578:865
|
||||||
|
fetch @ chunk-XKV5UAPV.js?v=b7115578:1170
|
||||||
|
executeFetch_fn @ chunk-XKV5UAPV.js?v=b7115578:1678
|
||||||
|
onSubscribe @ chunk-XKV5UAPV.js?v=b7115578:1369
|
||||||
|
subscribe @ chunk-XKV5UAPV.js?v=b7115578:24
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:3388
|
||||||
|
subscribeToStore @ chunk-RPCDYKBN.js?v=b7115578:11984
|
||||||
|
commitHookEffectListMount @ chunk-RPCDYKBN.js?v=b7115578:16915
|
||||||
|
commitPassiveMountOnFiber @ chunk-RPCDYKBN.js?v=b7115578:18156
|
||||||
|
commitPassiveMountEffects_complete @ chunk-RPCDYKBN.js?v=b7115578:18129
|
||||||
|
commitPassiveMountEffects_begin @ chunk-RPCDYKBN.js?v=b7115578:18119
|
||||||
|
commitPassiveMountEffects @ chunk-RPCDYKBN.js?v=b7115578:18109
|
||||||
|
flushPassiveEffectsImpl @ chunk-RPCDYKBN.js?v=b7115578:19490
|
||||||
|
flushPassiveEffects @ chunk-RPCDYKBN.js?v=b7115578:19447
|
||||||
|
(anonymous) @ chunk-RPCDYKBN.js?v=b7115578:19328
|
||||||
|
workLoop @ chunk-RPCDYKBN.js?v=b7115578:197
|
||||||
|
flushWork @ chunk-RPCDYKBN.js?v=b7115578:176
|
||||||
|
performWorkUntilDeadline @ chunk-RPCDYKBN.js?v=b7115578:384
|
||||||
|
items.ts:17 GET http://10.0.1.106:8000/api/v1/items?page=1&page_size=500 422 (Unprocessable Entity)
|
||||||
|
dispatchXhrRequest @ axios.js?v=b7115578:1706
|
||||||
|
xhr @ axios.js?v=b7115578:1583
|
||||||
|
dispatchRequest @ axios.js?v=b7115578:2117
|
||||||
|
_request @ axios.js?v=b7115578:2337
|
||||||
|
request @ axios.js?v=b7115578:2229
|
||||||
|
Axios.<computed> @ axios.js?v=b7115578:2356
|
||||||
|
wrap @ axios.js?v=b7115578:8
|
||||||
|
getAll @ items.ts:17
|
||||||
|
queryFn @ useItems.ts:24
|
||||||
|
fetchFn @ chunk-XKV5UAPV.js?v=b7115578:1123
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:822
|
||||||
|
start @ chunk-XKV5UAPV.js?v=b7115578:865
|
||||||
|
fetch @ chunk-XKV5UAPV.js?v=b7115578:1170
|
||||||
|
executeFetch_fn @ chunk-XKV5UAPV.js?v=b7115578:1678
|
||||||
|
onSubscribe @ chunk-XKV5UAPV.js?v=b7115578:1369
|
||||||
|
subscribe @ chunk-XKV5UAPV.js?v=b7115578:24
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:3388
|
||||||
|
subscribeToStore @ chunk-RPCDYKBN.js?v=b7115578:11984
|
||||||
|
commitHookEffectListMount @ chunk-RPCDYKBN.js?v=b7115578:16915
|
||||||
|
commitPassiveMountOnFiber @ chunk-RPCDYKBN.js?v=b7115578:18156
|
||||||
|
commitPassiveMountEffects_complete @ chunk-RPCDYKBN.js?v=b7115578:18129
|
||||||
|
commitPassiveMountEffects_begin @ chunk-RPCDYKBN.js?v=b7115578:18119
|
||||||
|
commitPassiveMountEffects @ chunk-RPCDYKBN.js?v=b7115578:18109
|
||||||
|
flushPassiveEffectsImpl @ chunk-RPCDYKBN.js?v=b7115578:19490
|
||||||
|
flushPassiveEffects @ chunk-RPCDYKBN.js?v=b7115578:19447
|
||||||
|
commitRootImpl @ chunk-RPCDYKBN.js?v=b7115578:19416
|
||||||
|
commitRoot @ chunk-RPCDYKBN.js?v=b7115578:19277
|
||||||
|
performSyncWorkOnRoot @ chunk-RPCDYKBN.js?v=b7115578:18895
|
||||||
|
flushSyncCallbacks @ chunk-RPCDYKBN.js?v=b7115578:9119
|
||||||
|
(anonymous) @ chunk-RPCDYKBN.js?v=b7115578:18627
|
||||||
|
client.ts:39 API Error: {detail: Array(1)}
|
||||||
|
(anonymous) @ client.ts:39
|
||||||
|
Promise.then
|
||||||
|
_request @ axios.js?v=b7115578:2344
|
||||||
|
request @ axios.js?v=b7115578:2229
|
||||||
|
Axios.<computed> @ axios.js?v=b7115578:2356
|
||||||
|
wrap @ axios.js?v=b7115578:8
|
||||||
|
getAll @ items.ts:17
|
||||||
|
queryFn @ useItems.ts:24
|
||||||
|
fetchFn @ chunk-XKV5UAPV.js?v=b7115578:1123
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:822
|
||||||
|
start @ chunk-XKV5UAPV.js?v=b7115578:865
|
||||||
|
fetch @ chunk-XKV5UAPV.js?v=b7115578:1170
|
||||||
|
executeFetch_fn @ chunk-XKV5UAPV.js?v=b7115578:1678
|
||||||
|
onSubscribe @ chunk-XKV5UAPV.js?v=b7115578:1369
|
||||||
|
subscribe @ chunk-XKV5UAPV.js?v=b7115578:24
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:3388
|
||||||
|
subscribeToStore @ chunk-RPCDYKBN.js?v=b7115578:11984
|
||||||
|
commitHookEffectListMount @ chunk-RPCDYKBN.js?v=b7115578:16915
|
||||||
|
commitPassiveMountOnFiber @ chunk-RPCDYKBN.js?v=b7115578:18156
|
||||||
|
commitPassiveMountEffects_complete @ chunk-RPCDYKBN.js?v=b7115578:18129
|
||||||
|
commitPassiveMountEffects_begin @ chunk-RPCDYKBN.js?v=b7115578:18119
|
||||||
|
commitPassiveMountEffects @ chunk-RPCDYKBN.js?v=b7115578:18109
|
||||||
|
flushPassiveEffectsImpl @ chunk-RPCDYKBN.js?v=b7115578:19490
|
||||||
|
flushPassiveEffects @ chunk-RPCDYKBN.js?v=b7115578:19447
|
||||||
|
commitRootImpl @ chunk-RPCDYKBN.js?v=b7115578:19416
|
||||||
|
commitRoot @ chunk-RPCDYKBN.js?v=b7115578:19277
|
||||||
|
performSyncWorkOnRoot @ chunk-RPCDYKBN.js?v=b7115578:18895
|
||||||
|
flushSyncCallbacks @ chunk-RPCDYKBN.js?v=b7115578:9119
|
||||||
|
(anonymous) @ chunk-RPCDYKBN.js?v=b7115578:18627
|
||||||
|
items.ts:17 GET http://10.0.1.106:8000/api/v1/items?page=1&page_size=500 422 (Unprocessable Entity)
|
||||||
|
dispatchXhrRequest @ axios.js?v=b7115578:1706
|
||||||
|
xhr @ axios.js?v=b7115578:1583
|
||||||
|
dispatchRequest @ axios.js?v=b7115578:2117
|
||||||
|
_request @ axios.js?v=b7115578:2337
|
||||||
|
request @ axios.js?v=b7115578:2229
|
||||||
|
Axios.<computed> @ axios.js?v=b7115578:2356
|
||||||
|
wrap @ axios.js?v=b7115578:8
|
||||||
|
getAll @ items.ts:17
|
||||||
|
queryFn @ useItems.ts:24
|
||||||
|
fetchFn @ chunk-XKV5UAPV.js?v=b7115578:1123
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:822
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:847
|
||||||
|
Promise.then
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:843
|
||||||
|
Promise.catch
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:826
|
||||||
|
start @ chunk-XKV5UAPV.js?v=b7115578:865
|
||||||
|
fetch @ chunk-XKV5UAPV.js?v=b7115578:1170
|
||||||
|
executeFetch_fn @ chunk-XKV5UAPV.js?v=b7115578:1678
|
||||||
|
onSubscribe @ chunk-XKV5UAPV.js?v=b7115578:1369
|
||||||
|
subscribe @ chunk-XKV5UAPV.js?v=b7115578:24
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:3388
|
||||||
|
subscribeToStore @ chunk-RPCDYKBN.js?v=b7115578:11984
|
||||||
|
commitHookEffectListMount @ chunk-RPCDYKBN.js?v=b7115578:16915
|
||||||
|
commitPassiveMountOnFiber @ chunk-RPCDYKBN.js?v=b7115578:18156
|
||||||
|
commitPassiveMountEffects_complete @ chunk-RPCDYKBN.js?v=b7115578:18129
|
||||||
|
commitPassiveMountEffects_begin @ chunk-RPCDYKBN.js?v=b7115578:18119
|
||||||
|
commitPassiveMountEffects @ chunk-RPCDYKBN.js?v=b7115578:18109
|
||||||
|
flushPassiveEffectsImpl @ chunk-RPCDYKBN.js?v=b7115578:19490
|
||||||
|
flushPassiveEffects @ chunk-RPCDYKBN.js?v=b7115578:19447
|
||||||
|
commitRootImpl @ chunk-RPCDYKBN.js?v=b7115578:19416
|
||||||
|
commitRoot @ chunk-RPCDYKBN.js?v=b7115578:19277
|
||||||
|
performSyncWorkOnRoot @ chunk-RPCDYKBN.js?v=b7115578:18895
|
||||||
|
flushSyncCallbacks @ chunk-RPCDYKBN.js?v=b7115578:9119
|
||||||
|
(anonymous) @ chunk-RPCDYKBN.js?v=b7115578:18627
|
||||||
|
client.ts:39 API Error: {detail: Array(1)}
|
||||||
|
(anonymous) @ client.ts:39
|
||||||
|
Promise.then
|
||||||
|
_request @ axios.js?v=b7115578:2344
|
||||||
|
request @ axios.js?v=b7115578:2229
|
||||||
|
Axios.<computed> @ axios.js?v=b7115578:2356
|
||||||
|
wrap @ axios.js?v=b7115578:8
|
||||||
|
getAll @ items.ts:17
|
||||||
|
queryFn @ useItems.ts:24
|
||||||
|
fetchFn @ chunk-XKV5UAPV.js?v=b7115578:1123
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:822
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:847
|
||||||
|
Promise.then
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:843
|
||||||
|
Promise.catch
|
||||||
|
run @ chunk-XKV5UAPV.js?v=b7115578:826
|
||||||
|
start @ chunk-XKV5UAPV.js?v=b7115578:865
|
||||||
|
fetch @ chunk-XKV5UAPV.js?v=b7115578:1170
|
||||||
|
executeFetch_fn @ chunk-XKV5UAPV.js?v=b7115578:1678
|
||||||
|
onSubscribe @ chunk-XKV5UAPV.js?v=b7115578:1369
|
||||||
|
subscribe @ chunk-XKV5UAPV.js?v=b7115578:24
|
||||||
|
(anonymous) @ chunk-XKV5UAPV.js?v=b7115578:3388
|
||||||
|
subscribeToStore @ chunk-RPCDYKBN.js?v=b7115578:11984
|
||||||
|
commitHookEffectListMount @ chunk-RPCDYKBN.js?v=b7115578:16915
|
||||||
|
commitPassiveMountOnFiber @ chunk-RPCDYKBN.js?v=b7115578:18156
|
||||||
|
commitPassiveMountEffects_complete @ chunk-RPCDYKBN.js?v=b7115578:18129
|
||||||
|
commitPassiveMountEffects_begin @ chunk-RPCDYKBN.js?v=b7115578:18119
|
||||||
|
commitPassiveMountEffects @ chunk-RPCDYKBN.js?v=b7115578:18109
|
||||||
|
flushPassiveEffectsImpl @ chunk-RPCDYKBN.js?v=b7115578:19490
|
||||||
|
flushPassiveEffects @ chunk-RPCDYKBN.js?v=b7115578:19447
|
||||||
|
commitRootImpl @ chunk-RPCDYKBN.js?v=b7115578:19416
|
||||||
|
commitRoot @ chunk-RPCDYKBN.js?v=b7115578:19277
|
||||||
|
performSyncWorkOnRoot @ chunk-RPCDYKBN.js?v=b7115578:18895
|
||||||
|
flushSyncCallbacks @ chunk-RPCDYKBN.js?v=b7115578:9119
|
||||||
|
(anonymous) @ chunk-RPCDYKBN.js?v=b7115578:18627
|
||||||
|
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="HomeStock - Gestion d'inventaire domestique" />
|
<meta name="description" content="HomeStock - Gestion d'inventaire domestique" />
|
||||||
|
<meta name="theme-color" content="#2563eb" />
|
||||||
<title>HomeStock - Inventaire Domestique</title>
|
<title>HomeStock - Inventaire Domestique</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
10
frontend/public/favicon.svg
Normal file
10
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<!-- Boîte principale -->
|
||||||
|
<rect x="4" y="10" width="24" height="18" rx="2" fill="#2563eb" stroke="#1d4ed8" stroke-width="1.5"/>
|
||||||
|
<!-- Couvercle -->
|
||||||
|
<path d="M2 10L16 4L30 10H2Z" fill="#3b82f6" stroke="#2563eb" stroke-width="1"/>
|
||||||
|
<!-- Ligne centrale du couvercle -->
|
||||||
|
<line x1="16" y1="4" x2="16" y2="10" stroke="#1d4ed8" stroke-width="1.5"/>
|
||||||
|
<!-- Poignée -->
|
||||||
|
<rect x="12" y="16" width="8" height="3" rx="1" fill="#1e40af"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 520 B |
@@ -2,10 +2,14 @@ import { useState } from 'react'
|
|||||||
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
|
||||||
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from '@/hooks/useCategories'
|
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from '@/hooks/useCategories'
|
||||||
import { useLocationTree, useCreateLocation, useUpdateLocation, useDeleteLocation } from '@/hooks/useLocations'
|
import { useLocationTree, useCreateLocation, useUpdateLocation, useDeleteLocation } from '@/hooks/useLocations'
|
||||||
import { useItems, useCreateItem, useUpdateItem, useDeleteItem } from '@/hooks/useItems'
|
import { useItems, useItem, useCreateItem, useUpdateItem, useDeleteItem } from '@/hooks/useItems'
|
||||||
import { ItemList, ItemForm } from '@/components/items'
|
import { useShops, useCreateShop, useUpdateShop, useDeleteShop } from '@/hooks/useShops'
|
||||||
|
import { ItemList, ItemForm, ItemDetailModal } from '@/components/items'
|
||||||
import { CategoryForm } from '@/components/categories'
|
import { CategoryForm } from '@/components/categories'
|
||||||
import { LocationForm } from '@/components/locations'
|
import { LocationForm } from '@/components/locations'
|
||||||
|
import { ShopForm } from '@/components/shops'
|
||||||
|
import { ImportPage } from '@/components/import'
|
||||||
|
import { SettingsPage } from '@/components/settings'
|
||||||
import {
|
import {
|
||||||
Loading,
|
Loading,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
@@ -13,14 +17,19 @@ import {
|
|||||||
IconAdd,
|
IconAdd,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconDelete,
|
IconDelete,
|
||||||
|
IconClose,
|
||||||
|
IconMenu,
|
||||||
IconHome,
|
IconHome,
|
||||||
IconInventory,
|
IconInventory,
|
||||||
IconCategory,
|
IconCategory,
|
||||||
IconLocation,
|
IconLocation,
|
||||||
|
IconSettings,
|
||||||
|
IconStore,
|
||||||
IconRoom,
|
IconRoom,
|
||||||
IconFurniture,
|
IconFurniture,
|
||||||
IconDrawer,
|
IconDrawer,
|
||||||
IconBox,
|
IconBox,
|
||||||
|
IconUpload,
|
||||||
} from '@/components/common'
|
} from '@/components/common'
|
||||||
import {
|
import {
|
||||||
LOCATION_TYPE_LABELS,
|
LOCATION_TYPE_LABELS,
|
||||||
@@ -30,6 +39,7 @@ import {
|
|||||||
Location,
|
Location,
|
||||||
Item,
|
Item,
|
||||||
LocationType,
|
LocationType,
|
||||||
|
ShopWithItemCount,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
|
|
||||||
// Mapping des icônes par type d'emplacement
|
// Mapping des icônes par type d'emplacement
|
||||||
@@ -41,56 +51,156 @@ const LOCATION_TYPE_ICONS: Record<LocationType, React.ReactNode> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
<header className="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
{/* Logo et titre */}
|
{/* Logo et titre */}
|
||||||
<Link to="/" className="flex items-center">
|
<Link to="/" className="flex items-center">
|
||||||
<h1 className="text-2xl font-bold text-primary-600">
|
<h1 className="text-xl sm:text-2xl font-bold text-primary-600">
|
||||||
HomeStock
|
HomeStock
|
||||||
</h1>
|
</h1>
|
||||||
<span className="ml-3 text-sm text-gray-500">
|
<span className="hidden sm:inline ml-3 text-sm text-gray-500">
|
||||||
Inventaire Domestique
|
Inventaire Domestique
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation desktop */}
|
||||||
<nav className="flex space-x-6">
|
<nav className="hidden md:flex space-x-4 lg:space-x-6">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
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"
|
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" />
|
<IconHome className="w-5 h-5" />
|
||||||
Accueil
|
<span className="hidden lg:inline">Accueil</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/items"
|
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"
|
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" />
|
<IconInventory className="w-5 h-5" />
|
||||||
Objets
|
<span className="hidden lg:inline">Objets</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/locations"
|
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"
|
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" />
|
<IconLocation className="w-5 h-5" />
|
||||||
Emplacements
|
<span className="hidden lg:inline">Emplacements</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/categories"
|
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"
|
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" />
|
||||||
|
<span className="hidden lg:inline">Catégories</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/shops"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<IconStore className="w-5 h-5" />
|
||||||
|
<span className="hidden lg:inline">Boutiques</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/import"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<IconUpload className="w-5 h-5" />
|
||||||
|
<span className="hidden lg:inline">Import</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<IconSettings className="w-5 h-5" />
|
||||||
|
<span className="hidden lg:inline">Paramètres</span>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Bouton menu mobile */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
className="md:hidden p-2 rounded-md text-gray-700 hover:text-primary-600 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<IconClose className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<IconMenu className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu mobile */}
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="md:hidden border-t border-gray-200 bg-white">
|
||||||
|
<nav className="px-4 py-3 space-y-1">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
>
|
||||||
|
<IconHome className="w-5 h-5" />
|
||||||
|
Accueil
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/items"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
>
|
||||||
|
<IconInventory className="w-5 h-5" />
|
||||||
|
Objets
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/locations"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
>
|
||||||
|
<IconLocation className="w-5 h-5" />
|
||||||
|
Emplacements
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/categories"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||||
>
|
>
|
||||||
<IconCategory className="w-5 h-5" />
|
<IconCategory className="w-5 h-5" />
|
||||||
Catégories
|
Catégories
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/shops"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
>
|
||||||
|
<IconStore className="w-5 h-5" />
|
||||||
|
Boutiques
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/import"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
>
|
||||||
|
<IconUpload className="w-5 h-5" />
|
||||||
|
Import CSV
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||||
|
>
|
||||||
|
<IconSettings className="w-5 h-5" />
|
||||||
|
Paramètres
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Contenu principal */}
|
{/* Contenu principal */}
|
||||||
@@ -100,6 +210,9 @@ function App() {
|
|||||||
<Route path="/items" element={<ItemsPage />} />
|
<Route path="/items" element={<ItemsPage />} />
|
||||||
<Route path="/locations" element={<LocationsPage />} />
|
<Route path="/locations" element={<LocationsPage />} />
|
||||||
<Route path="/categories" element={<CategoriesPage />} />
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
|
<Route path="/shops" element={<ShopsPage />} />
|
||||||
|
<Route path="/import" element={<ImportPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
@@ -123,8 +236,9 @@ function HomePage() {
|
|||||||
const { data: categoriesData, isLoading: loadingCategories } = useCategories(1, 100)
|
const { data: categoriesData, isLoading: loadingCategories } = useCategories(1, 100)
|
||||||
const { data: itemsData, isLoading: loadingItems } = useItems(1, 1)
|
const { data: itemsData, isLoading: loadingItems } = useItems(1, 1)
|
||||||
const { data: locationsData, isLoading: loadingLocations } = useLocationTree()
|
const { data: locationsData, isLoading: loadingLocations } = useLocationTree()
|
||||||
|
const { data: shopsData, isLoading: loadingShops } = useShops(1, 1)
|
||||||
|
|
||||||
const isLoading = loadingCategories || loadingItems || loadingLocations
|
const isLoading = loadingCategories || loadingItems || loadingLocations || loadingShops
|
||||||
|
|
||||||
// Compter les emplacements
|
// Compter les emplacements
|
||||||
const countLocations = (tree: LocationTree[]): number => {
|
const countLocations = (tree: LocationTree[]): number => {
|
||||||
@@ -135,6 +249,7 @@ function HomePage() {
|
|||||||
items: itemsData?.total || 0,
|
items: itemsData?.total || 0,
|
||||||
categories: categoriesData?.total || 0,
|
categories: categoriesData?.total || 0,
|
||||||
locations: locationsData ? countLocations(locationsData) : 0,
|
locations: locationsData ? countLocations(locationsData) : 0,
|
||||||
|
shops: shopsData?.total || 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -152,7 +267,7 @@ function HomePage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loading message="Chargement des statistiques..." size="sm" />
|
<Loading message="Chargement des statistiques..." size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-12">
|
||||||
<Link to="/items" className="card card-hover text-center">
|
<Link to="/items" className="card card-hover text-center">
|
||||||
<div className="text-4xl font-bold text-primary-600">{stats.items}</div>
|
<div className="text-4xl font-bold text-primary-600">{stats.items}</div>
|
||||||
<div className="text-gray-600 mt-2">Objets</div>
|
<div className="text-gray-600 mt-2">Objets</div>
|
||||||
@@ -165,6 +280,10 @@ function HomePage() {
|
|||||||
<div className="text-4xl font-bold text-gray-700">{stats.locations}</div>
|
<div className="text-4xl font-bold text-gray-700">{stats.locations}</div>
|
||||||
<div className="text-gray-600 mt-2">Emplacements</div>
|
<div className="text-gray-600 mt-2">Emplacements</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/shops" className="card card-hover text-center">
|
||||||
|
<div className="text-4xl font-bold text-purple-600">{stats.shops}</div>
|
||||||
|
<div className="text-gray-600 mt-2">Boutiques</div>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -202,9 +321,13 @@ function ItemsPage() {
|
|||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [editingItem, setEditingItem] = useState<Item | null>(null)
|
const [editingItem, setEditingItem] = useState<Item | null>(null)
|
||||||
const [deletingItem, setDeletingItem] = useState<Item | null>(null)
|
const [deletingItem, setDeletingItem] = useState<Item | null>(null)
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
|
||||||
|
|
||||||
const { data: categoriesData } = useCategories(1, 100)
|
const { data: categoriesData } = useCategories(1, 100)
|
||||||
const { data: locationsData } = useLocationTree()
|
const { data: locationsData } = useLocationTree()
|
||||||
|
const { data: allItemsData } = useItems(1, 500)
|
||||||
|
const { data: shopsData } = useShops(1, 100)
|
||||||
|
const { data: selectedItem } = useItem(selectedItemId || 0)
|
||||||
|
|
||||||
const createItem = useCreateItem()
|
const createItem = useCreateItem()
|
||||||
const updateItem = useUpdateItem()
|
const updateItem = useUpdateItem()
|
||||||
@@ -220,6 +343,14 @@ function ItemsPage() {
|
|||||||
setShowForm(true)
|
setShowForm(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEditFromDetail = () => {
|
||||||
|
if (selectedItem) {
|
||||||
|
setEditingItem(selectedItem)
|
||||||
|
setSelectedItemId(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (data: any) => {
|
const handleSubmit = async (data: any) => {
|
||||||
if (editingItem) {
|
if (editingItem) {
|
||||||
await updateItem.mutateAsync({ id: editingItem.id, data })
|
await updateItem.mutateAsync({ id: editingItem.id, data })
|
||||||
@@ -248,14 +379,19 @@ function ItemsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ItemList
|
<ItemList
|
||||||
onItemClick={(id) => {
|
onItemClick={(id) => setSelectedItemId(id)}
|
||||||
// TODO: ouvrir le détail de l'objet
|
|
||||||
console.log('Item clicked:', id)
|
|
||||||
}}
|
|
||||||
onItemEdit={handleEdit}
|
onItemEdit={handleEdit}
|
||||||
onItemDelete={setDeletingItem}
|
onItemDelete={setDeletingItem}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modale détails objet */}
|
||||||
|
<ItemDetailModal
|
||||||
|
isOpen={!!selectedItemId}
|
||||||
|
onClose={() => setSelectedItemId(null)}
|
||||||
|
item={selectedItem || null}
|
||||||
|
onEdit={handleEditFromDetail}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Formulaire création/édition */}
|
{/* Formulaire création/édition */}
|
||||||
<ItemForm
|
<ItemForm
|
||||||
isOpen={showForm}
|
isOpen={showForm}
|
||||||
@@ -267,6 +403,8 @@ function ItemsPage() {
|
|||||||
item={editingItem}
|
item={editingItem}
|
||||||
categories={categoriesData?.items || []}
|
categories={categoriesData?.items || []}
|
||||||
locations={locationsData || []}
|
locations={locationsData || []}
|
||||||
|
allItems={allItemsData?.items || []}
|
||||||
|
shops={shopsData?.items || []}
|
||||||
isLoading={createItem.isPending || updateItem.isPending}
|
isLoading={createItem.isPending || updateItem.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -440,6 +578,143 @@ function LocationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Page des catégories ===
|
// === Page des catégories ===
|
||||||
|
// === Page des boutiques ===
|
||||||
|
function ShopsPage() {
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editingShop, setEditingShop] = useState<ShopWithItemCount | null>(null)
|
||||||
|
const [deletingShop, setDeletingShop] = useState<ShopWithItemCount | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = useShops(1, 100)
|
||||||
|
const createShop = useCreateShop()
|
||||||
|
const updateShop = useUpdateShop()
|
||||||
|
const deleteShop = useDeleteShop()
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingShop(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (shop: ShopWithItemCount) => {
|
||||||
|
setEditingShop(shop)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: any) => {
|
||||||
|
if (editingShop) {
|
||||||
|
await updateShop.mutateAsync({ id: editingShop.id, data: formData })
|
||||||
|
} else {
|
||||||
|
await createShop.mutateAsync(formData)
|
||||||
|
}
|
||||||
|
setShowForm(false)
|
||||||
|
setEditingShop(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (deletingShop) {
|
||||||
|
await deleteShop.mutateAsync(deletingShop.id)
|
||||||
|
setDeletingShop(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <Loading message="Chargement des boutiques..." />
|
||||||
|
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 Boutiques</h2>
|
||||||
|
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||||
|
<IconAdd className="w-5 h-5" />
|
||||||
|
Nouvelle boutique
|
||||||
|
</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((shop) => (
|
||||||
|
<div key={shop.id} className="card group">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<IconStore className="w-5 h-5 mr-2 text-primary-500" />
|
||||||
|
<h3 className="font-semibold text-gray-900">{shop.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(shop)}
|
||||||
|
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={() => setDeletingShop(shop)}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="Supprimer"
|
||||||
|
disabled={shop.item_count > 0}
|
||||||
|
>
|
||||||
|
<IconDelete className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{shop.description && (
|
||||||
|
<p className="text-sm text-gray-600 mb-2">{shop.description}</p>
|
||||||
|
)}
|
||||||
|
{shop.url && (
|
||||||
|
<a
|
||||||
|
href={shop.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary-600 hover:underline block mb-2"
|
||||||
|
>
|
||||||
|
{new URL(shop.url).hostname}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{shop.address && (
|
||||||
|
<p className="text-xs text-gray-400 mb-2">{shop.address}</p>
|
||||||
|
)}
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{shop.item_count} objet(s)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<p className="text-gray-600 text-center py-8">
|
||||||
|
Aucune boutique créée. Commencez par en créer une.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ShopForm
|
||||||
|
isOpen={showForm}
|
||||||
|
onClose={() => {
|
||||||
|
setShowForm(false)
|
||||||
|
setEditingShop(null)
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
shop={editingShop}
|
||||||
|
isLoading={createShop.isPending || updateShop.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!deletingShop}
|
||||||
|
onClose={() => setDeletingShop(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Supprimer la boutique"
|
||||||
|
message={
|
||||||
|
deletingShop?.item_count && deletingShop.item_count > 0
|
||||||
|
? `Impossible de supprimer "${deletingShop.name}" car elle est associée à ${deletingShop.item_count} objet(s).`
|
||||||
|
: `Êtes-vous sûr de vouloir supprimer "${deletingShop?.name}" ?`
|
||||||
|
}
|
||||||
|
confirmText="Supprimer"
|
||||||
|
isLoading={deleteShop.isPending}
|
||||||
|
variant={deletingShop?.item_count && deletingShop.item_count > 0 ? 'warning' : 'danger'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function CategoriesPage() {
|
function CategoriesPage() {
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [editingCategory, setEditingCategory] = useState<CategoryWithItemCount | null>(null)
|
const [editingCategory, setEditingCategory] = useState<CategoryWithItemCount | null>(null)
|
||||||
|
|||||||
@@ -4,8 +4,24 @@
|
|||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
// URL de base de l'API
|
// URL de base de l'API — utilise le hostname du navigateur pour fonctionner
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1'
|
// automatiquement depuis localhost ou depuis le réseau local
|
||||||
|
export function getApiBaseUrl(): string {
|
||||||
|
const envUrl = import.meta.env.VITE_API_BASE_URL
|
||||||
|
if (envUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(envUrl)
|
||||||
|
// Remplacer le hostname de la config par celui du navigateur
|
||||||
|
// pour que l'API soit accessible depuis n'importe quel appareil
|
||||||
|
return `${url.protocol}//${window.location.hostname}:${url.port}${url.pathname}`
|
||||||
|
} catch {
|
||||||
|
return envUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}:8000/api/v1`
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = getApiBaseUrl()
|
||||||
|
|
||||||
// Instance Axios configurée
|
// Instance Axios configurée
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
|
|||||||
80
frontend/src/api/documents.ts
Normal file
80
frontend/src/api/documents.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* API pour les documents (photos, notices, factures, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient, getApiBaseUrl, SuccessResponse } from './client'
|
||||||
|
import { Document, DocumentType, DocumentUpdate, DocumentUploadResponse } from './types'
|
||||||
|
|
||||||
|
export const documentsApi = {
|
||||||
|
/**
|
||||||
|
* Upload un document
|
||||||
|
*/
|
||||||
|
async upload(
|
||||||
|
file: File,
|
||||||
|
itemId: number,
|
||||||
|
docType: DocumentType,
|
||||||
|
description?: string
|
||||||
|
): Promise<DocumentUploadResponse> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('item_id', itemId.toString())
|
||||||
|
formData.append('doc_type', docType)
|
||||||
|
if (description) {
|
||||||
|
formData.append('description', description)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.post<DocumentUploadResponse>('/documents/upload', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère tous les documents d'un item
|
||||||
|
*/
|
||||||
|
async getByItem(itemId: number, docType?: DocumentType): Promise<Document[]> {
|
||||||
|
const response = await apiClient.get<Document[]>(`/documents/item/${itemId}`, {
|
||||||
|
params: docType ? { doc_type: docType } : undefined,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un document par son ID
|
||||||
|
*/
|
||||||
|
async getById(id: number): Promise<Document> {
|
||||||
|
const response = await apiClient.get<Document>(`/documents/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour les métadonnées d'un document
|
||||||
|
*/
|
||||||
|
async update(id: number, data: DocumentUpdate): Promise<Document> {
|
||||||
|
const response = await apiClient.patch<Document>(`/documents/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un document
|
||||||
|
*/
|
||||||
|
async delete(id: number): Promise<void> {
|
||||||
|
await apiClient.delete(`/documents/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'URL de téléchargement d'un document
|
||||||
|
*/
|
||||||
|
getDownloadUrl(id: number): string {
|
||||||
|
return `${getApiBaseUrl()}/documents/${id}/download`
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'URL d'affichage d'une image
|
||||||
|
*/
|
||||||
|
getImageUrl(id: number): string {
|
||||||
|
return `${getApiBaseUrl()}/documents/${id}/download`
|
||||||
|
},
|
||||||
|
}
|
||||||
67
frontend/src/api/import.ts
Normal file
67
frontend/src/api/import.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Client API pour l'import CSV
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
export interface ImportPreviewItem {
|
||||||
|
index: number
|
||||||
|
name: string
|
||||||
|
price: number | null
|
||||||
|
quantity: number
|
||||||
|
purchase_date: string | null
|
||||||
|
seller_name: string | null
|
||||||
|
url: string | null
|
||||||
|
image_url: string | null
|
||||||
|
attributes: Record<string, string> | null
|
||||||
|
order_id: string | null
|
||||||
|
order_status: string | null
|
||||||
|
total_price: number | null
|
||||||
|
is_duplicate: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportPreviewResponse {
|
||||||
|
items: ImportPreviewItem[]
|
||||||
|
total_items: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResultResponse {
|
||||||
|
items_created: number
|
||||||
|
shops_created: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importApi = {
|
||||||
|
async previewAliexpress(file: File): Promise<ImportPreviewResponse> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const response = await apiClient.post<ImportPreviewResponse>(
|
||||||
|
'/import/csv/aliexpress/preview',
|
||||||
|
formData,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 30000 }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async importAliexpress(
|
||||||
|
file: File,
|
||||||
|
categoryId: number,
|
||||||
|
status: string,
|
||||||
|
selectedIndices: number[]
|
||||||
|
): Promise<ImportResultResponse> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('category_id', categoryId.toString())
|
||||||
|
formData.append('item_status', status)
|
||||||
|
formData.append('selected_indices', selectedIndices.join(','))
|
||||||
|
|
||||||
|
const response = await apiClient.post<ImportResultResponse>(
|
||||||
|
'/import/csv/aliexpress/import',
|
||||||
|
formData,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -5,5 +5,9 @@
|
|||||||
export * from './client'
|
export * from './client'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
export { categoriesApi } from './categories'
|
export { categoriesApi } from './categories'
|
||||||
|
export { documentsApi } from './documents'
|
||||||
export { locationsApi } from './locations'
|
export { locationsApi } from './locations'
|
||||||
export { itemsApi } from './items'
|
export { itemsApi } from './items'
|
||||||
|
export { shopsApi } from './shops'
|
||||||
|
export { importApi } from './import'
|
||||||
|
export type { ImportPreviewItem, ImportPreviewResponse, ImportResultResponse } from './import'
|
||||||
|
|||||||
35
frontend/src/api/shops.ts
Normal file
35
frontend/src/api/shops.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* API pour les boutiques
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient, PaginatedResponse, SuccessResponse } from './client'
|
||||||
|
import { Shop, ShopCreate, ShopUpdate, ShopWithItemCount } from './types'
|
||||||
|
|
||||||
|
export const shopsApi = {
|
||||||
|
async getAll(page = 1, pageSize = 20): Promise<PaginatedResponse<ShopWithItemCount>> {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<ShopWithItemCount>>('/shops', {
|
||||||
|
params: { page, page_size: pageSize },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(id: number): Promise<ShopWithItemCount> {
|
||||||
|
const response = await apiClient.get<ShopWithItemCount>(`/shops/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: ShopCreate): Promise<Shop> {
|
||||||
|
const response = await apiClient.post<Shop>('/shops', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: number, data: ShopUpdate): Promise<Shop> {
|
||||||
|
const response = await apiClient.put<Shop>(`/shops/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: number): Promise<SuccessResponse> {
|
||||||
|
const response = await apiClient.delete<SuccessResponse>(`/shops/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ export interface LocationUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Objets ===
|
// === Objets ===
|
||||||
export type ItemStatus = 'in_stock' | 'in_use' | 'broken' | 'sold' | 'lent'
|
export type ItemStatus = 'in_stock' | 'in_use' | 'integrated' | 'broken' | 'sold' | 'lent'
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
id: number
|
id: number
|
||||||
@@ -87,9 +87,12 @@ export interface Item {
|
|||||||
url: string | null
|
url: string | null
|
||||||
price: string | null
|
price: string | null
|
||||||
purchase_date: string | null
|
purchase_date: string | null
|
||||||
|
characteristics: Record<string, string> | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
category_id: number
|
category_id: number
|
||||||
location_id: number
|
location_id: number
|
||||||
|
parent_item_id: number | null
|
||||||
|
shop_id: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -97,6 +100,8 @@ export interface Item {
|
|||||||
export interface ItemWithRelations extends Item {
|
export interface ItemWithRelations extends Item {
|
||||||
category: Category
|
category: Category
|
||||||
location: Location
|
location: Location
|
||||||
|
thumbnail_id: number | null
|
||||||
|
parent_item_name: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemCreate {
|
export interface ItemCreate {
|
||||||
@@ -110,9 +115,12 @@ export interface ItemCreate {
|
|||||||
url?: string | null
|
url?: string | null
|
||||||
price?: number | null
|
price?: number | null
|
||||||
purchase_date?: string | null
|
purchase_date?: string | null
|
||||||
|
characteristics?: Record<string, string> | null
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
category_id: number
|
category_id: number
|
||||||
location_id: number
|
location_id: number
|
||||||
|
parent_item_id?: number | null
|
||||||
|
shop_id?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemUpdate {
|
export interface ItemUpdate {
|
||||||
@@ -126,9 +134,12 @@ export interface ItemUpdate {
|
|||||||
url?: string | null
|
url?: string | null
|
||||||
price?: number | null
|
price?: number | null
|
||||||
purchase_date?: string | null
|
purchase_date?: string | null
|
||||||
|
characteristics?: Record<string, string> | null
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
category_id?: number
|
category_id?: number
|
||||||
location_id?: number
|
location_id?: number
|
||||||
|
parent_item_id?: number | null
|
||||||
|
shop_id?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemFilter {
|
export interface ItemFilter {
|
||||||
@@ -151,6 +162,7 @@ export const LOCATION_TYPE_LABELS: Record<LocationType, string> = {
|
|||||||
export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = {
|
export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = {
|
||||||
in_stock: 'En stock',
|
in_stock: 'En stock',
|
||||||
in_use: 'En utilisation',
|
in_use: 'En utilisation',
|
||||||
|
integrated: 'Intégré',
|
||||||
broken: 'Cassé',
|
broken: 'Cassé',
|
||||||
sold: 'Vendu',
|
sold: 'Vendu',
|
||||||
lent: 'Prêté',
|
lent: 'Prêté',
|
||||||
@@ -159,7 +171,85 @@ export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = {
|
|||||||
export const ITEM_STATUS_COLORS: Record<ItemStatus, string> = {
|
export const ITEM_STATUS_COLORS: Record<ItemStatus, string> = {
|
||||||
in_stock: 'bg-green-100 text-green-800',
|
in_stock: 'bg-green-100 text-green-800',
|
||||||
in_use: 'bg-blue-100 text-blue-800',
|
in_use: 'bg-blue-100 text-blue-800',
|
||||||
|
integrated: 'bg-purple-100 text-purple-800',
|
||||||
broken: 'bg-red-100 text-red-800',
|
broken: 'bg-red-100 text-red-800',
|
||||||
sold: 'bg-gray-100 text-gray-800',
|
sold: 'bg-gray-100 text-gray-800',
|
||||||
lent: 'bg-yellow-100 text-yellow-800',
|
lent: 'bg-yellow-100 text-yellow-800',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Boutiques ===
|
||||||
|
export interface Shop {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
url: string | null
|
||||||
|
address: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShopWithItemCount extends Shop {
|
||||||
|
item_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShopCreate {
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
url?: string | null
|
||||||
|
address?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShopUpdate {
|
||||||
|
name?: string
|
||||||
|
description?: string | null
|
||||||
|
url?: string | null
|
||||||
|
address?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Documents ===
|
||||||
|
export type DocumentType = 'photo' | 'manual' | 'invoice' | 'warranty' | 'other'
|
||||||
|
|
||||||
|
export interface Document {
|
||||||
|
id: number
|
||||||
|
filename: string
|
||||||
|
original_name: string
|
||||||
|
type: DocumentType
|
||||||
|
mime_type: string
|
||||||
|
size_bytes: number
|
||||||
|
file_path: string
|
||||||
|
description: string | null
|
||||||
|
item_id: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentUploadResponse {
|
||||||
|
id: number
|
||||||
|
filename: string
|
||||||
|
original_name: string
|
||||||
|
type: DocumentType
|
||||||
|
mime_type: string
|
||||||
|
size_bytes: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentUpdate {
|
||||||
|
type?: DocumentType
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCUMENT_TYPE_LABELS: Record<DocumentType, string> = {
|
||||||
|
photo: 'Photo',
|
||||||
|
manual: 'Notice',
|
||||||
|
invoice: 'Facture',
|
||||||
|
warranty: 'Garantie',
|
||||||
|
other: 'Autre',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCUMENT_TYPE_ICONS: Record<DocumentType, string> = {
|
||||||
|
photo: 'MdImage',
|
||||||
|
manual: 'MdDescription',
|
||||||
|
invoice: 'MdReceipt',
|
||||||
|
warranty: 'MdVerified',
|
||||||
|
other: 'MdAttachFile',
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export {
|
|||||||
MdEdit as IconEdit,
|
MdEdit as IconEdit,
|
||||||
MdDelete as IconDelete,
|
MdDelete as IconDelete,
|
||||||
MdClose as IconClose,
|
MdClose as IconClose,
|
||||||
|
MdMenu as IconMenu,
|
||||||
MdSearch as IconSearch,
|
MdSearch as IconSearch,
|
||||||
MdSettings as IconSettings,
|
MdSettings as IconSettings,
|
||||||
MdArrowBack as IconBack,
|
MdArrowBack as IconBack,
|
||||||
@@ -36,10 +37,14 @@ export {
|
|||||||
// Documents
|
// Documents
|
||||||
MdAttachFile as IconAttachment,
|
MdAttachFile as IconAttachment,
|
||||||
MdImage as IconImage,
|
MdImage as IconImage,
|
||||||
|
MdImage as IconPhoto,
|
||||||
MdPictureAsPdf as IconPdf,
|
MdPictureAsPdf as IconPdf,
|
||||||
MdLink as IconLink,
|
MdLink as IconLink,
|
||||||
MdReceipt as IconReceipt,
|
MdReceipt as IconReceipt,
|
||||||
MdDescription as IconDocument,
|
MdDescription as IconDocument,
|
||||||
|
MdDescription as IconDescription,
|
||||||
|
MdFileUpload as IconUpload,
|
||||||
|
MdFileDownload as IconDownload,
|
||||||
|
|
||||||
// Personnes
|
// Personnes
|
||||||
MdPerson as IconPerson,
|
MdPerson as IconPerson,
|
||||||
@@ -49,6 +54,7 @@ export {
|
|||||||
MdStar as IconStar,
|
MdStar as IconStar,
|
||||||
MdFavorite as IconFavorite,
|
MdFavorite as IconFavorite,
|
||||||
MdShoppingCart as IconCart,
|
MdShoppingCart as IconCart,
|
||||||
|
MdStorefront as IconStore,
|
||||||
MdLocalOffer as IconTag,
|
MdLocalOffer as IconTag,
|
||||||
MdCalendarToday as IconCalendar,
|
MdCalendarToday as IconCalendar,
|
||||||
MdEuro as IconEuro,
|
MdEuro as IconEuro,
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ interface ModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'max-w-md',
|
sm: 'sm:max-w-md',
|
||||||
md: 'max-w-lg',
|
md: 'sm:max-w-lg',
|
||||||
lg: 'max-w-2xl',
|
lg: 'sm:max-w-2xl',
|
||||||
xl: 'max-w-4xl',
|
xl: 'sm:max-w-4xl',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
|
export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
|
||||||
@@ -51,12 +51,12 @@ export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalPr
|
|||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
onClick={handleOverlayClick}
|
onClick={handleOverlayClick}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50 sm:p-4"
|
||||||
>
|
>
|
||||||
<div className={`bg-white rounded-lg shadow-xl w-full ${sizeClasses[size]} max-h-[90vh] flex flex-col`}>
|
<div className={`bg-white rounded-t-xl sm:rounded-lg shadow-xl w-full ${sizeClasses[size]} max-h-[95vh] sm:max-h-[90vh] flex flex-col`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
<div className="flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-gray-200">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
|
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{title}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
@@ -66,7 +66,7 @@ export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalPr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-6 py-4 overflow-y-auto flex-1">
|
<div className="px-4 sm:px-6 py-4 overflow-y-auto flex-1">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
299
frontend/src/components/documents/DocumentUpload.tsx
Normal file
299
frontend/src/components/documents/DocumentUpload.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Composant d'upload de documents
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
documentsApi,
|
||||||
|
Document,
|
||||||
|
DocumentType,
|
||||||
|
DOCUMENT_TYPE_LABELS,
|
||||||
|
} from '@/api'
|
||||||
|
import {
|
||||||
|
IconPhoto,
|
||||||
|
IconDescription,
|
||||||
|
IconReceipt,
|
||||||
|
IconClose,
|
||||||
|
IconUpload,
|
||||||
|
IconDelete,
|
||||||
|
IconDownload,
|
||||||
|
} from '@/components/common/Icons'
|
||||||
|
|
||||||
|
interface DocumentUploadProps {
|
||||||
|
itemId: number
|
||||||
|
onUploadComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOCUMENT_TYPE_ICONS: Record<DocumentType, React.ReactNode> = {
|
||||||
|
photo: <IconPhoto className="w-5 h-5" />,
|
||||||
|
manual: <IconDescription className="w-5 h-5" />,
|
||||||
|
invoice: <IconReceipt className="w-5 h-5" />,
|
||||||
|
warranty: <IconDescription className="w-5 h-5" />,
|
||||||
|
other: <IconDescription className="w-5 h-5" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 Mo
|
||||||
|
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
const ALLOWED_PDF_TYPES = ['application/pdf']
|
||||||
|
const ALLOWED_TYPES = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_PDF_TYPES]
|
||||||
|
|
||||||
|
export function DocumentUpload({ itemId, onUploadComplete }: DocumentUploadProps) {
|
||||||
|
const [selectedType, setSelectedType] = useState<DocumentType>('photo')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// Récupérer les documents existants
|
||||||
|
const { data: documents = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['documents', itemId],
|
||||||
|
queryFn: () => documentsApi.getByItem(itemId),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mutation pour l'upload
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => documentsApi.upload(file, itemId, selectedType, description || undefined),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['documents', itemId] })
|
||||||
|
setDescription('')
|
||||||
|
setError(null)
|
||||||
|
onUploadComplete?.()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setError(err.message || "Erreur lors de l'upload")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mutation pour la suppression
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (docId: number) => documentsApi.delete(docId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['documents', itemId] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const validateFile = useCallback((file: File): string | null => {
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
return `Type de fichier non autorisé. Acceptés : images (JPEG, PNG, GIF, WebP) et PDF`
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return `Fichier trop volumineux (${(file.size / 1024 / 1024).toFixed(1)} Mo). Max : 10 Mo`
|
||||||
|
}
|
||||||
|
if (selectedType === 'photo' && !ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||||
|
return 'Le type "Photo" nécessite un fichier image'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [selectedType])
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback((files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
|
||||||
|
const file = files[0]
|
||||||
|
const validationError = validateFile(file)
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
uploadMutation.mutate(file)
|
||||||
|
}, [validateFile, uploadMutation])
|
||||||
|
|
||||||
|
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||||
|
setDragActive(true)
|
||||||
|
} else if (e.type === 'dragleave') {
|
||||||
|
setDragActive(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setDragActive(false)
|
||||||
|
handleFileSelect(e.dataTransfer.files)
|
||||||
|
}, [handleFileSelect])
|
||||||
|
|
||||||
|
const handleDelete = useCallback((doc: Document) => {
|
||||||
|
if (confirm(`Supprimer "${doc.original_name}" ?`)) {
|
||||||
|
deleteMutation.mutate(doc.id)
|
||||||
|
}
|
||||||
|
}, [deleteMutation])
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes} o`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedDocuments = documents.reduce((acc, doc) => {
|
||||||
|
if (!acc[doc.type]) acc[doc.type] = []
|
||||||
|
acc[doc.type].push(doc)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<DocumentType, Document[]>)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Zone d'upload */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Sélection du type */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(Object.keys(DOCUMENT_TYPE_LABELS) as DocumentType[]).map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedType(type)}
|
||||||
|
className={`flex items-center gap-1 px-3 py-1.5 rounded-full text-sm transition-colors ${
|
||||||
|
selectedType === type
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{DOCUMENT_TYPE_ICONS[type]}
|
||||||
|
{DOCUMENT_TYPE_LABELS[type]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description optionnelle */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Description (optionnelle)"
|
||||||
|
className="input text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Zone de drop */}
|
||||||
|
<div
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||||
|
dragActive
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
} ${uploadMutation.isPending ? 'opacity-50 pointer-events-none' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={selectedType === 'photo' ? 'image/*' : '.pdf,image/*'}
|
||||||
|
onChange={(e) => handleFileSelect(e.target.files)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<IconUpload className="w-8 h-8 mx-auto text-gray-400 mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{uploadMutation.isPending ? (
|
||||||
|
'Upload en cours...'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Glissez un fichier ici ou <span className="text-primary-600">parcourir</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{selectedType === 'photo' ? 'Images (JPEG, PNG, GIF, WebP)' : 'Images ou PDF'} - Max 10 Mo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message d'erreur */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 p-2 rounded">
|
||||||
|
<IconClose className="w-4 h-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des documents */}
|
||||||
|
{!isLoading && documents.length > 0 && (
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900">Documents attachés</h4>
|
||||||
|
|
||||||
|
{(Object.entries(groupedDocuments) as [DocumentType, Document[]][]).map(([type, docs]) => (
|
||||||
|
<div key={type} className="space-y-2">
|
||||||
|
<h5 className="text-xs font-medium text-gray-500 flex items-center gap-1">
|
||||||
|
{DOCUMENT_TYPE_ICONS[type]}
|
||||||
|
{DOCUMENT_TYPE_LABELS[type]} ({docs.length})
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
{type === 'photo' ? (
|
||||||
|
// Grille de photos
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{docs.map((doc) => (
|
||||||
|
<div key={doc.id} className="relative group aspect-square">
|
||||||
|
<img
|
||||||
|
src={documentsApi.getImageUrl(doc.id)}
|
||||||
|
alt={doc.original_name}
|
||||||
|
className="w-full h-full object-cover rounded"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded flex items-center justify-center gap-2">
|
||||||
|
<a
|
||||||
|
href={documentsApi.getDownloadUrl(doc.id)}
|
||||||
|
download={doc.original_name}
|
||||||
|
className="p-1.5 bg-white rounded-full text-gray-700 hover:bg-gray-100"
|
||||||
|
title="Télécharger"
|
||||||
|
>
|
||||||
|
<IconDownload className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(doc)}
|
||||||
|
className="p-1.5 bg-white rounded-full text-red-600 hover:bg-red-50"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<IconDelete className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Liste de fichiers
|
||||||
|
<div className="space-y-1">
|
||||||
|
{docs.map((doc) => (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className="flex items-center justify-between p-2 bg-gray-50 rounded text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<IconDescription className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="truncate">{doc.original_name}</span>
|
||||||
|
<span className="text-xs text-gray-400">({formatFileSize(doc.size_bytes)})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<a
|
||||||
|
href={documentsApi.getDownloadUrl(doc.id)}
|
||||||
|
download={doc.original_name}
|
||||||
|
className="p-1 text-gray-500 hover:text-gray-700"
|
||||||
|
title="Télécharger"
|
||||||
|
>
|
||||||
|
<IconDownload className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(doc)}
|
||||||
|
className="p-1 text-gray-500 hover:text-red-600"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<IconDelete className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
frontend/src/components/documents/index.ts
Normal file
5
frontend/src/components/documents/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Export des composants documents
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { DocumentUpload } from './DocumentUpload'
|
||||||
415
frontend/src/components/import/ImportPage.tsx
Normal file
415
frontend/src/components/import/ImportPage.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
/**
|
||||||
|
* Page d'import CSV AliExpress
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from 'react'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useCategories } from '@/hooks/useCategories'
|
||||||
|
import { importApi, ImportPreviewResponse } from '@/api/import'
|
||||||
|
import { ITEM_STATUS_LABELS, ItemStatus } from '@/api/types'
|
||||||
|
import { IconUpload, IconWarning, IconCheck, IconError } from '@/components/common'
|
||||||
|
|
||||||
|
export function ImportPage() {
|
||||||
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const [preview, setPreview] = useState<ImportPreviewResponse | null>(null)
|
||||||
|
const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set())
|
||||||
|
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||||
|
const [itemStatus, setItemStatus] = useState<ItemStatus>('in_stock')
|
||||||
|
const [importResult, setImportResult] = useState<{ items: number; shops: number; errors: string[] } | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: categoriesData } = useCategories(1, 100)
|
||||||
|
|
||||||
|
const previewMutation = useMutation({
|
||||||
|
mutationFn: (f: File) => importApi.previewAliexpress(f),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setPreview(data)
|
||||||
|
// Sélectionner tous les items non-doublons par défaut
|
||||||
|
const indices = new Set(
|
||||||
|
data.items.filter((item) => !item.is_duplicate).map((item) => item.index)
|
||||||
|
)
|
||||||
|
setSelectedIndices(indices)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!file || !categoryId) throw new Error('Paramètres manquants')
|
||||||
|
return importApi.importAliexpress(
|
||||||
|
file,
|
||||||
|
categoryId,
|
||||||
|
itemStatus,
|
||||||
|
Array.from(selectedIndices)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setImportResult({
|
||||||
|
items: data.items_created,
|
||||||
|
shops: data.shops_created,
|
||||||
|
errors: data.errors,
|
||||||
|
})
|
||||||
|
setPreview(null)
|
||||||
|
setFile(null)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['items'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shops'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback((files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
const f = files[0]
|
||||||
|
if (!f.name.toLowerCase().endsWith('.csv')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFile(f)
|
||||||
|
setPreview(null)
|
||||||
|
setImportResult(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setDragActive(false)
|
||||||
|
handleFileSelect(e.dataTransfer.files)
|
||||||
|
}, [handleFileSelect])
|
||||||
|
|
||||||
|
const handlePreview = () => {
|
||||||
|
if (file) {
|
||||||
|
previewMutation.mutate(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
if (file && categoryId && selectedIndices.size > 0) {
|
||||||
|
importMutation.mutate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (!preview) return
|
||||||
|
if (selectedIndices.size === preview.items.length) {
|
||||||
|
setSelectedIndices(new Set())
|
||||||
|
} else {
|
||||||
|
setSelectedIndices(new Set(preview.items.map((item) => item.index)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleItem = (index: number) => {
|
||||||
|
setSelectedIndices((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(index)) {
|
||||||
|
next.delete(index)
|
||||||
|
} else {
|
||||||
|
next.add(index)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Résultat post-import
|
||||||
|
if (importResult) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Import CSV</h2>
|
||||||
|
<div className="card text-center py-8">
|
||||||
|
<IconCheck className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">Import terminé</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
{importResult.items} objet(s) créé(s), {importResult.shops} boutique(s) créée(s)
|
||||||
|
</p>
|
||||||
|
{importResult.errors.length > 0 && (
|
||||||
|
<div className="mt-4 text-left max-w-lg mx-auto">
|
||||||
|
<p className="text-sm font-medium text-red-600 mb-2">Erreurs :</p>
|
||||||
|
{importResult.errors.map((err, i) => (
|
||||||
|
<p key={i} className="text-sm text-red-500">{err}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setImportResult(null)}
|
||||||
|
className="btn btn-primary mt-6"
|
||||||
|
>
|
||||||
|
Nouvel import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Import CSV AliExpress</h2>
|
||||||
|
|
||||||
|
{/* Zone d'upload */}
|
||||||
|
<div className="card mb-6">
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||||
|
dragActive
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: file
|
||||||
|
? 'border-green-300 bg-green-50'
|
||||||
|
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragActive(true) }}
|
||||||
|
onDragLeave={() => setDragActive(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={(e) => handleFileSelect(e.target.files)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{file ? (
|
||||||
|
<div>
|
||||||
|
<IconCheck className="w-10 h-10 text-green-500 mx-auto mb-2" />
|
||||||
|
<p className="text-green-700 font-medium">{file.name}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{(file.size / 1024).toFixed(1)} Ko - Cliquez pour changer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<IconUpload className="w-10 h-10 text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="text-gray-600 font-medium">
|
||||||
|
Glissez votre fichier CSV AliExpress ici
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">ou cliquez pour sélectionner</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{file && !preview && (
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={previewMutation.isPending}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{previewMutation.isPending ? 'Analyse en cours...' : 'Prévisualiser'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewMutation.isError && (
|
||||||
|
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
Erreur lors de l'analyse : {(previewMutation.error as Error).message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{preview && (
|
||||||
|
<>
|
||||||
|
{/* Résumé */}
|
||||||
|
<div className="card mb-6">
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-primary-600">{preview.total_items}</div>
|
||||||
|
<div className="text-sm text-gray-500">Articles trouvés</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{selectedIndices.size}</div>
|
||||||
|
<div className="text-sm text-gray-500">Sélectionnés</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-orange-600">
|
||||||
|
{preview.items.filter((i) => i.is_duplicate).length}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">Doublons</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-3">
|
||||||
|
Boutique : <span className="font-medium">AliExpress</span> (les noms de vendeurs seront stockés dans les caractéristiques)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paramètres d'import */}
|
||||||
|
<div className="card mb-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Paramètres d'import</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Catégorie <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={categoryId || ''}
|
||||||
|
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
{categoriesData?.items.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Statut par défaut
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={itemStatus}
|
||||||
|
onChange={(e) => setItemStatus(e.target.value as ItemStatus)}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
{Object.entries(ITEM_STATUS_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-3">
|
||||||
|
Emplacement : les items seront assignés à <span className="font-medium">"Non assigné"</span> (modifiable après import)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Erreurs de parsing */}
|
||||||
|
{preview.errors.length > 0 && (
|
||||||
|
<div className="card mb-6 border-orange-200 bg-orange-50">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<IconWarning className="w-5 h-5 text-orange-500" />
|
||||||
|
<h3 className="font-semibold text-orange-800">Avertissements</h3>
|
||||||
|
</div>
|
||||||
|
{preview.errors.map((err, i) => (
|
||||||
|
<p key={i} className="text-sm text-orange-700">{err}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tableau des items */}
|
||||||
|
<div className="card mb-6 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="py-2 px-3 text-left">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIndices.size === preview.items.length}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="py-2 px-3 text-left">Article</th>
|
||||||
|
<th className="py-2 px-3 text-left">Vendeur</th>
|
||||||
|
<th className="py-2 px-3 text-right">Prix</th>
|
||||||
|
<th className="py-2 px-3 text-center">Qté</th>
|
||||||
|
<th className="py-2 px-3 text-left">Date</th>
|
||||||
|
<th className="py-2 px-3 text-left">Statut</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.items.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.index}
|
||||||
|
className={`border-b border-gray-100 ${
|
||||||
|
item.is_duplicate ? 'bg-yellow-50' : ''
|
||||||
|
} ${
|
||||||
|
selectedIndices.has(item.index) ? '' : 'opacity-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIndices.has(item.index)}
|
||||||
|
onChange={() => toggleItem(item.index)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.image_url && (
|
||||||
|
<img
|
||||||
|
src={item.image_url}
|
||||||
|
alt=""
|
||||||
|
className="w-8 h-8 rounded object-cover flex-shrink-0"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-gray-900 truncate max-w-xs" title={item.name}>
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
{item.is_duplicate && (
|
||||||
|
<span className="text-xs text-yellow-600 flex items-center gap-1">
|
||||||
|
<IconWarning className="w-3 h-3" />
|
||||||
|
Doublon potentiel
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-gray-600 truncate max-w-[120px]" title={item.seller_name || ''}>
|
||||||
|
{item.seller_name || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-right whitespace-nowrap">
|
||||||
|
{item.price != null ? `${item.price.toFixed(2)} €` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-center">{item.quantity}</td>
|
||||||
|
<td className="py-2 px-3 text-gray-600 whitespace-nowrap">
|
||||||
|
{item.purchase_date || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span className="text-xs text-gray-500">{item.order_status || '-'}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bouton d'import */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mb-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{selectedIndices.size} article(s) sélectionné(s) sur {preview.total_items}
|
||||||
|
</p>
|
||||||
|
{!categoryId && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">
|
||||||
|
Veuillez sélectionner une catégorie pour activer l'import
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { setPreview(null); setFile(null) }}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={
|
||||||
|
importMutation.isPending ||
|
||||||
|
selectedIndices.size === 0 ||
|
||||||
|
!categoryId
|
||||||
|
}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{importMutation.isPending
|
||||||
|
? 'Import en cours...'
|
||||||
|
: `Importer ${selectedIndices.size} article(s)`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importMutation.isError && (
|
||||||
|
<div className="card border-red-200 bg-red-50 mb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconError className="w-5 h-5 text-red-500" />
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
Erreur lors de l'import : {(importMutation.error as Error).message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
frontend/src/components/import/index.ts
Normal file
1
frontend/src/components/import/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ImportPage } from './ImportPage'
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
* Carte d'affichage d'un objet
|
* Carte d'affichage d'un objet
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ItemWithRelations, Item, ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '@/api'
|
import { ItemWithRelations, Item, ITEM_STATUS_LABELS, ITEM_STATUS_COLORS, documentsApi } from '@/api'
|
||||||
import { Badge, IconEdit, IconDelete, IconLocation } from '../common'
|
import { Badge, IconEdit, IconDelete, IconLocation, IconImage } from '../common'
|
||||||
|
|
||||||
interface ItemCardProps {
|
interface ItemCardProps {
|
||||||
item: ItemWithRelations
|
item: ItemWithRelations
|
||||||
@@ -23,88 +23,108 @@ export function ItemCard({ item, onClick, onEdit, onDelete }: ItemCardProps) {
|
|||||||
onDelete?.(item)
|
onDelete?.(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const thumbnailUrl = item.thumbnail_id ? documentsApi.getImageUrl(item.thumbnail_id) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="card card-hover cursor-pointer group"
|
className="card card-hover cursor-pointer group"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
{/* Thumbnail */}
|
||||||
<h3 className="text-lg font-semibold text-gray-900 truncate">{item.name}</h3>
|
<div className="flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
|
||||||
{item.brand && (
|
{thumbnailUrl ? (
|
||||||
<p className="text-sm text-gray-500">
|
<img
|
||||||
{item.brand} {item.model && `- ${item.model}`}
|
src={thumbnailUrl}
|
||||||
</p>
|
alt={item.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconImage className="w-8 h-8 text-gray-300" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Badge className={ITEM_STATUS_COLORS[item.status]} variant="custom">
|
|
||||||
{ITEM_STATUS_LABELS[item.status]}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Contenu principal */}
|
||||||
{(onEdit || onDelete) && (
|
<div className="flex-1 min-w-0">
|
||||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
<div className="flex justify-between items-start">
|
||||||
{onEdit && (
|
<div className="flex-1 min-w-0">
|
||||||
<button
|
<h3 className="text-lg font-semibold text-gray-900 truncate">{item.name}</h3>
|
||||||
onClick={handleEdit}
|
{item.brand && (
|
||||||
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
|
<p className="text-sm text-gray-500">
|
||||||
title="Modifier"
|
{item.brand} {item.model && `- ${item.model}`}
|
||||||
>
|
</p>
|
||||||
<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 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-1 text-sm text-gray-600 truncate">{item.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 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-2 flex justify-between items-center text-sm">
|
||||||
|
{/* Quantité */}
|
||||||
|
<span className="text-gray-600">
|
||||||
|
Qté: <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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
211
frontend/src/components/items/ItemDetailModal.tsx
Normal file
211
frontend/src/components/items/ItemDetailModal.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Modale de détails d'un objet avec gestion des documents
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ItemWithRelations, ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '@/api'
|
||||||
|
import { Modal, Badge, IconLink, IconLocation, IconCalendar, IconEuro } from '@/components/common'
|
||||||
|
import { DocumentUpload } from '@/components/documents'
|
||||||
|
|
||||||
|
interface ItemDetailModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
item: ItemWithRelations | null
|
||||||
|
onEdit?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemDetailModal({ isOpen, onClose, item, onEdit }: ItemDetailModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'info' | 'documents'>('info')
|
||||||
|
|
||||||
|
if (!item) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={item.name} size="xl">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-gray-200 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('info')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'info'
|
||||||
|
? 'border-primary-500 text-primary-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Informations
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('documents')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'documents'
|
||||||
|
? 'border-primary-500 text-primary-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Documents
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'info' ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
{item.brand && (
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{item.brand} {item.model && `- ${item.model}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{item.serial_number && (
|
||||||
|
<p className="text-sm text-gray-400">S/N: {item.serial_number}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge className={ITEM_STATUS_COLORS[item.status]} variant="custom">
|
||||||
|
{ITEM_STATUS_LABELS[item.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{item.description && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-1">Description</h4>
|
||||||
|
<p className="text-gray-600">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Informations principales */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{/* Catégorie */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-1">Catégorie</h4>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2 py-1 rounded-md text-sm"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emplacement */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-1">Emplacement</h4>
|
||||||
|
<span className="inline-flex items-center text-sm text-gray-600">
|
||||||
|
<IconLocation className="w-4 h-4 mr-1 text-gray-400" />
|
||||||
|
{item.location.path}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantité */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-1">Quantité</h4>
|
||||||
|
<span className="text-gray-600">{item.quantity}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prix */}
|
||||||
|
{item.price && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-1">Prix</h4>
|
||||||
|
<span className="inline-flex items-center text-primary-600 font-semibold">
|
||||||
|
<IconEuro className="w-4 h-4 mr-1" />
|
||||||
|
{parseFloat(item.price).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date d'achat */}
|
||||||
|
{item.purchase_date && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-1">Date d'achat</h4>
|
||||||
|
<span className="inline-flex items-center text-sm text-gray-600">
|
||||||
|
<IconCalendar className="w-4 h-4 mr-1 text-gray-400" />
|
||||||
|
{new Date(item.purchase_date).toLocaleDateString('fr-FR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Objet parent */}
|
||||||
|
{item.parent_item_name && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-1">Intégré dans</h4>
|
||||||
|
<span className="inline-flex items-center text-sm text-purple-600 font-medium">
|
||||||
|
{item.parent_item_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
{item.url && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-1">Lien produit</h4>
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center text-sm text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
<IconLink className="w-4 h-4 mr-1" />
|
||||||
|
{new URL(item.url).hostname}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Caractéristiques */}
|
||||||
|
{item.characteristics && Object.keys(item.characteristics).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-2">Caractéristiques</h4>
|
||||||
|
<div className="bg-gray-50 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(item.characteristics).map(([key, value], index) => (
|
||||||
|
<tr
|
||||||
|
key={key}
|
||||||
|
className={index % 2 === 0 ? 'bg-gray-50' : 'bg-white'}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-1.5 font-medium text-gray-700 w-1/3">{key}</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-600">{value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{item.notes && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-1">Notes</h4>
|
||||||
|
<p className="text-sm text-gray-600 whitespace-pre-wrap">{item.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="pt-4 border-t border-gray-200 text-xs text-gray-400">
|
||||||
|
<p>Créé le {new Date(item.created_at).toLocaleDateString('fr-FR')}</p>
|
||||||
|
<p>Modifié le {new Date(item.updated_at).toLocaleDateString('fr-FR')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DocumentUpload itemId={item.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 pt-4 mt-4 border-t border-gray-200">
|
||||||
|
<button type="button" onClick={onClose} className="btn btn-secondary w-full sm:w-auto">
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
{onEdit && (
|
||||||
|
<button type="button" onClick={onEdit} className="btn btn-primary w-full sm:w-auto">
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
ItemStatus,
|
ItemStatus,
|
||||||
CategoryWithItemCount,
|
CategoryWithItemCount,
|
||||||
LocationTree,
|
LocationTree,
|
||||||
|
ShopWithItemCount,
|
||||||
ITEM_STATUS_LABELS,
|
ITEM_STATUS_LABELS,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import { Modal, IconLink } from '@/components/common'
|
import { Modal, IconLink, IconDelete, IconAdd } from '@/components/common'
|
||||||
|
|
||||||
interface ItemFormProps {
|
interface ItemFormProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -21,10 +22,12 @@ interface ItemFormProps {
|
|||||||
item?: Item | null
|
item?: Item | null
|
||||||
categories: CategoryWithItemCount[]
|
categories: CategoryWithItemCount[]
|
||||||
locations: LocationTree[]
|
locations: LocationTree[]
|
||||||
|
allItems?: Item[]
|
||||||
|
shops?: ShopWithItemCount[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEM_STATUSES: ItemStatus[] = ['in_stock', 'in_use', 'broken', 'sold', 'lent']
|
const ITEM_STATUSES: ItemStatus[] = ['in_stock', 'in_use', 'integrated', 'broken', 'sold', 'lent']
|
||||||
|
|
||||||
export function ItemForm({
|
export function ItemForm({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -33,6 +36,8 @@ export function ItemForm({
|
|||||||
item,
|
item,
|
||||||
categories,
|
categories,
|
||||||
locations,
|
locations,
|
||||||
|
allItems = [],
|
||||||
|
shops = [],
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: ItemFormProps) {
|
}: ItemFormProps) {
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
@@ -42,9 +47,13 @@ export function ItemForm({
|
|||||||
const [brand, setBrand] = useState('')
|
const [brand, setBrand] = useState('')
|
||||||
const [model, setModel] = useState('')
|
const [model, setModel] = useState('')
|
||||||
const [serialNumber, setSerialNumber] = useState('')
|
const [serialNumber, setSerialNumber] = useState('')
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
const [price, setPrice] = useState('')
|
const [price, setPrice] = useState('')
|
||||||
const [purchaseDate, setPurchaseDate] = useState('')
|
const [purchaseDate, setPurchaseDate] = useState('')
|
||||||
const [notes, setNotes] = useState('')
|
const [notes, setNotes] = useState('')
|
||||||
|
const [characteristics, setCharacteristics] = useState<Array<{ key: string; value: string }>>([])
|
||||||
|
const [shopId, setShopId] = useState<number | ''>('')
|
||||||
|
const [parentItemId, setParentItemId] = useState<number | ''>('')
|
||||||
const [categoryId, setCategoryId] = useState<number | ''>('')
|
const [categoryId, setCategoryId] = useState<number | ''>('')
|
||||||
const [locationId, setLocationId] = useState<number | ''>('')
|
const [locationId, setLocationId] = useState<number | ''>('')
|
||||||
|
|
||||||
@@ -75,9 +84,17 @@ export function ItemForm({
|
|||||||
setBrand(item.brand || '')
|
setBrand(item.brand || '')
|
||||||
setModel(item.model || '')
|
setModel(item.model || '')
|
||||||
setSerialNumber(item.serial_number || '')
|
setSerialNumber(item.serial_number || '')
|
||||||
|
setUrl(item.url || '')
|
||||||
setPrice(item.price || '')
|
setPrice(item.price || '')
|
||||||
setPurchaseDate(item.purchase_date ? item.purchase_date.split('T')[0] : '')
|
setPurchaseDate(item.purchase_date ? item.purchase_date.split('T')[0] : '')
|
||||||
setNotes(item.notes || '')
|
setNotes(item.notes || '')
|
||||||
|
setCharacteristics(
|
||||||
|
item.characteristics
|
||||||
|
? Object.entries(item.characteristics).map(([key, value]) => ({ key, value }))
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
setShopId(item.shop_id || '')
|
||||||
|
setParentItemId(item.parent_item_id || '')
|
||||||
setCategoryId(item.category_id)
|
setCategoryId(item.category_id)
|
||||||
setLocationId(item.location_id)
|
setLocationId(item.location_id)
|
||||||
} else {
|
} else {
|
||||||
@@ -88,9 +105,13 @@ export function ItemForm({
|
|||||||
setBrand('')
|
setBrand('')
|
||||||
setModel('')
|
setModel('')
|
||||||
setSerialNumber('')
|
setSerialNumber('')
|
||||||
|
setUrl('')
|
||||||
setPrice('')
|
setPrice('')
|
||||||
setPurchaseDate('')
|
setPurchaseDate('')
|
||||||
setNotes('')
|
setNotes('')
|
||||||
|
setCharacteristics([])
|
||||||
|
setShopId('')
|
||||||
|
setParentItemId('')
|
||||||
setCategoryId(categories.length > 0 ? categories[0].id : '')
|
setCategoryId(categories.length > 0 ? categories[0].id : '')
|
||||||
setLocationId(flatLocations.length > 0 ? flatLocations[0].id : '')
|
setLocationId(flatLocations.length > 0 ? flatLocations[0].id : '')
|
||||||
}
|
}
|
||||||
@@ -109,11 +130,21 @@ export function ItemForm({
|
|||||||
brand: brand.trim() || null,
|
brand: brand.trim() || null,
|
||||||
model: model.trim() || null,
|
model: model.trim() || null,
|
||||||
serial_number: serialNumber.trim() || null,
|
serial_number: serialNumber.trim() || null,
|
||||||
|
url: url.trim() || null,
|
||||||
price: price ? parseFloat(price) : null,
|
price: price ? parseFloat(price) : null,
|
||||||
purchase_date: purchaseDate || null,
|
purchase_date: purchaseDate || null,
|
||||||
|
characteristics: characteristics.length > 0
|
||||||
|
? Object.fromEntries(
|
||||||
|
characteristics
|
||||||
|
.filter((c) => c.key.trim() && c.value.trim())
|
||||||
|
.map((c) => [c.key.trim(), c.value.trim()])
|
||||||
|
)
|
||||||
|
: null,
|
||||||
notes: notes.trim() || null,
|
notes: notes.trim() || null,
|
||||||
category_id: categoryId,
|
category_id: categoryId,
|
||||||
location_id: locationId,
|
location_id: locationId,
|
||||||
|
parent_item_id: status === 'integrated' && parentItemId !== '' ? parentItemId : null,
|
||||||
|
shop_id: shopId !== '' ? shopId : null,
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(data)
|
onSubmit(data)
|
||||||
@@ -225,6 +256,29 @@ export function ItemForm({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Objet parent (visible si statut = intégré) */}
|
||||||
|
{status === 'integrated' && (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label htmlFor="parentItem" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Intégré dans
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="parentItem"
|
||||||
|
value={parentItemId}
|
||||||
|
onChange={(e) => setParentItemId(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">Aucun (autonome)</option>
|
||||||
|
{allItems
|
||||||
|
.filter((i) => i.id !== item?.id)
|
||||||
|
.map((i) => (
|
||||||
|
<option key={i.id} value={i.id}>
|
||||||
|
{i.name} {i.brand ? `(${i.brand})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
@@ -291,12 +345,105 @@ export function ItemForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<label htmlFor="url" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<IconLink className="w-4 h-4" />
|
||||||
|
Lien produit
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section caractéristiques */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900">Caractéristiques</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCharacteristics([...characteristics, { key: '', value: '' }])}
|
||||||
|
className="btn btn-secondary !py-1 !px-2 text-xs flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<IconAdd className="w-3.5 h-3.5" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{characteristics.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 italic">Aucune caractéristique</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{characteristics.map((char, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={char.key}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...characteristics]
|
||||||
|
updated[index] = { ...updated[index], key: e.target.value }
|
||||||
|
setCharacteristics(updated)
|
||||||
|
}}
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="Ex: RAM, CPU..."
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={char.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...characteristics]
|
||||||
|
updated[index] = { ...updated[index], value: e.target.value }
|
||||||
|
setCharacteristics(updated)
|
||||||
|
}}
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="Ex: 16 Go, i7-12700K..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCharacteristics(characteristics.filter((_, i) => i !== index))}
|
||||||
|
className="text-red-400 hover:text-red-600 p-1"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<IconDelete className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section achat */}
|
{/* Section achat */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Informations d'achat</h4>
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Boutique */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label htmlFor="shop" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Boutique
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="shop"
|
||||||
|
value={shopId}
|
||||||
|
onChange={(e) => setShopId(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">Aucune</option>
|
||||||
|
{shops.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Prix */}
|
{/* Prix */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
@@ -346,19 +493,19 @@ export function ItemForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 pt-4 border-t border-gray-200">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isValid || isLoading}
|
disabled={!isValid || isLoading}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
|
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
* Liste des objets avec recherche et filtres
|
* Liste des objets avec recherche et filtres
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useItems } from '@/hooks'
|
import { useItems } from '@/hooks'
|
||||||
import { ItemFilter, ItemStatus, Item, ITEM_STATUS_LABELS } from '@/api'
|
import { ItemFilter, ItemStatus, Item, ITEM_STATUS_LABELS } from '@/api'
|
||||||
import { Loading, ErrorMessage, EmptyState } from '../common'
|
import { Loading, ErrorMessage, EmptyState, IconSearch } from '../common'
|
||||||
import { ItemCard } from './ItemCard'
|
import { ItemCard } from './ItemCard'
|
||||||
|
|
||||||
interface ItemListProps {
|
interface ItemListProps {
|
||||||
@@ -14,49 +14,90 @@ interface ItemListProps {
|
|||||||
onItemDelete?: (item: Item) => void
|
onItemDelete?: (item: Item) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook de debounce pour la recherche temps réel
|
||||||
|
*/
|
||||||
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [value, delay])
|
||||||
|
|
||||||
|
return debouncedValue
|
||||||
|
}
|
||||||
|
|
||||||
export function ItemList({ onItemClick, onItemEdit, onItemDelete }: ItemListProps) {
|
export function ItemList({ onItemClick, onItemEdit, onItemDelete }: ItemListProps) {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [filters, setFilters] = useState<ItemFilter>({})
|
const [filters, setFilters] = useState<ItemFilter>({})
|
||||||
const [searchInput, setSearchInput] = useState('')
|
const [searchInput, setSearchInput] = useState('')
|
||||||
|
const debouncedSearch = useDebounce(searchInput, 300)
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useItems(page, 20, filters)
|
// Mettre à jour les filtres quand le texte debouncé change
|
||||||
|
useEffect(() => {
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
setFilters((prev) => ({ ...prev, search: debouncedSearch || undefined }))
|
||||||
e.preventDefault()
|
|
||||||
setFilters({ ...filters, search: searchInput || undefined })
|
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}, [debouncedSearch])
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error, refetch } = useItems(page, 20, filters)
|
||||||
|
|
||||||
const handleStatusFilter = (status: ItemStatus | '') => {
|
const handleStatusFilter = (status: ItemStatus | '') => {
|
||||||
setFilters({ ...filters, status: status || undefined })
|
setFilters({ ...filters, status: status || undefined })
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raccourci clavier / pour focus sur la recherche
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === '/' && document.activeElement?.tagName !== 'INPUT') {
|
||||||
|
e.preventDefault()
|
||||||
|
searchRef.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (isLoading) return <Loading message="Chargement des objets..." />
|
if (isLoading) return <Loading message="Chargement des objets..." />
|
||||||
if (error) return <ErrorMessage message="Erreur lors du chargement des objets" onRetry={refetch} />
|
if (error) return <ErrorMessage message="Erreur lors du chargement des objets" onRetry={refetch} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Barre de recherche et filtres */}
|
{/* Barre de recherche et filtres */}
|
||||||
<div className="mb-6 space-y-4">
|
<div className="mb-6 space-y-3">
|
||||||
<form onSubmit={handleSearch} className="flex gap-2">
|
<div className="relative">
|
||||||
|
<IconSearch className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
|
ref={searchRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
placeholder="Rechercher un objet..."
|
placeholder="Rechercher un objet... (appuyez sur / )"
|
||||||
className="input flex-1"
|
className="input pl-10 pr-10 w-full"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary">
|
{isFetching && searchInput && (
|
||||||
Rechercher
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
</button>
|
<div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||||
</form>
|
</div>
|
||||||
|
)}
|
||||||
|
{searchInput && !isFetching && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchInput('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
<select
|
<select
|
||||||
value={filters.status || ''}
|
value={filters.status || ''}
|
||||||
onChange={(e) => handleStatusFilter(e.target.value as ItemStatus | '')}
|
onChange={(e) => handleStatusFilter(e.target.value as ItemStatus | '')}
|
||||||
className="input w-auto"
|
className="input w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<option value="">Tous les statuts</option>
|
<option value="">Tous les statuts</option>
|
||||||
{Object.entries(ITEM_STATUS_LABELS).map(([value, label]) => (
|
{Object.entries(ITEM_STATUS_LABELS).map(([value, label]) => (
|
||||||
@@ -65,6 +106,11 @@ export function ItemList({ onItemClick, onItemEdit, onItemDelete }: ItemListProp
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{data && (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{data.total} résultat{data.total !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { ItemCard } from './ItemCard'
|
export { ItemCard } from './ItemCard'
|
||||||
export { ItemList } from './ItemList'
|
export { ItemDetailModal } from './ItemDetailModal'
|
||||||
export { ItemForm } from './ItemForm'
|
export { ItemForm } from './ItemForm'
|
||||||
|
export { ItemList } from './ItemList'
|
||||||
|
|||||||
161
frontend/src/components/settings/SettingsPage.tsx
Normal file
161
frontend/src/components/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Page des paramètres de l'application
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSettings, Theme, IconSize, FontSize } from '@/contexts'
|
||||||
|
import { IconSettings, IconRefresh } from '@/components/common'
|
||||||
|
|
||||||
|
const THEMES: { value: Theme; label: string; description: string }[] = [
|
||||||
|
{ value: 'light', label: 'Clair', description: 'Thème clair par défaut' },
|
||||||
|
{ value: 'gruvbox-dark', label: 'Gruvbox Dark', description: 'Thème sombre vintage aux tons chauds' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ICON_SIZES: { value: IconSize; label: string }[] = [
|
||||||
|
{ value: 'sm', label: 'Petites (16px)' },
|
||||||
|
{ value: 'md', label: 'Moyennes (20px)' },
|
||||||
|
{ value: 'lg', label: 'Grandes (24px)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FONT_SIZES: { value: FontSize; label: string }[] = [
|
||||||
|
{ value: 'sm', label: 'Petite (14px)' },
|
||||||
|
{ value: 'md', label: 'Normale (16px)' },
|
||||||
|
{ value: 'lg', label: 'Grande (18px)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const { settings, updateSettings, resetSettings } = useSettings()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<IconSettings className="w-8 h-8 text-primary-600" />
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Paramètres</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={resetSettings}
|
||||||
|
className="btn btn-secondary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<IconRefresh className="w-4 h-4" />
|
||||||
|
Réinitialiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Section Apparence */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Apparence</h3>
|
||||||
|
|
||||||
|
{/* Thème */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Thème
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{THEMES.map((theme) => (
|
||||||
|
<button
|
||||||
|
key={theme.value}
|
||||||
|
onClick={() => updateSettings({ theme: theme.value })}
|
||||||
|
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
||||||
|
settings.theme === theme.value
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Prévisualisation du thème */}
|
||||||
|
<div
|
||||||
|
className={`w-12 h-12 rounded-lg border ${
|
||||||
|
theme.value === 'light'
|
||||||
|
? 'bg-white border-gray-300'
|
||||||
|
: 'bg-[#282828] border-[#665c54]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`m-2 h-2 rounded ${
|
||||||
|
theme.value === 'light' ? 'bg-blue-500' : 'bg-[#fe8019]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`mx-2 h-1 rounded ${
|
||||||
|
theme.value === 'light' ? 'bg-gray-300' : 'bg-[#504945]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`mx-2 mt-1 h-1 rounded ${
|
||||||
|
theme.value === 'light' ? 'bg-gray-200' : 'bg-[#3c3836]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{theme.label}</div>
|
||||||
|
<div className="text-sm text-gray-500">{theme.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Taille des icônes */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Taille des icônes
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{ICON_SIZES.map((size) => (
|
||||||
|
<button
|
||||||
|
key={size.value}
|
||||||
|
onClick={() => updateSettings({ iconSize: size.value })}
|
||||||
|
className={`px-4 py-2 rounded-lg border transition-all ${
|
||||||
|
settings.iconSize === size.value
|
||||||
|
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{size.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Taille de la police */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Taille de la police
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{FONT_SIZES.map((size) => (
|
||||||
|
<button
|
||||||
|
key={size.value}
|
||||||
|
onClick={() => updateSettings({ fontSize: size.value })}
|
||||||
|
className={`px-4 py-2 rounded-lg border transition-all ${
|
||||||
|
settings.fontSize === size.value
|
||||||
|
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{size.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section À propos */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">À propos</h3>
|
||||||
|
<div className="space-y-2 text-sm text-gray-600">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">HomeStock</span> - Gestion d'inventaire domestique
|
||||||
|
</p>
|
||||||
|
<p>Version : {import.meta.env.VITE_APP_VERSION || '0.1.0'}</p>
|
||||||
|
<p>
|
||||||
|
Les paramètres sont sauvegardés localement dans votre navigateur.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
frontend/src/components/settings/index.ts
Normal file
5
frontend/src/components/settings/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Export des composants settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { SettingsPage } from './SettingsPage'
|
||||||
149
frontend/src/components/shops/ShopForm.tsx
Normal file
149
frontend/src/components/shops/ShopForm.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Formulaire de création/édition de boutique
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Shop, ShopCreate, ShopUpdate } from '@/api/types'
|
||||||
|
import { Modal, IconLink } from '@/components/common'
|
||||||
|
|
||||||
|
interface ShopFormProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: ShopCreate | ShopUpdate) => void
|
||||||
|
shop?: Shop | null
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShopForm({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
shop,
|
||||||
|
isLoading = false,
|
||||||
|
}: ShopFormProps) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [address, setAddress] = useState('')
|
||||||
|
|
||||||
|
const isEditing = !!shop
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shop) {
|
||||||
|
setName(shop.name)
|
||||||
|
setDescription(shop.description || '')
|
||||||
|
setUrl(shop.url || '')
|
||||||
|
setAddress(shop.address || '')
|
||||||
|
} else {
|
||||||
|
setName('')
|
||||||
|
setDescription('')
|
||||||
|
setUrl('')
|
||||||
|
setAddress('')
|
||||||
|
}
|
||||||
|
}, [shop, isOpen])
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
url: url.trim() || null,
|
||||||
|
address: address.trim() || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = name.trim().length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={isEditing ? 'Modifier la boutique' : 'Nouvelle boutique'}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="shopName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nom <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="shopName"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="Ex: Amazon, Leroy Merlin..."
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="shopDesc" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="shopDesc"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="input min-h-[60px]"
|
||||||
|
placeholder="Description optionnelle..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="shopUrl" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<IconLink className="w-4 h-4" />
|
||||||
|
Site web
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="shopUrl"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="shopAddress" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Adresse
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="shopAddress"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
className="input min-h-[60px]"
|
||||||
|
placeholder="Adresse physique..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 pt-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="btn btn-secondary w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || isLoading}
|
||||||
|
className="btn btn-primary w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
frontend/src/components/shops/index.ts
Normal file
1
frontend/src/components/shops/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ShopForm } from './ShopForm'
|
||||||
102
frontend/src/contexts/SettingsContext.tsx
Normal file
102
frontend/src/contexts/SettingsContext.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Contexte pour les paramètres de l'application
|
||||||
|
* Stocke les préférences utilisateur dans localStorage
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type Theme = 'light' | 'gruvbox-dark'
|
||||||
|
export type IconSize = 'sm' | 'md' | 'lg'
|
||||||
|
export type FontSize = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
theme: Theme
|
||||||
|
iconSize: IconSize
|
||||||
|
fontSize: FontSize
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsContextType {
|
||||||
|
settings: Settings
|
||||||
|
updateSettings: (updates: Partial<Settings>) => void
|
||||||
|
resetSettings: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valeurs par défaut
|
||||||
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
|
theme: 'light',
|
||||||
|
iconSize: 'md',
|
||||||
|
fontSize: 'md',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clé localStorage
|
||||||
|
const STORAGE_KEY = 'homestock-settings'
|
||||||
|
|
||||||
|
// Contexte
|
||||||
|
const SettingsContext = createContext<SettingsContextType | null>(null)
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [settings, setSettings] = useState<Settings>(() => {
|
||||||
|
// Charger depuis localStorage au démarrage
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur lecture settings:', e)
|
||||||
|
}
|
||||||
|
return DEFAULT_SETTINGS
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sauvegarder dans localStorage à chaque changement
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur sauvegarde settings:', e)
|
||||||
|
}
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
// Appliquer le thème au document
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
|
||||||
|
// Retirer les classes de thème existantes
|
||||||
|
root.classList.remove('theme-light', 'theme-gruvbox-dark')
|
||||||
|
|
||||||
|
// Ajouter la nouvelle classe
|
||||||
|
root.classList.add(`theme-${settings.theme}`)
|
||||||
|
|
||||||
|
// Appliquer les variables CSS pour les tailles
|
||||||
|
const iconSizes = { sm: '16px', md: '20px', lg: '24px' }
|
||||||
|
const fontSizes = { sm: '14px', md: '16px', lg: '18px' }
|
||||||
|
|
||||||
|
root.style.setProperty('--icon-size', iconSizes[settings.iconSize])
|
||||||
|
root.style.setProperty('--font-size-base', fontSizes[settings.fontSize])
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
const updateSettings = (updates: Partial<Settings>) => {
|
||||||
|
setSettings((prev) => ({ ...prev, ...updates }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSettings = () => {
|
||||||
|
setSettings(DEFAULT_SETTINGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={{ settings, updateSettings, resetSettings }}>
|
||||||
|
{children}
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook
|
||||||
|
export function useSettings() {
|
||||||
|
const context = useContext(SettingsContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSettings doit être utilisé dans un SettingsProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
6
frontend/src/contexts/index.ts
Normal file
6
frontend/src/contexts/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Export des contextes
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { SettingsProvider, useSettings } from './SettingsContext'
|
||||||
|
export type { Theme, IconSize, FontSize, Settings } from './SettingsContext'
|
||||||
@@ -5,3 +5,4 @@
|
|||||||
export * from './useCategories'
|
export * from './useCategories'
|
||||||
export * from './useLocations'
|
export * from './useLocations'
|
||||||
export * from './useItems'
|
export * from './useItems'
|
||||||
|
export * from './useShops'
|
||||||
|
|||||||
60
frontend/src/hooks/useShops.ts
Normal file
60
frontend/src/hooks/useShops.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Hooks React Query pour les boutiques
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { shopsApi, ShopCreate, ShopUpdate } from '@/api'
|
||||||
|
|
||||||
|
export const shopKeys = {
|
||||||
|
all: ['shops'] as const,
|
||||||
|
lists: () => [...shopKeys.all, 'list'] as const,
|
||||||
|
list: (page: number, pageSize: number) => [...shopKeys.lists(), { page, pageSize }] as const,
|
||||||
|
details: () => [...shopKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: number) => [...shopKeys.details(), id] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShops(page = 1, pageSize = 20) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: shopKeys.list(page, pageSize),
|
||||||
|
queryFn: () => shopsApi.getAll(page, pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShop(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: shopKeys.detail(id),
|
||||||
|
queryFn: () => shopsApi.getById(id),
|
||||||
|
enabled: id > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateShop() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: ShopCreate) => shopsApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: shopKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateShop() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: ShopUpdate }) => shopsApi.update(id, data),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: shopKeys.lists() })
|
||||||
|
queryClient.invalidateQueries({ queryKey: shopKeys.detail(id) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteShop() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => shopsApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: shopKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||||
|
import { SettingsProvider } from './contexts'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './styles/index.css'
|
import './styles/index.css'
|
||||||
|
|
||||||
@@ -29,10 +30,12 @@ const queryClient = new QueryClient({
|
|||||||
// Rendu de l'application
|
// Rendu de l'application
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<SettingsProvider>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
{/* DevTools React Query (visible uniquement en développement) */}
|
<App />
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
{/* DevTools React Query (visible uniquement en développement) */}
|
||||||
</QueryClientProvider>
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</SettingsProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,67 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* === Variables CSS pour les thèmes === */
|
||||||
|
:root {
|
||||||
|
/* Variables de taille (modifiées par JS) */
|
||||||
|
--icon-size: 20px;
|
||||||
|
--font-size-base: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thème Light (par défaut) */
|
||||||
|
.theme-light {
|
||||||
|
--color-bg-primary: #f9fafb;
|
||||||
|
--color-bg-secondary: #ffffff;
|
||||||
|
--color-bg-tertiary: #f3f4f6;
|
||||||
|
--color-text-primary: #111827;
|
||||||
|
--color-text-secondary: #6b7280;
|
||||||
|
--color-border: #e5e7eb;
|
||||||
|
--color-accent: #2563eb;
|
||||||
|
--color-accent-hover: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thème Gruvbox Dark Vintage */
|
||||||
|
.theme-gruvbox-dark {
|
||||||
|
--color-bg-primary: #282828;
|
||||||
|
--color-bg-secondary: #3c3836;
|
||||||
|
--color-bg-tertiary: #504945;
|
||||||
|
--color-text-primary: #ebdbb2;
|
||||||
|
--color-text-secondary: #a89984;
|
||||||
|
--color-border: #665c54;
|
||||||
|
--color-accent: #fe8019;
|
||||||
|
--color-accent-hover: #d65d0e;
|
||||||
|
|
||||||
|
/* Couleurs Gruvbox spécifiques */
|
||||||
|
--gruvbox-red: #fb4934;
|
||||||
|
--gruvbox-green: #b8bb26;
|
||||||
|
--gruvbox-yellow: #fabd2f;
|
||||||
|
--gruvbox-blue: #83a598;
|
||||||
|
--gruvbox-purple: #d3869b;
|
||||||
|
--gruvbox-aqua: #8ec07c;
|
||||||
|
--gruvbox-orange: #fe8019;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Styles de base personnalisés === */
|
/* === Styles de base personnalisés === */
|
||||||
@layer base {
|
@layer base {
|
||||||
/* Reset et styles du body */
|
/* Reset et styles du body */
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-50 text-gray-900 antialiased;
|
@apply antialiased;
|
||||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thème light par défaut */
|
||||||
|
.theme-light body,
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thème Gruvbox */
|
||||||
|
.theme-gruvbox-dark body {
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Titres */
|
/* Titres */
|
||||||
@@ -45,7 +100,7 @@
|
|||||||
@layer components {
|
@layer components {
|
||||||
/* Boutons */
|
/* Boutons */
|
||||||
.btn {
|
.btn {
|
||||||
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@@ -180,3 +235,192 @@
|
|||||||
.animate-spin-slow {
|
.animate-spin-slow {
|
||||||
animation: spin-slow 3s linear infinite;
|
animation: spin-slow 3s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Styles spécifiques au thème Gruvbox Dark === */
|
||||||
|
.theme-gruvbox-dark {
|
||||||
|
/* Body et fond */
|
||||||
|
background-color: #282828;
|
||||||
|
color: #ebdbb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .bg-gray-50 {
|
||||||
|
background-color: #282828 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .bg-white {
|
||||||
|
background-color: #3c3836 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .bg-gray-100 {
|
||||||
|
background-color: #504945 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .bg-gray-200 {
|
||||||
|
background-color: #665c54 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textes */
|
||||||
|
.theme-gruvbox-dark .text-gray-900 {
|
||||||
|
color: #ebdbb2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .text-gray-700 {
|
||||||
|
color: #d5c4a1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .text-gray-600 {
|
||||||
|
color: #bdae93 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .text-gray-500 {
|
||||||
|
color: #a89984 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .text-gray-400 {
|
||||||
|
color: #928374 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bordures */
|
||||||
|
.theme-gruvbox-dark .border-gray-200,
|
||||||
|
.theme-gruvbox-dark .border-gray-300 {
|
||||||
|
border-color: #665c54 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Couleur primaire -> Orange Gruvbox */
|
||||||
|
.theme-gruvbox-dark .text-primary-600 {
|
||||||
|
color: #fe8019 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .bg-primary-600 {
|
||||||
|
background-color: #fe8019 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .bg-primary-600:hover {
|
||||||
|
background-color: #d65d0e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .bg-primary-50 {
|
||||||
|
background-color: rgba(254, 128, 25, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .border-primary-500 {
|
||||||
|
border-color: #fe8019 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .text-primary-700 {
|
||||||
|
color: #fe8019 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .ring-primary-500 {
|
||||||
|
--tw-ring-color: #fe8019 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Couleur secondaire -> Aqua Gruvbox */
|
||||||
|
.theme-gruvbox-dark .text-secondary-600 {
|
||||||
|
color: #8ec07c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Statuts avec couleurs Gruvbox */
|
||||||
|
.theme-gruvbox-dark .bg-green-100 {
|
||||||
|
background-color: rgba(184, 187, 38, 0.2) !important;
|
||||||
|
}
|
||||||
|
.theme-gruvbox-dark .text-green-800 {
|
||||||
|
color: #b8bb26 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .bg-blue-100 {
|
||||||
|
background-color: rgba(131, 165, 152, 0.2) !important;
|
||||||
|
}
|
||||||
|
.theme-gruvbox-dark .text-blue-800 {
|
||||||
|
color: #83a598 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .bg-red-100 {
|
||||||
|
background-color: rgba(251, 73, 52, 0.2) !important;
|
||||||
|
}
|
||||||
|
.theme-gruvbox-dark .text-red-800 {
|
||||||
|
color: #fb4934 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .bg-yellow-100 {
|
||||||
|
background-color: rgba(250, 189, 47, 0.2) !important;
|
||||||
|
}
|
||||||
|
.theme-gruvbox-dark .text-yellow-800 {
|
||||||
|
color: #fabd2f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
.theme-gruvbox-dark .input {
|
||||||
|
background-color: #3c3836;
|
||||||
|
border-color: #665c54;
|
||||||
|
color: #ebdbb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .input::placeholder {
|
||||||
|
color: #928374;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .input:focus {
|
||||||
|
border-color: #fe8019;
|
||||||
|
box-shadow: 0 0 0 2px rgba(254, 128, 25, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.theme-gruvbox-dark .card {
|
||||||
|
background-color: #3c3836;
|
||||||
|
border-color: #504945;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Boutons secondaires */
|
||||||
|
.theme-gruvbox-dark .btn-secondary {
|
||||||
|
background-color: #504945;
|
||||||
|
color: #ebdbb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .btn-secondary:hover {
|
||||||
|
background-color: #665c54;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header et footer */
|
||||||
|
.theme-gruvbox-dark header {
|
||||||
|
background-color: #3c3836 !important;
|
||||||
|
border-color: #504945 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark footer {
|
||||||
|
background-color: #3c3836 !important;
|
||||||
|
border-color: #504945 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover états */
|
||||||
|
.theme-gruvbox-dark .hover\:bg-gray-50:hover {
|
||||||
|
background-color: #504945 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .hover\:bg-gray-100:hover {
|
||||||
|
background-color: #665c54 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .hover\:text-primary-600:hover {
|
||||||
|
color: #fe8019 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shadow ajustée pour le dark mode */
|
||||||
|
.theme-gruvbox-dark .shadow-sm,
|
||||||
|
.theme-gruvbox-dark .shadow-md,
|
||||||
|
.theme-gruvbox-dark .shadow-lg {
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar Gruvbox */
|
||||||
|
.theme-gruvbox-dark .scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background-color: #3c3836;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #665c54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-gruvbox-dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #7c6f64;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user