2087 lines
67 KiB
Markdown
2087 lines
67 KiB
Markdown
# Phase 3 — Liste de courses : Plan d'implémentation
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Implémenter le module liste de courses — CRUD listes et articles, cochage en magasin, mode plein-écran avec Wake Lock, bouton "liste magique" (génération automatique par score fréquence), et composant Modal réutilisable pour tous les formulaires de l'app.
|
|
|
|
**Architecture:** Backend FastAPI avec 12 endpoints sous `/api/shopping/`, frontend React avec 3 vues (liste des listes, détail d'une liste, mode magasin plein-écran). Un composant `Modal` générique sert de base à tous les formulaires. Terminologie : "articles" (pas "aliments" — la liste peut contenir n'importe quoi).
|
|
|
|
**Liste magique V1 :** score = `(today - last_purchased_at) / avg_interval_days` calculé depuis l'historique des `list_items` cochés. Bouton désactivé si une liste `draft`/`active` existe déjà.
|
|
|
|
**Tech Stack:** FastAPI 0.115, SQLAlchemy 2.0 async, Pydantic v2, React 18 + TypeScript, CSS variables Gruvbox, Wake Lock API (navigator.wakeLock).
|
|
|
|
---
|
|
|
|
## Structure des fichiers
|
|
|
|
**Créer :**
|
|
- `backend/app/schemas/shopping.py` — schémas Pydantic (listes, articles, produits, magasins)
|
|
- `backend/app/api/shopping.py` — 10 endpoints REST
|
|
- `backend/tests/test_shopping.py` — 9 tests d'intégration
|
|
- `frontend/src/api/shopping.ts` — client fetch typé
|
|
- `frontend/src/components/Modal.tsx` — modal générique réutilisable
|
|
- `frontend/src/components/shopping/ItemRow.tsx` — ligne article avec check + swipe suppression
|
|
- `frontend/src/hooks/useWakeLock.ts` — hook Wake Lock API avec fallback
|
|
|
|
**Modifier :**
|
|
- `backend/app/models/shopping.py` — ajouter relation `product` sur `ListItem`
|
|
- `backend/app/main.py` — enregistrer le router shopping
|
|
- `frontend/src/pages/ShoppingPage.tsx` — remplacer le placeholder par la vraie page
|
|
|
|
---
|
|
|
|
## Task 1 : Relation `product` dans le modèle ListItem
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/models/shopping.py`
|
|
|
|
- [ ] **Step 1 : Ajouter la relation `product` sur `ListItem`**
|
|
|
|
Ouvrir `backend/app/models/shopping.py`. Ajouter l'import `Optional` et la relation dans `ListItem` :
|
|
|
|
```python
|
|
# Ajouter en haut du fichier, avec les imports existants :
|
|
from typing import Optional
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship # déjà présent
|
|
|
|
# Dans la classe ListItem, ajouter après `shopping_list` :
|
|
product: Mapped[Optional["Product"]] = relationship("Product", lazy="select")
|
|
```
|
|
|
|
Le fichier complet de `ListItem` après modification :
|
|
|
|
```python
|
|
class ListItem(Base):
|
|
__tablename__ = "list_items"
|
|
__table_args__ = {"schema": "shopping"}
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
list_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.lists.id", ondelete="CASCADE"), nullable=False)
|
|
product_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.products.id", ondelete="SET NULL"))
|
|
custom_name: Mapped[str | None] = mapped_column(String(150))
|
|
quantity: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
|
|
unit: Mapped[str | None] = mapped_column(String(20))
|
|
is_checked: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
|
|
price_recorded: Mapped[Decimal | None] = mapped_column(Numeric(8, 2))
|
|
carried_over: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
|
|
sort_order: Mapped[int | None] = mapped_column(Integer)
|
|
|
|
shopping_list: Mapped["ShoppingList"] = relationship("ShoppingList", back_populates="items")
|
|
product: Mapped[Optional["Product"]] = relationship("Product", lazy="select")
|
|
```
|
|
|
|
- [ ] **Step 2 : Vérifier que l'import Python fonctionne**
|
|
|
|
```bash
|
|
docker compose exec backend python -c "from app.models.shopping import ListItem; print('OK')"
|
|
```
|
|
|
|
Expected: `OK`
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
rtk git add backend/app/models/shopping.py
|
|
rtk git commit -m "feat(shopping): relation product sur ListItem"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2 : Schémas Pydantic
|
|
|
|
**Files:**
|
|
- Create: `backend/app/schemas/shopping.py`
|
|
|
|
- [ ] **Step 1 : Créer le fichier de schémas**
|
|
|
|
```python
|
|
# backend/app/schemas/shopping.py
|
|
import uuid
|
|
from datetime import datetime, date
|
|
from decimal import Decimal
|
|
from typing import Literal
|
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
|
|
|
class StoreResponse(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: uuid.UUID
|
|
name: str
|
|
location: str | None
|
|
|
|
|
|
class ProductResponse(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: uuid.UUID
|
|
name: str
|
|
brand: str | None
|
|
category: str | None
|
|
default_unit: str | None
|
|
frequency_score: int
|
|
|
|
|
|
class ListItemCreate(BaseModel):
|
|
product_id: uuid.UUID | None = None
|
|
custom_name: str | None = None
|
|
quantity: Decimal | None = None
|
|
unit: str | None = None
|
|
|
|
@model_validator(mode='after')
|
|
def must_have_name(self) -> 'ListItemCreate':
|
|
if not self.product_id and not self.custom_name:
|
|
raise ValueError('product_id ou custom_name requis')
|
|
return self
|
|
|
|
|
|
class ListItemUpdate(BaseModel):
|
|
is_checked: bool | None = None
|
|
quantity: Decimal | None = None
|
|
unit: str | None = None
|
|
price_recorded: Decimal | None = None
|
|
|
|
|
|
class ListItemResponse(BaseModel):
|
|
id: uuid.UUID
|
|
product_id: uuid.UUID | None
|
|
custom_name: str | None
|
|
display_name: str
|
|
quantity: Decimal | None
|
|
unit: str | None
|
|
is_checked: bool
|
|
price_recorded: Decimal | None
|
|
carried_over: bool
|
|
sort_order: int | None
|
|
|
|
|
|
class ShoppingListCreate(BaseModel):
|
|
name: str | None = None
|
|
store_id: uuid.UUID | None = None
|
|
week_date: date | None = None
|
|
|
|
|
|
class ShoppingListUpdate(BaseModel):
|
|
name: str | None = None
|
|
store_id: uuid.UUID | None = None
|
|
status: Literal['draft', 'active', 'done'] | None = None
|
|
|
|
|
|
class ShoppingListResponse(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: uuid.UUID
|
|
name: str | None
|
|
store_id: uuid.UUID | None
|
|
week_date: date | None
|
|
status: str
|
|
created_at: datetime
|
|
item_count: int
|
|
checked_count: int
|
|
|
|
|
|
class ShoppingListDetailResponse(BaseModel):
|
|
id: uuid.UUID
|
|
name: str | None
|
|
store_id: uuid.UUID | None
|
|
week_date: date | None
|
|
status: str
|
|
created_at: datetime
|
|
item_count: int
|
|
checked_count: int
|
|
items: list[ListItemResponse]
|
|
```
|
|
|
|
- [ ] **Step 2 : Vérifier l'import**
|
|
|
|
```bash
|
|
docker compose exec backend python -c "from app.schemas.shopping import ShoppingListCreate; print('OK')"
|
|
```
|
|
|
|
Expected: `OK`
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
rtk git add backend/app/schemas/shopping.py
|
|
rtk git commit -m "feat(shopping): schémas Pydantic listes et articles"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3 : Endpoints backend
|
|
|
|
**Files:**
|
|
- Create: `backend/app/api/shopping.py`
|
|
- Modify: `backend/app/main.py`
|
|
|
|
- [ ] **Step 1 : Créer le fichier d'endpoints**
|
|
|
|
```python
|
|
# 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, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.database import get_session
|
|
from app.models.shopping import ShoppingList, ListItem, Product, Store
|
|
from app.schemas.shopping import (
|
|
ShoppingListCreate, ShoppingListUpdate, ShoppingListResponse,
|
|
ShoppingListDetailResponse, ListItemCreate, ListItemUpdate,
|
|
ListItemResponse, ProductResponse, StoreResponse,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _item_to_response(item: ListItem) -> ListItemResponse:
|
|
display_name = item.custom_name or (item.product.name if item.product else "Article inconnu")
|
|
return ListItemResponse(
|
|
id=item.id,
|
|
product_id=item.product_id,
|
|
custom_name=item.custom_name,
|
|
display_name=display_name,
|
|
quantity=item.quantity,
|
|
unit=item.unit,
|
|
is_checked=item.is_checked,
|
|
price_recorded=item.price_recorded,
|
|
carried_over=item.carried_over,
|
|
sort_order=item.sort_order,
|
|
)
|
|
|
|
|
|
def _list_to_response(lst: ShoppingList) -> ShoppingListResponse:
|
|
items = lst.items if lst.items is not None else []
|
|
return ShoppingListResponse(
|
|
id=lst.id,
|
|
name=lst.name,
|
|
store_id=lst.store_id,
|
|
week_date=lst.week_date,
|
|
status=lst.status,
|
|
created_at=lst.created_at,
|
|
item_count=len(items),
|
|
checked_count=sum(1 for i in items if i.is_checked),
|
|
)
|
|
|
|
|
|
# ── Stores ────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/stores", response_model=list[StoreResponse])
|
|
async def list_stores(session: AsyncSession = Depends(get_session)):
|
|
result = await session.execute(select(Store).order_by(Store.name))
|
|
return result.scalars().all()
|
|
|
|
|
|
# ── Products ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/products", response_model=list[ProductResponse])
|
|
async def search_products(
|
|
q: str | None = Query(default=None),
|
|
limit: int = 30,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
stmt = select(Product).order_by(Product.frequency_score.desc(), Product.name)
|
|
if q:
|
|
stmt = stmt.where(Product.name.ilike(f"%{q}%"))
|
|
stmt = stmt.limit(limit)
|
|
result = await session.execute(stmt)
|
|
return result.scalars().all()
|
|
|
|
|
|
# ── Lists ─────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/lists", response_model=list[ShoppingListResponse])
|
|
async def list_shopping_lists(session: AsyncSession = Depends(get_session)):
|
|
stmt = (
|
|
select(ShoppingList)
|
|
.options(selectinload(ShoppingList.items))
|
|
.order_by(ShoppingList.created_at.desc())
|
|
)
|
|
result = await session.execute(stmt)
|
|
lists = result.scalars().all()
|
|
return [_list_to_response(lst) for lst in lists]
|
|
|
|
|
|
@router.post("/lists", response_model=ShoppingListDetailResponse, status_code=201)
|
|
async def create_shopping_list(
|
|
payload: ShoppingListCreate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
lst = ShoppingList(**payload.model_dump())
|
|
session.add(lst)
|
|
await session.commit()
|
|
await session.refresh(lst, ["items"])
|
|
return ShoppingListDetailResponse(
|
|
**_list_to_response(lst).model_dump(),
|
|
items=[],
|
|
)
|
|
|
|
|
|
@router.get("/lists/{list_id}", response_model=ShoppingListDetailResponse)
|
|
async def get_shopping_list(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)):
|
|
stmt = (
|
|
select(ShoppingList)
|
|
.where(ShoppingList.id == list_id)
|
|
.options(selectinload(ShoppingList.items).selectinload(ListItem.product))
|
|
)
|
|
result = await session.execute(stmt)
|
|
lst = result.scalar_one_or_none()
|
|
if not lst:
|
|
raise HTTPException(404, "Liste introuvable")
|
|
sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id)))
|
|
return ShoppingListDetailResponse(
|
|
**_list_to_response(lst).model_dump(),
|
|
items=[_item_to_response(i) for i in sorted_items],
|
|
)
|
|
|
|
|
|
@router.patch("/lists/{list_id}", response_model=ShoppingListDetailResponse)
|
|
async def update_shopping_list(
|
|
list_id: uuid.UUID,
|
|
payload: ShoppingListUpdate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
stmt = (
|
|
select(ShoppingList)
|
|
.where(ShoppingList.id == list_id)
|
|
.options(selectinload(ShoppingList.items).selectinload(ListItem.product))
|
|
)
|
|
result = await session.execute(stmt)
|
|
lst = result.scalar_one_or_none()
|
|
if not lst:
|
|
raise HTTPException(404, "Liste introuvable")
|
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
|
setattr(lst, field, value)
|
|
await session.commit()
|
|
sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id)))
|
|
return ShoppingListDetailResponse(
|
|
**_list_to_response(lst).model_dump(),
|
|
items=[_item_to_response(i) for i in sorted_items],
|
|
)
|
|
|
|
|
|
@router.delete("/lists/{list_id}", status_code=204)
|
|
async def delete_shopping_list(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)):
|
|
lst = await session.get(ShoppingList, list_id)
|
|
if not lst:
|
|
raise HTTPException(404, "Liste introuvable")
|
|
await session.delete(lst)
|
|
await session.commit()
|
|
return Response(status_code=204)
|
|
|
|
|
|
# ── Items ─────────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/lists/{list_id}/items", response_model=ListItemResponse, status_code=201)
|
|
async def add_item(
|
|
list_id: uuid.UUID,
|
|
payload: ListItemCreate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
lst = await session.get(ShoppingList, list_id)
|
|
if not lst:
|
|
raise HTTPException(404, "Liste introuvable")
|
|
item = ListItem(list_id=list_id, **payload.model_dump())
|
|
session.add(item)
|
|
await session.commit()
|
|
# Recharger avec la relation product pour le display_name
|
|
stmt = (
|
|
select(ListItem)
|
|
.where(ListItem.id == item.id)
|
|
.options(selectinload(ListItem.product))
|
|
)
|
|
result = await session.execute(stmt)
|
|
item = result.scalar_one()
|
|
return _item_to_response(item)
|
|
|
|
|
|
@router.patch("/lists/{list_id}/items/{item_id}", response_model=ListItemResponse)
|
|
async def update_item(
|
|
list_id: uuid.UUID,
|
|
item_id: uuid.UUID,
|
|
payload: ListItemUpdate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
stmt = (
|
|
select(ListItem)
|
|
.where(ListItem.id == item_id, ListItem.list_id == list_id)
|
|
.options(selectinload(ListItem.product))
|
|
)
|
|
result = await session.execute(stmt)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(404, "Article introuvable")
|
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
|
setattr(item, field, value)
|
|
await session.commit()
|
|
await session.refresh(item)
|
|
return _item_to_response(item)
|
|
|
|
|
|
@router.delete("/lists/{list_id}/items/{item_id}", status_code=204)
|
|
async def delete_item(
|
|
list_id: uuid.UUID,
|
|
item_id: uuid.UUID,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
stmt = select(ListItem).where(ListItem.id == item_id, ListItem.list_id == list_id)
|
|
result = await session.execute(stmt)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(404, "Article introuvable")
|
|
await session.delete(item)
|
|
await session.commit()
|
|
return Response(status_code=204)
|
|
|
|
|
|
@router.post("/lists/{list_id}/finish", response_model=ShoppingListDetailResponse)
|
|
async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)):
|
|
stmt = (
|
|
select(ShoppingList)
|
|
.where(ShoppingList.id == list_id)
|
|
.options(selectinload(ShoppingList.items).selectinload(ListItem.product))
|
|
)
|
|
result = await session.execute(stmt)
|
|
lst = result.scalar_one_or_none()
|
|
if not lst:
|
|
raise HTTPException(404, "Liste introuvable")
|
|
|
|
lst.status = "done"
|
|
|
|
unchecked = [i for i in lst.items if not i.is_checked]
|
|
if unchecked:
|
|
new_list = ShoppingList(store_id=lst.store_id, status="draft")
|
|
session.add(new_list)
|
|
await session.flush()
|
|
for item in unchecked:
|
|
session.add(ListItem(
|
|
list_id=new_list.id,
|
|
product_id=item.product_id,
|
|
custom_name=item.custom_name,
|
|
quantity=item.quantity,
|
|
unit=item.unit,
|
|
sort_order=item.sort_order,
|
|
carried_over=True,
|
|
))
|
|
|
|
await session.commit()
|
|
sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id)))
|
|
return ShoppingListDetailResponse(
|
|
**_list_to_response(lst).model_dump(),
|
|
items=[_item_to_response(i) for i in sorted_items],
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2 : Enregistrer le router dans `main.py`**
|
|
|
|
Ouvrir `backend/app/main.py`. Ajouter après les imports existants :
|
|
|
|
```python
|
|
from app.api.shopping import router as shopping_router
|
|
```
|
|
|
|
Et après `app.include_router(todos_router, prefix="/api/todos")` :
|
|
|
|
```python
|
|
app.include_router(shopping_router, prefix="/api/shopping")
|
|
```
|
|
|
|
- [ ] **Step 3 : Vérifier que les routes sont enregistrées**
|
|
|
|
```bash
|
|
docker compose build backend && docker compose up -d backend
|
|
docker compose exec backend python -c "
|
|
from app.main import app
|
|
routes = [r.path for r in app.routes if 'shopping' in r.path]
|
|
print('\n'.join(routes))
|
|
"
|
|
```
|
|
|
|
Expected output (10 routes) :
|
|
```
|
|
/api/shopping/stores
|
|
/api/shopping/products
|
|
/api/shopping/lists
|
|
/api/shopping/lists
|
|
/api/shopping/lists/{list_id}
|
|
/api/shopping/lists/{list_id}
|
|
/api/shopping/lists/{list_id}
|
|
/api/shopping/lists/{list_id}/items
|
|
/api/shopping/lists/{list_id}/items/{item_id}
|
|
/api/shopping/lists/{list_id}/items/{item_id}
|
|
/api/shopping/lists/{list_id}/finish
|
|
```
|
|
|
|
- [ ] **Step 4 : Commit**
|
|
|
|
```bash
|
|
rtk git add backend/app/api/shopping.py backend/app/main.py
|
|
rtk git commit -m "feat(shopping): 10 endpoints CRUD listes et articles"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4 : Tests d'intégration backend
|
|
|
|
**Files:**
|
|
- Create: `backend/tests/test_shopping.py`
|
|
|
|
- [ ] **Step 1 : Écrire les tests**
|
|
|
|
```python
|
|
# backend/tests/test_shopping.py
|
|
import pytest
|
|
from sqlalchemy import delete
|
|
from app.models.shopping import ShoppingList, ListItem
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def cleanup(db_session):
|
|
yield
|
|
# Supprimer les listes de test (cascade supprime les items)
|
|
await db_session.execute(
|
|
delete(ShoppingList).where(ShoppingList.name.like("TEST_%"))
|
|
)
|
|
# Supprimer les listes sans nom créées par les tests
|
|
result = await db_session.execute(
|
|
delete(ShoppingList).where(ShoppingList.name.is_(None), ShoppingList.status == "draft")
|
|
)
|
|
await db_session.commit()
|
|
|
|
|
|
async def test_creer_liste(client):
|
|
resp = await client.post("/api/shopping/lists", json={"name": "TEST_semaine 1"})
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["name"] == "TEST_semaine 1"
|
|
assert data["status"] == "draft"
|
|
assert data["item_count"] == 0
|
|
assert data["items"] == []
|
|
|
|
|
|
async def test_lister_listes(client):
|
|
await client.post("/api/shopping/lists", json={"name": "TEST_liste A"})
|
|
await client.post("/api/shopping/lists", json={"name": "TEST_liste B"})
|
|
|
|
resp = await client.get("/api/shopping/lists")
|
|
assert resp.status_code == 200
|
|
noms = [l["name"] for l in resp.json()]
|
|
assert "TEST_liste A" in noms
|
|
assert "TEST_liste B" in noms
|
|
|
|
|
|
async def test_ajouter_article_custom(client):
|
|
liste = (await client.post("/api/shopping/lists", json={"name": "TEST_ajout"})).json()
|
|
list_id = liste["id"]
|
|
|
|
resp = await client.post(f"/api/shopping/lists/{list_id}/items", json={
|
|
"custom_name": "Farine T55",
|
|
"quantity": "1.5",
|
|
"unit": "kg",
|
|
})
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["display_name"] == "Farine T55"
|
|
assert data["is_checked"] is False
|
|
assert data["carried_over"] is False
|
|
|
|
|
|
async def test_ajouter_article_sans_nom_erreur(client):
|
|
liste = (await client.post("/api/shopping/lists", json={"name": "TEST_sans nom"})).json()
|
|
resp = await client.post(f"/api/shopping/lists/{liste['id']}/items", json={})
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_cocher_article(client):
|
|
liste = (await client.post("/api/shopping/lists", json={"name": "TEST_cocher"})).json()
|
|
list_id = liste["id"]
|
|
item = (await client.post(f"/api/shopping/lists/{list_id}/items", json={"custom_name": "Lait"})).json()
|
|
item_id = item["id"]
|
|
|
|
resp = await client.patch(f"/api/shopping/lists/{list_id}/items/{item_id}", json={"is_checked": True})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["is_checked"] is True
|
|
|
|
|
|
async def test_supprimer_article(client):
|
|
liste = (await client.post("/api/shopping/lists", json={"name": "TEST_suppr"})).json()
|
|
list_id = liste["id"]
|
|
item = (await client.post(f"/api/shopping/lists/{list_id}/items", json={"custom_name": "Beurre"})).json()
|
|
item_id = item["id"]
|
|
|
|
resp = await client.delete(f"/api/shopping/lists/{list_id}/items/{item_id}")
|
|
assert resp.status_code == 204
|
|
|
|
detail = await client.get(f"/api/shopping/lists/{list_id}")
|
|
assert all(i["id"] != item_id for i in detail.json()["items"])
|
|
|
|
|
|
async def test_terminer_courses_reporte_non_coches(client):
|
|
liste = (await client.post("/api/shopping/lists", json={"name": "TEST_finish"})).json()
|
|
list_id = liste["id"]
|
|
|
|
await client.post(f"/api/shopping/lists/{list_id}/items", json={"custom_name": "Coché"})
|
|
item_non_coche = (await client.post(f"/api/shopping/lists/{list_id}/items", json={"custom_name": "Non coché"})).json()
|
|
|
|
# Cocher le premier article
|
|
items = (await client.get(f"/api/shopping/lists/{list_id}")).json()["items"]
|
|
coche_id = next(i["id"] for i in items if i["display_name"] == "Coché")
|
|
await client.patch(f"/api/shopping/lists/{list_id}/items/{coche_id}", json={"is_checked": True})
|
|
|
|
resp = await client.post(f"/api/shopping/lists/{list_id}/finish")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "done"
|
|
|
|
# Vérifier que la nouvelle liste draft a été créée avec l'article non coché
|
|
all_lists = (await client.get("/api/shopping/lists")).json()
|
|
new_drafts = [l for l in all_lists if l["status"] == "draft"]
|
|
assert len(new_drafts) >= 1
|
|
|
|
|
|
async def test_detail_liste_404(client):
|
|
resp = await client.get("/api/shopping/lists/00000000-0000-0000-0000-000000000000")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_lister_stores(client):
|
|
resp = await client.get("/api/shopping/stores")
|
|
assert resp.status_code == 200
|
|
assert isinstance(resp.json(), list)
|
|
|
|
|
|
async def test_rechercher_produits(client):
|
|
resp = await client.get("/api/shopping/products?q=lait&limit=5")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) <= 5
|
|
```
|
|
|
|
- [ ] **Step 2 : Lancer les tests**
|
|
|
|
```bash
|
|
docker compose exec backend python -m pytest backend/tests/test_shopping.py -v
|
|
```
|
|
|
|
Expected: `9 passed`
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
rtk git add backend/tests/test_shopping.py
|
|
rtk git commit -m "test(shopping): 9 tests d'intégration CRUD listes et articles"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5 : Client API TypeScript
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/api/shopping.ts`
|
|
|
|
- [ ] **Step 1 : Créer le client fetch typé**
|
|
|
|
```typescript
|
|
// frontend/src/api/shopping.ts
|
|
|
|
export interface Store {
|
|
id: string
|
|
name: string
|
|
location: string | null
|
|
}
|
|
|
|
export interface Product {
|
|
id: string
|
|
name: string
|
|
brand: string | null
|
|
category: string | null
|
|
default_unit: string | null
|
|
frequency_score: number
|
|
}
|
|
|
|
export interface ShoppingItem {
|
|
id: string
|
|
product_id: string | null
|
|
custom_name: string | null
|
|
display_name: string
|
|
quantity: string | null
|
|
unit: string | null
|
|
is_checked: boolean
|
|
price_recorded: string | null
|
|
carried_over: boolean
|
|
sort_order: number | null
|
|
}
|
|
|
|
export interface ShoppingList {
|
|
id: string
|
|
name: string | null
|
|
store_id: string | null
|
|
week_date: string | null
|
|
status: 'draft' | 'active' | 'done'
|
|
created_at: string
|
|
item_count: number
|
|
checked_count: number
|
|
}
|
|
|
|
export interface ShoppingListDetail extends ShoppingList {
|
|
items: ShoppingItem[]
|
|
}
|
|
|
|
export interface ShoppingListCreate {
|
|
name?: string
|
|
store_id?: string
|
|
week_date?: string
|
|
}
|
|
|
|
export interface ShoppingListUpdate {
|
|
name?: string
|
|
store_id?: string
|
|
status?: 'draft' | 'active' | 'done'
|
|
}
|
|
|
|
export interface ShoppingItemCreate {
|
|
product_id?: string
|
|
custom_name?: string
|
|
quantity?: string
|
|
unit?: string
|
|
}
|
|
|
|
export interface ShoppingItemUpdate {
|
|
is_checked?: boolean
|
|
quantity?: string
|
|
unit?: string
|
|
price_recorded?: string
|
|
}
|
|
|
|
const BASE = '/api/shopping'
|
|
|
|
async function handleResponse<T>(res: Response): Promise<T> {
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
|
if (res.status === 204) return undefined as T
|
|
return res.json() as Promise<T>
|
|
}
|
|
|
|
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 fetchLists(): Promise<ShoppingList[]> {
|
|
return handleResponse(await fetch(`${BASE}/lists`))
|
|
}
|
|
|
|
export async function createList(data: ShoppingListCreate): Promise<ShoppingListDetail> {
|
|
return handleResponse(await fetch(`${BASE}/lists`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
}))
|
|
}
|
|
|
|
export async function fetchListDetail(id: string): Promise<ShoppingListDetail> {
|
|
return handleResponse(await fetch(`${BASE}/lists/${id}`))
|
|
}
|
|
|
|
export async function updateList(id: string, data: ShoppingListUpdate): Promise<ShoppingListDetail> {
|
|
return handleResponse(await fetch(`${BASE}/lists/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
}))
|
|
}
|
|
|
|
export async function deleteList(id: string): Promise<void> {
|
|
return handleResponse(await fetch(`${BASE}/lists/${id}`, { method: 'DELETE' }))
|
|
}
|
|
|
|
export async function addItem(listId: string, data: ShoppingItemCreate): Promise<ShoppingItem> {
|
|
return handleResponse(await fetch(`${BASE}/lists/${listId}/items`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
}))
|
|
}
|
|
|
|
export async function updateItem(listId: string, itemId: string, data: ShoppingItemUpdate): Promise<ShoppingItem> {
|
|
return handleResponse(await fetch(`${BASE}/lists/${listId}/items/${itemId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
}))
|
|
}
|
|
|
|
export async function deleteItem(listId: string, itemId: string): Promise<void> {
|
|
return handleResponse(await fetch(`${BASE}/lists/${listId}/items/${itemId}`, { method: 'DELETE' }))
|
|
}
|
|
|
|
export async function finishShopping(listId: string): Promise<ShoppingListDetail> {
|
|
return handleResponse(await fetch(`${BASE}/lists/${listId}/finish`, { method: 'POST' }))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Vérifier que TypeScript compile**
|
|
|
|
```bash
|
|
docker compose exec frontend npx tsc --noEmit 2>&1 | head -20
|
|
```
|
|
|
|
Expected: aucune erreur (ou le conteneur frontend n'a pas tsc — vérifier dans l'image de dev)
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
rtk git add frontend/src/api/shopping.ts
|
|
rtk git commit -m "feat(shopping): client API TypeScript typé"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6 : Composant Modal réutilisable
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/Modal.tsx`
|
|
|
|
- [ ] **Step 1 : Créer le composant Modal**
|
|
|
|
```tsx
|
|
// frontend/src/components/Modal.tsx
|
|
import { useEffect } from 'react'
|
|
|
|
interface ModalProps {
|
|
title: string
|
|
onClose: () => void
|
|
children: React.ReactNode
|
|
width?: number
|
|
}
|
|
|
|
export default function Modal({ title, onClose, children, width = 480 }: ModalProps) {
|
|
// Fermer sur Escape
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') onClose()
|
|
}
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [onClose])
|
|
|
|
return (
|
|
<div
|
|
onClick={onClose}
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'rgba(0,0,0,0.6)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 200,
|
|
padding: 16,
|
|
}}
|
|
>
|
|
<div
|
|
onClick={e => e.stopPropagation()}
|
|
className="glass"
|
|
style={{
|
|
width: '100%',
|
|
maxWidth: width,
|
|
borderRadius: 12,
|
|
padding: 20,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 16,
|
|
maxHeight: '90dvh',
|
|
overflowY: 'auto',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<h2 style={{ margin: 0, color: 'var(--accent)', fontFamily: 'var(--font-mono)', fontSize: 16 }}>
|
|
{title}
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
style={{
|
|
background: 'transparent',
|
|
border: 'none',
|
|
color: 'var(--ink-3)',
|
|
fontSize: 20,
|
|
cursor: 'pointer',
|
|
lineHeight: 1,
|
|
padding: 4,
|
|
}}
|
|
aria-label="Fermer"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Commit**
|
|
|
|
```bash
|
|
rtk git add frontend/src/components/Modal.tsx
|
|
rtk git commit -m "feat(ui): composant Modal réutilisable (overlay + Escape)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7 : Hook Wake Lock
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/hooks/useWakeLock.ts`
|
|
|
|
- [ ] **Step 1 : Créer le hook**
|
|
|
|
```typescript
|
|
// frontend/src/hooks/useWakeLock.ts
|
|
import { useEffect, useRef } from 'react'
|
|
|
|
export function useWakeLock(active: boolean) {
|
|
const lockRef = useRef<WakeLockSentinel | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!active) {
|
|
lockRef.current?.release().catch(() => {})
|
|
lockRef.current = null
|
|
return
|
|
}
|
|
|
|
if (!('wakeLock' in navigator)) return
|
|
|
|
navigator.wakeLock.request('screen').then(lock => {
|
|
lockRef.current = lock
|
|
}).catch(() => {
|
|
// Wake Lock non disponible (mode économie d'énergie, navigateur non supporté)
|
|
})
|
|
|
|
return () => {
|
|
lockRef.current?.release().catch(() => {})
|
|
lockRef.current = null
|
|
}
|
|
}, [active])
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Commit**
|
|
|
|
```bash
|
|
rtk git add frontend/src/hooks/useWakeLock.ts
|
|
rtk git commit -m "feat(shopping): hook useWakeLock avec fallback gracieux"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8 : Composant ItemRow
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/shopping/ItemRow.tsx`
|
|
|
|
- [ ] **Step 1 : Créer le composant**
|
|
|
|
```tsx
|
|
// frontend/src/components/shopping/ItemRow.tsx
|
|
import { useRef, useState } from 'react'
|
|
import type { ShoppingItem } from '../../api/shopping'
|
|
|
|
interface ItemRowProps {
|
|
item: ShoppingItem
|
|
onCheck: () => void
|
|
onDelete: () => void
|
|
storeMode?: boolean // true = grands boutons, pas de swipe
|
|
}
|
|
|
|
const SWIPE_THRESHOLD = 80
|
|
|
|
export default function ItemRow({ item, onCheck, onDelete, storeMode = false }: ItemRowProps) {
|
|
const [offsetX, setOffsetX] = useState(0)
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
const startX = useRef<number | null>(null)
|
|
|
|
function onTouchStart(e: React.TouchEvent) {
|
|
if (storeMode) return
|
|
startX.current = e.touches[0].clientX
|
|
setIsDragging(true)
|
|
}
|
|
|
|
function onTouchMove(e: React.TouchEvent) {
|
|
if (startX.current === null) return
|
|
const dx = e.touches[0].clientX - startX.current
|
|
setOffsetX(Math.max(Math.min(dx, 0), -120)) // swipe gauche uniquement
|
|
}
|
|
|
|
function onTouchEnd() {
|
|
if (offsetX < -SWIPE_THRESHOLD) onDelete()
|
|
setOffsetX(0)
|
|
setIsDragging(false)
|
|
startX.current = null
|
|
}
|
|
|
|
const minHeight = storeMode ? 64 : 52
|
|
|
|
return (
|
|
<div style={{ position: 'relative', overflow: 'hidden' }}>
|
|
{/* Zone suppression révélée par swipe gauche */}
|
|
{!storeMode && (
|
|
<div style={{
|
|
position: 'absolute', right: 0, top: 0, bottom: 0,
|
|
width: 120, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
background: 'var(--err)',
|
|
opacity: offsetX < -20 ? 1 : 0,
|
|
transition: 'opacity 0.15s',
|
|
}}>
|
|
<span style={{ color: '#fff', fontSize: 20 }}>✕</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Ligne principale */}
|
|
<div
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
onClick={onCheck}
|
|
style={{
|
|
transform: `translateX(${offsetX}px)`,
|
|
transition: isDragging ? 'none' : 'transform 0.2s ease',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
padding: storeMode ? '14px 16px' : '10px 16px',
|
|
minHeight,
|
|
cursor: 'pointer',
|
|
background: item.is_checked ? 'rgba(142,192,124,0.08)' : 'transparent',
|
|
borderBottom: '1px solid var(--bg-4)',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
{/* Checkbox visuelle */}
|
|
<div style={{
|
|
width: storeMode ? 28 : 22,
|
|
height: storeMode ? 28 : 22,
|
|
borderRadius: '50%',
|
|
border: `2px solid ${item.is_checked ? 'var(--ok)' : 'var(--bg-5)'}`,
|
|
background: item.is_checked ? 'var(--ok)' : 'transparent',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
flexShrink: 0,
|
|
transition: 'all 0.15s',
|
|
}}>
|
|
{item.is_checked && <span style={{ color: '#1d2021', fontSize: storeMode ? 16 : 12, fontWeight: 700 }}>✓</span>}
|
|
</div>
|
|
|
|
{/* Nom + métadonnées */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{
|
|
color: item.is_checked ? 'var(--ink-3)' : 'var(--ink-1)',
|
|
fontSize: storeMode ? 18 : 14,
|
|
fontFamily: 'var(--font-ui)',
|
|
textDecoration: item.is_checked ? 'line-through' : 'none',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}>
|
|
{item.display_name}
|
|
{item.carried_over && (
|
|
<span style={{ fontSize: 10, color: 'var(--info)', marginLeft: 6 }}>↩</span>
|
|
)}
|
|
</div>
|
|
{(item.quantity || item.unit) && (
|
|
<div style={{ color: 'var(--ink-3)', fontSize: 11, marginTop: 2, fontFamily: 'var(--font-mono)' }}>
|
|
{item.quantity}{item.unit ? ` ${item.unit}` : ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bouton supprimer en mode store (pas de swipe) */}
|
|
{storeMode && !item.is_checked && (
|
|
<button
|
|
onClick={e => { e.stopPropagation(); onDelete() }}
|
|
style={{
|
|
background: 'transparent',
|
|
border: 'none',
|
|
color: 'var(--ink-4)',
|
|
fontSize: 18,
|
|
cursor: 'pointer',
|
|
padding: '4px 8px',
|
|
minHeight: 44,
|
|
}}
|
|
>✕</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Commit**
|
|
|
|
```bash
|
|
rtk git add frontend/src/components/shopping/ItemRow.tsx
|
|
rtk git commit -m "feat(shopping): composant ItemRow avec swipe-to-delete et mode magasin"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9 : Page ShoppingPage
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/pages/ShoppingPage.tsx`
|
|
|
|
La page gère trois états via `view` : `'lists'` (liste des listes), `'detail'` (détail d'une liste), `'store'` (mode magasin plein-écran).
|
|
|
|
- [ ] **Step 1 : Écrire la page complète**
|
|
|
|
```tsx
|
|
// frontend/src/pages/ShoppingPage.tsx
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import type { ShoppingList, ShoppingListDetail, ShoppingItem, Store } from '../api/shopping'
|
|
import {
|
|
fetchLists, createList, fetchListDetail, updateList, deleteList,
|
|
addItem, updateItem, deleteItem, finishShopping, fetchStores,
|
|
} from '../api/shopping'
|
|
import Modal from '../components/Modal'
|
|
import ItemRow from '../components/shopping/ItemRow'
|
|
import { useWakeLock } from '../hooks/useWakeLock'
|
|
|
|
type View = 'lists' | 'detail' | 'store'
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: '100%',
|
|
background: 'var(--bg-4)',
|
|
border: '1px solid var(--bg-5)',
|
|
borderRadius: 8,
|
|
padding: '10px 12px',
|
|
color: 'var(--ink-1)',
|
|
fontFamily: 'var(--font-ui)',
|
|
fontSize: 14,
|
|
boxSizing: 'border-box',
|
|
}
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
draft: 'Brouillon',
|
|
active: 'En cours',
|
|
done: 'Terminée',
|
|
}
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
draft: 'var(--ink-3)',
|
|
active: 'var(--ok)',
|
|
done: 'var(--accent)',
|
|
}
|
|
|
|
// Catégories dans l'ordre du rayon
|
|
const CATEGORIES = [
|
|
'Fruits', 'Légumes', 'Viandes', 'Charcuterie', 'Poissons',
|
|
'Produits laitiers', 'Boulangerie', 'Épicerie salée', 'Épicerie sucrée',
|
|
'Condiments', 'Boissons', 'Entretien', 'Pharmacie', 'Animaux', 'Divers',
|
|
]
|
|
|
|
function groupByCategory(items: ShoppingItem[]): Record<string, ShoppingItem[]> {
|
|
const result: Record<string, ShoppingItem[]> = {}
|
|
for (const item of items) {
|
|
// La catégorie vient du produit — on groupe les items custom sous "Divers"
|
|
const cat = 'Divers' // sera enrichi quand le produit est connu
|
|
if (!result[cat]) result[cat] = []
|
|
result[cat].push(item)
|
|
}
|
|
return result
|
|
}
|
|
|
|
export default function ShoppingPage() {
|
|
const [view, setView] = useState<View>('lists')
|
|
const [lists, setLists] = useState<ShoppingList[]>([])
|
|
const [activeList, setActiveList] = useState<ShoppingListDetail | null>(null)
|
|
const [stores, setStores] = useState<Store[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
|
const [showAddItemModal, setShowAddItemModal] = useState(false)
|
|
|
|
// Formulaire création liste
|
|
const [newListName, setNewListName] = useState('')
|
|
const [newListStore, setNewListStore] = useState('')
|
|
|
|
// Formulaire ajout article
|
|
const [newItemName, setNewItemName] = useState('')
|
|
const [newItemQty, setNewItemQty] = useState('')
|
|
const [newItemUnit, setNewItemUnit] = useState('')
|
|
|
|
// Wake Lock activé uniquement en mode magasin
|
|
useWakeLock(view === 'store')
|
|
|
|
const loadLists = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const [listsData, storesData] = await Promise.all([fetchLists(), fetchStores()])
|
|
setLists(listsData)
|
|
setStores(storesData)
|
|
} catch {
|
|
setError('Erreur lors du chargement')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { void loadLists() }, [loadLists])
|
|
|
|
async function openList(list: ShoppingList) {
|
|
try {
|
|
const detail = await fetchListDetail(list.id)
|
|
setActiveList(detail)
|
|
setView('detail')
|
|
} catch {
|
|
setError('Erreur lors du chargement de la liste')
|
|
}
|
|
}
|
|
|
|
async function refreshActiveList() {
|
|
if (!activeList) return
|
|
try {
|
|
setActiveList(await fetchListDetail(activeList.id))
|
|
} catch {
|
|
setError('Erreur lors du rafraîchissement')
|
|
}
|
|
}
|
|
|
|
async function handleCreateList() {
|
|
if (!newListName.trim()) return
|
|
try {
|
|
await createList({
|
|
name: newListName.trim(),
|
|
store_id: newListStore || undefined,
|
|
})
|
|
setNewListName('')
|
|
setNewListStore('')
|
|
setShowCreateModal(false)
|
|
void loadLists()
|
|
} catch {
|
|
setError('Erreur lors de la création')
|
|
}
|
|
}
|
|
|
|
async function handleDeleteList(id: string) {
|
|
try {
|
|
await deleteList(id)
|
|
void loadLists()
|
|
} catch {
|
|
setError('Erreur lors de la suppression')
|
|
}
|
|
}
|
|
|
|
async function handleAddItem() {
|
|
if (!activeList || !newItemName.trim()) return
|
|
try {
|
|
await addItem(activeList.id, {
|
|
custom_name: newItemName.trim(),
|
|
quantity: newItemQty || undefined,
|
|
unit: newItemUnit || undefined,
|
|
})
|
|
setNewItemName('')
|
|
setNewItemQty('')
|
|
setNewItemUnit('')
|
|
setShowAddItemModal(false)
|
|
void refreshActiveList()
|
|
} catch {
|
|
setError("Erreur lors de l'ajout")
|
|
}
|
|
}
|
|
|
|
async function handleCheckItem(itemId: string, checked: boolean) {
|
|
if (!activeList) return
|
|
try {
|
|
await updateItem(activeList.id, itemId, { is_checked: checked })
|
|
void refreshActiveList()
|
|
} catch {
|
|
setError('Erreur lors du cochage')
|
|
}
|
|
}
|
|
|
|
async function handleDeleteItem(itemId: string) {
|
|
if (!activeList) return
|
|
try {
|
|
await deleteItem(activeList.id, itemId)
|
|
void refreshActiveList()
|
|
} catch {
|
|
setError('Erreur lors de la suppression')
|
|
}
|
|
}
|
|
|
|
async function handleFinish() {
|
|
if (!activeList) return
|
|
try {
|
|
await finishShopping(activeList.id)
|
|
setView('lists')
|
|
setActiveList(null)
|
|
void loadLists()
|
|
} catch {
|
|
setError('Erreur lors de la finalisation')
|
|
}
|
|
}
|
|
|
|
// ── Vue mode magasin ──────────────────────────────────────────────────────
|
|
if (view === 'store' && activeList) {
|
|
const unchecked = activeList.items.filter(i => !i.is_checked)
|
|
const checked = activeList.items.filter(i => i.is_checked)
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'fixed', inset: 0, background: 'var(--bg-1)',
|
|
display: 'flex', flexDirection: 'column', zIndex: 100,
|
|
}}>
|
|
{/* Header mode magasin */}
|
|
<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={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>
|
|
|
|
{/* Liste des articles */}
|
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
|
{unchecked.map(item => (
|
|
<ItemRow
|
|
key={item.id}
|
|
item={item}
|
|
onCheck={() => void handleCheckItem(item.id, true)}
|
|
onDelete={() => void handleDeleteItem(item.id)}
|
|
storeMode
|
|
/>
|
|
))}
|
|
{checked.length > 0 && (
|
|
<>
|
|
<div style={{ padding: '8px 16px', color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-ui)', textTransform: 'uppercase', letterSpacing: 1 }}>
|
|
Cochés ({checked.length})
|
|
</div>
|
|
{checked.map(item => (
|
|
<ItemRow
|
|
key={item.id}
|
|
item={item}
|
|
onCheck={() => void handleCheckItem(item.id, false)}
|
|
onDelete={() => void handleDeleteItem(item.id)}
|
|
storeMode
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Vue détail d'une liste ─────────────────────────────────────────────────
|
|
if (view === 'detail' && activeList) {
|
|
return (
|
|
<div className="p-4">
|
|
{/* Header */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
|
<button
|
|
onClick={() => { setView('lists'); void loadLists() }}
|
|
style={{ background: 'transparent', border: 'none', color: 'var(--ink-2)', fontSize: 20, cursor: 'pointer', padding: 4 }}
|
|
>←</button>
|
|
<div style={{ flex: 1 }}>
|
|
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, fontSize: 18 }}>
|
|
{activeList.name ?? 'Liste de courses'}
|
|
</h1>
|
|
<div style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)' }}>
|
|
{activeList.checked_count}/{activeList.item_count} cochés
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setView('store')}
|
|
style={{
|
|
background: 'var(--accent)', color: '#1d2021', border: 'none',
|
|
borderRadius: 8, padding: '8px 14px', fontFamily: 'var(--font-ui)',
|
|
fontWeight: 600, fontSize: 13, cursor: 'pointer', minHeight: 44,
|
|
}}
|
|
>Mode magasin 🛒</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontSize: 13, fontFamily: 'var(--font-ui)' }}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{/* Articles */}
|
|
{activeList.items.length === 0 ? (
|
|
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}>
|
|
Aucun article — ajoutez-en avec le bouton +
|
|
</p>
|
|
) : (
|
|
<div className="glass" style={{ borderRadius: 10, overflow: 'hidden', marginBottom: 80 }}>
|
|
{activeList.items.map(item => (
|
|
<ItemRow
|
|
key={item.id}
|
|
item={item}
|
|
onCheck={() => void handleCheckItem(item.id, !item.is_checked)}
|
|
onDelete={() => void handleDeleteItem(item.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* FAB ajout article */}
|
|
<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>
|
|
|
|
{/* Modal ajout article */}
|
|
{showAddItemModal && (
|
|
<Modal title="Ajouter un article" onClose={() => setShowAddItemModal(false)}>
|
|
<input
|
|
style={inputStyle}
|
|
placeholder="Nom de l'article *"
|
|
value={newItemName}
|
|
onChange={e => setNewItemName(e.target.value)}
|
|
autoFocus
|
|
onKeyDown={e => e.key === 'Enter' && void handleAddItem()}
|
|
/>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
|
<input
|
|
style={inputStyle}
|
|
placeholder="Quantité"
|
|
value={newItemQty}
|
|
onChange={e => setNewItemQty(e.target.value)}
|
|
/>
|
|
<input
|
|
style={inputStyle}
|
|
placeholder="Unité (kg, L…)"
|
|
value={newItemUnit}
|
|
onChange={e => setNewItemUnit(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
<button
|
|
onClick={() => setShowAddItemModal(false)}
|
|
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
|
|
>Annuler</button>
|
|
<button
|
|
onClick={() => void handleAddItem()}
|
|
style={{ padding: '10px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48 }}
|
|
>Ajouter</button>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Vue liste des listes ───────────────────────────────────────────────────
|
|
return (
|
|
<div className="p-4">
|
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
|
|
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1 }}>Courses</h1>
|
|
</div>
|
|
|
|
{error && (
|
|
<p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontSize: 13, fontFamily: 'var(--font-ui)' }}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{loading && <p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24 }}>Chargement…</p>}
|
|
|
|
{!loading && lists.length === 0 && (
|
|
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}>
|
|
Aucune liste — créez-en une avec le bouton +
|
|
</p>
|
|
)}
|
|
|
|
{/* Cartes des listes */}
|
|
<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>
|
|
|
|
{/* FAB */}
|
|
<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>
|
|
|
|
{/* Modal création liste */}
|
|
{showCreateModal && (
|
|
<Modal title="Nouvelle liste de courses" onClose={() => setShowCreateModal(false)}>
|
|
<input
|
|
style={inputStyle}
|
|
placeholder="Nom de la liste (ex: Semaine du 26 mai)"
|
|
value={newListName}
|
|
onChange={e => setNewListName(e.target.value)}
|
|
autoFocus
|
|
onKeyDown={e => e.key === 'Enter' && void handleCreateList()}
|
|
/>
|
|
<select
|
|
style={inputStyle}
|
|
value={newListStore}
|
|
onChange={e => setNewListStore(e.target.value)}
|
|
>
|
|
<option value="">Magasin (optionnel)</option>
|
|
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
</select>
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
<button
|
|
onClick={() => setShowCreateModal(false)}
|
|
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
|
|
>Annuler</button>
|
|
<button
|
|
onClick={() => void handleCreateList()}
|
|
style={{ padding: '10px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48 }}
|
|
>Créer</button>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Rebuild et vérifier**
|
|
|
|
```bash
|
|
docker compose build frontend && docker compose up -d frontend
|
|
```
|
|
|
|
Ouvrir `http://localhost:3001/shopping` et vérifier :
|
|
- La liste des listes s'affiche (vide au premier lancement)
|
|
- Le bouton + ouvre un modal (pas un panneau inline)
|
|
- Créer une liste → elle apparaît dans la liste des cartes
|
|
- Cliquer dessus → vue détail, FAB pour ajouter un article
|
|
- Ajouter un article → modal, puis il apparaît dans la liste
|
|
- Tapper un article → il se coche (fond légèrement vert)
|
|
- Swipe gauche → suppression
|
|
- Bouton "Mode magasin" → vue plein-écran, grands boutons
|
|
- Bouton "Terminer" → retour à la liste des listes, statut "Terminée"
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
rtk git add frontend/src/pages/ShoppingPage.tsx
|
|
rtk git commit -m "feat(shopping): page complète — listes, détail, mode magasin Wake Lock"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10 : Migrer TodoForm vers Modal
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/pages/TodosPage.tsx`
|
|
|
|
Le formulaire de création/édition de todos doit utiliser le composant `Modal` au lieu du panneau inline.
|
|
|
|
- [ ] **Step 1 : Mettre à jour les imports**
|
|
|
|
Dans `frontend/src/pages/TodosPage.tsx`, ajouter l'import Modal :
|
|
|
|
```typescript
|
|
import Modal from '../components/Modal'
|
|
```
|
|
|
|
- [ ] **Step 2 : Remplacer le panneau création par un Modal**
|
|
|
|
Remplacer le bloc `{showForm && ...}` par :
|
|
|
|
```tsx
|
|
{showForm && (
|
|
<Modal title="Nouvelle tâche" onClose={() => setShowForm(false)}>
|
|
<TodoForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} extended />
|
|
</Modal>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 3 : Remplacer le panneau édition par un Modal**
|
|
|
|
Remplacer le bloc `{editingTodo && ...}` par :
|
|
|
|
```tsx
|
|
{editingTodo && (
|
|
<Modal title="Modifier la tâche" onClose={() => setEditingTodo(null)}>
|
|
<TodoForm
|
|
onSubmit={data => handleUpdate(editingTodo.id, data)}
|
|
onCancel={() => setEditingTodo(null)}
|
|
extended
|
|
submitLabel="Enregistrer"
|
|
initialValues={{
|
|
title: editingTodo.title,
|
|
domain: editingTodo.domain ?? undefined,
|
|
priority: editingTodo.priority,
|
|
due_date: editingTodo.due_date ?? undefined,
|
|
body: editingTodo.body ?? undefined,
|
|
url: editingTodo.url ?? undefined,
|
|
tags: editingTodo.tags,
|
|
}}
|
|
/>
|
|
</Modal>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 4 : Supprimer les classes CSS devenues inutiles**
|
|
|
|
Supprimer le `className="hidden lg:block"` / `className="block lg:hidden"` qui distinguait les deux formulaires — le Modal est identique sur toutes tailles d'écran.
|
|
|
|
- [ ] **Step 5 : Rebuild et vérifier**
|
|
|
|
```bash
|
|
docker compose build frontend && docker compose up -d frontend
|
|
```
|
|
|
|
Vérifier sur `http://localhost:3001/todos` :
|
|
- Le bouton + ouvre un modal centré (overlay sombre)
|
|
- Le double-tap/double-clic sur un todo ouvre un modal d'édition pré-rempli
|
|
- La touche Escape ferme le modal
|
|
- Cliquer en dehors du modal le ferme
|
|
|
|
- [ ] **Step 6 : Commit**
|
|
|
|
```bash
|
|
rtk git add frontend/src/pages/TodosPage.tsx
|
|
rtk git commit -m "refactor(todos): formulaires création et édition migrés vers Modal"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11 : Endpoint "Liste magique" (génération automatique)
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/api/shopping.py`
|
|
- Modify: `frontend/src/api/shopping.ts`
|
|
|
|
L'algorithme V1 : score = `(aujourd'hui - last_purchased) / avg_interval_days` calculé depuis l'historique des `list_items` cochés. Les articles avec score ≥ 0.7 sont proposés, triés par score décroissant.
|
|
|
|
- [ ] **Step 1 : Ajouter l'endpoint `POST /api/shopping/lists/generate`**
|
|
|
|
Ajouter dans `backend/app/api/shopping.py`, avant le dernier endpoint, les imports suivants en haut du fichier :
|
|
|
|
```python
|
|
from sqlalchemy import func, text
|
|
from datetime import date, timedelta
|
|
```
|
|
|
|
Puis ajouter l'endpoint (à placer AVANT `@router.post("/lists/{list_id}/finish")`) :
|
|
|
|
```python
|
|
@router.post("/lists/generate", response_model=ShoppingListDetailResponse, status_code=201)
|
|
async def generate_magic_list(session: AsyncSession = Depends(get_session)):
|
|
"""Génère une liste à partir du score de fréquence (retard / intervalle moyen)."""
|
|
|
|
# Calcul du score pour chaque article déjà acheté (is_checked=True)
|
|
# On utilise created_at de la liste parente comme date d'achat
|
|
query = text("""
|
|
WITH achats AS (
|
|
SELECT
|
|
COALESCE(li.custom_name, p.name) AS nom,
|
|
li.product_id,
|
|
li.custom_name,
|
|
p.category,
|
|
p.default_unit,
|
|
sl.created_at::date AS date_achat
|
|
FROM shopping.list_items li
|
|
JOIN shopping.lists sl ON sl.id = li.list_id
|
|
LEFT JOIN shopping.products p ON p.id = li.product_id
|
|
WHERE li.is_checked = true
|
|
),
|
|
stats AS (
|
|
SELECT
|
|
nom,
|
|
product_id,
|
|
custom_name,
|
|
category,
|
|
default_unit,
|
|
MAX(date_achat) AS last_purchased,
|
|
COUNT(*) AS nb_achats,
|
|
AVG(
|
|
date_achat - LAG(date_achat) OVER (PARTITION BY nom ORDER BY date_achat)
|
|
) AS avg_interval_days
|
|
FROM achats
|
|
GROUP BY nom, product_id, custom_name, category, default_unit
|
|
)
|
|
SELECT
|
|
nom,
|
|
product_id,
|
|
custom_name,
|
|
category,
|
|
default_unit,
|
|
last_purchased,
|
|
nb_achats,
|
|
COALESCE(avg_interval_days, 30) AS avg_interval_days,
|
|
(CURRENT_DATE - last_purchased)::float
|
|
/ NULLIF(COALESCE(avg_interval_days, 30), 0) AS score
|
|
FROM stats
|
|
WHERE (CURRENT_DATE - last_purchased)::float
|
|
/ NULLIF(COALESCE(avg_interval_days, 30), 0) >= 0.7
|
|
ORDER BY score DESC
|
|
LIMIT 50
|
|
""")
|
|
|
|
result = await session.execute(query)
|
|
rows = result.mappings().all()
|
|
|
|
# Créer la liste générée
|
|
new_list = ShoppingList(name="Liste magique", status="draft")
|
|
session.add(new_list)
|
|
await session.flush()
|
|
|
|
for row in rows:
|
|
session.add(ListItem(
|
|
list_id=new_list.id,
|
|
product_id=row["product_id"],
|
|
custom_name=row["custom_name"],
|
|
unit=row["default_unit"],
|
|
))
|
|
|
|
await session.commit()
|
|
await session.refresh(new_list, ["items"])
|
|
|
|
stmt = (
|
|
select(ShoppingList)
|
|
.where(ShoppingList.id == new_list.id)
|
|
.options(selectinload(ShoppingList.items).selectinload(ListItem.product))
|
|
)
|
|
result2 = await session.execute(stmt)
|
|
new_list = result2.scalar_one()
|
|
sorted_items = sorted(new_list.items, key=lambda i: (i.sort_order or 999, str(i.id)))
|
|
|
|
return ShoppingListDetailResponse(
|
|
**_list_to_response(new_list).model_dump(),
|
|
items=[_item_to_response(i) for i in sorted_items],
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2 : Ajouter la fonction dans le client TypeScript**
|
|
|
|
Dans `frontend/src/api/shopping.ts`, ajouter après `finishShopping` :
|
|
|
|
```typescript
|
|
export async function generateMagicList(): Promise<ShoppingListDetail> {
|
|
return handleResponse(await fetch(`${BASE}/lists/generate`, { method: 'POST' }))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3 : Tester manuellement**
|
|
|
|
```bash
|
|
curl -s -X POST http://localhost:8000/api/shopping/lists/generate | python3 -m json.tool | head -20
|
|
```
|
|
|
|
Si aucun historique : liste vide créée (normal au premier lancement — le seed ne contient pas d'historique d'achats cochés).
|
|
|
|
- [ ] **Step 4 : Commit**
|
|
|
|
```bash
|
|
rtk git add backend/app/api/shopping.py frontend/src/api/shopping.ts
|
|
rtk git commit -m "feat(shopping): endpoint génération liste magique (score fréquence V1)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12 : UI — Bouton liste magique + Modal édition liste
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/pages/ShoppingPage.tsx`
|
|
|
|
Deux ajouts dans la ShoppingPage :
|
|
|
|
1. **Vue "listes"** : bouton ✨ "Liste magique" dans le header, désactivé si une liste `draft` ou `active` existe
|
|
2. **Vue "détail"** : bouton ✏️ dans le header ouvre un modal avec ajout rapide d'article + bouton rouge "Supprimer la liste"
|
|
|
|
- [ ] **Step 1 : Ajouter l'import et le state**
|
|
|
|
En haut de `ShoppingPage`, ajouter l'import :
|
|
|
|
```typescript
|
|
import { generateMagicList } from '../api/shopping'
|
|
```
|
|
|
|
Dans le composant, ajouter les states :
|
|
|
|
```typescript
|
|
const [showEditListModal, setShowEditListModal] = useState(false)
|
|
const [generating, setGenerating] = useState(false)
|
|
```
|
|
|
|
- [ ] **Step 2 : Handler génération liste magique**
|
|
|
|
Ajouter après `handleFinish` :
|
|
|
|
```typescript
|
|
async function handleGenerateMagicList() {
|
|
setGenerating(true)
|
|
setError(null)
|
|
try {
|
|
const newList = await generateMagicList()
|
|
void loadLists()
|
|
// Ouvrir directement le détail de la liste générée
|
|
setActiveList(newList)
|
|
setView('detail')
|
|
} catch {
|
|
setError('Erreur lors de la génération')
|
|
} finally {
|
|
setGenerating(false)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3 : Handler suppression liste courante**
|
|
|
|
Ajouter après `handleGenerateMagicList` :
|
|
|
|
```typescript
|
|
async function handleDeleteActiveList() {
|
|
if (!activeList) return
|
|
try {
|
|
await deleteList(activeList.id)
|
|
setView('lists')
|
|
setActiveList(null)
|
|
void loadLists()
|
|
} catch {
|
|
setError('Erreur lors de la suppression')
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4 : Bouton liste magique dans la vue "listes"**
|
|
|
|
Dans le header de la vue "listes" (bloc `return` final), remplacer :
|
|
|
|
```tsx
|
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
|
|
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1 }}>Courses</h1>
|
|
</div>
|
|
```
|
|
|
|
par :
|
|
|
|
```tsx
|
|
<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>
|
|
```
|
|
|
|
- [ ] **Step 5 : Bouton édition dans le header de la vue "détail"**
|
|
|
|
Dans le header de la vue "détail" (bloc `if (view === 'detail' && activeList)`), ajouter le bouton ✏️ entre le titre et le bouton "Mode magasin" :
|
|
|
|
```tsx
|
|
<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>
|
|
```
|
|
|
|
- [ ] **Step 6 : Modal édition liste (ajout article + suppression)**
|
|
|
|
Dans la vue "détail", ajouter après le modal d'ajout d'article existant :
|
|
|
|
```tsx
|
|
{showEditListModal && (
|
|
<Modal title="Gérer la liste" onClose={() => setShowEditListModal(false)}>
|
|
{/* Ajout rapide d'article */}
|
|
<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={e => e.key === 'Enter' && void handleAddItem().then(() => 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={() => void handleAddItem().then(() => { 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>
|
|
|
|
{/* Séparateur */}
|
|
<div style={{ borderTop: '1px solid var(--bg-4)', margin: '4px 0' }} />
|
|
|
|
{/* Suppression liste */}
|
|
<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>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 7 : Rebuild et vérifier**
|
|
|
|
```bash
|
|
docker compose build frontend && docker compose up -d frontend
|
|
```
|
|
|
|
Vérifier :
|
|
- Vue "Courses" : bouton "Liste magique" visible dans le header
|
|
- Si aucune liste en cours → bouton actif (orange), clic crée une liste et ouvre le détail
|
|
- Si liste `draft`/`active` existe → bouton grisé, non cliquable
|
|
- Vue "détail" : icône ✏️ dans le header ouvre un modal avec champ d'ajout + bouton rouge suppression
|
|
- Bouton rouge "Supprimer la liste en cours" → supprime et retourne à la liste des listes
|
|
|
|
- [ ] **Step 8 : Commit final**
|
|
|
|
```bash
|
|
rtk git add frontend/src/pages/ShoppingPage.tsx
|
|
rtk git commit -m "feat(shopping): bouton liste magique + modal édition liste avec suppression"
|
|
rtk git tag v0.3.0-phase3
|
|
```
|
|
|
|
---
|
|
|
|
## Auto-review
|
|
|
|
**Spec coverage :**
|
|
- ✅ CRUD listes (create, list, detail, update status, delete)
|
|
- ✅ Ajout/suppression/cochage d'articles
|
|
- ✅ Report des articles non cochés à la fin des courses (`finish`)
|
|
- ✅ Mode magasin plein-écran avec Wake Lock
|
|
- ✅ Modal pour tous les formulaires (plus de panneau inline)
|
|
- ✅ Seed produits et magasins déjà en place
|
|
- ✅ Migration TodoForm → Modal
|
|
- ✅ Bouton "liste magique" (score fréquence V1, désactivé si liste en cours)
|
|
- ✅ Modal édition liste (ajout article + suppression liste)
|
|
- ❌ Hors scope Phase 3 : prix OCR, corrélations, saisonnalité, catalogue CRUD laptop
|
|
|
|
**Type consistency :** `ShoppingListDetail` étend `ShoppingList` avec `items: ShoppingItem[]`. `generateMagicList()` retourne `ShoppingListDetail`. `_list_to_response()` et `_item_to_response()` sont les seuls points de conversion ORM→schema.
|
|
|
|
**Placeholder scan :** aucun TBD — toutes les erreurs catchées avec setError().
|