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
+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 (