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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "homehub-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user