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
co-authored by Claude Sonnet 4.6
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 - **Viewport non zoomable** : `maximum-scale=1.0, user-scalable=no` dans index.html
- **Domaines stockés en tableau** (TEXT[]) — migration 002 Alembic - **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) - **FAB + unique** : bouton circulaire "+" pour créer une tâche, visible sur mobile et laptop (position adaptée) — bouton "Nouvelle tâche" supprimé
- bien differencier le fonctionnement de l'interface entre le mode laptop et smartphone - **Todos terminés conservés** : statut `done` en base, filtre "Terminé" dans le select du header pour les retrouver
- conserve les todo terminé en base (déjà le cas, mais vérifier UI) - **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 contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.health import router as health_router from app.api.health import router as health_router
from app.api.media import router as media_router from app.api.media import router as media_router
from app.api.todos import router as todos_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(media_router, prefix="/api/media")
app.include_router(todos_router, prefix="/api/todos") app.include_router(todos_router, prefix="/api/todos")
app.include_router(shopping_router, prefix="/api/shopping") 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 price: Decimal | None = None
quantity_per_unit: Decimal | None = None quantity_per_unit: Decimal | None = None
default_store_id: uuid.UUID | None = None default_store_id: uuid.UUID | None = None
image_path: str | None = None
thumbnail_path: str | None = None
class ProductUpdate(BaseModel): class ProductUpdate(BaseModel):
@@ -50,6 +52,8 @@ class ProductUpdate(BaseModel):
price: Decimal | None = None price: Decimal | None = None
quantity_per_unit: Decimal | None = None quantity_per_unit: Decimal | None = None
default_store_id: uuid.UUID | None = None default_store_id: uuid.UUID | None = None
image_path: str | None = None
thumbnail_path: str | None = None
class ProductResponse(BaseModel): class ProductResponse(BaseModel):
@@ -65,6 +69,8 @@ class ProductResponse(BaseModel):
quantity_per_unit: Decimal | None quantity_per_unit: Decimal | None
default_store_id: uuid.UUID | None default_store_id: uuid.UUID | None
frequency_score: int frequency_score: int
image_path: str | None
thumbnail_path: str | None
class ListItemCreate(BaseModel): class ListItemCreate(BaseModel):
+6
View File
@@ -18,6 +18,12 @@ server {
proxy_set_header X-Real-IP $remote_addr; 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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+6
View File
@@ -34,6 +34,8 @@ export interface Product {
quantity_per_unit: string | null quantity_per_unit: string | null
default_store_id: string | null default_store_id: string | null
frequency_score: number frequency_score: number
image_path: string | null
thumbnail_path: string | null
} }
export interface ProductCreate { export interface ProductCreate {
@@ -46,6 +48,8 @@ export interface ProductCreate {
price?: string price?: string
quantity_per_unit?: string quantity_per_unit?: string
default_store_id?: string default_store_id?: string
image_path?: string
thumbnail_path?: string
} }
export interface ProductUpdate { export interface ProductUpdate {
@@ -58,6 +62,8 @@ export interface ProductUpdate {
price?: string price?: string
quantity_per_unit?: string quantity_per_unit?: string
default_store_id?: string default_store_id?: string
image_path?: string
thumbnail_path?: string
} }
export interface ShoppingItem { 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 type { Product, ProductCreate, Store } from '../../api/shopping'
import { searchProducts, createProduct, updateProduct, deleteProduct } from '../../api/shopping' import { searchProducts, createProduct, updateProduct, deleteProduct } from '../../api/shopping'
import Modal from '../Modal' import Modal from '../Modal'
@@ -26,6 +26,8 @@ const emptyForm: ProductCreate = {
name: '', brand: '', category: '', description: '', name: '', brand: '', category: '', description: '',
default_unit: '', barcode: '', price: '', quantity_per_unit: '', default_unit: '', barcode: '', price: '', quantity_per_unit: '',
default_store_id: undefined, default_store_id: undefined,
image_path: undefined,
thumbnail_path: undefined,
} }
export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) { export default function CatalogueModal({ stores, onClose }: CatalogueModalProps) {
@@ -35,7 +37,9 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [form, setForm] = useState<ProductCreate>(emptyForm) const [form, setForm] = useState<ProductCreate>(emptyForm)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const photoRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
void loadProducts() void loadProducts()
@@ -67,6 +71,8 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
price: p.price ?? '', price: p.price ?? '',
quantity_per_unit: p.quantity_per_unit ?? '', quantity_per_unit: p.quantity_per_unit ?? '',
default_store_id: p.default_store_id ?? undefined, default_store_id: p.default_store_id ?? undefined,
image_path: p.image_path ?? undefined,
thumbnail_path: p.thumbnail_path ?? undefined,
}) })
setEditing(p) setEditing(p)
setCreating(false) setCreating(false)
@@ -90,6 +96,28 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
price: f.price?.trim() || undefined, price: f.price?.trim() || undefined,
quantity_per_unit: f.quantity_per_unit?.trim() || undefined, quantity_per_unit: f.quantity_per_unit?.trim() || undefined,
default_store_id: f.default_store_id || 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 isFormOpen = creating || editing !== null
const previewSrc = form.thumbnail_path ?? form.image_path
return ( return (
<Modal title="Catalogue d'articles" onClose={onClose} width={580}> <Modal title="Catalogue d'articles" onClose={onClose} width={580}>
@@ -134,6 +163,38 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
{isFormOpen ? ( {isFormOpen ? (
/* ── Formulaire création / édition ── */ /* ── Formulaire création / édition ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <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 <input
style={inputStyle} placeholder="Nom *" value={form.name} style={inputStyle} placeholder="Nom *" value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
@@ -236,6 +297,15 @@ export default function CatalogueModal({ stores, onClose }: CatalogueModalProps)
userSelect: 'none', 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={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.name} {p.name}
+1
View File
@@ -39,6 +39,7 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true }, '/api': { target: 'http://localhost:8000', changeOrigin: true },
'/media': { target: 'http://localhost:8000', changeOrigin: true },
}, },
}, },
}) })