feat(shopping): photo par article dans le catalogue

- Upload photo (context=product → thumbnail 150×150) dans CatalogueModal
- Miniature affichée dans la liste et dans le formulaire
- Schémas ProductCreate/Update/Response exposent image_path + thumbnail_path
- Backend sert /media/* via StaticFiles (FastAPI)
- Proxy /media → backend dans vite.config et nginx.conf

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 06:37:33 +02:00
parent 5dc335ad17
commit 7b1b6521e5
7 changed files with 99 additions and 5 deletions
+6
View File
@@ -34,6 +34,8 @@ export interface Product {
quantity_per_unit: string | null
default_store_id: string | null
frequency_score: number
image_path: string | null
thumbnail_path: string | null
}
export interface ProductCreate {
@@ -46,6 +48,8 @@ export interface ProductCreate {
price?: string
quantity_per_unit?: string
default_store_id?: string
image_path?: string
thumbnail_path?: string
}
export interface ProductUpdate {
@@ -58,6 +62,8 @@ export interface ProductUpdate {
price?: string
quantity_per_unit?: string
default_store_id?: string
image_path?: string
thumbnail_path?: string
}
export interface ShoppingItem {
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import type { Product, ProductCreate, Store } from '../../api/shopping'
import { searchProducts, createProduct, updateProduct, deleteProduct } from '../../api/shopping'
import Modal from '../Modal'
@@ -26,6 +26,8 @@ const emptyForm: ProductCreate = {
name: '', brand: '', category: '', description: '',
default_unit: '', barcode: '', price: '', quantity_per_unit: '',
default_store_id: undefined,
image_path: undefined,
thumbnail_path: undefined,
}
export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) {
@@ -35,7 +37,9 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
const [creating, setCreating] = useState(false)
const [form, setForm] = useState<ProductCreate>(emptyForm)
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const photoRef = useRef<HTMLInputElement>(null)
useEffect(() => {
void loadProducts()
@@ -67,6 +71,8 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
price: p.price ?? '',
quantity_per_unit: p.quantity_per_unit ?? '',
default_store_id: p.default_store_id ?? undefined,
image_path: p.image_path ?? undefined,
thumbnail_path: p.thumbnail_path ?? undefined,
})
setEditing(p)
setCreating(false)
@@ -90,6 +96,28 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
price: f.price?.trim() || undefined,
quantity_per_unit: f.quantity_per_unit?.trim() || undefined,
default_store_id: f.default_store_id || undefined,
image_path: f.image_path || undefined,
thumbnail_path: f.thumbnail_path || undefined,
}
}
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setError(null)
try {
const fd = new FormData()
fd.append('file', file)
const res = await fetch('/api/media/upload?context=product', { method: 'POST', body: fd })
if (!res.ok) throw new Error('Upload échoué')
const data = await res.json() as { file_path: string; thumbnail_path: string | null }
setForm(f => ({ ...f, image_path: data.file_path, thumbnail_path: data.thumbnail_path ?? undefined }))
} catch {
setError('Erreur lors de l\'upload photo')
} finally {
setUploading(false)
if (photoRef.current) photoRef.current.value = ''
}
}
@@ -124,6 +152,7 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
}
const isFormOpen = creating || editing !== null
const previewSrc = form.thumbnail_path ?? form.image_path
return (
<Modal title="Catalogue d'articles" onClose={onClose} width={580}>
@@ -134,6 +163,38 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
{isFormOpen ? (
/* ── Formulaire création / édition ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Zone photo */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{previewSrc ? (
<img
src={`/media/${previewSrc}`}
alt="photo"
style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8, flexShrink: 0 }}
/>
) : (
<div style={{
width: 56, height: 56, borderRadius: 8, flexShrink: 0,
background: 'var(--bg-4)', border: '1px dashed var(--bg-5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--ink-4)', fontSize: 22,
}}>📷</div>
)}
<input ref={photoRef} type="file" accept="image/*" capture="environment" style={{ display: 'none' }} onChange={handlePhotoChange} />
<button
type="button"
onClick={() => photoRef.current?.click()}
disabled={uploading}
style={{ padding: '6px 14px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'var(--bg-4)', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 13 }}
>{uploading ? '…' : previewSrc ? 'Changer' : 'Ajouter photo'}</button>
{previewSrc && (
<button
type="button"
onClick={() => setForm(f => ({ ...f, image_path: undefined, thumbnail_path: undefined }))}
style={{ padding: '6px 10px', borderRadius: 8, border: 'none', background: 'transparent', color: 'var(--err)', cursor: 'pointer', fontSize: 14 }}
></button>
)}
</div>
<input
style={inputStyle} placeholder="Nom *" value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
@@ -236,6 +297,15 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
userSelect: 'none',
}}
>
{p.thumbnail_path || p.image_path ? (
<img
src={`/media/${p.thumbnail_path ?? p.image_path}`}
alt=""
style={{ width: 36, height: 36, objectFit: 'cover', borderRadius: 6, flexShrink: 0 }}
/>
) : (
<div style={{ width: 36, height: 36, borderRadius: 6, background: 'var(--bg-4)', flexShrink: 0 }} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.name}