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
co-authored by Claude Sonnet 4.6
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 datetime import datetime, date
from decimal import Decimal from decimal import Decimal
from sqlalchemy import String, Text, Integer, TIMESTAMP, Date, Numeric, Boolean, ForeignKey, text 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -26,6 +26,7 @@ class Product(Base):
frequency_score: Mapped[int] = mapped_column(Integer, server_default=text("0")) frequency_score: Mapped[int] = mapped_column(Integer, server_default=text("0"))
last_purchased_at: Mapped[date | None] = mapped_column(Date) last_purchased_at: Mapped[date | None] = mapped_column(Date)
avg_interval_days: Mapped[Decimal | None] = mapped_column(Numeric(8, 1)) 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)) 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()")) 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 default_store_id: uuid.UUID | None = None
image_path: str | None = None image_path: str | None = None
thumbnail_path: str | None = None thumbnail_path: str | None = None
tags: list[str] | None = None
class ProductUpdate(BaseModel): class ProductUpdate(BaseModel):
@@ -54,6 +55,7 @@ class ProductUpdate(BaseModel):
default_store_id: uuid.UUID | None = None default_store_id: uuid.UUID | None = None
image_path: str | None = None image_path: str | None = None
thumbnail_path: str | None = None thumbnail_path: str | None = None
tags: list[str] | None = None
class ProductResponse(BaseModel): class ProductResponse(BaseModel):
@@ -73,6 +75,7 @@ class ProductResponse(BaseModel):
avg_interval_days: Decimal | None avg_interval_days: Decimal | None
image_path: str | None image_path: str | None
thumbnail_path: str | None thumbnail_path: str | None
tags: list[str] | None
class ListItemCreate(BaseModel): class ListItemCreate(BaseModel):
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "homehub-frontend", "name": "homehub-frontend",
"private": true, "private": true,
"version": "0.4.11", "version": "0.4.12",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+3
View File
@@ -38,6 +38,7 @@ export interface Product {
avg_interval_days: string | null avg_interval_days: string | null
image_path: string | null image_path: string | null
thumbnail_path: string | null thumbnail_path: string | null
tags: string[] | null
} }
export interface ProductCreate { export interface ProductCreate {
@@ -52,6 +53,7 @@ export interface ProductCreate {
default_store_id?: string default_store_id?: string
image_path?: string image_path?: string
thumbnail_path?: string thumbnail_path?: string
tags?: string[]
} }
export interface ProductUpdate { export interface ProductUpdate {
@@ -66,6 +68,7 @@ export interface ProductUpdate {
default_store_id?: string default_store_id?: string
image_path?: string image_path?: string
thumbnail_path?: string thumbnail_path?: string
tags?: string[]
} }
export interface ShoppingItem { export interface ShoppingItem {
@@ -29,6 +29,7 @@ const emptyForm: ProductCreate = {
default_store_id: undefined, default_store_id: undefined,
image_path: undefined, image_path: undefined,
thumbnail_path: undefined, thumbnail_path: undefined,
tags: [],
} }
export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) { 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 [editing, setEditing] = useState<Product | null>(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [form, setForm] = useState<ProductCreate>(emptyForm) const [form, setForm] = useState<ProductCreate>(emptyForm)
const [tagInput, setTagInput] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -54,12 +56,18 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
} }
} }
const filteredProducts = products.filter(p => const filteredProducts = products.filter(p => {
!search.trim() || matchesSearch(p.name, search) || matchesSearch(p.brand ?? '', search) || matchesSearch(p.category ?? '', search) 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() { function startCreate() {
setForm(emptyForm) setForm(emptyForm)
setTagInput('')
setEditing(null) setEditing(null)
setCreating(true) setCreating(true)
setError(null) setError(null)
@@ -78,7 +86,9 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
default_store_id: p.default_store_id ?? undefined, default_store_id: p.default_store_id ?? undefined,
image_path: p.image_path ?? undefined, image_path: p.image_path ?? undefined,
thumbnail_path: p.thumbnail_path ?? undefined, thumbnail_path: p.thumbnail_path ?? undefined,
tags: p.tags ?? [],
}) })
setTagInput('')
setEditing(p) setEditing(p)
setCreating(false) setCreating(false)
setError(null) setError(null)
@@ -87,9 +97,34 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
function cancelForm() { function cancelForm() {
setEditing(null) setEditing(null)
setCreating(false) setCreating(false)
setTagInput('')
setError(null) 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 { function capitalize(s: string): string {
const t = s.trim() const t = s.trim()
return t.charAt(0).toUpperCase() + t.slice(1) 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, default_store_id: f.default_store_id || undefined,
image_path: f.image_path || undefined, image_path: f.image_path || undefined,
thumbnail_path: f.thumbnail_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>)} {stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select> </select>
</div> </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' }}> <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button <button
onClick={cancelForm} onClick={cancelForm}
@@ -282,7 +353,7 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<input <input
style={{ ...inputStyle, flex: 1 }} style={{ ...inputStyle, flex: 1 }}
placeholder="Rechercher un article…" placeholder="Rechercher un article ou tag…"
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
autoFocus autoFocus
@@ -323,7 +394,7 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
{p.name} {p.name}
{p.brand && <span style={{ color: 'var(--ink-3)', fontSize: 12, marginLeft: 6 }}>{p.brand}</span>} {p.brand && <span style={{ color: 'var(--ink-3)', fontSize: 12, marginLeft: 6 }}>{p.brand}</span>}
</div> </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.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.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>} {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`} {p.avg_interval_days && ` · ~${Math.round(Number(p.avg_interval_days))}j`}
</span> </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>
</div> </div>
<button <button
+5 -1
View File
@@ -378,7 +378,11 @@ export default function ShoppingPage() {
const filteredProducts = products.filter(p => { const filteredProducts = products.filter(p => {
const term = itemSearch.trim() 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 ( return (