feat(shopping): listes projet + déduplication nommage hebdo

Backend :
- Migration 007 : list_type VARCHAR(20) sur shopping.lists (weekly/project),
  url/description/image_url sur shopping.list_items
- Modèle ShoppingList : champ list_type
- Modèle ListItem : champs url, description, image_url
- Schémas : list_type sur Create/Response, nouveaux champs sur ItemCreate/Update/Response
- _unique_week_label() : évite les doublons S22 2026 → S22 2026 (2)
- finish_shopping : carry-over uniquement pour list_type='weekly'

Frontend :
- api/shopping.ts : list_type, champs enrichis item, createProjectList()
- ProjectItemCard.tsx : carte avec image, description, URL, boutique, cochage
- ShoppingPage :
  · Séparation weekly / project dans la sélection de liste active
  · Section "Listes projet" sur l'écran vide avec navigation
  · Badge PROJET dans l'en-tête
  · Bouton "Clôturer la semaine" et badge "semaine dépassée" masqués sur projet
  · Bouton "+ Ajouter" (mobile + laptop) sur les listes projet
  · Vue grille ProjectItemCard pour les listes projet
  · Modale création liste projet (nom + boutique)
  · Modale ajout/édition item projet (nom, description, URL, image URL)

v0.5.14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 09:59:53 +02:00
parent 031708ad8f
commit 7bf6caa3dd
8 changed files with 502 additions and 35 deletions
+27 -5
View File
@@ -27,6 +27,19 @@ def _iso_week_label() -> str:
return f"S{iso[1]} {iso[0]}"
async def _unique_week_label(session: AsyncSession) -> str:
base = _iso_week_label()
existing = (await session.execute(
select(ShoppingList.name).where(ShoppingList.name.like(f"{base}%"))
)).scalars().all()
if base not in existing:
return base
counter = 2
while f"{base} ({counter})" in existing:
counter += 1
return f"{base} ({counter})"
def _item_to_response(item: ListItem) -> ListItemResponse:
display_name = item.custom_name or (item.product.name if item.product else "Article inconnu")
return ListItemResponse(
@@ -40,6 +53,9 @@ def _item_to_response(item: ListItem) -> ListItemResponse:
price_recorded=item.price_recorded,
carried_over=item.carried_over,
sort_order=item.sort_order,
url=item.url,
description=item.description,
image_url=item.image_url,
)
@@ -48,6 +64,7 @@ def _list_to_response(lst: ShoppingList) -> ShoppingListResponse:
return ShoppingListResponse(
id=lst.id,
name=lst.name,
list_type=lst.list_type,
store_id=lst.store_id,
week_date=lst.week_date,
status=lst.status,
@@ -167,8 +184,8 @@ async def create_shopping_list(
session: AsyncSession = Depends(get_session),
):
data = payload.model_dump()
if not data.get('name'):
data['name'] = _iso_week_label()
if not data.get('name') and data.get('list_type', 'weekly') == 'weekly':
data['name'] = await _unique_week_label(session)
lst = ShoppingList(**data)
session.add(lst)
await session.commit()
@@ -380,7 +397,7 @@ async def generate_magic_list(session: AsyncSession = Depends(get_session)):
result = await session.execute(query)
rows = result.mappings().all()
new_list = ShoppingList(name=_iso_week_label(), status="draft")
new_list = ShoppingList(name=await _unique_week_label(session), list_type="weekly", status="draft")
session.add(new_list)
await session.flush()
@@ -425,8 +442,13 @@ async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(ge
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", name=_iso_week_label())
if unchecked and lst.list_type == 'weekly':
new_list = ShoppingList(
store_id=lst.store_id,
list_type="weekly",
status="draft",
name=await _unique_week_label(session),
)
session.add(new_list)
await session.flush()
for item in unchecked: