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 && ( +
+ {item.display_name} setImgError(true)} + style={{ width: '100%', height: '100%', objectFit: 'cover' }} + /> +
+ )} + +
+ {/* Nom + actions */} +
+ + +
+
+ {item.display_name} +
+
+ +
+ + +
+
+ + {/* Description */} + {item.description && ( +

+ {item.description} +

+ )} + + {/* Meta : boutique + lien */} +
+ {store && ( + + + {store.name} + + )} + {item.url && ( + e.stopPropagation()} + style={{ + display: 'inline-flex', alignItems: 'center', gap: 5, + color: 'var(--info)', fontFamily: 'var(--font-ui)', fontSize: 12, + textDecoration: 'none', + }} + > + + Voir le produit + + )} + {item.quantity && ( + + {item.quantity}{item.unit ? ` ${item.unit}` : ''} + + )} +
+
+
+ ) +} 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 && ( )} + {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} + > +
+ {!editingProjectItem && ( + setProjItemName(e.target.value)} + autoFocus + /> + )} +