diff --git a/backend/alembic/versions/003_shopping_enrichments.py b/backend/alembic/versions/003_shopping_enrichments.py new file mode 100644 index 0000000..355ecc6 --- /dev/null +++ b/backend/alembic/versions/003_shopping_enrichments.py @@ -0,0 +1,38 @@ +"""shopping enrichments — champs enrichis produits et boutiques""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = '003' +down_revision = '002' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Nouveaux champs produits + op.add_column('products', sa.Column('description', sa.Text(), nullable=True), schema='shopping') + op.add_column('products', sa.Column('price', sa.Numeric(8, 2), nullable=True), schema='shopping') + op.add_column('products', sa.Column('quantity_per_unit', sa.Numeric(8, 3), nullable=True), schema='shopping') + op.add_column('products', sa.Column('default_store_id', UUID(as_uuid=True), nullable=True), schema='shopping') + op.create_foreign_key( + 'fk_products_default_store', + 'products', 'stores', + ['default_store_id'], ['id'], + source_schema='shopping', referent_schema='shopping', + ondelete='SET NULL', + ) + + # Nouveaux champs boutiques + op.add_column('stores', sa.Column('url', sa.Text(), nullable=True), schema='shopping') + op.add_column('stores', sa.Column('store_type', sa.String(50), nullable=True), schema='shopping') + + +def downgrade() -> None: + op.drop_constraint('fk_products_default_store', 'products', schema='shopping', type_='foreignkey') + op.drop_column('products', 'description', schema='shopping') + op.drop_column('products', 'price', schema='shopping') + op.drop_column('products', 'quantity_per_unit', schema='shopping') + op.drop_column('products', 'default_store_id', schema='shopping') + op.drop_column('stores', 'url', schema='shopping') + op.drop_column('stores', 'store_type', schema='shopping') diff --git a/backend/app/api/shopping.py b/backend/app/api/shopping.py index 8fc57fa..320f93b 100644 --- a/backend/app/api/shopping.py +++ b/backend/app/api/shopping.py @@ -1,5 +1,6 @@ # backend/app/api/shopping.py import uuid +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from sqlalchemy import select, text @@ -11,12 +12,19 @@ from app.models.shopping import ShoppingList, ListItem, Product, Store from app.schemas.shopping import ( ShoppingListCreate, ShoppingListUpdate, ShoppingListResponse, ShoppingListDetailResponse, ListItemCreate, ListItemUpdate, - ListItemResponse, ProductResponse, StoreResponse, + ListItemResponse, ProductCreate, ProductUpdate, ProductResponse, + StoreCreate, StoreUpdate, StoreResponse, ) router = APIRouter() +def _iso_week_label() -> str: + now = datetime.now(tz=timezone.utc) + iso = now.isocalendar() + return f"S{iso[1]} {iso[0]}" + + def _item_to_response(item: ListItem) -> ListItemResponse: display_name = item.custom_name or (item.product.name if item.product else "Article inconnu") return ListItemResponse( @@ -55,12 +63,43 @@ async def list_stores(session: AsyncSession = Depends(get_session)): return result.scalars().all() +@router.post("/stores", response_model=StoreResponse, status_code=201) +async def create_store(payload: StoreCreate, session: AsyncSession = Depends(get_session)): + store = Store(**payload.model_dump()) + session.add(store) + await session.commit() + await session.refresh(store) + return store + + +@router.patch("/stores/{store_id}", response_model=StoreResponse) +async def update_store(store_id: uuid.UUID, payload: StoreUpdate, session: AsyncSession = Depends(get_session)): + store = await session.get(Store, store_id) + if not store: + raise HTTPException(404, "Boutique introuvable") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(store, field, value) + await session.commit() + await session.refresh(store) + return store + + +@router.delete("/stores/{store_id}", status_code=204) +async def delete_store(store_id: uuid.UUID, session: AsyncSession = Depends(get_session)): + store = await session.get(Store, store_id) + if not store: + raise HTTPException(404, "Boutique introuvable") + await session.delete(store) + await session.commit() + return Response(status_code=204) + + # ── Products ────────────────────────────────────────────────────────────────── @router.get("/products", response_model=list[ProductResponse]) async def search_products( q: str | None = Query(default=None), - limit: int = 30, + limit: int = 50, session: AsyncSession = Depends(get_session), ): stmt = select(Product).order_by(Product.frequency_score.desc(), Product.name) @@ -71,6 +110,41 @@ async def search_products( return result.scalars().all() +@router.post("/products", response_model=ProductResponse, status_code=201) +async def create_product(payload: ProductCreate, session: AsyncSession = Depends(get_session)): + product = Product(**payload.model_dump()) + session.add(product) + await session.commit() + await session.refresh(product) + return product + + +@router.patch("/products/{product_id}", response_model=ProductResponse) +async def update_product( + product_id: uuid.UUID, + payload: ProductUpdate, + session: AsyncSession = Depends(get_session), +): + product = await session.get(Product, product_id) + if not product: + raise HTTPException(404, "Produit introuvable") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(product, field, value) + await session.commit() + await session.refresh(product) + return product + + +@router.delete("/products/{product_id}", status_code=204) +async def delete_product(product_id: uuid.UUID, session: AsyncSession = Depends(get_session)): + product = await session.get(Product, product_id) + if not product: + raise HTTPException(404, "Produit introuvable") + await session.delete(product) + await session.commit() + return Response(status_code=204) + + # ── Lists ───────────────────────────────────────────────────────────────────── @router.get("/lists", response_model=list[ShoppingListResponse]) @@ -90,7 +164,10 @@ async def create_shopping_list( payload: ShoppingListCreate, session: AsyncSession = Depends(get_session), ): - lst = ShoppingList(**payload.model_dump()) + data = payload.model_dump() + if not data.get('name'): + data['name'] = _iso_week_label() + lst = ShoppingList(**data) session.add(lst) await session.commit() await session.refresh(lst, ["items"]) @@ -275,7 +352,7 @@ async def generate_magic_list(session: AsyncSession = Depends(get_session)): result = await session.execute(query) rows = result.mappings().all() - new_list = ShoppingList(name="Liste magique", status="draft") + new_list = ShoppingList(name=_iso_week_label(), status="draft") session.add(new_list) await session.flush() @@ -320,7 +397,7 @@ async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(ge unchecked = [i for i in lst.items if not i.is_checked] if unchecked: - new_list = ShoppingList(store_id=lst.store_id, status="draft") + new_list = ShoppingList(store_id=lst.store_id, status="draft", name=_iso_week_label()) session.add(new_list) await session.flush() for item in unchecked: diff --git a/backend/app/models/shopping.py b/backend/app/models/shopping.py index 0144f78..2245de6 100644 --- a/backend/app/models/shopping.py +++ b/backend/app/models/shopping.py @@ -15,10 +15,14 @@ class Product(Base): name: Mapped[str] = mapped_column(String(150), nullable=False) brand: Mapped[str | None] = mapped_column(String(100)) category: Mapped[str | None] = mapped_column(String(50)) + description: Mapped[str | None] = mapped_column(Text) image_path: Mapped[str | None] = mapped_column(String(255)) thumbnail_path: Mapped[str | None] = mapped_column(String(255)) default_unit: Mapped[str | None] = mapped_column(String(20)) barcode: Mapped[str | None] = mapped_column(String(50)) + price: Mapped[Decimal | None] = mapped_column(Numeric(8, 2)) + quantity_per_unit: Mapped[Decimal | None] = mapped_column(Numeric(8, 3)) + default_store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL")) frequency_score: Mapped[int] = mapped_column(Integer, server_default=text("0")) owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()")) @@ -31,6 +35,8 @@ class Store(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(100), nullable=False) location: Mapped[str | None] = mapped_column(Text) + url: Mapped[str | None] = mapped_column(Text) + store_type: Mapped[str | None] = mapped_column(String(50)) owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) diff --git a/backend/app/schemas/shopping.py b/backend/app/schemas/shopping.py index 8247941..e71088d 100644 --- a/backend/app/schemas/shopping.py +++ b/backend/app/schemas/shopping.py @@ -5,11 +5,51 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, model_validator +class StoreCreate(BaseModel): + name: str + location: str | None = None + url: str | None = None + store_type: str | None = None + + +class StoreUpdate(BaseModel): + name: str | None = None + location: str | None = None + url: str | None = None + store_type: str | None = None + + class StoreResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID name: str location: str | None + url: str | None + store_type: str | None + + +class ProductCreate(BaseModel): + name: str + brand: str | None = None + category: str | None = None + description: str | None = None + default_unit: str | None = None + barcode: str | None = None + price: Decimal | None = None + quantity_per_unit: Decimal | None = None + default_store_id: uuid.UUID | None = None + + +class ProductUpdate(BaseModel): + name: str | None = None + brand: str | None = None + category: str | None = None + description: str | None = None + default_unit: str | None = None + barcode: str | None = None + price: Decimal | None = None + quantity_per_unit: Decimal | None = None + default_store_id: uuid.UUID | None = None class ProductResponse(BaseModel): @@ -18,7 +58,12 @@ class ProductResponse(BaseModel): name: str brand: str | None category: str | None + description: str | None default_unit: str | None + barcode: str | None + price: Decimal | None + quantity_per_unit: Decimal | None + default_store_id: uuid.UUID | None frequency_score: int diff --git a/frontend/src/api/shopping.ts b/frontend/src/api/shopping.ts index c887378..aab470a 100644 --- a/frontend/src/api/shopping.ts +++ b/frontend/src/api/shopping.ts @@ -4,6 +4,22 @@ export interface Store { id: string name: string location: string | null + url: string | null + store_type: string | null +} + +export interface StoreCreate { + name: string + location?: string + url?: string + store_type?: string +} + +export interface StoreUpdate { + name?: string + location?: string + url?: string + store_type?: string } export interface Product { @@ -11,10 +27,39 @@ export interface Product { name: string brand: string | null category: string | null + description: string | null default_unit: string | null + barcode: string | null + price: string | null + quantity_per_unit: string | null + default_store_id: string | null frequency_score: number } +export interface ProductCreate { + name: string + brand?: string + category?: string + description?: string + default_unit?: string + barcode?: string + price?: string + quantity_per_unit?: string + default_store_id?: string +} + +export interface ProductUpdate { + name?: string + brand?: string + category?: string + description?: string + default_unit?: string + barcode?: string + price?: string + quantity_per_unit?: string + default_store_id?: string +} + export interface ShoppingItem { id: string product_id: string | null @@ -77,15 +122,62 @@ async function handleResponse(res: Response): Promise { return res.json() as Promise } +// ── Stores ─────────────────────────────────────────────────────────────────── + export async function fetchStores(): Promise { return handleResponse(await fetch(`${BASE}/stores`)) } -export async function searchProducts(q?: string): Promise { - const qs = q ? `?q=${encodeURIComponent(q)}&limit=30` : '?limit=30' - return handleResponse(await fetch(`${BASE}/products${qs}`)) +export async function createStore(data: StoreCreate): Promise { + return handleResponse(await fetch(`${BASE}/stores`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + })) } +export async function updateStore(id: string, data: StoreUpdate): Promise { + return handleResponse(await fetch(`${BASE}/stores/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + })) +} + +export async function deleteStore(id: string): Promise { + return handleResponse(await fetch(`${BASE}/stores/${id}`, { method: 'DELETE' })) +} + +// ── Products ───────────────────────────────────────────────────────────────── + +export async function searchProducts(q?: string, limit = 50): Promise { + const qs = new URLSearchParams({ limit: String(limit) }) + if (q) qs.set('q', q) + return handleResponse(await fetch(`${BASE}/products?${qs}`)) +} + +export async function createProduct(data: ProductCreate): Promise { + return handleResponse(await fetch(`${BASE}/products`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + })) +} + +export async function updateProduct(id: string, data: ProductUpdate): Promise { + return handleResponse(await fetch(`${BASE}/products/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + })) +} + +export async function deleteProduct(id: string): Promise { + return handleResponse(await fetch(`${BASE}/products/${id}`, { method: 'DELETE' })) +} + +// ── Lists ───────────────────────────────────────────────────────────────────── + export async function fetchLists(): Promise { return handleResponse(await fetch(`${BASE}/lists`)) } @@ -114,6 +206,8 @@ export async function deleteList(id: string): Promise { return handleResponse(await fetch(`${BASE}/lists/${id}`, { method: 'DELETE' })) } +// ── Items ───────────────────────────────────────────────────────────────────── + export async function addItem(listId: string, data: ShoppingItemCreate): Promise { return handleResponse(await fetch(`${BASE}/lists/${listId}/items`, { method: 'POST', diff --git a/frontend/src/components/shopping/BoutiquesModal.tsx b/frontend/src/components/shopping/BoutiquesModal.tsx new file mode 100644 index 0000000..d8591ff --- /dev/null +++ b/frontend/src/components/shopping/BoutiquesModal.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react' +import type { Store, StoreCreate } from '../../api/shopping' +import { createStore, updateStore, deleteStore } from '../../api/shopping' +import Modal from '../Modal' + +interface BoutiquesModalProps { + stores: Store[] + onClose: () => void + onStoresChanged: () => void +} + +const STORE_TYPES = ['alimentaire', 'bricolage', 'jardinage', 'pharmacie', 'sport', 'vêtements', 'autre'] + +const inputStyle: React.CSSProperties = { + width: '100%', + background: 'var(--bg-4)', + border: '1px solid var(--bg-5)', + borderRadius: 8, + padding: '8px 12px', + color: 'var(--ink-1)', + fontFamily: 'var(--font-ui)', + fontSize: 14, + boxSizing: 'border-box', +} + +const TYPE_COLORS: Record = { + alimentaire: 'var(--ok)', + bricolage: 'var(--warn)', + jardinage: 'var(--ok)', + pharmacie: 'var(--info)', + sport: 'var(--accent)', + vêtements: 'var(--info)', + autre: 'var(--ink-3)', +} + +const emptyForm: StoreCreate = { name: '', location: '', url: '', store_type: '' } + +export default function BoutiquesModal({ stores, onClose, onStoresChanged }: BoutiquesModalProps) { + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form, setForm] = useState(emptyForm) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + function startCreate() { + setForm(emptyForm) + setEditing(null) + setCreating(true) + setError(null) + } + + function startEdit(s: Store) { + setForm({ + name: s.name, + location: s.location ?? '', + url: s.url ?? '', + store_type: s.store_type ?? '', + }) + setEditing(s) + setCreating(false) + setError(null) + } + + function cancelForm() { + setEditing(null) + setCreating(false) + setError(null) + } + + function cleanForm(f: StoreCreate): StoreCreate { + return { + name: f.name.trim(), + location: f.location?.trim() || undefined, + url: f.url?.trim() || undefined, + store_type: f.store_type?.trim() || undefined, + } + } + + async function handleSave() { + if (!form.name.trim()) return + setSaving(true) + setError(null) + try { + const data = cleanForm(form) + if (creating) { + await createStore(data) + } else if (editing) { + await updateStore(editing.id, data) + } + cancelForm() + onStoresChanged() + } catch { + setError('Erreur lors de la sauvegarde') + } finally { + setSaving(false) + } + } + + async function handleDelete(id: string) { + if (!confirm('Supprimer cette boutique ?')) return + try { + await deleteStore(id) + onStoresChanged() + } catch { + setError('Erreur lors de la suppression') + } + } + + const isFormOpen = creating || editing !== null + + return ( + + {error && ( +

{error}

+ )} + + {isFormOpen ? ( +
+ setForm(f => ({ ...f, name: e.target.value }))} + autoFocus + /> + + setForm(f => ({ ...f, location: e.target.value }))} + /> + setForm(f => ({ ...f, url: e.target.value }))} + type="url" + /> +
+ + +
+
+ ) : ( + <> + + +
+ {stores.length === 0 && ( +

+ Aucune boutique enregistrée +

+ )} + {stores.map(s => ( +
+
+
{s.name}
+
+ {s.store_type && ( + {s.store_type} + )} + {s.location && {s.location}} +
+
+ + +
+ ))} +
+ + )} +
+ ) +} diff --git a/frontend/src/components/shopping/CatalogueModal.tsx b/frontend/src/components/shopping/CatalogueModal.tsx new file mode 100644 index 0000000..0cf331d --- /dev/null +++ b/frontend/src/components/shopping/CatalogueModal.tsx @@ -0,0 +1,265 @@ +import { useState, useEffect } from 'react' +import type { Product, ProductCreate, Store } from '../../api/shopping' +import { searchProducts, createProduct, updateProduct, deleteProduct } from '../../api/shopping' +import Modal from '../Modal' + +interface CatalogueModalProps { + stores: Store[] + onClose: () => void +} + +const CATEGORIES = ['alimentaire', 'boisson', 'hygiène', 'ménage', 'bricolage', 'jardinage', 'autre'] + +const inputStyle: React.CSSProperties = { + width: '100%', + background: 'var(--bg-4)', + border: '1px solid var(--bg-5)', + borderRadius: 8, + padding: '8px 12px', + color: 'var(--ink-1)', + fontFamily: 'var(--font-ui)', + fontSize: 14, + boxSizing: 'border-box', +} + +const emptyForm: ProductCreate = { + name: '', brand: '', category: '', description: '', + default_unit: '', barcode: '', price: '', quantity_per_unit: '', + default_store_id: undefined, +} + +export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) { + const [products, setProducts] = useState([]) + const [search, setSearch] = useState('') + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form, setForm] = useState(emptyForm) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + void loadProducts() + }, [search]) + + async function loadProducts() { + try { + setProducts(await searchProducts(search || undefined)) + } catch { + setError('Erreur de chargement') + } + } + + function startCreate() { + setForm(emptyForm) + setEditing(null) + setCreating(true) + setError(null) + } + + function startEdit(p: Product) { + setForm({ + name: p.name, + brand: p.brand ?? '', + category: p.category ?? '', + description: p.description ?? '', + default_unit: p.default_unit ?? '', + barcode: p.barcode ?? '', + price: p.price ?? '', + quantity_per_unit: p.quantity_per_unit ?? '', + default_store_id: p.default_store_id ?? undefined, + }) + setEditing(p) + setCreating(false) + setError(null) + } + + function cancelForm() { + setEditing(null) + setCreating(false) + setError(null) + } + + function cleanForm(f: ProductCreate): ProductCreate { + return { + name: f.name.trim(), + brand: f.brand?.trim() || undefined, + category: f.category?.trim() || undefined, + description: f.description?.trim() || undefined, + default_unit: f.default_unit?.trim() || undefined, + barcode: f.barcode?.trim() || undefined, + price: f.price?.trim() || undefined, + quantity_per_unit: f.quantity_per_unit?.trim() || undefined, + default_store_id: f.default_store_id || undefined, + } + } + + async function handleSave() { + if (!form.name.trim()) return + setSaving(true) + setError(null) + try { + const data = cleanForm(form) + if (creating) { + await createProduct(data) + } else if (editing) { + await updateProduct(editing.id, data) + } + cancelForm() + void loadProducts() + } catch { + setError('Erreur lors de la sauvegarde') + } finally { + setSaving(false) + } + } + + async function handleDelete(id: string) { + if (!confirm('Supprimer cet article du catalogue ?')) return + try { + await deleteProduct(id) + void loadProducts() + } catch { + setError('Erreur lors de la suppression') + } + } + + const isFormOpen = creating || editing !== null + + return ( + + {error && ( +

{error}

+ )} + + {isFormOpen ? ( + /* ── Formulaire création / édition ── */ +
+ setForm(f => ({ ...f, name: e.target.value }))} + autoFocus + /> +
+ setForm(f => ({ ...f, brand: e.target.value }))} + /> + +
+