feat(shopping): bottom sheet multi-select — quantités inline +/- par article v0.4.3

- QtyControls: boutons +/- design system (var(--ok)/var(--err)/var(--bg-5))
- qty=0 → article non sélectionné, seul "+" visible (var(--ok) plein)
- qty>0 → fond teinté vert, "−" + valeur mono + "+" ; "−" retire si qty atteint 1
- Quantité transmise à addItem lors de la confirmation
- Articles libres : même comportement +/- en tête de liste

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 08:50:22 +02:00
parent 8b2081764e
commit 56f0815667
2 changed files with 80 additions and 42 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "homehub-frontend",
"private": true,
"version": "0.4.2",
"version": "0.4.3",
"type": "module",
"scripts": {
"dev": "vite",
+79 -41
View File
@@ -28,6 +28,28 @@ const inputStyle: React.CSSProperties = {
const noSelect: React.CSSProperties = { userSelect: 'none' }
function QtyControls({ qty, onDecrement, onIncrement }: { qty: number; onDecrement: () => void; onIncrement: () => void }) {
const btnBase: React.CSSProperties = {
width: 32, height: 32, borderRadius: 8, border: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, fontWeight: 700, cursor: 'pointer', flexShrink: 0,
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
{qty > 0 && (
<>
<button onClick={onDecrement} style={{ ...btnBase, background: 'var(--bg-5)', color: 'var(--err)' }}></button>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 15, fontWeight: 700, color: 'var(--ok)', minWidth: 18, textAlign: 'center' }}>{qty}</span>
</>
)}
<button
onClick={onIncrement}
style={{ ...btnBase, background: qty > 0 ? 'var(--bg-5)' : 'var(--ok)', color: qty > 0 ? 'var(--ok)' : '#1d2021', transition: 'all 0.15s' }}
>+</button>
</div>
)
}
export default function ShoppingPage() {
const [currentList, setCurrentList] = useState<ShoppingListDetail | null>(null)
const [allLists, setAllLists] = useState<ShoppingList[]>([])
@@ -45,7 +67,7 @@ export default function ShoppingPage() {
const [editQty, setEditQty] = useState('')
const [editUnit, setEditUnit] = useState('')
type Selection = { type: 'product'; product: Product } | { type: 'custom'; name: string }
type Selection = { type: 'product'; product: Product; qty: number } | { type: 'custom'; name: string; qty: number }
const [itemSearch, setItemSearch] = useState('')
const [selections, setSelections] = useState<Selection[]>([])
const [addSaving, setAddSaving] = useState(false)
@@ -115,11 +137,24 @@ export default function ShoppingPage() {
setShowAddSheet(false)
}
function toggleProduct(p: Product) {
function getProductQty(id: string): number {
return (selections.find(s => s.type === 'product' && s.product.id === id) as Extract<Selection, { type: 'product' }> | undefined)?.qty ?? 0
}
function incrementProduct(p: Product) {
setSelections(prev => {
const exists = prev.some(s => s.type === 'product' && s.product.id === p.id)
if (exists) return prev.filter(s => !(s.type === 'product' && s.product.id === p.id))
return [...prev, { type: 'product' as const, product: p }]
const idx = prev.findIndex(s => s.type === 'product' && s.product.id === p.id)
if (idx === -1) return [...prev, { type: 'product' as const, product: p, qty: 1 }]
return prev.map((s, i) => i === idx ? { ...s, qty: s.qty + 1 } : s)
})
}
function decrementProduct(p: Product) {
setSelections(prev => {
const idx = prev.findIndex(s => s.type === 'product' && s.product.id === p.id)
if (idx === -1) return prev
if (prev[idx].qty <= 1) return prev.filter((_, i) => i !== idx)
return prev.map((s, i) => i === idx ? { ...s, qty: s.qty - 1 } : s)
})
}
@@ -129,16 +164,22 @@ 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 }]
return [...prev, { type: 'custom' as const, name, qty: 1 }]
})
setItemSearch('')
}
function removeSelection(key: string) {
setSelections(prev => prev.filter(s => {
if (s.type === 'product') return s.product.id !== key
return s.name !== key
}))
function incrementCustom(name: string) {
setSelections(prev => prev.map(s => s.type === 'custom' && s.name === name ? { ...s, qty: s.qty + 1 } : s))
}
function decrementCustom(name: string) {
setSelections(prev => {
const idx = prev.findIndex(s => s.type === 'custom' && s.name === name)
if (idx === -1) return prev
if (prev[idx].qty <= 1) return prev.filter((_, i) => i !== idx)
return prev.map((s, i) => i === idx ? { ...s, qty: s.qty - 1 } : s)
})
}
async function handleConfirmAdd() {
@@ -149,10 +190,14 @@ export default function ShoppingPage() {
if (sel.type === 'product') {
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 })
await addItem(currentList.id, {
custom_name: sel.name,
quantity: String(sel.qty),
})
}
}
closeAddSheet()
@@ -505,58 +550,46 @@ export default function ShoppingPage() {
{/* Liste scrollable */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{/* Articles libres sélectionnés (affichés en tête) */}
{selections.filter(s => s.type === 'custom').map(s => (
{/* Articles libres sélectionnés */}
{(selections.filter(s => s.type === 'custom') as Extract<typeof selections[number], { type: 'custom' }>[]).map(s => (
<div
key={`custom:${s.name}`}
onClick={() => removeSelection(s.name)}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 16px', cursor: 'pointer', minHeight: 56,
padding: '10px 16px', minHeight: 52,
background: 'rgba(142,192,124,0.12)',
borderBottom: '1px solid var(--bg-4)',
}}
>
<div style={{
width: 24, height: 24, borderRadius: '50%',
border: '2px solid var(--ok)', background: 'var(--ok)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<span style={{ color: '#1d2021', fontSize: 12, fontWeight: 700 }}></span>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 15, color: 'var(--ink-1)' }}>{s.name}</div>
<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>
</div>
<QtyControls
qty={s.qty}
onDecrement={() => decrementCustom(s.name)}
onIncrement={() => incrementCustom(s.name)}
/>
</div>
))}
{/* Produits du catalogue */}
{filteredProducts.map(p => {
const selected = selections.some(s => s.type === 'product' && s.product.id === p.id)
const qty = getProductQty(p.id)
const selected = qty > 0
return (
<div
key={p.id}
onClick={() => toggleProduct(p)}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 16px', cursor: 'pointer', minHeight: 56,
padding: '10px 16px', minHeight: 52,
background: selected ? 'rgba(142,192,124,0.12)' : 'transparent',
borderBottom: '1px solid var(--bg-4)',
transition: 'background 0.1s',
}}
>
<div style={{
width: 24, height: 24, borderRadius: '50%',
border: `2px solid ${selected ? 'var(--ok)' : 'var(--bg-5)'}`,
background: selected ? 'var(--ok)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
transition: 'all 0.15s',
}}>
{selected && <span style={{ color: '#1d2021', fontSize: 12, fontWeight: 700 }}></span>}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 15, color: 'var(--ink-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 15, color: selected ? 'var(--ink-1)' : 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.name}
</div>
{(p.brand || p.default_unit) && (
@@ -565,6 +598,11 @@ export default function ShoppingPage() {
</div>
)}
</div>
<QtyControls
qty={qty}
onDecrement={() => decrementProduct(p)}
onIncrement={() => incrementProduct(p)}
/>
</div>
)
})}
@@ -576,11 +614,11 @@ export default function ShoppingPage() {
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '14px 16px', cursor: 'pointer',
color: 'var(--info)', borderBottom: '1px solid var(--bg-4)',
borderBottom: '1px solid var(--bg-4)',
}}
>
<span style={{ fontSize: 22, fontWeight: 300, lineHeight: 1 }}>+</span>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 14 }}>
<span style={{ fontSize: 22, color: 'var(--ok)', fontWeight: 700, lineHeight: 1 }}>+</span>
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 14, color: 'var(--ink-2)' }}>
Ajouter <strong style={{ color: 'var(--ink-1)' }}>"{itemSearch.trim()}"</strong>
</span>
</div>