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
@@ -0,0 +1,32 @@
"""007 - list_type sur shopping.lists, url/description/image_url sur list_items
Revision ID: 007
Revises: 006
Create Date: 2026-05-30
"""
from alembic import op
import sqlalchemy as sa
revision = '007'
down_revision = '006'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'lists',
sa.Column('list_type', sa.String(20), nullable=False, server_default='weekly'),
schema='shopping',
)
op.add_column('list_items', sa.Column('url', sa.Text, nullable=True), schema='shopping')
op.add_column('list_items', sa.Column('description', sa.Text, nullable=True), schema='shopping')
op.add_column('list_items', sa.Column('image_url', sa.String(255), nullable=True), schema='shopping')
def downgrade():
op.drop_column('list_items', 'image_url', schema='shopping')
op.drop_column('list_items', 'description', schema='shopping')
op.drop_column('list_items', 'url', schema='shopping')
op.drop_column('lists', 'list_type', schema='shopping')
+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:
+4
View File
@@ -63,6 +63,7 @@ class ShoppingList(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str | None] = mapped_column(String(100))
list_type: Mapped[str] = mapped_column(String(20), server_default="weekly")
store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL"))
week_date: Mapped[date | None] = mapped_column(Date)
status: Mapped[str] = mapped_column(String(20), server_default="draft")
@@ -86,6 +87,9 @@ class ListItem(Base):
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)
url: Mapped[str | None] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text)
image_url: Mapped[str | None] = mapped_column(String(255))
shopping_list: Mapped["ShoppingList"] = relationship("ShoppingList", back_populates="items")
product: Mapped["Product | None"] = relationship("Product", lazy="select")
+18
View File
@@ -83,6 +83,9 @@ class ListItemCreate(BaseModel):
custom_name: str | None = None
quantity: Decimal | None = None
unit: str | None = None
url: str | None = None
description: str | None = None
image_url: str | None = None
@model_validator(mode='after')
def must_have_name(self) -> 'ListItemCreate':
@@ -96,6 +99,9 @@ class ListItemUpdate(BaseModel):
quantity: Decimal | None = None
unit: str | None = None
price_recorded: Decimal | None = None
url: str | None = None
description: str | None = None
image_url: str | None = None
class ListItemResponse(BaseModel):
@@ -110,13 +116,23 @@ class ListItemResponse(BaseModel):
price_recorded: Decimal | None
carried_over: bool
sort_order: int | None
url: str | None
description: str | None
image_url: str | None
class ShoppingListCreate(BaseModel):
name: str | None = None
list_type: Literal['weekly', 'project'] = 'weekly'
store_id: uuid.UUID | None = None
week_date: date | None = None
@model_validator(mode='after')
def project_requires_name(self) -> 'ShoppingListCreate':
if self.list_type == 'project' and not self.name:
raise ValueError('Une liste projet doit avoir un nom')
return self
class ShoppingListUpdate(BaseModel):
name: str | None = None
@@ -128,6 +144,7 @@ class ShoppingListResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str | None
list_type: str
store_id: uuid.UUID | None
week_date: date | None
status: str
@@ -140,6 +157,7 @@ class ShoppingListDetailResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str | None
list_type: str
store_id: uuid.UUID | None
week_date: date | None
status: str