import ali

This commit is contained in:
2026-02-01 01:45:51 +01:00
parent bdbfa4e25a
commit 46d6d88ce5
48 changed files with 6714 additions and 185 deletions

2532
ali.csv Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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"

View File

@@ -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.

View File

@@ -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__":

View File

@@ -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",
] ]

View File

@@ -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."""

View 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}')>"

View File

@@ -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)

View 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

View File

@@ -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",
] ]

View 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()

View 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,
)

View 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)

View File

@@ -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):

View 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")

View File

@@ -1,24 +1,169 @@
# Contrat derreurs 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 derreur : <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
View 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

View File

@@ -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>

View 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

View File

@@ -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)

View File

@@ -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({

View 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`
},
}

View 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
},
}

View File

@@ -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
View 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
},
}

View File

@@ -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',
}

View File

@@ -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,

View File

@@ -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>

View 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>
)
}

View File

@@ -0,0 +1,5 @@
/**
* Export des composants documents
*/
export { DocumentUpload } from './DocumentUpload'

View 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>
)
}

View File

@@ -0,0 +1 @@
export { ImportPage } from './ImportPage'

View File

@@ -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>
) )
} }

View 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>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View 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>
)
}

View File

@@ -0,0 +1,5 @@
/**
* Export des composants settings
*/
export { SettingsPage } from './SettingsPage'

View 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>
)
}

View File

@@ -0,0 +1 @@
export { ShopForm } from './ShopForm'

View 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
}

View File

@@ -0,0 +1,6 @@
/**
* Export des contextes
*/
export { SettingsProvider, useSettings } from './SettingsContext'
export type { Theme, IconSize, FontSize, Settings } from './SettingsContext'

View File

@@ -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'

View 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() })
},
})
}

View File

@@ -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>
) )

View File

@@ -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;
}