feat(shopping): tags sur les articles du catalogue

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:57:25 +02:00
parent 8e878e2e5a
commit aa9ac2a6ea
7 changed files with 123 additions and 8 deletions
@@ -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')
+2 -1
View File
@@ -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()"))
+3
View File
@@ -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):
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "homehub-frontend",
"private": true,
"version": "0.4.11",
"version": "0.4.12",
"type": "module",
"scripts": {
"dev": "vite",
+3
View File
@@ -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 {
@@ -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<Product | null>(null)
const [creating, setCreating] = useState(false)
const [form, setForm] = useState<ProductCreate>(emptyForm)
const [tagInput, setTagInput] = useState('')
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(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<HTMLInputElement>) {
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 => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
{/* Champ tags */}
<div style={{
background: 'var(--bg-4)', border: '1px solid var(--bg-5)', borderRadius: 8,
padding: '6px 10px', display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center',
minHeight: 40,
}}>
{(form.tags ?? []).map(tag => (
<span key={tag} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'var(--bg-5)', color: 'var(--ink-2)',
borderRadius: 999, padding: '2px 8px',
fontFamily: 'var(--font-ui)', fontSize: 12,
}}>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
style={{ background: 'none', border: 'none', color: 'var(--ink-3)', cursor: 'pointer', padding: 0, fontSize: 11, lineHeight: 1 }}
></button>
</span>
))}
<input
style={{
flex: 1, minWidth: 80, background: 'none', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 13,
}}
placeholder={(form.tags ?? []).length === 0 ? 'Tags (Entrée pour ajouter)' : ''}
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
onBlur={addTag}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={cancelForm}
@@ -282,7 +353,7 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
<div style={{ display: 'flex', gap: 8 }}>
<input
style={{ ...inputStyle, flex: 1 }}
placeholder="Rechercher un article…"
placeholder="Rechercher un article ou tag…"
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
@@ -323,7 +394,7 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
{p.name}
{p.brand && <span style={{ color: 'var(--ink-3)', fontSize: 12, marginLeft: 6 }}>{p.brand}</span>}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, marginTop: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{p.category && <span style={{ color: 'var(--info)', fontSize: 11, fontFamily: 'var(--font-ui)' }}>{p.category}</span>}
{p.default_unit && <span style={{ color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>{p.default_unit}</span>}
{p.price && <span style={{ color: 'var(--ok)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>{p.price} </span>}
@@ -333,6 +404,13 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
{p.avg_interval_days && ` · ~${Math.round(Number(p.avg_interval_days))}j`}
</span>
)}
{p.tags && p.tags.length > 0 && p.tags.map(tag => (
<span key={tag} style={{
background: 'var(--bg-5)', color: 'var(--ink-3)',
borderRadius: 999, padding: '1px 6px',
fontFamily: 'var(--font-ui)', fontSize: 10,
}}>{tag}</span>
))}
</div>
</div>
<button
+5 -1
View File
@@ -378,7 +378,11 @@ export default function ShoppingPage() {
const filteredProducts = products.filter(p => {
const term = itemSearch.trim()
return !term || matchesSearch(p.name, term) || matchesSearch(p.brand ?? '', term)
if (!term) return true
if (matchesSearch(p.name, term)) return true
if (matchesSearch(p.brand ?? '', term)) return true
if (p.tags?.some(t => matchesSearch(t, term))) return true
return false
})
return (