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:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "homehub-frontend",
|
"name": "homehub-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.3",
|
"version": "0.4.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } f
|
|||||||
import {
|
import {
|
||||||
fetchLists, createList, fetchListDetail, deleteList,
|
fetchLists, createList, fetchListDetail, deleteList,
|
||||||
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
|
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
|
||||||
searchProducts,
|
searchProducts, createProduct,
|
||||||
} from '../api/shopping'
|
} from '../api/shopping'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
import BottomSheet from '../components/BottomSheet'
|
import BottomSheet from '../components/BottomSheet'
|
||||||
@@ -67,7 +67,9 @@ 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; 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 [itemSearch, setItemSearch] = useState('')
|
||||||
const [selections, setSelections] = useState<Selection[]>([])
|
const [selections, setSelections] = useState<Selection[]>([])
|
||||||
const [addSaving, setAddSaving] = useState(false)
|
const [addSaving, setAddSaving] = useState(false)
|
||||||
@@ -137,6 +139,24 @@ export default function ShoppingPage() {
|
|||||||
setShowAddSheet(false)
|
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 {
|
function getProductQty(id: string): number {
|
||||||
return (selections.find(s => s.type === 'product' && s.product.id === id) as Extract<Selection, { type: 'product' }> | undefined)?.qty ?? 0
|
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 => {
|
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, qty: 1 }]
|
return [...prev, { type: 'custom' as const, name, qty: 1, addToCatalogue: true }]
|
||||||
})
|
})
|
||||||
setItemSearch('')
|
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) {
|
function incrementCustom(name: string) {
|
||||||
setSelections(prev => prev.map(s => s.type === 'custom' && s.name === name ? { ...s, qty: s.qty + 1 } : s))
|
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() {
|
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)
|
setAddSaving(true)
|
||||||
try {
|
try {
|
||||||
for (const sel of selections) {
|
for (const sel of toProcess) {
|
||||||
if (sel.type === 'product') {
|
if (sel.type === 'product') {
|
||||||
await addItem(currentList.id, {
|
if (sel.existingItemId) {
|
||||||
product_id: sel.product.id,
|
await updateItem(currentList.id, sel.existingItemId, { quantity: String(sel.qty) })
|
||||||
quantity: String(sel.qty),
|
} else {
|
||||||
unit: sel.product.default_unit || undefined,
|
await addItem(currentList.id, {
|
||||||
})
|
product_id: sel.product.id,
|
||||||
|
quantity: String(sel.qty),
|
||||||
|
unit: sel.product.default_unit || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await addItem(currentList.id, {
|
if (sel.existingItemId) {
|
||||||
custom_name: sel.name,
|
await updateItem(currentList.id, sel.existingItemId, { quantity: String(sel.qty) })
|
||||||
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()
|
closeAddSheet()
|
||||||
@@ -479,7 +515,7 @@ export default function ShoppingPage() {
|
|||||||
{/* FAB + — masqué quand le sheet est ouvert */}
|
{/* FAB + — masqué quand le sheet est ouvert */}
|
||||||
{!showAddSheet && (
|
{!showAddSheet && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddSheet(true)}
|
onClick={openAddSheet}
|
||||||
aria-label="Ajouter un article"
|
aria-label="Ajouter un article"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed', bottom: 72, right: 20,
|
position: 'fixed', bottom: 72, right: 20,
|
||||||
@@ -563,7 +599,19 @@ export default function ShoppingPage() {
|
|||||||
>
|
>
|
||||||
<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' }}>{s.name}</div>
|
<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>
|
</div>
|
||||||
<QtyControls
|
<QtyControls
|
||||||
qty={s.qty}
|
qty={s.qty}
|
||||||
@@ -632,22 +680,27 @@ export default function ShoppingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bouton confirm sticky */}
|
{/* Bouton confirm sticky */}
|
||||||
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--bg-4)', flexShrink: 0 }}>
|
{(() => {
|
||||||
<button
|
const actionCount = selections.filter(sel => !sel.existingItemId || sel.qty !== sel.originalQty).length
|
||||||
onClick={() => void handleConfirmAdd()}
|
return (
|
||||||
disabled={selections.length === 0 || addSaving}
|
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--bg-4)', flexShrink: 0 }}>
|
||||||
style={{
|
<button
|
||||||
width: '100%', padding: '14px', borderRadius: 12, border: 'none',
|
onClick={() => void handleConfirmAdd()}
|
||||||
background: selections.length === 0 ? 'var(--bg-4)' : 'var(--accent)',
|
disabled={actionCount === 0 || addSaving}
|
||||||
color: selections.length === 0 ? 'var(--ink-4)' : '#1d2021',
|
style={{
|
||||||
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 16,
|
width: '100%', padding: '14px', borderRadius: 12, border: 'none',
|
||||||
cursor: selections.length === 0 ? 'default' : 'pointer',
|
background: actionCount === 0 ? 'var(--bg-4)' : 'var(--accent)',
|
||||||
minHeight: 52, transition: 'background 0.15s',
|
color: actionCount === 0 ? 'var(--ink-4)' : '#1d2021',
|
||||||
}}
|
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 16,
|
||||||
>
|
cursor: actionCount === 0 ? 'default' : 'pointer',
|
||||||
{addSaving ? 'Ajout en cours…' : selections.length === 0 ? 'Sélectionner des articles' : `Ajouter (${selections.length})`}
|
minHeight: 52, transition: 'background 0.15s',
|
||||||
</button>
|
}}
|
||||||
</div>
|
>
|
||||||
|
{addSaving ? 'Enregistrement…' : actionCount === 0 ? 'Aucune modification' : `Confirmer (${actionCount})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user