feat(shopping): refonte UX + CRUD catalogue/boutiques + champs enrichis

- UX : vue par défaut = liste en cours, landing si pas de liste (+ vert +
  baguette magique), suppression des vues "listes" et "mode magasin" séparés
- Articles cochés barrés et déplacés en bas, tri alphabétique par section
- Nom de liste auto avec numéro de semaine ISO (S21 2026)
- Wake lock activé dès qu'une liste est ouverte
- CRUD boutiques : POST/PATCH/DELETE /stores + modal Boutiques
- CRUD articles : POST/PATCH/DELETE /products + modal Catalogue
- Champs enrichis produits : description, prix, quantité/unité, boutique défaut
- Champs enrichis boutiques : url, store_type (alimentaire, bricolage…)
- Migration 003 : nouveaux champs en base

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 16:21:45 +02:00
co-authored by Claude Sonnet 4.6
parent 925e077afe
commit 85093f1b99
8 changed files with 1056 additions and 442 deletions
+317 -434
View File
@@ -1,16 +1,16 @@
// frontend/src/pages/ShoppingPage.tsx
import { useState, useEffect, useCallback } from 'react'
import type { ShoppingList, ShoppingListDetail, ShoppingItem, Store } from '../api/shopping'
import type { ShoppingListDetail, ShoppingList, Store } from '../api/shopping'
import {
fetchLists, createList, fetchListDetail, updateList, deleteList,
fetchLists, createList, fetchListDetail, deleteList,
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
} from '../api/shopping'
import Modal from '../components/Modal'
import ItemRow from '../components/shopping/ItemRow'
import CatalogueModal from '../components/shopping/CatalogueModal'
import BoutiquesModal from '../components/shopping/BoutiquesModal'
import { useWakeLock } from '../hooks/useWakeLock'
type View = 'lists' | 'detail' | 'store'
const inputStyle: React.CSSProperties = {
width: '100%',
background: 'var(--bg-4)',
@@ -23,46 +23,41 @@ const inputStyle: React.CSSProperties = {
boxSizing: 'border-box',
}
const STATUS_LABELS: Record<string, string> = {
draft: 'Brouillon',
active: 'En cours',
done: 'Terminée',
}
const STATUS_COLORS: Record<string, string> = {
draft: 'var(--ink-3)',
active: 'var(--ok)',
done: 'var(--accent)',
}
const noSelect: React.CSSProperties = { userSelect: 'none' }
export default function ShoppingPage() {
const [view, setView] = useState<View>('lists')
const [lists, setLists] = useState<ShoppingList[]>([])
const [activeList, setActiveList] = useState<ShoppingListDetail | null>(null)
const [currentList, setCurrentList] = useState<ShoppingListDetail | null>(null)
const [allLists, setAllLists] = useState<ShoppingList[]>([])
const [stores, setStores] = useState<Store[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [showAddItemModal, setShowAddItemModal] = useState(false)
const [showEditListModal, setShowEditListModal] = useState(false)
const [generating, setGenerating] = useState(false)
const [newListName, setNewListName] = useState('')
const [newListStore, setNewListStore] = useState('')
const [showAddItemModal, setShowAddItemModal] = useState(false)
const [showHistoryModal, setShowHistoryModal] = useState(false)
const [showCatalogueModal, setShowCatalogueModal] = useState(false)
const [showBoutiquesModal, setShowBoutiquesModal] = useState(false)
const [newItemName, setNewItemName] = useState('')
const [newItemQty, setNewItemQty] = useState('')
const [newItemUnit, setNewItemUnit] = useState('')
useWakeLock(view === 'store')
useWakeLock(currentList !== null)
const loadLists = useCallback(async () => {
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [listsData, storesData] = await Promise.all([fetchLists(), fetchStores()])
setLists(listsData)
setAllLists(listsData)
setStores(storesData)
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 {
@@ -70,56 +65,45 @@ export default function ShoppingPage() {
}
}, [])
useEffect(() => { void loadLists() }, [loadLists])
useEffect(() => { void loadData() }, [loadData])
async function openList(list: ShoppingList) {
async function refreshCurrentList() {
if (!currentList) return
try {
const detail = await fetchListDetail(list.id)
setActiveList(detail)
setView('detail')
setCurrentList(await fetchListDetail(currentList.id))
} catch {
setError('Erreur lors du chargement de la liste')
setError('Erreur de rafraîchissement')
}
}
async function refreshActiveList() {
if (!activeList) return
async function handleCreateManualList() {
try {
setActiveList(await fetchListDetail(activeList.id))
} catch {
setError('Erreur lors du rafraîchissement')
}
}
async function handleCreateList() {
if (!newListName.trim()) return
try {
await createList({
name: newListName.trim(),
store_id: newListStore || undefined,
})
setNewListName('')
setNewListStore('')
setShowCreateModal(false)
void loadLists()
const detail = await createList({})
setCurrentList(detail)
void loadData()
} catch {
setError('Erreur lors de la création')
}
}
async function handleDeleteList(id: string) {
async function handleGenerateMagicList() {
setGenerating(true)
setError(null)
try {
await deleteList(id)
void loadLists()
const detail = await generateMagicList()
setCurrentList(detail)
void loadData()
} catch {
setError('Erreur lors de la suppression')
setError('Erreur lors de la génération')
} finally {
setGenerating(false)
}
}
async function handleAddItem() {
if (!activeList || !newItemName.trim()) return
if (!currentList || !newItemName.trim()) return
try {
await addItem(activeList.id, {
await addItem(currentList.id, {
custom_name: newItemName.trim(),
quantity: newItemQty || undefined,
unit: newItemUnit || undefined,
@@ -128,446 +112,345 @@ export default function ShoppingPage() {
setNewItemQty('')
setNewItemUnit('')
setShowAddItemModal(false)
void refreshActiveList()
void refreshCurrentList()
} catch {
setError("Erreur lors de l'ajout")
}
}
async function handleCheckItem(itemId: string, checked: boolean) {
if (!activeList) return
if (!currentList) return
try {
await updateItem(activeList.id, itemId, { is_checked: checked })
void refreshActiveList()
await updateItem(currentList.id, itemId, { is_checked: checked })
void refreshCurrentList()
} catch {
setError('Erreur lors du cochage')
}
}
async function handleDeleteItem(itemId: string) {
if (!activeList) return
if (!currentList) return
try {
await deleteItem(activeList.id, itemId)
void refreshActiveList()
await deleteItem(currentList.id, itemId)
void refreshCurrentList()
} catch {
setError('Erreur lors de la suppression')
}
}
async function handleFinish() {
if (!activeList) return
if (!currentList) return
try {
await finishShopping(activeList.id)
setView('lists')
setActiveList(null)
void loadLists()
await finishShopping(currentList.id)
void loadData()
} catch {
setError('Erreur lors de la finalisation')
}
}
async function handleGenerateMagicList() {
setGenerating(true)
setError(null)
async function handleDeleteCurrentList() {
if (!currentList) return
if (!confirm('Supprimer la liste en cours ?')) return
try {
const newList = await generateMagicList()
void loadLists()
setActiveList(newList)
setView('detail')
} catch {
setError('Erreur lors de la génération')
} finally {
setGenerating(false)
}
}
async function handleDeleteActiveList() {
if (!activeList) return
try {
await deleteList(activeList.id)
setView('lists')
setActiveList(null)
void loadLists()
await deleteList(currentList.id)
void loadData()
} catch {
setError('Erreur lors de la suppression')
}
}
// ── Vue mode magasin ──────────────────────────────────────────────────────
if (view === 'store' && activeList) {
const unchecked = activeList.items.filter(i => !i.is_checked)
const checked = activeList.items.filter(i => i.is_checked)
return (
<div style={{
position: 'fixed', inset: 0, background: 'var(--bg-1)',
display: 'flex', flexDirection: 'column', zIndex: 100,
}}>
<div style={{
background: 'var(--bg-2)',
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
gap: 12,
borderBottom: '1px solid var(--bg-4)',
}}>
<button
onClick={() => setView('detail')}
style={{ background: 'transparent', border: 'none', color: 'var(--ink-2)', fontSize: 20, cursor: 'pointer', padding: 4 }}
></button>
<div style={{ flex: 1 }}>
<div style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', fontSize: 14 }}>Mode magasin</div>
<div style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)' }}>
{checked.length}/{activeList.item_count} cochés
</div>
</div>
<button
onClick={() => void handleFinish()}
style={{
background: 'var(--ok)', color: '#1d2021', border: 'none',
borderRadius: 8, padding: '10px 16px', fontFamily: 'var(--font-ui)',
fontWeight: 700, fontSize: 14, cursor: 'pointer', minHeight: 48,
}}
>Terminer </button>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{unchecked.map(item => (
<ItemRow
key={item.id}
item={item}
onCheck={() => void handleCheckItem(item.id, true)}
onDelete={() => void handleDeleteItem(item.id)}
storeMode
/>
))}
{checked.length > 0 && (
<>
<div style={{ padding: '8px 16px', color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-ui)', textTransform: 'uppercase', letterSpacing: 1 }}>
Cochés ({checked.length})
</div>
{checked.map(item => (
<ItemRow
key={item.id}
item={item}
onCheck={() => void handleCheckItem(item.id, false)}
onDelete={() => void handleDeleteItem(item.id)}
storeMode
/>
))}
</>
)}
</div>
</div>
)
async function handleOpenHistoryList(list: ShoppingList) {
try {
const detail = await fetchListDetail(list.id)
setCurrentList(detail)
setShowHistoryModal(false)
} catch {
setError('Erreur lors du chargement')
}
}
// ── Vue détail d'une liste ─────────────────────────────────────────────────
if (view === 'detail' && activeList) {
return (
<div className="p-4">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<button
onClick={() => { setView('lists'); void loadLists() }}
style={{ background: 'transparent', border: 'none', color: 'var(--ink-2)', fontSize: 20, cursor: 'pointer', padding: 4 }}
></button>
<div style={{ flex: 1 }}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, fontSize: 18 }}>
{activeList.name ?? 'Liste de courses'}
</h1>
<div style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)' }}>
{activeList.checked_count}/{activeList.item_count} cochés
</div>
</div>
<button
onClick={() => setShowEditListModal(true)}
title="Modifier / gérer la liste"
style={{
background: 'var(--bg-3)',
border: '1px solid var(--bg-5)',
borderRadius: 8,
color: 'var(--ink-2)',
cursor: 'pointer',
padding: '8px 12px',
fontSize: 16,
minHeight: 44,
}}
>
<i className="fa-solid fa-pen" />
</button>
<button
onClick={() => setView('store')}
style={{
background: 'var(--accent)', color: '#1d2021', border: 'none',
borderRadius: 8, padding: '8px 14px', fontFamily: 'var(--font-ui)',
fontWeight: 600, fontSize: 13, cursor: 'pointer', minHeight: 44,
}}
>Mode magasin 🛒</button>
</div>
// 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')
})
{error && (
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontSize: 13, fontFamily: 'var(--font-ui)' }}>
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')
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>
)}
{activeList.items.length === 0 ? (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}>
Aucun article ajoutez-en avec le bouton +
{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 className="glass" style={{ borderRadius: 10, overflow: 'hidden', marginBottom: 80 }}>
{activeList.items.map(item => (
<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: 80 }}>
{uncheckedItems.map(item => (
<ItemRow
key={item.id}
item={item}
onCheck={() => void handleCheckItem(item.id, !item.is_checked)}
onCheck={() => void handleCheckItem(item.id, true)}
onDelete={() => void handleDeleteItem(item.id)}
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)}
storeMode
/>
))}
</>
)}
</div>
)}
<button
onClick={() => setShowAddItemModal(true)}
aria-label="Ajouter un article"
style={{
position: 'fixed', bottom: 72, right: 20,
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)', color: '#1d2021', border: 'none',
fontSize: 28, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>+</button>
{showAddItemModal && (
<Modal title="Ajouter un article" onClose={() => setShowAddItemModal(false)}>
<input
style={inputStyle}
placeholder="Nom de l'article *"
value={newItemName}
onChange={e => setNewItemName(e.target.value)}
autoFocus
onKeyDown={e => e.key === 'Enter' && void handleAddItem()}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input
style={inputStyle}
placeholder="Quantité"
value={newItemQty}
onChange={e => setNewItemQty(e.target.value)}
/>
<input
style={inputStyle}
placeholder="Unité (kg, L…)"
value={newItemUnit}
onChange={e => setNewItemUnit(e.target.value)}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => setShowAddItemModal(false)}
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 handleAddItem()}
style={{ padding: '10px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48 }}
>Ajouter</button>
</div>
</Modal>
)}
{showEditListModal && (
<Modal title="Gérer la liste" onClose={() => setShowEditListModal(false)}>
<p style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)', margin: 0, textTransform: 'uppercase', letterSpacing: 1 }}>
Ajouter un article
</p>
<input
style={inputStyle}
placeholder="Nom de l'article *"
value={newItemName}
onChange={e => setNewItemName(e.target.value)}
autoFocus
onKeyDown={async e => {
if (e.key === 'Enter') {
await handleAddItem()
setShowEditListModal(false)
}
}}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input
style={inputStyle}
placeholder="Quantité"
value={newItemQty}
onChange={e => setNewItemQty(e.target.value)}
/>
<input
style={inputStyle}
placeholder="Unité (kg, L…)"
value={newItemUnit}
onChange={e => setNewItemUnit(e.target.value)}
/>
</div>
<button
onClick={async () => {
await handleAddItem()
if (!error) setShowEditListModal(false)
}}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
background: 'var(--accent)', color: '#1d2021', cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48, width: '100%',
}}
>
<i className="fa-solid fa-plus" style={{ marginRight: 8 }} />
Ajouter
</button>
<div style={{ borderTop: '1px solid var(--bg-4)', margin: '4px 0' }} />
<button
onClick={() => {
setShowEditListModal(false)
void handleDeleteActiveList()
}}
style={{
padding: '10px 20px', borderRadius: 8,
border: '1px solid var(--err)',
background: 'transparent', color: 'var(--err)',
cursor: 'pointer', fontFamily: 'var(--font-ui)',
fontWeight: 600, minHeight: 48, width: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
>
<i className="fa-solid fa-trash" />
Supprimer la liste en cours
</button>
</Modal>
)}
</div>
)
}
// ── Vue liste des listes ───────────────────────────────────────────────────
return (
<div className="p-4">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1 }}>Courses</h1>
<button
onClick={() => void handleGenerateMagicList()}
disabled={generating || lists.some(l => l.status === 'draft' || l.status === 'active')}
title="Générer une liste automatiquement"
style={{
background: 'var(--bg-3)',
border: '1px solid var(--bg-5)',
borderRadius: 8,
color: lists.some(l => l.status === 'draft' || l.status === 'active')
? 'var(--ink-4)'
: 'var(--accent)',
cursor: lists.some(l => l.status === 'draft' || l.status === 'active')
? 'not-allowed'
: 'pointer',
padding: '8px 12px',
fontFamily: 'var(--font-ui)',
fontSize: 13,
display: 'flex',
alignItems: 'center',
gap: 6,
minHeight: 44,
}}
>
<i className="fa-solid fa-wand-magic-sparkles" />
{generating ? 'Génération…' : 'Liste magique'}
</button>
</div>
{error && (
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontSize: 13, fontFamily: 'var(--font-ui)' }}>
{error}
</p>
{/* FAB + */}
<button
onClick={() => setShowAddItemModal(true)}
aria-label="Ajouter un article"
style={{
position: 'fixed', bottom: 72, right: 20,
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)', color: '#1d2021', border: 'none',
fontSize: 28, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>+</button>
</>
)}
{loading && <p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24 }}>Chargement</p>}
{/* ── Modals ── */}
{!loading && lists.length === 0 && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}>
Aucune liste créez-en une avec le bouton +
</p>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{lists.map(list => (
<div
key={list.id}
className="glass interactive"
onClick={() => void openList(list)}
style={{ borderRadius: 10, padding: '14px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12 }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 15, fontWeight: 500 }}>
{list.name ?? 'Liste sans nom'}
</div>
<div style={{ display: 'flex', gap: 12, marginTop: 4, alignItems: 'center' }}>
<span style={{ color: STATUS_COLORS[list.status], fontSize: 11, fontFamily: 'var(--font-ui)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{STATUS_LABELS[list.status]}
</span>
<span style={{ color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>
{list.checked_count}/{list.item_count} articles
</span>
</div>
</div>
<button
onClick={e => { e.stopPropagation(); void handleDeleteList(list.id) }}
style={{ background: 'transparent', border: 'none', color: 'var(--ink-4)', fontSize: 18, cursor: 'pointer', padding: '4px 8px', minHeight: 44 }}
title="Supprimer la liste"
></button>
</div>
))}
</div>
<button
onClick={() => setShowCreateModal(true)}
aria-label="Nouvelle liste"
style={{
position: 'fixed', bottom: 72, right: 20,
width: 56, height: 56, borderRadius: '50%',
background: 'var(--accent)', color: '#1d2021', border: 'none',
fontSize: 28, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>+</button>
{showCreateModal && (
<Modal title="Nouvelle liste de courses" onClose={() => setShowCreateModal(false)}>
{showAddItemModal && (
<Modal title="Ajouter un article" onClose={() => setShowAddItemModal(false)}>
<input
style={inputStyle}
placeholder="Nom de la liste (ex: Semaine du 26 mai)"
value={newListName}
onChange={e => setNewListName(e.target.value)}
placeholder="Nom de l'article *"
value={newItemName}
onChange={e => setNewItemName(e.target.value)}
autoFocus
onKeyDown={e => e.key === 'Enter' && void handleCreateList()}
onKeyDown={e => e.key === 'Enter' && void handleAddItem()}
/>
<select
style={inputStyle}
value={newListStore}
onChange={e => setNewListStore(e.target.value)}
>
<option value="">Magasin (optionnel)</option>
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input
style={inputStyle} placeholder="Quantité"
value={newItemQty}
onChange={e => setNewItemQty(e.target.value)}
/>
<input
style={inputStyle} placeholder="Unité (kg, L…)"
value={newItemUnit}
onChange={e => setNewItemUnit(e.target.value)}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => setShowCreateModal(false)}
onClick={() => setShowAddItemModal(false)}
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 handleCreateList()}
onClick={() => void handleAddItem()}
style={{ padding: '10px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48 }}
>Créer</button>
>Ajouter</button>
</div>
</Modal>
)}
{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)} />
)}
{showBoutiquesModal && (
<BoutiquesModal
stores={stores}
onClose={() => setShowBoutiquesModal(false)}
onStoresChanged={() => void loadData()}
/>
)}
</div>
)
}