From 7bf6caa3ddda250292322648e559799c2ca6a540 Mon Sep 17 00:00:00 2001
From: Gilles Soulier
Date: Sat, 30 May 2026 09:59:53 +0200
Subject: [PATCH] =?UTF-8?q?feat(shopping):=20listes=20projet=20+=20d=C3=A9?=
=?UTF-8?q?duplication=20nommage=20hebdo?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
..._shopping_list_type_and_item_enrichment.py | 32 ++
backend/app/api/shopping.py | 32 +-
backend/app/models/shopping.py | 4 +
backend/app/schemas/shopping.py | 18 +
frontend/package.json | 2 +-
frontend/src/api/shopping.ts | 15 +
.../components/shopping/ProjectItemCard.tsx | 125 +++++++
frontend/src/pages/ShoppingPage.tsx | 309 ++++++++++++++++--
8 files changed, 502 insertions(+), 35 deletions(-)
create mode 100644 backend/alembic/versions/007_shopping_list_type_and_item_enrichment.py
create mode 100644 frontend/src/components/shopping/ProjectItemCard.tsx
diff --git a/backend/alembic/versions/007_shopping_list_type_and_item_enrichment.py b/backend/alembic/versions/007_shopping_list_type_and_item_enrichment.py
new file mode 100644
index 0000000..c843130
--- /dev/null
+++ b/backend/alembic/versions/007_shopping_list_type_and_item_enrichment.py
@@ -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')
diff --git a/backend/app/api/shopping.py b/backend/app/api/shopping.py
index 919c3b5..a7302ad 100644
--- a/backend/app/api/shopping.py
+++ b/backend/app/api/shopping.py
@@ -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:
diff --git a/backend/app/models/shopping.py b/backend/app/models/shopping.py
index b91ea69..220c573 100644
--- a/backend/app/models/shopping.py
+++ b/backend/app/models/shopping.py
@@ -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")
diff --git a/backend/app/schemas/shopping.py b/backend/app/schemas/shopping.py
index 73c5a79..8d3a526 100644
--- a/backend/app/schemas/shopping.py
+++ b/backend/app/schemas/shopping.py
@@ -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
diff --git a/frontend/package.json b/frontend/package.json
index aee9085..d4869ed 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "homehub-frontend",
"private": true,
- "version": "0.5.13",
+ "version": "0.5.14",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/api/shopping.ts b/frontend/src/api/shopping.ts
index ac003f9..3542c16 100644
--- a/frontend/src/api/shopping.ts
+++ b/frontend/src/api/shopping.ts
@@ -82,11 +82,15 @@ export interface ShoppingItem {
price_recorded: string | null
carried_over: boolean
sort_order: number | null
+ url: string | null
+ description: string | null
+ image_url: string | null
}
export interface ShoppingList {
id: string
name: string | null
+ list_type: 'weekly' | 'project'
store_id: string | null
week_date: string | null
status: 'draft' | 'active' | 'done'
@@ -101,6 +105,7 @@ export interface ShoppingListDetail extends ShoppingList {
export interface ShoppingListCreate {
name?: string
+ list_type?: 'weekly' | 'project'
store_id?: string
week_date?: string
}
@@ -116,6 +121,9 @@ export interface ShoppingItemCreate {
custom_name?: string
quantity?: string
unit?: string
+ url?: string
+ description?: string
+ image_url?: string
}
export interface ShoppingItemUpdate {
@@ -123,6 +131,9 @@ export interface ShoppingItemUpdate {
quantity?: string
unit?: string
price_recorded?: string
+ url?: string
+ description?: string
+ image_url?: string
}
const BASE = '/api/shopping'
@@ -201,6 +212,10 @@ export async function createList(data: ShoppingListCreate): Promise {
+ return createList({ name, list_type: 'project', store_id: storeId })
+}
+
export async function fetchListDetail(id: string): Promise {
return handleResponse(await fetch(`${BASE}/lists/${id}`))
}
diff --git a/frontend/src/components/shopping/ProjectItemCard.tsx b/frontend/src/components/shopping/ProjectItemCard.tsx
new file mode 100644
index 0000000..456d018
--- /dev/null
+++ b/frontend/src/components/shopping/ProjectItemCard.tsx
@@ -0,0 +1,125 @@
+import { useState } from 'react'
+import type { ShoppingItem, Store } from '../../api/shopping'
+
+interface ProjectItemCardProps {
+ item: ShoppingItem
+ stores: Store[]
+ onCheck: () => void
+ onDelete: () => void
+ onEdit: () => void
+}
+
+export default function ProjectItemCard({ item, stores, onCheck, onDelete, onEdit }: ProjectItemCardProps) {
+ const [imgError, setImgError] = useState(false)
+ const store = stores.find(s => s.id === item.product_id)
+
+ return (
+
+ {/* Image */}
+ {item.image_url && !imgError && (
+
+

setImgError(true)}
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
+ />
+
+ )}
+
+
+ {/* Nom + actions */}
+
+
+
+
+
+ {item.display_name}
+
+
+
+
+
+
+
+
+
+ {/* Description */}
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+ {/* Meta : boutique + lien */}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/ShoppingPage.tsx b/frontend/src/pages/ShoppingPage.tsx
index 36b7c6e..7ce79e3 100644
--- a/frontend/src/pages/ShoppingPage.tsx
+++ b/frontend/src/pages/ShoppingPage.tsx
@@ -4,13 +4,14 @@ import { useServerEvents } from '../hooks/useServerEvents'
import { matchesSearch } from '../utils/search'
import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } from '../api/shopping'
import {
- fetchLists, createList, fetchListDetail, deleteList,
+ fetchLists, createList, createProjectList, fetchListDetail, deleteList,
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
searchProducts, createProduct,
} from '../api/shopping'
import Modal from '../components/Modal'
import BottomSheet from '../components/BottomSheet'
import ItemRow from '../components/shopping/ItemRow'
+import ProjectItemCard from '../components/shopping/ProjectItemCard'
import CatalogueModal from '../components/shopping/CatalogueModal'
import BoutiquesModal from '../components/shopping/BoutiquesModal'
import { useWakeLock } from '../hooks/useWakeLock'
@@ -132,7 +133,8 @@ export default function ShoppingPage() {
setStores(storesData)
setProducts([...productsData].sort((a, b) => a.name.localeCompare(b.name, 'fr')))
- const current = listsData.find(l => l.status === 'draft' || l.status === 'active')
+ const current = listsData.find(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'weekly')
+ ?? listsData.find(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'project')
if (current) {
setCurrentList(await fetchListDetail(current.id))
} else {
@@ -353,6 +355,81 @@ export default function ShoppingPage() {
}
const [showFinishConfirm, setShowFinishConfirm] = useState(false)
+ const [showNewProjectModal, setShowNewProjectModal] = useState(false)
+ const [newProjectName, setNewProjectName] = useState('')
+ const [newProjectStoreId, setNewProjectStoreId] = useState('')
+ const [projectCreating, setProjectCreating] = useState(false)
+ const [showProjectItemModal, setShowProjectItemModal] = useState(false)
+ const [editingProjectItem, setEditingProjectItem] = useState(null)
+ const [projItemName, setProjItemName] = useState('')
+ const [projItemDesc, setProjItemDesc] = useState('')
+ const [projItemUrl, setProjItemUrl] = useState('')
+ const [projItemImageUrl, setProjItemImageUrl] = useState('')
+ const [projItemStoreId, setProjItemStoreId] = useState('')
+ const [projItemSaving, setProjItemSaving] = useState(false)
+
+ async function handleCreateProject() {
+ if (!newProjectName.trim()) return
+ setProjectCreating(true)
+ try {
+ const detail = await createProjectList(newProjectName.trim(), newProjectStoreId || undefined)
+ setCurrentList(detail)
+ setShowNewProjectModal(false)
+ setNewProjectName('')
+ setNewProjectStoreId('')
+ void loadData()
+ } catch {
+ setError('Erreur lors de la création')
+ } finally {
+ setProjectCreating(false)
+ }
+ }
+
+ function openProjectItemModal(item?: ShoppingItem) {
+ if (item) {
+ setEditingProjectItem(item)
+ setProjItemName(item.display_name)
+ setProjItemDesc(item.description ?? '')
+ setProjItemUrl(item.url ?? '')
+ setProjItemImageUrl(item.image_url ?? '')
+ setProjItemStoreId('')
+ } else {
+ setEditingProjectItem(null)
+ setProjItemName('')
+ setProjItemDesc('')
+ setProjItemUrl('')
+ setProjItemImageUrl('')
+ setProjItemStoreId('')
+ }
+ setShowProjectItemModal(true)
+ }
+
+ async function handleSaveProjectItem() {
+ if (!currentList || !projItemName.trim()) return
+ setProjItemSaving(true)
+ try {
+ if (editingProjectItem) {
+ await updateItem(currentList.id, editingProjectItem.id, {
+ url: projItemUrl.trim() || undefined,
+ description: projItemDesc.trim() || undefined,
+ image_url: projItemImageUrl.trim() || undefined,
+ })
+ } else {
+ await addItem(currentList.id, {
+ custom_name: projItemName.trim(),
+ description: projItemDesc.trim() || undefined,
+ url: projItemUrl.trim() || undefined,
+ image_url: projItemImageUrl.trim() || undefined,
+ })
+ }
+ setShowProjectItemModal(false)
+ void refreshCurrentList()
+ } catch {
+ setError("Erreur lors de l'enregistrement")
+ } finally {
+ setProjItemSaving(false)
+ }
+ }
async function handleFinish() {
if (!currentList) return
@@ -397,7 +474,9 @@ export default function ShoppingPage() {
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')
+ const isProjectList = currentList?.list_type === 'project'
+ const pastLists = allLists.filter(l => l.status === 'done' && l.list_type === 'weekly')
+ const activeProjectLists = allLists.filter(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'project')
const filteredProducts = products.filter(p => {
const term = itemSearch.trim()
@@ -418,8 +497,13 @@ export default function ShoppingPage() {
borderBottom: '1px solid var(--bg-4)',
position: 'sticky', top: 0, zIndex: 10,
}}>
-
+
{hasCurrentList ? (currentList.name ?? 'Courses') : 'Courses'}
+ {isProjectList && (
+
+ PROJET
+
+ )}
- {hasCurrentList && (
+ {hasCurrentList && !isProjectList && (
Article
)}
+ {hasCurrentList && isProjectList && (
+
+ )}
{/* ── Erreur ── */}
@@ -520,6 +619,38 @@ export default function ShoppingPage() {
}}
>Voir l'historique ({pastLists.length})
)}
+
+ {/* Séparateur + listes projet */}
+
+
+
+
+ Listes projet
+
+
+
+ {activeProjectLists.length === 0 && (
+
Aucun projet en cours
+ )}
+ {activeProjectLists.map(list => (
+
void fetchListDetail(list.id).then(d => { setCurrentList(d) })}
+ className="glass interactive"
+ style={{ borderRadius: 8, padding: '10px 14px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}
+ >
+
+
+
{list.name}
+
{list.item_count} article{list.item_count > 1 ? 's' : ''}
+
+
→
+
+ ))}
+
)}
@@ -537,7 +668,7 @@ export default function ShoppingPage() {
{checkedItems.length}/{currentList.item_count} cochés
- {isListOutdated(currentList.name) && (
+ {!isProjectList && isListOutdated(currentList.name) && (
void handleDeleteCurrentList()}
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 8px', ...noSelect }}
>Supprimer
-
+ {!isProjectList && (
+
+ )}
+ {isProjectList && (
+
+ )}
{/* Articles non cochés */}
@@ -584,17 +732,32 @@ export default function ShoppingPage() {
)}
-
- {uncheckedItems.map(item => (
-
void handleCheckItem(item.id, true)}
- onDelete={() => void handleDeleteItem(item.id)}
- onEdit={() => openEditItem(item)}
- storeMode
- />
- ))}
+
+ {isProjectList ? (
+
+ {sortedItems.map(item => (
+
void handleCheckItem(item.id, !item.is_checked)}
+ onDelete={() => void handleDeleteItem(item.id)}
+ onEdit={() => openProjectItemModal(item)}
+ />
+ ))}
+
+ ) : (
+ uncheckedItems.map(item => (
+
void handleCheckItem(item.id, true)}
+ onDelete={() => void handleDeleteItem(item.id)}
+ onEdit={() => openEditItem(item)}
+ storeMode
+ />
+ ))
+ )}
{checkedItems.length > 0 && (
<>
@@ -886,6 +1049,94 @@ export default function ShoppingPage() {
)}
+
+ {/* Modale création liste projet */}
+ {showNewProjectModal && (
+ setShowNewProjectModal(false)} width={420}>
+
+
setNewProjectName(e.target.value)}
+ autoFocus
+ onKeyDown={e => e.key === 'Enter' && void handleCreateProject()}
+ />
+
+
+
+
+
+
+
+ )}
+
+ {/* Modale ajout/édition item projet */}
+ {showProjectItemModal && (
+ setShowProjectItemModal(false)}
+ width={480}
+ >
+
+
+ )}
)
}