From aa9ac2a6ea76e850ceeb27782f306c409f6d1bd9 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Mon, 25 May 2026 12:57:25 +0200 Subject: [PATCH] feat(shopping): tags sur les articles du catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 006 : colonne tags TEXT[] sur shopping.products - Modèle SQLAlchemy + schémas Pydantic mis à jour (ProductCreate/Update/Response) - Interface TypeScript Product/ProductCreate/ProductUpdate avec tags?: string[] - CatalogueModal : chip input (Entrée/virgule pour ajouter, clic pour supprimer, Backspace pour retirer le dernier) - Recherche dans le catalogue et le bottom sheet étendue aux tags (insensible aux accents) - Tags affichés en pills dans la liste du catalogue v0.4.12 Co-Authored-By: Claude Sonnet 4.6 --- backend/alembic/versions/006_product_tags.py | 26 ++++++ backend/app/models/shopping.py | 3 +- backend/app/schemas/shopping.py | 3 + frontend/package.json | 2 +- frontend/src/api/shopping.ts | 3 + .../components/shopping/CatalogueModal.tsx | 88 +++++++++++++++++-- frontend/src/pages/ShoppingPage.tsx | 6 +- 7 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 backend/alembic/versions/006_product_tags.py diff --git a/backend/alembic/versions/006_product_tags.py b/backend/alembic/versions/006_product_tags.py new file mode 100644 index 0000000..ce05be2 --- /dev/null +++ b/backend/alembic/versions/006_product_tags.py @@ -0,0 +1,26 @@ +"""Ajout colonne tags (TEXT[]) sur shopping.products + +Revision ID: 006 +Revises: 005 +Create Date: 2026-05-25 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import ARRAY + +revision = '006' +down_revision = '005' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'products', + sa.Column('tags', ARRAY(sa.Text()), nullable=True), + schema='shopping', + ) + + +def downgrade(): + op.drop_column('products', 'tags', schema='shopping') diff --git a/backend/app/models/shopping.py b/backend/app/models/shopping.py index 8e8de75..b91ea69 100644 --- a/backend/app/models/shopping.py +++ b/backend/app/models/shopping.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, date from decimal import Decimal from sqlalchemy import String, Text, Integer, TIMESTAMP, Date, Numeric, Boolean, ForeignKey, text -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import UUID, ARRAY from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base @@ -26,6 +26,7 @@ class Product(Base): frequency_score: Mapped[int] = mapped_column(Integer, server_default=text("0")) last_purchased_at: Mapped[date | None] = mapped_column(Date) avg_interval_days: Mapped[Decimal | None] = mapped_column(Numeric(8, 1)) + tags: Mapped[list[str] | None] = mapped_column(ARRAY(Text()), nullable=True) owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()")) diff --git a/backend/app/schemas/shopping.py b/backend/app/schemas/shopping.py index c252ee4..73c5a79 100644 --- a/backend/app/schemas/shopping.py +++ b/backend/app/schemas/shopping.py @@ -40,6 +40,7 @@ class ProductCreate(BaseModel): default_store_id: uuid.UUID | None = None image_path: str | None = None thumbnail_path: str | None = None + tags: list[str] | None = None class ProductUpdate(BaseModel): @@ -54,6 +55,7 @@ class ProductUpdate(BaseModel): default_store_id: uuid.UUID | None = None image_path: str | None = None thumbnail_path: str | None = None + tags: list[str] | None = None class ProductResponse(BaseModel): @@ -73,6 +75,7 @@ class ProductResponse(BaseModel): avg_interval_days: Decimal | None image_path: str | None thumbnail_path: str | None + tags: list[str] | None class ListItemCreate(BaseModel): diff --git a/frontend/package.json b/frontend/package.json index c2be4d3..5d83e9c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "homehub-frontend", "private": true, - "version": "0.4.11", + "version": "0.4.12", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/shopping.ts b/frontend/src/api/shopping.ts index b41c73b..ac003f9 100644 --- a/frontend/src/api/shopping.ts +++ b/frontend/src/api/shopping.ts @@ -38,6 +38,7 @@ export interface Product { avg_interval_days: string | null image_path: string | null thumbnail_path: string | null + tags: string[] | null } export interface ProductCreate { @@ -52,6 +53,7 @@ export interface ProductCreate { default_store_id?: string image_path?: string thumbnail_path?: string + tags?: string[] } export interface ProductUpdate { @@ -66,6 +68,7 @@ export interface ProductUpdate { default_store_id?: string image_path?: string thumbnail_path?: string + tags?: string[] } export interface ShoppingItem { diff --git a/frontend/src/components/shopping/CatalogueModal.tsx b/frontend/src/components/shopping/CatalogueModal.tsx index 1a8cbd4..e9e30da 100644 --- a/frontend/src/components/shopping/CatalogueModal.tsx +++ b/frontend/src/components/shopping/CatalogueModal.tsx @@ -29,6 +29,7 @@ const emptyForm: ProductCreate = { default_store_id: undefined, image_path: undefined, thumbnail_path: undefined, + tags: [], } export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) { @@ -37,6 +38,7 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form, setForm] = useState(emptyForm) + const [tagInput, setTagInput] = useState('') const [saving, setSaving] = useState(false) const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) @@ -54,12 +56,18 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) } } - const filteredProducts = products.filter(p => - !search.trim() || matchesSearch(p.name, search) || matchesSearch(p.brand ?? '', search) || matchesSearch(p.category ?? '', search) - ) + const filteredProducts = products.filter(p => { + if (!search.trim()) return true + if (matchesSearch(p.name, search)) return true + if (matchesSearch(p.brand ?? '', search)) return true + if (matchesSearch(p.category ?? '', search)) return true + if (p.tags?.some(t => matchesSearch(t, search))) return true + return false + }) function startCreate() { setForm(emptyForm) + setTagInput('') setEditing(null) setCreating(true) setError(null) @@ -78,7 +86,9 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) default_store_id: p.default_store_id ?? undefined, image_path: p.image_path ?? undefined, thumbnail_path: p.thumbnail_path ?? undefined, + tags: p.tags ?? [], }) + setTagInput('') setEditing(p) setCreating(false) setError(null) @@ -87,9 +97,34 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) function cancelForm() { setEditing(null) setCreating(false) + setTagInput('') setError(null) } + function addTag() { + const raw = tagInput.trim().toLowerCase() + if (!raw) return + const tags = form.tags ?? [] + if (!tags.includes(raw)) { + setForm(f => ({ ...f, tags: [...(f.tags ?? []), raw] })) + } + setTagInput('') + } + + function removeTag(tag: string) { + setForm(f => ({ ...f, tags: (f.tags ?? []).filter(t => t !== tag) })) + } + + function handleTagKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + addTag() + } else if (e.key === 'Backspace' && !tagInput && (form.tags ?? []).length > 0) { + const tags = form.tags ?? [] + setForm(f => ({ ...f, tags: tags.slice(0, -1) })) + } + } + function capitalize(s: string): string { const t = s.trim() return t.charAt(0).toUpperCase() + t.slice(1) @@ -108,6 +143,7 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) default_store_id: f.default_store_id || undefined, image_path: f.image_path || undefined, thumbnail_path: f.thumbnail_path || undefined, + tags: (f.tags ?? []).length > 0 ? f.tags : undefined, } } @@ -264,6 +300,41 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) {stores.map(s => )} + + {/* Champ tags */} +
+ {(form.tags ?? []).map(tag => ( + + {tag} + + + ))} + setTagInput(e.target.value)} + onKeyDown={handleTagKeyDown} + onBlur={addTag} + /> +
+
-
+
{p.category && {p.category}} {p.default_unit && {p.default_unit}} {p.price && {p.price} €} @@ -333,6 +404,13 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) {p.avg_interval_days && ` · ~${Math.round(Number(p.avg_interval_days))}j`} )} + {p.tags && p.tags.length > 0 && p.tags.map(tag => ( + {tag} + ))}