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:
@@ -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,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()"))
|
||||
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "homehub-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.11",
|
||||
"version": "0.4.12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user