Files
home_hub/frontend/src/pages/ShoppingPage.tsx
T
gilles aa9ac2a6ea feat(shopping): tags sur les articles du catalogue
- Migration 006 : colonne tags TEXT[] sur shopping.products
- Modèle SQLAlchemy + schémas Pydantic mis à jour (ProductCreate/Update/Response)
- Interface TypeScript Product/ProductCreate/ProductUpdate avec tags?: string[]
- CatalogueModal : chip input (Entrée/virgule pour ajouter, clic pour supprimer, Backspace pour retirer le dernier)
- Recherche dans le catalogue et le bottom sheet étendue aux tags (insensible aux accents)
- Tags affichés en pills dans la liste du catalogue

v0.4.12

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 12:57:25 +02:00

803 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// frontend/src/pages/ShoppingPage.tsx
import { useState, useEffect, useCallback, useRef } from 'react'
import { matchesSearch } from '../utils/search'
import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } from '../api/shopping'
import {
fetchLists, createList, fetchListDetail, deleteList,
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
searchProducts, createProduct,
} from '../api/shopping'
import Modal from '../components/Modal'
import BottomSheet from '../components/BottomSheet'
import ItemRow from '../components/shopping/ItemRow'
import CatalogueModal from '../components/shopping/CatalogueModal'
import BoutiquesModal from '../components/shopping/BoutiquesModal'
import { useWakeLock } from '../hooks/useWakeLock'
import { useActionButton } from '../contexts/ActionButtonContext'
const inputStyle: React.CSSProperties = {
width: '100%',
background: 'var(--bg-4)',
border: '1px solid var(--bg-5)',
borderRadius: 8,
padding: '10px 12px',
color: 'var(--ink-1)',
fontFamily: 'var(--font-ui)',
fontSize: 14,
boxSizing: 'border-box',
}
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[]>([])
const [stores, setStores] = useState<Store[]>([])
const [products, setProducts] = useState<Product[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [showAddSheet, setShowAddSheet] = useState(false)
const [showHistoryModal, setShowHistoryModal] = useState(false)
const [showCatalogueModal, setShowCatalogueModal] = useState(false)
const [showBoutiquesModal, setShowBoutiquesModal] = useState(false)
const [editingItem, setEditingItem] = useState<ShoppingItem | null>(null)
const [editQty, setEditQty] = useState('')
const [editUnit, setEditUnit] = useState('')
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)
useWakeLock(currentList !== null)
const { setActionButton } = useActionButton()
const openAddSheetRef = useRef(openAddSheet)
useEffect(() => { openAddSheetRef.current = openAddSheet })
useEffect(() => {
if (!currentList || showAddSheet) {
setActionButton(null)
return
}
setActionButton(
<button
onClick={() => openAddSheetRef.current()}
aria-label="Ajouter un article"
style={{
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)', color: '#1d2021', border: 'none',
fontSize: 22, cursor: 'pointer',
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<i className="fa-solid fa-cart-plus" />
</button>
)
return () => setActionButton(null)
}, [currentList, showAddSheet, setActionButton])
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [listsData, storesData, productsData] = await Promise.all([fetchLists(), fetchStores(), searchProducts()])
setAllLists(listsData)
setStores(storesData)
setProducts([...productsData].sort((a, b) => a.name.localeCompare(b.name, 'fr')))
const current = listsData.find(l => l.status === 'draft' || l.status === 'active')
if (current) {
setCurrentList(await fetchListDetail(current.id))
} else {
setCurrentList(null)
}
} catch {
setError('Erreur lors du chargement')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { void loadData() }, [loadData])
async function refreshProducts() {
try {
const data = await searchProducts()
setProducts([...data].sort((a, b) => a.name.localeCompare(b.name, 'fr')))
} catch { /* silencieux */ }
}
async function refreshCurrentList() {
if (!currentList) return
try {
setCurrentList(await fetchListDetail(currentList.id))
} catch {
setError('Erreur de rafraîchissement')
}
}
async function handleCreateManualList() {
try {
const detail = await createList({})
setCurrentList(detail)
void loadData()
} catch {
setError('Erreur lors de la création')
}
}
async function handleGenerateMagicList() {
setGenerating(true)
setError(null)
try {
const detail = await generateMagicList()
setCurrentList(detail)
void loadData()
} catch {
setError('Erreur lors de la génération')
} finally {
setGenerating(false)
}
}
function closeAddSheet() {
setItemSearch('')
setSelections([])
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
}
function incrementProduct(p: Product) {
setSelections(prev => {
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
const sel = prev[idx]
if (sel.qty <= 1) {
// article pré-chargé : qty=0 = marqué pour suppression
if (sel.existingItemId) return prev.map((s, i) => i === idx ? { ...s, qty: 0 } : s)
return prev.filter((_, i) => i !== idx)
}
return prev.map((s, i) => i === idx ? { ...s, qty: s.qty - 1 } : s)
})
}
function addCustomItem() {
const raw = itemSearch.trim()
if (!raw) return
const name = raw.charAt(0).toUpperCase() + raw.slice(1)
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, 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))
}
function decrementCustom(name: string) {
setSelections(prev => {
const idx = prev.findIndex(s => s.type === 'custom' && s.name === name)
if (idx === -1) return prev
const sel = prev[idx]
if (sel.qty <= 1) {
if (sel.existingItemId) return prev.map((s, i) => i === idx ? { ...s, qty: 0 } : s)
return prev.filter((_, i) => i !== idx)
}
return prev.map((s, i) => i === idx ? { ...s, qty: s.qty - 1 } : s)
})
}
async function handleConfirmAdd() {
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 toProcess) {
if (sel.type === 'product') {
if (sel.existingItemId) {
if (sel.qty === 0) await deleteItem(currentList.id, sel.existingItemId)
else 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 {
if (sel.existingItemId) {
if (sel.qty === 0) await deleteItem(currentList.id, sel.existingItemId)
else 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()
void refreshCurrentList()
} catch {
setError("Erreur lors de l'ajout")
} finally {
setAddSaving(false)
}
}
async function handleCheckItem(itemId: string, checked: boolean) {
if (!currentList) return
try {
await updateItem(currentList.id, itemId, { is_checked: checked })
void refreshCurrentList()
} catch {
setError('Erreur lors du cochage')
}
}
async function handleDeleteItem(itemId: string) {
if (!currentList) return
try {
await deleteItem(currentList.id, itemId)
void refreshCurrentList()
} catch {
setError('Erreur lors de la suppression')
}
}
function openEditItem(item: ShoppingItem) {
setEditingItem(item)
setEditQty(item.quantity ?? '')
setEditUnit(item.unit ?? '')
}
async function handleEditItem() {
if (!currentList || !editingItem) return
try {
await updateItem(currentList.id, editingItem.id, {
quantity: editQty || undefined,
unit: editUnit || undefined,
})
setEditingItem(null)
void refreshCurrentList()
} catch {
setError('Erreur lors de la modification')
}
}
async function handleFinish() {
if (!currentList) return
try {
await finishShopping(currentList.id)
void loadData()
} catch {
setError('Erreur lors de la finalisation')
}
}
async function handleDeleteCurrentList() {
if (!currentList) return
if (!confirm('Supprimer la liste en cours ?')) return
try {
await deleteList(currentList.id)
void loadData()
} catch {
setError('Erreur lors de la suppression')
}
}
async function handleOpenHistoryList(list: ShoppingList) {
try {
const detail = await fetchListDetail(list.id)
setCurrentList(detail)
setShowHistoryModal(false)
} catch {
setError('Erreur lors du chargement')
}
}
const actionCount = selections.filter(sel => !sel.existingItemId || sel.qty !== sel.originalQty).length
// Tri : non cochés alpha, cochés alpha (en bas)
const sortedItems = [...(currentList?.items ?? [])].sort((a, b) => {
if (a.is_checked !== b.is_checked) return a.is_checked ? 1 : -1
return a.display_name.localeCompare(b.display_name, 'fr')
})
const uncheckedItems = sortedItems.filter(i => !i.is_checked)
const checkedItems = sortedItems.filter(i => i.is_checked)
const hasCurrentList = currentList !== null
const pastLists = allLists.filter(l => l.status === 'done')
const filteredProducts = products.filter(p => {
const term = itemSearch.trim()
if (!term) return true
if (matchesSearch(p.name, term)) return true
if (matchesSearch(p.brand ?? '', term)) return true
if (p.tags?.some(t => matchesSearch(t, term))) return true
return false
})
return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100%' }}>
{/* ── En-tête ── */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '12px 16px',
background: 'var(--bg-2)',
borderBottom: '1px solid var(--bg-4)',
position: 'sticky', top: 0, zIndex: 10,
}}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, fontSize: 18, ...noSelect }}>
{hasCurrentList ? (currentList.name ?? 'Courses') : 'Courses'}
</h1>
<button
onClick={() => setShowCatalogueModal(true)}
style={{
background: 'var(--bg-3)', border: '1px solid var(--bg-5)',
borderRadius: 8, color: 'var(--ink-2)', cursor: 'pointer',
padding: '6px 12px', fontFamily: 'var(--font-ui)', fontSize: 12, minHeight: 36,
...noSelect,
}}
>Articles</button>
<button
onClick={() => setShowBoutiquesModal(true)}
style={{
background: 'var(--bg-3)', border: '1px solid var(--bg-5)',
borderRadius: 8, color: 'var(--ink-2)', cursor: 'pointer',
padding: '6px 12px', fontFamily: 'var(--font-ui)', fontSize: 12, minHeight: 36,
...noSelect,
}}
>Boutiques</button>
</div>
{/* ── Erreur ── */}
{error && (
<div style={{ padding: '8px 16px' }}>
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', margin: 0, fontSize: 13, fontFamily: 'var(--font-ui)' }}>
{error}
</p>
</div>
)}
{loading && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 40, fontFamily: 'var(--font-ui)', ...noSelect }}>Chargement</p>
)}
{/* ── Vue : pas de liste en cours ── */}
{!loading && !hasCurrentList && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: 32 }}>
<p style={{ color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 15, margin: 0, textAlign: 'center', ...noSelect }}>
Aucune liste de courses en cours
</p>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
<button
onClick={() => void handleCreateManualList()}
style={{
padding: '14px 24px', borderRadius: 12,
background: 'var(--ok)', color: '#1d2021', border: 'none',
cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 700,
fontSize: 15, minHeight: 56, minWidth: 140,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
...noSelect,
}}
>+ Nouvelle liste</button>
<button
onClick={() => void handleGenerateMagicList()}
disabled={generating}
style={{
padding: '14px 24px', borderRadius: 12,
background: generating ? 'var(--bg-4)' : 'var(--bg-3)',
color: generating ? 'var(--ink-4)' : 'var(--accent)',
border: '1px solid var(--bg-5)',
cursor: generating ? 'not-allowed' : 'pointer',
fontFamily: 'var(--font-ui)', fontWeight: 600,
fontSize: 15, minHeight: 56, minWidth: 140,
...noSelect,
}}
>
<i className="fa-solid fa-wand-magic-sparkles" style={{ marginRight: 8 }} />
{generating ? 'Génération…' : 'Liste magique'}
</button>
</div>
{pastLists.length > 0 && (
<button
onClick={() => setShowHistoryModal(true)}
style={{
background: 'transparent', border: 'none',
color: 'var(--ink-3)', cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 13,
textDecoration: 'underline', padding: '4px 8px',
...noSelect,
}}
>Voir l'historique ({pastLists.length})</button>
)}
</div>
)}
{/* ── Vue : liste en cours ── */}
{!loading && hasCurrentList && (
<>
{/* Barre d'info + actions */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 16px',
background: 'var(--bg-3)',
borderBottom: '1px solid var(--bg-4)',
}}>
<span style={{ flex: 1, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
{checkedItems.length}/{currentList.item_count} cochés
</span>
{pastLists.length > 0 && (
<button
onClick={() => setShowHistoryModal(true)}
style={{ background: 'transparent', border: 'none', color: 'var(--ink-3)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 8px', ...noSelect }}
>Historique</button>
)}
<button
onClick={() => void handleDeleteCurrentList()}
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 8px', ...noSelect }}
>Supprimer</button>
<button
onClick={() => void handleFinish()}
style={{
background: 'var(--ok)', color: '#1d2021', border: 'none',
borderRadius: 8, padding: '6px 14px',
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 13,
cursor: 'pointer', minHeight: 36, ...noSelect,
}}
>Terminer </button>
</div>
{/* Articles non cochés */}
{uncheckedItems.length === 0 && checkedItems.length === 0 && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', margin: '40px 0', fontFamily: 'var(--font-ui)', fontSize: 14, ...noSelect }}>
Liste vide ajoutez des articles avec le bouton +
</p>
)}
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 64 }}>
{uncheckedItems.map(item => (
<ItemRow
key={item.id}
item={item}
onCheck={() => void handleCheckItem(item.id, true)}
onDelete={() => void handleDeleteItem(item.id)}
onEdit={() => openEditItem(item)}
storeMode
/>
))}
{checkedItems.length > 0 && (
<>
<div style={{
padding: '6px 16px',
color: 'var(--ink-4)', fontSize: 11,
fontFamily: 'var(--font-ui)', textTransform: 'uppercase',
letterSpacing: 1, background: 'var(--bg-2)',
borderTop: '1px solid var(--bg-4)',
borderBottom: '1px solid var(--bg-4)',
...noSelect,
}}>
Cochés ({checkedItems.length})
</div>
{checkedItems.map(item => (
<ItemRow
key={item.id}
item={item}
onCheck={() => void handleCheckItem(item.id, false)}
onDelete={() => void handleDeleteItem(item.id)}
onEdit={() => openEditItem(item)}
storeMode
/>
))}
</>
)}
</div>
</>
)}
{/* ── Modals ── */}
{/* Modal édition quantité/unité */}
{editingItem && (
<Modal title={`Modifier — ${editingItem.display_name}`} onClose={() => setEditingItem(null)} width={320}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input
style={inputStyle}
placeholder="Quantité"
inputMode="decimal"
value={editQty}
onChange={e => setEditQty(e.target.value)}
autoFocus
onKeyDown={e => e.key === 'Enter' && void handleEditItem()}
/>
<input
style={inputStyle}
placeholder="Unité (kg, L…)"
value={editUnit}
onChange={e => setEditUnit(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void handleEditItem()}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => setEditingItem(null)}
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
>Annuler</button>
<button
onClick={() => void handleEditItem()}
style={{ padding: '10px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48 }}
>Enregistrer</button>
</div>
</div>
</Modal>
)}
{showAddSheet && (
<BottomSheet onClose={closeAddSheet}>
{/* Actions annuler / valider */}
<div style={{ display: 'flex', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
<button
onClick={closeAddSheet}
aria-label="Annuler"
style={{
flex: 1, minHeight: 48, borderRadius: 999,
border: '1.5px solid var(--err)', background: 'transparent',
color: 'var(--err)', fontSize: 20, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<i className="fa-solid fa-xmark" />
</button>
<button
onClick={() => void handleConfirmAdd()}
disabled={actionCount === 0 || addSaving}
aria-label="Valider"
style={{
flex: 1, minHeight: 48, borderRadius: 999, border: 'none',
background: actionCount === 0 ? 'var(--bg-4)' : 'var(--ok)',
color: actionCount === 0 ? 'var(--ink-4)' : '#1d2021',
fontSize: 20, cursor: actionCount === 0 ? 'default' : 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.15s',
}}
>
<i className={addSaving ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-check'} />
</button>
</div>
{/* Recherche */}
<div style={{ padding: '0 16px 10px', flexShrink: 0 }}>
<input
style={inputStyle}
placeholder="Rechercher ou saisir un article…"
value={itemSearch}
onChange={e => setItemSearch(e.target.value)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
</div>
{/* Liste scrollable */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{/* Articles libres sélectionnés */}
{(selections.filter(s => s.type === 'custom') as Extract<typeof selections[number], { type: 'custom' }>[]).map(s => {
const willDelete = s.qty === 0 && !!s.existingItemId
return (
<div
key={`custom:${s.name}`}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 16px', minHeight: 52,
background: willDelete ? 'rgba(251,73,52,0.08)' : 'rgba(142,192,124,0.12)',
borderBottom: '1px solid var(--bg-4)',
opacity: willDelete ? 0.6 : 1,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 15, color: 'var(--ink-1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textDecoration: willDelete ? 'line-through' : 'none' }}>{s.name}</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}
onDecrement={() => decrementCustom(s.name)}
onIncrement={() => incrementCustom(s.name)}
/>
</div>
)
})}
{/* Produits du catalogue */}
{filteredProducts.map(p => {
const qty = getProductQty(p.id)
const selected = qty > 0
return (
<div
key={p.id}
style={{
display: 'flex', alignItems: 'center', gap: 12,
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={{ flex: 1, minWidth: 0 }}>
<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) && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
{[p.brand, p.default_unit].filter(Boolean).join(' · ')}
</div>
)}
</div>
<QtyControls
qty={qty}
onDecrement={() => decrementProduct(p)}
onIncrement={() => incrementProduct(p)}
/>
</div>
)
})}
{/* Article libre si aucun match */}
{itemSearch.trim() && filteredProducts.length === 0 && (
<div
onClick={addCustomItem}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '14px 16px', cursor: 'pointer',
borderBottom: '1px solid var(--bg-4)',
}}
>
<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>
)}
{filteredProducts.length === 0 && !itemSearch.trim() && (
<p style={{ color: 'var(--ink-4)', textAlign: 'center', padding: '24px 16px', fontFamily: 'var(--font-ui)', fontSize: 13 }}>
Catalogue vide utilisez le bouton Articles
</p>
)}
</div>
</BottomSheet>
)}
{showHistoryModal && (
<Modal title="Historique des courses" onClose={() => setShowHistoryModal(false)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{allLists.filter(l => l.status === 'done').map(list => (
<div
key={list.id}
onClick={() => void handleOpenHistoryList(list)}
className="glass interactive"
style={{ borderRadius: 8, padding: '10px 14px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}
>
<div style={{ flex: 1 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14, ...noSelect }}>
{list.name ?? 'Liste terminée'}
</div>
<div style={{ color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-mono)', ...noSelect }}>
{new Date(list.created_at).toLocaleDateString('fr-FR')} · {list.checked_count}/{list.item_count} articles
</div>
</div>
<span style={{ color: 'var(--ink-3)', fontSize: 16 }}></span>
</div>
))}
{allLists.filter(l => l.status === 'done').length === 0 && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 16, fontFamily: 'var(--font-ui)', fontSize: 13, ...noSelect }}>
Aucune liste terminée
</p>
)}
</div>
</Modal>
)}
{showCatalogueModal && (
<CatalogueModal stores={stores} onClose={() => { setShowCatalogueModal(false); void refreshProducts() }} />
)}
{showBoutiquesModal && (
<BoutiquesModal
stores={stores}
onClose={() => setShowBoutiquesModal(false)}
onStoresChanged={() => void loadData()}
/>
)}
</div>
)
}