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:
2026-05-25 07:08:14 +02:00
co-authored by Claude Sonnet 4.6
parent 377531d08e
commit dee7037d70
7 changed files with 171 additions and 14 deletions
+2
View File
@@ -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
}
+58 -12
View File
@@ -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<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) {
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 }:
)}
</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 && (
<button
className="flex lg:hidden"
onClick={e => { e.stopPropagation(); onDelete() }}
style={{
background: 'transparent',
border: 'none',
color: 'var(--ink-4)',
fontSize: 18,
cursor: 'pointer',
padding: '4px 8px',
minHeight: 44,
background: 'transparent', border: 'none', color: 'var(--ink-4)',
fontSize: 18, cursor: 'pointer', padding: '4px 8px', minHeight: 44,
}}
></button>
)}
+61 -1
View File
@@ -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<ShoppingItem | null>(null)
const [editQty, setEditQty] = useState('')
const [editUnit, setEditUnit] = useState('')
const [itemSearch, setItemSearch] = useState('')
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() {
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 && (
<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 && (
<Modal title="Ajouter un article" onClose={closeAddItemModal} width={420}>
{/* Barre de recherche / nom personnalisé */}