feat(shopping): pré-remplissage bottom sheet + toggle catalogue v0.4.4

- openAddSheet() pré-charge les articles non cochés de la liste courante
  (qty existante, PATCH si modifiée / POST si nouvel article)
- Toggle "Ajouter au catalogue" sur les articles libres nouvellement saisis
  (coché par défaut, créé via createProduct puis lié en product_id)
- Bouton "Confirmer (N)" comptant uniquement les actions réelles
  (new items + existing avec qty modifiée)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 09:10:46 +02:00
parent 56f0815667
commit a949a22cca
2 changed files with 86 additions and 33 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "homehub-frontend",
"private": true,
"version": "0.4.3",
"version": "0.4.4",
"type": "module",
"scripts": {
"dev": "vite",
+85 -32
View File
@@ -5,7 +5,7 @@ import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } f
import {
fetchLists, createList, fetchListDetail, deleteList,
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
searchProducts,
searchProducts, createProduct,
} from '../api/shopping'
import Modal from '../components/Modal'
import BottomSheet from '../components/BottomSheet'
@@ -67,7 +67,9 @@ export default function ShoppingPage() {
const [editQty, setEditQty] = useState('')
const [editUnit, setEditUnit] = useState('')
type Selection = { type: 'product'; product: Product; qty: number } | { type: 'custom'; name: string; qty: number }
type Selection =
| { type: 'product'; product: Product; qty: number; existingItemId?: string; originalQty?: number }
| { type: 'custom'; name: string; qty: number; addToCatalogue: boolean; existingItemId?: string; originalQty?: number }
const [itemSearch, setItemSearch] = useState('')
const [selections, setSelections] = useState<Selection[]>([])
const [addSaving, setAddSaving] = useState(false)
@@ -137,6 +139,24 @@ export default function ShoppingPage() {
setShowAddSheet(false)
}
function openAddSheet() {
setItemSearch('')
const initial: Selection[] = []
if (currentList) {
for (const item of currentList.items.filter(i => !i.is_checked)) {
const qty = Math.max(1, Math.round(parseFloat(item.quantity ?? '1') || 1))
if (item.product_id) {
const product = products.find(p => p.id === item.product_id)
if (product) initial.push({ type: 'product', product, qty, existingItemId: item.id, originalQty: qty })
} else if (item.custom_name) {
initial.push({ type: 'custom', name: item.custom_name, qty, addToCatalogue: false, existingItemId: item.id, originalQty: qty })
}
}
}
setSelections(initial)
setShowAddSheet(true)
}
function getProductQty(id: string): number {
return (selections.find(s => s.type === 'product' && s.product.id === id) as Extract<Selection, { type: 'product' }> | undefined)?.qty ?? 0
}
@@ -164,11 +184,17 @@ export default function ShoppingPage() {
setSelections(prev => {
const exists = prev.some(s => s.type === 'custom' && s.name === name)
if (exists) return prev
return [...prev, { type: 'custom' as const, name, qty: 1 }]
return [...prev, { type: 'custom' as const, name, qty: 1, addToCatalogue: true }]
})
setItemSearch('')
}
function toggleCatalogue(name: string) {
setSelections(prev => prev.map(s =>
s.type === 'custom' && s.name === name ? { ...s, addToCatalogue: !s.addToCatalogue } : s
))
}
function incrementCustom(name: string) {
setSelections(prev => prev.map(s => s.type === 'custom' && s.name === name ? { ...s, qty: s.qty + 1 } : s))
}
@@ -183,21 +209,31 @@ export default function ShoppingPage() {
}
async function handleConfirmAdd() {
if (!currentList || selections.length === 0) return
if (!currentList) return
const toProcess = selections.filter(sel => !sel.existingItemId || sel.qty !== sel.originalQty)
if (toProcess.length === 0) { closeAddSheet(); return }
setAddSaving(true)
try {
for (const sel of selections) {
for (const sel of toProcess) {
if (sel.type === 'product') {
await addItem(currentList.id, {
product_id: sel.product.id,
quantity: String(sel.qty),
unit: sel.product.default_unit || undefined,
})
if (sel.existingItemId) {
await updateItem(currentList.id, sel.existingItemId, { quantity: String(sel.qty) })
} else {
await addItem(currentList.id, {
product_id: sel.product.id,
quantity: String(sel.qty),
unit: sel.product.default_unit || undefined,
})
}
} else {
await addItem(currentList.id, {
custom_name: sel.name,
quantity: String(sel.qty),
})
if (sel.existingItemId) {
await updateItem(currentList.id, sel.existingItemId, { quantity: String(sel.qty) })
} else if (sel.addToCatalogue) {
const newProduct = await createProduct({ name: sel.name })
await addItem(currentList.id, { product_id: newProduct.id, quantity: String(sel.qty) })
} else {
await addItem(currentList.id, { custom_name: sel.name, quantity: String(sel.qty) })
}
}
}
closeAddSheet()
@@ -479,7 +515,7 @@ export default function ShoppingPage() {
{/* FAB + — masqué quand le sheet est ouvert */}
{!showAddSheet && (
<button
onClick={() => setShowAddSheet(true)}
onClick={openAddSheet}
aria-label="Ajouter un article"
style={{
position: 'fixed', bottom: 72, right: 20,
@@ -563,7 +599,19 @@ export default function ShoppingPage() {
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 15, color: 'var(--ink-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.name}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>article libre</div>
{!s.existingItemId ? (
<label style={{ display: 'flex', alignItems: 'center', gap: 5, marginTop: 3, cursor: 'pointer' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={s.addToCatalogue}
onChange={() => toggleCatalogue(s.name)}
style={{ accentColor: 'var(--accent)', width: 14, height: 14, cursor: 'pointer', flexShrink: 0 }}
/>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 11, color: 'var(--ink-3)' }}>Ajouter au catalogue</span>
</label>
) : (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>article libre</div>
)}
</div>
<QtyControls
qty={s.qty}
@@ -632,22 +680,27 @@ export default function ShoppingPage() {
</div>
{/* Bouton confirm sticky */}
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--bg-4)', flexShrink: 0 }}>
<button
onClick={() => void handleConfirmAdd()}
disabled={selections.length === 0 || addSaving}
style={{
width: '100%', padding: '14px', borderRadius: 12, border: 'none',
background: selections.length === 0 ? 'var(--bg-4)' : 'var(--accent)',
color: selections.length === 0 ? 'var(--ink-4)' : '#1d2021',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 16,
cursor: selections.length === 0 ? 'default' : 'pointer',
minHeight: 52, transition: 'background 0.15s',
}}
>
{addSaving ? 'Ajout en cours…' : selections.length === 0 ? 'Sélectionner des articles' : `Ajouter (${selections.length})`}
</button>
</div>
{(() => {
const actionCount = selections.filter(sel => !sel.existingItemId || sel.qty !== sel.originalQty).length
return (
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--bg-4)', flexShrink: 0 }}>
<button
onClick={() => void handleConfirmAdd()}
disabled={actionCount === 0 || addSaving}
style={{
width: '100%', padding: '14px', borderRadius: 12, border: 'none',
background: actionCount === 0 ? 'var(--bg-4)' : 'var(--accent)',
color: actionCount === 0 ? 'var(--ink-4)' : '#1d2021',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 16,
cursor: actionCount === 0 ? 'default' : 'pointer',
minHeight: 52, transition: 'background 0.15s',
}}
>
{addSaving ? 'Enregistrement…' : actionCount === 0 ? 'Aucune modification' : `Confirmer (${actionCount})`}
</button>
</div>
)
})()}
</BottomSheet>
)}