feat(shopping): stats achat produit + édition quantité article
Backend : - Migration 004 : last_purchased_at (DATE) + avg_interval_days (NUMERIC) sur shopping.products - update_item : met à jour les stats au premier cochage d'un article lié à un produit (moyenne mobile exp. 70/30) - ProductResponse expose les deux nouveaux champs Frontend : - ItemRow : long press 500ms → onEdit() (mobile) ; crayon + croix (laptop) - ShoppingPage : modal édition quantité/unité, état editingItem - api/shopping.ts : Product inclut last_purchased_at + avg_interval_days Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# backend/app/api/shopping.py
|
# backend/app/api/shopping.py
|
||||||
import uuid
|
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 import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy import select, text, or_
|
from sqlalchemy import select, text, or_
|
||||||
@@ -270,8 +271,28 @@ async def update_item(
|
|||||||
item = result.scalar_one_or_none()
|
item = result.scalar_one_or_none()
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(404, "Article introuvable")
|
raise HTTPException(404, "Article introuvable")
|
||||||
|
|
||||||
|
was_checked = item.is_checked
|
||||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
setattr(item, field, value)
|
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.commit()
|
||||||
await session.refresh(item, ["product"])
|
await session.refresh(item, ["product"])
|
||||||
return _item_to_response(item)
|
return _item_to_response(item)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class Product(Base):
|
|||||||
quantity_per_unit: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
|
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"))
|
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"))
|
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))
|
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()"))
|
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ class ProductResponse(BaseModel):
|
|||||||
quantity_per_unit: Decimal | None
|
quantity_per_unit: Decimal | None
|
||||||
default_store_id: uuid.UUID | None
|
default_store_id: uuid.UUID | None
|
||||||
frequency_score: int
|
frequency_score: int
|
||||||
|
last_purchased_at: date | None
|
||||||
|
avg_interval_days: Decimal | None
|
||||||
image_path: str | None
|
image_path: str | None
|
||||||
thumbnail_path: str | None
|
thumbnail_path: str | None
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface Product {
|
|||||||
quantity_per_unit: string | null
|
quantity_per_unit: string | null
|
||||||
default_store_id: string | null
|
default_store_id: string | null
|
||||||
frequency_score: number
|
frequency_score: number
|
||||||
|
last_purchased_at: string | null
|
||||||
|
avg_interval_days: string | null
|
||||||
image_path: string | null
|
image_path: string | null
|
||||||
thumbnail_path: string | null
|
thumbnail_path: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,35 +5,62 @@ interface ItemRowProps {
|
|||||||
item: ShoppingItem
|
item: ShoppingItem
|
||||||
onCheck: () => void
|
onCheck: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onEdit?: () => void
|
||||||
storeMode?: boolean
|
storeMode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SWIPE_THRESHOLD = 80
|
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 [offsetX, setOffsetX] = useState(0)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const startX = useRef<number | null>(null)
|
const startX = useRef<number | null>(null)
|
||||||
|
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const didLongPress = useRef(false)
|
||||||
|
|
||||||
|
function clearLongPress() {
|
||||||
|
if (longPressTimer.current) {
|
||||||
|
clearTimeout(longPressTimer.current)
|
||||||
|
longPressTimer.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onTouchStart(e: React.TouchEvent) {
|
function onTouchStart(e: React.TouchEvent) {
|
||||||
if (storeMode) return
|
|
||||||
startX.current = e.touches[0].clientX
|
startX.current = e.touches[0].clientX
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
|
didLongPress.current = false
|
||||||
|
|
||||||
|
if (onEdit) {
|
||||||
|
longPressTimer.current = setTimeout(() => {
|
||||||
|
didLongPress.current = true
|
||||||
|
onEdit()
|
||||||
|
}, LONG_PRESS_MS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTouchMove(e: React.TouchEvent) {
|
function onTouchMove(e: React.TouchEvent) {
|
||||||
if (startX.current === null) return
|
if (startX.current === null) return
|
||||||
const dx = e.touches[0].clientX - startX.current
|
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() {
|
function onTouchEnd() {
|
||||||
if (offsetX < -SWIPE_THRESHOLD) onDelete()
|
clearLongPress()
|
||||||
|
if (!storeMode && offsetX < -SWIPE_THRESHOLD) {
|
||||||
|
onDelete()
|
||||||
|
}
|
||||||
setOffsetX(0)
|
setOffsetX(0)
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
startX.current = null
|
startX.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (didLongPress.current) return
|
||||||
|
onCheck()
|
||||||
|
}
|
||||||
|
|
||||||
const minHeight = storeMode ? 64 : 52
|
const minHeight = storeMode ? 64 : 52
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +81,7 @@ export default function ItemRow({ item, onCheck, onDelete, storeMode = false }:
|
|||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
onClick={onCheck}
|
onClick={handleClick}
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(${offsetX}px)`,
|
transform: `translateX(${offsetX}px)`,
|
||||||
transition: isDragging ? 'none' : 'transform 0.2s ease',
|
transition: isDragging ? 'none' : 'transform 0.2s ease',
|
||||||
@@ -104,17 +131,36 @@ export default function ItemRow({ item, onCheck, onDelete, storeMode = false }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Actions laptop */}
|
||||||
|
<div className="hidden lg:flex" style={{ gap: 4 }} onClick={e => e.stopPropagation()}>
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
title="Modifier quantité"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-5)', border: 'none', color: 'var(--ink-3)',
|
||||||
|
borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 13, minHeight: 32,
|
||||||
|
}}
|
||||||
|
>✏️</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
title="Supprimer"
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none', color: 'var(--ink-4)',
|
||||||
|
borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 16, minHeight: 32,
|
||||||
|
}}
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suppression mode magasin mobile uniquement */}
|
||||||
{storeMode && !item.is_checked && (
|
{storeMode && !item.is_checked && (
|
||||||
<button
|
<button
|
||||||
|
className="flex lg:hidden"
|
||||||
onClick={e => { e.stopPropagation(); onDelete() }}
|
onClick={e => { e.stopPropagation(); onDelete() }}
|
||||||
style={{
|
style={{
|
||||||
background: 'transparent',
|
background: 'transparent', border: 'none', color: 'var(--ink-4)',
|
||||||
border: 'none',
|
fontSize: 18, cursor: 'pointer', padding: '4px 8px', minHeight: 44,
|
||||||
color: 'var(--ink-4)',
|
|
||||||
fontSize: 18,
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '4px 8px',
|
|
||||||
minHeight: 44,
|
|
||||||
}}
|
}}
|
||||||
>✕</button>
|
>✕</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// frontend/src/pages/ShoppingPage.tsx
|
// frontend/src/pages/ShoppingPage.tsx
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { matchesSearch } from '../utils/search'
|
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 {
|
import {
|
||||||
fetchLists, createList, fetchListDetail, deleteList,
|
fetchLists, createList, fetchListDetail, deleteList,
|
||||||
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
|
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
|
||||||
@@ -40,6 +40,9 @@ export default function ShoppingPage() {
|
|||||||
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
||||||
const [showCatalogueModal, setShowCatalogueModal] = useState(false)
|
const [showCatalogueModal, setShowCatalogueModal] = useState(false)
|
||||||
const [showBoutiquesModal, setShowBoutiquesModal] = useState(false)
|
const [showBoutiquesModal, setShowBoutiquesModal] = useState(false)
|
||||||
|
const [editingItem, setEditingItem] = useState<ShoppingItem | null>(null)
|
||||||
|
const [editQty, setEditQty] = useState('')
|
||||||
|
const [editUnit, setEditUnit] = useState('')
|
||||||
|
|
||||||
const [itemSearch, setItemSearch] = useState('')
|
const [itemSearch, setItemSearch] = useState('')
|
||||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
|
const [selectedProduct, setSelectedProduct] = useState<Product | null>(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() {
|
async function handleFinish() {
|
||||||
if (!currentList) return
|
if (!currentList) return
|
||||||
try {
|
try {
|
||||||
@@ -367,6 +390,7 @@ export default function ShoppingPage() {
|
|||||||
item={item}
|
item={item}
|
||||||
onCheck={() => void handleCheckItem(item.id, true)}
|
onCheck={() => void handleCheckItem(item.id, true)}
|
||||||
onDelete={() => void handleDeleteItem(item.id)}
|
onDelete={() => void handleDeleteItem(item.id)}
|
||||||
|
onEdit={() => openEditItem(item)}
|
||||||
storeMode
|
storeMode
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -390,6 +414,7 @@ export default function ShoppingPage() {
|
|||||||
item={item}
|
item={item}
|
||||||
onCheck={() => void handleCheckItem(item.id, false)}
|
onCheck={() => void handleCheckItem(item.id, false)}
|
||||||
onDelete={() => void handleDeleteItem(item.id)}
|
onDelete={() => void handleDeleteItem(item.id)}
|
||||||
|
onEdit={() => openEditItem(item)}
|
||||||
storeMode
|
storeMode
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -414,6 +439,41 @@ export default function ShoppingPage() {
|
|||||||
|
|
||||||
{/* ── Modals ── */}
|
{/* ── Modals ── */}
|
||||||
|
|
||||||
|
{/* Modal édition quantité/unité */}
|
||||||
|
{editingItem && (
|
||||||
|
<Modal title={`Modifier — ${editingItem.display_name}`} onClose={() => setEditingItem(null)} width={320}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Quantité"
|
||||||
|
value={editQty}
|
||||||
|
onChange={e => setEditQty(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={e => e.key === 'Enter' && void handleEditItem()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Unité (kg, L…)"
|
||||||
|
value={editUnit}
|
||||||
|
onChange={e => setEditUnit(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && void handleEditItem()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingItem(null)}
|
||||||
|
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
|
||||||
|
>Annuler</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleEditItem()}
|
||||||
|
style={{ padding: '10px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48 }}
|
||||||
|
>Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
{showAddItemModal && (
|
{showAddItemModal && (
|
||||||
<Modal title="Ajouter un article" onClose={closeAddItemModal} width={420}>
|
<Modal title="Ajouter un article" onClose={closeAddItemModal} width={420}>
|
||||||
{/* Barre de recherche / nom personnalisé */}
|
{/* Barre de recherche / nom personnalisé */}
|
||||||
|
|||||||
Reference in New Issue
Block a user