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 -4
View File
@@ -31,8 +31,10 @@
- **Viewport non zoomable** : `maximum-scale=1.0, user-scalable=no` dans index.html
- **Domaines stockés en tableau** (TEXT[]) — migration 002 Alembic
## En attente
## Implémentées (suite) ✅
- il y a un bouton + et un bouton nouvelle tache, si meme fonction garder bouton+ (supprimer "Nouvelle tâche" sur laptop si même action)
- bien differencier le fonctionnement de l'interface entre le mode laptop et smartphone
- conserve les todo terminé en base (déjà le cas, mais vérifier UI)
- **FAB + unique** : bouton circulaire "+" pour créer une tâche, visible sur mobile et laptop (position adaptée) — bouton "Nouvelle tâche" supprimé
- **Todos terminés conservés** : statut `done` en base, filtre "Terminé" dans le select du header pour les retrouver
- **Différenciation mobile/laptop** : mobile = groupes par domaine + swipe ; laptop = tableau avec filtres date, colonnes domaines/priorité/statut
## En attente
+3
View File
@@ -1,6 +1,7 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.health import router as health_router
from app.api.media import router as media_router
from app.api.todos import router as todos_router
@@ -29,3 +30,5 @@ app.include_router(health_router, prefix="/api")
app.include_router(media_router, prefix="/api/media")
app.include_router(todos_router, prefix="/api/todos")
app.include_router(shopping_router, prefix="/api/shopping")
app.mount("/media", StaticFiles(directory=str(settings.upload_path)), name="media")
+6
View File
@@ -38,6 +38,8 @@ class ProductCreate(BaseModel):
price: Decimal | None = None
quantity_per_unit: Decimal | None = None
default_store_id: uuid.UUID | None = None
image_path: str | None = None
thumbnail_path: str | None = None
class ProductUpdate(BaseModel):
@@ -50,6 +52,8 @@ class ProductUpdate(BaseModel):
price: Decimal | None = None
quantity_per_unit: Decimal | None = None
default_store_id: uuid.UUID | None = None
image_path: str | None = None
thumbnail_path: str | None = None
class ProductResponse(BaseModel):
@@ -65,6 +69,8 @@ class ProductResponse(BaseModel):
quantity_per_unit: Decimal | None
default_store_id: uuid.UUID | None
frequency_score: int
image_path: str | None
thumbnail_path: str | None
class ListItemCreate(BaseModel):
+6
View File
@@ -18,6 +18,12 @@ server {
proxy_set_header X-Real-IP $remote_addr;
}
location /media/ {
proxy_pass http://backend:8000/media/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}
+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}
+1
View File
@@ -39,6 +39,7 @@ export default defineConfig({
server: {
proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true },
'/media': { target: 'http://localhost:8000', changeOrigin: true },
},
},
})