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:
@@ -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')
|
||||
@@ -1,5 +1,6 @@
|
||||
# backend/app/api/shopping.py
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select, text
|
||||
@@ -11,12 +12,19 @@ from app.models.shopping import ShoppingList, ListItem, Product, Store
|
||||
from app.schemas.shopping import (
|
||||
ShoppingListCreate, ShoppingListUpdate, ShoppingListResponse,
|
||||
ShoppingListDetailResponse, ListItemCreate, ListItemUpdate,
|
||||
ListItemResponse, ProductResponse, StoreResponse,
|
||||
ListItemResponse, ProductCreate, ProductUpdate, ProductResponse,
|
||||
StoreCreate, StoreUpdate, StoreResponse,
|
||||
)
|
||||
|
||||
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:
|
||||
display_name = item.custom_name or (item.product.name if item.product else "Article inconnu")
|
||||
return ListItemResponse(
|
||||
@@ -55,12 +63,43 @@ async def list_stores(session: AsyncSession = Depends(get_session)):
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/products", response_model=list[ProductResponse])
|
||||
async def search_products(
|
||||
q: str | None = Query(default=None),
|
||||
limit: int = 30,
|
||||
limit: int = 50,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
stmt = select(Product).order_by(Product.frequency_score.desc(), Product.name)
|
||||
@@ -71,6 +110,41 @@ async def search_products(
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/lists", response_model=list[ShoppingListResponse])
|
||||
@@ -90,7 +164,10 @@ async def create_shopping_list(
|
||||
payload: ShoppingListCreate,
|
||||
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)
|
||||
await session.commit()
|
||||
await session.refresh(lst, ["items"])
|
||||
@@ -275,7 +352,7 @@ async def generate_magic_list(session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(query)
|
||||
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)
|
||||
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]
|
||||
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)
|
||||
await session.flush()
|
||||
for item in unchecked:
|
||||
|
||||
@@ -15,10 +15,14 @@ class Product(Base):
|
||||
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
brand: Mapped[str | None] = mapped_column(String(100))
|
||||
category: Mapped[str | None] = mapped_column(String(50))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
image_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))
|
||||
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"))
|
||||
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()"))
|
||||
@@ -31,6 +35,8 @@ class Store(Base):
|
||||
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)
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,51 @@ from typing import Literal
|
||||
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):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
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):
|
||||
@@ -18,7 +58,12 @@ class ProductResponse(BaseModel):
|
||||
name: str
|
||||
brand: str | None
|
||||
category: str | None
|
||||
description: 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
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,22 @@ export interface Store {
|
||||
id: string
|
||||
name: string
|
||||
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 {
|
||||
@@ -11,10 +27,39 @@ export interface Product {
|
||||
name: string
|
||||
brand: string | null
|
||||
category: string | null
|
||||
description: 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
|
||||
}
|
||||
|
||||
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 {
|
||||
id: string
|
||||
product_id: string | null
|
||||
@@ -77,15 +122,62 @@ async function handleResponse<T>(res: Response): Promise<T> {
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// ── Stores ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchStores(): Promise<Store[]> {
|
||||
return handleResponse(await fetch(`${BASE}/stores`))
|
||||
}
|
||||
|
||||
export async function searchProducts(q?: string): Promise<Product[]> {
|
||||
const qs = q ? `?q=${encodeURIComponent(q)}&limit=30` : '?limit=30'
|
||||
return handleResponse(await fetch(`${BASE}/products${qs}`))
|
||||
export async function createStore(data: StoreCreate): Promise<Store> {
|
||||
return handleResponse(await fetch(`${BASE}/stores`, {
|
||||
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[]> {
|
||||
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' }))
|
||||
}
|
||||
|
||||
// ── Items ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addItem(listId: string, data: ShoppingItemCreate): Promise<ShoppingItem> {
|
||||
return handleResponse(await fetch(`${BASE}/lists/${listId}/items`, {
|
||||
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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user