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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8000', changeOrigin: true },
|
||||
'/media': { target: 'http://localhost:8000', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user