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
@@ -0,0 +1,38 @@
"""shopping enrichments — champs enrichis produits et boutiques"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = '003'
down_revision = '002'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Nouveaux champs produits
op.add_column('products', sa.Column('description', sa.Text(), nullable=True), schema='shopping')
op.add_column('products', sa.Column('price', sa.Numeric(8, 2), nullable=True), schema='shopping')
op.add_column('products', sa.Column('quantity_per_unit', sa.Numeric(8, 3), nullable=True), schema='shopping')
op.add_column('products', sa.Column('default_store_id', UUID(as_uuid=True), nullable=True), schema='shopping')
op.create_foreign_key(
'fk_products_default_store',
'products', 'stores',
['default_store_id'], ['id'],
source_schema='shopping', referent_schema='shopping',
ondelete='SET NULL',
)
# Nouveaux champs boutiques
op.add_column('stores', sa.Column('url', sa.Text(), nullable=True), schema='shopping')
op.add_column('stores', sa.Column('store_type', sa.String(50), nullable=True), schema='shopping')
def downgrade() -> None:
op.drop_constraint('fk_products_default_store', 'products', schema='shopping', type_='foreignkey')
op.drop_column('products', 'description', schema='shopping')
op.drop_column('products', 'price', schema='shopping')
op.drop_column('products', 'quantity_per_unit', schema='shopping')
op.drop_column('products', 'default_store_id', schema='shopping')
op.drop_column('stores', 'url', schema='shopping')
op.drop_column('stores', 'store_type', schema='shopping')
+82 -5
View File
@@ -1,5 +1,6 @@
# backend/app/api/shopping.py # backend/app/api/shopping.py
import uuid import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response from fastapi.responses import Response
from sqlalchemy import select, text from sqlalchemy import select, text
@@ -11,12 +12,19 @@ from app.models.shopping import ShoppingList, ListItem, Product, Store
from app.schemas.shopping import ( from app.schemas.shopping import (
ShoppingListCreate, ShoppingListUpdate, ShoppingListResponse, ShoppingListCreate, ShoppingListUpdate, ShoppingListResponse,
ShoppingListDetailResponse, ListItemCreate, ListItemUpdate, ShoppingListDetailResponse, ListItemCreate, ListItemUpdate,
ListItemResponse, ProductResponse, StoreResponse, ListItemResponse, ProductCreate, ProductUpdate, ProductResponse,
StoreCreate, StoreUpdate, StoreResponse,
) )
router = APIRouter() router = APIRouter()
def _iso_week_label() -> str:
now = datetime.now(tz=timezone.utc)
iso = now.isocalendar()
return f"S{iso[1]} {iso[0]}"
def _item_to_response(item: ListItem) -> ListItemResponse: def _item_to_response(item: ListItem) -> ListItemResponse:
display_name = item.custom_name or (item.product.name if item.product else "Article inconnu") display_name = item.custom_name or (item.product.name if item.product else "Article inconnu")
return ListItemResponse( return ListItemResponse(
@@ -55,12 +63,43 @@ async def list_stores(session: AsyncSession = Depends(get_session)):
return result.scalars().all() return result.scalars().all()
@router.post("/stores", response_model=StoreResponse, status_code=201)
async def create_store(payload: StoreCreate, session: AsyncSession = Depends(get_session)):
store = Store(**payload.model_dump())
session.add(store)
await session.commit()
await session.refresh(store)
return store
@router.patch("/stores/{store_id}", response_model=StoreResponse)
async def update_store(store_id: uuid.UUID, payload: StoreUpdate, session: AsyncSession = Depends(get_session)):
store = await session.get(Store, store_id)
if not store:
raise HTTPException(404, "Boutique introuvable")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(store, field, value)
await session.commit()
await session.refresh(store)
return store
@router.delete("/stores/{store_id}", status_code=204)
async def delete_store(store_id: uuid.UUID, session: AsyncSession = Depends(get_session)):
store = await session.get(Store, store_id)
if not store:
raise HTTPException(404, "Boutique introuvable")
await session.delete(store)
await session.commit()
return Response(status_code=204)
# ── Products ────────────────────────────────────────────────────────────────── # ── Products ──────────────────────────────────────────────────────────────────
@router.get("/products", response_model=list[ProductResponse]) @router.get("/products", response_model=list[ProductResponse])
async def search_products( async def search_products(
q: str | None = Query(default=None), q: str | None = Query(default=None),
limit: int = 30, limit: int = 50,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
stmt = select(Product).order_by(Product.frequency_score.desc(), Product.name) stmt = select(Product).order_by(Product.frequency_score.desc(), Product.name)
@@ -71,6 +110,41 @@ async def search_products(
return result.scalars().all() return result.scalars().all()
@router.post("/products", response_model=ProductResponse, status_code=201)
async def create_product(payload: ProductCreate, session: AsyncSession = Depends(get_session)):
product = Product(**payload.model_dump())
session.add(product)
await session.commit()
await session.refresh(product)
return product
@router.patch("/products/{product_id}", response_model=ProductResponse)
async def update_product(
product_id: uuid.UUID,
payload: ProductUpdate,
session: AsyncSession = Depends(get_session),
):
product = await session.get(Product, product_id)
if not product:
raise HTTPException(404, "Produit introuvable")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value)
await session.commit()
await session.refresh(product)
return product
@router.delete("/products/{product_id}", status_code=204)
async def delete_product(product_id: uuid.UUID, session: AsyncSession = Depends(get_session)):
product = await session.get(Product, product_id)
if not product:
raise HTTPException(404, "Produit introuvable")
await session.delete(product)
await session.commit()
return Response(status_code=204)
# ── Lists ───────────────────────────────────────────────────────────────────── # ── Lists ─────────────────────────────────────────────────────────────────────
@router.get("/lists", response_model=list[ShoppingListResponse]) @router.get("/lists", response_model=list[ShoppingListResponse])
@@ -90,7 +164,10 @@ async def create_shopping_list(
payload: ShoppingListCreate, payload: ShoppingListCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
lst = ShoppingList(**payload.model_dump()) data = payload.model_dump()
if not data.get('name'):
data['name'] = _iso_week_label()
lst = ShoppingList(**data)
session.add(lst) session.add(lst)
await session.commit() await session.commit()
await session.refresh(lst, ["items"]) await session.refresh(lst, ["items"])
@@ -275,7 +352,7 @@ async def generate_magic_list(session: AsyncSession = Depends(get_session)):
result = await session.execute(query) result = await session.execute(query)
rows = result.mappings().all() rows = result.mappings().all()
new_list = ShoppingList(name="Liste magique", status="draft") new_list = ShoppingList(name=_iso_week_label(), status="draft")
session.add(new_list) session.add(new_list)
await session.flush() await session.flush()
@@ -320,7 +397,7 @@ async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(ge
unchecked = [i for i in lst.items if not i.is_checked] unchecked = [i for i in lst.items if not i.is_checked]
if unchecked: if unchecked:
new_list = ShoppingList(store_id=lst.store_id, status="draft") new_list = ShoppingList(store_id=lst.store_id, status="draft", name=_iso_week_label())
session.add(new_list) session.add(new_list)
await session.flush() await session.flush()
for item in unchecked: for item in unchecked:
+6
View File
@@ -15,10 +15,14 @@ class Product(Base):
name: Mapped[str] = mapped_column(String(150), nullable=False) name: Mapped[str] = mapped_column(String(150), nullable=False)
brand: Mapped[str | None] = mapped_column(String(100)) brand: Mapped[str | None] = mapped_column(String(100))
category: Mapped[str | None] = mapped_column(String(50)) category: Mapped[str | None] = mapped_column(String(50))
description: Mapped[str | None] = mapped_column(Text)
image_path: Mapped[str | None] = mapped_column(String(255)) image_path: Mapped[str | None] = mapped_column(String(255))
thumbnail_path: Mapped[str | None] = mapped_column(String(255)) thumbnail_path: Mapped[str | None] = mapped_column(String(255))
default_unit: Mapped[str | None] = mapped_column(String(20)) default_unit: Mapped[str | None] = mapped_column(String(20))
barcode: Mapped[str | None] = mapped_column(String(50)) barcode: Mapped[str | None] = mapped_column(String(50))
price: Mapped[Decimal | None] = mapped_column(Numeric(8, 2))
quantity_per_unit: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
default_store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL"))
frequency_score: Mapped[int] = mapped_column(Integer, server_default=text("0")) frequency_score: Mapped[int] = mapped_column(Integer, server_default=text("0"))
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()")) created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))
@@ -31,6 +35,8 @@ class Store(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)
location: Mapped[str | None] = mapped_column(Text) location: Mapped[str | None] = mapped_column(Text)
url: Mapped[str | None] = mapped_column(Text)
store_type: Mapped[str | None] = mapped_column(String(50))
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
+45
View File
@@ -5,11 +5,51 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, model_validator from pydantic import BaseModel, ConfigDict, model_validator
class StoreCreate(BaseModel):
name: str
location: str | None = None
url: str | None = None
store_type: str | None = None
class StoreUpdate(BaseModel):
name: str | None = None
location: str | None = None
url: str | None = None
store_type: str | None = None
class StoreResponse(BaseModel): class StoreResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
name: str name: str
location: str | None location: str | None
url: str | None
store_type: str | None
class ProductCreate(BaseModel):
name: str
brand: str | None = None
category: str | None = None
description: str | None = None
default_unit: str | None = None
barcode: str | None = None
price: Decimal | None = None
quantity_per_unit: Decimal | None = None
default_store_id: uuid.UUID | None = None
class ProductUpdate(BaseModel):
name: str | None = None
brand: str | None = None
category: str | None = None
description: str | None = None
default_unit: str | None = None
barcode: str | None = None
price: Decimal | None = None
quantity_per_unit: Decimal | None = None
default_store_id: uuid.UUID | None = None
class ProductResponse(BaseModel): class ProductResponse(BaseModel):
@@ -18,7 +58,12 @@ class ProductResponse(BaseModel):
name: str name: str
brand: str | None brand: str | None
category: str | None category: str | None
description: str | None
default_unit: str | None default_unit: str | None
barcode: str | None
price: Decimal | None
quantity_per_unit: Decimal | None
default_store_id: uuid.UUID | None
frequency_score: int frequency_score: int
+97 -3
View File
@@ -4,6 +4,22 @@ export interface Store {
id: string id: string
name: string name: string
location: string | null location: string | null
url: string | null
store_type: string | null
}
export interface StoreCreate {
name: string
location?: string
url?: string
store_type?: string
}
export interface StoreUpdate {
name?: string
location?: string
url?: string
store_type?: string
} }
export interface Product { export interface Product {
@@ -11,10 +27,39 @@ export interface Product {
name: string name: string
brand: string | null brand: string | null
category: string | null category: string | null
description: string | null
default_unit: string | null default_unit: string | null
barcode: string | null
price: string | null
quantity_per_unit: string | null
default_store_id: string | null
frequency_score: number frequency_score: number
} }
export interface ProductCreate {
name: string
brand?: string
category?: string
description?: string
default_unit?: string
barcode?: string
price?: string
quantity_per_unit?: string
default_store_id?: string
}
export interface ProductUpdate {
name?: string
brand?: string
category?: string
description?: string
default_unit?: string
barcode?: string
price?: string
quantity_per_unit?: string
default_store_id?: string
}
export interface ShoppingItem { export interface ShoppingItem {
id: string id: string
product_id: string | null product_id: string | null
@@ -77,15 +122,62 @@ async function handleResponse<T>(res: Response): Promise<T> {
return res.json() as Promise<T> return res.json() as Promise<T>
} }
// ── Stores ───────────────────────────────────────────────────────────────────
export async function fetchStores(): Promise<Store[]> { export async function fetchStores(): Promise<Store[]> {
return handleResponse(await fetch(`${BASE}/stores`)) return handleResponse(await fetch(`${BASE}/stores`))
} }
export async function searchProducts(q?: string): Promise<Product[]> { export async function createStore(data: StoreCreate): Promise<Store> {
const qs = q ? `?q=${encodeURIComponent(q)}&limit=30` : '?limit=30' return handleResponse(await fetch(`${BASE}/stores`, {
return handleResponse(await fetch(`${BASE}/products${qs}`)) method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}))
} }
export async function updateStore(id: string, data: StoreUpdate): Promise<Store> {
return handleResponse(await fetch(`${BASE}/stores/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}))
}
export async function deleteStore(id: string): Promise<void> {
return handleResponse(await fetch(`${BASE}/stores/${id}`, { method: 'DELETE' }))
}
// ── Products ─────────────────────────────────────────────────────────────────
export async function searchProducts(q?: string, limit = 50): Promise<Product[]> {
const qs = new URLSearchParams({ limit: String(limit) })
if (q) qs.set('q', q)
return handleResponse(await fetch(`${BASE}/products?${qs}`))
}
export async function createProduct(data: ProductCreate): Promise<Product> {
return handleResponse(await fetch(`${BASE}/products`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}))
}
export async function updateProduct(id: string, data: ProductUpdate): Promise<Product> {
return handleResponse(await fetch(`${BASE}/products/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}))
}
export async function deleteProduct(id: string): Promise<void> {
return handleResponse(await fetch(`${BASE}/products/${id}`, { method: 'DELETE' }))
}
// ── Lists ─────────────────────────────────────────────────────────────────────
export async function fetchLists(): Promise<ShoppingList[]> { export async function fetchLists(): Promise<ShoppingList[]> {
return handleResponse(await fetch(`${BASE}/lists`)) return handleResponse(await fetch(`${BASE}/lists`))
} }
@@ -114,6 +206,8 @@ export async function deleteList(id: string): Promise<void> {
return handleResponse(await fetch(`${BASE}/lists/${id}`, { method: 'DELETE' })) return handleResponse(await fetch(`${BASE}/lists/${id}`, { method: 'DELETE' }))
} }
// ── Items ─────────────────────────────────────────────────────────────────────
export async function addItem(listId: string, data: ShoppingItemCreate): Promise<ShoppingItem> { export async function addItem(listId: string, data: ShoppingItemCreate): Promise<ShoppingItem> {
return handleResponse(await fetch(`${BASE}/lists/${listId}/items`, { return handleResponse(await fetch(`${BASE}/lists/${listId}/items`, {
method: 'POST', method: 'POST',
@@ -0,0 +1,206 @@
import { useState } from 'react'
import type { Store, StoreCreate } from '../../api/shopping'
import { createStore, updateStore, deleteStore } from '../../api/shopping'
import Modal from '../Modal'
interface BoutiquesModalProps {
stores: Store[]
onClose: () => void
onStoresChanged: () => void
}
const STORE_TYPES = ['alimentaire', 'bricolage', 'jardinage', 'pharmacie', 'sport', 'vêtements', 'autre']
const inputStyle: React.CSSProperties = {
width: '100%',
background: 'var(--bg-4)',
border: '1px solid var(--bg-5)',
borderRadius: 8,
padding: '8px 12px',
color: 'var(--ink-1)',
fontFamily: 'var(--font-ui)',
fontSize: 14,
boxSizing: 'border-box',
}
const TYPE_COLORS: Record<string, string> = {
alimentaire: 'var(--ok)',
bricolage: 'var(--warn)',
jardinage: 'var(--ok)',
pharmacie: 'var(--info)',
sport: 'var(--accent)',
vêtements: 'var(--info)',
autre: 'var(--ink-3)',
}
const emptyForm: StoreCreate = { name: '', location: '', url: '', store_type: '' }
export default function BoutiquesModal({ stores, onClose, onStoresChanged }: BoutiquesModalProps) {
const [editing, setEditing] = useState<Store | null>(null)
const [creating, setCreating] = useState(false)
const [form, setForm] = useState<StoreCreate>(emptyForm)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
function startCreate() {
setForm(emptyForm)
setEditing(null)
setCreating(true)
setError(null)
}
function startEdit(s: Store) {
setForm({
name: s.name,
location: s.location ?? '',
url: s.url ?? '',
store_type: s.store_type ?? '',
})
setEditing(s)
setCreating(false)
setError(null)
}
function cancelForm() {
setEditing(null)
setCreating(false)
setError(null)
}
function cleanForm(f: StoreCreate): StoreCreate {
return {
name: f.name.trim(),
location: f.location?.trim() || undefined,
url: f.url?.trim() || undefined,
store_type: f.store_type?.trim() || undefined,
}
}
async function handleSave() {
if (!form.name.trim()) return
setSaving(true)
setError(null)
try {
const data = cleanForm(form)
if (creating) {
await createStore(data)
} else if (editing) {
await updateStore(editing.id, data)
}
cancelForm()
onStoresChanged()
} catch {
setError('Erreur lors de la sauvegarde')
} finally {
setSaving(false)
}
}
async function handleDelete(id: string) {
if (!confirm('Supprimer cette boutique ?')) return
try {
await deleteStore(id)
onStoresChanged()
} catch {
setError('Erreur lors de la suppression')
}
}
const isFormOpen = creating || editing !== null
return (
<Modal title="Boutiques" onClose={onClose} width={480}>
{error && (
<p style={{ color: 'var(--err)', fontSize: 13, fontFamily: 'var(--font-ui)', margin: 0 }}>{error}</p>
)}
{isFormOpen ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input
style={inputStyle} placeholder="Nom de la boutique *"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
autoFocus
/>
<select
style={inputStyle} value={form.store_type ?? ''}
onChange={e => setForm(f => ({ ...f, store_type: e.target.value }))}
>
<option value="">Type</option>
{STORE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<input
style={inputStyle} placeholder="Adresse / localisation"
value={form.location ?? ''}
onChange={e => setForm(f => ({ ...f, location: e.target.value }))}
/>
<input
style={inputStyle} placeholder="URL (site web)"
value={form.url ?? ''}
onChange={e => setForm(f => ({ ...f, url: e.target.value }))}
type="url"
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={cancelForm}
style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 40 }}
>Annuler</button>
<button
onClick={() => void handleSave()}
disabled={saving || !form.name.trim()}
style={{ padding: '8px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 40 }}
>{saving ? '…' : 'Enregistrer'}</button>
</div>
</div>
) : (
<>
<button
onClick={startCreate}
style={{ width: '100%', padding: '10px', borderRadius: 8, border: 'none', background: 'var(--ok)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 14, minHeight: 44 }}
>+ Ajouter une boutique</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{stores.length === 0 && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 16, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
Aucune boutique enregistrée
</p>
)}
{stores.map(s => (
<div
key={s.id}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 12px', borderRadius: 8,
background: 'var(--bg-3)',
userSelect: 'none',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14 }}>{s.name}</div>
<div style={{ display: 'flex', gap: 8, marginTop: 2, alignItems: 'center' }}>
{s.store_type && (
<span style={{
color: TYPE_COLORS[s.store_type] ?? 'var(--ink-3)',
fontSize: 11, fontFamily: 'var(--font-ui)',
textTransform: 'uppercase', letterSpacing: 0.5,
}}>{s.store_type}</span>
)}
{s.location && <span style={{ color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-ui)' }}>{s.location}</span>}
</div>
</div>
<button
onClick={() => startEdit(s)}
style={{ background: 'var(--bg-5)', border: 'none', color: 'var(--ink-2)', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
>✏️</button>
<button
onClick={() => void handleDelete(s.id)}
style={{ background: 'transparent', border: 'none', color: 'var(--err)', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 14 }}
>✕</button>
</div>
))}
</div>
</>
)}
</Modal>
)
}
@@ -0,0 +1,265 @@
import { useState, useEffect } from 'react'
import type { Product, ProductCreate, Store } from '../../api/shopping'
import { searchProducts, createProduct, updateProduct, deleteProduct } from '../../api/shopping'
import Modal from '../Modal'
interface CatalogueModalProps {
stores: Store[]
onClose: () => void
}
const CATEGORIES = ['alimentaire', 'boisson', 'hygiène', 'ménage', 'bricolage', 'jardinage', 'autre']
const inputStyle: React.CSSProperties = {
width: '100%',
background: 'var(--bg-4)',
border: '1px solid var(--bg-5)',
borderRadius: 8,
padding: '8px 12px',
color: 'var(--ink-1)',
fontFamily: 'var(--font-ui)',
fontSize: 14,
boxSizing: 'border-box',
}
const emptyForm: ProductCreate = {
name: '', brand: '', category: '', description: '',
default_unit: '', barcode: '', price: '', quantity_per_unit: '',
default_store_id: undefined,
}
export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) {
const [products, setProducts] = useState<Product[]>([])
const [search, setSearch] = useState('')
const [editing, setEditing] = useState<Product | null>(null)
const [creating, setCreating] = useState(false)
const [form, setForm] = useState<ProductCreate>(emptyForm)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
void loadProducts()
}, [search])
async function loadProducts() {
try {
setProducts(await searchProducts(search || undefined))
} catch {
setError('Erreur de chargement')
}
}
function startCreate() {
setForm(emptyForm)
setEditing(null)
setCreating(true)
setError(null)
}
function startEdit(p: Product) {
setForm({
name: p.name,
brand: p.brand ?? '',
category: p.category ?? '',
description: p.description ?? '',
default_unit: p.default_unit ?? '',
barcode: p.barcode ?? '',
price: p.price ?? '',
quantity_per_unit: p.quantity_per_unit ?? '',
default_store_id: p.default_store_id ?? undefined,
})
setEditing(p)
setCreating(false)
setError(null)
}
function cancelForm() {
setEditing(null)
setCreating(false)
setError(null)
}
function cleanForm(f: ProductCreate): ProductCreate {
return {
name: f.name.trim(),
brand: f.brand?.trim() || undefined,
category: f.category?.trim() || undefined,
description: f.description?.trim() || undefined,
default_unit: f.default_unit?.trim() || undefined,
barcode: f.barcode?.trim() || undefined,
price: f.price?.trim() || undefined,
quantity_per_unit: f.quantity_per_unit?.trim() || undefined,
default_store_id: f.default_store_id || undefined,
}
}
async function handleSave() {
if (!form.name.trim()) return
setSaving(true)
setError(null)
try {
const data = cleanForm(form)
if (creating) {
await createProduct(data)
} else if (editing) {
await updateProduct(editing.id, data)
}
cancelForm()
void loadProducts()
} catch {
setError('Erreur lors de la sauvegarde')
} finally {
setSaving(false)
}
}
async function handleDelete(id: string) {
if (!confirm('Supprimer cet article du catalogue ?')) return
try {
await deleteProduct(id)
void loadProducts()
} catch {
setError('Erreur lors de la suppression')
}
}
const isFormOpen = creating || editing !== null
return (
<Modal title="Catalogue d'articles" onClose={onClose} width={580}>
{error && (
<p style={{ color: 'var(--err)', fontSize: 13, fontFamily: 'var(--font-ui)', margin: 0 }}>{error}</p>
)}
{isFormOpen ? (
/* ── Formulaire création / édition ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input
style={inputStyle} placeholder="Nom *" value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
autoFocus
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input
style={inputStyle} placeholder="Marque"
value={form.brand ?? ''}
onChange={e => setForm(f => ({ ...f, brand: e.target.value }))}
/>
<select
style={inputStyle} value={form.category ?? ''}
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
>
<option value="">Catégorie</option>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<textarea
style={{ ...inputStyle, minHeight: 56, resize: 'vertical' }}
placeholder="Description"
value={form.description ?? ''}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
<input
style={inputStyle} placeholder="Unité (kg, L…)"
value={form.default_unit ?? ''}
onChange={e => setForm(f => ({ ...f, default_unit: e.target.value }))}
/>
<input
style={inputStyle} placeholder="Prix (€)"
value={form.price ?? ''}
onChange={e => setForm(f => ({ ...f, price: e.target.value }))}
/>
<input
style={inputStyle} placeholder="Qté/unité"
value={form.quantity_per_unit ?? ''}
onChange={e => setForm(f => ({ ...f, quantity_per_unit: e.target.value }))}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input
style={inputStyle} placeholder="Code-barres"
value={form.barcode ?? ''}
onChange={e => setForm(f => ({ ...f, barcode: e.target.value }))}
/>
<select
style={inputStyle}
value={form.default_store_id ?? ''}
onChange={e => setForm(f => ({ ...f, default_store_id: e.target.value || undefined }))}
>
<option value="">Boutique par défaut</option>
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={cancelForm}
style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 40 }}
>Annuler</button>
<button
onClick={() => void handleSave()}
disabled={saving || !form.name.trim()}
style={{ padding: '8px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 40 }}
>{saving ? '…' : 'Enregistrer'}</button>
</div>
</div>
) : (
/* ── Liste des produits ── */
<>
<div style={{ display: 'flex', gap: 8 }}>
<input
style={{ ...inputStyle, flex: 1 }}
placeholder="Rechercher un article…"
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
<button
onClick={startCreate}
style={{ padding: '8px 14px', borderRadius: 8, border: 'none', background: 'var(--ok)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 20, minHeight: 40, flexShrink: 0 }}
>+</button>
</div>
<div style={{ maxHeight: 400, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 2 }}>
{products.length === 0 && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 20, fontFamily: 'var(--font-ui)', fontSize: 13 }}>
Aucun article{search ? ` pour "${search}"` : ''}
</p>
)}
{products.map(p => (
<div
key={p.id}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 12px', borderRadius: 8,
background: editing?.id === p.id ? 'var(--bg-4)' : 'var(--bg-3)',
userSelect: 'none',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.name}
{p.brand && <span style={{ color: 'var(--ink-3)', fontSize: 12, marginLeft: 6 }}>{p.brand}</span>}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 2 }}>
{p.category && <span style={{ color: 'var(--info)', fontSize: 11, fontFamily: 'var(--font-ui)' }}>{p.category}</span>}
{p.default_unit && <span style={{ color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>{p.default_unit}</span>}
{p.price && <span style={{ color: 'var(--ok)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>{p.price} €</span>}
</div>
</div>
<button
onClick={() => startEdit(p)}
style={{ background: 'var(--bg-5)', border: 'none', color: 'var(--ink-2)', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
>✏️</button>
<button
onClick={() => void handleDelete(p.id)}
style={{ background: 'transparent', border: 'none', color: 'var(--err)', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 14 }}
>✕</button>
</div>
))}
</div>
</>
)}
</Modal>
)
}
+317 -434
View File
@@ -1,16 +1,16 @@
// frontend/src/pages/ShoppingPage.tsx // frontend/src/pages/ShoppingPage.tsx
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import type { ShoppingList, ShoppingListDetail, ShoppingItem, Store } from '../api/shopping' import type { ShoppingListDetail, ShoppingList, Store } from '../api/shopping'
import { import {
fetchLists, createList, fetchListDetail, updateList, deleteList, fetchLists, createList, fetchListDetail, deleteList,
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList, addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
} from '../api/shopping' } from '../api/shopping'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import ItemRow from '../components/shopping/ItemRow' import ItemRow from '../components/shopping/ItemRow'
import CatalogueModal from '../components/shopping/CatalogueModal'
import BoutiquesModal from '../components/shopping/BoutiquesModal'
import { useWakeLock } from '../hooks/useWakeLock' import { useWakeLock } from '../hooks/useWakeLock'
type View = 'lists' | 'detail' | 'store'
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', width: '100%',
background: 'var(--bg-4)', background: 'var(--bg-4)',
@@ -23,46 +23,41 @@ const inputStyle: React.CSSProperties = {
boxSizing: 'border-box', boxSizing: 'border-box',
} }
const STATUS_LABELS: Record<string, string> = { const noSelect: React.CSSProperties = { userSelect: 'none' }
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() { export default function ShoppingPage() {
const [view, setView] = useState<View>('lists') const [currentList, setCurrentList] = useState<ShoppingListDetail | null>(null)
const [lists, setLists] = useState<ShoppingList[]>([]) const [allLists, setAllLists] = useState<ShoppingList[]>([])
const [activeList, setActiveList] = useState<ShoppingListDetail | null>(null)
const [stores, setStores] = useState<Store[]>([]) const [stores, setStores] = useState<Store[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) 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 [generating, setGenerating] = useState(false)
const [newListName, setNewListName] = useState('') const [showAddItemModal, setShowAddItemModal] = useState(false)
const [newListStore, setNewListStore] = useState('') const [showHistoryModal, setShowHistoryModal] = useState(false)
const [showCatalogueModal, setShowCatalogueModal] = useState(false)
const [showBoutiquesModal, setShowBoutiquesModal] = useState(false)
const [newItemName, setNewItemName] = useState('') const [newItemName, setNewItemName] = useState('')
const [newItemQty, setNewItemQty] = useState('') const [newItemQty, setNewItemQty] = useState('')
const [newItemUnit, setNewItemUnit] = useState('') const [newItemUnit, setNewItemUnit] = useState('')
useWakeLock(view === 'store') useWakeLock(currentList !== null)
const loadLists = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const [listsData, storesData] = await Promise.all([fetchLists(), fetchStores()]) const [listsData, storesData] = await Promise.all([fetchLists(), fetchStores()])
setLists(listsData) setAllLists(listsData)
setStores(storesData) setStores(storesData)
const current = listsData.find(l => l.status === 'draft' || l.status === 'active')
if (current) {
setCurrentList(await fetchListDetail(current.id))
} else {
setCurrentList(null)
}
} catch { } catch {
setError('Erreur lors du chargement') setError('Erreur lors du chargement')
} finally { } 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 { try {
const detail = await fetchListDetail(list.id) setCurrentList(await fetchListDetail(currentList.id))
setActiveList(detail)
setView('detail')
} catch { } catch {
setError('Erreur lors du chargement de la liste') setError('Erreur de rafraîchissement')
} }
} }
async function refreshActiveList() { async function handleCreateManualList() {
if (!activeList) return
try { try {
setActiveList(await fetchListDetail(activeList.id)) const detail = await createList({})
} catch { setCurrentList(detail)
setError('Erreur lors du rafraîchissement') void loadData()
}
}
async function handleCreateList() {
if (!newListName.trim()) return
try {
await createList({
name: newListName.trim(),
store_id: newListStore || undefined,
})
setNewListName('')
setNewListStore('')
setShowCreateModal(false)
void loadLists()
} catch { } catch {
setError('Erreur lors de la création') setError('Erreur lors de la création')
} }
} }
async function handleDeleteList(id: string) { async function handleGenerateMagicList() {
setGenerating(true)
setError(null)
try { try {
await deleteList(id) const detail = await generateMagicList()
void loadLists() setCurrentList(detail)
void loadData()
} catch { } catch {
setError('Erreur lors de la suppression') setError('Erreur lors de la génération')
} finally {
setGenerating(false)
} }
} }
async function handleAddItem() { async function handleAddItem() {
if (!activeList || !newItemName.trim()) return if (!currentList || !newItemName.trim()) return
try { try {
await addItem(activeList.id, { await addItem(currentList.id, {
custom_name: newItemName.trim(), custom_name: newItemName.trim(),
quantity: newItemQty || undefined, quantity: newItemQty || undefined,
unit: newItemUnit || undefined, unit: newItemUnit || undefined,
@@ -128,446 +112,345 @@ export default function ShoppingPage() {
setNewItemQty('') setNewItemQty('')
setNewItemUnit('') setNewItemUnit('')
setShowAddItemModal(false) setShowAddItemModal(false)
void refreshActiveList() void refreshCurrentList()
} catch { } catch {
setError("Erreur lors de l'ajout") setError("Erreur lors de l'ajout")
} }
} }
async function handleCheckItem(itemId: string, checked: boolean) { async function handleCheckItem(itemId: string, checked: boolean) {
if (!activeList) return if (!currentList) return
try { try {
await updateItem(activeList.id, itemId, { is_checked: checked }) await updateItem(currentList.id, itemId, { is_checked: checked })
void refreshActiveList() void refreshCurrentList()
} catch { } catch {
setError('Erreur lors du cochage') setError('Erreur lors du cochage')
} }
} }
async function handleDeleteItem(itemId: string) { async function handleDeleteItem(itemId: string) {
if (!activeList) return if (!currentList) return
try { try {
await deleteItem(activeList.id, itemId) await deleteItem(currentList.id, itemId)
void refreshActiveList() void refreshCurrentList()
} catch { } catch {
setError('Erreur lors de la suppression') setError('Erreur lors de la suppression')
} }
} }
async function handleFinish() { async function handleFinish() {
if (!activeList) return if (!currentList) return
try { try {
await finishShopping(activeList.id) await finishShopping(currentList.id)
setView('lists') void loadData()
setActiveList(null)
void loadLists()
} catch { } catch {
setError('Erreur lors de la finalisation') setError('Erreur lors de la finalisation')
} }
} }
async function handleGenerateMagicList() { async function handleDeleteCurrentList() {
setGenerating(true) if (!currentList) return
setError(null) if (!confirm('Supprimer la liste en cours ?')) return
try { try {
const newList = await generateMagicList() await deleteList(currentList.id)
void loadLists() void loadData()
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()
} catch { } catch {
setError('Erreur lors de la suppression') setError('Erreur lors de la suppression')
} }
} }
// ── Vue mode magasin ────────────────────────────────────────────────────── async function handleOpenHistoryList(list: ShoppingList) {
if (view === 'store' && activeList) { try {
const unchecked = activeList.items.filter(i => !i.is_checked) const detail = await fetchListDetail(list.id)
const checked = activeList.items.filter(i => i.is_checked) setCurrentList(detail)
setShowHistoryModal(false)
return ( } catch {
<div style={{ setError('Erreur lors du chargement')
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 ───────────────────────────────────────────────── // Tri : non cochés alpha, cochés alpha (en bas)
if (view === 'detail' && activeList) { const sortedItems = [...(currentList?.items ?? [])].sort((a, b) => {
return ( if (a.is_checked !== b.is_checked) return a.is_checked ? 1 : -1
<div className="p-4"> return a.display_name.localeCompare(b.display_name, 'fr')
<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>
{error && ( const uncheckedItems = sortedItems.filter(i => !i.is_checked)
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontSize: 13, fontFamily: 'var(--font-ui)' }}> 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} {error}
</p> </p>
)} </div>
)}
{activeList.items.length === 0 ? ( {loading && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}> <p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 40, fontFamily: 'var(--font-ui)', ...noSelect }}>Chargement…</p>
Aucun article — ajoutez-en avec le bouton + )}
{/* ── 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> </p>
) : (
<div className="glass" style={{ borderRadius: 10, overflow: 'hidden', marginBottom: 80 }}> <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
{activeList.items.map(item => ( <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 <ItemRow
key={item.id} key={item.id}
item={item} item={item}
onCheck={() => void handleCheckItem(item.id, !item.is_checked)} onCheck={() => void handleCheckItem(item.id, true)}
onDelete={() => void handleDeleteItem(item.id)} 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> </div>
)}
<button {/* FAB + */}
onClick={() => setShowAddItemModal(true)} <button
aria-label="Ajouter un article" onClick={() => setShowAddItemModal(true)}
style={{ aria-label="Ajouter un article"
position: 'fixed', bottom: 72, right: 20, style={{
width: 56, height: 56, borderRadius: '50%', position: 'fixed', bottom: 72, right: 20,
background: 'var(--accent)', color: '#1d2021', border: 'none', width: 56, height: 56, borderRadius: '50%',
fontSize: 28, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.5)', background: 'var(--accent)', color: '#1d2021', border: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
}} display: 'flex', alignItems: 'center', justifyContent: 'center',
>+</button> }}
>+</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>
)} )}
{loading && <p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24 }}>Chargement…</p>} {/* ── Modals ── */}
{!loading && lists.length === 0 && ( {showAddItemModal && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}> <Modal title="Ajouter un article" onClose={() => setShowAddItemModal(false)}>
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 <input
style={inputStyle} style={inputStyle}
placeholder="Nom de la liste (ex: Semaine du 26 mai)" placeholder="Nom de l'article *"
value={newListName} value={newItemName}
onChange={e => setNewListName(e.target.value)} onChange={e => setNewItemName(e.target.value)}
autoFocus autoFocus
onKeyDown={e => e.key === 'Enter' && void handleCreateList()} onKeyDown={e => e.key === 'Enter' && void handleAddItem()}
/> />
<select <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
style={inputStyle} <input
value={newListStore} style={inputStyle} placeholder="Quantité"
onChange={e => setNewListStore(e.target.value)} value={newItemQty}
> onChange={e => setNewItemQty(e.target.value)}
<option value="">Magasin (optionnel)</option> />
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)} <input
</select> style={inputStyle} placeholder="Unité (kg, L…)"
value={newItemUnit}
onChange={e => setNewItemUnit(e.target.value)}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button <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 }} 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> >Annuler</button>
<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 }} 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> </div>
</Modal> </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> </div>
) )
} }