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 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()"))
|
||||||
|
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in New Issue
Block a user