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",
|
"name": "homehub-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.2",
|
"version": "0.4.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -28,6 +28,28 @@ const inputStyle: React.CSSProperties = {
|
|||||||
|
|
||||||
const noSelect: React.CSSProperties = { userSelect: 'none' }
|
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() {
|
export default function ShoppingPage() {
|
||||||
const [currentList, setCurrentList] = useState<ShoppingListDetail | null>(null)
|
const [currentList, setCurrentList] = useState<ShoppingListDetail | null>(null)
|
||||||
const [allLists, setAllLists] = useState<ShoppingList[]>([])
|
const [allLists, setAllLists] = useState<ShoppingList[]>([])
|
||||||
@@ -45,7 +67,7 @@ export default function ShoppingPage() {
|
|||||||
const [editQty, setEditQty] = useState('')
|
const [editQty, setEditQty] = useState('')
|
||||||
const [editUnit, setEditUnit] = 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 [itemSearch, setItemSearch] = useState('')
|
||||||
const [selections, setSelections] = useState<Selection[]>([])
|
const [selections, setSelections] = useState<Selection[]>([])
|
||||||
const [addSaving, setAddSaving] = useState(false)
|
const [addSaving, setAddSaving] = useState(false)
|
||||||
@@ -115,11 +137,24 @@ export default function ShoppingPage() {
|
|||||||
setShowAddSheet(false)
|
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 => {
|
setSelections(prev => {
|
||||||
const exists = prev.some(s => s.type === 'product' && s.product.id === p.id)
|
const idx = prev.findIndex(s => s.type === 'product' && s.product.id === p.id)
|
||||||
if (exists) return prev.filter(s => !(s.type === 'product' && s.product.id === p.id))
|
if (idx === -1) return [...prev, { type: 'product' as const, product: p, qty: 1 }]
|
||||||
return [...prev, { type: 'product' as const, product: p }]
|
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 => {
|
setSelections(prev => {
|
||||||
const exists = prev.some(s => s.type === 'custom' && s.name === name)
|
const exists = prev.some(s => s.type === 'custom' && s.name === name)
|
||||||
if (exists) return prev
|
if (exists) return prev
|
||||||
return [...prev, { type: 'custom' as const, name }]
|
return [...prev, { type: 'custom' as const, name, qty: 1 }]
|
||||||
})
|
})
|
||||||
setItemSearch('')
|
setItemSearch('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSelection(key: string) {
|
function incrementCustom(name: string) {
|
||||||
setSelections(prev => prev.filter(s => {
|
setSelections(prev => prev.map(s => s.type === 'custom' && s.name === name ? { ...s, qty: s.qty + 1 } : s))
|
||||||
if (s.type === 'product') return s.product.id !== key
|
}
|
||||||
return s.name !== key
|
|
||||||
}))
|
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() {
|
async function handleConfirmAdd() {
|
||||||
@@ -149,10 +190,14 @@ export default function ShoppingPage() {
|
|||||||
if (sel.type === 'product') {
|
if (sel.type === 'product') {
|
||||||
await addItem(currentList.id, {
|
await addItem(currentList.id, {
|
||||||
product_id: sel.product.id,
|
product_id: sel.product.id,
|
||||||
|
quantity: String(sel.qty),
|
||||||
unit: sel.product.default_unit || undefined,
|
unit: sel.product.default_unit || undefined,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await addItem(currentList.id, { custom_name: sel.name })
|
await addItem(currentList.id, {
|
||||||
|
custom_name: sel.name,
|
||||||
|
quantity: String(sel.qty),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
closeAddSheet()
|
closeAddSheet()
|
||||||
@@ -505,58 +550,46 @@ export default function ShoppingPage() {
|
|||||||
{/* Liste scrollable */}
|
{/* Liste scrollable */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
|
||||||
{/* Articles libres sélectionnés (affichés en tête) */}
|
{/* Articles libres sélectionnés */}
|
||||||
{selections.filter(s => s.type === 'custom').map(s => (
|
{(selections.filter(s => s.type === 'custom') as Extract<typeof selections[number], { type: 'custom' }>[]).map(s => (
|
||||||
<div
|
<div
|
||||||
key={`custom:${s.name}`}
|
key={`custom:${s.name}`}
|
||||||
onClick={() => removeSelection(s.name)}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 12,
|
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)',
|
background: 'rgba(142,192,124,0.12)',
|
||||||
borderBottom: '1px solid var(--bg-4)',
|
borderBottom: '1px solid var(--bg-4)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
width: 24, height: 24, borderRadius: '50%',
|
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 15, color: 'var(--ink-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.name}</div>
|
||||||
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={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>article libre</div>
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>article libre</div>
|
||||||
</div>
|
</div>
|
||||||
|
<QtyControls
|
||||||
|
qty={s.qty}
|
||||||
|
onDecrement={() => decrementCustom(s.name)}
|
||||||
|
onIncrement={() => incrementCustom(s.name)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Produits du catalogue */}
|
{/* Produits du catalogue */}
|
||||||
{filteredProducts.map(p => {
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => toggleProduct(p)}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 12,
|
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',
|
background: selected ? 'rgba(142,192,124,0.12)' : 'transparent',
|
||||||
borderBottom: '1px solid var(--bg-4)',
|
borderBottom: '1px solid var(--bg-4)',
|
||||||
transition: 'background 0.1s',
|
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={{ 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}
|
{p.name}
|
||||||
</div>
|
</div>
|
||||||
{(p.brand || p.default_unit) && (
|
{(p.brand || p.default_unit) && (
|
||||||
@@ -565,6 +598,11 @@ export default function ShoppingPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<QtyControls
|
||||||
|
qty={qty}
|
||||||
|
onDecrement={() => decrementProduct(p)}
|
||||||
|
onIncrement={() => incrementProduct(p)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -576,11 +614,11 @@ export default function ShoppingPage() {
|
|||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 12,
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
padding: '14px 16px', cursor: 'pointer',
|
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={{ fontSize: 22, color: 'var(--ok)', fontWeight: 700, lineHeight: 1 }}>+</span>
|
||||||
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 14 }}>
|
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 14, color: 'var(--ink-2)' }}>
|
||||||
Ajouter <strong style={{ color: 'var(--ink-1)' }}>"{itemSearch.trim()}"</strong>
|
Ajouter <strong style={{ color: 'var(--ink-1)' }}>"{itemSearch.trim()}"</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user