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
|
- **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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user