feat(shopping): page complète — listes, détail, mode magasin Wake Lock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,433 @@
|
||||
// frontend/src/pages/ShoppingPage.tsx
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { ShoppingList, ShoppingListDetail, ShoppingItem, Store } from '../api/shopping'
|
||||
import {
|
||||
fetchLists, createList, fetchListDetail, updateList, deleteList,
|
||||
addItem, updateItem, deleteItem, finishShopping, fetchStores,
|
||||
} from '../api/shopping'
|
||||
import Modal from '../components/Modal'
|
||||
import ItemRow from '../components/shopping/ItemRow'
|
||||
import { useWakeLock } from '../hooks/useWakeLock'
|
||||
|
||||
type View = 'lists' | 'detail' | 'store'
|
||||
|
||||
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 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)',
|
||||
}
|
||||
|
||||
export default function ShoppingPage() {
|
||||
const [view, setView] = useState<View>('lists')
|
||||
const [lists, setLists] = useState<ShoppingList[]>([])
|
||||
const [activeList, setActiveList] = useState<ShoppingListDetail | null>(null)
|
||||
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 [newListName, setNewListName] = useState('')
|
||||
const [newListStore, setNewListStore] = useState('')
|
||||
|
||||
const [newItemName, setNewItemName] = useState('')
|
||||
const [newItemQty, setNewItemQty] = useState('')
|
||||
const [newItemUnit, setNewItemUnit] = useState('')
|
||||
|
||||
useWakeLock(view === 'store')
|
||||
|
||||
const loadLists = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [listsData, storesData] = await Promise.all([fetchLists(), fetchStores()])
|
||||
setLists(listsData)
|
||||
setStores(storesData)
|
||||
} catch {
|
||||
setError('Erreur lors du chargement')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { void loadLists() }, [loadLists])
|
||||
|
||||
async function openList(list: ShoppingList) {
|
||||
try {
|
||||
const detail = await fetchListDetail(list.id)
|
||||
setActiveList(detail)
|
||||
setView('detail')
|
||||
} catch {
|
||||
setError('Erreur lors du chargement de la liste')
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshActiveList() {
|
||||
if (!activeList) return
|
||||
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()
|
||||
} catch {
|
||||
setError('Erreur lors de la création')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteList(id: string) {
|
||||
try {
|
||||
await deleteList(id)
|
||||
void loadLists()
|
||||
} catch {
|
||||
setError('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddItem() {
|
||||
if (!activeList || !newItemName.trim()) return
|
||||
try {
|
||||
await addItem(activeList.id, {
|
||||
custom_name: newItemName.trim(),
|
||||
quantity: newItemQty || undefined,
|
||||
unit: newItemUnit || undefined,
|
||||
})
|
||||
setNewItemName('')
|
||||
setNewItemQty('')
|
||||
setNewItemUnit('')
|
||||
setShowAddItemModal(false)
|
||||
void refreshActiveList()
|
||||
} catch {
|
||||
setError("Erreur lors de l'ajout")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckItem(itemId: string, checked: boolean) {
|
||||
if (!activeList) return
|
||||
try {
|
||||
await updateItem(activeList.id, itemId, { is_checked: checked })
|
||||
void refreshActiveList()
|
||||
} catch {
|
||||
setError('Erreur lors du cochage')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteItem(itemId: string) {
|
||||
if (!activeList) return
|
||||
try {
|
||||
await deleteItem(activeList.id, itemId)
|
||||
void refreshActiveList()
|
||||
} catch {
|
||||
setError('Erreur lors de la suppression')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFinish() {
|
||||
if (!activeList) return
|
||||
try {
|
||||
await finishShopping(activeList.id)
|
||||
setView('lists')
|
||||
setActiveList(null)
|
||||
void loadLists()
|
||||
} catch {
|
||||
setError('Erreur lors de la finalisation')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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={() => 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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{activeList.items.length === 0 ? (
|
||||
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}>
|
||||
Aucun article — ajoutez-en avec le bouton +
|
||||
</p>
|
||||
) : (
|
||||
<div className="glass" style={{ borderRadius: 10, overflow: 'hidden', marginBottom: 80 }}>
|
||||
{activeList.items.map(item => (
|
||||
<ItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onCheck={() => void handleCheckItem(item.id, !item.is_checked)}
|
||||
onDelete={() => void handleDeleteItem(item.id)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Vue liste des listes ───────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{ padding: 24, color: 'var(--ink-1)', fontFamily: 'var(--font-ui)' }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>Courses</h1>
|
||||
<p style={{ color: 'var(--ink-3)' }}>Module en cours de développement — Phase 3</p>
|
||||
<div className="p-4">
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1 }}>Courses</h1>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{loading && <p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24 }}>Chargement…</p>}
|
||||
|
||||
{!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)}>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="Nom de la liste (ex: Semaine du 26 mai)"
|
||||
value={newListName}
|
||||
onChange={e => setNewListName(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={e => e.key === 'Enter' && void handleCreateList()}
|
||||
/>
|
||||
<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: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(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()}
|
||||
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>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user