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