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:
@@ -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