diff --git a/backend/alembic/versions/004_product_purchase_stats.py b/backend/alembic/versions/004_product_purchase_stats.py new file mode 100644 index 0000000..a668a61 --- /dev/null +++ b/backend/alembic/versions/004_product_purchase_stats.py @@ -0,0 +1,24 @@ +"""004 - stats d'achat sur les produits (last_purchased_at, avg_interval_days) + +Revision ID: 004 +Revises: 003 +Create Date: 2026-05-25 +""" + +from alembic import op +import sqlalchemy as sa + +revision = '004' +down_revision = '003' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('products', sa.Column('last_purchased_at', sa.Date(), nullable=True), schema='shopping') + op.add_column('products', sa.Column('avg_interval_days', sa.Numeric(8, 1), nullable=True), schema='shopping') + + +def downgrade(): + op.drop_column('products', 'avg_interval_days', schema='shopping') + op.drop_column('products', 'last_purchased_at', schema='shopping') diff --git a/backend/app/api/shopping.py b/backend/app/api/shopping.py index 38efe00..678462d 100644 --- a/backend/app/api/shopping.py +++ b/backend/app/api/shopping.py @@ -1,6 +1,7 @@ # backend/app/api/shopping.py import uuid -from datetime import datetime, timezone +from datetime import datetime, timezone, date as date_type +from decimal import Decimal from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from sqlalchemy import select, text, or_ @@ -270,8 +271,28 @@ async def update_item( item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Article introuvable") + + was_checked = item.is_checked for field, value in payload.model_dump(exclude_unset=True).items(): setattr(item, field, value) + + # Mise à jour des stats produit lors du premier cochage + if not was_checked and item.is_checked and item.product_id: + product = await session.get(Product, item.product_id) + if product: + today = date_type.today() + if product.last_purchased_at and product.last_purchased_at < today: + days = (today - product.last_purchased_at).days + if product.avg_interval_days is None: + product.avg_interval_days = Decimal(str(days)) + else: + # Moyenne mobile exponentielle (70 % passé, 30 % nouvel intervalle) + product.avg_interval_days = Decimal(str( + round(float(product.avg_interval_days) * 0.7 + days * 0.3, 1) + )) + product.last_purchased_at = today + product.frequency_score += 1 + await session.commit() await session.refresh(item, ["product"]) return _item_to_response(item) diff --git a/backend/app/models/shopping.py b/backend/app/models/shopping.py index 2245de6..8e8de75 100644 --- a/backend/app/models/shopping.py +++ b/backend/app/models/shopping.py @@ -24,6 +24,8 @@ class Product(Base): 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")) + last_purchased_at: Mapped[date | None] = mapped_column(Date) + avg_interval_days: Mapped[Decimal | None] = mapped_column(Numeric(8, 1)) 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()")) diff --git a/backend/app/schemas/shopping.py b/backend/app/schemas/shopping.py index 915f13a..c252ee4 100644 --- a/backend/app/schemas/shopping.py +++ b/backend/app/schemas/shopping.py @@ -69,6 +69,8 @@ class ProductResponse(BaseModel): quantity_per_unit: Decimal | None default_store_id: uuid.UUID | None frequency_score: int + last_purchased_at: date | None + avg_interval_days: Decimal | None image_path: str | None thumbnail_path: str | None diff --git a/frontend/src/api/shopping.ts b/frontend/src/api/shopping.ts index b41b7f0..1bc0014 100644 --- a/frontend/src/api/shopping.ts +++ b/frontend/src/api/shopping.ts @@ -34,6 +34,8 @@ export interface Product { quantity_per_unit: string | null default_store_id: string | null frequency_score: number + last_purchased_at: string | null + avg_interval_days: string | null image_path: string | null thumbnail_path: string | null } diff --git a/frontend/src/components/shopping/ItemRow.tsx b/frontend/src/components/shopping/ItemRow.tsx index 7de768a..bce11ad 100644 --- a/frontend/src/components/shopping/ItemRow.tsx +++ b/frontend/src/components/shopping/ItemRow.tsx @@ -5,35 +5,62 @@ interface ItemRowProps { item: ShoppingItem onCheck: () => void onDelete: () => void + onEdit?: () => void storeMode?: boolean } const SWIPE_THRESHOLD = 80 +const LONG_PRESS_MS = 500 -export default function ItemRow({ item, onCheck, onDelete, storeMode = false }: ItemRowProps) { +export default function ItemRow({ item, onCheck, onDelete, onEdit, storeMode = false }: ItemRowProps) { const [offsetX, setOffsetX] = useState(0) const [isDragging, setIsDragging] = useState(false) const startX = useRef(null) + const longPressTimer = useRef | null>(null) + const didLongPress = useRef(false) + + function clearLongPress() { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current) + longPressTimer.current = null + } + } function onTouchStart(e: React.TouchEvent) { - if (storeMode) return startX.current = e.touches[0].clientX setIsDragging(true) + didLongPress.current = false + + if (onEdit) { + longPressTimer.current = setTimeout(() => { + didLongPress.current = true + onEdit() + }, LONG_PRESS_MS) + } } function onTouchMove(e: React.TouchEvent) { if (startX.current === null) return const dx = e.touches[0].clientX - startX.current - setOffsetX(Math.max(Math.min(dx, 0), -120)) + if (Math.abs(dx) > 8) clearLongPress() + if (!storeMode) setOffsetX(Math.max(Math.min(dx, 0), -120)) } function onTouchEnd() { - if (offsetX < -SWIPE_THRESHOLD) onDelete() + clearLongPress() + if (!storeMode && offsetX < -SWIPE_THRESHOLD) { + onDelete() + } setOffsetX(0) setIsDragging(false) startX.current = null } + function handleClick() { + if (didLongPress.current) return + onCheck() + } + const minHeight = storeMode ? 64 : 52 return ( @@ -54,7 +81,7 @@ export default function ItemRow({ item, onCheck, onDelete, storeMode = false }: onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} - onClick={onCheck} + onClick={handleClick} style={{ transform: `translateX(${offsetX}px)`, transition: isDragging ? 'none' : 'transform 0.2s ease', @@ -104,17 +131,36 @@ export default function ItemRow({ item, onCheck, onDelete, storeMode = false }: )} + {/* Actions laptop */} +
e.stopPropagation()}> + {onEdit && ( + + )} + +
+ + {/* Suppression mode magasin mobile uniquement */} {storeMode && !item.is_checked && ( )} diff --git a/frontend/src/pages/ShoppingPage.tsx b/frontend/src/pages/ShoppingPage.tsx index 520fa64..94066f4 100644 --- a/frontend/src/pages/ShoppingPage.tsx +++ b/frontend/src/pages/ShoppingPage.tsx @@ -1,7 +1,7 @@ // frontend/src/pages/ShoppingPage.tsx import { useState, useEffect, useCallback } from 'react' import { matchesSearch } from '../utils/search' -import type { ShoppingListDetail, ShoppingList, Store, Product } from '../api/shopping' +import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } from '../api/shopping' import { fetchLists, createList, fetchListDetail, deleteList, addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList, @@ -40,6 +40,9 @@ export default function ShoppingPage() { const [showHistoryModal, setShowHistoryModal] = useState(false) const [showCatalogueModal, setShowCatalogueModal] = useState(false) const [showBoutiquesModal, setShowBoutiquesModal] = useState(false) + const [editingItem, setEditingItem] = useState(null) + const [editQty, setEditQty] = useState('') + const [editUnit, setEditUnit] = useState('') const [itemSearch, setItemSearch] = useState('') const [selectedProduct, setSelectedProduct] = useState(null) @@ -172,6 +175,26 @@ export default function ShoppingPage() { } } + function openEditItem(item: ShoppingItem) { + setEditingItem(item) + setEditQty(item.quantity ?? '') + setEditUnit(item.unit ?? '') + } + + async function handleEditItem() { + if (!currentList || !editingItem) return + try { + await updateItem(currentList.id, editingItem.id, { + quantity: editQty || undefined, + unit: editUnit || undefined, + }) + setEditingItem(null) + void refreshCurrentList() + } catch { + setError('Erreur lors de la modification') + } + } + async function handleFinish() { if (!currentList) return try { @@ -367,6 +390,7 @@ export default function ShoppingPage() { item={item} onCheck={() => void handleCheckItem(item.id, true)} onDelete={() => void handleDeleteItem(item.id)} + onEdit={() => openEditItem(item)} storeMode /> ))} @@ -390,6 +414,7 @@ export default function ShoppingPage() { item={item} onCheck={() => void handleCheckItem(item.id, false)} onDelete={() => void handleDeleteItem(item.id)} + onEdit={() => openEditItem(item)} storeMode /> ))} @@ -414,6 +439,41 @@ export default function ShoppingPage() { {/* ── Modals ── */} + {/* Modal édition quantité/unité */} + {editingItem && ( + setEditingItem(null)} width={320}> +
+
+ setEditQty(e.target.value)} + autoFocus + onKeyDown={e => e.key === 'Enter' && void handleEditItem()} + /> + setEditUnit(e.target.value)} + onKeyDown={e => e.key === 'Enter' && void handleEditItem()} + /> +
+
+ + +
+
+
+ )} + {showAddItemModal && ( {/* Barre de recherche / nom personnalisé */}